Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cb7b71dcc5 | ||
|
|
f0d39b4a4a | ||
|
|
89fd0c06e1 | ||
|
|
17038b2a07 | ||
|
|
4dde21f0ab | ||
|
|
7d1d6bffe3 | ||
|
|
7283209d1a | ||
|
|
64f0ab4051 | ||
|
|
5f147fe6b9 |
@@ -0,0 +1,29 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-host/internal/link"
|
||||
"github.com/novox/mesh-host/internal/outward"
|
||||
"github.com/novox/mesh-host/internal/profile"
|
||||
)
|
||||
|
||||
func reportOn(networks ...outward.Network) link.Report {
|
||||
return link.Report{Outward: []string{"eth0"},
|
||||
Profile: profileAsReported(profile.Profile{Machine: &profile.Machine{Networks: networks}})}
|
||||
}
|
||||
|
||||
// **A change of the networks a machine is attached to is said unasked** (novox/hq ADR 0340 §7): a port
|
||||
// reached from the local network is admitted from them, so a laptop that joins another network is said at
|
||||
// the next reconcile, not at the next push — and the same networks again are not news.
|
||||
func TestAChangedNetworkIsSaidUnasked(t *testing.T) {
|
||||
w := &adoptionWatch{}
|
||||
home := reportOn(outward.Network{Link: "eth0", Network: "192.168.1.0/24"})
|
||||
w.said(home)
|
||||
if w.differs(reportOn(outward.Network{Link: "eth0", Network: "192.168.1.0/24"})) {
|
||||
t.Fatal("the same networks read as a change, so the machine would speak on every reconcile")
|
||||
}
|
||||
if !w.differs(reportOn(outward.Network{Link: "eth0", Network: "10.20.0.0/16"})) {
|
||||
t.Fatal("a machine that moved to another network does not say so until it is pushed to")
|
||||
}
|
||||
}
|
||||
@@ -1397,6 +1397,12 @@ func adoptionFingerprint(r link.Report) string {
|
||||
// it could only speak when a declaration arrived, and a declaration cannot be composed until it
|
||||
// has spoken: a machine waiting for a push that is waiting for the machine.
|
||||
parts := []string{"firewall=" + r.Firewall, "outward=" + strings.Join(r.Outward, ",")}
|
||||
// **And the networks those links are on** (novox/hq ADR 0340 §7): a port reached from the local
|
||||
// network is admitted from them, so a laptop that joins another network, or a lease that moves, is
|
||||
// said at the next reconcile rather than at the next push or the next day.
|
||||
if networks := attachedNetworksOf(r); networks != "" {
|
||||
parts = append(parts, "networks="+networks)
|
||||
}
|
||||
for _, h := range r.Held {
|
||||
parts = append(parts, "held "+h.ID+"="+h.Changed)
|
||||
}
|
||||
@@ -1428,6 +1434,26 @@ func adoptionFingerprint(r link.Report) string {
|
||||
return strings.Join(parts, "\n")
|
||||
}
|
||||
|
||||
// attachedNetworksOf is the networks a report's profile says the machine is attached to, as one line: the
|
||||
// part of what the machine is made of that the filter is composed from (novox/hq ADR 0340).
|
||||
func attachedNetworksOf(r link.Report) string {
|
||||
machine, ok := r.Profile["machine"].(map[string]any)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
networks, ok := machine["networks"].([]any)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
said := make([]string, 0, len(networks))
|
||||
for _, n := range networks {
|
||||
if entry, ok := n.(map[string]any); ok {
|
||||
said = append(said, fmt.Sprintf("%v %v", entry["link"], entry["network"]))
|
||||
}
|
||||
}
|
||||
return strings.Join(said, ",")
|
||||
}
|
||||
|
||||
// applyState is how an apply went, as the fingerprint keeps it: refused, clean, or failed with what it did not
|
||||
// apply and which keys its failure is said under.
|
||||
func applyState(r link.Report) string {
|
||||
|
||||
@@ -49,7 +49,7 @@ func TestTheBundleCarriesTheFoundationAndTheControlPlaneAndNothingElse(t *testin
|
||||
}
|
||||
sort.Strings(images)
|
||||
|
||||
want := []string{"mesh-controller", "nats", "postgres"}
|
||||
want := []string{"mesh-controller", "nats", "pgvector"}
|
||||
if strings.Join(images, ",") != strings.Join(want, ",") {
|
||||
t.Fatalf("the bundle carries %v; expected exactly %v.\n\n"+
|
||||
"Adding one is a change to what every first node becomes, and to ADR 0006's "+
|
||||
|
||||
@@ -28,11 +28,12 @@
|
||||
// image, and this file is applied on a machine with no mesh to ask about anything (ADR 0006). The
|
||||
// registry prefix is the lab's; the digests are upstream's.
|
||||
//
|
||||
// The store's data is a NAMED VOLUME, not a directory on the machine. A directory the host
|
||||
// creates is owned by root, and the database runs as somebody else inside the container — so it
|
||||
// could not write, and the container crash-looped. A named volume lets the image set up its own
|
||||
// ownership, and outlives the container, which is what you want for the thing holding the mesh's
|
||||
// state.
|
||||
// **The store is raised where the `postgres` module keeps it** (novox/hq ADR 0338): the container
|
||||
// `postgres`, on the module's pgvector image, its data in the directory /var/lib/mesh-store owned by
|
||||
// the image's postgres user, its superuser password read from the module's own-secret file — so the
|
||||
// store step adopts it as it runs. Pinned upstream by the module's own digest, not through the lab's
|
||||
// registry prefix, because the two must be the same reference (mesh-host internal/bootstrap holds
|
||||
// them to each other against the module's manifest).
|
||||
|
||||
{
|
||||
"declaration": 1,
|
||||
@@ -83,17 +84,28 @@
|
||||
"boot": "enabled",
|
||||
"restart-on": ["base-filter"]
|
||||
},
|
||||
// The store's data is a directory owned by the image's postgres user (999), made before the
|
||||
// container starts: a directory the runtime made would be root's, and the database could not
|
||||
// write it. Measured on the module's image 2026-10-13: it initialises a new store on a directory
|
||||
// made so (novox/hq ADR 0338 §5).
|
||||
{
|
||||
"id": "store-data",
|
||||
"type": "directory",
|
||||
"path": "/var/lib/mesh-store",
|
||||
"mode": "0700",
|
||||
"owner": "999:70"
|
||||
},
|
||||
{
|
||||
"id": "store",
|
||||
"type": "container",
|
||||
"name": "mesh-store",
|
||||
"image": "192.0.2.250:5000/postgres@sha256:7abf537131b66ed5af448d90653abf1679b0c7e9a1f07efdd4c3108a401b259a",
|
||||
"name": "postgres",
|
||||
"image": "pgvector/pgvector@sha256:cf134a767f474095eeba57e0117be8e568e011a63f33fbf252f14c9b760f8e6f",
|
||||
"env": {
|
||||
"POSTGRES_PASSWORD": "bootstrap",
|
||||
"PGDATA": "/var/lib/postgresql/data/pgdata"
|
||||
},
|
||||
"ports": ["5432:5432"],
|
||||
"volumes": ["mesh-store-data:/var/lib/postgresql/data"]
|
||||
"volumes": ["/var/lib/mesh-store:/var/lib/postgresql/data"]
|
||||
},
|
||||
// Over TCP, not the socket. While the store initialises it runs a temporary server on the
|
||||
// socket ONLY, then stops it and starts the real one — so a socket check passes, the action
|
||||
@@ -102,21 +114,21 @@
|
||||
{
|
||||
"id": "store-ready",
|
||||
"type": "action",
|
||||
"in": "mesh-store",
|
||||
"in": "postgres",
|
||||
"command": ["sh", "-c", "for i in $(seq 1 180); do pg_isready -h 127.0.0.1 -U postgres >/dev/null 2>&1 && exit 0; sleep 1; done; echo 'the store did not answer within 180s; its own last words follow'; pg_isready -h 127.0.0.1 -U postgres; tail -n 20 /var/lib/postgresql/data/log/*.log 2>/dev/null; exit 1"],
|
||||
"verify": ["pg_isready", "-h", "127.0.0.1", "-U", "postgres"]
|
||||
},
|
||||
{
|
||||
"id": "inventory-database",
|
||||
"type": "action",
|
||||
"in": "mesh-store",
|
||||
"in": "postgres",
|
||||
"command": ["sh", "-c", "psql -U postgres -c 'CREATE DATABASE inventory'"],
|
||||
"verify": ["sh", "-c", "psql -U postgres -lqt | cut -d'|' -f1 | grep -qw inventory"]
|
||||
},
|
||||
{
|
||||
"id": "identity-database",
|
||||
"type": "action",
|
||||
"in": "mesh-store",
|
||||
"in": "postgres",
|
||||
"command": ["sh", "-c", "psql -U postgres -c 'CREATE DATABASE identity'"],
|
||||
"verify": ["sh", "-c", "psql -U postgres -lqt | cut -d'|' -f1 | grep -qw identity"]
|
||||
},
|
||||
@@ -126,20 +138,20 @@
|
||||
{
|
||||
"id": "licences-database",
|
||||
"type": "action",
|
||||
"in": "mesh-store",
|
||||
"in": "postgres",
|
||||
"command": ["sh", "-c", "psql -U postgres -c 'CREATE DATABASE licences'"],
|
||||
"verify": ["sh", "-c", "psql -U postgres -lqt | cut -d'|' -f1 | grep -qw licences"]
|
||||
},
|
||||
{
|
||||
"id": "context-schemas",
|
||||
"type": "action",
|
||||
"command": ["docker", "run", "--rm", "--network", "container:mesh-store",
|
||||
"command": ["docker", "run", "--rm", "--network", "container:postgres",
|
||||
"-e", "MESH_STORE_INVENTORY=postgres://postgres:bootstrap@127.0.0.1:5432/inventory?sslmode=disable",
|
||||
"-e", "MESH_STORE_IDENTITY=postgres://postgres:bootstrap@127.0.0.1:5432/identity?sslmode=disable",
|
||||
"-e", "MESH_STORE_LICENCES=postgres://postgres:bootstrap@127.0.0.1:5432/licences?sslmode=disable",
|
||||
"192.0.2.250:5000/mesh-controller@sha256:c67db38439ff0aee242b467486765467bb95801f52175fc5727cc4e437338ace",
|
||||
"migrate"],
|
||||
"verify": ["sh", "-c", "docker exec mesh-store psql -U postgres -d inventory -tAc \"select to_regclass('public.node')\" | grep -qx node && docker exec mesh-store psql -U postgres -d identity -tAc \"select to_regclass('public.signing_key')\" | grep -qx signing_key && docker exec mesh-store psql -U postgres -d licences -tAc \"select to_regclass('public.licence')\" | grep -qx licence"]
|
||||
"verify": ["sh", "-c", "docker exec postgres psql -U postgres -d inventory -tAc \"select to_regclass('public.node')\" | grep -qx node && docker exec postgres psql -U postgres -d identity -tAc \"select to_regclass('public.signing_key')\" | grep -qx signing_key && docker exec postgres psql -U postgres -d licences -tAc \"select to_regclass('public.licence')\" | grep -qx licence"]
|
||||
},
|
||||
// **The bus is raised where the `nats` module keeps it** (novox/hq ADR 0333 §1): the container
|
||||
// `mesh-broker-nats`, its configuration and user list in /var/lib/nats-module/conf, its streams in
|
||||
|
||||
@@ -38,27 +38,6 @@ func recording(t *testing.T) (*controlRecorder, controlPlane) {
|
||||
return c, controlPlane{container: "temp-mesh-controller", run: c.run, timeout: time.Second}
|
||||
}
|
||||
|
||||
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 got := c.settings[BuilderModule+"-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[BuilderModule+"-module.json"]; got != theBuildersBinding {
|
||||
t.Errorf("the installer changed the builder's manifest:\n%s", got)
|
||||
}
|
||||
set, push := c.index("settings set "+BuilderModule), 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 TestTheBuilderOnTheDefaultPackagesPortIsToldNothing(t *testing.T) {
|
||||
c, control := recording(t)
|
||||
o := Options{Node: "anchor", Wait: time.Second}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Genesis seals the build agent the forge it raised (novox/hq ADR 0336 §3, issue 121): the agent's
|
||||
// npm-package-registry requirement is optional and binds only once the gitea module is assigned, after
|
||||
// the base, so until then the packages step's genesis document is how the sdk and base steps reach the
|
||||
// forge.
|
||||
|
||||
func TestThePackagesStepSealsTheGenesisDocumentToTheBuildAgent(t *testing.T) {
|
||||
if BuilderModule != "build-agent" {
|
||||
t.Fatalf("genesis installs %q; the document is the build agent's", BuilderModule)
|
||||
}
|
||||
c, control := recording(t)
|
||||
o := Options{Node: "anchor", Ports: FoundationPorts{Packages: 3100}, Wait: time.Second}
|
||||
doc := genesisRegistry(o.Ports.orDefaults(), "builder-password")
|
||||
if err := deliverGenesisRegistry(context.Background(), o, control, doc, quietly); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
carried := c.settings["accepting-"+GenesisRegistrySecret]
|
||||
var got GenesisRegistry
|
||||
if err := json.Unmarshal([]byte(carried), &got); err != nil {
|
||||
t.Fatalf("the genesis document carried is not JSON: %q", carried)
|
||||
}
|
||||
want := GenesisRegistry{Scheme: "http", At: "127.0.0.1", Port: 3100, NpmPath: "/api/packages/novox/npm/",
|
||||
As: "mesh-builder", Password: "builder-password"}
|
||||
if got != want {
|
||||
t.Fatalf("the genesis document says %+v, want %+v", got, want)
|
||||
}
|
||||
accept := c.index("secret accept anchor build-agent genesis-package-registry --from /accepting-genesis-package-registry")
|
||||
push := c.index("push anchor")
|
||||
if accept < 0 || push < 0 || accept > push {
|
||||
t.Fatalf("the genesis document was not sealed to the build agent before the push: %v", c.told)
|
||||
}
|
||||
}
|
||||
|
||||
// What the retired builder was given and the build agent declares neither of: an `npm-password`
|
||||
// own-secret and a `serves` setting for its carried binding (ADR 0336 §3).
|
||||
func TestNoStepGivesTheBuildAgentAServesSettingOrAnNpmPassword(t *testing.T) {
|
||||
for _, module := range []string{BuilderModule} {
|
||||
c, control := recording(t)
|
||||
o := Options{Node: "anchor", Ports: FoundationPorts{Packages: 3100}, Wait: time.Second}
|
||||
if _, err := installModule(context.Background(), o, control, module, []byte(`{"module":"`+module+`"}`), quietly); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, set := c.settings[module+"-settings.json"]; set {
|
||||
t.Errorf("%s was given a setting on a node with a non-default packages port: %s", module, got)
|
||||
}
|
||||
if err := deliverGenesisRegistry(context.Background(), o, control,
|
||||
genesisRegistry(o.Ports.orDefaults(), "p"), quietly); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, told := range c.told {
|
||||
if strings.Contains(told, "npm-password") || strings.Contains(told, "serves") {
|
||||
t.Errorf("genesis told the controller %q", told)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -137,7 +137,26 @@ func registerAndAssign(ctx context.Context, o Options, control controlPlane, mod
|
||||
// should hold, seals every secret in them and publishes them, and on a first node that is the
|
||||
// slowest thing the mesh does.
|
||||
func pushNode(ctx context.Context, o Options, control controlPlane, say func(string)) (string, error) {
|
||||
said, err := control.within(o.Wait).tell(ctx, "push", o.Node)
|
||||
return pushing(ctx, o, control, false, say)
|
||||
}
|
||||
|
||||
// pushNodeApplied is pushNode that returns only once the node reports it applied exactly what it was
|
||||
// sent (`push <node> --wait`, novox/hq ADR 0010): for a push whose content the very next step reads
|
||||
// on the machine. A plain push returns as soon as the declaration is sent, and the node applies it a
|
||||
// moment later (issue 615).
|
||||
func pushNodeApplied(ctx context.Context, o Options, control controlPlane, say func(string)) (string, error) {
|
||||
return pushing(ctx, o, control, true, say)
|
||||
}
|
||||
|
||||
func pushing(ctx context.Context, o Options, control controlPlane, applied bool, say func(string)) (string, error) {
|
||||
args := []string{"push", o.Node}
|
||||
patience := o.Wait
|
||||
if applied {
|
||||
args = append(args, "--wait", o.Wait.String())
|
||||
// The controller's own wait, and time to compose and send before it starts.
|
||||
patience = 2 * o.Wait
|
||||
}
|
||||
said, err := control.within(patience).tell(ctx, args...)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf(
|
||||
"%w\n\nWhat was registered and assigned is registered and assigned, and this node has "+
|
||||
@@ -145,7 +164,20 @@ func pushNode(ctx context.Context, o Options, control controlPlane, say func(str
|
||||
"at all. Fix what it named and run this installer again: it will find the module "+
|
||||
"already registered and try the push again", err)
|
||||
}
|
||||
say(" pushed " + o.Node)
|
||||
if applied {
|
||||
say(" pushed " + o.Node + ", and it applied it")
|
||||
} else {
|
||||
say(" pushed " + o.Node)
|
||||
}
|
||||
// **Every push may carry a new bus user, and until the adoption the bus hears of it only from here**
|
||||
// (novox/hq issue 608, ADR 0331 §5, ADR 0333 §4). A module's account is minted into the mesh's
|
||||
// records and sealed to this node with the push; the bus reads its user list from a file, and until
|
||||
// the broker step adopts the bus as the nats module nothing but this installer writes that file. The
|
||||
// list was placed only when the node enrolled, so the build agent's account (step 13) never reached
|
||||
// the bus and the agent was refused ("Authorization Violation") until the sdk step gave up.
|
||||
if err := placeUntilAdopted(ctx, control, say); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimSpace(said), nil
|
||||
}
|
||||
|
||||
|
||||
@@ -84,7 +84,7 @@ func InstallStore(ctx context.Context, o Options, control controlPlane,
|
||||
say); err != nil {
|
||||
return err
|
||||
}
|
||||
say(" adopted mesh-store — the foundation's store is now the " + module + " module")
|
||||
say(" adopted " + store.Name + " — the foundation's store is now the " + module + " module")
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package bootstrap
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
@@ -24,7 +25,7 @@ import (
|
||||
const (
|
||||
// foundationStore is the foundation's postgres container — the mesh's own memory, raised from the
|
||||
// bundle. gitea's bootstrap database lives here too, so a mesh runs one postgres (issue 051).
|
||||
foundationStore = "mesh-store"
|
||||
foundationStore = "postgres"
|
||||
// giteaBootstrap is the gitea server raised directly at genesis, before gitea is a module —
|
||||
// under the name the gitea MODULE declares for its container, so the module finds it and holds
|
||||
// it rather than raising a second forge beside it (novox/hq ADR 0163, rule 7; issue 090).
|
||||
@@ -49,8 +50,8 @@ const (
|
||||
packagesTeam = "packages"
|
||||
// giteaAdminUser is the admin the bootstrap creates and the provisioner later authenticates as.
|
||||
giteaAdminUser = "mesh-admin"
|
||||
// builderGiteaUser is the gitea account the builder publishes and pulls with at genesis. It is
|
||||
// the `as` the builder's static package binding names.
|
||||
// builderGiteaUser is the gitea account the build agent publishes and pulls with at genesis: the
|
||||
// `as` of the genesis document (novox/hq ADR 0336 §3). Retiring it is issue 602.
|
||||
builderGiteaUser = "mesh-builder"
|
||||
// giteaDBRole/giteaDBName is gitea's own database in the foundation store.
|
||||
giteaDBRole = "mesh_gitea"
|
||||
@@ -133,8 +134,8 @@ func RaisePackageRegistry(ctx context.Context, o Options, d Deps, control contro
|
||||
return err
|
||||
}
|
||||
|
||||
say(" delivering the builder its registry credential")
|
||||
if err := deliverBuilderNpm(ctx, o, control, builderPassword, say); err != nil {
|
||||
say(" sealing the build agent the genesis document for the forge it raised")
|
||||
if err := deliverGenesisRegistry(ctx, o, control, genesisRegistry(ports, builderPassword), say); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -362,27 +363,69 @@ func packagePasswords() (db, admin, builder string, err error) {
|
||||
return db, admin, builder, nil
|
||||
}
|
||||
|
||||
// deliverBuilderNpm seals the builder's registry password to this node as its `npm-password`
|
||||
// own-secret, the same way the control plane's store connections are delivered — carry the value in,
|
||||
// `secret accept`, and the next push writes it sealed where the builder reads it.
|
||||
func deliverBuilderNpm(ctx context.Context, o Options, control controlPlane, password string,
|
||||
say func(string)) error {
|
||||
at := "/accepting-npm-password"
|
||||
if err := control.carryingSecret(ctx, "mesh-accepting-npm-password", []byte(password), at); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := control.tell(ctx, "secret", "accept", o.Node, BuilderModule, "npm-password", "--from", at); err != nil {
|
||||
return err
|
||||
}
|
||||
// Push so the sealed secret reaches the builder, which restarts on it and comes back credentialed.
|
||||
if _, err := control.tell(ctx, "push", o.Node); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
// GenesisRegistrySecret is the build agent's own-secret the genesis document is sealed as (novox/hq
|
||||
// ADR 0336 §3). The build-agent manifest declares it, issued outside the mesh: only this installer
|
||||
// gives one, on genesis's node.
|
||||
const GenesisRegistrySecret = "genesis-package-registry"
|
||||
|
||||
// GenesisRegistry is the genesis document (novox/hq ADR 0336 §3): the forge this step raised, the port
|
||||
// this node gave the package registry, its npm path, and the account this step made with its password.
|
||||
// mesh-controller's cmd/mesh-builder reads it under the same field names while the build agent's
|
||||
// npm-package-registry requirement is unbound, and prefers the binding once the gitea module is
|
||||
// assigned and it binds.
|
||||
type GenesisRegistry struct {
|
||||
Scheme string `json:"scheme"`
|
||||
At string `json:"at"`
|
||||
Port int `json:"port"`
|
||||
NpmPath string `json:"npm-path"`
|
||||
As string `json:"as"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
// PublishTheSDK dispatches a build of the SDK to the builder. The builder has its registry
|
||||
// credential by now, so the build's `npm publish` authenticates; the artifact is a `package`, built
|
||||
// genesisRegistry is the document for the forge raised here: on the machine's own loopback, where it
|
||||
// answers on the host network and where its ROOT_URL says it is (raiseGiteaServer), on the port this
|
||||
// node gave the package registry.
|
||||
func genesisRegistry(ports FoundationPorts, password string) GenesisRegistry {
|
||||
return GenesisRegistry{Scheme: "http", At: "127.0.0.1", Port: ports.Packages,
|
||||
NpmPath: "/api/packages/" + packagesOrg + "/npm/", As: builderGiteaUser, Password: password}
|
||||
}
|
||||
|
||||
// deliverGenesisRegistry seals the genesis document to the build agent on this node as its
|
||||
// `genesis-package-registry` own-secret (novox/hq ADR 0336 §3), the same way the control plane's store
|
||||
// connections are delivered — carry the value in, `secret accept`, and the next push writes it sealed
|
||||
// where the build agent reads it.
|
||||
//
|
||||
// **The build agent's one way to the forge until the registry binds.** Its npm-package-registry
|
||||
// requirement is optional (ADR 0336 §1), and nothing provides it until the gitea module is assigned
|
||||
// after the base, so the sdk step (15) publishes and the base step (16) resolves with the account in
|
||||
// this document. It replaces the retired builder's `npm-password` own-secret and the `serves.port`
|
||||
// setting its carried binding took, neither of which the build agent declares.
|
||||
func deliverGenesisRegistry(ctx context.Context, o Options, control controlPlane, doc GenesisRegistry,
|
||||
say func(string)) error {
|
||||
raw, err := json.Marshal(doc)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
at := "/accepting-" + GenesisRegistrySecret
|
||||
if err := control.carryingSecret(ctx, "mesh-accepting-"+GenesisRegistrySecret, raw, at); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := control.tell(ctx, "secret", "accept", o.Node, BuilderModule, GenesisRegistrySecret, "--from", at); err != nil {
|
||||
return err
|
||||
}
|
||||
// Push so the sealed document reaches the build agent, which reads it at its next build — and,
|
||||
// like every push before the adoption, the bus hears the users it carries (issue 608).
|
||||
//
|
||||
// **Waited for until the node applied it** (issue 615). The next step asks the build agent to
|
||||
// publish the SDK, and the agent reads the document from the file the node writes when it applies
|
||||
// this push. A push that returned on sending let the ask race the apply: one genesis found the
|
||||
// document in time, the next was refused, "neither its binding nor the genesis document".
|
||||
_, err = pushNodeApplied(ctx, o, control, say)
|
||||
return err
|
||||
}
|
||||
|
||||
// PublishTheSDK dispatches a build of the SDK to the build agent. The agent has the genesis document by
|
||||
// now (novox/hq ADR 0336 §7), so the build's `npm publish` authenticates; the artifact is a `package`, built
|
||||
// on a public base, so this needs no toolchain — which is the whole point of doing it before the base.
|
||||
func PublishTheSDK(ctx context.Context, o Options, control controlPlane, say func(string)) error {
|
||||
if o.SDKSource.Repository == "" {
|
||||
|
||||
@@ -117,24 +117,6 @@ const busModulePort = 4222
|
||||
// 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.
|
||||
//
|
||||
// **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,
|
||||
// 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
|
||||
@@ -148,13 +130,8 @@ 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}
|
||||
}
|
||||
// No `serves` setting for the build agent: the port given for the package registry reaches it in
|
||||
// the genesis document (novox/hq ADR 0336 §3), and from the binding once the registry binds.
|
||||
if len(values) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -68,7 +68,7 @@ func TestGivenPortsMoveOnlyTheMachinesSide(t *testing.T) {
|
||||
if got.Places == 0 {
|
||||
t.Fatal("nothing was rewritten")
|
||||
}
|
||||
storeC := containerNamed(r.Declaration, "mesh-store")
|
||||
storeC := containerNamed(r.Declaration, "postgres")
|
||||
if len(storeC.Ports) != 1 || storeC.Ports[0] != "5433:5432" {
|
||||
t.Errorf("the store publishes %v", storeC.Ports)
|
||||
}
|
||||
@@ -239,6 +239,12 @@ func (c *controlRecorder) run(_ context.Context, name string, args ...string) (s
|
||||
}
|
||||
if name == "docker" && args[0] == "exec" {
|
||||
c.told = append(c.told, strings.Join(args[3:], " "))
|
||||
if strings.Join(args[3:], " ") == "broker accounts" {
|
||||
return "accounts {}\n", nil // the composed user list, placed after every push (issue 608)
|
||||
}
|
||||
}
|
||||
if name == "sh" {
|
||||
c.told = append(c.told, "placed the bus's users")
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Defends novox/hq issue 608 (ADR 0331 §5, ADR 0333 §4): until the bus is adopted as the nats module,
|
||||
// every push genesis makes is followed by the installer placing the composed user list where the bus
|
||||
// reads it, and making the bus re-read it. The build agent's account was minted at step 13 and sealed
|
||||
// with that push, and the list was placed only at enrolment, so the bus refused the agent
|
||||
// ("Authorization Violation") and genesis stopped at step 15.
|
||||
|
||||
// placing answers the controller as a mesh whose bus is or is not the nats module yet, and records
|
||||
// what was run, in order.
|
||||
func placing(adopted bool) (*asked, controlPlane) {
|
||||
listed := "build-agent 1\n"
|
||||
if adopted {
|
||||
listed += "nats 1\n"
|
||||
}
|
||||
runtime := &asked{}
|
||||
runtime.answer = func(name string, args []string) (string, error) {
|
||||
joined := strings.Join(args, " ")
|
||||
switch {
|
||||
case name == "sh":
|
||||
return "", nil
|
||||
case strings.HasSuffix(joined, "module list"):
|
||||
return listed, nil
|
||||
case strings.HasSuffix(joined, "broker accounts"):
|
||||
return "accounts {\n MESH { users = [ { user: \"anchor.build-agent\" } ] }\n}\n", nil
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
return runtime, controlPlane{container: "mesh-controller", run: runtime.run, timeout: time.Second}
|
||||
}
|
||||
|
||||
// placedAfterThePush is whether the bus's users were placed — written at the module's bus-users path,
|
||||
// the bus told to re-read them — after the push, and how often.
|
||||
func placedAfterThePush(t *testing.T, commands []string) int {
|
||||
t.Helper()
|
||||
pushed, placed := -1, 0
|
||||
for i, c := range commands {
|
||||
if strings.Contains(c, "/mesh-controller push anchor") && pushed < 0 {
|
||||
pushed = i
|
||||
}
|
||||
if strings.HasPrefix(c, "sh -c") && strings.Contains(c, busAccounts) &&
|
||||
strings.Contains(c, "kill -s HUP "+busContainer) {
|
||||
if pushed < 0 || i < pushed {
|
||||
t.Fatalf("the bus's users were placed before the push that carries the account: %v", commands)
|
||||
}
|
||||
placed++
|
||||
}
|
||||
}
|
||||
if pushed < 0 {
|
||||
t.Fatalf("nothing was pushed: %v", commands)
|
||||
}
|
||||
return placed
|
||||
}
|
||||
|
||||
func TestTheBusHearsTheBuildAgentsAccountAfterItsPush(t *testing.T) {
|
||||
t.Setenv("TMPDIR", t.TempDir())
|
||||
runtime, control := placing(false)
|
||||
o := Options{Node: "anchor", Wait: time.Second}
|
||||
if _, err := installModule(context.Background(), o, control, BuilderModule, []byte(`{}`), quietly); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n := placedAfterThePush(t, runtime.commands); n != 1 {
|
||||
t.Fatalf("the bus's users were placed %d time(s) after the build agent's push; once: %v", n, runtime.commands)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheGenesisDocumentsPushIsHeardByTheBusToo(t *testing.T) {
|
||||
t.Setenv("TMPDIR", t.TempDir())
|
||||
runtime, control := placing(false)
|
||||
o := Options{Node: "anchor", Wait: time.Second}
|
||||
if err := deliverGenesisRegistry(context.Background(), o, control, genesisRegistry(DefaultPorts(), "pw"), quietly); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n := placedAfterThePush(t, runtime.commands); n != 1 {
|
||||
t.Fatalf("the bus's users were placed %d time(s) after the genesis document's push; once: %v", n, runtime.commands)
|
||||
}
|
||||
}
|
||||
|
||||
// From the adoption on the nats module writes the list, and the installer does not (ADR 0333 §4).
|
||||
func TestOnceTheBusIsTheNatsModulesThePushPlacesNothing(t *testing.T) {
|
||||
t.Setenv("TMPDIR", t.TempDir())
|
||||
runtime, control := placing(true)
|
||||
o := Options{Node: "anchor", Wait: time.Second}
|
||||
if _, err := installModule(context.Background(), o, control, BuilderModule, []byte(`{}`), quietly); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n := placedAfterThePush(t, runtime.commands); n != 0 {
|
||||
t.Fatalf("the installer placed the bus's users over the nats module's: %v", runtime.commands)
|
||||
}
|
||||
}
|
||||
|
||||
// Defends novox/hq issue 615: the packages step's push is waited for until the node applied it, because
|
||||
// the sdk step right after asks the build agent for a build that reads the genesis document from the
|
||||
// file that apply writes. A push that returned on sending let the ask race the apply.
|
||||
func TestThePackagesStepWaitsUntilTheNodeAppliedTheGenesisDocument(t *testing.T) {
|
||||
t.Setenv("TMPDIR", t.TempDir())
|
||||
runtime, control := placing(false)
|
||||
o := Options{Node: "anchor", Wait: 3 * time.Minute}
|
||||
if err := deliverGenesisRegistry(context.Background(), o, control, genesisRegistry(DefaultPorts(), "pw"), quietly); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, c := range runtime.commands {
|
||||
if strings.Contains(c, "/mesh-controller push anchor") {
|
||||
if !strings.HasSuffix(c, "push anchor --wait 3m0s") {
|
||||
t.Fatalf("the genesis document's push does not wait for the node to apply it: %s", c)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatalf("the genesis document was never pushed: %v", runtime.commands)
|
||||
}
|
||||
|
||||
// Every other push of genesis still returns on sending: only a push whose content the next step reads on
|
||||
// the machine waits.
|
||||
func TestAnOrdinaryPushDoesNotWait(t *testing.T) {
|
||||
t.Setenv("TMPDIR", t.TempDir())
|
||||
runtime, control := placing(false)
|
||||
o := Options{Node: "anchor", Wait: time.Second}
|
||||
if _, err := installModule(context.Background(), o, control, BuilderModule, []byte(`{}`), quietly); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, c := range runtime.commands {
|
||||
if strings.Contains(c, "push anchor --wait") {
|
||||
t.Fatalf("an ordinary push waits: %s", c)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -58,8 +58,12 @@ func aMeshThatAgrees(answers map[string]string) func(string, []string) (string,
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case name == "sh" && len(args) == 2 && strings.Contains(args[1], "accounts.conf"):
|
||||
return "", nil // the bus's users placed after a push (issue 608)
|
||||
case name != "docker":
|
||||
return "", fmt.Errorf("unexpected program %q", name)
|
||||
case strings.HasSuffix(joined, "broker accounts"):
|
||||
return "accounts {}\n", nil
|
||||
case args[0] == "cp":
|
||||
return "", nil
|
||||
case args[0] == "container":
|
||||
|
||||
@@ -47,6 +47,9 @@ const (
|
||||
// StoreSuperuserFile is where the store's superuser password lives on the machine — the
|
||||
// postgres module's own-secret path, so genesis and adoption write the same file.
|
||||
StoreSuperuserFile = "/var/lib/postgres/superuser.secret"
|
||||
// StoreDataDir is where the store keeps its data — the postgres module's data directory, so
|
||||
// genesis raises the store where the module adopts it (novox/hq ADR 0338 §1).
|
||||
StoreDataDir = "/var/lib/mesh-store"
|
||||
// BusControllerFile is where the controller's password on the bus lives on the machine. Not a
|
||||
// module's path: no module reads it — the permanent controller is handed the address the
|
||||
// temporary one was given — and genesis keeps it only so a second run puts the same account
|
||||
@@ -62,7 +65,7 @@ const (
|
||||
// What the template says, matched exactly. A template that says something else is a template
|
||||
// this installer does not know how to make safe, and it says so rather than guessing.
|
||||
templateStorePassword = `"POSTGRES_PASSWORD": "bootstrap"`
|
||||
templateStoreVolumes = `"volumes": ["mesh-store-data:/var/lib/postgresql/data"]`
|
||||
templateStoreVolumes = `"volumes": ["` + StoreDataDir + `:/var/lib/postgresql/data"]`
|
||||
templateStoreURL = "postgres:bootstrap@"
|
||||
|
||||
// BusURLPlaceholder is the controller's account in its bus address, as the template names it.
|
||||
@@ -265,8 +268,8 @@ func Masking(say func(string), c RootCredentials) func(string) {
|
||||
// RefuseExistingServers stops a run that would put a made credential in front of a store raised
|
||||
// by an earlier installer with the template's.
|
||||
//
|
||||
// The store's password is set by initdb, once, on an empty volume. A machine that already holds
|
||||
// `mesh-store-data` and has no credential file was raised with `bootstrap`, and minting a new
|
||||
// The store's password is set by initdb, once, on an empty data directory. A machine that already holds
|
||||
// a cluster in the store's data directory and has no credential file was raised with `bootstrap`, and minting a new
|
||||
// value here would make a bundle that dials with a password the server does not have — failing
|
||||
// three steps later, in the schemas, with nothing pointing back here. Refused by name instead,
|
||||
// with the way forward.
|
||||
@@ -278,11 +281,14 @@ func RefuseExistingServers(ctx context.Context, run Runner, c RootCredentials) e
|
||||
if !c.StoreMade {
|
||||
return nil
|
||||
}
|
||||
if _, err := run(ctx, "docker", "volume", "inspect", "mesh-store-data"); err != nil {
|
||||
return nil // no such volume: a fresh machine, which is the case this installer makes
|
||||
// Where the store keeps its data, as the postgres module does (novox/hq ADR 0338 §2): a cluster
|
||||
// there is one initdb already set a password for. The docker volume genesis used before is not
|
||||
// the store's data any more, and a machine holding only that is not refused.
|
||||
if _, err := run(ctx, "test", "-e", StoreDataDir+"/pgdata/PG_VERSION"); err != nil {
|
||||
return nil // no cluster there: a fresh machine, which is the case this installer makes
|
||||
}
|
||||
return fmt.Errorf(
|
||||
"this machine already holds the store's data (volume mesh-store-data) and no credential at %s, so it was raised "+
|
||||
"this machine already holds the store's data (in "+StoreDataDir+") and no credential at %s, so it was raised "+
|
||||
"by an earlier installer with the template's password. A new one made here would not open it. "+
|
||||
"Put the password the store has into %s (0600, the value alone) and run again; then change it "+
|
||||
"on the server and accept the new value — this installer does not rotate a running store",
|
||||
@@ -311,7 +317,7 @@ func RewriteRoot(r *Rewritten, c RootCredentials) (RootRewrite, error) {
|
||||
return out, err
|
||||
}
|
||||
if bundle, err = replaceOnce(bundle, templateStoreVolumes,
|
||||
`"volumes": ["mesh-store-data:/var/lib/postgresql/data", "`+StoreSuperuserFile+":"+storeSuperuserMount+`:ro"]`,
|
||||
`"volumes": ["`+StoreDataDir+`:/var/lib/postgresql/data", "`+StoreSuperuserFile+":"+storeSuperuserMount+`:ro"]`,
|
||||
"the store's volumes"); err != nil {
|
||||
return out, err
|
||||
}
|
||||
|
||||
@@ -59,7 +59,7 @@ func TestTheProducedBundleCarriesNoWellKnownCredential(t *testing.T) {
|
||||
switch x := res.(type) {
|
||||
case *declaration.Container:
|
||||
switch x.Name {
|
||||
case "mesh-store":
|
||||
case "postgres":
|
||||
store = true
|
||||
if _, has := x.Env["POSTGRES_PASSWORD"]; has {
|
||||
t.Error("the store still takes its password from its environment")
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-host/internal/declaration"
|
||||
)
|
||||
|
||||
// Defends novox/hq ADR 0338 (issue 609): genesis raises its store as the postgres module's server, so
|
||||
// the store step adopts it as it runs. Until then the bundle raised `mesh-store`, on another image, in a
|
||||
// docker volume, and genesis stopped at step 17 of 24.
|
||||
|
||||
// theStoreGenesisRaises is the store container of the bundle as the installer applies it: rewritten for
|
||||
// this machine's credentials, so its password is the file the module reads too.
|
||||
func theStoreGenesisRaises(t *testing.T) *declaration.Container {
|
||||
t.Helper()
|
||||
r := producedBundle(t)
|
||||
store, err := storeIn(r.Declaration)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return store
|
||||
}
|
||||
|
||||
// The bundle's store is exactly the catalogue's postgres module's server, read from the module's own
|
||||
// manifest (ADR 0338 §1): its name, its image (so a drift of either pin fails here), where its data is,
|
||||
// and the file its superuser password is read from, with the environment that says so.
|
||||
func TestTheBundleRaisesTheStoreAsTheCataloguesPostgresModuleDeclaresIt(t *testing.T) {
|
||||
manifest := theCataloguesModule(t, "postgres")
|
||||
var m struct {
|
||||
Resources []struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Name string `json:"name"`
|
||||
Image string `json:"image"`
|
||||
Env map[string]string `json:"env"`
|
||||
Ports []string `json:"ports"`
|
||||
Volumes []string `json:"volumes"`
|
||||
} `json:"resources"`
|
||||
}
|
||||
if err := json.Unmarshal(manifest, &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ours := theStoreGenesisRaises(t)
|
||||
found := false
|
||||
for _, r := range m.Resources {
|
||||
if r.Type != "container" || r.ID != "server" {
|
||||
continue
|
||||
}
|
||||
found = true
|
||||
if ours.Name != r.Name {
|
||||
t.Errorf("the bundle raises the store as %q; the module's server is %q", ours.Name, r.Name)
|
||||
}
|
||||
if ours.Image != r.Image {
|
||||
t.Errorf("the bundle raises the store on %s; the module pins %s — the two pins move together", ours.Image, r.Image)
|
||||
}
|
||||
for _, key := range []string{"POSTGRES_PASSWORD_FILE", "PGDATA"} {
|
||||
if ours.Env[key] != r.Env[key] {
|
||||
t.Errorf("the bundle's store has %s=%q; the module's %q", key, ours.Env[key], r.Env[key])
|
||||
}
|
||||
}
|
||||
if _, has := ours.Env["POSTGRES_PASSWORD"]; has {
|
||||
t.Error("the bundle's store takes its password from its environment; the module reads a file")
|
||||
}
|
||||
// The module's own directory for its state is <data root>/<module>, where genesis keeps the
|
||||
// superuser's file (StoreSuperuserFile).
|
||||
var theirs []string
|
||||
for _, v := range r.Volumes {
|
||||
theirs = append(theirs, strings.ReplaceAll(v, "${dir:state}", "/var/lib/postgres"))
|
||||
}
|
||||
if strings.Join(ours.Volumes, " ") != strings.Join(theirs, " ") {
|
||||
t.Errorf("the bundle's store mounts %v; the module's %v", ours.Volumes, theirs)
|
||||
}
|
||||
if strings.Join(ours.Ports, " ") != strings.Join(r.Ports, " ") {
|
||||
t.Errorf("the bundle publishes the store on %v; the module on %v", ours.Ports, r.Ports)
|
||||
}
|
||||
// And the store step's own check agrees: by name and image, before anything is registered.
|
||||
if err := serverMatchesFoundation(manifest, ours, "postgres"); err != nil {
|
||||
t.Errorf("the store step would refuse the store the bundle raises: %v", err)
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("the postgres module declares no server container")
|
||||
}
|
||||
}
|
||||
|
||||
// The store's data directory is made by the bundle before the store starts, owned by the image's
|
||||
// postgres user, as the module declares it (ADR 0338 §1, and the measurement under its §5).
|
||||
func TestTheBundleMakesTheStoresDataDirectoryAsTheModuleDoes(t *testing.T) {
|
||||
r := producedBundle(t)
|
||||
at, store := -1, -1
|
||||
for i, res := range r.Declaration.Resources {
|
||||
if d, ok := res.(*declaration.Directory); ok && d.Path == StoreDataDir {
|
||||
at = i
|
||||
if d.Mode != "0700" || d.Owner != "999:70" {
|
||||
t.Errorf("the store's data directory is made %s %s; the module makes it 0700 999:70", d.Mode, d.Owner)
|
||||
}
|
||||
}
|
||||
if res.Identity() == StoreID {
|
||||
store = i
|
||||
}
|
||||
}
|
||||
if at < 0 || store < 0 || at > store {
|
||||
t.Fatalf("the store's data directory %s is not made before the store (directory at %d, store at %d)", StoreDataDir, at, store)
|
||||
}
|
||||
}
|
||||
|
||||
// No step of the bundle and no step of the installer names `mesh-store`, which is the seat's name only
|
||||
// (ADR 0338 §2): every step that reaches the store reaches the container the bundle raised.
|
||||
func TestNoStepReachesTheStoreByTheSeatsName(t *testing.T) {
|
||||
text := string(theRealBundle(t))
|
||||
for _, by := range []string{`"in": "mesh-store"`, `container:mesh-store`, `exec mesh-store`, `"name": "mesh-store"`, `mesh-store-data`} {
|
||||
if strings.Contains(text, by) {
|
||||
t.Errorf("the bundle still reaches the store as %s", by)
|
||||
}
|
||||
}
|
||||
store := theStoreGenesisRaises(t)
|
||||
if foundationStore != store.Name {
|
||||
t.Errorf("the packages step reaches the store as %q; the bundle raises it as %q", foundationStore, store.Name)
|
||||
}
|
||||
r := producedBundle(t)
|
||||
for _, res := range r.Declaration.Resources {
|
||||
if a, ok := res.(*declaration.Action); ok && a.In != "" && a.In != store.Name && strings.Contains(a.ID, "database") {
|
||||
t.Errorf("the step %s reaches the store in %q", a.ID, a.In)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The installer's refusal to raise a store over data it cannot open looks where the store keeps its data
|
||||
// (ADR 0338 §2): a cluster there and no credential is refused; the old docker volume alone is not.
|
||||
func TestTheRefusalOverExistingDataLooksInTheStoresDirectory(t *testing.T) {
|
||||
made := RootCredentials{Store: "s", Bus: "b", StoreMade: true}
|
||||
machine := func(cluster, volume bool) Runner {
|
||||
return func(_ context.Context, name string, args ...string) (string, error) {
|
||||
joined := name + " " + strings.Join(args, " ")
|
||||
switch {
|
||||
case joined == "test -e "+StoreDataDir+"/pgdata/PG_VERSION":
|
||||
if cluster {
|
||||
return "", nil
|
||||
}
|
||||
case strings.HasPrefix(joined, "docker volume inspect"):
|
||||
if volume {
|
||||
return "[]", nil
|
||||
}
|
||||
}
|
||||
return "", errors.New("exit status 1")
|
||||
}
|
||||
}
|
||||
err := RefuseExistingServers(context.Background(), machine(true, false), made)
|
||||
if err == nil || !strings.Contains(err.Error(), StoreDataDir) {
|
||||
t.Fatalf("a cluster in %s and no credential was not refused naming it: %v", StoreDataDir, err)
|
||||
}
|
||||
if err := RefuseExistingServers(context.Background(), machine(false, true), made); err != nil {
|
||||
t.Fatalf("a machine holding only the old volume was refused: %v", err)
|
||||
}
|
||||
if err := RefuseExistingServers(context.Background(), machine(true, false), RootCredentials{Store: "s"}); err != nil {
|
||||
t.Fatalf("a kept credential was refused: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -89,7 +89,7 @@ func TestAFoundationThatIsUpAndAnsweringIsAccepted(t *testing.T) {
|
||||
// run-once and scheduled shapes are excluded on purpose — a step that has exited is not a
|
||||
// fault. The name is `temp-mesh-controller` because the permanent one is a module and takes the
|
||||
// plain name (novox/hq ADR 0067), which is what makes the two of them coexist at all.
|
||||
want := []string{"mesh-store", "mesh-broker-nats", "temp-mesh-controller"}
|
||||
want := []string{"postgres", "mesh-broker-nats", "temp-mesh-controller"}
|
||||
if len(verified.Running) != len(want) {
|
||||
t.Fatalf("confirmed %v running, want %v", verified.Running, want)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
package outward
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/netip"
|
||||
"sort"
|
||||
)
|
||||
|
||||
// Network is one network this machine is directly attached to, and the link it is on (novox/hq ADR
|
||||
// 0340 §7): the address and prefix of the network, not of the machine's own address on it.
|
||||
//
|
||||
// **Every one is said, and the machine judges none of them.** Which count as the machine's local
|
||||
// network — private ranges only, never a public subnet, never a link-local one — is the controller's
|
||||
// judgement (ADR 0340 §2), made where the filter is composed and shown with why; a machine that left a
|
||||
// network out would make that judgement silently.
|
||||
type Network struct {
|
||||
Link string `json:"link"`
|
||||
Network string `json:"network"`
|
||||
}
|
||||
|
||||
// AddressesOf is the addresses on one link, as address and prefix. A parameter so a test can hold a
|
||||
// machine's links without one.
|
||||
type AddressesOf func(link string) ([]netip.Prefix, error)
|
||||
|
||||
// RealAddresses reads one link's addresses from the kernel.
|
||||
func RealAddresses(link string) ([]netip.Prefix, error) {
|
||||
iface, err := net.InterfaceByName(link)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
addrs, err := iface.Addrs()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []netip.Prefix
|
||||
for _, a := range addrs {
|
||||
ipnet, ok := a.(*net.IPNet)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
addr, ok := netip.AddrFromSlice(ipnet.IP)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
bits, _ := ipnet.Mask.Size()
|
||||
out = append(out, netip.PrefixFrom(addr.Unmap(), bits))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Networks is the networks on each of these links — the links that face outside (Links) — sorted by link
|
||||
// and network, without repeats. A link that has gone since Links read it has no networks rather than
|
||||
// failing the rest; a link whose addresses cannot be read fails, so the machine says the fact is unknown
|
||||
// rather than reporting a partial account as the whole.
|
||||
func Networks(links []string, addressesOf AddressesOf) ([]Network, error) {
|
||||
if addressesOf == nil {
|
||||
addressesOf = RealAddresses
|
||||
}
|
||||
seen := map[Network]bool{}
|
||||
var out []Network
|
||||
for _, link := range links {
|
||||
prefixes, err := addressesOf(link)
|
||||
if err != nil {
|
||||
if isNoSuchInterface(err) {
|
||||
continue
|
||||
}
|
||||
return nil, fmt.Errorf("cannot read the addresses on %s: %w", link, err)
|
||||
}
|
||||
for _, p := range prefixes {
|
||||
n := Network{Link: link, Network: p.Masked().String()}
|
||||
if !seen[n] {
|
||||
seen[n] = true
|
||||
out = append(out, n)
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
if out[i].Link != out[j].Link {
|
||||
return out[i].Link < out[j].Link
|
||||
}
|
||||
return out[i].Network < out[j].Network
|
||||
})
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// isNoSuchInterface is the error net.InterfaceByName gives for a link that is not there.
|
||||
func isNoSuchInterface(err error) bool {
|
||||
var op *net.OpError
|
||||
return errors.As(err, &op) && op.Err != nil && op.Err.Error() == "no such network interface"
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package outward
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/netip"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func prefixes(s ...string) []netip.Prefix {
|
||||
var out []netip.Prefix
|
||||
for _, p := range s {
|
||||
out = append(out, netip.MustParsePrefix(p))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// **Each link facing outside says the networks it is attached to** (novox/hq ADR 0340 §7), as the
|
||||
// network's address and prefix, never the machine's own address on it — and every one, public and
|
||||
// link-local included, because which count is the controller's judgement and said with why.
|
||||
func TestEachOutwardLinkSaysTheNetworksItIsOn(t *testing.T) {
|
||||
addrs := map[string][]netip.Prefix{
|
||||
"eth0": prefixes("192.168.1.10/24", "fe80::1/64", "fd12:3456:789a::5/64"),
|
||||
"eth1": prefixes("203.0.113.7/24"),
|
||||
// Not asked: the bridge is not a link facing outside.
|
||||
"docker0": prefixes("172.17.0.1/16"),
|
||||
}
|
||||
got, err := Networks([]string{"eth1", "eth0"}, func(link string) ([]netip.Prefix, error) { return addrs[link], nil })
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []Network{
|
||||
{Link: "eth0", Network: "192.168.1.0/24"},
|
||||
{Link: "eth0", Network: "fd12:3456:789a::/64"},
|
||||
{Link: "eth0", Network: "fe80::/64"},
|
||||
{Link: "eth1", Network: "203.0.113.0/24"},
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("got %+v\nwant %+v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// A link whose addresses cannot be read fails the whole, so the machine says the fact is unknown rather
|
||||
// than reporting part of its networks as all of them.
|
||||
func TestAnUnreadableLinkIsNotAPartialAccount(t *testing.T) {
|
||||
_, err := Networks([]string{"eth0"}, func(string) ([]netip.Prefix, error) { return nil, errors.New("denied") })
|
||||
if err == nil {
|
||||
t.Fatal("an unreadable link was reported as having no networks")
|
||||
}
|
||||
}
|
||||
|
||||
// A link gone between reading the routing table and reading its addresses has no networks, and the
|
||||
// rest are still said.
|
||||
func TestALinkGoneSinceItWasListedHasNoNetworks(t *testing.T) {
|
||||
got, err := Networks([]string{"gone0"}, RealAddresses)
|
||||
if err != nil || len(got) != 0 {
|
||||
t.Fatalf("got %v, %v", got, err)
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,8 @@ import (
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-host/internal/outward"
|
||||
)
|
||||
|
||||
// Machine is what this machine is made of, as it says itself (novox/hq issue 160): what somebody
|
||||
@@ -50,6 +52,11 @@ type Machine struct {
|
||||
// Filesystems is each local filesystem mounted, by where, with its size and what is free.
|
||||
Filesystems []Filesystem `json:"filesystems,omitempty"`
|
||||
|
||||
// Networks is every network the machine is directly attached to on a link facing outside it, as the
|
||||
// link and the network's address and prefix (novox/hq ADR 0340 §7): what a port reached from
|
||||
// `local-network` is resolved from. Every one is said; which count is the controller's judgement.
|
||||
Networks []outward.Network `json:"networks,omitempty"`
|
||||
|
||||
// Unknown names each fact that could not be determined, and why.
|
||||
Unknown map[string]string `json:"unknown,omitempty"`
|
||||
}
|
||||
@@ -79,11 +86,12 @@ const (
|
||||
FactTimezone = "timezone"
|
||||
FactFilesystems = "filesystems"
|
||||
FactPublicAddress = "public address"
|
||||
FactNetworks = "networks"
|
||||
)
|
||||
|
||||
// MachineFacts is every fact Unknown may name, in the order a reader shows them.
|
||||
var MachineFacts = []string{FactKernelRelease, FactDistribution, FactVersion, FactProcessor, FactCores,
|
||||
FactThreads, FactMemory, FactVirtual, FactUptime, FactTimezone, FactFilesystems, FactPublicAddress}
|
||||
FactThreads, FactMemory, FactVirtual, FactUptime, FactTimezone, FactFilesystems, FactNetworks, FactPublicAddress}
|
||||
|
||||
// publicAddressUnknown is why the public uplink address is not said. A machine behind a NAT cannot see
|
||||
// its public address from itself, and asking a service outside the mesh is a dependency the mesh does not
|
||||
@@ -101,11 +109,24 @@ type Sources struct {
|
||||
// Statfs is (size, free) in bytes of the filesystem mounted at a path.
|
||||
Statfs func(path string) (size, free int64, err error)
|
||||
Now func() time.Time
|
||||
// Networks is the networks on the links facing outside this machine (novox/hq ADR 0340).
|
||||
Networks func() ([]outward.Network, error)
|
||||
}
|
||||
|
||||
// RealSources reads this machine.
|
||||
func RealSources() Sources {
|
||||
return Sources{Read: os.ReadFile, Readlink: os.Readlink, Run: ExecRunner, Statfs: statfs, Now: time.Now}
|
||||
return Sources{Read: os.ReadFile, Readlink: os.Readlink, Run: ExecRunner, Statfs: statfs, Now: time.Now,
|
||||
Networks: attachedNetworks}
|
||||
}
|
||||
|
||||
// attachedNetworks is this machine's networks on the links that face outside it, read the way the report
|
||||
// reads those links (novox/hq ADR 0140), so the two never disagree about which links those are.
|
||||
func attachedNetworks() ([]outward.Network, error) {
|
||||
links, err := outward.Links("", "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return outward.Networks(links, nil)
|
||||
}
|
||||
|
||||
func statfs(path string) (int64, int64, error) {
|
||||
@@ -193,6 +214,18 @@ func DescribeMachine(ctx context.Context, src Sources) Machine {
|
||||
}
|
||||
}
|
||||
|
||||
// The networks it is attached to, for the local network (novox/hq ADR 0340): every one on a link facing
|
||||
// outside, or why none is said — never an empty list that reads as a machine on no network.
|
||||
if src.Networks == nil {
|
||||
unknown(FactNetworks, "this node-engine was given no way to read its links")
|
||||
} else if networks, err := src.Networks(); err != nil {
|
||||
unknown(FactNetworks, err.Error())
|
||||
} else if len(networks) == 0 {
|
||||
unknown(FactNetworks, "no link facing outside this machine carries an address")
|
||||
} else {
|
||||
m.Networks = networks
|
||||
}
|
||||
|
||||
unknown(FactPublicAddress, publicAddressUnknown)
|
||||
return m
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ import (
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-host/internal/outward"
|
||||
)
|
||||
|
||||
// The parsing layer, with the machine's files given (novox/hq ADR 0017 allows a fake here and only here);
|
||||
@@ -40,6 +42,9 @@ func given(read map[string]string) Sources {
|
||||
Run: func(context.Context, string, ...string) (string, error) { return "none\n", errors.New("exited 1") },
|
||||
Statfs: func(p string) (int64, int64, error) { return 1000, 400, nil },
|
||||
Now: func() time.Time { return time.Date(2026, 10, 12, 15, 0, 0, 0, time.UTC) },
|
||||
Networks: func() ([]outward.Network, error) {
|
||||
return []outward.Network{{Link: "eth0", Network: "192.168.1.0/24"}, {Link: "eth1", Network: "203.0.113.0/24"}}, nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,6 +97,7 @@ func TestMachine_aFactItCannotReadIsSaidUnknownNeverZero(t *testing.T) {
|
||||
src := given(map[string]string{})
|
||||
src.Readlink = func(string) (string, error) { return "", os.ErrNotExist }
|
||||
src.Run = func(context.Context, string, ...string) (string, error) { return "", errors.New("not found") }
|
||||
src.Networks = func() ([]outward.Network, error) { return nil, errors.New("cannot read the routing table") }
|
||||
m := DescribeMachine(context.Background(), src)
|
||||
|
||||
raw, _ := json.Marshal(m)
|
||||
@@ -183,3 +189,26 @@ func TestAgainstThisMachine_machineSaysWhatItIs(t *testing.T) {
|
||||
raw, _ := json.MarshalIndent(m, "", " ")
|
||||
t.Logf("%s", raw)
|
||||
}
|
||||
|
||||
// **The networks it is attached to are among its facts** (novox/hq ADR 0340 §7), every one — the public
|
||||
// one too, which the controller judges and never counts — and a machine with none on a link facing
|
||||
// outside says why rather than reporting an empty list.
|
||||
func TestMachine_saysTheNetworksItIsAttachedTo(t *testing.T) {
|
||||
m := DescribeMachine(context.Background(), given(files))
|
||||
raw, _ := json.Marshal(m)
|
||||
var said struct {
|
||||
Networks []map[string]string `json:"networks"`
|
||||
}
|
||||
_ = json.Unmarshal(raw, &said)
|
||||
if len(said.Networks) != 2 || said.Networks[0]["link"] != "eth0" || said.Networks[0]["network"] != "192.168.1.0/24" ||
|
||||
said.Networks[1]["network"] != "203.0.113.0/24" {
|
||||
t.Fatalf("networks said as %s", raw)
|
||||
}
|
||||
|
||||
src := given(files)
|
||||
src.Networks = func() ([]outward.Network, error) { return nil, nil }
|
||||
m = DescribeMachine(context.Background(), src)
|
||||
if len(m.Networks) != 0 || !strings.Contains(m.Unknown[FactNetworks], "no link facing outside") {
|
||||
t.Fatalf("a machine with no network said %v, unknown %q", m.Networks, m.Unknown[FactNetworks])
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user