Merge pull request 'The vault the only issuer, placement read per node and module, hand acts copied once, routes only from the provisioner's plans (issue 774)' (#579) from feat/072-moves into main
This commit was merged in pull request #579.
This commit is contained in:
@@ -1,6 +1,12 @@
|
||||
// Package credentialform refuses an offer that does not say the form of its credential: an own secret, an issuer, or
|
||||
// `form` pair or none (the lead, 2026-10-29). It reads every modules/*/module.json through mesh-sdk's
|
||||
// CredentialFormProblems.
|
||||
//
|
||||
// It refuses a route received as a file too (issue 774, research 072 row 34, the operator on 2026-10-31: "there is only
|
||||
// 1 path, the other is simply deprecated/invalid"): a route is a provision, served from the seat mesh-provisioner's
|
||||
// plans alone, and a manifest's `receives.route` is the deprecated path. A module still moving off it is listed in
|
||||
// stillReceivingRoutes with why; every refusal names them all, and a listed module that no longer receives one is
|
||||
// refused too, so the list only shrinks.
|
||||
package credentialform
|
||||
|
||||
import (
|
||||
@@ -9,6 +15,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
@@ -21,6 +28,7 @@ func Problems(root string) ([]string, error) {
|
||||
}
|
||||
sort.Strings(files)
|
||||
var out []string
|
||||
var receiving []string
|
||||
reads := map[string]bool{}
|
||||
var binders []string
|
||||
for _, f := range files {
|
||||
@@ -33,6 +41,11 @@ func Problems(root string) ([]string, error) {
|
||||
return nil, fmt.Errorf("%s: %w", f, err)
|
||||
}
|
||||
out = append(out, catalogue.CredentialFormProblems(m)...)
|
||||
if _, received := m.Receives["route"]; received {
|
||||
receiving = append(receiving, m.Module)
|
||||
} else if _, listed := stillReceivingRoutes[m.Module]; listed {
|
||||
out = append(out, fmt.Sprintf("%s no longer receives route; take it off stillReceivingRoutes", m.Module))
|
||||
}
|
||||
var kept struct {
|
||||
Reads []string `json:"reads"`
|
||||
State []json.RawMessage `json:"state"`
|
||||
@@ -52,9 +65,22 @@ func Problems(root string) ([]string, error) {
|
||||
out = append(out, fmt.Sprintf("%s provides and keeps a `bound` state, and mesh-provisioner's manifest does not read %s.bound", module, module))
|
||||
}
|
||||
}
|
||||
for _, module := range receiving {
|
||||
if _, listed := stillReceivingRoutes[module]; !listed {
|
||||
out = append(out, fmt.Sprintf("%s receives route as a file: a route is a provision, served from the seat "+
|
||||
"mesh-provisioner's plans alone (issue 774); still on the deprecated path: %s", module,
|
||||
strings.Join(receiving, ", ")))
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// stillReceivingRoutes is every module still receiving its routes as a file, and why it has not moved yet.
|
||||
var stillReceivingRoutes = map[string]string{
|
||||
"route-adapter": "migration scaffolding writing into a predecessor's proxy (novox/hq ADR 0104), deleted when that " +
|
||||
"migration ends; it reads the received file its TypeScript adapter turns into the predecessor's route files",
|
||||
}
|
||||
|
||||
// keepsBound says whether a manifest's `state` names `bound`, as a plain name or an object with a name.
|
||||
func keepsBound(state []json.RawMessage) bool {
|
||||
for _, s := range state {
|
||||
|
||||
@@ -22,22 +22,27 @@ func aCatalogue(t *testing.T, manifests map[string]string) string {
|
||||
return root
|
||||
}
|
||||
|
||||
// An offer that says no form is refused and named; one that says pair, none or an own secret passes; and the real
|
||||
// catalogue is clean.
|
||||
// An offer that says no form is refused and named; one that says pair, none or an own secret passes; a route received
|
||||
// as a file is refused but for the module still moving off it; and the real catalogue is clean.
|
||||
func TestEveryOfferSaysTheFormOfItsCredential(t *testing.T) {
|
||||
root := aCatalogue(t, map[string]string{
|
||||
"silent": `{"module":"silent","provides":[{"name":"thing","scope":"mesh"}]}`,
|
||||
"binder": `{"module":"binder","provides":[{"name":"d","credential":{"form":"pair"}}],"state":["bound"]}`,
|
||||
"mesh-provisioner": `{"module":"mesh-provisioner","reads":["other.bound"]}`,
|
||||
"says": `{"module":"says","provides":[{"name":"a","credential":{"form":"pair"}},{"name":"b","credential":{"form":"none"}},{"name":"c","credential":{"own":"jwt"}}]}`,
|
||||
// A route received as a file (issue 774, research 072 row 34): refused, naming every module still on it; the one
|
||||
// listed as still moving passes.
|
||||
"old-proxy": `{"module":"old-proxy","receives":{"route":"/var/lib/old-proxy/mesh.json"}}`,
|
||||
"route-adapter": `{"module":"route-adapter","receives":{"route":"/var/lib/route-adapter/mesh.json"}}`,
|
||||
})
|
||||
got, err := Problems(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 2 || !strings.Contains(got[0], `silent provides "thing"`) ||
|
||||
!strings.Contains(got[1], "binder") || !strings.Contains(got[1], "mesh-provisioner") {
|
||||
t.Fatalf("refused %v, wanted the silent offer and the binder the provisioner does not read", got)
|
||||
if len(got) != 3 || !strings.Contains(got[0], `silent provides "thing"`) ||
|
||||
!strings.Contains(got[1], "binder") || !strings.Contains(got[1], "mesh-provisioner") ||
|
||||
!strings.HasPrefix(got[2], "old-proxy receives route") || !strings.Contains(got[2], "old-proxy, route-adapter") {
|
||||
t.Fatalf("refused %v, wanted the silent offer, the binder the provisioner does not read and the received route", got)
|
||||
}
|
||||
real, err := Problems(filepath.Join("..", ".."))
|
||||
if err != nil {
|
||||
|
||||
@@ -2,4 +2,4 @@ module checks/health
|
||||
|
||||
go 1.26.0
|
||||
|
||||
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030001116-2da0de75c3f0
|
||||
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261031084708-acbf80ad910a
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030001116-2da0de75c3f0 h1:lKU3HjNXGiVbLW2hUgFjU/TMTUYMlNnnmZxG5T7biQA=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030001116-2da0de75c3f0/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261031084708-acbf80ad910a h1:DcSvfEmdPBKqCg5OmXRNb269EkkPe7BsEp+RykEhkgE=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261031084708-acbf80ad910a/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
|
||||
@@ -2,4 +2,4 @@ module checks/manifests
|
||||
|
||||
go 1.26.0
|
||||
|
||||
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030181247-4c1384faefa0
|
||||
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261031084708-acbf80ad910a
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030181247-4c1384faefa0 h1:t04baH2Xz/NUsOEV3ctKY59XjiY5WmEuststvH+XeOs=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030181247-4c1384faefa0/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261031084708-acbf80ad910a h1:DcSvfEmdPBKqCg5OmXRNb269EkkPe7BsEp+RykEhkgE=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261031084708-acbf80ad910a/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
|
||||
@@ -216,26 +216,26 @@ func (k *Keeper) Have(key string, value []byte, deleted bool) error {
|
||||
return k.judge(node, module, "")
|
||||
}
|
||||
|
||||
// Placement takes one change of the placement: which module holds which seat on a node.
|
||||
// Placement takes one change of the placement: which module holds which seat on a node. The keys are read in either
|
||||
// form, a node key and its module keys or the form before (delivery.PlacementKeys; research 072 row 37).
|
||||
func (k *Keeper) Placement(key string, value []byte, deleted bool) {
|
||||
node, err := delivery.ParsePlacementKey(key)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
k.mu.Lock()
|
||||
defer k.mu.Unlock()
|
||||
k.init()
|
||||
node, err := k.placement.Put(key, value, 0, deleted)
|
||||
if node == "" {
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
k.Logf("the placement of %s is not readable as it stands, so its seats' holders are kept as last read: %v", node, err)
|
||||
}
|
||||
for hk := range k.holders {
|
||||
if strings.HasPrefix(hk, node+".") {
|
||||
delete(k.holders, hk)
|
||||
}
|
||||
}
|
||||
if deleted {
|
||||
return
|
||||
}
|
||||
p, err := delivery.DecodePlacement(value)
|
||||
if err != nil {
|
||||
k.Logf("the placement of %s is not readable, so its seats' holders are not known: %v", node, err)
|
||||
p, _, ok := k.placement.Of(node)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
for _, h := range p.Holds {
|
||||
|
||||
@@ -113,12 +113,19 @@ func TestADeliveryHoldsItsOwnFindingsUntilItEnds(t *testing.T) {
|
||||
// A seat's finding is about the module the placement says holds the seat on that node (the lead, 2026-10-29): held
|
||||
// while the assigner is delivered on ace, though the seat is named mesh-placement.
|
||||
r := newRig()
|
||||
placed, err := delivery.EncodePlacement(delivery.Placement{Node: "ace", Modules: []delivery.PlacedModule{{Module: "mesh-delivery-assigner"}},
|
||||
// Research 072 row 37: the placement in its keys per module and node; a stale legacy key beside them is not read.
|
||||
stale, err := delivery.EncodePlacement(delivery.Placement{Node: "ace"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r.k.Placement("placement.ace", stale, false)
|
||||
placed, err := delivery.PlacementValues(delivery.Placement{Node: "ace", Modules: []delivery.PlacedModule{{Module: "mesh-delivery-assigner"}},
|
||||
Holds: []delivery.Holding{{Claim: "mesh-placement", Scope: "mesh", Module: "mesh-delivery-assigner"}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r.k.Placement("placement.ace", placed, false)
|
||||
r.k.Placement("ace.mesh-delivery-assigner", placed["ace.mesh-delivery-assigner"], false)
|
||||
r.k.Placement("ace.node", placed["ace.node"], false)
|
||||
_ = r.k.Want("want.ace.mesh-delivery-assigner", want(ctrlNext, ctrlNextComp), false)
|
||||
_ = r.k.Have("ace.mesh-delivery-assigner", have(ctrlRunning, ctrlComposed, delivery.PhaseRunning, delivery.HealthOK), false)
|
||||
seat, seatRaw := finding(t, selfCheck, health.ScopeSeat, "mesh-placement.ace", "holder-silent", "D3", health.Warning, r.now)
|
||||
|
||||
@@ -112,8 +112,10 @@ type Keeper struct {
|
||||
haves map[string]delivery.Have
|
||||
// holders are, by `<node>.<seat>`, the module the placement says holds the seat there.
|
||||
holders map[string]string
|
||||
stuck map[string]string
|
||||
held map[string]held
|
||||
// placement is the placement's keys as heard, assembled per node.
|
||||
placement *delivery.PlacementKeys
|
||||
stuck map[string]string
|
||||
held map[string]held
|
||||
// passed are the deliveries whose bound passed, by node and module, at the fingerprint wanted then; outOfHold the
|
||||
// line a key raised out of a hold says (hold.go).
|
||||
passed map[string]string
|
||||
@@ -130,6 +132,7 @@ func (k *Keeper) init() {
|
||||
map[string]string{}, map[string]held{}, map[string]clearedMemo{}
|
||||
k.takeover = health.Takeover{V: health.TakeoverVersion, Follow: health.FollowController}
|
||||
k.passed, k.outOfHold, k.holders = map[string]string{}, map[string]string{}, map[string]string{}
|
||||
k.placement = delivery.NewPlacementKeys()
|
||||
}
|
||||
if k.Now == nil {
|
||||
k.Now = time.Now
|
||||
|
||||
@@ -3,7 +3,7 @@ module mesh-conditions
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030001116-2da0de75c3f0
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261031084708-acbf80ad910a
|
||||
github.com/nats-io/nats.go v1.54.0
|
||||
)
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030001116-2da0de75c3f0 h1:lKU3HjNXGiVbLW2hUgFjU/TMTUYMlNnnmZxG5T7biQA=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030001116-2da0de75c3f0/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261031084708-acbf80ad910a h1:DcSvfEmdPBKqCg5OmXRNb269EkkPe7BsEp+RykEhkgE=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261031084708-acbf80ad910a/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=
|
||||
|
||||
@@ -134,34 +134,58 @@ func (m *memStore) unassign(t *testing.T, node, module string) {
|
||||
m.place(t, node, module, false)
|
||||
}
|
||||
|
||||
// place writes a node's placement as the seat writes it: its node key and module keys (research 072 row 37), and for
|
||||
// ace in the form before, `placement.ace`, which a reader still takes while the change is delivered.
|
||||
func (m *memStore) place(t *testing.T, node, module string, on bool) {
|
||||
t.Helper()
|
||||
type placed struct {
|
||||
Module string `json:"module"`
|
||||
keys := map[string][]byte{}
|
||||
for _, k := range m.keysOf(PlacementRead) {
|
||||
e, _ := m.Get(PlacementRead, k)
|
||||
keys[k] = e.Value
|
||||
}
|
||||
p := struct {
|
||||
V int `json:"v"`
|
||||
Node string `json:"node"`
|
||||
Modules []placed `json:"modules"`
|
||||
}{V: 1, Node: node}
|
||||
if e, _ := m.Get(PlacementRead, "placement."+node); e != nil {
|
||||
if err := json.Unmarshal(e.Value, &p); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
all, err := delivery.PlacementsOf(keys)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var kept []placed
|
||||
p := all[node]
|
||||
p.Node = node
|
||||
var kept []delivery.PlacedModule
|
||||
for _, x := range p.Modules {
|
||||
if x.Module != module {
|
||||
kept = append(kept, x)
|
||||
}
|
||||
}
|
||||
if on {
|
||||
kept = append(kept, placed{Module: module})
|
||||
kept = append(kept, delivery.PlacedModule{Module: module})
|
||||
}
|
||||
p.Modules = kept
|
||||
if err := m.Put(PlacementRead, "placement."+node, p); err != nil {
|
||||
for _, k := range m.keysOf(PlacementRead) {
|
||||
if n, _, _, err := delivery.ParsePlacementKey(k); err == nil && n == node {
|
||||
_ = m.Delete(PlacementRead, k)
|
||||
}
|
||||
}
|
||||
if node == "ace" {
|
||||
raw, err := delivery.EncodePlacement(p)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = m.Put(PlacementRead, "placement.ace", json.RawMessage(raw))
|
||||
return
|
||||
}
|
||||
values, err := delivery.PlacementValues(p)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for k, raw := range values {
|
||||
if err := m.Put(PlacementRead, k, json.RawMessage(raw)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *memStore) keysOf(state string) []string {
|
||||
keys, _ := m.Keys(state)
|
||||
return keys
|
||||
}
|
||||
|
||||
// manifest keeps a manifest in the indexer's manifests by its digest, as the indexer writes it, and answers the digest.
|
||||
|
||||
@@ -70,32 +70,32 @@ func (c *Config) sourcesFrom(source string) (Sources, error) {
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// readPlacement is every module the seat mesh-placement places on a node (ADR 0567 §1, §9): each node's key
|
||||
// `placement.<node>`.
|
||||
// readPlacement is every module the seat mesh-placement places on a node (ADR 0567 §1, §9), from the state's keys in
|
||||
// either form: a node key and its module keys, or the form before, `placement.<node>` (delivery.PlacementsOf;
|
||||
// research 072 row 37). By node in name order, each node's modules in the order its placement names them.
|
||||
func (c *Config) readPlacement(s *Sources) error {
|
||||
placed := map[string]bool{}
|
||||
return c.each(PlacementRead, func(key string, raw []byte) error {
|
||||
node, err := delivery.ParsePlacementKey(key)
|
||||
if err != nil {
|
||||
return nil // a key of another kind
|
||||
}
|
||||
var p struct {
|
||||
Node string `json:"node"`
|
||||
Modules []struct {
|
||||
Module string `json:"module"`
|
||||
} `json:"modules"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &p); err != nil || p.Node != node {
|
||||
return fmt.Errorf("the placement %s is not one", key)
|
||||
}
|
||||
for _, m := range p.Modules {
|
||||
if m.Module != "" && !placed[node+"."+m.Module] {
|
||||
placed[node+"."+m.Module] = true
|
||||
s.Assignments = append(s.Assignments, Assignment{Node: node, Module: m.Module})
|
||||
}
|
||||
}
|
||||
keys := map[string][]byte{}
|
||||
if err := c.each(PlacementRead, func(key string, raw []byte) error {
|
||||
keys[key] = raw
|
||||
return nil
|
||||
})
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
placements, err := delivery.PlacementsOf(keys)
|
||||
if err != nil {
|
||||
return fmt.Errorf("the placement is not read whole: %w", err)
|
||||
}
|
||||
nodes := make([]string, 0, len(placements))
|
||||
for node := range placements {
|
||||
nodes = append(nodes, node)
|
||||
}
|
||||
sort.Strings(nodes)
|
||||
for _, node := range nodes {
|
||||
for _, module := range placements[node].Assigned() {
|
||||
s.Assignments = append(s.Assignments, Assignment{Node: node, Module: module})
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// readDeclarations reads each placed module's declarations from its manifests as the delivery holds them: the trunk's
|
||||
|
||||
@@ -2,7 +2,7 @@ module mesh-config
|
||||
|
||||
go 1.26.0
|
||||
|
||||
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030174137-a37181261c24
|
||||
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261031084708-acbf80ad910a
|
||||
|
||||
require (
|
||||
github.com/klauspost/compress v1.20.0 // indirect
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030174137-a37181261c24 h1:s0XXZtR7CFPBbnaDygWnE4/Q827iEFt0YfEzujk4Q9g=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030174137-a37181261c24/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261031084708-acbf80ad910a h1:DcSvfEmdPBKqCg5OmXRNb269EkkPe7BsEp+RykEhkgE=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261031084708-acbf80ad910a/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=
|
||||
|
||||
@@ -22,5 +22,12 @@ alone writes the log.
|
||||
- `mesh-hand-acts.hand-acts`: both logs, this one and the controller's `mesh-controller_hand-acts` until its lease
|
||||
ends, newest first, with the causes repaired by hand twice within a fortnight; `warrants=true` lists the warrants.
|
||||
|
||||
- `hand_acts_copied` and `hand_acts_copy` (research 072 row 26): the controller's hand acts are copied once into this
|
||||
seat's log, each with its own key, time and author. It runs at the module's start while the old bucket is readable
|
||||
and no marker (`copied`, key `controller-hand-acts`) says it was done, and by the tool; an act already in the log is
|
||||
never appended again, and `hand-acts` counts a copied act once. `hand_acts_copied` says whether, when and how many.
|
||||
The lease write has no hook to run it from, so the start is the trigger; `hand_acts_copy` with `top_up=true`, asked
|
||||
after the lease write, appends any act the controller wrote since the copy (a finished copy is otherwise a no-op).
|
||||
|
||||
No act is written to both logs: every recorder calls this seat from its delivery, and the controller keeps its own
|
||||
acts in its own bucket until it loses its lease.
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"slices"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -287,8 +288,18 @@ func (l *Log) HandActs(days int, cause, who string) Listing {
|
||||
since := now.Add(-time.Duration(days) * 24 * time.Hour)
|
||||
out := Listing{Acts: []Act{}, Repeated: map[string]int{}, Days: days, Kept: map[string]int{}}
|
||||
var all []Act
|
||||
// What was copied from the controller's log is in this one: it is read from this one only (copy.go).
|
||||
copied := map[string]bool{}
|
||||
for _, log := range []string{ActsLog, ControllerActs} {
|
||||
acts, kept, err := l.read(log, now.Add(-max(time.Duration(days)*24*time.Hour, RepeatedWithin)))
|
||||
if log == ActsLog && err == nil {
|
||||
for _, a := range acts {
|
||||
copied[a.ID] = a.ID != ""
|
||||
}
|
||||
}
|
||||
if log == ControllerActs {
|
||||
acts = slices.DeleteFunc(acts, func(a Act) bool { return a.ID != "" && copied[a.ID] })
|
||||
}
|
||||
if err != nil {
|
||||
if out.Unread == nil {
|
||||
out.Unread = map[string]string{}
|
||||
|
||||
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -284,3 +285,87 @@ func TestHandActsReadsBothLogs(t *testing.T) {
|
||||
t.Errorf("warrants answered %v, %v", w, err)
|
||||
}
|
||||
}
|
||||
|
||||
// The controller's hand acts are copied into this seat's log once, each with its own key, time and author, and
|
||||
// never twice: a marker makes it once, a key already in the log is not appended again, a log that cannot be read or
|
||||
// written leaves no marker so the next call copies again, and hand-acts counts a copied act once (research 072 row 26).
|
||||
func TestTheControllersHandActsAreCopiedOnce(t *testing.T) {
|
||||
base := time.Date(2026, 10, 28, 0, 0, 0, 0, time.UTC)
|
||||
old := []string{fmt.Sprintf("act-%d-1", base.UnixNano()), fmt.Sprintf("act-%d-2", base.UnixNano()+1),
|
||||
fmt.Sprintf("act-%d-3", base.UnixNano()+2), "warrant-x-y"}
|
||||
cases := []struct {
|
||||
name string
|
||||
already []string // keys in the new log before the copy
|
||||
oldErr, newErr error
|
||||
junk bool
|
||||
wantErr string
|
||||
wantCopied, wantAlready int
|
||||
wantMarker bool
|
||||
}{
|
||||
{"all", nil, nil, nil, false, "", 4, 0, true},
|
||||
{"finishes a half copy", []string{old[0], old[1]}, nil, nil, false, "", 2, 2, true},
|
||||
{"an entry that is no act is copied unchanged", nil, nil, nil, true, "", 5, 0, true},
|
||||
{"the old log cannot be read", nil, errors.New("away"), nil, false, "cannot be read", 0, 0, false},
|
||||
{"the new log cannot be written", nil, nil, errors.New("full"), false, "could not be copied", 0, 0, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
l, store, _, _ := newLog()
|
||||
for _, k := range old {
|
||||
_ = store.Put(ControllerActs, k, Act{ID: k, At: base, By: "ace.mesh-config", Verb: VerbRecord, Why: "w", Cause: "c"})
|
||||
}
|
||||
if tc.junk {
|
||||
_ = store.Put(ControllerActs, fmt.Sprintf("act-%d-4", base.UnixNano()+3), "not an act")
|
||||
}
|
||||
for _, k := range tc.already {
|
||||
_ = store.Append(ActsLog, k, Act{ID: k})
|
||||
}
|
||||
store.keysErr[ControllerActs], store.keysErr[ActsLog] = tc.oldErr, tc.newErr
|
||||
st, err := l.Copy("test", false)
|
||||
if tc.wantErr != "" {
|
||||
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
|
||||
t.Errorf("%s: %v", tc.name, err)
|
||||
}
|
||||
} else if err != nil || !st.Done || st.Marker.Copied != tc.wantCopied || st.Marker.Already != tc.wantAlready {
|
||||
t.Fatalf("%s: %+v, %v", tc.name, st, err)
|
||||
}
|
||||
_, marked, _ := store.Get(CopiedState, CopyKey)
|
||||
if marked != tc.wantMarker || len(store.logKeys(ActsLog)) != map[bool]int{true: tc.wantCopied + tc.wantAlready, false: len(tc.already)}[tc.wantMarker] {
|
||||
t.Errorf("%s: marker %v, log %v", tc.name, marked, store.logKeys(ActsLog))
|
||||
}
|
||||
if !tc.wantMarker {
|
||||
continue
|
||||
}
|
||||
// Each act is kept with its own time and author, as the controller wrote it.
|
||||
raw, _, _ := store.Last(ActsLog, old[2])
|
||||
var kept Act
|
||||
_ = json.Unmarshal(raw, &kept)
|
||||
if kept.By != "ace.mesh-config" || !kept.At.Equal(base) {
|
||||
t.Errorf("%s: kept %+v", tc.name, kept)
|
||||
}
|
||||
// Asked again: nothing more is appended, and hand-acts counts each act once.
|
||||
before := len(store.logKeys(ActsLog))
|
||||
if again, err := l.Copy("test", false); err != nil || !again.Done || !strings.Contains(again.Said, "already copied") ||
|
||||
len(store.logKeys(ActsLog)) != before {
|
||||
t.Errorf("%s: a second copy: %+v, %v", tc.name, again, err)
|
||||
}
|
||||
if got := l.HandActs(30, "", ""); got.Kept[ControllerActs] != len(old)+boolInt(tc.junk) || len(got.Acts) != len(old) {
|
||||
t.Errorf("%s: %d acts listed from %d kept: %+v", tc.name, len(got.Acts), before, got.Kept)
|
||||
}
|
||||
// An act the controller wrote since is appended by a top-up, and only that one.
|
||||
late := fmt.Sprintf("act-%d-9", base.UnixNano()+9)
|
||||
_ = store.Put(ControllerActs, late, Act{ID: late, At: base, By: "ace.late", Verb: VerbRecord, Why: "w", Cause: "c"})
|
||||
if up, err := l.Copy("test", true); err != nil || up.Marker.Copied != 1 || len(store.logKeys(ActsLog)) != before+1 {
|
||||
t.Errorf("%s: a top-up: %+v, %v", tc.name, up, err)
|
||||
}
|
||||
if s, err := l.Copied(); err != nil || !s.Done || s.Marker == nil {
|
||||
t.Errorf("%s: copied says %+v, %v", tc.name, s, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func boolInt(b bool) int {
|
||||
if b {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
package main
|
||||
|
||||
// The one-time copy of the controller's hand-act log into this seat's log (novox/hq research 072 row 26, ADR 0571
|
||||
// §13). The controller kept every act of its life in its bucket `mesh-controller_hand-acts` (574 on 2026-10-31); from
|
||||
// the lease write only this seat reads them, so they are copied once into `acts`, each as the controller wrote it: the
|
||||
// same key, the same bytes, so its time and its author are what they were.
|
||||
//
|
||||
// **When it runs.** The lease write has no hook to run from (it is a person's step, ADR 0561 §6), so the copy runs at
|
||||
// this module's start while the old log is readable and no marker says it was copied, and on the tool
|
||||
// `hand_acts_copy`. Both do the same thing, and neither does it twice (a `top_up` of the tool appends only what is new): the marker `controller-hand-acts` of the state
|
||||
// `copied` is created once, after every act is in the log; an act already in the log (by its key) is never appended
|
||||
// again, so a copy that stopped half way is finished by the next one. Until the marker exists `hand-acts` hides an
|
||||
// old act that is already copied, so the two logs never count one act twice. `hand_acts_copied` says where it stands.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"time"
|
||||
)
|
||||
|
||||
// CopiedState is the state that holds the marker, and CopyKey the marker's key.
|
||||
const (
|
||||
CopiedState = "copied"
|
||||
CopyKey = "controller-hand-acts"
|
||||
)
|
||||
|
||||
// CopyMarker is what the copy leaves behind once it is whole: when, by what, how many acts, and which entries could not
|
||||
// be read as acts (copied unchanged all the same, so nothing is lost).
|
||||
type CopyMarker struct {
|
||||
At time.Time `json:"at"`
|
||||
By string `json:"by"`
|
||||
Source string `json:"source"`
|
||||
Found int `json:"found"`
|
||||
Copied int `json:"copied"`
|
||||
Already int `json:"already-there"`
|
||||
// Unreadable are the keys whose value is not an act; they were copied as they were.
|
||||
Unreadable []string `json:"unreadable,omitempty"`
|
||||
FirstKey string `json:"first-key,omitempty"`
|
||||
LastKey string `json:"last-key,omitempty"`
|
||||
}
|
||||
|
||||
// CopyStatus is what the tools answer: the marker if the copy is whole, and how the two logs stand now.
|
||||
type CopyStatus struct {
|
||||
Done bool `json:"done"`
|
||||
Marker *CopyMarker `json:"marker,omitempty"`
|
||||
// Said is a sentence for a person: what this call did or found.
|
||||
Said string `json:"said"`
|
||||
// Pending are the old acts not yet in this seat's log (counted only while the copy is not whole).
|
||||
Pending *int `json:"pending,omitempty"`
|
||||
Old *int `json:"old-log-keys,omitempty"`
|
||||
New *int `json:"new-log-entries,omitempty"`
|
||||
}
|
||||
|
||||
// Copy copies the old log into this one, once. by says what asked (`start` or a caller). With topUp it also runs after
|
||||
// the marker exists, appending only the acts the log does not hold yet (the controller's, written since the copy), and
|
||||
// leaves the marker as it was. An old log that cannot be
|
||||
// read, or an act that cannot be appended, is an error and leaves no marker, so the next call copies again.
|
||||
func (l *Log) Copy(by string, topUp bool) (CopyStatus, error) {
|
||||
marked := false
|
||||
if raw, found, err := l.Store.Get(CopiedState, CopyKey); err != nil {
|
||||
return CopyStatus{}, fmt.Errorf("whether the controller's hand acts were copied cannot be read: %w", err)
|
||||
} else if found {
|
||||
var m CopyMarker
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
return CopyStatus{}, fmt.Errorf("the copy's marker is not readable: %w", err)
|
||||
}
|
||||
if !topUp {
|
||||
return CopyStatus{Done: true, Marker: &m, Said: fmt.Sprintf("already copied at %s: %d acts of %s, nothing done",
|
||||
m.At.Format(time.RFC3339), m.Copied+m.Already, m.Source)}, nil
|
||||
}
|
||||
marked = true
|
||||
}
|
||||
keys, err := l.Store.Keys(ControllerActs)
|
||||
if err != nil {
|
||||
return CopyStatus{}, fmt.Errorf("the controller's hand acts cannot be read, so none were copied: %w", err)
|
||||
}
|
||||
sortByTime(keys)
|
||||
m := CopyMarker{By: by, Source: ControllerActs, Found: len(keys)}
|
||||
l.Logf("[%s] copying the controller's hand acts into %s: %d found in %s", Module, ActsLog, len(keys), ControllerActs)
|
||||
for i, k := range keys {
|
||||
raw, found, err := l.Store.Get(ControllerActs, k)
|
||||
if err != nil {
|
||||
return CopyStatus{}, fmt.Errorf("%s of %s cannot be read after %d of %d acts were copied: %w", k, ControllerActs, m.Copied, len(keys), err)
|
||||
}
|
||||
if !found {
|
||||
continue // removed since it was listed
|
||||
}
|
||||
if _, there, err := l.Store.Last(ActsLog, k); err != nil {
|
||||
return CopyStatus{}, fmt.Errorf("whether %s is in %s cannot be read, after %d acts were copied: %w", k, ActsLog, m.Copied, err)
|
||||
} else if there {
|
||||
m.Already++
|
||||
continue
|
||||
}
|
||||
var a Act
|
||||
if json.Unmarshal(raw, &a) != nil {
|
||||
m.Unreadable = append(m.Unreadable, k)
|
||||
l.Logf("[%s] %s of %s is not an act; copied unchanged", Module, k, ControllerActs)
|
||||
}
|
||||
if err := l.Store.Append(ActsLog, k, json.RawMessage(raw)); err != nil {
|
||||
return CopyStatus{}, fmt.Errorf("%s could not be copied into %s after %d acts: %w", k, ActsLog, m.Copied, err)
|
||||
}
|
||||
m.Copied++
|
||||
if m.FirstKey == "" {
|
||||
m.FirstKey = k
|
||||
}
|
||||
m.LastKey = k
|
||||
if (i+1)%100 == 0 {
|
||||
l.Logf("[%s] copied %d of %d", Module, i+1, len(keys))
|
||||
}
|
||||
}
|
||||
m.At = l.Now().UTC()
|
||||
if marked {
|
||||
l.Logf("[%s] topped up the copy: %d acts copied, %d already there, of %d found", Module, m.Copied, m.Already, m.Found)
|
||||
return CopyStatus{Done: true, Marker: &m, Said: fmt.Sprintf("topped up: %d acts copied that the first copy did not hold (%d already there)", m.Copied, m.Already)}, nil
|
||||
}
|
||||
if err := l.Store.Create(CopiedState, CopyKey, m); errors.Is(err, ErrExists) {
|
||||
return CopyStatus{Said: "another instance finished the copy first; nothing more done"}, nil
|
||||
} else if err != nil {
|
||||
return CopyStatus{}, fmt.Errorf("every act was copied but the marker could not be written, so the next call checks them again: %w", err)
|
||||
}
|
||||
l.Logf("[%s] copied the controller's hand acts: %d copied, %d already there, %d not acts, of %d found (%s .. %s)",
|
||||
Module, m.Copied, m.Already, len(m.Unreadable), m.Found, m.FirstKey, m.LastKey)
|
||||
return CopyStatus{Done: true, Marker: &m, Said: fmt.Sprintf("copied %d acts (%d were already there) of %d found in %s",
|
||||
m.Copied, m.Already, m.Found, ControllerActs)}, nil
|
||||
}
|
||||
|
||||
// Copied says where the copy stands without doing anything: the marker, or how many old acts wait.
|
||||
func (l *Log) Copied() (CopyStatus, error) {
|
||||
raw, found, err := l.Store.Get(CopiedState, CopyKey)
|
||||
if err != nil {
|
||||
return CopyStatus{}, fmt.Errorf("the copy's marker cannot be read: %w", err)
|
||||
}
|
||||
var st CopyStatus
|
||||
if found {
|
||||
var m CopyMarker
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
return CopyStatus{}, fmt.Errorf("the copy's marker is not readable: %w", err)
|
||||
}
|
||||
st = CopyStatus{Done: true, Marker: &m, Said: fmt.Sprintf("copied at %s by %s: %d copied, %d already there, of %d in %s",
|
||||
m.At.Format(time.RFC3339), m.By, m.Copied, m.Already, m.Found, m.Source)}
|
||||
}
|
||||
if f, err := l.Store.Fill(ActsLog); err == nil {
|
||||
n := int(f.Entries)
|
||||
st.New = &n
|
||||
}
|
||||
keys, err := l.Store.Keys(ControllerActs)
|
||||
if err != nil {
|
||||
if !st.Done {
|
||||
st.Said = "not copied yet, and the controller's log cannot be read: " + err.Error()
|
||||
}
|
||||
return st, nil
|
||||
}
|
||||
n := len(keys)
|
||||
st.Old = &n
|
||||
if !st.Done {
|
||||
pending := 0
|
||||
for _, k := range keys {
|
||||
if _, there, err := l.Store.Last(ActsLog, k); err != nil || !there {
|
||||
pending++
|
||||
}
|
||||
}
|
||||
st.Pending = &pending
|
||||
st.Said = fmt.Sprintf("not copied yet: %d of %d old acts wait; hand_acts_copy copies them", pending, n)
|
||||
}
|
||||
return st, nil
|
||||
}
|
||||
|
||||
// sortByTime orders keys oldest first by the time they name, those that name none after, by key.
|
||||
func sortByTime(keys []string) {
|
||||
sort.SliceStable(keys, func(i, j int) bool {
|
||||
ti, oi := keyTime(keys[i])
|
||||
tj, oj := keyTime(keys[j])
|
||||
switch {
|
||||
case oi && oj && !ti.Equal(tj):
|
||||
return ti.Before(tj)
|
||||
case oi != oj:
|
||||
return oi
|
||||
}
|
||||
return keys[i] < keys[j]
|
||||
})
|
||||
}
|
||||
@@ -77,6 +77,15 @@ func (runtimeStore) Fill(log string) (Fill, error) {
|
||||
func main() {
|
||||
l := &Log{Store: runtimeStore{}, Now: time.Now, Logf: logf,
|
||||
Publish: stdio.SeatPublish}
|
||||
// No hook of the lease write runs this module, so the one-time copy of the controller's hand acts runs at start
|
||||
// and on the tool hand_acts_copy; the marker makes it once (copy.go).
|
||||
go func() {
|
||||
if st, err := l.Copy("start", false); err != nil {
|
||||
logf("[%s] the copy of the controller's hand acts is NOT done: %v", Module, err)
|
||||
} else {
|
||||
logf("[%s] %s", Module, st.Said)
|
||||
}
|
||||
}()
|
||||
if err := stdio.Serve("", Tools(l)); err != nil {
|
||||
logf("%v", err)
|
||||
os.Exit(1)
|
||||
|
||||
@@ -47,7 +47,7 @@ func TestTheManifestSaysWhatTheCodeDoes(t *testing.T) {
|
||||
t.Errorf("emits %v, reads %v", m.Emits, m.Reads)
|
||||
}
|
||||
readBy := map[string]string{ActsLog: HandActsTool, WarrantsState: HandActsTool, ControllerActs: HandActsTool,
|
||||
RouterRecords: WarrantedTool}
|
||||
RouterRecords: WarrantedTool, CopiedState: CopiedTool}
|
||||
tools := map[string]bool{}
|
||||
for _, tool := range Tools(&Log{}) {
|
||||
tools[tool.Name] = true
|
||||
@@ -58,8 +58,8 @@ func TestTheManifestSaysWhatTheCodeDoes(t *testing.T) {
|
||||
}
|
||||
for _, raw := range m.State {
|
||||
var name string
|
||||
if json.Unmarshal(raw, &name) != nil || name != WarrantsState {
|
||||
t.Errorf("the state %s is not the warrants, or ages", raw)
|
||||
if json.Unmarshal(raw, &name) != nil || (name != WarrantsState && name != CopiedState) {
|
||||
t.Errorf("the state %s is neither the warrants nor the copy's marker, or ages", raw)
|
||||
}
|
||||
if !tools[readBy[name]] {
|
||||
t.Errorf("the state %s has no tool that reads it", name)
|
||||
|
||||
@@ -21,6 +21,9 @@ const (
|
||||
HandActTool = Seat + ".hand-act"
|
||||
HandActsTool = Seat + ".hand-acts"
|
||||
WarrantedTool = Seat + ".warranted"
|
||||
// CopiedTool reads where the one-time copy of the controller's hand acts stands, CopyTool does it (copy.go).
|
||||
CopiedTool = "hand_acts_copied"
|
||||
CopyTool = "hand_acts_copy"
|
||||
)
|
||||
|
||||
func obj(props map[string]any, required ...string) map[string]any {
|
||||
@@ -70,6 +73,21 @@ func Tools(l *Log) []stdio.Tool {
|
||||
Run: func(a map[string]any) (any, error) {
|
||||
return l.Warranted(stdio.CallerOf(a), arg(a, "asker"), arg(a, "ask"))
|
||||
}},
|
||||
{Name: CopiedTool,
|
||||
Description: "Where the one-time copy of the controller's hand acts into this seat's log stands (novox/hq research 072 " +
|
||||
"row 26): done, with when, by what and how many acts were copied, or how many old acts still wait, and how " +
|
||||
"full both logs are. Changes nothing.",
|
||||
Input: obj(map[string]any{}),
|
||||
Run: func(map[string]any) (any, error) { return l.Copied() }},
|
||||
{Name: CopyTool,
|
||||
Description: "Copy the controller's hand acts (its bucket mesh-controller_hand-acts) into this seat's log once, each " +
|
||||
"with its own key, time and author. Does nothing when the copy is already done; an act already in the log is " +
|
||||
"never appended again, so a copy that stopped is finished by asking again. With top_up=true it also runs after the " +
|
||||
"copy is done and appends only the acts written since. This also runs at the module's start.",
|
||||
Input: obj(map[string]any{"top_up": str("\"true\": after the copy is done, append the acts written since")}),
|
||||
Run: func(a map[string]any) (any, error) {
|
||||
return l.Copy("tool, called by "+stdio.CallerOf(a), a["top_up"] == true || strings.EqualFold(arg(a, "top_up"), "true"))
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -338,7 +338,7 @@ func TestTheHandActLogWholeRecordsOnTheBus(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, b := range []string{Module + "_" + WarrantsState, "mesh-controller_hand-acts", "messenger_asks"} {
|
||||
for _, b := range []string{Module + "_" + WarrantsState, Module + "_" + CopiedState, "mesh-controller_hand-acts", "messenger_asks"} {
|
||||
if _, err := js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: b}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -354,6 +354,13 @@ func TestTheHandActLogWholeRecordsOnTheBus(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// One act of the controller's, kept before the module starts: the start copies it into the log.
|
||||
oldActs, _ := js.KeyValue(ctx, "mesh-controller_hand-acts")
|
||||
oldKey := fmt.Sprintf("act-%d-1", time.Now().Add(-time.Hour).UnixNano())
|
||||
if _, err := oldActs.Put(ctx, oldKey, []byte(`{"id":"`+oldKey+`","at":"`+time.Now().Add(-time.Hour).UTC().Format(time.RFC3339)+
|
||||
`","by":"ace.old","verb":"hand-act","why":"w","cause":"c"}`)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cmd := exec.CommandContext(ctx, bin)
|
||||
cmd.Env = append(os.Environ(), "MESH_NODE=novox", "MESH_MODULE="+Module)
|
||||
stdin, _ := cmd.StdinPipe()
|
||||
@@ -395,8 +402,19 @@ func TestTheHandActLogWholeRecordsOnTheBus(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if info, err := logStream.Info(ctx); err != nil || info.State.Msgs != 2 {
|
||||
t.Errorf("the log keeps %v, want the act and one warrant (%v)", info, err)
|
||||
for i := 0; ; i++ {
|
||||
if c, err := p.call(CopiedTool, "person.jochen", map[string]any{}); err == nil && strings.Contains(string(c), `"done":true`) {
|
||||
break
|
||||
} else if i > 50 {
|
||||
t.Fatalf("the controller's hand acts were not copied at the start: %s, %v", c, err)
|
||||
}
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
}
|
||||
if e, err := logStream.GetLastMsgForSubject(ctx, "mesh.log."+Module+"."+ActsLog+"."+oldKey); err != nil || !strings.Contains(string(e.Data), `"by":"ace.old"`) {
|
||||
t.Errorf("the controller's act is not copied with its author: %v", err)
|
||||
}
|
||||
if info, err := logStream.Info(ctx); err != nil || info.State.Msgs != 3 {
|
||||
t.Errorf("the log keeps %v, want the copied act, the act and one warrant (%v)", info, err)
|
||||
}
|
||||
listed, err := p.call(HandActsTool, "person.jochen", map[string]any{})
|
||||
if err != nil || !strings.Contains(string(listed), "warrant-claude-code-a1") || !strings.Contains(string(listed), r.Act.ID) {
|
||||
|
||||
@@ -24,7 +24,8 @@
|
||||
}
|
||||
],
|
||||
"state": [
|
||||
"warrants"
|
||||
"warrants",
|
||||
"copied"
|
||||
],
|
||||
"own-secrets": {
|
||||
"broker": "${dir:state}/broker"
|
||||
|
||||
@@ -108,6 +108,10 @@ type PlannedProvision struct {
|
||||
// CredentialTool is, for a software-issued offer, the provider's tool the vault asks for the value (issue 928);
|
||||
// the vault acts on it and this client only reads it.
|
||||
CredentialTool string `json:"credential-tool,omitempty"`
|
||||
// Contributed is what this module gives the provider through the provision, composed, as one opaque payload
|
||||
// (issue 774): the provider reads it with its own contract (route-proxy's for a route); the client keeps it unread,
|
||||
// so the plan reads whole and no provision's fields are known here.
|
||||
Contributed json.RawMessage `json:"contributed,omitempty"`
|
||||
}
|
||||
|
||||
// issuedForHolder is whether the entry names a value its provider's software issued for this holder alone (issue
|
||||
|
||||
@@ -9,8 +9,9 @@ import (
|
||||
|
||||
// A plan provision carries everything the controller's PlanProvision writes (issue 954): what the consumer
|
||||
// contributes, where the provider is and who the consumer is at its end (issue 788), the names the mesh calls it
|
||||
// (issue 792) and, for a software-issued offer, the tool the vault asks (issue 928). The client still refuses a field
|
||||
// nobody writes.
|
||||
// (issue 792), for a software-issued offer, the tool the vault asks (issue 928), and what the consumer contributes as
|
||||
// one opaque payload, carried unread (issue 774: route-proxy serves its routes from it, read with its own contract).
|
||||
// The client still refuses a field nobody writes, but never looks inside the payload.
|
||||
func TestAPlanProvisionWithEveryFieldTheControllerWritesIsRead(t *testing.T) {
|
||||
p, err := json.Marshal(contributor(t, "shell:zsh:normal", "alias nox=mesh-cli"))
|
||||
if err != nil {
|
||||
@@ -18,7 +19,9 @@ func TestAPlanProvisionWithEveryFieldTheControllerWritesIsRead(t *testing.T) {
|
||||
}
|
||||
provision := `"provisions":[{"provision":"route","local":"web","provider":"novox","provider-module":"route-proxy",` +
|
||||
`"credential-form":"pair","contributes":{"label":"git"},"at":"10.10.0.1","from":"novox","as":"gitea",` +
|
||||
`"names":{"name-web":"git.example.org"}},{"name":"gitea-read","provision":"gitea-read","provider":"novox",` +
|
||||
`"names":{"name-web":"git.example.org"},` +
|
||||
`"contributed":{"v":1,"at":"10.10.0.2","each":[{"local":"web","values":{"name":"git.example.org","anything":1}}]}},` +
|
||||
`{"name":"gitea-read","provision":"gitea-read","provider":"novox",` +
|
||||
`"provider-module":"gitea","credential-form":"issued","credential-tool":"gitea_agent_token",` +
|
||||
`"path":"/var/lib/claude-code/gitea-read.secret"}],"items":`
|
||||
value := fmt.Sprintf(`{"node":"g14","module":"mesh-cli","provisioning":%s}`,
|
||||
@@ -29,7 +32,8 @@ func TestAPlanProvisionWithEveryFieldTheControllerWritesIsRead(t *testing.T) {
|
||||
}
|
||||
if len(got.Provisions) != 2 || got.Provisions[0].At != "10.10.0.1" || got.Provisions[0].From != "novox" ||
|
||||
got.Provisions[0].As != "gitea" || got.Provisions[0].Names["name-web"] != "git.example.org" ||
|
||||
got.Provisions[1].CredentialTool != "gitea_agent_token" {
|
||||
got.Provisions[1].CredentialTool != "gitea_agent_token" || string(got.Provisions[0].Contributed) !=
|
||||
`{"v":1,"at":"10.10.0.2","each":[{"local":"web","values":{"name":"git.example.org","anything":1}}]}` {
|
||||
t.Fatalf("the plan's provisions read as %+v", got.Provisions)
|
||||
}
|
||||
spoilt := strings.Replace(value, `"at":`, `"surprise":1,"at":`, 1)
|
||||
|
||||
@@ -903,9 +903,9 @@ func (h *Holder) put(key string, p provisions.Provision) {
|
||||
|
||||
// ---- what a change touches --------------------------------------------------------------------------------------
|
||||
|
||||
// NodesOfPlacement is the node a placement key names.
|
||||
// NodesOfPlacement is the node a placement key names, in either form (research 072 row 37).
|
||||
func NodesOfPlacement(key string) []string {
|
||||
if n, err := delivery.ParsePlacementKey(key); err == nil {
|
||||
if n, _, _, err := delivery.ParsePlacementKey(key); err == nil {
|
||||
return []string{n}
|
||||
}
|
||||
return nil
|
||||
@@ -938,25 +938,23 @@ func (h *Holder) NodesOfModule(module string) []string {
|
||||
return sortedKeys(nodes)
|
||||
}
|
||||
|
||||
// placedOn is every node a module is placed on, sorted.
|
||||
// placedOn is every node a module is placed on, sorted, from the placement's keys in either form
|
||||
// (delivery.PlacementsOf; research 072 row 37). A node whose keys do not read whole is read as they stand.
|
||||
func (h *Holder) placedOn(module string) []string {
|
||||
nodes := map[string]bool{}
|
||||
raw := map[string][]byte{}
|
||||
if keys, err := h.Store.Keys(PlacementRead); err == nil {
|
||||
for _, k := range keys {
|
||||
n, err := delivery.ParsePlacementKey(k)
|
||||
if err != nil {
|
||||
continue
|
||||
if e, err := h.Store.Get(PlacementRead, k); err == nil && e != nil {
|
||||
raw[k] = e.Value
|
||||
}
|
||||
e, err := h.Store.Get(PlacementRead, k)
|
||||
if err != nil || e == nil {
|
||||
continue
|
||||
}
|
||||
if p, err := delivery.DecodePlacement(e.Value); err == nil {
|
||||
for _, m := range p.Modules {
|
||||
if m.Module == module {
|
||||
nodes[n] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
placements, _ := delivery.PlacementsOf(raw)
|
||||
for n, p := range placements {
|
||||
for _, m := range p.Modules {
|
||||
if m.Module == module {
|
||||
nodes[n] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -191,7 +191,17 @@ func TestAKeysStateFollowsItsProvidersOutcome(t *testing.T) {
|
||||
// A provision's credential is the vault's own provision, on the consumer's node for a login of its own, and the
|
||||
// provision depends on it (clauses 3, 6).
|
||||
c := newMesh()
|
||||
c.s.write(t, PlacementRead, "placement.novox", `{"v":1,"node":"novox","decided":{},"modules":[{"module":"mesh-vault"}]}`)
|
||||
// The placement in its keys per module and node (research 072 row 37).
|
||||
novox, err := delivery.PlacementValues(delivery.Placement{Node: "novox", Modules: []delivery.PlacedModule{{Module: "mesh-vault"}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for k, v := range novox {
|
||||
c.s.write(t, PlacementRead, k, string(v))
|
||||
if n := NodesOfPlacement(k); !slices.Equal(n, []string{"novox"}) {
|
||||
t.Errorf("NodesOfPlacement(%q) = %v", k, n)
|
||||
}
|
||||
}
|
||||
pair := pgDB
|
||||
pair.CredentialForm = "pair"
|
||||
c.atWant["g14"] = map[string]catalogue.Plan{"gitea": planOf(pair)}
|
||||
|
||||
@@ -3,7 +3,7 @@ module mesh-provisioner
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030212912-6c5a8ef893b4
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261031084708-acbf80ad910a
|
||||
github.com/nats-io/nats.go v1.54.0
|
||||
)
|
||||
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030182415-754edfae6924 h1:aIiKj066gYFEDokUOq0i2n4vsygu5g0XMKJRRaMI4fM=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030182415-754edfae6924/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030212912-6c5a8ef893b4 h1:uzXzKfjoWB8wCbAPgxUAWSArr+3DKwi8qrB3BUEOVOE=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030212912-6c5a8ef893b4/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261031084708-acbf80ad910a h1:DcSvfEmdPBKqCg5OmXRNb269EkkPe7BsEp+RykEhkgE=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261031084708-acbf80ad910a/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=
|
||||
|
||||
@@ -31,29 +31,26 @@ const (
|
||||
ScopeMachine = health.ScopeMachine
|
||||
)
|
||||
|
||||
// placements is every node's placement, by node.
|
||||
// placements is every node's placement, by node, from the state's keys in either form: a node key and its module keys,
|
||||
// or the form before, `placement.<node>` (delivery.PlacementsOf; research 072 row 37).
|
||||
func (c *Checker) placements() (map[string]delivery.Placement, error) {
|
||||
keys, err := c.Store.Keys(PlacementRead)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the placement cannot be read: %w", err)
|
||||
}
|
||||
out := map[string]delivery.Placement{}
|
||||
values := map[string][]byte{}
|
||||
for _, k := range keys {
|
||||
raw, ok, err := c.Store.Get(PlacementRead, k)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the placement of %s cannot be read: %w", k, err)
|
||||
return nil, fmt.Errorf("the placement's key %s cannot be read: %w", k, err)
|
||||
}
|
||||
if !ok {
|
||||
continue
|
||||
if ok {
|
||||
values[k] = raw
|
||||
}
|
||||
var p delivery.Placement
|
||||
if err := json.Unmarshal(raw, &p); err != nil {
|
||||
return nil, fmt.Errorf("the placement of %s is not readable: %w", k, err)
|
||||
}
|
||||
if p.Node == "" {
|
||||
p.Node = k
|
||||
}
|
||||
out[p.Node] = p
|
||||
}
|
||||
out, err := delivery.PlacementsOf(values)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the placement is not readable: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@@ -260,8 +260,12 @@ func machines() *memStore {
|
||||
node, address, endpoint string
|
||||
online bool
|
||||
}{{"novox", "10.10.0.1/16", "203.0.113.5:51820", true}, {"ace", "10.10.0.2/16", "", true}, {"g14", "10.10.0.3/16", "", false}} {
|
||||
_ = s.Put(PlacementRead, p.node, delivery.Placement{V: 1, Node: p.node,
|
||||
Decided: delivery.NodeDecided{Address: p.address, Endpoint: p.endpoint, Hub: p.endpoint != ""}})
|
||||
pl := delivery.Placement{V: 1, Node: p.node,
|
||||
Decided: delivery.NodeDecided{Address: p.address, Endpoint: p.endpoint, Hub: p.endpoint != ""}}
|
||||
if p.node == "ace" {
|
||||
pl.Modules = []delivery.PlacedModule{{Module: "sshd"}} // a module key beside the node key
|
||||
}
|
||||
place(s, pl)
|
||||
_ = s.Put(PresenceRead, p.node, delivery.Presence{V: 1, Online: p.online})
|
||||
key, _ := delivery.SeatHoldersKey(BannedSeat, p.node)
|
||||
_ = s.Put(SeatHoldersRead, key, delivery.SeatHolders{V: 1, Seat: BannedSeat, Scope: "node", Node: p.node,
|
||||
@@ -270,6 +274,31 @@ func machines() *memStore {
|
||||
return s
|
||||
}
|
||||
|
||||
// place writes a node's placement as the seat does, its node key and a key per module (research 072 row 37); g14's in
|
||||
// the form before, `placement.g14`, which the probes still read while the change is delivered.
|
||||
func place(s *memStore, p delivery.Placement) {
|
||||
if p.Node == "g14" {
|
||||
raw, _ := delivery.EncodePlacement(p)
|
||||
_ = s.Put(PlacementRead, "placement.g14", json.RawMessage(raw))
|
||||
return
|
||||
}
|
||||
values, _ := delivery.PlacementValues(p)
|
||||
for k, raw := range values {
|
||||
_ = s.Put(PlacementRead, k, json.RawMessage(raw))
|
||||
}
|
||||
}
|
||||
|
||||
// placed is every node's placement the store holds.
|
||||
func placed(s *memStore) map[string]delivery.Placement {
|
||||
keys, _ := s.Keys(PlacementRead)
|
||||
values := map[string][]byte{}
|
||||
for _, k := range keys {
|
||||
values[k], _, _ = s.Get(PlacementRead, k)
|
||||
}
|
||||
out, _ := delivery.PlacementsOf(values)
|
||||
return out
|
||||
}
|
||||
|
||||
// filling is a store holding the self-check's written fill finding of a stream: the condition open.
|
||||
func filling(stream string) *memStore {
|
||||
s := newStore()
|
||||
@@ -285,11 +314,9 @@ func filling(stream string) *memStore {
|
||||
func agents(verdicts map[string]string) *memStore {
|
||||
s := machines()
|
||||
for node, v := range verdicts {
|
||||
raw, _, _ := s.Get(PlacementRead, node)
|
||||
var p delivery.Placement
|
||||
_ = json.Unmarshal(raw, &p)
|
||||
p := placed(s)[node]
|
||||
p.Decided.AgentAccount = "agent"
|
||||
_ = s.Put(PlacementRead, node, p)
|
||||
place(s, p)
|
||||
h := delivery.NodeHealth{V: 1, Node: node, Contract: 13}
|
||||
switch v {
|
||||
case "":
|
||||
@@ -303,7 +330,7 @@ func agents(verdicts map[string]string) *memStore {
|
||||
h.Root = []delivery.RootVerdict{{Account: "agent", Module: "claude-code", Resource: "claude-code.agent", State: v,
|
||||
Reason: "can become root without a person: a sudo rule"}}
|
||||
}
|
||||
raw, _ = delivery.EncodeNodeHealth(h)
|
||||
raw, _ := delivery.EncodeNodeHealth(h)
|
||||
_ = s.Put(HealthRead, node, json.RawMessage(raw))
|
||||
}
|
||||
return s
|
||||
|
||||
@@ -3,7 +3,7 @@ module mesh-self-check
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030001116-2da0de75c3f0
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261031084708-acbf80ad910a
|
||||
github.com/nats-io/nats.go v1.54.0
|
||||
)
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030001116-2da0de75c3f0 h1:lKU3HjNXGiVbLW2hUgFjU/TMTUYMlNnnmZxG5T7biQA=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030001116-2da0de75c3f0/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261031084708-acbf80ad910a h1:DcSvfEmdPBKqCg5OmXRNb269EkkPe7BsEp+RykEhkgE=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261031084708-acbf80ad910a/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=
|
||||
|
||||
@@ -323,9 +323,29 @@ func (v *Vault) AcceptTake(caller, call, sealed string) (Changed, error) {
|
||||
if origin == "" {
|
||||
origin = OriginAccepted
|
||||
}
|
||||
if origin == OriginIssued {
|
||||
// **The vault's mint wins** (issue 774): during the delivery a controller older than the hand-over may still mint
|
||||
// a covered account and hand it here; it never replaces an account the vault minted, judged under the store's
|
||||
// lock, so the value and the hash the bus loaded stay the vault's.
|
||||
return v.keepWith(c.secret, string(value), someoneOr(c.by, caller), caller, origin, keepOptions{refuse: v.vaultMinted})
|
||||
}
|
||||
return v.keep(c.secret, string(value), someoneOr(c.by, caller), caller, origin)
|
||||
}
|
||||
|
||||
// vaultMinted refuses a value handed for a secret holding an account the vault minted itself (mint.go): one is never
|
||||
// replaced but by the vault's own rotation.
|
||||
func (v *Vault) vaultMinted(before Record) string {
|
||||
if before.Origin != OriginMade || before.ChangedBy != "the vault" || before.MovedFrom != nil {
|
||||
return ""
|
||||
}
|
||||
if was, err := openAtRest(before, v); err == nil && BusAccountValue(before.Name, was) {
|
||||
return before.Name + " holds a bus account the vault minted (generation " + fmt.Sprint(before.Generation) +
|
||||
"): a value the controller issued never replaces it, so nothing was taken; the vault is the one issuer " +
|
||||
"(issue 774), and a new value is the vault's rotation"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ---- secret-ask ---------------------------------------------------------------------------------------------
|
||||
|
||||
// AskOf is what an ask is for, and where.
|
||||
|
||||
@@ -1,28 +1,20 @@
|
||||
package main
|
||||
|
||||
// The switch on the controller's lease (novox/hq ADR 0561 §3): minting is on only while the lease is handed to a holder
|
||||
// that is no controller, and off for a controller's value, a key deleted or expired, or one that cannot be read. The
|
||||
// write that hands it away turns it on and mints every wanted account the vault holds no value for; a lease handed back
|
||||
// turns it off. No clock and no setting turns it. A key's age passing is heard by no watch, so the lease is read afresh
|
||||
// before every mint (LeaseRead): a lease that expired since mints nothing.
|
||||
// The controller's lease as the vault reads it (novox/hq ADR 0561 §3): handed away only to a holder that is no
|
||||
// controller, and not for a controller's value, a key deleted or expired, or one that cannot be read. It turns retirement
|
||||
// (retire.go) and rotation (rotation.go), never minting: the vault mints every wanted account it holds no value for,
|
||||
// whoever holds the lease (mint.go, issue 774). No clock and no setting turns it. A key's age passing is heard by no
|
||||
// watch, so the lease is read afresh before a retirement or a rotation (LeaseRead).
|
||||
|
||||
import "git.novox.be/novox/mesh-sdk/go/buscompose"
|
||||
|
||||
// LeaseHeard is the watch of the controller's lease. When minting turns on or off, every wanted account is judged again:
|
||||
// minted when on, held back with the new why when off.
|
||||
// LeaseHeard is the watch of the controller's lease: it records whether it is handed away, and says when that turned.
|
||||
func (v *Vault) LeaseHeard(raw []byte, found bool, readErr error) {
|
||||
if !v.setLease(raw, found, readErr) {
|
||||
return
|
||||
}
|
||||
on, why := v.mintingOn()
|
||||
v.log("minting is %s: %s", map[bool]string{true: "ON", false: "off"}[on], why)
|
||||
for _, user := range v.wantedUsers() {
|
||||
if w, ok := v.wanted(user); ok {
|
||||
if _, err := v.mintWanted(w, "the lease write"); err != nil {
|
||||
v.log("%s is not minted: %v", user, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
on, why := v.leaseHanded()
|
||||
v.log("the controller's lease is %s: %s", map[bool]string{true: "HANDED AWAY", false: "not handed away"}[on], why)
|
||||
}
|
||||
|
||||
// setLease records what the lease says, and answers whether minting turned on or off by it.
|
||||
@@ -39,7 +31,7 @@ func (v *Vault) setLease(raw []byte, found bool, readErr error) bool {
|
||||
return flipped
|
||||
}
|
||||
|
||||
// leaseAfresh reads the lease again before a mint, where the vault can: a key that expired since is heard by no watch.
|
||||
// leaseAfresh reads the lease again before a retirement or a rotation, where the vault can: a key that expired since is heard by no watch.
|
||||
func (v *Vault) leaseAfresh() {
|
||||
if v.LeaseRead == nil {
|
||||
return
|
||||
|
||||
@@ -10,10 +10,14 @@ package main
|
||||
// bcrypt hash into `bus-accounts`; and says `account-issued`, then `secret-changed`. The node's engine asks `own` again
|
||||
// on `secret-changed` and composes.
|
||||
//
|
||||
// **One issuer at every moment** (§3): minting is on only while the controller's lease is handed to a holder that is no
|
||||
// controller (mesh-sdk LeaseHandedAway). Until then the controller mints, the vault keeps hashing what it is handed, and
|
||||
// vault_status names every wanted account it holds back and why. **Nothing is minted anew** (§4): an account whose value
|
||||
// the vault holds, or whose hash it adopted, is never minted; the node accounts and the controller's own are never wanted.
|
||||
// **The vault is the one issuer** (issue 774, research 072's gap "vault mints after the lease"): it mints every wanted
|
||||
// account it holds no value for, whoever holds the controller's lease, and the controller mints none once a vault is
|
||||
// installed (mesh-controller forwards `module issue` and its assignment's issue to issue-account). No account depends
|
||||
// on a node reporting to the controller. The lease still turns retirement and rotation (retire.go, rotation.go), and
|
||||
// vault_status names it. **Never two issuers of one value** (§3, §4): an account whose value the vault holds, or whose
|
||||
// hash it adopted, is never minted; the node accounts and the controller's own are never wanted; a value the controller
|
||||
// moves in never replaces one held (move.go), and one it hands with origin issued never replaces an account the vault
|
||||
// minted (AcceptTake): during the delivery an older controller may still mint, and the vault's value wins.
|
||||
// **Never two values for one user** (§7): a user whose hash the vault already wrote from another of its secrets is held
|
||||
// back, said, and never replaced but by a rotation.
|
||||
|
||||
@@ -43,8 +47,9 @@ const (
|
||||
// AccountIssuedEvent is what the vault says when it minted an account (mesh-sdk buscompose.AccountIssued).
|
||||
const AccountIssuedEvent = buscompose.AccountIssuedEvent
|
||||
|
||||
// minting is the switch and what the vault knows of the wanted accounts, kept in memory and read again from the bus at
|
||||
// every start: the grants part's state and the lease are the record, never this.
|
||||
// minting is what the vault knows of the wanted accounts and of the controller's lease, kept in memory and read again
|
||||
// from the bus at every start: the grants part's state and the lease are the record, never this. on, why and since are
|
||||
// the lease (handed away or not), which turns retirement and rotation, never minting.
|
||||
type minting struct {
|
||||
mu sync.Mutex
|
||||
on bool
|
||||
@@ -76,41 +81,77 @@ type Minted struct {
|
||||
Hashed bool `json:"hashed"`
|
||||
}
|
||||
|
||||
// MintState is what vault_status says of minting.
|
||||
// MintState is what vault_status says of minting: always on (the vault is the one issuer, issue 774), and the lease,
|
||||
// which turns retirement and rotation.
|
||||
type MintState struct {
|
||||
On bool `json:"on"`
|
||||
Why string `json:"why"`
|
||||
On bool `json:"on"`
|
||||
Why string `json:"why"`
|
||||
// Lease is whether the controller's lease is handed away, LeaseWhy why, and Since when that last turned.
|
||||
Lease bool `json:"lease_handed_away"`
|
||||
LeaseWhy string `json:"lease_why"`
|
||||
Since time.Time `json:"since,omitzero"`
|
||||
Wanted int `json:"wanted"`
|
||||
HeldBack map[string]string `json:"held_back"`
|
||||
Minted []Minted `json:"minted"`
|
||||
}
|
||||
|
||||
// MintState answers how minting stands.
|
||||
// mintingWhy is why the vault mints, whoever holds the lease.
|
||||
const mintingWhy = "the vault is the one issuer of bus accounts: it mints every wanted account it holds no value for " +
|
||||
"and adopted no hash of, whoever holds the controller's lease (issue 774)"
|
||||
|
||||
// MintState answers how minting stands. A user held back whose account the vault holds a value for meanwhile (moved in,
|
||||
// handed, or minted by another path) is not said held back: it is not (issue 774, 2026-10-31).
|
||||
func (v *Vault) MintState() MintState {
|
||||
m := &v.minting
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
out := MintState{On: m.on, Why: m.why, Since: m.since, Wanted: len(m.wants), HeldBack: map[string]string{}, Minted: []Minted{}}
|
||||
if out.Why == "" {
|
||||
out.Why = "the controller's lease has not been read yet, so nothing is minted"
|
||||
out := MintState{On: true, Why: mintingWhy, Lease: m.on, LeaseWhy: m.why, Since: m.since, Wanted: len(m.wants),
|
||||
HeldBack: map[string]string{}, Minted: []Minted{}}
|
||||
if out.LeaseWhy == "" {
|
||||
out.LeaseWhy = leaseUnread
|
||||
}
|
||||
wants := map[string]buscompose.WantedAccount{}
|
||||
for u, why := range m.heldBack {
|
||||
out.HeldBack[u] = why
|
||||
if w, ok := m.wants[u]; ok {
|
||||
wants[u] = w
|
||||
}
|
||||
}
|
||||
for _, x := range m.minted {
|
||||
out.Minted = append(out.Minted, x)
|
||||
}
|
||||
m.mu.Unlock()
|
||||
for u, w := range wants {
|
||||
if v.holdsAccount(w) {
|
||||
delete(out.HeldBack, u)
|
||||
}
|
||||
}
|
||||
sort.Slice(out.Minted, func(i, j int) bool { return out.Minted[i].User < out.Minted[j].User })
|
||||
return out
|
||||
}
|
||||
|
||||
// mintingOn says whether the vault mints now, and why.
|
||||
func (v *Vault) mintingOn() (bool, string) {
|
||||
// holdsAccount says the vault holds a bus account as w's own secret: a value that is an account.
|
||||
func (v *Vault) holdsAccount(w buscompose.WantedAccount) bool {
|
||||
secret, err := AccountSecret(w)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
rec, held, err := v.Store.Read(secret)
|
||||
if err != nil || !held {
|
||||
return false
|
||||
}
|
||||
value, err := openAtRest(rec, v)
|
||||
return err == nil && BusAccountValue(secret, value)
|
||||
}
|
||||
|
||||
// leaseUnread is why nothing is retired or rotated before the lease is read.
|
||||
const leaseUnread = "the controller's lease has not been read yet, so nothing is retired or rotated"
|
||||
|
||||
// leaseHanded says whether the controller's lease is handed away, and why: what retirement and rotation wait for.
|
||||
func (v *Vault) leaseHanded() (bool, string) {
|
||||
v.minting.mu.Lock()
|
||||
defer v.minting.mu.Unlock()
|
||||
if v.minting.why == "" {
|
||||
return false, "the controller's lease has not been read yet, so nothing is minted"
|
||||
return false, leaseUnread
|
||||
}
|
||||
return v.minting.on, v.minting.why
|
||||
}
|
||||
@@ -248,11 +289,6 @@ func (v *Vault) mintOnce(w buscompose.WantedAccount, by string) (Minted, error)
|
||||
v.holdBack(w.User, why)
|
||||
return Minted{}, errHeldBack{why}
|
||||
}
|
||||
v.leaseAfresh()
|
||||
if on, why := v.mintingOn(); !on {
|
||||
v.holdBack(w.User, "held back: "+why)
|
||||
return Minted{}, errHeldBack{why}
|
||||
}
|
||||
raw := make([]byte, 30)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return Minted{}, err
|
||||
@@ -360,8 +396,9 @@ func heldBackOrMinted(m Minted, err error) (any, error) {
|
||||
}
|
||||
|
||||
// IssueAccount is the seat's verb issue-account (ADR 0561 §9): one wanted user minted now, its key read afresh from the
|
||||
// grants part's state. The recovery when a want was not heard: it mints nothing a want would not, and is refused while
|
||||
// minting is off, for a node's account and the controller's (never minted here, §4), and for a user nobody wants.
|
||||
// grants part's state. The recovery when a want was not heard, and what the controller's `module issue` forwards to
|
||||
// (issue 774): it mints nothing a want would not, answers "held already" for an account it holds, and is refused for a
|
||||
// node's account and the controller's (never minted here, §4) and for a user nobody wants.
|
||||
func (v *Vault) IssueAccount(caller, user string) (any, error) {
|
||||
user = strings.TrimSpace(user)
|
||||
if user == "" {
|
||||
@@ -371,10 +408,6 @@ func (v *Vault) IssueAccount(caller, user string) (any, error) {
|
||||
return nil, fmt.Errorf("%s is never minted by the vault: a node's account and the controller's keep the hash they "+
|
||||
"have (ADR 0561 §4)", user)
|
||||
}
|
||||
v.leaseAfresh()
|
||||
if on, why := v.mintingOn(); !on {
|
||||
return nil, fmt.Errorf("not minted: %s", why)
|
||||
}
|
||||
raw, found, err := []byte(nil), false, error(nil)
|
||||
if v.WantRead != nil {
|
||||
raw, found, err = v.WantRead(user)
|
||||
|
||||
@@ -5,9 +5,9 @@ package main
|
||||
// The vault whole against a real bus (novox/hq ADR 0561 §1 to §4, issue 774's acceptance 12): the built binary, played
|
||||
// over its real stdin and stdout as the node's tool runtime plays it (tools/call with the caller in _meta; mesh/state.*
|
||||
// and mesh/publish answered from a real nats-server's key-value buckets and subjects), its store in a directory of its
|
||||
// own. A want written to the grants part's state is minted only once the controller's lease is handed away: the hash is
|
||||
// in bus-accounts, own answers the value sealed to the node, the value is the controller's shape and its hash's
|
||||
// password, account-issued is said; an adopted user is never minted, and a lease handed back mints nothing.
|
||||
// own. A want written to the grants part's state is minted whoever holds the controller's lease (issue 774: the vault is
|
||||
// the one issuer): the hash is in bus-accounts, own answers the value sealed to the node, the value is the controller's
|
||||
// shape and its hash's password, account-issued is said; an adopted user is never minted.
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
@@ -297,7 +297,7 @@ func eventually(t *testing.T, what string, ok func() bool) {
|
||||
}
|
||||
|
||||
// 774/AC12.
|
||||
func TestTheVaultWholeMintsOnTheBusOnlyFromTheLeaseWrite(t *testing.T) {
|
||||
func TestTheVaultWholeMintsOnTheBusWhoeverHoldsTheLease(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
t.Cleanup(cancel)
|
||||
dir := t.TempDir()
|
||||
@@ -358,21 +358,16 @@ func TestTheVaultWholeMintsOnTheBusOnlyFromTheLeaseWrite(t *testing.T) {
|
||||
_ = json.Unmarshal(raw, &s)
|
||||
return s.Minting
|
||||
}
|
||||
// The controller holds the lease: a want is held back, nothing is minted.
|
||||
// The controller holds the lease, and the want is minted all the same.
|
||||
p.put(t, "mesh-controller.lease", LeaseKey, map[string]any{"instance": "novox/1/2026-10-27", "epoch": 57})
|
||||
x, y, z := aWant("mod-x"), aWant("mod-y"), aWant("mod-z")
|
||||
x, y := aWant("mod-x"), aWant("mod-y")
|
||||
p.put(t, "mesh-vault.bus-accounts", y.User, AccountHash{User: y.User, Hash: "$2a$04$adoptedadoptedadoptedadoptedadoptedadoptedadopte", Adopted: true})
|
||||
p.put(t, "mesh-grants.accounts", x.User, x)
|
||||
p.put(t, "mesh-grants.accounts", y.User, y)
|
||||
eventually(t, "the want held back under the controller's lease", func() bool {
|
||||
return strings.Contains(status().HeldBack[x.User], "controller holds the lease")
|
||||
})
|
||||
if p.hashOf(t, x.User) != nil {
|
||||
t.Fatal("minted under the controller's lease")
|
||||
eventually(t, "the account minted under the controller's lease", func() bool { return p.hashOf(t, x.User) != nil })
|
||||
if st := status(); !st.On || st.Lease || len(st.HeldBack) != 0 {
|
||||
t.Errorf("vault_status says %+v", st)
|
||||
}
|
||||
// The write that hands the lease away mints it.
|
||||
p.put(t, "mesh-controller.lease", LeaseKey, map[string]any{"handed-to": "the operator's hand-over"})
|
||||
eventually(t, "the account minted at the lease write", func() bool { return p.hashOf(t, x.User) != nil })
|
||||
nodePub, nodePriv := aSealingKey(t)
|
||||
if _, err := p.call(Seat+".node-key", "node.ace", map[string]any{"key": nodePub}); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -410,13 +405,4 @@ func TestTheVaultWholeMintsOnTheBusOnlyFromTheLeaseWrite(t *testing.T) {
|
||||
if !strings.Contains(said, AccountIssuedEvent+` {"`) || !strings.Contains(said, `"user":"ace.mod-x"`) || strings.Contains(said, held.Password) {
|
||||
t.Errorf("the vault said: %s", said)
|
||||
}
|
||||
// Handed back to a controller: a new want mints nothing.
|
||||
p.put(t, "mesh-controller.lease", LeaseKey, map[string]any{"instance": "novox/2/2026-10-28", "epoch": 58})
|
||||
p.put(t, "mesh-grants.accounts", z.User, z)
|
||||
eventually(t, "the new want held back once the lease is the controller's again", func() bool {
|
||||
return strings.Contains(status().HeldBack[z.User], "controller holds the lease")
|
||||
})
|
||||
if p.hashOf(t, z.User) != nil {
|
||||
t.Error("minted after the lease was handed back")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,51 +106,55 @@ func TestTheVaultMintsAWantedAccountOnceAndNothingItHolds(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 774/AC8 (novox/hq ADR 0561 §3): while the controller holds the lease nothing is minted and vault_status says why and
|
||||
// names each wanted account held back (never an adopted one); the write that hands the lease away mints them; a lease
|
||||
// handed back, expired or unreadable turns minting off again, read afresh before every mint.
|
||||
func TestNothingIsMintedUntilTheLeaseIsHandedAway(t *testing.T) {
|
||||
// 774/AC8, after research 072's gap "vault mints after the lease": the vault is the one issuer, so a wanted account is
|
||||
// minted while the controller still holds the lease (never an adopted one), vault_status says minting is on and the
|
||||
// lease apart, and a want held back is not said held back once the vault holds its value. During the delivery an older
|
||||
// controller may still mint: its value handed with origin issued never replaces the account the vault minted.
|
||||
func TestTheVaultMintsWhoeverHoldsTheLease(t *testing.T) {
|
||||
v, accounts, _ := mintingVault(t, false)
|
||||
accounts.keys = map[string]AccountHash{"ace.mod-y": {User: "ace.mod-y", Hash: "$2a$04$adopted", Adopted: true}}
|
||||
for _, m := range []string{"mod-x", "mod-y"} {
|
||||
w := aWant(m)
|
||||
v.WantHeard(w.User, wantRaw(t, w), false)
|
||||
}
|
||||
st := v.MintState()
|
||||
if st.On || !strings.Contains(st.Why, "controller holds the lease") || len(st.HeldBack) != 1 ||
|
||||
!strings.Contains(st.HeldBack["ace.mod-x"], "controller holds the lease") {
|
||||
t.Fatalf("under the controller's lease vault_status says %+v", st)
|
||||
}
|
||||
if _, held, _ := v.Store.Read("own/ace/mod-x/broker"); held {
|
||||
t.Fatal("minted while the controller holds the lease")
|
||||
}
|
||||
// Handed away: the wanted account is minted at that write, the adopted one is not.
|
||||
lease := []byte(`{"handed-to":"the operator's hand-over"}`)
|
||||
v.LeaseRead = func() ([]byte, bool, error) { return lease, true, nil }
|
||||
v.LeaseHeard(lease, true, nil)
|
||||
if _, held, _ := v.Store.Read("own/ace/mod-x/broker"); !held {
|
||||
t.Fatalf("not minted at the lease write: %+v", v.MintState())
|
||||
minted, held, _ := v.Store.Read("own/ace/mod-x/broker")
|
||||
if !held {
|
||||
t.Fatalf("not minted under the controller's lease: %+v", v.MintState())
|
||||
}
|
||||
if _, held, _ := v.Store.Read("own/ace/mod-y/broker"); held {
|
||||
t.Error("an adopted account was minted at the lease write")
|
||||
t.Error("an adopted account was minted")
|
||||
}
|
||||
if st := v.MintState(); !st.On || len(st.HeldBack) != 0 {
|
||||
t.Errorf("after the lease write vault_status says %+v", st)
|
||||
st := v.MintState()
|
||||
if !st.On || st.Lease || !strings.Contains(st.LeaseWhy, "controller holds the lease") || len(st.HeldBack) != 0 {
|
||||
t.Errorf("under the controller's lease vault_status says %+v", st)
|
||||
}
|
||||
// The key expired (no watch hears a key's age pass): read afresh before the next mint, nothing is minted.
|
||||
v.LeaseRead = func() ([]byte, bool, error) { return nil, false, nil }
|
||||
// An older controller's covered account, handed with origin issued: refused, the vault's value kept.
|
||||
x := aWant("mod-x")
|
||||
theirs, _ := buscompose.HeldCredential(x, "the-controllers-password")
|
||||
if _, err := handIssued(t, v, ControllerCaller, "own/ace/mod-x/broker", string(theirs)); err == nil ||
|
||||
!strings.Contains(err.Error(), "the vault minted") {
|
||||
t.Errorf("a controller's issued value over the vault's mint: %v", err)
|
||||
}
|
||||
if rec, _, _ := v.Store.Read("own/ace/mod-x/broker"); rec.Generation != minted.Generation || rec.ChangedBy != "the vault" {
|
||||
t.Errorf("the vault's minted account was replaced: %+v", rec)
|
||||
}
|
||||
// Held back, and then held (moved in, handed): no longer said held back.
|
||||
z := aWant("mod-z")
|
||||
v.WantHeard(z.User, wantRaw(t, z), false)
|
||||
if _, held, _ := v.Store.Read("own/ace/mod-z/broker"); held {
|
||||
t.Error("minted after the lease's key expired")
|
||||
v.minting.mu.Lock()
|
||||
v.minting.wants[z.User] = z
|
||||
v.minting.mu.Unlock()
|
||||
v.holdBack(z.User, "held back for a reason that has passed")
|
||||
value, _ := buscompose.HeldCredential(z, "moved-in")
|
||||
if _, err := v.keep("own/ace/mod-z/broker", string(value), "the move", ControllerCaller, OriginMade); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if st := v.MintState(); st.On || !strings.Contains(st.HeldBack["ace.mod-z"], "no lease is written") {
|
||||
t.Errorf("after the key expired vault_status says %+v", st)
|
||||
if st := v.MintState(); st.HeldBack[z.User] != "" {
|
||||
t.Errorf("an account the vault holds is said held back: %+v", st.HeldBack)
|
||||
}
|
||||
}
|
||||
|
||||
// 774/AC10 issue-account (novox/hq ADR 0561 §9): the verb mints one wanted user now, read afresh; it is refused while
|
||||
// minting is off, for a node's account and the controller's, and for a user nobody wants; a user held already is said so.
|
||||
// 774/AC10 issue-account (novox/hq ADR 0561 §9): the verb mints one wanted user now, read afresh, whoever holds the lease;
|
||||
// it is refused for a node's account and the controller's, and for a user nobody wants; a user held already is said so.
|
||||
func TestIssueAccountMintsOneWantedUserNowAndRefusesTheRest(t *testing.T) {
|
||||
v, _, _ := mintingVault(t, false)
|
||||
w := aWant("mod-x")
|
||||
@@ -162,7 +166,6 @@ func TestIssueAccountMintsOneWantedUserNowAndRefusesTheRest(t *testing.T) {
|
||||
return nil, false, nil
|
||||
}
|
||||
for user, says := range map[string]string{
|
||||
"ace.mod-x": "controller holds the lease",
|
||||
"node.ace": "never minted",
|
||||
"controller": "never minted",
|
||||
} {
|
||||
@@ -170,7 +173,7 @@ func TestIssueAccountMintsOneWantedUserNowAndRefusesTheRest(t *testing.T) {
|
||||
t.Errorf("%s: %v, want a refusal saying %q", user, err, says)
|
||||
}
|
||||
}
|
||||
v.LeaseHeard([]byte(`{"handed-to":"the operator's hand-over"}`), true, nil)
|
||||
// Minted while the controller still holds the lease (issue 774): `module issue` forwards here.
|
||||
if _, err := v.IssueAccount("person.jochen", "ace.nobody"); err == nil || !strings.Contains(err.Error(), "no wanted account") {
|
||||
t.Errorf("a user nobody wants: %v", err)
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ package main
|
||||
// provisioner is left out: its client fills its secrets from `get` (ADR 0349 §17).
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
@@ -38,6 +37,11 @@ type OwnAnswer struct {
|
||||
Own []NodeOwnSecret `json:"own"`
|
||||
Credentials []NodeCredential `json:"credentials,omitempty"`
|
||||
Missing []CredentialAsked `json:"missing,omitempty"`
|
||||
// Handed is, by module of the machine, whether the vault judged it handed to the provisioner (catalogue.AssignmentHands
|
||||
// of the plan in its assignment), and so left its own secrets out of Own: the node composing itself hands exactly
|
||||
// those (mesh-sdk compose/frombus HandedReader, issue 774). Every module it holds a secret of, and every module
|
||||
// placed there whose plan reads; one it could not judge is not named, and the node judges it as before.
|
||||
Handed map[string]bool `json:"handed,omitempty"`
|
||||
}
|
||||
|
||||
// CredentialAsked names one pair credential by its ends, as a node composing its own asks for it (mesh-sdk
|
||||
@@ -163,6 +167,15 @@ func (p *planReader) placedOn(module string) (bool, error) {
|
||||
return slices.Contains(p.placed, module), p.placedErr
|
||||
}
|
||||
|
||||
// placedModules is the machine's placed modules, read once for the answer.
|
||||
func (p *planReader) placedModules() ([]string, error) {
|
||||
if p.placement == nil {
|
||||
return nil, errors.New("the machine's placement is not read here")
|
||||
}
|
||||
p.placedOn("")
|
||||
return p.placed, p.placedErr
|
||||
}
|
||||
|
||||
// read is module's assignment on the machine, nil when there is none, or why the vault reads none.
|
||||
func (p *planReader) read(module string) ([]byte, string, error) {
|
||||
if p.why != "" {
|
||||
@@ -283,7 +296,7 @@ func (v *Vault) ownOfNode(caller string, asked ...CredentialAsked) (OwnAnswer, e
|
||||
}
|
||||
plan := v.plansOf(node)
|
||||
var handings []Handing
|
||||
out := OwnAnswer{Node: node, Key: key}
|
||||
out := OwnAnswer{Node: node, Key: key, Handed: map[string]bool{}}
|
||||
for _, r := range all {
|
||||
parts := strings.Split(r.Name, "/")
|
||||
if len(parts) != 4 || parts[0] != "own" || parts[1] != node {
|
||||
@@ -293,6 +306,7 @@ func (v *Vault) ownOfNode(caller string, asked ...CredentialAsked) (OwnAnswer, e
|
||||
if err != nil {
|
||||
return OwnAnswer{}, err
|
||||
}
|
||||
out.Handed[parts[2]] = handed
|
||||
if handed {
|
||||
continue
|
||||
}
|
||||
@@ -308,6 +322,17 @@ func (v *Vault) ownOfNode(caller string, asked ...CredentialAsked) (OwnAnswer, e
|
||||
out.Own = append(out.Own, NodeOwnSecret{Module: parts[2], Name: parts[3], Origin: composeOrigin(r.Origin),
|
||||
Generation: r.Generation, Sealed: sealed})
|
||||
}
|
||||
// And every module placed there that it holds no secret of yet, where its plan reads: the node hands it as judged here.
|
||||
if placed, err := plan.placedModules(); err == nil {
|
||||
for _, module := range placed {
|
||||
if _, judged := out.Handed[module]; judged {
|
||||
continue
|
||||
}
|
||||
if handed, err := plan.handed(module); err == nil {
|
||||
out.Handed[module] = handed
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Slice(out.Own, func(i, j int) bool {
|
||||
if out.Own[i].Module != out.Own[j].Module {
|
||||
return out.Own[i].Module < out.Own[j].Module
|
||||
@@ -392,15 +417,7 @@ func (p *planReader) handed(module string) (bool, error) {
|
||||
// handed) hands nothing, and that module's files and own secrets stay its node's composition. Whether a plan exists
|
||||
// is never the rule; a reader that asks only who binds whom reads the provisions and says so ("not handing", which
|
||||
// the catalogue check checks/planhands holds every module to).
|
||||
func handsOf(raw []byte) (bool, error) {
|
||||
var a struct {
|
||||
Provisioning *catalogue.Plan `json:"provisioning"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &a); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return a.Provisioning != nil && a.Provisioning.Hands(), nil
|
||||
}
|
||||
func handsOf(raw []byte) (bool, error) { return catalogue.AssignmentHands(raw) }
|
||||
|
||||
// hands is handsOf for a value already read as the controller's: one that cannot be read hands nothing.
|
||||
func hands(raw []byte) bool {
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -286,4 +287,8 @@ func TestOwnLeavesOutOnlyAModuleWhosePlanHandsIt(t *testing.T) {
|
||||
if len(got.Own) != 1 || got.Own[0].Module != "grafana" || got.Own[0].Name != "admin" {
|
||||
t.Errorf("anchor was answered %+v; want grafana's admin alone (its plan keys a route and hands nothing)", got.Own)
|
||||
}
|
||||
// And it says how it judged each, so the node composing itself hands exactly those (issue 774, postgres on novox).
|
||||
if want := map[string]bool{"grafana": false, "web": true, "db": true}; !reflect.DeepEqual(got.Handed, want) {
|
||||
t.Errorf("own judged %v handed, want %v", got.Handed, want)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,8 @@ type PlacementReader struct {
|
||||
Read func(key string) ([]byte, error)
|
||||
}
|
||||
|
||||
// placements is every node's placement, by node; none when the vault reads no placement.
|
||||
// placements is every node's placement, by node, from the state's keys in either form (delivery.PlacementsOf; research
|
||||
// 072 row 37); none when the vault reads no placement. A node whose keys do not read whole is read as they stand.
|
||||
func (v *Vault) placements() (map[string]delivery.Placement, error) {
|
||||
out := map[string]delivery.Placement{}
|
||||
if v.Placement.Keys == nil || v.Placement.Read == nil {
|
||||
@@ -35,52 +36,73 @@ func (v *Vault) placements() (map[string]delivery.Placement, error) {
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the placement's keys could not be read: %w", err)
|
||||
}
|
||||
values := map[string][]byte{}
|
||||
for _, k := range keys {
|
||||
node, err := delivery.ParsePlacementKey(k)
|
||||
if err != nil {
|
||||
if _, _, _, err := delivery.ParsePlacementKey(k); err != nil {
|
||||
continue
|
||||
}
|
||||
raw, err := v.Placement.Read(k)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the placement of %s could not be read: %w", node, err)
|
||||
return nil, fmt.Errorf("the placement's key %s could not be read: %w", k, err)
|
||||
}
|
||||
if raw == nil {
|
||||
continue
|
||||
if raw != nil {
|
||||
values[k] = raw
|
||||
}
|
||||
p, err := delivery.DecodePlacement(raw)
|
||||
if err != nil || p.Node != node {
|
||||
continue
|
||||
}
|
||||
out[node] = p
|
||||
}
|
||||
out, _ = delivery.PlacementsOf(values)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// nodePlaced is what node's own key of the placement says: the modules placed there and what a person decided of it,
|
||||
// read from its node key `<node>.node` alone (which names every module placed, research 072 row 37), or, for a node not
|
||||
// converted yet, from its key in the form before, `placement.<node>`. found is false when it has neither.
|
||||
func (v *Vault) nodePlaced(node string) (modules []string, decided delivery.NodeDecided, found bool, err error) {
|
||||
key, err := delivery.PlacementNodeKey(node)
|
||||
if err != nil {
|
||||
return nil, decided, false, err
|
||||
}
|
||||
raw, err := v.Placement.Read(key)
|
||||
if err != nil {
|
||||
return nil, decided, false, err
|
||||
}
|
||||
if raw != nil {
|
||||
n, err := delivery.DecodePlacementNode(raw)
|
||||
if err != nil {
|
||||
return nil, decided, false, fmt.Errorf("the placement of %s is not readable: %w", node, err)
|
||||
}
|
||||
for _, r := range n.Modules {
|
||||
modules = append(modules, r.Module)
|
||||
}
|
||||
return modules, n.Decided, true, nil
|
||||
}
|
||||
legacy, err := delivery.LegacyPlacementKey(node)
|
||||
if err != nil {
|
||||
return nil, decided, false, err
|
||||
}
|
||||
if raw, err = v.Placement.Read(legacy); err != nil || raw == nil {
|
||||
return nil, decided, false, err
|
||||
}
|
||||
p, err := delivery.DecodePlacement(raw)
|
||||
if err != nil {
|
||||
return nil, decided, false, fmt.Errorf("the placement of %s is not readable: %w", node, err)
|
||||
}
|
||||
return p.Assigned(), p.Decided, true, nil
|
||||
}
|
||||
|
||||
// placedOn says module is placed on node by the catalogue.
|
||||
func (v *Vault) placedOn(node, module string) (bool, error) {
|
||||
placed, err := v.placedModules(node)
|
||||
return slices.Contains(placed, module), err
|
||||
}
|
||||
|
||||
// placedModules is every module the catalogue places on node, read from the node's one key of the placement; none when
|
||||
// the vault reads no placement or the node has no key.
|
||||
// placedModules is every module the catalogue places on node, read from the node's own key of the placement
|
||||
// (nodePlaced); none when the vault reads no placement or the node has no key.
|
||||
func (v *Vault) placedModules(node string) ([]string, error) {
|
||||
if v.Placement.Read == nil {
|
||||
return nil, nil
|
||||
}
|
||||
key, err := delivery.PlacementKey(node)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
raw, err := v.Placement.Read(key)
|
||||
if err != nil || raw == nil {
|
||||
return nil, err
|
||||
}
|
||||
p, err := delivery.DecodePlacement(raw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the placement of %s is not readable: %w", node, err)
|
||||
}
|
||||
return p.Assigned(), nil
|
||||
modules, _, _, err := v.nodePlaced(node)
|
||||
return modules, err
|
||||
}
|
||||
|
||||
// standsOn says module stands on node: placed there, or assigned there while the assignments stand. A read that fails is
|
||||
|
||||
@@ -16,12 +16,14 @@ func placementsOf(t *testing.T, nodes map[string][]string) PlacementReader {
|
||||
for _, m := range modules {
|
||||
p.Modules = append(p.Modules, delivery.PlacedModule{Module: m})
|
||||
}
|
||||
raw, err := delivery.EncodePlacement(p)
|
||||
// Research 072 row 37: the node key and a key per module.
|
||||
kv, err := delivery.PlacementValues(p)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
key, _ := delivery.PlacementKey(node)
|
||||
values[key] = raw
|
||||
for k, raw := range kv {
|
||||
values[k] = raw
|
||||
}
|
||||
}
|
||||
return PlacementReader{
|
||||
Keys: func() ([]string, error) {
|
||||
|
||||
@@ -136,7 +136,7 @@ func (v *Vault) RetirePass() {
|
||||
return
|
||||
}
|
||||
v.leaseAfresh()
|
||||
on, leaseWhy := v.mintingOn()
|
||||
on, leaseWhy := v.leaseHanded()
|
||||
m := &v.minting
|
||||
m.mu.Lock()
|
||||
read := m.retiring.wantsRead
|
||||
@@ -239,7 +239,7 @@ func (v *Vault) RetireAccounts(caller string, users []string, why string) (any,
|
||||
// As the passes: never while the controller holds the lease (it forgets the accounts it mints), nor before the wanted
|
||||
// accounts are read whole (an account not read yet is not known to be gone).
|
||||
v.leaseAfresh()
|
||||
if on, why := v.mintingOn(); !on {
|
||||
if on, why := v.leaseHanded(); !on {
|
||||
return nil, fmt.Errorf("nothing was retired: %s", why)
|
||||
}
|
||||
v.minting.mu.Lock()
|
||||
|
||||
@@ -42,7 +42,7 @@ func (v *Vault) BeginRotation(user string) (Rotating, error) {
|
||||
if nodeOrController(user) {
|
||||
return Rotating{}, fmt.Errorf("%s does not rotate here: a node's account and the controller's keep the hash they have (ADR 0561 §4)", user)
|
||||
}
|
||||
if on, why := v.mintingOn(); !on {
|
||||
if on, why := v.leaseHanded(); !on {
|
||||
return Rotating{}, fmt.Errorf("no rotation begins: %s", why)
|
||||
}
|
||||
v.minting.mu.Lock()
|
||||
|
||||
@@ -303,22 +303,14 @@ func (v *Vault) internalName(node string) (string, error) {
|
||||
if v.Placement.Read == nil {
|
||||
return "", errors.New("the vault reads no placement, so it cannot tell a node's internal name")
|
||||
}
|
||||
key, err := delivery.PlacementKey(node)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
raw, err := v.Placement.Read(key)
|
||||
_, decided, found, err := v.nodePlaced(node)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf(notYetPrefix+"the placement of %s could not be read: %w", node, err)
|
||||
}
|
||||
if raw == nil {
|
||||
if !found {
|
||||
return "", fmt.Errorf("%s has no placement of the seat mesh-placement, so it has no name inside the mesh", node)
|
||||
}
|
||||
p, err := delivery.DecodePlacement(raw)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("the placement of %s is not readable: %w", node, err)
|
||||
}
|
||||
if p.Decided.Address == "" {
|
||||
if decided.Address == "" {
|
||||
return "", fmt.Errorf("%s is not on the private network (its placement names no address), so it has no name inside the mesh to be certified for", node)
|
||||
}
|
||||
return overlay.InternalName(node), nil
|
||||
|
||||
@@ -40,16 +40,21 @@ func TestTheVaultSigns(t *testing.T) {
|
||||
"g14": {Node: "g14", Decided: delivery.NodeDecided{Address: "10.77.0.4"}},
|
||||
"plain": {Node: "plain"},
|
||||
}
|
||||
// Research 072 row 37: ace and plain in their keys per module and node, g14 still in the form before.
|
||||
v.Placement = PlacementReader{Read: func(key string) ([]byte, error) {
|
||||
node, err := delivery.ParsePlacementKey(key)
|
||||
node, _, kind, err := delivery.ParsePlacementKey(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p, ok := placements[node]
|
||||
if !ok {
|
||||
if !ok || (node == "g14") != (kind == delivery.PlacementLegacyKind) {
|
||||
return nil, nil
|
||||
}
|
||||
return delivery.EncodePlacement(p)
|
||||
if kind == delivery.PlacementLegacyKind {
|
||||
return delivery.EncodePlacement(p)
|
||||
}
|
||||
values, err := delivery.PlacementValues(p)
|
||||
return values[key], err
|
||||
}}
|
||||
records := map[string]string{"ace": base64.StdEncoding.EncodeToString(servingPub), "plain": base64.StdEncoding.EncodeToString(servingPub)}
|
||||
v.Haves = func(key string) ([]byte, error) {
|
||||
|
||||
@@ -306,9 +306,9 @@ func definitions(env *Env) []toolDef {
|
||||
}},
|
||||
{Name: Seat + ".issue-account",
|
||||
Description: "Mint one bus account the grants part wants, now (novox/hq ADR 0561 §9): the recovery when the vault did not " +
|
||||
"hear a want. Reads the wanted key afresh from mesh-grants.accounts and mints it as a want would: only while " +
|
||||
"the controller's lease is handed away, never for a node's account or the controller's, never a user nobody " +
|
||||
"wants, and never anew for one whose value the vault holds or whose hash it adopted (it says so). Answers " +
|
||||
"hear a want, and what the controller's `module issue` forwards to (issue 774). Reads the wanted key afresh from " +
|
||||
"mesh-grants.accounts and mints it as a want would, whoever holds the controller's lease: never for a node's " +
|
||||
"account or the controller's, never a user nobody wants, and never anew for one whose value the vault holds or whose hash it adopted (it says so). Answers " +
|
||||
"whether it minted, the secret and generation, never the value or the hash.",
|
||||
Input: map[string]any{"user": str("the bus user, e.g. ace.mesh-build-go")},
|
||||
run: func(caller string, a map[string]any) (any, error) {
|
||||
@@ -633,8 +633,9 @@ func definitions(env *Env) []toolDef {
|
||||
{Name: "vault_status",
|
||||
Description: "What needs a person's eye in the vault: operator copies still sealed to an earlier key, the operator " +
|
||||
"key the mesh's export holds if the vault refused it, how often the record of hand-outs was rolled, and " +
|
||||
"minting (novox/hq ADR 0561 §3, §9): whether the vault mints bus accounts now and why (only while the " +
|
||||
"controller's lease is handed away), every wanted account it holds back with why, and those it minted.",
|
||||
"minting (novox/hq ADR 0561 §9, issue 774): the vault mints every wanted bus account it holds no value for, " +
|
||||
"whoever holds the controller's lease; whether that lease is handed away (it turns retirement and rotation), " +
|
||||
"every wanted account it holds back with why (never one whose value it holds), and those it minted.",
|
||||
run: func(string, map[string]any) (any, error) {
|
||||
if env.Vault == nil {
|
||||
return map[string]any{"ready": false, "error": fmt.Sprint(env.Broken)}, nil
|
||||
|
||||
@@ -3,7 +3,7 @@ module mesh-vault
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030225009-17fc9b9b8115
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261031084708-acbf80ad910a
|
||||
github.com/nats-io/nats-server/v2 v2.11.17
|
||||
github.com/nats-io/nats.go v1.54.0
|
||||
golang.org/x/crypto v0.57.0
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030225009-17fc9b9b8115 h1:ezKg8aAKQoZ5XEAWQQVk4y/x0h2qC73GcUKBmbqY5jw=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030225009-17fc9b9b8115/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261031082917-2d1d8def7535 h1:Bgrm3YfWCxr+FEm03kEP5fDIe9hNbe+j0CDVatGAGKg=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261031082917-2d1d8def7535/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261031082933-085f17e935db h1:PJ1V9tDjNqp/aogm/r52RRePxcHyW58f7lYBceMMY9c=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261031082933-085f17e935db/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261031084708-acbf80ad910a h1:DcSvfEmdPBKqCg5OmXRNb269EkkPe7BsEp+RykEhkgE=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261031084708-acbf80ad910a/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=
|
||||
|
||||
@@ -112,12 +112,12 @@ func TestTheBussManifestMountsItsProgramsIntoTheUpstreamImage(t *testing.T) {
|
||||
}
|
||||
// The tools that change the bus (issue 1952 N1-N3): listed, their audit events declared, and the credential
|
||||
// they dial with the module's own secret `broker`.
|
||||
for _, want := range []string{"nats_consumer_remove", "nats_bucket_remove"} {
|
||||
for _, want := range []string{"nats_consumer_remove", "nats_bucket_remove", "nats_stream_remove"} {
|
||||
if !slices.Contains(m.Tools, want) {
|
||||
t.Errorf("tools do not list %s", want)
|
||||
}
|
||||
}
|
||||
for _, want := range []string{"consumer-removed", "bucket-keys-removed"} {
|
||||
for _, want := range []string{"consumer-removed", "bucket-keys-removed", "stream-removed"} {
|
||||
if !slices.Contains(m.Emits, want) {
|
||||
t.Errorf("emits do not list %s: the removal tools say it as the audit line", want)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package main
|
||||
|
||||
// The two tools of this module that change the bus (novox/hq ADR 0564 clause 5, issue 1952 notes 6, 7 and 9):
|
||||
// remove one named consumer, and remove one node's keys under a prefix of a key-value bucket. Everything else
|
||||
// The three tools of this module that change the bus (novox/hq ADR 0564 clause 5, issue 1952 notes 6, 7 and 9):
|
||||
// remove one named consumer, remove one named stream, and remove one node's keys under a prefix of a key-value bucket. Everything else
|
||||
// here reads.
|
||||
//
|
||||
// **Narrow by what the tool needs, and narrower by what the bus grants.** The tools reach the bus on the
|
||||
@@ -34,6 +34,7 @@ type Said func(event string, body any) error
|
||||
const (
|
||||
consumerRemoved = "consumer-removed"
|
||||
bucketKeysRemoved = "bucket-keys-removed"
|
||||
streamRemoved = "stream-removed"
|
||||
// maxKeysShown bounds the keys an answer lists; the count is always whole.
|
||||
maxKeysShown = 200
|
||||
)
|
||||
@@ -120,9 +121,13 @@ func RemoveConsumer(ctx context.Context, bus Bus, said Said, a map[string]any) (
|
||||
sctx, cancel = step(ctx)
|
||||
_, err = js.Consumer(sctx, stream, consumer)
|
||||
cancel()
|
||||
// Only the bus saying it is not there is a removal; a read that failed otherwise confirms nothing.
|
||||
if err == nil {
|
||||
return nil, fmt.Errorf("consumer %s of %s is still there after the removal was accepted", consumer, stream)
|
||||
}
|
||||
if !errors.Is(err, jetstream.ErrConsumerNotFound) && !errors.Is(err, jetstream.ErrStreamNotFound) {
|
||||
return nil, unanswered(err, fmt.Sprintf("the removal of consumer %s of %s was accepted but could not be confirmed", consumer, stream))
|
||||
}
|
||||
out := ConsumerRemoved{Stream: stream, Consumer: consumer, Why: w, Was: was, Removed: true, At: time.Now().UTC()}
|
||||
if err := said(consumerRemoved, out); err != nil {
|
||||
return nil, fmt.Errorf("consumer %s of %s WAS removed (%s) but the audit event could not be said: %w", consumer, stream, w, err)
|
||||
@@ -227,7 +232,89 @@ func RemoveKeys(ctx context.Context, bus Bus, said Said, a map[string]any) (any,
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// removalTools are the module's two tools that change the bus; every other tool here reads.
|
||||
// StreamRemoved is what nats_stream_remove answers and says.
|
||||
type StreamRemoved struct {
|
||||
Stream string `json:"stream"`
|
||||
Why string `json:"why"`
|
||||
DryRun bool `json:"dry_run"`
|
||||
Present bool `json:"present"`
|
||||
Messages uint64 `json:"messages"`
|
||||
Bytes uint64 `json:"bytes"`
|
||||
Consumers int `json:"consumers"`
|
||||
Removed bool `json:"removed"`
|
||||
Said string `json:"said"`
|
||||
At time.Time `json:"at"`
|
||||
}
|
||||
|
||||
// RemoveStream removes one named stream whole, after reading it, and says so (the lease write's step for the stream
|
||||
// ASSIGNMENTS, novox/hq research 072 row 33). **A stream already gone is no error**: the removal is the same act
|
||||
// however often it is asked, and says it found nothing. A stream with a consumer is refused, naming how many: a reader
|
||||
// is still attached, and that is for a person to look at before the stream goes.
|
||||
func RemoveStream(ctx context.Context, bus Bus, said Said, a map[string]any) (any, error) {
|
||||
w, err := why(a)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stream := arg(a, "stream")
|
||||
if !name.MatchString(stream) {
|
||||
return nil, errors.New("stream is required, one stream's name (letters, digits, - and _): nothing was removed")
|
||||
}
|
||||
dry, _ := a["dry_run"].(bool)
|
||||
js, closeBus, err := bus(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer closeBus()
|
||||
out := StreamRemoved{Stream: stream, Why: w, DryRun: dry, At: time.Now().UTC()}
|
||||
sctx, cancel := step(ctx)
|
||||
s, err := js.Stream(sctx, stream)
|
||||
cancel()
|
||||
if errors.Is(err, jetstream.ErrStreamNotFound) {
|
||||
out.Said = stream + " is not there: nothing to remove"
|
||||
return out, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, unanswered(err, fmt.Sprintf("the stream %s could not be read, so it was not removed", stream))
|
||||
}
|
||||
sctx, cancel = step(ctx)
|
||||
info, err := s.Info(sctx)
|
||||
cancel()
|
||||
if err != nil {
|
||||
return nil, unanswered(err, fmt.Sprintf("the stream %s could not be read, so it was not removed", stream))
|
||||
}
|
||||
out.Present, out.Messages, out.Bytes, out.Consumers = true, info.State.Msgs, info.State.Bytes, info.State.Consumers
|
||||
if info.State.Consumers > 0 {
|
||||
return nil, fmt.Errorf("%s has %d consumers: a reader is attached, so it was not removed", stream, info.State.Consumers)
|
||||
}
|
||||
if dry {
|
||||
out.Said = fmt.Sprintf("%s holds %d messages (%d bytes) and no consumer; it would be removed", stream, out.Messages, out.Bytes)
|
||||
return out, nil
|
||||
}
|
||||
sctx, cancel = step(ctx)
|
||||
err = js.DeleteStream(sctx, stream)
|
||||
cancel()
|
||||
if err != nil {
|
||||
return nil, unanswered(err, fmt.Sprintf("stream %s was not removed", stream))
|
||||
}
|
||||
sctx, cancel = step(ctx)
|
||||
_, err = js.Stream(sctx, stream)
|
||||
cancel()
|
||||
// Only the bus saying it is not there is a removal; a read that failed otherwise confirms nothing.
|
||||
if err == nil {
|
||||
return nil, fmt.Errorf("stream %s is still there after the removal was accepted", stream)
|
||||
}
|
||||
if !errors.Is(err, jetstream.ErrStreamNotFound) {
|
||||
return nil, unanswered(err, fmt.Sprintf("the removal of stream %s was accepted but could not be confirmed", stream))
|
||||
}
|
||||
out.Removed = true
|
||||
out.Said = fmt.Sprintf("%s removed: %d messages (%d bytes) and no consumer", stream, out.Messages, out.Bytes)
|
||||
if err := said(streamRemoved, out); err != nil {
|
||||
return nil, fmt.Errorf("stream %s WAS removed (%s) but the audit event could not be said: %w", stream, w, err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// removalTools are the module's three tools that change the bus; every other tool here reads.
|
||||
func removalTools(bus Bus, said Said) []stdio.Tool {
|
||||
run := func(f func(context.Context, Bus, Said, map[string]any) (any, error)) func(map[string]any) (any, error) {
|
||||
return func(a map[string]any) (any, error) { return f(context.Background(), bus, said, a) }
|
||||
@@ -237,6 +324,10 @@ func removalTools(bus Bus, said Said) []stdio.Tool {
|
||||
Description: "Removes (deletes) one named JetStream consumer of one stream, e.g. the consumer `controller` of DEAD_LETTER_NOTICES at the lease write (novox/hq ADR 0564 clause 5). Needs `why`, kept in the audit trail; refuses an unknown stream or consumer; reads the consumer first and answers what it was (filters, pending, unacknowledged); the stream and its other consumers are not touched. The bus refuses any consumer the module's credential is not granted.",
|
||||
Input: map[string]any{"stream": str("the stream, e.g. DEAD_LETTER_NOTICES"), "consumer": str("the consumer's name, e.g. controller"), "why": str("why it is removed, for the audit trail (required)")},
|
||||
Run: run(RemoveConsumer)},
|
||||
{Name: "nats_stream_remove",
|
||||
Description: "Removes (deletes) one named JetStream stream whole, e.g. ASSIGNMENTS at the lease write, beside the consumer of DEAD_LETTER_NOTICES (novox/hq research 072 row 33). Needs `why`, kept in the audit trail; reads the stream first and answers its messages, bytes and consumers; refuses a stream that still has a consumer; a stream already gone is answered as such and is no error, so asking again is safe. `dry_run` only reads. The bus refuses any stream the module's credential is not granted.",
|
||||
Input: map[string]any{"stream": str("the stream, e.g. ASSIGNMENTS"), "why": str("why it is removed, for the audit trail (required)"), "dry_run": map[string]any{"type": "boolean", "description": "read the stream, remove nothing"}},
|
||||
Run: run(RemoveStream)},
|
||||
{Name: "nats_bucket_remove",
|
||||
Description: "Removes (purges) keys from a key-value bucket: one node's keys (`node`, the keys starting `<node>.`) or the keys under a `prefix`, e.g. a full acts bucket of the node-engine. Needs `why`, kept in the audit trail; never the whole bucket. Counts the keys first and lists them (`dry_run` only counts; `expect` refuses when the count differs); removes only those keys, and their history. See the buckets and their keys with nats_buckets. The bus refuses any bucket the module's credential is not granted.",
|
||||
Input: map[string]any{"bucket": str("the bucket as nats_buckets lists it, e.g. mesh-host_acts"), "node": str("a node whose keys (`<node>.`) go"), "prefix": str("key prefix instead of node"),
|
||||
|
||||
@@ -31,6 +31,8 @@ accounts { MESH { jetstream: enabled, users: [
|
||||
publish: { allow: [
|
||||
"$JS.API.CONSUMER.INFO.DEAD_LETTER_NOTICES.controller",
|
||||
"$JS.API.CONSUMER.DELETE.DEAD_LETTER_NOTICES.controller",
|
||||
"$JS.API.STREAM.INFO.ASSIGNMENTS",
|
||||
"$JS.API.STREAM.DELETE.ASSIGNMENTS",
|
||||
"$JS.API.STREAM.INFO.KV_mesh-host_acts",
|
||||
"$KV.mesh-host_acts.>" ] },
|
||||
subscribe: { allow: ["_INBOX.nats-tools.>"] } } }
|
||||
@@ -73,6 +75,14 @@ accounts { MESH { jetstream: enabled, users: [
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := ajs.CreateStream(ctx, jetstream.StreamConfig{Name: "ASSIGNMENTS", Subjects: []string{"mesh.assignment.*.*"}, MaxMsgsPerSubject: 1}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, sub := range []string{"mesh.assignment.g14.a", "mesh.assignment.ace.b"} {
|
||||
if _, err := ajs.Publish(ctx, sub, []byte("{}")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
other, _ := ajs.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: "other_state"})
|
||||
for _, k := range []string{"g14.1", "g14.2", "g14.sub.3", "g14x.4", "ace.5"} {
|
||||
acts.Put(ctx, k, []byte("v"))
|
||||
@@ -131,6 +141,30 @@ accounts { MESH { jetstream: enabled, users: [
|
||||
t.Fatalf("a consumer already gone: %v", err)
|
||||
}
|
||||
|
||||
// Research 072 row 33: the stream ASSIGNMENTS goes whole, once; read first, and asking again is no error; a stream
|
||||
// outside the grant is not touched.
|
||||
if _, err := RemoveStream(ctx, bus, say, map[string]any{"stream": "ASSIGNMENTS"}); err == nil || !strings.Contains(err.Error(), "why is required") {
|
||||
t.Fatalf("a stream without why: %v", err)
|
||||
}
|
||||
if _, err := RemoveStream(ctx, bus, say, map[string]any{"stream": "DEAD_LETTER_NOTICES", "why": "x"}); err == nil || !strings.Contains(err.Error(), "grant") {
|
||||
t.Fatalf("a stream outside the grant: %v", err)
|
||||
}
|
||||
if d, err := RemoveStream(ctx, bus, say, map[string]any{"stream": "ASSIGNMENTS", "why": "look", "dry_run": true}); err != nil || d.(StreamRemoved).Messages != 2 || d.(StreamRemoved).Removed {
|
||||
t.Fatalf("a dry run: %+v %v", d, err)
|
||||
}
|
||||
if _, err := ajs.Stream(ctx, "ASSIGNMENTS"); err != nil {
|
||||
t.Fatalf("a dry run removed the stream: %v", err)
|
||||
}
|
||||
if r, err := RemoveStream(ctx, bus, say, map[string]any{"stream": "ASSIGNMENTS", "why": "the lease write"}); err != nil || !r.(StreamRemoved).Removed || r.(StreamRemoved).Messages != 2 {
|
||||
t.Fatalf("the removal: %+v %v", r, err)
|
||||
}
|
||||
if _, err := ajs.Stream(ctx, "ASSIGNMENTS"); err == nil {
|
||||
t.Fatal("ASSIGNMENTS is still there")
|
||||
}
|
||||
if r, err := RemoveStream(ctx, bus, say, map[string]any{"stream": "ASSIGNMENTS", "why": "again"}); err != nil || r.(StreamRemoved).Removed || r.(StreamRemoved).Present {
|
||||
t.Fatalf("a second removal: %+v %v", r, err)
|
||||
}
|
||||
|
||||
// N3: only the node's keys go; counted first; refused without why, for a whole bucket, and outside the grant.
|
||||
if _, err := RemoveKeys(ctx, bus, say, map[string]any{"bucket": "mesh-host_acts", "node": "g14"}); err == nil || !strings.Contains(err.Error(), "why is required") {
|
||||
t.Fatalf("without why: %v", err)
|
||||
@@ -167,7 +201,7 @@ accounts { MESH { jetstream: enabled, users: [
|
||||
if k := keys(other); k != "g14.1" {
|
||||
t.Fatalf("another bucket changed: %s", k)
|
||||
}
|
||||
if strings.Join(said, ",") != "consumer-removed,bucket-keys-removed" {
|
||||
if strings.Join(said, ",") != "consumer-removed,stream-removed,bucket-keys-removed" {
|
||||
t.Fatalf("the audit events said: %v", said)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -234,6 +234,10 @@ func TestTheRemovalToolsRefuseBeforeTheyReachTheBus(t *testing.T) {
|
||||
{"nats_bucket_remove", map[string]any{"bucket": "mesh-host_acts", "node": "g14", "prefix": "g", "why": "x"}, "exactly one of node"},
|
||||
{"nats_bucket_remove", map[string]any{"bucket": "mesh-host_acts", "prefix": "g*", "why": "x"}, "without wildcards"},
|
||||
{"nats_bucket_remove", map[string]any{"bucket": "", "node": "g14", "why": "x"}, "bucket is required"},
|
||||
{"nats_stream_remove", map[string]any{"stream": "ASSIGNMENTS"}, "why is required"},
|
||||
{"nats_stream_remove", map[string]any{"stream": "ASSIGNMENTS", "why": " "}, "why is required"},
|
||||
{"nats_stream_remove", map[string]any{"stream": "A.>", "why": "x"}, "stream is required"},
|
||||
{"nats_stream_remove", map[string]any{"stream": "", "why": "x"}, "stream is required"},
|
||||
} {
|
||||
var run func(map[string]any) (any, error)
|
||||
for _, tl := range removalTools(bus, said) {
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@ module nats-tools
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030001116-2da0de75c3f0
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261031084708-acbf80ad910a
|
||||
github.com/klauspost/compress v1.20.0
|
||||
github.com/nats-io/nats-server/v2 v2.11.17
|
||||
github.com/nats-io/nats.go v1.54.0
|
||||
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030001116-2da0de75c3f0 h1:lKU3HjNXGiVbLW2hUgFjU/TMTUYMlNnnmZxG5T7biQA=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030001116-2da0de75c3f0/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261031084708-acbf80ad910a h1:DcSvfEmdPBKqCg5OmXRNb269EkkPe7BsEp+RykEhkgE=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261031084708-acbf80ad910a/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=
|
||||
|
||||
@@ -30,7 +30,8 @@
|
||||
],
|
||||
"emits": [
|
||||
"consumer-removed",
|
||||
"bucket-keys-removed"
|
||||
"bucket-keys-removed",
|
||||
"stream-removed"
|
||||
],
|
||||
"consumes": [],
|
||||
"listens": [
|
||||
@@ -54,7 +55,8 @@
|
||||
"nats_user_can",
|
||||
"nats_snapshot_verify",
|
||||
"nats_consumer_remove",
|
||||
"nats_bucket_remove"
|
||||
"nats_bucket_remove",
|
||||
"nats_stream_remove"
|
||||
],
|
||||
"guards": [
|
||||
8222
|
||||
@@ -208,7 +210,8 @@
|
||||
],
|
||||
"bus": {
|
||||
"removes": [
|
||||
"DEAD_LETTER_NOTICES.controller"
|
||||
"DEAD_LETTER_NOTICES.controller",
|
||||
"ASSIGNMENTS"
|
||||
]
|
||||
},
|
||||
"env": {
|
||||
|
||||
Reference in New Issue
Block a user