Networking is a module, and a domain module is how you avoid choosing
Connectivity was code beside the module system doing the module system's
job: every machine with an address was on the private network and there
was no way to keep one off.
A manifest can now say its resources are computed by the control plane,
which is what a peer list needs — it is derived from every machine at
once, so nothing could be written in advance. The network is a module
from there on: assigned, resolved, settled, and absent from a machine
nobody gave it to.
Three modules rather than one, because WireGuard is one VPN of several:
mesh-wireguard provides private-network, mesh-addressing
claims the-private-network, one per node
mesh-names provides name-resolution, requires mesh-addressing
networking requires both, and ships no files of its own
The last is the point. Most people want the network up and do not want
to choose a VPN, so `assign networking` takes the only answer to each
requirement silently. The day the catalogue holds a second one there are
two answers, the resolver refuses and names them, and choosing is
assigning the one you want. No flavor field, nothing to configure.
Names left the WireGuard declaration for their own module. They would be
identical over a different private network, and bundling them made one
module out of two things.
Three faults the walk found:
- choosing tailscale still installed WireGuard, dragged back in by the
names needing the mesh's own addresses. Caught now by a claim: running
two VPNs is fine, being *the* mesh network is singular.
- a requirement wanted by two modules was reported twice, identically.
- "this mesh has no hub" was reported when the real cause was that a
node could not be resolved at all. It now names the node and the why.
And a test that asserts the manifests actually shipped, after the claim
went missing from the real one while every test stayed green.
This commit is contained in:
@@ -0,0 +1,158 @@
|
||||
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: []string{"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(), nil)
|
||||
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(), nil)
|
||||
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(), nil)
|
||||
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(), nil)
|
||||
_, 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(), nil)
|
||||
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: []string{"private-network"}},
|
||||
Manifest{Module: "tailscale", Provides: []string{"private-network"}},
|
||||
Manifest{Module: "backups", Requires: []string{"private-network"}},
|
||||
), []string{"backups"}, workstation(), nil)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A domain module is a module with requirements and no files of its own.
|
||||
//
|
||||
// Most people want the network working and do not want to choose a VPN. Some want a particular
|
||||
// one. Both are the same mechanism: assigning `networking` takes the only answer to each of its
|
||||
// requirements silently, and the day there are two answers the resolver refuses and names them,
|
||||
// so choosing is assigning the one you want. There is no flavor field and nothing to configure.
|
||||
|
||||
func networkingShelf(extra ...Manifest) map[string]Manifest {
|
||||
base := []Manifest{
|
||||
{Module: "networking", Requires: []string{"private-network", "name-resolution"}},
|
||||
{Module: "mesh-wireguard", Computed: "mesh-wireguard",
|
||||
Provides: []string{"private-network", "mesh-addressing"},
|
||||
Claims: []Claim{{Name: "the-private-network", Scope: ScopeNode}}},
|
||||
{Module: "mesh-names", Computed: "mesh-names",
|
||||
Provides: []string{"name-resolution"}, Requires: []string{"mesh-addressing"}},
|
||||
}
|
||||
return shelf(append(base, extra...)...)
|
||||
}
|
||||
|
||||
func TestOneWordBringsUpTheNetwork(t *testing.T) {
|
||||
// The case that has to stay easy. Nothing is asked, because with one answer to each
|
||||
// requirement there was never a question.
|
||||
got, err := Resolve(networkingShelf(), []string{"networking"}, workstation(), nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
have := strings.Join(names(got), " ")
|
||||
for _, want := range []string{"networking", "mesh-wireguard", "mesh-names"} {
|
||||
if !strings.Contains(have, want) {
|
||||
t.Fatalf("assigning networking did not bring in %s: %s", want, have)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestASecondVPNTurnsItIntoAChoice(t *testing.T) {
|
||||
// And the choice is offered rather than made. A default here would be the flavor field coming
|
||||
// back under another name.
|
||||
_, err := Resolve(networkingShelf(
|
||||
Manifest{Module: "tailscale", Provides: []string{"private-network"},
|
||||
Claims: []Claim{{Name: "the-private-network", Scope: ScopeNode}}},
|
||||
), []string{"networking"}, workstation(), nil)
|
||||
if err == nil {
|
||||
t.Fatal("two VPNs and one was picked silently")
|
||||
}
|
||||
for _, want := range []string{"mesh-wireguard", "tailscale"} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Fatalf("the refusal does not name %s: %v", want, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestChoosingIsAssigning(t *testing.T) {
|
||||
// No second verb. Assigning the one you want answers the requirement, and the bundle takes it.
|
||||
got, err := Resolve(networkingShelf(
|
||||
Manifest{Module: "tailscale",
|
||||
Provides: []string{"private-network", "name-resolution"},
|
||||
Claims: []Claim{{Name: "the-private-network", Scope: ScopeNode}}},
|
||||
), []string{"networking", "tailscale"}, workstation(), nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
have := strings.Join(names(got), " ")
|
||||
if !strings.Contains(have, "tailscale") {
|
||||
t.Fatalf("the chosen VPN is not in the set: %s", have)
|
||||
}
|
||||
if strings.Contains(have, "mesh-wireguard") {
|
||||
t.Fatalf("choosing tailscale still installed WireGuard: %s", have)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChoosingOneVPNCannotDragTheOtherBackIn(t *testing.T) {
|
||||
// This happened. The person chose tailscale; the names module required the mesh's own
|
||||
// addressing; only WireGuard provides that; so both were installed and nobody was told.
|
||||
//
|
||||
// The claim is what catches it. Providing a private network is not the singular part — a
|
||||
// machine could run two VPNs for two purposes — but being *the* one the mesh runs over is.
|
||||
_, err := Resolve(networkingShelf(
|
||||
Manifest{Module: "tailscale", Provides: []string{"private-network"},
|
||||
Claims: []Claim{{Name: "the-private-network", Scope: ScopeNode}}},
|
||||
), []string{"networking", "tailscale"}, workstation(), nil)
|
||||
if err == nil {
|
||||
t.Fatal("a machine was given two private networks without being told")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "the-private-network") {
|
||||
t.Fatalf("the refusal does not say what collided: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNamesNeedTheMeshsOwnAddresses(t *testing.T) {
|
||||
// Names are computed from addresses the mesh handed out. Over a VPN that hands out its own,
|
||||
// the mesh has nothing to write, so the names module requires the addressing rather than a
|
||||
// private network in general — otherwise a machine gets a hosts file full of addresses that
|
||||
// mean nothing on it.
|
||||
_, err := Resolve(shelf(
|
||||
Manifest{Module: "mesh-names", Computed: "mesh-names",
|
||||
Provides: []string{"name-resolution"}, Requires: []string{"mesh-addressing"}},
|
||||
Manifest{Module: "tailscale", Provides: []string{"private-network"}},
|
||||
), []string{"mesh-names", "tailscale"}, workstation(), nil)
|
||||
if err == nil {
|
||||
t.Fatal("the mesh's names were installed over a VPN whose addresses it does not hand out")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "mesh-addressing") {
|
||||
t.Fatalf("the refusal does not say what is missing: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestARequirementWantedTwiceIsReportedOnce(t *testing.T) {
|
||||
// Two identical lines make a person hunt for the difference between them before realising
|
||||
// there is none.
|
||||
_, err := Resolve(shelf(
|
||||
Manifest{Module: "one", Requires: []string{"shell"}},
|
||||
Manifest{Module: "two", Requires: []string{"shell"}},
|
||||
Manifest{Module: "bash", Provides: []string{"shell"}},
|
||||
Manifest{Module: "zsh", Provides: []string{"shell"}},
|
||||
), []string{"one", "two"}, workstation(), nil)
|
||||
if err == nil {
|
||||
t.Fatal("two shells and one was picked silently")
|
||||
}
|
||||
if n := strings.Count(err.Error(), `"shell" is wanted by`); n != 1 {
|
||||
t.Fatalf("the same requirement was reported %d times:\n%v", n, err)
|
||||
}
|
||||
}
|
||||
@@ -69,6 +69,20 @@ type Manifest struct {
|
||||
|
||||
// Resources are what this module puts on a node, in the host's own vocabulary.
|
||||
Resources []map[string]any `json:"resources,omitempty"`
|
||||
|
||||
// Computed names something in the control plane that works this module's resources out per
|
||||
// node, instead of them being fixed here.
|
||||
//
|
||||
// Because some files cannot be written in advance. A machine's peer list on the private
|
||||
// network is derived from every other machine, so it differs on each one and changes when any
|
||||
// of them changes — there is nothing to put in a manifest.
|
||||
//
|
||||
// Being a module anyway is the point: it is assigned like anything else, so a machine that
|
||||
// should not be on the private network simply is not given it, and the network is worked out
|
||||
// over the machines that have it. Before this, connectivity was code beside the module system
|
||||
// doing the same job, and every machine with an address was on the network whether or not
|
||||
// anybody wanted it there.
|
||||
Computed string `json:"computed,omitempty"`
|
||||
}
|
||||
|
||||
// ParseManifest reads a module manifest, refusing anything it cannot act on.
|
||||
@@ -117,6 +131,13 @@ func ParseManifest(raw []byte) (Manifest, error) {
|
||||
m.Module, c.Name, c.Scope))
|
||||
}
|
||||
}
|
||||
if m.Computed != "" && len(m.Resources) > 0 {
|
||||
// One or the other. A module that both ships files and has them computed would leave
|
||||
// nobody able to say where a given file came from.
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s has resources of its own and says they are computed by %q; it is one or the other",
|
||||
m.Module, m.Computed))
|
||||
}
|
||||
for i, r := range m.Resources {
|
||||
id, _ := r["id"].(string)
|
||||
if id == "" {
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
package catalogue_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-control/internal/catalogue"
|
||||
"github.com/novox/mesh-control/internal/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(), overlay.NamesManifest(), overlay.DomainManifest(),
|
||||
} {
|
||||
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 TestTheShippedNetworkingModulesResolveOnTheirOwn(t *testing.T) {
|
||||
got, err := catalogue.Resolve(provided(t), []string{overlay.Domain},
|
||||
catalogue.Node{Name: "workstation", Site: "house"}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("assigning %s does not work out of the box: %v", overlay.Domain, err)
|
||||
}
|
||||
var have []string
|
||||
for _, m := range got.Modules {
|
||||
have = append(have, m.Module)
|
||||
}
|
||||
for _, want := range []string{overlay.Domain, overlay.Name, overlay.Names} {
|
||||
if !strings.Contains(strings.Join(have, " "), want) {
|
||||
t.Fatalf("%s did not bring in %s: %v", overlay.Domain, want, have)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheShippedWireGuardModuleClaimsBeingTheNetwork(t *testing.T) {
|
||||
// Without this, a person who chose another VPN gets WireGuard as well, dragged in by the
|
||||
// names, and is not told. The claim is the only thing that catches it.
|
||||
shipped := provided(t)
|
||||
_, err := catalogue.Resolve(
|
||||
withTailscale(shipped),
|
||||
[]string{overlay.Domain, "tailscale"},
|
||||
catalogue.Node{Name: "workstation", Site: "house"}, nil)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheShippedNamesModuleNeedsTheMeshsOwnAddresses(t *testing.T) {
|
||||
// Over a VPN whose addresses the mesh does not hand out, it has no names to write. Refusing
|
||||
// is what stops a machine getting a hosts file that means nothing on it.
|
||||
shipped := provided(t)
|
||||
delete(shipped, overlay.Name)
|
||||
_, err := catalogue.Resolve(shipped, []string{overlay.Names},
|
||||
catalogue.Node{Name: "workstation", Site: "house"}, nil)
|
||||
if err == nil {
|
||||
t.Fatal("the mesh's names resolved with nothing handing out the mesh's addresses")
|
||||
}
|
||||
if !strings.Contains(err.Error(), overlay.Addressing) {
|
||||
t.Fatalf("the refusal does not name what is missing: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
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: []string{overlay.Requirement},
|
||||
Claims: []catalogue.Claim{{Name: overlay.TheNetwork, Scope: catalogue.ScopeNode}},
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -34,6 +34,9 @@ type Held struct {
|
||||
|
||||
// Resolution is what a node should run, and why.
|
||||
type Resolution struct {
|
||||
// Node is which machine this was resolved for, so a generator can be asked about it.
|
||||
Node string
|
||||
|
||||
// Modules in the order they were resolved: assigned first, then what they pulled in.
|
||||
Modules []Manifest
|
||||
// Because says why each module is here — assigned, or required by something.
|
||||
@@ -99,10 +102,15 @@ func Resolve(catalogue map[string]Manifest, assigned []string, node Node, elsewh
|
||||
}
|
||||
}
|
||||
|
||||
// What has already been complained about. A requirement can be wanted by several modules at
|
||||
// once, and saying the same thing twice makes a person hunt for the difference between two
|
||||
// identical lines before realising there is none.
|
||||
reported := map[string]bool{}
|
||||
|
||||
for len(queue) > 0 {
|
||||
want := queue[0]
|
||||
queue = queue[1:]
|
||||
if chosen[want] {
|
||||
if chosen[want] || reported[want] {
|
||||
continue
|
||||
}
|
||||
// Already answered by something in the set. This is the case that makes assigning zsh do
|
||||
@@ -114,6 +122,7 @@ func Resolve(catalogue map[string]Manifest, assigned []string, node Node, elsewh
|
||||
candidates := offers[want]
|
||||
switch len(candidates) {
|
||||
case 0:
|
||||
reported[want] = true
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"nothing provides %q, wanted by %s", want, because[want]))
|
||||
continue
|
||||
@@ -121,6 +130,7 @@ func Resolve(catalogue map[string]Manifest, assigned []string, node Node, elsewh
|
||||
// No choice to make, so none is made. This is the case that lets `install i3` bring
|
||||
// in xorg without anybody being asked anything.
|
||||
default:
|
||||
reported[want] = true
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%q is wanted by %s and %d modules provide it — choose one and assign it: %s",
|
||||
want, because[want], len(candidates), strings.Join(candidates, ", ")))
|
||||
@@ -148,7 +158,7 @@ func Resolve(catalogue map[string]Manifest, assigned []string, node Node, elsewh
|
||||
}
|
||||
}
|
||||
|
||||
resolution := Resolution{Because: because}
|
||||
resolution := Resolution{Node: node.Name, Because: because}
|
||||
for _, n := range order {
|
||||
resolution.Modules = append(resolution.Modules, catalogue[n])
|
||||
}
|
||||
@@ -275,16 +285,50 @@ func checkResources(modules []Manifest) []string {
|
||||
// SettingsBy is the layers that apply to each module, keyed by module name.
|
||||
type SettingsBy map[string][]Layer
|
||||
|
||||
// Generator works out a module's resources for one node, where they cannot be written in advance.
|
||||
type Generator interface {
|
||||
// Resources for this node. Absent means the node is not part of whatever this generates,
|
||||
// which is an ordinary answer rather than a failure — a machine assigned the module before it
|
||||
// has an address on the network is in exactly that state.
|
||||
Resources(node string) ([]map[string]any, bool, error)
|
||||
}
|
||||
|
||||
// Rendering is everything needed to turn a resolution into the declaration a node is sent.
|
||||
type Rendering struct {
|
||||
Settings SettingsBy
|
||||
Generators map[string]Generator
|
||||
}
|
||||
|
||||
// Declaration is everything the resolved modules put on the node, with settings applied.
|
||||
//
|
||||
// Resource identities are prefixed with the module they came from. Two modules may reasonably
|
||||
// both call something "config", and without this the second would silently replace the first —
|
||||
// the node applying one of them and reporting success.
|
||||
func (r Resolution) Declaration(settings SettingsBy) ([]map[string]any, error) {
|
||||
func (r Resolution) Declaration(with Rendering) ([]map[string]any, error) {
|
||||
var out []map[string]any
|
||||
for _, m := range r.Modules {
|
||||
for _, unsettled := range m.Resources {
|
||||
resource, err := ApplySettings(unsettled, settings[m.Module])
|
||||
resources := m.Resources
|
||||
if m.Computed != "" {
|
||||
generator, known := with.Generators[m.Computed]
|
||||
if !known {
|
||||
return nil, fmt.Errorf(
|
||||
"%s says its resources are computed by %q, and this control plane has no %q",
|
||||
m.Module, m.Computed, m.Computed)
|
||||
}
|
||||
generated, part, err := generator.Resources(r.Node)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !part {
|
||||
// Assigned, and not yet part of what this generates. Nothing to put on the
|
||||
// machine, which is different from an error: a node given the network module
|
||||
// before it has an address is in exactly that state, briefly.
|
||||
continue
|
||||
}
|
||||
resources = generated
|
||||
}
|
||||
for _, unsettled := range resources {
|
||||
resource, err := ApplySettings(unsettled, with.Settings[m.Module])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -320,7 +320,7 @@ func TestARequirementNamingAModuleMeansThatModule(t *testing.T) {
|
||||
|
||||
func mustDeclare(t *testing.T, r Resolution) []map[string]any {
|
||||
t.Helper()
|
||||
out, err := r.Declaration(nil)
|
||||
out, err := r.Declaration(Rendering{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user