diff --git a/cmd/mesh-control/main.go b/cmd/mesh-control/main.go index c36c6bf..a1355b8 100644 --- a/cmd/mesh-control/main.go +++ b/cmd/mesh-control/main.go @@ -127,7 +127,7 @@ func usage() { settings set what a module's config should say, for the whole mesh settings set --node ...or for one machine settings clear [--node ] take a layer away - plan what that node would run, and why + plan [--files|--json] what that node would run, and why push [] send a node everything it should be version what this binary is @@ -1038,12 +1038,16 @@ func planCommand(ctx context.Context, args []string) error { // Because "one resource" does not tell you whether the settings landed. Being able to read // the file before it is sent is the difference between believing a merge worked and knowing. show := set.Bool("files", false, "print the files this node would be given") + // The declaration exactly as the node would receive it. For handing to something else -- + // checking it against the host's own parser, most usefully, which is the only way to know + // that what the control plane emits is what the host accepts. + asJSON := set.Bool("json", false, "print the declaration this node would be sent") positionals, err := parseAround(set, args) if err != nil { return err } if len(positionals) != 1 { - return errors.New("plan [--files]") + return errors.New("plan [--files] [--json]") } args = positionals inv, err := openInventory(ctx) @@ -1060,6 +1064,25 @@ func planCommand(ctx context.Context, args []string) error { fmt.Printf("%s is assigned nothing\n", args[0]) return nil } + if *asJSON { + gens, err := generators(ctx, inv) + if err != nil { + return err + } + resources, err := plan.Declaration( + catalogue.Rendering{Settings: settings, Generators: gens}) + if err != nil { + return err + } + body, err := json.MarshalIndent( + map[string]any{"declaration": 1, "resources": resources}, "", " ") + if err != nil { + return err + } + fmt.Println(string(body)) + return nil + } + fmt.Printf("%s would run:\n", args[0]) for _, m := range plan.Modules { fmt.Printf(" %-20s %s\n", m.Module, plan.Because[m.Module]) diff --git a/internal/catalogue/contributes_test.go b/internal/catalogue/contributes_test.go new file mode 100644 index 0000000..37bfe37 --- /dev/null +++ b/internal/catalogue/contributes_test.go @@ -0,0 +1,221 @@ +package catalogue + +import ( + "encoding/json" + "strings" + "testing" +) + +// The other half of an edge. +// +// `requires` says a thing must be there. It never said what to do with it — a web application +// requiring a reverse proxy has to say *which name, which port*, and there was nowhere to put +// that. The two modules that needed it most, the proxy and the VPN, went round the outside and +// opened a connection to the control plane's database, which is why every node held a credential +// to it permanently. + +func published(module, host string, port int) Manifest { + return Manifest{Module: module, Version: "1", + Contributes: map[string]map[string]any{ + "reverse-proxy": {"host": host, "port": port}, + }} +} + +func proxy() Manifest { + return Manifest{Module: "traefik", Version: "1", + Provides: []string{"reverse-proxy"}, + Receives: map[string]string{"reverse-proxy": "/etc/traefik/mesh.json"}, + Resources: []map[string]any{ + {"id": "up", "type": "service", "unit": "traefik", "state": "running", + "restart-on": []any{ReceivedID("reverse-proxy")}}, + }} +} + +// received digs the delivered contributions back out of a declaration. +func received(t *testing.T, out []map[string]any) []Contribution { + t.Helper() + for _, r := range out { + if r["path"] != "/etc/traefik/mesh.json" { + continue + } + body := r["content"].(string) + var parsed struct { + Requirement string `json:"requirement"` + Given []Contribution `json:"given"` + } + if err := json.Unmarshal([]byte(body), &parsed); err != nil { + t.Fatalf("the file the proxy is given is not readable: %v\n%s", err, body) + } + if parsed.Requirement != "reverse-proxy" { + t.Fatalf("the file does not say what it is about: %q", parsed.Requirement) + } + return parsed.Given + } + t.Fatalf("the provider was given no file at all: %v", out) + return nil +} + +func TestTheFileTheProviderGetsIsMachineReadable(t *testing.T) { + // It is written for a program, and the first version put a `//` header above the JSON — a + // file that says "do not edit" to a person and fails to parse for the thing meant to read it. + got, _ := Resolve(shelf(proxy(), published("board", "board", 8080)), + []string{"board"}, workstation(), nil) + for _, r := range mustDeclare(t, got) { + if r["path"] != "/etc/traefik/mesh.json" { + continue + } + var any map[string]any + if err := json.Unmarshal([]byte(r["content"].(string)), &any); err != nil { + t.Fatalf("the file is not parseable: %v\n%s", err, r["content"]) + } + if note, _ := any["generated"].(string); !strings.Contains(note, "do not edit") { + // Inside the document rather than above it, so it reaches a person without + // breaking the parse. + t.Fatalf("the file does not say it is generated: %v", any["generated"]) + } + return + } + t.Fatal("no received file") +} + +func TestAModuleTellsItsProviderWhatItNeeds(t *testing.T) { + got, err := Resolve(shelf(proxy(), published("board", "board", 8080)), + []string{"board"}, workstation(), nil) + if err != nil { + t.Fatal(err) + } + out, err := got.Declaration(Rendering{}) + if err != nil { + t.Fatal(err) + } + given := received(t, out) + if len(given) != 1 || given[0].From != "board" { + t.Fatalf("the proxy was not told about board: %v", given) + } + if given[0].Values["host"] != "board" || given[0].Values["port"] != float64(8080) { + t.Fatalf("the contribution did not survive: %v", given[0].Values) + } +} + +func TestContributingToSomethingIsRequiringIt(t *testing.T) { + // Asking to be published means a publisher must exist. A module that had to say both would + // eventually say one, and the failure would be a machine where nothing serves the route. + got, err := Resolve(shelf(proxy(), published("board", "board", 8080)), + []string{"board"}, workstation(), nil) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(strings.Join(names(got), " "), "traefik") { + t.Fatalf("contributing to reverse-proxy did not bring one in: %v", names(got)) + } +} + +func TestNothingToContributeToIsRefused(t *testing.T) { + _, err := Resolve(shelf(published("board", "board", 8080)), + []string{"board"}, workstation(), nil) + if err == nil { + t.Fatal("a module was published through a proxy that does not exist") + } + if !strings.Contains(err.Error(), "reverse-proxy") { + t.Fatalf("the refusal does not name what is missing: %v", err) + } +} + +func TestEveryPublisherOnTheMachineIsInOneFile(t *testing.T) { + got, err := Resolve(shelf(proxy(), + published("board", "board", 8080), + published("archive", "archive", 9000), + ), []string{"board", "archive"}, workstation(), nil) + if err != nil { + t.Fatal(err) + } + given := received(t, mustDeclare(t, got)) + if len(given) != 2 { + t.Fatalf("the proxy was told about %d of 2: %v", len(given), given) + } + // Ordered by module, because this becomes a file and a file whose lines move about looks + // changed when nothing changed — which would restart the proxy for ever. + if given[0].From != "archive" || given[1].From != "board" { + t.Fatalf("the order is not stable: %v", given) + } +} + +func TestAProviderWithNoConsumersStillGetsTheFile(t *testing.T) { + // Empty rather than absent. A provider that finds no file cannot tell "nothing asked for me" + // from "the mesh never wrote it", and those want completely different responses — the same + // rule the host follows about a service that does not exist. + got, err := Resolve(shelf(proxy()), []string{"traefik"}, workstation(), nil) + if err != nil { + t.Fatal(err) + } + if given := received(t, mustDeclare(t, got)); len(given) != 0 { + t.Fatalf("got %v", given) + } +} + +func TestTheProviderCanReloadWhenTheRoutesChange(t *testing.T) { + // A proxy that got a new route and did not reload is a route that silently does not work. + // The same fault the private network had when a peer list changed under a running interface, + // which is why the received file has a name a module can point at. + got, _ := Resolve(shelf(proxy(), published("board", "board", 8080)), + []string{"board"}, workstation(), nil) + out := mustDeclare(t, got) + for _, r := range out { + if r["type"] != "service" { + continue + } + reflects, ok := r["restart-on"].([]any) + if !ok || len(reflects) != 1 { + t.Fatalf("the proxy does not reflect anything: %v", r) + } + if reflects[0] != "traefik."+ReceivedID("reverse-proxy") { + t.Fatalf("it reflects %v, which is not the file it was given", reflects[0]) + } + return + } + t.Fatal("no service in the declaration") +} + +func TestARouteCanBeSetPerMesh(t *testing.T) { + // The hostname is exactly the kind of thing that differs between one mesh and the next. A + // module whose route could not be set would have to be edited to be reused anywhere. + got, _ := Resolve(shelf(proxy(), published("board", "board", 8080)), + []string{"board"}, workstation(), nil) + out, err := got.Declaration(Rendering{Settings: SettingsBy{ + "board": {{From: "the mesh", Values: map[string]any{"host": "dashboard"}}}, + }}) + if err != nil { + t.Fatal(err) + } + given := received(t, out) + if given[0].Values["host"] != "dashboard" { + t.Fatalf("the setting did not reach the route: %v", given[0].Values) + } + if given[0].Values["port"] != float64(8080) { + t.Fatalf("setting the host dropped the port: %v", given[0].Values) + } +} + +func TestReceivingWhatYouDoNotProvideIsRefused(t *testing.T) { + // It would create a file nobody ever writes to, on a machine where nothing asked for it. + _, err := ParseManifest([]byte(`{"module":"traefik","version":"1", + "receives":{"reverse-proxy":"/etc/traefik/mesh.json"}}`)) + if err == nil { + t.Fatal("a module received contributions to something it does not provide") + } + if !strings.Contains(err.Error(), "does not provide") { + t.Fatalf("unhelpful refusal: %v", err) + } +} + +func TestAnEmptyContributionIsRefused(t *testing.T) { + // Either a mistake or a requirement written the long way round, and both are better said. + _, err := ParseManifest([]byte(`{"module":"board","version":"1", + "contributes":{"reverse-proxy":{}}}`)) + if err == nil { + t.Fatal("a module contributed nothing and was accepted") + } + if !strings.Contains(err.Error(), "require it") { + t.Fatalf("the refusal does not say what to do instead: %v", err) + } +} diff --git a/internal/catalogue/manifest.go b/internal/catalogue/manifest.go index 6726e0b..33e9f4e 100644 --- a/internal/catalogue/manifest.go +++ b/internal/catalogue/manifest.go @@ -83,8 +83,56 @@ type Manifest struct { // 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"` + + // Contributes is what this module tells whatever answers a requirement. + // + // The other half of an edge. `requires` says a thing must be there; this says what to do with + // it — a web application requiring a reverse proxy has to say *which name, which port*, and + // until now there was nowhere to put that. Every module that needed it was reduced to + // reaching into the control plane's database directly, which is how two of them came to hold + // a credential to it permanently. + // + // Keyed by the requirement, because that is what the contribution is *about*. Contributing to + // something is requiring it: asking to be published means a publisher must exist, and a + // module that had to say both would eventually say one. + Contributes map[string]map[string]any `json:"contributes,omitempty"` + + // Receives is where this module wants its consumers' contributions written, per requirement + // it provides. + // + // A file, in the mesh's own shape, replaced whenever the set changes. **The control plane + // does not know what a reverse proxy is** and does not write one's configuration — it + // delivers the facts, and the module turns them into whatever it runs. That boundary is why + // swapping the proxy does not touch a single module that publishes through it. + Receives map[string]string `json:"receives,omitempty"` } +// Wants is everything that must be provided on the same node: what this module requires, and what +// it contributes to. +func (m Manifest) Wants() []string { + out := append([]string{}, m.Requires...) + for to := range m.Contributes { + var already bool + for _, r := range m.Requires { + if r == to { + already = true + } + } + if !already { + out = append(out, to) + } + } + sort.Strings(out) + return out +} + +// ReceivedID is the resource identity of the file a provider is given its contributions in. +// +// Named rather than positional so a module can point `restart-on` at it: a proxy that got a new +// route and did not reload is a route that silently does not work, which is the same fault the +// overlay had when a peer list changed under a running interface. +func ReceivedID(requirement string) string { return "received-" + requirement } + // ParseManifest reads a module manifest, refusing anything it cannot act on. // // Every problem is reported rather than the first, because somebody writing a manifest fixes @@ -138,6 +186,38 @@ func ParseManifest(raw []byte) (Manifest, error) { "%s has resources of its own and says they are computed by %q; it is one or the other", m.Module, m.Computed)) } + for to, values := range m.Contributes { + if !name.MatchString(to) { + problems = append(problems, fmt.Sprintf("%q is not a usable name to contribute to", to)) + } + if len(values) == 0 { + // An empty contribution is either a mistake or a requirement written the long way + // round, and both are better said plainly. + problems = append(problems, fmt.Sprintf( + "%s contributes nothing to %q; if it only needs one, require it", m.Module, to)) + } + } + for to, where := range m.Receives { + if !name.MatchString(to) { + problems = append(problems, fmt.Sprintf("%q is not a usable name to receive", to)) + } + if !strings.HasPrefix(where, "/") { + problems = append(problems, fmt.Sprintf( + "%s receives %q at %q, which is not an absolute path", m.Module, to, where)) + } + var offered bool + for _, o := range m.Offers() { + if o == to { + offered = true + } + } + if !offered { + // Receiving contributions to something you do not provide would create a file nobody + // ever writes to, on a machine where nothing asked for it. + problems = append(problems, fmt.Sprintf( + "%s receives contributions to %q and does not provide it", m.Module, to)) + } + } for i, r := range m.Resources { id, _ := r["id"].(string) if id == "" { diff --git a/internal/catalogue/resolve.go b/internal/catalogue/resolve.go index b4d7900..2899cdd 100644 --- a/internal/catalogue/resolve.go +++ b/internal/catalogue/resolve.go @@ -1,6 +1,7 @@ package catalogue import ( + "encoding/json" "errors" "fmt" "sort" @@ -150,7 +151,7 @@ func Resolve(catalogue map[string]Manifest, assigned []string, node Node, elsewh because[m.Module] = fmt.Sprintf("required by %s", because[want]) } - for _, r := range m.Requires { + for _, r := range m.Wants() { if _, ok := because[r]; !ok { because[r] = m.Module } @@ -305,9 +306,21 @@ type Rendering struct { // 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(with Rendering) ([]map[string]any, error) { + given, err := r.contributions(with.Settings) + if err != nil { + return nil, err + } + var out []map[string]any for _, m := range r.Modules { resources := m.Resources + for _, to := range sortedKeys(m.Receives) { + file, err := receivedFile(to, m.Receives[to], given[to]) + if err != nil { + return nil, err + } + resources = append(append([]map[string]any{}, resources...), file) + } if m.Computed != "" { generator, known := with.Generators[m.Computed] if !known { @@ -351,3 +364,76 @@ func (r Resolution) Declaration(with Rendering) ([]map[string]any, error) { } return out, nil } + +// Contribution is one module telling the answer to a requirement what it needs from it. +type Contribution struct { + // From is the module that said it, so the provider and a person reading the file can tell + // which route belongs to what. + From string `json:"from"` + // Values are the module's own, with settings applied. What the keys mean is agreed by the + // requirement's name — everything providing `reverse-proxy` understands the same shape, which + // is what makes swapping one for another cost nothing. + Values map[string]any `json:"values"` +} + +// contributions collects what every module in this set contributes, by requirement. +// +// Ordered by contributing module, because the result becomes a file on a machine and a file whose +// lines move about is a file that looks changed when nothing changed. +func (r Resolution) contributions(settings SettingsBy) (map[string][]Contribution, error) { + out := map[string][]Contribution{} + modules := append([]Manifest{}, r.Modules...) + sort.Slice(modules, func(i, j int) bool { return modules[i].Module < modules[j].Module }) + + for _, m := range modules { + for _, to := range sortedKeys(m.Contributes) { + // Settings reach a contribution the same way they reach a file. A route's hostname is + // exactly the kind of thing that differs between one mesh and the next, and a module + // that could not have it set would have to be edited to be reused. + values, err := settle(m.Contributes[to], settings[m.Module], nil, + m.Module+" contributing to "+to) + if err != nil { + return nil, fmt.Errorf("%s contributing to %s: %w", m.Module, to, err) + } + out[to] = append(out[to], Contribution{From: m.Module, Values: values}) + } + } + return out, nil +} + +// receivedFile is the file a provider is given its consumers' contributions in. +func receivedFile(requirement, path string, given []Contribution) (map[string]any, error) { + if given == nil { + // Nobody contributed. The file is still written, empty, rather than left absent: a + // provider that finds no file cannot tell "nothing asked for me" from "the mesh never + // wrote it", and the two want completely different responses. + given = []Contribution{} + } + // The note goes *inside* the document, not above it. The first version wrote a `//` header + // and produced a file that says "do not edit" to a person and fails to parse for the program + // meant to read it — which is the whole audience. + body, err := json.MarshalIndent(map[string]any{ + "contributions": 1, + "requirement": requirement, + "generated": "by the mesh — do not edit; replaced whenever a module contributing to " + + requirement + " arrives or leaves", + "given": given, + }, "", " ") + if err != nil { + return nil, err + } + return map[string]any{ + "id": ReceivedID(requirement), "type": "file", "path": path, "mode": "0644", + "content": string(body) + "\n", + }, nil +} + +// sortedKeys is map iteration made repeatable, which everything written to a machine needs. +func sortedKeys[V any](m map[string]V) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + sort.Strings(out) + return out +} diff --git a/internal/catalogue/settings.go b/internal/catalogue/settings.go index a2c4c6d..3ae9bb7 100644 --- a/internal/catalogue/settings.go +++ b/internal/catalogue/settings.go @@ -63,18 +63,9 @@ func ApplySettings(resource map[string]any, layers []Layer) (map[string]any, err protected[k] = true } - merged := deepCopy(base) - for _, layer := range layers { - for key, value := range layer.Values { - if protected[key] { - // The module said it must own this one. Refused rather than ignored: a setting - // that is quietly dropped is somebody believing they changed something. - return nil, fmt.Errorf( - "%s sets %q on %v, and that module keeps %q for itself — it is not settable", - layer.From, key, resource["id"], key) - } - merged[key] = mergeValue(merged[key], value) - } + merged, err := settle(base, layers, protected, fmt.Sprint(resource["id"])) + if err != nil { + return nil, err } out := map[string]any{} @@ -94,6 +85,29 @@ func ApplySettings(resource map[string]any, layers []Layer) (map[string]any, err return out, nil } +// settle lays the layers over a module's own values, in order. +// +// Shared by a file's content and a module's contributions, because they are the same act: the +// module says what it means by default, and somebody says what it means here. A contribution that +// could not be settled would have to be edited to be reused anywhere else. +func settle(base map[string]any, layers []Layer, protected map[string]bool, what string) ( + map[string]any, error) { + merged := deepCopy(base) + for _, layer := range layers { + for key, value := range layer.Values { + if protected[key] { + // The module said it must own this one. Refused rather than ignored: a setting + // that is quietly dropped is somebody believing they changed something. + return nil, fmt.Errorf( + "%s sets %q on %v, and that module keeps %q for itself — it is not settable", + layer.From, key, what, key) + } + merged[key] = mergeValue(merged[key], value) + } + } + return merged, nil +} + // mergeValue combines one value with the one over it. // // Two objects merge key by key, so setting one field of a nested block does not delete its @@ -130,20 +144,29 @@ func deepCopy(in map[string]any) map[string]any { // nothing — and would find out by the machine not behaving differently, which is the slowest // way there is. This is what makes that visible at the moment they set it. func UnusedSettings(m Manifest, layers []Layer) []string { - mergeable := false for _, r := range m.Resources { if how, _ := r["merge"].(string); how != "" { - mergeable = true + return nil } } - if mergeable { + // A contribution is a destination too. A route's hostname is exactly the kind of thing that + // differs between one mesh and the next, and calling it stray would refuse the one setting + // most modules that publish anything will have. + if len(m.Contributes) > 0 { + return nil + } + // A computed module has no resources here to look at — they are worked out per node, and + // whether a setting lands is not knowable until then. Silence rather than a wrong answer: + // claiming every setting on the private network is stray would be worse than saying nothing. + if m.Computed != "" { return nil } var unused []string for _, layer := range layers { for key := range layer.Values { - unused = append(unused, fmt.Sprintf("%s sets %q, and %s has no file to merge it into", + unused = append(unused, fmt.Sprintf( + "%s sets %q, and %s has no file or contribution to merge it into", layer.From, key, m.Module)) } } diff --git a/internal/catalogue/settings_test.go b/internal/catalogue/settings_test.go index b6977b4..3bd6dcf 100644 --- a/internal/catalogue/settings_test.go +++ b/internal/catalogue/settings_test.go @@ -167,7 +167,7 @@ func TestSettingsThatReachNothingAreNamed(t *testing.T) { {"id": "conf", "type": "file", "path": "/etc/thing", "content": "plain"}, }} unused := UnusedSettings(m, []Layer{{From: "node", Values: map[string]any{"port": 1}}}) - if len(unused) != 1 || !strings.Contains(unused[0], "no file to merge it into") { + if len(unused) != 1 || !strings.Contains(unused[0], "no file or contribution to merge it into") { t.Errorf("settings that reached nothing were not named: %v", unused) } }