Compare commits

..
24 changed files with 246 additions and 533 deletions
@@ -1,65 +0,0 @@
package main
import (
"io"
"os"
"strings"
"testing"
"git.novox.be/novox/mesh-sdk/go/catalogue"
)
// Issue 1638 (novox/hq ADR 0480 clause 7), taken in with mesh-sdk's container `groups`: the controller sends a container's
// groups only to a node-engine that reports the capability container-groups, and holds the module on any other, naming it.
const n8nWithAGroup = `{"module":"n8n","version":"1",
"config":{"media-gid":{"type":"number","minimum":1,"required":true,"description":"the group that owns the media library"}},
"resources":[{"id":"server","type":"container","name":"n8n",
"image":"n8nio/n8n@sha256:4846eb2f4b874ab04cde7fc1e249d2ddaec66e9aea64439beb2972cfea88e3c0",
"groups":["${config:media-gid}"]}]}`
// printedBy runs reportUnhostable and returns what it printed.
func printedBy(t *testing.T, node string, plan catalogue.Resolution) string {
t.Helper()
stdout := os.Stdout
r, w, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
os.Stdout = w
reportUnhostable(node, plan)
w.Close()
os.Stdout = stdout
out, _ := io.ReadAll(r)
return string(out)
}
// 1638/AC1: a node that reports the capability gets the module; one that does not has it held, the capability named.
func TestAModuleWithGroupsIsHeldOnANodeThatDoesNotReportTheCapability(t *testing.T) {
m, err := catalogue.ParseManifest([]byte(n8nWithAGroup))
if err != nil {
t.Fatal(err)
}
shelf := catalogue.Shelf{"n8n": m}
reads := catalogue.Node{Name: "ace", Capabilities: map[string]bool{"container-runtime": true, "container-groups": true}}
old := catalogue.Node{Name: "g14", Capabilities: map[string]bool{"container-runtime": true}}
got, err := catalogue.Resolve(shelf, []string{"n8n"}, reads, catalogue.World{})
if err != nil || len(got.Unhostable) != 0 || len(got.Modules) != 1 {
t.Fatalf("a node that reads groups: %v, held %+v", err, got.Unhostable)
}
if out := printedBy(t, "ace", got); out != "" {
t.Errorf("a node that reads groups was told: %q", out)
}
got, err = catalogue.Resolve(shelf, []string{"n8n"}, old, catalogue.World{})
if err != nil {
t.Fatal(err)
}
if len(got.Modules) != 0 || len(got.Unhostable) != 1 {
t.Fatalf("n8n was sent to a node that does not report container-groups: hosted %d, held %+v", len(got.Modules), got.Unhostable)
}
if out := printedBy(t, "g14", got); !strings.Contains(out, "n8n") || !strings.Contains(out, "container-groups") {
t.Errorf("the hold does not name the module and the capability: %q", out)
}
}
+1 -1
View File
@@ -3,7 +3,7 @@ module github.com/novox/mesh-controller
go 1.26.0
require (
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021130958-85b5a2687e6a
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021093645-465beb4ea5e3
github.com/jackc/pgx/v5 v5.10.0
github.com/nats-io/nats-server/v2 v2.11.17
github.com/nats-io/nats.go v1.54.0
+2 -2
View File
@@ -1,7 +1,7 @@
git.novox.be/novox/mesh-host v0.0.0-20261018171808-9d9e3a46c4f5 h1:E5stfIeCN6uYgA9TCv+4K8JgaURg3zQppvUgJLaDMP4=
git.novox.be/novox/mesh-host v0.0.0-20261018171808-9d9e3a46c4f5/go.mod h1:4wyoCr6IIIIarEUuROThWDj7DAvJvIidqMjWcpSnOwE=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021130958-85b5a2687e6a h1:CVTQBD4vxbGQ+TW9MWAut/ABQN6nvAZ1VTqojNpvGUE=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021130958-85b5a2687e6a/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021093645-465beb4ea5e3 h1:OKxU64kHN1yQ/GOa6LwoUwVmiQ74d83WMSZJRFPVm9c=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021093645-465beb4ea5e3/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+37
View File
@@ -362,6 +362,14 @@ func (m *natsMachine) Take(ctx context.Context, do func(context.Context, Build))
_ = msg.Nak()
continue
}
// **A duplicate of a check another builder is running is dropped** (issue 1649): an ask redelivered to this machine
// because its first holder did not say, in time, that it had it. The running key names that builder and this same
// ask; running it a second time would spend a second place on one check.
if why := m.runningElsewhere(ctx, request); why != "" {
fmt.Fprintf(os.Stderr, "%s is handed out again while another builder runs it and is dropped: %s\n", request.ID, why)
_ = msg.Ack()
continue
}
// A build outlives the acknowledgement window many times over; said while it runs,
// as the controller says it for its own long handlers, so the server neither hands
// the ask to a second machine nor counts the wait against its deliveries.
@@ -376,6 +384,35 @@ func (m *natsMachine) Take(ctx context.Context, do func(context.Context, Build))
}
}
// runningElsewhere says why an ask need not run here, or nothing: the bucket of the checks running says another builder
// runs this very ask (its id, its head and its trunk commit). A key that cannot be read runs the check.
func (m *natsMachine) runningElsewhere(ctx context.Context, r BuildRequest) string {
if r.Check == nil {
return ""
}
running, ok, err := ReadRunning(ctx, m.js.Conn(), r)
if err != nil {
fmt.Fprintf(os.Stderr, "could not read whether %s is running elsewhere, so it runs: %v\n", r.ID, err)
return ""
}
return duplicateOf(running, ok, r, m.on)
}
// runningFreshFor is how recent a running key's last heartbeat must be for its builder to be taken as alive: two missed
// heartbeats of ten seconds are not yet a dead builder, and a redelivery after a crash comes a minute after the last one.
const runningFreshFor = 25 * time.Second
// duplicateOf says why an ask is a second hand-out of one another builder is running, or nothing.
func duplicateOf(running CheckRunning, found bool, r BuildRequest, me string) string {
// Seen is the server's time and runningFreshFor is judged against this builder's clock: a skew over about 35 s would matter.
// A key not renewed for runningFreshFor is a builder that has gone: its ask is redelivered after the ack wait, and
// that is the recovery, not a duplicate (the key itself lives two minutes).
if found && running.Build == r.ID && running.Builder != me && running.Same(r) && time.Since(running.Seen) < runningFreshFor {
return fmt.Sprintf("%s is running it as %s since %s", running.Builder, running.Build, running.Started.Local().Format("15:04:05"))
}
return ""
}
// bind binds a queue's worker, which exists and is not created here.
//
// **The consumer's own filter, not the one subject this machine cares about.** The client checks what is asked for
+18 -1
View File
@@ -253,11 +253,25 @@ func watchPullIn(ctx context.Context, buckets []string, open func(ctx context.Co
// checkTakeWait is how long a taking loop waits on one queue for an ask that is not there, and takeIdleWait how long it
// waits on the first when all three are empty.
//
// **A fetch's pull request must end before the fetch does** (issue 1649). nats.go gives the server-side request an expiry a tenth
// shorter than the fetch's wait (fetchExpiryOf), and the server then answers with a status message at the expiry; a wait so
// short that the answer can land after the fetch gave up left a status message in the pull's buffer, and anything a request
// could still deliver would be held by a machine that is busy. Each wait leaves at least minFetchMargin between the two.
const (
checkTakeWait = 30 * time.Millisecond
checkTakeWait = 100 * time.Millisecond
takeIdleWait = time.Second
)
// minFetchMargin is the least time between a fetch's server-side expiry and the end of its wait.
const minFetchMargin = 10 * time.Millisecond
// fetchExpiryOf is the expiry nats.go gives the pull request of a fetch that waits this long: the wait less a tenth of it, or
// less five seconds.
func fetchExpiryOf(wait time.Duration) time.Duration {
return wait - min(wait/10, 5*time.Second)
}
// CheckRunning is what a builder says of the check it is running, in the bucket of the checks running (novox/hq ADR 0406
// §3): the head and trunk commit it checks, the builder and its build. The key is the pull request's (RunningKeyOf).
type CheckRunning struct {
@@ -268,6 +282,8 @@ type CheckRunning struct {
// Started is when the check started, kept on every heartbeat: the entry's own time is the last heartbeat's, and a
// reader that asks how long a check has run reads this (novox/hq ADR 0433 §6).
Started time.Time `json:"started"`
// Seen is when the key was last put, which is the last heartbeat: read from the entry, never written (issue 1649).
Seen time.Time `json:"-"`
}
// RunningKeyOf is the key a check is said running under: its pull request's tokens, or `group.<name>` for a group's
@@ -319,6 +335,7 @@ func ReadRunning(ctx context.Context, conn *nats.Conn, r BuildRequest) (CheckRun
if err := json.Unmarshal(e.Value(), &running); err != nil {
return CheckRunning{}, false, nil // a key of a builder from before this means no match
}
running.Seen = e.Created()
return running, true, nil
}
@@ -6,6 +6,7 @@ import (
"context"
"encoding/json"
"reflect"
"strings"
"testing"
"time"
@@ -510,3 +511,122 @@ func TestAnUnacknowledgedCheckGoesToASecondBuilder(t *testing.T) {
stop()
}
}
// An ask handed out a second time to another builder while the first still runs it is acknowledged and not run: the
// running key names the other builder and this same ask (issue 1649).
func TestAnAskHandedOutAgainWhileAnotherBuilderRunsItIsNotRunTwice(t *testing.T) {
js := aBusWithTheBuildRole(t)
aKeyBucket(t, js)
aRunningBucket(t, js, 2*time.Minute)
ctx, stop := context.WithCancel(t.Context())
defer stop()
asks := BuildsOn(js, TheBuildMachine)
running := make(chan string, 4)
release := make(chan struct{})
a := MachineOverNATS(js, "builder-a")
defer a.Close()
go func() {
_ = a.Take(ctx, func(ctx context.Context, work Build) {
running <- work.Request().ID
<-release
_ = work.Announce(ctx, BuildResult{ID: work.Request().ID, On: "builder-a", Failed: "no"})
_ = work.Done()
})
}()
check := aPullCheck("build-twice", 7, "head-7-000000000", "trunk-1-000000", false)
if err := asks.Ask(ctx, check); err != nil {
t.Fatal(err)
}
select {
case <-running:
case <-time.After(10 * time.Second):
t.Fatal("builder a took nothing")
}
// The bus's redelivery, as it reaches the other machine: the same ask, the same id.
if err := asks.Ask(ctx, check); err != nil {
t.Fatal(err)
}
b := MachineOverNATS(js, "builder-b")
defer b.Close()
took := make(chan string, 4)
go func() {
_ = b.Take(ctx, func(ctx context.Context, work Build) {
took <- work.Request().ID
_ = work.Done()
})
}()
select {
case id := <-took:
t.Fatalf("builder b ran %s, which builder a is running", id)
case <-time.After(3 * time.Second):
}
if n := len(queueOf(t, js).Of(AskWaiting)); n != 0 {
t.Errorf("%d request(s) still wait; builder b was to acknowledge the duplicate", n)
}
close(release)
}
// A busy machine fetches nothing, so no ask waiting in the queue is taken by it, whatever status message sits in its pulls
// (issue 1649). A pull request of our own that expires at once leaves such a message in the other checks' pull.
func TestABusyMachineTakesNoWaitingAskWhateverItsPullsHold(t *testing.T) {
js := aBusWithTheBuildRole(t)
aKeyBucket(t, js)
aRunningBucket(t, js, 2*time.Minute)
ctx, stop := context.WithCancel(t.Context())
defer stop()
asks := BuildsOn(js, TheBuildMachine)
busy := make(chan string, 4)
release := make(chan struct{})
a := MachineOverNATS(js, "builder-a").(*natsMachine)
defer a.Close()
go func() {
_ = a.Take(ctx, func(ctx context.Context, work Build) {
busy <- work.Request().ID
<-release
_ = work.Announce(ctx, BuildResult{ID: work.Request().ID, On: "builder-a", Failed: "no"})
_ = work.Done()
})
}()
if err := asks.Ask(ctx, BuildRequest{ID: "busy", Repository: "/d"}); err != nil {
t.Fatal(err)
}
select {
case <-busy:
case <-time.After(10 * time.Second):
t.Fatal("builder a took nothing")
}
var other *takeQueue
for _, q := range a.subs {
if q.kind == queueOther && q.bound {
other = q
}
}
if other == nil {
t.Fatal("builder a has no pull on the other checks")
}
// The queue is empty, so this request is answered at its expiry with a status message that lands in the pull.
reply := strings.TrimSuffix(other.sub.Subject, "*") + "late"
if err := js.Conn().PublishRequest("$JS.API.CONSUMER.MSG.NEXT."+other.worker.Stream+"."+other.worker.Name, reply,
[]byte(`{"batch":1,"expires":1000000}`)); err != nil {
t.Fatal(err)
}
time.Sleep(500 * time.Millisecond)
for i, id := range []string{"wait-1", "wait-2"} {
if err := asks.Ask(ctx, aPullCheck(id, 20+i, "head-x-000000000", "trunk-1-000000", false)); err != nil {
t.Fatal(err)
}
}
time.Sleep(5 * time.Second)
info, err := js.Context().ConsumerInfo(other.worker.Stream, other.worker.Name)
if err != nil {
t.Fatal(err)
}
if info.NumAckPending != 0 || info.NumRedelivered != 0 || info.Delivered.Consumer != 0 {
t.Fatalf("a busy machine took from the queue: %d delivered, %d pending, %d redelivered", info.Delivered.Consumer,
info.NumAckPending, info.NumRedelivered)
}
if n := len(queueOf(t, js).Of(AskWaiting)); n != 2 {
t.Fatalf("%d asks wait; want the 2 nobody took", n)
}
close(release)
}
+43
View File
@@ -0,0 +1,43 @@
package link
import (
"testing"
"time"
)
// An ask a machine holds but does not start gets no heartbeat, and the bus hands it to another machine after the ack
// wait, so two machines run one check (issue 1649).
// A fetch's server-side pull request ends clearly before the fetch does, so the server's answer and any message it could still
// deliver come inside the fetch and never into a buffer nobody reads while the machine builds.
func TestAFetchsPullRequestEndsClearlyBeforeTheFetchDoes(t *testing.T) {
for name, wait := range map[string]time.Duration{"a check queue": checkTakeWait, "the idle wait": takeIdleWait} {
if margin := wait - fetchExpiryOf(wait); margin < minFetchMargin {
t.Errorf("%s: the pull request ends %v before a fetch of %v does; want at least %v", name, margin, wait, minFetchMargin)
}
}
}
func TestAnAskAnotherBuilderIsRunningIsADuplicateAndYoursIsNot(t *testing.T) {
r := aPullCheck("build-1", 423, "head-4771ee53aa", "trunk-0123456789", false)
run := CheckRunning{Head: r.Ref, Trunk: "trunk-0123456789", Builder: "shanks", Build: "build-1", Started: time.Now().Add(-time.Minute), Seen: time.Now().Add(-4 * time.Second)}
dead := run
dead.Seen = time.Now().Add(-60 * time.Second)
for name, c := range map[string]struct {
running CheckRunning
found bool
me string
dup bool
}{
"another builder runs this very ask": {run, true, "ace", true},
"its builder stopped renewing the key a minute ago": {dead, true, "ace", false},
"this builder runs it itself": {run, true, "shanks", false},
"nobody runs it": {CheckRunning{}, false, "ace", false},
"another ask of the same head": {CheckRunning{Head: r.Ref, Trunk: "trunk-0123456789", Builder: "shanks", Build: "build-0"}, true, "ace", false},
"the same ask at another head": {CheckRunning{Head: "head-other-0000", Trunk: "trunk-0123456789", Builder: "shanks", Build: "build-1"}, true, "ace", false},
} {
if got := duplicateOf(c.running, c.found, r, c.me) != ""; got != c.dup {
t.Errorf("%s: duplicate = %v; want %v", name, got, c.dup)
}
}
}
+2 -2
View File
@@ -1,5 +1,5 @@
module route-proxy
go 1.26.0
go 1.25.0
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021114014-66e8fdaa62e2
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261018003858-f185aedbf5d6
+4 -2
View File
@@ -1,2 +1,4 @@
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021114014-66e8fdaa62e2 h1:Cdl5N56QPfmFBoLHrDfvvyc5Q+PUnTjf3fgYD7imcco=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021114014-66e8fdaa62e2/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261016174426-a4e7f480b3b0 h1:EK84so3Y8JdFT3BHrzj9cPQQrDUPa/d+IdOGnerlmEI=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261016174426-a4e7f480b3b0/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261018003858-f185aedbf5d6 h1:Sv1Qzix+U1RpaUkw5IbB0IJEyZ+FqgzHmbZrCZkcWf0=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261018003858-f185aedbf5d6/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
@@ -17,5 +17,3 @@ at the same version as the TypeScript package.
What it holds, under ADR 0039's test: the stdio loop and the tool type, and `cli`, the local protocol
`mesh-cli` speaks to the node-engine on its machine (novox/hq ADR 0272): the socket's path and one request and one
answer; and `seal`, the mesh's sealed box (novox/hq ADR 0206: X25519, HKDF-SHA256, AES-256-GCM, `{v, eph, iv, tag, ct}`), byte for byte the box the licence manager made before it lived here and the TypeScript `@novox/mesh-sdk/seal` makes. Nothing else — no bus, no client, nothing of any module's. `go build ./example` and pipe a `tools/list` request into it.
From release 4a of design 47 (novox/hq ADR 0453 clause 3, ADR 0446 clause 4) it also holds the composition of a machine's declaration, over a plain input and with nothing behind it: `declaration` and `validate`, the node-engine's parser and its validator, copied from mesh-host; `catalogue`, the manifests, their resolution and rendering, and `overlay`, the private network's generator, copied from mesh-controller; `compose`, the controller's planFor, renderingFor and declarationWith as functions of an `Input` (the composition never allocates, makes, keeps or writes: what a send would do it names as foreseen or returns); `sdkversion`, which mesh-sdk version a program was built with; and, in `delivery`, the layouts of the keys the builder, the checker and the indexer write (the source fingerprint's new form, the builds, the verdicts and kept verdicts, the manifests by digest). None of them holds a bus, a store or a vault client, which `go list -deps` of `compose` shows.
@@ -5,127 +5,16 @@
# seat in the mesh's Go toolchain, its own container, because the SDK's merge-check.sh declares it
# (`# mesh-check-also: go go/merge-check.sh`) — and by hand, from anywhere: `sh go/merge-check.sh`.
#
# Formatted, vetted (with and without the build tag `integration`), and run at two levels (novox/hq ADR 0391):
#
# 1. the unit level: `go test ./...` untagged, no bus or other process, timed and held to a bound
# (unit_bound below); over it the check fails and names the slowest tests (issue 895);
# 2. the integration level: the whole suite with `-tags integration`, each test against a bus of its
# own (internal/testbus, which issue 1171 brings with the first test that needs a bus) so nothing is
# serialised. With no tagged test yet, the level runs the unit tests again and says their count.
#
# **A test that starts a bus or another process carries `//go:build integration`** as the first line of
# its file; a file that held both kinds is two files (`<name>_integration_test.go` beside `<name>_test.go`).
# Each level says its test count and its time.
#
# No dependency is committed (no vendor directory: .gitignore; the operator, 2026-10-19; issue 1304). Every dependency
# is resolved by the version and checksum go.sum names (novox/hq ADR 0405 §5), from its public source: this binding
# requires nothing of the mesh's own forge. `go mod verify` fails by module name when a downloaded dependency does not
# match go.sum, and -mod=readonly fails a build whose go.sum lacks an entry instead of writing it. gofmt reads only
# the binding's own files.
#
# Exit 0 is a pass; anything else fails `mesh/repo-check` with the last line printed.
# Formatted, vetted, and its whole suite run: the stdio loop and the local protocol mesh-cli speaks to the
# node-engine. Standard library only, so nothing is fetched.
set -eu
cd "$(dirname "$0")"
# The toolchain the check runs in sets -mod=vendor; there is no vendor directory, so it is replaced.
GOFLAGS="$(printf '%s' "${GOFLAGS:-}" | sed 's/-mod=[a-z]*//g') -mod=readonly"
export GOFLAGS
export GOPRIVATE=git.novox.be
# **The unit level's bound, in seconds** (issue 895): the slowest package's own test time of the untagged run,
# the compile not counted. A run over it fails the check and names the slowest tests. The SDK's unit suite is
# small (its slowest package measured well under a second on a quiet machine), so 2 s leaves room for a busier
# build node without hiding a test that should carry `integration` instead.
unit_bound=2
# judge_unit_run says the unit level as `go test -v` reported it and fails when it was over the bound: $1 the
# file holding the output of the untagged `go test -v` of every package (packages one after another, each
# ending in its `ok <package> <time>` line), $2 the bound in seconds. Packages run side by side, so the
# level's time is that of its slowest package; the five slowest tests of the run are named when it is over,
# so that the bound is kept by making them cheap or by tagging them `integration`, and never by raising it
# (issue 895).
judge_unit_run() {
out=$1 bound=$2
awk '
/^--- (PASS|FAIL|SKIP): / {
name = $3; took = $4; gsub(/[()s]/, "", took)
pending[++n] = took "\t" name
if ($2 == "SKIP:") skipped++
next
}
/^(ok|FAIL)[ \t]/ && NF >= 3 && $3 ~ /^[0-9.]+s$/ {
t = $3; sub(/s$/, "", t)
for (i = 1; i <= n; i++) print "T\t" pending[i] "\t" $2
n = 0
print "P\t" t "\t" $2
}
END { print "S\t" skipped + 0 }
' "$out" >"$out.judged"
tests=$(grep -c '^T' "$out.judged" || true)
skipped=$(awk -F'\t' '$1 == "S" {print $2}' "$out.judged")
level=$(awk -F'\t' '$1 == "P" && $2 + 0 > m {m = $2 + 0} END {printf "%.2f", m}' "$out.judged")
echo "unit level: $tests tests ($skipped skipped), $level s (bound $bound s)"
if awk -v t="$level" -v b="$bound" 'BEGIN {exit !(t + 0 > b + 0)}'; then
echo "the unit level took $level s, over its bound of $bound s; the slowest tests:"
awk -F'\t' '$1 == "T" {print $2 "\t" $4 "\t" $3}' "$out.judged" | sort -t"$(printf '\t')" -k1,1 -nr | head -n 5 |
awk -F'\t' '{printf " %s s %s %s\n", $1, $3, $2}'
echo "make them cheap (no wait, no process), or give their files //go:build integration if they start a bus or another process"
return 1
fi
}
# `sh go/merge-check.sh judge-unit-run <output> <bound>` runs only the judging of the unit level, on a
# recorded output.
if [ "${1:-}" = judge-unit-run ]; then
shift
judge_unit_run "$@"
exit
fi
if ! fetched=$(go mod download 2>&1); then
echo "FAILED: the dependencies cannot be fetched: $(printf '%s\n' "$fetched" | tail -n 1)"
exit 1
fi
if ! verified=$(go mod verify 2>&1); then
echo "FAILED: a dependency does not match go.sum: $verified"
exit 1
fi
unformatted=$(find . -name '*.go' -not -path './vendor/*' | xargs gofmt -l)
unformatted=$(gofmt -l .)
if [ -n "$unformatted" ]; then
echo "not gofmt'd:"
echo "$unformatted"
exit 1
fi
go vet ./...
# The files tagged `integration` are vetted too, or nothing would read them.
go vet -tags integration ./...
# NOT RACE-CHECKED here: the SDK's suite is small and carries no store or other shared state between
# packages, so the race detector is not required of this check (novox/hq issue 1175); a module that needs
# it asks for it in its own merge-check.sh.
echo "NOT RACE-CHECKED: the Go binding's check runs without the race detector"
work=$(mktemp -d)
trap 'rm -rf "$work"' EXIT
# The unit level first and alone: every package untagged, no bus or other process (judged above).
status=0
go test -count=1 -v ./... >"$work/unit.out" 2>&1 || status=$?
if [ "$status" -ne 0 ]; then
grep -vE '^(=== |[[:space:]]*--- PASS)' "$work/unit.out"
echo "the unit level failed (go test exited $status)"
exit 1
fi
judge_unit_run "$work/unit.out" "$unit_bound"
# The integration level: the whole suite with the tag, each test against its own bus (internal/testbus).
integration_start=$(date +%s)
status=0
go test -tags integration -count=1 -v ./... >"$work/integration.out" 2>&1 || status=$?
grep -vE '^(=== |[[:space:]]*--- PASS)' "$work/integration.out" || true
tests=$(grep -cE '^--- (PASS|FAIL|SKIP): ' "$work/integration.out" || true)
skipped=$(grep -cE '^--- SKIP: ' "$work/integration.out" || true)
echo "integration level: $tests tests ($skipped skipped), $(($(date +%s) - integration_start)) s"
if [ "$status" -ne 0 ]; then
echo "the integration level failed (go test exited $status)"
exit 1
fi
go test -count=1 ./...
@@ -57,11 +57,7 @@ type message struct {
// channel is the one conversation with the runtime: what this bundle writes, serialised, and what
// it asked and waits for.
type channel struct {
mu sync.Mutex
// wmu orders the writes to the runtime and nothing else: a write waits on the pipe for as long as
// the runtime does not read, and mu must never wait with it, or the loop that reads the runtime's
// answers stops (issue 1286).
wmu sync.Mutex
mu sync.Mutex
enc *json.Encoder
next int
waiting map[string]chan message
@@ -94,8 +90,8 @@ type Envelope struct {
func (c *channel) say(m map[string]any) {
m["jsonrpc"] = "2.0"
c.wmu.Lock()
defer c.wmu.Unlock()
c.mu.Lock()
defer c.mu.Unlock()
_ = c.enc.Encode(m)
}
@@ -476,15 +472,10 @@ func matchFrom(p []string, pi int, k []string, ki int) bool {
return matchFrom(p, pi+1, k, ki+1)
}
// EmitOptions is the provenance an emitter may attach (ADR 0042), and the message id it may choose.
// EmitOptions is the provenance an emitter may attach (ADR 0042).
type EmitOptions struct {
CausationID string
Schema string
// MessageID is the id this emit is sent under, as x-event-id (and, by the tool runner, as the bus's
// own Nats-Msg-Id): chosen by the emitter so a repeated publish inside the stream's duplicate window
// is kept to one, never generated once an id is given (novox/hq ADR 0436 clause 4). Empty is a random
// one, as on main.
MessageID string
}
// Emit asks the runtime to publish an event as this bundle's module, and returns once the bus has
@@ -504,17 +495,8 @@ func Emit(eventType string, body any, opts ...EmitOptions) error {
if node == "" {
node = "unknown"
}
id := ""
for _, o := range opts {
if o.MessageID != "" {
id = o.MessageID
}
}
if id == "" {
id = eventID()
}
headers := map[string]string{
"x-event-id": id, "x-source": source, "x-node": node,
"x-event-id": eventID(), "x-source": source, "x-node": node,
"x-time": time.Now().UTC().Format(time.RFC3339Nano), "content-type": "application/json",
}
for _, o := range opts {
+2 -2
View File
@@ -1,4 +1,4 @@
# git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021114014-66e8fdaa62e2
## explicit; go 1.26.0
# git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261018003858-f185aedbf5d6
## explicit; go 1.22
git.novox.be/novox/mesh-sdk/go
git.novox.be/novox/mesh-sdk/go/provider
+1 -1
View File
@@ -9,7 +9,7 @@ a second place to clean besides the catalogue itself.
mesh-catalog e0bc919d4945bb5f13f971f74643a0b714cf45da modules/*/module.json, modules/nats/Dockerfile
mesh-host 9d9e3a46c4f5943cfffb413d962ce48b75e2fd39 examples/foundation-first-node.lock
mesh-sdk 85b5a2687e6a8d0854dd8e53ed68f98e0e9d3c1b conformance/events/module-event.json
mesh-sdk 465beb4ea5e37eb36f6ba7fc60274de2d5a0d060 conformance/events/module-event.json
To move them, from this repository's root, with the two repositories checked out beside it:
@@ -2214,7 +2214,6 @@ func parseManifest(raw []byte, predefined PredefinedList) (Manifest, error) {
problems = append(problems, replacesProblems(m)...)
problems = append(problems, UnitSettingProblems(m)...)
problems = append(problems, OwnerSettingProblems(m)...)
problems = append(problems, GroupSettingProblems(m)...)
problems = append(problems, endpointNameProblems(m)...)
problems = append(problems, RouteProblems(m)...)
for _, port := range m.Guards {
+1 -18
View File
@@ -904,7 +904,7 @@ func isModule(catalogue map[string]Manifest, want string) bool {
// machine can host the module as far as its capabilities go.
func missingCapabilities(m Manifest, node Node) []string {
var missing []string
for _, c := range append(append([]string(nil), m.Capabilities...), impliedCapabilities(m)...) {
for _, c := range m.Capabilities {
if !node.Capabilities[c] {
missing = append(missing, c)
}
@@ -1457,20 +1457,3 @@ func packageNames(m map[string][]string) []string {
sort.Strings(names)
return names
}
// ContainerGroups is the capability a node-engine reports when it reads a container's `groups` and passes each as a
// supplementary group (novox/hq ADR 0480 clause 7, issue 1638). An older engine refuses the key (strict decoding).
const ContainerGroups = "container-groups"
// impliedCapabilities are the capabilities a module needs for what its resources say, beyond those it declares: a
// container that names groups needs an engine that reads them, so the module is left out of a machine whose report
// lacks the capability, naming it, and the field is sent to no engine that would refuse it. None for a module whose
// containers name no groups.
func impliedCapabilities(m Manifest) []string {
for _, r := range m.Resources {
if fmt.Sprint(r["type"]) == "container" && len(groupsOf(r)) > 0 {
return []string{ContainerGroups}
}
}
return nil
}
@@ -66,8 +66,7 @@ var capabilityName = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*$`)
// names an `if-capability` may give (novox/hq ADR 0255). A name nothing detects would leave its
// contribution out on every machine, silently, so it is refused. A detector added there is added here.
var KnownCapabilities = []string{
"battery", "container-groups", "container-runtime", "file-references", "firewall", "graphical-session", "overlay",
"package-manager",
"battery", "container-runtime", "firewall", "graphical-session", "overlay", "package-manager",
"power-meter", "privileged", "seat", "service-manager", "uplink-dhcpcd", "uplink-networkmanager",
"uplink-systemd-networkd", "user-namespaces", "virtualisation",
}
@@ -85,9 +85,6 @@ func settingInto(resource map[string]any, layers []Layer, module string) error {
if fmt.Sprint(resource["type"]) == "directory" {
return settingIntoOwner(resource, layers, module)
}
if fmt.Sprint(resource["type"]) == "container" {
return settingIntoGroups(resource, layers, module)
}
if fmt.Sprint(resource["type"]) != "file" {
return nil
}
@@ -346,12 +343,6 @@ func settingKeysAskedOutsideOwners(m Manifest) map[string]bool {
if unit, ok := r["unit"].(string); ok {
note(unit)
}
case "container":
for _, g := range groupsOf(r) {
if s, ok := g.(string); ok {
note(s)
}
}
}
}
inValues := func(values map[string]any) {
@@ -374,100 +365,3 @@ func settingKeysAskedOutsideOwners(m Manifest) map[string]bool {
}
return used
}
// A container's supplementary groups may be an operator's value (novox/hq ADR 0480 clause 7, ADR 0348 §11, issue 1636):
// n8n joins the media library's group, whose number is the machine's, never the definition's (ADR 0112 §1). Each
// element of `groups` is a whole placeholder or a whole number; a placeholder is filled with the number the
// config value holds, and the list reaches the node-engine as numbers.
// groupSide is one element of a container's groups: a whole placeholder or a whole number.
var groupSide = regexp.MustCompile(`^(?:\$\{setting:[a-z0-9][a-z0-9_.-]*\}|[0-9]+)$`)
// groupsOf is a container resource's groups as the manifest wrote them (text or numbers), none when it names none.
func groupsOf(r map[string]any) []any {
groups, _ := r["groups"].([]any)
return groups
}
// GroupSettingProblems refuses, where a manifest is read, a container whose groups hold anything but whole
// placeholders and whole numbers, or a placeholder whose key the module neither declares nor asks for elsewhere.
func GroupSettingProblems(m Manifest) []string {
var problems []string
for _, r := range m.Resources {
if fmt.Sprint(r["type"]) != "container" {
continue
}
for _, g := range groupsOf(r) {
s, isText := g.(string)
if !isText {
continue // a number: the declaration judges its value
}
if !groupSide.MatchString(s) {
problems = append(problems, fmt.Sprintf("%s: the container %v's group %q is not a whole "+
"${config:key} or a whole number: a supplementary group is a number, and nothing else is "+
"written into it (novox/hq ADR 0480 clause 7)", m.Module, r["id"], s))
continue
}
if m.Templated() {
continue
}
for _, key := range settingsUsed(s) {
if _, declared := m.Settings[key]; !declared && !m.DeclaresConfig() {
continue // the settings form judges its own keys
}
if _, declared := m.Settings[key]; !declared {
problems = append(problems, fmt.Sprintf("%s: the container %v's group says ${config:%s}, and the module "+
"declares no such value (novox/hq ADR 0348 §4)", m.Module, r["id"], key))
}
}
}
}
return problems
}
// settingIntoGroups fills ${setting:…} in a container's groups, refusing — with the key and why, the resource left as
// it was — a key nothing sets and a value that is not a whole number above zero. A number is kept; the list that
// results is numbers alone.
func settingIntoGroups(resource map[string]any, layers []Layer, module string) error {
groups := groupsOf(resource)
if len(groups) == 0 {
return nil
}
filled := make([]any, 0, len(groups))
for _, g := range groups {
s, isText := g.(string)
if !isText {
if f, isNumber := g.(float64); isNumber && f == float64(int(f)) {
g = int(f) // a number the manifest wrote, as the numbers a placeholder gives
}
filled = append(filled, g)
continue
}
keys := settingsUsed(s)
if len(keys) != 1 || settingRef.ReplaceAllString(s, "") != "" {
filled = append(filled, g) // not a whole placeholder: the manifest check refuses it
continue
}
key := keys[0]
value, set := settingValue(layers, key)
if !set {
return &UnsetSettingError{Module: module, Setting: key,
where: fmt.Sprintf("a container %v whose groups", resource["id"]), setToday: orNoSettings(layers),
said: fmt.Sprintf(
"%s has a container %v whose groups say ${setting:%s}, and nothing sets %q for it — an operator's "+
"value is the assignment's, never the definition's (novox/hq ADR 0112): "+
"`settings set %s <file>` with {%q: …}%s",
module, resource["id"], key, key, module, key, orNoSettings(layers))}
}
v := plainly(value)
id, whole := wholeID(v)
if !whole || id == 0 {
return fmt.Errorf("%s: the setting %q is %q, which cannot be a group of the container %v: a supplementary "+
"group is a whole number above zero, never a name or other text (novox/hq ADR 0480 clause 7, ADR 0328 §15)",
module, key, v, resource["id"])
}
filled = append(filled, int(id))
}
resource["groups"] = filled
return nil
}
@@ -56,7 +56,7 @@ type filler struct {
// seatInto, machineInto and contributionsInto. A pass that comes to read another field adds it here,
// or the sweep refuses the placeholder it would have filled.
var fillers = []filler{
{namespace: "setting", patterns: []*regexp.Regexp{settingRef}, fields: map[string][]string{"file": {"content"}, "service": {"unit"}, "directory": {"owner"}, "container": {"groups"}}},
{namespace: "setting", patterns: []*regexp.Regexp{settingRef}, fields: map[string][]string{"file": {"content"}, "service": {"unit"}, "directory": {"owner"}}},
{namespace: "dir", patterns: []*regexp.Regexp{dirRef}, fields: map[string][]string{"*": {"path", "content", "volumes", "env", "env-file"}}},
{namespace: "access", patterns: []*regexp.Regexp{accessRef}, fields: map[string][]string{"*": {"path", "content", "volumes", "env", "env-file"}}},
{namespace: "secret", patterns: []*regexp.Regexp{placeholder}, fields: map[string][]string{"file": {"content"}},
@@ -324,15 +324,6 @@ type File struct {
// depend on a field somewhere else.
Bytes string `json:"bytes,omitempty"`
// Digest and Size say the content is not carried but fetched: a file over FileReferenceThreshold travels as
// the sha256 digest of its content, "sha256:<64 hex>", and its size in bytes, in place of Content or Bytes
// (novox/hq ADR 0486 §1). The node-engine fetches the object of that name from FileBucket, checks what it
// received against Digest and Size, and only then writes the file. Exclusive with Content, Sealed, Bytes,
// VaultSealed and Secrets; sent only to an engine that reports FileReferencesCapability, as an older engine
// refuses a field it does not know, whole.
Digest string `json:"digest,omitempty"`
Size int64 `json:"size,omitempty"`
// Owner is the user this belongs to, by name. Absent means root.
Owner string `json:"owner,omitempty"`
@@ -474,10 +465,6 @@ func (f *File) validate(where string, _ bool) []string {
problems = append(problems, where+": a file the vault sealed whole has no holes for secrets")
}
}
problems = append(problems, f.checkReference(where)...)
if f.Digest != "" || f.Size != 0 {
said = append(said, "digest")
}
if len(said) > 1 {
sort.Strings(said)
problems = append(problems, where+
@@ -1467,14 +1454,6 @@ type Container struct {
// container runs as its image says. Its key is `identity`, the manifest's word; the field is not, because
// a resource's Identity is its id.
RunAs *ContainerIdentity `json:"identity,omitempty"`
// Groups are the supplementary groups the container's process joins, as numbers, each passed to the runtime
// as a group to add (novox/hq ADR 0480 clause 7, issue 1636): n8n writes the media library through the
// library's group, which a stock image knows nothing of. Each is above zero and below the kernel's "no group"
// (ADR 0328 §15: never root). Part of the spec, so a change recreates the container. Absent: none are added.
// An older node-engine refuses the key (strict decoding), so the controller sends it only to a node that
// reports it reads it.
Groups []int64 `json:"groups,omitempty"`
}
// The ways an image is told who to run as (novox/hq ADR 0325 §2).
@@ -1542,30 +1521,6 @@ func (i *ContainerIdentity) problems(where string, env map[string]string) []stri
return problems
}
// groupProblems refuses a supplementary group that is root's or below it, the kernel's "no group" number, or a
// number named twice (novox/hq ADR 0480 clause 7, ADR 0328 §15).
func (c *Container) groupProblems(where string) []string {
var problems []string
seen := map[int64]bool{}
for _, g := range c.Groups {
switch {
case g == 0:
problems = append(problems, fmt.Sprintf("%s: container %s adds group 0, and the mesh never makes or keeps anything "+
"as root: a supplementary group is never root's (novox/hq ADR 0328 §15)", where, c.Name))
case g < 0:
problems = append(problems, fmt.Sprintf("%s: container %s adds group %d, below zero: a group is a whole number "+
"above zero", where, c.Name, g))
case g >= 1<<32-1:
problems = append(problems, fmt.Sprintf("%s: container %s adds group %d, which is the kernel's \"no group\" "+
"number or beyond it", where, c.Name, g))
case seen[g]:
problems = append(problems, fmt.Sprintf("%s: container %s adds group %d twice", where, c.Name, g))
}
seen[g] = true
}
return problems
}
func (c *Container) Identity() string { return c.ID }
func (c *Container) Kind() Type { return TypeContainer }
func (c *Container) Target() string { return c.Name }
@@ -1648,7 +1603,6 @@ func (c *Container) validate(where string, _ bool) []string {
"runs and exits, and joins nothing afterwards")
}
problems = append(problems, c.RunAs.problems(where+": container "+c.Name, c.Env)...)
problems = append(problems, c.groupProblems(where)...)
return append(problems, checkImage(where, c.Image)...)
}
@@ -1,125 +0,0 @@
package declaration
import (
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"fmt"
"regexp"
)
// The three names of a file travelling by digest (novox/hq ADR 0486 §1, §2, §5), shared by the node-engine that
// reads them and the controller that writes them.
const (
// FileReferenceThreshold is the size above which a file travels by digest: a file of this many bytes or fewer
// stays in the declaration as it is.
FileReferenceThreshold = 64 * 1024
// FileBucket is the object bucket of the bus that holds the contents, each under the hex of its sha256 digest
// (ObjectName). The controller alone writes it; a node-engine reads an object by name.
FileBucket = "declaration-files"
// FileReferencesCapability is what a node-engine reports when it reads a file given as a digest and size.
FileReferencesCapability = "file-references"
)
var digestForm = regexp.MustCompile(`^sha256:[0-9a-f]{64}$`)
// DigestOf is the digest a reference names for a content: "sha256:<64 lower-case hex>".
func DigestOf(content []byte) string {
sum := sha256.Sum256(content)
return "sha256:" + hex.EncodeToString(sum[:])
}
// ObjectName is the name of the object holding the content a digest names: the hex without its "sha256:" prefix.
func ObjectName(digest string) (string, error) {
if !digestForm.MatchString(digest) {
return "", fmt.Errorf("digest %q is not sha256:<64 lower-case hex characters>", digest)
}
return digest[len("sha256:"):], nil
}
// IsReference reports whether the file gives its content as a digest and size instead of carrying it.
func (f *File) IsReference() bool { return f.Digest != "" }
// checkReference judges the digest and size, and what a reference may not be given with.
func (f *File) checkReference(where string) []string {
if f.Digest == "" && f.Size == 0 {
return nil
}
var problems []string
if !digestForm.MatchString(f.Digest) {
problems = append(problems, fmt.Sprintf("%s: digest %q; a file by reference names its content as "+
"sha256:<64 lower-case hex characters>", where, f.Digest))
}
if f.Size <= 0 {
problems = append(problems, fmt.Sprintf("%s: size %d; a file by reference states its size in bytes, "+
"more than zero", where, f.Size))
}
if f.Content != "" || f.Bytes != "" || f.Sealed != "" || f.VaultSealed != nil || len(f.Secrets) > 0 {
problems = append(problems, where+": a file by reference carries its content in the object its digest "+
"names, so it says no content, bytes, sealed, vault-sealed or secrets")
}
if f.Into != "" {
problems = append(problems, where+": a file by reference is written whole; a file written into says its "+
"part in content")
}
return problems
}
// Body is the bytes the file's content stands for: its content, or its bytes decoded.
func (f *File) Body() ([]byte, error) {
if f.Bytes == "" {
return []byte(f.Content), nil
}
b, err := base64.StdEncoding.DecodeString(f.Bytes)
if err != nil {
return nil, fmt.Errorf("file %s: bytes are not base64: %w", f.ID, err)
}
return b, nil
}
// TravelsByDigest reports whether the controller may put this file's content in the bucket: it is plain content
// or bytes (not sealed, not filled with secrets, not written into another file) of more than FileReferenceThreshold
// bytes.
func (f *File) TravelsByDigest() bool {
if f.IsReference() || f.Sealed != "" || f.VaultSealed != nil || len(f.Secrets) > 0 || f.Into != "" {
return false
}
if f.Bytes != "" {
// DecodedLen counts padding, so the exact length is taken off the decoded bytes.
body, err := f.Body()
return err == nil && len(body) > FileReferenceThreshold
}
return len(f.Content) > FileReferenceThreshold
}
// AsReference is the file as it is sent to an engine that reports FileReferencesCapability: a copy with the digest
// and size in place of the content, and the content the controller puts into the bucket under ObjectName(digest).
// A file that does not travel by digest is refused, not passed through, so a caller cannot send a small file empty.
func (f *File) AsReference() (File, []byte, error) {
if !f.TravelsByDigest() {
return File{}, nil, fmt.Errorf("file %s does not travel by digest", f.ID)
}
body, err := f.Body()
if err != nil {
return File{}, nil, err
}
ref := *f
ref.Content, ref.Bytes = "", ""
ref.Digest, ref.Size = DigestOf(body), int64(len(body))
return ref, body, nil
}
// Verify checks what was fetched against the digest and size the file names; the node-engine writes the file only
// after it returns nil. A mismatch is the apply's failure, never a partial apply (ADR 0486 §1).
func (f *File) Verify(body []byte) error {
if !f.IsReference() {
return fmt.Errorf("file %s is not given by reference", f.ID)
}
if int64(len(body)) != f.Size {
return fmt.Errorf("file %s: the object is %d bytes and the declaration says %d", f.ID, len(body), f.Size)
}
if got := DigestOf(body); got != f.Digest {
return fmt.Errorf("file %s: the object hashes to %s and the declaration names %s", f.ID, got, f.Digest)
}
return nil
}
@@ -25,7 +25,7 @@ type BuildRecord struct {
}
// Artifact is one thing a build made: mesh-controller internal/chain/records.go Artifact, json keys name, kind and
// reference, nothing else (novox/hq ADR 0478 clause 8).
// reference, nothing else (novox/hq ADR 0478 clause 7).
type Artifact struct {
Name string `json:"name"`
Kind string `json:"kind"`
+2 -16
View File
@@ -389,20 +389,11 @@ type Module struct {
Src uint64 `json:"src,omitempty"`
Repository string `json:"repository"`
Commit string `json:"commit"`
// Path is the module's directory in its repository, trimmed of slashes, and RootPath (".") when its manifest is the
// repository's root (novox/hq ADR 0478 clause 9, issue 1615): the build tools need it. Empty means not known: a value
// written before the indexer learned paths has none.
// Path is the module's directory in its repository, trimmed of slashes, empty when its manifest is the repository's
// root (novox/hq ADR 0478 clause 8): the build tools need it. A value written before has none.
Path string `json:"path,omitempty"`
// Toolchains are the toolchains FP2 was computed with, by language (go, typescript): the version installed and the
// build module's recipe version the build nodes agreed on then (novox/hq ADR 0477 clause 2, ADR 0478 clause 3). A builder
// builds the fingerprint only with these. Empty when FP2 is empty or the module has no toolchain; a value written
// before has none (issue 1615).
Toolchains map[string]ToolchainUsed `json:"toolchains,omitempty"`
}
// RootPath is Module.Path of a module whose manifest is its repository's root.
const RootPath = "."
// KeptInputs2 are SourceInputs2 without the module's name (the key has it) and without toolchains (those come from
// the build modules' toolchain keys when the fingerprint is computed): what a Module keeps of a merge.
type KeptInputs2 struct {
@@ -430,11 +421,6 @@ func (m Module) check() error {
case m.FP2 != "" && m.None2 != "":
return fmt.Errorf("delivery: a module has both fp2 and none2")
}
for language, t := range m.Toolchains {
if t.Version == "" || t.Recipe == "" {
return fmt.Errorf("delivery: the module's toolchain of %q needs its version and its recipe version", language)
}
}
if m.Inputs2 != nil && m.Manifest == "" {
return fmt.Errorf("delivery: a module keeps new-form inputs but has no manifest digest")
}
+1 -1
View File
@@ -1,4 +1,4 @@
# git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021130958-85b5a2687e6a
# git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021093645-465beb4ea5e3
## explicit; go 1.26.0
git.novox.be/novox/mesh-sdk/go/asks
git.novox.be/novox/mesh-sdk/go/catalogue