mesh-sdk takes the catalogue's remaining tests and the facts snapshot's layout, with each machine's grants and bindings (design 47 R4b, issues 1379, 1380) #49
@@ -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)))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 <nil>", 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 <nil>".
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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=<b>,place=<p>}`, 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 <attribute>=<value>",
|
||||
"${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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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=<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)
|
||||
}
|
||||
}
|
||||
@@ -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/<module>, 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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 "))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 - "))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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:<key>}, 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)
|
||||
}
|
||||
}
|
||||
@@ -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>.<name>`},
|
||||
{`{"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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+3
-2
@@ -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
|
||||
|
||||
@@ -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 + ")"
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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...) }
|
||||
Vendored
+27
@@ -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 <commit> -- 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 <commit>: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.
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
Adwaita:dark
|
||||
@@ -0,0 +1 @@
|
||||
/usr/share/themes/Adwaita-dark/gtk-2.0/gtkrc
|
||||
+1
@@ -0,0 +1 @@
|
||||
qt6ct
|
||||
@@ -0,0 +1 @@
|
||||
6
|
||||
@@ -0,0 +1 @@
|
||||
Fusion
|
||||
@@ -0,0 +1 @@
|
||||
24
|
||||
@@ -0,0 +1 @@
|
||||
Adwaita
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
! adwaita: the cursor, for X programs that take it from the resources.
|
||||
Xcursor.theme: Adwaita
|
||||
Xcursor.size: 24
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
+4
@@ -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}
|
||||
+65
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
+33
@@ -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}%"
|
||||
+41
@@ -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
|
||||
+20
@@ -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
|
||||
+27
@@ -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"
|
||||
+12
@@ -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
|
||||
+46
@@ -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 "$@"
|
||||
+29
@@ -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
|
||||
+229
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
+11
@@ -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
|
||||
+12
@@ -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
|
||||
+7
@@ -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
|
||||
+30
@@ -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
|
||||
+10
@@ -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
|
||||
go/testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/templates/modprobe.d/g14-nvidia-power.conf
Vendored
+16
@@ -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
|
||||
+6
@@ -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
|
||||
+7
@@ -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
|
||||
+7
@@ -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
|
||||
+7
@@ -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
|
||||
+7
@@ -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
|
||||
Vendored
+5
@@ -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"
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"password": "${secret:admin}",
|
||||
"host": "${bound:route:name}"
|
||||
}
|
||||
@@ -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}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"accounts": {}
|
||||
}
|
||||
@@ -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 "$@"
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"identities": {},
|
||||
"calendars": {},
|
||||
"address-books": {},
|
||||
"filters": {}
|
||||
}
|
||||
+25
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"prefs": {}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"tags": {}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
${machine:agent-account}
|
||||
@@ -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 "$@"
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"node": "${machine:name}",
|
||||
"console": "http://127.0.0.1:${bound:mcp-endpoint:port}/mcp"
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"role": "",
|
||||
"mcp_servers": {},
|
||||
"managed_settings": {},
|
||||
"instructions": {},
|
||||
"output_styles": {},
|
||||
"skills": {},
|
||||
"commands": {},
|
||||
"agents": {}
|
||||
}
|
||||
@@ -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=$(</etc/claude-code/agent-account)
|
||||
if [[ -n $agent && $agent != $USERNAME ]]; then
|
||||
alias claude=claude-agent
|
||||
fi
|
||||
}
|
||||
fi
|
||||
@@ -0,0 +1,247 @@
|
||||
{
|
||||
"module": "claude-code",
|
||||
"version": "1",
|
||||
"moved": true,
|
||||
"slug": "agent",
|
||||
"capabilities": [
|
||||
"package-manager"
|
||||
],
|
||||
"requires": [
|
||||
"mcp-endpoint",
|
||||
{
|
||||
"name": "gitea-read",
|
||||
"optional": true
|
||||
}
|
||||
],
|
||||
"binds": {
|
||||
"mcp-endpoint": "${dir:state}/mcp-endpoint.json"
|
||||
},
|
||||
"secrets": {
|
||||
"gitea-read": "${dir:state}/gitea-read.secret"
|
||||
},
|
||||
"uses": [
|
||||
"operator-channel"
|
||||
],
|
||||
"state": [
|
||||
"servers",
|
||||
"holdings",
|
||||
"config",
|
||||
"proposals",
|
||||
"questions",
|
||||
"memory"
|
||||
],
|
||||
"reads": [
|
||||
"claude-licence-manager.bindings"
|
||||
],
|
||||
"tools": [
|
||||
"claude_code_status",
|
||||
"claude_code_render",
|
||||
"claude_code_guard",
|
||||
"claude_code_pull",
|
||||
"claude_code_grant",
|
||||
"claude_code_add_api_key",
|
||||
"claude_code_registrations",
|
||||
"claude_code_memory",
|
||||
"claude_code_mcp_list",
|
||||
"claude_code_mcp_register",
|
||||
"claude_code_mcp_unregister",
|
||||
"claude_code_skill_list",
|
||||
"claude_code_skill_register",
|
||||
"claude_code_skill_unregister",
|
||||
"claude_code_agent_list",
|
||||
"claude_code_agent_register",
|
||||
"claude_code_agent_unregister",
|
||||
"claude_code_command_list",
|
||||
"claude_code_command_register",
|
||||
"claude_code_command_unregister",
|
||||
"claude_code_hook_list",
|
||||
"claude_code_hook_register",
|
||||
"claude_code_hook_unregister",
|
||||
"claude_code_output_style_list",
|
||||
"claude_code_output_style_register",
|
||||
"claude_code_output_style_unregister",
|
||||
"claude_code_instructions_list",
|
||||
"claude_code_instructions_register",
|
||||
"claude_code_instructions_unregister",
|
||||
"claude_code_instructions_propose",
|
||||
"claude_code_proposals",
|
||||
"claude_code_ask",
|
||||
"claude_code_answer",
|
||||
"claude_code_settings_get",
|
||||
"claude_code_settings_set",
|
||||
"claude_code_settings_clear",
|
||||
"claude_code_permission_add",
|
||||
"claude_code_permission_remove",
|
||||
"claude_code_config_list",
|
||||
"claude_code_config_show",
|
||||
"claude_code_config_status",
|
||||
"claude_code_config_import",
|
||||
"claude_code_home_show",
|
||||
"claude_code_home_remove",
|
||||
"claude_code_home_removed",
|
||||
"claude_code_home_restore",
|
||||
"claude_code_judge",
|
||||
"claude_code_judged"
|
||||
],
|
||||
"data": {
|
||||
"own": [
|
||||
{
|
||||
"id": "agent-home",
|
||||
"path": "${dir:agent-home}",
|
||||
"class": "valuable",
|
||||
"why": "the operator's agent's own files: its memory, history and projects"
|
||||
}
|
||||
]
|
||||
},
|
||||
"config": {
|
||||
"role": {
|
||||
"type": "string",
|
||||
"description": "What this machine is for, in a sentence, written into the instructions every Claude Code session on it reads; empty says nothing.",
|
||||
"trusted": true,
|
||||
"default": ""
|
||||
},
|
||||
"mcp_servers": {
|
||||
"type": "object",
|
||||
"description": "The tool servers every session on this machine is given beside the mesh's own, by name, each as Claude Code's managed MCP configuration takes it; empty adds none.",
|
||||
"trusted": true,
|
||||
"default": {}
|
||||
},
|
||||
"managed_settings": {
|
||||
"type": "object",
|
||||
"description": "Keys of Claude Code's managed settings every session on this machine obeys, such as spinnerTipsEnabled, laid over the mesh's own; empty adds none. Falls back to agent-managed-settings, the mesh's or this node's.",
|
||||
"trusted": true,
|
||||
"predefined": "agent-managed-settings",
|
||||
"default": {}
|
||||
},
|
||||
"instructions": {
|
||||
"type": "object",
|
||||
"description": "The instructions every session on this machine reads, by name, each a text as one line or a list of its lines; empty adds none.",
|
||||
"trusted": true,
|
||||
"default": {}
|
||||
},
|
||||
"output_styles": {
|
||||
"type": "object",
|
||||
"description": "The output styles offered to every session on this machine, by name, each a text as one line or a list of its lines; empty adds none.",
|
||||
"trusted": true,
|
||||
"default": {}
|
||||
},
|
||||
"skills": {
|
||||
"type": "object",
|
||||
"description": "The skills offered to every session on this machine, by name, each its files by path, a text as one line or a list of its lines; a skill without its SKILL.md is left out; empty adds none.",
|
||||
"trusted": true,
|
||||
"default": {}
|
||||
},
|
||||
"commands": {
|
||||
"type": "object",
|
||||
"description": "The slash commands offered to every session on this machine, by name, each a text as one line or a list of its lines; empty adds none.",
|
||||
"trusted": true,
|
||||
"default": {}
|
||||
},
|
||||
"agents": {
|
||||
"type": "object",
|
||||
"description": "The agents offered to every session on this machine, by name, each a text as one line or a list of its lines; empty adds none.",
|
||||
"trusted": true,
|
||||
"default": {}
|
||||
}
|
||||
},
|
||||
"resources": [
|
||||
{
|
||||
"id": "package",
|
||||
"type": "package",
|
||||
"package": "claude-code"
|
||||
},
|
||||
{
|
||||
"id": "managed",
|
||||
"type": "directory",
|
||||
"path": "/etc/claude-code",
|
||||
"mode": "0755",
|
||||
"unknown": "program"
|
||||
},
|
||||
{
|
||||
"id": "start",
|
||||
"type": "file",
|
||||
"path": "/usr/local/bin/claude-agent",
|
||||
"mode": "0755",
|
||||
"template": "files/claude-agent"
|
||||
},
|
||||
{
|
||||
"id": "agent-account-name",
|
||||
"type": "file",
|
||||
"path": "/etc/claude-code/agent-account",
|
||||
"mode": "0644",
|
||||
"template": "files/agent-account"
|
||||
},
|
||||
{
|
||||
"id": "agent-account",
|
||||
"type": "user",
|
||||
"name": "${machine:agent-account}",
|
||||
"home": "${machine:agent-home}",
|
||||
"root": "${machine:agent-root}"
|
||||
},
|
||||
{
|
||||
"id": "agent-home",
|
||||
"type": "directory",
|
||||
"path": "${machine:agent-home}/.claude",
|
||||
"mode": "0700",
|
||||
"owner": "${machine:agent-account}",
|
||||
"unknown": "module"
|
||||
},
|
||||
{
|
||||
"id": "state",
|
||||
"type": "directory",
|
||||
"mode": "0700",
|
||||
"owner": "${machine:account}",
|
||||
"place": ".",
|
||||
"unknown": "program"
|
||||
},
|
||||
{
|
||||
"id": "facts",
|
||||
"type": "file",
|
||||
"path": "${dir:state}/facts.json",
|
||||
"mode": "0600",
|
||||
"owner": "${machine:account}",
|
||||
"template": "files/facts.json"
|
||||
},
|
||||
{
|
||||
"id": "settings",
|
||||
"type": "file",
|
||||
"path": "${dir:state}/settings.json",
|
||||
"mode": "0600",
|
||||
"owner": "${machine:account}",
|
||||
"takes-config": ["role", "mcp_servers", "managed_settings", "instructions", "output_styles", "skills", "commands", "agents"],
|
||||
"merge": "json",
|
||||
"template": "files/settings.json"
|
||||
}
|
||||
],
|
||||
"items": [
|
||||
{
|
||||
"collection": "shell:zsh:normal",
|
||||
"template": "files/zsh-normal"
|
||||
}
|
||||
],
|
||||
"build": {
|
||||
"artifacts": [
|
||||
{
|
||||
"name": "tools",
|
||||
"kind": "bundle",
|
||||
"language": "go",
|
||||
"system": "arch",
|
||||
"from": "cmd/claude-code",
|
||||
"binary": "claude-code",
|
||||
"loads": [
|
||||
"claude-code"
|
||||
],
|
||||
"env": {
|
||||
"MESH_CLAUDE_CODE_STATE": "${dir:state}",
|
||||
"MESH_CLAUDE_CODE_FACTS": "${dir:state}/facts.json",
|
||||
"MESH_CLAUDE_CODE_SETTINGS": "${dir:state}/settings.json"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "files",
|
||||
"kind": "archive",
|
||||
"from": "files"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
postgresql://${bound:postgres-database:as}:${secret:postgres-database}@${bound:postgres-database:at}:${bound:postgres-database:port}/${bound:postgres-database:as}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"cadence_minutes": 240,
|
||||
"floor_minutes": 60,
|
||||
"failures_to_notify": 3,
|
||||
"cooldown_hours": 24,
|
||||
"refresh_warn_days": 3
|
||||
}
|
||||
+184
@@ -0,0 +1,184 @@
|
||||
{
|
||||
"module": "claude-licence-manager",
|
||||
"version": "1",
|
||||
"slug": "licmgr",
|
||||
"requires": [
|
||||
"postgres-database",
|
||||
"secret"
|
||||
],
|
||||
"contributes": {
|
||||
"postgres-database": {
|
||||
"name": "claude_licences",
|
||||
"readable": [
|
||||
"audit",
|
||||
"binding"
|
||||
]
|
||||
}
|
||||
},
|
||||
"binds": {
|
||||
"postgres-database": "${dir:state}/database.json"
|
||||
},
|
||||
"secrets": {
|
||||
"postgres-database": "${dir:state}/database.secret",
|
||||
"secret": {
|
||||
"grant-key": "${dir:state}/grant.key"
|
||||
}
|
||||
},
|
||||
"seats": [
|
||||
{
|
||||
"name": "anthropic-licence-manager",
|
||||
"scope": "mesh",
|
||||
"serves": [
|
||||
"licences",
|
||||
"bindings",
|
||||
"bind",
|
||||
"switch",
|
||||
"release",
|
||||
"refresh",
|
||||
"usage",
|
||||
"adopt",
|
||||
"current",
|
||||
"public-key"
|
||||
]
|
||||
}
|
||||
],
|
||||
"claims": [
|
||||
{
|
||||
"name": "anthropic-licence-manager",
|
||||
"scope": "mesh",
|
||||
"serves": [
|
||||
"licences",
|
||||
"bindings",
|
||||
"bind",
|
||||
"switch",
|
||||
"release",
|
||||
"refresh",
|
||||
"usage",
|
||||
"adopt",
|
||||
"current",
|
||||
"public-key"
|
||||
]
|
||||
}
|
||||
],
|
||||
"emits": [
|
||||
"licence.adopted",
|
||||
"licence.refused",
|
||||
"licence.bound",
|
||||
"licence.switched",
|
||||
"licence.released",
|
||||
"licence.rotated",
|
||||
"licence.failing",
|
||||
"usage.read"
|
||||
],
|
||||
"state": [
|
||||
"bindings"
|
||||
],
|
||||
"reads": [
|
||||
"claude-code.holdings"
|
||||
],
|
||||
"config": {
|
||||
"cadence_minutes": {
|
||||
"type": "number",
|
||||
"description": "How old a licence's grant may grow before it is rotated, in minutes; 0 keeps 240.",
|
||||
"minimum": 0,
|
||||
"trusted": true,
|
||||
"default": 240
|
||||
},
|
||||
"floor_minutes": {
|
||||
"type": "number",
|
||||
"description": "How little time a grant may have left before it is refreshed in any case, in minutes; 0 keeps 60.",
|
||||
"minimum": 0,
|
||||
"trusted": true,
|
||||
"default": 60
|
||||
},
|
||||
"failures_to_notify": {
|
||||
"type": "number",
|
||||
"description": "How many refreshes of a licence fail in a row before the operator is told, a count; 0 keeps 3.",
|
||||
"minimum": 0,
|
||||
"trusted": true,
|
||||
"default": 3
|
||||
},
|
||||
"cooldown_hours": {
|
||||
"type": "number",
|
||||
"description": "How long after telling the operator about a licence nothing more is said about it, in hours; 0 keeps 24.",
|
||||
"minimum": 0,
|
||||
"trusted": true,
|
||||
"default": 24
|
||||
},
|
||||
"refresh_warn_days": {
|
||||
"type": "number",
|
||||
"description": "How long before a licence's refresh token itself expires the operator is warned, in days; 0 keeps 3.",
|
||||
"minimum": 0,
|
||||
"trusted": true,
|
||||
"default": 3
|
||||
}
|
||||
},
|
||||
"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",
|
||||
"type": "file",
|
||||
"path": "${dir:state}/settings.json",
|
||||
"mode": "0600",
|
||||
"takes-config": ["cadence_minutes", "floor_minutes", "failures_to_notify", "cooldown_hours", "refresh_warn_days"],
|
||||
"merge": "json",
|
||||
"template": "files/settings.json"
|
||||
},
|
||||
{
|
||||
"id": "prepare",
|
||||
"type": "process",
|
||||
"name": "claude-licence-manager-prepare",
|
||||
"artifact": "code",
|
||||
"run": [
|
||||
"./claude-licence-manager",
|
||||
"prepare"
|
||||
],
|
||||
"run-once": true,
|
||||
"env": {
|
||||
"DATABASE_URL_FILE": "${dir:state}/database.url"
|
||||
},
|
||||
"restart-on": [
|
||||
"database-url"
|
||||
]
|
||||
}
|
||||
],
|
||||
"build": {
|
||||
"artifacts": [
|
||||
{
|
||||
"name": "code",
|
||||
"kind": "bundle",
|
||||
"language": "go",
|
||||
"system": "arch",
|
||||
"from": "cmd/claude-licence-manager",
|
||||
"binary": "claude-licence-manager",
|
||||
"loads": [
|
||||
"claude-licence-manager"
|
||||
],
|
||||
"env": {
|
||||
"DATABASE_URL_FILE": "${dir:state}/database.url",
|
||||
"MESH_LICENCE_STATE": "${dir:state}",
|
||||
"MESH_LICENCE_KEY_FILE": "${dir:state}/grant.key",
|
||||
"MESH_LICENCE_SETTINGS": "${dir:state}/settings.json"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "files",
|
||||
"kind": "archive",
|
||||
"from": "files"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user