Publish to the registry, and a command that builds a repository

One store, and it is the registry the bootstrap already pulls from. An
OCI registry is a content-addressed blob store that also understands
images: PUT a blob and it is retrievable at /v2/<name>/blobs/sha256:… for
ever, by digest. An archive is a content-addressed blob.

A second store beside it was considered and is the right answer for
objects that are mutable, need per-reader access, or are not build output
— somebody's uploads, a backup, a thing with a lifecycle. None of that
describes a digest-pinned archive, and running a second service to hold
one kind of immutable blob is two things to run, two to back up, and two
ways for an artifact to be missing. Overturnable by reading: the manifest
carries a URL and a digest, and neither says what served it.

`build <repository>` clones, reads module.json, builds what it declares,
publishes, and records the manifest with the commit it came from. It is a
command rather than something the control plane does on its own, because
building runs things on a machine and what the control plane may send a
machine is bounded by the declaration language. This is the shape the
builder module takes when it is given work over the broker.

Proven end to end on a real repository and a real registry: a shell
module with a package, a user and a dotfile archive built, published,
fetched back at the digest it declared, rebuilt to the same digest, and
its manifest accepted by the host's own parser — including `user` and
`archive`, which did not exist this morning.

A tag is never accepted as a pin, and a blob already stored is not sent
again — it is named by its content, so re-uploading asks the registry to
store what it already has under the name it already has.
This commit is contained in:
2026-08-30 03:36:04 +02:00
parent 604b04886b
commit 7d033ad9f6
4 changed files with 397 additions and 0 deletions
+67
View File
@@ -20,6 +20,7 @@ import (
"time"
"github.com/novox/mesh-control/internal/broker"
"github.com/novox/mesh-control/internal/builder"
"github.com/novox/mesh-control/internal/catalogue"
"github.com/novox/mesh-control/internal/identity"
"github.com/novox/mesh-control/internal/inventory"
@@ -63,6 +64,8 @@ func run() error {
defer stop()
switch args[0] {
case "build":
return buildCommand(ctx, args[1:])
case "pin":
return pinCommand(ctx, args[1:], true)
case "unpin":
@@ -131,6 +134,7 @@ func usage() {
settings set <module> <file> what a module's config should say, for the whole mesh
settings set <module> <file> --node <n> ...or for one machine
settings clear <module> [--node <n>] take a layer away
build <repository> [--ref R] build a module from its source and record it
pin <node> <provision> <from> which node this one gets a provision from
unpin <node> <provision> put that question back
plan <node> [--files|--json] what that node would run, and why
@@ -1625,3 +1629,66 @@ func pinCommand(ctx context.Context, args []string, setting bool) error {
fmt.Printf(" run `push %s` to send it\n", args[0])
return nil
}
// buildCommand builds a module from its source and records what came out.
//
// **Run where there is a container runtime**, which is why it is a command rather than something
// the control plane does on its own: building needs to run things on a machine, and what the
// control plane may send a machine is bounded by the declaration language. This is the shape the
// builder module will take when it is given work over the broker; today a person runs it, and the
// mesh records the result the same way either way.
func buildCommand(ctx context.Context, args []string) error {
set := flag.NewFlagSet("build", flag.ContinueOnError)
ref := set.String("ref", "", "the branch, tag or commit to build")
registry := set.String("registry", os.Getenv("MESH_REGISTRY"),
"host:port of the registry to publish to")
workspace := set.String("workspace", os.TempDir(), "where to clone and build")
dryRun := set.Bool("dry-run", false, "build and print the manifest, recording nothing")
positionals, err := parseAround(set, args)
if err != nil {
return err
}
if len(positionals) != 1 {
return errors.New("build <repository> [--ref R] [--registry host:port]")
}
if strings.TrimSpace(*registry) == "" {
return errors.New(
"no --registry and no MESH_REGISTRY: a built artifact nobody can fetch is not built")
}
publisher := builder.Registry{Address: *registry, Run: builder.Command}
result, err := builder.Build(ctx, builder.Command, publisher, positionals[0], *ref, *workspace)
if err != nil {
return err
}
for _, made := range result.Built {
fmt.Printf(" %-12s %s %s\n", made.Name, made.Kind, made.Reference)
}
if *dryRun {
body, err := json.MarshalIndent(result.Manifest, "", " ")
if err != nil {
return err
}
fmt.Println(string(body))
return nil
}
inv, err := openInventory(ctx)
if err != nil {
return err
}
defer inv.Close()
// Recorded with where it came from, so "is this current?" is answerable without building it
// again (novox/hq ADR 0009).
if err := inv.RegisterModule(ctx, result.Manifest, inventory.Source{
Repository: positionals[0], Ref: *ref, BuiltFrom: result.Commit, Head: result.Commit,
}); err != nil {
return err
}
fmt.Printf("\n%s %s, built from %s\n",
result.Manifest.Module, result.Manifest.Version, short(result.Commit))
fmt.Printf(" run `assign <node> %s` to put it somewhere\n", result.Manifest.Module)
return nil
}