The packages port given at genesis is a node setting, not manifest text (hq issue 085) #21

Merged
jschoubben merged 2 commits from feat/packages-port into main 2026-09-22 19:59:47 +00:00
6 changed files with 240 additions and 70 deletions
+3 -3
View File
@@ -98,8 +98,8 @@ const usage = `mesh-bootstrap — make a bare machine into a mesh
everything this mesh will ever run everything this mesh will ever run
--source-path the module's directory inside that repository, if not its root --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 --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 control plane's, the builder's and — when a packages port is given
after step 6 — the forge's manifests. Without it this stops after step 6
--node the name this machine is known by (default: its hostname) --node the name this machine is known by (default: its hostname)
--registry where this mesh keeps its own images (default ` + defaultRegistry + `) --registry where this mesh keeps its own images (default ` + defaultRegistry + `)
every node pulls the control plane from this, so on a mesh of more 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.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.State, "state", opts.State, "where this node records what it has applied")
set.StringVar(&opts.Catalogue, "catalog", opts.Catalogue, 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, set.StringVar(&opts.Source.Repository, "source", opts.Source.Repository,
"the repository the control plane is built from, on a mesh that already exists") "the repository the control plane is built from, on a mesh that already exists")
set.StringVar(&opts.Source.Ref, "source-ref", opts.Source.Ref, set.StringVar(&opts.Source.Ref, "source-ref", opts.Source.Ref,
-27
View File
@@ -1,10 +1,8 @@
package bootstrap package bootstrap
import ( import (
"bytes"
"context" "context"
"fmt" "fmt"
"strconv"
"strings" "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 "+ "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) "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) pinned, places, err := pinPlaceholder(manifest, published.Reference, BuilderModule)
if err != nil { if err != nil {
return out, err 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) out.Installed.Pushed, err = pushNode(ctx, o, control, say)
return out, err 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
}
+136 -36
View File
@@ -1,16 +1,21 @@
package bootstrap package bootstrap
import ( import (
"encoding/json" "context"
"os"
"path/filepath"
"strings" "strings"
"testing" "testing"
"time"
) )
// Defends novox/hq ADR 0100: a port given for the package registry at genesis reaches the one // Defends novox/hq 04-ISSUES/085: the port given for the package registry at genesis is a setting
// thing that dials it by a fixed number, the builder's package binding. // 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. // theBuildersBinding is the resource the builder carries in place of a binding nothing can resolve
const builderManifest = `{ // yet, exactly as the catalogue's manifest has it — settable, with the port as its only default.
const theBuildersBinding = `{
"module": "builder", "module": "builder",
"resources": [ "resources": [
{ {
@@ -18,52 +23,147 @@ const builderManifest = `{
"type": "file", "type": "file",
"path": "/var/lib/mesh/builder/package-registry.json", "path": "/var/lib/mesh/builder/package-registry.json",
"mode": "0600", "mode": "0600",
"merge": "json",
"protected": ["provision", "from", "at", "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" "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() t.Helper()
var m struct { t.Setenv("TMPDIR", t.TempDir())
Resources []struct { c := &controlRecorder{settings: map[string]string{}}
Content string `json:"content"` return c, controlPlane{container: "temp-mesh-controller", run: c.run, timeout: time.Second}
} `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
} }
func TestTheBuilderFollowsThePackageRegistrysGivenPort(t *testing.T) { func TestTheBuildersPackageRegistryPortIsASettingAndNotTheManifest(t *testing.T) {
got, err := followPackagesPort([]byte(builderManifest), 3100) c, control := recording(t)
if err != nil { 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) t.Fatal(err)
} }
if p := bindingPort(t, got); p != 3100 { if got := c.settings["builder-settings.json"]; got != `{"serves":{"port":3100}}` {
t.Errorf("the builder's binding dials %v, not the port given", p) 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) { func TestTheBuilderOnTheDefaultPackagesPortIsToldNothing(t *testing.T) {
got, err := followPackagesPort([]byte(builderManifest), 3000) c, control := recording(t)
if err != nil || string(got) != builderManifest { o := Options{Node: "anchor", Wait: time.Second}
t.Errorf("the default port changed the manifest: %v", err) 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) { // aCatalogueWith writes a checkout holding one module's manifest, which is all the installer reads
moved := strings.Replace(builderManifest, `\"port\": 3000`, `\"port\": 3001`, 1) // a catalogue for.
if _, err := followPackagesPort([]byte(moved), 3100); err == nil || !strings.Contains(err.Error(), "--packages-port") { func aCatalogueWith(t *testing.T, module, manifest string) string {
t.Errorf("a manifest the port cannot reach was accepted: %v", err) 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])
}
}
// knowingControl is a control plane that already has the forge in its catalogue, as a mesh that
// has been running has.
type knowingControl struct{ controlRecorder }
func (k *knowingControl) run(ctx context.Context, name string, args ...string) (string, error) {
out, err := k.controlRecorder.run(ctx, name, args...)
if name == "docker" && args[0] == "exec" && len(args) > 4 &&
args[3] == "module" && args[4] == "list" {
return ForgeModule + " 1 assigned to anchor\n", nil
}
return out, err
}
func TestARerunDoesNotReplaceAForgeTheMeshAlreadyHas(t *testing.T) {
t.Setenv("TMPDIR", t.TempDir())
k := &knowingControl{controlRecorder{settings: map[string]string{}}}
control := controlPlane{container: "mesh-controller", run: k.run, timeout: time.Second}
// An older checkout than the mesh is running, which is what makes an overwrite here damage.
o := Options{Node: "anchor", Catalogue: aCatalogueWith(t, ForgeModule, `{"module": "gitea"}`),
Ports: FoundationPorts{Packages: 3100}, Wait: time.Second}
if err := recordTheForgesPort(context.Background(), o, control, quietly); err != nil {
t.Fatal(err)
}
// Registering is an overwrite, and the manifest of a forge that is built and assigned is not
// this installer's to replace with whatever checkout it was pointed at.
if k.index("module add") >= 0 {
t.Errorf("a re-run replaced the registered forge's manifest: %v", k.told)
}
// The port is still recorded: a settings row needs the module row, and it is already there.
if got := k.settings["gitea-settings.json"]; got != `{"ports":{"3000":3100}}` {
t.Errorf("the forge's port was not recorded on a mesh that knows it: %q", got)
}
}
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)
} }
} }
+73
View File
@@ -47,6 +47,21 @@ const (
defaultGiteaPort = 3000 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). What follows that setting is
// what the mesh derives from a module's ports: the forge's container mapping, its rule in the
// filter, its opening on an adopted node, and what it says it serves — so a consumer the mesh binds
// is told where the machine actually put the registry.
//
// What does NOT follow it, and is not this change's to fix: the address of itself the forge's
// runtime is given, which the catalogue writes as a literal (novox/hq 04-ISSUES/088), and the port
// in its route contribution. Both are already wrong for any machine port the mesh assigned, with a
// given port or without one.
const ForgeModule = "gitea"
// RaisePackageRegistry puts a working npm registry in front of the base build. It is idempotent: // 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. // every step tolerates having been done, because genesis is safe to run again.
func RaisePackageRegistry(ctx context.Context, o Options, d Deps, control controlPlane, func RaisePackageRegistry(ctx context.Context, o Options, d Deps, control controlPlane,
@@ -100,6 +115,11 @@ func RaisePackageRegistry(ctx context.Context, o Options, d Deps, control contro
return err 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") say(" delivering the builder its registry credential")
if err := deliverBuilderNpm(ctx, o, control, builderPassword, say); err != nil { if err := deliverBuilderNpm(ctx, o, control, builderPassword, say); err != nil {
return err return err
@@ -108,6 +128,59 @@ func RaisePackageRegistry(ctx context.Context, o Options, d Deps, control contro
return nil 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: a settings row needs the module row to
// exist. 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.
//
// **Registered only when the mesh does not already know it, which is the opposite of every other
// `module add` here.** The rest of the installer registers every run on purpose: the manifest is
// what changes between runs, and skipping it would pin the mesh to a previous image. This one
// changes nothing about the forge and wants nothing of the checkout's manifest — and registering
// is an overwrite, so a re-run of genesis pointed at an older catalogue would replace the manifest
// of a forge that is built and assigned, with a push a few lines later. The port is the only thing
// this is here to record, and the setting does not need the manifest to be this checkout's.
//
// 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
}
known, err := control.tell(ctx, "module", "list")
if err != nil {
return err
}
if mentions(known, ForgeModule) {
say(" known " + ForgeModule + " — left as the mesh has it; only its port is set here")
} else {
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 // 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 // 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 // is created before the database because the database is owned by it. Both are tolerant of already
+26
View File
@@ -105,12 +105,31 @@ func (p FoundationPorts) moduleSettings() map[string]map[string]int {
add("lavinmq", d.AMQP, p.AMQP) add("lavinmq", d.AMQP, p.AMQP)
add("lavinmq", d.Management, p.Management) add("lavinmq", d.Management, p.Management)
add(RegistryModule, d.Registry, p.Registry) add(RegistryModule, d.Registry, p.Registry)
add(ForgeModule, d.Packages, p.Packages)
return out return out
} }
// PortsSetting is the controller's settings key for a module's given ports. // PortsSetting is the controller's settings key for a module's given ports.
const PortsSetting = "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.
//
// **A settings layer is the module's, not one resource's.** The controller lays it over every file
// of that module which merges as JSON, so `serves` lands in the package binding only because that
// binding is the builder's one mergeable file. A second mergeable file added to the builder would
// be given a `serves` key too, meaning nothing to whatever reads it. Worth knowing before adding
// one.
const ServesSetting = "serves"
// setFoundationSettings tells the controller the ports this node gave a foundation module — and, // 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 // 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 // has a private-network address (novox/hq ADR 0100). Done after the module is registered and before
@@ -124,6 +143,13 @@ func setFoundationSettings(ctx context.Context, o Options, control controlPlane,
if o.Adopted && module == RegistryModule { if o.Adopted && module == RegistryModule {
values["expose"] = map[string]string{strconv.Itoa(DefaultPorts().Registry): "anywhere"} 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 { if len(values) == 0 {
return nil return nil
} }
+2 -4
View File
@@ -220,7 +220,7 @@ func TestAPredecessorsContainerUnderTheMeshsNameIsRefused(t *testing.T) {
} }
// controlRecorder is a control plane that answers everything and writes down what it was told, // 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 { type controlRecorder struct {
told []string told []string
settings map[string]string settings map[string]string
@@ -229,9 +229,7 @@ type controlRecorder struct {
func (c *controlRecorder) run(_ context.Context, name string, args ...string) (string, error) { func (c *controlRecorder) run(_ context.Context, name string, args ...string) (string, error) {
if name == "docker" && args[0] == "cp" { if name == "docker" && args[0] == "cp" {
raw, _ := os.ReadFile(args[1]) 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 return "", nil
} }
if name == "docker" && args[0] == "exec" { if name == "docker" && args[0] == "exec" {