Compare commits
27
Commits
407dc86a74
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a62a7e552 | ||
|
|
5890eb104d | ||
|
|
262b39ae09 | ||
|
|
2f2a8bb57e | ||
|
|
1cdf12df00 | ||
|
|
f614eeb445 | ||
|
|
53a3c91f53 | ||
|
|
df5f01bfa1 | ||
|
|
32014ac6de | ||
|
|
c7cb9f0a2c | ||
|
|
87410d79ed | ||
|
|
1c3c3edc7d | ||
|
|
b2e2bf0638 | ||
|
|
e231185572 | ||
|
|
b12755c163 | ||
|
|
abf37c7212 | ||
|
|
ead062569e | ||
|
|
f4ddddd59d | ||
|
|
f45b4cb213 | ||
|
|
210f7cf5e6 | ||
|
|
eea5866216 | ||
|
|
b5da0b2161 | ||
|
|
9cb08299b3 | ||
|
|
5fa523f608 | ||
|
|
9a5c02bd60 | ||
|
|
9ce77e4e7b | ||
|
|
470c97654b |
@@ -391,10 +391,10 @@ func blockedElsewhere(ctx context.Context, open *stores, except string) string {
|
||||
}
|
||||
|
||||
// issueOnAssign gives a newly assigned module its bus credential, the way `module issue` does, and
|
||||
// says what it did in one line. Nothing for a module that declares no broker secret; nothing for one
|
||||
// whose user is already minted (a credential is rotated on purpose, never by re-assigning); and when
|
||||
// the bus cannot be reached from here, the line names the verb and the push that would refuse the
|
||||
// module until it is run — never a silent placeholder (novox/hq issue 203).
|
||||
// says what it did in one line. Nothing for a module that declares no broker secret and runs no
|
||||
// provisioner client; nothing for one whose user is already minted (a credential is rotated on
|
||||
// purpose, never by re-assigning); and when the bus cannot be reached from here, the line names the
|
||||
// verb and the push that would refuse the module until it is run — never a silent placeholder (novox/hq issue 203).
|
||||
func issueOnAssign(ctx context.Context, open *stores, node, module string) string {
|
||||
inv := open.inventory
|
||||
shelf, err := inv.Catalogue(ctx)
|
||||
@@ -406,6 +406,10 @@ func issueOnAssign(ctx context.Context, open *stores, node, module string) strin
|
||||
return ""
|
||||
}
|
||||
user := broker.Principal{Kind: broker.KindModule, Node: node, Module: module}.Username()
|
||||
if _, reads := m.OwnSecrets["broker"]; !reads {
|
||||
// A module running the provisioner client alone is minted the client's account (issue 917).
|
||||
user = broker.Principal{Kind: broker.KindProvisioner, Node: node, Module: module}.Username()
|
||||
}
|
||||
if _, minted, err := inv.BusUserHash(ctx, user); err != nil || minted {
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -287,6 +287,12 @@ func gatherFacts(ctx context.Context, open *stores, busVersion string) (snapshot
|
||||
return snapshot.Facts{}, err
|
||||
}
|
||||
m.Architecture, m.Kernel = reported.Architecture, reported.Kernel
|
||||
// Whether its node-engine takes handed files is its contract (issue 969).
|
||||
if stated, had, err := inv.HealthOf(ctx, n.Name); err != nil {
|
||||
return snapshot.Facts{}, err
|
||||
} else if had {
|
||||
m.Contract = stated.Contract
|
||||
}
|
||||
outward, err := inv.OutwardLinksOf(ctx, n.Name)
|
||||
if err != nil {
|
||||
return snapshot.Facts{}, err
|
||||
|
||||
@@ -1017,6 +1017,13 @@ func raiseFromFacts(ctx context.Context, open *stores, f snapshot.Facts, shelf m
|
||||
"kernel": m.Kernel, "capabilities": caps}); err != nil {
|
||||
return notes, err
|
||||
}
|
||||
// What its node-engine takes, as the mesh composes it today (issue 969): without the statement the gate
|
||||
// composes a moved module the old way on a machine the mesh hands it to.
|
||||
if stated, ok := standInHealth(m, f.Taken); ok {
|
||||
if _, err := inv.RecordHealth(ctx, stated); err != nil {
|
||||
return notes, err
|
||||
}
|
||||
}
|
||||
for _, record := range []func(context.Context, string, string) error{inv.RecordSealingKey, inv.RecordOverlayKey} {
|
||||
key, err := standInKey()
|
||||
if err != nil {
|
||||
@@ -1355,3 +1362,12 @@ func firstOr(items, otherwise []string) string {
|
||||
}
|
||||
return "it says nothing more"
|
||||
}
|
||||
|
||||
// standInHealth is the statement the gate gives a machine of the snapshot so that it composes as the mesh does:
|
||||
// only its contract, which says whether it takes handed files (issue 969). False for a snapshot that carries none.
|
||||
func standInHealth(m snapshot.Machine, taken time.Time) (inventory.NodeHealth, bool) {
|
||||
if m.Contract == 0 {
|
||||
return inventory.NodeHealth{}, false
|
||||
}
|
||||
return inventory.NodeHealth{Node: m.Name, Contract: m.Contract, SaidAt: taken, HeardAt: taken}, true
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
snapshot "github.com/novox/mesh-controller/internal/facts"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
)
|
||||
|
||||
// Issue 969: the gate composes a machine as the mesh does, so a moved module is handed where the machine takes
|
||||
// handed files and composed the old way where it does not.
|
||||
func TestIssue969TheGateGivesAMachineItsContractSoItTakesHandedFiles(t *testing.T) {
|
||||
taken := time.Date(2026, 10, 16, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
stated, ok := standInHealth(snapshot.Machine{Name: "f35", Contract: link.HandedContract}, taken)
|
||||
if !ok || !takesHanded(stated, ok) {
|
||||
t.Fatalf("a machine whose engine takes handed files must be given that in the gate: %+v, %v", stated, ok)
|
||||
}
|
||||
old, ok := standInHealth(snapshot.Machine{Name: "e12", Contract: link.HandedContract - 1}, taken)
|
||||
if !ok || takesHanded(old, ok) {
|
||||
t.Fatalf("a machine whose engine is older must not take handed files: %+v, %v", old, ok)
|
||||
}
|
||||
none, ok := standInHealth(snapshot.Machine{Name: "d01"}, taken)
|
||||
if ok || takesHanded(none, ok) {
|
||||
t.Fatalf("a snapshot that carries no contract states nothing: %+v, %v", none, ok)
|
||||
}
|
||||
}
|
||||
|
||||
// Issue 969, through the store: a machine whose engine stated the handed contract is in the snapshot with it, the
|
||||
// gate raises it as taking handed files, and one whose statement is below it is raised as not taking them. Run in
|
||||
// the merge check with the other tests that need a store; the composition itself, handed here and refused there, is
|
||||
// the catalogue's TestAHandedModuleNamingARunTimeBoundKeyComposesOnlyWhereItIsHanded.
|
||||
func TestIssue969TheGateRaisesEachMachineAsItsEngineStatedItself(t *testing.T) {
|
||||
open := aMesh(t)
|
||||
ctx := t.Context()
|
||||
said := time.Date(2026, 10, 16, 12, 0, 0, 0, time.UTC)
|
||||
for node, contract := range map[string]int{"anchor": link.HandedContract, "laptop": link.HandedContract - 1} {
|
||||
if _, err := open.inventory.RecordHealth(ctx, inventory.NodeHealth{Node: node, Contract: contract,
|
||||
SaidAt: said, HeardAt: said}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
f, err := gatherFacts(ctx, open, "2.11.17")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(f.Machines) != 2 {
|
||||
t.Fatalf("the snapshot holds %d machines", len(f.Machines))
|
||||
}
|
||||
want := map[string]bool{}
|
||||
for _, m := range f.Machines {
|
||||
if m.Contract == 0 {
|
||||
t.Fatalf("%s: the snapshot carries no contract", m.Name)
|
||||
}
|
||||
want[m.Name] = m.Contract >= link.HandedContract
|
||||
}
|
||||
handing := 0
|
||||
for _, hands := range want {
|
||||
if hands {
|
||||
handing++
|
||||
}
|
||||
}
|
||||
if handing != 1 {
|
||||
t.Fatalf("one machine takes handed files in the snapshot, got %v", want)
|
||||
}
|
||||
|
||||
raised, drop, err := throwawayStores(ctx, os.Getenv("MESH_TEST_POSTGRES"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer drop()
|
||||
if _, err := raiseFromFacts(ctx, raised, f, map[string]catalogue.Manifest{}, map[string]inventory.Source{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for name, hands := range want {
|
||||
got, err := engineTakesHanded(ctx, raised.inventory, name)
|
||||
if err != nil || got != hands {
|
||||
t.Errorf("%s: the gate raises it as taking handed files = %v (%v), the mesh says %v", name, got, err, hands)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -923,8 +923,10 @@ func brokerReachableAt(ctx context.Context, inv *inventory.Inventory, known brok
|
||||
// module's own secret named broker; a module that declares none has nothing to read it with, and
|
||||
// an account nothing reads is an orphan on the bus (novox/hq 04-ISSUES/078). Said before the
|
||||
// account is made, not after.
|
||||
// A module that runs its machine's provisioner client and declares no broker is issued the client's account
|
||||
// alone (issue 917): the client speaks on the bus only as itself.
|
||||
func mayIssue(m catalogue.Manifest) error {
|
||||
if _, reads := m.OwnSecrets["broker"]; !reads {
|
||||
if _, reads := m.OwnSecrets["broker"]; !reads && !runsTheProvisionerClient(m) {
|
||||
return fmt.Errorf("%s declares no own secret named broker, so there is nowhere to deliver "+
|
||||
"an account; a module that speaks on the bus declares \"own-secrets\": {\"broker\": <path>}", m.Module)
|
||||
}
|
||||
@@ -943,14 +945,6 @@ func mayIssue(m catalogue.Manifest) error {
|
||||
func issueOnTheNewBus(ctx context.Context, inv *inventory.Inventory, m catalogue.Manifest,
|
||||
node, busAddress string) error {
|
||||
|
||||
user := broker.Principal{Kind: broker.KindModule, Node: node, Module: m.Module}.Username()
|
||||
password, err := inv.MintBusPassword(ctx, inventory.BusUser{
|
||||
Username: user, Kind: busKindOf(m.Module), Node: node, Module: m.Module,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Where the module is told to find the bus, and what certificate it must present. The same pair
|
||||
// a node is told, for the same reason: a mesh's bus presents its own certificate, in no public
|
||||
// trust store, so an address alone fails at TLS.
|
||||
@@ -962,8 +956,17 @@ func issueOnTheNewBus(ctx context.Context, inv *inventory.Inventory, m catalogue
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := issueWith(ctx, inv, m, node, busAddress, known, reachable, user, password); err != nil {
|
||||
return err
|
||||
if _, reads := m.OwnSecrets["broker"]; reads {
|
||||
user := broker.Principal{Kind: broker.KindModule, Node: node, Module: m.Module}.Username()
|
||||
password, err := inv.MintBusPassword(ctx, inventory.BusUser{
|
||||
Username: user, Kind: busKindOf(m.Module), Node: node, Module: m.Module,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := issueWith(ctx, inv, m, node, busAddress, known, reachable, user, password); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if !runsTheProvisionerClient(m) {
|
||||
return nil
|
||||
|
||||
@@ -1126,7 +1126,13 @@ func engineTakesHanded(ctx context.Context, inv *inventory.Inventory, node strin
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return had && stated.Contract >= link.HandedContract, nil
|
||||
return takesHanded(stated, had), nil
|
||||
}
|
||||
|
||||
// takesHanded is whether a machine's newest statement (had: it ever stated one) is of an engine that writes a
|
||||
// filled file on the provisioner client's word alone.
|
||||
func takesHanded(stated inventory.NodeHealth, had bool) bool {
|
||||
return had && stated.Contract >= link.HandedContract
|
||||
}
|
||||
|
||||
// provisionerAccount is the account the provisioner's client runs as on this machine (novox/hq ADR 0349 §4): the user
|
||||
|
||||
@@ -320,7 +320,11 @@ func probeAgentRoot(ctx context.Context, d *doctor) ([]conditions.Observation, e
|
||||
quiet, err := searchStillRunning(ctx, inv, node, n.AgentAccount, h, had, now)
|
||||
return err == nil && quiet
|
||||
}
|
||||
return rootObservations(ctx, r, trustedMachines(r.entries), time.Now()), nil
|
||||
obs := rootObservations(ctx, r, trustedMachines(r.entries), time.Now())
|
||||
// A membership that lost its verified sender to a judgement that failed at a push gets it back here, once
|
||||
// the machines pass (issue 930); the bound is one self-check.
|
||||
giveVerifiedSendersBack(ctx, d)
|
||||
return obs, nil
|
||||
}
|
||||
|
||||
// rootObservations judges the machines and says each that fails.
|
||||
@@ -365,14 +369,3 @@ func rootFreeAnswer(ctx context.Context, machines string, now time.Time) (any, e
|
||||
}
|
||||
return map[string]any{"machines": judgeRootFree(ctx, newRootReader(ctx, d.open.inventory, conn), names, now)}, nil
|
||||
}
|
||||
|
||||
// rootFreeNow is the machines judged root-free, for composing a push's memberships: only those that pass.
|
||||
func rootFreeNow(ctx context.Context, r *rootReader, machines []string, now time.Time) map[string]bool {
|
||||
free := map[string]bool{}
|
||||
for _, v := range judgeRootFree(ctx, r, machines, now) {
|
||||
if v.Free {
|
||||
free[v.Machine] = true
|
||||
}
|
||||
}
|
||||
return free
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/broker"
|
||||
@@ -60,3 +61,32 @@ func TestTheProvisionerClientsAccountIsIssuedForItsServiceAndNeverInvented(t *te
|
||||
t.Fatal("a module with nowhere to read the client's account is read as running it")
|
||||
}
|
||||
}
|
||||
|
||||
// A module that runs the client and declares no broker of its own, as the catalogue's mesh-provisioner-client does,
|
||||
// may be issued: the client's account alone (issue 917). Before, `module issue` refused it for having no broker,
|
||||
// while a push refused it for having no account, so no machine could run the client.
|
||||
func TestAClientWithoutABrokerOfItsOwnMayBeIssued(t *testing.T) {
|
||||
if err := mayIssue(aClientModule()); err != nil {
|
||||
t.Fatalf("the client alone is refused an account: %v", err)
|
||||
}
|
||||
neither := aBot("bot", false)
|
||||
if err := mayIssue(neither); err == nil {
|
||||
t.Fatal("a module with nowhere to read any account may be issued one")
|
||||
}
|
||||
}
|
||||
|
||||
// Assigning the client tries to issue the client's account, as assigning any module issues its broker (issue 917):
|
||||
// without a bus to issue on, the line says so and names the verb, never nothing.
|
||||
func TestAssigningTheClientIssuesItsAccount(t *testing.T) {
|
||||
open := aMesh(t)
|
||||
ctx := t.Context()
|
||||
client := aClientModule()
|
||||
register(t, open, client)
|
||||
out, err := assign(ctx, open, "laptop", client.Module)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(out, "bus credential") {
|
||||
t.Fatalf("assigning the client said nothing of its account:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1333,8 +1333,14 @@ func issueMemberships(ctx context.Context, open *stores, server *link.Server, se
|
||||
}
|
||||
// Which machines are root-free now (novox/hq ADR 0259 §8): a channel's verified sender is composed for the
|
||||
// router only from one, beside a router on one. Judged once per push, by the root-free verb's judgement.
|
||||
records.RootFree = rootFreeNow(ctx, newRootReader(ctx, open.inventory, bus.Conn), records.Nodes, time.Now())
|
||||
var whys map[string]string
|
||||
records.RootFree, whys = rootVerdictsOf(judgeRootFree(ctx, newRootReader(ctx, open.inventory, bus.Conn),
|
||||
records.Nodes, time.Now()))
|
||||
where := broker.PlacementsOf(records, records.Interchangeable)
|
||||
// What the judgement kept off is said, with its reason (novox/hq issue 930); the self-check gives it back.
|
||||
for _, line := range droppedVerifiedSenders(records, where, whys) {
|
||||
fmt.Println(line)
|
||||
}
|
||||
// **Every declared state's bucket, before the memberships that name it** (novox/hq ADR 0201). The
|
||||
// raise at start asserts them too, but a module registered and assigned since would otherwise have
|
||||
// its bucket only after the control plane next restarts — found the first time a module declared
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats.go"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/broker"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
)
|
||||
|
||||
// A channel's verified sender is composed into a membership only while the machines are judged root-free at
|
||||
// that moment (novox/hq ADR 0259 §8), and the judgement fails closed when a read fails (novox/hq issue 930):
|
||||
// a push composed during a failed read issued the router a membership without it, and nothing composed it
|
||||
// again, so every approval was refused until somebody pushed. Two things follow.
|
||||
//
|
||||
// What a composition drops it says (droppedVerifiedSenders), with the reason the judgement gave.
|
||||
//
|
||||
// And the self-check issues it again (reissueVerifiedSenders): each time it judges the machines, a membership
|
||||
// that lacks a verified sender the composition would now give it is issued again with only its kinds changed.
|
||||
// **Bound: one self-check, doctorEvery (five minutes), after the judgement passes.** It only ever adds: a
|
||||
// machine that stops passing keeps what it was issued, as at a push, because the router asks the root-free
|
||||
// verb again before it honours an approval, and that is the check that holds.
|
||||
|
||||
// droppedVerifiedSenders says, one line each, every kind that declares `verified-sender` and was composed
|
||||
// without it, naming the kind, its machine and why. whys is each machine's verdict reason (rootVerdict.Why).
|
||||
func droppedVerifiedSenders(r broker.Records, where broker.Placements, whys map[string]string) []string {
|
||||
var routerNodes []string
|
||||
for node, declared := range r.Assigned {
|
||||
for _, d := range declared {
|
||||
for _, s := range d.Holds {
|
||||
if s.Name == routerSeat && !slices.Contains(routerNodes, node) {
|
||||
routerNodes = append(routerNodes, node)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Strings(routerNodes)
|
||||
var out []string
|
||||
for _, k := range where.Kinds {
|
||||
declared := false
|
||||
for _, d := range r.Assigned[k.Node] {
|
||||
for _, s := range d.Holds {
|
||||
if d.Module == k.Module && s.Name == k.Seat && s.Kind == k.Kind && slices.Contains(s.Capabilities, "verified-sender") {
|
||||
declared = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if !declared || slices.Contains(k.Capabilities, "verified-sender") {
|
||||
continue
|
||||
}
|
||||
var why string
|
||||
switch {
|
||||
case !r.RootFree[k.Node]:
|
||||
why = k.Node + " is not judged root-free: " + whys[k.Node]
|
||||
case len(routerNodes) != 1:
|
||||
why = fmt.Sprintf("the router is placed on %d machines, not one", len(routerNodes))
|
||||
case !r.RootFree[routerNodes[0]]:
|
||||
why = "the router's machine " + routerNodes[0] + " is not judged root-free: " + whys[routerNodes[0]]
|
||||
default:
|
||||
why = k.Module + " does not run as an account of its own"
|
||||
}
|
||||
out = append(out, fmt.Sprintf(" %s kind %s of %s on %s is composed without its verified sender: %s",
|
||||
k.Seat, k.Kind, k.Module, k.Node, why))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// withVerifiedSenders is a membership as issued with its seat traffic's kinds replaced by the composed ones,
|
||||
// and whether that changed it: only when a composed kind carries a verified sender the issued one lacks. Pure.
|
||||
func withVerifiedSenders(issued []byte, composed *broker.SeatTraffic) ([]byte, bool, error) {
|
||||
if composed == nil {
|
||||
return issued, false, nil
|
||||
}
|
||||
var fields map[string]json.RawMessage
|
||||
if err := json.Unmarshal(issued, &fields); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
var traffic map[string]json.RawMessage
|
||||
var had []broker.KindHeld
|
||||
if raw, ok := fields["seat-traffic"]; ok {
|
||||
if err := json.Unmarshal(raw, &traffic); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if raw, ok := traffic["kinds"]; ok {
|
||||
if err := json.Unmarshal(raw, &had); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
}
|
||||
}
|
||||
gains := false
|
||||
for _, k := range composed.Kinds {
|
||||
if !slices.Contains(k.Capabilities, "verified-sender") {
|
||||
continue
|
||||
}
|
||||
held := false
|
||||
for _, h := range had {
|
||||
if h.Seat == k.Seat && h.Kind == k.Kind && h.Module == k.Module && h.Node == k.Node &&
|
||||
slices.Contains(h.Capabilities, "verified-sender") {
|
||||
held = true
|
||||
}
|
||||
}
|
||||
gains = gains || !held
|
||||
}
|
||||
if !gains {
|
||||
return issued, false, nil
|
||||
}
|
||||
if traffic == nil {
|
||||
traffic = map[string]json.RawMessage{}
|
||||
}
|
||||
raw, err := json.Marshal(composed.Kinds)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
traffic["kinds"] = raw
|
||||
if fields["seat-traffic"], err = json.Marshal(traffic); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
body, err := json.Marshal(fields)
|
||||
return body, true, err
|
||||
}
|
||||
|
||||
// reissueVerifiedSenders issues again each membership that lacks a verified sender the records now give it:
|
||||
// read as last issued, its kinds replaced, published only if no newer one was issued since (a push runs beside
|
||||
// the self-check, and its newer membership must not be overwritten by the older fields read here). One never issued is left to its own send. Failures are
|
||||
// said and passed over; the next self-check tries again.
|
||||
func reissueVerifiedSenders(r broker.Records, where broker.Placements,
|
||||
last func(node, module string) ([]byte, uint64, error), publish func(node, module string, body []byte, seq uint64) error,
|
||||
say func(format string, args ...any)) int {
|
||||
issued := 0
|
||||
for node, declared := range r.Assigned {
|
||||
for _, d := range declared {
|
||||
composed := broker.MembershipFor(node, d, where).SeatTraffic
|
||||
if composed == nil || !slices.ContainsFunc(composed.Kinds, func(k broker.KindHeld) bool {
|
||||
return slices.Contains(k.Capabilities, "verified-sender")
|
||||
}) {
|
||||
continue
|
||||
}
|
||||
was, seq, err := last(node, d.Module)
|
||||
if err != nil {
|
||||
if !errors.Is(err, nats.ErrMsgNotFound) {
|
||||
say(" %s on %s: its membership could not be read to give its verified sender back: %v", d.Module, node, err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
body, changed, err := withVerifiedSenders(was, composed)
|
||||
if err != nil {
|
||||
say(" %s on %s: its membership could not be read to give its verified sender back: %v", d.Module, node, err)
|
||||
continue
|
||||
}
|
||||
if !changed {
|
||||
continue
|
||||
}
|
||||
if err := publish(node, d.Module, body, seq); err != nil {
|
||||
// A push issued another since it was read: that one is newer, and the next self-check looks again.
|
||||
if strings.Contains(err.Error(), "wrong last sequence") {
|
||||
say(" %s on %s: a newer membership was issued meanwhile; its verified sender is looked at again next time",
|
||||
d.Module, node)
|
||||
continue
|
||||
}
|
||||
say(" %s on %s: its verified sender could not be issued again: %v", d.Module, node, err)
|
||||
continue
|
||||
}
|
||||
say(" %s on %s: issued its membership again, with the verified sender its machines now pass for", d.Module, node)
|
||||
issued++
|
||||
}
|
||||
}
|
||||
return issued
|
||||
}
|
||||
|
||||
// rootVerdictsOf is each machine's verdict reason, for saying why one was not free.
|
||||
func rootVerdictsOf(verdicts []rootVerdict) (free map[string]bool, whys map[string]string) {
|
||||
free, whys = map[string]bool{}, map[string]string{}
|
||||
for _, v := range verdicts {
|
||||
if v.Free {
|
||||
free[v.Machine] = true
|
||||
}
|
||||
whys[v.Machine] = strings.TrimSpace(v.Why)
|
||||
}
|
||||
return free, whys
|
||||
}
|
||||
|
||||
// giveVerifiedSendersBack is the self-check's step: every machine judged, the memberships that lost a verified
|
||||
// sender to a failed judgement issued again. Never fails the probe: what could not be done is said.
|
||||
func giveVerifiedSendersBack(ctx context.Context, d *doctor) {
|
||||
if d.js == nil || d.open == nil || d.open.inventory == nil {
|
||||
return
|
||||
}
|
||||
records, err := d.open.inventory.BusRecords(ctx)
|
||||
if err != nil {
|
||||
fmt.Printf(" the memberships could not be read to give a verified sender back: %v\n", err)
|
||||
return
|
||||
}
|
||||
// Judged for every machine the records name, as a push does, not only those the self-check judged.
|
||||
r := newRootReader(ctx, d.open.inventory, d.js.Conn())
|
||||
all := judgeRootFree(ctx, r, records.Nodes, time.Now())
|
||||
records.RootFree, _ = rootVerdictsOf(all)
|
||||
where := broker.PlacementsOf(records, records.Interchangeable)
|
||||
reissueVerifiedSendersOn(ctx, link.OverNATS{Conn: d.js.Conn(), JS: d.js.Context()}, records, where)
|
||||
}
|
||||
|
||||
// reissueVerifiedSendersOn is reissueVerifiedSenders on the bus: memberships read from the AssignmentsStream and
|
||||
// published to it.
|
||||
func reissueVerifiedSendersOn(ctx context.Context, bus link.OverNATS, records broker.Records, where broker.Placements) int {
|
||||
return reissueVerifiedSenders(records, where,
|
||||
func(node, module string) ([]byte, uint64, error) {
|
||||
m, err := bus.JS.GetLastMsg(broker.AssignmentsStream, broker.MembershipSubject(node, module))
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return m.Data, m.Sequence, nil
|
||||
},
|
||||
func(node, module string, body []byte, seq uint64) error {
|
||||
return bus.PublishMembershipAfter(ctx, node, module, body, seq)
|
||||
},
|
||||
func(format string, args ...any) { fmt.Printf(format+"\n", args...) })
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/nats-io/nats.go"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/broker"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
"github.com/novox/mesh-controller/internal/testbus"
|
||||
)
|
||||
|
||||
func verifiedSenderRecords(free map[string]bool) (broker.Records, broker.Declared) {
|
||||
tg := broker.Seat{Name: "channel", Scope: "mesh", Accepts: []string{"show"}, Kinded: true, Kind: "telegram",
|
||||
DeclaredBy: "messenger", Capabilities: []string{"choice", "verified-sender"}}
|
||||
router := broker.Declared{Module: "messenger", RunsAs: "messenger", Holds: []broker.Seat{{Name: "operator-channel",
|
||||
Scope: "mesh", Accepts: []string{"ask"}}}, Uses: []broker.Seat{{Name: "channel", Scope: "mesh", Kinded: true,
|
||||
DeclaredBy: "messenger"}}}
|
||||
return broker.Records{Nodes: []string{"novox"}, RootFree: free, Assigned: map[string][]broker.Declared{
|
||||
"novox": {router, {Module: "telegram", RunsAs: "telegram", Holds: []broker.Seat{tg}}}}}, router
|
||||
}
|
||||
|
||||
type fakeBus struct {
|
||||
last map[string][]byte
|
||||
published map[string][]byte
|
||||
seq map[string]uint64
|
||||
// between runs once, after a read and before the write: a push issuing meanwhile.
|
||||
between func()
|
||||
}
|
||||
|
||||
func (f *fakeBus) get(node, module string) ([]byte, uint64, error) {
|
||||
if b, ok := f.last[node+"/"+module]; ok {
|
||||
return b, f.seq[node+"/"+module], nil
|
||||
}
|
||||
return nil, 0, nats.ErrMsgNotFound
|
||||
}
|
||||
func (f *fakeBus) put(node, module string, body []byte, seq uint64) error {
|
||||
if f.between != nil {
|
||||
f.between()
|
||||
f.between = nil
|
||||
}
|
||||
if seq != f.seq[node+"/"+module] {
|
||||
return errors.New("nats: wrong last sequence")
|
||||
}
|
||||
f.published[node+"/"+module] = body
|
||||
f.last[node+"/"+module] = body
|
||||
f.seq[node+"/"+module]++
|
||||
return nil
|
||||
}
|
||||
|
||||
func hasVerified(t *testing.T, membership []byte) bool {
|
||||
t.Helper()
|
||||
var m broker.Membership
|
||||
if err := json.Unmarshal(membership, &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, k := range m.SeatTraffic.Kinds {
|
||||
if k.Kind == "telegram" {
|
||||
return strings.Contains(strings.Join(k.Capabilities, ","), "verified-sender")
|
||||
}
|
||||
}
|
||||
t.Fatal("telegram not in the router's membership")
|
||||
return false
|
||||
}
|
||||
|
||||
// A membership composed while the judgement failed is issued again once it passes, with the verified sender
|
||||
// (novox/hq issue 930); while it fails, nothing is given back (ADR 0259 §8).
|
||||
func TestAMembershipThatLostItsVerifiedSenderGetsItBackWhenTheJudgementPasses(t *testing.T) {
|
||||
failed, router := verifiedSenderRecords(nil)
|
||||
whereFailed := broker.PlacementsOf(failed, nil)
|
||||
bus := &fakeBus{last: map[string][]byte{}, published: map[string][]byte{}, seq: map[string]uint64{}}
|
||||
body, _ := json.Marshal(broker.MembershipFor("novox", router, whereFailed))
|
||||
bus.last["novox/messenger"] = body
|
||||
if hasVerified(t, body) {
|
||||
t.Fatal("composed with a failed judgement, and the verified sender is kept")
|
||||
}
|
||||
say := func(string, ...any) {}
|
||||
|
||||
if n := reissueVerifiedSenders(failed, whereFailed, bus.get, bus.put, say); n != 0 || len(bus.published) != 0 {
|
||||
t.Fatalf("issued %d while the judgement still fails", n)
|
||||
}
|
||||
passing, _ := verifiedSenderRecords(map[string]bool{"novox": true})
|
||||
wherePassing := broker.PlacementsOf(passing, nil)
|
||||
if n := reissueVerifiedSenders(passing, wherePassing, bus.get, bus.put, say); n != 1 {
|
||||
t.Fatalf("issued %d memberships after the judgement passed, want 1", n)
|
||||
}
|
||||
if !hasVerified(t, bus.published["novox/messenger"]) {
|
||||
t.Fatal("the membership issued again lacks the verified sender")
|
||||
}
|
||||
if n := reissueVerifiedSenders(passing, wherePassing, bus.get, bus.put, say); n != 0 {
|
||||
t.Fatalf("issued %d again with nothing changed", n)
|
||||
}
|
||||
}
|
||||
|
||||
// What a composition drops it says, naming the kind, the machine and the judgement's reason.
|
||||
func TestADroppedVerifiedSenderIsSaidWithItsReason(t *testing.T) {
|
||||
records, _ := verifiedSenderRecords(nil)
|
||||
where := broker.PlacementsOf(records, nil)
|
||||
lines := droppedVerifiedSenders(records, where, map[string]string{"novox": "not measured: no verb heard"})
|
||||
if len(lines) != 1 || !strings.Contains(lines[0], "telegram") || !strings.Contains(lines[0], "novox") ||
|
||||
!strings.Contains(lines[0], "not measured: no verb heard") {
|
||||
t.Fatalf("said %q", lines)
|
||||
}
|
||||
records, _ = verifiedSenderRecords(map[string]bool{"novox": true})
|
||||
if lines := droppedVerifiedSenders(records, broker.PlacementsOf(records, nil), nil); len(lines) != 0 {
|
||||
t.Fatalf("said %q of a kind that kept its verified sender", lines)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAMembershipNeverIssuedIsLeftToItsOwnSend(t *testing.T) {
|
||||
records, _ := verifiedSenderRecords(map[string]bool{"novox": true})
|
||||
bus := &fakeBus{last: map[string][]byte{}, published: map[string][]byte{}, seq: map[string]uint64{}}
|
||||
var said []string
|
||||
n := reissueVerifiedSenders(records, broker.PlacementsOf(records, nil), bus.get, bus.put,
|
||||
func(f string, a ...any) { said = append(said, f) })
|
||||
if n != 0 || len(said) != 0 {
|
||||
t.Fatalf("issued %d, said %v", n, said)
|
||||
}
|
||||
}
|
||||
|
||||
// **On a real bus** (novox/hq issue 930): a router's membership issued without the verified sender because the
|
||||
// judgement failed is, once the judgement passes, published again to the AssignmentsStream with it in its seat
|
||||
// traffic's kinds, and a second pass publishes nothing.
|
||||
func TestNatsAMembershipIssuedWithoutItsVerifiedSenderIsIssuedAgainWhenTheJudgementPasses(t *testing.T) {
|
||||
js, err := broker.Dial(testbus.URL(t))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(js.Close)
|
||||
if err := broker.AssertMeshStreams(js); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bus := link.OverNATS{Conn: js.Conn(), JS: js.Context()}
|
||||
|
||||
failed, router := verifiedSenderRecords(nil)
|
||||
body, err := json.Marshal(broker.MembershipFor("novox", router, broker.PlacementsOf(failed, nil)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := bus.PublishMembership(t.Context(), "novox", "messenger", body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
issued := func() []byte {
|
||||
m, err := js.Context().GetLastMsg(broker.AssignmentsStream, broker.MembershipSubject("novox", "messenger"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return m.Data
|
||||
}
|
||||
if hasVerified(t, issued()) {
|
||||
t.Fatal("issued with a failed judgement, and the verified sender is kept: the test proves nothing")
|
||||
}
|
||||
if n := reissueVerifiedSendersOn(t.Context(), bus, failed, broker.PlacementsOf(failed, nil)); n != 0 {
|
||||
t.Fatalf("issued %d memberships while the judgement still fails", n)
|
||||
}
|
||||
passing, _ := verifiedSenderRecords(map[string]bool{"novox": true})
|
||||
where := broker.PlacementsOf(passing, nil)
|
||||
if n := reissueVerifiedSendersOn(t.Context(), bus, passing, where); n != 1 {
|
||||
t.Fatalf("issued %d memberships after the judgement passed, want 1", n)
|
||||
}
|
||||
if !hasVerified(t, issued()) {
|
||||
t.Fatal("the membership on the bus lacks the verified sender after the judgement passed")
|
||||
}
|
||||
if n := reissueVerifiedSendersOn(t.Context(), bus, passing, where); n != 0 {
|
||||
t.Fatalf("a second pass issued %d memberships", n)
|
||||
}
|
||||
}
|
||||
|
||||
// A push issuing a newer membership between the self-check's read and its write is not overwritten (issue 930).
|
||||
func TestANewerMembershipIssuedMeanwhileIsNotOverwritten(t *testing.T) {
|
||||
failed, router := verifiedSenderRecords(nil)
|
||||
bus := &fakeBus{last: map[string][]byte{}, published: map[string][]byte{}, seq: map[string]uint64{}}
|
||||
old, _ := json.Marshal(broker.MembershipFor("novox", router, broker.PlacementsOf(failed, nil)))
|
||||
bus.last["novox/messenger"], bus.seq["novox/messenger"] = old, 1
|
||||
newer := []byte(`{"node":"novox","module":"messenger","newer":true}`)
|
||||
bus.between = func() { bus.last["novox/messenger"], bus.seq["novox/messenger"] = newer, 2 }
|
||||
passing, _ := verifiedSenderRecords(map[string]bool{"novox": true})
|
||||
if n := reissueVerifiedSenders(passing, broker.PlacementsOf(passing, nil), bus.get, bus.put, func(string, ...any) {}); n != 0 {
|
||||
t.Fatalf("issued %d over a newer membership", n)
|
||||
}
|
||||
if string(bus.last["novox/messenger"]) != string(newer) || len(bus.published) != 0 {
|
||||
t.Fatalf("the newer membership was overwritten: %s", bus.last["novox/messenger"])
|
||||
}
|
||||
}
|
||||
@@ -160,3 +160,87 @@ func TestNatsOnlyMeshConfigsAccountPutsToItsBuckets(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// **The controller asks mesh-config's `config` in its own name** (issue 925): on a real server whose users are
|
||||
// composed from records that hold mesh-config on novox serving `config`, the controller's request on
|
||||
// `mesh.seat.mesh-config.call.config.controller` reaches mesh-config's account and its reply reaches the
|
||||
// controller's inbox; the same request under another caller's name is refused by the server.
|
||||
func TestNatsTheControllerAsksMeshConfigInItsOwnName(t *testing.T) {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte("pw"), bcrypt.MinCost)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
seat := Seat{Name: "mesh-config", Scope: "mesh", Serves: []string{"config"}}
|
||||
users, err := Users(Records{Nodes: []string{"novox"}, Assigned: map[string][]Declared{
|
||||
"novox": {{Module: RuntimeModule}, {Module: "mesh-config", RunsAs: "mesh-config", Holds: []Seat{seat}}},
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := range users {
|
||||
users[i].PasswordHash = string(hash)
|
||||
}
|
||||
accounts, err := ComposeAccounts(users)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
conf := filepath.Join(t.TempDir(), "bus.conf")
|
||||
if err := os.WriteFile(conf, []byte("listen: 127.0.0.1:-1\n"+accounts), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
opts, err := server.ProcessConfigFile(conf)
|
||||
if err != nil {
|
||||
t.Fatalf("the composed accounts do not parse: %v", err)
|
||||
}
|
||||
opts.NoLog, opts.NoSigs = true, true
|
||||
s, err := server.NewServer(opts)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
go s.Start()
|
||||
if !s.ReadyForConnections(10 * time.Second) {
|
||||
t.Fatal("the bus did not come up")
|
||||
}
|
||||
t.Cleanup(s.Shutdown)
|
||||
connect := func(user string) (*nats.Conn, chan error) {
|
||||
t.Helper()
|
||||
refused := make(chan error, 8)
|
||||
nc, err := nats.Connect(s.ClientURL(), nats.UserInfo(user, "pw"), nats.CustomInboxPrefix("_INBOX."+user),
|
||||
nats.ErrorHandler(func(_ *nats.Conn, _ *nats.Subscription, err error) { refused <- err }))
|
||||
if err != nil {
|
||||
t.Fatalf("%s cannot connect: %v", user, err)
|
||||
}
|
||||
t.Cleanup(nc.Close)
|
||||
return nc, refused
|
||||
}
|
||||
owner, _ := connect("novox.mesh-config")
|
||||
subject := CalledSubject("mesh.seat.mesh-config.tool.config", ControllerName)
|
||||
if subject != "mesh.seat.mesh-config.call.config.controller" {
|
||||
t.Fatalf("the subject is %s", subject)
|
||||
}
|
||||
if _, err := owner.Subscribe("mesh.seat.mesh-config.call.config.*", func(m *nats.Msg) {
|
||||
_ = m.Respond([]byte(`{"ok":true}`))
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := owner.Flush(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
controller, refused := connect(ControllerName)
|
||||
reply, err := controller.Request(subject, []byte(`{"action":"set"}`), 5*time.Second)
|
||||
if err != nil || string(reply.Data) != `{"ok":true}` {
|
||||
t.Fatalf("the controller's own-name request got %v (%v), want mesh-config's reply", reply, err)
|
||||
}
|
||||
if err := controller.Publish(CalledSubject("mesh.seat.mesh-config.tool.config", "anchor.node-tools"), nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = controller.Flush()
|
||||
select {
|
||||
case err := <-refused:
|
||||
if !errors.Is(err, nats.ErrPermissionViolation) {
|
||||
t.Errorf("the server said %v, want a permissions violation", err)
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Error("the server did not refuse the controller a request in another's name")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -226,3 +226,45 @@ func TestTheControllerMayAskTheVaultInItsOwnName(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// **The controller may ask mesh-config's `config` in its own name** (issue 925): move-config and the settings verb
|
||||
// set a trusted value through mesh-config, which takes it as the controller carries it only from the subject that
|
||||
// names the controller as caller. Granted when the records hold a holder serving the verb, and not otherwise;
|
||||
// the installer's first user list carries none.
|
||||
func TestTheControllerMayAskMeshConfigInItsOwnName(t *testing.T) {
|
||||
config := Declared{Module: "mesh-config", Holds: []Seat{{Name: "mesh-config", Serves: []string{"config"}}}}
|
||||
users, err := Users(Records{Nodes: []string{"novox"}, Assigned: map[string][]Declared{"novox": {config}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Join(users[0].AsksTheConfig, ",") != "config" {
|
||||
t.Fatalf("composed %v", users[0].AsksTheConfig)
|
||||
}
|
||||
p, err := PermissionsFor(users[0])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !slices.Contains(p.Publish, "mesh.seat.mesh-config.call.config.controller") {
|
||||
t.Errorf("the controller may not publish mesh.seat.mesh-config.call.config.controller: %v", p.Publish)
|
||||
}
|
||||
if !MayPublish(p, CalledSubject("mesh.seat.mesh-config.tool.config", ControllerName)) {
|
||||
t.Error("the controller may not ask mesh-config's config in its own name")
|
||||
}
|
||||
if MayPublish(p, CalledSubject("mesh.seat.mesh-config.tool.config", "anchor.node-tools")) {
|
||||
t.Error("the controller may ask mesh-config's config in another's name")
|
||||
}
|
||||
// No holder serving it composes no grant, nor does genesis.
|
||||
other := Declared{Module: "mesh-config", Holds: []Seat{{Name: "mesh-config", Serves: []string{"list"}}}}
|
||||
for _, r := range []Records{{Nodes: []string{"novox"}}, {Nodes: []string{"novox"}, Assigned: map[string][]Declared{"novox": {other}}}} {
|
||||
users, _ = Users(r)
|
||||
if len(users[0].AsksTheConfig) != 0 {
|
||||
t.Errorf("composed %v without a holder serving config", users[0].AsksTheConfig)
|
||||
}
|
||||
}
|
||||
genesis, _ := PermissionsFor(Principal{Kind: KindController, PasswordHash: "x"})
|
||||
for _, s := range genesis.Publish {
|
||||
if strings.Contains(s, "mesh-config") {
|
||||
t.Errorf("the installer's controller may publish %s", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,6 +141,12 @@ type Principal struct {
|
||||
// controller asks. Composed from the records like AsksIssued, so the installer's first user list carries none.
|
||||
AsksTheVault []string
|
||||
|
||||
// AsksTheConfig are the mesh-config verbs the controller asks in its own name (VerbsTheControllerAsksTheConfig,
|
||||
// issue 925), those mesh-config's holder serves: on the subject that names its caller, so mesh-config takes a
|
||||
// value as the controller carries it (ADR 0348 §8). Composed from the records like AsksTheVault, so the
|
||||
// installer's first user list carries none.
|
||||
AsksTheConfig []string
|
||||
|
||||
// AsksTheDesk says this principal is the vault's holder, which asks the desks' secret prompt in its own name
|
||||
// (novox/hq ADR 0349 clause 9, issue 723): `node-launcher.secret` on any machine, on the subject that names
|
||||
// its caller, and nothing else of that seat. With AsksIssued on the same principal, the vault's whole reach
|
||||
@@ -316,6 +322,12 @@ var VerbsTheControllerAsksTheVault = []SeatVerb{{Seat: "mesh-vault", Verb: "acce
|
||||
{Seat: "mesh-vault", Verb: "secret-ask"}, {Seat: "mesh-vault", Verb: "issued"}, {Seat: "mesh-vault", Verb: "rotate"},
|
||||
{Seat: "mesh-vault", Verb: "move-asks"}, {Seat: "mesh-vault", Verb: "get"}}
|
||||
|
||||
// VerbsTheControllerAsksTheConfig are the mesh-config seat's verbs the controller calls in its own name (issue 925):
|
||||
// `config`, the one verb mesh-config serves, which move-config uses to set each value and the settings verb to set,
|
||||
// clear and propose through mesh-config. mesh-config takes a value as the controller carries it only from the
|
||||
// subject that names the controller as caller (ADR 0348 §8); granted as Principal.AsksTheConfig says.
|
||||
var VerbsTheControllerAsksTheConfig = []SeatVerb{{Seat: "mesh-config", Verb: "config"}}
|
||||
|
||||
// VaultMoveIn is the vault's verb a machine's node-engine asks in its own name to hand in a value sealed to its
|
||||
// machine (design 57's V3, issue 676); granted as Principal.MovesIntoTheVault says.
|
||||
var VaultMoveIn = SeatVerb{Seat: "mesh-vault", Verb: "move-in"}
|
||||
@@ -1089,6 +1101,13 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
||||
pub = append(pub, "mesh.seat."+v.Seat+"."+CallKind+"."+v.Verb+"."+CallerToken(p.Username()))
|
||||
}
|
||||
}
|
||||
// **And mesh-config's verb** (issue 925): it takes a trusted value from the controller alone, known from the
|
||||
// subject's last token.
|
||||
for _, v := range VerbsTheControllerAsksTheConfig {
|
||||
if slices.Contains(p.AsksTheConfig, v.Verb) {
|
||||
pub = append(pub, "mesh.seat."+v.Seat+"."+CallKind+"."+v.Verb+"."+CallerToken(p.Username()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sort.Strings(pub)
|
||||
|
||||
@@ -102,7 +102,8 @@ type Records struct {
|
||||
// is a mesh that cannot be told anything, and there is no state of the records in which that is
|
||||
// correct.
|
||||
func Users(r Records) ([]Principal, error) {
|
||||
out := []Principal{{Kind: KindController, Uses: asksSeatOf(r), AsksIssued: issuedOf(r), AsksTheVault: vaultVerbsOf(r)}}
|
||||
out := []Principal{{Kind: KindController, Uses: asksSeatOf(r), AsksIssued: issuedOf(r), AsksTheVault: vaultVerbsOf(r),
|
||||
AsksTheConfig: servedOf(r, VerbsTheControllerAsksTheConfig)}}
|
||||
|
||||
for _, node := range sortedCopy(r.Nodes) {
|
||||
witness := false
|
||||
@@ -290,13 +291,16 @@ func holdsSeat(d Declared, seat string) bool {
|
||||
|
||||
// vaultVerbsOf are the vault's intake verbs its holder serves, as the records say (issue 675): what the controller
|
||||
// is granted to ask it in its own name. None while no holder serves them.
|
||||
func vaultVerbsOf(r Records) []string {
|
||||
func vaultVerbsOf(r Records) []string { return servedOf(r, VerbsTheControllerAsksTheVault) }
|
||||
|
||||
// servedOf are the verbs of asked that a seat's holder serves, as the records say, by name.
|
||||
func servedOf(r Records, asked []SeatVerb) []string {
|
||||
seen := map[string]bool{}
|
||||
var out []string
|
||||
for _, node := range sortedCopy(r.Nodes) {
|
||||
for _, d := range r.Assigned[node] {
|
||||
for _, s := range d.Holds {
|
||||
for _, v := range VerbsTheControllerAsksTheVault {
|
||||
for _, v := range asked {
|
||||
if s.Name == v.Seat && slices.Contains(s.Serves, v.Verb) && !seen[v.Verb] {
|
||||
seen[v.Verb] = true
|
||||
out = append(out, v.Verb)
|
||||
|
||||
@@ -111,7 +111,7 @@ func boundNeed(n Needed, bound Chosen, catalogue map[string]Manifest, node Node,
|
||||
}
|
||||
held.From, held.At, held.Module = node.Name, at, m.Module
|
||||
held.Serves, held.SharedOwn, held.Identity = servedByOne(m, n.Name), sharedByOne(m, n.Name), m.IdentityBoundOf(n.Name)
|
||||
held.CredentialForm = m.CredentialFormOf(n.Name)
|
||||
held.CredentialForm, held.CredentialTool = m.CredentialFormOf(n.Name), m.CredentialToolOf(n.Name)
|
||||
return held, true, ""
|
||||
}
|
||||
matching := bound.among(world.Offered[n.Name])
|
||||
@@ -125,7 +125,7 @@ func boundNeed(n Needed, bound Chosen, catalogue map[string]Manifest, node Node,
|
||||
identity := DefaultIdentityBound
|
||||
if pm, known := catalogue[p.Module]; known {
|
||||
held.SharedOwn, _ = pm.SharedCredentialOf(n.Name)
|
||||
held.CredentialForm = pm.CredentialFormOf(n.Name)
|
||||
held.CredentialForm, held.CredentialTool = pm.CredentialFormOf(n.Name), pm.CredentialToolOf(n.Name)
|
||||
identity = pm.IdentityBoundOf(n.Name)
|
||||
}
|
||||
held.From, held.At, held.Module, held.Serves, held.Identity = p.Node, p.At, p.Module, p.Serves, identity
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"path"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// A node's files, read and managed (novox/hq ADR 0394, issue 900; superseding ADR 0310; hq to-be 41 WP9).
|
||||
//
|
||||
// **One seat for every file on a node.** The holder runs as the tool runner's account, the node's operator
|
||||
// account (ADR 0394 §2), and reaches further through `sudo -n` where that account's rights do not. It refuses by
|
||||
// name the places a secret is kept (reads only, ADR 0394 §7) and the paths a module owns, unless a write names a reason (`override`).
|
||||
// node-reads stays beside it until mesh-files is assigned on every node (ADR 0394 §9); it goes in the next
|
||||
// delivery.
|
||||
|
||||
// FilesSeat is the role that reads and manages a node's files and accounts (novox/hq ADR 0394).
|
||||
const FilesSeat = "node-files"
|
||||
|
||||
// FilesOwnedPaths is the word the holder of node-files is given beside the secret places: a JSON object from
|
||||
// each absolute path a module owns on its node to "module (tool to use)" (novox/hq ADR 0394 §5).
|
||||
const FilesOwnedPaths = "MESH_OWNED_PATHS"
|
||||
|
||||
const (
|
||||
filesAsAccount = "as the node's operator account, through sudo -n where its rights do not reach; a place a " +
|
||||
"secret is kept, or a known credential file, is refused by name"
|
||||
overrideWord = "a reason, to change a path a module owns anyway (optional)"
|
||||
)
|
||||
|
||||
// filesVerbs is the contract every holder of node-files serves (novox/hq ADR 0394 §1): the reads of node-reads,
|
||||
// with their descriptions restated for the operator's account, and the writes.
|
||||
func filesVerbs() []Verb {
|
||||
var verbs []Verb
|
||||
for _, v := range readsVerbs() {
|
||||
v.Description = strings.Replace(v.Description,
|
||||
"Read as the holder's own account, which holds no secret: a component it may not reach says so.",
|
||||
"", 1)
|
||||
v.Description = strings.TrimSpace(v.Description) + " Read " + filesAsAccount + "."
|
||||
verbs = append(verbs, v)
|
||||
}
|
||||
write := func(name, description string, in map[string]string, required []string, optional []string, replaces ...string) Verb {
|
||||
in["reason"] = "why the file is changed, for the audit trail"
|
||||
in["override"] = overrideWord
|
||||
return Verb{Name: name, Description: description + " Refuses a path a module owns " +
|
||||
"unless override names a reason; done as the node's operator account, through sudo -n where its rights do not reach.",
|
||||
Input: schema(in, append(required, "reason"), append(optional, "override")...), Replaces: replaces}
|
||||
}
|
||||
return append(verbs,
|
||||
write("write", "Write one text file whole, replacing what it holds; the old content goes to the node's trash.",
|
||||
map[string]string{"path": "an absolute path", "content": "the text to write",
|
||||
"mode": "the file's mode, such as 0644 (optional)"},
|
||||
[]string{"path", "content"}, []string{"mode"}, "tee", ">"),
|
||||
write("copy", "Copy a file or directory to a path on this node. A target on another node (<node>:/path) is "+
|
||||
"refused: copying between nodes comes in step 2 (issue 903). An existing target is overwritten, the old one "+
|
||||
"kept in the node's trash for 7 days.",
|
||||
map[string]string{"from": "an absolute path", "to": "an absolute path on this node"},
|
||||
[]string{"from", "to"}, nil, "cp", "cp -a", "ssh <node> cp"),
|
||||
write("move", "Move or rename a file or directory. A target on another node is refused: copying between nodes "+
|
||||
"comes in step 2 (issue 903). An existing target is overwritten, the old one kept in the node's trash for 7 days.",
|
||||
map[string]string{"from": "an absolute path", "to": "an absolute path"},
|
||||
[]string{"from", "to"}, nil, "mv"),
|
||||
write("delete", "Delete a file or directory into the node's trash, where restore can bring it back.",
|
||||
map[string]string{"path": "an absolute path", "recursive": "\"true\": delete a directory with what it holds"},
|
||||
[]string{"path"}, []string{"recursive"}, "rm", "rm -r"),
|
||||
write("mkdir", "Make a directory.",
|
||||
map[string]string{"path": "an absolute path", "parents": "\"true\": make the missing parents too"},
|
||||
[]string{"path"}, []string{"parents"}, "mkdir", "mkdir -p"),
|
||||
write("chmod", "Change the mode of a file or directory.",
|
||||
map[string]string{"path": "an absolute path", "mode": "the mode, such as 0644 or u+x",
|
||||
"recursive": "\"true\": also everything under a directory"},
|
||||
[]string{"path", "mode"}, []string{"recursive"}, "chmod"),
|
||||
write("chown", "Change the owner and group of a file or directory.",
|
||||
map[string]string{"path": "an absolute path", "owner": "an account's name or number",
|
||||
"group": "a group's name or number (optional)", "recursive": "\"true\": also everything under a directory"},
|
||||
[]string{"path", "owner"}, []string{"group", "recursive"}, "chown", "chgrp"),
|
||||
Verb{Name: "trash", Description: "What the node's trash holds: each deleted or replaced item with its id, " +
|
||||
"original path and when it went.",
|
||||
Input: schema(map[string]string{}, nil), Replaces: []string{"ls ~/.local/share/Trash"}},
|
||||
write("restore", "Put an item of the node's trash back where it was; refuses when something is there now.",
|
||||
map[string]string{"id": "an id from trash"}, []string{"id"}, nil, "trash-restore"),
|
||||
)
|
||||
}
|
||||
|
||||
// FilesHolderProblems is what a holder of node-files is held to (novox/hq ADR 0394 §2): it runs as the tool
|
||||
// runner's account, the node's operator account, so it declares no runs-as.
|
||||
func FilesHolderProblems(m Manifest) []string {
|
||||
if !m.ClaimsSeat(FilesSeat) || m.RunsAs == "" {
|
||||
return nil
|
||||
}
|
||||
return []string{fmt.Sprintf("%s claims %s and declares runs-as %q: its holder runs as the tool runner's account, "+
|
||||
"the node's operator account (novox/hq ADR 0394 §2)", m.Module, FilesSeat, m.RunsAs)}
|
||||
}
|
||||
|
||||
// ownedPaths is every path a module owns on this node, as the holder is told (novox/hq ADR 0394 §5): the
|
||||
// paths every seat declares (ADR 0287) with the module holding the seat here, and the paths and files each
|
||||
// module's manifest places (declaredPaths). A seat's path wins where both name one.
|
||||
func (r Resolution) ownedPaths() map[string]string {
|
||||
owned := map[string]string{}
|
||||
for _, m := range r.Modules {
|
||||
for _, p := range sortedKeys(declaredPaths(m)) {
|
||||
owned[p] = m.Module + " (its own tools)"
|
||||
}
|
||||
}
|
||||
for _, s := range Seats() {
|
||||
for _, sp := range s.Paths {
|
||||
for _, m := range r.Modules {
|
||||
if m.ClaimsSeat(s.Name) && declaresPath(m, path.Clean(sp.Path)) {
|
||||
owned[sp.Path] = m.Module + " (" + s.Name + ")"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return owned
|
||||
}
|
||||
|
||||
// filesWords is what the holder of node-files is given beside its own words: the node's secret places and the
|
||||
// paths modules own here.
|
||||
func (r Resolution) filesWords(m Manifest, words map[string]string, with Rendering) (map[string]string, error) {
|
||||
if !m.ClaimsSeat(FilesSeat) {
|
||||
return words, nil
|
||||
}
|
||||
places, err := r.secretPlaces(with)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
places2, err := json.Marshal(places)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
owned, err := json.Marshal(r.ownedPaths())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if words == nil {
|
||||
words = map[string]string{}
|
||||
}
|
||||
for _, w := range []string{ReadsSecretPlaces, FilesOwnedPaths} {
|
||||
if _, has := words[w]; has {
|
||||
return nil, fmt.Errorf("%s gives its own %s; the node's secret places and owned paths are the mesh's to "+
|
||||
"say (novox/hq ADR 0394 §5)", m.Module, w)
|
||||
}
|
||||
}
|
||||
words[ReadsSecretPlaces] = string(places2)
|
||||
words[FilesOwnedPaths] = string(owned)
|
||||
return words, nil
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// issue 900, novox/hq ADR 0394 §1: node-files is a node seat whose verbs say the commands they replace.
|
||||
func TestNodeFilesIsANodeSeatWithEveryVerbOfADR0394(t *testing.T) {
|
||||
s, ok := SeatNamed(FilesSeat)
|
||||
if !ok {
|
||||
t.Fatalf("the mesh defines no seat %s", FilesSeat)
|
||||
}
|
||||
if s.Scope != ScopeNode || s.Delivers != "" || !strings.Contains(s.Decision, "ADR 0394") {
|
||||
t.Errorf("%s: scope %q, delivers %q, decision %q", FilesSeat, s.Scope, s.Delivers, s.Decision)
|
||||
}
|
||||
have := map[string]bool{}
|
||||
replaced := map[string]string{}
|
||||
for _, v := range s.Serves {
|
||||
have[v.Name] = true
|
||||
if v.Optional {
|
||||
t.Errorf("%s.%s is optional", FilesSeat, v.Name)
|
||||
}
|
||||
if len(v.Replaces) == 0 {
|
||||
t.Errorf("%s.%s says nothing it replaces", FilesSeat, v.Name)
|
||||
}
|
||||
for _, c := range v.Replaces {
|
||||
replaced[c] = v.Name
|
||||
}
|
||||
}
|
||||
for _, name := range []string{"stat", "readlink", "list", "read", "search", "account", "write", "copy", "move",
|
||||
"delete", "mkdir", "chmod", "chown", "trash", "restore"} {
|
||||
if !have[name] {
|
||||
t.Errorf("%s serves no verb %s", FilesSeat, name)
|
||||
}
|
||||
}
|
||||
for command, verb := range map[string]string{"cat": "read", "tee": "write", "cp": "copy", "mv": "move",
|
||||
"rm": "delete", "mkdir -p": "mkdir", "chmod": "chmod", "chgrp": "chown"} {
|
||||
if replaced[command] != verb {
|
||||
t.Errorf("%q is replaced by %q, want %s.%s", command, replaced[command], FilesSeat, verb)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// issue 900, ADR 0394 §2: a holder of node-files runs as the tool runner's account, so it declares no runs-as.
|
||||
func TestAHolderOfNodeFilesDeclaringRunsAsIsRefused(t *testing.T) {
|
||||
p := FilesHolderProblems(Manifest{Module: "files", RunsAs: "mesh-files", Claims: []Claim{{Name: FilesSeat}}})
|
||||
if len(p) != 1 || !strings.Contains(p[0], "0394") || !strings.Contains(p[0], "runs-as") {
|
||||
t.Errorf("a holder declaring runs-as was not refused: %v", p)
|
||||
}
|
||||
if p := FilesHolderProblems(Manifest{Module: "files", Claims: []Claim{{Name: FilesSeat}}}); len(p) != 0 {
|
||||
t.Errorf("a holder declaring none was refused: %v", p)
|
||||
}
|
||||
if p := FilesHolderProblems(Manifest{Module: "other", RunsAs: "x"}); len(p) != 0 {
|
||||
t.Errorf("a non-holder was judged: %v", p)
|
||||
}
|
||||
}
|
||||
|
||||
// issue 900, ADR 0394 §4: the holder is told the secret places and the paths modules own on its node.
|
||||
func TestTheHolderOfNodeFilesIsToldSecretPlacesAndOwnedPaths(t *testing.T) {
|
||||
files := Manifest{Module: "files", Version: "1", Claims: []Claim{{Name: FilesSeat}},
|
||||
OwnSecrets: OwnSecrets{"broker": {Path: "/var/lib/files/broker"}}}
|
||||
host := Manifest{Module: "hostnames", Version: "1", Claims: []Claim{{Name: "node-hostname"}},
|
||||
Resources: []map[string]any{{"id": "hosts", "type": "file", "path": "/etc/hosts"}}}
|
||||
app := Manifest{Module: "app", Version: "1",
|
||||
Resources: []map[string]any{{"id": "conf", "type": "file", "path": "/etc/app/app.conf"}}}
|
||||
r := Resolution{Node: "anchor", Modules: []Manifest{files, host, app}}
|
||||
words, err := r.filesWords(files, nil, Rendering{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var places []string
|
||||
if err := json.Unmarshal([]byte(words[ReadsSecretPlaces]), &places); err != nil || len(places) == 0 {
|
||||
t.Errorf("no secret places: %v %v", err, words)
|
||||
}
|
||||
var owned map[string]string
|
||||
if err := json.Unmarshal([]byte(words[FilesOwnedPaths]), &owned); err != nil {
|
||||
t.Fatalf("no owned paths: %v (%v)", err, words)
|
||||
}
|
||||
if !strings.HasPrefix(owned["/etc/hosts"], "hostnames") {
|
||||
t.Errorf("/etc/hosts is owned by %q, want hostnames", owned["/etc/hosts"])
|
||||
}
|
||||
if !strings.HasPrefix(owned["/etc/app/app.conf"], "app") {
|
||||
t.Errorf("a placed file is owned by %q, want app", owned["/etc/app/app.conf"])
|
||||
}
|
||||
if w, _ := r.filesWords(app, nil, Rendering{}); w[FilesOwnedPaths] != "" {
|
||||
t.Error("a module not holding node-files is told the owned paths")
|
||||
}
|
||||
}
|
||||
|
||||
// issue 900: restore, like every write verb, takes a reason and an override.
|
||||
func TestNodeFilesRestoreTakesIdAndReason(t *testing.T) {
|
||||
s, _ := SeatNamed(FilesSeat)
|
||||
for _, v := range s.Serves {
|
||||
if v.Name != "restore" {
|
||||
continue
|
||||
}
|
||||
props, _ := v.Input["properties"].(map[string]any)
|
||||
for _, p := range []string{"id", "reason", "override"} {
|
||||
if _, has := props[p]; !has {
|
||||
t.Errorf("restore has no input %s", p)
|
||||
}
|
||||
}
|
||||
if req, _ := v.Input["required"].([]string); strings.Join(req, ",") != "id,reason" {
|
||||
t.Errorf("restore requires %v, want id and reason", req)
|
||||
}
|
||||
return
|
||||
}
|
||||
t.Error("no restore verb")
|
||||
}
|
||||
@@ -104,6 +104,9 @@ type PlanProvision struct {
|
||||
Provider string `json:"provider"`
|
||||
ProviderModule string `json:"provider-module"`
|
||||
CredentialForm string `json:"credential-form,omitempty"`
|
||||
// CredentialTool is, for an offer whose credential the provider's software issues (issue 928), the provider's tool
|
||||
// the vault asks for each holder's value; empty for any other form.
|
||||
CredentialTool string `json:"credential-tool,omitempty"`
|
||||
Path string `json:"path,omitempty"`
|
||||
// Contributes is what the consumer's manifest tells its provider for this provision (Manifest.Contributes: a
|
||||
// database's name, its extensions), so the provider's helper learns it from the bus (issue 776). Nil where the
|
||||
@@ -134,7 +137,7 @@ func provisionsOf(needs []Needed, module, as string, contributes map[string]map[
|
||||
continue
|
||||
}
|
||||
p := PlanProvision{Provision: n.Name, Local: n.Local, Provider: n.From, ProviderModule: n.Module,
|
||||
CredentialForm: n.CredentialForm, Contributes: maps.Clone(contributes[n.Name]),
|
||||
CredentialForm: n.CredentialForm, CredentialTool: n.CredentialTool, Contributes: maps.Clone(contributes[n.Name]),
|
||||
At: n.At, From: n.From, As: as}
|
||||
// **A credential only a template names** (issue 788): named like a file's, so the client fills it from the
|
||||
// vault's row for this holder.
|
||||
|
||||
@@ -123,3 +123,37 @@ func TestAHandedModulesLocalCredentialsOfTheVaultAreNamedByLocalInItsPlan(t *tes
|
||||
t.Fatalf("the plan names %v, want admin and internal-token", names)
|
||||
}
|
||||
}
|
||||
|
||||
// **Issue 969: a key its provider serves only at run time.** A moved module's file may name `${bound:…}` of a
|
||||
// provision whose provider tells it at run time (ssh-client's known_host of the forge). Where the machine takes
|
||||
// handed files the client fills it and the module composes; where it does not the controller must fill it from what
|
||||
// the provider serves statically, cannot, and refuses it with the very words an unmarked module is refused with
|
||||
// (ADR 0349 §17: the old way, byte for byte).
|
||||
func TestAHandedModuleNamingARunTimeBoundKeyComposesOnlyWhereItIsHanded(t *testing.T) {
|
||||
text := "Host ${bound:postgres-database:at}\n KnownHost ${bound:postgres-database:known_host}\n"
|
||||
moved := strings.Replace(botMoved(), `"own-secrets":{"token":"${dir:state}/token"}`,
|
||||
`"requires":["postgres-database"],"own-secrets":{"token":"${dir:state}/token"}`, 1)
|
||||
unmoved := strings.Replace(moved, `"moved":true,`, ``, 1)
|
||||
if unmoved == moved {
|
||||
t.Fatal("the twin is still marked moved")
|
||||
}
|
||||
|
||||
c, got := composeWith(t, withDatabase(t, moved, text, true), handingTo("store:5000"))
|
||||
if strings.Contains(got, "known_host") || len(c.Filled) == 0 {
|
||||
t.Fatalf("handed, the module is filled by the client and the controller names nothing: %v %s", c.Filled, got)
|
||||
}
|
||||
|
||||
for name, with := range map[string]Rendering{
|
||||
"an engine that does not take a filled file alone": {Provisioner: "mesh-provisioner-client", ArtifactStore: "store:5000"},
|
||||
"no provisioner client on the machine": {Hands: true, ArtifactStore: "store:5000"},
|
||||
} {
|
||||
_, err := withDatabase(t, moved, text, true).Compose(sealedForBot(with))
|
||||
if err == nil || !strings.Contains(err.Error(), `asks its postgres-database binding for "known_host"`) {
|
||||
t.Fatalf("%s: the key is not refused: %v", name, err)
|
||||
}
|
||||
_, before := withDatabase(t, unmoved, text, false).Compose(sealedForBot(with))
|
||||
if before == nil || before.Error() != err.Error() {
|
||||
t.Fatalf("%s: refused otherwise than the unmarked module: %v / %v", name, err, before)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -242,3 +242,33 @@ func TestAnOfferAnsweredByTheHelperIsReadAndHandsNoFile(t *testing.T) {
|
||||
t.Fatalf("the flag changed what is composed or handed:\nbefore: %s\nnow: %s\n%v %v", before, got, c2.Filled, c2.Plans)
|
||||
}
|
||||
}
|
||||
|
||||
// A plan entry names the tool of an offer whose credential the provider's software issues (issue 928), so the vault
|
||||
// asks that tool for each holder; an offer of any other form names none.
|
||||
func TestAHandedModulesPlanNamesTheCredentialToolOfASoftwareIssuedOffer(t *testing.T) {
|
||||
for credential, want := range map[string]string{
|
||||
``: "",
|
||||
`,"credential":{"own":"admin"}`: "",
|
||||
`,"credential":{"issued-by":"outside"}`: "",
|
||||
`,"credential":{"issued-by":"software","tool":"gitea_agent_token"}`: "gitea_agent_token",
|
||||
} {
|
||||
raw := `{"module":"store","version":"1","provides":[{"name":"postgres-database","scope":"mesh"` +
|
||||
credential + `}],"serves":{"postgres-database":{"port":5432}}`
|
||||
if strings.Contains(credential, `"own"`) {
|
||||
raw += `,"own-secrets":{"admin":{"path":"/var/lib/store/admin","taken":"at-start"}}`
|
||||
}
|
||||
node := workstation()
|
||||
node.At = "workstation.internal"
|
||||
consumer := notifierConsumer(t, true, false)
|
||||
r, err := Resolve(shelf(consumer, mustParse(t, raw+`}`)), []string{consumer.Module}, node, twoStores("anchor"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r.Account = "op"
|
||||
c, _ := composeWith(t, r, handingTo("store:5000"))
|
||||
got := c.Plans["notify"].Provisions
|
||||
if len(got) != 1 || got[0].CredentialTool != want {
|
||||
t.Errorf("an offer with %q: the plan names %+v, want the tool %q", credential, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A jail whose pattern names <HOST> twice is refused by name: fail2ban would read it as a duplicate
|
||||
// capture group and refuse its whole configuration, leaving the machine with no bans at all — which
|
||||
// is what happened on the control node the day this was built (novox/hq ADR 0179).
|
||||
func TestAJailsPatternNamesTheHostOncePerShape(t *testing.T) {
|
||||
twice := Manifest{Module: "route-proxy", Jails: []Jail{{
|
||||
Name: "route-proxy",
|
||||
Failregex: `^.*(?:refused for <HOST>:\d+|asked from <HOST>:\d+)$`,
|
||||
Jail: "backend = systemd",
|
||||
}}}
|
||||
problems := twice.jailProblems()
|
||||
if len(problems) != 1 || !strings.Contains(problems[0], "names <HOST> 2 times") {
|
||||
t.Fatalf("a pattern naming <HOST> twice was not refused by name: %v", problems)
|
||||
}
|
||||
if !strings.Contains(problems[0], "no bans at all") {
|
||||
t.Errorf("the refusal does not say what it costs: %q", problems[0])
|
||||
}
|
||||
|
||||
// The same two shapes, one pattern each, is how it is written — and is accepted.
|
||||
split := Manifest{Module: "route-proxy", Jails: []Jail{{
|
||||
Name: "route-proxy",
|
||||
Failregex: "^.*refused for <HOST>:\\d+$\n ^.*asked from <HOST>:\\d+$",
|
||||
Jail: "backend = systemd",
|
||||
}}}
|
||||
if problems := split.jailProblems(); len(problems) != 0 {
|
||||
t.Fatalf("one <HOST> per pattern was refused: %v", problems)
|
||||
}
|
||||
}
|
||||
|
||||
// A jail with no name, no pattern, or a name another jail of the same module already took is refused.
|
||||
func TestAJailIsNamedOnceAndSaysWhatAFailureLooksLike(t *testing.T) {
|
||||
m := Manifest{Module: "mailu", Jails: []Jail{
|
||||
{Name: "", Failregex: "^.*rip=<HOST>$"},
|
||||
{Name: "mailu-front", Failregex: " "},
|
||||
{Name: "mailu-front", Failregex: "^.*rip=<HOST>$"},
|
||||
}}
|
||||
problems := strings.Join(m.jailProblems(), "\n")
|
||||
for _, want := range []string{"a jail with no name", "says nothing a failed attempt looks like", "two jails called"} {
|
||||
if !strings.Contains(problems, want) {
|
||||
t.Errorf("missing %q in:\n%s", want, problems)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -303,6 +303,13 @@ func (m Manifest) IssuedCredentialOf(provision string) (string, bool) {
|
||||
return "", false
|
||||
}
|
||||
|
||||
// CredentialToolOf is the tool a plan names for a provision, set only where the offer says the provider's software
|
||||
// issues its credential (issue 928); empty otherwise.
|
||||
func (m Manifest) CredentialToolOf(provision string) string {
|
||||
tool, _ := m.IssuedCredentialOf(provision)
|
||||
return tool
|
||||
}
|
||||
|
||||
// IssuedOutsideOf is whether this module's offer of a provision says only a person outside the mesh can give its
|
||||
// credential (novox/hq ADR 0337): the mesh never makes a value for it, and a consumer holds one only once a person
|
||||
// gave it with `secret accept`.
|
||||
@@ -1984,6 +1991,7 @@ func parseManifest(raw []byte, predefined PredefinedList) (Manifest, error) {
|
||||
problems = append(problems, declaredSeatProblems(m)...)
|
||||
problems = append(problems, RunsAsProblems(m)...)
|
||||
problems = append(problems, ReadsHolderProblems(m)...)
|
||||
problems = append(problems, FilesHolderProblems(m)...)
|
||||
if m.Computed != "" && len(m.Resources) > 0 {
|
||||
// One or the other. A module that both ships files and has them computed would leave
|
||||
// nobody able to say where a given file came from.
|
||||
|
||||
@@ -230,6 +230,8 @@ type Needed struct {
|
||||
// CredentialForm is the credential form the provider's offer declares (Manifest.CredentialFormOf, issue 768),
|
||||
// named in a handed consumer's plan for the vault; empty where the provider's manifest is not in hand.
|
||||
CredentialForm string
|
||||
// CredentialTool is the tool of the offer that the provider's software issues the credential through (issue 928).
|
||||
CredentialTool string
|
||||
// Manager is set when this holder is a refreshable-grant licence's MANAGER, delivered the refresh
|
||||
// token rather than an access token (novox/hq ADR 0050). It changes one thing downstream: an empty
|
||||
// Sealed is tolerated — the manager has not adopted a refresh token yet, which is a real waiting
|
||||
@@ -543,7 +545,7 @@ func Resolve(catalogue map[string]Manifest, assigned []string, node Node, world
|
||||
needs = append(needs, Needed{
|
||||
Name: want, From: node.Name, At: at, Module: by.Module,
|
||||
Serves: servedByOne(by, want), For: because[want],
|
||||
SharedOwn: sharedByOne(by, want), CredentialForm: by.CredentialFormOf(want),
|
||||
SharedOwn: sharedByOne(by, want), CredentialForm: by.CredentialFormOf(want), CredentialTool: by.CredentialToolOf(want),
|
||||
Identity: by.IdentityBoundOf(want)})
|
||||
} else if served := servedByOne(by, want); len(served) > 0 {
|
||||
// Answered here with no credential to mint, but the provider serves facts the
|
||||
@@ -565,7 +567,7 @@ func Resolve(catalogue map[string]Manifest, assigned []string, node Node, world
|
||||
}
|
||||
needs = append(needs, Needed{
|
||||
Name: want, From: node.Name, At: at, Serves: served, For: because[want], Module: by.Module,
|
||||
CredentialForm: by.CredentialFormOf(want)})
|
||||
CredentialForm: by.CredentialFormOf(want), CredentialTool: by.CredentialToolOf(want)})
|
||||
}
|
||||
continue
|
||||
}
|
||||
@@ -594,17 +596,17 @@ func Resolve(catalogue map[string]Manifest, assigned []string, node Node, world
|
||||
node.Name, want, p.Node, meshNetwork))
|
||||
return
|
||||
}
|
||||
shared, form := "", ""
|
||||
shared, form, tool := "", "", ""
|
||||
// A provider whose definition is not in hand is held to the tightest bound the
|
||||
// mesh knows, not to none: what it keeps is not known here (ADR 0225).
|
||||
bound := DefaultIdentityBound
|
||||
if pm, known := catalogue[p.Module]; known {
|
||||
shared, _ = pm.SharedCredentialOf(want)
|
||||
form = pm.CredentialFormOf(want)
|
||||
form, tool = pm.CredentialFormOf(want), pm.CredentialToolOf(want)
|
||||
bound = pm.IdentityBoundOf(want)
|
||||
}
|
||||
needs = append(needs, Needed{Name: want, From: p.Node, At: p.At, Module: p.Module,
|
||||
Serves: p.Serves, For: because[want], SharedOwn: shared, CredentialForm: form, Identity: bound,
|
||||
Serves: p.Serves, For: because[want], SharedOwn: shared, CredentialForm: form, CredentialTool: tool, Identity: bound,
|
||||
LeftOut: p.LeftOut})
|
||||
}
|
||||
switch {
|
||||
|
||||
@@ -178,6 +178,10 @@ func (r Resolution) runtimeProcess(with Rendering) (map[string]any, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// The holder of node-files is told the same, and the paths modules own here (novox/hq ADR 0394 §4).
|
||||
if words, err = r.filesWords(m, words, with); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(words) > 0 {
|
||||
given[m.Module] = words
|
||||
}
|
||||
|
||||
@@ -421,6 +421,9 @@ var defaultSeats = append([]Seat{
|
||||
// A node's files and accounts, read (novox/hq ADR 0310, reads.go): by an account of the holder's own that holds
|
||||
// no secret, on every node, so the reads ADR 0268 §5 takes over from the login shell's execute have a tool.
|
||||
{Name: ReadsSeat, Scope: ScopeNode, Decision: "novox/hq ADR 0310", Serves: readsVerbs()},
|
||||
// A node's files, read and managed (novox/hq ADR 0394, files.go): beside node-reads until mesh-files is assigned
|
||||
// on every node, then node-reads goes (ADR 0394 §9).
|
||||
{Name: FilesSeat, Scope: ScopeNode, Decision: "novox/hq ADR 0394", Serves: filesVerbs()},
|
||||
},
|
||||
// The graphical session's roles (novox/hq ADR 0208), last because they are a workstation's.
|
||||
graphicalSessionSeats()...)
|
||||
|
||||
@@ -48,6 +48,7 @@ func TestTheSeatsAreAClosedSetAndEachNamesItsDecision(t *testing.T) {
|
||||
delivered[s.Delivers] = s.Name
|
||||
}
|
||||
}
|
||||
// Forty-five with node-files (novox/hq ADR 0394); forty-four again once node-reads goes.
|
||||
// Forty-four with node-provisioner (novox/hq ADR 0349 clause 3); forty-three with mesh-provisioner (novox/hq ADR 0349 clause 3); forty-two with mesh-config (novox/hq ADR 0348 §9); forty-one with node-reads (novox/hq ADR 0310); forty with node-nfs-server and node-mounts (novox/hq ADR 0263); thirty-eight with node-resolver (novox/hq ADR 0247); thirty-seven with mesh-delivery (novox/hq ADR 0239); thirty-six since node-resolver-config retired
|
||||
// into node-uplink (novox/hq ADR 0223); thirty-seven
|
||||
// since the retired node-dns-resolver went (novox/hq ADR 0220); thirty-eight with
|
||||
@@ -59,8 +60,8 @@ func TestTheSeatsAreAClosedSetAndEachNamesItsDecision(t *testing.T) {
|
||||
// node-container-runtime (ADR 0207); nineteen with node-environment and node-login-shell (ADR 0203,
|
||||
// ADR 0204); seventeen with node-build-agent (ADR 0190). One fewer once the retired
|
||||
// mesh-build-machine row goes, when no registered manifest claims it.
|
||||
if len(Seats()) != 44 {
|
||||
t.Errorf("the mesh defines %d seats rather than 44; the set is closed, so a change here is "+
|
||||
if len(Seats()) != 45 {
|
||||
t.Errorf("the mesh defines %d seats rather than 45; the set is closed, so a change here is "+
|
||||
"a decision (novox/hq ADR 0110): %s", len(Seats()), seatNames())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,6 +112,10 @@ type Machine struct {
|
||||
// NodeEngine and NodeTools are the builds it runs.
|
||||
NodeEngine string `json:"node-engine,omitempty"`
|
||||
NodeTools string `json:"node-tools,omitempty"`
|
||||
// Contract is the contract of the node-engine's newest health statement (0 when it never stated one): what
|
||||
// says whether the machine takes handed files (novox/hq issue 969). A check that composed it without would
|
||||
// judge a moved module the old way where the mesh hands it.
|
||||
Contract int `json:"contract,omitempty"`
|
||||
// Capabilities are what it reported it can do; the detail kept only where it is a version.
|
||||
Capabilities []Capability `json:"capabilities,omitempty"`
|
||||
// Site, Hub, Public: where it is on the private network — its site (a pseudonym), whether it is the
|
||||
|
||||
@@ -81,18 +81,10 @@ type ProvisionedCollection struct {
|
||||
}
|
||||
|
||||
// ProvisionedCredential is one provision the module requires, under one local name, and its provider (issue 760);
|
||||
// with a name, a credential issued for this holder alone (issue 757).
|
||||
type ProvisionedCredential struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
Provision string `json:"provision"`
|
||||
Local string `json:"local,omitempty"`
|
||||
Provider string `json:"provider"`
|
||||
ProviderModule string `json:"provider-module"`
|
||||
// CredentialForm is the form the provider's offer declares for its credential (issue 768): shared, pair,
|
||||
// issued or given; empty when the provider's manifest was not in hand.
|
||||
CredentialForm string `json:"credential-form,omitempty"`
|
||||
Path string `json:"path,omitempty"`
|
||||
}
|
||||
// with a name, a credential issued for this holder alone (issue 757). It is the composition's own type, not a copy:
|
||||
// the plan is kept and read back as the one it was sent as, so no field of it (contributes, at, from, as, names) can
|
||||
// be lost between the two (issue 923).
|
||||
type ProvisionedCredential = catalogue.PlanProvision
|
||||
|
||||
// ProvisionedSecret is one of the module's own secrets, by its name in the manifest. Path, when set, is the file
|
||||
// holding the secret alone (the manifest's `own-secrets`), which the client fills with its value and nothing else.
|
||||
|
||||
@@ -352,3 +352,42 @@ func TestAPlanMayHoldSeveralNamedItemsOfOneCollectionAndRefusesABadOne(t *testin
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// **A kept plan's provisions keep every field the composition sets** (issue 923): the names, the place, the machine
|
||||
// and the identity the provider's helper writes into the consumer's entry, and what the manifest contributes, are
|
||||
// read back as they were sent, so the assignments mirror carries them to the provider.
|
||||
func TestAKeptPlansProvisionsKeepContributesAtFromAsAndNames(t *testing.T) {
|
||||
sent := catalogue.PlanProvision{Provision: "route", Provider: "novox", ProviderModule: "route-proxy",
|
||||
Contributes: map[string]any{"database": "gitea"}, At: "10.0.0.1", From: "novox", As: "gitea@novox",
|
||||
Names: map[string]string{"name-web": "git.example.org", "internal-name": "gitea.mesh"}}
|
||||
raw, err := json.Marshal(map[string]any{"gitea": map[string]any{
|
||||
"provisions": []catalogue.PlanProvision{sent}, "required-by": "forge"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var kept map[string]keptPlan
|
||||
if err := json.Unmarshal(raw, &kept); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := kept["gitea"].Provisions
|
||||
if len(got) != 1 || !reflect.DeepEqual(got[0], sent) {
|
||||
t.Fatalf("the kept plan's provisions are %+v, want %+v", got, sent)
|
||||
}
|
||||
}
|
||||
|
||||
// A kept plan's provision keeps the tool the vault asks for a software-issued credential (issue 928).
|
||||
func TestAKeptPlansProvisionKeepsItsCredentialTool(t *testing.T) {
|
||||
sent := catalogue.PlanProvision{Provision: "gitea-read", Provider: "novox", ProviderModule: "gitea",
|
||||
CredentialForm: catalogue.CredentialFormIssued, CredentialTool: "gitea_agent_token"}
|
||||
raw, err := json.Marshal(map[string]any{"claude-code": map[string]any{"provisions": []catalogue.PlanProvision{sent}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var kept map[string]keptPlan
|
||||
if err := json.Unmarshal(raw, &kept); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := kept["claude-code"].Provisions; len(got) != 1 || got[0].CredentialTool != "gitea_agent_token" {
|
||||
t.Fatalf("the kept plan's provisions are %+v, want the tool kept", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,6 +179,19 @@ func (b OverNATS) PublishMembership(ctx context.Context, node, module string, bo
|
||||
return nil
|
||||
}
|
||||
|
||||
// PublishMembershipAfter issues a membership only if the one read at sequence seq is still the newest of its
|
||||
// subject: a push that issued another since is not overwritten, and the refusal comes back (novox/hq issue 930).
|
||||
func (b OverNATS) PublishMembershipAfter(ctx context.Context, node, module string, body []byte, seq uint64) error {
|
||||
ctx, cancel := context.WithTimeout(ctx, MembershipWait)
|
||||
defer cancel()
|
||||
_, err := b.JS.Publish(broker.MembershipSubject(node, module), body, nats.Context(ctx),
|
||||
nats.ExpectLastSequencePerSubject(seq))
|
||||
if err != nil {
|
||||
return fmt.Errorf("issuing %s on %s its membership after sequence %d: %w", module, node, seq, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b OverNATS) PublishDeclaration(ctx context.Context, node string, body []byte) error {
|
||||
_, err := b.JS.Publish(DeclareSubject(node), body, nats.Context(ctx))
|
||||
if err != nil {
|
||||
|
||||
Vendored
+2
@@ -8,6 +8,7 @@
|
||||
./cmd/mesh-controller TestAStopWithoutAMergeLeavesItOutOfABatch
|
||||
./cmd/mesh-controller TestSavingPlansAndMovingMergesIsOneAct
|
||||
./cmd/mesh-controller TestAPlanFollowedFromTheStoreAnswersWhenItMoves
|
||||
./cmd/mesh-controller TestIssue969TheGateRaisesEachMachineAsItsEngineStatedItself
|
||||
./internal/inventory TestTheLastSendSaysWhatTheMachineAnswered
|
||||
./internal/inventory TestAReportIsTheLastSendsAnswerOnlyWhenItIsAboutThatSend
|
||||
./cmd/mesh-controller TestNodeShowAndStatusReadTheLastSendFromTheStore
|
||||
@@ -126,3 +127,4 @@
|
||||
./cmd/mesh-controller TestTheRecheckVerbAsksMeshDeliveryOnABusOfItsOwn
|
||||
./cmd/mesh-controller TestAGroupsCompositionIntoAReleaseBranchIsJudgedByItsOwnController
|
||||
./cmd/mesh-controller TestAGroupsCompositionJudgedWithTheValidatorIntoAReleaseBranchIsJudgedByItsOwnController
|
||||
./cmd/mesh-controller TestAssigningTheClientIssuesItsAccount
|
||||
|
||||
Reference in New Issue
Block a user