diff --git a/examples/foundation-first-node.lock b/examples/foundation-first-node.lock index 3f516b1..d5bfdd1 100644 --- a/examples/foundation-first-node.lock +++ b/examples/foundation-first-node.lock @@ -55,7 +55,7 @@ "POSTGRES_PASSWORD": "bootstrap", "PGDATA": "/var/lib/postgresql/data/pgdata" }, - "ports": ["127.0.0.1:5432:5432"], + "ports": ["5432:5432"], "volumes": ["mesh-store-data:/var/lib/postgresql/data"] }, // Over TCP, not the socket. While the store initialises it runs a temporary server on the diff --git a/internal/bootstrap/bootstrap.go b/internal/bootstrap/bootstrap.go index e65496b..3b7013f 100644 --- a/internal/bootstrap/bootstrap.go +++ b/internal/bootstrap/bootstrap.go @@ -588,10 +588,12 @@ func Run(ctx context.Context, o Options, d Deps, say func(string)) (Result, erro } // ---- 14. store ------------------------------------------------------------------------ - // A database PROVIDER. The foundation's store is the control plane's own memory and offers - // nothing to anything; the first thing that wants a database is the catalogue, next. - say("store — a database provider, which the foundation's own store is not") - if err := InstallFromCatalogue(ctx, o, permanentControl, "postgres", say); err != nil { + // The foundation's own store, ADOPTED as the `postgres` module (novox/hq issue 051): one + // server holds the control plane's contexts and every module's database, rather than the + // foundation's store beside a second one a module raised. Adopted in place — the module names + // the container the foundation is already running, and the applier leaves it be (phase3.go). + say("store — the foundation's store, adopted as the postgres module: one server, not two") + if err := InstallStore(ctx, o, permanentControl, rewritten.Declaration, say); err != nil { return result, failed(StepStore, err) } diff --git a/internal/bootstrap/builder.go b/internal/bootstrap/builder.go index 865bc83..1b3d94f 100644 --- a/internal/bootstrap/builder.go +++ b/internal/bootstrap/builder.go @@ -53,7 +53,7 @@ func InstallBuilder(ctx context.Context, o Options, d Deps, control controlPlane "This is the manifest that makes the builder an ordinary module. Without it the mesh "+ "has the image and no way to run it, so nothing can be built here", err) } - pinned, places, err := pinImage(manifest, published.Reference) + pinned, places, err := pinImage(manifest, published.Reference, BuilderModule) if err != nil { return out, err } diff --git a/internal/bootstrap/control.go b/internal/bootstrap/control.go index e7a655a..3125dd0 100644 --- a/internal/bootstrap/control.go +++ b/internal/bootstrap/control.go @@ -68,7 +68,7 @@ func InstallControlPlane(ctx context.Context, o Options, d Deps, control control "have pivoted", err) } - pinned, places, err := pinImage(manifest, image) + pinned, places, err := pinImage(manifest, image, ControlPlaneModule) if err != nil { return out, err } @@ -130,15 +130,15 @@ func InstallControlPlane(ctx context.Context, o Options, d Deps, control control // carrying a real digest is one somebody pinned by hand, and quietly registering it would install a // control plane that is not the image this machine just published — which is the one thing this // step exists to guarantee. -func pinImage(manifest []byte, reference string) ([]byte, int, error) { +func pinImage(manifest []byte, reference, module string) ([]byte, int, error) { places := bytes.Count(manifest, []byte(placeholderDigest)) if places == 0 { return nil, 0, fmt.Errorf( "the %s module's manifest carries no placeholder digest (%s), so there is nothing to "+ "pin to the image this machine just published.\n"+ "A manifest already naming a digest was pinned by somebody else, to some other "+ - "build. Registering it would install a control plane that is not the one this "+ - "installer carried and pushed", ControlPlaneModule, placeholderDigest) + "build. Registering it would install a module that is not the one this "+ + "installer carried and pushed", module, placeholderDigest) } // The reference the registry gave back is `/@sha256:…`, and what the // manifest holds is `@sha256:0…0`. Replacing only the digest would leave the @@ -157,7 +157,7 @@ func pinImage(manifest []byte, reference string) ([]byte, int, error) { if start < 0 { return nil, 0, fmt.Errorf( "the %s module's manifest has a placeholder digest that is not inside a JSON "+ - "string, so the installer cannot tell what image it belongs to", ControlPlaneModule) + "string, so the installer cannot tell what image it belongs to", module) } out.Write(rest[:start+1]) out.WriteString(reference) @@ -170,12 +170,12 @@ func pinImage(manifest []byte, reference string) ([]byte, int, error) { var checked map[string]any if err := json.Unmarshal(pinned, &checked); err != nil { return nil, 0, fmt.Errorf( - "pinning the %s module's image broke its manifest: %w", ControlPlaneModule, err) + "pinning the %s module's image broke its manifest: %w", module, err) } if bytes.Contains(pinned, []byte(placeholderDigest)) { return nil, 0, fmt.Errorf( "the %s module's manifest still carries a placeholder digest after pinning", - ControlPlaneModule) + module) } return pinned, places, nil } diff --git a/internal/bootstrap/control_test.go b/internal/bootstrap/control_test.go index e4b0d6f..8f5a27d 100644 --- a/internal/bootstrap/control_test.go +++ b/internal/bootstrap/control_test.go @@ -63,7 +63,7 @@ const pushedReference = "127.0.0.1:5000/mesh-controller@sha256:" + // with no registry in front — which a runtime would go to the internet for, and this mesh's // control plane exists in no public registry by design. func TestTheControlPlaneIsPinnedToWhatThisMeshsRegistryAssigned(t *testing.T) { - pinned, places, err := pinImage([]byte(theControlPlaneModule), pushedReference) + pinned, places, err := pinImage([]byte(theControlPlaneModule), pushedReference, "mesh-controller") if err != nil { t.Fatal(err) } @@ -85,7 +85,7 @@ func TestTheControlPlaneIsPinnedToWhatThisMeshsRegistryAssigned(t *testing.T) { func TestAManifestAlreadyPinnedByHandIsRefused(t *testing.T) { already := strings.Replace(theControlPlaneModule, placeholderDigest, "sha256:"+strings.Repeat("9", 64), 1) - if _, _, err := pinImage([]byte(already), pushedReference); err == nil { + if _, _, err := pinImage([]byte(already), pushedReference, "mesh-controller"); err == nil { t.Fatal("a manifest already pinned to some other image was accepted") } } @@ -100,7 +100,7 @@ func TestEveryPlaceTheManifestNamesTheImageIsPinned(t *testing.T) { {"id": "migrate", "type": "container", "name": "mesh-controller-migrate", "run-once": true, "image": "mesh-controller@`+placeholderDigest+`", "args": ["migrate"]},`, 1) - pinned, places, err := pinImage([]byte(twice), pushedReference) + pinned, places, err := pinImage([]byte(twice), pushedReference, "mesh-controller") if err != nil { t.Fatal(err) } diff --git a/internal/bootstrap/phase3.go b/internal/bootstrap/phase3.go new file mode 100644 index 0000000..6ccc82a --- /dev/null +++ b/internal/bootstrap/phase3.go @@ -0,0 +1,205 @@ +package bootstrap + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "github.com/novox/mesh-host/internal/declaration" +) + +// Phase three — nothing is special after installation (novox/hq issue 051). +// +// Genesis raises a store and a broker before any module system exists, because the control plane +// cannot ask provisioning for the store it keeps its own records in or the broker it is reached over +// (novox/hq ADR 0006). That leaves two servers behind: the foundation's, and a second one the +// `postgres`/`lavinmq` modules used to raise for other modules to use. This turns the foundation's +// own servers into those modules, so a mesh runs ONE postgres and ONE lavinmq — the control plane's +// contexts and every module's database in the same server (WBS 3.1/3.2). +// +// **Adopted in place, not replaced.** The control plane is stateless and is swapped for a fresh +// container (control.go); the store and broker hold the mesh's memory and its bus, so they are kept. +// The module declares a container with the same name, image and spec the foundation raised, and the +// applier — which keys on the container name and compares a spec digest (mesh-host internal/apply) — +// finds it already running and leaves it be. The image is pinned to the one the foundation is +// running, read from the bundle this installer produced, so the two specs are the same digest and +// nothing is recreated. A recreate happens only on a real upgrade, which is where a stated window +// belongs (WBS 3.3). + +// StoreID and BrokerID are what the foundation bundle calls the two servers it raises; the modules +// that adopt them are found by these ids in the bundle this installer produced, the same way the +// control plane's own container is (ControlPlaneID). +const ( + StoreID = "store" + BrokerID = "broker" +) + +// InstallStore makes the foundation's store the `postgres` module, adopted in place. +// +// The order is InstallFromCatalogue's, with two additions the store needs and an ordinary provider +// does not: the server image is pinned to the one the foundation is already running (so the module's +// container is the same spec and is adopted, not a second one raised), and the superuser password — +// the foundation's, made at genesis — is carried in through `secret accept`, because the mesh cannot +// invent a credential that already created the databases (the same reasoning as the control plane's +// store connections, control.go deliverStores). +func InstallStore(ctx context.Context, o Options, control controlPlane, + foundation *declaration.Declaration, say func(string)) error { + + const module = "postgres" + manifest, err := readManifest(o.Catalogue, module) + if err != nil { + return err + } + + store, err := storeIn(foundation) + if err != nil { + return err + } + + // The module adopts the running store rather than raising a second one, so its server container + // has to BE the foundation's — same name, same image. The applier keys on the name and compares + // a spec digest (internal/apply), so a mismatch here would not adopt the mesh's memory but + // replace it. Checked before anything is registered, so a drift between the two pinned upstream + // images (the catalogue's and the foundation bundle's) fails fast and by name, rather than + // surfacing as the mesh's store being torn down and recreated. + // + // Not rewritten to the foundation's: the server image travels through the builder, which reads + // the manifest from the repository and leaves a concrete image alone but would carry a rewrite + // nowhere. The two are kept equal at the source — one pinned postgres, named in both places. + if err := serverMatchesFoundation(manifest, store, module); err != nil { + return err + } + say(" adopting " + store.Name + " — the store the foundation raised, unchanged") + + remote := "/" + module + "-module.json" + if err := control.carrying(ctx, module+"-module.json", manifest, remote); err != nil { + return err + } + if _, err := control.tell(ctx, "module", "add", remote); err != nil { + return err + } + say(" registered " + module) + + if o.CatalogSource.Repository == "" { + return fmt.Errorf("%s has to be built and there is no --catalog-source to build it from: "+ + "the catalogue CHECKOUT says what it is, the catalogue REPOSITORY is where a builder "+ + "clones it", module) + } + say(" building " + module + " (the provisioner; the server is adopted, not built)") + if _, err := control.within(buildWait).tell(ctx, "build", o.CatalogSource.Repository, + "--path", "modules/"+module, "--ref", refOr(o.CatalogSource.Ref), "--wait", "1200s"); err != nil { + return err + } + + if _, err := control.tell(ctx, "module", "issue", module, "--node", o.Node); err != nil { + say(" no account " + module + " — it declares nothing to say on the broker") + } else { + say(" account issued " + module) + } + + if _, err := control.tell(ctx, "assign", o.Node, module); err != nil { + return err + } + + // The superuser is the foundation's, made at genesis — carried in before the push, or the push + // would seal random bytes where a working password has to be and the provisioner would not open + // the store it is meant to manage. + if err := deliverSuperuser(ctx, o, control, module, store, say); err != nil { + return err + } + + if _, err := pushNode(ctx, o, control, say); err != nil { + return err + } + say(" adopted mesh-store — the foundation's store is now the " + module + " module") + return nil +} + +// storeIn finds the store container in the bundle this installer produced. +func storeIn(d *declaration.Declaration) (*declaration.Container, error) { + return foundationContainer(d, StoreID, "store") +} + +// brokerIn finds the broker container in the bundle this installer produced. +func brokerIn(d *declaration.Declaration) (*declaration.Container, error) { + return foundationContainer(d, BrokerID, "broker") +} + +func foundationContainer(d *declaration.Declaration, id, what string) (*declaration.Container, error) { + for _, r := range d.Resources { + if r.Identity() != id { + continue + } + container, ok := r.(*declaration.Container) + if !ok { + return nil, fmt.Errorf( + "this bundle's %q is a %s, not a container, so the %s module has nothing to adopt", + id, r.Kind(), what) + } + return container, nil + } + return nil, fmt.Errorf( + "this bundle names no %q, so there is no %s for a module to adopt. It declares: %s", + id, what, strings.Join(identities(d), ", ")) +} + +// serverMatchesFoundation checks that the module's adopting container is the one the foundation +// raised — same name, same image — so the applier reconciles it in place rather than replacing it. +func serverMatchesFoundation(manifest []byte, store *declaration.Container, module string) error { + var m struct { + Resources []struct { + Type string `json:"type"` + Name string `json:"name"` + Image string `json:"image"` + } `json:"resources"` + } + if err := json.Unmarshal(manifest, &m); err != nil { + return fmt.Errorf("the %s module's manifest is not readable: %w", module, err) + } + for _, r := range m.Resources { + if r.Type != "container" || r.Name != store.Name { + continue + } + if r.Image != store.Image { + return fmt.Errorf( + "the %s module's %q container is pinned to %q, and the foundation is running %q.\n"+ + "The module adopts the foundation's store in place, so the two must name the same "+ + "image — a different one would tear down the mesh's store and raise a new one on "+ + "its data. Pin both to the same postgres image", + module, store.Name, r.Image, store.Image) + } + return nil + } + return fmt.Errorf( + "the %s module declares no container named %q, so it has nothing to adopt the foundation's "+ + "store with. Its server container has to carry the name the foundation raised", + module, store.Name) +} + +// deliverSuperuser carries the store's superuser password into the module. +// +// It is the foundation's, set on the bundle's store container at genesis; the mesh cannot invent a +// credential that already made the databases, so it goes in through `secret accept`, exactly as the +// control plane's store connections do (control.go deliverStores). +func deliverSuperuser(ctx context.Context, o Options, control controlPlane, module string, + store *declaration.Container, say func(string)) error { + + const secret = "superuser" + value := strings.TrimSpace(store.Env["POSTGRES_PASSWORD"]) + if value == "" { + return fmt.Errorf( + "the foundation's store names no POSTGRES_PASSWORD, so the %s module has no superuser "+ + "to open it with — and the mesh cannot invent the one that already made the databases", + module) + } + at := "/accepting-" + secret + if err := control.carrying(ctx, "mesh-accepting-"+secret, []byte(value), at); err != nil { + return err + } + if _, err := control.tell(ctx, "secret", "accept", o.Node, module, secret, "--from", at); err != nil { + return err + } + say(" accepted " + secret + " — the store's superuser, as the foundation made it") + return nil +}