Files
mesh-host/internal/bootstrap/publish.go
T
jschoubben 121367319d Rename mesh-control -> mesh-controller, substrate -> foundation
One name per thing, per the HQ glossary: the module/container/image/binary/repo
becomes mesh-controller, the seat the-controller, and the store+broker pair the
foundation (embedded base bundles, default template and example lock renamed with
their go:embed directives). No behaviour change — a pure vocabulary rename.

Claude-Session: https://claude.ai/code/session_01D6qtiYU3P9jk3pnAXyAFyx
2026-09-16 18:40:40 +02:00

186 lines
8.2 KiB
Go

package bootstrap
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
)
// ControlPlaneRepository is what the control plane's image is called in the mesh's own registry.
const ControlPlaneRepository = "mesh-controller"
// genesisTag is the tag the first push uses.
//
// A tag is not a pin and is never what anything is deployed from — the digest the registry assigns
// is (novox/hq ADR 0006). This exists so a person reading `/v2/mesh-controller/tags/list` can see
// which image this mesh started from, and so the push has something to name. Everything downstream
// uses the digest that comes back.
const genesisTag = "genesis"
// Published is what step 8 did.
type Published struct {
// Reference is `<registry>/mesh-controller@sha256:…` — the first manifest digest this image has
// ever had, and the thing that makes the control plane an ordinary module.
Reference string
// Tagged is where it was pushed, tag and all.
Tagged string
// Already is true when the registry was already serving it and nothing was pushed.
Already bool
}
// PublishControlPlane puts the carried image into the mesh's own registry and reads back its digest.
//
// **This is the pivot's hinge.** Every image must be pinned by digest, and a digest a pin can mean
// is one a REGISTRY assigned when something was pushed to it. The control plane's image is built
// from source and pushed nowhere, so it has none — which is why the foundation names it by the
// digest of its own configuration, and why that is legal exactly where nothing could have served
// one. The moment this push completes, that stops being true: the image has a manifest digest, so
// the control plane can be named the way every other module is named, so the mesh can build and
// roll out its own upgrades. If this step is skipped the machine still works and the mesh cannot
// upgrade itself, which is the check novox/hq ADR 0067 states: after installing, the running
// control plane must be pinned by a digest the mesh's own registry assigned, not by an image id.
//
// **It mirrors mesh-controller's `internal/builder`.PublishImage rather than importing it.** Tag,
// push, read back `RepoDigests`, refuse anything without `@sha256:` — the same four steps, because
// there is exactly one right way to learn what a registry will serve something as, and it is to
// ask the registry. It is not imported because that code is tier 2: the host and its installer
// depend on nothing that must be installed first (novox/hq ADR 0041), and taking a dependency on
// the control plane's repository to raise the control plane would be the cycle this whole ADR is
// about, one layer up. The duplication is four commands, and it is deliberate.
//
// One difference, and it is a correction rather than a divergence: the digest is chosen from
// `RepoDigests` by repository instead of taken as element zero. An image that has been pushed to
// more than one registry has more than one entry, and element zero is then whichever the runtime
// happened to list first — which would pin this mesh to somebody else's registry, silently.
func PublishControlPlane(ctx context.Context, o Options, d Deps, imageID string,
say func(string)) (Published, error) {
return publishAs(ctx, o, d, imageID, ControlPlaneRepository, say)
}
// publishAs puts one locally held image into this mesh's registry, under a repository name.
//
// **The same act for every image genesis has to place**, which is now two: the control plane it
// built, and the builder it carried. They arrive differently and are published identically — the
// registry does not care where an image came from, and a second copy of this that drifted would be
// the kind of difference nobody finds until one of them stops working.
func publishAs(ctx context.Context, o Options, d Deps, imageID, repository string,
say func(string)) (Published, error) {
remote := o.Registry + "/" + repository
out := Published{Tagged: remote + ":" + genesisTag}
// Asked first. A digest already served is a fact about the registry, and re-pushing an image
// the registry already holds is asking it to store what it already has under the name it
// already has.
if held, err := digestOf(ctx, o, d, remote); err != nil {
return out, err
} else if held != "" {
out.Reference, out.Already = held, true
say(" already published " + held)
return out, nil
}
if _, err := d.Run(ctx, "docker", "tag", imageID, out.Tagged); err != nil {
return out, fmt.Errorf("cannot tag %s as %s: %w", imageID, out.Tagged, err)
}
if _, err := d.Run(ctx, "docker", "push", out.Tagged); err != nil {
return out, fmt.Errorf(
"the container runtime would not push %s: %w\n"+
"The registry is plain HTTP and wants no credentials, deliberately — it is reached "+
"over the mesh's own network, which is already the encrypted and authenticated "+
"thing. A runtime refusing it for being insecure is refusing a registry on %s, "+
"which it does not do for a loopback address",
out.Tagged, err, o.Registry)
}
// Read back, from the registry's own answer rather than computed here. What matters is what
// the registry will serve for that reference, and only it can say (novox/hq ADR 0018).
pinned, err := digestOf(ctx, o, d, remote)
if err != nil {
return out, err
}
if pinned == "" {
return out, fmt.Errorf(
"%s was pushed and the registry does not serve it.\n"+
"The next step names this module by the digest this was supposed to produce, so "+
"there is nothing to name. Check `docker push` and "+
"http://%s/v2/%s/tags/list", out.Tagged, o.Registry, repository)
}
out.Reference = pinned
say(" published " + pinned)
return out, nil
}
// digestOf is what the registry serves this repository as, or empty if it serves it at all.
//
// Both halves are asked, because either alone lies. The registry's tag list says something was
// pushed and not what its digest is; the runtime's `RepoDigests` says what a digest was and not
// whether the registry still has it — a registry whose volume was recreated would leave the
// runtime remembering a digest nothing serves, and the module registered against it would pin the
// mesh to an image that cannot be pulled.
func digestOf(ctx context.Context, o Options, d Deps, remote string) (string, error) {
asking, cancel := context.WithTimeout(ctx, o.Timeout)
status, body, err := d.Fetch(asking,
"http://"+o.Registry+"/v2/"+ControlPlaneRepository+"/tags/list")
cancel()
if err != nil {
return "", fmt.Errorf("cannot ask the registry at %s what it holds: %w", o.Registry, err)
}
if status == http.StatusNotFound {
// Nothing has ever been pushed under this name. An answer, not a failure.
return "", nil
}
if status != http.StatusOK {
return "", fmt.Errorf("the registry answered %d when asked what it holds for %s",
status, ControlPlaneRepository)
}
var listed struct {
Tags []string `json:"tags"`
}
if err := json.Unmarshal([]byte(body), &listed); err != nil {
return "", fmt.Errorf("the registry's answer about %s is not readable: %w",
ControlPlaneRepository, err)
}
if !contains(listed.Tags, genesisTag) {
return "", nil
}
// The registry has it. What digest, according to the runtime that pushed it.
reading, cancel := context.WithTimeout(ctx, o.Timeout)
out, err := d.Run(reading, "docker", "inspect", "--format", "{{json .RepoDigests}}",
remote+":"+genesisTag)
cancel()
if err != nil {
// The registry holds the tag and this machine's runtime does not hold the image. That
// happens on a re-run after the image was pruned, and it is not something to work around
// by trusting the tag: a tag can be made to point elsewhere.
return "", nil
}
var digests []string
if err := json.Unmarshal([]byte(strings.TrimSpace(out)), &digests); err != nil {
return "", fmt.Errorf("the runtime's answer about %s is not readable: %w", remote, err)
}
for _, digest := range digests {
if !strings.HasPrefix(digest, remote+"@sha256:") {
// Somebody else's registry serving the same image. Skipped rather than used: pinning
// this mesh's control plane to a registry it does not run is exactly the dependency
// the pivot exists to remove.
continue
}
return digest, nil
}
return "", nil
}
func contains(values []string, want string) bool {
for _, v := range values {
if v == want {
return true
}
}
return false
}