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
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-controller-*.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
|
|
}
|