The mesh runs on the seat's bus alone (novox/hq ADR 0131, design 28 task 5.5). The old transport's consume loop, build request, tool ask, management API and account scoping are deleted, and the bus switch with them; the controller connects to the broker seat and to nothing else. The store-window tests keep their assertions on a bus-less fake, and the tests that only made sense for the old transport's in-memory holding go with it.
456 lines
18 KiB
Go
456 lines
18 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"
|
|
"net/url"
|
|
"os"
|
|
"os/signal"
|
|
"strings"
|
|
"syscall"
|
|
|
|
"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_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()
|
|
|
|
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) {
|
|
// **The credential names the bus, and there is one** (novox/hq ADR 0131, design 28 task 5.5).
|
|
// A credential for the mesh's bus carries user, password and fingerprint beside the address,
|
|
// and that is enough to dial it, pinned.
|
|
if !credential.onTheNewBus() {
|
|
return nil, fmt.Errorf("the credential at hand names %q, which is not the mesh's bus", credential.URL)
|
|
}
|
|
js, err := broker.DialPinned(credential.natsURL(), credential.Fingerprint)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return link.MachineOverNATS(js, 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
|
|
}
|
|
return Credential{}, fmt.Errorf(
|
|
"no MESH_BROKER_FILE: a build machine with no credential for the bus has nothing to build")
|
|
}
|
|
|
|
// 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 string `json:"fingerprint,omitempty"`
|
|
// User and Password ride beside the address on the bus being built (design 25): a credential
|
|
// embedded in a URL leaks into every log line that prints a connection, so the mesh seals them
|
|
// as two fields and this machine joins them once, here, to dial.
|
|
User string `json:"user,omitempty"`
|
|
Password string `json:"password,omitempty"`
|
|
}
|
|
|
|
// onTheNewBus is whether a credential is for the bus being built: its address says so, and the
|
|
// mesh only ever seals such a credential with the user and password beside it.
|
|
func (c Credential) onTheNewBus() bool { return strings.HasPrefix(strings.TrimSpace(c.URL), "nats://") }
|
|
|
|
// natsURL is the address with this machine's credential in it, for the one dial that needs it.
|
|
func (c Credential) natsURL() string {
|
|
rest := strings.TrimPrefix(strings.TrimSpace(c.URL), "nats://")
|
|
if c.User == "" {
|
|
return "nats://" + rest
|
|
}
|
|
return "nats://" + c.User + ":" + c.Password + "@" + rest
|
|
}
|