Files
mesh-host/internal/image/image.go
T
jschoubben e1a2fe7323 The installer carries a builder and builds the control plane it raises
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.
2026-09-13 04:08:58 +02:00

197 lines
8.7 KiB
Go

// Package image is the control plane's image, carried inside the installer.
//
// **Carried rather than built, and that is not an optimisation.** The mesh's forge runs on the
// mesh. A bootstrap that needed the control plane's source in order to build it would need a
// forge to fetch that source from, and the forge is one of the things the mesh raises — so the
// mesh would be required in order to raise the mesh. Embedding the image breaks that cycle the
// same way `internal/bundle` breaks it for the declaration: the installer arrives holding
// everything a bare machine has to be given, and a bare machine is given one file
// (novox/hq ADR 0005).
//
// It is also the rule the rest of tier 0 already follows. Nobody compiles `mesh-host` on the
// machine it will run on; the binary is put there. The image it raises arrives the same way.
//
// What is embedded is the output of `docker save` — a tar holding the image's config, its layers
// and a `manifest.json` naming them. The control plane's image is `FROM scratch` with one static
// binary in it (novox/hq ADR 0006), so this is tens of megabytes rather than hundreds.
package image
import (
"archive/tar"
"bytes"
_ "embed"
"encoding/json"
"errors"
"fmt"
"io"
"path"
"strings"
)
// The saved image, replaced at release time by `make bootstrap`.
//
// **It is the builder, not the control plane** (novox/hq ADR 0073). The installer used to carry
// the thing it was going to run; it now carries the thing that makes it. One artifact either way —
// but a mesh raised by the second one holds a control plane it built from source, out of the same
// repository and path every later rebuild of it will use, and can therefore rebuild it. A mesh
// raised by the first held an artifact it could not reproduce and knew nothing about.
//
// What is committed here is a placeholder, for the same reason `internal/bundle` commits locks
// that are only comments: `go:embed` refuses to compile against a file that is not there, so a
// checkout with nothing embedded would not build at all — and someone reading this repository or
// running `go test ./...` would meet a compile error instead of a program. The placeholder keeps
// the tree buildable and makes the absence a thing the installer *says*, at the earliest moment
// it can, rather than a thing a compiler says to the wrong person.
//
// It is small and it is committed. A saved image is not, and `make bootstrap` puts one here for
// the length of one build and then puts the placeholder back — exactly what `make host` does with
// the bundle it embeds.
//
//go:embed builder.tar
var saved []byte
// ErrEmpty means this installer carries no builder image.
//
// A separate error rather than a message, so the caller can refuse in preflight — before a
// machine has been touched — instead of discovering it at the load, after the runtime has been
// probed and a bundle has been written.
var ErrEmpty = errors.New(
"this mesh-bootstrap carries no builder image, so it cannot raise a mesh. A release build " +
"embeds one: `make bootstrap IMAGE=<image>` in the mesh-host repository, where <image> " +
"is a mesh-builder image already built from the mesh-control source")
// IsEmpty reports whether anything was built in.
//
// It asks whether the bytes are a tar rather than comparing them against the placeholder's text,
// because the question that matters is "can this be loaded", and a truncated or corrupted embed
// answers no to that while matching no placeholder. A tar's first header carries the string
// `ustar` at offset 257 and nothing else does by accident.
func IsEmpty() bool {
const magicAt, magic = 257, "ustar"
return len(saved) < magicAt+len(magic) || string(saved[magicAt:magicAt+len(magic)]) != magic
}
// Saved returns the embedded tar, for loading into a container runtime.
func Saved() ([]byte, error) {
if IsEmpty() {
return nil, ErrEmpty
}
return saved, nil
}
// manifestEntry is the part of a saved image's `manifest.json` this needs.
//
// One field. The layers are the runtime's business and the repository tags are decoration — what
// is wanted is the config, because the digest of the config IS the image id.
type manifestEntry struct {
Config string `json:"Config"`
RepoTags []string `json:"RepoTags"`
}
// ArchiveID is what this archive calls the image it holds. **It is not necessarily the id the
// runtime will assign when the archive is loaded, and it must never be what a bundle names.**
//
// This was called ID, and its comment said it was the id the runtime would give the image once
// loaded. That was wrong, and wrong in the worst available way: right on the machine the image
// was saved on, and wrong on the machine it was carried to. 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 and 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
//
// A bundle naming this id would then name 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 stop at a pull that cannot succeed. The id a bundle names is read
// back from the runtime after the load (`internal/bootstrap`.Load), which is the only place it is
// a fact rather than a prediction.
//
// What it is still good for is a statement about the FILE — which build somebody embedded — and
// as a hint printed beside the runtime's answer when the two differ, so a person can see that
// they did.
//
// `manifest.json` names the configuration document by its digest — as `<64hex>.json` in the older
// layout and `blobs/sha256/<64hex>` in the OCI one — so both forms reduce to the same sixty-four
// characters.
func ArchiveID(saved []byte) (string, error) {
manifest, err := fileFromTar(saved, "manifest.json")
if err != nil {
return "", err
}
var entries []manifestEntry
if err := json.Unmarshal(manifest, &entries); err != nil {
return "", fmt.Errorf(
"the carried image has a manifest.json this cannot read, so the image it holds "+
"cannot be named: %w", err)
}
// One image, deliberately. A tar holding several would leave the installer choosing which
// one is the control plane, and a bootstrap must not be the thing that guesses.
if len(entries) != 1 {
return "", fmt.Errorf(
"the carried image holds %d images, and the installer raises exactly one control "+
"plane. Save a single image: `docker save --output … <image>`", len(entries))
}
digest := strings.TrimSuffix(path.Base(entries[0].Config), ".json")
if !isSHA256(digest) {
return "", fmt.Errorf(
"the carried image names its configuration %q, which is not a sha256 digest. An "+
"image id is the digest of that configuration, so there is nothing to call this "+
"image", entries[0].Config)
}
return "sha256:" + digest, nil
}
// Tags is what the saved image was called when it was saved.
//
// **Not decoration any more, and this comment used to say it was.** A tag is exactly what a pinned
// bundle may not rely on (novox/hq ADR 0006) and none of this ever reaches a bundle — but the tag
// is how the installer ASKS the runtime what id it assigned, because the id itself is not
// knowable beforehand (see ArchiveID). It is the one name that survives `docker save` and
// `docker load` unchanged, which is precisely what the id does not.
//
// It also still says which build somebody embedded, which is otherwise sixty-four hex characters.
func Tags(saved []byte) []string {
manifest, err := fileFromTar(saved, "manifest.json")
if err != nil {
return nil
}
var entries []manifestEntry
if err := json.Unmarshal(manifest, &entries); err != nil || len(entries) == 0 {
return nil
}
return entries[0].RepoTags
}
func fileFromTar(archive []byte, want string) ([]byte, error) {
reader := tar.NewReader(bytes.NewReader(archive))
for {
header, err := reader.Next()
if errors.Is(err, io.EOF) {
return nil, fmt.Errorf(
"the carried image has no %s, so it is not something `docker save` produced. "+
"Embed the output of `docker save`, not a layer or a build context", want)
}
if err != nil {
return nil, fmt.Errorf("the carried image cannot be read as a tar: %w", err)
}
if path.Clean(header.Name) == want {
return io.ReadAll(reader)
}
}
}
func isSHA256(s string) bool {
if len(s) != 64 {
return false
}
for _, c := range s {
if (c < '0' || c > '9') && (c < 'a' || c > 'f') {
return false
}
}
return true
}