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:
@@ -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))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+10
@@ -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)
|
||||
}
|
||||
+25
@@ -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" }
|
||||
} }
|
||||
]
|
||||
}
|
||||
}
|
||||
Vendored
+119
@@ -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')"
|
||||
Vendored
+36
@@ -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.
|
||||
Vendored
+126
@@ -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')"
|
||||
Vendored
+36
@@ -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.
|
||||
Vendored
+109
@@ -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')"
|
||||
Vendored
+36
@@ -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.
|
||||
+116
@@ -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')"
|
||||
Vendored
+36
@@ -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.
|
||||
+26
@@ -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
|
||||
Vendored
+23
@@ -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}
|
||||
+9
@@ -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
|
||||
Vendored
+6
@@ -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}
|
||||
+310
@@ -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
|
||||
+201
@@ -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
|
||||
+86
@@ -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
|
||||
+48
@@ -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)
|
||||
+53
@@ -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
|
||||
Vendored
+44
@@ -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))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user