Files
mesh-host/internal/bootstrap/load.go
T
jschoubben cb5e137297 bootstrap: read the image id back from the runtime, never predict it
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
2026-09-11 00:12:36 +02:00

200 lines
9.2 KiB
Go

package bootstrap
import (
"context"
"fmt"
"os"
"strings"
"github.com/novox/mesh-host/internal/image"
)
// Loaded is the control plane's image on this machine.
type Loaded struct {
// ID is what THIS RUNTIME holds the image as, read back from it after the load. It is what the
// bundle names, and outside a dry run it is never a prediction — see Load.
ID string
// Archive is what the carried tar calls the same image. Kept because the two differ in
// practice, and a report showing only one of them cannot say that they did. Never what the
// bundle names.
Archive string
// Tag is the name the runtime is asked by. Load-bearing rather than decoration: it is the one
// name that survives `docker save` and `docker load` unchanged.
Tag string
// Tags is everything the archive was called when it was saved.
Tags []string
// Held is true when the machine already held it and nothing moved.
Held bool
// Predicted is true only on a dry run, where nothing was loaded and ID is therefore the
// archive's id — which is not necessarily the one this machine would end up with.
Predicted bool
}
// Load puts the carried control-plane image into this machine's container runtime, and reports
// what the runtime decided to call it.
//
// **The digest of a configuration is not portable across runtimes, and that is why the id is read
// back rather than predicted.** An image id is the sha256 of the image's configuration document,
// and a runtime REWRITES that document as it loads: a newer Docker saves in one format, an older
// one stores it in another, and the same layers come out under a different name. Measured on a
// live raise, an image saved as `sha256:b86bb81c…` on a workstation was loaded as
// `sha256:2dc21904…` on the machine it was carried to.
//
// This code used to read the id out of the tar before the runtime was asked anything and use it
// for both idempotence and the bundle. That is right on the machine the image was built on and
// wrong on every machine it is carried to — which is every machine this program exists for. The
// bundle would have named an image the machine does not hold; nothing serves an image named by
// the digest of its own configuration, which is the whole point of naming one that way; and the
// apply would have stopped inside a pull that cannot succeed. The lab hit exactly this.
//
// **So the image is identified by its TAG.** A tag is ordinary metadata the tar carries through
// unchanged, and asking the runtime what a tag resolves to is asking the only party entitled to
// answer. The tag never reaches the bundle — a pinned bundle may not rely on one
// (novox/hq ADR 0006) — it is how the id is obtained, not what is written down.
//
// **Idempotence is decided from what the runtime holds.** The tag is asked before the load and
// again after: the same id either side means nothing moved, which is a fact about this machine
// rather than a guess about the file. A tag that already resolves means the image is already
// held, and nothing is loaded at all.
//
// It reads back (novox/hq ADR 0018). A load that reported success and left nothing there is a
// failure, not a convergence.
func Load(ctx context.Context, run Runner, dryRun bool, say func(string)) (Loaded, error) {
saved, err := image.Saved()
if err != nil {
return Loaded{}, err
}
return loadImage(ctx, run, saved, dryRun, say)
}
// loadImage is Load with the carried bytes handed in, so the whole path can be tested against a
// saved image a test builds rather than against whatever a particular build embedded.
func loadImage(ctx context.Context, run Runner, saved []byte, dryRun bool, say func(string)) (Loaded, error) {
archiveID, err := image.ArchiveID(saved)
if err != nil {
return Loaded{}, err
}
loaded := Loaded{Archive: archiveID, Tags: image.Tags(saved)}
// **An untagged archive is a build-time fault, refused here rather than worked around.**
// Without a tag there is no portable name to ask the runtime about, and the only thing left is
// scraping the sentence `docker load` prints for a person — which differs between runtime
// versions and is exactly the kind of guess this whole step exists to stop making. The release
// target tags the image; an installer built without one was built wrong.
loaded.Tag = firstOr(loaded.Tags, "")
if loaded.Tag == "" {
return loaded, fmt.Errorf(
"the carried control-plane image has no tag, so there is no portable name to ask this "+
"machine's runtime what id it gave it.\n"+
"An image id is the digest of the image's configuration and a runtime rewrites "+
"that as it loads, so the id in the archive (%s) is not necessarily the id this "+
"machine will hold — and a bundle naming the wrong one names an image nothing "+
"serves. Rebuild the installer with a tagged image: `make bootstrap "+
"IMAGE=<name>:<tag>`", archiveID)
}
// Already held? Asked of the runtime, by the tag, before anything is written anywhere.
before, err := idOfImage(ctx, run, loaded.Tag)
if err != nil {
return loaded, err
}
if before != "" {
loaded.ID, loaded.Held = before, true
say(" already held " + before + " as " + loaded.Tag + " — nothing loaded")
sayIfDifferent(say, archiveID, before)
return loaded, nil
}
if dryRun {
// Nothing is loaded, so the runtime has not been asked to decide anything — and what it
// would decide cannot be worked out from here. Said, rather than quietly guessed at.
loaded.ID, loaded.Predicted = archiveID, true
say(fmt.Sprintf(" would load %s (%d bytes) as %s", archiveID, len(saved), loaded.Tag))
say(" NOT THE FINAL ID a runtime rewrites an image's configuration as it loads, and an")
say(" id is that configuration's digest. The bundle names what the")
say(" runtime answers for " + loaded.Tag + " afterwards, which a dry run")
say(" cannot ask for without loading.")
return loaded, nil
}
// Through a file rather than through stdin: the runner this repository shares runs a command
// and captures its output, and giving it a second mouth for one caller would change every
// applier's contract for the sake of one step (internal/apply's Runner).
tarball, err := os.CreateTemp("", "mesh-control-*.tar")
if err != nil {
return loaded, fmt.Errorf("nowhere to put the carried image while loading it: %w", err)
}
defer os.Remove(tarball.Name())
if _, err := tarball.Write(saved); err != nil {
tarball.Close()
return loaded, fmt.Errorf("cannot write the carried image to %s: %w", tarball.Name(), err)
}
if err := tarball.Close(); err != nil {
return loaded, fmt.Errorf("cannot finish writing %s: %w", tarball.Name(), err)
}
if _, err := run(ctx, "docker", "load", "--input", tarball.Name()); err != nil {
return loaded, fmt.Errorf(
"the container runtime would not load the carried control-plane image: %w", err)
}
// Read back, and THIS is the answer the bundle is rewritten to.
after, err := idOfImage(ctx, run, loaded.Tag)
if err != nil {
return loaded, err
}
if after == "" {
return loaded, fmt.Errorf(
"the load reported success and this machine holds nothing called %s.\n"+
"The bundle names the control plane by the id this runtime assigned, so there is "+
"nothing to name. Check what `docker load` actually took", loaded.Tag)
}
loaded.ID = after
say(" loaded " + after + " as " + loaded.Tag)
sayIfDifferent(say, archiveID, after)
return loaded, nil
}
// sayIfDifferent reports the archive's own id when the runtime chose another.
//
// Said every time it happens, because it is surprising, it is ordinary, and somebody comparing
// this report against `docker images` on the machine the image was built on would otherwise
// conclude that the wrong image had been carried.
func sayIfDifferent(say func(string), archiveID, held string) {
if archiveID == held {
return
}
say(" the archive says " + archiveID)
say(" this runtime stored the same image under a different configuration, " +
"which is ordinary — the bundle names what the machine holds")
}
// idOfImage asks the runtime what it holds under a name, or empty if it holds nothing.
//
// It asks for the id back rather than reading the exit code, because the id is what is wanted and
// an exit code is not it. Absent is an answer and not a failure: every other reason the runtime
// might refuse looks the same from here, which is why preflight proves the runtime answers before
// this runs rather than this trying to tell the two apart from an exit status.
//
// What it will not do is accept an answer that is not an image id. That answer becomes the name
// the bundle applies on a machine with no mesh to check anything against, so it is checked here
// where the refusal can say whose mistake it is.
func idOfImage(ctx context.Context, run Runner, name string) (string, error) {
out, err := run(ctx, "docker", "image", "inspect", "--format", "{{.Id}}", name)
if err != nil {
return "", nil
}
got := strings.TrimSpace(firstLineOf(out))
if got == "" {
return "", nil
}
if !isImageID(got) {
return "", fmt.Errorf(
"asked what this machine holds as %q, the runtime answered %q, which is not an image "+
"id. The bundle would name the control plane by that answer, and it is refused "+
"rather than written down", name, got)
}
return got, nil
}