diff --git a/cmd/mesh-control/main.go b/cmd/mesh-control/main.go index d3c4d49..d40d62b 100644 --- a/cmd/mesh-control/main.go +++ b/cmd/mesh-control/main.go @@ -123,6 +123,9 @@ func usage() { node add create a node record node list the nodes this mesh knows about node show what one machine reported it can do, and why + node public-domain the domain it composes its routed names under + node public-domain ...set it to d + node public-domain --clear ...it faces the outside no longer token issue --node a one-time right to join, for an existing record token issue --new create the record and issue for it identity show this control plane's signing key @@ -134,7 +137,8 @@ func usage() { module add register a module from its manifest module list what modules this mesh knows about module moved the source has a newer commit than the mesh built - module forget remove one, unless a node is running it + module forget remove one, unless a node runs it or the mesh holds things for it + module forget --and-what-it-holds ...and discard its settings, secrets and ports too module issue --node a broker account for a module, scoped to its emits and consumes status [--json] what is wrong, what is quiet, and what is out of date board [--listen ADDR] the same three questions, as a page that holds nothing diff --git a/cmd/mesh-control/nodes.go b/cmd/mesh-control/nodes.go index 587f147..d4265f1 100644 --- a/cmd/mesh-control/nodes.go +++ b/cmd/mesh-control/nodes.go @@ -22,7 +22,7 @@ import ( func nodeCommand(ctx context.Context, args []string) error { if len(args) == 0 { - return errors.New("node add , node list, node show , or node public-domain [domain]") + return errors.New("node add , node list, node show , or " + publicDomainUsage) } open, err := openStores(ctx) if err != nil { @@ -66,32 +66,84 @@ func nodeCommand(ctx context.Context, args []string) error { return nil case "public-domain": - // The domain this node composes its routed names under (novox/hq ADR 0056). Given a domain, - // it is set; given nothing, it is cleared — a node that stops facing the outside composes no - // names. Lab-versus-production is this one setting and nothing else (see the ADR). - if len(args) < 2 || len(args) > 3 { - return errors.New( - "node public-domain [domain] — a domain sets it, nothing clears it") - } - domain := "" - if len(args) == 3 { - domain = args[2] - } - if err := inv.SetPublicDomain(ctx, args[1], domain); err != nil { - return err - } - if domain == "" { - fmt.Printf("%s has no public domain, so it composes no routed names\n", args[1]) - } else { - fmt.Printf("%s composes its routed names under %s\n", args[1], domain) - } - return nil + // The domain this node composes its routed names under (novox/hq ADR 0056). + // + // **The form with no argument reports; clearing is asked for by name.** It used to clear — + // so `node public-domain anchor`, which reads like a question and is what anybody types to + // find out what the answer is, silently took every routed name the node had. A read-shaped + // invocation must never be a destructive write: there is no output that makes up for it, + // because the damage is already done by the time it prints. + return publicDomain(ctx, inv, args[1:]) default: return fmt.Errorf("node has no %q; it has add, list, show and public-domain", args[0]) } } +// publicDomainUsage is the one description of the three forms, so a refusal and the help agree. +const publicDomainUsage = "node public-domain — what it is now; " + + " to set it; --clear to take it away" + +// publicDomain reads, sets or clears the domain a node composes its routed names under. +// +// Three forms, and the destructive one is the only one that has to be asked for. Clearing is a +// real thing to want — a machine that stops facing the outside composes no names, and +// lab-versus-production is this one setting (novox/hq ADR 0056) — so it keeps a way to say it. +// What it does not keep is being the thing that happens when nothing was said at all. +func publicDomain(ctx context.Context, inv *inventory.Inventory, args []string) error { + set := flag.NewFlagSet("node public-domain", flag.ContinueOnError) + clear := set.Bool("clear", false, "take the domain away; it composes no routed names after") + positionals, err := parseAround(set, args) + if err != nil { + return err + } + if len(positionals) == 0 || len(positionals) > 2 { + return errors.New(publicDomainUsage) + } + node := positionals[0] + + switch { + case *clear && len(positionals) == 2: + // Both, which cannot be meant. Refused rather than one of them silently winning. + return fmt.Errorf("give %s a domain or --clear, not both: %q and --clear say opposite "+ + "things and the mesh will not choose between them", node, positionals[1]) + + case *clear: + if err := inv.SetPublicDomain(ctx, node, ""); err != nil { + return err + } + fmt.Printf("%s has no public domain, so it composes no routed names\n", node) + fmt.Printf(" run `push %s` to take them off it\n", node) + return nil + + case len(positionals) == 2: + if err := inv.SetPublicDomain(ctx, node, positionals[1]); err != nil { + return err + } + fmt.Printf("%s composes its routed names under %s\n", node, positionals[1]) + fmt.Printf(" run `push %s` to send it\n", node) + return nil + + default: + // Asked, so answered. NodeByName first, so a name the mesh has never heard of is a refusal + // rather than "it has no public domain", which is true of that name and says nothing. + if _, err := inv.NodeByName(ctx, node); err != nil { + return err + } + domain, err := inv.PublicDomainOf(ctx, node) + if err != nil { + return err + } + if domain == "" { + fmt.Printf("%s has no public domain, so it composes no routed names\n", node) + fmt.Printf(" `node public-domain %s ` gives it one\n", node) + return nil + } + fmt.Printf("%s composes its routed names under %s\n", node, domain) + return nil + } +} + func tokenCommand(ctx context.Context, args []string) error { if len(args) == 0 || args[0] != "issue" { return errors.New("token issue --node , or token issue --new ") diff --git a/cmd/mesh-control/nodes_test.go b/cmd/mesh-control/nodes_test.go new file mode 100644 index 0000000..a27d3ba --- /dev/null +++ b/cmd/mesh-control/nodes_test.go @@ -0,0 +1,99 @@ +package main + +import ( + "strings" + "testing" +) + +// **A read-shaped invocation is never a destructive write.** +// +// `node public-domain anchor` used to clear the domain. It reads like a question — it is what +// anybody types to find out what the answer is — and it silently took every routed name the node +// had. There is no output that makes up for that: by the time it prints, the fact is gone. +func TestAskingForANodesPublicDomainDoesNotTakeItAway(t *testing.T) { + open := aMesh(t) + ctx := t.Context() + if err := open.inventory.SetPublicDomain(ctx, "anchor", "example.test"); err != nil { + t.Fatal(err) + } + + if err := publicDomain(ctx, open.inventory, []string{"anchor"}); err != nil { + t.Fatal(err) + } + domain, err := open.inventory.PublicDomainOf(ctx, "anchor") + if err != nil { + t.Fatal(err) + } + if domain != "example.test" { + t.Fatalf("asking what the domain is took it away: %q", domain) + } +} + +// Clearing is a real thing to want — a machine that stops facing the outside composes no names — +// so it keeps a way to be said. What it stops being is what happens when nothing was said. +func TestClearingANodesPublicDomainIsAskedForByName(t *testing.T) { + open := aMesh(t) + ctx := t.Context() + if err := open.inventory.SetPublicDomain(ctx, "anchor", "example.test"); err != nil { + t.Fatal(err) + } + if err := publicDomain(ctx, open.inventory, []string{"anchor", "--clear"}); err != nil { + t.Fatal(err) + } + domain, err := open.inventory.PublicDomainOf(ctx, "anchor") + if err != nil { + t.Fatal(err) + } + if domain != "" { + t.Fatalf("--clear did not clear it: %q", domain) + } +} + +// A domain sets it, as it always did. +func TestGivingANodeAPublicDomainSetsIt(t *testing.T) { + open := aMesh(t) + ctx := t.Context() + if err := publicDomain(ctx, open.inventory, []string{"anchor", "example.test"}); err != nil { + t.Fatal(err) + } + domain, err := open.inventory.PublicDomainOf(ctx, "anchor") + if err != nil { + t.Fatal(err) + } + if domain != "example.test" { + t.Fatalf("got %q", domain) + } +} + +// A domain and --clear say opposite things. Refused rather than one of them silently winning. +func TestADomainAndClearTogetherIsRefused(t *testing.T) { + open := aMesh(t) + ctx := t.Context() + if err := open.inventory.SetPublicDomain(ctx, "anchor", "example.test"); err != nil { + t.Fatal(err) + } + err := publicDomain(ctx, open.inventory, []string{"anchor", "other.test", "--clear"}) + if err == nil { + t.Fatal("a domain and --clear together were accepted") + } + if !strings.Contains(err.Error(), "not both") { + t.Fatalf("the refusal does not say why: %v", err) + } + // And neither half happened. + domain, err := open.inventory.PublicDomainOf(ctx, "anchor") + if err != nil { + t.Fatal(err) + } + if domain != "example.test" { + t.Fatalf("a refused command changed something: %q", domain) + } +} + +// Asking about a name the mesh has never heard of is a refusal, not "it has no public domain" — +// which is true of that name and says nothing. +func TestAskingAboutAMachineTheMeshHasNeverHeardOfIsRefused(t *testing.T) { + open := aMesh(t) + if err := publicDomain(t.Context(), open.inventory, []string{"nowhere"}); err == nil { + t.Fatal("a name the mesh does not know was answered as if it were a machine") + } +}