Merge feat/modules-name-no-node into bulk-a

This commit is contained in:
jochen
2026-10-22 21:29:03 +02:00
21 changed files with 225 additions and 87 deletions
-3
View File
@@ -1,3 +0,0 @@
# The modules the check covers (novox/hq ADR 0507 §9, issue 1838), one a line; issue 1838 widens it.
pacman
kernel
+41 -16
View File
@@ -1,12 +1,14 @@
// Command particulars keeps this mesh's node names out of the modules it covers (novox/hq ADR 0507 §9,
// issue 1838): a module is for any mesh, so no manifest, file, code string, identifier, comment, test name or
// fixture of a covered module names a node of this one.
// fixture of any module names a node of this one.
//
// The names are in the file `particulars` at the catalogue's root, one a line, `#` comments — the one place
// they may be written. The modules covered are in `covered` at checks/particulars/covered, one a line. A name is matched
// whole and in any case; a hyphen, an underscore or a CamelCase boundary parts one, so a longer word holding a name is another word.
// The forge is not a node name: its host (git.novox.be) and the organisation path (novox/<repo>) are blanked
// before a line is read.
// they may be written. Every directory under modules/ is read, with no list of covered modules, so a new module
// is covered from its first commit. A name is matched whole and in any case; a hyphen, an underscore or a
// CamelCase boundary parts one, so a longer word holding a name is another word.
// Three names hold a node name and are not one, so they are blanked before a line is read: the forge's host
// (git.novox.be) and organisation path (novox/<repo>), the hardware model (asus-zephyrus-g14, ROG Zephyrus G14)
// and the software's own name (Novox Mesh, novox-mesh).
//
// go run . <catalogue root>
//
@@ -29,6 +31,11 @@ import (
// (testdata/novox/x, /srv/novox/data) is not blanked.
var forge = regexp.MustCompile(`(?i)git\.novox\.be(?:/novox/[\w.-]+)?|(?:^|[^/\w.-])novox/[\w.-]+`)
// ownNames are the hardware model's and the software's own names, blanked before a line is read: the model
// in any separator and case (asus-zephyrus-g14, ASUS_ZEPHYRUS_G14, ROG Zephyrus G14), the software as two
// words or hyphened, whole (Novox Mesh, novox mesh, novox-mesh), so novox-mesh-vault stays a node name joined.
var ownNames = regexp.MustCompile(`(?i)(?:asus[-_ ])?zephyrus[-_ ]g14|(?:^|[^\w-])novox[- ]mesh(?:$|[^\w-])`)
func main() {
root := "."
if len(os.Args) > 1 {
@@ -43,18 +50,18 @@ func check(root string) int {
if err == nil && len(names) == 0 {
err = fmt.Errorf("particulars lists no name — the check would pass on anything")
}
var covered []string
var modules []string
if err == nil {
covered, err = readList(filepath.Join(root, "checks/particulars/covered"))
if err == nil && len(covered) == 0 {
err = fmt.Errorf("covered lists no module — the check would pass on anything")
modules, err = moduleDirs(root)
if err == nil && len(modules) == 0 {
err = fmt.Errorf("modules/ holds no module — the check would pass on anything")
}
}
if err != nil {
fmt.Fprintln(os.Stderr, "particulars:", err)
return 2
}
findings, err := run(root, names, covered)
findings, err := run(root, names, modules)
if err != nil {
fmt.Fprintln(os.Stderr, "particulars:", err)
return 2
@@ -66,7 +73,7 @@ func check(root string) int {
fmt.Printf("particulars: %d node name(s) in a module for any mesh (novox/hq ADR 0507 §9) — say what it shows, not where it was seen\n", len(findings))
return 1
}
fmt.Printf("particulars: none of %d node names in the %d covered modules\n", len(names), len(covered))
fmt.Printf("particulars: none of %d node names in the %d modules\n", len(names), len(modules))
return 0
}
@@ -89,10 +96,25 @@ func readList(path string) ([]string, error) {
return out, sc.Err()
}
// run reads every text file of each covered module and returns the findings, sorted.
func run(root string, names, covered []string) ([]string, error) {
// moduleDirs returns every directory under the catalogue's modules/, sorted.
func moduleDirs(root string) ([]string, error) {
entries, err := os.ReadDir(filepath.Join(root, "modules"))
if err != nil {
return nil, err
}
var out []string
for _, m := range covered {
for _, e := range entries {
if e.IsDir() {
out = append(out, e.Name())
}
}
return out, nil
}
// run reads every text file of each module named and returns the findings, sorted.
func run(root string, names, modules []string) ([]string, error) {
var out []string
for _, m := range modules {
dir := filepath.Join(root, "modules", m)
err := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
if err != nil {
@@ -143,9 +165,12 @@ func scan(file, src string, names []string) []string {
}
// hits returns the node names one line holds: as a word (a hyphen or underscore parts it), as a part of a
// CamelCase identifier (TestAce, novoxTree), the forge's host and organisation path blanked first.
// CamelCase identifier (TestAce, novoxTree), the forge's host and organisation path and the hardware's and the
// software's own names blanked first.
func hits(line string, names []string) []string {
line = forge.ReplaceAllStringFunc(line, func(s string) string { return strings.Repeat(" ", len(s)) })
blank := func(s string) string { return strings.Repeat(" ", len(s)) }
line = forge.ReplaceAllStringFunc(line, blank)
line = ownNames.ReplaceAllStringFunc(line, blank)
var out []string
for _, n := range names {
q := regexp.QuoteMeta(n)
+57 -2
View File
@@ -82,7 +82,6 @@ func TestCheckReturnsOneOnAFindingAndTwoOnAnUnreadableList(t *testing.T) {
os.WriteFile(p, []byte(s), 0o644)
}
write("particulars", "ace\n")
write("checks/particulars/covered", "kernel\n")
write("modules/kernel/a", "clean\n")
if got := check(root); got != 0 {
t.Errorf("clean = %d, want 0", got)
@@ -97,7 +96,7 @@ func TestCheckReturnsOneOnAFindingAndTwoOnAnUnreadableList(t *testing.T) {
}
}
func TestOnlyCoveredModulesAreRead(t *testing.T) {
func TestRunReadsOnlyTheModulesItIsGiven(t *testing.T) {
root := t.TempDir()
write := func(p, s string) {
p = filepath.Join(root, p)
@@ -114,3 +113,59 @@ func TestOnlyCoveredModulesAreRead(t *testing.T) {
t.Errorf("got %v", got)
}
}
// 1838/AC1: the check reads every directory under modules/ and no list of covered modules, so a module is
// covered from its first commit.
func TestEveryModuleIsReadWithoutAList(t *testing.T) {
root := t.TempDir()
write := func(p, s string) {
p = filepath.Join(root, p)
os.MkdirAll(filepath.Dir(p), 0o755)
os.WriteFile(p, []byte(s), 0o644)
}
write("particulars", "novox\n")
write("modules/kernel/files/a", "clean\n")
if got := check(root); got != 0 {
t.Errorf("clean, no covered list = %d, want 0", got)
}
write("modules/new-module/files/a", "on novox\n")
if got := check(root); got != 1 {
t.Errorf("a finding in a module no list names = %d, want 1", got)
}
}
// 1838/AC2: the hardware model's own name and the software's own name are not node names.
func TestTheHardwareModelAndTheSoftwareAreNotNodeNames(t *testing.T) {
for _, s := range []string{
"modules/asus-zephyrus-g14/README.md",
"the asus-zephyrus-g14 module",
"ASUS_ZEPHYRUS_G14=1",
"product_family: ROG Zephyrus G14",
"# This machine is a node of a Novox mesh",
"Novox Mesh turns a handful of personal machines into one system",
"<!-- novox mesh: this file was changed on two nodes",
`const sealInfo = "novox-mesh sealed box v1"`,
"module git.novox.be/novox/mesh-catalog/modules/kernel",
} {
if got := scan("f", s, names); len(got) != 0 {
t.Errorf("%q refused: %v", s, got)
}
}
}
// 1838/AC2: a node name standing alone is still one, beside the hardware's or the software's name too.
func TestANodeNameBesideTheHardwareOrTheSoftwareIsRefused(t *testing.T) {
cases := map[string]string{
"g14": "the asus-zephyrus-g14 module on g14",
"shanks": "Novox Mesh on shanks",
"novox": "resolves under novox.internal",
"ace": "ROG Zephyrus G14 beside ace",
"novox mesh-ish": "mesh.mod.gitea.call.x.novox-mesh-vault",
}
for want, s := range cases {
got := scan("modules/m/a.go", s, names)
if len(got) != 1 || !strings.HasPrefix(got[0], "modules/m/a.go:1: ") {
t.Errorf("%s: %q gave %v, want one finding naming file:line", want, s, got)
}
}
}
+4 -3
View File
@@ -38,7 +38,7 @@ the SDK, or the catalogue's bus, forge or build agent) resolves with `replay` or
needs a `reason`. A refused change changes nothing and says which rule refused it.
**Who works on it** (hq ADR 0284). A move to `diagnosing` or `fixing` names in `by` the issue's **worker**: who
works on it and its node, as `who@node` (such as `fix-399@g14` or `jochen@shanks`, kept in lower case). The issue
works on it and its node, as `who@node` (such as `fix-399@node-b` or `operator@node-d`, kept in lower case). The issue
keeps its worker (`worker`: `by`, `since`, `active`) while it stays at either status; any other status ends the
work. `get` shows it, with `worker-stale`, and each `list` row shows `worker`, `since` and `stale`. A move to either
status by someone else while the worker is current is refused, naming the worker; with `take-over` saying why, the
@@ -342,7 +342,7 @@ when. A change whose event the bus did not take is kept, and its answer says the
ADR 0275 §5 records the caller from the bus credential, never from an argument. **Who acted is the bus user
that called** (hq issue 365): a call on a tool's caller-named subject carries the caller's bus user as its last
token, the bus lets each user publish there with its own name alone, and the tool runner hands the module that
user from the subject the call arrived on (mesh-sdk `stdio.CallerOf`) — `shanks.node-tools` for an agent on
user from the subject the call arrived on (mesh-sdk `stdio.CallerOf`) — `node-d.node-tools` for an agent on
that machine's console, `person.<name>` for a person's credential. A call on the subject that names no caller
(served beside it for one release, hq issue 464), or through a runtime from before issue 365, is recorded as
by `unproven`. No verb takes an argument naming a caller, and none can be given one. The caller is only as sound
@@ -351,7 +351,8 @@ as the credential, which ADR 0266 keeps agents from reading (hq issue 333).
## Settings
`duplicate-check` (`on`; `off` stops the triage of new issues), `judge-model` (`sonnet`; `haiku` or `opus`) and
`forge-owner` (the forge's owner of the repositories `fixed-by` names, for asking a delivery) steer the triage;
`forge-owner` (the forge's owner of the repositories `fixed-by` names, for asking a delivery: required, with no
default, novox/hq ADR 0507 §9) steer the triage;
`stuck-after` (`2h`) is how long a merged fix may take to be delivered before it is said;
`delivered-not-resolved-after` (`1h`) how long an issue may stay in fixing once every fix is delivered before it is
said (hq issue 483).
@@ -427,9 +427,16 @@ func nonZero(t *time.Time) *time.Time {
// DeliveryState asks mesh-delivery for one pull request's delivery.
type DeliveryState func(repository string, number string) (Delivery, error)
// errNoForgeOwner is what asking the forge says when no forge owner is set: the forge is not asked under a
// name nobody gave (novox/hq ADR 0507 §9).
var errNoForgeOwner = errors.New("forge-owner is not set: the forge is not asked (set the module's forge-owner)")
// busDeliveryState asks mesh-delivery's checks over the bus.
func busDeliveryState(owner string) DeliveryState {
return func(repository, number string) (Delivery, error) {
if owner == "" {
return Delivery{}, errNoForgeOwner
}
raw, err := stdio.Ask("seat:mesh-delivery.checks", map[string]any{"repository": owner + "/" + repository, "number": number})
if err != nil {
return Delivery{}, err
@@ -362,3 +362,15 @@ func TestATransitionIsTakenUnderEveryNameTheTrackerHears(t *testing.T) {
})
}
}
// With no forge owner, the forge is not asked under a name nobody gave: the delivery and the approval each say
// the owner is not set (novox/hq ADR 0507 §9).
// 1838/AC4
func TestAnUnsetForgeOwnerAsksNoForgeAndSaysWhy(t *testing.T) {
if _, err := busDeliveryState("")("mesh-catalog", "5"); err == nil || !strings.Contains(err.Error(), "forge-owner is not set") {
t.Errorf("delivery with no owner: err %v, want it to say forge-owner is not set", err)
}
if _, _, err := busApproval(func() string { return "" })("mesh-catalog", 5); err == nil || !strings.Contains(err.Error(), "forge-owner is not set") {
t.Errorf("approval with no owner: err %v, want it to say forge-owner is not set", err)
}
}
@@ -638,7 +638,11 @@ func verdictApproves(body, commit string) bool {
// busApproval asks the forge's reviews of a pull request over the bus (gitea_pull_reviews).
func busApproval(owner func() string) Approval {
return func(repository string, number int) (time.Time, bool, error) {
raw, err := stdio.Ask("gitea.gitea_pull_reviews", map[string]any{"owner": owner(), "repo": repository, "number": number})
o := owner()
if o == "" {
return time.Time{}, false, errNoForgeOwner
}
raw, err := stdio.Ask("gitea.gitea_pull_reviews", map[string]any{"owner": o, "repo": repository, "number": number})
if err != nil {
return time.Time{}, false, err
}
+6 -5
View File
@@ -39,7 +39,8 @@ type Settings struct {
// JudgeModel is the model the duplicate check asks; DefaultJudgeModel when empty.
JudgeModel string `json:"judge-model,omitempty"`
// ForgeOwner is the owner on the forge of the repositories fixed-by names, for asking a pull request's
// delivery (ADR 0279 §3).
// delivery (ADR 0279 §3): the operator's, a required config value with no default (ADR 0507 §9); empty,
// the forge is not asked and each asking says so.
ForgeOwner string `json:"forge-owner,omitempty"`
// StuckAfter is how long a fix may be merged and not delivered before it is said stuck (ADR 0281), as a
// duration such as 2h; DefaultStuckAfter when empty.
@@ -99,7 +100,7 @@ func main() {
if s, err := t.Settings(); err == nil && strings.TrimSpace(s.ForgeOwner) != "" {
return strings.TrimSpace(s.ForgeOwner)
}
return "novox"
return ""
}
t.Deliveries = func(repository, number string) (Delivery, error) {
return busDeliveryState(forgeOwner())(repository, number)
@@ -533,7 +534,7 @@ func Tools(t *Tracker) []stdio.Tool {
{Name: Seat + ".move",
Description: "Change an issue's status — " + statuses + " — with what that status needs, under the " +
"development cycle's rules. Move an issue to diagnosing or fixing when you start work on it, with by: " +
"who works on it and its node, as who@node (such as fix-399@g14); the issue shows it until it moves on. " +
"who works on it and its node, as who@node (such as fix-399@node-b); the issue shows it until it moves on. " +
"Refused while someone else works on it, naming them, unless take-over says why (noted on the issue); a " +
"worker who did nothing on it for a day may be taken over from without a reason, and with no pull request open " +
"the answer says it was abandoned. located needs located-in, owners on " +
@@ -551,7 +552,7 @@ func Tools(t *Tracker) []stdio.Tool {
"replay": text("for a core issue resolved: the replay's id in mesh-lab's replays register, such as R361"),
"replay-none": text("for a core issue resolved: why no replay is possible"),
"reason": text("for wontfix: why it will not be fixed"),
"by": text("for diagnosing and fixing: who works on it and its node, as who@node, such as fix-399@g14 or jochen@shanks"),
"by": text("for diagnosing and fixing: who works on it and its node, as who@node, such as fix-399@node-b or operator@node-d"),
"take-over": text("for diagnosing and fixing, when someone else works on it: why you take it over"),
}),
Run: func(m map[string]any) (any, error) {
@@ -734,7 +735,7 @@ func Tools(t *Tracker) []stdio.Tool {
"operator's channel asks it to say in a condition's message which issue tracks it. Refused: a key not of a condition " +
"key's shape.",
Input: schema(nil, map[string]any{
"conditions": texts("only these condition keys, such as module.mounts.shanks.unhealthy; all when not given"),
"conditions": texts("only these condition keys, such as module.mounts.node-d.unhealthy; all when not given"),
}),
Run: func(m map[string]any) (any, error) {
a, err := readArgs("tracking", m, "conditions")
@@ -270,3 +270,40 @@ func TestEveryCodeModuleOfTheCatalogueIsAnOwner(t *testing.T) {
}
}
}
// The forge's owner differs between meshes, so it is a config value the operator gives, with no default naming
// this mesh, and the template the config merges into holds none either (novox/hq ADR 0507 §9).
// 1838/AC4
func TestTheForgeOwnerIsARequiredValueWithNoDefault(t *testing.T) {
raw, err := os.ReadFile(filepath.Join("..", "..", "module.json"))
if err != nil {
t.Fatal(err)
}
var m struct {
Config map[string]map[string]any `json:"config"`
}
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatal(err)
}
owner, ok := m.Config["forge-owner"]
if !ok {
t.Fatal("no forge-owner config value")
}
if d, has := owner["default"]; has {
t.Errorf("forge-owner has the default %v: the forge's owner is the operator's, with no default", d)
}
if owner["required"] != true {
t.Errorf("forge-owner is not required: an unset owner would compose and ask no forge in silence")
}
tmpl, err := os.ReadFile(filepath.Join("..", "..", "files", "settings.json"))
if err != nil {
t.Fatal(err)
}
var s map[string]any
if err := json.Unmarshal(tmpl, &s); err != nil {
t.Fatal(err)
}
if v, has := s["forge-owner"]; has {
t.Errorf("files/settings.json holds forge-owner %v: the value comes from the config only", v)
}
}
@@ -393,7 +393,7 @@ func (t *Tracker) move(by string, a MoveAsk) (*Moved, error) {
if worked(to) {
if strings.TrimSpace(a.By) == "" {
return nil, fmt.Errorf("a move to %s needs by: who works on it and its node, as who@node, such as "+
"fix-399@g14 or jochen@shanks", to)
"fix-399@node-b or operator@node-d", to)
}
if worker, err = checkWorker(a.By); err != nil {
return nil, err
@@ -53,7 +53,7 @@ func checkWorkerField(field, s string) (string, error) {
s = strings.ToLower(s)
if !workerName.MatchString(s) {
return "", fmt.Errorf("%s %q is not who works on it and its node: give who@node, the agent or person and the "+
"machine it works on, such as fix-399@g14 or jochen@shanks", field, s)
"machine it works on, such as fix-399@node-b or operator@node-d", field, s)
}
return s, nil
}
-1
View File
@@ -161,7 +161,6 @@
],
"duplicate-check": "on",
"judge-model": "sonnet",
"forge-owner": "novox",
"stuck-after": "2h",
"worker-stale-after": "24h",
"delivered-not-resolved-after": "1h",
+2 -2
View File
@@ -267,9 +267,9 @@
},
"forge-owner": {
"type": "string",
"description": "The owner on the forge of the repositories an issue's fixed-by names, for asking after a pull request's delivery (novox/hq ADR 0279 §3); empty asks no forge.",
"description": "The owner on the forge of the repositories an issue's fixed-by names, for asking after a pull request's delivery (novox/hq ADR 0279 §3), such as the organisation that holds them: the operator's, with no default (novox/hq ADR 0507 §9).",
"trusted": true,
"default": "novox"
"required": true
},
"stuck-after": {
"type": "string",
+8 -8
View File
@@ -10,7 +10,7 @@ import (
"time"
)
// respond answers one query: novox.internal A 10.10.0.1 via a compressed name, anything else rcode.
// respond answers one query: node-c.internal A 10.10.0.1 via a compressed name, anything else rcode.
func respond(q []byte, rcode byte) []byte {
end := 12
for q[end] != 0 {
@@ -20,7 +20,7 @@ func respond(q []byte, rcode byte) []byte {
name := strings.ToLower(string(q[13:end]))
r := append([]byte{}, q[:2]...)
r = append(r, 0x81, 0x80|rcode, 0, 1)
answer := rcode == 0 && strings.Contains(name, "novox") && binary.BigEndian.Uint16(q[end+1:]) == 1
answer := rcode == 0 && strings.Contains(name, "node-c") && binary.BigEndian.Uint16(q[end+1:]) == 1
if answer {
r = append(r, 0, 1, 0, 0, 0, 0)
} else {
@@ -80,11 +80,11 @@ func tcpResolver(t *testing.T) string {
// Issue 348: the question that could not be asked during the outage — does the resolver at one address answer?
func TestDNSAsksANamedResolverOverUDPAndTCPAndSaysItsResponseCode(t *testing.T) {
got, err := AskDNS("novox.internal", "A", udpResolver(t, 0), false, time.Second)
got, err := AskDNS("node-c.internal", "A", udpResolver(t, 0), false, time.Second)
if err != nil || got.Rcode != "NOERROR" || got.Protocol != "udp" || len(got.Answers) != 1 || got.Answers[0] != "10.10.0.1" || got.Error != "" {
t.Fatalf("udp: %+v, %v", got, err)
}
got, err = AskDNS("novox.internal", "A", tcpResolver(t), true, time.Second)
got, err = AskDNS("node-c.internal", "A", tcpResolver(t), true, time.Second)
if err != nil || got.Rcode != "NOERROR" || got.Protocol != "tcp" || len(got.Answers) != 1 || got.Answers[0] != "10.10.0.1" {
t.Fatalf("tcp: %+v, %v", got, err)
}
@@ -92,7 +92,7 @@ func TestDNSAsksANamedResolverOverUDPAndTCPAndSaysItsResponseCode(t *testing.T)
if got.Rcode != "NXDOMAIN" || got.Error == "" || len(got.Answers) != 0 {
t.Fatalf("no such name is a result with its code: %+v", got)
}
got, _ = AskDNS("novox.internal", "A", udpResolver(t, 5), false, time.Second)
got, _ = AskDNS("node-c.internal", "A", udpResolver(t, 5), false, time.Second)
if got.Rcode != "REFUSED" || got.Error == "" {
t.Fatalf("a refusal: %+v", got)
}
@@ -102,14 +102,14 @@ func TestDNSSaysARefusedPortAndRefusesANameAsServer(t *testing.T) {
l, _ := net.Listen("tcp", "127.0.0.1:0")
addr := l.Addr().String()
l.Close()
got, err := AskDNS("novox.internal", "A", addr, true, time.Second)
got, err := AskDNS("node-c.internal", "A", addr, true, time.Second)
if err != nil || !strings.Contains(got.Error, "connection refused") {
t.Fatalf("nothing listening is a result naming it: %+v, %v", got, err)
}
if _, err := AskDNS("novox.internal", "A", "resolver.example", false, time.Second); err == nil {
if _, err := AskDNS("node-c.internal", "A", "resolver.example", false, time.Second); err == nil {
t.Fatal("a server given by name was accepted")
}
if _, err := AskDNS("novox.internal", "A", "", true, time.Second); err == nil {
if _, err := AskDNS("node-c.internal", "A", "", true, time.Second); err == nil {
t.Fatal("tcp without a server was accepted")
}
if a, _ := serverAddress("10.10.0.1"); a != "10.10.0.1:53" {
+7 -7
View File
@@ -13,8 +13,8 @@ func dump(now time.Time) string {
fresh := now.Add(-40 * time.Second).Unix()
old := now.Add(-10 * time.Minute).Unix()
return privateKey + "\tPUBkeyOfTheHubAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\t51820\toff\n" +
"PUBkeyAceAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\t(none)\t84.193.151.6:40122\t10.10.0.2/32\t" + itoa(old) + "\t1000\t2000\t25\n" +
"PUBkeyShanksAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\t(none)\t84.193.151.6:51820\t10.10.0.3/32\t" + itoa(fresh) + "\t3000\t4000\toff\n" +
"PUBkeyNodeAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\t(none)\t84.193.151.6:40122\t10.10.0.2/32\t" + itoa(old) + "\t1000\t2000\t25\n" +
"PUBkeyNodeDAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\t(none)\t84.193.151.6:51820\t10.10.0.3/32\t" + itoa(fresh) + "\t3000\t4000\toff\n" +
"PUBkeyNewAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\t(none)\t(none)\t10.10.0.9/32\t0\t0\t0\toff\n"
}
@@ -33,12 +33,12 @@ func TestTheDumpIsReadAndNoKeyIsSaid(t *testing.T) {
if tun.ListenPort != 51820 || len(tun.Peers) != 3 {
t.Fatalf("got %+v", tun)
}
ace, shanks, fresh := tun.Peers[0], tun.Peers[1], tun.Peers[2]
if ace.Endpoint != "84.193.151.6:40122" || ace.HandshakeAge != 600 || !ace.Stale || ace.Keepalive != "25s" || ace.Sent != 2000 {
t.Errorf("ace %+v", ace)
nodeA, nodeD, fresh := tun.Peers[0], tun.Peers[1], tun.Peers[2]
if nodeA.Endpoint != "84.193.151.6:40122" || nodeA.HandshakeAge != 600 || !nodeA.Stale || nodeA.Keepalive != "25s" || nodeA.Sent != 2000 {
t.Errorf("nodeA %+v", nodeA)
}
if shanks.Stale || shanks.HandshakeAge != 40 || shanks.Keepalive != "" {
t.Errorf("shanks %+v", shanks)
if nodeD.Stale || nodeD.HandshakeAge != 40 || nodeD.Keepalive != "" {
t.Errorf("nodeD %+v", nodeD)
}
if fresh.Endpoint != "" || fresh.HandshakeAge != -1 || !fresh.Stale || fresh.Handshake != "" {
t.Errorf("a peer never heard from %+v", fresh)
@@ -81,16 +81,16 @@ func TestWithoutTheBackupListTheExportsStayAndItIsSaid(t *testing.T) {
}
func TestBackupFromTheSeatsAndTheData(t *testing.T) {
seats := json.RawMessage(`{"answer":{"seats":[{"seat":"node-backup","scope":"node","holders":[{"module":"restic","node":"ace"},{"module":"borg","node":"shanks"}]}]},"ok":true}`)
data := json.RawMessage(`{"answer":{"data":[{"machine":"ace","module":"restic","item":"repository","path":"/storage/restic"},{"machine":"ace","module":"restic","item":"state","path":"/var/lib/restic"},{"machine":"ace","module":"plex","item":"data","path":"/mnt/plex/data"}]},"ok":true}`)
holder, paths, err := BackupFrom("ace", seats, data)
seats := json.RawMessage(`{"answer":{"seats":[{"seat":"node-backup","scope":"node","holders":[{"module":"restic","node":"node-a"},{"module":"borg","node":"node-d"}]}]},"ok":true}`)
data := json.RawMessage(`{"answer":{"data":[{"machine":"node-a","module":"restic","item":"repository","path":"/storage/restic"},{"machine":"node-a","module":"restic","item":"state","path":"/var/lib/restic"},{"machine":"node-a","module":"plex","item":"data","path":"/mnt/plex/data"}]},"ok":true}`)
holder, paths, err := BackupFrom("node-a", seats, data)
if err != nil || holder != "restic" || strings.Join(paths, " ") != "/storage/restic /var/lib/restic" {
t.Errorf("got %q %v %v", holder, paths, err)
}
if _, _, err := BackupFrom("g14", seats, data); err == nil {
if _, _, err := BackupFrom("node-b", seats, data); err == nil {
t.Errorf("a machine whose backup seat nobody holds is not said")
}
if _, _, err := BackupFrom("ace", seats, json.RawMessage(`{"ok":true}`)); err == nil {
if _, _, err := BackupFrom("node-a", seats, json.RawMessage(`{"ok":true}`)); err == nil {
t.Errorf("an answer with no data list reads as an empty list")
}
}
@@ -99,11 +99,11 @@ func TestBackupFromTheSeatsAndTheData(t *testing.T) {
func TestAFailedAskKeepsTheLastBackupPaths(t *testing.T) {
f := newFake(t)
m := f.machine()
m.TakeBackupWith("ace", func(string, any) (json.RawMessage, error) {
m.TakeBackupWith("node-a", func(string, any) (json.RawMessage, error) {
return nil, errors.New("no responders")
})
f.backup("restic", "/storage/restic")
m.TakeBackupWith("ace", func(string, any) (json.RawMessage, error) {
m.TakeBackupWith("node-a", func(string, any) (json.RawMessage, error) {
return nil, errors.New("timeout")
})
var b BackupFromBus
@@ -64,9 +64,9 @@ func TestAFailedReloadIsTriedAgainWhenNothingElseAsks(t *testing.T) {
}
func TestBackupFromRefusesAnAnswerWithoutADataList(t *testing.T) {
seats := json.RawMessage(`{"answer":{"seats":[{"seat":"node-backup","holders":[{"module":"restic","node":"ace"}]}]}}`)
seats := json.RawMessage(`{"answer":{"seats":[{"seat":"node-backup","holders":[{"module":"restic","node":"node-a"}]}]}}`)
for _, data := range []string{`{"answer":{}}`, `{"answer":{"data":null}}`} {
if _, paths, err := BackupFrom("ace", seats, json.RawMessage(data)); err == nil {
if _, paths, err := BackupFrom("node-a", seats, json.RawMessage(data)); err == nil {
t.Errorf("%s was read as the empty list %v: never \"nothing to fear\"", data, paths)
}
}
+1 -1
View File
@@ -8,7 +8,7 @@
//
// **Which broker nodes are the mesh's.** Never guessed: a flow may talk to a broker that has nothing
// to do with this mesh. The step owns the node it creates itself (id `mesh-mqtt-topic`, "mesh:
// mqtt-topic") and the ones an assignment names in settings (`mqtt.brokers`: node ids — how ace's
// mqtt-topic") and the ones an assignment names in settings (`mqtt.brokers`: node ids — how node-a's
// existing broker node, which every one of its MQTT flows uses, is handed over). With none named and
// none made yet, it makes one, so a fresh Node-RED has a broker the flows can pick.
//
+15 -15
View File
@@ -4,7 +4,7 @@
// differs; a node that is not named is never touched; a loopback broker address is refused.
//
// Node-RED and the broker are fakes answering as the real ones do (admin API v2 of nodered/node-red
// 5.0.7, the build ace runs).
// 5.0.7, the build node-a runs).
import { test } from "node:test";
import assert from "node:assert/strict";
@@ -13,10 +13,10 @@ import { MESH_BROKER_ID, namedBrokers, reconcileBrokers, type Binding, type Flow
import type { Probe } from "../mqtt/probe.ts";
const MINTED = "mesh-minted-password";
const binding = (at = "ace.internal"): Binding => ({ provision: "mqtt-topic", from: "ace", at, as: "mesh_ace_nodered", serves: { scheme: "mqtt", port: 1883 } });
const binding = (at = "node-a.internal"): Binding => ({ provision: "mqtt-topic", from: "node-a", at, as: "mesh_node_a_nodered", serves: { scheme: "mqtt", port: 1883 } });
/** ace's flows, reduced: its one broker node (dead, zurag.be:1884) and a node that uses it. */
function aceFlows(): FlowNode[] {
/** node-a's flows, reduced: its one broker node (dead, zurag.be:1884) and a node that uses it. */
function nodeAFlows(): FlowNode[] {
return [
{ id: "2b0aece9c5f3b307", type: "mqtt-broker", name: "MQTT Broker", broker: "zurag.be", port: "1884", clientid: "", usetls: false, protocolVersion: "4", keepalive: "60" },
{ id: "fe0cae96f1e3ae4d", type: "mqtt in", topic: "stat/sonoff_office_light_switch/RESULT", broker: "2b0aece9c5f3b307", z: "t" },
@@ -52,17 +52,17 @@ const marks = (): Marks & { store: Map<string, string> } => {
const store = new Map<string, string>();
return { store, get: async (k) => store.get(k), set: async (k, v) => void store.set(k, v) };
};
const takes: Probe = async (_h, _p, user, pass) => ({ connack: user === "mesh_ace_nodered" && pass === MINTED ? 0 : 5, suback: 0 });
const takes: Probe = async (_h, _p, user, pass) => ({ connack: user === "mesh_node_a_nodered" && pass === MINTED ? 0 : 5, suback: 0 });
test("ace: the named broker node is moved to the bound broker and login; the other broker is not touched", async () => {
const f = fakeNodeRed(aceFlows(), { "2b0aece9c5f3b307": { user: "luffy", password: "old" }, "other-broker": { user: "x", password: "y" } });
test("node-a: the named broker node is moved to the bound broker and login; the other broker is not touched", async () => {
const f = fakeNodeRed(nodeAFlows(), { "2b0aece9c5f3b307": { user: "luffy", password: "old" }, "other-broker": { user: "x", password: "y" } });
const m = marks();
const out = await reconcileBrokers({ nodered: f.nodered, probe: takes, marks: m }, binding(), `${MINTED}\n`, ["2b0aece9c5f3b307"]);
assert.deepEqual(out, [{ what: "broker 2b0aece9c5f3b307", result: "written", fields: ["broker", "port", "user", "password"] }]);
const node = f.flows.find((n) => n.id === "2b0aece9c5f3b307");
assert.deepEqual(node, { ...aceFlows()[0], broker: "ace.internal", port: "1883", usetls: false });
assert.deepEqual(f.creds["2b0aece9c5f3b307"], { user: "mesh_ace_nodered", password: MINTED });
assert.deepEqual(f.flows.find((n) => n.id === "other-broker"), aceFlows()[2]);
assert.deepEqual(node, { ...nodeAFlows()[0], broker: "node-a.internal", port: "1883", usetls: false });
assert.deepEqual(f.creds["2b0aece9c5f3b307"], { user: "mesh_node_a_nodered", password: MINTED });
assert.deepEqual(f.flows.find((n) => n.id === "other-broker"), nodeAFlows()[2]);
assert.deepEqual(f.creds["other-broker"], { user: "x", password: "y" });
// Only the changed node carried credentials in the deploy.
assert.deepEqual(f.deploys[0].filter((n) => n.credentials).map((n) => n.id), ["2b0aece9c5f3b307"]);
@@ -78,19 +78,19 @@ test("fresh: with nothing named, the step makes its own broker node", async () =
const out = await reconcileBrokers({ nodered: f.nodered, probe: takes, marks: marks() }, binding(), MINTED, []);
assert.deepEqual(out, [{ what: `broker ${MESH_BROKER_ID}`, result: "written", fields: ["node"] }]);
assert.equal(f.flows[0].type, "mqtt-broker");
assert.equal(f.flows[0].broker, "ace.internal");
assert.deepEqual(f.creds[MESH_BROKER_ID], { user: "mesh_ace_nodered", password: MINTED });
assert.equal(f.flows[0].broker, "node-a.internal");
assert.deepEqual(f.creds[MESH_BROKER_ID], { user: "mesh_node_a_nodered", password: MINTED });
});
test("a login the broker does not take is never written", async () => {
const f = fakeNodeRed(aceFlows());
const f = fakeNodeRed(nodeAFlows());
const out = await reconcileBrokers({ nodered: f.nodered, probe: async () => ({ connack: 5 }), marks: marks() }, binding(), MINTED, ["2b0aece9c5f3b307"]);
assert.equal(out[0].result, "refused");
assert.equal(f.deploys.length, 0);
});
test("a named node that is not there, or a loopback broker, is refused", async () => {
const f = fakeNodeRed(aceFlows());
const f = fakeNodeRed(nodeAFlows());
const out = await reconcileBrokers({ nodered: f.nodered, probe: takes, marks: marks() }, binding(), MINTED, ["gone"]);
assert.match((out[0] as { problem: string }).problem, /no such node/);
const lo = await reconcileBrokers({ nodered: f.nodered, probe: takes, marks: marks() }, binding("127.0.0.1"), MINTED, []);
@@ -99,7 +99,7 @@ test("a named node that is not there, or a loopback broker, is refused", async (
});
test("a deploy that raced another is read again once", async () => {
const f = fakeNodeRed(aceFlows());
const f = fakeNodeRed(nodeAFlows());
let first = true;
const racing: NodeRed = {
...f.nodered,
+11 -11
View File
@@ -217,7 +217,7 @@ func TestAQuestionIsShownWithItsTicketsAndRingsAndAWarningIsSilent(t *testing.T)
if f.of("sendMessage")[1].Body["disable_notification"] != true {
t.Error("a clearing rang (D4)")
}
subject, body = work(asks.Send, asks.Message{Handle: "m3", Title: "Urgent: ace silent", Urgent: true, To: "42"})
subject, body = work(asks.Send, asks.Message{Handle: "m3", Title: "Urgent: node-a silent", Urgent: true, To: "42"})
_ = c.Take(subject, body)
if f.of("sendMessage")[2].Body["disable_notification"] != false {
t.Error("an urgent message was silent (D4)")
@@ -243,7 +243,7 @@ func TestALongMessageIsCutAndTheCutSaid(t *testing.T) {
// nor injects. The tap on it is still compared by the plain words, which is what Telegram gives back.
func TestAMessageIsRenderedInHTMLWithItsMarkupEscaped(t *testing.T) {
c, f, s, _ := newChannel(t, goodToken)
subject, body := work(asks.Show, asks.Message{Handle: "q1", Title: "Set mounts on <shanks> & co?",
subject, body := work(asks.Show, asks.Message{Handle: "q1", Title: "Set mounts on <node-d> & co?",
Body: "Changes two settings.\n+ shares: media=/storage/media\n~ sources: a=b (was: c)",
Note: "Asked by mesh-controller at 15:11.\nFingerprint 1234 5678 9abc.",
To: "42", Buttons: []asks.Button{{Label: "Approve", Ticket: "tkt"}}})
@@ -251,7 +251,7 @@ func TestAMessageIsRenderedInHTMLWithItsMarkupEscaped(t *testing.T) {
t.Fatal(err)
}
sent := f.of("sendMessage")
want := "<b>Set mounts on &lt;shanks&gt; &amp; co?</b>\nChanges two settings.\n<code>+ shares: media=/storage/media</code>\n" +
want := "<b>Set mounts on &lt;node-d&gt; &amp; co?</b>\nChanges two settings.\n<code>+ shares: media=/storage/media</code>\n" +
"<code>~ sources: a=b (was: c)</code>\n\n<blockquote expandable>Asked by mesh-controller at 15:11.\nFingerprint 1234 5678 9abc.</blockquote>"
if len(sent) != 1 || sent[0].Body["text"] != want || sent[0].Body["parse_mode"] != "HTML" {
t.Fatalf("sent %v", sent)
@@ -262,7 +262,7 @@ func TestAMessageIsRenderedInHTMLWithItsMarkupEscaped(t *testing.T) {
// Telegram gives the words back plain, the space around the blockquote as it pleases: the tap on them is taken.
c.Handle(Update{UpdateID: 1, Callback: &Callback{ID: "cb1", Data: "tkt", From: User{ID: 42, FirstName: "Jo"},
Message: &Msg{MessageID: shown, Chat: Chat{ID: 42, Type: "private"},
Text: "Set mounts on <shanks> & co?\nChanges two settings.\n+ shares: media=/storage/media\n~ sources: a=b (was: c)\n" +
Text: "Set mounts on <node-d> & co?\nChanges two settings.\n+ shares: media=/storage/media\n~ sources: a=b (was: c)\n" +
"Asked by mesh-controller at 15:11.\nFingerprint 1234 5678 9abc."}}})
if got := s.of(asks.IntakeSubject(asks.Chosen, Kind)); len(got) != 1 {
t.Fatalf("the tap on the rendered message was not taken: %s", got)
@@ -270,20 +270,20 @@ func TestAMessageIsRenderedInHTMLWithItsMarkupEscaped(t *testing.T) {
// Words changed by another holder of the token are still told apart.
c.Handle(Update{UpdateID: 2, Callback: &Callback{ID: "cb2", Data: "tkt", From: User{ID: 42, FirstName: "Jo"},
Message: &Msg{MessageID: shown, Chat: Chat{ID: 42, Type: "private"},
Text: "Set mounts on <shanks> & co?\nChanges three settings.\n+ shares: media=/storage/media\n~ sources: a=b (was: c)\n" +
Text: "Set mounts on <node-d> & co?\nChanges three settings.\n+ shares: media=/storage/media\n~ sources: a=b (was: c)\n" +
"Asked by mesh-controller at 15:11.\nFingerprint 1234 5678 9abc."}}})
if got := s.of(asks.IntakeSubject(asks.Chosen, Kind)); len(got) != 1 {
t.Fatalf("a tap on changed words was taken: %s", got)
}
// The edit says what was chosen, one line with the headline, and is in the same mode.
subject, body = work(asks.Edit, asks.Message{Handle: "q1", Title: "You chose Approve at 15:06 on Telegram · Set mounts on <shanks> & co?",
subject, body = work(asks.Edit, asks.Message{Handle: "q1", Title: "You chose Approve at 15:06 on Telegram · Set mounts on <node-d> & co?",
Note: "Told to mesh-controller, which acts on it itself.", To: "42"})
if err := c.Take(subject, body); err != nil {
t.Fatal(err)
}
ed := f.of("editMessageText")
if len(ed) != 1 || ed[0].Body["parse_mode"] != "HTML" ||
ed[0].Body["text"] != "<b>You chose Approve at 15:06 on Telegram · Set mounts on &lt;shanks&gt; &amp; co?</b>\n\n<blockquote expandable>Told to mesh-controller, which acts on it itself.</blockquote>" {
ed[0].Body["text"] != "<b>You chose Approve at 15:06 on Telegram · Set mounts on &lt;node-d&gt; &amp; co?</b>\n\n<blockquote expandable>Told to mesh-controller, which acts on it itself.</blockquote>" {
t.Errorf("edited %v", ed)
}
// A plain answer in the chat is escaped too.
@@ -657,7 +657,7 @@ func TestAToastIsCutAtTelegramsBound(t *testing.T) {
// loses its buttons. A new handle is a new message, and nothing is deleted.
func TestAMessageSaidAgainUnderItsHandleReplacesTheEarlierOne(t *testing.T) {
c, f, _, st := newChannel(t, goodToken)
subject, body := work(asks.Show, asks.Message{Handle: "m1", Title: "Urgent: ace silent", Urgent: true, To: "42",
subject, body := work(asks.Show, asks.Message{Handle: "m1", Title: "Urgent: node-a silent", Urgent: true, To: "42",
Buttons: []asks.Button{{Label: "Silence for a week", Ticket: "0123456789abcdef0123456789abcdef"}}})
if err := c.Take(subject, body); err != nil {
t.Fatal(err)
@@ -668,7 +668,7 @@ func TestAMessageSaidAgainUnderItsHandleReplacesTheEarlierOne(t *testing.T) {
if len(f.of("deleteMessage")) != 0 {
t.Fatal("a new handle deleted a message")
}
subject, body = work(asks.Show, asks.Message{Handle: "m1", Title: "Still open: ace silent", Urgent: true, To: "42",
subject, body = work(asks.Show, asks.Message{Handle: "m1", Title: "Still open: node-a silent", Urgent: true, To: "42",
Buttons: []asks.Button{{Label: "Silence for a week", Ticket: "fedcba9876543210fedcba9876543210"}}})
if err := c.Take(subject, body); err != nil {
t.Fatal(err)
@@ -686,7 +686,7 @@ func TestAMessageSaidAgainUnderItsHandleReplacesTheEarlierOne(t *testing.T) {
return 400, `{"ok":false,"error_code":400,"description":"Bad Request: message can't be deleted"}`
}
second := st.placed["m1"].ID
subject, body = work(asks.Send, asks.Message{Handle: "m1", Title: "Now urgent: ace silent", Urgent: true, To: "42"})
subject, body = work(asks.Send, asks.Message{Handle: "m1", Title: "Now urgent: node-a silent", Urgent: true, To: "42"})
_ = c.Take(subject, body)
ed := f.of("editMessageText")
if len(ed) != 1 || ed[0].Body["message_id"] != float64(second) || !strings.Contains(ed[0].Body["text"].(string), "Replaced") {
@@ -705,7 +705,7 @@ func TestAReplacementThatCannotBeSentLeavesTheEarlierMessage(t *testing.T) {
f.answer["sendMessage"] = func(map[string]any) (int, string) {
return 400, `{"ok":false,"error_code":400,"description":"Bad Request: chat not found"}`
}
subject, body := work(asks.Send, asks.Message{Handle: "m1", Title: "Still open: ace silent", Urgent: true, To: "42"})
subject, body := work(asks.Send, asks.Message{Handle: "m1", Title: "Still open: node-a silent", Urgent: true, To: "42"})
_ = c.Take(subject, body)
if len(f.of("deleteMessage")) != 0 || st.placed["m1"].ID != 7 {
t.Fatalf("the earlier message was taken away though its replacement was not sent: %+v", f.of("deleteMessage"))
+1 -1
View File
@@ -20,7 +20,7 @@ var adr0343 = asks.Message{Handle: "h-343", To: "42",
"• A deleted file is marked, and a newer change elsewhere outlives the deletion",
Note: "Asked by mesh-knowledge at 16:05. Unanswered by tomorrow 15:55: the record stays proposed; nothing of it is in force.\n" +
"Approve: the record is accepted and its clauses are in force. Decline: the record stays proposed; nothing of it is in force.\n" +
"ADR 0343, proposed by fix-367@g14; the knowledge base's own review approved it. Fingerprint a59f 361b f60e cb5e d237 a837.\n" +
"ADR 0343, proposed by fix-367@node-b; the knowledge base's own review approved it. Fingerprint a59f 361b f60e cb5e d237 a837.\n" +
"Read its 13 clauses with the knowledge base's get, number 343.",
Buttons: []asks.Button{{Label: "Approve", Ticket: "t-approve"}, {Label: "Decline", Ticket: "t-decline"}}}