Feature 57 (config and provisioning): modules declare config and provisions, their files rendered from templates; synced with main (issues of feature 57, 874)

This commit is contained in:
2026-10-16 07:37:43 +00:00
parent 1a2c917abf
commit 9652b2d5ce
771 changed files with 52063 additions and 1774 deletions
@@ -0,0 +1,119 @@
package main
import (
"bytes"
"encoding/json"
"fmt"
"strings"
"github.com/nats-io/nats.go"
)
// A module's code asks at runtime (novox/hq ADR 0349 clause 14 as ADR 0370 changes it, design 57 S1, issue 685).
//
// While a module's code runs in its machine's tool runner, its asks reach the bus under the tool runner's account,
// and the bus proves only that account. So the client is asked on AskSubject, which the controller grants its own
// machine's tool runner alone to publish (ADR 0370 §3), and the module is the one the tool runner names on the ask,
// taken from the bundle that asks (§4). The client answers that module's config values, for what its manifest
// declares — mesh-config resolves a module's declared keys and no other — and only when the composition places the
// module on this machine, where the tool runner carries every module assigned to it. It never answers a secret on
// the tool runner's word (§2): the vault hands a secret only to a caller the bus proves (ADR 0349 clause 11), and a
// module in the tool runner reads its secrets from its files. A binding's non-secret values are its provider's
// (ADR 0349 clause 12); this client fills none yet, so it answers none yet either, and says so.
//
// A watch is the tool runner's: the client says on ChangedSubject which value of a module moved, never the value,
// and the tool runner asks again for the watches it holds.
// AskSubject is where this machine's tool runner asks (mesh-controller internal/broker ProvisionerAskSubject).
func AskSubject(node string) string { return "prov.ask." + node }
// ChangedSubject is where the client says a value of one module moved (mesh-controller internal/broker
// ProvisionerChangedSubject).
func ChangedSubject(node, module string) string { return "prov.changed." + node + "." + module }
// The kinds of value an ask names.
const (
AskConfig = "config"
AskBound = "bound"
AskSecret = "secret"
)
// Ask is what the tool runner asks: the module it names, from the bundle that asks; the kind; and the key, a
// config key, or `<provision>:<key>` of a binding.
type Ask struct {
Module string `json:"module"`
Kind string `json:"kind"`
Key string `json:"key"`
}
// Answered is the client's answer: the value as mesh-config resolved it (JSON), or why it is refused.
type Answered struct {
Value json.RawMessage `json:"value,omitempty"`
Error string `json:"error,omitempty"`
}
// Changed is what the client says moved of one module: a kind, and the key, or every key of the kind when empty.
type Changed struct {
Kind string `json:"kind"`
Key string `json:"key,omitempty"`
}
// answer is the answer to one ask from the state the client holds.
func (c *Client) answer(s *state, body []byte) Answered {
var a Ask
dec := json.NewDecoder(bytes.NewReader(body))
dec.DisallowUnknownFields()
if err := dec.Decode(&a); err != nil || a.Module == "" || a.Kind == "" || a.Key == "" {
return Answered{Error: "an ask names its module, its kind and its key: {module, kind, key}"}
}
switch a.Kind {
case AskSecret:
return Answered{Error: fmt.Sprintf("%s's secret %s is not answered on the tool runner's word: the vault hands "+
"a secret only to a caller the bus proves (novox/hq ADR 0370 §2, ADR 0349 §11); while the module's code "+
"runs in the tool runner, its secrets reach it through its files", a.Module, a.Key)}
case AskConfig, AskBound:
default:
return Answered{Error: fmt.Sprintf("%q is not a kind the client answers: config or bound", a.Kind)}
}
if !s.assigned[a.Module] {
return Answered{Error: fmt.Sprintf("%s is not placed on %s, so this machine's tool runner carries no code of "+
"it to answer (novox/hq ADR 0370 §3)", a.Module, c.Node)}
}
if a.Kind == AskBound {
return Answered{Error: fmt.Sprintf("%s's bound value %s is not answered yet: a binding's values come from its "+
"provider (novox/hq ADR 0349 §12), which this client fills into the module's files (issue 679) and does not "+
"answer on an ask yet", a.Module, a.Key)}
}
v, ok := s.config[a.Module][a.Key]
if !ok {
return Answered{Error: fmt.Sprintf("%s declares no config value %s, or mesh-config holds none for it on %s",
a.Module, a.Key, c.Node)}
}
return Answered{Value: v}
}
// respond answers one ask on its reply.
func (c *Client) respond(s *state, m *nats.Msg) {
if m.Reply == "" {
return
}
a := c.answer(s, m.Data)
body, err := json.Marshal(a)
if err != nil {
return
}
if err := m.Respond(body); err != nil {
logf("[provisioner-client] an ask could not be answered: %v", err)
}
}
// tell says a value of a module moved, so its code's watches are asked again.
func (c *Client) tell(module string, ch Changed) {
if module == "" || strings.ContainsAny(module, "*> ") {
return
}
body, _ := json.Marshal(ch)
if err := c.Conn.Publish(ChangedSubject(c.Node, module), body); err != nil {
logf("[provisioner-client] what moved of %s could not be said: %v", module, err)
}
}
@@ -0,0 +1,154 @@
package main
import (
"encoding/json"
"fmt"
"strings"
"testing"
"time"
"github.com/nats-io/nats.go"
)
// A module's code asks at runtime (novox/hq ADR 0349 clause 14, ADR 0370, design 57 S1, issue 685), against a real
// bus whose accounts are the client's and its machine's tool runner's as mesh-controller composes them: the tool
// runner asks for a carried module's config value and is answered; a change is said, so a watch asks again; a key
// the module does not declare, a module not placed on the machine, a secret and a malformed ask are refused, each
// with its reason.
// toolRunner is g14's tool runner on the bus, asking as its account may.
type toolRunner struct {
t *testing.T
nc *nats.Conn
}
func aToolRunner(t *testing.T, b *aBus) *toolRunner {
t.Helper()
nc, err := nats.Connect(b.url, nats.UserInfo(node+".node-tools", "x"),
nats.CustomInboxPrefix("_INBOX."+node+".node-tools"),
nats.ErrorHandler(func(_ *nats.Conn, _ *nats.Subscription, err error) {
t.Errorf("the bus refused the tool runner: %v", err)
}))
if err != nil {
t.Fatal(err)
}
t.Cleanup(nc.Close)
return &toolRunner{t: t, nc: nc}
}
func (r *toolRunner) ask(body string) Answered {
r.t.Helper()
var a Answered
deadline := time.Now().Add(10 * time.Second)
for {
msg, err := r.nc.Request(AskSubject(node), []byte(body), 2*time.Second)
if err == nil {
if err := json.Unmarshal(msg.Data, &a); err != nil {
r.t.Fatalf("the client answered %s", msg.Data)
}
return a
}
if time.Now().After(deadline) {
r.t.Fatalf("the client did not answer %s: %v", body, err)
}
time.Sleep(50 * time.Millisecond) // not listening yet
}
}
func (r *toolRunner) askConfig(module, key string) Answered {
r.t.Helper()
return r.ask(fmt.Sprintf(`{"module":%q,"kind":"config","key":%q}`, module, key))
}
// hear collects what the client says moved on this machine.
func (r *toolRunner) hear() chan string {
r.t.Helper()
heard := make(chan string, 64)
if _, err := r.nc.Subscribe("prov.changed."+node+".>", func(m *nats.Msg) {
heard <- strings.TrimPrefix(m.Subject, "prov.changed."+node+".") + " " + string(m.Data)
}); err != nil {
r.t.Fatal(err)
}
_ = r.nc.Flush()
return heard
}
// placed writes an assignment of a module the controller has not handed to the provisioner: placed, no plan.
func (b *aBus) placed(t *testing.T, module string) {
t.Helper()
b.put(t, AssignmentsBucket, node+"."+module, fmt.Sprintf(`{"node":%q,"module":%q,"assigned":"2026-10-14T20:00:00Z"}`,
node, module))
}
func until(t *testing.T, heard chan string, want string) {
t.Helper()
deadline := time.After(10 * time.Second)
for {
select {
case got := <-heard:
if got == want {
return
}
case <-deadline:
t.Fatalf("the client did not say %s", want)
}
}
}
func TestNatsAModulesCodeReadsItsConfigAndIsToldOfAChange(t *testing.T) {
b := startBus(t)
e := startEngine(t, nil)
b.placed(t, "claude-code")
b.config(t, "claude-code", "managed_settings", `{"spinnerTipsEnabled":false}`)
runner := aToolRunner(t, b)
heard := runner.hear()
aClient(t, b, e, t.TempDir(), time.Hour)
// The value as mesh-config resolved it.
if a := runner.askConfig("claude-code", "managed_settings"); a.Error != "" || string(a.Value) != `{"spinnerTipsEnabled":false}` {
t.Fatalf("answered %+v", a)
}
// Coming up, it says every module's config moved, so a watch held across its absence asks again.
until(t, heard, `claude-code {"kind":"config"}`)
// A change is said, which value and never the value; asked again, the new value.
b.config(t, "claude-code", "managed_settings", `{"spinnerTipsEnabled":true}`)
until(t, heard, `claude-code {"kind":"config","key":"managed_settings"}`)
if a := runner.askConfig("claude-code", "managed_settings"); string(a.Value) != `{"spinnerTipsEnabled":true}` {
t.Fatalf("after the change, answered %+v", a)
}
// Taken off the machine: said, and no longer answered.
b.del(t, AssignmentsBucket, node+".claude-code")
until(t, heard, `claude-code {"kind":"config"}`)
if a := runner.askConfig("claude-code", "managed_settings"); !strings.Contains(a.Error, "not placed on g14") {
t.Fatalf("a module taken off the machine was answered %+v", a)
}
}
func TestNatsWhatAModulesCodeMayNotReadIsRefusedWithTheReason(t *testing.T) {
b := startBus(t)
e := startEngine(t, nil)
b.placed(t, "claude-code")
b.config(t, "claude-code", "managed_settings", `{}`)
// Resolved for a module the composition does not place on this machine.
b.config(t, "dunst", "font-size", "13")
runner := aToolRunner(t, b)
aClient(t, b, e, t.TempDir(), time.Hour)
for _, c := range []struct{ ask, refused string }{
{`{"module":"claude-code","kind":"config","key":"no_such_key"}`, "declares no config value no_such_key"},
{`{"module":"dunst","kind":"config","key":"font-size"}`, "dunst is not placed on g14"},
{`{"module":"claude-code","kind":"secret","key":"gitea-read"}`, "not answered on the tool runner's word"},
{`{"module":"claude-code","kind":"bound","key":"mcp-endpoint:url"}`, "not answered yet"},
{`{"module":"claude-code","kind":"setting","key":"role"}`, "not a kind the client answers"},
{`{"module":"claude-code","kind":"config"}`, "names its module, its kind and its key"},
{`{"module":"claude-code","kind":"config","key":"managed_settings","as":"dunst"}`, "names its module, its kind and its key"},
{`not json`, "names its module, its kind and its key"},
} {
a := runner.ask(c.ask)
if len(a.Value) != 0 || !strings.Contains(a.Error, c.refused) {
t.Errorf("%s: answered %+v, want refused with %q", c.ask, a, c.refused)
}
}
}
@@ -0,0 +1,89 @@
package main
import (
"encoding/json"
"os"
"path/filepath"
"testing"
"time"
)
// The bar files i3status-rust's bars come to, filled by this client from power's items of the collection
// contribution:node-bar:block, are byte for byte what the old shaped contribution made (design 57 L3, issue 781).
// The module files are the catalogue's own; testdata/bars holds the bars as composed before the move, for a machine
// with neither capability, the battery, the power meter and both. The controller's side of the same bars, not handed,
// is pinned by mesh-controller internal/catalogue TestTheBarsOfTheCatalogueAreTheSameHandedOrNot.
func TestNatsTheBarsFilledFromPowersItemsAreWhatTheOldContributionMade(t *testing.T) {
modules := filepath.Join("..", "..", "..")
raw, err := os.ReadFile(filepath.Join(modules, "power", "module.json"))
if err != nil {
t.Fatal(err)
}
var power struct {
Items []struct {
Collection string `json:"collection"`
Name string `json:"name"`
IfCapability string `json:"if-capability"`
Attributes map[string]string `json:"attributes"`
Template string `json:"template"`
} `json:"items"`
}
if err := json.Unmarshal(raw, &power); err != nil {
t.Fatal(err)
}
for name, caps := range map[string][]string{"none": nil, "battery": {"battery"}, "power-meter": {"power-meter"},
"both": {"battery", "power-meter"}} {
t.Run(name, func(t *testing.T) {
has := map[string]bool{}
for _, c := range caps {
has[c] = true
}
bar := func(which string) string {
b, err := os.ReadFile(filepath.Join(modules, "i3status-rust", "files", which+"-bar.toml"))
if err != nil {
t.Fatal(err)
}
return string(b)
}
barTpl, _ := templates(t, map[string]string{"bottom": bar("bottom"), "top": bar("top")})
bars := Plan{Templates: barTpl, Files: []PlannedFile{
{ID: "bottom", Path: "/bars/bottom.toml", Template: "bottom", OwnerAtRuntime: "owned"},
{ID: "top", Path: "/bars/top.toml", Template: "top", OwnerAtRuntime: "owned"}},
Collections: []PlannedCollection{{Name: "contribution:node-bar:block", Comment: "#"}},
Placement: map[string]string{"machine:name": "workstation"}}
texts := map[string]string{}
var items []PlannedItem
for _, it := range power.Items {
if it.IfCapability != "" && !has[it.IfCapability] {
continue // the controller leaves it out of the plan
}
text, err := os.ReadFile(filepath.Join(modules, "power", it.Template))
if err != nil {
t.Fatal(err)
}
id := filepath.Base(it.Template)
texts[id] = string(text)
items = append(items, PlannedItem{Collection: it.Collection, Name: it.Name, Template: id,
Attributes: it.Attributes})
}
itemTpl, _ := templates(t, texts)
contrib := Plan{Templates: itemTpl, Items: items, Placement: map[string]string{"machine:name": "workstation"}}
b := startBus(t)
e := startEngine(t, map[string]string{"/bars/bottom.toml": "owned", "/bars/top.toml": "owned"})
b.assign(t, "i3status-rust", bars)
b.assign(t, "power", contrib)
aClient(t, b, e, t.TempDir(), time.Hour)
for _, which := range []string{"bottom", "top"} {
golden, err := os.ReadFile(filepath.Join("testdata", "bars", name+"."+which+"-bar.toml"))
if err != nil {
t.Fatal(err)
}
eventually(t, "the "+which+" bar is what it was", func() bool {
return e.read(t, "/bars/"+which+".toml") == string(golden)
})
}
})
}
}
@@ -0,0 +1,146 @@
package main
import (
"context"
"os"
"strings"
"testing"
"time"
"github.com/nats-io/nats.go/jetstream"
)
// A handed gitea's server.env (issue 788): its template is the catalogue's own, and its machine's client fills it from
// its provider's entry as the provider's helper now writes it (`as`, and `at` and `from` among the values) and from
// the vault's rows, to the text the controller composed for the same values before it was handed.
const giteaEnvPath = "/etc/gitea/server.env"
func giteaTemplate(t *testing.T) string {
t.Helper()
raw, err := os.ReadFile("../../../gitea/files/server.env")
if err != nil {
t.Fatal(err)
}
return string(raw)
}
// giteaBoundExpected is that file as the controller rendered it: the database as the binding file told gitea.
func giteaBoundExpected(internalToken, password string) string {
return "GITEA__security__INTERNAL_TOKEN=" + internalToken + "\n" +
"GITEA__database__DB_TYPE=postgres\n" +
"GITEA__database__HOST=10.7.0.1:5432\n" +
"GITEA__database__NAME=mesh_g14_gitea\n" +
"GITEA__database__USER=mesh_g14_gitea\n" +
"GITEA__database__PASSWD=" + password + "\n" +
"GITEA__webhook__ALLOWED_HOST_LIST=172.17.0.1\n"
}
func giteaRun(t *testing.T, entry string, extra string) (*engine, *aBus, *reports) {
t.Helper()
b := startBus(t)
registryOf679(t, b)
vault := startVault(t, b)
vault.keep("own/g14/gitea/internal-token", "tok-never-on-the-bus", false)
vault.keep("pair/novox/postgres-database/g14/gitea", "pw-never-on-the-bus", false)
heard := b.listen(t)
e := startEngine(t, map[string]string{giteaEnvPath: "owned"})
tpl, _ := templates(t, map[string]string{"server.env": giteaTemplate(t) + extra})
b.assign(t, "gitea", Plan{Templates: tpl, Files: []PlannedFile{{ID: "env", Path: giteaEnvPath,
Template: "server.env", OwnerAtRuntime: "owned"}},
Secrets: []PlannedSecret{{Name: "internal-token"}},
Provisions: []PlannedProvision{{Name: "postgres-database", Provision: "postgres-database", Provider: "novox",
ProviderModule: "postgres", CredentialForm: "pair"}}})
kv, err := b.domain.CreateKeyValue(context.Background(), jetstream.KeyValueConfig{Bucket: "postgres_bound"})
if err != nil {
t.Fatal(err)
}
b.kv["postgres_bound"] = kv
b.put(t, "postgres_bound", node+".gitea.postgres-database", entry)
aClient(t, b, e, t.TempDir(), time.Hour)
return e, b, heard
}
func TestNatsAHandedGiteasServerEnvIsFilledAsTheControllerComposedIt(t *testing.T) {
e, _, _ := giteaRun(t, `{"provider":"novox","provider-module":"postgres","provision":"postgres-database",`+
`"consumer":"g14","module":"gitea","as":"mesh_g14_gitea","user":"mesh_g14_gitea",`+
`"values":{"at":"10.7.0.1","from":"novox","port":5432},"credential":"pair/novox/postgres-database/g14/gitea"}`,
"FROM=${bound:postgres-database:from}\n")
eventually(t, "server.env is filled to the controller's text", func() bool {
return e.read(t, giteaEnvPath) == giteaBoundExpected("tok-never-on-the-bus", "pw-never-on-the-bus")+"FROM=novox\n"
})
}
// An entry without `at`, or without `from`, fills nothing: the module fails naming the key, never a file with a hole.
func TestNatsAnEntryWithoutAtOrFromLeavesGiteasServerEnvUnfilled(t *testing.T) {
for key, values := range map[string]string{"at": `{"from":"novox","port":5432}`, "from": `{"at":"10.7.0.1","port":5432}`} {
e, _, heard := giteaRun(t, `{"as":"mesh_g14_gitea","user":"mesh_g14_gitea","values":`+values+`}`,
"FROM=${bound:postgres-database:from}\n")
eventually(t, "the module fails for the missing "+key, func() bool {
r, _ := heard.last("gitea")
return r.Status == StatusFailed && strings.Contains(r.Reason, `no "`+key+`"`)
})
if got := e.read(t, giteaEnvPath); strings.Contains(got, "GITEA__database__HOST") {
t.Fatalf("without %s the file was filled: %q", key, got)
}
}
}
// A handed gitea's origins file (issue 792): filled by its machine's client from the route provider's entry, which
// carries the public name the controller composed for gitea, to the text the controller wrote for the binding file.
func TestNatsAHandedGiteasOriginsAreFilledFromTheRouteProvidersEntry(t *testing.T) {
const originsPath = "/etc/gitea/origins"
raw, err := os.ReadFile("../../../gitea/files/origins")
if err != nil {
t.Fatal(err)
}
b := startBus(t)
registryOf679(t, b)
startVault(t, b)
e := startEngine(t, map[string]string{originsPath: "owned"})
tpl, _ := templates(t, map[string]string{"origins": string(raw)})
b.assign(t, "gitea", Plan{Templates: tpl, Files: []PlannedFile{{ID: "origins", Path: originsPath,
Template: "origins", OwnerAtRuntime: "owned"}},
Provisions: []PlannedProvision{{Provision: "route", Provider: "novox", ProviderModule: "route-adapter"}}})
kv, err := b.domain.CreateKeyValue(context.Background(), jetstream.KeyValueConfig{Bucket: "route-adapter_bound"})
if err != nil {
t.Fatal(err)
}
b.kv["route-adapter_bound"] = kv
b.put(t, "route-adapter_bound", node+".gitea.route", `{"provider":"novox","provider-module":"route-adapter",`+
`"provision":"route","consumer":"g14","module":"gitea","as":"mesh_g14_gitea",`+
`"values":{"name":"git.example.org","internal-name":"git.novox.internal","name-web":"git.example.org"}}`)
aClient(t, b, e, t.TempDir(), time.Hour)
eventually(t, "origins is filled to the controller's text", func() bool {
return e.read(t, originsPath) == "https://git.example.org\n"
})
}
// The same where route-proxy, not route-adapter, answers the route, as on novox (issue 792): filled by its machine's client from the route provider's entry, which
// carries the public name the controller composed for gitea, to the text the controller wrote for the binding file.
func TestNatsAHandedGiteasOriginsAreFilledFromRouteProxysEntryOnNovox(t *testing.T) {
const originsPath = "/etc/gitea/origins"
raw, err := os.ReadFile("../../../gitea/files/origins")
if err != nil {
t.Fatal(err)
}
b := startBus(t)
registryOf679(t, b)
startVault(t, b)
e := startEngine(t, map[string]string{originsPath: "owned"})
tpl, _ := templates(t, map[string]string{"origins": string(raw)})
b.assign(t, "gitea", Plan{Templates: tpl, Files: []PlannedFile{{ID: "origins", Path: originsPath,
Template: "origins", OwnerAtRuntime: "owned"}},
Provisions: []PlannedProvision{{Provision: "route", Provider: "novox", ProviderModule: "route-proxy"}}})
kv, err := b.domain.CreateKeyValue(context.Background(), jetstream.KeyValueConfig{Bucket: "route-proxy_bound"})
if err != nil {
t.Fatal(err)
}
b.kv["route-proxy_bound"] = kv
b.put(t, "route-proxy_bound", node+".gitea.route", `{"provider":"novox","provider-module":"route-proxy",`+
`"provision":"route","consumer":"g14","module":"gitea","as":"mesh_g14_gitea",`+
`"values":{"name":"git.example.org","internal-name":"git.novox.internal","name-web":"git.example.org"}}`)
aClient(t, b, e, t.TempDir(), time.Hour)
eventually(t, "origins is filled to the controller's text", func() bool {
return e.read(t, originsPath) == "https://git.example.org\n"
})
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,649 @@
package main
import (
"archive/tar"
"bytes"
"compress/gzip"
"context"
"encoding/json"
"errors"
"fmt"
"net"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"github.com/nats-io/nats-server/v2/server"
"github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream"
)
// The client's test list of novox/hq ADR 0349 ("How it is checked"), against a real bus (design 57 P5, issue 671):
// a template fills from each owner's keys; a missing value keeps the old file and reports what it waits for; a change
// refills, writes once and triggers the declared reaction; an owned file changed on disk is written back, a seed
// file is left, a managed file keeps the program's other keys; a module taken off the node loses its files and
// needs; a node coming back fills from current values.
//
// The bus is a real server, its accounts the client's as mesh-controller composes them (testdata/accounts.conf), so
// every read and write the client makes is one its grants allow. The node-engine is a stand-in on a real unix
// socket speaking its format (mesh-host internal/filling), writing each file as its ownership says — the
// node-engine's own tests prove its writes; these prove the client asks for the right ones — and counting each
// write that moved a file, which is what the node-engine reloads or restarts the program for.
const node = "g14"
// aBus is a server of this test alone with the client's buckets, the registry written as mesh-provisioner writes it.
type aBus struct {
url string
domain jetstream.JetStream
conn *nats.Conn
kv map[string]jetstream.KeyValue
}
func startBus(t *testing.T) *aBus {
t.Helper()
accounts, err := os.ReadFile("testdata/accounts.conf")
if err != nil {
t.Fatal(err)
}
conf := filepath.Join(t.TempDir(), "bus.conf")
if err := os.WriteFile(conf, []byte("listen: 127.0.0.1:-1\njetstream { store_dir: \""+t.TempDir()+"\" }\n"+
string(accounts)), 0o600); err != nil {
t.Fatal(err)
}
opts, err := server.ProcessConfigFile(conf)
if err != nil {
t.Fatal(err)
}
opts.NoLog, opts.NoSigs = true, true
s, err := server.NewServer(opts)
if err != nil {
t.Fatal(err)
}
go s.Start()
if !s.ReadyForConnections(10 * time.Second) {
t.Fatal("the bus did not come up")
}
t.Cleanup(s.Shutdown)
nc, err := nats.Connect(s.ClientURL(), nats.UserInfo("domain", "x"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(nc.Close)
js, err := jetstream.New(nc)
if err != nil {
t.Fatal(err)
}
b := &aBus{url: s.ClientURL(), domain: js, conn: nc, kv: map[string]jetstream.KeyValue{}}
for _, name := range []string{AssignmentsBucket, ConfigBucket, RegistryBucket, NeedsBucket, AppliedBucket,
CollectBucket} {
kv, err := js.CreateKeyValue(context.Background(), jetstream.KeyValueConfig{Bucket: name})
if err != nil {
t.Fatal(err)
}
b.kv[name] = kv
}
b.put(t, RegistryBucket, "config", `{"kind":"config","domain":"mesh-config","from":"bucket",`+
`"bucket":"mesh-config_resolved","key":"<node>.<module>.<key>"}`)
b.put(t, RegistryBucket, "secret", `{"kind":"secret","domain":"mesh-vault","from":"ask","ask":"mesh-vault.get"}`)
for _, kind := range append([]string{CollectedKind}, oldCollectionKinds...) {
b.put(t, RegistryBucket, kind, `{"kind":"`+kind+`","domain":"mesh-provisioner","from":"bucket",`+
`"bucket":"mesh-provisioner_collect","key":"<node>.<collection>.<contributor>"}`)
}
for _, kind := range placementKinds {
b.put(t, RegistryBucket, kind, `{"kind":"`+kind+`","domain":"mesh-controller","from":"bucket",`+
`"bucket":"mesh-controller_assignments","key":"<node>.<module>"}`)
}
return b
}
func (b *aBus) put(t *testing.T, bucket, key, value string) {
t.Helper()
if _, err := b.kv[bucket].PutString(context.Background(), key, value); err != nil {
t.Fatalf("writing %s in %s: %v", key, bucket, err)
}
}
func (b *aBus) del(t *testing.T, bucket, key string) {
t.Helper()
if err := b.kv[bucket].Delete(context.Background(), key); err != nil {
t.Fatal(err)
}
}
// config writes a resolved value as mesh-config does.
func (b *aBus) config(t *testing.T, module, key, value string) {
t.Helper()
b.put(t, ConfigBucket, node+"."+module+"."+key, fmt.Sprintf(`{"node":%q,"module":%q,"key":%q,"value":%s,`+
`"source":"node"}`, node, module, key, value))
}
// assign writes a module's assignment with its plan, as the controller does.
func (b *aBus) assign(t *testing.T, module string, plan Plan) {
t.Helper()
p, err := json.Marshal(plan)
if err != nil {
t.Fatal(err)
}
b.put(t, AssignmentsBucket, node+"."+module, fmt.Sprintf(`{"node":%q,"module":%q,"assigned":"2026-10-14T20:00:00Z",`+
`"provisioning":%s}`, node, module, p))
}
// keys is the bucket's keys under prefix.
func (b *aBus) keys(t *testing.T, bucket, prefix string) []string {
t.Helper()
all, err := b.kv[bucket].Keys(context.Background())
if err != nil && !errors.Is(err, jetstream.ErrNoKeysFound) {
t.Fatal(err)
}
var out []string
for _, k := range all {
if strings.HasPrefix(k, prefix) {
out = append(out, k)
}
}
return out
}
// reports collects what is said on prov.applied.
type reports struct {
mu sync.Mutex
all []Report
}
func (b *aBus) listen(t *testing.T) *reports {
t.Helper()
r := &reports{}
sub, err := b.conn.Subscribe("prov.applied."+node+".>", func(m *nats.Msg) {
var rep Report
if err := json.Unmarshal(m.Data, &rep); err == nil {
r.mu.Lock()
r.all = append(r.all, rep)
r.mu.Unlock()
}
})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = sub.Unsubscribe() })
_ = b.conn.Flush()
return r
}
func (r *reports) last(module string) (Report, int) {
r.mu.Lock()
defer r.mu.Unlock()
n := 0
var out Report
for _, rep := range r.all {
if rep.Module == module {
out, n = rep, n+1
}
}
return out, n
}
// engine stands in for the node-engine's write socket: each path written under root, as its ownership says.
type engine struct {
root string
socket string
mu sync.Mutex
owner map[string]string // path → owned, seed or managed
moved map[string]int // writes that changed the file: what the program is reloaded for
asked map[string]int
wrote map[string]string // what it last wrote
}
func startEngine(t *testing.T, owners map[string]string) *engine {
t.Helper()
// A unix socket's path is short: under /tmp, not the test's own directory.
dir, err := os.MkdirTemp("/tmp", "p5-")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { os.RemoveAll(dir) })
e := &engine{root: t.TempDir(), socket: filepath.Join(dir, "fill.sock"), owner: owners,
moved: map[string]int{}, asked: map[string]int{}, wrote: map[string]string{}}
l, err := net.Listen("unix", e.socket)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { l.Close() })
go func() {
for {
conn, err := l.Accept()
if err != nil {
return
}
go func() {
defer conn.Close()
var req FillRequest
dec := json.NewDecoder(conn)
dec.DisallowUnknownFields()
if err := dec.Decode(&req); err != nil {
_ = json.NewEncoder(conn).Encode(FillAnswer{Refused: "unreadable: " + err.Error()})
return
}
_ = json.NewEncoder(conn).Encode(e.write(req))
}()
}
}()
return e
}
func (e *engine) at(path string) string { return filepath.Join(e.root, path) }
func (e *engine) write(req FillRequest) FillAnswer {
e.mu.Lock()
defer e.mu.Unlock()
e.asked[req.Path]++
owner, declared := e.owner[req.Path]
if !declared {
return FillAnswer{Path: req.Path, Refused: req.Path + " is not a file this node's declaration names as filled"}
}
at := e.at(req.Path)
have, err := os.ReadFile(at)
exists := err == nil
want := string(req.Content)
switch owner {
case "seed":
if exists {
return FillAnswer{Path: req.Path, Action: "kept"}
}
case "managed":
// The mesh's block between its marks; the program's lines around it kept.
const begin, end = "# >>> mesh\n", "# <<< mesh\n"
block := begin + want + end
if exists {
text := string(have)
if i, j := strings.Index(text, begin), strings.Index(text, end); i >= 0 && j > i {
block = text[:i] + block + text[j+len(end):]
} else {
block = text + block
}
}
want = block
}
action := "created"
switch {
case exists && string(have) == want:
return FillAnswer{Path: req.Path, Action: "unchanged", Digest: digestOf(have)}
case exists && string(have) != e.wrote[req.Path]:
action = "corrected"
case exists:
action = "updated"
}
_ = os.MkdirAll(filepath.Dir(at), 0o755)
if err := os.WriteFile(at, []byte(want), 0o644); err != nil {
return FillAnswer{Path: req.Path, Failed: err.Error()}
}
e.wrote[req.Path] = want
e.moved[req.Path]++
return FillAnswer{Path: req.Path, Action: action, Digest: digestOf([]byte(want))}
}
func (e *engine) counts(path string) (moved, asked int) {
e.mu.Lock()
defer e.mu.Unlock()
return e.moved[path], e.asked[path]
}
func (e *engine) read(t *testing.T, path string) string {
t.Helper()
b, err := os.ReadFile(e.at(path))
if err != nil {
return ""
}
return string(b)
}
// templates is an artifact store holding one archive per name, and the plan's Templates for it.
func templates(t *testing.T, files map[string]string) (Templates, *httptest.Server) {
t.Helper()
var buf bytes.Buffer
z := gzip.NewWriter(&buf)
w := tar.NewWriter(z)
for _, name := range sortedKeys(files) {
if err := w.WriteHeader(&tar.Header{Name: name, Mode: 0o644, Size: int64(len(files[name])),
Typeflag: tar.TypeReg}); err != nil {
t.Fatal(err)
}
if _, err := w.Write([]byte(files[name])); err != nil {
t.Fatal(err)
}
}
if err := w.Close(); err != nil {
t.Fatal(err)
}
if err := z.Close(); err != nil {
t.Fatal(err)
}
body := buf.Bytes()
srv := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, _ *http.Request) { _, _ = rw.Write(body) }))
t.Cleanup(srv.Close)
return Templates{Source: srv.URL + "/files.tar.gz", Digest: digestOf(body)}, srv
}
// aClient runs a client on g14's account until the returned stop is called.
func aClient(t *testing.T, b *aBus, e *engine, state string, drift time.Duration) (*Client, func()) {
t.Helper()
nc, err := nats.Connect(b.url, nats.UserInfo("provisioner."+node, "x"),
nats.CustomInboxPrefix("_INBOX.provisioner."+node),
nats.ErrorHandler(func(_ *nats.Conn, _ *nats.Subscription, err error) {
t.Errorf("the bus refused the client: %v", err)
}))
if err != nil {
t.Fatal(err)
}
js, err := jetstream.New(nc)
if err != nil {
t.Fatal(err)
}
secrets, err := NewVaultSecrets(nc, "provisioner."+node)
if err != nil {
t.Fatal(err)
}
secrets.Within = 2 * time.Second
c := &Client{Node: node, JS: js, Conn: nc, Drift: drift, Secrets: secrets,
Fill: SocketFiller{Socket: e.socket, Busy: 10 * time.Millisecond, Tries: 3},
Templates: &templateStore{dir: state, fetch: HTTPFetch}}
ctx, cancel := context.WithCancel(context.Background())
done := make(chan error, 1)
go func() { done <- c.Run(ctx) }()
stopped := false
stop := func() {
if stopped {
return
}
stopped = true
cancel()
if err := <-done; err != nil && !errors.Is(err, context.Canceled) {
t.Errorf("the client stopped with %v", err)
}
nc.Close()
}
t.Cleanup(stop)
return c, stop
}
func eventually(t *testing.T, what string, ok func() bool) {
t.Helper()
deadline := time.Now().Add(10 * time.Second)
for !ok() {
if time.Now().After(deadline) {
t.Fatalf("not within 10s: %s", what)
}
time.Sleep(20 * time.Millisecond)
}
}
const dunstrc = "/home/nox/.config/dunst/dunstrc"
// dunst is a module with one owned file naming a config value and a placement value.
func dunst(t *testing.T) Plan {
tpl, _ := templates(t, map[string]string{
"dunstrc": "[global]\nfont = Monospace ${config:font-size}\nicons = ${machine:account-home}/.icons\n"})
return Plan{Templates: tpl, Files: []PlannedFile{{ID: "dunstrc", Path: dunstrc, Template: "dunstrc",
OwnerAtRuntime: "owned", Takes: "reload-on"}},
Placement: map[string]string{"machine:account-home": "/home/nox"}}
}
func TestNatsATemplateFillsFromEachOwnersKeys(t *testing.T) {
b := startBus(t)
heard := b.listen(t)
e := startEngine(t, map[string]string{dunstrc: "owned"})
b.config(t, "dunst", "font-size", "13")
b.assign(t, "dunst", dunst(t))
aClient(t, b, e, t.TempDir(), time.Hour)
want := "[global]\nfont = Monospace 13\nicons = /home/nox/.icons\n"
eventually(t, "dunstrc is filled", func() bool { return e.read(t, dunstrc) == want })
eventually(t, "prov.applied says applied", func() bool {
r, _ := heard.last("dunst")
return r.Status == StatusApplied
})
r, _ := heard.last("dunst")
if r.Templates == "" || len(r.Files) != 1 || r.Files[0].Action != "created" || r.Files[0].Takes != "reload-on" {
t.Errorf("prov.applied said %+v", r)
}
// The needs, one per placeholder, under the node's and the module's name.
eventually(t, "the needs are recorded", func() bool {
got := b.keys(t, NeedsBucket, node+".dunst.")
return len(got) == 2 && got[0] == "g14.dunst.config.font-size" && got[1] == "g14.dunst.machine.account-home"
})
kept, err := b.kv[AppliedBucket].Get(context.Background(), node+".dunst")
if err != nil || !strings.Contains(string(kept.Value()), `"status":"applied"`) {
t.Errorf("mesh-provisioner_applied keeps %v (%v)", kept, err)
}
}
func TestNatsAMissingValueKeepsTheOldFileAndSaysWhatItWaitsFor(t *testing.T) {
b := startBus(t)
heard := b.listen(t)
e := startEngine(t, map[string]string{dunstrc: "owned"})
b.assign(t, "dunst", dunst(t))
aClient(t, b, e, t.TempDir(), time.Hour)
// No value yet: nothing written, and the wait said.
eventually(t, "prov.applied says it waits", func() bool {
r, _ := heard.last("dunst")
return r.Status == StatusWaiting
})
r, _ := heard.last("dunst")
if len(r.WaitsFor) != 1 || !strings.HasPrefix(r.WaitsFor[0], "config:font-size") {
t.Errorf("it waits for %v", r.WaitsFor)
}
if _, asked := e.counts(dunstrc); asked != 0 {
t.Errorf("a file waiting for a value was written %d times", asked)
}
// Filled once it is there; and a value gone keeps the file there is.
b.config(t, "dunst", "font-size", "13")
eventually(t, "dunstrc is filled", func() bool { return strings.Contains(e.read(t, dunstrc), "Monospace 13") })
b.del(t, ConfigBucket, node+".dunst.font-size")
eventually(t, "prov.applied says it waits again", func() bool {
r, _ := heard.last("dunst")
return r.Status == StatusWaiting
})
if !strings.Contains(e.read(t, dunstrc), "Monospace 13") {
t.Errorf("a value gone changed the file there was: %q", e.read(t, dunstrc))
}
if _, asked := e.counts(dunstrc); asked != 1 {
t.Errorf("dunstrc was asked for %d times, want 1", asked)
}
}
func TestNatsAChangeRefillsWritesOnceAndTheProgramTakesIt(t *testing.T) {
b := startBus(t)
heard := b.listen(t)
e := startEngine(t, map[string]string{dunstrc: "owned"})
b.config(t, "dunst", "font-size", "13")
b.assign(t, "dunst", dunst(t))
aClient(t, b, e, t.TempDir(), time.Hour)
eventually(t, "dunstrc is filled", func() bool { return strings.Contains(e.read(t, dunstrc), "Monospace 13") })
b.config(t, "dunst", "font-size", "15")
eventually(t, "dunstrc takes the change", func() bool { return strings.Contains(e.read(t, dunstrc), "Monospace 15") })
eventually(t, "prov.applied says it", func() bool {
r, _ := heard.last("dunst")
return r.Status == StatusApplied && len(r.Files) == 1 && r.Files[0].Action == "updated"
})
// The same value again, and a key the template does not name: nothing written.
b.config(t, "dunst", "font-size", "15")
b.config(t, "dunst", "geometry", `"300x5"`)
time.Sleep(300 * time.Millisecond)
if moved, asked := e.counts(dunstrc); moved != 2 || asked != 2 {
t.Errorf("two values wrote dunstrc %d times (asked %d); want 2 and 2: the program is reloaded once a change",
moved, asked)
}
}
func TestNatsAnOwnedFileIsWrittenBackASeedLeftAndAManagedFileKeepsTheProgramsLines(t *testing.T) {
const seed, managed = "/home/nox/.config/app/first.conf", "/home/nox/.config/app/settings.ini"
b := startBus(t)
heard := b.listen(t)
e := startEngine(t, map[string]string{dunstrc: "owned", seed: "seed", managed: "managed"})
tpl, _ := templates(t, map[string]string{"dunstrc": "font = ${config:font-size}\n",
"first.conf": "start = ${config:font-size}\n", "settings.ini": "size = ${config:font-size}\n"})
b.config(t, "app", "font-size", "13")
b.assign(t, "app", Plan{Templates: tpl, Files: []PlannedFile{
{ID: "rc", Path: dunstrc, Template: "dunstrc", OwnerAtRuntime: "owned"},
{ID: "first", Path: seed, Template: "first.conf", OwnerAtRuntime: "seed"},
{ID: "settings", Path: managed, Template: "settings.ini", OwnerAtRuntime: "managed", Takes: "read-on-use"}}})
aClient(t, b, e, t.TempDir(), 200*time.Millisecond)
eventually(t, "all three are placed", func() bool {
r, _ := heard.last("app")
return r.Status == StatusApplied
})
// The program changes each on disk.
for path, text := range map[string]string{dunstrc: "font = 99\n", seed: "start = mine\n",
managed: "theme = dark\n" + e.read(t, managed) + "recent = a,b\n"} {
if err := os.WriteFile(e.at(path), []byte(text), 0o644); err != nil {
t.Fatal(err)
}
}
eventually(t, "the owned file is written back", func() bool { return e.read(t, dunstrc) == "font = 13\n" })
eventually(t, "prov.applied says it was put back", func() bool {
r, _ := heard.last("app")
return r.Status == StatusApplied && len(r.Files) == 3 && r.Files[0].Action == "corrected"
})
time.Sleep(500 * time.Millisecond)
if got := e.read(t, seed); got != "start = mine\n" {
t.Errorf("the seed file is %q: written once, then the program's", got)
}
if got := e.read(t, managed); !strings.Contains(got, "theme = dark\n") || !strings.Contains(got, "recent = a,b\n") ||
!strings.Contains(got, "size = 13\n") {
t.Errorf("the managed file is %q: the mesh's block and the program's own lines", got)
}
// The drift pass that finds every file as it should be says nothing.
_, before := heard.last("app")
time.Sleep(600 * time.Millisecond)
if _, after := heard.last("app"); after != before {
t.Errorf("drift passes that moved nothing said %d reports", after-before)
}
}
func TestNatsAModuleTakenOffTheNodeLosesItsNeedsAndItsReport(t *testing.T) {
b := startBus(t)
heard := b.listen(t)
e := startEngine(t, map[string]string{dunstrc: "owned"})
b.config(t, "dunst", "font-size", "13")
b.assign(t, "dunst", dunst(t))
aClient(t, b, e, t.TempDir(), time.Hour)
eventually(t, "the needs are recorded", func() bool { return len(b.keys(t, NeedsBucket, node+".dunst.")) == 2 })
b.del(t, AssignmentsBucket, node+".dunst")
eventually(t, "its needs go", func() bool { return len(b.keys(t, NeedsBucket, node+".dunst.")) == 0 })
eventually(t, "its report goes", func() bool { return len(b.keys(t, AppliedBucket, node+".dunst")) == 0 })
eventually(t, "prov.applied says it is removed", func() bool {
r, _ := heard.last("dunst")
return r.Status == StatusRemoved
})
}
func TestNatsANodeComingBackFillsFromTheCurrentValues(t *testing.T) {
b := startBus(t)
const zshrc = "/home/nox/.zshrc"
e := startEngine(t, map[string]string{dunstrc: "owned", zshrc: "owned"})
b.config(t, "dunst", "font-size", "13")
b.assign(t, "dunst", dunst(t))
b.config(t, "zsh", "prompt", `"%"`)
tpl, _ := templates(t, map[string]string{"zshrc": "PS1=${config:prompt}\n"})
b.assign(t, "zsh", Plan{Templates: tpl, Files: []PlannedFile{{ID: "zshrc", Path: zshrc, Template: "zshrc",
OwnerAtRuntime: "owned"}}})
state := t.TempDir()
_, stop := aClient(t, b, e, state, time.Hour)
eventually(t, "both are filled", func() bool {
return strings.Contains(e.read(t, dunstrc), "Monospace 13") && e.read(t, zshrc) == "PS1=%\n"
})
// Away: a value changes and a module leaves, and the artifact store is gone.
stop()
b.config(t, "dunst", "font-size", "17")
b.del(t, AssignmentsBucket, node+".zsh")
heard := b.listen(t)
aClient(t, b, e, state, time.Hour)
eventually(t, "dunstrc has the value set while it was away", func() bool {
return strings.Contains(e.read(t, dunstrc), "Monospace 17")
})
eventually(t, "the module that left lost its needs", func() bool { return len(b.keys(t, NeedsBucket, node+".zsh.")) == 0 })
eventually(t, "and its report", func() bool {
r, _ := heard.last("zsh")
return r.Status == StatusRemoved && len(b.keys(t, AppliedBucket, node+".zsh")) == 0
})
}
// A plan that cannot be acted on, templates that are not the ones named, and a secret the plan does not name each
// fail, with the reason; nothing is written.
func TestNatsWhatCannotBeFilledFailsWithTheReason(t *testing.T) {
b := startBus(t)
heard := b.listen(t)
e := startEngine(t, map[string]string{dunstrc: "owned", "/etc/app.conf": "owned"})
aClient(t, b, e, t.TempDir(), time.Hour)
b.put(t, AssignmentsBucket, node+".broken", `{"node":"g14","module":"broken","provisioning":{"templates":`+
`{"source":"x","digest":"sha256:00"},"files":[],"surprise":1}}`)
p := dunst(t)
p.Templates.Digest = "sha256:" + strings.Repeat("0", 64)
b.assign(t, "forged", p)
tpl, _ := templates(t, map[string]string{"app.conf": "token = ${secret:app-token}\n"})
b.assign(t, "secretive", Plan{Templates: tpl, Files: []PlannedFile{{ID: "conf", Path: "/etc/app.conf",
Template: "app.conf", OwnerAtRuntime: "owned"}}})
for module, why := range map[string]string{"broken": "surprise", "forged": "and the plan names",
"secretive": "the plan names no secret app-token"} {
eventually(t, module+" fails", func() bool {
r, _ := heard.last(module)
return r.Status == StatusFailed && strings.Contains(r.Reason, why)
})
}
if _, asked := e.counts("/etc/app.conf"); asked != 0 {
t.Errorf("a file that cannot be filled was written")
}
}
func TestANeedsKeyHoldsAnyName(t *testing.T) {
for n, want := range map[Need]string{
{Kind: "config", Name: "font-size"}: "g14.dunst.config.font-size",
{Kind: "seat", Name: "mesh-broker:4222"}: "g14.dunst.seat.mesh-broker=3A4222",
{Kind: "config", Name: "a=b c"}: "g14.dunst.config.a=3Db=20c",
{Kind: "machine", Name: "account-home"}: "g14.dunst.machine.account-home",
} {
if got := NeedKey(node, "dunst", n); got != want {
t.Errorf("%v: %s, want %s", n, got, want)
}
}
}
// TestNatsResticsFilesFillFromTheBackupPlacement is the backup lines as placement (issue 794): restic's two files
// are the plan's `backup:lines` and `backup:data`, each exactly the text the controller composed, trailing newline
// and all, and a changed plan refills them.
func TestNatsResticsFilesFillFromTheBackupPlacement(t *testing.T) {
const backups, data = "/var/lib/restic/backups.conf", "/var/lib/restic/data.conf"
b := startBus(t)
e := startEngine(t, map[string]string{backups: "owned", data: "owned"})
tpl, _ := templates(t, map[string]string{
"backups.conf": "# What the modules on this machine back up, composed by the mesh. Do not edit.\n${backup:lines}",
"data.conf": "# The data the modules on this machine declare, composed by the mesh. Do not edit.\n${backup:data}"})
plan := func(lines, declared string) Plan {
return Plan{Templates: tpl, Files: []PlannedFile{
{ID: "declared", Path: backups, Template: "backups.conf", OwnerAtRuntime: "owned"},
{ID: "data-declared", Path: data, Template: "data.conf", OwnerAtRuntime: "owned"}},
Placement: map[string]string{"backup:lines": lines, "backup:data": declared}}
}
b.assign(t, "restic", plan("# a\n/srv/a\n# b\n/srv/b\n", "# a\nstate\n"))
aClient(t, b, e, t.TempDir(), time.Hour)
head := "# What the modules on this machine back up, composed by the mesh. Do not edit.\n"
eventually(t, "backups.conf is filled", func() bool { return e.read(t, backups) == head+"# a\n/srv/a\n# b\n/srv/b\n" })
eventually(t, "data.conf is filled", func() bool {
return e.read(t, data) == "# The data the modules on this machine declare, composed by the mesh. Do not edit.\n# a\nstate\n"
})
b.assign(t, "restic", plan("# a\n/srv/a\n", "# a\nstate\n"))
eventually(t, "a module taken away refills backups.conf", func() bool { return e.read(t, backups) == head+"# a\n/srv/a\n" })
}
@@ -0,0 +1,256 @@
package main
import (
"cmp"
"fmt"
"slices"
"strconv"
"strings"
)
// Collections (novox/hq ADR 0349 clause 13, design 57 L1, issue 684): a provision with many providers. A collector,
// a module taking additions from others, names a collection in its template; each contributor ships its item as a
// template of its own. The client of a node fills each contributor's item placed there, from the same owners as a
// file, and records it in mesh-provisioner_collect at `<node>.<collection>.<contributor>`; it fills a collector's
// template from every item of the collection recorded for its node, in the contributors' order, each introduced by
// a line naming its contributor. An item goes when its contributor leaves the node, and the collector is filled
// again without it.
//
// **The three old namespaces read as collections.** `${shell:<shell>:<slot>}`, `${environment:<format>}` and
// `${contribution:<seat>:<kind>}`, which the controller fills for a module not handed to the provisioner, name in a
// handed collector's template the collection of the same name: `${shell:zsh:normal}` is `shell:zsh:normal`, the
// collection a contributor's item for zsh's normal slot is recorded in.
// CollectBucket keeps the items of every collection, `<node>.<collection>.<contributor>`.
const CollectBucket = "mesh-provisioner_collect"
// CollectedKind is the namespace of a collection's placeholder, `${collected:<collection>}`.
const CollectedKind = "collected"
// oldCollectionKinds are the namespaces read as collections of their own name.
var oldCollectionKinds = []string{"shell", "environment", "contribution"}
// collectionOf is the collection a need names, without its selector, and whether it names one.
func collectionOf(n Need) (string, bool) {
name := ""
if _, is := environmentRenderer(n); is {
return EnvironmentCollection, true
}
switch {
case n.Kind == CollectedKind:
name = n.Name
case slices.Contains(oldCollectionKinds, n.Kind):
name = n.Kind + ":" + n.Name
default:
return "", false
}
collection, _ := splitSelector(name)
return collection, true
}
// selectorOfNeed is the attributes a need's selector asks for (`${collected:c:bar=top,place=status}`), none for a
// need without one, or why the selector is none (design 57 L3, issue 777).
func selectorOfNeed(n Need) (map[string]string, error) {
name := n.Name
if n.Kind != CollectedKind {
name = n.Kind + ":" + n.Name
}
_, selector := splitSelector(name)
out := map[string]string{}
if selector == "" {
return out, nil
}
for _, pair := range strings.Split(selector, ",") {
k, v, ok := strings.Cut(pair, "=")
switch {
case !ok || !attributeKeyShape.MatchString(k) || !attributeValueShape.MatchString(v):
return nil, fmt.Errorf("%q in %q is not <attribute>=<value>", pair, selector)
case out[k] != "":
return nil, fmt.Errorf("%q names %s twice", selector, k)
}
out[k] = v
}
return out, nil
}
// splitSelector is a placeholder's name without its selector, and the selector: `blocks:bar=top,place=left` is
// `blocks` and `bar=top,place=left`. A collection's name holds no `=`, so the selector begins at the last colon
// before the first one (mesh-controller internal/catalogue collections.go).
func splitSelector(name string) (collection, selector string) {
eq := strings.Index(name, "=")
if eq < 0 {
return name, ""
}
colon := strings.LastIndex(name[:eq], ":")
if colon < 0 {
return name, ""
}
return name[:colon], name[colon+1:]
}
// ItemRecord is one contributor's item, as mesh-provisioner_collect keeps it: filled, never holding a secret.
type ItemRecord struct {
Node string `json:"node"`
Collection string `json:"collection"`
Contributor string `json:"contributor"`
Text string `json:"text"`
// Name tells the item from the contributor's others of the collection; Attributes are what a placeholder
// selecting by them is matched against, `order` among them (design 57 L3, issue 777).
Name string `json:"name,omitempty"`
Attributes map[string]string `json:"attributes,omitempty"`
// Templates is the digest of the contributor's templates it was filled from.
Templates string `json:"templates,omitempty"`
}
// itemID is an item's name among its contributor's: the module, and `/<name>` for a named item. It is the
// contributor part of the item's key.
func itemID(module, name string) string {
if name == "" {
return module
}
return module + "/" + name
}
// splitID is an item's module and name.
func splitID(id string) (module, name string) {
module, name, _ = strings.Cut(id, "/")
return module, name
}
// itemValue is an item as the client holds it: its filled text and its attributes.
type itemValue struct {
text string
attributes map[string]string
}
// order is the item's place among the items of one placeholder: its `order` attribute (0 to 99), else 50.
func (v itemValue) order() int {
if n, err := strconv.Atoi(v.attributes[OrderAttribute]); err == nil && n >= 0 && n <= 99 {
return n
}
return OrderDefault
}
// OrderAttribute orders the items of a placeholder, lowest first; OrderDefault is the order of an item with none.
const (
OrderAttribute = "order"
OrderDefault = 50
)
// CollectKey is an item's key, `<node>.<collection>.<contributor>`: the collection with every character a key's
// token cannot hold written `=<hex>`, so `shell:zsh:normal` is `shell=3Azsh=3Anormal`.
func CollectKey(node, collection, contributor string) string {
return node + "." + keyToken(collection) + "." + contributor
}
func keyToken(s string) string {
var b strings.Builder
for _, r := range []byte(s) {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-', r == '_':
b.WriteByte(r)
default:
fmt.Fprintf(&b, "=%02X", r)
}
}
return b.String()
}
// fromKeyToken reads a token keyToken wrote.
func fromKeyToken(s string) (string, bool) {
var b strings.Builder
for i := 0; i < len(s); i++ {
if s[i] != '=' {
b.WriteByte(s[i])
continue
}
if i+2 >= len(s) {
return "", false
}
v, err := strconv.ParseUint(s[i+1:i+3], 16, 8)
if err != nil {
return "", false
}
b.WriteByte(byte(v))
i += 2
}
return b.String(), true
}
// itemOfKey is an item key's collection and contributor, for this node; false for a key not in the shape.
func itemOfKey(node, key string) (collection, contributor string, ok bool) {
rest, mine := strings.CutPrefix(key, node+".")
if !mine {
return "", "", false
}
token, contributor, two := strings.Cut(rest, ".")
if !two || token == "" || contributor == "" || strings.Contains(contributor, ".") {
return "", "", false
}
if module, name := splitID(contributor); module == "" || strings.Contains(name, "/") ||
(name == "" && strings.Contains(contributor, "/")) {
return "", "", false
}
collection, ok = fromKeyToken(token)
return collection, contributor, ok && collectionShape.MatchString(collection)
}
// collectionText is a collection's value on this node: every item of it the selector takes (all without one), by
// order, then contributor, then item name, each introduced by a line naming its contributor in the collector's
// comment (none without one), each ending its line. Empty when nothing is contributed: a collection with no item is
// filled, not waited for.
func collectionText(items map[string]itemValue, comment string, where map[string]string) string {
var ids []string
for id, it := range items {
if matches(it, where) {
ids = append(ids, id)
}
}
slices.SortFunc(ids, func(a, b string) int {
if c := cmp.Compare(items[a].order(), items[b].order()); c != 0 {
return c
}
ma, na := splitID(a)
mb, nb := splitID(b)
return cmp.Or(cmp.Compare(ma, mb), cmp.Compare(na, nb))
})
var b strings.Builder
for _, id := range ids {
if comment != "" {
module, _ := splitID(id)
fmt.Fprintf(&b, "%s %s\n", comment, module)
}
text := items[id].text
b.WriteString(text)
if !strings.HasSuffix(text, "\n") {
b.WriteString("\n")
}
}
return b.String()
}
// matches is whether the item carries every attribute the selector asks for.
func matches(it itemValue, where map[string]string) bool {
for k, v := range where {
if got, has := it.attributes[k]; !has || got != v {
return false
}
}
return true
}
// collectionNamed is the plan's collection of that name.
func (p Plan) collectionNamed(name string) (PlannedCollection, bool) {
for _, c := range p.Collections {
if c.Name == name {
return c, true
}
}
return PlannedCollection{}, false
}
// collects is whether the plan collects that collection.
func (p Plan) collects(name string) bool {
_, ok := p.collectionNamed(name)
return ok
}
@@ -0,0 +1,131 @@
package main
import (
"strings"
"testing"
"time"
)
// An item may carry attributes and a module may give several named items to one collection; a placeholder selecting
// by attributes takes only the items that carry them, in the order of the old forms: order, contributor, item name
// (design 57 L3, issue 777). Against a real bus, on the client's own account.
const barsConf = "/etc/bars.conf"
// barCollector fills one file from the bottom bar's status blocks, the top bar's, and all of them.
func barCollector(t *testing.T) Plan {
tpl, _ := templates(t, map[string]string{"bars": "[bottom]\n${collected:blocks:bar=bottom,place=status}" +
"[top]\n${collected:blocks:bar=top,place=status}[all]\n${collected:blocks}"})
return Plan{Templates: tpl, Files: []PlannedFile{{ID: "conf", Path: barsConf, Template: "bars",
OwnerAtRuntime: "owned"}}, Collections: []PlannedCollection{{Name: "blocks", Comment: "#"}}}
}
func blocksOf(t *testing.T, items map[string]PlannedItem, texts map[string]string) Plan {
tpl, _ := templates(t, texts)
p := Plan{Templates: tpl}
for _, name := range sortedKeys(items) {
p.Items = append(p.Items, items[name])
}
return p
}
func TestNatsASelectingPlaceholderTakesOnlyTheItemsCarryingItsAttributesInOrder(t *testing.T) {
b := startBus(t)
e := startEngine(t, map[string]string{barsConf: "owned"})
b.assign(t, "bars", barCollector(t))
b.assign(t, "power", blocksOf(t, map[string]PlannedItem{
"battery": {Collection: "blocks", Name: "battery", Template: "battery",
Attributes: map[string]string{"bar": "bottom", "place": "status"}},
"cpu": {Collection: "blocks", Name: "cpu", Template: "cpu",
Attributes: map[string]string{"bar": "bottom", "place": "status", "order": "20"}},
"clock": {Collection: "blocks", Name: "clock", Template: "clock",
Attributes: map[string]string{"bar": "top", "place": "status"}},
}, map[string]string{"battery": "battery\n", "cpu": "cpu\n", "clock": "clock\n"}))
b.assign(t, "another", blocksOf(t, map[string]PlannedItem{
"disk": {Collection: "blocks", Template: "disk", Attributes: map[string]string{"bar": "bottom", "place": "status", "order": "20"}},
}, map[string]string{"disk": "disk\n"}))
aClient(t, b, e, t.TempDir(), time.Hour)
want := "[bottom]\n# another\ndisk\n# power\ncpu\n# power\nbattery\n" +
"[top]\n# power\nclock\n" +
"[all]\n# another\ndisk\n# power\ncpu\n# power\nbattery\n# power\nclock\n"
eventually(t, "the file holds each placeholder's items", func() bool { return e.read(t, barsConf) == want })
eventually(t, "the named items are recorded under their module and name", func() bool {
got := b.keys(t, CollectBucket, node+".blocks.")
return len(got) == 4 && got[0] == "g14.blocks.another" && got[1] == "g14.blocks.power/battery" &&
got[2] == "g14.blocks.power/clock" && got[3] == "g14.blocks.power/cpu"
})
// An item no longer contributed goes, and only that one.
b.assign(t, "power", blocksOf(t, map[string]PlannedItem{
"clock": {Collection: "blocks", Name: "clock", Template: "clock", Attributes: map[string]string{"bar": "top", "place": "status"}},
}, map[string]string{"clock": "clock\n"}))
eventually(t, "the items of the module's others go", func() bool {
return strings.Contains(e.read(t, barsConf), "[bottom]\n# another\ndisk\n[top]")
})
if got := b.keys(t, CollectBucket, node+".blocks."); len(got) != 2 {
t.Errorf("kept %v", got)
}
}
func TestASelectorIsReadFromAPlaceholderOfAnyNamespace(t *testing.T) {
for n, want := range map[Need]string{
{Kind: "collected", Name: "blocks:bar=top,place=status"}: "blocks",
{Kind: "collected", Name: "blocks"}: "blocks",
{Kind: "collected", Name: "a:b:k=v"}: "a:b",
{Kind: "contribution", Name: "node-bar:block:bar=top,place=left"}: "contribution:node-bar:block",
} {
if got, ok := collectionOf(n); !ok || got != want {
t.Errorf("%s is the collection %q (%v), want %q", n, got, ok, want)
}
}
if where, err := selectorOfNeed(Need{Kind: "collected", Name: "blocks:bar=top,place=status"}); err != nil ||
len(where) != 2 || where["bar"] != "top" || where["place"] != "status" {
t.Errorf("the selector is %v (%v)", where, err)
}
for _, bad := range []string{"blocks:bar=top,bar=top", "blocks:bar=", "blocks:bar=top,"} {
if _, err := selectorOfNeed(Need{Kind: "collected", Name: bad}); err == nil {
t.Errorf("%s was read as a selector", bad)
}
}
}
func TestAnItemsKeyHoldsItsName(t *testing.T) {
key := CollectKey(node, "blocks", itemID("power", "battery"))
if key != "g14.blocks.power/battery" {
t.Fatal(key)
}
if c, id, ok := itemOfKey(node, key); !ok || c != "blocks" || id != "power/battery" {
t.Errorf("reads back as %q %q %v", c, id, ok)
}
for _, bad := range []string{"g14.blocks.power/", "g14.blocks./x", "g14.blocks.a/b/c"} {
if _, _, ok := itemOfKey(node, bad); ok {
t.Errorf("%s read as an item's key", bad)
}
}
p := Plan{Templates: Templates{Source: "x", Digest: "sha256:" + strings.Repeat("0", 64)},
Items: []PlannedItem{{Collection: "a", Name: "n", Template: "i"}, {Collection: "a", Name: "n", Template: "j"},
{Collection: "a", Name: "Big", Template: "k", Attributes: map[string]string{"order": "100", "Bar": "x"}}}}
got := strings.Join(p.problems(), "\n")
for _, want := range []string{"item a/n is named twice", `"Big" is not a name`, "0 to 99", "attribute Bar"} {
if !strings.Contains(got, want) {
t.Errorf("the problems %q do not say %q", got, want)
}
}
}
func TestAnItemOfAPlanReadsItsNameAndAttributesAndRefusesAnUnknownField(t *testing.T) {
plan := func(item string) string {
return `{"node":"g14","module":"power","provisioning":{"templates":{"source":"x","digest":"sha256:` +
strings.Repeat("0", 64) + `"},"files":[],"items":[` + item + `]}}`
}
_, p, err := planOf("g14", "g14.power", []byte(plan(
`{"collection":"blocks","name":"battery","attributes":{"bar":"top"},"template":"battery"}`)))
if err != nil || len(p.Items) != 1 || p.Items[0].Name != "battery" || p.Items[0].Attributes["bar"] != "top" {
t.Fatalf("read %+v (%v)", p.Items, err)
}
if _, _, err := planOf("g14", "g14.power", []byte(plan(
`{"collection":"blocks","template":"battery","colour":"red"}`))); err == nil || !strings.Contains(err.Error(), "colour") {
t.Errorf("an unknown item field was accepted: %v", err)
}
}
@@ -0,0 +1,179 @@
package main
import (
"context"
"encoding/json"
"strings"
"testing"
"time"
)
// A collection's test of novox/hq ADR 0349 ("How it is checked", design 57 L1, issue 684): zsh's file holds every
// placed contributor's item and loses one whose contributor leaves. Against a real bus, on the client's own account.
const zshrc = "/home/nox/.zshrc"
// zshCollector is zsh as a handed collector: its rc names its normal slot the old way, read as the collection
// shell:zsh:normal, and a collection of the new form.
func zshCollector(t *testing.T) Plan {
tpl, _ := templates(t, map[string]string{"zshrc": "# zsh\n${shell:zsh:normal}# then\n${collected:zsh-widgets}# end\n"})
return Plan{Templates: tpl, Files: []PlannedFile{{ID: "rc", Path: zshrc, Template: "zshrc",
OwnerAtRuntime: "owned"}},
Collections: []PlannedCollection{{Name: "shell:zsh:normal", Comment: "#"}, {Name: "zsh-widgets"}}}
}
// contributor is a module whose only part on the node is its item of collection, from its own template.
func contributor(t *testing.T, collection, text string) Plan {
tpl, _ := templates(t, map[string]string{"item": text})
return Plan{Templates: tpl, Items: []PlannedItem{{Collection: collection, Template: "item"}},
Placement: map[string]string{"machine:account-home": "/home/nox"}}
}
func TestNatsZshsFileHoldsEveryPlacedContributorsItemAndLosesOneWhoseContributorLeaves(t *testing.T) {
b := startBus(t)
heard := b.listen(t)
e := startEngine(t, map[string]string{zshrc: "owned", "/etc/fzf.conf": "owned"})
b.assign(t, "zsh", zshCollector(t))
b.assign(t, "powerlevel10k", contributor(t, "shell:zsh:normal",
"source ${machine:account-home}/.p10k.zsh\n"))
b.assign(t, "mesh-cli", contributor(t, "shell:zsh:normal", "alias nox=mesh-cli"))
b.config(t, "fzf", "binding", `"^T"`)
fzf := contributor(t, "zsh-widgets", "bindkey ${config:binding} fzf-file-widget\n")
b.assign(t, "fzf", fzf)
aClient(t, b, e, t.TempDir(), time.Hour)
every := "# zsh\n# mesh-cli\nalias nox=mesh-cli\n# powerlevel10k\nsource /home/nox/.p10k.zsh\n# then\n" +
"bindkey ^T fzf-file-widget\n# end\n"
eventually(t, "zshrc holds every contributor's item", func() bool { return e.read(t, zshrc) == every })
// Each item recorded under the node, the collection and its contributor; filled, the contributor's report says so.
eventually(t, "the items are recorded", func() bool {
got := b.keys(t, CollectBucket, node+".")
return len(got) == 3 && got[0] == "g14.shell=3Azsh=3Anormal.mesh-cli" &&
got[1] == "g14.shell=3Azsh=3Anormal.powerlevel10k" && got[2] == "g14.zsh-widgets.fzf"
})
kept, err := b.kv[CollectBucket].Get(context.Background(), "g14.shell=3Azsh=3Anormal.powerlevel10k")
if err != nil {
t.Fatal(err)
}
var it ItemRecord
if err := json.Unmarshal(kept.Value(), &it); err != nil || it.Text != "source /home/nox/.p10k.zsh\n" ||
it.Collection != "shell:zsh:normal" || it.Contributor != "powerlevel10k" {
t.Errorf("the item kept is %s (%v)", kept.Value(), err)
}
eventually(t, "powerlevel10k's report says its item", func() bool {
r, _ := heard.last("powerlevel10k")
return r.Status == StatusApplied && len(r.Items) == 1 && r.Items[0] == "shell:zsh:normal"
})
// zsh records the collections it reads as its needs, like any value.
eventually(t, "zsh's needs are recorded", func() bool {
got := b.keys(t, NeedsBucket, node+".zsh.")
return len(got) == 2 && got[0] == "g14.zsh.collected.zsh-widgets" && got[1] == "g14.zsh.shell.zsh=3Anormal"
})
// A contributor's own value changes: its item, and so zshrc, follow.
b.config(t, "fzf", "binding", `"^F"`)
eventually(t, "zshrc follows the item", func() bool { return strings.Contains(e.read(t, zshrc), "bindkey ^F") })
// powerlevel10k leaves the node: its item goes, and zshrc without it.
b.del(t, AssignmentsBucket, node+".powerlevel10k")
without := "# zsh\n# mesh-cli\nalias nox=mesh-cli\n# then\nbindkey ^F fzf-file-widget\n# end\n"
eventually(t, "zshrc loses the item of the contributor that left", func() bool { return e.read(t, zshrc) == without })
if got := b.keys(t, CollectBucket, node+".shell=3Azsh=3Anormal.powerlevel10k"); len(got) != 0 {
t.Errorf("the item of a contributor that left is kept: %v", got)
}
// And a contributor that stops contributing, though it stays: the same.
b.assign(t, "fzf", Plan{Templates: fzf.Templates, Files: []PlannedFile{{ID: "conf", Path: "/etc/fzf.conf",
Template: "item", OwnerAtRuntime: "owned"}}, Placement: fzf.Placement})
eventually(t, "zshrc loses an item no longer contributed", func() bool {
return e.read(t, zshrc) == "# zsh\n# mesh-cli\nalias nox=mesh-cli\n# then\n# end\n"
})
}
func TestNatsACollectorStartingWithItsNodeHoldsTheItemsAlreadyThereAndOnesRecordedWhileAway(t *testing.T) {
b := startBus(t)
e := startEngine(t, map[string]string{zshrc: "owned"})
b.assign(t, "zsh", zshCollector(t))
b.assign(t, "mesh-cli", contributor(t, "shell:zsh:normal", "alias nox=mesh-cli\n"))
state := t.TempDir()
_, stop := aClient(t, b, e, state, time.Hour)
eventually(t, "zshrc holds mesh-cli's item", func() bool { return strings.Contains(e.read(t, zshrc), "alias nox") })
stop()
b.del(t, AssignmentsBucket, node+".mesh-cli")
b.assign(t, "zsh-autosuggestions", contributor(t, "shell:zsh:normal", "source suggestions.zsh\n"))
aClient(t, b, e, state, time.Hour)
want := "# zsh\n# zsh-autosuggestions\nsource suggestions.zsh\n# then\n# end\n"
eventually(t, "zshrc is filled from the items now", func() bool { return e.read(t, zshrc) == want })
eventually(t, "the item of the module that left while away is removed", func() bool {
return len(b.keys(t, CollectBucket, node+".shell=3Azsh=3Anormal.mesh-cli")) == 0
})
}
// A collection the plan does not name, and an item naming a secret, fail with the reason; an item is never
// recorded with a secret in it.
func TestNatsACollectionNotPlannedAndAnItemNamingASecretFail(t *testing.T) {
b := startBus(t)
heard := b.listen(t)
e := startEngine(t, map[string]string{zshrc: "owned"})
p := zshCollector(t)
p.Collections = p.Collections[:1]
b.assign(t, "zsh", p)
b.assign(t, "leaky", contributor(t, "shell:zsh:normal", "export TOKEN=${secret:token}\n"))
aClient(t, b, e, t.TempDir(), time.Hour)
for module, why := range map[string]string{"zsh": "the plan names no collection zsh-widgets",
"leaky": "where no secret is"} {
eventually(t, module+" fails", func() bool {
r, _ := heard.last(module)
return r.Status == StatusFailed && strings.Contains(r.Reason, why)
})
}
if got := b.keys(t, CollectBucket, node+"."); len(got) != 0 {
t.Errorf("recorded %v", got)
}
if _, asked := e.counts(zshrc); asked != 0 {
t.Errorf("a collector naming a collection its plan does not was written")
}
}
func TestAnItemsKeyHoldsAnyCollectionAndReadsBack(t *testing.T) {
for collection, want := range map[string]string{
"zsh-widgets": "g14.zsh-widgets.fzf",
"shell:zsh:normal": "g14.shell=3Azsh=3Anormal.fzf",
"contribution:node-hotkeys:trigger": "g14.contribution=3Anode-hotkeys=3Atrigger.fzf",
} {
key := CollectKey(node, collection, "fzf")
if key != want {
t.Errorf("%s: %s, want %s", collection, key, want)
}
c, who, ok := itemOfKey(node, key)
if !ok || c != collection || who != "fzf" {
t.Errorf("%s reads back as %q %q %v", key, c, who, ok)
}
}
for _, bad := range []string{"g14.x", "ace.zsh-widgets.fzf", "g14.=ZZ.fzf", "g14.a.b.c", "g14.Upper.fzf"} {
if _, _, ok := itemOfKey(node, bad); ok {
t.Errorf("%s read as an item's key", bad)
}
}
}
func TestAPlansItemsAndCollectionsAreJudged(t *testing.T) {
p := Plan{Templates: Templates{Source: "x", Digest: "sha256:" + strings.Repeat("0", 64)},
Items: []PlannedItem{{Collection: "a", Template: "item"}, {Collection: "a", Template: "../x"},
{Collection: "Bad", Template: "y"}},
Collections: []PlannedCollection{{Name: "c", Comment: "#\n"}, {Name: "c"}}}
got := strings.Join(p.problems(), "\n")
for _, want := range []string{"item a is named twice", `template "../x"`, `item "Bad" is not`,
"collection c: its comment holds a line break", "collection c is named twice"} {
if !strings.Contains(got, want) {
t.Errorf("the problems %q do not say %q", got, want)
}
}
// A plan contributing an item and filling no file is one the client acts on.
if wrong := (Plan{Templates: p.Templates, Items: p.Items[:1]}).problems(); len(wrong) != 0 {
t.Errorf("a contributor's plan: %v", wrong)
}
if wrong := (Plan{Templates: p.Templates}).problems(); len(wrong) != 1 {
t.Errorf("a plan doing nothing: %v", wrong)
}
}
@@ -0,0 +1,257 @@
package main
import (
"context"
"encoding/json"
"fmt"
"reflect"
"strconv"
"strings"
)
// A provider's consumers' credentials, and a consumer's values from its provider (novox/hq ADR 0349 clause 12, ADR
// 0373, issue 679).
//
// **`${consumers:<provision>}`: each consumer bound to this provider, with its credential.** A provider in the tool
// runner may not ask the vault (ADR 0349 clause 11, ADR 0370 clause 2), so its machine's client fills its file. A
// consumer binds the provider where its plan, on any machine, names this machine and this module as the provider
// of the provision (issue 760); so the client reads every machine's plans (the whole assignments mirror, which
// carries no secret's value) for who binds, and asks the vault for each consumer's own credential,
// `pair/<provider>/<provision>/<node>/<module>[/<local>]`, which the vault hands the provider's machine while the
// provider is handed there (mesh-vault pair.go). The file lists one entry per consumer, in the order of the
// assignments' keys and, within one plan, of its provisions entries: a JSON list, each naming the consumer and its
// credential's name and carrying its value, which the provider's helper reads (mesh-sdk go/provider ConsumersFile).
// A consumer whose credential the vault does not hold yet, or will not hand, is left out and said, so the others are
// served; the provider's helper waits for it. An entry with a name is a credential the provider's software issued
// per holder (issue 757), which the provider holds already: not listed; nor is a bind whose offer names another
// credential form than a login per consumer (issue 768): a shared value is the provider's own secret, and an issued
// or given one is not the vault's to make. The file is the provider's alone: the
// catalogue holds it to mode 0600 and owned (mesh-controller internal/catalogue consumers.go). It is filled again
// when a consumer binds or unbinds, and when the vault says one of the credentials changed.
//
// **`${bound:<provision>:<key>}`: the consumer's entry in its provider's bucket.** The provider's helper writes one
// entry per consumer to its own bucket, `<provider module>_bound`, keyed `<node>.<module>.<provision>[.<local>]`
// (mesh-sdk go/provider, issue 678), as the registry says. The client watches its own machine's keys of the bucket
// of each provider its plans name, and fills a key from the entry: `as`, `user`, or one of its served values. An
// entry the provider refused fails the module with the provider's reason; one not written yet is waited for.
// ConsumersKind is the namespace of a provider's consumers' credentials, `${consumers:<provision>}`.
const ConsumersKind = "consumers"
// BoundKind is the namespace of a consumer's value from its provider, `${bound:<provision>:<key>}`.
const BoundKind = "bound"
// boundSuffix ends a provider's bucket of entries: `<provider module>_bound`.
const boundSuffix = "_bound"
// bindsOf is as much of any machine's assignment as the client reads for who binds a provider: lenient, since
// another machine's plan is that machine's client's to act on.
type bindsOf struct {
Node string `json:"node"`
Module string `json:"module"`
Provisioning *struct {
Provisions []PlannedProvision `json:"provisions"`
} `json:"provisioning"`
}
// binding is one assignment's binds: its machine and module, and its plan's provisions in order.
type binding struct {
node, module string
provisions []PlannedProvision
}
// takeBinds folds one assignment, of any machine, into who binds what; it answers whether that changed.
func takeBinds(s *state, key string, value []byte, gone bool) bool {
var was *binding
if b, ok := s.binds[key]; ok {
was = &b
}
if gone {
delete(s.binds, key)
return was != nil
}
var a bindsOf
if err := json.Unmarshal(value, &a); err != nil || a.Node+"."+a.Module != key {
logf("[provisioner-client] the assignment %s is not the controller's, so whom it binds is not read", key)
delete(s.binds, key)
return was != nil
}
b := binding{node: a.Node, module: a.Module}
if a.Provisioning != nil {
b.provisions = a.Provisioning.Provisions
}
s.binds[key] = b
return was == nil || was.node != b.node || was.module != b.module || !reflect.DeepEqual(was.provisions, b.provisions)
}
// PairName is one consumer's own credential as the vault names it (mesh-vault pair.go, mesh-sdk go/provider
// PairName).
func PairName(provider, provision, node, module, local string) string {
n := "pair/" + provider + "/" + provision + "/" + node + "/" + module
if local != "" {
n += "/" + local
}
return n
}
// consumerListed is one entry of a provider's consumers' file (mesh-sdk go/provider ConsumerCredential).
type consumerListed struct {
Consumer string `json:"consumer"`
Module string `json:"module"`
Local string `json:"local,omitempty"`
Credential string `json:"credential"`
Value string `json:"value"`
}
// consumersOf is the file text of a provider's consumers for one provision, or why it cannot be filled.
func (c *Client) consumersOf(ctx context.Context, s *state, module string, n Need) (value, wait, fail string) {
s.providing[module] = true
list := []consumerListed{}
var left []string
for _, key := range sortedKeys(s.binds) {
b := s.binds[key]
for _, pr := range b.provisions {
if pr.issuedForHolder() || pr.Provider != c.Node || pr.ProviderModule != module || pr.Provision != n.Name ||
(pr.CredentialForm != "" && pr.CredentialForm != "pair") {
continue
}
name := PairName(c.Node, n.Name, b.node, b.module, pr.Local)
v, w, f := c.vaultValue(ctx, s, n, name)
switch {
case f != "":
left = append(left, f)
continue
case w != "":
left = append(left, w)
continue
}
list = append(list, consumerListed{Consumer: b.node, Module: b.module, Local: pr.Local, Credential: name,
Value: v})
}
}
said := strings.Join(left, "; ")
if s.leftOut[module+" "+n.Name] != said {
s.leftOut[module+" "+n.Name] = said
if said != "" {
logf("[provisioner-client] %s: consumers left out of %s until the vault hands their credential: %s",
module, n, said)
}
}
text, err := json.Marshal(list)
if err != nil {
return "", "", fmt.Sprintf("%s: %v", n, err)
}
return string(text), "", ""
}
// boundEntry is as much of a provider's entry as a consumer's file reads (mesh-sdk go/provider Entry).
type boundEntry struct {
As string `json:"as"`
User string `json:"user"`
Values map[string]any `json:"values"`
Refused string `json:"refused"`
}
// boundOf is a consumer's value from its provider's entry, or what it waits for, or why it cannot be filled.
func (c *Client) boundOf(s *state, module string, p Plan, n Need, answer Answer) (value, wait, fail string) {
provision, key, ok := strings.Cut(n.Name, ":")
if !ok || provision == "" || key == "" {
return "", "", n.String() + ": a bound value is ${bound:<provision>:<key>}"
}
key, fallback, defaulted := strings.Cut(key, "|default:")
var pr *PlannedProvision
for i := range p.Provisions {
q := p.Provisions[i]
if q.issuedForHolder() || q.Provision != provision {
continue
}
if pr == nil || q.Local == "" {
pr = &p.Provisions[i]
}
}
switch {
case pr == nil && defaulted:
return fallback, "", ""
case pr == nil:
return "", "", fmt.Sprintf("%s: the plan names no provider of %s for %s here", n, provision, module)
case pr.ProviderModule == "":
return "", "", fmt.Sprintf("%s: the plan names no provider module of %s", n, provision)
}
bucket := strings.Replace(answer.Bucket, "<provider-module>", pr.ProviderModule, 1)
if bucket != pr.ProviderModule+boundSuffix {
return "", "", fmt.Sprintf("%s: the registry answers bound from %q, which this client does not read", n,
answer.Bucket)
}
if s.boundUsers[bucket] == nil {
s.boundUsers[bucket] = map[string]bool{}
}
s.boundUsers[bucket][module] = true
if c.watchBound == nil {
return "", "", n.String() + ": this client watches no provider's bucket"
}
if err := c.watchBound(bucket); err != nil {
return "", fmt.Sprintf("%s (the provider's bucket %s cannot be watched yet: %v)", n, bucket, err), ""
}
if !s.boundReady[bucket] {
return "", fmt.Sprintf("%s (the entries of %s are not read yet)", n, bucket), ""
}
entryKey := c.Node + "." + module + "." + provision
if pr.Local != "" {
entryKey += "." + pr.Local
}
raw, ok := s.bound[bucket][entryKey]
if !ok {
return "", fmt.Sprintf("%s (%s on %s has not answered %s on %s yet)", n, pr.ProviderModule, pr.Provider,
module, c.Node), ""
}
var e boundEntry
if err := json.Unmarshal(raw, &e); err != nil {
return "", "", fmt.Sprintf("%s: the entry %s of %s is not the provider's", n, entryKey, bucket)
}
if e.Refused != "" {
return "", "", fmt.Sprintf("%s: %s on %s refused it: %s", n, pr.ProviderModule, pr.Provider, e.Refused)
}
switch key {
case "as":
if e.As != "" {
return e.As, "", ""
}
case "user":
if e.User != "" {
return e.User, "", ""
}
}
if v, ok := e.Values[key]; ok {
return plainly(v), "", ""
}
have := []string{}
if e.As != "" {
have = append(have, "as")
}
if e.User != "" {
have = append(have, "user")
}
have = append(have, sortedKeys(e.Values)...)
return "", "", fmt.Sprintf("%s: %s on %s serves %s no %q; it serves %s", n, pr.ProviderModule, pr.Provider, module,
key, strings.Join(have, ", "))
}
// plainly is a served value as a file takes it: a string as it is, a whole number without a fraction, anything else
// as its JSON (mesh-controller internal/catalogue plainly).
func plainly(v any) string {
switch x := v.(type) {
case string:
return x
case float64:
if x == float64(int64(x)) {
return strconv.FormatInt(int64(x), 10)
}
return strconv.FormatFloat(x, 'f', -1, 64)
case bool:
return strconv.FormatBool(x)
case nil:
return ""
}
raw, _ := json.Marshal(v)
return string(raw)
}
@@ -0,0 +1,262 @@
package main
import (
"context"
"encoding/json"
"fmt"
"strings"
"testing"
"time"
"github.com/nats-io/nats.go/jetstream"
)
// A provider's consumers' credentials, filled by its machine's client (novox/hq ADR 0373, issue 679), against a real
// bus whose accounts are the client's as mesh-controller composes them: a provider template with
// ${consumers:<provision>} is filled with one entry per bound consumer, in the plan's provisions order, each carrying
// the credential the vault holds for that pair; a consumer that binds or unbinds, or a credential the vault remakes,
// changes the file within one watch; no value crosses the bus in the clear. And a consumer's ${bound:…} is filled from
// its provider's entry (mesh-sdk go/provider, issue 678).
const consumersFile = "/var/lib/postgres/consumers.json"
// registryOf679 writes the registry's two answers this issue adds, as mesh-provisioner writes them.
func registryOf679(t *testing.T, b *aBus) {
t.Helper()
b.put(t, RegistryBucket, "consumers", `{"kind":"consumers","domain":"mesh-vault","from":"ask","ask":"mesh-vault.get"}`)
b.put(t, RegistryBucket, "bound", `{"kind":"bound","domain":"provider","from":"bucket",`+
`"bucket":"<provider-module>_bound","key":"<node>.<module>.<provision>[.<local>]"}`)
}
// bind writes another machine's module's assignment, whose plan binds it to providers, as the controller does.
func (b *aBus) bind(t *testing.T, at, module string, provisions ...PlannedProvision) {
t.Helper()
ps, err := json.Marshal(provisions)
if err != nil {
t.Fatal(err)
}
b.put(t, AssignmentsBucket, at+"."+module, fmt.Sprintf(`{"node":%q,"module":%q,"assigned":"2026-10-14T20:00:00Z",`+
`"provisioning":{"templates":{"source":"x","digest":"sha256:%s"},"files":[{"id":"c","path":"/etc/c",`+
`"template":"c","owner-at-runtime":"owned"}],"provisions":%s}}`, at, module, strings.Repeat("0", 64), ps))
}
func postgresPlan(t *testing.T, text string) Plan {
tpl, _ := templates(t, map[string]string{"consumers.json": text})
return Plan{Templates: tpl, Files: []PlannedFile{{ID: "consumers", Path: consumersFile, Template: "consumers.json",
OwnerAtRuntime: "owned"}}}
}
func fromHere(local string) PlannedProvision {
return PlannedProvision{Provision: "postgres-database", Local: local, Provider: node, ProviderModule: "postgres"}
}
func pairOf(at, module, local string) string {
n := "pair/" + node + "/postgres-database/" + at + "/" + module
if local != "" {
n += "/" + local
}
return n
}
type listed struct {
Consumer string `json:"consumer"`
Module string `json:"module"`
Local string `json:"local,omitempty"`
Credential string `json:"credential"`
Value string `json:"value"`
}
func consumersIn(t *testing.T, e *engine) []listed {
t.Helper()
var out []listed
if err := json.Unmarshal([]byte(e.read(t, consumersFile)), &out); err != nil {
return nil
}
return out
}
func sameList(have []listed, want ...listed) bool {
if len(have) != len(want) {
return false
}
for i := range want {
if have[i] != want[i] {
return false
}
}
return true
}
func TestNatsAProvidersConsumersAreFilledFromTheVaultAndFollowEachBindAndRemake(t *testing.T) {
b := startBus(t)
registryOf679(t, b)
heard := b.listen(t)
wire := sniff(t, b)
vault := startVault(t, b)
vault.keep(pairOf("ace", "letta", ""), "letta-pw-never-on-the-bus", false)
vault.keep(pairOf("shanks", "gitea", "cache"), "gitea-pw-never-on-the-bus", false)
// Bound to this provider: ace's letta, and shanks's gitea under a local name. Not: a provision of another
// provider, another provision, and a credential its provider's software issued per holder (issue 757).
b.bind(t, "ace", "letta", fromHere(""),
PlannedProvision{Provision: "postgres-database", Local: "old", Provider: "ace", ProviderModule: "postgres"})
b.bind(t, "shanks", "gitea", PlannedProvision{Provision: "redis", Provider: node, ProviderModule: "postgres"},
fromHere("cache"),
PlannedProvision{Name: "token", Provision: "forge-read", Provider: node, ProviderModule: "postgres"})
// Nor a bind whose offer names another credential form than a login per consumer (issue 768).
b.bind(t, "ace", "shared", PlannedProvision{Provision: "postgres-database", Provider: node,
ProviderModule: "postgres", CredentialForm: "shared"})
e := startEngine(t, map[string]string{consumersFile: "owned"})
b.assign(t, "postgres", postgresPlan(t, "${consumers:postgres-database}"))
aClient(t, b, e, t.TempDir(), time.Hour)
letta := listed{Consumer: "ace", Module: "letta", Credential: pairOf("ace", "letta", ""),
Value: "letta-pw-never-on-the-bus"}
gitea := listed{Consumer: "shanks", Module: "gitea", Local: "cache", Credential: pairOf("shanks", "gitea", "cache"),
Value: "gitea-pw-never-on-the-bus"}
eventually(t, "the file lists each bound consumer with its credential, in the plans' order", func() bool {
return sameList(consumersIn(t, e), letta, gitea)
})
eventually(t, "prov.applied says applied", func() bool {
r, _ := heard.last("postgres")
return r.Status == StatusApplied
})
// A consumer binds, on this machine: listed once the vault holds its credential; until then left out, and the
// others kept.
b.bind(t, node, "photos", fromHere(""))
time.Sleep(300 * time.Millisecond)
if !sameList(consumersIn(t, e), letta, gitea) {
t.Fatalf("a consumer whose credential the vault does not hold yet changed the file: %+v", consumersIn(t, e))
}
vault.keep(pairOf(node, "photos", ""), "photos-pw-never-on-the-bus", true)
photos := listed{Consumer: node, Module: "photos", Credential: pairOf(node, "photos", ""),
Value: "photos-pw-never-on-the-bus"}
eventually(t, "a consumer that binds is listed when the vault holds its credential, in its assignment's order",
func() bool { return sameList(consumersIn(t, e), letta, photos, gitea) })
// The vault remakes a credential.
vault.keep(pairOf("ace", "letta", ""), "letta-pw-remade-never-on-the-bus", true)
letta.Value = "letta-pw-remade-never-on-the-bus"
eventually(t, "a credential the vault remakes reaches the file", func() bool {
return sameList(consumersIn(t, e), letta, photos, gitea)
})
// A consumer unbinds.
b.del(t, AssignmentsBucket, "shanks.gitea")
eventually(t, "a consumer that unbinds leaves the file", func() bool {
return sameList(consumersIn(t, e), letta, photos)
})
// Another provider's consumer's credential changing is no reason to ask.
before := vault.times(pairOf("ace", "letta", ""))
vault.keep("pair/ace/postgres-database/ace/letta/old", "elsewhere", true)
time.Sleep(300 * time.Millisecond)
if vault.times("pair/ace/postgres-database/ace/letta/old") != 0 || vault.times(pairOf("ace", "letta", "")) != before {
t.Error("a change of another provider's consumer made the client ask")
}
if wire.heard() == 0 {
t.Fatal("the sniffer heard nothing: it proves nothing")
}
for _, value := range []string{"letta-pw", "gitea-pw", "photos-pw"} {
if said := wire.carrying(value); len(said) > 0 {
t.Errorf("a consumer's credential crossed the bus in the clear: %q", said)
}
}
// The need is recorded as any other, by name: never a value.
if keys := b.keys(t, NeedsBucket, node+".postgres."); len(keys) != 1 ||
keys[0] != node+".postgres.consumers.postgres-database" {
t.Errorf("the provider's needs are %v", keys)
}
}
// A provider with no consumer bound is filled with an empty list, never left waiting.
func TestNatsAProviderWithNoConsumerIsFilledWithAnEmptyList(t *testing.T) {
b := startBus(t)
registryOf679(t, b)
startVault(t, b)
e := startEngine(t, map[string]string{consumersFile: "owned"})
b.assign(t, "postgres", postgresPlan(t, "${consumers:postgres-database}"))
aClient(t, b, e, t.TempDir(), time.Hour)
eventually(t, "the file holds an empty list", func() bool { return e.read(t, consumersFile) == "[]" })
}
// An item is kept in a bucket, where no secret is: an item naming its consumers fails.
func TestAnItemNamingConsumersIsRefused(t *testing.T) {
p := Plan{Items: []PlannedItem{{Collection: "c", Template: "item"}}}
if _, why := itemTargets(p, map[string]string{"item": "${consumers:postgres-database}"}); !strings.Contains(why,
"no secret") {
t.Fatalf("an item naming consumers: %q", why)
}
}
const appBound = "/etc/app/database.env"
func appPlan(t *testing.T) Plan {
tpl, _ := templates(t, map[string]string{"database.env": "HOST=${bound:postgres-database:host}\n" +
"PORT=${bound:postgres-database:port}\nUSER=${bound:postgres-database:user}\n"})
return Plan{Templates: tpl, Files: []PlannedFile{{ID: "db", Path: appBound, Template: "database.env",
OwnerAtRuntime: "owned"}},
Provisions: []PlannedProvision{{Provision: "postgres-database", Provider: "novox", ProviderModule: "postgres"}}}
}
func TestNatsAConsumersBoundValuesAreFilledFromItsProvidersEntry(t *testing.T) {
b := startBus(t)
registryOf679(t, b)
heard := b.listen(t)
e := startEngine(t, map[string]string{appBound: "owned"})
b.assign(t, "app", appPlan(t))
aClient(t, b, e, t.TempDir(), time.Hour)
// No bucket of the provider's yet: waited for, and said.
eventually(t, "the consumer waits for its provider's bucket", func() bool {
r, _ := heard.last("app")
return r.Status == StatusWaiting && strings.Contains(strings.Join(r.WaitsFor, " "), "postgres_bound")
})
kv, err := b.domain.CreateKeyValue(context.Background(), jetstream.KeyValueConfig{Bucket: "postgres_bound"})
if err != nil {
t.Fatal(err)
}
b.kv["postgres_bound"] = kv
// The provider has not answered it yet: waited for.
b.put(t, "postgres_bound", "ace.app.postgres-database", `{"values":{"host":"elsewhere"}}`)
b.assign(t, "app", appPlan(t)) // a change of its own, so it looks again before the drift pass
eventually(t, "the consumer waits for its own entry", func() bool {
r, _ := heard.last("app")
return r.Status == StatusWaiting && strings.Contains(strings.Join(r.WaitsFor, " "), "postgres on novox")
})
b.put(t, "postgres_bound", node+".app.postgres-database", `{"provider":"novox","provider-module":"postgres",`+
`"provision":"postgres-database","consumer":"g14","module":"app","as":"mesh_g14_app","user":"mesh_g14_app",`+
`"values":{"host":"novox.mesh","port":5432},"credential":"pair/novox/postgres-database/g14/app"}`)
eventually(t, "the file is filled from its provider's entry", func() bool {
return e.read(t, appBound) == "HOST=novox.mesh\nPORT=5432\nUSER=mesh_g14_app\n"
})
b.put(t, "postgres_bound", node+".app.postgres-database", `{"user":"mesh_g14_app",`+
`"values":{"host":"novox.lan","port":5432}}`)
eventually(t, "a changed entry refills the file", func() bool {
return e.read(t, appBound) == "HOST=novox.lan\nPORT=5432\nUSER=mesh_g14_app\n"
})
b.put(t, "postgres_bound", node+".app.postgres-database", `{"refused":"app on g14 keeps several holders"}`)
eventually(t, "an entry the provider refused fails the consumer with its reason", func() bool {
r, _ := heard.last("app")
return r.Status == StatusFailed && strings.Contains(r.Reason, "keeps several holders")
})
}
// A consumer whose plan names its login's file (issue 779) is still a bind of its provider: it is listed, where an
// entry naming a value its provider's software issued per holder (issue 757) is not.
func TestNatsAProviderListsAConsumerWhoseEntryNamesItsLoginFile(t *testing.T) {
b := startBus(t)
registryOf679(t, b)
vault := startVault(t, b)
vault.keep(pairOf("ace", "nextcloud", ""), "nc-pw-never-on-the-bus", false)
vault.keep(pairOf("ace", "forge", ""), "forge-pw", false)
b.bind(t, "ace", "nextcloud", PlannedProvision{Name: "database", Provision: "postgres-database", Provider: node,
ProviderModule: "postgres", CredentialForm: "pair", Path: "/s/database.secret"})
b.bind(t, "ace", "forge", PlannedProvision{Name: "database", Provision: "postgres-database", Provider: node,
ProviderModule: "postgres", CredentialForm: "issued", Path: "/s/forge.secret"})
e := startEngine(t, map[string]string{consumersFile: "owned"})
b.assign(t, "postgres", postgresPlan(t, "${consumers:postgres-database}"))
aClient(t, b, e, t.TempDir(), time.Hour)
eventually(t, "the consumer with a login file is listed, the per-holder one is not", func() bool {
return sameList(consumersIn(t, e), listed{Consumer: "ace", Module: "nextcloud",
Credential: pairOf("ace", "nextcloud", ""), Value: "nc-pw-never-on-the-bus"})
})
}
@@ -0,0 +1,234 @@
package main
import (
"cmp"
"fmt"
"regexp"
"slices"
"strings"
)
// The session environment as a collection (novox/hq ADR 0203, ADR 0349 clause 13, design 57 L3, issue 795). One
// collection, `environment`, whose items are typed by attributes: an item is a variable (`kind=variable`,
// `name=<NAME>`) or a PATH entry (`kind=path`, `at=start|end`), and its template's text is the variable's value or
// the entry. The collector names the rendering, not a selector: `${collected:environment:posix}` and
// `${collected:environment:systemd}` (and the old `${environment:posix|systemd}`) are the collection `environment`
// written for a POSIX shell or for environment.d. Both renderings are the ones mesh-controller internal/catalogue
// environment_into.go writes for a collector not handed, byte for byte (its golden files are this client's).
// Environment collection and renderers.
const (
EnvironmentCollection = "environment"
EnvironmentPOSIX = "posix"
EnvironmentSystemd = "systemd"
)
// environmentName is an environment variable's name, which only this collection's `name` attribute may be.
var environmentName = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
// attributeFits is whether an item's attribute is of the shape the collection allows.
func attributeFits(collection, k, v string) bool {
if collection == EnvironmentCollection && k == "name" {
return environmentName.MatchString(v)
}
return attributeKeyShape.MatchString(k) && attributeValueShape.MatchString(v)
}
// environmentRenderer is the rendering a need names, when it is the environment's: `${collected:environment:posix}`
// or `${environment:posix}`.
func environmentRenderer(n Need) (string, bool) {
name := n.Name
switch {
case n.Kind == "environment":
case n.Kind == CollectedKind:
rest, is := strings.CutPrefix(name, EnvironmentCollection+":")
if !is {
return "", false
}
name = rest
default:
return "", false
}
return name, name == EnvironmentPOSIX || name == EnvironmentSystemd
}
type envVariable struct{ module, name, value string }
type envEntry struct {
module, entry string
order int
item string
}
// literalProblem is why a value cannot be written, unquoted by either reader, as the same string in both formats
// (mesh-controller internal/catalogue environment_into.go).
func literalProblem(v string) string {
switch {
case strings.ContainsAny(v, `'"`):
return "holds a quote"
case strings.Contains(v, `\`):
return "holds a backslash"
case strings.ContainsAny(v, "\n\r"):
return "holds a line break"
case strings.ContainsRune(v, 0):
return "holds a NUL"
case strings.Contains(v, "$"):
return "holds a $, which the two readers expand differently"
}
return ""
}
// environmentText is the collection's items written for one renderer: modules in name order, each module's variables
// sorted by name under a line naming it, then PATH's entries, each once where the first puts it, as the old form
// wrote them. An empty collection is empty text.
func environmentText(items map[string]itemValue, renderer string) (string, error) {
type owned struct {
id string
itemValue
}
var all []owned
for id, it := range items {
all = append(all, owned{id, it})
}
slices.SortFunc(all, func(a, b owned) int {
ma, na := splitID(a.id)
mb, nb := splitID(b.id)
return cmp.Or(cmp.Compare(ma, mb), cmp.Compare(na, nb))
})
var variables []envVariable
var start, end []envEntry
setter := map[string]string{}
placed := map[string]bool{}
// Items of one module in the order the old form declared them: by order, then name.
slices.SortStableFunc(all, func(a, b owned) int {
ma, _ := splitID(a.id)
mb, _ := splitID(b.id)
if c := cmp.Compare(ma, mb); c != 0 {
return c
}
if c := cmp.Compare(a.order(), b.order()); c != 0 {
return c
}
_, na := splitID(a.id)
_, nb := splitID(b.id)
return cmp.Compare(na, nb)
})
for _, it := range all {
module, _ := splitID(it.id)
value := strings.TrimSuffix(it.text, "\n")
if why := literalProblem(value); why != "" {
return "", fmt.Errorf("%s's item %s is %q on this machine, which %s: a value is a literal both readers "+
"take alike", module, it.id, value, why)
}
switch it.attributes["kind"] {
case "variable":
n := it.attributes["name"]
if n == "" {
return "", fmt.Errorf("%s's item %s is a variable and has no name", module, it.id)
}
if first, taken := setter[n]; taken {
return "", fmt.Errorf("%s and %s both set %s on this machine; the account has one environment, so "+
"one of them must stop setting it (novox/hq ADR 0203)", first, module, n)
}
setter[n] = module
variables = append(variables, envVariable{module, n, value})
case "path":
if strings.Contains(value, ":") {
return "", fmt.Errorf("%s puts %q on PATH on this machine, which holds a colon, PATH's own separator",
module, value)
}
if value == "" {
return "", fmt.Errorf("%s's item %s is a PATH entry and says none", module, it.id)
}
if placed[value] {
continue
}
placed[value] = true
e := envEntry{module: module, entry: value, order: it.order(), item: it.id}
switch it.attributes["at"] {
case "start":
start = append(start, e)
case "end":
end = append(end, e)
default:
return "", fmt.Errorf("%s's item %s is a PATH entry and is not at start or end", module, it.id)
}
default:
return "", fmt.Errorf("%s's item %s is of the environment and is not kind=variable or kind=path", module,
it.id)
}
}
// Variables per module, sorted by name within it.
slices.SortStableFunc(variables, func(a, b envVariable) int {
return cmp.Or(cmp.Compare(a.module, b.module), cmp.Compare(a.name, b.name))
})
if renderer == EnvironmentSystemd {
return systemdText(variables, start, end), nil
}
return posixText(variables, start, end), nil
}
func posixText(variables []envVariable, start, end []envEntry) string {
var b strings.Builder
named := ""
for _, v := range variables {
if v.module != named {
fmt.Fprintf(&b, "# %s\n", v.module)
named = v.module
}
fmt.Fprintf(&b, "export %s='%s'\n", v.name, v.value)
}
named = ""
for i := len(start) - 1; i >= 0; i-- {
p := start[i]
if p.module != named {
fmt.Fprintf(&b, "# %s\n", p.module)
named = p.module
}
fmt.Fprintf(&b, "case \":${PATH}:\" in *':%s:'*) ;; *) PATH='%s'\"${PATH:+:${PATH}}\" ;; esac\n",
p.entry, p.entry)
}
named = ""
for _, p := range end {
if p.module != named {
fmt.Fprintf(&b, "# %s\n", p.module)
named = p.module
}
fmt.Fprintf(&b, "case \":${PATH}:\" in *':%s:'*) ;; *) PATH=\"${PATH:+${PATH}:}\"'%s' ;; esac\n",
p.entry, p.entry)
}
if len(start)+len(end) > 0 {
b.WriteString("export PATH\n")
}
return b.String()
}
func systemdText(variables []envVariable, start, end []envEntry) string {
var b strings.Builder
named := ""
for _, v := range variables {
if v.module != named {
fmt.Fprintf(&b, "# %s\n", v.module)
named = v.module
}
fmt.Fprintf(&b, "%s=%s\n", v.name, v.value)
}
line := func(entries []envEntry, format string) {
if len(entries) == 0 {
return
}
var modules, paths []string
seen := map[string]bool{}
for _, p := range entries {
if !seen[p.module] {
seen[p.module] = true
modules = append(modules, p.module)
}
paths = append(paths, p.entry)
}
fmt.Fprintf(&b, "# %s\n"+format+"\n", strings.Join(modules, ", "), strings.Join(paths, ":"))
}
line(start, "PATH=%s${PATH:+:$PATH}")
line(end, "PATH=${PATH:+$PATH:}%s")
return b.String()
}
@@ -0,0 +1,192 @@
package main
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// The session environment's collection (design 57 L3, issue 795), filled by this client from typed items, is what the
// controller composed from the old forms before the move. testdata/environment holds that composition for a machine
// with the five contributors and for one with zsh alone; mesh-controller's internal/catalogue
// TestTheEnvironmentOfTheCatalogueIsTheSameHandedOrNot holds the not handed half against the same files.
type envItem struct{ module, name, kind, attr, value string }
// theCataloguesEnvironment is the five contributors' environment as the catalogue declared it before the move.
var theCataloguesEnvironment = []envItem{
{"adwaita", "gtk-theme", "variable", "GTK_THEME", "Adwaita:dark"},
{"adwaita", "gtk2-rc-files", "variable", "GTK2_RC_FILES", "/usr/share/themes/Adwaita-dark/gtk-2.0/gtkrc"},
{"adwaita", "qt-qpa-platformtheme", "variable", "QT_QPA_PLATFORMTHEME", "qt6ct"},
{"adwaita", "qt-style-override", "variable", "QT_STYLE_OVERRIDE", "Fusion"},
{"adwaita", "qt-select", "variable", "QT_SELECT", "6"},
{"adwaita", "xcursor-theme", "variable", "XCURSOR_THEME", "Adwaita"},
{"adwaita", "xcursor-size", "variable", "XCURSOR_SIZE", "24"},
{"clipmenu", "cm-selections", "variable", "CM_SELECTIONS", "clipboard"},
{"clipmenu", "cm-max-clips", "variable", "CM_MAX_CLIPS", "500"},
{"clipmenu", "cm-histlength", "variable", "CM_HISTLENGTH", "15"},
{"i3", "xdg-current-desktop", "variable", "XDG_CURRENT_DESKTOP", "i3"},
{"i3", "xdg-session-desktop", "variable", "XDG_SESSION_DESKTOP", "i3"},
{"xterm", "terminal", "variable", "TERMINAL", "xterm"},
{"zsh", "editor", "variable", "EDITOR", "vim"},
{"zsh", "visual", "variable", "VISUAL", "vim"},
{"zsh", "xdg-config-home", "variable", "XDG_CONFIG_HOME", "${machine:account-home}/.config"},
{"zsh", "path-1", "path:10", "", "${machine:account-home}/.local/bin"},
{"zsh", "path-2", "path:20", "", "${machine:account-home}/scripts"},
{"zsh", "path-3", "path:30", "", "${machine:account-home}/scripts/bin"},
}
func envPlans(t *testing.T, items []envItem) (collector Plan, contributors map[string]Plan) {
t.Helper()
posix := "# header\n${collected:environment:posix}"
systemd := "${environment:systemd}"
tpl, _ := templates(t, map[string]string{"posix": posix, "systemd": systemd})
facts := map[string]string{"machine:account-home": "/home/op"}
collector = Plan{Templates: tpl, Placement: facts,
Files: []PlannedFile{{ID: "posix", Path: "/env/environment.sh", Template: "posix", OwnerAtRuntime: "owned"},
{ID: "systemd", Path: "/env/50-mesh.conf", Template: "systemd", OwnerAtRuntime: "owned"}},
Collections: []PlannedCollection{{Name: "environment", Comment: "#"}}}
texts := map[string]map[string]string{}
planned := map[string][]PlannedItem{}
for _, it := range items {
if texts[it.module] == nil {
texts[it.module] = map[string]string{}
}
texts[it.module]["env-"+it.name] = it.value + "\n"
attrs := map[string]string{"kind": "variable", "name": it.attr}
if strings.HasPrefix(it.kind, "path") {
attrs = map[string]string{"kind": "path", "at": "start", "order": strings.TrimPrefix(it.kind, "path:")}
}
planned[it.module] = append(planned[it.module], PlannedItem{Collection: "environment", Name: it.name,
Attributes: attrs, Template: "env-" + it.name})
}
contributors = map[string]Plan{}
for m := range texts {
tp, _ := templates(t, texts[m])
contributors[m] = Plan{Templates: tp, Items: planned[m], Placement: facts}
}
return collector, contributors
}
func TestNatsTheEnvironmentFilledFromTypedItemsIsWhatTheOldContributionMade(t *testing.T) {
for name, only := range map[string]string{"all": "", "zsh": "zsh"} {
t.Run(name, func(t *testing.T) {
var items []envItem
for _, it := range theCataloguesEnvironment {
if only == "" || it.module == only {
items = append(items, it)
}
}
collector, contributors := envPlans(t, items)
b := startBus(t)
e := startEngine(t, map[string]string{"/env/environment.sh": "owned", "/env/50-mesh.conf": "owned"})
b.assign(t, "node-env", collector)
for m, p := range contributors {
b.assign(t, m, p)
}
aClient(t, b, e, t.TempDir(), time.Hour)
for file, golden := range map[string]string{"/env/environment.sh": "posix", "/env/50-mesh.conf": "systemd"} {
want, err := os.ReadFile(filepath.Join("testdata", "environment", name+"."+golden))
if err != nil {
t.Fatal(err)
}
prefix := ""
if golden == "posix" {
prefix = "# header\n"
}
eventually(t, file+" is what it was", func() bool { return e.read(t, file) == prefix+string(want) })
}
})
}
}
func TestTheEnvironmentRefusesAVariableTwoModulesSetAndAColonOnPATH(t *testing.T) {
items := map[string]itemValue{
"a": {text: "1\n", attributes: map[string]string{"kind": "variable", "name": "X"}},
"b": {text: "2\n", attributes: map[string]string{"kind": "variable", "name": "X"}},
}
if _, err := environmentText(items, EnvironmentPOSIX); err == nil || !strings.Contains(err.Error(), "both set X") {
t.Fatalf("two modules setting X: %v", err)
}
items = map[string]itemValue{"a": {text: "/x:/y\n", attributes: map[string]string{"kind": "path", "at": "end"}}}
if _, err := environmentText(items, EnvironmentSystemd); err == nil || !strings.Contains(err.Error(), "colon") {
t.Fatalf("a colon on PATH: %v", err)
}
if !attributeFits("environment", "name", "GTK_THEME") || attributeFits("shell:zsh:normal", "name", "GTK_THEME") {
t.Fatal("an upper-case name is the environment's alone")
}
}
// The catalogue's own modules, as they are declared now: node-env's two files and the five contributors' items, read
// from their module.json and templates, fill to the files the old form composed (issue 795).
func TestNatsTheEnvironmentOfTheCataloguesModulesIsWhatTheOldFormComposed(t *testing.T) {
modules := filepath.Join("..", "..", "..")
type item struct {
Collection string `json:"collection"`
Name string `json:"name"`
Attributes map[string]string `json:"attributes"`
Template string `json:"template"`
}
read := func(path string) []byte {
b, err := os.ReadFile(filepath.Join(modules, path))
if err != nil {
t.Fatal(err)
}
return b
}
for name, only := range map[string][]string{"all": {"adwaita", "clipmenu", "i3", "xterm", "zsh"}, "zsh": {"zsh"}} {
t.Run(name, func(t *testing.T) {
facts := map[string]string{"machine:account-home": "/home/op"}
tpl, _ := templates(t, map[string]string{"posix": string(read("node-env/files/environment.sh")),
"systemd": string(read("node-env/files/50-mesh.conf"))})
b := startBus(t)
e := startEngine(t, map[string]string{"/env/environment.sh": "owned", "/env/50-mesh.conf": "owned"})
b.assign(t, "node-env", Plan{Templates: tpl, Placement: facts,
Files: []PlannedFile{{ID: "posix", Path: "/env/environment.sh", Template: "posix", OwnerAtRuntime: "owned"},
{ID: "systemd", Path: "/env/50-mesh.conf", Template: "systemd", OwnerAtRuntime: "owned"}},
Collections: []PlannedCollection{{Name: "environment", Comment: "#"}}})
for _, m := range only {
var manifest struct {
Moved bool `json:"moved"`
Items []item `json:"items"`
}
if err := json.Unmarshal(read(m+"/module.json"), &manifest); err != nil {
t.Fatal(err)
}
if !manifest.Moved || len(manifest.Items) == 0 {
t.Fatalf("%s is not a moved contributor of items", m)
}
texts := map[string]string{}
var planned []PlannedItem
for _, it := range manifest.Items {
id := filepath.Base(it.Template)
texts[id] = string(read(m + "/" + it.Template))
planned = append(planned, PlannedItem{Collection: it.Collection, Name: it.Name,
Attributes: it.Attributes, Template: id})
}
tp, _ := templates(t, texts)
b.assign(t, m, Plan{Templates: tp, Items: planned, Placement: facts})
}
aClient(t, b, e, t.TempDir(), time.Hour)
for file, golden := range map[string]string{"/env/environment.sh": "posix", "/env/50-mesh.conf": "systemd"} {
want, err := os.ReadFile(filepath.Join("testdata", "environment", name+"."+golden))
if err != nil {
t.Fatal(err)
}
// node-env's own files hold their header, which is the module's, then the collected text.
header := strings.SplitAfter(string(read("node-env/files/"+map[string]string{"posix": "environment.sh",
"systemd": "50-mesh.conf"}[golden])), "\n")
head := ""
for _, l := range header {
if strings.HasPrefix(l, "#") {
head += l
}
}
eventually(t, file+" is what it was", func() bool { return e.read(t, file) == head+string(want) })
}
})
}
}
@@ -0,0 +1,104 @@
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"net"
"strings"
"time"
)
// The node-engine's narrow local write verb (novox/hq ADR 0349 clause 4, design 57 P2; mesh-host internal/filling):
// one unix socket, one JSON request a line — a filled file's path and its content — and one answer. **The client
// fills; the node-engine writes**, as the declared file, with its owner, mode and who owns it at runtime: an owned
// file whole and written back whenever it differs, a seed only when absent, a managed file's own keys or block
// only. And it makes what reads the file take the change (restart-on, reload-on, a container that reads it). So
// the client never writes a machine's file itself and needs no right to.
//
// The request and the answer are copied here, field for field, from mesh-host internal/filling: the client is
// the one other reader of that format, and the node-engine's decoder refuses a field it does not know.
// DefaultSocket is where the node-engine serves the write.
const DefaultSocket = "/run/nox-mesh-host/fill.sock"
// FillRequest is one write.
type FillRequest struct {
Path string `json:"path"`
Content []byte `json:"content"`
}
// FillAnswer is what came of it: Refused is a write not attempted, Failed one attempted that did not complete,
// each with why; otherwise Action is what the write did (created, updated, corrected, unchanged, kept) and Digest
// what the file holds now.
type FillAnswer struct {
Path string `json:"path,omitempty"`
Action string `json:"action,omitempty"`
Digest string `json:"digest,omitempty"`
Refused string `json:"refused,omitempty"`
Failed string `json:"failed,omitempty"`
}
// Filler has the node-engine write a filled file.
type Filler interface {
Fill(ctx context.Context, path string, content []byte) (FillAnswer, error)
}
// SocketFiller is the node-engine's socket.
type SocketFiller struct {
Socket string
// Busy is how long a write the node-engine says it is busy for waits before it is asked again, and Tries how
// many times it is asked.
Busy time.Duration
Tries int
}
// Fill asks the node-engine to write one file, again while it says it is busy.
func (s SocketFiller) Fill(ctx context.Context, path string, content []byte) (FillAnswer, error) {
tries := max(s.Tries, 1)
var a FillAnswer
var err error
for i := 0; i < tries; i++ {
if a, err = s.once(ctx, path, content); err != nil || !strings.HasPrefix(a.Refused, "busy") {
return a, err
}
select {
case <-ctx.Done():
return a, ctx.Err()
case <-time.After(s.Busy):
}
}
return a, err
}
func (s SocketFiller) once(ctx context.Context, path string, content []byte) (FillAnswer, error) {
var d net.Dialer
conn, err := d.DialContext(ctx, "unix", s.Socket)
if err != nil {
return FillAnswer{}, fmt.Errorf("the node-engine's write socket %s: %w", s.Socket, err)
}
defer conn.Close()
if deadline, ok := ctx.Deadline(); ok {
_ = conn.SetDeadline(deadline)
}
if err := json.NewEncoder(conn).Encode(FillRequest{Path: path, Content: content}); err != nil {
return FillAnswer{}, fmt.Errorf("asking the node-engine to write %s: %w", path, err)
}
var a FillAnswer
if err := json.NewDecoder(conn).Decode(&a); err != nil {
return FillAnswer{}, fmt.Errorf("the node-engine's answer for %s: %w", path, err)
}
return a, nil
}
// errFill is a write the node-engine refused or failed, with its word.
func errFill(a FillAnswer) error {
switch {
case a.Refused != "":
return errors.New("the node-engine refused it: " + a.Refused)
case a.Failed != "":
return errors.New("the node-engine's write failed: " + a.Failed)
}
return nil
}
@@ -0,0 +1,90 @@
package main
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// i3's window-manager configuration, filled by this client from the items of the collection
// contribution:node-display-session:config that its contributors declare, is byte for byte what the controller
// composed from their old contributions before the move (design 57 L3, issue 848). The module files are the
// catalogue's own; testdata/i3 holds the configuration as the controller composed it, for a machine with all nine
// contributors (dunst, rofi, i3status-rust, asus-zephyrus-g14, clipmenu, feh, slack, polychromatic and i3 itself) and for
// one with only i3. The golden files were composed once, by a one-off run of mesh-controller's catalogue at the
// feature branch's head over the catalogue as it was before the move; the controller's composition for a collector
// not handed, from the same items, was compared with them in the same way and is byte for byte the same.
func TestNatsI3sConfigFilledFromItsContributorsItemsIsWhatTheOldContributionsMade(t *testing.T) {
modules := filepath.Join("..", "..", "..")
read := func(path string) string {
b, err := os.ReadFile(filepath.Join(modules, path))
if err != nil {
t.Fatal(err)
}
return string(b)
}
const collection = "contribution:node-display-session:config"
all := []string{"asus-zephyrus-g14", "clipmenu", "dunst", "feh", "i3status-rust", "polychromatic", "rofi", "slack"}
for name, contributors := range map[string][]string{"all": all, "i3-only": nil} {
t.Run(name, func(t *testing.T) {
facts := map[string]string{"machine:account-home": "/home/op"}
tpl, _ := templates(t, map[string]string{"config": read("i3/files/config")})
collector := Plan{Templates: tpl, Placement: facts,
Files: []PlannedFile{{ID: "config", Path: "/i3/config", Template: "config", OwnerAtRuntime: "owned"}},
Collections: []PlannedCollection{{Name: collection, Comment: "#"}}}
b := startBus(t)
e := startEngine(t, map[string]string{"/i3/config": "owned"})
b.assign(t, "i3", collector)
for _, m := range contributors {
var c struct {
Moved bool `json:"moved"`
Contributions []any `json:"contributions"`
Items []struct {
Collection string `json:"collection"`
Name string `json:"name"`
Attributes map[string]string `json:"attributes"`
Template string `json:"template"`
} `json:"items"`
}
if err := json.Unmarshal([]byte(read(m+"/module.json")), &c); err != nil {
t.Fatal(err)
}
if !c.Moved || len(c.Contributions) != 0 {
t.Fatalf("%s is not moved or still holds contributions", m)
}
texts := map[string]string{}
var planned []PlannedItem
for _, it := range c.Items {
if it.Collection != collection {
continue
}
id := filepath.Base(it.Template)
texts[id] = read(m + "/" + it.Template)
planned = append(planned, PlannedItem{Collection: it.Collection, Name: it.Name, Template: id,
Attributes: it.Attributes})
}
if len(planned) != 1 {
t.Fatalf("%s declares %d items of the window-manager configuration, want 1", m, len(planned))
}
tp, _ := templates(t, texts)
b.assign(t, m, Plan{Templates: tp, Items: planned, Placement: facts})
}
aClient(t, b, e, t.TempDir(), time.Hour)
want, err := os.ReadFile(filepath.Join("testdata", "i3", name+".config"))
if err != nil {
t.Fatal(err)
}
// The golden holds asus-zephyrus-g14's directory as @ASUS_LIB@: the catalogue's path check keeps one module's
// path out of another module's files, and the golden is the one file that holds every contributor's lines.
golden := strings.ReplaceAll(string(want), "@ASUS_LIB@", "/usr/local/lib/asus-zephyrus-g14")
eventually(t, "i3's configuration is what it was", func() bool { return e.read(t, "/i3/config") == golden })
if t.Failed() {
t.Logf("got:\n%s", e.read(t, "/i3/config"))
}
})
}
}
@@ -0,0 +1,170 @@
// mesh-provisioner-client: the provisioner's client, holder of the node-provisioner seat on every node (novox/hq
// ADR 0349 clauses 3 to 5, design 57 P5, issue 671). A service of its own, outside the tool runner, on a bus account
// of its own (`provisioner.<node>`, mesh-controller internal/broker, design 57 P3): the vault hands a secret only to
// an account of its own, and the bus grants this one its machine's keys and nothing else. It fills the templates of
// each module handed to the provisioner on its node and has the node-engine write them (client.go).
package main
import (
"context"
"crypto/sha256"
"crypto/tls"
"crypto/x509"
"encoding/hex"
"encoding/json"
"errors"
"flag"
"fmt"
"os"
"os/signal"
"path/filepath"
"syscall"
"time"
"github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream"
)
func logf(format string, a ...any) { fmt.Fprintf(os.Stderr, format+"\n", a...) }
// Credential is the client's account as the controller delivers it, its own secret `provisioner-broker`
// (mesh-controller cmd/mesh-controller issueProvisionerClient): the bus, its certificate by fingerprint, the
// machine, the user and its password.
type Credential struct {
URL string `json:"url"`
Fingerprint string `json:"fingerprint,omitempty"`
Node string `json:"node"`
User string `json:"user"`
Password string `json:"password"`
}
func readCredential(path string) (Credential, error) {
raw, err := os.ReadFile(path)
if err != nil {
return Credential{}, fmt.Errorf("the client's account cannot be read: %w", err)
}
var c Credential
if err := json.Unmarshal(raw, &c); err != nil {
return Credential{}, errors.New("the client's account is not the JSON the mesh delivers (url, fingerprint, " +
"node, user, password)")
}
if c.URL == "" || c.Node == "" || c.User == "" || c.Password == "" {
return Credential{}, errors.New("the client's account names no bus, machine, user or password")
}
if c.User != "provisioner."+c.Node {
return Credential{}, fmt.Errorf("the account %s is not machine %s's provisioner client's", c.User, c.Node)
}
return c, nil
}
// pinnedTo accepts exactly the certificate with this SHA-256: the mesh's bus presents one of its own, in no trust
// store (mesh-controller internal/broker PinnedToFingerprint).
func pinnedTo(want string) *tls.Config {
return &tls.Config{
InsecureSkipVerify: true, //nolint:gosec // replaced by the pin below, which is stricter
MinVersion: tls.VersionTLS12,
VerifyPeerCertificate: func(rawCerts [][]byte, _ [][]*x509.Certificate) error {
if len(rawCerts) == 0 {
return errors.New("the bus presented no certificate")
}
sum := sha256.Sum256(rawCerts[0])
if got := "sha256:" + hex.EncodeToString(sum[:]); got != want {
return fmt.Errorf("the bus presented a certificate this mesh does not know (%s)", got)
}
return nil
},
}
}
func main() {
credential := flag.String("credential", os.Getenv("MESH_PROVISIONER_CREDENTIAL"),
"the client's account, its own secret provisioner-broker (MESH_PROVISIONER_CREDENTIAL)")
socket := flag.String("socket", DefaultSocket, "the node-engine's socket for writing a filled file")
stateDir := flag.String("state", os.Getenv("MESH_PROVISIONER_STATE"),
"where the client keeps the templates it fetched (MESH_PROVISIONER_STATE)")
drift := flag.Duration("drift", time.Minute, "how often every filled file is written again")
flag.Parse()
if *credential == "" || *stateDir == "" {
logf("usage: mesh-provisioner-client -credential <file> -state <dir>")
os.Exit(2)
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
if err := serve(ctx, *credential, *socket, *stateDir, *drift); err != nil && ctx.Err() == nil {
logf("[provisioner-client] %v", err)
os.Exit(1)
}
}
// serve connects and runs the client until ctx ends. A run that stops — a watch ended, the bus came back — is run
// again from the current values, after a wait that grows to a minute.
func serve(ctx context.Context, credential, socket, stateDir string, drift time.Duration) error {
c, err := readCredential(credential)
if err != nil {
return err
}
back := make(chan struct{}, 1)
opts := []nats.Option{
nats.UserInfo(c.User, c.Password),
nats.Name("mesh-provisioner-client " + c.Node),
// Answers come to the account's own inbox and no other (novox/hq design 25 §4).
nats.CustomInboxPrefix("_INBOX." + c.User),
nats.MaxReconnects(-1),
nats.ReconnectHandler(func(*nats.Conn) {
select {
case back <- struct{}{}:
default:
}
}),
}
if c.Fingerprint != "" {
opts = append(opts, nats.Secure(pinnedTo(c.Fingerprint)))
}
nc, err := nats.Connect(c.URL, opts...)
if err != nil {
return fmt.Errorf("cannot reach the bus at %s as %s: %w", c.URL, c.User, err)
}
defer nc.Close()
js, err := jetstream.New(nc)
if err != nil {
return err
}
// Its own key, for the vault to seal each secret to: made now, kept in memory alone.
secrets, err := NewVaultSecrets(nc, c.User)
if err != nil {
return err
}
client := &Client{Node: c.Node, JS: js, Conn: nc, Drift: drift, Secrets: secrets,
Fill: SocketFiller{Socket: socket, Busy: time.Second, Tries: 10},
Templates: &templateStore{dir: filepath.Join(stateDir, "templates"), fetch: HTTPFetch}}
wait := time.Second
for {
run, cancel := context.WithCancel(ctx)
go func() {
select {
case <-back:
logf("[provisioner-client] the bus is back; filling from the current values")
cancel()
case <-run.Done():
}
}()
started := time.Now()
err := client.Run(run)
cancel()
if ctx.Err() != nil {
return nil
}
if time.Since(started) > time.Minute {
wait = time.Second
}
if err != nil && !errors.Is(err, context.Canceled) {
logf("[provisioner-client] stopped (%v); running again in %s", err, wait)
}
select {
case <-ctx.Done():
return nil
case <-time.After(wait):
}
wait = min(2*wait, time.Minute)
}
}
@@ -0,0 +1,105 @@
package main
import (
"encoding/json"
"os"
"path/filepath"
"regexp"
"testing"
"time"
)
// The collectors design 57 L2 moves (issue 681): zsh, power and triggerhappy, each with every contributor the
// catalogue gives it, filled by this client from the modules' real templates, write the file the controller composed
// from the old forms before the move, byte for byte (composedBeforeTheMove).
// catalogueModules is where the catalogue's modules are, from this package.
const catalogueModules = "../../.."
// repoManifest is the part of a module's manifest in the catalogue this test reads.
type repoManifest struct {
Module string `json:"module"`
Templates string `json:"templates"`
Moved bool `json:"moved"`
Items []struct {
Collection string `json:"collection"`
Template string `json:"template"`
} `json:"items"`
}
var oldNamespacePlaceholder = regexp.MustCompile(`\$\{((?:shell|contribution):[^}]*)\}`)
func TestNatsTheCollectorsL2MovedFillFromTheirContributorsItemsAsTheyWereComposed(t *testing.T) {
collectors := []struct{ module, template, want string }{
{"zsh", "files/zshrc", "zsh.zshrc"},
{"triggerhappy", "files/triggers.conf", "triggerhappy.triggers.conf"},
{"power", "templates/mesh-power/moments/after-wake", "power.after-wake"},
}
paths, err := filepath.Glob(filepath.Join(catalogueModules, "*", "module.json"))
if err != nil || len(paths) == 0 {
t.Fatalf("no catalogue at %s: %v", catalogueModules, err)
}
var modules []repoManifest
for _, p := range paths {
raw, err := os.ReadFile(p)
if err != nil {
t.Fatal(err)
}
var m repoManifest
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatalf("%s: %v", p, err)
}
modules = append(modules, m)
}
for _, c := range collectors {
t.Run(c.module, func(t *testing.T) {
text, err := os.ReadFile(filepath.Join(catalogueModules, c.module, c.template))
if err != nil {
t.Fatal(err)
}
want, ok := composedBeforeTheMove[c.want]
if !ok {
t.Fatalf("no file composed before the move for %s", c.want)
}
path := "/etc/" + c.module + "/collected"
b := startBus(t)
e := startEngine(t, map[string]string{path: "owned"})
tpl, _ := templates(t, map[string]string{"file": string(text)})
collector := Plan{Templates: tpl, Files: []PlannedFile{{ID: "file", Path: path, Template: "file",
OwnerAtRuntime: "owned"}}}
names := map[string]bool{}
for _, f := range oldNamespacePlaceholder.FindAllStringSubmatch(string(text), -1) {
if !names[f[1]] {
names[f[1]] = true
// The controller plans an old namespace's collection with the comment it wrote: "#" for these.
collector.Collections = append(collector.Collections, PlannedCollection{Name: f[1], Comment: "#"})
}
}
b.assign(t, c.module, collector)
contributors := 0
for _, m := range modules {
for _, it := range m.Items {
if !names[it.Collection] {
continue
}
if !m.Moved {
t.Errorf("%s contributes an item and is not moved", m.Module)
}
item, err := os.ReadFile(filepath.Join(catalogueModules, m.Module, it.Template))
if err != nil {
t.Fatal(err)
}
b.assign(t, m.Module, contributor(t, it.Collection, string(item)))
contributors++
}
}
aClient(t, b, e, t.TempDir(), time.Hour)
eventually(t, c.module+"'s file as the controller composed it", func() bool {
return e.read(t, path) == want
})
if t.Failed() {
t.Logf("%d contributors; got:\n%s", contributors, e.read(t, path))
}
})
}
}
@@ -0,0 +1,10 @@
package main
// The files the controller composed from the old forms at the feature branch before design 57 L2 moved their
// collectors (issue 681): zsh's rc, triggerhappy's triggers and power's after-wake moment, every contributor placed.
// Kept in a test file, where the paths check (novox/hq ADR 0260) reads no other module's path.
var composedBeforeTheMove = map[string]string{
"zsh.zshrc": "# The mesh's block (module zsh, novox/hq ADR 0204): the defaults every machine shares, and the code\n# other modules contribute in three slots. It is replaced at every push. Everything below it is\n# yours, kept as you wrote it, and runs after it, so your lines win.\n\n# History, kept across sessions and shared between open shells. Without these zsh saves none:\n# SAVEHIST defaults to 0 (novox/hq to-be 41, found on review 2026-10-04).\nHISTFILE=\"$HOME/.zsh_history\"\nHISTSIZE=100000\nSAVEHIST=100000\nsetopt EXTENDED_HISTORY SHARE_HISTORY HIST_IGNORE_DUPS HIST_IGNORE_SPACE HIST_REDUCE_BLANKS\n\n# The completion system. Nothing initialised it, so only zsh's basic completion ran. Its cache\n# goes under ~/.cache rather than beside ~/.zshrc.\n[[ -d \"$HOME/.cache/zsh\" ]] || mkdir -p \"$HOME/.cache/zsh\"\nautoload -Uz compinit && compinit -d \"$HOME/.cache/zsh/zcompdump\"\nzstyle ':completion:*' menu select\n\n# Terminal title: host, directory, git branch\nfunction set_terminal_title() {\n local git_branch=\"\"\n if git rev-parse --is-inside-work-tree &>/dev/null; then\n git_branch=\" ($(git branch --show-current 2>/dev/null))\"\n fi\n print -Pn \"\\e]2;%m: %~${git_branch}\\a\"\n}\nprecmd_functions+=(set_terminal_title)\n\n# Keybindings\nbindkey \"^[[H\" beginning-of-line # Home\nbindkey \"^[OH\" beginning-of-line # Home\nbindkey \"^A\" beginning-of-line # Ctrl + A\nbindkey \"^[[F\" end-of-line # End\nbindkey \"^[OF\" end-of-line # End\nbindkey \"^E\" end-of-line # Ctrl + E\nbindkey \"^[[3~\" delete-char # Del\n\n# Colour support of ls and friends\nif [ -x /usr/bin/dircolors ]; then\n test -r $HOME/.dircolors && eval \"$(dircolors -b $HOME/.dircolors)\" || eval \"$(dircolors -b)\"\n alias ls='ls --color=auto'\n alias dir='dir --color=auto'\n alias vdir='vdir --color=auto'\n alias grep='grep --color=auto'\n alias fgrep='fgrep --color=auto'\n alias egrep='egrep --color=auto'\nfi\nalias ll='ls -alhF'\nalias la='ls -Ah'\nalias l='ls -CFh'\n\n# A throwaway container, and what takes the space under a directory\nalias drun='docker run -it --rm'\ndisksize() {\n du -h --max-depth=1 \"${1:-.}\" | sort -h\n}\nsudo-disksize() {\n sudo du -h --max-depth=1 \"${1:-.}\" | sort -h\n}\n\n# claude-code\n# The agent's session (module claude-code, novox/hq ADR 0266): where this machine names an account of the\n# agents' own, claude in an interactive zsh is claude-agent, which starts the session as that account. Where\n# agents run as the operator's account the file names that account, and nothing is added. claude-agent runs\n# the real claude: exec, sudo and its sh see no alias.\nif [[ -r /etc/claude-code/agent-account ]]; then\n\t() {\n\t\tlocal agent=$(</etc/claude-code/agent-account)\n\t\tif [[ -n $agent && $agent != $USERNAME ]]; then\n\t\t\talias claude=claude-agent\n\t\tfi\n\t}\nfi\n# mesh-cli\n# The operator's command (module mesh-cli, novox/hq ADR 0272): nox is mesh-cli, in interactive zsh only.\n# A script, sudo and a document meant to work everywhere say mesh-cli.\nalias nox=mesh-cli\n# powerlevel10k\n# The prompt: powerlevel10k v1.20.0, pinned in this module (novox/hq ADR 0205), and its configuration.\n[[ ! -f ~/.local/share/powerlevel10k/powerlevel10k.zsh-theme ]] || source ~/.local/share/powerlevel10k/powerlevel10k.zsh-theme\n[[ ! -f ~/.config/powerlevel10k/p10k.zsh ]] || source ~/.config/powerlevel10k/p10k.zsh\n# zsh-autosuggestions\n# Suggestions from history as you type, from the distribution's package.\n[[ ! -f /usr/share/zsh/plugins/zsh-autosuggestions/zsh-autosuggestions.zsh ]] || source /usr/share/zsh/plugins/zsh-autosuggestions/zsh-autosuggestions.zsh\n# zsh-syntax-highlighting\n# Syntax highlighting of the command line, from the distribution's package. Last, as upstream asks:\n# it wraps every widget defined before it.\n[[ ! -f /usr/share/zsh/plugins/zsh-syntax-highlighting/zsh-syntax-highlighting.zsh ]] || source /usr/share/zsh/plugins/zsh-syntax-highlighting/zsh-syntax-highlighting.zsh\n\n",
"triggerhappy.triggers.conf": "# The machine's hotkeys, written by the mesh (module triggerhappy, novox/hq ADR 0212). Replaced on\n# every push: a key is added by the module it belongs to, as a contribution to node-hotkeys.\n# A line is: the key, its state (1 pressed, 2 held, 0 released), the command.\n# asus-zephyrus-g14\n# The ROG Zephyrus G14's vendor keys, which reach no X client: media (the M-keys), panel brightness,\n# and the touchpad key. Each runs this module's own script, as the operator's account.\nKEY_PROG1\t1\t/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-session /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-media play-pause\nKEY_PROG3\t1\t/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-session /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-media previous\nKEY_PROG4\t1\t/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-session /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-media next\nKEY_BRIGHTNESSDOWN\t1\t/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-backlight -\nKEY_BRIGHTNESSDOWN\t2\t/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-backlight -\nKEY_BRIGHTNESSUP\t1\t/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-backlight +\nKEY_BRIGHTNESSUP\t2\t/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-backlight +\nKEY_F21\t1\t/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-touchpad reset\n",
"power.after-wake": "# What every module on this machine runs at the moment after-wake, as the mesh placed it (module power,\n# novox/hq ADR 0211). Replaced on every push: code is added by the module it belongs to, as a\n# contribution for this moment. Each module's piece runs on its own, as root, bounded in time.\n# asus-zephyrus-g14\n# The touchpad's settings again after waking, in the operator's session: X applies the module's\n# input class when the device appears, and this covers a wake that does not initialise it again.\n# Runs as root from the power module; the reset itself runs as the session's owner.\nsleep 2\nowner=$(ps -o user= -C i3 | head -n 1)\n[ -n \"$owner\" ] && runuser -u \"$owner\" -- /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-touchpad reset\ntrue\n",
}
@@ -0,0 +1,106 @@
package main
import (
"context"
"encoding/json"
"os"
"testing"
"time"
"github.com/nats-io/nats.go/jetstream"
)
// The forge path of feature 57 (issue 761): with gitea marked moved, one machine's client fills gitea's server.env and
// origins and claude-code's facts.json and gitea-read file from the bus alone, from the catalogue's own templates, to
// the text the controller composed for the same values before gitea was handed: the database and the public name as
// their providers' entries carry them, the console's port from node-tools, the secrets and the per-holder read
// credential from the vault.
//
// The controller's placement (nothing refused or unplaced) and the byte-identity of every file not handed are checked
// against the controller itself, not here: this module cannot import its internals (the report of issue 761).
func catalogFile(t *testing.T, path string) string {
t.Helper()
raw, err := os.ReadFile("../../../" + path)
if err != nil {
t.Fatal(err)
}
return string(raw)
}
// The two modules are handed to the provisioner only where their manifests say moved.
func TestGiteaAndClaudeCodeAreMarkedMoved(t *testing.T) {
for _, m := range []string{"gitea", "claude-code"} {
var manifest struct {
Moved bool `json:"moved"`
}
if err := json.Unmarshal([]byte(catalogFile(t, m+"/module.json")), &manifest); err != nil {
t.Fatal(err)
}
if !manifest.Moved {
t.Errorf("%s is not marked moved, so its machine's client is never handed its files", m)
}
}
}
func TestNatsAMovedGiteaAndClaudeCodeHaveTheirFilesFilledFromTheBus(t *testing.T) {
const (
serverEnv = "/etc/gitea/server.env"
origins = "/etc/gitea/origins"
facts = "/var/lib/claude-code/state/facts.json"
readCred = "/var/lib/claude-code/state/gitea-read.secret"
credential = "https://agent-g14:gta-never-on-the-bus@git.example.org"
)
b := startBus(t)
registryOf679(t, b)
vault := startVault(t, b)
vault.keep("own/g14/gitea/internal-token", "tok-never-on-the-bus", false)
vault.keep("pair/novox/postgres-database/g14/gitea", "pw-never-on-the-bus", false)
vault.keep(forgeHolder, credential, false)
e := startEngine(t, map[string]string{serverEnv: "owned", origins: "owned", facts: "owned", readCred: "owned"})
giteaTpl, _ := templates(t, map[string]string{"server.env": catalogFile(t, "gitea/files/server.env"),
"origins": catalogFile(t, "gitea/files/origins")})
b.assign(t, "gitea", Plan{Templates: giteaTpl, Files: []PlannedFile{
{ID: "env", Path: serverEnv, Template: "server.env", OwnerAtRuntime: "owned"},
{ID: "origins", Path: origins, Template: "origins", OwnerAtRuntime: "owned"}},
Secrets: []PlannedSecret{{Name: "internal-token"}},
Provisions: []PlannedProvision{
{Name: "postgres-database", Provision: "postgres-database", Provider: "novox", ProviderModule: "postgres",
CredentialForm: "pair"},
{Provision: "route", Provider: "novox", ProviderModule: "route-proxy"}}})
ccTpl, _ := templates(t, map[string]string{"facts.json": catalogFile(t, "claude-code/files/facts.json")})
b.assign(t, "claude-code", Plan{Templates: ccTpl, Files: []PlannedFile{
{ID: "facts", Path: facts, Template: "facts.json", OwnerAtRuntime: "owned"}},
Placement: map[string]string{"machine:name": node},
Provisions: []PlannedProvision{
{Provision: "mcp-endpoint", Provider: node, ProviderModule: "node-tools"},
{Name: "gitea-read", Provision: "gitea-read", Provider: "novox", Path: readCred}}})
for _, bucket := range []string{"postgres_bound", "route-proxy_bound", "node-tools_bound"} {
kv, err := b.domain.CreateKeyValue(context.Background(), jetstream.KeyValueConfig{Bucket: bucket})
if err != nil {
t.Fatal(err)
}
b.kv[bucket] = kv
}
b.put(t, "postgres_bound", node+".gitea.postgres-database", `{"provider":"novox","provider-module":"postgres",`+
`"provision":"postgres-database","consumer":"g14","module":"gitea","as":"mesh_g14_gitea","user":"mesh_g14_gitea",`+
`"values":{"at":"10.7.0.1","from":"novox","port":5432},"credential":"pair/novox/postgres-database/g14/gitea"}`)
b.put(t, "route-proxy_bound", node+".gitea.route", `{"provider":"novox","provider-module":"route-proxy",`+
`"provision":"route","consumer":"g14","module":"gitea","as":"mesh_g14_gitea",`+
`"values":{"name":"git.example.org","internal-name":"git.novox.internal","name-web":"git.example.org"}}`)
b.put(t, "node-tools_bound", node+".claude-code.mcp-endpoint", `{"provider":"g14","provider-module":"node-tools",`+
`"provision":"mcp-endpoint","consumer":"g14","module":"claude-code","as":"mesh_g14_claude-code",`+
`"values":{"port":4270}}`)
aClient(t, b, e, t.TempDir(), time.Hour)
for path, want := range map[string]string{
serverEnv: giteaBoundExpected("tok-never-on-the-bus", "pw-never-on-the-bus"),
origins: "https://git.example.org\n",
facts: "{\n \"node\": \"g14\",\n \"console\": \"http://127.0.0.1:4270/mcp\"\n}\n",
readCred: credential,
} {
eventually(t, path+" is filled to the controller's text", func() bool { return e.read(t, path) == want })
}
}
@@ -0,0 +1,335 @@
package main
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"path"
"path/filepath"
"regexp"
"slices"
"strconv"
"strings"
)
// The provisioner's plan of a module on this node (novox/hq ADR 0349 clause 4, design 57 P5): placement's owner,
// the controller, carries it in the module's assignment, `<node>.<module>` of mesh-controller_assignments
// (mesh-controller internal/inventory Provisioning), which this client reads for its own machine alone. An
// assignment with no plan is a module the controller has not handed to the provisioner: its files are the
// node-engine's, from the declaration, and this client leaves them alone (ADR 0349 clause 17). A module only pulled
// in by another on this node, and handed, has a value of its own there, which names what requires it and no time it
// was assigned (issue 765): read and filled the same way.
// Assignment is one value of the assignments mirror. **Lenient at the top**, so a field the controller adds to an
// assignment (design 54 §1) does not stop the client; **strict in the plan**, which is the client's to act on.
type Assignment struct {
Node string `json:"node"`
Module string `json:"module"`
Provisioning json.RawMessage `json:"provisioning,omitempty"`
}
// Plan is one module's plan on this node.
type Plan struct {
Templates Templates `json:"templates"`
Files []PlannedFile `json:"files"`
Placement map[string]string `json:"placement,omitempty"`
// Secrets are the module's own secrets its declaration on this node names (design 57 V4): each one its
// templates name as `${secret:<name>}`, and each it keeps in a file of its own at Path. The vault hands this
// client a secret only when the plan names it (mesh-vault askers.go).
Secrets []PlannedSecret `json:"secrets,omitempty"`
// Items are the module's items of collections (ADR 0349 clause 13, design 57 L1): each filled from its template
// like a file, and recorded in mesh-provisioner_collect for this node instead of written to a file. The collector
// of each, on this node, is filled from every contributor's item there.
Items []PlannedItem `json:"items,omitempty"`
// Collections are the collections the module's templates name, as their collector: `${collected:<name>}`, or
// one of the three old namespaces read as a collection of the same name (`${shell:zsh:normal}` is the collection
// `shell:zsh:normal`). Each with the comment its tool writes a line in, which introduces each contributor's
// item with the contributor's name; none for a tool without one.
Collections []PlannedCollection `json:"collections,omitempty"`
// Provisions are what the module requires on this node, each with the provider serving it (issue 760), which
// the vault acts on and this client only reads. An entry with a name is a credential this client fills from the
// vault (issue 757): the value a provider's software issued for this module on this machine. Its templates name
// one as `${secret:<name>}`, and Path, when set, is a file holding it alone. The vault hands one only when the
// plan names it (mesh-vault askers.go).
Provisions []PlannedProvision `json:"provisions,omitempty"`
}
// PlannedItem is one item the module contributes: to which collection, and the template it is filled from.
//
// A module may give several items to one collection, each with a Name of its own, and any with Attributes a
// placeholder selecting by them is matched against (design 57 L3, issue 777). An item whose capability the machine
// lacks is not in the plan: the controller judged that.
type PlannedItem struct {
Collection string `json:"collection"`
Name string `json:"name,omitempty"`
Attributes map[string]string `json:"attributes,omitempty"`
Template string `json:"template"`
}
// key is the item among its module's: the collection, and `/<name>` for a named item.
func (it PlannedItem) key() string {
if it.Name == "" {
return it.Collection
}
return it.Collection + "/" + it.Name
}
// PlannedCollection is one collection the module collects.
type PlannedCollection struct {
Name string `json:"name"`
Comment string `json:"comment,omitempty"`
}
// PlannedProvision is one provision the module requires, under its local name (empty for the ordinary one), and
// its provider: the machine and the module serving it. With a name, a credential of it this client fills: Name as
// the module's files name it (`${secret:<name>}`), and the file holding it alone, when there is one.
type PlannedProvision struct {
Name string `json:"name,omitempty"`
Provision string `json:"provision"`
Local string `json:"local,omitempty"`
Provider string `json:"provider"`
ProviderModule string `json:"provider-module,omitempty"`
// CredentialForm is the form the provider's offer declares for its credential (issue 768), which the vault acts
// on and this client only reads: one of credentialForms, or none from a controller before it.
CredentialForm string `json:"credential-form,omitempty"`
Path string `json:"path,omitempty"`
// Contributes is what this module tells the provider about itself for this provision (a database's name, its
// extensions): the consumer manifest's `contributes`, which the provider's helper reads and the client keeps.
Contributes map[string]any `json:"contributes,omitempty"`
}
// issuedForHolder is whether the entry names a value its provider's software issued for this holder alone (issue
// 757). An entry of the login-per-consumer form that names a file (issue 779) is still the bind it was: its provider
// lists this consumer and its bound values are read from it.
func (p PlannedProvision) issuedForHolder() bool { return p.Name != "" && p.CredentialForm != "pair" }
// credentialForms are the forms a provider's offer may declare for its credential (mesh-vault pair.go): one value
// every holder shares, a login per consumer, a value its software issues, or one a person gives.
var credentialForms = []string{"shared", "pair", "issued", "given"}
// PlannedSecret is one secret of the module's own, by its name in the module's manifest; Path, when set, is the
// file holding it alone (the manifest's `own-secrets`), which the client fills with its value and nothing else.
type PlannedSecret struct {
Name string `json:"name"`
Path string `json:"path,omitempty"`
}
// Templates is the archive the module's build packed its templates directory into: fetched from Source, and used only when it
// hashes to Digest.
type Templates struct {
Source string `json:"source"`
Digest string `json:"digest"`
}
// PlannedFile is one file the provisioner fills.
type PlannedFile struct {
ID string `json:"id"`
Path string `json:"path"`
Template string `json:"template"`
OwnerAtRuntime string `json:"owner-at-runtime"`
Takes string `json:"takes,omitempty"`
}
// The placeholder kinds placement answers, as the plan's Placement keys begin (ADR 0349 clause 2).
var placementKinds = []string{"dir", "access", "port", "seat", "machine", "backup"}
var (
digestShape = regexp.MustCompile(`^sha256:[0-9a-f]{64}$`)
// secretShape is a secret's name as a manifest writes it and `${secret:…}` names it (mesh-controller
// internal/catalogue secrets_into_files.go).
secretShape = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*$`)
// collectionShape is a collection's name (mesh-controller internal/catalogue collections.go): words joined by
// colons, so an old namespace's placeholder names one as it reads, `shell:zsh:normal`.
collectionShape = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*(:[a-z0-9][a-z0-9-]*)*$`)
// itemNameShape, attributeKeyShape and attributeValueShape are an item's name and attributes (mesh-controller
// internal/catalogue collections.go); none holds a colon, a comma or an equals sign.
itemNameShape = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*$`)
attributeKeyShape = regexp.MustCompile(`^[a-z][a-z0-9-]*$`)
attributeValueShape = regexp.MustCompile(`^[a-z0-9][a-z0-9_.-]*$`)
// nodeShape is a machine's name as the vault writes it in a secret's name (mesh-vault intake.go namePart).
nodeShape = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{0,62}$`)
ownerWords = []string{"owned", "seed", "managed"}
takeWords = []string{"restart-on", "reload-on", "read-on-use"}
)
// errNoPlan is an assignment the controller has not handed to the provisioner.
var errNoPlan = errors.New("no plan")
// planOf reads an assignment's value: its module and its plan, errNoPlan when it has none, or why the plan cannot
// be acted on.
func planOf(node, key string, value []byte) (string, Plan, error) {
var a Assignment
if err := json.Unmarshal(value, &a); err != nil {
return "", Plan{}, fmt.Errorf("the assignment %s is not the controller's JSON: %w", key, err)
}
if a.Node != node || a.Node+"."+a.Module != key {
return "", Plan{}, fmt.Errorf("the assignment %s names node %q and module %q, not its key's", key, a.Node,
a.Module)
}
if len(a.Provisioning) == 0 || string(a.Provisioning) == "null" {
return a.Module, Plan{}, errNoPlan
}
var p Plan
dec := json.NewDecoder(bytes.NewReader(a.Provisioning))
dec.DisallowUnknownFields()
if err := dec.Decode(&p); err != nil {
return a.Module, Plan{}, fmt.Errorf("the plan of %s cannot be read: %w", a.Module, err)
}
if wrong := p.problems(); len(wrong) > 0 {
return a.Module, Plan{}, fmt.Errorf("the plan of %s cannot be acted on: %s", a.Module,
strings.Join(wrong, "; "))
}
return a.Module, p, nil
}
func (p Plan) problems() []string {
var out []string
if p.Templates.Source == "" {
out = append(out, "it names no source for its templates")
}
if !digestShape.MatchString(p.Templates.Digest) {
out = append(out, fmt.Sprintf("its templates' digest %q is not sha256:<64 hex>", p.Templates.Digest))
}
if len(p.Files) == 0 && len(p.Items) == 0 {
out = append(out, "it fills no file and contributes no item")
}
seen := map[string]bool{}
for _, f := range p.Files {
switch {
case !filepath.IsAbs(f.Path) || filepath.Clean(f.Path) != f.Path:
out = append(out, fmt.Sprintf("%s: %q is not a clean absolute path", f.ID, f.Path))
case seen[f.Path]:
out = append(out, fmt.Sprintf("%s: %s is named twice", f.ID, f.Path))
}
seen[f.Path] = true
if !plainTemplate(f.Template) {
out = append(out, fmt.Sprintf("%s: template %q is not a plain path inside the templates archive", f.ID,
f.Template))
}
if !slices.Contains(ownerWords, f.OwnerAtRuntime) {
out = append(out, fmt.Sprintf("%s: owner-at-runtime %q is not owned, seed or managed", f.ID,
f.OwnerAtRuntime))
}
if f.Takes != "" && !slices.Contains(takeWords, f.Takes) {
out = append(out, fmt.Sprintf("%s: takes %q is not restart-on, reload-on or read-on-use", f.ID, f.Takes))
}
}
named := map[string]bool{}
for _, sec := range p.Secrets {
if !secretShape.MatchString(sec.Name) {
out = append(out, fmt.Sprintf("secret %q is not a secret's name", sec.Name))
}
if named[sec.Name] {
out = append(out, fmt.Sprintf("secret %s is named twice", sec.Name))
}
named[sec.Name] = true
if sec.Path == "" {
continue
}
switch {
case !filepath.IsAbs(sec.Path) || filepath.Clean(sec.Path) != sec.Path:
out = append(out, fmt.Sprintf("secret %s: %q is not a clean absolute path", sec.Name, sec.Path))
case seen[sec.Path]:
out = append(out, fmt.Sprintf("secret %s: %s is named twice", sec.Name, sec.Path))
}
seen[sec.Path] = true
}
bound := map[[2]string]bool{}
for _, pr := range p.Provisions {
which := pr.Name
if which == "" {
which = pr.Provision + " " + pr.Local
}
switch {
case pr.Name == "":
case !secretShape.MatchString(pr.Name):
out = append(out, fmt.Sprintf("provision credential %q is not a secret's name", pr.Name))
case named[pr.Name]:
out = append(out, fmt.Sprintf("secret %s is named twice", pr.Name))
}
if pr.Name != "" {
named[pr.Name] = true
}
if !secretShape.MatchString(pr.Provision) {
out = append(out, fmt.Sprintf("%s: provision %q is not a provision's name", which, pr.Provision))
}
if !nodeShape.MatchString(pr.Provider) {
out = append(out, fmt.Sprintf("%s: provider %q is not a machine's name", which, pr.Provider))
}
if pr.CredentialForm != "" && !slices.Contains(credentialForms, pr.CredentialForm) {
out = append(out, fmt.Sprintf("%s: credential-form %q is not %s", which, pr.CredentialForm,
strings.Join(credentialForms, ", ")))
}
if key := [2]string{pr.Provision, pr.Local}; bound[key] {
out = append(out, fmt.Sprintf("%s: its provider is named twice", which))
} else {
bound[key] = true
}
if pr.Path == "" {
continue
}
if pr.Name == "" {
out = append(out, fmt.Sprintf("%s: %s is a file with no credential to fill it with", which, pr.Path))
continue
}
switch {
case !filepath.IsAbs(pr.Path) || filepath.Clean(pr.Path) != pr.Path:
out = append(out, fmt.Sprintf("secret %s: %q is not a clean absolute path", pr.Name, pr.Path))
case seen[pr.Path]:
out = append(out, fmt.Sprintf("secret %s: %s is named twice", pr.Name, pr.Path))
}
seen[pr.Path] = true
}
contributed := map[string]bool{}
for _, it := range p.Items {
if !collectionShape.MatchString(it.Collection) {
out = append(out, fmt.Sprintf("item %q is not a collection's name", it.Collection))
}
if it.Name != "" && !itemNameShape.MatchString(it.Name) {
out = append(out, fmt.Sprintf("item %s: %q is not a name", it.Collection, it.Name))
}
if contributed[it.key()] {
out = append(out, fmt.Sprintf("item %s is named twice: a module contributes one item to a collection, "+
"or several each with a name of its own", it.key()))
}
contributed[it.key()] = true
for _, k := range sortedKeys(it.Attributes) {
if v := it.Attributes[k]; !attributeFits(it.Collection, k, v) {
out = append(out, fmt.Sprintf("item %s: attribute %s=%q is not a name and a value", it.key(), k, v))
} else if n, err := strconv.Atoi(v); k == OrderAttribute && (err != nil || n < 0 || n > 99) {
out = append(out, fmt.Sprintf("item %s: order=%q is not a whole number from 0 to 99", it.key(), v))
}
}
if !plainTemplate(it.Template) {
out = append(out, fmt.Sprintf("item %s: template %q is not a plain path inside the templates archive",
it.Collection, it.Template))
}
}
collected := map[string]bool{}
for _, c := range p.Collections {
if !collectionShape.MatchString(c.Name) {
out = append(out, fmt.Sprintf("collection %q is not a collection's name", c.Name))
}
if collected[c.Name] {
out = append(out, fmt.Sprintf("collection %s is named twice", c.Name))
}
collected[c.Name] = true
if strings.ContainsAny(c.Comment, "\n\r") {
out = append(out, fmt.Sprintf("collection %s: its comment holds a line break", c.Name))
}
}
for k := range p.Placement {
kind, name, ok := strings.Cut(k, ":")
if !ok || name == "" || !slices.Contains(placementKinds, kind) {
out = append(out, fmt.Sprintf("placement %q is not <kind>:<name> of a placement kind", k))
}
}
slices.Sort(out)
return out
}
// plainTemplate is whether a template's path is a plain path inside the templates archive.
func plainTemplate(t string) bool {
return t != "" && !strings.HasPrefix(t, "/") && path.Clean(t) == t && t != ".." && !strings.HasPrefix(t, "../")
}
@@ -0,0 +1,104 @@
package main
import (
"reflect"
"strings"
"testing"
)
// A plan names, for each provision its module requires, the provider serving it (design 57 V5a, issue 760): the
// client reads that strictly, before the controller writes it, and fills nothing from an entry with no credential;
// the vault acts on it.
func TestAPlanNamingItsProvidersIsRead(t *testing.T) {
value := `{"node":"g14","module":"app","provisioning":{` +
`"templates":{"source":"x","digest":"sha256:` + strings.Repeat("0", 64) + `"},` +
`"files":[{"id":"conf","path":"/etc/app.conf","template":"app.conf","owner-at-runtime":"owned"}],` +
`"provisions":[{"provision":"postgres-database","provider":"novox","provider-module":"postgres"},` +
`{"provision":"s3-bucket","local":"hot","provider":"ace","provider-module":"minio"},` +
`{"name":"forge-read","provision":"forge-read","provider":"novox","provider-module":"forge",` +
`"path":"/var/lib/app/forge.secret"}]}}`
_, p, err := planOf("g14", "g14.app", []byte(value))
if err != nil {
t.Fatal(err)
}
want := []PlannedProvision{{Provision: "postgres-database", Provider: "novox", ProviderModule: "postgres"},
{Provision: "s3-bucket", Local: "hot", Provider: "ace", ProviderModule: "minio"},
{Name: "forge-read", Provision: "forge-read", Provider: "novox", ProviderModule: "forge",
Path: "/var/lib/app/forge.secret"}}
if len(p.Provisions) != len(want) {
t.Fatalf("the plan's provisions are read as %+v, want %+v", p.Provisions, want)
}
for i := range want {
if !reflect.DeepEqual(p.Provisions[i], want[i]) {
t.Fatalf("the plan's provisions are read as %+v, want %+v", p.Provisions, want)
}
}
// Only the credential is a file to fill: an entry naming a provider alone fills nothing.
targets, why := fillTargets(p, map[string]string{"app.conf": "x"})
if why != "" || len(targets) != 2 || targets[1].Path != "/var/lib/app/forge.secret" {
t.Fatalf("the files filled are %+v (%s)", targets, why)
}
for why, provisions := range map[string][]PlannedProvision{
"a file and no credential's name": {{Provision: "p", Provider: "a", ProviderModule: "m", Path: "/var/lib/p"}},
"one provision and local twice": {{Provision: "p", Local: "l", Provider: "a", ProviderModule: "m"},
{Provision: "p", Local: "l", Provider: "b", ProviderModule: "m"}},
"no provider": {{Provision: "p", ProviderModule: "m"}},
} {
bad := p
bad.Provisions = provisions
if len(bad.problems()) == 0 {
t.Errorf("a plan with %s was taken", why)
}
}
}
// Each entry names the credential form of its provider's offer (issue 768), which the vault acts on: the client
// reads it strictly, before the controller writes it, and refuses a form it does not know.
func TestAPlanNamingEachProvidersCredentialFormIsRead(t *testing.T) {
value := `{"node":"g14","module":"app","provisioning":{` +
`"templates":{"source":"x","digest":"sha256:` + strings.Repeat("0", 64) + `"},` +
`"files":[{"id":"conf","path":"/etc/app.conf","template":"app.conf","owner-at-runtime":"owned"}],` +
`"provisions":[{"provision":"postgres-database","provider":"novox","provider-module":"postgres",` +
`"credential-form":"pair"},` +
`{"provision":"sonarr-api","provider":"ace","provider-module":"sonarr","credential-form":"shared"},` +
`{"name":"forge-read","provision":"forge-read","provider":"novox","provider-module":"forge",` +
`"credential-form":"issued","path":"/var/lib/app/forge.secret"},` +
`{"provision":"smtp-relay","provider":"ace","provider-module":"relay","credential-form":"given"}]}}`
_, p, err := planOf("g14", "g14.app", []byte(value))
if err != nil {
t.Fatal(err)
}
var forms []string
for _, pr := range p.Provisions {
forms = append(forms, pr.CredentialForm)
}
if strings.Join(forms, ",") != "pair,shared,issued,given" {
t.Fatalf("the credential forms are read as %v", forms)
}
bad := strings.Replace(value, `"credential-form":"pair"`, `"credential-form":"one-for-all"`, 1)
if _, _, err := planOf("g14", "g14.app", []byte(bad)); err == nil || !strings.Contains(err.Error(), "one-for-all") {
t.Fatalf("a credential form the client does not know was taken: %v", err)
}
}
// A consumer's plan entry may carry what its module contributes to the provider (issue 776): the client reads it
// strictly, keeps it on the entry, and still refuses any other field it does not know.
func TestAPlanEntryCarryingItsContributionIsRead(t *testing.T) {
value := `{"node":"g14","module":"app","provisioning":{` +
`"templates":{"source":"x","digest":"sha256:` + strings.Repeat("0", 64) + `"},` +
`"files":[{"id":"conf","path":"/etc/app.conf","template":"app.conf","owner-at-runtime":"owned"}],` +
`"provisions":[{"provision":"postgres-database","provider":"novox","provider-module":"postgres",` +
`"contributes":{"name":"nextcloud","extensions":["pg_trgm"]}}]}}`
_, p, err := planOf("g14", "g14.app", []byte(value))
if err != nil {
t.Fatal(err)
}
want := map[string]any{"name": "nextcloud", "extensions": []any{"pg_trgm"}}
if len(p.Provisions) != 1 || !reflect.DeepEqual(p.Provisions[0].Contributes, want) {
t.Fatalf("the plan's provisions are read as %+v, want the contribution %v kept", p.Provisions, want)
}
bad := strings.Replace(value, `"contributes":`, `"contribution":`, 1)
if _, _, err := planOf("g14", "g14.app", []byte(bad)); err == nil {
t.Error("a plan entry with a field the client does not know was taken")
}
}
@@ -0,0 +1,78 @@
package main
import (
"encoding/json"
"fmt"
"strings"
"testing"
"time"
)
// A module only pulled in by another on this node has a value of its own in the assignments mirror (mesh-controller
// internal/inventory AssignmentValues, issue 765): its plan, what requires it, and no time it was assigned. The
// client fills it exactly like an assigned module: its files, its secrets from the vault and its items.
// pullIn writes a module's value as the controller writes one only pulled in, by requiredBy.
func (b *aBus) pullIn(t *testing.T, module, requiredBy string, plan Plan) {
t.Helper()
p, err := json.Marshal(plan)
if err != nil {
t.Fatal(err)
}
b.put(t, AssignmentsBucket, node+"."+module, fmt.Sprintf(`{"node":%q,"module":%q,"required-by":%q,`+
`"provisioning":%s}`, node, module, requiredBy, p))
}
func TestAPulledInModulesValueIsReadForItsPlan(t *testing.T) {
plan := contributor(t, "shell:zsh:normal", "alias nox=mesh-cli")
p, err := json.Marshal(plan)
if err != nil {
t.Fatal(err)
}
value := fmt.Sprintf(`{"node":"g14","module":"mesh-cli","required-by":"desk","provisioning":%s}`, p)
module, got, err := planOf("g14", "g14.mesh-cli", []byte(value))
if err != nil || module != "mesh-cli" || len(got.Items) != 1 || got.Items[0].Collection != "shell:zsh:normal" {
t.Fatalf("a pulled-in module's value reads as %s %+v (%v)", module, got, err)
}
// Still strict in the plan.
spoilt := strings.Replace(value, `"items":`, `"surprise":1,"items":`, 1)
if _, _, err := planOf("g14", "g14.mesh-cli", []byte(spoilt)); err == nil {
t.Fatalf("a pulled-in module's plan with an unknown field is acted on: %s", spoilt)
}
}
func TestNatsAPulledInModuleIsFilledWithItsSecretAndItsItemIsRecorded(t *testing.T) {
b := startBus(t)
heard := b.listen(t)
vault := startVault(t, b)
const token, password = "tok-765-never-on-the-bus", "pw-765-never-on-the-bus"
vault.keep("own/g14/app/app-token", token, false)
vault.keep("own/g14/app/db-password", password, false)
e := startEngine(t, map[string]string{appConf: "owned", appDB: "owned", zshrc: "owned"})
b.config(t, "app", "font-size", "13")
b.pullIn(t, "app", "desk", app(t))
b.assign(t, "zsh", zshCollector(t))
b.pullIn(t, "mesh-cli", "desk", contributor(t, "shell:zsh:normal", "alias nox=mesh-cli\n"))
aClient(t, b, e, t.TempDir(), time.Hour)
eventually(t, "the pulled-in app.conf is filled with its secret", func() bool {
return e.read(t, appConf) == "token = "+token+"\nfont = 13\n"
})
eventually(t, "its secret's own file holds it alone", func() bool { return e.read(t, appDB) == password })
eventually(t, "the pulled-in contributor's item is recorded", func() bool {
got := b.keys(t, CollectBucket, node+".")
return len(got) == 1 && got[0] == "g14.shell=3Azsh=3Anormal.mesh-cli"
})
eventually(t, "zshrc holds the pulled-in contributor's item", func() bool {
return e.read(t, zshrc) == "# zsh\n# mesh-cli\nalias nox=mesh-cli\n# then\n# end\n"
})
eventually(t, "prov.applied says app applied", func() bool {
r, _ := heard.last("app")
return r.Status == StatusApplied && len(r.Files) == 2
})
// What required it lets it go: its item leaves the collection.
b.del(t, AssignmentsBucket, node+".mesh-cli")
eventually(t, "zshrc loses the item of a module no longer pulled in", func() bool {
return e.read(t, zshrc) == "# zsh\n# then\n# end\n"
})
}
@@ -0,0 +1,254 @@
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"slices"
"strings"
"time"
"git.novox.be/novox/mesh-sdk/go/seal"
"github.com/nats-io/nats.go"
)
// A module's secrets, from the vault (novox/hq ADR 0349 clauses 7 and 11, design 57 V4, issue 647).
//
// **Asked for on request and reply, sealed to this client's key.** The client asks the vault's `get` in its own
// name, on the subject that names its account, so the vault knows from the bus's word which machine's client asks;
// it sends the public half of a key it made when it started and keeps in memory alone, and the vault answers the
// secret sealed to it (the sealed box of ADR 0206). Only the box crosses the bus. The value lives in this process
// while it fills the module's files, and reaches the machine only through the node-engine's local write socket.
// It is never written to a bucket, a report, an event or a log.
//
// **Asked once, and again when it changes.** A secret the client holds is asked again when the vault says it
// changed (`secret-changed`: which, by whom, its generation, never a value), so a rotation reaches the file
// without a new declaration; and every time the client starts or its node comes back, when it starts from nothing.
// A secret the vault does not hold yet, or a vault that does not answer, is waited for; a refusal fails the module
// with the vault's reason, and is asked again only after RefusedFor, so a refused client does not fill the vault's
// record of hand-outs every minute.
// VaultSeat is the vault's seat, and GetVerb the verb a client asks.
const (
VaultSeat = "mesh-vault"
GetVerb = "get"
)
// SecretChangedSubject is where the vault says a secret changed (mesh-controller link.EventSubject of mesh-vault's
// `secret-changed`): never a value.
const SecretChangedSubject = "mesh.mod.mesh-vault.event.secret-changed"
// RefusedFor is how long a refusal stands before the vault is asked again.
const RefusedFor = 10 * time.Minute
// VaultName is a module's own secret on one machine as the vault names it (mesh-vault intake.go OwnSecret).
func VaultName(node, module, name string) string { return "own/" + node + "/" + module + "/" + name }
// HolderName is the value a provider's software issued for one holder, a module on one machine, as the vault names
// it (mesh-vault perholder.go HolderSecret, issue 749).
func HolderName(provider, provision, node, module string) string {
return "provision/" + provider + "/" + provision + "/" + node + "/" + module
}
// callerToken is a bus user's name as the last token of a call it publishes, each dot written `~` (mesh-controller
// internal/broker CallerToken).
func callerToken(user string) string { return strings.ReplaceAll(user, ".", "~") }
// GetSubject is where this client asks the vault's `get` in its own name (mesh-controller internal/broker
// CalledSubject of `mesh.seat.mesh-vault.tool.get`).
func GetSubject(user string) string {
return "mesh.seat." + VaultSeat + ".call." + GetVerb + "." + callerToken(user)
}
// errNotYet is a secret the vault cannot hand yet: it holds none of that name, or it does not answer.
var errNotYet = errors.New("not yet")
// Secrets hands a module's secret, opened.
type Secrets interface {
// Get is the secret's value and generation, errNotYet (wrapped, with why) when it is to be waited for, or why
// it was refused.
Get(ctx context.Context, name string) (value string, generation int, err error)
}
// VaultSecrets asks the vault over the bus.
type VaultSecrets struct {
Conn *nats.Conn
// User is the client's own bus account, `provisioner.<node>`.
User string
// Key is the client's own key, made at start and never written down.
Key seal.KeyPair
// Within bounds one ask.
Within time.Duration
}
// NewVaultSecrets is the vault reached as user, with a key of its own made now.
func NewVaultSecrets(conn *nats.Conn, user string) (*VaultSecrets, error) {
key, err := seal.GenerateKeyPair()
if err != nil {
return nil, fmt.Errorf("the client could not make its key: %w", err)
}
return &VaultSecrets{Conn: conn, User: user, Key: key, Within: 15 * time.Second}, nil
}
// seatAnswer is a seat's answer on the bus (mesh-controller link.Answer).
type seatAnswer struct {
Result json.RawMessage `json:"result,omitempty"`
Error string `json:"error,omitempty"`
}
// vaultAnswer is `get`'s result (mesh-vault vault.go Answer): never a value in the clear.
type vaultAnswer struct {
Name string `json:"name"`
Generation int `json:"generation"`
Sealed seal.Box `json:"sealed"`
}
// Get asks the vault for name, sealed to the client's key, and opens it.
func (v *VaultSecrets) Get(ctx context.Context, name string) (string, int, error) {
body, err := json.Marshal(map[string]string{"name": name, "public_key": v.Key.PublicKey})
if err != nil {
return "", 0, err
}
within := v.Within
if within <= 0 {
within = 15 * time.Second
}
asking, cancel := context.WithTimeout(ctx, within)
defer cancel()
msg, err := v.Conn.RequestWithContext(asking, GetSubject(v.User), body)
switch {
case errors.Is(err, nats.ErrNoResponders):
return "", 0, fmt.Errorf("%w: nothing answers the vault's get", errNotYet)
case errors.Is(err, context.DeadlineExceeded), errors.Is(err, nats.ErrTimeout):
return "", 0, fmt.Errorf("%w: the vault did not answer within %s", errNotYet, within)
case err != nil:
return "", 0, fmt.Errorf("asking the vault: %w", err)
}
var a seatAnswer
if err := json.Unmarshal(msg.Data, &a); err != nil {
return "", 0, errors.New("the vault answered something that is not a seat's answer")
}
if a.Error != "" {
if strings.Contains(a.Error, "holds no secret named") {
return "", 0, fmt.Errorf("%w: the vault holds no %s yet", errNotYet, name)
}
return "", 0, fmt.Errorf("the vault refused it: %s", a.Error)
}
var got vaultAnswer
if err := json.Unmarshal(a.Result, &got); err != nil || got.Name != name {
return "", 0, fmt.Errorf("the vault answered something that is not %s", name)
}
value, err := seal.Open(got.Sealed, v.Key.PrivateKey)
if err != nil {
return "", 0, fmt.Errorf("the vault's answer for %s does not open with the client's key: %w", name, err)
}
return value, got.Generation, nil
}
// heldSecret is a secret as one run of the client holds it: its value, or why it was refused and when.
type heldSecret struct {
value string
generation int
refused string
refusedAt time.Time
}
// changedSecret is what `secret-changed` says, as far as the client reads it.
type changedSecret struct {
Name string `json:"name"`
Generation int `json:"generation"`
}
// secretOf is a module's secret, from what the run holds or from the vault: its value, or what it waits for, or
// why it cannot be filled.
func (c *Client) secretOf(ctx context.Context, s *state, module string, p Plan, n Need) (value, wait, fail string) {
name := ""
if slices.ContainsFunc(p.Secrets, func(sec PlannedSecret) bool { return sec.Name == n.Name }) {
name = VaultName(c.Node, module, n.Name)
} else if i := slices.IndexFunc(p.Provisions, func(pr PlannedProvision) bool { return pr.Name == n.Name }); i >= 0 {
// A credential of what the module requires, issued for this module on this machine (issue 757).
pr := p.Provisions[i]
name = HolderName(pr.Provider, pr.Provision, c.Node, module)
if pr.CredentialForm == "pair" {
// This consumer's own login from a provider that gives each consumer one (issue 779, ADR 0158 §11).
name = PairName(pr.Provider, pr.Provision, c.Node, module, pr.Local)
}
} else {
return "", "", fmt.Sprintf("%s: the plan names no secret %s, and the vault hands only what this machine's "+
"declaration names", n, n.Name)
}
return c.vaultValue(ctx, s, n, name)
}
// vaultValue is the vault's secret name for the need n, from what the run holds or from the vault: its value, or what
// it waits for, or why it cannot be filled.
func (c *Client) vaultValue(ctx context.Context, s *state, n Need, name string) (value, wait, fail string) {
if c.Secrets == nil {
return "", "", n.String() + ": this client has no way to ask the vault"
}
if h, ok := s.secrets[name]; ok {
if h.refused == "" {
return h.value, "", ""
}
if c.now().Sub(h.refusedAt) < RefusedFor {
return "", "", fmt.Sprintf("%s: %s", n, h.refused)
}
}
got, generation, err := c.Secrets.Get(ctx, name)
switch {
case errors.Is(err, errNotYet):
delete(s.secrets, name)
return "", fmt.Sprintf("%s (%s)", n, strings.TrimPrefix(err.Error(), errNotYet.Error()+": ")), ""
case err != nil:
s.secrets[name] = heldSecret{refused: err.Error(), refusedAt: c.now()}
return "", "", fmt.Sprintf("%s: %v", n, err)
}
s.secrets[name] = heldSecret{value: got, generation: generation}
return got, "", ""
}
// secretChanged folds what the vault said changed into the state: a secret this run holds is asked again. It
// answers the module whose secret it is, or nothing.
func (c *Client) secretChanged(s *state, body []byte) []string {
var ch changedSecret
if err := json.Unmarshal(body, &ch); err != nil {
return nil
}
parts := strings.Split(ch.Name, "/")
var module string
switch {
case len(parts) == 4 && parts[0] == "own" && parts[1] == c.Node:
module = parts[2]
case len(parts) == 5 && parts[0] == "provision" && parts[3] == c.Node:
// A value issued for one of this machine's modules (issue 757).
module = parts[4]
case (len(parts) == 5 || len(parts) == 6) && parts[0] == "pair" && (parts[1] == c.Node || parts[3] == c.Node):
// A consumer's own credential (ADR 0373): from a provider on this machine, every provider here listing its
// consumers is filled again, which asks for it if it lists that consumer, held or waited for; and for a module
// on this machine, the module whose plan names it as its file's credential (issue 779).
if h, held := s.secrets[ch.Name]; held && h.refused == "" && h.generation >= ch.Generation {
return nil
}
delete(s.secrets, ch.Name)
var out []string
if parts[1] == c.Node {
out = sortedKeys(s.providing)
}
if parts[3] == c.Node && hasPlan(s, parts[4]) && !slices.Contains(out, parts[4]) {
out = append(out, parts[4])
}
return out
default:
return nil
}
if !hasPlan(s, module) {
return nil
}
h, held := s.secrets[ch.Name]
if held && h.refused == "" && h.generation >= ch.Generation {
return nil
}
delete(s.secrets, ch.Name)
return []string{module}
}
@@ -0,0 +1,511 @@
package main
import (
"context"
"encoding/json"
"strings"
"sync"
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/seal"
"github.com/nats-io/nats.go"
)
// A node's secrets come from the vault (novox/hq ADR 0349 clauses 7 and 11, design 57 V4, issue 647), against a
// real bus whose accounts are the client's as mesh-controller composes them: a moved module's file is filled with
// its secret from the vault's get, asked in the client's own name and answered sealed to its key; the value never
// crosses the bus in the clear; a secret rotated in the vault reaches its file without a new declaration; a secret
// the vault does not hold yet is waited for, and a refusal fails the module with the vault's reason.
//
// The vault is a stand-in answering `mesh-vault.get` as its seat does on the bus: on the subject naming the caller,
// sealing to the key the caller sends, handing only what it allows that caller (the vault's own tests prove its
// rule), and saying `secret-changed` with no value.
// standInVault answers get on the bus, for provisioner.g14 alone and only the names it allows.
type standInVault struct {
t *testing.T
conn *nats.Conn
mu sync.Mutex
held map[string]string
gen map[string]int
allowed map[string]bool
asked map[string]int
refuse string
}
func startVault(t *testing.T, b *aBus) *standInVault {
t.Helper()
v := &standInVault{t: t, conn: b.conn, held: map[string]string{}, gen: map[string]int{}, allowed: map[string]bool{},
asked: map[string]int{}}
sub, err := b.conn.Subscribe("mesh.seat.mesh-vault.call.get.*", func(m *nats.Msg) {
caller := strings.ReplaceAll(m.Subject[strings.LastIndex(m.Subject, ".")+1:], "~", ".")
var args struct {
Name string `json:"name"`
PublicKey string `json:"public_key"`
}
_ = json.Unmarshal(m.Data, &args)
v.mu.Lock()
defer v.mu.Unlock()
v.asked[args.Name]++
answer := map[string]any{}
value, held := v.held[args.Name]
switch {
case caller != "provisioner."+node || !v.allowed[args.Name]:
answer["error"] = caller + " may not ask for " + args.Name + ": not on the list"
case v.refuse != "":
answer["error"] = v.refuse
case !held:
answer["error"] = "the vault holds no secret named " + args.Name
default:
box, err := seal.Seal(value, args.PublicKey)
if err != nil {
answer["error"] = err.Error()
break
}
answer["result"] = map[string]any{"name": args.Name, "generation": v.gen[args.Name],
"changed-at": time.Now().UTC(), "sealed": box}
}
body, _ := json.Marshal(answer)
_ = m.Respond(body)
})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = sub.Unsubscribe() })
_ = b.conn.Flush()
return v
}
// keep holds value as name and allows the client to ask for it; with say, it says secret-changed as the vault does.
func (v *standInVault) keep(name, value string, say bool) {
v.mu.Lock()
v.held[name] = value
v.gen[name]++
v.allowed[name] = true
gen := v.gen[name]
v.mu.Unlock()
if say {
body, _ := json.Marshal(map[string]any{"name": name, "by": "the test", "at": time.Now().UTC(), "generation": gen})
if err := v.conn.Publish(SecretChangedSubject, body); err != nil {
v.t.Fatal(err)
}
}
}
func (v *standInVault) times(name string) int {
v.mu.Lock()
defer v.mu.Unlock()
return v.asked[name]
}
// sniffer hears every message on the bus, to prove a value never crosses it in the clear.
type sniffer struct {
mu sync.Mutex
seen []string
}
func sniff(t *testing.T, b *aBus) *sniffer {
t.Helper()
s := &sniffer{}
sub, err := b.conn.Subscribe(">", func(m *nats.Msg) {
s.mu.Lock()
s.seen = append(s.seen, m.Subject+" "+string(m.Data))
s.mu.Unlock()
})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = sub.Unsubscribe() })
_ = b.conn.Flush()
return s
}
func (s *sniffer) carrying(value string) []string {
s.mu.Lock()
defer s.mu.Unlock()
var out []string
for _, m := range s.seen {
if strings.Contains(m, value) {
out = append(out, m)
}
}
return out
}
func (s *sniffer) heard() int {
s.mu.Lock()
defer s.mu.Unlock()
return len(s.seen)
}
const appConf, appDB = "/etc/app/app.conf", "/var/lib/app/db-password"
// app is a moved module with a template naming a secret and a config value, and a secret kept in a file of its own.
func app(t *testing.T) Plan {
tpl, _ := templates(t, map[string]string{"app.conf": "token = ${secret:app-token}\nfont = ${config:font-size}\n"})
return Plan{Templates: tpl, Files: []PlannedFile{{ID: "conf", Path: appConf, Template: "app.conf",
OwnerAtRuntime: "owned", Takes: "restart-on"}},
Secrets: []PlannedSecret{{Name: "app-token"}, {Name: "db-password", Path: appDB}}}
}
func TestNatsAMovedModulesFileIsFilledWithItsSecretFromTheVault(t *testing.T) {
b := startBus(t)
heard := b.listen(t)
wire := sniff(t, b)
vault := startVault(t, b)
const token, password = "tok-3f9a1c-never-on-the-bus", "pw-77b2e0-never-on-the-bus"
vault.keep("own/g14/app/app-token", token, false)
vault.keep("own/g14/app/db-password", password, false)
e := startEngine(t, map[string]string{appConf: "owned", appDB: "owned"})
b.config(t, "app", "font-size", "13")
b.assign(t, "app", app(t))
aClient(t, b, e, t.TempDir(), time.Hour)
eventually(t, "app.conf is filled with its secret", func() bool {
return e.read(t, appConf) == "token = "+token+"\nfont = 13\n"
})
eventually(t, "the secret's own file holds it alone", func() bool { return e.read(t, appDB) == password })
eventually(t, "prov.applied says applied", func() bool {
r, _ := heard.last("app")
return r.Status == StatusApplied && len(r.Files) == 2
})
eventually(t, "the needs name the secrets, never their values", func() bool {
got := b.keys(t, NeedsBucket, node+".app.")
return strings.Join(got, " ") == "g14.app.config.font-size g14.app.secret.app-token g14.app.secret.db-password"
})
if wire.heard() == 0 {
t.Fatal("the sniffer heard nothing: it proves nothing")
}
for _, value := range []string{token, password} {
if said := wire.carrying(value); len(said) > 0 {
t.Errorf("the value crossed the bus in the clear: %q", said)
}
}
if vault.times("own/g14/app/app-token") != 1 || vault.times("own/g14/app/db-password") != 1 {
t.Errorf("the vault was asked %d and %d times, want once each", vault.times("own/g14/app/app-token"),
vault.times("own/g14/app/db-password"))
}
}
func TestNatsASecretRotatedInTheVaultReachesItsFileWithoutANewDeclaration(t *testing.T) {
b := startBus(t)
heard := b.listen(t)
vault := startVault(t, b)
vault.keep("own/g14/app/app-token", "first", false)
vault.keep("own/g14/app/db-password", "pw", false)
e := startEngine(t, map[string]string{appConf: "owned", appDB: "owned"})
b.config(t, "app", "font-size", "13")
b.assign(t, "app", app(t))
aClient(t, b, e, t.TempDir(), time.Hour)
eventually(t, "app.conf is filled", func() bool { return strings.Contains(e.read(t, appConf), "token = first\n") })
assigned, err := b.kv[AssignmentsBucket].Get(context.Background(), node+".app")
if err != nil {
t.Fatal(err)
}
vault.keep("own/g14/app/app-token", "second", true)
eventually(t, "the rotated value reaches its file", func() bool {
return strings.Contains(e.read(t, appConf), "token = second\n")
})
eventually(t, "prov.applied says it was updated", func() bool {
r, _ := heard.last("app")
return r.Status == StatusApplied && r.Files[0].Action == "updated"
})
now, err := b.kv[AssignmentsBucket].Get(context.Background(), node+".app")
if err != nil || now.Revision() != assigned.Revision() {
t.Errorf("the module's assignment was written again: revision %d, was %d", now.Revision(), assigned.Revision())
}
if moved, _ := e.counts(appConf); moved != 2 {
t.Errorf("app.conf moved %d times, want 2: the program restarts once for the rotation", moved)
}
if moved, _ := e.counts(appDB); moved != 1 {
t.Errorf("the other secret's file moved %d times: only what changed is written", moved)
}
// What another machine's secret, or a secret no plan here names, changes is no reason to ask.
vault.keep("own/ace/app/app-token", "elsewhere", true)
vault.keep("own/g14/other/token", "unplanned", true)
time.Sleep(300 * time.Millisecond)
if n := vault.times("own/g14/app/app-token"); n != 2 {
t.Errorf("the vault was asked for app-token %d times, want 2", n)
}
if vault.times("own/ace/app/app-token")+vault.times("own/g14/other/token") != 0 {
t.Error("a change of a secret this machine's plans do not name made the client ask")
}
}
func TestNatsASecretTheVaultDoesNotHoldYetIsWaitedFor(t *testing.T) {
b := startBus(t)
heard := b.listen(t)
vault := startVault(t, b)
vault.keep("own/g14/app/db-password", "pw", false)
vault.allowed["own/g14/app/app-token"] = true // the plan names it; the vault does not hold it yet
e := startEngine(t, map[string]string{appConf: "owned", appDB: "owned"})
b.config(t, "app", "font-size", "13")
b.assign(t, "app", app(t))
aClient(t, b, e, t.TempDir(), time.Hour)
eventually(t, "prov.applied says it waits for the secret", func() bool {
r, _ := heard.last("app")
return r.Status == StatusWaiting && len(r.WaitsFor) == 1 &&
strings.HasPrefix(r.WaitsFor[0], "secret:app-token (the vault holds no")
})
if _, asked := e.counts(appConf); asked != 0 {
t.Error("a file waiting for its secret was written")
}
if _, asked := e.counts(appDB); asked != 0 {
t.Error("a module waiting for one value had another file written: every value or none")
}
vault.keep("own/g14/app/app-token", "arrived", true)
eventually(t, "it is filled once the vault holds it", func() bool {
return strings.Contains(e.read(t, appConf), "token = arrived\n") && e.read(t, appDB) == "pw"
})
}
func TestNatsARefusalFailsTheModuleWithTheVaultsReasonAndIsNotAskedEveryPass(t *testing.T) {
b := startBus(t)
heard := b.listen(t)
vault := startVault(t, b)
vault.keep("own/g14/app/app-token", "tok", false)
vault.keep("own/g14/app/db-password", "pw", false)
vault.refuse = "provisioner.g14 may not ask for own/g14/app/app-token: app's plan on g14 names no secret app-token"
e := startEngine(t, map[string]string{appConf: "owned", appDB: "owned"})
b.config(t, "app", "font-size", "13")
b.assign(t, "app", app(t))
aClient(t, b, e, t.TempDir(), 100*time.Millisecond)
eventually(t, "prov.applied says it failed, with the vault's reason", func() bool {
r, _ := heard.last("app")
return r.Status == StatusFailed && strings.Contains(r.Reason, "names no secret app-token")
})
time.Sleep(600 * time.Millisecond)
if n := vault.times("own/g14/app/app-token"); n != 1 {
t.Errorf("a refused secret was asked %d times in six drift passes, want once", n)
}
if _, asked := e.counts(appConf); asked != 0 {
t.Error("a file whose secret was refused was written")
}
}
func TestNatsAVaultThatDoesNotAnswerIsWaitedFor(t *testing.T) {
b := startBus(t)
heard := b.listen(t)
e := startEngine(t, map[string]string{appConf: "owned", appDB: "owned"})
b.config(t, "app", "font-size", "13")
b.assign(t, "app", app(t))
aClient(t, b, e, t.TempDir(), time.Hour)
eventually(t, "prov.applied says it waits for the vault", func() bool {
r, _ := heard.last("app")
return r.Status == StatusWaiting && len(r.WaitsFor) == 2 && strings.Contains(r.WaitsFor[0], "nothing answers")
})
}
// The plan's secrets are read strictly: a name that is not a secret's, one named twice, and a file path that is not
// clean and absolute, or a template's, each refuse the plan.
func TestAPlansSecretsAreReadStrictly(t *testing.T) {
base := func() Plan {
return Plan{Templates: Templates{Source: "x", Digest: "sha256:" + strings.Repeat("0", 64)},
Files: []PlannedFile{{ID: "conf", Path: appConf, Template: "app.conf", OwnerAtRuntime: "owned"}}}
}
for why, secrets := range map[string][]PlannedSecret{
"a name that is not a secret's": {{Name: "App Token"}},
"a name given twice": {{Name: "t"}, {Name: "t"}},
"a relative path": {{Name: "t", Path: "etc/t"}},
"a template's path": {{Name: "t", Path: appConf}},
} {
p := base()
p.Secrets = secrets
if len(p.problems()) == 0 {
t.Errorf("a plan with %s was taken", why)
}
}
p := base()
p.Secrets = []PlannedSecret{{Name: "app-token"}, {Name: "db-password", Path: appDB}}
if wrong := p.problems(); len(wrong) > 0 {
t.Errorf("a good plan was refused: %v", wrong)
}
}
// The forge's read credential for claude-code on this machine (issue 757, issue 737): git's credential-store line
// the forge's software issued for this holder alone, kept by the vault as
// provision/<provider>/gitea-read/<node>/claude-code.
const (
forgeFile = "/var/lib/claude-code/state/gitea-read.secret"
settingsFile = "/etc/claude-code/managed-settings.json"
forgeHolder = "provision/novox/gitea-read/" + node + "/claude-code"
)
// claudeCode is claude-code's plan with the credential of what it requires, gitea-read from novox, kept in a file of
// its own and named in a template.
func claudeCode(t *testing.T) Plan {
tpl, _ := templates(t, map[string]string{"managed-settings.json": `{"forge":"${secret:gitea-read}"}`})
return Plan{Templates: tpl, Files: []PlannedFile{{ID: "settings", Path: settingsFile,
Template: "managed-settings.json", OwnerAtRuntime: "owned"}},
Provisions: []PlannedProvision{{Name: "gitea-read", Provision: "gitea-read", Provider: "novox",
Path: forgeFile}}}
}
func TestNatsAModulesRequiredCredentialIssuedForItIsFilledFromTheVaultAndFollowsARotation(t *testing.T) {
b := startBus(t)
heard := b.listen(t)
wire := sniff(t, b)
vault := startVault(t, b)
const first = "https://agent-g14:gta-first-never-on-the-bus@forge.example"
const second = "https://agent-g14:gta-second-never-on-the-bus@forge.example"
vault.keep(forgeHolder, first, false)
e := startEngine(t, map[string]string{forgeFile: "owned", settingsFile: "owned"})
b.assign(t, "claude-code", claudeCode(t))
aClient(t, b, e, t.TempDir(), time.Hour)
eventually(t, "gitea-read's file holds the value issued for claude-code here, alone", func() bool {
return e.read(t, forgeFile) == first
})
eventually(t, "the template naming it is filled with it", func() bool {
return e.read(t, settingsFile) == `{"forge":"`+first+`"}`
})
eventually(t, "prov.applied says applied", func() bool {
r, _ := heard.last("claude-code")
return r.Status == StatusApplied && len(r.Files) == 2
})
vault.keep(forgeHolder, second, true)
eventually(t, "a value the forge issued anew reaches the file without a new declaration", func() bool {
return e.read(t, forgeFile) == second
})
// Another machine's or another module's holder value changing is no reason to ask.
vault.keep("provision/novox/gitea-read/ace/claude-code", "elsewhere", true)
vault.keep("provision/novox/gitea-read/"+node+"/zsh", "unplanned", true)
time.Sleep(300 * time.Millisecond)
if n := vault.times(forgeHolder); n != 2 {
t.Errorf("the vault was asked for the holder value %d times, want 2", n)
}
if vault.times("provision/novox/gitea-read/ace/claude-code")+vault.times("provision/novox/gitea-read/"+node+"/zsh") != 0 {
t.Error("a change of a value no plan here names made the client ask")
}
if wire.heard() == 0 {
t.Fatal("the sniffer heard nothing: it proves nothing")
}
for _, value := range []string{first, second, "gta-first-never-on-the-bus", "gta-second-never-on-the-bus"} {
if said := wire.carrying(value); len(said) > 0 {
t.Errorf("the value crossed the bus in the clear: %q", said)
}
}
}
// A required credential the vault refuses fails the module with the vault's reason, and nothing is written.
func TestNatsARequiredCredentialTheVaultRefusesFailsTheModule(t *testing.T) {
b := startBus(t)
heard := b.listen(t)
vault := startVault(t, b)
vault.keep(forgeHolder, "https://agent-g14:tok@forge.example", false)
vault.refuse = "provisioner.g14 may not ask for " + forgeHolder + ": claude-code's plan on g14 names no credential of gitea-read from novox"
e := startEngine(t, map[string]string{forgeFile: "owned", settingsFile: "owned"})
b.assign(t, "claude-code", claudeCode(t))
aClient(t, b, e, t.TempDir(), time.Hour)
eventually(t, "prov.applied says it failed, with the vault's reason", func() bool {
r, _ := heard.last("claude-code")
return r.Status == StatusFailed && strings.Contains(r.Reason, "names no credential of gitea-read")
})
if _, asked := e.counts(forgeFile); asked != 0 {
t.Error("a file whose credential was refused was written")
}
}
// The plan's provisions are read strictly.
func TestAPlansProvisionsAreReadStrictly(t *testing.T) {
base := func() Plan {
return Plan{Templates: Templates{Source: "x", Digest: "sha256:" + strings.Repeat("0", 64)},
Files: []PlannedFile{{ID: "conf", Path: appConf, Template: "app.conf", OwnerAtRuntime: "owned"}},
Secrets: []PlannedSecret{{Name: "own"}}}
}
good := PlannedProvision{Name: "gitea-read", Provision: "gitea-read", Provider: "novox", Path: forgeFile}
for why, change := range map[string]func(*PlannedProvision){
"a name that is not a secret's": func(p *PlannedProvision) { p.Name = "Gitea Read" },
"a name an own secret has": func(p *PlannedProvision) { p.Name = "own" },
"no provision": func(p *PlannedProvision) { p.Provision = "" },
"a provider that is no machine": func(p *PlannedProvision) { p.Provider = "../ace" },
"no provider": func(p *PlannedProvision) { p.Provider = "" },
"a relative path": func(p *PlannedProvision) { p.Path = "var/x" },
"a template's path": func(p *PlannedProvision) { p.Path = appConf },
} {
p := base()
pr := good
change(&pr)
p.Provisions = []PlannedProvision{pr}
if len(p.problems()) == 0 {
t.Errorf("a plan with %s was taken", why)
}
}
p := base()
p.Provisions = []PlannedProvision{good}
if wrong := p.problems(); len(wrong) > 0 {
t.Errorf("a good plan was refused: %v", wrong)
}
p.Provisions = append(p.Provisions, good)
if len(p.problems()) == 0 {
t.Error("a plan naming one credential twice was taken")
}
}
const (
dbFile = "/var/lib/nextcloud/state/database.secret"
dbConf = "/etc/nextcloud/config.php"
dbHolder = "pair/novox/postgres-database/" + node + "/nextcloud"
)
func nextcloud(t *testing.T) Plan {
tpl, _ := templates(t, map[string]string{"config.php": `dbpass=${secret:database}`})
return Plan{Templates: tpl, Files: []PlannedFile{{ID: "config", Path: dbConf, Template: "config.php",
OwnerAtRuntime: "owned"}},
Provisions: []PlannedProvision{{Name: "database", Provision: "postgres-database", Provider: "novox",
ProviderModule: "postgres", CredentialForm: "pair", Path: dbFile}}}
}
// A consumer's own login from a provider that gives each consumer one (issue 779) is the vault's pair row for this
// module on this machine: its secrets file holds it alone, and it is asked for again when the vault says it changed.
func TestNatsAModulesOwnLoginFromAPerLoginProviderIsFilledFromTheVaultAndFollowsARotation(t *testing.T) {
b := startBus(t)
heard := b.listen(t)
wire := sniff(t, b)
vault := startVault(t, b)
const first, second = "login-first-never-on-the-bus", "login-second-never-on-the-bus"
vault.keep(dbHolder, first, false)
e := startEngine(t, map[string]string{dbFile: "owned", dbConf: "owned"})
b.assign(t, "nextcloud", nextcloud(t))
aClient(t, b, e, t.TempDir(), time.Hour)
eventually(t, "the secrets file holds the module's own login, alone", func() bool {
return e.read(t, dbFile) == first
})
eventually(t, "the template naming it is filled with it", func() bool {
return e.read(t, dbConf) == "dbpass="+first
})
eventually(t, "prov.applied says applied", func() bool {
r, _ := heard.last("nextcloud")
return r.Status == StatusApplied && len(r.Files) == 2
})
vault.keep(dbHolder, second, true)
eventually(t, "a login rotated in the vault reaches the file without a new declaration", func() bool {
return e.read(t, dbFile) == second
})
// Another consumer's login changing is no reason to ask.
other := "pair/novox/postgres-database/ace/nextcloud"
vault.keep(other, "elsewhere", true)
time.Sleep(300 * time.Millisecond)
if n := vault.times(dbHolder); n != 2 {
t.Errorf("the vault was asked for the login %d times, want 2", n)
}
if vault.times(other) != 0 {
t.Error("a change of another consumer's login made the client ask")
}
for _, value := range []string{first, second} {
if said := wire.carrying(value); len(said) > 0 {
t.Errorf("the login crossed the bus in the clear: %q", said)
}
}
}
func TestAConsumersLoginIsNamedWithItsLocalName(t *testing.T) {
if got := PairName("novox", "postgres-database", "g14", "umami", "stats"); got != "pair/novox/postgres-database/g14/umami/stats" {
t.Errorf("a login under a local name: %s", got)
}
}
@@ -0,0 +1,194 @@
package main
import (
"archive/tar"
"bytes"
"compress/gzip"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"path"
"path/filepath"
"regexp"
"strings"
"time"
)
// A module's templates, fetched by digest (novox/hq ADR 0349 clause 2, design 57 P5): the archive its build packed
// its templates directory into, fetched from where the plan says and used only when it hashes to the plan's
// digest — the bytes come from a network the mesh does not control, and only the digest makes them the ones the
// mesh built. Kept in the client's state by digest, so a node that cannot reach the artifact store still fills
// from what it fetched.
// maxTemplates bounds a templates archive: configuration text, never a theme.
const maxTemplates = 32 << 20
// Fetcher fetches an archive's bytes from its source.
type Fetcher func(ctx context.Context, source string) ([]byte, error)
// HTTPFetch is the artifact store, over HTTP.
func HTTPFetch(ctx context.Context, source string) ([]byte, error) {
if !strings.HasPrefix(source, "https://") && !strings.HasPrefix(source, "http://") {
return nil, fmt.Errorf("%s is not an address the templates can be fetched from", source)
}
ctx, cancel := context.WithTimeout(ctx, 2*time.Minute)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, source, nil)
if err != nil {
return nil, err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, fmt.Errorf("cannot fetch %s: %w", source, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("fetching %s: %s", source, resp.Status)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, maxTemplates+1))
if err != nil {
return nil, err
}
if len(body) > maxTemplates {
return nil, fmt.Errorf("%s is over %d bytes, more than a module's templates", source, maxTemplates)
}
return body, nil
}
// templateStore keeps fetched archives by digest under dir, and their texts in memory.
type templateStore struct {
dir string
fetch Fetcher
texts map[string]map[string]string
}
// load is every template's text of the archive the plan names, by its path inside the archive.
func (s *templateStore) load(ctx context.Context, t Templates) (map[string]string, error) {
if texts, ok := s.texts[t.Digest]; ok {
return texts, nil
}
kept := filepath.Join(s.dir, strings.TrimPrefix(t.Digest, "sha256:")+".tar.gz")
body, err := os.ReadFile(kept)
if err != nil || digestOf(body) != t.Digest {
if body, err = s.fetch(ctx, t.Source); err != nil {
return nil, err
}
if got := digestOf(body); got != t.Digest {
return nil, fmt.Errorf("the templates at %s are %s, and the plan names %s: nothing was filled from them",
t.Source, got, t.Digest)
}
if err := writeKept(kept, body); err != nil {
// Said and gone on: the archive is in hand, and is fetched again next start.
logf("[provisioner-client] the templates %s could not be kept at %s: %v", t.Digest, kept, err)
}
}
texts, err := unpackTexts(body)
if err != nil {
return nil, fmt.Errorf("the templates %s cannot be read: %w", t.Digest, err)
}
if s.texts == nil {
s.texts = map[string]map[string]string{}
}
s.texts[t.Digest] = texts
return texts, nil
}
func digestOf(b []byte) string {
sum := sha256.Sum256(b)
return "sha256:" + hex.EncodeToString(sum[:])
}
func writeKept(at string, body []byte) error {
if err := os.MkdirAll(filepath.Dir(at), 0o700); err != nil {
return err
}
tmp, err := os.CreateTemp(filepath.Dir(at), ".templates-*")
if err != nil {
return err
}
defer os.Remove(tmp.Name())
if _, err := tmp.Write(body); err != nil {
tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
return os.Rename(tmp.Name(), at)
}
// unpackTexts is every regular file of a gzipped tar, by its clean path inside it.
func unpackTexts(body []byte) (map[string]string, error) {
zipped, err := gzip.NewReader(bytes.NewReader(body))
if err != nil {
return nil, err
}
defer zipped.Close()
r := tar.NewReader(zipped)
out := map[string]string{}
for {
h, err := r.Next()
if errors.Is(err, io.EOF) {
return out, nil
}
if err != nil {
return nil, err
}
if h.Typeflag != tar.TypeReg {
continue
}
b, err := io.ReadAll(io.LimitReader(r, maxTemplates))
if err != nil {
return nil, err
}
out[strings.TrimPrefix(path.Clean(h.Name), "./")] = string(b)
}
}
// placeholder is `${<kind>:<name>}`, as ADR 0349 clause 2 writes one.
var placeholder = regexp.MustCompile(`\$\{([a-z][a-z0-9-]*):([^}]+)\}`)
// Need is one value a template names.
type Need struct {
Kind string
Name string
}
func (n Need) String() string { return n.Kind + ":" + n.Name }
// needsOf is every placeholder a text names, in the order each first appears.
func needsOf(text string) []Need {
var out []Need
seen := map[Need]bool{}
for _, m := range placeholder.FindAllStringSubmatch(text, -1) {
n := Need{Kind: m[1], Name: m[2]}
if !seen[n] {
seen[n] = true
out = append(out, n)
}
}
return out
}
// render fills every placeholder of text from values, which holds each.
func render(text string, values map[Need]string) string {
return placeholder.ReplaceAllStringFunc(text, func(ref string) string {
m := placeholder.FindStringSubmatch(ref)
return values[Need{Kind: m[1], Name: m[2]}]
})
}
// configText is a config value as a file takes it: a string as its text, anything else as its JSON.
func configText(raw json.RawMessage) string {
var s string
if json.Unmarshal(raw, &s) == nil {
return s
}
return string(raw)
}
@@ -0,0 +1,25 @@
# The provisioner client's account as mesh-controller composes it (internal/broker ComposeAccounts, feature branch
# 57-config-and-provisioning with issue 647, which grants it the vault's secret-changed, issue 685, which lets its
# own machine's tool runner ask it, and issue 679, which lets it read the whole assignments mirror and its machine's
# keys of a provider's bucket of entries, here postgres_bound, and for gitea's route, route-adapter_bound and route-proxy_bound, issue 792; node-tools_bound for claude-code's console port, issue 761), the tool runner's account of the same machine
# carrying claude-code, and a `domain` user granted everything, standing for the controller, mesh-config and the
# vault, which own the buckets the client reads and answer its asks. Composed again if the client's grants change.
accounts {
MESH {
jetstream: enabled
users = [
{ user: "domain", password: "x" }
{ user: "provisioner.g14", password: "x", permissions: {
publish: { allow: ["$JS.API.CONSUMER.CREATE.KV_mesh-config_resolved.*.$KV.mesh-config_resolved.g14.>", "$JS.API.CONSUMER.CREATE.KV_mesh-controller_assignments.>", "$JS.API.CONSUMER.CREATE.KV_mesh-provisioner_applied.*.$KV.mesh-provisioner_applied.g14.>", "$JS.API.CONSUMER.CREATE.KV_mesh-provisioner_collect.*.$KV.mesh-provisioner_collect.g14.>", "$JS.API.CONSUMER.CREATE.KV_mesh-provisioner_needs.*.$KV.mesh-provisioner_needs.g14.>", "$JS.API.CONSUMER.CREATE.KV_mesh-provisioner_registry.>", "$JS.API.CONSUMER.CREATE.KV_postgres_bound.*.$KV.postgres_bound.g14.>", "$JS.API.CONSUMER.CREATE.KV_route-adapter_bound.*.$KV.route-adapter_bound.g14.>", "$JS.API.CONSUMER.CREATE.KV_route-proxy_bound.*.$KV.route-proxy_bound.g14.>", "$JS.API.CONSUMER.CREATE.KV_node-tools_bound.*.$KV.node-tools_bound.g14.>", "$JS.API.CONSUMER.DELETE.KV_mesh-config_resolved.>", "$JS.API.CONSUMER.DELETE.KV_mesh-controller_assignments.>", "$JS.API.CONSUMER.DELETE.KV_mesh-provisioner_applied.>", "$JS.API.CONSUMER.DELETE.KV_mesh-provisioner_collect.>", "$JS.API.CONSUMER.DELETE.KV_mesh-provisioner_needs.>", "$JS.API.CONSUMER.DELETE.KV_mesh-provisioner_registry.>", "$JS.API.CONSUMER.DELETE.KV_postgres_bound.>", "$JS.API.CONSUMER.DELETE.KV_route-adapter_bound.>", "$JS.API.CONSUMER.DELETE.KV_route-proxy_bound.>", "$JS.API.CONSUMER.DELETE.KV_node-tools_bound.>", "$JS.API.DIRECT.GET.KV_mesh-config_resolved.$KV.mesh-config_resolved.g14.>", "$JS.API.DIRECT.GET.KV_mesh-controller_assignments.>", "$JS.API.DIRECT.GET.KV_mesh-provisioner_applied.$KV.mesh-provisioner_applied.g14.>", "$JS.API.DIRECT.GET.KV_mesh-provisioner_collect.$KV.mesh-provisioner_collect.g14.>", "$JS.API.DIRECT.GET.KV_mesh-provisioner_needs.$KV.mesh-provisioner_needs.g14.>", "$JS.API.DIRECT.GET.KV_mesh-provisioner_registry.>", "$JS.API.DIRECT.GET.KV_postgres_bound.$KV.postgres_bound.g14.>", "$JS.API.DIRECT.GET.KV_route-adapter_bound.$KV.route-adapter_bound.g14.>", "$JS.API.DIRECT.GET.KV_route-proxy_bound.$KV.route-proxy_bound.g14.>", "$JS.API.DIRECT.GET.KV_node-tools_bound.$KV.node-tools_bound.g14.>", "$JS.API.STREAM.INFO.KV_mesh-config_resolved", "$JS.API.STREAM.INFO.KV_mesh-controller_assignments", "$JS.API.STREAM.INFO.KV_mesh-provisioner_applied", "$JS.API.STREAM.INFO.KV_mesh-provisioner_collect", "$JS.API.STREAM.INFO.KV_mesh-provisioner_needs", "$JS.API.STREAM.INFO.KV_mesh-provisioner_registry", "$JS.API.STREAM.INFO.KV_postgres_bound", "$JS.API.STREAM.INFO.KV_route-adapter_bound", "$JS.API.STREAM.INFO.KV_route-proxy_bound", "$JS.API.STREAM.INFO.KV_node-tools_bound", "$JS.FC.KV_mesh-config_resolved.>", "$JS.FC.KV_mesh-controller_assignments.>", "$JS.FC.KV_mesh-provisioner_applied.>", "$JS.FC.KV_mesh-provisioner_collect.>", "$JS.FC.KV_mesh-provisioner_needs.>", "$JS.FC.KV_mesh-provisioner_registry.>", "$JS.FC.KV_postgres_bound.>", "$JS.FC.KV_route-adapter_bound.>", "$JS.FC.KV_route-proxy_bound.>", "$JS.FC.KV_node-tools_bound.>", "$KV.mesh-provisioner_applied.g14.>", "$KV.mesh-provisioner_collect.g14.>", "$KV.mesh-provisioner_needs.g14.>", "mesh.seat.mesh-vault.call.get.provisioner~g14", "prov.applied.g14.>", "prov.changed.g14.>"] }
subscribe: { allow: ["_INBOX.provisioner.g14.>", "mesh.mod.mesh-vault.event.secret-changed", "prov.ask.g14"] }
allow_responses: { max: 1, ttl: "1m" }
} }
{ user: "g14.node-tools", password: "x", permissions: {
publish: { allow: ["$JS.API.DIRECT.GET.ASSIGNMENTS.mesh.assignment.g14.*", "$SRV.INFO", "$SRV.INFO.>", "$SRV.PING", "$SRV.PING.>", "mesh.control.g14.tools-alive", "mesh.mod.*.call.*.*.g14~node-tools", "mesh.mod.*.call.*.g14~node-tools", "mesh.mod.*.tool.>", "mesh.seat.*.call.*.*.g14~node-tools", "mesh.seat.*.call.*.g14~node-tools", "mesh.seat.*.tool.>", "prov.ask.g14"], deny: ["mesh.seat.node-launcher.tool.secret", "mesh.seat.node-launcher.tool.secret.*", "mesh.mod.*.tool.node-launcher.secret", "mesh.mod.*.tool.node-launcher.secret.*", "mesh.seat.node-launcher.call.secret.>", "mesh.mod.*.call.node-launcher.secret.>"] }
subscribe: { allow: ["$SRV.INFO", "$SRV.INFO.claude-code", "$SRV.INFO.claude-code.>", "$SRV.INFO.node-tools", "$SRV.INFO.node-tools.>", "$SRV.PING", "$SRV.PING.claude-code", "$SRV.PING.claude-code.>", "$SRV.PING.node-tools", "$SRV.PING.node-tools.>", "$SRV.STATS", "$SRV.STATS.claude-code", "$SRV.STATS.claude-code.>", "$SRV.STATS.node-tools", "$SRV.STATS.node-tools.>", "_INBOX.g14.node-tools.>", "mesh.assignment.g14.*", "mesh.mod.claude-code.call.>", "mesh.mod.claude-code.tool.>", "prov.changed.g14.>"] }
allow_responses: { max: 1, ttl: "1m" }
} }
]
}
}
@@ -0,0 +1,119 @@
# The bottom bar (module i3status-rust, novox/hq ADR 0208). Owned by the mesh: replaced at every
# push. Adopted from the workstations of 2026-10-04 with what follows a machine's hardware or one
# person's devices left out: a block that depends on the hardware is contributed by the module that
# knows it (novox/hq ADR 0255), and a headset's address is not the bar's to know. Every block written
# here works the same on any machine.
icons_format = "{icon}"
[theme]
theme = "plain"
[theme.overrides]
idle_bg = "#000000"
idle_fg = "#f1f1f1"
info_bg = "#000000"
info_fg = "#f1f1f1"
good_bg = "#000000"
good_fg = "#859900"
warning_bg = "#000000"
warning_fg = "#de5200"
critical_bg = "#000000"
critical_fg = "#ff0000"
separator = " | "
separator_fg = "#ffffff"
separator_bg = "#000000"
[icons]
icons = "custom-icons"
[[block]]
block = "tea_timer"
format = "$icon{ $minutes:$seconds|}"
done_cmd = "notify-send 'Timer finished'"
# Pending updates: official and, where an AUR helper is installed, AUR. The script keeps the last good
# count through a network gap and shows an error only after ten failed checks in a row. Click: the
# list, in the launcher's menu.
[[block]]
block = "custom"
command = "~/.local/bin/i3status-updates 10"
json = true
interval = 1800
[[block.click]]
button = "left"
cmd = "checkupdates | dmenu -l 20 -p Updates"
[[block]]
block = "cpu"
[[block]]
block = "disk_space"
path = "/"
info_type = "used"
alert_unit = "GB"
interval = 20
warning = 850
alert = 920
format = "$icon / $used.eng(w:2) ($percentage.eng(w:2))"
[[block]]
block = "disk_space"
path = "/home"
info_type = "used"
alert_unit = "GB"
interval = 20
warning = 850
alert = 920
format = "$icon /home $used.eng(w:2) ($percentage.eng(w:2))"
[[block]]
block = "memory"
# What programs hold, without the cache the kernel gives back when asked: the figure the memory-pressure
# module judges by. Counting the cache as used read near full on a machine with room to spare.
format = "$icon $mem_used.eng(w:2) ($mem_used_percents.eng(w:2))"
format_alt = "$icon_swap $swap_used.eng(w:2) ($swap_used_percents.eng(w:2))"
# Blocks other modules contribute beside the machine's resources (novox/hq ADR 0255), rendered from
# what they show by this module's template, and only on a machine that has what they show.
[[block]]
block = "docker"
interval = 2
format = "$icon $running/$total"
[[block]]
block = "sound"
[[block.click]]
button = "left"
cmd = "pavucontrol"
# Blocks other modules contribute beside the sound (novox/hq ADR 0255): the power module's battery and
# power draw, where the machine has them.
# power
[[block]]
block = "battery"
format = "$icon $percentage{ $time|}"
charging_format = "$icon $percentage{ $time|}"
not_charging_format = "$icon $percentage"
full_format = "$icon $percentage"
empty_format = "$icon $percentage"
missing_format = ""
# The weather from the Norwegian Meteorological Institute, which needs no key, where the machine is.
[[block]]
block = "weather"
format = "$icon $weather_verbose ($location) $temp"
autolocate = true
autolocate_interval = 600
[block.service]
name = "metno"
[[block]]
block = "notify"
driver = "dunst"
format = " $icon {($notification_count.eng(w:1))|}"
[[block]]
block = "time"
interval = 1
format = "$timestamp.datetime(f:'%a %d/%m %H:%M:%S')"
@@ -0,0 +1,36 @@
# The top bar (module i3status-rust, novox/hq ADR 0208): the focused window's title and the uptime.
# Owned by the mesh: replaced at every push. Adopted unchanged from the workstations of 2026-10-04.
icons_format = "{icon}"
[theme]
theme = "plain"
[theme.overrides]
idle_bg = "#000000"
idle_fg = "#f1f1f1"
info_bg = "#000000"
info_fg = "#f1f1f1"
good_bg = "#000000"
good_fg = "#859900"
warning_bg = "#000000"
warning_fg = "#de5200"
critical_bg = "#000000"
critical_fg = "#ff0000"
separator = " | "
separator_fg = "#ffffff"
separator_bg = "#000000"
[icons]
icons = "custom-icons"
[[block]]
block = "focused_window"
[block.format]
full = " $title.str(max_w:50) |"
short = " $title.str(max_w:100) |"
[[block]]
block = "uptime"
interval = 60
# Blocks other modules contribute to the top bar (novox/hq ADR 0255). None do yet; both places are kept
# so that one that does is never dropped.
@@ -0,0 +1,126 @@
# The bottom bar (module i3status-rust, novox/hq ADR 0208). Owned by the mesh: replaced at every
# push. Adopted from the workstations of 2026-10-04 with what follows a machine's hardware or one
# person's devices left out: a block that depends on the hardware is contributed by the module that
# knows it (novox/hq ADR 0255), and a headset's address is not the bar's to know. Every block written
# here works the same on any machine.
icons_format = "{icon}"
[theme]
theme = "plain"
[theme.overrides]
idle_bg = "#000000"
idle_fg = "#f1f1f1"
info_bg = "#000000"
info_fg = "#f1f1f1"
good_bg = "#000000"
good_fg = "#859900"
warning_bg = "#000000"
warning_fg = "#de5200"
critical_bg = "#000000"
critical_fg = "#ff0000"
separator = " | "
separator_fg = "#ffffff"
separator_bg = "#000000"
[icons]
icons = "custom-icons"
[[block]]
block = "tea_timer"
format = "$icon{ $minutes:$seconds|}"
done_cmd = "notify-send 'Timer finished'"
# Pending updates: official and, where an AUR helper is installed, AUR. The script keeps the last good
# count through a network gap and shows an error only after ten failed checks in a row. Click: the
# list, in the launcher's menu.
[[block]]
block = "custom"
command = "~/.local/bin/i3status-updates 10"
json = true
interval = 1800
[[block.click]]
button = "left"
cmd = "checkupdates | dmenu -l 20 -p Updates"
[[block]]
block = "cpu"
[[block]]
block = "disk_space"
path = "/"
info_type = "used"
alert_unit = "GB"
interval = 20
warning = 850
alert = 920
format = "$icon / $used.eng(w:2) ($percentage.eng(w:2))"
[[block]]
block = "disk_space"
path = "/home"
info_type = "used"
alert_unit = "GB"
interval = 20
warning = 850
alert = 920
format = "$icon /home $used.eng(w:2) ($percentage.eng(w:2))"
[[block]]
block = "memory"
# What programs hold, without the cache the kernel gives back when asked: the figure the memory-pressure
# module judges by. Counting the cache as used read near full on a machine with room to spare.
format = "$icon $mem_used.eng(w:2) ($mem_used_percents.eng(w:2))"
format_alt = "$icon_swap $swap_used.eng(w:2) ($swap_used_percents.eng(w:2))"
# Blocks other modules contribute beside the machine's resources (novox/hq ADR 0255), rendered from
# what they show by this module's template, and only on a machine that has what they show.
[[block]]
block = "docker"
interval = 2
format = "$icon $running/$total"
[[block]]
block = "sound"
[[block.click]]
button = "left"
cmd = "pavucontrol"
# Blocks other modules contribute beside the sound (novox/hq ADR 0255): the power module's battery and
# power draw, where the machine has them.
# power
[[block]]
block = "battery"
format = "$icon $percentage{ $time|}"
charging_format = "$icon $percentage{ $time|}"
not_charging_format = "$icon $percentage"
full_format = "$icon $percentage"
empty_format = "$icon $percentage"
missing_format = ""
# power
[[block]]
block = "custom"
command = "~/.local/bin/i3status-value power.draw workstation"
interval = 5
hide_when_empty = true
# The weather from the Norwegian Meteorological Institute, which needs no key, where the machine is.
[[block]]
block = "weather"
format = "$icon $weather_verbose ($location) $temp"
autolocate = true
autolocate_interval = 600
[block.service]
name = "metno"
[[block]]
block = "notify"
driver = "dunst"
format = " $icon {($notification_count.eng(w:1))|}"
[[block]]
block = "time"
interval = 1
format = "$timestamp.datetime(f:'%a %d/%m %H:%M:%S')"
@@ -0,0 +1,36 @@
# The top bar (module i3status-rust, novox/hq ADR 0208): the focused window's title and the uptime.
# Owned by the mesh: replaced at every push. Adopted unchanged from the workstations of 2026-10-04.
icons_format = "{icon}"
[theme]
theme = "plain"
[theme.overrides]
idle_bg = "#000000"
idle_fg = "#f1f1f1"
info_bg = "#000000"
info_fg = "#f1f1f1"
good_bg = "#000000"
good_fg = "#859900"
warning_bg = "#000000"
warning_fg = "#de5200"
critical_bg = "#000000"
critical_fg = "#ff0000"
separator = " | "
separator_fg = "#ffffff"
separator_bg = "#000000"
[icons]
icons = "custom-icons"
[[block]]
block = "focused_window"
[block.format]
full = " $title.str(max_w:50) |"
short = " $title.str(max_w:100) |"
[[block]]
block = "uptime"
interval = 60
# Blocks other modules contribute to the top bar (novox/hq ADR 0255). None do yet; both places are kept
# so that one that does is never dropped.
@@ -0,0 +1,109 @@
# The bottom bar (module i3status-rust, novox/hq ADR 0208). Owned by the mesh: replaced at every
# push. Adopted from the workstations of 2026-10-04 with what follows a machine's hardware or one
# person's devices left out: a block that depends on the hardware is contributed by the module that
# knows it (novox/hq ADR 0255), and a headset's address is not the bar's to know. Every block written
# here works the same on any machine.
icons_format = "{icon}"
[theme]
theme = "plain"
[theme.overrides]
idle_bg = "#000000"
idle_fg = "#f1f1f1"
info_bg = "#000000"
info_fg = "#f1f1f1"
good_bg = "#000000"
good_fg = "#859900"
warning_bg = "#000000"
warning_fg = "#de5200"
critical_bg = "#000000"
critical_fg = "#ff0000"
separator = " | "
separator_fg = "#ffffff"
separator_bg = "#000000"
[icons]
icons = "custom-icons"
[[block]]
block = "tea_timer"
format = "$icon{ $minutes:$seconds|}"
done_cmd = "notify-send 'Timer finished'"
# Pending updates: official and, where an AUR helper is installed, AUR. The script keeps the last good
# count through a network gap and shows an error only after ten failed checks in a row. Click: the
# list, in the launcher's menu.
[[block]]
block = "custom"
command = "~/.local/bin/i3status-updates 10"
json = true
interval = 1800
[[block.click]]
button = "left"
cmd = "checkupdates | dmenu -l 20 -p Updates"
[[block]]
block = "cpu"
[[block]]
block = "disk_space"
path = "/"
info_type = "used"
alert_unit = "GB"
interval = 20
warning = 850
alert = 920
format = "$icon / $used.eng(w:2) ($percentage.eng(w:2))"
[[block]]
block = "disk_space"
path = "/home"
info_type = "used"
alert_unit = "GB"
interval = 20
warning = 850
alert = 920
format = "$icon /home $used.eng(w:2) ($percentage.eng(w:2))"
[[block]]
block = "memory"
# What programs hold, without the cache the kernel gives back when asked: the figure the memory-pressure
# module judges by. Counting the cache as used read near full on a machine with room to spare.
format = "$icon $mem_used.eng(w:2) ($mem_used_percents.eng(w:2))"
format_alt = "$icon_swap $swap_used.eng(w:2) ($swap_used_percents.eng(w:2))"
# Blocks other modules contribute beside the machine's resources (novox/hq ADR 0255), rendered from
# what they show by this module's template, and only on a machine that has what they show.
[[block]]
block = "docker"
interval = 2
format = "$icon $running/$total"
[[block]]
block = "sound"
[[block.click]]
button = "left"
cmd = "pavucontrol"
# Blocks other modules contribute beside the sound (novox/hq ADR 0255): the power module's battery and
# power draw, where the machine has them.
# The weather from the Norwegian Meteorological Institute, which needs no key, where the machine is.
[[block]]
block = "weather"
format = "$icon $weather_verbose ($location) $temp"
autolocate = true
autolocate_interval = 600
[block.service]
name = "metno"
[[block]]
block = "notify"
driver = "dunst"
format = " $icon {($notification_count.eng(w:1))|}"
[[block]]
block = "time"
interval = 1
format = "$timestamp.datetime(f:'%a %d/%m %H:%M:%S')"
@@ -0,0 +1,36 @@
# The top bar (module i3status-rust, novox/hq ADR 0208): the focused window's title and the uptime.
# Owned by the mesh: replaced at every push. Adopted unchanged from the workstations of 2026-10-04.
icons_format = "{icon}"
[theme]
theme = "plain"
[theme.overrides]
idle_bg = "#000000"
idle_fg = "#f1f1f1"
info_bg = "#000000"
info_fg = "#f1f1f1"
good_bg = "#000000"
good_fg = "#859900"
warning_bg = "#000000"
warning_fg = "#de5200"
critical_bg = "#000000"
critical_fg = "#ff0000"
separator = " | "
separator_fg = "#ffffff"
separator_bg = "#000000"
[icons]
icons = "custom-icons"
[[block]]
block = "focused_window"
[block.format]
full = " $title.str(max_w:50) |"
short = " $title.str(max_w:100) |"
[[block]]
block = "uptime"
interval = 60
# Blocks other modules contribute to the top bar (novox/hq ADR 0255). None do yet; both places are kept
# so that one that does is never dropped.
@@ -0,0 +1,116 @@
# The bottom bar (module i3status-rust, novox/hq ADR 0208). Owned by the mesh: replaced at every
# push. Adopted from the workstations of 2026-10-04 with what follows a machine's hardware or one
# person's devices left out: a block that depends on the hardware is contributed by the module that
# knows it (novox/hq ADR 0255), and a headset's address is not the bar's to know. Every block written
# here works the same on any machine.
icons_format = "{icon}"
[theme]
theme = "plain"
[theme.overrides]
idle_bg = "#000000"
idle_fg = "#f1f1f1"
info_bg = "#000000"
info_fg = "#f1f1f1"
good_bg = "#000000"
good_fg = "#859900"
warning_bg = "#000000"
warning_fg = "#de5200"
critical_bg = "#000000"
critical_fg = "#ff0000"
separator = " | "
separator_fg = "#ffffff"
separator_bg = "#000000"
[icons]
icons = "custom-icons"
[[block]]
block = "tea_timer"
format = "$icon{ $minutes:$seconds|}"
done_cmd = "notify-send 'Timer finished'"
# Pending updates: official and, where an AUR helper is installed, AUR. The script keeps the last good
# count through a network gap and shows an error only after ten failed checks in a row. Click: the
# list, in the launcher's menu.
[[block]]
block = "custom"
command = "~/.local/bin/i3status-updates 10"
json = true
interval = 1800
[[block.click]]
button = "left"
cmd = "checkupdates | dmenu -l 20 -p Updates"
[[block]]
block = "cpu"
[[block]]
block = "disk_space"
path = "/"
info_type = "used"
alert_unit = "GB"
interval = 20
warning = 850
alert = 920
format = "$icon / $used.eng(w:2) ($percentage.eng(w:2))"
[[block]]
block = "disk_space"
path = "/home"
info_type = "used"
alert_unit = "GB"
interval = 20
warning = 850
alert = 920
format = "$icon /home $used.eng(w:2) ($percentage.eng(w:2))"
[[block]]
block = "memory"
# What programs hold, without the cache the kernel gives back when asked: the figure the memory-pressure
# module judges by. Counting the cache as used read near full on a machine with room to spare.
format = "$icon $mem_used.eng(w:2) ($mem_used_percents.eng(w:2))"
format_alt = "$icon_swap $swap_used.eng(w:2) ($swap_used_percents.eng(w:2))"
# Blocks other modules contribute beside the machine's resources (novox/hq ADR 0255), rendered from
# what they show by this module's template, and only on a machine that has what they show.
[[block]]
block = "docker"
interval = 2
format = "$icon $running/$total"
[[block]]
block = "sound"
[[block.click]]
button = "left"
cmd = "pavucontrol"
# Blocks other modules contribute beside the sound (novox/hq ADR 0255): the power module's battery and
# power draw, where the machine has them.
# power
[[block]]
block = "custom"
command = "~/.local/bin/i3status-value power.draw workstation"
interval = 5
hide_when_empty = true
# The weather from the Norwegian Meteorological Institute, which needs no key, where the machine is.
[[block]]
block = "weather"
format = "$icon $weather_verbose ($location) $temp"
autolocate = true
autolocate_interval = 600
[block.service]
name = "metno"
[[block]]
block = "notify"
driver = "dunst"
format = " $icon {($notification_count.eng(w:1))|}"
[[block]]
block = "time"
interval = 1
format = "$timestamp.datetime(f:'%a %d/%m %H:%M:%S')"
@@ -0,0 +1,36 @@
# The top bar (module i3status-rust, novox/hq ADR 0208): the focused window's title and the uptime.
# Owned by the mesh: replaced at every push. Adopted unchanged from the workstations of 2026-10-04.
icons_format = "{icon}"
[theme]
theme = "plain"
[theme.overrides]
idle_bg = "#000000"
idle_fg = "#f1f1f1"
info_bg = "#000000"
info_fg = "#f1f1f1"
good_bg = "#000000"
good_fg = "#859900"
warning_bg = "#000000"
warning_fg = "#de5200"
critical_bg = "#000000"
critical_fg = "#ff0000"
separator = " | "
separator_fg = "#ffffff"
separator_bg = "#000000"
[icons]
icons = "custom-icons"
[[block]]
block = "focused_window"
[block.format]
full = " $title.str(max_w:50) |"
short = " $title.str(max_w:100) |"
[[block]]
block = "uptime"
interval = 60
# Blocks other modules contribute to the top bar (novox/hq ADR 0255). None do yet; both places are kept
# so that one that does is never dropped.
@@ -0,0 +1,26 @@
# adwaita
export GTK2_RC_FILES='/usr/share/themes/Adwaita-dark/gtk-2.0/gtkrc'
export GTK_THEME='Adwaita:dark'
export QT_QPA_PLATFORMTHEME='qt6ct'
export QT_SELECT='6'
export QT_STYLE_OVERRIDE='Fusion'
export XCURSOR_SIZE='24'
export XCURSOR_THEME='Adwaita'
# clipmenu
export CM_HISTLENGTH='15'
export CM_MAX_CLIPS='500'
export CM_SELECTIONS='clipboard'
# i3
export XDG_CURRENT_DESKTOP='i3'
export XDG_SESSION_DESKTOP='i3'
# xterm
export TERMINAL='xterm'
# zsh
export EDITOR='vim'
export VISUAL='vim'
export XDG_CONFIG_HOME='/home/op/.config'
# zsh
case ":${PATH}:" in *':/home/op/scripts/bin:'*) ;; *) PATH='/home/op/scripts/bin'"${PATH:+:${PATH}}" ;; esac
case ":${PATH}:" in *':/home/op/scripts:'*) ;; *) PATH='/home/op/scripts'"${PATH:+:${PATH}}" ;; esac
case ":${PATH}:" in *':/home/op/.local/bin:'*) ;; *) PATH='/home/op/.local/bin'"${PATH:+:${PATH}}" ;; esac
export PATH
@@ -0,0 +1,23 @@
# adwaita
GTK2_RC_FILES=/usr/share/themes/Adwaita-dark/gtk-2.0/gtkrc
GTK_THEME=Adwaita:dark
QT_QPA_PLATFORMTHEME=qt6ct
QT_SELECT=6
QT_STYLE_OVERRIDE=Fusion
XCURSOR_SIZE=24
XCURSOR_THEME=Adwaita
# clipmenu
CM_HISTLENGTH=15
CM_MAX_CLIPS=500
CM_SELECTIONS=clipboard
# i3
XDG_CURRENT_DESKTOP=i3
XDG_SESSION_DESKTOP=i3
# xterm
TERMINAL=xterm
# zsh
EDITOR=vim
VISUAL=vim
XDG_CONFIG_HOME=/home/op/.config
# zsh
PATH=/home/op/.local/bin:/home/op/scripts:/home/op/scripts/bin${PATH:+:$PATH}
@@ -0,0 +1,9 @@
# zsh
export EDITOR='vim'
export VISUAL='vim'
export XDG_CONFIG_HOME='/home/op/.config'
# zsh
case ":${PATH}:" in *':/home/op/scripts/bin:'*) ;; *) PATH='/home/op/scripts/bin'"${PATH:+:${PATH}}" ;; esac
case ":${PATH}:" in *':/home/op/scripts:'*) ;; *) PATH='/home/op/scripts'"${PATH:+:${PATH}}" ;; esac
case ":${PATH}:" in *':/home/op/.local/bin:'*) ;; *) PATH='/home/op/.local/bin'"${PATH:+:${PATH}}" ;; esac
export PATH
@@ -0,0 +1,6 @@
# zsh
EDITOR=vim
VISUAL=vim
XDG_CONFIG_HOME=/home/op/.config
# zsh
PATH=/home/op/.local/bin:/home/op/scripts:/home/op/scripts/bin${PATH:+:$PATH}
@@ -0,0 +1,310 @@
# i3 config file (v4), written by the mesh (module i3, novox/hq ADR 0208). Replaced at every push;
# change the module instead. i3's user guide is the reference.
#
# Other modules add to this configuration as contributions to node-display-session (novox/hq ADR
# 0212): the mesh places their lines near the end, each module's under a line naming it, where every
# variable set here ($mod, $ws1 … $ws10) is in scope. A file of yours in ~/.config/i3/config.d/ is read
# after them, by the include at the very end, and is yours.
#
# The reload watcher of this module reloads i3 when this file or a drop-in changes, after checking the
# result with i3 -C; it never reloads into a configuration with errors.
# Font for window titles, and the bars below: the mesh's monospace face (research 026/04).
font pango:JetBrainsMono Nerd Font 11
# XDG autostart entries (~/.config/autostart, /etc/xdg/autostart), started once at login.
exec --no-startup-id dex --autostart --environment i3
#########################################
###### Keys ####
#########################################
# To find key symbols: xmodmap -pke / xmodmap -pm
set $mod Mod4
set $alt Mod1
set $shift Shift
set $ctrl Control
# use these keys for focus, movement, and resize directions when reaching for
# the arrows is not convenient
set $left h
set $down j
set $up k
set $right l
# use Mouse+$mod to drag floating windows to their wanted position
floating_modifier $mod
# move tiling windows via drag & drop by left-clicking into the title bar,
# or left-clicking anywhere into the window while holding the floating modifier.
tiling_drag modifier titlebar
# start a terminal: whichever the terminal module names in $TERMINAL
bindsym $mod+Return exec i3-sensible-terminal
# kill focused window
bindsym $mod+$shift+q kill
# change focus
bindsym $mod+$left focus left
bindsym $mod+$down focus down
bindsym $mod+$up focus up
bindsym $mod+$right focus right
bindsym $mod+Left focus left
bindsym $mod+Down focus down
bindsym $mod+Up focus up
bindsym $mod+Right focus right
# move focused window
bindsym $mod+$shift+$left move left
bindsym $mod+$shift+$down move down
bindsym $mod+$shift+$up move up
bindsym $mod+$shift+$right move right
bindsym $mod+$shift+Left move left
bindsym $mod+$shift+Down move down
bindsym $mod+$shift+Up move up
bindsym $mod+$shift+Right move right
# split in horizontal orientation
bindsym $mod+c split h
# split in vertical orientation
bindsym $mod+v split v
# enter fullscreen mode for the focused container
bindsym $mod+f fullscreen toggle
# change container layout (stacked, tabbed, toggle split)
bindsym $mod+s layout stacking
bindsym $mod+w layout tabbed
bindsym $mod+e layout toggle split
# toggle tiling / floating
bindsym $mod+$shift+space floating toggle
# change focus between tiling / floating windows
bindsym $mod+space focus mode_toggle
# focus the parent container
bindsym $mod+a focus parent
# alt-tab functionality
bindsym $mod+Tab workspace back_and_forth
#########################################
###### Workspace mgmt ####
#########################################
set $ws1 "1"
set $ws2 "2"
set $ws3 "3"
set $ws4 "4"
set $ws5 "5"
set $ws6 "6"
set $ws7 "7"
set $ws8 "8"
set $ws9 "9"
set $ws10 "10"
bindsym $mod+1 workspace number $ws1
bindsym $mod+2 workspace number $ws2
bindsym $mod+3 workspace number $ws3
bindsym $mod+4 workspace number $ws4
bindsym $mod+5 workspace number $ws5
bindsym $mod+6 workspace number $ws6
bindsym $mod+7 workspace number $ws7
bindsym $mod+8 workspace number $ws8
bindsym $mod+9 workspace number $ws9
bindsym $mod+0 workspace number $ws10
bindsym $mod+$shift+1 move container to workspace number $ws1
bindsym $mod+$shift+2 move container to workspace number $ws2
bindsym $mod+$shift+3 move container to workspace number $ws3
bindsym $mod+$shift+4 move container to workspace number $ws4
bindsym $mod+$shift+5 move container to workspace number $ws5
bindsym $mod+$shift+6 move container to workspace number $ws6
bindsym $mod+$shift+7 move container to workspace number $ws7
bindsym $mod+$shift+8 move container to workspace number $ws8
bindsym $mod+$shift+9 move container to workspace number $ws9
bindsym $mod+$shift+0 move container to workspace number $ws10
#########################################
###### Window mgmt ####
#########################################
set $resize_px 10 px
bindsym $mod+$ctrl+$left resize shrink width $resize_px
bindsym $mod+$ctrl+$down resize grow height $resize_px
bindsym $mod+$ctrl+$up resize shrink height $resize_px
bindsym $mod+$ctrl+$right resize grow width $resize_px
#########################################
###### Session mgmt ####
#########################################
bindsym $mod+$shift+e exec "i3-nagbar -t warning -m 'You pressed the exit shortcut. Do you really want to exit i3? This will end your X session.' -B 'Yes, exit i3' 'i3-msg exit'"
# Lock the screen through logind, so the one locker the lock screen's module runs handles it (and
# suspend and lid close too).
bindsym $mod+Delete exec --no-startup-id loginctl lock-session
# reload the configuration file
bindsym $mod+$shift+c reload
# restart i3 inplace (preserves your layout/session, can be used to upgrade i3)
bindsym $mod+$shift+r restart
#########################################
###### Borders ####
#########################################
default_border pixel 1
smart_borders on
#########################################
###### Gaps ####
#########################################
gaps inner 0
gaps outer 0
# Only the accent-bearing slots are themed; background and text keep i3's own defaults. Borders are
# `pixel`, so no title bar shows: the colour says which window has focus.
# border background text indicator child_border
client.focused #de5200 #de5200 #1E2127 #de5200 #de5200
client.urgent #900000 #900000 #ffffff #900000 #900000
#########################################
###### Until their modules carry them ##
#########################################
# Each line below belongs to something other than i3, named on its line. When that module is written
# it contributes the line to node-display-session, and the line goes from here in the same change.
# The launcher, the clipboard, the wallpaper, the bars, a machine model's keys and the peripherals'
# tray already contribute theirs (rofi, clipmenu, feh, i3status-rust, asus-zephyrus-g14,
# polychromatic).
# the operator's scripts: volume, games volume, sessions, screenshot
bindsym XF86AudioRaiseVolume exec --no-startup-id volume-notify up
bindsym XF86AudioLowerVolume exec --no-startup-id volume-notify down
bindsym XF86AudioMute exec --no-startup-id volume-notify mute
bindsym XF86AudioMicMute exec --no-startup-id mic-notify
bindsym $ctrl+XF86AudioRaiseVolume exec --no-startup-id set-games-volume 5
bindsym $ctrl+XF86AudioLowerVolume exec --no-startup-id set-games-volume -5
bindsym $mod+$shift+Return exec --no-startup-id ~/scripts/i3-sessions/launcher.sh
bindsym --release $ctrl+$shift+x exec --no-startup-id $XDG_CONFIG_HOME/i3/scripts/screenshot.sh
#########################################
###### Other modules' lines ####
#########################################
# Placed by the mesh from every other module's contribution (novox/hq ADR 0212): the launcher, the
# clipboard, the wallpaper, the bars, a machine model's keys, the peripherals' tray. Each module's
# under a line naming it.
# asus-zephyrus-g14
# The laptop's own lines in i3 (module asus-zephyrus-g14, novox/hq ADR 0208, ADR 0210). Owned by the
# mesh: replaced at every push. Once the controller places contributions to node-display-session
# (ADR 0210), these become the module's contribution instead of a file in i3's directory.
#
# The keys the firmware turns into ordinary key presses. The vendor keys that reach no X client are
# triggerhappy's (/etc/triggerhappy/triggers.d/asus-g14.conf): M4 and Fn+F4/F5 for media, Fn+F7/F8 for
# the panel, Fn+F10 for the touchpad. The keyboard backlight (Fn+F2/F3) is the firmware's and asusd's.
# Fn+F6, the screenshot key: the firmware sends Super+Shift+S. Released before it runs, because the
# screenshot grabs the pointer to select a region, which fails while the key is still held.
bindsym --release $mod+Shift+s exec --no-startup-id $XDG_CONFIG_HOME/i3/scripts/screenshot.sh
# Fn+F9, the display key: the firmware sends Super+P. Odd workspaces to the panel, even ones to the
# external output.
bindsym $mod+p exec --no-startup-id @ASUS_LIB@/bin/zephyrus-display order
# The keyboard backlight's level, shown when it changes.
exec --no-startup-id @ASUS_LIB@/bin/zephyrus-kbd-notify
# The laptop's own lines in i3 (module asus-zephyrus-g14, novox/hq ADR 0208, ADR 0210). Owned by the
# mesh: replaced at every push. Once the controller places contributions to node-display-session
# (ADR 0210), these become the module's contribution instead of a file in i3's directory.
#
# The model's panel and touchpad.
# The internal panel is the primary output, where the bars' tray goes. Which monitors are on and where
# is the display server's (autorandr, run at every session start).
exec --no-startup-id @ASUS_LIB@/bin/zephyrus-display primary
# The touchpad's settings again, by hand. X applies them itself whenever the device appears.
bindsym $mod+Shift+x exec --no-startup-id @ASUS_LIB@/bin/zephyrus-touchpad reset
# clipmenu
# The clipboard's history key (module clipmenu, novox/hq ADR 0208). Owned by the mesh: replaced at
# every push. clipmenu shows the history through `dmenu`, the node's dmenu-compatible command, which
# the holder of node-launcher answers (rofi on the workstations); the chosen entry is put back on the
# clipboard.
bindsym $mod+period exec --no-startup-id clipmenu -p Clipboard
# dunst
# The notifications' key (module dunst, novox/hq ADR 0208). Owned by the mesh: replaced at every push.
# Super+N opens the menu (rofi) of the actions and links of every notification on screen, so one is
# chosen from the keyboard as a tap chooses it on the notification.
bindsym $mod+n exec --no-startup-id dunstctl context
# feh
# The wallpaper's key (module feh, novox/hq ADR 0208). Owned by the mesh: replaced at every push.
# It puts the declared wallpaper back, after a monitor change or a wallpaper set for the session.
bindsym $mod+Shift+b exec --no-startup-id ~/.fehbg
# i3status-rust
# The bars (module i3status-rust, novox/hq ADR 0208). Owned by the mesh: replaced at every push.
# i3 reads this file through its configuration's `include ~/.config/i3/config.d/*.conf`. The bottom
# bar shows the machine; the top bar the focused window and the tray, on the primary output. The
# face is the monospace one every desktop module names.
bar {
font pango:JetBrainsMono Nerd Font 11
position bottom
status_command i3status-rs ~/.config/i3status-rust/bottom-bar.toml
tray_output none
colors {
separator #ffffff
background #000000
statusline #ffffff
# The text on an accent-coloured button is dark: light text on the accent was barely
# legible.
focused_workspace #de5200 #de5200 #000000
active_workspace #de5200 #de5200 #000000
inactive_workspace #000000 #000000 #ffffff
urgent_workspace #2f343a #900000 #ffffff
}
}
bar {
font pango:JetBrainsMono Nerd Font 11
position top
status_command i3status-rs ~/.config/i3status-rust/top-bar.toml
workspace_buttons no
tray_output primary
colors {
separator #ffffff
background #000000
statusline #ffffff
}
}
# polychromatic
# The Razer peripherals' tray (module polychromatic, novox/hq ADR 0208, ADR 0212). Owned by the mesh:
# replaced at every push. This line is the tray's one start, at the session's start (exec, not
# exec_always, so a reload starts nothing). The package's own login helper starts it too while the
# application's setting "Start the tray applet when I log on" is ticked; polychromatic_check names
# that as a second start.
exec --no-startup-id polychromatic-tray-applet
# rofi
# The launcher's key bindings (module rofi, novox/hq ADR 0208). Owned by the mesh: replaced at every
# push. i3 reads this file through its configuration's `include ~/.config/i3/config.d/*.conf`, after
# the variables it sets, so $mod is i3's.
bindsym $mod+d exec --no-startup-id ~/.local/bin/rofi-launch drun
bindsym $mod+t exec --no-startup-id ~/.local/bin/rofi-launch run
bindsym $mod+Shift+t exec --no-startup-id ~/.local/bin/rofi-launch sudo
bindsym $mod+Shift+w exec --no-startup-id ~/.local/bin/rofi-launch window
bindsym $mod+Escape exec --no-startup-id ~/.local/bin/rofi-powermenu
# slack
# Slack (module slack, novox/hq ADR 0208, ADR 0212). Owned by the mesh: replaced at every push.
# Its one start: at login, to the tray (-s), with the arguments of the package's own desktop entry.
# No XDG autostart entry starts it as well; slack_check names one if it appears.
exec --no-startup-id /usr/bin/slack --gtk-version=3 -s
# The chat window. Slack owns four X windows, and the only one i3 manages has the class "slack" in
# lower case; the three of class "Slack" (the packaged entry's StartupWMClass) are its unmanaged
# helpers and its tray icon, so a rule on class="Slack" matches nothing. window_role keeps a call or
# screen-share window out of the rules. $ws3 is i3's, in scope here.
for_window [class="(?i)^slack$" window_role="browser-window"] move to workspace $ws3
for_window [class="(?i)^slack$" window_role="browser-window"] floating disable
for_window [class="(?i)^slack$" window_role="browser-window"] border pixel 2
#########################################
###### Your own files ####
#########################################
include ~/.config/i3/config.d/*.conf
@@ -0,0 +1,201 @@
# i3 config file (v4), written by the mesh (module i3, novox/hq ADR 0208). Replaced at every push;
# change the module instead. i3's user guide is the reference.
#
# Other modules add to this configuration as contributions to node-display-session (novox/hq ADR
# 0212): the mesh places their lines near the end, each module's under a line naming it, where every
# variable set here ($mod, $ws1 … $ws10) is in scope. A file of yours in ~/.config/i3/config.d/ is read
# after them, by the include at the very end, and is yours.
#
# The reload watcher of this module reloads i3 when this file or a drop-in changes, after checking the
# result with i3 -C; it never reloads into a configuration with errors.
# Font for window titles, and the bars below: the mesh's monospace face (research 026/04).
font pango:JetBrainsMono Nerd Font 11
# XDG autostart entries (~/.config/autostart, /etc/xdg/autostart), started once at login.
exec --no-startup-id dex --autostart --environment i3
#########################################
###### Keys ####
#########################################
# To find key symbols: xmodmap -pke / xmodmap -pm
set $mod Mod4
set $alt Mod1
set $shift Shift
set $ctrl Control
# use these keys for focus, movement, and resize directions when reaching for
# the arrows is not convenient
set $left h
set $down j
set $up k
set $right l
# use Mouse+$mod to drag floating windows to their wanted position
floating_modifier $mod
# move tiling windows via drag & drop by left-clicking into the title bar,
# or left-clicking anywhere into the window while holding the floating modifier.
tiling_drag modifier titlebar
# start a terminal: whichever the terminal module names in $TERMINAL
bindsym $mod+Return exec i3-sensible-terminal
# kill focused window
bindsym $mod+$shift+q kill
# change focus
bindsym $mod+$left focus left
bindsym $mod+$down focus down
bindsym $mod+$up focus up
bindsym $mod+$right focus right
bindsym $mod+Left focus left
bindsym $mod+Down focus down
bindsym $mod+Up focus up
bindsym $mod+Right focus right
# move focused window
bindsym $mod+$shift+$left move left
bindsym $mod+$shift+$down move down
bindsym $mod+$shift+$up move up
bindsym $mod+$shift+$right move right
bindsym $mod+$shift+Left move left
bindsym $mod+$shift+Down move down
bindsym $mod+$shift+Up move up
bindsym $mod+$shift+Right move right
# split in horizontal orientation
bindsym $mod+c split h
# split in vertical orientation
bindsym $mod+v split v
# enter fullscreen mode for the focused container
bindsym $mod+f fullscreen toggle
# change container layout (stacked, tabbed, toggle split)
bindsym $mod+s layout stacking
bindsym $mod+w layout tabbed
bindsym $mod+e layout toggle split
# toggle tiling / floating
bindsym $mod+$shift+space floating toggle
# change focus between tiling / floating windows
bindsym $mod+space focus mode_toggle
# focus the parent container
bindsym $mod+a focus parent
# alt-tab functionality
bindsym $mod+Tab workspace back_and_forth
#########################################
###### Workspace mgmt ####
#########################################
set $ws1 "1"
set $ws2 "2"
set $ws3 "3"
set $ws4 "4"
set $ws5 "5"
set $ws6 "6"
set $ws7 "7"
set $ws8 "8"
set $ws9 "9"
set $ws10 "10"
bindsym $mod+1 workspace number $ws1
bindsym $mod+2 workspace number $ws2
bindsym $mod+3 workspace number $ws3
bindsym $mod+4 workspace number $ws4
bindsym $mod+5 workspace number $ws5
bindsym $mod+6 workspace number $ws6
bindsym $mod+7 workspace number $ws7
bindsym $mod+8 workspace number $ws8
bindsym $mod+9 workspace number $ws9
bindsym $mod+0 workspace number $ws10
bindsym $mod+$shift+1 move container to workspace number $ws1
bindsym $mod+$shift+2 move container to workspace number $ws2
bindsym $mod+$shift+3 move container to workspace number $ws3
bindsym $mod+$shift+4 move container to workspace number $ws4
bindsym $mod+$shift+5 move container to workspace number $ws5
bindsym $mod+$shift+6 move container to workspace number $ws6
bindsym $mod+$shift+7 move container to workspace number $ws7
bindsym $mod+$shift+8 move container to workspace number $ws8
bindsym $mod+$shift+9 move container to workspace number $ws9
bindsym $mod+$shift+0 move container to workspace number $ws10
#########################################
###### Window mgmt ####
#########################################
set $resize_px 10 px
bindsym $mod+$ctrl+$left resize shrink width $resize_px
bindsym $mod+$ctrl+$down resize grow height $resize_px
bindsym $mod+$ctrl+$up resize shrink height $resize_px
bindsym $mod+$ctrl+$right resize grow width $resize_px
#########################################
###### Session mgmt ####
#########################################
bindsym $mod+$shift+e exec "i3-nagbar -t warning -m 'You pressed the exit shortcut. Do you really want to exit i3? This will end your X session.' -B 'Yes, exit i3' 'i3-msg exit'"
# Lock the screen through logind, so the one locker the lock screen's module runs handles it (and
# suspend and lid close too).
bindsym $mod+Delete exec --no-startup-id loginctl lock-session
# reload the configuration file
bindsym $mod+$shift+c reload
# restart i3 inplace (preserves your layout/session, can be used to upgrade i3)
bindsym $mod+$shift+r restart
#########################################
###### Borders ####
#########################################
default_border pixel 1
smart_borders on
#########################################
###### Gaps ####
#########################################
gaps inner 0
gaps outer 0
# Only the accent-bearing slots are themed; background and text keep i3's own defaults. Borders are
# `pixel`, so no title bar shows: the colour says which window has focus.
# border background text indicator child_border
client.focused #de5200 #de5200 #1E2127 #de5200 #de5200
client.urgent #900000 #900000 #ffffff #900000 #900000
#########################################
###### Until their modules carry them ##
#########################################
# Each line below belongs to something other than i3, named on its line. When that module is written
# it contributes the line to node-display-session, and the line goes from here in the same change.
# The launcher, the clipboard, the wallpaper, the bars, a machine model's keys and the peripherals'
# tray already contribute theirs (rofi, clipmenu, feh, i3status-rust, asus-zephyrus-g14,
# polychromatic).
# the operator's scripts: volume, games volume, sessions, screenshot
bindsym XF86AudioRaiseVolume exec --no-startup-id volume-notify up
bindsym XF86AudioLowerVolume exec --no-startup-id volume-notify down
bindsym XF86AudioMute exec --no-startup-id volume-notify mute
bindsym XF86AudioMicMute exec --no-startup-id mic-notify
bindsym $ctrl+XF86AudioRaiseVolume exec --no-startup-id set-games-volume 5
bindsym $ctrl+XF86AudioLowerVolume exec --no-startup-id set-games-volume -5
bindsym $mod+$shift+Return exec --no-startup-id ~/scripts/i3-sessions/launcher.sh
bindsym --release $ctrl+$shift+x exec --no-startup-id $XDG_CONFIG_HOME/i3/scripts/screenshot.sh
#########################################
###### Other modules' lines ####
#########################################
# Placed by the mesh from every other module's contribution (novox/hq ADR 0212): the launcher, the
# clipboard, the wallpaper, the bars, a machine model's keys, the peripherals' tray. Each module's
# under a line naming it.
#########################################
###### Your own files ####
#########################################
include ~/.config/i3/config.d/*.conf
@@ -0,0 +1,86 @@
# The mesh's block (module xorg, novox/hq ADR 0208 §5): the graphical session's start, replaced at
# every push. It ends by starting the session, so no line below it in this file runs any more. A line
# of your own that must run at every session start goes in ~/.xinitrc.local, which this block sources
# just before the session starts (the module's README lists where each of today's lines went).
# 1. The account's environment (ADR 0203): the file every shell sources, written by node-environment.
if [ -r "/home/op/.config/mesh/environment.sh" ]; then
. "/home/op/.config/mesh/environment.sh"
fi
# The session bus is the user manager's. pam_systemd normally hands it over; when it did not, take
# the canonical socket rather than starting a second bus, which runs every D-Bus-activated service
# twice (two notifiers, two portals) and hides one copy from the other.
if [ -z "$DBUS_SESSION_BUS_ADDRESS" ] && [ -S "/run/user/$(id -u)/bus" ]; then
DBUS_SESSION_BUS_ADDRESS="unix:path=/run/user/$(id -u)/bus"
export DBUS_SESSION_BUS_ADDRESS
fi
# 2. The session's own words into the user manager and D-Bus activation, so the portal, the notifier
# and every user service see the display and the desktop's identity. An explicit list and never
# --all: a session's environment may hold things no user service should be able to read back.
mesh_words=
for mesh_word in DISPLAY XAUTHORITY XDG_SESSION_TYPE XDG_CURRENT_DESKTOP XDG_SESSION_DESKTOP \
XDG_CONFIG_HOME XDG_DATA_DIRS GTK_THEME GTK2_RC_FILES QT_QPA_PLATFORMTHEME QT_STYLE_OVERRIDE \
QT_SELECT XCURSOR_THEME XCURSOR_SIZE TERMINAL; do
if printenv "$mesh_word" >/dev/null 2>&1; then
mesh_words="$mesh_words $mesh_word"
fi
done
dbus-update-activation-environment --systemd $mesh_words
unset mesh_word mesh_words
# 3. X resources: the mesh's, then yours in ~/.Xresources, which win. The mesh's file is merged
# without the C preprocessor, so no compiler is needed for it.
xrdb -nocpp -merge "/home/op/.config/xorg/xresources"
if [ -r "$HOME/.Xresources" ]; then
xrdb -merge "$HOME/.Xresources"
fi
# 4. The monitors: the saved layout profile that matches the monitors connected, if one does.
if command -v autorandr >/dev/null 2>&1; then
autorandr --change >/dev/null 2>&1 || true
fi
# 5. Other modules' session lines (ADR 0208 §4), then yours.
# gnome-keyring
# The ssh agent (module gnome-keyring, novox/hq ADR 0208): gcr's, which the account's service manager
# starts on first use from its socket. Named here, for the session and every terminal it starts, until
# the account's environment can say a path under the runtime directory (see the module's README).
SSH_AUTH_SOCK="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/gcr/ssh"
export SSH_AUTH_SOCK
# adwaita
# The appearance (module adwaita): GSettings is where the portal reads dark or light, and the portal is
# the only way it reaches Electron, Chromium, Firefox and flatpaks. Set at every session start to the
# module's default; adwaita_appearance switches it for a session.
gsettings set org.gnome.desktop.interface color-scheme 'prefer-dark' || true
gsettings set org.gnome.desktop.interface gtk-theme 'Adwaita' || true
gsettings set org.gnome.desktop.interface icon-theme 'Adwaita' || true
gsettings set org.gnome.desktop.interface cursor-theme 'Adwaita' || true
gsettings set org.gnome.desktop.interface cursor-size 24 || true
gsettings set org.gnome.desktop.interface font-name 'Inter 11' || true
gsettings set org.gnome.desktop.interface monospace-font-name 'JetBrainsMono Nerd Font 11' || true
# feh
# The wallpaper (module feh, novox/hq ADR 0208): the declared one, set once per session.
"$HOME/.fehbg"
# i3status-rust
# The bar watchdog (module i3status-rust, novox/hq ADR 0208): i3 never restarts a bar that dies; this
# brings it back. Once per session, ending with the session's own process ($$).
"$HOME/.local/bin/i3bar-watchdog" "$$" &
# screen-lock
# The lock screen (module screen-lock, novox/hq ADR 0208): the session locks after 30 minutes idle,
# the displays go to standby and suspend then and off after an hour, and xss-lock runs the locker on
# idle, before suspend and on logind's Lock. xss-lock needs this login session, so it starts here and
# not as a unit. It is started again if it exits, for as long as this session lasts ($$ is the
# session's own process, which becomes the window manager).
xset s 1800 1800
xset dpms 1800 1800 3600
(while kill -0 $$ 2>/dev/null; do xss-lock --transfer-sleep-lock -- "$HOME/.local/bin/screen-lock"; sleep 2; done) &
if [ -r "$HOME/.xinitrc.local" ]; then
. "$HOME/.xinitrc.local"
fi
# 6. The session itself: the holder of node-display-session starts it from the last slot.
# i3
# The session: i3, with 25 MiB of debug log kept in memory for i3-dump-log.
exec i3 --shmlog-size=26214400
@@ -0,0 +1,48 @@
! The mesh's X resources (module xorg, novox/hq ADR 0208 §4), replaced at every push. The session's
! start merges them with xrdb -nocpp before ~/.Xresources, so your own resources there win. Without
! the preprocessor there is no #include or #define here, and a line starting with # is skipped,
! which is what the line naming each contributing module is.
!
! Font rendering, as both workstations had it. 96 DPI becomes a setting with issue 168; xorg_dpi
! changes it for the running session.
Xft.dpi: 96
Xft.antialias: 1
Xft.hinting: 1
Xft.hintstyle: hintslight
Xft.rgba: rgb
# adwaita
! adwaita: the cursor, for X programs that take it from the resources.
Xcursor.theme: Adwaita
Xcursor.size: 24
# xterm
! xterm: the face, the palette and the clipboard keys. Scoped to the XTerm class, so they
! outrank any `*name` resource of yours and touch no other X program.
XTerm*termName: xterm-256color
XTerm*saveLines: 10000
XTerm*renderFont: true
XTerm*faceName: JetBrainsMono Nerd Font
XTerm*faceSize: 12
XTerm*boldMode: false
XTerm*alwaysBoldMode: false
XTerm*allowBoldFonts: true
XTerm*background: #000000
XTerm*foreground: #a8a8a8
XTerm*color0: #000000
XTerm*color8: #666666
XTerm*color1: #9e1828
XTerm*color9: #bc5766
XTerm*color2: #008800
XTerm*color10: #61a171
XTerm*color3: #d2bb4b
XTerm*color11: #e7db52
XTerm*color4: #414171
XTerm*color12: #5085af
XTerm*color5: #963c59
XTerm*color13: #a97a99
XTerm*color6: #418179
XTerm*color14: #6ba4a4
XTerm*color7: #bebebe
XTerm*color15: #ffffff
XTerm*VT100.Translations: #override \n\
Ctrl Shift ~Alt <Key> C: copy-selection(CLIPBOARD) \n\
Ctrl Shift ~Alt <Key> V: insert-selection(CLIPBOARD)
@@ -0,0 +1,53 @@
# The mesh's block (module xorg, novox/hq ADR 0208 §5): the graphical session's start, replaced at
# every push. It ends by starting the session, so no line below it in this file runs any more. A line
# of your own that must run at every session start goes in ~/.xinitrc.local, which this block sources
# just before the session starts (the module's README lists where each of today's lines went).
# 1. The account's environment (ADR 0203): the file every shell sources, written by node-environment.
if [ -r "/home/op/.config/mesh/environment.sh" ]; then
. "/home/op/.config/mesh/environment.sh"
fi
# The session bus is the user manager's. pam_systemd normally hands it over; when it did not, take
# the canonical socket rather than starting a second bus, which runs every D-Bus-activated service
# twice (two notifiers, two portals) and hides one copy from the other.
if [ -z "$DBUS_SESSION_BUS_ADDRESS" ] && [ -S "/run/user/$(id -u)/bus" ]; then
DBUS_SESSION_BUS_ADDRESS="unix:path=/run/user/$(id -u)/bus"
export DBUS_SESSION_BUS_ADDRESS
fi
# 2. The session's own words into the user manager and D-Bus activation, so the portal, the notifier
# and every user service see the display and the desktop's identity. An explicit list and never
# --all: a session's environment may hold things no user service should be able to read back.
mesh_words=
for mesh_word in DISPLAY XAUTHORITY XDG_SESSION_TYPE XDG_CURRENT_DESKTOP XDG_SESSION_DESKTOP \
XDG_CONFIG_HOME XDG_DATA_DIRS GTK_THEME GTK2_RC_FILES QT_QPA_PLATFORMTHEME QT_STYLE_OVERRIDE \
QT_SELECT XCURSOR_THEME XCURSOR_SIZE TERMINAL; do
if printenv "$mesh_word" >/dev/null 2>&1; then
mesh_words="$mesh_words $mesh_word"
fi
done
dbus-update-activation-environment --systemd $mesh_words
unset mesh_word mesh_words
# 3. X resources: the mesh's, then yours in ~/.Xresources, which win. The mesh's file is merged
# without the C preprocessor, so no compiler is needed for it.
xrdb -nocpp -merge "/home/op/.config/xorg/xresources"
if [ -r "$HOME/.Xresources" ]; then
xrdb -merge "$HOME/.Xresources"
fi
# 4. The monitors: the saved layout profile that matches the monitors connected, if one does.
if command -v autorandr >/dev/null 2>&1; then
autorandr --change >/dev/null 2>&1 || true
fi
# 5. Other modules' session lines (ADR 0208 §4), then yours.
if [ -r "$HOME/.xinitrc.local" ]; then
. "$HOME/.xinitrc.local"
fi
# 6. The session itself: the holder of node-display-session starts it from the last slot.
# i3
# The session: i3, with 25 MiB of debug log kept in memory for i3-dump-log.
exec i3 --shmlog-size=26214400
@@ -0,0 +1,44 @@
! The mesh's X resources (module xorg, novox/hq ADR 0208 §4), replaced at every push. The session's
! start merges them with xrdb -nocpp before ~/.Xresources, so your own resources there win. Without
! the preprocessor there is no #include or #define here, and a line starting with # is skipped,
! which is what the line naming each contributing module is.
!
! Font rendering, as both workstations had it. 96 DPI becomes a setting with issue 168; xorg_dpi
! changes it for the running session.
Xft.dpi: 96
Xft.antialias: 1
Xft.hinting: 1
Xft.hintstyle: hintslight
Xft.rgba: rgb
# xterm
! xterm: the face, the palette and the clipboard keys. Scoped to the XTerm class, so they
! outrank any `*name` resource of yours and touch no other X program.
XTerm*termName: xterm-256color
XTerm*saveLines: 10000
XTerm*renderFont: true
XTerm*faceName: JetBrainsMono Nerd Font
XTerm*faceSize: 12
XTerm*boldMode: false
XTerm*alwaysBoldMode: false
XTerm*allowBoldFonts: true
XTerm*background: #000000
XTerm*foreground: #a8a8a8
XTerm*color0: #000000
XTerm*color8: #666666
XTerm*color1: #9e1828
XTerm*color9: #bc5766
XTerm*color2: #008800
XTerm*color10: #61a171
XTerm*color3: #d2bb4b
XTerm*color11: #e7db52
XTerm*color4: #414171
XTerm*color12: #5085af
XTerm*color5: #963c59
XTerm*color13: #a97a99
XTerm*color6: #418179
XTerm*color14: #6ba4a4
XTerm*color7: #bebebe
XTerm*color15: #ffffff
XTerm*VT100.Translations: #override \n\
Ctrl Shift ~Alt <Key> C: copy-selection(CLIPBOARD) \n\
Ctrl Shift ~Alt <Key> V: insert-selection(CLIPBOARD)
@@ -0,0 +1,98 @@
package main
import (
"encoding/json"
"os"
"path/filepath"
"testing"
"time"
)
// The graphical session's two files, xinitrc and xresources, filled by this client from the items of the collections
// shell:xinitrc:<slot> and shell:xresources:<slot> their seven contributors declare, are byte for byte what the
// controller composed from the old shell entries before the move (design 57 L3, issue 807). The module files are the
// catalogue's own; testdata/xorg holds the two files as the controller composed them, for a machine with all seven
// contributors and for one with only i3 and xterm. The not handed half is pinned in mesh-controller by the same files.
func TestNatsTheSessionFilesFilledFromTheirContributorsItemsAreWhatTheOldFormComposed(t *testing.T) {
modules := filepath.Join("..", "..", "..")
read := func(path string) string {
b, err := os.ReadFile(filepath.Join(modules, path))
if err != nil {
t.Fatal(err)
}
return string(b)
}
all := []string{"adwaita", "i3status-rust", "i3", "feh", "gnome-keyring", "screen-lock", "xterm"}
for name, contributors := range map[string][]string{"all": all, "i3-xterm": {"i3", "xterm"}} {
t.Run(name, func(t *testing.T) {
facts := map[string]string{"machine:account-home": "/home/op"}
tpl, _ := templates(t, map[string]string{"xinitrc": read("xorg/files/xinitrc"),
"xresources": read("xorg/files/xresources")})
collector := Plan{Templates: tpl, Placement: facts, Files: []PlannedFile{
{ID: "xinitrc", Path: "/session/xinitrc", Template: "xinitrc", OwnerAtRuntime: "owned"},
{ID: "xresources", Path: "/session/xresources", Template: "xresources", OwnerAtRuntime: "owned"}}}
for _, f := range []string{"xinitrc", "xresources"} {
for _, slot := range []string{"first", "normal", "last"} {
collector.Collections = append(collector.Collections,
PlannedCollection{Name: "shell:" + f + ":" + slot, Comment: "#"})
}
}
var manifest struct {
Moved bool `json:"moved"`
}
if err := json.Unmarshal([]byte(read("xorg/module.json")), &manifest); err != nil || !manifest.Moved {
t.Fatalf("xorg is not moved: %v", err)
}
b := startBus(t)
e := startEngine(t, map[string]string{"/session/xinitrc": "owned", "/session/xresources": "owned"})
b.assign(t, "xorg", collector)
for _, m := range contributors {
var c struct {
Moved bool `json:"moved"`
Shell []any `json:"shell"`
Items []struct {
Collection string `json:"collection"`
Name string `json:"name"`
Attributes map[string]string `json:"attributes"`
Template string `json:"template"`
} `json:"items"`
}
if err := json.Unmarshal([]byte(read(m+"/module.json")), &c); err != nil {
t.Fatal(err)
}
if !c.Moved || len(c.Shell) != 0 {
t.Fatalf("%s is not moved or still holds shell entries", m)
}
texts := map[string]string{}
var planned []PlannedItem
for _, it := range c.Items {
if it.Collection != "shell:xinitrc:first" && it.Collection != "shell:xinitrc:normal" &&
it.Collection != "shell:xinitrc:last" && it.Collection != "shell:xresources:normal" {
continue
}
id := filepath.Base(it.Template)
texts[id] = read(m + "/" + it.Template)
planned = append(planned, PlannedItem{Collection: it.Collection, Name: it.Name, Template: id,
Attributes: it.Attributes})
}
if len(planned) == 0 {
t.Fatalf("%s declares no item of the session files", m)
}
tp, _ := templates(t, texts)
b.assign(t, m, Plan{Templates: tp, Items: planned, Placement: facts})
}
aClient(t, b, e, t.TempDir(), time.Hour)
for _, f := range []string{"xinitrc", "xresources"} {
want, err := os.ReadFile(filepath.Join("testdata", "xorg", name+"."+f))
if err != nil {
t.Fatal(err)
}
eventually(t, "the "+f+" is what it was", func() bool { return e.read(t, "/session/"+f) == string(want) })
if t.Failed() {
t.Logf("got:\n%s", e.read(t, "/session/"+f))
}
}
})
}
}