Merge pull request 'mesh-grants: a seat keeps its holder under any placement, and no list or membership leaves a claimed seat without one (issue 1945)' (#88) from fix/1945-grants-seats into main
This commit was merged in pull request #88.
This commit is contained in:
@@ -85,8 +85,9 @@ func toolsOf(p *grants.Part) []stdio.Tool {
|
||||
"the part's process (which alone holds the grant to publish a membership) to compose the memberships, the user list and " +
|
||||
"the assertions, publish each membership that differs from the one last published, and keep what changed in its own " +
|
||||
"state (one revision when an input changed, none when not), saying user-list-changed and membership-changed. Answers " +
|
||||
"the ask and the last pass; grants_user_list shows the pass that answers it. A membership is published only when it is " +
|
||||
"the first or only adds to the last one; any loss stays a shadow difference, named. The user list, assertions and " +
|
||||
"the ask and the last pass; grants_user_list shows the pass that answers it. A membership is published unless it would " +
|
||||
"leave a seat a placed module claims with no holder, or drop a capability its holder still asks for (verified-sender), " +
|
||||
"which stays a shadow difference, named. The user list, assertions and " +
|
||||
"grants stay in shadow. It cannot ask the controller to write a module's grant again: the controller serves no such " +
|
||||
"verb, and the answer says so.",
|
||||
Run: func(a map[string]any) (any, error) { return p.AskCompose(stdio.CallerOf(a)) }},
|
||||
|
||||
@@ -52,6 +52,8 @@ type fakeBus struct {
|
||||
// creates are the streams the part asked to create, refused when refuseCreate.
|
||||
creates []string
|
||||
refuseCreate bool
|
||||
// refuseUsers refuses the bus module's nats_users: the loaded list cannot be read.
|
||||
refuseUsers bool
|
||||
// rootFree answers the controller's root-free verb: nil is no answerer at all (no responders).
|
||||
rootFree func(machines []string) (any, error)
|
||||
}
|
||||
@@ -155,6 +157,9 @@ func (b *fakeBus) Call(key string, body any) (json.RawMessage, error) {
|
||||
args, _ := body.(map[string]any)
|
||||
switch key {
|
||||
case grants.CallUsers:
|
||||
if b.refuseUsers {
|
||||
return nil, errors.New("nats: no responders available for request")
|
||||
}
|
||||
part, _ := args["user"].(string)
|
||||
if part == "" {
|
||||
var names []map[string]any
|
||||
@@ -248,6 +253,17 @@ const (
|
||||
yManifest = `{"module":"mod-y","version":"1","own-secrets":{"broker":"/var/lib/y/broker"},"reads":["mod-x.kept"]}`
|
||||
)
|
||||
|
||||
// brokerSeat is one verb of the bus's seat as a membership serves it: nats claims the seat in fixture and no membership
|
||||
// composed there serves it, so a membership or list that drops it leaves the seat with no holder (issue 1945).
|
||||
func brokerSeat() (seat, verb string) {
|
||||
s, _ := catalogue.SeatNamed(catalogue.BrokerSeat)
|
||||
verb = "v"
|
||||
if len(s.Serves) > 0 {
|
||||
verb = s.Serves[0].Name
|
||||
}
|
||||
return s.Name, verb
|
||||
}
|
||||
|
||||
// fixture is a mesh of two machines: a runs the bus and mod-x, b runs mod-y; the bus node's agent loaded a list.
|
||||
func fixture() *fakeBus {
|
||||
b := newFakeBus()
|
||||
@@ -761,6 +777,62 @@ func TestGrants(t *testing.T) {
|
||||
if read := grants.ReadInputs(b, &grants.Manifests{}); slices.ContainsFunc(read.Gaps, func(g grants.Gap) bool { return g.Input == grants.ReadPlacement && g.Failed }) {
|
||||
t.Errorf("placement not on the bus is a failed read: %v", read.Gaps)
|
||||
}
|
||||
// Issue 1945, the user list of 2026-10-27 09:21: a placement's holds are the holders on record, not every holder.
|
||||
// A mesh seat no placement holds keeps the module claiming it as its holder, wherever placement puts it.
|
||||
in.Manifests["other"] = catalogue.Manifest{Module: "other", Version: "1", Claims: []catalogue.Claim{{Name: "other-seat", Scope: catalogue.ScopeMesh}},
|
||||
DefinesSeats: []catalogue.SeatDeclaration{{Name: "other-seat", Scope: catalogue.ScopeMesh, Serves: []catalogue.Verb{{Name: "v"}}}}}
|
||||
in.Assigned["b"] = append(in.Assigned["b"], "other")
|
||||
in.Placed["b"] = delivery.Placement{Node: "b", Modules: []delivery.PlacedModule{{Module: "store"}, {Module: "other"}}}
|
||||
if recs, _ := grants.Records(in); holds(recs, "a") != 1 || holds(recs, "b") != 1 {
|
||||
t.Errorf("a seat no placement holds is held on b %d times, want once (and store-seat on a alone: a %d)", holds(recs, "b"), holds(recs, "a"))
|
||||
}
|
||||
in.Placed["b"] = delivery.Placement{Node: "b", Modules: []delivery.PlacedModule{{Module: "store"}, {Module: "other"}},
|
||||
Holds: []delivery.Holding{{Claim: "other-seat", Scope: "mesh", Module: "someone-else"}}}
|
||||
if recs, _ := grants.Records(in); holds(recs, "b") != 0 {
|
||||
t.Errorf("a seat placement holds by another module is still held by other on b")
|
||||
}
|
||||
// The live shapes: the seed's novox placement (mesh-catalog 0c61bad, its twelve holdings, without
|
||||
// anthropic-licence-manager), the licence manager's manifest (mesh-catalog c05aa5e6), and the subjects the bus
|
||||
// grants novox.node-tools for its seat (nats_users, 2026-10-27 19:40Z).
|
||||
file := func(name string, v any) {
|
||||
raw, err := os.ReadFile(filepath.Join("testdata", name))
|
||||
if err == nil {
|
||||
err = json.Unmarshal(raw, v)
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
var seed delivery.Placement
|
||||
var live struct{ Users []grants.User }
|
||||
file("placement-novox-0c61bad.json", &seed)
|
||||
file("node-tools-novox-licence-subjects.json", &live)
|
||||
licence, _ := os.ReadFile(filepath.Join("testdata", "claude-licence-manager.json"))
|
||||
n := newFakeBus()
|
||||
n.manifestOf("nats", natsManifest)
|
||||
n.manifestOf("claude-licence-manager", string(licence))
|
||||
n.assign("novox", "nats")
|
||||
n.assign("novox", "claude-licence-manager")
|
||||
n.manifestOf("node-tools", `{"module":"node-tools","version":"1"}`) // the machine's tool runner, which carries the seat
|
||||
n.assign("novox", "node-tools")
|
||||
n.put(grants.ReadHave, "novox.node", map[string]any{"v": 1})
|
||||
pl, _ = delivery.EncodePlacement(seed)
|
||||
n.states[grants.ReadPlacement] = map[string][]byte{"placement.novox": pl}
|
||||
recs, _ := grants.Records(grants.ReadInputs(n, &grants.Manifests{}))
|
||||
if m := grants.Memberships(recs)["novox.claude-licence-manager"]; len(m.Membership.Seats) == 0 {
|
||||
t.Errorf("the licence manager's membership under the seeded placement carries no seats: %+v", m.Membership)
|
||||
}
|
||||
var runtime grants.User
|
||||
for _, u := range shadowOf(t, n) {
|
||||
if u.User == "novox.node-tools" {
|
||||
runtime = u
|
||||
}
|
||||
}
|
||||
for _, s := range live.Users[0].Subscribe {
|
||||
if strings.Contains(s, ".call.") && !slices.Contains(runtime.Subscribe, s) {
|
||||
t.Errorf("under the seeded placement novox.node-tools is not granted %s, which the bus grants it today", s)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("compare excuses a retired verb and a multiply claimed mesh seat's added subscriptions on its machines, nothing else", func(t *testing.T) {
|
||||
@@ -787,6 +859,34 @@ func TestGrants(t *testing.T) {
|
||||
t.Errorf("%s: equal %v, excused %v", row.name, c.Equal(), c.Excused)
|
||||
}
|
||||
}
|
||||
// The guard of the user list and the memberships (issue 1945, the lead's decision in review round 1 of its fix): a
|
||||
// seat the list in force holds left with no holder while a placed module still claims it, nothing else.
|
||||
sm, _ := catalogue.SeatNamed(catalogue.ServiceManagerSeat)
|
||||
nodeSeat := func(node string) string {
|
||||
return "mesh.seat." + sm.Name + ".tool." + sm.Serves[0].Name + "." + node
|
||||
}
|
||||
h := grants.HoldingsOf(grants.Inputs{Assigned: map[string][]string{"novox": {"postgres", "systemd"}, "ace": {"postgres", "systemd"}},
|
||||
Manifests: map[string]catalogue.Manifest{
|
||||
"postgres": {Module: "postgres", Claims: []catalogue.Claim{{Name: "mesh-store", Scope: catalogue.ScopeMesh}}},
|
||||
"systemd": {Module: "systemd", Claims: []catalogue.Claim{{Name: sm.Name, Scope: catalogue.ScopeNode}}}}})
|
||||
u := func(user string, sub ...string) grants.User { return grants.User{User: user, Subscribe: sub} }
|
||||
for _, row := range []struct {
|
||||
name string
|
||||
inForce, now []grants.User
|
||||
lost []string
|
||||
}{
|
||||
{"09:21: the seat's holder lost, its claimant still placed", []grants.User{u("novox.node-tools", "x", store)}, []grants.User{u("novox.node-tools", "x")}, []string{"mesh-store"}},
|
||||
{"a dropped tool", []grants.User{u("novox.node-tools", "mesh.mod.postgres.tool.gone", store)}, []grants.User{u("novox.node-tools", store)}, nil},
|
||||
{"a seat moved from novox to ace", []grants.User{u("novox.node-tools", store)}, []grants.User{u("novox.node-tools"), u("ace.node-tools", store)}, nil},
|
||||
{"a seat no placed module claims any more", []grants.User{u("novox.node-tools", "mesh.seat.unclaimed.tool.v")}, []grants.User{u("novox.node-tools")}, nil},
|
||||
{"a retired verb dropped", []grants.User{u("novox.node-tools", retired)}, []grants.User{u("novox.node-tools")}, nil},
|
||||
{"a node seat lost on novox, held on ace", []grants.User{u("novox.node-tools", nodeSeat("novox")), u("ace.node-tools", nodeSeat("ace"))},
|
||||
[]grants.User{u("novox.node-tools"), u("ace.node-tools", nodeSeat("ace"))}, []string{sm.Name + "@novox"}},
|
||||
} {
|
||||
if lost := h.LastHolders(h.ByUsers(row.inForce), h.ByUsers(row.now)); !slices.Equal(lost, row.lost) {
|
||||
t.Errorf("%s: lost %v, want %v", row.name, lost, row.lost)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("tools", func(t *testing.T) {
|
||||
@@ -851,11 +951,12 @@ func TestGrants(t *testing.T) {
|
||||
if third := p.Compose(); third.Written || third.MembershipsWritten != 0 || len(third.Published) != 0 || len(b.publishes) != 3 {
|
||||
t.Errorf("a membership equal to the one last published was published again, or a state written: %+v", third)
|
||||
}
|
||||
// mod-x placed on b too: b.mod-x is added and its first membership published; a.mod-x's would lose the module's
|
||||
// plain subject (mod-x now runs on two machines), a loss, so it stays a shadow difference.
|
||||
// mod-x placed on b too: b.mod-x is added and its first membership published; a.mod-x's loses the module's plain
|
||||
// subject (mod-x now runs on two machines), which leaves no seat without its holder, so it is published too (issue
|
||||
// 1945, the lead's decision in review round 1 of its fix).
|
||||
b.assign("b", "mod-x")
|
||||
moved := p.Compose()
|
||||
if !moved.Written || moved.Revision != 2 || !slices.Equal(moved.Published, []string{"b.mod-x"}) || len(moved.NotPublished) != 1 || !strings.HasPrefix(moved.NotPublished[0], "a.mod-x: "+grants.ShadowDifference) || moved.UserList.Compare.Equal() ||
|
||||
if !moved.Written || moved.Revision != 2 || !slices.Equal(moved.Published, []string{"a.mod-x", "b.mod-x"}) || len(moved.NotPublished) != 0 || moved.UserList.Compare.Equal() ||
|
||||
!slices.Equal(moved.UserList.Compare.Added, []string{"b.mod-x"}) {
|
||||
t.Errorf("a composition after placement moved = %+v", moved)
|
||||
}
|
||||
@@ -1068,6 +1169,50 @@ func TestGrants(t *testing.T) {
|
||||
if len(fresh) == 0 || fresh[0].Key != grants.HeldCondition || fresh[0].State != asks.StateCleared {
|
||||
t.Errorf("a fresh part's first pass does not say the held warning is cleared: %+v", fresh)
|
||||
}
|
||||
// Issue 1945, 2026-10-27 09:21: a list that would leave a seat the list in force holds with no holder,
|
||||
// while a placed module still claims it, is held, as such a membership is: not written, the warning
|
||||
// raised naming the seat. A list that only adds is written; one whose loaded grants cannot be read is held.
|
||||
inForce := slices.Clone(b.loaded)
|
||||
seat, verb := brokerSeat()
|
||||
seatGrant := "mesh.seat." + seat + ".call." + verb + ".*"
|
||||
for i := range b.loaded {
|
||||
if b.loaded[i].User == "a.nats" {
|
||||
b.loaded[i].Subscribe = append(slices.Clone(b.loaded[i].Subscribe), seatGrant)
|
||||
}
|
||||
}
|
||||
b.put(grants.ReadAssignments, "a.mod-y", map[string]any{"node": "a", "module": "mod-y", "assigned": "2026-10-27T08:20:00Z"})
|
||||
b.put(grants.ReadAccounts, "a.mod-y", map[string]any{"user": "a.mod-y", "hash": "$2a$10$x", "generation": 1})
|
||||
said = nil
|
||||
res := p.Compose()
|
||||
if res.Sealed == nil || !strings.Contains(res.Sealed.Why, "publishing would lose the last holder of "+seat) {
|
||||
t.Errorf("a list losing a seat grant = %+v", res.Sealed)
|
||||
}
|
||||
if now, _, _ := p.Sealed(); now.Digest != g.Digest {
|
||||
t.Errorf("a list losing a seat grant was written: %s, was %s", now.Digest, g.Digest)
|
||||
}
|
||||
if len(said) != 1 || said[0].Key != grants.HeldCondition || said[0].State != asks.StateRaised || !strings.Contains(said[0].Summary, "the last holder of "+seat) {
|
||||
t.Errorf("the held list did not raise the warning naming the lost grant: %+v", said)
|
||||
}
|
||||
b.loaded = inForce
|
||||
if p.Compose(); func() bool { now, _, _ := p.Sealed(); return now.Digest == g.Digest }() {
|
||||
t.Errorf("a list that only adds a.mod-y was not written")
|
||||
}
|
||||
written, _, _ := p.Sealed()
|
||||
b.mu.Lock()
|
||||
b.refuseUsers = true
|
||||
b.mu.Unlock()
|
||||
b.mu.Lock()
|
||||
delete(b.states[grants.ReadAssignments], "a.mod-y")
|
||||
b.mu.Unlock()
|
||||
if res := p.Compose(); res.Sealed == nil || !strings.Contains(res.Sealed.Why, "the grants of the list in force cannot be read") {
|
||||
t.Errorf("a pass whose loaded list cannot be read = %+v", res.Sealed)
|
||||
}
|
||||
if now, _, _ := p.Sealed(); now.Digest != written.Digest {
|
||||
t.Errorf("a list was written while the loaded list could not be read")
|
||||
}
|
||||
b.mu.Lock()
|
||||
b.refuseUsers = false
|
||||
b.mu.Unlock()
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1169,8 +1314,16 @@ func TestGrants(t *testing.T) {
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("memberships: a first or an additive one published, a loss never", func(t *testing.T) {
|
||||
t.Run("memberships: published unless a seat would lose its last holder or a holder a capability it asks for", func(t *testing.T) {
|
||||
const key = "a.mod-x"
|
||||
brokerName, brokerVerb := brokerSeat()
|
||||
// The telegram module's claim of its channel, as mesh-catalog's telegram manifest has it (c05aa5e6), on mod-x.
|
||||
const telegramClaim = `[{"name":"channel","scope":"mesh","kind":"telegram","capabilities":["deliver","verified-sender"]}]`
|
||||
verified := func(m map[string]any) map[string]any {
|
||||
m["seat-traffic"] = map[string]any{"kinds": []any{map[string]any{"seat": "channel", "kind": "telegram", "module": "mod-x", "node": "a",
|
||||
"capabilities": []any{"deliver", "verified-sender"}}}}
|
||||
return m
|
||||
}
|
||||
composed := func(b *fakeBus) map[string]any {
|
||||
recs, _ := grants.Records(grants.ReadInputs(b, &grants.Manifests{}))
|
||||
raw, _ := json.Marshal(grants.Memberships(recs)[key].Membership)
|
||||
@@ -1183,35 +1336,46 @@ func TestGrants(t *testing.T) {
|
||||
last func(map[string]any) map[string]any
|
||||
published bool
|
||||
names string
|
||||
// claims, when set, are mod-x's claims in its manifest for the row.
|
||||
claims string
|
||||
}{
|
||||
{"a first membership is published", nil, true, ""},
|
||||
{"a first membership is published", nil, true, "", ""},
|
||||
{"an additive change is published, the private network carried", func(m map[string]any) map[string]any {
|
||||
delete(m, "state")
|
||||
m["mesh"] = []any{"10.0.0.1"}
|
||||
return m
|
||||
}, true, ""},
|
||||
{"a change that drops verified-sender is not published, and named", func(m map[string]any) map[string]any {
|
||||
m["seat-traffic"] = map[string]any{"kinds": []any{map[string]any{"seat": "channel", "kind": "telegram", "capabilities": []any{"verified-sender"}}}}
|
||||
return m
|
||||
}, false, "verified-sender"},
|
||||
{"a change that drops any other field is not published", func(m map[string]any) map[string]any {
|
||||
}, true, "", ""},
|
||||
// The lead's decision in review round 1 of the fix of issue 1945: only a seat left with no holder, or a
|
||||
// capability its holder still asks for (verified-sender, dropped by a root-free word that failed), holds a
|
||||
// membership back; a dropped tool or field is published.
|
||||
{"a change that drops a tool is published", func(m map[string]any) map[string]any {
|
||||
m["serves"] = append(m["serves"].([]any), map[string]any{"subject": "mesh.mod.mod-x.tool.gone"})
|
||||
m["mesh"] = []any{"10.0.0.1"}
|
||||
return m
|
||||
}, false, "tool.gone"},
|
||||
}, true, "", ""},
|
||||
{"a change that drops verified-sender its holder still asks for is not published, and named", verified, false, "channel telegram verified-sender of a/mod-x", telegramClaim},
|
||||
{"a change that drops verified-sender its holder no longer asks for is published", verified, true, `"published":"published"`, `[{"name":"channel","scope":"mesh","kind":"telegram"}]`},
|
||||
{"a change that takes the last holder from a seat a placed module claims is not published, the seat named", func(m map[string]any) map[string]any {
|
||||
m["seats"] = []any{map[string]any{"seat": brokerName, "verb": brokerVerb, "subject": "mesh.seat." + brokerName + ".tool." + brokerVerb}}
|
||||
return m
|
||||
}, false, "the last holder of " + brokerName, ""},
|
||||
// A verb the seat catalogue retired on purpose (mark-ready, mesh-sdk d506f21, ADR 0421) is dropped and named; a
|
||||
// verb of a seat the catalogue does not define is still a loss (issue 1945).
|
||||
{"a change that drops only a retired seat verb is published, the verb named", func(m map[string]any) map[string]any {
|
||||
m["seats"] = []any{map[string]any{"seat": "mesh-delivery", "verb": "mark-ready", "subject": "mesh.seat.mesh-delivery.tool.mark-ready"}}
|
||||
return m
|
||||
}, true, "mesh-delivery mark-ready"},
|
||||
{"a retired verb and a module seat's verb dropped: not published, the module seat's named", func(m map[string]any) map[string]any {
|
||||
}, true, "mesh-delivery mark-ready", ""},
|
||||
{"a seat no placed module claims dropped is published", func(m map[string]any) map[string]any {
|
||||
m["seats"] = []any{map[string]any{"seat": "mesh-delivery", "verb": "mark-ready", "subject": "mesh.seat.mesh-delivery.tool.mark-ready"},
|
||||
map[string]any{"seat": "telegram-sender", "verb": "send", "subject": "mesh.seat.telegram-sender.tool.send"}}
|
||||
return m
|
||||
}, false, "telegram-sender"},
|
||||
}, true, "mesh-delivery mark-ready", ""},
|
||||
} {
|
||||
t.Run(row.name, func(t *testing.T) {
|
||||
b := fixture()
|
||||
if row.claims != "" {
|
||||
b.manifestOf("mod-x", strings.Replace(xManifest, `"own-secrets"`, `"claims":`+row.claims+`,"own-secrets"`, 1))
|
||||
}
|
||||
b.loaded = shadowOf(t, b)
|
||||
if row.last != nil {
|
||||
raw, _ := json.Marshal(row.last(composed(b)))
|
||||
@@ -1378,7 +1542,8 @@ func TestGrants(t *testing.T) {
|
||||
raw, _ := json.Marshal(grants.Memberships(recs)[key].Membership)
|
||||
var m map[string]any
|
||||
_ = json.Unmarshal(raw, &m)
|
||||
m["serves"] = append(m["serves"].([]any), map[string]any{"subject": "mesh.mod.lost.tool.gone"})
|
||||
seat, verb := brokerSeat()
|
||||
m["seats"] = []any{map[string]any{"seat": seat, "verb": verb, "subject": "mesh.seat." + seat + ".tool." + verb}}
|
||||
raw, _ = json.Marshal(m)
|
||||
subject := "mesh.assignment." + key
|
||||
b.stream[subject], b.seqs[subject] = raw, uint64(i+1)
|
||||
@@ -1404,7 +1569,7 @@ func TestGrants(t *testing.T) {
|
||||
<-done
|
||||
held, err := partOn(b).MembershipOf("", "")
|
||||
h, _ := held.(map[string]any)
|
||||
if err != nil || h["count"] != 2 || !strings.Contains(fmt.Sprint(h["held"]), "tool.gone") {
|
||||
if seat, _ := brokerSeat(); err != nil || h["count"] != 2 || !strings.Contains(fmt.Sprint(h["held"]), "the last holder of "+seat) {
|
||||
t.Errorf("grants_membership with neither node nor module = %v, %v", held, err)
|
||||
}
|
||||
if _, err := partOn(b).MembershipOf("a", ""); err == nil {
|
||||
|
||||
@@ -74,8 +74,9 @@ const (
|
||||
)
|
||||
|
||||
// publish publishes m when it differs from the membership last published for its assignment, carrying that one's private
|
||||
// network and received contributions; it answers m as published (or why not) and whether it was published now.
|
||||
func publish(p Publisher, m Membership) (Membership, bool) {
|
||||
// network and received contributions; it answers m as published (or why not) and whether it was published now. held is
|
||||
// every holding the memberships composed now carry (Holdings.ByMemberships).
|
||||
func publish(p Publisher, m Membership, h Holdings, held map[string]bool) (Membership, bool) {
|
||||
if p == nil {
|
||||
m.Published = "not published: no publisher here (only the part's process publishes)"
|
||||
return m, false
|
||||
@@ -101,15 +102,24 @@ func publish(p Publisher, m Membership) (Membership, bool) {
|
||||
m.Published = MembershipUnchanged
|
||||
return m, false
|
||||
}
|
||||
// Only a first membership, or one that only adds to the last, is published (the lead's decision, review of 1ff5049):
|
||||
// composed with no root-free judgement, a kind's verified-sender would be dropped, and the operator's approvals by
|
||||
// a channel would no longer be honoured. Any loss stays a shadow difference, named.
|
||||
// A membership is held back only when it would take the last holder from a seat some placed module still claims (the
|
||||
// 09:21 kind of issue 1945; the lead's decision in review round 1 of its fix, replacing the hold on any loss of review
|
||||
// 1ff5049), or drop a capability a kind's holder still asks for (verified-sender, dropped by a root-free word that
|
||||
// failed): a dropped tool or verb, or a seat handed to another node, is published.
|
||||
if found {
|
||||
kept, retired := WithoutRetired(was)
|
||||
m.Retired = retired
|
||||
if lost := Losses(kept, body); len(lost) > 0 {
|
||||
m.Published = ShadowDifference + strings.Join(lost, "; ")
|
||||
return m, false
|
||||
var last membership.Membership
|
||||
if json.Unmarshal(kept, &last) == nil {
|
||||
last.Node = m.Node
|
||||
if lost := h.LastHolders(h.ByMemberships(last), held); len(lost) > 0 {
|
||||
m.Published = ShadowDifference + "the last holder of " + strings.Join(lost, ", ")
|
||||
return m, false
|
||||
}
|
||||
if lost := h.CapabilitiesLost(last, m.Membership); len(lost) > 0 {
|
||||
m.Published = CapabilityDifference + strings.Join(lost, "; ")
|
||||
return m, false
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := p.PublishMembership(m.Node, m.Module, body, seq); err != nil {
|
||||
@@ -184,73 +194,157 @@ func sameMembership(was, now []byte) bool {
|
||||
return reflect.DeepEqual(a, b)
|
||||
}
|
||||
|
||||
// ShadowDifference opens what a membership's pass says when it was not published because it would lose something.
|
||||
// ShadowDifference opens what a membership's or the user list's pass says when it was not published because it would
|
||||
// leave a seat with no holder (LastHolders).
|
||||
const ShadowDifference = "not published, a shadow difference: publishing would lose "
|
||||
|
||||
// Losses names everything the last membership holds that now drops or weakens: a field gone, a value changed, a list
|
||||
// element (a capability, a kind, a served address, a seat, a state, a stream) with no element in now that holds all of it.
|
||||
// Empty means now holds all the last one did, and maybe more. Pure.
|
||||
func Losses(last, now []byte) []string {
|
||||
var a, b any
|
||||
if json.Unmarshal(last, &a) != nil {
|
||||
return nil
|
||||
}
|
||||
if json.Unmarshal(now, &b) != nil {
|
||||
return []string{"the new membership is not JSON"}
|
||||
}
|
||||
return losses("", a, b)
|
||||
// Holdings is how the part tells whether a list or a membership would leave a seat with no holder (issue 1945): every
|
||||
// holding some assigned module claims now, and each seat's scope. A holding is named by its seat for a seat held once
|
||||
// for the mesh or a site, and `<seat>@<node>` for a seat of every node, so a seat handed to another node keeps its
|
||||
// holding and one dropped on a node does not.
|
||||
type Holdings struct {
|
||||
Claimed map[string]bool
|
||||
Scopes map[string]string
|
||||
// Asked is every capability an assigned module's claim of a kind asks for, `<node>/<module>/<seat>/<kind>/<capability>`.
|
||||
Asked map[string]bool
|
||||
}
|
||||
|
||||
func losses(path string, last, now any) []string {
|
||||
switch l := last.(type) {
|
||||
case map[string]any:
|
||||
n, ok := now.(map[string]any)
|
||||
if !ok {
|
||||
return []string{fmt.Sprintf("%s: %s", name(path), brief(last))}
|
||||
// HoldingsOf is the holdings the assigned modules claim now, by their running manifest or the trunk's, each scope as the
|
||||
// seat's declaration says (the seat catalogue, or the manifest defining it), else the claim's. Pure.
|
||||
func HoldingsOf(in Inputs) Holdings {
|
||||
h := Holdings{Claimed: map[string]bool{}, Scopes: map[string]string{}, Asked: map[string]bool{}}
|
||||
for _, m := range in.Manifests {
|
||||
for _, d := range m.DefinesSeats {
|
||||
if d.Scope != "" {
|
||||
h.Scopes[canonicalSeat(d.Name)] = d.Scope
|
||||
}
|
||||
}
|
||||
keys := make([]string, 0, len(l))
|
||||
for k := range l {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
var out []string
|
||||
for _, k := range keys {
|
||||
nv, ok := n[k]
|
||||
}
|
||||
for node, modules := range in.Assigned {
|
||||
for _, module := range modules {
|
||||
m, ok := in.Running[node+"."+module]
|
||||
if !ok {
|
||||
m, ok = in.Manifests[module]
|
||||
}
|
||||
if !ok {
|
||||
out = append(out, fmt.Sprintf("%s: %s", name(path+"."+k), brief(l[k])))
|
||||
continue
|
||||
}
|
||||
out = append(out, losses(path+"."+k, l[k], nv)...)
|
||||
}
|
||||
return out
|
||||
case []any:
|
||||
n, _ := now.([]any)
|
||||
var out []string
|
||||
for i, e := range l {
|
||||
if !slices.ContainsFunc(n, func(c any) bool { return len(losses("", e, c)) == 0 }) {
|
||||
out = append(out, fmt.Sprintf("%s[%d]: %s", name(path), i, brief(e)))
|
||||
for _, c := range m.Claims {
|
||||
seat := canonicalSeat(c.Name)
|
||||
if _, known := h.scope(seat); !known {
|
||||
h.Scopes[seat] = c.At()
|
||||
}
|
||||
h.Claimed[h.key(seat, node)] = true
|
||||
for _, capability := range c.Capabilities {
|
||||
h.Asked[askedKey(node, module, seat, c.Kind, capability)] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
if !reflect.DeepEqual(last, now) {
|
||||
return []string{fmt.Sprintf("%s: %s, now %s", name(path), brief(last), brief(now))}
|
||||
}
|
||||
return nil
|
||||
return h
|
||||
}
|
||||
|
||||
func name(path string) string {
|
||||
if path == "" {
|
||||
return "the membership"
|
||||
func canonicalSeat(name string) string {
|
||||
if s, ok := catalogue.SeatNamed(name); ok {
|
||||
return s.Name
|
||||
}
|
||||
return strings.TrimPrefix(path, ".")
|
||||
return name
|
||||
}
|
||||
|
||||
// brief is a value in JSON, cut at 200 bytes.
|
||||
func brief(v any) string {
|
||||
raw, _ := json.Marshal(v)
|
||||
if len(raw) > 200 {
|
||||
return string(raw[:200]) + "…"
|
||||
func (h Holdings) scope(seat string) (string, bool) {
|
||||
if s, ok := catalogue.SeatNamed(seat); ok && s.Scope != "" {
|
||||
return s.Scope, true
|
||||
}
|
||||
return string(raw)
|
||||
sc, ok := h.Scopes[seat]
|
||||
return sc, ok
|
||||
}
|
||||
|
||||
func (h Holdings) key(seat, node string) string {
|
||||
seat = canonicalSeat(seat)
|
||||
if sc, _ := h.scope(seat); sc == catalogue.ScopeNode {
|
||||
return seat + "@" + node
|
||||
}
|
||||
return seat
|
||||
}
|
||||
|
||||
// ByUsers is every holding a user list carries: a user subscribed to a seat's verb (seatSubject) holds it on its
|
||||
// machine; a verb the seat catalogue retired holds nothing. Pure.
|
||||
func (h Holdings) ByUsers(users []User) map[string]bool {
|
||||
out := map[string]bool{}
|
||||
for _, u := range users {
|
||||
node, _, _ := strings.Cut(u.User, ".")
|
||||
if node == "node" || node == "provisioner" {
|
||||
_, node, _ = strings.Cut(u.User, ".")
|
||||
}
|
||||
for _, s := range u.Subscribe {
|
||||
if m := seatSubject.FindStringSubmatch(s); m != nil && !retiredVerb(m[1], m[2]) {
|
||||
out[h.key(m[1], node)] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ByMemberships is every holding memberships carry: a seat verb served holds the seat on the membership's node. Pure.
|
||||
func (h Holdings) ByMemberships(ms ...membership.Membership) map[string]bool {
|
||||
out := map[string]bool{}
|
||||
for _, m := range ms {
|
||||
for _, s := range m.Seats {
|
||||
if !retiredVerb(s.Seat, s.Verb) {
|
||||
out[h.key(s.Seat, m.Node)] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// LastHolders is every holding before carries that now does not while an assigned module still claims it: the seats a
|
||||
// list or a membership would leave with no holder, sorted. Empty means none would; a seat no longer claimed, a verb
|
||||
// dropped, or a seat held elsewhere now, is no loss here. Pure.
|
||||
func (h Holdings) LastHolders(before, now map[string]bool) []string {
|
||||
var out []string
|
||||
for k := range before {
|
||||
if !now[k] && h.Claimed[k] {
|
||||
out = append(out, k)
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func askedKey(node, module, seat, kind, capability string) string {
|
||||
return node + "/" + module + "/" + canonicalSeat(seat) + "/" + kind + "/" + capability
|
||||
}
|
||||
|
||||
// CapabilitiesLost is every capability a kind's holder carries in the membership in force (its seat traffic's kinds) that
|
||||
// the composed one lacks while that holder is still assigned and its manifest's claim still asks for it, `<seat> <kind>
|
||||
// <capability> of <node>/<module>`, sorted. Such a drop comes from a word that failed for one pass (the controller's
|
||||
// root-free verb drops verified-sender), not from a release, and losing verified-sender stops the operator's approvals by
|
||||
// a channel (issue 1945; review 1ff5049's protection, kept by the lead in review round 1 of its fix). Pure.
|
||||
func (h Holdings) CapabilitiesLost(last, now membership.Membership) []string {
|
||||
if last.SeatTraffic == nil {
|
||||
return nil
|
||||
}
|
||||
has := map[string]bool{}
|
||||
if now.SeatTraffic != nil {
|
||||
for _, k := range now.SeatTraffic.Kinds {
|
||||
for _, c := range k.Capabilities {
|
||||
has[askedKey(k.Node, k.Module, k.Seat, k.Kind, c)] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
var out []string
|
||||
for _, k := range last.SeatTraffic.Kinds {
|
||||
for _, c := range k.Capabilities {
|
||||
if key := askedKey(k.Node, k.Module, k.Seat, k.Kind, c); !has[key] && h.Asked[key] {
|
||||
out = append(out, k.Seat+" "+k.Kind+" "+c+" of "+k.Node+"/"+k.Module)
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return slices.Compact(out)
|
||||
}
|
||||
|
||||
// CapabilityDifference opens what a membership's pass says when it was not published because it would drop a capability
|
||||
// its holder still asks for.
|
||||
const CapabilityDifference = ShadowDifference + "a capability its holder still asks for: "
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"git.novox.be/novox/mesh-sdk/go/membership"
|
||||
)
|
||||
|
||||
// The part's own states, as its manifest declares them, and the keys of the two that hold one value.
|
||||
@@ -109,7 +110,7 @@ type Result struct {
|
||||
}
|
||||
|
||||
// NothingTakesEffect is what every composition says it did on the bus beyond its own state and the memberships.
|
||||
const NothingTakesEffect = "memberships (on the assignments stream, a first one or one that only adds to the last published or drops only a verb the seat catalogue retired; any other loss stays a shadow difference), and each bucket, log or stream a manifest declares that the bus lacks, created once with the manifest's limits (the operator, 2026-10-26 08:54); the user list, the grants and any change to a stream the bus holds stay in shadow until take-over"
|
||||
const NothingTakesEffect = "memberships (on the assignments stream, unless one would leave a seat a placed module claims with no holder, or drop a capability its holder still asks for, which stays a shadow difference), and each bucket, log or stream a manifest declares that the bus lacks, created once with the manifest's limits (the operator, 2026-10-26 08:54); the user list, the grants and any change to a stream the bus holds stay in shadow until take-over"
|
||||
|
||||
// Part is the grants part: it reads, calls and writes through these, and nothing else.
|
||||
type Part struct {
|
||||
@@ -195,6 +196,7 @@ func (p *Part) Compose() Result {
|
||||
res.Refused = append(res.Refused, g.Input+": "+g.Why)
|
||||
}
|
||||
recs, left := Records(in)
|
||||
holdings := HoldingsOf(in)
|
||||
res.RootNotFree = WithRootFree(&recs, p.Call)
|
||||
st := UserListState{V: 1, Mode: p.SwitchState(), LeftOut: left, Loaded: in.Loaded, BusNode: in.BusNode,
|
||||
Gaps: append(append([]Gap(nil), in.Gaps...), MembershipGaps...), At: p.now()}
|
||||
@@ -236,16 +238,22 @@ func (p *Part) Compose() Result {
|
||||
res.Errors = append(res.Errors, "the user list could not be composed whole: "+err.Error())
|
||||
} else {
|
||||
// The users the bus loaded: what a first list after take-over must keep where the part has not yet written one.
|
||||
var bus []string
|
||||
var bus, lost []string
|
||||
if st.Compare.Unread == "" {
|
||||
bus = names(loaded)
|
||||
lost = holdings.LastHolders(holdings.ByUsers(loaded), holdings.ByUsers(users))
|
||||
}
|
||||
p.writeSealed(&res, list, n, leftOut, in, bus)
|
||||
p.writeSealed(&res, list, n, leftOut, in, bus, lost)
|
||||
}
|
||||
ms := Memberships(recs)
|
||||
res.Memberships = len(ms)
|
||||
composed := make([]membership.Membership, 0, len(ms))
|
||||
for _, m := range ms {
|
||||
composed = append(composed, m.Membership)
|
||||
}
|
||||
seated := holdings.ByMemberships(composed...)
|
||||
for _, key := range sortedKeys(ms) {
|
||||
m, now := publish(p.Publish, ms[key])
|
||||
m, now := publish(p.Publish, ms[key], holdings, seated)
|
||||
switch {
|
||||
case now:
|
||||
res.Published = append(res.Published, key)
|
||||
|
||||
@@ -61,7 +61,7 @@ func Records(in Inputs) (buscompose.Records, []Gap) {
|
||||
}
|
||||
d := declaredFor(m, seats, declarers)
|
||||
if len(in.Placed) > 0 {
|
||||
d.Holds = heldWherePlaced(d.Holds, in.Placed[node], m.Module)
|
||||
d.Holds = heldWherePlaced(d.Holds, in.Placed, node, m.Module)
|
||||
}
|
||||
for _, sr := range catalogue.ReadsGranted(m, here, node) {
|
||||
d.KeyedReads = append(d.KeyedReads, buscompose.KeyedRead{Read: sr.Read, Key: sr.Key})
|
||||
@@ -304,21 +304,43 @@ func hearsOf(m catalogue.Manifest, declared map[string]catalogue.Manifest, node
|
||||
return out, touches, onEvents
|
||||
}
|
||||
|
||||
// heldWherePlaced is a module's holds on a node as the catalogue's placement says them (ADR 0549 §1, §3): a seat held once
|
||||
// for the mesh is held there only when the node's placement names this module holding it, so a module claiming such a seat
|
||||
// on two machines holds it on the one placement names, not on both; a seat of every node is held wherever the module runs.
|
||||
func heldWherePlaced(holds []buscompose.Seat, p delivery.Placement, module string) []buscompose.Seat {
|
||||
// heldWherePlaced is a module's holds on a node as the catalogue's placement says them (ADR 0549 §1, §3). A placement's
|
||||
// holds are the holders on record, as catalogue.World's Holdings are: a seat held once for the mesh that some node's
|
||||
// placement holds is held only where a placement names it (this node's naming this module), so a module claiming it on
|
||||
// two machines holds it on the one placement names, not on both. A seat no placement holds is held by derivation, by the
|
||||
// module claiming it wherever it is placed, as it was before placement: the seeded placements hold only the seats the
|
||||
// controller had on record (twelve on the control-node), and treating their holds as every holder left nine seats with
|
||||
// no holder in the user list of 2026-10-27 09:21 (issue 1945). A seat of every node is held wherever the module runs.
|
||||
func heldWherePlaced(holds []buscompose.Seat, placed map[string]delivery.Placement, node, module string) []buscompose.Seat {
|
||||
same := func(x delivery.Holding, h buscompose.Seat) bool {
|
||||
return (x.Scope == "" || x.Scope == catalogue.ScopeMesh) && sameSeat(x.Claim, h.Name) && x.Kind == h.Kind
|
||||
}
|
||||
var out []buscompose.Seat
|
||||
for _, h := range holds {
|
||||
if h.Scope != "" && h.Scope != catalogue.ScopeMesh {
|
||||
out = append(out, h)
|
||||
continue
|
||||
}
|
||||
if slices.ContainsFunc(p.Holds, func(x delivery.Holding) bool {
|
||||
return x.Claim == h.Name && x.Module == module && x.Kind == h.Kind
|
||||
}) {
|
||||
onRecord := false
|
||||
for _, p := range placed {
|
||||
if slices.ContainsFunc(p.Holds, func(x delivery.Holding) bool { return same(x, h) }) {
|
||||
onRecord = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !onRecord || slices.ContainsFunc(placed[node].Holds, func(x delivery.Holding) bool { return same(x, h) && x.Module == module }) {
|
||||
out = append(out, h)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// sameSeat says two claims name one seat, under either of its names (catalogue.SeatNamed, ADR 0122).
|
||||
func sameSeat(a, b string) bool {
|
||||
if a == b {
|
||||
return true
|
||||
}
|
||||
sa, oka := catalogue.SeatNamed(a)
|
||||
sb, okb := catalogue.SeatNamed(b)
|
||||
return oka && okb && sa.Name == sb.Name
|
||||
}
|
||||
|
||||
@@ -72,7 +72,7 @@ func followOf(state string) string {
|
||||
|
||||
// writeSealed puts the list sealed to the bus node and writes the key, each only when what it says changed. It says in
|
||||
// res what it wrote, or why it wrote nothing.
|
||||
func (p *Part) writeSealed(res *Result, list []byte, users int, leftOut []string, in Inputs, bus []string) {
|
||||
func (p *Part) writeSealed(res *Result, list []byte, users int, leftOut []string, in Inputs, bus []string, lost []string) {
|
||||
cur, _, had := p.Sealed()
|
||||
next := cur
|
||||
next.Follow = followOf(p.SwitchState())
|
||||
@@ -91,10 +91,10 @@ func (p *Part) writeSealed(res *Result, list []byte, users int, leftOut []string
|
||||
why = "no node runs the bus's module, so the list is sealed to nobody"
|
||||
case key == "":
|
||||
why = "the bus node's have of the bus module names no key yet (its node-engine says it from this release)"
|
||||
case next.Follow == delivery.FollowGrants && cur.Digest != digest && p.holdsBack(in, leftOut, bus) != "":
|
||||
case next.Follow == delivery.FollowGrants && cur.Digest != digest && p.holdsBack(in, leftOut, bus, lost) != "":
|
||||
// After take-over the list in force is never replaced by one composed past a failed read, or one that drops a user
|
||||
// of it for want of a hash: that user's module would be locked out of the bus (review of the switch).
|
||||
why = p.holdsBack(in, leftOut, bus)
|
||||
why = p.holdsBack(in, leftOut, bus, lost)
|
||||
held = why
|
||||
case cur.Digest != digest || cur.SealedTo != delivery.SealingKeyID(key) || cur.Node != in.BusNode:
|
||||
object, err := delivery.SealUserList(list, key)
|
||||
@@ -297,14 +297,22 @@ func listUsers(list []byte) []string {
|
||||
return out
|
||||
}
|
||||
|
||||
// holdsBack says why a list composed now may not replace the one in force after take-over: a read failed this pass, or
|
||||
// a user of the list in force is left out for want of a hash. The list in force is the one the part wrote, by the users
|
||||
// holdsBack says why a list composed now may not replace the one in force after take-over: a read failed this pass, a
|
||||
// user of the list in force is left out for want of a hash, or the list would leave a seat the list in force holds with
|
||||
// no holder while a placed module still claims it (lost, Holdings.LastHolders: the membership's guard, issue 1945; the
|
||||
// loaded list not read holds it too, since that cannot be ruled out). The list in force is the one the part wrote, by the users
|
||||
// it kept of it; where it kept none (a list written before this release), the users the bus loaded (bus, nil when
|
||||
// they could not be read). Empty when it may.
|
||||
func (p *Part) holdsBack(in Inputs, leftOut, bus []string) string {
|
||||
func (p *Part) holdsBack(in Inputs, leftOut, bus, lost []string) string {
|
||||
if f := in.ReadFailures(); len(f) > 0 {
|
||||
return "held: the list in force is kept while a read of this pass failed (" + strings.Join(f, "; ") + ")"
|
||||
}
|
||||
if bus == nil {
|
||||
return "held: the grants of the list in force cannot be read, so a loss of seats cannot be ruled out"
|
||||
}
|
||||
if len(lost) > 0 {
|
||||
return "held, " + ShadowDifference + "the last holder of " + strings.Join(lost, ", ")
|
||||
}
|
||||
raw, _, found, err := p.Store.Get(StateUserList, KeyWrittenUsers)
|
||||
if err != nil {
|
||||
return "held: the users of the list in force cannot be read: " + err.Error()
|
||||
@@ -313,19 +321,17 @@ func (p *Part) holdsBack(in Inputs, leftOut, bus []string) string {
|
||||
switch {
|
||||
case found:
|
||||
_ = json.Unmarshal(raw, &had)
|
||||
case bus == nil:
|
||||
return "held: the users of the list in force are not known: the part kept none, and the loaded list could not be read"
|
||||
default:
|
||||
had = bus
|
||||
}
|
||||
var lost []string
|
||||
var unhashed []string
|
||||
for _, u := range leftOut {
|
||||
if slices.Contains(had, u) {
|
||||
lost = append(lost, u)
|
||||
unhashed = append(unhashed, u)
|
||||
}
|
||||
}
|
||||
if len(lost) > 0 {
|
||||
return "held: the list in force is kept, since " + strings.Join(lost, ", ") + " would be left out for want of a hash in the vault"
|
||||
if len(unhashed) > 0 {
|
||||
return "held: the list in force is kept, since " + strings.Join(unhashed, ", ") + " would be left out for want of a hash in the vault"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -368,7 +368,8 @@ func (c Comparison) Excusing(f func(user, subject string, added, subscribe bool)
|
||||
return c
|
||||
}
|
||||
|
||||
var seatSubject = regexp.MustCompile(`^mesh\.seat\.([^.]+)\.(?:call|tool)\.([^.]+)(?:\.\*)?$`)
|
||||
// seatSubject is a seat verb's subject, for any caller (`.*`) or one machine's (`.<node>`, a seat of every node).
|
||||
var seatSubject = regexp.MustCompile(`^mesh\.seat\.([^.]+)\.(?:call|tool)\.([^.]+)(?:\.[^.]+)?$`)
|
||||
var seatService = regexp.MustCompile(`^\$SRV\.(?:INFO|PING|STATS)\.([^.]+)(?:\.>)?$`)
|
||||
|
||||
// ExcusedBy is the excuse of a comparison until placement lives in the catalogue (the operator, 2026-10-26): a verb the
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
{
|
||||
"module": "claude-licence-manager",
|
||||
"version": "1",
|
||||
"slug": "licmgr",
|
||||
"requires": [
|
||||
"postgres-database",
|
||||
"secret"
|
||||
],
|
||||
"contributes": {
|
||||
"postgres-database": {
|
||||
"name": "claude_licences",
|
||||
"readable": [
|
||||
"audit",
|
||||
"binding"
|
||||
]
|
||||
}
|
||||
},
|
||||
"binds": {
|
||||
"postgres-database": "${dir:state}/database.json"
|
||||
},
|
||||
"secrets": {
|
||||
"postgres-database": "${dir:state}/database.secret",
|
||||
"secret": {
|
||||
"grant-key": "${dir:state}/grant.key"
|
||||
}
|
||||
},
|
||||
"seats": [
|
||||
{
|
||||
"name": "anthropic-licence-manager",
|
||||
"scope": "mesh",
|
||||
"serves": [
|
||||
"licences",
|
||||
"bindings",
|
||||
"bind",
|
||||
"switch",
|
||||
"release",
|
||||
"refresh",
|
||||
"usage",
|
||||
"adopt",
|
||||
"current",
|
||||
"public-key"
|
||||
]
|
||||
}
|
||||
],
|
||||
"claims": [
|
||||
{
|
||||
"name": "anthropic-licence-manager",
|
||||
"scope": "mesh",
|
||||
"serves": [
|
||||
"licences",
|
||||
"bindings",
|
||||
"bind",
|
||||
"switch",
|
||||
"release",
|
||||
"refresh",
|
||||
"usage",
|
||||
"adopt",
|
||||
"current",
|
||||
"public-key"
|
||||
]
|
||||
}
|
||||
],
|
||||
"emits": [
|
||||
"licence.adopted",
|
||||
"licence.refused",
|
||||
"licence.bound",
|
||||
"licence.switched",
|
||||
"licence.released",
|
||||
"licence.rotated",
|
||||
"licence.failing",
|
||||
"usage.read"
|
||||
],
|
||||
"state": [
|
||||
"bindings"
|
||||
],
|
||||
"reads": [
|
||||
"claude-code.holdings"
|
||||
],
|
||||
"config": {
|
||||
"cadence_minutes": {
|
||||
"type": "number",
|
||||
"description": "How old a licence's grant may grow before it is rotated, in minutes; 0 keeps 240.",
|
||||
"minimum": 0,
|
||||
"trusted": true,
|
||||
"default": 240
|
||||
},
|
||||
"floor_minutes": {
|
||||
"type": "number",
|
||||
"description": "How little time a grant may have left before it is refreshed in any case, in minutes; 0 keeps 60.",
|
||||
"minimum": 0,
|
||||
"trusted": true,
|
||||
"default": 60
|
||||
},
|
||||
"failures_to_notify": {
|
||||
"type": "number",
|
||||
"description": "How many refreshes of a licence fail in a row before the operator is told, a count; 0 keeps 3.",
|
||||
"minimum": 0,
|
||||
"trusted": true,
|
||||
"default": 3
|
||||
},
|
||||
"cooldown_hours": {
|
||||
"type": "number",
|
||||
"description": "How long after telling the operator about a licence nothing more is said about it, in hours; 0 keeps 24.",
|
||||
"minimum": 0,
|
||||
"trusted": true,
|
||||
"default": 24
|
||||
},
|
||||
"refresh_warn_days": {
|
||||
"type": "number",
|
||||
"description": "How long before a licence's refresh token itself expires the operator is warned, in days; 0 keeps 3.",
|
||||
"minimum": 0,
|
||||
"trusted": true,
|
||||
"default": 3
|
||||
}
|
||||
},
|
||||
"resources": [
|
||||
{
|
||||
"id": "state",
|
||||
"type": "directory",
|
||||
"mode": "0700",
|
||||
"place": ".",
|
||||
"unknown": "program"
|
||||
},
|
||||
{
|
||||
"id": "database-url",
|
||||
"type": "file",
|
||||
"path": "${dir:state}/database.url",
|
||||
"mode": "0600",
|
||||
"template": "files/database.url"
|
||||
},
|
||||
{
|
||||
"id": "settings",
|
||||
"type": "file",
|
||||
"path": "${dir:state}/settings.json",
|
||||
"mode": "0600",
|
||||
"takes-config": ["cadence_minutes", "floor_minutes", "failures_to_notify", "cooldown_hours", "refresh_warn_days"],
|
||||
"merge": "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"
|
||||
]
|
||||
}
|
||||
],
|
||||
"build": {
|
||||
"artifacts": [
|
||||
{
|
||||
"name": "code",
|
||||
"kind": "bundle",
|
||||
"language": "go",
|
||||
"system": "arch",
|
||||
"from": "cmd/claude-licence-manager",
|
||||
"binary": "claude-licence-manager",
|
||||
"loads": [
|
||||
"claude-licence-manager"
|
||||
],
|
||||
"env": {
|
||||
"DATABASE_URL_FILE": "${dir:state}/database.url",
|
||||
"MESH_LICENCE_STATE": "${dir:state}",
|
||||
"MESH_LICENCE_KEY_FILE": "${dir:state}/grant.key",
|
||||
"MESH_LICENCE_SETTINGS": "${dir:state}/settings.json"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "files",
|
||||
"kind": "archive",
|
||||
"from": "files"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"users": [
|
||||
{
|
||||
"user": "novox.node-tools",
|
||||
"subscribe": [
|
||||
"$SRV.INFO.anthropic-licence-manager",
|
||||
"$SRV.INFO.anthropic-licence-manager.>",
|
||||
"$SRV.PING.anthropic-licence-manager",
|
||||
"$SRV.PING.anthropic-licence-manager.>",
|
||||
"$SRV.STATS.anthropic-licence-manager",
|
||||
"$SRV.STATS.anthropic-licence-manager.>",
|
||||
"mesh.seat.anthropic-licence-manager.call.adopt.*",
|
||||
"mesh.seat.anthropic-licence-manager.call.bind.*",
|
||||
"mesh.seat.anthropic-licence-manager.call.bindings.*",
|
||||
"mesh.seat.anthropic-licence-manager.call.current.*",
|
||||
"mesh.seat.anthropic-licence-manager.call.licences.*",
|
||||
"mesh.seat.anthropic-licence-manager.call.public-key.*",
|
||||
"mesh.seat.anthropic-licence-manager.call.refresh.*",
|
||||
"mesh.seat.anthropic-licence-manager.call.release.*",
|
||||
"mesh.seat.anthropic-licence-manager.call.switch.*",
|
||||
"mesh.seat.anthropic-licence-manager.call.usage.*",
|
||||
"mesh.seat.anthropic-licence-manager.tool.adopt",
|
||||
"mesh.seat.anthropic-licence-manager.tool.bind",
|
||||
"mesh.seat.anthropic-licence-manager.tool.bindings",
|
||||
"mesh.seat.anthropic-licence-manager.tool.current",
|
||||
"mesh.seat.anthropic-licence-manager.tool.licences",
|
||||
"mesh.seat.anthropic-licence-manager.tool.public-key",
|
||||
"mesh.seat.anthropic-licence-manager.tool.refresh",
|
||||
"mesh.seat.anthropic-licence-manager.tool.release",
|
||||
"mesh.seat.anthropic-licence-manager.tool.switch",
|
||||
"mesh.seat.anthropic-licence-manager.tool.usage"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
{
|
||||
"v": 1,
|
||||
"node": "novox",
|
||||
"modules": [
|
||||
{
|
||||
"module": "artifact-store-tools"
|
||||
},
|
||||
{
|
||||
"module": "avahi"
|
||||
},
|
||||
{
|
||||
"module": "ca-trust"
|
||||
},
|
||||
{
|
||||
"module": "claude-code"
|
||||
},
|
||||
{
|
||||
"module": "claude-licence-manager"
|
||||
},
|
||||
{
|
||||
"module": "dbus"
|
||||
},
|
||||
{
|
||||
"module": "de-spiegel"
|
||||
},
|
||||
{
|
||||
"module": "desk-channel"
|
||||
},
|
||||
{
|
||||
"module": "dev-tools"
|
||||
},
|
||||
{
|
||||
"module": "disk-load"
|
||||
},
|
||||
{
|
||||
"module": "distribution"
|
||||
},
|
||||
{
|
||||
"module": "dnsmasq"
|
||||
},
|
||||
{
|
||||
"module": "docker"
|
||||
},
|
||||
{
|
||||
"module": "fail2ban"
|
||||
},
|
||||
{
|
||||
"module": "gitea"
|
||||
},
|
||||
{
|
||||
"module": "go"
|
||||
},
|
||||
{
|
||||
"module": "hostname"
|
||||
},
|
||||
{
|
||||
"module": "invoicing"
|
||||
},
|
||||
{
|
||||
"module": "kernel"
|
||||
},
|
||||
{
|
||||
"module": "keycloak"
|
||||
},
|
||||
{
|
||||
"module": "link2pay"
|
||||
},
|
||||
{
|
||||
"module": "localization"
|
||||
},
|
||||
{
|
||||
"module": "logrotate"
|
||||
},
|
||||
{
|
||||
"module": "mailu"
|
||||
},
|
||||
{
|
||||
"module": "mesh-build-go"
|
||||
},
|
||||
{
|
||||
"module": "mesh-build-node"
|
||||
},
|
||||
{
|
||||
"module": "mesh-catalog"
|
||||
},
|
||||
{
|
||||
"module": "mesh-cli"
|
||||
},
|
||||
{
|
||||
"module": "mesh-config"
|
||||
},
|
||||
{
|
||||
"module": "mesh-controller"
|
||||
},
|
||||
{
|
||||
"module": "mesh-delivery-checker"
|
||||
},
|
||||
{
|
||||
"module": "mesh-delivery-forge-bridge"
|
||||
},
|
||||
{
|
||||
"module": "mesh-delivery-gate"
|
||||
},
|
||||
{
|
||||
"module": "mesh-delivery-indexer"
|
||||
},
|
||||
{
|
||||
"module": "mesh-delivery-teller"
|
||||
},
|
||||
{
|
||||
"module": "mesh-delivery-watchman"
|
||||
},
|
||||
{
|
||||
"module": "mesh-files"
|
||||
},
|
||||
{
|
||||
"module": "mesh-grants"
|
||||
},
|
||||
{
|
||||
"module": "mesh-host"
|
||||
},
|
||||
{
|
||||
"module": "mesh-issues"
|
||||
},
|
||||
{
|
||||
"module": "mesh-knowledge"
|
||||
},
|
||||
{
|
||||
"module": "mesh-mail"
|
||||
},
|
||||
{
|
||||
"module": "mesh-mail-reader"
|
||||
},
|
||||
{
|
||||
"module": "mesh-mail-web"
|
||||
},
|
||||
{
|
||||
"module": "mesh-provisioner"
|
||||
},
|
||||
{
|
||||
"module": "mesh-provisioner-client"
|
||||
},
|
||||
{
|
||||
"module": "mesh-reads"
|
||||
},
|
||||
{
|
||||
"module": "mesh-vault"
|
||||
},
|
||||
{
|
||||
"module": "mesh-wireguard"
|
||||
},
|
||||
{
|
||||
"module": "messenger"
|
||||
},
|
||||
{
|
||||
"module": "minio"
|
||||
},
|
||||
{
|
||||
"module": "mongodb"
|
||||
},
|
||||
{
|
||||
"module": "mssql"
|
||||
},
|
||||
{
|
||||
"module": "nats"
|
||||
},
|
||||
{
|
||||
"module": "netcheck"
|
||||
},
|
||||
{
|
||||
"module": "nextcloud"
|
||||
},
|
||||
{
|
||||
"module": "nftables"
|
||||
},
|
||||
{
|
||||
"module": "node-env"
|
||||
},
|
||||
{
|
||||
"module": "node-tools"
|
||||
},
|
||||
{
|
||||
"module": "nodejs"
|
||||
},
|
||||
{
|
||||
"module": "only-office"
|
||||
},
|
||||
{
|
||||
"module": "pacman"
|
||||
},
|
||||
{
|
||||
"module": "photos"
|
||||
},
|
||||
{
|
||||
"module": "postgres"
|
||||
},
|
||||
{
|
||||
"module": "power"
|
||||
},
|
||||
{
|
||||
"module": "powerlevel10k"
|
||||
},
|
||||
{
|
||||
"module": "records"
|
||||
},
|
||||
{
|
||||
"module": "restic"
|
||||
},
|
||||
{
|
||||
"module": "route-proxy"
|
||||
},
|
||||
{
|
||||
"module": "ssh-client"
|
||||
},
|
||||
{
|
||||
"module": "sshd"
|
||||
},
|
||||
{
|
||||
"module": "step-ca"
|
||||
},
|
||||
{
|
||||
"module": "sudo"
|
||||
},
|
||||
{
|
||||
"module": "systemd"
|
||||
},
|
||||
{
|
||||
"module": "systemd-networkd"
|
||||
},
|
||||
{
|
||||
"module": "telegram"
|
||||
},
|
||||
{
|
||||
"module": "time-sync"
|
||||
},
|
||||
{
|
||||
"module": "umami"
|
||||
},
|
||||
{
|
||||
"module": "website"
|
||||
},
|
||||
{
|
||||
"module": "zsh"
|
||||
},
|
||||
{
|
||||
"module": "zsh-autosuggestions"
|
||||
},
|
||||
{
|
||||
"module": "zsh-syntax-highlighting"
|
||||
}
|
||||
],
|
||||
"holds": [
|
||||
{
|
||||
"claim": "git",
|
||||
"scope": "mesh",
|
||||
"module": "gitea",
|
||||
"site": "hosting"
|
||||
},
|
||||
{
|
||||
"claim": "mesh-artifact-store",
|
||||
"scope": "mesh",
|
||||
"module": "distribution",
|
||||
"site": "hosting"
|
||||
},
|
||||
{
|
||||
"claim": "mesh-broker",
|
||||
"scope": "mesh",
|
||||
"module": "nats",
|
||||
"site": "hosting"
|
||||
},
|
||||
{
|
||||
"claim": "mesh-catalog",
|
||||
"scope": "mesh",
|
||||
"module": "mesh-catalog",
|
||||
"site": "hosting"
|
||||
},
|
||||
{
|
||||
"claim": "mesh-config",
|
||||
"scope": "mesh",
|
||||
"module": "mesh-config",
|
||||
"site": "hosting"
|
||||
},
|
||||
{
|
||||
"claim": "mesh-controller",
|
||||
"scope": "mesh",
|
||||
"module": "mesh-controller",
|
||||
"site": "hosting"
|
||||
},
|
||||
{
|
||||
"claim": "mesh-delivery",
|
||||
"scope": "mesh",
|
||||
"module": "mesh-delivery-gate",
|
||||
"site": "hosting"
|
||||
},
|
||||
{
|
||||
"claim": "mesh-dns-resolver",
|
||||
"scope": "mesh",
|
||||
"module": "dnsmasq",
|
||||
"site": "hosting"
|
||||
},
|
||||
{
|
||||
"claim": "mesh-provisioner",
|
||||
"scope": "mesh",
|
||||
"module": "mesh-provisioner",
|
||||
"site": "hosting"
|
||||
},
|
||||
{
|
||||
"claim": "mesh-store",
|
||||
"scope": "mesh",
|
||||
"module": "postgres",
|
||||
"site": "hosting"
|
||||
},
|
||||
{
|
||||
"claim": "mesh-vault",
|
||||
"scope": "mesh",
|
||||
"module": "mesh-vault",
|
||||
"site": "hosting"
|
||||
},
|
||||
{
|
||||
"claim": "npm-package-registry",
|
||||
"scope": "mesh",
|
||||
"module": "gitea",
|
||||
"site": "hosting"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user