The only complete written-down copy of how a mesh is stood up was an integration test in the lab. That is why every bootstrap gap kept being found late: an install procedure that lives as a test fixture is exercised by whoever writes tests, never by whoever installs. This is that procedure. A separate binary, not a mesh-host subcommand. mesh-host says of itself that it connects to nothing and listens on nothing and that what it applies comes from a file, and that sentence is what makes an always-running root daemon auditable. An installer loads images and interrogates a control plane. Same tier, different program. The control plane's image is carried, not built and not fetched. The forge that holds its source runs on the mesh, so a bootstrap that had to fetch it would need a mesh in order to raise one. Embedding breaks that cycle the way the carried bundle breaks "copy it onto a machine and run it". The image id is read out of the saved tar before the runtime is asked anything, which is what makes the load idempotent: the installer can ask whether the machine already holds exactly this. Five steps, each idempotent and each saying whether it found or changed something, because this is run over and over by somebody getting a machine working. It stops at a running substrate with a control plane that replies — enrolment, the module catalogue and assignment are the next stage and are deliberately absent. Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF
212 lines
8.1 KiB
Go
212 lines
8.1 KiB
Go
package bootstrap
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/novox/mesh-host/internal/declaration"
|
|
"github.com/novox/mesh-host/internal/image"
|
|
"github.com/novox/mesh-host/internal/profile"
|
|
)
|
|
|
|
// DefaultRegistry is where an image reference that names no host comes from.
|
|
const DefaultRegistry = "registry-1.docker.io:443"
|
|
|
|
// Preflight refuses early and plainly, and returns the bundle template it read.
|
|
//
|
|
// Everything here is a thing that will otherwise be discovered half way through: a machine with
|
|
// no runtime found after a bundle has been written, a template that does not parse found after an
|
|
// image has been loaded, a registry that cannot be reached found inside a `docker pull` that
|
|
// reports a network error and not a missing image. The order is cheapest first, so a mistake in
|
|
// what the installer was pointed at costs nothing to find.
|
|
func Preflight(ctx context.Context, o Options, d Deps, say func(string)) ([]byte, error) {
|
|
// 1. Does this installer carry what it claims to?
|
|
//
|
|
// Asked before the machine is touched, for the same reason `mesh-host bundle` exists: a host
|
|
// that carries no substrate must say so when somebody asks, not on a first node
|
|
// (internal/bundle). An installer built without an image would otherwise get a machine as far
|
|
// as a running store and a running broker and stop.
|
|
if image.IsEmpty() {
|
|
return nil, image.ErrEmpty
|
|
}
|
|
saved, err := image.Saved()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
carriedID, err := image.ID(saved)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
say(fmt.Sprintf(" control plane %s carried (%s)",
|
|
firstOr(image.Tags(saved), "untagged"), carriedID))
|
|
|
|
// 2. Is the template there, and is it a substrate?
|
|
template, err := os.ReadFile(o.Template)
|
|
if err != nil {
|
|
return nil, fmt.Errorf(
|
|
"the bundle template could not be read: %w\n"+
|
|
"It is what this machine will be asked to be, so there is nothing to do without "+
|
|
"it. Point --bundle at one; mesh-host's examples/substrate-first-node.lock is "+
|
|
"the shape", err)
|
|
}
|
|
// Parsed here as well as at the rewrite, because a template that is not a declaration should
|
|
// cost a second rather than an image load and a written file.
|
|
parsed, err := declaration.ParseFileTrusted(template)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("the bundle template is not a declaration: %w", err)
|
|
}
|
|
if _, err := controlPlaneIn(parsed); err != nil {
|
|
return nil, err
|
|
}
|
|
say(fmt.Sprintf(" bundle template %s (%d resources)", o.Template, len(parsed.Resources)))
|
|
|
|
// 3. Does a container runtime ANSWER?
|
|
//
|
|
// Not "is it installed" — novox/hq 04-ISSUES/007 is exactly that mistake, and the detector
|
|
// this uses is the one written for it: it asks the daemon for its server version, which fails
|
|
// when the daemon is down however complete the installation is.
|
|
//
|
|
// **Yes, the bundle installs the runtime itself**, and that is not a contradiction. The
|
|
// installer needs one BEFORE the apply, because the control plane's image is loaded into it
|
|
// first; the bundle still declares the package and the service because the host must own them
|
|
// and reassert them at every reconcile. So this is not a duplicate check — it is the one thing
|
|
// the bootstrap cannot bootstrap.
|
|
//
|
|
// Polled rather than asked once. A socket-activated daemon queued behind
|
|
// `network-online.target` is not absent, it is a few seconds away, and `docker load` against
|
|
// one blocks silently rather than failing (04-ISSUES/024). Waiting is the honest reading.
|
|
if err := waitForRuntime(ctx, d.Run, o.Timeout, o.Wait, say); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// 4. Can this machine reach what the bundle's images come from?
|
|
//
|
|
// Asked of the hosts the bundle actually names rather than of the internet in general. The
|
|
// mesh's own image is carried and needs nothing served — it is skipped here for exactly that
|
|
// reason. Everything else is somebody else's image at somebody else's registry, and a machine
|
|
// that cannot reach it fails inside a pull, which reports a network error where a person
|
|
// reads a missing image.
|
|
for _, host := range registriesIn(parsed) {
|
|
dialing, cancel := context.WithTimeout(ctx, o.Timeout)
|
|
err := d.Dial(dialing, host)
|
|
cancel()
|
|
if err != nil {
|
|
return nil, fmt.Errorf(
|
|
"this machine cannot reach %s, and the bundle's images are served from there: "+
|
|
"%w\nThe apply would fail inside a pull, which says the wrong thing. Fix the "+
|
|
"machine's network, or point the bundle at a registry it can reach",
|
|
host, err)
|
|
}
|
|
say(" reachable " + host)
|
|
}
|
|
return template, nil
|
|
}
|
|
|
|
// waitForRuntime asks the runtime, repeatedly, until it answers or the wait runs out.
|
|
func waitForRuntime(ctx context.Context, run Runner, probe, wait time.Duration, say func(string)) error {
|
|
detector := containerRuntimeDetector(run)
|
|
|
|
deadline := time.Now().Add(wait)
|
|
var last string
|
|
for {
|
|
probing, cancel := context.WithTimeout(ctx, probe)
|
|
verdict := detector.Detect(probing)
|
|
cancel()
|
|
if verdict.Present {
|
|
say(" container runtime " + verdict.Detail)
|
|
return nil
|
|
}
|
|
last = verdict.Detail
|
|
|
|
if time.Now().After(deadline) {
|
|
break
|
|
}
|
|
select {
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
case <-time.After(runtimeAskEvery):
|
|
}
|
|
}
|
|
return fmt.Errorf(
|
|
"this machine has no container runtime that answers, after waiting %s: %s\n"+
|
|
"An installed package is not a capability (novox/hq 04-ISSUES/007) — the daemon was "+
|
|
"asked and did not reply. Start it, then run this again; every step is idempotent",
|
|
wait, last)
|
|
}
|
|
|
|
// runtimeAskEvery is how often the runtime is asked again while waiting for it.
|
|
var runtimeAskEvery = 2 * time.Second
|
|
|
|
// containerRuntimeDetector is the host's OWN detector for a working runtime, not a second
|
|
// implementation of the same question. Two answers to "is there a container runtime here" is how
|
|
// the installer and the host come to disagree about a machine.
|
|
func containerRuntimeDetector(run Runner) profile.Detector {
|
|
for _, detector := range profile.Default(profile.Runner(run)) {
|
|
if detector.Name() == profile.CapContainerRuntime {
|
|
return detector
|
|
}
|
|
}
|
|
// Unreachable unless the host's own detector set loses its container runtime, which would be
|
|
// a change nobody would make on purpose — said rather than nil-dereferenced.
|
|
panic("the host detects no container runtime capability, and the installer needs that answer")
|
|
}
|
|
|
|
// registriesIn is every host the bundle's images would be fetched from, without duplicates and in
|
|
// the order they appear.
|
|
func registriesIn(d *declaration.Declaration) []string {
|
|
var hosts []string
|
|
seen := map[string]bool{}
|
|
for _, r := range d.Resources {
|
|
container, ok := r.(*declaration.Container)
|
|
if !ok {
|
|
continue
|
|
}
|
|
host, served := registryOf(container.Image)
|
|
if !served || seen[host] {
|
|
continue
|
|
}
|
|
seen[host] = true
|
|
hosts = append(hosts, host)
|
|
}
|
|
return hosts
|
|
}
|
|
|
|
// registryOf says where an image would be fetched from, and whether anything has to serve it.
|
|
//
|
|
// The second return is false for an image named by the digest of its own configuration: nothing
|
|
// serves those and nothing can (see `internal/declaration`'s checkImage). That is the whole reason
|
|
// the mesh's own control plane can be raised on a machine with no registry anywhere.
|
|
//
|
|
// The rule for the rest is the container runtime's own: the part before the first slash is a
|
|
// registry host if it looks like one — it has a dot, or a port, or it is `localhost` — and
|
|
// otherwise it is part of a repository name on the default registry.
|
|
func registryOf(reference string) (string, bool) {
|
|
if reference == "" || strings.HasPrefix(reference, "sha256:") {
|
|
return "", false
|
|
}
|
|
name := reference
|
|
if at := strings.Index(name, "@"); at >= 0 {
|
|
name = name[:at]
|
|
}
|
|
|
|
first, _, hasPath := strings.Cut(name, "/")
|
|
if !hasPath || !(strings.Contains(first, ".") || strings.Contains(first, ":") || first == "localhost") {
|
|
return DefaultRegistry, true
|
|
}
|
|
if !strings.Contains(first, ":") {
|
|
// A registry with no port is reached over HTTPS, which is where a pull would go.
|
|
return first + ":443", true
|
|
}
|
|
return first, true
|
|
}
|
|
|
|
func firstOr(values []string, fallback string) string {
|
|
if len(values) == 0 || strings.TrimSpace(values[0]) == "" {
|
|
return fallback
|
|
}
|
|
return values[0]
|
|
}
|