bootstrap: the rest of the pivot — enrol, registry, publish, reinstall, retire

Steps 6 to 10, which turn a substrate into a mesh that can maintain itself
(novox/hq ADR 0067).

 6 enrol      a node record, a token, `mesh-host enrol`, and the host agent
              running. Proved by the mesh having HEARD from the node, not by a
              process existing: a host that cannot reach the broker looks exactly
              like a successful install until the first push applies nothing.
 7 registry   the module that gives this mesh an image store, registered from a
              --catalog checkout, assigned and pushed. Its image is upstream and
              never built (04-ISSUES/029) — a placeholder digest there is refused.
              Verified by asking `/v2/`, because a container that is up is not a
              registry that serves.
 8 publish    the carried image pushed into that registry, which assigns it the
              first manifest digest it has ever had. This is the hinge: without
              it the mesh works and can never upgrade itself.
 9 control    the control plane registered as an ordinary module pinned to that
              digest, with the substrate's own store connections delivered
              through `secret accept` — read out of the bundle that made them,
              because the mesh cannot invent a credential that predates it.
10 retire     the temporary control plane dropped from the bundle and removed by
              the host's ordinary removal pass.

Every step asks before it acts and reports "already done". No step leaves the
machine without a control plane: steps 9 and 10 overlap deliberately, and two
stateless control planes are untidy rather than broken.

mesh-control's `internal/builder`.PublishImage is mirrored rather than imported —
tier 0 depends on nothing that must be installed first — with one correction: the
digest is chosen from RepoDigests by repository instead of taken as element zero,
so an image pushed to two registries cannot silently pin this mesh to the wrong
one.

Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF
This commit is contained in:
2026-09-10 23:59:57 +02:00
parent af953dbb0d
commit f534cf8b42
15 changed files with 2910 additions and 34 deletions
+173
View File
@@ -0,0 +1,173 @@
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-control"
// 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-control/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-control@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 substrate 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-control'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) {
remote := o.Registry + "/" + ControlPlaneRepository
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 the carried image as %s: %w", 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 the control plane's 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, ControlPlaneRepository)
}
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
}