H-21: the manifest invokes revision (none-yet) and withheld, and config view-waits-minutes
The long-form invokes entry is read by mesh-sdk's second reader release (09d6070e): packaging's manifest tests pass with it placed and fail before it is joined. Builds: design 47 G §558, §617, §633, §635
This commit is contained in:
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -54,3 +55,23 @@ func TestTheEnginesOwnNameIsReadFromItsAppliedRecordOrItsFirstComposing(t *testi
|
||||
t.Fatal("the writer was never made after the name was known")
|
||||
}
|
||||
}
|
||||
|
||||
// §633: the engine's bound for the first seat list is the file the manifest's `view-waits-minutes` feeds, five minutes
|
||||
// when it holds nothing usable.
|
||||
func TestTheViewWaitsBoundIsTheManifestsSetting(t *testing.T) {
|
||||
was := viewWaitsFile
|
||||
defer func() { viewWaitsFile = was }()
|
||||
for content, want := range map[string]time.Duration{"2\n": 2 * time.Minute, "": 5 * time.Minute, "0": 5 * time.Minute, "x": 5 * time.Minute} {
|
||||
viewWaitsFile = filepath.Join(t.TempDir(), "w")
|
||||
if err := os.WriteFile(viewWaitsFile, []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := viewWaitsBound(); got != want {
|
||||
t.Errorf("the file %q gives %v, want %v", content, got, want)
|
||||
}
|
||||
}
|
||||
viewWaitsFile = filepath.Join(t.TempDir(), "missing")
|
||||
if got := viewWaitsBound(); got != 5*time.Minute {
|
||||
t.Errorf("no file gives %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
@@ -163,7 +165,7 @@ func startSeatList(ctx context.Context, session *link.Session, node string, find
|
||||
return nil, fmt.Errorf("the node-engine cannot follow the seat list: %w", err)
|
||||
}
|
||||
st.reader = reader
|
||||
st.waiter = &seatwait.Waiter{Node: node, ObserverOf: findings.observer, Reader: reader, Writer: findings, Ctx: ctx,
|
||||
st.waiter = &seatwait.Waiter{Node: node, ObserverOf: findings.observer, Reader: reader, Writer: findings, Ctx: ctx, Bound: viewWaitsBound(),
|
||||
Clock: seatWaitClock, Say: say}
|
||||
told := func() {
|
||||
st.waiter.Changed()
|
||||
@@ -259,3 +261,21 @@ func (f *lateFindings) Held() []condseat.Finding {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// viewWaitsFile is the file the manifest declares with the content ${config:view-waits-minutes} (design 47 G §633), read at
|
||||
// the engine's start as recoveryWaitFile is.
|
||||
var viewWaitsFile = "/var/lib/mesh-host/view-waits-minutes"
|
||||
|
||||
// viewWaitsBound is how long the reader of the seat list may wait before the engine says it: the file's minutes, the design's
|
||||
// five (seatwait.Bound) when the file is missing, empty or not a positive number.
|
||||
func viewWaitsBound() time.Duration {
|
||||
raw, err := os.ReadFile(viewWaitsFile)
|
||||
if err != nil {
|
||||
return seatwait.Bound
|
||||
}
|
||||
n, err := strconv.Atoi(strings.TrimSpace(string(raw)))
|
||||
if err != nil || n <= 0 {
|
||||
return seatwait.Bound
|
||||
}
|
||||
return time.Duration(n) * time.Minute
|
||||
}
|
||||
|
||||
@@ -71,6 +71,8 @@ type Waiter struct {
|
||||
Reader Reader
|
||||
Writer Writer
|
||||
Clock Clock
|
||||
// Bound is how long the reader may wait; zero is the default Bound (the manifest's config `view-waits-minutes`, §633).
|
||||
Bound time.Duration
|
||||
// Ctx is the run's context for the writer's acts; nil is the background.
|
||||
Ctx context.Context
|
||||
// Say tells the node's journal; nil says nothing.
|
||||
@@ -121,7 +123,7 @@ func (w *Waiter) Changed() {
|
||||
return // an earlier process wrote it and the list is still unread: it stands until the first full read
|
||||
}
|
||||
w.since = w.Clock.Now()
|
||||
w.timer = w.Clock.AfterFunc(Bound, w.expired)
|
||||
w.timer = w.Clock.AfterFunc(w.bound(), w.expired)
|
||||
}
|
||||
|
||||
// Waiting says whether the bound is running or the finding stands, and since when.
|
||||
@@ -172,3 +174,10 @@ func observerOf(fixed string, of func() string) string {
|
||||
}
|
||||
return fixed
|
||||
}
|
||||
|
||||
func (w *Waiter) bound() time.Duration {
|
||||
if w.Bound > 0 {
|
||||
return w.Bound
|
||||
}
|
||||
return Bound
|
||||
}
|
||||
|
||||
@@ -233,9 +233,27 @@ func caseAnEntryNotFoundIsAFindingOfTheFingerprint(t *testing.T) {
|
||||
}
|
||||
|
||||
// ADR 0636 §1, the node's finding of a seat list unread.
|
||||
// §633: the bound is the manifest's `view-waits-minutes` when one is given: a finding at two minutes, none before.
|
||||
func caseAGivenBoundReplacesTheFiveMinutes(t *testing.T) {
|
||||
reader := &fakeReader{st: seatlist.Status{Heard: false, LastError: "no answer"}}
|
||||
clock := &fakeClock{now: time.Date(2026, 11, 5, 12, 0, 0, 0, time.UTC)}
|
||||
findings := &fakeFindings{}
|
||||
w := &Waiter{Node: "ace", Observer: "node-engine", Reader: reader, Writer: findings, Clock: clock, Bound: 2 * time.Minute}
|
||||
w.Changed()
|
||||
clock.advance(2*time.Minute - time.Second)
|
||||
if len(findings.put) != 0 {
|
||||
t.Fatalf("a finding was written before the given bound: %+v", findings.put)
|
||||
}
|
||||
clock.advance(time.Second)
|
||||
if len(findings.put) != 1 {
|
||||
t.Fatalf("no finding at the given bound: %+v", findings.put)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheSeatListWait(t *testing.T) {
|
||||
for name, run := range map[string]func(*testing.T){
|
||||
"five minutes write one finding and the first full read clears it": caseTheNodeWritesOneFindingAfterFiveMinutesAndClearsItAtTheFirstFullRead,
|
||||
"a given bound replaces the five minutes": caseAGivenBoundReplacesTheFiveMinutes,
|
||||
"a read within the bound writes nothing": caseAReadWithinTheBoundWritesNothing,
|
||||
"a finding that cannot be written is said and tried again": caseAFindingThatCannotBeWrittenIsSaidAndTriedAgain,
|
||||
"a finding of an earlier process is cleared at the first full read": caseAFindingOfAnEarlierProcessIsClearedAtTheFirstFullRead,
|
||||
|
||||
+15
@@ -41,7 +41,9 @@
|
||||
],
|
||||
"invokes": [
|
||||
"seat:mesh-catalog.entry",
|
||||
{"verb": "seat:mesh-catalog.revision", "fields": ["none-yet"]},
|
||||
"seat:mesh-catalog.seat-definitions",
|
||||
"seat:mesh-grants.withheld",
|
||||
"seat:mesh-module-assigner.assignments",
|
||||
"seat:mesh-recovery.kept-points",
|
||||
"seat:mesh-recovery.restore-kept",
|
||||
@@ -91,6 +93,12 @@
|
||||
"minimum": 1,
|
||||
"default": 90
|
||||
},
|
||||
"view-waits-minutes": {
|
||||
"type": "number",
|
||||
"description": "How long the node-engine waits for the first complete seat list or catalog view before it writes the finding that it waits, in minutes (design 47 G §633, ADR 0636 §1).",
|
||||
"minimum": 1,
|
||||
"default": 5
|
||||
},
|
||||
"operator-signers": {
|
||||
"type": "string",
|
||||
"description": "The operator's public keys, ed25519 in base64, one a line: the node applies a composition made by hand only when one of these signed it (ADR 0539 clause 3). Public keys, no secret. Empty trusts no operator key.",
|
||||
@@ -132,6 +140,13 @@
|
||||
"path": "/var/lib/mesh-host/recovery-wait-minutes",
|
||||
"mode": "0644",
|
||||
"content": "${config:recovery-wait-minutes}\n"
|
||||
},
|
||||
{
|
||||
"id": "view-waits",
|
||||
"type": "file",
|
||||
"path": "/var/lib/mesh-host/view-waits-minutes",
|
||||
"mode": "0644",
|
||||
"content": "${config:view-waits-minutes}\n"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package packaging_test
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -62,7 +63,7 @@ func TestTheManifestConsumesTheBackupEvent(t *testing.T) {
|
||||
}
|
||||
var m struct {
|
||||
Consumes []string `json:"consumes"`
|
||||
Invokes []string `json:"invokes"`
|
||||
Invokes []any `json:"invokes"` // a verb, or the long form {verb, fields} (§558)
|
||||
Config map[string]struct {
|
||||
Default any `json:"default"`
|
||||
} `json:"config"`
|
||||
@@ -71,11 +72,16 @@ func TestTheManifestConsumesTheBackupEvent(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Design 47 G §630, §633, A-77: the recovery module's three verbs it asks, and its bound as a config key of 90.
|
||||
for _, v := range []string{"seat:mesh-recovery.kept-points", "seat:mesh-recovery.restore-kept", "seat:mesh-recovery.snapshot-kept"} {
|
||||
if !slices.Contains(m.Invokes, v) {
|
||||
t.Errorf("invokes %v lacks %s", m.Invokes, v)
|
||||
// §635, §558, §617: it invokes `withheld` and names no `uses`; the catalog's revision in the long form with the field none-yet.
|
||||
for _, v := range []any{"seat:mesh-recovery.kept-points", "seat:mesh-recovery.restore-kept", "seat:mesh-recovery.snapshot-kept",
|
||||
"seat:mesh-grants.withheld", map[string]any{"verb": "seat:mesh-catalog.revision", "fields": []any{"none-yet"}}} {
|
||||
if !slices.ContainsFunc(m.Invokes, func(have any) bool { return reflect.DeepEqual(have, v) }) {
|
||||
t.Errorf("invokes %v lacks %v", m.Invokes, v)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(string(raw), `"content": "${config:view-waits-minutes}\n"`) || m.Config["view-waits-minutes"].Default != float64(5) {
|
||||
t.Errorf("module.json declares no file with the content ${config:view-waits-minutes} or its default is not 5 (§633)")
|
||||
}
|
||||
if !strings.Contains(string(raw), `"content": "${config:recovery-wait-minutes}\n"`) {
|
||||
t.Errorf("module.json declares no file with the content ${config:recovery-wait-minutes} (design 47 G A-112)")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user