diff --git a/cmd/mesh-bootstrap/main.go b/cmd/mesh-bootstrap/main.go index f8ae993..c001e7a 100644 --- a/cmd/mesh-bootstrap/main.go +++ b/cmd/mesh-bootstrap/main.go @@ -98,8 +98,8 @@ const usage = `mesh-bootstrap — make a bare machine into a mesh everything this mesh will ever run --source-path the module's directory inside that repository, if not its root --catalog a checkout of the mesh's catalogue, holding the registry's, the - control plane's and the builder's manifests. Without it this stops - after step 6 + control plane's, the builder's and — when a packages port is given + — the forge's manifests. Without it this stops after step 6 --node the name this machine is known by (default: its hostname) --registry where this mesh keeps its own images (default ` + defaultRegistry + `) every node pulls the control plane from this, so on a mesh of more @@ -279,7 +279,7 @@ func newFlagSet(opts *bootstrap.Options, jsonOut *bool) *flag.FlagSet { set.StringVar(&opts.Out, "out", opts.Out, "where the produced bundle is written") set.StringVar(&opts.State, "state", opts.State, "where this node records what it has applied") set.StringVar(&opts.Catalogue, "catalog", opts.Catalogue, - "a checkout of the mesh's catalogue, for the registry's and the builder's manifests and what phase two installs; without it this stops after the foundation") + "a checkout of the mesh's catalogue, for the registry's, the builder's and the forge's manifests and what phase two installs; without it this stops after the foundation") set.StringVar(&opts.Source.Repository, "source", opts.Source.Repository, "the repository the control plane is built from, on a mesh that already exists") set.StringVar(&opts.Source.Ref, "source-ref", opts.Source.Ref, diff --git a/internal/bootstrap/builder.go b/internal/bootstrap/builder.go index e8ca324..b86c1b1 100644 --- a/internal/bootstrap/builder.go +++ b/internal/bootstrap/builder.go @@ -1,10 +1,8 @@ package bootstrap import ( - "bytes" "context" "fmt" - "strconv" "strings" ) @@ -55,9 +53,6 @@ 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) } - if manifest, err = followPackagesPort(manifest, o.Ports.orDefaults().Packages); err != nil { - return out, err - } pinned, places, err := pinPlaceholder(manifest, published.Reference, BuilderModule) if err != nil { return out, err @@ -89,25 +84,3 @@ func InstallBuilder(ctx context.Context, o Options, d Deps, control controlPlane out.Installed.Pushed, err = pushNode(ctx, o, control, say) return out, err } - -// packagesPortInBinding is the package registry's port as the builder's manifest names it, in the -// binding file it carries — JSON inside a JSON string, so its quotes are escaped. -const packagesPortInBinding = `\"port\": 3000` - -// followPackagesPort points the builder's package binding at the port the node gave the package -// registry (novox/hq ADR 0100). Genesis raises the registry by hand before gitea is a module, so no -// binding the controller resolves can say where it is; the builder carries the address in its own -// manifest, and a port given at genesis must reach it there or the base build dials a port nothing -// answers on. At the default it is left byte for byte as the catalogue has it. -func followPackagesPort(manifest []byte, port int) ([]byte, error) { - if port == defaultGiteaPort { - return manifest, nil - } - if n := bytes.Count(manifest, []byte(packagesPortInBinding)); n != 1 { - return nil, fmt.Errorf("the builder's manifest names the package registry's port %d time(s) where "+ - "this installer looks for it once (%s), so the port given with --packages-port cannot reach "+ - "it; nothing was changed", n, packagesPortInBinding) - } - return bytes.Replace(manifest, []byte(packagesPortInBinding), - []byte(`\"port\": `+strconv.Itoa(port)), 1), nil -} diff --git a/internal/bootstrap/builder_test.go b/internal/bootstrap/builder_test.go index c2f9848..fa61eba 100644 --- a/internal/bootstrap/builder_test.go +++ b/internal/bootstrap/builder_test.go @@ -1,16 +1,21 @@ package bootstrap import ( - "encoding/json" + "context" + "os" + "path/filepath" "strings" "testing" + "time" ) -// Defends novox/hq ADR 0100: a port given for the package registry at genesis reaches the one -// thing that dials it by a fixed number, the builder's package binding. +// Defends novox/hq 04-ISSUES/085: the port given for the package registry at genesis is a setting +// of a module and not text in a manifest, so registering that manifest again does not put the +// catalogue's number back. -// The builder's package binding exactly as the catalogue's manifest carries it. -const builderManifest = `{ +// theBuildersBinding is the resource the builder carries in place of a binding nothing can resolve +// yet, exactly as the catalogue's manifest has it — settable, with the port as its only default. +const theBuildersBinding = `{ "module": "builder", "resources": [ { @@ -18,52 +23,113 @@ const builderManifest = `{ "type": "file", "path": "/var/lib/mesh/builder/package-registry.json", "mode": "0600", + "merge": "json", + "protected": ["provision", "from", "as"], "content": "{\"provision\": \"package-registry\", \"from\": \"gitea\", \"at\": \"127.0.0.1\", \"as\": \"mesh-builder\", \"serves\": {\"scheme\": \"http\", \"port\": 3000, \"npm-path\": \"/api/packages/novox/npm/\"}}\n" } ] }` -func bindingPort(t *testing.T, manifest []byte) float64 { +func recording(t *testing.T) (*controlRecorder, controlPlane) { t.Helper() - var m struct { - Resources []struct { - Content string `json:"content"` - } `json:"resources"` - } - if err := json.Unmarshal(manifest, &m); err != nil { - t.Fatal(err) - } - var binding struct { - Serves struct { - Port float64 `json:"port"` - } `json:"serves"` - } - if err := json.Unmarshal([]byte(m.Resources[0].Content), &binding); err != nil { - t.Fatal(err) - } - return binding.Serves.Port + t.Setenv("TMPDIR", t.TempDir()) + c := &controlRecorder{settings: map[string]string{}} + return c, controlPlane{container: "temp-mesh-controller", run: c.run, timeout: time.Second} } -func TestTheBuilderFollowsThePackageRegistrysGivenPort(t *testing.T) { - got, err := followPackagesPort([]byte(builderManifest), 3100) - if err != nil { +func TestTheBuildersPackageRegistryPortIsASettingAndNotTheManifest(t *testing.T) { + c, control := recording(t) + o := Options{Node: "anchor", Ports: FoundationPorts{Packages: 3100}, Wait: time.Second} + if _, err := installModule(context.Background(), o, control, BuilderModule, + []byte(theBuildersBinding), quietly); err != nil { t.Fatal(err) } - if p := bindingPort(t, got); p != 3100 { - t.Errorf("the builder's binding dials %v, not the port given", p) + if got := c.settings["builder-settings.json"]; got != `{"serves":{"port":3100}}` { + t.Errorf("the builder was told %q about the package registry's port", got) + } + // The manifest reaches the mesh as the catalogue wrote it. A port rewritten into it here is a + // port the next registration from the catalogue silently takes back. + if got := c.settings["builder-module.json"]; got != theBuildersBinding { + t.Errorf("the installer changed the builder's manifest:\n%s", got) + } + set, push := c.index("settings set builder"), c.index("push anchor") + if set < 0 || push < 0 || set > push { + t.Fatalf("the port was not set before the push that raises the builder: %v", c.told) } } -func TestTheBuilderOnTheDefaultPortIsUnchanged(t *testing.T) { - got, err := followPackagesPort([]byte(builderManifest), 3000) - if err != nil || string(got) != builderManifest { - t.Errorf("the default port changed the manifest: %v", err) +func TestTheBuilderOnTheDefaultPackagesPortIsToldNothing(t *testing.T) { + c, control := recording(t) + o := Options{Node: "anchor", Wait: time.Second} + if _, err := installModule(context.Background(), o, control, BuilderModule, + []byte(theBuildersBinding), quietly); err != nil { + t.Fatal(err) + } + if c.index("settings") >= 0 { + t.Errorf("a genesis on the catalogue's packages port set a setting: %v", c.told) } } -func TestABuilderManifestThatNoLongerNamesThePortIsRefused(t *testing.T) { - moved := strings.Replace(builderManifest, `\"port\": 3000`, `\"port\": 3001`, 1) - if _, err := followPackagesPort([]byte(moved), 3100); err == nil || !strings.Contains(err.Error(), "--packages-port") { - t.Errorf("a manifest the port cannot reach was accepted: %v", err) +// aCatalogueWith writes a checkout holding one module's manifest, which is all the installer reads +// a catalogue for. +func aCatalogueWith(t *testing.T, module, manifest string) string { + t.Helper() + dir := t.TempDir() + at := filepath.Join(dir, catalogueDir, module) + if err := os.MkdirAll(at, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(at, "module.json"), []byte(manifest), 0o600); err != nil { + t.Fatal(err) + } + return dir +} + +const theForgesManifest = `{"module": "gitea", "version": "1"}` + +func TestThePackagesPortIsTheForgeModulesSettingOnThisNode(t *testing.T) { + c, control := recording(t) + o := Options{Node: "anchor", Catalogue: aCatalogueWith(t, ForgeModule, theForgesManifest), + Ports: FoundationPorts{Packages: 3100}, Wait: time.Second} + if err := recordTheForgesPort(context.Background(), o, control, quietly); err != nil { + t.Fatal(err) + } + if got := c.settings["gitea-settings.json"]; got != `{"ports":{"3000":3100}}` { + t.Errorf("the forge module was told %q about its port", got) + } + // Registered so there is something to hold the setting against, and never assigned: the forge + // module cannot run until the base it stands on has been built. + add, set := c.index("module add"), c.index("settings set gitea") + if add < 0 || set < 0 || add > set { + t.Fatalf("the forge's setting was recorded against a module the mesh does not know: %v", c.told) + } + if c.index("assign") >= 0 { + t.Errorf("genesis assigned the forge module: %v", c.told) + } + if !strings.Contains(c.told[set], "--node anchor") { + t.Errorf("the forge's port was set for the whole mesh, not this machine: %s", c.told[set]) + } +} + +func TestAGenesisOnTheCataloguesPackagesPortRegistersNoForge(t *testing.T) { + c, control := recording(t) + o := Options{Node: "anchor", Catalogue: aCatalogueWith(t, ForgeModule, theForgesManifest), + Wait: time.Second} + if err := recordTheForgesPort(context.Background(), o, control, quietly); err != nil { + t.Fatal(err) + } + if len(c.told) != 0 { + t.Errorf("a genesis on the defaults told the mesh something new: %v", c.told) + } +} + +func TestAForgeManifestTheCatalogueDoesNotHaveIsNamed(t *testing.T) { + _, control := recording(t) + o := Options{Node: "anchor", Catalogue: t.TempDir(), + Ports: FoundationPorts{Packages: 3100}, Wait: time.Second} + err := recordTheForgesPort(context.Background(), o, control, quietly) + if err == nil || !strings.Contains(err.Error(), ForgeModule) || + !strings.Contains(err.Error(), "package registry") { + t.Errorf("a missing forge manifest was not explained: %v", err) } } diff --git a/internal/bootstrap/phase_packages.go b/internal/bootstrap/phase_packages.go index bb8414b..551f81f 100644 --- a/internal/bootstrap/phase_packages.go +++ b/internal/bootstrap/phase_packages.go @@ -47,6 +47,16 @@ const ( defaultGiteaPort = 3000 ) +// ForgeModule is the module that owns the package registry — the one the bootstrap forge above is +// a stand-in for, and which takes it over once the base exists. +// +// Named here because the port given for the package registry is that module's setting on this node +// and not this installer's private number (novox/hq 04-ISSUES/085): the forge's own container, its +// filter rule, its opening, what it says it serves and what consumers are told all read it from +// there, so taking the bootstrap forge over does not move the registry back to the catalogue's +// port. +const ForgeModule = "gitea" + // RaisePackageRegistry puts a working npm registry in front of the base build. It is idempotent: // every step tolerates having been done, because genesis is safe to run again. func RaisePackageRegistry(ctx context.Context, o Options, d Deps, control controlPlane, @@ -100,6 +110,11 @@ func RaisePackageRegistry(ctx context.Context, o Options, d Deps, control contro return err } + say(" recording the port it was given as the forge module's own") + if err := recordTheForgesPort(ctx, o, control, say); err != nil { + return err + } + say(" delivering the builder its registry credential") if err := deliverBuilderNpm(ctx, o, control, builderPassword, say); err != nil { return err @@ -108,6 +123,43 @@ func RaisePackageRegistry(ctx context.Context, o Options, d Deps, control contro return nil } +// recordTheForgesPort makes the port given for the package registry a setting of the module that +// serves it, on this node (novox/hq 04-ISSUES/085, ADR 0100). +// +// **The forge is the one foundation port that was not a setting.** The store's, the bus's, the +// broker's management port and the registry's each become a `ports` setting of the module that +// binds them, set where that module is registered and assigned; the forge is raised by hand here, +// long before its module can be built, so there was no registration to hang it on and the number +// lived in rewritten manifest text instead. So the manifest is registered here — not assigned, and +// nothing of it runs — for the one thing registering buys: the controller will hold a setting +// against it. Whenever somebody later assigns the forge on this node, it comes up on the port this +// machine was given rather than on the catalogue's, and so does the address its consumers are told. +// +// A node given the catalogue's own port records nothing and registers nothing, so a genesis on the +// defaults does exactly what it did before. +func recordTheForgesPort(ctx context.Context, o Options, control controlPlane, + say func(string)) error { + if o.Ports.orDefaults().Packages == DefaultPorts().Packages { + return nil + } + manifest, err := readManifest(o.Catalogue, ForgeModule) + if err != nil { + return fmt.Errorf("%w\n"+ + "This is the module that owns the forge genesis just raised. Without it the port this "+ + "machine was given for the package registry is nobody's setting, and taking the forge "+ + "over would put it back on the catalogue's port", err) + } + remote := "/" + ForgeModule + "-module.json" + if err := control.carrying(ctx, ForgeModule+"-module.json", manifest, remote); err != nil { + return err + } + if _, err := control.tell(ctx, "module", "add", remote); err != nil { + return err + } + say(" registered " + ForgeModule + " — registered, not assigned: nothing of it runs yet") + return setFoundationSettings(ctx, o, control, ForgeModule, say) +} + // seedGiteaDatabase creates gitea's role and database inside the foundation postgres, the same way // the foundation creates its own — psql run through the store container (the map's Route B). The role // is created before the database because the database is owned by it. Both are tolerant of already diff --git a/internal/bootstrap/ports.go b/internal/bootstrap/ports.go index f7a77c3..a580a01 100644 --- a/internal/bootstrap/ports.go +++ b/internal/bootstrap/ports.go @@ -105,12 +105,25 @@ func (p FoundationPorts) moduleSettings() map[string]map[string]int { add("lavinmq", d.AMQP, p.AMQP) add("lavinmq", d.Management, p.Management) add(RegistryModule, d.Registry, p.Registry) + add(ForgeModule, d.Packages, p.Packages) return out } // PortsSetting is the controller's settings key for a module's given ports. const PortsSetting = "ports" +// ServesSetting is the settings key that carries what a provider serves, inside the one binding a +// module carries rather than resolves — the builder's package binding (novox/hq 04-ISSUES/085). +// +// **A port given at genesis has to be a setting wherever it is read, or something puts it back.** +// The builder reaches the package registry through a binding written in its own manifest, because +// at genesis no module yet provides `package-registry` and so there is nothing for the controller +// to resolve it from. That binding used to be rewritten, as text, when the installer registered the +// builder — which a later registration from the catalogue undid silently. Set as a node's setting +// instead, it is held by the controller rather than by the manifest, so re-registering the builder +// leaves it where it was. +const ServesSetting = "serves" + // setFoundationSettings tells the controller the ports this node gave a foundation module — and, // on an adopted node, that the registry is reached from anywhere, as a node pulls from it before it // has a private-network address (novox/hq ADR 0100). Done after the module is registered and before @@ -124,6 +137,13 @@ func setFoundationSettings(ctx context.Context, o Options, control controlPlane, if o.Adopted && module == RegistryModule { values["expose"] = map[string]string{strconv.Itoa(DefaultPorts().Registry): "anywhere"} } + if packages := o.Ports.orDefaults().Packages; module == BuilderModule && + packages != DefaultPorts().Packages { + // The one binding nothing resolves: the builder dials the forge by a number it carries. + // The rest of the binding — the provision, the forge it is from, the account it presents — + // is the manifest's and stays there. + values[ServesSetting] = map[string]int{"port": packages} + } if len(values) == 0 { return nil } diff --git a/internal/bootstrap/ports_test.go b/internal/bootstrap/ports_test.go index a94371a..366a3dd 100644 --- a/internal/bootstrap/ports_test.go +++ b/internal/bootstrap/ports_test.go @@ -220,7 +220,7 @@ func TestAPredecessorsContainerUnderTheMeshsNameIsRefused(t *testing.T) { } // controlRecorder is a control plane that answers everything and writes down what it was told, -// with the content of every settings file carried to it. +// with the content of every file carried to it, by the name it was carried under. type controlRecorder struct { told []string settings map[string]string @@ -229,9 +229,7 @@ type controlRecorder struct { func (c *controlRecorder) run(_ context.Context, name string, args ...string) (string, error) { if name == "docker" && args[0] == "cp" { raw, _ := os.ReadFile(args[1]) - if strings.HasSuffix(args[2], "-settings.json") { - c.settings[filepath.Base(args[2])] = string(raw) - } + c.settings[filepath.Base(args[2])] = string(raw) return "", nil } if name == "docker" && args[0] == "exec" {