M-9: every change of a binding is said as binding.changed (issue 1409)
Bind, switch, release, a login's move and a rotation's advance each say one binding.changed per consumer moved, with the licence's key, after the key of bindings is written or deleted; a switch through the verbs says one licence.switched with the from, to, consumers, by and why the audit's switched entry also holds. licence.bound, released and rotated are said no more. An adoption advances the consumers already bound before it moves the login's nodes, so each is said once. Builds: design 39 bus-and-switch §21, §27 (the verb's switch), §36, B7, B16 Claude-Session: https://claude.ai/code/session_01NcbgfHb9d4T9nq5Q6WqdiJ
This commit is contained in:
@@ -79,27 +79,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
|
||||||
|
// `rotated` are said no more (B16). Never a token.
|
||||||
func (m *Manager) record(ctx context.Context, what, line string, detail map[string]any) error {
|
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 {
|
if err := m.Store.Audit(ctx, what, detail); err != nil {
|
||||||
m.Log("%s, but %v", line, err)
|
m.Log("%s, but %v", line, err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
m.Log("%s", line)
|
m.Log("%s", line)
|
||||||
_ = m.Emit("licence."+what, detail)
|
|
||||||
return nil
|
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) error {
|
// 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)
|
||||||
}
|
}
|
||||||
return 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)})
|
||||||
|
}
|
||||||
|
|
||||||
|
// switchedFields are a switch's fields, the same in the event licence.switched and the audit entry switched (§27).
|
||||||
|
func switchedFields(from, to string, consumers []string, c Cause) map[string]any {
|
||||||
|
return map[string]any{"from": from, "to": to, "consumers": consumers, "by": c.By, "why": c.Why}
|
||||||
|
}
|
||||||
|
|
||||||
|
// moved records a consumer bound or switched, after its key is written: in the journal and the audit (a switch with
|
||||||
|
// §27's fields), and as binding.changed.
|
||||||
|
func (m *Manager) moved(ctx context.Context, b Binding, from string, c Cause) error {
|
||||||
|
what, line := "bound", fmt.Sprintf("bound %s to %s", b.Consumer, b.Licence)
|
||||||
|
detail := map[string]any{"consumer": b.Consumer, "licence": b.Licence, "from": from, "generation": b.Generation,
|
||||||
|
"why": c.Why, "by": c.By}
|
||||||
|
if from != "" && from != b.Licence {
|
||||||
|
what, line = "switched", fmt.Sprintf("switched %s from %s to %s", b.Consumer, from, b.Licence)
|
||||||
|
detail = switchedFields(from, b.Licence, []string{b.Consumer}, c)
|
||||||
|
}
|
||||||
|
err := m.record(ctx, what, fmt.Sprintf("%s (generation %d): %s; asked by %s", line, b.Generation, c.Why, c.By), detail)
|
||||||
|
m.changed(b, from)
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Settings are the manager's own, declared with defaults (design 39 §8).
|
// Settings are the manager's own, declared with defaults (design 39 §8).
|
||||||
@@ -305,7 +326,7 @@ func (m *Manager) bindReporters(ctx context.Context, reports []Holdings, floor f
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := m.moved(ctx, rep.Node, "", l.Name, b.Generation, c); err != nil {
|
if err := m.moved(ctx, b, "", c); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -418,6 +439,11 @@ func (m *Manager) adoptOne(ctx context.Context, c Candidate, reports []Holdings,
|
|||||||
// 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 {
|
||||||
@@ -429,7 +455,7 @@ func (m *Manager) adoptOne(ctx context.Context, c Candidate, reports []Holdings,
|
|||||||
if b != nil {
|
if b != nil {
|
||||||
from = b.Licence
|
from = b.Licence
|
||||||
}
|
}
|
||||||
if err := m.moved(ctx, c.Node, from, l.Name, moved.Generation, cause); err != nil {
|
if err := m.moved(ctx, moved, from, cause); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -442,14 +468,11 @@ func (m *Manager) adoptOne(ctx context.Context, c Candidate, reports []Holdings,
|
|||||||
}
|
}
|
||||||
bc := Cause{Why: rep.Node + " reports the account of " + l.Name + ", logged in on " + c.Node + ", and was bound to nothing", By: Itself}
|
bc := Cause{Why: rep.Node + " reports the account of " + l.Name + ", logged in on " + c.Node + ", and was bound to nothing", By: Itself}
|
||||||
if b, err := m.Store.Bind(ctx, rep.Node, l.Name, Move{Kind: l.Kind, Why: bc.Why, By: bc.By, Floor: floor}); err == nil {
|
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 {
|
||||||
if err := m.moved(ctx, rep.Node, "", l.Name, b.Generation, bc); err != nil {
|
if err := m.moved(ctx, b, "", bc); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err := m.publishAdvance(ctx, l, floor); err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
_ = m.Emit("licence.adopted", map[string]any{"licence": l.Name, "from": c.Node, "replaced": held != nil})
|
_ = 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
|
||||||
@@ -506,7 +529,10 @@ func (m *Manager) grantOf(ctx context.Context, l Licence) (FullGrant, error) {
|
|||||||
// it fetched again (novox/hq issue 625).
|
// it fetched again (novox/hq issue 625).
|
||||||
func (m *Manager) publishAdvance(ctx context.Context, l Licence, floor func(string) int64) error {
|
func (m *Manager) publishAdvance(ctx context.Context, l Licence, floor func(string) int64) error {
|
||||||
c := CauseOf(ctx)
|
c := CauseOf(ctx)
|
||||||
_, err := m.Store.Advance(ctx, l.Name, Move{Kind: l.Kind, Why: c.Why, By: c.By, Floor: floor})
|
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
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -761,9 +787,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
|
||||||
}
|
}
|
||||||
if err := m.moved(ctx, consumer, from, licence, b.Generation, c); err != nil {
|
if err := m.moved(ctx, b, from, c); 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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -781,6 +810,7 @@ func (m *Manager) Release(ctx context.Context, consumer string) (map[string]any,
|
|||||||
map[string]any{"consumer": consumer, "licence": was.Licence, "generation": was.Generation, "why": c.Why, "by": c.By}); err != nil {
|
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
|
||||||
}
|
}
|
||||||
|
m.changed(Binding{Consumer: consumer, Generation: was.Generation, Why: c.Why, By: c.By}, was.Licence)
|
||||||
return map[string]any{"consumer": consumer, "released": true, "was": was.Licence}, nil
|
return map[string]any{"consumer": consumer, "released": true, "was": was.Licence}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -186,12 +186,11 @@ func TestAManagerWithNoLicenceAdoptsTheNewestLoginThatRefreshesAndNeverExchanges
|
|||||||
if len(bs) != 2 || mm.bound("laptop").Licence != licence1 || mm.bound("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.bindings.values)
|
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -522,27 +521,62 @@ 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.bound("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 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" {
|
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)
|
||||||
}
|
}
|
||||||
@@ -551,35 +585,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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
all := mm.audit.under("")
|
all := mm.audit.under("switched")
|
||||||
last := all[len(all)-1]
|
if raw, _ := json.Marshal(all[len(all)-1]); !strings.Contains(string(raw), `"by":"g14.node-tools"`) || !strings.Contains(string(raw), `"to":"api"`) {
|
||||||
if raw, _ := json.Marshal(last); !strings.Contains(string(raw), `"by":"g14.node-tools"`) || !strings.Contains(string(raw), "switched") {
|
|
||||||
t.Fatalf("the audit kept %s", raw)
|
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)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user