Two different things were both written `requires`. A shell, a display
server and a private network have to be on the machine that needs them.
A database does not — it runs somewhere and is reached over the network.
Both were answered the same way, so requiring a database installed
PostgreSQL on every machine that ran a web application.
What a module provides now carries a scope, the same idea claims already
use, written short in the ordinary case:
"provides": ["shell"]
"provides": [{"name": "database", "scope": "mesh"}]
A mesh-scoped requirement is answered by finding the node already running
it — never by installing it here. Choosing a machine to put a database on
is a decision with consequences, and nothing resolving a web application
should make it silently. With nothing anywhere it refuses and says which
module to assign; with two it refuses and says how to choose.
Choosing is `pin <node> <provision> <from>`, kept per node because that
is the granularity the choice has. A pin at a machine that does not
provide it refuses rather than falling back — a fallback would quietly
move somebody's data. One provider does not overrule a pin either.
Resolving a node now needs to know what the others offer, and working
that out needs them resolved, so it is two passes: the first answers only
what each node offers, the second answers everything. Nothing is ever
declared from the first.
A node's plan says what it takes from elsewhere. It is the only part of a
set that stops working when a different machine goes away, and nothing
else in that output would have said so. It is also where a credential
will hang once there is a mechanism for handing one back.
One test found passing for the wrong reason: it read pins through a join
on the provider, which hides a dangling row whether or not it was cleaned
up. It counts rows now, and bites when the cascade is removed.
160 lines
6.0 KiB
Go
160 lines
6.0 KiB
Go
package catalogue
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// A module whose files cannot be written in advance.
|
|
//
|
|
// The mesh's private network is the case: a machine's peer list is derived from every other
|
|
// machine, so it differs on each one and changes when any of them changes. What matters in these
|
|
// tests is not that it works, but that being computed changes *nothing else* about being a
|
|
// module — it is assigned, resolved, settled and absent when nobody asked for it.
|
|
|
|
type fake struct {
|
|
on map[string]bool
|
|
asked []string
|
|
err error
|
|
}
|
|
|
|
func (f *fake) Resources(node string) ([]map[string]any, bool, error) {
|
|
f.asked = append(f.asked, node)
|
|
if f.err != nil {
|
|
return nil, false, f.err
|
|
}
|
|
if !f.on[node] {
|
|
return nil, false, nil
|
|
}
|
|
return []map[string]any{{"id": "peers", "type": "file", "path": "/etc/x.conf",
|
|
"merge": MergeJSON, "content": `{"peer":"` + node + `"}`}}, true, nil
|
|
}
|
|
|
|
func computedShelf() map[string]Manifest {
|
|
return shelf(Manifest{Module: "mesh-network", Computed: "mesh-network",
|
|
Provides: Offers("private-network")})
|
|
}
|
|
|
|
func TestAComputedModuleIsAskedAboutTheNodeItIsFor(t *testing.T) {
|
|
// The generator gets a node name, not a plan. Everything it needs is the whole mesh, which
|
|
// it was built with — this is the one thing a node could never work out for itself.
|
|
got, err := Resolve(computedShelf(), []string{"mesh-network"}, workstation(), World{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
gen := &fake{on: map[string]bool{"workstation": true}}
|
|
out, err := got.Declaration(Rendering{Generators: map[string]Generator{"mesh-network": gen}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(gen.asked) != 1 || gen.asked[0] != "workstation" {
|
|
t.Fatalf("asked about %v, wanted workstation once", gen.asked)
|
|
}
|
|
if len(out) != 1 || !strings.Contains(out[0]["content"].(string), `"peer": "workstation"`) {
|
|
t.Fatalf("got %v", out)
|
|
}
|
|
}
|
|
|
|
func TestAMachineNobodyGaveItGetsNothing(t *testing.T) {
|
|
// The whole point of making the network a module. Before this, every machine with an address
|
|
// was on the private network and there was no way to say one should stay off.
|
|
got, err := Resolve(computedShelf(), nil, workstation(), World{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
gen := &fake{on: map[string]bool{"workstation": true}}
|
|
out, err := got.Declaration(Rendering{Generators: map[string]Generator{"mesh-network": gen}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(out) != 0 {
|
|
t.Fatalf("a machine that was assigned nothing got %d resource(s)", len(out))
|
|
}
|
|
if len(gen.asked) != 0 {
|
|
t.Fatalf("the generator was asked about %v, and nobody had asked for it", gen.asked)
|
|
}
|
|
}
|
|
|
|
func TestAssignedAndNotYetPartOfItIsNotAFailure(t *testing.T) {
|
|
// A machine given the network module before it has a place on it. Brief and ordinary — the
|
|
// answer is "nothing yet", and treating it as an error would make an ordering a fault.
|
|
got, _ := Resolve(computedShelf(), []string{"mesh-network"}, workstation(), World{})
|
|
gen := &fake{on: map[string]bool{}}
|
|
out, err := got.Declaration(Rendering{Generators: map[string]Generator{"mesh-network": gen}})
|
|
if err != nil {
|
|
t.Fatalf("refused a node that is not on the network yet: %v", err)
|
|
}
|
|
if len(out) != 0 {
|
|
t.Fatalf("got %v", out)
|
|
}
|
|
}
|
|
|
|
func TestAGeneratorThisControlPlaneDoesNotHaveIsRefused(t *testing.T) {
|
|
// Sending a machine a module with no files would look like it worked. Named in the message,
|
|
// because the only fix is a control plane that has it.
|
|
got, _ := Resolve(shelf(Manifest{Module: "weather", Computed: "the-weather"}), []string{"weather"}, workstation(), World{})
|
|
|
|
_, err := got.Declaration(Rendering{Generators: map[string]Generator{"mesh-network": &fake{}}})
|
|
if err == nil {
|
|
t.Fatal("a module computed by nothing was accepted")
|
|
}
|
|
if !strings.Contains(err.Error(), "the-weather") {
|
|
t.Fatalf("the refusal does not name what is missing: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestAModuleIsEitherWrittenOrComputedNotBoth(t *testing.T) {
|
|
// Otherwise nobody could say where a given file on a machine came from.
|
|
_, err := ParseManifest([]byte(`{"module":"mesh-network","version":"1",
|
|
"computed":"mesh-network",
|
|
"resources":[{"id":"a","type":"package","package":"wireguard-tools"}]}`))
|
|
if err == nil {
|
|
t.Fatal("a module that both ships files and has them computed was accepted")
|
|
}
|
|
if !strings.Contains(err.Error(), "one or the other") {
|
|
t.Fatalf("unhelpful refusal: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestSettingsApplyToAComputedModuleToo(t *testing.T) {
|
|
// Being computed is about where the files come from, not about whether they are configurable.
|
|
// A line drawn there would be arbitrary and nobody could predict it.
|
|
got, _ := Resolve(computedShelf(), []string{"mesh-network"}, workstation(), World{})
|
|
gen := &fake{on: map[string]bool{"workstation": true}}
|
|
out, err := got.Declaration(Rendering{
|
|
Generators: map[string]Generator{"mesh-network": gen},
|
|
Settings: SettingsBy{"mesh-network": {{From: "the mesh",
|
|
Values: map[string]any{"keepalive": 25}}}},
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
content := out[0]["content"].(string)
|
|
if !strings.Contains(content, `"keepalive": 25`) {
|
|
t.Fatalf("the setting did not reach a computed file: %s", content)
|
|
}
|
|
if !strings.Contains(content, `"peer": "workstation"`) {
|
|
t.Fatalf("the setting replaced what the generator computed: %s", content)
|
|
}
|
|
}
|
|
|
|
func TestTwoWaysToBeOnAPrivateNetworkRefuseAndNameBoth(t *testing.T) {
|
|
// WireGuard is one way. The refusing rule is what makes a second one safe to add: assigning
|
|
// both is caught rather than producing a machine on two networks that each half-work.
|
|
_, err := Resolve(shelf(
|
|
Manifest{Module: "mesh-network", Computed: "mesh-network",
|
|
Provides: Offers("private-network")},
|
|
Manifest{Module: "tailscale", Provides: Offers("private-network")},
|
|
Manifest{Module: "backups", Requires: []string{"private-network"}},
|
|
), []string{"backups"}, workstation(), World{})
|
|
|
|
if err == nil {
|
|
t.Fatal("two answers to one requirement were taken silently")
|
|
}
|
|
for _, want := range []string{"mesh-network", "tailscale", "private-network"} {
|
|
if !strings.Contains(err.Error(), want) {
|
|
t.Fatalf("the refusal does not name %s: %v", want, err)
|
|
}
|
|
}
|
|
}
|