diff --git a/go/catalogue/amqp_is_not_a_provision_test.go b/go/catalogue/amqp_is_not_a_provision_test.go new file mode 100644 index 0000000..11d1ad5 --- /dev/null +++ b/go/catalogue/amqp_is_not_a_provision_test.go @@ -0,0 +1,50 @@ +package catalogue + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "git.novox.be/novox/mesh-sdk/go/internal/beside" +) + +// **The word does not come back through a manifest** (novox/hq ADR 0131). A module that wants +// messaging wants the mesh's bus, reached through the sdk and named by the `mesh-broker` seat. Naming +// the old wire protocol asks for the one server being retired, so both directions are refused at the +// parser — this is judged from the manifest alone, no store needed. + +func TestAManifestProvidingAmqpIsRefused(t *testing.T) { + raw := []byte(`{"module":"old-broker","version":"1","provides":[{"name":"amqp","scope":"mesh"}]}`) + _, err := ParseManifest(raw) + if err == nil || !strings.Contains(err.Error(), `provides "amqp", which is not a provision`) { + t.Fatalf("a module providing amqp was not refused, or not for the reason: %v", err) + } +} + +func TestAManifestRequiringAmqpIsRefused(t *testing.T) { + raw := []byte(`{"module":"forwarder","version":"1","requires":["amqp"]}`) + _, err := ParseManifest(raw) + if err == nil || !strings.Contains(err.Error(), `requires "amqp", which is not a provision`) { + t.Fatalf("a module requiring amqp was not refused, or not for the reason: %v", err) + } +} + +// And the catalogue (internal/beside) names it nowhere — the three modules that did are removed under +// design 28 task 5.4, not converted. +func TestNoCatalogueManifestNamesAmqp(t *testing.T) { + modules, err := filepath.Glob(filepath.Join(beside.Catalogue(t), "*", "module.json")) + if err != nil || len(modules) == 0 { + t.Fatalf("no manifests in the catalogue, so this proved nothing: %v", err) + } + for _, path := range modules { + raw, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(raw), `"amqp"`) { + t.Errorf("%s names amqp, which is not a provision (novox/hq ADR 0131)", + filepath.Base(filepath.Dir(path))) + } + } +} diff --git a/go/catalogue/artifact_store_seat_test.go b/go/catalogue/artifact_store_seat_test.go new file mode 100644 index 0000000..182dcac --- /dev/null +++ b/go/catalogue/artifact_store_seat_test.go @@ -0,0 +1,38 @@ +package catalogue + +import ( + "strings" + "testing" +) + +// The artifact store's seat is one per mesh, read from the catalogue beside this checkout. +// +// **A second store anywhere is refused by name, not discovered as a consumer failure.** The seat +// was node-scoped, so a second `distribution` on another machine resolved cleanly there — and a +// node-scoped requirement with one candidate installs that candidate on the node, so anything that +// required the store's presence beside it would have raised a fresh, empty store on the wrong +// machine. Only afterwards did the mesh notice: `artifact-store` offered by two nodes, and every +// consumer elsewhere refusing to choose. The claim says it first, where the second store is +// assigned. +func TestASecondArtifactStoreAnywhereIsRefusedByName(t *testing.T) { + store := catalogueManifest(t, "distribution") + + // The first store resolves as it always has. + if _, err := Resolve(shelf(store), []string{"distribution"}, workstation(), World{}); err != nil { + t.Fatalf("the store alone does not resolve: %v", err) + } + + // A second one, on any other machine, is refused — and the refusal names the seat. + elsewhere := World{Held: []Held{{Claim: "mesh-artifact-store", Scope: ScopeMesh, + Node: "anchor", Module: "distribution"}}} + other := workstation() + other.Name = "laptop" + _, err := Resolve(shelf(store), []string{"distribution"}, other, elsewhere) + if err == nil { + t.Fatal("a second store was accepted on another machine; it would offer artifact-store a " + + "second time and every consumer elsewhere would refuse to choose") + } + if !strings.Contains(err.Error(), "mesh-artifact-store") || !strings.Contains(err.Error(), "one per mesh") { + t.Fatalf("refused without naming the seat: %v", err) + } +} diff --git a/go/catalogue/broker_seat_test.go b/go/catalogue/broker_seat_test.go new file mode 100644 index 0000000..c04389a --- /dev/null +++ b/go/catalogue/broker_seat_test.go @@ -0,0 +1,87 @@ +package catalogue + +import ( + "strings" + "testing" +) + +// The mesh's bus is one per mesh, read from the catalogue beside this checkout. +// +// **This is step 2's claim, and it is checked here rather than in a bed** (novox/hq ADR 0116): +// adoption puts the NATS server into the `mesh-broker` seat on a mesh that is already running, +// and the property that matters is that a second one anywhere is refused *when it is assigned*, +// not discovered later as two servers holding different halves of the mesh's traffic. A second +// bus is not a degraded mesh; it is two meshes that both believe they are the one. +func TestASecondMeshBusAnywhereIsRefusedByName(t *testing.T) { + nats := catalogueManifest(t, "nats") + + if _, err := Resolve(shelf(nats), []string{"nats"}, workstation(), World{}); err != nil { + t.Fatalf("the bus alone does not resolve: %v", err) + } + + elsewhere := World{Held: []Held{{Claim: "mesh-broker", Scope: ScopeMesh, + Node: "anchor", Module: "nats"}}} + other := workstation() + other.Name = "laptop" + _, err := Resolve(shelf(nats), []string{"nats"}, other, elsewhere) + if err == nil { + t.Fatal("a second bus was accepted on another machine") + } + if !strings.Contains(err.Error(), "mesh-broker") || !strings.Contains(err.Error(), "one per mesh") { + t.Fatalf("refused without naming the seat: %v", err) + } +} + +// The seat is the server's role, not the product's name (novox/hq ADR 0079). A different +// implementation of the bus claims the same seat, and the mesh refuses it for the same reason — +// which is the property that lets the bus be replaced at all. +func TestTheSeatRefusesADifferentBusToo(t *testing.T) { + nats := catalogueManifest(t, "nats") + held := World{Held: []Held{{Claim: "mesh-broker", Scope: ScopeMesh, + Node: "anchor", Module: "some-other-broker"}}} + other := workstation() + other.Name = "laptop" + if _, err := Resolve(shelf(nats), []string{"nats"}, other, held); err == nil { + t.Fatal("the seat admitted a second holder because the module's name differed") + } +} + +// The old broker is gone from the catalogue (novox/hq ADR 0131, design 28 task 5.4), so it is no +// longer a fixture here. That two eligible holders stand beside each other with one on record is +// pinned in holdings_test.go against manifests this package owns. + +// **A seat and the interface it delivers are different names, and renaming one must not rename +// the other** (novox/hq ADR 0118). This nearly went wrong: the seats were renamed to the `mesh-*` +// prefix, and a blanket search-and-replace also renamed `npm-package-registry` and `git` where +// they are *provisions* — which a consumer requires and a provider offers. The tests failed with +// "the package registry is served on ", which does not say "you renamed an interface". +func TestRenamingASeatDidNotRenameTheInterfaceItDelivers(t *testing.T) { + for _, pair := range []struct{ seat, delivers string }{ + {"git", "git"}, + {"npm-package-registry", "npm-package-registry"}, + {"mesh-artifact-store", "artifact-store"}, + {"mesh-store", "postgres-database"}, + {"mesh-broker", "mesh-bus"}, + } { + s, known := SeatNamed(pair.seat) + if !known { + t.Fatalf("%q is not a seat", pair.seat) + } + if s.Delivers != pair.delivers { + t.Errorf("the %s seat delivers %q, expected %q — renaming the seat moved the "+ + "interface with it, and every consumer requiring it would stop resolving", + pair.seat, s.Delivers, pair.delivers) + } + // **Three of these deliberately share a name with what they deliver**, and that is not an + // incomplete rename. Renaming a seat that delivers a provision cascades to every consumer + // requiring it, with a mesh-wide window where a holder stops resolving mid-flight — so the + // trunk deferred exactly those three (novox/hq ADR 0121) while renaming the node-scoped ones. + // What this test is for is the other direction: that renaming a seat never moves the + // interface, which once produced "the package registry is served on ". + } +} + +// A manifest written against an old seat name is told what it became rather than refused as +// unknown. **That map is the controller's store now, not this package** (novox/hq ADR 0122): a +// rename is a row, so the courtesy survives a rename nobody recompiled for. Checked where the +// table is read, not here, where there is no longer a hardcoded list to check against. diff --git a/go/catalogue/bus_snapshot_test.go b/go/catalogue/bus_snapshot_test.go new file mode 100644 index 0000000..e767d48 --- /dev/null +++ b/go/catalogue/bus_snapshot_test.go @@ -0,0 +1,80 @@ +package catalogue + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "git.novox.be/novox/mesh-sdk/go/internal/beside" +) + +// The module holding mesh-broker is the bus, and its account is granted the bus's snapshot API and +// nothing else (novox/hq ADR 0235). Anything it declared to say or hear on the bus would be granted +// nothing, so it is refused at the parser rather than silently dropped. +func TestTheBussAccountSaysNothingOnTheBus(t *testing.T) { + raw := []byte(`{"module":"bus","version":"1","provides":[{"name":"mesh-bus","scope":"mesh"}], + "claims":[{"name":"mesh-broker","scope":"mesh"}],"own-secrets":{"broker":"/run/broker"}, + "emits":["something.happened"]}`) + _, err := ParseManifest(raw) + if err == nil || !strings.Contains(err.Error(), "granted the bus's snapshot API and nothing else") { + t.Fatalf("a bus module declaring what it emits was not refused, or not for the reason: %v", err) + } + + // Its tools bundle says what it emits (novox/hq ADR 0357): the node's runtime carries the bundle and is + // granted the module's events, so a bus module that ships one may declare them. + bundled := []byte(`{"module":"bus","version":"1","provides":[{"name":"mesh-bus","scope":"mesh"}], + "claims":[{"name":"mesh-broker","scope":"mesh"}],"own-secrets":{"broker":"/run/broker"}, + "emits":["bus.slow-consumer"],"tools":["bus_streams"], + "build":{"artifacts":[{"name":"tools","kind":"bundle","language":"go","system":"arch","from":"cmd/t","binary":"t"}]}}`) + if _, err := ParseManifest(bundled); err != nil { + t.Fatalf("a bus module whose tools bundle declares what it emits was refused: %v", err) + } + + quiet := []byte(`{"module":"bus","version":"1","provides":[{"name":"mesh-bus","scope":"mesh"}], + "claims":[{"name":"mesh-broker","scope":"mesh"}],"own-secrets":{"broker":"/run/broker"}, + "tools":["bus_streams"]}`) + m, err := ParseManifest(quiet) + if err != nil { + t.Fatalf("a bus module with an account and tools was refused: %v", err) + } + if !m.ClaimsSeat(BrokerSeat) { + t.Fatal("it does not read as holding the broker seat") + } +} + +// The catalogue's bus protects its streams by the snapshot, not as live files: its streams' item is a +// dump into its snapshots, it has the account the dump runs as, and the dump runs the snapshot +// program in the bus's own container. +func TestTheCataloguesBusIsBackedUpBySnapshot(t *testing.T) { + raw, err := os.ReadFile(filepath.Join(beside.Catalogue(t), "nats", "module.json")) + if err != nil { + t.Fatal(err) + } + m, err := ParseManifest(raw) + if err != nil { + t.Fatal(err) + } + if _, account := m.OwnSecrets["broker"]; !account { + t.Fatal("the bus declares no account, so its snapshot could not reach it") + } + var found bool + if m.Data == nil { + t.Fatal("the bus declares no data") + } + for _, it := range m.Data.Own { + if it.ID != "jetstream" { + continue + } + found = true + if !it.Backup.IsDump() || it.Backup.Into != "snapshots" { + t.Fatalf("the bus's streams are protected by %+v, not a snapshot into its snapshots", it.Backup) + } + if !strings.Contains(it.Backup.Dump, "mesh-nats-snapshot snapshot") { + t.Fatalf("the dump does not run the snapshot program: %s", it.Backup.Dump) + } + } + if !found { + t.Fatal("the bus declares no item for its streams") + } +} diff --git a/go/catalogue/catalogue_check_test.go b/go/catalogue/catalogue_check_test.go new file mode 100644 index 0000000..a0d13fd --- /dev/null +++ b/go/catalogue/catalogue_check_test.go @@ -0,0 +1,138 @@ +package catalogue + +import ( + "path/filepath" + "strings" + "testing" + + "git.novox.be/novox/mesh-sdk/go/internal/beside" +) + +// TestEveryCatalogueManifestParses runs the real catalogue through the real gate. +// +// Not a fixture: the point is whether the manifests as written are accepted by the control plane that +// will read them, and a copy of one manifest proves nothing about the other seventy-one. +// catalogueRoot is the catalogue these checks run over: in a merge check the clone the build seat put +// beside this one, elsewhere the copy captured in testdata/beside (internal/beside). A check that only +// ran when somebody remembered a variable was a check nobody ran (novox/hq issue 134), and one that read +// whatever checkout sat beside judged the machine, not the change (novox/hq issue 432): it never skips. +func catalogueRoot(t *testing.T) string { + t.Helper() + return beside.Dir(t, "mesh-catalog") +} + +func TestEveryCatalogueManifestParses(t *testing.T) { + root := catalogueRoot(t) + found, err := filepath.Glob(filepath.Join(root, "modules", "*", "module.json")) + if err != nil || len(found) == 0 { + t.Fatalf("no manifests under %s: %v", root, err) + } + named, routed := 0, 0 + for _, p := range found { + m, err := ReadTreeManifest(root, filepath.Dir(p)) + if err != nil { + t.Errorf("%s: %v", filepath.Base(filepath.Dir(p)), err) + continue + } + for _, l := range m.Listens { + if l.Name != "" { + named++ + } + } + for port := range RoutedPorts(m) { + _ = port + routed++ + } + } + t.Logf("%d manifests, %d named endpoints, %d routed endpoints resolved", len(found), named, routed) + if named == 0 { + t.Fatal("no endpoint in the catalogue is named, so this proved nothing") + } +} + +// TestNoCatalogueManifestNamesAnInstallation is ADR 0112's check, run over the real catalogue: no +// definition names a domain or a public address the mesh acts on, and every value that must for now +// carries its reason (novox/hq ADR 0155, issue 134). The list it prints is the one that shrinks. +func TestNoCatalogueManifestNamesAnInstallation(t *testing.T) { + root := catalogueRoot(t) + found, err := filepath.Glob(filepath.Join(root, "modules", "*", "module.json")) + if err != nil || len(found) == 0 { + t.Fatalf("no manifests under %s: %v", root, err) + } + var named []string + for _, p := range found { + m, err := ReadTreeManifest(root, filepath.Dir(p)) + if err != nil { + t.Errorf("%s: %v", p, err) + continue + } + named = append(named, InstallationProblems(m)...) + } + if len(named) > 0 { + t.Fatalf("%d value(s) name an installation:\n %s", len(named), strings.Join(named, "\n ")) + } +} + +// TestEveryCatalogueModuleDeclaresItsData is ADR 0233's check over the real catalogue (it replaced ADR +// 0214's store list): every provider that grants says what it keeps for its consumers, nothing writes a +// backup line by hand, every directory a container writes is declared, and every irreplaceable item is +// backed up — so a store added is a store backed up, without a list of stores to keep in step. +func TestEveryCatalogueModuleDeclaresItsData(t *testing.T) { + root := catalogueRoot(t) + found, err := filepath.Glob(filepath.Join(root, "modules", "*", "module.json")) + if err != nil || len(found) == 0 { + t.Fatalf("no manifests under %s: %v", root, err) + } + shelf := Shelf{} + granting := 0 + for _, p := range found { + m, err := ReadTreeManifest(root, filepath.Dir(p)) + if err != nil { + continue // TestEveryCatalogueManifestParses says why + } + if len(m.Grants) > 0 { + granting++ + } + shelf[m.Module] = m + } + for _, problem := range DataProblems(shelf) { + t.Error(problem) + } + if granting == 0 { + t.Fatal("no module in the catalogue grants a provision, so this proved nothing") + } +} + +// TestEveryCatalogueIdentityFitsWhatItRequires is ADR 0225's check over the real catalogue: every +// module's identity, on the longest machine name, fits the bound of every provision it wants. An +// overflow fails here, in the pull request that introduces it, rather than on the provider's machine +// the first time a real machine's name meets the module's (issue 263). +func TestEveryCatalogueIdentityFitsWhatItRequires(t *testing.T) { + root := catalogueRoot(t) + found, err := filepath.Glob(filepath.Join(root, "modules", "*", "module.json")) + if err != nil || len(found) == 0 { + t.Fatalf("no manifests under %s: %v", root, err) + } + shelf := Shelf{} + for _, p := range found { + m, err := ReadTreeManifest(root, filepath.Dir(p)) + if err != nil { + t.Fatalf("%s: %v", p, err) + } + shelf[m.Module] = m + } + for _, p := range IdentityProblems(shelf, DefaultLongestMachine) { + t.Error(p) + } + // The night it was found: the resolver provision bounds nothing, and the object store still 20. + if dns, ok := shelf["dnsmasq"]; ok { + if b := dns.IdentityBoundOf("wildcard-resolution"); b.Bounded() { + t.Errorf("the resolver provision bounds its consumers' identities: %+v", b) + } + } + if store, ok := shelf["minio"]; ok { + if b := store.IdentityBoundOf("s3-bucket"); b.Max != 20 { + t.Errorf("the object store's access key is not bounded at 20: %+v", b) + } + } +} diff --git a/go/catalogue/catrust_manifest_test.go b/go/catalogue/catrust_manifest_test.go new file mode 100644 index 0000000..d55bdb8 --- /dev/null +++ b/go/catalogue/catrust_manifest_test.go @@ -0,0 +1,69 @@ +package catalogue + +import ( + "path/filepath" + "strings" + "testing" + + "git.novox.be/novox/mesh-sdk/go/internal/beside" +) + +// **A machine trusts the mesh's authority because a module put its root there** (novox/hq ADR +// 0147, issue 129). The module carries a shell script and a unit, and both are worthless unless +// the mesh fills in where the authority is — which is the one thing about it the manifest cannot +// state, because the authority's address is a fact about the mesh and not about the module. +// +// So what is checked here is the rendering, not the parsing: the script the machine will run +// names the authority it was bound to, and the unit runs that script both ways. The verification +// itself — a plain client trusting an internal name on a machine holding this, and failing on one +// that does not — is the lab's, and cannot be had here. +func TestCaTrustRendersTheAuthorityItWasBoundTo(t *testing.T) { + m, err := ReadTreeManifest(beside.Dir(t, "mesh-catalog"), filepath.Join(beside.Catalogue(t), "ca-trust")) + if err != nil { + t.Fatalf("the trust module does not parse:\n%v", err) + } + + r := Resolution{ + Node: "workstation", + Modules: []Manifest{m}, + Needs: []Needed{{ + Name: "internal-acme-ca", From: "anchor", At: "anchor.internal", For: "ca-trust", + Serves: map[string]any{ + "port": float64(9000), "path": "/acme/acme/directory", "roots": "/roots.pem", + }, + }}, + } + out, err := r.Declaration(Rendering{}) + if err != nil { + t.Fatalf("the trust module could not be composed for a machine: %v", err) + } + + script := fileNamed(out, "ca-trust.anchor") + if script == nil { + t.Fatalf("nothing writes the script the unit runs: %v", out) + } + body, _ := script["content"].(string) + if !strings.Contains(body, "https://anchor.internal:9000/roots.pem") { + t.Errorf("the script does not fetch from the authority it was bound to:\n%s", body) + } + if script["mode"] != "0755" { + t.Errorf("the script is written %v, which systemd cannot execute", script["mode"]) + } + + unit := fileNamed(out, "ca-trust.unit") + if unit == nil { + t.Fatalf("no unit: %v", out) + } + text, _ := unit["content"].(string) + // Both halves. A unit that only installs the anchor leaves a machine trusting an authority + // nobody assigned it to any more, which is the half issue 129 asked for by name. + for _, want := range []string{ + "ExecStart=" + script["path"].(string) + " install", + "ExecStop=" + script["path"].(string) + " remove", + "RemainAfterExit=yes", + } { + if !strings.Contains(text, want) { + t.Errorf("the unit does not say %q:\n%s", want, text) + } + } +} diff --git a/go/catalogue/collections_bar_test.go b/go/catalogue/collections_bar_test.go new file mode 100644 index 0000000..54de9d6 --- /dev/null +++ b/go/catalogue/collections_bar_test.go @@ -0,0 +1,135 @@ +package catalogue + +import ( + "os" + "path/filepath" + "reflect" + "testing" +) + +// A bar's blocks are a collection of text (design 57 L3, issue 781): the holder of node-bar names the kind as +// `${collected:contribution:node-bar:block:bar=,place=

}`, which a handed collector has its client fill from the +// items and an unhanded one has the controller fill as it fills the old placeholder. + +const ( + collectedBarsText = "top\n${collected:contribution:node-bar:block:bar=top,place=status}" + + "bottom\n${collected:contribution:node-bar:block:bar=bottom,place=status}" + collectedBars = `{"module":"bars","version":"1","moved":true, + "claims":[{"name":"node-bar","scope":"node","renders":{"block":"{{.shows}}"}}], + "build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]}, + "resources":[{"id":"conf","type":"file","path":"/etc/bars.conf","template":"files/bars"}]}` + itemsOfPower = `{"module":"power","version":"1","moved":true, + "items":[{"collection":"contribution:node-bar:block","name":"battery","if-capability":"battery","attributes":{"bar":"bottom","place":"status","order":"40"},"template":"files/battery"}, + {"collection":"contribution:node-bar:block","name":"draw","attributes":{"bar":"bottom","place":"status","order":"45"},"template":"files/draw"}], + "build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]}}` +) + +func collectedBarsOn(t *testing.T, caps map[string]bool) Resolution { + t.Helper() + bars := held(t, collectedBars, builtWith("bars", map[string]string{"bars": collectedBarsText})) + power := held(t, itemsOfPower, builtWith("power", map[string]string{"battery": "B\n", "draw": "D ${machine:name}\n"})) + r, err := Resolve(shelf(bars, power), []string{"bars", "power"}, workstation(), World{}) + if err != nil { + t.Fatal(err) + } + r.Account, r.Capabilities = "op", caps + return r +} + +func TestACollectorMayNameTheBarsBlocksAsACollectionAndNoOtherDataKind(t *testing.T) { + bars := held(t, collectedBars, builtWith("bars", map[string]string{"bars": collectedBarsText})) + if _, err := collectedBarsOn(t, nil).Compose(handingTo("store:5000")); err != nil { + t.Fatalf("the handed collector is refused: %v", err) + } + if got := collectedPlaceholderProblems(bars, map[string]any{"id": "x", + "content": "${collected:contribution:node-bar:block:bar=top,place=status}"}); len(got) != 0 { + t.Fatalf("the check refuses it: %v", got) + } + // A data kind whose shape does not say its items are text stays refused. + _, plain, _ := receivable(BarSeat, BarKindBlock) + plain.Shape = &Shape{} + if collectableAsText(plain) { + t.Fatal("a shape not marked as text is collectable") + } + if _, _, err := oldCollection("contribution:node-bar:block", false); err != nil { + t.Fatal(err) + } +} + +func TestTheBarsCollectionIsFilledTheOldWayWhereTheCollectorIsNotHanded(t *testing.T) { + for _, caps := range []map[string]bool{nil, {"battery": true}} { + got := composedFile(t, collectedBarsOn(t, caps), "bars.conf") + want := "top\nbottom\n# power\nD workstation\n" + if caps["battery"] { + want = "top\nbottom\n# power\nB\n# power\nD workstation\n" + } + if got != want { + t.Errorf("caps %v: the file is %q, want %q", caps, got, want) + } + } +} + +func TestTheBarsCollectionIsNamedInTheHandedPlan(t *testing.T) { + c, _ := composeWith(t, collectedBarsOn(t, map[string]bool{"battery": true}), handingTo("store:5000")) + if !reflect.DeepEqual(c.Plans["bars"].Collections, []PlanCollection{{Name: "contribution:node-bar:block", Comment: "#"}}) { + t.Fatalf("the plan collects %+v", c.Plans["bars"].Collections) + } + if n := len(c.Plans["power"].Items); n != 2 { + t.Fatalf("the contributor's plan names %d items", n) + } +} + +// The bars of the catalogue's i3status-rust, with the blocks of power's items, compose to the files the old shaped +// contribution made (issue 781). testdata/bars holds the catalogue's bars and block texts as of the move and the bars as +// composed before it, for a machine with neither capability, the battery, the power meter and both. Where the +// collector is not handed the controller fills the files; where it is, it names the items for the client, which +// fills the same files (mesh-catalog's provisioner client holds that half against the same golden files). +func TestTheBarsOfTheCatalogueAreTheSameHandedOrNot(t *testing.T) { + read := func(name string) string { + b, err := os.ReadFile(filepath.Join("testdata", "bars", name)) + if err != nil { + t.Fatal(err) + } + return string(b) + } + collector := `{"module":"bars","version":"1","moved":true,"claims":[{"name":"node-bar","scope":"node"}], + "build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]}, + "resources":[{"id":"top","type":"file","path":"/t","template":"files/top"}, + {"id":"bottom","type":"file","path":"/b","template":"files/bottom"}]}` + power := `{"module":"power","version":"1","moved":true, + "items":[{"collection":"contribution:node-bar:block","name":"battery","if-capability":"battery","attributes":{"bar":"bottom","place":"status","order":"40"},"template":"files/battery"}, + {"collection":"contribution:node-bar:block","name":"power-draw","if-capability":"power-meter","attributes":{"bar":"bottom","place":"status","order":"45"},"template":"files/draw"}], + "build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]}}` + for name, caps := range map[string]map[string]bool{"none": nil, "battery": {"battery": true}, + "power-meter": {"power-meter": true}, "both": {"battery": true, "power-meter": true}} { + bars := held(t, collector, builtWith("bars", map[string]string{"top": read("top-bar.toml"), "bottom": read("bottom-bar.toml")})) + blocks := held(t, power, builtWith("power", map[string]string{"battery": read("bar-battery"), "draw": read("bar-power-draw")})) + r, err := Resolve(shelf(bars, blocks), []string{"bars", "power"}, workstation(), World{}) + if err != nil { + t.Fatal(err) + } + r.Account, r.Capabilities = "op", caps + // Not handed: the controller fills them. + for id, which := range map[string]string{"bars.top": "top", "bars.bottom": "bottom"} { + if got := composedFile(t, r, id); got != read(name+"."+which+"-bar.toml") { + t.Errorf("%s, %s bar, not handed:\n%s\nwant\n%s", name, which, got, read(name+"."+which+"-bar.toml")) + } + } + // Handed: the plan names the items the machine's capabilities allow, for the client to fill. + c, _ := composeWith(t, r, handingTo("store:5000")) + var names []string + for _, it := range c.Plans["power"].Items { + names = append(names, it.Name) + } + want := []string{} + if caps["battery"] { + want = append(want, "battery") + } + if caps["power-meter"] { + want = append(want, "power-draw") + } + if !reflect.DeepEqual(append([]string{}, names...), want) { + t.Errorf("%s: the plan names %v, want %v", name, names, want) + } + } +} diff --git a/go/catalogue/collections_l3_test.go b/go/catalogue/collections_l3_test.go new file mode 100644 index 0000000..4c687a4 --- /dev/null +++ b/go/catalogue/collections_l3_test.go @@ -0,0 +1,158 @@ +package catalogue + +import ( + "reflect" + "strings" + "testing" +) + +// An item may carry attributes and a capability, a module may give several items to one collection, and a +// placeholder may select by attributes (design 57 L3, issue 777). The controller leaves out of a plan an item whose +// capability the machine lacks, accepts a selecting placeholder where it accepts the collection, and renders a +// selected moved item into an old placeholder exactly as the contribution it replaces. + +const ( + barCollector = `{"module":"bars","version":"1","moved":true, + "collections":[{"name":"blocks","comment":"#"}], + "build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]}, + "resources":[{"id":"conf","type":"file","path":"/etc/bars.conf","template":"files/bars"}]}` + barsText = "top\n${collected:blocks:bar=top,place=status}bottom\n${collected:blocks:bar=bottom,place=status}${collected:blocks}" + barContribute = `{"module":"power","version":"1","moved":true, + "items":[{"collection":"blocks","name":"battery","if-capability":"battery","attributes":{"bar":"bottom","place":"status","order":"20"},"template":"files/battery"}, + {"collection":"blocks","name":"clock","attributes":{"bar":"top","place":"status"},"template":"files/clock"}], + "build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]}}` +) + +func blocksOn(t *testing.T, caps map[string]bool) Composed { + t.Helper() + bars := held(t, barCollector, builtWith("bars", map[string]string{"bars": barsText})) + power := held(t, barContribute, builtWith("power", map[string]string{"battery": "B\n", "clock": "C\n"})) + r, err := Resolve(shelf(bars, power), []string{"bars", "power"}, workstation(), World{}) + if err != nil { + t.Fatal(err) + } + r.Account, r.Capabilities = "op", caps + c, _ := composeWith(t, r, handingTo("store:5000")) + return c +} + +func TestAnItemWhoseCapabilityTheMachineLacksIsLeftOutOfItsPlan(t *testing.T) { + with := blocksOn(t, map[string]bool{"battery": true}).Plans["power"] + want := []PlanItem{ + {Collection: "blocks", Name: "battery", Template: "battery", + Attributes: map[string]string{"bar": "bottom", "place": "status", "order": "20"}}, + {Collection: "blocks", Name: "clock", Template: "clock", Attributes: map[string]string{"bar": "top", "place": "status"}}, + } + if !reflect.DeepEqual(with.Items, want) { + t.Fatalf("with the capability the plan carries %+v", with.Items) + } + without := blocksOn(t, map[string]bool{"seat": true}).Plans["power"] + if !reflect.DeepEqual(without.Items, want[1:]) { + t.Fatalf("without it the plan carries %+v", without.Items) + } +} + +func TestACollectorSelectingByAttributesCollectsTheCollectionOnce(t *testing.T) { + bars := blocksOn(t, nil).Plans["bars"] + if !reflect.DeepEqual(bars.Collections, []PlanCollection{{Name: "blocks", Comment: "#"}}) { + t.Fatalf("the plan collects %+v", bars.Collections) + } +} + +func TestASelectorIsRefusedWhenMalformedOrNamingACollectionNotCollected(t *testing.T) { + for placeholder, want := range map[string]string{ + "${collected:blocks:bar}": "", // no equals sign: a collection named blocks:bar + "${collected:blocks:bar=top,bar=bottom}": "names bar twice", + "${collected:blocks:bar=}": "is not =", + "${collected:other:bar=top}": `does not collect "other"`, + } { + raw := strings.Replace(barCollector, `"template":"files/bars"`, `"content":"`+placeholder+`"`, 1) + raw = strings.Replace(raw, `"build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]},`, "", 1) + raw = strings.Replace(raw, `"moved":true,`, "", 1) + m, err := ParseManifest([]byte(raw)) + if err == nil { + _, err = (Resolution{Node: "n", Modules: []Manifest{m}}).Declaration(Rendering{}) + } + if want == "" { + continue + } + if err == nil || !strings.Contains(err.Error(), want) { + t.Errorf("%s: %v, want %q", placeholder, err, want) + } + } +} + +func TestItemNamesAttributesAndCapabilitiesAreJudged(t *testing.T) { + item := func(extra string) string { + return `{"module":"p","moved":true,"build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]},` + + `"items":[` + extra + `]}` + } + for items, want := range map[string]string{ + `{"collection":"c","name":"a","template":"files/a"},{"collection":"c","template":"files/b"}`: "has no name", + `{"collection":"c","name":"a","template":"files/a"},{"collection":"c","name":"a","template":"files/b"}`: "twice", + `{"collection":"c","name":"Big","template":"files/a"}`: "is not a name", + `{"collection":"c","attributes":{"order":"100"},"template":"files/a"}`: "0 to 99", + `{"collection":"c","attributes":{"bar":"a b"},"template":"files/a"}`: "is not a value", + `{"collection":"c","if-capability":"batery","template":"files/a"}`: "no machine reports", + `{"collection":"contribution:node-bar:block","attributes":{"bar":"left"},"template":"files/a"}`: "bar=left", + `{"collection":"contribution:node-bar:block","attributes":{"colour":"red"},"template":"files/a"}`: `"colour" is none of them`, + `{"collection":"c","name":"a","template":"files/a","colour":"red"}`: "colour", + } { + _, err := ParseManifest([]byte(item(items))) + if err == nil || !strings.Contains(err.Error(), want) { + t.Errorf("%s: %v, want %q", items, err, want) + } + } + ok := item(`{"collection":"contribution:node-bar:block","name":"a","attributes":{"bar":"top","place":"status","order":"5"},"if-capability":"battery","template":"files/a"}`) + if _, err := ParseManifest([]byte(ok)); err != nil { + t.Fatal(err) + } +} + +// A moved contributor's items, selected by the old placeholder's pair, render in the old order (order, module, item +// name) and with the same lines as the contributions they replace. +func TestASelectedMovedItemRendersIntoTheOldBarPlaceholderAsTheContributionDid(t *testing.T) { + piece := func(order, command string) string { + return "[[block]]\nblock = \"custom\"\ncommand = \"" + command + "\"\ninterval = 5\n" + } + asOld := Manifest{Module: "power", Contributions: []SeatContribution{ + block(`{"bar":"bottom","place":"status","order":20,"shows":"command","options":{"command":"two","interval":5}}`), + block(`{"bar":"bottom","place":"status","shows":"command","options":{"command":"one","interval":5}}`), + block(`{"bar":"top","place":"resources","shows":"command","options":{"command":"up","interval":5}}`), + }} + asItems := Manifest{Module: "power", Moved: true, Items: []Item{ + {Collection: "contribution:node-bar:block", Name: "b", Content: piece("", "one"), + Attributes: map[string]string{"bar": "bottom", "place": "status"}}, + {Collection: "contribution:node-bar:block", Name: "a", Content: piece("", "two"), + Attributes: map[string]string{"bar": "bottom", "place": "status", "order": "20"}}, + {Collection: "contribution:node-bar:block", Name: "c", Content: piece("", "up"), + Attributes: map[string]string{"bar": "top", "place": "resources"}}, + {Collection: "contribution:node-bar:block", Name: "d", Content: "x\n", IfCapability: "battery", + Attributes: map[string]string{"bar": "bottom", "place": "status"}}, + }} + other := Manifest{Module: "another", Contributions: []SeatContribution{ + block(`{"bar":"bottom","place":"status","order":20,"shows":"command","options":{"command":"mid","interval":5}}`)}} + for _, caps := range []map[string]bool{nil, {"battery": true}} { + was := composedFile(t, Resolution{Node: "n", Account: "op", Capabilities: caps, + Modules: []Manifest{barHolder(), asOld, other}}, "a-bar.bottom") + is := composedFile(t, Resolution{Node: "n", Account: "op", Capabilities: caps, + Modules: []Manifest{barHolder(), asItems, other}}, "a-bar.bottom") + // The old form puts the contributions of a module under one line naming it and the item form puts one under + // each item: the same pieces in the same order, otherwise the same bytes. + want := "own-first\nown-middle\n# another\n" + piece("", "mid") + "# power\n" + piece("", "two") + + "# power\n" + piece("", "one") + if caps["battery"] { + want += "# power\nx\n" + } + want += "own-last\n" + if is != want { + t.Errorf("caps %v: the items render\n%s\nnot\n%s", caps, is, want) + } + if !strings.Contains(was, piece("", "two")) { + t.Errorf("the contributions made\n%s", was) + } + } + if got := composedFile(t, Resolution{Node: "n", Account: "op", Modules: []Manifest{barHolder(), asItems}}, "a-bar.top"); !strings.Contains(got, "\"up\"") || strings.Contains(got, `"one"`) { + t.Errorf("the top bar renders\n%s", got) + } +} diff --git a/go/catalogue/engine_guard_test.go b/go/catalogue/engine_guard_test.go new file mode 100644 index 0000000..2988c9e --- /dev/null +++ b/go/catalogue/engine_guard_test.go @@ -0,0 +1,71 @@ +package catalogue + +import ( + "go/ast" + "go/parser" + "go/token" + "path/filepath" + "slices" + "strconv" + "testing" + + "git.novox.be/novox/mesh-sdk/go/internal/beside" +) + +// The controller refuses a definition's resolved path by the node-engine's own rules, no more (novox/hq issue +// 496): the same lists, read here from mesh-host's internal/apply/placement_guard.go — in a merge check the clone +// beside it at the commit the mesh runs, elsewhere the copy captured in testdata/beside. When the engine's lists +// move, this fails until the controller's move with them, so the gate never refuses what the engine applies nor +// passes what it refuses by path alone. +func TestTheResolvedPathRulesAreTheNodeEnginesOwn(t *testing.T) { + file := filepath.Join(beside.Dir(t, "mesh-host"), "internal", "apply", "placement_guard.go") + parsed, err := parser.ParseFile(token.NewFileSet(), file, nil, 0) + if err != nil { + t.Fatalf("the node-engine's guard does not parse: %v", err) + } + lists := map[string][]string{} + consts := map[string]string{} + ast.Inspect(parsed, func(n ast.Node) bool { + spec, ok := n.(*ast.ValueSpec) + if !ok { + return true + } + for i, name := range spec.Names { + if i >= len(spec.Values) { + continue + } + switch v := spec.Values[i].(type) { + case *ast.CompositeLit: + for _, elt := range v.Elts { + if lit, ok := elt.(*ast.BasicLit); ok && lit.Kind == token.STRING { + s, _ := strconv.Unquote(lit.Value) + lists[name.Name] = append(lists[name.Name], s) + } + } + case *ast.BasicLit: + if v.Kind == token.STRING { + consts[name.Name], _ = strconv.Unquote(v.Value) + } + } + } + return true + }) + for name, ours := range map[string][]string{ + "protectedRoots": protectedRoots, "forbiddenBelow": forbiddenBelow, "engineTrees": engineTrees, + } { + theirs := lists[name] + if len(theirs) == 0 { + t.Errorf("the node-engine's guard names no %s any more; read it and say where its rule went", name) + continue + } + a, b := slices.Clone(ours), slices.Clone(theirs) + slices.Sort(a) + slices.Sort(b) + if !slices.Equal(a, b) { + t.Errorf("%s differs from the node-engine's:\n controller %v\n node-engine %v", name, a, b) + } + } + if consts["engineModule"] != engineModule { + t.Errorf("the node-engine's own module is %q there and %q here", consts["engineModule"], engineModule) + } +} diff --git a/go/catalogue/environment_items_test.go b/go/catalogue/environment_items_test.go new file mode 100644 index 0000000..cd4c93b --- /dev/null +++ b/go/catalogue/environment_items_test.go @@ -0,0 +1,165 @@ +package catalogue + +import ( + "fmt" + "os" + "path/filepath" + "reflect" + "strings" + "testing" +) + +// The session environment is a collection of typed items (design 57 L3, issue 795): a variable is an item with +// kind=variable and name=, a PATH entry one with kind=path and at=start|end, each a template whose text is the +// value or the entry. Where node-env is not handed the controller renders the items as the old form was rendered; +// where it is, it names the collection for the client, which fills the same files (mesh-catalog's provisioner client +// holds that half against the same golden files). testdata/environment holds the environment of the catalogue's five +// contributors as the old form composed it, with all five and with zsh alone. + +type envDecl struct{ module, item, name, value string } + +func environmentOfTheCatalogue() []envDecl { + return []envDecl{ + {"adwaita", "gtk-theme", "GTK_THEME", "Adwaita:dark"}, + {"adwaita", "gtk2-rc-files", "GTK2_RC_FILES", "/usr/share/themes/Adwaita-dark/gtk-2.0/gtkrc"}, + {"adwaita", "qt-qpa-platformtheme", "QT_QPA_PLATFORMTHEME", "qt6ct"}, + {"adwaita", "qt-style-override", "QT_STYLE_OVERRIDE", "Fusion"}, + {"adwaita", "qt-select", "QT_SELECT", "6"}, + {"adwaita", "xcursor-theme", "XCURSOR_THEME", "Adwaita"}, + {"adwaita", "xcursor-size", "XCURSOR_SIZE", "24"}, + {"clipmenu", "cm-selections", "CM_SELECTIONS", "clipboard"}, + {"clipmenu", "cm-max-clips", "CM_MAX_CLIPS", "500"}, + {"clipmenu", "cm-histlength", "CM_HISTLENGTH", "15"}, + {"i3", "xdg-current-desktop", "XDG_CURRENT_DESKTOP", "i3"}, + {"i3", "xdg-session-desktop", "XDG_SESSION_DESKTOP", "i3"}, + {"xterm", "terminal", "TERMINAL", "xterm"}, + {"zsh", "editor", "EDITOR", "vim"}, + {"zsh", "visual", "VISUAL", "vim"}, + {"zsh", "xdg-config-home", "XDG_CONFIG_HOME", "${machine:account-home}/.config"}, + {"zsh", "path-local-bin", "path:10", "${machine:account-home}/.local/bin"}, + {"zsh", "path-scripts", "path:20", "${machine:account-home}/scripts"}, + {"zsh", "path-scripts-bin", "path:30", "${machine:account-home}/scripts/bin"}, + } +} + +const environmentCollector = `{"module":"node-env","version":"1","moved":true, + "claims":[{"name":"node-environment","scope":"node"}], + "build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]}, + "resources":[{"id":"posix","type":"file","path":"/home/op/.config/mesh/environment.sh","template":"files/posix"}, + {"id":"systemd","type":"file","path":"/home/op/.config/environment.d/50-mesh.conf","template":"files/systemd"}]}` + +// nodeEnvWith is node-env, its file placing the environment as plan says, with the declared contributors' items. +func nodeEnvWith(t *testing.T, decls []envDecl, plan string) Resolution { + t.Helper() + collector := held(t, environmentCollector, builtWith("node-env", map[string]string{ + "posix": "# header\n" + plan, "systemd": "${environment:systemd}"})) + mods := []Manifest{collector} + order := []string{"node-env"} + byModule := map[string][]envDecl{} + var modules []string + for _, d := range decls { + if byModule[d.module] == nil { + modules = append(modules, d.module) + } + byModule[d.module] = append(byModule[d.module], d) + } + for _, name := range modules { + var items []string + texts := map[string]string{} + for _, d := range byModule[name] { + attrs := fmt.Sprintf(`{"kind":"variable","name":%q}`, d.name) + if order, isPath := strings.CutPrefix(d.name, "path:"); isPath { + attrs = fmt.Sprintf(`{"kind":"path","at":"start","order":%q}`, order) + } + items = append(items, fmt.Sprintf(`{"collection":"environment","name":%q,"attributes":%s,"template":"files/env-%s"}`, + d.item, attrs, d.item)) + texts["env-"+d.item] = d.value + "\n" + } + raw := fmt.Sprintf(`{"module":%q,"version":"1","moved":true,"items":[%s], + "build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]}}`, name, strings.Join(items, ",")) + mods = append(mods, held(t, raw, builtWith(name, texts))) + order = append(order, name) + } + r, err := Resolve(shelf(mods...), order, workstation(), World{}) + if err != nil { + t.Fatal(err) + } + r.Account = "op" + return r +} + +func TestTheEnvironmentOfTheCatalogueIsTheSameHandedOrNot(t *testing.T) { + read := func(name string) string { + b, err := os.ReadFile(filepath.Join("testdata", "environment", name)) + if err != nil { + t.Fatal(err) + } + return string(b) + } + for name, only := range map[string]string{"all": "", "zsh": "zsh"} { + var decls []envDecl + for _, d := range environmentOfTheCatalogue() { + if only == "" || d.module == only { + decls = append(decls, d) + } + } + // Named as the collector names it, and as the old placeholder does. + for _, plan := range []string{"${collected:environment:posix}", "${environment:posix}"} { + r := nodeEnvWith(t, decls, plan) + // Not handed: the controller renders each moved contributor's items as the old form was rendered. + if got, want := composedFile(t, r, "node-env.posix"), "# header\n"+read(name+".posix"); got != want { + t.Errorf("%s, %s, not handed, the POSIX file is\n%s\nwant\n%s", name, plan, got, want) + } + if got, want := composedFile(t, r, "node-env.systemd"), read(name+".systemd"); got != want { + t.Errorf("%s, %s, not handed, the environment.d file is\n%s\nwant\n%s", name, plan, got, want) + } + // Handed: one collection, named for the client; each contributor's plan names its items. + c, _ := composeWith(t, r, handingTo("store:5000")) + if got := c.Plans["node-env"].Collections; !reflect.DeepEqual(got, []PlanCollection{{Name: "environment", Comment: "#"}}) { + t.Errorf("%s: the plan collects %+v", name, got) + } + items, got := len(decls), 0 + for m := range c.Plans { + got += len(c.Plans[m].Items) + } + if got != items { + t.Errorf("%s: the plans name %d items, want %d", name, got, items) + } + } + } +} + +func TestAnEnvironmentItemIsTypedAndNamesWhatItNeeds(t *testing.T) { + item := func(attrs map[string]string) []string { + return environmentItemProblems("x", Item{Collection: "environment", Attributes: attrs}) + } + for _, ok := range []map[string]string{ + {"kind": "variable", "name": "GTK_THEME"}, + {"kind": "path", "at": "end", "order": "10"}, + } { + if p := item(ok); len(p) != 0 { + t.Errorf("%v is refused: %v", ok, p) + } + } + for _, bad := range []map[string]string{ + nil, {"kind": "other"}, {"kind": "variable"}, {"kind": "variable", "name": "PATH"}, + {"kind": "path"}, {"kind": "path", "at": "middle"}, {"kind": "path", "at": "start", "name": "X"}, + {"kind": "variable", "name": "A", "color": "red"}, + } { + if p := item(bad); len(p) == 0 { + t.Errorf("%v is taken", bad) + } + } + if !attributeFits("environment", "name", "GTK_THEME") || attributeFits("shell:zsh:normal", "name", "GTK_THEME") || + attributeFits("environment", "kind", "Variable") { + t.Error("an upper-case name is the environment's name alone") + } + // A module that sets a variable twice, in two modules, is refused as the old form refused it. + a := Manifest{Module: "a", Items: []Item{{Collection: "environment", Content: "1\n", + Attributes: map[string]string{"kind": "variable", "name": "X"}}}} + b := Manifest{Module: "b", Items: []Item{{Collection: "environment", Content: "2\n", + Attributes: map[string]string{"kind": "variable", "name": "X"}}}} + if err := variablesSetOnce([]Manifest{a, b}); err == nil || !strings.Contains(err.Error(), "both set X") { + t.Errorf("two modules setting X: %v", err) + } +} diff --git a/go/catalogue/foundation_manifests_test.go b/go/catalogue/foundation_manifests_test.go new file mode 100644 index 0000000..cc52f6a --- /dev/null +++ b/go/catalogue/foundation_manifests_test.go @@ -0,0 +1,334 @@ +package catalogue + +import ( + "encoding/json" + "fmt" + "path/filepath" + "reflect" + "strings" + "testing" + + "git.novox.be/novox/mesh-sdk/go/internal/beside" +) + +// The catalogue's foundation modules as they are — in a merge check the catalogue cloned beside it, +// elsewhere the copy captured in testdata/beside (internal/beside, novox/hq issue 432) — parsed by the +// real parser (novox/hq ADR 0100): +// the store and the broker say which of their ports the mesh guards on an adopted node, and the +// filter module loads its table through a unit of its own whose stop deletes only that table. +// moduleTree is where a module is read from: the catalogue beside, with the catalogue as the root. In +// mesh-controller it first looked in the repository's own modules/, where build-agent and +// route-proxy live whole (novox/hq ADR 0420 §1, issue 1019); mesh-sdk has no such directory, and the +// tests that read those two modules stay in mesh-controller (issue 1379). +func moduleTree(t *testing.T, module string) (root, dir string) { + t.Helper() + return beside.Dir(t, "mesh-catalog"), filepath.Join(beside.Catalogue(t), module) +} + +func catalogueManifest(t *testing.T, module string) Manifest { + t.Helper() + root, dir := moduleTree(t, module) + m, err := ReadTreeManifest(root, dir) + if err != nil { + t.Fatalf("%s does not parse:\n%v", module, err) + } + return m +} + +func TestTheStoreAndTheBrokerSayWhatTheMeshGuards(t *testing.T) { + if got := catalogueManifest(t, "postgres").Guards; !reflect.DeepEqual(got, []int{5432}) { + t.Errorf("postgres guards %v; the store's port must be refused from outside", got) + } + // The bus's monitoring port, not its client port: a node reaches the bus, nobody outside + // reads its state (novox/hq ADR 0131 — the broker that guarded 15672 has left the catalogue). + if got := catalogueManifest(t, "nats").Guards; !reflect.DeepEqual(got, []int{8222}) { + t.Errorf("nats guards %v; the monitoring port must be refused from outside", got) + } +} + +func TestTheFilterModuleNeverFlushesTheRuleset(t *testing.T) { + m := catalogueManifest(t, "nftables") + var unit, stock, load map[string]any + for _, r := range m.Resources { + switch r["id"] { + case "unit": + unit = r + case "stock-unit-stop": + stock = r + case "load": + load = r + } + } + if load == nil || load["unit"] != "mesh-filter.service" { + t.Fatalf("the filter is not loaded by its own unit: %v", load) + } + content, _ := unit["content"].(string) + if unit == nil || unit["path"] != "/etc/systemd/system/mesh-filter.service" { + t.Fatalf("the filter's unit is not written: %v", unit) + } + if strings.Contains(content, "flush") { + t.Fatalf("stopping the filter flushes the whole ruleset — the runtime's and the found "+ + "firewall's with it:\n%s", content) + } + if !strings.Contains(content, "ExecStop=nft delete table inet mesh\n") || + !strings.Contains(content, "ExecStart=nft -f "+m.Filtering.Into+"\n") { + t.Fatalf("the unit does not load the computed rule set and delete only its own table:\n%s", + content) + } + // A node converged before the filter had its own unit still has the stock nftables.service + // enabled, whose stop flushes the whole ruleset: a drop-in makes it delete only the mesh's + // table, and the load is restarted on it so the host reloads units and the drop-in is read. + if stock == nil || stock["path"] != "/etc/systemd/system/nftables.service.d/mesh.conf" || + !strings.HasSuffix(fmt.Sprint(stock["content"]), + "[Service]\nExecStop=\nExecStop=nft delete table inet mesh\n") { + t.Fatalf("the stock unit's stop is not replaced with deleting the mesh's table: %v", stock) + } + // A changed rule set is RELOADED — ExecReload replaces the table in one `nft -f`, so the node + // is never unfiltered — and only the units themselves restart it, which is the one change a + // reload cannot carry. + if !reflect.DeepEqual(load["reload-on"], []any{"filtering"}) { + t.Fatalf("the filter is restarted rather than reloaded when its rules change, leaving the "+ + "node unfiltered in between: %v", load) + } + if !reflect.DeepEqual(load["restart-on"], []any{"unit", "stock-unit-stop"}) { + t.Fatalf("the filter is not restarted when its unit or the stock unit's drop-in changes: %v", + load["restart-on"]) + } +} + +// The package registry's port is the node's, like every other foundation port (novox/hq +// 04-ISSUES/085, ADR 0100). The forge is reached through what it says it serves, and consumers — +// the builder among them — are told that, rather than carrying a number of their own. + +func TestTheForgesPortIsGivenLikeAnyOtherProvidersPort(t *testing.T) { + forge := catalogueManifest(t, "gitea") + + // The catalogue's number is a default and the node's setting moves it. + given, err := GivenPorts(forge, []Layer{{From: "anchor", + Placement: map[string]any{PortsSetting: map[string]any{"3000": float64(3100)}}}}) + if err != nil { + t.Fatalf("the forge's port cannot be given on a node: %v", err) + } + if given[3000] != 3100 { + t.Fatalf("the forge was given %v", given) + } + + // And every consumer of the package registry is told where the machine actually put it, + // because that is read from what the forge serves rather than written in the consumer. + if got := ServedOn(forge, "npm-package-registry", given)["port"]; got != 3100 { + t.Errorf("the package registry is served on %v, not the port this node gave it", got) + } + if got := ServedOn(forge, "npm-package-registry", nil)["port"]; got != float64(3000) { + t.Errorf("without a setting the forge serves %v, not the catalogue's port", got) + } + // And so is where a repository on it is cloned from (novox/hq ADR 0111), for the same reason: + // a build composes the URL from what the forge serves, so a given port is a followed port. + if got := ServedOn(forge, "git", given)["port"]; got != 3100 { + t.Errorf("git is served on %v, not the port this node gave the forge", got) + } +} + +// The forge holds the seats it answers for (novox/hq ADR 0110, 0111), parsed by the real parser — +// which refuses a delivering seat claimed by a module that does not provide what it delivers. +func TestTheForgeHoldsTheNpmAndGitSeats(t *testing.T) { + forge := catalogueManifest(t, "gitea") + holds := map[string]bool{} + for _, c := range forge.Claims { + holds[c.Name] = true + } + for _, seat := range []string{"npm-package-registry", "git"} { + if !holds[seat] { + t.Errorf("gitea does not claim the %s seat: %+v", seat, forge.Claims) + } + } + git := ServedOn(forge, "git", nil) + if git["scheme"] != "http" || git["port"] != float64(3000) { + t.Errorf("gitea serves nothing a clone URL can be composed from: %v", git) + } + npm := ServedOn(forge, "npm-package-registry", nil) + if npm["npm-path"] != "/api/packages/novox/npm/" { + t.Errorf("gitea no longer says where its npm registry is: %v", npm) + } +} + +// **And the forge's own address follows it**, composed from the manifest in the catalogue beside +// this checkout (novox/hq 04-ISSUES/088). +// +// The forge is reached a third way that neither test above covers: by its own code, over the +// machine's loopback, told where to go in its environment. The `2999:3000` mapping that lets the +// forge go on binding 3000 does nothing for a caller dialling the machine — so a literal there is +// wrong on every node whose assignment differs, and wrong for a second reason on a node given the +// port (ADR 0100). Composed through the whole path, because what proves the placeholder resolves +// in an `env` at all is a declaration, not a manifest. +func TestTheForgesOwnAddressFollowsThePortTheNodeGaveIt(t *testing.T) { + forge, err := catalogueManifest(t, "gitea").Resolve(forgeBuilt(catalogueManifest(t, "gitea"))) + if err != nil { + t.Fatalf("the forge's manifest does not resolve against its own build: %v", err) + } + r := Resolution{Node: "anchor", Account: "op", PublicDomain: "example.org", + Modules: []Manifest{forge, theRuntime(t)}, Needs: []Needed{ + {Name: "postgres-database", For: "gitea", From: "anchor", At: "127.0.0.1", + Serves: map[string]any{"port": float64(5432)}, Sealed: "sealed-db"}, + {Name: "route", For: "gitea", From: "anchor"}, + {Name: "secret", For: "gitea", From: "anchor", Local: "internal-token", Sealed: "sealed-token"}, + {Name: "secret", For: "gitea", From: "anchor", Local: "admin", Sealed: "sealed-admin"}, + }} + + // The number this node was given for the forge — the one the machine it is about to run on + // already publishes. + out, err := r.Declaration(Rendering{ArtifactStore: "anchor.internal:5101", + Needed: map[string]map[string]string{RuntimeModule: {"broker": "sealed-broker"}}, + Given: map[string]map[int]int{"gitea": {3000: 2999}}, + }) + if err != nil { + t.Fatalf("the forge does not compose: %v", err) + } + + // What the machine publishes, and what the forge's sidecar is told to dial: one number. + server := fileNamed(out, "gitea.server") + if server == nil { + t.Fatalf("the forge's own container is not in the declaration: %v", out) + } + if published := fmt.Sprint(server["ports"]); !strings.Contains(published, "2999:3000") { + t.Fatalf("the forge is not published on the port this node gave it: %v", server["ports"]) + } + // The forge's own code runs in the node's runtime (novox/hq ADR 0198), given its words there. + runtime := fileNamed(out, RuntimeModule+"."+RuntimeProcessID()) + if runtime == nil { + t.Fatalf("the node's runtime is not in the declaration: %v", ids(out)) + } + env, _ := runtime["env"].(map[string]string) + var given map[string]map[string]string + if err := json.Unmarshal([]byte(env[RuntimeToolEnv]), &given); err != nil { + t.Fatalf("the runtime's %s is not JSON: %q", RuntimeToolEnv, env[RuntimeToolEnv]) + } + if given["gitea"]["MESH_GITEA_URL"] != "http://127.0.0.1:2999" { + t.Fatalf("the forge's code dials %v while the machine publishes the forge on 2999 — "+ + "whatever reads it dials a dead port", given["gitea"]["MESH_GITEA_URL"]) + } +} + +// gitea's own sshd is unmodified — the module's own internal port is 22, the number in +// `listens`, the same convention every other module in the catalogue uses (its internal port, +// not an invented identity). Composed from the manifest in the catalogue beside this checkout, +// because what the mesh publishes is a fact about what the module actually writes. +func declaredGiteaSsh(t *testing.T, given map[int]int) map[string]any { + t.Helper() + forge := catalogueManifest(t, "gitea") + resolved, err := forge.Resolve(forgeBuilt(forge)) + if err != nil { + t.Fatalf("the forge's manifest does not resolve against its own build: %v", err) + } + r := Resolution{Node: "anchor", Account: "op", PublicDomain: "example.org", + Modules: []Manifest{resolved, theRuntime(t)}, Needs: []Needed{ + {Name: "postgres-database", For: "gitea", From: "anchor", At: "127.0.0.1", + Serves: map[string]any{"port": float64(5432)}, Sealed: "sealed-db"}, + {Name: "route", For: "gitea", From: "anchor"}, + {Name: "secret", For: "gitea", From: "anchor", Local: "internal-token", Sealed: "sealed-token"}, + {Name: "secret", For: "gitea", From: "anchor", Local: "admin", Sealed: "sealed-admin"}, + }} + givenPorts := map[int]int{3000: 3000} + for k, v := range given { + givenPorts[k] = v + } + out, err := r.Declaration(Rendering{ArtifactStore: "anchor.internal:5101", + Needed: map[string]map[string]string{RuntimeModule: {"broker": "sealed-broker"}}, + Ports: map[string]map[int]int{"gitea": givenPorts}, + Given: map[string]map[int]int{"gitea": given}, + }) + if err != nil { + t.Fatalf("the forge does not compose: %v", err) + } + server := fileNamed(out, "gitea.server") + if server == nil { + t.Fatalf("the forge's own container is not in the declaration: %v", out) + } + return server +} + +// **The forge publishes ssh at the mesh's own fixed convention by default** (novox/hq ADR 0100). +// +// `222` is the mesh's own public convention for the forge's ssh, written directly in the +// manifest's `ports` — every node the forge has run on used the same number, so it needs no +// per-node setting to reach it. +func TestTheForgesSshPortIsTheMeshsFixedConventionByDefault(t *testing.T) { + forge := catalogueManifest(t, "gitea") + // Nothing was given — no node moved this port — which is the ordinary answer: the mesh only + // reports what a setting moved, and the manifest's own `222:22` needs no move to be reached. + given, err := GivenPorts(forge, nil) + if err != nil { + t.Fatalf("the forge's ssh port cannot be given on a node: %v", err) + } + if len(given) != 0 { + t.Fatalf("nothing moved the forge's ssh port, yet it was given %v", given) + } + server := declaredGiteaSsh(t, given) + if published := fmt.Sprint(server["ports"]); !strings.Contains(published, "222:22") { + t.Fatalf("the forge is published on %v, not its own fixed convention", server["ports"]) + } +} + +// **A node whose predecessor served git on a different number can still be told to leave it +// there.** The setting names the port the module itself listens on — 22, gitea's own sshd, the +// same number `listens` uses — not the mesh's own default machine-side number, so moving it does +// not require guessing what the manifest happens to default to. +func TestANodeMayGiveTheForgesSshPortADifferentNumber(t *testing.T) { + forge := catalogueManifest(t, "gitea") + given, err := GivenPorts(forge, []Layer{{From: "anchor", + Placement: map[string]any{PortsSetting: map[string]any{"22": float64(9022)}}}}) + if err != nil { + t.Fatalf("the forge's ssh port cannot be moved on a node: %v", err) + } + if want := map[int]int{22: 9022}; !reflect.DeepEqual(given, want) { + t.Fatalf("the forge was given %v, and the setting named %v", given, want) + } + server := declaredGiteaSsh(t, given) + if published := fmt.Sprint(server["ports"]); !strings.Contains(published, "9022:22") || + strings.Contains(published, "222:22") { + t.Fatalf("the forge is published on %v, not the port this node gave it", server["ports"]) + } +} + +// forgeBuilt is a build of every artifact the forge's manifest declares: its code bundle and, since the +// catalogue added it (novox/hq ADR 0251 §4), its npm registry's. Named from the manifest, so a third +// artifact does not break every forge test again. +func forgeBuilt(forge Manifest) []Built { + var out []Built + for _, a := range forge.Build.Artifacts { + out = append(out, Built{Name: a.Name, Kind: ArtifactBundle, + Reference: ArtifactStoreScheme + "gitea/" + a.Name + "/blobs/" + bundleDigest, Digest: bundleDigest}) + } + return out +} + +// catalogueResolved is a catalogue module as the mesh holds it once built: the manifest as written, resolved against +// what the build produced besides the archive its templates are packed into, which carries the text read back from the +// module's files/ directory as the real build reads it (novox/hq ADR 0349 §2) — so a module whose file text moved into +// templates composes with it, and one that did not is resolved as it was. +func catalogueResolved(t *testing.T, module string, built ...Built) Manifest { + t.Helper() + root, dir := moduleTree(t, module) + m, err := ReadTreeManifestWritten(root, dir) + if err != nil { + t.Fatalf("%s does not parse:\n%v", module, err) + } + if name := TemplatesArtifact(m); name != "" { + read, err := ReadTemplates(m, dir) + if err != nil { + t.Fatalf("%s: %v", module, err) + } + // By the path inside the archive, as the build reads it back, not by the path in the module's directory. + texts := map[string]string{} + for path, text := range read { + texts[strings.TrimPrefix(path, m.TemplatesDir()+"/")] = text + } + built = append(built, Built{Name: name, Kind: ArtifactArchive, + Reference: ArtifactStoreScheme + module + "/" + name + "/blobs/" + bundleDigest, Digest: bundleDigest, + Texts: texts}) + } + held, err := m.Resolve(built) + if err != nil { + t.Fatalf("%s does not resolve against its own build: %v", module, err) + } + return held +} diff --git a/go/catalogue/generated_collision_test.go b/go/catalogue/generated_collision_test.go new file mode 100644 index 0000000..45fcabe --- /dev/null +++ b/go/catalogue/generated_collision_test.go @@ -0,0 +1,85 @@ +package catalogue + +import ( + "strings" + "testing" +) + +// novox/hq issue 190, step 5 (ADR 0222): what the mesh computes for a module is held to the rule +// every module is — no two modules on a machine declare one path, unit or name (a package may be declared by +// several, novox/hq ADR 0303). The +// private network once declared the container runtime's file and service beside the runtime's own +// module, and nothing refused it, because the collision check only ever saw catalogue manifests. + +func runtimesOwn() Manifest { + return Manifest{Module: "docker", Resources: []map[string]any{ + {"id": "daemon", "type": "file", "path": "/etc/docker/daemon.json", "into": "json", + "content": `{"live-restore": true}`}, + {"id": "runtime", "type": "service", "unit": "docker.service", "state": "running", + "reload-on": []any{"daemon"}}, + }} +} + +func TestAGeneratedResourceCollidingWithAModulesIsRefused(t *testing.T) { + r := Resolution{Node: "workstation", Modules: []Manifest{ + {Module: "mesh-network", Computed: "mesh-network", Provides: Offers("private-network")}, + runtimesOwn(), + }} + _, err := r.Declaration(Rendering{Generators: map[string]Generator{"mesh-network": reloading{}}}) + if err == nil { + t.Fatal("a generated resource declaring the runtime's file beside the runtime's module was accepted") + } + for _, want := range []string{"mesh-network", "docker", "/etc/docker/daemon.json", "docker.service"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("the refusal does not name %s: %v", want, err) + } + } +} + +func TestAGeneratedResourceBesideAModulesOwnIsComposed(t *testing.T) { + r := Resolution{Node: "workstation", Modules: []Manifest{ + {Module: "mesh-network", Computed: "mesh-network", Provides: Offers("private-network")}, + runtimesOwn(), + }} + gen := &fake{on: map[string]bool{"workstation": true}} + out, err := r.Declaration(Rendering{Generators: map[string]Generator{"mesh-network": gen}}) + if err != nil { + t.Fatalf("disjoint resources were refused: %v", err) + } + if fileNamed(out, "docker.daemon") == nil { + t.Fatalf("the runtime's own file is missing: %v", out) + } + // And a machine not on the network yet generates nothing, which collides with nothing. + if _, err := r.Declaration(Rendering{Generators: map[string]Generator{"mesh-network": &fake{}}}); err != nil { + t.Fatalf("a machine off the network was refused: %v", err) + } +} + +// The catalogue's container runtime module states the mesh's registry itself (ADR 0222). +func TestTheRuntimesModuleTrustsTheMeshsRegistry(t *testing.T) { + docker := catalogueManifest(t, "docker") + r := Resolution{Node: "workstation", Modules: []Manifest{docker}} + out, err := r.Declaration(Rendering{ + SeatReach: map[string]string{"mesh-artifact-store": "anchor.internal:5100"}, + }) + if err != nil { + t.Fatal(err) + } + daemon := fileNamed(out, "docker.daemon") + if daemon == nil || daemon["into"] != "json" { + t.Fatalf("the runtime's file is not written into: %v", daemon) + } + content, _ := daemon["content"].(string) + if !strings.Contains(content, `"insecure-registries": ["anchor.internal:5100"]`) || + !strings.Contains(content, `"live-restore": true`) { + t.Fatalf("the runtime's file says %q", content) + } + + out, err = r.Declaration(Rendering{}) + if err != nil { + t.Fatal(err) + } + if content, _ := fileNamed(out, "docker.daemon")["content"].(string); strings.Contains(content, "insecure-registries") { + t.Fatalf("with no store on the network, the runtime is told %q", content) + } +} diff --git a/go/catalogue/hostname_seat_test.go b/go/catalogue/hostname_seat_test.go new file mode 100644 index 0000000..c151efa --- /dev/null +++ b/go/catalogue/hostname_seat_test.go @@ -0,0 +1,98 @@ +package catalogue + +import ( + "strings" + "testing" +) + +// novox/hq ADR 0223 part 3: a machine's names are one seat's. The `hosts` module holding +// `node-hosts-file` became `hostname` holding `node-hostname`, which writes /etc/hostname beside the +// machine's own lines in /etc/hosts. The seat was renamed (ADR 0122), so what claims the old name — a +// manifest registered before the rename — still holds the one seat. + +func withHostnameAlias(t *testing.T) { + t.Helper() + was := aliases + t.Cleanup(func() { aliases = was }) + UseAliases(map[string]string{"node-hosts-file": "node-hostname"}) +} + +func TestTheHostsFilesFormerNameResolvesToTheHostnameSeat(t *testing.T) { + if _, known := SeatNamed("node-hostname"); !known { + t.Fatal("node-hostname is not in the mesh's set") + } + withHostnameAlias(t) + seat, known := SeatNamed("node-hosts-file") + if !known || seat.Name != "node-hostname" || seat.Scope != ScopeNode { + t.Fatalf("the former name did not resolve: %+v %v", seat, known) + } + var verbs []string + for _, v := range seat.Serves { + verbs = append(verbs, v.Name) + } + if strings.Join(verbs, " ") != "entries add remove" { + t.Errorf("the renamed seat serves %v; its verbs are unchanged", verbs) + } +} + +// One seat under either name: the module registered before the rename and the one after cannot both +// hold it on one machine. +func TestTheOldAndTheNewClaimantAreOneSeatOnAMachine(t *testing.T) { + withHostnameAlias(t) + cat := shelf( + mod("hosts", nil, nil, nil, Claim{Name: "node-hosts-file"}), + mod("hostname", nil, nil, nil, Claim{Name: "node-hostname"}), + ) + _, err := Resolve(cat, []string{"hosts", "hostname"}, workstation(), World{}) + if err == nil || !strings.Contains(err.Error(), "node-hostname") { + t.Errorf("hosts and hostname both held the machine's names on one machine: %v", err) + } + if _, err := Resolve(cat, []string{"hosts"}, workstation(), World{}); err != nil { + t.Errorf("a machine still assigned hosts under the old name does not resolve: %v", err) + } +} + +// The catalogue's module: /etc/hostname is the operator's `hostname` setting. Without it the module is +// left out, naming the key — the mesh never renames a machine on its own — and a mesh-wide setting +// naming ${machine:name} gives every machine its mesh name. +func TestTheMachinesNameIsItsSetting(t *testing.T) { + cat := map[string]Manifest{"hostname": catalogueManifest(t, "hostname")} + got, err := Resolve(cat, []string{"hostname"}, Node{Name: "ace", At: "ace.internal"}, World{}) + if err != nil { + t.Fatal(err) + } + machines := map[string]string{"ace.internal": "10.42.0.2"} + nameOf := func(settings SettingsBy) (string, string) { + t.Helper() + composed, err := got.Compose(Rendering{Names: machines, Machines: machines, Suffix: "internal", + Settings: settings}) + if err != nil { + t.Fatal(err) + } + if why := composed.LeftOut["hostname"]; why != "" { + return "", why + } + for _, r := range composed.Resources { + if r["path"] == "/etc/hostname" { + return r["content"].(string), "" + } + } + t.Fatal("no /etc/hostname composed") + return "", "" + } + + if _, why := nameOf(nil); !strings.Contains(why, "hostname") { + t.Errorf("with no setting the machine's name was written, or left out for another reason: %q", why) + } + if name, why := nameOf(SettingsBy{"hostname": {{From: "ace", Values: map[string]any{"hostname": "Ace"}}}}); name != "Ace\n" { + t.Errorf("the operator's name for the machine gave %q (%s)", name, why) + } + mesh := Layer{From: "the mesh", Values: map[string]any{"hostname": "${machine:name}"}} + if name, why := nameOf(SettingsBy{"hostname": {mesh}}); name != "ace\n" { + t.Errorf("a mesh-wide ${machine:name} gave %q (%s)", name, why) + } + node := Layer{From: "ace", Values: map[string]any{"hostname": "Ace"}} + if name, why := nameOf(SettingsBy{"hostname": {mesh, node}}); name != "Ace\n" { + t.Errorf("a machine's own name over the mesh-wide one gave %q (%s)", name, why) + } +} diff --git a/go/catalogue/logs_test.go b/go/catalogue/logs_test.go new file mode 100644 index 0000000..76e7ee0 --- /dev/null +++ b/go/catalogue/logs_test.go @@ -0,0 +1,68 @@ +package catalogue + +import ( + "encoding/json" + "strings" + "testing" +) + +// A module declares the logs it keeps (novox/hq ADR 0297 §1): a log by its bare name, or with its cap +// in MiB; the default cap where none is said. +func TestAManifestMaySayWhatLogsItKeeps(t *testing.T) { + m, err := ParseManifest([]byte(`{"module":"mesh-issues","version":"1",` + + `"logs":["changes",{"name":"moves","max-mib":2048}]}`)) + if err != nil { + t.Fatal(err) + } + if len(m.Logs) != 2 || m.Logs[0].Name != "changes" || m.Logs[1].Name != "moves" { + t.Fatalf("logs not read: %+v", m.Logs) + } + if m.Logs[0].Cap() != LogDefaultMiB || m.Logs[0].Cap() != 1024 || m.Logs[1].Cap() != 2048 { + t.Fatalf("caps read as %d and %d", m.Logs[0].Cap(), m.Logs[1].Cap()) + } + out, _ := json.Marshal(m.Logs) + if string(out) != `["changes",{"name":"moves","max-mib":2048}]` { + t.Fatalf("written back as %s", out) + } + for _, edge := range []string{`{"name":"a","max-mib":1}`, `{"name":"a","max-mib":8192}`} { + if _, err := ParseManifest([]byte(`{"module":"a","version":"1","logs":[` + edge + `]}`)); err != nil { + t.Errorf("%s is inside the caps and was refused: %v", edge, err) + } + } +} + +// The catalogue check refuses a log outside the caps, with a name that is not one plain token, with a +// field the mesh does not know, or kept by a module whose own name is not one plain token. +func TestALogIsNamedLocallyAndCapped(t *testing.T) { + for _, c := range []struct{ manifest, says string }{ + {`{"module":"a","version":"1","logs":["mesh.changes"]}`, `keeps log "mesh.changes": a log is named locally`}, + {`{"module":"a","version":"1","logs":["my_changes"]}`, `keeps log "my_changes"`}, + {`{"module":"a","version":"1","logs":["Changes"]}`, `keeps log "Changes"`}, + {`{"module":"a","version":"1","logs":["c","c"]}`, `keeps log "c" twice`}, + {`{"module":"a","version":"1","logs":[{"name":"c","max-mib":8193}]}`, `between 1 and 8192 MiB`}, + {`{"module":"a","version":"1","logs":[{"name":"c","max-mib":-1}]}`, `between 1 and 8192 MiB`}, + {`{"module":"a","version":"1","logs":[{"name":"c","max-mib":0}]}`, `max-mib is between 1 and 8192, not 0`}, + {`{"module":"a","version":"1","logs":[{"name":"c","stream":"LOG_x"}]}`, `{name, max-mib}`}, + {`{"module":"a.b","version":"1","logs":["c"]}`, `no dot`}, + } { + _, err := ParseManifest([]byte(c.manifest)) + if err == nil { + t.Errorf("%s was accepted", c.manifest) + continue + } + if !strings.Contains(err.Error(), c.says) { + t.Errorf("%s refused for the wrong reason: %v", c.manifest, err) + } + } +} + +// **Across the whole catalogue**: every log is named locally and capped within the mesh's caps. +func TestEveryManifestsLogsAreLocalAndCapped(t *testing.T) { + var problems []string + for _, m := range theCatalogue(t) { + problems = append(problems, LogProblems(m)...) + } + if len(problems) > 0 { + t.Fatalf("the catalogue's logs are not what ADR 0297 says:\n %s", strings.Join(problems, "\n ")) + } +} diff --git a/go/catalogue/mounts_test.go b/go/catalogue/mounts_test.go new file mode 100644 index 0000000..d13c6dc --- /dev/null +++ b/go/catalogue/mounts_test.go @@ -0,0 +1,104 @@ +package catalogue + +import ( + "path/filepath" + "strings" + "testing" + + "git.novox.be/novox/mesh-sdk/go/internal/beside" +) + +// A bind mount the module never declared is refused where it is written (novox/hq 04-ISSUES/026, +// ADR 0091). The container runtime creates a missing bind source itself, as root, with a mode it +// picks, so the module's own owner and mode never reach the directory holding its data, and the +// rule that keeps data when a module goes away (ADR 0030) does not cover it. + +const aContainerMounting = `{"id":"server","type":"container","name":"store","image":"x@sha256:` + + `0000000000000000000000000000000000000000000000000000000000000000","volumes":["%s:/inside"]}` + +func manifestMounting(from string, beside string) []byte { + res := aContainerMounting + if beside != "" { + res = beside + "," + res + } + return []byte(`{"module":"store","resources":[` + strings.Replace(res, "%s", from, 1) + `]}`) +} + +func TestAMountNothingDeclaresIsRefused(t *testing.T) { + _, err := ParseManifest(manifestMounting("/services/store/data", "")) + if err == nil { + t.Fatal("a container mounting a path no resource declares was accepted; the runtime " + + "would create it as root and the module's owner and mode would never apply") + } + if !strings.Contains(err.Error(), "/services/store/data") { + t.Fatalf("refused without naming the path, which leaves the author guessing: %v", err) + } +} + +// Declaring it is enough — including declaring the directory above it. +func TestAMountUnderADeclaredDirectoryIsAccepted(t *testing.T) { + _, err := ParseManifest(manifestMounting("/services/store/data", + `{"id":"state","type":"directory","path":"/services/store","mode":"0700"}`)) + if err != nil { + t.Fatalf("a module that said where its data lives was refused anyway: %v", err) + } +} + +// The operator's path, granted for use (ADR 0051), is declared by `accesses`, not by a directory the +// module would then own. +func TestAMountOfAnAccessedPathIsAccepted(t *testing.T) { + _, err := ParseManifest([]byte(`{"module":"store","accesses":[{"path":"/services/media/movies","mode":"read"}],` + + `"resources":[` + strings.Replace(aContainerMounting, "%s", "/services/media/movies", 1) + `]}`)) + if err != nil { + t.Fatalf("a mount of a path the module declares it accesses was refused: %v", err) + } +} + +// The machine's facility — the container runtime's socket — is granted by the capability that names +// it, and declaring it as the module's own directory would be a lie the host would act on. This is +// the case the first version of this check refused, and was withdrawn for: the builder. +func TestTheRuntimeSocketIsGrantedByTheCapabilityAndNotOtherwise(t *testing.T) { + granted := `{"module":"builder","capabilities":["container-runtime"],"resources":[` + + strings.Replace(aContainerMounting, "%s", "/var/run/docker.sock", 1) + `]}` + if _, err := ParseManifest([]byte(granted)); err != nil { + t.Fatalf("a module with the container-runtime capability may mount its socket: %v", err) + } + if _, err := ParseManifest(manifestMounting("/var/run/docker.sock", "")); err == nil { + t.Fatal("a module mounted the container runtime's socket without declaring the capability, and was accepted") + } +} + +// **Every manifest in the catalogue (internal/beside) passes**, so the rule is not one the catalogue +// is already breaking. +func TestEveryCatalogueManifestDeclaresWhatItMounts(t *testing.T) { + files, _ := filepath.Glob(filepath.Join(beside.Catalogue(t), "*", "module.json")) + if len(files) == 0 { + t.Fatal("no manifests in the catalogue, so this proved nothing") + } + root := beside.Dir(t, "mesh-catalog") // once: it copies the captured tree + for _, file := range files { + if _, err := ReadTreeManifest(root, filepath.Dir(file)); err != nil { + t.Errorf("%s: %v", filepath.Base(filepath.Dir(file)), err) + } + } +} + +// Where a bound fact lands is the mesh's file too, and a container may mount it directly. +func TestAMountOfABoundFactIsAccepted(t *testing.T) { + _, err := ParseManifest([]byte(`{"module":"store","requires":["model-access"],` + + `"binds":{"model-access":"/var/lib/store/model.json"},"resources":[` + + strings.Replace(aContainerMounting, "%s", "/var/lib/store/model.json", 1) + `]}`)) + if err != nil { + t.Fatalf("a mount of the file the mesh writes a binding to was refused: %v", err) + } +} + +// The file a filter module's rule set is written to is declared by `filtering.into` (novox/hq ADR +// 0169): the module's runtime mounts it to reload the mesh's own table, and nothing else declares it. +func TestAMountOfTheFilterFileIsDeclaredByFilteringInto(t *testing.T) { + _, err := ParseManifest([]byte(`{"module":"nftables","filtering":{"into":"/etc/nftables.conf"},` + + `"resources":[` + strings.Replace(aContainerMounting, "%s", "/etc/nftables.conf", 1) + `]}`)) + if err != nil { + t.Fatalf("a filter module mounting its own filter file was refused: %v", err) + } +} diff --git a/go/catalogue/no_subjects_test.go b/go/catalogue/no_subjects_test.go new file mode 100644 index 0000000..a247296 --- /dev/null +++ b/go/catalogue/no_subjects_test.go @@ -0,0 +1,124 @@ +package catalogue + +import ( + "encoding/json" + "os" + "path/filepath" + "regexp" + "strings" + "testing" + + "git.novox.be/novox/mesh-sdk/go/internal/beside" +) + +// **A manifest holds no subject** (novox/hq design 29 §1). +// +// A module names its events, tools and seats locally, and the mesh derives where they land. The +// property that buys: reorganise the subject space and every manifest in the catalogue is still +// correct. It holds today by construction — nothing reads a subject from a manifest — and a rule +// held by construction is one a later field breaks quietly, with the symptom appearing as a +// permission that does not match a subject rather than as a manifest that was wrong. +func TestNoManifestContainsASubject(t *testing.T) { + root := beside.Catalogue(t) + entries, err := os.ReadDir(root) + if err != nil { + t.Fatal(err) + } + + // Anything in the mesh's own subject space, and anything shaped like a wire address. + subject := regexp.MustCompile(`^(mesh|\$JS)\.[a-zA-Z0-9_*>.-]+$`) + + var found []string + var walk func(module string, path string, v any) + walk = func(module, path string, v any) { + switch t := v.(type) { + case string: + if subject.MatchString(t) { + found = append(found, module+" "+path+" = "+t) + } + case map[string]any: + for k, inner := range t { + walk(module, path+"."+k, inner) + } + case []any: + for _, inner := range t { + walk(module, path+"[]", inner) + } + } + } + + checked := 0 + for _, e := range entries { + if !e.IsDir() { + continue + } + raw, err := os.ReadFile(filepath.Join(root, e.Name(), "module.json")) + if err != nil { + continue + } + var m any + if err := json.Unmarshal(raw, &m); err != nil { + t.Errorf("%s: %v", e.Name(), err) + continue + } + checked++ + walk(e.Name(), "", m) + } + if checked == 0 { + t.Fatal("no manifests read, so this proved nothing") + } + if len(found) > 0 { + t.Errorf("a manifest names a subject, so reorganising the subject space would mean "+ + "editing the catalogue:\n %s", strings.Join(found, "\n ")) + } + t.Logf("%d manifests hold no subject", checked) +} + +// **Every module's event names are what design 29 says, across the whole catalogue.** +// +// The rule above holds by construction and turned out to be weaker than it reads: a manifest holds +// no subject, and every manifest in the catalogue still held the old bus's routing key, which +// derives into a namespace nobody owns (novox/hq 04-ISSUES/127). Nothing failed — the services +// started and none of them reacted. This is the check that was missing. +func TestEveryManifestsEventNamesAreLocal(t *testing.T) { + manifests := theCatalogue(t) + + var problems []string + for _, m := range manifests { + problems = append(problems, EventProblems(m)...) + } + if len(problems) > 0 { + t.Fatalf("the catalogue holds %d event name(s) the mesh would derive wrongly:\n %s", + len(problems), strings.Join(problems, "\n ")) + } +} + +// theCatalogue is every manifest of the catalogue internal/beside finds, parsed the way registration +// parses one. +func theCatalogue(t *testing.T) []Manifest { + t.Helper() + root := beside.Catalogue(t) + entries, err := os.ReadDir(root) + if err != nil { + t.Fatal(err) + } + var out []Manifest + for _, e := range entries { + if !e.IsDir() { + continue + } + raw, err := os.ReadFile(filepath.Join(root, e.Name(), "module.json")) + if err != nil { + continue + } + var m Manifest + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatalf("%s: %v", e.Name(), err) + } + out = append(out, m) + } + if len(out) == 0 { + t.Fatalf("no manifests under %s, so this proved nothing", root) + } + return out +} diff --git a/go/catalogue/node_resolver_test.go b/go/catalogue/node_resolver_test.go new file mode 100644 index 0000000..b5cec6d --- /dev/null +++ b/go/catalogue/node_resolver_test.go @@ -0,0 +1,216 @@ +package catalogue + +import ( + "strings" + "testing" +) + +// Defends novox/hq ADR 0247: a machine's own resolver is a node seat, held only where something requires +// `split-dns`; where it is held it writes the resolver file and the uplink's holder steps back from it. + +// The seat: node-scoped, decided by ADR 0247, and its three verbs required of every holder — a holder +// exists only once the module serving them does, so nothing has to be optional while it catches up. +func TestTheMachinesOwnResolverIsANodeSeatWithItsVerbs(t *testing.T) { + s, ok := SeatNamed(ResolverSeat) + if !ok { + t.Fatalf("%s is not in the mesh's set", ResolverSeat) + } + if s.Scope != ScopeNode || s.Decision != "novox/hq ADR 0247" || s.Delivers != "" || s.Replicated { + t.Errorf("%s is %+v; a node seat under ADR 0247 that delivers nothing", ResolverSeat, s) + } + var got []string + for _, v := range s.Serves { + got = append(got, v.Name) + if v.Optional { + t.Errorf("%s.%s is optional; its first holder serves it", ResolverSeat, v.Name) + } + if v.Description == "" || v.Input["type"] != "object" || len(v.Replaces) == 0 { + t.Errorf("%s.%s has no description, no object schema or says it replaces nothing", ResolverSeat, v.Name) + } + } + if strings.Join(got, " ") != "routes route unroute" { + t.Errorf("%s serves %v, not routes, route and unroute", ResolverSeat, got) + } + // The verbs name a link, domains and servers, and never a VPN: the resolver knows nothing of one. + for _, v := range s.Serves { + if strings.Contains(strings.ToLower(v.Description), "forti") { + t.Errorf("%s.%s names a VPN client: %s", ResolverSeat, v.Name, v.Description) + } + } +} + +// localResolver is a stand-in holder: it claims the seat and renders the resolver file naming the +// machine's own address. What is under test is the controller's rule, not the catalogue's module. +func localResolver() Manifest { + return Manifest{Module: "local-resolver", Version: "1", + Claims: []Claim{{Name: ResolverSeat, Scope: ScopeNode}}, + Facts: map[string]RosterFile{"resolvers": {Path: ResolverFile, + Template: "# Managed by the mesh\n{{range .Machines}}{{if eq .Name $.Node}}nameserver {{.Address}}\n{{end}}{{end}}"}}} +} + +func splitDNSLaptop() Node { + return Node{Name: "laptop", At: "laptop.internal", Capabilities: map[string]bool{ + "package-manager": true, "service-manager": true, "uplink-systemd-networkd": true}} +} + +// Where a module holds node-resolver, the machine is composed one resolver file, the holder's, naming +// the machine's own address; the uplink's holder composes everything else it declares, and not that file. +func TestWhereTheResolverIsHeldItWritesTheFileAndTheUplinkStepsBack(t *testing.T) { + shelf := resolverShelf(t) + shelf["local-resolver"] = localResolver() + got, err := Resolve(shelf, []string{"dnsmasq", "systemd-networkd", "local-resolver"}, splitDNSLaptop(), World{}) + if err != nil { + t.Fatalf("the resolver's holder and the uplink's were refused together: %v", err) + } + out, err := got.Declaration(Rendering{Names: twoMachines, Machines: twoMachines, Suffix: "internal", + Holders: map[string]map[string]string{"mesh-dns-resolver": {"anchor.internal": "10.42.0.1"}}, + Needed: map[string]map[string]string{"dnsmasq": {"broker": "sealed"}}, + Settings: SettingsBy{"dnsmasq": {{From: "the mesh", Values: map[string]any{"listen-addresses": "127.0.0.1"}}}}, + }) + if err != nil { + t.Fatal(err) + } + var files []string + for _, r := range out { + if r["path"] == ResolverFile { + files = append(files, r["id"].(string)) + } + } + if strings.Join(files, " ") != "local-resolver.fact-resolvers" { + t.Fatalf("the resolver file is composed as %v; once, the resolver's", files) + } + content := byID(out)["local-resolver.fact-resolvers"]["content"].(string) + if !strings.Contains(content, "nameserver 10.42.0.2\n") || strings.Contains(content, "10.42.0.1") { + t.Errorf("the resolver file does not name this machine's own resolver alone:\n%s", content) + } + // The uplink's holder is still composed: only the one file moved. + uplinkComposed := false + for _, r := range out { + if id, _ := r["id"].(string); strings.HasPrefix(id, "systemd-networkd.") { + uplinkComposed = true + } + } + if !uplinkComposed { + t.Errorf("the uplink's holder composed nothing once the resolver was held") + } +} + +// Where nobody holds it, nothing changes: the uplink's holder writes the file, listing the mesh's +// resolvers (ADR 0223) — every machine but the one that requires split-dns. +func TestWithoutTheResolverTheUplinkWritesTheFileAsBefore(t *testing.T) { + got, err := Resolve(resolverShelf(t), []string{"dnsmasq", "systemd-networkd"}, splitDNSLaptop(), World{}) + if err != nil { + t.Fatal(err) + } + out, err := got.Declaration(Rendering{Names: twoMachines, Machines: twoMachines, Suffix: "internal", + Holders: map[string]map[string]string{"mesh-dns-resolver": {"anchor.internal": "10.42.0.1"}}, + Needed: map[string]map[string]string{"dnsmasq": {"broker": "sealed"}}, + Settings: SettingsBy{"dnsmasq": {{From: "the mesh", Values: map[string]any{"listen-addresses": "127.0.0.1"}}}}, + }) + if err != nil { + t.Fatal(err) + } + if r := byID(out)["systemd-networkd.fact-resolvers"]; r == nil || r["path"] != ResolverFile { + t.Fatalf("without the resolver, the uplink's holder no longer writes the resolver file: %v", r) + } +} + +// Only the uplink's holder steps back. A third module rendering or declaring the file beside the +// resolver's is two owners of one path, refused as before; and a module rendering it without holding +// the seat is not the resolver, so the uplink's holder does not step back for it. +func TestOnlyTheUplinkStepsBackForTheResolver(t *testing.T) { + uplink := Manifest{Module: "uplink", Version: "1", Claims: []Claim{{Name: "node-uplink"}}, + Facts: map[string]RosterFile{"resolvers": {Path: ResolverFile, Template: "nameserver 10.42.0.1\n"}}} + if p := checkResources([]Manifest{uplink, localResolver()}); len(p) != 0 { + t.Errorf("the uplink's holder and the resolver's were refused together: %v", p) + } + other := Manifest{Module: "other", Version: "1", + Facts: map[string]RosterFile{"mine": {Path: ResolverFile, Template: "nameserver 10.42.0.9\n"}}} + if p := checkResources([]Manifest{uplink, localResolver(), other}); len(p) == 0 { + t.Error("a third module wrote the resolver file beside the resolver's") + } + if p := checkResources([]Manifest{uplink, other}); len(p) == 0 { + t.Error("a module that does not hold node-resolver took the resolver file from the uplink's holder") + } + declared := Manifest{Module: "declared", Version: "1", Resources: []map[string]any{ + {"id": "mine", "type": "file", "path": ResolverFile, "content": "nameserver 10.42.0.9\n"}}} + if p := checkResources([]Manifest{uplink, localResolver(), declared}); len(p) == 0 { + t.Error("a module declaring the resolver file was let beside the resolver's") + } + // What steps back is the one file: the uplink's other facts stay. + uplink.Facts["hosts"] = RosterFile{Path: "/etc/elsewhere", Template: "x"} + if got := stepsBack(uplink, []Manifest{uplink, localResolver()}); len(got.Facts) != 1 || got.Facts["hosts"].Path == "" { + t.Errorf("the uplink's holder lost more than the resolver file: %v", got.Facts) + } + if got := stepsBack(uplink, []Manifest{uplink}); len(got.Facts) != 2 { + t.Errorf("the uplink's holder stepped back with no resolver held: %v", got.Facts) + } +} + +// The catalogue as it is: every holder of node-resolver renders the resolver file as the mesh's own +// (its header is how the uplink's verb and the node-engine read a file as the mesh's), and provides +// split-dns at the machine's reach — a requirement is answered only on the same machine and never pulls +// the resolver in. Skipped while the catalogue has no holder. +func TestTheCataloguesResolverHoldersWriteTheFileAndProvideSplitDNS(t *testing.T) { + held := 0 + for _, m := range theCatalogue(t) { + if !holdsSeat(m, ResolverSeat) { + continue + } + held++ + f, ok := m.Facts["resolvers"] + if !ok || f.Path != ResolverFile || !strings.HasPrefix(f.Template, "# Managed by the mesh") { + t.Errorf("%s holds %s and does not render the resolver file as the mesh's: %+v", m.Module, ResolverSeat, f) + } + provides := false + for _, o := range m.Provides { + if o.Name == "split-dns" && o.Reach == ReachMachine { + provides = true + } + } + if !provides { + t.Errorf("%s holds %s and does not provide split-dns at the machine's reach", m.Module, ResolverSeat) + } + } + if held == 0 { + t.Skip("no module of the catalogue holds node-resolver yet") + } +} + +// The catalogue's systemd-resolved, composed on a machine beside its uplink: the resolver file names the +// machine's own private address alone, its kept copy is the same file, and resolved is given every mesh +// resolver as its default route and the private address to listen on. Skipped without the catalogue. +func TestTheCataloguesResolverComposesOnAMachine(t *testing.T) { + shelf := resolverShelf(t) + shelf["systemd-resolved"] = catalogueManifest(t, "systemd-resolved") + got, err := Resolve(shelf, []string{"dnsmasq", "systemd-networkd", "systemd-resolved"}, splitDNSLaptop(), World{}) + if err != nil { + t.Fatal(err) + } + out, err := got.Declaration(Rendering{Names: twoMachines, Machines: twoMachines, Suffix: "internal", + Holders: map[string]map[string]string{"mesh-dns-resolver": {"anchor.internal": "10.42.0.1", "home.internal": "10.42.0.3"}}, + Needed: map[string]map[string]string{"dnsmasq": {"broker": "sealed"}}, + Settings: SettingsBy{"dnsmasq": {{From: "the mesh", Values: map[string]any{"listen-addresses": "127.0.0.1"}}}}, + }) + if err != nil { + t.Fatal(err) + } + ids := byID(out) + resolv, _ := ids["systemd-resolved.fact-resolvers"]["content"].(string) + kept, _ := ids["systemd-resolved.fact-kept"]["content"].(string) + if !strings.Contains(resolv, "\nnameserver 10.42.0.2\noptions timeout:1 attempts:2 edns0\n") || resolv != kept { + t.Errorf("the resolver file does not name this machine's own resolver alone, or its copy differs:\n%s", resolv) + } + if ids["systemd-networkd.fact-resolvers"] != nil { + t.Error("the uplink's holder still writes the resolver file beside the resolver's") + } + conf, _ := ids["systemd-resolved.fact-resolved"]["content"].(string) + for _, want := range []string{"\nDNS=10.42.0.1 10.42.0.3 \n", "\nDNSStubListenerExtra=10.42.0.2\n", "\nFallbackDNS=\n"} { + if !strings.Contains(conf, want) { + t.Errorf("resolved's drop-in lacks %q:\n%s", want, conf) + } + } + if s, _ := ids["systemd-resolved.fact-suffix"]["content"].(string); s != "internal\n" { + t.Errorf("the mesh's domain is rendered as %q", s) + } +} diff --git a/go/catalogue/provided_test.go b/go/catalogue/provided_test.go new file mode 100644 index 0000000..f09c0e9 --- /dev/null +++ b/go/catalogue/provided_test.go @@ -0,0 +1,144 @@ +package catalogue_test + +import ( + "encoding/json" + "strings" + "testing" + + "git.novox.be/novox/mesh-sdk/go/catalogue" + "git.novox.be/novox/mesh-sdk/go/overlay" +) + +// The manifests the control plane actually ships, resolved. +// +// Written because the earlier tests built their own manifests and passed while the real one was +// missing a claim — a whole mechanism could have been absent from what ships and every test would +// still have been green. + +func provided(t *testing.T) map[string]catalogue.Manifest { + t.Helper() + out := map[string]catalogue.Manifest{} + for _, raw := range []map[string]any{ + overlay.Manifest(), + } { + b, err := json.Marshal(raw) + if err != nil { + t.Fatal(err) + } + m, err := catalogue.ParseManifest(b) + if err != nil { + t.Fatalf("a manifest this control plane ships is not valid: %v", err) + } + out[m.Module] = m + } + return out +} + +func TestTheShippedPrivateNetworkResolvesOnItsOwn(t *testing.T) { + // **Assigned directly** (novox/hq ADR 0226): the `networking` bundle that required it is + // retired, so the private network's own module is what a machine is given, and it must need + // nothing the control plane does not ship beside it. + got, err := catalogue.Resolve(provided(t), []string{overlay.Name}, catalogue.Node{Name: "workstation", Site: "house"}, catalogue.World{}) + if err != nil { + t.Fatalf("assigning %s does not work out of the box: %v", overlay.Name, err) + } + if len(got.Modules) != 1 || got.Modules[0].Module != overlay.Name { + t.Fatalf("assigning %s brought %v", overlay.Name, got.Modules) + } + + // **The network writes no names** (novox/hq ADR 0199): /etc/hosts is the hosts seat holder's + // file, and the mesh's resolver answers the machines' names. No fact of the network's module + // may name that file. + for _, m := range got.Modules { + for name, f := range m.Facts { + if f.Path == "/etc/hosts" { + t.Fatalf("the network's provider still writes /etc/hosts, as its %q fact", name) + } + } + } +} + +func TestTheControlPlaneShipsNoNetworkingBundle(t *testing.T) { + // ADR 0226: one module for the one private network. A bundle shipped beside it again would be + // a second name every machine carries for the same thing. + shipped := provided(t) + if len(shipped) != 1 { + t.Fatalf("the control plane ships %d modules, want only %s", len(shipped), overlay.Name) + } + if _, ok := shipped["networking"]; ok { + t.Fatal("the retired networking bundle is shipped again") + } +} + +func TestARefusalForWantOfThePrivateNetworkNamesItsModule(t *testing.T) { + // The hint in a refusal is a string in the resolver rather than an import of this package. It + // named `networking` until ADR 0226; a hint naming a module nobody ships sends a person to + // assign something that does not exist. + shelf := map[string]catalogue.Manifest{ + "app": {Module: "app", Version: "1", Requires: []string{"postgres-database"}}, + "postgres": {Module: "postgres", Version: "1", Provides: catalogue.FromAnywhere("postgres-database")}, + } + _, err := catalogue.Resolve(shelf, []string{"app"}, catalogue.Node{Name: "workstation"}, + catalogue.World{Offered: map[string][]catalogue.Provider{ + "postgres-database": {{Node: "anchor", At: "anchor.internal"}}}}) + if err == nil { + t.Fatal("an app off the private network was pointed at a database on it") + } + if !strings.Contains(err.Error(), "assign "+overlay.Name) { + t.Fatalf("the refusal does not name the private network's module %s: %v", overlay.Name, err) + } +} + +func TestAnotherVPNIsChosenByAssigningItInstead(t *testing.T) { + // What the bundle was for, kept without it: a machine given another VPN answers + // private-network from that VPN, and WireGuard is not dragged in by anything. + shipped := provided(t) + shelf := withTailscale(shipped) + shelf["needs-network"] = catalogue.Manifest{Module: "needs-network", Version: "1", + Requires: []string{overlay.Requirement}} + got, err := catalogue.Resolve(shelf, []string{"needs-network", "tailscale"}, + catalogue.Node{Name: "workstation", Site: "house"}, catalogue.World{}) + if err != nil { + t.Fatalf("choosing another VPN was refused: %v", err) + } + for _, m := range got.Modules { + if m.Module == overlay.Name { + t.Fatalf("the other VPN was chosen and WireGuard came anyway: %v", got.Modules) + } + } +} + +func TestTwoVPNsAssignedTogetherStillCollide(t *testing.T) { + // The claim still guards the case it was always for: both assigned EXPLICITLY, which is a + // machine with two private networks and a coin toss about which one a peer reaches it on. + shipped := provided(t) + _, err := catalogue.Resolve( + withTailscale(shipped), + []string{overlay.Name, "tailscale"}, + catalogue.Node{Name: "workstation", Site: "house"}, catalogue.World{}) + if err == nil { + t.Fatal("a machine was given two private networks and nobody was told") + } + if !strings.Contains(err.Error(), overlay.TheNetwork) { + t.Fatalf("the refusal does not say what collided: %v", err) + } +} + +// The names-need-addressing test went with the names module: names are a fact now, and a machine +// the mesh cannot place is simply left out of the file (facts_test.go) — which is the same +// protection, enforced where the file is written rather than by a provision refusing. + +func withTailscale(shelf map[string]catalogue.Manifest) map[string]catalogue.Manifest { + out := map[string]catalogue.Manifest{} + for k, v := range shelf { + out[k] = v + } + // Deliberately without name-resolution of its own, which is the case that used to install + // both VPNs: the names then needed the mesh's addressing, and only WireGuard has it. + out["tailscale"] = catalogue.Manifest{ + Module: "tailscale", Version: "1", + Provides: catalogue.Offers(overlay.Requirement), + Claims: []catalogue.Claim{{Name: overlay.TheNetwork, Scope: catalogue.ScopeNode}}, + } + return out +} diff --git a/go/catalogue/public_issuer_test.go b/go/catalogue/public_issuer_test.go new file mode 100644 index 0000000..ec8e992 --- /dev/null +++ b/go/catalogue/public_issuer_test.go @@ -0,0 +1,41 @@ +package catalogue + +import ( + "os" + "path/filepath" + "testing" + + "git.novox.be/novox/mesh-sdk/go/internal/beside" +) + +// The modules ADR 0226 retired stay retired, and nothing asks for what they provided. A module is +// in the catalogue when its manifest is: a directory left behind with no manifest in it (a build's +// leftovers, untracked by git) is not a module, and failed this on a desktop (novox/hq issue 432). +func TestTheRetiredNetworkingModulesAreNotInTheCatalogue(t *testing.T) { + modules := beside.Catalogue(t) + for _, gone := range []string{"public-acme", "dhcpcd", "cloudflare-dns"} { + if _, err := os.Stat(filepath.Join(modules, gone, "module.json")); err == nil { + t.Errorf("%s is in the catalogue again; ADR 0226 retired it", gone) + } + } + entries, err := os.ReadDir(modules) + if err != nil { + t.Fatal(err) + } + for _, e := range entries { + raw, err := os.ReadFile(filepath.Join(modules, e.Name(), "module.json")) + if err != nil { + continue + } + m, err := ParseManifest(raw) + if err != nil { + continue // judged by the catalogue's own tests + } + for _, r := range m.Requires { + if r == "acme-ca" || r == "public-dns" { + t.Errorf("%s requires %q, which nothing in the catalogue provides since ADR 0226", + m.Module, r) + } + } + } +} diff --git a/go/catalogue/published_ports_test.go b/go/catalogue/published_ports_test.go new file mode 100644 index 0000000..48036d2 --- /dev/null +++ b/go/catalogue/published_ports_test.go @@ -0,0 +1,75 @@ +package catalogue + +import ( + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + "testing" +) + +// A container publishes only a port its module declares (novox/hq issue 227). +// +// **The short form is a question the mesh answers.** `"80"` means *publish what the software +// calls 80*, and the mesh fills in the machine's half from the port it assigned +// ([ADR 0038](0038)). It can only assign one for a port the module declared in `listens` — so a +// container publishing a number that appears nowhere in `listens` gets no assignment, and +// `publishedOn` falls back to the number as written. It escapes to the machine. +// +// That is how the photo module asked for port 80 on the control node, where the reverse proxy +// holds it: it declared its web endpoint at 4001, published a bare 80, and the container never +// started. Four other modules publish 80 quite safely — because they declare 80, so the mesh +// gives them a machine port for it. The difference is the declaration, not the number. +// +// A mapping written the long way is a module pinning both halves on purpose and is left alone. +func TestEveryPublishedPortIsOneItsModuleDeclares(t *testing.T) { + root := catalogueRoot(t) + entries, err := os.ReadDir(filepath.Join(root, "modules")) + if err != nil { + t.Fatal(err) + } + var escaped []string + for _, entry := range entries { + if !entry.IsDir() { + continue + } + raw, err := os.ReadFile(filepath.Join(root, "modules", entry.Name(), "module.json")) + if err != nil { + continue + } + m, err := ParseManifest(raw) + if err != nil { + // Whether every manifest parses is TestEveryCatalogueManifestParses's question. + continue + } + declared := map[int]bool{} + for _, l := range m.Listens { + declared[l.Port] = true + } + for _, r := range m.Resources { + if fmt.Sprint(r["type"]) != "container" { + continue + } + listed, _ := r["ports"].([]any) + for _, p := range listed { + written := strings.Split(fmt.Sprint(p), "/")[0] + if strings.Contains(written, ":") { + continue // pinned by hand, both halves, on purpose + } + port, err := strconv.Atoi(strings.TrimSpace(written)) + if err != nil || declared[port] { + continue + } + escaped = append(escaped, fmt.Sprintf( + "%s's %v publishes %d, and %s declares no such port — the mesh has nothing "+ + "to assign, so %d reaches the machine as written", + m.Module, r["id"], port, m.Module, port)) + } + } + } + if len(escaped) > 0 { + t.Fatalf("a container may publish only a port its module declares:\n - %s", + strings.Join(escaped, "\n - ")) + } +} diff --git a/go/catalogue/reads_test.go b/go/catalogue/reads_test.go new file mode 100644 index 0000000..69f314f --- /dev/null +++ b/go/catalogue/reads_test.go @@ -0,0 +1,137 @@ +package catalogue + +import ( + "encoding/json" + "strings" + "testing" +) + +// novox/hq ADR 0310 §1, §2: node-reads is a node seat, and its verbs say the commands they replace, so +// mesh_search finds them by `cat`, `grep` and `getent passwd` (ADR 0245 §3). +func TestNodeReadsIsANodeSeatWhoseVerbsSayWhatTheyReplace(t *testing.T) { + s, ok := SeatNamed(ReadsSeat) + if !ok { + t.Fatalf("the mesh defines no seat %s", ReadsSeat) + } + if s.Scope != ScopeNode || s.Delivers != "" || !strings.Contains(s.Decision, "0310") { + t.Errorf("%s: scope %q, delivers %q, decision %q", ReadsSeat, s.Scope, s.Delivers, s.Decision) + } + replaced := map[string]string{} + for _, v := range s.Serves { + if v.Optional { + t.Errorf("%s.%s is optional: a new seat's holder serves every verb", ReadsSeat, v.Name) + } + if len(v.Replaces) == 0 { + t.Errorf("%s.%s says nothing it replaces", ReadsSeat, v.Name) + } + for _, c := range v.Replaces { + replaced[c] = v.Name + } + } + for command, verb := range map[string]string{"cat": "read", "grep": "search", "getent passwd": "account", + "getent group": "account", "id": "account", "stat": "stat", "readlink": "readlink", "ls": "list"} { + if replaced[command] != verb { + t.Errorf("%q is replaced by %q, want %s.%s", command, replaced[command], ReadsSeat, verb) + } + } +} + +// novox/hq ADR 0310 §3: a holder of node-reads reads as an account of its own; one the machine's runtime would +// carry — as the operator's account — is refused at parse. +func TestAHolderOfNodeReadsWithoutAnAccountOfItsOwnIsRefused(t *testing.T) { + _, err := ParseManifest([]byte(`{"module": "reads", "version": "1", + "claims": [{"name": "node-reads", "scope": "node"}]}`)) + if err == nil || !strings.Contains(err.Error(), "runs-as") || !strings.Contains(err.Error(), "0310") { + t.Errorf("a holder of node-reads without runs-as was not refused for it: %v", err) + } + if p := ReadsHolderProblems(Manifest{Module: "reads", RunsAs: "mesh-reads", + Claims: []Claim{{Name: ReadsSeat}}}); len(p) != 0 { + t.Errorf("a holder running as its own account was refused: %v", p) + } +} + +// novox/hq ADR 0310 §5: the holder is told, by the composition, where its node keeps a secret — the directory +// of every module's own secret, every credential file and grant directory, the vault's directories and the +// place the operator-sealed secrets are kept — and no other module is told. +func TestTheHolderOfNodeReadsIsToldWhereItsNodeKeepsASecret(t *testing.T) { + with := Rendering{ArtifactStore: "anchor.internal:5101", + Needed: map[string]map[string]string{RuntimeModule: {"broker": "sealed-credential"}, + "reads": {"broker": "own"}, "desk": {"broker": "own"}}} + goRuntime := Manifest{Module: RuntimeModule, Version: "1", + OwnSecrets: OwnSecrets{"broker": {Path: "/var/lib/mesh/" + RuntimeModule + "/broker"}}, + Build: &Build{Artifacts: []Artifact{{Name: "runtime", Kind: ArtifactBundle, Language: "go", + System: "arch", From: "cmd/node-tools"}}}} + goRuntime, err := goRuntime.Resolve([]Built{{Name: "runtime", Kind: ArtifactBundle, + Reference: ArtifactStoreScheme + RuntimeModule + "/runtime/blobs/" + bundleDigest, Digest: bundleDigest}}) + if err != nil { + t.Fatal(err) + } + reads := aToolsModule(t, "reads", "tools/index.js") + reads.RunsAs, reads.SecretsOwner = "mesh-reads", "mesh-reads" + reads.Claims = []Claim{{Name: ReadsSeat}} + reads.OwnSecrets = OwnSecrets{"broker": {Path: "/var/lib/mesh-reads/broker"}} + desk := aToolsModule(t, "desk", "tools/index.js") + desk.RunsAs, desk.SecretsOwner = "desk", "desk" + desk.OwnSecrets = OwnSecrets{"broker": {Path: "/var/lib/desk/broker"}} + // The vault as the catalogue declares it: its directories have no path of their own, and the node places them. + vault, err := placedManifest(catalogueResolved(t, "mesh-vault", Built{Name: "code", Kind: ArtifactBundle, + Reference: ArtifactStoreScheme + "mesh-vault/code/blobs/" + bundleDigest, Digest: bundleDigest}), with) + if err != nil { + t.Fatal(err) + } + app := Manifest{Module: "app", Version: "1", Secrets: map[string]string{"postgres-database": "/etc/app/db.secret"}, + OwnSecrets: OwnSecrets{"token": {Path: "/home/ops/.config/app/token"}}} + r := Resolution{Node: "anchor", Account: "ops", Modules: []Manifest{reads, desk, vault, app, goRuntime}} + owns, err := r.ownRuntimes(with) + if err != nil { + t.Fatal(err) + } + var given map[string]map[string]string + for _, p := range owns { + if p["id"] == "reads."+OwnRuntimeID() { + if err := json.Unmarshal([]byte(p["env"].(map[string]string)[RuntimeToolEnv]), &given); err != nil { + t.Fatal(err) + } + } + if p["id"] == "desk."+OwnRuntimeID() && strings.Contains(p["env"].(map[string]string)[RuntimeToolEnv], ReadsSecretPlaces) { + t.Error("a module not holding node-reads is told the node's secret places") + } + } + var places []string + if err := json.Unmarshal([]byte(given["reads"][ReadsSecretPlaces]), &places); err != nil { + t.Fatalf("the holder is not told its node's secret places: %v (%v)", err, given) + } + want := []string{"/etc/app/db.secret", "/home/ops/.config/app", "/var/lib/desk", "/var/lib/mesh-reads", + "/var/lib/mesh-vault", "/var/lib/mesh/" + RuntimeModule} + if strings.Join(places, " ") != strings.Join(want, " ") { + t.Errorf("secret places:\n got %v\nwant %v", places, want) + } +} + +// The review of #257: a secret place the controller cannot resolve fails the composition, naming the module, +// rather than leaving the list one place shorter. +func TestASecretPlaceThatCannotBeResolvedFailsTheComposition(t *testing.T) { + r := Resolution{Node: "anchor", Modules: []Manifest{ + {Module: "app", Version: "1", Keeps: "${dir:keep}/sealed"}, + }} + if _, err := r.secretPlaces(Rendering{}); err == nil || !strings.Contains(err.Error(), "app") { + t.Errorf("an unresolved place was dropped: %v", err) + } + r.Modules[0].Keeps = "relative/place" + if _, err := r.secretPlaces(Rendering{}); err == nil { + t.Error("a relative place was dropped") + } +} + +// ADR 0310 §3: the account that reads is in no group beside its own; one put in a group is refused. +func TestTheReadsAccountIsInNoGroup(t *testing.T) { + m := Manifest{Module: "reads", RunsAs: "mesh-reads", Claims: []Claim{{Name: ReadsSeat}}, + Resources: []map[string]any{{"id": "account", "type": "user", "name": "mesh-reads", "groups": []any{"docker"}}}} + if p := ReadsHolderProblems(m); len(p) != 1 || !strings.Contains(p[0], "docker") { + t.Errorf("an account in docker was not refused: %v", p) + } + m.Resources[0]["groups"] = []any{} + if p := ReadsHolderProblems(m); len(p) != 0 { + t.Errorf("an account in no group was refused: %v", p) + } +} diff --git a/go/catalogue/resolver_file_is_the_uplinks_test.go b/go/catalogue/resolver_file_is_the_uplinks_test.go new file mode 100644 index 0000000..dc2de44 --- /dev/null +++ b/go/catalogue/resolver_file_is_the_uplinks_test.go @@ -0,0 +1,59 @@ +package catalogue + +import ( + "reflect" + "testing" +) + +// novox/hq ADR 0223 part 2: /etc/resolv.conf belongs to the module holding node-uplink. The seat that +// wrote it, node-resolver-config, and ADR 0220's dependency of it on the uplink retire: one owner for +// the file, and it is the program that would otherwise rewrite it. + +func TestTheResolverConfigSeatIsGone(t *testing.T) { + if _, known := SeatNamed("node-resolver-config"); known { + t.Error("node-resolver-config is still in the mesh's set; the uplink's holder writes the resolver file") + } + // An uplink's holder needs no seat beside it for the file: it is its own. + if got := DependsOn(nil, mod("networkmanager", nil, nil, nil, Claim{Name: "node-uplink"})); len(got) != 0 { + t.Errorf("an uplink holder with nothing declared depends on %v", got) + } +} + +// The catalogue as it is: nothing claims the retired seat, and every manager the mesh knows holds the +// uplink and writes the resolver file; beside them only a holder of node-resolver does (ADR 0247). +func TestTheCataloguesUplinksWriteTheResolverFileAndNothingElseDoes(t *testing.T) { + cat := map[string]Manifest{} + for _, m := range theCatalogue(t) { + cat[m.Module] = m + } + if got := PossibleHolders(cat, "node-uplink"); !reflect.DeepEqual(got, uplinks) { + t.Errorf("node-uplink can be held by %v, not %v", got, uplinks) + } + for name, m := range cat { + for _, c := range m.Claims { + if c.Name == "node-resolver-config" { + t.Errorf("%s still claims node-resolver-config", name) + } + } + _, writes := m.Facts["resolvers"] + isUplink := false + for _, u := range uplinks { + isUplink = isUplink || u == name + } + for _, f := range m.Facts { + // Or the machine's own resolver's, where it is held (novox/hq ADR 0247): the uplink's holder + // steps back from the file there, and nowhere else. + if f.Path == "/etc/resolv.conf" && !isUplink && !holdsSeat(m, ResolverSeat) { + t.Errorf("%s writes /etc/resolv.conf and holds neither the uplink nor the machine's resolver", name) + } + } + for _, r := range m.Resources { + if r["path"] == "/etc/resolv.conf" { + t.Errorf("%s declares /etc/resolv.conf as a file of its own", name) + } + } + if isUplink && !writes { + t.Errorf("%s holds the uplink and does not write the resolver file", name) + } + } +} diff --git a/go/catalogue/resolver_manifests_test.go b/go/catalogue/resolver_manifests_test.go new file mode 100644 index 0000000..c3dfadd --- /dev/null +++ b/go/catalogue/resolver_manifests_test.go @@ -0,0 +1,327 @@ +package catalogue + +import ( + "encoding/json" + "strings" + "testing" +) + +// The catalogue's resolver modules as they are, parsed by the real parser and composed as a +// machine would receive them (hal dnsmasq-app conversion, novox/hq 08-connectivity). +// +// The predecessor's resolver answered every name on a machine: the mesh's own itself, the rest +// forwarded to two fixed upstreams, with the machine's resolv.conf naming it alone and the +// container runtime pointed at its private-network address. These hold the mesh's modules to the +// same arrangement, and to the two things a resolver here must never do — read resolv.conf for +// its upstreams, or take an address systemd-resolved holds. + +// resolverShelf is the resolver, an uplink module that writes what the machine asks (novox/hq ADR +// 0223), and the container runtime beside something that answers `mesh-addressing`. +// The networking module that really does is composed in the controller and cannot be imported +// here, so a stand-in offers the same word; what is under test is the manifests, not the network. +func resolverShelf(t *testing.T) map[string]Manifest { + t.Helper() + shelf := map[string]Manifest{ + "net": {Module: "net", Version: "1", Provides: []Offer{{Name: "mesh-addressing"}}}, + } + for _, name := range []string{"dnsmasq", "systemd-networkd", "docker"} { + shelf[name] = catalogueManifest(t, name) + } + return shelf +} + +// twoMachines is what the control plane hands a rendering: internal names and their addresses. +var twoMachines = map[string]string{"anchor.internal": "10.42.0.1", "laptop.internal": "10.42.0.2"} + +// Its configuration forwards to the upstreams the predecessor's module shipped, and gets them from +// nowhere else: `no-resolv` is what makes the documented loop — the resolver finding its own +// address in resolv.conf and becoming its own upstream — impossible. +func TestTheResolverForwardsToFixedUpstreamsAndNeverReadsResolvConf(t *testing.T) { + m := catalogueManifest(t, "dnsmasq") + var config string + for _, r := range m.Resources { + if r["id"] == "config" { + config, _ = r["content"].(string) + } + } + if config == "" { + t.Fatal("the resolver has no configuration file") + } + for _, want := range []string{ + "\nno-resolv\n", "\nserver=1.1.1.1\n", "\nserver=8.8.8.8\n", + // The private address and loopback, never a LAN's (novox/hq ADR 0194): a device that is not a + // member cannot reach what the mesh's names point at. + "\nlisten-address=127.0.0.1\n", "\nlisten-address=${machine:address}\n", + + // No hosts file and no operator's files: the mesh's resolver answers every node (ADR 0199). + "\nno-hosts\n", + "\nconf-file=" + m.Facts["zones"].Path + "\n", + "\ndomain-needed\n", "\nbogus-priv\n", + "\nconf-file=" + m.Facts["node-zones"].Path + "\n", + } { + if !strings.Contains(config, want) { + t.Errorf("the resolver's configuration lacks %q:\n%s", strings.TrimSpace(want), config) + } + } + // Bound to each address once, at start, and never by bind-dynamic (novox/hq issue 348): bind-dynamic + // re-reads the machine's addresses on every change of any link and closes each listener that reading + // did not find, and a reading made while the container runtime tears down a bridge's virtual links + // fails, so the private address was left unbound for twenty minutes, the daemon running and silent. + // The catalogue's main binds once since mesh-catalog PR 161 (105cc7c0). + if !strings.Contains(config, "\nbind-interfaces\n") { + t.Errorf("the resolver's configuration does not bind its addresses once (bind-interfaces):\n%s", config) + } + if strings.Contains(config, "\nbind-dynamic\n") { + t.Errorf("the resolver's configuration says bind-dynamic, which closes its listeners when a bridge is "+ + "torn down (novox/hq issue 348):\n%s", config) + } + // By address and never by interface: dnsmasq admits a query by the interface it arrives on + // when told one, and a container's query to the private address arrives on the runtime's + // bridge — `interface=mesh0` dropped every such query, silently (novox/hq issue 110). + for _, line := range strings.Split(config, "\n") { + if strings.HasPrefix(line, "interface=") { + t.Errorf("the resolver answers by interface, so a container's query on a bridge is dropped: %s", line) + } + } + // Not .53 or .54, which systemd-resolved holds; and not .55 any more, which was a convention + // beside the one every machine already followed — the predecessor's resolv.conf says .1. + for _, taken := range []string{"127.0.0.53", "127.0.0.54", "127.0.0.55"} { + if strings.Contains(config, "listen-address="+taken) { + t.Errorf("the resolver listens on %s", taken) + } + } + // Never a directory or a file the operator keeps: a line written for one machine's programs would + // become an answer for every node (ADR 0199). + for _, never := range []string{"conf-dir=", "addn-hosts=", "listen-address=${setting:"} { + if strings.Contains(config, never) { + t.Errorf("the mesh's resolver still reads or listens on %q", never) + } + } + // And the file that decides what the machine asks names every one of the mesh's resolvers, by + // address, from the seat's holders, and no public one (ADR 0223): a resolver library that asks + // every listed server at once takes the first reply, and a public "no such name" for a mesh name + // won it. Written by every uplink module, since the uplink's holder owns the file. + for _, uplink := range uplinks { + fact, ok := catalogueManifest(t, uplink).Facts["resolvers"] + if !ok || fact.Path != "/etc/resolv.conf" { + t.Fatalf("%s's resolver file is not rendered from the roster: %+v", uplink, fact) + } + if !strings.Contains(fact.Template, `{{range index .Holders "mesh-dns-resolver"}}nameserver {{.Address}}`) { + t.Errorf("%s's resolv.conf does not list every holder of the mesh's resolver:\n%s", uplink, fact.Template) + } + for _, line := range strings.Split(fact.Template, "\n") { + if strings.HasPrefix(line, "nameserver ") && !strings.Contains(line, "{{") { + t.Errorf("%s's resolv.conf names a resolver of its own beside the mesh's: %s", uplink, line) + } + } + if !strings.Contains(fact.Template, "options timeout:1 attempts:2 edns0\n") { + t.Errorf("%s: a silent resolver is not passed over after one short wait:\n%s", uplink, fact.Template) + } + } +} + +// The resolver and what points the machine at it compose on one machine, and what arrives is the +// mesh's account of every machine as a wildcard, the suffix kept local, the daemon restarting on +// that file, the machine pointed at the resolver by address, and the runtime given no resolver of +// its own but kept running across a restart (ADR 0196). +func TestTheResolverAndWhatAsksItComposeOnOneMachine(t *testing.T) { + got, err := Resolve(resolverShelf(t), []string{"dnsmasq", "systemd-networkd", "docker"}, + Node{Name: "anchor", At: "anchor.internal", Capabilities: map[string]bool{ + "package-manager": true, "service-manager": true, "privileged": true, + "uplink-systemd-networkd": true}}, World{}) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(strings.Join(named(got), " "), "net") { + t.Fatalf("the resolver's data is the mesh's addresses, and nothing answering them was taken: %v", named(got)) + } + out, err := got.Declaration(Rendering{ + // Names is every name the mesh serves; Machines is the subset that is a node (novox/hq + // issue 111) — the resolver's zones read only the second, and in this scenario the two + // happen to be the same map, since nothing routed is part of it. + Names: twoMachines, Machines: twoMachines, Suffix: "internal", + Zones: []ZoneAt{{Zone: "incus", Address: "10.42.0.2", Port: 5353}}, + Holders: map[string]map[string]string{"mesh-dns-resolver": {"anchor.internal": "10.42.0.1"}}, + Needed: map[string]map[string]string{"dnsmasq": {"broker": "sealed"}}, + Settings: SettingsBy{"dnsmasq": {{From: "the mesh", Values: map[string]any{"listen-addresses": "127.0.0.1"}}}}, + }) + if err != nil { + t.Fatal(err) + } + + ids := byID(out) + zones := ids["dnsmasq.fact-node-zones"] + if zones == nil || zones["path"] != "/etc/mesh-resolver/nodes.conf" { + t.Fatalf("the resolver was not given the machines where its configuration reads them: %v", zones) + } + content, _ := zones["content"].(string) + for _, want := range []string{ + "local=/internal/", "address=/anchor.internal/10.42.0.1", "address=/laptop.internal/10.42.0.2", + } { + if !strings.Contains(content, want) { + t.Errorf("the machines file lacks %q:\n%s", want, content) + } + } + + service := ids["dnsmasq.service"] + if service == nil { + t.Fatal("no resolver service composed") + } + reflects := map[string]bool{} + for _, id := range service["restart-on"].([]any) { + reflects[id.(string)] = true + } + if !reflects["dnsmasq.config"] || !reflects["dnsmasq.fact-node-zones"] || !reflects["dnsmasq.fact-zones"] { + t.Errorf("the daemon does not restart on its configuration, the machines and the zones: %v", service["restart-on"]) + } + if z, _ := ids["dnsmasq.fact-zones"]["content"].(string); !strings.Contains(z, "server=/incus/10.42.0.2#5353\n") { + t.Errorf("the resolver was not told to forward the zone to its answerer:\n%s", z) + } + + // The runtime's own file, written into (novox/hq ADR 0102) with one key, by the runtime's own + // module — a module does not write another software's configuration (issue 190): a restart keeps + // every container running. No `dns` — a container copies its machine's resolvers (ADR 0196), and + // neither the resolver nor what decides how the machine resolves writes the runtime's file. + if ids["dnsmasq.runtime-dns"] != nil { + t.Errorf("the resolver still writes the runtime's dns: %v", ids["dnsmasq.runtime-dns"]) + } + for id := range ids { + if strings.HasPrefix(id, "systemd-networkd.runtime") { + t.Errorf("what the machine asks still writes the runtime's file: %s", id) + } + } + runtime := ids["docker.daemon"] + if runtime == nil || runtime["path"] != "/etc/docker/daemon.json" || runtime["into"] != "json" { + t.Fatalf("live-restore is not written into the runtime's file: %v", runtime) + } + var keys map[string]any + if err := json.Unmarshal([]byte(runtime["content"].(string)), &keys); err != nil { + t.Fatalf("the runtime's keys are not JSON: %v", err) + } + // What the runtime is given is what its own module declares, and nothing a layer or another module + // adds. This used to say "live-restore and nothing else", to hold the line ADR 0196 drew when `dns` + // left the file; since the docker module gained log rotation as a preference (mesh-catalog #205 and + // #217, novox/hq issue 452) that frozen copy failed every check (novox/hq issue 498). So the keys are + // judged against the module's own daemon.json instead: live-restore is given, `dns` never is, and no + // key the module does not declare appears. + if keys["live-restore"] != true { + t.Errorf("the runtime is given %v; a restart must keep every container running (live-restore)", keys) + } + if _, ok := keys["dns"]; ok { + t.Errorf("the runtime is given a resolver of its own, %v; a container copies its machine's (ADR 0196)", keys["dns"]) + } + declared := daemonKeysDeclared(t) + for key := range keys { + if !declared[key] { + t.Errorf("the runtime is given %q, which the docker module does not declare: %v", key, keys) + } + } + // The log rotation preference, at its defaults, wherever the module declares it. The copy captured in + // testdata/beside predates it, so outside a merge check it is not there to judge. + if declared["log-driver"] { + opts, _ := keys["log-opts"].(map[string]any) + if keys["log-driver"] != "json-file" || opts["max-size"] != "100m" || opts["max-file"] != "10" { + t.Errorf("the runtime is given %v; its containers' logs are cut at 100m and ten are kept (novox/hq issue 452)", keys) + } + } + // The runtime is reloaded when that file changes, and never restarted: a restart stops every + // container on the machine (ADR 0102), and a reload is what turns live-restore on. + var reloaded bool + for _, r := range out { + if r["type"] != "service" || r["unit"] != "docker.service" { + continue + } + if _, restarts := r["restart-on"]; restarts { + t.Errorf("the runtime is ordered restarted, which stops every container (ADR 0102): %v", r) + } + for _, on := range asStrings(r["reload-on"]) { + if on == "docker.daemon" { + reloaded = true + } + } + } + if !reloaded { + t.Errorf("the runtime is not reloaded when its file changes, so live-restore never takes effect") + } + + resolv := ids["systemd-networkd.fact-resolvers"] + if resolv == nil || !strings.Contains(resolv["content"].(string), "\nnameserver 10.42.0.1\noptions ") { + t.Fatalf("the machine is not pointed at the resolver by address, and only at it: %v", resolv) + } +} + +// Two modules deciding what a machine asks are refused on one machine, as before — now that the file +// is the uplink's (novox/hq ADR 0223), by two things: a machine runs one network manager, and no other +// module may write the resolver file beside the uplink's, as a file or as a rendered fact. +func TestTwoThingsDecidingWhatAMachineAsksAreRefused(t *testing.T) { + shelf := resolverShelf(t) + shelf["networkmanager"] = catalogueManifest(t, "networkmanager") + node := Node{Name: "anchor", At: "anchor.internal", Capabilities: map[string]bool{ + "package-manager": true, "service-manager": true, + "uplink-systemd-networkd": true, "uplink-networkmanager": true}} + _, err := Resolve(shelf, []string{"dnsmasq", "systemd-networkd", "networkmanager"}, node, World{}) + if err == nil || !strings.Contains(err.Error(), "node-uplink") { + t.Fatalf("two network managers were both assigned to one machine: %v", err) + } + + // The second is made up: what is under test is that the resolver file has one owner, so any + // module asking the mesh to render it — or declaring it — will do. + for name, m := range map[string]Manifest{ + "a-rendered-one": {Module: "a-rendered-one", Version: "1", + Facts: map[string]RosterFile{"mine": {Path: "/etc/resolv.conf", Template: "nameserver 10.42.0.1\n"}}}, + "a-declared-one": {Module: "a-declared-one", Version: "1", Resources: []map[string]any{ + {"id": "mine", "type": "file", "path": "/etc/resolv.conf", "content": "nameserver 10.42.0.1\n"}}}, + } { + shelf := resolverShelf(t) + shelf[name] = m + _, err := Resolve(shelf, []string{"dnsmasq", "systemd-networkd", name}, node, World{}) + if err == nil || !strings.Contains(err.Error(), "/etc/resolv.conf") { + t.Errorf("%s wrote the resolver file beside the uplink's: %v", name, err) + } + } +} + +// A machine that is not on the private network has no address for the runtime to be pointed at. +// Refused where the module and the machine are both named, rather than a placeholder written into +// the runtime's file and read as an address. +func TestTheResolverOnAMachineOffTheNetworkIsRefused(t *testing.T) { + got, err := Resolve(resolverShelf(t), []string{"dnsmasq"}, Node{Name: "anchor"}, World{}) + if err != nil { + t.Fatal(err) + } + // Left out of the declaration and said, rather than composed listening nowhere: a module that + // cannot compose on a machine is kept as it is there, with the reason (hq ADR 0163). + composed, err := got.Compose(Rendering{Names: twoMachines, Suffix: "internal", + Needed: map[string]map[string]string{"dnsmasq": {"broker": "sealed"}}, + Settings: SettingsBy{"dnsmasq": {{From: "the mesh", Values: map[string]any{"listen-addresses": "127.0.0.1"}}}}}) + if err == nil && !strings.Contains(composed.LeftOut["dnsmasq"], "${machine:address}") { + t.Fatalf("a machine off the network was composed a resolver, or left out for another reason: %v", + composed.LeftOut) + } + if err != nil && !strings.Contains(err.Error(), "${machine:address}") { + t.Fatalf("a machine off the network was refused for another reason: %v", err) + } +} + +// daemonKeysDeclared is the top-level keys of the docker module's own daemon.json, read from its +// manifest: what the runtime may be given (novox/hq issue 498). A placeholder sits inside a JSON +// string there, so the content parses as it is. +func daemonKeysDeclared(t *testing.T) map[string]bool { + t.Helper() + for _, r := range catalogueManifest(t, "docker").Resources { + if r["id"] != "daemon" { + continue + } + var keys map[string]any + if err := json.Unmarshal([]byte(r["content"].(string)), &keys); err != nil { + t.Fatalf("the docker module's daemon.json is not JSON: %v", err) + } + declared := map[string]bool{} + for key := range keys { + declared[key] = true + } + return declared + } + t.Fatal("the docker module declares no daemon.json") + return nil +} diff --git a/go/catalogue/seat_data_test.go b/go/catalogue/seat_data_test.go new file mode 100644 index 0000000..3d2313e --- /dev/null +++ b/go/catalogue/seat_data_test.go @@ -0,0 +1,426 @@ +package catalogue + +import ( + "encoding/json" + "fmt" + "strconv" + "strings" + "testing" +) + +// Defends novox/hq ADR 0255: a seat may receive a kind as data in its own shape, which the holder +// renders with its template; the holder places every combination of the shape's placing fields once; +// a contribution may be placed only where the machine reports a capability; and an offered kind makes +// no dependency on its seat. + +const barTemplate = `{{if eq .shows "battery"}}[[block]] +block = "battery" +{{with .options}}{{with .device}}device = {{quote .}} +{{end}}{{end}}{{else if eq .shows "command"}}[[block]] +block = "custom" +command = {{quote .options.command}} +interval = {{.options.interval}} +{{else if eq .shows "state"}}[[block]] +block = "custom" +command = {{quote (printf "show %s %s" .options.state (or .options.key .machine))}} +{{end}}` + +func barHolder() Manifest { + place := func(bar string) string { + return "own-first\n${contribution:node-bar:block:bar=" + bar + ",place=resources}" + + "own-middle\n${contribution:node-bar:block:bar=" + bar + ",place=status}own-last\n" + } + return Manifest{Module: "a-bar", Claims: []Claim{{Name: BarSeat, Renders: map[string]string{BarKindBlock: barTemplate}}}, + Resources: []map[string]any{ + {"id": "bottom", "type": "file", "path": "/b", "content": place("bottom")}, + {"id": "top", "type": "file", "path": "/t", "content": place("top")}, + }} +} + +func block(data string) SeatContribution { + var d map[string]any + if err := json.Unmarshal([]byte(data), &d); err != nil { + panic(err) + } + return SeatContribution{Seat: BarSeat, Kind: BarKindBlock, Data: d} +} + +func composedFile(t *testing.T, r Resolution, id string) string { + t.Helper() + out, err := r.Declaration(Rendering{}) + if err != nil { + t.Fatal(err) + } + for _, res := range out { + if res["id"] == id { + return res["content"].(string) + } + } + t.Fatalf("%s was not composed", id) + return "" +} + +func TestABlockIsRenderedByTheHolderInItsPlaceAndOrderNamedByModule(t *testing.T) { + power := Manifest{Module: "power", Contributions: []SeatContribution{ + block(`{"bar":"bottom","place":"status","shows":"battery"}`), + block(`{"bar":"bottom","place":"status","order":20,"shows":"command","options":{"command":"draw \"now\"","interval":5}}`), + }} + other := Manifest{Module: "another", Contributions: []SeatContribution{ + block(`{"bar":"bottom","place":"status","order":70,"shows":"battery","options":{"device":"BAT1"}}`), + block(`{"bar":"top","place":"resources","shows":"command","options":{"command":"up","interval":60}}`), + }} + r := Resolution{Node: "laptop", Account: "op", Modules: []Manifest{barHolder(), power, other}} + + // Ordered by order (20, 50, 70), then module order; each piece under its module's name; the + // command quoted so its own quotes cannot end the line. + want := "own-first\nown-middle\n" + + "# power\n[[block]]\nblock = \"custom\"\ncommand = \"draw \\\"now\\\"\"\ninterval = 5\n" + + "# power\n[[block]]\nblock = \"battery\"\n" + + "# another\n[[block]]\nblock = \"battery\"\ndevice = \"BAT1\"\n" + + "own-last\n" + if got := composedFile(t, r, "a-bar.bottom"); got != want { + t.Fatalf("the bottom bar is\n%s\nnot\n%s", got, want) + } + want = "own-first\n# another\n[[block]]\nblock = \"custom\"\ncommand = \"up\"\ninterval = 60\nown-middle\nown-last\n" + if got := composedFile(t, r, "a-bar.top"); got != want { + t.Fatalf("the top bar is\n%s\nnot\n%s", got, want) + } +} + +func TestAContributionIfACapabilityIsPlacedOnlyWhereTheMachineReportsIt(t *testing.T) { + c := block(`{"bar":"bottom","place":"status","shows":"battery"}`) + c.IfCapability = "battery" + power := Manifest{Module: "power", Contributions: []SeatContribution{c}} + for _, c := range []struct { + caps map[string]bool + has bool + }{{map[string]bool{"battery": true}, true}, {map[string]bool{"seat": true}, false}, {nil, false}} { + r := Resolution{Node: "n", Account: "op", Capabilities: c.caps, Modules: []Manifest{barHolder(), power}} + if bar := composedFile(t, r, "a-bar.bottom"); strings.Contains(bar, `block = "battery"`) != c.has { + t.Errorf("with %v: the battery placed is not %v:\n%s", c.caps, c.has, bar) + } + } +} + +func TestIfACapabilityNamesOneTheNodeEngineDetectsOnAnOfferedKindOnly(t *testing.T) { + cases := map[string]string{ + // A name nothing detects would leave the piece out on every machine. + `{"seat":"node-bar","kind":"block","if-capability":"batery","data":{"bar":"top","place":"status","shows":"battery"}}`: "which no machine reports", + // A kind the holder depends on is never left out for want of something. + `{"seat":"node-hotkeys","kind":"trigger","if-capability":"battery","content":"KEY_BATTERY 1 x"}`: "only a kind the seat offers", + `{"seat":"node-backup","kind":"backup","if-capability":"seat","content":"path /x"}`: "only a kind the seat offers", + } + for c, want := range cases { + raw := `{"module":"power","contributions":[` + c + `]}` + if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), want) { + t.Errorf("%s: accepted, or refused without %q: %v", c, want, err) + } + } + for _, name := range []string{"battery", "power-meter", "seat"} { + if !oneOf(KnownCapabilities, name) { + t.Errorf("%s is not a known capability", name) + } + } +} + +func TestResolveCarriesTheMachinesCapabilities(t *testing.T) { + node := workstation() + node.Capabilities["battery"] = true + r, err := Resolve(shelf(Manifest{Module: "a"}), []string{"a"}, node, World{}) + if err != nil { + t.Fatal(err) + } + if !r.Capabilities["battery"] || !r.Capabilities["seat"] { + t.Fatalf("the resolution carries %v, not the node's %v", r.Capabilities, node.Capabilities) + } +} + +func TestAPieceTheHolderCannotRenderIsLeftOutAndNamedNotTheMachine(t *testing.T) { + // A shows the shape gained after the holder's template was written: built here, past the check. + power := Manifest{Module: "power", Contributions: []SeatContribution{ + block(`{"bar":"bottom","place":"status","shows":"gpu"}`), + block(`{"bar":"bottom","place":"status","shows":"battery"}`), + }} + r := Resolution{Node: "n", Account: "op", Modules: []Manifest{barHolder(), power}} + c, err := r.Compose(Rendering{}) + if err != nil { + t.Fatalf("one piece failed the whole machine: %v", err) + } + if len(c.Unplaced) != 1 || !strings.Contains(c.Unplaced[0], "power's node-bar:block") || + !strings.Contains(c.Unplaced[0], "shows=gpu") || !strings.Contains(c.Unplaced[0], "renders nothing") { + t.Fatalf("unplaced: %v", c.Unplaced) + } + for _, res := range c.Resources { + if res["id"] == "a-bar.bottom" && !strings.Contains(res["content"].(string), `block = "battery"`) { + t.Fatalf("the piece beside it was lost too: %s", res["content"]) + } + } +} + +// A moved bar is rendered by the provisioner's client, not by the controller, so the rule that its template renders +// every example of the shape is no longer judged here (issue 915, which also covers placementProblems not reading the +// collected form). What the controller still owns is that the bar's files place the blocks at all: a moved bar's +// carry the collected placement, and one that stopped placing them fails here rather than being skipped (issue 432). +func TestTheCataloguesBarPlacesBlocksAndItsShapeCoversEveryShow(t *testing.T) { + holder := catalogueManifest(t, "i3status-rust") + s, _ := SeatNamed(BarSeat) + if holder.Moved { + placed := "${collected:contribution:" + BarSeat + ":" + BarKindBlock + ":" + found := false + for _, res := range holder.Resources { + if content, _ := res["content"].(string); strings.Contains(content, placed) { + found = true + } + } + if !found { + t.Fatalf("the catalogue's moved bar places no blocks: none of its files carries %s…}", placed) + } + } else { + if !placesKind(holder, s, BarKindBlock) { + // It places them since the catalogue's bar took the seat; a skip here hid a bar that stopped + // (novox/hq issue 432). + t.Fatal("the catalogue's bar places no blocks, so none of the shape's examples would render") + } + tmpl, err := holderTemplate(holder, s, BarKindBlock) + if err != nil { + t.Fatal(err) + } + for _, ex := range s.Receives[0].Shape.Examples { + out, err := renderPiece(tmpl, "example", "a-machine", ex) + if err != nil || !strings.Contains(out, "[[block]]") { + t.Errorf("the catalogue's bar renders %v as %q: %v", ex, out, err) + } + } + } + for shows := range barShows { + covered := false + for _, ex := range s.Receives[0].Shape.Examples { + covered = covered || ex["shows"] == shows + } + if !covered { + t.Errorf("the shape has no example that shows %s, so no holder is made to render it", shows) + } + } +} + +func TestQuoteIsATomlBasicStringOfTheSameText(t *testing.T) { + for _, in := range []string{`plain`, `say "hi" \ there`, "tab\tnew\nline", "del\x7fend", "nul\x00 esc\x1b", "<&> é ☃"} { + q, err := quote(in) + if err != nil { + t.Fatal(err) + } + got, err := tomlBasicString(q) + if err != nil || got != in { + t.Errorf("quote(%q) = %s, which TOML reads as %q: %v", in, q, got, err) + } + } +} + +// tomlBasicString reads one TOML basic string, as the TOML specification (1.0, "String") defines it: +// any character but a quote, a backslash and the control characters U+0000–U+0008, U+000A–U+001F +// and U+007F, which are written as escapes. +func tomlBasicString(s string) (string, error) { + if len(s) < 2 || s[0] != '"' || s[len(s)-1] != '"' { + return "", fmt.Errorf("not quoted") + } + var b strings.Builder + rs := []rune(s[1 : len(s)-1]) + for i := 0; i < len(rs); i++ { + r := rs[i] + switch { + case r == '"': + return "", fmt.Errorf("a bare quote at %d", i) + case r == 0x7f || (r < 0x20 && r != '\t'): + return "", fmt.Errorf("a bare control character %U at %d", r, i) + case r != '\\': + b.WriteRune(r) + continue + } + i++ + if i >= len(rs) { + return "", fmt.Errorf("an escape at the end") + } + switch rs[i] { + case 'b': + b.WriteRune('\b') + case 't': + b.WriteRune('\t') + case 'n': + b.WriteRune('\n') + case 'f': + b.WriteRune('\f') + case 'r': + b.WriteRune('\r') + case '"': + b.WriteRune('"') + case '\\': + b.WriteRune('\\') + case 'u', 'U': + n := 4 + if rs[i] == 'U' { + n = 8 + } + if i+n > len(rs)-1 { + return "", fmt.Errorf("a short \\u escape") + } + v, err := strconv.ParseUint(string(rs[i+1:i+1+n]), 16, 32) + if err != nil { + return "", err + } + b.WriteRune(rune(v)) + i += n + default: + return "", fmt.Errorf("the escape \\%c is not TOML's", rs[i]) + } + } + return b.String(), nil +} + +func TestABlockOutsideTheShapeIsRefused(t *testing.T) { + cases := map[string]string{ + `{"seat":"node-bar","kind":"block","content":"[[block]]"}`: "has no data", + `{"seat":"node-bar","kind":"block","content":"x","data":{"bar":"top","place":"status","shows":"battery"}}`: "has content", + `{"seat":"node-hotkeys","kind":"trigger","data":{"a":"b"}}`: "receives trigger as text", + `{"seat":"node-bar","kind":"block","data":{"bar":"left","place":"status","shows":"battery"}}`: `bar "left"`, + `{"seat":"node-bar","kind":"block","data":{"bar":"top","place":"middle","shows":"battery"}}`: `place "middle"`, + `{"seat":"node-bar","kind":"block","data":{"bar":"top","place":"status","shows":"gpu"}}`: "shows one of battery, command", + `{"seat":"node-bar","kind":"block","data":{"bar":"top","place":"status","shows":"battery","colour":"red"}}`: `no field "colour"`, + `{"seat":"node-bar","kind":"block","data":{"bar":"top","place":"status","shows":"battery","order":100}}`: "whole number from 0 to 99", + `{"seat":"node-bar","kind":"block","data":{"bar":"top","place":"status","shows":"command"}}`: "needs the option command", + `{"seat":"node-bar","kind":"block","data":{"bar":"top","place":"status","shows":"battery","options":{"x":"y"}}}`: `no option "x"`, + `{"seat":"node-bar","kind":"block","data":{"bar":"top","place":"status","shows":"command","options":{"command":"c","interval":0}}}`: "seconds", + `{"seat":"node-bar","kind":"block","if-capability":"Has Battery","data":{"bar":"top","place":"status","shows":"battery"}}`: "not a capability", + } + for c, want := range cases { + raw := `{"module":"power","contributions":[` + c + `]}` + if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), want) { + t.Errorf("%s: accepted, or refused without %q: %v", c, want, err) + } + } + ok := `{"module":"power","contributions":[{"seat":"node-bar","kind":"block","if-capability":"battery",` + + `"data":{"bar":"bottom","place":"status","order":10,"shows":"command","options":{"command":"c","interval":5}}}]}` + if _, err := ParseManifest([]byte(ok)); err != nil { + t.Errorf("a block in the shape was refused: %v", err) + } +} + +func TestAHolderPlacesEveryCombinationOnceWithATemplateThatRendersEveryExample(t *testing.T) { + holder := func(template string, contents ...string) string { + var res []string + for i, c := range contents { + b, _ := json.Marshal(c) + res = append(res, `{"id":"f`+string(rune('a'+i))+`","type":"file","path":"/f`+string(rune('a'+i))+`","content":`+string(b)+`}`) + } + tb, _ := json.Marshal(template) + renders := "" + if template != "" { + renders = `,"renders":{"block":` + string(tb) + `}` + } + return `{"module":"a-bar","claims":[{"name":"node-bar"` + renders + `}],"resources":[` + strings.Join(res, ",") + `]}` + } + all := "${contribution:node-bar:block:bar=bottom,place=resources}${contribution:node-bar:block:bar=bottom,place=status}" + + "${contribution:node-bar:block:bar=top,place=resources}${contribution:node-bar:block:bar=top,place=status}" + cases := map[string]string{ + holder(barTemplate, "${contribution:node-bar:block:bar=bottom}"): "never places node-bar:block for bar=top,place=resources", + holder(barTemplate, all, "${contribution:node-bar:block:place=status}"): "for bar=bottom,place=status 2 times", + holder("", all): "gives no template", + holder(`{{if eq .shows "battery"}}b{{end}}`, all): "renders nothing", + holder(`{{.nothing}}`, all): "renders nothing", + holder(`{{if}`, all): "does not parse", + holder(barTemplate, "${contribution:node-bar:block:bar=left}"+all): "bar is one of bottom, top", + holder(barTemplate, "${contribution:node-bar:block:colour=red}"+all): "placed by bar, place", + holder(barTemplate, "${contribution:node-hotkeys:trigger:bar=top}"): "does not claim node-hotkeys", + } + for raw, want := range cases { + if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), want) { + t.Errorf("%s:\naccepted, or refused without %q: %v", raw, want, err) + } + } + for _, raw := range []string{holder(barTemplate, all), holder(barTemplate, "${contribution:node-bar:block}"), + // A holder that places no block at all is older than the kind, and is not refused for it. + holder("", "no blocks here")} { + if _, err := ParseManifest([]byte(raw)); err != nil { + t.Errorf("%s: refused: %v", raw, err) + } + } +} + +func TestAnOfferedBlockMakesNoDependencyOnTheBar(t *testing.T) { + power := Manifest{Module: "power", Contributions: []SeatContribution{ + block(`{"bar":"bottom","place":"status","shows":"battery"}`), + {Seat: HotkeysSeat, Kind: "trigger", Content: "x"}, + }} + for _, seat := range DependsOn(nil, power) { + if seat == BarSeat { + t.Fatalf("a block made the power module depend on the bar: %v", DependsOn(nil, power)) + } + } + // And on a machine with no bar it is placed nowhere, and nothing fails. + r := Resolution{Node: "server", Account: "op", Capabilities: map[string]bool{"battery": true}, + Modules: []Manifest{hotkeysHolder(), power}} + if _, err := r.Declaration(Rendering{}); err != nil { + t.Fatal(err) + } +} + +func TestTheBarReceivesBlocksAsData(t *testing.T) { + s, _ := SeatNamed(BarSeat) + if len(s.Receives) != 1 || s.Receives[0].Kind != BarKindBlock || s.Receives[0].Shape == nil || !s.Receives[0].Offered { + t.Fatalf("node-bar receives %+v", s.Receives) + } + for _, ex := range s.Receives[0].Shape.Examples { + if p := barBlockProblems(ex); len(p) > 0 { + t.Errorf("the shape's own example %v is refused: %v", ex, p) + } + } +} + +func TestAStateBlockIsRenderedForThisMachineAndTheHolderIsGrantedToReadIt(t *testing.T) { + power := Manifest{Module: "power", State: []StateDeclaration{{Name: "draw"}}, Contributions: []SeatContribution{ + block(`{"bar":"bottom","place":"status","shows":"state","options":{"state":"power.draw"}}`), + }} + r := Resolution{Node: "laptop", Account: "op", Modules: []Manifest{barHolder(), power}} + if bar := composedFile(t, r, "a-bar.bottom"); !strings.Contains(bar, `command = "show power.draw laptop"`) { + t.Fatalf("the block does not read this machine's key:\n%s", bar) + } + if got := ReadsGranted(barHolder(), []Manifest{barHolder(), power}, "laptop"); len(got) != 1 || + got[0] != (StateRead{Read: "power.draw", Key: "laptop"}) { + t.Fatalf("the holder is granted %v", got) + } + // Only the holder, and only for what the modules on its machine offer. + if got := ReadsGranted(power, []Manifest{barHolder(), power}, "laptop"); len(got) != 0 { + t.Fatalf("a contributor is granted %v", got) + } + if got := ReadsGranted(barHolder(), []Manifest{barHolder()}, "laptop"); len(got) != 0 { + t.Fatalf("a holder alone is granted %v", got) + } +} + +func TestAModuleOffersItsOwnStateOnly(t *testing.T) { + block := `{"seat":"node-bar","kind":"block","data":{"bar":"bottom","place":"status","shows":"state","options":{"state":"%s"}}}` + cases := map[string]string{ + `{"module":"power","state":["draw"],"contributions":[` + fmt.Sprintf(block, "other.draw") + `]}`: "which power does not keep", + `{"module":"power","contributions":[` + fmt.Sprintf(block, "power.draw") + `]}`: "which power does not keep", + `{"module":"power","state":["draw"],"contributions":[` + fmt.Sprintf(block, "draw") + `]}`: "does not name a module", + } + for raw, want := range cases { + if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), want) { + t.Errorf("%s: accepted, or refused without %q: %v", raw, want, err) + } + } + ok := `{"module":"power","state":["draw"],"contributions":[` + fmt.Sprintf(block, "power.draw") + `]}` + if _, err := ParseManifest([]byte(ok)); err != nil { + t.Errorf("its own state was refused: %v", err) + } +} + +func TestPerMachineStateIsDeclaredAndKeptInItsLongForm(t *testing.T) { + m, err := ParseManifest([]byte(`{"module":"power","state":[{"name":"draw","ttl-seconds":30,"per-machine":true}]}`)) + if err != nil || len(m.State) != 1 || !m.State[0].PerMachine { + t.Fatalf("%+v %v", m.State, err) + } + raw, _ := json.Marshal(StateDeclaration{Name: "draw", PerMachine: true}) + if string(raw) != `{"name":"draw","per-machine":true}` { + t.Fatalf("%s", raw) + } +} diff --git a/go/catalogue/seat_paths_test.go b/go/catalogue/seat_paths_test.go new file mode 100644 index 0000000..a6f5df5 --- /dev/null +++ b/go/catalogue/seat_paths_test.go @@ -0,0 +1,311 @@ +package catalogue + +import ( + "strings" + "testing" +) + +// Defends novox/hq ADR 0287 (issue 412): a seat names the paths its holder owns, a claim without them is +// refused, and only a claimant declares a seat's path. + +// The starting set, exactly: node-hostname names /etc/hosts and /etc/hostname, node-uplink the resolver +// file stepping back for node-resolver, node-resolver the resolver file, and every other seat names none. +func TestTheSeatsThatNamePathsAreTheStartingSet(t *testing.T) { + want := map[string]string{ + "node-hostname": "/etc/hosts /etc/hostname", + "node-uplink": "/etc/resolv.conf>node-resolver", + ResolverSeat: "/etc/resolv.conf", + } + for _, s := range DefaultSeats() { + var got []string + for _, p := range s.Paths { + if p.StepsBackFor != "" { + got = append(got, p.Path+">"+p.StepsBackFor) + } else { + got = append(got, p.Path) + } + } + if strings.Join(got, " ") != want[s.Name] { + t.Errorf("%s names %v; ADR 0287 gives it %q", s.Name, got, want[s.Name]) + } + } +} + +// The compiled set keeps the rules: node seats only, absolute and literal, one seat per path without +// stepping back, and a stepping back that cannot cycle. +func TestTheCompiledSeatPathsKeepTheRules(t *testing.T) { + if p := SeatPathProblems(DefaultSeats()); len(p) != 0 { + t.Fatalf("the compiled set breaks ADR 0287: %v", p) + } + for _, c := range []struct { + why string + set []Seat + want string + }{ + {"a mesh seat naming a path", []Seat{{Name: "mesh-x", Scope: ScopeMesh, Paths: []SeatPath{{Path: "/etc/x"}}}}, + "only a node seat names paths"}, + {"a placeholder", []Seat{{Name: "node-x", Scope: ScopeNode, Paths: []SeatPath{{Path: "/etc/${setting:x}"}}}}, + "placeholder"}, + {"a relative path", []Seat{{Name: "node-x", Scope: ScopeNode, Paths: []SeatPath{{Path: "etc/x"}}}}, + "not a clean absolute path"}, + {"two owners", []Seat{ + {Name: "node-a", Scope: ScopeNode, Paths: []SeatPath{{Path: "/etc/x"}}}, + {Name: "node-b", Scope: ScopeNode, Paths: []SeatPath{{Path: "/etc/x"}}}}, "no two seats name one path"}, + {"a cycle", []Seat{ + {Name: "node-a", Scope: ScopeNode, Paths: []SeatPath{{Path: "/etc/x", StepsBackFor: "node-b"}}}, + {Name: "node-b", Scope: ScopeNode, Paths: []SeatPath{{Path: "/etc/x", StepsBackFor: "node-a"}}}}, + "does not own that path itself"}, + {"stepping back for nobody", []Seat{ + {Name: "node-a", Scope: ScopeNode, Paths: []SeatPath{{Path: "/etc/x", StepsBackFor: "node-z"}}}}, + "not a node seat"}, + } { + if got := strings.Join(SeatPathProblems(c.set), "; "); !strings.Contains(got, c.want) { + t.Errorf("%s: %q does not say %q", c.why, got, c.want) + } + } +} + +// What the store's rows carry is never the paths: a row read back keeps the compiled ones. +func TestTheSeatPathsSurviveTheStoresRows(t *testing.T) { + before := seats + t.Cleanup(func() { seats = before }) + UseSeats([]Seat{{Name: "node-hostname", Scope: ScopeNode}}) + s, _ := SeatNamed("node-hostname") + if len(s.Paths) != 2 { + t.Fatalf("node-hostname read back from its row names %v", s.Paths) + } +} + +func hostnameSeat(t *testing.T) Seat { + t.Helper() + s, ok := SeatNamed("node-hostname") + if !ok { + t.Fatal("no node-hostname seat") + } + return s +} + +func hostnameHolder(resources ...map[string]any) Manifest { + return Manifest{Module: "names", Version: "1", Claims: []Claim{{Name: "node-hostname", Scope: ScopeNode, + Serves: []string{"entries", "add", "remove"}}}, Resources: resources} +} + +// A holder declares every path its seat names, as a resource or as a file the mesh renders for it, or +// CanHold refuses it naming the seat and each missing path — the judgement registration and handover share. +func TestAClaimWithoutTheSeatsPathsIsRefused(t *testing.T) { + seat := hostnameSeat(t) + hosts := map[string]any{"id": "hosts", "type": "file", "path": "/etc/hosts", "content": "x"} + name := map[string]any{"id": "name", "type": "file", "path": "/etc/hostname", "content": "x"} + + if err := CanHold(hostnameHolder(hosts, name), seat); err != nil { + t.Errorf("a holder declaring both paths as resources was refused: %v", err) + } + rendered := hostnameHolder(hosts) + rendered.Facts = map[string]RosterFile{"name": {Path: "/etc/hostname", Template: "{{.Node}}\n"}} + if err := CanHold(rendered, seat); err != nil { + t.Errorf("a holder declaring a path as a file the mesh renders was refused: %v", err) + } + err := CanHold(hostnameHolder(), seat) + if err == nil { + t.Fatal("a holder declaring neither path was accepted") + } + for _, want := range []string{"node-hostname", "/etc/hosts", "/etc/hostname", "ADR 0287"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("the refusal does not name %s: %v", want, err) + } + } + // A file under the operator's home is placed per account: it is not the machine's path. + home := hostnameHolder(hosts) + home.Facts = map[string]RosterFile{"name": {Path: "/etc/hostname", Home: true, Template: "x"}} + if err := CanHold(home, seat); err == nil { + t.Error("a file in the operator's home counted as the machine's /etc/hostname") + } + // At registration, the same refusal. + if got := strings.Join(CatalogueProblems(Shelf{"names": hostnameHolder(hosts)}), "; "); !strings.Contains(got, + "does not declare /etc/hostname") { + t.Errorf("registration let a holder without /etc/hostname through: %q", got) + } +} + +// Only a claimant declares a seat's path: a module claiming no seat that names /etc/hosts and declaring +// it is refused at registration naming the seat; the uplink's holder and the resolver's both declaring +// the resolver file are each a claimant of a seat naming it. Reading a seat's path is access, and allowed. +func TestOnlyAClaimantDeclaresASeatsPath(t *testing.T) { + stranger := Manifest{Module: "stranger", Version: "1", Resources: []map[string]any{ + {"id": "hosts", "type": "file", "path": "/etc/hosts", "into": "block", "content": "10.0.0.1 x\n"}}} + got := strings.Join(CatalogueProblems(Shelf{"stranger": stranger}), "; ") + if !strings.Contains(got, `stranger declares the path "/etc/hosts", which the seat node-hostname names`) { + t.Errorf("a module declaring /etc/hosts without claiming node-hostname was not refused: %q", got) + } + // However the path is spelled (novox/hq issue 412 review). + unclean := Manifest{Module: "unclean", Version: "1", Resources: []map[string]any{ + {"id": "hosts", "type": "file", "path": "/etc/../etc/hosts", "content": "10.0.0.1 x\n"}}} + if got := strings.Join(CatalogueProblems(Shelf{"unclean": unclean}), "; "); !strings.Contains(got, "node-hostname") { + t.Errorf("/etc/../etc/hosts from a module not claiming node-hostname was not refused: %q", got) + } + reader := Manifest{Module: "reader", Version: "1", Accesses: []Access{{ID: "hosts", Path: "/etc/hosts"}}} + if got := CatalogueProblems(Shelf{"reader": reader}); len(got) != 0 { + t.Errorf("reading /etc/hosts was refused: %v", got) + } + uplink := Manifest{Module: "uplink", Version: "1", Claims: []Claim{{Name: "node-uplink", Scope: ScopeNode}}, + Facts: map[string]RosterFile{"resolvers": {Path: ResolverFile, Template: "nameserver 10.42.0.1\n"}}} + resolver := localResolver() + resolver.Claims[0].Serves = []string{"routes", "route", "unroute"} + if got := CatalogueProblems(Shelf{"uplink": uplink, resolver.Module: resolver}); len(got) != 0 { + t.Errorf("the uplink's holder and the resolver's declaring the resolver file were refused: %v", got) + } +} + +// A module's own seat names paths under the same rules: a node seat only, literal, and none another seat +// names — the second declaration loses. Its holder declares them, as a mesh seat's does. +func TestAModulesOwnSeatNamesPathsUnderTheSameRules(t *testing.T) { + meshScoped := Manifest{Module: "a", Version: "1", DefinesSeats: []SeatDeclaration{ + {Name: "a-seat", Paths: []string{"/etc/a"}}}} + if got := strings.Join(declaredSeatProblems(meshScoped), "; "); !strings.Contains(got, "only a node seat names paths") { + t.Errorf("a module's mesh seat naming a path was accepted: %q", got) + } + placeholder := Manifest{Module: "a", Version: "1", DefinesSeats: []SeatDeclaration{ + {Name: "a-seat", Scope: ScopeNode, Paths: []string{"${dir:state}/a"}}}} + if got := strings.Join(declaredSeatProblems(placeholder), "; "); !strings.Contains(got, "placeholder") { + t.Errorf("a module's seat naming a path with a placeholder was accepted: %q", got) + } + + taken := Manifest{Module: "b", Version: "1", DefinesSeats: []SeatDeclaration{ + {Name: "b-seat", Scope: ScopeNode, Paths: []string{"/etc/hosts"}}}} + if got := strings.Join(CatalogueProblems(Shelf{"b": taken}), "; "); !strings.Contains(got, + `b declares the seat b-seat naming the path "/etc/hosts", which node-hostname already names`) { + t.Errorf("a module's seat naming /etc/hosts was accepted: %q", got) + } + + own := Manifest{Module: "c", Version: "1", + DefinesSeats: []SeatDeclaration{{Name: "c-seat", Scope: ScopeNode, Paths: []string{"/etc/c.conf"}}}, + Claims: []Claim{{Name: "c-seat", Scope: ScopeNode}}} + if got := strings.Join(CatalogueProblems(Shelf{"c": own}), "; "); !strings.Contains(got, + "c claims c-seat but does not declare /etc/c.conf") { + t.Errorf("a holder of a module's seat without its path was accepted: %q", got) + } + own.Resources = []map[string]any{{"id": "conf", "type": "file", "path": "/etc/c.conf", "content": "x"}} + if got := CatalogueProblems(Shelf{"c": own}); len(got) != 0 { + t.Errorf("a holder declaring its own seat's path was refused: %v", got) + } + other := Manifest{Module: "d", Version: "1", Resources: []map[string]any{ + {"id": "conf", "type": "file", "path": "/etc/c.conf", "content": "x"}}} + if got := strings.Join(CatalogueProblems(Shelf{"c": own, "d": other}), "; "); !strings.Contains(got, + `d declares the path "/etc/c.conf", which the seat c-seat names`) { + t.Errorf("a module not claiming c-seat declared its path: %q", got) + } +} + +type writesHosts struct{} + +func (writesHosts) Resources(string) ([]map[string]any, bool, error) { + return []map[string]any{{"id": "hosts", "type": "file", "path": "/etc/hosts", "into": "block", + "content": "10.42.0.1 anchor\n"}}, true, nil +} + +// The controller is bound too: what it generates for a machine on a seat's path is refused at composition. +func TestAGeneratedResourceOnASeatsPathIsRefused(t *testing.T) { + r := Resolution{Node: "workstation", Modules: []Manifest{ + {Module: "mesh-names", Computed: "mesh-names"}, + }} + _, err := r.Declaration(Rendering{Generators: map[string]Generator{"mesh-names": writesHosts{}}}) + if err == nil { + t.Fatal("a generated resource on /etc/hosts was composed") + } + for _, want := range []string{"mesh-names", "/etc/hosts", "node-hostname"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("the refusal does not name %s: %v", want, err) + } + } +} + +// Who owns a seat's path on a machine: the seat naming it and its holder there, with the stepping back +// applied — the resolver's holder where node-resolver is held, else the uplink's. +func TestPathOwnersApplyTheSteppingBack(t *testing.T) { + held := []Held{ + {Claim: "node-uplink", Scope: ScopeNode, Node: "laptop", Module: "networkmanager"}, + {Claim: ResolverSeat, Scope: ScopeNode, Node: "laptop", Module: "systemd-resolved"}, + {Claim: "node-uplink", Scope: ScopeNode, Node: "server", Module: "systemd-networkd"}, + {Claim: "node-hostname", Scope: ScopeNode, Node: "server", Module: "hostname"}, + } + var got []string + for _, o := range PathOwners(Seats(), held) { + got = append(got, o.Path+" "+o.Node+" "+o.Seat+" "+o.Module) + } + want := []string{ + "/etc/hostname server node-hostname hostname", + "/etc/hosts server node-hostname hostname", + "/etc/resolv.conf laptop node-resolver systemd-resolved", + "/etc/resolv.conf server node-uplink systemd-networkd", + } + if strings.Join(got, "\n") != strings.Join(want, "\n") { + t.Errorf("the owners are\n%s\nnot\n%s", strings.Join(got, "\n"), strings.Join(want, "\n")) + } +} + +// The stepping back comes from the seat, and is of a rendered file only: an uplink holder declaring the +// resolver file as a resource beside a node-resolver holder is two writers of one path, refused when the +// machine is resolved — never composed twice — as before the seat named its paths. +func TestAnUplinkDeclaringTheResolverFileAsAResourceIsRefusedBesideTheResolver(t *testing.T) { + shelf := resolverShelf(t) + uplink := shelf["systemd-networkd"] + uplink.Facts = nil + uplink.Resources = append(append([]map[string]any(nil), uplink.Resources...), + map[string]any{"id": "resolvers", "type": "file", "path": ResolverFile, "content": "nameserver 10.42.0.1\n"}) + shelf["systemd-networkd"] = uplink + shelf["local-resolver"] = localResolver() + _, err := Resolve(shelf, []string{"dnsmasq", "systemd-networkd", "local-resolver"}, splitDNSLaptop(), World{}) + if err == nil || !strings.Contains(err.Error(), `both declare the path "/etc/resolv.conf"`) { + t.Fatalf("an uplink declaring the resolver file as a resource beside the resolver was not refused: %v", err) + } + // Its rendered file steps back, by the seat's definition, and nothing else of it does. + rendered := Manifest{Module: "uplink", Version: "1", Claims: []Claim{{Name: "node-uplink"}}, + Facts: map[string]RosterFile{"resolvers": {Path: "/etc//resolv.conf", Template: "x"}, + "other": {Path: "/etc/uplink.conf", Template: "x"}}} + got := stepsBack(rendered, []Manifest{rendered, localResolver()}) + if len(got.Facts) != 1 || got.Facts["other"].Path == "" { + t.Errorf("the uplink's holder renders %v beside the resolver", got.Facts) + } +} + +// A path is compared as the machine writes it: cleaned. Each spelling of /etc/hosts is /etc/hosts. +func TestAnUncleanSpellingOfASeatsPathIsTheSeatsPath(t *testing.T) { + for _, spelled := range []string{"/etc//hosts", "/etc/./hosts", "/etc/../etc/hosts", "/etc/hosts/"} { + stranger := Manifest{Module: "stranger", Version: "1", Resources: []map[string]any{ + {"id": "hosts", "type": "file", "path": spelled, "content": "10.0.0.1 x\n"}}} + if got := strings.Join(CatalogueProblems(Shelf{"stranger": stranger}), "; "); !strings.Contains(got, + `stranger declares the path "/etc/hosts", which the seat node-hostname names`) { + t.Errorf("%s from a module not claiming node-hostname was not refused: %q", spelled, got) + } + holder := hostnameHolder(map[string]any{"id": "hosts", "type": "file", "path": spelled, "content": "x"}, + map[string]any{"id": "name", "type": "file", "path": "/etc/hostname", "content": "x"}) + if err := CanHold(holder, hostnameSeat(t)); err != nil { + t.Errorf("a holder declaring %s was refused: %v", spelled, err) + } + } +} + +// A seat names a role's own path, never the root or a top-level directory. +func TestASeatNamesNoRootOrTopLevelDirectory(t *testing.T) { + for _, p := range []string{"/", "/etc"} { + m := Manifest{Module: "a", Version: "1", DefinesSeats: []SeatDeclaration{ + {Name: "a-seat", Scope: ScopeNode, Paths: []string{p}}}} + if got := strings.Join(declaredSeatProblems(m), "; "); !strings.Contains(got, "top-level directory") { + t.Errorf("a module's seat naming %s was accepted: %q", p, got) + } + } +} + +// The catalogue as it is passes: no holder of a seat naming a path is missing one, and nobody else +// declares one. +func TestTheCataloguePassesTheSeatPathRules(t *testing.T) { + shelf := Shelf{} + for _, m := range theCatalogue(t) { + shelf[m.Module] = m + } + for _, p := range CatalogueProblems(shelf) { + if strings.Contains(p, "ADR 0287") { + t.Errorf("the catalogue breaks ADR 0287: %s", p) + } + } +} diff --git a/go/catalogue/seats_test.go b/go/catalogue/seats_test.go new file mode 100644 index 0000000..2b01748 --- /dev/null +++ b/go/catalogue/seats_test.go @@ -0,0 +1,302 @@ +package catalogue + +import ( + "regexp" + "strings" + "testing" +) + +// Defends novox/hq ADR 0110: a seat is a module assignment from a closed set. + +// The set is closed, and changing it is a decision. +// +// **The count is asserted, and every entry names the record that made it a seat**, so the next +// person changing the set finds the argument rather than a number to edit — the pattern the host's +// vocabulary test follows. If this fails because a seat was added, the fix is a record in novox/hq +// and a row in to-be 26, not a new number here. +func TestTheSeatsAreAClosedSetAndEachNamesItsDecision(t *testing.T) { + // A seat a later record extends names both, "novox/hq ADR 0166, ADR 0207": the one that defined + // it and the one that seeded it. + record := regexp.MustCompile(`^novox/hq ADR \d{4}(, ADR \d{4})*$`) + seen := map[string]bool{} + delivered := map[string]string{} + for _, s := range Seats() { + if seen[s.Name] { + t.Errorf("%s is in the set twice", s.Name) + } + seen[s.Name] = true + if !record.MatchString(s.Decision) { + t.Errorf("%s names %q as its decision; every seat names the record that made it one", + s.Name, s.Decision) + } + switch s.Scope { + case ScopeNode, ScopeSite, ScopeMesh: + default: + t.Errorf("%s is held per %q, which is not a scope", s.Name, s.Scope) + } + if s.Delivers != "" { + // Two seats answering for one provision would put the question "which one?" back, + // which is the question a seat exists to answer. + if other, twice := delivered[s.Delivers]; twice { + t.Errorf("%s and %s both deliver %q", other, s.Name, s.Delivers) + } + delivered[s.Delivers] = s.Name + } + } + // Forty-five with node-files (novox/hq ADR 0394); forty-four again once node-reads goes. + // Forty-four with node-provisioner (novox/hq ADR 0349 clause 3); forty-three with mesh-provisioner (novox/hq ADR 0349 clause 3); forty-two with mesh-config (novox/hq ADR 0348 §9); forty-one with node-reads (novox/hq ADR 0310); forty with node-nfs-server and node-mounts (novox/hq ADR 0263); thirty-eight with node-resolver (novox/hq ADR 0247); thirty-seven with mesh-delivery (novox/hq ADR 0239); thirty-six since node-resolver-config retired + // into node-uplink (novox/hq ADR 0223); thirty-seven + // since the retired node-dns-resolver went (novox/hq ADR 0220); thirty-eight with + // node-backup (novox/hq ADR 0214); thirty-seven with node-message-bus (novox/hq ADR 0215); + // thirty-six with mesh-dns-resolver (novox/hq ADR 0194) and node-hosts-file (ADR 0199, now + // node-hostname); thirty-four + // with node-hotkeys (ADR 0212); thirty-three with node-power (ADR 0211); thirty-two since the + // graphical session's eleven (ADR 0208); twenty-one with node-package-manager and + // node-container-runtime (ADR 0207); nineteen with node-environment and node-login-shell (ADR 0203, + // ADR 0204); seventeen with node-build-agent (ADR 0190). One fewer once the retired + // mesh-build-machine row goes, when no registered manifest claims it. + if len(Seats()) != 45 { + t.Errorf("the mesh defines %d seats rather than 45; the set is closed, so a change here is "+ + "a decision (novox/hq ADR 0110): %s", len(Seats()), seatNames()) + } +} + +// novox/hq ADR 0117: a machine's uplink is a seat, held per machine, and delivers nothing. +// +// **Nothing, because nothing may be required of it.** A holder only keeps its network manager from +// contradicting the mesh; a requirement resolving to it would make the manager the mesh's answer +// for something, and the manager's link is the one thing the mesh must never be able to break. +func TestTheUplinkIsANodeSeatThatDeliversNothing(t *testing.T) { + seat, known := SeatNamed("node-uplink") + if !known { + t.Fatalf("the uplink is not a seat; the seats are: %s", seatNames()) + } + if seat.Scope != ScopeNode || seat.Delivers != "" || seat.Decision != "novox/hq ADR 0117" { + t.Fatalf("the uplink is %+v, not a node seat delivering nothing by ADR 0117", seat) + } + // And a manager's module can hold it without providing anything. + raw := []byte(`{"module":"networkmanager","version":"1","claims":[{"name":"node-uplink","scope":"node"}]}`) + if _, err := ParseManifest(raw); err != nil { + t.Fatalf("a network manager's module could not hold the uplink: %v", err) + } +} + +func claimed(claims string) []byte { + return []byte(`{"module":"thing","version":"1","provides":[{"name":"npm-package-registry","scope":"mesh"}],"claims":` + claims + `}`) +} + +// **The refusal moved, it did not go** (novox/hq ADR 0118, superseding 0110). A module may now +// declare its own seats, so whether a claimed seat exists is a fact about the *catalogue* and not +// about the manifest in front of the parser: a claim on a seat another registered module declares +// is perfectly good, and the parser cannot tell the two cases apart. So the parser accepts it and +// registration refuses it — the same guarantee, at the same moment work would otherwise start, +// from a set nobody maintains by hand. +func TestAClaimOnASeatNobodyDeclaresIsRefusedAtRegistration(t *testing.T) { + m, err := ParseManifest(claimed(`[{"name":"the-anything","scope":"node"}]`)) + if err != nil { + t.Fatalf("the parser judged a claim it cannot judge alone: %v", err) + } + + problems := CatalogueProblems(Shelf{m.Module: m}) + if len(problems) == 0 { + t.Fatal("a module invented a seat by claiming it, and registration allowed it") + } + joined := strings.Join(problems, "; ") + if !strings.Contains(joined, "the-anything") || !strings.Contains(joined, "no module declares") { + t.Fatalf("the refusal does not say the seat is nobody's: %v", problems) + } +} + +// And the same claim is fine once something declares that seat, which is the case the parser +// could not have distinguished. +func TestAClaimOnASeatAnotherModuleDeclaresIsAccepted(t *testing.T) { + claimant, err := ParseManifest(claimed(`[{"name":"the-anything","scope":"node"}]`)) + if err != nil { + t.Fatal(err) + } + declarer := Manifest{Module: "someone", DefinesSeats: []SeatDeclaration{ + {Name: "the-anything", Scope: ScopeNode, Accepts: []string{"work"}}, + }} + if problems := CatalogueProblems(Shelf{claimant.Module: claimant, "someone": declarer}); len(problems) != 0 { + t.Fatalf("a claim on a declared seat was refused: %v", problems) + } +} + +// A module may define its own seat and claim it — the mesh enforces exclusivity without knowing +// what it means (novox/hq ADR 0121). But it may not define one in the mesh's own namespace. +func TestAModuleDefinesAndClaimsItsOwnSeat(t *testing.T) { + ok := []byte(`{"module":"showcase","version":"1","seats":[{"name":"the-showcase","scope":"node"}],` + + `"claims":[{"name":"the-showcase","scope":"node"}]}`) + if _, err := ParseManifest(ok); err != nil { + t.Fatalf("a module could not define and claim its own seat: %v", err) + } + // Claiming a name nobody defines is still refused — but **at registration, not here**: with + // seats declared by modules, a claim on a seat *another* module declares is good, and the + // parser cannot tell that from an invented name. See + // TestAClaimOnASeatNobodyDeclaresIsRefusedAtRegistration. + claimant, err := ParseManifest(claimed(`[{"name":"the-anything","scope":"node"}]`)) + if err != nil { + t.Fatalf("the parser judged a claim it cannot judge alone: %v", err) + } + if len(CatalogueProblems(Shelf{claimant.Module: claimant})) == 0 { + t.Fatal("a module claimed a seat nobody defines") + } + // A module may not carve its seat out of the mesh's own namespace. + bad := []byte(`{"module":"x","version":"1","seats":[{"name":"node-mine","scope":"node"}],` + + `"claims":[{"name":"node-mine","scope":"node"}]}`) + if _, err := ParseManifest(bad); err == nil || !strings.Contains(err.Error(), "own namespace") { + t.Fatalf("a module defined a seat in the mesh's namespace and was not refused: %v", err) + } +} + +// **At registration, not in the parser** (novox/hq ADR 0122): a seat's scope is a property of the +// set, the set is the store's, and the parser also runs on a build machine that has no store. +func TestASeatClaimedAtAnotherScopeIsRefused(t *testing.T) { + m, err := ParseManifest(claimed(`[{"name":"npm-package-registry","scope":"node"}]`)) + if err != nil { + t.Fatalf("the parser judged a scope it reads from data it may not have: %v", err) + } + got := strings.Join(CatalogueProblems(Shelf{m.Module: m}), "; ") + if !strings.Contains(got, "mesh seat") { + t.Fatalf("the refusal does not say which scope the seat is: %q", got) + } +} + +func TestADeliveringSeatIsOnlyHeldByAModuleThatProvides(t *testing.T) { + // Holding it makes the module the mesh's answer for the provision. A module that cannot answer + // would be the answer anyway, and every consumer would be sent to it. + // And refused at registration, where the seat set is the store's: what a seat delivers is + // data, so a compiled copy of it may not be what refuses a build (novox/hq ADR 0122). + raw := []byte(`{"module":"thing","version":"1","claims":[{"name":"git","scope":"mesh"}]}`) + m, err := ParseManifest(raw) + if err != nil { + t.Fatalf("the parser judged what a seat delivers: %v", err) + } + got := strings.Join(CatalogueProblems(Shelf{m.Module: m}), "; ") + if !strings.Contains(got, `does not provide "git"`) { + t.Fatalf("the refusal does not say what is missing: %q", got) + } +} + +func TestAClaimThatIsMalformedIsRefusedOnceForThat(t *testing.T) { + // Not a second time for being unknown: one mistake, one line. + _, err := ParseManifest(claimed(`[{"name":"Not A Name","scope":"node"}]`)) + if err == nil { + t.Fatal("a malformed claim was accepted") + } + if strings.Contains(err.Error(), "not a seat") { + t.Fatalf("a malformed claim was also called unknown: %v", err) + } +} + +// The holder of a seat answers among several providers. + +func registryShelf() map[string]Manifest { + return shelf( + Manifest{Module: "gitea", Version: "1", Provides: FromAnywhere("npm-package-registry"), + Claims: []Claim{{Name: "npm-package-registry", Scope: ScopeMesh}}}, + Manifest{Module: "verdaccio", Version: "1", Provides: FromAnywhere("npm-package-registry")}, + Manifest{Module: "builder", Version: "1", Requires: []string{"npm-package-registry"}}, + ) +} + +func twoRegistries() map[string][]Provider { + return map[string][]Provider{"npm-package-registry": { + {Node: "anchor", At: "anchor.internal", Module: "gitea"}, + {Node: "archive", At: "archive.internal", Module: "verdaccio"}, + }} +} + +func giteaHoldsTheSeat() []Held { + return []Held{{Claim: "npm-package-registry", Scope: ScopeMesh, Node: "anchor", Module: "gitea"}} +} + +func TestTheSeatsHolderAnswersWhenSeveralProvide(t *testing.T) { + // The whole point: a second registry beside the holder harms nothing, and nobody pins. + got, err := Resolve(registryShelf(), []string{"builder"}, reachable(), + World{Offered: twoRegistries(), Held: giteaHoldsTheSeat()}) + if err != nil { + t.Fatal(err) + } + if len(got.Needs) != 1 || got.Needs[0].From != "anchor" { + t.Fatalf("the seat's holder did not answer: %v", got.Needs) + } +} + +func TestAPinStillWinsOverTheSeat(t *testing.T) { + // A consumer coupled to one provider's contents has said so, and the seat does not overrule it. + got, err := Resolve(registryShelf(), []string{"builder"}, reachable(), + World{Offered: twoRegistries(), Held: giteaHoldsTheSeat(), + Pinned: map[string]Chosen{"npm-package-registry": {Node: "archive", Module: "verdaccio"}}}) + if err != nil { + t.Fatal(err) + } + if len(got.Needs) != 1 || got.Needs[0].From != "archive" { + t.Fatalf("the pin was overruled by the seat: %v", got.Needs) + } +} + +func TestWithTheSeatUnheldSeveralProvidersAreStillRefused(t *testing.T) { + // No seat held is no choice made, and ADR 0009's rule stands: never guessed. + _, err := Resolve(registryShelf(), []string{"builder"}, reachable(), + World{Offered: twoRegistries()}) + if err == nil { + t.Fatal("one of two registries was picked with nobody holding the seat") + } + if !strings.Contains(err.Error(), "pin") { + t.Fatalf("the refusal does not say how to choose: %v", err) + } +} + +func TestTheHolderIsTheModuleNotTheMachine(t *testing.T) { + // Two modules on one machine could provide the same thing; only the one holding the seat + // answers. A holder matched by node alone would send consumers to whichever came first. + providers := []Provider{ + {Node: "anchor", At: "anchor.internal", Module: "verdaccio"}, + {Node: "anchor", At: "anchor.internal", Module: "gitea"}, + } + holder, held := HolderAmong("npm-package-registry", providers, giteaHoldsTheSeat()) + if !held || holder.Module != "gitea" { + t.Fatalf("the holder was not told apart from a neighbour: %+v", holder) + } +} + +// The working set is loaded from the store, and an empty load never erases it (novox/hq ADR 0122). +func TestUseSeatsReplacesTheSetButNeverEmptiesIt(t *testing.T) { + before := Seats() + defer UseSeats(DefaultSeats()) // restore for other tests, whatever this leaves it as + + // An empty load (store not seeded, or unreadable) leaves the compiled defaults in force. + UseSeats(nil) + if len(Seats()) != len(before) { + t.Fatalf("an empty load changed the set from %d to %d seats", len(before), len(Seats())) + } + // A non-empty load replaces it — this is how a rename in the store reaches the lookups. + UseSeats([]Seat{{Name: "node-firewall", Scope: ScopeNode, Decision: "novox/hq ADR 0122"}}) + if _, known := SeatNamed("node-firewall"); !known { + t.Fatal("the loaded set did not replace the working set") + } + if len(Seats()) != 1 { + t.Fatalf("the working set is %d seats, not the one that was loaded", len(Seats())) + } +} + +// A former name resolves to the seat it was renamed from (novox/hq ADR 0122), so a manifest's claim +// and a held record naming the old name break nothing after a rename. +func TestAFormerNameResolvesAfterARename(t *testing.T) { + defer func() { UseSeats(DefaultSeats()); UseAliases(nil) }() + UseSeats([]Seat{{Name: "git", Scope: ScopeMesh, Delivers: "git", Decision: "novox/hq ADR 0121"}}) + UseAliases(map[string]string{"git": "git"}) + + // The old name resolves to the renamed seat. + if s, ok := SeatNamed("git"); !ok || s.Name != "git" { + t.Fatalf("the former name did not resolve to the renamed seat: %+v ok=%v", s, ok) + } + // And a holder recorded under the old name is still found for the provision the seat delivers. + providers := []Provider{{Node: "anchor", At: "anchor.internal", Module: "gitea"}} + held := []Held{{Claim: "git", Scope: ScopeMesh, Node: "anchor", Module: "gitea"}} + holder, found := HolderAmong("git", providers, held) + if !found || holder.Module != "gitea" { + t.Fatalf("the holder recorded under the former name was not matched: %+v found=%v", holder, found) + } +} diff --git a/go/catalogue/showcase_manifest_test.go b/go/catalogue/showcase_manifest_test.go new file mode 100644 index 0000000..1ba866c --- /dev/null +++ b/go/catalogue/showcase_manifest_test.go @@ -0,0 +1,86 @@ +package catalogue + +import ( + "os" + "path/filepath" + "testing" + + "git.novox.be/novox/mesh-sdk/go/internal/beside" +) + +// **The showcase module is parsed by the real parser, in the real test suite.** +// +// A module that exercises every capability is only worth having if something checks it still does. +// Written as a test rather than a script so it runs whenever anything about manifests changes — +// which is exactly when a module using all of it would quietly stop being valid. +func TestTheShowcaseModuleIsAValidManifest(t *testing.T) { + raw, err := os.ReadFile(filepath.Join(beside.Catalogue(t), "showcase", "module.json")) + if err != nil { + t.Fatal(err) + } + m, err := ParseManifest(raw) + if err != nil { + t.Fatalf("the module that exercises everything does not parse:\n%v", err) + } + + // Every resource kind a MODULE may use, actually in it. + // + // Two of the host's eleven are deliberately absent, and the reasons are worth keeping: + // + // - `action` is refused to modules outright. The link may not carry a command to run (ADR + // 0005), so a module that needs something done ships a program that reads what the mesh + // delivered and reconciles — which is what a run-once `process` is. + // - `service` puts an EXISTING unit into a state and deliberately installs none, which is + // right for software that ships its own unit. A module whose code the mesh built has no + // such unit until the mesh writes one, and that is a `process`. + kinds := map[string]bool{} + for _, r := range m.Resources { + kind, _ := r["type"].(string) + kinds[kind] = true + } + for _, want := range []string{ + "access", "archive", "container", "directory", "file", "network", "package", + "process", "user", + } { + if !kinds[want] { + t.Errorf("showcase no longer exercises %q", want) + } + } + + // And all three ways a module's own code can run, which is the thing most easily lost. + var stays, once, scheduled bool + for _, r := range m.Resources { + if kind, _ := r["type"].(string); kind != "process" { + continue + } + switch { + case r["run-once"] == true: + once = true + case r["schedule"] != nil: + scheduled = true + default: + stays = true + } + } + if !stays || !once || !scheduled { + t.Errorf("showcase does not exercise all three process modes: stays=%v once=%v scheduled=%v", + stays, once, scheduled) + } + + // And the artifact kinds, including the one that compiles. + var bundle, archive, upstream bool + for _, a := range m.Build.Artifacts { + switch a.Kind { + case ArtifactBundle: + bundle = true + case ArtifactArchive: + archive = true + case ArtifactUpstream: + upstream = true + } + } + if !bundle || !archive || !upstream { + t.Errorf("showcase does not exercise every artifact kind: bundle=%v archive=%v upstream=%v", + bundle, archive, upstream) + } +} diff --git a/go/catalogue/ssh_client_test.go b/go/catalogue/ssh_client_test.go new file mode 100644 index 0000000..4abec06 --- /dev/null +++ b/go/catalogue/ssh_client_test.go @@ -0,0 +1,117 @@ +package catalogue + +import ( + "io/fs" + "os" + "path/filepath" + "regexp" + "strings" + "testing" + + "git.novox.be/novox/mesh-sdk/go/internal/beside" +) + +// boundKeys are every key the files of a module ask of ${bound:forge-account:}, read from the module's own +// tree on the shelf, so the test offers what ssh-client asks however often it asks one more (issue 985). +func boundKeys(t *testing.T, module, provision string) map[string]bool { + t.Helper() + ask := regexp.MustCompile(`\$\{bound:` + regexp.QuoteMeta(provision) + `:([A-Za-z0-9_.-]+)\}`) + keys := map[string]bool{} + root := filepath.Join(beside.Catalogue(t), module) + err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { + if err != nil || d.IsDir() || strings.HasSuffix(path, "_test.go") { + return err + } + body, err := os.ReadFile(path) + if err != nil { + return err + } + for _, m := range ask.FindAllStringSubmatch(string(body), -1) { + keys[m[1]] = true + } + return nil + }) + if err != nil { + t.Fatalf("reading what %s asks of %s: %v", module, provision, err) + } + return keys +} + +// The ssh-client module, composed as a machine receives it (novox/hq to-be 29, research 027/03): a +// region at the START of the operator's ~/.ssh/config that includes ~/.ssh/config.d/* — first, +// because ssh takes the first value it finds for each option — and the mesh's Host blocks as the +// whole of ~/.ssh/config.d/00-mesh, every other node with its account. ~/.ssh and ~/.ssh/config.d +// are created 0700 and owned by the account; the operator's own config below the region is kept. +func TestSSHClientOwnsTheOperatorsSSHConfig(t *testing.T) { + shelf := shelf(catalogueManifest(t, "ssh-client"), catalogueManifest(t, "gitea")) + // ssh-client requires forge-account since mesh-catalog #340 (novox/hq issue 955), answered by the forge's + // holder on another node; this test is about the operator's config, so the forge is offered from there + // (issue 985). + // Every key ssh-client's files ask is offered, read from them (boundKeys); a few have a realistic value. + serves := map[string]any{} + for key := range boundKeys(t, "ssh-client", "forge-account") { + serves[key] = "sample-" + key + } + for key, value := range map[string]any{"ssh_host": "marge.internal", "ssh_port": 222, + "known_host": "[marge.internal]:222 ssh-ed25519 AAAA", "ssh_base": "ssh://git@marge.internal:222/", + "https_base": "https://git.example.org/"} { + serves[key] = value + } + forge := World{Offered: map[string][]Provider{ + "forge-account": {{Node: "marge", At: "marge.internal", Module: "gitea", Serves: serves}}}} + got, err := Resolve(shelf, []string{"ssh-client"}, + Node{Name: "homer", At: "homer.internal", Account: "jo", AccountHome: "/home/jo"}, forge) + if err != nil { + t.Fatal(err) + } + names := map[string]string{"homer.internal": "10.10.0.1", "marge.internal": "10.10.0.2", + "bart.internal": "10.10.0.3"} + out, err := got.Declaration(Rendering{ + Names: names, Machines: names, + Accounts: map[string]string{"homer": "jo", "marge": "jo", "bart": "op"}, + Suffix: "internal", + }) + if err != nil { + t.Fatal(err) + } + by := map[string]map[string]any{} + for _, r := range out { + by[r["id"].(string)] = r + } + + for id, path := range map[string]string{ + "ssh-client.ssh-dir": "/home/jo/.ssh", "ssh-client.config-d": "/home/jo/.ssh/config.d"} { + dir := by[id] + if dir == nil || dir["type"] != "directory" || dir["path"] != path || dir["owner"] != "jo" || dir["mode"] != "0700" { + t.Fatalf("%s is not created 0700 owned by the account: %v", path, dir) + } + } + + cfg := by["ssh-client.config"] + if cfg == nil || cfg["path"] != "/home/jo/.ssh/config" || cfg["owner"] != "jo" || + cfg["into"] != "block" || cfg["at"] != "start" { + t.Fatalf("the mesh's region is not the first thing in the operator's ~/.ssh/config: %v", cfg) + } + if body := cfg["content"].(string); !strings.Contains(body, "\nInclude ~/.ssh/config.d/*\n") || strings.Contains(body, "\nHost ") { + t.Fatalf("the region does not just include config.d:\n%s", body) + } + + var hosts map[string]any + for _, r := range out { + if r["path"] == "/home/jo/.ssh/config.d/00-mesh" { + hosts = r + } + } + if hosts == nil || hosts["type"] != "file" || hosts["into"] != nil { + t.Fatalf("the mesh's hosts are not the whole of ~/.ssh/config.d/00-mesh: %v", hosts) + } + body := hosts["content"].(string) + for _, want := range []string{"Host marge marge.internal", "Host bart bart.internal", "User jo", "User op"} { + if !strings.Contains(body, want) { + t.Fatalf("00-mesh does not say %q — every other node with its account:\n%s", want, body) + } + } + if strings.Contains(body, "Host homer ") { + t.Fatalf("00-mesh names the machine itself, not only its peers:\n%s", body) + } +} diff --git a/go/catalogue/state_test.go b/go/catalogue/state_test.go new file mode 100644 index 0000000..d4cf687 --- /dev/null +++ b/go/catalogue/state_test.go @@ -0,0 +1,125 @@ +package catalogue + +import ( + "encoding/json" + "strings" + "testing" +) + +// A module declares the state it keeps and the state it reads (novox/hq ADR 0201), a bucket by its +// bare name or with the owner's options. +func TestAManifestMaySayWhatStateItKeepsAndReads(t *testing.T) { + m, err := ParseManifest([]byte(`{"module":"claude-code","version":"1",` + + `"state":["servers",{"name":"seen","history":5,"ttl-seconds":3600}],` + + `"reads":["licence-manager.bindings"]}`)) + if err != nil { + t.Fatal(err) + } + if len(m.State) != 2 || m.State[0].Name != "servers" || m.State[1].History != 5 || m.State[1].TTLSeconds != 3600 { + t.Fatalf("state not read: %+v", m.State) + } + if len(m.Reads) != 1 || m.Reads[0] != "licence-manager.bindings" { + t.Fatalf("reads not read: %v", m.Reads) + } + // Written back as it came in: the short form where nothing else is said. + out, _ := json.Marshal(m.State) + if string(out) != `["servers",{"name":"seen","history":5,"ttl-seconds":3600}]` { + t.Fatalf("written back as %s", out) + } +} + +// A name the bus could not hold, or that would let two modules derive one bucket, is refused at +// registration in the manifest's words. +func TestAStateNameIsLocalAndOneToken(t *testing.T) { + for _, c := range []struct{ manifest, says string }{ + {`{"module":"a","version":"1","state":["mesh.servers"]}`, `keeps state "mesh.servers": a state is named locally`}, + {`{"module":"a","version":"1","state":["my_servers"]}`, `keeps state "my_servers"`}, + {`{"module":"a","version":"1","state":["s","s"]}`, `keeps state "s" twice`}, + {`{"module":"a","version":"1","state":[{"name":"s","history":65}]}`, `a key keeps between 1 and 64`}, + {`{"module":"a.b","version":"1","state":["s"]}`, `no dot`}, + {`{"module":"a","version":"1","reads":["bindings"]}`, `a read is .`}, + {`{"module":"a","version":"1","reads":["a.s"]}`, `which is its own state`}, + {`{"module":"a","version":"1","state":[{"name":"s","shared":true}]}`, `{name, history, ttl-seconds, per-machine, key-ttl}`}, + } { + _, err := ParseManifest([]byte(c.manifest)) + if err == nil { + t.Errorf("%s was accepted", c.manifest) + continue + } + if !strings.Contains(err.Error(), c.says) { + t.Errorf("%s refused for the wrong reason: %v", c.manifest, err) + } + } +} + +// A read whose owner is present must name a state that owner keeps; an absent owner says nothing, +// because a module may be installed before the one whose state it reads. +func TestAReadNamesStateItsOwnerKeeps(t *testing.T) { + owner := Manifest{Module: "licence-manager", State: []StateDeclaration{{Name: "bindings"}}} + good := Manifest{Module: "claude-code", Reads: []string{"licence-manager.bindings", "absent.anything"}} + bad := Manifest{Module: "other", Reads: []string{"licence-manager.tokens"}} + if p := StateReadsNothingDeclares([]Manifest{owner, good}); len(p) != 0 { + t.Fatalf("a read of declared state was refused: %v", p) + } + p := StateReadsNothingDeclares([]Manifest{owner, bad}) + if len(p) != 1 || !strings.Contains(p[0], `licence-manager keeps no state called "tokens"`) { + t.Fatalf("a read of state nobody keeps was not named: %v", p) + } +} + +// **The controller's record mirrors are kept state** (design 54 §5, design 57 F3): a module may read +// `mesh-controller.assignments` and `mesh-controller.declared-config` although the controller's manifest +// declares no `state`; a read of anything else of the controller's is still named. +func TestAReadOfTheControllersMirrorsIsAReadOfKeptState(t *testing.T) { + controller := Manifest{Module: ControllerSeatName} + reader := Manifest{Module: "mesh-config", Reads: []string{"mesh-controller.assignments", "mesh-controller.declared-config"}} + if p := StateReadsNothingDeclares([]Manifest{controller, reader}); len(p) != 0 { + t.Fatalf("a read of the controller's mirrors was refused: %v", p) + } + other := Manifest{Module: "other", Reads: []string{"mesh-controller.calls"}} + if p := StateReadsNothingDeclares([]Manifest{controller, other}); len(p) != 1 { + t.Fatalf("a read of the controller's own calls was not named: %v", p) + } +} + +// **Across the whole catalogue**: every state name is local, and every read whose owner is present +// names state that owner keeps. +func TestEveryManifestsStateIsLocalAndEveryReadIsKept(t *testing.T) { + manifests := theCatalogue(t) + var problems []string + for _, m := range manifests { + problems = append(problems, StateProblems(m)...) + } + problems = append(problems, StateReadsNothingDeclares(manifests)...) + if len(problems) > 0 { + t.Fatalf("the catalogue's state is not what ADR 0201 says:\n %s", strings.Join(problems, "\n ")) + } +} + +// `module check` says it too: the cross-catalogue pass names a read nothing on the shelf keeps. +func TestTheCataloguePassNamesAReadItsOwnerDoesNotKeep(t *testing.T) { + shelf := Shelf{ + "licence-manager": {Module: "licence-manager", State: []StateDeclaration{{Name: "bindings"}}}, + "claude-code": {Module: "claude-code", Reads: []string{"licence-manager.tokens"}}, + } + problems := CatalogueProblems(shelf) + if len(problems) != 1 || !strings.Contains(problems[0], `keeps no state called "tokens"`) { + t.Fatalf("the catalogue pass said %v", problems) + } +} + +// 1154/AC1: a state entry may say key-ttl; it is read, written back, and a bare name or an entry without it reads as before. +func TestAStateMayHaveKeysThatExpireOneByOne(t *testing.T) { + m, err := ParseManifest([]byte(`{"module":"mesh-delivery-watchman","version":"1",` + + `"state":[{"name":"pulse","key-ttl":true},"stuck",{"name":"seen","history":5}]}`)) + if err != nil { + t.Fatal(err) + } + if !m.State[0].KeyTTL || m.State[1].KeyTTL || m.State[2].KeyTTL { + t.Fatalf("key-ttl not read as said: %+v", m.State) + } + out, _ := json.Marshal(m.State) + if string(out) != `[{"name":"pulse","key-ttl":true},"stuck",{"name":"seen","history":5}]` { + t.Fatalf("written back as %s", out) + } +} diff --git a/go/catalogue/two_resolvers_test.go b/go/catalogue/two_resolvers_test.go new file mode 100644 index 0000000..f68bb95 --- /dev/null +++ b/go/catalogue/two_resolvers_test.go @@ -0,0 +1,271 @@ +package catalogue + +import ( + "strings" + "testing" +) + +// The mesh has two resolvers (novox/hq ADR 0223): `mesh-dns-resolver` is replicated, held on the +// anchor and on the home server, each answering the same names; every machine's resolver file lists +// every holder — its own first when it is one — and no public resolver. ADR 0196 listed the mesh's +// resolver then a public one, and musl asks both at once and takes the first reply: from the home +// server the public "no such name" for the anchor's mesh name won, every time, in every Alpine build. +// The file is written by the module holding the machine's uplink (ADR 0223 part 2). + +// resolverMachines is the anchor and the home server holding the resolver, and a laptop holding nothing. +var resolverMachines = map[string]string{ + "anchor.internal": "10.42.0.1", "laptop.internal": "10.42.0.2", "home.internal": "10.42.0.3"} + +// bothResolvers is the two holders on record, as `seat mesh-dns-resolver --add` leaves them. +var bothResolvers = []Held{ + {Claim: "mesh-dns-resolver", Scope: ScopeMesh, Node: "anchor", Module: "dnsmasq"}, + {Claim: "mesh-dns-resolver", Scope: ScopeMesh, Node: "home", Module: "dnsmasq"}, +} + +// uplinks is every module in the catalogue holding node-uplink, and so writing the machine's resolver +// file (novox/hq ADR 0223 part 2): the program that would otherwise rewrite it is the one that writes it. +// dhcpcd's left the catalogue with ADR 0226, held by no machine. +var uplinks = []string{"networkmanager", "systemd-networkd"} + +// managing is a machine as each uplink module needs it: able to install, run a service and run the +// manager that module is for. +func managing(node string) Node { + caps := map[string]bool{"package-manager": true, "service-manager": true} + for _, u := range uplinks { + caps["uplink-"+u] = true + } + return Node{Name: node, At: node + ".internal", Capabilities: caps} +} + +// twoResolverShelf is the resolver, the uplink modules that write what a machine asks, and a stand-in +// answering `mesh-addressing`. +func twoResolverShelf(t *testing.T) map[string]Manifest { + t.Helper() + out := map[string]Manifest{ + "net": {Module: "net", Version: "1", Provides: []Offer{{Name: "mesh-addressing"}}}, + "dnsmasq": catalogueManifest(t, "dnsmasq"), + } + for _, u := range uplinks { + out[u] = catalogueManifest(t, u) + } + return out +} + +// worldWithout is the rest of the mesh as a plan for one machine sees it: every other holder's claim +// and offer, and both holders on record. +func worldWithout(node string) World { + w := World{Holdings: bothResolvers, Offered: map[string][]Provider{}} + for _, h := range bothResolvers { + if h.Node == node { + continue + } + w.Held = append(w.Held, h) + w.Offered["wildcard-resolution"] = append(w.Offered["wildcard-resolution"], + Provider{Node: h.Node, At: h.Node + ".internal", Module: h.Module}) + } + return w +} + +// resolvConfOn resolves and composes one machine and answers with the nameservers its resolver file +// lists, in order, and the file. The file is the uplink's — `uplink` is one of the assigned modules — +// and nothing else on the machine declares that path. +func resolvConfOn(t *testing.T, node, uplink string, assigned []string) ([]string, string) { + t.Helper() + got, err := Resolve(twoResolverShelf(t), assigned, managing(node), worldWithout(node)) + if err != nil { + t.Fatalf("%s with %s does not resolve with two resolvers on record: %v", node, uplink, err) + } + out, err := got.Declaration(Rendering{ + Names: resolverMachines, Machines: resolverMachines, Suffix: "internal", + Holders: map[string]map[string]string{"mesh-dns-resolver": { + "anchor.internal": "10.42.0.1", "home.internal": "10.42.0.3"}}, + Needed: map[string]map[string]string{"dnsmasq": {"broker": "sealed"}}, + }) + if err != nil { + t.Fatalf("%s with %s does not compose: %v", node, uplink, err) + } + var file map[string]any + for _, r := range out { + if r["path"] != "/etc/resolv.conf" { + continue + } + if file != nil { + t.Fatalf("%s declares /etc/resolv.conf twice: %v and %v", node, file["id"], r["id"]) + } + file = r + } + if file == nil || file["id"] != uplink+".fact-resolvers" { + t.Fatalf("%s's resolver file is not %s's: %v", node, uplink, file) + } + content, _ := file["content"].(string) + var servers []string + for _, line := range strings.Split(content, "\n") { + if strings.HasPrefix(line, "nameserver ") { + servers = append(servers, strings.TrimPrefix(line, "nameserver ")) + } + } + return servers, content +} + +// Per uplink module: each writes a resolver file listing both holders, the machine's own first on a +// holder, and only the holders on a machine that is none. +func TestEveryUplinkListsBothResolversItsOwnFirst(t *testing.T) { + for _, uplink := range uplinks { + for node, want := range map[string][]string{ + "anchor": {"10.42.0.1", "10.42.0.3"}, + "home": {"10.42.0.3", "10.42.0.1"}, + "laptop": {"10.42.0.1", "10.42.0.3"}, + } { + assigned := []string{uplink} + if node != "laptop" { + assigned = append(assigned, "dnsmasq") + } + servers, content := resolvConfOn(t, node, uplink, assigned) + if strings.Join(servers, " ") != strings.Join(want, " ") { + t.Errorf("%s on %s lists %v; want %v\n%s", uplink, node, servers, want, content) + } + for _, public := range []string{"1.1.1.1", "8.8.8.8", "9.9.9.9"} { + if strings.Contains(content, public) { + t.Errorf("%s on %s lists a public resolver beside the mesh's (ADR 0223):\n%s", uplink, node, content) + } + } + if !strings.HasSuffix(content, "\noptions timeout:1 attempts:2 edns0\n") { + t.Errorf("%s on %s does not end with two short attempts:\n%s", uplink, node, content) + } + } + } +} + +// One file, whichever manager the machine runs: the three modules carry the same template, so a +// machine changing its manager changes nothing in what it asks, and a fix made to one is made to all. +func TestEveryUplinkWritesTheSameResolverFile(t *testing.T) { + var first, firstOf string + for _, uplink := range uplinks { + fact, ok := catalogueManifest(t, uplink).Facts["resolvers"] + if !ok || fact.Path != "/etc/resolv.conf" || fact.Shared || fact.Home { + t.Fatalf("%s does not write the machine's resolver file whole: %+v", uplink, fact) + } + if first == "" { + first, firstOf = fact.Template, uplink + continue + } + if fact.Template != first { + t.Errorf("%s's resolver file differs from %s's; the three are kept identical", uplink, firstOf) + } + } +} + +// The requirement stays with what writes the file (ADR 0223 part 1): a machine is refused when nothing +// in the mesh resolves, rather than given a file listing nothing. +func TestEveryUplinkRequiresTheMeshsResolver(t *testing.T) { + for _, uplink := range uplinks { + found := false + for _, r := range catalogueManifest(t, uplink).Requires { + found = found || r == "wildcard-resolution" + } + if !found { + t.Errorf("%s writes the resolver file and does not require wildcard-resolution", uplink) + } + } + _, err := Resolve(twoResolverShelf(t), []string{"networkmanager"}, managing("laptop"), World{}) + if err == nil || !strings.Contains(err.Error(), "wildcard-resolution") { + t.Errorf("an uplink was composed on a mesh with no resolver: %v", err) + } +} + +// A holder answers its own requirement, even though the other holder sorts first (issue 258 kept). +func TestAHolderAnswersItsOwnRequirement(t *testing.T) { + got, err := Resolve(twoResolverShelf(t), []string{"dnsmasq", "networkmanager"}, + managing("home"), worldWithout("home")) + if err != nil { + t.Fatal(err) + } + for _, n := range got.Needs { + if n.Name == "wildcard-resolution" && n.From != "home" { + t.Errorf("the home server's uplink is bound to %s; it holds the seat itself", n.From) + } + } +} + +// A seat held once is still held once: a second claimant on another machine is refused while +// nothing is on record, and a store recording two holders of it is refused, naming the seat. +func TestASingleHolderMeshSeatStillRefusesASecondHolder(t *testing.T) { + store := shelf(mod("postgres", nil, nil, nil, Claim{Name: "mesh-store", Scope: ScopeMesh})) + other := Held{Claim: "mesh-store", Scope: ScopeMesh, Node: "anchor", Module: "postgres"} + if _, err := Resolve(store, []string{"postgres"}, workstation(), World{Held: []Held{other}}); err == nil || + !strings.Contains(err.Error(), "one per mesh") { + t.Errorf("a second claimant of a seat held once was not refused: %v", err) + } + here := Held{Claim: "mesh-store", Scope: ScopeMesh, Node: workstation().Name, Module: "postgres"} + _, err := Resolve(store, []string{"postgres"}, workstation(), + World{Held: []Held{other}, Holdings: []Held{other, here}}) + if err == nil || !strings.Contains(err.Error(), "on record as held by 2") { + t.Errorf("two holders on record for a seat held once were not refused: %v", err) + } +} + +// Replicated is not "whoever is assigned": two claimants with nothing on record are refused, as for +// any mesh seat, and each holder is added by an act. +func TestTwoUnrecordedClaimantsOfTheReplicatedSeatAreRefused(t *testing.T) { + w := worldWithout("home") + w.Holdings = nil + _, err := Resolve(twoResolverShelf(t), []string{"dnsmasq"}, Node{Name: "home", At: "home.internal"}, w) + if err == nil || !strings.Contains(err.Error(), "seat mesh-dns-resolver --to") { + t.Errorf("a second resolver with nothing on record was not refused, naming the handover: %v", err) + } +} + +// Only the mesh's resolver is replicated: a seat being replicated is a decision, recorded. +func TestOnlyTheResolverIsReplicated(t *testing.T) { + for _, s := range Seats() { + if s.Replicated != (s.Name == "mesh-dns-resolver") { + t.Errorf("%s replicated = %v; only mesh-dns-resolver is (ADR 0223)", s.Name, s.Replicated) + } + } + UseSeats([]Seat{{Name: "mesh-dns-resolver", Scope: ScopeMesh, Delivers: "wildcard-resolution"}}) + defer UseSeats(DefaultSeats()) + if s, _ := SeatNamed("mesh-dns-resolver"); !s.Replicated { + t.Error("loading the set from the store, which has no column for it, lost the resolver's replication") + } +} + +// Every consumer is bound to the same holder whatever order the mesh resolved its machines in. +func TestTheFirstHolderIsTheFirstProvider(t *testing.T) { + providers := []Provider{{Node: "anchor", Module: "dnsmasq"}, {Node: "home", Module: "dnsmasq"}} + for _, held := range [][]Held{bothResolvers, {bothResolvers[1], bothResolvers[0]}} { + if p, ok := HolderAmong("wildcard-resolution", providers, held); !ok || p.Node != "anchor" { + t.Errorf("held in order %v answered %v", held, p) + } + } +} + +// One host record per machine, beside its wildcard (novox/hq issue 262): a name with a host record +// says it exists and has no IPv6 address, where the wildcard alone said there is no such name, and +// musl reads that as final. +func TestTheResolverHasOneHostRecordPerMachine(t *testing.T) { + got, err := Resolve(twoResolverShelf(t), []string{"dnsmasq"}, Node{Name: "anchor", At: "anchor.internal"}, + World{}) + if err != nil { + t.Fatal(err) + } + out, err := got.Declaration(Rendering{Names: resolverMachines, Machines: resolverMachines, Suffix: "internal", + Needed: map[string]map[string]string{"dnsmasq": {"broker": "sealed"}}}) + if err != nil { + t.Fatal(err) + } + content, _ := byID(out)["dnsmasq.fact-node-zones"]["content"].(string) + records := map[string]int{} + for _, line := range strings.Split(content, "\n") { + if strings.HasPrefix(line, "host-record=") { + records[strings.TrimPrefix(line, "host-record=")]++ + } + } + for name, at := range resolverMachines { + if records[name+","+at] != 1 { + t.Errorf("%s has %d host records at %s, and has one:\n%s", name, records[name+","+at], at, content) + } + } + if len(records) != len(resolverMachines) { + t.Errorf("%d host records for %d machines:\n%s", len(records), len(resolverMachines), content) + } +} diff --git a/go/compose/input.go b/go/compose/input.go index e98a7fe..853eab7 100644 --- a/go/compose/input.go +++ b/go/compose/input.go @@ -70,7 +70,8 @@ type Input struct { // Issued is the credentials made for the consumers of each provider (inv.SecretsFrom): one row for each pair credential // that exists, by the provider node it is for. The grants a provider must create are read from these, so a credential not - // made yet has no grant until the send that makes it. + // made yet has no grant until the send that makes it. An assigner fills it from the facts snapshot's Grants of each + // machine (go/facts, from format 2), which carry who each credential is for and never the credential. Issued []Issued // OwnSecrets is the own secrets held on each machine (the module_secret rows): who made each, and the key it is sealed to. // Their sealed values are optional. @@ -147,7 +148,7 @@ type Node struct { // Pins is the provider each provision was pinned to for it (inv.PinsFor). Pins map[string]catalogue.Chosen // Bound is where each of its consumers of a provision that keeps data was last sent (inv.BindingsFor), by consumer - // module and provision. + // module and provision. An assigner fills it from the facts snapshot's Bindings of the machine (go/facts, from format 2). Bound map[string]map[string]catalogue.Chosen // Settings is its settings layers by module (inv.SettingsFor); a module with none is absent. Settings map[string][]catalogue.Layer diff --git a/go/facts/facts.go b/go/facts/facts.go new file mode 100644 index 0000000..c6333cd --- /dev/null +++ b/go/facts/facts.go @@ -0,0 +1,415 @@ +// Package facts is the mesh's facts snapshot: what a check needs to judge a change against the mesh +// that runs, and nothing it could leak (novox/hq to-be 45 §9, ADR 0227 rule 9). +// +// **Why it exists.** Every check the mesh had was right about the world it was given, and none was +// given the mesh's world: a module passed every test and refused the anchor's whole declaration because +// a real machine's name made its identity 23 characters (issue 263); a manifest passed the catalogue +// check and was refused by the node-engine (issue 236); a resolver answer that glibc forgave was final +// to musl (issue 262). The controller holds those facts. It writes them here, the build seat reads them, +// and a merge check composes every machine of the snapshot with the change applied. +// +// **What it holds, and what it never holds.** Every machine — under a stable pseudonym of the same length +// as its name, because the length is what a name limit meets — with its roles, system, C library, +// architecture, builds, what it reported it can do, what is assigned there, its pins and settings; +// every seat and its holders; every module the mesh holds, as its manifest; the sources the mesh built +// them from; the versions of the bus, the store and the node-engine it runs; and how each machine's +// declaration composes today. **No secret and no address**: a setting whose key or value reads as a +// secret is withheld, an address is replaced by one from a documentation range, and a machine's name, a +// site, an account and a public domain are replaced wherever they appear. So a snapshot can be copied +// into a test, a replay or a pull request without carrying anything of the installation it came from. +// +// Copied from mesh-controller internal/facts at 6c2a8d62 (R4b, issue 1380); the one change is each machine's +// Grants and Bindings, from format 2. +package facts + +import ( + "crypto/sha256" + "encoding/json" + "fmt" + "sort" + "time" +) + +// Format is the snapshot's version. A reader refuses one newer than it knows: a field it cannot read +// is a fact it would judge without. +// +// Format 2 is the first to carry a machine's Grants and Bindings (GrantsFrom): a reader of format 1 refuses a +// snapshot of 2 rather than judge a change to a credential or a binding without them, and a reader of 2 reads a +// snapshot of 1 as one whose machines have none. MediaType stays as it is: it names the kind of document, and the +// format inside it says which fields it carries. +const Format = 2 + +// GrantsFrom is the first format that carries each machine's Grants and Bindings (novox/hq ADR 0446 section 2): the +// credentials made for the consumers of what the machine provides, and where each of its consumers of a provision is +// bound. A snapshot of an earlier format says nothing of them, which is not the same as saying there are none. +const GrantsFrom = 2 + +// Repository and Tag are where the controller keeps the newest snapshot in the artifact store, and +// MediaType what it is kept as. +const ( + Repository = "facts" + Tag = "latest" + MediaType = "application/vnd.novox.mesh.facts.v1+json" +) + +// Facts is one snapshot. +type Facts struct { + Format int `json:"facts"` + // Taken is when the controller composed it. + Taken time.Time `json:"taken"` + // Controller is the controller build that composed it — the one the mesh runs, which is the one a + // change to the catalogue must be readable by (version skew). + Controller Build `json:"controller"` + // Versions are what the mesh runs of the things its tests stand in for. + Versions Versions `json:"versions"` + // Sources are the repositories the mesh builds modules from, each with the newest commit it built. + Sources []Source `json:"sources"` + // Machines, in name order of their pseudonyms. + Machines []Machine `json:"machines"` + // Seats are every seat held, with its holders. + Seats []Seat `json:"seats"` + // Modules are every module the mesh holds, as it holds them. + Modules []Module `json:"modules"` + // Settings are the mesh-wide layer of every module's settings, scrubbed. + Settings []Settings `json:"settings,omitempty"` + // Edges are the build dependencies between modules, as the mesh recorded them — what a merge's + // rebuild width is computed from. + Edges []Edge `json:"edges,omitempty"` + // Beside is the ref each repository is cloned at beside a merge check, by the directory it is found + // under — the commits the mesh runs, the catalogue's main — so a check run by hand reads the siblings + // the build seat reads (novox/hq issue 286). + Beside map[string]string `json:"beside,omitempty"` +} + +// Build names one build of a core component. +type Build struct { + Version string `json:"version,omitempty"` + Commit string `json:"commit,omitempty"` +} + +// Versions are what the mesh runs. +type Versions struct { + // Bus is the bus server's release, as the server tells a client that connects. + Bus string `json:"bus,omitempty"` + // Store is the store's server version, as the store answers it. + Store string `json:"store,omitempty"` + // NodeEngines are the node-engine builds the machines report, each once. + NodeEngines []string `json:"node-engines,omitempty"` + // Toolchains are the toolchain images a merge check runs in, by language, as the mesh holds them — + // with no address: the artifact store the snapshot is read from is where they are pulled from. What + // a repository's merge-check.sh runs in on the build seat, and so what it must run in anywhere else + // (novox/hq issue 286): two releases of one compiler disagree, down to how gofmt lays out a file. + Toolchains map[string]string `json:"toolchains,omitempty"` +} + +// Source is a repository the mesh builds from, and the newest commit it built a module of. +type Source struct { + Repository string `json:"repository"` + Commit string `json:"commit,omitempty"` + Modules int `json:"modules"` +} + +// Machine is one machine, under its pseudonym. +type Machine struct { + Name string `json:"name"` + // Length is the length of its real name, which the pseudonym keeps. + Length int `json:"length"` + // Roles are what it is to the mesh, in words: the control node, the hub, the bus's machine, a + // holder of a mesh seat. What a check names when it fails one. + Roles []string `json:"roles,omitempty"` + // System is the node-engine's system (arch, alpine, …), Libc its C library, as far as the mesh knows. + System string `json:"system,omitempty"` + Libc string `json:"libc,omitempty"` + Architecture string `json:"architecture,omitempty"` + Kernel string `json:"kernel,omitempty"` + // NodeEngine and NodeTools are the builds it runs. + NodeEngine string `json:"node-engine,omitempty"` + NodeTools string `json:"node-tools,omitempty"` + // Contract is the contract of the node-engine's newest health statement (0 when it never stated one): what + // says whether the machine takes handed files (novox/hq issue 969). A check that composed it without would + // judge a moved module the old way where the mesh hands it. + Contract int `json:"contract,omitempty"` + // Capabilities are what it reported it can do; the detail kept only where it is a version. + Capabilities []Capability `json:"capabilities,omitempty"` + // Site, Hub, Public: where it is on the private network — its site (a pseudonym), whether it is the + // hub, whether others can dial it. No address. + Site string `json:"site,omitempty"` + Hub bool `json:"hub,omitempty"` + Public bool `json:"public,omitempty"` + // OnNetwork is whether it has a place on the private network at all. + OnNetwork bool `json:"on-network,omitempty"` + // OutwardLinks are the links it reported as facing outside it, scrubbed: a machine that reported none + // is sent no filter, so a check that raised it without them would compose a machine the mesh does + // not (novox/hq ADR 0140). + OutwardLinks []string `json:"outward-links,omitempty"` + // Adopted is whether the mesh adopted it rather than converged it. + Adopted bool `json:"adopted,omitempty"` + // Account is the operator's login there (a pseudonym of the same length), and AccountHome where its + // home is when that is not the derived one. + Account string `json:"account,omitempty"` + AccountHome string `json:"account-home,omitempty"` + // AgentAccount is the account agents run as there when it is not the operator's (a pseudonym), and + // AgentAccountHome its home when not derived (novox/hq ADR 0266). + AgentAccount string `json:"agent-account,omitempty"` + AgentAccountHome string `json:"agent-account-home,omitempty"` + // PublicDomain is the domain it answers for, its labels replaced. + PublicDomain string `json:"public-domain,omitempty"` + // Assigned is every module assigned there. + Assigned []string `json:"assigned,omitempty"` + // Pins are where it was told a provision comes from. + Pins []Pin `json:"pins,omitempty"` + // Settings are its own layer of each module's settings, scrubbed. + Settings []Settings `json:"settings,omitempty"` + // Accepted are the secrets a person gave the mesh for modules here, by name only: the mesh cannot + // make them, so a check composing this machine gives each a stand-in instead of refusing it. + Accepted []Accepted `json:"accepted,omitempty"` + // Grants are the pair credentials made for the consumers of what this machine provides (mesh-controller's + // inv.SecretsFrom for it): who each is for, never the credential, sealed or not. Written from format GrantsFrom; an + // assigner reads them as compose.Input.Issued. + Grants []Grant `json:"grants,omitempty"` + // Bindings are where each consumer on this machine was last bound to a provision that keeps data (mesh-controller's + // inv.BindingsFor for it), by consumer module and provision. Written from format GrantsFrom; an assigner reads them + // as compose.Node.Bound. + Bindings []Binding `json:"bindings,omitempty"` + // Declaration is how its declaration composes today, as the controller that took this saw it. + Declaration Declaration `json:"declaration"` +} + +// Capability is one thing a machine reported it can or cannot do. +type Capability struct { + Name string `json:"name"` + Present bool `json:"present"` + Detail string `json:"detail,omitempty"` +} + +// Pin is one provision a machine was told where to take from. +type Pin struct { + Provision string `json:"provision"` + Machine string `json:"machine"` + Module string `json:"module,omitempty"` +} + +// Accepted is one secret a person gave: the module's own when Provider is empty, else the credential it +// takes from that machine's provider under Local. +type Accepted struct { + Module string `json:"module"` + Name string `json:"name"` + Provider string `json:"provider,omitempty"` + Local string `json:"local,omitempty"` +} + +// Grant is one pair credential made for a consumer of a provision this machine provides: the provision, the machine and +// module that consume it, and the local name the consumer takes it under. The machine is a pseudonym. Nothing of the +// credential is here, not even sealed (compose.Issued without its ForProvider). +type Grant struct { + Provision string `json:"provision"` + ConsumerNode string `json:"consumer-node"` + ConsumerModule string `json:"consumer-module"` + Local string `json:"local,omitempty"` +} + +// Binding is where one consumer module on this machine was last bound for a provision: the provider's machine (a +// pseudonym) and module (compose.Node.Bound). +type Binding struct { + ConsumerModule string `json:"consumer-module"` + Provision string `json:"provision"` + ProviderNode string `json:"provider-node"` + ProviderModule string `json:"provider-module"` +} + +// Settings is one layer of one module's settings. +type Settings struct { + Module string `json:"module"` + Values map[string]any `json:"values"` +} + +// Declaration is how one machine's declaration composed when the snapshot was taken. +type Declaration struct { + // Composes is whether it composed and the node-engine's validator took it. + Composes bool `json:"composes"` + // Digest is its body's digest — composed as the next push would, so it moves with what the mesh + // would send, and is not the digest of what was last sent. + Digest string `json:"digest,omitempty"` + // Resources are what it declares, as `type:id`, sorted. + Resources []string `json:"resources,omitempty"` + // Problems are why it does not compose or validate, scrubbed. + Problems []string `json:"problems,omitempty"` + // LeftOut are the modules assigned there and left out of it, each with why. + LeftOut map[string]string `json:"left-out,omitempty"` + // Withheld are the consumers its grants leave out because an identity overflows the provision's + // bound (ADR 0225), and Unbound the credentials on record for consumers bound elsewhere (issue 274), + // each said in words. + Withheld []string `json:"withheld,omitempty"` + Unbound []string `json:"unbound,omitempty"` +} + +// Seat is one seat and the machines holding it. +type Seat struct { + Name string `json:"name"` + Scope string `json:"scope"` + Holders []Holder `json:"holders"` +} + +// Holder is one module on one machine holding a seat. +type Holder struct { + Machine string `json:"machine"` + Module string `json:"module"` +} + +// Module is one module the mesh holds. +type Module struct { + Name string `json:"name"` + // Repository and Path are where it is built from; empty for one that came with the controller. + Repository string `json:"repository,omitempty"` + Path string `json:"path,omitempty"` + // Commit is the commit the manifest the mesh holds was read at. + Commit string `json:"commit,omitempty"` + // Provided is a module that came with the controller rather than from a repository. + Provided bool `json:"provided,omitempty"` + // RollOut is its upgrade policy: rolled out when built, or recorded. + RollOut bool `json:"roll-out,omitempty"` + // Reads are the other repositories its build read source from. + Reads []string `json:"reads,omitempty"` + // Sources are the build source its newest build said it read, per repository (novox/hq ADR 0267): a + // context of Reads by name with its paths, or the module's own repository (Own). Absent, the module's + // build reads every file of each of Reads and of its own directory, as before. + Sources []BuildSource `json:"sources,omitempty"` + // Manifest is the module as the mesh holds it: artifacts resolved to the builds it runs. + Manifest json.RawMessage `json:"manifest"` +} + +// BuildSource is the build source a module's build read in one repository (novox/hq ADR 0267). +type BuildSource struct { + Repository string `json:"repository,omitempty"` + Own bool `json:"own,omitempty"` + Paths []string `json:"paths"` +} + +// Edge is one build dependency: From is built standing on To. +type Edge struct { + From string `json:"from"` + To string `json:"to"` + Kind string `json:"kind,omitempty"` +} + +// Sorted puts every list in a fixed order, so two snapshots of one mesh are the same bytes apart from +// when they were taken. +func (f *Facts) Sorted() { + sort.Slice(f.Machines, func(i, j int) bool { return f.Machines[i].Name < f.Machines[j].Name }) + for i := range f.Machines { + m := &f.Machines[i] + sort.Strings(m.Roles) + sort.Strings(m.Assigned) + sort.Slice(m.Capabilities, func(a, b int) bool { return m.Capabilities[a].Name < m.Capabilities[b].Name }) + sort.Slice(m.Pins, func(a, b int) bool { return m.Pins[a].Provision < m.Pins[b].Provision }) + sort.Slice(m.Settings, func(a, b int) bool { return m.Settings[a].Module < m.Settings[b].Module }) + sort.Slice(m.Grants, func(a, b int) bool { + x, y := m.Grants[a], m.Grants[b] + return x.Provision+"\x00"+x.ConsumerNode+"\x00"+x.ConsumerModule+"\x00"+x.Local < + y.Provision+"\x00"+y.ConsumerNode+"\x00"+y.ConsumerModule+"\x00"+y.Local + }) + sort.Slice(m.Bindings, func(a, b int) bool { + x, y := m.Bindings[a], m.Bindings[b] + return x.ConsumerModule+"\x00"+x.Provision < y.ConsumerModule+"\x00"+y.Provision + }) + sort.Slice(m.Accepted, func(a, b int) bool { + x, y := m.Accepted[a], m.Accepted[b] + return x.Module+"\x00"+x.Name+"\x00"+x.Provider+"\x00"+x.Local < y.Module+"\x00"+y.Name+"\x00"+y.Provider+"\x00"+y.Local + }) + sort.Strings(m.Declaration.Resources) + } + sort.Slice(f.Seats, func(i, j int) bool { + if f.Seats[i].Name != f.Seats[j].Name { + return f.Seats[i].Name < f.Seats[j].Name + } + return f.Seats[i].Scope < f.Seats[j].Scope + }) + for i := range f.Seats { + h := f.Seats[i].Holders + sort.Slice(h, func(a, b int) bool { + if h[a].Machine != h[b].Machine { + return h[a].Machine < h[b].Machine + } + return h[a].Module < h[b].Module + }) + } + sort.Slice(f.Modules, func(i, j int) bool { return f.Modules[i].Name < f.Modules[j].Name }) + sort.Slice(f.Sources, func(i, j int) bool { return f.Sources[i].Repository < f.Sources[j].Repository }) + sort.Slice(f.Settings, func(i, j int) bool { return f.Settings[i].Module < f.Settings[j].Module }) + sort.Slice(f.Edges, func(i, j int) bool { + if f.Edges[i].From != f.Edges[j].From { + return f.Edges[i].From < f.Edges[j].From + } + return f.Edges[i].To < f.Edges[j].To + }) + sort.Strings(f.Versions.NodeEngines) +} + +// Encode is the snapshot as it is kept: sorted, indented, so a person can read the one the build seat +// read and a diff of two says what moved. +func (f Facts) Encode() ([]byte, error) { + f.Sorted() + return json.MarshalIndent(f, "", " ") +} + +// Content is the digest of everything but when it was taken: two snapshots of an unchanged mesh have +// the same content, which is how the controller tells a change from another day. +func (f Facts) Content() (string, error) { + f.Taken = time.Time{} + body, err := f.Encode() + if err != nil { + return "", err + } + sum := sha256.Sum256(body) + return fmt.Sprintf("sha256:%x", sum), nil +} + +// Decode reads a snapshot, refusing one newer than this reader knows and one that is not a snapshot. +func Decode(body []byte) (Facts, error) { + var f Facts + if err := json.Unmarshal(body, &f); err != nil { + return Facts{}, fmt.Errorf("not a facts snapshot: %w", err) + } + switch { + case f.Format == 0: + return Facts{}, fmt.Errorf("not a facts snapshot: it says no format") + case f.Format > Format: + return Facts{}, fmt.Errorf("a facts snapshot of format %d, and this reads format %d: a newer controller "+ + "took it, and what it says beyond %d would be judged without", f.Format, Format, Format) + } + return f, nil +} + +// Longest is the length of the longest machine name in the snapshot — what a consumer's identity is +// judged on (novox/hq ADR 0225). +func (f Facts) Longest() int { + longest := 0 + for _, m := range f.Machines { + if m.Length > longest { + longest = m.Length + } + } + return longest +} + +// Machine is the machine of that pseudonym. +func (f Facts) Machine(name string) (Machine, bool) { + for _, m := range f.Machines { + if m.Name == name { + return m, true + } + } + return Machine{}, false +} + +// Described is how a check names a machine: its roles, then its pseudonym. +func (m Machine) Described() string { + if len(m.Roles) == 0 { + return "a machine (" + m.Name + ")" + } + out := m.Roles[0] + for _, r := range m.Roles[1:] { + out += ", " + r + } + return out + " (" + m.Name + ")" +} diff --git a/go/facts/facts_test.go b/go/facts/facts_test.go new file mode 100644 index 0000000..bef9959 --- /dev/null +++ b/go/facts/facts_test.go @@ -0,0 +1,189 @@ +package facts + +import ( + "fmt" + "net" + "regexp" + "strings" + "testing" + "time" +) + +// A pseudonym keeps what a limit meets — the length, and letters where letters were — and nothing else. +func TestAPseudonymKeepsTheLengthAndShapeAndIsStable(t *testing.T) { + for _, name := range []string{"ace", "g14", "novox", "shanks", "home-server", "a"} { + p := Pseudonym("machine", name) + if len(p) != len(name) { + t.Errorf("%s became %s: %d characters for %d", name, p, len(p), len(name)) + } + if p == name { + t.Errorf("%s was kept as itself", name) + } + if p != Pseudonym("machine", name) { + t.Errorf("%s is not stable", name) + } + for i := range name { + isLetter := func(c byte) bool { return c >= 'a' && c <= 'z' } + isDigit := func(c byte) bool { return c >= '0' && c <= '9' } + if isLetter(name[i]) != isLetter(p[i]) || isDigit(name[i]) != isDigit(p[i]) { + t.Errorf("%s became %s: the shape moved at %d", name, p, i) + } + } + } + if Pseudonym("machine", "ace") == Pseudonym("site", "ace") { + t.Error("a site and a machine of one name share a pseudonym, so a snapshot says they are one thing") + } +} + +// Nothing of the installation survives the scrubber: names, domains, accounts, addresses, mail, secrets. +func TestTheScrubberLeavesNothingOfTheInstallation(t *testing.T) { + s := NewScrubber() + machine := s.Machine("homeserver") + s.Account("jochens") + domain := s.Domain("zurag.be") + if len(domain) != len("zurag.be") || !strings.HasSuffix(domain, ".be") || domain == "zurag.be" { + t.Errorf("the domain became %q", domain) + } + in := map[string]any{ + "hub": "homeserver", + "listen": "10.42.0.7:51820", + "upstream": []any{"192.168.1.135", "fd00::1"}, + "site": "https://grafana.zurag.be/login", + "admin": "jschoubben@gmail.com", + "home": "/home/jochens/.ssh", + "api_key": "sk-live-abcdef", + "nested": map[string]any{"password": "hunter2", "port": float64(5432)}, + "opaque": "a8F3kQ9zL2mX7vB4nC6dE1rT5yU0iO8pA3sD", + "dsn": "postgres://app:s3cr3tpass@db.internal:5432/app", + "pem": "-----BEGIN PRIVATE KEY-----\nMIIB", + "plain": "a-long-plain-module-directory-name", + "digest": "sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a", + "version": "2.11.17", + "homeserver": true, + } + out := s.Values(in) + flat := flatten(out) + for _, leaked := range []string{"homeserver\"", "10.42.0.7", "192.168.1.135", "fd00::1", "zurag", "jschoubben", + "gmail", "jochens", "sk-live", "hunter2", "a8F3kQ9zL2mX7vB4nC6dE1rT5yU0iO8pA3sD", "s3cr3tpass", "MIIB"} { + if strings.Contains(flat, leaked) { + t.Errorf("%q survived the scrubber:\n%s", leaked, flat) + } + } + if out["hub"] != machine { + t.Errorf("a machine named in a setting became %v, not its pseudonym %s", out["hub"], machine) + } + for _, kept := range []string{"a-long-plain-module-directory-name", "2.11.17", "sha256:44136fa3", "5432"} { + if !strings.Contains(flat, kept) { + t.Errorf("%q was scrubbed, and it is not the installation's:\n%s", kept, flat) + } + } + // Every address left is a documentation address. + for _, a := range regexp.MustCompile(`[0-9a-f:.]{7,}`).FindAllString(flat, -1) { + ip := net.ParseIP(strings.Trim(a, ".:")) + if ip == nil { + continue + } + doc := false + for _, cidr := range []string{"192.0.2.0/24", "198.51.100.0/24", "203.0.113.0/24", "2001:db8::/32"} { + _, n, _ := net.ParseCIDR(cidr) + doc = doc || n.Contains(ip) + } + if !doc { + t.Errorf("%s is not a documentation address", a) + } + } + // The same address is always the same stand-in. + if s.Text("10.42.0.7") != s.Text("at 10.42.0.7")[3:] { + t.Error("one address became two stand-ins") + } +} + +func flatten(v any) string { + var b strings.Builder + var walk func(any) + walk = func(v any) { + switch t := v.(type) { + case map[string]any: + for k, e := range t { + b.WriteString(k + "\"=") + walk(e) + b.WriteString("\n") + } + case []any: + for _, e := range t { + walk(e) + b.WriteString(",") + } + case string: + b.WriteString(t + "\"") + default: + b.WriteString(fmt.Sprint(t)) + } + } + walk(v) + return b.String() +} + +// Two snapshots of one mesh, taken apart, are one content. +func TestASnapshotOfAnUnchangedMeshIsTheSameContentAnotherDay(t *testing.T) { + f := Facts{Format: Format, Taken: time.Now(), Machines: []Machine{{Name: "b"}, {Name: "a"}}} + g := f + g.Taken = f.Taken.Add(24 * time.Hour) + g.Machines = []Machine{{Name: "a"}, {Name: "b"}} + a, err := f.Content() + if err != nil { + t.Fatal(err) + } + b, _ := g.Content() + if a != b { + t.Error("the same mesh a day later reads as a change") + } + g.Machines = append(g.Machines, Machine{Name: "c"}) + if c, _ := g.Content(); c == a { + t.Error("a machine added reads as no change") + } +} + +// A reader refuses a snapshot it cannot read whole. +func TestANewerSnapshotIsRefusedNotHalfRead(t *testing.T) { + if _, err := Decode([]byte(`{"facts": 99}`)); err == nil || !strings.Contains(err.Error(), "newer controller") { + t.Errorf("a newer format read as %v", err) + } + if _, err := Decode([]byte(`{"machines": []}`)); err == nil { + t.Error("a document with no format read as a snapshot") + } + f, err := Decode([]byte(`{"facts": 1, "machines": [{"name": "abc", "length": 3}, {"name": "defgh", "length": 5}]}`)) + if err != nil || f.Longest() != 5 { + t.Errorf("read %+v, %v", f, err) + } +} + +// A path that reads as a key is withheld, and keeps a path's shape: an access or a place must be an +// absolute path, and a bare word left a machine the mesh composes refused where a check raised it. +func TestAWithheldPathStaysAPath(t *testing.T) { + s := NewScrubber() + if got := s.Text("/storage/media/Formula1-Season-2026"); got != "/"+Withheld { + t.Errorf("a path reading as a key became %q", got) + } + if got := s.Text("/storage/media/movies"); got != "/storage/media/movies" { + t.Errorf("a plain path became %q", got) + } + if got := s.Text("Hunter2Hunter2Hunter2Hunter2xx"); got != Withheld { + t.Errorf("a key became %q", got) + } +} + +// **Issue 288**: a repository was kept as the URL it was cloned from, the forge's address with it. +func TestARepositoryIsNamedWithoutTheForge(t *testing.T) { + for in, want := range map[string]string{ + "http://forge.internal:3000/owner/repo.git": "owner/repo", + "ssh://git@forge.internal:222/owner/repo": "owner/repo", + "git@forge.internal:owner/repo.git": "owner/repo", + "owner/repo": "owner/repo", + "": "", + } { + if got := RepositoryName(in); got != want { + t.Errorf("%q is named %q, not %q", in, got, want) + } + } +} diff --git a/go/facts/grants_test.go b/go/facts/grants_test.go new file mode 100644 index 0000000..f9df78a --- /dev/null +++ b/go/facts/grants_test.go @@ -0,0 +1,130 @@ +package facts + +import ( + "reflect" + "strings" + "testing" + "time" +) + +// A snapshot the controller wrote before grants and bindings existed is read as one that has none. The body +// is the format 1 document facts_test.go's TestANewerSnapshotIsRefusedNotHalfRead reads (mesh-controller +// 6c2a8d62, internal/facts/facts_test.go), with the fields of a machine that format 1 wrote. +func TestASnapshotWithoutGrantsOrBindingsReadsAsHavingNone(t *testing.T) { + f, err := Decode([]byte(`{"facts": 1, "machines": [{"name": "abc", "length": 3}, {"name": "defgh", "length": 5, + "pins": [{"provision": "store", "machine": "xyz", "module": "postgres"}]}]}`)) + if err != nil { + t.Fatal(err) + } + for _, m := range f.Machines { + if len(m.Grants) != 0 || len(m.Bindings) != 0 { + t.Errorf("%s reads grants %v and bindings %v from a snapshot that carries none", m.Name, m.Grants, m.Bindings) + } + } + body, err := f.Encode() + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(body), "grants") || strings.Contains(string(body), "bindings") { + t.Errorf("a machine with none wrote them anyway:\n%s", body) + } +} + +// A snapshot that carries them reads back what it carried, and writes the same bytes again. +func TestGrantsAndBindingsRoundTripByteForByte(t *testing.T) { + f := Facts{Format: Format, Taken: time.Date(2026, 10, 20, 8, 0, 0, 0, time.UTC), Machines: []Machine{{ + Name: "abcdef", Length: 6, + Grants: []Grant{ + {Provision: "store", ConsumerNode: "ghijkl", ConsumerModule: "mesh-issues", Local: "database"}, + {Provision: "store", ConsumerNode: "abcdef", ConsumerModule: "records"}, + }, + Bindings: []Binding{ + {ConsumerModule: "records", Provision: "store", ProviderNode: "abcdef", ProviderModule: "postgres"}, + }, + }}} + first, err := f.Encode() + if err != nil { + t.Fatal(err) + } + for _, want := range []string{`"grants"`, `"bindings"`, `"consumer-node": "ghijkl"`, `"consumer-module": "mesh-issues"`, + `"provider-node": "abcdef"`, `"provider-module": "postgres"`, `"local": "database"`} { + if !strings.Contains(string(first), want) { + t.Errorf("the snapshot does not say %s:\n%s", want, first) + } + } + back, err := Decode(first) + if err != nil { + t.Fatal(err) + } + second, err := back.Encode() + if err != nil { + t.Fatal(err) + } + if string(first) != string(second) { + t.Errorf("the snapshot did not round-trip:\nfirst %s\nsecond %s", first, second) + } + m, _ := back.Machine("abcdef") + // Sorted, so two snapshots of one mesh are the same bytes: the grant for the lower node name comes first. + if len(m.Grants) != 2 || m.Grants[0].ConsumerNode != "abcdef" || len(m.Bindings) != 1 { + t.Errorf("read back grants %+v and bindings %+v", m.Grants, m.Bindings) + } +} + +// A machine name in a grant or a binding is scrubbed as a pin's is: to the machine's pseudonym, never kept. +func TestTheScrubberScrubsTheMachineNamesOfGrantsAndBindings(t *testing.T) { + s := NewScrubber() + pin := Pin{Provision: "store", Machine: s.Machine("homeserver"), Module: "postgres"} + grant := s.Grant("store", "laptop2", "mesh-issues", "database") + binding := s.Binding("records", "store", "homeserver", "postgres") + if grant.ConsumerNode != s.Machine("laptop2") || grant.ConsumerNode == "laptop2" { + t.Errorf("the grant's consumer node is %q", grant.ConsumerNode) + } + if binding.ProviderNode != pin.Machine || binding.ProviderNode == "homeserver" { + t.Errorf("the binding's provider node is %q, not the pin's pseudonym %q", binding.ProviderNode, pin.Machine) + } + if grant.Provision != "store" || grant.ConsumerModule != "mesh-issues" || grant.Local != "database" || + binding.ConsumerModule != "records" || binding.Provision != "store" || binding.ProviderModule != "postgres" { + t.Errorf("the scrubber changed what is not a machine's name: %+v %+v", grant, binding) + } + if len(grant.ConsumerNode) != len("laptop2") { + t.Errorf("the pseudonym %q does not keep the length of the name", grant.ConsumerNode) + } +} + +// A grant says that a credential was made and for whom, never the credential: no field of either type is +// a sealed value, a secret or a hash. +func TestNoFieldOfAGrantOrABindingHoldsASecret(t *testing.T) { + for _, typ := range []reflect.Type{reflect.TypeOf(Grant{}), reflect.TypeOf(Binding{})} { + for i := 0; i < typ.NumField(); i++ { + name := typ.Field(i).Name + for _, banned := range []string{"Sealed", "Secret", "Hash", "Password"} { + if strings.Contains(strings.ToLower(name), strings.ToLower(banned)) { + t.Errorf("%s.%s may hold a secret", typ.Name(), name) + } + } + if typ.Field(i).Type.Kind() != reflect.String { + t.Errorf("%s.%s is a %s, not a name", typ.Name(), name, typ.Field(i).Type) + } + } + } +} + +// The format number says from which number a snapshot carries them: a reader of 1 refuses 2 rather than +// judge without them, and a reader of 2 reads 1. +func TestTheFormatSaysFromWhichNumberGrantsAndBindingsAreWritten(t *testing.T) { + if GrantsFrom != 2 { + t.Errorf("grants and bindings are written from format %d, not 2", GrantsFrom) + } + if Format < GrantsFrom { + t.Errorf("the format is %d, below %d, where grants and bindings begin", Format, GrantsFrom) + } + if _, err := Decode([]byte(`{"facts": 2, "machines": []}`)); err != nil { + t.Errorf("format 2 is not read: %v", err) + } + if _, err := Decode([]byte(`{"facts": 1, "machines": []}`)); err != nil { + t.Errorf("format 1 is not read: %v", err) + } + if _, err := Decode([]byte(`{"facts": 3, "machines": []}`)); err == nil { + t.Error("format 3 is read by a reader of format 2") + } +} diff --git a/go/facts/scrub.go b/go/facts/scrub.go new file mode 100644 index 0000000..febc1f5 --- /dev/null +++ b/go/facts/scrub.go @@ -0,0 +1,362 @@ +package facts + +import ( + "crypto/sha256" + "fmt" + "net" + "regexp" + "sort" + "strings" +) + +// Withheld is what a value that reads as a secret becomes. A check composing with it gets a stand-in of +// the right kind (a string), never the value. +const Withheld = "withheld" + +// Pseudonym is the stable stand-in for a name: the same length, letters for letters and digits for +// digits, anything else kept where it was. Stable, so two snapshots of one mesh name a machine alike and +// a check's verdict can be compared across them; derived from the name alone, so it needs no key to be +// kept anywhere. +// +// It hides nothing from somebody who can guess the names: that is not its purpose. Its purpose is that a +// snapshot — and every fixture, replay or pull-request comment made from one — carries no name of the +// installation it came from, while every length a limit meets stays the length it was. +func Pseudonym(kind, name string) string { + sum := sha256.Sum256([]byte("novox-mesh-facts\x00" + kind + "\x00" + name)) + out := []byte(name) + for i, c := range out { + b := sum[i%len(sum)] ^ byte(i/len(sum)) + switch { + case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z': + out[i] = 'a' + b%26 + case c >= '0' && c <= '9': + out[i] = '0' + b%10 + } + } + // A name must still read as one: a machine's begins with a letter. + if len(out) > 0 && (out[0] < 'a' || out[0] > 'z') && name[0] >= 'a' && name[0] <= 'z' { + out[0] = 'a' + sum[0]%26 + } + return string(out) +} + +// Scrubber replaces what a snapshot must not carry, wherever it appears in text. +type Scrubber struct { + // replace is every real word and what it becomes, longest first, so a domain is replaced before a + // label inside it. + replace [][2]string + seen map[string]bool +} + +// NewScrubber knows the installation's names: machines, sites, accounts, public domains. +func NewScrubber() *Scrubber { return &Scrubber{seen: map[string]bool{}} } + +// Machine registers a machine's name and answers its pseudonym. +func (s *Scrubber) Machine(name string) string { return s.word("machine", name) } + +// Grant is a grant with the consumer's machine under its pseudonym, as a pin's machine is: the one place a grant is +// built from a real name. +func (s *Scrubber) Grant(provision, consumerNode, consumerModule, local string) Grant { + return Grant{Provision: provision, ConsumerNode: s.Machine(consumerNode), ConsumerModule: consumerModule, Local: local} +} + +// Binding is a binding with the provider's machine under its pseudonym. +func (s *Scrubber) Binding(consumerModule, provision, providerNode, providerModule string) Binding { + return Binding{ConsumerModule: consumerModule, Provision: provision, ProviderNode: s.Machine(providerNode), ProviderModule: providerModule} +} + +// Site registers a site's name and answers its pseudonym. +func (s *Scrubber) Site(name string) string { return s.word("site", name) } + +// Account registers an account's name and answers its pseudonym. +func (s *Scrubber) Account(name string) string { return s.word("account", name) } + +// Domain registers a public domain and answers its stand-in: each label but the last replaced, so the +// shape and every length stay. +func (s *Scrubber) Domain(domain string) string { + if domain == "" { + return "" + } + labels := strings.Split(domain, ".") + for i := range labels { + if i == len(labels)-1 && len(labels) > 1 { + break + } + labels[i] = Pseudonym("domain", labels[i]) + } + out := strings.Join(labels, ".") + s.add(domain, out) + return out +} + +func (s *Scrubber) word(kind, name string) string { + if name == "" { + return "" + } + out := Pseudonym(kind, name) + s.add(name, out) + return out +} + +func (s *Scrubber) add(from, to string) { + if from == "" || s.seen[from] { + return + } + s.seen[from] = true + s.replace = append(s.replace, [2]string{from, to}) + sort.SliceStable(s.replace, func(i, j int) bool { return len(s.replace[i][0]) > len(s.replace[j][0]) }) +} + +// wordBoundary is what may stand beside a name for it to be that name and not part of another word. +func wordBoundary(c byte) bool { + return !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '_') +} + +// Text is s with every known name replaced, every address put in a documentation range, every address +// of mail replaced, and every run that reads as a secret withheld. +func (s *Scrubber) Text(text string) string { + if text == "" { + return text + } + text = secretRuns(text) + text = emails.ReplaceAllStringFunc(text, func(mail string) string { + if strings.HasPrefix(mail, Withheld+"@") { + return mail // a URL's withheld credentials, not an address of mail + } + return "someone@example.org" + }) + text = addresses(text) + for _, r := range s.replace { + text = replaceWord(text, r[0], r[1]) + } + return text +} + +// replaceWord replaces from where it stands as a word of its own. +func replaceWord(text, from, to string) string { + var b strings.Builder + for { + i := strings.Index(text, from) + if i < 0 { + b.WriteString(text) + return b.String() + } + end := i + len(from) + if (i == 0 || wordBoundary(text[i-1])) && (end == len(text) || wordBoundary(text[end])) { + b.WriteString(text[:i]) + b.WriteString(to) + } else { + b.WriteString(text[:end]) + } + text = text[end:] + } +} + +// Values is a settings layer with every key that names a secret withheld, and every string scrubbed. +func (s *Scrubber) Values(values map[string]any) map[string]any { + out := make(map[string]any, len(values)) + for k, v := range values { + // A key may itself be a name — a map of machines to something. + key := s.Text(k) + if secretKey.MatchString(k) { + out[key] = withheldLike(v) + continue + } + out[key] = s.value(v) + } + return out +} + +func (s *Scrubber) value(v any) any { + switch t := v.(type) { + case string: + return s.Text(t) + case map[string]any: + return s.Values(t) + case []any: + out := make([]any, len(t)) + for i, e := range t { + out[i] = s.value(e) + } + return out + default: + return v + } +} + +// withheldLike is a stand-in of the same kind: a string for a string, a list for a list, so a check +// composing a setting still finds the shape it expects. +func withheldLike(v any) any { + switch t := v.(type) { + case nil: + return nil + case bool, float64, int, int64: + return t + case []any: + out := make([]any, len(t)) + for i, e := range t { + out[i] = withheldLike(e) + } + return out + case map[string]any: + out := map[string]any{} + for k, e := range t { + out[k] = withheldLike(e) + } + return out + default: + return Withheld + } +} + +// secretKey is a setting's key that names a secret. +var secretKey = regexp.MustCompile(`(?i)(secret|passw|token|api[-_]?key|private[-_]?key|credential|bearer|cookie|salt|signing)`) + +// emails are addresses of mail: a person's name, or an installation's domain, in either half. +var emails = regexp.MustCompile(`[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}`) + +// secretRun is a run of characters a key, a token or a hash is made of, long enough to be one. +var secretRun = regexp.MustCompile(`[A-Za-z0-9+/=_\-]{24,}`) + +// userinfo is the credentials part of a URL. +var userinfo = regexp.MustCompile(`(://)[^/@\s:]+:[^/@\s]+@`) + +// secretRuns withholds a URL's credentials and every run that reads as a key: long, and mixing letters +// with digits or symbols. A long plain word (a path, a module's name) is left alone. +func secretRuns(text string) string { + if strings.Contains(text, "-----BEGIN") { + return Withheld + } + text = userinfo.ReplaceAllString(text, "${1}"+Withheld+"@") + var b strings.Builder + last := 0 + for _, at := range secretRun.FindAllStringIndex(text, -1) { + run := text[at[0]:at[1]] + b.WriteString(text[last:at[0]]) + last = at[1] + // A digest names an artifact, not a secret. + if strings.HasSuffix(text[:at[0]], "sha256:") { + b.WriteString(run) + continue + } + judged := judgeRun(run) + // A path withheld keeps a path's shape — an absolute one stays absolute — so a check composing a + // place or an access still finds a path where one must be. + if judged == Withheld && strings.HasPrefix(run, "/") { + judged = "/" + Withheld + } + b.WriteString(judged) + } + b.WriteString(text[last:]) + return b.String() +} + +// judgeRun is a run withheld when it reads as a key: long, and mixing letters with digits or symbols. +func judgeRun(run string) string { + { + var lower, upper, digit, symbol bool + for _, c := range run { + switch { + case c >= 'a' && c <= 'z': + lower = true + case c >= 'A' && c <= 'Z': + upper = true + case c >= '0' && c <= '9': + digit = true + default: + symbol = true + } + } + classes := 0 + for _, b := range []bool{lower, upper, digit, symbol} { + if b { + classes++ + } + } + // A sha256 digest names an artifact, not a secret, and a dashed word is a name. + if strings.HasPrefix(run, "sha256") || (!digit && !upper) { + return run + } + if classes >= 3 || (digit && (lower || upper) && len(run) >= 32) { + return Withheld + } + return run + } +} + +// addresses puts every IP address in text into a documentation range (RFC 5737, RFC 3849): the same +// address always becomes the same stand-in, so two settings naming one machine still name one. +func addresses(text string) string { + var b strings.Builder + i := 0 + for i < len(text) { + if !addressChar(text[i]) { + b.WriteByte(text[i]) + i++ + continue + } + j := i + for j < len(text) && addressChar(text[j]) { + j++ + } + b.WriteString(standIn(text[i:j])) + i = j + } + return b.String() +} + +func addressChar(c byte) bool { + return c >= '0' && c <= '9' || c >= 'a' && c <= 'f' || c >= 'A' && c <= 'F' || c == '.' || c == ':' +} + +// standIn is the documentation address for a run that is an address, or the run itself. +func standIn(run string) string { + trimmed := strings.TrimRight(run, ".:") + tail := run[len(trimmed):] + ip := net.ParseIP(trimmed) + switch { + case ip == nil: + // A port after an IPv4 address reads as part of the run: try without it. + if host, port, ok := strings.Cut(trimmed, ":"); ok && strings.Count(trimmed, ":") == 1 && + net.ParseIP(host) != nil && strings.Contains(host, ".") { + return standIn(host) + ":" + port + tail + } + return run + case ip.To4() != nil && strings.Contains(trimmed, "."): + sum := sha256.Sum256([]byte("v4\x00" + trimmed)) + ranges := []string{"192.0.2", "198.51.100", "203.0.113"} + return fmt.Sprintf("%s.%d", ranges[sum[0]%3], 1+sum[1]%254) + tail + case strings.Count(trimmed, ":") >= 2: + sum := sha256.Sum256([]byte("v6\x00" + trimmed)) + return fmt.Sprintf("2001:db8::%x:%x", uint16(sum[0])<<8|uint16(sum[1]), uint16(sum[2])<<8|uint16(sum[3])) + tail + } + return run +} + +// RepositoryName is a repository as `owner/repository`, without the forge's address it was cloned from: +// http://forge.internal:3000/owner/repo.git → owner/repo (novox/hq issue 288). What a check matches a +// pull request's repository by is its owner and name, never the forge's address, so nothing is lost. +func RepositoryName(repository string) string { + r := strings.TrimSuffix(strings.TrimSuffix(strings.TrimSpace(repository), "/"), ".git") + if r == "" { + return "" + } + if _, rest, found := strings.Cut(r, "://"); found { + r = rest + if _, path, found := strings.Cut(r, "/"); found { + r = path + } else { + return "" + } + } else if at := strings.Index(r, "@"); at >= 0 { + // scp-like: git@forge:owner/repo + if _, path, found := strings.Cut(r[at+1:], ":"); found { + r = path + } + } + parts := strings.Split(strings.Trim(r, "/"), "/") + if len(parts) >= 2 { + return parts[len(parts)-2] + "/" + parts[len(parts)-1] + } + return parts[len(parts)-1] +} diff --git a/go/internal/beside/beside.go b/go/internal/beside/beside.go new file mode 100644 index 0000000..93a0d40 --- /dev/null +++ b/go/internal/beside/beside.go @@ -0,0 +1,129 @@ +// Package beside is where a test finds another repository of the mesh it reads: the catalogue's manifests, +// the node-engine's genesis template (novox/hq issue 432), the SDK's conformance fixtures (issue 449). +// +// Copied from mesh-controller internal/beside at commit 6c2a8d62 (R4b, issue 1379); the one change is that Dir +// lays out the captured copy of a repository once per process, not once per test (see laidOut). In mesh-sdk the +// catalogue's tests read mesh-catalog and mesh-host only; the captured copy holds just what they read. +// +// A test used to read the checkout beside this one, `../../../mesh-catalog`, so its verdict depended on +// whatever sat on the machine running it: a stale or dirty checkout failed it on a desktop, and where none +// was beside it skipped and said nothing. Now there are two inputs, both named, and no third: +// +// - In a merge check, the build seat clones each core repository beside the one checked (the catalogue +// at its main, the node-engine at the commit the mesh runs) and says where in MESH_CHECK_BESIDE. A +// test judges against those clones, so agreement with the other repository is checked where +// `mesh/repo-check` runs; a repository missing there fails the test, never skips it. Which clones a +// check gets is chosen from the inventory: mesh-catalog by the source of the `nats` module, mesh-host +// by the source of `mesh-host`, and mesh-sdk as the controller's sibling at the commit the controller's +// go.mod pins. In a mesh where either module has no source repository nothing is +// cloned, and these tests fail loudly with "not beside this check": a cause in the setup, not in the +// change. And in a delivery group that holds a mesh-catalog pull request, these tests read the +// catalogue's main, not the group's head. +// - Anywhere else, the test judges against the copy captured in this repository's testdata/beside, at the +// commit testdata/beside/CAPTURED names. Never against a developer's own checkout: to judge one, set +// MESH_CHECK_BESIDE to the directory holding it, as the check does. +// +// The captured manifests are named module.json.captured, and put back as module.json in a directory of +// the test's own: a module.json anywhere in this repository is a module of it to the forge's announcer and +// the planner, and a merge would build and register a hundred copies of the catalogue's. +package beside + +import ( + "io/fs" + "os" + "path/filepath" + "runtime" + "strings" + "sync" + "testing" +) + +// Env is where a merge check says the repositories cloned beside the one checked are (internal/builder's +// EnvBeside, repeated here so a test does not import the builder). +const Env = "MESH_CHECK_BESIDE" + +// CapturedSuffix is what a captured file's name carries that the repository's own does not. +const CapturedSuffix = ".captured" + +// Dir is the named repository a test reads — the clone beside a merge check, or the captured copy — and +// says which in the test's log. +func Dir(t testing.TB, repository string) string { + t.Helper() + if root := os.Getenv(Env); root != "" { + dir := filepath.Join(root, repository) + if _, err := os.Stat(dir); err != nil { + t.Fatalf("%s is not beside this check in %s=%s, so its agreement with this repository cannot be "+ + "judged here: %v", repository, Env, root, err) + } + t.Logf("judged against %s as cloned beside this check", dir) + return dir + } + from := filepath.Join(Captured(), repository) + if _, err := os.Stat(from); err != nil { + t.Fatalf("no captured copy of %s at %s: %v", repository, from, err) + } + dir, err := layOut(repository, from) + if err != nil { + t.Fatalf("the captured copy of %s could not be laid out: %v", repository, err) + } + t.Logf("judged against the copy of %s captured in testdata/beside (see CAPTURED); a merge check "+ + "judges it against the repository's own clone", repository) + return dir +} + +// laidOut is the captured copies already put back in place in this process, by repository. A copy is +// read-only to every test, so it is laid out once per process and not once per test: with a copy per test +// the catalogue's tests alone took 2.14 s, over mesh-sdk's unit bound of 2 s, and with one 0.81 s (measured +// 2026-10-20). The directories are in the temp directory, which the system clears; a process cannot remove +// them while another test may still read them. +var ( + laidOutMu sync.Mutex + laidOut = map[string]string{} +) + +func layOut(repository, from string) (string, error) { + laidOutMu.Lock() + defer laidOutMu.Unlock() + if dir, ok := laidOut[repository]; ok { + return dir, nil + } + root, err := os.MkdirTemp("", "beside-") + if err != nil { + return "", err + } + dir := filepath.Join(root, repository) + err = filepath.WalkDir(from, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + rel, _ := filepath.Rel(from, path) + into := filepath.Join(dir, strings.TrimSuffix(rel, CapturedSuffix)) + if d.IsDir() { + return os.MkdirAll(into, 0o755) + } + raw, err := os.ReadFile(path) + if err != nil { + return err + } + return os.WriteFile(into, raw, 0o644) + }) + if err != nil { + os.RemoveAll(root) + return "", err + } + laidOut[repository] = dir + return dir, nil +} + +// Catalogue is the catalogue's modules directory, as Dir finds the catalogue. +func Catalogue(t testing.TB) string { + t.Helper() + return filepath.Join(Dir(t, "mesh-catalog"), "modules") +} + +// Captured is this repository's testdata/beside, found from this file rather than from the working +// directory, so a test in any package reaches it. +func Captured() string { + _, file, _, _ := runtime.Caller(0) + return filepath.Join(filepath.Dir(file), "..", "..", "testdata", "beside") +} diff --git a/go/internal/beside/beside_test.go b/go/internal/beside/beside_test.go new file mode 100644 index 0000000..47655eb --- /dev/null +++ b/go/internal/beside/beside_test.go @@ -0,0 +1,62 @@ +package beside + +import ( + "fmt" + "os" + "path/filepath" + "testing" +) + +// The captured copy of a repository is laid out once per test process: a second call, in another test, +// gets the same directory (issue 1379, AC3). Laying it out per test made go/catalogue's tests take 2.14 s +// against the unit level's 2 s bound; once, 0.81 s. +func TestTheCapturedCopyIsLaidOutOnce(t *testing.T) { + t.Setenv(Env, "") + var first, second string + t.Run("one", func(t *testing.T) { first = Dir(t, "mesh-catalog") }) + t.Run("two", func(t *testing.T) { second = Dir(t, "mesh-catalog") }) + if first == "" || first != second { + t.Fatalf("the captured copy was laid out twice: %q and %q", first, second) + } + if _, err := os.Stat(filepath.Join(first, "modules")); err != nil { + t.Fatalf("the copy holds no modules directory: %v", err) + } + // And it outlives the test that first asked, which a t.TempDir would not. + if _, err := os.Stat(first); err != nil { + t.Fatalf("the copy is gone after the first test ended: %v", err) + } +} + +// With MESH_CHECK_BESIDE naming a directory that lacks the repository, the test fails naming it (AC4; the +// rule is beside's own, unchanged). Dir fails the test it is given, so it is given a recorder. +func TestABesideDirectoryWithoutTheRepositoryFailsNamingIt(t *testing.T) { + empty := t.TempDir() + t.Setenv(Env, empty) + r := &recorder{TB: t} + func() { + defer func() { _ = recover() }() + Dir(r, "mesh-catalog") + }() + if !r.failed { + t.Fatal("a repository missing beside the check did not fail the test") + } + if want := "mesh-catalog is not beside this check in " + Env + "=" + empty; len(r.msg) < len(want) || r.msg[:len(want)] != want { + t.Fatalf("the failure does not name the repository and the directory: %q", r.msg) + } +} + +type recorder struct { + testing.TB + failed bool + msg string +} + +func (r *recorder) Fatalf(format string, args ...any) { + r.failed = true + r.msg = sprintf(format, args...) + panic(stop{}) +} + +type stop struct{} + +func sprintf(format string, args ...any) string { return fmt.Sprintf(format, args...) } diff --git a/go/testdata/beside/CAPTURED b/go/testdata/beside/CAPTURED new file mode 100644 index 0000000..dc56ca5 --- /dev/null +++ b/go/testdata/beside/CAPTURED @@ -0,0 +1,27 @@ +What a test reads of another repository when no merge check has cloned it beside this one (internal/beside, +novox/hq issue 432). Copied from the repositories at the commits below, never written by hand. Each +module.json is kept as module.json.captured: a module.json in this repository is a module of it to the +forge and the planner, and a merge would build and register it. + +The copy carries the catalogue's private details: domains, first names in module names, a node name and +private addresses. That is acceptable while mesh-sdk is private; if it ever goes public, this copy is +a second place to clean besides the catalogue itself. + +Only what go/catalogue's tests read is captured (R4b, issue 1379). Refreshed 2026-10-20 from the mains of both +repositories; before that it was mesh-controller's copy, which still held two modules that had left the catalogue. + +mesh-catalog bb0fe93bbdddd869ad808210352461efc506c3d3 modules/*/module.json, modules/*/files/**, modules/*/templates/**, predefined.json +mesh-host cdcba9ca73755854d21e4e657d7e46c32b9b5265 internal/apply/placement_guard.go + +To move them, from this repository's go/ directory, with the two repositories checked out beside mesh-sdk: + + rm -rf testdata/beside/mesh-catalog testdata/beside/mesh-host + mkdir -p testdata/beside/mesh-catalog testdata/beside/mesh-host/internal/apply + git -C ../../mesh-catalog archive -- predefined.json ':(glob)modules/*/module.json' \ + ':(glob)modules/*/files/**' ':(glob)modules/*/templates/**' | tar -x -C testdata/beside/mesh-catalog + find testdata/beside -name module.json -exec mv {} {}.captured \; + git -C ../../mesh-host show :internal/apply/placement_guard.go \ + > testdata/beside/mesh-host/internal/apply/placement_guard.go.captured + +and write the commits here. The placement guard is kept as .captured so no Go tool reads it as this +repository's code. diff --git a/go/testdata/beside/mesh-catalog/modules/adwaita/files/cursor-index.theme b/go/testdata/beside/mesh-catalog/modules/adwaita/files/cursor-index.theme new file mode 100644 index 0000000..d848240 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/adwaita/files/cursor-index.theme @@ -0,0 +1,5 @@ +# Written by the mesh (module adwaita, novox/hq ADR 0208): the default cursor theme, for programs that +# read neither XCURSOR_THEME nor the X resources. +[Icon Theme] +Name=Default +Inherits=Adwaita diff --git a/go/testdata/beside/mesh-catalog/modules/adwaita/files/env-gtk-theme b/go/testdata/beside/mesh-catalog/modules/adwaita/files/env-gtk-theme new file mode 100644 index 0000000..b98c809 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/adwaita/files/env-gtk-theme @@ -0,0 +1 @@ +Adwaita:dark diff --git a/go/testdata/beside/mesh-catalog/modules/adwaita/files/env-gtk2-rc-files b/go/testdata/beside/mesh-catalog/modules/adwaita/files/env-gtk2-rc-files new file mode 100644 index 0000000..8a782fb --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/adwaita/files/env-gtk2-rc-files @@ -0,0 +1 @@ +/usr/share/themes/Adwaita-dark/gtk-2.0/gtkrc diff --git a/go/testdata/beside/mesh-catalog/modules/adwaita/files/env-qt-qpa-platformtheme b/go/testdata/beside/mesh-catalog/modules/adwaita/files/env-qt-qpa-platformtheme new file mode 100644 index 0000000..349e5d6 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/adwaita/files/env-qt-qpa-platformtheme @@ -0,0 +1 @@ +qt6ct diff --git a/go/testdata/beside/mesh-catalog/modules/adwaita/files/env-qt-select b/go/testdata/beside/mesh-catalog/modules/adwaita/files/env-qt-select new file mode 100644 index 0000000..1e8b314 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/adwaita/files/env-qt-select @@ -0,0 +1 @@ +6 diff --git a/go/testdata/beside/mesh-catalog/modules/adwaita/files/env-qt-style-override b/go/testdata/beside/mesh-catalog/modules/adwaita/files/env-qt-style-override new file mode 100644 index 0000000..a0da7db --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/adwaita/files/env-qt-style-override @@ -0,0 +1 @@ +Fusion diff --git a/go/testdata/beside/mesh-catalog/modules/adwaita/files/env-xcursor-size b/go/testdata/beside/mesh-catalog/modules/adwaita/files/env-xcursor-size new file mode 100644 index 0000000..a45fd52 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/adwaita/files/env-xcursor-size @@ -0,0 +1 @@ +24 diff --git a/go/testdata/beside/mesh-catalog/modules/adwaita/files/env-xcursor-theme b/go/testdata/beside/mesh-catalog/modules/adwaita/files/env-xcursor-theme new file mode 100644 index 0000000..cb9676b --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/adwaita/files/env-xcursor-theme @@ -0,0 +1 @@ +Adwaita diff --git a/go/testdata/beside/mesh-catalog/modules/adwaita/files/gtk-settings.ini b/go/testdata/beside/mesh-catalog/modules/adwaita/files/gtk-settings.ini new file mode 100644 index 0000000..a535ba4 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/adwaita/files/gtk-settings.ini @@ -0,0 +1,9 @@ +# Written by the mesh (module adwaita, novox/hq ADR 0208), for GTK 3 and GTK 4 alike. Replaced at +# every push; adwaita_appearance switches dark and light for the running session. +[Settings] +gtk-theme-name=Adwaita +gtk-icon-theme-name=Adwaita +gtk-cursor-theme-name=Adwaita +gtk-cursor-theme-size=24 +gtk-font-name=Inter 11 +gtk-application-prefer-dark-theme=1 diff --git a/go/testdata/beside/mesh-catalog/modules/adwaita/files/portals.conf b/go/testdata/beside/mesh-catalog/modules/adwaita/files/portals.conf new file mode 100644 index 0000000..d2e813f --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/adwaita/files/portals.conf @@ -0,0 +1,11 @@ +# Written by the mesh (module adwaita, novox/hq ADR 0208). Replaced at every push. +# +# Which portal backend answers each interface. i3 is not a desktop xdg-desktop-portal knows, so with +# no preference it uses whichever backend happens to be installed: fine while gtk is the only one, +# wrong the day another arrives as somebody else's dependency. Named instead. gtk also serves +# org.freedesktop.appearance (dark or light) from GSettings, which the session's start sets. +[preferred] +default=gtk +# Secrets for sandboxed programs come from the keyring's backend. Without this line no backend answers +# the interface: gtk does not implement it, and gnome-keyring's names only GNOME as its desktop. +org.freedesktop.impl.portal.Secret=gnome-keyring diff --git a/go/testdata/beside/mesh-catalog/modules/adwaita/files/qt6ct.conf b/go/testdata/beside/mesh-catalog/modules/adwaita/files/qt6ct.conf new file mode 100644 index 0000000..7f396d2 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/adwaita/files/qt6ct.conf @@ -0,0 +1,29 @@ +[Appearance] +color_scheme_path=/usr/share/qt6ct/colors/darker.conf +custom_palette=true +icon_theme=Adwaita +standard_dialogs=default +style=Fusion + +[Fonts] +fixed="JetBrainsMono Nerd Font,11,-1,5,50,0,0,0,0,0" +general="Inter,11,-1,5,50,0,0,0,0,0" + +[Interface] +activate_item_on_single_click=1 +buttonbox_layout=0 +cursor_flash_time=1000 +dialog_buttons_have_icons=1 +double_click_interval=400 +gui_effects=@Invalid() +keyboard_scheme=2 +menus_have_icons=true +show_shortcuts_in_context_menus=true +stylesheets=@Invalid() +toolbutton_style=4 +underline_shortcut=1 +wheel_scroll_lines=3 + +[Troubleshooting] +force_raster_widgets=1 +ignored_applications=@Invalid() diff --git a/go/testdata/beside/mesh-catalog/modules/adwaita/files/xinitrc-normal b/go/testdata/beside/mesh-catalog/modules/adwaita/files/xinitrc-normal new file mode 100644 index 0000000..864b3cf --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/adwaita/files/xinitrc-normal @@ -0,0 +1,10 @@ +# 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 diff --git a/go/testdata/beside/mesh-catalog/modules/adwaita/files/xresources-normal b/go/testdata/beside/mesh-catalog/modules/adwaita/files/xresources-normal new file mode 100644 index 0000000..02e7ec3 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/adwaita/files/xresources-normal @@ -0,0 +1,3 @@ +! adwaita: the cursor, for X programs that take it from the resources. +Xcursor.theme: Adwaita +Xcursor.size: 24 diff --git a/go/testdata/beside/mesh-catalog/modules/adwaita/module.json.captured b/go/testdata/beside/mesh-catalog/modules/adwaita/module.json.captured new file mode 100644 index 0000000..bf81c83 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/adwaita/module.json.captured @@ -0,0 +1,176 @@ +{ + "module": "adwaita", + "version": "1", + "moved": true, + "capabilities": [ + "package-manager" + ], + "tools": [ + "adwaita_appearance", + "adwaita_cursor", + "adwaita_icons", + "adwaita_portal_check" + ], + "items": [ + { + "collection": "environment", + "name": "gtk-theme", + "attributes": { + "kind": "variable", + "name": "GTK_THEME" + }, + "template": "files/env-gtk-theme" + }, + { + "collection": "environment", + "name": "gtk2-rc-files", + "attributes": { + "kind": "variable", + "name": "GTK2_RC_FILES" + }, + "template": "files/env-gtk2-rc-files" + }, + { + "collection": "environment", + "name": "qt-qpa-platformtheme", + "attributes": { + "kind": "variable", + "name": "QT_QPA_PLATFORMTHEME" + }, + "template": "files/env-qt-qpa-platformtheme" + }, + { + "collection": "environment", + "name": "qt-style-override", + "attributes": { + "kind": "variable", + "name": "QT_STYLE_OVERRIDE" + }, + "template": "files/env-qt-style-override" + }, + { + "collection": "environment", + "name": "qt-select", + "attributes": { + "kind": "variable", + "name": "QT_SELECT" + }, + "template": "files/env-qt-select" + }, + { + "collection": "environment", + "name": "xcursor-theme", + "attributes": { + "kind": "variable", + "name": "XCURSOR_THEME" + }, + "template": "files/env-xcursor-theme" + }, + { + "collection": "environment", + "name": "xcursor-size", + "attributes": { + "kind": "variable", + "name": "XCURSOR_SIZE" + }, + "template": "files/env-xcursor-size" + }, + { + "collection": "shell:xresources:normal", + "name": "xresources-normal", + "template": "files/xresources-normal" + }, + { + "collection": "shell:xinitrc:normal", + "name": "xinitrc-normal", + "template": "files/xinitrc-normal" + } + ], + "resources": [ + { + "id": "package-gnome-themes-extra", + "type": "package", + "package": "gnome-themes-extra" + }, + { + "id": "package-adwaita-icon-theme", + "type": "package", + "package": "adwaita-icon-theme" + }, + { + "id": "package-adwaita-cursors", + "type": "package", + "package": "adwaita-cursors" + }, + { + "id": "package-qt6ct", + "type": "package", + "package": "qt6ct" + }, + { + "id": "package-xdg-desktop-portal-gtk", + "type": "package", + "package": "xdg-desktop-portal-gtk" + }, + { + "id": "gtk3", + "type": "file", + "path": "${machine:account-home}/.config/gtk-3.0/settings.ini", + "owner": "${machine:account}", + "mode": "0644", + "template": "files/gtk-settings.ini" + }, + { + "id": "gtk4", + "type": "file", + "path": "${machine:account-home}/.config/gtk-4.0/settings.ini", + "owner": "${machine:account}", + "mode": "0644", + "template": "files/gtk-settings.ini" + }, + { + "id": "qt6ct", + "type": "file", + "path": "${machine:account-home}/.config/qt6ct/qt6ct.conf", + "owner": "${machine:account}", + "mode": "0644", + "template": "files/qt6ct.conf" + }, + { + "id": "portals", + "type": "file", + "path": "${machine:account-home}/.config/xdg-desktop-portal/portals.conf", + "owner": "${machine:account}", + "mode": "0644", + "template": "files/portals.conf" + }, + { + "id": "cursor", + "type": "file", + "path": "${machine:account-home}/.icons/default/index.theme", + "owner": "${machine:account}", + "mode": "0644", + "template": "files/cursor-index.theme" + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/adwaita-tools", + "binary": "adwaita-tools", + "loads": [ + "adwaita-tools" + ] + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/go/testdata/beside/mesh-catalog/modules/artifact-store-tools/files/store-names b/go/testdata/beside/mesh-catalog/modules/artifact-store-tools/files/store-names new file mode 100644 index 0000000..29cab31 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/artifact-store-tools/files/store-names @@ -0,0 +1,4 @@ +# The names the artifact store had before, from the setting older-store-names, written by the mesh. +# artifact_store_references reads every definition under these too (novox/hq issue 323). +# Replaced on every push; change the setting, never this file. +older=${config:older-store-names} diff --git a/go/testdata/beside/mesh-catalog/modules/artifact-store-tools/module.json.captured b/go/testdata/beside/mesh-catalog/modules/artifact-store-tools/module.json.captured new file mode 100644 index 0000000..208329c --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/artifact-store-tools/module.json.captured @@ -0,0 +1,65 @@ +{ + "module": "artifact-store-tools", + "version": "1", + "invokes": [ + "seat:mesh-controller.artifacts", + "seat:mesh-controller.collect", + "mesh-catalog.catalog_modules", + "mesh-catalog.catalog_module" + ], + "tools": [ + "artifact_store_repositories", + "artifact_store_usage", + "artifact_store_references", + "artifact_store_collect" + ], + "config": { + "older-store-names": { + "type": "string", + "description": "The names the artifact store had before, as host names or host:port separated by spaces, or none when it had no other. artifact_store_references reads every definition under these as well as under the store's current name, so an image a definition pins under one is said to be named by a definition (novox/hq issue 323).", + "pattern": "none|[A-Za-z0-9.:-]+( [A-Za-z0-9.:-]+)*", + "default": "none" + } + }, + "resources": [ + { + "id": "state", + "type": "directory", + "mode": "0700", + "place": ".", + "unknown": "program" + }, + { + "id": "store-names", + "type": "file", + "path": "${dir:state}/store-names", + "mode": "0600", + "trusted": false, + "template": "files/store-names" + } + ], + "build": { + "artifacts": [ + { + "name": "tools-go", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/artifact-store-tools", + "binary": "artifact-store-tools", + "loads": [ + "artifact-store-tools" + ], + "env": { + "MESH_ARTIFACT_STORE_CONTAINER": "mesh-registry", + "MESH_ARTIFACT_STORE_NAMES_FILE": "${dir:state}/store-names" + } + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/go/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/files/bin/zephyrus-backlight b/go/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/files/bin/zephyrus-backlight new file mode 100644 index 0000000..1a3ec31 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/files/bin/zephyrus-backlight @@ -0,0 +1,33 @@ +#!/bin/bash +# zephyrus-backlight + | - | PERCENT — step or set the internal panel's backlight, never below 1 %. +# Shipped by the mesh's asus-zephyrus-g14 module; edit the catalogue. +# +# The panel is the backlight beneath the eDP connector, not a name: in hybrid mode this model also +# registers the discrete GPU's backlight (nvidia_0), which moves nothing. Writable by the video group +# through the module's udev rule, so the vendor-key trigger needs no root. +set -u +STEP=5 +dev="" +for d in /sys/class/backlight/*; do + [ -e "$d" ] || continue + case "$(readlink -f "$d")" in *-eDP-*) dev=$d; break ;; esac +done +if [ -z "$dev" ]; then + for d in /sys/class/backlight/*; do [ -e "$d" ] && { dev=$d; break; }; done +fi +[ -n "$dev" ] || { echo "zephyrus-backlight: no backlight" >&2; exit 1; } +cur=$(cat "$dev/brightness") +max=$(cat "$dev/max_brightness") +pct=$(( cur * 100 / max )) +case "${1:-}" in + +|up|Up) pct=$(( pct + STEP )) ;; + -|down|Down) pct=$(( pct - STEP )) ;; + ''|*[!0-9]*) echo "usage: zephyrus-backlight + | - | PERCENT" >&2; exit 2 ;; + *) pct=$1 ;; +esac +(( pct < 1 )) && pct=1 +(( pct > 100 )) && pct=100 +new=$(( max * pct / 100 )) +(( new < 1 )) && new=1 +printf '%s' "$new" >"$dev/brightness" || exit 1 +exec "$(dirname "$0")/zephyrus-notify" 5555 "Brightness: ${pct}%" diff --git a/go/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/files/bin/zephyrus-display b/go/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/files/bin/zephyrus-display new file mode 100644 index 0000000..0b7d21b --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/files/bin/zephyrus-display @@ -0,0 +1,41 @@ +#!/bin/bash +# zephyrus-display primary | order — the laptop's internal panel among the outputs. +# Shipped by the mesh's asus-zephyrus-g14 module; edit the catalogue. +# +# primary makes the internal panel X's primary output, where the bars' tray goes. +# order the display key (Fn+F9, which the firmware sends as Super+P): odd workspaces to the +# internal panel, even ones to the first external output, or all to the panel when it +# is alone. The predecessor's orden-workspaces. +# +# The panel is found, not named: the connected output whose name starts with eDP. The predecessor +# wrote eDP-1, which is what this model calls it today and not a promise. +set -u +here=$(dirname "$0") +panel=$(xrandr --query 2>/dev/null | awk '$2 == "connected" && $1 ~ /^eDP/ { print $1; exit }') +[ -n "$panel" ] || { echo "zephyrus-display: no internal panel connected" >&2; exit 1; } +case "${1:-}" in +primary) + exec xrandr --output "$panel" --primary + ;; +order) + external=$(xrandr --listmonitors 2>/dev/null | awk -v p="$panel" 'NR > 1 && $NF != p { print $NF; exit }') + [ -n "$external" ] || external=$panel + # i3's workspace list, one object per workspace; num and output read from each. No jq: the + # fields are flat strings and numbers, and rect, the only nested value, holds neither name. + i3-msg -t get_workspaces 2>/dev/null | sed 's/},{"id"/}\n{"id"/g' | + while read -r ws; do + num=$(printf '%s' "$ws" | grep -o '"num":-\?[0-9]*' | cut -d: -f2) + out=$(printf '%s' "$ws" | grep -o '"output":"[^"]*"' | cut -d'"' -f4) + [ -n "$num" ] && [ "$num" -ge 0 ] || continue + if [ $((num % 2)) -eq 0 ]; then dest=$external; else dest=$panel; fi + [ "$out" = "$dest" ] && continue + i3-msg "workspace number $num; move workspace to output $dest" >/dev/null + done + if [ "$external" = "$panel" ]; then + "$here/zephyrus-notify" 7780 "Workspaces: all on the panel" + else + "$here/zephyrus-notify" 7780 "Workspaces: odd on the panel, even on $external" + fi + ;; +*) echo "usage: zephyrus-display primary | order" >&2; exit 2 ;; +esac diff --git a/go/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/files/bin/zephyrus-kbd-notify b/go/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/files/bin/zephyrus-kbd-notify new file mode 100644 index 0000000..aee878b --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/files/bin/zephyrus-kbd-notify @@ -0,0 +1,20 @@ +#!/bin/bash +# zephyrus-kbd-notify — shows the keyboard backlight's level when it changes. The level itself is set +# by the firmware and asusd (Fn+F2/F3), which tell UPower; this only listens and notifies. Started once +# per session from the module's i3 fragment. Shipped by the mesh's asus-zephyrus-g14 module. +# +# One instance per session: i3 runs its `exec` lines again on an in-place restart, and the +# predecessor's listener ran twice after one, showing every change twice. +set -u +here=$(dirname "$0") +lock="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/zephyrus-kbd-notify.lock" +exec 9>"$lock" +flock -n 9 || exit 0 +levels=(Off Low Med High) +dbus-monitor --system "type='signal',interface='org.freedesktop.UPower.KbdBacklight',member='BrightnessChanged'" 2>/dev/null | + while read -r line; do + if [[ $line =~ int32\ ([0-9]+) ]]; then + v=${BASH_REMATCH[1]} + "$here/zephyrus-notify" 5556 "Keyboard: ${levels[$v]:-$v}" + fi + done diff --git a/go/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/files/bin/zephyrus-media b/go/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/files/bin/zephyrus-media new file mode 100644 index 0000000..8b77d79 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/files/bin/zephyrus-media @@ -0,0 +1,27 @@ +#!/bin/bash +# zephyrus-media play-pause | next | previous — the media keys, through MPRIS (playerctl), with a short +# notification of what happened. Shipped by the mesh's asus-zephyrus-g14 module; edit the catalogue. +# +# From the predecessor's media-control, kept: the lock against a double fire (the vendor keys can +# repeat), and the notification. Dropped: its fallback to a media server's local API, which needed a +# token from a file of secrets (novox/hq research 027 question 2). A player that speaks MPRIS is +# reached; one that does not says so. +set -u +here=$(dirname "$0") +action=${1:-play-pause} +case "$action" in play-pause | next | previous) ;; *) echo "usage: zephyrus-media play-pause | next | previous" >&2; exit 2 ;; esac +lock="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/zephyrus-media.lock" +exec 9>"$lock" +flock -n 9 || exit 0 +if ! playerctl status >/dev/null 2>&1; then + "$here/zephyrus-notify" 7777 "Media: no player" + exit 0 +fi +playerctl "$action" 2>/dev/null +if [ "$action" = play-pause ]; then + sleep 0.2 + [ "$(playerctl status 2>/dev/null)" = Playing ] && label=Playing || label=Paused +else + label=${action^} +fi +"$here/zephyrus-notify" 7777 "Media: $label" diff --git a/go/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/files/bin/zephyrus-notify b/go/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/files/bin/zephyrus-notify new file mode 100644 index 0000000..1e8273f --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/files/bin/zephyrus-notify @@ -0,0 +1,12 @@ +#!/bin/bash +# zephyrus-notify ID SUMMARY — a short desktop notification that replaces the previous one with the +# same ID, through the session's notification service on its bus. busctl is the service manager's +# own client, so nothing is installed for it. Shipped by the mesh's asus-zephyrus-g14 module. +set -u +id=${1:-0} +summary=${2:-} +uid=$(id -u) +DBUS_SESSION_BUS_ADDRESS="unix:path=/run/user/${uid}/bus" \ + busctl --user call org.freedesktop.Notifications /org/freedesktop/Notifications \ + org.freedesktop.Notifications Notify susssasa{sv}i \ + asus-zephyrus-g14 "$id" "" "$summary" "" 0 1 urgency y 0 1500 >/dev/null 2>&1 || true diff --git a/go/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/files/bin/zephyrus-session b/go/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/files/bin/zephyrus-session new file mode 100644 index 0000000..0f21f91 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/files/bin/zephyrus-session @@ -0,0 +1,46 @@ +#!/bin/bash +# zephyrus-session COMMAND [ARG...] — run a command in the operator's graphical session from outside +# it: from a vendor-key trigger, which triggerhappy runs as the operator's account but with none of +# the session's environment. Shipped by the mesh's asus-zephyrus-g14 module; edit the catalogue. +# +# What it replaces: the predecessor's `as-user`, which triggerhappy ran as root and which `su`-ed to +# a named person with a hard-coded user id and display, and sourced a file of secrets on the way. +# Here the account is whoever runs it, the bus is that account's, and the display is the one the +# account's own session uses. Nothing is sourced. +set -u +uid=$(id -u) +export XDG_RUNTIME_DIR="/run/user/${uid}" +export DBUS_SESSION_BUS_ADDRESS="unix:path=${XDG_RUNTIME_DIR}/bus" +home=$(getent passwd "$uid" | cut -d: -f6) +[ -n "$home" ] && export HOME="$home" +# The session's own window manager says best which display and authority the session uses: read +# them from its environment, as the desktop modules' session finder does. Then logind, then any +# process of this account that has a display. +from_environ() { + env=$(tr '\0' '\n' <"/proc/$1/environ" 2>/dev/null) || return 1 + d=$(printf '%s\n' "$env" | sed -n 's/^DISPLAY=//p' | head -n1) + [ -n "$d" ] || return 1 + export DISPLAY="$d" + a=$(printf '%s\n' "$env" | sed -n 's/^XAUTHORITY=//p' | head -n1) + [ -n "$a" ] && export XAUTHORITY="$a" + return 0 +} +if [ -z "${DISPLAY:-}" ]; then + for pid in $(pgrep -xu "$uid" i3 2>/dev/null); do + from_environ "$pid" && break + done +fi +if [ -z "${DISPLAY:-}" ]; then + for s in $(loginctl list-sessions --no-legend 2>/dev/null | awk -v u="$uid" '$2 == u { print $1 }'); do + d=$(loginctl show-session "$s" -p Display --value 2>/dev/null) + if [ -n "$d" ]; then export DISPLAY="$d"; break; fi + done +fi +if [ -z "${DISPLAY:-}" ]; then + for pid in $(pgrep -u "$uid" 2>/dev/null); do + from_environ "$pid" && break + done +fi +: "${XAUTHORITY:=${HOME}/.Xauthority}" +export XAUTHORITY +exec "$@" diff --git a/go/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/files/bin/zephyrus-touchpad b/go/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/files/bin/zephyrus-touchpad new file mode 100644 index 0000000..637be36 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/files/bin/zephyrus-touchpad @@ -0,0 +1,29 @@ +#!/bin/bash +# zephyrus-touchpad reset | toggle — apply the touchpad's settings again, or switch it on or off. +# Shipped by the mesh's asus-zephyrus-g14 module; edit the catalogue. +# +# The settings themselves are an X input class (/etc/X11/xorg.conf.d/30-asus-zephyrus-g14-touchpad.conf), +# which X applies every time the device appears — after a resume too, which is what the predecessor's +# sleep hook existed for. This is the manual form, bound to the touchpad key. +set -u +here=$(dirname "$0") +name=$("$here/zephyrus-session" xinput list --name-only 2>/dev/null | grep -m1 -i 'touchpad') +[ -n "$name" ] || { echo "zephyrus-touchpad: no touchpad in this session" >&2; exit 1; } +x() { "$here/zephyrus-session" xinput "$@"; } +case "${1:-reset}" in + reset) + x set-prop "$name" "libinput Tapping Enabled" 1 + x set-prop "$name" "libinput Natural Scrolling Enabled" 1 + x set-prop "$name" "libinput Accel Speed" 0.15 + x enable "$name" + "$here/zephyrus-session" "$here/zephyrus-notify" 7779 "Touchpad: reset" + ;; + toggle) + if x list-props "$name" | grep -q 'Device Enabled ([0-9]*):[[:space:]]*1'; then + x disable "$name"; "$here/zephyrus-session" "$here/zephyrus-notify" 7779 "Touchpad: off" + else + x enable "$name"; "$here/zephyrus-session" "$here/zephyrus-notify" 7779 "Touchpad: on" + fi + ;; + *) echo "usage: zephyrus-touchpad reset | toggle" >&2; exit 2 ;; +esac diff --git a/go/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/module.json.captured b/go/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/module.json.captured new file mode 100644 index 0000000..d1ee8d3 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/module.json.captured @@ -0,0 +1,229 @@ +{ + "module": "asus-zephyrus-g14", + "version": "1", + "moved": true, + "templates": "templates", + "capabilities": [ + "package-manager", + "service-manager" + ], + "requires": [ + "x11-display" + ], + "emits": [ + "profile.switched" + ], + "tools": [ + "zephyrus_brightness", + "zephyrus_battery", + "zephyrus_charge_limit", + "zephyrus_gpu_mode", + "zephyrus_profile", + "zephyrus_thermals", + "zephyrus_power_draw", + "zephyrus_profile_policy", + "zephyrus_fan_curves", + "zephyrus_keys", + "zephyrus_check" + ], + "resources": [ + { + "id": "asusctl", + "type": "package", + "package": "asusctl" + }, + { + "id": "playerctl", + "type": "package", + "package": "playerctl" + }, + { + "id": "asusd", + "type": "service", + "unit": "asusd.service", + "state": "running" + }, + { + "id": "supergfxd", + "type": "service", + "unit": "supergfxd.service", + "state": "running", + "boot": "enabled" + }, + { + "id": "scripts", + "type": "archive", + "path": "/usr/local/lib/asus-zephyrus-g14", + "artifact": "scripts", + "unknown": "program" + }, + { + "id": "nvidia-options", + "type": "file", + "path": "/etc/modprobe.d/g14-nvidia-power.conf", + "mode": "0644", + "template": "templates/modprobe.d/g14-nvidia-power.conf" + }, + { + "id": "video-options", + "type": "file", + "path": "/etc/modprobe.d/video-brightness-switch.conf", + "mode": "0644", + "template": "templates/modprobe.d/video-brightness-switch.conf" + }, + { + "id": "suspend-drop-ins", + "type": "directory", + "path": "/etc/systemd/system/systemd-suspend.service.d", + "mode": "0755", + "unknown": "program" + }, + { + "id": "nvidia-on-suspend", + "type": "file", + "path": "/etc/systemd/system/systemd-suspend.service.d/asus-zephyrus-g14-nvidia.conf", + "mode": "0644", + "template": "templates/systemd/system/systemd-suspend.service.d/asus-zephyrus-g14-nvidia.conf" + }, + { + "id": "hibernate-drop-ins", + "type": "directory", + "path": "/etc/systemd/system/systemd-hibernate.service.d", + "mode": "0755", + "unknown": "program" + }, + { + "id": "nvidia-on-hibernate", + "type": "file", + "path": "/etc/systemd/system/systemd-hibernate.service.d/asus-zephyrus-g14-nvidia.conf", + "mode": "0644", + "template": "templates/systemd/system/systemd-hibernate.service.d/asus-zephyrus-g14-nvidia.conf" + }, + { + "id": "suspend-then-hibernate-drop-ins", + "type": "directory", + "path": "/etc/systemd/system/systemd-suspend-then-hibernate.service.d", + "mode": "0755", + "unknown": "program" + }, + { + "id": "nvidia-on-suspend-then-hibernate", + "type": "file", + "path": "/etc/systemd/system/systemd-suspend-then-hibernate.service.d/asus-zephyrus-g14-nvidia.conf", + "mode": "0644", + "template": "templates/systemd/system/systemd-suspend-then-hibernate.service.d/asus-zephyrus-g14-nvidia.conf" + }, + { + "id": "powerd-drop-ins", + "type": "directory", + "path": "/etc/systemd/system/nvidia-powerd.service.d", + "mode": "0755", + "unknown": "program" + }, + { + "id": "powerd-opt-in", + "type": "file", + "path": "/etc/systemd/system/nvidia-powerd.service.d/asus-zephyrus-g14.conf", + "mode": "0644", + "template": "templates/systemd/system/nvidia-powerd.service.d/asus-zephyrus-g14.conf" + }, + { + "id": "backlight-rule", + "type": "file", + "path": "/etc/udev/rules.d/90-backlight.rules", + "mode": "0644", + "template": "templates/udev/rules.d/90-backlight.rules" + }, + { + "id": "udev", + "type": "service", + "unit": "systemd-udevd.service", + "reload-on": [ + "backlight-rule" + ] + }, + { + "id": "upower-package", + "type": "package", + "package": "upower" + }, + { + "id": "upower-drop-ins", + "type": "directory", + "path": "/etc/UPower/UPower.conf.d", + "mode": "0755", + "unknown": "program" + }, + { + "id": "low-battery", + "type": "file", + "path": "/etc/UPower/UPower.conf.d/50-asus-zephyrus-g14.conf", + "mode": "0644", + "template": "templates/UPower/UPower.conf.d/50-asus-zephyrus-g14.conf" + }, + { + "id": "upower", + "type": "service", + "unit": "upower.service", + "state": "running", + "boot": "enabled", + "restart-on": [ + "low-battery" + ] + }, + { + "id": "xorg-drop-ins", + "type": "directory", + "path": "/etc/X11/xorg.conf.d", + "mode": "0755", + "unknown": "program" + }, + { + "id": "touchpad", + "type": "file", + "path": "/etc/X11/xorg.conf.d/30-asus-zephyrus-g14-touchpad.conf", + "mode": "0644", + "template": "templates/X11/xorg.conf.d/30-asus-zephyrus-g14-touchpad.conf" + } + ], + "items": [ + { + "collection": "shell:after-wake:normal", + "template": "templates/after-wake" + }, + { + "collection": "contribution:node-hotkeys:trigger", + "template": "templates/hotkeys-triggers" + }, + { + "collection": "contribution:node-display-session:config", + "name": "display-session", + "template": "templates/display-session" + } + ], + "build": { + "artifacts": [ + { + "name": "tools-go", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/zephyrus", + "binary": "zephyrus", + "loads": [ + "zephyrus" + ] + }, + { + "name": "scripts", + "kind": "archive", + "from": "files" + }, + { + "name": "templates", + "kind": "archive", + "from": "templates" + } + ] + } +} diff --git a/go/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/templates/UPower/UPower.conf.d/50-asus-zephyrus-g14.conf b/go/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/templates/UPower/UPower.conf.d/50-asus-zephyrus-g14.conf new file mode 100644 index 0000000..f3d6bef --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/templates/UPower/UPower.conf.d/50-asus-zephyrus-g14.conf @@ -0,0 +1,11 @@ +# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead. +# +# On low battery the machine suspends rather than powering off, at 7 % — s2idle still draws a little, +# so it leaves headroom. A drop-in over the package's own UPower.conf, which stays the package's. +[UPower] +UsePercentageForPolicy=true +PercentageLow=15.0 +PercentageCritical=10.0 +PercentageAction=7.0 +CriticalPowerAction=Suspend +AllowRiskyCriticalPowerAction=true diff --git a/go/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/templates/X11/xorg.conf.d/30-asus-zephyrus-g14-touchpad.conf b/go/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/templates/X11/xorg.conf.d/30-asus-zephyrus-g14-touchpad.conf new file mode 100644 index 0000000..f30830c --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/templates/X11/xorg.conf.d/30-asus-zephyrus-g14-touchpad.conf @@ -0,0 +1,12 @@ +# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead. +# +# The touchpad's settings, applied by X every time the device appears — at login and after every +# resume, when the device is initialised again. This replaces the predecessor's sleep hook, which ran +# xinput after a resume as a named person on a guessed display. +Section "InputClass" + Identifier "asus-zephyrus-g14 touchpad" + MatchIsTouchpad "on" + Option "Tapping" "on" + Option "NaturalScrolling" "true" + Option "AccelSpeed" "0.15" +EndSection diff --git a/go/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/templates/after-wake b/go/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/templates/after-wake new file mode 100644 index 0000000..af60a9d --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/templates/after-wake @@ -0,0 +1,7 @@ +# The touchpad's settings again after waking, in the operator's session: X applies the module's +# input class when the device appears, and this covers a wake that does not initialise it again. +# Runs as root from the power module; the reset itself runs as the session's owner. +sleep 2 +owner=$(ps -o user= -C i3 | head -n 1) +[ -n "$owner" ] && runuser -u "$owner" -- /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-touchpad reset +true diff --git a/go/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/templates/display-session b/go/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/templates/display-session new file mode 100644 index 0000000..38f9e5b --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/templates/display-session @@ -0,0 +1,30 @@ +# 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 /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-display order + +# The keyboard backlight's level, shown when it changes. +exec --no-startup-id /usr/local/lib/asus-zephyrus-g14/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 /usr/local/lib/asus-zephyrus-g14/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 /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-touchpad reset diff --git a/go/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/templates/hotkeys-triggers b/go/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/templates/hotkeys-triggers new file mode 100644 index 0000000..5e4d165 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/templates/hotkeys-triggers @@ -0,0 +1,10 @@ +# The ROG Zephyrus G14's vendor keys, which reach no X client: media (the M-keys), panel brightness, +# and the touchpad key. Each runs this module's own script, as the operator's account. +KEY_PROG1 1 /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-session /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-media play-pause +KEY_PROG3 1 /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-session /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-media previous +KEY_PROG4 1 /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-session /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-media next +KEY_BRIGHTNESSDOWN 1 /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-backlight - +KEY_BRIGHTNESSDOWN 2 /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-backlight - +KEY_BRIGHTNESSUP 1 /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-backlight + +KEY_BRIGHTNESSUP 2 /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-backlight + +KEY_F21 1 /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-touchpad reset diff --git a/go/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/templates/modprobe.d/g14-nvidia-power.conf b/go/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/templates/modprobe.d/g14-nvidia-power.conf new file mode 100644 index 0000000..7894102 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/templates/modprobe.d/g14-nvidia-power.conf @@ -0,0 +1,16 @@ +# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead. +# +# The discrete GPU's driver options on the ROG Zephyrus G14 (GA403, RTX 40 series, hybrid graphics). +# +# NVreg_DynamicPowerManagement=0x00 turns runtime D3 off. With it on, a change of power source sends +# the driver an ACPI notification it fails to handle on this model ("RmHandleDNotifierEvent: Failed to +# handle ACPI D-Notifier event, status=0x62"), and the GPU stops making progress until the machine is +# powered off. Off costs a few idle watts in hybrid mode and keeps the machine up. +# +# NVreg_PreserveVideoMemoryAllocations=1 saves video memory across suspend, so what used the GPU still +# works after waking. It needs nvidia-suspend, -hibernate and -resume to run around a sleep, which this +# module's drop-ins on the sleep services ask for. +# +# A change here applies when the driver next loads: at the next boot. +options nvidia NVreg_PreserveVideoMemoryAllocations=1 +options nvidia NVreg_DynamicPowerManagement=0x00 diff --git a/go/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/templates/modprobe.d/video-brightness-switch.conf b/go/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/templates/modprobe.d/video-brightness-switch.conf new file mode 100644 index 0000000..5b2de1d --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/templates/modprobe.d/video-brightness-switch.conf @@ -0,0 +1,6 @@ +# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead. +# +# The ACPI video driver does not change the backlight itself on the brightness keys: on this model it +# moves the wrong one. The keys are triggerhappy's (see /etc/triggerhappy/triggers.d/asus-g14.conf). +# Applies when the module next loads: at the next boot. +options video brightness_switch_enabled=0 diff --git a/go/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/templates/systemd/system/nvidia-powerd.service.d/asus-zephyrus-g14.conf b/go/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/templates/systemd/system/nvidia-powerd.service.d/asus-zephyrus-g14.conf new file mode 100644 index 0000000..4466dd0 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/templates/systemd/system/nvidia-powerd.service.d/asus-zephyrus-g14.conf @@ -0,0 +1,7 @@ +# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead. +# +# nvidia-powerd (Dynamic Boost) was the first error in the chain that hung this model's GPU on a change +# of power source, and asusd starts it on mains. It runs only when the kernel command line says +# zephyrus.nvidia-powerd — an explicit opt-in, at boot. +[Unit] +ConditionKernelCommandLine=zephyrus.nvidia-powerd diff --git a/go/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/templates/systemd/system/systemd-hibernate.service.d/asus-zephyrus-g14-nvidia.conf b/go/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/templates/systemd/system/systemd-hibernate.service.d/asus-zephyrus-g14-nvidia.conf new file mode 100644 index 0000000..1f56e3b --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/templates/systemd/system/systemd-hibernate.service.d/asus-zephyrus-g14-nvidia.conf @@ -0,0 +1,7 @@ +# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead. +# +# The NVIDIA driver's own sleep actions, asked for by the sleep itself rather than enabled as +# links: the mesh declares files and never makes links (novox/hq ADR 0012), and the host's service +# shape must not start these units by hand, which would put the GPU to sleep with the machine awake. +[Unit] +Wants=nvidia-hibernate.service nvidia-resume.service diff --git a/go/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/templates/systemd/system/systemd-suspend-then-hibernate.service.d/asus-zephyrus-g14-nvidia.conf b/go/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/templates/systemd/system/systemd-suspend-then-hibernate.service.d/asus-zephyrus-g14-nvidia.conf new file mode 100644 index 0000000..5813188 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/templates/systemd/system/systemd-suspend-then-hibernate.service.d/asus-zephyrus-g14-nvidia.conf @@ -0,0 +1,7 @@ +# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead. +# +# The NVIDIA driver's own sleep actions, asked for by the sleep itself rather than enabled as +# links: the mesh declares files and never makes links (novox/hq ADR 0012), and the host's service +# shape must not start these units by hand, which would put the GPU to sleep with the machine awake. +[Unit] +Wants=nvidia-suspend-then-hibernate.service nvidia-resume.service diff --git a/go/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/templates/systemd/system/systemd-suspend.service.d/asus-zephyrus-g14-nvidia.conf b/go/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/templates/systemd/system/systemd-suspend.service.d/asus-zephyrus-g14-nvidia.conf new file mode 100644 index 0000000..92a0111 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/templates/systemd/system/systemd-suspend.service.d/asus-zephyrus-g14-nvidia.conf @@ -0,0 +1,7 @@ +# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead. +# +# The NVIDIA driver's own sleep actions, asked for by the sleep itself rather than enabled as +# links: the mesh declares files and never makes links (novox/hq ADR 0012), and the host's service +# shape must not start these units by hand, which would put the GPU to sleep with the machine awake. +[Unit] +Wants=nvidia-suspend.service nvidia-resume.service diff --git a/go/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/templates/udev/rules.d/90-backlight.rules b/go/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/templates/udev/rules.d/90-backlight.rules new file mode 100644 index 0000000..0a9a894 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/templates/udev/rules.d/90-backlight.rules @@ -0,0 +1,5 @@ +# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead. +# +# The backlights are writable by the video group, so the brightness keys and the module's brightness tool +# move the panel without root. +ACTION=="add", SUBSYSTEM=="backlight", RUN+="/usr/bin/chgrp video /sys/class/backlight/%k/brightness", RUN+="/usr/bin/chmod g+w /sys/class/backlight/%k/brightness" diff --git a/go/testdata/beside/mesh-catalog/modules/audit-logger/module.json.captured b/go/testdata/beside/mesh-catalog/modules/audit-logger/module.json.captured new file mode 100644 index 0000000..122b94e --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/audit-logger/module.json.captured @@ -0,0 +1,69 @@ +{ + "module": "audit-logger", + "version": "1", + "slug": "audit", + "consumes": [ + "**" + ], + "build": { + "artifacts": [ + { + "name": "code", + "kind": "bundle", + "language": "typescript", + "entrypoints": [ + "index.js", + "tools/index.js" + ], + "loads": [ + "index.js", + "tools/index.js" + ], + "env": { + "AUDIT_LOG": "${dir:trail}/audit.log", + "AUDIT_SPOOL": "${dir:spool}" + } + } + ] + }, + "data": { + "own": [ + { + "id": "trail", + "path": "${dir:trail}", + "class": "valuable", + "why": "the audit trail, written nowhere else" + }, + { + "id": "spool", + "path": "${dir:spool}", + "class": "valuable", + "why": "events the trail could not take yet; after the bus gives one up, the only copy" + } + ] + }, + "resources": [ + { + "id": "state", + "type": "directory", + "mode": "0700", + "place": ".", + "unknown": "program" + }, + { + "id": "trail", + "type": "directory", + "mode": "0700", + "unknown": "program" + }, + { + "id": "spool", + "type": "directory", + "mode": "0700", + "unknown": "program" + } + ], + "capabilities": [ + "container-runtime" + ] +} diff --git a/go/testdata/beside/mesh-catalog/modules/avahi/module.json.captured b/go/testdata/beside/mesh-catalog/modules/avahi/module.json.captured new file mode 100644 index 0000000..de99c7c --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/avahi/module.json.captured @@ -0,0 +1,43 @@ +{ + "module": "avahi", + "version": "1", + "capabilities": [ + "package-manager", + "service-manager" + ], + "tools": [ + "avahi_status", + "avahi_browse", + "avahi_resolve", + "avahi_services" + ], + "resources": [ + { + "id": "package", + "type": "package", + "package": "avahi" + }, + { + "id": "daemon", + "type": "service", + "unit": "avahi-daemon.service", + "state": "running", + "boot": "enabled" + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/avahi-tools", + "binary": "avahi-tools", + "loads": [ + "avahi-tools" + ] + } + ] + } +} diff --git a/go/testdata/beside/mesh-catalog/modules/baserow/files/config.json b/go/testdata/beside/mesh-catalog/modules/baserow/files/config.json new file mode 100644 index 0000000..955ac24 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/baserow/files/config.json @@ -0,0 +1,4 @@ +{ + "password": "${secret:admin}", + "host": "${bound:route:name}" +} diff --git a/go/testdata/beside/mesh-catalog/modules/baserow/files/server.env b/go/testdata/beside/mesh-catalog/modules/baserow/files/server.env new file mode 100644 index 0000000..7b716b4 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/baserow/files/server.env @@ -0,0 +1,7 @@ +DATABASE_HOST=${bound:postgres-database:at} +DATABASE_PORT=${bound:postgres-database:port} +DATABASE_NAME=${bound:postgres-database:as} +DATABASE_USER=${bound:postgres-database:as} +DATABASE_PASSWORD_FILE=/run/secrets/database +DISABLE_EMBEDDED_PSQL=true +BASEROW_PUBLIC_URL=https://${bound:route:name} diff --git a/go/testdata/beside/mesh-catalog/modules/baserow/module.json.captured b/go/testdata/beside/mesh-catalog/modules/baserow/module.json.captured new file mode 100644 index 0000000..8119621 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/baserow/module.json.captured @@ -0,0 +1,158 @@ +{ + "module": "baserow", + "version": "1", + "capabilities": [ + "container-runtime" + ], + "requires": [ + "postgres-database", + "route" + ], + "contributes": { + "postgres-database": { + "name": "baserow" + }, + "route": { + "label": "baserow", + "endpoint": "web" + } + }, + "binds": { + "postgres-database": "${dir:state}/database.json", + "route": "${dir:state}/route.json" + }, + "secrets": { + "postgres-database": "${dir:state}/database.secret" + }, + "own-secrets": { + "admin": "${dir:state}/admin.secret" + }, + "listens": [ + { + "name": "web", + "port": 80, + "protocol": "tcp", + "from": "mesh", + "why": "the Baserow web UI and REST API, served by the image's own Caddy; a public name is the route's" + } + ], + "data": { + "own": [ + { + "id": "data", + "path": "${dir:data}", + "class": "valuable", + "why": "uploaded files and media; the tables are in the database" + } + ] + }, + "config": { + "host": { + "type": "string", + "description": "The Host header the module's tools send to Baserow, a host name; the file's default is the name the module's route was given.", + "trusted": true, + "default": "${bound:route:name}" + }, + "email": { + "type": "string", + "description": "The email address of the Baserow account the module's tools sign in as; unset, the code falls back to its environment.", + "trusted": true, + "default": "" + }, + "url": { + "type": "string", + "description": "The address the module's tools reach Baserow at, as a URL; unset, the code falls back to its environment, else this machine's Baserow on port 80.", + "trusted": true, + "default": "" + } + }, + "resources": [ + { + "id": "mesh-state", + "type": "directory", + "mode": "0700", + "place": "mesh", + "unknown": "program" + }, + { + "id": "state", + "type": "directory", + "mode": "0700", + "place": ".", + "unknown": "program" + }, + { + "id": "data", + "type": "directory", + "mode": "0755", + "owner": "9999:9999", + "unknown": "program" + }, + { + "id": "server-env", + "type": "file", + "path": "${dir:state}/server.env", + "mode": "0600", + "template": "files/server.env" + }, + { + "id": "net", + "type": "network", + "name": "baserow" + }, + { + "id": "server", + "type": "container", + "name": "baserow", + "image": "baserow/baserow@sha256:263ea6c4b72c9eccabcd975ffe9fdebf23913a293a514bec6a3897a5e0a5a080", + "health": { + "kind": "runtime" + }, + "network": "baserow", + "env-file": [ + "${dir:state}/server.env" + ], + "ports": [ + "80" + ], + "volumes": [ + "${dir:data}:/baserow/data", + "${dir:state}/database.secret:/run/secrets/database:ro" + ] + }, + { + "id": "runtime-config", + "type": "file", + "path": "${dir:mesh-state}/config.json", + "mode": "0600", + "template": "files/config.json", + "merge": "json", + "takes-config": ["host", "email", "url"], + "protected": ["password"] + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "typescript", + "entrypoints": [ + "tools/index.js" + ], + "loads": [ + "tools/index.js" + ], + "env": { + "MESH_BASEROW_URL": "http://127.0.0.1:${port:80}", + "MESH_BASEROW_CONFIG_FILE": "${dir:mesh-state}/config.json" + } + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/go/testdata/beside/mesh-catalog/modules/betterbird/files/accounts.json b/go/testdata/beside/mesh-catalog/modules/betterbird/files/accounts.json new file mode 100644 index 0000000..e182cc3 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/betterbird/files/accounts.json @@ -0,0 +1,3 @@ +{ + "accounts": {} +} diff --git a/go/testdata/beside/mesh-catalog/modules/betterbird/files/betterbird b/go/testdata/beside/mesh-catalog/modules/betterbird/files/betterbird new file mode 100644 index 0000000..3ce088a --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/betterbird/files/betterbird @@ -0,0 +1,10 @@ +#!/bin/sh +# Betterbird's start, written by the mesh (module betterbird, novox/hq ADR 0316). Replaced at every push. +# +# Before Betterbird starts, the module's tools place its user.js in the profile (the settings prefs, accounts, identities, calendars, +# address-books and tags) and write what Betterbird rewrites itself: the account lists and each declared account's filters. They do +# it only while no Betterbird of this account runs, because Betterbird rewrites its saved preferences when it quits; a +# second start while one runs only hands over to it. What the preparation could not do it says here, and +# betterbird_check says it too. Betterbird starts either way: its configuration never holds the mail back. +/usr/local/lib/mesh-betterbird/betterbird-tools prepare || echo "betterbird: the mesh's preparation did not complete; betterbird_check says why" >&2 +exec /opt/betterbird/betterbird "$@" diff --git a/go/testdata/beside/mesh-catalog/modules/betterbird/files/configuration.json b/go/testdata/beside/mesh-catalog/modules/betterbird/files/configuration.json new file mode 100644 index 0000000..7020446 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/betterbird/files/configuration.json @@ -0,0 +1,6 @@ +{ + "identities": {}, + "calendars": {}, + "address-books": {}, + "filters": {} +} diff --git a/go/testdata/beside/mesh-catalog/modules/betterbird/files/eu.betterbird.Betterbird.desktop b/go/testdata/beside/mesh-catalog/modules/betterbird/files/eu.betterbird.Betterbird.desktop new file mode 100644 index 0000000..e409fcf --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/betterbird/files/eu.betterbird.Betterbird.desktop @@ -0,0 +1,25 @@ +[Desktop Entry] +Version=1.0 +Type=Application +Name=Betterbird +GenericName=Mail Client +Comment=Send and receive mail with Betterbird +Exec=/usr/local/bin/betterbird %u +Icon=/opt/betterbird/chrome/icons/default/default128.png +Terminal=false +Categories=Network;Email; +MimeType=message/rfc822;x-scheme-handler/mailto;application/x-xpinstall;application/x-extension-ics;text/calendar;text/vcard;text/x-vcard;x-scheme-handler/webcal;x-scheme-handler/webcals;x-scheme-handler/mid; +StartupNotify=true +StartupWMClass=eu.betterbird.Betterbird +Keywords=Email;E-mail;Calendar;Thunderbird; +Actions=ComposeMessage;OpenAddressBook; +# Written by the mesh (module betterbird, novox/hq ADR 0316 §3), in place of the community package's entry: +# it starts Betterbird through the module's start, and names the icon in the installation, with no link. + +[Desktop Action ComposeMessage] +Name=Write new message +Exec=/usr/local/bin/betterbird -compose + +[Desktop Action OpenAddressBook] +Name=Open address book +Exec=/usr/local/bin/betterbird -addressbook diff --git a/go/testdata/beside/mesh-catalog/modules/betterbird/files/prefs.json b/go/testdata/beside/mesh-catalog/modules/betterbird/files/prefs.json new file mode 100644 index 0000000..45fd973 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/betterbird/files/prefs.json @@ -0,0 +1,3 @@ +{ + "prefs": {} +} diff --git a/go/testdata/beside/mesh-catalog/modules/betterbird/files/tags.json b/go/testdata/beside/mesh-catalog/modules/betterbird/files/tags.json new file mode 100644 index 0000000..4772f09 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/betterbird/files/tags.json @@ -0,0 +1,3 @@ +{ + "tags": {} +} diff --git a/go/testdata/beside/mesh-catalog/modules/betterbird/module.json.captured b/go/testdata/beside/mesh-catalog/modules/betterbird/module.json.captured new file mode 100644 index 0000000..891f883 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/betterbird/module.json.captured @@ -0,0 +1,253 @@ +{ + "module": "betterbird", + "version": "1", + "upgrade": { + "policy": "record", + "why": "a new build may remove the community package betterbird-bin and replaces the installation in /opt/betterbird, which a running Betterbird has open: the switch on each node and every upgrade wait for the operator, who quits Betterbird first and then pushes (novox/hq ADR 0316 §4, §5)" + }, + "requires": [ + "x11-display" + ], + "invokes": [ + "seat:mesh-controller.settings", + "seat:mesh-controller.nodes", + "seat:mesh-controller.node", + "betterbird.betterbird_derive" + ], + "tools": [ + "betterbird_status", + "betterbird_prefs", + "betterbird_config", + "betterbird_config_apply", + "betterbird_user_js", + "betterbird_log", + "betterbird_restart", + "betterbird_check", + "betterbird_accounts", + "betterbird_identities", + "betterbird_calendars", + "betterbird_address_books", + "betterbird_filters", + "betterbird_tags", + "betterbird_derive", + "betterbird_drift", + "betterbird_reminder_test" + ], + "config": { + "prefs": { + "type": "object", + "description": "Betterbird's preferences the operator sets, by their own names (mailnews.*, calendar.* and the like), laid into prefs.json; an account's, a server's and a tag's are refused, as accounts, the four kinds and tags own them (novox/hq ADR 0329 §6).", + "trusted": true, + "default": {} + }, + "accounts": { + "type": "object", + "description": "The mail accounts Betterbird is set up with, each by a name of the operator's: its address, its sender's name and its incoming and outgoing servers; no password, token or cookie (novox/hq ADR 0329 §2).", + "trusted": true, + "default": {} + }, + "identities": { + "type": "object", + "description": "The identities beside an account's own, each by a name of the operator's: the address and name it sends as, its signature as a list of lines, and the profile's key to adopt on a node.", + "trusted": true, + "default": {} + }, + "calendars": { + "type": "object", + "description": "The calendars Betterbird shows, each by a name of the operator's: where it is and how it is reached, and the profile's key to adopt on a node.", + "trusted": true, + "default": {} + }, + "address-books": { + "type": "object", + "description": "The address books Betterbird uses, each by a name of the operator's: where it is and how it is reached, and the profile's key to adopt on a node.", + "trusted": true, + "default": {} + }, + "filters": { + "type": "object", + "description": "The message filters of each account, by account: what each matches and what it does with the mail.", + "trusted": true, + "default": {} + }, + "tags": { + "type": "object", + "description": "The tags Betterbird offers, each by its own key: its name, colour and order; trusted since issue 751, so a change to it is the operator's to approve.", + "trusted": true, + "default": {} + } + }, + "resources": [ + { + "id": "configuration-dir", + "type": "directory", + "path": "${machine:account-home}/.config/betterbird", + "owner": "${machine:account}", + "mode": "0755", + "unknown": "move-out", + "knows": ["accounts-record.json", "configuration-record.json", "kept/"] + }, + { + "id": "prefs", + "type": "file", + "path": "${machine:account-home}/.config/betterbird/prefs.json", + "owner": "${machine:account}", + "mode": "0644", + "merge": "json", + "takes-config": [ + "prefs" + ], + "template": "files/prefs.json", + "trusted": true + }, + { + "id": "accounts", + "type": "file", + "path": "${machine:account-home}/.config/betterbird/accounts.json", + "owner": "${machine:account}", + "mode": "0600", + "merge": "json", + "takes-config": [ + "accounts" + ], + "template": "files/accounts.json", + "trusted": true + }, + { + "id": "configuration", + "type": "file", + "path": "${machine:account-home}/.config/betterbird/configuration.json", + "owner": "${machine:account}", + "mode": "0600", + "merge": "json", + "takes-config": [ + "identities", + "calendars", + "address-books", + "filters" + ], + "template": "files/configuration.json", + "trusted": true + }, + { + "id": "tags", + "type": "file", + "path": "${machine:account-home}/.config/betterbird/tags.json", + "owner": "${machine:account}", + "mode": "0644", + "merge": "json", + "takes-config": [ + "tags" + ], + "template": "files/tags.json", + "trusted": true + }, + { + "id": "community-package", + "type": "package", + "package": "betterbird-bin", + "absent": true + }, + { + "id": "installation", + "type": "archive", + "path": "/opt/betterbird", + "artifact": "archive", + "unknown": "program" + }, + { + "id": "owl", + "type": "archive", + "path": "/opt/betterbird/owl", + "artifact": "owl", + "unknown": "program" + }, + { + "id": "policies", + "type": "archive", + "path": "/opt/betterbird/distribution", + "artifact": "distribution", + "unknown": "program" + }, + { + "id": "spelling", + "type": "archive", + "path": "/opt/betterbird/defaults/pref", + "artifact": "defaults", + "unknown": "program" + }, + { + "id": "preparation", + "type": "archive", + "path": "/usr/local/lib/mesh-betterbird", + "artifact": "tools", + "unknown": "program" + }, + { + "id": "start", + "type": "file", + "path": "/usr/local/bin/betterbird", + "mode": "0755", + "template": "files/betterbird" + }, + { + "id": "launcher", + "type": "file", + "path": "/usr/local/share/applications/eu.betterbird.Betterbird.desktop", + "mode": "0644", + "template": "files/eu.betterbird.Betterbird.desktop" + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/betterbird-tools", + "binary": "betterbird-tools", + "loads": [ + "betterbird-tools" + ] + }, + { + "name": "archive", + "kind": "archive", + "url": "https://www.betterbird.eu/downloads/LinuxArchive/betterbird-153.4.0esr-bb10.en-US.linux-x86_64.tar.xz", + "sha256": "38dd24890a66b4cf9a45bc24e15192e00c26b929e1bb8df7974c16a4539129e2", + "version": "153.4.0esr-bb10", + "within": "betterbird", + "names-on-purpose": { + "www.betterbird.eu": "Betterbird's project publishes its Linux release there; the build seat fetches it once, by its pinned sha256 (novox/hq ADR 0316 §2)" + } + }, + { + "name": "owl", + "kind": "archive", + "url": "https://www.beonex.com/owl/owl.xpi", + "sha256": "a54cc3d52113b34f97942ee19190947dee23561e2217f18db3d80ac023991221", + "version": "1.5.6.1", + "file": "owl.xpi", + "names-on-purpose": { + "www.beonex.com": "Owl's vendor publishes its xpi there; the build seat fetches it once, by its pinned sha256 (novox/hq ADR 0316 §6)" + } + }, + { + "name": "distribution", + "kind": "archive", + "from": "install/distribution" + }, + { + "name": "defaults", + "kind": "archive", + "from": "install/defaults-pref" + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/go/testdata/beside/mesh-catalog/modules/blueman/module.json.captured b/go/testdata/beside/mesh-catalog/modules/blueman/module.json.captured new file mode 100644 index 0000000..73a7640 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/blueman/module.json.captured @@ -0,0 +1,37 @@ +{ + "module": "blueman", + "version": "1", + "capabilities": [ + "package-manager" + ], + "requires": [ + "x11-display" + ], + "tools": [ + "blueman_status", + "blueman_restart", + "blueman_check" + ], + "resources": [ + { + "id": "package", + "type": "package", + "package": "blueman" + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/blueman-tools", + "binary": "blueman-tools", + "loads": [ + "blueman-tools" + ] + } + ] + } +} diff --git a/go/testdata/beside/mesh-catalog/modules/bluetooth/module.json.captured b/go/testdata/beside/mesh-catalog/modules/bluetooth/module.json.captured new file mode 100644 index 0000000..dfaabf1 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/bluetooth/module.json.captured @@ -0,0 +1,53 @@ +{ + "module": "bluetooth", + "version": "1", + "capabilities": [ + "package-manager", + "service-manager" + ], + "tools": [ + "bluetooth_controller", + "bluetooth_power", + "bluetooth_devices", + "bluetooth_scan", + "bluetooth_connect", + "bluetooth_disconnect", + "bluetooth_trust", + "bluetooth_pair", + "bluetooth_remove" + ], + "resources": [ + { + "id": "stack", + "type": "package", + "package": "bluez" + }, + { + "id": "utilities", + "type": "package", + "package": "bluez-utils" + }, + { + "id": "daemon", + "type": "service", + "unit": "bluetooth.service", + "state": "running", + "boot": "enabled" + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/bluetooth-tools", + "binary": "bluetooth-tools", + "loads": [ + "bluetooth-tools" + ] + } + ] + } +} diff --git a/go/testdata/beside/mesh-catalog/modules/ca-trust/files/anchor b/go/testdata/beside/mesh-catalog/modules/ca-trust/files/anchor new file mode 100644 index 0000000..72576d2 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/ca-trust/files/anchor @@ -0,0 +1,58 @@ +#!/bin/sh +# The mesh's internal certificate authority, trusted by this machine. +# +# Written by the mesh from the ca-trust module's manifest (novox/hq ADR 0147). +# Editing it here lasts until the next apply. +# +# There is no prior trust to verify the fetch against — this is the thing that +# establishes it — so it is made over the mesh's own private network, which is +# what authenticates it (novox/hq ADR 0098, the same reasoning that lets the +# route proxy fetch this root for itself). What comes back is checked here: a +# body that is not a certificate is refused now, rather than believed and then +# failed by whatever reads the trust store next. +set -eu + +ROOTS='https://${bound:internal-acme-ca:at}:${bound:internal-acme-ca:port}${bound:internal-acme-ca:roots}' +ANCHORS=/etc/ca-certificates/trust-source/anchors +ANCHOR="$ANCHORS/mesh-internal-ca.crt" + +# Arch's layout, said out loud rather than assumed: a machine that keeps its +# anchors elsewhere fails here, visibly, instead of writing a file nothing +# reads. That failure is the signal that this belongs in the host, where one +# operating system's difference lives (novox/hq ADR 0147, option 2). +[ -d "$ANCHORS" ] || { + echo "this machine keeps no trust anchors in $ANCHORS; ca-trust is written for that layout" >&2 + exit 1 +} + +case "${1:-}" in +install) + tmp=$(mktemp) + trap 'rm -f "$tmp"' EXIT + # The authority may still be starting, or this machine may have come up + # before it: two minutes of asking, then an honest failure. + n=0 + while [ "$n" -lt 60 ]; do + if curl --fail --silent --show-error --insecure --max-time 10 \ + --output "$tmp" "$ROOTS" && + grep -q 'BEGIN CERTIFICATE' "$tmp"; then + install -m 0644 "$tmp" "$ANCHOR" + update-ca-trust + exit 0 + fi + n=$((n + 1)) + sleep 2 + done + echo "the authority at $ROOTS did not serve a certificate within two minutes" >&2 + exit 1 + ;; +remove) + # What stopping the unit does, and therefore what being unassigned does. + rm -f "$ANCHOR" + update-ca-trust + ;; +*) + echo "usage: $(basename "$0") install|remove" >&2 + exit 2 + ;; +esac diff --git a/go/testdata/beside/mesh-catalog/modules/ca-trust/files/mesh-ca-trust.service b/go/testdata/beside/mesh-catalog/modules/ca-trust/files/mesh-ca-trust.service new file mode 100644 index 0000000..2411560 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/ca-trust/files/mesh-ca-trust.service @@ -0,0 +1,16 @@ +[Unit] +Description=The mesh's internal certificate authority, trusted by this machine +# novox/hq ADR 0147. Starting this unit places the mesh's root among this +# machine's trust anchors; stopping it takes the root away again, which is what +# the host does when the module is no longer assigned here. +Wants=network-online.target +After=network-online.target + +[Service] +Type=oneshot +RemainAfterExit=yes +ExecStart=${dir:state}/anchor install +ExecStop=${dir:state}/anchor remove + +[Install] +WantedBy=multi-user.target diff --git a/go/testdata/beside/mesh-catalog/modules/ca-trust/module.json.captured b/go/testdata/beside/mesh-catalog/modules/ca-trust/module.json.captured new file mode 100644 index 0000000..b3c9dc9 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/ca-trust/module.json.captured @@ -0,0 +1,66 @@ +{ + "module": "ca-trust", + "version": "1", + "slug": "catrust", + "capabilities": [ + "service-manager" + ], + "requires": [ + "internal-acme-ca" + ], + "seats": [ + { + "name": "the-mesh-trust-anchor", + "scope": "node" + } + ], + "claims": [ + { + "name": "the-mesh-trust-anchor", + "scope": "node" + } + ], + "resources": [ + { + "id": "state", + "type": "directory", + "mode": "0700", + "place": ".", + "unknown": "program" + }, + { + "id": "anchor", + "type": "file", + "path": "${dir:state}/anchor", + "mode": "0755", + "template": "files/anchor" + }, + { + "id": "unit", + "type": "file", + "path": "/etc/systemd/system/mesh-ca-trust.service", + "mode": "0644", + "template": "files/mesh-ca-trust.service" + }, + { + "id": "trust", + "type": "service", + "unit": "mesh-ca-trust.service", + "state": "running", + "boot": "enabled", + "restart-on": [ + "anchor", + "unit" + ] + } + ], + "build": { + "artifacts": [ + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/go/testdata/beside/mesh-catalog/modules/claude-code/files/agent-account b/go/testdata/beside/mesh-catalog/modules/claude-code/files/agent-account new file mode 100644 index 0000000..b081a6c --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/claude-code/files/agent-account @@ -0,0 +1 @@ +${machine:agent-account} diff --git a/go/testdata/beside/mesh-catalog/modules/claude-code/files/claude-agent b/go/testdata/beside/mesh-catalog/modules/claude-code/files/claude-agent new file mode 100644 index 0000000..e3d330f --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/claude-code/files/claude-agent @@ -0,0 +1,13 @@ +#!/bin/sh +# The mesh's (module claude-code, novox/hq ADR 0266): start the agent as the account agents run as on this +# machine. Written whole at every push: an edit here is overwritten. +agent='${machine:agent-account}' +if [ "$(id -un)" = "$agent" ]; then + exec claude "$@" +fi +# Another account, the operator's, becomes the agent's through its own sudo: the person is the authority. The +# operator's override of the guard travels only when it is set in this shell, as it would reach claude. +if [ -n "$MESH_GUARD_OVERRIDE" ]; then + exec sudo -iu "$agent" env MESH_GUARD_OVERRIDE="$MESH_GUARD_OVERRIDE" claude "$@" +fi +exec sudo -iu "$agent" claude "$@" diff --git a/go/testdata/beside/mesh-catalog/modules/claude-code/files/facts.json b/go/testdata/beside/mesh-catalog/modules/claude-code/files/facts.json new file mode 100644 index 0000000..7fd72bd --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/claude-code/files/facts.json @@ -0,0 +1,4 @@ +{ + "node": "${machine:name}", + "console": "http://127.0.0.1:${bound:mcp-endpoint:port}/mcp" +} diff --git a/go/testdata/beside/mesh-catalog/modules/claude-code/files/settings.json b/go/testdata/beside/mesh-catalog/modules/claude-code/files/settings.json new file mode 100644 index 0000000..8284e66 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/claude-code/files/settings.json @@ -0,0 +1,10 @@ +{ + "role": "", + "mcp_servers": {}, + "managed_settings": {}, + "instructions": {}, + "output_styles": {}, + "skills": {}, + "commands": {}, + "agents": {} +} diff --git a/go/testdata/beside/mesh-catalog/modules/claude-code/files/zsh-normal b/go/testdata/beside/mesh-catalog/modules/claude-code/files/zsh-normal new file mode 100644 index 0000000..b90b85f --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/claude-code/files/zsh-normal @@ -0,0 +1,12 @@ +# The agent's session (module claude-code, novox/hq ADR 0266): where this machine names an account of the +# agents' own, claude in an interactive zsh is claude-agent, which starts the session as that account. Where +# agents run as the operator's account the file names that account, and nothing is added. claude-agent runs +# the real claude: exec, sudo and its sh see no alias. +if [[ -r /etc/claude-code/agent-account ]]; then + () { + local agent=$(/dev/null | grep -E '^(DISPLAY|XAUTHORITY)='); do + export "$pair" + done + [ -n "$DISPLAY" ] && break + set -- /tmp/.X11-unix/X* + if [ $# -eq 1 ] && [ -S "$1" ]; then + DISPLAY=":${1##*/X}" + export DISPLAY + else + sleep 1 + fi +done +if [ -z "$XAUTHORITY" ] && [ -r "$HOME/.Xauthority" ]; then + XAUTHORITY="$HOME/.Xauthority" + export XAUTHORITY +fi +# clipmenud allows one daemon per account: it takes an exclusive flock on its session_lock file and exits +# with status 2 when that is held. After a delivery without a new login, the session's older clipmenud (from +# its xinitrc) still holds it, and a start would run the unit round its restart loop. So this waits, quietly, +# until the lock is free: it looks at the same file clipmenud locks (the directory is clipmenud's own rule) +# and lets go at once. Exit status 1 is "held" for util-linux and BusyBox flock alike (no -E: BusyBox lacks it); any other outcome starts clipmenud, which says its own +# error. Another daemon winning the gap between look and start costs one restart, not a loop. +lock="${CM_DIR:-${XDG_RUNTIME_DIR-${TMPDIR-/tmp}}}/clipmenu.6.$USER/session_lock" +while [ -e "$lock" ]; do + flock -n "$lock" true + [ $? -eq 1 ] || break + sleep 2 +done +PATH="/usr/local/lib/mesh-clipmenu:$PATH" +export PATH +exec clipmenud diff --git a/go/testdata/beside/mesh-catalog/modules/clipmenu/files/bin/xsel b/go/testdata/beside/mesh-catalog/modules/clipmenu/files/bin/xsel new file mode 100644 index 0000000..10f9bbd --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/clipmenu/files/bin/xsel @@ -0,0 +1,20 @@ +#!/bin/sh +# xsel as clipmenud sees it, written by the mesh (module clipmenu). Replaced at every push. +# +# clipmenud records text, and reads a selection with `timeout 1 xsel -o`. A selection holding an +# image (a screenshot copied as image/png) is sent in pieces; one second is too short for megabytes, +# timeout kills xsel half way, and the program owning the image waits for ever for a reader that is +# gone. From then on nothing can ask the clipboard anything: pastes hang, and an Electron app that +# asks on its main thread freezes. So a read goes ahead only when the selection offers text. +case " $* " in +*" -o "* | *" --output "*) + selection=clipboard + case " $* " in + *" --primary "* | *" -p "*) selection=primary ;; + *" --secondary "* | *" -s "*) selection=secondary ;; + esac + targets=$(timeout 1 xclip -selection "$selection" -t TARGETS -o 2>/dev/null) || exit 1 + printf '%s\n' "$targets" | grep -qxE 'UTF8_STRING|STRING|TEXT|text/plain(;charset=utf-8)?' || exit 1 + ;; +esac +exec /usr/bin/xsel "$@" diff --git a/go/testdata/beside/mesh-catalog/modules/clipmenu/files/env-cm-histlength b/go/testdata/beside/mesh-catalog/modules/clipmenu/files/env-cm-histlength new file mode 100644 index 0000000..60d3b2f --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/clipmenu/files/env-cm-histlength @@ -0,0 +1 @@ +15 diff --git a/go/testdata/beside/mesh-catalog/modules/clipmenu/files/env-cm-max-clips b/go/testdata/beside/mesh-catalog/modules/clipmenu/files/env-cm-max-clips new file mode 100644 index 0000000..1b79f38 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/clipmenu/files/env-cm-max-clips @@ -0,0 +1 @@ +500 diff --git a/go/testdata/beside/mesh-catalog/modules/clipmenu/files/env-cm-selections b/go/testdata/beside/mesh-catalog/modules/clipmenu/files/env-cm-selections new file mode 100644 index 0000000..613d81a --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/clipmenu/files/env-cm-selections @@ -0,0 +1 @@ +clipboard diff --git a/go/testdata/beside/mesh-catalog/modules/clipmenu/files/i3/50-clipmenu.conf b/go/testdata/beside/mesh-catalog/modules/clipmenu/files/i3/50-clipmenu.conf new file mode 100644 index 0000000..41835e6 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/clipmenu/files/i3/50-clipmenu.conf @@ -0,0 +1,5 @@ +# 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 diff --git a/go/testdata/beside/mesh-catalog/modules/clipmenu/files/systemd/clipmenud-mesh.conf b/go/testdata/beside/mesh-catalog/modules/clipmenu/files/systemd/clipmenud-mesh.conf new file mode 100644 index 0000000..799e22d --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/clipmenu/files/systemd/clipmenud-mesh.conf @@ -0,0 +1,14 @@ +# clipmenud in the operator's account (module clipmenu, novox/hq ADR 0208). Owned by the mesh: replaced at every push. +# The package's unit starts clipmenud with no DISPLAY; the module's launcher finds the display and puts the +# text-only xsel first on the PATH. The settings repeat the module's environment, which the account's +# service manager does not read. +[Unit] +StartLimitIntervalSec=0 + +[Service] +RestartSec=2s +Environment=CM_SELECTIONS=clipboard +Environment=CM_MAX_CLIPS=500 +Environment=CM_HISTLENGTH=15 +ExecStart= +ExecStart=/usr/local/lib/mesh-clipmenu/clipmenud-session diff --git a/go/testdata/beside/mesh-catalog/modules/clipmenu/module.json.captured b/go/testdata/beside/mesh-catalog/modules/clipmenu/module.json.captured new file mode 100644 index 0000000..30ec9c0 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/clipmenu/module.json.captured @@ -0,0 +1,135 @@ +{ + "module": "clipmenu", + "version": "1", + "moved": true, + "capabilities": [ + "package-manager" + ], + "requires": [ + "x11-display" + ], + "claims": [ + { + "name": "node-clipboard", + "scope": "node", + "serves": [ + "history", + "copy" + ] + } + ], + "tools": [ + "clipmenu_paste", + "clipmenu_clear", + "clipmenu_delete" + ], + "items": [ + { + "collection": "environment", + "name": "cm-selections", + "attributes": { + "kind": "variable", + "name": "CM_SELECTIONS" + }, + "template": "files/env-cm-selections" + }, + { + "collection": "environment", + "name": "cm-max-clips", + "attributes": { + "kind": "variable", + "name": "CM_MAX_CLIPS" + }, + "template": "files/env-cm-max-clips" + }, + { + "collection": "environment", + "name": "cm-histlength", + "attributes": { + "kind": "variable", + "name": "CM_HISTLENGTH" + }, + "template": "files/env-cm-histlength" + }, + { + "collection": "contribution:node-display-session:config", + "name": "display-session", + "template": "files/i3/50-clipmenu.conf" + } + ], + "resources": [ + { + "id": "package", + "type": "package", + "package": "clipmenu" + }, + { + "id": "greenclip", + "type": "package", + "package": "rofi-greenclip", + "absent": true + }, + { + "id": "text-only", + "type": "file", + "path": "/usr/local/lib/mesh-clipmenu/xsel", + "mode": "0755", + "template": "files/bin/xsel" + }, + { + "id": "daemon-dropins", + "type": "directory", + "path": "${machine:account-home}/.config/systemd/user/clipmenud.service.d", + "owner": "${machine:account}", + "mode": "0755", + "unknown": "move-out" + }, + { + "id": "daemon-dropin", + "type": "file", + "path": "${machine:account-home}/.config/systemd/user/clipmenud.service.d/mesh.conf", + "owner": "${machine:account}", + "mode": "0644", + "template": "files/systemd/clipmenud-mesh.conf" + }, + { + "id": "launcher", + "type": "file", + "path": "/usr/local/lib/mesh-clipmenu/clipmenud-session", + "mode": "0755", + "template": "files/bin/clipmenud-session" + }, + { + "id": "daemon", + "type": "service", + "unit": "clipmenud.service", + "scope": "user", + "user": "${machine:account}", + "state": "running", + "boot": "enabled", + "health": { + "kind": "unit" + } + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/clipmenu-tools", + "binary": "clipmenu-tools", + "loads": [ + "clipmenu-tools" + ] + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/go/testdata/beside/mesh-catalog/modules/confluence/files/config.json b/go/testdata/beside/mesh-catalog/modules/confluence/files/config.json new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/confluence/files/config.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/go/testdata/beside/mesh-catalog/modules/confluence/module.json.captured b/go/testdata/beside/mesh-catalog/modules/confluence/module.json.captured new file mode 100644 index 0000000..caa0d1b --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/confluence/module.json.captured @@ -0,0 +1,76 @@ +{ + "module": "confluence", + "version": "1", + "slug": "confl", + "own-secrets": { + "token": "${dir:state}/token" + }, + "config": { + "atlassian-url": { + "type": "string", + "description": "The address of the operator's Atlassian site the confluence tools call, as a URL such as https://example.atlassian.net, laid into the file as ATLASSIAN_URL; unset, the code falls back to its environment and answers no calls until it is given.", + "trusted": true, + "default": "" + }, + "atlassian-email": { + "type": "string", + "description": "The email address of the Atlassian account the confluence tools sign in as with its API token, laid into the file as ATLASSIAN_EMAIL; unset, the code falls back to its environment and answers no calls until it is given.", + "trusted": true, + "default": "" + } + }, + "resources": [ + { + "id": "state", + "type": "directory", + "mode": "0700", + "place": ".", + "unknown": "program" + }, + { + "id": "config", + "type": "file", + "path": "${dir:state}/config.json", + "merge": "json", + "template": "files/config.json", + "mode": "0600", + "takes-config": [ + { + "key": "atlassian-url", + "as": "ATLASSIAN_URL" + }, + { + "key": "atlassian-email", + "as": "ATLASSIAN_EMAIL" + } + ] + } + ], + "capabilities": [ + "container-runtime" + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "typescript", + "entrypoints": [ + "tools/index.js" + ], + "loads": [ + "tools/index.js" + ], + "env": { + "MESH_CONFLUENCE_TOKEN_FILE": "${dir:state}/token", + "MESH_CONFLUENCE_CONFIG_FILE": "${dir:state}/config.json" + } + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/go/testdata/beside/mesh-catalog/modules/cups/module.json.captured b/go/testdata/beside/mesh-catalog/modules/cups/module.json.captured new file mode 100644 index 0000000..01a4822 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/cups/module.json.captured @@ -0,0 +1,58 @@ +{ + "module": "cups", + "version": "1", + "capabilities": [ + "package-manager", + "service-manager" + ], + "tools": [ + "cups_printers", + "cups_queue", + "cups_cancel", + "cups_print", + "cups_default", + "cups_resume", + "cups_drivers" + ], + "resources": [ + { + "id": "package", + "type": "package", + "package": "cups" + }, + { + "id": "driverless", + "type": "package", + "package": "cups-filters" + }, + { + "id": "socket", + "type": "service", + "unit": "cups.socket", + "state": "running", + "boot": "enabled" + }, + { + "id": "scheduler", + "type": "service", + "unit": "cups.service", + "state": "running", + "boot": "enabled" + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/cups-tools", + "binary": "cups-tools", + "loads": [ + "cups-tools" + ] + } + ] + } +} diff --git a/go/testdata/beside/mesh-catalog/modules/dbus/module.json.captured b/go/testdata/beside/mesh-catalog/modules/dbus/module.json.captured new file mode 100644 index 0000000..c0e28b3 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/dbus/module.json.captured @@ -0,0 +1,67 @@ +{ + "module": "dbus", + "version": "1", + "capabilities": [ + "package-manager", + "service-manager" + ], + "claims": [ + { + "name": "node-message-bus", + "scope": "node" + } + ], + "emits": [ + "bus.stalled", + "bus.recovered", + "bus.restarted", + "service.appeared", + "service.left", + "policy.denied" + ], + "tools": [ + "dbus_names", + "dbus_introspect", + "dbus_monitor", + "dbus_check", + "dbus_health" + ], + "resources": [ + { + "id": "dbus", + "type": "package", + "package": "dbus" + }, + { + "id": "broker", + "type": "package", + "package": "dbus-broker" + }, + { + "id": "broker-units", + "type": "package", + "package": "dbus-broker-units" + }, + { + "id": "system-bus", + "type": "service", + "unit": "dbus-broker.service", + "state": "running" + } + ], + "build": { + "artifacts": [ + { + "name": "tools-go", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/dbus-tools", + "binary": "dbus-tools", + "loads": [ + "dbus-tools" + ] + } + ] + } +} diff --git a/go/testdata/beside/mesh-catalog/modules/desk-channel/files/settings.json b/go/testdata/beside/mesh-catalog/modules/desk-channel/files/settings.json new file mode 100644 index 0000000..1ef16fd --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/desk-channel/files/settings.json @@ -0,0 +1,3 @@ +{ + "desktop-machines": [] +} diff --git a/go/testdata/beside/mesh-catalog/modules/desk-channel/module.json.captured b/go/testdata/beside/mesh-catalog/modules/desk-channel/module.json.captured new file mode 100644 index 0000000..115ce62 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/desk-channel/module.json.captured @@ -0,0 +1,106 @@ +{ + "module": "desk-channel", + "version": "1", + "slug": "desk", + "runs-as": "desk-channel", + "claims": [ + { + "name": "channel", + "scope": "mesh", + "kind": "desktop", + "capabilities": [ + "deliver", + "silent", + "loud", + "edit", + "private", + "choice", + "exact-render" + ] + }, + { + "name": "intake", + "scope": "mesh", + "kind": "desktop" + } + ], + "consumes": [ + "dunst.action-chosen", + "dunst.started", + "dunst.paused" + ], + "invokes": [ + "seat:node-notifier.send" + ], + "state": [ + { + "name": "shown", + "ttl-seconds": 2592000 + } + ], + "own-secrets": { + "broker": "${dir:state}/broker" + }, + "secrets-owner": "desk-channel", + "tools": [ + "desk_channel_status" + ], + "config": { + "desktop-machines": { + "type": "list", + "description": "The machines, by node name, whose desktop shows the operator's questions and notifications, in the order they are tried; empty shows them on none.", + "trusted": true, + "default": [] + } + }, + "resources": [ + { + "id": "account", + "type": "user", + "name": "desk-channel", + "shell": "/usr/bin/nologin", + "home": "/var/lib/desk-channel" + }, + { + "id": "state", + "type": "directory", + "mode": "0700", + "owner": "desk-channel", + "place": ".", + "unknown": "program" + }, + { + "id": "settings", + "type": "file", + "path": "${dir:state}/settings.json", + "mode": "0600", + "owner": "desk-channel", + "takes-config": ["desktop-machines"], + "merge": "json", + "template": "files/settings.json" + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/desk-channel", + "binary": "desk-channel", + "loads": [ + "desk-channel" + ], + "env": { + "MESH_DESK_SETTINGS": "${dir:state}/settings.json" + } + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/go/testdata/beside/mesh-catalog/modules/dev-tools/module.json.captured b/go/testdata/beside/mesh-catalog/modules/dev-tools/module.json.captured new file mode 100644 index 0000000..8ee6fd1 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/dev-tools/module.json.captured @@ -0,0 +1,49 @@ +{ + "module": "dev-tools", + "version": "1", + "capabilities": [ + "package-manager" + ], + "resources": [ + { + "id": "go", + "type": "package", + "package": "go" + }, + { + "id": "c-compiler", + "type": "package", + "package": "gcc" + }, + { + "id": "make", + "type": "package", + "package": "make" + }, + { + "id": "nodejs", + "type": "package", + "package": "nodejs" + }, + { + "id": "npm", + "type": "package", + "package": "npm" + }, + { + "id": "python", + "type": "package", + "package": "python" + }, + { + "id": "git", + "type": "package", + "package": "git" + }, + { + "id": "jq", + "type": "package", + "package": "jq" + } + ] +} diff --git a/go/testdata/beside/mesh-catalog/modules/disk-load/module.json.captured b/go/testdata/beside/mesh-catalog/modules/disk-load/module.json.captured new file mode 100644 index 0000000..e50253e --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/disk-load/module.json.captured @@ -0,0 +1,26 @@ +{ + "module": "disk-load", + "version": "1", + "tools": [ + "disk_load", + "disk_top", + "disk_filesystems", + "disk_devices", + "cpu_top" + ], + "build": { + "artifacts": [ + { + "name": "tools-go", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/disk-load", + "binary": "disk-load", + "loads": [ + "disk-load" + ] + } + ] + } +} diff --git a/go/testdata/beside/mesh-catalog/modules/distribution/module.json.captured b/go/testdata/beside/mesh-catalog/modules/distribution/module.json.captured new file mode 100644 index 0000000..eb9f7c2 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/distribution/module.json.captured @@ -0,0 +1,96 @@ +{ + "module": "distribution", + "version": "1", + "provides": [ + { + "name": "artifact-store", + "scope": "mesh" + } + ], + "claims": [ + { + "name": "mesh-artifact-store", + "scope": "mesh" + } + ], + "capabilities": [ + "container-runtime" + ], + "own-secrets": { + "broker": "/var/lib/mesh/registry/broker" + }, + "serves": { + "artifact-store": { + "port": 5000 + } + }, + "listens": [ + { + "name": "registry", + "port": 5000, + "protocol": "tcp", + "from": "mesh", + "why": "every machine pulls images and artifacts from here" + } + ], + "data": { + "own": [ + { + "id": "registry", + "path": "${dir:registry-data}", + "class": "rebuildable", + "backup": "none", + "measure": "shallow", + "why": "the artifact store: every image is built again from its source, and too large to copy every night (ADR 0214 left it out)" + } + ] + }, + "resources": [ + { + "id": "state", + "type": "directory", + "path": "/var/lib/mesh/registry", + "mode": "0700", + "unknown": "program" + }, + { + "id": "registry-data", + "type": "directory", + "path": "/var/lib/mesh-registry", + "mode": "0700", + "unknown": "program" + }, + { + "id": "store", + "type": "container", + "name": "mesh-registry", + "image": "registry@sha256:a3d8aaa63ed8681a604f1dea0aa03f100d5895b6a58ace528858a7b332415373", + "ports": [ + "5000:5000" + ], + "volumes": [ + "/var/lib/mesh-registry:/var/lib/registry" + ], + "env": { + "REGISTRY_STORAGE_DELETE_ENABLED": "true" + } + }, + { + "id": "collect", + "type": "container", + "name": "mesh-registry-collect", + "image": "registry@sha256:a3d8aaa63ed8681a604f1dea0aa03f100d5895b6a58ace528858a7b332415373", + "volumes": [ + "/var/lib/mesh-registry:/var/lib/registry" + ], + "args": [ + "garbage-collect", + "/etc/docker/registry/config.yml" + ], + "schedule": "30 3 * * *", + "while-stopped": [ + "store" + ] + } + ] +} diff --git a/go/testdata/beside/mesh-catalog/modules/dmenu/module.json.captured b/go/testdata/beside/mesh-catalog/modules/dmenu/module.json.captured new file mode 100644 index 0000000..aeeb3ce --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/dmenu/module.json.captured @@ -0,0 +1,33 @@ +{ + "module": "dmenu", + "version": "1", + "capabilities": [ + "package-manager" + ], + "tools": [ + "dmenu_menu", + "dmenu_session" + ], + "resources": [ + { + "id": "package", + "type": "package", + "package": "dmenu" + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/dmenu-tools", + "binary": "dmenu-tools", + "loads": [ + "dmenu-tools" + ] + } + ] + } +} diff --git a/go/testdata/beside/mesh-catalog/modules/dnsmasq/files/dnsmasq.conf b/go/testdata/beside/mesh-catalog/modules/dnsmasq/files/dnsmasq.conf new file mode 100644 index 0000000..10f3926 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/dnsmasq/files/dnsmasq.conf @@ -0,0 +1,87 @@ +# Managed by the mesh. dnsmasq's own defaults are replaced whole rather than +# patched, because this module owns the file and a patch would leave whatever +# was there before to be discovered later. +# +# This is one of the mesh's resolvers (novox/hq ADR 0194, 0223): it holds the +# `mesh-dns-resolver` seat, which more than one machine may hold, each answering +# the same names from the same roster. Every node and every container lists every +# holder and no public resolver, so whichever answers first gives the one answer. +# It holds the mesh's own names and nothing else (ADR 0191) — the roster is the +# mesh's, rendered into each holder; no other copy lives on any machine. + +# What the mesh computed: one wildcard per machine — its name and everything +# under it — and the mesh's own suffix as a local domain, so a name under it is +# answered here or not at all and is never asked upstream. Rewritten whenever a +# machine joins or leaves, which is why the service below restarts on it: a +# reload makes dnsmasq re-read hosts files, not its configuration, and a +# wildcard is configuration. +conf-file=/etc/mesh-resolver/nodes.conf + +# Every zone a module answers itself (ADR 0199): one forwarding line per zone, to +# the private address of the machine its module runs on and the port its +# answerer is published on. Nothing in a zone is answered here; what names a zone +# holds is the module's. Rewritten whenever a zone is declared or moves, and the +# service restarts on it for the same reason as above. +conf-file=/etc/mesh-resolver/zones.conf + +# Where it answers: this machine's private address, so every node — and every +# container on every node, which copies its machine's resolvers — can ask; and +# loopback, for this machine's own use. Never a LAN address: a device that is +# not a member cannot reach what the mesh's names point at, and a LAN's resolver +# is its router's (ADR 0194). +# +# Named as an ADDRESS and not as the interface that carries it, on purpose. A +# container's query is addressed to this address but arrives on the runtime's +# bridge, and dnsmasq checks every query against what it was told to answer on: +# an `interface=` line admits queries by the interface they arrive on, a +# `listen-address=` line by the address they are sent to. With `interface=mesh0` +# alone, a query from a container was received and dropped without a word +# (novox/hq 04-ISSUES/110). The address admits it whatever bridge it comes in on. +# +# bind-interfaces, and never bind-dynamic (novox/hq issue 348). bind-dynamic +# re-reads the machine's addresses on every change of any link and closes each +# listener whose interface that reading did not find; a reading made while the +# container runtime deletes a bridge's virtual links fails ("index gone"), and +# then every listener is closed until a later change reads cleanly. On +# 2026-10-09 the private address was left unbound for twenty minutes, the daemon +# running and silent. bind-interfaces binds each address once, at start, and +# never closes it. It cannot bind an address that is not there yet, so the unit +# waits for it (the drop-in below); a new private address restarts the unit, as +# this file changing always did. +bind-interfaces +listen-address=${machine:address} +listen-address=127.0.0.1 + +# **It must never read resolv.conf to find out where to forward.** This +# machine's resolv.conf names this resolver — so a resolver that read it for +# upstreams would find itself, and every query it could not answer locally would +# loop until its receive queue filled. That is not theoretical: it filled with +# 15KB of queries and every lookup on the machine hung. no-resolv makes that loop +# impossible: the upstreams are the two lines below, and nothing on the machine +# can redirect them. The mesh's own names never reach them: the local= line in +# the file above stops them here, answered or refused. +no-resolv +server=1.1.1.1 +server=8.8.8.8 + +# Both upstreams validate DNSSEC and say so with the Authenticated Data bit, +# and this passes that bit down rather than dropping it, which dnsmasq does +# unless told. It does not validate itself: an answer's trust is the upstream's +# and the path to it, which is what a forwarding resolver's trust always was. +# Without it a program that refuses to run behind a resolver that does not +# validate — a mail system's admin does exactly that check at start — cannot +# use this resolver (novox/hq 04-ISSUES/171). +proxy-dnssec + +# A name without a dot is never forwarded, and reverse lookups of private ranges +# are answered here rather than asking the world who 10.x is. +domain-needed +bogus-priv + +# **No hosts file, and no operator's files.** This machine's /etc/hosts is its +# own — the hosts module's block and the operator's lines (ADR 0199) — and the +# mesh's resolver answers every node, so a line written for one machine's own +# programs must not become an answer for all of them. Every per-node resolver +# went wrong exactly here: a hosts file read once at start, an operator's old +# line beside the mesh's, a drop-in read from a directory nobody owned. +no-hosts diff --git a/go/testdata/beside/mesh-catalog/modules/dnsmasq/files/mesh.conf b/go/testdata/beside/mesh-catalog/modules/dnsmasq/files/mesh.conf new file mode 100644 index 0000000..96d8d7d --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/dnsmasq/files/mesh.conf @@ -0,0 +1,14 @@ +# Written by the mesh (module dnsmasq, novox/hq issue 348). Replaced on every push. +# +# The resolver binds its addresses once, at start (bind-interfaces), so it starts only once this +# machine's private address is there: it waits up to 80 seconds for it, and a start that found it +# missing is tried again every ten seconds rather than left failed. +[Unit] +After=network-online.target +Wants=network-online.target +StartLimitIntervalSec=0 + +[Service] +ExecStartPre=/usr/bin/sh -c 'for i in $(seq 1 80); do ip -4 -o addr show to ${machine:address} | grep -q . && exit 0; sleep 1; done; echo "${machine:address} is not on this machine" >&2; exit 1' +Restart=on-failure +RestartSec=10s diff --git a/go/testdata/beside/mesh-catalog/modules/dnsmasq/module.json.captured b/go/testdata/beside/mesh-catalog/modules/dnsmasq/module.json.captured new file mode 100644 index 0000000..58cc0da --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/dnsmasq/module.json.captured @@ -0,0 +1,162 @@ +{ + "module": "dnsmasq", + "version": "1", + "upgrade": { + "policy": "record", + "why": "the mesh's resolver: a build that breaks it can stop a machine resolving the bus, and then neither the gate's rollback nor a push reaches it (hq ADR 0236, issue 260)" + }, + "provides": [ + { + "name": "wildcard-resolution", + "scope": "mesh", + "identity": false + } + ], + "requires": [ + "mesh-addressing" + ], + "emits": [ + "name.added", + "name.removed", + "restarted", + "restart-judged" + ], + "own-secrets": { + "broker": "${dir:mesh-state}/broker" + }, + "claims": [ + { + "name": "mesh-dns-resolver", + "scope": "mesh" + } + ], + "tools": [ + "dnsmasq_health", + "dnsmasq_names", + "dnsmasq_resolve" + ], + "listens": [ + { + "name": "dns-udp", + "port": 53, + "protocol": "udp", + "from": "mesh", + "why": "one of the mesh's resolvers (ADR 0194, 0223): every node's and every container's resolver, beside any other holder of the seat — the mesh's own names answered here, a module's zone forwarded to it, the rest forwarded upstream", + "fixed": true + }, + { + "name": "dns-tcp", + "port": 53, + "protocol": "tcp", + "from": "mesh", + "why": "the same names over tcp, which a resolver answers on as well and is asked for whenever an answer will not fit in a datagram. Declared because the daemon serves it: a declaration that covers one of the two protocols its own service listens on leaves the other closed while everything reports success", + "fixed": true + } + ], + "resources": [ + { + "id": "mesh-state", + "type": "directory", + "mode": "0700", + "place": "mesh", + "unknown": "program" + }, + { + "id": "package", + "type": "package", + "package": "dnsmasq" + }, + { + "id": "config", + "type": "file", + "path": "/etc/dnsmasq.conf", + "mode": "0644", + "template": "files/dnsmasq.conf" + }, + { + "id": "drop-ins", + "type": "directory", + "path": "/etc/systemd/system/dnsmasq.service.d", + "mode": "0755", + "unknown": "program" + }, + { + "id": "wait-for-address", + "type": "file", + "path": "/etc/systemd/system/dnsmasq.service.d/mesh.conf", + "mode": "0644", + "template": "files/mesh.conf" + }, + { + "id": "service", + "type": "service", + "unit": "dnsmasq.service", + "state": "running", + "boot": "enabled", + "restart-on": [ + "config", + "wait-for-address", + "dnsmasq.fact-node-zones", + "dnsmasq.fact-zones" + ], + "health": { + "kind": "unit" + } + }, + { + "id": "watch-dir", + "type": "directory", + "path": "/var/lib/dnsmasq-watch", + "mode": "0755", + "unknown": "program" + }, + { + "id": "watch", + "type": "process", + "name": "dnsmasq-watch", + "artifact": "tools", + "run": [ + "./dnsmasq-tools", + "watch" + ], + "health": { + "kind": "tool", + "tool": "dnsmasq_health", + "interval": "20s", + "timeout": "10s", + "looks": 2, + "grace": "60s" + } + } + ], + "facts": { + "node-zones": { + "path": "/etc/mesh-resolver/nodes.conf", + "template": "# Generated by the mesh. Do not edit — this file is replaced whenever a machine\n# joins or leaves, and an edit would survive until then and vanish.\n\nlocal=/{{.Suffix}}/\n# A machine's own name is a host record as well as a wildcard: asked for an IPv6 address, the\n# resolver then says the name exists and has none, where the wildcard alone said there is no such\n# name — and a resolver that reads that as final (musl, so every Alpine container) failed to find\n# the machine at all (novox/hq issue 262).\n{{range .Machines}}address=/{{.FQDN}}/{{.Address}}\nhost-record={{.FQDN}},{{.Address}}\n{{end}}" + }, + "zones": { + "path": "/etc/mesh-resolver/zones.conf", + "template": "# Generated by the mesh. Do not edit — this file is replaced whenever a module declares a zone or\n# its machine moves (novox/hq ADR 0199). One forwarding line per zone, to the module answering it.\n\n{{range .Zones}}server=/{{.Zone}}/{{.Address}}#{{.Port}}\n{{end}}" + } + }, + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/dnsmasq-tools", + "binary": "dnsmasq-tools", + "loads": [ + "dnsmasq-tools" + ] + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/go/testdata/beside/mesh-catalog/modules/docker-compose/module.json.captured b/go/testdata/beside/mesh-catalog/modules/docker-compose/module.json.captured new file mode 100644 index 0000000..2f576b4 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/docker-compose/module.json.captured @@ -0,0 +1,40 @@ +{ + "module": "docker-compose", + "version": "1", + "capabilities": [ + "package-manager" + ], + "tools": [ + "docker_compose_projects", + "docker_compose_ps", + "docker_compose_logs", + "docker_compose_config", + "docker_compose_up", + "docker_compose_down", + "docker_compose_restart", + "docker_compose_pull", + "docker_compose_job" + ], + "resources": [ + { + "id": "package", + "type": "package", + "package": "docker-compose" + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/docker-compose-tools", + "binary": "docker-compose-tools", + "loads": [ + "docker-compose-tools" + ] + } + ] + } +} diff --git a/go/testdata/beside/mesh-catalog/modules/docker/files/daemon.json b/go/testdata/beside/mesh-catalog/modules/docker/files/daemon.json new file mode 100644 index 0000000..83750f4 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/docker/files/daemon.json @@ -0,0 +1 @@ +{"live-restore": true, "insecure-registries": ["${seat:mesh-artifact-store:reach}"], "log-driver": "json-file", "log-opts": {"max-size": "${config:log-max-size}", "max-file": "${config:log-max-file}"}} diff --git a/go/testdata/beside/mesh-catalog/modules/docker/files/docker-prune.service b/go/testdata/beside/mesh-catalog/modules/docker/files/docker-prune.service new file mode 100644 index 0000000..b9b92c1 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/docker/files/docker-prune.service @@ -0,0 +1,15 @@ +# Generated by the mesh. Do not edit — module docker writes this file and replaces it at every push. +[Unit] +Description=Prune dangling images and unused build cache (the mesh's docker module) +# Never volumes, never a container, never an image a container uses: dangling +# images and build cache nothing refers to, unused for a week. What a person +# prunes beyond that is docker_prune's, by hand. +After=docker.service +ConditionPathExists=/run/docker.sock + +[Service] +Type=oneshot +Nice=19 +IOSchedulingClass=idle +ExecStart=/usr/bin/docker image prune --force --filter until=168h +ExecStart=/usr/bin/docker builder prune --force --filter until=168h diff --git a/go/testdata/beside/mesh-catalog/modules/docker/files/docker-prune.timer b/go/testdata/beside/mesh-catalog/modules/docker/files/docker-prune.timer new file mode 100644 index 0000000..82befd1 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/docker/files/docker-prune.timer @@ -0,0 +1,11 @@ +# Generated by the mesh. Do not edit — module docker writes this file and replaces it at every push. +[Unit] +Description=Weekly prune of dangling images and unused build cache (the mesh's docker module) + +[Timer] +OnCalendar=weekly +RandomizedDelaySec=1h +Persistent=true + +[Install] +WantedBy=timers.target diff --git a/go/testdata/beside/mesh-catalog/modules/docker/files/log-rotation.conf b/go/testdata/beside/mesh-catalog/modules/docker/files/log-rotation.conf new file mode 100644 index 0000000..3919036 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/docker/files/log-rotation.conf @@ -0,0 +1,5 @@ +# The log rotation daemon.json takes, written by the mesh from this machine's settings log-max-size and +# log-max-file, and judged by the step log-rotation-check before daemon.json is written (novox/hq issue 452). +# Replaced on every push; change the settings, never this file. +size=${config:log-max-size} +files=${config:log-max-file} diff --git a/go/testdata/beside/mesh-catalog/modules/docker/module.json.captured b/go/testdata/beside/mesh-catalog/modules/docker/module.json.captured new file mode 100644 index 0000000..a3a75e0 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/docker/module.json.captured @@ -0,0 +1,179 @@ +{ + "module": "docker", + "version": "1", + "capabilities": [ + "package-manager", + "service-manager", + "privileged" + ], + "invokes": [ + "seat:mesh-controller.images" + ], + "claims": [ + { + "name": "node-container-runtime", + "scope": "node" + } + ], + "tools": [ + "docker_list", + "docker_inspect", + "docker_resolv_conf", + "docker_logs", + "docker_secrets_in_logs", + "docker_secrets_in_events", + "docker_stats", + "docker_start", + "docker_stop", + "docker_restart", + "docker_top", + "docker_images", + "docker_prune", + "docker_prune_images", + "docker_disk_usage", + "docker_networks", + "docker_volumes", + "docker_events", + "docker_daemon_config", + "docker_unlabelled", + "docker_problems", + "docker_ports" + ], + "replaces": { + "docker_resolv_conf": [ + "docker exec cat /etc/resolv.conf" + ] + }, + "config": { + "log-max-size": { + "type": "string", + "description": "The size each container's log is cut at, a whole number from 1 with k, m or g after it; the runtime keeps log-max-file of them, so 100m and 10 hold about 1 GB per container (novox/hq issue 452). The runtime reads it only at its next start, which the mesh never forces, and a container created before that keeps the options it was created with until it is created again.", + "pattern": "[1-9][0-9]*[kmg]", + "trusted": true, + "default": "100m" + }, + "log-max-file": { + "type": "number", + "description": "How many of a container's log files of log-max-size the runtime keeps, a count from 1 to 99, the oldest dropped first; taken at the runtime's next start and by containers created after it, like log-max-size (novox/hq issue 452).", + "minimum": 1, + "maximum": 99, + "trusted": true, + "default": 10 + } + }, + "resources": [ + { + "id": "package", + "type": "package", + "package": "docker" + }, + { + "id": "buildx", + "type": "package", + "package": "docker-buildx" + }, + { + "id": "socket", + "type": "service", + "unit": "docker.socket", + "state": "running", + "boot": "enabled" + }, + { + "id": "state", + "type": "directory", + "mode": "0700", + "place": "mesh", + "unknown": "program" + }, + { + "id": "log-rotation", + "type": "file", + "path": "${dir:state}/log-rotation.conf", + "mode": "0600", + "trusted": true, + "template": "files/log-rotation.conf" + }, + { + "id": "log-rotation-check", + "type": "process", + "name": "docker-log-rotation-check", + "artifact": "tools", + "run": [ + "./docker-tools", + "check-log-rotation" + ], + "run-once": true, + "env": { + "DOCKER_LOG_ROTATION": "${dir:state}/log-rotation.conf" + }, + "restart-on": [ + "log-rotation" + ] + }, + { + "id": "daemon", + "type": "file", + "path": "/etc/docker/daemon.json", + "mode": "0644", + "into": "json", + "trusted": true, + "template": "files/daemon.json" + }, + { + "id": "runtime", + "type": "service", + "unit": "docker.service", + "state": "running", + "boot": "enabled", + "reload-on": [ + "daemon" + ] + }, + { + "id": "prune-service", + "type": "file", + "path": "/etc/systemd/system/docker-prune.service", + "mode": "0644", + "template": "files/docker-prune.service" + }, + { + "id": "prune-timer", + "type": "file", + "path": "/etc/systemd/system/docker-prune.timer", + "mode": "0644", + "template": "files/docker-prune.timer" + }, + { + "id": "prune", + "type": "service", + "unit": "docker-prune.timer", + "state": "running", + "boot": "enabled", + "restart-on": [ + "prune-service", + "prune-timer" + ] + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/docker-tools", + "binary": "docker-tools", + "loads": [ + "docker-tools" + ] + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/go/testdata/beside/mesh-catalog/modules/dunst/files/dunstrc b/go/testdata/beside/mesh-catalog/modules/dunst/files/dunstrc new file mode 100644 index 0000000..afba676 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/dunst/files/dunstrc @@ -0,0 +1,110 @@ +# dunst, the notifier (module dunst, novox/hq ADR 0208). Owned by the mesh: this file is replaced +# at every push. Adopted from the laptop's file of 2026-10-04 (the two workstations differed in +# position, transparency and corner radius; the laptop's square, opaque, top-right one matches the +# rest of the desktop). Only what differs from dunst's defaults, and what the desktop relies on. +# +# Other modules' rules go in ~/.config/dunst/dunstrc.d/*.conf, which dunst reads after this file, +# so a drop-in outranks it. dunst is started by D-Bus on the first notification: nothing starts it. +# When the mesh rewrites this file (a font-size or width setting changed), it tells a running dunst +# to read it again (its unit's reload, dunstctl reload); what is on screen stays. + +[global] + monitor = 0 + follow = none + + # Geometry + width = ${config:width} + height = (0, 300) + origin = top-right + offset = (10, 50) + notification_limit = 20 + + progress_bar = true + progress_bar_height = 10 + progress_bar_frame_width = 1 + progress_bar_min_width = 150 + progress_bar_max_width = 300 + + indicate_hidden = yes + transparency = 0 + separator_height = 2 + padding = 8 + horizontal_padding = 8 + text_icon_padding = 0 + frame_width = 3 + frame_color = "#de5200" + gap_size = 0 + separator_color = frame + sort = yes + corner_radius = 0 + + # Text: the interface face (research 026/04) + font = Inter ${config:font-size} + line_height = 0 + markup = full + format = "%s\n%b" + alignment = left + vertical_alignment = center + show_age_threshold = 60 + ellipsize = middle + ignore_newline = no + stack_duplicates = true + hide_duplicate_count = false + show_indicators = yes + + # Icons, from the desktop's icon theme + enable_recursive_icon_lookup = true + icon_theme = Adwaita + icon_position = left + min_icon_size = 32 + max_icon_size = 128 + + # History + sticky_history = yes + history_length = 20 + + # The context menu is rofi (installed by the rofi module); -no-custom keeps typed text from becoming a choice. Not `dmenu`, the node-launcher's command in + # ~/.local/bin: dunst runs in the account's service manager, whose PATH does not hold that + # directory (seen on the laptop on 2026-10-10), so the menu never opened. Links open in the + # desktop's default browser. + dmenu = rofi -dmenu -no-custom -p dunst + browser = /usr/bin/xdg-open + always_run_script = true + + title = Dunst + class = Dunst + ignore_dbusclose = false + + # A tap answers (the touchpads have no middle button). do_action runs the notification's only + # action, or opens the context menu when it has several; without actions it opens its one link, + # if it has one. The notification then closes; a menu dismissed with Escape leaves it shown. + mouse_left_click = do_action, close_current + mouse_middle_click = do_action, close_current + mouse_right_click = close_all + +[urgency_low] + background = "#000000" + foreground = "#ffffff" + timeout = 10 + +[urgency_normal] + background = "#000000" + foreground = "#ffffff" + timeout = 10 + +[urgency_critical] + background = "#000000" + foreground = "#ffffff" + frame_color = "#ff0000" + timeout = 0 + +# What is shown once is never kept (the review of 2026-10-09): a notification marked secret (a link's +# code, category mesh.secret) and any transient one leave no entry in the history, which +# node-notifier.history serves to every caller of the mesh's verbs. +[mesh-secret] + category = "mesh.secret" + history_ignore = yes + +[transient-history-ignore] + match_transient = yes + history_ignore = yes diff --git a/go/testdata/beside/mesh-catalog/modules/dunst/files/i3/50-dunst.conf b/go/testdata/beside/mesh-catalog/modules/dunst/files/i3/50-dunst.conf new file mode 100644 index 0000000..fec1f8d --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/dunst/files/i3/50-dunst.conf @@ -0,0 +1,4 @@ +# 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 diff --git a/go/testdata/beside/mesh-catalog/modules/dunst/module.json.captured b/go/testdata/beside/mesh-catalog/modules/dunst/module.json.captured new file mode 100644 index 0000000..0c6983b --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/dunst/module.json.captured @@ -0,0 +1,123 @@ +{ + "module": "dunst", + "version": "1", + "moved": true, + "capabilities": [ + "package-manager" + ], + "claims": [ + { + "name": "node-notifier", + "scope": "node", + "serves": [ + "send", + "history" + ] + } + ], + "tools": [ + "dunst_pause", + "dunst_resume", + "dunst_close_all", + "dunst_rules", + "dunst_count", + "dunst_reload" + ], + "config": { + "font-size": { + "type": "number", + "description": "The size of a notification's text, in points of Inter: 10 reads well on a screen of about 100 DPI, and a denser screen needs a larger size.", + "minimum": 6, + "maximum": 72, + "predefined": "font-size", + "default": 10 + }, + "width": { + "type": "number", + "description": "The width of a notification, in pixels: 250 fits a title of about forty characters at 10 points, and a larger font needs a wider notification.", + "minimum": 50, + "maximum": 4000, + "default": 250 + } + }, + "resources": [ + { + "id": "package", + "type": "package", + "package": "dunst" + }, + { + "id": "client", + "type": "package", + "package": "libnotify" + }, + { + "id": "configuration-dir", + "type": "directory", + "path": "${machine:account-home}/.config/dunst", + "owner": "${machine:account}", + "mode": "0755", + "unknown": "move-out" + }, + { + "id": "dropins", + "type": "directory", + "path": "${machine:account-home}/.config/dunst/dunstrc.d", + "owner": "${machine:account}", + "mode": "0755", + "unknown": "move-out", + "knows": ["50-slack.conf"] + }, + { + "id": "configuration", + "trusted": false, + "type": "file", + "path": "${machine:account-home}/.config/dunst/dunstrc", + "owner": "${machine:account}", + "mode": "0644", + "template": "files/dunstrc" + }, + { + "id": "notifier", + "type": "service", + "unit": "dunst.service", + "scope": "user", + "user": "${machine:account}", + "reload-on": [ + "configuration" + ] + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/dunst-tools", + "binary": "dunst-tools", + "loads": [ + "dunst-tools" + ] + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + }, + "emits": [ + "action-chosen", + "started", + "paused" + ], + "items": [ + { + "collection": "contribution:node-display-session:config", + "name": "display-session", + "template": "files/i3/50-dunst.conf" + } + ] +} diff --git a/go/testdata/beside/mesh-catalog/modules/fail2ban/files/fail2ban b/go/testdata/beside/mesh-catalog/modules/fail2ban/files/fail2ban new file mode 100644 index 0000000..b5395db --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/fail2ban/files/fail2ban @@ -0,0 +1,7 @@ +/var/log/fail2ban.log { + missingok + notifempty + postrotate + /usr/bin/fail2ban-client flushlogs >/dev/null || true + endscript +} diff --git a/go/testdata/beside/mesh-catalog/modules/fail2ban/files/iptables-allports-dualchain.conf b/go/testdata/beside/mesh-catalog/modules/fail2ban/files/iptables-allports-dualchain.conf new file mode 100644 index 0000000..2c9fc73 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/fail2ban/files/iptables-allports-dualchain.conf @@ -0,0 +1,40 @@ +# Fail2Ban action: ban in both INPUT and DOCKER-USER chains +# Used by recidive to block repeat offenders from both host and Docker services + +[INCLUDES] + +before = iptables.conf + +[Definition] + +type = allports + +actionstart = { -C f2b- -j >/dev/null 2>&1; } || { -N f2b- || true; -A f2b- -j ; } + { -C INPUT -p -j f2b- >/dev/null 2>&1; } || { -I INPUT -p -j f2b-; } + { -C DOCKER-USER -p -j f2b- >/dev/null 2>&1; } || { -I DOCKER-USER -p -j f2b-; } + +actionstop = -D INPUT -p -j f2b- 2>/dev/null || true + -D DOCKER-USER -p -j f2b- 2>/dev/null || true + -F f2b- + -X f2b- + +actioncheck = -n -L f2b- >/dev/null + +actionban = -I f2b- 1 -s -j + +actionunban = -D f2b- -s -j + +[Init] + +chain = INPUT +name = default +protocol = tcp +blocktype = REJECT --reject-with icmp-port-unreachable +returntype = RETURN +lockingopt = -w +iptables = iptables + +[Init?family=inet6] + +blocktype = REJECT --reject-with icmp6-port-unreachable +iptables = ip6tables diff --git a/go/testdata/beside/mesh-catalog/modules/fail2ban/files/jail.local b/go/testdata/beside/mesh-catalog/modules/fail2ban/files/jail.local new file mode 100644 index 0000000..1fa0ffe --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/fail2ban/files/jail.local @@ -0,0 +1,48 @@ +[INCLUDES] + +before = paths-arch.conf + +[DEFAULT] + +# Never act on the machine itself or on a tunnel peer: the mesh's private range is +# ${machine:mesh-range}, named here rather than written as a value the module cannot +# know (novox/hq ADR 0112). Without this, fail2ban could ban the mesh's own nodes. +# **A ban list never holds a neighbour.** The mesh's own range is named rather than written +# (novox/hq ADR 0112), and every private range beside it: a source on one is somebody's own +# network, not the internet. On a machine behind a router that reflects local traffic, every +# client in the house arrives as the gateway's address — so one mistyped local request banned +# 192.168.1.1 on the home server and would have cut the whole house off from it (ADR 0186). +# +# **Nor the operator's own addresses** (novox/hq ADR 0339, issue 238): the public addresses the operator names +# as theirs, in the mesh-wide setting own-addresses. An agent polling the forge from the operator's home banned +# that home for four weeks on 2026-10-04, and nothing in the house reached the control-node until it was found +# among 400 bans. The file is written by this module's watch from the setting, one entry per line; fail2ban +# reads it again within a second of a change, so a new value needs no restart. Empty until the operator +# fills the setting. A jail that sets its own ignoreip leaves all of this out; the watch says so. +ignoreip = 127.0.0.1/8 ::1 ${machine:mesh-range} 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16 169.254.0.0/16 fc00::/7 fe80::/10 file:/var/lib/mesh-fail2ban/own-addresses + +# Three failures in a day ban for a day (novox/hq ADR 0179). The attackers this mesh sees pace +# themselves at one try every ten minutes, under any ten-minute window; a day's window counts +# them, and a day's ban costs a person who mistyped three times once, from one address, while +# the mesh's own range is never banned at all. +bantime = 1d +findtime = 1d +maxretry = 3 + +# Ban through iptables, not through a firewall front-end the machine may not have. ufw is +# installed on two of this mesh's machines and absent on the other two, and fail2ban finds out +# only at ban time: the service reports healthy, the jail counts the attempt, the ban command +# exits 127, and nothing is blocked. Proven on 2026-09-28 -- 'ufw: command not found' on a +# machine the mesh reported as protected. +# +# The action below is this module's own, already used by the recidive jail on every machine +# here, and it bans in DOCKER-USER as well as INPUT, so a container's published port is +# covered too. +banaction = iptables-allports-dualchain +banaction_allports = iptables-allports-dualchain + +[sshd] +enabled = true +port = ssh +logpath = %(sshd_log)s +backend = %(sshd_backend)s diff --git a/go/testdata/beside/mesh-catalog/modules/fail2ban/files/mesh-own-addresses.json b/go/testdata/beside/mesh-catalog/modules/fail2ban/files/mesh-own-addresses.json new file mode 100644 index 0000000..0eb9604 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/fail2ban/files/mesh-own-addresses.json @@ -0,0 +1,3 @@ +{ + "own-addresses": [] +} diff --git a/go/testdata/beside/mesh-catalog/modules/fail2ban/files/own-addresses b/go/testdata/beside/mesh-catalog/modules/fail2ban/files/own-addresses new file mode 100644 index 0000000..e69de29 diff --git a/go/testdata/beside/mesh-catalog/modules/fail2ban/files/recidive.conf b/go/testdata/beside/mesh-catalog/modules/fail2ban/files/recidive.conf new file mode 100644 index 0000000..1f8786f --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/fail2ban/files/recidive.conf @@ -0,0 +1,9 @@ +[recidive] +enabled = true +logpath = /var/log/fail2ban.log +# Ban in both INPUT (host services like SSH) and DOCKER-USER (container services) +banaction = iptables-allports-dualchain +# Banned twice in two weeks, by any jail, is banned for four (novox/hq ADR 0179). +bantime = 4w +findtime = 2w +maxretry = 2 diff --git a/go/testdata/beside/mesh-catalog/modules/fail2ban/files/sshd.conf b/go/testdata/beside/mesh-catalog/modules/fail2ban/files/sshd.conf new file mode 100644 index 0000000..c8a30f2 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/fail2ban/files/sshd.conf @@ -0,0 +1,8 @@ +[sshd] +enabled = true +port = ssh +logpath = %(sshd_log)s +backend = %(sshd_backend)s +maxretry = 3 +findtime = 1d +bantime = 1d diff --git a/go/testdata/beside/mesh-catalog/modules/fail2ban/module.json.captured b/go/testdata/beside/mesh-catalog/modules/fail2ban/module.json.captured new file mode 100644 index 0000000..446395f --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/fail2ban/module.json.captured @@ -0,0 +1,195 @@ +{ + "module": "fail2ban", + "version": "1", + "capabilities": [ + "firewall" + ], + "claims": [ + { + "name": "node-intrusion-prevention", + "scope": "node", + "serves": [ + "status", + "banned", + "ban", + "unban" + ] + } + ], + "tools": [ + "fail2ban_settings", + "fail2ban_health" + ], + "jailing": { + "into": "/etc/fail2ban/jail.d/mesh.conf", + "filter-into": "/etc/fail2ban/filter.d" + }, + "config": { + "own-addresses": { + "type": "list", + "description": "The addresses and networks outside the mesh that are the operator's own, each an IP address or a CIDR network: every jail ignores them beside the private ranges, the ban verb refuses them, and a failure a jail counted from one is said as a warning; empty is none.", + "trusted": true, + "default": [] + } + }, + "resources": [ + { + "id": "package", + "type": "package", + "package": "fail2ban" + }, + { + "id": "state-dir", + "type": "directory", + "path": "/var/lib/mesh-fail2ban", + "mode": "0755", + "unknown": "program" + }, + { + "id": "own-addresses-list", + "type": "file", + "path": "/var/lib/mesh-fail2ban/own-addresses", + "mode": "0644", + "create-once": true, + "template": "files/own-addresses" + }, + { + "id": "jail-d", + "type": "directory", + "path": "/etc/fail2ban/jail.d", + "mode": "0755", + "unknown": "program" + }, + { + "id": "action-d", + "type": "directory", + "path": "/etc/fail2ban/action.d", + "mode": "0755", + "unknown": "program" + }, + { + "id": "filter-d", + "type": "directory", + "path": "/etc/fail2ban/filter.d", + "mode": "0755", + "unknown": "program" + }, + { + "id": "run-dir", + "type": "directory", + "path": "/var/run/fail2ban", + "mode": "0755", + "unknown": "program" + }, + { + "id": "jail-local", + "type": "file", + "path": "/etc/fail2ban/jail.local", + "mode": "0644", + "template": "files/jail.local" + }, + { + "id": "jail-sshd", + "type": "file", + "path": "/etc/fail2ban/jail.d/sshd.conf", + "mode": "0644", + "template": "files/sshd.conf" + }, + { + "id": "own-addresses", + "type": "file", + "path": "/etc/fail2ban/mesh-own-addresses.json", + "mode": "0644", + "merge": "json", + "takes-config": [ + "own-addresses" + ], + "trusted": true, + "template": "files/mesh-own-addresses.json" + }, + { + "id": "watch", + "type": "process", + "name": "fail2ban-watch", + "artifact": "tools", + "run": [ + "./fail2ban-tools", + "watch" + ], + "health": { + "kind": "tool", + "tool": "fail2ban_health", + "interval": "60s", + "timeout": "10s", + "looks": 2, + "grace": "90s" + } + }, + { + "id": "log", + "type": "file", + "path": "/var/log/fail2ban.log", + "mode": "0640", + "create-once": true, + "template": "files/own-addresses" + }, + { + "id": "jail-recidive", + "type": "file", + "path": "/etc/fail2ban/jail.d/recidive.conf", + "mode": "0644", + "template": "files/recidive.conf" + }, + { + "id": "action-dualchain", + "type": "file", + "path": "/etc/fail2ban/action.d/iptables-allports-dualchain.conf", + "mode": "0644", + "template": "files/iptables-allports-dualchain.conf" + }, + { + "id": "logrotate", + "type": "file", + "path": "/etc/logrotate.d/fail2ban", + "mode": "0644", + "template": "files/fail2ban" + }, + { + "id": "run", + "type": "service", + "unit": "fail2ban.service", + "state": "running", + "boot": "enabled", + "restart-on": [ + "jail-local", + "jail-sshd", + "jail-recidive", + "action-dualchain", + "composed-jails" + ], + "health": { + "kind": "unit" + } + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/fail2ban-tools", + "binary": "fail2ban-tools", + "loads": [ + "fail2ban-tools" + ] + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/go/testdata/beside/mesh-catalog/modules/feh/files/fehbg b/go/testdata/beside/mesh-catalog/modules/feh/files/fehbg new file mode 100644 index 0000000..a3159bb --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/feh/files/fehbg @@ -0,0 +1,6 @@ +#!/bin/sh +# The wallpaper (module feh, novox/hq ADR 0208). Owned by the mesh: replaced at every push. The +# session's start runs it, and so may anything that wants the declared wallpaper back. The image is +# the module's own, in ~/.local/share/feh/wallpapers. feh_set changes the wallpaper for a session +# without touching this file. +feh --no-fehbg --bg-fill "$HOME/.local/share/feh/wallpapers/default.jpg" diff --git a/go/testdata/beside/mesh-catalog/modules/feh/files/i3/50-feh.conf b/go/testdata/beside/mesh-catalog/modules/feh/files/i3/50-feh.conf new file mode 100644 index 0000000..40ac7da --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/feh/files/i3/50-feh.conf @@ -0,0 +1,3 @@ +# 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 diff --git a/go/testdata/beside/mesh-catalog/modules/feh/files/xinitrc-normal b/go/testdata/beside/mesh-catalog/modules/feh/files/xinitrc-normal new file mode 100644 index 0000000..8e91e82 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/feh/files/xinitrc-normal @@ -0,0 +1,2 @@ +# The wallpaper (module feh, novox/hq ADR 0208): the declared one, set once per session. +"$HOME/.fehbg" diff --git a/go/testdata/beside/mesh-catalog/modules/feh/module.json.captured b/go/testdata/beside/mesh-catalog/modules/feh/module.json.captured new file mode 100644 index 0000000..d6e4407 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/feh/module.json.captured @@ -0,0 +1,75 @@ +{ + "module": "feh", + "version": "1", + "moved": true, + "capabilities": [ + "package-manager" + ], + "requires": [ + "x11-display" + ], + "tools": [ + "feh_set", + "feh_current" + ], + "items": [ + { + "collection": "shell:xinitrc:normal", + "name": "xinitrc-normal", + "template": "files/xinitrc-normal" + }, + { + "collection": "contribution:node-display-session:config", + "name": "display-session", + "template": "files/i3/50-feh.conf" + } + ], + "resources": [ + { + "id": "package", + "type": "package", + "package": "feh" + }, + { + "id": "wallpapers", + "type": "archive", + "path": "${machine:account-home}/.local/share/feh/wallpapers", + "owner": "${machine:account}", + "artifact": "wallpapers", + "unknown": "move-out" + }, + { + "id": "fehbg", + "type": "file", + "path": "${machine:account-home}/.fehbg", + "owner": "${machine:account}", + "mode": "0755", + "template": "files/fehbg" + } + ], + "build": { + "artifacts": [ + { + "name": "wallpapers", + "kind": "archive", + "from": "wallpaper" + }, + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/feh-tools", + "binary": "feh-tools", + "loads": [ + "feh-tools" + ] + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/go/testdata/beside/mesh-catalog/modules/flatpak/module.json.captured b/go/testdata/beside/mesh-catalog/modules/flatpak/module.json.captured new file mode 100644 index 0000000..76d4721 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/flatpak/module.json.captured @@ -0,0 +1,42 @@ +{ + "module": "flatpak", + "version": "1", + "capabilities": [ + "package-manager" + ], + "tools": [ + "flatpak_list", + "flatpak_runtimes", + "flatpak_remotes", + "flatpak_updates", + "flatpak_unused", + "flatpak_disk_usage", + "flatpak_install", + "flatpak_remove", + "flatpak_update", + "flatpak_remove_unused", + "flatpak_job" + ], + "resources": [ + { + "id": "package", + "type": "package", + "package": "flatpak" + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/flatpak-tools", + "binary": "flatpak-tools", + "loads": [ + "flatpak-tools" + ] + } + ] + } +} diff --git a/go/testdata/beside/mesh-catalog/modules/fonts/files/50-mesh-fonts.conf b/go/testdata/beside/mesh-catalog/modules/fonts/files/50-mesh-fonts.conf new file mode 100644 index 0000000..76d0c87 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/fonts/files/50-mesh-fonts.conf @@ -0,0 +1,40 @@ + + + + + The mesh: the faces monospace, sans-serif, serif and emoji mean + + + Hack Nerd Fontmonospace + MesloLGS NFmonospace + Iosevka Nerd Fontmonospace + JetBrains Mono Nerd FontJetBrainsMono Nerd Font + + + monospaceJetBrainsMono Nerd Font + sans-serifInterNoto Sans + system-uiInter + serifNoto Serif + emojiNoto Color Emoji + + + + Symbols Nerd Font + Noto Color Emoji + + diff --git a/go/testdata/beside/mesh-catalog/modules/fonts/module.json.captured b/go/testdata/beside/mesh-catalog/modules/fonts/module.json.captured new file mode 100644 index 0000000..b2958da --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/fonts/module.json.captured @@ -0,0 +1,70 @@ +{ + "module": "fonts", + "version": "1", + "capabilities": [ + "package-manager" + ], + "tools": [ + "fonts_families", + "fonts_match", + "fonts_glyph", + "fonts_sources", + "fonts_config", + "fonts_cache_rebuild" + ], + "resources": [ + { + "id": "monospace", + "type": "package", + "package": "ttf-jetbrains-mono-nerd" + }, + { + "id": "interface", + "type": "package", + "package": "inter-font" + }, + { + "id": "symbols", + "type": "package", + "package": "ttf-nerd-fonts-symbols" + }, + { + "id": "noto", + "type": "package", + "package": "noto-fonts" + }, + { + "id": "emoji", + "type": "package", + "package": "noto-fonts-emoji" + }, + { + "id": "defaults", + "type": "file", + "path": "${machine:account-home}/.config/fontconfig/conf.d/50-mesh-fonts.conf", + "owner": "${machine:account}", + "mode": "0644", + "template": "files/50-mesh-fonts.conf" + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/fonts-tools", + "binary": "fonts-tools", + "loads": [ + "fonts-tools" + ] + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/go/testdata/beside/mesh-catalog/modules/forticlient/module.json.captured b/go/testdata/beside/mesh-catalog/modules/forticlient/module.json.captured new file mode 100644 index 0000000..631d3fa --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/forticlient/module.json.captured @@ -0,0 +1,57 @@ +{ + "module": "forticlient", + "version": "1", + "upgrade": { + "policy": "record", + "why": "its adapter routes the company's domains on the machine a person works on: a build that breaks it cuts the person off from work names until a person pushes the next (hq ADR 0236, ADR 0247)" + }, + "capabilities": [ + "service-manager" + ], + "requires": [ + "x11-display", + "split-dns" + ], + "tools": [ + "forticlient_status", + "forticlient_restart", + "forticlient_check" + ], + "resources": [ + { + "id": "scheduler", + "type": "service", + "unit": "forticlient.service", + "state": "running", + "boot": "enabled" + }, + { + "id": "split-dns", + "type": "process", + "name": "forticlient-split-dns", + "artifact": "tools", + "run": [ + "./forticlient-tools", + "split-dns" + ], + "health": { + "kind": "unit" + } + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/forticlient-tools", + "binary": "forticlient-tools", + "loads": [ + "forticlient-tools" + ] + } + ] + } +} diff --git a/go/testdata/beside/mesh-catalog/modules/gitea/files/agent-teams.json b/go/testdata/beside/mesh-catalog/modules/gitea/files/agent-teams.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/gitea/files/agent-teams.json @@ -0,0 +1 @@ +{} diff --git a/go/testdata/beside/mesh-catalog/modules/gitea/files/config.json b/go/testdata/beside/mesh-catalog/modules/gitea/files/config.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/gitea/files/config.json @@ -0,0 +1 @@ +{} diff --git a/go/testdata/beside/mesh-catalog/modules/gitea/files/consumers.json b/go/testdata/beside/mesh-catalog/modules/gitea/files/consumers.json new file mode 100644 index 0000000..790b51f --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/gitea/files/consumers.json @@ -0,0 +1 @@ +${consumers:forge-account} diff --git a/go/testdata/beside/mesh-catalog/modules/gitea/files/origins b/go/testdata/beside/mesh-catalog/modules/gitea/files/origins new file mode 100644 index 0000000..6bbfb35 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/gitea/files/origins @@ -0,0 +1 @@ +https://${bound:route:name-web} diff --git a/go/testdata/beside/mesh-catalog/modules/gitea/files/repositories.json b/go/testdata/beside/mesh-catalog/modules/gitea/files/repositories.json new file mode 100644 index 0000000..36626cc --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/gitea/files/repositories.json @@ -0,0 +1 @@ +{"repositories": ${config:repositories}} diff --git a/go/testdata/beside/mesh-catalog/modules/gitea/files/server.env b/go/testdata/beside/mesh-catalog/modules/gitea/files/server.env new file mode 100644 index 0000000..8cb88e2 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/gitea/files/server.env @@ -0,0 +1,7 @@ +GITEA__security__INTERNAL_TOKEN=${secret:internal-token} +GITEA__database__DB_TYPE=postgres +GITEA__database__HOST=${bound:postgres-database:at}:${bound:postgres-database:port} +GITEA__database__NAME=${bound:postgres-database:as} +GITEA__database__USER=${bound:postgres-database:as} +GITEA__database__PASSWD=${secret:postgres-database} +GITEA__webhook__ALLOWED_HOST_LIST=172.17.0.1 diff --git a/go/testdata/beside/mesh-catalog/modules/gitea/module.json.captured b/go/testdata/beside/mesh-catalog/modules/gitea/module.json.captured new file mode 100644 index 0000000..2de9730 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/gitea/module.json.captured @@ -0,0 +1,545 @@ +{ + "module": "gitea", + "version": "1", + "moved": true, + "requires": [ + "postgres-database", + "route", + "secret" + ], + "contributes": { + "postgres-database": { + "name": "gitea" + }, + "route": { + "web": { + "label": "git", + "endpoint": "web" + }, + "internal-api-refused": { + "label": "git", + "path": "/api/internal", + "deny": true, + "priority": 100000 + } + } + }, + "binds": { + "postgres-database": "${dir:state}/database.json", + "route": "${dir:state}/route.json" + }, + "secrets": { + "postgres-database": "${dir:state}/database.secret", + "secret": { + "internal-token": "${dir:state}/internal-token.secret", + "admin": "${dir:state}/admin.secret" + } + }, + "capabilities": [ + "container-runtime" + ], + "emits": [ + "repo.created", + "issue.opened", + "pull.merged", + "pull.updated", + "pull.closed", + "forge.push", + "forge.create", + "forge.delete", + "forge.fork", + "forge.status", + "forge.pull-request.opened", + "forge.pull-request.reopened", + "forge.pull-request.synchronized", + "forge.pull-request.edited", + "forge.pull-request.closed", + "forge.pull-request.merged", + "forge.pull-request.labeled", + "forge.pull-request.unlabeled", + "forge.pull-request.assigned", + "forge.pull-request.unassigned", + "forge.pull-request.reviewed", + "forge.pull-request.review-requested", + "forge.pull-request.commented", + "forge.pull-request.changed", + "forge.issue-comment.created", + "forge.issue-comment.edited", + "forge.issue-comment.deleted", + "forge.issues.opened", + "forge.issues.reopened", + "forge.issues.closed", + "forge.issues.edited", + "forge.issues.labeled", + "forge.issues.unlabeled", + "forge.issues.assigned", + "forge.issues.unassigned", + "forge.issues.changed", + "forge.release.published", + "forge.release.updated", + "forge.release.deleted", + "forge.release.changed", + "forge.repository.created", + "forge.repository.deleted", + "forge.repository.changed", + "forge.wiki.changed", + "forge.other" + ], + "consumes": [ + "mesh-controller.checked", + "node-build-agent.started" + ], + "listens": [ + { + "name": "web", + "port": 3000, + "protocol": "tcp", + "from": "mesh", + "why": "the forge, over http" + }, + { + "name": "ssh", + "port": 22, + "protocol": "tcp", + "from": "mesh", + "why": "git over ssh, gitea's own unmodified sshd. Published on the machine's own side at 222, the mesh's fixed public convention — not 22, which the machine's own daemon holds and a module does not take" + }, + { + "name": "forge-events", + "port": 3047, + "protocol": "tcp", + "from": "machine", + "why": "the forge's system webhook, every event signed with a secret the module keeps, from the forge's container on the docker bridge to the module's receiver at the bridge's gateway 172.17.0.1 (novox/hq issue 729)" + } + ], + "serves": { + "npm-package-registry": { + "scheme": "http", + "port": 3000, + "npm-path": "/api/packages/novox/npm/", + "public-root": "https://${bound:route:name-web}/api/packages/novox/npm/" + }, + "git": { + "scheme": "http", + "port": 3000 + } + }, + "receives": { + "npm-package-registry": "${dir:grants}/npm.json" + }, + "grants": { + "npm-package-registry": "${dir:grants}" + }, + "claims": [ + { + "name": "npm-package-registry", + "scope": "mesh" + }, + { + "name": "git", + "scope": "mesh" + } + ], + "data": { + "own": [ + { + "id": "data", + "path": "${dir:data}", + "class": "valuable", + "why": "every repository, its issues and attachments, and the package registry" + } + ], + "consumers": { + "npm-package-registry": { + "class": "rebuildable", + "in": "data", + "why": "a consumer's packages are published again from its source" + } + } + }, + "config": { + "url": { + "type": "string", + "description": "The address the module's tools reach the forge at, as a URL; unset, the code falls back to its environment, else this machine's gitea on its port.", + "trusted": true, + "default": "" + }, + "agent-read-teams": { + "type": "object", + "description": "The read teams of the agents' forge accounts (ADR 0365 clause 3): each team by name, {\"\": {\"repos\": [\"/\", ...], \"members\": [\"\", ...]}}. The forge gets a team agent-read- that reads only, with exactly those repositories and the accounts agent-; a team taken out of the value is emptied. Nothing else on the forge is touched; an agent account is in no team until it is listed here.", + "trusted": true, + "default": {} + }, + "repositories": { + "type": "object", + "description": "The repositories of the organisation novox the mesh holds (ADR 0428): each by name, {\"\": {\"private\": true, \"description\": \"...\"}}. One that does not exist is made by the module's setup login under its own account, its main protected and given a first commit (a README naming the description and a merge-check.sh), its rule narrowed to no push and the tools' merge requiring mesh/repo-check and mesh/merge-gate, and only then moved into the organisation; one that exists only has main's rule put right, its statuses kept. A repository taken out of the value is never touched.", + "trusted": true, + "default": {} + } + }, + "facts": { + "forge-host": { + "path": "/etc/gitea/forge-host", + "template": "{{range .Machines}}{{if eq .Name $.Node}}{{.FQDN}}{{end}}{{end}}\n" + } + }, + "resources": [ + { + "id": "mesh-state", + "type": "directory", + "mode": "0700", + "place": "mesh", + "unknown": "program" + }, + { + "id": "runtime-state", + "type": "directory", + "path": "${dir:mesh-state}/state", + "mode": "0700", + "unknown": "program" + }, + { + "id": "forge-patches", + "type": "directory", + "path": "/var/lib/mesh/forge-patches", + "mode": "0733", + "owner": "${machine:account}", + "unknown": "program" + }, + { + "id": "state", + "type": "directory", + "mode": "0700", + "place": ".", + "unknown": "program" + }, + { + "id": "grants", + "type": "directory", + "mode": "0700", + "unknown": "program" + }, + { + "id": "server-env", + "type": "file", + "path": "${dir:state}/server.env", + "mode": "0600", + "template": "files/server.env" + }, + { + "id": "data", + "type": "directory", + "mode": "0700", + "owner": "1000:1000", + "unknown": "program" + }, + { + "id": "server", + "type": "container", + "health": { + "kind": "http", + "endpoint": "web", + "path": "/api/healthz", + "status": 200, + "needs": "postgres-database" + }, + "name": "gitea", + "image": "gitea/gitea@sha256:87a67ee09d3ae0d1df5fda5dcda3e2a1f9236a45b0a59025d6e00e46adc43bef", + "env": { + "DB_TYPE": "postgres", + "USER_UID": "1000", + "USER_GID": "1000" + }, + "env-file": [ + "${dir:state}/server.env" + ], + "ports": [ + "3000", + "222:22" + ], + "volumes": [ + "${dir:data}:/data" + ], + "secrets-in-environment": "gitea honours GITEA__database__PASSWD__FILE and GITEA__security__INTERNAL_TOKEN__FILE; convertible, awaiting a bed that proves it", + "logging": "journald" + }, + { + "id": "admin-bootstrap", + "type": "container", + "name": "mesh-gitea-admin", + "image": "gitea/gitea@sha256:87a67ee09d3ae0d1df5fda5dcda3e2a1f9236a45b0a59025d6e00e46adc43bef", + "run-once": true, + "env": { + "USER_UID": "1000", + "USER_GID": "1000", + "MESH_GITEA_ADMIN_USER": "mesh-admin" + }, + "env-file": [ + "${dir:state}/server.env" + ], + "volumes": [ + "${dir:data}:/data", + "${dir:state}/admin.secret:/run/secrets/admin:ro" + ], + "args": [ + "/bin/sh", + "-c", + "su-exec git gitea admin user create --admin --username \"$MESH_GITEA_ADMIN_USER\" --email mesh-admin@localhost --password \"$(cat /run/secrets/admin)\" --must-change-password=false || true" + ], + "secrets-in-environment": "gitea honours GITEA__database__PASSWD__FILE and GITEA__security__INTERNAL_TOKEN__FILE; convertible, awaiting a bed that proves it" + }, + { + "id": "origins", + "type": "file", + "path": "${dir:mesh-state}/origins", + "mode": "0644", + "template": "files/origins" + }, + { + "id": "runtime-config", + "type": "file", + "path": "${dir:mesh-state}/config.json", + "mode": "0600", + "template": "files/config.json", + "merge": "json", + "takes-config": [ + "url" + ] + }, + { + "id": "agent-teams-file", + "type": "file", + "path": "${dir:mesh-state}/agent-teams.json", + "mode": "0600", + "template": "files/agent-teams.json", + "merge": "json", + "takes-config": [ + "agent-read-teams" + ] + }, + { + "id": "repositories-file", + "type": "file", + "path": "${dir:mesh-state}/repositories.json", + "mode": "0600", + "template": "files/repositories.json" + }, + { + "id": "consumers", + "type": "file", + "path": "${dir:state}/consumers.json", + "mode": "0600", + "template": "files/consumers.json" + }, + { + "id": "agent-teams", + "type": "process", + "name": "gitea-agent-teams", + "artifact": "agent-teams", + "run": [ + "./agent-teams" + ], + "run-once": true, + "env": { + "MESH_GITEA_URL": "http://127.0.0.1:${port:3000}", + "MESH_GITEA_ADMIN_USER": "mesh-admin", + "MESH_GITEA_ADMIN_PASSWORD_FILE": "${dir:state}/admin.secret", + "MESH_AGENT_TEAMS_FILE": "${dir:mesh-state}/agent-teams.json" + }, + "restart-on": [ + "agent-teams-file" + ] + }, + { + "id": "repositories", + "type": "process", + "name": "gitea-repositories", + "artifact": "repositories", + "run": [ + "./repositories", + "sync" + ], + "run-once": true, + "env": { + "MESH_GITEA_URL": "http://127.0.0.1:${port:3000}", + "MESH_GITEA_ADMIN_USER": "mesh-admin", + "MESH_GITEA_ADMIN_PASSWORD_FILE": "${dir:state}/admin.secret", + "MESH_GITEA_TOOLS_USER": "mesh-tools", + "MESH_REPOSITORIES_FILE": "${dir:mesh-state}/repositories.json", + "MESH_REPOSITORIES_OUTCOME": "${dir:runtime-state}/repositories-outcome.json" + }, + "restart-on": [ + "repositories-file" + ] + } + ], + "provides": [ + { + "name": "npm-package-registry", + "scope": "mesh", + "identity": { + "max": 40, + "in": "a Gitea user name" + } + }, + { + "name": "git", + "scope": "mesh" + }, + { + "name": "gitea-read", + "scope": "mesh", + "credential": { + "issued-by": "software", + "tool": "gitea_agent_token" + } + }, + { + "name": "forge-account", + "scope": "mesh" + } + ], + "build": { + "artifacts": [ + { + "name": "code", + "kind": "bundle", + "language": "typescript", + "entrypoints": [ + "index.js", + "tools/index.js", + "provisioner/index.js" + ], + "loads": [ + "index.js", + "tools/index.js", + "provisioner/index.js" + ], + "env": { + "MESH_GITEA_URL": "http://127.0.0.1:${port:3000}", + "MESH_GITEA_CONFIG_FILE": "${dir:mesh-state}/config.json", + "MESH_GITEA_ADMIN_USER": "mesh-admin", + "MESH_GITEA_ADMIN_PASSWORD_FILE": "${dir:state}/admin.secret", + "MESH_GITEA_TOOLS_USER": "mesh-tools", + "MESH_GITEA_STATE_DIR": "${dir:runtime-state}", + "MESH_RECEIVES": "${dir:grants}/npm.json", + "MESH_GITEA_HOOK_URL": "http://172.17.0.1:3047", + "MESH_GITEA_HOOK_PORT": "3047", + "MESH_GITEA_HOOK_HOST": "172.17.0.1" + } + }, + { + "name": "npm-registry", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/npm-registry", + "binary": "npm-registry", + "loads": [ + "npm-registry" + ], + "env": { + "MESH_GITEA_URL": "http://127.0.0.1:${port:3000}", + "MESH_GITEA_ADMIN_USER": "mesh-admin", + "MESH_GITEA_ADMIN_PASSWORD_FILE": "${dir:state}/admin.secret", + "MESH_NPM_OWNER": "novox" + } + }, + { + "name": "forge", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/forge", + "binary": "forge", + "loads": [ + "forge" + ], + "env": { + "MESH_GITEA_URL": "http://127.0.0.1:${port:3000}", + "MESH_GITEA_TOOLS_USER": "mesh-tools", + "MESH_GITEA_TOKEN_FILE": "${dir:runtime-state}/token", + "MESH_GITEA_PATCH_DIR": "${dir:forge-patches}" + } + }, + { + "name": "files", + "kind": "archive", + "from": "files" + }, + { + "name": "agent-token", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/agent-token", + "binary": "agent-token", + "loads": [ + "agent-token" + ], + "env": { + "MESH_GITEA_URL": "http://127.0.0.1:${port:3000}", + "MESH_GITEA_ADMIN_USER": "mesh-admin", + "MESH_GITEA_ADMIN_PASSWORD_FILE": "${dir:state}/admin.secret", + "MESH_GITEA_ORIGINS_FILE": "${dir:mesh-state}/origins" + } + }, + { + "name": "agent-teams", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/agent-teams", + "binary": "agent-teams", + "loads": [ + "agent-teams" + ] + }, + { + "name": "repositories", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/repositories", + "binary": "repositories", + "loads": [ + "repositories" + ] + }, + { + "name": "forge-account-provider", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/forge-account-provider", + "binary": "forge-account-provider", + "loads": [ + "forge-account-provider" + ], + "env": { + "MESH_GITEA_URL": "http://127.0.0.1:${port:3000}", + "MESH_GITEA_ADMIN_USER": "mesh-admin", + "MESH_GITEA_ADMIN_PASSWORD_FILE": "${dir:state}/admin.secret", + "MESH_GITEA_ORIGINS_FILE": "${dir:mesh-state}/origins", + "MESH_GITEA_SSH_PORT": "222", + "MESH_FORGE_HOST_FILE": "/etc/gitea/forge-host" + } + } + ] + }, + "jails": [ + { + "name": "gitea", + "failregex": "^.*Failed authentication attempt for .* from (?::\\d+)?\\s*$\n ^.*Invalid user .* from port \\d+\\s*$\n ^.*User \\S+ from not allowed because .*$", + "jail": "backend = systemd\njournalmatch = CONTAINER_NAME=gitea\nport = http,https,222\nmaxretry = 3\nfindtime = 1d\nbantime = 1d" + } + ], + "state": [ + "bound", + "announced" + ], + "reads": [ + "mesh-controller.assignments", + "ssh-client.keys" + ] +} diff --git a/go/testdata/beside/mesh-catalog/modules/gitlab/files/config.json b/go/testdata/beside/mesh-catalog/modules/gitlab/files/config.json new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/gitlab/files/config.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/go/testdata/beside/mesh-catalog/modules/gitlab/module.json.captured b/go/testdata/beside/mesh-catalog/modules/gitlab/module.json.captured new file mode 100644 index 0000000..3cb33fb --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/gitlab/module.json.captured @@ -0,0 +1,65 @@ +{ + "module": "gitlab", + "version": "1", + "own-secrets": { + "token": "${dir:state}/token" + }, + "config": { + "gitlab-url": { + "type": "string", + "description": "The address of the GitLab instance the gitlab tools call, as a URL such as https://gitlab.com, laid into the file as GITLAB_URL; unset, the code falls back to its environment and answers no calls until it is given.", + "trusted": true, + "default": "" + } + }, + "resources": [ + { + "id": "state", + "type": "directory", + "mode": "0700", + "place": ".", + "unknown": "program" + }, + { + "id": "config", + "type": "file", + "path": "${dir:state}/config.json", + "merge": "json", + "template": "files/config.json", + "mode": "0600", + "takes-config": [ + { + "key": "gitlab-url", + "as": "GITLAB_URL" + } + ] + } + ], + "capabilities": [ + "container-runtime" + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "typescript", + "entrypoints": [ + "tools/index.js" + ], + "loads": [ + "tools/index.js" + ], + "env": { + "MESH_GITLAB_TOKEN_FILE": "${dir:state}/token", + "MESH_GITLAB_CONFIG_FILE": "${dir:state}/config.json" + } + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/go/testdata/beside/mesh-catalog/modules/gnome-keyring/files/pam/login b/go/testdata/beside/mesh-catalog/modules/gnome-keyring/files/pam/login new file mode 100644 index 0000000..47833d9 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/gnome-keyring/files/pam/login @@ -0,0 +1,4 @@ +# The login keyring (module gnome-keyring, novox/hq ADR 0208, ADR 0102): the password typed at the +# login screen unlocks the keyring, and the login session starts the keyring daemon with it. +auth optional pam_gnome_keyring.so +session optional pam_gnome_keyring.so auto_start diff --git a/go/testdata/beside/mesh-catalog/modules/gnome-keyring/files/pam/passwd b/go/testdata/beside/mesh-catalog/modules/gnome-keyring/files/pam/passwd new file mode 100644 index 0000000..dd43bb8 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/gnome-keyring/files/pam/passwd @@ -0,0 +1,3 @@ +# The login keyring (module gnome-keyring, novox/hq ADR 0208, ADR 0102): changing the account's +# password changes the login keyring's with it, so the next login still unlocks it. +password optional pam_gnome_keyring.so diff --git a/go/testdata/beside/mesh-catalog/modules/gnome-keyring/files/xinitrc-first b/go/testdata/beside/mesh-catalog/modules/gnome-keyring/files/xinitrc-first new file mode 100644 index 0000000..5fb92b8 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/gnome-keyring/files/xinitrc-first @@ -0,0 +1,5 @@ +# 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 diff --git a/go/testdata/beside/mesh-catalog/modules/gnome-keyring/module.json.captured b/go/testdata/beside/mesh-catalog/modules/gnome-keyring/module.json.captured new file mode 100644 index 0000000..9f75b9f --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/gnome-keyring/module.json.captured @@ -0,0 +1,82 @@ +{ + "module": "gnome-keyring", + "version": "1", + "moved": true, + "capabilities": [ + "package-manager" + ], + "claims": [ + { + "name": "node-secret-service", + "scope": "node" + } + ], + "tools": [ + "gnome_keyring_unlocked", + "gnome_keyring_lock", + "gnome_keyring_collections", + "gnome_keyring_ssh_keys" + ], + "items": [ + { + "collection": "shell:xinitrc:first", + "name": "xinitrc-first", + "template": "files/xinitrc-first" + } + ], + "resources": [ + { + "id": "package", + "type": "package", + "package": "gnome-keyring" + }, + { + "id": "library", + "type": "package", + "package": "libsecret" + }, + { + "id": "manager", + "type": "package", + "package": "seahorse" + }, + { + "id": "pam-login", + "type": "file", + "path": "/etc/pam.d/login", + "mode": "0644", + "into": "block", + "at": "end", + "template": "files/pam/login" + }, + { + "id": "pam-passwd", + "type": "file", + "path": "/etc/pam.d/passwd", + "mode": "0644", + "into": "block", + "at": "end", + "template": "files/pam/passwd" + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/gnome-keyring-tools", + "binary": "gnome-keyring-tools", + "loads": [ + "gnome-keyring-tools" + ] + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/go/testdata/beside/mesh-catalog/modules/grafana/files/admin.secret b/go/testdata/beside/mesh-catalog/modules/grafana/files/admin.secret new file mode 100644 index 0000000..51bf424 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/grafana/files/admin.secret @@ -0,0 +1 @@ +${secret:admin} \ No newline at end of file diff --git a/go/testdata/beside/mesh-catalog/modules/grafana/files/config.json b/go/testdata/beside/mesh-catalog/modules/grafana/files/config.json new file mode 100644 index 0000000..567df9a --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/grafana/files/config.json @@ -0,0 +1,4 @@ +{ + "user": "admin", + "password": "${secret:admin}" +} diff --git a/go/testdata/beside/mesh-catalog/modules/grafana/files/influxdb-api b/go/testdata/beside/mesh-catalog/modules/grafana/files/influxdb-api new file mode 100644 index 0000000..8329ab7 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/grafana/files/influxdb-api @@ -0,0 +1 @@ +${secret:influxdb-api} \ No newline at end of file diff --git a/go/testdata/beside/mesh-catalog/modules/grafana/files/mesh-influxdb.yaml b/go/testdata/beside/mesh-catalog/modules/grafana/files/mesh-influxdb.yaml new file mode 100644 index 0000000..e341005 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/grafana/files/mesh-influxdb.yaml @@ -0,0 +1,19 @@ +apiVersion: 1 +# Written by the mesh from grafana's influxdb-api binding, only while it is bound (novox/hq ADR 0330): +# without influxdb, grafana runs with no mesh data source. grafana reads it at start. Its own name and +# uid, so a data source somebody made in the UI is never overwritten, and read-only in the UI because +# the mesh resets it. The password is read from the file the mesh delivers, never written here. +datasources: + - name: InfluxDB (mesh) + uid: mesh-influxdb-api + type: influxdb + access: proxy + url: ${bound:influxdb-api:scheme}://${bound:influxdb-api:at}:${bound:influxdb-api:port} + user: ${bound:influxdb-api:as} + isDefault: false + editable: false + jsonData: + dbName: ${bound:influxdb-api:bucket} + httpMode: POST + secureJsonData: + password: $__file{/run/secrets/influxdb/influxdb-api} diff --git a/go/testdata/beside/mesh-catalog/modules/grafana/files/oidc-client.secret b/go/testdata/beside/mesh-catalog/modules/grafana/files/oidc-client.secret new file mode 100644 index 0000000..9146dc1 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/grafana/files/oidc-client.secret @@ -0,0 +1 @@ +${secret:oidc-client} \ No newline at end of file diff --git a/go/testdata/beside/mesh-catalog/modules/grafana/files/oidc.env b/go/testdata/beside/mesh-catalog/modules/grafana/files/oidc.env new file mode 100644 index 0000000..bcc1e9c --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/grafana/files/oidc.env @@ -0,0 +1,13 @@ +GF_SERVER_ROOT_URL=https://${bound:route:name} +GF_AUTH_GENERIC_OAUTH_ENABLED=true +GF_AUTH_GENERIC_OAUTH_NAME=Keycloak +GF_AUTH_GENERIC_OAUTH_CLIENT_ID=${bound:oidc-client:as} +GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET__FILE=/run/secrets/oidc-client +GF_AUTH_GENERIC_OAUTH_SCOPES=openid email profile roles +GF_AUTH_GENERIC_OAUTH_AUTH_URL=${bound:oidc-client:issuer}${bound:oidc-client:authorization-path} +GF_AUTH_GENERIC_OAUTH_TOKEN_URL=${bound:oidc-client:issuer}${bound:oidc-client:token-path} +GF_AUTH_GENERIC_OAUTH_API_URL=${bound:oidc-client:issuer}${bound:oidc-client:userinfo-path} +GF_AUTH_GENERIC_OAUTH_ROLE_ATTRIBUTE_PATH=contains(roles[*], 'admin') && 'Admin' || contains(realm_access.roles[*], 'admin') && 'Admin' || 'Viewer' +GF_AUTH_GENERIC_OAUTH_USE_PKCE=true +GF_AUTH_GENERIC_OAUTH_ALLOW_SIGN_UP=true +GF_AUTH_GENERIC_OAUTH_ALLOW_ASSIGN_GRAFANA_ADMIN=true diff --git a/go/testdata/beside/mesh-catalog/modules/grafana/module.json.captured b/go/testdata/beside/mesh-catalog/modules/grafana/module.json.captured new file mode 100644 index 0000000..1cbf995 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/grafana/module.json.captured @@ -0,0 +1,225 @@ +{ + "module": "grafana", + "version": "1", + "emits": [ + "alert.firing" + ], + "own-secrets": { + "admin": "${dir:mesh-state}/admin" + }, + "capabilities": [ + "container-runtime" + ], + "listens": [ + { + "name": "web", + "port": 3000, + "protocol": "tcp", + "from": "mesh", + "why": "the dashboards. Also 3000 inside, like the forge - which is the mesh's port assignment earning its keep" + } + ], + "data": { + "own": [ + { + "id": "data", + "path": "${dir:data}", + "class": "valuable", + "why": "dashboards, users and alert rules" + } + ] + }, + "config": { + "user": { + "type": "string", + "description": "The Grafana account the module's tools sign in as with the vault's password; the file's default is admin.", + "trusted": true, + "default": "admin" + }, + "url": { + "type": "string", + "description": "The address the module's tools reach Grafana at, as a URL; unset, the code falls back to its environment, else this machine's Grafana on its port.", + "trusted": true, + "default": "" + } + }, + "resources": [ + { + "id": "mesh-state", + "type": "directory", + "mode": "0700", + "place": "mesh", + "unknown": "program" + }, + { + "id": "state", + "type": "directory", + "mode": "0700", + "place": ".", + "unknown": "program" + }, + { + "id": "data", + "type": "directory", + "mode": "0700", + "owner": "472:472", + "unknown": "program" + }, + { + "id": "admin-secret", + "type": "file", + "path": "${dir:state}/admin.secret", + "mode": "0400", + "owner": "472:472", + "template": "files/admin.secret" + }, + { + "id": "oidc-secret", + "type": "file", + "path": "${dir:state}/oidc-client.secret", + "mode": "0400", + "owner": "472:472", + "template": "files/oidc-client.secret" + }, + { + "id": "oidc-env", + "type": "file", + "path": "${dir:state}/oidc.env", + "mode": "0644", + "template": "files/oidc.env" + }, + { + "id": "influxdb-datasources", + "type": "directory", + "path": "${dir:state}/datasources", + "mode": "0755", + "owner": "472:472", + "unknown": "program" + }, + { + "id": "influxdb-credential", + "type": "directory", + "path": "${dir:state}/influxdb", + "mode": "0700", + "owner": "472:472", + "unknown": "program" + }, + { + "id": "influxdb-secret", + "type": "file", + "path": "${dir:influxdb-credential}/influxdb-api", + "mode": "0400", + "owner": "472:472", + "template": "files/influxdb-api", + "only-when-bound": "influxdb-api" + }, + { + "id": "influxdb-datasource", + "type": "file", + "path": "${dir:influxdb-datasources}/mesh-influxdb.yaml", + "mode": "0644", + "template": "files/mesh-influxdb.yaml", + "only-when-bound": "influxdb-api" + }, + { + "id": "server", + "type": "container", + "name": "grafana", + "image": "grafana/grafana@sha256:ac461fb352abc50da10a51c7d02462e9c05488f11f53f14b3ad79a8145f638a0", + "health": { + "kind": "http", + "endpoint": "web", + "path": "/" + }, + "ports": [ + "3000" + ], + "volumes": [ + "${dir:data}:/var/lib/grafana", + "${dir:state}/admin.secret:/run/secrets/admin:ro", + "${dir:state}/oidc-client.secret:/run/secrets/oidc-client:ro", + "${dir:influxdb-credential}:/run/secrets/influxdb:ro", + "${dir:influxdb-datasources}:/etc/grafana/provisioning/datasources:ro" + ], + "env": { + "GF_SECURITY_ADMIN_PASSWORD__FILE": "/run/secrets/admin" + }, + "env-file": [ + "${dir:state}/oidc.env" + ], + "restart-on": [ + "oidc-env", + "oidc-secret", + "influxdb-datasource", + "influxdb-secret" + ] + }, + { + "id": "runtime-config", + "type": "file", + "path": "${dir:mesh-state}/config.json", + "mode": "0600", + "template": "files/config.json", + "merge": "json", + "takes-config": ["user", "url"], + "protected": ["password", "token"] + } + ], + "requires": [ + "route", + "oidc-client", + { + "name": "influxdb-api", + "optional": true + } + ], + "contributes": { + "route": { + "label": "grafana", + "endpoint": "web" + }, + "oidc-client": { + "label": "grafana", + "endpoint": "web", + "callback": "/login/generic_oauth" + }, + "influxdb-api": { + "access": "read" + } + }, + "binds": { + "route": "${dir:state}/route.json", + "oidc-client": "${dir:state}/oidc.json", + "influxdb-api": "${dir:state}/influxdb.json" + }, + "secrets": { + "oidc-client": "${dir:mesh-state}/oidc-client", + "influxdb-api": "${dir:mesh-state}/influxdb-api" + }, + "build": { + "artifacts": [ + { + "name": "code", + "kind": "bundle", + "language": "typescript", + "entrypoints": [ + "index.js", + "tools/index.js" + ], + "loads": [ + "index.js", + "tools/index.js" + ], + "env": { + "MESH_GRAFANA_URL": "http://127.0.0.1:${port:3000}", + "MESH_GRAFANA_CONFIG_FILE": "${dir:mesh-state}/config.json" + } + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/go/testdata/beside/mesh-catalog/modules/hello-web/files/index.html b/go/testdata/beside/mesh-catalog/modules/hello-web/files/index.html new file mode 100644 index 0000000..0d513fd --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/hello-web/files/index.html @@ -0,0 +1 @@ +hello from hello-web, routed by the mesh diff --git a/go/testdata/beside/mesh-catalog/modules/hello-web/module.json.captured b/go/testdata/beside/mesh-catalog/modules/hello-web/module.json.captured new file mode 100644 index 0000000..005578c --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/hello-web/module.json.captured @@ -0,0 +1,82 @@ +{ + "module": "hello-web", + "slug": "hello", + "version": "1", + "capabilities": [ + "container-runtime" + ], + "requires": [ + "route" + ], + "contributes": { + "route": { + "label": "hello", + "endpoint": "web" + } + }, + "binds": { + "route": "${dir:state}/route.json" + }, + "listens": [ + { + "name": "web", + "port": 8080, + "protocol": "tcp", + "from": "mesh", + "why": "the demo web page; only the route-proxy reaches it, and the public name is a route grant" + } + ], + "resources": [ + { + "id": "state", + "type": "directory", + "mode": "0700", + "place": ".", + "unknown": "program" + }, + { + "id": "page", + "type": "file", + "path": "${dir:state}/index.html", + "mode": "0644", + "template": "files/index.html" + }, + { + "id": "net", + "type": "network", + "name": "hello-web" + }, + { + "id": "server", + "type": "container", + "name": "hello-web", + "network": "hello-web", + "ports": [ + "8080" + ], + "volumes": [ + "${dir:state}/index.html:/www/index.html:ro" + ], + "args": [ + "sh", + "-c", + "while true; do { printf 'HTTP/1.1 200 OK\\r\\nContent-Type: text/plain\\r\\nConnection: close\\r\\n\\r\\n'; cat /www/index.html; } | nc -l -p 8080; done" + ], + "artifact": "server" + } + ], + "build": { + "artifacts": [ + { + "name": "server", + "kind": "upstream", + "from": "alpine@sha256:28bd5fe8b56d1bd048e5babf5b10710ebe0bae67db86916198a6eec434943f8b" + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/go/testdata/beside/mesh-catalog/modules/home-assistant/files/config.json b/go/testdata/beside/mesh-catalog/modules/home-assistant/files/config.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/home-assistant/files/config.json @@ -0,0 +1 @@ +{} diff --git a/go/testdata/beside/mesh-catalog/modules/home-assistant/files/provisions.env b/go/testdata/beside/mesh-catalog/modules/home-assistant/files/provisions.env new file mode 100644 index 0000000..93aba0c --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/home-assistant/files/provisions.env @@ -0,0 +1,4 @@ +MESH_HOMEASSISTANT_URL=http://127.0.0.1:${port:8123} +MESH_HOMEASSISTANT_TOKEN_FILE=${dir:mesh-state}/token +MESH_PROVISIONS_DIR=${dir:state} +MESH_WRITTEN_DIR=${dir:written} diff --git a/go/testdata/beside/mesh-catalog/modules/home-assistant/module.json.captured b/go/testdata/beside/mesh-catalog/modules/home-assistant/module.json.captured new file mode 100644 index 0000000..9c4f99d --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/home-assistant/module.json.captured @@ -0,0 +1,209 @@ +{ + "module": "home-assistant", + "version": "1", + "slug": "hass", + "capabilities": [ + "container-runtime" + ], + "emits": [ + "state.changed" + ], + "own-secrets": { + "token": "${dir:mesh-state}/token" + }, + "listens": [ + { + "name": "web", + "port": 8123, + "protocol": "tcp", + "from": "mesh", + "why": "the dashboard, the API and the companion apps" + }, + { + "name": "sonos-events", + "port": 1400, + "protocol": "tcp", + "from": "mesh", + "why": "the Sonos integration's event callback: speakers push their state changes here" + }, + { + "name": "webrtc", + "port": 18555, + "protocol": "tcp", + "from": "mesh", + "why": "the bundled go2rtc's WebRTC port, which camera streams to a browser use" + } + ], + "data": { + "own": [ + { + "id": "config", + "path": "${dir:config}", + "class": "valuable", + "active": "1d", + "why": "the house's configuration, automations and history; written all the time" + } + ] + }, + "config": { + "url": { + "type": "string", + "description": "The address the module's tools reach Home Assistant at, as a URL; unset, the code falls back to its environment, else this machine's Home Assistant on its port.", + "trusted": true, + "default": "" + } + }, + "resources": [ + { + "id": "mesh-state", + "type": "directory", + "mode": "0700", + "place": "mesh", + "unknown": "program" + }, + { + "id": "state", + "type": "directory", + "mode": "0700", + "place": ".", + "unknown": "program" + }, + { + "id": "config", + "type": "directory", + "mode": "0700", + "unknown": "program" + }, + { + "id": "written", + "type": "directory", + "mode": "0700", + "unknown": "program" + }, + { + "id": "server", + "type": "container", + "name": "home-assistant", + "image": "ghcr.io/home-assistant/home-assistant@sha256:d8922685169707fd91e8b9729902d975f06157d005e422874d201e0261dda196", + "network": "host", + "env": { + "TZ": "Etc/UTC" + }, + "volumes": [ + "${dir:config}:/config" + ] + }, + { + "id": "runtime-config", + "type": "file", + "path": "${dir:mesh-state}/config.json", + "mode": "0600", + "template": "files/config.json", + "merge": "json", + "takes-config": ["url"] + }, + { + "id": "provisions-env", + "type": "file", + "path": "${dir:state}/provisions.env", + "mode": "0600", + "template": "files/provisions.env" + }, + { + "id": "provisions", + "type": "process", + "name": "home-assistant-provisions", + "artifact": "code", + "run": [ + "node", + "provisions/index.js" + ], + "run-once": true, + "env-file": [ + "${dir:state}/provisions.env" + ], + "restart-on": [ + "provisions-env", + "bound-mqtt-topic", + "secret-mqtt-topic", + "bound-sonarr-api", + "secret-sonarr-api", + "bound-radarr-api", + "secret-radarr-api", + "bound-lidarr-api", + "secret-lidarr-api" + ] + } + ], + "requires": [ + { + "name": "lidarr-api", + "optional": true + }, + { + "name": "mqtt-topic", + "optional": true + }, + { + "name": "radarr-api", + "optional": true + }, + "route", + { + "name": "sonarr-api", + "optional": true + } + ], + "contributes": { + "mqtt-topic": { + "topics": [ + "#" + ] + }, + "route": { + "label": "home-assistant", + "endpoint": "web" + } + }, + "binds": { + "route": "${dir:state}/route.json", + "mqtt-topic": "${dir:state}/mqtt-topic.json", + "sonarr-api": "${dir:state}/sonarr-api.json", + "radarr-api": "${dir:state}/radarr-api.json", + "lidarr-api": "${dir:state}/lidarr-api.json" + }, + "secrets": { + "mqtt-topic": "${dir:state}/mqtt-topic.secret", + "sonarr-api": "${dir:state}/sonarr-api.secret", + "radarr-api": "${dir:state}/radarr-api.secret", + "lidarr-api": "${dir:state}/lidarr-api.secret" + }, + "build": { + "artifacts": [ + { + "name": "code", + "kind": "bundle", + "language": "typescript", + "entrypoints": [ + "index.js", + "tools/index.js", + "provisions/index.js" + ], + "loads": [ + "index.js", + "tools/index.js" + ], + "env": { + "MESH_HOMEASSISTANT_URL": "http://127.0.0.1:${port:8123}", + "MESH_HOMEASSISTANT_TOKEN_FILE": "${dir:mesh-state}/token", + "MESH_HOMEASSISTANT_CONFIG_FILE": "${dir:mesh-state}/config.json" + } + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/go/testdata/beside/mesh-catalog/modules/hostname/files/hostname b/go/testdata/beside/mesh-catalog/modules/hostname/files/hostname new file mode 100644 index 0000000..34c12a4 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/hostname/files/hostname @@ -0,0 +1 @@ +${config:hostname} diff --git a/go/testdata/beside/mesh-catalog/modules/hostname/files/hosts b/go/testdata/beside/mesh-catalog/modules/hostname/files/hosts new file mode 100644 index 0000000..7c9c073 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/hostname/files/hosts @@ -0,0 +1,7 @@ +# The machine's own names (module hostname, novox/hq ADR 0199, ADR 0223). Every line outside this +# block is the operator's: kept across every push, changed through the node-hostname verbs add and +# remove, and given back when this module goes. The mesh's names are not here: the mesh's resolver +# answers them. +127.0.0.1 localhost +::1 localhost +127.0.1.1 ${machine:name} diff --git a/go/testdata/beside/mesh-catalog/modules/hostname/module.json.captured b/go/testdata/beside/mesh-catalog/modules/hostname/module.json.captured new file mode 100644 index 0000000..e84e7be --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/hostname/module.json.captured @@ -0,0 +1,61 @@ +{ + "module": "hostname", + "version": "1", + "claims": [ + { + "name": "node-hostname", + "scope": "node", + "serves": [ + "entries", + "add", + "remove" + ] + } + ], + "config": { + "hostname": { + "type": "string", + "description": "The machine's host name, written to /etc/hostname: the operator's name for this machine, with no default (novox/hq ADR 0112).", + "trusted": true, + "required": true + } + }, + "resources": [ + { + "id": "own", + "type": "file", + "path": "/etc/hosts", + "mode": "0644", + "into": "block", + "at": "start", + "template": "files/hosts" + }, + { + "id": "name", + "type": "file", + "path": "/etc/hostname", + "mode": "0644", + "template": "files/hostname" + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/hostname-tools", + "binary": "hostname-tools", + "loads": [ + "hostname-tools" + ] + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/go/testdata/beside/mesh-catalog/modules/i3/files/config b/go/testdata/beside/mesh-catalog/modules/i3/files/config new file mode 100644 index 0000000..c49398c --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/i3/files/config @@ -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. +${contribution:node-display-session:config} +######################################### +###### Your own files #### +######################################### +include ~/.config/i3/config.d/*.conf diff --git a/go/testdata/beside/mesh-catalog/modules/i3/files/env-xdg-current-desktop b/go/testdata/beside/mesh-catalog/modules/i3/files/env-xdg-current-desktop new file mode 100644 index 0000000..04ad29f --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/i3/files/env-xdg-current-desktop @@ -0,0 +1 @@ +i3 diff --git a/go/testdata/beside/mesh-catalog/modules/i3/files/env-xdg-session-desktop b/go/testdata/beside/mesh-catalog/modules/i3/files/env-xdg-session-desktop new file mode 100644 index 0000000..04ad29f --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/i3/files/env-xdg-session-desktop @@ -0,0 +1 @@ +i3 diff --git a/go/testdata/beside/mesh-catalog/modules/i3/files/wms/i3 b/go/testdata/beside/mesh-catalog/modules/i3/files/wms/i3 new file mode 100644 index 0000000..4e269ad --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/i3/files/wms/i3 @@ -0,0 +1,9 @@ +#!/bin/sh +# The i3 session (module i3, novox/hq ADR 0208), offered by the login manager. It runs the session's +# start, ~/.xinitrc, which the display server's module writes and which ends by starting i3. +# Replaced at every push. +# +# The login manager hands the session a stdout and stderr whose reading end nobody holds, so a +# program that writes to them dies of EPIPE (an Electron tray app shows it as an error dialog). +# The session's output goes to the journal instead, under `journalctl -t x-session`. +exec systemd-cat -t x-session /bin/sh "$HOME/.xinitrc" diff --git a/go/testdata/beside/mesh-catalog/modules/i3/files/xinitrc-last b/go/testdata/beside/mesh-catalog/modules/i3/files/xinitrc-last new file mode 100644 index 0000000..e84ff3d --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/i3/files/xinitrc-last @@ -0,0 +1,2 @@ +# The session: i3, with 25 MiB of debug log kept in memory for i3-dump-log. +exec i3 --shmlog-size=26214400 diff --git a/go/testdata/beside/mesh-catalog/modules/i3/module.json.captured b/go/testdata/beside/mesh-catalog/modules/i3/module.json.captured new file mode 100644 index 0000000..1ec9242 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/i3/module.json.captured @@ -0,0 +1,124 @@ +{ + "module": "i3", + "version": "1", + "moved": true, + "capabilities": [ + "package-manager" + ], + "requires": [ + "x11-display" + ], + "claims": [ + { + "name": "node-display-session", + "scope": "node", + "serves": [ + "reload", + "workspaces", + "windows" + ] + } + ], + "tools": [ + "i3_focus", + "i3_move", + "i3_layout_save", + "i3_layout_restore", + "i3_exec", + "i3_kill", + "i3_bindings", + "i3_config_check", + "i3_marks", + "i3_scratchpad" + ], + "items": [ + { + "collection": "environment", + "name": "xdg-current-desktop", + "attributes": { + "kind": "variable", + "name": "XDG_CURRENT_DESKTOP" + }, + "template": "files/env-xdg-current-desktop" + }, + { + "collection": "environment", + "name": "xdg-session-desktop", + "attributes": { + "kind": "variable", + "name": "XDG_SESSION_DESKTOP" + }, + "template": "files/env-xdg-session-desktop" + }, + { + "collection": "shell:xinitrc:last", + "name": "xinitrc-last", + "template": "files/xinitrc-last" + } + ], + "resources": [ + { + "id": "package", + "type": "package", + "package": "i3-wm" + }, + { + "id": "package-dex", + "type": "package", + "package": "dex" + }, + { + "id": "config-dir", + "type": "directory", + "path": "${machine:account-home}/.config/i3", + "owner": "${machine:account}", + "mode": "0755", + "unknown": "move-out", + "knows": ["scripts/"] + }, + { + "id": "drop-ins", + "type": "directory", + "path": "${machine:account-home}/.config/i3/config.d", + "owner": "${machine:account}", + "mode": "0755", + "unknown": "move-out", + "knows": ["10-asus.conf", "20-g14.conf"] + }, + { + "id": "config", + "type": "file", + "path": "${machine:account-home}/.config/i3/config", + "owner": "${machine:account}", + "mode": "0644", + "template": "files/config" + }, + { + "id": "session", + "type": "file", + "path": "/etc/lemurs/wms/i3", + "mode": "0755", + "template": "files/wms/i3" + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/i3-tools", + "binary": "i3-tools", + "loads": [ + "i3-tools" + ] + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/go/testdata/beside/mesh-catalog/modules/i3status-rust/files/bin/i3bar-watchdog b/go/testdata/beside/mesh-catalog/modules/i3status-rust/files/bin/i3bar-watchdog new file mode 100644 index 0000000..63b2b02 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/i3status-rust/files/bin/i3bar-watchdog @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +# i3bar-watchdog [session-pid] (module i3status-rust, novox/hq ADR 0208): respawns a bar that died. +# +# i3 starts one i3bar per `bar { }` block and never restarts one that exits, and a reload does not +# either. Changing the monitor setup reliably kills the bar that owns the tray. This brings back the +# one missing i3bar, without restarting i3, and logs the outputs at that moment: the evidence for the +# cause, which is i3bar's. +# +# Started once per session from the session's start, with the session's own pid; it ends when that +# process does. Adopted from the predecessor's i3-bar-watchdog user unit of 2026-10-04. +set -uo pipefail + +session_pid="${1:-}" +interval="${I3_BAR_WATCHDOG_INTERVAL:-5}" +# Two misses in a row before acting: an i3 restart tears every bar down and starts them again. +confirm="${I3_BAR_WATCHDOG_CONFIRM:-2}" +# Never respawn the same bar more often than this, so a bar that dies at once is not a tight loop. +cooldown="${I3_BAR_WATCHDOG_COOLDOWN:-30}" + +log() { printf 'i3bar-watchdog: %s\n' "$*" >&2; } + +for tool in i3-msg i3bar pgrep; do + command -v "$tool" >/dev/null 2>&1 || { + log "$tool is missing; not watching" + exit 1 + } +done + +declare -A missed=() fixed=() + +bar_ids() { i3-msg -t get_bar_config 2>/dev/null | grep -oE '"[^"]+"' | tr -d '"'; } +outputs() { xrandr --listmonitors 2>/dev/null | tail -n +2 | awk '{print $2" "$3}' | tr '\n' ' '; } +session_alive() { [ -z "$session_pid" ] || kill -0 "$session_pid" 2>/dev/null; } + +while session_alive; do + sleep "$interval" + socket="$(i3 --get-socketpath 2>/dev/null)" + [ -n "$socket" ] && [ -S "$socket" ] || continue + ids="$(bar_ids)" + [ -n "$ids" ] || continue + while read -r id; do + [ -n "$id" ] || continue + if pgrep -u "$EUID" -f -- "i3bar --bar_id=$id" >/dev/null 2>&1; then + missed[$id]=0 + continue + fi + missed[$id]=$((${missed[$id]:-0} + 1)) + [ "${missed[$id]}" -ge "$confirm" ] || continue + now="$(date +%s)" + if [ $((now - ${fixed[$id]:-0})) -lt "$cooldown" ]; then + continue + fi + log "$id is gone; respawning it. Outputs now: $(outputs)" + nohup i3bar --bar_id="$id" --socket="$socket" >/dev/null 2>&1 & + disown 2>/dev/null || true + fixed[$id]="$now" + sleep 2 + if pgrep -u "$EUID" -f -- "i3bar --bar_id=$id" >/dev/null 2>&1; then + missed[$id]=0 + else + log "$id exited within 2s of respawning; check its status_command" + fi + done <<<"$ids" +done diff --git a/go/testdata/beside/mesh-catalog/modules/i3status-rust/files/bin/i3status-updates b/go/testdata/beside/mesh-catalog/modules/i3status-rust/files/bin/i3status-updates new file mode 100644 index 0000000..1a70cf9 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/i3status-rust/files/bin/i3status-updates @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +# i3status-updates [threshold] (module i3status-rust, novox/hq ADR 0208): the pending updates, for +# the bottom bar's custom block (json = true). Emits one JSON line. +# +# Adopted from the operator's pkg-updates of 2026-10-04. The native `packages` block showed a raw +# red error on every transient network failure, which on a roaming laptop is often. This keeps the +# last good result through a failure and shows an error only after `threshold` failures in a row +# (default 10). Official packages through checkupdates (pacman-contrib, a temporary database, no +# root); AUR packages through paru when it is installed, and none counted when it is not. +set -uo pipefail + +threshold="${1:-10}" +icon="update" +state_dir="${XDG_RUNTIME_DIR:-/tmp}/i3status-updates" +mkdir -p "$state_dir" +last="$state_dir/last_good.json" +failures="$state_dir/failures" + +json() { printf '{"icon":"%s","state":"%s","text":"%s","short_text":"%s"}' "$icon" "$1" "$2" "$3"; } +count() { [ -n "$1" ] && printf '%s\n' "$1" | grep -c . || echo 0; } + +official="$(checkupdates 2>/dev/null)" +official_rc=$? +aur="" aur_rc=0 +if command -v paru >/dev/null 2>&1; then + aur="$(paru -Qua 2>/dev/null)" + aur_rc=$? +fi + +# checkupdates: 0 updates listed, 2 none pending; anything else is a failure. +if { [ "$official_rc" -eq 0 ] || [ "$official_rc" -eq 2 ]; } && [ "$aur_rc" -eq 0 ]; then + o="$(count "$official")" + a="$(count "$aur")" + total=$((o + a)) + if [ "$total" -eq 0 ]; then + payload="$(json Good "up to date" "")" + else + # A kernel update wants a reboot: the one worth not putting off. + state=Info + printf '%s\n' "$official" | grep -qE '^(linux|linux-lts|linux-zen) ' && state=Warning + label="$o + $a = $total updates" + [ "$a" -eq 0 ] && label="$total updates" + [ "$total" -eq 1 ] && label="1 update" + payload="$(json "$state" "$label" "$total")" + fi + printf '%s' "$payload" >"$last" + printf '0' >"$failures" + printf '%s\n' "$payload" + exit 0 +fi + +n=$(($(cat "$failures" 2>/dev/null || echo 0) + 1)) +printf '%s' "$n" >"$failures" +if [ "$n" -ge "$threshold" ]; then + json Critical "update check failing (${n}x)" "!" + echo +elif [ -s "$last" ]; then + cat "$last" + echo +else + json Idle "" "" + echo +fi diff --git a/go/testdata/beside/mesh-catalog/modules/i3status-rust/files/bin/i3status-value b/go/testdata/beside/mesh-catalog/modules/i3status-rust/files/bin/i3status-value new file mode 100644 index 0000000..8fea95e --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/i3status-rust/files/bin/i3status-value @@ -0,0 +1,13 @@ +#!/bin/sh +# i3status-value (module i3status-rust, novox/hq ADR 0260): one value another module keeps +# on the bus, for a block it contributed. +# +# This module's own tools bundle watches the state on the bus (the mesh grants it the read) and leaves +# the value's text in this module's own directory; this prints it. A value not refreshed for thirty +# seconds is one its module stopped giving, and prints nothing, so the block hides rather than show an +# old number as if it were now. +dir="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/i3status-rust/values" +f="$dir/$1@$2" +if [ -r "$f" ] && [ $(( $(date +%s) - $(stat -c %Y "$f") )) -le 30 ]; then + head -n 1 "$f" +fi diff --git a/go/testdata/beside/mesh-catalog/modules/i3status-rust/files/bottom-bar.toml b/go/testdata/beside/mesh-catalog/modules/i3status-rust/files/bottom-bar.toml new file mode 100644 index 0000000..12e6805 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/i3status-rust/files/bottom-bar.toml @@ -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. +${collected:contribution:node-bar:block:bar=bottom,place=resources} +[[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. +${collected:contribution:node-bar:block:bar=bottom,place=status} +# 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')" diff --git a/go/testdata/beside/mesh-catalog/modules/i3status-rust/files/i3/60-i3status-rust.conf b/go/testdata/beside/mesh-catalog/modules/i3status-rust/files/i3/60-i3status-rust.conf new file mode 100644 index 0000000..20acbb1 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/i3status-rust/files/i3/60-i3status-rust.conf @@ -0,0 +1,34 @@ +# 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 + } +} diff --git a/go/testdata/beside/mesh-catalog/modules/i3status-rust/files/icons/custom-icons.toml b/go/testdata/beside/mesh-catalog/modules/i3status-rust/files/icons/custom-icons.toml new file mode 100644 index 0000000..9636a82 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/i3status-rust/files/icons/custom-icons.toml @@ -0,0 +1,135 @@ +# Icons for the bars (module i3status-rust, novox/hq ADR 0208), from the JetBrains Mono Nerd Font. +# Owned by the mesh: replaced at every push. Adopted unchanged from the workstations of 2026-10-04. +# Material from NerdFont +# Codepoints from the Nerd Fonts cheat sheet +backlight = [ + "\ue38d", # nf-weather-moon_new + "\ue3d4", # nf-weather-moon_alt_waxing_gibbous_6 + "\ue3d3", # nf-weather-moon_alt_waxing_gibbous_5 + "\ue3d2", # nf-weather-moon_alt_waxing_gibbous_4 + "\ue3d1", # nf-weather-moon_alt_waxing_gibbous_3 + "\ue3d0", # nf-weather-moon_alt_waxing_gibbous_2 + "\ue3cf", # nf-weather-moon_alt_waxing_gibbous_1 + "\ue3ce", # nf-weather-moon_alt_first_quarter + "\ue3cd", # nf-weather-moon_alt_waxing_crescent_6 + "\ue3cc", # nf-weather-moon_alt_waxing_crescent_5 + "\ue3cb", # nf-weather-moon_alt_waxing_crescent_4 + "\ue3ca", # nf-weather-moon_alt_waxing_crescent_3 + "\ue3c9", # nf-weather-moon_alt_waxing_crescent_2 + "\ue3c8", # nf-weather-moon_alt_waxing_crescent_1 + "\ue39b", # nf-weather-moon_full +] +bat_charging = "\U000f0084" # nf-md-battery_charging +bat_not_available = "\U000f0091" # nf-md-battery_unknown +bat = [ + "\U000f007a", # nf-md-battery_10 + "\U000f007b", # nf-md-battery_20 + "\U000f007c", # nf-md-battery_30 + "\U000f007d", # nf-md-battery_40 + "\U000f007e", # nf-md-battery_50 + "\U000f007f", # nf-md-battery_60 + "\U000f0080", # nf-md-battery_70 + "\U000f0081", # nf-md-battery_80 + "\U000f0082", # nf-md-battery_90 + "\U000f0079", # nf-md-battery +] +bell = "\U000f009c" # nf-md-bell_outline +bell-slash = "\U000f009b" # nf-md-bell_off +blackberry = "\uf307" # nf-md-blackberry +bluetooth = "\U000f00af" # nf-md-bluetooth +calendar = "\U000f00ed" # nf-md-calendar +cogs = "\U000f0493" # nf-md-cog +cpu = [ + "\U000F0F86", # nf-md-speedometer_slow + "\U000F0F85", # nf-md-speedometer_medium + "\U000F04C5", # nf-md-speedometer +] +cpu_boost_on = "\U000f0521" # nf-md-toggle_switch +cpu_boost_off = "\U000f0a19" # nf-md-toggle_switch_off_outline +disk_drive = "\U000f02ca" # nf-md-harddisk +docker = "\uf308" # nf-linux-docker +github = "\U000f02a4" # nf-md-github +gpu = "\U000f0379" # nf-md-monitor +headphones = "\U000f02cb" # nf-md-headphones +joystick = "\U000f0297" # nf-md-gamepad_variant +keyboard = "\U000f030c" # nf-md-keyboard +mail = "\U000f01ee" # nf-md-email +memory_mem = "\U000f035b" # nf-md-memory +memory_swap = "\U000f02ca" # nf-md-harddisk +mouse = "\U000f037d" # nf-md-mouse +music = "\U000f075a" # nf-md-music +music_next = "\U000f04ad" # nf-md-skip_next +music_pause = "\U000f03e4" # nf-md-pause +music_play = "\U000f040a" # nf-md-play +music_prev = "\U000f04ae" # nf-md-skip_previous +net_bridge = "\U000f04aa" # nf-md-sitemap +net_down = "\U000f01da" # nf-md-download +net_loopback = "\U000f006f" # nf-md-backup_restore +net_modem = "\U000f03f2" # nf-md-phone +net_cellular = [ + "\U000F08FD", # nf-md-network_strength_off_outline + "\U000F08FE", # nf-md-network_strength_outline + "\U000F08F4", # nf-md-network_strength_1 + "\U000F08F6", # nf-md-network_strength_2 + "\U000F08F8", # nf-md-network_strength_3 + "\U000F08FA", # nf-md-network_strength_4 +] +net_up = "\U000f0552" # nf-md-upload +net_vpn = "\U000f0582" # nf-md-vpn +net_wired = "\U000f0200" # nf-md-ethernet +net_wireless = [ + "\U000F092F", # nf-md-wifi_strength_outline + "\U000F091F", # nf-md-wifi_strength_1 + "\U000F0922", # nf-md-wifi_strength_2 + "\U000F0925", # nf-md-wifi_strength_3 + "\U000F0928", # nf-md-wifi_strength_4 +] +notification = "\U000f009c" # nf-md-bell_outline +phone = "\U000f03f2" # nf-md-phone +phone_disconnected = "\U000f0658" # nf-md-phone_minus +ping = "\U000f051f" # nf-md-timer_sand +pomodoro = "\ue001" # nf-pom-pomodoro_done +pomodoro_break = "\U000f0176" # nf-md-coffee +pomodoro_paused = "\U000f03e4" # nf-md-pause +pomodoro_started = "\U000f040a" # nf-md-play +pomodoro_stopped = "\U000f04db" # nf-md-stop +resolution = "\U000f0293" # nf-md-fullscreen +tasks = "\U000f05c7" # nf-md-playlist_check +tea = "\U000f0d9e" # nf-md-tea +thermometer = [ + "\U000f10c3", # nf-md-thermometer_low + "\U000f050f", # nf-md-thermometer + "\U000f10c2", # nf-md-thermometer_high +] +time = "\U000f0150" # nf-md-clock_outline +toggle_off = "\U000f0a19" # nf-md-toggle_switch_off_outline +toggle_on = "\U000f0521" # nf-md-toggle_switch +unknown = "\U000f0186" # nf-md-comment_question_outline | TODO: Make default? +update = "\U000f03d5" # nf-md-package_up +uptime = "\U000f0153" # nf-md-clock_in +volume_muted = "\U000f075f" # nf-md-volume_mute +volume = [ + "\U000f057f", # nf-md-volume_low + "\U000f0580", # nf-md-volume_medium + "\U000f057e", # nf-md-volume_high +] +microphone_muted = "\U000f036d" # nf-md-microphone_off +microphone = [ + "\U000f036e", # nf-md-microphone_outline + "\U000f036c", # nf-md-microphone + "\U000f036c", # nf-md-microphone +] +xrandr = "\U000f037a" # nf-md-monitor_multiple + +# Weather icons (the names the weather block documents) +weather_sun = "\ue30d" # nf-weather-day_sunny (when weather is reported as “Clear” during the day) +weather_moon = "\ue32b" # nf-weather-night_clear (when weather is reported as “Clear” at night) +weather_clouds = "\ue312" # nf-weather-cloudy (when weather is reported as “Clouds” during the day) +weather_clouds_night = "\ue37e" # nf-weather-night_alt_cloudy (when weather is reported as “Clouds” at night) +weather_fog = "\ue313" # nf-weather-fog (when weather is reported as “Fog” or “Mist” during the day) +weather_fog_night = "\ue346" # nf-weather-night_fog (when weather is reported as “Fog” or “Mist” at night) +weather_rain = "\ue318" # nf-weather-rain (when weather is reported as “Rain” or “Drizzle” during the day) +weather_rain_night = "\ue325" # nf-weather-night_alt_rain (when weather is reported as “Rain” or “Drizzle” at night) +weather_snow = "\ue31a" # nf-weather-snow (when weather is reported as “Snow”) +weather_thunder = "\ue31d" # nf-weather-thunderstorm (when weather is reported as “Thunderstorm” during the day) +weather_thunder_night = "\ue32a" # nf-weather-night_alt_thunderstorm (when weather is reported as “Thunderstorm” at night) diff --git a/go/testdata/beside/mesh-catalog/modules/i3status-rust/files/top-bar.toml b/go/testdata/beside/mesh-catalog/modules/i3status-rust/files/top-bar.toml new file mode 100644 index 0000000..be198e8 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/i3status-rust/files/top-bar.toml @@ -0,0 +1,37 @@ +# 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. +${collected:contribution:node-bar:block:bar=top,place=resources}${collected:contribution:node-bar:block:bar=top,place=status} diff --git a/go/testdata/beside/mesh-catalog/modules/i3status-rust/files/xinitrc-normal b/go/testdata/beside/mesh-catalog/modules/i3status-rust/files/xinitrc-normal new file mode 100644 index 0000000..888acdb --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/i3status-rust/files/xinitrc-normal @@ -0,0 +1,3 @@ +# 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" "$$" & diff --git a/go/testdata/beside/mesh-catalog/modules/i3status-rust/module.json.captured b/go/testdata/beside/mesh-catalog/modules/i3status-rust/module.json.captured new file mode 100644 index 0000000..1e07556 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/i3status-rust/module.json.captured @@ -0,0 +1,126 @@ +{ + "module": "i3status-rust", + "version": "1", + "moved": true, + "capabilities": [ + "package-manager" + ], + "requires": [ + "x11-display" + ], + "claims": [ + { + "name": "node-bar", + "scope": "node" + } + ], + "tools": [ + "i3status_rust_reload", + "i3status_rust_blocks", + "i3status_rust_block_run", + "i3status_rust_themes" + ], + "items": [ + { + "collection": "shell:xinitrc:normal", + "name": "xinitrc-normal", + "template": "files/xinitrc-normal" + }, + { + "collection": "contribution:node-display-session:config", + "name": "display-session", + "template": "files/i3/60-i3status-rust.conf" + } + ], + "resources": [ + { + "id": "package", + "type": "package", + "package": "i3status-rust" + }, + { + "id": "configuration-dir", + "type": "directory", + "path": "${machine:account-home}/.config/i3status-rust", + "owner": "${machine:account}", + "mode": "0755", + "unknown": "move-out" + }, + { + "id": "icons-dir", + "type": "directory", + "path": "${machine:account-home}/.config/i3status-rust/icons", + "owner": "${machine:account}", + "mode": "0755", + "unknown": "move-out" + }, + { + "id": "top-bar", + "type": "file", + "path": "${machine:account-home}/.config/i3status-rust/top-bar.toml", + "owner": "${machine:account}", + "mode": "0644", + "template": "files/top-bar.toml" + }, + { + "id": "bottom-bar", + "type": "file", + "path": "${machine:account-home}/.config/i3status-rust/bottom-bar.toml", + "owner": "${machine:account}", + "mode": "0644", + "template": "files/bottom-bar.toml" + }, + { + "id": "icons", + "type": "file", + "path": "${machine:account-home}/.config/i3status-rust/icons/custom-icons.toml", + "owner": "${machine:account}", + "mode": "0644", + "template": "files/icons/custom-icons.toml" + }, + { + "id": "updates", + "type": "file", + "path": "${machine:account-home}/.local/bin/i3status-updates", + "owner": "${machine:account}", + "mode": "0755", + "template": "files/bin/i3status-updates" + }, + { + "id": "watchdog", + "type": "file", + "path": "${machine:account-home}/.local/bin/i3bar-watchdog", + "owner": "${machine:account}", + "mode": "0755", + "template": "files/bin/i3bar-watchdog" + }, + { + "id": "value", + "type": "file", + "path": "${machine:account-home}/.local/bin/i3status-value", + "owner": "${machine:account}", + "mode": "0755", + "template": "files/bin/i3status-value" + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/i3status-rust-tools", + "binary": "i3status-rust-tools", + "loads": [ + "i3status-rust-tools" + ] + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/go/testdata/beside/mesh-catalog/modules/icecast/files/config.json b/go/testdata/beside/mesh-catalog/modules/icecast/files/config.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/icecast/files/config.json @@ -0,0 +1 @@ +{} diff --git a/go/testdata/beside/mesh-catalog/modules/icecast/files/icecast.xml b/go/testdata/beside/mesh-catalog/modules/icecast/files/icecast.xml new file mode 100644 index 0000000..de4dd79 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/icecast/files/icecast.xml @@ -0,0 +1,56 @@ + + + Earth + icemaster@localhost + + 100 + 2 + 524288 + 30 + 15 + 10 + 1 + 65535 + + + ${secret:source} + ${secret:relay} + admin + ${secret:admin} + + + localhost + + 8000 + + +

+ + 1 + + /usr/share/icecast + /var/log/icecast + /usr/share/icecast/web + /usr/share/icecast/admin + + + + access.log + error.log + 3 + 10000 + + + 0 + + + icecast + icecast + + + diff --git a/go/testdata/beside/mesh-catalog/modules/icecast/module.json.captured b/go/testdata/beside/mesh-catalog/modules/icecast/module.json.captured new file mode 100644 index 0000000..6f9ab54 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/icecast/module.json.captured @@ -0,0 +1,152 @@ +{ + "module": "icecast", + "version": "1", + "requires": [ + "route", + "secret" + ], + "contributes": { + "route": { + "label": "icecast", + "endpoint": "stream" + } + }, + "binds": { + "route": "${dir:state}/route.json" + }, + "secrets": { + "secret": { + "source": "${dir:state}/source.secret", + "admin": "${dir:state}/admin.secret", + "relay": "${dir:state}/relay.secret" + } + }, + "capabilities": [ + "container-runtime" + ], + "emits": [ + "stream.started", + "stream.stopped" + ], + "listens": [ + { + "name": "stream", + "port": 8000, + "protocol": "tcp", + "from": "mesh", + "why": "streams in from sources (HTTP PUT) and out to listeners, plus the status and admin pages; a public name is its route" + } + ], + "data": { + "own": [ + { + "id": "logs", + "path": "${dir:logs}", + "class": "cache", + "why": "the streaming server's logs" + } + ] + }, + "config": { + "url": { + "type": "string", + "description": "The address the module's tools reach Icecast at, as a URL; unset, the code falls back to its environment, else this machine's Icecast on its port.", + "trusted": true, + "default": "" + }, + "user": { + "type": "string", + "description": "The Icecast admin account the module's tools sign in as; unset, the code falls back to its environment, else admin.", + "trusted": true, + "default": "" + } + }, + "resources": [ + { + "id": "mesh-state", + "type": "directory", + "mode": "0700", + "place": "mesh", + "unknown": "program" + }, + { + "id": "state", + "type": "directory", + "mode": "0700", + "place": ".", + "unknown": "program" + }, + { + "id": "logs", + "type": "directory", + "mode": "0700", + "owner": "100:101", + "unknown": "program" + }, + { + "id": "server-conf", + "type": "file", + "path": "${dir:state}/icecast.xml", + "mode": "0600", + "template": "files/icecast.xml" + }, + { + "id": "net", + "type": "network", + "name": "icecast" + }, + { + "id": "server", + "type": "container", + "name": "icecast", + "image": "infiniteproject/icecast@sha256:cd506cf3dfe31ce05fd37d7e672dbd1213e7255cc93d28ecf5a3b547af4e162c", + "network": "icecast", + "ports": [ + "8000" + ], + "volumes": [ + "${dir:state}/icecast.xml:/etc/icecast.xml:ro", + "${dir:logs}:/var/log/icecast" + ], + "restart-on": [ + "server-conf" + ] + }, + { + "id": "runtime-config", + "type": "file", + "path": "${dir:mesh-state}/config.json", + "mode": "0600", + "template": "files/config.json", + "merge": "json", + "takes-config": ["url", "user"] + } + ], + "build": { + "artifacts": [ + { + "name": "code", + "kind": "bundle", + "language": "typescript", + "entrypoints": [ + "index.js", + "tools/index.js" + ], + "loads": [ + "index.js", + "tools/index.js" + ], + "env": { + "MESH_ICECAST_URL": "http://127.0.0.1:${port:8000}", + "MESH_ICECAST_CONFIG_FILE": "${dir:mesh-state}/config.json", + "MESH_ICECAST_ADMIN_PASSWORD_FILE": "${dir:state}/admin.secret" + } + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/go/testdata/beside/mesh-catalog/modules/influxdb/files/config.json b/go/testdata/beside/mesh-catalog/modules/influxdb/files/config.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/influxdb/files/config.json @@ -0,0 +1 @@ +{} diff --git a/go/testdata/beside/mesh-catalog/modules/influxdb/files/server.env b/go/testdata/beside/mesh-catalog/modules/influxdb/files/server.env new file mode 100644 index 0000000..d2552e0 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/influxdb/files/server.env @@ -0,0 +1,6 @@ +DOCKER_INFLUXDB_INIT_MODE=setup +DOCKER_INFLUXDB_INIT_USERNAME=admin +DOCKER_INFLUXDB_INIT_PASSWORD_FILE=/run/secrets/admin +DOCKER_INFLUXDB_INIT_ADMIN_TOKEN_FILE=/run/secrets/admin-token +DOCKER_INFLUXDB_INIT_ORG=mesh +DOCKER_INFLUXDB_INIT_BUCKET=default diff --git a/go/testdata/beside/mesh-catalog/modules/influxdb/module.json.captured b/go/testdata/beside/mesh-catalog/modules/influxdb/module.json.captured new file mode 100644 index 0000000..53389a5 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/influxdb/module.json.captured @@ -0,0 +1,198 @@ +{ + "module": "influxdb", + "version": "1", + "provides": [ + { + "name": "influxdb-api", + "scope": "mesh", + "identity": { + "in": "an InfluxDB v1 authorization" + } + } + ], + "capabilities": [ + "container-runtime" + ], + "own-secrets": { + "admin": "${dir:state}/admin.secret", + "admin-token": "${dir:state}/admin-token.secret" + }, + "listens": [ + { + "name": "api", + "port": 8086, + "protocol": "tcp", + "from": "mesh", + "why": "queries, writes and the web UI, over http; consumers granted influxdb-api sign in with the mesh's credential, and a name is a route grant" + } + ], + "config": { + "org": { + "type": "string", + "description": "The organisation influxdb-api names to its consumers and the module's tools write to; mesh is the one influxdb makes at its first start (server.env), and a machine whose server was set up with another sets its own (novox/hq issue 696, ADR 0348 clause 11).", + "trusted": true, + "default": "mesh" + }, + "bucket": { + "type": "string", + "description": "The bucket influxdb-api names to its consumers and the module's tools write to; default is the one influxdb makes at its first start (server.env), and a machine whose server was set up with another sets its own (novox/hq issue 696, ADR 0348 clause 11).", + "trusted": true, + "default": "default" + }, + "url": { + "type": "string", + "description": "The address the module's tools reach influxdb at, as a URL; empty is this machine's influxdb on its port.", + "trusted": true, + "default": "" + } + }, + "serves": { + "influxdb-api": { + "scheme": "http", + "port": 8086, + "org": "${config:org}", + "bucket": "${config:bucket}" + } + }, + "receives": { + "influxdb-api": "${dir:grants}/mesh.json" + }, + "grants": { + "influxdb-api": "${dir:grants}" + }, + "data": { + "own": [ + { + "id": "data", + "path": "${dir:data}", + "class": "valuable", + "why": "every consumer's time series" + }, + { + "id": "config", + "path": "${dir:config}", + "class": "valuable", + "why": "the server's own configuration and its operator token, made at its first start" + } + ], + "consumers": { + "influxdb-api": { + "class": "valuable", + "in": "data", + "why": "a consumer's measurements are the only copy" + } + } + }, + "resources": [ + { + "id": "mesh-state", + "type": "directory", + "mode": "0700", + "place": "mesh", + "unknown": "program" + }, + { + "id": "state", + "type": "directory", + "mode": "0700", + "place": ".", + "unknown": "program" + }, + { + "id": "data", + "type": "directory", + "mode": "0700", + "owner": "1000:1000", + "unknown": "program" + }, + { + "id": "config", + "type": "directory", + "mode": "0700", + "owner": "1000:1000", + "unknown": "program" + }, + { + "id": "grants", + "type": "directory", + "mode": "0700", + "unknown": "program" + }, + { + "id": "server-env", + "type": "file", + "path": "${dir:state}/server.env", + "mode": "0600", + "template": "files/server.env" + }, + { + "id": "server", + "type": "container", + "name": "influxdb", + "image": "influxdb@sha256:f75e48af0598e8aec7986e991a848d19a119101a7d563a2e5db1dfaac9c45daa", + "env-file": [ + "${dir:state}/server.env" + ], + "ports": [ + "8086" + ], + "volumes": [ + "${dir:data}:/var/lib/influxdb2", + "${dir:config}:/etc/influxdb2", + "${dir:state}/admin.secret:/run/secrets/admin:ro", + "${dir:state}/admin-token.secret:/run/secrets/admin-token:ro" + ] + }, + { + "id": "runtime-config", + "type": "file", + "path": "${dir:mesh-state}/config.json", + "mode": "0600", + "template": "files/config.json", + "merge": "json", + "takes-config": [ + "bucket", + "org", + "url" + ], + "trusted": true + } + ], + "requires": [ + "route" + ], + "contributes": { + "route": { + "label": "influxdb", + "endpoint": "api" + } + }, + "build": { + "artifacts": [ + { + "name": "code", + "kind": "bundle", + "language": "typescript", + "entrypoints": [ + "tools/index.js", + "provisioner/index.js" + ], + "loads": [ + "tools/index.js", + "provisioner/index.js" + ], + "env": { + "MESH_INFLUXDB_URL": "http://127.0.0.1:${port:8086}", + "MESH_INFLUXDB_CONFIG_FILE": "${dir:mesh-state}/config.json", + "MESH_INFLUXDB_TOKEN_FILE": "${dir:state}/admin-token.secret", + "MESH_RECEIVES": "${dir:grants}/mesh.json" + } + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/go/testdata/beside/mesh-catalog/modules/invoicing/files/api.env b/go/testdata/beside/mesh-catalog/modules/invoicing/files/api.env new file mode 100644 index 0000000..8eaee6f --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/invoicing/files/api.env @@ -0,0 +1,9 @@ +NODE_ENV=production +PORT=9000 +MONGO_URL=mongodb://${bound:mongodb-database:as}:${secret:mongodb-database}@${bound:mongodb-database:at}:${bound:mongodb-database:port}/${bound:mongodb-database:as}?authSource=${bound:mongodb-database:as} +MONGO_DB=${bound:mongodb-database:as} +MINIO_BUCKET=${bound:s3-bucket:bucket} +MINIO_ENDPOINT=${bound:s3-bucket:at} +MINIO_PORT=${bound:s3-bucket:port} +MINIO_ACCESSKEY=${bound:s3-bucket:as} +MINIO_SECRET=${secret:s3-bucket} diff --git a/go/testdata/beside/mesh-catalog/modules/invoicing/module.json.captured b/go/testdata/beside/mesh-catalog/modules/invoicing/module.json.captured new file mode 100644 index 0000000..29670a3 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/invoicing/module.json.captured @@ -0,0 +1,128 @@ +{ + "module": "invoicing", + "version": "1", + "slug": "invoice", + "capabilities": [ + "container-runtime" + ], + "requires": [ + "mongodb-database", + "s3-bucket", + "route" + ], + "contributes": { + "mongodb-database": { + "name": "invoicing" + }, + "route": { + "site": { + "label": "invoicing", + "endpoint": "web" + }, + "api": { + "label": "invoicing-api", + "endpoint": "api" + } + } + }, + "binds": { + "mongodb-database": "${dir:state}/database.json", + "s3-bucket": "${dir:state}/store.json", + "route": "${dir:state}/route.json" + }, + "secrets": { + "mongodb-database": "${dir:state}/database.secret", + "s3-bucket": "${dir:state}/store.secret" + }, + "listens": [ + { + "name": "web", + "port": 80, + "protocol": "tcp", + "from": "mesh", + "why": "the invoicing web frontend; a public name is a route grant later" + }, + { + "name": "api", + "port": 9000, + "protocol": "tcp", + "from": "mesh", + "why": "the invoicing REST API the frontend and integrations call" + } + ], + "resources": [ + { + "id": "mesh-state", + "type": "directory", + "mode": "0700", + "place": "mesh", + "unknown": "program" + }, + { + "id": "state", + "type": "directory", + "mode": "0700", + "place": ".", + "unknown": "program" + }, + { + "id": "api-env", + "type": "file", + "path": "${dir:state}/api.env", + "mode": "0600", + "template": "files/api.env" + }, + { + "id": "net", + "type": "network", + "name": "invoicing" + }, + { + "id": "app", + "type": "container", + "name": "invoicing-app", + "image": "registry-api.novox.be/novox/invoicing-app@sha256:1e6ed40822f07169b24867cbfe8fc1ab3ef6a15ad642f3b3a2882a8e15c3dbec", + "network": "invoicing", + "env": { + "UID": "2201", + "GID": "2201" + }, + "ports": [ + "80" + ], + "names-on-purpose": { + "registry-api.novox.be": "built outside the mesh, from the application's own repository, and pulled from the registry that built it; moves when that repository is a build source on the git seat (novox/hq ADR 0155, issue 122)" + } + }, + { + "id": "api", + "type": "container", + "name": "invoicing-api", + "image": "registry-api.novox.be/novox/invoicing-api@sha256:efa6fba1fa9ba78849e94e958d33793a76654df0468e3012da9c02c54c265354", + "network": "invoicing", + "env": { + "UID": "2201", + "GID": "2201" + }, + "env-file": [ + "${dir:state}/api.env" + ], + "ports": [ + "9000" + ], + "secrets-in-environment": "the application's own code reads MONGO_URL and MINIO_SECRET from the environment (invoicing-app server/src/config.js); converting is that repository's change", + "names-on-purpose": { + "registry-api.novox.be": "built outside the mesh, from the application's own repository, and pulled from the registry that built it; moves when that repository is a build source on the git seat (novox/hq ADR 0155, issue 122)" + } + } + ], + "build": { + "artifacts": [ + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/go/testdata/beside/mesh-catalog/modules/jira/files/config.json b/go/testdata/beside/mesh-catalog/modules/jira/files/config.json new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/jira/files/config.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/go/testdata/beside/mesh-catalog/modules/jira/module.json.captured b/go/testdata/beside/mesh-catalog/modules/jira/module.json.captured new file mode 100644 index 0000000..9f98dd8 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/jira/module.json.captured @@ -0,0 +1,75 @@ +{ + "module": "jira", + "version": "1", + "own-secrets": { + "token": "${dir:state}/token" + }, + "config": { + "atlassian-url": { + "type": "string", + "description": "The address of the operator's Atlassian site the jira tools call, as a URL such as https://example.atlassian.net, laid into the file as ATLASSIAN_URL; unset, the code falls back to its environment and answers no calls until it is given.", + "trusted": true, + "default": "" + }, + "atlassian-email": { + "type": "string", + "description": "The email address of the Atlassian account the jira tools sign in as with its API token, laid into the file as ATLASSIAN_EMAIL; unset, the code falls back to its environment and answers no calls until it is given.", + "trusted": true, + "default": "" + } + }, + "resources": [ + { + "id": "state", + "type": "directory", + "mode": "0700", + "place": ".", + "unknown": "program" + }, + { + "id": "config", + "type": "file", + "path": "${dir:state}/config.json", + "merge": "json", + "template": "files/config.json", + "mode": "0600", + "takes-config": [ + { + "key": "atlassian-url", + "as": "ATLASSIAN_URL" + }, + { + "key": "atlassian-email", + "as": "ATLASSIAN_EMAIL" + } + ] + } + ], + "capabilities": [ + "container-runtime" + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "typescript", + "entrypoints": [ + "tools/index.js" + ], + "loads": [ + "tools/index.js" + ], + "env": { + "MESH_JIRA_TOKEN_FILE": "${dir:state}/token", + "MESH_JIRA_CONFIG_FILE": "${dir:state}/config.json" + } + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/go/testdata/beside/mesh-catalog/modules/kernel/files/huge-pages.sh b/go/testdata/beside/mesh-catalog/modules/kernel/files/huge-pages.sh new file mode 100644 index 0000000..e0a9815 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/kernel/files/huge-pages.sh @@ -0,0 +1,84 @@ +#!/bin/sh +# Transparent huge pages as the mesh sets them on this machine (module kernel, novox/hq issue 746). +# Written by the mesh from this machine's setting transparent-huge-pages and run by +# mesh-kernel-huge-pages.service: `apply` at boot and whenever the setting changes, `restore` when the +# unit stops (the module unassigned, or the machine shutting down). Replaced on every push; change the +# setting, never this file. A change of the setting reloads the unit, which runs `apply` again: the mode +# moves straight to the new value and never passes back through the one the machine was found with. +# +# huge-pages apply|restore [ []] +# +# The two directories are arguments only so the module's tests can run this against a stand-in tree. +set -eu + +want='${config:transparent-huge-pages}' +sys=/sys/kernel/mm/transparent_hugepage +state='${dir:state}' +[ $# -ge 2 ] && sys=$2 +[ $# -ge 3 ] && state=$3 + +case "$want" in +always | madvise | never) ;; +*) + echo "the setting transparent-huge-pages is '$want'; the kernel takes always, madvise or never" >&2 + exit 2 + ;; +esac +[ -f "$sys/enabled" ] || { + echo "this kernel has no $sys/enabled: it was built without transparent huge pages, so there is nothing to set" >&2 + exit 1 +} + +# The mode in force is the word the kernel brackets: "always [madvise] never". A file holding one bare +# word (the stand-in tree of the tests) is read as that word. +mode() { sed -n -e 's/.*\[\([a-z]*\)\].*/\1/p' -e t -e 's/^ *\([a-z]*\) *$/\1/p' "$sys/enabled"; } + +[ $# -ge 1 ] || set -- usage +case "$1" in +apply) + now=$(mode) + # What the machine had before the mesh first set it, kept once, so `restore` gives it back. + [ -s "$state/found" ] || printf '%s\n' "$now" >"$state/found" + if [ "$now" = "$want" ]; then + echo "transparent huge pages: $want, as set" + exit 0 + fi + printf '%s\n' "$want" >"$sys/enabled" + got=$(mode) + [ "$got" = "$want" ] || { + echo "wrote $want to $sys/enabled and the kernel says $got" >&2 + exit 1 + } + echo "transparent huge pages: $now -> $want" + # A file folio already in the page cache keeps its size: the active journal files, mapped by journald + # and by every reader, stay in 2 MiB folios until they are archived (issue 746). Rotating starts new + # files that are mapped under the new mode. A failed rotation is said and does not fail the setting. + # At boot nothing is in huge folios yet, and the persistent journal opens only after this unit: no rotation + # while the service manager is still bringing the machine up. + booting=$(systemctl is-system-running 2>/dev/null || true) + case "$booting" in + initializing | starting) echo "the machine is still starting: nothing to rotate yet" ;; + *) + if [ "$want" != always ] && command -v journalctl >/dev/null 2>&1; then + journalctl --rotate || echo "the journal could not be rotated: its active files keep their huge pages until they are archived" >&2 + fi + ;; + esac + ;; +restore) + found=$(cat "$state/found" 2>/dev/null || true) + case "$found" in + always | madvise | never) + printf '%s\n' "$found" >"$sys/enabled" + echo "transparent huge pages: back to $found, as the machine had them" + ;; + *) echo "transparent huge pages: nothing recorded as found, left at $(mode)" ;; + esac + # Unassigned or shut down: nothing of the module is kept; the next start records what it finds again. + rm -f "$state/found" + ;; +*) + echo "usage: huge-pages apply|restore [ []]" >&2 + exit 2 + ;; +esac diff --git a/go/testdata/beside/mesh-catalog/modules/kernel/files/mesh-kernel-huge-pages.service b/go/testdata/beside/mesh-catalog/modules/kernel/files/mesh-kernel-huge-pages.service new file mode 100644 index 0000000..77a9059 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/kernel/files/mesh-kernel-huge-pages.service @@ -0,0 +1,25 @@ +[Unit] +Description=Transparent huge pages as the mesh sets them on this machine +# Module kernel, novox/hq issue 746. Starting this unit sets the mode from the setting +# transparent-huge-pages; reloading it (the setting changed) sets the new mode; stopping it gives back the +# mode the machine had before. It runs early in boot, before the services that fill the page cache. +DefaultDependencies=no +After=local-fs.target +Before=sysinit.target shutdown.target +Conflicts=shutdown.target + +[Service] +Type=oneshot +RemainAfterExit=yes +ExecStart=${dir:state}/huge-pages apply +ExecReload=${dir:state}/huge-pages apply +ExecStop=${dir:state}/huge-pages restore +# It writes one file under /sys and its own state, and asks the journal daemon to rotate over its socket. +ProtectSystem=strict +ReadWritePaths=${dir:state} +ProtectHome=yes +PrivateNetwork=yes +NoNewPrivileges=yes + +[Install] +WantedBy=sysinit.target diff --git a/go/testdata/beside/mesh-catalog/modules/kernel/module.json.captured b/go/testdata/beside/mesh-catalog/modules/kernel/module.json.captured new file mode 100644 index 0000000..67d3215 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/kernel/module.json.captured @@ -0,0 +1,78 @@ +{ + "module": "kernel", + "version": "1", + "capabilities": [ + "service-manager" + ], + "config": { + "transparent-huge-pages": { + "type": "string", + "description": "madvise is the value the mesh chose for every node (novox/hq ADR 0381), not the always the distribution's kernel boots with; a machine that needs another value is given it as its own setting; madvise gives huge pages only to programs that ask for them, which stops a small write to a mapped file (the journal) dirtying and writing back a whole 2 MiB page on a machine whose disks are slow (novox/hq issue 746)", + "trusted": true, + "default": "madvise" + } + }, + "tools": [ + "kernel_huge_pages" + ], + "resources": [ + { + "id": "state", + "type": "directory", + "mode": "0700", + "place": ".", + "unknown": "program" + }, + { + "id": "huge-pages", + "type": "file", + "path": "${dir:state}/huge-pages", + "mode": "0755", + "trusted": true, + "template": "files/huge-pages.sh" + }, + { + "id": "unit", + "type": "file", + "path": "/etc/systemd/system/mesh-kernel-huge-pages.service", + "mode": "0644", + "template": "files/mesh-kernel-huge-pages.service" + }, + { + "id": "apply", + "type": "service", + "unit": "mesh-kernel-huge-pages.service", + "state": "running", + "boot": "enabled", + "restart-on": [ + "unit" + ], + "reload-on": [ + "huge-pages" + ], + "health": { + "kind": "unit" + } + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/kernel-tools", + "binary": "kernel-tools", + "loads": [ + "kernel-tools" + ] + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/go/testdata/beside/mesh-catalog/modules/keycloak/files/admin.env b/go/testdata/beside/mesh-catalog/modules/keycloak/files/admin.env new file mode 100644 index 0000000..3e52ae5 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/keycloak/files/admin.env @@ -0,0 +1,2 @@ +KEYCLOAK_ADMIN=admin +KEYCLOAK_ADMIN_PASSWORD=${secret:admin} diff --git a/go/testdata/beside/mesh-catalog/modules/keycloak/files/config.json b/go/testdata/beside/mesh-catalog/modules/keycloak/files/config.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/keycloak/files/config.json @@ -0,0 +1 @@ +{} diff --git a/go/testdata/beside/mesh-catalog/modules/keycloak/files/consumers.json b/go/testdata/beside/mesh-catalog/modules/keycloak/files/consumers.json new file mode 100644 index 0000000..b0af957 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/keycloak/files/consumers.json @@ -0,0 +1 @@ +${consumers:oidc-client} diff --git a/go/testdata/beside/mesh-catalog/modules/keycloak/files/database.env b/go/testdata/beside/mesh-catalog/modules/keycloak/files/database.env new file mode 100644 index 0000000..454ee99 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/keycloak/files/database.env @@ -0,0 +1,3 @@ +KC_DB_URL=jdbc:postgresql://${bound:postgres-database:at}:${bound:postgres-database:port}/${bound:postgres-database:as} +KC_DB_USERNAME=${bound:postgres-database:as} +KC_DB_PASSWORD=${secret:postgres-database} diff --git a/go/testdata/beside/mesh-catalog/modules/keycloak/files/hostname.env b/go/testdata/beside/mesh-catalog/modules/keycloak/files/hostname.env new file mode 100644 index 0000000..92f061a --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/keycloak/files/hostname.env @@ -0,0 +1 @@ +KC_HOSTNAME=https://${bound:route:name} diff --git a/go/testdata/beside/mesh-catalog/modules/keycloak/module.json.captured b/go/testdata/beside/mesh-catalog/modules/keycloak/module.json.captured new file mode 100644 index 0000000..847d5f4 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/keycloak/module.json.captured @@ -0,0 +1,245 @@ +{ + "module": "keycloak", + "version": "1", + "moved": true, + "upgrade": { + "policy": "record", + "why": "every person's sign-in to every site goes through it, and its new version migrates its database on start: a person takes each build, after a backup (hq ADR 0236)" + }, + "provides": [ + { + "name": "oidc-client", + "scope": "mesh", + "identity": { + "max": 255, + "in": "a Keycloak client id" + } + } + ], + "requires": [ + "postgres-database", + "route" + ], + "contributes": { + "postgres-database": { + "name": "keycloak" + }, + "route": { + "label": "keycloak", + "endpoint": "web" + } + }, + "binds": { + "postgres-database": "${dir:state}/database.json", + "route": "${dir:state}/route.json" + }, + "secrets": { + "postgres-database": "${dir:state}/database.secret" + }, + "capabilities": [ + "container-runtime" + ], + "emits": [ + "user.created", + "user.deleted", + "password.reset", + "client.created", + "client.retired", + "group.created", + "role.created", + "admin.repaired", + "admin.unrepaired" + ], + "listens": [ + { + "name": "web", + "port": 8080, + "protocol": "tcp", + "from": "mesh", + "why": "anything the mesh runs that authenticates a person" + } + ], + "serves": { + "oidc-client": { + "authorization-path": "/protocol/openid-connect/auth", + "token-path": "/protocol/openid-connect/token", + "userinfo-path": "/protocol/openid-connect/userinfo", + "issuer": "${config:issuer}" + } + }, + "receives": { + "oidc-client": "${dir:grants}/mesh.json" + }, + "grants": { + "oidc-client": "${dir:grants}" + }, + "own-secrets": { + "admin": "${dir:state}/admin.secret" + }, + "data": { + "consumers": { + "oidc-client": { + "class": "rebuildable", + "in": "postgres-database", + "why": "a consumer's client is made again from its declaration, with a new secret" + } + } + }, + "config": { + "url": { + "type": "string", + "description": "The address the module's provider reaches Keycloak's admin API at, as a URL; empty is this machine's Keycloak on its port.", + "trusted": true, + "default": "" + }, + "user": { + "type": "string", + "description": "The Keycloak administrator the module's provider signs in as; empty is admin.", + "trusted": true, + "default": "" + }, + "realm": { + "type": "string", + "description": "The realm the module's provider makes its consumers' clients in; empty is master.", + "trusted": true, + "default": "" + }, + "issuer": { + "type": "string", + "description": "The issuer Keycloak's tokens name and oidc consumers are told, as the realm's URL, such as https://keycloak.example.org/realms/Example; the operator's, with no default (novox/hq ADR 0112, ADR 0155).", + "trusted": true, + "required": true + } + }, + "resources": [ + { + "id": "mesh-state", + "type": "directory", + "mode": "0700", + "place": "mesh", + "unknown": "program" + }, + { + "id": "state", + "type": "directory", + "mode": "0700", + "place": ".", + "unknown": "program" + }, + { + "id": "grants", + "type": "directory", + "mode": "0700", + "unknown": "program" + }, + { + "id": "consumers", + "type": "file", + "path": "${dir:state}/consumers.json", + "mode": "0600", + "template": "files/consumers.json" + }, + { + "id": "admin-env", + "type": "file", + "path": "${dir:state}/admin.env", + "mode": "0600", + "template": "files/admin.env" + }, + { + "id": "database-env", + "type": "file", + "path": "${dir:state}/database.env", + "mode": "0600", + "template": "files/database.env" + }, + { + "id": "net", + "type": "network", + "name": "keycloak" + }, + { + "id": "hostname", + "type": "file", + "path": "${dir:state}/hostname.env", + "mode": "0644", + "template": "files/hostname.env" + }, + { + "id": "server", + "type": "container", + "name": "keycloak", + "image": "quay.io/keycloak/keycloak@sha256:ecd43971114b0c764f8a3288dddab73f98cb473daccc4feaffe4dc14adeaf866", + "network": "keycloak", + "args": [ + "start-dev" + ], + "env": { + "KC_DB": "postgres", + "KC_HTTP_ENABLED": "true", + "KC_HEALTH_ENABLED": "true", + "KC_PROXY_HEADERS": "xforwarded" + }, + "env-file": [ + "${dir:state}/admin.env", + "${dir:state}/database.env", + "${dir:state}/hostname.env" + ], + "ports": [ + "8080" + ], + "secrets-in-environment": "KC_DB_PASSWORD is convertible through a generated keycloak.conf (db-password=); KEYCLOAK_ADMIN_PASSWORD is env-only before Keycloak 26; not yet converted", + "restart-on": [ + "hostname" + ] + }, + { + "id": "runtime-config", + "type": "file", + "path": "${dir:mesh-state}/config.json", + "mode": "0600", + "template": "files/config.json", + "merge": "json", + "takes-config": [ + "url", + "user", + "realm", + "issuer" + ] + } + ], + "build": { + "artifacts": [ + { + "name": "code", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/keycloak-provider", + "binary": "keycloak-provider", + "loads": [ + "keycloak-provider" + ], + "env": { + "MESH_KEYCLOAK_URL": "http://127.0.0.1:${port:8080}", + "MESH_KEYCLOAK_CONFIG_FILE": "${dir:mesh-state}/config.json", + "MESH_KEYCLOAK_PASSWORD_FILE": "${dir:state}/admin.secret", + "MESH_RECEIVES": "${dir:grants}/mesh.json", + "MESH_KEYCLOAK_CONTAINER": "keycloak", + "MESH_CONSUMERS_FILE": "${dir:state}/consumers.json" + } + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + }, + "state": [ + "bound" + ], + "reads": [ + "mesh-controller.assignments" + ] +} diff --git a/go/testdata/beside/mesh-catalog/modules/lab/files/lab.env b/go/testdata/beside/mesh-catalog/modules/lab/files/lab.env new file mode 100644 index 0000000..35aa1a2 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/lab/files/lab.env @@ -0,0 +1,3 @@ +MESH_LAB_FORGE=${config:forge} +MESH_LAB_FORGE_BINDING=${dir:state}/package-registry.json +MESH_LAB_FORGE_SECRET=${dir:state}/package-registry.secret diff --git a/go/testdata/beside/mesh-catalog/modules/lab/module.json.captured b/go/testdata/beside/mesh-catalog/modules/lab/module.json.captured new file mode 100644 index 0000000..e23963d --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/lab/module.json.captured @@ -0,0 +1,129 @@ +{ + "module": "lab", + "version": "1", + "capabilities": [ + "container-runtime", + "virtualisation" + ], + "requires": [ + "npm-package-registry" + ], + "contributes": { + "npm-package-registry": { + "access": "read" + } + }, + "binds": { + "npm-package-registry": "${dir:state}/package-registry.json" + }, + "secrets": { + "npm-package-registry": "${dir:state}/package-registry.secret" + }, + "data": { + "own": [ + { + "id": "work", + "path": "${dir:work}", + "class": "cache", + "why": "the lab's runs, each thrown away" + } + ] + }, + "config": { + "forge": { + "type": "string", + "description": "The forge the lab's machines take their packages from, as its address: the operator's, with no default (novox/hq ADR 0112).", + "trusted": true, + "required": true + } + }, + "resources": [ + { + "id": "state", + "type": "directory", + "mode": "0700", + "place": ".", + "unknown": "program" + }, + { + "id": "work", + "type": "directory", + "path": "/var/lib/mesh-lab-runs", + "mode": "0700", + "unknown": "program" + }, + { + "id": "runtime-env", + "type": "file", + "path": "${dir:state}/lab.env", + "mode": "0600", + "template": "files/lab.env" + }, + { + "id": "git", + "type": "package", + "package": "git" + }, + { + "id": "make", + "type": "package", + "package": "make" + }, + { + "id": "python", + "type": "package", + "package": "python" + }, + { + "id": "file", + "type": "package", + "package": "file" + }, + { + "id": "iproute2", + "type": "package", + "package": "iproute2" + }, + { + "id": "npm", + "type": "package", + "package": "npm" + }, + { + "id": "go", + "type": "package", + "package": "go" + }, + { + "id": "incus", + "type": "package", + "package": "incus" + } + ], + "build": { + "artifacts": [ + { + "name": "code", + "kind": "bundle", + "language": "typescript", + "entrypoints": [ + "tools/index.js" + ], + "loads": [ + "tools/index.js" + ], + "env": { + "MESH_LAB_WORK": "${dir:work}", + "MESH_LAB_ENV_FILE": "${dir:state}/lab.env", + "MESH_LAB_FORGE_BINDING": "${dir:state}/package-registry.json", + "MESH_LAB_FORGE_SECRET": "${dir:state}/package-registry.secret" + } + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/go/testdata/beside/mesh-catalog/modules/lemurs/files/config.toml b/go/testdata/beside/mesh-catalog/modules/lemurs/files/config.toml new file mode 100644 index 0000000..6e5667d --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/lemurs/files/config.toml @@ -0,0 +1,369 @@ +# The login manager's configuration, written by the mesh (module lemurs, novox/hq ADR 0208). Replaced +# at every push; change the module instead. The structure is lemurs 0.4's own (the shipped file, with +# every option, as lemurs requires); what the mesh changes from it is marked "mesh:". +# +# The sessions offered are the executable files other modules place in the two scripts directories +# below (/etc/lemurs/wms for X, /etc/lemurs/wayland for Wayland), one per session module. A file there +# is named as the session is offered. Desktop entries that packages install (/usr/share/xsessions) are +# not offered: they start the window manager bare, skipping the session's start (~/.xinitrc), which is +# where the account's environment, the X resources and every module's session lines are. +# +# Lemurs configuration file. +# Contains all the customization options of lemurs. +# +# Note: that as of now you need to have all options in the selected +# configuration file. Otherwise Lemurs will not work. +# +# Colors: +# --------- +# There is a list of predefined colors. These include: +# - black +# - white +# - (dark) gray +# - (light) red +# - (light) blue +# - (light) green +# - (light) magenta +# - (light) cyan +# - (light) yellow +# - orange +# +# You can also utilize custom colors with hex color codes. +# "#87CEEB" will create a Sky Blue color. +# +# Note: If the color wasn't recognized, it will default to white. +# --------- +# +# Modifiers: +# --------- +# There is a number of modifiers you can use. These can be combined by +# delimiting them with a comma (e.g. "bold,italic"). The modifiers are: +# - bold +# - dim +# - italic +# - underlined +# - reverse +# - crossed out +# - hidden +# --------- +# + +# The tty which contains lemurs. This has to be mirrored in the lemurs.service +tty = 2 + +# Where to log the main lemurs control flow. +main_log_path = "/var/log/lemurs.log" + +# Where to log to for the client. The Client is the Desktop Environment or +# Window Manager for Xorg, the Compositor for Wayland and the Shell for TTY. +client_log_path = "/var/log/lemurs.client.log" + +# At which point to point the cache. If you want to disable the cache globally +# you can use `/dev/null`. +cache_path = "/var/cache/lemurs" + +# Disable all logging. This is overwritten by the `--no-log` flag. +do_log = true + +# The PAM service that should be used to login +pam_service = "lemurs" + +# Path to system shell that gets used to execute linux commands. In almost all +# cases, this should refer to a bash shell. +system_shell = "/bin/sh" + +# Initial state of the `PATH` environment variable. +initial_path = "/usr/local/sbin:/usr/local/bin:/usr/bin" + +# The type flag that will be appended to the shell that calls the session +# environment. This may depend on your shell. Options: +# - 'none'. Disables calling a login shell +# - 'short'. Produces the `-l` flag. Supported by most shells. +# - 'long'. This produces the `--login` flag and is suited for bash and zsh. +shell_login_flag = "short" + +# Focus behaviour of fields when Lemurs is initially started +# +# Possible values: +# - default: Initially focus on first non-cached value +# - no-focus: No initial focus +# - environment: Initially focus on the environment selector +# - username: Initially focus on the username field +# - password: Initially focus on the password field +focus_behaviour = "default" + +# General settings for background style +[background] + +# Control whether to render background widget or not +show_background = false + +[background.style] +# Allow to set the default background color for the login shell +color = "black" +# Settings for the background block's borders +show_border = true +border_color = "white" + +[power_controls] +# The margin between hints +hint_margin = 2 + +# There are no additional entries by default +entries = [] + +# Example +# Reboot to another os option +#[[power_controls.entries]] +## The text in the top-left to display how to reboot. +#hint = "Reboot to OS" +# +## The color and modifiers of the hint in the top-left corner +#hint_color = "dark gray" +#hint_modifiers = "" +# +## The key used to reboot. Possibilities are F1 to F12. +#key = "F3" +## The command that is executed when the key is pressed +#cmd = "efibootmgr -n0 && systemctl reboot -l" + + +# If you want to remove the base_entries +# base_entries = [] + +# Shutdown option +[[power_controls.base_entries]] +# The text in the top-left to display how to shutdown. +hint = "Shutdown" + +# The color and modifiers of the hint in the top-left corner +hint_color = "dark gray" +hint_modifiers = "" + +# The key used to shutdown. Possibilities are F1 to F12. +key = "F1" +# The command that is executed when the key is pressed +cmd = "systemctl poweroff -l" + +# Reboot option +[[power_controls.base_entries]] +# The text in the top-left to display how to reboot. +hint = "Reboot" + +# The color and modifiers of the hint in the top-left corner +hint_color = "dark gray" +hint_modifiers = "" + +# The key used to reboot. Possibilities are F1 to F12. +key = "F2" +# The command that is executed when the key is pressed +cmd = "systemctl reboot -l" + +# Setting for the selector of the desktop environment you are using. +[environment_switcher] +# Terms: +# --------- +# Movers: indicators which show which direction one can move whilst selecting +# the desktop environment +# Selected: The currently selected desktop environment. +# Neighbours: The adjacent desktop environment to the one current selected +# +# Visualisation: +# +# < i3 bspwm awesome > +# +# ^ ^ ^ ^ ^ +# | | | | | +# mover | selected | mover +# | | +# neighbour neighbour +# --------- +# + +# Control the visibility of the switcher +# Options: +# - "visible" - Always show the switcher [default] +# - "hidden" - Always hide the switcher +# - [key] - F1-F12 to be able to toggle the visibility +# mesh: hidden, as both workstations had it, but F3 shows it, so a second session can be chosen. +switcher_visibility = "F3" + +# The text in the top-left to display how to toggle the switcher. The text +# '%key%' will be replaced with the switcher_visibility key. This is not shown +# if switcher_visibility is set to "visible" or "hidden". +toggle_hint = "Switcher %key%" + +# The color and modifiers of the hint in the top-left corner +toggle_hint_color = "dark gray" +toggle_hint_modifiers = "" + + +# Show an option for the TTY shell when logging in as one of the environments. +# NOTE: it is always shown when no viable options are found. +include_tty_shell = false + +# Remember the selected environment after logging in for the next time +remember = true + +# Enables showing the movers +show_movers = true + +# Mover's color and modifiers whilst the selector is unfocused +mover_color = "dark gray" +mover_modifiers = "" + +# Mover's color and modifiers whilst the selector is focused +mover_color_focused = "orange" +mover_modifiers_focused = "bold" + +# The characters used to display the movers. Suggestions are: +# - "<" ">" +# - "<-" "->" +# - "<<" ">>" +# - "[" "]" +left_mover = "<" +right_mover = ">" + +# The margin between the movers and the neighbours or selected (depending on +# `show_neighbours`) +mover_margin = 1 + +# Enables showing the neighbours +show_neighbours = true + +# Neighbours' color and modifiers whilst the selector is unfocused +neighbour_color = "dark gray" +neighbour_modifiers = "" + +# Neighbours' color and modifiers whilst the selector is focused +neighbour_color_focused = "gray" +neighbour_modifiers_focused = "" + +# Margin between neighbours and selected +neighbour_margin = 1 + +# Selected's color and modifiers whilst the selector is unfocused +selected_color = "gray" +selected_modifiers = "underlined" + +# Selected's color and modifiers whilst the selector is focused +selected_color_focused = "white" +selected_modifiers_focused = "bold" + +# The length of the name of the desktop environment which is displayed. +max_display_length = 8 + +# The text used when no desktop environments are available +no_envs_text = "No environments..." + +# The color and modifiers of the 'no desktop environments available text' +# whilst the selector is unfocused +no_envs_color = "white" +no_envs_modifiers = "" + +# The color and modifiers of the 'no desktop environments available text' +# whilst the selector is focused +no_envs_color_focused = "red" +no_envs_modifiers_focused = "" + +[username_field] + +# Remember the username for the next time after a successful login attempt. +remember = true + +[username_field.style] +# Enables showing a title +show_title = true +# The text used within the title +title = "Login" + +# The title's color and modifiers whilst the username field is unfocused +title_color = "white" +content_color = "white" + +# The title's color and modifiers whilst the username field is focused +title_color_focused = "orange" +content_color_focused = "orange" + +# Enables showing the borders +show_border = true +# The borders' color and modifiers whilst the username field is unfocused +border_color = "white" +# The borders' color and modifiers whilst the username field is focused +border_color_focused = "orange" + +# Constrain the width of the username field +use_max_width = true +# The constraint of the username field's width +max_width = 48 + +[password_field] + +# The character used for replacement when typing a password. Leave empty for no +# feedback. +# Note: Only one character is accepted. +content_replacement_character = "*" + +[password_field.style] +# Enables showing a title +show_title = true +# The text used within the title +title = "Password" + +# The title's color and modifiers whilst the password field is unfocused +title_color = "white" +content_color = "white" + +# The title's color and modifiers whilst the password field is focused +title_color_focused = "orange" +content_color_focused = "orange" + +# Enables showing the borders +show_border = true +# The borders' color and modifiers whilst the password field is unfocused +border_color = "white" +# The borders' color and modifiers whilst the password field is focused +border_color_focused = "orange" + +# Constrain the width of the password field +use_max_width = true +# The constraint of the password field's width +max_width = 48 + +[x11] +# Where to log to for the XServer. +xserver_log_path = "/var/log/lemurs.xorg.log" + +# The value of the `DISPLAY` environment variable for X11 sessions +x11_display = ":1" + +# How many seconds to give the X server to start. To make it infinitely, put it +# to 0. +xserver_timeout_secs = 60 + +# Where to find the X11 server binary +# mesh: the server through the wrapper below, which adds -noreset. +xserver_path = "/etc/lemurs/xserver" + +# Where to find the X11 xauth binary +xauth_path = "/usr/bin/xauth" + +# Path to the directory where the startup scripts for the X11 sessions are found +scripts_path = "/etc/lemurs/wms" + +# Path to the xsetup script that is needed for the environment setup of the +# window manager. +xsetup_path = "/etc/lemurs/xsetup.sh" + +# The directory to use for desktop entries X11 sessions. +# mesh: an empty directory of the module's own, so no package's desktop entry is offered. +xsessions_path = "/etc/lemurs/xsessions" + +[wayland] +# Path to the directory where the startup scripts for the Wayland sessions are +# found +scripts_path = "/etc/lemurs/wayland" + +# The directory to use for desktop entries wayland sessions. +# mesh: likewise for Wayland. +wayland_sessions_path = "/etc/lemurs/wayland-sessions" diff --git a/go/testdata/beside/mesh-catalog/modules/lemurs/files/xserver b/go/testdata/beside/mesh-catalog/modules/lemurs/files/xserver new file mode 100644 index 0000000..5908f22 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/lemurs/files/xserver @@ -0,0 +1,8 @@ +#!/bin/sh +# The X server as lemurs starts it, written by the mesh (module lemurs). Replaced at every push. +# +# -noreset: without it, X resets whenever its last client disconnects, and a reset empties the +# resource database. The session's start merges the X resources with xrdb before any long-lived +# client is connected, so the server reset twice in the four seconds after a login and threw the +# resources away: xterm fell back to the bitmap "fixed" font, without the mesh's face and colours. +exec /usr/bin/X "$@" -noreset diff --git a/go/testdata/beside/mesh-catalog/modules/lemurs/module.json.captured b/go/testdata/beside/mesh-catalog/modules/lemurs/module.json.captured new file mode 100644 index 0000000..702a959 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/lemurs/module.json.captured @@ -0,0 +1,84 @@ +{ + "module": "lemurs", + "version": "1", + "capabilities": [ + "package-manager", + "service-manager", + "seat" + ], + "claims": [ + { + "name": "node-login-manager", + "scope": "node", + "serves": [ + "sessions" + ] + } + ], + "tools": [ + "lemurs_default_session", + "lemurs_logins" + ], + "resources": [ + { + "id": "package", + "type": "package", + "package": "lemurs" + }, + { + "id": "xserver", + "type": "file", + "path": "/etc/lemurs/xserver", + "mode": "0755", + "template": "files/xserver" + }, + { + "id": "config", + "type": "file", + "path": "/etc/lemurs/config.toml", + "mode": "0644", + "template": "files/config.toml" + }, + { + "id": "xsessions", + "type": "directory", + "path": "/etc/lemurs/xsessions", + "mode": "0755", + "unknown": "program" + }, + { + "id": "wayland-sessions", + "type": "directory", + "path": "/etc/lemurs/wayland-sessions", + "mode": "0755", + "unknown": "program" + }, + { + "id": "service", + "type": "service", + "unit": "lemurs.service", + "state": "running", + "boot": "enabled" + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/lemurs-tools", + "binary": "lemurs-tools", + "loads": [ + "lemurs-tools" + ] + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/go/testdata/beside/mesh-catalog/modules/letta/files/config.json b/go/testdata/beside/mesh-catalog/modules/letta/files/config.json new file mode 100644 index 0000000..8ed994d --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/letta/files/config.json @@ -0,0 +1,3 @@ +{ + "password": "${secret:server-password}" +} diff --git a/go/testdata/beside/mesh-catalog/modules/letta/files/pgpass b/go/testdata/beside/mesh-catalog/modules/letta/files/pgpass new file mode 100644 index 0000000..cd41b66 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/letta/files/pgpass @@ -0,0 +1 @@ +*:*:*:${bound:postgres-database:as}:${secret:postgres-database} diff --git a/go/testdata/beside/mesh-catalog/modules/letta/files/server.env b/go/testdata/beside/mesh-catalog/modules/letta/files/server.env new file mode 100644 index 0000000..fe2acb9 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/letta/files/server.env @@ -0,0 +1,6 @@ +LETTA_PG_URI=postgresql://${bound:postgres-database:as}@${bound:postgres-database:at}:${bound:postgres-database:port}/${bound:postgres-database:as} +PGPASSFILE=/run/secrets/pgpass +LETTA_SERVER_PASSWORD=${secret:server-password} +OPENAI_API_KEY=${secret:openai-api-key} +SECURE=true +TZ=Europe/Brussels diff --git a/go/testdata/beside/mesh-catalog/modules/letta/files/start.sh b/go/testdata/beside/mesh-catalog/modules/letta/files/start.sh new file mode 100644 index 0000000..9955592 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/letta/files/start.sh @@ -0,0 +1,23 @@ +#!/bin/sh +# Generated by the mesh. Do not edit: module letta writes this file and replaces it at every push. +# +# letta 0.6.8 prints secrets it is given to its log (novox/hq issue 268): +# letta/server/rest_api/app.py prints its server password when it starts in secure mode; +# startup.sh, alembic/env.py and letta/server/server.py print LETTA_PG_URI whole. +# The URI carries no password (libpq reads it from PGPASSFILE), and the one print of the server +# password is rewritten before the server starts. Either one failing refuses the start: a letta +# that does not start says why here, and one that leaks says nothing. +set -e +app=/app/letta/server/rest_api/app.py +sed -i 's/Using secure mode with password: {random_password}/Using secure mode (the password is not printed)/' "$app" +if grep -q 'print(.*random_password' "$app"; then + echo "letta: $app still prints the server password; not starting (novox/hq issue 268)" >&2 + exit 1 +fi +case "$LETTA_PG_URI" in + *://*:*@*) + echo "letta: LETTA_PG_URI carries a password, and letta prints that URI; not starting (novox/hq issue 268)" >&2 + exit 1 + ;; +esac +exec ./letta/server/startup.sh diff --git a/go/testdata/beside/mesh-catalog/modules/letta/module.json.captured b/go/testdata/beside/mesh-catalog/modules/letta/module.json.captured new file mode 100644 index 0000000..de7baf6 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/letta/module.json.captured @@ -0,0 +1,156 @@ +{ + "module": "letta", + "version": "1", + "capabilities": [ + "container-runtime" + ], + "requires": [ + "postgres-database", + "route" + ], + "contributes": { + "postgres-database": { + "name": "letta", + "extensions": [ + "vector" + ] + }, + "route": { + "label": "letta", + "endpoint": "web" + } + }, + "binds": { + "postgres-database": "${dir:state}/database.json", + "route": "${dir:state}/route.json" + }, + "secrets": { + "postgres-database": "${dir:state}/database.secret" + }, + "own-secrets": { + "server-password": { + "path": "${dir:state}/server-password.secret", + "taken": "at-start" + }, + "openai-api-key": { + "path": "${dir:state}/openai-api-key.secret", + "taken": "at-start", + "issued-by": "outside" + } + }, + "listens": [ + { + "name": "web", + "port": 8283, + "protocol": "tcp", + "from": "mesh", + "why": "the Letta agent server REST API and web UI, password-protected (--secure); a public name is the route's" + } + ], + "config": { + "url": { + "type": "string", + "description": "The address the module's tools reach Letta at, as a URL; unset, the code falls back to its environment, else this machine's Letta on port 8283.", + "trusted": true, + "default": "" + } + }, + "resources": [ + { + "id": "mesh-state", + "type": "directory", + "mode": "0700", + "place": "mesh", + "unknown": "program" + }, + { + "id": "state", + "type": "directory", + "mode": "0700", + "place": ".", + "unknown": "program" + }, + { + "id": "server-env", + "type": "file", + "path": "${dir:state}/server.env", + "mode": "0600", + "template": "files/server.env" + }, + { + "id": "pgpass", + "type": "file", + "path": "${dir:state}/pgpass", + "mode": "0600", + "template": "files/pgpass" + }, + { + "id": "start", + "type": "file", + "path": "${dir:state}/start.sh", + "mode": "0644", + "template": "files/start.sh" + }, + { + "id": "net", + "type": "network", + "name": "letta" + }, + { + "id": "server", + "type": "container", + "name": "letta", + "image": "letta/letta@sha256:bfd1e49ce45b9a208c941e832c1d1d194017ff210a3784b0ca6c323aed767a29", + "network": "letta", + "env-file": [ + "${dir:state}/server.env" + ], + "ports": [ + "8283" + ], + "volumes": [ + "${dir:state}/pgpass:/run/secrets/pgpass:ro", + "${dir:state}/start.sh:/run/letta/start.sh:ro" + ], + "args": [ + "sh", + "/run/letta/start.sh" + ], + "secrets-in-environment": "letta 0.6.x reads its settings from the environment only (pydantic settings, no secrets_dir or _FILE twin): LETTA_SERVER_PASSWORD and OPENAI_API_KEY have no file source. The database password is not here: LETTA_PG_URI, which letta prints at start, names no password, and libpq reads it from the mounted pgpass file (PGPASSFILE)" + }, + { + "id": "runtime-config", + "type": "file", + "path": "${dir:mesh-state}/config.json", + "mode": "0600", + "template": "files/config.json", + "merge": "json", + "takes-config": ["url"], + "protected": ["password"] + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "typescript", + "entrypoints": [ + "tools/index.js" + ], + "loads": [ + "tools/index.js" + ], + "env": { + "MESH_LETTA_URL": "http://127.0.0.1:${port:8283}", + "MESH_LETTA_CONFIG_FILE": "${dir:mesh-state}/config.json" + } + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/go/testdata/beside/mesh-catalog/modules/local-model-consumer/files/openai.env b/go/testdata/beside/mesh-catalog/modules/local-model-consumer/files/openai.env new file mode 100644 index 0000000..9aca396 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/local-model-consumer/files/openai.env @@ -0,0 +1,3 @@ +OPENAI_BASE_URL=http://${bound:model-access:at}:${bound:model-access:port}/v1 +OPENAI_MODEL=${bound:model-access:model} +OPENAI_API_KEY=local diff --git a/go/testdata/beside/mesh-catalog/modules/local-model-consumer/module.json.captured b/go/testdata/beside/mesh-catalog/modules/local-model-consumer/module.json.captured new file mode 100644 index 0000000..7300209 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/local-model-consumer/module.json.captured @@ -0,0 +1,42 @@ +{ + "module": "local-model-consumer", + "version": "1", + "slug": "local", + "requires": [ + "model-access" + ], + "binds": { + "model-access": "${dir:state}/model.json" + }, + "resources": [ + { + "id": "state", + "type": "directory", + "mode": "0700", + "place": ".", + "unknown": "program" + }, + { + "id": "config", + "type": "directory", + "mode": "0700", + "unknown": "program" + }, + { + "id": "openai-env", + "type": "file", + "path": "${dir:config}/openai.env", + "mode": "0600", + "template": "files/openai.env" + } + ], + "build": { + "artifacts": [ + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/go/testdata/beside/mesh-catalog/modules/localization/files/locale.conf b/go/testdata/beside/mesh-catalog/modules/localization/files/locale.conf new file mode 100644 index 0000000..fe405b5 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/localization/files/locale.conf @@ -0,0 +1,3 @@ +# The mesh's (module localization, novox/hq to-be 42): the system locale. Written whole at every +# push; an edit here is overwritten. Read at the next login. +LANG=en_US.UTF-8 diff --git a/go/testdata/beside/mesh-catalog/modules/localization/files/vconsole.conf b/go/testdata/beside/mesh-catalog/modules/localization/files/vconsole.conf new file mode 100644 index 0000000..88d96f6 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/localization/files/vconsole.conf @@ -0,0 +1,3 @@ +# The mesh's (module localization, novox/hq to-be 42): the console keymap. Written whole at every +# push; an edit here is overwritten. Read at the next boot. +KEYMAP=us diff --git a/go/testdata/beside/mesh-catalog/modules/localization/module.json.captured b/go/testdata/beside/mesh-catalog/modules/localization/module.json.captured new file mode 100644 index 0000000..0974ed3 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/localization/module.json.captured @@ -0,0 +1,61 @@ +{ + "module": "localization", + "version": "1", + "capabilities": [ + "service-manager" + ], + "tools": [ + "localization_get", + "localization_time_zone", + "localization_locales", + "localization_keymaps" + ], + "resources": [ + { + "id": "locale", + "type": "file", + "path": "/etc/locale.conf", + "mode": "0644", + "template": "files/locale.conf" + }, + { + "id": "keymap", + "type": "file", + "path": "/etc/vconsole.conf", + "mode": "0644", + "template": "files/vconsole.conf" + }, + { + "id": "time-zone", + "type": "process", + "name": "localization-time-zone", + "artifact": "tools", + "run": [ + "./localization-tools", + "set-time-zone", + "Europe/Brussels" + ], + "run-once": true + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/localization-tools", + "binary": "localization-tools", + "loads": [ + "localization-tools" + ] + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/go/testdata/beside/mesh-catalog/modules/logrotate/files/logrotate.conf b/go/testdata/beside/mesh-catalog/modules/logrotate/files/logrotate.conf new file mode 100644 index 0000000..d646137 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/logrotate/files/logrotate.conf @@ -0,0 +1,32 @@ +# The mesh's (module logrotate, novox/hq to-be 42): the base configuration every rotation inherits. +# Written whole at every push; an edit here is overwritten. Each package's own rules are in +# /etc/logrotate.d and stay the packages'. + +# Weekly, four weeks kept, a new empty log created after each rotation. +weekly +rotate 4 +create + +# Rotated logs are compressed, one rotation late, so a program still writing to the file it had open +# loses nothing to the compression. +compress +delaycompress + +# A package's replaced configuration is never read as a rule. +tabooext + .pacorig .pacnew .pacsave + +include /etc/logrotate.d + +/var/log/wtmp { + monthly + create 0664 root utmp + minsize 1M + rotate 1 +} + +/var/log/btmp { + missingok + monthly + create 0600 root utmp + rotate 1 +} diff --git a/go/testdata/beside/mesh-catalog/modules/logrotate/module.json.captured b/go/testdata/beside/mesh-catalog/modules/logrotate/module.json.captured new file mode 100644 index 0000000..8318451 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/logrotate/module.json.captured @@ -0,0 +1,58 @@ +{ + "module": "logrotate", + "version": "1", + "capabilities": [ + "package-manager", + "service-manager" + ], + "tools": [ + "logrotate_status", + "logrotate_configs", + "logrotate_check", + "logrotate_big_logs", + "logrotate_force", + "logrotate_journal_usage", + "logrotate_journal_vacuum" + ], + "resources": [ + { + "id": "package", + "type": "package", + "package": "logrotate" + }, + { + "id": "config", + "type": "file", + "path": "/etc/logrotate.conf", + "mode": "0644", + "template": "files/logrotate.conf" + }, + { + "id": "timer", + "type": "service", + "unit": "logrotate.timer", + "state": "running", + "boot": "enabled" + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/logrotate-tools", + "binary": "logrotate-tools", + "loads": [ + "logrotate-tools" + ] + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/go/testdata/beside/mesh-catalog/modules/mailu/files/admin.env b/go/testdata/beside/mesh-catalog/modules/mailu/files/admin.env new file mode 100644 index 0000000..f066bb5 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/mailu/files/admin.env @@ -0,0 +1,2 @@ +INITIAL_ADMIN_PW=${secret:admin} +API_TOKEN=${secret:api-token} diff --git a/go/testdata/beside/mesh-catalog/modules/mailu/files/config.json b/go/testdata/beside/mesh-catalog/modules/mailu/files/config.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/mailu/files/config.json @@ -0,0 +1 @@ +{} diff --git a/go/testdata/beside/mesh-catalog/modules/mailu/files/database.env b/go/testdata/beside/mesh-catalog/modules/mailu/files/database.env new file mode 100644 index 0000000..ba70d3d --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/mailu/files/database.env @@ -0,0 +1,5 @@ +DB_FLAVOR=postgresql +DB_HOST=${bound:postgres-database:at}:${bound:postgres-database:port} +DB_USER=${bound:postgres-database:as} +DB_NAME=${bound:postgres-database:as} +DB_PW=${secret:postgres-database} diff --git a/go/testdata/beside/mesh-catalog/modules/mailu/files/mailu.env b/go/testdata/beside/mesh-catalog/modules/mailu/files/mailu.env new file mode 100644 index 0000000..2c977d3 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/mailu/files/mailu.env @@ -0,0 +1,78 @@ +ADMIN_ADDRESS=mailu-admin +ANTISPAM_ADDRESS=mailu-antispam +ANTIVIRUS_ADDRESS=mailu-antivirus +IMAP_ADDRESS=mailu-imap +SMTP_ADDRESS=mailu-smtp +FRONT_ADDRESS=mailu-front +WEBMAIL_ADDRESS=mailu-webmail +WEBDAV_ADDRESS=mailu-webdav +REDIS_ADDRESS=mailu-redis +PORTS=25,80,443,465,993,995,4190,110,143,587 +DOMAIN=${config:domain} +HOSTNAMES=${bound:route:name-web} +POSTMASTER=admin +SITENAME=${config:sitename} +WEBSITE=${config:website} +TLS_FLAVOR=letsencrypt +SUBNET=192.168.203.0/24 +COMPOSE_PROJECT_NAME=mailu +ANTIVIRUS=clamav +WEBMAIL=roundcube +WEBDAV=radicale +FETCHMAIL_ENABLED=True +FETCHMAIL_DELAY=600 +ADMIN=true +WEB_ADMIN=/admin +WEB_WEBMAIL=/webmail +WEBROOT_REDIRECT=/webmail +API=true +WEB_API=/api +AUTH_RATELIMIT_IP=6000/hour +AUTH_RATELIMIT_USER=1000/day +CREDENTIAL_ROUNDS=12 +PASSWORD_SCHEME=PBKDF2 +DISABLE_STATISTICS=True +MESSAGE_SIZE_LIMIT=50000000 +MESSAGE_RATELIMIT=200/day +RECIPIENT_DELIMITER=+ +POSTFIX_MYNETWORKS=127.0.0.0/8 [::1]/128 +RELAYNETS= +RELAYHOST= +REJECT_UNLISTED_RECIPIENT= +DB_FLAVOR=postgresql +INITIAL_ADMIN_ACCOUNT=admin +INITIAL_ADMIN_DOMAIN=${config:domain} +INITIAL_ADMIN_MODE=ifmissing +SMTP_PORT=25 +SMTPS_PORT=465 +SUBMISSION_PORT=587 +POP3_PORT=110 +POP3S_PORT=995 +IMAP_PORT=143 +IMAPS_PORT=993 +HTTP_PORT=7080 +HTTPS_PORT=7443 +AUTOMX_PORT=4243 +AMX_SMTP_ADDRESS=${bound:route:name-web} +AMX_SMTP_PORT=587 +AMX_IMAP_ADDRESS=${bound:route:name-web} +AMX_IMAP_PORT=143 +AMX_MAIL_DOMAINS=${config:domain} +DMARC_RUA=admin +DMARC_RUF=admin +LETSENCRYPT_SHORTCHAIN=True +TZ=Etc/UTC +LOG_LEVEL=INFO +WELCOME=false +REAL_IP_HEADER=X-Real-IP +REAL_IP_FROM=${config:proxy-address} +COMPRESSION= +COMPRESS_LEVEL= +COMPRESSION_LEVEL= +BIND_ADDRESS4=127.0.0.1 +BIND_ADDRESS6=::1 +MAILU_VERSION=1.9 +DOCKER_ORG=mailu +DOCKER_PREFIX= +WELCOME_SUBJECT=Welcome to your new email account +WELCOME_BODY=Welcome to your new email account, if you can read this, then it is configured properly! diff --git a/go/testdata/beside/mesh-catalog/modules/mailu/files/secret.env b/go/testdata/beside/mesh-catalog/modules/mailu/files/secret.env new file mode 100644 index 0000000..c6f6b66 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/mailu/files/secret.env @@ -0,0 +1 @@ +SECRET_KEY=${secret:secret-key} diff --git a/go/testdata/beside/mesh-catalog/modules/mailu/module.json.captured b/go/testdata/beside/mesh-catalog/modules/mailu/module.json.captured new file mode 100644 index 0000000..568be14 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/mailu/module.json.captured @@ -0,0 +1,772 @@ +{ + "module": "mailu", + "version": "1", + "upgrade": { + "policy": "record", + "why": "people's mail: any change to how its containers are declared recreates them together in one send, and the mail is down for everyone until they are up again — a person chooses the moment (hq ADR 0242, issue 295)" + }, + "unhealthy": { + "severity": "urgent", + "why": "while mailu is broken, mail is refused or stuck for everyone with a mailbox here (hq ADR 0355, issue 687)" + }, + "capabilities": [ + "container-runtime" + ], + "requires": [ + "postgres-database", + "route", + "secret" + ], + "contributes": { + "postgres-database": { + "name": "mailu" + }, + "route": { + "web": { + "label": "mail", + "endpoint": "web-tls", + "scheme": "https", + "insecure": true + }, + "acme": { + "label": "mail", + "path": "/.well-known/acme-challenge", + "endpoint": "web", + "priority": 100 + }, + "autoconfig": { + "label": "autoconfig", + "endpoint": "autoconfig" + }, + "autodiscover": { + "label": "autodiscover", + "endpoint": "autoconfig" + }, + "automx": { + "label": "automx", + "endpoint": "autoconfig" + } + } + }, + "binds": { + "postgres-database": "${dir:state}/database.json", + "route": "${dir:state}/route.json" + }, + "secrets": { + "postgres-database": "${dir:state}/database.secret", + "secret": { + "secret-key": "${dir:state}/secret-key.secret", + "admin": "${dir:state}/admin.secret", + "api-token": "${dir:state}/api-token.secret" + } + }, + "tools": [ + "mailu_list_users", + "mailu_create_user", + "mailu_change_password", + "mailu_delete_user", + "mailu_list_aliases", + "mailu_create_alias", + "mailu_delete_alias", + "mailu_list_domains", + "mailu_read_mail", + "mailu_search_mail", + "mailu_health" + ], + "emits": [ + "user.created", + "user.deleted", + "alias.created", + "alias.deleted" + ], + "listens": [ + { + "name": "smtp", + "port": 25, + "protocol": "tcp", + "from": "anywhere", + "why": "mail from other mail servers", + "fixed": true + }, + { + "name": "pop3", + "port": 110, + "protocol": "tcp", + "from": "anywhere", + "why": "POP3, kept at parity with the predecessor; pruning legacy protocols is its own deliberate change", + "fixed": true + }, + { + "name": "imap", + "port": 143, + "protocol": "tcp", + "from": "anywhere", + "why": "IMAP with STARTTLS, kept at parity", + "fixed": true + }, + { + "name": "smtps", + "port": 465, + "protocol": "tcp", + "from": "anywhere", + "why": "submission over TLS", + "fixed": true + }, + { + "name": "submission", + "port": 587, + "protocol": "tcp", + "from": "anywhere", + "why": "submission; also what the smtp provision serves consumers", + "fixed": true + }, + { + "name": "imaps", + "port": 993, + "protocol": "tcp", + "from": "anywhere", + "why": "IMAP over TLS", + "fixed": true + }, + { + "name": "pop3s", + "port": 995, + "protocol": "tcp", + "from": "anywhere", + "why": "POP3 over TLS, kept at parity", + "fixed": true + }, + { + "name": "web", + "port": 7080, + "protocol": "tcp", + "from": "mesh", + "why": "the web front over http; only the ACME HTTP-01 passthrough is routed here — everything else 301s to https and would loop a proxy" + }, + { + "name": "web-tls", + "port": 7443, + "protocol": "tcp", + "from": "mesh", + "why": "the web front over its own TLS (admin, webmail, API); its public name is a route grant reaching it here" + }, + { + "name": "autoconfig", + "port": 4243, + "protocol": "tcp", + "from": "mesh", + "why": "automx: mail client autoconfiguration; the autoconfig, autodiscover and automx names are route grants reaching it here" + }, + { + "name": "admin-api", + "port": 8080, + "protocol": "tcp", + "from": "machine", + "why": "the admin API, which this module's own code reaches on loopback from the node's runtime now that it runs outside the mailu network" + } + ], + "data": { + "own": [ + { + "id": "mail", + "path": "${dir:data-mail}", + "class": "valuable", + "why": "every mailbox" + }, + { + "id": "dkim", + "path": "${dir:data-dkim}", + "class": "valuable", + "why": "the domain's signing keys; a new one means a new DNS record" + }, + { + "id": "data", + "path": "${dir:data-data}", + "class": "valuable", + "why": "the server's own data" + }, + { + "id": "dav", + "path": "${dir:data-dav}", + "class": "valuable", + "why": "calendars and contacts" + }, + { + "id": "webmail", + "path": "${dir:data-webmail}", + "class": "valuable", + "why": "the webmail's own data: its users' settings and address books" + }, + { + "id": "queue", + "path": "${dir:data-mailqueue}", + "class": "valuable", + "why": "mail accepted and not yet delivered, kept nowhere else" + }, + { + "id": "filter", + "path": "${dir:data-filter}", + "class": "rebuildable", + "why": "the spam filter's learned statistics; it learns again" + }, + { + "id": "certs", + "path": "${dir:data-certs}", + "class": "rebuildable", + "why": "certificates, issued again" + }, + { + "id": "automx", + "path": "${dir:data-automx}", + "class": "rebuildable", + "why": "client autoconfiguration, written again" + }, + { + "id": "fetchmail", + "path": "${dir:data-fetchmail}", + "class": "rebuildable", + "why": "which remote messages were fetched; lost, some are fetched twice" + }, + { + "id": "clamav", + "path": "${dir:data-clamav}", + "class": "cache", + "why": "virus signatures, downloaded again" + }, + { + "id": "redis", + "path": "${dir:data-redis}", + "class": "cache", + "why": "the filter's working set" + } + ], + "consumers": { + "smtp": { + "class": "valuable", + "in": "mail", + "why": "a consumer's mailbox holds the only copy of its mail" + } + } + }, + "config": { + "domain": { + "type": "string", + "description": "The mail domain Mailu serves, such as example.org: its first mail domain, the postmaster's and the one consumers send as; the operator's, with no default (novox/hq ADR 0112).", + "trusted": true, + "required": true + }, + "proxy-address": { + "type": "string", + "description": "The address of the reverse proxy in front of Mailu, whose X-Real-IP header Mailu believes; the operator's, with no default.", + "trusted": true, + "required": true + }, + "sitename": { + "type": "string", + "description": "The name Mailu's web pages show for the mail service; the operator's, with no default.", + "trusted": true, + "required": true + }, + "website": { + "type": "string", + "description": "The address of the website Mailu's web pages link to, as a URL; the operator's, with no default.", + "trusted": true, + "required": true + }, + "url": { + "type": "string", + "description": "The address the module's tools reach Mailu's API at, as a URL; empty leaves them to MESH_MAILU_URL.", + "trusted": true, + "default": "" + }, + "container": { + "type": "string", + "description": "The name of the container holding Mailu's mailboxes, which the module's tools read mail in; empty is mailu-imap.", + "trusted": true, + "default": "" + } + }, + "resources": [ + { + "id": "mesh-state", + "type": "directory", + "mode": "0700", + "place": "mesh", + "unknown": "program" + }, + { + "id": "state", + "type": "directory", + "mode": "0700", + "place": ".", + "unknown": "program" + }, + { + "id": "grants", + "type": "directory", + "mode": "0700", + "unknown": "program" + }, + { + "id": "data-automx", + "type": "directory", + "mode": "0700", + "unknown": "program" + }, + { + "id": "config-env", + "type": "file", + "path": "${dir:state}/mailu.env", + "mode": "0644", + "template": "files/mailu.env" + }, + { + "id": "secret-env", + "type": "file", + "path": "${dir:state}/secret.env", + "mode": "0600", + "template": "files/secret.env" + }, + { + "id": "database-env", + "type": "file", + "path": "${dir:state}/database.env", + "mode": "0600", + "template": "files/database.env" + }, + { + "id": "admin-env", + "type": "file", + "path": "${dir:state}/admin.env", + "mode": "0600", + "template": "files/admin.env" + }, + { + "id": "data-certs", + "type": "directory", + "mode": "0700", + "unknown": "program" + }, + { + "id": "data-data", + "type": "directory", + "mode": "0700", + "unknown": "program" + }, + { + "id": "data-dkim", + "type": "directory", + "mode": "0700", + "unknown": "program" + }, + { + "id": "data-mail", + "type": "directory", + "mode": "0700", + "unknown": "program" + }, + { + "id": "data-mailqueue", + "type": "directory", + "mode": "0755", + "unknown": "program" + }, + { + "id": "data-filter", + "type": "directory", + "mode": "0700", + "unknown": "program" + }, + { + "id": "data-clamav", + "type": "directory", + "mode": "0700", + "unknown": "program" + }, + { + "id": "data-redis", + "type": "directory", + "mode": "0700", + "unknown": "program" + }, + { + "id": "data-webmail", + "type": "directory", + "mode": "0700", + "unknown": "program" + }, + { + "id": "data-dav", + "type": "directory", + "mode": "0700", + "unknown": "program" + }, + { + "id": "data-fetchmail", + "type": "directory", + "mode": "0700", + "unknown": "program" + }, + { + "id": "data-overrides-nginx", + "type": "directory", + "mode": "0700", + "unknown": "program" + }, + { + "id": "data-overrides-dovecot", + "type": "directory", + "mode": "0700", + "unknown": "program" + }, + { + "id": "data-overrides-postfix", + "type": "directory", + "mode": "0700", + "unknown": "program" + }, + { + "id": "data-overrides-rspamd", + "type": "directory", + "mode": "0700", + "unknown": "program" + }, + { + "id": "data-overrides-roundcube", + "type": "directory", + "mode": "0700", + "unknown": "program" + }, + { + "id": "net", + "type": "network", + "name": "mailu" + }, + { + "id": "resolver", + "type": "container", + "name": "mailu-resolver", + "image": "ghcr.io/mailu/unbound@sha256:2c037bc5c988b5c0ab7e1b6e6ffa762e14ba1e45c96281ac87f8b9be8eee8b6e", + "health": { + "kind": "runtime" + }, + "network": "mailu", + "env-file": [ + "${dir:state}/mailu.env", + "${dir:state}/secret.env" + ], + "secrets-in-environment": "mailu-admin honours SECRET_KEY_FILE, DB_PW_FILE and API_TOKEN_FILE (configuration.py) but INITIAL_ADMIN_PW is env-only (start.py); the remaining containers' need for SECRET_KEY is unverified", + "ip": "192.168.203.254" + }, + { + "id": "redis", + "type": "container", + "name": "mailu-redis", + "image": "redis@sha256:4bed291aa5efb9f0d77b76ff7d4ab71eee410962965d052552db1fb80576431d", + "network": "mailu", + "volumes": [ + "${dir:data-redis}:/data" + ] + }, + { + "id": "admin", + "type": "container", + "name": "mailu-admin", + "image": "ghcr.io/mailu/admin@sha256:6dbfdadc4a9590dcb7652357b505200115b689b74008653bbf369e4599a3be5a", + "health": { + "kind": "runtime" + }, + "network": "mailu", + "ports": [ + "8080" + ], + "env-file": [ + "${dir:state}/mailu.env", + "${dir:state}/secret.env", + "${dir:state}/database.env", + "${dir:state}/admin.env" + ], + "volumes": [ + "${dir:data-data}:/data", + "${dir:data-dkim}:/dkim" + ], + "secrets-in-environment": "mailu-admin honours SECRET_KEY_FILE, DB_PW_FILE and API_TOKEN_FILE (configuration.py) but INITIAL_ADMIN_PW is env-only (start.py); the remaining containers' need for SECRET_KEY is unverified" + }, + { + "id": "imap", + "type": "container", + "name": "mailu-imap", + "image": "ghcr.io/mailu/dovecot@sha256:7f0ed5db996fbdc00adc5c5e38a08492e04f7eb4a9fbd66a03aa9a28ddf23993", + "health": { + "kind": "runtime" + }, + "network": "mailu", + "env-file": [ + "${dir:state}/mailu.env" + ], + "volumes": [ + "${dir:data-mail}:/mail", + "${dir:data-overrides-dovecot}:/overrides:ro" + ], + "dns": [ + "192.168.203.254" + ] + }, + { + "id": "smtp", + "type": "container", + "name": "mailu-smtp", + "image": "ghcr.io/mailu/postfix@sha256:e2e49f39e53b80eac9e7a2f18d9df11edeb4914fd62dbba89b3155e8e034f62e", + "health": { + "kind": "tool", + "tool": "mailu_health", + "interval": "60s", + "timeout": "30s", + "looks": 3, + "grace": "120s" + }, + "network": "mailu", + "env-file": [ + "${dir:state}/mailu.env" + ], + "volumes": [ + "${dir:data-mailqueue}:/queue", + "${dir:data-overrides-postfix}:/overrides:ro" + ], + "dns": [ + "192.168.203.254" + ] + }, + { + "id": "antispam", + "type": "container", + "name": "mailu-antispam", + "image": "ghcr.io/mailu/rspamd@sha256:ff3666d8a61f17d309c5c6f6bcf4d40470b82299ca706ac650301175bb1a079d", + "health": { + "kind": "runtime" + }, + "network": "mailu", + "env-file": [ + "${dir:state}/mailu.env" + ], + "volumes": [ + "${dir:data-filter}:/var/lib/rspamd", + "${dir:data-overrides-rspamd}:/etc/rspamd/override.d:ro" + ], + "dns": [ + "192.168.203.254" + ] + }, + { + "id": "antivirus", + "type": "container", + "name": "mailu-antivirus", + "image": "clamav/clamav-debian@sha256:b12ef8fefddbba7d88de59bea8a32622f365339154adf02d38fd089112e6745a", + "network": "mailu", + "volumes": [ + "${dir:data-clamav}:/var/lib/clamav" + ], + "dns": [ + "192.168.203.254" + ] + }, + { + "id": "webmail", + "type": "container", + "name": "mailu-webmail", + "image": "ghcr.io/mailu/webmail@sha256:bdbee44cdb05a4658f0e3b62cc448de55ca8f8aea172279fda594826144c04f6", + "health": { + "kind": "runtime" + }, + "network": "mailu", + "env-file": [ + "${dir:state}/mailu.env", + "${dir:state}/secret.env" + ], + "volumes": [ + "${dir:data-webmail}:/data", + "${dir:data-overrides-roundcube}:/overrides:ro" + ], + "secrets-in-environment": "mailu-admin honours SECRET_KEY_FILE, DB_PW_FILE and API_TOKEN_FILE (configuration.py) but INITIAL_ADMIN_PW is env-only (start.py); the remaining containers' need for SECRET_KEY is unverified", + "dns": [ + "192.168.203.254" + ] + }, + { + "id": "webdav", + "type": "container", + "name": "mailu-webdav", + "image": "ghcr.io/mailu/radicale@sha256:690ed6edf189dfef100a5a8b37c195ebf5d9241ac5f23f2f44b8b7b75726e3de", + "health": { + "kind": "runtime" + }, + "network": "mailu", + "env-file": [ + "${dir:state}/mailu.env", + "${dir:state}/secret.env" + ], + "volumes": [ + "${dir:data-dav}:/data" + ], + "secrets-in-environment": "mailu-admin honours SECRET_KEY_FILE, DB_PW_FILE and API_TOKEN_FILE (configuration.py) but INITIAL_ADMIN_PW is env-only (start.py); the remaining containers' need for SECRET_KEY is unverified", + "dns": [ + "192.168.203.254" + ] + }, + { + "id": "fetchmail", + "type": "container", + "name": "mailu-fetchmail", + "image": "ghcr.io/mailu/fetchmail@sha256:f881c8412d3bbe73d638469b48321558d6403a9d45bfa043c1e52c752103d42d", + "health": { + "kind": "runtime" + }, + "network": "mailu", + "env-file": [ + "${dir:state}/mailu.env", + "${dir:state}/secret.env" + ], + "volumes": [ + "${dir:data-fetchmail}:/data" + ], + "secrets-in-environment": "mailu-admin honours SECRET_KEY_FILE, DB_PW_FILE and API_TOKEN_FILE (configuration.py) but INITIAL_ADMIN_PW is env-only (start.py); the remaining containers' need for SECRET_KEY is unverified", + "dns": [ + "192.168.203.254" + ] + }, + { + "id": "front", + "type": "container", + "name": "mailu-front", + "image": "ghcr.io/mailu/nginx@sha256:36f98897cd1bc9d27628bbb4e04bdf60147af2ec7507d6da77f002c4f256896d", + "health": { + "kind": "exec", + "command": "curl -m3 -skfLo /dev/null http://127.0.0.1:10204/health && kill -0 $(cat /run/dovecot/master.pid) && python3 -c \"import smtplib; s = smtplib.SMTP('127.0.0.1', 25, timeout=5); s.ehlo('health'); s.quit()\" && openssl x509 -checkend 1209600 -noout -in /certs/letsencrypt/live/mailu/nginx-chain.pem", + "interval": "60s", + "timeout": "15s", + "looks": 3, + "grace": "120s" + }, + "network": "mailu", + "env-file": [ + "${dir:state}/mailu.env" + ], + "ports": [ + "25", + "110", + "143", + "465", + "587", + "993", + "995", + "7080:80", + "7443:443" + ], + "volumes": [ + "${dir:data-certs}:/certs", + "${dir:data-overrides-nginx}:/overrides:ro" + ], + "dns": [ + "192.168.203.254" + ], + "logging": "journald" + }, + { + "id": "runtime-config", + "type": "file", + "path": "${dir:mesh-state}/config.json", + "mode": "0600", + "template": "files/config.json", + "takes-config": ["domain", "proxy-address", "sitename", "website", "url", "container"], + "merge": "json" + }, + { + "id": "automx", + "type": "container", + "name": "mailu-automx", + "artifact": "automx", + "network": "mailu", + "env-file": [ + "${dir:state}/mailu.env" + ], + "ports": [ + "4243" + ], + "volumes": [ + "${dir:data-automx}:/data" + ] + } + ], + "build": { + "on": [ + { + "arg": "PYTHON_BASE", + "image": "python@sha256:25f3cfeaceca14921366af4d1240b56457ef46273bdb508c7b0e8f469f6fd228" + } + ], + "artifacts": [ + { + "name": "code", + "kind": "bundle", + "language": "typescript", + "entrypoints": [ + "index.js", + "tools/index.js", + "provisioner/index.js" + ], + "loads": [ + "index.js", + "tools/index.js", + "provisioner/index.js" + ], + "env": { + "MESH_MAILU_URL": "http://127.0.0.1:${port:8080}/api/v1", + "MESH_MAILU_API_KEY_FILE": "${dir:state}/api-token.secret", + "MESH_MAILU_IMAP_CONTAINER": "mailu-imap", + "MESH_MAILU_SMTP_CONTAINER": "mailu-smtp", + "MESH_MAILU_CONFIG_FILE": "${dir:mesh-state}/config.json", + "MESH_RECEIVES": "${dir:grants}/mesh.json" + } + }, + { + "name": "automx", + "kind": "image", + "from": "automx/Dockerfile" + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + }, + "provides": [ + { + "name": "smtp", + "scope": "mesh", + "identity": { + "max": 64, + "in": "a mailbox's local part" + } + } + ], + "serves": { + "smtp": { + "port": 587, + "domain": "${config:domain}" + } + }, + "receives": { + "smtp": "${dir:grants}/mesh.json" + }, + "grants": { + "smtp": "${dir:grants}" + }, + "jails": [ + { + "name": "mailu-front", + "failregex": "^.*(?:imap|pop3|submission|managesieve)-login: .*\\(auth failed, \\d+ attempts(?: in \\d+ secs)?\\):.*rip=(?:,|$)\n ^.*client login failed: \"AUTH not supported\" while in http auth state, client: , server: \\S+:25,", + "jail": "backend = systemd\njournalmatch = CONTAINER_NAME=mailu-front\nport = smtp,submission,submissions,imap,imaps,pop3,pop3s\nmaxretry = 3\nfindtime = 1d\nbantime = 1d" + } + ] +} diff --git a/go/testdata/beside/mesh-catalog/modules/marrytts/module.json.captured b/go/testdata/beside/mesh-catalog/modules/marrytts/module.json.captured new file mode 100644 index 0000000..ed242aa --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/marrytts/module.json.captured @@ -0,0 +1,37 @@ +{ + "module": "marrytts", + "version": "1", + "capabilities": [ + "container-runtime" + ], + "listens": [ + { + "name": "api", + "port": 59125, + "protocol": "tcp", + "from": "mesh", + "why": "the MaryTTS text-to-speech HTTP API and web interface" + } + ], + "resources": [ + { + "id": "mesh-state", + "type": "directory", + "mode": "0700", + "place": "mesh", + "unknown": "program" + }, + { + "id": "server", + "type": "container", + "name": "marrytts", + "image": "synesthesiam/marytts@sha256:45970ecb3e21a2981c66c60563a70cf00be8e95c02565e7d74b3a73dcec7db2c", + "env": { + "TZ": "Europe/Brussels" + }, + "ports": [ + "59125" + ] + } + ] +} diff --git a/go/testdata/beside/mesh-catalog/modules/matrix/files/conduit.toml b/go/testdata/beside/mesh-catalog/modules/matrix/files/conduit.toml new file mode 100644 index 0000000..9b72401 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/matrix/files/conduit.toml @@ -0,0 +1,15 @@ +# Written by the mesh (modules/matrix). Conduit reads this file (CONDUIT_CONFIG); nothing comes +# from the environment. server_name is the homeserver's permanent identity: every user id, room id +# and signature in the database carries it, so it is the name this module is served under +# (${bound:route:name-homeserver}) and never changes once a database exists. +[global] +server_name = "${bound:route:name-homeserver}" +database_backend = "rocksdb" +database_path = "/var/lib/matrix-conduit/" +address = "0.0.0.0" +port = 6167 +max_request_size = 20000000 +allow_registration = false +allow_federation = true +allow_check_for_updates = true +trusted_servers = ["matrix.org"] diff --git a/go/testdata/beside/mesh-catalog/modules/matrix/files/element.json b/go/testdata/beside/mesh-catalog/modules/matrix/files/element.json new file mode 100644 index 0000000..305a89f --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/matrix/files/element.json @@ -0,0 +1,53 @@ +{ + "default_server_name": "${bound:route:name-homeserver}", + "default_server_config": { + "m.homeserver": { + "base_url": "https://${bound:route:name-homeserver}" + }, + "m.identity_server": { + "base_url": "https://vector.im" + } + }, + "brand": "Element", + "integrations_ui_url": "https://scalar.vector.im/", + "integrations_rest_url": "https://scalar.vector.im/api", + "integrations_widgets_urls": [ + "https://scalar.vector.im/_matrix/integrations/v1", + "https://scalar.vector.im/api", + "https://scalar-staging.vector.im/_matrix/integrations/v1", + "https://scalar-staging.vector.im/api", + "https://scalar-staging.riot.im/scalar/api" + ], + "bug_report_endpoint_url": "https://element.io/bugreports/submit", + "uisi_autorageshake_app": "element-auto-uisi", + "show_labs_settings": true, + "room_directory": { + "servers": [ + "${bound:route:name-homeserver}", + "matrix.org", + "gitter.im", + "libera.chat" + ] + }, + "enable_presence_by_hs_url": { + "https://matrix.org": false, + "https://matrix-client.matrix.org": false + }, + "terms_and_conditions_links": [ + { + "url": "https://element.io/privacy", + "text": "Privacy Policy" + }, + { + "url": "https://element.io/cookie-policy", + "text": "Cookie Policy" + } + ], + "features": { + "feature_video_rooms": true, + "feature_rust_crypto": true + }, + "element_call": { + "url": "https://call.element.dev" + } +} diff --git a/go/testdata/beside/mesh-catalog/modules/matrix/module.json.captured b/go/testdata/beside/mesh-catalog/modules/matrix/module.json.captured new file mode 100644 index 0000000..170719c --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/matrix/module.json.captured @@ -0,0 +1,257 @@ +{ + "module": "matrix", + "version": "1", + "capabilities": [ + "container-runtime" + ], + "listens": [ + { + "name": "client", + "port": 6167, + "protocol": "tcp", + "from": "mesh", + "why": "Conduit's client-server and federation APIs over plain HTTP. Both arrive through the route on 443: Conduit answers /.well-known/matrix/server with :443, so other homeservers federate through the proxy and nothing needs the traditional 8448" + }, + { + "name": "web", + "port": 80, + "protocol": "tcp", + "from": "mesh", + "why": "Element Web, the static browser client, served by the image's nginx; reached through its route" + } + ], + "requires": [ + "route" + ], + "contributes": { + "route": { + "homeserver": { + "label": "matrix", + "endpoint": "client" + }, + "element": { + "label": "element", + "endpoint": "web" + } + } + }, + "binds": { + "route": "${dir:state}/route.json" + }, + "data": { + "own": [ + { + "id": "db", + "path": "${dir:db}", + "class": "valuable", + "why": "every room, message and account" + } + ] + }, + "config": { + "default_server_name": { + "type": "string", + "description": "Element's default_server_name setting of config.json: the homeserver Element signs in to by default, by name; the file's default is the module's own route.", + "trusted": true, + "default": "${bound:route:name-homeserver}" + }, + "default_server_config": { + "type": "object", + "description": "Element's default_server_config setting of config.json: the homeserver and identity server Element uses by default, by URL.", + "trusted": true, + "default": { + "m.homeserver": { + "base_url": "https://${bound:route:name-homeserver}" + }, + "m.identity_server": { + "base_url": "https://vector.im" + } + } + }, + "brand": { + "type": "string", + "description": "Element's brand setting of config.json: the name Element shows itself as.", + "trusted": true, + "default": "Element" + }, + "integrations_ui_url": { + "type": "string", + "description": "Element's integrations_ui_url setting of config.json: the integration manager's page, a URL.", + "trusted": true, + "default": "https://scalar.vector.im/" + }, + "integrations_rest_url": { + "type": "string", + "description": "Element's integrations_rest_url setting of config.json: the integration manager's API, a URL.", + "trusted": true, + "default": "https://scalar.vector.im/api" + }, + "integrations_widgets_urls": { + "type": "list", + "description": "Element's integrations_widgets_urls setting of config.json: the URLs whose widgets Element trusts as the integration manager's.", + "trusted": true, + "default": [ + "https://scalar.vector.im/_matrix/integrations/v1", + "https://scalar.vector.im/api", + "https://scalar-staging.vector.im/_matrix/integrations/v1", + "https://scalar-staging.vector.im/api", + "https://scalar-staging.riot.im/scalar/api" + ] + }, + "bug_report_endpoint_url": { + "type": "string", + "description": "Element's bug_report_endpoint_url setting of config.json: where Element sends a bug report, a URL; its default here is empty, and the file's own value (Element's, naming outside servers) stays in the file and is never replaced unless this is set.", + "trusted": true, + "default": "" + }, + "uisi_autorageshake_app": { + "type": "string", + "description": "Element's uisi_autorageshake_app setting of config.json: the app name Element files an automatic report of an undecryptable message under.", + "trusted": true, + "default": "element-auto-uisi" + }, + "show_labs_settings": { + "type": "boolean", + "description": "Element's show_labs_settings setting of config.json: whether Element shows its Labs settings.", + "trusted": true, + "default": true + }, + "room_directory": { + "type": "object", + "description": "Element's room_directory setting of config.json: the servers whose room directories Element offers; its default here is empty, and the file's own value (Element's, naming outside servers) stays in the file and is never replaced unless this is set.", + "trusted": true, + "default": {} + }, + "enable_presence_by_hs_url": { + "type": "object", + "description": "Element's enable_presence_by_hs_url setting of config.json: whether Element shows presence, per homeserver URL.", + "trusted": true, + "default": { + "https://matrix.org": false, + "https://matrix-client.matrix.org": false + } + }, + "terms_and_conditions_links": { + "type": "list", + "description": "Element's terms_and_conditions_links setting of config.json: the links Element shows as its terms, each a URL and its text; its default here is empty, and the file's own value (Element's, naming outside servers) stays in the file and is never replaced unless this is set.", + "trusted": true, + "default": [] + }, + "features": { + "type": "object", + "description": "Element's features setting of config.json: Element's feature switches, each on or off.", + "trusted": true, + "default": { + "feature_video_rooms": true, + "feature_rust_crypto": true + } + }, + "element_call": { + "type": "object", + "description": "Element's element_call setting of config.json: where Element starts a call with Element Call, a URL.", + "trusted": true, + "default": { + "url": "https://call.element.dev" + } + } + }, + "resources": [ + { + "id": "state", + "type": "directory", + "mode": "0700", + "place": ".", + "unknown": "program" + }, + { + "id": "db", + "type": "directory", + "mode": "0700", + "unknown": "program" + }, + { + "id": "conduit-conf", + "type": "file", + "path": "${dir:state}/conduit.toml", + "mode": "0644", + "template": "files/conduit.toml", + "names-on-purpose": { + "matrix.org": "the federation's public key server, trusted by default; the world's, not this mesh's" + } + }, + { + "id": "element-conf", + "type": "file", + "path": "${dir:state}/element.json", + "mode": "0644", + "merge": "json", + "takes-config": ["default_server_name", "default_server_config", "brand", "integrations_ui_url", "integrations_rest_url", "integrations_widgets_urls", "bug_report_endpoint_url", "uisi_autorageshake_app", "show_labs_settings", "room_directory", "enable_presence_by_hs_url", "terms_and_conditions_links", "features", "element_call"], + "template": "files/element.json", + "names-on-purpose": { + "matrix.org": "the public room directory and the federation's largest homeserver; the world's", + "matrix-client.matrix.org": "the same homeserver's client endpoint; the world's", + "vector.im": "Element's public identity server; the world's", + "scalar.vector.im": "Element's public integration manager; the world's", + "scalar-staging.vector.im": "Element's staging integration manager, named by the upstream default config; the world's", + "scalar-staging.riot.im": "the same, under its former name; the world's", + "element.io": "Element's bug reports, privacy and cookie pages; the world's", + "gitter.im": "a public room directory; the world's", + "libera.chat": "a public room directory; the world's", + "call.element.dev": "Element Call's public instance; the world's" + } + }, + { + "id": "net", + "type": "network", + "name": "matrix" + }, + { + "id": "homeserver", + "type": "container", + "name": "matrix", + "image": "matrixconduit/matrix-conduit@sha256:b0d24248e94f944ca49f90f10c429e3d65f4472bdde25661ecea9840134fb133", + "network": "matrix", + "env": { + "CONDUIT_CONFIG": "/etc/conduit/conduit.toml" + }, + "ports": [ + "6167" + ], + "volumes": [ + "${dir:db}:/var/lib/matrix-conduit", + "${dir:state}/conduit.toml:/etc/conduit/conduit.toml:ro" + ], + "restart-on": [ + "conduit-conf" + ] + }, + { + "id": "element", + "type": "container", + "name": "element-web", + "image": "vectorim/element-web@sha256:a8f415462ab8d2600a592ba1b92bea51efe5a4d10eb738aab9bed769f7099613", + "health": { + "kind": "runtime" + }, + "network": "matrix", + "ports": [ + "80" + ], + "volumes": [ + "${dir:state}/element.json:/app/config.json:ro" + ], + "restart-on": [ + "element-conf" + ] + } + ], + "build": { + "artifacts": [ + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/go/testdata/beside/mesh-catalog/modules/memory-pressure/files/oomd.conf b/go/testdata/beside/mesh-catalog/modules/memory-pressure/files/oomd.conf new file mode 100644 index 0000000..39af6de --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/memory-pressure/files/oomd.conf @@ -0,0 +1,8 @@ +# Managed by the mesh (module memory-pressure). Replaced on every push; edit the catalogue instead. +# +# systemd-oomd kills the worst unit before the kernel's OOM killer freezes the machine: when swap is +# 90 % used, or when a watched unit stalls on memory for 20 s above its limit. +[OOM] +SwapUsedLimit=90% +DefaultMemoryPressureLimit=60% +DefaultMemoryPressureDurationSec=20s diff --git a/go/testdata/beside/mesh-catalog/modules/memory-pressure/files/root-slice.conf b/go/testdata/beside/mesh-catalog/modules/memory-pressure/files/root-slice.conf new file mode 100644 index 0000000..edae3de --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/memory-pressure/files/root-slice.conf @@ -0,0 +1,5 @@ +# Managed by the mesh (module memory-pressure). Replaced on every push; edit the catalogue instead. +# +# systemd-oomd may act on swap exhaustion across the whole machine. +[Slice] +ManagedOOMSwap=kill diff --git a/go/testdata/beside/mesh-catalog/modules/memory-pressure/files/sysctl.conf b/go/testdata/beside/mesh-catalog/modules/memory-pressure/files/sysctl.conf new file mode 100644 index 0000000..678251c --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/memory-pressure/files/sysctl.conf @@ -0,0 +1,9 @@ +# Managed by the mesh (module memory-pressure). Replaced on every push; edit the catalogue instead. +# +# Swap-in reads one page, not eight: read-ahead exists to amortise a disk's seek, and compressed swap in +# RAM has none — the speculation only costs decompression and memory. +vm.page-cluster = 0 +# +# vm.swappiness is deliberately left alone. The usual zram advice (150-180) holds only while the +# compressed swap has room; once it is full, a higher swappiness moves pages to the disk swap, the thing +# being avoided. Raise it only together with zram-size. diff --git a/go/testdata/beside/mesh-catalog/modules/memory-pressure/files/user-manager.conf b/go/testdata/beside/mesh-catalog/modules/memory-pressure/files/user-manager.conf new file mode 100644 index 0000000..8ef300c --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/memory-pressure/files/user-manager.conf @@ -0,0 +1,7 @@ +# Managed by the mesh (module memory-pressure). Replaced on every push; edit the catalogue instead. +# +# systemd-oomd may kill the worst unit in a person's service manager when its memory pressure stays +# above 80 % — instead of the kernel freezing the machine. +[Service] +ManagedOOMMemoryPressure=kill +ManagedOOMMemoryPressureLimit=80% diff --git a/go/testdata/beside/mesh-catalog/modules/memory-pressure/files/zram-generator.conf b/go/testdata/beside/mesh-catalog/modules/memory-pressure/files/zram-generator.conf new file mode 100644 index 0000000..cccfb05 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/memory-pressure/files/zram-generator.conf @@ -0,0 +1,11 @@ +# Managed by the mesh (module memory-pressure). Replaced on every push; edit the catalogue instead. +# +# Compressed swap in RAM: fast, at a higher priority than any swap on disk, so it takes the everyday +# pressure before anything spills to a disk. Half the RAM, at most 16 GiB, compressed with zstd. +# The swap on disk, its size and whether one exists at all are the machine's, not this module's. +# +# Read by the generator at boot: a change applies at the next boot. +[zram0] +zram-size = min(ram / 2, 16384) +compression-algorithm = zstd +swap-priority = 100 diff --git a/go/testdata/beside/mesh-catalog/modules/memory-pressure/module.json.captured b/go/testdata/beside/mesh-catalog/modules/memory-pressure/module.json.captured new file mode 100644 index 0000000..1af96e3 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/memory-pressure/module.json.captured @@ -0,0 +1,130 @@ +{ + "module": "memory-pressure", + "version": "1", + "capabilities": [ + "package-manager", + "service-manager" + ], + "emits": [ + "pressure.high", + "pressure.cleared" + ], + "tools": [ + "memory_status", + "memory_top", + "memory_oom_history", + "memory_zram", + "memory_oomd", + "memory_guard" + ], + "resources": [ + { + "id": "zram-generator", + "type": "package", + "package": "zram-generator" + }, + { + "id": "zram", + "type": "file", + "path": "/etc/systemd/zram-generator.conf", + "mode": "0644", + "template": "files/zram-generator.conf" + }, + { + "id": "sysctl-drop-ins", + "type": "directory", + "path": "/etc/sysctl.d", + "mode": "0755", + "unknown": "program" + }, + { + "id": "swap-tunables", + "type": "file", + "path": "/etc/sysctl.d/90-memory-pressure.conf", + "mode": "0644", + "template": "files/sysctl.conf" + }, + { + "id": "sysctl", + "type": "service", + "unit": "systemd-sysctl.service", + "restart-on": [ + "swap-tunables" + ] + }, + { + "id": "oomd-drop-ins", + "type": "directory", + "path": "/etc/systemd/oomd.conf.d", + "mode": "0755", + "unknown": "program" + }, + { + "id": "oomd-limits", + "type": "file", + "path": "/etc/systemd/oomd.conf.d/memory-pressure.conf", + "mode": "0644", + "template": "files/oomd.conf" + }, + { + "id": "root-slice-drop-ins", + "type": "directory", + "path": "/etc/systemd/system/-.slice.d", + "mode": "0755", + "unknown": "program" + }, + { + "id": "root-slice", + "type": "file", + "path": "/etc/systemd/system/-.slice.d/10-oomd.conf", + "mode": "0644", + "template": "files/root-slice.conf" + }, + { + "id": "user-manager-drop-ins", + "type": "directory", + "path": "/etc/systemd/system/user@.service.d", + "mode": "0755", + "unknown": "program" + }, + { + "id": "user-manager", + "type": "file", + "path": "/etc/systemd/system/user@.service.d/10-oomd.conf", + "mode": "0644", + "template": "files/user-manager.conf" + }, + { + "id": "oomd", + "type": "service", + "unit": "systemd-oomd.service", + "state": "running", + "boot": "enabled", + "restart-on": [ + "oomd-limits", + "root-slice", + "user-manager" + ] + } + ], + "build": { + "artifacts": [ + { + "name": "tools-go", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/memory-pressure", + "binary": "memory-pressure", + "loads": [ + "memory-pressure" + ] + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/go/testdata/beside/mesh-catalog/modules/mesh-catalog/files/database.url b/go/testdata/beside/mesh-catalog/modules/mesh-catalog/files/database.url new file mode 100644 index 0000000..dc59da9 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/mesh-catalog/files/database.url @@ -0,0 +1 @@ +postgresql://${bound:postgres-database:as}:${secret:postgres-database}@${bound:postgres-database:at}:${bound:postgres-database:port}/${bound:postgres-database:as} diff --git a/go/testdata/beside/mesh-catalog/modules/mesh-catalog/module.json.captured b/go/testdata/beside/mesh-catalog/modules/mesh-catalog/module.json.captured new file mode 100644 index 0000000..8d8c659 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/mesh-catalog/module.json.captured @@ -0,0 +1,98 @@ +{ + "module": "mesh-catalog", + "slug": "catalog", + "version": "1", + "capabilities": [ + "container-runtime" + ], + "claims": [ + { + "name": "mesh-catalog", + "scope": "mesh" + } + ], + "requires": [ + "postgres-database" + ], + "contributes": { + "postgres-database": { + "name": "mesh_catalog" + } + }, + "binds": { + "postgres-database": "${dir:state}/database.json" + }, + "secrets": { + "postgres-database": "${dir:state}/database.secret" + }, + "consumes": [ + "node-build-agent.built", + "mesh-build-machine.built", + "mesh-controller.built-before" + ], + "emits": [ + "registered", + "upgraded", + "rebuild-needed", + "catching-up" + ], + "resources": [ + { + "id": "state", + "type": "directory", + "mode": "0700", + "place": ".", + "unknown": "program" + }, + { + "id": "database-url", + "type": "file", + "path": "${dir:state}/database.url", + "mode": "0600", + "template": "files/database.url" + }, + { + "id": "prepare", + "type": "process", + "name": "mesh-catalog-prepare", + "artifact": "code", + "run": [ + "node", + "prepare/index.js" + ], + "run-once": true, + "env": { + "DATABASE_URL_FILE": "${dir:state}/database.url" + }, + "restart-on": [ + "database-url" + ] + } + ], + "build": { + "artifacts": [ + { + "name": "code", + "kind": "bundle", + "language": "typescript", + "entrypoints": [ + "index.js", + "tools/index.js", + "prepare/index.js" + ], + "loads": [ + "index.js", + "tools/index.js" + ], + "env": { + "DATABASE_URL_FILE": "${dir:state}/database.url" + } + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/go/testdata/beside/mesh-catalog/modules/mesh-cli/files/zsh-normal b/go/testdata/beside/mesh-catalog/modules/mesh-cli/files/zsh-normal new file mode 100644 index 0000000..117f2a5 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/mesh-cli/files/zsh-normal @@ -0,0 +1,3 @@ +# The operator's command (module mesh-cli, novox/hq ADR 0272): nox is mesh-cli, in interactive zsh only. +# A script, sudo and a document meant to work everywhere say mesh-cli. +alias nox=mesh-cli diff --git a/go/testdata/beside/mesh-catalog/modules/mesh-cli/module.json.captured b/go/testdata/beside/mesh-catalog/modules/mesh-cli/module.json.captured new file mode 100644 index 0000000..681a3e5 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/mesh-cli/module.json.captured @@ -0,0 +1,37 @@ +{ + "module": "mesh-cli", + "version": "1", + "moved": true, + "resources": [ + { + "id": "program", + "type": "archive", + "artifact": "program", + "path": "/usr/local/bin", + "unknown": "program" + } + ], + "items": [ + { + "collection": "shell:zsh:normal", + "template": "files/zsh-normal" + } + ], + "build": { + "artifacts": [ + { + "name": "program", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/mesh-cli", + "binary": "mesh-cli" + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/go/testdata/beside/mesh-catalog/modules/mesh-config/module.json.captured b/go/testdata/beside/mesh-catalog/modules/mesh-config/module.json.captured new file mode 100644 index 0000000..e03e891 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/mesh-config/module.json.captured @@ -0,0 +1,74 @@ +{ + "module": "mesh-config", + "version": "1", + "runs-as": "mesh-config", + "claims": [ + { + "name": "mesh-config", + "scope": "mesh", + "serves": [ + "config" + ] + } + ], + "emits": [ + "config-changed" + ], + "uses": [ + "operator-channel" + ], + "invokes": [ + "seat:mesh-controller.hand-act", + "seat:mesh-controller.warranted" + ], + "reads": [ + "mesh-controller.assignments", + "mesh-controller.declared-config", + "mesh-controller.predefined" + ], + "state": [ + "values", + "resolved", + "predefined", + "proposals" + ], + "logs": [ + "history" + ], + "own-secrets": { + "broker": "${dir:state}/broker" + }, + "secrets-owner": "mesh-config", + "resources": [ + { + "id": "account", + "type": "user", + "name": "mesh-config", + "shell": "/usr/bin/nologin", + "home": "/var/lib/mesh-config" + }, + { + "id": "state", + "type": "directory", + "mode": "0700", + "owner": "mesh-config", + "place": ".", + "unknown": "program" + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/mesh-config", + "binary": "mesh-config", + "loads": [ + "mesh-config" + ] + } + ] + } +} diff --git a/go/testdata/beside/mesh-catalog/modules/mesh-delivery-gate/module.json.captured b/go/testdata/beside/mesh-catalog/modules/mesh-delivery-gate/module.json.captured new file mode 100644 index 0000000..ec83852 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/mesh-delivery-gate/module.json.captured @@ -0,0 +1,110 @@ +{ + "module": "mesh-delivery-gate", + "version": "1", + "slug": "gate", + "claims": [ + { + "name": "mesh-delivery", + "scope": "mesh", + "serves": [ + "deliveries", + "times", + "show", + "groups", + "what-if", + "checks", + "table", + "stalled", + "recheck", + "release", + "stop", + "close", + "retire-history" + ] + } + ], + "consumes": [ + "gitea.pull.updated", + "gitea.pull.merged", + "gitea.pull.closed", + "gitea.forge.status", + "gitea.forge.pull-request.edited", + "gitea.forge.pull-request.closed", + "mesh-controller.checked", + "mesh-controller.plan-moved", + "mesh-delivery-forge-bridge.forge.**" + ], + "emits": [ + "transition", + "group", + "merged" + ], + "invokes": [ + "seat:mesh-controller.deliver", + "seat:mesh-controller.delivery-stop", + "seat:mesh-controller.delivery-backups-again", + "seat:mesh-controller.delivery-walks", + "seat:mesh-controller.delivery-check", + "seat:mesh-controller.forget-checks", + "seat:mesh-controller.plans", + "gitea.gitea_commit_statuses", + "gitea.gitea_contains", + "gitea.gitea_open_pulls" + ], + "state": [ + "deliveries", + "groups", + "pulls", + { + "name": "asks", + "history": 5 + }, + { + "name": "merges", + "history": 10 + }, + { + "name": "owed", + "history": 5 + }, + { + "name": "source", + "history": 64 + } + ], + "reads": [ + "mesh-delivery.deliveries", + "mesh-delivery.groups", + "mesh-delivery.pulls", + "mesh-delivery-teller.answers", + "mesh-controller.assignments", + "node-engine.have" + ], + "tools": [ + "delivery_status" + ], + "resources": [ + { + "id": "state", + "type": "directory", + "mode": "0700", + "place": ".", + "unknown": "program" + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/mesh-delivery-gate", + "binary": "mesh-delivery-gate", + "loads": [ + "mesh-delivery-gate" + ] + } + ] + } +} diff --git a/go/testdata/beside/mesh-catalog/modules/mesh-delivery/module.json.captured b/go/testdata/beside/mesh-catalog/modules/mesh-delivery/module.json.captured new file mode 100644 index 0000000..1a40896 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/mesh-delivery/module.json.captured @@ -0,0 +1,95 @@ +{ + "module": "mesh-delivery", + "version": "1", + "slug": "deliver", + "claims": [ + { + "name": "mesh-delivery", + "scope": "mesh", + "serves": [ + "deliveries", + "times", + "show", + "groups", + "what-if", + "checks", + "table", + "stalled", + "recheck", + "release", + "stop", + "close", + "retire-history" + ] + } + ], + "consumes": [ + "gitea.pull.updated", + "gitea.pull.merged", + "gitea.pull.closed", + "gitea.forge.status", + "gitea.forge.pull-request.edited", + "gitea.forge.pull-request.closed", + "mesh-controller.checked", + "mesh-controller.plan-moved" + ], + "emits": [ + "transition", + "group", + "merged" + ], + "invokes": [ + "seat:mesh-controller.delivery-plan", + "seat:mesh-controller.delivery-check", + "seat:mesh-controller.deliver", + "seat:mesh-controller.delivery-backups-again", + "seat:mesh-controller.delivery-stop", + "seat:mesh-controller.delivery-walks", + "seat:mesh-controller.forget-checks", + "gitea.gitea_note_append", + "gitea.gitea_delivery_view", + "gitea.gitea_commit_status", + "gitea.gitea_commit_statuses", + "gitea.gitea_contains", + "gitea.gitea_open_pulls", + "gitea.gitea_merge_pull_request", + "gitea.gitea_add_comment", + "seat:operator-channel.notify" + ], + "uses": [ + "operator-channel" + ], + "state": [ + "deliveries", + "groups", + "asked", + "pulls" + ], + "tools": [ + "delivery_status" + ], + "resources": [ + { + "id": "state", + "type": "directory", + "mode": "0700", + "place": ".", + "unknown": "program" + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/mesh-delivery", + "binary": "mesh-delivery", + "loads": [ + "mesh-delivery" + ] + } + ] + } +} diff --git a/go/testdata/beside/mesh-catalog/modules/mesh-files/module.json.captured b/go/testdata/beside/mesh-catalog/modules/mesh-files/module.json.captured new file mode 100644 index 0000000..cc1df16 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/mesh-files/module.json.captured @@ -0,0 +1,57 @@ +{ + "module": "mesh-files", + "version": "1", + "claims": [ + { + "name": "node-files", + "scope": "node", + "serves": [ + "stat", + "readlink", + "list", + "read", + "search", + "account", + "write", + "copy", + "move", + "delete", + "mkdir", + "chmod", + "chown", + "trash", + "restore" + ] + } + ], + "invokes": [ + "seat:operator-channel.tell" + ], + "resources": [ + { + "id": "state", + "type": "directory", + "mode": "0700", + "place": ".", + "unknown": "program" + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/mesh-files", + "binary": "mesh-files", + "loads": [ + "mesh-files" + ], + "env": { + "MESH_FILES_STATE": "${dir:state}" + } + } + ] + } +} diff --git a/go/testdata/beside/mesh-catalog/modules/mesh-issues/files/settings.json b/go/testdata/beside/mesh-catalog/modules/mesh-issues/files/settings.json new file mode 100644 index 0000000..66abd38 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/mesh-issues/files/settings.json @@ -0,0 +1,147 @@ +{ + "first-number": 365, + "owners": [ + "hq", + "mesh-catalog", + "mesh-catalog modules/adwaita", + "mesh-catalog modules/artifact-store-tools", + "mesh-catalog modules/asus-zephyrus-g14", + "mesh-catalog modules/audit-logger", + "mesh-catalog modules/avahi", + "mesh-catalog modules/baserow", + "mesh-catalog modules/betterbird", + "mesh-catalog modules/blueman", + "mesh-catalog modules/bluetooth", + "mesh-catalog modules/ca-trust", + "mesh-catalog modules/claude-code", + "mesh-catalog modules/claude-licence-manager", + "mesh-catalog modules/clipmenu", + "mesh-catalog modules/confluence", + "mesh-catalog modules/cups", + "mesh-catalog modules/dbus", + "mesh-catalog modules/desk-channel", + "mesh-catalog modules/disk-load", + "mesh-catalog modules/distribution", + "mesh-catalog modules/dmenu", + "mesh-catalog modules/dnsmasq", + "mesh-catalog modules/docker", + "mesh-catalog modules/docker-compose", + "mesh-catalog modules/dunst", + "mesh-catalog modules/fail2ban", + "mesh-catalog modules/feh", + "mesh-catalog modules/flatpak", + "mesh-catalog modules/fonts", + "mesh-catalog modules/forticlient", + "mesh-catalog modules/gitea", + "mesh-catalog modules/gitlab", + "mesh-catalog modules/gnome-keyring", + "mesh-catalog modules/grafana", + "mesh-catalog modules/hello-web", + "mesh-catalog modules/home-assistant", + "mesh-catalog modules/hostname", + "mesh-catalog modules/i3", + "mesh-catalog modules/i3status-rust", + "mesh-catalog modules/icecast", + "mesh-catalog modules/influxdb", + "mesh-catalog modules/invoicing", + "mesh-catalog modules/jira", + "mesh-catalog modules/keycloak", + "mesh-catalog modules/lab", + "mesh-catalog modules/lemurs", + "mesh-catalog modules/letta", + "mesh-catalog modules/local-model-consumer", + "mesh-catalog modules/localization", + "mesh-catalog modules/logrotate", + "mesh-catalog modules/mailu", + "mesh-catalog modules/marrytts", + "mesh-catalog modules/matrix", + "mesh-catalog modules/memory-pressure", + "mesh-catalog modules/mesh-catalog", + "mesh-catalog modules/mesh-cli", + "mesh-catalog modules/mesh-delivery", + "mesh-catalog modules/mesh-delivery-gate", + "mesh-catalog modules/mesh-issues", + "mesh-catalog modules/mesh-vault", + "mesh-catalog modules/mesh-watcher", + "mesh-catalog modules/messenger", + "mesh-catalog modules/minio", + "mesh-catalog modules/model-usage", + "mesh-catalog modules/mongodb", + "mesh-catalog modules/mosquitto", + "mesh-catalog modules/mounts", + "mesh-catalog modules/mssql", + "mesh-catalog modules/n8n", + "mesh-catalog modules/nats", + "mesh-catalog modules/netcheck", + "mesh-catalog modules/networkmanager", + "mesh-catalog modules/nextcloud", + "mesh-catalog modules/nextcloud-client", + "mesh-catalog modules/nfs-server", + "mesh-catalog modules/nftables", + "mesh-catalog modules/nm-applet", + "mesh-catalog modules/node-env", + "mesh-catalog modules/nodered", + "mesh-catalog modules/ollama", + "mesh-catalog modules/only-office", + "mesh-catalog modules/openai-consumer", + "mesh-catalog modules/openrazer", + "mesh-catalog modules/pacman", + "mesh-catalog modules/photos-eef", + "mesh-catalog modules/photos-filip", + "mesh-catalog modules/picom", + "mesh-catalog modules/polychromatic", + "mesh-catalog modules/postgres", + "mesh-catalog modules/power", + "mesh-catalog modules/powerlevel10k", + "mesh-catalog modules/records", + "mesh-catalog modules/redis", + "mesh-catalog modules/restic", + "mesh-catalog modules/rofi", + "mesh-catalog modules/route-adapter", + "mesh-catalog modules/screen-lock", + "mesh-catalog modules/searxng", + "mesh-catalog modules/sensors", + "mesh-catalog modules/showcase", + "mesh-catalog modules/slack", + "mesh-catalog modules/snapd", + "mesh-catalog modules/ssh-client", + "mesh-catalog modules/sshd", + "mesh-catalog modules/step-ca", + "mesh-catalog modules/sudo", + "mesh-catalog modules/supabase", + "mesh-catalog modules/systemd", + "mesh-catalog modules/systemd-networkd", + "mesh-catalog modules/systemd-resolved", + "mesh-catalog modules/telegram", + "mesh-catalog modules/time-sync", + "mesh-catalog modules/triggerhappy", + "mesh-catalog modules/umami", + "mesh-catalog modules/unifi", + "mesh-catalog modules/website", + "mesh-catalog modules/xclip", + "mesh-catalog modules/xdg", + "mesh-catalog modules/xorg", + "mesh-catalog modules/xterm", + "mesh-catalog modules/zfs", + "mesh-catalog modules/zsh", + "mesh-catalog modules/zsh-autosuggestions", + "mesh-catalog modules/zsh-syntax-highlighting", + "mesh-controller", + "mesh-controller modules/build-agent", + "mesh-controller modules/route-proxy", + "mesh-delivery", + "mesh-host", + "mesh-lab", + "mesh-media-catalog", + "mesh-sdk", + "mesh-tools" + ], + "duplicate-check": "on", + "judge-model": "sonnet", + "forge-owner": "novox", + "stuck-after": "2h", + "worker-stale-after": "24h", + "delivered-not-resolved-after": "1h", + "decision-floor": 293, + "research-floor": 45 +} diff --git a/go/testdata/beside/mesh-catalog/modules/mesh-issues/module.json.captured b/go/testdata/beside/mesh-catalog/modules/mesh-issues/module.json.captured new file mode 100644 index 0000000..592e81c --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/mesh-issues/module.json.captured @@ -0,0 +1,330 @@ +{ + "module": "mesh-issues", + "version": "1", + "slug": "issues", + "seats": [ + { + "name": "issue-tracker", + "scope": "mesh", + "serves": [ + "open", + "get", + "list", + "move", + "note", + "link", + "import", + "groom", + "set", + "tracking", + "reserve", + "fix-times" + ] + } + ], + "claims": [ + { + "name": "issue-tracker", + "scope": "mesh", + "serves": [ + "open", + "get", + "list", + "move", + "note", + "link", + "import", + "groom", + "set", + "tracking", + "reserve", + "fix-times" + ] + } + ], + "emits": [ + "opened", + "moved", + "noted", + "linked", + "imported", + "set", + "triaged", + "ready", + "ready-to-verify", + "reserved" + ], + "consumes": [ + "mesh-issues.opened", + "mesh-issues.moved", + "mesh-delivery.transition", + "mesh-delivery-gate.transition" + ], + "invokes": [ + "claude-code.claude_code_judge", + "seat:mesh-delivery.checks", + "seat:operator-channel.notify", + "gitea.gitea_pull_reviews", + "records.records_status", + "records.records_list", + "records.records_read", + "mesh-knowledge.knowledge_highest" + ], + "logs": [ + "changes" + ], + "state": [ + { + "name": "issues", + "history": 2 + }, + "counters", + "grooming", + "reservations" + ], + "config": { + "first-number": { + "type": "number", + "description": "The number the first issue the tracker numbers gets, read once when its counter starts and never again; the import reads it as the boundary between the folders hq numbered and the tracker's issues.", + "minimum": 1, + "trusted": true, + "default": 365 + }, + "owners": { + "type": "list", + "description": "The registered places an issue's located-in may name: a repository, or a repository and a folder in it separated by a space.", + "trusted": true, + "default": [ + "hq", + "mesh-catalog", + "mesh-catalog modules/adwaita", + "mesh-catalog modules/artifact-store-tools", + "mesh-catalog modules/asus-zephyrus-g14", + "mesh-catalog modules/audit-logger", + "mesh-catalog modules/avahi", + "mesh-catalog modules/baserow", + "mesh-catalog modules/betterbird", + "mesh-catalog modules/blueman", + "mesh-catalog modules/bluetooth", + "mesh-catalog modules/ca-trust", + "mesh-catalog modules/claude-code", + "mesh-catalog modules/claude-licence-manager", + "mesh-catalog modules/clipmenu", + "mesh-catalog modules/confluence", + "mesh-catalog modules/cups", + "mesh-catalog modules/dbus", + "mesh-catalog modules/desk-channel", + "mesh-catalog modules/disk-load", + "mesh-catalog modules/distribution", + "mesh-catalog modules/dmenu", + "mesh-catalog modules/dnsmasq", + "mesh-catalog modules/docker", + "mesh-catalog modules/docker-compose", + "mesh-catalog modules/dunst", + "mesh-catalog modules/fail2ban", + "mesh-catalog modules/feh", + "mesh-catalog modules/flatpak", + "mesh-catalog modules/fonts", + "mesh-catalog modules/forticlient", + "mesh-catalog modules/gitea", + "mesh-catalog modules/gitlab", + "mesh-catalog modules/gnome-keyring", + "mesh-catalog modules/grafana", + "mesh-catalog modules/hello-web", + "mesh-catalog modules/home-assistant", + "mesh-catalog modules/hostname", + "mesh-catalog modules/i3", + "mesh-catalog modules/i3status-rust", + "mesh-catalog modules/icecast", + "mesh-catalog modules/influxdb", + "mesh-catalog modules/invoicing", + "mesh-catalog modules/jira", + "mesh-catalog modules/keycloak", + "mesh-catalog modules/lab", + "mesh-catalog modules/lemurs", + "mesh-catalog modules/letta", + "mesh-catalog modules/local-model-consumer", + "mesh-catalog modules/localization", + "mesh-catalog modules/logrotate", + "mesh-catalog modules/mailu", + "mesh-catalog modules/marrytts", + "mesh-catalog modules/matrix", + "mesh-catalog modules/memory-pressure", + "mesh-catalog modules/mesh-catalog", + "mesh-catalog modules/mesh-cli", + "mesh-catalog modules/mesh-delivery", + "mesh-catalog modules/mesh-delivery-gate", + "mesh-catalog modules/mesh-issues", + "mesh-catalog modules/mesh-vault", + "mesh-catalog modules/mesh-watcher", + "mesh-catalog modules/messenger", + "mesh-catalog modules/minio", + "mesh-catalog modules/model-usage", + "mesh-catalog modules/mongodb", + "mesh-catalog modules/mosquitto", + "mesh-catalog modules/mounts", + "mesh-catalog modules/mssql", + "mesh-catalog modules/n8n", + "mesh-catalog modules/nats", + "mesh-catalog modules/netcheck", + "mesh-catalog modules/networkmanager", + "mesh-catalog modules/nextcloud", + "mesh-catalog modules/nextcloud-client", + "mesh-catalog modules/nfs-server", + "mesh-catalog modules/nftables", + "mesh-catalog modules/nm-applet", + "mesh-catalog modules/node-env", + "mesh-catalog modules/nodered", + "mesh-catalog modules/ollama", + "mesh-catalog modules/only-office", + "mesh-catalog modules/openai-consumer", + "mesh-catalog modules/openrazer", + "mesh-catalog modules/pacman", + "mesh-catalog modules/photos-eef", + "mesh-catalog modules/photos-filip", + "mesh-catalog modules/picom", + "mesh-catalog modules/polychromatic", + "mesh-catalog modules/postgres", + "mesh-catalog modules/power", + "mesh-catalog modules/powerlevel10k", + "mesh-catalog modules/records", + "mesh-catalog modules/redis", + "mesh-catalog modules/restic", + "mesh-catalog modules/rofi", + "mesh-catalog modules/route-adapter", + "mesh-catalog modules/screen-lock", + "mesh-catalog modules/searxng", + "mesh-catalog modules/sensors", + "mesh-catalog modules/showcase", + "mesh-catalog modules/slack", + "mesh-catalog modules/snapd", + "mesh-catalog modules/ssh-client", + "mesh-catalog modules/sshd", + "mesh-catalog modules/step-ca", + "mesh-catalog modules/sudo", + "mesh-catalog modules/supabase", + "mesh-catalog modules/systemd", + "mesh-catalog modules/systemd-networkd", + "mesh-catalog modules/systemd-resolved", + "mesh-catalog modules/telegram", + "mesh-catalog modules/time-sync", + "mesh-catalog modules/triggerhappy", + "mesh-catalog modules/umami", + "mesh-catalog modules/unifi", + "mesh-catalog modules/website", + "mesh-catalog modules/xclip", + "mesh-catalog modules/xdg", + "mesh-catalog modules/xorg", + "mesh-catalog modules/xterm", + "mesh-catalog modules/zfs", + "mesh-catalog modules/zsh", + "mesh-catalog modules/zsh-autosuggestions", + "mesh-catalog modules/zsh-syntax-highlighting", + "mesh-controller", + "mesh-controller modules/build-agent", + "mesh-controller modules/route-proxy", + "mesh-delivery", + "mesh-host", + "mesh-lab", + "mesh-media-catalog", + "mesh-sdk", + "mesh-tools" + ] + }, + "duplicate-check": { + "type": "string", + "description": "Whether a new issue is checked against the open ones for a duplicate: off stops it, anything else is on (novox/hq ADR 0278 §7).", + "trusted": true, + "default": "on" + }, + "judge-model": { + "type": "string", + "description": "The model the duplicate check asks, by the agent's name for it, such as sonnet; empty is the tracker's own default.", + "trusted": true, + "default": "sonnet" + }, + "forge-owner": { + "type": "string", + "description": "The owner on the forge of the repositories an issue's fixed-by names, for asking after a pull request's delivery (novox/hq ADR 0279 §3); empty asks no forge.", + "trusted": true, + "default": "novox" + }, + "stuck-after": { + "type": "string", + "description": "How long a fix may be merged and not delivered before it is said stuck, as a duration such as 2h (novox/hq ADR 0281); empty is the tracker's own default.", + "pattern": "|[0-9]+(\\.[0-9]+)?(ns|us|µs|ms|s|m|h)([0-9]+(\\.[0-9]+)?(ns|us|µs|ms|s|m|h))*", + "trusted": true, + "default": "2h" + }, + "worker-stale-after": { + "type": "string", + "description": "How long an issue's worker stays current with nothing done on it, as a duration such as 24h (novox/hq ADR 0284 §4); empty is the tracker's own default.", + "pattern": "|[0-9]+(\\.[0-9]+)?(ns|us|µs|ms|s|m|h)([0-9]+(\\.[0-9]+)?(ns|us|µs|ms|s|m|h))*", + "trusted": true, + "default": "24h" + }, + "delivered-not-resolved-after": { + "type": "string", + "description": "How long an issue may stay in fixing once every fix it names is delivered before the operator is warned, as a duration such as 1h (novox/hq issue 483); empty is the tracker's own default.", + "pattern": "|[0-9]+(\\.[0-9]+)?(ns|us|µs|ms|s|m|h)([0-9]+(\\.[0-9]+)?(ns|us|µs|ms|s|m|h))*", + "trusted": true, + "default": "1h" + }, + "decision-floor": { + "type": "number", + "description": "The highest decision number already taken somewhere the tracker cannot read, such as an open branch of hq; the decision sequence's first number is one above it, read once when the counter starts (novox/hq issue 424).", + "minimum": 0, + "trusted": true, + "default": 293 + }, + "research-floor": { + "type": "number", + "description": "The highest research number already taken somewhere the tracker cannot read; the research sequence's first number is one above it, read once when the counter starts (novox/hq issue 424).", + "minimum": 0, + "trusted": true, + "default": 45 + } + }, + "resources": [ + { + "id": "state", + "type": "directory", + "mode": "0700", + "place": ".", + "unknown": "program" + }, + { + "id": "settings", + "trusted": true, + "type": "file", + "path": "${dir:state}/settings.json", + "mode": "0600", + "takes-config": ["first-number", "owners", "duplicate-check", "judge-model", "forge-owner", "stuck-after", "worker-stale-after", "delivered-not-resolved-after", "decision-floor", "research-floor"], + "merge": "json", + "template": "files/settings.json" + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/mesh-issues", + "binary": "mesh-issues", + "loads": [ + "mesh-issues" + ], + "env": { + "MESH_ISSUES_SETTINGS": "${dir:state}/settings.json" + } + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/go/testdata/beside/mesh-catalog/modules/mesh-knowledge/files/database.url b/go/testdata/beside/mesh-catalog/modules/mesh-knowledge/files/database.url new file mode 100644 index 0000000..dc59da9 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/mesh-knowledge/files/database.url @@ -0,0 +1 @@ +postgresql://${bound:postgres-database:as}:${secret:postgres-database}@${bound:postgres-database:at}:${bound:postgres-database:port}/${bound:postgres-database:as} diff --git a/go/testdata/beside/mesh-catalog/modules/mesh-knowledge/files/settings.json b/go/testdata/beside/mesh-catalog/modules/mesh-knowledge/files/settings.json new file mode 100644 index 0000000..6f682fe --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/mesh-knowledge/files/settings.json @@ -0,0 +1,5 @@ +{ + "judge-model": "sonnet", + "drafting": "on", + "reserved-for": "168h" +} diff --git a/go/testdata/beside/mesh-catalog/modules/mesh-knowledge/module.json.captured b/go/testdata/beside/mesh-catalog/modules/mesh-knowledge/module.json.captured new file mode 100644 index 0000000..92b15f2 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/mesh-knowledge/module.json.captured @@ -0,0 +1,218 @@ +{ + "module": "mesh-knowledge", + "version": "1", + "slug": "knowledge", + "requires": [ + "postgres-database" + ], + "contributes": { + "postgres-database": { + "name": "knowledge" + } + }, + "binds": { + "postgres-database": "${dir:state}/database.json" + }, + "secrets": { + "postgres-database": "${dir:state}/database.secret" + }, + "seats": [ + { + "name": "knowledge-base", + "scope": "mesh", + "serves": [ + "ask", + "get", + "list", + "findings", + "holds", + "links", + "history", + "reserve", + "propose", + "import", + "return", + "withdraw" + ] + } + ], + "claims": [ + { + "name": "knowledge-base", + "scope": "mesh", + "serves": [ + "ask", + "get", + "list", + "findings", + "holds", + "links", + "history", + "reserve", + "propose", + "import", + "return", + "withdraw" + ] + } + ], + "tools": [ + "knowledge_health", + "knowledge_highest" + ], + "emits": [ + "reserved", + "proposed", + "reviewed", + "accepted", + "changed", + "imported", + "drafted", + "confirmed", + "returned" + ], + "uses": [ + "operator-channel" + ], + "invokes": [ + "seat:issue-tracker.reserve", + "claude-code.claude_code_judge", + "claude-code.claude_code_judged", + "records.records_status", + "records.records_list", + "records.records_read", + "seat:operator-channel.notify" + ], + "config": { + "judge-model": { + "type": "string", + "description": "The model the knowledge base's drafting and confirming judgements ask, by the agent's name for it, such as sonnet; empty is sonnet.", + "default": "sonnet" + }, + "drafting": { + "type": "string", + "description": "Whether the knowledge base drafts and confirms clauses itself: off stops it, anything else is on.", + "default": "on" + }, + "reserved-for": { + "type": "string", + "description": "How long a reserved number waits for its proposal before it is withdrawn, as a duration such as 168h; empty is the knowledge base's own default.", + "pattern": "|[0-9]+(\\.[0-9]+)?(ns|us|µs|ms|s|m|h)([0-9]+(\\.[0-9]+)?(ns|us|µs|ms|s|m|h))*", + "default": "168h" + } + }, + "resources": [ + { + "id": "state", + "type": "directory", + "mode": "0700", + "place": ".", + "unknown": "program" + }, + { + "id": "database-url", + "type": "file", + "path": "${dir:state}/database.url", + "mode": "0600", + "template": "files/database.url" + }, + { + "id": "settings", + "trusted": false, + "type": "file", + "path": "${dir:state}/settings.json", + "mode": "0600", + "takes-config": ["judge-model", "drafting", "reserved-for"], + "merge": "json", + "template": "files/settings.json" + }, + { + "id": "prepare", + "type": "process", + "name": "mesh-knowledge-prepare", + "artifact": "tools", + "run": [ + "./mesh-knowledge", + "prepare" + ], + "run-once": true, + "env": { + "DATABASE_URL_FILE": "${dir:state}/database.url" + }, + "restart-on": [ + "database-url" + ] + }, + { + "id": "keep", + "type": "process", + "name": "mesh-knowledge-keep", + "artifact": "tools", + "run": [ + "./mesh-knowledge", + "keep" + ], + "env": { + "DATABASE_URL_FILE": "${dir:state}/database.url" + }, + "restart-on": [ + "database-url" + ], + "health": { + "kind": "unit" + } + }, + { + "id": "watch", + "type": "process", + "name": "mesh-knowledge-watch", + "artifact": "tools", + "run": [ + "./mesh-knowledge", + "watch" + ], + "user": "${machine:account}", + "env": { + "DATABASE_URL_FILE": "${dir:state}/database.url", + "MESH_KNOWLEDGE_STATUS": "${dir:state}/health.json" + }, + "restart-on": [ + "database-url" + ], + "health": { + "kind": "tool", + "tool": "knowledge_health", + "needs": "postgres-database", + "interval": "60s", + "timeout": "10s", + "looks": 2, + "grace": "180s" + } + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/mesh-knowledge", + "binary": "mesh-knowledge", + "loads": [ + "mesh-knowledge" + ], + "env": { + "DATABASE_URL_FILE": "${dir:state}/database.url", + "MESH_KNOWLEDGE_SETTINGS": "${dir:state}/settings.json", + "MESH_KNOWLEDGE_STATUS": "${dir:state}/health.json" + } + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/go/testdata/beside/mesh-catalog/modules/mesh-mail-notifier/module.json.captured b/go/testdata/beside/mesh-catalog/modules/mesh-mail-notifier/module.json.captured new file mode 100644 index 0000000..9afba46 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/mesh-mail-notifier/module.json.captured @@ -0,0 +1,68 @@ +{ + "module": "mesh-mail-notifier", + "version": "1", + "runs-as": "mesh-mail-notifier", + "claims": [ + { + "name": "mail-notifier", + "scope": "node", + "serves": ["status"] + } + ], + "consumes": [ + "mesh-mail.mail.*.message.added" + ], + "invokes": [ + "seat:node-notifier.send", + "seat:mail-store.accounts" + ], + "emits": [ + "shown" + ], + "state": [ + { + "name": "shown", + "ttl-seconds": 2592000 + } + ], + "tools": [ + "mail-notifier.status", + "mail_notifier_health" + ], + "own-secrets": { + "broker": "${dir:state}/broker" + }, + "secrets-owner": "mesh-mail-notifier", + "resources": [ + { + "id": "account", + "type": "user", + "name": "mesh-mail-notifier", + "shell": "/usr/bin/nologin", + "home": "/var/lib/mesh-mail-notifier" + }, + { + "id": "state", + "type": "directory", + "mode": "0700", + "owner": "mesh-mail-notifier", + "place": ".", + "unknown": "program" + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/mesh-mail-notifier", + "binary": "mesh-mail-notifier", + "loads": [ + "mesh-mail-notifier" + ] + } + ] + } +} diff --git a/go/testdata/beside/mesh-catalog/modules/mesh-mail-web/files/oidc.json b/go/testdata/beside/mesh-catalog/modules/mesh-mail-web/files/oidc.json new file mode 100644 index 0000000..f864608 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/mesh-mail-web/files/oidc.json @@ -0,0 +1,5 @@ +{ + "client-id": "${bound:oidc-client:as}", + "client-secret": "${secret:oidc-client}", + "issuer": "${bound:oidc-client:issuer}" +} diff --git a/go/testdata/beside/mesh-catalog/modules/mesh-mail-web/files/operators.json b/go/testdata/beside/mesh-catalog/modules/mesh-mail-web/files/operators.json new file mode 100644 index 0000000..dea781c --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/mesh-mail-web/files/operators.json @@ -0,0 +1,3 @@ +{ + "operators": [] +} diff --git a/go/testdata/beside/mesh-catalog/modules/mesh-mail-web/module.json.captured b/go/testdata/beside/mesh-catalog/modules/mesh-mail-web/module.json.captured new file mode 100644 index 0000000..fd92839 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/mesh-mail-web/module.json.captured @@ -0,0 +1,125 @@ +{ + "module": "mesh-mail-web", + "version": "1", + "slug": "mail-web", + "runs-as": "mesh-mail-web", + "claims": [ + { + "name": "mail-webclient", + "scope": "mesh", + "serves": ["open", "status"] + } + ], + "invokes": [ + "seat:mail-store.accounts", + "seat:mail-store.search", + "seat:mail-store.read", + "seat:mail-store.send", + "seat:mail-store.send-status" + ], + "reads": ["mesh-mail.accounts"], + "requires": ["route", "oidc-client"], + "contributes": { + "route": { + "label": "mail", + "endpoint": "web" + }, + "oidc-client": { + "label": "mail", + "endpoint": "web", + "callback": "/signed-in" + } + }, + "binds": { + "route": "${dir:state}/route.json", + "oidc-client": "${dir:state}/oidc-bound.json" + }, + "secrets": { + "oidc-client": "${dir:state}/oidc-bound.secret" + }, + "listens": [ + { + "name": "web", + "port": 8099, + "protocol": "tcp", + "from": "mesh", + "why": "the mail web client's pages, over http; only the route proxy reaches it, and the operator's own browser through the internal name (novox/hq ADR 0440 §2) — its public name, if any, is a route grant" + } + ], + "config": { + "operators": { + "type": "list", + "description": "The Keycloak identities (the ID token's preferred_username) let to sign in; nobody by default (novox/hq ADR 0440 §3).", + "trusted": true, + "default": [] + } + }, + "tools": [ + "mail-webclient.open", + "mail-webclient.status", + "mail_web_health" + ], + "own-secrets": { + "broker": "${dir:state}/broker" + }, + "secrets-owner": "mesh-mail-web", + "resources": [ + { + "id": "account", + "type": "user", + "name": "mesh-mail-web", + "shell": "/usr/bin/nologin", + "home": "/var/lib/mesh-mail-web" + }, + { + "id": "state", + "type": "directory", + "mode": "0700", + "owner": "mesh-mail-web", + "place": ".", + "unknown": "program" + }, + { + "id": "operators", + "trusted": true, + "type": "file", + "path": "${dir:state}/operators.json", + "mode": "0600", + "owner": "mesh-mail-web", + "merge": "json", + "template": "files/operators.json", + "takes-config": ["operators"] + }, + { + "id": "oidc-config", + "type": "file", + "path": "${dir:state}/oidc.json", + "mode": "0600", + "owner": "mesh-mail-web", + "template": "files/oidc.json" + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/mesh-mail-web", + "binary": "mesh-mail-web", + "loads": ["mesh-mail-web"], + "env": { + "MESH_MAIL_WEB_OPERATORS": "${dir:state}/operators.json", + "MESH_MAIL_WEB_OIDC": "${dir:state}/oidc.json", + "MESH_MAIL_WEB_LISTEN": "${port:8099}" + } + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/go/testdata/beside/mesh-catalog/modules/mesh-mail/files/accounts.json b/go/testdata/beside/mesh-catalog/modules/mesh-mail/files/accounts.json new file mode 100644 index 0000000..e182cc3 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/mesh-mail/files/accounts.json @@ -0,0 +1,3 @@ +{ + "accounts": {} +} diff --git a/go/testdata/beside/mesh-catalog/modules/mesh-mail/files/database-url b/go/testdata/beside/mesh-catalog/modules/mesh-mail/files/database-url new file mode 100644 index 0000000..dc59da9 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/mesh-mail/files/database-url @@ -0,0 +1 @@ +postgresql://${bound:postgres-database:as}:${secret:postgres-database}@${bound:postgres-database:at}:${bound:postgres-database:port}/${bound:postgres-database:as} diff --git a/go/testdata/beside/mesh-catalog/modules/mesh-mail/files/grants.json b/go/testdata/beside/mesh-catalog/modules/mesh-mail/files/grants.json new file mode 100644 index 0000000..458eabb --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/mesh-mail/files/grants.json @@ -0,0 +1,3 @@ +{ + "grants": {} +} diff --git a/go/testdata/beside/mesh-catalog/modules/mesh-mail/files/store.json b/go/testdata/beside/mesh-catalog/modules/mesh-mail/files/store.json new file mode 100644 index 0000000..8c2075d --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/mesh-mail/files/store.json @@ -0,0 +1,9 @@ +{ + "scheme": "${bound:s3-bucket:scheme}", + "at": "${bound:s3-bucket:at}", + "port": "${bound:s3-bucket:port}", + "bucket": "${bound:s3-bucket:bucket}", + "region": "${bound:s3-bucket:region}", + "key": "${bound:s3-bucket:as}", + "secret": "${secret:s3-bucket}" +} diff --git a/go/testdata/beside/mesh-catalog/modules/mesh-mail/module.json.captured b/go/testdata/beside/mesh-catalog/modules/mesh-mail/module.json.captured new file mode 100644 index 0000000..1eba0cf --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/mesh-mail/module.json.captured @@ -0,0 +1,312 @@ +{ + "module": "mesh-mail", + "version": "1", + "slug": "mail", + "seats": [ + { + "name": "mail-store", + "scope": "mesh", + "serves": ["accounts", "search", "read", "send", "send-status"] + }, + { + "name": "mail-webclient", + "scope": "mesh", + "serves": ["open", "status"] + }, + { + "name": "mail-notifier", + "scope": "node", + "needs": ["node-notifier"], + "serves": ["status"] + } + ], + "claims": [ + { + "name": "mail-store", + "scope": "mesh", + "serves": ["accounts", "search", "read", "send", "send-status"] + } + ], + "requires": [ + "postgres-database", + "s3-bucket" + ], + "contributes": { + "postgres-database": { + "name": "mail" + } + }, + "binds": { + "postgres-database": "${dir:state}/database.json", + "s3-bucket": "${dir:state}/store-binding.json" + }, + "secrets": { + "postgres-database": "${dir:state}/database.secret", + "s3-bucket": "${dir:state}/store.secret" + }, + "own-secrets": { + "mail-credential-*": { + "path": "${dir:state}/mail-credential-*.secret", + "issued-by": "outside", + "prompt": "the password of the mail account {member}", + "owner": "mesh-mail-send" + }, + "broker-send": { + "path": "${dir:state}/broker-send.secret", + "owner": "mesh-mail-send" + } + }, + "config": { + "accounts": { + "type": "object", + "description": "The mail accounts, each by a lower-case name that names it on the bus and its secret: its kind (imap), address, incoming and outgoing servers (host, port, security tls or starttls), and optionally the folders watched and the user it signs in as; no password, which is the own secret mail-credential- (novox/hq ADR 0359 §5).", + "trusted": true, + "default": {} + }, + "grants": { + "type": "object", + "description": "Per account, who may read its mail and who may send from it: a module's name (on every machine), ., or person.. Nobody by default; it restricts the stream MAIL and the tools mail_search, mail_read and mail_send alike (novox/hq ADR 0359 §4).", + "trusted": true, + "default": {} + } + }, + "tools": [ + "mail_accounts", + "mail_search", + "mail_read", + "mail_health", + "mail_send", + "mail_send_status", + "mail-store.accounts", + "mail-store.search", + "mail-store.read", + "mail-store.send", + "mail-store.send-status" + ], + "uses": [ + "operator-channel" + ], + "state": [ + "sends", + "accounts", + "mails" + ], + "emits": [ + "mail.send.sent", + "mail.send.failed" + ], + "streams": [ + { + "name": "mail", + "events": [ + "mail.*.message.added", + "mail.*.message.changed", + "mail.*.message.removed" + ], + "max-age-days": 7, + "grant": { + "config": "grants", + "events": "mail.{key}.**" + } + }, + { + "name": "mail-sends", + "events": [ + "mail.send.approved" + ], + "max-age-days": 1, + "max-mib": 64, + "retention": "work-queue", + "max-deliveries": 5, + "duplicates": 24 + } + ], + "resources": [ + { + "id": "account", + "type": "user", + "name": "mesh-mail-send" + }, + { + "id": "state", + "type": "directory", + "mode": "0711", + "place": ".", + "unknown": "program" + }, + { + "id": "database-url", + "type": "file", + "path": "${dir:state}/database.url", + "mode": "0600", + "template": "files/database-url" + }, + { + "id": "store", + "type": "file", + "path": "${dir:state}/store.json", + "mode": "0600", + "template": "files/store.json" + }, + { + "id": "accounts", + "trusted": true, + "type": "file", + "path": "${dir:state}/accounts.json", + "mode": "0644", + "merge": "json", + "template": "files/accounts.json", + "takes-config": [ + "accounts" + ] + }, + { + "id": "grants", + "trusted": true, + "type": "file", + "path": "${dir:state}/grants.json", + "mode": "0600", + "merge": "json", + "template": "files/grants.json", + "takes-config": [ + "grants" + ] + }, + { + "id": "prepare", + "type": "process", + "name": "mesh-mail-prepare", + "artifact": "tools", + "run": [ + "./mesh-mail", + "prepare" + ], + "run-once": true, + "env": { + "DATABASE_URL_FILE": "${dir:state}/database.url" + }, + "restart-on": [ + "database-url" + ] + }, + { + "id": "keep", + "type": "process", + "name": "mesh-mail-keep", + "artifact": "tools", + "run": [ + "./mesh-mail", + "keep" + ], + "env": { + "DATABASE_URL_FILE": "${dir:state}/database.url", + "MESH_MAIL_ACCOUNTS": "${dir:state}/accounts.json", + "MESH_MAIL_SECRETS": "${dir:state}", + "MESH_MAIL_STORE": "${dir:state}/store.json" + }, + "restart-on": [ + "database-url", + "store", + "accounts" + ], + "health": { + "kind": "unit" + } + }, + { + "id": "watch", + "type": "process", + "name": "mesh-mail-watch", + "artifact": "tools", + "run": [ + "./mesh-mail", + "watch" + ], + "user": "${machine:account}", + "env": { + "DATABASE_URL_FILE": "${dir:state}/database.url", + "MESH_MAIL_ACCOUNTS": "${dir:state}/accounts.json", + "MESH_MAIL_SECRETS": "${dir:state}", + "MESH_MAIL_STATUS": "${dir:state}/health.json" + }, + "restart-on": [ + "database-url", + "accounts" + ], + "health": { + "kind": "tool", + "tool": "mail_health", + "needs": "postgres-database", + "interval": "60s", + "timeout": "10s", + "looks": 2, + "grace": "180s" + } + }, + { + "id": "send", + "type": "process", + "name": "mesh-mail-send", + "artifact": "tools", + "run": [ + "./mesh-mail", + "send" + ], + "user": "mesh-mail-send", + "env": { + "MESH_MAIL_ACCOUNTS": "${dir:state}/accounts.json", + "MESH_MAIL_SECRETS": "${dir:state}" + }, + "restart-on": [ + "accounts" + ], + "bus": { + "emits": [ + "mail.send.sent", + "mail.send.failed" + ], + "queues": [ + "mail-sends" + ], + "state": [ + "sends" + ], + "asks": true, + "invokes": [ + "seat:operator-channel.tell" + ] + }, + "health": { + "kind": "unit" + } + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/mesh-mail", + "binary": "mesh-mail", + "loads": [ + "mesh-mail" + ], + "env": { + "DATABASE_URL_FILE": "${dir:state}/database.url", + "MESH_MAIL_ACCOUNTS": "${dir:state}/accounts.json", + "MESH_MAIL_GRANTS": "${dir:state}/grants.json", + "MESH_MAIL_SECRETS": "${dir:state}", + "MESH_MAIL_STORE": "${dir:state}/store.json", + "MESH_MAIL_STATUS": "${dir:state}/health.json" + } + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/go/testdata/beside/mesh-catalog/modules/mesh-provisioner-client/module.json.captured b/go/testdata/beside/mesh-catalog/modules/mesh-provisioner-client/module.json.captured new file mode 100644 index 0000000..095128a --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/mesh-provisioner-client/module.json.captured @@ -0,0 +1,60 @@ +{ + "module": "mesh-provisioner-client", + "version": "1", + "claims": [ + { + "name": "node-provisioner", + "scope": "node" + } + ], + "own-secrets": { + "provisioner-broker": "${dir:state}/broker" + }, + "secrets-owner": "mesh-provisioner-client", + "resources": [ + { + "id": "account", + "type": "user", + "name": "mesh-provisioner-client", + "shell": "/usr/bin/nologin", + "home": "/var/lib/mesh-provisioner-client" + }, + { + "id": "state", + "type": "directory", + "mode": "0700", + "owner": "mesh-provisioner-client", + "place": ".", + "unknown": "program" + }, + { + "id": "client", + "type": "process", + "name": "mesh-provisioner-client", + "artifact": "client", + "run": [ + "./mesh-provisioner-client" + ], + "env": { + "MESH_PROVISIONER_CREDENTIAL": "${dir:state}/broker", + "MESH_PROVISIONER_STATE": "${dir:state}" + }, + "user": "mesh-provisioner-client", + "health": { + "kind": "unit" + } + } + ], + "build": { + "artifacts": [ + { + "name": "client", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/mesh-provisioner-client", + "binary": "mesh-provisioner-client" + } + ] + } +} diff --git a/go/testdata/beside/mesh-catalog/modules/mesh-provisioner/module.json.captured b/go/testdata/beside/mesh-catalog/modules/mesh-provisioner/module.json.captured new file mode 100644 index 0000000..650425b --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/mesh-provisioner/module.json.captured @@ -0,0 +1,56 @@ +{ + "module": "mesh-provisioner", + "version": "1", + "runs-as": "mesh-provisioner", + "claims": [ + { + "name": "mesh-provisioner", + "scope": "mesh", + "serves": [ + "provisioning" + ] + } + ], + "state": [ + "registry", + "needs", + "applied", + "collect" + ], + "own-secrets": { + "broker": "${dir:state}/broker" + }, + "secrets-owner": "mesh-provisioner", + "resources": [ + { + "id": "account", + "type": "user", + "name": "mesh-provisioner", + "shell": "/usr/bin/nologin", + "home": "/var/lib/mesh-provisioner" + }, + { + "id": "state", + "type": "directory", + "mode": "0700", + "owner": "mesh-provisioner", + "place": ".", + "unknown": "program" + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/mesh-provisioner", + "binary": "mesh-provisioner", + "loads": [ + "mesh-provisioner" + ] + } + ] + } +} diff --git a/go/testdata/beside/mesh-catalog/modules/mesh-reads/module.json.captured b/go/testdata/beside/mesh-catalog/modules/mesh-reads/module.json.captured new file mode 100644 index 0000000..1806275 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/mesh-reads/module.json.captured @@ -0,0 +1,55 @@ +{ + "module": "mesh-reads", + "version": "1", + "runs-as": "mesh-reads", + "claims": [ + { + "name": "node-reads", + "scope": "node", + "serves": [ + "stat", + "readlink", + "list", + "read", + "search", + "account" + ] + } + ], + "own-secrets": { + "broker": "${dir:state}/broker" + }, + "secrets-owner": "mesh-reads", + "resources": [ + { + "id": "account", + "type": "user", + "name": "mesh-reads", + "shell": "/usr/bin/nologin", + "home": "/var/lib/mesh-reads" + }, + { + "id": "state", + "type": "directory", + "mode": "0700", + "owner": "mesh-reads", + "place": ".", + "unknown": "program" + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/mesh-reads", + "binary": "mesh-reads", + "loads": [ + "mesh-reads" + ] + } + ] + } +} diff --git a/go/testdata/beside/mesh-catalog/modules/mesh-vault/module.json.captured b/go/testdata/beside/mesh-catalog/modules/mesh-vault/module.json.captured new file mode 100644 index 0000000..44944c9 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/mesh-vault/module.json.captured @@ -0,0 +1,173 @@ +{ + "module": "mesh-vault", + "version": "1", + "runs-as": "mesh-vault", + "provides": [ + { + "name": "secret", + "scope": "mesh", + "identity": { + "in": "a vault entry" + } + } + ], + "emits": [ + "provisioned", + "rotated", + "secret-changed" + ], + "reads": [ + "mesh-controller.assignments" + ], + "receives": { + "secret": "${dir:grants}/mesh.json" + }, + "grants": { + "secret": "${dir:grants}" + }, + "keeps": "/var/lib/mesh-vault/root", + "data": { + "own": [ + { + "id": "state", + "path": "${dir:state}", + "class": "valuable", + "why": "the vault's own keys" + }, + { + "id": "ledger", + "path": "${dir:ledger}", + "class": "valuable", + "why": "every secret the vault keeps" + }, + { + "id": "root", + "path": "${dir:root}", + "class": "valuable", + "why": "the vault's root material" + }, + { + "id": "store", + "path": "${dir:store}", + "class": "valuable", + "why": "every secret the vault holds, sealed to its own key and to the operator key" + } + ], + "consumers": { + "secret": { + "class": "valuable", + "in": "ledger", + "why": "a secret given to a consumer is kept nowhere else in the clear" + } + } + }, + "own-secrets": { + "broker": "${dir:state}/broker" + }, + "secrets-owner": "mesh-vault", + "runtime-health": { + "kind": "tool", + "tool": "vault_health", + "interval": "60s", + "timeout": "10s", + "looks": 2, + "grace": "60s" + }, + "resources": [ + { + "id": "account", + "type": "user", + "name": "mesh-vault", + "shell": "/usr/bin/nologin", + "home": "/var/lib/mesh-vault" + }, + { + "id": "state", + "type": "directory", + "mode": "0700", + "owner": "mesh-vault", + "place": ".", + "unknown": "program" + }, + { + "id": "grants", + "type": "directory", + "mode": "0700", + "owner": "mesh-vault", + "unknown": "program" + }, + { + "id": "ledger", + "type": "directory", + "mode": "0700", + "owner": "mesh-vault", + "unknown": "program" + }, + { + "id": "root", + "type": "directory", + "mode": "0700", + "owner": "mesh-vault", + "unknown": "program" + }, + { + "id": "store", + "type": "directory", + "mode": "0700", + "owner": "mesh-vault", + "unknown": "program" + } + ], + "build": { + "artifacts": [ + { + "name": "code", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/mesh-vault", + "binary": "mesh-vault", + "loads": [ + "mesh-vault" + ], + "env": { + "MESH_RECEIVES": "${dir:grants}/mesh.json", + "MESH_VAULT_LEDGER": "${dir:ledger}", + "MESH_VAULT_ROOT": "${dir:root}", + "MESH_VAULT_STORE": "${dir:store}", + "MESH_VAULT_STATE": "${dir:state}" + } + } + ] + }, + "tools": [ + "secret_export", + "secret_holders", + "secret_verify", + "secret_handed", + "secret_asks", + "secret_vault_export", + "provisioner_retirement", + "provisioner_retire_approve", + "provisioner_retire_reject", + "provisioner_delete", + "vault_status", + "vault_health" + ], + "claims": [ + { + "name": "mesh-vault", + "scope": "mesh", + "serves": [ + "get", + "accept", + "secret-ask", + "issued", + "rotate", + "move-in", + "node-key", + "move-asks" + ] + } + ] +} diff --git a/go/testdata/beside/mesh-catalog/modules/mesh-watcher/files/settings.json b/go/testdata/beside/mesh-catalog/modules/mesh-watcher/files/settings.json new file mode 100644 index 0000000..84b9ecc --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/mesh-watcher/files/settings.json @@ -0,0 +1,3 @@ +{ + "telegram-chat-id": "" +} diff --git a/go/testdata/beside/mesh-catalog/modules/mesh-watcher/module.json.captured b/go/testdata/beside/mesh-catalog/modules/mesh-watcher/module.json.captured new file mode 100644 index 0000000..e9543a5 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/mesh-watcher/module.json.captured @@ -0,0 +1,71 @@ +{ + "module": "mesh-watcher", + "version": "1", + "slug": "watch", + "consumes": [ + "mesh-controller.doctor-heartbeat" + ], + "invokes": [ + "mesh-watcher.watcher_ping", + "seat:mesh-controller.seats" + ], + "own-secrets": { + "telegram-token": "${dir:state}/telegram-token" + }, + "tools": [ + "watcher_status", + "watcher_last_heard", + "watcher_test", + "watcher_ping" + ], + "config": { + "telegram-chat-id": { + "type": "string", + "description": "The Telegram chat the watcher reports to: the chat's id, a number, or @name of a channel; empty reports to none.", + "trusted": true, + "default": "" + } + }, + "resources": [ + { + "id": "state", + "type": "directory", + "mode": "0700", + "place": ".", + "unknown": "program" + }, + { + "id": "settings", + "type": "file", + "path": "${dir:state}/settings.json", + "mode": "0600", + "takes-config": ["telegram-chat-id"], + "merge": "json", + "template": "files/settings.json" + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/mesh-watcher", + "binary": "mesh-watcher", + "loads": [ + "mesh-watcher" + ], + "env": { + "MESH_WATCHER_SETTINGS": "${dir:state}/settings.json", + "MESH_WATCHER_TELEGRAM_TOKEN_FILE": "${dir:state}/telegram-token" + } + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/go/testdata/beside/mesh-catalog/modules/messenger/files/settings.json b/go/testdata/beside/mesh-catalog/modules/messenger/files/settings.json new file mode 100644 index 0000000..ce76334 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/messenger/files/settings.json @@ -0,0 +1,4 @@ +{ + "time-zone": "", + "only-what-needs-me": [] +} diff --git a/go/testdata/beside/mesh-catalog/modules/messenger/module.json.captured b/go/testdata/beside/mesh-catalog/modules/messenger/module.json.captured new file mode 100644 index 0000000..8501bd4 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/messenger/module.json.captured @@ -0,0 +1,188 @@ +{ + "module": "messenger", + "version": "1", + "slug": "msgr", + "runs-as": "messenger", + "seats": [ + { + "name": "operator-channel", + "scope": "mesh", + "serves": [ + "open", + "history", + "notify", + "tell", + "asks" + ], + "accepts": [ + "ask", + "cancel", + "condition" + ], + "emits": [ + "decided" + ], + "by-caller": [ + "ask", + "cancel", + "condition", + "decided" + ], + "records": [ + "asks" + ] + }, + { + "name": "channel", + "scope": "mesh", + "kinded": true, + "accepts": [ + "show", + "edit", + "send" + ] + }, + { + "name": "intake", + "scope": "mesh", + "kinded": true, + "emits": [ + "choice", + "link", + "failed", + "standing" + ], + "proofs": [ + "code" + ] + } + ], + "claims": [ + { + "name": "operator-channel", + "scope": "mesh", + "serves": [ + "open", + "history", + "notify", + "tell", + "asks" + ] + } + ], + "uses": [ + "channel" + ], + "consumes": [ + "mesh-controller.condition-raised", + "mesh-controller.condition-changed", + "mesh-controller.condition-cleared", + "mesh-controller.plan-moved", + "intake.choice", + "intake.link", + "intake.failed", + "intake.standing" + ], + "emits": [ + "refused" + ], + "invokes": [ + "seat:mesh-controller.conditions", + "seat:mesh-controller.root-free", + "seat:issue-tracker.tracking" + ], + "state": [ + "open", + "sent", + { + "name": "asks", + "ttl-seconds": 2592000 + }, + "identities", + "walks" + ], + "own-secrets": { + "broker": "${dir:state}/broker" + }, + "secrets-owner": "messenger", + "tools": [ + "messenger_status", + "messenger_recent", + "messenger_test", + "messenger_preview", + "messenger_check", + "messenger_identities", + "messenger_unlink" + ], + "config": { + "time-zone": { + "type": "string", + "description": "The time zone every time a message says is said in, an IANA name such as Europe/Brussels; empty is this machine's own zone.", + "trusted": true, + "default": "" + }, + "desktop-machines": { + "type": "list", + "description": "The machines whose desktop showed the operator's messages, by node name. Nothing in this module reads it since the desk channel took the desktop over; it is taken so the file stays as a layer that still sets it makes it, until that layer drops it.", + "trusted": true, + "default": [] + }, + "only-what-needs-me": { + "type": "list", + "description": "The kinds of channel set to say only what needs the operator: a walk that delivered with nothing held and nothing open is not told there (ADR 0308 §4); empty is none.", + "trusted": true, + "default": [] + } + }, + "resources": [ + { + "id": "account", + "type": "user", + "name": "messenger", + "shell": "/usr/bin/nologin", + "home": "/var/lib/messenger" + }, + { + "id": "state", + "type": "directory", + "mode": "0700", + "place": ".", + "owner": "messenger", + "unknown": "program" + }, + { + "id": "settings", + "type": "file", + "path": "${dir:state}/settings.json", + "mode": "0600", + "takes-config": ["time-zone", "desktop-machines", "only-what-needs-me"], + "merge": "json", + "template": "files/settings.json", + "owner": "messenger" + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/messenger", + "binary": "messenger", + "loads": [ + "messenger" + ], + "env": { + "MESH_MESSENGER_SETTINGS": "${dir:state}/settings.json", + "MESH_MESSENGER_STATE": "${dir:state}" + } + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/go/testdata/beside/mesh-catalog/modules/minio/files/root.env b/go/testdata/beside/mesh-catalog/modules/minio/files/root.env new file mode 100644 index 0000000..2255260 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/minio/files/root.env @@ -0,0 +1,2 @@ +MINIO_ROOT_USER=meshroot +MINIO_BROWSER_REDIRECT_URL=https://${bound:route:name-console} diff --git a/go/testdata/beside/mesh-catalog/modules/minio/module.json.captured b/go/testdata/beside/mesh-catalog/modules/minio/module.json.captured new file mode 100644 index 0000000..85b8253 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/minio/module.json.captured @@ -0,0 +1,188 @@ +{ + "module": "minio", + "version": "1", + "upgrade": { + "policy": "record", + "why": "holds the photos themselves (irreplaceable, kept by photos) for its consumers: a person takes each build, after a backup (hq ADR 0236)" + }, + "provides": [ + { + "name": "s3-bucket", + "scope": "mesh", + "identity": { + "max": 20, + "in": "an S3 access key" + } + } + ], + "requires": [ + "route" + ], + "contributes": { + "route": { + "api": { + "label": "files-api", + "endpoint": "s3" + }, + "console": { + "label": "files", + "endpoint": "console" + } + } + }, + "capabilities": [ + "container-runtime" + ], + "emits": [ + "bucket.created", + "bucket.removed" + ], + "listens": [ + { + "name": "s3", + "port": 9000, + "protocol": "tcp", + "from": "mesh", + "why": "the S3 endpoint" + }, + { + "name": "console", + "port": 9001, + "protocol": "tcp", + "from": "mesh", + "why": "the admin console" + } + ], + "serves": { + "s3-bucket": { + "scheme": "http", + "region": "eu-west", + "port": 9000, + "bucket": "${consumer:as:dns}" + } + }, + "receives": { + "s3-bucket": "${dir:grants}/mesh.json" + }, + "grants": { + "s3-bucket": "${dir:grants}" + }, + "own-secrets": { + "root": "${dir:state}/root.secret" + }, + "data": { + "own": [ + { + "id": "data", + "path": "${dir:data}", + "class": "valuable", + "why": "every consumer's bucket and its objects" + } + ], + "consumers": { + "s3-bucket": { + "class": "valuable", + "in": "data", + "why": "a consumer's objects are the only copy of what it stored; a consumer that keeps something irreplaceable here says so (kept-by)" + } + } + }, + "resources": [ + { + "id": "state", + "type": "directory", + "mode": "0700", + "place": ".", + "unknown": "program" + }, + { + "id": "grants", + "type": "directory", + "mode": "0700", + "unknown": "program" + }, + { + "id": "root-env", + "type": "file", + "path": "${dir:state}/root.env", + "mode": "0600", + "template": "files/root.env" + }, + { + "id": "data", + "type": "directory", + "path": "/var/lib/minio-store", + "mode": "0700", + "unknown": "program" + }, + { + "id": "net", + "type": "network", + "name": "minio-net" + }, + { + "id": "server", + "type": "container", + "name": "minio", + "image": "docker.io/pgsty/minio@sha256:b6bfe7239bfc83fb90d31612d9704d86039dd714f7904b3f1ad68f211e602372", + "network": "minio-net", + "args": [ + "server", + "/data", + "--console-address", + ":9001" + ], + "env-file": [ + "${dir:state}/root.env" + ], + "ports": [ + "9000", + "9001" + ], + "volumes": [ + "/var/lib/minio-store:/data", + "${dir:state}/root.secret:/run/secrets/root:ro" + ], + "env": { + "MINIO_ROOT_PASSWORD_FILE": "/run/secrets/root", + "MINIO_REGION": "eu-west" + } + }, + { + "id": "client", + "type": "package", + "package": "minio-client" + } + ], + "build": { + "artifacts": [ + { + "name": "code", + "kind": "bundle", + "language": "typescript", + "entrypoints": [ + "tools/index.js", + "provisioner/index.js" + ], + "loads": [ + "tools/index.js", + "provisioner/index.js" + ], + "env": { + "MESH_MINIO_ENDPOINT": "http://127.0.0.1:${port:9000}", + "MESH_MINIO_ROOT_USER": "meshroot", + "MESH_MINIO_ROOT_PASSWORD_FILE": "${dir:state}/root.secret", + "MESH_MINIO_REGION": "eu-west", + "MESH_MINIO_MC_BIN": "mcli", + "MESH_MINIO_MC_CONFIG": "${dir:state}/mc", + "MESH_RECEIVES": "${dir:grants}/mesh.json" + } + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/go/testdata/beside/mesh-catalog/modules/model-usage/files/database.url b/go/testdata/beside/mesh-catalog/modules/model-usage/files/database.url new file mode 100644 index 0000000..dc59da9 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/model-usage/files/database.url @@ -0,0 +1 @@ +postgresql://${bound:postgres-database:as}:${secret:postgres-database}@${bound:postgres-database:at}:${bound:postgres-database:port}/${bound:postgres-database:as} diff --git a/go/testdata/beside/mesh-catalog/modules/model-usage/module.json.captured b/go/testdata/beside/mesh-catalog/modules/model-usage/module.json.captured new file mode 100644 index 0000000..50743a3 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/model-usage/module.json.captured @@ -0,0 +1,107 @@ +{ + "module": "model-usage", + "version": "1", + "slug": "usage", + "capabilities": [ + "container-runtime" + ], + "requires": [ + "postgres-database" + ], + "contributes": { + "postgres-database": { + "name": "model_usage" + } + }, + "binds": { + "postgres-database": "${dir:state}/database.json" + }, + "secrets": { + "postgres-database": "${dir:state}/database.secret" + }, + "consumes": [ + "*.usage.*" + ], + "data": { + "own": [ + { + "id": "state", + "path": "${dir:state}", + "class": "cache", + "why": "the mesh's own files for it; its records are in its database" + }, + { + "id": "spool", + "path": "${dir:spool}", + "class": "valuable", + "why": "usage events the store could not take yet; after the bus gives one up, the only copy" + } + ] + }, + "resources": [ + { + "id": "state", + "type": "directory", + "mode": "0700", + "place": ".", + "unknown": "program" + }, + { + "id": "database-url", + "type": "file", + "path": "${dir:state}/database.url", + "mode": "0600", + "template": "files/database.url" + }, + { + "id": "spool", + "type": "directory", + "mode": "0700", + "unknown": "program" + }, + { + "id": "prepare", + "type": "process", + "name": "model-usage-prepare", + "artifact": "code", + "run": [ + "node", + "prepare/index.js" + ], + "run-once": true, + "env": { + "DATABASE_URL_FILE": "${dir:state}/database.url" + }, + "restart-on": [ + "database-url" + ] + } + ], + "build": { + "artifacts": [ + { + "name": "code", + "kind": "bundle", + "language": "typescript", + "entrypoints": [ + "index.js", + "tools/index.js", + "prepare/index.js" + ], + "loads": [ + "index.js", + "tools/index.js" + ], + "env": { + "DATABASE_URL_FILE": "${dir:state}/database.url", + "USAGE_SPOOL": "${dir:spool}" + } + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/go/testdata/beside/mesh-catalog/modules/mongodb/files/server-root.secret b/go/testdata/beside/mesh-catalog/modules/mongodb/files/server-root.secret new file mode 100644 index 0000000..0ccabee --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/mongodb/files/server-root.secret @@ -0,0 +1 @@ +${secret:root} \ No newline at end of file diff --git a/go/testdata/beside/mesh-catalog/modules/mongodb/module.json.captured b/go/testdata/beside/mesh-catalog/modules/mongodb/module.json.captured new file mode 100644 index 0000000..cbb1268 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/mongodb/module.json.captured @@ -0,0 +1,170 @@ +{ + "module": "mongodb", + "version": "1", + "upgrade": { + "policy": "record", + "why": "a provider whose restart drops every consumer on its machine, and which holds the photos' albums (irreplaceable, kept by photos): a person takes each build, after a backup (hq ADR 0236)" + }, + "provides": [ + { + "name": "mongodb-database", + "scope": "mesh", + "identity": { + "max": 63, + "in": "a MongoDB database name" + } + } + ], + "capabilities": [ + "container-runtime" + ], + "emits": [ + "database.provisioned", + "database.deprovisioned" + ], + "consumes": [ + "mongodb.database.provisioned", + "mongodb.database.deprovisioned" + ], + "listens": [ + { + "name": "database", + "port": 27017, + "protocol": "tcp", + "from": "mesh", + "why": "modules on any machine that were granted a database" + } + ], + "serves": { + "mongodb-database": { + "port": 27017 + } + }, + "receives": { + "mongodb-database": "${dir:grants}/mesh.json" + }, + "grants": { + "mongodb-database": "${dir:grants}" + }, + "own-secrets": { + "root": "${dir:state}/root.secret" + }, + "data": { + "own": [ + { + "id": "data", + "path": "${dir:data}", + "class": "valuable", + "backup": { + "dump": "docker exec mongodb-server sh -c 'printf \"password: %s\\n\" \"$(cat /run/secrets/root)\" > /tmp/.backup.yaml && mongodump --quiet --config /tmp/.backup.yaml --username root --authenticationDatabase admin --archive; s=$?; rm -f /tmp/.backup.yaml; exit $s' > ${dir:dumps}/all.archive.partial && mv ${dir:dumps}/all.archive.partial ${dir:dumps}/all.archive", + "into": "dumps" + }, + "why": "every consumer's database; copied by the dump, not as live files" + }, + { + "id": "dumps", + "path": "${dir:dumps}", + "class": "rebuildable", + "why": "last night's dump, made again every night" + } + ], + "consumers": { + "mongodb-database": { + "class": "valuable", + "in": "data", + "why": "a consumer's documents are the only copy of what it wrote; a consumer that keeps something irreplaceable here says so (kept-by)" + } + } + }, + "resources": [ + { + "id": "state", + "type": "directory", + "mode": "0700", + "place": ".", + "unknown": "program" + }, + { + "id": "grants", + "type": "directory", + "mode": "0700", + "unknown": "program" + }, + { + "id": "data", + "type": "directory", + "mode": "0700", + "unknown": "program" + }, + { + "id": "dumps", + "type": "directory", + "mode": "0700", + "unknown": "program" + }, + { + "id": "net", + "type": "network", + "name": "mongodb" + }, + { + "id": "server-root", + "type": "file", + "path": "${dir:state}/server-root.secret", + "mode": "0400", + "owner": "999:999", + "template": "files/server-root.secret" + }, + { + "id": "server", + "type": "container", + "name": "mongodb-server", + "image": "mongo@sha256:e3fa459b4f4b72f3257c67a23c145e250b8b5700f033860392c68539b998bbe3", + "health": { + "kind": "tcp", + "endpoint": "database" + }, + "network": "mongodb", + "env": { + "MONGO_INITDB_ROOT_USERNAME": "root", + "MONGO_INITDB_ROOT_PASSWORD_FILE": "/run/secrets/root" + }, + "ports": [ + "27017" + ], + "volumes": [ + "${dir:data}:/data/db", + "${dir:state}/server-root.secret:/run/secrets/root:ro" + ] + } + ], + "build": { + "artifacts": [ + { + "name": "code", + "kind": "bundle", + "language": "typescript", + "entrypoints": [ + "index.js", + "tools/index.js", + "provisioner/index.js" + ], + "loads": [ + "index.js", + "tools/index.js", + "provisioner/index.js" + ], + "env": { + "MESH_PROVISION_MONGODB": "mongodb://root@127.0.0.1:${port:27017}/admin?authSource=admin", + "MESH_PROVISION_PASSWORD_FILE": "${dir:state}/root.secret", + "MESH_RECEIVES": "${dir:grants}/mesh.json" + } + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/go/testdata/beside/mesh-catalog/modules/mosquitto/files/bootstrap.env b/go/testdata/beside/mesh-catalog/modules/mosquitto/files/bootstrap.env new file mode 100644 index 0000000..65b710e --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/mosquitto/files/bootstrap.env @@ -0,0 +1,7 @@ +MESH_PROVISION_MQTT=127.0.0.1:${port:1883} +MESH_PROVISION_ADMIN_USER=mesh-admin +MESH_PROVISION_PASSWORD_FILE=${dir:mesh-state}/admin +MESH_DYNSEC_FILE=${dir:data}/dynamic-security.json +MESH_MQTT_CTRL_IMAGE=eclipse-mosquitto@sha256:38c0da4f2ef84284d47b3b3eeea1cb3bdeabe81ee10caf0cd5c5ff61ee3ea408 +MESH_MQTT_CTRL_CONTAINER=mosquitto +MESH_MQTT_ADMIN_APPLIED_FILE=${dir:state}/admin.applied diff --git a/go/testdata/beside/mesh-catalog/modules/mosquitto/files/mosquitto.conf b/go/testdata/beside/mesh-catalog/modules/mosquitto/files/mosquitto.conf new file mode 100644 index 0000000..500c2a6 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/mosquitto/files/mosquitto.conf @@ -0,0 +1,20 @@ +persistence true +persistence_location /mosquitto/data + +log_dest stdout +log_type warning +log_type error +log_type notice + +# Every client authenticates; identities and their per-topic ACLs are managed +# at runtime by the dynamic security plugin, whose store the plugin itself owns. +allow_anonymous false +plugin /usr/lib/mosquitto_dynamic_security.so +plugin_opt_config_file /mosquitto/data/dynamic-security.json + +# MQTT listener +listener 1883 + +# MQTT-over-WebSockets listener +listener 8081 +protocol websockets diff --git a/go/testdata/beside/mesh-catalog/modules/mosquitto/module.json.captured b/go/testdata/beside/mesh-catalog/modules/mosquitto/module.json.captured new file mode 100644 index 0000000..21058e0 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/mosquitto/module.json.captured @@ -0,0 +1,194 @@ +{ + "module": "mosquitto", + "version": "1", + "slug": "mosq", + "provides": [ + { + "name": "mqtt-topic", + "scope": "mesh", + "identity": { + "in": "a Mosquitto client and topic prefix" + } + } + ], + "capabilities": [ + "container-runtime" + ], + "emits": [ + "topic.provisioned", + "topic.deprovisioned" + ], + "consumes": [ + "mosquitto.topic.provisioned", + "mosquitto.topic.deprovisioned" + ], + "serves": { + "mqtt-topic": { + "scheme": "mqtt", + "port": 1883 + } + }, + "receives": { + "mqtt-topic": "${dir:grants}/mesh.json" + }, + "grants": { + "mqtt-topic": "${dir:grants}" + }, + "own-secrets": { + "admin": { + "path": "${dir:mesh-state}/admin", + "taken": "at-start" + } + }, + "listens": [ + { + "name": "mqtt", + "port": 1883, + "protocol": "tcp", + "from": "mesh", + "why": "modules on any machine that were granted a topic namespace" + }, + { + "name": "mqtt-websockets", + "port": 8081, + "protocol": "tcp", + "from": "mesh", + "why": "the same broker over MQTT-on-WebSockets, for browser clients" + } + ], + "data": { + "own": [ + { + "id": "data", + "path": "${dir:data}", + "class": "rebuildable", + "why": "retained messages and sessions; devices publish them again" + } + ], + "consumers": { + "mqtt-topic": { + "class": "rebuildable", + "in": "data", + "why": "retained messages are published again by the devices" + } + } + }, + "resources": [ + { + "id": "mesh-state", + "type": "directory", + "mode": "0700", + "place": "mesh", + "unknown": "program" + }, + { + "id": "state", + "type": "directory", + "mode": "0700", + "place": ".", + "unknown": "program" + }, + { + "id": "grants", + "type": "directory", + "mode": "0700", + "unknown": "program" + }, + { + "id": "data", + "type": "directory", + "mode": "0700", + "owner": "1883:1883", + "unknown": "program" + }, + { + "id": "server-conf", + "type": "file", + "path": "${dir:state}/mosquitto.conf", + "mode": "0600", + "owner": "1883:1883", + "template": "files/mosquitto.conf" + }, + { + "id": "net", + "type": "network", + "name": "mosquitto" + }, + { + "id": "bootstrap-env", + "type": "file", + "path": "${dir:state}/bootstrap.env", + "mode": "0600", + "template": "files/bootstrap.env" + }, + { + "id": "bootstrap", + "type": "process", + "name": "mosquitto-bootstrap", + "artifact": "code", + "run": [ + "node", + "bootstrap/index.js" + ], + "run-once": true, + "env-file": [ + "${dir:state}/bootstrap.env" + ], + "restart-on": [ + "bootstrap-env", + "needs-admin" + ] + }, + { + "id": "server", + "type": "container", + "name": "mosquitto", + "image": "eclipse-mosquitto@sha256:38c0da4f2ef84284d47b3b3eeea1cb3bdeabe81ee10caf0cd5c5ff61ee3ea408", + "health": { + "kind": "tcp", + "endpoint": "mqtt" + }, + "network": "mosquitto", + "ports": [ + "1883", + "8081" + ], + "volumes": [ + "${dir:data}:/mosquitto/data", + "${dir:state}/mosquitto.conf:/mosquitto/config/mosquitto.conf:ro" + ] + } + ], + "build": { + "artifacts": [ + { + "name": "code", + "kind": "bundle", + "language": "typescript", + "entrypoints": [ + "index.js", + "tools/index.js", + "provisioner/index.js", + "bootstrap/index.js" + ], + "loads": [ + "index.js", + "tools/index.js", + "provisioner/index.js" + ], + "env": { + "MESH_RECEIVES": "${dir:grants}/mesh.json", + "MESH_PROVISION_MQTT": "127.0.0.1:${port:1883}", + "MESH_PROVISION_ADMIN_USER": "mesh-admin", + "MESH_PROVISION_PASSWORD_FILE": "${dir:mesh-state}/admin", + "MESH_MQTT_CTRL_CONTAINER": "mosquitto" + } + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/go/testdata/beside/mesh-catalog/modules/mounts/files/mounts.conf b/go/testdata/beside/mesh-catalog/modules/mounts/files/mounts.conf new file mode 100644 index 0000000..1d5f87d --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/mounts/files/mounts.conf @@ -0,0 +1,11 @@ +# Written by the mesh (module mounts, novox/hq ADR 0263) from this machine's assignment: the settings +# shares and sources, and the binding of nfs-share. Replaced on every push; change the settings, not this. +# The module's process reads it as root every 30 seconds and writes one .mount and .automount per entry. +shares=${config:shares} +sources=${config:sources} +server=${bound:nfs-share:from} +at=${bound:nfs-share:at} +account=${machine:account} +node=${machine:name} +passwords=${dir:state} +state=${dir:state} diff --git a/go/testdata/beside/mesh-catalog/modules/mounts/module.json.captured b/go/testdata/beside/mesh-catalog/modules/mounts/module.json.captured new file mode 100644 index 0000000..488ebb9 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/mounts/module.json.captured @@ -0,0 +1,160 @@ +{ + "module": "mounts", + "version": "1", + "requires": [ + "nfs-share" + ], + "reads": [ + "nfs-server.exports" + ], + "invokes": [ + "seat:mesh-controller.data" + ], + "capabilities": [ + "package-manager", + "service-manager" + ], + "claims": [ + { + "name": "node-mounts", + "scope": "node", + "serves": [ + "list", + "test", + "mount", + "unmount", + "adopt" + ] + } + ], + "tools": [ + "mounts_list", + "mounts_exports", + "mounts_health" + ], + "state": [ + { + "name": "shares", + "per-machine": true + } + ], + "config": { + "shares": { + "type": "string", + "description": "The shares of another machine this machine mounts: space-separated name=mountpoint, each optionally :ro, or none (novox/hq ADR 0263 rule 5).", + "trusted": true, + "default": "none" + }, + "sources": { + "type": "string", + "description": "The occasional sources this machine mounts: space-separated name=smb://[@]/@ or name=disk:@, each optionally :ro, or none (novox/hq ADR 0263 rule 6); an SMB source's username is in its entry, its password the secret smb-password- (novox/hq ADR 0283).", + "trusted": true, + "default": "none" + } + }, + "own-secrets": { + "smb-password-*": { + "path": "${dir:state}/smb-password-*.secret", + "issued-by": "outside" + } + }, + "resources": [ + { + "id": "nfs-utils", + "type": "package", + "package": "nfs-utils" + }, + { + "id": "cifs-utils", + "type": "package", + "package": "cifs-utils" + }, + { + "id": "state", + "type": "directory", + "mode": "0700", + "place": ".", + "unknown": "program" + }, + { + "id": "config-dir", + "type": "directory", + "path": "/etc/mesh-mounts", + "mode": "0755", + "unknown": "program" + }, + { + "id": "config", + "type": "file", + "path": "/etc/mesh-mounts/mounts.conf", + "mode": "0644", + "template": "files/mounts.conf" + }, + { + "id": "bus-dir-parent", + "type": "directory", + "path": "/var/lib/mesh-mounts", + "mode": "0755", + "unknown": "program" + }, + { + "id": "bus-dir", + "type": "directory", + "path": "/var/lib/mesh-mounts/from-bus", + "mode": "0755", + "owner": "${machine:account}", + "unknown": "program" + }, + { + "id": "apply", + "type": "process", + "name": "mesh-mounts-apply", + "artifact": "tools-go", + "run": [ + "./mounts", + "apply" + ], + "health": { + "kind": "unit" + } + }, + { + "id": "watch", + "type": "process", + "name": "mesh-mounts-watch", + "artifact": "tools-go", + "run": [ + "./mounts", + "watch" + ], + "health": { + "kind": "tool", + "tool": "mounts_health", + "interval": "60s", + "timeout": "10s", + "looks": 2, + "grace": "90s" + } + } + ], + "build": { + "artifacts": [ + { + "name": "tools-go", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/mounts", + "binary": "mounts", + "loads": [ + "mounts" + ] + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/go/testdata/beside/mesh-catalog/modules/mssql/files/backup.sql b/go/testdata/beside/mesh-catalog/modules/mssql/files/backup.sql new file mode 100644 index 0000000..fd2ecce --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/mssql/files/backup.sql @@ -0,0 +1,14 @@ +SET NOCOUNT ON; +DECLARE @n sysname, @s nvarchar(max); +DECLARE c CURSOR LOCAL FAST_FORWARD FOR + SELECT name FROM sys.databases WHERE database_id > 4 AND state = 0 AND source_database_id IS NULL; +OPEN c; +FETCH NEXT FROM c INTO @n; +WHILE @@FETCH_STATUS = 0 +BEGIN + SET @s = N'BACKUP DATABASE ' + QUOTENAME(@n) + N' TO DISK = N''/var/opt/mssql/backup/' + REPLACE(@n, N'''', N'''''') + N'.bak'' WITH INIT, COPY_ONLY, CHECKSUM'; + EXEC (@s); + FETCH NEXT FROM c INTO @n; +END +CLOSE c; +DEALLOCATE c; diff --git a/go/testdata/beside/mesh-catalog/modules/mssql/files/sa.env b/go/testdata/beside/mesh-catalog/modules/mssql/files/sa.env new file mode 100644 index 0000000..e7dd444 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/mssql/files/sa.env @@ -0,0 +1,2 @@ +ACCEPT_EULA=Y +MSSQL_SA_PASSWORD=${secret:sa} diff --git a/go/testdata/beside/mesh-catalog/modules/mssql/module.json.captured b/go/testdata/beside/mesh-catalog/modules/mssql/module.json.captured new file mode 100644 index 0000000..ed4e448 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/mssql/module.json.captured @@ -0,0 +1,174 @@ +{ + "module": "mssql", + "version": "1", + "upgrade": { + "policy": "record", + "why": "a provider whose restart drops every consumer on its machine, and whose new version may upgrade its databases in place: a person takes each build, after a backup (hq ADR 0236)" + }, + "provides": [ + { + "name": "mssql-database", + "scope": "mesh", + "identity": { + "max": 128, + "in": "a SQL Server login and database name" + } + } + ], + "capabilities": [ + "container-runtime" + ], + "emits": [ + "database.provisioned", + "database.deprovisioned" + ], + "consumes": [ + "mssql.database.provisioned", + "mssql.database.deprovisioned" + ], + "listens": [ + { + "name": "database", + "port": 1433, + "protocol": "tcp", + "from": "mesh", + "why": "modules on any machine that were granted a database, and the people who were given its address; the machine port is the assignment's to pin" + } + ], + "serves": { + "mssql-database": {} + }, + "receives": { + "mssql-database": "${dir:grants}/mesh.json" + }, + "grants": { + "mssql-database": "${dir:grants}" + }, + "own-secrets": { + "sa": "${dir:state}/sa.secret", + "reader": "${dir:state}/reader.secret" + }, + "data": { + "own": [ + { + "id": "data", + "path": "${dir:data}", + "class": "valuable", + "backup": { + "dump": "{ cat ${dir:state}/sa.secret; echo; cat ${dir:state}/backup.sql; } | docker exec -i mssql sh -c 'read -r p; SQLCMDPASSWORD=\"$p\" exec /opt/mssql-tools18/bin/sqlcmd -C -b -S localhost -U sa -i /dev/stdin'", + "into": "dumps" + }, + "why": "every consumer's database; copied by the dump, not as live files" + }, + { + "id": "dumps", + "path": "${dir:dumps}", + "class": "rebuildable", + "why": "last night's dump, made again every night" + } + ], + "consumers": { + "mssql-database": { + "class": "valuable", + "in": "data", + "why": "a consumer's rows are the only copy of what it wrote" + } + } + }, + "resources": [ + { + "id": "state", + "type": "directory", + "mode": "0700", + "place": ".", + "unknown": "program" + }, + { + "id": "grants", + "type": "directory", + "mode": "0700", + "unknown": "program" + }, + { + "id": "sa-env", + "type": "file", + "path": "${dir:state}/sa.env", + "mode": "0600", + "template": "files/sa.env" + }, + { + "id": "data", + "type": "directory", + "mode": "0700", + "owner": "10001:0", + "unknown": "program" + }, + { + "id": "dumps", + "type": "directory", + "path": "${dir:data}/backup", + "mode": "0700", + "owner": "10001:0", + "unknown": "program" + }, + { + "id": "net", + "type": "network", + "name": "mssql" + }, + { + "id": "server", + "type": "container", + "name": "mssql", + "image": "mcr.microsoft.com/mssql/server@sha256:4402d880dd4c34bfa7d8705e56a86cd6c88da80a1f6bbbe741f999e76264a090", + "network": "mssql", + "env-file": [ + "${dir:state}/sa.env" + ], + "ports": [ + "1433" + ], + "volumes": [ + "${dir:data}:/var/opt/mssql" + ], + "secrets-in-environment": "the image documents only MSSQL_SA_PASSWORD, no _FILE and no configuration field; not convertible without a wrapper entrypoint" + }, + { + "id": "backup-sql", + "type": "file", + "path": "${dir:state}/backup.sql", + "mode": "0600", + "template": "files/backup.sql" + } + ], + "build": { + "artifacts": [ + { + "name": "code", + "kind": "bundle", + "language": "typescript", + "entrypoints": [ + "index.js", + "tools/index.js", + "provisioner/index.js" + ], + "loads": [ + "index.js", + "tools/index.js", + "provisioner/index.js" + ], + "env": { + "MESH_PROVISION_MSSQL": "mssql://sa@127.0.0.1:${port:1433}/master", + "MESH_PROVISION_PASSWORD_FILE": "${dir:state}/sa.secret", + "MESH_RECEIVES": "${dir:grants}/mesh.json", + "MESH_MSSQL_READER_PASSWORD_FILE": "${dir:state}/reader.secret" + } + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/go/testdata/beside/mesh-catalog/modules/n8n/files/n8n-database.secret b/go/testdata/beside/mesh-catalog/modules/n8n/files/n8n-database.secret new file mode 100644 index 0000000..e811e88 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/n8n/files/n8n-database.secret @@ -0,0 +1 @@ +${secret:postgres-database} \ No newline at end of file diff --git a/go/testdata/beside/mesh-catalog/modules/n8n/files/server.env b/go/testdata/beside/mesh-catalog/modules/n8n/files/server.env new file mode 100644 index 0000000..2f74e0f --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/n8n/files/server.env @@ -0,0 +1,12 @@ +N8N_HOST=${bound:route:name} +N8N_PORT=5678 +N8N_PROTOCOL=https +WEBHOOK_URL=https://${bound:route:name}/ +NODE_FUNCTION_ALLOW_BUILTIN=* +NODE_FUNCTION_ALLOW_EXTERNAL=* +DB_TYPE=postgresdb +DB_POSTGRESDB_HOST=${bound:postgres-database:at} +DB_POSTGRESDB_PORT=${bound:postgres-database:port} +DB_POSTGRESDB_DATABASE=${bound:postgres-database:as} +DB_POSTGRESDB_USER=${bound:postgres-database:as} +DB_POSTGRESDB_PASSWORD_FILE=/run/secrets/database diff --git a/go/testdata/beside/mesh-catalog/modules/n8n/module.json.captured b/go/testdata/beside/mesh-catalog/modules/n8n/module.json.captured new file mode 100644 index 0000000..975247f --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/n8n/module.json.captured @@ -0,0 +1,173 @@ +{ + "module": "n8n", + "version": "1", + "capabilities": [ + "container-runtime" + ], + "requires": [ + "postgres-database", + "route" + ], + "contributes": { + "postgres-database": { + "name": "n8n" + }, + "route": { + "label": "n8n", + "endpoint": "web" + } + }, + "binds": { + "postgres-database": "${dir:state}/database.json", + "route": "${dir:state}/route.json" + }, + "secrets": { + "postgres-database": "${dir:state}/database.secret" + }, + "accesses": [ + { + "id": "media", + "mode": "read-write" + } + ], + "listens": [ + { + "name": "web", + "port": 5678, + "protocol": "tcp", + "from": "mesh", + "why": "the n8n editor, its REST API and the webhook endpoints workflows are triggered through; a public name is the route's" + } + ], + "data": { + "own": [ + { + "id": "data", + "path": "${dir:data}", + "class": "valuable", + "why": "the instance's encryption key, settings and binary data; workflows are in the database" + }, + { + "id": "cache", + "path": "${dir:cache}", + "class": "cache", + "why": "scratch space" + } + ] + }, + "resources": [ + { + "id": "state", + "type": "directory", + "mode": "0700", + "place": ".", + "unknown": "program" + }, + { + "id": "data", + "type": "directory", + "mode": "0700", + "owner": "1000:1000", + "unknown": "program" + }, + { + "id": "cache", + "type": "directory", + "mode": "0700", + "owner": "999:999", + "unknown": "program" + }, + { + "id": "database-secret", + "type": "file", + "path": "${dir:state}/n8n-database.secret", + "mode": "0400", + "owner": "1000:1000", + "template": "files/n8n-database.secret" + }, + { + "id": "server-env", + "type": "file", + "path": "${dir:state}/server.env", + "mode": "0600", + "template": "files/server.env" + }, + { + "id": "net", + "type": "network", + "name": "n8n" + }, + { + "id": "server", + "type": "container", + "health": { + "kind": "http", + "endpoint": "web", + "path": "/healthz/readiness", + "status": 200, + "needs": "postgres-database" + }, + "name": "n8n", + "artifact": "server", + "network": "n8n", + "env-file": [ + "${dir:state}/server.env" + ], + "ports": [ + "5678" + ], + "volumes": [ + "${dir:data}:/home/node/.n8n", + "${dir:state}/n8n-database.secret:/run/secrets/database:ro", + "${access:media}:/media-library" + ] + }, + { + "id": "cache-server", + "type": "container", + "name": "n8n-redis", + "image": "redis@sha256:8a1efc5f479551822b47424ccae982026b633f28818eab0387348120a61e10e2", + "network": "n8n", + "args": [ + "redis-server", + "--appendonly", + "yes" + ], + "volumes": [ + "${dir:cache}:/data" + ] + }, + { + "id": "browser", + "type": "container", + "name": "n8n-selenium", + "image": "selenium/standalone-chrome@sha256:9ae1c78e9b2ca9fe4b22e57873b5ee34aeb8e814e3122293eef4c3abe4c5f448", + "network": "n8n", + "env": { + "SE_ENABLE_TRACING": "false", + "SE_NODE_MAX_SESSIONS": "5", + "SE_NODE_OVERRIDE_MAX_SESSIONS": "true" + } + } + ], + "build": { + "on": [ + { + "arg": "N8N_BASE", + "image": "n8nio/n8n@sha256:4846eb2f4b874ab04cde7fc1e249d2ddaec66e9aea64439beb2972cfea88e3c0" + } + ], + "artifacts": [ + { + "name": "server", + "kind": "image", + "from": "Dockerfile" + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/go/testdata/beside/mesh-catalog/modules/nats/files/nats.conf b/go/testdata/beside/mesh-catalog/modules/nats/files/nats.conf new file mode 100644 index 0000000..e1d33eb --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/nats/files/nats.conf @@ -0,0 +1,46 @@ +# The nats module's own server settings. Declared by the module, because a port, a TLS path +# and a store directory are properties of the container this module raises: they live in its +# image and its mounts and change when it does. +# +# The mesh writes accounts.conf beside this one and nothing else. A controller that wrote the +# whole file would have to be kept in step with a Dockerfile it never sees. + +port: 4222 +# Monitoring on every interface *inside* the container, so the publish below can reach it; the publish +# is 127.0.0.1:8222 on the machine, so nothing beyond the machine reaches it, and the module guards 8222 +# too. Bound to the container's own loopback until 2026-10-04, the published port answered nothing +# (connection reset), and the only way in was `docker exec`. +http: 0.0.0.0:8222 + +# The mesh's own broker certificate — the one every machine already pins by fingerprint and the +# controller already trusts (MESH_BROKER_CERTIFICATE). Serving the new bus with it means no +# machine's pin changes when it moves, and no second certificate exists to be wrong about. +tls { + cert_file: "/tls/tls.crt" + key_file: "/tls/tls.key" +} + +# **No `verify`, deliberately, and it was `verify: true` until a probe ran this image.** That +# setting makes the server demand a *client* certificate, and nothing in the mesh presents one: a +# host pins this server's exact certificate and authenticates with the password the mesh minted +# (novox/hq ADR 0004, design 25 §4), and so does a module's runtime. With it on, every connection +# in the mesh is refused at the TLS handshake, before any password is looked at — and the error is +# "client didn't provide a certificate", which reads as a client fault. +# +# TLS is still required: a tls block is what makes it required, and verify only decides whether +# client certificates are checked. What is given up is a second factor the mesh has no machinery +# to issue or rotate — a certificate per module per node — and what is kept is stronger than a +# name check in both directions: an exact pin outward, a per-user password inward. + +jetstream { + store_dir: "/data" +} + +# Every user of the mesh, composed by the controller and rewritten whenever a module is +# assigned, a node enrols or a person's access changes. +# +# **Relative, and in this same directory, because it has to be.** An absolute include path is +# resolved relative to the including file's directory, not from the root: nats-server given +# `include /etc/nats/accounts.conf` from /etc/nats-server/nats.conf looks for +# /etc/nats-server/etc/nats/accounts.conf and refuses to start. Verified against the server. +include accounts.conf diff --git a/go/testdata/beside/mesh-catalog/modules/nats/module.json.captured b/go/testdata/beside/mesh-catalog/modules/nats/module.json.captured new file mode 100644 index 0000000..3ad66bc --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/nats/module.json.captured @@ -0,0 +1,171 @@ +{ + "module": "nats", + "version": "1", + "upgrade": { + "policy": "record", + "why": "the bus: replaced only as a planned step a person starts (`bus upgrade`), its streams snapshotted first and checked after (hq ADR 0236); the controller holds this whatever is said here" + }, + "provides": [ + { + "name": "mesh-bus", + "scope": "mesh" + } + ], + "claims": [ + { + "name": "mesh-broker", + "scope": "mesh" + } + ], + "bus-users": "/var/lib/nats-module/conf/accounts.conf", + "own-secrets": { + "broker": "${dir:mesh-state}/broker" + }, + "capabilities": [ + "container-runtime" + ], + "emits": [], + "consumes": [], + "listens": [ + { + "name": "bus", + "port": 4222, + "protocol": "tcp", + "from": "mesh", + "why": "the mesh bus — every link the mesh has, over TLS, reached across the overlay" + } + ], + "tools": [ + "nats_server", + "nats_connections", + "nats_closed_connections", + "nats_subscriptions", + "nats_streams", + "nats_backlog", + "nats_buckets", + "nats_users", + "nats_user_can" + ], + "guards": [ + 8222 + ], + "data": { + "own": [ + { + "id": "jetstream", + "path": "${dir:jetstream-data}", + "class": "valuable", + "active": "1d", + "backup": { + "dump": "docker exec -i mesh-broker-nats mesh-nats-snapshot snapshot < ${dir:mesh-state}/broker > ${dir:snapshots}/bus.tar.partial && mv ${dir:snapshots}/bus.tar.partial ${dir:snapshots}/bus.tar || { rm -f ${dir:snapshots}/bus.tar.partial; exit 1; }", + "into": "snapshots" + }, + "why": "the bus's streams and key-value buckets: the hand-act log, conditions, every module's state; written all the time, so copied by the server's own snapshot of each stream (novox/hq ADR 0235), never as live files" + }, + { + "id": "snapshots", + "path": "${dir:snapshots}", + "class": "rebuildable", + "why": "last night's snapshot of every stream with its manifest, made again every night" + } + ] + }, + "resources": [ + { + "id": "jetstream-data", + "type": "directory", + "path": "/var/lib/mesh-broker-nats", + "mode": "0700", + "unknown": "program" + }, + { + "id": "conf-dir", + "type": "directory", + "path": "/var/lib/nats-module/conf", + "mode": "0700", + "unknown": "program" + }, + { + "id": "snapshots", + "type": "directory", + "mode": "0700", + "unknown": "program" + }, + { + "id": "mesh-state", + "type": "directory", + "mode": "0700", + "place": "mesh", + "unknown": "program" + }, + { + "id": "server-conf", + "type": "file", + "path": "/var/lib/nats-module/conf/nats.conf", + "template": "files/nats.conf", + "mode": "0644" + }, + { + "id": "server", + "type": "container", + "name": "mesh-broker-nats", + "ports": [ + "4222:4222", + "127.0.0.1:8222:8222" + ], + "volumes": [ + "/var/lib/mesh-broker-nats:/data", + "/var/lib/nats-module/conf:/etc/nats:ro", + "${access:tls}:/tls:ro" + ], + "artifact": "server" + } + ], + "accesses": [ + { + "id": "tls", + "path": "/var/lib/mesh-broker-tls", + "mode": "read" + } + ], + "build": { + "on": [ + { + "arg": "NATS_BASE", + "image": "nats@sha256:e4bf19f15fd3218814a4e3c9e0064e1334bd8aa20d5984b9f1a0afd084f8cc00" + }, + { + "arg": "GO_BASE", + "image": "golang@sha256:8ac98ca534ac3f51e1f420a1dd2c15e74c75cfa0f23f3ad27eb5d7236c349a0c" + } + ], + "artifacts": [ + { + "name": "server", + "kind": "image", + "from": "Dockerfile" + }, + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/nats-tools", + "binary": "nats-tools", + "loads": [ + "nats-tools" + ], + "env": { + "MESH_NATS_MONITOR": "http://127.0.0.1:8222", + "MESH_NATS_CONTAINER": "mesh-broker-nats", + "MESH_NATS_USERS_FILE": "/etc/nats/accounts.conf" + } + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/go/testdata/beside/mesh-catalog/modules/netcheck/module.json.captured b/go/testdata/beside/mesh-catalog/modules/netcheck/module.json.captured new file mode 100644 index 0000000..ec01003 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/netcheck/module.json.captured @@ -0,0 +1,57 @@ +{ + "module": "netcheck", + "version": "1", + "emits": [ + "tunnel.endpoint-changed", + "tunnel.handshake-stale", + "tunnel.handshake-fresh" + ], + "tools": [ + "netcheck_tunnel", + "netcheck_tcp", + "netcheck_dns", + "netcheck_http", + "netcheck_listening" + ], + "replaces": { + "netcheck_listening": [ + "ss -lunt", + "ss -ltn", + "netstat -lnt" + ], + "netcheck_dns": [ + "dig", + "nslookup" + ], + "netcheck_tunnel": [ + "wg show", + "wg show mesh0 dump" + ] + }, + "build": { + "artifacts": [ + { + "name": "tools-go", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/netcheck", + "binary": "netcheck", + "loads": [ + "netcheck" + ] + }, + { + "name": "tools-typescript", + "kind": "bundle", + "language": "typescript", + "entrypoints": [ + "tools/index.js" + ], + "loads": [ + "tools/index.js" + ] + } + ] + } +} diff --git a/go/testdata/beside/mesh-catalog/modules/networkmanager/files/50-mesh.conf b/go/testdata/beside/mesh-catalog/modules/networkmanager/files/50-mesh.conf new file mode 100644 index 0000000..f8c8e8a --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/networkmanager/files/50-mesh.conf @@ -0,0 +1,63 @@ +# Managed by the mesh (module networkmanager). Replaced on every push; edit the +# catalogue instead. +# +# This machine's uplink is NetworkManager's, and this file is the whole of what +# the mesh asks of it (novox/hq ADR 0117): leave the resolver file to the mesh, +# and leave the private network's interface alone. Nothing more. The mesh never +# declares a connection profile, an address, a route, a wireless network or its +# credentials — those are joined at the machine, by the person using it, and +# the link they make is the only channel the mesh reaches this machine over. A +# push that got a link wrong could not be undone by the next one. +# +# A drop-in of the mesh's own, beside NetworkManager.conf and whatever else the +# operator keeps in this directory. NetworkManager reads the files here sorted by +# name and a later one wins a key it sets again — so a file of the operator's +# that sorts after this one (any name starting with a letter does) and sets dns= +# or unmanaged-devices= overrides it. That is the operator's to decide, and the +# reason this file sets nothing but the two keys it must. +# +# NetworkManager itself is the machine's: the mesh never starts, stops, enables +# or disables it (its service is declared with no state), because stopping it +# takes every link down, this machine's channel to the mesh included — and a +# module unassigned by mistake must not be able to do that. When this file +# changes, a running NetworkManager is reloaded (its D-Bus Reload call, which +# re-reads its configuration — NetworkManager(8)), never restarted. + +[main] +# The resolver file is the mesh's: this module writes /etc/resolv.conf itself, +# listing the mesh's resolvers (novox/hq ADR 0223). Without this line +# NetworkManager rewrites that file on +# every connectivity change — every network joined, every lease renewed — +# and the mesh's resolver is silently replaced while every surface of the mesh +# still reads green. none: "NetworkManager will not modify resolv.conf. This +# implies rc-manager unmanaged" (NetworkManager.conf(5), 1.58). On an adopted +# machine the predecessor wrote the same line in a file of its own; both say one +# thing, and the predecessor's is retired by hand after the take. +# +# Not NetworkManager's own global DNS ([global-dns-domain-*] with rc-manager=file): +# it writes its own header and composes the options line itself, so it cannot +# write the mesh's file byte for byte, and the three uplink modules would write +# three different files for one fact. dns=none, and the file declared beside it. +dns=none + +[keyfile] +# mesh0 is the private network's interface: the mesh brings it up and the mesh +# alone configures it. A manager that considers every interface its own could +# try to configure it, or tear it down on a profile change. +# +# unmanaged-devices rather than a [device-mesh0] section with managed=0, because +# NetworkManager.conf(5) says a device unmanaged by this key "is strictly +# unmanaged and cannot be overruled by using the API like nmcli device set +# $IFNAME managed yes", while device*.managed "can be overruled at runtime via +# D-Bus". The same page adds that device*.managed "may be a better choice" for +# exactly those reasons — for an interface the operator might want to hand back +# at runtime. For the mesh's own interface, strict is the point. +# +# += rather than =: the same page documents appending to a list-valued key set +# earlier ("plugins+=another-plugin") as an extension of its key file format, +# and unmanaged-devices is a device list. = would replace whatever devices the +# operator already keeps NetworkManager away from; += adds this one to them +# (novox/hq ADR 0102: a list is added to, never replaced). A file of the +# operator's read after this one that sets the key with = replaces it again; +# that is the operator's to decide. +unmanaged-devices+=interface-name:mesh0 diff --git a/go/testdata/beside/mesh-catalog/modules/networkmanager/module.json.captured b/go/testdata/beside/mesh-catalog/modules/networkmanager/module.json.captured new file mode 100644 index 0000000..68ace34 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/networkmanager/module.json.captured @@ -0,0 +1,71 @@ +{ + "module": "networkmanager", + "version": "1", + "upgrade": { + "policy": "record", + "why": "the machine's network: a build that breaks it can cut the machine off from the bus, and then neither the gate's rollback nor a push reaches it (hq ADR 0236)" + }, + "slug": "nm", + "requires": [ + "wildcard-resolution" + ], + "capabilities": [ + "package-manager", + "service-manager", + "uplink-networkmanager" + ], + "claims": [ + { + "name": "node-uplink", + "scope": "node" + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/uplink-tools", + "binary": "uplink-tools", + "loads": [ + "uplink-tools" + ] + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + }, + "facts": { + "resolvers": { + "path": "/etc/resolv.conf", + "template": "# Managed by the mesh, and written by the module holding this machine's uplink:\n# the program that manages the machine's network would otherwise rewrite this\n# file on every change of network, so its holder is the one that writes it\n# (novox/hq ADR 0117, ADR 0223). Replaced on every push; edit nothing here.\n#\n# Every resolver of the mesh, by address, and nothing else (novox/hq ADR 0223) \u2014\n# this machine's own first when it holds one, then the others by name. Each\n# answers the mesh's names from the same roster and forwards every other name, so\n# whichever answers first gives the one answer. There is no public resolver here:\n# a C library that asks every listed server at once and takes the first reply \u2014\n# musl, so every Alpine container \u2014 took a public resolver's \"no such name\" for\n# a mesh name and failed. A machine that reaches none of these has no names until\n# it does. Containers copy these lines from their machine.\n{{range index .Holders \"mesh-dns-resolver\"}}nameserver {{.Address}}\n{{end}}options timeout:1 attempts:2 edns0\n" + } + }, + "resources": [ + { + "id": "package", + "type": "package", + "package": "networkmanager" + }, + { + "id": "config", + "type": "file", + "path": "/etc/NetworkManager/conf.d/50-mesh.conf", + "mode": "0644", + "template": "files/50-mesh.conf" + }, + { + "id": "service", + "type": "service", + "unit": "NetworkManager.service", + "reload-on": [ + "config" + ] + } + ] +} diff --git a/go/testdata/beside/mesh-catalog/modules/nextcloud-client/module.json.captured b/go/testdata/beside/mesh-catalog/modules/nextcloud-client/module.json.captured new file mode 100644 index 0000000..af71acb --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/nextcloud-client/module.json.captured @@ -0,0 +1,38 @@ +{ + "module": "nextcloud-client", + "version": "1", + "capabilities": [ + "package-manager" + ], + "requires": [ + "x11-display" + ], + "tools": [ + "nextcloud_status", + "nextcloud_log", + "nextcloud_restart", + "nextcloud_check" + ], + "resources": [ + { + "id": "package", + "type": "package", + "package": "nextcloud-client" + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/nextcloud-client-tools", + "binary": "nextcloud-client-tools", + "loads": [ + "nextcloud-client-tools" + ] + } + ] + } +} diff --git a/go/testdata/beside/mesh-catalog/modules/nextcloud/files/config.json b/go/testdata/beside/mesh-catalog/modules/nextcloud/files/config.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/nextcloud/files/config.json @@ -0,0 +1 @@ +{} diff --git a/go/testdata/beside/mesh-catalog/modules/nextcloud/files/server.env b/go/testdata/beside/mesh-catalog/modules/nextcloud/files/server.env new file mode 100644 index 0000000..df6e20f --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/nextcloud/files/server.env @@ -0,0 +1,14 @@ +POSTGRES_HOST=${bound:postgres-database:at}:${bound:postgres-database:port} +POSTGRES_DB=${bound:postgres-database:as} +POSTGRES_USER=${bound:postgres-database:as} +POSTGRES_PASSWORD=${secret:postgres-database} +NEXTCLOUD_ADMIN_USER=mesh-admin +NEXTCLOUD_ADMIN_PASSWORD=${secret:admin} +OBJECTSTORE_S3_HOST=${bound:s3-bucket:at} +OBJECTSTORE_S3_PORT=${bound:s3-bucket:port} +OBJECTSTORE_S3_BUCKET=${bound:s3-bucket:bucket} +OBJECTSTORE_S3_KEY=${bound:s3-bucket:as} +OBJECTSTORE_S3_SECRET=${secret:s3-bucket} +OBJECTSTORE_S3_SSL=false +OBJECTSTORE_S3_USEPATH_STYLE=true +OBJECTSTORE_S3_REGION=${bound:s3-bucket:region} diff --git a/go/testdata/beside/mesh-catalog/modules/nextcloud/module.json.captured b/go/testdata/beside/mesh-catalog/modules/nextcloud/module.json.captured new file mode 100644 index 0000000..2854a16 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/nextcloud/module.json.captured @@ -0,0 +1,168 @@ +{ + "module": "nextcloud", + "version": "1", + "slug": "ncloud", + "requires": [ + "postgres-database", + "s3-bucket", + "route" + ], + "contributes": { + "postgres-database": { + "name": "nextcloud" + }, + "route": { + "label": "drive", + "endpoint": "web" + } + }, + "binds": { + "postgres-database": "${dir:state}/database.json", + "s3-bucket": "${dir:state}/store.json", + "route": "${dir:state}/route.json" + }, + "secrets": { + "postgres-database": "${dir:state}/database.secret", + "s3-bucket": "${dir:state}/store.secret" + }, + "emits": [ + "user.created", + "share.created" + ], + "own-secrets": { + "admin": "${dir:state}/admin.secret" + }, + "capabilities": [ + "container-runtime" + ], + "listens": [ + { + "name": "web", + "port": 80, + "protocol": "tcp", + "from": "mesh", + "why": "files and sync, over http; a public name is a route grant later" + } + ], + "data": { + "own": [ + { + "id": "html", + "path": "${dir:html}", + "class": "valuable", + "why": "the instance's configuration, its secret and installed apps; the files are in the object store" + } + ] + }, + "config": { + "container": { + "type": "string", + "description": "The name of the container the module's tools run Nextcloud's occ in; empty is nextcloud.", + "trusted": true, + "default": "" + }, + "url": { + "type": "string", + "description": "The address the module's tools reach Nextcloud's sharing API at, as a URL; empty is this machine's Nextcloud on its port.", + "trusted": true, + "default": "" + }, + "user": { + "type": "string", + "description": "The Nextcloud account the module's tools act as; empty is admin.", + "trusted": true, + "default": "" + } + }, + "resources": [ + { + "id": "mesh-state", + "type": "directory", + "mode": "0700", + "place": "mesh", + "unknown": "program" + }, + { + "id": "state", + "type": "directory", + "mode": "0700", + "place": ".", + "unknown": "program" + }, + { + "id": "server-env", + "type": "file", + "path": "${dir:state}/server.env", + "mode": "0600", + "template": "files/server.env" + }, + { + "id": "html", + "type": "directory", + "mode": "0750", + "owner": "33:33", + "unknown": "program" + }, + { + "id": "server", + "type": "container", + "health": { + "kind": "http", + "endpoint": "web", + "path": "/status.php", + "status": 200, + "body": "\"installed\":true", + "needs": "postgres-database" + }, + "name": "nextcloud", + "image": "nextcloud@sha256:fb966733647ea03f0446b0c22eac9733c8eb616d37b960caca9d4c3010e14a08", + "env-file": [ + "${dir:state}/server.env" + ], + "ports": [ + "80" + ], + "volumes": [ + "${dir:html}:/var/www/html" + ], + "secrets-in-environment": "the image honours POSTGRES_PASSWORD_FILE and NEXTCLOUD_ADMIN_PASSWORD_FILE (entrypoint file_env); OBJECTSTORE_S3_SECRET has none and needs a generated config fragment; convertible, awaiting a bed" + }, + { + "id": "runtime-config", + "type": "file", + "path": "${dir:mesh-state}/config.json", + "mode": "0600", + "template": "files/config.json", + "takes-config": ["container", "url", "user"], + "merge": "json" + } + ], + "build": { + "artifacts": [ + { + "name": "code", + "kind": "bundle", + "language": "typescript", + "entrypoints": [ + "index.js", + "tools/index.js" + ], + "loads": [ + "index.js", + "tools/index.js" + ], + "env": { + "MESH_NEXTCLOUD_URL": "http://127.0.0.1:${port:80}", + "MESH_NEXTCLOUD_CONFIG_FILE": "${dir:mesh-state}/config.json", + "MESH_NEXTCLOUD_ADMIN_USER": "mesh-admin", + "MESH_NEXTCLOUD_ADMIN_PASSWORD_FILE": "${dir:state}/admin.secret" + } + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/go/testdata/beside/mesh-catalog/modules/nfs-server/files/50-mesh.conf b/go/testdata/beside/mesh-catalog/modules/nfs-server/files/50-mesh.conf new file mode 100644 index 0000000..2640a8b --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/nfs-server/files/50-mesh.conf @@ -0,0 +1,12 @@ +# Written by the mesh (module nfs-server, novox/hq ADR 0263). Replaced on every push; a drop-in of +# the operator's that sorts after this one overrides it, and is theirs. +# +# NFS version 4 only: a client needs port 2049 and nothing else, so the module opens nothing more +# than that, to the private network. Version 3 needs rpcbind and mountd, on ports the mesh does not open. +[nfsd] +vers2=n +vers3=n +vers4=y +vers4.0=n +vers4.1=y +vers4.2=y diff --git a/go/testdata/beside/mesh-catalog/modules/nfs-server/files/shares.conf b/go/testdata/beside/mesh-catalog/modules/nfs-server/files/shares.conf new file mode 100644 index 0000000..f25f026 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/nfs-server/files/shares.conf @@ -0,0 +1,10 @@ +# Written by the mesh (module nfs-server, novox/hq ADR 0263) from this machine's assignment. +# Replaced on every push; change the `shares` setting, never this file. +# +# The shares: name=folder, or name=folder:ro, one share per folder. The grants: share=node:rw|ro[,…], +# which nodes may mount each and how. The module's process exports a share to a node's private address +# only when it is granted here and that node's mounts module wants it, every client mapped to the +# folder's owner, and only to addresses inside the range below. +shares=${config:shares} +grants=${config:grants} +range=${machine:mesh-range} diff --git a/go/testdata/beside/mesh-catalog/modules/nfs-server/module.json.captured b/go/testdata/beside/mesh-catalog/modules/nfs-server/module.json.captured new file mode 100644 index 0000000..f9994d1 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/nfs-server/module.json.captured @@ -0,0 +1,191 @@ +{ + "module": "nfs-server", + "version": "1", + "upgrade": { + "policy": "record", + "why": "the folders other machines mount: a build that breaks the exports leaves every client's mount hanging or refused, and the gate on this one machine does not see the clients (hq ADR 0236, ADR 0263)" + }, + "capabilities": [ + "package-manager", + "service-manager" + ], + "provides": [ + { + "name": "nfs-share", + "scope": "mesh", + "identity": false + } + ], + "data": { + "consumers": { + "nfs-share": { + "class": "none", + "why": "the shared folders are the operator's data (hq ADR 0051): what a client writes lands in them, and they are protected where the operator declares them, never by this module, which keeps nothing of a consumer's" + } + } + }, + "claims": [ + { + "name": "node-nfs-server", + "scope": "node", + "serves": [ + "exports", + "clients", + "test", + "reload", + "adopt" + ] + } + ], + "state": [ + { + "name": "exports", + "ttl-seconds": 120, + "per-machine": true + } + ], + "reads": [ + "mounts.shares" + ], + "invokes": [ + "seat:mesh-controller.seats", + "seat:mesh-controller.data" + ], + "config": { + "shares": { + "type": "string", + "description": "The folders this machine exports, as space-separated name=path; the operator's, with no default (novox/hq ADR 0263).", + "trusted": true, + "required": true + }, + "grants": { + "type": "string", + "description": "Which nodes may mount which share, and how: share=node:rw|ro[,node:rw|ro], entries separated by spaces, or none to export to no node whatever a node wants (novox/hq ADR 0263).", + "trusted": true, + "default": "none" + } + }, + "facts": { + "machines": { + "path": "/etc/nfs-server/machines", + "template": "# Written by the mesh (module nfs-server, novox/hq ADR 0263): every machine of the mesh and its private\n# address, from which the module takes a node's address. Replaced on every push.\n{{range .Machines}}{{.Name}} {{.Address}}\n{{end}}" + } + }, + "tools": [ + "nfs_health" + ], + "listens": [ + { + "name": "nfs", + "port": 2049, + "protocol": "tcp", + "from": "mesh", + "fixed": true, + "why": "the shares, to the mesh's machines only (hq ADR 0263): NFS version 4 alone, which needs no other port, and never the home network, where a device that is not a node could claim any user id" + } + ], + "resources": [ + { + "id": "package", + "type": "package", + "package": "nfs-utils" + }, + { + "id": "nfs-conf", + "type": "file", + "path": "/etc/nfs.conf.d/50-mesh.conf", + "mode": "0644", + "template": "files/50-mesh.conf" + }, + { + "id": "run-dir", + "type": "directory", + "path": "/run/nfs-server", + "mode": "0755", + "unknown": "program" + }, + { + "id": "server", + "type": "service", + "unit": "nfs-server.service", + "state": "running", + "boot": "enabled", + "restart-on": [ + "nfs-conf" + ], + "health": { + "kind": "unit" + } + }, + { + "id": "wants-dir-parent", + "type": "directory", + "path": "/var/lib/nfs-server", + "mode": "0755", + "unknown": "program" + }, + { + "id": "wants-dir", + "type": "directory", + "path": "/var/lib/nfs-server/from-bus", + "mode": "0755", + "owner": "${machine:account}", + "unknown": "program" + }, + { + "id": "exports", + "type": "process", + "name": "nfs-server-exports", + "artifact": "tools", + "run": [ + "./nfs-server", + "exports" + ], + "restart-on": [ + "config" + ], + "health": { + "kind": "tool", + "tool": "nfs_health", + "interval": "60s", + "timeout": "10s", + "looks": 2, + "grace": "90s" + } + }, + { + "id": "config-dir", + "type": "directory", + "path": "/etc/nfs-server", + "mode": "0755", + "unknown": "program" + }, + { + "id": "config", + "type": "file", + "path": "/etc/nfs-server/shares.conf", + "mode": "0644", + "template": "files/shares.conf" + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/nfs-server", + "binary": "nfs-server", + "loads": [ + "nfs-server" + ] + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/go/testdata/beside/mesh-catalog/modules/nftables/files/mesh-filter.service b/go/testdata/beside/mesh-catalog/modules/nftables/files/mesh-filter.service new file mode 100644 index 0000000..5a6cced --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/nftables/files/mesh-filter.service @@ -0,0 +1,14 @@ +[Unit] +Description=The mesh's packet filter, derived from what is assigned to this node +Wants=network-pre.target +Before=network-pre.target + +[Service] +Type=oneshot +RemainAfterExit=yes +ExecStart=nft -f /etc/nftables.conf +ExecReload=nft -f /etc/nftables.conf +ExecStop=nft delete table inet mesh + +[Install] +WantedBy=multi-user.target diff --git a/go/testdata/beside/mesh-catalog/modules/nftables/files/mesh.conf b/go/testdata/beside/mesh-catalog/modules/nftables/files/mesh.conf new file mode 100644 index 0000000..f1a7bb0 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/nftables/files/mesh.conf @@ -0,0 +1,5 @@ +# The mesh: stopping the stock unit deletes only the mesh's table, never the whole ruleset +# (novox/hq ADR 0100) — a flush would take the container runtime's rules and any firewall with it. +[Service] +ExecStop= +ExecStop=nft delete table inet mesh diff --git a/go/testdata/beside/mesh-catalog/modules/nftables/module.json.captured b/go/testdata/beside/mesh-catalog/modules/nftables/module.json.captured new file mode 100644 index 0000000..b7b0c83 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/nftables/module.json.captured @@ -0,0 +1,91 @@ +{ + "module": "nftables", + "version": "1", + "upgrade": { + "policy": "record", + "why": "the machine's packet filter: a build that breaks it can cut the machine off from the bus, and then neither the gate's rollback nor a push reaches it (hq ADR 0236)" + }, + "capabilities": [ + "firewall" + ], + "claims": [ + { + "name": "node-packet-filter", + "scope": "node", + "serves": [ + "rules", + "reload", + "remove" + ] + } + ], + "filtering": { + "into": "/etc/nftables.conf" + }, + "resources": [ + { + "id": "package", + "type": "package", + "package": "nftables" + }, + { + "id": "legacy-tools", + "type": "package", + "package": "iptables" + }, + { + "id": "unit", + "type": "file", + "path": "/etc/systemd/system/mesh-filter.service", + "template": "files/mesh-filter.service", + "mode": "0644" + }, + { + "id": "stock-unit-stop", + "type": "file", + "path": "/etc/systemd/system/nftables.service.d/mesh.conf", + "template": "files/mesh.conf", + "mode": "0644" + }, + { + "id": "load", + "type": "service", + "unit": "mesh-filter.service", + "state": "running", + "boot": "enabled", + "restart-on": [ + "unit", + "stock-unit-stop" + ], + "reload-on": [ + "filtering" + ] + }, + { + "id": "front-end", + "type": "package", + "package": "ufw", + "absent": true + } + ], + "tools": [ + "firewall_rules" + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "typescript", + "entrypoints": [ + "tools/index.js" + ] + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/go/testdata/beside/mesh-catalog/modules/nm-applet/module.json.captured b/go/testdata/beside/mesh-catalog/modules/nm-applet/module.json.captured new file mode 100644 index 0000000..1c6764e --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/nm-applet/module.json.captured @@ -0,0 +1,37 @@ +{ + "module": "nm-applet", + "version": "1", + "capabilities": [ + "package-manager" + ], + "requires": [ + "x11-display" + ], + "tools": [ + "nm_applet_status", + "nm_applet_restart", + "nm_applet_check" + ], + "resources": [ + { + "id": "package", + "type": "package", + "package": "network-manager-applet" + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/nm-applet-tools", + "binary": "nm-applet-tools", + "loads": [ + "nm-applet-tools" + ] + } + ] + } +} diff --git a/go/testdata/beside/mesh-catalog/modules/node-env/files/50-mesh.conf b/go/testdata/beside/mesh-catalog/modules/node-env/files/50-mesh.conf new file mode 100644 index 0000000..3aebc87 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/node-env/files/50-mesh.conf @@ -0,0 +1,4 @@ +# The operator account's environment for its service manager and graphical session, generated by +# the mesh (module node-env, novox/hq ADR 0203). Do not edit: this file is replaced at every push. +# The same facts as ~/.config/mesh/environment.sh, in environment.d(5) syntax. +${collected:environment:systemd} \ No newline at end of file diff --git a/go/testdata/beside/mesh-catalog/modules/node-env/files/environment.sh b/go/testdata/beside/mesh-catalog/modules/node-env/files/environment.sh new file mode 100644 index 0000000..c28adf3 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/node-env/files/environment.sh @@ -0,0 +1,5 @@ +# The operator account's environment, generated by the mesh (module node-env, novox/hq ADR 0203). +# Do not edit: this file is replaced at every push. Every line names the module that contributed it. +# Sourced by the login shell from its always-read startup file (for zsh, ~/.zshenv), so a script, a +# login and the shell's execute verb all see it. Your own variables belong in your shell's own lines. +${collected:environment:posix} \ No newline at end of file diff --git a/go/testdata/beside/mesh-catalog/modules/node-env/module.json.captured b/go/testdata/beside/mesh-catalog/modules/node-env/module.json.captured new file mode 100644 index 0000000..85fd7e5 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/node-env/module.json.captured @@ -0,0 +1,55 @@ +{ + "module": "node-env", + "version": "1", + "moved": true, + "claims": [ + { + "name": "node-environment", + "scope": "node" + } + ], + "resources": [ + { + "id": "mesh-config-dir", + "type": "directory", + "path": "${machine:account-home}/.config/mesh", + "owner": "${machine:account}", + "mode": "0755", + "unknown": "move-out", + "knows": ["forge.credentials"] + }, + { + "id": "environment-d", + "type": "directory", + "path": "${machine:account-home}/.config/environment.d", + "owner": "${machine:account}", + "mode": "0755", + "unknown": "move-out" + }, + { + "id": "posix", + "type": "file", + "path": "${machine:account-home}/.config/mesh/environment.sh", + "owner": "${machine:account}", + "mode": "0644", + "template": "files/environment.sh" + }, + { + "id": "systemd", + "type": "file", + "path": "${machine:account-home}/.config/environment.d/50-mesh.conf", + "owner": "${machine:account}", + "mode": "0644", + "template": "files/50-mesh.conf" + } + ], + "build": { + "artifacts": [ + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/go/testdata/beside/mesh-catalog/modules/nodered/files/config.json b/go/testdata/beside/mesh-catalog/modules/nodered/files/config.json new file mode 100644 index 0000000..a396fbd --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/nodered/files/config.json @@ -0,0 +1,3 @@ +{ + "token": "${secret:api-token}" +} diff --git a/go/testdata/beside/mesh-catalog/modules/nodered/files/mqtt.env b/go/testdata/beside/mesh-catalog/modules/nodered/files/mqtt.env new file mode 100644 index 0000000..5331f3c --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/nodered/files/mqtt.env @@ -0,0 +1,4 @@ +MESH_NODERED_URL=http://127.0.0.1:${port:1880} +MESH_NODERED_CONFIG_FILE=${dir:mesh-state}/config.json +MESH_PROVISIONS_DIR=${dir:state} +MESH_WRITTEN_DIR=${dir:written} diff --git a/go/testdata/beside/mesh-catalog/modules/nodered/files/settings.js b/go/testdata/beside/mesh-catalog/modules/nodered/files/settings.js new file mode 100644 index 0000000..0d0f351 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/nodered/files/settings.js @@ -0,0 +1,48 @@ +// Node-RED's settings, written by the mesh from the nodered module. What an assignment may change +// is settings.json beside this file (merged key by key); the credentials are the mesh's secrets and +// reach Node-RED only through this file. The flows' own credentials stay encrypted in the user +// directory under the key Node-RED keeps there (.config.runtime.json), which is data, not this. +const fs = require("fs"); +const path = require("path"); +const crypto = require("crypto"); + +const ADMIN_PASSWORD = "${secret:admin}"; +const API_TOKEN = "${secret:api-token}"; +const ADMIN = { username: "admin", permissions: "*" }; + +const settings = JSON.parse(fs.readFileSync(path.join(__dirname, "settings.json"), "utf8")); +// The mesh's keys, not Node-RED's: endpoints lands in every merged file; timeZone is the +// assignment's way to set the zone flows schedule and format in; mqtt names the broker nodes the +// module's MQTT step keeps pointed at the mesh's broker; topics is what nodered asks the broker for. +if (settings.timeZone) process.env.TZ = settings.timeZone; +delete settings.timeZone; +delete settings.endpoints; +delete settings.mqtt; +delete settings.topics; + +function same(a, b) { + const x = crypto.createHash("sha256").update(String(a)).digest(); + const y = crypto.createHash("sha256").update(String(b)).digest(); + return crypto.timingSafeEqual(x, y); +} + +// The admin secret is a password, or, accepted from an existing install, the bcrypt hash its +// settings held, so the password people already use keeps working. +function passwordMatches(given) { + if (/^\$2[aby]\$\d\d\$/.test(ADMIN_PASSWORD)) return require("bcryptjs").compare(String(given), ADMIN_PASSWORD); + return Promise.resolve(same(given, ADMIN_PASSWORD)); +} + +module.exports = Object.assign(settings, { + uiPort: 1880, + adminAuth: { + type: "credentials", + users: (username) => Promise.resolve(username === ADMIN.username ? ADMIN : null), + authenticate: (username, password) => + username === ADMIN.username + ? passwordMatches(password).then((ok) => (ok ? ADMIN : null)) + : Promise.resolve(null), + // The module's own tools call the admin API with this bearer token. + tokens: (token) => Promise.resolve(same(token, API_TOKEN) ? { username: "mesh", permissions: "*" } : null), + }, +}); diff --git a/go/testdata/beside/mesh-catalog/modules/nodered/files/settings.json b/go/testdata/beside/mesh-catalog/modules/nodered/files/settings.json new file mode 100644 index 0000000..93cf157 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/nodered/files/settings.json @@ -0,0 +1,14 @@ +{ + "flowFile": "flows.json", + "flowFilePretty": true, + "diagnostics": { "enabled": true, "ui": true }, + "runtimeState": { "enabled": false, "ui": false }, + "logging": { "console": { "level": "info", "metrics": false, "audit": false } }, + "exportGlobalContextKeys": false, + "externalModules": {}, + "editorTheme": { "projects": { "enabled": false } }, + "functionExternalModules": true, + "debugMaxLength": 1000, + "mqttReconnectTime": 15000, + "serialReconnectTime": 15000 +} diff --git a/go/testdata/beside/mesh-catalog/modules/nodered/module.json.captured b/go/testdata/beside/mesh-catalog/modules/nodered/module.json.captured new file mode 100644 index 0000000..f8068dd --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/nodered/module.json.captured @@ -0,0 +1,297 @@ +{ + "module": "nodered", + "version": "1", + "emits": [ + "flows.deployed" + ], + "own-secrets": { + "admin": { + "path": "${dir:mesh-state}/admin", + "taken": "at-start" + }, + "api-token": { + "path": "${dir:mesh-state}/api-token", + "taken": "at-start" + } + }, + "capabilities": [ + "container-runtime" + ], + "listens": [ + { + "name": "web", + "port": 1880, + "protocol": "tcp", + "from": "mesh", + "why": "the flow editor and the endpoints flows expose" + } + ], + "data": { + "own": [ + { + "id": "data", + "path": "${dir:data}", + "class": "valuable", + "why": "flows and their credentials" + } + ] + }, + "config": { + "flow-file": { + "type": "string", + "description": "Node-RED's flowFile setting: the file in the user directory the flows are kept in.", + "trusted": true, + "default": "flows.json" + }, + "flow-file-pretty": { + "type": "boolean", + "description": "Node-RED's flowFilePretty setting: whether the flows file is written indented.", + "trusted": true, + "default": true + }, + "diagnostics": { + "type": "object", + "description": "Node-RED's diagnostics setting: whether its diagnostics report is on, and shown in the editor.", + "trusted": true, + "default": { + "enabled": true, + "ui": true + } + }, + "runtime-state": { + "type": "object", + "description": "Node-RED's runtimeState setting: whether flows may be started and stopped from the editor.", + "trusted": true, + "default": { + "enabled": false, + "ui": false + } + }, + "logging": { + "type": "object", + "description": "Node-RED's logging setting: the console log's level, and whether metrics and audit events are logged.", + "trusted": true, + "default": { + "console": { + "level": "info", + "metrics": false, + "audit": false + } + } + }, + "export-global-context-keys": { + "type": "boolean", + "description": "Node-RED's exportGlobalContextKeys setting: whether a function node may list the global context's keys.", + "trusted": true, + "default": false + }, + "external-modules": { + "type": "object", + "description": "Node-RED's externalModules setting: which modules the editor may install, and from where.", + "trusted": true, + "default": {} + }, + "editor-theme": { + "type": "object", + "description": "Node-RED's editorTheme setting: the editor's look and features, projects among them.", + "trusted": true, + "default": { + "projects": { + "enabled": false + } + } + }, + "function-external-modules": { + "type": "boolean", + "description": "Node-RED's functionExternalModules setting: whether a function node may load modules of its own.", + "trusted": true, + "default": true + }, + "debug-max-length": { + "type": "number", + "description": "Node-RED's debugMaxLength setting: the most characters a debug message shows, a count.", + "trusted": true, + "default": 1000 + }, + "mqtt-reconnect-time": { + "type": "number", + "description": "Node-RED's mqttReconnectTime setting: how long an MQTT node waits before reconnecting, in milliseconds.", + "trusted": true, + "default": 15000 + }, + "serial-reconnect-time": { + "type": "number", + "description": "Node-RED's serialReconnectTime setting: how long a serial node waits before reconnecting, in milliseconds.", + "trusted": true, + "default": 15000 + }, + "mqtt": { + "type": "object", + "description": "The broker nodes the module's MQTT step keeps pointed at the mesh's broker, as {\"brokers\": [their ids]}; empty, none.", + "trusted": true, + "default": {} + }, + "time-zone": { + "type": "string", + "description": "The time zone the flows schedule and format in, an IANA name such as Europe/Brussels; unset, the container's UTC.", + "trusted": true, + "default": "" + } + }, + "resources": [ + { + "id": "mesh-state", + "type": "directory", + "mode": "0700", + "place": "mesh", + "unknown": "program" + }, + { + "id": "state", + "type": "directory", + "mode": "0700", + "place": ".", + "unknown": "program" + }, + { + "id": "data", + "type": "directory", + "mode": "0700", + "owner": "1000:1000", + "unknown": "program" + }, + { + "id": "written", + "type": "directory", + "mode": "0700", + "unknown": "program" + }, + { + "id": "settings-code", + "type": "file", + "path": "${dir:state}/settings.js", + "mode": "0600", + "owner": "1000:1000", + "template": "files/settings.js" + }, + { + "id": "settings", + "type": "file", + "path": "${dir:state}/settings.json", + "mode": "0600", + "owner": "1000:1000", + "merge": "json", + "takes-config": [{"key": "flow-file", "as": "flowFile"}, {"key": "flow-file-pretty", "as": "flowFilePretty"}, "diagnostics", {"key": "runtime-state", "as": "runtimeState"}, "logging", {"key": "export-global-context-keys", "as": "exportGlobalContextKeys"}, {"key": "external-modules", "as": "externalModules"}, {"key": "editor-theme", "as": "editorTheme"}, {"key": "function-external-modules", "as": "functionExternalModules"}, {"key": "debug-max-length", "as": "debugMaxLength"}, {"key": "mqtt-reconnect-time", "as": "mqttReconnectTime"}, {"key": "serial-reconnect-time", "as": "serialReconnectTime"}, "mqtt", {"key": "time-zone", "as": "timeZone"}], + "template": "files/settings.json" + }, + { + "id": "server", + "type": "container", + "name": "nodered", + "image": "nodered/node-red@sha256:a649dd711d55490151a2c39a8e48ad0c44325488fbc0e66315f2d2e19e5e1ace", + "health": { + "kind": "runtime" + }, + "env": { + "TZ": "Etc/UTC" + }, + "ports": [ + "1880" + ], + "args": [ + "--settings", + "/data/settings.js" + ], + "volumes": [ + "${dir:data}:/data", + "${dir:state}/settings.js:/data/settings.js:ro", + "${dir:state}/settings.json:/data/settings.json:ro" + ], + "restart-on": [ + "settings-code", + "settings" + ] + }, + { + "id": "runtime-config", + "type": "file", + "path": "${dir:mesh-state}/config.json", + "mode": "0600", + "template": "files/config.json" + }, + { + "id": "mqtt-env", + "type": "file", + "path": "${dir:state}/mqtt.env", + "mode": "0600", + "template": "files/mqtt.env" + }, + { + "id": "mqtt", + "type": "process", + "name": "nodered-mqtt", + "artifact": "code", + "run": [ + "node", + "mqtt/index.js" + ], + "run-once": true, + "env-file": [ + "${dir:state}/mqtt.env" + ], + "restart-on": [ + "mqtt-env", + "bound-mqtt-topic", + "secret-mqtt-topic", + "settings" + ] + } + ], + "requires": [ + "mqtt-topic", + "route" + ], + "contributes": { + "mqtt-topic": { + "topics": [ + "#" + ] + }, + "route": { + "label": "nodered", + "endpoint": "web" + } + }, + "binds": { + "route": "${dir:state}/route.json", + "mqtt-topic": "${dir:state}/mqtt-topic.json" + }, + "secrets": { + "mqtt-topic": "${dir:state}/mqtt-topic.secret" + }, + "build": { + "artifacts": [ + { + "name": "code", + "kind": "bundle", + "language": "typescript", + "entrypoints": [ + "tools/index.js", + "mqtt/index.js" + ], + "loads": [ + "tools/index.js" + ], + "env": { + "MESH_NODERED_URL": "http://127.0.0.1:${port:1880}", + "MESH_NODERED_CONFIG_FILE": "${dir:mesh-state}/config.json" + } + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/go/testdata/beside/mesh-catalog/modules/ollama/module.json.captured b/go/testdata/beside/mesh-catalog/modules/ollama/module.json.captured new file mode 100644 index 0000000..2d5fb48 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/ollama/module.json.captured @@ -0,0 +1,62 @@ +{ + "module": "ollama", + "version": "1", + "capabilities": [ + "container-runtime" + ], + "provides": [ + { + "name": "model-access", + "scope": "node" + } + ], + "listens": [ + { + "name": "api", + "port": 11434, + "protocol": "tcp", + "from": "machine", + "why": "consumers on this machine reaching the local model server's OpenAI-compatible API" + } + ], + "serves": { + "model-access": { + "port": 11434, + "model": "llama3.2" + } + }, + "data": { + "own": [ + { + "id": "models", + "path": "${dir:state}", + "class": "rebuildable", + "backup": "none", + "measure": "shallow", + "why": "models, downloaded again, and too large to copy every night" + } + ] + }, + "resources": [ + { + "id": "state", + "type": "directory", + "path": "/services/ollama", + "mode": "0700", + "unknown": "program" + }, + { + "id": "server", + "type": "container", + "name": "ollama", + "image": "ollama/ollama@sha256:32931b46719f673c05fdbaa81ccb26da18ea4a1c57590a754874ab28ba269eb2", + "network": "host", + "env": { + "OLLAMA_HOST": "0.0.0.0:11434" + }, + "volumes": [ + "/services/ollama:/root/.ollama" + ] + } + ] +} diff --git a/go/testdata/beside/mesh-catalog/modules/only-office/files/server.env b/go/testdata/beside/mesh-catalog/modules/only-office/files/server.env new file mode 100644 index 0000000..19fab11 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/only-office/files/server.env @@ -0,0 +1,5 @@ +JWT_ENABLED=true +JWT_SECRET=${secret:jwt} +JWT_HEADER=Authorization +JWT_IN_BODY=true +ALLOW_PRIVATE_IP_ADDRESS=true diff --git a/go/testdata/beside/mesh-catalog/modules/only-office/module.json.captured b/go/testdata/beside/mesh-catalog/modules/only-office/module.json.captured new file mode 100644 index 0000000..4af651f --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/only-office/module.json.captured @@ -0,0 +1,173 @@ +{ + "module": "only-office", + "version": "1", + "slug": "office", + "capabilities": [ + "container-runtime" + ], + "requires": [ + "route" + ], + "contributes": { + "route": { + "label": "office", + "endpoint": "web" + } + }, + "binds": { + "route": "${dir:state}/route.json" + }, + "own-secrets": { + "jwt": "${dir:state}/jwt.secret" + }, + "listens": [ + { + "name": "web", + "port": 9070, + "protocol": "tcp", + "from": "mesh", + "why": "the document server over http; its public name is a route grant, and route-proxy reaches it on this published port" + } + ], + "data": { + "own": [ + { + "id": "data", + "path": "${dir:data}", + "class": "rebuildable", + "why": "documents being edited; the documents themselves are the file store's" + }, + { + "id": "lib", + "path": "${dir:lib}", + "class": "rebuildable", + "why": "the document server's working files" + }, + { + "id": "db", + "path": "${dir:db}", + "class": "rebuildable", + "why": "the document server's own database of editing sessions" + }, + { + "id": "fonts", + "path": "${dir:fonts}", + "class": "rebuildable", + "why": "fonts, installed again" + }, + { + "id": "logs", + "path": "${dir:logs}", + "class": "cache", + "why": "logs" + }, + { + "id": "rabbitmq", + "path": "${dir:rabbitmq}", + "class": "cache", + "why": "its queue's working set" + }, + { + "id": "redis", + "path": "${dir:redis}", + "class": "cache", + "why": "its cache" + } + ] + }, + "resources": [ + { + "id": "state", + "type": "directory", + "mode": "0700", + "place": ".", + "unknown": "program" + }, + { + "id": "server-env", + "type": "file", + "path": "${dir:state}/server.env", + "mode": "0600", + "template": "files/server.env" + }, + { + "id": "logs", + "type": "directory", + "mode": "0700", + "unknown": "program" + }, + { + "id": "data", + "type": "directory", + "mode": "0700", + "unknown": "program" + }, + { + "id": "lib", + "type": "directory", + "mode": "0700", + "unknown": "program" + }, + { + "id": "db", + "type": "directory", + "mode": "0700", + "unknown": "program" + }, + { + "id": "rabbitmq", + "type": "directory", + "mode": "0700", + "unknown": "program" + }, + { + "id": "redis", + "type": "directory", + "mode": "0700", + "unknown": "program" + }, + { + "id": "fonts", + "type": "directory", + "mode": "0700", + "unknown": "program" + }, + { + "id": "net", + "type": "network", + "name": "only-office" + }, + { + "id": "server", + "type": "container", + "name": "only-office", + "image": "onlyoffice/documentserver@sha256:3ab6ebc7c605e5a32b7ae3ff19daed4925090245acc8100ce2230bd766c88212", + "network": "only-office", + "env-file": [ + "${dir:state}/server.env" + ], + "ports": [ + "9070:80" + ], + "volumes": [ + "${dir:logs}:/var/log/onlyoffice", + "${dir:data}:/var/www/onlyoffice/Data", + "${dir:lib}:/var/lib/onlyoffice", + "${dir:db}:/var/lib/postgresql", + "${dir:rabbitmq}:/var/lib/rabbitmq", + "${dir:redis}:/var/lib/redis", + "${dir:fonts}:/usr/share/fonts/truetype/custom" + ], + "secrets-in-environment": "run-document-server.sh regenerates JWT_SECRET from the environment on every start and overwrites local.json; not convertible" + } + ], + "build": { + "artifacts": [ + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/go/testdata/beside/mesh-catalog/modules/openai-consumer/module.json.captured b/go/testdata/beside/mesh-catalog/modules/openai-consumer/module.json.captured new file mode 100644 index 0000000..2e98414 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/openai-consumer/module.json.captured @@ -0,0 +1,61 @@ +{ + "module": "openai-consumer", + "version": "1", + "slug": "openai", + "capabilities": [ + "container-runtime" + ], + "requires": [ + "model-access" + ], + "binds": { + "model-access": "${dir:state}/model.json" + }, + "secrets": { + "model-access": "${dir:state}/api-key" + }, + "resources": [ + { + "id": "state", + "type": "directory", + "mode": "0700", + "place": ".", + "unknown": "program" + }, + { + "id": "config", + "type": "directory", + "mode": "0700", + "unknown": "program" + }, + { + "id": "apply", + "type": "process", + "name": "openai-consumer-apply", + "artifact": "code", + "run": [ + "node", + "apply/index.js" + ], + "schedule": "*/5 * * * *", + "env": { + "MESH_MODEL_ACCESS_SECRET_FILE": "${dir:state}/api-key", + "MESH_MODEL_ACCESS_BIND_FILE": "${dir:state}/model.json", + "MESH_OPENAI_ENV_FILE": "${dir:state}/config/openai.env", + "MESH_OPENAI_CREDENTIALS_FILE": "${dir:state}/config/auth.json" + } + } + ], + "build": { + "artifacts": [ + { + "name": "code", + "kind": "bundle", + "language": "typescript", + "entrypoints": [ + "apply/index.js" + ] + } + ] + } +} diff --git a/go/testdata/beside/mesh-catalog/modules/openrazer/module.json.captured b/go/testdata/beside/mesh-catalog/modules/openrazer/module.json.captured new file mode 100644 index 0000000..faa34f8 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/openrazer/module.json.captured @@ -0,0 +1,52 @@ +{ + "module": "openrazer", + "version": "1", + "capabilities": [ + "package-manager" + ], + "tools": [ + "openrazer_status", + "openrazer_restart", + "openrazer_check" + ], + "resources": [ + { + "id": "driver", + "type": "package", + "package": "openrazer-driver-dkms" + }, + { + "id": "daemon", + "type": "package", + "package": "openrazer-daemon" + }, + { + "id": "library", + "type": "package", + "package": "python-openrazer" + }, + { + "id": "account", + "type": "user", + "name": "${machine:account}", + "groups": [ + "openrazer" + ] + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/openrazer-tools", + "binary": "openrazer-tools", + "loads": [ + "openrazer-tools" + ] + } + ] + } +} diff --git a/go/testdata/beside/mesh-catalog/modules/pacman/files/pacman.conf b/go/testdata/beside/mesh-catalog/modules/pacman/files/pacman.conf new file mode 100644 index 0000000..0390e25 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/pacman/files/pacman.conf @@ -0,0 +1,29 @@ +# The mesh's (module pacman, novox/hq to-be 42): the package manager's configuration. Written +# whole at every push: an edit here is overwritten, and the file a machine had before is kept once by +# the host. Owned whole because [options] cannot take a block by appending: anything added at the end +# of the file lands in the last repository's section. +# +# The repositories are the union of what the machines had enabled when the module was written +# (core, extra, multilib). The options are the distribution's defaults with four more: colour on a +# terminal, parallel downloads, package lists in columns, and downloads run as the unprivileged +# alpm user, which pacman 7 creates. + +[options] +HoldPkg = pacman glibc +Architecture = auto +CheckSpace +Color +VerbosePkgLists +ParallelDownloads = 5 +DownloadUser = alpm +SigLevel = Required DatabaseOptional +LocalFileSigLevel = Optional + +[core] +Include = /etc/pacman.d/mirrorlist + +[extra] +Include = /etc/pacman.d/mirrorlist + +[multilib] +Include = /etc/pacman.d/mirrorlist diff --git a/go/testdata/beside/mesh-catalog/modules/pacman/files/reflector.conf b/go/testdata/beside/mesh-catalog/modules/pacman/files/reflector.conf new file mode 100644 index 0000000..eb9e731 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/pacman/files/reflector.conf @@ -0,0 +1,8 @@ +# The mesh's (module pacman, novox/hq to-be 42): how reflector refreshes the mirror list, weekly, +# through reflector.timer. Written whole at every push. Before the module, every machine's list was +# generated once and never again. +--save /etc/pacman.d/mirrorlist +--protocol https +--country Belgium,Netherlands,Luxembourg,Germany,France +--latest 20 +--sort rate diff --git a/go/testdata/beside/mesh-catalog/modules/pacman/module.json.captured b/go/testdata/beside/mesh-catalog/modules/pacman/module.json.captured new file mode 100644 index 0000000..746c8c5 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/pacman/module.json.captured @@ -0,0 +1,96 @@ +{ + "module": "pacman", + "version": "1", + "capabilities": [ + "package-manager", + "service-manager" + ], + "claims": [ + { + "name": "node-package-manager", + "scope": "node" + } + ], + "tools": [ + "pacman_search", + "pacman_info", + "pacman_installed", + "pacman_owns", + "pacman_files", + "pacman_updates", + "pacman_upgrade", + "pacman_orphans", + "pacman_remove_orphans", + "pacman_cache", + "pacman_history", + "pacman_mirrors", + "pacman_foreign", + "pacman_news", + "pacman_config" + ], + "resources": [ + { + "id": "package", + "type": "package", + "package": "pacman" + }, + { + "id": "config", + "type": "file", + "path": "/etc/pacman.conf", + "mode": "0644", + "template": "files/pacman.conf" + }, + { + "id": "contrib", + "type": "package", + "package": "pacman-contrib" + }, + { + "id": "reflector", + "type": "package", + "package": "reflector" + }, + { + "id": "mirrors", + "type": "file", + "path": "/etc/xdg/reflector/reflector.conf", + "mode": "0644", + "template": "files/reflector.conf" + }, + { + "id": "mirror-refresh", + "type": "service", + "unit": "reflector.timer", + "state": "running", + "boot": "enabled" + }, + { + "id": "cache-cleaning", + "type": "service", + "unit": "paccache.timer", + "state": "running", + "boot": "enabled" + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/pacman-tools", + "binary": "pacman-tools", + "loads": [ + "pacman-tools" + ] + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/go/testdata/beside/mesh-catalog/modules/photos-eef/module.json.captured b/go/testdata/beside/mesh-catalog/modules/photos-eef/module.json.captured new file mode 100644 index 0000000..101bdc2 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/photos-eef/module.json.captured @@ -0,0 +1,56 @@ +{ + "module": "photos-eef", + "version": "1", + "slug": "eef", + "capabilities": [ + "container-runtime" + ], + "requires": [ + "route" + ], + "contributes": { + "route": { + "label": "eef", + "endpoint": "web" + } + }, + "binds": { + "route": "${dir:state}/route.json" + }, + "listens": [ + { + "name": "web", + "port": 4012, + "protocol": "tcp", + "from": "mesh", + "why": "the eef photos client site over http; its public name is a route grant, and route-proxy reaches it on this published port" + } + ], + "resources": [ + { + "id": "state", + "type": "directory", + "mode": "0700", + "place": ".", + "unknown": "program" + }, + { + "id": "net", + "type": "network", + "name": "photos-eef" + }, + { + "id": "client", + "type": "container", + "name": "photos-eef", + "image": "registry-api.novox.be/novox/photos-client@sha256:f87d63ee7bfb44c9f9748b99be6ba6dc0daf955a1d463699e9e7e3009693c0ab", + "network": "photos-eef", + "ports": [ + "4012:80" + ], + "names-on-purpose": { + "registry-api.novox.be": "built outside the mesh, from the application's own repository, and pulled from the registry that built it; moves when that repository is a build source on the git seat (novox/hq ADR 0155, issue 122)" + } + } + ] +} diff --git a/go/testdata/beside/mesh-catalog/modules/photos-filip/module.json.captured b/go/testdata/beside/mesh-catalog/modules/photos-filip/module.json.captured new file mode 100644 index 0000000..9c1c83f --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/photos-filip/module.json.captured @@ -0,0 +1,56 @@ +{ + "module": "photos-filip", + "version": "1", + "slug": "filip", + "capabilities": [ + "container-runtime" + ], + "requires": [ + "route" + ], + "contributes": { + "route": { + "label": "filip", + "endpoint": "web" + } + }, + "binds": { + "route": "${dir:state}/route.json" + }, + "listens": [ + { + "name": "web", + "port": 4013, + "protocol": "tcp", + "from": "mesh", + "why": "the filip photos client site over http; its public name is a route grant, and route-proxy reaches it on this published port" + } + ], + "resources": [ + { + "id": "state", + "type": "directory", + "mode": "0700", + "place": ".", + "unknown": "program" + }, + { + "id": "net", + "type": "network", + "name": "photos-filip" + }, + { + "id": "client", + "type": "container", + "name": "photos-filip", + "image": "registry-api.novox.be/novox/photos-client@sha256:f87d63ee7bfb44c9f9748b99be6ba6dc0daf955a1d463699e9e7e3009693c0ab", + "network": "photos-filip", + "ports": [ + "4013:80" + ], + "names-on-purpose": { + "registry-api.novox.be": "built outside the mesh, from the application's own repository, and pulled from the registry that built it; moves when that repository is a build source on the git seat (novox/hq ADR 0155, issue 122)" + } + } + ] +} diff --git a/go/testdata/beside/mesh-catalog/modules/picom/files/picom.conf b/go/testdata/beside/mesh-catalog/modules/picom/files/picom.conf new file mode 100644 index 0000000..c6b8555 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/picom/files/picom.conf @@ -0,0 +1,38 @@ +# picom, the X compositor (module picom, novox/hq ADR 0208). Owned by the mesh: this file is +# replaced at every push. Adopted from the two workstations' file of 2026-10-04 and moved to +# picom's window rules, which supersede opacity-rule, inactive-opacity and inactive-dim. + +# The backend and damage tracking are each machine's settings (backend, use-damage; novox/hq research +# 037, issue 345): the right ones depend on the machine's graphics driver. The defaults are what every +# machine ran before: glx, and picom's own default of repainting only what changed. xrender, or +# use-damage false (repaint the whole screen each frame), is what to try on a machine where a window is +# left unpainted or stale. +backend = "${config:backend}"; +use-damage = ${config:use-damage}; +vsync = true; + +fading = true; +fade-in-step = 0.05; +fade-out-step = 0.05; + +# Tiled windows hide their shadows and corners, so neither is drawn. +shadow = false; +corner-radius = 0; +blur-method = "none"; + +# "Focused" is i3's _NET_ACTIVE_WINDOW, not X focus events: under i3 those do not reliably reach +# the toplevel picom tracks, and a terminal then never lifts to its focused opacity. +use-ewmh-active-win = true; + +# Window rules are applied in order, and a later match wins. Only terminals are translucent: +# nothing else on screen (browsers, video, games) is touched. A terminal is matched by its class; +# the node's terminal emulator today is xterm (class XTerm). A window's own opacity property is +# used wherever no rule sets one, which is what the window-opacity tool sets. +rules = ( + { match = "window_type = 'tooltip'"; fade = false; opacity = 0.95; }, + { match = "window_type = 'dock' || window_type = 'desktop'"; fade = false; }, + { match = "class_g = 'XTerm' && focused"; opacity = 0.95; }, + { match = "class_g = 'XTerm' && !focused"; opacity = 0.75; }, + # A full-screen window is opaque, a terminal included: a video or a game is never see-through. + { match = "fullscreen"; opacity = 1; } +); diff --git a/go/testdata/beside/mesh-catalog/modules/picom/module.json.captured b/go/testdata/beside/mesh-catalog/modules/picom/module.json.captured new file mode 100644 index 0000000..cfdc397 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/picom/module.json.captured @@ -0,0 +1,90 @@ +{ + "module": "picom", + "version": "1", + "capabilities": [ + "package-manager" + ], + "requires": [ + "x11-display" + ], + "claims": [ + { + "name": "node-compositor", + "scope": "node" + } + ], + "config": { + "backend": { + "type": "string", + "description": "How picom draws: glx with the graphics card, which has served every machine so far, or xrender on the CPU where glx leaves a window unpainted or stale on the machine's driver (novox/hq research 037, issue 345); xr_glx_hybrid and egl are picom's other backends.", + "enum": [ + "glx", + "xrender", + "xr_glx_hybrid", + "egl" + ], + "trusted": true, + "default": "glx" + }, + "use-damage": { + "type": "boolean", + "description": "Whether picom repaints only what changed (true, picom's own default) or the whole screen each frame (false), which costs work and cures a window left stale or unpainted on some drivers (novox/hq issue 345).", + "trusted": true, + "default": true + } + }, + "tools": [ + "picom_restart", + "picom_rules", + "picom_window_opacity", + "picom_toggle" + ], + "resources": [ + { + "id": "package", + "type": "package", + "package": "picom" + }, + { + "id": "window-properties", + "type": "package", + "package": "xorg-xprop" + }, + { + "id": "configuration-dir", + "type": "directory", + "path": "${machine:account-home}/.config/picom", + "owner": "${machine:account}", + "mode": "0755", + "unknown": "move-out" + }, + { + "id": "configuration", + "type": "file", + "path": "${machine:account-home}/.config/picom/picom.conf", + "owner": "${machine:account}", + "mode": "0644", + "template": "files/picom.conf" + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/picom-tools", + "binary": "picom-tools", + "loads": [ + "picom-tools" + ] + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/go/testdata/beside/mesh-catalog/modules/polychromatic/files/display-session b/go/testdata/beside/mesh-catalog/modules/polychromatic/files/display-session new file mode 100644 index 0000000..bbdbd6f --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/polychromatic/files/display-session @@ -0,0 +1,6 @@ +# 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 diff --git a/go/testdata/beside/mesh-catalog/modules/polychromatic/module.json.captured b/go/testdata/beside/mesh-catalog/modules/polychromatic/module.json.captured new file mode 100644 index 0000000..4d6ba4b --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/polychromatic/module.json.captured @@ -0,0 +1,40 @@ +{ + "module": "polychromatic", + "version": "1", + "moved": true, + "requires": [ + "x11-display" + ], + "tools": [ + "polychromatic_status", + "polychromatic_restart", + "polychromatic_check" + ], + "items": [ + { + "collection": "contribution:node-display-session:config", + "name": "display-session", + "template": "files/display-session" + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/polychromatic-tools", + "binary": "polychromatic-tools", + "loads": [ + "polychromatic-tools" + ] + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/go/testdata/beside/mesh-catalog/modules/postgres/files/consumers.json b/go/testdata/beside/mesh-catalog/modules/postgres/files/consumers.json new file mode 100644 index 0000000..30d160e --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/postgres/files/consumers.json @@ -0,0 +1 @@ +${consumers:postgres-database} diff --git a/go/testdata/beside/mesh-catalog/modules/postgres/module.json.captured b/go/testdata/beside/mesh-catalog/modules/postgres/module.json.captured new file mode 100644 index 0000000..919df42 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/postgres/module.json.captured @@ -0,0 +1,191 @@ +{ + "module": "postgres", + "version": "1", + "moved": true, + "upgrade": { + "policy": "record", + "why": "a provider whose restart drops every consumer on its machine, and whose new major version changes its data's format: a person takes each build, after a backup (hq ADR 0236)" + }, + "provides": [ + { + "name": "postgres-database", + "scope": "mesh", + "identity": { + "max": 63, + "in": "a PostgreSQL role and database name" + } + } + ], + "claims": [ + { + "name": "mesh-store", + "scope": "mesh", + "serves": [ + "databases", + "query" + ] + } + ], + "capabilities": [ + "container-runtime" + ], + "emits": [ + "database.provisioned", + "database.deprovisioned" + ], + "consumes": [ + "postgres.database.provisioned", + "postgres.database.deprovisioned" + ], + "listens": [ + { + "name": "database", + "port": 5432, + "protocol": "tcp", + "from": "mesh", + "why": "modules on any machine that were granted a database" + } + ], + "guards": [ + 5432 + ], + "serves": { + "postgres-database": { + "port": 5432 + } + }, + "receives": { + "postgres-database": "${dir:grants}/mesh.json" + }, + "grants": { + "postgres-database": "${dir:grants}" + }, + "own-secrets": { + "superuser": "${dir:state}/superuser.secret", + "reader": "${dir:state}/reader.secret" + }, + "data": { + "own": [ + { + "id": "store", + "path": "${dir:store-data}", + "class": "valuable", + "backup": { + "dump": "docker exec -u postgres postgres sh -c 'cd /var/lib/postgresql/data/dumps && for db in $(psql -Atc \"select datname from pg_database where oid >= 16384 order by 1\"); do pg_dump -Fc -f \"$db.dump.partial\" \"$db\" && mv \"$db.dump.partial\" \"$db.dump\" || exit 1; done'", + "into": "dumps" + }, + "why": "every consumer's database; copied by the dump below, since a running store's files are not a consistent copy" + }, + { + "id": "dumps", + "path": "${dir:dumps}", + "class": "rebuildable", + "why": "last night's dump of the store, made again every night" + } + ], + "consumers": { + "postgres-database": { + "class": "valuable", + "in": "store", + "why": "a consumer's rows are the only copy of what it wrote" + } + } + }, + "resources": [ + { + "id": "state", + "type": "directory", + "mode": "0700", + "place": ".", + "unknown": "program" + }, + { + "id": "grants", + "type": "directory", + "mode": "0700", + "unknown": "program" + }, + { + "id": "consumers", + "type": "file", + "path": "${dir:state}/consumers.json", + "mode": "0600", + "template": "files/consumers.json" + }, + { + "id": "store-data", + "type": "directory", + "path": "/var/lib/mesh-store", + "mode": "0700", + "owner": "999:70", + "unknown": "program" + }, + { + "id": "dumps", + "type": "directory", + "path": "${dir:store-data}/dumps", + "mode": "0700", + "owner": "999:70", + "unknown": "program" + }, + { + "id": "server", + "type": "container", + "name": "postgres", + "image": "pgvector/pgvector@sha256:cf134a767f474095eeba57e0117be8e568e011a63f33fbf252f14c9b760f8e6f", + "health": { + "kind": "tcp", + "endpoint": "database" + }, + "env": { + "POSTGRES_PASSWORD_FILE": "/run/secrets/superuser", + "PGDATA": "/var/lib/postgresql/data/pgdata" + }, + "ports": [ + "5432:5432" + ], + "volumes": [ + "/var/lib/mesh-store:/var/lib/postgresql/data", + "${dir:state}/superuser.secret:/run/secrets/superuser:ro" + ] + }, + { + "id": "client", + "type": "package", + "package": "postgresql-libs" + } + ], + "build": { + "artifacts": [ + { + "name": "code", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/postgres-provider", + "binary": "postgres-provider", + "loads": [ + "postgres-provider" + ], + "env": { + "MESH_PROVISION_POSTGRES": "postgres://postgres@127.0.0.1:${port:5432}/postgres?sslmode=disable", + "MESH_PROVISION_PASSWORD_FILE": "${dir:state}/superuser.secret", + "MESH_RECEIVES": "${dir:grants}/mesh.json", + "MESH_POSTGRES_READER_PASSWORD_FILE": "${dir:state}/reader.secret", + "MESH_CONSUMERS_FILE": "${dir:state}/consumers.json" + } + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + }, + "state": [ + "bound" + ], + "reads": [ + "mesh-controller.assignments" + ] +} diff --git a/go/testdata/beside/mesh-catalog/modules/power/files/bin/power-moment b/go/testdata/beside/mesh-catalog/modules/power/files/bin/power-moment new file mode 100644 index 0000000..e7095e4 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/power/files/bin/power-moment @@ -0,0 +1,90 @@ +#!/bin/sh +# power-moment MOMENT [--force] — run every module's code for one power moment (novox/hq ADR 0211). +# Shipped by the mesh's power module; edit the catalogue. +# +# MOMENT is after-boot, before-sleep, after-wake, before-shutdown, on-mains or on-battery, or +# `supply`, which reads the machine's power source and runs on-mains or on-battery once per change. +# +# The moment's file holds what the controller placed: each module's code under a line `# `. +# Each module's piece runs on its own, with sh, as root, bounded by MESH_POWER_BOUND seconds +# (default 30) and killed 5 s after that, so one module that hangs cannot hold the machine awake or +# stop the next module. Every outcome goes to the journal under the tag mesh-power. +set -u + +moment=${1:-} +force=${2:-} +moments=/etc/mesh-power/moments +bound=${MESH_POWER_BOUND:-30} +runtime=/run/mesh-power + +say() { logger -t mesh-power -- "$*" 2>/dev/null || true; printf '%s\n' "$*"; } + +# The power source as the kernel says it: on mains when any Mains supply is online. A machine with +# no Mains supply (a desktop, a server, a USB-C source only) has no source to change. +source_now() { + found=no + for s in /sys/class/power_supply/*; do + [ "$(cat "$s/type" 2>/dev/null)" = Mains ] || continue + found=yes + [ "$(cat "$s/online" 2>/dev/null)" = 1 ] && { echo on-mains; return; } + done + [ "$found" = yes ] && echo on-battery || echo none +} + +case "$moment" in +supply) + now=$(source_now) + [ "$now" = none ] && exit 0 + mkdir -p "$runtime" + last=$(cat "$runtime/source" 2>/dev/null || true) + [ "$now" = "$last" ] && [ "$force" != --force ] && exit 0 + echo "$now" >"$runtime/source" + moment=$now + ;; +after-boot | before-sleep | after-wake | on-mains | on-battery) ;; +before-shutdown) + # Run only while the machine is really going down. The unit that runs it is stopped at shutdown, + # and also when the module is taken off the machine; the second must not run anyone's shutdown code. + state=$(systemctl is-system-running 2>/dev/null || true) + if [ "$state" != stopping ] && [ "$force" != --force ]; then + say "moment=before-shutdown skipped: the system is $state, not stopping" + exit 0 + fi + ;; +*) + echo "power-moment: no moment $moment (after-boot, before-sleep, after-wake, before-shutdown, on-mains, on-battery, supply)" >&2 + exit 2 + ;; +esac + +file="$moments/$moment" +[ -r "$file" ] || { say "moment=$moment: nothing placed"; exit 0; } + +work=$(mktemp -d) || exit 1 +trap 'rm -rf "$work"' EXIT + +# One file per module piece, in the order the controller placed them. A line that is exactly +# "# " opens a piece; lines before the first are the holder's own header. +awk -v dir="$work" ' + /^# [a-z0-9][a-z0-9-]*$/ { n++; name = substr($0, 3); out = sprintf("%s/%03d-%s", dir, n, name); next } + n > 0 { print > out } +' "$file" + +ran=0 failed=0 +for piece in "$work"/*; do + [ -e "$piece" ] || continue + module=${piece##*/} + module=${module#*-} + start=$(date +%s) + timeout --kill-after=5 "$bound" sh "$piece" "$piece.out" 2>&1 + rc=$? + took=$(($(date +%s) - start)) + ran=$((ran + 1)) + case $rc in + 0) say "moment=$moment module=$module ok in ${took}s" ;; + 124 | 137) failed=$((failed + 1)); say "moment=$moment module=$module ended after ${bound}s: it did not finish" ;; + *) failed=$((failed + 1)); say "moment=$moment module=$module exited $rc: $(tail -n 3 "$piece.out" | tr '\n' ' ')" ;; + esac +done +say "moment=$moment: $ran piece(s), $failed failed" +exit 0 diff --git a/go/testdata/beside/mesh-catalog/modules/power/module.json.captured b/go/testdata/beside/mesh-catalog/modules/power/module.json.captured new file mode 100644 index 0000000..a110d41 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/power/module.json.captured @@ -0,0 +1,408 @@ +{ + "module": "power", + "version": "1", + "moved": true, + "templates": "templates", + "capabilities": [ + "package-manager", + "service-manager" + ], + "claims": [ + { + "name": "node-power", + "scope": "node" + } + ], + "emits": [ + "booted", + "sleeping", + "woke", + "shutting-down", + "on-mains", + "on-battery", + "battery-low" + ], + "state": [ + { + "name": "draw", + "ttl-seconds": 30, + "per-machine": true + } + ], + "tools": [ + "power_state", + "power_hooks", + "power_history", + "power_run", + "power_check" + ], + "config": { + "handle-power-key": { + "type": "string", + "description": "What logind does when the power key is pressed: one of logind's actions, such as suspend, ignore or poweroff (logind.conf(5)); the operator's choice for this machine, with no default.", + "enum": [ + "ignore", + "poweroff", + "reboot", + "halt", + "kexec", + "suspend", + "hibernate", + "hybrid-sleep", + "suspend-then-hibernate", + "lock", + "factory-reset", + "sleep" + ], + "trusted": true, + "required": true + }, + "handle-lid-switch": { + "type": "string", + "description": "What logind does when the lid is closed on battery: one of logind's actions, such as suspend, ignore or poweroff (logind.conf(5)); the operator's choice for this machine, with no default.", + "enum": [ + "ignore", + "poweroff", + "reboot", + "halt", + "kexec", + "suspend", + "hibernate", + "hybrid-sleep", + "suspend-then-hibernate", + "lock", + "factory-reset", + "sleep" + ], + "trusted": true, + "required": true + }, + "handle-lid-switch-external-power": { + "type": "string", + "description": "What logind does when the lid is closed on external power: one of logind's actions, such as suspend, ignore or poweroff (logind.conf(5)); the operator's choice for this machine, with no default.", + "enum": [ + "ignore", + "poweroff", + "reboot", + "halt", + "kexec", + "suspend", + "hibernate", + "hybrid-sleep", + "suspend-then-hibernate", + "lock", + "factory-reset", + "sleep" + ], + "trusted": true, + "required": true + }, + "handle-lid-switch-docked": { + "type": "string", + "description": "What logind does when the lid is closed while docked or with another screen attached: one of logind's actions, such as suspend, ignore or poweroff (logind.conf(5)); the operator's choice for this machine, with no default.", + "enum": [ + "ignore", + "poweroff", + "reboot", + "halt", + "kexec", + "suspend", + "hibernate", + "hybrid-sleep", + "suspend-then-hibernate", + "lock", + "factory-reset", + "sleep" + ], + "trusted": true, + "required": true + } + }, + "resources": [ + { + "id": "polkit", + "type": "package", + "package": "polkit" + }, + { + "id": "scripts", + "type": "archive", + "path": "/usr/local/lib/mesh-power", + "artifact": "scripts", + "unknown": "program" + }, + { + "id": "config-dir", + "type": "directory", + "path": "/etc/mesh-power", + "mode": "0755", + "unknown": "program" + }, + { + "id": "moments-dir", + "type": "directory", + "path": "/etc/mesh-power/moments", + "mode": "0755", + "unknown": "program" + }, + { + "id": "moment-after-boot", + "type": "file", + "path": "/etc/mesh-power/moments/after-boot", + "mode": "0644", + "template": "templates/mesh-power/moments/after-boot" + }, + { + "id": "moment-before-sleep", + "type": "file", + "path": "/etc/mesh-power/moments/before-sleep", + "mode": "0644", + "template": "templates/mesh-power/moments/before-sleep" + }, + { + "id": "moment-after-wake", + "type": "file", + "path": "/etc/mesh-power/moments/after-wake", + "mode": "0644", + "template": "templates/mesh-power/moments/after-wake" + }, + { + "id": "moment-before-shutdown", + "type": "file", + "path": "/etc/mesh-power/moments/before-shutdown", + "mode": "0644", + "template": "templates/mesh-power/moments/before-shutdown" + }, + { + "id": "moment-on-mains", + "type": "file", + "path": "/etc/mesh-power/moments/on-mains", + "mode": "0644", + "template": "templates/mesh-power/moments/on-mains" + }, + { + "id": "moment-on-battery", + "type": "file", + "path": "/etc/mesh-power/moments/on-battery", + "mode": "0644", + "template": "templates/mesh-power/moments/on-battery" + }, + { + "id": "logind-drop-ins", + "type": "directory", + "path": "/etc/systemd/logind.conf.d", + "mode": "0755", + "unknown": "program" + }, + { + "id": "logind-power", + "type": "file", + "path": "/etc/systemd/logind.conf.d/power.conf", + "mode": "0644", + "template": "templates/systemd/logind.conf.d/power.conf" + }, + { + "id": "logind", + "type": "service", + "unit": "systemd-logind.service", + "reload-on": [ + "logind-power" + ] + }, + { + "id": "after-boot-unit", + "type": "file", + "path": "/etc/systemd/system/mesh-power-after-boot.service", + "mode": "0644", + "template": "templates/systemd/system/mesh-power-after-boot.service" + }, + { + "id": "before-shutdown-unit", + "type": "file", + "path": "/etc/systemd/system/mesh-power-before-shutdown.service", + "mode": "0644", + "template": "templates/systemd/system/mesh-power-before-shutdown.service" + }, + { + "id": "before-sleep-unit", + "type": "file", + "path": "/etc/systemd/system/mesh-power-before-sleep.service", + "mode": "0644", + "template": "templates/systemd/system/mesh-power-before-sleep.service" + }, + { + "id": "after-wake-unit", + "type": "file", + "path": "/etc/systemd/system/mesh-power-after-wake.service", + "mode": "0644", + "template": "templates/systemd/system/mesh-power-after-wake.service" + }, + { + "id": "supply-unit", + "type": "file", + "path": "/etc/systemd/system/mesh-power-supply.service", + "mode": "0644", + "template": "templates/systemd/system/mesh-power-supply.service" + }, + { + "id": "sleep-drop-ins", + "type": "directory", + "path": "/etc/systemd/system/sleep.target.d", + "mode": "0755", + "unknown": "program" + }, + { + "id": "sleep-wants", + "type": "file", + "path": "/etc/systemd/system/sleep.target.d/mesh-power.conf", + "mode": "0644", + "template": "templates/systemd/system/sleep.target.d/mesh-power.conf" + }, + { + "id": "suspend-drop-ins", + "type": "directory", + "path": "/etc/systemd/system/suspend.target.d", + "mode": "0755", + "unknown": "program" + }, + { + "id": "suspend-wants", + "type": "file", + "path": "/etc/systemd/system/suspend.target.d/mesh-power.conf", + "mode": "0644", + "template": "templates/systemd/system/suspend.target.d/mesh-power.conf" + }, + { + "id": "hibernate-drop-ins", + "type": "directory", + "path": "/etc/systemd/system/hibernate.target.d", + "mode": "0755", + "unknown": "program" + }, + { + "id": "hibernate-wants", + "type": "file", + "path": "/etc/systemd/system/hibernate.target.d/mesh-power.conf", + "mode": "0644", + "template": "templates/systemd/system/hibernate.target.d/mesh-power.conf" + }, + { + "id": "hybrid-sleep-drop-ins", + "type": "directory", + "path": "/etc/systemd/system/hybrid-sleep.target.d", + "mode": "0755", + "unknown": "program" + }, + { + "id": "hybrid-sleep-wants", + "type": "file", + "path": "/etc/systemd/system/hybrid-sleep.target.d/mesh-power.conf", + "mode": "0644", + "template": "templates/systemd/system/hybrid-sleep.target.d/mesh-power.conf" + }, + { + "id": "suspend-then-hibernate-drop-ins", + "type": "directory", + "path": "/etc/systemd/system/suspend-then-hibernate.target.d", + "mode": "0755", + "unknown": "program" + }, + { + "id": "suspend-then-hibernate-wants", + "type": "file", + "path": "/etc/systemd/system/suspend-then-hibernate.target.d/mesh-power.conf", + "mode": "0644", + "template": "templates/systemd/system/suspend-then-hibernate.target.d/mesh-power.conf" + }, + { + "id": "udev-rule", + "type": "file", + "path": "/etc/udev/rules.d/90-mesh-power.rules", + "mode": "0644", + "template": "templates/udev/rules.d/90-mesh-power.rules" + }, + { + "id": "after-boot", + "type": "service", + "unit": "mesh-power-after-boot.service", + "state": "running", + "boot": "enabled" + }, + { + "id": "before-shutdown", + "type": "service", + "unit": "mesh-power-before-shutdown.service", + "state": "running", + "boot": "enabled" + }, + { + "id": "draw-dir", + "type": "directory", + "path": "/run/mesh-power", + "mode": "0755", + "unknown": "program" + }, + { + "id": "draw", + "type": "process", + "name": "mesh-power-draw", + "artifact": "tools-go", + "run": [ + "./power", + "draw" + ], + "health": { + "kind": "unit" + } + } + ], + "build": { + "artifacts": [ + { + "name": "tools-go", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/power", + "binary": "power", + "loads": [ + "power" + ] + }, + { + "name": "scripts", + "kind": "archive", + "from": "files" + }, + { + "name": "templates", + "kind": "archive", + "from": "templates" + } + ] + }, + "items": [ + { + "collection": "contribution:node-bar:block", + "name": "battery", + "if-capability": "battery", + "attributes": { + "bar": "bottom", + "place": "status", + "order": "40" + }, + "template": "templates/bar-battery" + }, + { + "collection": "contribution:node-bar:block", + "name": "power-draw", + "if-capability": "power-meter", + "attributes": { + "bar": "bottom", + "place": "status", + "order": "45" + }, + "template": "templates/bar-power-draw" + } + ] +} diff --git a/go/testdata/beside/mesh-catalog/modules/power/templates/bar-battery b/go/testdata/beside/mesh-catalog/modules/power/templates/bar-battery new file mode 100644 index 0000000..d2386ba --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/power/templates/bar-battery @@ -0,0 +1,9 @@ +[[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 = "" + diff --git a/go/testdata/beside/mesh-catalog/modules/power/templates/bar-power-draw b/go/testdata/beside/mesh-catalog/modules/power/templates/bar-power-draw new file mode 100644 index 0000000..3d0d338 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/power/templates/bar-power-draw @@ -0,0 +1,6 @@ +[[block]] +block = "custom" +command = "~/.local/bin/i3status-value power.draw ${machine:name}" +interval = 5 +hide_when_empty = true + diff --git a/go/testdata/beside/mesh-catalog/modules/power/templates/mesh-power/moments/after-boot b/go/testdata/beside/mesh-catalog/modules/power/templates/mesh-power/moments/after-boot new file mode 100644 index 0000000..c5a90f6 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/power/templates/mesh-power/moments/after-boot @@ -0,0 +1,4 @@ +# What every module on this machine runs at the moment after-boot, as the mesh placed it (module power, +# novox/hq ADR 0211). Replaced on every push: code is added by the module it belongs to, as a +# contribution for this moment. Each module's piece runs on its own, as root, bounded in time. +${shell:after-boot:first}${shell:after-boot:normal}${shell:after-boot:last} \ No newline at end of file diff --git a/go/testdata/beside/mesh-catalog/modules/power/templates/mesh-power/moments/after-wake b/go/testdata/beside/mesh-catalog/modules/power/templates/mesh-power/moments/after-wake new file mode 100644 index 0000000..2655c22 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/power/templates/mesh-power/moments/after-wake @@ -0,0 +1,4 @@ +# What every module on this machine runs at the moment after-wake, as the mesh placed it (module power, +# novox/hq ADR 0211). Replaced on every push: code is added by the module it belongs to, as a +# contribution for this moment. Each module's piece runs on its own, as root, bounded in time. +${shell:after-wake:first}${shell:after-wake:normal}${shell:after-wake:last} \ No newline at end of file diff --git a/go/testdata/beside/mesh-catalog/modules/power/templates/mesh-power/moments/before-shutdown b/go/testdata/beside/mesh-catalog/modules/power/templates/mesh-power/moments/before-shutdown new file mode 100644 index 0000000..65cc76d --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/power/templates/mesh-power/moments/before-shutdown @@ -0,0 +1,4 @@ +# What every module on this machine runs at the moment before-shutdown, as the mesh placed it (module power, +# novox/hq ADR 0211). Replaced on every push: code is added by the module it belongs to, as a +# contribution for this moment. Each module's piece runs on its own, as root, bounded in time. +${shell:before-shutdown:first}${shell:before-shutdown:normal}${shell:before-shutdown:last} \ No newline at end of file diff --git a/go/testdata/beside/mesh-catalog/modules/power/templates/mesh-power/moments/before-sleep b/go/testdata/beside/mesh-catalog/modules/power/templates/mesh-power/moments/before-sleep new file mode 100644 index 0000000..e5fc2a5 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/power/templates/mesh-power/moments/before-sleep @@ -0,0 +1,4 @@ +# What every module on this machine runs at the moment before-sleep, as the mesh placed it (module power, +# novox/hq ADR 0211). Replaced on every push: code is added by the module it belongs to, as a +# contribution for this moment. Each module's piece runs on its own, as root, bounded in time. +${shell:before-sleep:first}${shell:before-sleep:normal}${shell:before-sleep:last} \ No newline at end of file diff --git a/go/testdata/beside/mesh-catalog/modules/power/templates/mesh-power/moments/on-battery b/go/testdata/beside/mesh-catalog/modules/power/templates/mesh-power/moments/on-battery new file mode 100644 index 0000000..58e41a9 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/power/templates/mesh-power/moments/on-battery @@ -0,0 +1,4 @@ +# What every module on this machine runs at the moment on-battery, as the mesh placed it (module power, +# novox/hq ADR 0211). Replaced on every push: code is added by the module it belongs to, as a +# contribution for this moment. Each module's piece runs on its own, as root, bounded in time. +${shell:on-battery:first}${shell:on-battery:normal}${shell:on-battery:last} \ No newline at end of file diff --git a/go/testdata/beside/mesh-catalog/modules/power/templates/mesh-power/moments/on-mains b/go/testdata/beside/mesh-catalog/modules/power/templates/mesh-power/moments/on-mains new file mode 100644 index 0000000..b4a6586 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/power/templates/mesh-power/moments/on-mains @@ -0,0 +1,4 @@ +# What every module on this machine runs at the moment on-mains, as the mesh placed it (module power, +# novox/hq ADR 0211). Replaced on every push: code is added by the module it belongs to, as a +# contribution for this moment. Each module's piece runs on its own, as root, bounded in time. +${shell:on-mains:first}${shell:on-mains:normal}${shell:on-mains:last} \ No newline at end of file diff --git a/go/testdata/beside/mesh-catalog/modules/power/templates/systemd/logind.conf.d/power.conf b/go/testdata/beside/mesh-catalog/modules/power/templates/systemd/logind.conf.d/power.conf new file mode 100644 index 0000000..9cf93bd --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/power/templates/systemd/logind.conf.d/power.conf @@ -0,0 +1,8 @@ +# Written by the mesh (module power, novox/hq ADR 0211). Replaced on every push. +# +# The power key and the lid, from this module's settings: the mesh's, then this machine's. +[Login] +HandlePowerKey=${config:handle-power-key} +HandleLidSwitch=${config:handle-lid-switch} +HandleLidSwitchExternalPower=${config:handle-lid-switch-external-power} +HandleLidSwitchDocked=${config:handle-lid-switch-docked} diff --git a/go/testdata/beside/mesh-catalog/modules/power/templates/systemd/system/hibernate.target.d/mesh-power.conf b/go/testdata/beside/mesh-catalog/modules/power/templates/systemd/system/hibernate.target.d/mesh-power.conf new file mode 100644 index 0000000..81223d3 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/power/templates/systemd/system/hibernate.target.d/mesh-power.conf @@ -0,0 +1,3 @@ +# Written by the mesh (module power, novox/hq ADR 0211). Replaced on every push. +[Unit] +Wants=mesh-power-after-wake.service diff --git a/go/testdata/beside/mesh-catalog/modules/power/templates/systemd/system/hybrid-sleep.target.d/mesh-power.conf b/go/testdata/beside/mesh-catalog/modules/power/templates/systemd/system/hybrid-sleep.target.d/mesh-power.conf new file mode 100644 index 0000000..81223d3 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/power/templates/systemd/system/hybrid-sleep.target.d/mesh-power.conf @@ -0,0 +1,3 @@ +# Written by the mesh (module power, novox/hq ADR 0211). Replaced on every push. +[Unit] +Wants=mesh-power-after-wake.service diff --git a/go/testdata/beside/mesh-catalog/modules/power/templates/systemd/system/mesh-power-after-boot.service b/go/testdata/beside/mesh-catalog/modules/power/templates/systemd/system/mesh-power-after-boot.service new file mode 100644 index 0000000..b704577 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/power/templates/systemd/system/mesh-power-after-boot.service @@ -0,0 +1,13 @@ +# Written by the mesh (module power, novox/hq ADR 0211). Replaced on every push. +[Unit] +Description=Every module's code after boot (mesh power) +Wants=network-online.target +After=network-online.target + +[Service] +Type=oneshot +RemainAfterExit=yes +ExecStart=/usr/local/lib/mesh-power/bin/power-moment after-boot + +[Install] +WantedBy=multi-user.target diff --git a/go/testdata/beside/mesh-catalog/modules/power/templates/systemd/system/mesh-power-after-wake.service b/go/testdata/beside/mesh-catalog/modules/power/templates/systemd/system/mesh-power-after-wake.service new file mode 100644 index 0000000..773b228 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/power/templates/systemd/system/mesh-power-after-wake.service @@ -0,0 +1,11 @@ +# Written by the mesh (module power, novox/hq ADR 0211). Replaced on every push. +# +# Pulled in by the four sleep targets through this module's drop-ins on them, and ordered after +# them, so it runs once the machine is awake again. Never enabled. +[Unit] +Description=Every module's code after waking (mesh power) +After=suspend.target hibernate.target hybrid-sleep.target suspend-then-hibernate.target + +[Service] +Type=oneshot +ExecStart=/usr/local/lib/mesh-power/bin/power-moment after-wake diff --git a/go/testdata/beside/mesh-catalog/modules/power/templates/systemd/system/mesh-power-before-shutdown.service b/go/testdata/beside/mesh-catalog/modules/power/templates/systemd/system/mesh-power-before-shutdown.service new file mode 100644 index 0000000..52db0a4 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/power/templates/systemd/system/mesh-power-before-shutdown.service @@ -0,0 +1,18 @@ +# Written by the mesh (module power, novox/hq ADR 0211). Replaced on every push. +# +# Started at boot and left active; stopping it is the shutdown. Ordered after the network, so at +# shutdown it stops, and runs every module's code, while the network is still up. +[Unit] +Description=Every module's code before shutdown (mesh power) +Wants=network-online.target +After=network-online.target + +[Service] +Type=oneshot +RemainAfterExit=yes +ExecStart=/bin/true +ExecStop=/usr/local/lib/mesh-power/bin/power-moment before-shutdown +TimeoutStopSec=120 + +[Install] +WantedBy=multi-user.target diff --git a/go/testdata/beside/mesh-catalog/modules/power/templates/systemd/system/mesh-power-before-sleep.service b/go/testdata/beside/mesh-catalog/modules/power/templates/systemd/system/mesh-power-before-sleep.service new file mode 100644 index 0000000..727587e --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/power/templates/systemd/system/mesh-power-before-sleep.service @@ -0,0 +1,11 @@ +# Written by the mesh (module power, novox/hq ADR 0211). Replaced on every push. +# +# Pulled in by sleep.target through this module's drop-in on it, never enabled: a one-shot waiting +# for a sleep is not a service whose state the mesh can declare. +[Unit] +Description=Every module's code before sleep (mesh power) +Before=sleep.target + +[Service] +Type=oneshot +ExecStart=/usr/local/lib/mesh-power/bin/power-moment before-sleep diff --git a/go/testdata/beside/mesh-catalog/modules/power/templates/systemd/system/mesh-power-supply.service b/go/testdata/beside/mesh-catalog/modules/power/templates/systemd/system/mesh-power-supply.service new file mode 100644 index 0000000..5096a00 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/power/templates/systemd/system/mesh-power-supply.service @@ -0,0 +1,10 @@ +# Written by the mesh (module power, novox/hq ADR 0211). Replaced on every push. +# +# Started by the module's udev rule when a power supply changes; runs on-mains or on-battery once per +# change of source. +[Unit] +Description=Every module's code for the power source (mesh power) + +[Service] +Type=oneshot +ExecStart=/usr/local/lib/mesh-power/bin/power-moment supply diff --git a/go/testdata/beside/mesh-catalog/modules/power/templates/systemd/system/sleep.target.d/mesh-power.conf b/go/testdata/beside/mesh-catalog/modules/power/templates/systemd/system/sleep.target.d/mesh-power.conf new file mode 100644 index 0000000..096f793 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/power/templates/systemd/system/sleep.target.d/mesh-power.conf @@ -0,0 +1,3 @@ +# Written by the mesh (module power, novox/hq ADR 0211). Replaced on every push. +[Unit] +Wants=mesh-power-before-sleep.service diff --git a/go/testdata/beside/mesh-catalog/modules/power/templates/systemd/system/suspend-then-hibernate.target.d/mesh-power.conf b/go/testdata/beside/mesh-catalog/modules/power/templates/systemd/system/suspend-then-hibernate.target.d/mesh-power.conf new file mode 100644 index 0000000..81223d3 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/power/templates/systemd/system/suspend-then-hibernate.target.d/mesh-power.conf @@ -0,0 +1,3 @@ +# Written by the mesh (module power, novox/hq ADR 0211). Replaced on every push. +[Unit] +Wants=mesh-power-after-wake.service diff --git a/go/testdata/beside/mesh-catalog/modules/power/templates/systemd/system/suspend.target.d/mesh-power.conf b/go/testdata/beside/mesh-catalog/modules/power/templates/systemd/system/suspend.target.d/mesh-power.conf new file mode 100644 index 0000000..81223d3 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/power/templates/systemd/system/suspend.target.d/mesh-power.conf @@ -0,0 +1,3 @@ +# Written by the mesh (module power, novox/hq ADR 0211). Replaced on every push. +[Unit] +Wants=mesh-power-after-wake.service diff --git a/go/testdata/beside/mesh-catalog/modules/power/templates/udev/rules.d/90-mesh-power.rules b/go/testdata/beside/mesh-catalog/modules/power/templates/udev/rules.d/90-mesh-power.rules new file mode 100644 index 0000000..d0a75bb --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/power/templates/udev/rules.d/90-mesh-power.rules @@ -0,0 +1,5 @@ +# Written by the mesh (module power, novox/hq ADR 0211). Replaced on every push. +# Only the charger's change can switch the machine between mains and battery. A battery reports its +# level many times a minute, and USB-C supplies come and go; matching them started the supply unit +# every second or two on the laptop. +SUBSYSTEM=="power_supply", ACTION=="change", ATTR{type}=="Mains", RUN+="/usr/bin/systemctl --no-block start mesh-power-supply.service" diff --git a/go/testdata/beside/mesh-catalog/modules/powerlevel10k/files/zsh-normal b/go/testdata/beside/mesh-catalog/modules/powerlevel10k/files/zsh-normal new file mode 100644 index 0000000..ba4fc1d --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/powerlevel10k/files/zsh-normal @@ -0,0 +1,3 @@ +# The prompt: powerlevel10k v1.20.0, pinned in this module (novox/hq ADR 0205), and its configuration. +[[ ! -f ~/.local/share/powerlevel10k/powerlevel10k.zsh-theme ]] || source ~/.local/share/powerlevel10k/powerlevel10k.zsh-theme +[[ ! -f ~/.config/powerlevel10k/p10k.zsh ]] || source ~/.config/powerlevel10k/p10k.zsh diff --git a/go/testdata/beside/mesh-catalog/modules/powerlevel10k/module.json.captured b/go/testdata/beside/mesh-catalog/modules/powerlevel10k/module.json.captured new file mode 100644 index 0000000..da1bd4d --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/powerlevel10k/module.json.captured @@ -0,0 +1,49 @@ +{ + "module": "powerlevel10k", + "version": "1", + "moved": true, + "resources": [ + { + "id": "theme", + "type": "archive", + "path": "${machine:account-home}/.local/share/powerlevel10k", + "owner": "${machine:account}", + "artifact": "theme", + "unknown": "move-out", + "knows": ["powerlevel9k.zsh-theme.zwc", "powerlevel10k.zsh-theme.zwc", "internal/p10k.zsh.zwc", "internal/icons.zsh.zwc", "internal/configure.zsh.zwc", "internal/worker.zsh.zwc", "internal/parser.zsh.zwc", "gitstatus/gitstatus.plugin.zsh.zwc", "gitstatus/install.zwc"] + }, + { + "id": "configuration", + "type": "archive", + "path": "${machine:account-home}/.config/powerlevel10k", + "owner": "${machine:account}", + "artifact": "configuration", + "unknown": "move-out" + } + ], + "items": [ + { + "collection": "shell:zsh:normal", + "template": "files/zsh-normal" + } + ], + "build": { + "artifacts": [ + { + "name": "theme", + "kind": "archive", + "from": "theme" + }, + { + "name": "configuration", + "kind": "archive", + "from": "config" + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/go/testdata/beside/mesh-catalog/modules/records/files/config.json b/go/testdata/beside/mesh-catalog/modules/records/files/config.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/records/files/config.json @@ -0,0 +1 @@ +{} diff --git a/go/testdata/beside/mesh-catalog/modules/records/files/origin b/go/testdata/beside/mesh-catalog/modules/records/files/origin new file mode 100644 index 0000000..8c829dc --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/records/files/origin @@ -0,0 +1 @@ +${bound:git:scheme}://${bound:git:at}:${bound:git:port} diff --git a/go/testdata/beside/mesh-catalog/modules/records/module.json.captured b/go/testdata/beside/mesh-catalog/modules/records/module.json.captured new file mode 100644 index 0000000..db6f7e5 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/records/module.json.captured @@ -0,0 +1,103 @@ +{ + "module": "records", + "version": "1", + "slug": "records", + "requires": [ + "git" + ], + "binds": { + "git": "${dir:mesh-state}/git.json" + }, + "consumes": [ + "gitea.pull.merged" + ], + "tools": [ + "records_search", + "records_read", + "records_list", + "records_decisions", + "records_status", + "records_sync" + ], + "data": { + "own": [ + { + "id": "checkout", + "path": "${dir:checkout}", + "class": "rebuildable", + "why": "a clone of the record, cloned again" + } + ] + }, + "config": { + "repository": { + "type": "string", + "description": "The repository the records module keeps a checkout of and serves the record from, as owner/name on the mesh's forge; the operator's, with no default (novox/hq ADR 0112).", + "pattern": "[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+", + "trusted": true, + "required": true + } + }, + "resources": [ + { + "id": "mesh-state", + "type": "directory", + "mode": "0700", + "place": "mesh", + "unknown": "program" + }, + { + "id": "checkout", + "type": "directory", + "mode": "0700", + "place": ".", + "unknown": "program" + }, + { + "id": "config", + "type": "file", + "path": "${dir:mesh-state}/config.json", + "mode": "0600", + "template": "files/config.json", + "takes-config": ["repository"], + "merge": "json" + }, + { + "id": "origin", + "type": "file", + "path": "${dir:mesh-state}/origin", + "mode": "0600", + "template": "files/origin" + }, + { + "id": "git", + "type": "package", + "package": "git" + } + ], + "build": { + "artifacts": [ + { + "name": "code", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/records", + "binary": "records", + "loads": [ + "records" + ], + "env": { + "MESH_RECORDS_CONFIG_FILE": "${dir:mesh-state}/config.json", + "MESH_RECORDS_ORIGIN_FILE": "${dir:mesh-state}/origin", + "MESH_RECORDS_DIR": "${dir:checkout}" + } + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/go/testdata/beside/mesh-catalog/modules/redis/files/redis.conf b/go/testdata/beside/mesh-catalog/modules/redis/files/redis.conf new file mode 100644 index 0000000..ac27331 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/redis/files/redis.conf @@ -0,0 +1,3 @@ +requirepass ${secret:secret} +appendonly yes +dir /data diff --git a/go/testdata/beside/mesh-catalog/modules/redis/module.json.captured b/go/testdata/beside/mesh-catalog/modules/redis/module.json.captured new file mode 100644 index 0000000..58e1106 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/redis/module.json.captured @@ -0,0 +1,154 @@ +{ + "module": "redis", + "version": "1", + "provides": [ + { + "name": "redis-cache", + "scope": "mesh", + "identity": { + "in": "a Redis ACL user and key prefix" + } + } + ], + "requires": [ + "secret" + ], + "capabilities": [ + "container-runtime" + ], + "emits": [ + "cache.provisioned", + "cache.deprovisioned" + ], + "consumes": [ + "redis.cache.provisioned", + "redis.cache.deprovisioned" + ], + "serves": { + "redis-cache": { + "port": 6379 + } + }, + "receives": { + "redis-cache": "${dir:grants}/mesh.json" + }, + "grants": { + "redis-cache": "${dir:grants}" + }, + "secrets": { + "secret": "${dir:state}/default.secret" + }, + "listens": [ + { + "name": "cache", + "port": 6379, + "protocol": "tcp", + "from": "mesh", + "why": "modules on any machine that were granted a cache" + } + ], + "data": { + "own": [ + { + "id": "data", + "path": "${dir:data}", + "class": "cache", + "why": "the cache's own snapshot" + } + ], + "consumers": { + "redis-cache": { + "class": "cache", + "why": "a cache: nothing in this mesh requires it, and a consumer that kept data in it would be using a cache as a store" + } + } + }, + "resources": [ + { + "id": "state", + "type": "directory", + "mode": "0700", + "place": ".", + "unknown": "program" + }, + { + "id": "grants", + "type": "directory", + "mode": "0700", + "unknown": "program" + }, + { + "id": "data", + "type": "directory", + "mode": "0700", + "owner": "999:1000", + "unknown": "program" + }, + { + "id": "server-conf", + "type": "file", + "path": "${dir:state}/redis.conf", + "mode": "0600", + "template": "files/redis.conf", + "owner": "999:1000" + }, + { + "id": "net", + "type": "network", + "name": "redis" + }, + { + "id": "server", + "type": "container", + "name": "redis", + "image": "redis@sha256:520775a41a63e77e06c73e35d2fd9cc15921a609516818796b4ecbb813078bc7", + "health": { + "kind": "tcp", + "endpoint": "cache" + }, + "network": "redis", + "ports": [ + "6379" + ], + "volumes": [ + "${dir:data}:/data", + "${dir:state}/redis.conf:/etc/redis/redis.conf:ro" + ], + "args": [ + "/etc/redis/redis.conf" + ], + "restart-on": [ + "server-conf" + ] + } + ], + "build": { + "artifacts": [ + { + "name": "code", + "kind": "bundle", + "language": "typescript", + "entrypoints": [ + "index.js", + "tools/index.js", + "provisioner/index.js" + ], + "loads": [ + "index.js", + "tools/index.js", + "provisioner/index.js" + ], + "env": { + "MESH_RECEIVES": "${dir:grants}/mesh.json", + "MESH_PROVISION_REDIS": "127.0.0.1:${port:6379}", + "MESH_PROVISION_PASSWORD_FILE": "${dir:state}/default.secret" + } + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/go/testdata/beside/mesh-catalog/modules/restic/files/backups.conf b/go/testdata/beside/mesh-catalog/modules/restic/files/backups.conf new file mode 100644 index 0000000..2f23f90 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/restic/files/backups.conf @@ -0,0 +1,2 @@ +# What the modules on this machine back up, composed by the mesh (novox/hq to-be 43). Do not edit. +${backup:lines} \ No newline at end of file diff --git a/go/testdata/beside/mesh-catalog/modules/restic/files/data.conf b/go/testdata/beside/mesh-catalog/modules/restic/files/data.conf new file mode 100644 index 0000000..f19e849 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/restic/files/data.conf @@ -0,0 +1,2 @@ +# The data the modules on this machine declare, composed by the mesh (novox/hq ADR 0233). Do not edit. +${backup:data} \ No newline at end of file diff --git a/go/testdata/beside/mesh-catalog/modules/restic/files/retention.conf b/go/testdata/beside/mesh-catalog/modules/restic/files/retention.conf new file mode 100644 index 0000000..cd7954c --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/restic/files/retention.conf @@ -0,0 +1,6 @@ +# How many restore points the night keeps, written by the mesh from this machine's settings keep-daily, +# keep-weekly, keep-monthly (novox/hq issue 416) and keep-hand-taken-days (novox/hq issue 453). Replaced on every push; change the settings, never this file. +daily=${config:keep-daily} +weekly=${config:keep-weekly} +monthly=${config:keep-monthly} +hand=${config:keep-hand-taken-days} diff --git a/go/testdata/beside/mesh-catalog/modules/restic/module.json.captured b/go/testdata/beside/mesh-catalog/modules/restic/module.json.captured new file mode 100644 index 0000000..18ee811 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/restic/module.json.captured @@ -0,0 +1,196 @@ +{ + "module": "restic", + "version": "1", + "moved": true, + "claims": [ + { + "name": "node-backup", + "scope": "node", + "serves": [ + "backed-up", + "now", + "restore" + ] + } + ], + "tools": [ + "backup_health", + "backup_delete_retired", + "backup_deleted" + ], + "own-secrets": { + "repository": "${dir:state}/repository.secret" + }, + "data": { + "own": [ + { + "id": "repository", + "path": "${dir:repository}", + "class": "rebuildable", + "backup": "none", + "why": "the restore points themselves: a copy of data kept elsewhere on this machine, never the only copy of anything; lost, the history goes and nothing live does" + }, + { + "id": "state", + "path": "${dir:state}", + "class": "cache", + "why": "what the last nights and measurements were; the next night writes it again" + } + ] + }, + "config": { + "keep-daily": { + "type": "number", + "description": "How many nightly restore points are kept, a count from 1 to 9999: two weeks undo a mistake noticed within a fortnight. Read by the night, which thins nothing while it does not read (novox/hq issue 416); lowering it deletes the restore points beyond the new count at the next night, and that cannot be undone.", + "minimum": 1, + "maximum": 9999, + "trusted": true, + "default": 14 + }, + "keep-weekly": { + "type": "number", + "description": "How many weekly restore points are kept behind the dailies, a count from 1 to 9999, refused by the night like keep-daily; lowering it deletes the restore points beyond the new count at the next night, and that cannot be undone.", + "minimum": 1, + "maximum": 9999, + "trusted": true, + "default": 8 + }, + "keep-monthly": { + "type": "number", + "description": "How many monthly restore points are kept behind the weeklies, a count from 1 to 9999, refused by the night like keep-daily; lowering it deletes the restore points beyond the new count at the next night, and that cannot be undone.", + "minimum": 1, + "maximum": 9999, + "trusted": true, + "default": 6 + }, + "keep-hand-taken-days": { + "type": "number", + "description": "How many days a restore point taken by hand (node-backup.now, or the mesh's before a module moves) is kept at least, a count from 1 to 9999; it is kept until a later good night of its module exists, and this many days in any case, so the next thinning never takes the one taken against a risky change (novox/hq issue 453). Lowering it lets go of the older ones taken by hand at the next thinning, and that cannot be undone.", + "minimum": 1, + "maximum": 9999, + "trusted": true, + "default": 7 + } + }, + "resources": [ + { + "id": "state", + "type": "directory", + "mode": "0700", + "place": ".", + "unknown": "program" + }, + { + "id": "repository", + "type": "directory", + "mode": "0700", + "unknown": "program" + }, + { + "id": "declared", + "type": "file", + "path": "${dir:state}/backups.conf", + "mode": "0600", + "template": "files/backups.conf" + }, + { + "id": "data-declared", + "type": "file", + "path": "${dir:state}/data.conf", + "mode": "0600", + "template": "files/data.conf" + }, + { + "id": "retention", + "type": "file", + "path": "${dir:state}/retention.conf", + "mode": "0644", + "trusted": true, + "template": "files/retention.conf" + }, + { + "id": "tool", + "type": "package", + "package": "restic" + }, + { + "id": "sqlite", + "type": "package", + "package": "sqlite" + }, + { + "id": "night", + "type": "process", + "name": "restic-night", + "artifact": "tools", + "run": [ + "./restic-backups", + "night" + ], + "user": "${machine:account}", + "env": { + "MESH_BACKUP_DECLARED": "${dir:state}/backups.conf", + "MESH_BACKUP_REPOSITORY": "${dir:repository}", + "MESH_BACKUP_PASSWORD_FILE": "${dir:state}/repository.secret", + "MESH_BACKUP_STATE": "${dir:state}", + "MESH_BACKUP_DATA": "${dir:state}/data.conf" + }, + "health": { + "kind": "tool", + "tool": "backup_health", + "interval": "60s", + "timeout": "20s", + "looks": 2, + "grace": "60s" + } + }, + { + "id": "measure", + "type": "process", + "name": "restic-measure", + "artifact": "tools", + "run": [ + "./restic-backups", + "measure" + ], + "user": "${machine:account}", + "env": { + "MESH_BACKUP_DECLARED": "${dir:state}/backups.conf", + "MESH_BACKUP_REPOSITORY": "${dir:repository}", + "MESH_BACKUP_PASSWORD_FILE": "${dir:state}/repository.secret", + "MESH_BACKUP_STATE": "${dir:state}", + "MESH_BACKUP_DATA": "${dir:state}/data.conf" + }, + "health": { + "kind": "unit" + } + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/restic-backups", + "binary": "restic-backups", + "loads": [ + "restic-backups" + ], + "env": { + "MESH_BACKUP_DECLARED": "${dir:state}/backups.conf", + "MESH_BACKUP_REPOSITORY": "${dir:repository}", + "MESH_BACKUP_PASSWORD_FILE": "${dir:state}/repository.secret", + "MESH_BACKUP_STATE": "${dir:state}", + "MESH_BACKUP_DATA": "${dir:state}/data.conf" + } + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/go/testdata/beside/mesh-catalog/modules/rofi/files/bin/dmenu b/go/testdata/beside/mesh-catalog/modules/rofi/files/bin/dmenu new file mode 100644 index 0000000..b7460e9 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/rofi/files/bin/dmenu @@ -0,0 +1,26 @@ +#!/bin/sh +# dmenu, as this node's launcher answers it (module rofi, novox/hq ADR 0208). The node-launcher +# seat's dmenu-compatible command: choices on standard input, the chosen one on standard output, +# exit 1 when nothing was chosen. A script calls `dmenu` and works whichever module holds the seat. +# +# rofi understands dmenu's -p, -l and -i. dmenu's look options (-fn, -nb, -nf, -sb, -sf, -m, -w) +# take a value rofi would misread, so they are dropped with it; -b and -f are dropped alone. The +# look is the launcher's theme. +set -u +n=$# +while [ "$n" -gt 0 ]; do + arg=$1 + shift + n=$((n - 1)) + case $arg in + -fn | -nb | -nf | -sb | -sf | -m | -w) + if [ "$n" -gt 0 ]; then + shift + n=$((n - 1)) + fi + ;; + -b | -f) ;; + *) set -- "$@" "$arg" ;; + esac +done +exec rofi -dmenu "$@" diff --git a/go/testdata/beside/mesh-catalog/modules/rofi/files/bin/rofi-launch b/go/testdata/beside/mesh-catalog/modules/rofi/files/bin/rofi-launch new file mode 100644 index 0000000..788f23c --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/rofi/files/bin/rofi-launch @@ -0,0 +1,39 @@ +#!/bin/sh +# rofi-launch drun|run|window|filebrowser|sudo (module rofi, novox/hq ADR 0208): what the launcher's +# key bindings run. +# +# After a resume the keyboard grab can fail for a moment, and rofi then exits at once: a key press +# that opens nothing. So a failure within half a second is tried again, up to five times. A person +# closing the menu takes longer than that, and is never shown it a second time. +set -u +mode=${1:-drun} + +now_ms() { date +%s%3N; } + +attempt() { + i=0 + while [ "$i" -lt 5 ]; do + start=$(now_ms) + "$@" && return 0 + [ $(($(now_ms) - start)) -ge 500 ] && return 1 + i=$((i + 1)) + sleep 0.1 + done + return 1 +} + +case $mode in +drun | run | window | filebrowser) + attempt rofi -show "$mode" + ;; +sudo) + # A command line, run with sudo in the node's terminal. + command=$(attempt rofi -dmenu -p sudo &2 + exit 2 + ;; +esac diff --git a/go/testdata/beside/mesh-catalog/modules/rofi/files/bin/rofi-powermenu b/go/testdata/beside/mesh-catalog/modules/rofi/files/bin/rofi-powermenu new file mode 100644 index 0000000..2bdf64c --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/rofi/files/bin/rofi-powermenu @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +# rofi-powermenu (module rofi, novox/hq ADR 0208): lock, suspend, log out, reboot, shut down. +# +# It belongs to the session, and it is the launcher's: its whole face is rofi's. Every action is a +# verb of logind or the service manager, so nothing here names a window manager or a locker: +# - lock asks logind to lock the session, which the holder of node-lock-screen answers; +# - log out ends this login session, whichever window manager runs in it. +# Adopted from the workstations' power menu of 2026-10-04 (after adi1090x's powermenu type-2). +set -u + +theme=mesh-powermenu +uptime="$(uptime -p | sed -e 's/^up //')" + +shutdown='󰤂' +reboot='󰜉' +lock='󰌾' +suspend='󰤄' +logout='󰍃' +yes='󰄲' +no='󰅖' + +menu() { + rofi -dmenu -p "Uptime: $uptime" -mesg "Uptime: $uptime" -theme "$theme" +} + +confirm() { + printf '%s\n%s\n' "$yes" "$no" | rofi -dmenu -p 'Confirmation' -mesg 'Are you sure?' -theme "$theme" \ + -theme-str 'window {location: center; anchor: center; fullscreen: false; width: 350px;}' \ + -theme-str 'mainbox {children: [ "message", "listview" ];}' \ + -theme-str 'listview {columns: 2; lines: 1;}' \ + -theme-str 'element-text {horizontal-align: 0.5;}' \ + -theme-str 'textbox {horizontal-align: 0.5;}' +} + +session() { + # The login session this menu runs in: the session's own id, else logind's answer for this process. + if [ -n "${XDG_SESSION_ID:-}" ]; then + echo "$XDG_SESSION_ID" + else + loginctl show-session auto -p Id --value 2>/dev/null + fi +} + +confirmed() { [ "$(confirm)" = "$yes" ]; } + +chosen="$(printf '%s\n' "$lock" "$suspend" "$logout" "$reboot" "$shutdown" | menu)" || exit 0 +case $chosen in +"$lock") loginctl lock-session "$(session)" ;; +"$suspend") confirmed && systemctl suspend ;; +"$logout") confirmed && loginctl terminate-session "$(session)" ;; +"$reboot") confirmed && systemctl reboot ;; +"$shutdown") confirmed && systemctl poweroff ;; +esac +exit 0 diff --git a/go/testdata/beside/mesh-catalog/modules/rofi/files/config.rasi b/go/testdata/beside/mesh-catalog/modules/rofi/files/config.rasi new file mode 100644 index 0000000..d123371 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/rofi/files/config.rasi @@ -0,0 +1,54 @@ +/* rofi, the launcher (module rofi, novox/hq ADR 0208). Owned by the mesh: this file is replaced at + * every push. Adopted from the two workstations' file of 2026-10-04: the settings that differ from + * rofi's defaults, nothing else. Its look is the theme "mesh", in themes/ beside this file. */ + +configuration { + modi: "drun,run,window,filebrowser"; + show-icons: true; + case-sensitive: false; + normalize-match: true; + matching: "normal"; + tokenize: true; + cycle: true; + steal-focus: false; + click-to-exit: true; + sort: false; + + /* Applications: the user's and the system's desktop entries, read fresh each time so a + * program installed a minute ago is there. */ + drun-match-fields: "name,generic,exec,categories,keywords"; + drun-display-format: "{name} [({generic})]"; + drun-show-actions: false; + drun-url-launcher: "xdg-open"; + drun-use-desktop-cache: false; + drun-reload-desktop-cache: false; + run,drun { + fallback-icon: "application-x-addon"; + } + + /* A command run from `run` opens in the node's terminal when it asks for one. */ + terminal: "rofi-sensible-terminal"; + run-shell-command: "{terminal} -e {cmd}"; + ssh-command: "{terminal} -e {ssh-client} {host} [-p {port}]"; + parse-hosts: true; + parse-known-hosts: true; + + window-match-fields: "title,class,role,name,desktop"; + window-format: "{w} - {c} - {t:0}"; + + disable-history: false; + max-history-size: 25; + + filebrowser { + directories-first: true; + sorting-method: "name"; + } + + display-window: "Windows"; + display-run: "Run"; + display-ssh: "SSH"; + display-drun: "Apps"; + display-filebrowser: "Files"; +} + +@theme "mesh" diff --git a/go/testdata/beside/mesh-catalog/modules/rofi/files/i3/50-rofi.conf b/go/testdata/beside/mesh-catalog/modules/rofi/files/i3/50-rofi.conf new file mode 100644 index 0000000..1395d2c --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/rofi/files/i3/50-rofi.conf @@ -0,0 +1,8 @@ +# 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 diff --git a/go/testdata/beside/mesh-catalog/modules/rofi/files/themes/mesh-powermenu.rasi b/go/testdata/beside/mesh-catalog/modules/rofi/files/themes/mesh-powermenu.rasi new file mode 100644 index 0000000..5b837f4 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/rofi/files/themes/mesh-powermenu.rasi @@ -0,0 +1,172 @@ +/** + * The theme "mesh-powermenu" (module rofi, novox/hq ADR 0208): the power menu's five buttons. + * Owned by the mesh; replaced at every push. Adopted from the workstations' power menu theme of + * 2026-10-04 (after adi1090x's powermenu type-2, style-1), its colours inlined. + **/ + +/*****----- Configuration -----*****/ +configuration { + show-icons: false; +} + +/*****----- Global Properties -----*****/ +* { + background: #000000FF; + background-alt: #282B31FF; + foreground: #FFFFFFFF; + selected: #DE5200FF; + active: #DE5200FF; + urgent: #CC0000FF; + + font: "JetBrainsMono Nerd Font 11"; +} + + +/*****----- Main Window -----*****/ +window { + /* properties for window widget */ + transparency: "real"; + location: center; + anchor: center; + fullscreen: false; + width: 800px; + x-offset: 0px; + y-offset: 0px; + + /* properties for all widgets */ + enabled: true; + margin: 0px; + padding: 0px; + border: 0px solid; + border-radius: 0px; + border-color: @selected; + cursor: "default"; + background-color: @background; +} + +/*****----- Main Box -----*****/ +mainbox { + enabled: true; + spacing: 15px; + margin: 0px; + padding: 30px; + border: 0px solid; + border-radius: 0px; + border-color: @selected; + background-color: transparent; + children: [ "inputbar", "listview" ]; +} + +/*****----- Inputbar -----*****/ +inputbar { + enabled: true; + spacing: 15px; + margin: 0px; + padding: 0px; + border: 0px; + border-radius: 0px; + border-color: @selected; + background-color: transparent; + text-color: @foreground; + children: [ "textbox-prompt-colon", "prompt"]; +} + +dummy { + background-color: transparent; +} + +textbox-prompt-colon { + enabled: true; + expand: false; + str: ""; + padding: 12px 16px; + border-radius: 0px; + background-color: @urgent; + text-color: @background; +} +prompt { + enabled: true; + padding: 12px; + border-radius: 0px; + background-color: @active; + text-color: @background; +} + +/*****----- Message -----*****/ +message { + enabled: true; + margin: 0px; + padding: 12px; + border: 0px solid; + border-radius: 0px; + border-color: @selected; + background-color: @background-alt; + text-color: @foreground; +} +textbox { + background-color: inherit; + text-color: inherit; + vertical-align: 0.5; + horizontal-align: 0.5; + placeholder-color: @foreground; + blink: true; + markup: true; +} +error-message { + padding: 12px; + border: 0px solid; + border-radius: 0px; + border-color: @selected; + background-color: @background; + text-color: @foreground; +} + +/*****----- Listview -----*****/ +listview { + enabled: true; + columns: 5; + lines: 1; + cycle: true; + dynamic: true; + scrollbar: false; + layout: vertical; + reverse: false; + fixed-height: true; + fixed-columns: true; + + spacing: 15px; + margin: 0px; + padding: 0px; + border: 0px solid; + border-radius: 0px; + border-color: @selected; + background-color: transparent; + text-color: @foreground; + cursor: "default"; +} + +/*****----- Elements -----*****/ +element { + enabled: true; + spacing: 0px; + margin: 0px; + padding: 40px 10px; + border: 0px solid; + border-radius: 0px; + border-color: @selected; + background-color: @background-alt; + text-color: @foreground; + cursor: pointer; +} +element-text { + font: "JetBrainsMono Nerd Font Bold 32"; + background-color: transparent; + text-color: inherit; + cursor: inherit; + vertical-align: 0.5; + horizontal-align: 0.5; +} +element selected.normal { + background-color: var(selected); + text-color: var(background); +} diff --git a/go/testdata/beside/mesh-catalog/modules/rofi/files/themes/mesh.rasi b/go/testdata/beside/mesh-catalog/modules/rofi/files/themes/mesh.rasi new file mode 100644 index 0000000..8be0f55 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/rofi/files/themes/mesh.rasi @@ -0,0 +1,263 @@ +/** + * The theme "mesh" (module rofi, novox/hq ADR 0208): every rofi window unless a caller names + * another. Owned by the mesh; replaced at every push. Adopted from the workstations' theme of + * 2026-10-04 (after adi1090x's launcher type-4, style-1), its colour file inlined so the theme is + * one file, and its face the monospace one every desktop module names. + **/ + +/*****----- Global Properties -----*****/ +* { + background: #000000FF; + background-alt: #282B31FF; + foreground: #FFFFFFFF; + selected: #DE5200FF; + active: #DE5200FF; + urgent: #CC0000FF; + + font: "JetBrainsMono Nerd Font 11"; + + border-colour: var(selected); + handle-colour: var(selected); + background-colour: var(background); + foreground-colour: var(foreground); + alternate-background: var(background-alt); + normal-background: var(background); + normal-foreground: var(foreground); + urgent-background: var(urgent); + urgent-foreground: var(background); + active-background: var(active); + active-foreground: var(background); + selected-normal-background: var(selected); + selected-normal-foreground: var(background); + selected-urgent-background: var(active); + selected-urgent-foreground: var(background); + selected-active-background: var(urgent); + selected-active-foreground: var(background); + alternate-normal-background: var(background); + alternate-normal-foreground: var(foreground); + alternate-urgent-background: var(urgent); + alternate-urgent-foreground: var(background); + alternate-active-background: var(active); + alternate-active-foreground: var(background); +} + +/*****----- Main Window -----*****/ +window { + transparency: "real"; + location: center; + anchor: center; + fullscreen: false; + width: 600px; + x-offset: 0px; + y-offset: 0px; + enabled: true; + margin: 0px; + padding: 0px; + border: 1px solid; + border-radius: 0px; + border-color: @border-colour; + cursor: "default"; + background-color: @background-colour; +} + +/*****----- Main Box -----*****/ +mainbox { + enabled: true; + spacing: 10px; + margin: 0px; + padding: 10px; + border: 0px solid; + border-radius: 0px; + border-color: @border-colour; + background-color: transparent; + children: [ "inputbar", "message", "listview", "mode-switcher" ]; +} + +/*****----- Inputbar -----*****/ +inputbar { + enabled: true; + spacing: 10px; + margin: 0px; + padding: 10px; + border: 0px 0px 1px 0px; + border-radius: 0px; + border-color: @border-colour; + background-color: @alternate-background; + text-color: @foreground-colour; + children: [ "prompt", "entry" ]; +} + +prompt { + enabled: true; + background-color: inherit; + text-color: inherit; +} +textbox-prompt-colon { + enabled: true; + expand: false; + str: "::"; + background-color: inherit; + text-color: inherit; +} +entry { + enabled: true; + background-color: inherit; + text-color: inherit; + cursor: text; + placeholder: "Search..."; + placeholder-color: inherit; +} + +/*****----- Listview -----*****/ +listview { + enabled: true; + columns: 1; + lines: 8; + cycle: true; + dynamic: true; + scrollbar: false; + layout: vertical; + reverse: false; + fixed-height: true; + fixed-columns: true; + spacing: 0px; + margin: 0px; + padding: 0px; + border: 0px solid; + border-radius: 0px; + border-color: @border-colour; + background-color: transparent; + text-color: @foreground-colour; + cursor: "default"; +} +scrollbar { + handle-width: 5px; + handle-color: @handle-colour; + border-radius: 0px; + background-color: @alternate-background; +} + +/*****----- Elements -----*****/ +element { + enabled: true; + spacing: 10px; + margin: 0px; + padding: 10px; + border: 0px solid; + border-radius: 0px; + border-color: @border-colour; + background-color: transparent; + text-color: @foreground-colour; + cursor: pointer; +} +element normal.normal { + background-color: var(normal-background); + text-color: var(normal-foreground); +} +element normal.urgent { + background-color: var(urgent-background); + text-color: var(urgent-foreground); +} +element normal.active { + background-color: var(active-background); + text-color: var(active-foreground); +} +element selected.normal { + background-color: var(selected-normal-background); + text-color: var(selected-normal-foreground); +} +element selected.urgent { + background-color: var(selected-urgent-background); + text-color: var(selected-urgent-foreground); +} +element selected.active { + background-color: var(selected-active-background); + text-color: var(selected-active-foreground); +} +element alternate.normal { + background-color: var(alternate-normal-background); + text-color: var(alternate-normal-foreground); +} +element alternate.urgent { + background-color: var(alternate-urgent-background); + text-color: var(alternate-urgent-foreground); +} +element alternate.active { + background-color: var(alternate-active-background); + text-color: var(alternate-active-foreground); +} +element-icon { + background-color: transparent; + text-color: inherit; + size: 24px; + cursor: inherit; +} +element-text { + background-color: transparent; + text-color: inherit; + highlight: inherit; + cursor: inherit; + vertical-align: 0.5; + horizontal-align: 0.0; +} + +/*****----- Mode Switcher -----*****/ +mode-switcher { + enabled: true; + spacing: 10px; + margin: 0px; + padding: 0px; + border: 0px solid; + border-radius: 0px; + border-color: @border-colour; + background-color: transparent; + text-color: @foreground-colour; +} +button { + padding: 10px; + border: 0px solid; + border-radius: 0px; + border-color: @border-colour; + background-color: @alternate-background; + text-color: inherit; + cursor: pointer; +} +button selected { + background-color: var(selected-normal-background); + text-color: var(selected-normal-foreground); +} + +/*****----- Message -----*****/ +message { + enabled: true; + margin: 0px; + padding: 0px; + border: 0px solid; + border-radius: 0px; + border-color: @border-colour; + background-color: transparent; + text-color: @foreground-colour; +} +textbox { + font: "Inter 11"; + padding: 10px; + border: 0px solid; + border-radius: 0px; + border-color: @border-colour; + background-color: @alternate-background; + text-color: @foreground-colour; + vertical-align: 0.5; + horizontal-align: 0.0; + highlight: none; + placeholder-color: @foreground-colour; + blink: true; + markup: true; +} +error-message { + padding: 10px; + border: 0px solid; + border-radius: 0px; + border-color: @border-colour; + background-color: @background-colour; + text-color: @foreground-colour; +} diff --git a/go/testdata/beside/mesh-catalog/modules/rofi/module.json.captured b/go/testdata/beside/mesh-catalog/modules/rofi/module.json.captured new file mode 100644 index 0000000..fbd061d --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/rofi/module.json.captured @@ -0,0 +1,124 @@ +{ + "module": "rofi", + "version": "1", + "moved": true, + "capabilities": [ + "package-manager" + ], + "requires": [ + "x11-display" + ], + "claims": [ + { + "name": "node-launcher", + "scope": "node", + "serves": [ + "menu", + "secret" + ] + } + ], + "tools": [ + "rofi_applications", + "rofi_themes", + "rofi_run" + ], + "resources": [ + { + "id": "package", + "type": "package", + "package": "rofi" + }, + { + "id": "configuration-dir", + "type": "directory", + "path": "${machine:account-home}/.config/rofi", + "owner": "${machine:account}", + "mode": "0755", + "unknown": "move-out" + }, + { + "id": "themes-dir", + "type": "directory", + "path": "${machine:account-home}/.config/rofi/themes", + "owner": "${machine:account}", + "mode": "0755", + "unknown": "move-out" + }, + { + "id": "configuration", + "type": "file", + "path": "${machine:account-home}/.config/rofi/config.rasi", + "owner": "${machine:account}", + "mode": "0644", + "template": "files/config.rasi" + }, + { + "id": "theme", + "type": "file", + "path": "${machine:account-home}/.config/rofi/themes/mesh.rasi", + "owner": "${machine:account}", + "mode": "0644", + "template": "files/themes/mesh.rasi" + }, + { + "id": "theme-powermenu", + "type": "file", + "path": "${machine:account-home}/.config/rofi/themes/mesh-powermenu.rasi", + "owner": "${machine:account}", + "mode": "0644", + "template": "files/themes/mesh-powermenu.rasi" + }, + { + "id": "dmenu", + "type": "file", + "path": "${machine:account-home}/.local/bin/dmenu", + "owner": "${machine:account}", + "mode": "0755", + "template": "files/bin/dmenu" + }, + { + "id": "launch", + "type": "file", + "path": "${machine:account-home}/.local/bin/rofi-launch", + "owner": "${machine:account}", + "mode": "0755", + "template": "files/bin/rofi-launch" + }, + { + "id": "powermenu", + "type": "file", + "path": "${machine:account-home}/.local/bin/rofi-powermenu", + "owner": "${machine:account}", + "mode": "0755", + "template": "files/bin/rofi-powermenu" + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/rofi-tools", + "binary": "rofi-tools", + "loads": [ + "rofi-tools" + ] + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + }, + "items": [ + { + "collection": "contribution:node-display-session:config", + "name": "display-session", + "template": "files/i3/50-rofi.conf" + } + ] +} diff --git a/go/testdata/beside/mesh-catalog/modules/route-adapter/files/config.json b/go/testdata/beside/mesh-catalog/modules/route-adapter/files/config.json new file mode 100644 index 0000000..1d8d8c2 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/route-adapter/files/config.json @@ -0,0 +1,6 @@ +{ + "dynamic": "/services/traefik/dynamic", + "entrypoint": "websecure", + "certificate-resolver": "le", + "machine": "host.docker.internal" +} diff --git a/go/testdata/beside/mesh-catalog/modules/route-adapter/module.json.captured b/go/testdata/beside/mesh-catalog/modules/route-adapter/module.json.captured new file mode 100644 index 0000000..5234c78 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/route-adapter/module.json.captured @@ -0,0 +1,134 @@ +{ + "module": "route-adapter", + "version": "1", + "moved": true, + "slug": "radapt", + "capabilities": [ + "container-runtime" + ], + "provides": [ + { + "name": "route", + "scope": "mesh", + "identity": false + } + ], + "serves": { + "route": {} + }, + "receives": { + "route": "${dir:routes-dir}/mesh.json" + }, + "accesses": [ + { + "id": "dynamic", + "path": "/services/traefik/dynamic", + "mode": "read-write" + } + ], + "config": { + "dynamic": { + "type": "string", + "description": "The directory the predecessor proxy's file provider reads, on this machine, as an absolute path.", + "pattern": "/.*", + "trusted": true, + "default": "/services/traefik/dynamic" + }, + "entrypoint": { + "type": "string", + "description": "The predecessor proxy's entry point it serves public HTTPS on, by its own name.", + "trusted": true, + "default": "websecure" + }, + "certificate-resolver": { + "type": "string", + "description": "The predecessor proxy's certificate resolver, by its own name.", + "trusted": true, + "default": "le" + }, + "machine": { + "type": "string", + "description": "How the predecessor's proxy reaches this machine: a host name or an address.", + "trusted": true, + "default": "host.docker.internal" + } + }, + "resources": [ + { + "id": "state", + "type": "directory", + "mode": "0700", + "place": ".", + "unknown": "program" + }, + { + "id": "routes-dir", + "type": "directory", + "path": "/var/lib/route-adapter/routes", + "mode": "0700", + "unknown": "program" + }, + { + "id": "config", + "type": "file", + "path": "${dir:state}/config.json", + "takes-config": ["dynamic", "entrypoint", "certificate-resolver", "machine"], + "merge": "json", + "mode": "0644", + "template": "files/config.json" + }, + { + "id": "adapt", + "type": "process", + "name": "route-adapter", + "artifact": "code", + "run": [ + "node", + "index.js" + ], + "run-once": true, + "env": { + "MESH_RECEIVES": "${dir:routes-dir}/mesh.json", + "MESH_ROUTE_ADAPTER_CONFIG": "${dir:state}/config.json" + }, + "restart-on": [ + "received-route", + "config" + ] + } + ], + "build": { + "artifacts": [ + { + "name": "code", + "kind": "bundle", + "language": "typescript", + "entrypoints": [ + "index.js" + ] + }, + { + "name": "provider", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/route-provider", + "binary": "route-provider", + "loads": [ + "route-provider" + ] + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + }, + "state": [ + "bound" + ], + "reads": [ + "mesh-controller.assignments" + ] +} diff --git a/go/testdata/beside/mesh-catalog/modules/screen-lock/files/bin/screen-lock b/go/testdata/beside/mesh-catalog/modules/screen-lock/files/bin/screen-lock new file mode 100644 index 0000000..18008bd --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/screen-lock/files/bin/screen-lock @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +# screen-lock (module screen-lock, novox/hq ADR 0208): the locker. xss-lock runs it on idle, before +# suspend and on logind's Lock, and node-lock-screen's `lock` ends in it. +# +# The operator's look, adopted from the predecessor's my-i3lock: the screen as it was, blurred, with +# an orange ring, the time and the date. That needs i3lock-color, from the distribution's user +# repository, kept as found until the mesh carries such software (novox/hq research 027, question 1). +# On a machine without it the distribution's i3lock shows the same blurred screen, taken here, with +# its own plain ring; failing that, black. +# +# Before a suspend, xss-lock hands this script a lock on the sleep (XSS_SLEEP_LOCK_FD) and suspends +# once it is released. The locker must not inherit it, or the machine would wait for the unlock +# before sleeping; it is released once i3lock is up, so the machine never sleeps unlocked. This is +# xss-lock's own documented pattern for i3lock. +set -u + +# One locker: a second press of the key, or a lock while locked, changes nothing. +if pgrep -xu "$EUID" i3lock >/dev/null; then + exit 0 +fi + +# The colour build numbers its versions .c. (2.13.c.5); its version line never says "color". +if i3lock --version 2>&1 | grep -qE '[0-9]\.c\.[0-9]'; then + blank='#00000000' clear='#ffffff22' accent='#ca4a00' wrong='#880000bb' verifying='#bb00bbbb' + options=( + --insidever-color="$clear" --ringver-color="$verifying" + --insidewrong-color="$clear" --ringwrong-color="$wrong" + --inside-color="$blank" --ring-color="$accent" --line-color="$blank" --separator-color="$accent" + --verif-color="$accent" --wrong-color="$accent" --time-color="$accent" --date-color="$accent" + --layout-color="$accent" --keyhl-color="$wrong" --bshl-color="$wrong" + --screen 1 --blur 5 --ring-width=7.0 --clock --indicator + --time-str="%H:%M:%S" --date-str="%A, %Y-%m-%d" + --time-font=sans-serif --date-font=sans-serif --verif-font=sans-serif + --wrong-font=sans-serif --layout-font=sans-serif --keylayout 1 + --show-failed-attempts --ignore-empty-password + ) +else + options=(--color=000000 --show-failed-attempts --ignore-empty-password) + shot="${XDG_RUNTIME_DIR:-/tmp}/screen-lock.png" + if command -v magick >/dev/null && magick import -window root -resize 25% -blur 0x3 -resize 400% "$shot" 2>/dev/null; then + options+=(--image="$shot") + fi +fi + +if [[ -e /dev/fd/${XSS_SLEEP_LOCK_FD:--1} ]]; then + kill_i3lock() { pkill -xu "$EUID" "$@" i3lock; } + trap kill_i3lock TERM INT + i3lock "${options[@]}" {XSS_SLEEP_LOCK_FD}<&- + exec {XSS_SLEEP_LOCK_FD}<&- + while kill_i3lock -0; do + sleep 0.5 + done +else + trap 'kill %%' TERM INT + i3lock --nofork "${options[@]}" & + wait +fi diff --git a/go/testdata/beside/mesh-catalog/modules/screen-lock/files/xinitrc-normal b/go/testdata/beside/mesh-catalog/modules/screen-lock/files/xinitrc-normal new file mode 100644 index 0000000..f7f7123 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/screen-lock/files/xinitrc-normal @@ -0,0 +1,8 @@ +# 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) & diff --git a/go/testdata/beside/mesh-catalog/modules/screen-lock/module.json.captured b/go/testdata/beside/mesh-catalog/modules/screen-lock/module.json.captured new file mode 100644 index 0000000..1791c79 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/screen-lock/module.json.captured @@ -0,0 +1,78 @@ +{ + "module": "screen-lock", + "version": "1", + "moved": true, + "capabilities": [ + "package-manager" + ], + "requires": [ + "x11-display" + ], + "claims": [ + { + "name": "node-lock-screen", + "scope": "node", + "serves": [ + "lock" + ] + } + ], + "tools": [ + "screen_lock_idle", + "screen_lock_inhibit", + "screen_lock_locked" + ], + "items": [ + { + "collection": "shell:xinitrc:normal", + "name": "xinitrc-normal", + "template": "files/xinitrc-normal" + } + ], + "resources": [ + { + "id": "screensaver", + "type": "package", + "package": "xscreensaver", + "absent": true + }, + { + "id": "watcher", + "type": "package", + "package": "xss-lock" + }, + { + "id": "locker", + "type": "package", + "package": "i3lock" + }, + { + "id": "wrapper", + "type": "file", + "path": "${machine:account-home}/.local/bin/screen-lock", + "owner": "${machine:account}", + "mode": "0755", + "template": "files/bin/screen-lock" + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/screen-lock-tools", + "binary": "screen-lock-tools", + "loads": [ + "screen-lock-tools" + ] + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/go/testdata/beside/mesh-catalog/modules/searxng/files/config.json b/go/testdata/beside/mesh-catalog/modules/searxng/files/config.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/searxng/files/config.json @@ -0,0 +1 @@ +{} diff --git a/go/testdata/beside/mesh-catalog/modules/searxng/files/settings.yml b/go/testdata/beside/mesh-catalog/modules/searxng/files/settings.yml new file mode 100644 index 0000000..6d7248e --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/searxng/files/settings.yml @@ -0,0 +1,16 @@ +{ + "use_default_settings": true, + "server": { + "secret_key": "${secret:secret}", + "base_url": false, + "limiter": false, + "image_proxy": false, + "public_instance": false + }, + "search": { + "formats": ["html", "json"] + }, + "valkey": { + "url": "valkey://valkey:6379/0" + } +} diff --git a/go/testdata/beside/mesh-catalog/modules/searxng/module.json.captured b/go/testdata/beside/mesh-catalog/modules/searxng/module.json.captured new file mode 100644 index 0000000..cee18e4 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/searxng/module.json.captured @@ -0,0 +1,229 @@ +{ + "module": "searxng", + "version": "1", + "capabilities": [ + "container-runtime" + ], + "own-secrets": { + "secret": { + "path": "${dir:mesh-state}/secret", + "taken": "at-start" + } + }, + "listens": [ + { + "name": "web", + "port": 8080, + "protocol": "tcp", + "from": "mesh", + "why": "the search pages" + } + ], + "data": { + "own": [ + { + "id": "valkey", + "path": "${dir:valkey-data}", + "class": "cache", + "why": "the search cache" + } + ] + }, + "config": { + "general": { + "type": "object", + "description": "SearXNG's general section of settings.yml, laid over the file's own and SearXNG's defaults: the instance's name, contact and debug switches.", + "trusted": true, + "default": {} + }, + "brand": { + "type": "object", + "description": "SearXNG's brand section of settings.yml, laid over the file's own and SearXNG's defaults: the links the pages show for the instance's source, issues and documentation.", + "trusted": true, + "default": {} + }, + "search": { + "type": "object", + "description": "SearXNG's search section of settings.yml, laid over the file's own and SearXNG's defaults: safe search, autocomplete, the result formats offered and how long an engine is suspended after an error.", + "trusted": true, + "default": { + "formats": [ + "html", + "json" + ] + } + }, + "server": { + "type": "object", + "description": "SearXNG's server section of settings.yml, laid over the file's own and SearXNG's defaults: the public base URL, the request method, the limiter and the image proxy; its secret_key is the vault's.", + "trusted": true, + "default": { + "secret_key": "${secret:secret}", + "base_url": false, + "limiter": false, + "image_proxy": false, + "public_instance": false + } + }, + "ui": { + "type": "object", + "description": "SearXNG's ui section of settings.yml, laid over the file's own and SearXNG's defaults: the theme, the default locale and how results are shown.", + "trusted": true, + "default": {} + }, + "outgoing": { + "type": "object", + "description": "SearXNG's outgoing section of settings.yml, laid over the file's own and SearXNG's defaults: how SearXNG reaches the engines: timeouts, connection pool and proxies.", + "trusted": true, + "default": {} + }, + "engines": { + "type": "list", + "description": "SearXNG's engines section of settings.yml, laid over the file's own and SearXNG's defaults: the engines to add or change, each by its name.", + "trusted": true, + "default": [] + }, + "plugins": { + "type": "object", + "description": "SearXNG's plugins section of settings.yml, laid over the file's own and SearXNG's defaults: the plugins to switch on or off, by their id.", + "trusted": true, + "default": {} + }, + "categories_as_tabs": { + "type": "object", + "description": "SearXNG's categories_as_tabs section of settings.yml, laid over the file's own and SearXNG's defaults: the categories shown as tabs above the results.", + "trusted": true, + "default": {} + }, + "preferences": { + "type": "object", + "description": "SearXNG's preferences section of settings.yml, laid over the file's own and SearXNG's defaults: the preferences a user cannot change (lock).", + "trusted": true, + "default": {} + } + }, + "resources": [ + { + "id": "mesh-state", + "type": "directory", + "mode": "0700", + "place": "mesh", + "unknown": "program" + }, + { + "id": "state", + "type": "directory", + "mode": "0700", + "place": ".", + "unknown": "program" + }, + { + "id": "valkey-data", + "type": "directory", + "mode": "0700", + "unknown": "program" + }, + { + "id": "net", + "type": "network", + "name": "searxng" + }, + { + "id": "cache", + "type": "container", + "name": "valkey", + "image": "valkey/valkey@sha256:b21fd94099dcd4bc6b2b9230daef69b6558b887ad4a2a1afe56ff6e745a88cdb", + "network": "searxng", + "args": [ + "valkey-server", + "--save", + "30", + "1", + "--loglevel", + "warning" + ], + "volumes": [ + "${dir:valkey-data}:/data" + ] + }, + { + "id": "settings", + "type": "file", + "path": "${dir:state}/settings.yml", + "mode": "0600", + "merge": "json", + "takes-config": [ + "general", + "brand", + "search", + "server", + "ui", + "outgoing", + "engines", + "plugins", + "categories_as_tabs", + "preferences" + ], + "template": "files/settings.yml" + }, + { + "id": "server", + "type": "container", + "name": "searxng", + "image": "searxng/searxng@sha256:cd8812607ab73730a0b1a0dc4990223fe1b9e383f6f35947114d0bef7f8bb441", + "network": "searxng", + "ports": [ + "8080" + ], + "volumes": [ + "${dir:state}/settings.yml:/etc/searxng/settings.yml:ro" + ], + "restart-on": [ + "settings" + ] + }, + { + "id": "runtime-config", + "type": "file", + "path": "${dir:mesh-state}/config.json", + "mode": "0600", + "template": "files/config.json" + } + ], + "requires": [ + "route" + ], + "contributes": { + "route": { + "label": "searxng", + "endpoint": "web" + } + }, + "binds": { + "route": "${dir:state}/route.json" + }, + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "typescript", + "entrypoints": [ + "tools/index.js" + ], + "loads": [ + "tools/index.js" + ], + "env": { + "MESH_SEARXNG_URL": "http://127.0.0.1:${port:8080}", + "MESH_SEARXNG_CONFIG_FILE": "${dir:mesh-state}/config.json" + } + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/go/testdata/beside/mesh-catalog/modules/sensors/module.json.captured b/go/testdata/beside/mesh-catalog/modules/sensors/module.json.captured new file mode 100644 index 0000000..3df6783 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/sensors/module.json.captured @@ -0,0 +1,69 @@ +{ + "module": "sensors", + "version": "1", + "capabilities": [ + "package-manager", + "service-manager" + ], + "tools": [ + "sensors_summary", + "sensors_temperatures", + "sensors_fans", + "sensors_drives", + "sensors_faults", + "sensors_health" + ], + "resources": [ + { + "id": "smartmontools", + "type": "package", + "package": "smartmontools" + }, + { + "id": "drives", + "type": "process", + "name": "sensors-drives", + "artifact": "tools", + "run": [ + "./sensors", + "drives" + ], + "health": { + "kind": "unit" + } + }, + { + "id": "watch", + "type": "process", + "name": "sensors-watch", + "artifact": "tools", + "run": [ + "./sensors", + "watch" + ], + "health": { + "kind": "tool", + "tool": "sensors_health", + "interval": "60s", + "timeout": "10s", + "looks": 2, + "grace": "60s" + } + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/sensors", + "binary": "sensors", + "loads": [ + "sensors" + ] + } + ] + } +} diff --git a/go/testdata/beside/mesh-catalog/modules/showcase/files/README.md b/go/testdata/beside/mesh-catalog/modules/showcase/files/README.md new file mode 100644 index 0000000..5c369cc --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/showcase/files/README.md @@ -0,0 +1,6 @@ +# showcase's packed files + +Packed as an `archive` artifact and unpacked onto the machine by an `archive` resource. + +This exists to exercise the case inlining cannot serve: a tree of files that belongs on a machine +and would make a declaration enormous if it were carried inside one. diff --git a/go/testdata/beside/mesh-catalog/modules/showcase/files/marker.txt b/go/testdata/beside/mesh-catalog/modules/showcase/files/marker.txt new file mode 100644 index 0000000..0246772 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/showcase/files/marker.txt @@ -0,0 +1 @@ +showcase diff --git a/go/testdata/beside/mesh-catalog/modules/showcase/module.json.captured b/go/testdata/beside/mesh-catalog/modules/showcase/module.json.captured new file mode 100644 index 0000000..e657e2a --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/showcase/module.json.captured @@ -0,0 +1,207 @@ +{ + "module": "showcase", + "version": "1", + "templates": "templates", + "slug": "show", + "capabilities": [ + "container-runtime" + ], + "provides": [ + { + "name": "greeting", + "scope": "mesh" + } + ], + "serves": { + "greeting": { + "path": "/greeting" + } + }, + "requires": [ + "postgres-database" + ], + "binds": { + "postgres-database": "${dir:state}/database.json" + }, + "secrets": { + "postgres-database": "${dir:state}/database.secret" + }, + "own-secrets": { + "broker": "${dir:mesh-state}/broker" + }, + "claims": [ + { + "name": "the-showcase", + "scope": "node" + } + ], + "emits": [ + "greeted", + "acknowledged" + ], + "consumes": [ + "showcase.greeted" + ], + "listens": [ + { + "name": "web", + "port": 8080, + "protocol": "tcp", + "from": "mesh", + "why": "the port the daemon itself listens on. The mesh assigns the machine-side number and tells consumers that one (ADR 0038)" + } + ], + "build": { + "artifacts": [ + { + "name": "code", + "kind": "bundle", + "language": "typescript", + "entrypoints": [ + "index.js", + "tools/index.js", + "provisioner/index.js", + "daemon/index.js", + "step/index.js", + "report/index.js" + ] + }, + { + "name": "files", + "kind": "archive", + "from": "files" + }, + { + "name": "helper", + "kind": "upstream", + "from": "alpine@sha256:28bd5fe8b56d1bd048e5babf5b10710ebe0bae67db86916198a6eec434943f8b" + }, + { + "name": "templates", + "kind": "archive", + "from": "templates" + } + ] + }, + "resources": [ + { + "id": "account", + "type": "user", + "name": "showcase", + "shell": "/usr/bin/nologin", + "home": "/var/lib/showcase" + }, + { + "id": "logs", + "type": "access", + "path": "/var/log", + "mode": "0755" + }, + { + "id": "mesh-state", + "type": "directory", + "mode": "0700", + "place": "mesh", + "unknown": "program" + }, + { + "id": "state", + "type": "directory", + "mode": "0755", + "place": ".", + "unknown": "program" + }, + { + "id": "settings", + "type": "file", + "path": "${dir:state}/showcase.env", + "mode": "0600", + "template": "templates/showcase.env" + }, + { + "id": "packed", + "type": "archive", + "path": "/opt/showcase", + "artifact": "files", + "unknown": "program" + }, + { + "id": "net", + "type": "network", + "name": "showcase" + }, + { + "id": "tooling", + "type": "package", + "package": "jq" + }, + { + "id": "migrate", + "type": "process", + "name": "showcase-migrate", + "artifact": "code", + "run": [ + "node", + "step/index.js" + ], + "run-once": true, + "env-file": [ + "${dir:state}/showcase.env" + ] + }, + { + "id": "server", + "type": "process", + "name": "showcase", + "artifact": "code", + "run": [ + "node", + "daemon/index.js" + ], + "user": "showcase", + "env-file": [ + "${dir:state}/showcase.env" + ], + "restart-on": [ + "settings" + ] + }, + { + "id": "reporting", + "type": "process", + "name": "showcase-report", + "artifact": "code", + "run": [ + "node", + "report/index.js" + ], + "schedule": "0 3 * * *", + "env-file": [ + "${dir:state}/showcase.env" + ] + }, + { + "id": "tools", + "type": "container", + "name": "the-showcase", + "artifact": "helper", + "network": "showcase", + "volumes": [ + "${dir:mesh-state}/broker:/run/secrets/broker:ro" + ], + "env": { + "MESH_BROKER_FILE": "/run/secrets/broker" + }, + "args": [ + "sleep", + "infinity" + ] + } + ], + "seats": [ + { + "name": "the-showcase", + "scope": "node" + } + ] +} diff --git a/go/testdata/beside/mesh-catalog/modules/showcase/templates/showcase.env b/go/testdata/beside/mesh-catalog/modules/showcase/templates/showcase.env new file mode 100644 index 0000000..8ea4a90 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/showcase/templates/showcase.env @@ -0,0 +1,5 @@ +SHOWCASE_GREETING=hello +SHOWCASE_EVERY_SECONDS=30 +SHOWCASE_STATE=/var/lib/showcase +SHOWCASE_DATABASE=${bound:postgres-database:at} +SHOWCASE_LISTEN=${port:8080} diff --git a/go/testdata/beside/mesh-catalog/modules/slack/files/display-session b/go/testdata/beside/mesh-catalog/modules/slack/files/display-session new file mode 100644 index 0000000..01660f8 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/slack/files/display-session @@ -0,0 +1,11 @@ +# 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 diff --git a/go/testdata/beside/mesh-catalog/modules/slack/module.json.captured b/go/testdata/beside/mesh-catalog/modules/slack/module.json.captured new file mode 100644 index 0000000..9feeedb --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/slack/module.json.captured @@ -0,0 +1,41 @@ +{ + "module": "slack", + "version": "1", + "moved": true, + "requires": [ + "x11-display" + ], + "tools": [ + "slack_status", + "slack_log", + "slack_restart", + "slack_check" + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/slack-tools", + "binary": "slack-tools", + "loads": [ + "slack-tools" + ] + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + }, + "items": [ + { + "collection": "contribution:node-display-session:config", + "name": "display-session", + "template": "files/display-session" + } + ] +} diff --git a/go/testdata/beside/mesh-catalog/modules/snapd/module.json.captured b/go/testdata/beside/mesh-catalog/modules/snapd/module.json.captured new file mode 100644 index 0000000..93ad730 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/snapd/module.json.captured @@ -0,0 +1,31 @@ +{ + "module": "snapd", + "version": "1", + "tools": [ + "snapd_status", + "snapd_list", + "snapd_info", + "snapd_updates", + "snapd_disk_usage", + "snapd_services", + "snapd_changes", + "snapd_install", + "snapd_remove", + "snapd_refresh" + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/snapd-tools", + "binary": "snapd-tools", + "loads": [ + "snapd-tools" + ] + } + ] + } +} diff --git a/go/testdata/beside/mesh-catalog/modules/ssh-client/files/config b/go/testdata/beside/mesh-catalog/modules/ssh-client/files/config new file mode 100644 index 0000000..dac07fb --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/ssh-client/files/config @@ -0,0 +1,6 @@ +# The mesh's region (module ssh-client, novox/hq research 027/03). It comes first because ssh +# takes the first value it finds for each option. It brings in ~/.ssh/config.d/ in name order: +# 00-mesh, the mesh's Host per machine, then each file another module places there. Replaced at +# every push. Everything below it is yours, kept as you wrote it, and applies to every host the +# files above leave unsettled. +Include ~/.ssh/config.d/* diff --git a/go/testdata/beside/mesh-catalog/modules/ssh-client/files/forge b/go/testdata/beside/mesh-catalog/modules/ssh-client/files/forge new file mode 100644 index 0000000..6bbf4e2 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/ssh-client/files/forge @@ -0,0 +1,7 @@ +Host ${bound:forge-account:ssh_alias} + HostName ${bound:forge-account:ssh_host} + Port ${bound:forge-account:ssh_port} + HostKeyAlias ${bound:forge-account:ssh_alias} + User git + IdentityFile ~/.ssh/mesh_ed25519 + IdentitiesOnly yes diff --git a/go/testdata/beside/mesh-catalog/modules/ssh-client/files/gitconfig b/go/testdata/beside/mesh-catalog/modules/ssh-client/files/gitconfig new file mode 100644 index 0000000..0525230 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/ssh-client/files/gitconfig @@ -0,0 +1,2 @@ +[url "ssh://${bound:forge-account:ssh_alias}/"] + insteadOf = ${bound:forge-account:https_base} diff --git a/go/testdata/beside/mesh-catalog/modules/ssh-client/files/known_hosts b/go/testdata/beside/mesh-catalog/modules/ssh-client/files/known_hosts new file mode 100644 index 0000000..e0df6d8 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/ssh-client/files/known_hosts @@ -0,0 +1 @@ +${bound:forge-account:ssh_alias} ${bound:forge-account:host_key} diff --git a/go/testdata/beside/mesh-catalog/modules/ssh-client/module.json.captured b/go/testdata/beside/mesh-catalog/modules/ssh-client/module.json.captured new file mode 100644 index 0000000..a696544 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/ssh-client/module.json.captured @@ -0,0 +1,112 @@ +{ + "module": "ssh-client", + "version": "1", + "moved": true, + "tools": [ + "ssh_client_hosts", + "ssh_client_resolve", + "ssh_client_check", + "ssh_client_keys", + "ssh_client_authorized", + "ssh_client_revoke", + "ssh_client_known_host", + "ssh_client_test" + ], + "requires": [ + "forge-account" + ], + "state": [ + "keys" + ], + "data": { + "own": [ + { + "id": "ssh", + "path": "${dir:ssh-dir}", + "class": "valuable", + "why": "the operator's keys and known hosts" + } + ] + }, + "resources": [ + { + "id": "ssh-dir", + "type": "directory", + "path": "${machine:account-home}/.ssh", + "owner": "${machine:account}", + "mode": "0700", + "unknown": "module" + }, + { + "id": "config-d", + "type": "directory", + "path": "${machine:account-home}/.ssh/config.d", + "owner": "${machine:account}", + "mode": "0700", + "unknown": "module" + }, + { + "id": "config", + "type": "file", + "path": "${machine:account-home}/.ssh/config", + "owner": "${machine:account}", + "mode": "0600", + "into": "block", + "at": "start", + "template": "files/config" + }, + { + "id": "known-hosts", + "type": "file", + "path": "${machine:account-home}/.ssh/known_hosts", + "owner": "${machine:account}", + "mode": "0644", + "into": "block", + "template": "files/known_hosts" + }, + { + "id": "forge-host", + "type": "file", + "path": "${machine:account-home}/.ssh/config.d/10-forge", + "owner": "${machine:account}", + "mode": "0600", + "template": "files/forge" + }, + { + "id": "git-config", + "type": "file", + "path": "${machine:account-home}/.config/git/config", + "owner": "${machine:account}", + "mode": "0644", + "into": "block", + "template": "files/gitconfig" + } + ], + "facts": { + "mesh-hosts": { + "path": ".ssh/config.d/00-mesh", + "home": true, + "template": "# Generated by the mesh. Do not edit — module ssh-client writes this file whenever a machine\n# joins, leaves or is renamed. ~/.ssh/config includes it first, so these hosts win over every\n# later line; a host of your own goes below the mesh's region in ~/.ssh/config.\n{{range .Machines}}{{if ne .Name $.Node}}\nHost {{.Name}} {{.FQDN}}\n HostName {{.FQDN}}\n{{if .Account}} User {{.Account}}\n{{end}}{{end}}{{end}}" + } + }, + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/ssh-client-tools", + "binary": "ssh-client-tools", + "loads": [ + "ssh-client-tools" + ] + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/go/testdata/beside/mesh-catalog/modules/sshd/files/10-mesh.conf b/go/testdata/beside/mesh-catalog/modules/sshd/files/10-mesh.conf new file mode 100644 index 0000000..86cd883 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/sshd/files/10-mesh.conf @@ -0,0 +1,10 @@ +# Managed by the mesh (module sshd). Replaced on every push; edit the catalogue instead. +Port 22 +PermitRootLogin no +PasswordAuthentication no +PubkeyAuthentication yes +KbdInteractiveAuthentication no +UsePAM yes +X11Forwarding no +PrintMotd no +AcceptEnv LANG LC_* diff --git a/go/testdata/beside/mesh-catalog/modules/sshd/module.json.captured b/go/testdata/beside/mesh-catalog/modules/sshd/module.json.captured new file mode 100644 index 0000000..066e058 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/sshd/module.json.captured @@ -0,0 +1,54 @@ +{ + "module": "sshd", + "version": "1", + "upgrade": { + "policy": "record", + "why": "the operator's way into the machine when the mesh cannot reach it: a build that breaks it is found only when that way is needed, which no gate sees (hq ADR 0236)" + }, + "capabilities": [ + "package-manager", + "service-manager" + ], + "listens": [ + { + "name": "ssh", + "port": 22, + "protocol": "tcp", + "from": "anywhere", + "why": "the operator's own door. From anywhere because the machines that need it are exactly the ones not on the mesh yet \u2014 and locking the operator out is the one failure a firewall must never arrange" + } + ], + "resources": [ + { + "id": "package", + "type": "package", + "package": "openssh" + }, + { + "id": "config", + "type": "file", + "path": "/etc/ssh/sshd_config.d/10-mesh.conf", + "mode": "0644", + "template": "files/10-mesh.conf" + }, + { + "id": "run", + "type": "service", + "unit": "sshd.service", + "state": "running", + "boot": "enabled", + "restart-on": [ + "config" + ] + } + ], + "build": { + "artifacts": [ + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/go/testdata/beside/mesh-catalog/modules/step-ca/files/config.json b/go/testdata/beside/mesh-catalog/modules/step-ca/files/config.json new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/step-ca/files/config.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/go/testdata/beside/mesh-catalog/modules/step-ca/files/init.env b/go/testdata/beside/mesh-catalog/modules/step-ca/files/init.env new file mode 100644 index 0000000..31d0896 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/step-ca/files/init.env @@ -0,0 +1,2 @@ +DOCKER_STEPCA_INIT_PASSWORD=${secret:password} +DOCKER_STEPCA_INIT_DNS_NAMES=${machine:at},${machine:name},localhost,127.0.0.1 diff --git a/go/testdata/beside/mesh-catalog/modules/step-ca/module.json.captured b/go/testdata/beside/mesh-catalog/modules/step-ca/module.json.captured new file mode 100644 index 0000000..b11d2b9 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/step-ca/module.json.captured @@ -0,0 +1,106 @@ +{ + "module": "step-ca", + "version": "1", + "capabilities": [ + "container-runtime" + ], + "provides": [ + { + "name": "internal-acme-ca", + "scope": "mesh" + } + ], + "serves": { + "internal-acme-ca": { + "path": "/acme/acme/directory", + "roots": "/roots.pem" + } + }, + "listens": [ + { + "name": "acme", + "port": 9000, + "protocol": "tcp", + "from": "mesh", + "why": "the ACME directory and step-ca API; a proxy on any node reaches it here over the mesh to obtain certificates, and its single listen is what the consumer is told the port is" + } + ], + "own-secrets": { + "password": "${dir:mesh-state}/password" + }, + "data": { + "own": [ + { + "id": "home", + "path": "${dir:home}", + "class": "valuable", + "why": "the mesh's certificate authority: its keys and its database of what it issued" + } + ] + }, + "resources": [ + { + "id": "mesh-state", + "type": "directory", + "mode": "0700", + "place": "mesh", + "unknown": "program" + }, + { + "id": "home", + "type": "directory", + "mode": "0700", + "owner": "1000:1000", + "place": ".", + "unknown": "program" + }, + { + "id": "config", + "type": "file", + "path": "${dir:mesh-state}/config.json", + "mode": "0644", + "template": "files/config.json", + "takes-config": [], + "merge": "json" + }, + { + "id": "init-env", + "type": "file", + "path": "${dir:mesh-state}/init.env", + "mode": "0600", + "template": "files/init.env" + }, + { + "id": "server", + "type": "container", + "name": "step-ca", + "image": "smallstep/step-ca@sha256:a2b17872915c193259b75a5474c398326f41bd199f0842093e52cf4182bc8270", + "health": { + "kind": "runtime" + }, + "network": "host", + "env-file": [ + "${dir:mesh-state}/init.env" + ], + "env": { + "DOCKER_STEPCA_INIT_NAME": "Mesh Internal CA", + "DOCKER_STEPCA_INIT_ACME": "true", + "DOCKER_STEPCA_INIT_REMOTE_MANAGEMENT": "false" + }, + "volumes": [ + "${dir:home}:/home/step", + "${dir:mesh-state}:/run/mesh:ro" + ], + "secrets-in-environment": "the entrypoint honours DOCKER_STEPCA_INIT_PASSWORD_FILE; convertible, awaiting a bed that proves it" + } + ], + "build": { + "artifacts": [ + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/go/testdata/beside/mesh-catalog/modules/sudo/files/10-mesh-operator b/go/testdata/beside/mesh-catalog/modules/sudo/files/10-mesh-operator new file mode 100644 index 0000000..997a49a --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/sudo/files/10-mesh-operator @@ -0,0 +1,9 @@ +# The mesh's (module sudo, novox/hq to-be 42, research 027): the operator account escalates +# without a prompt. The mesh's tools that act as root run `sudo -n` as this account and rely on it; +# until this file, every machine said so only in a line set by hand in /etc/sudoers. +# Written whole at every push: an edit here is overwritten. A file of this directory whose name +# holds a dot or ends in ~ is not read by sudo; this name holds neither. +# Every command run through sudo gets a terminal of its own (novox/hq ADR 0266): an agent the operator +# starts as the agent account through sudo cannot push keystrokes into the operator's shell (TIOCSTI). +Defaults use_pty +${machine:account} ALL=(ALL:ALL) NOPASSWD: ALL diff --git a/go/testdata/beside/mesh-catalog/modules/sudo/module.json.captured b/go/testdata/beside/mesh-catalog/modules/sudo/module.json.captured new file mode 100644 index 0000000..364ce5f --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/sudo/module.json.captured @@ -0,0 +1,47 @@ +{ + "module": "sudo", + "version": "1", + "capabilities": [ + "package-manager" + ], + "tools": [ + "sudo_rules", + "sudo_check", + "sudo_escalation", + "sudo_drop_ins" + ], + "resources": [ + { + "id": "package", + "type": "package", + "package": "sudo" + }, + { + "id": "operator", + "type": "file", + "path": "/etc/sudoers.d/10-mesh-operator", + "mode": "0440", + "template": "files/10-mesh-operator" + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/sudo-tools", + "binary": "sudo-tools", + "loads": [ + "sudo-tools" + ] + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/go/testdata/beside/mesh-catalog/modules/supabase/files/analytics-env.env b/go/testdata/beside/mesh-catalog/modules/supabase/files/analytics-env.env new file mode 100644 index 0000000..b1b877f --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/supabase/files/analytics-env.env @@ -0,0 +1,14 @@ +LOGFLARE_NODE_HOST=127.0.0.1 +DB_USERNAME=supabase_admin +DB_DATABASE=_supabase +DB_HOSTNAME=supabase-db +DB_PORT=5432 +DB_PASSWORD=${secret:postgres} +DB_SCHEMA=_analytics +LOGFLARE_API_KEY=${secret:logflare} +LOGFLARE_SINGLE_TENANT=true +LOGFLARE_SUPABASE_MODE=true +LOGFLARE_MIN_CLUSTER_SIZE=1 +POSTGRES_BACKEND_URL=postgresql://supabase_admin:${secret:postgres}@supabase-db:5432/_supabase +POSTGRES_BACKEND_SCHEMA=_analytics +LOGFLARE_FEATURE_FLAG_OVERRIDE=multibackend=true diff --git a/go/testdata/beside/mesh-catalog/modules/supabase/files/analytics-reconcile.exs b/go/testdata/beside/mesh-catalog/modules/supabase/files/analytics-reconcile.exs new file mode 100644 index 0000000..179776e --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/supabase/files/analytics-reconcile.exs @@ -0,0 +1,86 @@ +# Generated by the mesh. Do not edit: module supabase writes this file and replaces it at every push. +# +# logflare 1.4.0 copies two of its settings into its own database once and never reads them again: +# LOGFLARE_API_KEY becomes the default user's api_key when that user is created, and +# POSTGRES_BACKEND_URL becomes each source's backend when that source is created. A changed value +# reaches neither: a rotated key is refused, and a moved database is never written to (every +# source here pointed at db:5432, a name that resolves nowhere, so nothing was stored). +# +# Run by the start script before logflare starts, through logflare's own code (its Repo, its Users +# and Backends contexts and their changesets): it makes both copies say what the environment says. +# It changes nothing when they already do, says what it changed, never prints the key or a URL's +# password, and stops the start when it cannot finish. +fail = fn why -> + IO.puts(:stderr, "analytics: #{why}; not starting") + System.halt(1) +end + +say = fn what -> IO.puts(:stderr, "analytics: #{what}") end + +key = System.get_env("LOGFLARE_API_KEY") +url = System.get_env("POSTGRES_BACKEND_URL") +schema = System.get_env("POSTGRES_BACKEND_SCHEMA") + +if key in [nil, ""], do: fail.("LOGFLARE_API_KEY is not set, so the key logflare must accept is unknown") +if url in [nil, ""], do: fail.("POSTGRES_BACKEND_URL is not set, so where logflare must store logs is unknown") + +where = fn u -> + case URI.parse(to_string(u)) do + %URI{host: host, port: port, path: path} when is_binary(host) -> "#{host}:#{port}#{path}" + _ -> "an unreadable URL" + end +end + +Application.ensure_all_started(:ssl) + +outcome = + Ecto.Migrator.with_repo(Logflare.Repo, fn _repo -> + # Backends.update_source_backend_config restarts the source's pipeline after saving, and looks + # it up in this registry to do so; logflare is not running yet, so the registry is empty and + # there is nothing to restart. + {:ok, _} = Registry.start_link(keys: :unique, name: Logflare.Backends.SourceRegistry) + + case Logflare.SingleTenant.get_default_user() do + nil -> + say.("no default user yet; logflare creates it from LOGFLARE_API_KEY and POSTGRES_BACKEND_URL as it starts") + :ok + + user -> + if user.api_key == key and is_nil(user.old_api_key) do + say.("the default user's API key is LOGFLARE_API_KEY already") + else + # old_api_key is cleared too: logflare's UI can swap it back in, and the key replaced + # here may be one that leaked. + case Logflare.Users.update_user_all_fields(user, %{api_key: key, old_api_key: nil}) do + {:ok, _} -> say.("the default user's API key was replaced with LOGFLARE_API_KEY") + {:error, changeset} -> fail.("the default user's API key could not be replaced: #{inspect(changeset.errors)}") + end + end + + results = + for source <- Logflare.Sources.list_sources_by_user(user), + backend <- Logflare.Backends.list_source_backends(source), + backend.type == :postgres do + if backend.config.url == url and backend.config.schema == schema do + :same + else + case Logflare.Backends.update_source_backend_config(backend, %{url: url, schema: schema}) do + {:ok, _} -> + say.("source #{source.name} stored logs at #{where.(backend.config.url)} schema #{inspect(backend.config.schema)}; now at #{where.(url)} schema #{inspect(schema)}") + + {:error, changeset} -> + fail.("source #{source.name}'s backend could not be pointed at #{where.(url)}: #{inspect(changeset.errors)}") + end + end + end + + say.("#{Enum.count(results, &(&1 == :same))} source backend(s) already at #{where.(url)}") + + :ok + end + end) + +case outcome do + {:ok, :ok, _} -> :ok + other -> fail.("logflare's database could not be reconciled: #{inspect(other)}") +end diff --git a/go/testdata/beside/mesh-catalog/modules/supabase/files/analytics-start.sh b/go/testdata/beside/mesh-catalog/modules/supabase/files/analytics-start.sh new file mode 100644 index 0000000..bf39892 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/supabase/files/analytics-start.sh @@ -0,0 +1,41 @@ +#!/bin/sh +# Generated by the mesh. Do not edit: module supabase writes this file and replaces it at every push. +# +# Two things logflare 1.4.0 does not do for itself, done here before it starts; either failing +# stops the start, with the reason as the last line of this container's log. +# +# 1. The API key never travels in a URL. logflare prints the whole URL of every request that fails, +# query string included, in the error report Plug.Cowboy writes ("Request: POST +# /api/logs?source_name=...&api_key=..."). That is an error, so no log level hides it while errors +# are logged at all (novox/hq issue 268). logflare reads the key from the x-api-key header as well, +# and the mesh's caller, vector, sends it there; this refuses to start logflare while the vector +# config it is given still passes the key in a URL: an analytics that does not start says why +# here, and one that leaks says nothing. +# +# 2. Its database says what its environment says. logflare copies LOGFLARE_API_KEY into its default +# user and POSTGRES_BACKEND_URL into every source's backend once, when it creates them, and never +# again; reconcile.exs makes both copies match the environment through logflare's own code, after +# its migrations and before it starts. That is what lets the mesh rotate the key by starting +# logflare again, and what moves the sources' backend when the database's address or password +# changes. +set -e +conf=/run/logflare/vector.yml +if [ ! -r "$conf" ]; then + echo "analytics: $conf is not there to check, so whether the API key travels in a URL is unknown; not starting (novox/hq issue 268)" >&2 + exit 1 +fi +if grep -v '^[[:space:]]*#' "$conf" | grep -q 'api_key='; then + echo "analytics: $conf passes the API key in a URL, and logflare prints a failed request's URL; not starting (novox/hq issue 268)" >&2 + exit 1 +fi +reconcile=/run/logflare/reconcile.exs +if [ ! -r "$reconcile" ]; then + echo "analytics: $reconcile is not there, so whether logflare's stored key and backends match its environment is unknown; not starting" >&2 + exit 1 +fi +cd /opt/app/rel/logflare/bin +# The tables reconcile.exs reads exist only once the migrations have run; run.sh runs them again, +# which finds nothing left to do. +./logflare eval Logflare.Release.migrate +./logflare eval 'Code.eval_file("/run/logflare/reconcile.exs")' +exec sh run.sh diff --git a/go/testdata/beside/mesh-catalog/modules/supabase/files/db-init/jwt.sql b/go/testdata/beside/mesh-catalog/modules/supabase/files/db-init/jwt.sql new file mode 100644 index 0000000..90d8822 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/supabase/files/db-init/jwt.sql @@ -0,0 +1,2 @@ +ALTER DATABASE postgres SET "app.settings.jwt_secret" TO '${secret:jwt}'; +ALTER DATABASE postgres SET "app.settings.jwt_exp" TO '3600'; diff --git a/go/testdata/beside/mesh-catalog/modules/supabase/files/db-init/logs.sql b/go/testdata/beside/mesh-catalog/modules/supabase/files/db-init/logs.sql new file mode 100644 index 0000000..255c0f4 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/supabase/files/db-init/logs.sql @@ -0,0 +1,6 @@ +\set pguser `echo "$POSTGRES_USER"` + +\c _supabase +create schema if not exists _analytics; +alter schema _analytics owner to :pguser; +\c postgres diff --git a/go/testdata/beside/mesh-catalog/modules/supabase/files/db-init/pooler.sql b/go/testdata/beside/mesh-catalog/modules/supabase/files/db-init/pooler.sql new file mode 100644 index 0000000..162c5b9 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/supabase/files/db-init/pooler.sql @@ -0,0 +1,6 @@ +\set pguser `echo "$POSTGRES_USER"` + +\c _supabase +create schema if not exists _supavisor; +alter schema _supavisor owner to :pguser; +\c postgres diff --git a/go/testdata/beside/mesh-catalog/modules/supabase/files/db-init/realtime.sql b/go/testdata/beside/mesh-catalog/modules/supabase/files/db-init/realtime.sql new file mode 100644 index 0000000..4d4b9ff --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/supabase/files/db-init/realtime.sql @@ -0,0 +1,4 @@ +\set pguser `echo "$POSTGRES_USER"` + +create schema if not exists _realtime; +alter schema _realtime owner to :pguser; diff --git a/go/testdata/beside/mesh-catalog/modules/supabase/files/db-init/roles.sql b/go/testdata/beside/mesh-catalog/modules/supabase/files/db-init/roles.sql new file mode 100644 index 0000000..2a05c0b --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/supabase/files/db-init/roles.sql @@ -0,0 +1,7 @@ +\set pgpass `echo "$POSTGRES_PASSWORD"` + +ALTER USER authenticator WITH PASSWORD :'pgpass'; +ALTER USER pgbouncer WITH PASSWORD :'pgpass'; +ALTER USER supabase_auth_admin WITH PASSWORD :'pgpass'; +ALTER USER supabase_functions_admin WITH PASSWORD :'pgpass'; +ALTER USER supabase_storage_admin WITH PASSWORD :'pgpass'; diff --git a/go/testdata/beside/mesh-catalog/modules/supabase/files/db-init/supabase.sql b/go/testdata/beside/mesh-catalog/modules/supabase/files/db-init/supabase.sql new file mode 100644 index 0000000..6236ae1 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/supabase/files/db-init/supabase.sql @@ -0,0 +1,3 @@ +\set pguser `echo "$POSTGRES_USER"` + +CREATE DATABASE _supabase WITH OWNER :pguser; diff --git a/go/testdata/beside/mesh-catalog/modules/supabase/files/db-init/webhooks.sql b/go/testdata/beside/mesh-catalog/modules/supabase/files/db-init/webhooks.sql new file mode 100644 index 0000000..5837b86 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/supabase/files/db-init/webhooks.sql @@ -0,0 +1,208 @@ +BEGIN; + -- Create pg_net extension + CREATE EXTENSION IF NOT EXISTS pg_net SCHEMA extensions; + -- Create supabase_functions schema + CREATE SCHEMA supabase_functions AUTHORIZATION supabase_admin; + GRANT USAGE ON SCHEMA supabase_functions TO postgres, anon, authenticated, service_role; + ALTER DEFAULT PRIVILEGES IN SCHEMA supabase_functions GRANT ALL ON TABLES TO postgres, anon, authenticated, service_role; + ALTER DEFAULT PRIVILEGES IN SCHEMA supabase_functions GRANT ALL ON FUNCTIONS TO postgres, anon, authenticated, service_role; + ALTER DEFAULT PRIVILEGES IN SCHEMA supabase_functions GRANT ALL ON SEQUENCES TO postgres, anon, authenticated, service_role; + -- supabase_functions.migrations definition + CREATE TABLE supabase_functions.migrations ( + version text PRIMARY KEY, + inserted_at timestamptz NOT NULL DEFAULT NOW() + ); + -- Initial supabase_functions migration + INSERT INTO supabase_functions.migrations (version) VALUES ('initial'); + -- supabase_functions.hooks definition + CREATE TABLE supabase_functions.hooks ( + id bigserial PRIMARY KEY, + hook_table_id integer NOT NULL, + hook_name text NOT NULL, + created_at timestamptz NOT NULL DEFAULT NOW(), + request_id bigint + ); + CREATE INDEX supabase_functions_hooks_request_id_idx ON supabase_functions.hooks USING btree (request_id); + CREATE INDEX supabase_functions_hooks_h_table_id_h_name_idx ON supabase_functions.hooks USING btree (hook_table_id, hook_name); + COMMENT ON TABLE supabase_functions.hooks IS 'Supabase Functions Hooks: Audit trail for triggered hooks.'; + CREATE FUNCTION supabase_functions.http_request() + RETURNS trigger + LANGUAGE plpgsql + AS $function$ + DECLARE + request_id bigint; + payload jsonb; + url text := TG_ARGV[0]::text; + method text := TG_ARGV[1]::text; + headers jsonb DEFAULT '{}'::jsonb; + params jsonb DEFAULT '{}'::jsonb; + timeout_ms integer DEFAULT 1000; + BEGIN + IF url IS NULL OR url = 'null' THEN + RAISE EXCEPTION 'url argument is missing'; + END IF; + + IF method IS NULL OR method = 'null' THEN + RAISE EXCEPTION 'method argument is missing'; + END IF; + + IF TG_ARGV[2] IS NULL OR TG_ARGV[2] = 'null' THEN + headers = '{"Content-Type": "application/json"}'::jsonb; + ELSE + headers = TG_ARGV[2]::jsonb; + END IF; + + IF TG_ARGV[3] IS NULL OR TG_ARGV[3] = 'null' THEN + params = '{}'::jsonb; + ELSE + params = TG_ARGV[3]::jsonb; + END IF; + + IF TG_ARGV[4] IS NULL OR TG_ARGV[4] = 'null' THEN + timeout_ms = 1000; + ELSE + timeout_ms = TG_ARGV[4]::integer; + END IF; + + CASE + WHEN method = 'GET' THEN + SELECT http_get INTO request_id FROM net.http_get( + url, + params, + headers, + timeout_ms + ); + WHEN method = 'POST' THEN + payload = jsonb_build_object( + 'old_record', OLD, + 'record', NEW, + 'type', TG_OP, + 'table', TG_TABLE_NAME, + 'schema', TG_TABLE_SCHEMA + ); + + SELECT http_post INTO request_id FROM net.http_post( + url, + payload, + params, + headers, + timeout_ms + ); + ELSE + RAISE EXCEPTION 'method argument % is invalid', method; + END CASE; + + INSERT INTO supabase_functions.hooks + (hook_table_id, hook_name, request_id) + VALUES + (TG_RELID, TG_NAME, request_id); + + RETURN NEW; + END + $function$; + -- Supabase super admin + DO + $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_roles + WHERE rolname = 'supabase_functions_admin' + ) + THEN + CREATE USER supabase_functions_admin NOINHERIT CREATEROLE LOGIN NOREPLICATION; + END IF; + END + $$; + GRANT ALL PRIVILEGES ON SCHEMA supabase_functions TO supabase_functions_admin; + GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA supabase_functions TO supabase_functions_admin; + GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA supabase_functions TO supabase_functions_admin; + ALTER USER supabase_functions_admin SET search_path = "supabase_functions"; + ALTER table "supabase_functions".migrations OWNER TO supabase_functions_admin; + ALTER table "supabase_functions".hooks OWNER TO supabase_functions_admin; + ALTER function "supabase_functions".http_request() OWNER TO supabase_functions_admin; + GRANT supabase_functions_admin TO postgres; + -- Remove unused supabase_pg_net_admin role + DO + $$ + BEGIN + IF EXISTS ( + SELECT 1 + FROM pg_roles + WHERE rolname = 'supabase_pg_net_admin' + ) + THEN + REASSIGN OWNED BY supabase_pg_net_admin TO supabase_admin; + DROP OWNED BY supabase_pg_net_admin; + DROP ROLE supabase_pg_net_admin; + END IF; + END + $$; + -- pg_net grants when extension is already enabled + DO + $$ + BEGIN + IF EXISTS ( + SELECT 1 + FROM pg_extension + WHERE extname = 'pg_net' + ) + THEN + GRANT USAGE ON SCHEMA net TO supabase_functions_admin, postgres, anon, authenticated, service_role; + ALTER function net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) SECURITY DEFINER; + ALTER function net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) SECURITY DEFINER; + ALTER function net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) SET search_path = net; + ALTER function net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) SET search_path = net; + REVOKE ALL ON FUNCTION net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) FROM PUBLIC; + REVOKE ALL ON FUNCTION net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) FROM PUBLIC; + GRANT EXECUTE ON FUNCTION net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) TO supabase_functions_admin, postgres, anon, authenticated, service_role; + GRANT EXECUTE ON FUNCTION net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) TO supabase_functions_admin, postgres, anon, authenticated, service_role; + END IF; + END + $$; + -- Event trigger for pg_net + CREATE OR REPLACE FUNCTION extensions.grant_pg_net_access() + RETURNS event_trigger + LANGUAGE plpgsql + AS $$ + BEGIN + IF EXISTS ( + SELECT 1 + FROM pg_event_trigger_ddl_commands() AS ev + JOIN pg_extension AS ext + ON ev.objid = ext.oid + WHERE ext.extname = 'pg_net' + ) + THEN + GRANT USAGE ON SCHEMA net TO supabase_functions_admin, postgres, anon, authenticated, service_role; + ALTER function net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) SECURITY DEFINER; + ALTER function net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) SECURITY DEFINER; + ALTER function net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) SET search_path = net; + ALTER function net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) SET search_path = net; + REVOKE ALL ON FUNCTION net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) FROM PUBLIC; + REVOKE ALL ON FUNCTION net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) FROM PUBLIC; + GRANT EXECUTE ON FUNCTION net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) TO supabase_functions_admin, postgres, anon, authenticated, service_role; + GRANT EXECUTE ON FUNCTION net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) TO supabase_functions_admin, postgres, anon, authenticated, service_role; + END IF; + END; + $$; + COMMENT ON FUNCTION extensions.grant_pg_net_access IS 'Grants access to pg_net'; + DO + $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_event_trigger + WHERE evtname = 'issue_pg_net_access' + ) THEN + CREATE EVENT TRIGGER issue_pg_net_access ON ddl_command_end WHEN TAG IN ('CREATE EXTENSION') + EXECUTE PROCEDURE extensions.grant_pg_net_access(); + END IF; + END + $$; + INSERT INTO supabase_functions.migrations (version) VALUES ('20210809183423_update_grants'); + ALTER function supabase_functions.http_request() SECURITY DEFINER; + ALTER function supabase_functions.http_request() SET search_path = supabase_functions; + REVOKE ALL ON FUNCTION supabase_functions.http_request() FROM PUBLIC; + GRANT EXECUTE ON FUNCTION supabase_functions.http_request() TO postgres, anon, authenticated, service_role; +COMMIT; diff --git a/go/testdata/beside/mesh-catalog/modules/supabase/files/functions-env.env b/go/testdata/beside/mesh-catalog/modules/supabase/files/functions-env.env new file mode 100644 index 0000000..5eade5e --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/supabase/files/functions-env.env @@ -0,0 +1,6 @@ +JWT_SECRET=${secret:jwt} +SUPABASE_URL=http://supabase-kong:8000 +SUPABASE_ANON_KEY=${secret:anon-key} +SUPABASE_SERVICE_ROLE_KEY=${secret:service-role-key} +SUPABASE_DB_URL=postgresql://postgres:${secret:postgres}@supabase-db:5432/postgres +VERIFY_JWT=false diff --git a/go/testdata/beside/mesh-catalog/modules/supabase/files/functions-main/index.ts b/go/testdata/beside/mesh-catalog/modules/supabase/files/functions-main/index.ts new file mode 100644 index 0000000..a094010 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/supabase/files/functions-main/index.ts @@ -0,0 +1,94 @@ +import { serve } from 'https://deno.land/std@0.131.0/http/server.ts' +import * as jose from 'https://deno.land/x/jose@v4.14.4/index.ts' + +console.log('main function started') + +const JWT_SECRET = Deno.env.get('JWT_SECRET') +const VERIFY_JWT = Deno.env.get('VERIFY_JWT') === 'true' + +function getAuthToken(req: Request) { + const authHeader = req.headers.get('authorization') + if (!authHeader) { + throw new Error('Missing authorization header') + } + const [bearer, token] = authHeader.split(' ') + if (bearer !== 'Bearer') { + throw new Error(`Auth header is not 'Bearer {token}'`) + } + return token +} + +async function verifyJWT(jwt: string): Promise { + const encoder = new TextEncoder() + const secretKey = encoder.encode(JWT_SECRET) + try { + await jose.jwtVerify(jwt, secretKey) + } catch (err) { + console.error(err) + return false + } + return true +} + +serve(async (req: Request) => { + if (req.method !== 'OPTIONS' && VERIFY_JWT) { + try { + const token = getAuthToken(req) + const isValidJWT = await verifyJWT(token) + + if (!isValidJWT) { + return new Response(JSON.stringify({ msg: 'Invalid JWT' }), { + status: 401, + headers: { 'Content-Type': 'application/json' }, + }) + } + } catch (e) { + console.error(e) + return new Response(JSON.stringify({ msg: e.toString() }), { + status: 401, + headers: { 'Content-Type': 'application/json' }, + }) + } + } + + const url = new URL(req.url) + const { pathname } = url + const path_parts = pathname.split('/') + const service_name = path_parts[1] + + if (!service_name || service_name === '') { + const error = { msg: 'missing function name in request' } + return new Response(JSON.stringify(error), { + status: 400, + headers: { 'Content-Type': 'application/json' }, + }) + } + + const servicePath = `/home/deno/functions/${service_name}` + console.error(`serving the request with ${servicePath}`) + + const memoryLimitMb = 150 + const workerTimeoutMs = 1 * 60 * 1000 + const noModuleCache = false + const importMapPath = null + const envVarsObj = Deno.env.toObject() + const envVars = Object.keys(envVarsObj).map((k) => [k, envVarsObj[k]]) + + try { + const worker = await EdgeRuntime.userWorkers.create({ + servicePath, + memoryLimitMb, + workerTimeoutMs, + noModuleCache, + importMapPath, + envVars, + }) + return await worker.fetch(req) + } catch (e) { + const error = { msg: e.toString() } + return new Response(JSON.stringify(error), { + status: 500, + headers: { 'Content-Type': 'application/json' }, + }) + } +}) diff --git a/go/testdata/beside/mesh-catalog/modules/supabase/files/gotrue.env b/go/testdata/beside/mesh-catalog/modules/supabase/files/gotrue.env new file mode 100644 index 0000000..f29f778 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/supabase/files/gotrue.env @@ -0,0 +1,28 @@ +GOTRUE_API_HOST=0.0.0.0 +GOTRUE_API_PORT=9999 +API_EXTERNAL_URL=https://${bound:route:name} +GOTRUE_DB_DRIVER=postgres +GOTRUE_DB_DATABASE_URL=postgres://supabase_auth_admin:${secret:postgres}@supabase-db:5432/postgres +GOTRUE_SITE_URL=https://${bound:route:name} +GOTRUE_URI_ALLOW_LIST= +GOTRUE_DISABLE_SIGNUP=true +GOTRUE_JWT_ADMIN_ROLES=service_role +GOTRUE_JWT_AUD=authenticated +GOTRUE_JWT_DEFAULT_GROUP_NAME=authenticated +GOTRUE_JWT_EXP=3600 +GOTRUE_JWT_SECRET=${secret:jwt} +GOTRUE_EXTERNAL_EMAIL_ENABLED=false +GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED=false +GOTRUE_MAILER_AUTOCONFIRM=false +GOTRUE_SMTP_ADMIN_EMAIL=admin@example.com +GOTRUE_SMTP_HOST=supabase-mail +GOTRUE_SMTP_PORT=2500 +GOTRUE_SMTP_USER=fake_mail_user +GOTRUE_SMTP_PASS=fake_mail_password +GOTRUE_SMTP_SENDER_NAME=fake_sender +GOTRUE_MAILER_URLPATHS_INVITE=/auth/v1/verify +GOTRUE_MAILER_URLPATHS_CONFIRMATION=/auth/v1/verify +GOTRUE_MAILER_URLPATHS_RECOVERY=/auth/v1/verify +GOTRUE_MAILER_URLPATHS_EMAIL_CHANGE=/auth/v1/verify +GOTRUE_EXTERNAL_PHONE_ENABLED=false +GOTRUE_SMS_AUTOCONFIRM=true diff --git a/go/testdata/beside/mesh-catalog/modules/supabase/files/kong.yml b/go/testdata/beside/mesh-catalog/modules/supabase/files/kong.yml new file mode 100644 index 0000000..e13bdb2 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/supabase/files/kong.yml @@ -0,0 +1,242 @@ +# Written by the mesh (modules/supabase): the gateway's declarative config, credentials rendered in. +_format_version: '2.1' +_transform: true + +### +### Consumers / Users +### +consumers: + - username: DASHBOARD + - username: anon + keyauth_credentials: + - key: "${secret:anon-key}" + - username: service_role + keyauth_credentials: + - key: "${secret:service-role-key}" + +### +### Access Control List +### +acls: + - consumer: anon + group: anon + - consumer: service_role + group: admin + +### +### Dashboard credentials +### +basicauth_credentials: + - consumer: DASHBOARD + username: "${secret:dashboard-user}" + password: "${secret:dashboard}" + +### +### API Routes +### +services: + ## Open Auth routes + - name: auth-v1-open + url: http://supabase-auth:9999/verify + routes: + - name: auth-v1-open + strip_path: true + paths: + - /auth/v1/verify + plugins: + - name: cors + - name: auth-v1-open-callback + url: http://supabase-auth:9999/callback + routes: + - name: auth-v1-open-callback + strip_path: true + paths: + - /auth/v1/callback + plugins: + - name: cors + - name: auth-v1-open-authorize + url: http://supabase-auth:9999/authorize + routes: + - name: auth-v1-open-authorize + strip_path: true + paths: + - /auth/v1/authorize + plugins: + - name: cors + + ## Secure Auth routes + - name: auth-v1 + _comment: 'GoTrue: /auth/v1/* -> http://auth:9999/*' + url: http://supabase-auth:9999/ + routes: + - name: auth-v1-all + strip_path: true + paths: + - /auth/v1/ + plugins: + - name: cors + - name: key-auth + config: + hide_credentials: false + - name: acl + config: + hide_groups_header: true + allow: + - admin + - anon + + ## Secure REST routes + - name: rest-v1 + _comment: 'PostgREST: /rest/v1/* -> http://rest:3000/*' + url: http://supabase-rest:3000/ + routes: + - name: rest-v1-all + strip_path: true + paths: + - /rest/v1/ + plugins: + - name: cors + - name: key-auth + config: + hide_credentials: true + - name: acl + config: + hide_groups_header: true + allow: + - admin + - anon + + ## Secure GraphQL routes + - name: graphql-v1 + _comment: 'PostgREST: /graphql/v1/* -> http://rest:3000/rpc/graphql' + url: http://supabase-rest:3000/rpc/graphql + routes: + - name: graphql-v1-all + strip_path: true + paths: + - /graphql/v1 + plugins: + - name: cors + - name: key-auth + config: + hide_credentials: true + - name: request-transformer + config: + add: + headers: + - Content-Profile:graphql_public + - name: acl + config: + hide_groups_header: true + allow: + - admin + - anon + + ## Secure Realtime routes + - name: realtime-v1-ws + _comment: 'Realtime: /realtime/v1/* -> ws://realtime:4000/socket/*' + url: http://realtime-dev.supabase-realtime:4000/socket + protocol: ws + routes: + - name: realtime-v1-ws + strip_path: true + paths: + - /realtime/v1/ + plugins: + - name: cors + - name: key-auth + config: + hide_credentials: false + - name: acl + config: + hide_groups_header: true + allow: + - admin + - anon + - name: realtime-v1-rest + _comment: 'Realtime: /realtime/v1/* -> ws://realtime:4000/socket/*' + url: http://realtime-dev.supabase-realtime:4000/api + protocol: http + routes: + - name: realtime-v1-rest + strip_path: true + paths: + - /realtime/v1/api + plugins: + - name: cors + - name: key-auth + config: + hide_credentials: false + - name: acl + config: + hide_groups_header: true + allow: + - admin + - anon + ## Storage routes: the storage server manages its own auth + - name: storage-v1 + _comment: 'Storage: /storage/v1/* -> http://storage:5000/*' + url: http://supabase-storage:5000/ + routes: + - name: storage-v1-all + strip_path: true + paths: + - /storage/v1/ + plugins: + - name: cors + + ## Edge Functions routes + - name: functions-v1 + _comment: 'Edge Functions: /functions/v1/* -> http://functions:9000/*' + url: http://supabase-edge-functions:9000/ + routes: + - name: functions-v1-all + strip_path: true + paths: + - /functions/v1/ + plugins: + - name: cors + + ## Analytics routes + - name: analytics-v1 + _comment: 'Analytics: /analytics/v1/* -> http://logflare:4000/*' + url: http://supabase-analytics:4000/ + routes: + - name: analytics-v1-all + strip_path: true + paths: + - /analytics/v1/ + + ## Secure Database routes + - name: meta + _comment: 'pg-meta: /pg/* -> http://pg-meta:8080/*' + url: http://supabase-meta:8080/ + routes: + - name: meta-all + strip_path: true + paths: + - /pg/ + plugins: + - name: key-auth + config: + hide_credentials: false + - name: acl + config: + hide_groups_header: true + allow: + - admin + + ## Protected Dashboard - catch all remaining routes + - name: dashboard + _comment: 'Studio: /* -> http://studio:3000/*' + url: http://supabase-studio:3000/ + routes: + - name: dashboard-all + strip_path: true + paths: + - / + plugins: + - name: cors + - name: basic-auth + config: + hide_credentials: true diff --git a/go/testdata/beside/mesh-catalog/modules/supabase/files/meta-env.env b/go/testdata/beside/mesh-catalog/modules/supabase/files/meta-env.env new file mode 100644 index 0000000..c9d2509 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/supabase/files/meta-env.env @@ -0,0 +1,6 @@ +PG_META_PORT=8080 +PG_META_DB_HOST=supabase-db +PG_META_DB_PORT=5432 +PG_META_DB_NAME=postgres +PG_META_DB_USER=supabase_admin +PG_META_DB_PASSWORD=${secret:postgres} diff --git a/go/testdata/beside/mesh-catalog/modules/supabase/files/pooler-env.env b/go/testdata/beside/mesh-catalog/modules/supabase/files/pooler-env.env new file mode 100644 index 0000000..f02c7cd --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/supabase/files/pooler-env.env @@ -0,0 +1,14 @@ +PORT=4000 +POSTGRES_PORT=5432 +POSTGRES_DB=postgres +POSTGRES_PASSWORD=${secret:postgres} +DATABASE_URL=ecto://supabase_admin:${secret:postgres}@supabase-db:5432/_supabase +CLUSTER_POSTGRES=true +SECRET_KEY_BASE=${secret:key-base-a}${secret:key-base-b} +VAULT_ENC_KEY=${secret:pooler-vault} +API_JWT_SECRET=${secret:jwt} +METRICS_JWT_SECRET=${secret:jwt} +REGION=local +ERL_AFLAGS=-proto_dist inet_tcp +RELEASE_NODE=supavisor@127.0.0.1 +POOLER_POOL_MODE=transaction diff --git a/go/testdata/beside/mesh-catalog/modules/supabase/files/pooler.exs b/go/testdata/beside/mesh-catalog/modules/supabase/files/pooler.exs new file mode 100644 index 0000000..61b5ba8 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/supabase/files/pooler.exs @@ -0,0 +1,43 @@ +{:ok, _} = Application.ensure_all_started(:supavisor) + +# Written by the mesh (modules/supabase). Per-machine values come from settings.json (the module's +# one merge:json file); secrets come from the environment the image reads. +settings = "/etc/pooler/settings.json" |> File.read!() |> Jason.decode!() |> Map.get("pooler", %{}) + +{:ok, version} = + case Supavisor.Repo.query!("select version()") do + %{rows: [[ver]]} -> Supavisor.Helpers.parse_pg_version(ver) + _ -> nil + end + +params = %{ + "external_id" => to_string(settings["tenant"] || "default"), + "db_host" => "supabase-db", + "db_port" => System.get_env("POSTGRES_PORT"), + "db_database" => System.get_env("POSTGRES_DB"), + "require_user" => false, + "auth_query" => "SELECT * FROM pgbouncer.get_auth($1)", + "default_max_clients" => settings["max-client-connections"] || 100, + "default_pool_size" => settings["pool-size"] || 20, + "default_parameter_status" => %{"server_version" => version}, + "users" => [%{ + "db_user" => "pgbouncer", + "db_password" => System.get_env("POSTGRES_PASSWORD"), + "mode_type" => System.get_env("POOLER_POOL_MODE"), + "pool_size" => settings["pool-size"] || 20, + "is_manager" => true + }] +} + +case Supavisor.Tenants.get_tenant_by_external_id(params["external_id"]) do + nil -> + {:ok, _} = Supavisor.Tenants.create_tenant(params) + + # A tenant made elsewhere (HAL named the database host "db") is pointed at this module's + # database container; nothing else it holds is changed. + %{db_host: host} = tenant when host != "supabase-db" -> + {:ok, _} = Supavisor.Tenants.update_tenant(tenant, %{"db_host" => "supabase-db"}) + + _ -> + :ok +end diff --git a/go/testdata/beside/mesh-catalog/modules/supabase/files/postgrest.conf b/go/testdata/beside/mesh-catalog/modules/supabase/files/postgrest.conf new file mode 100644 index 0000000..1755753 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/supabase/files/postgrest.conf @@ -0,0 +1,7 @@ +db-uri = "postgres://authenticator:${secret:postgres}@supabase-db:5432/postgres" +db-schemas = "public,storage,graphql_public" +db-anon-role = "anon" +jwt-secret = "${secret:jwt}" +db-use-legacy-gucs = false +app.settings.jwt_secret = "${secret:jwt}" +app.settings.jwt_exp = "3600" diff --git a/go/testdata/beside/mesh-catalog/modules/supabase/files/realtime-env.env b/go/testdata/beside/mesh-catalog/modules/supabase/files/realtime-env.env new file mode 100644 index 0000000..75e2136 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/supabase/files/realtime-env.env @@ -0,0 +1,16 @@ +PORT=4000 +DB_HOST=supabase-db +DB_PORT=5432 +DB_USER=supabase_admin +DB_PASSWORD=${secret:postgres} +DB_NAME=postgres +DB_AFTER_CONNECT_QUERY=SET search_path TO _realtime +DB_ENC_KEY=supabaserealtime +API_JWT_SECRET=${secret:jwt} +SECRET_KEY_BASE=${secret:key-base-a}${secret:key-base-b} +ERL_AFLAGS=-proto_dist inet_tcp +DNS_NODES='' +RLIMIT_NOFILE=10000 +APP_NAME=realtime +SEED_SELF_HOST=true +RUN_JANITOR=true diff --git a/go/testdata/beside/mesh-catalog/modules/supabase/files/settings.json b/go/testdata/beside/mesh-catalog/modules/supabase/files/settings.json new file mode 100644 index 0000000..8ab8cfa --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/supabase/files/settings.json @@ -0,0 +1,7 @@ +{ + "pooler": { + "tenant": "default", + "pool-size": 20, + "max-client-connections": 100 + } +} diff --git a/go/testdata/beside/mesh-catalog/modules/supabase/files/storage-env.env b/go/testdata/beside/mesh-catalog/modules/supabase/files/storage-env.env new file mode 100644 index 0000000..fd5da86 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/supabase/files/storage-env.env @@ -0,0 +1,13 @@ +ANON_KEY=${secret:anon-key} +SERVICE_KEY=${secret:service-role-key} +POSTGREST_URL=http://supabase-rest:3000 +PGRST_JWT_SECRET=${secret:jwt} +DATABASE_URL=postgres://supabase_storage_admin:${secret:postgres}@supabase-db:5432/postgres +FILE_SIZE_LIMIT=52428800 +STORAGE_BACKEND=file +FILE_STORAGE_BACKEND_PATH=/var/lib/storage +TENANT_ID=stub +REGION=stub +GLOBAL_S3_BUCKET=stub +ENABLE_IMAGE_TRANSFORMATION=true +IMGPROXY_URL=http://supabase-imgproxy:5001 diff --git a/go/testdata/beside/mesh-catalog/modules/supabase/files/studio-env.env b/go/testdata/beside/mesh-catalog/modules/supabase/files/studio-env.env new file mode 100644 index 0000000..303dbc5 --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/supabase/files/studio-env.env @@ -0,0 +1,18 @@ +# Next.js listens on the address HOSTNAME names, and docker sets HOSTNAME to the container's id, +# so studio answered only on its network address while its image's health check asks localhost: +# unhealthy for ever while working. Every address, as the upstream compose file has it. +HOSTNAME=:: +STUDIO_PG_META_URL=http://supabase-meta:8080 +POSTGRES_PASSWORD=${secret:postgres} +DEFAULT_ORGANIZATION_NAME=Default Organization +DEFAULT_PROJECT_NAME=Default Project +OPENAI_API_KEY=${secret:openai} +SUPABASE_URL=http://supabase-kong:8000 +SUPABASE_PUBLIC_URL=https://${bound:route:name} +SUPABASE_ANON_KEY=${secret:anon-key} +SUPABASE_SERVICE_KEY=${secret:service-role-key} +AUTH_JWT_SECRET=${secret:jwt} +LOGFLARE_API_KEY=${secret:logflare} +LOGFLARE_URL=http://supabase-analytics:4000 +NEXT_PUBLIC_ENABLE_LOGS=true +NEXT_ANALYTICS_BACKEND_PROVIDER=postgres diff --git a/go/testdata/beside/mesh-catalog/modules/supabase/files/vector.yml b/go/testdata/beside/mesh-catalog/modules/supabase/files/vector.yml new file mode 100644 index 0000000..fb0f03d --- /dev/null +++ b/go/testdata/beside/mesh-catalog/modules/supabase/files/vector.yml @@ -0,0 +1,258 @@ +# Written by the mesh (modules/supabase). +# +# Every sink hands logflare its API key in the x-api-key header, never as an api_key query parameter: +# logflare prints a failed request's whole URL, query string included, in its error report, so a +# key in the URL is a key in its log (novox/hq issue 268). supabase-analytics refuses to start +# while this file still puts the key in a URL. +api: + enabled: true + address: 0.0.0.0:9001 + +sources: + docker_host: + type: docker_logs + include_containers: + - supabase-kong + - supabase-auth + - supabase-rest + - realtime-dev.supabase-realtime + - supabase-storage + - supabase-edge-functions + - supabase-db + +transforms: + project_logs: + type: remap + inputs: + - docker_host + source: |- + .project = "default" + .event_message = del(.message) + .appname = del(.container_name) + del(.container_created_at) + del(.container_id) + del(.source_type) + del(.stream) + del(.label) + del(.image) + del(.host) + del(.stream) + router: + type: route + inputs: + - project_logs + route: + kong: '.appname == "supabase-kong"' + auth: '.appname == "supabase-auth"' + rest: '.appname == "supabase-rest"' + realtime: '.appname == "realtime-dev.supabase-realtime"' + storage: '.appname == "supabase-storage"' + functions: '.appname == "supabase-edge-functions"' + db: '.appname == "supabase-db"' + # Ignores non nginx errors since they are related with kong booting up + kong_logs: + type: remap + inputs: + - router.kong + source: |- + req, err = parse_nginx_log(.event_message, "combined") + if err == null { + .timestamp = req.timestamp + .metadata.request.headers.referer = req.referer + .metadata.request.headers.user_agent = req.agent + .metadata.request.headers.cf_connecting_ip = req.client + .metadata.request.method = req.method + .metadata.request.path = req.path + .metadata.request.protocol = req.protocol + .metadata.response.status_code = req.status + } + if err != null { + abort + } + # Ignores non nginx errors since they are related with kong booting up + kong_err: + type: remap + inputs: + - router.kong + source: |- + .metadata.request.method = "GET" + .metadata.response.status_code = 200 + parsed, err = parse_nginx_log(.event_message, "error") + if err == null { + .timestamp = parsed.timestamp + .severity = parsed.severity + .metadata.request.host = parsed.host + .metadata.request.headers.cf_connecting_ip = parsed.client + url, err = split(parsed.request, " ") + if err == null { + .metadata.request.method = url[0] + .metadata.request.path = url[1] + .metadata.request.protocol = url[2] + } + } + if err != null { + abort + } + # Gotrue logs are structured json strings which frontend parses directly. But we keep metadata for consistency. + auth_logs: + type: remap + inputs: + - router.auth + source: |- + parsed, err = parse_json(.event_message) + if err == null { + .metadata.timestamp = parsed.time + .metadata = merge!(.metadata, parsed) + } + # PostgREST logs are structured so we separate timestamp from message using regex + rest_logs: + type: remap + inputs: + - router.rest + source: |- + parsed, err = parse_regex(.event_message, r'^(?P