mesh/delivery-group group adr-0307-state-history delivered: every member is delivered
mesh/delivery delivered
mesh/merge-gate pass: builds mesh-tools, node-tools → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did without (4 of …
mesh/repo-check pass: THE CHANGE ALTERS ITS OWN CHECK (merge-check.sh): main's version judged it; the change's judges the pull requests after it merges; it…
mesh/state.history answers every value a bucket still keeps for a key, oldest first, to whoever may get it, by the same watch the runtime already uses, so no new grant is needed.
402 lines
12 KiB
Go
402 lines
12 KiB
Go
package bus
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/nats-io/nats.go/jetstream"
|
|
)
|
|
|
|
// A module's state on the bus (novox/hq ADR 0201): key-value buckets the controller creates from what
|
|
// the module declared, and issues to each assignment in its membership by the name the module uses —
|
|
// its own state by the local name, another module's as `<module>.<name>`.
|
|
//
|
|
// **The runtime keeps each module to its own buckets.** One account per machine carries every module
|
|
// on it, so the bus enforces only the union; and a write the bus refuses reaches the writer as a
|
|
// timeout, not a refusal (measured, novox/hq research 024). So what a module may reach is decided here,
|
|
// from its membership, and refused with the reason before anything is sent.
|
|
|
|
// StateIssued is one bucket an assignment may reach (ADR 0201).
|
|
type StateIssued struct {
|
|
Name string `json:"name"`
|
|
Bucket string `json:"bucket"`
|
|
Writes bool `json:"writes,omitempty"`
|
|
}
|
|
|
|
// StateEntry is one key's current value.
|
|
type StateEntry struct {
|
|
Key string `json:"key"`
|
|
Value json.RawMessage `json:"value"`
|
|
Revision uint64 `json:"revision"`
|
|
}
|
|
|
|
// StateChange is one change a watch delivers: a key put or deleted.
|
|
type StateChange struct {
|
|
State string `json:"state"`
|
|
Key string `json:"key"`
|
|
Op string `json:"op"`
|
|
Value json.RawMessage `json:"value,omitempty"`
|
|
Revision uint64 `json:"revision"`
|
|
// Current is true for a value that was there when the watch began, false for a change since.
|
|
Current bool `json:"current"`
|
|
}
|
|
|
|
// StateTimeout bounds one state operation on the bus.
|
|
var StateTimeout = 10 * time.Second
|
|
|
|
// stateKey is a key the bus can hold: letters, digits and `-/_=.`, no leading or trailing dot. A
|
|
// module's convention for naming a machine in a key (`all.<server>`, `<machine>.<server>`) fits it.
|
|
var stateKey = regexp.MustCompile(`^[-/_=a-zA-Z0-9]+(\.[-/_=a-zA-Z0-9]+)*$`)
|
|
|
|
// issuedState is the bucket a module may reach by a name, and whether it may write it — or the reason
|
|
// it may not reach it at all.
|
|
func (c *Conn) issuedState(module, name string) (StateIssued, error) {
|
|
m := c.Membership(module)
|
|
if m == nil {
|
|
return StateIssued{}, fmt.Errorf("%s has no membership issued on %s yet, so no state of it is reachable "+
|
|
"until the mesh issues one (novox/hq ADR 0201)", module, c.node)
|
|
}
|
|
var names []string
|
|
for _, s := range m.State {
|
|
if s.Name == name {
|
|
return s, nil
|
|
}
|
|
names = append(names, s.Name)
|
|
}
|
|
sort.Strings(names)
|
|
issued := "none"
|
|
if len(names) > 0 {
|
|
issued = strings.Join(names, ", ")
|
|
}
|
|
return StateIssued{}, fmt.Errorf("%s keeps and reads no state called %q: it declares what it keeps under "+
|
|
"`state` and what it reads under `reads` as <module>.<name>, and was issued: %s (novox/hq ADR 0201)",
|
|
module, name, issued)
|
|
}
|
|
|
|
func (c *Conn) bucket(ctx context.Context, s StateIssued) (jetstream.KeyValue, error) {
|
|
js, err := jetstream.New(c.nc)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
kv, err := js.KeyValue(ctx, s.Bucket)
|
|
if err != nil {
|
|
if errors.Is(err, jetstream.ErrBucketNotFound) {
|
|
return nil, fmt.Errorf("the state %q is issued and its bucket is not on the bus yet: the controller "+
|
|
"creates it from the catalogue on its next raise", s.Name)
|
|
}
|
|
return nil, err
|
|
}
|
|
return kv, nil
|
|
}
|
|
|
|
func checkKey(key string) error {
|
|
if !stateKey.MatchString(key) {
|
|
return fmt.Errorf("%q is not a key the bus can hold: letters, digits and -/_=, in dot-separated "+
|
|
"names", key)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// StateGet is one key's current value, or nil when it has none.
|
|
func (c *Conn) StateGet(module, name, key string) (*StateEntry, error) {
|
|
s, err := c.issuedState(module, name)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := checkKey(key); err != nil {
|
|
return nil, err
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), StateTimeout)
|
|
defer cancel()
|
|
kv, err := c.bucket(ctx, s)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
e, err := kv.Get(ctx, key)
|
|
if errors.Is(err, jetstream.ErrKeyNotFound) {
|
|
return nil, nil
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &StateEntry{Key: e.Key(), Value: valueOf(e.Value()), Revision: e.Revision()}, nil
|
|
}
|
|
|
|
// StateKept is one value a bucket still keeps for a key: a put, or a delete (a purge reads as one).
|
|
type StateKept struct {
|
|
Key string `json:"key"`
|
|
Value json.RawMessage `json:"value,omitempty"`
|
|
Revision uint64 `json:"revision"`
|
|
Op string `json:"op"`
|
|
At time.Time `json:"at"`
|
|
}
|
|
|
|
// StateHistory is every value the bucket still keeps for one key, oldest first, or none (novox/hq ADR
|
|
// 0307). It is a read like StateGet: served to the owner and to every reader, and writes nothing.
|
|
func (c *Conn) StateHistory(module, name, key string) ([]StateKept, error) {
|
|
s, err := c.issuedState(module, name)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := checkKey(key); err != nil {
|
|
return nil, err
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), StateTimeout)
|
|
defer cancel()
|
|
kv, err := c.bucket(ctx, s)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
entries, err := kv.History(ctx, key)
|
|
kept := []StateKept{}
|
|
if errors.Is(err, jetstream.ErrKeyNotFound) {
|
|
return kept, nil
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, e := range entries {
|
|
k := StateKept{Key: e.Key(), Revision: e.Revision(), Op: "delete", At: e.Created().UTC()}
|
|
if e.Operation() == jetstream.KeyValuePut {
|
|
k.Op = "put"
|
|
k.Value = valueOf(e.Value())
|
|
}
|
|
kept = append(kept, k)
|
|
}
|
|
return kept, nil
|
|
}
|
|
|
|
// StatePut writes one key, as the module, where the module keeps the state. It answers the revision.
|
|
func (c *Conn) StatePut(module, name, key string, value json.RawMessage) (uint64, error) {
|
|
s, err := c.writable(module, name)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
if err := checkKey(key); err != nil {
|
|
return 0, err
|
|
}
|
|
if len(value) == 0 || !json.Valid(value) {
|
|
return 0, fmt.Errorf("a state value is JSON")
|
|
}
|
|
if field := credentialField(value); field != "" {
|
|
return 0, fmt.Errorf("%s's %s.%s carries a field %q, which names a credential: no secret is kept in state, "+
|
|
"sealed or not — a bucket is a stream, and a machine joining a year later reads it whole. Name the "+
|
|
"secret and fetch it on request/reply (novox/hq ADR 0201, design 32 §10)", module, name, key, field)
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), StateTimeout)
|
|
defer cancel()
|
|
kv, err := c.bucket(ctx, s)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return kv.Put(ctx, key, value)
|
|
}
|
|
|
|
// StateDelete removes one key, as the module, where the module keeps the state. A key that was not
|
|
// there is not an error: what is asked for is that it is gone.
|
|
func (c *Conn) StateDelete(module, name, key string) error {
|
|
s, err := c.writable(module, name)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := checkKey(key); err != nil {
|
|
return err
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), StateTimeout)
|
|
defer cancel()
|
|
kv, err := c.bucket(ctx, s)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return kv.Delete(ctx, key)
|
|
}
|
|
|
|
// StateKeys is every key with a current value, sorted.
|
|
func (c *Conn) StateKeys(module, name string) ([]string, error) {
|
|
s, err := c.issuedState(module, name)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), StateTimeout)
|
|
defer cancel()
|
|
kv, err := c.bucket(ctx, s)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
lister, err := kv.ListKeys(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer func() { _ = lister.Stop() }()
|
|
keys := []string{}
|
|
for k := range lister.Keys() {
|
|
keys = append(keys, k)
|
|
}
|
|
sort.Strings(keys)
|
|
return keys, nil
|
|
}
|
|
|
|
func (c *Conn) writable(module, name string) (StateIssued, error) {
|
|
s, err := c.issuedState(module, name)
|
|
if err != nil {
|
|
return s, err
|
|
}
|
|
if !s.Writes {
|
|
owner := name
|
|
if dot := strings.LastIndex(name, "."); dot > 0 {
|
|
owner = name[:dot]
|
|
}
|
|
return s, fmt.Errorf("%s reads %s and does not keep it: only %s's own instances write it (novox/hq ADR 0201)",
|
|
module, name, owner)
|
|
}
|
|
return s, nil
|
|
}
|
|
|
|
// StateWatch hands deliver the current value of every key matching the pattern — none that is
|
|
// deleted — and then every change, in order (ADR 0201). It returns once the current values are
|
|
// delivered; deliver is called from one goroutine, one change at a time, and an error from it is said
|
|
// and the watch goes on: state is not a queue, and the next change, or the next start, reads it again.
|
|
// The pattern is a key, with `*` for one name and `**` for the rest; empty is every key.
|
|
func (c *Conn) StateWatch(module, name, pattern string, deliver func(StateChange) error) (stop func(), err error) {
|
|
s, err := c.issuedState(module, name)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
filter := ">"
|
|
if pattern != "" {
|
|
parts := strings.Split(pattern, ".")
|
|
for i, p := range parts {
|
|
if p == "**" {
|
|
if i != len(parts)-1 {
|
|
return nil, fmt.Errorf("%q: `**` stands for the rest of a key, so it comes last", pattern)
|
|
}
|
|
parts[i] = ">"
|
|
continue
|
|
}
|
|
if p != "*" && !stateKey.MatchString(p) {
|
|
return nil, fmt.Errorf("%q is not a key pattern: names, `*` for one and `**` for the rest", pattern)
|
|
}
|
|
}
|
|
filter = strings.Join(parts, ".")
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), StateTimeout)
|
|
kv, err := c.bucket(ctx, s)
|
|
cancel()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
watching, stopWatching := context.WithCancel(context.Background())
|
|
w, err := kv.Watch(watching, filter)
|
|
if err != nil {
|
|
stopWatching()
|
|
return nil, err
|
|
}
|
|
var once sync.Once
|
|
stop = func() {
|
|
once.Do(func() {
|
|
_ = w.Stop()
|
|
stopWatching()
|
|
})
|
|
}
|
|
current := make(chan struct{})
|
|
go func() {
|
|
initial := true
|
|
for e := range w.Updates() {
|
|
if e == nil {
|
|
// The end of what was there when the watch began.
|
|
if initial {
|
|
initial = false
|
|
close(current)
|
|
}
|
|
continue
|
|
}
|
|
change := StateChange{State: name, Key: e.Key(), Revision: e.Revision(), Current: initial}
|
|
switch e.Operation() {
|
|
case jetstream.KeyValuePut:
|
|
change.Op = "put"
|
|
change.Value = valueOf(e.Value())
|
|
default:
|
|
// A deletion among the current values is a key that is not there: not handed over
|
|
// (measured, research 024 — the server sends its marker among the initial values).
|
|
if initial {
|
|
continue
|
|
}
|
|
change.Op = "delete"
|
|
}
|
|
if err := deliver(change); err != nil {
|
|
c.Logf("[mesh-tools] %s did not take %s %s of %s: %v; its next change, or its next start, reads it again",
|
|
module, change.Op, change.Key, name, err)
|
|
}
|
|
}
|
|
if initial {
|
|
close(current)
|
|
}
|
|
}()
|
|
select {
|
|
case <-current:
|
|
return stop, nil
|
|
case <-time.After(StateTimeout):
|
|
stop()
|
|
return nil, fmt.Errorf("the current values of %s did not arrive in %s", name, StateTimeout)
|
|
}
|
|
}
|
|
|
|
func valueOf(raw []byte) json.RawMessage {
|
|
if len(raw) == 0 || !json.Valid(raw) {
|
|
b, _ := json.Marshal(string(raw))
|
|
return b
|
|
}
|
|
return json.RawMessage(raw)
|
|
}
|
|
|
|
// credentialEndings are the ends of a field name that say its value is a credential. A guard against
|
|
// the ordinary mistake, not a determined one: a sealed value is plain text to anything inspecting it,
|
|
// so the rule is checked where it can be and said to be partial (ADR 0201).
|
|
var credentialEndings = []string{"password", "passwd", "secret", "token", "credential", "credentials",
|
|
"authorization", "apikey", "privatekey", "accesskey", "cookie"}
|
|
|
|
// credentialField is the first field anywhere in a JSON value whose name says it is a credential, or "".
|
|
func credentialField(value json.RawMessage) string {
|
|
var v any
|
|
if json.Unmarshal(value, &v) != nil {
|
|
return ""
|
|
}
|
|
var walk func(any) string
|
|
walk = func(v any) string {
|
|
switch t := v.(type) {
|
|
case map[string]any:
|
|
keys := make([]string, 0, len(t))
|
|
for k := range t {
|
|
keys = append(keys, k)
|
|
}
|
|
sort.Strings(keys)
|
|
for _, k := range keys {
|
|
norm := strings.NewReplacer("-", "", "_", "", " ", "").Replace(strings.ToLower(k))
|
|
for _, end := range credentialEndings {
|
|
if strings.HasSuffix(norm, end) {
|
|
return k
|
|
}
|
|
}
|
|
if found := walk(t[k]); found != "" {
|
|
return found
|
|
}
|
|
}
|
|
case []any:
|
|
for _, x := range t {
|
|
if found := walk(x); found != "" {
|
|
return found
|
|
}
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
return walk(v)
|
|
}
|