// 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" "net/url" "os" "os/signal" "strings" "syscall" amqp "github.com/rabbitmq/amqp091-go" "github.com/novox/mesh-controller/internal/broker" "github.com/novox/mesh-controller/internal/builder" "github.com/novox/mesh-controller/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 [--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() machine, err := takeWorkFrom(credential, on) if err != nil { return err } defer machine.Close() fmt.Fprintf(os.Stderr, "building for the mesh, publishing to %s\n", registry) publisher := builder.Registry{Address: registry, Run: builder.Command} return machine.Take(ctx, func(ctx context.Context, work link.Build) { answer(ctx, publisher, on, workspace, work) }) } // takeWorkFrom opens this machine's link to whichever bus the mesh is on. // // **One place chooses**, as everywhere else the bus change went (novox/hq ADR 0116 step 5): a build // machine told about both would take work from one and answer on the other, and every log line would // say it was fine. func takeWorkFrom(credential Credential, on string) (link.BuildMachine, error) { address, onNATS, err := broker.OnNATS() if err != nil { return nil, err } if err := broker.MustBeOneBus(credential.URL, address); err != nil { return nil, err } if onNATS { js, err := broker.Dial(address) if err != nil { return nil, fmt.Errorf("cannot reach the bus at %s: %w", address, err) } return link.MachineOverNATS(js, on), nil } conn, err := dial(credential) if err != nil { // Not quoted back: the URL carries this builder's broker password. return nil, fmt.Errorf("cannot reach the broker: %w", err) } channel, err := conn.Channel() if err != nil { conn.Close() return nil, err } return link.MachineOverCurrent(conn, channel, on), nil } // answer does one build and says what happened, whichever way it went. func answer(ctx context.Context, publisher builder.Publisher, on, workspace string, work link.Build) { request := work.Request() // **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 for %s\n", request.Repository) 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, forgeFrom(), 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)) } } if err := work.Announce(ctx, result); err != nil { // Said, not fatal: the build happened. A build reported as failed because announcing it // failed is a lie about work that was done — and the request stays unsettled below only if // nothing was said at all, so another machine can try. fmt.Fprintf(os.Stderr, "cannot say what came of a build: %v\n", err) return } // Settled only once the outcome is away, so a machine that dies before answering leaves the work // for another rather than losing it. if err := work.Done(); err != nil { fmt.Fprintf(os.Stderr, "the outcome is away and the request could not be settled: %v\n", err) } } // 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 } // forgeFrom is the git credential this builder may offer a clone, composed from the same binding // and sealed secret its package-registry half already reads: the forge that answers npm is the // forge that hosts the repositories, and its provisioner applies one password to one user for // both. Anything missing means no credential, and every clone stays anonymous — which is all a // mesh of public repositories ever needs. // // The URL names the binding's own address — the machine the mesh says the forge is on — so a // private repository is registered and built by that address, and a clone of anything else is // never shown this credential (git's credential store matches the whole origin). func forgeFrom() builder.GitCredential { path := strings.TrimSpace(os.Getenv("MESH_PACKAGE_BINDING")) if path == "" { return builder.GitCredential{} } raw, err := os.ReadFile(path) if err != nil { return builder.GitCredential{} } var told struct { At string `json:"at"` As string `json:"as"` Serves map[string]any `json:"serves"` } if err := json.Unmarshal(raw, &told); err != nil || told.At == "" || told.As == "" { return builder.GitCredential{} } secret := strings.TrimSpace(os.Getenv("MESH_NPM_TOKEN")) if file := strings.TrimSpace(os.Getenv("MESH_NPM_TOKEN_FILE")); file != "" { if raw, err := os.ReadFile(file); err == nil { secret = strings.TrimSpace(string(raw)) } } if secret == "" { return builder.GitCredential{} } scheme := "https" if s, ok := told.Serves["scheme"]; ok { scheme = fmt.Sprintf("%v", s) } host := told.At if port, ok := told.Serves["port"]; ok { host = fmt.Sprintf("%s:%v", told.At, port) } made := url.URL{Scheme: scheme, User: url.UserPassword(told.As, secret), Host: host} return builder.GitCredential{URL: made.String()} } 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 }, } }