review: hold the hosts region through composition, and say so in plan
A declaration-level test composes the shipped networking module with a resolver and asserts /etc/hosts arrives as mesh-wireguard.fact-node-names with into: block and region-only content, that the resolver's restart-on still names it, and that a resource's at passes through untouched — a composition step dropping into would otherwise go unnoticed. plan --show marks files written into, so a region is not read as the whole file. The rollout order is spelled out: every node's host, the controller's own included, must be block-aware before this controller ships (hq 128).
This commit is contained in:
@@ -923,12 +923,26 @@ func planCommand(ctx context.Context, args []string) error {
|
|||||||
if !ok {
|
if !ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
fmt.Printf("\n--- %v %v ---\n%s", r["id"], r["path"], content)
|
fmt.Printf("\n--- %s ---\n%s", shownAs(r), content)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// shownAs is the heading `plan --show` puts over a resource's content.
|
||||||
|
//
|
||||||
|
// **A file written into says so.** Its content is the mesh's part of a file that is otherwise the
|
||||||
|
// machine's — the keys of a JSON document (novox/hq ADR 0102), the region of a hosts file (issue
|
||||||
|
// 128). Shown under a bare path it reads as the whole file, and a person checking what a take
|
||||||
|
// replaces would see a hosts file of a dozen lines where the machine keeps thirty.
|
||||||
|
func shownAs(r map[string]any) string {
|
||||||
|
heading := fmt.Sprintf("%v %v", r["id"], r["path"])
|
||||||
|
if into, ok := r["into"].(string); ok && into != "" {
|
||||||
|
heading += fmt.Sprintf(" (written into, %s)", into)
|
||||||
|
}
|
||||||
|
return heading
|
||||||
|
}
|
||||||
|
|
||||||
// licencesFor is what this node can be answered with by record, and what it was put on.
|
// licencesFor is what this node can be answered with by record, and what it was put on.
|
||||||
//
|
//
|
||||||
// A mesh with no licences at all is the ordinary case and must not be an error: every existing
|
// A mesh with no licences at all is the ordinary case and must not be an error: every existing
|
||||||
|
|||||||
@@ -35,3 +35,17 @@ func TestListensLinesAreEmptyForAModuleWithNothingToListenOn(t *testing.T) {
|
|||||||
t.Errorf("a module with no listens should print nothing, got %v", got)
|
t.Errorf("a module with no listens should print nothing, got %v", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// `plan --show` says when a file is written into rather than over (novox/hq issue 128), or the
|
||||||
|
// mesh's region of a hosts file reads as the whole file.
|
||||||
|
func TestAFileWrittenIntoIsShownAsSuch(t *testing.T) {
|
||||||
|
region := shownAs(map[string]any{
|
||||||
|
"id": "mesh-wireguard.fact-node-names", "path": "/etc/hosts", "into": "block"})
|
||||||
|
if region != "mesh-wireguard.fact-node-names /etc/hosts (written into, block)" {
|
||||||
|
t.Errorf("the region is shown as %q", region)
|
||||||
|
}
|
||||||
|
whole := shownAs(map[string]any{"id": "dnsmasq.fact-node-zones", "path": "/etc/mesh-resolver/nodes.conf"})
|
||||||
|
if strings.Contains(whole, "written into") {
|
||||||
|
t.Errorf("a whole file is shown as written into: %q", whole)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -111,10 +111,14 @@ func FactsInto(m Manifest, r Resolution, addresses, machines map[string]string,
|
|||||||
// else does. Replacing nothing, it is written on an adopted node without being held,
|
// else does. Replacing nothing, it is written on an adopted node without being held,
|
||||||
// so a machine is named on the private network before its module is taken.
|
// so a machine is named on the private network before its module is taken.
|
||||||
//
|
//
|
||||||
// **Hosts first, then this.** A host older than the block mode refuses the whole
|
// **Hosts first, then this — an order to roll out in, not a note.** A host older than
|
||||||
// declaration on an `into` it does not know — not just this file, everything — so
|
// the block mode refuses the whole declaration on an `into` it does not know: not just
|
||||||
// every host is upgraded before a controller emitting it is rolled out, the same
|
// this file, everything the node was sent, so it stops converging on anything at all.
|
||||||
// order ADR 0102 set for `into: json` (novox/hq issue 128).
|
// Every node on the private network receives this fact, so every node's host —
|
||||||
|
// the controller's own machine included, which would otherwise stop taking the
|
||||||
|
// declaration that runs the controller — must run a block-aware host before a
|
||||||
|
// controller emitting it is rolled out. The same order ADR 0102 set for `into: json`
|
||||||
|
// (novox/hq issue 128).
|
||||||
file["into"] = "block"
|
file["into"] = "block"
|
||||||
}
|
}
|
||||||
out = append(out, file)
|
out = append(out, file)
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
package catalogue_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/novox/mesh-controller/internal/catalogue"
|
||||||
|
"github.com/novox/mesh-controller/internal/overlay"
|
||||||
|
)
|
||||||
|
|
||||||
|
// novox/hq issue 128, held where the host will see it: the shipped networking module's names,
|
||||||
|
// through the whole composition, and not only through FactsInto.
|
||||||
|
//
|
||||||
|
// Composition prefixes a fact's id with the module that asked for it and passes everything else
|
||||||
|
// through; a step that dropped `into` on the way would send the region as a whole file, and the
|
||||||
|
// host would write the machine's hosts file over again with every unit test above still green.
|
||||||
|
|
||||||
|
// onTheNetwork stands in for the overlay's generator: the node is part of the private network,
|
||||||
|
// and what the generator writes is not what is under test here.
|
||||||
|
type onTheNetwork struct{}
|
||||||
|
|
||||||
|
func (onTheNetwork) Resources(string) ([]map[string]any, bool, error) {
|
||||||
|
return []map[string]any{{"id": "overlay-config", "type": "file",
|
||||||
|
"path": "/etc/wireguard/mesh0.conf", "mode": "0600", "content": "[Interface]\n"}}, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheHostsRegionArrivesAsTheHostWillReadIt(t *testing.T) {
|
||||||
|
shelf := provided(t)
|
||||||
|
// A resolver restarting on the names another module put on the machine, and one resource it
|
||||||
|
// only runs at start — neither of which composition has any business changing.
|
||||||
|
resolver, err := catalogue.ParseManifest([]byte(`{
|
||||||
|
"module": "resolver", "version": "1", "requires": ["mesh-addressing"],
|
||||||
|
"resources": [
|
||||||
|
{"id": "seed", "type": "file", "path": "/etc/resolver/seed", "mode": "0644",
|
||||||
|
"content": "seed\n", "at": "start"},
|
||||||
|
{"id": "daemon", "type": "service", "unit": "resolver.service", "state": "running",
|
||||||
|
"restart-on": ["seed", "mesh-wireguard.fact-node-names"]}
|
||||||
|
]}`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
shelf[resolver.Module] = resolver
|
||||||
|
|
||||||
|
got, err := catalogue.Resolve(shelf, []string{overlay.Domain, "resolver"},
|
||||||
|
catalogue.Node{Name: "homer", At: "homer.internal"}, catalogue.World{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
names := map[string]string{"homer.internal": "10.42.0.1", "marge.internal": "10.42.0.2"}
|
||||||
|
out, err := got.Declaration(catalogue.Rendering{
|
||||||
|
Names: names, Machines: names, Suffix: "internal",
|
||||||
|
Generators: map[string]catalogue.Generator{overlay.Name: onTheNetwork{}},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ids := map[string]map[string]any{}
|
||||||
|
for _, r := range out {
|
||||||
|
ids[r["id"].(string)] = r
|
||||||
|
}
|
||||||
|
|
||||||
|
hosts := ids[overlay.Name+".fact-node-names"]
|
||||||
|
if hosts == nil {
|
||||||
|
t.Fatalf("no names reached the machine; the declaration has %v", keys(ids))
|
||||||
|
}
|
||||||
|
if hosts["path"] != "/etc/hosts" || hosts["into"] != "block" {
|
||||||
|
t.Fatalf("the hosts file is not written into as a region: %v", hosts)
|
||||||
|
}
|
||||||
|
content := hosts["content"].(string)
|
||||||
|
if !strings.Contains(content, "10.42.0.1\thomer.internal\thomer\t# this machine\n") {
|
||||||
|
t.Errorf("the region does not name the machine:\n%s", content)
|
||||||
|
}
|
||||||
|
for _, floor := range []string{"Generated by the mesh", "localhost", "127.0.1.1"} {
|
||||||
|
if strings.Contains(content, floor) {
|
||||||
|
t.Errorf("the region carries %q, which is the machine's:\n%s", floor, content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The resolver's reference to it still names a resource the host will be sent.
|
||||||
|
daemon := ids["resolver.daemon"]
|
||||||
|
if daemon == nil {
|
||||||
|
t.Fatalf("the resolver's service was not composed: %v", keys(ids))
|
||||||
|
}
|
||||||
|
for _, named := range daemon["restart-on"].([]any) {
|
||||||
|
if ids[named.(string)] == nil {
|
||||||
|
t.Errorf("the resolver restarts on %v, which is nothing the host is sent", named)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(daemon["restart-on"], []any{"resolver.seed", overlay.Name + ".fact-node-names"}) {
|
||||||
|
t.Errorf("restart-on is %v", daemon["restart-on"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// And a resource's own `at` passes through as the manifest wrote it.
|
||||||
|
if seed := ids["resolver.seed"]; seed == nil || seed["at"] != "start" {
|
||||||
|
t.Errorf("a resource's at did not survive composition: %v", seed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func keys(m map[string]map[string]any) []string {
|
||||||
|
out := make([]string, 0, len(m))
|
||||||
|
for k := range m {
|
||||||
|
out = append(out, k)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user