status --json, so a board has something to read
A board reads through interfaces and holds nothing. Everything it needs is already answered — as text, for people, which is not something a page can read. `--json` rather than a serving API, because nothing needs one yet: whatever serves a board runs the command, and the constraint holds either way — the board never touches a context's store. An API is the larger thing and should wait until something asks for it. Both forms are gathered from the same reads before either says anything, so they answer the same questions rather than being two implementations that can drift. That was not true of the first version: the JSON printed after the text, because the branch was too late. Four properties, each asserted and each confirmed to fail when removed: - refused and failed stay distinct all the way out. They are fixed in different places, so one word for both sends half a page's readers to the wrong one — and how much DID apply is carried, since "three of eight" and "none of eight" are different machines - a machine that never spoke carries no time at all, rather than a zero one that any page would format as a date in 1970 - nothing is null. A page distinguishing "no machines are wrong" from "this field is missing" has to handle both, and null is the one that gets forgotten - no field is named like a secret. Everything here comes from records that hold no readable one, but a shape a page is built against is exactly where one would eventually be added for convenience
This commit is contained in:
@@ -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"]
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
+90
-28
@@ -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 <name> <commit> the source has a newer commit than the mesh built
|
||||
module forget <name> 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 <node> <module> put a module on a node
|
||||
unassign <node> <module> take it off
|
||||
settings set <module> <file> 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 <name>")
|
||||
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 <name> [--node <machine>]")
|
||||
}
|
||||
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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user