A new 'package' artifact kind builds a module's own code on a public base image and publishes it to the mesh's package registry by version (hq ADR 0076) — the SDK above all, which the toolchain is built from and so cannot be built in the toolchain. The credential a build needs to resolve or publish packages is rendered as an .npmrc (basic auth, hq ADR 0048) and given to an image build as a buildkit secret, never a layer, so a token is not baked into the toolchain image. Claude-Session: https://claude.ai/code/session_01D6qtiYU3P9jk3pnAXyAFyx
516 lines
20 KiB
Go
516 lines
20 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"
|
|
"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_PACKAGE_BINDING a file the mesh wrote saying where the package registry is
|
|
MESH_NPM_TOKEN_FILE a file the mesh sealed holding the token for it
|
|
MESH_NPM_REGISTRY a package registry URL, when the mesh has not said (a person, the bootstrap)
|
|
MESH_NPM_TOKEN the token for it, likewise
|
|
MESH_NPM_SCOPE the scope it answers for (default: @novox)
|
|
MESH_WORKSPACE where to clone and build (default: a temporary directory)
|
|
|
|
It also builds one module and stops, which is how a mesh is raised — before there is a
|
|
broker to take work from or a registry to publish into:
|
|
|
|
mesh-builder build <repository> [--path P] [--ref COMMIT] [--registry HOST:PORT]
|
|
|
|
Without --registry the artifacts stay in this machine's container runtime, named by the
|
|
digest of their own configuration. The result is printed as JSON.
|
|
`
|
|
|
|
func run() error {
|
|
if len(os.Args) > 1 {
|
|
switch os.Args[1] {
|
|
case "version":
|
|
fmt.Println(version)
|
|
return nil
|
|
case "build":
|
|
return buildOnce(context.Background(), os.Args[2:])
|
|
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"
|
|
}
|
|
// **The mesh's name for this machine, not the container's.** A build is reported to the rest
|
|
// of the mesh, and a report whose origin reads `104cb10e105b` names something no other module
|
|
// can look up. The mesh already knows the answer and has a way to say it — `${machine:name}`
|
|
// in the environment file this module is handed — so the hostname is only what is left when
|
|
// nobody said.
|
|
on := os.Getenv("MESH_NODE")
|
|
if on == "" {
|
|
hostname, err := os.Hostname()
|
|
if err != nil {
|
|
return fmt.Errorf("this build machine has no name: nothing said MESH_NODE and the host would not say either: %w", err)
|
|
}
|
|
on = hostname
|
|
}
|
|
|
|
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.Fprintf(os.Stderr, "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) {
|
|
|
|
// **First thing, and to stdout.** A build request that arrives and produces no visible line
|
|
// until it either finishes or fails is indistinguishable from one that never arrived — which
|
|
// cost a long diagnosis against a running mesh, chasing "the handler never fired" when the
|
|
// truth was only that the handler said nothing until the end.
|
|
fmt.Fprintf(os.Stderr, "a build request arrived (%d bytes)\n", len(delivery.Body))
|
|
|
|
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, Path: request.Path,
|
|
Ref: request.Ref, On: on,
|
|
}
|
|
fmt.Fprintf(os.Stderr, "building %s", request.Repository)
|
|
if request.Path != "" {
|
|
fmt.Fprintf(os.Stderr, " at %s", request.Path)
|
|
}
|
|
if request.Ref != "" {
|
|
fmt.Fprintf(os.Stderr, " at %s", request.Ref)
|
|
}
|
|
fmt.Fprintln(os.Stderr)
|
|
|
|
npmrc, err := packagesFrom()
|
|
var built builder.Result
|
|
if err == nil {
|
|
// The package-registry credential is a build input, so it is resolved before the clone: a
|
|
// build that could not have resolved its dependencies is refused in front of the reason,
|
|
// not after a clone that then fails at npm ci.
|
|
built, err = builder.Build(ctx, builder.Command, publisher,
|
|
request.Repository, request.Path, request.Ref, workspace, request.Held, npmrc,
|
|
func(step, message string) {
|
|
fmt.Fprintf(os.Stderr, " [%s] %s\n", step, message)
|
|
})
|
|
}
|
|
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,
|
|
})
|
|
}
|
|
result.Against = built.Against
|
|
fmt.Fprintf(os.Stderr, " 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)
|
|
}
|
|
// **And announced, which is a different act from answering.** The reply goes to whoever asked
|
|
// and is correlated to their request; this says to the whole mesh that a module now exists at
|
|
// a commit, and the catalogue places it in the module graph (novox/hq ADR 0072). A build
|
|
// nobody asked for still has to be announced, or the graph knows less than the registry does.
|
|
//
|
|
// Only on success: a failed build produced no module-version, and announcing one would put
|
|
// something in the graph that was never made.
|
|
if result.Failed == "" && result.Commit != "" {
|
|
announced := map[string]any{
|
|
"module": moduleOf(result.Manifest), "commit": result.Commit,
|
|
"repository": result.Repository, "path": result.Path, "ref": result.Ref,
|
|
"manifest": json.RawMessage(result.Manifest), "against": result.Against,
|
|
"made": result.Made,
|
|
}
|
|
if err := link.EmitEvent(publishCtx, channel, link.KeyModuleBuilt, "builder", on, announced); err != nil {
|
|
// Said, not fatal: the build happened and was answered. A module the catalogue has not
|
|
// heard of is a gap somebody can close; a build reported as failed because announcing
|
|
// it failed is a lie about work that was done.
|
|
fmt.Fprintf(os.Stderr, " built, but could not announce it: %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)
|
|
}
|
|
|
|
// moduleOf reads the module's name out of the manifest it just built, which is the only place it is
|
|
// authoritative — the request named a repository and a path, not a module.
|
|
func moduleOf(manifest json.RawMessage) string {
|
|
var named struct {
|
|
Module string `json:"module"`
|
|
}
|
|
if err := json.Unmarshal(manifest, &named); err != nil {
|
|
return ""
|
|
}
|
|
return named.Module
|
|
}
|
|
|
|
// packagesFrom is where a build resolves the mesh's own published packages — the SDK above all
|
|
// (novox/hq ADR 0076, issue 053).
|
|
//
|
|
// Preferably from the mesh: a package-registry binding names the endpoint the way the artifact
|
|
// store's binding does, and a sealed token file the credential the way the broker's does. The
|
|
// environment variables remain for a builder run by a person, and for the bootstrap, where there is
|
|
// no registry yet — there the result is disabled and a build that needs no mesh-published dependency
|
|
// builds anyway.
|
|
func packagesFrom() (builder.Npmrc, error) {
|
|
scope := strings.TrimSpace(os.Getenv("MESH_NPM_SCOPE"))
|
|
if scope == "" {
|
|
scope = "@novox"
|
|
}
|
|
|
|
registry := strings.TrimSpace(os.Getenv("MESH_NPM_REGISTRY"))
|
|
var username string
|
|
if path := strings.TrimSpace(os.Getenv("MESH_PACKAGE_BINDING")); path != "" {
|
|
raw, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return builder.Npmrc{}, fmt.Errorf("cannot read what the mesh said about the package registry: %w", err)
|
|
}
|
|
var told struct {
|
|
From string `json:"from"`
|
|
At string `json:"at"`
|
|
As string `json:"as"`
|
|
Serves map[string]any `json:"serves"`
|
|
}
|
|
if err := json.Unmarshal(raw, &told); err != nil {
|
|
return builder.Npmrc{}, fmt.Errorf("%s is not a binding: %w", path, err)
|
|
}
|
|
if told.At == "" {
|
|
return builder.Npmrc{}, fmt.Errorf(
|
|
"%s says the package registry is on %q and gives no address for it", path, told.From)
|
|
}
|
|
// Composed from what the provider serves, so nothing here knows gitea's URL shape from
|
|
// another registry's: it states its port, the path its registry answers on, and the scheme.
|
|
scheme := "https"
|
|
if s, ok := told.Serves["scheme"]; ok {
|
|
scheme = fmt.Sprintf("%v", s)
|
|
}
|
|
port, ok := told.Serves["port"]
|
|
if !ok {
|
|
return builder.Npmrc{}, fmt.Errorf("%s says nothing about which port the package registry answers on", path)
|
|
}
|
|
npmPath, ok := told.Serves["npm-path"]
|
|
if !ok {
|
|
return builder.Npmrc{}, fmt.Errorf("%s says nothing about the path the package registry answers on", path)
|
|
}
|
|
registry = fmt.Sprintf("%s://%s:%v%v", scheme, told.At, port, npmPath)
|
|
username = told.As
|
|
}
|
|
|
|
// The credential the mesh sealed to this machine. The mesh authenticates the ordinary way — a
|
|
// generated password the provider only applies (novox/hq ADR 0048) — so with a username this is
|
|
// a password (basic auth); without one it is a bearer token a provider minted.
|
|
secret := strings.TrimSpace(os.Getenv("MESH_NPM_TOKEN"))
|
|
if path := strings.TrimSpace(os.Getenv("MESH_NPM_TOKEN_FILE")); path != "" {
|
|
raw, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return builder.Npmrc{}, fmt.Errorf("cannot read this builder's package-registry credential: %w", err)
|
|
}
|
|
secret = strings.TrimSpace(string(raw))
|
|
}
|
|
if u := strings.TrimSpace(os.Getenv("MESH_NPM_USER")); u != "" {
|
|
username = u
|
|
}
|
|
|
|
if registry == "" && secret == "" {
|
|
return builder.Npmrc{}, nil
|
|
}
|
|
if username != "" {
|
|
return builder.Npmrc{Scope: scope, Registry: registry, Username: username, Password: secret}, nil
|
|
}
|
|
return builder.Npmrc{Scope: scope, Registry: registry, Token: secret}, nil
|
|
}
|
|
|
|
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
|
|
},
|
|
}
|
|
}
|