diff --git a/cmd/mesh-controller/network.go b/cmd/mesh-controller/network.go index e32bba6..8e612e1 100644 --- a/cmd/mesh-controller/network.go +++ b/cmd/mesh-controller/network.go @@ -275,25 +275,9 @@ func network(ctx context.Context, inv *inventory.Inventory, on map[string]bool, if err != nil { return nil, err } + // No registry trust is composed here any more: the container runtime's module states it, told + // where the store is reached by ${seat:mesh-artifact-store:reach} (novox/hq ADR 0222, issue 190). g, err := overlay.From(nodes, cidr, "") - if g != nil { - // The artifact store, as this network reaches it. Found rather than configured: the - // provider is whichever module offers it, on whichever machine holds that module — and if - // nothing does yet (genesis raises the registry before the catalogue knows it), there is - // no trust to write and nothing is written (novox/hq ADR 0082). - // - // Refused rather than composed without it when the question could not be answered: a - // declaration missing the trust because a lookup failed is a machine that cannot pull, - // delivered by a push that reported success — and nothing recomposes it until the next - // push (the shape of novox/hq issues 042/048, reappearing as a race). - at, port, found, storeErr := artifactStoreOnNetwork(ctx, inv, on) - if storeErr != nil { - return nil, fmt.Errorf("finding the artifact store this network reaches: %w", storeErr) - } - if found { - g.TrustRegistry(overlay.InternalName(at) + ":" + port) - } - } if err != nil && len(refused) > 0 { // The network is missing something, and some machines could not be resolved at all. Those // are almost always the same fact: a node that does not resolve contributes nothing, so diff --git a/internal/catalogue/computed_test.go b/internal/catalogue/computed_test.go index 3e74716..ccdd198 100644 --- a/internal/catalogue/computed_test.go +++ b/internal/catalogue/computed_test.go @@ -159,7 +159,8 @@ func TestTwoWaysToBeOnAPrivateNetworkRefuseAndNameBoth(t *testing.T) { } } -// reloading answers the runtime's trust as the networking module does (novox/hq ADR 0102): a file +// reloading answers the runtime's trust as the networking module once did (novox/hq ADR 0102; it no +// longer does, ADR 0222 — and beside the runtime's module it is refused, generated_collision_test): a file // written into, and the runtime reloaded on it. type reloading struct{} diff --git a/internal/catalogue/declaration.go b/internal/catalogue/declaration.go index 86204a4..7e276bf 100644 --- a/internal/catalogue/declaration.go +++ b/internal/catalogue/declaration.go @@ -431,6 +431,11 @@ func (r Resolution) compose(with Rendering, owner map[string]string, return nil, err } + generated, err := r.generatedHere(with) + if err != nil { + return nil, err + } + var out []map[string]any for _, m := range r.Modules { if with.Adopted && m.Filtering != nil { @@ -697,23 +702,14 @@ func (r Resolution) compose(with Rendering, owner map[string]string, first = append(first, file) } 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 - } + mine, part := generated[m.Module] 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 + resources = mine } // Now, and not before: a module whose resources are computed replaces them wholesale, and @@ -2353,3 +2349,49 @@ func accountsFirst(resources []map[string]any) (accounts, rest []map[string]any) } return accounts, rest } + +// generatedHere is what each computed module's generator answers for this machine, by module — +// absent for a module whose machine is not yet part of what it generates — held to the rule every +// module is held to: no two modules on a machine declare one path, unit, name or package (novox/hq +// issue 190, step 5; ADR 0222). +// +// Resolution checks the catalogue's manifests, and a computed module's manifest has none of the +// resources it will declare — they exist only once its generator has answered for this machine, +// here — so a generated resource writing into another module's file was never seen. That is how +// the private network came to declare the container runtime's daemon file and service beside the +// runtime's own module. Asked once, so what is checked is exactly what is declared. +func (r Resolution) generatedHere(with Rendering) (map[string][]map[string]any, error) { + generated := map[string][]map[string]any{} + placed := make([]Manifest, 0, len(r.Modules)) + for _, m := range r.Modules { + if m.Computed == "" { + placed = append(placed, m) + continue + } + 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) + } + resources, part, err := generator.Resources(r.Node) + if err != nil { + return nil, err + } + computed := m + computed.Resources = nil + if part { + generated[m.Module] = resources + computed.Resources = resources + } + placed = append(placed, computed) + } + if len(generated) == 0 { + return generated, nil // nothing resolution has not already judged + } + if problems := checkResources(placed); len(problems) > 0 { + return nil, fmt.Errorf("what the mesh computes for this machine collides with a module's own: %s", + strings.Join(problems, "; ")) + } + return generated, nil +} diff --git a/internal/catalogue/generated_collision_test.go b/internal/catalogue/generated_collision_test.go new file mode 100644 index 0000000..e843853 --- /dev/null +++ b/internal/catalogue/generated_collision_test.go @@ -0,0 +1,84 @@ +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, name or package. 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) + } +} diff --git a/internal/overlay/generator.go b/internal/overlay/generator.go index 712578d..9f1a475 100644 --- a/internal/overlay/generator.go +++ b/internal/overlay/generator.go @@ -78,18 +78,13 @@ type Generator struct { // keyPath is where each node keeps the private half it generated. Named rather than carried: // the mesh has never seen it and never will. keyPath string - // registry is the mesh's artifact store as the network reaches it (host:port), or empty when - // the mesh has none. Being on the network is what grants a machine the right to pull from it - // (novox/hq ADR 0082), so the module that puts a machine on the network is what writes the - // runtime's trust. (That is still a write into another module's file, the container runtime's; - // novox/hq issue 190 has it handed to that module as a value.) - registry string + // No registry. Being on the network is still what grants a machine the right to pull from the + // mesh's artifact store in the clear (novox/hq ADR 0082), but the runtime's file is the runtime's + // module's: the private network writes nothing into it, and that module states the registry + // itself, told where the store is reached by ${seat:mesh-artifact-store:reach} (novox/hq ADR + // 0222, issue 190). } -// TrustRegistry names the artifact store this network's machines pull from in the clear — -// the overlay is the transport security (ADR 0082). -func (g *Generator) TrustRegistry(hostPort string) { g.registry = hostPort } - // From builds a generator over the machines that are part of the network. // // The nodes given are the ones assigned the module — not every node the mesh knows. A machine @@ -128,31 +123,7 @@ func (g *Generator) Resources(node string) ([]map[string]any, bool, error) { if err := json.Unmarshal(raw, &parsed); err != nil { return nil, false, err } - resources := parsed.Resources - if g.registry != "" { - trust, err := json.Marshal(map[string]any{"insecure-registries": []string{g.registry}}) - if err != nil { - return nil, false, err - } - resources = append(resources, - map[string]any{ - // Written into, not over (novox/hq ADR 0102): the runtime's daemon file is the - // machine's — its data directory, its logging, whatever a predecessor set — and - // this states one fact in it. The host sets this key and keeps every other. - // ("merge" is the operator's settings merged into this content; "into" is the - // content written into the machine's file.) The registry speaks plain HTTP - // because every path to it is already inside the overlay's encryption (ADR 0082). - "id": "registry-trust", "type": "file", "path": "/etc/docker/daemon.json", - "content": string(trust) + "\n", "mode": "0644", "merge": "json", "into": "json", - }, - map[string]any{ - // Reloaded, not restarted: the runtime re-reads its trusted registries on a reload, - // and a restart stops every container on the machine (measured; ADR 0102). - "id": "registry-trust-reload", "type": "service", "unit": "docker.service", - "state": "running", "reload-on": []string{"registry-trust"}, - }) - } - return resources, true, nil + return parsed.Resources, true, nil } // Nodes are the machines this generator was built over, so a caller can say who is on the network. diff --git a/internal/overlay/generator_test.go b/internal/overlay/generator_test.go index 2a54332..569322b 100644 --- a/internal/overlay/generator_test.go +++ b/internal/overlay/generator_test.go @@ -1,15 +1,13 @@ package overlay import ( - "fmt" - "strings" "testing" ) -func TestTheNetworkCarriesRegistryTrust(t *testing.T) { - // novox/hq ADR 0082: being on the network is what grants a machine the right to pull from the - // mesh's artifact store in the clear, so the network module writes the runtime's trust — and - // writes nothing when the mesh has no store to trust. +// novox/hq ADR 0222, issue 190: the runtime's file and service are the runtime's module's. The +// private network writes nothing into either — being on it still grants the right to pull from the +// mesh's store in the clear (ADR 0082), and the runtime's module states that trust itself. +func TestTheNetworkWritesNothingOfTheRuntimes(t *testing.T) { nodes := []Node{ {Name: "anchor", Site: "lab", Hub: true, Endpoint: "192.0.2.10:51820", Key: "k1", Address: "10.42.0.1"}, {Name: "node2", Site: "lab", Key: "k2", Address: "10.42.0.2"}, @@ -18,47 +16,15 @@ func TestTheNetworkCarriesRegistryTrust(t *testing.T) { if err != nil { t.Fatal(err) } - plain, _, err := g.Resources("node2") - if err != nil { - t.Fatal(err) - } - for _, r := range plain { - if r["id"] == "registry-trust" { - t.Fatal("trust was written with no artifact store to trust") + for _, node := range []string{"anchor", "node2"} { + resources, part, err := g.Resources(node) + if err != nil || !part { + t.Fatalf("%s: resources: %v part=%v", node, err, part) } - } - - g.TrustRegistry("anchor.internal:5000") - trusted, part, err := g.Resources("node2") - if err != nil || !part { - t.Fatalf("resources: %v part=%v", err, part) - } - var file, service map[string]any - for _, r := range trusted { - switch r["id"] { - case "registry-trust": - file = r - case "registry-trust-reload": - service = r + for _, r := range resources { + if r["path"] == "/etc/docker/daemon.json" || r["unit"] == "docker.service" { + t.Errorf("%s: the private network declares the runtime's %v", node, r) + } } } - if file == nil || service == nil { - t.Fatalf("the trust file or its reload is missing: %v", trusted) - } - if file["path"] != "/etc/docker/daemon.json" || file["merge"] != "json" || file["into"] != "json" { - t.Fatalf("the trust is not written into daemon.json (ADR 0102): %v", file) - } - if content, _ := file["content"].(string); !strings.Contains(content, `"anchor.internal:5000"`) { - t.Fatalf("the trust does not name the store: %v", file["content"]) - } - if service["unit"] != "docker.service" { - t.Fatalf("the reload is not the runtime's: %v", service) - } - // Reloaded, never restarted: a restart stops every container on the machine (ADR 0102). - if _, restarts := service["restart-on"]; restarts { - t.Fatalf("the runtime is restarted for its trust: %v", service) - } - if fmt.Sprint(service["reload-on"]) != "[registry-trust]" { - t.Fatalf("the runtime is not reloaded for its trust: %v", service) - } }