// 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" "crypto/sha256" "crypto/tls" "crypto/x509" "encoding/hex" "encoding/json" "errors" "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_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) ` 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 } } credential, err := brokerFrom() if err != nil { return err } 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 := dial(credential) 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 } // 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() (Credential, error) { if path := strings.TrimSpace(os.Getenv("MESH_BROKER_FILE")); path != "" { raw, err := os.ReadFile(path) if err != nil { return Credential{}, fmt.Errorf("cannot read this builder's credential: %w", err) } said := strings.TrimSpace(string(raw)) if said == "" { // An empty credential file is a machine that will connect as nobody and be refused, // with the reason three layers away. return Credential{}, fmt.Errorf("%s is empty, so this builder has no credential", path) } var held Credential if err := json.Unmarshal([]byte(said), &held); err == nil && held.URL != "" { return held, nil } // A file holding only a URL, which is what a person writing one by hand produces. The // broker is then verified against whatever this machine already trusts. return Credential{URL: said}, nil } url := strings.TrimSpace(os.Getenv("MESH_BROKER_AMQP")) if url == "" { return Credential{}, fmt.Errorf( "neither MESH_BROKER_FILE nor MESH_BROKER_AMQP: a builder with no broker has " + "nothing to build") } return Credential{URL: url}, nil } // Credential is what a build machine is given so it can reach the broker. // // Two things, because reaching a broker over TLS needs both: who to connect as, and what to check // the certificate against. A mesh's broker presents a certificate of the mesh's own, which is in // no public trust store, so a URL alone can only connect to a broker somebody else vouches for. // // **The same shape a node gets, for the same reason** (novox/hq ADR 0004): the fingerprint travels // out of band — here, sealed with the credential — and the endpoint is verified once at connect. type Credential struct { URL string `json:"url"` // Fingerprint is SHA-256 over the broker certificate's DER bytes, or empty to verify the // ordinary way. Fingerprint string `json:"fingerprint,omitempty"` } // dial opens the connection, pinning the broker's certificate when there is one to pin. func dial(held Credential) (*amqp.Connection, error) { if held.Fingerprint == "" { return amqp.Dial(held.URL) } return amqp.DialTLS(held.URL, pinning(held.Fingerprint)) } // pinning is a TLS configuration that trusts exactly one certificate. // // InsecureSkipVerify with a VerifyPeerCertificate is **pinning, not skipping**: the standard chain // check is replaced, not removed, and what replaces it is stricter — one certificate is accepted // rather than every certificate a public authority would sign. // // Its own function so a test can drive it against a real handshake. A pin check that is only ever // exercised through a broker is a pin check nothing tests. func pinning(fingerprint string) *tls.Config { return &tls.Config{ InsecureSkipVerify: true, VerifyPeerCertificate: func(raw [][]byte, _ [][]*x509.Certificate) error { if len(raw) == 0 { return errors.New("the broker presented no certificate") } // The leaf, and in the same spelling the mesh writes it — `sha256:` and 64 hex // characters. Comparing a bare digest against a written fingerprint never matches, // and the failure is indistinguishable from being pointed at the wrong broker. sum := sha256.Sum256(raw[0]) got := "sha256:" + hex.EncodeToString(sum[:]) if got != fingerprint { return fmt.Errorf( "this is not the broker this builder was told about\n expected %s\n "+ "got %s\nEither this mesh's broker was replaced, or this builder is "+ "being pointed at something else. Retrying will not help", fingerprint, got) } return nil }, } }