diff --git a/internal/apply/apply.go b/internal/apply/apply.go index 6b2c65d..baba8d4 100644 --- a/internal/apply/apply.go +++ b/internal/apply/apply.go @@ -1154,6 +1154,15 @@ func ensureImage(ctx context.Context, cri, image string, run Runner) error { if _, err := run(ctx, cri, "image", "inspect", image); err == nil { return nil } + // An image named by the digest of its own configuration is one this machine was supposed to + // already hold — built here, or handed over. There is no registry that answers for it, so + // pulling would fail somewhere that names a network problem instead of a missing image. + if strings.HasPrefix(image, "sha256:") { + return fmt.Errorf( + "image %s is not on this machine, and an image named by its own digest cannot be "+ + "fetched: nothing serves it. Build it here, or load it, before applying this", + image) + } if _, err := run(ctx, cri, "pull", image); err != nil { return fmt.Errorf("pulling image %s: %w", image, err) } diff --git a/internal/declaration/declaration.go b/internal/declaration/declaration.go index 3ffa8cf..84a6824 100644 --- a/internal/declaration/declaration.go +++ b/internal/declaration/declaration.go @@ -874,20 +874,41 @@ func checkMode(where, mode string) []string { return nil } -// checkImage insists on a digest. +// checkImage insists on content, not on a name. // // A tag moves and a digest does not. The bundle's whole claim is that what it names is exact // (novox/hq ADR 0006), and a bundle pinning `postgres:17` pins nothing — it names whatever // that tag points at on the day the host happens to run. +// +// **Two forms say something exact, and only one of them needs a registry.** `name@sha256:…` is a +// manifest digest, which a registry assigns on push. A bare `sha256:…` is an image the machine +// already holds, addressed by the digest of its own configuration — equally immutable, equally +// unforgeable, and requiring nothing to have served it. +// +// That second form is what a first machine needs. The mesh's own control plane exists in no public +// registry and never will: it is built from source, and until this mesh has a registry of its own +// there is nowhere to push it to and therefore no manifest digest to name it by. Insisting on one +// would mean a registry has to exist before the thing that lets a mesh have a registry can start — +// which is not a pin, it is a dependency the rule accidentally created. A machine that built an +// image, or was handed one, can name it by what it is. func checkImage(where, image string) []string { if image == "" { return []string{where + ": a container needs an image"} } + // An image this machine holds, named by the digest of its own configuration. + if strings.HasPrefix(image, "sha256:") { + if len(image) != len("sha256:")+64 { + return []string{fmt.Sprintf( + "%s: image id %q is not a sha256 digest", where, image)} + } + return nil + } name, digest, found := strings.Cut(image, "@") if !found || name == "" { return []string{fmt.Sprintf( - "%s: image %q is not pinned. Write it as name@sha256:... — a tag moves, and a "+ - "bundle that pinned a tag would not be pinned", where, image)} + "%s: image %q is not pinned. Write it as name@sha256:… — or as sha256:… for an image "+ + "this machine already holds. A tag moves, and a bundle that pinned a tag would "+ + "not be pinned", where, image)} } if !strings.HasPrefix(digest, "sha256:") || len(digest) != len("sha256:")+64 { return []string{fmt.Sprintf( diff --git a/internal/declaration/declaration_test.go b/internal/declaration/declaration_test.go index 46734b3..11244bc 100644 --- a/internal/declaration/declaration_test.go +++ b/internal/declaration/declaration_test.go @@ -219,6 +219,32 @@ func TestAnImageMustBePinnedByDigest(t *testing.T) { } } +// An image the machine already holds, addressed by the digest of its own configuration. +// +// **The form a first machine needs.** A manifest digest is assigned by a registry on push, so +// insisting on one means a registry has to exist before the thing that lets a mesh have a registry +// can start. The mesh's own control plane is built from source and lives in no public registry; a +// machine that built it, or was handed it, names it by what it is — a content address, not a name, +// and immutable in exactly the way the rule asks for. +func TestAnImageTheMachineHoldsIsNamedByItsOwnDigest(t *testing.T) { + held := "sha256:" + strings.Repeat("b", 64) + if _, err := ParseTrusted([]byte(`{"declaration":1,"resources":[ + {"id":"control","type":"container","name":"mesh-control","image":"` + held + `"} + ]}`)); err != nil { + t.Errorf("an image named by its own digest was refused: %v", err) + } + + // Still a digest, though. A truncated one names several images, and which one ran would be + // whichever the runtime happened to match first. + for _, bad := range []string{"sha256:abc", "sha256:", "sha256:" + strings.Repeat("b", 63)} { + if _, err := ParseTrusted([]byte(`{"declaration":1,"resources":[ + {"id":"control","type":"container","name":"mesh-control","image":"` + bad + `"} + ]}`)); err == nil { + t.Errorf("image id %q was accepted and is not a digest", bad) + } + } +} + func TestAFieldTheNewTypesDoNotUseIsRefused(t *testing.T) { // The field-set check must cover the types added last, not only the three it was written // for. A package that carries a `content` is a control plane believing it asked for