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
314 lines
13 KiB
Go
314 lines
13 KiB
Go
package bootstrap
|
|
|
|
import (
|
|
"archive/tar"
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/novox/mesh-host/internal/image"
|
|
)
|
|
|
|
// Docker is never required here. What is being tested is which commands the installer issues and
|
|
// what it concludes from the answers, so the runtime is injected the way `internal/apply` injects
|
|
// its Runner (novox/hq ADR 0017). Behaviour against a real runtime is proved in the lab.
|
|
|
|
// asked records every command, so a test can assert that something was NOT run — which is the
|
|
// whole of what idempotence means here.
|
|
type asked struct {
|
|
commands []string
|
|
answer func(name string, args []string) (string, error)
|
|
}
|
|
|
|
func (a *asked) run(_ context.Context, name string, args ...string) (string, error) {
|
|
a.commands = append(a.commands, strings.TrimSpace(name+" "+strings.Join(args, " ")))
|
|
if a.answer == nil {
|
|
return "", errors.New("this test did not expect any command to be run")
|
|
}
|
|
return a.answer(name, args)
|
|
}
|
|
|
|
func (a *asked) ran(fragment string) bool {
|
|
for _, command := range a.commands {
|
|
if strings.Contains(command, fragment) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// savedImageFixture builds what `docker save` produces, tagged `mesh-controller:test` unless a test
|
|
// asks for something else. Pass no tags for an archive saved without one.
|
|
func savedImageFixture(t *testing.T, digest string, tags ...string) []byte {
|
|
t.Helper()
|
|
if tags == nil {
|
|
tags = []string{"mesh-controller:test"}
|
|
}
|
|
entries, err := json.Marshal([]struct {
|
|
Config string
|
|
RepoTags []string
|
|
}{{Config: digest + ".json", RepoTags: tags}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
var buffer bytes.Buffer
|
|
writer := tar.NewWriter(&buffer)
|
|
if err := writer.WriteHeader(&tar.Header{
|
|
Name: "manifest.json", Mode: 0o644, Size: int64(len(entries)),
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := writer.Write(entries); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := writer.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return buffer.Bytes()
|
|
}
|
|
|
|
// fixtureDigest is what the ARCHIVE calls the image, and runtimeDigest is what a runtime calls it
|
|
// after loading the same bytes. They differ on purpose, because they differ in reality: an image
|
|
// id is the digest of the image's configuration, and a runtime rewrites that configuration as it
|
|
// loads. Measured on a live raise, `mesh-controller:development` was `sha256:b86bb81c…` on the
|
|
// workstation that saved it and `sha256:2dc21904…` on the machine that loaded it.
|
|
const (
|
|
fixtureDigest = "3333333333333333333333333333333333333333333333333333333333333333"
|
|
runtimeDigest = "4444444444444444444444444444444444444444444444444444444444444444"
|
|
)
|
|
|
|
// **The id the bundle is named by comes from the RUNTIME, not from the archive.**
|
|
//
|
|
// This is the test for the fault that took the lab down. The installer used to read the id out of
|
|
// the carried tar and use it for the bundle, which is correct on the machine the image was built
|
|
// on and wrong on every machine it is carried to — and a bundle naming an id the machine does not
|
|
// hold names an image nothing can serve, because an image named by the digest of its own
|
|
// configuration is by definition served by nobody. The apply then stops inside a pull that cannot
|
|
// succeed, three steps from the cause.
|
|
//
|
|
// So the image is identified by its TAG, which survives save and load unchanged, and the runtime
|
|
// is asked what that tag resolves to.
|
|
func TestTheIdComesFromTheRuntimeAndNotFromTheArchive(t *testing.T) {
|
|
runtime := &asked{}
|
|
inspected := 0
|
|
runtime.answer = func(_ string, args []string) (string, error) {
|
|
switch {
|
|
case len(args) > 1 && args[0] == "image" && args[1] == "inspect":
|
|
inspected++
|
|
if inspected == 1 {
|
|
// Nothing held yet.
|
|
return "", errors.New("Error: No such image")
|
|
}
|
|
// Loaded — and stored under a configuration of the runtime's own making.
|
|
return "sha256:" + runtimeDigest + "\n", nil
|
|
case len(args) > 0 && args[0] == "load":
|
|
return "Loaded image: mesh-controller:test\n", nil
|
|
}
|
|
return "", fmt.Errorf("unexpected command: %v", args)
|
|
}
|
|
|
|
var said []string
|
|
loaded, err := loadImage(context.Background(), runtime.run,
|
|
savedImageFixture(t, fixtureDigest), false, func(line string) { said = append(said, line) })
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if loaded.ID != "sha256:"+runtimeDigest {
|
|
t.Errorf("the bundle would name %q; this machine holds sha256:%s", loaded.ID, runtimeDigest)
|
|
}
|
|
if loaded.Archive != "sha256:"+fixtureDigest {
|
|
t.Errorf("the archive's own id is reported as %q", loaded.Archive)
|
|
}
|
|
if loaded.Predicted {
|
|
t.Error("a real run reported its id as a prediction")
|
|
}
|
|
// The runtime was asked BY THE TAG, which is the only name that survives the transfer.
|
|
if !runtime.ran("docker image inspect --format {{.Id}} mesh-controller:test") {
|
|
t.Errorf("the runtime was never asked what the tag resolves to: %v", runtime.commands)
|
|
}
|
|
// And the difference is said out loud, or somebody comparing this against `docker images` on
|
|
// the build machine concludes the wrong image was carried.
|
|
if !strings.Contains(strings.Join(said, "\n"), "the archive says") {
|
|
t.Errorf("nothing was said about the two ids differing: %v", said)
|
|
}
|
|
}
|
|
|
|
// A machine that already holds the image is not loaded again, and says so.
|
|
//
|
|
// This is the idempotence the installer's usefulness rests on: it is run over and over while
|
|
// somebody gets a machine working, and a step that did its work again every time would be
|
|
// indistinguishable from one that had never run. **Decided from what the runtime holds under the
|
|
// tag, not from what the archive predicts** — a predicted id cannot answer this question at all on
|
|
// a machine whose runtime rewrites configurations.
|
|
func TestAnImageThisMachineAlreadyHoldsIsNotLoadedAgain(t *testing.T) {
|
|
runtime := &asked{answer: func(_ string, args []string) (string, error) {
|
|
if len(args) > 1 && args[0] == "image" && args[1] == "inspect" {
|
|
return "sha256:" + runtimeDigest + "\n", nil
|
|
}
|
|
return "", fmt.Errorf("unexpected command: %v", args)
|
|
}}
|
|
|
|
var said []string
|
|
loaded, err := loadImage(context.Background(), runtime.run,
|
|
savedImageFixture(t, fixtureDigest), false, func(line string) { said = append(said, line) })
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if !loaded.Held {
|
|
t.Error("the machine already held the image and the load did not say so")
|
|
}
|
|
if loaded.ID != "sha256:"+runtimeDigest {
|
|
t.Errorf("the id reported for an already-held image is %q, and the machine holds sha256:%s",
|
|
loaded.ID, runtimeDigest)
|
|
}
|
|
if runtime.ran("docker load") {
|
|
t.Errorf("the image was loaded again although the machine held it: %v", runtime.commands)
|
|
}
|
|
if !strings.Contains(strings.Join(said, "\n"), "already held") {
|
|
t.Errorf("nothing was said about finding the image already there: %v", said)
|
|
}
|
|
}
|
|
|
|
// A load that reported success and left nothing there is a failure, not a convergence
|
|
// (novox/hq ADR 0018). Without the read-back it would surface later as the host refusing a bundle
|
|
// naming an image nothing serves — a true message about the wrong thing.
|
|
func TestALoadThatLeftNothingBehindIsAFailure(t *testing.T) {
|
|
runtime := &asked{answer: func(_ string, args []string) (string, error) {
|
|
if len(args) > 1 && args[0] == "image" && args[1] == "inspect" {
|
|
return "", errors.New("Error: No such image")
|
|
}
|
|
return "Loaded image: mesh-controller:test\n", nil
|
|
}}
|
|
|
|
_, err := loadImage(context.Background(), runtime.run,
|
|
savedImageFixture(t, fixtureDigest), false, func(string) {})
|
|
if err == nil {
|
|
t.Fatal("a load that left nothing on the machine was reported as success")
|
|
}
|
|
// Named by the tag, because that is what was asked about and what is missing.
|
|
if !strings.Contains(err.Error(), "mesh-controller:test") {
|
|
t.Errorf("the failure does not say what this machine holds nothing of: %v", err)
|
|
}
|
|
}
|
|
|
|
// **A dry run cannot know the id, and says so rather than pretending.** It loads nothing, so no
|
|
// runtime has decided anything, and the id in the archive is a fact about a file rather than a
|
|
// prediction about this machine. `--dry-run` still produces and checks the bundle's shape; what it
|
|
// cannot promise is the one value that only a load can settle.
|
|
func TestADryRunLoadsNothingAndSaysTheIdIsUnconfirmed(t *testing.T) {
|
|
runtime := &asked{answer: func(_ string, args []string) (string, error) {
|
|
if len(args) > 1 && args[0] == "image" && args[1] == "inspect" {
|
|
return "", errors.New("Error: No such image")
|
|
}
|
|
return "", fmt.Errorf("a dry run ran %v", args)
|
|
}}
|
|
|
|
var said []string
|
|
loaded, err := loadImage(context.Background(), runtime.run,
|
|
savedImageFixture(t, fixtureDigest), true, func(line string) { said = append(said, line) })
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !loaded.Predicted {
|
|
t.Error("a dry run reported an id no runtime had confirmed as though it had been")
|
|
}
|
|
if loaded.ID != "sha256:"+fixtureDigest {
|
|
t.Errorf("a dry run reported %q, and the archive says sha256:%s", loaded.ID, fixtureDigest)
|
|
}
|
|
if runtime.ran("docker load") {
|
|
t.Errorf("a dry run loaded an image: %v", runtime.commands)
|
|
}
|
|
if !strings.Contains(strings.Join(said, "\n"), "NOT THE FINAL ID") {
|
|
t.Errorf("a dry run did not say its id is unconfirmed: %v", said)
|
|
}
|
|
}
|
|
|
|
// A dry run on a machine that already holds the image DOES know the id, because the runtime was
|
|
// asked and answered. Reading is not changing, so a dry run is entitled to that.
|
|
func TestADryRunOnAMachineThatHoldsItKnowsTheRealId(t *testing.T) {
|
|
runtime := &asked{answer: func(_ string, args []string) (string, error) {
|
|
if len(args) > 1 && args[0] == "image" && args[1] == "inspect" {
|
|
return "sha256:" + runtimeDigest + "\n", nil
|
|
}
|
|
return "", fmt.Errorf("a dry run ran %v", args)
|
|
}}
|
|
|
|
loaded, err := loadImage(context.Background(), runtime.run,
|
|
savedImageFixture(t, fixtureDigest), true, func(string) {})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if loaded.Predicted {
|
|
t.Error("an id this machine's runtime supplied was reported as a prediction")
|
|
}
|
|
if loaded.ID != "sha256:"+runtimeDigest {
|
|
t.Errorf("the id is %q, and the runtime said sha256:%s", loaded.ID, runtimeDigest)
|
|
}
|
|
}
|
|
|
|
// **An untagged archive is a build-time fault, refused 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.
|
|
func TestAnUntaggedArchiveIsRefused(t *testing.T) {
|
|
runtime := &asked{answer: func(_ string, args []string) (string, error) {
|
|
return "", fmt.Errorf("nothing should have been run: %v", args)
|
|
}}
|
|
|
|
_, err := loadImage(context.Background(), runtime.run,
|
|
savedImageFixture(t, fixtureDigest, []string{}...), false, func(string) {})
|
|
if err == nil {
|
|
t.Fatal("an archive with no tag was accepted, and there is no way to ask about it")
|
|
}
|
|
if !strings.Contains(err.Error(), "make bootstrap") {
|
|
t.Errorf("the refusal does not say how to build one that is tagged: %v", err)
|
|
}
|
|
if len(runtime.commands) != 0 {
|
|
t.Errorf("the machine was touched first: %v", runtime.commands)
|
|
}
|
|
}
|
|
|
|
// An installer built from a plain checkout carries no image, and says which build step is missing.
|
|
// Discovered here, before a machine is touched, rather than after a bundle has been written.
|
|
func TestAnInstallerCarryingNoImageSaysSoRatherThanRaisingHalfAMesh(t *testing.T) {
|
|
if !image.IsEmpty() {
|
|
t.Skip("this checkout has a saved image embedded")
|
|
}
|
|
_, err := Load(context.Background(), (&asked{}).run, false, func(string) {})
|
|
if !errors.Is(err, image.ErrEmpty) {
|
|
t.Fatalf("an installer with no control-plane image gave %v, want ErrEmpty", err)
|
|
}
|
|
if !strings.Contains(err.Error(), "make bootstrap") {
|
|
t.Errorf("the refusal does not say how to build one that carries an image: %v", err)
|
|
}
|
|
}
|
|
|
|
// An answer that is not an image id is refused rather than written into a bundle.
|
|
//
|
|
// **This replaces a test that refused an answer differing from the archive's id.** That test
|
|
// encoded the mistake: a differing id is now the expected case, not a fault, because a runtime
|
|
// rewrites an image's configuration as it loads. What is still worth refusing is an answer that is
|
|
// not an id at all — that value becomes the name a bundle applies on a machine with no mesh to
|
|
// check anything against, so it is checked where the refusal can say whose mistake it is.
|
|
func TestARuntimeAnsweringSomethingThatIsNotAnImageIdIsRefused(t *testing.T) {
|
|
for _, nonsense := range []string{
|
|
"mesh-controller:test",
|
|
"sha256:" + strings.Repeat("9", 63),
|
|
"<no value>",
|
|
} {
|
|
runtime := &asked{answer: func(_ string, _ []string) (string, error) {
|
|
return nonsense + "\n", nil
|
|
}}
|
|
if _, err := loadImage(context.Background(), runtime.run,
|
|
savedImageFixture(t, fixtureDigest), false, func(string) {}); err == nil {
|
|
t.Errorf("the runtime answered %q and it was accepted as an image id", nonsense)
|
|
}
|
|
}
|
|
}
|