Merge pull request 'The licence manager keeps its data on the bus and switches licences by itself at 99% (issue 1409)' (#657) from feat/39-licence-switch into main
This commit was merged in pull request #657.
This commit is contained in:
@@ -78,9 +78,9 @@ must see, a node that joins later included — kept, so it carries no secret eit
|
|||||||
|
|
||||||
| what | how |
|
| what | how |
|
||||||
|---|---|
|
|---|---|
|
||||||
| what this node holds | the module's `holdings` state, one key for this node — the account, the kind, fingerprints and expiries, never a token — written at start and whenever the credentials file changes (hq ADR 0206) |
|
| what this node holds | the module's `holdings` state, one key for this node — the account, the kind, fingerprints and expiries, never a token — written at start and whenever the credentials file changes (hq ADR 0206), and said whole as the event `holdings-changed` at start, on every change and again when the licence manager says `holdings.asked` (design 39 bus-and-switch §23) |
|
||||||
| a person ran `/login` here | the credentials file gains a refresh token this module never writes; its next report shows it, and the licence manager asks `claude_code_grant` for it, giving its key — the one time a refresh token leaves the node, for the manager to adopt by refreshing it |
|
| a person ran `/login` here | the credentials file gains a refresh token this module never writes; its next report shows it, and the licence manager asks `claude_code_grant` for it, giving its key — the one time a refresh token leaves the node, for the manager to adopt by refreshing it |
|
||||||
| what this node should hold | the licence manager's `bindings` state, this node's key; on a newer generation this module asks `anthropic-licence-manager.current` for its token, sealed to the key it sends, and writes it access-token-only — so the agent here never refreshes. A node that was off reads its key when it is back |
|
| what this node should hold | the licence manager's `binding.changed` naming this node, and at start (or a reconnect) its first view, `anthropic-licence-manager.bindings` with `consumer` this node; an event for another node is ignored. On a newer generation, at start, and on the vault's `secret-changed` for the licence it holds, this module asks `anthropic-licence-manager.current` which licence, and the vault's `get` for that licence's access token (`licence.<key>.access`), sealed to its own key, and writes it access-token-only — so the agent here never refreshes, and never receives a refresh token. A home holding no token is fetched again at the same generation (design 39 bus-and-switch §19, §22) |
|
||||||
| an MCP server registered through this module before registering was refused (hq issue 340) | a key in the module's `servers` state — `all.<server>` for every node, `<node>.<server>` for one; every node watches it and renders what applies to it, a node's own entry over the one for every node. A node that joins later, or was off, reads the whole current set at start; unregistering is a delete. Nothing adds a key any more: a new tool server is the operator's, in the `mcp_servers` setting at the controller's terminal |
|
| an MCP server registered through this module before registering was refused (hq issue 340) | a key in the module's `servers` state — `all.<server>` for every node, `<node>.<server>` for one; every node watches it and renders what applies to it, a node's own entry over the one for every node. A node that joins later, or was off, reads the whole current set at start; unregistering is a delete. Nothing adds a key any more: a new tool server is the operator's, in the `mcp_servers` setting at the controller's terminal |
|
||||||
| a proposed instruction section (hq ADR 0274) | a key in the module's `proposals` state per proposal, the ask published on `operator-channel` under this module's name, and the router's record of it read under that name alone; what a node approved is its own file, never the bus |
|
| a proposed instruction section (hq ADR 0274) | a key in the module's `proposals` state per proposal, the ask published on `operator-channel` under this module's name, and the router's record of it read under that name alone; what a node approved is its own file, never the bus |
|
||||||
| a session's question to the operator (hq ADR 0332) | a key `q-…` in the module's `questions` state per question, the ask published on `operator-channel` under this module's name, and the router's record of it read under that name alone; the router's word that it ended without a choice is kept on the question; two slot keys `slot-1`, `slot-2` name the questions open |
|
| a session's question to the operator (hq ADR 0332) | a key `q-…` in the module's `questions` state per question, the ask published on `operator-channel` under this module's name, and the router's record of it read under that name alone; the router's word that it ended without a choice is kept on the question; two slot keys `slot-1`, `slot-2` name the questions open |
|
||||||
@@ -150,8 +150,8 @@ record is how a habit that found a way round is seen.
|
|||||||
|
|
||||||
## Tools
|
## Tools
|
||||||
|
|
||||||
`claude_code_status`, `claude_code_render`, `claude_code_guard`, `claude_code_pull`, `claude_code_grant` (for the licence
|
`claude_code_status`, `claude_code_render`, `claude_code_guard`, `claude_code_pull` (asks the vault for this node's
|
||||||
manager), `claude_code_mcp_list`, `claude_code_mcp_unregister` (this node by default; `nodes: "all"` or a list for
|
licence token now), `claude_code_grant` (for the licence manager), `claude_code_mcp_list`, `claude_code_mcp_unregister` (this node by default; `nodes: "all"` or a list for
|
||||||
more).
|
more).
|
||||||
|
|
||||||
**What a session runs or obeys is the operator's, at the controller's terminal** (hq issue 340). A hook, a
|
**What a session runs or obeys is the operator's, at the controller's terminal** (hq issue 340). A hook, a
|
||||||
|
|||||||
@@ -457,13 +457,9 @@ func TestTheGuardIsAskedAboutEveryToolAndRefusesEachForAnotherAccount(t *testing
|
|||||||
func TestAMoveWritesTheAccountFileBesideTheToken(t *testing.T) {
|
func TestAMoveWritesTheAccountFileBesideTheToken(t *testing.T) {
|
||||||
p, w := node(t, "anchor")
|
p, w := node(t, "anchor")
|
||||||
writeFile(t, p.binding(), `{"licence":"personal","kind":"subscription","generation":4}`)
|
writeFile(t, p.binding(), `{"licence":"personal","kind":"subscription","generation":4}`)
|
||||||
ask := func(address string, args any) (json.RawMessage, error) {
|
var asked []string
|
||||||
key := args.(map[string]any)["public_key"].(string)
|
ask := vaulted(t, Current{Licence: "personal", Key: "personal", Kind: "subscription", Generation: 4,
|
||||||
g, _ := json.Marshal(Grant{AccessToken: "at", ExpiresAt: now + 3_600_000})
|
Identity: &Identity{AccountUUID: "u-1", EmailAddress: "operator@example.org"}}, "at", &asked)
|
||||||
box, _ := Seal(string(g), key)
|
|
||||||
return json.Marshal(Current{Licence: "personal", Kind: "subscription", Generation: 4, Sealed: &box,
|
|
||||||
Identity: &Identity{AccountUUID: "u-1", EmailAddress: "operator@example.org"}})
|
|
||||||
}
|
|
||||||
if _, err := OnBinding(p, &BindingState{Licence: "personal", Kind: "subscription", Generation: 4}, ask, writer(w)); err != nil {
|
if _, err := OnBinding(p, &BindingState{Licence: "personal", Kind: "subscription", Generation: 4}, ask, writer(w)); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"reflect"
|
"reflect"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -282,31 +283,52 @@ func TestTheGrantAnswersOnlyAWaitingLoginSealedToTheManagersKey(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// seat answers `current` as the manager does: the grant sealed to the key the node sent.
|
// seat answers `current` as the manager does, the licence and its key and never a token, and `get` as the vault does:
|
||||||
|
// the licence's access secret sealed to the key the node sent (design 39 bus-and-switch §18, §19).
|
||||||
func seat(t *testing.T, licence, token string, gen int64, asked *[]string) Ask {
|
func seat(t *testing.T, licence, token string, gen int64, asked *[]string) Ask {
|
||||||
|
return vaulted(t, Current{Licence: licence, Key: licence, Kind: "subscription", Generation: gen}, token, asked)
|
||||||
|
}
|
||||||
|
|
||||||
|
// vaulted answers `current` with c and the vault's `get` of c's access secret with token.
|
||||||
|
func vaulted(t *testing.T, c Current, token string, asked *[]string) Ask {
|
||||||
return func(address string, args any) (json.RawMessage, error) {
|
return func(address string, args any) (json.RawMessage, error) {
|
||||||
*asked = append(*asked, address)
|
*asked = append(*asked, address)
|
||||||
key := args.(map[string]any)["public_key"].(string)
|
a := args.(map[string]any)
|
||||||
g, _ := json.Marshal(Grant{AccessToken: token, ExpiresAt: now + 3_600_000})
|
switch address {
|
||||||
box, err := Seal(string(g), key)
|
case SeatVerb("current"):
|
||||||
if err != nil {
|
if _, sent := a["public_key"]; sent || a["consumer"] == nil {
|
||||||
t.Fatal(err)
|
t.Fatalf("current asked with %v", a)
|
||||||
|
}
|
||||||
|
return json.Marshal(c)
|
||||||
|
case VaultGet:
|
||||||
|
if a["name"] != AccessSecret(c.Key) {
|
||||||
|
t.Fatalf("the vault asked for %v", a["name"])
|
||||||
|
}
|
||||||
|
// Each answer a little later, as each rotation hands a later token.
|
||||||
|
g, _ := json.Marshal(Grant{AccessToken: token, ExpiresAt: now + 3_600_000 + int64(len(*asked))*1000})
|
||||||
|
box, err := Seal(string(g), a["public_key"].(string))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return json.Marshal(map[string]any{"name": a["name"], "generation": 1, "sealed": box})
|
||||||
}
|
}
|
||||||
return json.Marshal(Current{Licence: licence, Kind: "subscription", Generation: gen, Sealed: &box})
|
t.Fatalf("asked %s", address)
|
||||||
|
return nil, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestANewerGenerationFetchesTheTokenOnceByTheSeatsVerb(t *testing.T) {
|
// design 39 bus-and-switch §19: a newer generation asks the seat which licence, and the vault for its token.
|
||||||
|
func TestANewerGenerationFetchesTheTokenOnceFromTheVault(t *testing.T) {
|
||||||
p, w := node(t, "laptop")
|
p, w := node(t, "laptop")
|
||||||
var asked []string
|
var asked []string
|
||||||
ask := seat(t, "personal", "at-1", 3, &asked)
|
ask := seat(t, "personal", "at-1", 3, &asked)
|
||||||
if _, err := OnBinding(p, &BindingState{Licence: "personal", Kind: "subscription", Generation: 3}, ask, writer(w)); err != nil {
|
if _, err := OnBinding(p, &BindingState{Licence: "personal", Kind: "subscription", Generation: 3}, ask, writer(w)); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if len(asked) != 1 || asked[0] != "seat:anthropic-licence-manager.current" || creds(t, p)["accessToken"] != "at-1" {
|
if len(asked) != 2 || asked[0] != SeatVerb("current") || asked[1] != VaultGet || creds(t, p)["accessToken"] != "at-1" {
|
||||||
t.Fatalf("asked %v, credentials %v", asked, creds(t, p))
|
t.Fatalf("asked %v, credentials %v", asked, creds(t, p))
|
||||||
}
|
}
|
||||||
if done, _ := OnBinding(p, &BindingState{Licence: "personal", Kind: "subscription", Generation: 3}, ask, writer(w)); done != "" || len(asked) != 1 {
|
if done, _ := OnBinding(p, &BindingState{Licence: "personal", Kind: "subscription", Generation: 3}, ask, writer(w)); done != "" || len(asked) != 2 {
|
||||||
t.Fatal("an equal generation asked again")
|
t.Fatal("an equal generation asked again")
|
||||||
}
|
}
|
||||||
if HoldingsOf(p).Generation != 3 || w["managed-mcp.json"] == "" {
|
if HoldingsOf(p).Generation != 3 || w["managed-mcp.json"] == "" {
|
||||||
@@ -497,30 +519,90 @@ func TestAnAPIKeyIsHandedOverSealedAndItsFileRemoved(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// A manager whose store was rebuilt counts generations from one again (novox/hq issue 243): a binding with a
|
// design 39 bus-and-switch §19, B11: only a newer generation than the one applied is fetched; a lower one is not, and
|
||||||
// lower generation than the one applied is still a binding to apply, and only the one applied is skipped.
|
// the vault's secret-changed for the licence held asks its token again, for another secret nothing.
|
||||||
func TestALowerGenerationAfterTheManagerWasRebuiltIsStillApplied(t *testing.T) {
|
func TestTheTokenIsAskedOnANewerGenerationAndOnTheVaultsChange(t *testing.T) {
|
||||||
p, w := node(t, "laptop")
|
p, w := node(t, "laptop")
|
||||||
var asked []string
|
var asked []string
|
||||||
if _, err := OnBinding(p, &BindingState{Licence: "personal", Kind: "subscription", Generation: 16},
|
if _, err := OnBinding(p, &BindingState{Licence: "personal", Kind: "subscription", Generation: 16},
|
||||||
seat(t, "personal", "at-old", 16, &asked), writer(w)); err != nil {
|
seat(t, "personal", "at-old", 16, &asked), writer(w)); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
// The rotation that came with it: a later token, as a refresh hands one over.
|
if done, err := OnBinding(p, &BindingState{Licence: "personal", Kind: "subscription", Generation: 3},
|
||||||
later := func(address string, args any) (json.RawMessage, error) {
|
seat(t, "personal", "at-low", 3, &asked), writer(w)); err != nil || done != "" || len(asked) != 2 {
|
||||||
asked = append(asked, address)
|
t.Fatalf("a lower generation was fetched: %q %v, asked %v", done, err, asked)
|
||||||
g, _ := json.Marshal(Grant{AccessToken: "at-new", ExpiresAt: now + 7_200_000})
|
|
||||||
box, err := Seal(string(g), args.(map[string]any)["public_key"].(string))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
return json.Marshal(Current{Licence: "personal", Kind: "subscription", Generation: 3, Sealed: &box})
|
|
||||||
}
|
}
|
||||||
if _, err := OnBinding(p, &BindingState{Licence: "personal", Kind: "subscription", Generation: 3},
|
rotated := seat(t, "personal", "at-new", 16, &asked)
|
||||||
later, writer(w)); err != nil {
|
for _, other := range []string{`{"name":"licence.personal.grant","generation":5}`, `{"name":"licence.other.access","generation":5}`} {
|
||||||
|
if done, err := OnSecretChanged(p, []byte(other), rotated, writer(w)); err != nil || done != "" || len(asked) != 2 {
|
||||||
|
t.Fatalf("%s asked again: %q %v, asked %v", other, done, err, asked)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := OnSecretChanged(p, []byte(`{"name":"licence.personal.access","generation":5,"by":"x","at":"2026-11-11T00:00:00Z"}`),
|
||||||
|
rotated, writer(w)); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if len(asked) != 2 || creds(t, p)["accessToken"] != "at-new" || HoldingsOf(p).Generation != 3 {
|
if len(asked) != 4 || asked[3] != VaultGet || creds(t, p)["accessToken"] != "at-new" || HoldingsOf(p).Generation != 16 {
|
||||||
t.Fatalf("asked %v, credentials %v: a lower generation was ignored", asked, creds(t, p))
|
t.Fatalf("asked %v, credentials %v: the vault's change was not applied", asked, creds(t, p))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// design 39 bus-and-switch §22/AC1, B22: binding.changed, decoded from a copy of the manager's form, is acted on only
|
||||||
|
// when it names this node's consumer; an event for another consumer is ignored.
|
||||||
|
func TestAnEventForAnotherConsumerIsIgnored(t *testing.T) {
|
||||||
|
p, w := node(t, "server")
|
||||||
|
var asked []string
|
||||||
|
ask := seat(t, "api", "at-3", 3, &asked)
|
||||||
|
said, err := os.ReadFile("testdata/binding-changed.json")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if done, err := OnBindingChanged(p, said, ask, writer(w)); err != nil || done != "" || len(asked) != 0 {
|
||||||
|
t.Fatalf("an event for laptop was acted on by server: %q %v, asked %v", done, err, asked)
|
||||||
|
}
|
||||||
|
p, w = node(t, "laptop")
|
||||||
|
if done, err := OnBindingChanged(p, said, ask, writer(w)); err != nil || !strings.Contains(done, `"generation":3`) || len(asked) != 2 {
|
||||||
|
t.Fatalf("laptop's own event: %q %v, asked %v", done, err, asked)
|
||||||
|
}
|
||||||
|
if done, _ := OnBindingChanged(p, []byte(`{"consumer":"laptop","licence":"","generation":3,"from":"api"}`), ask, writer(w)); !strings.Contains(done, "released") {
|
||||||
|
t.Fatalf("a release said %q", done)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// design 39 bus-and-switch §22, B10: at its start the module asks the seat's bindings for its own consumer and applies
|
||||||
|
// that first view; its manifest consumes binding.changed and no longer reads the manager's state.
|
||||||
|
func TestTheFirstViewOfTheBindingIsApplied(t *testing.T) {
|
||||||
|
p, w := node(t, "laptop")
|
||||||
|
var asked []string
|
||||||
|
current := vaulted(t, Current{Licence: "api", Key: "api", Kind: "subscription", Generation: 9}, "at-9", &asked)
|
||||||
|
ask := func(address string, args any) (json.RawMessage, error) {
|
||||||
|
if address == SeatVerb("bindings") {
|
||||||
|
asked = append(asked, address)
|
||||||
|
if args.(map[string]any)["consumer"] != "laptop" {
|
||||||
|
t.Fatalf("bindings asked with %v", args)
|
||||||
|
}
|
||||||
|
return json.RawMessage(`[{"consumer":"laptop","licence":"api","key":"api","kind":"api-key","generation":9,"why":"carried over","by":"claude-licence-manager"}]`), nil
|
||||||
|
}
|
||||||
|
return current(address, args)
|
||||||
|
}
|
||||||
|
if done, err := FirstBinding(p, ask, writer(w)); err != nil || !strings.Contains(done, `"generation":9`) {
|
||||||
|
t.Fatalf("the first view: %q %v, asked %v", done, err, asked)
|
||||||
|
}
|
||||||
|
if len(asked) != 3 || asked[0] != SeatVerb("bindings") || creds(t, p)["accessToken"] != "at-9" {
|
||||||
|
t.Fatalf("asked %v, credentials %v", asked, creds(t, p))
|
||||||
|
}
|
||||||
|
// §19: at every start the vault is asked, though the binding is the one applied.
|
||||||
|
if _, err := FirstBinding(p, ask, writer(w)); err != nil || len(asked) != 6 || asked[5] != VaultGet {
|
||||||
|
t.Fatalf("a start asked %v", asked)
|
||||||
|
}
|
||||||
|
raw, _ := os.ReadFile("../../module.json")
|
||||||
|
var m struct {
|
||||||
|
Consumes, Invokes []string
|
||||||
|
Reads []string
|
||||||
|
}
|
||||||
|
_ = json.Unmarshal(raw, &m)
|
||||||
|
if !slices.Contains(m.Consumes, BindingChanged) || !slices.Contains(m.Consumes, SecretChanged) ||
|
||||||
|
!slices.Contains(m.Invokes, SeatVerb("bindings")) || !slices.Contains(m.Invokes, VaultGet) || len(m.Reads) != 0 {
|
||||||
|
t.Fatalf("the manifest consumes %v, invokes %v, reads %v", m.Consumes, m.Invokes, m.Reads)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -276,10 +276,11 @@ func TestTheManifestListsEveryToolServed(t *testing.T) {
|
|||||||
}
|
}
|
||||||
_ = json.Unmarshal(raw, &m)
|
_ = json.Unmarshal(raw, &m)
|
||||||
// The hand-act seat's warranted and hand-act, the seat mesh-catalog's seat list (ADR 0635 §1), and the owners' read tools of the three followed states; no verb of
|
// The hand-act seat's warranted and hand-act, the seat mesh-catalog's seat list (ADR 0635 §1), and the owners' read tools of the three followed states; no verb of
|
||||||
// the controller, and no read of the indexer's or the assigner's state (ADR 0615 §1): their views are consumed.
|
// the controller, and no read of the indexer's or the assigner's state (ADR 0615 §1): their views are consumed. The
|
||||||
if !slices.Equal(m.Invokes, []string{"seat:mesh-hand-acts.warranted", "seat:mesh-hand-acts.hand-act", "seat:mesh-catalog.seat-definitions", ModulesTool, ManifestsTool, AssignmentsTool}) ||
|
// licence binding is the seat's bindings and the manager's binding.changed, its token the vault's get and secret-changed (design 39 bus-and-switch §19, §22, B9).
|
||||||
!slices.Equal(m.Reads, []string{"claude-licence-manager.bindings"}) ||
|
if !slices.Equal(m.Invokes, []string{"seat:mesh-hand-acts.warranted", "seat:mesh-hand-acts.hand-act", "seat:mesh-catalog.seat-definitions", ModulesTool, ManifestsTool, AssignmentsTool, SeatVerb("bindings"), SeatVerb("current"), VaultGet}) ||
|
||||||
!slices.Equal(m.Consumes, []string{"mesh-delivery-indexer.views.modules", "mesh-delivery-indexer.views.manifests", "mesh-delivery-assigner.views.assignments", "mesh-catalog.seat-definition-changed", "mesh-catalog.seat-definition-removed"}) {
|
len(m.Reads) != 0 ||
|
||||||
|
!slices.Equal(m.Consumes, []string{"mesh-delivery-indexer.views.modules", "mesh-delivery-indexer.views.manifests", "mesh-delivery-assigner.views.assignments", "mesh-catalog.seat-definition-changed", "mesh-catalog.seat-definition-removed", HoldingsAsked, BindingChanged, SecretChanged}) {
|
||||||
t.Errorf("invokes = %v, reads = %v, consumes = %v", m.Invokes, m.Reads, m.Consumes)
|
t.Errorf("invokes = %v, reads = %v, consumes = %v", m.Invokes, m.Reads, m.Consumes)
|
||||||
}
|
}
|
||||||
if src, _ := os.ReadFile("proposals.go"); !strings.Contains(string(src), `"seat:mesh-hand-acts.warranted"`) ||
|
if src, _ := os.ReadFile("proposals.go"); !strings.Contains(string(src), `"seat:mesh-hand-acts.warranted"`) ||
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// What this node holds, said (design 39 bus-and-switch §23): kept in this module's own state `holdings` and said as
|
||||||
|
// the event `holdings-changed`, the holdings report whole and never a token, at start, whenever it changes, and again
|
||||||
|
// when the licence manager says `holdings.asked`.
|
||||||
|
|
||||||
|
import "encoding/json"
|
||||||
|
|
||||||
|
// HoldingsChanged is the event that says what a node holds.
|
||||||
|
const HoldingsChanged = "holdings-changed"
|
||||||
|
|
||||||
|
// HoldingsAsked is the licence manager's event asking every node to say what it holds.
|
||||||
|
const HoldingsAsked = Manager + ".holdings.asked"
|
||||||
|
|
||||||
|
// HoldingsSayer keeps and says what this node holds.
|
||||||
|
type HoldingsSayer struct {
|
||||||
|
Put func(Holdings) error
|
||||||
|
Emit func(Holdings) error
|
||||||
|
said string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Say keeps and says now when it differs from what was said last, or always when again: true when it said it. A
|
||||||
|
// failure says nothing was said, so the next call tries again.
|
||||||
|
func (s *HoldingsSayer) Say(now Holdings, again bool) (bool, error) {
|
||||||
|
raw, _ := json.Marshal(now)
|
||||||
|
if !again && string(raw) == s.said {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
if err := s.Put(now); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if err := s.Emit(now); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
s.said = string(raw)
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// design 39 bus-and-switch §23: the holdings are said at start, on a change and when asked again, kept in the
|
||||||
|
// module's own state each time, the report whole and never a token.
|
||||||
|
func TestHoldingsAreSaidAtStartAndOnChange(t *testing.T) {
|
||||||
|
var kept, said []string
|
||||||
|
failing := false
|
||||||
|
s := &HoldingsSayer{
|
||||||
|
Put: func(h Holdings) error { raw, _ := json.Marshal(h); kept = append(kept, string(raw)); return nil },
|
||||||
|
Emit: func(h Holdings) error {
|
||||||
|
if failing {
|
||||||
|
return errors.New("no responders")
|
||||||
|
}
|
||||||
|
raw, _ := json.Marshal(h)
|
||||||
|
said = append(said, string(raw))
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
fp := "sha256:0123456789abcdef"
|
||||||
|
start := Holdings{Node: "laptop"}
|
||||||
|
changed := Holdings{Node: "laptop"}
|
||||||
|
changed.Refresh.Present, changed.Refresh.Fingerprint = true, &fp
|
||||||
|
for i, step := range []struct {
|
||||||
|
h Holdings
|
||||||
|
again, out bool
|
||||||
|
}{{start, false, true}, {start, false, false}, {changed, false, true}, {changed, true, true}} {
|
||||||
|
if got, err := s.Say(step.h, step.again); err != nil || got != step.out {
|
||||||
|
t.Fatalf("step %d: said %v (%v), want %v", i, got, err, step.out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(said) != 3 || len(kept) != 3 {
|
||||||
|
t.Fatalf("said %d, kept %d, want 3 each", len(said), len(kept))
|
||||||
|
}
|
||||||
|
if !strings.Contains(said[1], fp) || !strings.Contains(said[1], `"node":"laptop"`) {
|
||||||
|
t.Fatalf("the report is not said whole: %s", said[1])
|
||||||
|
}
|
||||||
|
failing = true
|
||||||
|
if got, err := s.Say(start, false); err == nil || got {
|
||||||
|
t.Fatal("a failed event counts as said")
|
||||||
|
}
|
||||||
|
failing = false
|
||||||
|
if got, _ := s.Say(start, false); !got {
|
||||||
|
t.Fatal("what failed to be said is not said at the next call")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ import (
|
|||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient"
|
"git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient"
|
||||||
@@ -345,7 +346,7 @@ func tools(p Paths, servers ServerState, view *ServerView, config ConfigState, c
|
|||||||
return GuardStatus(p, last), nil
|
return GuardStatus(p, last), nil
|
||||||
}},
|
}},
|
||||||
{Name: "claude_code_pull",
|
{Name: "claude_code_pull",
|
||||||
Description: "Ask the licence manager for this node's current token now and apply it, rather than waiting for its binding to change.",
|
Description: "Ask the licence manager which licence this node holds and the vault for its access token now, and apply it, rather than waiting for its binding or the token to change.",
|
||||||
Run: func(map[string]any) (any, error) { return Pull(p, ask, writeManaged) }},
|
Run: func(map[string]any) (any, error) { return Pull(p, ask, writeManaged) }},
|
||||||
{Name: "claude_code_grant",
|
{Name: "claude_code_grant",
|
||||||
Description: "For the licence manager (ADR 0206): the full grant in this node's credentials file — a login made here — sealed to the public key given, with the account it belongs to. Nothing when no login is waiting. Never answers a token in the clear.",
|
Description: "For the licence manager (ADR 0206): the full grant in this node's credentials file — a login made here — sealed to the public key given, with the account it belongs to. Nothing when no login is waiting. Never answers a token in the clear.",
|
||||||
@@ -567,15 +568,24 @@ func run(p Paths, view *ServerView, configView *ConfigView) {
|
|||||||
// every change of the credentials file, polled, because the file is replaced by rename and a watch on
|
// every change of the credentials file, polled, because the file is replaced by rename and a watch on
|
||||||
// the old inode would go quiet. Fingerprints and expiries only.
|
// the old inode would go quiet. Fingerprints and expiries only.
|
||||||
holdings := stdio.State("holdings")
|
holdings := stdio.State("holdings")
|
||||||
reported := ""
|
var sayMu sync.Mutex
|
||||||
report := func() {
|
sayer := &HoldingsSayer{
|
||||||
|
Put: func(h Holdings) error { _, err := holdings.Put(p.Node, h); return err },
|
||||||
|
Emit: func(h Holdings) error { return stdio.Emit(HoldingsChanged, h) },
|
||||||
|
}
|
||||||
|
sayHoldings := func(again bool) {
|
||||||
|
sayMu.Lock()
|
||||||
|
defer sayMu.Unlock()
|
||||||
now := HoldingsOf(p)
|
now := HoldingsOf(p)
|
||||||
raw, _ := json.Marshal(now)
|
said := false
|
||||||
if string(raw) == reported {
|
persist("reporting what this node holds", func() error {
|
||||||
return
|
var err error
|
||||||
}
|
said, err = sayer.Say(now, again)
|
||||||
persist("reporting what this node holds", func() error { _, err := holdings.Put(p.Node, now); return err }, func(int) {
|
return err
|
||||||
reported = string(raw)
|
}, func(int) {
|
||||||
|
if !said {
|
||||||
|
return
|
||||||
|
}
|
||||||
account := "no account"
|
account := "no account"
|
||||||
if now.Identity != nil && now.Identity.EmailAddress != "" {
|
if now.Identity != nil && now.Identity.EmailAddress != "" {
|
||||||
account = now.Identity.EmailAddress
|
account = now.Identity.EmailAddress
|
||||||
@@ -593,30 +603,50 @@ func run(p Paths, view *ServerView, configView *ConfigView) {
|
|||||||
say("%s", line)
|
say("%s", line)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
report := func() { sayHoldings(false) }
|
||||||
// What this node should hold (ADR 0206): the manager's `bindings` key for this node; a newer
|
// The licence manager asks every node to say what it holds again (§23): after its carry-over, at its start and on
|
||||||
// generation is fetched with the seat's `current`, sealed to this module's key.
|
// its resync.
|
||||||
go persist("watching this node's licence binding", func() error {
|
go persist("hearing the licence manager ask what this node holds", func() error {
|
||||||
return stdio.State(Manager+".bindings").Watch(p.Node, func(c stdio.StateChange) error {
|
return stdio.Subscribe(HoldingsAsked, func(stdio.Envelope) error {
|
||||||
if c.Key != p.Node {
|
go sayHoldings(true)
|
||||||
return nil
|
|
||||||
}
|
|
||||||
var b *BindingState
|
|
||||||
if c.Op == "put" {
|
|
||||||
b = &BindingState{}
|
|
||||||
if err := json.Unmarshal(c.Value, b); err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if done, err := OnBinding(p, b, ask, writeManaged); err != nil {
|
|
||||||
say("fetching this node's token failed: %v", err)
|
|
||||||
} else if done != "" {
|
|
||||||
say("%s", done)
|
|
||||||
}
|
|
||||||
go report()
|
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
}, func(n int) { say("watching this node's licence binding%s", refusals(n)) })
|
}, func(n int) { say("hearing %s%s", HoldingsAsked, refusals(n)) })
|
||||||
|
|
||||||
|
// What this node should hold (design 39 bus-and-switch §19, §22): the manager's binding.changed naming this node's
|
||||||
|
// consumer, and its first view, the seat's bindings for this consumer, asked once each time the events are heard
|
||||||
|
// anew (the start, or a reconnect); the token is asked from the vault at that first view and on a newer generation.
|
||||||
|
applied := func(done string, err error) {
|
||||||
|
if err != nil {
|
||||||
|
say("fetching this node's token failed: %v", err)
|
||||||
|
} else if done != "" {
|
||||||
|
say("%s", done)
|
||||||
|
}
|
||||||
|
go report()
|
||||||
|
}
|
||||||
|
go persist("hearing this node's licence binding", func() error {
|
||||||
|
err := stdio.Subscribe(BindingChanged, func(e stdio.Envelope) error {
|
||||||
|
applied(OnBindingChanged(p, e.Body, ask, writeManaged))
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
go persist("asking this node's licence binding", func() error {
|
||||||
|
done, err := FirstBinding(p, ask, writeManaged)
|
||||||
|
if err == nil {
|
||||||
|
applied(done, nil)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}, func(int) {})
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}, func(n int) { say("hearing this node's licence binding%s", refusals(n)) })
|
||||||
|
// The token of the licence this node holds, asked again when the vault says its access secret changed (§19).
|
||||||
|
go persist("hearing the vault's changes of this node's licence token", func() error {
|
||||||
|
return stdio.Subscribe(SecretChanged, func(e stdio.Envelope) error {
|
||||||
|
applied(OnSecretChanged(p, e.Body, ask, writeManaged))
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}, func(n int) { say("hearing %s%s", SecretChanged, refusals(n)) })
|
||||||
|
|
||||||
report()
|
report()
|
||||||
var last string
|
var last string
|
||||||
|
|||||||
@@ -10,8 +10,10 @@ package main
|
|||||||
// fingerprints and expiries — never a token. Written at start and on every change of the credentials
|
// fingerprints and expiries — never a token. Written at start and on every change of the credentials
|
||||||
// file, so the licence manager learns a login, or a node already logged in, from the state alone.
|
// file, so the licence manager learns a login, or a node already logged in, from the state alone.
|
||||||
// - The grant itself leaves only when the manager asks `claude_code_grant`, sealed to the key it gives.
|
// - The grant itself leaves only when the manager asks `claude_code_grant`, sealed to the key it gives.
|
||||||
// - What this node should hold is the manager's `bindings` state; a newer generation for this node is
|
// - What this node should hold is the manager's binding, heard as `binding.changed` and asked at start; the
|
||||||
// fetched with the seat's `current` verb, sealed to this module's key, and written access-token-only.
|
// seat's `current` says which licence, and its `access` secret is asked from the vault, sealed to this
|
||||||
|
// module's key, at start, on a newer generation and on the vault's `secret-changed` for it, and written
|
||||||
|
// access-token-only (design 39 bus-and-switch §19).
|
||||||
// - An MCP server registered through this module is a key in its `servers` state — `all.<server>` for
|
// - An MCP server registered through this module is a key in its `servers` state — `all.<server>` for
|
||||||
// every node, `<node>.<server>` for one — which every node watches.
|
// every node, `<node>.<server>` for one — which every node watches.
|
||||||
|
|
||||||
@@ -30,12 +32,23 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Seat is the licence manager's role, and Manager the module whose `bindings` state this one reads.
|
// Seat is the licence manager's role, and Manager the module whose events this one consumes.
|
||||||
const (
|
const (
|
||||||
Seat = "anthropic-licence-manager"
|
Seat = "anthropic-licence-manager"
|
||||||
Manager = "claude-licence-manager"
|
Manager = "claude-licence-manager"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// VaultGet is the vault's verb a licence's access secret is asked by, and SecretChanged the vault's event that it
|
||||||
|
// changed (design 39 bus-and-switch §19, B9).
|
||||||
|
const (
|
||||||
|
VaultGet = "seat:mesh-vault.get"
|
||||||
|
SecretChanged = "mesh-vault.secret-changed"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AccessSecret is the name of a licence's access secret in the vault, by the licence's key as the manager says it
|
||||||
|
// (§15, B7: the key is carried, never formed here).
|
||||||
|
func AccessSecret(key string) string { return "licence." + key + ".access" }
|
||||||
|
|
||||||
// SeatVerb is a seat's verb as the runtime addresses it: a role, not a module.
|
// SeatVerb is a seat's verb as the runtime addresses it: a role, not a module.
|
||||||
func SeatVerb(verb string) string { return "seat:" + Seat + "." + verb }
|
func SeatVerb(verb string) string { return "seat:" + Seat + "." + verb }
|
||||||
|
|
||||||
@@ -309,13 +322,14 @@ type BindingState struct {
|
|||||||
By string `json:"by,omitempty"`
|
By string `json:"by,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Current is what the seat answers to `current`: the licence this node is bound to and its token, sealed.
|
// Current is what the seat answers to `current`: the licence this node is bound to, its key and its account, never
|
||||||
|
// its token (§18, B7).
|
||||||
type Current struct {
|
type Current struct {
|
||||||
Licence string `json:"licence"`
|
Licence string `json:"licence"`
|
||||||
Kind string `json:"kind"`
|
Key string `json:"key"`
|
||||||
Generation int64 `json:"generation"`
|
Kind string `json:"kind"`
|
||||||
Sealed *SealedBox `json:"sealed"`
|
Generation int64 `json:"generation"`
|
||||||
Identity *Identity `json:"identity"`
|
Identity *Identity `json:"identity"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Holdings is what this node holds, as the `holdings` state carries it (ADR 0206): enough for the manager
|
// Holdings is what this node holds, as the `holdings` state carries it (ADR 0206): enough for the manager
|
||||||
@@ -404,40 +418,50 @@ func GrantFor(p Paths, managerPublicKey string) (GrantAnswer, error) {
|
|||||||
return GrantAnswer{Sealed: &box, Identity: ReadIdentity(p.account()), Fingerprint: Fingerprint(rt)}, nil
|
return GrantAnswer{Sealed: &box, Identity: ReadIdentity(p.account()), Fingerprint: Fingerprint(rt)}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pull asks the seat for this node's current token and applies it.
|
// Pull asks the seat which licence this node holds, then the vault for that licence's access secret, sealed to this
|
||||||
|
// module's key, and applies it (§19).
|
||||||
func Pull(p Paths, ask Ask, write WriteManaged) (map[string]any, error) {
|
func Pull(p Paths, ask Ask, write WriteManaged) (map[string]any, error) {
|
||||||
keys, err := Keypair(p)
|
keys, err := Keypair(p)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
raw, err := ask(SeatVerb("current"), map[string]any{"consumer": p.Node, "public_key": keys.PublicKey})
|
raw, err := ask(SeatVerb("current"), map[string]any{"consumer": p.Node})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
var c Current
|
var c Current
|
||||||
if err := json.Unmarshal(raw, &c); err != nil || c.Sealed == nil {
|
if err := json.Unmarshal(raw, &c); err != nil || c.Licence == "" || c.Key == "" {
|
||||||
return map[string]any{"applied": false, "reason": "the seat holds no licence for this node"}, nil
|
return map[string]any{"applied": false, "reason": "the seat holds no licence for this node"}, nil
|
||||||
}
|
}
|
||||||
return Apply(p, c, write)
|
raw, err = ask(VaultGet, map[string]any{"name": AccessSecret(c.Key), "public_key": keys.PublicKey})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var got struct {
|
||||||
|
Sealed *SealedBox `json:"sealed"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &got); err != nil || got.Sealed == nil {
|
||||||
|
return nil, fmt.Errorf("the vault answered no sealed %s", AccessSecret(c.Key))
|
||||||
|
}
|
||||||
|
return Apply(p, c, *got.Sealed, write)
|
||||||
}
|
}
|
||||||
|
|
||||||
// OnBinding takes a change to this node's key in the manager's `bindings` state (ADR 0206): the token is
|
// OnBinding takes this node's binding as the manager said it: the token is fetched when its generation is newer than
|
||||||
// fetched when the binding differs from the one applied. A released binding keeps the last token, which
|
// the one applied (§19). A home that holds no token of the applied kind has nothing applied, so it is fetched too. A
|
||||||
// lives hours, and says so.
|
// released binding keeps the last token, which lives hours, and says so.
|
||||||
//
|
|
||||||
// **Differs, not "is newer"** (novox/hq issue 243). The state keeps only the latest value per node, so
|
|
||||||
// nothing older can arrive. A manager whose store was rebuilt counts generations from one again, and
|
|
||||||
// a node that waited for a number above its own ignored every binding it was sent, its login and the
|
|
||||||
// licence's rotations included, until the count caught up. Only the binding already applied is skipped.
|
|
||||||
func OnBinding(p Paths, b *BindingState, ask Ask, write WriteManaged) (string, error) {
|
func OnBinding(p Paths, b *BindingState, ask Ask, write WriteManaged) (string, error) {
|
||||||
if b == nil {
|
if b == nil {
|
||||||
return "this node's binding was released; it keeps its last token until it expires", nil
|
return "this node's binding was released; it keeps its last token until it expires", nil
|
||||||
}
|
}
|
||||||
var applied Binding
|
var applied Binding
|
||||||
if readJSON(p.binding(), &applied) && applied.Generation == b.Generation && applied.Licence == b.Licence &&
|
if readJSON(p.binding(), &applied) && b.Generation <= applied.Generation && holdsApplied(p, applied.Kind) {
|
||||||
holdsApplied(p, applied.Kind) {
|
|
||||||
return "", nil
|
return "", nil
|
||||||
}
|
}
|
||||||
|
return fetched(p, b, ask, write)
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetched pulls this node's token for binding b and says the line naming it.
|
||||||
|
func fetched(p Paths, b *BindingState, ask Ask, write WriteManaged) (string, error) {
|
||||||
out, err := Pull(p, ask, write)
|
out, err := Pull(p, ask, write)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
@@ -455,14 +479,86 @@ func OnBinding(p Paths, b *BindingState, ask Ask, write WriteManaged) (string, e
|
|||||||
return string(raw), nil
|
return string(raw), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply applies what the seat handed over. A switch replaces the grant whole and cleans up after the old
|
// BindingChanged is the manager's event of a binding written or deleted (design 39 bus-and-switch §21), which this
|
||||||
// licence; whatever it is, the file is written without a refresh token, so the agent here never refreshes.
|
// module consumes (§22).
|
||||||
func Apply(p Paths, c Current, write WriteManaged) (map[string]any, error) {
|
const BindingChanged = Manager + ".binding.changed"
|
||||||
|
|
||||||
|
// bindingOf is a consumer's binding as the manager says it, in binding.changed and in the verb bindings' answer: an
|
||||||
|
// empty licence is a release (B22: decoded from a copy of the writer's form, testdata/binding-changed.json).
|
||||||
|
type bindingOf struct {
|
||||||
|
Consumer string `json:"consumer"`
|
||||||
|
BindingState
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b bindingOf) state() *BindingState {
|
||||||
|
if b.Licence == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &b.BindingState
|
||||||
|
}
|
||||||
|
|
||||||
|
// OnBindingChanged takes one binding.changed: acted on only when it names this node's consumer (§22).
|
||||||
|
func OnBindingChanged(p Paths, body []byte, ask Ask, write WriteManaged) (string, error) {
|
||||||
|
var b bindingOf
|
||||||
|
if err := json.Unmarshal(body, &b); err != nil {
|
||||||
|
return "", fmt.Errorf("%s is not a binding's change: %w", BindingChanged, err)
|
||||||
|
}
|
||||||
|
if b.Consumer != p.Node {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
return OnBinding(p, b.state(), ask, write)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FirstBinding asks the seat's verb bindings for this node's consumer, its first view (§22, B10), and applies it: at
|
||||||
|
// the module's start, and again whenever it hears the manager's events anew.
|
||||||
|
func FirstBinding(p Paths, ask Ask, write WriteManaged) (string, error) {
|
||||||
|
raw, err := ask(SeatVerb("bindings"), map[string]any{"consumer": p.Node})
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
var listed []bindingOf
|
||||||
|
if err := json.Unmarshal(raw, &listed); err != nil {
|
||||||
|
return "", fmt.Errorf("the seat's bindings answered what is not a list of bindings: %w", err)
|
||||||
|
}
|
||||||
|
for _, b := range listed {
|
||||||
|
if b.Consumer == p.Node && b.state() != nil {
|
||||||
|
// At its start the module asks the vault whatever it applied before (§19).
|
||||||
|
return fetched(p, b.state(), ask, write)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return OnBinding(p, nil, ask, write)
|
||||||
|
}
|
||||||
|
|
||||||
|
// secretChanged is the vault's `secret-changed` as it says it: the secret's name and generation, never its value.
|
||||||
|
type secretChanged struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Generation int64 `json:"generation"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// OnSecretChanged takes one `secret-changed`: acted on only when it names the access secret of the licence this node
|
||||||
|
// holds, which is then asked again (§19).
|
||||||
|
func OnSecretChanged(p Paths, body []byte, ask Ask, write WriteManaged) (string, error) {
|
||||||
|
var e secretChanged
|
||||||
|
if err := json.Unmarshal(body, &e); err != nil {
|
||||||
|
return "", fmt.Errorf("%s is not a secret's change: %w", SecretChanged, err)
|
||||||
|
}
|
||||||
|
var applied Binding
|
||||||
|
if !readJSON(p.binding(), &applied) || applied.Key == "" || e.Name != AccessSecret(applied.Key) {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
return fetched(p, &BindingState{Licence: applied.Licence, Kind: applied.Kind, Generation: applied.Generation,
|
||||||
|
Why: "the vault's " + e.Name + " changed"}, ask, write)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply applies the access secret the vault handed over for the licence the seat named. A switch replaces the grant
|
||||||
|
// whole and cleans up after the old licence; whatever it is, the file is written without a refresh token, so the
|
||||||
|
// agent here never refreshes.
|
||||||
|
func Apply(p Paths, c Current, sealed SealedBox, write WriteManaged) (map[string]any, error) {
|
||||||
keys, err := Keypair(p)
|
keys, err := Keypair(p)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
plain, err := Open(*c.Sealed, keys.PrivateKey)
|
plain, err := Open(sealed, keys.PrivateKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -522,7 +618,7 @@ func Apply(p Paths, c Current, write WriteManaged) (map[string]any, error) {
|
|||||||
if gen == 0 {
|
if gen == 0 {
|
||||||
gen = previous.Generation
|
gen = previous.Generation
|
||||||
}
|
}
|
||||||
raw, _ := json.Marshal(Binding{Licence: c.Licence, Kind: c.Kind, Generation: gen})
|
raw, _ := json.Marshal(Binding{Licence: c.Licence, Key: c.Key, Kind: c.Kind, Generation: gen})
|
||||||
if err := fsys.WriteFile(p.binding(), append(raw, '\n'), 0o600); err != nil {
|
if err := fsys.WriteFile(p.binding(), append(raw, '\n'), 0o600); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,7 +50,9 @@ type Settings struct {
|
|||||||
|
|
||||||
// Binding is the licence this node holds, as it was last applied.
|
// Binding is the licence this node holds, as it was last applied.
|
||||||
type Binding struct {
|
type Binding struct {
|
||||||
Licence string `json:"licence"`
|
Licence string `json:"licence"`
|
||||||
|
// Key is the licence's key as the manager says it, which names its access secret in the vault (§19, B7).
|
||||||
|
Key string `json:"key,omitempty"`
|
||||||
Kind string `json:"kind"` // subscription | api-key
|
Kind string `json:"kind"` // subscription | api-key
|
||||||
Generation int64 `json:"generation,omitempty"`
|
Generation int64 `json:"generation,omitempty"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
{"at":"2026-10-04T12:00:00Z","by":"g14.node-tools","consumer":"laptop","from":"acct-1@example.org","generation":3,"key":"api","kind":"api-key","licence":"api","why":"the switch verb"}
|
||||||
@@ -28,14 +28,20 @@
|
|||||||
"seat:mesh-catalog.seat-definitions",
|
"seat:mesh-catalog.seat-definitions",
|
||||||
"mesh-delivery-indexer.indexer_read_modules",
|
"mesh-delivery-indexer.indexer_read_modules",
|
||||||
"mesh-delivery-indexer.indexer_read_manifests",
|
"mesh-delivery-indexer.indexer_read_manifests",
|
||||||
"mesh-delivery-assigner.assigner_read_assignments"
|
"mesh-delivery-assigner.assigner_read_assignments",
|
||||||
|
"seat:anthropic-licence-manager.bindings",
|
||||||
|
"seat:anthropic-licence-manager.current",
|
||||||
|
"seat:mesh-vault.get"
|
||||||
],
|
],
|
||||||
"consumes": [
|
"consumes": [
|
||||||
"mesh-delivery-indexer.views.modules",
|
"mesh-delivery-indexer.views.modules",
|
||||||
"mesh-delivery-indexer.views.manifests",
|
"mesh-delivery-indexer.views.manifests",
|
||||||
"mesh-delivery-assigner.views.assignments",
|
"mesh-delivery-assigner.views.assignments",
|
||||||
"mesh-catalog.seat-definition-changed",
|
"mesh-catalog.seat-definition-changed",
|
||||||
"mesh-catalog.seat-definition-removed"
|
"mesh-catalog.seat-definition-removed",
|
||||||
|
"claude-licence-manager.holdings.asked",
|
||||||
|
"claude-licence-manager.binding.changed",
|
||||||
|
"mesh-vault.secret-changed"
|
||||||
],
|
],
|
||||||
"state": [
|
"state": [
|
||||||
"servers",
|
"servers",
|
||||||
@@ -48,10 +54,8 @@
|
|||||||
"findings"
|
"findings"
|
||||||
],
|
],
|
||||||
"emits": [
|
"emits": [
|
||||||
"judged"
|
"judged",
|
||||||
],
|
"holdings-changed"
|
||||||
"reads": [
|
|
||||||
"claude-licence-manager.bindings"
|
|
||||||
],
|
],
|
||||||
"tools": [
|
"tools": [
|
||||||
"claude_code_status",
|
"claude_code_status",
|
||||||
|
|||||||
@@ -1,58 +1,103 @@
|
|||||||
# claude-licence-manager
|
# claude-licence-manager
|
||||||
|
|
||||||
Holds the `anthropic-licence-manager` seat: every Anthropic licence the mesh has, kept alive by one
|
Holds the `anthropic-licence-manager` seat: every Anthropic licence the mesh has, kept alive by one
|
||||||
rotation source, and handed to each consumer sealed (novox/hq ADR 0183, ADR 0206, design 39).
|
rotation source, its consumers moved off a licence near its limit by the manager itself (novox/hq ADR 0183,
|
||||||
|
ADR 0206, design 39 and its milestone bus-and-switch). Every grant and access token lives in the vault; this module
|
||||||
|
keeps none.
|
||||||
|
|
||||||
## How a licence comes to exist
|
## How a licence comes to exist
|
||||||
|
|
||||||
Nothing is configured. Every node running `claude-code` reports what it holds as that module's
|
Nothing is configured. Every node running `claude-code` says what it holds as the event
|
||||||
`holdings` state — the account, fingerprints and expiries, never a token. This module reads every report
|
`claude-code.holdings-changed` (the account, fingerprints and expiries, never a token), at its start, on every change,
|
||||||
when it starts and watches them:
|
and again when this module says `holdings.asked` (at every start, after the carry-over, and on `licences_resync`):
|
||||||
|
|
||||||
1. A report with a refresh token it does not hold is a **candidate**.
|
1. A report with a refresh token it does not hold is a **candidate**.
|
||||||
2. It asks that node's `claude_code_grant`, giving its public key, and receives the grant sealed to it.
|
2. It asks that node's `claude_code_grant`, giving its public key, and receives the grant sealed to it.
|
||||||
3. **It refreshes it.** If the vendor exchanges the token, the grant is this module's — encrypted at rest
|
3. **It refreshes it**, under the account's lease. If the vendor exchanges the token, the licence is new, named by its
|
||||||
with the key the vault made for it — and from then on it is the only refresher. If not, the candidate
|
account's address and kept under that name's key (`@` as `_at_`, any other character outside letters, digits, `-`
|
||||||
is recorded dead and nothing is adopted.
|
and `.` as `_`); its grant and access token go to the vault (`licence-put`), and from then on this module is the
|
||||||
|
only refresher. If not, the candidate is recorded dead and nothing is adopted. An adoption whose key another licence
|
||||||
|
has is refused.
|
||||||
4. Several nodes logged in to one account: newest login first; the rest are never exchanged.
|
4. Several nodes logged in to one account: newest login first; the rest are never exchanged.
|
||||||
5. A node reporting that account and bound to nothing is bound to it.
|
5. The node it came from is bound to it; a node reporting that account and bound to nothing is bound to it.
|
||||||
|
|
||||||
Each node is then handed an access token only, so the agent there never refreshes, and a refresh token
|
From its next reading a new subscription has a room and is a target of the switch. An API key enters through `adopt`,
|
||||||
appearing on a node later can only be a person's login — which wins if it refreshes.
|
from a file on this module's node or sealed by a node's `claude_code_add_api_key`; it is never a target.
|
||||||
|
|
||||||
An API key enters through `adopt`, from a file on this module's node.
|
## What it keeps on the bus
|
||||||
|
|
||||||
## What each consumer holds
|
Each piece asked for in `requires` by its template (ADR 0595), read by `licences_kept`:
|
||||||
|
|
||||||
This module's `bindings` state: one key per consumer (a node's name) with the licence, its kind and a
|
| name | template | what |
|
||||||
generation that grows with every rotation and switch. `claude-code` watches its own key and, on a newer
|
|---|---|---|
|
||||||
generation, asks `current` with its public key.
|
| `licences` | state, history 1 | one key per licence: account, address, organisation, expiries, failures, adoption, the vault's generation, the latest reading; never a token |
|
||||||
|
| `bindings` | state, history 8 | one key per consumer (a node's name): licence, kind, generation, since, why and by |
|
||||||
|
| `leases` | state, a lifetime per key | `licence.<key>`, `account.<id>`: one refresh or adoption at a time, two minutes |
|
||||||
|
| `offered` | state, a lifetime per key | each grant a node offered and its outcome, 90 days |
|
||||||
|
| `usage` | log, 35 days, 256 MiB | every reading, one subject per licence key, with the vendor's answer whole |
|
||||||
|
| `audit` | log, 64 MiB | every act, one subject per kind (`switched`, `carried`, `settings` among them) |
|
||||||
|
| `carried` | state | `postgres`: when the carry-over ran, and per table the rows read and entries written |
|
||||||
|
| `conditions` | state | each condition said raised, and since when |
|
||||||
|
|
||||||
|
Its database (`postgres-database`, read with the module's own credential) and `grant.key` are kept only as the
|
||||||
|
carry-over's source: at its first start, before it hears an event, refreshes or reads usage, the manager reads every
|
||||||
|
row of today's six tables and writes them here and to the vault, then `carried.postgres`. Until then it serves its
|
||||||
|
reads alone and answers every act with "the carry-over has not ended"; a failed read or write raises
|
||||||
|
`carry-over-failed`. With `carried.postgres` present it never reads the database again. The database is never written.
|
||||||
|
|
||||||
|
## Switching on usage
|
||||||
|
|
||||||
|
Every `usage_minutes` the manager reads each subscription's usage with its access token from the vault: the session,
|
||||||
|
weekly and sonnet windows, each with its reset time. A licence's room is 100 less the higher of its session and weekly
|
||||||
|
percentages, known only when it was read within `usage_stale_minutes` and its refresh is healthy. When a reading puts
|
||||||
|
a licence with consumers at or above `switch_at_pct`, every consumer bound to it moves, in one act, to the licence
|
||||||
|
with the most room whose windows are both below `room_below_pct`: one `binding.changed` per consumer, one
|
||||||
|
`licence.switched`, and one `operator-channel.tell`. A consumer is never moved back by the rule.
|
||||||
|
|
||||||
|
Its conditions are said on the operator channel's condition subject, the router deciding where they go:
|
||||||
|
`licence.<key>.no-room` (urgent; no licence has room), `licence.<key>.near-limit` (at `warn_at_pct`, once per
|
||||||
|
crossing), `licence.<key>.usage-unread`, `carry-over-failed` and `settings.<setting>.invalid`.
|
||||||
|
|
||||||
## The seat's verbs
|
## The seat's verbs
|
||||||
|
|
||||||
`licences`, `bindings`, `bind`, `switch`, `release`, `refresh`, `usage`, `adopt`, `current` — through
|
`licences`, `bindings` (with `consumer` for one), `bind`, `switch`, `release`, `refresh`, `usage` (`licence`,
|
||||||
the console as `anthropic-licence-manager.<verb>`. No answer carries a token.
|
`since`, `limit` up to 2000, `raw`), `adopt`, `public-key`, `current` (the licence, its key, kind, generation and
|
||||||
|
health, never the token) — through the console as `anthropic-licence-manager.<verb>`. No answer carries a token.
|
||||||
|
|
||||||
|
## Its tools
|
||||||
|
|
||||||
|
`licences_switches` (every switch and why), `licences_read_usage` (read now, `dry`), `licences_switch_now` (apply the
|
||||||
|
rule now, even with `switch_auto` off), `licences_settings` (each value, its default and where it was set),
|
||||||
|
`licences_kept` (every entry it keeps, how full against its cap), `licences_resync` (say every binding, holdings.asked
|
||||||
|
and each condition again, and hand each access token to the vault again), `licences_conditions`, `licences_carried`
|
||||||
|
(the carry-over's record; `run` while it has not ended).
|
||||||
|
|
||||||
## Settings
|
## Settings
|
||||||
|
|
||||||
`settings.json` in the state directory: `cadence_minutes` (240), `floor_minutes` (60),
|
Through mesh-config, each `trusted`, read again at every reading: `cadence_minutes` (240), `floor_minutes` (60),
|
||||||
`failures_to_notify` (3), `cooldown_hours` (24), `refresh_warn_days` (3).
|
`failures_to_notify` (3), `cooldown_hours` (24), `refresh_warn_days` (3), `switch_auto` (on), `switch_at_pct` (99,
|
||||||
|
1 to 100), `room_below_pct` (90, 1 to 100), `warn_at_pct` (90, 1 to 100), `usage_minutes` (5, 1 to 60),
|
||||||
|
`usage_stale_minutes` (15, 5 to 180). A value outside its bound keeps its default and raises its condition.
|
||||||
|
|
||||||
## Events
|
## Events
|
||||||
|
|
||||||
`licence.adopted`, `licence.refused`, `licence.failing`, `usage.read` — none carries a secret.
|
`licence.adopted`, `licence.refused`, `licence.switched`, `licence.failing`, `usage.read`, `binding.changed`,
|
||||||
|
`holdings.asked` — none carries a secret. It consumes `claude-code.holdings-changed`.
|
||||||
|
|
||||||
## Code and tests
|
## Code and tests
|
||||||
|
|
||||||
Go, one binary (`cmd/claude-licence-manager`): the seat's verbs and the daemon in one launched bundle,
|
Go, one binary (`cmd/claude-licence-manager`): the seat's verbs, the tools and the daemon in one launched bundle. The
|
||||||
`prepare` as the run-once preparation step. The sealed box is `claude-code`'s own format, byte for byte —
|
sealed box is `claude-code`'s own format, byte for byte — the two modules carry the same `seal.go` — and a test opens
|
||||||
the two modules carry the same `seal.go` — and a test opens one sealed by the TypeScript agent module the
|
one sealed by the TypeScript agent module the Go one replaced, so the format is the one already on the machines.
|
||||||
Go one replaced, so the format is the one already on the machines.
|
|
||||||
|
|
||||||
go test ./...
|
go test ./...
|
||||||
# the store against a real postgres:
|
# the whole module on a real bus, and today's rows on a real postgres:
|
||||||
docker run -d --rm --name licmgr-pg -e POSTGRES_PASSWORD=t -p 15498:5432 postgres:16-alpine
|
go test -tags integration ./...
|
||||||
MESH_TEST_POSTGRES=postgres://postgres:t@127.0.0.1:15498/postgres go test ./...
|
|
||||||
|
The whole tests (`whole_integration_test.go`) start the built binary as its runtime does, its kept data in a real
|
||||||
|
nats-server, and today's database on `MESH_TEST_POSTGRES` or a private postgres started from the machine's `initdb`
|
||||||
|
on a socket of its own; with neither, the tests that need one skip and say so. The vault, the agent modules, the
|
||||||
|
operator channel and the vendor are played beside it; a condition is read through the operator channel's client.
|
||||||
|
|
||||||
## Files it does not know
|
## Files it does not know
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,377 @@
|
|||||||
|
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"
|
||||||
|
"fmt"
|
||||||
|
"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
|
||||||
|
LeasesState keptState
|
||||||
|
OfferedState keptState
|
||||||
|
UsageLog keptLog
|
||||||
|
AuditLog keptLog
|
||||||
|
Now func() time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- leases and offers (§4, §5) ---------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// leaseValue is a lease's key in `leases`: its holder and until when.
|
||||||
|
type leaseValue struct {
|
||||||
|
Holder string `json:"holder"`
|
||||||
|
Until string `json:"until"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lease takes a lease by creating its key with a lifetime of d (§4): a create fails while another holds it, the bus's
|
||||||
|
// own compare-and-set. A key its own holder holds is taken again.
|
||||||
|
func (s *BusStore) Lease(_ context.Context, key, holder string, d time.Duration) (bool, error) {
|
||||||
|
v := leaseValue{Holder: holder, Until: s.Now().Add(d).UTC().Format(time.RFC3339)}
|
||||||
|
_, err := s.LeasesState.CreateTTL(key, v, d)
|
||||||
|
if err == nil {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
if !errors.Is(err, stdio.ErrChanged) {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
var held leaseValue
|
||||||
|
if ok, gerr := s.get(s.LeasesState, key, &held); gerr != nil || !ok || held.Holder != holder {
|
||||||
|
return false, gerr
|
||||||
|
}
|
||||||
|
_, err = s.LeasesState.PutTTL(key, v, d)
|
||||||
|
return err == nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unlease deletes a lease its holder holds; another's is left to it or its lifetime.
|
||||||
|
func (s *BusStore) Unlease(_ context.Context, key, holder string) error {
|
||||||
|
var held leaseValue
|
||||||
|
if ok, err := s.get(s.LeasesState, key, &held); err != nil || !ok || held.Holder != holder {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.LeasesState.Delete(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
// offeredFor is how long the outcome of an offered grant is kept (§5): longer than a refresh token lives.
|
||||||
|
const offeredFor = 90 * 24 * time.Hour
|
||||||
|
|
||||||
|
// offeredValue is a grant's key in `offered`: what became of it, where and when (§5, B13: every outcome).
|
||||||
|
type offeredValue struct {
|
||||||
|
Node string `json:"node"`
|
||||||
|
Account string `json:"account"`
|
||||||
|
Outcome Outcome `json:"outcome"`
|
||||||
|
Why string `json:"why"`
|
||||||
|
At string `json:"at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// OfferedKey is a fingerprint's key in `offered`: through the licence key form (B13).
|
||||||
|
func OfferedKey(fingerprint string) string { return KeyFor(fingerprint) }
|
||||||
|
|
||||||
|
func (s *BusStore) Outcome(_ context.Context, fingerprint string) (Outcome, error) {
|
||||||
|
var v offeredValue
|
||||||
|
if ok, err := s.get(s.OfferedState, OfferedKey(fingerprint), &v); err != nil || !ok {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return v.Outcome, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *BusStore) RecordOutcome(_ context.Context, fingerprint, node, account string, o Outcome, why string) error {
|
||||||
|
return s.putOffered(fingerprint, offeredValue{Node: node, Account: account, Outcome: o, Why: why,
|
||||||
|
At: s.Now().UTC().Format(time.RFC3339)}, offeredFor)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *BusStore) putOffered(fingerprint string, v offeredValue, ttl time.Duration) error {
|
||||||
|
_, 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)
|
||||||
|
Newest(keys string) ([]stdio.LogEntry, 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, none before since, the vendor's answer
|
||||||
|
// with raw (§35).
|
||||||
|
func (s *BusStore) Usage(ctx context.Context, licence string, since time.Time, limit int, raw bool) ([]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)
|
||||||
|
if at.Before(since) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name := names[e.Key]
|
||||||
|
if name == "" {
|
||||||
|
name = e.Key
|
||||||
|
}
|
||||||
|
row := UsageRow{Licence: name, At: at.UnixMilli(), Reading: u.Reading}
|
||||||
|
if raw {
|
||||||
|
row.Raw = u.Raw
|
||||||
|
}
|
||||||
|
out = append(out, row)
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,359 @@
|
|||||||
|
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, stdio.ErrChanged
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// design 39 bus-and-switch §4, §5, B13: a lease is a key created with a two-minute lifetime that another holder cannot
|
||||||
|
// create; an offered grant's outcome is kept under its fingerprint in the key form for 90 days.
|
||||||
|
func TestALeaseAndAnOfferAreKeptByTheirKeysAndLifetimes(t *testing.T) {
|
||||||
|
mm := newMesh(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
key := "licence." + KeyFor(licence1)
|
||||||
|
if ok, err := mm.store.Lease(ctx, key, "one", leaseFor); !ok || err != nil {
|
||||||
|
t.Fatalf("the first lease: %v %v", ok, err)
|
||||||
|
}
|
||||||
|
if ok, _ := mm.store.Lease(ctx, key, "two", leaseFor); ok {
|
||||||
|
t.Fatal("a second holder took a live lease")
|
||||||
|
}
|
||||||
|
if u := mm.leases.until[key]; !u.Equal(mm.now.Add(2 * time.Minute)) {
|
||||||
|
t.Fatalf("the lease lives until %v", u)
|
||||||
|
}
|
||||||
|
mm.now = mm.now.Add(2 * time.Minute)
|
||||||
|
if ok, _ := mm.store.Lease(ctx, key, "two", leaseFor); !ok {
|
||||||
|
t.Fatal("a lease past its lifetime was not taken")
|
||||||
|
}
|
||||||
|
_ = mm.store.Unlease(ctx, key, "one")
|
||||||
|
if _, held := mm.leases.values[key]; !held {
|
||||||
|
t.Fatal("another holder's lease was ended")
|
||||||
|
}
|
||||||
|
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-dead", false, t0)})
|
||||||
|
fp := OfferedKey(Fingerprint("rt-dead"))
|
||||||
|
if strings.ContainsAny(fp, ":") || !strings.HasPrefix(fp, "sha256_") {
|
||||||
|
t.Fatalf("the offered key %q is not in the key form", fp)
|
||||||
|
}
|
||||||
|
var v offeredValue
|
||||||
|
_ = json.Unmarshal(mm.offered.values[fp], &v)
|
||||||
|
if v.Outcome != Dead || v.Node != "laptop" || v.Account != "acct-1" || v.Why == "" || v.At == "" {
|
||||||
|
t.Fatalf("offered %+v", v)
|
||||||
|
}
|
||||||
|
if u := mm.offered.until[fp]; !u.Equal(mm.now.Add(90 * 24 * time.Hour)) {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *memLog) Newest(keys string) ([]stdio.LogEntry, error) {
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
last := map[string]int{}
|
||||||
|
for i, e := range l.entries {
|
||||||
|
if keys == "" || e.Key == keys {
|
||||||
|
last[e.Key] = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out := []stdio.LogEntry{}
|
||||||
|
for i, e := range l.entries {
|
||||||
|
if j, ok := last[e.Key]; ok && j == i {
|
||||||
|
out = append(out, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, 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, time.Time{}, 20, false)
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,486 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// The carry-over (design 39 bus-and-switch §8, §11 to §13, A3, B14): at its first start the manager reads every row of
|
||||||
|
// today's database, the module's own and read only, and writes it into its kept data on the bus and its grants into
|
||||||
|
// the vault, then the key `carried.postgres`. Until that key exists it serves its read verbs alone and does nothing
|
||||||
|
// else. With the key present it never reads the database again.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CarriedKey is the key of the state `carried` the carry-over writes at its end (§8).
|
||||||
|
const CarriedKey = "postgres"
|
||||||
|
|
||||||
|
// NotCarried is the answer of every acting verb and tool until the carry-over has ended (§11).
|
||||||
|
const NotCarried = "the carry-over has not ended"
|
||||||
|
|
||||||
|
// usageKeptFor is how far back the carry-over reads usage: the log `usage` keeps no older reading (§6, §12).
|
||||||
|
const usageKeptFor = 35 * 24 * time.Hour
|
||||||
|
|
||||||
|
// The tables of today's database, in the order the carry-over reads them (§11, §12).
|
||||||
|
var CarriedTables = []string{"licence", "binding", "lease", "offered", "usage", "audit"}
|
||||||
|
|
||||||
|
// LicenceRow is a row of the table `licence`: the licence and its grant at rest, sealed with `grant.key`.
|
||||||
|
type LicenceRow struct {
|
||||||
|
Licence
|
||||||
|
Sealed string
|
||||||
|
}
|
||||||
|
|
||||||
|
// OfferedRow is a row of the table `offered`.
|
||||||
|
type OfferedRow struct {
|
||||||
|
Fingerprint, Node, Account string
|
||||||
|
Outcome Outcome
|
||||||
|
Why string
|
||||||
|
At time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// UsageTableRow is a row of the table `usage`: the reading and the vendor's answer whole.
|
||||||
|
type UsageTableRow struct {
|
||||||
|
Licence string
|
||||||
|
At int64 // milliseconds
|
||||||
|
Reading UsageReading
|
||||||
|
Raw map[string]any
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuditRow is a row of the table `audit`.
|
||||||
|
type AuditRow struct {
|
||||||
|
At time.Time
|
||||||
|
What string
|
||||||
|
Detail map[string]any
|
||||||
|
}
|
||||||
|
|
||||||
|
// CarrySource reads the six tables of today's database, each oldest first; a table that does not exist is no rows.
|
||||||
|
type CarrySource interface {
|
||||||
|
Licences(ctx context.Context) ([]LicenceRow, error)
|
||||||
|
Bindings(ctx context.Context) ([]Binding, error)
|
||||||
|
Leases(ctx context.Context) ([]LeaseRow, error)
|
||||||
|
Offered(ctx context.Context) ([]OfferedRow, error)
|
||||||
|
Usage(ctx context.Context, since time.Time) ([]UsageTableRow, error)
|
||||||
|
Audit(ctx context.Context) ([]AuditRow, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// LeaseRow is one row of today's table `lease`: its key (`licence:<name>` or `account:<id>`), holder and until when.
|
||||||
|
type LeaseRow struct {
|
||||||
|
Key string
|
||||||
|
Holder string
|
||||||
|
Until time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// CarriedTable is one table in `carried.postgres`: the rows read and the entries written for them.
|
||||||
|
type CarriedTable struct {
|
||||||
|
Read int `json:"read"`
|
||||||
|
Written int `json:"written"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CarriedRecord is the value of `carried.postgres` (§8): when, and for each table the rows read and entries written.
|
||||||
|
type CarriedRecord struct {
|
||||||
|
At string `json:"at"`
|
||||||
|
Tables map[string]CarriedTable `json:"tables"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Carry is the carry-over's gate: closed until `carried.postgres` exists, and the failure of its last run.
|
||||||
|
type Carry struct {
|
||||||
|
State keptState // the state `carried`
|
||||||
|
// Source opens today's database; called only while the key is absent.
|
||||||
|
Source func(ctx context.Context) (CarrySource, error)
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
running bool
|
||||||
|
failure string
|
||||||
|
done chan struct{}
|
||||||
|
once sync.Once
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Carry) init() {
|
||||||
|
c.mu.Lock()
|
||||||
|
if c.done == nil {
|
||||||
|
c.done = make(chan struct{})
|
||||||
|
}
|
||||||
|
c.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ended is whether the carry-over has ended: the key `carried.postgres` exists.
|
||||||
|
func (c *Carry) Ended() bool {
|
||||||
|
if c == nil {
|
||||||
|
return true // a manager built with no carry-over to wait on: the tests' own
|
||||||
|
}
|
||||||
|
c.init()
|
||||||
|
select {
|
||||||
|
case <-c.done:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
if e, err := c.State.Get(CarriedKey); err == nil && e != nil && len(e.Value) > 0 {
|
||||||
|
c.end()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Carry) end() { c.once.Do(func() { close(c.done) }) }
|
||||||
|
|
||||||
|
// Wait is closed once the carry-over has ended.
|
||||||
|
func (c *Carry) Wait() <-chan struct{} {
|
||||||
|
c.init()
|
||||||
|
return c.done
|
||||||
|
}
|
||||||
|
|
||||||
|
// Acts answers an acting verb or tool: nothing until the carry-over has ended (§11).
|
||||||
|
func (c *Carry) Acts() error {
|
||||||
|
if c.Ended() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return errors.New(NotCarried)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Failure is what the last run that failed names, empty when none failed.
|
||||||
|
func (c *Carry) Failure() string {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
return c.failure
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run carries over every row when `carried.postgres` is absent, and says nothing else happened when it is present
|
||||||
|
// (§13). A run that fails names its table and error, and leaves the key absent, so the next is done again whole.
|
||||||
|
func (c *Carry) Run(ctx context.Context, m *Manager, s *BusStore) (*CarriedRecord, error) {
|
||||||
|
if c.Ended() {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
c.mu.Lock()
|
||||||
|
if c.running {
|
||||||
|
c.mu.Unlock()
|
||||||
|
return nil, errors.New("a carry-over is running")
|
||||||
|
}
|
||||||
|
c.running = true
|
||||||
|
c.mu.Unlock()
|
||||||
|
rec, err := c.run(ctx, m, s)
|
||||||
|
c.mu.Lock()
|
||||||
|
c.running = false
|
||||||
|
if err != nil {
|
||||||
|
c.failure = err.Error()
|
||||||
|
} else {
|
||||||
|
c.failure = ""
|
||||||
|
}
|
||||||
|
c.mu.Unlock()
|
||||||
|
if err != nil {
|
||||||
|
m.Log("the carry-over failed: %v", err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
c.end()
|
||||||
|
m.Log("carried over today's database: %v", rec.Tables)
|
||||||
|
return rec, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Carry) run(ctx context.Context, m *Manager, s *BusStore) (*CarriedRecord, error) {
|
||||||
|
src, err := c.Source(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("the database cannot be opened: %w", err)
|
||||||
|
}
|
||||||
|
if closer, ok := src.(interface{ Close() }); ok {
|
||||||
|
defer closer.Close()
|
||||||
|
}
|
||||||
|
co := carryOver{m: m, s: s, src: src, now: m.Now(), tables: map[string]CarriedTable{}}
|
||||||
|
if err := co.all(ctx); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
rec := CarriedRecord{At: co.now.UTC().Format(time.RFC3339), Tables: co.tables}
|
||||||
|
detail := map[string]any{"why": "the first start of the manager on the bus", "by": Itself, "tables": rec.Tables}
|
||||||
|
if err := s.Audit(ctx, "carried", detail); err != nil {
|
||||||
|
return nil, fmt.Errorf("carried: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := c.State.Put(CarriedKey, rec); err != nil {
|
||||||
|
return nil, fmt.Errorf("carried: the key %s was not written: %w", CarriedKey, err)
|
||||||
|
}
|
||||||
|
return &rec, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// carryOver is one run of the carry-over.
|
||||||
|
type carryOver struct {
|
||||||
|
m *Manager
|
||||||
|
s *BusStore
|
||||||
|
src CarrySource
|
||||||
|
now time.Time
|
||||||
|
tables map[string]CarriedTable
|
||||||
|
audit []AuditRow
|
||||||
|
}
|
||||||
|
|
||||||
|
func (co *carryOver) all(ctx context.Context) error {
|
||||||
|
// The audit is read first: a licence's adoptedFrom comes from it (B14).
|
||||||
|
audit, err := co.src.Audit(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("audit: %w", err)
|
||||||
|
}
|
||||||
|
co.audit = audit
|
||||||
|
for _, table := range CarriedTables {
|
||||||
|
var t CarriedTable
|
||||||
|
var err error
|
||||||
|
switch table {
|
||||||
|
case "licence":
|
||||||
|
t, err = co.licences(ctx)
|
||||||
|
case "binding":
|
||||||
|
t, err = co.bindings(ctx)
|
||||||
|
case "lease":
|
||||||
|
t, err = co.leases(ctx)
|
||||||
|
case "offered":
|
||||||
|
t, err = co.offered(ctx)
|
||||||
|
case "usage":
|
||||||
|
t, err = co.usage(ctx)
|
||||||
|
case "audit":
|
||||||
|
t, err = co.audits()
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("%s: %w", table, err)
|
||||||
|
}
|
||||||
|
co.tables[table] = t
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// adoptedFrom is the node the newest adoption of a licence names in the audit (B14).
|
||||||
|
func (co *carryOver) adoptedFrom(name string) string {
|
||||||
|
from := ""
|
||||||
|
for _, r := range co.audit {
|
||||||
|
if r.What != "adopted" || r.Detail["licence"] != name {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if n, _ := r.Detail["node"].(string); n != "" {
|
||||||
|
from = n
|
||||||
|
} else if f, _ := r.Detail["from"].(string); f != "" {
|
||||||
|
from = f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return from
|
||||||
|
}
|
||||||
|
|
||||||
|
// licences writes a key of `licences` per row, its grant opened with `grant.key` and handed to the vault at
|
||||||
|
// generation 1, the grant first (§12, §15).
|
||||||
|
func (co *carryOver) licences(ctx context.Context) (CarriedTable, error) {
|
||||||
|
rows, err := co.src.Licences(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return CarriedTable{}, err
|
||||||
|
}
|
||||||
|
t := CarriedTable{Read: len(rows)}
|
||||||
|
for _, r := range rows {
|
||||||
|
l := r.Licence
|
||||||
|
l.Generation, l.Usage = 0, nil
|
||||||
|
l.AdoptedFrom = co.adoptedFrom(l.Name)
|
||||||
|
if r.Sealed != "" {
|
||||||
|
if co.m.Crypt == nil {
|
||||||
|
return t, fmt.Errorf("the grant of %s cannot be opened: no grant.key", l.Name)
|
||||||
|
}
|
||||||
|
plain, err := co.m.Crypt.Open(r.Sealed)
|
||||||
|
if err != nil {
|
||||||
|
return t, fmt.Errorf("the grant of %s: %w", l.Name, err)
|
||||||
|
}
|
||||||
|
grant, access := plain, plain
|
||||||
|
if l.Kind == "subscription" {
|
||||||
|
var g FullGrant
|
||||||
|
if err := json.Unmarshal([]byte(plain), &g); err != nil {
|
||||||
|
return t, fmt.Errorf("the grant of %s is not a grant", l.Name)
|
||||||
|
}
|
||||||
|
access = accessOf(g)
|
||||||
|
}
|
||||||
|
if err := co.m.handOver(ctx, &l, grant, access); err != nil {
|
||||||
|
return t, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := co.s.SaveLicence(ctx, l); err != nil {
|
||||||
|
return t, err
|
||||||
|
}
|
||||||
|
t.Written++
|
||||||
|
}
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// bindings writes a key of `bindings` per row, its generation kept, why "carried over", by the manager, since the
|
||||||
|
// carry-over's time (§12, B14); a key a run before this one wrote for the same row keeps its since, so it is put again
|
||||||
|
// with the same value (§13).
|
||||||
|
func (co *carryOver) bindings(ctx context.Context) (CarriedTable, error) {
|
||||||
|
rows, err := co.src.Bindings(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return CarriedTable{}, err
|
||||||
|
}
|
||||||
|
kinds := map[string]string{}
|
||||||
|
if all, err := co.s.Licences(ctx); err == nil {
|
||||||
|
for _, l := range all {
|
||||||
|
kinds[l.Name] = l.Kind
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return CarriedTable{}, err
|
||||||
|
}
|
||||||
|
t := CarriedTable{Read: len(rows)}
|
||||||
|
for _, r := range rows {
|
||||||
|
v := BindingState{Licence: r.Licence, Kind: kinds[r.Licence], Generation: r.Generation,
|
||||||
|
Since: co.now.UTC().Format(time.RFC3339), Why: "carried over", By: Itself}
|
||||||
|
var held BindingState
|
||||||
|
ok, err := co.s.get(co.s.BindingsState, r.Consumer, &held)
|
||||||
|
if err != nil {
|
||||||
|
return t, err
|
||||||
|
}
|
||||||
|
if ok && held.Licence == v.Licence && held.Generation == v.Generation && held.Why == v.Why {
|
||||||
|
v.Since = held.Since
|
||||||
|
}
|
||||||
|
if _, err := co.s.BindingsState.Put(r.Consumer, v); err != nil {
|
||||||
|
return t, err
|
||||||
|
}
|
||||||
|
// Said after its key is written, as every binding's change is (§21), so a reader's first view taken during the
|
||||||
|
// carry-over is brought up to date by the event.
|
||||||
|
from := ""
|
||||||
|
if ok && held.Licence != v.Licence {
|
||||||
|
from = held.Licence
|
||||||
|
}
|
||||||
|
co.m.changed(Binding{Consumer: r.Consumer, Licence: v.Licence, Kind: v.Kind, Generation: v.Generation, Why: v.Why, By: v.By}, from)
|
||||||
|
t.Written++
|
||||||
|
}
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// leases writes a key of `leases` per lease still live at the run, with its remaining lifetime, so the manager never
|
||||||
|
// refreshes a licence the bundle before it holds a lease on, whatever the delivery's order; an expired one is counted
|
||||||
|
// read and not written (B44). Its key takes the bus's form: `licence.<licence key>` or `account.<id>` (§4).
|
||||||
|
func (co *carryOver) leases(ctx context.Context) (CarriedTable, error) {
|
||||||
|
rows, err := co.src.Leases(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return CarriedTable{}, err
|
||||||
|
}
|
||||||
|
t := CarriedTable{Read: len(rows)}
|
||||||
|
for _, r := range rows {
|
||||||
|
left := r.Until.Sub(co.now)
|
||||||
|
if left <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key := ""
|
||||||
|
if name, ok := strings.CutPrefix(r.Key, "licence:"); ok {
|
||||||
|
key = "licence." + KeyFor(name)
|
||||||
|
} else if account, ok := strings.CutPrefix(r.Key, "account:"); ok {
|
||||||
|
key = "account." + account
|
||||||
|
} else {
|
||||||
|
co.m.Log("the lease %s names neither a licence nor an account; it is not carried", r.Key)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
v := leaseValue{Holder: r.Holder, Until: r.Until.UTC().Format(time.RFC3339)}
|
||||||
|
if _, err := co.s.LeasesState.PutTTL(key, v, left); err != nil {
|
||||||
|
return t, err
|
||||||
|
}
|
||||||
|
t.Written++
|
||||||
|
}
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// offered writes a key of `offered` per row, its lifetime counted from the row's `at`; a row past its lifetime is
|
||||||
|
// gone already and written nowhere (§5, §12).
|
||||||
|
func (co *carryOver) offered(ctx context.Context) (CarriedTable, error) {
|
||||||
|
rows, err := co.src.Offered(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return CarriedTable{}, err
|
||||||
|
}
|
||||||
|
t := CarriedTable{Read: len(rows)}
|
||||||
|
for _, r := range rows {
|
||||||
|
left := offeredFor - co.now.Sub(r.At)
|
||||||
|
if left <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
v := offeredValue{Node: r.Node, Account: r.Account, Outcome: r.Outcome, Why: r.Why,
|
||||||
|
At: r.At.UTC().Format(time.RFC3339)}
|
||||||
|
if err := co.s.putOffered(r.Fingerprint, v, left); err != nil {
|
||||||
|
return t, err
|
||||||
|
}
|
||||||
|
t.Written++
|
||||||
|
}
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// lastAt is the `at` of a log's last entry for a subject, zero when it has none: rows at or before it are in the log
|
||||||
|
// already (§13).
|
||||||
|
func lastAt(l keptLog, key string) (time.Time, error) {
|
||||||
|
e, err := l.Last(key)
|
||||||
|
if err != nil || e == nil {
|
||||||
|
return time.Time{}, err
|
||||||
|
}
|
||||||
|
var v struct {
|
||||||
|
At string `json:"at"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal(e.Value, &v) != nil {
|
||||||
|
return time.Time{}, nil
|
||||||
|
}
|
||||||
|
at, _ := time.Parse(time.RFC3339Nano, v.At)
|
||||||
|
return at, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// usage appends one entry of the log `usage` per row of the last 35 days, oldest first, its `at` kept (§12).
|
||||||
|
func (co *carryOver) usage(ctx context.Context) (CarriedTable, error) {
|
||||||
|
rows, err := co.src.Usage(ctx, co.now.Add(-usageKeptFor))
|
||||||
|
if err != nil {
|
||||||
|
return CarriedTable{}, err
|
||||||
|
}
|
||||||
|
t := CarriedTable{Read: len(rows)}
|
||||||
|
last := map[string]time.Time{}
|
||||||
|
for _, r := range rows {
|
||||||
|
key := KeyFor(r.Licence)
|
||||||
|
before, seen := last[key]
|
||||||
|
if !seen {
|
||||||
|
if before, err = lastAt(co.s.UsageLog, key); err != nil {
|
||||||
|
return t, err
|
||||||
|
}
|
||||||
|
last[key] = before
|
||||||
|
}
|
||||||
|
at := time.UnixMilli(r.At).UTC()
|
||||||
|
if !at.After(before) {
|
||||||
|
t.Written++ // in the log already, from a run before this one
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, err := co.s.UsageLog.Append(key, UsageEntry{At: at.Format(time.RFC3339Nano), Reading: r.Reading,
|
||||||
|
Raw: r.Raw}); err != nil {
|
||||||
|
return t, err
|
||||||
|
}
|
||||||
|
t.Written++
|
||||||
|
}
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// audits appends one entry of the log `audit` per row, oldest first, its `at` kept, under the subject its `what`
|
||||||
|
// names, `drift` for any other (§7, §12).
|
||||||
|
func (co *carryOver) audits() (CarriedTable, error) {
|
||||||
|
t := CarriedTable{Read: len(co.audit)}
|
||||||
|
last := map[string]time.Time{}
|
||||||
|
for _, r := range co.audit {
|
||||||
|
kind := "drift"
|
||||||
|
for _, k := range AuditKinds {
|
||||||
|
if k == r.What {
|
||||||
|
kind = k
|
||||||
|
}
|
||||||
|
}
|
||||||
|
before, seen := last[kind]
|
||||||
|
if !seen {
|
||||||
|
var err error
|
||||||
|
if before, err = lastAt(co.s.AuditLog, kind); err != nil {
|
||||||
|
return t, err
|
||||||
|
}
|
||||||
|
last[kind] = before
|
||||||
|
}
|
||||||
|
if !r.At.After(before) {
|
||||||
|
t.Written++ // in the log already, from a run before this one
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
entry := map[string]any{}
|
||||||
|
for k, v := range r.Detail {
|
||||||
|
entry[k] = v
|
||||||
|
}
|
||||||
|
entry["at"] = r.At.UTC().Format(time.RFC3339Nano)
|
||||||
|
if kind != r.What {
|
||||||
|
entry["what"] = r.What
|
||||||
|
}
|
||||||
|
if _, err := co.s.AuditLog.Append(kind, entry); err != nil {
|
||||||
|
return t, fmt.Errorf("the audit did not take the carried %s entry: %w", r.What, err)
|
||||||
|
}
|
||||||
|
t.Written++
|
||||||
|
}
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,314 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgconn"
|
||||||
|
)
|
||||||
|
|
||||||
|
// memSource is today's database as the carry-over reads it, from rows given; failAt makes one table's read fail.
|
||||||
|
type memSource struct {
|
||||||
|
licences []LicenceRow
|
||||||
|
bindings []Binding
|
||||||
|
leases []LeaseRow
|
||||||
|
offered []OfferedRow
|
||||||
|
usage []UsageTableRow
|
||||||
|
audit []AuditRow
|
||||||
|
failAt string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *memSource) fail(table string) error {
|
||||||
|
if s.failAt == table {
|
||||||
|
return errors.New("connection reset by peer")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (s *memSource) Licences(context.Context) ([]LicenceRow, error) {
|
||||||
|
return s.licences, s.fail("licence")
|
||||||
|
}
|
||||||
|
func (s *memSource) Bindings(context.Context) ([]Binding, error) {
|
||||||
|
return s.bindings, s.fail("binding")
|
||||||
|
}
|
||||||
|
func (s *memSource) Leases(context.Context) ([]LeaseRow, error) { return s.leases, s.fail("lease") }
|
||||||
|
func (s *memSource) Offered(context.Context) ([]OfferedRow, error) {
|
||||||
|
return s.offered, s.fail("offered")
|
||||||
|
}
|
||||||
|
func (s *memSource) Audit(context.Context) ([]AuditRow, error) { return s.audit, s.fail("audit") }
|
||||||
|
func (s *memSource) Usage(_ context.Context, since time.Time) ([]UsageTableRow, error) {
|
||||||
|
var out []UsageTableRow
|
||||||
|
for _, u := range s.usage {
|
||||||
|
if u.At >= since.UnixMilli() {
|
||||||
|
out = append(out, u)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, s.fail("usage")
|
||||||
|
}
|
||||||
|
|
||||||
|
// todaysRows is a copy of the shape of today's rows: a subscription and an API key, two bindings, a lease, two offers,
|
||||||
|
// three readings (one older than 35 days) and four audit rows (one of a kind the log does not name).
|
||||||
|
func todaysRows(t *testing.T, mm *miniMesh) *memSource {
|
||||||
|
t.Helper()
|
||||||
|
grant, _ := json.Marshal(FullGrant{AccessToken: "at-old", RefreshToken: "rt-old", ExpiresAt: 7})
|
||||||
|
pct := 42.0
|
||||||
|
return &memSource{
|
||||||
|
licences: []LicenceRow{
|
||||||
|
{Licence: Licence{Name: licence1, Kind: "subscription", AccountUUID: "acct-1", Email: licence1,
|
||||||
|
RefreshFingerprint: Fingerprint("rt-old"), AccessExpiresAt: 7, AdoptedAt: 1}, Sealed: sealAtRest(t, mm.m.Crypt, string(grant))},
|
||||||
|
{Licence: Licence{Name: "api", Kind: "api-key", AdoptedAt: 2}, Sealed: sealAtRest(t, mm.m.Crypt, "sk-ant-api03-x")},
|
||||||
|
},
|
||||||
|
bindings: []Binding{{Consumer: "laptop", Licence: licence1, Generation: 1013}, {Consumer: "server", Licence: "api", Generation: 1016}},
|
||||||
|
leases: []LeaseRow{{Key: "licence:x", Holder: "novox:1", Until: t0.Add(-time.Minute)}},
|
||||||
|
offered: []OfferedRow{{Fingerprint: "sha256:aa", Node: "laptop", Account: "acct-1", Outcome: Adopted, Why: "refreshed", At: t0.Add(-24 * time.Hour)},
|
||||||
|
{Fingerprint: "sha256:bb", Node: "server", Outcome: Dead, Why: "400", At: t0.Add(-48 * time.Hour)}},
|
||||||
|
usage: []UsageTableRow{
|
||||||
|
{Licence: licence1, At: t0.Add(-40 * 24 * time.Hour).UnixMilli(), Reading: UsageReading{SessionPct: &pct}},
|
||||||
|
{Licence: licence1, At: t0.Add(-2 * time.Hour).UnixMilli(), Reading: UsageReading{SessionPct: &pct}, Raw: map[string]any{"five_hour": 1.0}},
|
||||||
|
{Licence: licence1, At: t0.Add(-1 * time.Hour).UnixMilli(), Reading: UsageReading{SessionPct: &pct}},
|
||||||
|
},
|
||||||
|
audit: []AuditRow{
|
||||||
|
{At: t0.Add(-72 * time.Hour), What: "adopted", Detail: map[string]any{"licence": licence1, "node": "laptop"}},
|
||||||
|
{At: t0.Add(-71 * time.Hour), What: "bound", Detail: map[string]any{"consumer": "laptop"}},
|
||||||
|
{At: t0.Add(-70 * time.Hour), What: "switched", Detail: map[string]any{"consumer": "server"}},
|
||||||
|
{At: t0.Add(-69 * time.Hour), What: "grant-moved", Detail: map[string]any{"licence": "api"}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func carryFor(mm *miniMesh, carried *memState, src CarrySource) *Carry {
|
||||||
|
return &Carry{State: carried, Source: func(context.Context) (CarrySource, error) { return src, nil }}
|
||||||
|
}
|
||||||
|
|
||||||
|
func readCarried(t *testing.T, carried *memState) CarriedRecord {
|
||||||
|
t.Helper()
|
||||||
|
var rec CarriedRecord
|
||||||
|
e, _ := carried.Get(CarriedKey)
|
||||||
|
if e == nil {
|
||||||
|
t.Fatal("carried.postgres is not written")
|
||||||
|
}
|
||||||
|
_ = json.Unmarshal(e.Value, &rec)
|
||||||
|
return rec
|
||||||
|
}
|
||||||
|
|
||||||
|
// design 39 bus-and-switch §11/AC1, §12/AC1: every row is carried over and the key written; the counts in
|
||||||
|
// carried.postgres equal the rows read for every table but lease, and the vault answers each licence's grant.
|
||||||
|
func TestEveryRowIsCarriedOver(t *testing.T) {
|
||||||
|
mm := newMesh(t)
|
||||||
|
carried := newMemState(func() time.Time { return mm.now })
|
||||||
|
src := todaysRows(t, mm)
|
||||||
|
c := carryFor(mm, carried, src)
|
||||||
|
if _, err := c.Run(context.Background(), mm.m, mm.store); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
rec := readCarried(t, carried)
|
||||||
|
want := map[string]int{"licence": 2, "binding": 2, "lease": 1, "offered": 2, "usage": 2, "audit": 4}
|
||||||
|
for table, n := range want {
|
||||||
|
got := rec.Tables[table]
|
||||||
|
if got.Read != n || (table != "lease" && got.Written != got.Read) {
|
||||||
|
t.Errorf("%s: read %d written %d, want %d read and as many written", table, got.Read, got.Written, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if g, err := mm.grants.Get(context.Background(), KeyFor(licence1), PartGrant); err != nil || !strings.Contains(g, "rt-old") {
|
||||||
|
t.Fatalf("the vault answers the grant %q, %v", g, err)
|
||||||
|
}
|
||||||
|
if a, _ := mm.grants.Get(context.Background(), KeyFor(licence1), PartAccess); strings.Contains(a, "rt-old") || !strings.Contains(a, "at-old") {
|
||||||
|
t.Fatalf("the access secret is %q", a)
|
||||||
|
}
|
||||||
|
if a, _ := mm.grants.Get(context.Background(), KeyFor("api"), PartAccess); a != "sk-ant-api03-x" {
|
||||||
|
t.Fatalf("the API key's access is %q", a)
|
||||||
|
}
|
||||||
|
l, _ := mm.store.Licence(context.Background(), licence1)
|
||||||
|
if l == nil || l.Generation != 1 || l.AdoptedFrom != "laptop" || l.AccountUUID != "acct-1" || l.AccessExpiresAt != 7 {
|
||||||
|
t.Fatalf("the licence is %+v", l)
|
||||||
|
}
|
||||||
|
if b := mm.bound("laptop"); b.Generation != 1013 || b.Why != "carried over" || b.By != Itself || b.Kind != "subscription" || b.Since == "" {
|
||||||
|
t.Fatalf("laptop's binding %+v", b)
|
||||||
|
}
|
||||||
|
if u := mm.offered.until[OfferedKey("sha256:aa")]; !u.Equal(t0.Add(-24 * time.Hour).Add(offeredFor)) {
|
||||||
|
t.Fatalf("the offer lives until %v", u)
|
||||||
|
}
|
||||||
|
if got := mm.audit.under("drift"); len(got) != 1 || got[0]["licence"] != "api" {
|
||||||
|
t.Fatalf("drift holds %v", got)
|
||||||
|
}
|
||||||
|
if got := mm.audit.under("adopted"); len(got) != 1 || got[0]["at"] != t0.Add(-72*time.Hour).Format(time.RFC3339Nano) {
|
||||||
|
t.Fatalf("adopted holds %v", got)
|
||||||
|
}
|
||||||
|
if got := mm.usage.under(KeyFor(licence1)); len(got) != 2 || got[0]["raw"] == nil {
|
||||||
|
t.Fatalf("usage holds %v", got)
|
||||||
|
}
|
||||||
|
if len(mm.audit.under("carried")) != 1 || !c.Ended() {
|
||||||
|
t.Fatal("the carry-over did not say it carried, or did not end")
|
||||||
|
}
|
||||||
|
// §21: each carried binding is said as binding.changed after its key is written.
|
||||||
|
said := map[string]bool{}
|
||||||
|
for _, e := range mm.changedSince(0) {
|
||||||
|
if e["why"] == "carried over" && e["key"] == KeyFor(fmt.Sprint(e["licence"])) {
|
||||||
|
said[fmt.Sprint(e["consumer"])] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !said["laptop"] || !said["server"] || len(mm.changedSince(0)) != 2 {
|
||||||
|
t.Fatalf("the carry-over said %v", mm.changedSince(0))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// design 39 bus-and-switch §13/AC1: a second start, with carried.postgres present, never opens the database and
|
||||||
|
// writes nothing.
|
||||||
|
func TestASecondStartCarriesNothing(t *testing.T) {
|
||||||
|
mm := newMesh(t)
|
||||||
|
carried := newMemState(func() time.Time { return mm.now })
|
||||||
|
if _, err := carryFor(mm, carried, todaysRows(t, mm)).Run(context.Background(), mm.m, mm.store); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
writes := carried.writes + mm.licences.writes + mm.bindings.writes + mm.offered.writes
|
||||||
|
entries := len(mm.audit.entries) + len(mm.usage.entries)
|
||||||
|
puts := len(mm.grants.puts)
|
||||||
|
opened := false
|
||||||
|
again := &Carry{State: carried, Source: func(context.Context) (CarrySource, error) { opened = true; return nil, errors.New("no") }}
|
||||||
|
if rec, err := again.Run(context.Background(), mm.m, mm.store); rec != nil || err != nil || opened {
|
||||||
|
t.Fatalf("a second start answered %v %v, opened the database: %v", rec, err, opened)
|
||||||
|
}
|
||||||
|
if carried.writes+mm.licences.writes+mm.bindings.writes+mm.offered.writes != writes ||
|
||||||
|
len(mm.audit.entries)+len(mm.usage.entries) != entries || len(mm.grants.puts) != puts {
|
||||||
|
t.Fatal("a second start wrote")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// design 39 bus-and-switch §13/AC2: a carry-over stopped after bindings leaves no key, names its table, and a start
|
||||||
|
// again carries over whole: every count equals the rows read, and no log entry is appended twice.
|
||||||
|
func TestAStoppedCarryOverIsDoneAgainWhole(t *testing.T) {
|
||||||
|
mm := newMesh(t)
|
||||||
|
carried := newMemState(func() time.Time { return mm.now })
|
||||||
|
src := todaysRows(t, mm)
|
||||||
|
src.failAt = "offered"
|
||||||
|
c := carryFor(mm, carried, src)
|
||||||
|
if _, err := c.Run(context.Background(), mm.m, mm.store); err == nil || !strings.Contains(c.Failure(), "offered") {
|
||||||
|
t.Fatalf("a stopped carry-over answered %v, its failure %q", err, c.Failure())
|
||||||
|
}
|
||||||
|
if e, _ := carried.Get(CarriedKey); e != nil || c.Ended() {
|
||||||
|
t.Fatal("a stopped carry-over wrote its key")
|
||||||
|
}
|
||||||
|
src.failAt = ""
|
||||||
|
since := mm.bound("laptop").Since
|
||||||
|
mm.now = mm.now.Add(time.Minute)
|
||||||
|
if _, err := c.Run(context.Background(), mm.m, mm.store); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for table, got := range readCarried(t, carried).Tables {
|
||||||
|
if table != "lease" && got.Written != got.Read {
|
||||||
|
t.Errorf("%s: read %d written %d", table, got.Read, got.Written)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if b := mm.bound("laptop"); b.Since != since {
|
||||||
|
t.Fatalf("the binding put again changed its since from %s to %s", since, b.Since)
|
||||||
|
}
|
||||||
|
if n := len(mm.audit.under("")); n != 5 {
|
||||||
|
t.Fatalf("the audit holds %d entries, want the four rows and one carried", n)
|
||||||
|
}
|
||||||
|
// the run stopped before the logs; a second pass over a log already written appends nothing.
|
||||||
|
co := carryOver{m: mm.m, s: mm.store, src: src, now: mm.now, tables: map[string]CarriedTable{}, audit: src.audit}
|
||||||
|
if _, err := co.usage(context.Background()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got, err := co.audits(); err != nil || got.Written != 4 || len(mm.audit.under("")) != 5 || len(mm.usage.under("")) != 2 {
|
||||||
|
t.Fatalf("a log written again appended: %+v %v, %d usage entries", got, err, len(mm.usage.under("")))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// design 39 bus-and-switch §11/AC2: with the database unreachable the carry-over fails naming it, every acting verb
|
||||||
|
// answers "the carry-over has not ended", a read verb is served, and no vendor call is made.
|
||||||
|
func TestAnUnreachableDatabaseActsNotAndCallsNoVendor(t *testing.T) {
|
||||||
|
mm := newMesh(t)
|
||||||
|
carried := newMemState(func() time.Time { return mm.now })
|
||||||
|
mm.m.Carry = &Carry{State: carried, Source: func(context.Context) (CarrySource, error) {
|
||||||
|
return nil, errors.New("dial tcp 192.0.2.1:5432: connect: connection refused")
|
||||||
|
}}
|
||||||
|
if _, err := mm.m.Carry.Run(context.Background(), mm.m, mm.store); err == nil || !strings.Contains(mm.m.Carry.Failure(), "connection refused") {
|
||||||
|
t.Fatalf("the run answered %v, its failure %q", err, mm.m.Carry.Failure())
|
||||||
|
}
|
||||||
|
built = mm.m
|
||||||
|
t.Cleanup(func() { built = nil })
|
||||||
|
served := map[string]stdTool{}
|
||||||
|
for _, tool := range tools() {
|
||||||
|
served[strings.TrimPrefix(tool.Name, Seat+".")] = stdTool(tool.Run)
|
||||||
|
}
|
||||||
|
for _, name := range []string{"bind", "switch", "release", "refresh", "adopt"} {
|
||||||
|
if _, err := served[name](map[string]any{"consumer": "laptop", "licence": licence1, "name": "api", "file": "/none"}); err == nil || err.Error() != NotCarried {
|
||||||
|
t.Errorf("%s answered %v", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, name := range []string{"licences", "bindings", "usage", "public-key"} {
|
||||||
|
if _, err := served[name](map[string]any{}); err != nil {
|
||||||
|
t.Errorf("the read verb %s answered %v", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(mm.vendor.exchanged) != 0 {
|
||||||
|
t.Fatalf("the vendor was called: %v", mm.vendor.exchanged)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type stdTool func(map[string]any) (any, error)
|
||||||
|
|
||||||
|
// design 39 bus-and-switch §11/AC3: a table that does not exist (a fresh install) is read as no rows; any other
|
||||||
|
// failure of the database is one.
|
||||||
|
func TestAMissingTableIsNoRows(t *testing.T) {
|
||||||
|
if !noTable(&pgconn.PgError{Code: "42P01", Message: `relation "offered" does not exist`}) {
|
||||||
|
t.Fatal("a missing table is a failure")
|
||||||
|
}
|
||||||
|
for _, err := range []error{&pgconn.PgError{Code: "28P01"}, errors.New("connection refused"), nil} {
|
||||||
|
if noTable(err) {
|
||||||
|
t.Fatalf("%v is read as a missing table", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// design 39 bus-and-switch §1/AC3: no code of the manager writes to its database.
|
||||||
|
func TestNoWriteToTheDatabase(t *testing.T) {
|
||||||
|
write := regexp.MustCompile(`(?i)\b(insert\s+into|update\s+\w+\s+set|delete\s+from|create\s+(table|sequence|index)|drop\s+(table|sequence|index)|alter\s+table|truncate\s|setval\s*\(|nextval\s*\()`)
|
||||||
|
files, _ := os.ReadDir(".")
|
||||||
|
for _, f := range files {
|
||||||
|
if !strings.HasSuffix(f.Name(), ".go") || strings.HasSuffix(f.Name(), "_test.go") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
raw, err := os.ReadFile(f.Name())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if w := write.Find(raw); w != nil {
|
||||||
|
t.Errorf("%s writes to the database: %q", f.Name(), w)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// design 39 bus-and-switch §12, B44: a lease still live at the run is carried with its remaining lifetime, and a
|
||||||
|
// refresh waits for it to end; an expired one is counted read and not written.
|
||||||
|
func TestALiveLeaseIsCarriedAndBlocksARefreshUntilItEnds(t *testing.T) {
|
||||||
|
mm := newMesh(t)
|
||||||
|
carried := newMemState(func() time.Time { return mm.now })
|
||||||
|
src := todaysRows(t, mm)
|
||||||
|
src.leases = []LeaseRow{{Key: "licence:" + licence1, Holder: "the old bundle", Until: mm.now.Add(90 * time.Second)},
|
||||||
|
{Key: "account:acct-1", Holder: "the old bundle", Until: mm.now.Add(-time.Second)}}
|
||||||
|
if _, err := carryFor(mm, carried, src).Run(context.Background(), mm.m, mm.store); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := readCarried(t, carried).Tables["lease"]; got.Read != 2 || got.Written != 1 {
|
||||||
|
t.Fatalf("lease: read %d written %d, want 2 read and 1 written", got.Read, got.Written)
|
||||||
|
}
|
||||||
|
if e, _ := mm.leases.Get("account.acct-1"); e != nil {
|
||||||
|
t.Fatalf("an expired lease was carried: %s", e.Value)
|
||||||
|
}
|
||||||
|
r, err := mm.m.Rotate(context.Background(), licence1, true)
|
||||||
|
if err != nil || r["refreshed"] != false || r["reason"] != "another run holds its lease" {
|
||||||
|
t.Fatalf("a refresh under the carried lease answered %v, %v", r, err)
|
||||||
|
}
|
||||||
|
mm.now = mm.now.Add(91 * time.Second)
|
||||||
|
if r, err := mm.m.Rotate(context.Background(), licence1, true); err != nil || r["reason"] == "another run holds its lease" {
|
||||||
|
t.Fatalf("once the carried lease ended the refresh answered %v, %v", r, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,328 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// The manager's conditions (design 39 bus-and-switch §9, §11, §29 to §31, §33, B17, B37 to B39): each said on the
|
||||||
|
// operator channel as any module says one (novox/hq ADR 0437 §9), the router alone deciding whether and where the
|
||||||
|
// operator is told, and each it said raised kept in its state `conditions`, written before it is said and deleted when
|
||||||
|
// it is said cleared. A raise or a clear is said again only when what it says changes (ADR 0289 §1); at its start the
|
||||||
|
// manager judges every kind anew, says raised again each it still holds and cleared each key it no longer holds.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
occ "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient"
|
||||||
|
oc "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel"
|
||||||
|
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Publisher is the name the manager says its conditions under, and the id of their subject (B17).
|
||||||
|
const Publisher = "claude-licence-manager"
|
||||||
|
|
||||||
|
// CarryOverFailed is the condition of a carry-over that failed (§11).
|
||||||
|
const CarryOverFailed = "carry-over-failed"
|
||||||
|
|
||||||
|
// HeadlineLength is the operator channel's limit on a headline; a longer one is cut, ending in "…" (B39).
|
||||||
|
const HeadlineLength = 60
|
||||||
|
|
||||||
|
// KeptCondition is the value of a key of `conditions` (§9): the condition said raised, and since when.
|
||||||
|
type KeptCondition struct {
|
||||||
|
Body oc.Condition `json:"body"`
|
||||||
|
Since string `json:"since"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SayCondition publishes a condition on the operator channel's condition accept, under the manager's name, in the form
|
||||||
|
// its client encodes (ADR 0437 §9).
|
||||||
|
func SayCondition(c oc.Condition) error {
|
||||||
|
subject, err := occ.ConditionSubject(Publisher)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
raw, err := occ.EncodeCondition(c)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return stdio.SeatPublish(subject, json.RawMessage(raw), "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// condition is a raised condition of the manager: its key, severity, words and the line said when it is over. An
|
||||||
|
// empty needs is "Nothing for you to do."; the explanation is opened by its verdict here.
|
||||||
|
func condition(key, severity, headline, summary, needs, explanation, resolved string) oc.Condition {
|
||||||
|
verdict := occ.NothingToDo
|
||||||
|
if needs != "" {
|
||||||
|
verdict = occ.NeedsYou + needs + "."
|
||||||
|
}
|
||||||
|
if explanation != "" {
|
||||||
|
verdict += " " + explanation
|
||||||
|
}
|
||||||
|
return oc.Condition{Key: key, Severity: severity, State: occ.StateRaised,
|
||||||
|
Subject: oc.ConditionSubject{Scope: "module", ID: Publisher},
|
||||||
|
Headline: cut(headline), Summary: summary, Needs: needs, Explanation: verdict, Resolved: resolved}
|
||||||
|
}
|
||||||
|
|
||||||
|
// cut keeps a headline within the operator channel's limit, ending a longer one in "…" (B39).
|
||||||
|
func cut(headline string) string {
|
||||||
|
r := []rune(headline)
|
||||||
|
if len(r) <= HeadlineLength {
|
||||||
|
return headline
|
||||||
|
}
|
||||||
|
return string(r[:HeadlineLength-1]) + "…"
|
||||||
|
}
|
||||||
|
|
||||||
|
// short is a licence's short name for a headline: its address before the "@", or its name (B39).
|
||||||
|
func short(name string) string {
|
||||||
|
if at := strings.Index(name, "@"); at > 0 {
|
||||||
|
return name[:at]
|
||||||
|
}
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
|
||||||
|
// machines is a count of consumers in a headline (B39).
|
||||||
|
func machines(n int) string {
|
||||||
|
if n == 1 {
|
||||||
|
return "1 machine"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%d machines", n)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Judge is every condition the manager holds now, judged anew from its own state and readings, by key.
|
||||||
|
func (m *Manager) Judge(ctx context.Context) (map[string]oc.Condition, error) {
|
||||||
|
held := map[string]oc.Condition{}
|
||||||
|
if m.Carry != nil && !m.Carry.Ended() {
|
||||||
|
if why := m.Carry.Failure(); why != "" {
|
||||||
|
held[CarryOverFailed] = condition(CarryOverFailed, occ.SeverityUrgent,
|
||||||
|
"The licence manager could not carry over its licences",
|
||||||
|
"the carry-over of today's database failed: "+why,
|
||||||
|
"the licence manager could not carry over its licences; no consumer is moved and no token is refreshed until it does",
|
||||||
|
"It failed: "+why+".", "The licence manager carried over its licences")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s := m.settings()
|
||||||
|
for _, o := range s.OutOfBound {
|
||||||
|
bound := fmt.Sprintf("at least %g", o.Min)
|
||||||
|
if o.HasMax {
|
||||||
|
bound = fmt.Sprintf("between %g and %g", o.Min, o.Max)
|
||||||
|
}
|
||||||
|
key := "settings." + o.Setting + ".invalid"
|
||||||
|
held[key] = condition(key, occ.SeverityWarning, o.Setting+" is outside its bound",
|
||||||
|
fmt.Sprintf("%s is %g, outside its bound; its default %g is used", o.Setting, o.Value, o.Default),
|
||||||
|
fmt.Sprintf("set %s %s", o.Setting, bound),
|
||||||
|
fmt.Sprintf("%s is set to %g; the licence manager uses its default %g until it is fixed.", o.Setting,
|
||||||
|
o.Value, o.Default), o.Setting+" is within its bound again")
|
||||||
|
}
|
||||||
|
for _, p := range s.Invalid() {
|
||||||
|
key := "settings." + p.Setting + ".invalid"
|
||||||
|
if _, ok := held[key]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
held[key] = condition(key, occ.SeverityWarning, p.Setting+" is above switch_at_pct",
|
||||||
|
fmt.Sprintf("%s is %g, above switch_at_pct %g; no consumer is moved", p.Setting, p.Value, p.SwitchAt),
|
||||||
|
fmt.Sprintf("set %s at or below switch_at_pct (%g)", p.Setting, p.SwitchAt),
|
||||||
|
fmt.Sprintf("%s is %g, above switch_at_pct %g; the licence manager moves no consumer until it is fixed.",
|
||||||
|
p.Setting, p.Value, p.SwitchAt), p.Setting+" is at or below switch_at_pct again")
|
||||||
|
}
|
||||||
|
licences, err := m.Store.Licences(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
bindings, err := m.Store.Bindings(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, l := range licences {
|
||||||
|
for key, c := range m.judgeLicence(l, licences, bindings, s) {
|
||||||
|
held[key] = c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return held, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// judgeLicence is what licence l holds of no-room (§29), near-limit (§30) and usage-unread (§31).
|
||||||
|
func (m *Manager) judgeLicence(l Licence, licences []Licence, bindings []Binding, s Settings) map[string]oc.Condition {
|
||||||
|
held := map[string]oc.Condition{}
|
||||||
|
key := "licence." + KeyFor(l.Name) + "."
|
||||||
|
consumers := consumersOf(l.Name, bindings)
|
||||||
|
list := strings.Join(consumers, ", ")
|
||||||
|
if r := m.RuleFor(l, licences, bindings, s); r.NoTarget {
|
||||||
|
o := r.Over
|
||||||
|
var rooms []string
|
||||||
|
for _, other := range licences {
|
||||||
|
if other.Name != l.Name {
|
||||||
|
rooms = append(rooms, other.Name+" "+m.roomWords(other, s))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(rooms)
|
||||||
|
others := "no other licence is held"
|
||||||
|
if len(rooms) > 0 {
|
||||||
|
others = strings.Join(rooms, "; ")
|
||||||
|
}
|
||||||
|
held[key+"no-room"] = condition(key+"no-room", occ.SeverityUrgent,
|
||||||
|
"No licence has room for "+machines(len(consumers)),
|
||||||
|
fmt.Sprintf("no licence has room for %s, bound to %s at %g %% of its %s window", list, l.Name, o.Pct, o.Window),
|
||||||
|
fmt.Sprintf("add a licence, or wait for %s's %s window to reset at %s", l.Name, o.Window, o.ResetsAt),
|
||||||
|
fmt.Sprintf("%s is at %g %% of its %s window, with %s bound to it; %s.", l.Name, o.Pct, o.Window, list, others),
|
||||||
|
list+" have a licence with room again")
|
||||||
|
}
|
||||||
|
if window, pct, resetsAt, ok := windowAt(l.Usage, s.WarnAt); ok && len(consumers) > 0 {
|
||||||
|
needs, plan := "", ""
|
||||||
|
if to, _ := m.targetFor(l, licences, s); to != "" {
|
||||||
|
plan = fmt.Sprintf("%s move to %s at %g %%. ", list, to, s.SwitchAt)
|
||||||
|
} else {
|
||||||
|
needs = "no other licence has room"
|
||||||
|
}
|
||||||
|
held[key+"near-limit"] = condition(key+"near-limit", occ.SeverityWarning,
|
||||||
|
fmt.Sprintf("%s at %g%% %s", short(l.Name), pct, window),
|
||||||
|
fmt.Sprintf("%s at %g %% of its %s window", l.Name, pct, window), needs,
|
||||||
|
fmt.Sprintf("%s%s is at %g %% of its %s window, which resets at %s; %s bound to it.", plan, l.Name, pct,
|
||||||
|
window, resetsAt, list), fmt.Sprintf("%s is below %g %% again", l.Name, s.WarnAt))
|
||||||
|
}
|
||||||
|
if since, stale := m.unread(l, s); stale {
|
||||||
|
needs := ""
|
||||||
|
if len(consumers) > 0 {
|
||||||
|
needs = list + " are not moved off it while its usage is unknown"
|
||||||
|
}
|
||||||
|
why := l.Usage.readError()
|
||||||
|
held[key+"usage-unread"] = condition(key+"usage-unread", occ.SeverityWarning,
|
||||||
|
short(l.Name)+"'s usage cannot be read",
|
||||||
|
fmt.Sprintf("the usage of %s cannot be read: %s", l.Name, why), needs,
|
||||||
|
fmt.Sprintf("%s, older than %g minutes; the vendor answers: %s.", since, s.UsageStale.Minutes(), why),
|
||||||
|
l.Name+"'s usage is read again")
|
||||||
|
}
|
||||||
|
return held
|
||||||
|
}
|
||||||
|
|
||||||
|
// readError is the vendor's reason a reading failed, or that none was given.
|
||||||
|
func (u *UsageReading) readError() string {
|
||||||
|
if u == nil || u.ReadError == "" {
|
||||||
|
return "no reason given"
|
||||||
|
}
|
||||||
|
return u.ReadError
|
||||||
|
}
|
||||||
|
|
||||||
|
// unread is whether subscription l's latest successful reading is older than usage_stale_minutes (§31), or, never
|
||||||
|
// read successfully, its adoption is (B38); and the words naming since when.
|
||||||
|
func (m *Manager) unread(l Licence, s Settings) (string, bool) {
|
||||||
|
if l.Kind != "subscription" {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
if l.Usage != nil && l.Usage.ReadAt != "" {
|
||||||
|
read, err := time.Parse(time.RFC3339Nano, l.Usage.ReadAt)
|
||||||
|
if err != nil {
|
||||||
|
return "its latest successful reading has no time", true
|
||||||
|
}
|
||||||
|
return "its latest successful reading was at " + l.Usage.ReadAt, m.Now().Sub(read) > s.UsageStale
|
||||||
|
}
|
||||||
|
if l.AdoptedAt == 0 {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
adopted := time.UnixMilli(l.AdoptedAt)
|
||||||
|
return "no reading has succeeded since its adoption at " + adopted.UTC().Format(time.RFC3339),
|
||||||
|
m.Now().Sub(adopted) > s.UsageStale
|
||||||
|
}
|
||||||
|
|
||||||
|
// roomWords is another licence's room for the condition no-room, or why it is not known or not enough (§29).
|
||||||
|
func (m *Manager) roomWords(o Licence, s Settings) string {
|
||||||
|
room, known := Room(o, m.Now(), s)
|
||||||
|
switch {
|
||||||
|
case known && (*o.Usage.SessionPct >= s.RoomBelow || *o.Usage.WeeklyPct >= s.RoomBelow):
|
||||||
|
return fmt.Sprintf("has %g %% room, a window not below %g %%", room, s.RoomBelow)
|
||||||
|
case known:
|
||||||
|
return fmt.Sprintf("has %g %% room", room)
|
||||||
|
case o.Kind != "subscription":
|
||||||
|
return "is an API key, whose room is never known"
|
||||||
|
case o.Usage == nil || o.Usage.ReadAt == "":
|
||||||
|
return "has no reading"
|
||||||
|
case o.Usage.SessionPct == nil || o.Usage.WeeklyPct == nil:
|
||||||
|
return "has no session or weekly window in its reading"
|
||||||
|
case o.Failures >= s.FailuresToNotify || (o.RefreshExpiresAt != 0 && o.RefreshExpiresAt <= m.Now().UnixMilli()):
|
||||||
|
return "does not refresh"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("was not read within %g minutes", s.UsageStale.Minutes())
|
||||||
|
}
|
||||||
|
|
||||||
|
// SayConditions judges every kind anew and says what changed: each condition held whose words differ from those kept
|
||||||
|
// is written in `conditions`, then said raised; each kept key no longer held is said cleared, then deleted. With
|
||||||
|
// again (at the start, and by licences_resync), each held is said raised again whether or not it changed.
|
||||||
|
func (m *Manager) SayConditions(ctx context.Context, again bool) error {
|
||||||
|
if m.Conditions == nil || m.Say == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
m.conditionsMu.Lock()
|
||||||
|
defer m.conditionsMu.Unlock()
|
||||||
|
held, err := m.Judge(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
keys, err := m.Conditions.Keys()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var errs []error
|
||||||
|
for _, key := range keys {
|
||||||
|
if _, ok := held[key]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var kept KeptCondition
|
||||||
|
e, err := m.Conditions.Get(key)
|
||||||
|
if err != nil {
|
||||||
|
errs = append(errs, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if e != nil && len(e.Value) > 0 {
|
||||||
|
_ = json.Unmarshal(e.Value, &kept)
|
||||||
|
}
|
||||||
|
cleared := kept.Body
|
||||||
|
cleared.Key, cleared.State = key, occ.StateCleared
|
||||||
|
cleared.Subject = oc.ConditionSubject{Scope: "module", ID: Publisher}
|
||||||
|
if err := m.Say(cleared); err != nil {
|
||||||
|
errs = append(errs, fmt.Errorf("the clearing of %s could not be said: %w", key, err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := m.Conditions.Delete(key); err != nil {
|
||||||
|
errs = append(errs, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var order []string
|
||||||
|
for key := range held {
|
||||||
|
order = append(order, key)
|
||||||
|
}
|
||||||
|
sort.Strings(order)
|
||||||
|
for _, key := range order {
|
||||||
|
c := held[key]
|
||||||
|
kept := KeptCondition{Body: c, Since: m.Now().UTC().Format(time.RFC3339)}
|
||||||
|
if e, err := m.Conditions.Get(key); err == nil && e != nil && len(e.Value) > 0 {
|
||||||
|
var was KeptCondition
|
||||||
|
if json.Unmarshal(e.Value, &was) == nil {
|
||||||
|
if !again && sameCondition(was.Body, c) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if was.Since != "" {
|
||||||
|
kept.Since = was.Since
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := m.Conditions.Put(key, kept); err != nil {
|
||||||
|
errs = append(errs, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := m.Say(c); err != nil {
|
||||||
|
// Not said: the key goes again, so the next judgement says it.
|
||||||
|
_ = m.Conditions.Delete(key)
|
||||||
|
errs = append(errs, fmt.Errorf("%s could not be said: %w", key, err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(errs) > 0 {
|
||||||
|
return fmt.Errorf("conditions: %v", errs)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// sameCondition is whether two raised conditions say the same.
|
||||||
|
func sameCondition(a, b oc.Condition) bool {
|
||||||
|
ra, _ := json.Marshal(a)
|
||||||
|
rb, _ := json.Marshal(b)
|
||||||
|
return string(ra) == string(rb)
|
||||||
|
}
|
||||||
@@ -0,0 +1,252 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
occ "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient"
|
||||||
|
oc "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel"
|
||||||
|
)
|
||||||
|
|
||||||
|
// saying gives mm's manager a state `conditions` and records each condition it says, in the form the operator
|
||||||
|
// channel's client encodes, refusing what it refuses.
|
||||||
|
func saying(mm *miniMesh) (*memState, *[]oc.Condition) {
|
||||||
|
kept := newMemState(func() time.Time { return mm.now })
|
||||||
|
said := &[]oc.Condition{}
|
||||||
|
mm.m.Conditions = kept
|
||||||
|
mm.m.Say = func(c oc.Condition) error {
|
||||||
|
if _, err := occ.EncodeCondition(c); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := occ.ConditionSubject(Publisher); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
*said = append(*said, c)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return kept, said
|
||||||
|
}
|
||||||
|
|
||||||
|
// of is what was said of key, as "state" per saying.
|
||||||
|
func of(said []oc.Condition, key string) []string {
|
||||||
|
var out []string
|
||||||
|
for _, c := range said {
|
||||||
|
if c.Key == key {
|
||||||
|
out = append(out, c.State)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// design 39 bus-and-switch §9/AC1, §11, §33, B17, B37: each kind of condition the manager holds of itself is written in
|
||||||
|
// `conditions` with its body and since, said once with its key, severity, scope and id, its words opened by their
|
||||||
|
// verdict, and said cleared, its key deleted, once it no longer holds.
|
||||||
|
func TestAConditionIsKeptThenSaidOnce(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
file := filepath.Join(t.TempDir(), "settings.json")
|
||||||
|
for _, c := range []struct {
|
||||||
|
name, key, severity string
|
||||||
|
raise, fix func(mm *miniMesh)
|
||||||
|
}{
|
||||||
|
{"an invalid pair", "settings.room_below_pct.invalid", occ.SeverityWarning,
|
||||||
|
func(mm *miniMesh) { mm.m.Settings.RoomBelow = 100 }, func(mm *miniMesh) { mm.m.Settings.RoomBelow = 90 }},
|
||||||
|
{"a setting outside its bound", "settings.switch_at_pct.invalid", occ.SeverityWarning,
|
||||||
|
func(mm *miniMesh) {
|
||||||
|
_ = os.WriteFile(file, []byte(`{"switch_at_pct": 150}`), 0o600)
|
||||||
|
mm.m.Settings = SettingsFrom(file)
|
||||||
|
},
|
||||||
|
func(mm *miniMesh) {
|
||||||
|
_ = os.WriteFile(file, []byte(`{"switch_at_pct": 98}`), 0o600)
|
||||||
|
mm.m.Settings = SettingsFrom(file)
|
||||||
|
}},
|
||||||
|
{"a carry-over that failed", CarryOverFailed, occ.SeverityUrgent,
|
||||||
|
func(mm *miniMesh) {
|
||||||
|
mm.m.Carry = &Carry{State: newMemState(func() time.Time { return mm.now }),
|
||||||
|
Source: func(context.Context) (CarrySource, error) { return nil, errors.New("connection refused") }}
|
||||||
|
_, _ = mm.m.Carry.Run(ctx, mm.m, mm.store)
|
||||||
|
},
|
||||||
|
func(mm *miniMesh) {
|
||||||
|
mm.m.Carry.Source = func(context.Context) (CarrySource, error) { return &memSource{}, nil }
|
||||||
|
_, _ = mm.m.Carry.Run(ctx, mm.m, mm.store)
|
||||||
|
}},
|
||||||
|
} {
|
||||||
|
mm := newMesh(t)
|
||||||
|
kept, said := saying(mm)
|
||||||
|
c.raise(mm)
|
||||||
|
for range 2 {
|
||||||
|
if err := mm.m.SayConditions(ctx, false); err != nil {
|
||||||
|
t.Fatalf("%s: %v", c.name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got := of(*said, c.key); strings.Join(got, ",") != "raised" {
|
||||||
|
t.Fatalf("%s: said %v of %s, all said %+v", c.name, got, c.key, *said)
|
||||||
|
}
|
||||||
|
s := (*said)[0]
|
||||||
|
if s.Severity != c.severity || s.Subject.Scope != "module" || s.Subject.ID != Publisher || s.Headline == "" ||
|
||||||
|
s.Summary == "" || s.Resolved == "" || !strings.HasPrefix(s.Explanation, occ.NeedsYou+s.Needs) || s.Needs == "" {
|
||||||
|
t.Fatalf("%s: said %+v", c.name, s)
|
||||||
|
}
|
||||||
|
var k KeptCondition
|
||||||
|
if e, _ := kept.Get(c.key); e == nil || json.Unmarshal(e.Value, &k) != nil || k.Body.Key != c.key || k.Since == "" {
|
||||||
|
t.Fatalf("%s: kept %+v", c.name, k)
|
||||||
|
}
|
||||||
|
c.fix(mm)
|
||||||
|
_ = mm.m.SayConditions(ctx, false)
|
||||||
|
if got := of(*said, c.key); strings.Join(got, ",") != "raised,cleared" {
|
||||||
|
t.Fatalf("%s: said %v", c.name, got)
|
||||||
|
}
|
||||||
|
if e, _ := kept.Get(c.key); e != nil {
|
||||||
|
t.Fatalf("%s: the key is kept after its clearing", c.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// design 39 bus-and-switch §9/AC2: at its start the manager says cleared each key of `conditions` it no longer holds,
|
||||||
|
// and says raised again each it still holds, unchanged or not.
|
||||||
|
func TestAStartClearsWhatItNoLongerHolds(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
mm := newMesh(t)
|
||||||
|
kept, said := saying(mm)
|
||||||
|
gone := condition("licence.gone.no-room", occ.SeverityUrgent, "No licence has room for 1 machine", "s", "add one",
|
||||||
|
"", "x has a licence with room again")
|
||||||
|
mm.m.Settings.WarnAt = 100
|
||||||
|
_ = mm.m.SayConditions(ctx, false)
|
||||||
|
_, _ = kept.Put(gone.Key, KeptCondition{Body: gone, Since: "2026-11-11T20:00:00Z"})
|
||||||
|
*said = nil
|
||||||
|
if err := mm.m.SayConditions(ctx, true); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if strings.Join(of(*said, gone.Key), ",") != "cleared" || strings.Join(of(*said, "settings.warn_at_pct.invalid"), ",") != "raised" {
|
||||||
|
t.Fatalf("a start said %+v", *said)
|
||||||
|
}
|
||||||
|
if (*said)[0].Resolved != gone.Resolved {
|
||||||
|
t.Fatalf("the clearing carries %+v", (*said)[0])
|
||||||
|
}
|
||||||
|
if e, _ := kept.Get(gone.Key); e != nil {
|
||||||
|
t.Fatal("a key no longer held is kept")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// design 39 bus-and-switch §29/AC1, B39: with every licence at its threshold, no-room is said once, its headline a
|
||||||
|
// count of machines, its words naming the licence, the window's reset and every other licence's room; a reading with
|
||||||
|
// room clears it.
|
||||||
|
func TestNoRoomIsSaidOnceAndClearedByRoom(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
mm := fourOnA(t)
|
||||||
|
_, said := saying(mm)
|
||||||
|
_ = mm.store.SaveLicence(ctx, sub("b", read(95, 20)))
|
||||||
|
_ = mm.store.SaveLicence(ctx, sub("c", read(40, 99)))
|
||||||
|
for range 2 {
|
||||||
|
_ = mm.m.SayConditions(ctx, false)
|
||||||
|
}
|
||||||
|
key := "licence.a.no-room"
|
||||||
|
if strings.Join(of(*said, key), ",") != "raised" {
|
||||||
|
t.Fatalf("said %+v", *said)
|
||||||
|
}
|
||||||
|
var c oc.Condition
|
||||||
|
for _, s := range *said {
|
||||||
|
if s.Key == key {
|
||||||
|
c = s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if c.Severity != occ.SeverityUrgent || c.Headline != "No licence has room for 4 machines" ||
|
||||||
|
c.Needs != "add a licence, or wait for a's weekly window to reset at 2026-11-14T09:00:00Z" ||
|
||||||
|
!strings.Contains(c.Explanation, "a is at 99 % of its weekly window, with ace, g14, laptop, server bound to it") ||
|
||||||
|
!strings.Contains(c.Explanation, "b has 5 % room, a window not below 90 %") ||
|
||||||
|
c.Resolved != "ace, g14, laptop, server have a licence with room again" {
|
||||||
|
t.Fatalf("no-room %+v", c)
|
||||||
|
}
|
||||||
|
_ = mm.store.SaveLicence(ctx, sub("b", read(10, 20)))
|
||||||
|
_ = mm.m.SayConditions(ctx, false)
|
||||||
|
if strings.Join(of(*said, key), ",") != "raised,cleared" {
|
||||||
|
t.Fatalf("said %v after a reading with room", of(*said, key))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// design 39 bus-and-switch §30/AC1, B39: a rising series of readings says near-limit once at the crossing and again only
|
||||||
|
// when its words change; with no other licence having room it needs the operator; a reading after the reset clears it.
|
||||||
|
func TestNearLimitIsSaidOncePerCrossing(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
mm := fourOnA(t)
|
||||||
|
_, said := saying(mm)
|
||||||
|
key := "licence.a.near-limit"
|
||||||
|
var last oc.Condition
|
||||||
|
for _, w := range []float64{80, 89, 91, 91, 95} {
|
||||||
|
_ = mm.store.SaveLicence(ctx, sub("a", read(10, w)))
|
||||||
|
_ = mm.m.SayConditions(ctx, false)
|
||||||
|
for _, s := range *said {
|
||||||
|
if s.Key == key {
|
||||||
|
last = s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Join(of(*said, key), ",") != "raised,raised" || last.Headline != "a at 95% weekly" || last.Needs != "" ||
|
||||||
|
!strings.HasPrefix(last.Explanation, occ.NothingToDo+" ace, g14, laptop, server move to b at 99 %.") ||
|
||||||
|
!strings.Contains(last.Explanation, "which resets at 2026-11-14T09:00:00Z") {
|
||||||
|
t.Fatalf("said %v, last %+v", of(*said, key), last)
|
||||||
|
}
|
||||||
|
_ = mm.store.SaveLicence(ctx, sub("b", read(95, 0)))
|
||||||
|
_ = mm.store.SaveLicence(ctx, sub("c", read(95, 0)))
|
||||||
|
_ = mm.m.SayConditions(ctx, false)
|
||||||
|
if c := (*said)[len(*said)-1]; c.Key != key || c.Needs != "no other licence has room" {
|
||||||
|
t.Fatalf("with no target said %+v", c)
|
||||||
|
}
|
||||||
|
_ = mm.store.SaveLicence(ctx, sub("a", read(3, 1)))
|
||||||
|
_ = mm.m.SayConditions(ctx, false)
|
||||||
|
if got := of(*said, key); got[len(got)-1] != "cleared" || len(got) != 4 {
|
||||||
|
t.Fatalf("after the reset said %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// design 39 bus-and-switch §31/AC1, B34, B38: three failed readings in a row, the latest success then older than
|
||||||
|
// usage_stale_minutes, say usage-unread naming the vendor's reason, needing the operator for the consumers bound to it;
|
||||||
|
// one success clears it. A subscription never read successfully is said once its adoption is that old.
|
||||||
|
func TestUsageUnreadIsSaidAndCleared(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
mm := newMesh(t)
|
||||||
|
_, said := saying(mm)
|
||||||
|
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0)})
|
||||||
|
if err := mm.m.ReadUsage(ctx); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
key := "licence." + KeyFor(licence1) + ".usage-unread"
|
||||||
|
mm.vendor.usageErr = errors.New("429 Too Many Requests: rate limited")
|
||||||
|
for i := 1; i <= 3; i++ {
|
||||||
|
mm.now = t0.Add(time.Duration(i) * 6 * time.Minute)
|
||||||
|
if err := mm.m.ReadUsage(ctx); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := len(of(*said, key)); (i < 3 && got != 0) || (i == 3 && got != 1) {
|
||||||
|
t.Fatalf("after %d failed readings said %v", i, of(*said, key))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c := (*said)[len(*said)-1]
|
||||||
|
if c.Severity != occ.SeverityWarning || c.Needs != "laptop are not moved off it while its usage is unknown" ||
|
||||||
|
!strings.Contains(c.Explanation, "the vendor answers: 429 Too Many Requests: rate limited") {
|
||||||
|
t.Fatalf("usage-unread %+v", c)
|
||||||
|
}
|
||||||
|
mm.vendor.usageErr = nil
|
||||||
|
_ = mm.m.ReadUsage(ctx)
|
||||||
|
if strings.Join(of(*said, key), ",") != "raised,cleared" {
|
||||||
|
t.Fatalf("after a success said %v", of(*said, key))
|
||||||
|
}
|
||||||
|
// B38: never read successfully since its adoption.
|
||||||
|
never := Licence{Name: "new@example.org", Kind: "subscription", AdoptedAt: mm.now.UnixMilli()}
|
||||||
|
_ = mm.store.SaveLicence(ctx, never)
|
||||||
|
_ = mm.m.SayConditions(ctx, false)
|
||||||
|
newKey := "licence." + KeyFor(never.Name) + ".usage-unread"
|
||||||
|
if len(of(*said, newKey)) != 0 {
|
||||||
|
t.Fatal("said at its adoption")
|
||||||
|
}
|
||||||
|
mm.now = mm.now.Add(16 * time.Minute)
|
||||||
|
_ = mm.m.SayConditions(ctx, false)
|
||||||
|
if strings.Join(of(*said, newKey), ",") != "raised" {
|
||||||
|
t.Fatalf("said %v of a licence never read", of(*said, newKey))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,7 +5,6 @@ package main
|
|||||||
// key derived from the vault's secret by SHA-256 so a secret of any length serves.
|
// key derived from the vault's secret by SHA-256 so a secret of any length serves.
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/rand"
|
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"errors"
|
"errors"
|
||||||
@@ -13,7 +12,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Crypt seals and opens what the store keeps.
|
// Crypt opens what today's database keeps at rest.
|
||||||
type Crypt struct{ key []byte }
|
type Crypt struct{ key []byte }
|
||||||
|
|
||||||
// NewCrypt is the store's cipher from the vault's secret.
|
// NewCrypt is the store's cipher from the vault's secret.
|
||||||
@@ -35,16 +34,8 @@ func CryptFromFile(path string) (*Crypt, error) {
|
|||||||
return NewCrypt(string(raw))
|
return NewCrypt(string(raw))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Seal encrypts a plaintext as `v1.<iv>.<ciphertext and tag>`.
|
// Open decrypts a grant today's database keeps at rest, `v1.<iv>.<ciphertext and tag>`: the carry-over's alone, since
|
||||||
func (c *Crypt) Seal(plaintext string) string {
|
// nothing seals a grant at rest any more (design 39 bus-and-switch §20, B15).
|
||||||
gcm, _ := newGCM(c.key)
|
|
||||||
iv := make([]byte, 12)
|
|
||||||
_, _ = rand.Read(iv)
|
|
||||||
b64 := base64.StdEncoding.EncodeToString
|
|
||||||
return "v1." + b64(iv) + "." + b64(gcm.Seal(nil, iv, []byte(plaintext), nil))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Open decrypts what Seal made with the same key.
|
|
||||||
func (c *Crypt) Open(sealed string) (string, error) {
|
func (c *Crypt) Open(sealed string) (string, error) {
|
||||||
parts := strings.Split(sealed, ".")
|
parts := strings.Split(sealed, ".")
|
||||||
if len(parts) != 3 || parts[0] != "v1" {
|
if len(parts) != 3 || parts[0] != "v1" {
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// What every node holds, heard as events (design 39 bus-and-switch §23, §46, B12): the agent module says
|
||||||
|
// `holdings-changed` with its holdings report whole, at its start, on every change and when asked; the manager says
|
||||||
|
// `holdings.asked` at every start, after its carry-over, and on its resync, and takes each report it hears as that
|
||||||
|
// node's latest.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// HoldingsChanged is the agent module's event saying what one node holds, which the manager consumes.
|
||||||
|
const HoldingsChanged = "claude-code.holdings-changed"
|
||||||
|
|
||||||
|
// HoldingsAsked is the manager's event asking every node to say what it holds again.
|
||||||
|
const HoldingsAsked = "holdings.asked"
|
||||||
|
|
||||||
|
// Heard is what every node last said it holds, by node.
|
||||||
|
type Heard struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
reports map[string]Holdings
|
||||||
|
}
|
||||||
|
|
||||||
|
// Take keeps one `holdings-changed` as its node's latest report.
|
||||||
|
func (h *Heard) Take(body []byte) (Holdings, error) {
|
||||||
|
var r Holdings
|
||||||
|
if err := json.Unmarshal(body, &r); err != nil {
|
||||||
|
return r, fmt.Errorf("%s is not a holdings report: %w", HoldingsChanged, err)
|
||||||
|
}
|
||||||
|
if r.Node == "" {
|
||||||
|
return r, fmt.Errorf("%s names no node", HoldingsChanged)
|
||||||
|
}
|
||||||
|
h.mu.Lock()
|
||||||
|
defer h.mu.Unlock()
|
||||||
|
if h.reports == nil {
|
||||||
|
h.reports = map[string]Holdings{}
|
||||||
|
}
|
||||||
|
h.reports[r.Node] = r
|
||||||
|
return r, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// All is every node's latest report, by node's name.
|
||||||
|
func (h *Heard) All() []Holdings {
|
||||||
|
h.mu.Lock()
|
||||||
|
all := make([]Holdings, 0, len(h.reports))
|
||||||
|
for _, r := range h.reports {
|
||||||
|
all = append(all, r)
|
||||||
|
}
|
||||||
|
h.mu.Unlock()
|
||||||
|
sort.Slice(all, func(i, j int) bool { return all[i].Node < all[j].Node })
|
||||||
|
return all
|
||||||
|
}
|
||||||
|
|
||||||
|
// Of is the generation node last reported applying.
|
||||||
|
func (h *Heard) Of(node string) int64 {
|
||||||
|
h.mu.Lock()
|
||||||
|
defer h.mu.Unlock()
|
||||||
|
return h.reports[node].Generation
|
||||||
|
}
|
||||||
|
|
||||||
|
// AskHoldings says `holdings.asked`, its time and who asks (§23).
|
||||||
|
func (m *Manager) AskHoldings(by string) error {
|
||||||
|
return m.Emit(HoldingsAsked, map[string]any{"at": m.Now().UTC().Format(time.RFC3339Nano), "by": by})
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// A licence's key form (design 39 bus-and-switch §10, B6): the name every verb and tool takes and answers, written
|
||||||
|
// in the characters a key of the bus takes. The key is the store's alone.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// KeyFor is a licence's key: its name in lower case, each `@` written as `_at_`, and every other character
|
||||||
|
// outside letters, digits, `-` and `.` written as `_`.
|
||||||
|
func KeyFor(name string) string {
|
||||||
|
var b strings.Builder
|
||||||
|
for _, r := range strings.ToLower(name) {
|
||||||
|
switch {
|
||||||
|
case r == '@':
|
||||||
|
b.WriteString("_at_")
|
||||||
|
case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '-', r == '.':
|
||||||
|
b.WriteRune(r)
|
||||||
|
default:
|
||||||
|
b.WriteByte('_')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// keyTaken names the licence other than name whose key is name's, or "" when none is.
|
||||||
|
func (m *Manager) keyTaken(ctx context.Context, name string) (string, error) {
|
||||||
|
all, err := m.Store.Licences(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
key := KeyFor(name)
|
||||||
|
for _, l := range all {
|
||||||
|
if l.Name != name && KeyFor(l.Name) == key {
|
||||||
|
return l.Name, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// design 39 bus-and-switch §10, B6.
|
||||||
|
func TestALicencesKeyForm(t *testing.T) {
|
||||||
|
for name, key := range map[string]string{
|
||||||
|
"jane@example.org": "jane_at_example.org",
|
||||||
|
"Jane.Doe@Example.ORG": "jane.doe_at_example.org",
|
||||||
|
"0c1d2e3f-4a5b-6c7d-8e9f-a0b1c2d3e4f5": "0c1d2e3f-4a5b-6c7d-8e9f-a0b1c2d3e4f5",
|
||||||
|
"team key_1": "team_key_1",
|
||||||
|
"a+b/c:d": "a_b_c_d",
|
||||||
|
"ops-key": "ops-key",
|
||||||
|
} {
|
||||||
|
if got := KeyFor(name); got != key {
|
||||||
|
t.Errorf("KeyFor(%q) = %q, want %q", name, got, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// design 39 bus-and-switch §10: an adoption whose key another licence has is refused, in the audit and as an event.
|
||||||
|
func TestAnAdoptionWhoseKeyIsTakenIsRefused(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
t.Run("an API key", func(t *testing.T) {
|
||||||
|
mm := newMesh(t)
|
||||||
|
_ = mm.store.SaveLicence(ctx, Licence{Name: "Ops-Key", Kind: "api-key"})
|
||||||
|
_, err := mm.m.adoptKey(ctx, "ops-key", "sk-ant-"+strings.Repeat("a", 40), "a test")
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "Ops-Key") {
|
||||||
|
t.Fatalf("adopted a key whose key form is taken: %v", err)
|
||||||
|
}
|
||||||
|
if l, _ := mm.store.Licence(ctx, "ops-key"); l != nil {
|
||||||
|
t.Fatal("the clashing licence was kept")
|
||||||
|
}
|
||||||
|
assertRefused(t, mm)
|
||||||
|
})
|
||||||
|
t.Run("a login", func(t *testing.T) {
|
||||||
|
mm := newMesh(t)
|
||||||
|
_ = mm.store.SaveLicence(ctx, Licence{Name: "ACCT-1@example.org", Kind: "api-key"})
|
||||||
|
r := mm.login("laptop", "rt-laptop", true, t0.Add(-time.Minute))
|
||||||
|
adopted, err := mm.m.Consider(ctx, []Holdings{r})
|
||||||
|
if err != nil || len(adopted) != 0 {
|
||||||
|
t.Fatalf("adopted %v (%v), want none", adopted, err)
|
||||||
|
}
|
||||||
|
if len(mm.vendor.exchanged) != 0 {
|
||||||
|
t.Fatalf("a refused login was exchanged: %v", mm.vendor.exchanged)
|
||||||
|
}
|
||||||
|
if o, _ := mm.store.Outcome(ctx, Fingerprint("rt-laptop")); o != Refused {
|
||||||
|
t.Fatalf("outcome %q, want refused", o)
|
||||||
|
}
|
||||||
|
assertRefused(t, mm)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertRefused(t *testing.T, mm *miniMesh) {
|
||||||
|
t.Helper()
|
||||||
|
said := false
|
||||||
|
for _, e := range mm.events {
|
||||||
|
said = said || strings.HasPrefix(e, "licence.refused ")
|
||||||
|
}
|
||||||
|
audited := false
|
||||||
|
for _, a := range mm.audit.under("") {
|
||||||
|
audited = audited || a["what"] == "refused"
|
||||||
|
}
|
||||||
|
if !said || !audited {
|
||||||
|
t.Fatalf("refusal said %v, audited %v: %v", said, audited, mm.events)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,9 +3,6 @@
|
|||||||
// `anthropic-licence-manager` seat's verbs and, beside them, runs long: it watches what every node reports
|
// `anthropic-licence-manager` seat's verbs and, beside them, runs long: it watches what every node reports
|
||||||
// holding and adopts a login it does not hold, keeps every grant alive under a lease, and reads usage.
|
// holding and adopts a login it does not hold, keeps every grant alive under a lease, and reads usage.
|
||||||
//
|
//
|
||||||
// `claude-licence-manager prepare` is the preparation step (ADR 0135): the host runs it once before the
|
|
||||||
// version that needs it, with the module's words and no bus, and it brings the store's schema to shape.
|
|
||||||
//
|
|
||||||
// stdout is the MCP channel; everything this module says, it says on stderr.
|
// stdout is the MCP channel; everything this module says, it says on stderr.
|
||||||
package main
|
package main
|
||||||
|
|
||||||
@@ -16,7 +13,6 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -32,14 +28,6 @@ func say(format string, args ...any) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
if len(os.Args) > 1 && os.Args[1] == "prepare" {
|
|
||||||
if err := prepare(); err != nil {
|
|
||||||
say("preparing the store failed: %v", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
say("the store's schema is what this version needs")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
go daemon()
|
go daemon()
|
||||||
if err := stdio.Serve("", tools()); err != nil {
|
if err := stdio.Serve("", tools()); err != nil {
|
||||||
say("%v", err)
|
say("%v", err)
|
||||||
@@ -47,17 +35,6 @@ func main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func prepare() error {
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
|
|
||||||
defer cancel()
|
|
||||||
store, err := PgStoreFromEnv(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer store.Close()
|
|
||||||
return store.Migrate(ctx)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- what the binary is handed -----------------------------------------------------------------------
|
// ---- what the binary is handed -----------------------------------------------------------------------
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -85,19 +62,21 @@ func manager() (*Manager, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
store, err := PgStoreFromEnv(context.Background())
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
node, _ := os.Hostname()
|
node, _ := os.Hostname()
|
||||||
if n := os.Getenv("MESH_NODE"); n != "" {
|
if n := os.Getenv("MESH_NODE"); n != "" {
|
||||||
node = n
|
node = n
|
||||||
}
|
}
|
||||||
bindings := stdio.State("bindings")
|
|
||||||
built = &Manager{
|
built = &Manager{
|
||||||
Store: store,
|
Store: &BusStore{LicencesState: stdio.State("licences"), BindingsState: stdio.State("bindings"),
|
||||||
|
LeasesState: stdio.State("leases"), OfferedState: stdio.State("offered"), UsageLog: stdio.Log("usage"),
|
||||||
|
AuditLog: stdio.Log("audit"), Now: time.Now},
|
||||||
Vendor: LiveVendor(),
|
Vendor: LiveVendor(),
|
||||||
Crypt: crypt,
|
Crypt: crypt,
|
||||||
|
// Today's database is the carry-over's source alone, read once and never written (§11, A3).
|
||||||
|
Carry: &Carry{State: stdio.State("carried"), Source: func(ctx context.Context) (CarrySource, error) {
|
||||||
|
return PgSourceFromEnv(ctx)
|
||||||
|
}},
|
||||||
|
Grants: VaultGrants{Keys: keys},
|
||||||
Keys: keys,
|
Keys: keys,
|
||||||
AskGrant: func(_ context.Context, node, publicKey string) (GrantAnswer, error) {
|
AskGrant: func(_ context.Context, node, publicKey string) (GrantAnswer, error) {
|
||||||
var a GrantAnswer
|
var a GrantAnswer
|
||||||
@@ -107,20 +86,48 @@ func manager() (*Manager, error) {
|
|||||||
}
|
}
|
||||||
return a, json.Unmarshal(raw, &a)
|
return a, json.Unmarshal(raw, &a)
|
||||||
},
|
},
|
||||||
PutBinding: func(_ context.Context, consumer string, b BindingState) error {
|
Reported: heard.Of,
|
||||||
_, err := bindings.Put(consumer, b)
|
Emit: func(event string, body map[string]any) error { return stdio.Emit(event, body) },
|
||||||
|
Tell: func(_ context.Context, headline string, lines []string) error {
|
||||||
|
_, err := stdio.Ask("seat:operator-channel.tell", map[string]any{"subject": Seat, "headline": headline,
|
||||||
|
"lines": lines})
|
||||||
return err
|
return err
|
||||||
},
|
},
|
||||||
DeleteBinding: func(_ context.Context, consumer string) error { return bindings.Delete(consumer) },
|
Now: time.Now,
|
||||||
Emit: func(event string, body map[string]any) error { return stdio.Emit(event, body) },
|
Log: say,
|
||||||
Now: time.Now,
|
Holder: fmt.Sprintf("%s:%d", node, os.Getpid()),
|
||||||
Log: say,
|
Settings: SettingsFrom(os.Getenv("MESH_LICENCE_SETTINGS")),
|
||||||
Holder: fmt.Sprintf("%s:%d", node, os.Getpid()),
|
SettingsFile: os.Getenv("MESH_LICENCE_SETTINGS"),
|
||||||
Settings: SettingsFrom(os.Getenv("MESH_LICENCE_SETTINGS")),
|
Conditions: stdio.State("conditions"),
|
||||||
|
Say: SayCondition,
|
||||||
|
ConfigLevels: func(context.Context) (map[string]string, error) { return configLevels(node) },
|
||||||
}
|
}
|
||||||
return built, nil
|
return built, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// configLevels asks mesh-config where each of the manager's own settings was set on its node (§40, B18): the source
|
||||||
|
// of each resolved value, by its key.
|
||||||
|
func configLevels(node string) (map[string]string, error) {
|
||||||
|
raw, err := stdio.Ask("seat:mesh-config.config", map[string]any{"action": "list", "module": Publisher, "node": node})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var answer struct {
|
||||||
|
Resolved []struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Source string `json:"source"`
|
||||||
|
} `json:"resolved"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &answer); err != nil {
|
||||||
|
return nil, fmt.Errorf("mesh-config's answer: %w", err)
|
||||||
|
}
|
||||||
|
levels := map[string]string{}
|
||||||
|
for _, r := range answer.Resolved {
|
||||||
|
levels[r.Key] = r.Source
|
||||||
|
}
|
||||||
|
return levels, nil
|
||||||
|
}
|
||||||
|
|
||||||
// keypair is this module's own, made once in its state directory; the private half never leaves it.
|
// keypair is this module's own, made once in its state directory; the private half never leaves it.
|
||||||
// Written whole, then linked into place, so a second process reads the first's key and never half of it.
|
// Written whole, then linked into place, so a second process reads the first's key and never half of it.
|
||||||
func keypair(dir string) (KeyPair, error) {
|
func keypair(dir string) (KeyPair, error) {
|
||||||
@@ -148,9 +155,33 @@ func keypair(dir string) (KeyPair, error) {
|
|||||||
|
|
||||||
// ---- the long-running half ---------------------------------------------------------------------------
|
// ---- the long-running half ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// heard is what every node last said it holds (ADR 0206, design 39 bus-and-switch §23), by node.
|
||||||
|
var heard = &Heard{}
|
||||||
|
|
||||||
|
// carryOverFirst runs the carry-over at the start, before the manager hears an event, refreshes or reads usage (§11), and
|
||||||
|
// answers once it has ended: at once when `carried.postgres` exists, else when a run writes it, this one or one asked
|
||||||
|
// by hand later.
|
||||||
|
func carryOverFirst() {
|
||||||
|
<-stdio.Served()
|
||||||
|
m, err := manager()
|
||||||
|
if err != nil {
|
||||||
|
say("the carry-over cannot start: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !m.Carry.Ended() {
|
||||||
|
bus, _ := m.Store.(*BusStore)
|
||||||
|
_, _ = m.Carry.Run(context.Background(), m, bus) // a failure is said, and the run is asked again by hand (§44)
|
||||||
|
}
|
||||||
|
// At its start the manager judges every condition anew: raised again each it holds, cleared each it no longer
|
||||||
|
// holds, carry-over-failed among them (§9, §11).
|
||||||
|
if err := m.SayConditions(context.Background(), true); err != nil {
|
||||||
|
say("saying the conditions at the start: %v", err)
|
||||||
|
}
|
||||||
|
<-m.Carry.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
func daemon() {
|
func daemon() {
|
||||||
var mu sync.Mutex
|
carryOverFirst()
|
||||||
reports := map[string]Holdings{}
|
|
||||||
var passing sync.Mutex
|
var passing sync.Mutex
|
||||||
pass := func() {
|
pass := func() {
|
||||||
passing.Lock() // one pass at a time: each account is leased, and a pass is cheap
|
passing.Lock() // one pass at a time: each account is leased, and a pass is cheap
|
||||||
@@ -160,14 +191,7 @@ func daemon() {
|
|||||||
say("not ready: %v", err)
|
say("not ready: %v", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
mu.Lock()
|
adopted, err := m.Consider(context.Background(), heard.All())
|
||||||
all := make([]Holdings, 0, len(reports))
|
|
||||||
for _, r := range reports {
|
|
||||||
all = append(all, r)
|
|
||||||
}
|
|
||||||
mu.Unlock()
|
|
||||||
sort.Slice(all, func(i, j int) bool { return all[i].Node < all[j].Node })
|
|
||||||
adopted, err := m.Consider(context.Background(), all)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
say("considering the reports failed: %v", err)
|
say("considering the reports failed: %v", err)
|
||||||
}
|
}
|
||||||
@@ -176,48 +200,45 @@ func daemon() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// What every node holds (ADR 0206): the whole current set, then each change. Asked again until it
|
// What every node holds (ADR 0206, design 39 bus-and-switch §23): each `holdings-changed` heard is that node's
|
||||||
// answers — the channel to the runtime opens as the bundle starts, and the agent module's state may
|
// latest report and starts a pass; once heard, every node is asked to say it again (B12: at every start, after the
|
||||||
// arrive after this one.
|
// carry-over). Asked again until it answers — the channel to the runtime opens as the bundle starts.
|
||||||
go func() {
|
go func() {
|
||||||
waits := []time.Duration{2 * time.Second, 5 * time.Second, 10 * time.Second, 30 * time.Second}
|
waits := []time.Duration{2 * time.Second, 5 * time.Second, 10 * time.Second, 30 * time.Second}
|
||||||
for attempt := 0; ; attempt++ {
|
for attempt := 0; ; attempt++ {
|
||||||
err := stdio.State("claude-code.holdings").Watch("", func(c stdio.StateChange) error {
|
err := stdio.Subscribe(HoldingsChanged, func(e stdio.Envelope) error {
|
||||||
mu.Lock()
|
if _, err := heard.Take(e.Body); err != nil {
|
||||||
if c.Op == "put" {
|
say("%v", err)
|
||||||
var h Holdings
|
return nil
|
||||||
if json.Unmarshal(c.Value, &h) == nil {
|
|
||||||
reports[c.Key] = h
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
delete(reports, c.Key)
|
|
||||||
}
|
|
||||||
mu.Unlock()
|
|
||||||
// During the current values the pass waits for the whole set: newest login first needs all.
|
|
||||||
if !c.Current {
|
|
||||||
go pass()
|
|
||||||
}
|
}
|
||||||
|
go pass()
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
if err == nil {
|
if err == nil {
|
||||||
mu.Lock()
|
say("hearing what the nodes hold")
|
||||||
n := len(reports)
|
if m, err := manager(); err != nil {
|
||||||
mu.Unlock()
|
say("not ready to ask what the nodes hold: %v", err)
|
||||||
say("watching what %d node(s) hold", n)
|
} else if err := m.AskHoldings(Itself); err != nil {
|
||||||
pass()
|
say("asking what the nodes hold could not be said: %v", err)
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
pause := time.Minute
|
pause := time.Minute
|
||||||
if attempt < len(waits) {
|
if attempt < len(waits) {
|
||||||
pause = waits[attempt]
|
pause = waits[attempt]
|
||||||
}
|
}
|
||||||
say("what the nodes hold cannot be watched yet (%v); asking again in %s", err, pause)
|
say("what the nodes hold cannot be heard yet (%v); asking again in %s", err, pause)
|
||||||
time.Sleep(pause)
|
time.Sleep(pause)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
refresh := time.NewTicker(time.Minute)
|
refresh := time.NewTicker(time.Minute)
|
||||||
usage := time.NewTicker(5 * time.Minute)
|
// Every usage_minutes, read again at every reading (§24, §33): the manager's one timer of its own readings.
|
||||||
|
every := Defaults.UsageEvery
|
||||||
|
if m, err := manager(); err == nil {
|
||||||
|
every = m.settings().UsageEvery
|
||||||
|
}
|
||||||
|
usage := time.NewTimer(every)
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-refresh.C:
|
case <-refresh.C:
|
||||||
@@ -237,7 +258,9 @@ func daemon() {
|
|||||||
if err := m.ReadUsage(context.Background()); err != nil {
|
if err := m.ReadUsage(context.Background()); err != nil {
|
||||||
say("reading usage failed: %v", err)
|
say("reading usage failed: %v", err)
|
||||||
}
|
}
|
||||||
|
every = m.settings().UsageEvery
|
||||||
}
|
}
|
||||||
|
usage.Reset(every)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -253,7 +276,8 @@ func text(a map[string]any, k string) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// verb is one of the seat's verbs: listed as `<seat>.<verb>`, so the runtime serves it on the seat's subject.
|
// verb is one of the seat's verbs: listed as `<seat>.<verb>`, so the runtime serves it on the seat's subject.
|
||||||
// What it changes is recorded as asked by its caller, as the runtime proved it (novox/hq issues 365 and 625).
|
// What it changes is recorded as asked by its caller, as the runtime proved it (novox/hq issues 365 and 625). A verb
|
||||||
|
// that acts answers nothing but "the carry-over has not ended" until it has (§11).
|
||||||
func verb(name, description string, input map[string]any, run func(ctx context.Context, m *Manager, a map[string]any) (any, error)) stdio.Tool {
|
func verb(name, description string, input map[string]any, run func(ctx context.Context, m *Manager, a map[string]any) (any, error)) stdio.Tool {
|
||||||
return stdio.Tool{Name: Seat + "." + name, Description: description, Input: input,
|
return stdio.Tool{Name: Seat + "." + name, Description: description, Input: input,
|
||||||
Run: func(a map[string]any) (any, error) {
|
Run: func(a map[string]any) (any, error) {
|
||||||
@@ -261,11 +285,41 @@ func verb(name, description string, input map[string]any, run func(ctx context.C
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if !reads[name] {
|
||||||
|
if err := m.Carry.Acts(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
ctx := WithCause(context.Background(), Cause{Why: "the " + name + " verb", By: stdio.CallerOf(a)})
|
ctx := WithCause(context.Background(), Cause{Why: "the " + name + " verb", By: stdio.CallerOf(a)})
|
||||||
return run(ctx, m, a)
|
return run(ctx, m, a)
|
||||||
}}
|
}}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// reads are the seat's verbs that only read, served while the carry-over has not ended (§11).
|
||||||
|
var reads = map[string]bool{"licences": true, "bindings": true, "usage": true, "public-key": true, "current": true}
|
||||||
|
|
||||||
|
// sinceOf is the argument since, RFC 3339; absent is the zero time.
|
||||||
|
func sinceOf(a map[string]any) (time.Time, error) {
|
||||||
|
v, _ := a["since"].(string)
|
||||||
|
if strings.TrimSpace(v) == "" {
|
||||||
|
return time.Time{}, nil
|
||||||
|
}
|
||||||
|
t, err := time.Parse(time.RFC3339Nano, strings.TrimSpace(v))
|
||||||
|
if err != nil {
|
||||||
|
return time.Time{}, fmt.Errorf("since is not a time in RFC 3339: %w", err)
|
||||||
|
}
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// limitOf is the argument limit: def when absent or not positive, at most most.
|
||||||
|
func limitOf(a map[string]any, def, most int) int {
|
||||||
|
limit := def
|
||||||
|
if v, ok := a["limit"].(float64); ok && v > 0 {
|
||||||
|
limit = int(v)
|
||||||
|
}
|
||||||
|
return min(limit, most)
|
||||||
|
}
|
||||||
|
|
||||||
func str(description string) map[string]any {
|
func str(description string) map[string]any {
|
||||||
return map[string]any{"type": "string", "description": description}
|
return map[string]any{"type": "string", "description": description}
|
||||||
}
|
}
|
||||||
@@ -278,11 +332,15 @@ func two(a map[string]any, k1, k2 string) (string, string, error) {
|
|||||||
|
|
||||||
func tools() []stdio.Tool {
|
func tools() []stdio.Tool {
|
||||||
consumer := str("the node's name")
|
consumer := str("the node's name")
|
||||||
return []stdio.Tool{
|
return append([]stdio.Tool{
|
||||||
verb("licences", "Every licence the manager holds — account, kind, when its token and its refresh token expire, failures in a row, which consumers are bound to it. Never a token.",
|
verb("licences", "Every licence the manager holds — account, kind, when its token and its refresh token expire, failures in a row, which consumers are bound to it, its latest usage with its reset times, its room or why it is not known, whether it is where a full licence's consumers move next, when and from where it was adopted, and its open conditions. Never a token.",
|
||||||
nil, func(ctx context.Context, m *Manager, _ map[string]any) (any, error) { return m.Licences(ctx) }),
|
nil, func(ctx context.Context, m *Manager, _ map[string]any) (any, error) { return m.Licences(ctx) }),
|
||||||
verb("bindings", "Which licence each consumer (a node's agent, by the node's name) is bound to, and the generation it was last given.",
|
verb("bindings", "Which licence each consumer (a node's agent, by the node's name) is bound to, and the generation it was last given; with consumer, that consumer's alone.",
|
||||||
nil, func(ctx context.Context, m *Manager, _ map[string]any) (any, error) { return m.Store.Bindings(ctx) }),
|
map[string]any{"consumer": str("a consumer; absent for every one")},
|
||||||
|
func(ctx context.Context, m *Manager, a map[string]any) (any, error) {
|
||||||
|
c, _ := a["consumer"].(string)
|
||||||
|
return m.BindingsOf(ctx, strings.TrimSpace(c))
|
||||||
|
}),
|
||||||
verb("bind", "Bind a consumer — a node's agent, by the node's name — to a licence. Its node fetches the licence's token at once.",
|
verb("bind", "Bind a consumer — a node's agent, by the node's name — to a licence. Its node fetches the licence's token at once.",
|
||||||
map[string]any{"consumer": consumer, "licence": str("a licence, as `licences` names it")},
|
map[string]any{"consumer": consumer, "licence": str("a licence, as `licences` names it")},
|
||||||
func(ctx context.Context, m *Manager, a map[string]any) (any, error) {
|
func(ctx context.Context, m *Manager, a map[string]any) (any, error) {
|
||||||
@@ -318,15 +376,18 @@ func tools() []stdio.Tool {
|
|||||||
}
|
}
|
||||||
return m.RotateDue(ctx)
|
return m.RotateDue(ctx)
|
||||||
}),
|
}),
|
||||||
verb("usage", "Usage readings, the latest first, for every licence or one.",
|
verb("usage", "Usage readings, the latest first, for every licence or one: the flattened reading with its three reset times, none before since, at most limit (default 20, at most 2000), and with raw the vendor's answer whole.",
|
||||||
map[string]any{"licence": str("a licence; absent for all"), "limit": map[string]any{"type": "number", "description": "how many readings (default 20)"}},
|
map[string]any{"licence": str("a licence; absent for all"), "since": str("a time, RFC 3339: no reading before it"),
|
||||||
|
"limit": map[string]any{"type": "number", "description": "how many readings (default 20, at most 2000)"},
|
||||||
|
"raw": map[string]any{"type": "boolean", "description": "also the vendor's answer whole"}},
|
||||||
func(ctx context.Context, m *Manager, a map[string]any) (any, error) {
|
func(ctx context.Context, m *Manager, a map[string]any) (any, error) {
|
||||||
l, _ := a["licence"].(string)
|
l, _ := a["licence"].(string)
|
||||||
limit := 20
|
since, err := sinceOf(a)
|
||||||
if v, ok := a["limit"].(float64); ok && v > 0 {
|
if err != nil {
|
||||||
limit = int(v)
|
return nil, err
|
||||||
}
|
}
|
||||||
return m.Store.Usage(ctx, l, limit)
|
raw, _ := a["raw"].(bool)
|
||||||
|
return m.Store.Usage(ctx, l, since, limitOf(a, 20, 2000), raw)
|
||||||
}),
|
}),
|
||||||
verb("adopt", "Adopt an API key — never as an argument: from a file on the manager's node (`file`), or sealed to the manager's `public-key` by a node's claude-code (`sealed`; its `claude_code_add_api_key` does this). Subscriptions are adopted from the nodes' logins by themselves.",
|
verb("adopt", "Adopt an API key — never as an argument: from a file on the manager's node (`file`), or sealed to the manager's `public-key` by a node's claude-code (`sealed`; its `claude_code_add_api_key` does this). Subscriptions are adopted from the nodes' logins by themselves.",
|
||||||
map[string]any{"name": str("the licence's name"), "file": str("a file on the manager's node holding the key"),
|
map[string]any{"name": str("the licence's name"), "file": str("a file on the manager's node holding the key"),
|
||||||
@@ -356,14 +417,14 @@ func tools() []stdio.Tool {
|
|||||||
nil, func(_ context.Context, m *Manager, _ map[string]any) (any, error) {
|
nil, func(_ context.Context, m *Manager, _ map[string]any) (any, error) {
|
||||||
return map[string]any{"public_key": m.Keys.PublicKey}, nil
|
return map[string]any{"public_key": m.Keys.PublicKey}, nil
|
||||||
}),
|
}),
|
||||||
verb("current", "For a consumer's agent module (ADR 0206): its token, sealed to the public key it sends, with the licence, kind and generation. Null when it is bound to nothing.",
|
verb("current", "For a consumer's agent module: which licence it is bound to, the licence's key, kind and generation, its health (ok, failing or expiring) and when its access token expires; never the token, which the vault hands. Null when it is bound to nothing.",
|
||||||
map[string]any{"consumer": consumer, "public_key": str("the consumer's public key, PEM")},
|
map[string]any{"consumer": consumer},
|
||||||
func(ctx context.Context, m *Manager, a map[string]any) (any, error) {
|
func(ctx context.Context, m *Manager, a map[string]any) (any, error) {
|
||||||
c, k, err := two(a, "consumer", "public_key")
|
c, err := text(a, "consumer")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return m.Current(ctx, c, k)
|
return m.Current(ctx, c)
|
||||||
}),
|
}),
|
||||||
}
|
}, moduleTools()...)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,10 @@ import (
|
|||||||
"regexp"
|
"regexp"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
oc "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Fingerprint names a token without being one: the agent module's own fingerprint, so the two compare.
|
// Fingerprint names a token without being one: the agent module's own fingerprint, so the two compare.
|
||||||
@@ -53,17 +56,6 @@ type Holdings struct {
|
|||||||
Generation int64 `json:"generation"`
|
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
|
// 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.
|
// called or the rule that fired, By the caller the runtime proved (stdio.CallerOf) or this module itself.
|
||||||
type Cause struct {
|
type Cause struct {
|
||||||
@@ -90,63 +82,48 @@ func CauseOf(ctx context.Context) Cause {
|
|||||||
return Cause{Why: "a rule of this module's own", By: Itself}
|
return Cause{Why: "a rule of this module's own", By: Itself}
|
||||||
}
|
}
|
||||||
|
|
||||||
// record says one change of what a consumer holds, the same fields in three places (novox/hq issue 625): a line
|
// record says one change of what a consumer holds, the same fields in two places (novox/hq issue 625): a line in
|
||||||
// in the journal, a row in the audit table, and the event `licence.<what>` on the bus. Never a token.
|
// the journal and an entry of the audit. The bus hears it as binding.changed (§21); `licence.bound`, `released` and
|
||||||
func (m *Manager) record(ctx context.Context, what, line string, detail map[string]any) {
|
// `rotated` are said no more (B16). Never a token.
|
||||||
_ = 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.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.
|
// changed says that one consumer's key of `bindings` was written or deleted (§21, B7): its licence (empty on a
|
||||||
func (m *Manager) moved(ctx context.Context, consumer, from, licence string, generation int64, c Cause) {
|
// release) and that licence's key, kind, generation, the licence before, why, by and when. Never a token.
|
||||||
what, line := "bound", fmt.Sprintf("bound %s to %s", consumer, licence)
|
func (m *Manager) changed(b Binding, from string) {
|
||||||
if from != "" {
|
key := ""
|
||||||
what, line = "switched", fmt.Sprintf("switched %s from %s to %s", consumer, from, licence)
|
if b.Licence != "" {
|
||||||
|
key = KeyFor(b.Licence)
|
||||||
}
|
}
|
||||||
m.record(ctx, what, fmt.Sprintf("%s (generation %d): %s; asked by %s", line, generation, c.Why, c.By),
|
_ = m.Emit("binding.changed", map[string]any{"consumer": b.Consumer, "licence": b.Licence, "key": key,
|
||||||
map[string]any{"consumer": consumer, "licence": licence, "from": from, "generation": generation,
|
"kind": b.Kind, "generation": b.Generation, "from": from, "why": b.Why, "by": b.By,
|
||||||
"why": c.Why, "by": c.By})
|
"at": m.Now().UTC().Format(time.RFC3339Nano)})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Settings are the manager's own, declared with defaults (design 39 §8).
|
// switchedFields are a switch's fields, the same in the event licence.switched and the audit entry switched (§27).
|
||||||
type Settings struct {
|
func switchedFields(from, to string, consumers []string, c Cause) map[string]any {
|
||||||
Cadence time.Duration // rotate a grant once older than this
|
return map[string]any{"from": from, "to": to, "consumers": consumers, "by": c.By, "why": c.Why}
|
||||||
Floor time.Duration // refresh in any case with less than this left
|
|
||||||
FailuresToNotify int
|
|
||||||
Cooldown time.Duration // at most one notification per licence in this window
|
|
||||||
RefreshWarn time.Duration // warn this long before a refresh token itself expires
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Defaults are the settings a fresh mesh runs with.
|
// moved records a consumer bound or switched, after its key is written: in the journal and the audit (a switch with
|
||||||
var Defaults = Settings{Cadence: 4 * time.Hour, Floor: time.Hour, FailuresToNotify: 3, Cooldown: 24 * time.Hour, RefreshWarn: 72 * time.Hour}
|
// §27's fields), and as binding.changed.
|
||||||
|
func (m *Manager) moved(ctx context.Context, b Binding, from string, c Cause) error {
|
||||||
// SettingsFrom reads the settings file, keeping a default for anything absent or not positive.
|
what, line := "bound", fmt.Sprintf("bound %s to %s", b.Consumer, b.Licence)
|
||||||
func SettingsFrom(path string) Settings {
|
detail := map[string]any{"consumer": b.Consumer, "licence": b.Licence, "from": from, "generation": b.Generation,
|
||||||
s := Defaults
|
"why": c.Why, "by": c.By}
|
||||||
raw, err := os.ReadFile(path)
|
if from != "" && from != b.Licence {
|
||||||
if err != nil {
|
what, line = "switched", fmt.Sprintf("switched %s from %s to %s", b.Consumer, from, b.Licence)
|
||||||
return s
|
detail = switchedFields(from, b.Licence, []string{b.Consumer}, c)
|
||||||
}
|
}
|
||||||
var f map[string]float64
|
err := m.record(ctx, what, fmt.Sprintf("%s (generation %d): %s; asked by %s", line, b.Generation, c.Why, c.By), detail)
|
||||||
if json.Unmarshal(raw, &f) != nil {
|
m.changed(b, from)
|
||||||
return s
|
return err
|
||||||
}
|
|
||||||
set := func(k string, unit time.Duration, into *time.Duration) {
|
|
||||||
if v := f[k]; v > 0 {
|
|
||||||
*into = time.Duration(v * float64(unit))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
set("cadence_minutes", time.Minute, &s.Cadence)
|
|
||||||
set("floor_minutes", time.Minute, &s.Floor)
|
|
||||||
set("cooldown_hours", time.Hour, &s.Cooldown)
|
|
||||||
if v := f["refresh_warn_days"]; v > 0 {
|
|
||||||
s.RefreshWarn = time.Duration(v * float64(24*time.Hour))
|
|
||||||
}
|
|
||||||
if v := f["failures_to_notify"]; v > 0 {
|
|
||||||
s.FailuresToNotify = int(v)
|
|
||||||
}
|
|
||||||
return s
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GrantAnswer is what a node's `claude_code_grant` answers: a login sealed to the key given, or nothing.
|
// GrantAnswer is what a node's `claude_code_grant` answers: a login sealed to the key given, or nothing.
|
||||||
@@ -160,20 +137,38 @@ type GrantAnswer struct {
|
|||||||
type Manager struct {
|
type Manager struct {
|
||||||
Store Store
|
Store Store
|
||||||
Vendor Vendor
|
Vendor Vendor
|
||||||
Crypt *Crypt
|
// Crypt opens the grants today's database keeps at rest, for the carry-over alone (design 39 bus-and-switch §12, A3).
|
||||||
|
Crypt *Crypt
|
||||||
|
// Carry is the carry-over's gate (§11): no act until it has ended. Nil is a manager with nothing to carry over.
|
||||||
|
Carry *Carry
|
||||||
|
// Grants is the vault, which holds every licence's grant and access token (§15, §16).
|
||||||
|
Grants Grants
|
||||||
Keys KeyPair
|
Keys KeyPair
|
||||||
// AskGrant asks a node's agent module for the grant a login left there, sealed to publicKey. An error
|
// 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.
|
// is the node not answering; a nil Sealed is no login waiting.
|
||||||
AskGrant func(ctx context.Context, node, publicKey string) (GrantAnswer, error)
|
AskGrant func(ctx context.Context, node, publicKey string) (GrantAnswer, error)
|
||||||
// PutBinding and DeleteBinding are this module's `bindings` state.
|
// Reported is the binding generation a node last reported applying (B11), 0 when none is known.
|
||||||
PutBinding func(ctx context.Context, consumer string, b BindingState) error
|
Reported func(node string) int64
|
||||||
DeleteBinding func(ctx context.Context, consumer string) error
|
Emit func(event string, body map[string]any) error
|
||||||
Emit func(event string, body map[string]any) error
|
// Tell tells the operator one notice that needs no answer, through the seat operator-channel's tell (§28).
|
||||||
Now func() time.Time
|
Tell func(ctx context.Context, headline string, lines []string) error
|
||||||
Log func(format string, args ...any)
|
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 names this process in a lease, so a second run is told apart.
|
||||||
Holder string
|
Holder string
|
||||||
Settings Settings
|
// Settings are those in force, read again from SettingsFile at every reading (settings.go); an empty SettingsFile
|
||||||
|
// keeps them.
|
||||||
|
Settings Settings
|
||||||
|
SettingsFile string
|
||||||
|
settingsMu sync.RWMutex
|
||||||
|
// Conditions is the state `conditions` (design 39 bus-and-switch §9), and Say says one condition on the operator
|
||||||
|
// channel; either nil says none.
|
||||||
|
Conditions keptState
|
||||||
|
Say func(c oc.Condition) error
|
||||||
|
conditionsMu sync.Mutex
|
||||||
|
// ConfigLevels is where mesh-config says each setting was set on the manager's node (mesh, node or default), by
|
||||||
|
// setting (§40, B18); nil answers every one as the default.
|
||||||
|
ConfigLevels func(ctx context.Context) (map[string]string, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
const leaseFor = 2 * time.Minute
|
const leaseFor = 2 * time.Minute
|
||||||
@@ -239,17 +234,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
|
// newest first; the first that refreshes is adopted and the rest are settled as skipped without being
|
||||||
// exchanged. Answers what was adopted.
|
// exchanged. Answers what was adopted.
|
||||||
func (m *Manager) Consider(ctx context.Context, reports []Holdings) ([]string, error) {
|
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
|
// **Never a generation a node has already passed** (novox/hq issue 243, B11): each consumer's count is its own,
|
||||||
// counts from one again, while every node still holds the number it last applied. The nodes no longer
|
// and moves past the number its node reported applying.
|
||||||
// wait for a higher number, but a binding numbered exactly as the one a node applied would still be
|
floor := m.floorIn(reports)
|
||||||
// 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
|
|
||||||
}
|
|
||||||
byAccount, err := m.CandidatesIn(ctx, reports)
|
byAccount, err := m.CandidatesIn(ctx, reports)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -262,19 +249,19 @@ 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
|
// 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
|
// (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.
|
// 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
|
return nil, err
|
||||||
}
|
}
|
||||||
var adopted []string
|
var adopted []string
|
||||||
for _, account := range accounts {
|
for _, account := range accounts {
|
||||||
list := byAccount[account]
|
list := byAccount[account]
|
||||||
key := "account:" + account
|
key := "account." + account
|
||||||
ok, err := m.Store.Lease(ctx, key, m.Holder, leaseFor)
|
ok, err := m.Store.Lease(ctx, key, m.Holder, leaseFor)
|
||||||
if err != nil || !ok {
|
if err != nil || !ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
for i, c := range list {
|
for i, c := range list {
|
||||||
name, err := m.adoptOne(ctx, c, reports)
|
name, err := m.adoptOne(ctx, c, reports, floor)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
m.Log("adopting %s's login failed: %v", c.Node, err)
|
m.Log("adopting %s's login failed: %v", c.Node, err)
|
||||||
continue
|
continue
|
||||||
@@ -293,7 +280,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.
|
// 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 {
|
for _, rep := range reports {
|
||||||
if rep.Identity == nil || rep.Identity.AccountUUID == "" {
|
if rep.Identity == nil || rep.Identity.AccountUUID == "" {
|
||||||
continue
|
continue
|
||||||
@@ -311,21 +298,19 @@ func (m *Manager) bindReporters(ctx context.Context, reports []Holdings) error {
|
|||||||
if l == nil {
|
if l == nil {
|
||||||
continue
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
c := Cause{Why: rep.Node + " reported the account of " + l.Name + " and was bound to nothing", By: Itself}
|
if err := m.moved(ctx, b, "", c); err != nil {
|
||||||
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 err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
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)
|
answer, err := m.AskGrant(ctx, c.Node, m.Keys.PublicKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Not answering is not an answer: asked again on the next pass.
|
// Not answering is not an answer: asked again on the next pass.
|
||||||
@@ -348,12 +333,34 @@ func (m *Manager) adoptOne(ctx context.Context, c Candidate, reports []Holdings)
|
|||||||
return "", nil
|
return "", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A new licence whose key another licence already has is refused before its grant is exchanged
|
||||||
|
// (design 39 bus-and-switch §10).
|
||||||
|
if held, err := m.Store.LicenceForAccount(ctx, c.Identity.AccountUUID); err != nil {
|
||||||
|
return "", err
|
||||||
|
} else if held == nil {
|
||||||
|
taken, err := m.keyTaken(ctx, NameFor(c.Identity))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Adopting is refreshing (ADR 0206 §4): the exchange is the check, and from here this module is the
|
// Adopting is refreshing (ADR 0206 §4): the exchange is the check, and from here this module is the
|
||||||
// only holder of a live refresh token for the account.
|
// only holder of a live refresh token for the account.
|
||||||
r := m.Vendor.Refresh(ctx, offered)
|
r := m.Vendor.Refresh(ctx, offered)
|
||||||
if !r.OK {
|
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.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),
|
_ = 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)})
|
"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)
|
m.Log("%s's login for %s did not refresh: %d %s", c.Node, NameFor(c.Identity), r.Status, r.Reason)
|
||||||
@@ -363,7 +370,9 @@ func (m *Manager) adoptOne(ctx context.Context, c Candidate, reports []Holdings)
|
|||||||
if r.Account != "" && r.Account != c.Identity.AccountUUID {
|
if r.Account != "" && r.Account != c.Identity.AccountUUID {
|
||||||
_ = m.Store.RecordOutcome(ctx, c.Fingerprint, c.Node, c.Identity.AccountUUID, Refused,
|
_ = m.Store.RecordOutcome(ctx, c.Fingerprint, c.Node, c.Identity.AccountUUID, Refused,
|
||||||
"the node says "+c.Identity.AccountUUID+", the vendor says "+r.Account)
|
"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),
|
_ = 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"})
|
"reason": "the account the node reported is not the one the vendor answered for"})
|
||||||
return "", nil
|
return "", nil
|
||||||
@@ -375,9 +384,10 @@ func (m *Manager) adoptOne(ctx context.Context, c Candidate, reports []Holdings)
|
|||||||
}
|
}
|
||||||
now := m.Now().UnixMilli()
|
now := m.Now().UnixMilli()
|
||||||
l := Licence{Name: NameFor(c.Identity), Kind: "subscription", AccountUUID: c.Identity.AccountUUID,
|
l := Licence{Name: NameFor(c.Identity), Kind: "subscription", AccountUUID: c.Identity.AccountUUID,
|
||||||
Email: c.Identity.EmailAddress, OrganizationUUID: c.Identity.OrganizationUUID}
|
Email: c.Identity.EmailAddress, OrganizationUUID: c.Identity.OrganizationUUID, AdoptedFrom: c.Node}
|
||||||
if held != nil {
|
if held != nil {
|
||||||
l.Name, l.NotifiedAt = held.Name, held.NotifiedAt
|
// A newer login keeps what the key holds beside its grant: its latest reading (§2, §24).
|
||||||
|
l.Name, l.NotifiedAt, l.Generation, l.Usage = held.Name, held.NotifiedAt, held.Generation, held.Usage
|
||||||
if l.Email == "" {
|
if l.Email == "" {
|
||||||
l.Email = held.Email
|
l.Email = held.Email
|
||||||
}
|
}
|
||||||
@@ -385,7 +395,9 @@ func (m *Manager) adoptOne(ctx context.Context, c Candidate, reports []Holdings)
|
|||||||
l.OrganizationUUID = held.OrganizationUUID
|
l.OrganizationUUID = held.OrganizationUUID
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
m.keep(&l, r.Grant)
|
if err := m.keep(ctx, &l, r.Grant); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
l.AdoptedAt = max(now, c.ChangedAt)
|
l.AdoptedAt = max(now, c.ChangedAt)
|
||||||
if err := m.Store.SaveLicence(ctx, l); err != nil {
|
if err := m.Store.SaveLicence(ctx, l); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
@@ -395,18 +407,25 @@ func (m *Manager) adoptOne(ctx context.Context, c Candidate, reports []Holdings)
|
|||||||
why = "a newer login"
|
why = "a newer login"
|
||||||
}
|
}
|
||||||
_ = m.Store.RecordOutcome(ctx, c.Fingerprint, c.Node, c.Identity.AccountUUID, Adopted, why)
|
_ = 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,
|
if err := m.Store.Audit(ctx, "adopted", map[string]any{"licence": l.Name, "node": c.Node, "account": c.Identity.AccountUUID,
|
||||||
"vendorNamedAccount": r.Account != ""})
|
"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 —
|
// 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.
|
// switched, if it was bound to another. Then every node reporting this account and bound to nothing.
|
||||||
// Who asked is the person who logged in there; the mesh knows them only as that login (novox/hq issue 625).
|
// Who asked is the person who logged in there; the mesh knows them only as that login (novox/hq issue 625).
|
||||||
cause := Cause{Why: "a login to " + l.Name + " on " + c.Node + " (ADR 0209)", By: Itself}
|
cause := Cause{Why: "a login to " + l.Name + " on " + c.Node + " (ADR 0209)", By: Itself}
|
||||||
ctx = WithCause(ctx, cause)
|
ctx = WithCause(ctx, cause)
|
||||||
|
// The consumers already bound to it take the new grant first, so each consumer this adoption moves is said once
|
||||||
|
// (§21).
|
||||||
|
if err := m.publishAdvance(ctx, l, floor); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
if b, err := m.Store.Binding(ctx, c.Node); err != nil {
|
if b, err := m.Store.Binding(ctx, c.Node); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
} else if b == nil || b.Licence != l.Name {
|
} 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 {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
@@ -414,7 +433,9 @@ func (m *Manager) adoptOne(ctx context.Context, c Candidate, reports []Holdings)
|
|||||||
if b != nil {
|
if b != nil {
|
||||||
from = b.Licence
|
from = b.Licence
|
||||||
}
|
}
|
||||||
m.moved(ctx, c.Node, from, l.Name, moved.Generation, cause)
|
if err := m.moved(ctx, moved, from, cause); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
for _, rep := range reports {
|
for _, rep := range reports {
|
||||||
if rep.Identity == nil || rep.Identity.AccountUUID != c.Identity.AccountUUID {
|
if rep.Identity == nil || rep.Identity.AccountUUID != c.Identity.AccountUUID {
|
||||||
@@ -423,23 +444,25 @@ func (m *Manager) adoptOne(ctx context.Context, c Candidate, reports []Holdings)
|
|||||||
if b, err := m.Store.Binding(ctx, rep.Node); err != nil || b != nil {
|
if b, err := m.Store.Binding(ctx, rep.Node); err != nil || b != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if b, err := m.Store.Bind(ctx, rep.Node, l.Name); err == nil {
|
bc := Cause{Why: rep.Node + " reports the account of " + l.Name + ", logged in on " + c.Node + ", and was bound to nothing", By: Itself}
|
||||||
m.moved(ctx, rep.Node, "", l.Name, b.Generation,
|
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 {
|
||||||
Cause{Why: rep.Node + " reports the account of " + l.Name + ", logged in on " + c.Node + ", and was bound to nothing", By: Itself})
|
if err := m.moved(ctx, b, "", bc); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err := m.publishAdvance(ctx, l); err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
_ = m.Emit("licence.adopted", map[string]any{"licence": l.Name, "from": c.Node, "replaced": held != nil})
|
_ = m.Emit("licence.adopted", map[string]any{"licence": l.Name, "from": c.Node, "replaced": held != nil})
|
||||||
m.Log("adopted %s from %s (%s)", l.Name, c.Node, why)
|
m.Log("adopted %s from %s (%s)", l.Name, c.Node, why)
|
||||||
return l.Name, nil
|
return l.Name, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// keep stores a grant on its licence: encrypted, fingerprinted, its expiries, fresh.
|
// keep hands a grant to the vault at the licence's next generation, the grant first and then its access (design 39
|
||||||
func (m *Manager) keep(l *Licence, g FullGrant) {
|
// bus-and-switch §15), and keeps on the licence its fingerprint and expiries alone: no token once it returns.
|
||||||
raw, _ := json.Marshal(g)
|
func (m *Manager) keep(ctx context.Context, l *Licence, g FullGrant) error {
|
||||||
l.Sealed = m.Crypt.Seal(string(raw))
|
grant, _ := json.Marshal(g)
|
||||||
|
if err := m.handOver(ctx, l, string(grant), accessOf(g)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
l.RefreshFingerprint = Fingerprint(g.RefreshToken)
|
l.RefreshFingerprint = Fingerprint(g.RefreshToken)
|
||||||
l.AccessExpiresAt = g.ExpiresAt
|
l.AccessExpiresAt = g.ExpiresAt
|
||||||
if g.RefreshTokenExpiresAt != nil {
|
if g.RefreshTokenExpiresAt != nil {
|
||||||
@@ -447,30 +470,73 @@ func (m *Manager) keep(l *Licence, g FullGrant) {
|
|||||||
}
|
}
|
||||||
l.Failures = 0
|
l.Failures = 0
|
||||||
l.RotatedAt = m.Now().UnixMilli()
|
l.RotatedAt = m.Now().UnixMilli()
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// publishAdvance gives every consumer of a licence a new generation, and tells each in the state with the
|
// accessOf is a subscription's `access` secret: its access token and expiry, never its refresh token (§15).
|
||||||
// cause ctx carries, so the node says why it fetched again (novox/hq issue 625).
|
func accessOf(g FullGrant) string {
|
||||||
func (m *Manager) publishAdvance(ctx context.Context, l Licence) error {
|
access, _ := json.Marshal(map[string]any{"accessToken": g.AccessToken, "expiresAt": g.ExpiresAt,
|
||||||
bindings, err := m.Store.Advance(ctx, l.Name)
|
"refreshTokenExpiresAt": g.RefreshTokenExpiresAt, "scopes": g.Scopes,
|
||||||
if err != nil {
|
"subscriptionType": g.SubscriptionType, "rateLimitTier": g.RateLimitTier})
|
||||||
return err
|
return string(access)
|
||||||
}
|
}
|
||||||
c := CauseOf(ctx)
|
|
||||||
for _, b := range bindings {
|
// handOver puts a licence's two secrets in the vault at its next generation, the grant first (§15).
|
||||||
if err := m.PutBinding(ctx, b.Consumer, BindingState{Licence: b.Licence, Kind: l.Kind, Generation: b.Generation,
|
func (m *Manager) handOver(ctx context.Context, l *Licence, grant, access string) error {
|
||||||
Why: c.Why, By: c.By}); err != nil {
|
generation := l.Generation + 1
|
||||||
return err
|
for _, part := range []struct{ name, value string }{{PartGrant, grant}, {PartAccess, access}} {
|
||||||
|
if err := m.Grants.Put(ctx, KeyFor(l.Name), part.name, generation, part.value); err != nil {
|
||||||
|
return fmt.Errorf("the vault did not take the %s of %s: %w", part.name, l.Name, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
l.Generation = generation
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// grantOf reads a subscription's grant from the vault for one act (§16); the caller drops it when the act ends.
|
||||||
|
func (m *Manager) grantOf(ctx context.Context, l Licence) (FullGrant, error) {
|
||||||
|
var g FullGrant
|
||||||
|
plain, err := m.Grants.Get(ctx, KeyFor(l.Name), PartGrant)
|
||||||
|
if err != nil {
|
||||||
|
return g, fmt.Errorf("the grant of %s cannot be read from the vault: %w", l.Name, err)
|
||||||
|
}
|
||||||
|
return g, json.Unmarshal([]byte(plain), &g)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
advanced, err := m.Store.Advance(ctx, l.Name, Move{Kind: l.Kind, Why: c.Why, By: c.By, Floor: floor})
|
||||||
|
for _, b := range advanced {
|
||||||
|
m.changed(b, b.Licence)
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---- keeping grants alive ----------------------------------------------------------------------------
|
// ---- keeping grants alive ----------------------------------------------------------------------------
|
||||||
|
|
||||||
// Due says whether a licence needs a refresh now: near expiry, or older than the cadence.
|
// Due says whether a licence needs a refresh now: near expiry, or older than the cadence.
|
||||||
func Due(l Licence, now time.Time, s Settings) bool {
|
func Due(l Licence, now time.Time, s Settings) bool {
|
||||||
if l.Kind != "subscription" || l.Sealed == "" {
|
if l.Kind != "subscription" || l.Generation == 0 {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
ms := now.UnixMilli()
|
ms := now.UnixMilli()
|
||||||
@@ -484,7 +550,7 @@ func Due(l Licence, now time.Time, s Settings) bool {
|
|||||||
// lease live and does nothing; one started just after finds a fresh grant and is not due. force refreshes
|
// lease live and does nothing; one started just after finds a fresh grant and is not due. force refreshes
|
||||||
// whatever the age — the seat's `refresh` verb.
|
// whatever the age — the seat's `refresh` verb.
|
||||||
func (m *Manager) Rotate(ctx context.Context, name string, force bool) (map[string]any, error) {
|
func (m *Manager) Rotate(ctx context.Context, name string, force bool) (map[string]any, error) {
|
||||||
key := "licence:" + name
|
key := "licence." + KeyFor(name)
|
||||||
ok, err := m.Store.Lease(ctx, key, m.Holder, leaseFor)
|
ok, err := m.Store.Lease(ctx, key, m.Holder, leaseFor)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -500,37 +566,38 @@ func (m *Manager) Rotate(ctx context.Context, name string, force bool) (map[stri
|
|||||||
if l == nil {
|
if l == nil {
|
||||||
return map[string]any{"licence": name, "refreshed": false, "reason": "no such licence"}, nil
|
return map[string]any{"licence": name, "refreshed": false, "reason": "no such licence"}, nil
|
||||||
}
|
}
|
||||||
if l.Kind != "subscription" || l.Sealed == "" {
|
if l.Kind != "subscription" || l.Generation == 0 {
|
||||||
return map[string]any{"licence": name, "refreshed": false, "reason": "an API key does not refresh"}, nil
|
return map[string]any{"licence": name, "refreshed": false, "reason": "an API key does not refresh"}, nil
|
||||||
}
|
}
|
||||||
now := m.Now()
|
now := m.Now()
|
||||||
if !force && !Due(*l, now, m.Settings) {
|
if !force && !Due(*l, now, m.settings()) {
|
||||||
return map[string]any{"licence": name, "refreshed": false, "reason": "not due"}, nil
|
return map[string]any{"licence": name, "refreshed": false, "reason": "not due"}, nil
|
||||||
}
|
}
|
||||||
plain, err := m.Crypt.Open(l.Sealed)
|
g, err := m.grantOf(ctx, *l)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
var g FullGrant
|
|
||||||
if err := json.Unmarshal([]byte(plain), &g); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
r := m.Vendor.Refresh(ctx, g)
|
r := m.Vendor.Refresh(ctx, g)
|
||||||
|
g = FullGrant{}
|
||||||
if !r.OK {
|
if !r.OK {
|
||||||
l.Failures++
|
l.Failures++
|
||||||
m.Log("%s did not refresh (%d in a row): %d %s", name, l.Failures, r.Status, r.Reason)
|
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 {
|
||||||
m.notify(l, fmt.Sprintf("refresh failed %d time(s) in a row: %d", l.Failures, r.Status), l.Failures >= m.Settings.FailuresToNotify)
|
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 {
|
if err := m.Store.SaveLicence(ctx, *l); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return map[string]any{"licence": name, "refreshed": false, "reason": fmt.Sprintf("%d %s", r.Status, r.Reason), "failures": l.Failures}, nil
|
return map[string]any{"licence": name, "refreshed": false, "reason": fmt.Sprintf("%d %s", r.Status, r.Reason), "failures": l.Failures}, nil
|
||||||
}
|
}
|
||||||
m.keep(l, r.Grant)
|
if err := m.keep(ctx, l, r.Grant); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
if l.RefreshExpiresAt != 0 {
|
if l.RefreshExpiresAt != 0 {
|
||||||
left := time.Duration(l.RefreshExpiresAt-now.UnixMilli()) * time.Millisecond
|
left := time.Duration(l.RefreshExpiresAt-now.UnixMilli()) * time.Millisecond
|
||||||
m.notify(l, fmt.Sprintf("its refresh token expires in %.1f day(s): a person must log in again", left.Hours()/24),
|
m.notify(l, fmt.Sprintf("its refresh token expires in %.1f day(s): a person must log in again", left.Hours()/24),
|
||||||
left < m.Settings.RefreshWarn)
|
left < m.settings().RefreshWarn)
|
||||||
}
|
}
|
||||||
if err := m.Store.SaveLicence(ctx, *l); err != nil {
|
if err := m.Store.SaveLicence(ctx, *l); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -545,10 +612,12 @@ 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})
|
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),
|
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 {
|
||||||
if err := m.publishAdvance(ctx, *l); err != nil {
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := m.publishAdvance(ctx, *l, m.floorIn(nil)); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return map[string]any{"licence": name, "refreshed": true, "expiresAt": time.UnixMilli(l.AccessExpiresAt).UTC().Format(time.RFC3339)}, nil
|
return map[string]any{"licence": name, "refreshed": true, "expiresAt": time.UnixMilli(l.AccessExpiresAt).UTC().Format(time.RFC3339)}, nil
|
||||||
@@ -560,7 +629,7 @@ func (m *Manager) notify(l *Licence, why string, when bool) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
now := m.Now().UnixMilli()
|
now := m.Now().UnixMilli()
|
||||||
if l.NotifiedAt != 0 && now-l.NotifiedAt < m.Settings.Cooldown.Milliseconds() {
|
if l.NotifiedAt != 0 && now-l.NotifiedAt < m.settings().Cooldown.Milliseconds() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
l.NotifiedAt = now
|
l.NotifiedAt = now
|
||||||
@@ -575,7 +644,7 @@ func (m *Manager) RotateDue(ctx context.Context) ([]map[string]any, error) {
|
|||||||
}
|
}
|
||||||
var out []map[string]any
|
var out []map[string]any
|
||||||
for _, l := range all {
|
for _, l := range all {
|
||||||
if Due(l, m.Now(), m.Settings) {
|
if Due(l, m.Now(), m.settings()) {
|
||||||
r, err := m.Rotate(ctx, l.Name, false)
|
r, err := m.Rotate(ctx, l.Name, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return out, err
|
return out, err
|
||||||
@@ -586,79 +655,165 @@ func (m *Manager) RotateDue(ctx context.Context) ([]map[string]any, error) {
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReadUsage reads and records each subscription's usage (ADR 0054); the event names the licence and numbers.
|
// ReadUsage reads every subscription's usage, on the manager's cadence (§24): each reading written, the rule applied
|
||||||
|
// after it (§26), and the conditions said (§9).
|
||||||
func (m *Manager) ReadUsage(ctx context.Context) error {
|
func (m *Manager) ReadUsage(ctx context.Context) error {
|
||||||
|
_, err := m.ReadUsageOf(ctx, "", false)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read is one reading as licences_read_usage answers it (§38): the licence, the reading or why it failed, and what §26
|
||||||
|
// did or, with dry, would do.
|
||||||
|
type Read struct {
|
||||||
|
Licence string `json:"licence"`
|
||||||
|
Reading *UsageReading `json:"reading,omitempty"`
|
||||||
|
Error string `json:"readError,omitempty"`
|
||||||
|
Switches []Switch `json:"switches"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadUsageOf reads the usage of one subscription, or every one when licence is empty, as §24 does, and applies §26
|
||||||
|
// after each reading unless dry; dry answers what §26 would do (§32, §38).
|
||||||
|
func (m *Manager) ReadUsageOf(ctx context.Context, licence string, dry bool) ([]Read, error) {
|
||||||
|
if err := m.ReadSettings(ctx); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
all, err := m.Store.Licences(ctx)
|
all, err := m.Store.Licences(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
out := []Read{}
|
||||||
for _, l := range all {
|
for _, l := range all {
|
||||||
if l.Kind != "subscription" || l.Sealed == "" {
|
if l.Kind != "subscription" || l.Generation == 0 || (licence != "" && l.Name != licence) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
plain, err := m.Crypt.Open(l.Sealed)
|
// The reading takes the licence's grant under its lease, so it never reads a grant a refresh is replacing (§4,
|
||||||
|
// §16); a grant the vault does not answer is a failed reading like a vendor's refusal (§31).
|
||||||
|
key := "licence." + KeyFor(l.Name)
|
||||||
|
leased, err := m.Store.Lease(ctx, key, m.Holder, leaseFor)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return out, err
|
||||||
}
|
}
|
||||||
var g FullGrant
|
if !leased {
|
||||||
_ = json.Unmarshal([]byte(plain), &g)
|
out = append(out, Read{Licence: l.Name, Error: "another run holds its lease", Switches: []Switch{}})
|
||||||
raw, err := m.Vendor.Usage(ctx, g.AccessToken)
|
continue
|
||||||
if err != nil || raw == nil {
|
}
|
||||||
|
unlease := func() { _ = m.Store.Unlease(ctx, key, m.Holder) }
|
||||||
|
if fresh, err := m.Store.Licence(ctx, l.Name); err == nil && fresh != nil {
|
||||||
|
l = *fresh
|
||||||
|
}
|
||||||
|
var raw map[string]any
|
||||||
|
g, err := m.grantOf(ctx, l)
|
||||||
|
if err == nil {
|
||||||
|
raw, err = m.Vendor.Usage(ctx, g.AccessToken)
|
||||||
|
}
|
||||||
|
g = FullGrant{}
|
||||||
|
if err == nil && raw == nil {
|
||||||
|
err = errors.New("the usage endpoint answered nothing")
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
// A failed reading keeps the last percentages and writes the vendor's status and reason (§24).
|
||||||
|
kept := UsageReading{}
|
||||||
|
if l.Usage != nil {
|
||||||
|
kept = *l.Usage
|
||||||
|
}
|
||||||
|
kept.ReadError = err.Error()
|
||||||
|
l.Usage = &kept
|
||||||
|
err := m.Store.SaveLicence(ctx, l)
|
||||||
|
unlease()
|
||||||
|
if err != nil {
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
m.Log("reading the usage of %s failed: %v", l.Name, err)
|
||||||
|
out = append(out, Read{Licence: l.Name, Error: kept.ReadError, Switches: []Switch{}})
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
reading := FlattenUsage(raw)
|
reading := FlattenUsage(raw)
|
||||||
if err := m.Store.RecordUsage(ctx, l.Name, m.Now().UnixMilli(), reading, raw); err != nil {
|
if err := m.Store.RecordUsage(ctx, l.Name, m.Now().UnixMilli(), reading, raw); err != nil {
|
||||||
return err
|
unlease()
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
// The latest reading beside its licence, so licences answers from one key (§2).
|
||||||
|
latest := reading
|
||||||
|
latest.ReadAt = m.Now().UTC().Format(time.RFC3339Nano)
|
||||||
|
l.Usage = &latest
|
||||||
|
err = m.Store.SaveLicence(ctx, l)
|
||||||
|
unlease()
|
||||||
|
if err != nil {
|
||||||
|
return out, err
|
||||||
}
|
}
|
||||||
_ = m.Emit("usage.read", map[string]any{"licence": l.Name, "sessionPct": reading.SessionPct,
|
_ = m.Emit("usage.read", map[string]any{"licence": l.Name, "sessionPct": reading.SessionPct,
|
||||||
"weeklyPct": reading.WeeklyPct, "sonnetPct": reading.SonnetPct})
|
"sessionResetsAt": reading.SessionResetsAt, "weeklyPct": reading.WeeklyPct,
|
||||||
|
"weeklyResetsAt": reading.WeeklyResetsAt, "sonnetPct": reading.SonnetPct,
|
||||||
|
"sonnetResetsAt": reading.SonnetResetsAt})
|
||||||
|
// On each reading, a licence at its threshold moves every consumer bound to it (§26).
|
||||||
|
acts, err := m.ApplyRule(ctx, l.Name, false, dry)
|
||||||
|
if acts == nil {
|
||||||
|
acts = []Switch{}
|
||||||
|
}
|
||||||
|
out = append(out, Read{Licence: l.Name, Reading: &latest, Switches: acts})
|
||||||
|
if err != nil {
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return nil
|
// What the readings and the settings now hold is said, each condition only when what it says changed (§9).
|
||||||
|
return out, m.SayConditions(ctx, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- the seat's verbs --------------------------------------------------------------------------------
|
// ---- the seat's verbs --------------------------------------------------------------------------------
|
||||||
|
|
||||||
// Current is a consumer's token sealed to the key it sent (ADR 0206 §6): for a subscription the access
|
// Current is which licence a consumer is bound to and its health, never the token (design 39 bus-and-switch §18, B7,
|
||||||
// token and its expiries only — never the refresh token, which no node holds. Nil when it is bound to
|
// B19): the licence, its key (the name of its secrets in the vault), kind, generation, health and accessExpiresAt, and
|
||||||
|
// for a subscription the account's identity the agent module points its account file at. Nil when it is bound to
|
||||||
// nothing.
|
// nothing.
|
||||||
func (m *Manager) Current(ctx context.Context, consumer, publicKey string) (map[string]any, error) {
|
func (m *Manager) Current(ctx context.Context, consumer string) (map[string]any, error) {
|
||||||
if !strings.Contains(publicKey, "PUBLIC KEY") {
|
|
||||||
return nil, errors.New("current seals to the consumer's public key, and none was given")
|
|
||||||
}
|
|
||||||
b, err := m.Store.Binding(ctx, consumer)
|
b, err := m.Store.Binding(ctx, consumer)
|
||||||
if err != nil || b == nil {
|
if err != nil || b == nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
l, err := m.Store.Licence(ctx, b.Licence)
|
l, err := m.Store.Licence(ctx, b.Licence)
|
||||||
if err != nil || l == nil || l.Sealed == "" {
|
if err != nil || l == nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
plain, err := m.Crypt.Open(l.Sealed)
|
out := map[string]any{"licence": l.Name, "key": KeyFor(l.Name), "kind": l.Kind, "generation": b.Generation,
|
||||||
if err != nil {
|
"health": Health(*l, m.Now(), m.settings())}
|
||||||
return nil, err
|
if l.AccessExpiresAt != 0 {
|
||||||
|
out["accessExpiresAt"] = time.UnixMilli(l.AccessExpiresAt).UTC().Format(time.RFC3339)
|
||||||
}
|
}
|
||||||
handed := plain
|
|
||||||
if l.Kind == "subscription" {
|
|
||||||
var g FullGrant
|
|
||||||
if err := json.Unmarshal([]byte(plain), &g); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
access, _ := json.Marshal(map[string]any{"accessToken": g.AccessToken, "expiresAt": g.ExpiresAt,
|
|
||||||
"refreshTokenExpiresAt": g.RefreshTokenExpiresAt, "scopes": g.Scopes,
|
|
||||||
"subscriptionType": g.SubscriptionType, "rateLimitTier": g.RateLimitTier})
|
|
||||||
handed = string(access)
|
|
||||||
}
|
|
||||||
box, err := Seal(handed, publicKey)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
out := map[string]any{"licence": l.Name, "kind": l.Kind, "generation": b.Generation, "sealed": box}
|
|
||||||
if l.AccountUUID != "" {
|
if l.AccountUUID != "" {
|
||||||
out["identity"] = Identity{AccountUUID: l.AccountUUID, EmailAddress: l.Email, OrganizationUUID: l.OrganizationUUID}
|
out["identity"] = Identity{AccountUUID: l.AccountUUID, EmailAddress: l.Email, OrganizationUUID: l.OrganizationUUID}
|
||||||
}
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Health is a licence's health (B19): `failing` once its refresh failed failures_to_notify times in a row, `expiring`
|
||||||
|
// when its refresh token expires within refresh_warn_days, else `ok`.
|
||||||
|
func Health(l Licence, now time.Time, s Settings) string {
|
||||||
|
switch {
|
||||||
|
case l.Failures >= s.FailuresToNotify:
|
||||||
|
return "failing"
|
||||||
|
case l.RefreshExpiresAt != 0 && l.RefreshExpiresAt-now.UnixMilli() < s.RefreshWarn.Milliseconds():
|
||||||
|
return "expiring"
|
||||||
|
}
|
||||||
|
return "ok"
|
||||||
|
}
|
||||||
|
|
||||||
|
// BindingsOf is every binding, or the one of consumer when one is named (B10): a list either way, unpaged, each with
|
||||||
|
// its licence's key beside the §3 fields (B25).
|
||||||
|
func (m *Manager) BindingsOf(ctx context.Context, consumer string) ([]Binding, error) {
|
||||||
|
all, err := m.Store.Bindings(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := []Binding{}
|
||||||
|
for _, b := range all {
|
||||||
|
if consumer == "" || b.Consumer == consumer {
|
||||||
|
b.Key = KeyFor(b.Licence)
|
||||||
|
out = append(out, b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
// Bind binds or switches a consumer: a person's act (ADR 0183), told to the consumer as a new generation, and
|
// Bind binds or switches a consumer: a person's act (ADR 0183), told to the consumer as a new generation, and
|
||||||
// recorded with the cause ctx carries — the verb and its proven caller (novox/hq issue 625).
|
// recorded with the cause ctx carries — the verb and its proven caller (novox/hq issue 625).
|
||||||
func (m *Manager) Bind(ctx context.Context, consumer, licence string) (map[string]any, error) {
|
func (m *Manager) Bind(ctx context.Context, consumer, licence string) (map[string]any, error) {
|
||||||
@@ -673,7 +828,8 @@ func (m *Manager) Bind(ctx context.Context, consumer, licence string) (map[strin
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -681,12 +837,12 @@ func (m *Manager) Bind(ctx context.Context, consumer, licence string) (map[strin
|
|||||||
if before != nil {
|
if before != nil {
|
||||||
from = before.Licence
|
from = before.Licence
|
||||||
}
|
}
|
||||||
c := CauseOf(ctx)
|
if err := m.moved(ctx, b, from, c); err != nil {
|
||||||
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 nil, err
|
||||||
}
|
}
|
||||||
|
if from != "" && from != licence {
|
||||||
|
_ = m.Emit("licence.switched", switchedFields(from, licence, []string{consumer}, c)) // one per act (§27, §36)
|
||||||
|
}
|
||||||
return map[string]any{"consumer": consumer, "licence": licence, "generation": b.Generation, "from": from}, nil
|
return map[string]any{"consumer": consumer, "licence": licence, "generation": b.Generation, "from": from}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -699,12 +855,12 @@ func (m *Manager) Release(ctx context.Context, consumer string) (map[string]any,
|
|||||||
if ok, err := m.Store.Unbind(ctx, consumer); err != nil || !ok {
|
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
|
return map[string]any{"consumer": consumer, "released": false, "reason": "it was bound to nothing"}, err
|
||||||
}
|
}
|
||||||
if err := m.DeleteBinding(ctx, consumer); err != nil {
|
c := CauseOf(ctx)
|
||||||
|
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 nil, err
|
||||||
}
|
}
|
||||||
c := CauseOf(ctx)
|
m.changed(Binding{Consumer: consumer, Generation: was.Generation, Why: c.Why, By: c.By}, was.Licence)
|
||||||
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})
|
|
||||||
return map[string]any{"consumer": consumer, "released": true, "was": was.Licence}, nil
|
return map[string]any{"consumer": consumer, "released": true, "was": was.Licence}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -751,22 +907,44 @@ func (m *Manager) adoptKey(ctx context.Context, name, key, from string) (map[str
|
|||||||
if held != nil && held.Kind != "api-key" {
|
if held != nil && held.Kind != "api-key" {
|
||||||
return nil, fmt.Errorf("%s is a subscription; an API key needs a name of its own", name)
|
return nil, fmt.Errorf("%s is a subscription; an API key needs a name of its own", name)
|
||||||
}
|
}
|
||||||
l := Licence{Name: name, Kind: "api-key", Sealed: m.Crypt.Seal(key), AdoptedAt: m.Now().UnixMilli()}
|
taken, err := m.keyTaken(ctx, name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if taken != "" {
|
||||||
|
why := "its key " + KeyFor(name) + " is the key of the licence " + taken
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
l := Licence{Name: name, Kind: "api-key", AdoptedAt: m.Now().UnixMilli(), AdoptedFrom: from}
|
||||||
|
if held != nil {
|
||||||
|
l.Generation, l.Usage = held.Generation, held.Usage
|
||||||
|
}
|
||||||
|
if err := m.handOver(ctx, &l, key, key); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
if err := m.Store.SaveLicence(ctx, l); err != nil {
|
if err := m.Store.SaveLicence(ctx, l); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
c := CauseOf(ctx)
|
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)
|
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})
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
_ = m.Emit("licence.adopted", map[string]any{"licence": name, "from": from, "replaced": held != nil})
|
_ = m.Emit("licence.adopted", map[string]any{"licence": name, "from": from, "replaced": held != nil})
|
||||||
return map[string]any{"licence": name, "kind": "api-key", "adopted": true, "fingerprint": Fingerprint(key)}, nil
|
return map[string]any{"licence": name, "kind": "api-key", "adopted": true, "fingerprint": Fingerprint(key)}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Licences is each licence as a person reads it: health and who is bound, never a token.
|
// Licences is each licence as a person reads it (design 39 bus-and-switch §34, §47, B19, B40): today's fields, its
|
||||||
|
// latest usage with its reset times, its room or why it is not known, whether it is the target a full licence's
|
||||||
|
// consumers move to next, when and from where it was adopted, and the conditions open about it. Never a token.
|
||||||
func (m *Manager) Licences(ctx context.Context) ([]map[string]any, error) {
|
func (m *Manager) Licences(ctx context.Context) ([]map[string]any, error) {
|
||||||
all, err := m.Store.Licences(ctx)
|
all, err := m.Store.Licences(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -776,27 +954,52 @@ func (m *Manager) Licences(ctx context.Context) ([]map[string]any, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
open, err := m.KeptConditions()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
s := m.settings()
|
||||||
stamp := func(ms int64) any {
|
stamp := func(ms int64) any {
|
||||||
if ms == 0 {
|
if ms == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return time.UnixMilli(ms).UTC().Format(time.RFC3339)
|
return time.UnixMilli(ms).UTC().Format(time.RFC3339)
|
||||||
}
|
}
|
||||||
|
targets := map[string]bool{}
|
||||||
|
for _, l := range all {
|
||||||
|
if len(consumersOf(l.Name, bindings)) > 0 {
|
||||||
|
if to, _ := m.targetFor(l, all, s); to != "" {
|
||||||
|
targets[to] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
out := []map[string]any{}
|
out := []map[string]any{}
|
||||||
for _, l := range all {
|
for _, l := range all {
|
||||||
bound := []string{}
|
bound := consumersOf(l.Name, bindings)
|
||||||
for _, b := range bindings {
|
if bound == nil {
|
||||||
if b.Licence == l.Name {
|
bound = []string{}
|
||||||
bound = append(bound, b.Consumer)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
account := l.Email
|
account := l.Email
|
||||||
if account == "" {
|
if account == "" {
|
||||||
account = l.AccountUUID
|
account = l.AccountUUID
|
||||||
}
|
}
|
||||||
|
var room any = m.roomWords(l, s)
|
||||||
|
if r, known := Room(l, m.Now(), s); known {
|
||||||
|
room = r
|
||||||
|
}
|
||||||
|
conditions := []map[string]any{}
|
||||||
|
prefix := "licence." + KeyFor(l.Name) + "."
|
||||||
|
for _, c := range open {
|
||||||
|
if strings.HasPrefix(c.Body.Key, prefix) {
|
||||||
|
conditions = append(conditions, map[string]any{"key": c.Body.Key, "severity": c.Body.Severity,
|
||||||
|
"headline": c.Body.Headline, "since": c.Since})
|
||||||
|
}
|
||||||
|
}
|
||||||
out = append(out, map[string]any{"name": l.Name, "kind": l.Kind, "account": account,
|
out = append(out, map[string]any{"name": l.Name, "kind": l.Kind, "account": account,
|
||||||
"accessExpiresAt": stamp(l.AccessExpiresAt), "refreshExpiresAt": stamp(l.RefreshExpiresAt),
|
"organisation": l.OrganizationUUID, "accessExpiresAt": stamp(l.AccessExpiresAt),
|
||||||
"rotatedAt": stamp(l.RotatedAt), "failures": l.Failures, "bound": bound})
|
"refreshExpiresAt": stamp(l.RefreshExpiresAt), "rotatedAt": stamp(l.RotatedAt), "failures": l.Failures,
|
||||||
|
"bound": bound, "usage": l.Usage, "room": room, "target": targets[l.Name], "adoptedAt": stamp(l.AdoptedAt),
|
||||||
|
"adoptedFrom": l.AdoptedFrom, "conditions": conditions})
|
||||||
}
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,9 @@ type stubVendor struct {
|
|||||||
issued int
|
issued int
|
||||||
account string
|
account string
|
||||||
now func() time.Time
|
now func() time.Time
|
||||||
|
// usage is what the usage endpoint answers, and usageErr its failure; nil answers a session window alone.
|
||||||
|
usage map[string]any
|
||||||
|
usageErr error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (v *stubVendor) Refresh(_ context.Context, g FullGrant) Refreshed {
|
func (v *stubVendor) Refresh(_ context.Context, g FullGrant) Refreshed {
|
||||||
@@ -44,31 +47,90 @@ func (v *stubVendor) Refresh(_ context.Context, g FullGrant) Refreshed {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (v *stubVendor) Usage(context.Context, string) (map[string]any, error) {
|
func (v *stubVendor) Usage(context.Context, string) (map[string]any, error) {
|
||||||
|
if v.usageErr != nil {
|
||||||
|
return nil, v.usageErr
|
||||||
|
}
|
||||||
|
if v.usage != nil {
|
||||||
|
return v.usage, nil
|
||||||
|
}
|
||||||
return map[string]any{"five_hour": map[string]any{"utilization": 12.0}}, nil
|
return map[string]any{"five_hour": map[string]any{"utilization": 12.0}}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// memGrants is the vault as the manager sees it: every licence's two secrets by name, the newest generation kept.
|
||||||
|
type memGrants struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
held map[string]heldGrant
|
||||||
|
puts []string
|
||||||
|
// refuseAccess is the vault refusing a licence's access token to the manager's machine, as it does for a licence no
|
||||||
|
// binding there names (§17).
|
||||||
|
refuseAccess bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type heldGrant struct {
|
||||||
|
generation int64
|
||||||
|
value string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *memGrants) Put(_ context.Context, key, part string, generation int64, value string) error {
|
||||||
|
g.mu.Lock()
|
||||||
|
defer g.mu.Unlock()
|
||||||
|
name := SecretName(key, part)
|
||||||
|
if h, ok := g.held[name]; ok && h.generation == generation && h.value == value {
|
||||||
|
return nil // the vault keeps what it holds, unchanged
|
||||||
|
} else if ok && h.generation >= generation {
|
||||||
|
return fmt.Errorf("%s is held at generation %d, and generation %d is not newer", name, h.generation, generation)
|
||||||
|
}
|
||||||
|
g.held[name] = heldGrant{generation, value}
|
||||||
|
g.puts = append(g.puts, fmt.Sprintf("%s@%d", name, generation))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *memGrants) Get(_ context.Context, key, part string) (string, error) {
|
||||||
|
g.mu.Lock()
|
||||||
|
defer g.mu.Unlock()
|
||||||
|
h, ok := g.held[SecretName(key, part)]
|
||||||
|
if g.refuseAccess && part == PartAccess {
|
||||||
|
return "", fmt.Errorf("no consumer on this machine is bound to the licence %s", key)
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return "", fmt.Errorf("the vault holds no secret named %s", SecretName(key, part))
|
||||||
|
}
|
||||||
|
return h.value, nil
|
||||||
|
}
|
||||||
|
|
||||||
type miniMesh struct {
|
type miniMesh struct {
|
||||||
m *Manager
|
grants *memGrants
|
||||||
store *MemoryStore
|
m *Manager
|
||||||
vendor *stubVendor
|
store *BusStore
|
||||||
logins map[string]FullGrant // node → what its credentials file holds
|
usage *memLog
|
||||||
state map[string]BindingState
|
audit *memLog
|
||||||
events []string
|
licences *memState
|
||||||
now time.Time
|
bindings *memState
|
||||||
keys KeyPair
|
leases *memState
|
||||||
askDown bool
|
offered *memState
|
||||||
|
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 {
|
func newMesh(t *testing.T) *miniMesh {
|
||||||
t.Helper()
|
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 }
|
now := func() time.Time { return mm.now }
|
||||||
mm.store = 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, UsageLog: mm.usage, AuditLog: mm.audit, Now: now}
|
||||||
mm.vendor = &stubVendor{live: map[string]bool{}, now: now}
|
mm.vendor = &stubVendor{live: map[string]bool{}, now: now}
|
||||||
crypt, _ := NewCrypt("a key the vault made")
|
crypt, _ := NewCrypt("a key the vault made")
|
||||||
mm.keys, _ = GenerateKeyPair()
|
mm.keys, _ = GenerateKeyPair()
|
||||||
mm.m = &Manager{
|
mm.m = &Manager{
|
||||||
Store: mm.store, Vendor: mm.vendor, Crypt: crypt, Keys: mm.keys,
|
Store: mm.store, Vendor: mm.vendor, Crypt: crypt, Grants: mm.grants, Keys: mm.keys,
|
||||||
AskGrant: func(_ context.Context, node, publicKey string) (GrantAnswer, error) {
|
AskGrant: func(_ context.Context, node, publicKey string) (GrantAnswer, error) {
|
||||||
if mm.askDown {
|
if mm.askDown {
|
||||||
return GrantAnswer{}, fmt.Errorf("503 no responders")
|
return GrantAnswer{}, fmt.Errorf("503 no responders")
|
||||||
@@ -81,8 +143,6 @@ func newMesh(t *testing.T) *miniMesh {
|
|||||||
box, err := Seal(string(raw), publicKey)
|
box, err := Seal(string(raw), publicKey)
|
||||||
return GrantAnswer{Sealed: &box, Fingerprint: Fingerprint(g.RefreshToken)}, err
|
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 {
|
Emit: func(event string, body map[string]any) error {
|
||||||
raw, _ := json.Marshal(body)
|
raw, _ := json.Marshal(body)
|
||||||
mm.events = append(mm.events, event+" "+string(raw))
|
mm.events = append(mm.events, event+" "+string(raw))
|
||||||
@@ -93,6 +153,15 @@ func newMesh(t *testing.T) *miniMesh {
|
|||||||
return mm
|
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 {
|
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"},
|
h := Holdings{Node: node, Identity: &Identity{AccountUUID: account, EmailAddress: account + "@example.org"},
|
||||||
Kind: "subscription", ChangedAt: at.Format(time.RFC3339Nano)}
|
Kind: "subscription", ChangedAt: at.Format(time.RFC3339Nano)}
|
||||||
@@ -129,15 +198,14 @@ func TestAManagerWithNoLicenceAdoptsTheNewestLoginThatRefreshesAndNeverExchanges
|
|||||||
}
|
}
|
||||||
// A first binding follows the login: both nodes reported this account and were bound to nothing.
|
// A first binding follows the login: both nodes reported this account and were bound to nothing.
|
||||||
bs, _ := mm.store.Bindings(ctx)
|
bs, _ := mm.store.Bindings(ctx)
|
||||||
if len(bs) != 2 || mm.state["laptop"].Licence != licence1 || mm.state["server"].Licence != licence1 {
|
if len(bs) != 2 || mm.bound("laptop").Licence != licence1 || mm.bound("server").Licence != licence1 {
|
||||||
t.Fatalf("bindings %v, state %v", bs, mm.state)
|
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).
|
// The adoption is said, and so is each binding it made, once (novox/hq issue 625; design 39 bus-and-switch §21).
|
||||||
events := strings.Join(mm.events, "|")
|
events := strings.Join(mm.events, "|")
|
||||||
for _, want := range []string{"licence.adopted ", `licence.bound {"by":"claude-licence-manager","consumer":"laptop"`,
|
for _, want := range []string{"licence.adopted ", `"consumer":"laptop"`, `"consumer":"server"`} {
|
||||||
`licence.bound {"by":"claude-licence-manager","consumer":"server"`} {
|
if strings.Count(events, want) != 1 {
|
||||||
if !strings.Contains(events, want) {
|
t.Fatalf("events say %s other than once: %v", want, mm.events)
|
||||||
t.Fatalf("events lack %s: %v", want, mm.events)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -169,6 +237,12 @@ func TestANewerLoginReplacesTheGrantHeldAndAnOlderOneDoesNot(t *testing.T) {
|
|||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0.Add(-time.Minute))})
|
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0.Add(-time.Minute))})
|
||||||
before, _ := mm.store.Licence(ctx, licence1)
|
before, _ := mm.store.Licence(ctx, licence1)
|
||||||
|
// §2: a licence says where it was adopted from, and a newer login keeps the latest reading the key holds.
|
||||||
|
pct := 42.0
|
||||||
|
before.Usage = &UsageReading{SessionPct: &pct, ReadAt: t0.Format(time.RFC3339Nano)}
|
||||||
|
if before.AdoptedFrom != "laptop" || mm.store.SaveLicence(ctx, *before) != nil {
|
||||||
|
t.Fatalf("the adopted licence is %+v", before)
|
||||||
|
}
|
||||||
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("server", "rt-old", true, t0.Add(-24*time.Hour))})
|
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("server", "rt-old", true, t0.Add(-24*time.Hour))})
|
||||||
if strings.Join(mm.vendor.exchanged, ",") != "rt-a" {
|
if strings.Join(mm.vendor.exchanged, ",") != "rt-a" {
|
||||||
t.Fatalf("an older login was exchanged: %v", mm.vendor.exchanged)
|
t.Fatalf("an older login was exchanged: %v", mm.vendor.exchanged)
|
||||||
@@ -179,6 +253,9 @@ func TestANewerLoginReplacesTheGrantHeldAndAnOlderOneDoesNot(t *testing.T) {
|
|||||||
if after.RefreshFingerprint == before.RefreshFingerprint || mm.vendor.exchanged[len(mm.vendor.exchanged)-1] != "rt-new" {
|
if after.RefreshFingerprint == before.RefreshFingerprint || mm.vendor.exchanged[len(mm.vendor.exchanged)-1] != "rt-new" {
|
||||||
t.Fatalf("a newer login did not win: %v", mm.vendor.exchanged)
|
t.Fatalf("a newer login did not win: %v", mm.vendor.exchanged)
|
||||||
}
|
}
|
||||||
|
if after.AdoptedFrom != "desktop" || after.Usage == nil || after.Usage.SessionPct == nil || *after.Usage.SessionPct != 42 {
|
||||||
|
t.Fatalf("after a newer login the licence is %+v", after)
|
||||||
|
}
|
||||||
if ls, _ := mm.store.Licences(ctx); len(ls) != 1 {
|
if ls, _ := mm.store.Licences(ctx); len(ls) != 1 {
|
||||||
t.Fatalf("one account became %d licences", len(ls))
|
t.Fatalf("one account became %d licences", len(ls))
|
||||||
}
|
}
|
||||||
@@ -201,11 +278,12 @@ func TestTwoRefreshRunsStartedTogetherRotateAGrantOnce(t *testing.T) {
|
|||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0)})
|
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0)})
|
||||||
mm.now = t0.Add(5 * time.Hour)
|
mm.now = t0.Add(5 * time.Hour)
|
||||||
second := *mm.m
|
second := &Manager{Store: mm.m.Store, Vendor: mm.m.Vendor, Crypt: mm.m.Crypt, Grants: mm.m.Grants, Keys: mm.m.Keys,
|
||||||
second.Holder = "another run"
|
AskGrant: mm.m.AskGrant, Emit: mm.m.Emit, Now: mm.m.Now, Log: mm.m.Log, Holder: "another run",
|
||||||
|
Settings: mm.m.Settings}
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
results := make([]map[string]any, 2)
|
results := make([]map[string]any, 2)
|
||||||
for i, m := range []*Manager{mm.m, &second} {
|
for i, m := range []*Manager{mm.m, second} {
|
||||||
wg.Add(1)
|
wg.Add(1)
|
||||||
go func() { defer wg.Done(); results[i], _ = m.Rotate(ctx, licence1, false) }()
|
go func() { defer wg.Done(); results[i], _ = m.Rotate(ctx, licence1, false) }()
|
||||||
}
|
}
|
||||||
@@ -228,9 +306,9 @@ func TestARotationAndASwitchEachGiveANewerGeneration(t *testing.T) {
|
|||||||
mm := newMesh(t)
|
mm := newMesh(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0)})
|
_, _ = 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)
|
_, _ = mm.m.Rotate(ctx, licence1, true)
|
||||||
g1 := mm.state["laptop"].Generation
|
g1 := mm.bound("laptop").Generation
|
||||||
if g1 <= g0 {
|
if g1 <= g0 {
|
||||||
t.Fatalf("a rotation went from %d to %d", g0, g1)
|
t.Fatalf("a rotation went from %d to %d", g0, g1)
|
||||||
}
|
}
|
||||||
@@ -242,43 +320,54 @@ func TestARotationAndASwitchEachGiveANewerGeneration(t *testing.T) {
|
|||||||
if _, err := mm.m.Bind(ctx, "laptop", "api"); err != nil {
|
if _, err := mm.m.Bind(ctx, "laptop", "api"); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if mm.state["laptop"].Licence != "api" || mm.state["laptop"].Generation <= g1 {
|
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.state["laptop"])
|
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 {
|
if _, err := mm.m.Release(ctx, "laptop"); err != nil {
|
||||||
t.Fatal(err)
|
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")
|
t.Fatal("a released consumer still has a binding in the state")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCurrentHandsAnAccessTokenOnlySealedToTheConsumersKey(t *testing.T) {
|
// design 39 bus-and-switch §18, B7, B19: no answer of current holds a token; it names the licence's key and health.
|
||||||
|
func TestCurrentHoldsNoToken(t *testing.T) {
|
||||||
mm := newMesh(t)
|
mm := newMesh(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0)})
|
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0)})
|
||||||
node, _ := GenerateKeyPair()
|
answer, err := mm.m.Current(ctx, "laptop")
|
||||||
answer, err := mm.m.Current(ctx, "laptop", node.PublicKey)
|
if err != nil || answer["kind"] != "subscription" || answer["key"] != KeyFor(licence1) || answer["health"] != "ok" ||
|
||||||
if err != nil || answer["kind"] != "subscription" {
|
answer["accessExpiresAt"] == nil || answer["identity"] == nil {
|
||||||
t.Fatalf("%v %v", answer, err)
|
t.Fatalf("%v %v", answer, err)
|
||||||
}
|
}
|
||||||
box := answer["sealed"].(SealedBox)
|
raw, _ := json.Marshal(answer)
|
||||||
plain, err := Open(box, node.PrivateKey)
|
for _, token := range []string{"rt-", "at-", "sealed"} {
|
||||||
if err != nil {
|
if strings.Contains(string(raw), token) {
|
||||||
t.Fatal(err)
|
t.Fatalf("current answered %s: %s", token, raw)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
var handed map[string]any
|
l, _ := mm.store.Licence(ctx, licence1)
|
||||||
_ = json.Unmarshal([]byte(plain), &handed)
|
for failures, health := range map[int]string{0: "ok", 3: "failing"} {
|
||||||
if !strings.HasPrefix(handed["accessToken"].(string), "at-") || handed["refreshToken"] != nil {
|
l.Failures = failures
|
||||||
t.Fatalf("handed %v", handed)
|
if got := Health(*l, mm.now, mm.m.Settings); got != health {
|
||||||
|
t.Errorf("%d failures: %s, want %s", failures, got, health)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
other, _ := GenerateKeyPair()
|
l.Failures, l.RefreshExpiresAt = 0, mm.now.Add(48*time.Hour).UnixMilli()
|
||||||
if _, err := Open(box, other.PrivateKey); err == nil {
|
if got := Health(*l, mm.now, mm.m.Settings); got != "expiring" {
|
||||||
t.Fatal("the hand-over opened with another key")
|
t.Errorf("a refresh token expiring in two days: %s", got)
|
||||||
}
|
}
|
||||||
if a, _ := mm.m.Current(ctx, "nobody", node.PublicKey); a != nil {
|
if a, _ := mm.m.Current(ctx, "nobody"); a != nil {
|
||||||
t.Fatalf("a consumer bound to nothing was answered %v", a)
|
t.Fatalf("a consumer bound to nothing was answered %v", a)
|
||||||
}
|
}
|
||||||
|
if bs, _ := mm.m.BindingsOf(ctx, "laptop"); len(bs) != 1 || bs[0].Consumer != "laptop" {
|
||||||
|
t.Fatalf("bindings of laptop: %v", bs)
|
||||||
|
}
|
||||||
|
// design 39 bus-and-switch B25: the verb bindings answers each binding's licence key beside the §3 fields.
|
||||||
|
if bs, _ := mm.m.BindingsOf(ctx, ""); len(bs) == 0 || bs[0].Key != KeyFor(bs[0].Licence) {
|
||||||
|
t.Fatalf("bindings answered %+v", bs)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNothingTheManagerPublishesKeepsOrListsCarriesAToken(t *testing.T) {
|
func TestNothingTheManagerPublishesKeepsOrListsCarriesAToken(t *testing.T) {
|
||||||
@@ -290,15 +379,15 @@ func TestNothingTheManagerPublishesKeepsOrListsCarriesAToken(t *testing.T) {
|
|||||||
_ = mm.m.ReadUsage(ctx)
|
_ = mm.m.ReadUsage(ctx)
|
||||||
ls, _ := mm.m.Licences(ctx)
|
ls, _ := mm.m.Licences(ctx)
|
||||||
bs, _ := mm.store.Bindings(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-"} {
|
for _, token := range []string{"rt-secret", "rt-1", "rt-2", "at-1", "at-2", "local-"} {
|
||||||
if strings.Contains(string(everything), token) {
|
if strings.Contains(string(everything), token) {
|
||||||
t.Fatalf("%s was published: %s", token, everything)
|
t.Fatalf("%s was published: %s", token, everything)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
row, _ := mm.store.Licence(ctx, licence1)
|
row, _ := mm.store.Licence(ctx, licence1)
|
||||||
if strings.Contains(row.Sealed, "rt-") {
|
if raw, _ := json.Marshal(row); strings.Contains(string(raw), "rt-") || strings.Contains(string(raw), "at-") {
|
||||||
t.Fatal("the grant is stored in the clear")
|
t.Fatalf("the licence keeps a token: %s", raw)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -324,22 +413,40 @@ func TestANodeThatDoesNotAnswerIsAskedAgainAndOneWithNoLoginIsSettled(t *testing
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAReportIsReadAsTheAgentModuleWritesIt(t *testing.T) {
|
// design 39 bus-and-switch §23, §46, B12, B22: a holdings-changed, decoded from a copy of the agent module's form, is
|
||||||
// The agent module's own report shape (claude-code's holdingsOf), parsed here.
|
// kept as its node's latest report, and one carrying a fingerprint the manager does not hold is a candidate; the
|
||||||
raw := `{"node":"laptop","identity":{"accountUuid":"u-1","emailAddress":"a@example.org"},"kind":"subscription",
|
// manager asks every node with holdings.asked, its time and who asks.
|
||||||
"refresh":{"present":true,"fingerprint":"sha256:0123456789abcdef","expiresAt":null},
|
func TestAHoldingsChangedIsHeardAndMakesACandidate(t *testing.T) {
|
||||||
"access":{"fingerprint":"sha256:fedcba9876543210","expiresAt":1},"licence":null,"generation":0,
|
mm := newMesh(t)
|
||||||
"changedAt":"2026-10-04T11:00:00.000Z"}`
|
raw, err := os.ReadFile("testdata/holdings-changed.json")
|
||||||
var h Holdings
|
if err != nil {
|
||||||
if err := json.Unmarshal([]byte(raw), &h); err != nil {
|
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
heard := &Heard{}
|
||||||
|
if _, err := heard.Take(raw); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
all := heard.All()
|
||||||
|
if len(all) != 1 || heard.Of("laptop") != 4 {
|
||||||
|
t.Fatalf("heard %+v", all)
|
||||||
|
}
|
||||||
|
h := all[0]
|
||||||
if h.Node != "laptop" || h.Identity.AccountUUID != "u-1" || !h.Refresh.Present || h.Refresh.Fingerprint != "sha256:0123456789abcdef" {
|
if h.Node != "laptop" || h.Identity.AccountUUID != "u-1" || !h.Refresh.Present || h.Refresh.Fingerprint != "sha256:0123456789abcdef" {
|
||||||
t.Fatalf("%+v", h)
|
t.Fatalf("%+v", h)
|
||||||
}
|
}
|
||||||
if _, err := time.Parse(time.RFC3339Nano, h.ChangedAt); err != nil {
|
if _, err := time.Parse(time.RFC3339Nano, h.ChangedAt); err != nil {
|
||||||
t.Fatalf("the report's time does not parse: %v", err)
|
t.Fatalf("the report's time does not parse: %v", err)
|
||||||
}
|
}
|
||||||
|
if c, err := mm.m.CandidatesIn(context.Background(), all); err != nil || len(c["u-1"]) != 1 {
|
||||||
|
t.Fatalf("candidates %v %v", c, err)
|
||||||
|
}
|
||||||
|
if _, err := heard.Take([]byte(`{"kind":"subscription"}`)); err == nil {
|
||||||
|
t.Fatal("a report naming no node was kept")
|
||||||
|
}
|
||||||
|
if err := mm.m.AskHoldings(Itself); err != nil || len(mm.events) != 1 ||
|
||||||
|
mm.events[0] != HoldingsAsked+` {"at":"`+t0.UTC().Format(time.RFC3339Nano)+`","by":"claude-licence-manager"}` {
|
||||||
|
t.Fatalf("said %v %v", mm.events, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// A node whose report arrives after its account was adopted — with an older login, so never a candidate —
|
// A node whose report arrives after its account was adopted — with an older login, so never a candidate —
|
||||||
@@ -350,15 +457,15 @@ func TestANodeReportingAnAdoptedAccountLaterIsBoundToIt(t *testing.T) {
|
|||||||
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("novox", "rt-new", true, t0)})
|
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("novox", "rt-new", true, t0)})
|
||||||
late := mm.login("laptop", "rt-older", true, t0.Add(-24*time.Hour))
|
late := mm.login("laptop", "rt-older", true, t0.Add(-24*time.Hour))
|
||||||
_, _ = mm.m.Consider(ctx, []Holdings{late})
|
_, _ = mm.m.Consider(ctx, []Holdings{late})
|
||||||
if mm.state["laptop"].Licence != licence1 {
|
if mm.bound("laptop").Licence != licence1 {
|
||||||
t.Fatalf("a node reporting the adopted account later was not bound: %v", mm.state)
|
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") {
|
if strings.Contains(strings.Join(mm.vendor.exchanged, ","), "rt-older") {
|
||||||
t.Fatal("the older login was exchanged")
|
t.Fatal("the older login was exchanged")
|
||||||
}
|
}
|
||||||
g := mm.state["laptop"].Generation
|
g := mm.bound("laptop").Generation
|
||||||
_, _ = mm.m.Consider(ctx, []Holdings{late})
|
_, _ = 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")
|
t.Fatal("a node already bound was bound again")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -368,8 +475,8 @@ func TestALoginToAnotherAccountMovesItsNodeAndNoOther(t *testing.T) {
|
|||||||
mm := newMesh(t)
|
mm := newMesh(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0), mm.report("server", "", t0, "acct-1")})
|
_, _ = 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 {
|
if mm.bound("laptop").Licence != licence1 || mm.bound("server").Licence != licence1 {
|
||||||
t.Fatalf("%v", mm.state)
|
t.Fatalf("%v", mm.bindings.values)
|
||||||
}
|
}
|
||||||
mm.now = t0.Add(time.Hour)
|
mm.now = t0.Add(time.Hour)
|
||||||
mm.logins["laptop"] = FullGrant{AccessToken: "local", RefreshToken: "rt-b", ExpiresAt: 1}
|
mm.logins["laptop"] = FullGrant{AccessToken: "local", RefreshToken: "rt-b", ExpiresAt: 1}
|
||||||
@@ -379,11 +486,11 @@ func TestALoginToAnotherAccountMovesItsNodeAndNoOther(t *testing.T) {
|
|||||||
if len(adopted) != 1 || adopted[0] != "acct-2@example.org" {
|
if len(adopted) != 1 || adopted[0] != "acct-2@example.org" {
|
||||||
t.Fatalf("adopted %v", adopted)
|
t.Fatalf("adopted %v", adopted)
|
||||||
}
|
}
|
||||||
if mm.state["laptop"].Licence != "acct-2@example.org" {
|
if mm.bound("laptop").Licence != "acct-2@example.org" {
|
||||||
t.Fatalf("the node a login was made on stayed bound to %v", mm.state["laptop"])
|
t.Fatalf("the node a login was made on stayed bound to %v", mm.bound("laptop"))
|
||||||
}
|
}
|
||||||
if mm.state["server"].Licence != licence1 {
|
if mm.bound("server").Licence != licence1 {
|
||||||
t.Fatalf("another node moved: %v", mm.state["server"])
|
t.Fatalf("another node moved: %v", mm.bound("server"))
|
||||||
}
|
}
|
||||||
ls, _ := mm.store.Licences(ctx)
|
ls, _ := mm.store.Licences(ctx)
|
||||||
if len(ls) != 2 {
|
if len(ls) != 2 {
|
||||||
@@ -396,11 +503,11 @@ func TestALoginThatDoesNotRefreshMovesNothing(t *testing.T) {
|
|||||||
mm := newMesh(t)
|
mm := newMesh(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0)})
|
_, _ = 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.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")})
|
_, _ = mm.m.Consider(ctx, []Holdings{mm.report("laptop", "rt-dead", t0.Add(time.Hour), "acct-2")})
|
||||||
if mm.state["laptop"] != g {
|
if mm.bound("laptop") != g {
|
||||||
t.Fatalf("a dead login moved its node: %v", mm.state["laptop"])
|
t.Fatalf("a dead login moved its node: %v", mm.bound("laptop"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -413,9 +520,10 @@ func TestAnAPIKeySealedByANodeIsAdopted(t *testing.T) {
|
|||||||
if err != nil || r["adopted"] != true {
|
if err != nil || r["adopted"] != true {
|
||||||
t.Fatalf("%v %v", r, err)
|
t.Fatalf("%v %v", r, err)
|
||||||
}
|
}
|
||||||
l, _ := mm.store.Licence(ctx, "api")
|
for _, part := range []string{PartGrant, PartAccess} {
|
||||||
if plain, _ := mm.m.Crypt.Open(l.Sealed); plain != "sk-ant-api03-test-0123456789abcdefghij" {
|
if plain, _ := mm.grants.Get(ctx, "api", part); plain != "sk-ant-api03-test-0123456789abcdefghij" {
|
||||||
t.Fatalf("stored %q", plain)
|
t.Fatalf("the vault holds %q as the %s", plain, part)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
other, _ := GenerateKeyPair()
|
other, _ := GenerateKeyPair()
|
||||||
wrong, _ := Seal("sk", other.PublicKey)
|
wrong, _ := Seal("sk", other.PublicKey)
|
||||||
@@ -439,7 +547,7 @@ func TestARebuiltStoreNeverGivesAGenerationANodeHasPassed(t *testing.T) {
|
|||||||
if _, err := mm.m.Consider(ctx, []Holdings{laptop}); err != nil {
|
if _, err := mm.m.Consider(ctx, []Holdings{laptop}); err != nil {
|
||||||
t.Fatal(err)
|
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)
|
t.Fatalf("the laptop applied generation 16 and was given %d", g)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -460,28 +568,63 @@ func TestWhatIsNotAnAPIKeyIsRefused(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// changedSince is the binding.changed events said from mark on, decoded.
|
||||||
|
func (mm *miniMesh) changedSince(mark int) []map[string]any {
|
||||||
|
var out []map[string]any
|
||||||
|
for _, e := range mm.events[mark:] {
|
||||||
|
if body, ok := strings.CutPrefix(e, "binding.changed "); ok {
|
||||||
|
var v map[string]any
|
||||||
|
_ = json.Unmarshal([]byte(body), &v)
|
||||||
|
out = append(out, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// A change of what a consumer holds says what, why and who in the journal, the audit and an event (novox/hq
|
// A change of what a consumer holds says what, why and who in the journal, the audit and an event (novox/hq
|
||||||
// issue 625): a login, a switch through the verb by a proven caller, a rotation and a release.
|
// issue 625): a login, a switch through the verb by a proven caller, a rotation and a release. design 39
|
||||||
func TestEveryMoveSaysWhatWhyAndWho(t *testing.T) {
|
// bus-and-switch §21/AC1, §36/AC1, B7, B16: each says one binding.changed per consumer moved, with its licence's key,
|
||||||
|
// after its key is written or deleted; a switch says one licence.switched with §27's fields.
|
||||||
|
func TestEveryMoveSaysOneBindingChangedPerConsumer(t *testing.T) {
|
||||||
mm := newMesh(t)
|
mm := newMesh(t)
|
||||||
var lines []string
|
var lines []string
|
||||||
mm.m.Log = func(format string, args ...any) { lines = append(lines, fmt.Sprintf(format, args...)) }
|
mm.m.Log = func(format string, args ...any) { lines = append(lines, fmt.Sprintf(format, args...)) }
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
once := func(mark int, consumer, licence, by string) map[string]any {
|
||||||
|
t.Helper()
|
||||||
|
got := mm.changedSince(mark)
|
||||||
|
if len(got) != 1 || got[0]["consumer"] != consumer || got[0]["licence"] != licence || got[0]["by"] != by {
|
||||||
|
t.Fatalf("binding.changed said %v, want one for %s to %q by %s", got, consumer, licence, by)
|
||||||
|
}
|
||||||
|
if licence != "" && got[0]["key"] != KeyFor(licence) {
|
||||||
|
t.Fatalf("binding.changed names the key %v", got[0]["key"])
|
||||||
|
}
|
||||||
|
if mm.bound(consumer).Licence != licence {
|
||||||
|
t.Fatalf("said before the key was written: %+v", mm.bound(consumer))
|
||||||
|
}
|
||||||
|
return got[0]
|
||||||
|
}
|
||||||
|
mark := len(mm.events)
|
||||||
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0)})
|
_, _ = 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)
|
t.Fatalf("a login's binding said %+v", s)
|
||||||
}
|
}
|
||||||
|
once(mark, "laptop", licence1, Itself)
|
||||||
file := filepath.Join(t.TempDir(), "key")
|
file := filepath.Join(t.TempDir(), "key")
|
||||||
_ = os.WriteFile(file, []byte("sk-ant-api03-key-0123456789abcdefghij\n"), 0o600)
|
_ = os.WriteFile(file, []byte("sk-ant-api03-key-0123456789abcdefghij\n"), 0o600)
|
||||||
if _, err := mm.m.AdoptKey(ctx, "api", file); err != nil {
|
if _, err := mm.m.AdoptKey(ctx, "api", file); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
mark = len(mm.events)
|
||||||
asked := WithCause(ctx, Cause{Why: "the switch verb", By: "g14.node-tools"})
|
asked := WithCause(ctx, Cause{Why: "the switch verb", By: "g14.node-tools"})
|
||||||
if _, err := mm.m.Bind(asked, "laptop", "api"); err != nil {
|
if _, err := mm.m.Bind(asked, "laptop", "api"); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if s := mm.state["laptop"]; s.Why != "the switch verb" || s.By != "g14.node-tools" {
|
if e := once(mark, "laptop", "api", "g14.node-tools"); e["from"] != licence1 || e["kind"] != "api-key" || e["at"] == nil {
|
||||||
|
t.Fatalf("the switch said %v", e)
|
||||||
|
}
|
||||||
|
if s := mm.bound("laptop"); s.Why != "the switch verb" || s.By != "g14.node-tools" {
|
||||||
t.Fatalf("the node is told %+v", s)
|
t.Fatalf("the node is told %+v", s)
|
||||||
}
|
}
|
||||||
said := strings.Join(lines, "\n")
|
said := strings.Join(lines, "\n")
|
||||||
@@ -489,34 +632,46 @@ func TestEveryMoveSaysWhatWhyAndWho(t *testing.T) {
|
|||||||
!strings.Contains(said, "the switch verb; asked by g14.node-tools") {
|
!strings.Contains(said, "the switch verb; asked by g14.node-tools") {
|
||||||
t.Fatalf("the journal said:\n%s", said)
|
t.Fatalf("the journal said:\n%s", said)
|
||||||
}
|
}
|
||||||
var switched string
|
var switched []string
|
||||||
for _, e := range mm.events {
|
for _, e := range mm.events {
|
||||||
if strings.HasPrefix(e, "licence.switched ") {
|
if strings.HasPrefix(e, "licence.switched ") {
|
||||||
switched = e
|
switched = append(switched, e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, want := range []string{`"by":"g14.node-tools"`, `"from":"` + licence1 + `"`, `"licence":"api"`, `"consumer":"laptop"`, `"generation":`} {
|
if len(switched) != 1 {
|
||||||
if !strings.Contains(switched, want) {
|
t.Fatalf("licence.switched said %d times: %v", len(switched), mm.events)
|
||||||
t.Fatalf("the event %q lacks %s; events %v", switched, want, mm.events)
|
}
|
||||||
|
for _, want := range []string{`"by":"g14.node-tools"`, `"from":"` + licence1 + `"`, `"to":"api"`, `"consumers":["laptop"]`, `"why":"the switch verb"`} {
|
||||||
|
if !strings.Contains(switched[0], want) {
|
||||||
|
t.Fatalf("the event %q lacks %s", switched[0], want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
last := mm.store.Audits[len(mm.store.Audits)-1]
|
all := mm.audit.under("switched")
|
||||||
if raw, _ := json.Marshal(last); !strings.Contains(string(raw), `"by":"g14.node-tools"`) || !strings.Contains(string(raw), "switched") {
|
if raw, _ := json.Marshal(all[len(all)-1]); !strings.Contains(string(raw), `"by":"g14.node-tools"`) || !strings.Contains(string(raw), `"to":"api"`) {
|
||||||
t.Fatalf("the audit kept %s", raw)
|
t.Fatalf("the audit kept %s", raw)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_, _ = mm.m.Bind(ctx, "server", licence1)
|
||||||
|
mark = len(mm.events)
|
||||||
_, _ = mm.m.Rotate(WithCause(ctx, Cause{Why: "the refresh verb", By: "novox.node-tools"}), licence1, true)
|
_, _ = mm.m.Rotate(WithCause(ctx, Cause{Why: "the refresh verb", By: "novox.node-tools"}), licence1, true)
|
||||||
if !strings.Contains(strings.Join(mm.events, "|"), `licence.rotated {"by":"novox.node-tools"`) {
|
once(mark, "server", licence1, "novox.node-tools")
|
||||||
t.Fatalf("a rotation asked for said no caller: %v", mm.events)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
mark = len(mm.events)
|
||||||
if _, err := mm.m.Release(WithCause(ctx, Cause{Why: "the release verb", By: "person.operator"}), "laptop"); err != nil {
|
if _, err := mm.m.Release(WithCause(ctx, Cause{Why: "the release verb", By: "person.operator"}), "laptop"); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
if e := once(mark, "laptop", "", "person.operator"); e["from"] != "api" {
|
||||||
|
t.Fatalf("the release said %v", e)
|
||||||
|
}
|
||||||
if said := strings.Join(lines, "\n"); !strings.Contains(said, "released laptop from api: the release verb; asked by person.operator") {
|
if said := strings.Join(lines, "\n"); !strings.Contains(said, "released laptop from api: the release verb; asked by person.operator") {
|
||||||
t.Fatalf("the journal said:\n%s", said)
|
t.Fatalf("the journal said:\n%s", said)
|
||||||
}
|
}
|
||||||
for _, e := range mm.events {
|
for _, e := range mm.events {
|
||||||
|
for _, gone := range []string{"licence.bound ", "licence.released ", "licence.rotated "} {
|
||||||
|
if strings.HasPrefix(e, gone) {
|
||||||
|
t.Fatalf("an event no longer said was said: %s", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
if strings.Contains(e, "rt-a") || strings.Contains(e, "sk-ant-") || strings.Contains(e, "local-laptop") {
|
if strings.Contains(e, "rt-a") || strings.Contains(e, "sk-ant-") || strings.Contains(e, "local-laptop") {
|
||||||
t.Fatalf("an event carries a secret: %s", e)
|
t.Fatalf("an event carries a secret: %s", e)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// design 39 bus-and-switch §1: the module moves to the new form whole — no `reads`, no old `state`, no `prepare`
|
||||||
|
// step — and asks for each piece of its kept data by template.
|
||||||
|
func TestTheManifestKeepsNoOldForm(t *testing.T) {
|
||||||
|
raw, err := os.ReadFile(filepath.Join(moduleDir, "module.json"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var m map[string]json.RawMessage
|
||||||
|
if err := json.Unmarshal(raw, &m); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, old := range []string{"reads", "state"} {
|
||||||
|
if _, ok := m[old]; ok {
|
||||||
|
t.Errorf("the manifest keeps the old section %q", old)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var resources []struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
}
|
||||||
|
_ = json.Unmarshal(m["resources"], &resources)
|
||||||
|
for _, r := range resources {
|
||||||
|
if r.ID == "prepare" {
|
||||||
|
t.Error("the manifest keeps the run-once step prepare")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var requires []json.RawMessage
|
||||||
|
_ = json.Unmarshal(m["requires"], &requires)
|
||||||
|
kept := map[string]string{}
|
||||||
|
for _, r := range requires {
|
||||||
|
var e struct{ Name, Template string }
|
||||||
|
if json.Unmarshal(r, &e) == nil {
|
||||||
|
kept[e.Name] = e.Template
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for name, template := range map[string]string{"licences": "state", "bindings": "state", "leases": "state",
|
||||||
|
"offered": "state", "usage": "log", "audit": "log", "carried": "state", "conditions": "state"} {
|
||||||
|
if kept[name] != template {
|
||||||
|
t.Errorf("bus-state entry %s is %q, want template %s", name, kept[name], template)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,71 +1,27 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
// The store on the mesh's postgres (novox/hq design 39 §1), the database the mesh provisioned for this
|
// Today's database, read once by the carry-over and never written (design 39 bus-and-switch §1, §11, A3): the
|
||||||
// module. The schema is brought to this version's shape by the preparation step (ADR 0135), each
|
// module's own, opened with the credential the mesh still provisions for it alone. Every query is a select; a table
|
||||||
// statement idempotent.
|
// that does not exist (a fresh install) is read as no rows.
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgconn"
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Schema is what this version needs.
|
// PgSource reads today's database.
|
||||||
var Schema = []string{
|
type PgSource struct{ pool *pgxpool.Pool }
|
||||||
`create table if not exists licence (
|
|
||||||
name text primary key,
|
|
||||||
kind text not null check (kind in ('subscription', 'api-key')),
|
|
||||||
account_uuid text unique,
|
|
||||||
email text,
|
|
||||||
organization_uuid text,
|
|
||||||
sealed text,
|
|
||||||
refresh_fingerprint text,
|
|
||||||
access_expires_at bigint,
|
|
||||||
refresh_expires_at bigint,
|
|
||||||
failures integer not null default 0,
|
|
||||||
notified_at bigint,
|
|
||||||
adopted_at bigint not null,
|
|
||||||
rotated_at bigint)`,
|
|
||||||
`create sequence if not exists binding_generation`,
|
|
||||||
`create table if not exists binding (
|
|
||||||
consumer text primary key,
|
|
||||||
licence text not null references licence(name),
|
|
||||||
generation bigint not null)`,
|
|
||||||
`create table if not exists lease (
|
|
||||||
key text primary key,
|
|
||||||
holder text not null,
|
|
||||||
until timestamptz not null)`,
|
|
||||||
`create table if not exists offered (
|
|
||||||
fingerprint text primary key,
|
|
||||||
node text not null,
|
|
||||||
account_uuid text,
|
|
||||||
outcome text not null,
|
|
||||||
why text not null,
|
|
||||||
at timestamptz not null default now())`,
|
|
||||||
`create table if not exists usage (
|
|
||||||
licence text not null,
|
|
||||||
at bigint not null,
|
|
||||||
reading jsonb not null,
|
|
||||||
raw jsonb not null)`,
|
|
||||||
`create index if not exists usage_by_licence on usage (licence, at desc)`,
|
|
||||||
`create table if not exists audit (
|
|
||||||
at timestamptz not null default now(),
|
|
||||||
what text not null,
|
|
||||||
detail jsonb not null)`,
|
|
||||||
}
|
|
||||||
|
|
||||||
// PgStore is the store on postgres.
|
// PgSourceFromEnv opens the database the mesh provisioned, its URL in the file DATABASE_URL_FILE names.
|
||||||
type PgStore struct{ pool *pgxpool.Pool }
|
func PgSourceFromEnv(ctx context.Context) (*PgSource, error) {
|
||||||
|
|
||||||
// PgStoreFromEnv opens the store the mesh provisioned, its URL in the file DATABASE_URL_FILE names.
|
|
||||||
func PgStoreFromEnv(ctx context.Context) (*PgStore, error) {
|
|
||||||
file := os.Getenv("DATABASE_URL_FILE")
|
file := os.Getenv("DATABASE_URL_FILE")
|
||||||
if file == "" {
|
if file == "" {
|
||||||
return nil, errors.New("DATABASE_URL_FILE is not set: the manager's database is a requirement the mesh resolves")
|
return nil, errors.New("DATABASE_URL_FILE is not set: the manager's database is a requirement the mesh resolves")
|
||||||
@@ -74,218 +30,132 @@ func PgStoreFromEnv(ctx context.Context) (*PgStore, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return OpenPgStore(ctx, strings.TrimSpace(string(raw)))
|
return OpenPgSource(ctx, strings.TrimSpace(string(raw)))
|
||||||
}
|
}
|
||||||
|
|
||||||
// OpenPgStore opens a store at a URL.
|
// OpenPgSource opens a database at a URL, and reaches it once, so an unreachable one fails here.
|
||||||
func OpenPgStore(ctx context.Context, url string) (*PgStore, error) {
|
func OpenPgSource(ctx context.Context, url string) (*PgSource, error) {
|
||||||
pool, err := pgxpool.New(ctx, url)
|
pool, err := pgxpool.New(ctx, url)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return &PgStore{pool: pool}, nil
|
if err := pool.Ping(ctx); err != nil {
|
||||||
|
pool.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &PgSource{pool: pool}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Migrate brings the schema to this version's shape.
|
// Close lets the database go.
|
||||||
func (s *PgStore) Migrate(ctx context.Context) error {
|
func (s *PgSource) Close() { s.pool.Close() }
|
||||||
for _, q := range Schema {
|
|
||||||
if _, err := s.pool.Exec(ctx, q); err != nil {
|
// noTable is whether err is postgres saying a table does not exist (SQLSTATE 42P01).
|
||||||
return fmt.Errorf("%s: %w", strings.Fields(q)[0:6], err)
|
func noTable(err error) bool {
|
||||||
|
var pg *pgconn.PgError
|
||||||
|
return errors.As(err, &pg) && pg.Code == "42P01"
|
||||||
|
}
|
||||||
|
|
||||||
|
// each runs a select and scans each row; a table that does not exist is no rows.
|
||||||
|
func (s *PgSource) each(ctx context.Context, q string, scan func(pgx.Rows) error, args ...any) error {
|
||||||
|
rows, err := s.pool.Query(ctx, q, args...)
|
||||||
|
if err != nil {
|
||||||
|
if noTable(err) {
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
for rows.Next() {
|
||||||
|
if err := scan(rows); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil && !noTable(err) {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
const licenceColumns = `name, kind, coalesce(account_uuid,''), coalesce(email,''), coalesce(organization_uuid,''),
|
func (s *PgSource) Licences(ctx context.Context) ([]LicenceRow, error) {
|
||||||
coalesce(sealed,''), coalesce(refresh_fingerprint,''), coalesce(access_expires_at,0), coalesce(refresh_expires_at,0),
|
out := []LicenceRow{}
|
||||||
failures, coalesce(notified_at,0), adopted_at, coalesce(rotated_at,0)`
|
err := s.each(ctx, `select name, kind, coalesce(account_uuid,''), coalesce(email,''), coalesce(organization_uuid,''),
|
||||||
|
coalesce(sealed,''), coalesce(refresh_fingerprint,''), coalesce(access_expires_at,0),
|
||||||
func scanLicence(row pgx.Row) (*Licence, error) {
|
coalesce(refresh_expires_at,0), failures, coalesce(notified_at,0), adopted_at, coalesce(rotated_at,0)
|
||||||
var l Licence
|
from licence order by adopted_at, name`, func(r pgx.Rows) error {
|
||||||
err := row.Scan(&l.Name, &l.Kind, &l.AccountUUID, &l.Email, &l.OrganizationUUID, &l.Sealed, &l.RefreshFingerprint,
|
var l LicenceRow
|
||||||
&l.AccessExpiresAt, &l.RefreshExpiresAt, &l.Failures, &l.NotifiedAt, &l.AdoptedAt, &l.RotatedAt)
|
err := r.Scan(&l.Name, &l.Kind, &l.AccountUUID, &l.Email, &l.OrganizationUUID, &l.Sealed, &l.RefreshFingerprint,
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
&l.AccessExpiresAt, &l.RefreshExpiresAt, &l.Failures, &l.NotifiedAt, &l.AdoptedAt, &l.RotatedAt)
|
||||||
return nil, nil
|
out = append(out, l)
|
||||||
}
|
return err
|
||||||
return &l, err
|
})
|
||||||
|
return out, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *PgStore) Licences(ctx context.Context) ([]Licence, error) {
|
func (s *PgSource) Bindings(ctx context.Context) ([]Binding, error) {
|
||||||
rows, err := s.pool.Query(ctx, `select `+licenceColumns+` from licence order by name`)
|
out := []Binding{}
|
||||||
if err != nil {
|
err := s.each(ctx, `select consumer, licence, generation from binding order by generation, consumer`,
|
||||||
return nil, err
|
func(r pgx.Rows) error {
|
||||||
}
|
var b Binding
|
||||||
defer rows.Close()
|
err := r.Scan(&b.Consumer, &b.Licence, &b.Generation)
|
||||||
var out []Licence
|
out = append(out, b)
|
||||||
for rows.Next() {
|
return err
|
||||||
l, err := scanLicence(rows)
|
})
|
||||||
if err != nil {
|
return out, err
|
||||||
return nil, err
|
}
|
||||||
|
|
||||||
|
func (s *PgSource) Leases(ctx context.Context) ([]LeaseRow, error) {
|
||||||
|
out := []LeaseRow{}
|
||||||
|
err := s.each(ctx, `select key, holder, until from lease order by key`, func(r pgx.Rows) error {
|
||||||
|
var l LeaseRow
|
||||||
|
err := r.Scan(&l.Key, &l.Holder, &l.Until)
|
||||||
|
out = append(out, l)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PgSource) Offered(ctx context.Context) ([]OfferedRow, error) {
|
||||||
|
out := []OfferedRow{}
|
||||||
|
err := s.each(ctx, `select fingerprint, node, coalesce(account_uuid,''), outcome, why, at from offered
|
||||||
|
order by at, fingerprint`, func(r pgx.Rows) error {
|
||||||
|
var o OfferedRow
|
||||||
|
var outcome string
|
||||||
|
err := r.Scan(&o.Fingerprint, &o.Node, &o.Account, &outcome, &o.Why, &o.At)
|
||||||
|
o.Outcome = Outcome(outcome)
|
||||||
|
out = append(out, o)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PgSource) Usage(ctx context.Context, since time.Time) ([]UsageTableRow, error) {
|
||||||
|
out := []UsageTableRow{}
|
||||||
|
err := s.each(ctx, `select licence, at, reading, raw from usage where at >= $1 order by at, licence`,
|
||||||
|
func(r pgx.Rows) error {
|
||||||
|
var u UsageTableRow
|
||||||
|
var reading, raw []byte
|
||||||
|
if err := r.Scan(&u.Licence, &u.At, &reading, &raw); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_ = json.Unmarshal(reading, &u.Reading)
|
||||||
|
_ = json.Unmarshal(raw, &u.Raw)
|
||||||
|
out = append(out, u)
|
||||||
|
return nil
|
||||||
|
}, since.UnixMilli())
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PgSource) Audit(ctx context.Context) ([]AuditRow, error) {
|
||||||
|
out := []AuditRow{}
|
||||||
|
err := s.each(ctx, `select at, what, detail from audit order by at`, func(r pgx.Rows) error {
|
||||||
|
var a AuditRow
|
||||||
|
var detail []byte
|
||||||
|
if err := r.Scan(&a.At, &a.What, &detail); err != nil {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
out = append(out, *l)
|
_ = json.Unmarshal(detail, &a.Detail)
|
||||||
}
|
out = append(out, a)
|
||||||
return out, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *PgStore) Licence(ctx context.Context, name string) (*Licence, error) {
|
|
||||||
return scanLicence(s.pool.QueryRow(ctx, `select `+licenceColumns+` from licence where name = $1`, name))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *PgStore) LicenceForAccount(ctx context.Context, account string) (*Licence, error) {
|
|
||||||
return scanLicence(s.pool.QueryRow(ctx, `select `+licenceColumns+` from licence where account_uuid = $1`, account))
|
|
||||||
}
|
|
||||||
|
|
||||||
func nullable(s string) any {
|
|
||||||
if s == "" {
|
|
||||||
return nil
|
return nil
|
||||||
}
|
})
|
||||||
return s
|
return out, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func nullableInt(v int64) any {
|
|
||||||
if v == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return v
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *PgStore) SaveLicence(ctx context.Context, l Licence) error {
|
|
||||||
_, err := s.pool.Exec(ctx, `insert into licence (name, kind, account_uuid, email, organization_uuid, sealed, refresh_fingerprint,
|
|
||||||
access_expires_at, refresh_expires_at, failures, notified_at, adopted_at, rotated_at)
|
|
||||||
values ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)
|
|
||||||
on conflict (name) do update set kind = excluded.kind, account_uuid = excluded.account_uuid, email = excluded.email,
|
|
||||||
organization_uuid = excluded.organization_uuid, sealed = excluded.sealed, refresh_fingerprint = excluded.refresh_fingerprint,
|
|
||||||
access_expires_at = excluded.access_expires_at, refresh_expires_at = excluded.refresh_expires_at,
|
|
||||||
failures = excluded.failures, notified_at = excluded.notified_at, adopted_at = excluded.adopted_at,
|
|
||||||
rotated_at = excluded.rotated_at`,
|
|
||||||
l.Name, l.Kind, nullable(l.AccountUUID), nullable(l.Email), nullable(l.OrganizationUUID), nullable(l.Sealed),
|
|
||||||
nullable(l.RefreshFingerprint), nullableInt(l.AccessExpiresAt), nullableInt(l.RefreshExpiresAt), l.Failures,
|
|
||||||
nullableInt(l.NotifiedAt), l.AdoptedAt, nullableInt(l.RotatedAt))
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Lease is taken in the store before a row is read (design 39 §3): a second run started together finds
|
|
||||||
// it live and does nothing.
|
|
||||||
func (s *PgStore) Lease(ctx context.Context, key, holder string, d time.Duration) (bool, error) {
|
|
||||||
tag, err := s.pool.Exec(ctx, `insert into lease (key, holder, until) values ($1, $2, now() + make_interval(secs => $3))
|
|
||||||
on conflict (key) do update set holder = excluded.holder, until = excluded.until
|
|
||||||
where lease.until < now() or lease.holder = excluded.holder`, key, holder, d.Seconds())
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
return tag.RowsAffected() == 1, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *PgStore) Unlease(ctx context.Context, key, holder string) error {
|
|
||||||
_, err := s.pool.Exec(ctx, `delete from lease where key = $1 and holder = $2`, key, holder)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *PgStore) bindingsWhere(ctx context.Context, q string, args ...any) ([]Binding, error) {
|
|
||||||
rows, err := s.pool.Query(ctx, q, args...)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
var out []Binding
|
|
||||||
for rows.Next() {
|
|
||||||
var b Binding
|
|
||||||
if err := rows.Scan(&b.Consumer, &b.Licence, &b.Generation); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
out = append(out, b)
|
|
||||||
}
|
|
||||||
return out, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *PgStore) Bindings(ctx context.Context) ([]Binding, error) {
|
|
||||||
return s.bindingsWhere(ctx, `select consumer, licence, generation from binding order by consumer`)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *PgStore) Binding(ctx context.Context, consumer string) (*Binding, error) {
|
|
||||||
out, err := s.bindingsWhere(ctx, `select consumer, licence, generation from binding where consumer = $1`, consumer)
|
|
||||||
if err != nil || len(out) == 0 {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &out[0], nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *PgStore) Bind(ctx context.Context, consumer, licence string) (Binding, error) {
|
|
||||||
out, err := s.bindingsWhere(ctx, `insert into binding (consumer, licence, generation) values ($1, $2, nextval('binding_generation'))
|
|
||||||
on conflict (consumer) do update set licence = excluded.licence, generation = excluded.generation
|
|
||||||
returning consumer, licence, generation`, consumer, licence)
|
|
||||||
if err != nil {
|
|
||||||
return Binding{}, err
|
|
||||||
}
|
|
||||||
return out[0], nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *PgStore) Unbind(ctx context.Context, consumer string) (bool, error) {
|
|
||||||
tag, err := s.pool.Exec(ctx, `delete from binding where consumer = $1`, consumer)
|
|
||||||
return err == nil && tag.RowsAffected() == 1, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *PgStore) GenerationsAbove(ctx context.Context, generation int64) error {
|
|
||||||
if generation <= 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
// setval with is_called, so the next nextval is generation+1; only ever forward.
|
|
||||||
_, err := s.pool.Exec(ctx, `select setval('binding_generation', $1, true) from binding_generation
|
|
||||||
where not is_called or last_value < $1`, generation)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *PgStore) Advance(ctx context.Context, licence string) ([]Binding, error) {
|
|
||||||
return s.bindingsWhere(ctx, `update binding set generation = nextval('binding_generation') where licence = $1
|
|
||||||
returning consumer, licence, generation`, licence)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *PgStore) Outcome(ctx context.Context, fp string) (Outcome, error) {
|
|
||||||
var o string
|
|
||||||
err := s.pool.QueryRow(ctx, `select outcome from offered where fingerprint = $1`, fp).Scan(&o)
|
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
|
||||||
return "", nil
|
|
||||||
}
|
|
||||||
return Outcome(o), err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *PgStore) RecordOutcome(ctx context.Context, fp, node, account string, o Outcome, why string) error {
|
|
||||||
_, err := s.pool.Exec(ctx, `insert into offered (fingerprint, node, account_uuid, outcome, why) values ($1,$2,$3,$4,$5)
|
|
||||||
on conflict (fingerprint) do update set outcome = excluded.outcome, why = excluded.why, at = now()`,
|
|
||||||
fp, node, nullable(account), string(o), why)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *PgStore) RecordUsage(ctx context.Context, licence string, at int64, r UsageReading, raw map[string]any) error {
|
|
||||||
reading, _ := json.Marshal(r)
|
|
||||||
rawJSON, _ := json.Marshal(raw)
|
|
||||||
_, err := s.pool.Exec(ctx, `insert into usage (licence, at, reading, raw) values ($1,$2,$3,$4)`, licence, at, reading, rawJSON)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *PgStore) Usage(ctx context.Context, licence string, limit int) ([]UsageRow, error) {
|
|
||||||
rows, err := s.pool.Query(ctx, `select licence, at, reading from usage where ($1 = '' or licence = $1) order by at desc limit $2`, licence, limit)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
var out []UsageRow
|
|
||||||
for rows.Next() {
|
|
||||||
var u UsageRow
|
|
||||||
var reading []byte
|
|
||||||
if err := rows.Scan(&u.Licence, &u.At, &reading); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
_ = json.Unmarshal(reading, &u.Reading)
|
|
||||||
out = append(out, u)
|
|
||||||
}
|
|
||||||
return out, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *PgStore) Audit(ctx context.Context, what string, detail map[string]any) error {
|
|
||||||
raw, _ := json.Marshal(detail)
|
|
||||||
_, err := s.pool.Exec(ctx, `insert into audit (what, detail) values ($1, $2)`, what, raw)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *PgStore) Close() { s.pool.Close() }
|
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// A licence's room (design 39 bus-and-switch §25, §48): 100 less the higher of its session and weekly percentages in
|
||||||
|
// its latest reading, known only for a subscription read successfully within usage_stale_minutes, whose refresh has not
|
||||||
|
// failed failures_to_notify times in a row and whose refresh token has not expired. An API key's room is never known,
|
||||||
|
// so it is never a target.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Room is a licence's room at now, and whether it is known.
|
||||||
|
func Room(l Licence, now time.Time, s Settings) (float64, bool) {
|
||||||
|
if l.Kind != "subscription" || l.Usage == nil || l.Usage.SessionPct == nil || l.Usage.WeeklyPct == nil {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
read, err := time.Parse(time.RFC3339Nano, l.Usage.ReadAt)
|
||||||
|
if err != nil || now.Sub(read) > s.UsageStale {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
if l.Failures >= s.FailuresToNotify || (l.RefreshExpiresAt != 0 && l.RefreshExpiresAt <= now.UnixMilli()) {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return 100 - math.Max(*l.Usage.SessionPct, *l.Usage.WeeklyPct), true
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// vendorUsage is the vendor's usage answer as it reads: three windows, each with its reset time.
|
||||||
|
var vendorUsage = map[string]any{
|
||||||
|
"five_hour": map[string]any{"utilization": 41.0, "resets_at": "2026-11-11T23:00:00Z"},
|
||||||
|
"seven_day": map[string]any{"utilization": 97.0, "resets_at": "2026-11-14T09:00:00Z"},
|
||||||
|
"seven_day_sonnet": map[string]any{"utilization": 12.0, "resets_at": "2026-11-15T09:00:00Z"},
|
||||||
|
}
|
||||||
|
|
||||||
|
// design 39 bus-and-switch §24/AC1: a vendor answer is flattened with its three reset times, kept beside the licence
|
||||||
|
// with when it was read, and said as usage.read with those fields.
|
||||||
|
func TestAReadingIsFlattenedWithThreeResets(t *testing.T) {
|
||||||
|
mm := newMesh(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0)})
|
||||||
|
mm.vendor.usage = vendorUsage
|
||||||
|
mm.events = nil
|
||||||
|
if err := mm.m.ReadUsage(ctx); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
l, _ := mm.store.Licence(ctx, licence1)
|
||||||
|
u := l.Usage
|
||||||
|
if u == nil || *u.SessionPct != 41 || u.SessionResetsAt != "2026-11-11T23:00:00Z" || *u.WeeklyPct != 97 ||
|
||||||
|
u.WeeklyResetsAt != "2026-11-14T09:00:00Z" || *u.SonnetPct != 12 || u.SonnetResetsAt != "2026-11-15T09:00:00Z" ||
|
||||||
|
u.ReadAt != t0.UTC().Format(time.RFC3339Nano) || u.ReadError != "" {
|
||||||
|
t.Fatalf("kept %+v", u)
|
||||||
|
}
|
||||||
|
said := strings.Join(mm.events, "\n")
|
||||||
|
for _, want := range []string{`"weeklyResetsAt":"2026-11-14T09:00:00Z"`, `"sonnetResetsAt":"2026-11-15T09:00:00Z"`,
|
||||||
|
`"sessionResetsAt":"2026-11-11T23:00:00Z"`} {
|
||||||
|
if !strings.Contains(said, "usage.read ") || !strings.Contains(said, want) {
|
||||||
|
t.Fatalf("said %s, lacking %s", said, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// design 39 bus-and-switch §24/AC2: a failed answer keeps the last percentages and their time, writes the vendor's
|
||||||
|
// status and reason, and appends no reading.
|
||||||
|
func TestAFailedReadingKeepsThePercentages(t *testing.T) {
|
||||||
|
mm := newMesh(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0)})
|
||||||
|
mm.vendor.usage = vendorUsage
|
||||||
|
_ = mm.m.ReadUsage(ctx)
|
||||||
|
mm.now = t0.Add(5 * time.Minute)
|
||||||
|
mm.vendor.usageErr = errors.New(`the usage endpoint answered 429: {"error":"rate_limited"}`)
|
||||||
|
if err := mm.m.ReadUsage(ctx); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
l, _ := mm.store.Licence(ctx, licence1)
|
||||||
|
u := l.Usage
|
||||||
|
if u == nil || *u.WeeklyPct != 97 || u.ReadAt != t0.UTC().Format(time.RFC3339Nano) ||
|
||||||
|
u.ReadError != `the usage endpoint answered 429: {"error":"rate_limited"}` {
|
||||||
|
t.Fatalf("kept %+v", u)
|
||||||
|
}
|
||||||
|
if n := len(mm.usage.entries); n != 1 {
|
||||||
|
t.Fatalf("a failed reading appended: %d entries", n)
|
||||||
|
}
|
||||||
|
// §16, §31: a grant the vault does not answer is a failed reading, written and gone past, never the end of the round.
|
||||||
|
mm.vendor.usageErr = nil
|
||||||
|
mm.grants.mu.Lock()
|
||||||
|
delete(mm.grants.held, SecretName(KeyFor(licence1), PartGrant))
|
||||||
|
mm.grants.mu.Unlock()
|
||||||
|
if err := mm.m.ReadUsage(ctx); err != nil {
|
||||||
|
t.Fatalf("a grant the vault did not answer ended the round: %v", err)
|
||||||
|
}
|
||||||
|
l, _ = mm.store.Licence(ctx, licence1)
|
||||||
|
if !strings.Contains(l.Usage.ReadError, "cannot be read from the vault") || *l.Usage.WeeklyPct != 97 {
|
||||||
|
t.Fatalf("after the vault's refusal the reading is %+v", l.Usage)
|
||||||
|
}
|
||||||
|
// §4, §16: a reading is taken under the licence's lease; with another run holding it, nothing is read or written.
|
||||||
|
if ok, _ := mm.store.Lease(ctx, "licence."+KeyFor(licence1), "another run", time.Minute); !ok {
|
||||||
|
t.Fatal("the lease was not taken")
|
||||||
|
}
|
||||||
|
reads, err := mm.m.ReadUsageOf(ctx, licence1, true)
|
||||||
|
if err != nil || len(reads) != 1 || reads[0].Error != "another run holds its lease" {
|
||||||
|
t.Fatalf("a reading under another's lease answered %+v, %v", reads, err)
|
||||||
|
}
|
||||||
|
if again, _ := mm.store.Licence(ctx, licence1); again.Usage.ReadError != l.Usage.ReadError {
|
||||||
|
t.Fatalf("a reading under another's lease wrote %+v", again.Usage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// design 39 bus-and-switch §25/AC1, §48: a table of readings, ages and healths and the room each gives.
|
||||||
|
func TestTheRoomOfEachLicence(t *testing.T) {
|
||||||
|
now := t0
|
||||||
|
pct := func(v float64) *float64 { return &v }
|
||||||
|
read := func(session, weekly float64, ago time.Duration) *UsageReading {
|
||||||
|
return &UsageReading{SessionPct: pct(session), WeeklyPct: pct(weekly), ReadAt: now.Add(-ago).Format(time.RFC3339Nano)}
|
||||||
|
}
|
||||||
|
for _, c := range []struct {
|
||||||
|
name string
|
||||||
|
l Licence
|
||||||
|
room float64
|
||||||
|
known bool
|
||||||
|
}{
|
||||||
|
{"the higher window counts", Licence{Kind: "subscription", Usage: read(41, 97, time.Minute)}, 3, true},
|
||||||
|
{"the session can be the higher", Licence{Kind: "subscription", Usage: read(80, 20, time.Minute)}, 20, true},
|
||||||
|
{"read at the stale bound", Licence{Kind: "subscription", Usage: read(10, 10, 15*time.Minute)}, 90, true},
|
||||||
|
{"read before the stale bound", Licence{Kind: "subscription", Usage: read(10, 10, 16*time.Minute)}, 0, false},
|
||||||
|
{"a failure since keeps the room", Licence{Kind: "subscription", Usage: func() *UsageReading {
|
||||||
|
u := read(10, 30, 5*time.Minute)
|
||||||
|
u.ReadError = "the usage endpoint answered 500: x"
|
||||||
|
return u
|
||||||
|
}()}, 70, true},
|
||||||
|
{"never read", Licence{Kind: "subscription"}, 0, false},
|
||||||
|
{"no weekly window", Licence{Kind: "subscription", Usage: &UsageReading{SessionPct: pct(10),
|
||||||
|
ReadAt: now.Format(time.RFC3339Nano)}}, 0, false},
|
||||||
|
{"refresh failing", Licence{Kind: "subscription", Failures: 3, Usage: read(10, 10, time.Minute)}, 0, false},
|
||||||
|
{"refresh failed twice", Licence{Kind: "subscription", Failures: 2, Usage: read(10, 10, time.Minute)}, 90, true},
|
||||||
|
{"refresh token expired", Licence{Kind: "subscription", RefreshExpiresAt: now.UnixMilli(),
|
||||||
|
Usage: read(10, 10, time.Minute)}, 0, false},
|
||||||
|
{"an API key", Licence{Kind: "api-key", Usage: read(0, 0, 0)}, 0, false},
|
||||||
|
} {
|
||||||
|
room, known := Room(c.l, now, Defaults)
|
||||||
|
if room != c.room || known != c.known {
|
||||||
|
t.Errorf("%s: room %v known %v, want %v %v", c.name, room, known, c.room, c.known)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// The manager's settings (design 39 §8, design 39 bus-and-switch §33): today's five and the six of the switch, each
|
||||||
|
// declared in the manifest's `config` with its default and bound, set through mesh-config and written into the
|
||||||
|
// settings file. They are read again at every reading, so a change takes effect at the next one, and each change of a
|
||||||
|
// value read is written in the audit under `settings`.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"reflect"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Settings are the manager's own, declared with defaults.
|
||||||
|
type Settings struct {
|
||||||
|
Cadence time.Duration // rotate a grant once older than this
|
||||||
|
Floor time.Duration // refresh in any case with less than this left
|
||||||
|
FailuresToNotify int
|
||||||
|
Cooldown time.Duration // at most one notification per licence in this window
|
||||||
|
RefreshWarn time.Duration // warn this long before a refresh token itself expires
|
||||||
|
// SwitchAuto is whether a reading at its threshold moves the licence's consumers (§26).
|
||||||
|
SwitchAuto bool
|
||||||
|
// SwitchAt, RoomBelow and WarnAt are percentages: where a licence's consumers move, what a target's windows must
|
||||||
|
// both be below, and where a licence with consumers is warned about.
|
||||||
|
SwitchAt, RoomBelow, WarnAt float64
|
||||||
|
// UsageEvery is how often each subscription's usage is read.
|
||||||
|
UsageEvery time.Duration
|
||||||
|
// UsageStale is how old a licence's latest successful reading may be for its room to be known (§25).
|
||||||
|
UsageStale time.Duration
|
||||||
|
// OutOfBound is every setting the file sets outside the bound the manifest declares, kept at its default and said
|
||||||
|
// as settings.<setting>.invalid until it is fixed (B37).
|
||||||
|
OutOfBound []OutOfBound
|
||||||
|
}
|
||||||
|
|
||||||
|
// OutOfBound is one setting set outside its bound: the value set, the bound and the default kept in its place.
|
||||||
|
type OutOfBound struct {
|
||||||
|
Setting string
|
||||||
|
Value float64
|
||||||
|
Min, Max float64
|
||||||
|
Default float64
|
||||||
|
HasMax bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Defaults are the settings a fresh mesh runs with.
|
||||||
|
var Defaults = Settings{Cadence: 4 * time.Hour, Floor: time.Hour, FailuresToNotify: 3, Cooldown: 24 * time.Hour,
|
||||||
|
RefreshWarn: 72 * time.Hour, SwitchAuto: true, SwitchAt: 99, RoomBelow: 90, WarnAt: 90, UsageEvery: 5 * time.Minute,
|
||||||
|
UsageStale: 15 * time.Minute}
|
||||||
|
|
||||||
|
// SettingsFrom reads the settings file. Today's five keep a default for anything absent or not positive; the six of
|
||||||
|
// the switch keep a default for anything absent or outside the bound the manifest declares (§33).
|
||||||
|
func SettingsFrom(path string) Settings {
|
||||||
|
s := Defaults
|
||||||
|
raw, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
var f map[string]any
|
||||||
|
if json.Unmarshal(raw, &f) != nil {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
num := func(k string) (float64, bool) { v, ok := f[k].(float64); return v, ok }
|
||||||
|
// below is a value under the manifest's minimum of 0 for today's five: kept at its default and said (B37).
|
||||||
|
below := func(k string, v float64, def float64) {
|
||||||
|
if v < 0 {
|
||||||
|
s.OutOfBound = append(s.OutOfBound, OutOfBound{Setting: k, Value: v, Default: def})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
set := func(k string, unit time.Duration, into *time.Duration) {
|
||||||
|
v, ok := num(k)
|
||||||
|
if ok && v > 0 {
|
||||||
|
*into = time.Duration(v * float64(unit))
|
||||||
|
} else if ok {
|
||||||
|
below(k, v, float64(*into)/float64(unit))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
set("cadence_minutes", time.Minute, &s.Cadence)
|
||||||
|
set("floor_minutes", time.Minute, &s.Floor)
|
||||||
|
set("cooldown_hours", time.Hour, &s.Cooldown)
|
||||||
|
set("refresh_warn_days", 24*time.Hour, &s.RefreshWarn)
|
||||||
|
if v, ok := num("failures_to_notify"); ok && v > 0 {
|
||||||
|
s.FailuresToNotify = int(v)
|
||||||
|
} else if ok {
|
||||||
|
below("failures_to_notify", v, float64(s.FailuresToNotify))
|
||||||
|
}
|
||||||
|
if v, ok := f["switch_auto"].(bool); ok {
|
||||||
|
s.SwitchAuto = v
|
||||||
|
}
|
||||||
|
bounded := func(k string, lo, hi float64) (float64, bool) {
|
||||||
|
v, ok := num(k)
|
||||||
|
if ok && (v < lo || v > hi) {
|
||||||
|
def, _ := Defaults.Values()[k].(float64)
|
||||||
|
s.OutOfBound = append(s.OutOfBound, OutOfBound{Setting: k, Value: v, Min: lo, Max: hi, Default: def, HasMax: true})
|
||||||
|
}
|
||||||
|
return v, ok && v >= lo && v <= hi
|
||||||
|
}
|
||||||
|
for _, p := range []struct {
|
||||||
|
key string
|
||||||
|
into *float64
|
||||||
|
}{{"switch_at_pct", &s.SwitchAt}, {"room_below_pct", &s.RoomBelow}, {"warn_at_pct", &s.WarnAt}} {
|
||||||
|
if v, ok := bounded(p.key, 1, 100); ok {
|
||||||
|
*p.into = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if v, ok := bounded("usage_minutes", 1, 60); ok {
|
||||||
|
s.UsageEvery = time.Duration(v * float64(time.Minute))
|
||||||
|
}
|
||||||
|
if v, ok := bounded("usage_stale_minutes", 5, 180); ok {
|
||||||
|
s.UsageStale = time.Duration(v * float64(time.Minute))
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// Values are the settings by their names in the manifest's `config`, in its units.
|
||||||
|
func (s Settings) Values() map[string]any {
|
||||||
|
return map[string]any{"cadence_minutes": s.Cadence.Minutes(), "floor_minutes": s.Floor.Minutes(),
|
||||||
|
"failures_to_notify": float64(s.FailuresToNotify), "cooldown_hours": s.Cooldown.Hours(),
|
||||||
|
"refresh_warn_days": s.RefreshWarn.Hours() / 24, "switch_auto": s.SwitchAuto, "switch_at_pct": s.SwitchAt,
|
||||||
|
"room_below_pct": s.RoomBelow, "warn_at_pct": s.WarnAt, "usage_minutes": s.UsageEvery.Minutes(),
|
||||||
|
"usage_stale_minutes": s.UsageStale.Minutes()}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invalid is a setting above switch_at_pct, which moves no consumer until it is fixed (§33): the setting and both
|
||||||
|
// values.
|
||||||
|
type Invalid struct {
|
||||||
|
Setting string
|
||||||
|
Value float64
|
||||||
|
SwitchAt float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invalid is every setting of the pairs §33 names that is above switch_at_pct: room_below_pct, then warn_at_pct.
|
||||||
|
func (s Settings) Invalid() []Invalid {
|
||||||
|
var out []Invalid
|
||||||
|
if s.RoomBelow > s.SwitchAt {
|
||||||
|
out = append(out, Invalid{Setting: "room_below_pct", Value: s.RoomBelow, SwitchAt: s.SwitchAt})
|
||||||
|
}
|
||||||
|
if s.WarnAt > s.SwitchAt {
|
||||||
|
out = append(out, Invalid{Setting: "warn_at_pct", Value: s.WarnAt, SwitchAt: s.SwitchAt})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// settings are those in force.
|
||||||
|
func (m *Manager) settings() Settings {
|
||||||
|
m.settingsMu.RLock()
|
||||||
|
defer m.settingsMu.RUnlock()
|
||||||
|
return m.Settings
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadSettings reads the settings file again, at every reading (§33): each value that changed is written in the audit
|
||||||
|
// under `settings`, its value before and now, and the settings read are those in force from then on.
|
||||||
|
func (m *Manager) ReadSettings(ctx context.Context) error {
|
||||||
|
if m.SettingsFile == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
next := SettingsFrom(m.SettingsFile)
|
||||||
|
m.settingsMu.Lock()
|
||||||
|
before := m.Settings
|
||||||
|
m.Settings = next
|
||||||
|
m.settingsMu.Unlock()
|
||||||
|
was, now := before.Values(), next.Values()
|
||||||
|
changed := map[string]any{}
|
||||||
|
for k, v := range now {
|
||||||
|
if !reflect.DeepEqual(was[k], v) {
|
||||||
|
changed[k] = map[string]any{"from": was[k], "to": v}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(changed) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return m.record(ctx, "settings", fmt.Sprintf("%d setting(s) changed: %v", len(changed), changed), changed)
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// design 39 bus-and-switch §33/AC1: each setting's default is the manifest's, a value at its bound is taken, and one
|
||||||
|
// outside its bound or absent keeps the default.
|
||||||
|
func TestEachSettingsDefaultAndBound(t *testing.T) {
|
||||||
|
raw, err := os.ReadFile("../../module.json")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var m struct {
|
||||||
|
Config map[string]struct {
|
||||||
|
Default any `json:"default"`
|
||||||
|
Minimum *float64 `json:"minimum"`
|
||||||
|
Maximum *float64 `json:"maximum"`
|
||||||
|
Trusted bool `json:"trusted"`
|
||||||
|
} `json:"config"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &m); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defaults := Defaults.Values()
|
||||||
|
if len(m.Config) != len(defaults) {
|
||||||
|
t.Fatalf("the manifest declares %d settings, the code %d", len(m.Config), len(defaults))
|
||||||
|
}
|
||||||
|
file := filepath.Join(t.TempDir(), "settings.json")
|
||||||
|
read := func(k string, v any) any {
|
||||||
|
b, _ := json.Marshal(map[string]any{k: v})
|
||||||
|
_ = os.WriteFile(file, b, 0o600)
|
||||||
|
return SettingsFrom(file).Values()[k]
|
||||||
|
}
|
||||||
|
for k, c := range m.Config {
|
||||||
|
if !c.Trusted || c.Default != defaults[k] {
|
||||||
|
t.Errorf("%s: the manifest's default %v (trusted %v), the code's %v", k, c.Default, c.Trusted, defaults[k])
|
||||||
|
}
|
||||||
|
if c.Maximum == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
lo, hi := *c.Minimum, *c.Maximum
|
||||||
|
for v, want := range map[float64]any{lo: lo, hi: hi, lo - 1: defaults[k], hi + 1: defaults[k]} {
|
||||||
|
if got := read(k, v); got != want {
|
||||||
|
t.Errorf("%s set to %v reads %v, want %v", k, v, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if read("switch_auto", false) != false || read("switch_at_pct", "high") != 99.0 {
|
||||||
|
t.Error("switch_auto off, or a value of the wrong type, is not read as written")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// design 39 bus-and-switch §33: room_below_pct or warn_at_pct above switch_at_pct is an invalid pair naming both
|
||||||
|
// values; equal is valid.
|
||||||
|
func TestAnInvalidPairIsFound(t *testing.T) {
|
||||||
|
s := Defaults
|
||||||
|
if len(s.Invalid()) != 0 {
|
||||||
|
t.Fatal("the defaults are invalid")
|
||||||
|
}
|
||||||
|
s.SwitchAt, s.RoomBelow, s.WarnAt = 80, 85, 80
|
||||||
|
got := s.Invalid()
|
||||||
|
if len(got) != 1 || got[0] != (Invalid{Setting: "room_below_pct", Value: 85, SwitchAt: 80}) {
|
||||||
|
t.Fatalf("invalid %+v", got)
|
||||||
|
}
|
||||||
|
s.WarnAt = 95
|
||||||
|
if got := s.Invalid(); len(got) != 2 || got[1].Setting != "warn_at_pct" {
|
||||||
|
t.Fatalf("invalid %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// design 39 bus-and-switch §33: the settings are read again at every reading, and each change of a value read is
|
||||||
|
// written once in the audit under settings.
|
||||||
|
func TestASettingChangedIsReadAtTheNextReadingAndAudited(t *testing.T) {
|
||||||
|
mm := newMesh(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
mm.m.SettingsFile = filepath.Join(t.TempDir(), "settings.json")
|
||||||
|
_ = os.WriteFile(mm.m.SettingsFile, []byte(`{"switch_at_pct": 99}`), 0o600)
|
||||||
|
if err := mm.m.ReadUsage(ctx); err != nil || len(mm.audit.under("settings")) != 0 {
|
||||||
|
t.Fatalf("an unchanged file audited %v (%v)", mm.audit.under("settings"), err)
|
||||||
|
}
|
||||||
|
_ = os.WriteFile(mm.m.SettingsFile, []byte(`{"switch_at_pct": 95, "usage_minutes": 10}`), 0o600)
|
||||||
|
_ = mm.m.ReadUsage(ctx)
|
||||||
|
_ = mm.m.ReadUsage(ctx)
|
||||||
|
if s := mm.m.settings(); s.SwitchAt != 95 || s.UsageEvery.Minutes() != 10 {
|
||||||
|
t.Fatalf("in force %+v", s)
|
||||||
|
}
|
||||||
|
got := mm.audit.under("settings")
|
||||||
|
if len(got) != 1 {
|
||||||
|
t.Fatalf("audited %v", got)
|
||||||
|
}
|
||||||
|
if c, _ := got[0]["switch_at_pct"].(map[string]any); c["from"] != 99.0 || c["to"] != 95.0 {
|
||||||
|
t.Fatalf("audited %v", got[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,39 +1,63 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
// The manager's store (novox/hq ADR 0183, design 39 §1): licences with their grants encrypted, bindings
|
// The manager's store (novox/hq ADR 0183, design 39 bus-and-switch §2 to §7): licences, bindings with a generation,
|
||||||
// with a generation, what became of each login it was offered, usage readings, and the audit. One
|
// leases, what became of each login it was offered, usage readings and the audit, all in the bus's own storage
|
||||||
// interface, two implementations — postgres for the mesh (pgstore.go), memory for the tests — so every
|
// (busstore.go). Every rule is tested on the same store over a bus kept in memory.
|
||||||
// rule is tested without a database, and the database is asked only to keep rows.
|
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"sort"
|
|
||||||
"sync"
|
|
||||||
"time"
|
"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 {
|
type Licence struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Kind string `json:"kind"` // subscription | api-key
|
Kind string `json:"kind"` // subscription | api-key
|
||||||
AccountUUID string `json:"accountUuid,omitempty"`
|
AccountUUID string `json:"account,omitempty"`
|
||||||
Email string `json:"email,omitempty"`
|
Email string `json:"address,omitempty"`
|
||||||
OrganizationUUID string `json:"organizationUuid,omitempty"`
|
OrganizationUUID string `json:"organisation,omitempty"`
|
||||||
Sealed string `json:"-"` // the grant or the key, encrypted at rest
|
RefreshFingerprint string `json:"refreshFingerprint,omitempty"`
|
||||||
RefreshFingerprint string `json:"-"`
|
|
||||||
AccessExpiresAt int64 `json:"accessExpiresAt,omitempty"`
|
AccessExpiresAt int64 `json:"accessExpiresAt,omitempty"`
|
||||||
RefreshExpiresAt int64 `json:"refreshExpiresAt,omitempty"`
|
RefreshExpiresAt int64 `json:"refreshExpiresAt,omitempty"`
|
||||||
Failures int `json:"failures"`
|
Failures int `json:"failures"`
|
||||||
NotifiedAt int64 `json:"-"`
|
NotifiedAt int64 `json:"notifiedAt,omitempty"`
|
||||||
AdoptedAt int64 `json:"adoptedAt"`
|
AdoptedAt int64 `json:"adoptedAt"`
|
||||||
|
AdoptedFrom string `json:"adoptedFrom,omitempty"`
|
||||||
RotatedAt int64 `json:"rotatedAt,omitempty"`
|
RotatedAt int64 `json:"rotatedAt,omitempty"`
|
||||||
|
// 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 {
|
type Binding struct {
|
||||||
Consumer string `json:"consumer"`
|
Consumer string `json:"consumer"`
|
||||||
Licence string `json:"licence"`
|
Licence string `json:"licence"`
|
||||||
|
// Key is the licence's key (§10), answered by the verb bindings (B25); the store keeps none.
|
||||||
|
Key string `json:"key,omitempty"`
|
||||||
|
Kind string `json:"kind,omitempty"`
|
||||||
Generation int64 `json:"generation"`
|
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.
|
// Outcome is what became of a login the manager was offered, by its refresh token's fingerprint.
|
||||||
@@ -52,6 +76,8 @@ type UsageRow struct {
|
|||||||
Licence string `json:"licence"`
|
Licence string `json:"licence"`
|
||||||
At int64 `json:"at"`
|
At int64 `json:"at"`
|
||||||
Reading UsageReading `json:"reading"`
|
Reading UsageReading `json:"reading"`
|
||||||
|
// Raw is the vendor's answer whole, answered only when asked (§35).
|
||||||
|
Raw map[string]any `json:"raw,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store is what the manager keeps.
|
// Store is what the manager keeps.
|
||||||
@@ -60,213 +86,22 @@ type Store interface {
|
|||||||
Licence(ctx context.Context, name string) (*Licence, error)
|
Licence(ctx context.Context, name string) (*Licence, error)
|
||||||
LicenceForAccount(ctx context.Context, account string) (*Licence, error)
|
LicenceForAccount(ctx context.Context, account string) (*Licence, error)
|
||||||
SaveLicence(ctx context.Context, l 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)
|
||||||
// Lease takes a lease for d, or answers false while another holder's is live.
|
// 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)
|
Lease(ctx context.Context, key, holder string, d time.Duration) (bool, error)
|
||||||
Unlease(ctx context.Context, key, holder string) 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)
|
Outcome(ctx context.Context, fingerprint string) (Outcome, error)
|
||||||
RecordOutcome(ctx context.Context, fingerprint, node, account string, o Outcome, why string) 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
|
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)
|
// Usage is the latest readings, newest first, of one licence or every one: at most limit, none before since (zero
|
||||||
|
// is none), the vendor's answer with raw (§35).
|
||||||
|
Usage(ctx context.Context, licence string, since time.Time, limit int, raw bool) ([]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
|
Audit(ctx context.Context, what string, detail map[string]any) error
|
||||||
Close()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MemoryStore is the tests' store.
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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{},
|
|
||||||
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()
|
|
||||||
if held, ok := m.leases[key]; ok && held.until.After(m.now()) && held.holder != holder {
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
m.leases[key] = struct {
|
|
||||||
holder string
|
|
||||||
until time.Time
|
|
||||||
}{holder, m.now().Add(d)}
|
|
||||||
return true, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *MemoryStore) Unlease(_ context.Context, key, holder string) error {
|
|
||||||
m.mu.Lock()
|
|
||||||
defer m.mu.Unlock()
|
|
||||||
if m.leases[key].holder == holder {
|
|
||||||
delete(m.leases, key)
|
|
||||||
}
|
|
||||||
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()
|
|
||||||
return m.outcomes[fp], nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *MemoryStore) RecordOutcome(_ context.Context, fp, _, _ string, o Outcome, _ string) error {
|
|
||||||
m.mu.Lock()
|
|
||||||
defer m.mu.Unlock()
|
|
||||||
m.outcomes[fp] = o
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
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() {}
|
|
||||||
|
|||||||
@@ -1,95 +1,12 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"crypto/rand"
|
||||||
"os"
|
"encoding/base64"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Against a real postgres, because the questions are the database's: does the schema apply twice, does a
|
|
||||||
// lease refuse a second holder, does a generation only grow. Skipped unless one is named:
|
|
||||||
//
|
|
||||||
// docker run -d --rm --name licmgr-pg -e POSTGRES_PASSWORD=t -p 15498:5432 postgres:16-alpine
|
|
||||||
// MESH_TEST_POSTGRES=postgres://postgres:t@127.0.0.1:15498/postgres go test ./...
|
|
||||||
func TestTheStoreOnPostgres(t *testing.T) {
|
|
||||||
url := os.Getenv("MESH_TEST_POSTGRES")
|
|
||||||
if url == "" {
|
|
||||||
t.Skip("MESH_TEST_POSTGRES unset")
|
|
||||||
}
|
|
||||||
ctx := context.Background()
|
|
||||||
s, err := OpenPgStore(ctx, url)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
defer s.Close()
|
|
||||||
for _, table := range []string{"binding", "licence", "lease", "offered", "usage", "audit"} {
|
|
||||||
_, _ = s.pool.Exec(ctx, "drop table if exists "+table+" cascade")
|
|
||||||
}
|
|
||||||
_, _ = s.pool.Exec(ctx, "drop sequence if exists binding_generation")
|
|
||||||
for i := 0; i < 2; i++ {
|
|
||||||
if err := s.Migrate(ctx); err != nil {
|
|
||||||
t.Fatalf("migration %d: %v", i+1, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
l := Licence{Name: "a@example.org", Kind: "subscription", AccountUUID: "u-1", Email: "a@example.org", Sealed: "v1.x.y",
|
|
||||||
RefreshFingerprint: "sha256:1", AccessExpiresAt: 1, RefreshExpiresAt: 2, AdoptedAt: 3}
|
|
||||||
if err := s.SaveLicence(ctx, l); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
l.Failures = 2
|
|
||||||
_ = s.SaveLicence(ctx, l)
|
|
||||||
if got, _ := s.LicenceForAccount(ctx, "u-1"); got == nil || got.Failures != 2 || got.OrganizationUUID != "" {
|
|
||||||
t.Fatalf("%+v", got)
|
|
||||||
}
|
|
||||||
if none, err := s.Licence(ctx, "nobody"); none != nil || err != nil {
|
|
||||||
t.Fatalf("%v %v", none, err)
|
|
||||||
}
|
|
||||||
lease := func(holder string) bool {
|
|
||||||
ok, err := s.Lease(ctx, "licence:a", holder, time.Minute)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
return ok
|
|
||||||
}
|
|
||||||
if !lease("one") || lease("two") || !lease("one") {
|
|
||||||
t.Fatal("a lease did not refuse a second holder, or its own holder could not renew it")
|
|
||||||
}
|
|
||||||
_ = s.Unlease(ctx, "licence:a", "one")
|
|
||||||
if !lease("two") {
|
|
||||||
t.Fatal("a released lease was not taken")
|
|
||||||
}
|
|
||||||
b1, _ := s.Bind(ctx, "laptop", l.Name)
|
|
||||||
adv, _ := s.Advance(ctx, l.Name)
|
|
||||||
b3, _ := s.Bind(ctx, "laptop", l.Name)
|
|
||||||
if len(adv) != 1 || !(b1.Generation < adv[0].Generation && adv[0].Generation < b3.Generation) {
|
|
||||||
t.Fatalf("generations %d %v %d", b1.Generation, adv, b3.Generation)
|
|
||||||
}
|
|
||||||
_ = s.RecordOutcome(ctx, "sha256:x", "laptop", "u-1", Dead, "400")
|
|
||||||
if o, _ := s.Outcome(ctx, "sha256:x"); o != Dead {
|
|
||||||
t.Fatalf("outcome %q", o)
|
|
||||||
}
|
|
||||||
if o, _ := s.Outcome(ctx, "sha256:none"); o != "" {
|
|
||||||
t.Fatalf("an unknown login is %q", o)
|
|
||||||
}
|
|
||||||
pct := 1.0
|
|
||||||
_ = s.RecordUsage(ctx, l.Name, 5, UsageReading{SessionPct: &pct}, map[string]any{})
|
|
||||||
if u, _ := s.Usage(ctx, "", 5); len(u) != 1 || *u[0].Reading.SessionPct != 1 {
|
|
||||||
t.Fatalf("usage %v", u)
|
|
||||||
}
|
|
||||||
if err := s.Audit(ctx, "bound", map[string]any{"consumer": "laptop"}); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if ok, _ := s.Unbind(ctx, "laptop"); !ok {
|
|
||||||
t.Fatal("unbind")
|
|
||||||
}
|
|
||||||
all, _ := s.Licences(ctx)
|
|
||||||
if len(all) != 1 || !strings.HasPrefix(all[0].Sealed, "v1.") {
|
|
||||||
t.Fatalf("%v", all)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestARefreshThatDoesNotRotateKeepsTheRefreshToken(t *testing.T) {
|
func TestARefreshThatDoesNotRotateKeepsTheRefreshToken(t *testing.T) {
|
||||||
prev := FullGrant{AccessToken: "a", RefreshToken: "r", ExpiresAt: 1}
|
prev := FullGrant{AccessToken: "a", RefreshToken: "r", ExpiresAt: 1}
|
||||||
in := int64(60)
|
in := int64(60)
|
||||||
@@ -109,7 +26,7 @@ func TestARefreshThatDoesNotRotateKeepsTheRefreshToken(t *testing.T) {
|
|||||||
func TestAGrantAtRestOpensOnlyWithItsKey(t *testing.T) {
|
func TestAGrantAtRestOpensOnlyWithItsKey(t *testing.T) {
|
||||||
a, _ := NewCrypt("one key")
|
a, _ := NewCrypt("one key")
|
||||||
b, _ := NewCrypt("another key")
|
b, _ := NewCrypt("another key")
|
||||||
sealed := a.Seal(`{"refreshToken":"rt"}`)
|
sealed := sealAtRest(t, a, `{"refreshToken":"rt"}`)
|
||||||
// The plaintext's own words, not two letters a random ciphertext holds one time in about seventy.
|
// The plaintext's own words, not two letters a random ciphertext holds one time in about seventy.
|
||||||
if strings.Contains(sealed, "refreshToken") {
|
if strings.Contains(sealed, "refreshToken") {
|
||||||
t.Fatal("stored in the clear")
|
t.Fatal("stored in the clear")
|
||||||
@@ -124,3 +41,16 @@ func TestAGrantAtRestOpensOnlyWithItsKey(t *testing.T) {
|
|||||||
t.Fatal("an empty key was accepted")
|
t.Fatal("an empty key was accepted")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// sealAtRest seals a grant as today's database keeps it, `v1.<iv>.<ciphertext and tag>`: the rows the carry-over reads.
|
||||||
|
func sealAtRest(t *testing.T, c *Crypt, plaintext string) string {
|
||||||
|
t.Helper()
|
||||||
|
gcm, err := newGCM(c.key)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
iv := make([]byte, 12)
|
||||||
|
_, _ = rand.Read(iv)
|
||||||
|
b64 := base64.StdEncoding.EncodeToString
|
||||||
|
return "v1." + b64(iv) + "." + b64(gcm.Seal(nil, iv, []byte(plaintext), nil))
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,188 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// The switch rule (design 39 bus-and-switch §26, §27, §28, §32): after a reading of licence L, when switch_auto is on and
|
||||||
|
// L, with one or more consumers bound to it, is at or above switch_at_pct in its session or weekly window, every
|
||||||
|
// consumer bound to L moves, in one act, to the other licence with known room whose windows are both below
|
||||||
|
// room_below_pct and that has the most room, the first by name on a tie. The act is said as one licence.switched, a
|
||||||
|
// binding.changed per consumer, one audit entry switched, and told to the operator. A consumer is only ever moved off
|
||||||
|
// a licence at its threshold, so none flaps between two.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ByUsage is who moves a consumer by the rule (§26).
|
||||||
|
const ByUsage = "usage"
|
||||||
|
|
||||||
|
// Switch is one act of the rule: from L to the target, the consumers it moves, the window of L that reached its
|
||||||
|
// threshold, its percentage and reset, and the target's room.
|
||||||
|
type Switch struct {
|
||||||
|
From string `json:"from"`
|
||||||
|
To string `json:"to"`
|
||||||
|
Consumers []string `json:"consumers"`
|
||||||
|
Window string `json:"window"`
|
||||||
|
Pct float64 `json:"pct"`
|
||||||
|
ResetsAt string `json:"resetsAt"`
|
||||||
|
Room float64 `json:"room"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// why is the cause a moved binding carries: L, the window, its percentage and its reset (§26).
|
||||||
|
func (s Switch) why() string {
|
||||||
|
return fmt.Sprintf("%s reached %g %% of its %s window, which resets at %s", s.From, s.Pct, s.Window, s.ResetsAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
// fields are the act's fields, the same in licence.switched and the audit entry switched (§27).
|
||||||
|
func (s Switch) fields() map[string]any {
|
||||||
|
f := switchedFields(s.From, s.To, s.Consumers, Cause{Why: s.why(), By: ByUsage})
|
||||||
|
f["window"], f["pct"], f["resetsAt"], f["room"] = s.Window, s.Pct, s.ResetsAt, s.Room
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rule is what the rule finds for one licence at its threshold: the act, or none with NoTarget when no other licence
|
||||||
|
// has room; Over is then the licence's consumers and the window at its threshold, for the condition no-room (§29).
|
||||||
|
type Rule struct {
|
||||||
|
Switch *Switch
|
||||||
|
NoTarget bool
|
||||||
|
Over *Switch
|
||||||
|
}
|
||||||
|
|
||||||
|
// consumersOf are the consumers bound to licence, sorted.
|
||||||
|
func consumersOf(licence string, bindings []Binding) []string {
|
||||||
|
var consumers []string
|
||||||
|
for _, b := range bindings {
|
||||||
|
if b.Licence == licence {
|
||||||
|
consumers = append(consumers, b.Consumer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(consumers)
|
||||||
|
return consumers
|
||||||
|
}
|
||||||
|
|
||||||
|
// windowAt is l's window at or above pct, the higher of the two, the session window on a tie (B36): its name,
|
||||||
|
// percentage and reset; ok false when neither is.
|
||||||
|
func windowAt(u *UsageReading, pct float64) (window string, at float64, resetsAt string, ok bool) {
|
||||||
|
if u == nil {
|
||||||
|
return "", 0, "", false
|
||||||
|
}
|
||||||
|
session, weekly := u.SessionPct, u.WeeklyPct
|
||||||
|
switch {
|
||||||
|
case session != nil && *session >= pct && (weekly == nil || *session >= *weekly):
|
||||||
|
return "session", *session, u.SessionResetsAt, true
|
||||||
|
case weekly != nil && *weekly >= pct:
|
||||||
|
return "weekly", *weekly, u.WeeklyResetsAt, true
|
||||||
|
}
|
||||||
|
return "", 0, "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
// targetFor is the licence §26 moves l's consumers to: among the others with known room whose windows are both below
|
||||||
|
// room_below_pct, the one with the most room, the first by name on a tie; empty when none has room.
|
||||||
|
func (m *Manager) targetFor(l Licence, licences []Licence, s Settings) (string, float64) {
|
||||||
|
others := append([]Licence(nil), licences...)
|
||||||
|
sort.Slice(others, func(i, j int) bool { return others[i].Name < others[j].Name })
|
||||||
|
to, best := "", -1.0
|
||||||
|
for _, o := range others {
|
||||||
|
if o.Name == l.Name {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
room, known := Room(o, m.Now(), s)
|
||||||
|
if !known || *o.Usage.SessionPct >= s.RoomBelow || *o.Usage.WeeklyPct >= s.RoomBelow || room <= best {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
best, to = room, o.Name
|
||||||
|
}
|
||||||
|
return to, best
|
||||||
|
}
|
||||||
|
|
||||||
|
// RuleFor is what §26 gives for licence l among every licence and binding, under the settings s. A licence below its
|
||||||
|
// threshold, or with no consumer, gives nothing.
|
||||||
|
func (m *Manager) RuleFor(l Licence, licences []Licence, bindings []Binding, s Settings) Rule {
|
||||||
|
consumers := consumersOf(l.Name, bindings)
|
||||||
|
if len(consumers) == 0 {
|
||||||
|
return Rule{}
|
||||||
|
}
|
||||||
|
sw := Switch{From: l.Name, Consumers: consumers}
|
||||||
|
var ok bool
|
||||||
|
if sw.Window, sw.Pct, sw.ResetsAt, ok = windowAt(l.Usage, s.SwitchAt); !ok {
|
||||||
|
return Rule{}
|
||||||
|
}
|
||||||
|
if sw.To, sw.Room = m.targetFor(l, licences, s); sw.To == "" {
|
||||||
|
sw.Room = 0
|
||||||
|
return Rule{NoTarget: true, Over: &sw}
|
||||||
|
}
|
||||||
|
return Rule{Switch: &sw}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ApplyRule applies §26 to one licence, or to every one when licence is empty: after a reading, by switch_auto, or by
|
||||||
|
// hand, even with switch_auto off (§32, §39). With dry it answers the moves and moves nothing. An invalid pair of
|
||||||
|
// settings moves no consumer (§33).
|
||||||
|
func (m *Manager) ApplyRule(ctx context.Context, licence string, byHand, dry bool) ([]Switch, error) {
|
||||||
|
s := m.settings()
|
||||||
|
if (!s.SwitchAuto && !byHand) || len(s.Invalid()) > 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
licences, err := m.Store.Licences(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
bindings, err := m.Store.Bindings(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var acts []Switch
|
||||||
|
for _, l := range licences {
|
||||||
|
if licence != "" && l.Name != licence {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
r := m.RuleFor(l, licences, bindings, s)
|
||||||
|
if r.Switch == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
acts = append(acts, *r.Switch)
|
||||||
|
if dry {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := m.act(ctx, *r.Switch, licences); err != nil {
|
||||||
|
return acts, err
|
||||||
|
}
|
||||||
|
// What was moved is bound to the target now, for the next licence's rule.
|
||||||
|
if bindings, err = m.Store.Bindings(ctx); err != nil {
|
||||||
|
return acts, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return acts, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// act moves every consumer of one act as the switch verb does, then says the act once and tells the operator (§26 to
|
||||||
|
// §28).
|
||||||
|
func (m *Manager) act(ctx context.Context, sw Switch, licences []Licence) error {
|
||||||
|
kind := ""
|
||||||
|
for _, l := range licences {
|
||||||
|
if l.Name == sw.To {
|
||||||
|
kind = l.Kind
|
||||||
|
}
|
||||||
|
}
|
||||||
|
floor := m.floorIn(nil)
|
||||||
|
for _, consumer := range sw.Consumers {
|
||||||
|
b, err := m.Store.Bind(ctx, consumer, sw.To, Move{Kind: kind, Why: sw.why(), By: ByUsage, Floor: floor})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("moving %s to %s: %w", consumer, sw.To, err)
|
||||||
|
}
|
||||||
|
m.changed(b, sw.From)
|
||||||
|
}
|
||||||
|
f := sw.fields()
|
||||||
|
if err := m.record(ctx, "switched", fmt.Sprintf("switched %s from %s to %s: %s; by %s",
|
||||||
|
strings.Join(sw.Consumers, ", "), sw.From, sw.To, sw.why(), ByUsage), f); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_ = m.Emit("licence.switched", f)
|
||||||
|
if m.Tell != nil {
|
||||||
|
line := fmt.Sprintf("%s; %s has %g %% room", sw.why(), sw.To, sw.Room)
|
||||||
|
if err := m.Tell(ctx, strings.Join(sw.Consumers, ", ")+" moved to "+sw.To, []string{line}); err != nil {
|
||||||
|
m.Log("the switch of %s to %s could not be told: %v", strings.Join(sw.Consumers, ", "), sw.To, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// read is a licence's latest reading, read at t0.
|
||||||
|
func read(session, weekly float64) *UsageReading {
|
||||||
|
return &UsageReading{SessionPct: &session, SessionResetsAt: "2026-11-11T23:00:00Z", WeeklyPct: &weekly,
|
||||||
|
WeeklyResetsAt: "2026-11-14T09:00:00Z", ReadAt: t0.UTC().Format(time.RFC3339Nano)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func sub(name string, u *UsageReading) Licence {
|
||||||
|
return Licence{Name: name, Kind: "subscription", Generation: 1, Usage: u}
|
||||||
|
}
|
||||||
|
|
||||||
|
// design 39 bus-and-switch §26/AC1, §25, §48: a table of licences, readings and bindings and the moves each gives.
|
||||||
|
func TestTheRuleMovesEachTable(t *testing.T) {
|
||||||
|
mm := newMesh(t)
|
||||||
|
on := func(consumers ...string) []Binding {
|
||||||
|
var out []Binding
|
||||||
|
for _, c := range consumers {
|
||||||
|
out = append(out, Binding{Consumer: c, Licence: "a"})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
for _, c := range []struct {
|
||||||
|
name string
|
||||||
|
licences []Licence
|
||||||
|
bindings []Binding
|
||||||
|
to string
|
||||||
|
window string
|
||||||
|
noTarget bool
|
||||||
|
consumers int
|
||||||
|
}{
|
||||||
|
{"below its threshold", []Licence{sub("a", read(50, 98)), sub("b", read(0, 0))}, on("x"), "", "", false, 0},
|
||||||
|
{"no consumer", []Licence{sub("a", read(99, 99)), sub("b", read(0, 0))}, nil, "", "", false, 0},
|
||||||
|
{"the weekly window at 99", []Licence{sub("a", read(40, 99)), sub("b", read(10, 30)), sub("c", read(5, 20))},
|
||||||
|
on("x", "y", "z", "w"), "c", "weekly", false, 4},
|
||||||
|
{"the session window at 100", []Licence{sub("a", read(100, 50)), sub("b", read(10, 30))}, on("x"), "b", "session", false, 1},
|
||||||
|
{"a tie goes to the first by name", []Licence{sub("a", read(99, 0)), sub("c", read(10, 10)), sub("b", read(10, 10))},
|
||||||
|
on("x"), "b", "session", false, 1},
|
||||||
|
{"a target's windows both below room_below_pct", []Licence{sub("a", read(99, 0)), sub("b", read(0, 90)),
|
||||||
|
sub("c", read(89, 89))}, on("x"), "c", "session", false, 1},
|
||||||
|
{"no other licence has room", []Licence{sub("a", read(99, 0)), sub("b", read(95, 0)),
|
||||||
|
{Name: "k", Kind: "api-key", Generation: 1}}, on("x"), "", "", true, 0},
|
||||||
|
{"an unread licence is no target", []Licence{sub("a", read(0, 99)), sub("b", nil)}, on("x"), "", "", true, 0},
|
||||||
|
} {
|
||||||
|
r := mm.m.RuleFor(c.licences[0], c.licences, c.bindings, Defaults)
|
||||||
|
to, window, n := "", "", 0
|
||||||
|
if r.Switch != nil {
|
||||||
|
to, window, n = r.Switch.To, r.Switch.Window, len(r.Switch.Consumers)
|
||||||
|
}
|
||||||
|
if to != c.to || window != c.window || n != c.consumers || r.NoTarget != c.noTarget {
|
||||||
|
t.Errorf("%s: to %q window %q consumers %d no target %v", c.name, to, window, n, r.NoTarget)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// fourOnA is four consumers bound to a, at 99 % of its weekly window, b with 70 % room and c with 60 %.
|
||||||
|
func fourOnA(t *testing.T) *miniMesh {
|
||||||
|
mm := newMesh(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
for _, l := range []Licence{sub("a", read(40, 99)), sub("b", read(30, 20)), sub("c", read(40, 10))} {
|
||||||
|
_ = mm.store.SaveLicence(ctx, l)
|
||||||
|
}
|
||||||
|
for _, c := range []string{"ace", "g14", "laptop", "server"} {
|
||||||
|
_, _ = mm.store.Bind(ctx, c, "a", Move{Kind: "subscription", Why: "set", By: "test"})
|
||||||
|
}
|
||||||
|
mm.events = nil
|
||||||
|
return mm
|
||||||
|
}
|
||||||
|
|
||||||
|
// design 39 bus-and-switch §27/AC1, §26, §21: one act says one licence.switched with the consumers moved, the window,
|
||||||
|
// its percentage, reset and the target's room; one binding.changed per consumer; one audit entry switched with the same
|
||||||
|
// fields; each binding moved by usage, its why naming the licence, the window, the percentage and the reset.
|
||||||
|
func TestOneSwitchedPerAct(t *testing.T) {
|
||||||
|
mm := fourOnA(t)
|
||||||
|
acts, err := mm.m.ApplyRule(context.Background(), "", false, false)
|
||||||
|
if err != nil || len(acts) != 1 || acts[0].To != "b" || acts[0].Room != 70 {
|
||||||
|
t.Fatalf("acts %+v %v", acts, err)
|
||||||
|
}
|
||||||
|
var switched []map[string]any
|
||||||
|
changed := 0
|
||||||
|
for _, e := range mm.events {
|
||||||
|
name, body, _ := strings.Cut(e, " ")
|
||||||
|
var f map[string]any
|
||||||
|
_ = json.Unmarshal([]byte(body), &f)
|
||||||
|
switch name {
|
||||||
|
case "licence.switched":
|
||||||
|
switched = append(switched, f)
|
||||||
|
case "binding.changed":
|
||||||
|
changed++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(switched) != 1 || changed != 4 {
|
||||||
|
t.Fatalf("said %v", mm.events)
|
||||||
|
}
|
||||||
|
s := switched[0]
|
||||||
|
if s["from"] != "a" || s["to"] != "b" || len(s["consumers"].([]any)) != 4 || s["by"] != ByUsage ||
|
||||||
|
s["window"] != "weekly" || s["pct"] != 99.0 || s["resetsAt"] != "2026-11-14T09:00:00Z" || s["room"] != 70.0 {
|
||||||
|
t.Fatalf("licence.switched %v", s)
|
||||||
|
}
|
||||||
|
audit := mm.audit.under("switched")
|
||||||
|
if len(audit) != 1 || audit[0]["window"] != "weekly" || len(audit[0]["consumers"].([]any)) != 4 {
|
||||||
|
t.Fatalf("audit %v", audit)
|
||||||
|
}
|
||||||
|
b := mm.bound("laptop")
|
||||||
|
if b.Licence != "b" || b.By != ByUsage || b.Why != "a reached 99 % of its weekly window, which resets at 2026-11-14T09:00:00Z" {
|
||||||
|
t.Fatalf("laptop %+v", b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// design 39 bus-and-switch §28/AC1: the rule tells the operator once per act, and the switch verb never.
|
||||||
|
func TestTheRuleTellsOnceAndTheVerbNever(t *testing.T) {
|
||||||
|
mm := fourOnA(t)
|
||||||
|
var told []string
|
||||||
|
mm.m.Tell = func(_ context.Context, headline string, lines []string) error {
|
||||||
|
told = append(told, headline+" | "+strings.Join(lines, " "))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
_, _ = mm.m.ApplyRule(context.Background(), "", false, false)
|
||||||
|
if len(told) != 1 || told[0] != "ace, g14, laptop, server moved to b | a reached 99 % of its weekly window, "+
|
||||||
|
"which resets at 2026-11-14T09:00:00Z; b has 70 % room" {
|
||||||
|
t.Fatalf("told %v", told)
|
||||||
|
}
|
||||||
|
_, _ = mm.m.Bind(WithCause(context.Background(), Cause{Why: "the switch verb", By: "a person"}), "ace", "c")
|
||||||
|
if len(told) != 1 {
|
||||||
|
t.Fatalf("the switch verb told %v", told)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// design 39 bus-and-switch §32/AC1, §33/AC2: dry answers the same moves as the act and moves nothing; an invalid pair
|
||||||
|
// of settings, or switch_auto off, moves nothing after a reading, and the rule by hand runs with switch_auto off.
|
||||||
|
func TestDryAnswersTheSameMovesAndMovesNothing(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
mm := fourOnA(t)
|
||||||
|
dry, err := mm.m.ApplyRule(ctx, "a", true, true)
|
||||||
|
if err != nil || len(dry) != 1 || mm.bound("ace").Licence != "a" || len(mm.events) != 0 {
|
||||||
|
t.Fatalf("dry %+v %v, said %v", dry, err, mm.events)
|
||||||
|
}
|
||||||
|
acts, _ := mm.m.ApplyRule(ctx, "a", true, false)
|
||||||
|
if len(acts) != 1 || acts[0].To != dry[0].To || strings.Join(acts[0].Consumers, ",") != strings.Join(dry[0].Consumers, ",") {
|
||||||
|
t.Fatalf("dry %+v, act %+v", dry, acts)
|
||||||
|
}
|
||||||
|
for name, change := range map[string]func(*Settings){
|
||||||
|
"an invalid pair": func(s *Settings) { s.RoomBelow = 100 },
|
||||||
|
"switch_auto off": func(s *Settings) { s.SwitchAuto = false },
|
||||||
|
} {
|
||||||
|
mm := fourOnA(t)
|
||||||
|
change(&mm.m.Settings)
|
||||||
|
if acts, _ := mm.m.ApplyRule(ctx, "", false, false); len(acts) != 0 || mm.bound("ace").Licence != "a" {
|
||||||
|
t.Errorf("%s moved %+v", name, acts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mm = fourOnA(t)
|
||||||
|
mm.m.Settings.SwitchAuto = false
|
||||||
|
if acts, _ := mm.m.ApplyRule(ctx, "", true, false); len(acts) != 1 || mm.bound("ace").Licence != "b" {
|
||||||
|
t.Fatalf("by hand with switch_auto off moved %+v", acts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// design 39 bus-and-switch §26: a reading at the threshold applies the rule at once.
|
||||||
|
func TestAReadingAtTheThresholdApplies(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: "b@example.org", Kind: "subscription", Usage: read(10, 10)})
|
||||||
|
mm.vendor.usage = map[string]any{"five_hour": map[string]any{"utilization": 30.0},
|
||||||
|
"seven_day": map[string]any{"utilization": 99.0, "resets_at": "2026-11-14T09:00:00Z"}}
|
||||||
|
if err := mm.m.ReadUsage(ctx); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if b := mm.bound("laptop"); b.Licence != "b@example.org" || b.By != ByUsage {
|
||||||
|
t.Fatalf("laptop %+v", b)
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
{"node":"laptop","identity":{"accountUuid":"u-1","emailAddress":"a@example.org"},"kind":"subscription","refresh":{"present":true,"fingerprint":"sha256:0123456789abcdef","expiresAt":null},"access":{"fingerprint":"sha256:fedcba9876543210","expiresAt":1},"licence":null,"generation":4,"changedAt":"2026-10-04T11:00:00.000Z"}
|
||||||
@@ -0,0 +1,421 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// The module's own tools (design 39 bus-and-switch §37 to §44, B40): beside the seat's verbs, they read everything the
|
||||||
|
// manager keeps and start by hand everything it does by itself. A tool that acts answers "the carry-over has not ended"
|
||||||
|
// until it has (§11); licences_carried's run is the carry-over itself.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"slices"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||||
|
)
|
||||||
|
|
||||||
|
// tool is one of the module's own tools; acts says whether it acts, so the carry-over's gate holds it (§11).
|
||||||
|
func tool(name, description string, acts bool, input map[string]any, run func(ctx context.Context, m *Manager, a map[string]any) (any, error)) stdio.Tool {
|
||||||
|
return stdio.Tool{Name: name, Description: description, Input: input,
|
||||||
|
Run: func(a map[string]any) (any, error) {
|
||||||
|
m, err := manager()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if acts {
|
||||||
|
if err := m.Carry.Acts(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ctx := WithCause(context.Background(), Cause{Why: "the " + name + " tool", By: stdio.CallerOf(a)})
|
||||||
|
return run(ctx, m, a)
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func boolean(description string) map[string]any {
|
||||||
|
return map[string]any{"type": "boolean", "description": description}
|
||||||
|
}
|
||||||
|
|
||||||
|
func number(description string) map[string]any {
|
||||||
|
return map[string]any{"type": "number", "description": description}
|
||||||
|
}
|
||||||
|
|
||||||
|
func moduleTools() []stdio.Tool {
|
||||||
|
licence := str("a licence, as `licences` names it; absent for all")
|
||||||
|
dry := boolean("answer what the rule would do, and do nothing")
|
||||||
|
return []stdio.Tool{
|
||||||
|
tool("licences_switches", "Every switch of a consumer and why, newest first, from the audit: when, from and to which licence, the consumers, by whom and why, and for a switch by usage the reading that moved them.",
|
||||||
|
false, map[string]any{"licence": licence, "consumer": str("a consumer; absent for all"),
|
||||||
|
"since": str("a time, RFC 3339: no switch before it"), "limit": number("how many (default 20)")},
|
||||||
|
func(ctx context.Context, m *Manager, a map[string]any) (any, error) {
|
||||||
|
l, _ := a["licence"].(string)
|
||||||
|
c, _ := a["consumer"].(string)
|
||||||
|
since, err := sinceOf(a)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return m.Switches(strings.TrimSpace(l), strings.TrimSpace(c), since, limitOf(a, 20, 2000))
|
||||||
|
}),
|
||||||
|
tool("licences_read_usage", "Read usage now, for one subscription or every one, as the manager does on its cadence; then apply the switch rule unless dry. Answers each reading and what the rule did or would do.",
|
||||||
|
true, map[string]any{"licence": licence, "dry": dry},
|
||||||
|
func(ctx context.Context, m *Manager, a map[string]any) (any, error) {
|
||||||
|
l, _ := a["licence"].(string)
|
||||||
|
d, _ := a["dry"].(bool)
|
||||||
|
return m.ReadUsageOf(ctx, strings.TrimSpace(l), d)
|
||||||
|
}),
|
||||||
|
tool("licences_switch_now", "Apply the switch rule now from the latest readings, for one licence or all, even with switch_auto off since a person asked; with dry, answer the moves and make none.",
|
||||||
|
true, map[string]any{"licence": licence, "dry": dry},
|
||||||
|
func(ctx context.Context, m *Manager, a map[string]any) (any, error) {
|
||||||
|
l, _ := a["licence"].(string)
|
||||||
|
d, _ := a["dry"].(bool)
|
||||||
|
acts, err := m.ApplyRule(ctx, strings.TrimSpace(l), true, d)
|
||||||
|
if acts == nil {
|
||||||
|
acts = []Switch{}
|
||||||
|
}
|
||||||
|
return acts, err
|
||||||
|
}),
|
||||||
|
tool("licences_settings", "The settings in force: each one's value, its default, where it was set (mesh, node or the default, as mesh-config answers it) and why it is invalid when it is.",
|
||||||
|
false, nil, func(ctx context.Context, m *Manager, _ map[string]any) (any, error) {
|
||||||
|
return m.SettingsInForce(ctx)
|
||||||
|
}),
|
||||||
|
tool("licences_kept", "Every entry the manager keeps on the bus: a state's keys, or a log's subjects with their last entry; with key, that key's value or that subject's last entry; and how full each is against its cap.",
|
||||||
|
false, map[string]any{"entry": str("one of licences, bindings, leases, offered, usage, audit, carried, conditions; absent for all"),
|
||||||
|
"key": str("a key of a state, or a subject of a log")},
|
||||||
|
func(ctx context.Context, m *Manager, a map[string]any) (any, error) {
|
||||||
|
e, _ := a["entry"].(string)
|
||||||
|
k, _ := a["key"].(string)
|
||||||
|
return m.Kept(strings.TrimSpace(e), strings.TrimSpace(k))
|
||||||
|
}),
|
||||||
|
tool("licences_resync", "Recover: say binding.changed again for every binding, ask every node what it holds, say again each condition held, and hand each licence's access token to the vault again. Answers what it said.",
|
||||||
|
true, nil, func(ctx context.Context, m *Manager, _ map[string]any) (any, error) {
|
||||||
|
return m.Resync(ctx)
|
||||||
|
}),
|
||||||
|
tool("licences_conditions", "The conditions the manager said and holds: each key of its state conditions with its body and since.",
|
||||||
|
false, nil, func(_ context.Context, m *Manager, _ map[string]any) (any, error) {
|
||||||
|
return m.KeptConditions()
|
||||||
|
}),
|
||||||
|
tool("licences_carried", "The carry-over's record (when, and per table the rows read and entries written), or, while it has not ended, the failure it names; with run and no record, carry over again. Refused once carried.",
|
||||||
|
false, map[string]any{"run": boolean("carry over again: only while it has not ended")},
|
||||||
|
func(ctx context.Context, m *Manager, a map[string]any) (any, error) {
|
||||||
|
run, _ := a["run"].(bool)
|
||||||
|
return m.Carried(ctx, run)
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Switches are the audit's switches, newest first: of a licence (from or to it) and a consumer when named, none before
|
||||||
|
// since, at most limit (§37).
|
||||||
|
func (m *Manager) Switches(licence, consumer string, since time.Time, limit int) ([]map[string]any, error) {
|
||||||
|
bus, ok := m.Store.(*BusStore)
|
||||||
|
if !ok {
|
||||||
|
return nil, errors.New("the audit is not kept on the bus")
|
||||||
|
}
|
||||||
|
info, err := bus.AuditLog.Info()
|
||||||
|
if err != nil || info.Last == 0 {
|
||||||
|
return []map[string]any{}, err
|
||||||
|
}
|
||||||
|
var all []stdio.LogEntry
|
||||||
|
for next := uint64(1); next != 0; {
|
||||||
|
page, err := bus.AuditLog.Read(next, "switched", 0)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
all = append(all, page.Entries...)
|
||||||
|
next = page.Next
|
||||||
|
}
|
||||||
|
out := []map[string]any{}
|
||||||
|
for i := len(all) - 1; i >= 0 && len(out) < limit; i-- {
|
||||||
|
var e map[string]any
|
||||||
|
if json.Unmarshal(all[i].Value, &e) != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
at, _ := time.Parse(time.RFC3339Nano, fmt.Sprint(e["at"]))
|
||||||
|
if at.Before(since) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if licence != "" && e["from"] != licence && e["to"] != licence {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
consumers, _ := e["consumers"].([]any)
|
||||||
|
if consumer != "" && !slices.Contains(consumers, any(consumer)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
s := map[string]any{"at": e["at"], "from": e["from"], "to": e["to"], "consumers": e["consumers"],
|
||||||
|
"by": e["by"], "why": e["why"]}
|
||||||
|
if _, ok := e["window"]; ok {
|
||||||
|
s["reading"] = map[string]any{"window": e["window"], "pct": e["pct"], "resetsAt": e["resetsAt"],
|
||||||
|
"room": e["room"]}
|
||||||
|
}
|
||||||
|
out = append(out, s)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SettingAnswer is one setting as licences_settings answers it (§40).
|
||||||
|
type SettingAnswer struct {
|
||||||
|
Setting string `json:"setting"`
|
||||||
|
Value any `json:"value"`
|
||||||
|
Default any `json:"default"`
|
||||||
|
Level string `json:"level"`
|
||||||
|
Invalid string `json:"invalid,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SettingsInForce is each setting in force, its default, where mesh-config says it was set, and why it is invalid
|
||||||
|
// when it is (§40, B18).
|
||||||
|
func (m *Manager) SettingsInForce(ctx context.Context) ([]SettingAnswer, error) {
|
||||||
|
levels := map[string]string{}
|
||||||
|
if m.ConfigLevels != nil {
|
||||||
|
got, err := m.ConfigLevels(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("mesh-config did not say where each setting was set: %w", err)
|
||||||
|
}
|
||||||
|
levels = got
|
||||||
|
}
|
||||||
|
s := m.settings()
|
||||||
|
held, err := m.Judge(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
values, defaults := s.Values(), Defaults.Values()
|
||||||
|
var out []SettingAnswer
|
||||||
|
for k, v := range values {
|
||||||
|
a := SettingAnswer{Setting: k, Value: v, Default: defaults[k], Level: levels[k]}
|
||||||
|
if a.Level == "" {
|
||||||
|
a.Level = "default"
|
||||||
|
}
|
||||||
|
if c, ok := held["settings."+k+".invalid"]; ok {
|
||||||
|
a.Invalid = c.Summary
|
||||||
|
}
|
||||||
|
out = append(out, a)
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(i, j int) bool { return out[i].Setting < out[j].Setting })
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// kept are the entries the manager keeps on the bus, by their local names in the manifest (§2 to §9).
|
||||||
|
func (m *Manager) kept() (map[string]keptState, map[string]keptLog) {
|
||||||
|
states, logs := map[string]keptState{}, map[string]keptLog{}
|
||||||
|
if bus, ok := m.Store.(*BusStore); ok {
|
||||||
|
states["licences"], states["bindings"] = bus.LicencesState, bus.BindingsState
|
||||||
|
states["leases"], states["offered"] = bus.LeasesState, bus.OfferedState
|
||||||
|
logs["usage"], logs["audit"] = bus.UsageLog, bus.AuditLog
|
||||||
|
}
|
||||||
|
if m.Carry != nil {
|
||||||
|
states["carried"] = m.Carry.State
|
||||||
|
}
|
||||||
|
if m.Conditions != nil {
|
||||||
|
states["conditions"] = m.Conditions
|
||||||
|
}
|
||||||
|
return states, logs
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kept answers one entry the manager keeps, or every one (§41): a state's keys and their count, a log's subjects with
|
||||||
|
// their last entry and its fill against its cap; with key, one key's value or one subject's last entry.
|
||||||
|
func (m *Manager) Kept(entry, key string) (map[string]any, error) {
|
||||||
|
states, logs := m.kept()
|
||||||
|
out := map[string]any{}
|
||||||
|
names := []string{}
|
||||||
|
for n := range states {
|
||||||
|
names = append(names, n)
|
||||||
|
}
|
||||||
|
for n := range logs {
|
||||||
|
names = append(names, n)
|
||||||
|
}
|
||||||
|
sort.Strings(names)
|
||||||
|
if entry != "" && !slices.Contains(names, entry) {
|
||||||
|
return nil, fmt.Errorf("the manager keeps no entry %q: it keeps %s", entry, strings.Join(names, ", "))
|
||||||
|
}
|
||||||
|
for _, n := range names {
|
||||||
|
if entry != "" && n != entry {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if st, ok := states[n]; ok {
|
||||||
|
a, err := keptStateAnswer(st, key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%s: %w", n, err)
|
||||||
|
}
|
||||||
|
out[n] = a
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
a, err := keptLogAnswer(logs[n], key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%s: %w", n, err)
|
||||||
|
}
|
||||||
|
out[n] = a
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func keptStateAnswer(st keptState, key string) (map[string]any, error) {
|
||||||
|
keys, err := st.Keys()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
a := map[string]any{"kind": "state", "keys": keys, "fill": map[string]any{"keys": len(keys), "cap": "none declared"}}
|
||||||
|
if key != "" {
|
||||||
|
e, err := st.Get(key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if e == nil {
|
||||||
|
a["value"] = nil
|
||||||
|
} else {
|
||||||
|
a["value"] = e.Value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return a, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func keptLogAnswer(l keptLog, key string) (map[string]any, error) {
|
||||||
|
info, err := l.Info()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
a := map[string]any{"kind": "log", "fill": map[string]any{"entries": info.Entries, "bytes": info.Bytes,
|
||||||
|
"maxBytes": info.MaxBytes}}
|
||||||
|
if key != "" {
|
||||||
|
e, err := l.Last(key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
a["last"] = e
|
||||||
|
return a, nil
|
||||||
|
}
|
||||||
|
newest, err := l.Newest("")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
subjects := map[string]any{}
|
||||||
|
for _, e := range newest {
|
||||||
|
subjects[e.Key] = e
|
||||||
|
}
|
||||||
|
a["subjects"] = subjects
|
||||||
|
return a, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resync says again what a consumer, the vault or a node may have missed (§42): binding.changed for every binding,
|
||||||
|
// holdings.asked, each condition held, and licence-put of each licence's access token. Answers what it said.
|
||||||
|
func (m *Manager) Resync(ctx context.Context) (map[string]any, error) {
|
||||||
|
bindings, err := m.Store.Bindings(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
said := map[string]any{}
|
||||||
|
consumers := []string{}
|
||||||
|
for _, b := range bindings {
|
||||||
|
m.changed(b, b.Licence)
|
||||||
|
consumers = append(consumers, b.Consumer)
|
||||||
|
}
|
||||||
|
said["binding.changed"] = consumers
|
||||||
|
if err := m.AskHoldings(CauseOf(ctx).By); err != nil {
|
||||||
|
return said, fmt.Errorf("holdings.asked could not be said: %w", err)
|
||||||
|
}
|
||||||
|
said["holdings.asked"] = true
|
||||||
|
if err := m.SayConditions(ctx, true); err != nil {
|
||||||
|
return said, err
|
||||||
|
}
|
||||||
|
conditions, err := m.KeptConditions()
|
||||||
|
if err != nil {
|
||||||
|
return said, err
|
||||||
|
}
|
||||||
|
keys := []string{}
|
||||||
|
for _, c := range conditions {
|
||||||
|
keys = append(keys, c.Body.Key)
|
||||||
|
}
|
||||||
|
said["conditions"] = keys
|
||||||
|
licences, err := m.Store.Licences(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return said, err
|
||||||
|
}
|
||||||
|
put := []string{}
|
||||||
|
var errs []error
|
||||||
|
for _, l := range licences {
|
||||||
|
if l.Generation == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key := KeyFor(l.Name)
|
||||||
|
// The access token is built from the grant, which the vault hands the manager; it hands a licence's access
|
||||||
|
// token only to a machine a binding names (§17), and the manager's machine may have none.
|
||||||
|
access, err := m.Grants.Get(ctx, key, PartGrant)
|
||||||
|
if err == nil && l.Kind == "subscription" {
|
||||||
|
var g FullGrant
|
||||||
|
if err = json.Unmarshal([]byte(access), &g); err == nil {
|
||||||
|
access = accessOf(g)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err == nil {
|
||||||
|
err = m.Grants.Put(ctx, key, PartAccess, l.Generation, access)
|
||||||
|
}
|
||||||
|
access = ""
|
||||||
|
if err != nil {
|
||||||
|
errs = append(errs, fmt.Errorf("the access token of %s: %w", l.Name, err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
put = append(put, l.Name)
|
||||||
|
}
|
||||||
|
said["licence-put"] = put
|
||||||
|
return said, errors.Join(errs...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// KeptConditions is each key of the state conditions with its body and since (§43), by key.
|
||||||
|
func (m *Manager) KeptConditions() ([]KeptCondition, error) {
|
||||||
|
out := []KeptCondition{}
|
||||||
|
if m.Conditions == nil {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
keys, err := m.Conditions.Keys()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
for _, k := range keys {
|
||||||
|
e, err := m.Conditions.Get(k)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var c KeptCondition
|
||||||
|
if e == nil || json.Unmarshal(e.Value, &c) != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, c)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Carried is the carry-over's record, or the failure its last run names while it has not ended; with run and no
|
||||||
|
// record it carries over again, and is refused once carried (§44).
|
||||||
|
func (m *Manager) Carried(ctx context.Context, run bool) (map[string]any, error) {
|
||||||
|
if m.Carry == nil {
|
||||||
|
return nil, errors.New("this manager has nothing to carry over")
|
||||||
|
}
|
||||||
|
if m.Carry.Ended() {
|
||||||
|
if run {
|
||||||
|
return nil, errors.New("the carry-over has ended: carried.postgres is written, and it is never done again")
|
||||||
|
}
|
||||||
|
e, err := m.Carry.State.Get(CarriedKey)
|
||||||
|
if err != nil || e == nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var rec CarriedRecord
|
||||||
|
if err := json.Unmarshal(e.Value, &rec); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return map[string]any{"carried": rec}, nil
|
||||||
|
}
|
||||||
|
if !run {
|
||||||
|
return map[string]any{"carried": nil, "failure": m.Carry.Failure()}, nil
|
||||||
|
}
|
||||||
|
bus, _ := m.Store.(*BusStore)
|
||||||
|
rec, err := m.Carry.Run(ctx, m, bus)
|
||||||
|
if serr := m.SayConditions(ctx, false); serr != nil {
|
||||||
|
m.Log("saying the conditions after the carry-over: %v", serr)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return map[string]any{"carried": nil, "failure": m.Carry.Failure()}, err
|
||||||
|
}
|
||||||
|
return map[string]any{"carried": rec}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,328 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// served is every verb and tool the binary serves, by name, run on mm's manager.
|
||||||
|
func served(t *testing.T, mm *miniMesh) map[string]stdTool {
|
||||||
|
t.Helper()
|
||||||
|
built = mm.m
|
||||||
|
t.Cleanup(func() { built = nil })
|
||||||
|
out := map[string]stdTool{}
|
||||||
|
for _, tool := range tools() {
|
||||||
|
out[strings.TrimPrefix(tool.Name, Seat+".")] = stdTool(tool.Run)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// answer is a tool's answer as JSON, decoded into a generic form.
|
||||||
|
func answer(t *testing.T, run stdTool, args map[string]any) any {
|
||||||
|
t.Helper()
|
||||||
|
got, err := run(args)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%v answered %v", args, err)
|
||||||
|
}
|
||||||
|
raw, _ := json.Marshal(got)
|
||||||
|
var v any
|
||||||
|
_ = json.Unmarshal(raw, &v)
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// design 39 bus-and-switch §34/AC1, §47, B40: an answer per licence carries today's fields, its organisation, usage,
|
||||||
|
// room or why it is not known, whether it is the next target, when and from where it was adopted and its open
|
||||||
|
// conditions, and no token.
|
||||||
|
func TestLicencesAnswersEachFieldAndNoToken(t *testing.T) {
|
||||||
|
mm := fourOnA(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
_ = mm.store.SaveLicence(ctx, Licence{Name: "k", Kind: "api-key", Generation: 1, AdoptedAt: t0.UnixMilli(),
|
||||||
|
AdoptedFrom: "a file"})
|
||||||
|
_, _ = saying(mm)
|
||||||
|
_ = mm.store.SaveLicence(ctx, sub("a", read(40, 95)))
|
||||||
|
_ = mm.m.SayConditions(ctx, false)
|
||||||
|
got := answer(t, served(t, mm)["licences"], nil).([]any)
|
||||||
|
by := map[string]map[string]any{}
|
||||||
|
for _, l := range got {
|
||||||
|
f := l.(map[string]any)
|
||||||
|
by[f["name"].(string)] = f
|
||||||
|
for _, k := range []string{"name", "kind", "account", "organisation", "accessExpiresAt", "refreshExpiresAt",
|
||||||
|
"failures", "bound", "usage", "room", "target", "adoptedAt", "adoptedFrom", "conditions"} {
|
||||||
|
if _, ok := f[k]; !ok {
|
||||||
|
t.Errorf("%s has no %s: %v", f["name"], k, f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if by["b"]["target"] != true || by["c"]["target"] != false || by["a"]["room"] != 5.0 ||
|
||||||
|
by["k"]["room"] != "is an API key, whose room is never known" || by["k"]["adoptedFrom"] != "a file" ||
|
||||||
|
len(by["a"]["bound"].([]any)) != 4 || by["a"]["usage"].(map[string]any)["weeklyResetsAt"] != "2026-11-14T09:00:00Z" {
|
||||||
|
t.Fatalf("licences %v", by)
|
||||||
|
}
|
||||||
|
if cs := by["a"]["conditions"].([]any); len(cs) != 1 || cs[0].(map[string]any)["key"] != "licence.a.near-limit" {
|
||||||
|
t.Fatalf("a's conditions %v", cs)
|
||||||
|
}
|
||||||
|
raw, _ := json.Marshal(got)
|
||||||
|
for _, token := range []string{"rt-", "at-", "local-"} {
|
||||||
|
if strings.Contains(string(raw), token) {
|
||||||
|
t.Fatalf("licences holds %s: %s", token, raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// design 39 bus-and-switch §35/AC1: since and limit bound the answer, newest first; raw adds the vendor's answer.
|
||||||
|
func TestUsageIsBoundedBySinceAndLimit(t *testing.T) {
|
||||||
|
mm := newMesh(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
_ = mm.store.SaveLicence(ctx, sub("a", nil))
|
||||||
|
for i := range 30 {
|
||||||
|
_ = mm.store.RecordUsage(ctx, "a", t0.Add(time.Duration(i)*time.Minute).UnixMilli(), UsageReading{},
|
||||||
|
map[string]any{"i": float64(i)})
|
||||||
|
}
|
||||||
|
run := served(t, mm)["usage"]
|
||||||
|
rows := answer(t, run, map[string]any{"licence": "a"}).([]any)
|
||||||
|
first := rows[0].(map[string]any)
|
||||||
|
if len(rows) != 20 || first["at"] != float64(t0.Add(29*time.Minute).UnixMilli()) || first["raw"] != nil {
|
||||||
|
t.Fatalf("by default %d rows, the first %v", len(rows), first)
|
||||||
|
}
|
||||||
|
since := t0.Add(25 * time.Minute).UTC().Format(time.RFC3339)
|
||||||
|
rows = answer(t, run, map[string]any{"since": since, "limit": 3.0, "raw": true}).([]any)
|
||||||
|
if len(rows) != 3 || rows[0].(map[string]any)["raw"].(map[string]any)["i"] != 29.0 {
|
||||||
|
t.Fatalf("since and limit 3: %v", rows)
|
||||||
|
}
|
||||||
|
if rows = answer(t, run, map[string]any{"since": since, "limit": 5000.0}).([]any); len(rows) != 5 {
|
||||||
|
t.Fatalf("since: %d rows", len(rows))
|
||||||
|
}
|
||||||
|
if _, err := run(map[string]any{"since": "yesterday"}); err == nil {
|
||||||
|
t.Fatal("a since that is not a time is answered")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// design 39 bus-and-switch §37/AC1: a switch by usage and one by a person are answered, newest first, with their
|
||||||
|
// causes, a switch by usage with the reading that moved them; licence and consumer narrow the answer.
|
||||||
|
func TestSwitchesAnswersBothCauses(t *testing.T) {
|
||||||
|
mm := fourOnA(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
_, _ = mm.m.ApplyRule(ctx, "", false, false)
|
||||||
|
mm.now = t0.Add(time.Minute)
|
||||||
|
_, _ = mm.m.Bind(WithCause(ctx, Cause{Why: "the switch verb", By: "a person"}), "ace", "c")
|
||||||
|
run := served(t, mm)["licences_switches"]
|
||||||
|
got := answer(t, run, nil).([]any)
|
||||||
|
if len(got) != 2 {
|
||||||
|
t.Fatalf("switches %v", got)
|
||||||
|
}
|
||||||
|
person, usage := got[0].(map[string]any), got[1].(map[string]any)
|
||||||
|
if person["by"] != "a person" || person["why"] != "the switch verb" || person["to"] != "c" || person["reading"] != nil ||
|
||||||
|
usage["by"] != ByUsage || usage["reading"].(map[string]any)["window"] != "weekly" || usage["from"] != "a" {
|
||||||
|
t.Fatalf("switches %v", got)
|
||||||
|
}
|
||||||
|
if got := answer(t, run, map[string]any{"licence": "c"}).([]any); len(got) != 1 {
|
||||||
|
t.Fatalf("of c %v", got)
|
||||||
|
}
|
||||||
|
if got := answer(t, run, map[string]any{"consumer": "g14"}).([]any); len(got) != 1 || got[0].(map[string]any)["by"] != ByUsage {
|
||||||
|
t.Fatalf("of g14 %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// design 39 bus-and-switch §38/AC1, §24, §32: reading usage now writes the reading and applies the rule, and with dry
|
||||||
|
// reads, answers the moves and moves nothing.
|
||||||
|
func TestReadUsageNowAppliesTheRuleUnlessDry(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
for _, dry := range []bool{true, false} {
|
||||||
|
mm := newMesh(t)
|
||||||
|
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0)})
|
||||||
|
_ = mm.store.SaveLicence(ctx, Licence{Name: "b@example.org", Kind: "subscription", Usage: read(10, 10)})
|
||||||
|
mm.vendor.usage = map[string]any{"five_hour": map[string]any{"utilization": 30.0},
|
||||||
|
"seven_day": map[string]any{"utilization": 99.0, "resets_at": "2026-11-14T09:00:00Z"}}
|
||||||
|
got := answer(t, served(t, mm)["licences_read_usage"], map[string]any{"dry": dry}).([]any)
|
||||||
|
r := got[0].(map[string]any)
|
||||||
|
moved := mm.bound("laptop").Licence == "b@example.org"
|
||||||
|
if len(got) != 1 || r["reading"].(map[string]any)["weeklyPct"] != 99.0 ||
|
||||||
|
len(r["switches"].([]any)) != 1 || moved == dry || len(mm.usage.under("")) != 1 {
|
||||||
|
t.Fatalf("dry %v: answered %v, moved %v", dry, got, moved)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// design 39 bus-and-switch §39/AC1, §32: switching now runs the rule with switch_auto off, and dry moves nothing; an
|
||||||
|
// acting tool waits for the carry-over (§11).
|
||||||
|
func TestSwitchNowRunsWithSwitchAutoOff(t *testing.T) {
|
||||||
|
mm := fourOnA(t)
|
||||||
|
mm.m.Settings.SwitchAuto = false
|
||||||
|
run := served(t, mm)["licences_switch_now"]
|
||||||
|
if got := answer(t, run, map[string]any{"dry": true}).([]any); len(got) != 1 || mm.bound("ace").Licence != "a" {
|
||||||
|
t.Fatalf("dry %v", got)
|
||||||
|
}
|
||||||
|
if got := answer(t, run, map[string]any{"licence": "a"}).([]any); len(got) != 1 || mm.bound("ace").Licence != "b" {
|
||||||
|
t.Fatalf("switched %v, ace on %s", got, mm.bound("ace").Licence)
|
||||||
|
}
|
||||||
|
mm.m.Carry = &Carry{State: newMemState(func() time.Time { return mm.now })}
|
||||||
|
if _, err := run(nil); err == nil || err.Error() != NotCarried {
|
||||||
|
t.Fatalf("before the carry-over answered %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// design 39 bus-and-switch §40/AC1, B18: a value set at node level is answered with its level, the rest with the
|
||||||
|
// default, each with its value and default, and an invalid one saying why.
|
||||||
|
func TestSettingsAnswersTheLevel(t *testing.T) {
|
||||||
|
mm := newMesh(t)
|
||||||
|
mm.m.Settings.SwitchAt, mm.m.Settings.WarnAt = 80, 85
|
||||||
|
mm.m.ConfigLevels = func(context.Context) (map[string]string, error) {
|
||||||
|
return map[string]string{"switch_at_pct": "node", "warn_at_pct": "mesh", "cadence_minutes": "default"}, nil
|
||||||
|
}
|
||||||
|
got := answer(t, served(t, mm)["licences_settings"], nil).([]any)
|
||||||
|
by := map[string]map[string]any{}
|
||||||
|
for _, s := range got {
|
||||||
|
f := s.(map[string]any)
|
||||||
|
by[f["setting"].(string)] = f
|
||||||
|
}
|
||||||
|
if len(by) != len(Defaults.Values()) || by["switch_at_pct"]["level"] != "node" || by["switch_at_pct"]["value"] != 80.0 ||
|
||||||
|
by["switch_at_pct"]["default"] != 99.0 || by["usage_minutes"]["level"] != "default" ||
|
||||||
|
!strings.Contains(fmtAny(by["room_below_pct"]["invalid"]), "above switch_at_pct 80") ||
|
||||||
|
!strings.Contains(fmtAny(by["warn_at_pct"]["invalid"]), "85") || by["switch_auto"]["invalid"] != nil {
|
||||||
|
t.Fatalf("settings %v", by)
|
||||||
|
}
|
||||||
|
mm.m.ConfigLevels = func(context.Context) (map[string]string, error) { return nil, errors.New("no responders") }
|
||||||
|
if _, err := served(t, mm)["licences_settings"](nil); err == nil {
|
||||||
|
t.Fatal("mesh-config not answering is answered as the default")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func fmtAny(v any) string { s, _ := v.(string); return s }
|
||||||
|
|
||||||
|
// design 39 bus-and-switch §41/AC1, §20: every bus-state entry the manifest asks for is read by licences_kept, with its
|
||||||
|
// keys or subjects, one key's value and its fill; and no entry holds a token.
|
||||||
|
func TestKeptReadsEveryEntryOfTheManifest(t *testing.T) {
|
||||||
|
raw, err := os.ReadFile("../../module.json")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var m struct {
|
||||||
|
Requires []json.RawMessage `json:"requires"`
|
||||||
|
}
|
||||||
|
_ = json.Unmarshal(raw, &m)
|
||||||
|
mm := newMesh(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
_, _ = saying(mm)
|
||||||
|
mm.m.Carry = &Carry{State: newMemState(func() time.Time { return mm.now })}
|
||||||
|
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-secret-1", true, t0)})
|
||||||
|
_ = mm.m.ReadUsage(ctx)
|
||||||
|
run := served(t, mm)["licences_kept"]
|
||||||
|
all := answer(t, run, nil).(map[string]any)
|
||||||
|
entries := 0
|
||||||
|
for _, r := range m.Requires {
|
||||||
|
var e struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Template string `json:"template"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal(r, &e) != nil || e.Template == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
entries++
|
||||||
|
got, ok := all[e.Name].(map[string]any)
|
||||||
|
if !ok || got["fill"] == nil || got["kind"] != map[string]string{"state": "state", "log": "log"}[e.Template] {
|
||||||
|
t.Errorf("licences_kept does not read %s as a %s: %v", e.Name, e.Template, all[e.Name])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if entries != 8 || len(all) != entries {
|
||||||
|
t.Fatalf("the manifest asks for %d entries, licences_kept reads %d", entries, len(all))
|
||||||
|
}
|
||||||
|
one := answer(t, run, map[string]any{"entry": "licences", "key": KeyFor(licence1)}).(map[string]any)
|
||||||
|
if one["licences"].(map[string]any)["value"] == nil {
|
||||||
|
t.Fatalf("one key %v", one)
|
||||||
|
}
|
||||||
|
if subjects := all["usage"].(map[string]any)["subjects"].(map[string]any); subjects[KeyFor(licence1)] == nil {
|
||||||
|
t.Fatalf("usage subjects %v", subjects)
|
||||||
|
}
|
||||||
|
if _, err := run(map[string]any{"entry": "tables"}); err == nil {
|
||||||
|
t.Fatal("an entry not kept is answered")
|
||||||
|
}
|
||||||
|
everything, _ := json.Marshal(all)
|
||||||
|
for _, token := range []string{"rt-secret", "rt-1", "at-1", "local-"} {
|
||||||
|
if strings.Contains(string(everything), token) {
|
||||||
|
t.Fatalf("a kept entry holds %s", token)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// design 39 bus-and-switch §42/AC1: resync says binding.changed once per binding, holdings.asked once, each condition
|
||||||
|
// once, and licence-put of each licence's access once.
|
||||||
|
func TestResyncSaysEachOnce(t *testing.T) {
|
||||||
|
mm := newMesh(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
_, said := saying(mm)
|
||||||
|
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0), mm.login("server", "rt-b", true, t0)})
|
||||||
|
mm.m.Settings.WarnAt = 100
|
||||||
|
_ = mm.m.SayConditions(ctx, false)
|
||||||
|
mm.events, *said = nil, nil
|
||||||
|
mm.grants.refuseAccess = true // §42: the access token is built from the grant, never read back from the vault
|
||||||
|
got := answer(t, served(t, mm)["licences_resync"], nil).(map[string]any)
|
||||||
|
count := map[string]int{}
|
||||||
|
for _, e := range mm.events {
|
||||||
|
name, _, _ := strings.Cut(e, " ")
|
||||||
|
count[name]++
|
||||||
|
}
|
||||||
|
puts, _ := got["licence-put"].([]any)
|
||||||
|
if count["binding.changed"] != 2 || count[HoldingsAsked] != 1 || len(*said) != 1 || len(puts) != 1 || puts[0] != licence1 {
|
||||||
|
t.Fatalf("said %v, conditions %d, answered %v", mm.events, len(*said), got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// design 39 bus-and-switch §43/AC1: each key of conditions is answered with its body and since.
|
||||||
|
func TestConditionsAnswersEachKey(t *testing.T) {
|
||||||
|
mm := fourOnA(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
_, _ = saying(mm)
|
||||||
|
_ = mm.store.SaveLicence(ctx, sub("a", read(40, 95)))
|
||||||
|
mm.m.Settings.RoomBelow = 100
|
||||||
|
_ = mm.m.SayConditions(ctx, false)
|
||||||
|
got := answer(t, served(t, mm)["licences_conditions"], nil).([]any)
|
||||||
|
var keys []string
|
||||||
|
for _, c := range got {
|
||||||
|
f := c.(map[string]any)
|
||||||
|
if f["since"] == "" || f["body"].(map[string]any)["headline"] == "" {
|
||||||
|
t.Fatalf("condition %v", f)
|
||||||
|
}
|
||||||
|
keys = append(keys, f["body"].(map[string]any)["key"].(string))
|
||||||
|
}
|
||||||
|
if strings.Join(keys, ",") != "licence.a.near-limit,settings.room_below_pct.invalid" {
|
||||||
|
t.Fatalf("keys %v", keys)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// design 39 bus-and-switch §44/AC1: licences_carried answers the failure while not carried, carries over with run, then
|
||||||
|
// answers the record and refuses run.
|
||||||
|
func TestCarriedRunIsRefusedOnceCarried(t *testing.T) {
|
||||||
|
mm := newMesh(t)
|
||||||
|
_, said := saying(mm)
|
||||||
|
down := true
|
||||||
|
mm.m.Carry = &Carry{State: newMemState(func() time.Time { return mm.now }),
|
||||||
|
Source: func(context.Context) (CarrySource, error) {
|
||||||
|
if down {
|
||||||
|
return nil, errors.New("connection refused")
|
||||||
|
}
|
||||||
|
return &memSource{}, nil
|
||||||
|
}}
|
||||||
|
run := served(t, mm)["licences_carried"]
|
||||||
|
if _, err := run(map[string]any{"run": true}); err == nil || len(of(*said, CarryOverFailed)) != 1 {
|
||||||
|
t.Fatalf("a failed run answered %v, said %v", err, *said)
|
||||||
|
}
|
||||||
|
if got := answer(t, run, nil).(map[string]any); !strings.Contains(fmtAny(got["failure"]), "connection refused") {
|
||||||
|
t.Fatalf("not carried %v", got)
|
||||||
|
}
|
||||||
|
down = false
|
||||||
|
if got := answer(t, run, map[string]any{"run": true}).(map[string]any); got["carried"] == nil ||
|
||||||
|
strings.Join(of(*said, CarryOverFailed), ",") != "raised,cleared" {
|
||||||
|
t.Fatalf("carried %v, said %v", got, *said)
|
||||||
|
}
|
||||||
|
if got := answer(t, run, nil).(map[string]any); got["carried"].(map[string]any)["tables"] == nil {
|
||||||
|
t.Fatalf("the record %v", got)
|
||||||
|
}
|
||||||
|
if _, err := run(map[string]any{"run": true}); err == nil {
|
||||||
|
t.Fatal("run is answered once carried")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// The vault holds every licence's grant and access token (design 39 bus-and-switch §15, §16, B4, B5): the manager hands
|
||||||
|
// what it gets to `licence-put` in two steps, with a key the vault made for the call, and reads a grant with `get`,
|
||||||
|
// sealed to its own public key, for one act.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||||
|
"golang.org/x/crypto/nacl/box"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The parts of a licence the vault holds (§15).
|
||||||
|
const (
|
||||||
|
PartGrant = "grant"
|
||||||
|
PartAccess = "access"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Grants is where a licence's two secrets are kept.
|
||||||
|
type Grants interface {
|
||||||
|
// Put hands one part of a licence, by its key, to the vault at a generation; an older one is refused.
|
||||||
|
Put(ctx context.Context, key, part string, generation int64, value string) error
|
||||||
|
// Get reads one part of a licence from the vault.
|
||||||
|
Get(ctx context.Context, key, part string) (string, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SecretName is the name of one part of a licence in the vault.
|
||||||
|
func SecretName(key, part string) string { return "licence." + key + "." + part }
|
||||||
|
|
||||||
|
// VaultGrants is the vault on the bus, asked as the seat mesh-vault.
|
||||||
|
type VaultGrants struct{ Keys KeyPair }
|
||||||
|
|
||||||
|
// Put asks licence-put's two steps.
|
||||||
|
func (v VaultGrants) Put(_ context.Context, key, part string, generation int64, value string) error {
|
||||||
|
raw, err := stdio.Ask("seat:mesh-vault.licence-put", map[string]any{"licence": key, "part": part, "generation": generation})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var opened struct {
|
||||||
|
Call string `json:"call"`
|
||||||
|
SealTo string `json:"seal_to"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &opened); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
pub, err := base64.StdEncoding.DecodeString(opened.SealTo)
|
||||||
|
if err != nil || len(pub) != 32 || opened.Call == "" {
|
||||||
|
return fmt.Errorf("licence-put answered no call and key to seal to: %s", raw)
|
||||||
|
}
|
||||||
|
var to [32]byte
|
||||||
|
copy(to[:], pub)
|
||||||
|
sealed, err := box.SealAnonymous(nil, []byte(value), &to, rand.Reader)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = stdio.Ask("seat:mesh-vault.licence-put", map[string]any{"call": opened.Call,
|
||||||
|
"sealed": base64.StdEncoding.EncodeToString(sealed)})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get asks get, sealed to the manager's public key, and opens the answer.
|
||||||
|
func (v VaultGrants) Get(_ context.Context, key, part string) (string, error) {
|
||||||
|
raw, err := stdio.Ask("seat:mesh-vault.get", map[string]any{"name": SecretName(key, part), "public_key": v.Keys.PublicKey})
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
var a struct {
|
||||||
|
Sealed SealedBox `json:"sealed"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &a); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return Open(a.Sealed, v.Keys.PrivateKey)
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// design 39 bus-and-switch §15, §16, §20: a rotation reads the grant from the vault and hands it back, the grant first
|
||||||
|
// and then the access, at the next generation; the licence keeps no token once the call returns.
|
||||||
|
func TestARotationPutsTwiceAndKeepsNoToken(t *testing.T) {
|
||||||
|
mm := newMesh(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
if _, err := mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0)}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
key := KeyFor(licence1)
|
||||||
|
mm.grants.puts = nil
|
||||||
|
if r, err := mm.m.Rotate(ctx, licence1, true); err != nil || r["refreshed"] != true {
|
||||||
|
t.Fatalf("%v %v", r, err)
|
||||||
|
}
|
||||||
|
want := []string{SecretName(key, PartGrant) + "@2", SecretName(key, PartAccess) + "@2"}
|
||||||
|
if strings.Join(mm.grants.puts, " ") != strings.Join(want, " ") {
|
||||||
|
t.Fatalf("the rotation put %v, want %v", mm.grants.puts, want)
|
||||||
|
}
|
||||||
|
if g, _ := mm.grants.Get(ctx, key, PartGrant); !strings.Contains(g, `"refreshToken":"rt-2"`) {
|
||||||
|
t.Fatalf("the vault holds the grant %s", g)
|
||||||
|
}
|
||||||
|
if a, _ := mm.grants.Get(ctx, key, PartAccess); !strings.Contains(a, `"accessToken":"at-2"`) || strings.Contains(a, "refreshToken\":\"rt") {
|
||||||
|
t.Fatalf("the vault holds the access %s", a)
|
||||||
|
}
|
||||||
|
l, _ := mm.store.Licence(ctx, licence1)
|
||||||
|
raw, _ := json.Marshal(l)
|
||||||
|
for _, token := range []string{"rt-", "at-"} {
|
||||||
|
if strings.Contains(string(raw), token) {
|
||||||
|
t.Fatalf("the licence keeps %s: %s", token, raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if l.Generation != 2 {
|
||||||
|
t.Fatalf("the licence's generation is %d, want 2", l.Generation)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -140,6 +140,8 @@ func (v liveVendor) Refresh(ctx context.Context, g FullGrant) Refreshed {
|
|||||||
return NextGrant(g, r, time.Now().UnixMilli())
|
return NextGrant(g, r, time.Now().UnixMilli())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Usage is the vendor's usage answer whole; a failed answer is an error naming its status and reason, the body, never
|
||||||
|
// only the status (design 39 bus-and-switch §24).
|
||||||
func (v liveVendor) Usage(ctx context.Context, accessToken string) (map[string]any, error) {
|
func (v liveVendor) Usage(ctx context.Context, accessToken string) (map[string]any, error) {
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, usageEndpoint(), nil)
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, usageEndpoint(), nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -148,25 +150,38 @@ func (v liveVendor) Usage(ctx context.Context, accessToken string) (map[string]a
|
|||||||
req.Header.Set("authorization", "Bearer "+accessToken)
|
req.Header.Set("authorization", "Bearer "+accessToken)
|
||||||
resp, err := v.client.Do(req)
|
resp, err := v.client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, fmt.Errorf("the usage endpoint did not answer: %w", err)
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||||
if resp.StatusCode/100 != 2 {
|
if resp.StatusCode/100 != 2 {
|
||||||
return nil, fmt.Errorf("the usage endpoint answered %d", resp.StatusCode)
|
reason := string(body)
|
||||||
|
if len(reason) > 400 {
|
||||||
|
reason = reason[:400]
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("the usage endpoint answered %d: %s", resp.StatusCode, reason)
|
||||||
}
|
}
|
||||||
var out map[string]any
|
var out map[string]any
|
||||||
return out, json.NewDecoder(resp.Body).Decode(&out)
|
if err := json.Unmarshal(body, &out); err != nil {
|
||||||
|
return nil, fmt.Errorf("the usage endpoint answered %d with what is not JSON", resp.StatusCode)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// UsageReading is the licence-grain reading (ADR 0054), flattened from the vendor's windows.
|
// UsageReading is the licence-grain reading (ADR 0054), flattened from the vendor's windows (design 39 bus-and-switch
|
||||||
|
// §24), with, as the licence keeps it (§2), when it was last read successfully and why the latest reading failed.
|
||||||
type UsageReading struct {
|
type UsageReading struct {
|
||||||
SessionPct *float64 `json:"sessionPct"`
|
SessionPct *float64 `json:"sessionPct"`
|
||||||
SessionResetsAt string `json:"sessionResetsAt,omitempty"`
|
SessionResetsAt string `json:"sessionResetsAt,omitempty"`
|
||||||
WeeklyPct *float64 `json:"weeklyPct"`
|
WeeklyPct *float64 `json:"weeklyPct"`
|
||||||
|
WeeklyResetsAt string `json:"weeklyResetsAt,omitempty"`
|
||||||
SonnetPct *float64 `json:"sonnetPct"`
|
SonnetPct *float64 `json:"sonnetPct"`
|
||||||
|
SonnetResetsAt string `json:"sonnetResetsAt,omitempty"`
|
||||||
|
ReadAt string `json:"readAt,omitempty"`
|
||||||
|
ReadError string `json:"readError,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// FlattenUsage reads the windows the predecessor read.
|
// FlattenUsage reads the session, weekly and sonnet windows, each with its reset time (§24).
|
||||||
func FlattenUsage(u map[string]any) UsageReading {
|
func FlattenUsage(u map[string]any) UsageReading {
|
||||||
window := func(k string) (*float64, string) {
|
window := func(k string) (*float64, string) {
|
||||||
w, ok := u[k].(map[string]any)
|
w, ok := u[k].(map[string]any)
|
||||||
@@ -180,8 +195,9 @@ func FlattenUsage(u map[string]any) UsageReading {
|
|||||||
}
|
}
|
||||||
return &pct, resets
|
return &pct, resets
|
||||||
}
|
}
|
||||||
s, resets := window("five_hour")
|
s, sResets := window("five_hour")
|
||||||
w, _ := window("seven_day")
|
w, wResets := window("seven_day")
|
||||||
so, _ := window("seven_day_sonnet")
|
so, soResets := window("seven_day_sonnet")
|
||||||
return UsageReading{SessionPct: s, SessionResetsAt: resets, WeeklyPct: w, SonnetPct: so}
|
return UsageReading{SessionPct: s, SessionResetsAt: sResets, WeeklyPct: w, WeeklyResetsAt: wResets, SonnetPct: so,
|
||||||
|
SonnetResetsAt: soResets}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -3,5 +3,11 @@
|
|||||||
"floor_minutes": 60,
|
"floor_minutes": 60,
|
||||||
"failures_to_notify": 3,
|
"failures_to_notify": 3,
|
||||||
"cooldown_hours": 24,
|
"cooldown_hours": 24,
|
||||||
"refresh_warn_days": 3
|
"refresh_warn_days": 3,
|
||||||
|
"switch_auto": true,
|
||||||
|
"switch_at_pct": 99,
|
||||||
|
"room_below_pct": 90,
|
||||||
|
"warn_at_pct": 90,
|
||||||
|
"usage_minutes": 5,
|
||||||
|
"usage_stale_minutes": 15
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,20 +3,28 @@ module claude-licence-manager
|
|||||||
go 1.26.0
|
go 1.26.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient v0.0.0-00010101000000-000000000000
|
||||||
|
git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel v0.0.0-00010101000000-000000000000
|
||||||
git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000
|
git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000
|
||||||
github.com/jackc/pgx/v5 v5.11.0
|
github.com/jackc/pgx/v5 v5.11.0
|
||||||
|
github.com/nats-io/nats.go v1.54.0
|
||||||
|
golang.org/x/crypto v0.57.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op // indirect
|
||||||
|
github.com/google/go-tpm v0.9.8 // indirect
|
||||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||||
github.com/klauspost/compress v1.20.1 // indirect
|
github.com/klauspost/compress v1.20.1 // indirect
|
||||||
github.com/nats-io/nats.go v1.54.0 // indirect
|
github.com/minio/highwayhash v1.0.4 // indirect
|
||||||
|
github.com/nats-io/jwt/v2 v2.8.2 // indirect
|
||||||
|
github.com/nats-io/nats-server/v2 v2.11.17 // indirect
|
||||||
github.com/nats-io/nkeys v0.4.16 // indirect
|
github.com/nats-io/nkeys v0.4.16 // indirect
|
||||||
github.com/nats-io/nuid v1.0.1 // indirect
|
github.com/nats-io/nuid v1.0.1 // indirect
|
||||||
golang.org/x/crypto v0.57.0 // indirect
|
|
||||||
golang.org/x/sync v0.23.0 // indirect
|
golang.org/x/sync v0.23.0 // indirect
|
||||||
golang.org/x/sys v0.48.0 // indirect
|
golang.org/x/sys v0.48.0 // indirect
|
||||||
golang.org/x/text v0.42.0 // indirect
|
golang.org/x/text v0.42.0 // indirect
|
||||||
|
golang.org/x/time v0.16.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,38 +1,50 @@
|
|||||||
|
github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
|
||||||
|
github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op h1:1BOWQJweNyvZMlpAHXGLiZQn9S+QXGcz3xh94lC0w6E=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
|
||||||
|
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
|
||||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||||
github.com/jackc/pgx/v5 v5.11.0 h1:IzBBtyK9AHqf98cctWFifYSci2hgQR/cd56wB4p+ogg=
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||||
github.com/jackc/pgx/v5 v5.11.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
github.com/jackc/pgx/v5 v5.11.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
github.com/jackc/pgx/v5 v5.11.0 h1:IzBBtyK9AHqf98cctWFifYSci2hgQR/cd56wB4p+ogg=
|
||||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||||
github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ=
|
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||||
github.com/klauspost/compress v1.20.1/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
|
github.com/klauspost/compress v1.20.1/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
|
||||||
github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA=
|
github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ=
|
||||||
|
github.com/minio/highwayhash v1.0.4/go.mod h1:GGYsuwP/fPD6Y9hMiXuapVvlIUEhFhMTh0rxU3ik1LQ=
|
||||||
|
github.com/minio/highwayhash v1.0.4 h1:asJizugGgchQod2ja9NJlGOWq4s7KsAWr5XUc9Clgl4=
|
||||||
|
github.com/nats-io/jwt/v2 v2.8.2/go.mod h1:Ag/56sq9OblL4JgdYufDd16Egb17Kr/8WwwuO/forVc=
|
||||||
|
github.com/nats-io/jwt/v2 v2.8.2 h1:XXRgB60MSTnqsRwejQurVDs/hcv2dkt+86GjI+I/bMc=
|
||||||
github.com/nats-io/nats.go v1.54.0/go.mod h1:y+DZoD1oBOYfZTU681eTUiUjI0vbqYGixNVFHcjHJ0k=
|
github.com/nats-io/nats.go v1.54.0/go.mod h1:y+DZoD1oBOYfZTU681eTUiUjI0vbqYGixNVFHcjHJ0k=
|
||||||
github.com/nats-io/nkeys v0.4.16 h1:rd5oAuLOb8mnAycB0xleuEBNS1pVVnN0fv/FF34Eypg=
|
github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA=
|
||||||
|
github.com/nats-io/nats-server/v2 v2.11.17/go.mod h1:B1sFVz4StNosQ903ak4N1G01Fl/9f8e06mXpFIE2K24=
|
||||||
|
github.com/nats-io/nats-server/v2 v2.11.17 h1:GKEghcFK6A+aFx11Yf1LjgLC3txAwvyhnYzhBIQZA8I=
|
||||||
github.com/nats-io/nkeys v0.4.16/go.mod h1:llLgWoI0o4z/Q57q2R1kHfmocyhGV6VG/U18Glg1Afs=
|
github.com/nats-io/nkeys v0.4.16/go.mod h1:llLgWoI0o4z/Q57q2R1kHfmocyhGV6VG/U18Glg1Afs=
|
||||||
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
|
github.com/nats-io/nkeys v0.4.16 h1:rd5oAuLOb8mnAycB0xleuEBNS1pVVnN0fv/FF34Eypg=
|
||||||
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
|
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
|
||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
|
||||||
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
|
|
||||||
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
|
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
|
||||||
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
|
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
|
||||||
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
|
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
|
||||||
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
|
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
|
||||||
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||||
golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
|
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
|
||||||
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
|
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
|
||||||
|
golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
|
||||||
|
golang.org/x/time v0.16.0/go.mod h1:rVKOqvZeKvrDKTQiAHJ7wmwP0RzleSphoEA9RcdLA0s=
|
||||||
|
golang.org/x/time v0.16.0 h1:vMb6ptszcQMkcwiRTAuNNU50gom6++Q/6gY2hDM6VDE=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
|||||||
@@ -4,7 +4,74 @@
|
|||||||
"slug": "licmgr",
|
"slug": "licmgr",
|
||||||
"requires": [
|
"requires": [
|
||||||
"postgres-database",
|
"postgres-database",
|
||||||
"secret"
|
"secret",
|
||||||
|
{
|
||||||
|
"name": "licences",
|
||||||
|
"storage": "key-value",
|
||||||
|
"template": "state",
|
||||||
|
"options": {
|
||||||
|
"history": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "bindings",
|
||||||
|
"storage": "key-value",
|
||||||
|
"template": "state",
|
||||||
|
"options": {
|
||||||
|
"history": 8
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "leases",
|
||||||
|
"storage": "key-value",
|
||||||
|
"template": "state",
|
||||||
|
"options": {
|
||||||
|
"history": 1,
|
||||||
|
"key-ttl": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "offered",
|
||||||
|
"storage": "key-value",
|
||||||
|
"template": "state",
|
||||||
|
"options": {
|
||||||
|
"history": 1,
|
||||||
|
"key-ttl": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "usage",
|
||||||
|
"storage": "stream",
|
||||||
|
"template": "log",
|
||||||
|
"options": {
|
||||||
|
"max-age-days": 35,
|
||||||
|
"max-mib": 256
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "audit",
|
||||||
|
"storage": "stream",
|
||||||
|
"template": "log",
|
||||||
|
"options": {
|
||||||
|
"max-mib": 64
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "carried",
|
||||||
|
"storage": "key-value",
|
||||||
|
"template": "state",
|
||||||
|
"options": {
|
||||||
|
"history": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "conditions",
|
||||||
|
"storage": "key-value",
|
||||||
|
"template": "state",
|
||||||
|
"options": {
|
||||||
|
"history": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
],
|
],
|
||||||
"contributes": {
|
"contributes": {
|
||||||
"postgres-database": {
|
"postgres-database": {
|
||||||
@@ -24,6 +91,15 @@
|
|||||||
"grant-key": "${dir:state}/grant.key"
|
"grant-key": "${dir:state}/grant.key"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"uses": [
|
||||||
|
"operator-channel"
|
||||||
|
],
|
||||||
|
"invokes": [
|
||||||
|
"seat:operator-channel.tell",
|
||||||
|
"seat:mesh-vault.licence-put",
|
||||||
|
"seat:mesh-vault.get",
|
||||||
|
"seat:mesh-config.config"
|
||||||
|
],
|
||||||
"seats": [
|
"seats": [
|
||||||
{
|
{
|
||||||
"name": "anthropic-licence-manager",
|
"name": "anthropic-licence-manager",
|
||||||
@@ -63,18 +139,14 @@
|
|||||||
"emits": [
|
"emits": [
|
||||||
"licence.adopted",
|
"licence.adopted",
|
||||||
"licence.refused",
|
"licence.refused",
|
||||||
"licence.bound",
|
|
||||||
"licence.switched",
|
"licence.switched",
|
||||||
"licence.released",
|
|
||||||
"licence.rotated",
|
|
||||||
"licence.failing",
|
"licence.failing",
|
||||||
"usage.read"
|
"usage.read",
|
||||||
|
"binding.changed",
|
||||||
|
"holdings.asked"
|
||||||
],
|
],
|
||||||
"state": [
|
"consumes": [
|
||||||
"bindings"
|
"claude-code.holdings-changed"
|
||||||
],
|
|
||||||
"reads": [
|
|
||||||
"claude-code.holdings"
|
|
||||||
],
|
],
|
||||||
"config": {
|
"config": {
|
||||||
"cadence_minutes": {
|
"cadence_minutes": {
|
||||||
@@ -111,6 +183,52 @@
|
|||||||
"minimum": 0,
|
"minimum": 0,
|
||||||
"trusted": true,
|
"trusted": true,
|
||||||
"default": 3
|
"default": 3
|
||||||
|
},
|
||||||
|
"switch_auto": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "Whether the manager moves every consumer off a licence whose session or weekly usage reaches switch_at_pct, to the licence with the most room; off, it still raises its no-room and near-limit conditions.",
|
||||||
|
"trusted": true,
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
"switch_at_pct": {
|
||||||
|
"type": "number",
|
||||||
|
"description": "The session or weekly usage of a licence, in percent, at which its consumers are moved to the licence with the most room.",
|
||||||
|
"minimum": 1,
|
||||||
|
"maximum": 100,
|
||||||
|
"trusted": true,
|
||||||
|
"default": 99
|
||||||
|
},
|
||||||
|
"room_below_pct": {
|
||||||
|
"type": "number",
|
||||||
|
"description": "What both the session and the weekly usage of a licence must be below, in percent, for it to be a target of a switch.",
|
||||||
|
"minimum": 1,
|
||||||
|
"maximum": 100,
|
||||||
|
"trusted": true,
|
||||||
|
"default": 90
|
||||||
|
},
|
||||||
|
"warn_at_pct": {
|
||||||
|
"type": "number",
|
||||||
|
"description": "The session or weekly usage of a licence with consumers, in percent, at which it is warned about once.",
|
||||||
|
"minimum": 1,
|
||||||
|
"maximum": 100,
|
||||||
|
"trusted": true,
|
||||||
|
"default": 90
|
||||||
|
},
|
||||||
|
"usage_minutes": {
|
||||||
|
"type": "number",
|
||||||
|
"description": "How often each subscription's usage is read, in minutes.",
|
||||||
|
"minimum": 1,
|
||||||
|
"maximum": 60,
|
||||||
|
"trusted": true,
|
||||||
|
"default": 5
|
||||||
|
},
|
||||||
|
"usage_stale_minutes": {
|
||||||
|
"type": "number",
|
||||||
|
"description": "How old a subscription's last successful usage reading may grow before its room is not known and it is said, in minutes.",
|
||||||
|
"minimum": 5,
|
||||||
|
"maximum": 180,
|
||||||
|
"trusted": true,
|
||||||
|
"default": 15
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"resources": [
|
"resources": [
|
||||||
@@ -133,28 +251,33 @@
|
|||||||
"type": "file",
|
"type": "file",
|
||||||
"path": "${dir:state}/settings.json",
|
"path": "${dir:state}/settings.json",
|
||||||
"mode": "0600",
|
"mode": "0600",
|
||||||
"takes-config": ["cadence_minutes", "floor_minutes", "failures_to_notify", "cooldown_hours", "refresh_warn_days"],
|
"takes-config": [
|
||||||
|
"cadence_minutes",
|
||||||
|
"floor_minutes",
|
||||||
|
"failures_to_notify",
|
||||||
|
"cooldown_hours",
|
||||||
|
"refresh_warn_days",
|
||||||
|
"switch_auto",
|
||||||
|
"switch_at_pct",
|
||||||
|
"room_below_pct",
|
||||||
|
"warn_at_pct",
|
||||||
|
"usage_minutes",
|
||||||
|
"usage_stale_minutes"
|
||||||
|
],
|
||||||
"merge": "json",
|
"merge": "json",
|
||||||
"template": "files/settings.json"
|
"template": "files/settings.json"
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "prepare",
|
|
||||||
"type": "process",
|
|
||||||
"name": "claude-licence-manager-prepare",
|
|
||||||
"artifact": "code",
|
|
||||||
"run": [
|
|
||||||
"./claude-licence-manager",
|
|
||||||
"prepare"
|
|
||||||
],
|
|
||||||
"run-once": true,
|
|
||||||
"env": {
|
|
||||||
"DATABASE_URL_FILE": "${dir:state}/database.url"
|
|
||||||
},
|
|
||||||
"restart-on": [
|
|
||||||
"database-url"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"tools": [
|
||||||
|
"licences_switches",
|
||||||
|
"licences_read_usage",
|
||||||
|
"licences_switch_now",
|
||||||
|
"licences_settings",
|
||||||
|
"licences_kept",
|
||||||
|
"licences_resync",
|
||||||
|
"licences_conditions",
|
||||||
|
"licences_carried"
|
||||||
|
],
|
||||||
"build": {
|
"build": {
|
||||||
"artifacts": [
|
"artifacts": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -136,6 +136,21 @@ the declaration sealed by the vault to their machine, which the controller carri
|
|||||||
machine's sealing key as its own node-engine stated it in its own name, in `move-in`'s first step (`sealing_key`).
|
machine's sealing key as its own node-engine stated it in its own name, in `move-in`'s first step (`sealing_key`).
|
||||||
A machine whose engine stated none, any other key, any other module and any other name are refused and recorded.
|
A machine whose engine stated none, any other key, any other module and any other name are refused and recorded.
|
||||||
|
|
||||||
|
**A licence's two secrets, for the licence manager and the machines its bindings name** (design 39 bus-and-switch
|
||||||
|
§15 to §17, B2, B3, B8, B21, B25, B31, B32, B42; `licences.go`, `licencebindings.go`). The licence manager hands
|
||||||
|
each licence's grant and access token to the vault with `mesh-vault.licence-put`, in two steps: the licence's key, the
|
||||||
|
part (`grant` or `access`) and the generation, answered with a call and a key made for it; then the call and the value
|
||||||
|
sealed to that key. The caller's generation is the record's: the newest is kept, an older one refused, and
|
||||||
|
`secret-changed` says the name `licence.<key>.<part>` and its generation, never the value. `get` hands a grant, sealed to
|
||||||
|
the key it is asked with, with no machine guard (B3); it hands an access token only to a machine on which a consumer's binding
|
||||||
|
names that licence, by the licence's key as the manager says it. Which licence each consumer is bound to the vault
|
||||||
|
keeps in its own state `licence-bindings` (one key per consumer: licence, key, generation; deleted on a release),
|
||||||
|
learned from the manager's `binding.changed`, which it consumes, and at its start from its first view,
|
||||||
|
`anthropic-licence-manager.bindings`, asked until it answers. A first view never replaces a binding of a newer
|
||||||
|
generation, and an access token asked before the first view answered is refused, saying why. The vault never reads
|
||||||
|
the manager's state. `vault_first_views` shows this first view beside the assignments', and `vault_first_view_again`
|
||||||
|
asks one again (`of`: `mesh-delivery-assigner.assignments` or `claude-licence-manager.bindings`; absent, both).
|
||||||
|
|
||||||
## Taking values in: `secret-ask`, `issued`, `rotate`
|
## Taking values in: `secret-ask`, `issued`, `rotate`
|
||||||
|
|
||||||
Design 57's V2 (issue 675; ADR 0349 clauses 8 to 10, ADR 0277 §22–§27 and ADR 0305 with the vault in the
|
Design 57's V2 (issue 675; ADR 0349 clauses 8 to 10, ADR 0277 §22–§27 and ADR 0305 with the vault in the
|
||||||
|
|||||||
@@ -168,25 +168,48 @@ func leaverKey(key string) (string, bool) {
|
|||||||
return node, ok && node != ""
|
return node, ok && node != ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// viewTools are the read tool of the first view and its re-read, as every reader has them (design 25 § 12.2).
|
// firstViews are the vault's first views begun, by the owner's state each follows (`<owner>.<state>`): the assigner's
|
||||||
|
// assignments and the licence manager's bindings (design 39 bus-and-switch B21, B42).
|
||||||
|
func firstViews() ([]string, map[string]*views.Follower) {
|
||||||
|
all := map[string]*views.Follower{}
|
||||||
|
if assignmentsView != nil {
|
||||||
|
all[assignerModule+".assignments"] = assignmentsView
|
||||||
|
}
|
||||||
|
if licenceBindingsView != nil {
|
||||||
|
all["claude-licence-manager.bindings"] = licenceBindingsView
|
||||||
|
}
|
||||||
|
names := make([]string, 0, len(all))
|
||||||
|
for n := range all {
|
||||||
|
names = append(names, n)
|
||||||
|
}
|
||||||
|
sort.Strings(names)
|
||||||
|
return names, all
|
||||||
|
}
|
||||||
|
|
||||||
|
// viewTools are the read tool of the first views and their re-read, as every reader has them (design 25 § 12.2; design
|
||||||
|
// 39 bus-and-switch B21, B42).
|
||||||
func viewTools(env *Env) []toolDef {
|
func viewTools(env *Env) []toolDef {
|
||||||
return []toolDef{
|
return []toolDef{
|
||||||
{Name: FirstViewsTool,
|
{Name: FirstViewsTool,
|
||||||
Description: "The vault's first view of the assigner's assignments: the tool it asks, the revision its picture holds, when it " +
|
Description: "The vault's first views, of the assigner's assignments and of the licence manager's bindings: for each, the tool " +
|
||||||
"asked and was answered, how many asks (and by hand), and whether it is waiting.",
|
"it asks, the revision its picture holds, when it asked and was answered, how many asks (and by hand), and whether it is waiting.",
|
||||||
run: func(string, map[string]any) (any, error) {
|
run: func(string, map[string]any) (any, error) {
|
||||||
if assignmentsView == nil {
|
names, all := firstViews()
|
||||||
return nil, fmt.Errorf("the vault reads the assigner's assignments only when it runs as the module")
|
if len(names) == 0 {
|
||||||
|
return nil, fmt.Errorf("the vault keeps its first views only when it runs as the module")
|
||||||
}
|
}
|
||||||
return map[string]any{"first-views": []views.Status{assignmentsView.Status()}}, nil
|
out := []views.Status{}
|
||||||
|
for _, n := range names {
|
||||||
|
out = append(out, all[n].Status())
|
||||||
|
}
|
||||||
|
return map[string]any{"first-views": out}, nil
|
||||||
}},
|
}},
|
||||||
{Name: FirstViewAgainTool,
|
{Name: FirstViewAgainTool,
|
||||||
Description: "Ask the first view of the assigner's assignments once more by hand, with why, recorded in the vault's log.",
|
Description: "Ask a first view once more by hand, with why, recorded in the vault's log: of names it by its owner's state " +
|
||||||
Input: map[string]any{"why": str("why the first view is asked again")},
|
"(mesh-delivery-assigner.assignments or claude-licence-manager.bindings); absent, both are asked again.",
|
||||||
|
Input: map[string]any{"why": str("why the first view is asked again"),
|
||||||
|
"of": str("the first view, by its owner's state: mesh-delivery-assigner.assignments or claude-licence-manager.bindings; absent for both")},
|
||||||
run: func(caller string, a map[string]any) (any, error) {
|
run: func(caller string, a map[string]any) (any, error) {
|
||||||
if assignmentsView == nil {
|
|
||||||
return nil, fmt.Errorf("the vault reads the assigner's assignments only when it runs as the module")
|
|
||||||
}
|
|
||||||
why := strings.TrimSpace(arg(a, "why"))
|
why := strings.TrimSpace(arg(a, "why"))
|
||||||
if why == "" {
|
if why == "" {
|
||||||
return nil, fmt.Errorf("say why: a first view asked by hand keeps its why")
|
return nil, fmt.Errorf("say why: a first view asked by hand keeps its why")
|
||||||
@@ -194,8 +217,23 @@ func viewTools(env *Env) []toolDef {
|
|||||||
if err := fromAGrantedCaller(caller, FirstViewAgainTool); err != nil {
|
if err := fromAGrantedCaller(caller, FirstViewAgainTool); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
say("the first view of the assigner's assignments is asked again by %s: %s", caller, why)
|
names, all := firstViews()
|
||||||
return map[string]any{"asked-again": true}, assignmentsView.Again(context.Background(), why)
|
if of := strings.TrimSpace(arg(a, "of")); of != "" {
|
||||||
|
if all[of] == nil {
|
||||||
|
return nil, fmt.Errorf("the vault keeps no first view of %q here; it keeps %v", of, names)
|
||||||
|
}
|
||||||
|
names = []string{of}
|
||||||
|
}
|
||||||
|
if len(names) == 0 {
|
||||||
|
return nil, fmt.Errorf("the vault keeps its first views only when it runs as the module")
|
||||||
|
}
|
||||||
|
for _, n := range names {
|
||||||
|
say("the first view of %s is asked again by %s: %s", n, caller, why)
|
||||||
|
if err := all[n].Again(context.Background(), why); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map[string]any{"asked-again": true}, nil
|
||||||
}},
|
}},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -91,10 +91,12 @@ type callKey struct {
|
|||||||
// move is what `move-in` takes: the row of the controller's store (move.go).
|
// move is what `move-in` takes: the row of the controller's store (move.go).
|
||||||
move *MoveOf
|
move *MoveOf
|
||||||
// why is what `move-in` with replace_running was asked for, in the caller's words (owntake.go).
|
// why is what `move-in` with replace_running was asked for, in the caller's words (owntake.go).
|
||||||
why string
|
why string
|
||||||
public [32]byte
|
// generation is the licence's generation `licence-put` keeps its value at (licences.go).
|
||||||
private [32]byte
|
generation int
|
||||||
until time.Time
|
public [32]byte
|
||||||
|
private [32]byte
|
||||||
|
until time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// Opened is a first step's answer: the call, and the key to seal its value to. Never a value.
|
// Opened is a first step's answer: the call, and the key to seal its value to. Never a value.
|
||||||
|
|||||||
@@ -0,0 +1,301 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// The licence bindings the vault keeps (design 39 bus-and-switch §17, B2, B10, B25): which licence each consumer is
|
||||||
|
// bound to, so the vault hands a licence's access token only to a machine a binding names. Learned from the licence
|
||||||
|
// manager's `binding.changed`, which the vault consumes, and at its start from the seat's verb `bindings`, its first
|
||||||
|
// view, asked until it answers; kept in the vault's own state `licence-bindings`, one key per consumer with the
|
||||||
|
// licence, its key and the generation, deleted on a release. The vault never reads the manager's state (ADR 0596 §2).
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient"
|
||||||
|
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||||
|
|
||||||
|
"git.novox.be/novox/mesh-sdk/go/views"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// LicenceBindingsState is the vault's own state of the licence bindings (§17; B2: in the old section `state`).
|
||||||
|
LicenceBindingsState = "licence-bindings"
|
||||||
|
// BindingChangedEvent is the licence manager's event of a binding written or deleted (§21), which the vault consumes.
|
||||||
|
BindingChangedEvent = "claude-licence-manager.binding.changed"
|
||||||
|
// LicenceBindingsVerb is the seat's verb the vault asks for its first view (B10), declared in its invokes (B9).
|
||||||
|
LicenceBindingsVerb = "seat:anthropic-licence-manager.bindings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LicenceBinding is a consumer's key of `licence-bindings` (B25): the licence, its key and the binding's generation.
|
||||||
|
type LicenceBinding struct {
|
||||||
|
Licence string `json:"licence"`
|
||||||
|
Key string `json:"key"`
|
||||||
|
Generation int64 `json:"generation"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LicenceBindings is the state `licence-bindings`.
|
||||||
|
type LicenceBindings struct {
|
||||||
|
Get func(consumer string) (*LicenceBinding, error)
|
||||||
|
Put func(consumer string, b LicenceBinding) error
|
||||||
|
Delete func(consumer string) error
|
||||||
|
Keys func() ([]string, error)
|
||||||
|
Log func(string, ...any)
|
||||||
|
// WrittenAt is when the bus wrote a consumer's key, so a first view keeps a key an event wrote after it was asked
|
||||||
|
// (B43); nil writes no time, and every key a first view does not name goes.
|
||||||
|
WrittenAt func(consumer string) (time.Time, error)
|
||||||
|
// Answered is whether the first view of the bindings has been answered since the vault started; until it has, an
|
||||||
|
// access token is refused, saying why (B31). Nil is a vault that keeps no first view (its tests).
|
||||||
|
Answered func() bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// busLicenceBindings is the state on the bus.
|
||||||
|
func busLicenceBindings() LicenceBindings {
|
||||||
|
st := stdio.State(LicenceBindingsState)
|
||||||
|
return LicenceBindings{
|
||||||
|
Get: func(consumer string) (*LicenceBinding, error) {
|
||||||
|
e, err := st.Get(consumer)
|
||||||
|
if err != nil || e == nil || len(e.Value) == 0 {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var b LicenceBinding
|
||||||
|
return &b, json.Unmarshal(e.Value, &b)
|
||||||
|
},
|
||||||
|
Put: func(consumer string, b LicenceBinding) error { _, err := st.Put(consumer, b); return err },
|
||||||
|
Delete: func(consumer string) error { return st.Delete(consumer) },
|
||||||
|
Keys: st.Keys,
|
||||||
|
Log: say,
|
||||||
|
WrittenAt: func(consumer string) (time.Time, error) {
|
||||||
|
e, err := st.Get(consumer)
|
||||||
|
if err != nil || e == nil {
|
||||||
|
return time.Time{}, err
|
||||||
|
}
|
||||||
|
return e.At, nil
|
||||||
|
},
|
||||||
|
Answered: func() bool { return licenceBindingsView != nil && !licenceBindingsView.Status().Answered.IsZero() },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// bindingChanged is the body of `binding.changed` as the manager says it (§21, B7; B22: decoded from a copy of the
|
||||||
|
// writer's form, testdata/binding-changed.json).
|
||||||
|
type bindingChanged struct {
|
||||||
|
Consumer string `json:"consumer"`
|
||||||
|
Licence string `json:"licence"`
|
||||||
|
Key string `json:"key"`
|
||||||
|
Generation int64 `json:"generation"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// keep writes a consumer's binding unless the state holds a newer generation; an empty licence is a release.
|
||||||
|
func (lb LicenceBindings) keep(consumer string, b LicenceBinding) error {
|
||||||
|
if consumer == "" {
|
||||||
|
return errors.New("a binding names no consumer")
|
||||||
|
}
|
||||||
|
held, err := lb.Get(consumer)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if held != nil && held.Generation > b.Generation {
|
||||||
|
return nil // the state holds a newer move already
|
||||||
|
}
|
||||||
|
if b.Licence == "" {
|
||||||
|
if held == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return lb.Delete(consumer)
|
||||||
|
}
|
||||||
|
if held != nil && *held == b {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return lb.Put(consumer, b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Heard takes one `binding.changed`.
|
||||||
|
func (lb LicenceBindings) Heard(body []byte) error {
|
||||||
|
var e bindingChanged
|
||||||
|
if err := json.Unmarshal(body, &e); err != nil {
|
||||||
|
return fmt.Errorf("%s is not a binding's change: %w", BindingChangedEvent, err)
|
||||||
|
}
|
||||||
|
if e.Licence != "" && e.Key == "" {
|
||||||
|
return fmt.Errorf("%s for %s names no licence key", BindingChangedEvent, e.Consumer)
|
||||||
|
}
|
||||||
|
return lb.keep(e.Consumer, LicenceBinding{Licence: e.Licence, Key: e.Key, Generation: e.Generation})
|
||||||
|
}
|
||||||
|
|
||||||
|
// FirstView takes the manager's answer to `bindings`, asked at asked, by consumer: every binding it names is kept, and
|
||||||
|
// every key the state holds for a consumer it does not name is deleted, since that consumer was released, unless an
|
||||||
|
// event wrote that key after the view was asked (B43): the view does not know that move yet.
|
||||||
|
func (lb LicenceBindings) FirstView(values map[string]json.RawMessage, asked time.Time) error {
|
||||||
|
var errs []error
|
||||||
|
for consumer, raw := range values {
|
||||||
|
var b LicenceBinding
|
||||||
|
if err := json.Unmarshal(raw, &b); err != nil {
|
||||||
|
errs = append(errs, fmt.Errorf("%s: %w", consumer, err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
errs = append(errs, lb.keep(consumer, b))
|
||||||
|
}
|
||||||
|
held, err := lb.Keys()
|
||||||
|
if err != nil {
|
||||||
|
return errors.Join(append(errs, err)...)
|
||||||
|
}
|
||||||
|
for _, consumer := range held {
|
||||||
|
if _, named := values[consumer]; named {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if lb.WrittenAt != nil && !asked.IsZero() {
|
||||||
|
at, err := lb.WrittenAt(consumer)
|
||||||
|
if err != nil {
|
||||||
|
errs = append(errs, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if at.After(asked) {
|
||||||
|
continue // written by an event after the view was asked (B43)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
errs = append(errs, lb.Delete(consumer))
|
||||||
|
}
|
||||||
|
return errors.Join(errs...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// licenceBindingsAnswer is the seat's answer to `bindings` (B10, B25) as a first view, by consumer.
|
||||||
|
func licenceBindingsAnswer(raw []byte) (views.Answer, error) {
|
||||||
|
var listed []struct {
|
||||||
|
Consumer string `json:"consumer"`
|
||||||
|
LicenceBinding
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(unwrapToolAnswer(raw), &listed); err != nil {
|
||||||
|
return views.Answer{}, fmt.Errorf("%s answered what is not a list of bindings: %w", LicenceBindingsVerb, err)
|
||||||
|
}
|
||||||
|
values := map[string]json.RawMessage{}
|
||||||
|
for _, b := range listed {
|
||||||
|
if b.Consumer == "" || b.Licence == "" || b.Key == "" {
|
||||||
|
return views.Answer{}, fmt.Errorf("%s answered a binding without its consumer, licence or key", LicenceBindingsVerb)
|
||||||
|
}
|
||||||
|
raw, _ := json.Marshal(b.LicenceBinding)
|
||||||
|
values[b.Consumer] = raw
|
||||||
|
}
|
||||||
|
return views.Answer{Owner: "claude-licence-manager", State: "bindings", Values: values, At: time.Now()}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// licenceBindingsView is the first view's follower; nil until main begins it.
|
||||||
|
var licenceBindingsView *views.Follower
|
||||||
|
|
||||||
|
// newLicenceBindingsView makes the follower of the first view: asked until it answers, and each answer, at the start or
|
||||||
|
// asked again by hand, written into the state.
|
||||||
|
func newLicenceBindingsView(ask func(subject string, body any) (json.RawMessage, error), lb LicenceBindings,
|
||||||
|
findings views.FindingWriter) *views.Follower {
|
||||||
|
var mu sync.Mutex
|
||||||
|
var taken time.Time
|
||||||
|
var f *views.Follower
|
||||||
|
f = &views.Follower{Owner: "claude-licence-manager", Tool: LicenceBindingsVerb, State: "bindings", Logf: say,
|
||||||
|
Ask: func(context.Context, string) (views.Answer, error) {
|
||||||
|
raw, err := ask(LicenceBindingsVerb, map[string]any{})
|
||||||
|
if err != nil {
|
||||||
|
return views.Answer{}, err
|
||||||
|
}
|
||||||
|
return licenceBindingsAnswer(raw)
|
||||||
|
},
|
||||||
|
Raise: func(s views.Status) {
|
||||||
|
fd := views.UnansweredFinding(Seat, s, time.Now())
|
||||||
|
if err := findings.Put(fd); err != nil {
|
||||||
|
say("the finding %s is not written: %v", fd.ID, err)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Clear: func(s views.Status) { _ = findings.Delete(views.UnansweredFinding(Seat, s, time.Now())) },
|
||||||
|
// Each answer, once: its picture is the manager's bindings when it answered.
|
||||||
|
Said: func(s views.Status) {
|
||||||
|
mu.Lock()
|
||||||
|
fresh := !s.Answered.IsZero() && s.Answered.After(taken)
|
||||||
|
if fresh {
|
||||||
|
taken = s.Answered
|
||||||
|
}
|
||||||
|
mu.Unlock()
|
||||||
|
if !fresh {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
values := f.Values()
|
||||||
|
if err := lb.FirstView(values, s.Asked); err != nil {
|
||||||
|
say("the licence bindings of the first view are not all kept: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
consumers := make([]string, 0, len(values))
|
||||||
|
for c := range values {
|
||||||
|
consumers = append(consumers, c)
|
||||||
|
}
|
||||||
|
sort.Strings(consumers)
|
||||||
|
say("the licence bindings of %d consumer(s) are kept from the first view: %v", len(consumers), consumers)
|
||||||
|
}}
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
// watchLicenceBindings begins the first view and hears `binding.changed`. It answers whether it began; one that did not
|
||||||
|
// is begun again at the next minute pass.
|
||||||
|
func watchLicenceBindings(lb LicenceBindings) bool {
|
||||||
|
err := stdio.Subscribe(BindingChangedEvent, func(e stdio.Envelope) error {
|
||||||
|
if err := lb.Heard(e.Body); err != nil {
|
||||||
|
say("%v", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
say("does not hear %s yet (%v); asked again at the next pass", BindingChangedEvent, err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if licenceBindingsView == nil {
|
||||||
|
licenceBindingsView = newLicenceBindingsView(stdio.Ask, lb,
|
||||||
|
meshconditionsclient.FindingWriter{State: stdio.State(FindingsState)})
|
||||||
|
go func() {
|
||||||
|
if err := licenceBindingsView.Start(context.Background()); err != nil {
|
||||||
|
say("the first view of the licence bindings ended unanswered: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// licenceRefusal answers whether name is a licence's secret and, if it is, why the vault does not hand it to caller,
|
||||||
|
// empty when it does (§16, §17, A1, B3, B8, B25): a grant to any caller (B3), an access token to a machine on which a
|
||||||
|
// consumer's binding names that licence's key. The rule that a value goes only to a module under a bus account of
|
||||||
|
// its own does not apply to a licence's secrets (§17).
|
||||||
|
func (v *Vault) licenceRefusal(caller, name string) (bool, string) {
|
||||||
|
rest, ok := strings.CutPrefix(name, "licence.")
|
||||||
|
if !ok {
|
||||||
|
return false, ""
|
||||||
|
}
|
||||||
|
key, part := rest, ""
|
||||||
|
for _, p := range []string{LicenceGrant, LicenceAccess} {
|
||||||
|
if k, cut := strings.CutSuffix(rest, "."+p); cut {
|
||||||
|
key, part = k, p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if part == "" || !licenceKey.MatchString(key) {
|
||||||
|
return false, ""
|
||||||
|
}
|
||||||
|
if part == LicenceGrant {
|
||||||
|
return true, ""
|
||||||
|
}
|
||||||
|
machine, _, _ := strings.Cut(caller, ".")
|
||||||
|
if v.LicenceBindings == nil {
|
||||||
|
return true, "the vault keeps no licence bindings, so no binding names the licence " + key
|
||||||
|
}
|
||||||
|
if v.LicenceBindings.Answered != nil && !v.LicenceBindings.Answered() {
|
||||||
|
return true, "the vault's first view of the licence bindings has not been answered since it started, so it does not yet know which machine a binding names; it asks the licence manager until it answers (B31)"
|
||||||
|
}
|
||||||
|
consumers, err := v.LicenceBindings.Keys()
|
||||||
|
if err != nil {
|
||||||
|
return true, "the licence bindings cannot be read: " + err.Error()
|
||||||
|
}
|
||||||
|
for _, consumer := range consumers {
|
||||||
|
if c, _, _ := strings.Cut(consumer, "."); c != machine {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if b, err := v.LicenceBindings.Get(consumer); err == nil && b != nil && b.Key == key {
|
||||||
|
return true, ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true, fmt.Sprintf("no consumer on %s is bound to the licence %s, and its token goes only to a machine a binding names", machine, key)
|
||||||
|
}
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"slices"
|
||||||
|
"sort"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.novox.be/novox/mesh-sdk/go/views"
|
||||||
|
)
|
||||||
|
|
||||||
|
// memLicenceBindings is the state licence-bindings in memory.
|
||||||
|
func memLicenceBindings() (LicenceBindings, map[string]LicenceBinding) {
|
||||||
|
held := map[string]LicenceBinding{}
|
||||||
|
return LicenceBindings{
|
||||||
|
Get: func(c string) (*LicenceBinding, error) {
|
||||||
|
if b, ok := held[c]; ok {
|
||||||
|
return &b, nil
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
},
|
||||||
|
Put: func(c string, b LicenceBinding) error { held[c] = b; return nil },
|
||||||
|
Delete: func(c string) error { delete(held, c); return nil },
|
||||||
|
Keys: func() ([]string, error) {
|
||||||
|
var out []string
|
||||||
|
for c := range held {
|
||||||
|
out = append(out, c)
|
||||||
|
}
|
||||||
|
sort.Strings(out)
|
||||||
|
return out, nil
|
||||||
|
},
|
||||||
|
Log: func(string, ...any) {},
|
||||||
|
}, held
|
||||||
|
}
|
||||||
|
|
||||||
|
// design 39 bus-and-switch §17, B22, B25: the vault keeps a consumer's binding from binding.changed, decoded from a copy
|
||||||
|
// of the manager's form, with the licence's key; an older generation is passed over and a release deletes the key.
|
||||||
|
func TestALicenceBindingIsKeptFromBindingChanged(t *testing.T) {
|
||||||
|
lb, held := memLicenceBindings()
|
||||||
|
said, err := os.ReadFile("testdata/binding-changed.json")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := lb.Heard(said); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if b := held["laptop"]; b != (LicenceBinding{Licence: "api", Key: "api", Generation: 3}) {
|
||||||
|
t.Fatalf("laptop is kept as %+v", b)
|
||||||
|
}
|
||||||
|
for _, c := range []struct {
|
||||||
|
body string
|
||||||
|
want *LicenceBinding
|
||||||
|
}{
|
||||||
|
{`{"consumer":"laptop","licence":"other","key":"other","generation":2}`, &LicenceBinding{Licence: "api", Key: "api", Generation: 3}},
|
||||||
|
{`{"consumer":"laptop","licence":"other","key":"other","generation":4}`, &LicenceBinding{Licence: "other", Key: "other", Generation: 4}},
|
||||||
|
{`{"consumer":"laptop","licence":"","key":"","generation":4,"from":"other"}`, nil},
|
||||||
|
} {
|
||||||
|
if err := lb.Heard([]byte(c.body)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, ok := held["laptop"]
|
||||||
|
if (c.want == nil) != !ok || (c.want != nil && got != *c.want) {
|
||||||
|
t.Fatalf("after %s laptop is kept as %+v (held %v), want %+v", c.body, got, ok, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := lb.Heard([]byte(`{"consumer":"server","licence":"api","generation":5}`)); err == nil {
|
||||||
|
t.Fatal("a binding without its licence's key was kept")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// design 39 bus-and-switch §17, B10, B25: the first view, the seat's answer to bindings, keeps every consumer's binding
|
||||||
|
// with its key and deletes a consumer it does not name; the vault invokes the verb and consumes the event its manifest
|
||||||
|
// names.
|
||||||
|
func TestTheFirstViewOfTheLicenceBindingsIsKept(t *testing.T) {
|
||||||
|
lb, held := memLicenceBindings()
|
||||||
|
held["gone"] = LicenceBinding{Licence: "api", Key: "api", Generation: 9}
|
||||||
|
raw, err := os.ReadFile("testdata/bindings-answer.json")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
a, err := licenceBindingsAnswer(raw)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := lb.FirstView(a.Values, time.Time{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
want := map[string]LicenceBinding{"laptop": {Licence: "acct-1@example.org", Key: "acct-1_at_example.org", Generation: 1013},
|
||||||
|
"server": {Licence: "api", Key: "api", Generation: 1016}}
|
||||||
|
if len(held) != 2 || held["laptop"] != want["laptop"] || held["server"] != want["server"] {
|
||||||
|
t.Fatalf("the first view kept %v", held)
|
||||||
|
}
|
||||||
|
if _, err := licenceBindingsAnswer([]byte(`[{"consumer":"laptop","licence":"api","generation":1}]`)); err == nil {
|
||||||
|
t.Fatal("an answer without a licence's key was taken")
|
||||||
|
}
|
||||||
|
lb.Put = func(string, LicenceBinding) error { return errors.New("the bucket is full") }
|
||||||
|
held["laptop"] = LicenceBinding{Licence: "api", Key: "api", Generation: 1}
|
||||||
|
if err := lb.FirstView(a.Values, time.Time{}); err == nil {
|
||||||
|
t.Fatal("a first view not kept was not said")
|
||||||
|
}
|
||||||
|
|
||||||
|
// B43: a key an event wrote after the view was asked is kept though the view does not name it; one written before
|
||||||
|
// the ask and not named goes.
|
||||||
|
lb, held = memLicenceBindings()
|
||||||
|
asked := time.Date(2026, 11, 12, 1, 0, 0, 0, time.UTC)
|
||||||
|
written := map[string]time.Time{"late": asked.Add(time.Second), "gone": asked.Add(-time.Second)}
|
||||||
|
lb.WrittenAt = func(c string) (time.Time, error) { return written[c], nil }
|
||||||
|
held["late"] = LicenceBinding{Licence: "api", Key: "api", Generation: 2000}
|
||||||
|
held["gone"] = LicenceBinding{Licence: "api", Key: "api", Generation: 9}
|
||||||
|
if err := lb.FirstView(a.Values, asked); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, ok := held["late"]; !ok {
|
||||||
|
t.Error("a key an event wrote after the first view was asked was removed by it")
|
||||||
|
}
|
||||||
|
if _, ok := held["gone"]; ok {
|
||||||
|
t.Error("a key written before the first view was asked, which it does not name, was kept")
|
||||||
|
}
|
||||||
|
|
||||||
|
manifest, _ := os.ReadFile("../../module.json")
|
||||||
|
var m struct {
|
||||||
|
Invokes, Consumes []string
|
||||||
|
State []json.RawMessage
|
||||||
|
}
|
||||||
|
_ = json.Unmarshal(manifest, &m)
|
||||||
|
kept := false
|
||||||
|
for _, st := range m.State {
|
||||||
|
kept = kept || string(st) == `"`+LicenceBindingsState+`"`
|
||||||
|
}
|
||||||
|
if !slices.Contains(m.Invokes, LicenceBindingsVerb) || !slices.Contains(m.Consumes, BindingChangedEvent) || !kept {
|
||||||
|
t.Fatalf("the manifest invokes %v, consumes %v, keeps %s", m.Invokes, m.Consumes, m.State)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// design 39 bus-and-switch B21, B42: vault_first_views lists the licence bindings' first view beside the assignments';
|
||||||
|
// vault_first_view_again asks the one its of names, and both when of is absent; an of it keeps no view of is refused.
|
||||||
|
func TestTheFirstViewToolsCoverTheLicenceBindings(t *testing.T) {
|
||||||
|
follower := func(owner, state string) *views.Follower {
|
||||||
|
return &views.Follower{Owner: owner, Tool: owner + ".read", State: state,
|
||||||
|
Ask: func(context.Context, string) (views.Answer, error) {
|
||||||
|
return views.Answer{Owner: owner, State: state, Values: map[string]json.RawMessage{}}, nil
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
keptA, keptL := assignmentsView, licenceBindingsView
|
||||||
|
t.Cleanup(func() { assignmentsView, licenceBindingsView = keptA, keptL })
|
||||||
|
assignmentsView, licenceBindingsView = follower(assignerModule, "assignments"), follower("claude-licence-manager", "bindings")
|
||||||
|
run := map[string]toolDef{}
|
||||||
|
for _, d := range viewTools(nil) {
|
||||||
|
run[d.Name] = d
|
||||||
|
}
|
||||||
|
listed, err := run[FirstViewsTool].run("person.operator", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var states []string
|
||||||
|
for _, s := range listed.(map[string]any)["first-views"].([]views.Status) {
|
||||||
|
states = append(states, s.Owner+"."+s.State)
|
||||||
|
}
|
||||||
|
if !slices.Equal(states, []string{"claude-licence-manager.bindings", "mesh-delivery-assigner.assignments"}) {
|
||||||
|
t.Errorf("vault_first_views lists %v", states)
|
||||||
|
}
|
||||||
|
byHand := func() (int, int) { return assignmentsView.Status().ByHand, licenceBindingsView.Status().ByHand }
|
||||||
|
again := run[FirstViewAgainTool].run
|
||||||
|
for _, c := range []struct {
|
||||||
|
of string
|
||||||
|
assignments, bindings int
|
||||||
|
refused bool
|
||||||
|
}{
|
||||||
|
{of: "claude-licence-manager.bindings", bindings: 1},
|
||||||
|
{of: "", assignments: 1, bindings: 2},
|
||||||
|
{of: "mesh-delivery-assigner.assignments", assignments: 2, bindings: 2},
|
||||||
|
{of: "claude-licence-manager.licences", assignments: 2, bindings: 2, refused: true},
|
||||||
|
} {
|
||||||
|
_, err := again("person.operator", map[string]any{"why": "a test", "of": c.of})
|
||||||
|
if (err != nil) != c.refused {
|
||||||
|
t.Errorf("of %q: %v", c.of, err)
|
||||||
|
}
|
||||||
|
if a, b := byHand(); a != c.assignments || b != c.bindings {
|
||||||
|
t.Errorf("of %q: asked by hand %d and %d times, want %d and %d", c.of, a, b, c.assignments, c.bindings)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// A licence's two secrets (design 39 bus-and-switch §15, B3 to B6): `licence.<licence key>.grant` and
|
||||||
|
// `licence.<licence key>.access`, handed to the vault by the licence manager through `licence-put` in two steps — name
|
||||||
|
// the licence, the part and the generation and get a call and a key made for it, then send the call and the value sealed
|
||||||
|
// to that key. The caller's generation is the record's; the vault keeps the newest and refuses an older one, and says
|
||||||
|
// `secret-changed` with the secret's name and generation, never the value.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LicencePutVerb is the verb that hands a licence's secret to the vault.
|
||||||
|
const LicencePutVerb = "licence-put"
|
||||||
|
|
||||||
|
// The parts of a licence the vault holds (§15).
|
||||||
|
const (
|
||||||
|
LicenceGrant = "grant"
|
||||||
|
LicenceAccess = "access"
|
||||||
|
)
|
||||||
|
|
||||||
|
// licenceKey is a licence's key form (§10, B6): lower case, letters, digits, `-`, `.` and `_`.
|
||||||
|
var licenceKey = regexp.MustCompile(`^[a-z0-9_.-]{1,120}$`)
|
||||||
|
|
||||||
|
// LicenceSecret is the name of one part of a licence in the vault.
|
||||||
|
func LicenceSecret(key, part string) (string, error) {
|
||||||
|
if !licenceKey.MatchString(key) {
|
||||||
|
return "", fmt.Errorf("%q is not a licence's key: lower-case letters, digits, -, . and _", key)
|
||||||
|
}
|
||||||
|
if part != LicenceGrant && part != LicenceAccess {
|
||||||
|
return "", fmt.Errorf("the part %q is not a part of a licence: grant or access", part)
|
||||||
|
}
|
||||||
|
return "licence." + key + "." + part, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// LicencePutOpen opens a call to take one part of a licence at a generation.
|
||||||
|
func (v *Vault) LicencePutOpen(caller, licence, part string, generation int) (Opened, error) {
|
||||||
|
if err := fromAGrantedCaller(caller, LicencePutVerb); err != nil {
|
||||||
|
return Opened{}, err
|
||||||
|
}
|
||||||
|
secret, err := LicenceSecret(licence, part)
|
||||||
|
if err != nil {
|
||||||
|
return Opened{}, err
|
||||||
|
}
|
||||||
|
if generation < 1 {
|
||||||
|
return Opened{}, errors.New("licence-put takes the licence's generation, one or more; nothing is taken")
|
||||||
|
}
|
||||||
|
id, key, err := v.openCall(LicencePutVerb, secret, caller, nil)
|
||||||
|
if err != nil {
|
||||||
|
return Opened{}, err
|
||||||
|
}
|
||||||
|
v.callsMu.Lock()
|
||||||
|
if c := v.pending[id]; c != nil {
|
||||||
|
c.generation = generation
|
||||||
|
}
|
||||||
|
v.callsMu.Unlock()
|
||||||
|
return Opened{Call: id, SealTo: key, Secret: secret}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// LicencePutTake opens the value sealed to the call's key and keeps it at the call's generation.
|
||||||
|
func (v *Vault) LicencePutTake(caller, call, sealed string) (Changed, error) {
|
||||||
|
c, err := v.takeCall(LicencePutVerb, call)
|
||||||
|
if err != nil {
|
||||||
|
return Changed{}, err
|
||||||
|
}
|
||||||
|
if c.by != caller {
|
||||||
|
return Changed{}, fmt.Errorf("the licence-put call %q was opened by another caller; nothing was taken", call)
|
||||||
|
}
|
||||||
|
value, err := c.open(sealed)
|
||||||
|
if err != nil {
|
||||||
|
return Changed{}, err
|
||||||
|
}
|
||||||
|
defer zero(value)
|
||||||
|
if len(value) == 0 {
|
||||||
|
return Changed{}, errors.New("the value handed in is empty; nothing was taken")
|
||||||
|
}
|
||||||
|
return v.keepWith(c.secret, string(value), caller, "", OriginAccepted, keepOptions{generation: c.generation})
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// putLicence hands one part of a licence to the vault in licence-put's two steps.
|
||||||
|
func putLicence(t *testing.T, v testVault, caller, key, part string, generation int, value string) (Changed, error) {
|
||||||
|
t.Helper()
|
||||||
|
o, err := v.LicencePutOpen(caller, key, part, generation)
|
||||||
|
if err != nil {
|
||||||
|
return Changed{}, err
|
||||||
|
}
|
||||||
|
return v.LicencePutTake(caller, o.Call, sealToCall(t, o.SealTo, value))
|
||||||
|
}
|
||||||
|
|
||||||
|
// design 39 bus-and-switch §15, B3, B5: the caller's generation is the record's; an older one is refused; the
|
||||||
|
// event names the secret and its generation, never the value.
|
||||||
|
func TestLicencePutKeepsTheNewestGenerationAndRefusesAnOlder(t *testing.T) {
|
||||||
|
v := newVault(t, nil)
|
||||||
|
const manager = "claude-licence-manager"
|
||||||
|
if _, err := putLicence(t, v, manager, "jane_at_example.org", LicenceGrant, 3, `{"refreshToken":"rt-3"}`); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
rec, held, err := v.Store.Read("licence.jane_at_example.org.grant")
|
||||||
|
if err != nil || !held || rec.Generation != 3 {
|
||||||
|
t.Fatalf("held %v at generation %d (%v), want generation 3", held, rec.Generation, err)
|
||||||
|
}
|
||||||
|
if _, err := putLicence(t, v, manager, "jane_at_example.org", LicenceGrant, 2, `{"refreshToken":"rt-2"}`); err == nil ||
|
||||||
|
!strings.Contains(err.Error(), "not newer") {
|
||||||
|
t.Fatalf("an older generation was taken: %v", err)
|
||||||
|
}
|
||||||
|
if c, err := putLicence(t, v, manager, "jane_at_example.org", LicenceGrant, 4, `{"refreshToken":"rt-4"}`); err != nil ||
|
||||||
|
c.Generation != 4 {
|
||||||
|
t.Fatalf("generation 4: %+v %v", c, err)
|
||||||
|
}
|
||||||
|
said := 0
|
||||||
|
for _, raw := range v.bus.raw {
|
||||||
|
if strings.Contains(raw, "rt-") {
|
||||||
|
t.Fatalf("an event carries the value: %s", raw)
|
||||||
|
}
|
||||||
|
if strings.Contains(raw, `"secret-changed"`) && strings.Contains(raw, "licence.jane_at_example.org.grant") {
|
||||||
|
said++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if said != 2 {
|
||||||
|
t.Fatalf("secret-changed said %d times, want 2: %v", said, v.bus.raw)
|
||||||
|
}
|
||||||
|
for _, bad := range []struct{ key, part string }{{"Jane@x", LicenceGrant}, {"jane", "refresh"}} {
|
||||||
|
if _, err := v.LicencePutOpen(manager, bad.key, bad.part, 1); err == nil {
|
||||||
|
t.Errorf("licence-put took %s/%s", bad.key, bad.part)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := v.LicencePutOpen("", "jane", LicenceAccess, 1); err == nil {
|
||||||
|
t.Error("licence-put took an unproven caller")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// design 39 bus-and-switch §17/AC1, §16, B3, B8, B25: a licence's access token is refused to a machine no binding names
|
||||||
|
// and for a licence its binding does not name, and given after a binding.changed naming it, to any consumer of that
|
||||||
|
// machine; the grant goes to the manager's tool runner, with no machine guard.
|
||||||
|
func TestATokenIsGivenOnlyToAMachineABindingNames(t *testing.T) {
|
||||||
|
v := newVault(t, nil)
|
||||||
|
lb, _ := memLicenceBindings()
|
||||||
|
v.LicenceBindings = &lb
|
||||||
|
const manager = "novox.node-tools"
|
||||||
|
for _, key := range []string{"jane_at_example.org", "api"} {
|
||||||
|
for _, part := range []string{LicenceGrant, LicenceAccess} {
|
||||||
|
if _, err := putLicence(t, v, manager, key, part, 1, key+"-"+part); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
access := "licence.jane_at_example.org.access"
|
||||||
|
// B31: until the first view of the bindings is answered, an access token is refused, saying why.
|
||||||
|
answered := false
|
||||||
|
lb.Answered = func() bool { return answered }
|
||||||
|
if err := lb.Heard([]byte(`{"consumer":"g14","licence":"jane","key":"jane_at_example.org","generation":1}`)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, _, err := asks(v, "g14.node-tools", access); err == nil || !strings.Contains(err.Error(), "first view of the licence bindings has not been answered") {
|
||||||
|
t.Fatalf("a token was handed before the first view was answered: %v", err)
|
||||||
|
}
|
||||||
|
answered = true
|
||||||
|
if _, _, err := asks(v, "g14.node-tools", access); err != nil {
|
||||||
|
t.Fatalf("once the first view is answered the bound machine is refused: %v", err)
|
||||||
|
}
|
||||||
|
if err := lb.Heard([]byte(`{"consumer":"g14","licence":"","key":"","generation":2}`)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, _, err := asks(v, "g14.node-tools", access); err == nil || !strings.Contains(err.Error(), "no consumer on g14") {
|
||||||
|
t.Fatalf("a machine no binding names was handed the token: %v", err)
|
||||||
|
}
|
||||||
|
if err := lb.Heard([]byte(`{"consumer":"g14","licence":"api","key":"api","generation":4}`)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, _, err := asks(v, "g14.node-tools", access); err == nil {
|
||||||
|
t.Fatal("a licence its binding does not name was handed to g14")
|
||||||
|
}
|
||||||
|
if got, _, err := asks(v, "g14.node-tools", "licence.api.access"); err != nil || got != "api-access" {
|
||||||
|
t.Fatalf("g14's own licence: %q %v", got, err)
|
||||||
|
}
|
||||||
|
if err := lb.Heard([]byte(`{"consumer":"g14.worker-1","licence":"jane@example.org","key":"jane_at_example.org","generation":2}`)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got, _, err := asks(v, "g14.node-tools", access); err != nil || got != "jane_at_example.org-access" {
|
||||||
|
t.Fatalf("after a binding.changed naming it: %q %v", got, err)
|
||||||
|
}
|
||||||
|
if _, _, err := asks(v, "ace.node-tools", access); err == nil {
|
||||||
|
t.Fatal("another machine was handed g14's token")
|
||||||
|
}
|
||||||
|
if got, _, err := asks(v, manager, "licence.jane_at_example.org.grant"); err != nil || got != "jane_at_example.org-grant" {
|
||||||
|
t.Fatalf("the manager's grant: %q %v", got, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -58,7 +58,8 @@ func build() *Env {
|
|||||||
// The assignments as the vault's watch keeps them (watchAssignments), read over the bus until it has caught up.
|
// The assignments as the vault's watch keeps them (watchAssignments), read over the bus until it has caught up.
|
||||||
assigned = &WatchedAssignments{Fallback: readPlanRaw}
|
assigned = &WatchedAssignments{Fallback: readPlanRaw}
|
||||||
watchedPlan := func(key string) ([]byte, error) { raw, err := assigned.Read(key); return asAssignment(key, raw, err) }
|
watchedPlan := func(key string) ([]byte, error) { raw, err := assigned.Read(key); return asAssignment(key, raw, err) }
|
||||||
env.Vault = &Vault{Store: store, Own: own, Askers: Declared{Read: watchedPlan, Confirm: readAssignment, Store: store},
|
licenceBindings := busLicenceBindings()
|
||||||
|
env.Vault = &Vault{Store: store, Own: own, LicenceBindings: &licenceBindings, Askers: Declared{Read: watchedPlan, Confirm: readAssignment, Store: store},
|
||||||
Assignments: Mirror{Keys: assignmentKeys, Read: readAssignment}, Emit: emit, Ask: stdio.Ask,
|
Assignments: Mirror{Keys: assignmentKeys, Read: readAssignment}, Emit: emit, Ask: stdio.Ask,
|
||||||
Now: time.Now, Log: say, Accounts: busAccounts{}, Haves: readHave, WantRead: readWant,
|
Now: time.Now, Log: say, Accounts: busAccounts{}, Haves: readHave, WantRead: readWant,
|
||||||
HaveKeys: func() ([]string, error) { return stdio.State(HaveRead).Keys() }, Assigner: seatAssignments, Signers: busSigners{},
|
HaveKeys: func() ([]string, error) { return stdio.State(HaveRead).Keys() }, Assigner: seatAssignments, Signers: busSigners{},
|
||||||
@@ -323,6 +324,8 @@ func main() {
|
|||||||
watched, watching := watchHandings(env), watchAssignments()
|
watched, watching := watchHandings(env), watchAssignments()
|
||||||
wants := watchWants(env)
|
wants := watchWants(env)
|
||||||
leavers := watchLeavers(env)
|
leavers := watchLeavers(env)
|
||||||
|
bindings := busLicenceBindings()
|
||||||
|
licences := watchLicenceBindings(bindings)
|
||||||
kick := make(chan struct{}, 1)
|
kick := make(chan struct{}, 1)
|
||||||
go func() {
|
go func() {
|
||||||
for range kick {
|
for range kick {
|
||||||
@@ -337,6 +340,9 @@ func main() {
|
|||||||
if !leavers {
|
if !leavers {
|
||||||
leavers = watchLeavers(env)
|
leavers = watchLeavers(env)
|
||||||
}
|
}
|
||||||
|
if !licences {
|
||||||
|
licences = watchLicenceBindings(bindings)
|
||||||
|
}
|
||||||
if !watched {
|
if !watched {
|
||||||
watched = watchHandings(env)
|
watched = watchHandings(env)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
{"at":"2026-10-04T12:00:00Z","by":"g14.node-tools","consumer":"laptop","from":"acct-1@example.org","generation":3,"key":"api","kind":"api-key","licence":"api","why":"the switch verb"}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
[{"consumer":"laptop","licence":"acct-1@example.org","key":"acct-1_at_example.org","kind":"subscription","generation":1013,"since":"2026-10-04T12:00:00Z","why":"carried over","by":"claude-licence-manager"},{"consumer":"server","licence":"api","key":"api","kind":"api-key","generation":1016,"since":"2026-10-04T12:00:00Z","why":"carried over","by":"claude-licence-manager"}]
|
||||||
@@ -208,6 +208,28 @@ func definitions(env *Env) []toolDef {
|
|||||||
}
|
}
|
||||||
return v.OwnDrift()
|
return v.OwnDrift()
|
||||||
}},
|
}},
|
||||||
|
{Name: Seat + "." + LicencePutVerb,
|
||||||
|
Description: "Hand the vault one of a licence's two secrets (design 39 bus-and-switch §15), in two steps: name " +
|
||||||
|
"the licence by its key, the part (grant or access) and its generation, and get a call and a key made for " +
|
||||||
|
"it; then send the call and the value sealed to that key. Keeps the newest generation and refuses an older " +
|
||||||
|
"one; says secret-changed with the secret's name and generation, never the value.",
|
||||||
|
Input: map[string]any{"type": "object", "properties": map[string]any{
|
||||||
|
"licence": str("the first step: the licence's key"),
|
||||||
|
"part": str("the first step: grant or access"),
|
||||||
|
"generation": map[string]any{"type": "number", "description": "the first step: the licence's generation"},
|
||||||
|
"call": str("the second step: the call the first step answered"),
|
||||||
|
"sealed": str("the second step: the value sealed to the call's key (NaCl anonymous box, base64) — never the value")}},
|
||||||
|
run: func(caller string, a map[string]any) (any, error) {
|
||||||
|
v, err := env.ready()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if call := arg(a, "call"); call != "" {
|
||||||
|
return v.LicencePutTake(caller, call, arg(a, "sealed"))
|
||||||
|
}
|
||||||
|
g, _ := a["generation"].(float64)
|
||||||
|
return v.LicencePutOpen(caller, arg(a, "licence"), arg(a, "part"), int(g))
|
||||||
|
}},
|
||||||
{Name: Seat + ".move-in",
|
{Name: Seat + ".move-in",
|
||||||
Description: "Take one value of the controller's store into the vault, as it is (design 57's V3): from the " +
|
Description: "Take one value of the controller's store into the vault, as it is (design 57's V3): from the " +
|
||||||
"node-engine of a machine it was sealed to, and no other caller, in two steps — name the row (node, " +
|
"node-engine of a machine it was sealed to, and no other caller, in two steps — name the row (node, " +
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ const ChangedEvent = "secret-changed"
|
|||||||
// Vault holds the secrets.
|
// Vault holds the secrets.
|
||||||
type Vault struct {
|
type Vault struct {
|
||||||
Store *Store
|
Store *Store
|
||||||
|
// LicenceBindings is the vault's own state of the licence bindings (licencebindings.go): which machine a licence's
|
||||||
|
// access token goes to. Nil hands it to no machine.
|
||||||
|
LicenceBindings *LicenceBindings
|
||||||
// Own is the vault's own key; the private half never leaves its state directory.
|
// Own is the vault's own key; the private half never leaves its state directory.
|
||||||
Own seal.KeyPair
|
Own seal.KeyPair
|
||||||
// Askers decides who may ask `get` for what. Nil allows nobody.
|
// Askers decides who may ask `get` for what. Nil allows nobody.
|
||||||
@@ -150,6 +153,9 @@ type keepOptions struct {
|
|||||||
replaceOnly bool
|
replaceOnly bool
|
||||||
keepPrevious bool
|
keepPrevious bool
|
||||||
refuse func(before Record) string
|
refuse func(before Record) string
|
||||||
|
// generation, when one or more, is the record's generation as the caller numbers it (licence-put, licences.go): a
|
||||||
|
// value at a generation no newer than the one held is refused.
|
||||||
|
generation int
|
||||||
}
|
}
|
||||||
|
|
||||||
// keepWith is keep with the move's options. A covered bus account kept anew has its hash written once it is kept
|
// keepWith is keep with the move's options. A covered bus account kept anew has its hash written once it is kept
|
||||||
@@ -234,6 +240,10 @@ func (v *Vault) keepLocked(name, value, by, via, origin string, opt keepOptions)
|
|||||||
return Changed{}, errors.New(why)
|
return Changed{}, errors.New(why)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if opt.generation > 0 && opt.generation <= before.Generation {
|
||||||
|
return Changed{}, fmt.Errorf("%s is held at generation %d, and generation %d is not newer: nothing was taken",
|
||||||
|
name, before.Generation, opt.generation)
|
||||||
|
}
|
||||||
if opt.neverReplace {
|
if opt.neverReplace {
|
||||||
return Changed{}, &HeldAnother{Name: name, Generation: before.Generation, ChangedBy: before.ChangedBy,
|
return Changed{}, &HeldAnother{Name: name, Generation: before.Generation, ChangedBy: before.ChangedBy,
|
||||||
ChangedAt: before.ChangedAt, Origin: before.Origin}
|
ChangedAt: before.ChangedAt, Origin: before.Origin}
|
||||||
@@ -266,6 +276,9 @@ func (v *Vault) keepLocked(name, value, by, via, origin string, opt keepOptions)
|
|||||||
} else if !opt.madeAt.IsZero() {
|
} else if !opt.madeAt.IsZero() {
|
||||||
rec.MadeAt = opt.madeAt.UTC()
|
rec.MadeAt = opt.madeAt.UTC()
|
||||||
}
|
}
|
||||||
|
if opt.generation > 0 {
|
||||||
|
rec.Generation = opt.generation
|
||||||
|
}
|
||||||
if opt.movedFrom != nil {
|
if opt.movedFrom != nil {
|
||||||
moved := *opt.movedFrom
|
moved := *opt.movedFrom
|
||||||
moved.At = now
|
moved.At = now
|
||||||
@@ -376,8 +389,16 @@ func (v *Vault) Get(caller, name, publicKey string) (Answer, error) {
|
|||||||
_ = v.recordHanding(Handing{To: caller, Name: name, Outcome: "refused", Why: why})
|
_ = v.recordHanding(Handing{To: caller, Name: name, Outcome: "refused", Why: why})
|
||||||
return Answer{}, fmt.Errorf("%s may not ask for %s: %s", caller, name, why)
|
return Answer{}, fmt.Errorf("%s may not ask for %s: %s", caller, name, why)
|
||||||
}
|
}
|
||||||
|
isLicence, licenceWhy := v.licenceRefusal(caller, name)
|
||||||
if isLogin {
|
if isLogin {
|
||||||
// Judged above: the node's own engine, sealed to its own key.
|
// Judged above: the node's own engine, sealed to its own key.
|
||||||
|
} else if isLicence {
|
||||||
|
// A licence's secret, by the licence rule (design 39 bus-and-switch §16, §17).
|
||||||
|
if licenceWhy != "" {
|
||||||
|
v.log("refused %s to %s: %s", name, caller, licenceWhy)
|
||||||
|
_ = v.recordHanding(Handing{To: caller, Name: name, Outcome: "refused", Why: licenceWhy})
|
||||||
|
return Answer{}, fmt.Errorf("%s may not ask for %s: %s", caller, name, licenceWhy)
|
||||||
|
}
|
||||||
} else if ok, why := askers.May(caller, name); !ok {
|
} else if ok, why := askers.May(caller, name); !ok {
|
||||||
if reason, notYet := strings.CutPrefix(why, notYetPrefix); notYet {
|
if reason, notYet := strings.CutPrefix(why, notYetPrefix); notYet {
|
||||||
// Not a refusal (issue 928): the client waits and asks again, and nothing is recorded against it.
|
// Not a refusal (issue 928): the client waits and asks again, and nothing is recorded against it.
|
||||||
|
|||||||
@@ -148,10 +148,11 @@ func TestNoBucketHoldsASecret(t *testing.T) {
|
|||||||
if err := json.Unmarshal(raw, &manifest); err != nil {
|
if err := json.Unmarshal(raw, &manifest); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if s, _ := json.Marshal(manifest["state"]); string(s) != `[{"history":2,"name":"bus-accounts"},"signers","bound","node-logins","findings"]` {
|
if s, _ := json.Marshal(manifest["state"]); string(s) != `[{"history":2,"name":"bus-accounts"},"signers","bound","node-logins","findings","licence-bindings"]` {
|
||||||
t.Errorf("the manifest declares buckets %s: a secret is in no bucket, sealed or not (ADR 0201 §13), and the "+
|
t.Errorf("the manifest declares buckets %s: a secret is in no bucket, sealed or not (ADR 0201 §13), and the "+
|
||||||
"buckets are the accounts' hashes (ADR 0547 clause 9), the signing keys' public halves (ADR 0550 §2) and the "+
|
"buckets are the accounts' hashes (ADR 0547 clause 9), the signing keys' public halves (ADR 0550 §2) and the "+
|
||||||
"credentials' outcomes, names and generations alone (ADR 0570 clauses 3, 6) and each node's login in use (ADR 0583)", s)
|
"credentials' outcomes, names and generations alone (ADR 0570 clauses 3, 6), each node's login in use (ADR 0583) "+
|
||||||
|
"and the licence bindings, a licence's name, key and generation per consumer (design 39 bus-and-switch §17)", s)
|
||||||
}
|
}
|
||||||
if r, _ := json.Marshal(manifest["reads"]); string(r) != `["node-engine.have","mesh-grants.accounts","mesh-provisioner.plans","mesh-provisioner.provisions"]` {
|
if r, _ := json.Marshal(manifest["reads"]); string(r) != `["node-engine.have","mesh-grants.accounts","mesh-provisioner.plans","mesh-provisioner.provisions"]` {
|
||||||
t.Errorf("the manifest reads %s: the vault reads the seat mesh-provisioner's plans and provisions, the haves, the "+
|
t.Errorf("the manifest reads %s: the vault reads the seat mesh-provisioner's plans and provisions, the haves, the "+
|
||||||
@@ -182,6 +183,12 @@ func TestNoBucketHoldsASecret(t *testing.T) {
|
|||||||
code = []byte(strings.Replace(string(code), "stdio.State(NodeLoginsState)."+call, "", -1))
|
code = []byte(strings.Replace(string(code), "stdio.State(NodeLoginsState)."+call, "", -1))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// The licence bindings, in licencebindings.go alone: the state opened once, a licence's name, key and generation per
|
||||||
|
// consumer, never a secret (design 39 bus-and-switch §17, B25).
|
||||||
|
if f == "licencebindings.go" {
|
||||||
|
code = []byte(strings.Replace(string(code), "st := stdio.State(LicenceBindingsState)", "", 1))
|
||||||
|
code = []byte(strings.Replace(string(code), "meshconditionsclient.FindingWriter{State: stdio.State(FindingsState)}", "", 1))
|
||||||
|
}
|
||||||
// The signing keys' public halves, in signing.go alone: one read and one write of its own bucket (ADR 0550 §2).
|
// The signing keys' public halves, in signing.go alone: one read and one write of its own bucket (ADR 0550 §2).
|
||||||
if f == "signing.go" {
|
if f == "signing.go" {
|
||||||
code = []byte(strings.Replace(string(code), "stdio.State(meshvault.StateSigners).Get(", "", 1))
|
code = []byte(strings.Replace(string(code), "stdio.State(meshvault.StateSigners).Get(", "", 1))
|
||||||
|
|||||||
@@ -32,7 +32,8 @@
|
|||||||
],
|
],
|
||||||
"invokes": [
|
"invokes": [
|
||||||
"seat:mesh-hand-acts.hand-acts",
|
"seat:mesh-hand-acts.hand-acts",
|
||||||
"mesh-delivery-assigner.assigner_read_assignments"
|
"mesh-delivery-assigner.assigner_read_assignments",
|
||||||
|
"seat:anthropic-licence-manager.bindings"
|
||||||
],
|
],
|
||||||
"reads": [
|
"reads": [
|
||||||
"node-engine.have",
|
"node-engine.have",
|
||||||
@@ -48,7 +49,8 @@
|
|||||||
"signers",
|
"signers",
|
||||||
"bound",
|
"bound",
|
||||||
"node-logins",
|
"node-logins",
|
||||||
"findings"
|
"findings",
|
||||||
|
"licence-bindings"
|
||||||
],
|
],
|
||||||
"receives": {
|
"receives": {
|
||||||
"secret": "${dir:grants}/mesh.json"
|
"secret": "${dir:grants}/mesh.json"
|
||||||
@@ -238,7 +240,8 @@
|
|||||||
"rotate-node",
|
"rotate-node",
|
||||||
"node-account-confirm",
|
"node-account-confirm",
|
||||||
"abandon",
|
"abandon",
|
||||||
"retire"
|
"retire",
|
||||||
|
"licence-put"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -253,6 +256,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"consumes": [
|
"consumes": [
|
||||||
"mesh-delivery-assigner.views.assignments"
|
"mesh-delivery-assigner.views.assignments",
|
||||||
|
"claude-licence-manager.binding.changed"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user