Merge main: the trunk renamed the seats and made them data
Both branches changed the seat set from the same starting point, so every number collided and every `mesh-*` name existed twice. The trunk's numbers and names win: this branch's records became 0129/0130 and its migrations 0037/0038, and the hardcoded rename map gave way to the trunk's `seat_alias` table — a rename is a row now (ADR 0122), not a recompile. Three of my checks were wrong and the merge is what showed it: A seat with an empty protocol is a marker, not an incomplete declaration. Most node-scoped seats are markers — which module is this machine's packet filter — and refusing one refused most of the set, the showcase module included. A mistyped field name is already refused by the parser, so an empty protocol was written as one deliberately. A claim on a seat this manifest does not declare is not the parser's to judge. A module may hold a seat another module declared; that is the whole reason ADR 0126 has callers name the seat and not its provider. Whether the seat exists is a fact about the catalogue, so the refusal is at registration, where every declaration is in view. And a seat may share a name with the provision it delivers. `git`, the npm registry and the artifact store still do, because renaming a delivering seat cascades to every consumer requiring it, with a window where a holder stops resolving mid-flight. The trunk deferred exactly those three on purpose. Full suite green against a real NATS and store.
This commit is contained in:
@@ -69,6 +69,17 @@ func (i *Inventory) AssignAddress(ctx context.Context, node, cidr string) (strin
|
||||
if key != nil && p.PublicKey == *key {
|
||||
// The tunnel already routes to this key: the node keeps that address, and the peer
|
||||
// notices nothing when its machine enrols.
|
||||
//
|
||||
// **Unless the operator named it something else** (novox/hq issue 112). The name is
|
||||
// what the mesh has been answering for this address in the meantime; a machine
|
||||
// enrolling under a different one would silently split the two — the name resolving
|
||||
// here, the node known as that — so it is refused where the operator can read it.
|
||||
if p.Named != "" && p.Named != name {
|
||||
return "", fmt.Errorf(
|
||||
"the carried peer at %s was named %q, and %q is enrolling under its key — "+
|
||||
"enrol it as %q, or rename the peer first (`overlay name`)",
|
||||
p.Address, p.Named, name, p.Named)
|
||||
}
|
||||
return i.place(ctx, node, p.Address)
|
||||
}
|
||||
taken[p.Address] = true
|
||||
|
||||
@@ -65,7 +65,7 @@ func TestTakingIsRefusedOnAConvergedNodeAndForAnUnassignedModule(t *testing.T) {
|
||||
if _, err := inv.AddNode(t.Context(), "converged"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := inv.Assign(t.Context(), "converged", "hello-web"); err != nil {
|
||||
if _, err := inv.Assign(t.Context(), "converged", "hello-web"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := inv.Take(t.Context(), "converged", "hello-web"); !errors.Is(err, ErrNotAdopted) {
|
||||
@@ -91,7 +91,7 @@ func TestATakenModuleOutlivesItsAssignmentAndReturningToAdopted(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, m := range []string{"hello-web", "postgres"} {
|
||||
if err := inv.Assign(t.Context(), "anchor", m); err != nil {
|
||||
if _, err := inv.Assign(t.Context(), "anchor", m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ func (i *Inventory) BusRecords(ctx context.Context) (broker.Records, error) {
|
||||
// one module and held by another, which is the whole reason a seat exists (ADR 0118).
|
||||
seats := map[string]catalogue.SeatDeclaration{}
|
||||
for _, m := range declared {
|
||||
for _, s := range m.Seats {
|
||||
for _, s := range m.DefinesSeats {
|
||||
seats[s.Name] = s
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ func aMeshWith(t *testing.T, manifests ...catalogue.Manifest) (*Inventory, conte
|
||||
func theSeatDeclarer() catalogue.Manifest {
|
||||
return catalogue.Manifest{
|
||||
Module: "telegram", Version: "1",
|
||||
Seats: []catalogue.SeatDeclaration{{
|
||||
DefinesSeats: []catalogue.SeatDeclaration{{
|
||||
Name: "telegram-sender", Accepts: []string{"send"}, Emits: []string{"delivered"},
|
||||
}},
|
||||
Claims: []catalogue.Claim{{Name: "telegram-sender", Scope: catalogue.ScopeMesh}},
|
||||
@@ -50,7 +50,7 @@ func TestAnAssignedModuleBecomesAUserWithWhatItDeclared(t *testing.T) {
|
||||
if _, err := inv.AddNode(ctx, "one"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := inv.Assign(ctx, "one", "shop"); err != nil {
|
||||
if _, err := inv.Assign(ctx, "one", "shop"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
|
||||
@@ -430,27 +430,36 @@ func (i *Inventory) discard(ctx context.Context, name string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Assign puts a module on a node.
|
||||
// Assign puts a module on a node, and says whether that is new.
|
||||
//
|
||||
// Records the intention and checks nothing. Whether the set of assignments can actually become a
|
||||
// declaration is resolution's question, asked over the whole set at once — and asking it here,
|
||||
// one module at a time, would let an assignment look accepted and then refuse when a second
|
||||
// arrives.
|
||||
func (i *Inventory) Assign(ctx context.Context, nodeName, module string) error {
|
||||
//
|
||||
// **One assignment of a module per node is the rule, not a race lost** (novox/hq ADR 0115). The
|
||||
// module's name is the assignment's identity — its database user, its broker account, its
|
||||
// containers and its placed directory are all named by it — so the schema's (node, module) key
|
||||
// is the decision, and a repeat is absorbed rather than refused. Absorbed audibly: the caller is
|
||||
// told nothing changed, because "is assigned" printed for a no-op reads as an action.
|
||||
func (i *Inventory) Assign(ctx context.Context, nodeName, module string) (bool, error) {
|
||||
node, err := i.NodeByName(ctx, nodeName)
|
||||
if err != nil {
|
||||
return err
|
||||
return false, err
|
||||
}
|
||||
if err := i.runsSomewhere(ctx, module); err != nil {
|
||||
return err
|
||||
return false, err
|
||||
}
|
||||
_, err = i.store.Pool().Exec(ctx,
|
||||
tag, err := i.store.Pool().Exec(ctx,
|
||||
`insert into assignment (node, module) values ($1, $2) on conflict do nothing`,
|
||||
node.ID, module)
|
||||
if err != nil && strings.Contains(err.Error(), "assignment_module_fkey") {
|
||||
return fmt.Errorf("%w: %s", ErrNoSuchModule, module)
|
||||
return false, fmt.Errorf("%w: %s", ErrNoSuchModule, module)
|
||||
}
|
||||
return err
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return tag.RowsAffected() > 0, nil
|
||||
}
|
||||
|
||||
// Unassign takes a module off a node.
|
||||
|
||||
@@ -77,7 +77,7 @@ func TestAModuleAMachineIsRunningCannotBeForgotten(t *testing.T) {
|
||||
if err := inv.RegisterModule(t.Context(), manifest("thing", nil, nil), Source{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := inv.Assign(t.Context(), "laptop", "thing"); err != nil {
|
||||
if _, err := inv.Assign(t.Context(), "laptop", "thing"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -104,7 +104,7 @@ func TestRemovingANodeTakesItsAssignments(t *testing.T) {
|
||||
if err := inv.RegisterModule(t.Context(), manifest("thing", nil, nil), Source{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := inv.Assign(t.Context(), "laptop", "thing"); err != nil {
|
||||
if _, err := inv.Assign(t.Context(), "laptop", "thing"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := inv.store.Pool().Exec(t.Context(), `delete from node where id = $1`, node.ID); err != nil {
|
||||
@@ -136,14 +136,16 @@ func TestAssigningAModuleTheMeshDoesNotKnowIsRefused(t *testing.T) {
|
||||
if _, err := inv.AddNode(t.Context(), "laptop"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err := inv.Assign(t.Context(), "laptop", "not-a-module")
|
||||
_, err := inv.Assign(t.Context(), "laptop", "not-a-module")
|
||||
if !errors.Is(err, ErrNoSuchModule) {
|
||||
t.Fatalf("assigning an unknown module gave %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssigningTwiceIsNotAnError(t *testing.T) {
|
||||
// It is a statement of what should be true, and it already is.
|
||||
func TestAssigningTwiceIsNotAnErrorAndSaysSo(t *testing.T) {
|
||||
// It is a statement of what should be true, and it already is — one assignment of a module
|
||||
// per node is the rule (novox/hq ADR 0115), so a repeat is absorbed, audibly: the caller is
|
||||
// told nothing was new.
|
||||
inv := fresh(t)
|
||||
if _, err := inv.AddNode(t.Context(), "laptop"); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -151,10 +153,18 @@ func TestAssigningTwiceIsNotAnError(t *testing.T) {
|
||||
if err := inv.RegisterModule(t.Context(), manifest("thing", nil, nil), Source{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := 0; i < 3; i++ {
|
||||
if err := inv.Assign(t.Context(), "laptop", "thing"); err != nil {
|
||||
first, err := inv.Assign(t.Context(), "laptop", "thing")
|
||||
if err != nil || !first {
|
||||
t.Fatalf("the first assignment is the new one; got fresh=%v err=%v", first, err)
|
||||
}
|
||||
for i := 0; i < 2; i++ {
|
||||
again, err := inv.Assign(t.Context(), "laptop", "thing")
|
||||
if err != nil {
|
||||
t.Fatalf("assigning again failed: %v", err)
|
||||
}
|
||||
if again {
|
||||
t.Fatal("a repeat must say nothing was new")
|
||||
}
|
||||
}
|
||||
assigned, err := inv.Assigned(t.Context(), "laptop")
|
||||
if err != nil {
|
||||
@@ -277,7 +287,7 @@ func TestBeingBehindNamesTheMachinesRunningTheOldOne(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, n := range []string{"laptop", "workstation"} {
|
||||
if err := inv.Assign(t.Context(), n, "thing"); err != nil {
|
||||
if _, err := inv.Assign(t.Context(), n, "thing"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -451,7 +461,7 @@ func TestTheCatalogueSaysWhereEachModuleCameFromAndWhoRunsIt(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, n := range []string{"workstation", "laptop"} {
|
||||
if err := inv.Assign(ctx, n, "shell"); err != nil {
|
||||
if _, err := inv.Assign(ctx, n, "shell"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -212,7 +212,7 @@ func TestAModuleStillAssignedRefusesBeforeAnythingAboutWhatItHolds(t *testing.T)
|
||||
if err := inv.SetSettings(ctx, "anchor", "step-ca", map[string]any{"a": 1}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := inv.Assign(ctx, "anchor", "step-ca"); err != nil {
|
||||
if _, err := inv.Assign(ctx, "anchor", "step-ca"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, forget := range []func() error{
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
-- A carried peer may be named before it enrols (novox/hq issue 112).
|
||||
--
|
||||
-- The tunnel the hub took over routes to machines the predecessor knows by name and the mesh
|
||||
-- knows only by address. A name the predecessor answers for must keep resolving until the
|
||||
-- machine behind it is a node — so the operator may state which machine a carried address is,
|
||||
-- and everything derived from "the machines the mesh knows" (a container's hosts, the hosts
|
||||
-- fact, the resolver) answers for it in the meantime. The mesh records the statement as the
|
||||
-- operator's, unverified: enrolment is what verifies it, and enrolling under a different name
|
||||
-- than the one stated is refused rather than silently renamed.
|
||||
alter table tunnel_peer add column named text;
|
||||
@@ -0,0 +1,17 @@
|
||||
-- The seats are data the control plane owns, not a slice compiled into it (novox/hq ADR 0122).
|
||||
--
|
||||
-- Until this, the closed set 0110 defines lived only as a Go slice, referenced by name everywhere,
|
||||
-- so renaming a seat or adding one meant a controller rebuild and a mesh-wide, freeze-prone deploy.
|
||||
-- The set is now a table: one row per seat, seeded from the binary's defaults the first time the
|
||||
-- control plane comes up, and thereafter the live copy the control plane reads and an operator can
|
||||
-- change. A rename becomes an update here rather than a release.
|
||||
--
|
||||
-- The name is the key for now, because claims and held records still reference a seat by name; the
|
||||
-- move to a stable id that a rename does not touch is the next step (ADR 0122). `delivers` is empty
|
||||
-- for a seat that answers for no provision, matching the compiled default.
|
||||
create table seat (
|
||||
name text primary key,
|
||||
scope text not null,
|
||||
delivers text not null default '',
|
||||
decided text not null
|
||||
);
|
||||
@@ -0,0 +1,12 @@
|
||||
-- A seat keeps its former names, so a rename breaks nothing (novox/hq ADR 0122).
|
||||
--
|
||||
-- Phase 1 made the seat set data, but a rename still broke every reference to the old name — a
|
||||
-- manifest's claim, a held record, the git-seat lookup — because they name the seat and the name
|
||||
-- had changed. This is the stable identity ADR 0122 asked for, realised the simple way: a seat's
|
||||
-- canonical name changes, and its old name becomes an alias that resolves to it forever. Nothing
|
||||
-- downstream has to change — a manifest goes on claiming the old name, the build machine goes on
|
||||
-- validating it — and a rename is one operation: set the new name, remember the old.
|
||||
create table seat_alias (
|
||||
alias text primary key, -- a former name of a seat
|
||||
seat text not null -- the seat's current canonical name it resolves to
|
||||
);
|
||||
@@ -0,0 +1,13 @@
|
||||
-- A node has an operator account: the human login on it (novox/hq to-be 29).
|
||||
--
|
||||
-- The mesh modelled the machine but not the person on it — `jochens` on novox, `ace` on ace,
|
||||
-- `jochen` on shanks and g14. That name decides who a file under a home is owned by and which
|
||||
-- account `ssh <node>` logs in as; it was silently lost when the predecessor's per-node `user:`
|
||||
-- was not carried over, and `ssh ace` failed to `ace` because nothing here said so.
|
||||
--
|
||||
-- Empty rather than null and defaulted, because "no operator account known yet" is a real state
|
||||
-- (a freshly enrolled machine, a headless box). The home is stored too rather than always assumed
|
||||
-- to be /home/<account>, because root's is /root and a machine may put a home elsewhere; empty
|
||||
-- means "derive it" (/root for root, /home/<account> otherwise), so the common case needs no entry.
|
||||
alter table node add column account text not null default '';
|
||||
alter table node add column account_home text not null default '';
|
||||
@@ -0,0 +1,92 @@
|
||||
package inventory
|
||||
|
||||
// A carried peer is nameable (novox/hq issue 112): the operator states which machine a carried
|
||||
// address is, the mesh answers for the name until the machine enrols, and enrolment verifies the
|
||||
// statement rather than silently renaming it.
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestACarriedPeerIsNamedAndTheRegistrySaysSo(t *testing.T) {
|
||||
inv := fresh(t)
|
||||
anAdoptedHub(t, inv)
|
||||
|
||||
if err := inv.NamePeer(t.Context(), "192.0.2.2", "home-server"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
carried, err := inv.CarriedPeers(t.Context())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
byKey := map[string]CarriedPeer{}
|
||||
for _, c := range carried {
|
||||
byKey[c.PublicKey] = c
|
||||
}
|
||||
if byKey[peerTwo].Named != "home-server" || byKey[peerThree].Named != "" {
|
||||
t.Fatalf("the statement was not recorded where it was made: %+v", carried)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNamingRefusesWhatWouldCollide(t *testing.T) {
|
||||
inv := fresh(t)
|
||||
anAdoptedHub(t, inv)
|
||||
|
||||
if err := inv.NamePeer(t.Context(), "192.0.2.9", "ghost"); err == nil ||
|
||||
!strings.Contains(err.Error(), "no carried peer") {
|
||||
t.Fatalf("naming an address nothing carries must refuse; got %v", err)
|
||||
}
|
||||
if err := inv.NamePeer(t.Context(), "192.0.2.2", "anchor"); err == nil ||
|
||||
!strings.Contains(err.Error(), "node of this mesh") {
|
||||
t.Fatalf("naming a peer after a node must refuse; got %v", err)
|
||||
}
|
||||
if err := inv.NamePeer(t.Context(), "192.0.2.2", "home-server"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := inv.NamePeer(t.Context(), "192.0.2.3", "home-server"); err == nil ||
|
||||
!strings.Contains(err.Error(), "already named") {
|
||||
t.Fatalf("one machine per name; got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrolmentUnderANamedKeyMustUseTheName(t *testing.T) {
|
||||
inv := fresh(t)
|
||||
anAdoptedHub(t, inv)
|
||||
if err := inv.NamePeer(t.Context(), "192.0.2.3", "home-server"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// The wrong name is refused where the operator can read it…
|
||||
imposter, err := inv.AddNode(t.Context(), "some-other-name")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := inv.RecordOverlayKey(t.Context(), imposter.ID, peerThree); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := inv.AssignAddress(t.Context(), imposter.ID, "192.0.2.0/24"); err == nil ||
|
||||
!strings.Contains(err.Error(), `named "home-server"`) {
|
||||
t.Fatalf("enrolling a named peer under another name must refuse; got %v", err)
|
||||
}
|
||||
|
||||
// …and the stated name enrols cleanly, keeping the carried address.
|
||||
named, err := inv.AddNode(t.Context(), "home-server")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := inv.RecordOverlayKey(t.Context(), named.ID, peerThree); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
address, err := inv.AssignAddress(t.Context(), named.ID, "192.0.2.0/24")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if address != "192.0.2.3" {
|
||||
t.Fatalf("the named peer keeps its carried address; got %s", address)
|
||||
}
|
||||
if err := inv.NamePeer(t.Context(), "192.0.2.3", "renamed"); err == nil ||
|
||||
!strings.Contains(err.Error(), "enrolled as") {
|
||||
t.Fatalf("an enrolled peer's name is the node's; got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -55,6 +55,29 @@ type Node struct {
|
||||
// AdoptedSince is when it last became so; zero for a converged node.
|
||||
Adopted bool
|
||||
AdoptedSince time.Time
|
||||
|
||||
// Account is the operator's login on this machine — `jochens` on novox, `ace` on ace (novox/hq
|
||||
// to-be 29). Empty when none is known yet. AccountHome is where that account's home is; empty
|
||||
// means derive it (/root for root, /home/<account> otherwise), so the common case needs no
|
||||
// entry. What decides who a file under a home is owned by, and which account `ssh <node>` uses.
|
||||
Account string
|
||||
AccountHome string
|
||||
}
|
||||
|
||||
// Home is the account's home directory, derived when not stored: /root for root, /home/<account>
|
||||
// otherwise. Empty only when there is no account at all.
|
||||
func (n Node) Home() string {
|
||||
if n.AccountHome != "" {
|
||||
return n.AccountHome
|
||||
}
|
||||
switch n.Account {
|
||||
case "":
|
||||
return ""
|
||||
case "root":
|
||||
return "/root"
|
||||
default:
|
||||
return "/home/" + n.Account
|
||||
}
|
||||
}
|
||||
|
||||
// Silent is how long since this node was last heard from, and whether it ever was.
|
||||
@@ -112,12 +135,13 @@ func (i *Inventory) AddNodeAs(ctx context.Context, name string, adopted bool) (N
|
||||
|
||||
// nodeColumns and scanNode are the one reading of a node row, so every way of finding a node
|
||||
// says whether it is adopted.
|
||||
const nodeColumns = `id, name, created, last_seen, adopted, adopted_since`
|
||||
const nodeColumns = `id, name, created, last_seen, adopted, adopted_since, account, account_home`
|
||||
|
||||
func scanNode(row pgx.Row) (Node, error) {
|
||||
var n Node
|
||||
var seen, since *time.Time
|
||||
if err := row.Scan(&n.ID, &n.Name, &n.Created, &seen, &n.Adopted, &since); err != nil {
|
||||
if err := row.Scan(&n.ID, &n.Name, &n.Created, &seen, &n.Adopted, &since,
|
||||
&n.Account, &n.AccountHome); err != nil {
|
||||
return Node{}, err
|
||||
}
|
||||
if seen != nil {
|
||||
@@ -129,6 +153,21 @@ func scanNode(row pgx.Row) (Node, error) {
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// SetAccount records the operator account on a node — its human login — and optionally where that
|
||||
// account's home is (novox/hq to-be 29). An empty home means the mesh derives it. Clearing the
|
||||
// account (empty name) is allowed: a machine may stop having a known operator.
|
||||
func (i *Inventory) SetAccount(ctx context.Context, node, account, home string) error {
|
||||
tag, err := i.store.Pool().Exec(ctx,
|
||||
`update node set account = $1, account_home = $2 where name = $3`, account, home, node)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return fmt.Errorf("%w: %s", ErrNoSuchNode, node)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Nodes are every node record, oldest first.
|
||||
func (i *Inventory) Nodes(ctx context.Context) ([]Node, error) {
|
||||
rows, err := i.store.Pool().Query(ctx,
|
||||
|
||||
@@ -221,7 +221,7 @@ func TestWhatAMachineNoLongerHoldsIsAvailableAgain(t *testing.T) {
|
||||
func TestUnassigningReleasesTheModulesPorts(t *testing.T) {
|
||||
inv, node := aNodeWithModules(t, "mailu", "other-mail")
|
||||
ctx := t.Context()
|
||||
if err := inv.Assign(ctx, node, "mailu"); err != nil {
|
||||
if _, err := inv.Assign(ctx, node, "mailu"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := inv.PortFor(ctx, node, "mailu", 25, true); err != nil {
|
||||
@@ -230,7 +230,7 @@ func TestUnassigningReleasesTheModulesPorts(t *testing.T) {
|
||||
if err := inv.Unassign(ctx, node, "mailu"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := inv.Assign(ctx, node, "other-mail"); err != nil {
|
||||
if _, err := inv.Assign(ctx, node, "other-mail"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := inv.PortFor(ctx, node, "other-mail", 25, true); err != nil {
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
package inventory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
)
|
||||
|
||||
// The seats the mesh has, as data (novox/hq ADR 0122).
|
||||
//
|
||||
// The set the control plane reads is a table here, not a slice compiled into it. It is seeded from
|
||||
// the binary's defaults the first time the mesh comes up (SeedSeats), and thereafter it is the live
|
||||
// copy: a rename or an added seat is a write here, and the control plane loads it at startup rather
|
||||
// than being rebuilt for it.
|
||||
|
||||
// Seats is every seat the mesh defines, read from the store.
|
||||
func (i *Inventory) Seats(ctx context.Context) ([]catalogue.Seat, error) {
|
||||
rows, err := i.store.Pool().Query(ctx,
|
||||
`select name, scope, delivers, decided from seat order by name`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var seats []catalogue.Seat
|
||||
for rows.Next() {
|
||||
var s catalogue.Seat
|
||||
if err := rows.Scan(&s.Name, &s.Scope, &s.Delivers, &s.Decision); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
seats = append(seats, s)
|
||||
}
|
||||
return seats, rows.Err()
|
||||
}
|
||||
|
||||
// SeedSeats writes the mesh's default set into the table where it is not already present.
|
||||
//
|
||||
// **Idempotent, and never overwriting.** Run every time the control plane migrates, it fills an
|
||||
// empty table on first boot and adds a seat a new release ships — but it leaves a row already there
|
||||
// exactly as it is, so an operator's rename in the table is not undone by the next deploy putting
|
||||
// the old name back. What a release removes from the defaults is not deleted here either; retiring a
|
||||
// seat is its own decision, not a silent consequence of it dropping out of the binary.
|
||||
func (i *Inventory) SeedSeats(ctx context.Context, defaults []catalogue.Seat) (int, error) {
|
||||
var added int
|
||||
for _, s := range defaults {
|
||||
tag, err := i.store.Pool().Exec(ctx,
|
||||
`insert into seat (name, scope, delivers, decided) values ($1, $2, $3, $4)
|
||||
on conflict (name) do nothing`,
|
||||
s.Name, s.Scope, s.Delivers, s.Decision)
|
||||
if err != nil {
|
||||
return added, err
|
||||
}
|
||||
added += int(tag.RowsAffected())
|
||||
}
|
||||
return added, nil
|
||||
}
|
||||
|
||||
// Aliases is every former seat name and the seat it now resolves to (novox/hq ADR 0122).
|
||||
func (i *Inventory) Aliases(ctx context.Context) (map[string]string, error) {
|
||||
rows, err := i.store.Pool().Query(ctx, `select alias, seat from seat_alias`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
aliases := map[string]string{}
|
||||
for rows.Next() {
|
||||
var alias, seat string
|
||||
if err := rows.Scan(&alias, &seat); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
aliases[alias] = seat
|
||||
}
|
||||
return aliases, rows.Err()
|
||||
}
|
||||
|
||||
// RenameSeat gives a seat a new name and keeps the old one as an alias (novox/hq ADR 0122).
|
||||
//
|
||||
// **This is the whole of a rename.** The seat's canonical name becomes `to`; `from` is remembered as
|
||||
// an alias so every reference to it — a manifest's claim, a held record, the build machine's
|
||||
// embedded set — goes on resolving to the same seat, unchanged. Nothing is rebuilt and nothing
|
||||
// freezes. Any alias that pointed to `from` is repointed to `to`, so a chain of renames does not
|
||||
// leave an older name resolving to a name that no longer exists.
|
||||
func (i *Inventory) RenameSeat(ctx context.Context, from, to string) error {
|
||||
if from == to {
|
||||
return fmt.Errorf("a seat is renamed to a different name; %q is already its name", to)
|
||||
}
|
||||
tag, err := i.store.Pool().Exec(ctx, `update seat set name = $1 where name = $2`, to, from)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return fmt.Errorf("no seat named %q to rename", from)
|
||||
}
|
||||
// The old name resolves to the new one; and any name that resolved to the old one now resolves
|
||||
// to the new one, so no alias is left pointing at a name that is gone.
|
||||
if _, err := i.store.Pool().Exec(ctx,
|
||||
`insert into seat_alias (alias, seat) values ($1, $2)
|
||||
on conflict (alias) do update set seat = excluded.seat`, from, to); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := i.store.Pool().Exec(ctx,
|
||||
`update seat_alias set seat = $1 where seat = $2`, to, from); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package inventory
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
)
|
||||
|
||||
// The seat set is data the control plane owns (novox/hq ADR 0122): seeded from the binary's
|
||||
// defaults, read back as the working set, and thereafter an operator's to change without a rebuild.
|
||||
|
||||
func TestSeatsAreSeededFromTheDefaultsAndReadBack(t *testing.T) {
|
||||
inv := ForTest(t)
|
||||
defaults := catalogue.DefaultSeats()
|
||||
|
||||
added, err := inv.SeedSeats(t.Context(), defaults)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if added != len(defaults) {
|
||||
t.Fatalf("seeded %d of %d seats", added, len(defaults))
|
||||
}
|
||||
got, err := inv.Seats(t.Context())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != len(defaults) {
|
||||
t.Fatalf("read back %d seats, seeded %d", len(got), len(defaults))
|
||||
}
|
||||
// The set round-trips: name, scope and what it delivers survive the store.
|
||||
by := map[string]catalogue.Seat{}
|
||||
for _, s := range got {
|
||||
by[s.Name] = s
|
||||
}
|
||||
for _, d := range defaults {
|
||||
if by[d.Name].Scope != d.Scope || by[d.Name].Delivers != d.Delivers {
|
||||
t.Errorf("%s came back as %+v, seeded %+v", d.Name, by[d.Name], d)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Re-seeding an already-seeded set adds nothing and changes nothing — every deploy runs SeedSeats,
|
||||
// and a mesh already holding the set must be left exactly as it is (an operator's edit to a row
|
||||
// included). A row's fields are not overwritten: on conflict the insert does nothing.
|
||||
//
|
||||
// (Phase 1 keys the table by name, so a seat *renamed* in the table would have its old name
|
||||
// re-seeded — the move to a stable id a rename does not touch is the next step, ADR 0122. This test
|
||||
// asserts only the property that holds now: an unchanged set re-seeds to a no-op.)
|
||||
func TestReSeedingAnUnchangedSetIsANoOp(t *testing.T) {
|
||||
inv := ForTest(t)
|
||||
defaults := catalogue.DefaultSeats()
|
||||
if _, err := inv.SeedSeats(t.Context(), defaults); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// An operator changes a row's scope in the table — the point of it being data.
|
||||
if _, err := inv.store.Pool().Exec(t.Context(),
|
||||
`update seat set scope = 'mesh' where name = 'node-uplink'`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
added, err := inv.SeedSeats(t.Context(), defaults)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if added != 0 {
|
||||
t.Fatalf("re-seeding an already-present set added %d rows", added)
|
||||
}
|
||||
got, err := inv.Seats(t.Context())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, s := range got {
|
||||
if s.Name == "node-uplink" && s.Scope != "mesh" {
|
||||
t.Fatalf("re-seeding overwrote the operator's change: node-uplink scope is %q", s.Scope)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A rename is one operation: the seat gets the new name, the old name becomes an alias that still
|
||||
// resolves to it (novox/hq ADR 0122).
|
||||
func TestRenameSeatKeepsTheFormerNameAsAnAlias(t *testing.T) {
|
||||
inv := ForTest(t)
|
||||
if _, err := inv.SeedSeats(t.Context(), catalogue.DefaultSeats()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := inv.RenameSeat(t.Context(), "node-packet-filter", "node-firewall"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
seats, err := inv.Seats(t.Context())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
names := map[string]bool{}
|
||||
for _, s := range seats {
|
||||
names[s.Name] = true
|
||||
}
|
||||
if !names["node-firewall"] || names["node-packet-filter"] {
|
||||
t.Fatalf("the seat was not renamed in place: %v", names)
|
||||
}
|
||||
aliases, err := inv.Aliases(t.Context())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if aliases["node-packet-filter"] != "node-firewall" {
|
||||
t.Fatalf("the former name is not an alias of the new one: %v", aliases)
|
||||
}
|
||||
// Renaming what has no seat is refused; renaming to the same name is refused.
|
||||
if err := inv.RenameSeat(t.Context(), "no-such-seat", "x"); err == nil {
|
||||
t.Fatal("renaming a seat that does not exist was accepted")
|
||||
}
|
||||
if err := inv.RenameSeat(t.Context(), "node-firewall", "node-firewall"); err == nil {
|
||||
t.Fatal("renaming a seat to its own name was accepted")
|
||||
}
|
||||
}
|
||||
@@ -350,7 +350,7 @@ func TestACredentialGoesWhenTheConsumerStopsAskingForIt(t *testing.T) {
|
||||
}, Source{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := inv.Assign(ctx, "consumer", "meshboard"); err != nil {
|
||||
if _, err := inv.Assign(ctx, "consumer", "meshboard"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := inv.SecretFor(ctx, "postgres-database", "consumer", "gitea", "provider", ""); err != nil {
|
||||
|
||||
@@ -33,6 +33,9 @@ type Tunnel struct {
|
||||
// Port is the port the found interface listened on — one the hosting provider already lets
|
||||
// through, which is why it is worth taking.
|
||||
Port int `json:"port"`
|
||||
// MTU is the found interface's, when it set one; the mesh's interface takes it over so a
|
||||
// tuned path does not silently regress to the default (novox/hq: a taken tunnel carries its MTU).
|
||||
MTU int `json:"mtu,omitempty"`
|
||||
// Address is the interface's own address with its prefix length, 192.0.2.1/24; Range is the
|
||||
// network that prefix names, 192.0.2.0/24.
|
||||
Address string `json:"address"`
|
||||
@@ -85,6 +88,9 @@ type CarriedPeer struct {
|
||||
Address string
|
||||
// EnrolledAs names the node that enrolled with this key, or is empty while none has.
|
||||
EnrolledAs string
|
||||
// Named is what the operator said this peer is, before it enrolled (novox/hq issue 112) —
|
||||
// a statement the mesh records and cannot verify, which is why enrolment checks it.
|
||||
Named string
|
||||
}
|
||||
|
||||
// ErrNoTunnel is asking about a tunnel on a node that presented none.
|
||||
@@ -247,7 +253,7 @@ func (i *Inventory) AdoptedTunnel(ctx context.Context) (Tunnel, string, bool, er
|
||||
// adopted no tunnel.
|
||||
func (i *Inventory) CarriedPeers(ctx context.Context) ([]CarriedPeer, error) {
|
||||
rows, err := i.store.Pool().Query(ctx,
|
||||
`select p.public_key, host(p.address), coalesce(n.name, '')
|
||||
`select p.public_key, host(p.address), coalesce(n.name, ''), coalesce(p.named, '')
|
||||
from tunnel_peer p
|
||||
join node hub on hub.id = p.node and hub.is_hub
|
||||
and hub.tunnel is not null and hub.overlay_key = hub.tunnel->>'public_key'
|
||||
@@ -260,7 +266,7 @@ func (i *Inventory) CarriedPeers(ctx context.Context) ([]CarriedPeer, error) {
|
||||
var out []CarriedPeer
|
||||
for rows.Next() {
|
||||
var p CarriedPeer
|
||||
if err := rows.Scan(&p.PublicKey, &p.Address, &p.EnrolledAs); err != nil {
|
||||
if err := rows.Scan(&p.PublicKey, &p.Address, &p.EnrolledAs, &p.Named); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, p)
|
||||
@@ -268,6 +274,46 @@ func (i *Inventory) CarriedPeers(ctx context.Context) ([]CarriedPeer, error) {
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// NamePeer records the operator's statement that a carried address is a particular machine
|
||||
// (novox/hq issue 112). Refused when nothing carried has that address, when a node of the mesh
|
||||
// already has the name — the statement would collide with something verified — and when another
|
||||
// peer was already named it. Naming an enrolled peer is refused too: its name is the node's now.
|
||||
func (i *Inventory) NamePeer(ctx context.Context, address, name string) error {
|
||||
peers, err := i.CarriedPeers(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var at *CarriedPeer
|
||||
for idx := range peers {
|
||||
if peers[idx].Address == address {
|
||||
at = &peers[idx]
|
||||
continue
|
||||
}
|
||||
if peers[idx].Named == name {
|
||||
return fmt.Errorf("the carried peer at %s is already named %q — one machine per name",
|
||||
peers[idx].Address, name)
|
||||
}
|
||||
}
|
||||
if at == nil {
|
||||
return fmt.Errorf("no carried peer has the address %s — `overlay show` lists them", address)
|
||||
}
|
||||
if at.EnrolledAs != "" {
|
||||
return fmt.Errorf("the peer at %s enrolled as %q — its name is the node's now", address, at.EnrolledAs)
|
||||
}
|
||||
if _, err := i.NodeByName(ctx, name); err == nil {
|
||||
return fmt.Errorf("%q is a node of this mesh — a carried peer cannot be named after one", name)
|
||||
}
|
||||
tag, err := i.store.Pool().Exec(ctx,
|
||||
`update tunnel_peer set named = $2 where host(address) = $1`, address, name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return fmt.Errorf("no carried peer has the address %s", address)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// FoundTunnel is a node's found tunnel with the node's mode, for composing: the takeover is
|
||||
// declared to an adopted node only, since only there is a found unit kept to be stopped.
|
||||
type FoundTunnel struct {
|
||||
|
||||
Reference in New Issue
Block a user