Join 10: the assigner reads its outside-wait settings from the files its manifest declares

The validator refused the manifest: a process's env is not filled from config. As A-112 says, a process reads each
setting from a file with the content ${config:<key>}, read at its start (the builder's form); a missing or empty file is
the default, a content that is no positive whole number stops the start, naming the file.

Builds: design 47 G §633, §706, A-112

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
This commit is contained in:
2026-11-12 02:50:38 +01:00
parent 9b3cc53db9
commit 512bb3c75c
6 changed files with 102 additions and 16 deletions
@@ -250,7 +250,11 @@ func (b *bed) startStoppable(t *testing.T) (<-chan error, func()) {
if err := os.WriteFile(broker, cred, 0o600); err != nil {
t.Fatal(err)
}
env := map[string]string{"MESH_BROKER_FILE": broker, "MESH_ASSIGNER_OUTSIDE_SETTLE_SECONDS": "1"}
settle := filepath.Join(b.dir, "outside-settle-seconds")
if err := os.WriteFile(settle, []byte("1"), 0o600); err != nil {
t.Fatal(err)
}
env := map[string]string{"MESH_BROKER_FILE": broker, "MESH_ASSIGNER_OUTSIDE_SETTLE_FILE": settle}
ctx, stop := context.WithCancel(b.ctx)
done := make(chan error, 1)
go func() {
@@ -526,7 +526,10 @@ func follow(ctx context.Context, js jetstream.JetStream, nc process.Subscriber,
// The wait on an outside cause (outside.go, design 47 G §706): the keeper's three events say when to judge again, and the
// seat mesh-wants' wait-held and wait-ended are said; registered before the hub starts.
if bus != nil && kvs[buckets.Waits] != nil {
bound, settle := outsideSettings(getenv)
bound, settle, err := outsideSettings(getenv)
if err != nil {
return err
}
p.outside = newOutsideActs(bus.CallKept, wake, logger.Printf, bound, settle)
for _, subject := range []string{mcseat.SubjectConditionRaised, mcseat.SubjectConditionChanged, mcseat.SubjectConditionCleared} {
subject := subject
@@ -3,7 +3,10 @@ package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"io/fs"
"os"
"strconv"
"strings"
"sync"
@@ -15,9 +18,9 @@ import (
"git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient"
"git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
mcseat "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions"
"git.novox.be/novox/mesh-delivery/sdk/catalogkey"
meshwants "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshwants"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-assigner/asks"
"git.novox.be/novox/mesh-delivery/sdk/catalogkey"
"git.novox.be/novox/mesh-sdk/go/seatdef"
"git.novox.be/novox/mesh-sdk/go/views"
@@ -94,15 +97,42 @@ func newOutsideActs(call func(string, any) (json.RawMessage, error), wake func()
}
// outsideSettings are the bounds of the wait, the settings outside-wait-minutes and outside-settle-seconds (design 47 G §633, §706),
// from the environment the manifest's config fills; zero for one not set, which is its default.
func outsideSettings(getenv func(string) string) (bound, settle time.Duration) {
if n, err := strconv.Atoi(strings.TrimSpace(getenv("MESH_ASSIGNER_OUTSIDE_WAIT_MINUTES"))); err == nil && n > 0 {
bound = time.Duration(n) * time.Minute
// each read at the start from the file the manifest declares with the content ${config:<key>}, which the environment names
// (A-112, as the builder reads its own); zero for one whose file is missing or empty, which is its default, and an error
// naming the file for a content that is no positive whole number.
func outsideSettings(getenv func(string) string) (bound, settle time.Duration, err error) {
minutes, err := settingFile(getenv("MESH_ASSIGNER_OUTSIDE_WAIT_FILE"))
if err != nil {
return 0, 0, err
}
if n, err := strconv.Atoi(strings.TrimSpace(getenv("MESH_ASSIGNER_OUTSIDE_SETTLE_SECONDS"))); err == nil && n > 0 {
settle = time.Duration(n) * time.Second
seconds, err := settingFile(getenv("MESH_ASSIGNER_OUTSIDE_SETTLE_FILE"))
if err != nil {
return 0, 0, err
}
return bound, settle
return time.Duration(minutes) * time.Minute, time.Duration(seconds) * time.Second, nil
}
// settingFile reads one setting's file: 0 for no path, no file or an empty one.
func settingFile(path string) (int, error) {
if path == "" {
return 0, nil
}
raw, err := os.ReadFile(path)
if errors.Is(err, fs.ErrNotExist) {
return 0, nil
}
if err != nil {
return 0, err
}
v := strings.TrimSpace(string(raw))
if v == "" {
return 0, nil
}
n, err := strconv.Atoi(v)
if err != nil || n < 1 {
return 0, fmt.Errorf("%s holds %q, which is not a positive whole number", path, v)
}
return n, nil
}
// readStart reads the open conditions of every machine once at the start, for the machines whose own network is held.
@@ -3,6 +3,8 @@ package main
import (
"context"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"time"
@@ -209,6 +211,28 @@ func TestTheWaitOnAnOutsideCause(t *testing.T) {
t.Errorf("events %v", r.events)
}
})
t.Run("its bounds are read at the start from the files the manifest declares (A-112)", func(t *testing.T) {
dir := t.TempDir()
write := func(name, content string) string {
p := filepath.Join(dir, name)
if err := os.WriteFile(p, []byte(content), 0o600); err != nil {
t.Fatal(err)
}
return p
}
env := map[string]string{"MESH_ASSIGNER_OUTSIDE_WAIT_FILE": write("wait", "45\n"), "MESH_ASSIGNER_OUTSIDE_SETTLE_FILE": write("settle", "7")}
if bound, settle, err := outsideSettings(func(k string) string { return env[k] }); err != nil || bound != 45*time.Minute || settle != 7*time.Second {
t.Errorf("bound %v settle %v err %v, want 45m and 7s", bound, settle, err)
}
env["MESH_ASSIGNER_OUTSIDE_WAIT_FILE"], env["MESH_ASSIGNER_OUTSIDE_SETTLE_FILE"] = filepath.Join(dir, "missing"), write("empty", "")
if bound, settle, err := outsideSettings(func(k string) string { return env[k] }); err != nil || bound != 0 || settle != 0 {
t.Errorf("bound %v settle %v err %v, want both defaults", bound, settle, err)
}
env["MESH_ASSIGNER_OUTSIDE_SETTLE_FILE"] = write("bad", "soon")
if _, _, err := outsideSettings(func(k string) string { return env[k] }); err == nil || !strings.Contains(err.Error(), "bad") {
t.Errorf("err %v, want one naming the file", err)
}
})
}
func TestAMachineWhoseOwnNetworkIsOpenGetsNoWant(t *testing.T) {
@@ -142,7 +142,8 @@ func TestManifest(t *testing.T) {
"seat:operator-channel.asks"}; !slices.Equal(slices.Sorted(slices.Values(m.Invokes)), want) { // ADR 0668 §4: the ended asks at bind
t.Errorf("invokes = %v, want %v", m.Invokes, want)
}
// design 47 G §633, §706: the bounds of the wait on an outside cause are `config` keys filling the process's environment.
// design 47 G §633, §706, A-112: the bounds of the wait on an outside cause are `config` keys, each filling a file the
// manifest declares, which the process reads at its start; the environment names only the files.
var raw struct {
Config map[string]struct {
Default any `json:"default"`
@@ -152,9 +153,17 @@ func TestManifest(t *testing.T) {
if err := json.Unmarshal(file, &raw); err != nil || raw.Config["outside-wait-minutes"].Default != float64(30) || raw.Config["outside-settle-seconds"].Default != float64(90) || len(raw.Config) != 2 {
t.Errorf("config = %+v (%v), want outside-wait-minutes of 30 and outside-settle-seconds of 90", raw.Config, err)
}
if !strings.Contains(string(file), `"MESH_ASSIGNER_OUTSIDE_WAIT_MINUTES": "${config:outside-wait-minutes}"`) ||
!strings.Contains(string(file), `"MESH_ASSIGNER_OUTSIDE_SETTLE_SECONDS": "${config:outside-settle-seconds}"`) {
t.Errorf("the process's env does not carry the two settings from config")
for _, f := range []struct{ env, path, key string }{
{"MESH_ASSIGNER_OUTSIDE_WAIT_FILE", "${dir:state}/outside-wait-minutes", "outside-wait-minutes"},
{"MESH_ASSIGNER_OUTSIDE_SETTLE_FILE", "${dir:state}/outside-settle-seconds", "outside-settle-seconds"},
} {
if !strings.Contains(string(file), `"`+f.env+`": "`+f.path+`"`) || !strings.Contains(string(file), `"path": "`+f.path+`"`) ||
!strings.Contains(string(file), `"content": "${config:`+f.key+`}"`) {
t.Errorf("the setting %s is not a file %s the env names as %s", f.key, f.path, f.env)
}
}
if strings.Contains(string(file), `": "${config:outside-`) && strings.Contains(string(file), `"MESH_ASSIGNER_OUTSIDE_WAIT_MINUTES"`) {
t.Errorf("the process's env still carries a setting")
}
if want := []string{"assignments-copied", "put", "views.asks", "views.copies", "views.differences", "views.findings", "views.assignments", "views.seat-holders", "views.wants"}; !slices.Equal(m.Emits, want) {
t.Errorf("emits = %v, want the copy, put and its six views", m.Emits)
+18 -2
View File
@@ -180,6 +180,22 @@
"place": ".",
"unknown": "program"
},
{
"id": "outside-wait",
"type": "file",
"path": "${dir:state}/outside-wait-minutes",
"owner": "mesh-delivery-assigner",
"mode": "0600",
"content": "${config:outside-wait-minutes}"
},
{
"id": "outside-settle",
"type": "file",
"path": "${dir:state}/outside-settle-seconds",
"owner": "mesh-delivery-assigner",
"mode": "0600",
"content": "${config:outside-settle-seconds}"
},
{
"id": "assigner",
"type": "process",
@@ -190,8 +206,8 @@
],
"env": {
"MESH_BROKER_FILE": "${dir:state}/broker",
"MESH_ASSIGNER_OUTSIDE_WAIT_MINUTES": "${config:outside-wait-minutes}",
"MESH_ASSIGNER_OUTSIDE_SETTLE_SECONDS": "${config:outside-settle-seconds}"
"MESH_ASSIGNER_OUTSIDE_WAIT_FILE": "${dir:state}/outside-wait-minutes",
"MESH_ASSIGNER_OUTSIDE_SETTLE_FILE": "${dir:state}/outside-settle-seconds"
},
"user": "mesh-delivery-assigner",
"health": {