A module is a repository and a path, the builder announces what it made, and the mesh acts on it #20
@@ -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
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user