The move mints every credential and tells each machine its membership
`rollout mint` gives every principal the new bus will have a credential it does not yet have and puts each where its owner reads it: a machine's as a membership — bus address, fingerprint, password, transport — sealed into its declaration (migration 0041, the `bus-membership` resource the host reads after applying); a module's as its broker secret, through the same delivery `module issue` uses; the control plane's own as its `bus` secret. Idempotent, and worked out from where the bus's module is assigned rather than from this process's environment, because this process is still on the old bus when it runs and must be. This is the half of design 28 task 5.2 the first live attempt found missing: a credential was minted only at enrolment, at `module issue` and for a person, so no machine already enrolled could ever be moved. `rollout check` was right to refuse; now there is something to run first.
This commit is contained in:
@@ -103,6 +103,11 @@ type Rendering struct {
|
||||
// **Only the users, never the server's own settings**: those are the module's, in its image and
|
||||
// its mounts (Manifest.BusUsers).
|
||||
BusUsers string
|
||||
// BusMembership is this machine's membership for the bus the mesh is moving to, sealed to it
|
||||
// (design 28, task 5.2). Empty for a machine not being moved. Written as a file the host reads
|
||||
// after the declaration has applied, so the bus it names is standing before the machine leaves
|
||||
// the one it is on.
|
||||
BusMembership string
|
||||
|
||||
// MeshRange is the private network's CIDR (the range node addresses are allocated from), for a
|
||||
// module that must name the whole mesh rather than one machine — an intrusion filter that must
|
||||
@@ -238,9 +243,23 @@ func (r Resolution) Compose(with Rendering) (Composed, error) {
|
||||
if err != nil {
|
||||
return Composed{}, err
|
||||
}
|
||||
if with.BusMembership != "" {
|
||||
// The machine's own, not any module's: how it reaches the mesh from now on. Sealed like a
|
||||
// secret and placed where the host looks for exactly this (design 28, task 5.2).
|
||||
resources = append(resources, map[string]any{
|
||||
"id": BusMembershipID(), "type": "file", "path": BusMembershipPath,
|
||||
"sealed": with.BusMembership, "mode": "0600",
|
||||
})
|
||||
}
|
||||
return Composed{Resources: resources, Owner: owner}, nil
|
||||
}
|
||||
|
||||
// BusMembershipID names the resource carrying a machine's membership for the new bus, and
|
||||
// BusMembershipPath is where the host reads it — the same constant on both sides.
|
||||
func BusMembershipID() string { return "bus-membership" }
|
||||
|
||||
const BusMembershipPath = "/var/lib/mesh/membership-next.json"
|
||||
|
||||
func (r Resolution) compose(with Rendering, owner map[string]string) ([]map[string]any, error) {
|
||||
// Every manifest is placed first (novox/hq ADR 0112): the maps naming where its bindings,
|
||||
// credentials and contributions land are resolved against this node's directories, so every
|
||||
|
||||
@@ -114,3 +114,32 @@ func TestCanHoldJudgesClaimScopeAndWhatTheSeatDelivers(t *testing.T) {
|
||||
t.Fatalf("with the row saying amqp, an amqp provider was refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A machine being moved is handed its membership for the new bus as a sealed file in its own
|
||||
// declaration — the machine's, not any module's (design 28, task 5.2).
|
||||
func TestAMembershipForTheNewBusIsComposedAsASealedFile(t *testing.T) {
|
||||
r := Resolution{Node: "anchor"}
|
||||
got, err := r.Compose(Rendering{BusMembership: "sealed-blob"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var found map[string]any
|
||||
for _, res := range got.Resources {
|
||||
if res["id"] == BusMembershipID() {
|
||||
found = res
|
||||
}
|
||||
}
|
||||
if found == nil {
|
||||
t.Fatalf("no membership resource in %v", got.Resources)
|
||||
}
|
||||
if found["path"] != BusMembershipPath || found["sealed"] != "sealed-blob" || found["mode"] != "0600" {
|
||||
t.Fatalf("the membership is not a sealed 0600 file where the host reads it: %v", found)
|
||||
}
|
||||
// And a machine not being moved is handed nothing.
|
||||
got, _ = r.Compose(Rendering{})
|
||||
for _, res := range got.Resources {
|
||||
if res["id"] == BusMembershipID() {
|
||||
t.Fatal("a machine with no membership on record was handed one")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -230,3 +230,35 @@ func (i *Inventory) ForgetPerson(ctx context.Context, name string) error {
|
||||
}
|
||||
return i.ForgetBusUser(ctx, "person."+name)
|
||||
}
|
||||
|
||||
// PutBusMembership records a machine's membership for the new bus, sealed to it (design 28, 5.2).
|
||||
// Replaces any earlier one: a machine has one membership per bus, and re-minting is re-telling.
|
||||
func (i *Inventory) PutBusMembership(ctx context.Context, nodeName, sealed string) error {
|
||||
node, err := i.NodeByName(ctx, nodeName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = i.store.Pool().Exec(ctx,
|
||||
`insert into bus_membership (node, sealed) values ($1, $2)
|
||||
on conflict (node) do update set sealed = excluded.sealed, since = now()`, node.ID, sealed)
|
||||
return err
|
||||
}
|
||||
|
||||
// BusMemberships is every machine's sealed membership for the new bus, by node name.
|
||||
func (i *Inventory) BusMemberships(ctx context.Context) (map[string]string, error) {
|
||||
rows, err := i.store.Pool().Query(ctx,
|
||||
`select n.name, b.sealed from bus_membership b join node n on n.id = b.node`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := map[string]string{}
|
||||
for rows.Next() {
|
||||
var name, sealed string
|
||||
if err := rows.Scan(&name, &sealed); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[name] = sealed
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
@@ -80,3 +80,20 @@ func TestUnassigningTheHolderTakesTheHoldingWithIt(t *testing.T) {
|
||||
t.Fatalf("the holding outlived the assignment it pointed at: %+v", held)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAMachinesMembershipIsOneRowReplacedAndGoesWithTheMachine(t *testing.T) {
|
||||
inv, ctx := twoBrokersOnTwoNodes(t)
|
||||
if err := inv.PutBusMembership(ctx, "anchor", "first"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := inv.PutBusMembership(ctx, "anchor", "second"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := inv.BusMemberships(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got["anchor"] != "second" || len(got) != 1 {
|
||||
t.Fatalf("a re-told membership did not replace the first: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
-- A machine already enrolled is moved to the new bus by being told its membership for it
|
||||
-- (novox/hq design 28, task 5.2). Until this, a membership — bus address, fingerprint, password,
|
||||
-- transport — existed only in the enrolment reply, and nothing could hand one to a machine that
|
||||
-- had already joined. The row is the membership sealed to that machine, composed into its
|
||||
-- declaration as a file it reads after applying; the plaintext exists once, at minting, and then
|
||||
-- only on the machine. One per node: the mesh moves to one bus.
|
||||
create table bus_membership (
|
||||
node uuid primary key references node(id) on delete cascade,
|
||||
sealed text not null,
|
||||
since timestamptz not null default now()
|
||||
);
|
||||
Reference in New Issue
Block a user