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
161 lines
6.5 KiB
Go
161 lines
6.5 KiB
Go
package image
|
|
|
|
import (
|
|
"archive/tar"
|
|
"bytes"
|
|
"encoding/json"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// Each test names the decision it defends (novox/hq ADR 0017).
|
|
|
|
// savedImage builds what `docker save` produces, as far as this package reads it.
|
|
func savedImage(t *testing.T, files map[string]string) []byte {
|
|
t.Helper()
|
|
var buffer bytes.Buffer
|
|
writer := tar.NewWriter(&buffer)
|
|
for name, content := range files {
|
|
header := &tar.Header{Name: name, Mode: 0o644, Size: int64(len(content))}
|
|
if err := writer.WriteHeader(header); err != nil {
|
|
t.Fatalf("building the fixture: %v", err)
|
|
}
|
|
if _, err := writer.Write([]byte(content)); err != nil {
|
|
t.Fatalf("building the fixture: %v", err)
|
|
}
|
|
}
|
|
if err := writer.Close(); err != nil {
|
|
t.Fatalf("building the fixture: %v", err)
|
|
}
|
|
return buffer.Bytes()
|
|
}
|
|
|
|
func manifest(t *testing.T, config string, tags ...string) string {
|
|
t.Helper()
|
|
raw, err := json.Marshal([]manifestEntry{{Config: config, RepoTags: tags}})
|
|
if err != nil {
|
|
t.Fatalf("building the fixture: %v", err)
|
|
}
|
|
return string(raw)
|
|
}
|
|
|
|
// The archive's own id is read from the FILE, in both layouts `docker save` has used.
|
|
//
|
|
// **This test used to say that reading it here was what made the load idempotent — that knowing
|
|
// the id in advance let the installer ask "do you already hold exactly this". That was wrong.** An
|
|
// id is the digest of the image's configuration document, and a runtime rewrites that document as
|
|
// it loads, so this is a fact about the archive and not a prediction about any machine. What the
|
|
// installer asks a runtime by is the TAG, and what a bundle names is the answer the runtime gives
|
|
// back (`internal/bootstrap`.Load).
|
|
//
|
|
// It is still read and still checked, because it is what says which build somebody embedded — and
|
|
// because printing it beside the runtime's answer is how a person sees that the two differ.
|
|
func TestTheArchivesOwnIdIsReadFromTheSavedFile(t *testing.T) {
|
|
digest := strings.Repeat("a", 64)
|
|
// Both layouts `docker save` has used. The older one names the config `<digest>.json`; the OCI
|
|
// one names it `blobs/sha256/<digest>`. They carry the same sixty-four characters, and a
|
|
// reader that understood only one would work until somebody upgraded their runtime.
|
|
for _, config := range []string{digest + ".json", "blobs/sha256/" + digest} {
|
|
saved := savedImage(t, map[string]string{"manifest.json": manifest(t, config)})
|
|
id, err := ArchiveID(saved)
|
|
if err != nil {
|
|
t.Fatalf("config %q: %v", config, err)
|
|
}
|
|
if id != "sha256:"+digest {
|
|
t.Errorf("config %q gave id %q, want sha256:%s", config, id, digest)
|
|
}
|
|
}
|
|
}
|
|
|
|
// The tag is read, and it is not decoration: it is the name the installer asks a runtime by,
|
|
// because it is the one thing that survives `docker save` and `docker load` unchanged. The id
|
|
// does not.
|
|
func TestTheSavedTagsAreRead(t *testing.T) {
|
|
saved := savedImage(t, map[string]string{
|
|
"manifest.json": manifest(t, strings.Repeat("b", 64)+".json", "mesh-control:v1"),
|
|
})
|
|
got := Tags(saved)
|
|
if len(got) != 1 || got[0] != "mesh-control:v1" {
|
|
t.Errorf("tags = %v, want [mesh-control:v1]", got)
|
|
}
|
|
}
|
|
|
|
// A tar that is not a saved image is refused with what is wrong, not with a nil id.
|
|
//
|
|
// Nothing here reaches a bundle any more, but this is still the earliest moment somebody can be
|
|
// told they embedded the wrong file — and the alternative is finding out at the load, from a
|
|
// container runtime, in a sentence about a tar rather than about what was built.
|
|
func TestSomethingThatIsNotASavedImageIsRefused(t *testing.T) {
|
|
notAnImage := savedImage(t, map[string]string{"hello": "world"})
|
|
if _, err := ArchiveID(notAnImage); err == nil {
|
|
t.Error("a tar with no manifest.json was accepted as a saved image")
|
|
} else if !strings.Contains(err.Error(), "docker save") {
|
|
t.Errorf("the refusal does not say what to embed instead: %v", err)
|
|
}
|
|
|
|
if _, err := ArchiveID([]byte("this is not a tar at all")); err == nil {
|
|
t.Error("bytes that are not a tar were accepted")
|
|
}
|
|
}
|
|
|
|
// Exactly one image. A bootstrap that chose between several would be the thing that guesses which
|
|
// one is the control plane, and it would guess right until the day somebody saved two.
|
|
func TestATarHoldingSeveralImagesIsRefused(t *testing.T) {
|
|
entries, err := json.Marshal([]manifestEntry{
|
|
{Config: strings.Repeat("a", 64) + ".json"},
|
|
{Config: strings.Repeat("b", 64) + ".json"},
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
saved := savedImage(t, map[string]string{"manifest.json": string(entries)})
|
|
if _, err := ArchiveID(saved); err == nil {
|
|
t.Error("a tar holding two images was accepted")
|
|
}
|
|
}
|
|
|
|
// An id is a digest or it is nothing. A truncated one names several images, and which one ran
|
|
// would be whichever the runtime matched first — the same reasoning `internal/declaration` gives
|
|
// for refusing a short image reference.
|
|
func TestAConfigThatIsNotADigestIsRefused(t *testing.T) {
|
|
for _, config := range []string{"config.json", "abc.json", "blobs/sha256/" + strings.Repeat("a", 63)} {
|
|
saved := savedImage(t, map[string]string{"manifest.json": manifest(t, config)})
|
|
if _, err := ArchiveID(saved); err == nil {
|
|
t.Errorf("config %q was accepted and is not a digest", config)
|
|
}
|
|
}
|
|
}
|
|
|
|
// The committed placeholder must read as "carries nothing", so an installer built from a plain
|
|
// checkout says so in preflight rather than getting a machine as far as a running store and
|
|
// stopping. This is the same guarantee `internal/bundle` makes about a lock file of only comments.
|
|
func TestAnInstallerBuiltFromAPlainCheckoutCarriesNothing(t *testing.T) {
|
|
if !IsEmpty() {
|
|
// Not a failure of this checkout: `make bootstrap` embeds a real image and puts the
|
|
// placeholder back, so a real image here means a build was interrupted.
|
|
t.Skip("this checkout has a saved image embedded, so there is no placeholder to check")
|
|
}
|
|
if _, err := Saved(); err == nil {
|
|
t.Fatal("an installer carrying only the placeholder reported it carries an image")
|
|
}
|
|
}
|
|
|
|
// And "empty" is decided by whether the bytes could be loaded, not by matching the placeholder's
|
|
// text. A truncated or corrupted embed is equally unloadable and equally worth refusing early.
|
|
func TestEmptyMeansUnloadableRatherThanEqualToThePlaceholder(t *testing.T) {
|
|
restore := saved
|
|
defer func() { saved = restore }()
|
|
|
|
saved = []byte("half a tar, cut off")
|
|
if !IsEmpty() {
|
|
t.Error("bytes that are not a tar were reported as a carried image")
|
|
}
|
|
|
|
saved = savedImage(t, map[string]string{
|
|
"manifest.json": manifest(t, strings.Repeat("c", 64)+".json"),
|
|
})
|
|
if IsEmpty() {
|
|
t.Error("a real saved image was reported as no image at all")
|
|
}
|
|
}
|