One bus: the AMQP transport is gone from the controller
The mesh runs on the seat's bus alone (novox/hq ADR 0131, design 28 task 5.5). The old transport's consume loop, build request, tool ask, management API and account scoping are deleted, and the bus switch with them; the controller connects to the broker seat and to nothing else. The store-window tests keep their assertions on a bus-less fake, and the tests that only made sense for the old transport's in-memory holding go with it.
This commit is contained in:
@@ -1,67 +0,0 @@
|
||||
package broker_test
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/broker"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
)
|
||||
|
||||
// The names are written twice, so a test keeps them agreeing.
|
||||
//
|
||||
// `link` imports `broker`, so `broker` cannot import `link` — the queue and exchange names
|
||||
// therefore exist in both. A scoped account naming a queue nothing publishes to produces a
|
||||
// builder that takes no work and says nothing about why, which is the worst kind of silence.
|
||||
//
|
||||
// An external test package, because it may import both without either importing the other.
|
||||
func TestTheNamesTheBrokerScopesAreTheNamesTheLinkUses(t *testing.T) {
|
||||
for _, agreed := range []struct {
|
||||
what string
|
||||
scoped string
|
||||
actually string
|
||||
}{
|
||||
{"the build queue", broker.BuildQueueName, link.BuildQueue},
|
||||
{"the exchange", broker.ExchangeName, link.Exchange},
|
||||
{"a node's queue", broker.QueueFor("somewhere"), link.QueueFor("somewhere")},
|
||||
} {
|
||||
if agreed.scoped != agreed.actually {
|
||||
t.Errorf("%s: the broker scopes %q and the link uses %q",
|
||||
agreed.what, agreed.scoped, agreed.actually)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestABuilderMayWriteToAReplyQueueAndReadNoNodesDeclarations(t *testing.T) {
|
||||
// The scoping, checked as patterns rather than by connecting: what it may write must include
|
||||
// the queue an asker actually waits on, and what it may read must not include any node's.
|
||||
//
|
||||
// This exists because the first version scoped writes to `amq.gen-*` — the name one broker
|
||||
// happens to generate — and the builder built, could not answer, and the connection simply
|
||||
// closed saying only "not allowed to publish to exchange \'\'". Answering through the
|
||||
// exchange is what removed the need for any of that.
|
||||
write := regexp.MustCompile("^" + regexp.QuoteMeta(broker.ExchangeName) + "$")
|
||||
if !write.MatchString(broker.ExchangeName) {
|
||||
t.Error("a builder may not write to the exchange, so it can take work and never answer")
|
||||
}
|
||||
// And not the default exchange, where permission is per exchange rather than per queue — a
|
||||
// builder allowed to use it could publish into any node's queue.
|
||||
//
|
||||
// Confirmed against a real broker as well, and worth recording how that nearly went wrong:
|
||||
// an unconfirmed publish is asynchronous, so a refusal arrives as a channel close afterwards
|
||||
// and a naive check reports success. With publisher confirms the broker's refusal is
|
||||
// immediate. **A negative security assertion made against an asynchronous call is not an
|
||||
// assertion.**
|
||||
if write.MatchString("") {
|
||||
t.Error("a builder may publish to the default exchange, and so into any node's queue")
|
||||
}
|
||||
|
||||
read := regexp.MustCompile("^" + regexp.QuoteMeta(broker.BuildQueueName) + "$")
|
||||
if !read.MatchString(broker.BuildQueueName) {
|
||||
t.Error("a builder may not read the build queue")
|
||||
}
|
||||
if read.MatchString(link.QueueFor("someone-else")) {
|
||||
// A build machine is not a node, and a node's queue carries its declarations.
|
||||
t.Error("a builder may read another machine's declarations")
|
||||
}
|
||||
}
|
||||
@@ -194,16 +194,3 @@ func TestTheAddressPortFollowsThePortTwin(t *testing.T) {
|
||||
t.Fatalf("the address is %q; the node put the bus on 5679", b.Address)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheManagementPortFollowsThePortTwin(t *testing.T) {
|
||||
t.Setenv(ManagementVar, "http://guest:guest@127.0.0.1:15672")
|
||||
t.Setenv(ManagementVar+"_FILE", "")
|
||||
t.Setenv(ManagementVar+"_PORT", "15673")
|
||||
m, err := ManagementFromEnvironment()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if m.base.Host != "127.0.0.1:15673" {
|
||||
t.Fatalf("the management API is at %q; the node put it on 15673", m.base.Host)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,366 +0,0 @@
|
||||
package broker
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/novox/mesh-controller/internal/envfile"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The mesh runs the broker, so there is no chicken-and-egg in a node needing an account before it
|
||||
// can connect: the account is created when the token is issued, and the one-time secret in that
|
||||
// token IS the password. A node's first connection is already authenticated, and enrolment is
|
||||
// what happens over it.
|
||||
//
|
||||
// novox/hq ADR 0004's *a node holds its own identity and nothing else* is why the account is per
|
||||
// node rather than shared. A shared enrolment account would let any node consume another's queue,
|
||||
// which is the shared-credential fault that record exists to remove, reappearing at the transport.
|
||||
|
||||
// ManagementVar holds the broker's management API, credentials included.
|
||||
const ManagementVar = "MESH_BROKER_MANAGEMENT"
|
||||
|
||||
// safeName is what a node may be called at the broker.
|
||||
//
|
||||
// The name goes into a URL path and into permission patterns, which are regular expressions. A
|
||||
// name carrying a `.` or a `*` would silently widen what that node may reach — so it is
|
||||
// constrained here rather than escaped later, because an escape that is forgotten once is a node
|
||||
// reading everybody's queues.
|
||||
var safeName = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,62}$`)
|
||||
|
||||
// Management is the broker's administrative interface.
|
||||
type Management struct {
|
||||
base *url.URL
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
// ManagementFromEnvironment reads where the management API is, if it is configured.
|
||||
//
|
||||
// On the port MESH_BROKER_MANAGEMENT_PORT names when the node moved it (novox/hq 04-ISSUES/102);
|
||||
// the URL's own port otherwise.
|
||||
func ManagementFromEnvironment() (*Management, error) {
|
||||
raw, err := envfile.Placed(ManagementVar)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if raw == "" {
|
||||
return nil, ErrNotConfigured
|
||||
}
|
||||
base, err := url.Parse(raw)
|
||||
if err != nil || base.Host == "" {
|
||||
// The value carries a password, so it is not quoted back.
|
||||
return nil, fmt.Errorf("%s is not a usable URL", ManagementVar)
|
||||
}
|
||||
return &Management{base: base, client: &http.Client{Timeout: 15 * time.Second}}, nil
|
||||
}
|
||||
|
||||
// QueueFor is the queue a node consumes from. One per node, named after it.
|
||||
func QueueFor(node string) string { return "node." + node }
|
||||
|
||||
// ExchangeName is where nodes publish what they have to say. One exchange, and the control plane
|
||||
// is the only consumer behind it (novox/hq ADR 0006 — one consumer, so two cannot silently split
|
||||
// the traffic between them).
|
||||
const ExchangeName = "mesh"
|
||||
|
||||
// BuildQueueName is where build work waits. Duplicated from `link` rather than imported, for the
|
||||
// same reason QueueFor above is: this package must not depend on the one that uses it, and a
|
||||
// constant that differed would be caught by the test that asserts they agree.
|
||||
const BuildQueueName = "builds"
|
||||
|
||||
// The events bus (novox/hq ADR 0042): one topic exchange every event rides, a second for tool
|
||||
// RPC kept apart, and a dead-letter home for a poison event. The foundation owns these — a module's
|
||||
// account cannot declare them, only bind its own queue to the events one.
|
||||
const (
|
||||
EventsExchangeName = "mesh.events"
|
||||
RPCExchangeName = "mesh.rpc"
|
||||
DeadExchangeName = "mesh.events.dead"
|
||||
)
|
||||
|
||||
// ModuleQueueFor is the durable queue a module consumes its events from — one per module per node
|
||||
// (novox/hq ADR 0042), named so the account that may read it is exactly this module's.
|
||||
func ModuleQueueFor(node, module string) string { return node + "." + module + ".events" }
|
||||
|
||||
// modulePermissions is a module's authority on the bus, derived from its manifest (novox/hq
|
||||
// ADR 0043): what it consumes and what it emits, and nothing else. Pure, so the scope is tested as
|
||||
// patterns without a broker — the way a builder's is.
|
||||
//
|
||||
// A note on the limit: the broker's write permission is per exchange, not per routing key (LavinMQ
|
||||
// has no topic permissions), so an emitting module is granted the events exchange whole. ADR 0042's
|
||||
// origin reservation — a module publishes only under `module.<self>.*` — is stamped by the sdk, not
|
||||
// enforced here; that gap is the broker's, and is recorded rather than hidden. A pure consumer like
|
||||
// the audit logger is unaffected: it is granted no write to the exchange at all.
|
||||
func modulePermissions(node, module string, emits, consumes []string) (configure, write, read string) {
|
||||
queue := regexp.QuoteMeta(ModuleQueueFor(node, module))
|
||||
events := regexp.QuoteMeta(EventsExchangeName)
|
||||
rpc := regexp.QuoteMeta(RPCExchangeName)
|
||||
// A module serves each of its tools on its own queue, namespaced by the module (novox/hq
|
||||
// ADR 0047) — serve.<module>.<tool> — so the account may declare, bind and read exactly its own,
|
||||
// and no other module's.
|
||||
serve := "serve\\." + regexp.QuoteMeta(module) + "\\..*"
|
||||
|
||||
// Declare its own events queue and its own tool serve queues.
|
||||
configure = "^(" + queue + "|" + serve + ")$"
|
||||
|
||||
// Write to bind its queue and serve queues (binding is a write on the queue), and to the RPC
|
||||
// exchange to publish replies (ADR 0047: replies ride mesh.rpc, never the default exchange, which
|
||||
// would let it publish into any queue). To the events exchange only if it emits.
|
||||
writes := []string{queue, serve, rpc}
|
||||
if len(emits) > 0 {
|
||||
writes = append(writes, events)
|
||||
}
|
||||
write = "^(" + strings.Join(writes, "|") + ")$"
|
||||
|
||||
// Read its own queue and serve queues to consume them, and the RPC exchange to bind its serve
|
||||
// queues onto. The events exchange to bind onto only if it consumes.
|
||||
reads := []string{queue, serve, rpc}
|
||||
if len(consumes) > 0 {
|
||||
reads = append(reads, events)
|
||||
}
|
||||
read = "^(" + strings.Join(reads, "|") + ")$"
|
||||
return configure, write, read
|
||||
}
|
||||
|
||||
// CreateModuleAccount gives an assigned module its own broker account, scoped by what it emits and
|
||||
// consumes (novox/hq ADR 0043). The account name carries the node so the same module on two machines
|
||||
// holds two accounts, each sealed to its own; the permissions carry the module so one module cannot
|
||||
// read another's queue. Generic — the builder is one instance of this rule, not a separate kind.
|
||||
func (m *Management) CreateModuleAccount(ctx context.Context, node, module, password string, emits, consumes []string) (string, error) {
|
||||
if !safeName.MatchString(node) {
|
||||
return "", fmt.Errorf("%q cannot be part of a broker account: it is a permission pattern", node)
|
||||
}
|
||||
if !safeName.MatchString(module) {
|
||||
return "", fmt.Errorf("%q cannot be part of a broker account: it is a permission pattern", module)
|
||||
}
|
||||
account := node + "-" + module
|
||||
if !safeName.MatchString(account) {
|
||||
return "", fmt.Errorf("%q is not a usable broker account name", account)
|
||||
}
|
||||
|
||||
if err := m.put(ctx, "/api/users/"+url.PathEscape(account),
|
||||
map[string]string{"password": password, "tags": ""}); err != nil {
|
||||
return "", fmt.Errorf("cannot create the broker account for %s on %s: %w", module, node, err)
|
||||
}
|
||||
|
||||
configure, write, read := modulePermissions(node, module, emits, consumes)
|
||||
if err := m.put(ctx, "/api/permissions/%2f/"+url.PathEscape(account), map[string]string{
|
||||
"configure": configure, "write": write, "read": read,
|
||||
}); err != nil {
|
||||
return "", fmt.Errorf("cannot scope the broker account for %s on %s: %w", module, node, err)
|
||||
}
|
||||
return account, nil
|
||||
}
|
||||
|
||||
// EnsureModuleQueue declares a consuming module's queue with its dead-letter exchange, idempotently.
|
||||
// The foundation declares it because a scoped module account may not: the broker refuses a queue with
|
||||
// a dead-letter exchange to a non-administrator (novox/hq ADR 0043), so a consumer passively checks
|
||||
// the queue the mesh made rather than declaring its own.
|
||||
func (m *Management) EnsureModuleQueue(ctx context.Context, node, module string) error {
|
||||
queue := ModuleQueueFor(node, module)
|
||||
if err := m.put(ctx, "/api/queues/%2f/"+url.PathEscape(queue), map[string]any{
|
||||
"durable": true,
|
||||
"arguments": map[string]any{"x-dead-letter-exchange": DeadExchangeName},
|
||||
}); err != nil {
|
||||
return fmt.Errorf("cannot declare the queue for %s on %s: %w", module, node, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// EnsureEventExchanges declares the bus's exchanges and the dead-letter home, idempotently. The
|
||||
// foundation owns them (a module's account may not declare an exchange), and a dead-letter exchange
|
||||
// with no queue behind it drops what it receives — so a durable queue bound to `#` retains a poison
|
||||
// event for inspection, which is the whole reason the trail exists.
|
||||
func (m *Management) EnsureEventExchanges(ctx context.Context) error {
|
||||
for _, exchange := range []string{EventsExchangeName, RPCExchangeName, DeadExchangeName} {
|
||||
if err := m.put(ctx, "/api/exchanges/%2f/"+url.PathEscape(exchange),
|
||||
map[string]any{"type": "topic", "durable": true}); err != nil {
|
||||
return fmt.Errorf("cannot declare the %s exchange: %w", exchange, err)
|
||||
}
|
||||
}
|
||||
if err := m.put(ctx, "/api/queues/%2f/"+url.PathEscape(DeadExchangeName),
|
||||
map[string]any{"durable": true}); err != nil {
|
||||
return fmt.Errorf("cannot declare the dead-letter queue: %w", err)
|
||||
}
|
||||
if err := m.post(ctx, "/api/bindings/%2f/e/"+url.PathEscape(DeadExchangeName)+
|
||||
"/q/"+url.PathEscape(DeadExchangeName), map[string]string{"routing_key": "#"}); err != nil {
|
||||
return fmt.Errorf("cannot bind the dead-letter queue: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreateNodeAccount gives a node its own broker account, with the token's secret as the password.
|
||||
//
|
||||
// Scoped so a node can reach its own queue and the one exchange, and nothing else. The patterns
|
||||
// are anchored: a node called `laptop` must not be able to read `laptop-of-somebody-else`.
|
||||
func (m *Management) CreateNodeAccount(ctx context.Context, node, password string) error {
|
||||
if !safeName.MatchString(node) {
|
||||
return fmt.Errorf(
|
||||
"%q cannot be a broker account name: it becomes part of a permission pattern, so it "+
|
||||
"is lower-case letters, digits and dashes", node)
|
||||
}
|
||||
|
||||
if err := m.put(ctx, "/api/users/"+url.PathEscape(node),
|
||||
map[string]string{"password": password, "tags": ""}); err != nil {
|
||||
return fmt.Errorf("cannot create the broker account for %s: %w", node, err)
|
||||
}
|
||||
|
||||
queue := regexp.QuoteMeta(QueueFor(node))
|
||||
if err := m.put(ctx, "/api/permissions/%2f/"+url.PathEscape(node), map[string]string{
|
||||
"configure": "^" + queue + "$",
|
||||
"write": "^(" + regexp.QuoteMeta(ExchangeName) + "|" + queue + ")$",
|
||||
"read": "^" + queue + "$",
|
||||
}); err != nil {
|
||||
return fmt.Errorf("cannot scope the broker account for %s: %w", node, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreateBuilderAccount scopes an account to taking build work and answering it.
|
||||
//
|
||||
// **A build machine is not a node**, and giving it a node's account would let it read another
|
||||
// machine's declarations. What it needs is narrower and different: read the build queue, and
|
||||
// write to the exchange and to whatever temporary queue an asker is waiting on.
|
||||
//
|
||||
// The reply queues are the reason `write` is not simply the exchange. `RequestBuild` declares an
|
||||
// exclusive queue with a generated name and waits on it, so a builder that could not write to it
|
||||
// could take work and never answer — which is the failure that looks like a builder that is not
|
||||
// running.
|
||||
func (m *Management) CreateBuilderAccount(ctx context.Context, name, password string) error {
|
||||
if !safeName.MatchString(name) {
|
||||
return fmt.Errorf(
|
||||
"%q cannot be a broker account name: it becomes part of a permission pattern, so it "+
|
||||
"is lower-case letters, digits and dashes", name)
|
||||
}
|
||||
|
||||
if err := m.put(ctx, "/api/users/"+url.PathEscape(name),
|
||||
map[string]string{"password": password, "tags": ""}); err != nil {
|
||||
return fmt.Errorf("cannot create the broker account for %s: %w", name, err)
|
||||
}
|
||||
|
||||
builds := regexp.QuoteMeta(BuildQueueName)
|
||||
if err := m.put(ctx, "/api/permissions/%2f/"+url.PathEscape(name), map[string]string{
|
||||
// It declares the build queue, because whichever builder starts first must be able to —
|
||||
// and a queue nobody may declare is a queue that exists only if the control plane has
|
||||
// already run, which makes the order they start in matter.
|
||||
"configure": "^" + builds + "$",
|
||||
// Two exchanges, and nothing else. **Not the default exchange**: permission there is
|
||||
// granted per exchange rather than per queue, so a builder allowed to use it could
|
||||
// publish into any node's queue — the privilege a build machine most obviously should
|
||||
// not have. Answers go through the node exchange; announcing what was built goes through
|
||||
// the events exchange, which is a different act with a different audience (novox/hq
|
||||
// ADR 0072). A builder that could answer and not announce would leave the module graph
|
||||
// knowing less than the registry does.
|
||||
"write": "^(" + regexp.QuoteMeta(ExchangeName) + "|" + regexp.QuoteMeta(EventsExchangeName) + ")$",
|
||||
// The build queue and nothing else. Not another machine's declarations.
|
||||
"read": "^" + builds + "$",
|
||||
}); err != nil {
|
||||
return fmt.Errorf("cannot scope the broker account for %s: %w", name, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveNodeAccount withdraws a node's access.
|
||||
func (m *Management) RemoveNodeAccount(ctx context.Context, node string) error {
|
||||
if !safeName.MatchString(node) {
|
||||
return fmt.Errorf("%q is not a broker account name", node)
|
||||
}
|
||||
return m.do(ctx, http.MethodDelete, "/api/users/"+url.PathEscape(node), nil)
|
||||
}
|
||||
|
||||
// Accounts lists the broker's users, so a picture can be read from the system rather than assumed
|
||||
// (novox/hq ADR 0018).
|
||||
func (m *Management) Accounts(ctx context.Context) ([]string, error) {
|
||||
body, err := m.get(ctx, "/api/users")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var users []struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &users); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
names := make([]string, 0, len(users))
|
||||
for _, u := range users {
|
||||
names = append(names, u.Name)
|
||||
}
|
||||
return names, nil
|
||||
}
|
||||
|
||||
func (m *Management) put(ctx context.Context, path string, body any) error {
|
||||
return m.do(ctx, http.MethodPut, path, body)
|
||||
}
|
||||
|
||||
func (m *Management) post(ctx context.Context, path string, body any) error {
|
||||
return m.do(ctx, http.MethodPost, path, body)
|
||||
}
|
||||
|
||||
func (m *Management) get(ctx context.Context, path string) ([]byte, error) {
|
||||
request, err := m.request(ctx, http.MethodGet, path, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
response, err := m.client.Do(request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("the broker's management API answered %s to GET %s",
|
||||
response.Status, path)
|
||||
}
|
||||
return io.ReadAll(io.LimitReader(response.Body, 1<<20))
|
||||
}
|
||||
|
||||
func (m *Management) do(ctx context.Context, method, path string, body any) error {
|
||||
request, err := m.request(ctx, method, path, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
response, err := m.client.Do(request)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode >= 300 {
|
||||
detail, _ := io.ReadAll(io.LimitReader(response.Body, 4096))
|
||||
return fmt.Errorf("the broker's management API answered %s to %s %s: %s",
|
||||
response.Status, method, path, strings.TrimSpace(string(detail)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Management) request(ctx context.Context, method, path string, body any) (*http.Request, error) {
|
||||
var payload io.Reader
|
||||
if body != nil {
|
||||
raw, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
payload = bytes.NewReader(raw)
|
||||
}
|
||||
|
||||
// Path joined by hand rather than through url.Parse: %2f is the default vhost and must reach
|
||||
// the broker still encoded. Parsing would decode it to a slash and address a different route.
|
||||
target := strings.TrimSuffix(m.base.String(), "/")
|
||||
if user := m.base.User; user != nil {
|
||||
target = strings.TrimSuffix(m.base.Scheme+"://"+m.base.Host, "/")
|
||||
}
|
||||
request, err := http.NewRequestWithContext(ctx, method, target+path, payload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if user := m.base.User; user != nil {
|
||||
password, _ := user.Password()
|
||||
request.SetBasicAuth(user.Username(), password)
|
||||
}
|
||||
if body != nil {
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
return request, nil
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
package broker_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/broker"
|
||||
)
|
||||
|
||||
// The audit logger consumes everything and emits nothing. Its account must let it declare and read
|
||||
// its own queue and read the events exchange to bind onto — and must not reach another module's
|
||||
// queue, nor grant any write to the events exchange (novox/hq ADR 0043).
|
||||
func TestAConsumerReadsItsOwnQueueAndTheEventsExchangeAndNoOthers(t *testing.T) {
|
||||
// Rebuilt through CreateModuleAccount's own path by asking for the same scope it would apply.
|
||||
// The queue this module reads:
|
||||
mine := broker.ModuleQueueFor("anchor", "audit-logger")
|
||||
other := broker.ModuleQueueFor("anchor", "plex")
|
||||
|
||||
read := scope(t, "read", "anchor", "audit-logger", nil, []string{"#"})
|
||||
if !read.MatchString(mine) {
|
||||
t.Error("the audit logger may not read its own queue, so it consumes nothing")
|
||||
}
|
||||
if !read.MatchString(broker.EventsExchangeName) {
|
||||
t.Error("the audit logger may not read the events exchange, so it cannot bind onto it")
|
||||
}
|
||||
if read.MatchString(other) {
|
||||
t.Error("the audit logger may read another module's queue")
|
||||
}
|
||||
|
||||
// It emits nothing, so it is granted no write to the events exchange — only its own queue, to bind.
|
||||
write := scope(t, "write", "anchor", "audit-logger", nil, []string{"#"})
|
||||
if write.MatchString(broker.EventsExchangeName) {
|
||||
t.Error("a pure consumer was granted write to the events exchange")
|
||||
}
|
||||
if !write.MatchString(mine) {
|
||||
t.Error("the audit logger may not write to its own queue, so it cannot bind it")
|
||||
}
|
||||
}
|
||||
|
||||
// An emitter is granted the events exchange to write; a consumer is not.
|
||||
func TestAnEmitterMayWriteTheEventsExchangeAndAConsumerMayNot(t *testing.T) {
|
||||
emitter := scope(t, "write", "anchor", "umami", []string{"module.umami.site.created"}, nil)
|
||||
if !emitter.MatchString(broker.EventsExchangeName) {
|
||||
t.Error("an emitting module may not write the events exchange, so it cannot emit")
|
||||
}
|
||||
}
|
||||
|
||||
// scope reconstructs one of the three permission patterns CreateModuleAccount would apply, by
|
||||
// reading it back from a captured request against a stub management API.
|
||||
func scope(t *testing.T, which, node, module string, emits, consumes []string) *regexp.Regexp {
|
||||
t.Helper()
|
||||
pat := capturePermission(t, which, node, module, emits, consumes)
|
||||
re, err := regexp.Compile(pat)
|
||||
if err != nil {
|
||||
t.Fatalf("the %s pattern does not compile: %v", which, err)
|
||||
}
|
||||
return re
|
||||
}
|
||||
|
||||
// capturePermission runs CreateModuleAccount against a stub management API and returns the pattern
|
||||
// it set for `which` ("configure"/"write"/"read"). The scope is tested where it is applied, not
|
||||
// reconstructed by the test — so a change to the mapping cannot pass a test that hard-codes the old
|
||||
// one.
|
||||
func capturePermission(t *testing.T, which, node, module string, emits, consumes []string) string {
|
||||
t.Helper()
|
||||
var captured map[string]string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasPrefix(r.URL.Path, "/api/permissions/") {
|
||||
_ = json.NewDecoder(r.Body).Decode(&captured)
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
t.Setenv(broker.ManagementVar, server.URL)
|
||||
m, err := broker.ManagementFromEnvironment()
|
||||
if err != nil {
|
||||
t.Fatalf("stub management not usable: %v", err)
|
||||
}
|
||||
if _, err := m.CreateModuleAccount(context.Background(), node, module, "pw", emits, consumes); err != nil {
|
||||
t.Fatalf("CreateModuleAccount: %v", err)
|
||||
}
|
||||
if captured == nil {
|
||||
t.Fatal("no permissions were set")
|
||||
}
|
||||
pattern, ok := captured[which]
|
||||
if !ok {
|
||||
t.Fatalf("no %s permission was set; got %v", which, captured)
|
||||
}
|
||||
return pattern
|
||||
}
|
||||
+11
-19
@@ -68,24 +68,16 @@ func BareAddress(address string) string {
|
||||
return "nats://" + bare
|
||||
}
|
||||
|
||||
// MustBeOneBus refuses a configuration that names both buses for the mesh's own traffic.
|
||||
//
|
||||
// **Both clients ship and that is the point; both being live is not.** The rollout moves every node
|
||||
// at once (ADR 0116 step 5): a mesh half on each is one where a declaration goes out on one bus and
|
||||
// the report comes back on the other, and nothing anywhere says so — every component would log
|
||||
// success. Refused at start, where it can be said in one sentence.
|
||||
func MustBeOneBus(amqp, nats string) error {
|
||||
if strings.TrimSpace(amqp) != "" && strings.TrimSpace(nats) != "" {
|
||||
return fmt.Errorf(
|
||||
"this control plane is told about both buses (%s and %s) and can only be on one. A mesh "+
|
||||
"half on each is one where a declaration goes out on one and the report comes back "+
|
||||
"on the other, and every component reports success while it happens. The rollout "+
|
||||
"moves every node at once: unset %s to stay, or unset %s to move",
|
||||
AMQPVarName, NATSVar, NATSVar, AMQPVarName)
|
||||
// BusAddress is where the mesh's bus is, with this process's credential, and refuses to be empty:
|
||||
// there is one bus, and a control plane without it can hold records and answer nothing (novox/hq
|
||||
// ADR 0131, design 28 task 5.5).
|
||||
func BusAddress() (string, error) {
|
||||
address, _, err := OnNATS()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return nil
|
||||
if address == "" {
|
||||
return "", fmt.Errorf("this control plane has no %s, so it cannot reach the mesh's bus", NATSVar)
|
||||
}
|
||||
return address, nil
|
||||
}
|
||||
|
||||
// AMQPVarName is the variable naming the bus the mesh runs on today. Named here rather than
|
||||
// imported from the link package, for the one direction of dependency.
|
||||
const AMQPVarName = "MESH_BROKER_AMQP"
|
||||
|
||||
@@ -1,43 +1,11 @@
|
||||
package broker
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Which bus the mesh is on is one fact, and being told about both is refused.
|
||||
//
|
||||
// **Not a warning.** A mesh half on each bus is one where a declaration goes out on one and the
|
||||
// report comes back on the other, and every component reports success while it happens — which is
|
||||
// the exact failure ADR 0074 exists to catch, arriving through configuration instead of through code.
|
||||
func TestBeingToldAboutBothBusesIsRefused(t *testing.T) {
|
||||
err := MustBeOneBus("amqps://broker:5671/", "nats://bus:4222")
|
||||
if err == nil {
|
||||
t.Fatal("a control plane told about both buses was allowed to start")
|
||||
}
|
||||
// The remedy is in the words, because whoever reads this has to choose one and the wrong choice
|
||||
// is a rollout half done.
|
||||
for _, want := range []string{AMQPVarName, NATSVar, "unset"} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("the refusal does not mention %s: %v", want, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// One bus, or none, is ordinary. None is a control plane that publishes nothing and holds records,
|
||||
// which several of its own commands are.
|
||||
func TestOneBusOrNeitherIsAllowed(t *testing.T) {
|
||||
for _, c := range []struct{ what, amqp, nats string }{
|
||||
{"the bus the mesh runs on today", "amqps://broker:5671/", ""},
|
||||
{"the bus being built", "", "nats://bus:4222"},
|
||||
{"neither", "", ""},
|
||||
{"neither, with whitespace for an address", " ", "\t"},
|
||||
} {
|
||||
if err := MustBeOneBus(c.amqp, c.nats); err != nil {
|
||||
t.Errorf("%s was refused: %v", c.what, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The controller's own credential arrives in its address, and has to be readable out of it — its user
|
||||
// is created by the installer at a bootstrap password, before the controller exists to mint one.
|
||||
|
||||
Reference in New Issue
Block a user