`status` says which machines are not doing what they were told, and nothing acted on it: a machine that refused or failed stayed wrong until somebody ran push again naming it. `push --behind` sends only to machines whose last report was not a clean apply. A command rather than a timer, deliberately: a scheduler is then a scheduler over this, where building the scheduler first would have meant two paths to one act with nothing to compare them against. Naming a machine and asking which machines need one are different requests, so `push <node> --behind` is refused rather than guessed. With nothing behind it says so, because "nothing needed one" and "this did not run" must never look the same. A machine failing the same way for six hours is pushed to anyway and said about — refusing would leave no way to retry after fixing the cause, and this is a command somebody ran. Proven in the lab: a machine is broken with a package that does not exist, `push --behind` names it and not the machine that is fine, the module is corrected, and the machine recovers without anybody naming it. And the builder can be told where to publish rather than configured. A builder that is a module requires an artifact store, and the mesh writes it the same binding any consumer of any provision gets. A binding with no address is refused rather than falling back to anything — that would publish to a store on the wrong machine and be found out much later. The variable remains for a builder run by a person, which is how it is still run while being developed.
263 lines
8.9 KiB
Go
263 lines
8.9 KiB
Go
// mesh-builder — the thing a build machine runs.
|
|
//
|
|
// It takes work from the mesh, turns a repository into artifacts, publishes them, and says what
|
|
// came out. It is **not** the control plane and it is **not** the host:
|
|
//
|
|
// - the control plane decides and never touches a machine. Building runs commands on one, and
|
|
// what the control plane may send a machine is bounded by the declaration language
|
|
// (novox/hq ADR 0005). "Run this build" is not in it, and widening the language so it could
|
|
// be would make the control plane able to run anything anywhere.
|
|
// - the host applies declarations and holds no opinion about what they contain. A host that
|
|
// also built things would need a container runtime and git, on every machine, to do something
|
|
// almost none of them will ever do.
|
|
//
|
|
// So it is a module: a program a machine runs because the mesh told it to, holding its own broker
|
|
// credential and nothing else. Compromise of a build machine is compromise of a build machine.
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"os/signal"
|
|
"strings"
|
|
"syscall"
|
|
"time"
|
|
|
|
amqp "github.com/rabbitmq/amqp091-go"
|
|
|
|
"github.com/novox/mesh-control/internal/builder"
|
|
"github.com/novox/mesh-control/internal/link"
|
|
)
|
|
|
|
// version is set at build time.
|
|
var version = "development"
|
|
|
|
func main() {
|
|
if err := run(); err != nil {
|
|
fmt.Fprintf(os.Stderr, "mesh-builder: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
const usage = `mesh-builder — builds modules for the mesh
|
|
|
|
It consumes build requests and answers with what it made. Nothing is listened on and nothing
|
|
is dialled except the broker.
|
|
|
|
MESH_BROKER_AMQP where the broker is, with this builder's own credential
|
|
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)
|
|
`
|
|
|
|
func run() error {
|
|
if len(os.Args) > 1 {
|
|
switch os.Args[1] {
|
|
case "version":
|
|
fmt.Println(version)
|
|
return nil
|
|
default:
|
|
fmt.Print(usage)
|
|
return nil
|
|
}
|
|
}
|
|
|
|
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")
|
|
}
|
|
registry, err := whereToPublish()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
workspace := os.Getenv("MESH_WORKSPACE")
|
|
if workspace == "" {
|
|
workspace = os.TempDir() + "/mesh-builder"
|
|
}
|
|
on, err := os.Hostname()
|
|
if err != nil {
|
|
on = "a build machine"
|
|
}
|
|
|
|
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
|
defer stop()
|
|
|
|
conn, err := amqp.Dial(amqpURL)
|
|
if err != nil {
|
|
// Not quoted back: the URL carries this builder's broker password.
|
|
return fmt.Errorf("cannot reach the broker: %w", err)
|
|
}
|
|
defer conn.Close()
|
|
channel, err := conn.Channel()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer channel.Close()
|
|
|
|
if _, err := channel.QueueDeclare(link.BuildQueue, true, false, false, false, nil); err != nil {
|
|
return err
|
|
}
|
|
// One at a time. A build machine that took five requests at once would run five container
|
|
// builds against one runtime and finish all of them slower than it would have finished the
|
|
// first — and the queue is what shares work between machines, so nothing is lost by it.
|
|
if err := channel.Qos(1, 0, false); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Not auto-acknowledged. A request acknowledged on arrival is a build that vanishes if this
|
|
// process dies mid-way, with nobody waiting on it ever hearing why.
|
|
requests, err := channel.ConsumeWithContext(ctx, link.BuildQueue, "mesh-builder",
|
|
false, false, false, false, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
fmt.Printf("building for the mesh, publishing to %s\n", registry)
|
|
publisher := builder.Registry{Address: registry, Run: builder.Command}
|
|
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
fmt.Println("stopping")
|
|
return nil
|
|
case delivery, ok := <-requests:
|
|
if !ok {
|
|
return fmt.Errorf("the broker closed the connection")
|
|
}
|
|
answer(ctx, channel, publisher, on, workspace, delivery)
|
|
}
|
|
}
|
|
}
|
|
|
|
// answer does one build and says what happened, whichever way it went.
|
|
func answer(ctx context.Context, channel *amqp.Channel, publisher builder.Publisher,
|
|
on, workspace string, delivery amqp.Delivery) {
|
|
|
|
var request link.BuildRequest
|
|
if err := json.Unmarshal(delivery.Body, &request); err != nil {
|
|
// Unreadable. Acknowledged and dropped rather than requeued: a message this builder
|
|
// cannot parse will not become parseable by being delivered again, and requeueing it
|
|
// would put it in front of every real request for ever.
|
|
fmt.Fprintf(os.Stderr, "a request could not be read and was dropped: %v\n", err)
|
|
_ = delivery.Ack(false)
|
|
return
|
|
}
|
|
|
|
result := link.BuildResult{
|
|
ID: request.ID, Repository: request.Repository, Ref: request.Ref, On: on,
|
|
}
|
|
fmt.Printf("building %s", request.Repository)
|
|
if request.Ref != "" {
|
|
fmt.Printf(" at %s", request.Ref)
|
|
}
|
|
fmt.Println()
|
|
|
|
built, err := builder.Build(ctx, builder.Command, publisher,
|
|
request.Repository, request.Ref, workspace)
|
|
if err != nil {
|
|
// A failure is a result. A build that fails and says nothing is indistinguishable from a
|
|
// builder that is not running, and those want completely different responses.
|
|
result.Failed = err.Error()
|
|
fmt.Fprintf(os.Stderr, " failed: %v\n", err)
|
|
} else {
|
|
manifest, marshalErr := json.Marshal(built.Manifest)
|
|
if marshalErr != nil {
|
|
result.Failed = marshalErr.Error()
|
|
} else {
|
|
result.Commit = built.Commit
|
|
result.Manifest = manifest
|
|
for _, made := range built.Built {
|
|
result.Made = append(result.Made, link.MadeArtifact{
|
|
Name: made.Name, Kind: made.Kind, Reference: made.Reference,
|
|
})
|
|
}
|
|
fmt.Printf(" built %s from %s\n", built.Manifest.Module, short(built.Commit))
|
|
}
|
|
}
|
|
|
|
body, err := json.Marshal(result)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "cannot report a build: %v\n", err)
|
|
_ = delivery.Ack(false)
|
|
return
|
|
}
|
|
|
|
// Always through the exchange, whether or not somebody is waiting.
|
|
//
|
|
// **Never the default exchange.** Permission there is granted per exchange rather than per
|
|
// queue, so a builder allowed to use it could publish into any node's queue — the privilege a
|
|
// build machine most obviously should not have. An asker binds its own reply queue to this
|
|
// key and filters by correlation; a control plane that records builds is bound to it too, so
|
|
// a result nobody asked for is still kept rather than reported into the void.
|
|
publishCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
|
defer cancel()
|
|
if err := channel.PublishWithContext(publishCtx, link.Exchange, link.KeyBuilt, false, false,
|
|
amqp.Publishing{
|
|
ContentType: "application/json",
|
|
CorrelationId: result.ID,
|
|
Body: body,
|
|
}); err != nil {
|
|
fmt.Fprintf(os.Stderr, "cannot answer a build request: %v\n", err)
|
|
}
|
|
// Acknowledged only once the answer is away, so a builder that dies before answering leaves
|
|
// the request for another machine rather than losing it.
|
|
_ = delivery.Ack(false)
|
|
}
|
|
|
|
func short(commit string) string {
|
|
if len(commit) > 8 {
|
|
return commit[:8]
|
|
}
|
|
return commit
|
|
}
|
|
|
|
// whereToPublish is the artifact store this builder uses.
|
|
//
|
|
// **Preferably from the mesh.** A builder that is a module requires an artifact store, and the
|
|
// mesh writes it a file saying which machine answers that and on what port — the same binding any
|
|
// consumer of any provision gets. Reading it means the address is not a setting somebody keeps in
|
|
// step by hand, and moving the store is an ordinary reassignment rather than an edit on every
|
|
// build machine.
|
|
//
|
|
// The environment variable remains for a builder run by a person, which is how this started and
|
|
// how it is still run while being developed.
|
|
func whereToPublish() (string, error) {
|
|
binding := strings.TrimSpace(os.Getenv("MESH_BINDING"))
|
|
if binding == "" {
|
|
registry := strings.TrimSpace(os.Getenv("MESH_REGISTRY"))
|
|
if registry == "" {
|
|
return "", fmt.Errorf("neither MESH_BINDING nor MESH_REGISTRY: a built artifact " +
|
|
"nobody can fetch is not built")
|
|
}
|
|
return registry, nil
|
|
}
|
|
|
|
raw, err := os.ReadFile(binding)
|
|
if err != nil {
|
|
return "", fmt.Errorf("cannot read what the mesh said about the artifact store: %w", err)
|
|
}
|
|
var told struct {
|
|
From string `json:"from"`
|
|
At string `json:"at"`
|
|
Serves map[string]any `json:"serves"`
|
|
}
|
|
if err := json.Unmarshal(raw, &told); err != nil {
|
|
return "", fmt.Errorf("%s is not a binding: %w", binding, err)
|
|
}
|
|
if told.At == "" {
|
|
// The provider is not on the private network, so there is no name to reach it by. Said
|
|
// rather than falling back to the machine's own name, which would publish to a store on
|
|
// the wrong machine and be found out much later.
|
|
return "", fmt.Errorf(
|
|
"%s says the artifact store is on %q and gives no address for it", binding, told.From)
|
|
}
|
|
port, ok := told.Serves["port"]
|
|
if !ok {
|
|
return "", fmt.Errorf("%s says nothing about which port the artifact store answers on",
|
|
binding)
|
|
}
|
|
return fmt.Sprintf("%s:%v", told.At, port), nil
|
|
}
|