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=:`", 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 }