mesh-enrolment: catch up only on an ask answered yes, and drop the address key first at confirm, decline and refused add
ADR 0589 version 29, clause 6. At start and after a catch-up an address key is an expiry never heard only when its enrolment key is gone and the router records its ask answered yes; one declined or not answered yet is no expiry, and one not answered stays until its ask ends. A confirm, a decline and a refused add delete the address key before their other steps, so one stopped midway never leaves a key the catch-up would take for an expiry and unplace a node that joined. A refused add (mesh-placement's refusal of the proposal, not a call that did not reach it) now ends the enrolment as refused; a call that failed still waits for again. Tests: rows in the end table (after a confirm stopped before its enrolment key went, after a decline, after a refused add, a declined ask, an ask not answered: nothing unplaced) and the integration test's holder-was-down case reading the yes; no new function. (issue 774) Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
This commit is contained in:
@@ -32,6 +32,11 @@ func (h *Holder) Confirm(caller, node string) (map[string]any, error) {
|
||||
if e.Stage != StageRedeeming {
|
||||
return nil, fmt.Errorf("the enrolment of %s is %s: it is confirmed after redeem", own, e.Stage)
|
||||
}
|
||||
// The address key goes first (ADR 0589 §6): a confirm stopped midway never leaves one the catch-up would take for an
|
||||
// expiry and unplace a node that joined.
|
||||
if err := h.dropAddress(e); err != nil {
|
||||
return nil, fmt.Errorf("%w (call confirm again)", err)
|
||||
}
|
||||
if err := h.sayEvent(EventEnrolled, e, ""); err != nil {
|
||||
return nil, fmt.Errorf("node-enrolled of %s cannot be said, so nothing is deleted (call confirm again): %w", own, err)
|
||||
}
|
||||
|
||||
@@ -131,6 +131,14 @@ func (h *Holder) reconcileLocked() error {
|
||||
if err != nil || r.Ask == "" || open[r.Node] {
|
||||
continue
|
||||
}
|
||||
// Only an ask answered yes made a token and placed the node (ADR 0589 §6): one declined, or not answered yet, is no
|
||||
// expiry, and one not answered stays until its ask ends.
|
||||
if yes, err := h.answeredYes(r.Ask); err != nil {
|
||||
errs = append(errs, fmt.Errorf("the answer to %s cannot be read, so the address %s waits for the next pass: %w", r.Ask, address, err))
|
||||
continue
|
||||
} else if !yes {
|
||||
continue
|
||||
}
|
||||
h.logf("the enrolment of %s (ask %s) is gone and its address %s is still reserved: it expired unseen, and is ended now", r.Node, r.Ask, address)
|
||||
if err := h.endExpired(Enrolment{Node: r.Node, Address: address, Ask: r.Ask, Issued: r.Issued}); err != nil {
|
||||
errs = append(errs, err)
|
||||
@@ -139,6 +147,15 @@ func (h *Holder) reconcileLocked() error {
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
// answeredYes reads the router's record of ask: whether the operator answered it yes.
|
||||
func (h *Holder) answeredYes(ask string) (bool, error) {
|
||||
_, w, err := h.whereIs(ask)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return w != nil && w.Option == "yes", nil
|
||||
}
|
||||
|
||||
// endExpired ends an enrolment whose key expired, in this order, each step safe to do again: the node, placed on the yes and
|
||||
// never confirmed, is taken out of placement on the warrant of its ask (only when placement holds it); enrolment-expired names
|
||||
// its issued event (said under one id, so said again is said once); the vault drops the token; the address key goes last, so
|
||||
|
||||
@@ -2,6 +2,8 @@ package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"reflect"
|
||||
"slices"
|
||||
@@ -72,6 +74,7 @@ func revokeEndsAtAnyStageAndAnExpiryMarkerSaysExpired(t *testing.T) {
|
||||
{"the holder was down, and its drop failed once", "down", "drop down", func(r *rig) error { return r.h.Reconcile() }},
|
||||
} {
|
||||
r.seed(c.node, "10.9.0.9", StageLive)
|
||||
r.asker.answer("ask-"+c.node, "yes")
|
||||
issued := r.now.Add(-time.Minute)
|
||||
_ = r.st.Delete(NodeKey(c.node)) // the bus took the key; the watch never handed its content over
|
||||
if c.fail != "" {
|
||||
@@ -92,6 +95,70 @@ func revokeEndsAtAnyStageAndAnExpiryMarkerSaysExpired(t *testing.T) {
|
||||
t.Errorf("%s: said %s (%v), placed %v, last placement call %+v, vault %v, keys %v", c.name, last.Body, err, placed, removed, r.vault.calls, r.keys())
|
||||
}
|
||||
}
|
||||
// ADR 0589 §6, the negative rows: an address key the catch-up finds with its enrolment key gone is no expiry when its ask
|
||||
// was declined or is not answered yet, and none is left behind by a confirm, a decline or a refused add, which delete it
|
||||
// first. In none of them is the node unplaced.
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
end func(r *rig, node string)
|
||||
key bool // the address key stays (an ask not answered yet stays until its ask ends)
|
||||
}{
|
||||
{"after a confirm stopped before the enrolment key went", func(r *rig, node string) {
|
||||
e, _ := r.enrolment(node)
|
||||
e.Stage, e.KeysDigest = StageRedeeming, delivery.KeysDigest("pk", "ov", "se", "sv")
|
||||
_, _ = r.st.Put(NodeKey(node), e, 0)
|
||||
r.asker.answer("ask-"+node, "yes")
|
||||
r.st.failDelete[NodeKey(node)] = true // the confirm stops after the address key, before the enrolment key
|
||||
_, _ = r.h.Confirm(NodeUser(node), "")
|
||||
_ = r.st.Delete(NodeKey(node)) // and the key is gone by the next pass
|
||||
}, false},
|
||||
{"after a decline", func(r *rig, node string) {
|
||||
_ = r.h.Warranted(asks.Option{ID: "no"}, asks.Warrant{Ask: "ask-" + node})
|
||||
r.asker.answer("ask-"+node, "no")
|
||||
}, false},
|
||||
{"after a refused add", func(r *rig, node string) {
|
||||
delete(r.rd["mesh-delivery-assigner.placement"], node+".node") // the add is tried, and refused
|
||||
r.placeErr = errors.New("mesh-placement did not take the node: the proposal is refused: change 1: an address taken")
|
||||
_ = r.h.Warranted(yes, asks.Warrant{Ask: "ask-" + node})
|
||||
r.asker.answer("ask-"+node, "yes")
|
||||
r.placeErr = nil
|
||||
if !slices.Contains(r.events(), EventRefused) {
|
||||
r.t.Errorf("a refused add did not end the enrolment: %v", r.events())
|
||||
}
|
||||
}, false},
|
||||
{"a declined ask whose enrolment key is gone", func(r *rig, node string) {
|
||||
r.asker.answer("ask-"+node, "no")
|
||||
_ = r.st.Delete(NodeKey(node))
|
||||
}, true},
|
||||
{"an ask not answered yet", func(r *rig, node string) { _ = r.st.Delete(NodeKey(node)) }, true},
|
||||
} {
|
||||
r := newRig(t)
|
||||
stage := StageAsked
|
||||
if strings.HasPrefix(c.name, "after a confirm") {
|
||||
stage = StageLive
|
||||
}
|
||||
r.seed("joiner", "10.9.0.11", stage)
|
||||
r.rd["mesh-delivery-assigner.placement"]["joiner.node"] = `{"v":1,"node":"joiner","decided":{"address":"10.9.0.11"},"modules":[]}`
|
||||
c.end(r, "joiner")
|
||||
removes := 0
|
||||
for _, a := range r.added {
|
||||
if a.Change.Op == delivery.OpRemoveNode {
|
||||
removes++
|
||||
}
|
||||
}
|
||||
if err := r.h.Reconcile(); err != nil {
|
||||
t.Errorf("%s: %v", c.name, err)
|
||||
}
|
||||
after := 0
|
||||
for _, a := range r.added {
|
||||
if a.Change.Op == delivery.OpRemoveNode {
|
||||
after++
|
||||
}
|
||||
}
|
||||
if after != removes || r.has(AddressKey("10.9.0.11")) != c.key || slices.Contains(r.events(), EventExpired) {
|
||||
t.Errorf("%s: the catch-up unplaced (%d to %d), address key %v, events %v", c.name, removes, after, r.has(AddressKey("10.9.0.11")), r.events())
|
||||
}
|
||||
}
|
||||
// An address reserved by an issue under way (no ask yet) is never taken for an expired enrolment.
|
||||
_, _ = r.st.Put(AddressKey("10.9.0.10"), Reservation{Node: "busy"}, 0)
|
||||
if err := r.h.Reconcile(); err != nil || !r.has(AddressKey("10.9.0.10")) {
|
||||
|
||||
@@ -425,6 +425,7 @@ func TestMeshEnrolmentWholeOnARealBus(t *testing.T) {
|
||||
if err := h.Warranted(asks.Option{ID: "yes"}, asks.Warrant{Ask: b.Ask}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
asker.answer(b.Ask, "yes") // the router's record of the answer, which the catch-up reads (ADR 0589 §6)
|
||||
if _, err := placement.Get(ctx, "novox.node"); err != nil {
|
||||
t.Fatalf("novox was not placed on the yes: %v", err)
|
||||
}
|
||||
|
||||
@@ -260,6 +260,10 @@ func (h *Holder) Warranted(o asks.Option, w asks.Warrant) error {
|
||||
// The node enters placement now, with its tunnel key, on this warrant: the hub peers it before the machine redeems over
|
||||
// the tunnel (the operator's decision of 2026-11-02 01:05). Only then is the token made.
|
||||
if err := h.place(e, placements); err != nil {
|
||||
if addRefused(err) {
|
||||
// mesh-placement refused the node itself, not the call to it: the enrolment ends (ADR 0589 §6).
|
||||
return h.refuse(e, "mesh-placement refused the node: "+err.Error())
|
||||
}
|
||||
return fmt.Errorf("the node %s is not added to placement, so no token is made (mesh-enrolment.again asks again): %w", e.Node, err)
|
||||
}
|
||||
if _, err := h.Vault.Token(e.Node, e.Expires, tunnel); err != nil {
|
||||
@@ -273,8 +277,37 @@ func (h *Holder) Warranted(o asks.Option, w asks.Warrant) error {
|
||||
return h.sayEvent(EventIssued, e, "")
|
||||
}
|
||||
|
||||
// addRefused says mesh-placement answered the add-node with a refusal of the proposal (the assigner's words, "the proposal is
|
||||
// refused: …"), as against a call that did not reach it or was not answered, which `again` asks once more.
|
||||
func addRefused(err error) bool {
|
||||
return err != nil && strings.Contains(err.Error(), "the proposal is refused")
|
||||
}
|
||||
|
||||
// dropAddress deletes the enrolment's address key while it still names this node: the first step of a confirm, a decline and a
|
||||
// refused add (ADR 0589 §6), so the catch-up never takes a key left by one of them for an expiry never heard.
|
||||
func (h *Holder) dropAddress(e Enrolment) error {
|
||||
row, found, err := h.Store.Get(AddressKey(e.Address))
|
||||
if err != nil {
|
||||
return fmt.Errorf("the address key %s cannot be read, so the end of %s waits: %w", e.Address, e.Node, err)
|
||||
}
|
||||
if !found {
|
||||
return nil
|
||||
}
|
||||
if r, err := delivery.DecodeEnrolmentAddress(row.Value); err == nil && r.Node != e.Node {
|
||||
return nil // another enrolment holds the address now
|
||||
}
|
||||
if err := h.Store.Delete(AddressKey(e.Address)); err != nil {
|
||||
return fmt.Errorf("the address key %s is not deleted, so the end of %s waits: %w", e.Address, e.Node, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// refuse ends an enrolment at stage asked: both keys go and enrolment-refused carries the key's content (ADR 0587 §4).
|
||||
func (h *Holder) refuse(e Enrolment, reason string) error {
|
||||
// The address key goes first (ADR 0589 §6): a refusal stopped midway never leaves one that looks like an expiry.
|
||||
if err := h.dropAddress(e); err != nil {
|
||||
return err
|
||||
}
|
||||
// A yes whose token failed added the node already; a refusal takes it off again.
|
||||
if err := h.unplace(e, reason); err != nil {
|
||||
return fmt.Errorf("%s is not taken off placement, so its enrolment stays: %w", e.Node, err)
|
||||
|
||||
@@ -158,6 +158,26 @@ type fakeAsker struct {
|
||||
mu sync.Mutex
|
||||
asked, cancelled []string
|
||||
err error
|
||||
answers map[string]string // the router's record: the option each ask was answered with
|
||||
}
|
||||
|
||||
// answer records that the operator answered ask with option, as the router keeps it.
|
||||
func (a *fakeAsker) answer(ask, option string) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
if a.answers == nil {
|
||||
a.answers = map[string]string{}
|
||||
}
|
||||
a.answers[ask] = option
|
||||
}
|
||||
|
||||
func (a *fakeAsker) Where(id string) (string, *asks.Warrant, error) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
if o, ok := a.answers[id]; ok {
|
||||
return "answered", &asks.Warrant{Ask: id, Option: o}, nil
|
||||
}
|
||||
return "", nil, nil // no record read: as a router whose record of the ask is gone
|
||||
}
|
||||
|
||||
func (a *fakeAsker) Ask(k asks.Ask) error {
|
||||
|
||||
Reference in New Issue
Block a user