An image id does not survive `docker save` -> transfer -> `docker load`. The id is
the digest of the image's *configuration*, and a runtime rewrites that
configuration as it loads: a newer Docker saves in one format, an older one stores
it in another. Same layers, same program, different name. Measured on a live raise:
saved on the workstation sha256:b86bb81ca2f9691f24f4725f50962d1e49c98c5ffe211113241243d42d18ceea
loaded on the machine sha256:2dc219046c73702fc640317f0342a28ec962ef1e9ef547b2f02861c508ca78fb
`internal/image`.ID read the id out of the carried tar and its comment said that
was the id the runtime would assign. That is true on the machine the image was
built on and false on every machine it is carried to — which is every machine this
program exists for. The installer then either stopped at step 2 refusing the
runtime's answer, or would have written a bundle naming an image the machine does
not hold; and nothing serves an image named by the digest of its own configuration,
which is the whole point of naming one that way, so the apply would have died
inside a pull that cannot succeed. The lab hit this.
So the image is identified by its TAG, which is ordinary metadata the tar carries
through unchanged. The runtime is asked what that tag resolves to before the load
(already held, nothing to do) and again after (this is what the bundle names). The
tag never reaches the bundle — a pinned bundle may not rely on one, ADR 0006 — it
is how the id is obtained, not what is written down.
- image.ID becomes image.ArchiveID, and says plainly that it is a fact about the
file and not a prediction about any machine. It is kept for reports, and printed
beside the runtime's answer whenever the two differ.
- Idempotence is decided from what the runtime holds under the tag, not from a
predicted id, which cannot answer the question at all here.
- An untagged archive is refused, in preflight and again at the load: there would
be no portable name to ask about, and the only thing left is scraping a sentence
`docker load` writes for a person. `make bootstrap` refuses an id or an untagged
image, so it is caught in front of whoever can fix it.
- A dry run cannot know the id and says so rather than pretending. Run refuses to
write a bundle carrying an unconfirmed id at all.
Tests: the injected Runner now answers with an id DIFFERING from the tar's, and the
runtime's answer is what must be used. The test that refused a differing id encoded
the mistake and is replaced by one refusing an answer that is not an id at all.
Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF
227 lines
9.0 KiB
Go
227 lines
9.0 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
|
|
}
|
|
//
|
|
// 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.
|
|
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]
|
|
}
|