diff --git a/cmd/mesh-builder/main.go b/cmd/mesh-builder/main.go index f35763c..5f5fad5 100644 --- a/cmd/mesh-builder/main.go +++ b/cmd/mesh-builder/main.go @@ -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 [--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 diff --git a/cmd/mesh-builder/once.go b/cmd/mesh-builder/once.go new file mode 100644 index 0000000..b1705b3 --- /dev/null +++ b/cmd/mesh-builder/once.go @@ -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 [--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"` +} diff --git a/internal/builder/local.go b/internal/builder/local.go new file mode 100644 index 0000000..7e471db --- /dev/null +++ b/internal/builder/local.go @@ -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) +}