A module is a repository and a path, the builder announces what it made, and the mesh acts on it #20

Merged
jschoubben merged 7 commits from feat/a-module-is-a-repository-and-a-path into main 2026-09-13 09:16:33 +00:00
3 changed files with 185 additions and 0 deletions
Showing only changes of commit 603be706fc - Show all commits
+10
View File
@@ -56,6 +56,14 @@ is dialled except the broker.
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)
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 {
@@ -64,6 +72,8 @@ func run() error {
case "version":
fmt.Println(version)
return nil
case "build":
return buildOnce(context.Background(), os.Args[2:])
default:
fmt.Print(usage)
return nil
+114
View File
@@ -0,0 +1,114 @@
package main
import (
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"os"
"github.com/novox/mesh-control/internal/builder"
)
// buildOnce is the builder doing one build and stopping, with no broker and no mesh.
//
// **This is how a mesh is raised** (novox/hq ADR 0073). The installer carries this program and runs
// it once, before anything else exists, to produce the control plane from the same repository and
// path that every later rebuild of it will use. What raises the mesh is therefore the same thing
// that maintains it — not a second mechanism that has to be kept in step with the first and is
// exercised once per new mesh, which is how often enough to rot.
//
// It takes no work from a queue and answers nobody: there is no broker yet, and the only thing
// waiting for the answer is the installer that started it. So the result goes to standard output as
// JSON, which is what the installer reads.
//
// With no --registry it publishes nowhere and the image stays in this machine's container runtime,
// named by the digest of its own configuration (see builder.Local). That is the genesis case. With
// one, it publishes as it always does — the same command is how an operator builds a module by hand
// on a mesh that already exists.
func buildOnce(ctx context.Context, args []string) error {
set := flag.NewFlagSet("build", flag.ContinueOnError)
path := set.String("path", "", "the module's directory inside the repository (default: its root)")
ref := set.String("ref", "", "the commit to build; a branch is a moving target somebody else controls")
registry := set.String("registry", "",
"host:port to publish to. Without it the artifacts stay in this machine's container runtime, which is the genesis case")
workspace := set.String("workspace", "", "where to clone and build (default: a temporary directory)")
if err := set.Parse(args); err != nil {
return err
}
if set.NArg() != 1 {
return errors.New("mesh-builder build <repository> [--path P] [--ref COMMIT] [--registry HOST:PORT]")
}
repository := set.Arg(0)
where := *workspace
if where == "" {
where = os.TempDir() + "/mesh-builder-once"
}
// Local unless told otherwise, because the moment this exists for has nowhere to publish. A
// default pointing at a registry would mean genesis failing at a push to something that is not
// there yet, one step away from the thing that could explain it.
var publisher builder.Publisher = builder.Local{Run: builder.Command}
if *registry != "" {
publisher = builder.Registry{Address: *registry, Run: builder.Command}
}
fmt.Fprintf(os.Stderr, "building %s", repository)
if *path != "" {
fmt.Fprintf(os.Stderr, " at %s", *path)
}
if *ref != "" {
fmt.Fprintf(os.Stderr, " at %s", *ref)
}
fmt.Fprintln(os.Stderr)
built, err := builder.Build(ctx, builder.Command, publisher, repository, *path, *ref, where)
if err != nil {
return err
}
// To standard output, and everything else to standard error, so the caller can read this
// without having to separate it from progress.
out := onceResult{
Module: built.Manifest.Module,
Commit: built.Commit,
Repository: repository,
Path: *path,
Ref: *ref,
Manifest: built.Manifest,
Against: built.Against,
}
for _, made := range built.Built {
out.Made = append(out.Made, madeArtifact{Name: made.Name, Kind: made.Kind, Reference: made.Reference})
}
body, err := json.MarshalIndent(out, "", " ")
if err != nil {
return err
}
fmt.Println(string(body))
fmt.Fprintf(os.Stderr, " built %s from %s\n", built.Manifest.Module, short(built.Commit))
return nil
}
// onceResult is what one build reports to whoever started it.
//
// The same fields the mesh records for a build, so a reader comparing a genesis build against an
// ordinary one is comparing the same thing said the same way.
type onceResult struct {
Module string `json:"module"`
Commit string `json:"commit"`
Repository string `json:"repository"`
Path string `json:"path,omitempty"`
Ref string `json:"ref,omitempty"`
Manifest any `json:"manifest"`
Made []madeArtifact `json:"made"`
Against []string `json:"against,omitempty"`
}
type madeArtifact struct {
Name string `json:"name"`
Kind string `json:"kind"`
Reference string `json:"reference"`
}
+61
View File
@@ -0,0 +1,61 @@
package builder
import (
"context"
"fmt"
"strings"
)
// Local is what a build publishes into when there is nowhere to publish yet.
//
// **This exists for exactly one moment: raising a mesh** (novox/hq ADR 0073). The installer builds
// the control plane on the machine that is about to run it, and at that moment there is no registry
// — the registry is installed afterwards, by the control plane this build produces. So the artifact
// stays where the build left it: in the machine's own container runtime.
//
// That is not a weaker kind of pinning. An image held locally is named by the digest of its own
// configuration, which is content-addressed and unforgeable and requires nothing to have served it
// — the same identity the installer has always used for the image it carried. What changes when a
// registry exists is not that the artifact becomes exact, but that something other than this
// machine can fetch it.
//
// It is deliberately unable to publish an archive. An archive has no local identity to fall back
// on: it is bytes that only mean something once something serves them at a URL. A build that
// produces one before there is anywhere to put it has produced nothing usable, and saying so is
// better than returning a path on a disk that no other machine can read.
type Local struct {
// Run is how docker is invoked, so a test does not need one.
Run Runner
}
// PublishImage leaves the image where the build put it, and names it by its own configuration.
//
// The local tag is not returned: a tag is a name somebody can move, and every other reference in a
// resolved manifest is exact. The digest is read back from the runtime rather than computed, for
// the same reason the registry publisher reads it back from the registry — what matters is what
// will be served for that reference, and only the thing serving it can say.
func (l Local) PublishImage(ctx context.Context, localTag, repository string) (string, error) {
out, err := l.Run(ctx, "", "docker", "image", "inspect", "--format", "{{.Id}}", localTag)
if err != nil {
return "", fmt.Errorf("cannot read back the image just built as %s: %w", localTag, err)
}
id := strings.TrimSpace(out)
if !strings.HasPrefix(id, "sha256:") || len(id) != len("sha256:")+64 {
// Refused rather than passed on. A bundle naming an image by anything a person could move
// is refused by the machine applying it, and a value that is not an identity would fail
// there instead — one step further from the thing that could explain it.
return "", fmt.Errorf(
"the container runtime named the image just built %q, which is not an image id: "+
"sha256 and sixty-four hex characters", id)
}
return id, nil
}
// PublishArchive refuses, and says why rather than inventing somewhere to put it.
func (l Local) PublishArchive(ctx context.Context, repository string, body []byte, digest string) (string, error) {
return "", fmt.Errorf(
"%s declares an archive, and this build has nowhere to publish one. An image can stay in "+
"the machine's own runtime and still be named exactly; an archive is bytes that mean "+
"nothing until something serves them. Build this once the mesh has a registry",
repository)
}