It carried the thing it was going to run; it now carries the thing that makes it. One artifact either way — but a mesh raised this way holds a control plane it built from a repository and a commit it can name, and can therefore build again. A mesh handed a finished image could not, and had no way to find that out until somebody needed it to. A build step sits between load and bundle, because the bundle must name an image and that image no longer arrives finished. Everything after it is unchanged: a locally built image is named by the digest of its own configuration, which is exactly what the carried one was named by. Refused in preflight when nothing says what to build, so a run that cannot finish says so before it has changed anything.
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 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
|
|
}
|
|
// 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(" control plane %s carried (the archive calls it %s)", tag, 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.
|
|
//
|
|
// "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-control@…`, 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]
|
|
}
|