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.
200 lines
9.2 KiB
Go
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 builder 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
|
|
}
|