Files
mesh-host/internal/bootstrap/registry.go
T
jschoubben f534cf8b42 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
2026-09-10 23:59:57 +02:00

205 lines
7.6 KiB
Go

package bootstrap
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
)
// RegistryModule is the module that provides the mesh's artifact store.
const RegistryModule = "registry"
// registryResource is the id of the resource in that module's manifest that runs the registry.
// What the container is CALLED is read from the manifest rather than assumed, because the name is
// the catalogue's to choose and the installer only has to know which resource to wait for.
const registryResource = "store"
// Registry is what step 7 did.
type Registry struct {
Installed
// Container is the container the manifest declares, confirmed running.
Container string
// Address is host:port the registry answers on, as this machine reaches it.
Address string
// Answered is the status the registry's own `/v2/` gave back.
Answered int
}
// InstallRegistry gives this mesh somewhere to put images.
//
// **Its image is upstream and it is never built.** novox/hq 04-ISSUES/029 is the whole reason this
// step exists in this position: a module that provides the artifact store cannot be delivered
// through the artifact store, so the registry is the one module whose image is pulled from the
// internet like the store and the broker before it. A manifest carrying the catalogue's
// placeholder digest here would mean somebody had made it buildable, which is the cycle again —
// so it is refused rather than pulled.
//
// **No credentials, and that is deliberate.** The registry is reached over the mesh's own private
// network, which is already the encrypted and authenticated thing; a second layer inside it would
// be certificates to issue and rotate for no property the first does not have (mesh-control's
// `internal/builder`, which pushes to it the same way). So there is nothing here to configure and
// nothing to seal — which is also why step 8 can push without the mesh having issued anything.
//
// **It is verified by asking it, not by looking at it.** A container that is up is not a registry
// that serves: `/v2/` is the registry API's own "yes, I am one and I am ready", and it is the
// question step 8 depends on the answer to.
func InstallRegistry(ctx context.Context, o Options, d Deps, control controlPlane,
say func(string)) (Registry, error) {
out := Registry{Address: o.Registry}
manifest, err := readManifest(o.Catalogue, RegistryModule)
if err != nil {
return out, err
}
container, image, err := containerIn(manifest, registryResource)
if err != nil {
return out, err
}
if strings.Contains(image, placeholderDigest) {
return out, fmt.Errorf(
"the %s module's image is %q, which is the catalogue's placeholder for something the "+
"mesh builds and pushes.\n"+
"This module is the one that cannot work that way: it PROVIDES the place built "+
"images go, so it can never be delivered through it (novox/hq 04-ISSUES/029). Its "+
"image is upstream and pinned in the manifest", RegistryModule, image)
}
out.Container = container
say(" registry image " + image + " — upstream, never built")
installed, err := installModule(ctx, o, control, RegistryModule, manifest, say)
out.Installed = installed
if err != nil {
return out, err
}
// Read back, in two stages, because they fail differently. A container that never appears is
// a declaration that did not reach this node or an image that would not pull; a container
// that is up and does not answer is a registry that started and failed.
if err := waitForContainer(ctx, control.run, o.Timeout, o.Wait, container, say); err != nil {
return out, err
}
status, err := waitForTheRegistry(ctx, d, o, say)
if err != nil {
return out, err
}
out.Answered = status
return out, nil
}
// waitForTheRegistry asks `/v2/` until it answers.
func waitForTheRegistry(ctx context.Context, d Deps, o Options, say func(string)) (int, error) {
where := "http://" + o.Registry + "/v2/"
deadline := time.Now().Add(o.Wait)
var last string
for {
asking, cancel := context.WithTimeout(ctx, o.Timeout)
status, _, err := d.Fetch(asking, where)
cancel()
switch {
case err != nil:
last = err.Error()
case status == http.StatusOK:
say(fmt.Sprintf(" replies %s answered %d", where, status))
return status, nil
default:
// A registry that answers 401 is one that wants credentials, which this one is
// configured not to. Reported as what it said rather than retried into a timeout.
last = fmt.Sprintf("it answered %d", status)
}
if time.Now().After(deadline) {
break
}
select {
case <-ctx.Done():
return 0, ctx.Err()
case <-time.After(answerEvery):
}
}
return 0, fmt.Errorf(
"the registry's container is running and %s does not answer, after waiting %s: %s\n"+
"Running is not serving. `/v2/` is the registry API saying it is ready, and the next "+
"step pushes the control plane's image to it — so this is refused here rather than "+
"discovered inside a `docker push`. `docker logs mesh-registry` says what it did",
where, o.Wait, last)
}
// waitForContainer waits for a container the mesh was asked to create to be running.
//
// Unlike the substrate's own verify, this one waits: the mesh applies through a node's host, over
// the broker, asynchronously. A push that the control plane accepted has not yet happened on the
// machine, and refusing on the first look would refuse every correct install.
func waitForContainer(ctx context.Context, run Runner, probe, wait time.Duration, name string,
say func(string)) error {
deadline := time.Now().Add(wait)
var last string
for {
state, err := containerRunning(ctx, run, probe, name)
switch {
case err != nil:
last = "it is not there at all"
case state.running:
say(" running " + name)
return nil
default:
last = "it is " + state.status
}
if time.Now().After(deadline) {
break
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(answerEvery):
}
}
return fmt.Errorf(
"the mesh accepted the push and %q is not running after %s: %s\n"+
"The control plane sends a declaration over the broker and this node's host applies "+
"it, so the two ends fail differently: `mesh-host` on this machine says what it made "+
"of the declaration, and `status` on the control plane says whether it was collected "+
"at all", name, wait, last)
}
// containerIn finds one container resource in a module manifest and gives back its name and image.
//
// It reads the manifest as data rather than through the catalogue's own parser, because that
// parser lives in the control plane and the host depends on nothing installed first
// (novox/hq ADR 0041) — importing it would put tier 2 inside tier 0. What is read here is two
// fields of a shape the catalogue owns; the manifest is handed to the control plane unchanged, and
// it is the control plane's `module add` that judges whether it is a manifest at all.
func containerIn(manifest []byte, id string) (name, image string, err error) {
var m struct {
Module string `json:"module"`
Resources []struct {
ID string `json:"id"`
Type string `json:"type"`
Name string `json:"name"`
Image string `json:"image"`
} `json:"resources"`
}
if err := json.Unmarshal(manifest, &m); err != nil {
return "", "", fmt.Errorf("this manifest is not readable as JSON: %w", err)
}
var containers []string
for _, r := range m.Resources {
if r.Type != "container" {
continue
}
containers = append(containers, r.ID)
if r.ID == id {
return r.Name, r.Image, nil
}
}
return "", "", fmt.Errorf(
"the %s module declares no container %q, so the installer does not know what to wait for. "+
"It declares: %s", m.Module, id, strings.Join(containers, ", "))
}