Take the catalogue's composition rules into the node-engine
ADR 0593 §7: the composer with its refusals leaves mesh-sdk, so mesh-sdk carries no composition rule. The catalogue functions only the node-engine's composition reaches (Resolve, Resolution's Compose, Declaration, DataItems, LeftOut, Rules and the rest, the settings, placeholder and template refusals, the rendering helpers) are copied with their tests into internal/compose/resolution; a method of a mesh-sdk shape becomes a function taking it first. The composition calls its own copies, and the captured catalogue its tests read lives here now, shared by mesh-compose's tests. Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
This commit is contained in:
@@ -0,0 +1,311 @@
|
||||
package resolution
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
|
||||
func published(module, host string, port int) mcat.Manifest {
|
||||
return mcat.Manifest{Module: module, Version: "1",
|
||||
Contributes: map[string]map[string]any{
|
||||
"reverse-proxy": {"host": host, "port": port},
|
||||
}}
|
||||
}
|
||||
|
||||
func proxy() mcat.Manifest {
|
||||
return mcat.Manifest{Module: "traefik", Version: "1",
|
||||
Provides: mcat.Offers("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{mcat.ReceivedID("reverse-proxy")}},
|
||||
}}
|
||||
}
|
||||
|
||||
// received digs the delivered contributions back out of a declaration.
|
||||
func received(t *testing.T, out []map[string]any) []mcat.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 []mcat.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(), mcat.World{})
|
||||
|
||||
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(), mcat.World{})
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err := Declaration(got, mcat.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(), mcat.World{})
|
||||
|
||||
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(), mcat.World{})
|
||||
|
||||
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(), mcat.World{})
|
||||
|
||||
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(), mcat.World{})
|
||||
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(), mcat.World{})
|
||||
|
||||
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."+mcat.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(), mcat.World{})
|
||||
|
||||
out, err := Declaration(got, mcat.Rendering{Settings: mcat.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 TestASettingReachesAContributionOnlyWhereItDeclaresTheKey(t *testing.T) {
|
||||
// novox/hq 04-ISSUES/173: the mail module's site name, set so its environment file could read
|
||||
// it, arrived in every route it contributed. A setting overrides a key the contribution
|
||||
// declares and adds none — the provider reads the contribution as a contract.
|
||||
got, _ := Resolve(shelf(proxy(), published("board", "board", 8080)), []string{"board"}, workstation(), mcat.World{})
|
||||
|
||||
out, err := Declaration(got, mcat.Rendering{Settings: mcat.SettingsBy{
|
||||
"board": {{From: "the mesh", Values: map[string]any{"host": "dashboard", "sitename": "Board"}}},
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
given := received(t, out)
|
||||
if given[0].Values["host"] != "dashboard" {
|
||||
t.Fatalf("the setting did not override the route's host: %v", given[0].Values)
|
||||
}
|
||||
if _, leaked := given[0].Values["sitename"]; leaked {
|
||||
t.Fatalf("a setting the route never declared reached the proxy: %v", given[0].Values)
|
||||
}
|
||||
}
|
||||
|
||||
func provider() mcat.Manifest {
|
||||
return mcat.Manifest{Module: "postgres", Version: "1",
|
||||
Provides: mcat.FromAnywhere("postgres-database"),
|
||||
Receives: map[string]string{"postgres-database": "/var/lib/postgres/grants/mesh.json"},
|
||||
Grants: map[string]string{"postgres-database": "/var/lib/postgres/grants"},
|
||||
}
|
||||
}
|
||||
|
||||
func grantedTo(t *testing.T, out []map[string]any) []mcat.Contribution {
|
||||
t.Helper()
|
||||
for _, r := range out {
|
||||
if r["path"] != "/var/lib/postgres/grants/mesh.json" {
|
||||
continue
|
||||
}
|
||||
var parsed struct {
|
||||
Given []mcat.Contribution `json:"given"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(r["content"].(string)), &parsed); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return parsed.Given
|
||||
}
|
||||
t.Fatalf("the provider was given no manifest: %v", out)
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestAConsumersOwnContributionsAreStillThere(t *testing.T) {
|
||||
// Cross-node grants are merged in with this machine's own, because from the provider's side
|
||||
// they are the same thing — somebody wanting something — and a provider that had to read two
|
||||
// lists would read one of them.
|
||||
got, err := Resolve(shelf(proxy(), published("board", "board", 8080)),
|
||||
[]string{"board"}, reachable(), mcat.World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
given := received(t, mustDeclare(t, got))
|
||||
if len(given) != 1 || given[0].Node != "" {
|
||||
t.Fatalf("a local contribution grew a node: %v", given)
|
||||
}
|
||||
}
|
||||
|
||||
func TestARequireOnlyConsumerOfAParameterlessProvisionStillAsks(t *testing.T) {
|
||||
// A consumer that requires a parameterless provision (one whose serves names no consumer key —
|
||||
// redis-cache, amqp) contributes no payload, but it still ASKS for the provision and must be
|
||||
// granted a credential. Keying "asks" on contributions alone gave its grant From="" — read as
|
||||
// withdrawn — so the provider never created the account and the consumer authenticated nowhere.
|
||||
r := mcat.Resolution{
|
||||
Node: "laptop",
|
||||
Modules: []mcat.Manifest{{
|
||||
Module: "amqp-ping",
|
||||
Requires: []string{"amqp"},
|
||||
}},
|
||||
}
|
||||
values, asks, err := ContributionsFrom(r, "amqp", "amqp-ping", mcat.SettingsBy{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !asks {
|
||||
t.Fatal("a require-only consumer was treated as not asking; its grant would be withdrawn and it would never be provisioned")
|
||||
}
|
||||
if values == nil {
|
||||
t.Fatalf("asks is true but values is nil; expected an empty contribution, got %v", values)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAConsumerThatStoppedAskingIsWithdrawn(t *testing.T) {
|
||||
// Withdrawal is how a credential is taken away. The provisioner removes what nobody asks for,
|
||||
// and it can only do that if the mesh stops asking — a consumer that was unassigned would
|
||||
// otherwise keep a working login for ever, and nothing would say so.
|
||||
//
|
||||
// A grant with no asking module is exactly that state: the mesh still holds the secret,
|
||||
// because it is sealed and unusable to the mesh anyway, and the machine no longer wants it.
|
||||
got, err := Resolve(shelf(provider()), []string{"postgres"}, reachable(), mcat.World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err := Declaration(got, mcat.Rendering{Grants: []mcat.Grant{
|
||||
{Provision: "postgres-database", Consumer: "still-here", From: "meshboard",
|
||||
Values: map[string]any{"name": "meshboard"}, Sealed: "c2VhbGVk"},
|
||||
{Provision: "postgres-database", Consumer: "gone-away", Sealed: "c3RhbGU="},
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
given := grantedTo(t, out)
|
||||
if len(given) != 1 || given[0].Node != "still-here" {
|
||||
t.Fatalf("the provider is still told about a machine that stopped asking: %v", given)
|
||||
}
|
||||
// And no credential is written for it either, or the provisioner would find a file for a
|
||||
// consumer its manifest does not mention and have to guess what that means.
|
||||
for _, r := range out {
|
||||
if path, _ := r["path"].(string); strings.Contains(path, "gone-away") {
|
||||
t.Fatalf("a withdrawn consumer's credential is still delivered: %v", r)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user