diff --git a/cmd/mesh-builder/Dockerfile b/cmd/mesh-builder/Dockerfile new file mode 100644 index 0000000..3c6cc5f --- /dev/null +++ b/cmd/mesh-builder/Dockerfile @@ -0,0 +1,20 @@ +# The builder, as a module ships one. +# +# Not FROM scratch, unlike the control plane: this one runs git and a container client, so it +# needs a filesystem with them on it. That is the honest cost of a machine whose job is to build — +# and it is why building is a MODULE on a machine that has a runtime rather than something the +# control plane does (novox/hq ADR 0005). +FROM golang:1.25-alpine AS build +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 go build -trimpath -ldflags '-s -w' -o /mesh-builder ./cmd/mesh-builder + +FROM alpine:3 +# git to clone what it is asked to build, and the docker client to build and push it. The daemon +# is the machine's, reached through its socket — a build machine shares the runtime it was given +# rather than running one inside itself. +RUN apk add --no-cache git docker-cli +COPY --from=build /mesh-builder /usr/local/bin/mesh-builder +ENTRYPOINT ["/usr/local/bin/mesh-builder"] diff --git a/cmd/mesh-builder/main.go b/cmd/mesh-builder/main.go index 57eddb0..2790411 100644 --- a/cmd/mesh-builder/main.go +++ b/cmd/mesh-builder/main.go @@ -47,6 +47,7 @@ It consumes build requests and answers with what it made. Nothing is listened on is dialled except the broker. MESH_BROKER_AMQP where the broker is, with this builder's own credential + MESH_BROKER_FILE a file the mesh sealed to this machine holding the same MESH_REGISTRY host:port to publish artifacts to, when the mesh has not said MESH_BINDING a file the mesh wrote saying where the artifact store is MESH_WORKSPACE where to clone and build (default: a temporary directory) @@ -64,9 +65,9 @@ func run() error { } } - amqpURL := strings.TrimSpace(os.Getenv("MESH_BROKER_AMQP")) - if amqpURL == "" { - return fmt.Errorf("no MESH_BROKER_AMQP: a builder with no broker has nothing to build") + amqpURL, err := brokerFrom() + if err != nil { + return err } registry, err := whereToPublish() if err != nil { @@ -260,3 +261,34 @@ func whereToPublish() (string, error) { } return fmt.Sprintf("%s:%v", told.At, port), nil } + +// brokerFrom is where this builder connects, and with what. +// +// **Preferably from a file the mesh sealed to this machine.** A builder that is a module is given +// its credential the way every other module is given one: generated or accepted centrally, sealed +// to the machine, written by the host. Putting it in an environment variable instead would mean +// the one copy that matters passing through a terminal and a process listing. +// +// The variable remains for a builder run by a person. +func brokerFrom() (string, error) { + if path := strings.TrimSpace(os.Getenv("MESH_BROKER_FILE")); path != "" { + raw, err := os.ReadFile(path) + if err != nil { + return "", fmt.Errorf("cannot read this builder's credential: %w", err) + } + url := strings.TrimSpace(string(raw)) + if url == "" { + // An empty credential file is a machine that will connect as nobody and be refused, + // with the reason three layers away. + return "", fmt.Errorf("%s is empty, so this builder has no credential", path) + } + return url, nil + } + url := strings.TrimSpace(os.Getenv("MESH_BROKER_AMQP")) + if url == "" { + return "", fmt.Errorf( + "neither MESH_BROKER_FILE nor MESH_BROKER_AMQP: a builder with no broker has " + + "nothing to build") + } + return url, nil +} diff --git a/cmd/mesh-builder/where_test.go b/cmd/mesh-builder/where_test.go index 7a87246..c7baa20 100644 --- a/cmd/mesh-builder/where_test.go +++ b/cmd/mesh-builder/where_test.go @@ -74,3 +74,44 @@ func TestNeitherIsRefusedRatherThanGuessed(t *testing.T) { t.Fatal("a builder with nowhere to publish reported somewhere") } } + +func TestTheCredentialComesFromAFileTheMeshSealed(t *testing.T) { + // A builder that is a module is given its credential the way every module is: sealed to the + // machine and written by the host. An environment variable instead would put the one copy + // that matters through a terminal and a process listing. + path := filepath.Join(t.TempDir(), "broker") + if err := os.WriteFile(path, []byte("amqps://a-builder:secret@broker.internal:5671/\n"), 0o600); err != nil { + t.Fatal(err) + } + t.Setenv("MESH_BROKER_FILE", path) + t.Setenv("MESH_BROKER_AMQP", "amqp://should-not-be-used@nowhere/") + + got, err := brokerFrom() + if err != nil { + t.Fatal(err) + } + if got != "amqps://a-builder:secret@broker.internal:5671/" { + t.Fatalf("got %q", got) + } +} + +func TestAnEmptyCredentialFileIsRefused(t *testing.T) { + // Otherwise the builder connects as nobody and is refused, with the reason three layers away. + path := filepath.Join(t.TempDir(), "broker") + if err := os.WriteFile(path, []byte("\n"), 0o600); err != nil { + t.Fatal(err) + } + t.Setenv("MESH_BROKER_FILE", path) + t.Setenv("MESH_BROKER_AMQP", "") + if _, err := brokerFrom(); err == nil { + t.Fatal("an empty credential was accepted") + } +} + +func TestABuilderWithNoCredentialAtAllSaysSo(t *testing.T) { + t.Setenv("MESH_BROKER_FILE", "") + t.Setenv("MESH_BROKER_AMQP", "") + if _, err := brokerFrom(); err == nil { + t.Fatal("a builder with no broker reported one") + } +} diff --git a/cmd/mesh-control/main.go b/cmd/mesh-control/main.go index 0a1bbb4..dd69bb9 100644 --- a/cmd/mesh-control/main.go +++ b/cmd/mesh-control/main.go @@ -102,7 +102,7 @@ func run() error { case "push": return pushCommand(ctx, args[1:]) case "status": - return statusCommand(ctx) + return statusCommand(ctx, args[1:]) case "version": fmt.Println(version) return nil @@ -133,7 +133,7 @@ func usage() { 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 - status what the mesh is behind on, and which nodes + status [--json] what is wrong, what is quiet, and what is out of date assign put a module on a node unassign take it off settings set what a module's config should say, for the whole mesh @@ -1561,7 +1561,13 @@ func short(commit string) string { // // The answer is not "a job succeeded". It is which modules the mesh has not built from what their // source now has, and which machines are running the old one. -func statusCommand(ctx context.Context) error { +func statusCommand(ctx context.Context, args []string) error { + set := flag.NewFlagSet("status", flag.ContinueOnError) + asJSON := set.Bool("json", false, "the same answers, for something other than a person") + if _, err := parseAround(set, args); err != nil { + return err + } + inv, err := openInventory(ctx) if err != nil { return err @@ -1571,10 +1577,48 @@ func statusCommand(ctx context.Context) error { // Three questions, in the order somebody asks them: is anything broken, is anything not // answering, is anything out of date. The first has consequences now, the second may, and // the third is a plan for later — and a status that led with the third would bury the first. + // + // All three are gathered before anything is said, so the two ways of saying it answer the + // same questions from the same reads rather than being two implementations. wrong, err := inv.NotDoingWhatTheyWereTold(ctx) if err != nil { return err } + nodes, err := inv.Nodes(ctx) + if err != nil { + return err + } + var quiet []inventory.Node + for _, n := range nodes { + // Never heard from, or not lately. Different from failing: a machine that says nothing + // may be new, switched off, or unreachable, and none of those is a machine that tried + // and could not. + if n.LastSeen.IsZero() || time.Since(n.LastSeen) > time.Hour { + quiet = append(quiet, n) + } + } + behind, err := inv.Behind(ctx) + if err != nil { + return err + } + sources := map[string]inventory.Source{} + for module := range behind { + from, err := inv.SourceOf(ctx, module) + if err != nil { + return err + } + sources[module] = from + } + + if *asJSON { + body, err := statusAsJSON(wrong, nodes, quiet, behind, sources) + if err != nil { + return err + } + fmt.Println(string(body)) + return nil + } + if len(wrong) > 0 { fmt.Printf("%d machine(s) are not doing what they were told:\n\n", len(wrong)) for _, d := range wrong { @@ -1591,28 +1635,15 @@ func statusCommand(ctx context.Context) error { fmt.Println() } - nodes, err := inv.Nodes(ctx) - if err != nil { - return err - } - var quiet []string - for _, n := range nodes { - // Never heard from, or not lately. Different from failing: a machine that says nothing - // may be new, switched off, or unreachable, and none of those is a machine that tried - // and could not. - if n.LastSeen.IsZero() || time.Since(n.LastSeen) > time.Hour { - quiet = append(quiet, n.Name+" ("+heardFrom(n)+")") - } - } if len(quiet) > 0 { + var said []string + for _, n := range quiet { + said = append(said, n.Name+" ("+heardFrom(n)+")") + } fmt.Printf("%d machine(s) not heard from lately:\n %s\n\n", - len(quiet), strings.Join(quiet, "\n ")) + len(quiet), strings.Join(said, "\n ")) } - behind, err := inv.Behind(ctx) - if err != nil { - return err - } if len(behind) > 0 { var names []string for m := range behind { @@ -1622,10 +1653,7 @@ func statusCommand(ctx context.Context) error { fmt.Printf("%d module(s) behind their source:\n\n", len(behind)) for _, m := range names { - from, err := inv.SourceOf(ctx, m) - if err != nil { - return err - } + from := sources[m] fmt.Printf(" %-18s holds %s, source has %s\n", m, short(from.BuiltFrom), short(from.Head)) if on := behind[m]; len(on) > 0 { // The part somebody actually wants. A module being out of date is a fact about @@ -2005,10 +2033,21 @@ func (b builds) Built(ctx context.Context, result link.BuildResult) error { // handed — which in practice meant the broker's own administrative one. A program documented as // holding its own credential and given somebody else's is worse than one with no story at all. func builderCommand(ctx context.Context, args []string) error { - if len(args) != 2 || args[0] != "issue" { - return errors.New("builder issue ") + set := flag.NewFlagSet("builder issue", flag.ContinueOnError) + // Which machine will use it. Given, the credential is delivered by the mesh rather than + // printed for somebody to carry — which is the difference between the builder being a module + // and being a program somebody configures. + forNode := set.String("node", "", + "the machine that will run it, so the mesh delivers the credential instead of printing it") + module := set.String("module", "builder", "the module on that machine that will read it") + positionals, err := parseAround(set, args) + if err != nil { + return err } - name := args[1] + if len(positionals) != 2 || positionals[0] != "issue" { + return errors.New("builder issue [--node ]") + } + name := positionals[1] management, err := broker.ManagementFromEnvironment() if err != nil { @@ -2029,6 +2068,29 @@ func builderCommand(ctx context.Context, args []string) error { fmt.Printf("broker account %s created, scoped to the %s queue and the %s exchange\n\n", name, link.BuildQueue, link.Exchange) + if *forNode != "" { + known, err := broker.FromEnvironment() + if err != nil { + return fmt.Errorf("cannot deliver a credential without knowing where the broker is: %w", err) + } + inv, err := openInventory(ctx) + if err != nil { + return err + } + defer inv.Close() + + url := fmt.Sprintf("amqps://%s:%s@%s/", name, password, known.Address) + if err := inv.AcceptSecretForModule(ctx, *forNode, *module, "broker", url); err != nil { + return err + } + // Not printed. It is sealed to that machine and the mesh cannot read it back, which is + // the whole point — printing it here would put the one copy that matters on a terminal. + fmt.Printf(" sealed to %s, for the %s module. It arrives with the next push.\n", + *forNode, *module) + fmt.Printf(" run `push %s` to send it\n", *forNode) + return nil + } + // The whole line only when the address is known. A URL with a placeholder where the host // should be is a URL somebody pastes and then debugs, and the placeholder is the last thing // they look at. diff --git a/cmd/mesh-control/readable.go b/cmd/mesh-control/readable.go new file mode 100644 index 0000000..06197bc --- /dev/null +++ b/cmd/mesh-control/readable.go @@ -0,0 +1,111 @@ +package main + +import ( + "encoding/json" + "fmt" + "time" + + "github.com/novox/mesh-control/internal/inventory" +) + +// The same answers, in a shape something other than a person can read. +// +// A board reads through interfaces and holds nothing (novox/hq 03-DESIGN/01-to-be/11-a-board.md). +// Everything it needs is already answered by these commands — as text, for people, which is not +// something a page can read. So each of them can say it again as JSON. +// +// **`--json` rather than a serving API**, because nothing needs one yet: whatever serves a board +// runs the command, and the constraint in the design holds either way — the board never touches a +// context's store. An API is the larger thing and should wait until something is asking for it. +// +// **These shapes are hard to change once anything is built against them.** So they stay close to +// what the domain already calls things, and carry no summary field that would have to be kept +// true. Nothing here is derived that a reader could not derive. + +// meshStatus is what `status --json` says: the three questions, in the order they are asked. +type meshStatus struct { + // Wrong is every machine whose last declaration was refused or partly failed. + Wrong []machineDoing `json:"wrong"` + // Quiet is every machine not heard from lately. Not the same as wrong: new, switched off and + // unreachable are not "tried and could not". + Quiet []machineQuiet `json:"quiet"` + // Behind is every module built from something older than its source has. + Behind []moduleBehind `json:"behind"` + // Machines is how many the mesh knows about, so a reader can tell "none wrong" from + // "none at all". + Machines int `json:"machines"` +} + +type machineDoing struct { + Node string `json:"node"` + // Outcome is refused or failed. Kept distinct all the way out: they are fixed in different + // places, and one word for both sends half the readers to the wrong one. + Outcome string `json:"outcome"` + Refused string `json:"refused,omitempty"` + Failed []struct { + ID string `json:"id"` + Error string `json:"error"` + } `json:"failed,omitempty"` + Applied int `json:"applied"` + At time.Time `json:"at"` +} + +type machineQuiet struct { + Node string `json:"node"` + // LastSeen is absent when the machine has never spoken, which is a different thing from + // having been quiet for a while. + LastSeen *time.Time `json:"lastSeen,omitempty"` +} + +type moduleBehind struct { + Module string `json:"module"` + BuiltFrom string `json:"builtFrom"` + Head string `json:"head"` + On []string `json:"on"` +} + +// statusAsJSON answers the same three questions as the text form, from the same calls. +func statusAsJSON(wrong []inventory.Doing, nodes []inventory.Node, quiet []inventory.Node, + behind map[string][]string, sources map[string]inventory.Source) ([]byte, error) { + + out := meshStatus{Machines: len(nodes), Wrong: []machineDoing{}, + Quiet: []machineQuiet{}, Behind: []moduleBehind{}} + + for _, d := range wrong { + row := machineDoing{ + Node: d.Node, Outcome: d.Outcome, Refused: d.Refused, Applied: d.Applied, At: d.At, + } + for _, f := range d.Failed { + row.Failed = append(row.Failed, struct { + ID string `json:"id"` + Error string `json:"error"` + }{ID: f.ID, Error: f.Error}) + } + out.Wrong = append(out.Wrong, row) + } + for _, n := range quiet { + row := machineQuiet{Node: n.Name} + if !n.LastSeen.IsZero() { + seen := n.LastSeen + row.LastSeen = &seen + } + out.Quiet = append(out.Quiet, row) + } + for module, on := range behind { + from := sources[module] + out.Behind = append(out.Behind, moduleBehind{ + Module: module, BuiltFrom: from.BuiltFrom, Head: from.Head, On: on, + }) + } + return json.MarshalIndent(out, "", " ") +} + +// say prints a value as JSON, for the commands that can answer either way. +func say(value any) error { + body, err := json.MarshalIndent(value, "", " ") + if err != nil { + return err + } + fmt.Println(string(body)) + return nil +} diff --git a/cmd/mesh-control/readable_test.go b/cmd/mesh-control/readable_test.go new file mode 100644 index 0000000..136dcd0 --- /dev/null +++ b/cmd/mesh-control/readable_test.go @@ -0,0 +1,138 @@ +package main + +import ( + "encoding/json" + "strings" + "testing" + "time" + + "github.com/novox/mesh-control/internal/inventory" +) + +// The shape something other than a person reads. +// +// Hard to change once anything is built against it, so it stays close to what the domain already +// calls things and carries no summary field that would have to be kept true. + +func statusOf(t *testing.T, wrong []inventory.Doing, nodes, quiet []inventory.Node, + behind map[string][]string, sources map[string]inventory.Source) map[string]any { + t.Helper() + body, err := statusAsJSON(wrong, nodes, quiet, behind, sources) + if err != nil { + t.Fatal(err) + } + var parsed map[string]any + if err := json.Unmarshal(body, &parsed); err != nil { + t.Fatalf("what a board would read is not JSON: %v", err) + } + return parsed +} + +func TestRefusedAndFailedStayDistinctAllTheWayOut(t *testing.T) { + // They are fixed in different places, so one word for both would send half the readers of a + // page to the wrong one. + got := statusOf(t, + []inventory.Doing{ + {Node: "one", Outcome: inventory.OutcomeRefused, Refused: "a file needs a path"}, + {Node: "two", Outcome: inventory.OutcomeFailed, Applied: 3, + Failed: []inventory.FailedResource{{ID: "shell.pkg", Error: "target not found"}}}, + }, + []inventory.Node{{Name: "one"}, {Name: "two"}}, nil, nil, nil) + + wrong, _ := got["wrong"].([]any) + if len(wrong) != 2 { + t.Fatalf("got %v", got["wrong"]) + } + first, _ := wrong[0].(map[string]any) + if first["outcome"] != inventory.OutcomeRefused || first["refused"] == nil { + t.Fatalf("a refusal did not survive: %v", first) + } + second, _ := wrong[1].(map[string]any) + if second["outcome"] != inventory.OutcomeFailed { + t.Fatalf("a failure did not survive: %v", second) + } + if second["applied"] != float64(3) { + // "Three of eight" and "none of eight" are different machines. + t.Fatalf("what did apply was not carried: %v", second) + } +} + +func TestAMachineThatNeverSpokeSaysSoByOmission(t *testing.T) { + // Never heard from and quiet for a while are different situations, and a zero time would read + // as a date in 1970 on any page that formatted it. + got := statusOf(t, nil, + []inventory.Node{{Name: "silent"}, {Name: "away", LastSeen: time.Now().Add(-3 * time.Hour)}}, + []inventory.Node{{Name: "silent"}, {Name: "away", LastSeen: time.Now().Add(-3 * time.Hour)}}, + nil, nil) + + quiet, _ := got["quiet"].([]any) + if len(quiet) != 2 { + t.Fatalf("got %v", got["quiet"]) + } + never, _ := quiet[0].(map[string]any) + if _, said := never["lastSeen"]; said { + t.Fatalf("a machine that never spoke carries a time: %v", never) + } + away, _ := quiet[1].(map[string]any) + if _, said := away["lastSeen"]; !said { + t.Fatalf("a machine that has been quiet carries no time: %v", away) + } +} + +func TestNothingWrongIsAnEmptyListRatherThanNothing(t *testing.T) { + // A page distinguishing "no machines are wrong" from "this field is missing" would have to + // handle both, and null is the one that gets forgotten. + got := statusOf(t, nil, []inventory.Node{{Name: "a", LastSeen: time.Now()}}, nil, nil, nil) + for _, key := range []string{"wrong", "quiet", "behind"} { + list, ok := got[key].([]any) + if !ok { + t.Fatalf("%q is %T, not a list", key, got[key]) + } + if len(list) != 0 { + t.Fatalf("%q is not empty: %v", key, list) + } + } + // And how many machines there are, so a reader can tell "none wrong" from "none at all". + if got["machines"] != float64(1) { + t.Fatalf("got %v", got["machines"]) + } +} + +func TestWhatIsBehindNamesTheMachinesRunningTheOldOne(t *testing.T) { + // A module being out of date is a fact about the catalogue; machines running the old one is + // the thing with consequences. + got := statusOf(t, nil, nil, nil, + map[string][]string{"shell": {"workstation", "laptop"}}, + map[string]inventory.Source{"shell": {BuiltFrom: "aaaaaaa1", Head: "bbbbbbb2"}}) + + behind, _ := got["behind"].([]any) + if len(behind) != 1 { + t.Fatalf("got %v", got["behind"]) + } + row, _ := behind[0].(map[string]any) + if row["module"] != "shell" || row["builtFrom"] != "aaaaaaa1" || row["head"] != "bbbbbbb2" { + t.Fatalf("got %v", row) + } + on, _ := row["on"].([]any) + if len(on) != 2 { + t.Fatalf("the machines running the old one are not named: %v", row) + } +} + +func TestNoSecretIsInWhatABoardReads(t *testing.T) { + // Everything here comes from the mesh's own records, which hold no readable secret — but a + // shape a page is built against is exactly where one would eventually be added for + // convenience, so this says it out loud. + body, err := statusAsJSON( + []inventory.Doing{{Node: "a", Outcome: inventory.OutcomeRefused, + Refused: "resource \"x\": a file needs a path"}}, + []inventory.Node{{Name: "a"}}, nil, nil, nil) + if err != nil { + t.Fatal(err) + } + for _, word := range []string{"password", "secret", "sealed", "credential", "token"} { + if strings.Contains(strings.ToLower(string(body)), word) { + t.Fatalf("what a board reads carries a %q field:\n%s", word, body) + } + } +} diff --git a/internal/inventory/secrets.go b/internal/inventory/secrets.go index 2cd2b51..de27a06 100644 --- a/internal/inventory/secrets.go +++ b/internal/inventory/secrets.go @@ -180,3 +180,40 @@ func (i *Inventory) SecretForModule(ctx context.Context, node, module, name stri } return made.ForConsumer, nil } + +// AcceptSecretForModule keeps a value somebody supplied as a module's own secret. +// +// The counterpart to SecretForModule, which generates one. Some of what a module needs the mesh +// cannot invent: a broker account exists because the broker was told about it, and the password is +// whatever was agreed with the broker at that moment. The mesh's job is to carry it to the machine +// that will use it without being able to read it afterwards. +// +// Sealed on the way in and the plaintext discarded, exactly as a generated one is — so the only +// difference between the two is where the value came from. +func (i *Inventory) AcceptSecretForModule(ctx context.Context, node, module, name, value string) error { + key, err := i.SealingKeyOf(ctx, node) + if err != nil { + return err + } + if key == "" { + return fmt.Errorf( + "%s has no sealing key, so nothing can be sealed to it — it joins again to get one", + node) + } + record, err := i.NodeByName(ctx, node) + if err != nil { + return err + } + + sealed, err := secrets.Accept(value, key, key) + if err != nil { + return err + } + _, err = i.store.Pool().Exec(ctx, + `insert into module_secret (node, module, name, sealed, node_key) + values ($1, $2, $3, $4, $5) + on conflict (node, module, name) do update set + sealed = excluded.sealed, node_key = excluded.node_key, made_at = now()`, + record.ID, module, name, sealed.ForConsumer, key) + return err +}