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")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user