Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
74ab66ca81 | ||
|
|
7f8ee816a9 | ||
|
|
ac7bf01abf | ||
|
|
fd8aba59c3 | ||
|
|
cb8cab8719 | ||
|
|
92b6dccbec | ||
|
|
7a86c62492 | ||
|
|
afed658c69 | ||
|
|
3f60eb95a0 | ||
|
|
23507fb3ef | ||
|
|
cd0ad47ad6 | ||
|
|
d5737f2c6d | ||
|
|
04276bfda1 |
@@ -170,6 +170,21 @@ func TryTools(tr Trying) []stdio.Tool {
|
||||
}
|
||||
}
|
||||
|
||||
// Seat is the seat the checker holds (novox/hq ADR 0547 §10, issue 774); its verbs are served as tools named <seat>.<verb>.
|
||||
const Seat = "mesh-delivery-checker"
|
||||
|
||||
// SeatTryTools are the try's tools as the seat's verbs: try, try-read and try-end, run by the same code as checker_try,
|
||||
// checker_try_read and checker_try_end, so the answer is the same. The module tools stay beside them.
|
||||
func SeatTryTools(tr Trying) []stdio.Tool {
|
||||
verbs := map[string]string{check.TryTool: "try", check.TryReadTool: "try-read", check.TryEndTool: "try-end"}
|
||||
var out []stdio.Tool
|
||||
for _, t := range TryTools(tr) {
|
||||
t.Name = Seat + "." + verbs[t.Name]
|
||||
out = append(out, t)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Tools are the read tools over the checker's states.
|
||||
func Tools(r check.Reader) []stdio.Tool {
|
||||
return []stdio.Tool{
|
||||
@@ -277,7 +292,7 @@ func main() {
|
||||
return err
|
||||
}}
|
||||
trying := Trying{Tries: store, Ender: store, Trunks: trunks}
|
||||
if err := stdio.Serve("", logged(append(Tools(r), TryTools(trying)...))); err != nil {
|
||||
if err := stdio.Serve("", logged(append(append(Tools(r), TryTools(trying)...), SeatTryTools(trying)...))); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "[mesh-delivery-checker-tools] %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
@@ -80,6 +80,24 @@ func TestTheTryToolsAskAndReadATry(t *testing.T) { // ADR 0501 §5, the agreed i
|
||||
if err != nil || !strings.Contains(string(raw.(json.RawMessage)), `"state":"queued"`) {
|
||||
t.Errorf("read = %s, %v", raw, err)
|
||||
}
|
||||
// The seat's verbs answer as the tools do (ADR 0547 §10, issue 774): the same ask is the same try, and the read is alike.
|
||||
verbs := SeatTryTools(Trying{Tries: store, Ender: store, Now: func() time.Time { return time.Unix(1, 5) },
|
||||
Trunks: func() (map[string]string, error) {
|
||||
return map[string]string{"novox/mesh-delivery": strings.Repeat("a", 40)}, nil
|
||||
}})
|
||||
if len(verbs) != 3 || verbs[0].Name != Seat+".try" || verbs[1].Name != Seat+".try-read" || verbs[2].Name != Seat+".try-end" {
|
||||
t.Fatalf("verbs = %+v", verbs)
|
||||
}
|
||||
again, err := verbs[0].Run(map[string]any{"repository": "novox/mesh-delivery", "head": strings.Repeat("1", 40), "trunk": "main"})
|
||||
if err != nil || again.(map[string]any)["id"] != id {
|
||||
t.Errorf("verb try = %v, %v, want the waiting try %s", again, err, id)
|
||||
}
|
||||
if viaVerb, err := verbs[1].Run(map[string]any{"id": id}); err != nil || string(viaVerb.(json.RawMessage)) != string(raw.(json.RawMessage)) {
|
||||
t.Errorf("verb try-read = %s, %v; tool = %s", viaVerb, err, raw)
|
||||
}
|
||||
if _, err := verbs[0].Run(map[string]any{"repository": "novox/hq", "head": strings.Repeat("1", 40)}); err == nil {
|
||||
t.Error("the verb tried a repository nothing checks")
|
||||
}
|
||||
// Issue 734: a try whose ask was lost is ended by hand with why, and its head is asked anew.
|
||||
ended, err := tools[2].Run(map[string]any{"id": id, "why": "its ask was lost"})
|
||||
if err != nil || ended.(map[string]any)["state"] != "error" {
|
||||
|
||||
@@ -3,7 +3,7 @@ module mesh-delivery-checker
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261027193820-8ad9372b1952
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261027220144-3f7957e16972
|
||||
github.com/nats-io/nats-server/v2 v2.11.17
|
||||
github.com/nats-io/nats.go v1.54.0
|
||||
)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261027193820-8ad9372b1952 h1:TmveT3Bija643RGWkilvkzP/h8UWlVvHFZZtQfJK5oU=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261027193820-8ad9372b1952/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261027220144-3f7957e16972 h1:iYDYv4HpV1ic20Ban0MaOErryQfNT8ngjlh0bKW6sCU=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261027220144-3f7957e16972/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
|
||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
|
||||
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
|
||||
|
||||
@@ -38,6 +38,11 @@ type stream struct {
|
||||
}
|
||||
|
||||
type manifest struct {
|
||||
Claims []struct {
|
||||
Name string `json:"name"`
|
||||
Scope string `json:"scope"`
|
||||
Serves []string `json:"serves"`
|
||||
} `json:"claims"`
|
||||
Streams []stream `json:"streams"`
|
||||
Tools []string `json:"tools"`
|
||||
Module string `json:"module"`
|
||||
@@ -128,7 +133,12 @@ func TestManifestDeclarations(t *testing.T) { // 1678/AC1
|
||||
if want := []string{"checker_kept", "checker_previews", "checker_pull", "checker_status", "checker_try", "checker_try_end", "checker_try_read", "checker_verdicts"}; !reflect.DeepEqual(sorted(m.Tools), want) {
|
||||
t.Errorf("tools = %v, want %v", m.Tools, want)
|
||||
}
|
||||
for _, k := range []string{"logs", "emits", "claims", "provides", "requires", "defines-seats", "seat", "seats", "bus", "config"} {
|
||||
// The try is the checker's own seat's verbs (ADR 0547 §10, issue 774), served beside the tools by the same code.
|
||||
if len(m.Claims) != 1 || m.Claims[0].Name != "mesh-delivery-checker" || m.Claims[0].Scope != "mesh" ||
|
||||
!reflect.DeepEqual(m.Claims[0].Serves, []string{"try", "try-read", "try-end"}) {
|
||||
t.Errorf("claims = %+v, want the mesh seat mesh-delivery-checker serving try, try-read and try-end", m.Claims)
|
||||
}
|
||||
for _, k := range []string{"logs", "emits", "provides", "requires", "defines-seats", "seat", "seats", "bus", "config"} {
|
||||
if _, ok := top[k]; ok {
|
||||
t.Errorf("the manifest declares %q: the checker has none", k)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,17 @@
|
||||
"module": "mesh-delivery-checker",
|
||||
"version": "1",
|
||||
"slug": "checker",
|
||||
"claims": [
|
||||
{
|
||||
"name": "mesh-delivery-checker",
|
||||
"scope": "mesh",
|
||||
"serves": [
|
||||
"try",
|
||||
"try-read",
|
||||
"try-end"
|
||||
]
|
||||
}
|
||||
],
|
||||
"consumes": [
|
||||
"mesh-build-go.toolchain.reported",
|
||||
"mesh-build-node.toolchain.reported"
|
||||
|
||||
@@ -214,6 +214,10 @@ func (s ownStore) Put(state, key string, value []byte) (uint64, error) {
|
||||
return s.c.State(state).Put(key, json.RawMessage(value))
|
||||
}
|
||||
|
||||
func (s ownStore) Keys(state string) ([]string, error) { return s.c.State(state).Keys() }
|
||||
|
||||
func (s ownStore) Delete(state, key string) error { return s.c.State(state).Delete(key) }
|
||||
|
||||
// emitting is the process connection as the part's emitter: its events, with no options.
|
||||
type emitting struct{ c *process.Conn }
|
||||
|
||||
|
||||
@@ -18,6 +18,41 @@ type creating struct {
|
||||
js jetstream.JetStream
|
||||
}
|
||||
|
||||
// CreateConsumer creates one durable consumer an account needs, by its stream and name (ADR 0561 §6): the create alone;
|
||||
// one the bus holds answers ErrHeld and is left as it is.
|
||||
func (c creating) CreateConsumer(stream string, cfg jetstream.ConsumerConfig) error {
|
||||
ctx, cancel := context.WithTimeout(c.ctx, requestWithin)
|
||||
defer cancel()
|
||||
if _, err := c.js.Consumer(ctx, stream, cfg.Durable); err == nil {
|
||||
return grants.ErrHeld
|
||||
}
|
||||
_, err := c.js.CreateConsumer(ctx, stream, cfg)
|
||||
switch {
|
||||
case err == nil:
|
||||
return nil
|
||||
case errors.Is(err, jetstream.ErrConsumerExists):
|
||||
return grants.ErrHeld
|
||||
case errors.Is(err, context.DeadlineExceeded):
|
||||
return fmt.Errorf("no answer in %s: a create the bus refuses is answered by none (%w)", requestWithin, err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// MatchConsumer brings a held consumer to cfg on a person's ask (grants_consumer_match), keeping where it delivers from:
|
||||
// the server refuses a changed deliver policy, and a consumer moved would replay or lose what it holds.
|
||||
func (c creating) MatchConsumer(stream string, cfg jetstream.ConsumerConfig) error {
|
||||
ctx, cancel := context.WithTimeout(c.ctx, requestWithin)
|
||||
defer cancel()
|
||||
held, err := c.js.Consumer(ctx, stream, cfg.Durable)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
have := held.CachedInfo().Config
|
||||
cfg.DeliverPolicy, cfg.OptStartSeq, cfg.OptStartTime = have.DeliverPolicy, have.OptStartSeq, have.OptStartTime
|
||||
_, err = c.js.UpdateConsumer(ctx, stream, cfg)
|
||||
return err
|
||||
}
|
||||
|
||||
func (c creating) CreateDeclared(cfg jetstream.StreamConfig) error {
|
||||
ctx, cancel := context.WithTimeout(c.ctx, requestWithin)
|
||||
defer cancel()
|
||||
|
||||
@@ -62,14 +62,9 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
// keptStates is what the part keeps, each readable (ADR 0495 §2-§3): the view and a session read them.
|
||||
func keptStates() []announce.State {
|
||||
var out []announce.State
|
||||
for _, s := range grants.States {
|
||||
out = append(out, announce.State{Name: s, Readable: true})
|
||||
}
|
||||
return out
|
||||
}
|
||||
// keptStates is what the part keeps (ADR 0495 §2-§3): each readable, for the view and a session, but the wanted accounts,
|
||||
// by the vault alone (ADR 0561 §2).
|
||||
func keptStates() []announce.State { return grants.Announced() }
|
||||
|
||||
// run is the process: it returns when ctx ends, or when it has no credential or cannot dial the bus at all. A bucket or
|
||||
// read the bus refuses never ends it.
|
||||
@@ -117,8 +112,9 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
|
||||
}
|
||||
bus := newBusReader(ctx, js)
|
||||
f.Part = &grants.Part{Read: bus, Call: conn, Store: ownStore{conn}, Emit: emitting{conn},
|
||||
Publish: memberships{ctx: ctx, nc: nc, js: js}, Create: creating{ctx: ctx, js: js},
|
||||
Objects: sealedObjects{ctx: ctx, js: js}, Logf: logger.Printf}
|
||||
Publish: memberships{ctx: ctx, nc: nc, js: js}, Create: creating{ctx: ctx, js: js}, Consumers: creating{ctx: ctx, js: js},
|
||||
Objects: sealedObjects{ctx: ctx, js: js}, Logf: logger.Printf,
|
||||
Bus: grants.BusAddress{URL: account.URL, Fingerprint: account.Fingerprint}}
|
||||
f.Watch = bus
|
||||
// The operator's word on take-over and hand-back is their yes on their channel: the part asks it itself, and acts on
|
||||
// the warrant once (mesh-sdk asker), after its refusals.
|
||||
@@ -138,8 +134,9 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
|
||||
})
|
||||
}
|
||||
// The controller makes this module's durable consumer on EVENTS from the manifest's consumes (filtered on the two
|
||||
// condition subjects); it is pulled and every message acknowledged, so its pending never grows. Where it is not there
|
||||
// yet the pass says so and hears again at the next change.
|
||||
// condition subjects and the vault's account-issued; ADR 0561 §6 gives that to the part from the lease write); it is
|
||||
// pulled and every message acknowledged, so its pending never grows. Where it is not there yet the pass says so and
|
||||
// hears again at the next change. The vault's bus-accounts is watched beside it, so a hash is composed either way.
|
||||
f.Hear = func(handle func(string, []byte)) error {
|
||||
cons, err := js.Consumer(ctx, "EVENTS", account.Node+"_"+account.Module)
|
||||
if err != nil {
|
||||
|
||||
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
|
||||
@@ -39,6 +40,10 @@ func (runtimeStore) Put(state, key string, value []byte) (uint64, error) {
|
||||
return stdio.State(state).Put(key, json.RawMessage(value))
|
||||
}
|
||||
|
||||
func (runtimeStore) Keys(state string) ([]string, error) { return stdio.State(state).Keys() }
|
||||
|
||||
func (runtimeStore) Delete(state, key string) error { return stdio.State(state).Delete(key) }
|
||||
|
||||
// runtimeBus calls tools and says events through the runtime.
|
||||
type runtimeBus struct{}
|
||||
|
||||
@@ -113,6 +118,43 @@ func toolsOf(p *grants.Part) []stdio.Tool {
|
||||
Run: func(a map[string]any) (any, error) {
|
||||
return p.HandBack(stdio.CallerOf(a), grants.Answer(a, "why"))
|
||||
}},
|
||||
{Name: grants.ToolAccounts, Description: "The bus accounts the mesh wants (novox/hq ADR 0561 §2, §9): every account the vault " +
|
||||
"mints that placement composes (a module's, a process's, a machine's provisioner client's), as the grants part last " +
|
||||
"wrote it to its state accounts, which the vault alone reads: user, kind, node, module, process, the bus's address and " +
|
||||
"fingerprint, a module's claims with the verbs each seat promises, and when the key was written; one user's or all; " +
|
||||
"what the last pass wrote, deleted or kept; and the durable consumers the accounts need as the last pass found them " +
|
||||
"(missing, differing and why, created; whether the lease is handed away). Never a password. Read-only: " +
|
||||
"grants_compose_now writes them again.",
|
||||
Input: schema(map[string]string{"user": "one bus user, e.g. ace.mesh-build-go"}),
|
||||
Run: func(a map[string]any) (any, error) { return p.Accounts(grants.Answer(a, "user")) }},
|
||||
{Name: grants.ToolConsumerMatch, Description: "Bring one durable consumer an account needs (a module's on EVENTS, a process's on " +
|
||||
"its work queue; novox/hq ADR 0561 §6) to the configuration the bus composer composes, by hand: the recovery when " +
|
||||
"the condition mesh-grants.consumer-differs names it. Asks the part's process, which alone may, and keeps where the " +
|
||||
"consumer delivers from. Refused while the controller holds its lease (the consumers are the controller's then) and " +
|
||||
"for a consumer the records do not compose. grants_user_list's last pass says what became of it.",
|
||||
Input: schema(map[string]string{"stream": "the stream, e.g. EVENTS", "consumer": "the consumer, e.g. ace_mesh-build-go"}, "stream", "consumer"),
|
||||
Run: func(a map[string]any) (any, error) {
|
||||
return p.MatchConsumer(grants.Answer(a, "stream"), grants.Answer(a, "consumer"))
|
||||
}},
|
||||
{Name: grants.ToolAcceptHashes, Description: "Release the hold of a user list that changes several loaded users' hashes at " +
|
||||
"once (novox/hq ADR 0561 §7; the condition mesh-grants.user-list-held names them), by a person's act with why: names " +
|
||||
"exactly the users held, bound to the held list's digest, so the part's process writes that list once at its next pass " +
|
||||
"and says hashes-accepted. It never releases another hold (a failed read, a user dropped for want of a hash, a lost " +
|
||||
"right), and a list composed otherwise since is held again for a new acceptance.",
|
||||
Input: map[string]any{"type": "object", "properties": map[string]any{
|
||||
"users": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "description": "the users held, as the condition names them"},
|
||||
"why": map[string]any{"type": "string", "description": "why their hashes change"}}, "required": []string{"users", "why"}},
|
||||
Run: func(a map[string]any) (any, error) {
|
||||
var users []string
|
||||
if list, ok := a["users"].([]any); ok {
|
||||
for _, u := range list {
|
||||
if s, ok := u.(string); ok {
|
||||
users = append(users, strings.TrimSpace(s))
|
||||
}
|
||||
}
|
||||
}
|
||||
return p.AcceptHashes(stdio.CallerOf(a), users, grants.Answer(a, "why"))
|
||||
}},
|
||||
{Name: grants.ToolTakeOverState, Description: "Where the switch stands: the list the bus node follows, the ask open now, the request, and " +
|
||||
"every act with what became of it (asked, switched, declined, expired, refused and why).",
|
||||
Input: schema(nil),
|
||||
|
||||
@@ -3,7 +3,7 @@ module mesh-grants
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261027201213-7a8e9f970648
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261027220144-3f7957e16972
|
||||
github.com/nats-io/nats.go v1.54.0
|
||||
)
|
||||
|
||||
|
||||
@@ -1,15 +1,5 @@
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026210326-979644420117 h1:88CNZOoQgu7YPYKW16qkWT8Gw9Ucg07aTrIImA2nYUk=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026210326-979644420117/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261027012704-062c29c3a22f h1:AKzV973UTWe0OYFoIjgGu0f+B26xh8QsAHzCpsTIjM8=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261027012704-062c29c3a22f/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261027014310-cadd7b1b2f8f h1:mimAGERguQmCGYVNdRTrFcZ1dhpU6GWo+Q3EDhHOrVk=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261027014310-cadd7b1b2f8f/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261027195758-cebc6519d5b0 h1:9B7B38e3RtjbtX1uGP9dRgU9VY1/J2yAfnb5Ajoq4/s=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261027195758-cebc6519d5b0/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261027200317-538ffbaa085b h1:LuBK5dtBgm978krhj41N2MKA07Yv4zetRPOdHRTB6L8=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261027200317-538ffbaa085b/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261027201213-7a8e9f970648 h1:XoMsgg9kSgjQWa86NZM8h8OpxJ2kovxcMS2C0i6WSVU=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261027201213-7a8e9f970648/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261027220144-3f7957e16972 h1:iYDYv4HpV1ic20Ban0MaOErryQfNT8ngjlh0bKW6sCU=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261027220144-3f7957e16972/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
|
||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
|
||||
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"maps"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
@@ -57,6 +58,45 @@ type fakeBus struct {
|
||||
refuseUsers bool
|
||||
// rootFree answers the controller's root-free verb: nil is no answerer at all (no responders).
|
||||
rootFree func(machines []string) (any, error)
|
||||
// consumers are the durable consumers the bus holds, by stream, as nats_streams answers them for one stream; and
|
||||
// consumerCreates each consumer the part asked to create, `<stream>.<name>`.
|
||||
consumers map[string][]map[string]any
|
||||
consumerCreates []string
|
||||
}
|
||||
|
||||
// MatchConsumer is the bus's update of a held durable consumer to the configuration given.
|
||||
func (b *fakeBus) MatchConsumer(stream string, cfg jetstream.ConsumerConfig) error {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
b.consumerCreates = append(b.consumerCreates, "match "+stream+"."+cfg.Durable)
|
||||
for _, c := range b.consumers[stream] {
|
||||
if c["name"] == cfg.Durable {
|
||||
c["max_deliver"] = cfg.MaxDeliver
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return errors.New("consumer not found")
|
||||
}
|
||||
|
||||
// CreateConsumer is the bus's create of a durable consumer: held when the bus holds one of that name, else held from now.
|
||||
func (b *fakeBus) CreateConsumer(stream string, cfg jetstream.ConsumerConfig) error {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
b.consumerCreates = append(b.consumerCreates, stream+"."+cfg.Durable)
|
||||
for _, c := range b.consumers[stream] {
|
||||
if c["name"] == cfg.Durable {
|
||||
return grants.ErrHeld
|
||||
}
|
||||
}
|
||||
filters := append([]string{}, cfg.FilterSubjects...)
|
||||
if cfg.FilterSubject != "" {
|
||||
filters = []string{cfg.FilterSubject}
|
||||
}
|
||||
if b.consumers == nil {
|
||||
b.consumers = map[string][]map[string]any{}
|
||||
}
|
||||
b.consumers[stream] = append(b.consumers[stream], map[string]any{"name": cfg.Durable, "filters": filters, "max_deliver": cfg.MaxDeliver})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *fakeBus) LastMembership(node, module string) ([]byte, uint64, bool, error) {
|
||||
@@ -177,6 +217,9 @@ func (b *fakeBus) Call(key string, body any) (json.RawMessage, error) {
|
||||
}
|
||||
return json.Marshal(map[string]any{"users": out})
|
||||
case grants.CallStreams:
|
||||
if stream, _ := args["stream"].(string); stream != "" {
|
||||
return json.Marshal(map[string]any{"detail": []map[string]any{{"name": stream, "consumer_detail": b.consumers[stream]}}})
|
||||
}
|
||||
return json.Marshal(map[string]any{"detail": b.streams})
|
||||
case grants.CallRootFree:
|
||||
if b.rootFree == nil {
|
||||
@@ -217,6 +260,27 @@ func (s ownStore) Put(state, key string, value []byte) (uint64, error) {
|
||||
return b.revs[state+"/"+key], nil
|
||||
}
|
||||
|
||||
func (s ownStore) Keys(state string) ([]string, error) {
|
||||
b := s.b
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
var out []string
|
||||
for k := range b.own[state] {
|
||||
out = append(out, k)
|
||||
}
|
||||
slices.Sort(out)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s ownStore) Delete(state, key string) error {
|
||||
b := s.b
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
b.writes = append(b.writes, state+" delete")
|
||||
delete(b.own[state], key)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *fakeBus) Emit(event string, _ any) error {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
@@ -1808,6 +1872,269 @@ func TestGrants(t *testing.T) {
|
||||
t.Errorf("the refusal was not said: %v", logged)
|
||||
}
|
||||
})
|
||||
|
||||
// 774/AC4 (novox/hq ADR 0561 §2): every account the vault mints that the records compose is written to the state
|
||||
// accounts once, with the bus's address and a module's claims, read by the vault alone; a key is deleted when placement
|
||||
// no longer names its user, and never in a pass whose reads failed.
|
||||
t.Run("wanted accounts: written once each, read by the vault alone, retired only when placement drops them", func(t *testing.T) {
|
||||
b := fixture()
|
||||
b.loaded = shadowOf(t, b)
|
||||
p := partOn(b)
|
||||
p.Bus = grants.BusAddress{URL: "nats://a.mesh:4222", Fingerprint: "ab"}
|
||||
p.Compose()
|
||||
p.Compose()
|
||||
wrote := 0
|
||||
for _, w := range b.writes {
|
||||
if w == grants.StateAccounts {
|
||||
wrote++
|
||||
}
|
||||
}
|
||||
held := slices.Sorted(maps.Keys(b.own[grants.StateAccounts]))
|
||||
if !slices.Equal(held, []string{"a.mod-x", "a.nats", "b.mod-y"}) || wrote != 3 {
|
||||
t.Fatalf("accounts %v written %d times in two passes, want the three modules' once each", held, wrote)
|
||||
}
|
||||
nats, err := buscompose.ReadWantedAccount("a.nats", b.own[grants.StateAccounts]["a.nats"])
|
||||
if err != nil || nats.URL != "nats://a.mesh:4222" || nats.Fingerprint != "ab" || len(nats.Claims) != 1 ||
|
||||
nats.Claims[0].Seat != catalogue.BrokerSeat || nats.At.IsZero() {
|
||||
t.Errorf("a.nats is wanted as %+v (%v)", nats, err)
|
||||
}
|
||||
var readers []string
|
||||
for _, s := range grants.Announced() {
|
||||
if s.Name == grants.StateAccounts {
|
||||
if s.Readable {
|
||||
t.Error("the wanted accounts are announced readable by anyone")
|
||||
}
|
||||
readers = s.Readers
|
||||
}
|
||||
}
|
||||
if !slices.Equal(readers, []string{"mesh-vault"}) || buscompose.ReaderMay("mesh-delivery-view", buscompose.GrantsAccounts) {
|
||||
t.Errorf("the wanted accounts are read by %v", readers)
|
||||
}
|
||||
// A read that fails retires nothing; once read, the user placement dropped is retired.
|
||||
delete(b.states[grants.ReadAssignments], "b.mod-y")
|
||||
b.refused[grants.ReadResolved] = true
|
||||
if res := p.Compose(); res.Accounts == nil || len(res.Accounts.Deleted) != 0 || b.own[grants.StateAccounts]["b.mod-y"] == nil {
|
||||
t.Fatalf("a pass whose read failed deleted %+v", res.Accounts)
|
||||
}
|
||||
delete(b.refused, grants.ReadResolved)
|
||||
if res := p.Compose(); res.Accounts == nil || !slices.Equal(res.Accounts.Deleted, []string{"b.mod-y"}) {
|
||||
t.Fatalf("the user placement dropped is not retired: %+v", res.Accounts)
|
||||
}
|
||||
// The tool reads them, one and all, each with when it was written.
|
||||
one, err := p.Accounts("a.nats")
|
||||
all, err2 := p.Accounts("")
|
||||
if err != nil || err2 != nil || one.(map[string]any)["count"] != 1 || all.(map[string]any)["count"] != 2 ||
|
||||
one.(map[string]any)["accounts"].([]buscompose.WantedAccount)[0].At.IsZero() {
|
||||
t.Errorf("the tool reads %v and %v (%v %v)", one, all, err, err2)
|
||||
}
|
||||
if _, err := p.Accounts("b.mod-y"); err == nil {
|
||||
t.Error("the tool reads an account no longer wanted")
|
||||
}
|
||||
p.Pass()
|
||||
if all, _ := p.Accounts(""); all.(map[string]any)["consumers"] == nil {
|
||||
t.Errorf("the tool does not say the account consumers in shadow: %v", all)
|
||||
}
|
||||
// With no bus address no account is written, and the pass says why.
|
||||
bare := partOn(fixture())
|
||||
if res := bare.Compose(); res.Accounts != nil || !strings.Contains(strings.Join(res.Errors, " "), "no bus address") {
|
||||
t.Errorf("a part with no bus address wrote %+v: %v", res.Accounts, res.Errors)
|
||||
}
|
||||
})
|
||||
|
||||
// 774/AC5 (novox/hq ADR 0561 §1): the vault's account-issued wakes a pass, so the next list carries the new user's hash
|
||||
// within one pass; an event of another kind does not.
|
||||
t.Run("the vault's account-issued composes the list again", func(t *testing.T) {
|
||||
b := fixture()
|
||||
var handle func(string, []byte)
|
||||
p := partOn(b)
|
||||
f := &grants.Follower{Part: p, Watch: &fakeWatcher{opened: make(chan string, 16)}, Logf: func(string, ...any) {},
|
||||
Hear: func(h func(string, []byte)) error { handle = h; return nil }}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan struct{})
|
||||
go func() { f.Run(ctx); close(done) }()
|
||||
defer func() { cancel(); <-done }()
|
||||
waitFor(t, func() bool { return b.count(grants.CallUsers) > 0 && handle != nil })
|
||||
held := func() bool {
|
||||
for _, c := range p.Last().UserList.Credentials.Users {
|
||||
if c.User == "a.mod-x" {
|
||||
return c.HashHeld
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
if held() {
|
||||
t.Fatal("a.mod-x has a hash before the vault made one")
|
||||
}
|
||||
b.mu.Lock()
|
||||
b.states[grants.ReadAccounts] = map[string][]byte{"a.mod-x": []byte(`{"user":"a.mod-x","hash":"$2a$10$x","generation":1}`)}
|
||||
b.mu.Unlock()
|
||||
handle(buscompose.AccountIssuedSubject, []byte(`{"user":"a.mod-x","kind":"module","node":"a","module":"mod-x","generation":1}`))
|
||||
waitFor(t, held)
|
||||
if !slices.Contains(grants.Consumes, grants.EventAccountIssued) || !grants.IsAccountIssued(grants.EventAccountIssued) ||
|
||||
grants.IsAccountIssued("mesh.mod.mesh-vault.event.secret-changed") {
|
||||
t.Errorf("the part hears %v", grants.Consumes)
|
||||
}
|
||||
})
|
||||
|
||||
// 774/AC6 (novox/hq ADR 0561 §6): the durable consumers an account needs, with the controller's configuration, are
|
||||
// compared in shadow while the controller holds the lease, and created, only where the bus lacks them, from the write
|
||||
// that hands the lease away; one the bus holds is never changed.
|
||||
t.Run("account consumers: compared while the controller holds the lease, created once it is handed away", func(t *testing.T) {
|
||||
const cManifest = `{"module":"mod-c","version":"1","own-secrets":{"broker":"/var/lib/c/broker"},"consumes":["mod-y.done"]}`
|
||||
b := fixture()
|
||||
b.manifestOf("mod-c", cManifest)
|
||||
b.assign("b", "mod-c")
|
||||
b.loaded = shadowOf(t, b)
|
||||
b.put(grants.ReadLease, grants.LeaseKey, map[string]any{"instance": "novox/1/2026-10-27", "epoch": 57})
|
||||
p := partOn(b)
|
||||
p.Consumers = b
|
||||
res := p.Compose()
|
||||
if res.Consumers == nil || res.Consumers.On || !slices.Contains(res.Consumers.Missing, "EVENTS.b_mod-c") || len(b.consumerCreates) != 0 {
|
||||
t.Fatalf("while the controller holds the lease: %+v, created %v", res.Consumers, b.consumerCreates)
|
||||
}
|
||||
b.put(grants.ReadLease, grants.LeaseKey, map[string]any{"handed-to": "the operator's hand-over"})
|
||||
b.consumers = map[string][]map[string]any{"EVENTS": {{"name": "b_other", "filters": []string{"x"}, "max_deliver": 5}}}
|
||||
res = p.Compose()
|
||||
if !res.Consumers.On || !slices.Equal(res.Consumers.Created, []string{"EVENTS.b_mod-c"}) {
|
||||
t.Fatalf("once handed away: %+v", res.Consumers)
|
||||
}
|
||||
var made map[string]any
|
||||
for _, c := range b.consumers["EVENTS"] {
|
||||
if c["name"] == "b_mod-c" {
|
||||
made = c
|
||||
}
|
||||
}
|
||||
if made == nil || made["max_deliver"] != 5 || !slices.Contains(made["filters"].([]string), "mesh.mod.mod-y.event.done") {
|
||||
t.Errorf("b_mod-c was made as %v, not as the controller makes it", made)
|
||||
}
|
||||
res = p.Compose()
|
||||
if len(res.Consumers.Created) != 0 || len(res.Consumers.Missing) != 0 || len(b.consumerCreates) != 1 {
|
||||
t.Errorf("a consumer the bus holds was asked again: %+v, %v", res.Consumers, b.consumerCreates)
|
||||
}
|
||||
// A held consumer that differs is never changed by a pass: it is raised as a condition naming it, and brought to
|
||||
// the composed configuration only by hand (grants_consumer_match), never while the controller holds the lease.
|
||||
var said []asks.Condition
|
||||
p.Say = func(c asks.Condition) error { said = append(said, c); return nil }
|
||||
made["max_deliver"] = 9
|
||||
res = p.Compose()
|
||||
if len(res.Consumers.Differ) != 1 || !strings.Contains(res.Consumers.Differ[0], "EVENTS.b_mod-c: max deliver 9") ||
|
||||
len(said) != 1 || said[0].Key != grants.ConsumerDiffersCondition || said[0].State != asks.StateRaised ||
|
||||
!strings.Contains(said[0].Explanation, "EVENTS.b_mod-c") {
|
||||
t.Fatalf("a differing consumer: %+v, said %+v", res.Consumers, said)
|
||||
}
|
||||
b.put(grants.ReadLease, grants.LeaseKey, map[string]any{"instance": "novox/2/2026-10-27", "epoch": 58})
|
||||
if _, err := p.MatchConsumer("EVENTS", "b_mod-c"); err == nil || !strings.Contains(err.Error(), "controller holds the lease") {
|
||||
t.Errorf("matched by hand under the controller's lease: %v", err)
|
||||
}
|
||||
b.put(grants.ReadLease, grants.LeaseKey, map[string]any{"handed-to": "the operator's hand-over"})
|
||||
if _, err := p.MatchConsumer("EVENTS", "b_nothing"); err == nil {
|
||||
t.Error("a consumer the records do not compose was asked to match")
|
||||
}
|
||||
if _, err := p.MatchConsumer("EVENTS", "b_mod-c"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
res = p.Compose()
|
||||
if !slices.Contains(b.consumerCreates, "match EVENTS.b_mod-c") || made["max_deliver"] != 5 || len(res.Consumers.Matched) != 1 {
|
||||
t.Errorf("the ask to match: %+v, %v", res.Consumers, b.consumerCreates)
|
||||
}
|
||||
res = p.Compose()
|
||||
if len(res.Consumers.Differ) != 0 || said[len(said)-1].State != asks.StateCleared {
|
||||
t.Errorf("once matched: %+v, said %+v", res.Consumers, said)
|
||||
}
|
||||
})
|
||||
|
||||
// 774/AC13 (novox/hq ADR 0561 §7): after take-over a list in which more than one loaded user's hash changes, with no
|
||||
// rotation by the vault naming each, is held: the list in force stays, user-list-held is raised naming those users. One
|
||||
// user's change, or changes the vault's rotation names, are written.
|
||||
t.Run("a list changing several loaded users' hashes is held unless the vault's rotation names each", func(t *testing.T) {
|
||||
b := fixture()
|
||||
b.loaded = shadowOf(t, b)
|
||||
hash := func(user, h string, generation int, rotating bool) {
|
||||
v := map[string]any{"user": user, "hash": h, "generation": generation, "fingerprint": fmt.Sprintf("sha256:%d", generation)}
|
||||
if rotating {
|
||||
v["rotating"] = map[string]any{"login": user + "--b", "generation": generation, "revision": 7}
|
||||
}
|
||||
b.put(grants.ReadAccounts, user, v)
|
||||
}
|
||||
for _, u := range shadowOf(t, b) {
|
||||
hash(u.User, "$2a$10$first", 1, false)
|
||||
}
|
||||
busKey, err := seal.GenerateKeyPair()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b.put(grants.ReadHave, "a.nats", delivery.Have{V: delivery.Version, Phase: delivery.PhaseRunning, Health: delivery.HealthOK,
|
||||
UserList: &delivery.UserListWritten{Source: "a.nats.src2", Revision: 7, Digest: "sha256:aa", Users: 6, Key: busKey.PublicKey}})
|
||||
p := partOn(b)
|
||||
p.Objects = &fakeObjects{}
|
||||
var said []asks.Condition
|
||||
p.Say = func(c asks.Condition) error { said = append(said, c); return nil }
|
||||
sw, _ := json.Marshal(grants.Switch{V: 1, State: grants.TookOver, Acts: []grants.Act{}})
|
||||
if _, err := (ownStore{b}).Put(grants.StateTakeover, grants.KeySwitch, sw); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p.Compose()
|
||||
inForce, _, _ := p.Sealed()
|
||||
if inForce.Digest == "" {
|
||||
t.Fatal("no list written after take-over")
|
||||
}
|
||||
// One user's hash changes: written.
|
||||
hash("a.mod-x", "$2a$10$second", 2, false)
|
||||
p.Compose()
|
||||
if now, _, _ := p.Sealed(); now.Digest == inForce.Digest {
|
||||
t.Fatal("a list changing one loaded user's hash was not written")
|
||||
}
|
||||
inForce, _, _ = p.Sealed()
|
||||
// Two change at once, no rotation names them: held, said naming both.
|
||||
hash("a.mod-x", "$2a$10$third", 3, false)
|
||||
hash("a.nats", "$2a$10$third", 2, false)
|
||||
said = nil
|
||||
res := p.Compose()
|
||||
if now, _, _ := p.Sealed(); now.Digest != inForce.Digest || res.Sealed == nil || !strings.Contains(res.Sealed.Why, "a.mod-x, a.nats") {
|
||||
t.Fatalf("a list changing two loaded users' hashes: %+v", res.Sealed)
|
||||
}
|
||||
if len(said) != 1 || said[0].Key != grants.HeldCondition || said[0].State != asks.StateRaised || !strings.Contains(said[0].Summary, "a.mod-x, a.nats") {
|
||||
t.Errorf("the hold was said as %+v", said)
|
||||
}
|
||||
// The hold is its own guard, beside take-over's refusals (ADR 0562): the switch stays taken over.
|
||||
if p.SwitchState() != grants.TookOver {
|
||||
t.Errorf("the hold moved the switch to %s", p.SwitchState())
|
||||
}
|
||||
// A person accepts exactly the named users' changes of the held list, with why: refused for another set, with no
|
||||
// why, or once the list changed since; accepted, the next pass writes it once, said as a hand act.
|
||||
if _, err := p.AcceptHashes("person.jochen", []string{"a.mod-x"}, "rotated by hand"); err == nil || !strings.Contains(err.Error(), "a.mod-x, a.nats") {
|
||||
t.Errorf("an acceptance of another set than the held one: %v", err)
|
||||
}
|
||||
if _, err := p.AcceptHashes("person.jochen", []string{"a.mod-x", "a.nats"}, ""); err == nil {
|
||||
t.Error("an acceptance with no why")
|
||||
}
|
||||
if _, err := p.AcceptHashes("person.jochen", []string{"a.nats", "a.mod-x"}, "the vault's store was restored"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The list changed since the acceptance (a.mod-x's hash again): the acceptance is bound to the held list, refused.
|
||||
hash("a.mod-x", "$2a$10$fourth", 4, false)
|
||||
res = p.Compose()
|
||||
if now, _, _ := p.Sealed(); now.Digest != inForce.Digest || res.Sealed == nil || !strings.Contains(res.Sealed.Why, "held") {
|
||||
t.Fatalf("an acceptance of a list that changed since released it: %+v", res.Sealed)
|
||||
}
|
||||
if _, err := p.AcceptHashes("person.jochen", []string{"a.mod-x", "a.nats"}, "the vault's store was restored"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b.events = nil
|
||||
p.Compose()
|
||||
if now, _, _ := p.Sealed(); now.Digest == inForce.Digest || said[len(said)-1].State != asks.StateCleared ||
|
||||
!slices.Contains(b.events, grants.EventHashesAccepted) {
|
||||
t.Fatalf("an accepted hold was not released once and said: %+v, events %v", said, b.events)
|
||||
}
|
||||
inForce, _, _ = p.Sealed()
|
||||
// The vault's rotation names each: written, the warning cleared.
|
||||
hash("a.mod-x", "$2a$10$fifth", 5, true)
|
||||
hash("a.nats", "$2a$10$fifth", 3, true)
|
||||
p.Compose()
|
||||
if now, _, _ := p.Sealed(); now.Digest == inForce.Digest || said[len(said)-1].State != asks.StateCleared {
|
||||
t.Errorf("changes the vault's rotation names were held: %+v", said)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
type fakeAsker struct {
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
package grants
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"slices"
|
||||
"sort"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/announce"
|
||||
"git.novox.be/novox/mesh-sdk/go/buscompose"
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
|
||||
// **The accounts the mesh wants, said to the vault** (novox/hq ADR 0561 §2, issue 774). The bus composer derives every
|
||||
// user from placement, the manifests and the wants; for each account the vault mints (a module's, a process's, a
|
||||
// machine's provisioner client's), the part writes one key of its state `accounts`, keyed by the bus user, with the
|
||||
// fields the controller's credential carries beside the password. A key is written only when it differs, and deleted
|
||||
// only when placement no longer names its user and every input of the pass was read: a refused read never retires an
|
||||
// account. The vault alone reads the state (buscompose.NamedReaders); it mints each user it holds no value for.
|
||||
|
||||
// StateAccounts is the part's state of wanted accounts.
|
||||
const StateAccounts = buscompose.AccountsState
|
||||
|
||||
// AccountsReaders are the only modules that may read StateAccounts.
|
||||
var AccountsReaders = buscompose.NamedReaders[buscompose.GrantsAccounts]
|
||||
|
||||
// Announced is what the part says it keeps: every state readable by any reader, but the wanted accounts, by the vault
|
||||
// alone (ADR 0495 §2-§3, ADR 0561 §2).
|
||||
func Announced() []announce.State {
|
||||
var out []announce.State
|
||||
for _, s := range States {
|
||||
if s == StateAccounts {
|
||||
out = append(out, announce.State{Name: s, Readers: slices.Clone(AccountsReaders)})
|
||||
continue
|
||||
}
|
||||
out = append(out, announce.State{Name: s, Readable: true})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// BusAddress is where every node on the private network reaches the bus, and its certificate's fingerprint: the part's
|
||||
// own credential says both, since the controller gave every node's account the bus holder's overlay name (its
|
||||
// brokerReachableAt).
|
||||
type BusAddress struct {
|
||||
URL string
|
||||
Fingerprint string
|
||||
}
|
||||
|
||||
// WantedAccounts is every account the vault mints that the records compose, keyed by user, with the bus's address. A
|
||||
// node, the controller, an enrolment user or a person is never one (ADR 0561 §4, §5). Pure.
|
||||
func WantedAccounts(in Inputs, recs buscompose.Records, bus BusAddress) (map[string]buscompose.WantedAccount, error) {
|
||||
principals, err := buscompose.Users(recs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := map[string]buscompose.WantedAccount{}
|
||||
for _, p := range principals {
|
||||
w := buscompose.WantedAccount{User: p.Username(), Kind: p.Kind, Node: p.Node, Module: p.Module, Process: p.Process,
|
||||
URL: bus.URL, Fingerprint: bus.Fingerprint}
|
||||
switch p.Kind {
|
||||
case buscompose.KindModule, buscompose.KindNodeTools:
|
||||
if m, ok := manifestOn(in, p.Node, p.Module); ok {
|
||||
w.Claims = ClaimsOf(m)
|
||||
}
|
||||
case buscompose.KindProcess, buscompose.KindProvisioner:
|
||||
default:
|
||||
continue
|
||||
}
|
||||
out[w.User] = w
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// manifestOn is the manifest a node runs of module: the build its want names, else the trunk's.
|
||||
func manifestOn(in Inputs, node, module string) (catalogue.Manifest, bool) {
|
||||
if m, ok := in.Running[node+"."+module]; ok {
|
||||
return m, true
|
||||
}
|
||||
m, ok := in.Manifests[module]
|
||||
return m, ok
|
||||
}
|
||||
|
||||
// ClaimsOf is a module's claims as its credential carries them: the controller's claimsFor, over the mesh's own seats
|
||||
// (its seat table is seeded from them): each claim's seat and scope, and the verbs the runtime serves for it, the claim's
|
||||
// own or every verb the seat promises; a seat the mesh does not define keeps the claim's scope and no verbs.
|
||||
func ClaimsOf(m catalogue.Manifest) []buscompose.ClaimedSeat {
|
||||
if len(m.Claims) == 0 {
|
||||
return nil
|
||||
}
|
||||
byName := map[string]catalogue.Seat{}
|
||||
for _, s := range catalogue.DefaultSeats() {
|
||||
byName[s.Name] = s
|
||||
}
|
||||
var out []buscompose.ClaimedSeat
|
||||
for _, c := range m.Claims {
|
||||
claimed := buscompose.ClaimedSeat{Seat: c.Name, Scope: c.At()}
|
||||
if s, known := byName[c.Name]; known {
|
||||
claimed.Scope = s.Scope
|
||||
claimed.Serves = c.ServesFor(catalogue.Manifest{Tools: catalogue.VerbNames(s.Serves)})
|
||||
}
|
||||
out = append(out, claimed)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Accounts answers the tool accounts (ADR 0561 §9): the wanted accounts as the part last wrote them, one user's or all,
|
||||
// each with when it was written; and what the last pass did to them. Never a password: the state holds none.
|
||||
func (p *Part) Accounts(user string) (any, error) {
|
||||
users := []string{user}
|
||||
if user == "" {
|
||||
keys, err := p.Store.Keys(StateAccounts)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("its own %s cannot be listed: %w", StateAccounts, err)
|
||||
}
|
||||
users = keys
|
||||
}
|
||||
out := []buscompose.WantedAccount{}
|
||||
for _, u := range users {
|
||||
raw, _, found, err := p.Store.Get(StateAccounts, u)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("its own %s %s cannot be read: %w", StateAccounts, u, err)
|
||||
}
|
||||
if !found {
|
||||
if user != "" {
|
||||
return nil, fmt.Errorf("no account %s is wanted: placement composes no module, process or provisioner client of that user", user)
|
||||
}
|
||||
continue
|
||||
}
|
||||
w, err := buscompose.ReadWantedAccount(u, raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, w)
|
||||
}
|
||||
answer := map[string]any{"accounts": out, "count": len(out), "read_by": AccountsReaders}
|
||||
if raw, _, found, err := p.Store.Get(StateUserList, KeyPass); err == nil && found {
|
||||
var res Result
|
||||
if json.Unmarshal(raw, &res) == nil && res.Accounts != nil {
|
||||
answer["last_pass"] = res.Accounts
|
||||
}
|
||||
// And the durable consumers the accounts need, as the last pass found them: missing and differing ones are read
|
||||
// here in shadow too, where no condition is raised (ADR 0561 §6, §9).
|
||||
if res.Consumers != nil {
|
||||
answer["consumers"] = res.Consumers
|
||||
}
|
||||
}
|
||||
return answer, nil
|
||||
}
|
||||
|
||||
// AccountsWritten is what one pass did to the state of wanted accounts.
|
||||
type AccountsWritten struct {
|
||||
Wanted int `json:"wanted"`
|
||||
Written []string `json:"written,omitempty"`
|
||||
Deleted []string `json:"deleted,omitempty"`
|
||||
// Kept are keys placement no longer names that this pass did not delete, with why.
|
||||
Kept []string `json:"kept,omitempty"`
|
||||
}
|
||||
|
||||
// writeAccounts writes each wanted account whose key differs, and deletes the key of a user no longer placed, but only
|
||||
// when every input of the pass was read.
|
||||
func (p *Part) writeAccounts(res *Result, in Inputs, recs buscompose.Records) {
|
||||
if p.Bus.URL == "" {
|
||||
res.Errors = append(res.Errors, "the wanted accounts are not written: the part knows no bus address to give them (its own credential names none)")
|
||||
return
|
||||
}
|
||||
wanted, err := WantedAccounts(in, recs, p.Bus)
|
||||
if err != nil {
|
||||
res.Errors = append(res.Errors, "the wanted accounts could not be composed: "+err.Error())
|
||||
return
|
||||
}
|
||||
done := &AccountsWritten{Wanted: len(wanted)}
|
||||
res.Accounts = done
|
||||
for _, user := range sortedKeys(wanted) {
|
||||
w := wanted[user]
|
||||
if raw, _, found, err := p.Store.Get(StateAccounts, user); err != nil {
|
||||
res.Errors = append(res.Errors, fmt.Sprintf("its own %s %s cannot be read: %v", StateAccounts, user, err))
|
||||
continue
|
||||
} else if found {
|
||||
if was, err := buscompose.ReadWantedAccount(user, raw); err == nil && was.Same(w) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
w.At = p.now()
|
||||
data, _ := json.Marshal(w)
|
||||
if _, err := p.Store.Put(StateAccounts, user, data); err != nil {
|
||||
res.Errors = append(res.Errors, fmt.Sprintf("its own %s %s cannot be written: %v", StateAccounts, user, err))
|
||||
continue
|
||||
}
|
||||
done.Written = append(done.Written, user)
|
||||
}
|
||||
held, err := p.Store.Keys(StateAccounts)
|
||||
if err != nil {
|
||||
res.Errors = append(res.Errors, fmt.Sprintf("its own %s cannot be listed, so no key of a user gone is deleted: %v", StateAccounts, err))
|
||||
return
|
||||
}
|
||||
failed := in.ReadFailures()
|
||||
placed := Placed(in)
|
||||
for _, user := range held {
|
||||
if _, still := wanted[user]; still {
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case placed(user):
|
||||
done.Kept = append(done.Kept, user+": placement still names it (composed with no account this pass)")
|
||||
continue
|
||||
case len(failed) > 0:
|
||||
done.Kept = append(done.Kept, user+": a read of this pass failed, so no account is retired")
|
||||
continue
|
||||
}
|
||||
if err := p.Store.Delete(StateAccounts, user); err != nil {
|
||||
res.Errors = append(res.Errors, fmt.Sprintf("its own %s %s cannot be deleted: %v", StateAccounts, user, err))
|
||||
continue
|
||||
}
|
||||
done.Deleted = append(done.Deleted, user)
|
||||
}
|
||||
sort.Strings(done.Kept)
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
package grants
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
"git.novox.be/novox/mesh-sdk/go/buscompose"
|
||||
)
|
||||
|
||||
// **The durable consumers an account needs** (novox/hq ADR 0561 §6, issue 774). The controller creates a module's
|
||||
// consumer on EVENTS and each process's on its work queue when it issues the account. From the write that hands its
|
||||
// lease away the grants part creates them, with the configuration the bus composer derives (buscompose.AccountConsumers),
|
||||
// only where the bus lacks one; one the bus holds is never changed or deleted, a difference is only said. Before that
|
||||
// write they are compared in shadow alone, so two parts never create one consumer.
|
||||
|
||||
// ReadLease is the controller's lease as the part reads it (buscompose.NamedReaders names the vault and the part), and
|
||||
// LeaseKey its one key.
|
||||
const (
|
||||
ReadLease = buscompose.ControllerLease
|
||||
LeaseKey = buscompose.ControllerLeaseKey
|
||||
)
|
||||
|
||||
// ConsumerCreator creates one durable consumer the bus does not hold: the part's process alone, whose account is granted
|
||||
// the create of each account's consumer by stream and name. One the bus holds answers ErrHeld.
|
||||
type ConsumerCreator interface {
|
||||
CreateConsumer(stream string, cfg jetstream.ConsumerConfig) error
|
||||
// MatchConsumer brings a held consumer to cfg, keeping where it delivers from: only on a person's ask
|
||||
// (grants_consumer_match), never by a pass.
|
||||
MatchConsumer(stream string, cfg jetstream.ConsumerConfig) error
|
||||
}
|
||||
|
||||
// ConsumersState is what one pass found and did of the account consumers.
|
||||
type ConsumersState struct {
|
||||
// On says the lease is handed away, so the part creates what the bus lacks; Why says why it is on or off.
|
||||
On bool `json:"on"`
|
||||
Why string `json:"why"`
|
||||
// Wanted is how many the records compose; Missing those the bus lacks (`<stream>.<name>`), Differ those it holds
|
||||
// otherwise, each with how.
|
||||
Wanted int `json:"wanted"`
|
||||
Missing []string `json:"missing,omitempty"`
|
||||
Differ []string `json:"differ,omitempty"`
|
||||
Created []string `json:"created,omitempty"`
|
||||
// Matched is a consumer brought to the composed configuration on a person's ask, or why not.
|
||||
Matched []string `json:"matched,omitempty"`
|
||||
NotCreated []string `json:"not_created,omitempty"`
|
||||
// Unread is a stream whose consumers could not be read: nothing on it is compared or created.
|
||||
Unread []string `json:"unread,omitempty"`
|
||||
}
|
||||
|
||||
// heldConsumer is one consumer as the bus module's nats_streams reads it for one stream.
|
||||
type heldConsumer struct {
|
||||
Name string `json:"name"`
|
||||
Filters []string `json:"filters"`
|
||||
MaxDeliver int `json:"max_deliver"`
|
||||
}
|
||||
|
||||
// heldConsumers reads the consumers the bus holds on stream, by name.
|
||||
func heldConsumers(c Caller, stream string) (map[string]heldConsumer, error) {
|
||||
raw, err := c.Call(CallStreams, map[string]any{"stream": stream})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s %s: %w", CallStreams, stream, err)
|
||||
}
|
||||
var got struct {
|
||||
Detail []struct {
|
||||
Name string `json:"name"`
|
||||
Consumers []heldConsumer `json:"consumer_detail"`
|
||||
} `json:"detail"`
|
||||
}
|
||||
if err := json.Unmarshal(unwrap(raw), &got); err != nil {
|
||||
return nil, fmt.Errorf("%s %s answered no consumers: %w", CallStreams, stream, err)
|
||||
}
|
||||
out := map[string]heldConsumer{}
|
||||
for _, d := range got.Detail {
|
||||
if d.Name != stream {
|
||||
continue
|
||||
}
|
||||
for _, c := range d.Consumers {
|
||||
out[c.Name] = c
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// leaseHandedAway reads the lease as the part's reads give it.
|
||||
func leaseHandedAway(r Reader) (bool, string) {
|
||||
raw, found, err := r.Get(ReadLease, LeaseKey)
|
||||
return buscompose.LeaseHandedAway(raw, found, err)
|
||||
}
|
||||
|
||||
// consumers compares the account consumers the records compose with those the bus holds, and creates the missing ones
|
||||
// once the lease is handed away and the part holds a creator.
|
||||
func (p *Part) consumers(res *Result, recs buscompose.Records, match string) {
|
||||
users, err := buscompose.Users(recs)
|
||||
if err != nil {
|
||||
res.Errors = append(res.Errors, "the account consumers could not be composed: "+err.Error())
|
||||
return
|
||||
}
|
||||
st := &ConsumersState{}
|
||||
st.On, st.Why = leaseHandedAway(p.Read)
|
||||
res.Consumers = st
|
||||
wanted := buscompose.AccountConsumers(users)
|
||||
st.Wanted = len(wanted)
|
||||
byStream := map[string][]buscompose.Consumer{}
|
||||
for _, mc := range wanted {
|
||||
byStream[mc.Consumer.Stream] = append(byStream[mc.Consumer.Stream], mc.Consumer)
|
||||
}
|
||||
for _, stream := range sortedKeys(byStream) {
|
||||
held, err := heldConsumers(p.Call, stream)
|
||||
if err != nil {
|
||||
st.Unread = append(st.Unread, stream+": "+err.Error())
|
||||
continue
|
||||
}
|
||||
for _, c := range byStream[stream] {
|
||||
key := stream + "." + c.Name
|
||||
h, ok := held[c.Name]
|
||||
if ok {
|
||||
why := consumerDiffers(c, h)
|
||||
if why != "" && key == match && st.On && p.Consumers != nil {
|
||||
if err := p.Consumers.MatchConsumer(stream, buscompose.ConsumerConfig(c)); err != nil {
|
||||
st.Matched = append(st.Matched, key+": not matched: "+err.Error())
|
||||
} else {
|
||||
st.Matched = append(st.Matched, key+": matched, asked by hand")
|
||||
continue
|
||||
}
|
||||
}
|
||||
if why != "" {
|
||||
st.Differ = append(st.Differ, key+": "+why)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !st.On || p.Consumers == nil {
|
||||
st.Missing = append(st.Missing, key)
|
||||
continue
|
||||
}
|
||||
switch err := p.Consumers.CreateConsumer(stream, buscompose.ConsumerConfig(c)); {
|
||||
case err == nil:
|
||||
st.Created = append(st.Created, key)
|
||||
p.say(res, EventAsserted, map[string]any{"consumer": c.Name, "stream": stream})
|
||||
case errors.Is(err, ErrHeld):
|
||||
default:
|
||||
st.Missing = append(st.Missing, key)
|
||||
st.NotCreated = append(st.NotCreated, key+": "+err.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
p.sayDiffer(st)
|
||||
}
|
||||
|
||||
// sayDiffer raises the warning condition mesh-grants.consumer-differs while an account consumer the part owns (from the
|
||||
// lease write) differs from its composed configuration, naming each with the fields that differ, and clears it when they
|
||||
// match. Said once per set of differences, again when it changes. Before the lease write the consumers are the
|
||||
// controller's, and a difference is only said in the pass.
|
||||
func (p *Part) sayDiffer(st *ConsumersState) {
|
||||
if p.Say == nil {
|
||||
return
|
||||
}
|
||||
why := ""
|
||||
if st.On && len(st.Differ) > 0 {
|
||||
why = strings.Join(st.Differ, "; ")
|
||||
}
|
||||
p.holdMu.Lock()
|
||||
defer p.holdMu.Unlock()
|
||||
if p.differKnown && why == p.differSaid {
|
||||
return
|
||||
}
|
||||
c := asks.Condition{Key: ConsumerDiffersCondition, Subject: asks.Scope{Scope: "module", ID: "mesh-grants"}}
|
||||
if why == "" {
|
||||
c.State = asks.StateCleared
|
||||
} else {
|
||||
c.Severity, c.State = asks.SeverityWarning, asks.StateRaised
|
||||
c.Summary = fmt.Sprintf("%d durable consumers differ from their composed configuration", len(st.Differ))
|
||||
c.Headline, c.Needs = "A module's consumer is not as composed", "a look, and grants_consumer_match for each"
|
||||
c.Explanation = asks.NeedsYou + "a look. The bus holds these consumers otherwise than the bus composer composes them, " +
|
||||
"and the grants part never changes a held one by itself (its delivery position would be lost): " + why
|
||||
c.Resolved = "every account consumer matches, by grants_consumer_match or otherwise"
|
||||
}
|
||||
if p.Say(c) == nil {
|
||||
p.differSaid, p.differKnown = why, true
|
||||
}
|
||||
}
|
||||
|
||||
// ConsumerDiffersCondition is the key of the warning raised while an account consumer differs (ADR 0561 §6).
|
||||
const ConsumerDiffersCondition = "mesh-grants.consumer-differs"
|
||||
|
||||
// MatchConsumer is the tool consumer-match (ADR 0561 §6, §9): it asks the part's process to bring one account consumer
|
||||
// the records compose to its composed configuration, keeping where it delivers from. Refused while the controller holds
|
||||
// the lease (the consumers are the controller's then), and for a consumer the records do not compose.
|
||||
func (p *Part) MatchConsumer(stream, name string) (any, error) {
|
||||
if stream == "" || name == "" {
|
||||
return nil, errors.New("consumer-match names a stream and a consumer")
|
||||
}
|
||||
if on, why := leaseHandedAway(p.Read); !on {
|
||||
return nil, fmt.Errorf("not asked: %s; the controller makes and matches the consumers while it holds the lease", why)
|
||||
}
|
||||
in := ReadInputs(p.Read, &p.cache)
|
||||
recs, _ := Records(in)
|
||||
users, err := buscompose.Users(recs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
found := false
|
||||
for _, mc := range buscompose.AccountConsumers(users) {
|
||||
if mc.Consumer.Stream == stream && mc.Consumer.Name == name {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return nil, fmt.Errorf("the records compose no account consumer %s on %s: the part brings only those to their configuration", name, stream)
|
||||
}
|
||||
ask, _ := json.Marshal(Ask{By: "grants_consumer_match", At: p.now(), Consumer: stream + "." + name})
|
||||
rev, err := p.Store.Put(StateUserList, KeyAsked, ask)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the ask could not be written: %w", err)
|
||||
}
|
||||
return map[string]any{"asked": rev, "says": "the part's process brings " + stream + "." + name + " to its composed " +
|
||||
"configuration at this ask, keeping where it delivers from; the last pass's consumers.matched says what became of it"}, nil
|
||||
}
|
||||
|
||||
// consumerDiffers says how a held consumer differs from the composed one in what nats_streams reads back, or "".
|
||||
func consumerDiffers(c buscompose.Consumer, h heldConsumer) string {
|
||||
var why []string
|
||||
want, have := slices.Sorted(slices.Values(c.Filters)), slices.Sorted(slices.Values(h.Filters))
|
||||
if !slices.Equal(want, have) {
|
||||
why = append(why, fmt.Sprintf("filters %v, composed %v", have, want))
|
||||
}
|
||||
maxDeliver := c.MaxDeliver
|
||||
if maxDeliver == 0 {
|
||||
maxDeliver = -1
|
||||
}
|
||||
if h.MaxDeliver != maxDeliver {
|
||||
why = append(why, fmt.Sprintf("max deliver %d, composed %d", h.MaxDeliver, maxDeliver))
|
||||
}
|
||||
sort.Strings(why)
|
||||
return strings.Join(why, "; ")
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/buscompose"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
@@ -18,7 +19,7 @@ type Watcher interface {
|
||||
// Followed are the states whose change composes again: placement, the indexer's manifests by module, the resolved
|
||||
// config values, the node agents' haves (a machine's first have, and the bus node's loaded list) and the assigner's
|
||||
// wants (the manifest each machine is wanted to run), and the vault's hashes of the bus accounts.
|
||||
var Followed = []string{ReadAssignments, ReadModules, ReadResolved, ReadHave, ReadWants, ReadAccounts, ReadPlacement}
|
||||
var Followed = []string{ReadAssignments, ReadModules, ReadResolved, ReadHave, ReadWants, ReadAccounts, ReadPlacement, ReadLease}
|
||||
|
||||
// Follower composes again on every change it hears, never on a timer. It never stops for a refusal: a watch it cannot
|
||||
// open is said, composed without, and opened again at the next wake (any change it does hear, or a reconnect).
|
||||
@@ -28,9 +29,9 @@ type Follower struct {
|
||||
Logf func(string, ...any)
|
||||
// Asked watches the part's own ask to compose now (a tool's compose-now), waking at each; nil watches none.
|
||||
Asked func(wake func()) error
|
||||
// Hear hears the controller seat's condition events (Consumes) and hands each, by event name, with its body, to
|
||||
// handle; nil hears none. A condition of the kind root-not-free raised or cleared wakes a pass: a machine's
|
||||
// root-free word changed.
|
||||
// Hear hears the controller seat's condition events and the vault's account-issued (Consumes) and hands each, by event
|
||||
// name, with its body, to handle; nil hears none. A condition of the kind root-not-free raised or cleared wakes a pass:
|
||||
// a machine's root-free word changed; and so does an account the vault minted (ADR 0561 §1), whose hash the list carries.
|
||||
Hear func(handle func(event string, body []byte)) error
|
||||
|
||||
wake chan struct{}
|
||||
@@ -46,6 +47,8 @@ type Follower struct {
|
||||
held string
|
||||
notAsserted string
|
||||
refusedMans string
|
||||
// leaseOn is whether the lease was handed away at its last change heard, once leaseSeen.
|
||||
leaseOn, leaseSeen bool
|
||||
}
|
||||
|
||||
func (f *Follower) init() {
|
||||
@@ -85,6 +88,15 @@ func (f *Follower) logf(format string, args ...any) {
|
||||
// heard says whether a change composes again: every change but a have's, and of the haves a machine's first and the
|
||||
// bus module's loaded list moving.
|
||||
func (f *Follower) heard(state, key string, value []byte) bool {
|
||||
if state == ReadLease {
|
||||
// Renewed every few seconds by its holder: only the write that hands it away, or back, composes again (ADR 0561 §6).
|
||||
on, _ := buscompose.LeaseHandedAway(value, len(value) > 0, nil)
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
was, seen := f.leaseOn, f.leaseSeen
|
||||
f.leaseOn, f.leaseSeen = on, true
|
||||
return !seen || was != on
|
||||
}
|
||||
if state != ReadHave {
|
||||
return true
|
||||
}
|
||||
@@ -124,7 +136,7 @@ func (f *Follower) open() {
|
||||
}
|
||||
if f.Hear != nil && !f.heard_ {
|
||||
err := f.Hear(func(event string, body []byte) {
|
||||
if IsRootNotFree(body) {
|
||||
if IsAccountIssued(event) || IsRootNotFree(body) {
|
||||
f.Wake()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -41,7 +41,7 @@ const (
|
||||
|
||||
// Reads is every state the part reads, in the manifest's order.
|
||||
var Reads = []string{ReadAssignments, ReadControllerGrant, ReadModules, ReadManifests, ReadResolved, ReadHave, ReadWants, ReadBuilds,
|
||||
ReadAccounts, ReadPlacement}
|
||||
ReadAccounts, ReadPlacement, ReadLease}
|
||||
|
||||
// The bus module's tools the part calls, as its manifest's invokes name them: read-only, each.
|
||||
const (
|
||||
@@ -139,6 +139,22 @@ type HeldHash struct {
|
||||
Generation int `json:"generation,omitempty"`
|
||||
At time.Time `json:"at"`
|
||||
Adopted bool `json:"adopted,omitempty"`
|
||||
// Fingerprint is the vault's fingerprint of the value the hash is of: with the generation, what says the password
|
||||
// changed, never the hash itself (a hash of the same password differs by its salt).
|
||||
Fingerprint string `json:"fingerprint,omitempty"`
|
||||
// Rotating is the vault's rotation of the account under way: a change of its hash it announces (ADR 0561 §4, §7).
|
||||
Rotating *struct {
|
||||
Login string `json:"login"`
|
||||
} `json:"rotating,omitempty"`
|
||||
}
|
||||
|
||||
// credentialOf is what says which password a held hash is of, never the hash: adopted, or the vault's generation and the
|
||||
// fingerprint of its value.
|
||||
func credentialOf(h HeldHash) string {
|
||||
if h.Adopted {
|
||||
return "adopted"
|
||||
}
|
||||
return fmt.Sprintf("generation %d %s", h.Generation, h.Fingerprint)
|
||||
}
|
||||
|
||||
// readHashes reads the vault's bus-accounts; a refusal is a gap, and every user then has no hash held.
|
||||
|
||||
@@ -31,7 +31,7 @@ const (
|
||||
)
|
||||
|
||||
// States is every state the part keeps.
|
||||
var States = []string{StateAssertions, StateMemberships, StateTakeover, StateUserList}
|
||||
var States = []string{StateAccounts, StateAssertions, StateMemberships, StateTakeover, StateUserList}
|
||||
|
||||
// The events the part says.
|
||||
const (
|
||||
@@ -42,12 +42,15 @@ const (
|
||||
)
|
||||
|
||||
// Emits is every event the part says.
|
||||
var Emits = []string{EventAsserted, EventMembership, EventUserList}
|
||||
var Emits = []string{EventAsserted, EventHashesAccepted, EventMembership, EventUserList}
|
||||
|
||||
// Store is the part's own state, by local name: the only thing the part writes.
|
||||
type Store interface {
|
||||
Get(state, key string) ([]byte, uint64, bool, error)
|
||||
Put(state, key string, value []byte) (uint64, error)
|
||||
// Keys and Delete are for the wanted accounts alone (accounts.go): every other state is only put.
|
||||
Keys(state string) ([]string, error)
|
||||
Delete(state, key string) error
|
||||
}
|
||||
|
||||
// Emitter says one of the part's events.
|
||||
@@ -104,9 +107,13 @@ type Result struct {
|
||||
// free, <why>`: a verified sender is composed as from no root-free machine there (issue 1945).
|
||||
RootNotFree []string `json:"root_not_free,omitempty"`
|
||||
// Sealed is the list sealed to the bus node as the key `user-list.sealed` names it (ADR 0547 clause 7).
|
||||
Sealed *SealedState `json:"sealed,omitempty"`
|
||||
Errors []string `json:"errors,omitempty"`
|
||||
PublishedOrAsserted string `json:"published_or_asserted"`
|
||||
Sealed *SealedState `json:"sealed,omitempty"`
|
||||
// Consumers is what the pass found and created of the durable consumers an account needs (ADR 0561 §6).
|
||||
Consumers *ConsumersState `json:"consumers,omitempty"`
|
||||
// Accounts is what the pass wrote of the wanted accounts the vault mints from (ADR 0561 §2).
|
||||
Accounts *AccountsWritten `json:"accounts,omitempty"`
|
||||
Errors []string `json:"errors,omitempty"`
|
||||
PublishedOrAsserted string `json:"published_or_asserted"`
|
||||
}
|
||||
|
||||
// NothingTakesEffect is what every composition says it did on the bus beyond its own state and the memberships.
|
||||
@@ -129,6 +136,12 @@ type Part struct {
|
||||
Logf func(string, ...any)
|
||||
// Objects is the part's object bucket of sealed lists: the process's connection; nil in the tools.
|
||||
Objects Objects
|
||||
// Consumers creates an account's durable consumer from the lease write (consumers.go): the process's connection;
|
||||
// nil in the tools, which compare only.
|
||||
Consumers ConsumerCreator
|
||||
// Bus is where the nodes reach the bus, which each wanted account carries (accounts.go): the process's own credential's;
|
||||
// empty in the tools, which write no account.
|
||||
Bus BusAddress
|
||||
// Say says a condition to the operator: the process's; nil in the tools.
|
||||
Say func(asks.Condition) error
|
||||
// Forget takes away the record the asker kept of an ask it failed to publish; Nonce is a test's id suffix.
|
||||
@@ -150,6 +163,11 @@ type Part struct {
|
||||
holdMu sync.Mutex
|
||||
heldSaid string
|
||||
heldKnown bool
|
||||
// differSaid is the account consumers' differences last said as a condition, once differKnown (consumers.go).
|
||||
differSaid string
|
||||
differKnown bool
|
||||
// hashHold are the users whose hash changes held the pass's list, or nil (sealed.go).
|
||||
hashHold []string
|
||||
}
|
||||
|
||||
// Ask is the value of KeyAsked: a tool's ask to compose now, and, from grants_assert, to create one module's missing
|
||||
@@ -158,18 +176,21 @@ type Ask struct {
|
||||
By string `json:"by"`
|
||||
At time.Time `json:"at"`
|
||||
Assert string `json:"assert,omitempty"`
|
||||
// Consumer is, from grants_consumer_match, one account consumer `<stream>.<name>` to bring to the composed
|
||||
// configuration by hand (ADR 0561 §6).
|
||||
Consumer string `json:"consumer,omitempty"`
|
||||
}
|
||||
|
||||
// askedAssert is the module a new ask names to assert by hand, read once per ask; empty when none.
|
||||
func (p *Part) askedAssert() string {
|
||||
// asked is a new ask, read once per ask; the zero Ask when none.
|
||||
func (p *Part) asked() Ask {
|
||||
raw, rev, found, err := p.Store.Get(StateUserList, KeyAsked)
|
||||
if err != nil || !found || rev == p.askSeen {
|
||||
return ""
|
||||
return Ask{}
|
||||
}
|
||||
p.askSeen = rev
|
||||
var a Ask
|
||||
_ = json.Unmarshal(raw, &a)
|
||||
return a.Assert
|
||||
return a
|
||||
}
|
||||
|
||||
func (p *Part) now() time.Time {
|
||||
@@ -233,6 +254,10 @@ func (p *Part) Compose() Result {
|
||||
}
|
||||
}
|
||||
}
|
||||
// The accounts the vault mints from, said in the state only it reads (ADR 0561 §2).
|
||||
if p.Store != nil {
|
||||
p.writeAccounts(&res, in, recs)
|
||||
}
|
||||
// The list itself, sealed to the bus node and named in `user-list.sealed` (ADR 0547 clause 7).
|
||||
if list, n, leftOut, err := ComposeList(recs, in.Hashes); err != nil {
|
||||
res.Errors = append(res.Errors, "the user list could not be composed whole: "+err.Error())
|
||||
@@ -267,10 +292,11 @@ func (p *Part) Compose() Result {
|
||||
}
|
||||
}
|
||||
held, heldErr := HeldStreams(p.Call)
|
||||
forced := ""
|
||||
if p.Create != nil {
|
||||
forced = p.askedAssert()
|
||||
var ask Ask
|
||||
if p.Create != nil || p.Consumers != nil {
|
||||
ask = p.asked()
|
||||
}
|
||||
forced := ask.Assert
|
||||
for _, module := range sortedKeys(in.Manifests) {
|
||||
if !assigned(in, module) {
|
||||
continue
|
||||
@@ -292,6 +318,7 @@ func (p *Part) Compose() Result {
|
||||
res.AssertionsWritten++
|
||||
}
|
||||
}
|
||||
p.consumers(&res, recs, ask.Consumer)
|
||||
if _, _, found, err := p.Store.Get(StateTakeover, KeySwitch); err == nil && !found {
|
||||
data, _ := json.Marshal(Switch{V: 1, State: Shadow, Acts: []Act{}})
|
||||
if _, err := p.Store.Put(StateTakeover, KeySwitch, data); err != nil {
|
||||
|
||||
@@ -23,8 +23,17 @@ const (
|
||||
KindRootNotFree = "root-not-free"
|
||||
)
|
||||
|
||||
// EventAccountIssued is the vault's word that it minted an account (novox/hq ADR 0561 §1): its hash is in the vault's
|
||||
// bus-accounts, so a pass composes the list with it.
|
||||
const EventAccountIssued = "mesh-vault." + buscompose.AccountIssuedEvent
|
||||
|
||||
// Consumes is every event the part hears, as its manifest's consumes name them.
|
||||
var Consumes = []string{EventConditionRaised, EventConditionCleared}
|
||||
var Consumes = []string{EventConditionRaised, EventConditionCleared, EventAccountIssued}
|
||||
|
||||
// IsAccountIssued says an event heard is the vault's account-issued, by its name or its bus subject.
|
||||
func IsAccountIssued(event string) bool {
|
||||
return event == EventAccountIssued || event == buscompose.AccountIssuedSubject
|
||||
}
|
||||
|
||||
// EventSubject is the bus subject of a controller seat event named `mesh-controller.<event>`.
|
||||
func EventSubject(event string) string {
|
||||
|
||||
@@ -2,10 +2,12 @@ package grants
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
"git.novox.be/novox/mesh-sdk/go/buscompose"
|
||||
@@ -91,11 +93,12 @@ 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, lost) != "":
|
||||
case next.Follow == delivery.FollowGrants && cur.Digest != digest && p.holdsBack(in, leftOut, bus, lost, digest) != "":
|
||||
// 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, lost)
|
||||
why = p.holdsBack(in, leftOut, bus, lost, digest)
|
||||
held = why
|
||||
p.keepHashHold(res, digest)
|
||||
case cur.Digest != digest || cur.SealedTo != delivery.SealingKeyID(key) || cur.Node != in.BusNode:
|
||||
object, err := delivery.SealUserList(list, key)
|
||||
if err == nil {
|
||||
@@ -133,7 +136,8 @@ func (p *Part) writeSealed(res *Result, list []byte, users int, leftOut []string
|
||||
return
|
||||
}
|
||||
if next.Digest == digest && cur.Digest != digest {
|
||||
p.keepWrittenUsers(res, list)
|
||||
p.keepWrittenUsers(res, list, in.Hashes)
|
||||
p.acceptedWritten(res, digest)
|
||||
}
|
||||
// The object the key named before is taken away once the key names the new one: the bucket keeps the list in force.
|
||||
if had && cur.Object != "" && cur.Object != next.Object {
|
||||
@@ -279,11 +283,50 @@ func (p *Part) followReasons(res Result, in Inputs) []string {
|
||||
// composed after take-over must keep.
|
||||
const KeyWrittenUsers = "sealed-users"
|
||||
|
||||
func (p *Part) keepWrittenUsers(res *Result, list []byte) {
|
||||
data, _ := json.Marshal(listUsers(list))
|
||||
// KeyWrittenCredentials is the key of the state user-list naming, per user of the list in force, which password its hash
|
||||
// is of (credentialOf: adopted, or the vault's generation and fingerprint), never the hash: what tells a list that changes
|
||||
// several loaded users' hashes at once (ADR 0561 §7).
|
||||
const KeyWrittenCredentials = "sealed-credentials"
|
||||
|
||||
func (p *Part) keepWrittenUsers(res *Result, list []byte, hashes map[string]HeldHash) {
|
||||
users := listUsers(list)
|
||||
data, _ := json.Marshal(users)
|
||||
if _, err := p.Store.Put(StateUserList, KeyWrittenUsers, data); err != nil {
|
||||
res.Errors = append(res.Errors, fmt.Sprintf("its own %s %s cannot be written: %v", StateUserList, KeyWrittenUsers, err))
|
||||
}
|
||||
creds := map[string]string{}
|
||||
for _, u := range users {
|
||||
if h, ok := hashes[u]; ok {
|
||||
creds[u] = credentialOf(h)
|
||||
}
|
||||
}
|
||||
data, _ = json.Marshal(creds)
|
||||
if _, err := p.Store.Put(StateUserList, KeyWrittenCredentials, data); err != nil {
|
||||
res.Errors = append(res.Errors, fmt.Sprintf("its own %s %s cannot be written: %v", StateUserList, KeyWrittenCredentials, err))
|
||||
}
|
||||
}
|
||||
|
||||
// changedHashes are the users of the list in force whose hash the list composed now would change with no rotation by the
|
||||
// vault naming them, sorted; none when the part kept no credentials of the list in force (a list written before).
|
||||
func (p *Part) changedHashes(hashes map[string]HeldHash) ([]string, error) {
|
||||
raw, _, found, err := p.Store.Get(StateUserList, KeyWrittenCredentials)
|
||||
if err != nil || !found {
|
||||
return nil, err
|
||||
}
|
||||
var had map[string]string
|
||||
if err := json.Unmarshal(raw, &had); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var changed []string
|
||||
for user, was := range had {
|
||||
h, ok := hashes[user]
|
||||
if !ok || credentialOf(h) == was || h.Rotating != nil {
|
||||
continue
|
||||
}
|
||||
changed = append(changed, user)
|
||||
}
|
||||
slices.Sort(changed)
|
||||
return changed, nil
|
||||
}
|
||||
|
||||
var listUser = regexp.MustCompile(`\{\s*user:\s*"([^"]+)"`)
|
||||
@@ -303,7 +346,8 @@ func listUsers(list []byte) []string {
|
||||
// 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, lost []string) string {
|
||||
func (p *Part) holdsBack(in Inputs, leftOut, bus, lost []string, digest string) string {
|
||||
p.hashHold = nil
|
||||
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, "; ") + ")"
|
||||
}
|
||||
@@ -333,5 +377,127 @@ func (p *Part) holdsBack(in Inputs, leftOut, bus, lost []string) string {
|
||||
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"
|
||||
}
|
||||
// More than one loaded user's hash changing at once, with no rotation by the vault naming each (ADR 0561 §7): a new
|
||||
// writer of hashes must not lock several holders out in one list, as nine seats were cut on 2026-10-27 at 09:21.
|
||||
changed, err := p.changedHashes(in.Hashes)
|
||||
if err != nil {
|
||||
return "held: the credentials of the list in force cannot be read: " + err.Error()
|
||||
}
|
||||
if len(changed) > 1 && !p.accepted(digest, changed) {
|
||||
p.hashHold = changed
|
||||
return "held: the list in force is kept, since the hashes of " + fmt.Sprint(len(changed)) + " loaded users would change at " +
|
||||
"once with no rotation by the vault naming them: " + strings.Join(changed, ", ")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// **A person accepts a held list's hash changes** (ADR 0561 §7, §9; tools-are-how-a-module-is-spoken-to): the hold of
|
||||
// several loaded users' hashes changing at once is released only by grants_accept_hashes, naming exactly the users held,
|
||||
// with why, bound to the held list's digest. The acceptance releases this guard alone, once: the other holds (a failed
|
||||
// read, a user dropped for want of a hash, a lost right) stand whatever it says; and a list composed otherwise since is
|
||||
// held again, for a new acceptance.
|
||||
|
||||
// KeyHashHold names the list held for its hash changes (its digest and the users), and KeyHashAccept a person's acceptance.
|
||||
const (
|
||||
KeyHashHold = "hash-hold"
|
||||
KeyHashAccept = "hash-accept"
|
||||
)
|
||||
|
||||
// EventHashesAccepted is said when a list held for its hash changes is written on a person's acceptance: a hand act.
|
||||
const EventHashesAccepted = "hashes-accepted"
|
||||
|
||||
// HashHold is the value of KeyHashHold, and HashAccept of KeyHashAccept.
|
||||
type HashHold struct {
|
||||
Digest string `json:"digest"`
|
||||
Users []string `json:"users"`
|
||||
At time.Time `json:"at"`
|
||||
}
|
||||
|
||||
type HashAccept struct {
|
||||
Digest string `json:"digest"`
|
||||
Users []string `json:"users"`
|
||||
By string `json:"by"`
|
||||
Why string `json:"why"`
|
||||
At time.Time `json:"at"`
|
||||
}
|
||||
|
||||
// keepHashHold writes which list is held for its hash changes, when that is the hold.
|
||||
func (p *Part) keepHashHold(res *Result, digest string) {
|
||||
if p.hashHold == nil {
|
||||
return
|
||||
}
|
||||
data, _ := json.Marshal(HashHold{Digest: digest, Users: p.hashHold, At: p.now()})
|
||||
if raw, _, found, err := p.Store.Get(StateUserList, KeyHashHold); err == nil && found {
|
||||
var was HashHold
|
||||
if json.Unmarshal(raw, &was) == nil && was.Digest == digest && slices.Equal(was.Users, p.hashHold) {
|
||||
return
|
||||
}
|
||||
}
|
||||
if _, err := p.Store.Put(StateUserList, KeyHashHold, data); err != nil {
|
||||
res.Errors = append(res.Errors, fmt.Sprintf("its own %s %s cannot be written: %v", StateUserList, KeyHashHold, err))
|
||||
}
|
||||
}
|
||||
|
||||
// accepted says a person accepted exactly these users' hash changes of the list of this digest.
|
||||
func (p *Part) accepted(digest string, users []string) bool {
|
||||
raw, _, found, err := p.Store.Get(StateUserList, KeyHashAccept)
|
||||
if err != nil || !found {
|
||||
return false
|
||||
}
|
||||
var a HashAccept
|
||||
return json.Unmarshal(raw, &a) == nil && a.Digest == digest && slices.Equal(a.Users, users)
|
||||
}
|
||||
|
||||
// acceptedWritten says, once, that the list a person accepted is written, and forgets the acceptance and the hold.
|
||||
func (p *Part) acceptedWritten(res *Result, digest string) {
|
||||
raw, _, found, err := p.Store.Get(StateUserList, KeyHashAccept)
|
||||
if err != nil || !found {
|
||||
return
|
||||
}
|
||||
var a HashAccept
|
||||
if json.Unmarshal(raw, &a) != nil || a.Digest != digest {
|
||||
return
|
||||
}
|
||||
p.say(res, EventHashesAccepted, map[string]any{"digest": digest, "users": a.Users, "by": a.By, "why": a.Why})
|
||||
for _, k := range []string{KeyHashAccept, KeyHashHold} {
|
||||
if err := p.Store.Delete(StateUserList, k); err != nil {
|
||||
res.Errors = append(res.Errors, fmt.Sprintf("its own %s %s cannot be taken away: %v", StateUserList, k, err))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// AcceptHashes is the tool accept-hashes: a person accepts the hash changes of the list held for them, naming exactly its
|
||||
// users, with why. Refused when no list is held for its hashes, or for another set of users. Bound to the held list's
|
||||
// digest: a list composed otherwise since is held again.
|
||||
func (p *Part) AcceptHashes(caller string, users []string, why string) (any, error) {
|
||||
if strings.TrimSpace(why) == "" {
|
||||
return nil, errors.New("accept-hashes says why (`why`)")
|
||||
}
|
||||
raw, _, found, err := p.Store.Get(StateUserList, KeyHashHold)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the held list cannot be read: %w", err)
|
||||
}
|
||||
var h HashHold
|
||||
if !found || json.Unmarshal(raw, &h) != nil || h.Digest == "" {
|
||||
return nil, errors.New("no list is held for its users' hashes changing: there is nothing to accept")
|
||||
}
|
||||
named := slices.Sorted(slices.Values(users))
|
||||
if !slices.Equal(named, h.Users) {
|
||||
return nil, fmt.Errorf("the list held (%s) changes the hashes of %s: an acceptance names exactly those, and %s is not that",
|
||||
short(h.Digest), strings.Join(h.Users, ", "), strings.Join(named, ", "))
|
||||
}
|
||||
data, _ := json.Marshal(HashAccept{Digest: h.Digest, Users: h.Users, By: someoneOr(caller), Why: strings.TrimSpace(why), At: p.now()})
|
||||
rev, err := p.Store.Put(StateUserList, KeyHashAccept, data)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the acceptance could not be written: %w", err)
|
||||
}
|
||||
return map[string]any{"accepted": rev, "digest": h.Digest, "users": h.Users, "says": "the part's process writes the held " +
|
||||
"list at its next pass, once, unless another hold stands or the list composed then is another; it says hashes-accepted"}, nil
|
||||
}
|
||||
|
||||
func someoneOr(caller string) string {
|
||||
if caller == "" {
|
||||
return "a caller the runtime did not prove"
|
||||
}
|
||||
return caller
|
||||
}
|
||||
|
||||
@@ -21,10 +21,14 @@ const (
|
||||
ToolTakeOver = "grants_take_over"
|
||||
ToolTakeOverState = "grants_take_over_state"
|
||||
ToolHandBack = "grants_hand_back"
|
||||
ToolAccounts = "grants_accounts"
|
||||
ToolConsumerMatch = "grants_consumer_match"
|
||||
ToolAcceptHashes = "grants_accept_hashes"
|
||||
)
|
||||
|
||||
// Tools is every tool, in the manifest's order.
|
||||
var Tools = []string{ToolGrants, ToolMembership, ToolUserList, ToolComposeNow, ToolAssert, ToolTakeOver, ToolHandBack, ToolTakeOverState}
|
||||
var Tools = []string{ToolGrants, ToolMembership, ToolUserList, ToolComposeNow, ToolAssert, ToolTakeOver, ToolHandBack, ToolTakeOverState,
|
||||
ToolAccounts, ToolConsumerMatch, ToolAcceptHashes}
|
||||
|
||||
// WritesNoGrant is what grants says of the part's own grants in this milestone.
|
||||
const WritesNoGrant = "none: the grants part writes no grants.<module>.<fp> before ADR 0547's third milestone (clause 5); the controller's are beside"
|
||||
|
||||
@@ -12,11 +12,13 @@
|
||||
"mesh-delivery-indexer.modules",
|
||||
"mesh-delivery-indexer.placement",
|
||||
"node-engine.have",
|
||||
"mesh-vault.bus-accounts"
|
||||
"mesh-vault.bus-accounts",
|
||||
"mesh-controller.lease"
|
||||
],
|
||||
"consumes": [
|
||||
"mesh-controller.condition-raised",
|
||||
"mesh-controller.condition-cleared"
|
||||
"mesh-controller.condition-cleared",
|
||||
"mesh-vault.account-issued"
|
||||
],
|
||||
"uses": [
|
||||
"operator-channel"
|
||||
@@ -29,6 +31,7 @@
|
||||
],
|
||||
"emits": [
|
||||
"asserted",
|
||||
"hashes-accepted",
|
||||
"membership-changed",
|
||||
"user-list-changed"
|
||||
],
|
||||
@@ -40,9 +43,16 @@
|
||||
"grants_assert",
|
||||
"grants_take_over",
|
||||
"grants_take_over_state",
|
||||
"grants_hand_back"
|
||||
"grants_hand_back",
|
||||
"grants_accounts",
|
||||
"grants_consumer_match",
|
||||
"grants_accept_hashes"
|
||||
],
|
||||
"state": [
|
||||
{
|
||||
"name": "accounts",
|
||||
"history": 5
|
||||
},
|
||||
{
|
||||
"name": "assertions",
|
||||
"history": 5
|
||||
|
||||
Reference in New Issue
Block a user