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
+88
View File
@@ -0,0 +1,88 @@
# mesh-provisioner-client
Holds the `node-provisioner` seat on every node: the provisioner's client (novox/hq ADR 0349 clauses 3 to 5, design 57
P5). Go, one binary (`cmd/mesh-provisioner-client`), run as a service of its own under the account
`mesh-provisioner-client`, outside the tool runner, on a bus account of its own, `provisioner.<node>`, delivered as
its own secret `provisioner-broker` (mesh-controller `internal/broker/provisioner.go`). The bus grants that account
its own machine's keys and, to read alone, the whole assignments mirror, where a provider's consumers on any
machine are bound to it (ADR 0373); nothing else.
## What it does
For each module the controller handed to the provisioner on its node — an assignment in
`mesh-controller_assignments` (`<node>.<module>`) whose value carries a `provisioning` plan: the templates archive
by reference and digest, the files it fills (path, template, `owner-at-runtime`, how its program takes a change)
and the placement values its templates name — the client:
- fetches the templates archive and uses it only when it hashes to the plan's digest, keeping it by digest;
- records each placeholder as a need in `mesh-provisioner_needs`, `<node>.<module>.<kind>.<name>` (a character a
key cannot hold written `=<hex>`);
- watches each owner's keys for its node: placement in the plan, config in `mesh-config_resolved`
(`<node>.<module>.<key>`), both named by `mesh-provisioner_registry`;
- asks the vault (`mesh-vault.get`, named by the registry) for each of the module's own secrets its plan names
(`provisioning.secrets`), in its own name on the subject naming its account, with the public half of a key it
made at start and keeps in memory alone; the vault answers the secret sealed to that key (ADR 0206's box), so only
the box crosses the bus. A secret is asked once per run, and again when the vault says `secret-changed` for it,
so a rotation reaches its file without a new declaration. A secret with a `path` is written to that file alone,
as an `owned` file. 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 after ten minutes. A value is never written
to a bucket, a report or a log;
- asks the vault the same way for each credential of what the module requires that its plan names
(a `provisioning.provisions` entry with a `name`, issue 757; every entry names the provider serving that
provision, issue 760, and its offer's credential form, issue 768, which the vault acts on): the value a provider's software issued for this module on this machine,
`provision/<provider>/<provision>/<node>/<module>` (the forge's read token for claude-code, issue 737), filled
wherever `${secret:<name>}` names it and written to its `path` alone, and asked again on its `secret-changed`;
- fills each item the module contributes to a collection (`provisioning.items`: the collection and its template)
from the same owners as a file, and records it in `mesh-provisioner_collect`, `<node>.<collection>.<contributor>`
(a character a key's token cannot hold written `=<hex>`: `shell:zsh:normal` is `shell=3Azsh=3Anormal`). An item
naming a secret fails: an item is kept in a bucket, and no secret is;
- fills a collector's `${collected:<collection>}` from every item of that collection recorded for its node, in the
contributors' order, each introduced by a line naming its contributor in the collection's comment
(`provisioning.collections`: the collections the module collects, each with its comment). The three old
namespaces read as collections of the same name: `${shell:zsh:normal}` is the collection `shell:zsh:normal`,
`${environment:posix}` is `environment:posix`, `${contribution:node-hotkeys:trigger}` is
`contribution:node-hotkeys:trigger`. A collector naming a collection its plan does not fails. A collection with no
item is filled empty, and filled again whenever an item of it is recorded or goes;
- fills a module's files only once every value is there; otherwise it writes nothing and reports `waiting` with what
it waits for;
- has the node-engine write each changed file through its local write socket (`/run/nox-mesh-host/fill.sock`,
mesh-host `internal/filling`), which writes it as the declaration says (an `owned` file whole, a `seed` only when
absent, a `managed` file's own keys or block) and restarts or reloads its program as the declaration says;
- writes every file again every minute, so the node-engine puts back an `owned` file changed on disk;
- reports `prov.applied.<node>.<module>` and keeps the same report in `mesh-provisioner_applied` (`<node>.<module>`):
`applied` with each file's action and digest, `waiting`, `failed` with the reason, or `removed`;
- when a module is taken off the node, removes its needs, its items and its report (the node-engine removes the files its
declaration no longer names); and when the bus comes back, or the client starts, fills from the current values;
- fills a provider's `${consumers:<provision>}` (novox/hq ADR 0373, issue 679) with one entry per consumer bound to
it for that provision, in the order of the assignments' keys and, within a plan, of its `provisions` entries: a
JSON list, each entry `{consumer, module, local, credential, value}`, the credential its own,
`pair/<provider>/<provision>/<node>/<module>[/<local>]`, asked of the vault in the client's own name as a secret
is. Who binds a provider is read from every machine's plans, so the client watches the whole assignments mirror
(it carries no secret's value) and reads only its own machine's plans as its own. A consumer whose credential the
vault does not hold yet, or will not hand, is left out and said in the log, so the others are served; the
provider's helper waits for it (mesh-sdk `go/provider` ConsumersFile). It is filled again when a consumer binds or
unbinds and when the vault says one of the credentials changed. The catalogue holds the file to mode 0600 and
owned, and refuses the placeholder in a module not providing that provision (mesh-controller internal/catalogue
consumers.go); an item naming it fails;
- fills a consumer's `${bound:<provision>:<key>}` from its provider's entry (mesh-sdk `go/provider`, issue 678): the
bucket `<provider module>_bound` of the provider its plan names, key `<node>.<module>.<provision>[.<local>]`, as
the registry says, watched for its own machine's keys once a plan names it; `as`, `user` or one of the served
values. An entry not written yet, or a bucket not there yet, is waited for; one the provider refused fails the
module with the provider's reason.
It fills `config`, `secret` (design 57 V4, issue 647), the collections (`collected`, and `shell`, `environment`
and `contribution` read as collections: design 57 L1, issue 684), `consumers` and `bound` (issue 679) and the
placement kinds (`dir`, `access`, `port`, `seat`, `machine`). Any other kind fails with the reason.
## A module's code asks at runtime
A module whose code runs in its machine's tool runner reads its config values through the SDK (ADR 0349 clause 14
as ADR 0370 changes it, issue 685). The tool runner asks this client on `prov.ask.<node>`, which only the same
machine's tool runner may publish, with `{module, kind, key}`; the module is the one whose bundle asks, named by the
tool runner and never by the module's code. The client answers `{value}`, the value as `mesh-config_resolved` holds
it, only for a module assigned to its machine and a key mesh-config resolved for it (what its manifest declares), or
`{error}` with the reason. It never answers a secret: the vault hands a secret only to a caller the bus proves, so a
module in the tool runner reads its secrets from its files. A binding's non-secret values (`bound`) are filled
into files (issue 679) and not yet answered on an ask. It says on `prov.changed.<node>.<module>` which value moved (`{kind, key}`, every
key of the kind when `key` is absent, never the value), when the client starts and on every change, so the tool
runner asks again for the watches it holds.
@@ -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))
}
}
})
}
}
+22
View File
@@ -0,0 +1,22 @@
module mesh-provisioner-client
go 1.25.0
require (
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013235212-c8b07242a009
github.com/nats-io/nats-server/v2 v2.11.17
github.com/nats-io/nats.go v1.51.0
)
require (
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op // indirect
github.com/google/go-tpm v0.9.8 // indirect
github.com/klauspost/compress v1.18.5 // indirect
github.com/minio/highwayhash v1.0.4 // indirect
github.com/nats-io/jwt/v2 v2.8.1 // indirect
github.com/nats-io/nkeys v0.4.15 // indirect
github.com/nats-io/nuid v1.0.1 // indirect
golang.org/x/crypto v0.50.0 // indirect
golang.org/x/sys v0.43.0 // indirect
golang.org/x/time v0.15.0 // indirect
)
+27
View File
@@ -0,0 +1,27 @@
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013235212-c8b07242a009 h1:I9rm8HhYRJ0+mzQQz+VAH0kgv2YJWkvctEc67Yd0PX8=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013235212-c8b07242a009/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/minio/highwayhash v1.0.4 h1:asJizugGgchQod2ja9NJlGOWq4s7KsAWr5XUc9Clgl4=
github.com/minio/highwayhash v1.0.4/go.mod h1:GGYsuwP/fPD6Y9hMiXuapVvlIUEhFhMTh0rxU3ik1LQ=
github.com/nats-io/jwt/v2 v2.8.1 h1:V0xpGuD/N8Mi+fQNDynXohVvp7ZztevW5io8CUWlPmU=
github.com/nats-io/jwt/v2 v2.8.1/go.mod h1:nWnOEEiVMiKHQpnAy4eXlizVEtSfzacZ1Q43LIRavZg=
github.com/nats-io/nats-server/v2 v2.11.17 h1:GKEghcFK6A+aFx11Yf1LjgLC3txAwvyhnYzhBIQZA8I=
github.com/nats-io/nats-server/v2 v2.11.17/go.mod h1:B1sFVz4StNosQ903ak4N1G01Fl/9f8e06mXpFIE2K24=
github.com/nats-io/nats.go v1.51.0 h1:ByW84XTz6W03GSSsygsZcA+xgKK8vPGaa/FCAAEHnAI=
github.com/nats-io/nats.go v1.51.0/go.mod h1:26HypzazeOkyO3/mqd1zZd53STJN0EjCYF9Uy2ZOBno=
github.com/nats-io/nkeys v0.4.15 h1:JACV5jRVO9V856KOapQ7x+EY8Jo3qw1vJt/9Jpwzkk4=
github.com/nats-io/nkeys v0.4.15/go.mod h1:CpMchTXC9fxA5zrMo4KpySxNjiDVvr8ANOSZdiNfUrs=
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
@@ -0,0 +1,59 @@
{
"module": "mesh-provisioner-client",
"version": "1",
"claims": [
{
"name": "node-provisioner",
"scope": "node"
}
],
"own-secrets": {
"provisioner-broker": "${dir:state}/broker"
},
"secrets-owner": "mesh-provisioner-client",
"resources": [
{
"id": "account",
"type": "user",
"name": "mesh-provisioner-client",
"shell": "/usr/bin/nologin",
"home": "/var/lib/mesh-provisioner-client"
},
{
"id": "state",
"type": "directory",
"mode": "0700",
"owner": "mesh-provisioner-client",
"place": "."
},
{
"id": "client",
"type": "process",
"name": "mesh-provisioner-client",
"artifact": "client",
"run": [
"./mesh-provisioner-client"
],
"env": {
"MESH_PROVISIONER_CREDENTIAL": "${dir:state}/broker",
"MESH_PROVISIONER_STATE": "${dir:state}"
},
"user": "mesh-provisioner-client",
"health": {
"kind": "unit"
}
}
],
"build": {
"artifacts": [
{
"name": "client",
"kind": "bundle",
"language": "go",
"system": "arch",
"from": "cmd/mesh-provisioner-client",
"binary": "mesh-provisioner-client"
}
]
}
}