mesh-mail: pin mesh-sdk go to release 2, declare the sender account, bus process and work-queue stream (ADR 0436 clauses 7, 8; issue 1172)

This commit is contained in:
jochen
2026-10-19 09:23:45 +02:00
parent b4527d9091
commit 75a9a45c6f
5 changed files with 267 additions and 20 deletions
@@ -30,11 +30,16 @@ type manifest struct {
OwnSecrets map[string]map[string]string `json:"own-secrets"`
Tools []string `json:"tools"`
Emits []string `json:"emits"`
State []string `json:"state"`
Streams []struct {
Name string `json:"name"`
Events []string `json:"events"`
MaxAgeDays int `json:"max-age-days"`
Grant struct {
Name string `json:"name"`
Events []string `json:"events"`
MaxAgeDays int `json:"max-age-days"`
MaxMiB int `json:"max-mib"`
Retention string `json:"retention"`
MaxDeliveries int `json:"max-deliveries"`
Duplicates int `json:"duplicates"`
Grant struct {
Config string `json:"config"`
Events string `json:"events"`
} `json:"grant"`
@@ -95,13 +100,35 @@ func TestTheManifestSaysWhatTheCodeDoes(t *testing.T) {
t.Fatalf("tools %v, the code serves %v", m.Tools, names)
}
// Its events go to its own stream MAIL, kept a week, each account's part heard only by the consumers the trusted
// config value grants names for it (ADR 0359 §2, §4; ADR 0363): none on the shared EVENTS stream.
// config value grants names for it (ADR 0359 §2, §4; ADR 0363): none on the shared EVENTS stream. The module's
// top-level emits is the sender's two outcomes (novox/hq ADR 0436 §7, §11, issue 1172): the module as a whole may
// publish them, though only the process send actually does (its own bus.emits, checked in
// TestItsManifestDeclaresTheSenderProcess).
var events []string
for _, c := range Changes {
events = append(events, EventKey("*", c))
}
if len(m.Emits) != 0 || len(m.Streams) != 1 || m.Streams[0].Name != "mail" || !reflect.DeepEqual(m.Streams[0].Events, events) ||
m.Streams[0].MaxAgeDays != 7 || m.Streams[0].Grant.Config != "grants" || m.Streams[0].Grant.Events != "mail.{key}.**" {
var mailStream *struct {
Name string `json:"name"`
Events []string `json:"events"`
MaxAgeDays int `json:"max-age-days"`
MaxMiB int `json:"max-mib"`
Retention string `json:"retention"`
MaxDeliveries int `json:"max-deliveries"`
Duplicates int `json:"duplicates"`
Grant struct {
Config string `json:"config"`
Events string `json:"events"`
} `json:"grant"`
}
for i := range m.Streams {
if m.Streams[i].Name == "mail" {
mailStream = &m.Streams[i]
}
}
if !reflect.DeepEqual(m.Emits, []string{"mail.send.sent", "mail.send.failed"}) || len(m.Streams) != 2 || mailStream == nil ||
!reflect.DeepEqual(mailStream.Events, events) || mailStream.MaxAgeDays != 7 || mailStream.Grant.Config != "grants" ||
mailStream.Grant.Events != "mail.{key}.**" {
t.Fatalf("emits %v, streams %+v; the code says %v", m.Emits, m.Streams, events)
}
// It calls nothing and consumes nothing: mesh-mail-notifier (issue 800 part B) is the one that invokes and
@@ -187,3 +214,104 @@ func TestItsProcessesAreThePreparationTheActAndTheJudge(t *testing.T) {
t.Fatalf("keep restarts on %v", keep["restart-on"])
}
}
// 1172/AC2: the manifest declares the sender's account, own-secrets owner, work-queue stream and process bus,
// and widens the state directory and accounts.json so the sender (not the operator account) can read them,
// mirroring mesh-controller's own fixtures (internal/catalogue/process_bus_test.go:14-31,
// own_secret_owner_test.go:16-29, stream_work_queue_test.go:13-14).
func TestItsManifestDeclaresTheSenderProcess(t *testing.T) {
m, byID := readManifest(t)
account := byID["account"]
if account == nil || account["type"] != "user" || account["name"] != "mesh-mail-send" {
t.Fatalf("the sender's account resource: %v", account)
}
fam := m.OwnSecrets[SecretFamily+"*"]
if fam["owner"] != "mesh-mail-send" {
t.Fatalf("mail-credential-*'s owner: %v; want mesh-mail-send", fam["owner"])
}
var sendsStream *struct {
Name string `json:"name"`
Events []string `json:"events"`
MaxAgeDays int `json:"max-age-days"`
MaxMiB int `json:"max-mib"`
Retention string `json:"retention"`
MaxDeliveries int `json:"max-deliveries"`
Duplicates int `json:"duplicates"`
Grant struct {
Config string `json:"config"`
Events string `json:"events"`
} `json:"grant"`
}
for i := range m.Streams {
if m.Streams[i].Name == "mail-sends" {
sendsStream = &m.Streams[i]
}
}
if sendsStream == nil || !reflect.DeepEqual(sendsStream.Events, []string{"mail.send.approved"}) ||
sendsStream.MaxAgeDays != 1 || sendsStream.MaxMiB != 64 || sendsStream.Retention != "work-queue" ||
sendsStream.MaxDeliveries != 5 || sendsStream.Duplicates != 24 {
t.Fatalf("the stream mail-sends: %+v", sendsStream)
}
if !reflect.DeepEqual(m.Emits, []string{"mail.send.sent", "mail.send.failed"}) {
t.Fatalf("top-level emits: %v", m.Emits)
}
send := byID["send"]
if send == nil || send["type"] != "process" || send["user"] != "mesh-mail-send" {
t.Fatalf("the process send: %v", send)
}
run, _ := send["run"].([]any)
if len(run) != 2 || run[0] != "./mesh-mail" || run[1] != "send" {
t.Fatalf("send's run: %v", send["run"])
}
env, _ := send["env"].(map[string]any)
keepEnv, _ := byID["keep"]["env"].(map[string]any)
for _, k := range []string{"MESH_MAIL_ACCOUNTS", "MESH_MAIL_SECRETS"} {
if env[k] != keepEnv[k] {
t.Fatalf("send's %s is %v, keep's is %v", k, env[k], keepEnv[k])
}
}
h, _ := send["health"].(map[string]any)
if h["kind"] != "unit" {
t.Fatalf("send's health: %v", send["health"])
}
bus, _ := send["bus"].(map[string]any)
if bus == nil {
t.Fatalf("send declares no bus")
}
if !reflect.DeepEqual(toStrings(bus["emits"]), []string{"mail.send.sent", "mail.send.failed"}) ||
!reflect.DeepEqual(toStrings(bus["queues"]), []string{"mail-sends"}) ||
!reflect.DeepEqual(toStrings(bus["state"]), []string{"sends"}) || bus["asks"] != true ||
!reflect.DeepEqual(toStrings(bus["invokes"]), []string{"seat:operator-channel.tell"}) {
t.Fatalf("send's bus: %+v", bus)
}
// On novox the operator account's state directory is 0700, so mesh-mail-send cannot reach its own 0600
// files inside it: the state directory becomes 0711 (passable, not listable) and accounts.json (server
// names and addresses, no password) becomes 0644, so the sender can read the outgoing server. No password
// file's mode widens (the lead's decision, issue 1172).
if byID["state"]["mode"] != "0711" {
t.Fatalf("the state directory's mode: %v; want 0711", byID["state"]["mode"])
}
if byID["accounts"]["mode"] != "0644" {
t.Fatalf("accounts.json's mode: %v; want 0644", byID["accounts"]["mode"])
}
if byID["grants"]["mode"] != "0600" {
t.Fatalf("grants.json's mode widened to %v; it holds no password, but was not asked to widen", byID["grants"]["mode"])
}
}
func toStrings(v any) []string {
list, _ := v.([]any)
out := make([]string, 0, len(list))
for _, item := range list {
if s, ok := item.(string); ok {
out = append(out, s)
}
}
return out
}
@@ -0,0 +1,31 @@
package main
// The SDK pin a process's own bus connection needs (novox/hq ADR 0436 §4, issue 1172): mesh-mail's go.mod
// requires mesh-sdk go at the release-2 commit (main 34392e7) and go 1.26.0, and go/process is importable.
// The blank import below is what proves importability at compile time; go vet (run before every commit, per
// the context file) already fails the build if it is not.
import (
"os"
"strings"
"testing"
_ "git.novox.be/novox/mesh-sdk/go/process"
)
// 1172/AC1: given mesh-mail's go.mod, when built, then it requires mesh-sdk go at
// v0.1.14-0.20261019062050-34392e74916c and go 1.26.0, and go/process is importable (the import above).
func TestGoModPinsTheSDKsProcessRelease(t *testing.T) {
raw, err := os.ReadFile("../../go.mod")
if err != nil {
t.Fatal(err)
}
mod := string(raw)
const wantSDK = "git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261019062050-34392e74916c"
if !strings.Contains(mod, wantSDK) {
t.Fatalf("go.mod does not require %q:\n%s", wantSDK, mod)
}
if !strings.Contains(mod, "go 1.26.0") {
t.Fatalf("go.mod does not declare go 1.26.0:\n%s", mod)
}
}