The controller refused the manifest: config.operators declared type "array" (not one of the controller's
five types, novox/hq ADR 0348 §4 — the word is "list"), and the bundle's env word MESH_MAIL_WEB_LISTEN said
${port:web} (a bundle's word takes the port NUMBER a listens entry gives, novox/hq ADR 0192; mesh-controller
internal/catalogue/port_into.go portsFilledInto matches it against listens[].Port, never an endpoint's
label). Fixed to type "list" and ${port:8099} (the listens entry's own port). main.go and the integration
harness already read/write a plain port number, so neither needed a change. manifest_test.go now asserts
both: config.operators.Type == "list", and the listen word equals ${port:<the listens entry's port>};
confirmed each independently fails against the old values before this fix.
185 lines
7.1 KiB
Go
185 lines
7.1 KiB
Go
package main
|
|
|
|
// The manifest says what the code does (novox/hq ADR 0440 §1, §2, §3, §8; issue 1262).
|
|
//
|
|
// 1262/AC5: the repository's own way to run "the controller's manifest rules" is merge-check.sh, which shells
|
|
// out to the real controller binary ("$MESH_GATE" module check modules/*/module.json, merge-check.sh:127) — not
|
|
// something this package can call as a unit test, and this batch's brief refuses running merge-check.sh or any
|
|
// integration test. mesh-mail's and mesh-mail-notifier's own manifest_test.go files do not run the controller
|
|
// either: they assert the manifest's shape by hand, field by field, in Go. There is no unit-level way in this
|
|
// repository to run the controller's actual module-check rules, so TestTheManifestSaysWhatTheCodeDoes below is
|
|
// as far as the repo allows for AC5: it is the same hand-written shape assertion the sibling modules use, now
|
|
// covering every field AC1 names. The real module check still runs once, for real, through the lead's
|
|
// mesh-controller.try.
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"reflect"
|
|
"testing"
|
|
)
|
|
|
|
type seat struct {
|
|
Name string `json:"name"`
|
|
Scope string `json:"scope"`
|
|
Serves []string `json:"serves"`
|
|
}
|
|
|
|
type listen struct {
|
|
Name string `json:"name"`
|
|
Port int `json:"port"`
|
|
Protocol string `json:"protocol"`
|
|
From string `json:"from"`
|
|
}
|
|
|
|
type configEntry struct {
|
|
Type string `json:"type"`
|
|
Trusted bool `json:"trusted"`
|
|
Default []string `json:"default"`
|
|
}
|
|
|
|
type manifest struct {
|
|
Module string `json:"module"`
|
|
RunsAs string `json:"runs-as"`
|
|
Claims []seat `json:"claims"`
|
|
Invokes []string `json:"invokes"`
|
|
Reads []string `json:"reads"`
|
|
Requires []string `json:"requires"`
|
|
Contributes map[string]map[string]any `json:"contributes"`
|
|
Binds map[string]string `json:"binds"`
|
|
Secrets map[string]string `json:"secrets"`
|
|
Listens []listen `json:"listens"`
|
|
Config map[string]configEntry `json:"config"`
|
|
Tools []string `json:"tools"`
|
|
State []any `json:"state"`
|
|
OwnSecrets map[string]string `json:"own-secrets"`
|
|
SecretsOwner string `json:"secrets-owner"`
|
|
Resources []map[string]any `json:"resources"`
|
|
Build struct {
|
|
Artifacts []struct {
|
|
From string `json:"from"`
|
|
Binary string `json:"binary"`
|
|
Loads []string `json:"loads"`
|
|
Env map[string]string `json:"env"`
|
|
} `json:"artifacts"`
|
|
} `json:"build"`
|
|
}
|
|
|
|
func readManifest(t *testing.T) manifest {
|
|
t.Helper()
|
|
raw, err := os.ReadFile(filepath.Join("..", "..", "module.json"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var m manifest
|
|
if err := json.Unmarshal(raw, &m); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return m
|
|
}
|
|
|
|
// 1262/AC1 (and, as far as the repo allows, AC5 — see the file's own comment above).
|
|
func TestTheManifestSaysWhatTheCodeDoes(t *testing.T) {
|
|
m := readManifest(t)
|
|
|
|
if m.Module != "mesh-mail-web" {
|
|
t.Fatalf("module %q", m.Module)
|
|
}
|
|
if m.RunsAs != "mesh-mail-web" {
|
|
t.Fatalf("runs-as %q, want its own account", m.RunsAs)
|
|
}
|
|
wantClaims := []seat{{Name: "mail-webclient", Scope: "mesh", Serves: []string{"open", "status"}}}
|
|
if !reflect.DeepEqual(m.Claims, wantClaims) {
|
|
t.Fatalf("claims %+v, want %+v", m.Claims, wantClaims)
|
|
}
|
|
wantInvokes := []string{
|
|
"seat:mail-store.accounts", "seat:mail-store.search", "seat:mail-store.read",
|
|
"seat:mail-store.send", "seat:mail-store.send-status",
|
|
}
|
|
if !reflect.DeepEqual(m.Invokes, wantInvokes) {
|
|
t.Fatalf("invokes %v, want exactly the five mail-store verbs %v", m.Invokes, wantInvokes)
|
|
}
|
|
if !reflect.DeepEqual(m.Reads, []string{"mesh-mail.accounts"}) {
|
|
t.Fatalf("reads %v, want [mesh-mail.accounts]", m.Reads)
|
|
}
|
|
if !contains(m.Requires, "route") || !contains(m.Requires, "oidc-client") {
|
|
t.Fatalf("requires %v, want route and oidc-client", m.Requires)
|
|
}
|
|
if len(m.Listens) != 1 || m.Listens[0].Name != "web" || m.Listens[0].From != "mesh" || m.Listens[0].Protocol != "tcp" {
|
|
t.Fatalf("listens %+v, want exactly one, named web, from the mesh only", m.Listens)
|
|
}
|
|
// "list" is the controller's own word for a trusted array value (novox/hq ADR 0348 §4); "array" is not
|
|
// one of its five types (number, string, boolean, object, list) and the controller refuses it.
|
|
op, ok := m.Config["operators"]
|
|
if !ok || op.Type != "list" || !op.Trusted || len(op.Default) != 0 {
|
|
t.Fatalf("config.operators %+v, want a trusted list defaulting to nobody", op)
|
|
}
|
|
// No database, no state bucket, no writable directory beyond the account's own state dir: only the
|
|
// account (a user resource, not writable), the state directory, and the two files rendered into it.
|
|
if len(m.State) != 0 {
|
|
t.Fatalf("state %v, want none", m.State)
|
|
}
|
|
for _, dbish := range []string{"postgres-database", "s3-bucket"} {
|
|
if contains(m.Requires, dbish) {
|
|
t.Fatalf("requires %v, want no database or object store", m.Requires)
|
|
}
|
|
}
|
|
wantResourceIDs := map[string]bool{"account": true, "state": true, "operators": true, "oidc-config": true}
|
|
if len(m.Resources) != len(wantResourceIDs) {
|
|
t.Fatalf("resources %v, want exactly %v", m.Resources, wantResourceIDs)
|
|
}
|
|
for _, r := range m.Resources {
|
|
id := r["id"].(string)
|
|
if !wantResourceIDs[id] {
|
|
t.Fatalf("an unexpected resource %q: no writable directory beyond the state dir is wanted", id)
|
|
}
|
|
if id != "account" && r["type"] == "directory" && id != "state" {
|
|
t.Fatalf("an extra writable directory %q", id)
|
|
}
|
|
}
|
|
|
|
// The manifest's own tools match the code's (mirroring mesh-mail's and mesh-mail-notifier's own check).
|
|
var names []string
|
|
for _, tool := range (&Bundle{}).Tools() {
|
|
names = append(names, tool.Name)
|
|
}
|
|
if !reflect.DeepEqual(m.Tools, names) {
|
|
t.Fatalf("tools %v, the code serves %v", m.Tools, names)
|
|
}
|
|
|
|
a := m.Build.Artifacts
|
|
if len(a) != 2 || a[0].From != "cmd/mesh-mail-web" || a[0].Binary != "mesh-mail-web" || !reflect.DeepEqual(a[0].Loads, []string{"mesh-mail-web"}) {
|
|
t.Fatalf("build %+v", m.Build)
|
|
}
|
|
for k, v := range map[string]string{"MESH_MAIL_WEB_OPERATORS": "operators", "MESH_MAIL_WEB_OIDC": "oidc-config"} {
|
|
found := false
|
|
for _, r := range m.Resources {
|
|
if r["id"] == v {
|
|
found = a[0].Env[k] == r["path"]
|
|
}
|
|
}
|
|
if !found {
|
|
t.Fatalf("the bundle reads %s at %s, which does not match resource %s's path", k, a[0].Env[k], v)
|
|
}
|
|
}
|
|
// A bundle's word is ${port:<the number a listens entry gives>}, never the endpoint's own name
|
|
// (novox/hq ADR 0192; mesh-controller internal/catalogue/port_into.go portsFilledInto matches it against
|
|
// listens[].Port, a plain number, not a label like "web"). len(m.Listens)==1 is already checked above.
|
|
wantListen := fmt.Sprintf("${port:%d}", m.Listens[0].Port)
|
|
if a[0].Env["MESH_MAIL_WEB_LISTEN"] != wantListen {
|
|
t.Fatalf("MESH_MAIL_WEB_LISTEN is %q, want %q (the listens entry's own port number)",
|
|
a[0].Env["MESH_MAIL_WEB_LISTEN"], wantListen)
|
|
}
|
|
}
|
|
|
|
func contains(list []string, s string) bool {
|
|
for _, x := range list {
|
|
if x == s {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|