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
250 lines
11 KiB
Go
250 lines
11 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 foundation 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
|
|
}
|
|
// And was it told what to build?
|
|
//
|
|
// Asked here rather than at the build, for the same reason as the line above: a run that
|
|
// cannot finish should say so before it has changed anything. The installer carries a builder
|
|
// and nothing else (novox/hq ADR 0073), so an installer with no source is an installer that
|
|
// would raise a store and a broker and then have nothing to raise a control plane from.
|
|
if err := o.Source.Check(); err != nil {
|
|
return nil, fmt.Errorf("%w. Nothing has been changed on this machine", err)
|
|
}
|
|
saved, err := image.Saved()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
//
|
|
// The id said here is the ARCHIVE's, and it is reported as such: it is a fact about the file
|
|
// and not about this machine. What this runtime will call the image once it holds it is the
|
|
// runtime's decision, made at the load, and asked for there (see Load).
|
|
carriedID, err := image.ArchiveID(saved)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
tag := firstOr(image.Tags(saved), "")
|
|
if tag == "" {
|
|
// Refused here as well as at the load, because preflight's whole job is to find at the
|
|
// start what would otherwise be found half way through — and this would be found after an
|
|
// image had been written to disk and handed to a container runtime.
|
|
return nil, fmt.Errorf(
|
|
"the carried control-plane image has no tag, and the installer identifies it by one: "+
|
|
"an image id is the digest of a configuration that a runtime rewrites as it loads, "+
|
|
"so the archive's id (%s) is not necessarily the id this machine would hold.\n"+
|
|
"Rebuild the installer with a tagged image: `make bootstrap IMAGE=<name>:<tag>`",
|
|
carriedID)
|
|
}
|
|
say(fmt.Sprintf(" builder %s carried (the archive calls it %s)", tag, carriedID))
|
|
|
|
// 2. Is the template there, and is it a foundation?
|
|
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/foundation-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.
|
|
//
|
|
// "The mesh's own image is skipped" used to mean "skipped if the template happened to name it
|
|
// in a way that needs no registry", and that is not the same sentence. A template names the
|
|
// control plane by SOMETHING — the reference is a slot, and step 3 replaces whatever is in it
|
|
// with the id of the image this installer carries. Whatever the slot held is therefore never
|
|
// pulled, never fetched, and never reached; requiring it to be reachable refuses a correct
|
|
// install because of a string that is about to be thrown away. Found on the first real run: the
|
|
// lab's template still carried `192.0.2.250:5000/mesh-controller@…`, the address of a registry that
|
|
// no longer exists, and preflight timed out dialling it.
|
|
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.
|
|
//
|
|
// The control plane's own resource is excluded by identity rather than by the shape of what it
|
|
// names. Its image reference is a slot the installer overwrites with the id of the image it
|
|
// carries, so no registry ever serves it — and a template that filled that slot with a registry
|
|
// this machine cannot reach is not a machine with a network problem.
|
|
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 || container.Identity() == ControlPlaneID {
|
|
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]
|
|
}
|