Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c1334f3f85 | ||
|
|
70d379816c | ||
|
|
d8e7c13f6d | ||
|
|
1851a15e57 | ||
|
|
d9dbc9a59a | ||
|
|
d5e1332dda | ||
|
|
5ea0e87059 | ||
|
|
8e81266cdc | ||
|
|
70705ffe45 | ||
|
|
91c4da8a82 | ||
|
|
e9df5dccab | ||
|
|
990ef27cd2 | ||
|
|
0a17a9a2eb | ||
|
|
23907984a3 | ||
|
|
f0634e11f4 | ||
|
|
542ce76c0a | ||
|
|
2882b5fcb1 | ||
|
|
481b1a7b05 | ||
|
|
b58578f88d | ||
|
|
6cb285dd5c | ||
|
|
46f324b10b | ||
|
|
d188eec318 | ||
|
|
c978aa7d64 | ||
|
|
0c7f42a18a | ||
|
|
9a5584b1b6 | ||
|
|
1bc099a2db | ||
|
|
3fc1feff38 | ||
|
|
ffe176f6d1 | ||
|
|
8057cc4888 | ||
|
|
9d6dad37c5 | ||
|
|
7f84ddecc5 | ||
|
|
94ab9f665e | ||
|
|
3600f2cf16 | ||
|
|
005bc16c24 | ||
|
|
985e2008ba | ||
|
|
bd7ee12938 | ||
|
|
0983b00284 | ||
|
|
8ee2e4d441 | ||
|
|
1d9c102889 | ||
|
|
2542aa67b0 | ||
|
|
1da96e8803 | ||
|
|
cf2f62cf14 | ||
|
|
7683ba8b5b | ||
|
|
a0d7d72a26 | ||
|
|
bfd983e3f8 | ||
|
|
e7da39de57 | ||
|
|
e6ddc59cde | ||
|
|
6c5dfd0c25 | ||
|
|
775df79893 | ||
|
|
3fbf658c16 | ||
|
|
bc31745607 | ||
|
|
e5c2eb20f2 | ||
|
|
05fb7fb5eb | ||
|
|
2c1733de8d | ||
|
|
e51c94dcb5 | ||
|
|
07c07902ff |
@@ -27,8 +27,18 @@ build:
|
||||
IMAGE ?= mesh-controller:$(VERSION)
|
||||
DEV_TAG ?= mesh-controller:development
|
||||
|
||||
# The base the module declares, read from the manifest rather than written here twice.
|
||||
#
|
||||
# **`make image` was broken and stayed broken**, because the Dockerfile's fallback base was a Go
|
||||
# older than go.mod asks for: every build died at `go mod download` with "go.mod requires go >=
|
||||
# 1.26.0", and the pipeline never saw it because the pipeline passes the declared base in. Anybody
|
||||
# building the image by hand hit it and had to find the digest themselves (novox/hq 04-ISSUES/146,
|
||||
# what it cost).
|
||||
GO_BASE ?= $(shell python3 -c "import json;print(next(o['image'] for o in json.load(open('module.json'))['build']['on'] if o['arg']=='GO_BASE'))" 2>/dev/null)
|
||||
|
||||
image:
|
||||
docker build --build-arg VERSION=$(VERSION) -t $(IMAGE) -t $(DEV_TAG) .
|
||||
@test -n "$(GO_BASE)" || { echo "module.json declares no GO_BASE; pass GO_BASE=<image> or fix the manifest"; exit 1; }
|
||||
docker build --build-arg GO_BASE=$(GO_BASE) --build-arg VERSION=$(VERSION) -t $(IMAGE) -t $(DEV_TAG) .
|
||||
@echo
|
||||
@docker image inspect $(IMAGE) --format 'built {{.RepoTags}} {{.Size}} bytes'
|
||||
|
||||
@@ -38,7 +48,8 @@ BUILDER_IMAGE ?= mesh-builder:$(VERSION)
|
||||
BUILDER_DEV_TAG ?= mesh-builder:development
|
||||
|
||||
builder-image:
|
||||
docker build -f cmd/mesh-builder/Dockerfile -t $(BUILDER_IMAGE) -t $(BUILDER_DEV_TAG) .
|
||||
@test -n "$(GO_BASE)" || { echo "module.json declares no GO_BASE; pass GO_BASE=<image> or fix the manifest"; exit 1; }
|
||||
docker build --build-arg GO_BASE=$(GO_BASE) -f cmd/mesh-builder/Dockerfile -t $(BUILDER_IMAGE) -t $(BUILDER_DEV_TAG) .
|
||||
@echo
|
||||
@docker image inspect $(BUILDER_IMAGE) --format 'built {{.RepoTags}} {{.Size}} bytes'
|
||||
|
||||
|
||||
@@ -171,10 +171,9 @@ func answer(ctx context.Context, publisher builder.Publisher, on, workspace stri
|
||||
// after a clone that then fails at npm ci.
|
||||
built, err = builder.Build(ctx, builder.Command, publisher,
|
||||
request.Repository, request.Path, request.Ref, workspace, request.Held, npmrc,
|
||||
forgeFrom(),
|
||||
func(step, message string) {
|
||||
forgeFrom(), func(step, message string) {
|
||||
fmt.Fprintf(os.Stderr, " [%s] %s\n", step, message)
|
||||
})
|
||||
}, request.Seats)
|
||||
}
|
||||
if err != nil {
|
||||
// A failure is a result. A build that fails and says nothing is indistinguishable from a
|
||||
|
||||
@@ -46,6 +46,13 @@ func assign(ctx context.Context, open *stores, node, module string) (string, err
|
||||
return "", err
|
||||
}
|
||||
defer release()
|
||||
// **Before the new assignment can unsettle a seat somebody holds only by being alone**
|
||||
// (novox/hq 04-ISSUES/170): what the mesh derived so far is written down, and then the
|
||||
// assignment resolves against a record rather than against a coincidence.
|
||||
settled, err := recordDerivedHolders(ctx, open)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
fresh, err := open.inventory.Assign(ctx, node, module)
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -57,6 +64,9 @@ func assign(ctx context.Context, open *stores, node, module string) (string, err
|
||||
node, module), nil
|
||||
}
|
||||
said := fmt.Sprintf("%s is assigned %s", node, module)
|
||||
for _, line := range settled {
|
||||
said += "\n " + line
|
||||
}
|
||||
plan, _, err := planFor(ctx, open, node)
|
||||
if err != nil {
|
||||
// Kept, and still refused. Both halves are the answer, and the rest of the mesh is still
|
||||
|
||||
@@ -408,6 +408,7 @@ func buildOne(ctx context.Context, source buildSource, path, ref string, wait ti
|
||||
Path: path,
|
||||
Ref: ref,
|
||||
Held: heldBy(ctx),
|
||||
Seats: seatBases(ctx),
|
||||
}
|
||||
fmt.Printf("asked for %s", source)
|
||||
if source.Seat != "" {
|
||||
@@ -513,7 +514,7 @@ func buildAndShow(ctx context.Context, source buildSource, path, ref string, wai
|
||||
result, err := ask.Submit(ctx, link.BuildRequest{
|
||||
ID: fmt.Sprintf("%s-%d", "build", time.Now().UnixNano()),
|
||||
Repository: repository, Path: path, Ref: ref,
|
||||
Held: heldBy(ctx),
|
||||
Held: heldBy(ctx), Seats: seatBases(ctx),
|
||||
}, wait)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -556,6 +557,16 @@ type answers struct {
|
||||
// a consequence of the refusals above: a node that does not resolve is not on the network, and
|
||||
// a mesh whose hub is that node has no hub.
|
||||
network string
|
||||
// untaken is, per machine, each assigned module whose resources the machine is holding as it
|
||||
// found them, and how many — a module that was assigned, sent, and is running none of what it
|
||||
// declares because nothing has taken it (novox/hq ADR 0100, 04-ISSUES/125).
|
||||
//
|
||||
// **Its absence cost an outage.** The module was assigned, the push reported success, this
|
||||
// command said the machine was doing everything it was told, and the module's three containers
|
||||
// did not exist. On the strength of those reports the predecessor's proxy was stopped and every
|
||||
// public name on the machine went dark. The holds were correct; they were recorded only in the
|
||||
// machine's own state file, and the one visible symptom was a count that did not add up.
|
||||
untaken map[string]map[string]int
|
||||
}
|
||||
|
||||
// heldBy is every artifact this mesh has built, for a build that may need one as its base.
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/broker"
|
||||
)
|
||||
|
||||
// The bus's own certificate, made by the mesh rather than borrowed from an image.
|
||||
//
|
||||
// **The foundation asked a third-party image for a tool it never said must be there** (novox/hq
|
||||
// 04-ISSUES/146). The bootstrap made this certificate by running `openssl` inside the broker's
|
||||
// image, which worked while the broker was one that happened to carry it and stopped the day the
|
||||
// bus changed: the new one has a shell and no openssl, so the step exited 127 and no mesh could be
|
||||
// raised. Substituting another image the bundle names does not help — none of them carry it
|
||||
// either.
|
||||
//
|
||||
// So the program that needs a certificate makes one. It is the mesh's own binary, already on the
|
||||
// machine at this point in the bootstrap (the schema step ran it), and it needs nothing from the
|
||||
// image it writes into but a mounted directory.
|
||||
//
|
||||
// **Self-signed, and that is the design** — a host pins this server's exact certificate and
|
||||
// authenticates with a password (novox/hq ADR 0004). There is no authority above it to ask, and at
|
||||
// this moment in a bootstrap there is no mesh to ask one of.
|
||||
//
|
||||
// Idempotent, because the step is applied again on every reconcile and a second certificate would
|
||||
// be one the hosts that pinned the first no longer believe.
|
||||
|
||||
// busCertificateNames is what the bus is reached by: the container name on a mesh network, and the
|
||||
// loopback address the machine's own foundation dials.
|
||||
var busCertificateNames = []string{"mesh-broker"}
|
||||
|
||||
const busCertificateLife = 10 * 365 * 24 * time.Hour
|
||||
|
||||
// busCertificate makes the bus's certificate in a directory, or says whether one is there.
|
||||
//
|
||||
// broker certificate --into /tls make it if it is not there
|
||||
// broker certificate --check --into /tls exit non-zero unless a usable pair is
|
||||
func busCertificate(args []string) error {
|
||||
into, check := "", false
|
||||
for i := 0; i < len(args); i++ {
|
||||
switch args[i] {
|
||||
case "--check":
|
||||
check = true
|
||||
case "--into":
|
||||
if i+1 >= len(args) {
|
||||
return errors.New("--into needs a directory")
|
||||
}
|
||||
into = args[i+1]
|
||||
i++
|
||||
default:
|
||||
return fmt.Errorf("broker certificate [--check] --into <directory>: %q", args[i])
|
||||
}
|
||||
}
|
||||
if into == "" {
|
||||
return errors.New("broker certificate [--check] --into <directory>")
|
||||
}
|
||||
crt, key := filepath.Join(into, "tls.crt"), filepath.Join(into, "tls.key")
|
||||
|
||||
if usable, err := busCertificateUsable(crt, key); err != nil {
|
||||
return err
|
||||
} else if usable {
|
||||
fmt.Printf("the bus already has a certificate at %s, and it was left alone\n", crt)
|
||||
return nil
|
||||
}
|
||||
if check {
|
||||
// Said as a failure, because that is what the caller asked: a bootstrap's verify runs
|
||||
// this and a false answer is what makes the step run.
|
||||
return fmt.Errorf("no usable certificate and key at %s", into)
|
||||
}
|
||||
return writeBusCertificate(crt, key)
|
||||
}
|
||||
|
||||
// busCertificateUsable says whether a certificate and its key are both there and parse.
|
||||
//
|
||||
// Both, and parsed rather than stat'ed: a half-written pair is the state a bootstrap interrupted
|
||||
// between the two files leaves behind, and a step that treated it as done would hand the server a
|
||||
// certificate with no key and report success.
|
||||
func busCertificateUsable(crt, key string) (bool, error) {
|
||||
certPEM, err := os.ReadFile(crt)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
keyPEM, err := os.ReadFile(key)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if _, err := tlsPairParses(certPEM, keyPEM); err != nil {
|
||||
return false, nil
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func tlsPairParses(certPEM, keyPEM []byte) (*x509.Certificate, error) {
|
||||
block, _ := pem.Decode(certPEM)
|
||||
if block == nil || block.Type != "CERTIFICATE" {
|
||||
return nil, errors.New("not a certificate")
|
||||
}
|
||||
certificate, err := x509.ParseCertificate(block.Bytes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
keyBlock, _ := pem.Decode(keyPEM)
|
||||
if keyBlock == nil {
|
||||
return nil, errors.New("not a key")
|
||||
}
|
||||
if _, err := x509.ParsePKCS8PrivateKey(keyBlock.Bytes); err != nil {
|
||||
if _, err := x509.ParsePKCS1PrivateKey(keyBlock.Bytes); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return certificate, nil
|
||||
}
|
||||
|
||||
func writeBusCertificate(crt, key string) error {
|
||||
private, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
template := &x509.Certificate{
|
||||
SerialNumber: serial,
|
||||
Subject: pkix.Name{CommonName: busCertificateNames[0]},
|
||||
DNSNames: busCertificateNames,
|
||||
IPAddresses: []net.IP{net.ParseIP("127.0.0.1")},
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().Add(busCertificateLife),
|
||||
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||
BasicConstraintsValid: true,
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, template, template, &private.PublicKey, private)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pkcs8, err := x509.MarshalPKCS8PrivateKey(private)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// **The key first, and only then the certificate**, so the pair a reader finds is never a
|
||||
// certificate whose key has not been written yet — the one order in which an interruption
|
||||
// leaves something that looks finished (novox/hq 04-ISSUES/014, a key present and unusable).
|
||||
if err := os.WriteFile(key, pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: pkcs8}), 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(crt, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("made the bus a certificate for %v, valid until %s\n %s\n %s\n",
|
||||
busCertificateNames, template.NotAfter.Format(time.RFC3339), crt, key)
|
||||
return nil
|
||||
}
|
||||
|
||||
// busAccounts writes the mesh's composed user list to a file.
|
||||
//
|
||||
// **For genesis, where no declaration can deliver it** (novox/hq 04-ISSUES/146). Everywhere else
|
||||
// the list reaches the machine running the bus as a resource of the module that holds it — which
|
||||
// requires that machine to be an enrolled node, and at genesis it is not: the first node cannot
|
||||
// enrol because the account it would enrol with cannot be composed onto a bus it has no declaration
|
||||
// for. The installer breaks that circle by placing the file itself, once, and the module takes the
|
||||
// file over from its first push.
|
||||
//
|
||||
// The same composition, not a second one: this asks the store for the same records and renders them
|
||||
// with the same composer the declaration uses. A genesis that hand-wrote an account would be a
|
||||
// second statement of who may say what, able to disagree with the first.
|
||||
//
|
||||
// **It writes to standard output unless told a file**, and that is the point: the control plane
|
||||
// composes and says what it composed, and whoever is raising the machine puts it where that
|
||||
// machine's bus reads it. A control plane that wrote into the bus's own directory would have to
|
||||
// know where that is and how to make the server re-read it — which is the module's knowledge, and
|
||||
// the module is what takes this over on the first push.
|
||||
//
|
||||
// broker accounts > /var/lib/mesh-bus-conf/accounts.conf
|
||||
func busAccounts(ctx context.Context, args []string) error {
|
||||
into := ""
|
||||
for i := 0; i < len(args); i++ {
|
||||
switch args[i] {
|
||||
case "--into":
|
||||
if i+1 >= len(args) {
|
||||
return errors.New("--into needs a file")
|
||||
}
|
||||
into = args[i+1]
|
||||
i++
|
||||
default:
|
||||
return fmt.Errorf("broker accounts --into <file>: %q", args[i])
|
||||
}
|
||||
}
|
||||
|
||||
open, err := openStores(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer open.Close()
|
||||
|
||||
records, err := open.inventory.BusRecords(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
users, err := broker.Users(records)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
kept, err := open.inventory.BusUsers(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
hashes := make(map[string]string, len(kept))
|
||||
for name, u := range kept {
|
||||
hashes[name] = u.PasswordHash
|
||||
}
|
||||
filled, missing := broker.WithPasswords(users, hashes)
|
||||
if len(missing) > 0 {
|
||||
// To standard error, always: the composed file may be going to standard output, and a
|
||||
// remark in the middle of it is a configuration the server refuses to parse.
|
||||
fmt.Fprintf(os.Stderr, "leaving out %d user(s) the mesh has minted no credential for: %s\n",
|
||||
len(missing), strings.Join(missing, ", "))
|
||||
}
|
||||
if len(filled) == 0 {
|
||||
return errors.New("not one user has a credential, so this list would refuse every " +
|
||||
"connection in the mesh")
|
||||
}
|
||||
accounts, err := broker.ComposeAccounts(filled)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if into == "" {
|
||||
fmt.Print(accounts)
|
||||
return nil
|
||||
}
|
||||
if err := os.WriteFile(into, []byte(accounts), 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("wrote %d user(s) to %s\n", len(filled), into)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// novox/hq 04-ISSUES/146. The bootstrap could not make the bus a certificate: it asked an image for
|
||||
// `openssl` and the image it asks has none. What replaces it is this command, so what is checked is
|
||||
// what the bootstrap needs from it — a pair a TLS server can actually load, made once and only once.
|
||||
|
||||
func TestTheBusCertificateLoadsAsAServersWould(t *testing.T) {
|
||||
into := t.TempDir()
|
||||
if err := busCertificate([]string{"--into", into}); err != nil {
|
||||
t.Fatalf("the bus could not be given a certificate: %v", err)
|
||||
}
|
||||
|
||||
// The check a cheaper test would not make. The key was present and valid and the server could
|
||||
// not start, once, because nothing loaded the pair the way a server loads it
|
||||
// (novox/hq 04-ISSUES/014).
|
||||
pair, err := tls.LoadX509KeyPair(filepath.Join(into, "tls.crt"), filepath.Join(into, "tls.key"))
|
||||
if err != nil {
|
||||
t.Fatalf("a TLS server cannot load what was written: %v", err)
|
||||
}
|
||||
leaf := pair.Leaf
|
||||
if leaf == nil {
|
||||
if leaf, err = x509.ParseCertificate(pair.Certificate[0]); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := leaf.VerifyHostname("mesh-broker"); err != nil {
|
||||
t.Errorf("the certificate is not for the name the bus is reached by: %v", err)
|
||||
}
|
||||
if len(leaf.IPAddresses) == 0 || leaf.IPAddresses[0].String() != "127.0.0.1" {
|
||||
t.Errorf("the certificate does not cover the loopback address the foundation dials: %v", leaf.IPAddresses)
|
||||
}
|
||||
|
||||
// The key is not readable by anything else on the machine; the certificate is public and is.
|
||||
key, err := os.Stat(filepath.Join(into, "tls.key"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if key.Mode().Perm() != 0o600 {
|
||||
t.Errorf("the key is %v", key.Mode().Perm())
|
||||
}
|
||||
crt, err := os.Stat(filepath.Join(into, "tls.crt"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if crt.Mode().Perm() != 0o644 {
|
||||
t.Errorf("the certificate is %v, which the server runs as another user cannot read", crt.Mode().Perm())
|
||||
}
|
||||
}
|
||||
|
||||
// **Made once.** The step is applied again on every reconcile, and a second certificate is one the
|
||||
// hosts that pinned the first no longer believe (novox/hq ADR 0004).
|
||||
func TestTheBusCertificateIsMadeOnce(t *testing.T) {
|
||||
into := t.TempDir()
|
||||
if err := busCertificate([]string{"--into", into}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first, err := os.ReadFile(filepath.Join(into, "tls.crt"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := busCertificate([]string{"--into", into}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
again, err := os.ReadFile(filepath.Join(into, "tls.crt"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(first) != string(again) {
|
||||
t.Fatal("running it twice replaced the certificate every host had pinned")
|
||||
}
|
||||
}
|
||||
|
||||
// The verify half: false before, true after, which is what makes the bootstrap run the step at all.
|
||||
func TestTheCheckIsFalseUntilThereIsAPair(t *testing.T) {
|
||||
into := t.TempDir()
|
||||
if err := busCertificate([]string{"--check", "--into", into}); err == nil {
|
||||
t.Fatal("an empty directory reported a usable certificate")
|
||||
}
|
||||
if err := busCertificate([]string{"--into", into}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := busCertificate([]string{"--check", "--into", into}); err != nil {
|
||||
t.Fatalf("the certificate it just made does not satisfy its own check: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A half-written pair is not a pair. An interrupted bootstrap leaves exactly this, and a step that
|
||||
// called it done would hand the server a certificate with no key and report success.
|
||||
func TestACertificateWithoutItsKeyIsNotUsable(t *testing.T) {
|
||||
into := t.TempDir()
|
||||
if err := busCertificate([]string{"--into", into}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Remove(filepath.Join(into, "tls.key")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := busCertificate([]string{"--check", "--into", into}); err == nil {
|
||||
t.Fatal("a certificate with no key passed the check")
|
||||
}
|
||||
if err := busCertificate([]string{"--into", into}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := tls.LoadX509KeyPair(filepath.Join(into, "tls.crt"), filepath.Join(into, "tls.key")); err != nil {
|
||||
t.Fatalf("it did not replace the unusable pair: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhereToWriteIsRequired(t *testing.T) {
|
||||
if err := busCertificate(nil); err == nil || !strings.Contains(err.Error(), "--into") {
|
||||
t.Fatalf("it did not ask where to write: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
)
|
||||
|
||||
// moduleCheck judges manifests where they are written, with no mesh (novox/hq ADR 0037, issue 148).
|
||||
//
|
||||
// **The same functions registration runs, and nothing the command line adds** (ADR 0035): the strict
|
||||
// parse with every per-manifest problem, then the rules no single manifest can be judged against,
|
||||
// over exactly the manifests given. Somebody describing their own application in their own
|
||||
// repository runs this before pushing and finds out there, rather than when a running mesh refuses
|
||||
// the registration or, later, when a machine applies something that resolved and should not have.
|
||||
//
|
||||
// **What it cannot know without a store, it says.** The mesh's own seat set is the store's (ADR
|
||||
// 0122); this binary carries a compiled copy that the store overrides when loaded, so a claim on a
|
||||
// mesh seat is judged fully only at registration. A seat another module declares is unknown unless
|
||||
// that module's manifest is passed too. Both are printed as a note, not as a problem — a check that
|
||||
// refused what it could not see would teach people to ignore it.
|
||||
func moduleCheck(paths []string, out io.Writer) error {
|
||||
if len(paths) == 0 {
|
||||
return errors.New("module check <manifest.json>... — one file per module; pass every " +
|
||||
"manifest of a repository together so the rules between them are checked too")
|
||||
}
|
||||
shelf := catalogue.Shelf{}
|
||||
faulted := map[string]bool{}
|
||||
failed := 0
|
||||
for _, path := range paths {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
fmt.Fprintf(out, "%s: %v\n", path, err)
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
m, err := catalogue.ParseManifest(raw)
|
||||
if err != nil {
|
||||
fmt.Fprintf(out, "%s: %v\n", path, err)
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
if first, twice := shelf[m.Module]; twice {
|
||||
_ = first
|
||||
fmt.Fprintf(out, "%s: %s was already given; two manifests name one module\n", path, m.Module)
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
// A definition names no installation (novox/hq ADR 0112, ADR 0155): judged here and in the
|
||||
// catalogue-wide test, not yet at registration, while the declared exceptions shrink.
|
||||
if named := catalogue.InstallationProblems(m); len(named) > 0 {
|
||||
for _, p := range named {
|
||||
fmt.Fprintf(out, "%s: %s\n", path, p)
|
||||
}
|
||||
failed += len(named)
|
||||
faulted[m.Module] = true
|
||||
}
|
||||
shelf[m.Module] = m
|
||||
}
|
||||
|
||||
// Between the manifests: a seat declared twice, a use of a seat nothing declares, a claim on
|
||||
// a seat that does not exist. Run only over what parsed, because a problem inside one manifest
|
||||
// has already been said and would be said again here in a worse form.
|
||||
problems := catalogue.CatalogueProblems(shelf)
|
||||
sort.Strings(problems)
|
||||
for _, p := range problems {
|
||||
fmt.Fprintln(out, p)
|
||||
}
|
||||
failed += len(problems)
|
||||
|
||||
var names []string
|
||||
for name := range shelf {
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
for _, name := range names {
|
||||
m := shelf[name]
|
||||
if faulted[name] {
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(out, "%s: ok", name)
|
||||
if n := len(m.Tools); n > 0 {
|
||||
fmt.Fprintf(out, ", %d tool(s)", n)
|
||||
}
|
||||
if len(m.Invokes) > 0 {
|
||||
fmt.Fprintf(out, ", invokes %s", joinInvokes(m.Invokes))
|
||||
}
|
||||
fmt.Fprintln(out)
|
||||
}
|
||||
if failed > 0 {
|
||||
return fmt.Errorf("%d problem(s) in %d manifest(s)", failed, len(paths))
|
||||
}
|
||||
fmt.Fprintf(out, "%d manifest(s) checked. Judged against the seats this binary carries; a claim on "+
|
||||
"one of the mesh's own seats is judged fully at registration, and a seat declared by a "+
|
||||
"module not given here reads as unknown\n", len(paths))
|
||||
return nil
|
||||
}
|
||||
|
||||
func joinInvokes(invokes []string) string {
|
||||
if len(invokes) == 1 && invokes[0] == "*" {
|
||||
return "every tool"
|
||||
}
|
||||
s := ""
|
||||
for i, t := range invokes {
|
||||
if i > 0 {
|
||||
s += ", "
|
||||
}
|
||||
s += t
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// manifestsUnder lists every module.json below a directory, for `module check <dir>`.
|
||||
func manifestsUnder(dir string) ([]string, error) {
|
||||
var found []string
|
||||
err := filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if d.IsDir() && (d.Name() == "node_modules" || d.Name() == ".git" || d.Name() == "dist") {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
if !d.IsDir() && d.Name() == "module.json" {
|
||||
found = append(found, path)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
sort.Strings(found)
|
||||
return found, err
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The check anybody can run is the check registration runs (novox/hq issue 148, ADR 0037): a manifest
|
||||
// with a known fault is named, and one without passes, with no store opened.
|
||||
func TestModuleCheckNamesAFaultAndNeedsNoMesh(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
good := filepath.Join(dir, "good.json")
|
||||
bad := filepath.Join(dir, "bad.json")
|
||||
os.WriteFile(good, []byte(`{"module":"shop","version":"1","tools":["price"],"invokes":["mesh-catalog.catalog_modules"]}`), 0o600)
|
||||
os.WriteFile(bad, []byte(`{"module":"till","version":"1","invokes":["shop"]}`), 0o600)
|
||||
|
||||
var out bytes.Buffer
|
||||
if err := moduleCheck([]string{good}, &out); err != nil {
|
||||
t.Fatalf("a sound manifest was refused: %v\n%s", err, out.String())
|
||||
}
|
||||
if !strings.Contains(out.String(), "shop: ok, 1 tool(s), invokes mesh-catalog.catalog_modules") {
|
||||
t.Fatalf("the report does not say what it checked:\n%s", out.String())
|
||||
}
|
||||
|
||||
out.Reset()
|
||||
err := moduleCheck([]string{good, bad}, &out)
|
||||
if err == nil {
|
||||
t.Fatal("a manifest invoking a module and no tool passed")
|
||||
}
|
||||
if !strings.Contains(out.String(), `till invokes "shop", which does not name a tool`) {
|
||||
t.Fatalf("the fault is not named in the manifest's words:\n%s", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
// The rules between manifests run over what was given together: a seat two modules declare is
|
||||
// refused, which no single-manifest check can see.
|
||||
func TestModuleCheckJudgesBetweenTheManifestsGiven(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
a := filepath.Join(dir, "a.json")
|
||||
b := filepath.Join(dir, "b.json")
|
||||
os.WriteFile(a, []byte(`{"module":"a","version":"1","seats":[{"name":"printer","scope":"mesh"}]}`), 0o600)
|
||||
os.WriteFile(b, []byte(`{"module":"b","version":"1","seats":[{"name":"printer","scope":"mesh"}]}`), 0o600)
|
||||
var out bytes.Buffer
|
||||
if err := moduleCheck([]string{a, b}, &out); err == nil {
|
||||
t.Fatalf("two declarations of one seat passed:\n%s", out.String())
|
||||
}
|
||||
if !strings.Contains(out.String(), "a seat name means one protocol") {
|
||||
t.Fatalf("the cross-manifest rule was not the one named:\n%s", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
// The real catalogue passes the command, the way it passes the test that used to be the only check.
|
||||
func TestModuleCheckPassesTheCatalogue(t *testing.T) {
|
||||
root := filepath.Join("..", "..", "..", "mesh-catalog", "modules")
|
||||
if _, err := os.Stat(root); err != nil {
|
||||
t.Skipf("catalogue sibling not present: %v", err)
|
||||
}
|
||||
paths, err := manifestsUnder(root)
|
||||
if err != nil || len(paths) == 0 {
|
||||
t.Fatalf("no manifests under %s: %v", root, err)
|
||||
}
|
||||
var out bytes.Buffer
|
||||
if err := moduleCheck(paths, &out); err != nil {
|
||||
t.Fatalf("the catalogue does not pass its own check: %v\n%s", err, out.String())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
)
|
||||
|
||||
// recordDerivedHolders writes down who holds each mesh-scoped seat that nobody was ever recorded
|
||||
// as holding.
|
||||
//
|
||||
// **A seat held by derivation is a seat held by accident of being alone** (novox/hq
|
||||
// 04-ISSUES/170). ADR 0131 lets a holder on record settle a seat, and lets any other assignment
|
||||
// whose module could hold it stand beside the holder, eligible and silent. But a seat nobody
|
||||
// ever handed over has no record, so its holder is whichever assignment happened to be the sole
|
||||
// claimant — and the day a second one is assigned, both claim, both are refused, and the first
|
||||
// one's whole machine stops resolving. That is what assigning a second postgres did to the
|
||||
// control plane's own store.
|
||||
//
|
||||
// So the mesh writes the derived answer down before it acts on an assignment: for every
|
||||
// mesh-scoped seat with exactly one resolved holder and nothing on record, that holder is
|
||||
// recorded as the standing one — the same record `seat <name> --to <node>/<module>` makes by
|
||||
// hand, made from what the mesh already resolved. A seat with two derived claimants is left
|
||||
// alone: that is the ambiguity a person settles, and recording either would be guessing.
|
||||
//
|
||||
// Node-scoped seats are untouched: a record is one holder per seat, and a node-scoped seat has
|
||||
// one holder per machine (ADR 0121), so there is nothing for a record to settle there.
|
||||
func recordDerivedHolders(ctx context.Context, open *stores) ([]string, error) {
|
||||
inv := open.inventory
|
||||
shelf, err := inv.Catalogue(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// exclude nobody: every node's claims, resolved with the holdings on record.
|
||||
world, err := theRestOfTheMesh(ctx, inv, shelf, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
recorded, err := inv.Holdings(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// A record is a row against a seat the store knows. A seat it does not — a mesh whose seats
|
||||
// were never seeded, a seat a module declares for itself — stays held by derivation, as it
|
||||
// always was; a missing row is not a reason an assignment fails.
|
||||
known, err := inv.Seats(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
recordable := map[string]bool{}
|
||||
for _, s := range known {
|
||||
recordable[s.Name] = true
|
||||
}
|
||||
onRecord := map[string]bool{}
|
||||
for _, h := range recorded {
|
||||
if s, ok := catalogue.SeatNamed(h.Claim); ok {
|
||||
onRecord[s.Name] = true
|
||||
}
|
||||
}
|
||||
holders := map[string][]catalogue.Held{}
|
||||
for _, h := range world.Held {
|
||||
if h.Scope != catalogue.ScopeMesh {
|
||||
continue
|
||||
}
|
||||
s, ok := catalogue.SeatNamed(h.Claim)
|
||||
if !ok || onRecord[s.Name] || !recordable[s.Name] {
|
||||
continue
|
||||
}
|
||||
holders[s.Name] = append(holders[s.Name], h)
|
||||
}
|
||||
names := make([]string, 0, len(holders))
|
||||
for name := range holders {
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
var said []string
|
||||
for _, name := range names {
|
||||
if len(holders[name]) != 1 {
|
||||
continue
|
||||
}
|
||||
h := holders[name][0]
|
||||
if err := inv.HoldSeat(ctx, name, catalogue.ScopeMesh, h.Node, h.Module); err != nil {
|
||||
return said, err
|
||||
}
|
||||
said = append(said, fmt.Sprintf(
|
||||
"recorded %s on %s as the standing holder of %s, which it held only by being alone",
|
||||
h.Module, h.Node, name))
|
||||
}
|
||||
return said, nil
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
)
|
||||
|
||||
// A seat nobody ever handed over is held by whichever assignment happened to be alone — and the
|
||||
// day a second module able to hold it is assigned, both claimed, both were refused, and the first
|
||||
// one's machine stopped resolving (novox/hq 04-ISSUES/170). The mesh now writes the derived holder
|
||||
// down before it acts, so the second assignment stands beside the holder on record.
|
||||
|
||||
func aSeatedStore() catalogue.Manifest {
|
||||
return catalogue.Manifest{Module: "store", Version: "1",
|
||||
Provides: []catalogue.Offer{{Name: "postgres-database", Scope: catalogue.ScopeMesh}},
|
||||
Serves: map[string]map[string]any{"postgres-database": {"port": 5432}},
|
||||
Claims: []catalogue.Claim{{Name: "mesh-store", Scope: catalogue.ScopeMesh}}}
|
||||
}
|
||||
|
||||
func TestASecondEligibleHolderStandsBesideTheOneHeldByBeingAlone(t *testing.T) {
|
||||
open := aMesh(t)
|
||||
ctx := t.Context()
|
||||
// Every deploy seeds the mesh's own seats; a record is a row against one of them.
|
||||
if _, err := open.inventory.SeedSeats(ctx, catalogue.DefaultSeats()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
register(t, open, aSeatedStore())
|
||||
|
||||
if _, err := assign(ctx, open, "anchor", "store"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
said, err := assign(ctx, open, "laptop", "store")
|
||||
if err != nil {
|
||||
t.Fatalf("a second store, eligible for the seat, was refused:\n%s\n%v", said, err)
|
||||
}
|
||||
if strings.Contains(said, "cannot be worked out") {
|
||||
t.Fatalf("assigning a second store unsettled the first one's machine:\n%s", said)
|
||||
}
|
||||
if !strings.Contains(said, "recorded store on anchor as the standing holder of mesh-store") {
|
||||
t.Fatalf("the holder by derivation was not written down:\n%s", said)
|
||||
}
|
||||
|
||||
holdings, err := open.inventory.Holdings(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var found bool
|
||||
for _, h := range holdings {
|
||||
if h.Claim == "mesh-store" {
|
||||
found = true
|
||||
if h.Node != "anchor" || h.Module != "store" {
|
||||
t.Fatalf("mesh-store is recorded on %s/%s, not on the one that held it", h.Node, h.Module)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("mesh-store has no holder on record after assigning: %v", holdings)
|
||||
}
|
||||
|
||||
// And the record decides from here: the anchor's plan holds the seat, the laptop's does not.
|
||||
for node, holds := range map[string]bool{"anchor": true, "laptop": false} {
|
||||
plan, _, err := planFor(ctx, open, node)
|
||||
if err != nil {
|
||||
t.Fatalf("%s no longer resolves: %v", node, err)
|
||||
}
|
||||
var claimed bool
|
||||
for _, c := range plan.Claims {
|
||||
if c.Claim == "mesh-store" {
|
||||
claimed = true
|
||||
}
|
||||
}
|
||||
if claimed != holds {
|
||||
t.Fatalf("%s holds mesh-store: %v, want %v", node, claimed, holds)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAHolderOnRecordIsNotRewrittenByDerivation(t *testing.T) {
|
||||
open := aMesh(t)
|
||||
ctx := t.Context()
|
||||
if _, err := open.inventory.SeedSeats(ctx, catalogue.DefaultSeats()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
register(t, open, aSeatedStore())
|
||||
for _, node := range []string{"anchor", "laptop"} {
|
||||
if _, err := assign(ctx, open, node, "store"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
// A person hands the seat to the laptop. From here the record decides, and what the mesh
|
||||
// derives must never write over it.
|
||||
if err := open.inventory.HoldSeat(ctx, "mesh-store", catalogue.ScopeMesh, "laptop", "store"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
said, err := recordDerivedHolders(ctx, open)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(said) != 0 {
|
||||
t.Fatalf("a seat on record was written again from derivation: %v", said)
|
||||
}
|
||||
holdings, _ := open.inventory.Holdings(ctx)
|
||||
for _, h := range holdings {
|
||||
if h.Claim == "mesh-store" && h.Node != "laptop" {
|
||||
t.Fatalf("the record moved to %s", h.Node)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
)
|
||||
|
||||
// A host refuses a declaration carrying a field it does not know, and refuses it whole — so every new
|
||||
// field is a flag day, and the mesh had no record of which host any machine ran (novox/hq
|
||||
// 04-ISSUES/087). The order was kept by somebody remembering it.
|
||||
|
||||
func TestTheMeshNamesWhichMachinesRunWhichHost(t *testing.T) {
|
||||
split := hostSplit([]inventory.Node{
|
||||
{Name: "anchor", HostVersion: "04a27ca"},
|
||||
{Name: "laptop", HostVersion: "ced54d4"},
|
||||
{Name: "spare", HostVersion: "04a27ca"},
|
||||
})
|
||||
if len(split) != 2 {
|
||||
t.Fatalf("two versions were reported and the split has %d: %v", len(split), split)
|
||||
}
|
||||
if got := strings.Join(split["04a27ca"], ","); got != "anchor,spare" && got != "spare,anchor" {
|
||||
t.Fatalf("04a27ca is held by %q", got)
|
||||
}
|
||||
if got := strings.Join(split["ced54d4"], ","); got != "laptop" {
|
||||
t.Fatalf("ced54d4 is held by %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheMeshDoesNotClaimWhichHostIsNewer(t *testing.T) {
|
||||
// **The fault this replaced.** A host reports its version as a commit, and commits have no order.
|
||||
// The first version compared them as strings and, on the live mesh, named the three machines
|
||||
// running the NEWER host as the ones behind: `ced54d4` sorts above `04a27ca` and means nothing.
|
||||
//
|
||||
// There is no assertion to make about which is newer, and that is the point — the type says so.
|
||||
// hostSplit returns who runs what, and nothing that could be read as an ordering.
|
||||
split := hostSplit([]inventory.Node{
|
||||
{Name: "old-but-sorts-high", HostVersion: "ced54d4"},
|
||||
{Name: "new-but-sorts-low", HostVersion: "04a27ca"},
|
||||
})
|
||||
for version, machines := range split {
|
||||
if len(machines) != 1 {
|
||||
t.Fatalf("%s is held by %v", version, machines)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAMachineThatHasNotSaidIsNotAVersion(t *testing.T) {
|
||||
// It may be running anything. Counting it as a version would invent a disagreement; `node show`
|
||||
// says per machine that it has not said.
|
||||
split := hostSplit([]inventory.Node{
|
||||
{Name: "anchor", HostVersion: "04a27ca"},
|
||||
{Name: "quiet"},
|
||||
})
|
||||
if split != nil {
|
||||
t.Fatalf("one reported version and one silence read as a disagreement: %v", split)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMachinesAgreeingOnTheirHostAreNotADisagreement(t *testing.T) {
|
||||
if split := hostSplit([]inventory.Node{
|
||||
{Name: "anchor", HostVersion: "v2"},
|
||||
{Name: "laptop", HostVersion: "v2"},
|
||||
}); split != nil {
|
||||
t.Fatalf("machines agreeing reported a split: %v", split)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAMeshWhereNothingReportedAHostStatesNoDisagreement(t *testing.T) {
|
||||
if split := hostSplit([]inventory.Node{{Name: "anchor"}, {Name: "laptop"}}); split != nil {
|
||||
t.Fatalf("a mesh told no host version reported a split: %v", split)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAReportedHostVersionIsKeptAndReadBack(t *testing.T) {
|
||||
// The machine has sent this since ADR 0141 and the controller's own copy of the report did not
|
||||
// have the field, so it was unmarshalled into nothing. End to end through the store, because the
|
||||
// fault was a field that existed on one side of the wire only.
|
||||
open := aMesh(t)
|
||||
record, err := open.inventory.NodeByName(t.Context(), "anchor")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if record.HostVersion != "" {
|
||||
t.Fatalf("a machine that never reported one has host version %q", record.HostVersion)
|
||||
}
|
||||
if err := open.inventory.RecordHostVersion(t.Context(), record.ID, "ced54d4"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
again, err := open.inventory.NodeByName(t.Context(), "anchor")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if again.HostVersion != "ced54d4" {
|
||||
t.Fatalf("the reported host version read back as %q", again.HostVersion)
|
||||
}
|
||||
// An empty report never clears what a machine last said: a bare word that the node is there says
|
||||
// nothing about its host.
|
||||
if err := open.inventory.RecordHostVersion(t.Context(), record.ID, " "); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
kept, err := open.inventory.NodeByName(t.Context(), "anchor")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if kept.HostVersion != "ced54d4" {
|
||||
t.Fatalf("a report carrying no host version cleared what the machine had said: %q",
|
||||
kept.HostVersion)
|
||||
}
|
||||
}
|
||||
@@ -88,7 +88,7 @@ func run() error {
|
||||
case "identity":
|
||||
return identityCommand(ctx, args[1:])
|
||||
case "broker":
|
||||
return brokerCommand(args[1:])
|
||||
return brokerCommand(ctx, args[1:])
|
||||
case "serve":
|
||||
return serve(ctx)
|
||||
case "upgrade":
|
||||
@@ -161,6 +161,7 @@ func usage() {
|
||||
overlay place <node> [flags] say where a node is and how it is reached
|
||||
overlay show the private network, as the mesh computes it
|
||||
module add <file> register a module from its manifest
|
||||
module check <file|dir>... judge manifests where they are written, with no mesh (exit 1 on any problem)
|
||||
module list what modules this mesh knows about
|
||||
module moved <name> <commit> the source has a newer commit than the mesh built
|
||||
module forget <name> remove one, unless a node runs it or the mesh holds things for it
|
||||
|
||||
@@ -54,7 +54,24 @@ var provided = providedModules()
|
||||
|
||||
func moduleCommand(ctx context.Context, args []string) error {
|
||||
if len(args) == 0 {
|
||||
return errors.New("module add <file>, module list, or module forget <name>")
|
||||
return errors.New("module add <file>, module check <file>..., module list, or module forget <name>")
|
||||
}
|
||||
// `check` needs no mesh, and must not: it is what somebody runs in their own repository before
|
||||
// there is a mesh in reach (novox/hq issue 148). A directory expands to every manifest under it.
|
||||
if args[0] == "check" {
|
||||
var paths []string
|
||||
for _, a := range args[1:] {
|
||||
if info, err := os.Stat(a); err == nil && info.IsDir() {
|
||||
under, err := manifestsUnder(a)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
paths = append(paths, under...)
|
||||
continue
|
||||
}
|
||||
paths = append(paths, a)
|
||||
}
|
||||
return moduleCheck(paths, os.Stdout)
|
||||
}
|
||||
open, err := openStores(ctx)
|
||||
if err != nil {
|
||||
@@ -275,7 +292,7 @@ func moduleCommand(ctx context.Context, args []string) error {
|
||||
return issueOnTheNewBus(ctx, inv, m, *forNode, busAddress)
|
||||
|
||||
default:
|
||||
return fmt.Errorf("module has no %q; it has add, list, moved, forget and issue", args[0])
|
||||
return fmt.Errorf("module has no %q; it has add, check, list, moved, forget and issue", args[0])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -346,9 +346,16 @@ func whoResolves(ctx context.Context, open *stores, requirement string) (
|
||||
refused := map[string]string{}
|
||||
for _, n := range nodes {
|
||||
plan, _, err := planFor(ctx, open, n.Name)
|
||||
if err != nil {
|
||||
switch {
|
||||
case unresolvable(err):
|
||||
refused[n.Name] = err.Error()
|
||||
continue
|
||||
case err != nil:
|
||||
// Not a node that does not resolve — a question that went unanswered. Recording it as a
|
||||
// refusal would take the machine off the private network, and the generator that reads
|
||||
// this would then write a roster and a filter without it (novox/hq 04-ISSUES/152).
|
||||
return nil, nil, fmt.Errorf("whether %s answers %q cannot be read: %w",
|
||||
n.Name, requirement, err)
|
||||
}
|
||||
for _, m := range plan.Modules {
|
||||
for _, offered := range m.Offers() {
|
||||
|
||||
@@ -363,9 +363,15 @@ func identityCommand(ctx context.Context, args []string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func brokerCommand(args []string) error {
|
||||
func brokerCommand(ctx context.Context, args []string) error {
|
||||
if len(args) > 0 && args[0] == "certificate" {
|
||||
return busCertificate(args[1:])
|
||||
}
|
||||
if len(args) > 0 && args[0] == "accounts" {
|
||||
return busAccounts(ctx, args[1:])
|
||||
}
|
||||
if len(args) == 0 || args[0] != "show" {
|
||||
return errors.New("broker show")
|
||||
return errors.New("broker show | broker certificate [--check] --into <directory> | broker accounts --into <file>")
|
||||
}
|
||||
known, err := broker.FromEnvironment()
|
||||
if errors.Is(err, broker.ErrNotConfigured) {
|
||||
@@ -430,6 +436,12 @@ func showNode(ctx context.Context, inv *inventory.Inventory, name string) error
|
||||
}
|
||||
fmt.Printf("%s\n", node.Name)
|
||||
fmt.Printf(" last heard from %s\n", heardFrom(node))
|
||||
// Which host runs it, as it reported (novox/hq 04-ISSUES/087). Said whenever known, because a
|
||||
// host refuses a declaration carrying a field it does not understand and refuses it WHOLE — so
|
||||
// which host a machine runs is what decides whether the mesh can send it anything new, and
|
||||
// nothing could say it. "not reported" rather than blank: a machine that has not said is a
|
||||
// different thing from one running nothing.
|
||||
fmt.Printf(" host %s\n", orNotReported(node.HostVersion))
|
||||
if err := showMode(ctx, inv, node); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -480,3 +492,11 @@ func showNode(ctx context.Context, inv *inventory.Inventory, name string) error
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// orNotReported is a fact a machine states about itself, or the fact that it has not.
|
||||
func orNotReported(s string) string {
|
||||
if strings.TrimSpace(s) == "" {
|
||||
return "not reported — this machine has not said since the mesh began keeping it"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
@@ -25,7 +25,36 @@ import (
|
||||
// cheapest next step. That is how novox/hq ADR 0001 records `hal/sdk` reaching 34,636:
|
||||
// nothing in it was wrong, and no one edit was the one that should have been a new file.
|
||||
|
||||
// notResolvable marks the one failure in planFor that is a statement about the node: its assigned
|
||||
// modules do not compose. Every other failure means the mesh could not be *asked* — the store was
|
||||
// unreachable, a key could not be read — and says nothing about the node at all.
|
||||
//
|
||||
// The distinction exists because three callers gather something across every machine and must carry
|
||||
// on when one machine's set is broken. Each of them read a plain error as "their set does not
|
||||
// resolve", and so read a store that was briefly unreachable as a machine that runs nothing. On the
|
||||
// roster of routed names that is not a degraded answer but a false one: it states, to every machine
|
||||
// at once, that another machine's names do not exist. A control node spent hours replacing every
|
||||
// container it ran, on a six-minute cycle, because each pass restarted the store this is read from,
|
||||
// the read failed, one name left the roster, and the roster is part of every container's identity
|
||||
// (novox/hq 04-ISSUES/152, and 04-ISSUES/151 for why a changed roster is a changed container).
|
||||
//
|
||||
// So: skip a node that cannot resolve, and never a node that could not be read.
|
||||
type notResolvable struct{ err error }
|
||||
|
||||
func (n notResolvable) Error() string { return n.err.Error() }
|
||||
func (n notResolvable) Unwrap() error { return n.err }
|
||||
|
||||
// unresolvable reports whether err is a node's own set failing to compose, rather than the mesh
|
||||
// being unable to answer.
|
||||
func unresolvable(err error) bool {
|
||||
var n notResolvable
|
||||
return errors.As(err, &n)
|
||||
}
|
||||
|
||||
// planFor works out everything a node should run, from what was assigned to it.
|
||||
//
|
||||
// A failure to compose the node's own modules is wrapped as notResolvable; every other failure is
|
||||
// returned as it is. Callers gathering across the mesh must tell them apart — see notResolvable.
|
||||
func planFor(ctx context.Context, open *stores, nodeName string) (catalogue.Resolution, catalogue.SettingsBy, error) {
|
||||
inv := open.inventory
|
||||
shelf, err := inv.Catalogue(ctx)
|
||||
@@ -95,7 +124,9 @@ func planFor(ctx context.Context, open *stores, nodeName string) (catalogue.Reso
|
||||
At: onNetwork[nodeName], PublicDomain: publicDomain,
|
||||
Account: who.Account, AccountHome: who.AccountHome}, world)
|
||||
if err != nil {
|
||||
return catalogue.Resolution{}, nil, err
|
||||
// The node's own set does not compose. Marked, because this is the only failure here that
|
||||
// a mesh-wide gatherer may pass over — see notResolvable.
|
||||
return catalogue.Resolution{}, nil, notResolvable{err}
|
||||
}
|
||||
|
||||
// The credential for each thing this node takes from elsewhere. Made once and kept, so the
|
||||
@@ -163,8 +194,13 @@ func planFor(ctx context.Context, open *stores, nodeName string) (catalogue.Reso
|
||||
if len(stray) > 0 {
|
||||
// Somebody set something that reaches no file. Said here rather than discovered by the
|
||||
// machine not behaving differently, which is the slowest way there is.
|
||||
return catalogue.Resolution{}, nil, fmt.Errorf(
|
||||
"these settings reach nothing:\n - %s", strings.Join(stray, "\n - "))
|
||||
//
|
||||
// Marked like a set that will not compose, and for the same reason: it is a standing fact
|
||||
// about this node's own configuration, not a question the mesh could not answer. A gatherer
|
||||
// passes over it as it always did — one node's stray setting must not stop every other node
|
||||
// being described (novox/hq 04-ISSUES/152).
|
||||
return catalogue.Resolution{}, nil, notResolvable{fmt.Errorf(
|
||||
"these settings reach nothing:\n - %s", strings.Join(stray, "\n - "))}
|
||||
}
|
||||
return resolved, settings, nil
|
||||
}
|
||||
@@ -671,11 +707,17 @@ func renderingFor(ctx context.Context, open *stores, node string,
|
||||
// routed name only because it carried a label the mesh composed, never because the mesh knows what
|
||||
// "route" means. A node that does not resolve is skipped, so one machine's broken set does not cost
|
||||
// the rest their names.
|
||||
//
|
||||
// **A node that could not be READ is a different matter and is raised.** Skipping one states, to
|
||||
// every machine at once, that its names do not exist — and since the roster is part of every
|
||||
// container's identity, that withdraws them and replaces every container (novox/hq 04-ISSUES/152,
|
||||
// 151). So every failure here says which machine and which read, because the alternative is a
|
||||
// mesh-wide refusal with nothing named in it.
|
||||
func routeNamesInTheMesh(ctx context.Context, open *stores) (map[string]string, error) {
|
||||
inv := open.inventory
|
||||
places, err := inv.Overlays(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("where the machines are cannot be read: %w", err)
|
||||
}
|
||||
address := map[string]string{}
|
||||
for _, p := range places {
|
||||
@@ -686,14 +728,22 @@ func routeNamesInTheMesh(ctx context.Context, open *stores) (map[string]string,
|
||||
|
||||
nodes, err := inv.Nodes(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("which machines the mesh has cannot be read: %w", err)
|
||||
}
|
||||
|
||||
out := map[string]string{}
|
||||
for _, n := range nodes {
|
||||
plan, settings, err := planFor(ctx, open, n.Name)
|
||||
if err != nil {
|
||||
switch {
|
||||
case unresolvable(err):
|
||||
// Their set does not compose, so they serve no names. Passed over, so one machine's
|
||||
// broken set does not cost the rest theirs.
|
||||
continue
|
||||
case err != nil:
|
||||
// The mesh could not be asked. Returning the roster without this machine's names would
|
||||
// state that they do not exist — to every machine, and indistinguishably from the
|
||||
// operator having withdrawn them (novox/hq 04-ISSUES/152).
|
||||
return nil, fmt.Errorf("the names %s serves cannot be read: %w", n.Name, err)
|
||||
}
|
||||
for _, m := range plan.Modules {
|
||||
for to := range m.Contributes {
|
||||
@@ -823,11 +873,17 @@ func grantsFor(ctx context.Context, open *stores, node string) ([]catalogue.Gran
|
||||
out := make([]catalogue.Grant, 0, len(issued))
|
||||
for _, s := range issued {
|
||||
plan, settings, err := planFor(ctx, open, s.Consumer)
|
||||
if err != nil {
|
||||
switch {
|
||||
case unresolvable(err):
|
||||
// Their set does not resolve. Skipped rather than fatal: this node is not the place
|
||||
// to report another machine's problem, and a grant for something that is not going to
|
||||
// run would have the provider create a user nothing uses.
|
||||
continue
|
||||
case err != nil:
|
||||
// The mesh could not be asked what they wanted, which is not the same as their wanting
|
||||
// nothing — and withholding a grant on that reading takes a consumer's access away
|
||||
// (novox/hq 04-ISSUES/152).
|
||||
return nil, fmt.Errorf("what %s asked of %s cannot be read: %w", s.Consumer, s.Name, err)
|
||||
}
|
||||
values, asks, err := plan.ContributionsFrom(s.Name, s.ConsumerModule, settings)
|
||||
if err != nil {
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
@@ -124,6 +125,22 @@ func serve(ctx context.Context) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// And the mesh's own verbs, as the seat this control plane holds (novox/hq ADR 0154). Served
|
||||
// from the store's row, so what the seat declares is what is answered.
|
||||
handlers, err := seatToolHandlers()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
bus, isNATS := server.Bus().(link.OverNATS)
|
||||
if !isNATS {
|
||||
return errors.New("the mesh's verbs are served over the bus, and this control plane is not on it")
|
||||
}
|
||||
stopServing, err := bus.ServeSeatTools(catalogue.ControllerSeatName, handlers, log.New(os.Stdout, "", log.LstdFlags))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer stopServing()
|
||||
|
||||
return server.Serve(ctx)
|
||||
}
|
||||
|
||||
@@ -338,6 +355,12 @@ func pushCommand(ctx context.Context, args []string) error {
|
||||
sentDigest := map[string]string{}
|
||||
defer release()
|
||||
for _, s := range sending {
|
||||
// Numbered under the hold, one higher than the last, before the body exists — the number is
|
||||
// inside the signed bytes, so a replayed older declaration cannot borrow a newer one's
|
||||
// (novox/hq 04-ISSUES/107).
|
||||
if err := number(ctx, inv, &s); err != nil {
|
||||
return err
|
||||
}
|
||||
body, err := s.declared.Body()
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -564,6 +587,9 @@ func sendRound(ctx context.Context, open *stores, names []string,
|
||||
return compose(held, node)
|
||||
})
|
||||
for _, s := range sending {
|
||||
if err := number(ctx, open.inventory, &s); err != nil {
|
||||
return refused, err
|
||||
}
|
||||
body, err := s.declared.Body()
|
||||
if err != nil {
|
||||
return refused, err
|
||||
@@ -645,6 +671,9 @@ func sendTo(ctx context.Context, open *stores, names []string) error {
|
||||
defer server.Close()
|
||||
|
||||
for _, s := range sending {
|
||||
if err := number(ctx, inv, &s); err != nil {
|
||||
return err
|
||||
}
|
||||
body, err := s.declared.Body()
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -694,6 +723,12 @@ func wouldSend(ctx context.Context, open *stores,
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
// Composed with the number the machine was LAST sent, so this is byte for byte what it was
|
||||
// sent when nothing else changed. A fresh number here would make every machine read as
|
||||
// behind for ever (novox/hq 04-ISSUES/107).
|
||||
if declared.Sequence, err = open.inventory.Sequence(ctx, n.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
body, err := declared.Body()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -717,6 +752,12 @@ func raiseTheBus(ctx context.Context, inv *inventory.Inventory, address string)
|
||||
broker.BareAddress(address), err)
|
||||
}
|
||||
defer js.Close()
|
||||
// What the raise decided not to fail over. Said, for the reason everything else here is said:
|
||||
// a consumer kept as it was is a difference between what the mesh asked for and what the bus
|
||||
// holds, and one nobody would find by reading either (novox/hq 04-ISSUES/156).
|
||||
js.Note = func(format string, args ...any) {
|
||||
fmt.Printf(" "+format+"\n", args...)
|
||||
}
|
||||
|
||||
// **Its own user, before anything else.** The controller's account is created by the installer at
|
||||
// a bootstrap password, before there is a controller to mint one — so nothing recorded a hash for
|
||||
@@ -821,3 +862,17 @@ func seatHolders(ctx context.Context, inv *inventory.Inventory) (map[string]brok
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// number gives one send the next sequence for its node (novox/hq 04-ISSUES/107).
|
||||
func number(ctx context.Context, inv *inventory.Inventory, s *readyNode) error {
|
||||
record, err := inv.NodeByName(ctx, s.node)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
seq, err := inv.NextSequence(ctx, record.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.declared.Sequence = seq
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -56,6 +56,23 @@ type meshStatus struct {
|
||||
Machines int `json:"machines"`
|
||||
// Adopted is every node still adopted (novox/hq ADR 0100); absent when none is.
|
||||
Adopted []string `json:"adopted,omitempty"`
|
||||
// Untaken is every module assigned to a machine that is holding what it found rather than
|
||||
// running what the module declares, because nothing took it (novox/hq 04-ISSUES/125). Absent
|
||||
// when nothing is held.
|
||||
//
|
||||
// **A document without this said an outage was a well mesh.** Read from what each machine
|
||||
// reported, so it is the machine's account and not the mesh's take-time listing.
|
||||
Untaken []machineUntaken `json:"untaken,omitempty"`
|
||||
}
|
||||
|
||||
// machineUntaken is one module a machine is holding rather than running, and how many resources of
|
||||
// it are held.
|
||||
type machineUntaken struct {
|
||||
Node string `json:"node"`
|
||||
Module string `json:"module"`
|
||||
// Held is how many of the module's resources the machine is keeping as it found them. Zero is
|
||||
// impossible here: a module with nothing held is not in this list.
|
||||
Held int `json:"held"`
|
||||
}
|
||||
|
||||
type machineUnresolved struct {
|
||||
@@ -136,6 +153,23 @@ func statusAsJSON(asked answers) ([]byte, error) {
|
||||
Quiet: []machineQuiet{}, Behind: []moduleBehind{}, Waiting: []machineWaiting{},
|
||||
Reported: []machineReported{}, Unresolved: []machineUnresolved{},
|
||||
Network: asked.network, Adopted: adoptedNodes(nodes)}
|
||||
// In a stated order, so two readings of an unchanged mesh are the same document.
|
||||
untakenNodes := make([]string, 0, len(asked.untaken))
|
||||
for name := range asked.untaken {
|
||||
untakenNodes = append(untakenNodes, name)
|
||||
}
|
||||
sort.Strings(untakenNodes)
|
||||
for _, name := range untakenNodes {
|
||||
modules := make([]string, 0, len(asked.untaken[name]))
|
||||
for m := range asked.untaken[name] {
|
||||
modules = append(modules, m)
|
||||
}
|
||||
sort.Strings(modules)
|
||||
for _, m := range modules {
|
||||
out.Untaken = append(out.Untaken,
|
||||
machineUntaken{Node: name, Module: m, Held: asked.untaken[name][m]})
|
||||
}
|
||||
}
|
||||
for name := range asked.refused {
|
||||
out.Unresolved = append(out.Unresolved, machineUnresolved{
|
||||
Node: name, Problem: asked.refused[name]})
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A node's own set failing to compose, and the mesh being unable to answer at all, are different
|
||||
// things, and only the first may be passed over when something is gathered across every machine
|
||||
// (novox/hq 04-ISSUES/152). These pin that distinction where the three gatherers rely on it.
|
||||
|
||||
func TestASetThatDoesNotComposeIsMarkedAsTheNodesOwnProblem(t *testing.T) {
|
||||
open := aMesh(t)
|
||||
one, two := rivals()
|
||||
register(t, open, one)
|
||||
register(t, open, two)
|
||||
for _, m := range []string{one.Module, two.Module} {
|
||||
if _, err := open.inventory.Assign(t.Context(), "laptop", m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
_, _, err := planFor(t.Context(), open, "laptop")
|
||||
if err == nil {
|
||||
t.Fatal("two modules claiming one seat composed anyway")
|
||||
}
|
||||
if !unresolvable(err) {
|
||||
t.Fatalf("a set that cannot compose was not marked as the node's own problem: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAStoreThatCannotBeReadIsNotANodeThatDoesNotCompose(t *testing.T) {
|
||||
open := aMesh(t)
|
||||
|
||||
// Nothing is wrong with anchor. The question simply cannot be asked.
|
||||
stopped, cancel := context.WithCancel(t.Context())
|
||||
cancel()
|
||||
|
||||
_, _, err := planFor(stopped, open, "anchor")
|
||||
if err == nil {
|
||||
t.Fatal("a plan composed against a store that could not be read")
|
||||
}
|
||||
if unresolvable(err) {
|
||||
t.Fatalf("a question the mesh could not answer was read as a node that runs nothing: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOneIncoherentNodeDoesNotCostTheRestTheirNames(t *testing.T) {
|
||||
open := aMesh(t)
|
||||
one, two := rivals()
|
||||
register(t, open, one)
|
||||
register(t, open, two)
|
||||
for _, m := range []string{one.Module, two.Module} {
|
||||
if _, err := open.inventory.Assign(t.Context(), "laptop", m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// laptop cannot compose. That is laptop's problem and nobody else's: the roster is still
|
||||
// answerable, and anchor keeps whatever it serves.
|
||||
if _, err := routeNamesInTheMesh(t.Context(), open); err != nil {
|
||||
t.Fatalf("one node's broken set cost the whole mesh its roster: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestARosterIsNeverReturnedWithNamesItCouldNotRead(t *testing.T) {
|
||||
open := aMesh(t)
|
||||
|
||||
stopped, cancel := context.WithCancel(t.Context())
|
||||
cancel()
|
||||
|
||||
names, err := routeNamesInTheMesh(stopped, open)
|
||||
if err == nil {
|
||||
t.Fatalf("a roster was composed from a store that could not be read: %v", names)
|
||||
}
|
||||
// The failure must be raised, not turned into an absence. A roster missing a machine's names
|
||||
// is indistinguishable, on every machine that receives it, from the operator withdrawing them —
|
||||
// and because the roster is part of every container's identity, it replaces all of them.
|
||||
if names != nil {
|
||||
t.Fatalf("a partial roster was returned beside the error: %v", names)
|
||||
}
|
||||
}
|
||||
|
||||
// Kept so the reason survives the next person reading it: the message the gatherer raises must say
|
||||
// which machine could not be read, or the operator is left with a mesh-wide failure and no name.
|
||||
func TestTheRaisedFailureNamesTheMachineItCouldNotRead(t *testing.T) {
|
||||
open := aMesh(t)
|
||||
stopped, cancel := context.WithCancel(t.Context())
|
||||
cancel()
|
||||
|
||||
_, err := routeNamesInTheMesh(stopped, open)
|
||||
if err == nil {
|
||||
t.Fatal("no failure was raised")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "cannot be read") {
|
||||
t.Fatalf("the failure does not say the mesh could not be read: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
)
|
||||
|
||||
// The mesh's own verbs, served as the mesh-controller seat's tools (novox/hq ADR 0154, design 33).
|
||||
//
|
||||
// **Each tool runs the command it names, in this same binary, and answers what it printed.** That is
|
||||
// ADR 0035 taken literally: the logic lives once, in the command, and a surface is an adapter with no
|
||||
// decisions in it. Running a fresh process rather than calling the function keeps two things true
|
||||
// that calling it would not — every command opens and closes its own stores the way it does from a
|
||||
// shell, and nothing a command prints to the process's standard output can leak into another call's
|
||||
// answer. It also means a refusal is the same refusal in the same words, because it is the same
|
||||
// output.
|
||||
|
||||
// verbAnswer is what a verb answers: what the command printed, whether it succeeded, and — where the
|
||||
// command speaks JSON — the same as data.
|
||||
type verbAnswer struct {
|
||||
Output string `json:"output"`
|
||||
OK bool `json:"ok"`
|
||||
Answer any `json:"answer,omitempty"`
|
||||
}
|
||||
|
||||
// argvFor is the command line a verb and its arguments become. Only the verbs the seat declares, and
|
||||
// only the arguments each declares: a caller cannot reach a flag the schema did not name.
|
||||
func argvFor(verb string, args map[string]any) ([]string, error) {
|
||||
str := func(key string) string {
|
||||
v, _ := args[key].(string)
|
||||
return strings.TrimSpace(v)
|
||||
}
|
||||
need := func(keys ...string) error {
|
||||
for _, k := range keys {
|
||||
if str(k) == "" {
|
||||
return fmt.Errorf("%s needs %q", verb, k)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
switch verb {
|
||||
case "status":
|
||||
return []string{"status", "--json"}, nil
|
||||
case "nodes":
|
||||
return []string{"node", "list"}, nil
|
||||
case "node":
|
||||
if err := need("node"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return []string{"node", "show", str("node")}, nil
|
||||
case "modules":
|
||||
return []string{"module", "list"}, nil
|
||||
case "seats":
|
||||
return []string{"seats", "--json"}, nil
|
||||
case "builds":
|
||||
if m := str("module"); m != "" {
|
||||
return []string{"builds", m}, nil
|
||||
}
|
||||
return []string{"builds"}, nil
|
||||
case "plan":
|
||||
if err := need("node"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return []string{"plan", str("node"), "--json"}, nil
|
||||
case "assign", "unassign":
|
||||
if err := need("node", "module"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return []string{verb, str("node"), str("module")}, nil
|
||||
case "push":
|
||||
// Sent and not waited for: the asker reads `status` for what the machine did, which is
|
||||
// what a person at a shell does too. A tool call that blocked for a push's whole apply would
|
||||
// time out on every machine that takes a minute, and say nothing about the ones that did not.
|
||||
if n := str("node"); n != "" {
|
||||
return []string{"push", n, "--wait", "0"}, nil
|
||||
}
|
||||
return []string{"push", "--behind", "--wait", "0"}, nil
|
||||
case "build":
|
||||
if err := need("repository"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
argv := []string{"build", str("repository"), "--wait", "0"}
|
||||
if p := str("path"); p != "" {
|
||||
argv = append(argv, "--path", p)
|
||||
}
|
||||
if r := str("ref"); r != "" {
|
||||
argv = append(argv, "--ref", r)
|
||||
}
|
||||
return argv, nil
|
||||
}
|
||||
return nil, fmt.Errorf("%q is not a verb the %s seat serves", verb, catalogue.ControllerSeatName)
|
||||
}
|
||||
|
||||
// jsonVerbs are the verbs whose command speaks JSON, so the answer carries it as data as well.
|
||||
var jsonVerbs = map[string]bool{"status": true, "seats": true, "plan": true}
|
||||
|
||||
// runVerb runs this binary with the given command line and gathers what it said.
|
||||
func runVerb(ctx context.Context, argv []string) (verbAnswer, error) {
|
||||
self, err := os.Executable()
|
||||
if err != nil {
|
||||
return verbAnswer{}, err
|
||||
}
|
||||
cmd := exec.CommandContext(ctx, self, argv...)
|
||||
// The same environment: the stores' credentials, the bus, the broker — everything a command run
|
||||
// from a shell in this container would have, because it is that.
|
||||
cmd.Env = os.Environ()
|
||||
// Two buffers, one answer. What the command *says* is both streams, in the order a person at
|
||||
// a shell would read them; what it *answers as data* is standard output alone — `status --json`
|
||||
// prints its warnings beside the document, and a JSON parsed from the two together parsed
|
||||
// nothing (2026-09-30, the first status asked through the console had no `answer`).
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
runErr := cmd.Run()
|
||||
answer := verbAnswer{Output: stdout.String() + stderr.String(), OK: runErr == nil}
|
||||
if jsonVerbs[argv[0]] && runErr == nil {
|
||||
var parsed any
|
||||
if json.Unmarshal(bytes.TrimSpace(stdout.Bytes()), &parsed) == nil {
|
||||
answer.Answer = parsed
|
||||
}
|
||||
}
|
||||
var exit *exec.ExitError
|
||||
if runErr != nil && !errors.As(runErr, &exit) {
|
||||
// Not the command refusing — the command not running at all, which is this process's fault.
|
||||
return answer, fmt.Errorf("could not run %s: %w", strings.Join(argv, " "), runErr)
|
||||
}
|
||||
return answer, nil
|
||||
}
|
||||
|
||||
// seatToolHandlers are the handlers for every verb the mesh-controller seat declares, from the
|
||||
// store's row, so a verb the row does not carry is not served and a verb it carries that this binary
|
||||
// cannot run is said at start rather than at the first call.
|
||||
func seatToolHandlers() (map[string]link.ToolHandler, error) {
|
||||
seat, known := catalogue.SeatNamed(catalogue.ControllerSeatName)
|
||||
if !known {
|
||||
return nil, fmt.Errorf("this mesh defines no %s seat", catalogue.ControllerSeatName)
|
||||
}
|
||||
handlers := map[string]link.ToolHandler{}
|
||||
for _, v := range seat.Serves {
|
||||
verb := v.Name
|
||||
if verb == "tools" {
|
||||
handlers[verb] = func(ctx context.Context, _ json.RawMessage) (any, error) {
|
||||
return seatTools(), nil
|
||||
}
|
||||
continue
|
||||
}
|
||||
if _, err := argvFor(verb, map[string]any{"node": "x", "module": "x", "repository": "x"}); err != nil {
|
||||
return nil, fmt.Errorf("the %s seat's row declares %q, which this control plane cannot run: %w",
|
||||
catalogue.ControllerSeatName, verb, err)
|
||||
}
|
||||
handlers[verb] = func(ctx context.Context, raw json.RawMessage) (any, error) {
|
||||
args := map[string]any{}
|
||||
if len(raw) > 0 {
|
||||
if err := json.Unmarshal(raw, &args); err != nil {
|
||||
return nil, fmt.Errorf("the arguments are not a JSON object: %w", err)
|
||||
}
|
||||
}
|
||||
argv, err := argvFor(verb, args)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return runVerb(ctx, argv)
|
||||
}
|
||||
}
|
||||
return handlers, nil
|
||||
}
|
||||
|
||||
// seatTools is what `tools` answers: every seat with a protocol, and the tools each serves, from the
|
||||
// mesh's own records — no holder in the path, so it is true while a holder restarts (design 33 §5).
|
||||
func seatTools() map[string]any {
|
||||
var seats []map[string]any
|
||||
for _, s := range catalogue.SeatsWithAProtocol() {
|
||||
if len(s.Serves) == 0 {
|
||||
continue
|
||||
}
|
||||
var tools []map[string]any
|
||||
for _, v := range s.Serves {
|
||||
tools = append(tools, map[string]any{
|
||||
"name": v.Name, "description": v.Description, "input": v.Input, "output": v.Output,
|
||||
})
|
||||
}
|
||||
seats = append(seats, map[string]any{"seat": s.Name, "scope": s.Scope, "tools": tools})
|
||||
}
|
||||
return map[string]any{"seats": seats}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
)
|
||||
|
||||
// Every verb the mesh-controller seat declares is one this binary can run, with the arguments the
|
||||
// schema names and no other (novox/hq ADR 0154, ADR 0035).
|
||||
func TestEveryDeclaredVerbHasACommandLine(t *testing.T) {
|
||||
for _, v := range catalogue.ControllerVerbs {
|
||||
if v.Name == "tools" {
|
||||
continue
|
||||
}
|
||||
args := map[string]any{}
|
||||
props, _ := v.Input["properties"].(map[string]any)
|
||||
for name := range props {
|
||||
args[name] = "x"
|
||||
}
|
||||
argv, err := argvFor(v.Name, args)
|
||||
if err != nil {
|
||||
t.Errorf("%s: %v", v.Name, err)
|
||||
continue
|
||||
}
|
||||
if argv[0] == "" {
|
||||
t.Errorf("%s: empty command", v.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A required argument missing is refused in the verb's own words, before anything runs.
|
||||
func TestAVerbMissingWhatItNeedsIsRefused(t *testing.T) {
|
||||
if _, err := argvFor("node", map[string]any{}); err == nil || !strings.Contains(err.Error(), `node needs "node"`) {
|
||||
t.Fatalf("node without a machine was accepted: %v", err)
|
||||
}
|
||||
if _, err := argvFor("upgrade", map[string]any{}); err == nil {
|
||||
t.Fatal("a verb the seat does not serve was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
// A push and a build are sent, not waited for: the asker reads status for what happened.
|
||||
func TestActsDoNotBlockTheCall(t *testing.T) {
|
||||
argv, _ := argvFor("push", map[string]any{"node": "one"})
|
||||
if strings.Join(argv, " ") != "push one --wait 0" {
|
||||
t.Fatalf("push waits: %v", argv)
|
||||
}
|
||||
argv, _ = argvFor("build", map[string]any{"repository": "novox/x", "path": "modules/x"})
|
||||
if strings.Join(argv, " ") != "build novox/x --wait 0 --path modules/x" {
|
||||
t.Fatalf("build: %v", argv)
|
||||
}
|
||||
}
|
||||
|
||||
// What `tools` answers is the seats' records, with each verb's schema.
|
||||
func TestToolsAnswersTheSeatsRecords(t *testing.T) {
|
||||
handlers, err := seatToolHandlers()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(handlers) != len(catalogue.ControllerVerbs) {
|
||||
t.Fatalf("%d handlers for %d verbs", len(handlers), len(catalogue.ControllerVerbs))
|
||||
}
|
||||
answer := seatTools()
|
||||
seats, _ := answer["seats"].([]map[string]any)
|
||||
var found bool
|
||||
for _, s := range seats {
|
||||
if s["seat"] == catalogue.ControllerSeatName {
|
||||
found = true
|
||||
tools, _ := s["tools"].([]map[string]any)
|
||||
if len(tools) != len(catalogue.ControllerVerbs) || tools[0]["input"] == nil {
|
||||
t.Fatalf("the controller seat's tools are not listed in full: %v", tools)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("the mesh-controller seat is not in the listing")
|
||||
}
|
||||
}
|
||||
|
||||
// A JSON verb's answer is parsed from what the command wrote to standard output alone; a warning it
|
||||
// printed beside the document does not take the document away. The test binary stands in for the
|
||||
// controller: `-test.run` with a name that matches nothing prints `ok` and a warning about no tests.
|
||||
func TestAJSONVerbsAnswerIsItsStandardOutput(t *testing.T) {
|
||||
jsonVerbs["-test.run"] = true
|
||||
t.Cleanup(func() { delete(jsonVerbs, "-test.run") })
|
||||
answer, err := runVerb(t.Context(), []string{"-test.run", "TestAnswerEcho", "-test.v"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !answer.OK {
|
||||
t.Fatalf("the command failed: %s", answer.Output)
|
||||
}
|
||||
if !strings.Contains(answer.Output, "PASS") {
|
||||
t.Fatalf("stderr and stdout are both what the command said: %s", answer.Output)
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,10 @@ import (
|
||||
// make a machine look out of date for ever, or send something `plan` never showed.
|
||||
type sendable struct {
|
||||
Resources []map[string]any
|
||||
// Sequence orders this send against every other to the same node: one higher each time, taken
|
||||
// under the node's hold just before the body is made (novox/hq 04-ISSUES/107). Zero is not sent
|
||||
// at all, which a host reads as "no order claimed" — the shape of every declaration before this.
|
||||
Sequence int64
|
||||
// Adoption is nil for a converged node, and then the body is byte for byte what it was before
|
||||
// adoption existed: an older host parses the envelope strictly and would refuse the key.
|
||||
Adoption *adoptionEnvelope
|
||||
@@ -38,6 +42,9 @@ func (s sendable) Body() ([]byte, error) {
|
||||
if s.Adoption != nil {
|
||||
envelope["adoption"] = s.Adoption
|
||||
}
|
||||
if s.Sequence > 0 {
|
||||
envelope["sequence"] = s.Sequence
|
||||
}
|
||||
// An empty declaration is deliberate here — the node owns nothing the mesh put there
|
||||
// (novox/hq issue 127) — and the host refuses an empty body unless it is told the emptiness
|
||||
// is meant, so a truncated or mis-composed body is never mistaken for "own nothing".
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A declaration's only identity was the digest of its bytes; the controller already held a per-node
|
||||
// lock and recorded each send, so the order existed and was thrown away at the wire (novox/hq
|
||||
// 04-ISSUES/107).
|
||||
|
||||
func TestASendCarriesItsNumberInsideTheSignedBytes(t *testing.T) {
|
||||
body, err := sendable{Resources: []map[string]any{{"id": "x", "type": "file"}}, Sequence: 7}.Body()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var env map[string]any
|
||||
if err := json.Unmarshal(body, &env); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, _ := env["sequence"].(float64); got != 7 {
|
||||
t.Fatalf("the body carries sequence %v, wanted 7", env["sequence"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnUnnumberedSendIsByteForByteWhatItWasBefore(t *testing.T) {
|
||||
// Zero is not sent at all. A host reads absence as "no order claimed" — the shape of every
|
||||
// declaration before this — so an older host, or the read-only comparison against a machine
|
||||
// sent nothing since sends were numbered, sees exactly the bytes it always saw.
|
||||
body, err := sendable{Resources: []map[string]any{{"id": "x", "type": "file"}}}.Body()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var env map[string]any
|
||||
if err := json.Unmarshal(body, &env); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, present := env["sequence"]; present {
|
||||
t.Fatalf("a send numbered zero put a sequence on the wire: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEachSendToANodeIsOneHigherAndReadable(t *testing.T) {
|
||||
open := aMesh(t)
|
||||
record, err := open.inventory.NodeByName(t.Context(), "anchor")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Sent nothing since numbering existed: what it would be sent is composed with zero, which is
|
||||
// not on the wire, which is what it was actually sent.
|
||||
if n, err := open.inventory.Sequence(t.Context(), record.ID); err != nil || n != 0 {
|
||||
t.Fatalf("a fresh node reads sequence %d, %v", n, err)
|
||||
}
|
||||
first, err := open.inventory.NextSequence(t.Context(), record.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, err := open.inventory.NextSequence(t.Context(), record.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if first != 1 || second != 2 {
|
||||
t.Fatalf("two sends were numbered %d and %d", first, second)
|
||||
}
|
||||
// And the read path sees the last one taken, so the comparison composes what was sent.
|
||||
if n, err := open.inventory.Sequence(t.Context(), record.ID); err != nil || n != 2 {
|
||||
t.Fatalf("after two sends the node reads sequence %d, %v", n, err)
|
||||
}
|
||||
// Another node counts on its own.
|
||||
other, err := open.inventory.NodeByName(t.Context(), "laptop")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n, err := open.inventory.NextSequence(t.Context(), other.ID); err != nil || n != 1 {
|
||||
t.Fatalf("a second node's first send was numbered %d, %v", n, err)
|
||||
}
|
||||
}
|
||||
@@ -82,6 +82,16 @@ func cloneFrom(ctx context.Context, source buildSource) (string, error) {
|
||||
// serves no scheme or port has nothing to compose from — a default port here would be the forge's
|
||||
// address guessed, which is the thing this exists to stop.
|
||||
func clonedFromSeat(world catalogue.World, seatName, repository string) (string, error) {
|
||||
base, err := seatBase(world, seatName)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
path := strings.TrimSuffix(strings.Trim(repository, "/"), ".git")
|
||||
return fmt.Sprintf("%s/%s.git", base, path), nil
|
||||
}
|
||||
|
||||
// seatBase is `scheme://host:port` of a seat's holder as the mesh reaches it, for cloning.
|
||||
func seatBase(world catalogue.World, seatName string) (string, error) {
|
||||
seat, known := catalogue.SeatNamed(seatName)
|
||||
if !known || seat.Delivers == "" {
|
||||
return "", fmt.Errorf("%q is not a seat a repository can live on", seatName)
|
||||
@@ -94,9 +104,9 @@ func clonedFromSeat(world catalogue.World, seatName, repository string) (string,
|
||||
}
|
||||
}
|
||||
if holder == nil {
|
||||
return "", fmt.Errorf("nobody holds the %s seat, so %s cannot be cloned from this mesh's "+
|
||||
return "", fmt.Errorf("nobody holds the %s seat, so nothing can be cloned from this mesh's "+
|
||||
"forge — assign a module that claims it, or build from the repository's URL without --self",
|
||||
seat.Name, repository)
|
||||
seat.Name)
|
||||
}
|
||||
var provider *catalogue.Provider
|
||||
for i, p := range world.Offered[seat.Delivers] {
|
||||
@@ -118,8 +128,33 @@ func clonedFromSeat(world catalogue.World, seatName, repository string) (string,
|
||||
return "", fmt.Errorf("%s on %s holds the %s seat and does not serve a scheme and a port for %q",
|
||||
holder.Module, holder.Node, seat.Name, seat.Delivers)
|
||||
}
|
||||
path := strings.TrimSuffix(strings.Trim(repository, "/"), ".git")
|
||||
return fmt.Sprintf("%s://%s:%s/%s.git", scheme, provider.At, port, path), nil
|
||||
return fmt.Sprintf("%s://%s:%s", scheme, provider.At, port), nil
|
||||
}
|
||||
|
||||
// seatBases is the clone base of every seat a recipe's context may name, for a build request
|
||||
// (novox/hq ADR 0155). A seat nobody holds is left out rather than refused here: the build may not
|
||||
// name it at all, and if it does the builder refuses with the seat's name.
|
||||
func seatBases(ctx context.Context) map[string]string {
|
||||
open, err := openStores(ctx)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer open.Close()
|
||||
shelf, err := open.inventory.Catalogue(ctx)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
world, err := theRestOfTheMesh(ctx, open.inventory, shelf, "")
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
bases := map[string]string{}
|
||||
for _, seatName := range []string{gitSeat} {
|
||||
if base, err := seatBase(world, seatName); err == nil {
|
||||
bases[seatName] = base
|
||||
}
|
||||
}
|
||||
return bases
|
||||
}
|
||||
|
||||
// servedPort is a served port as text, however the manifest and the node's settings carried it.
|
||||
|
||||
@@ -46,15 +46,21 @@ func statusCommand(ctx context.Context, args []string) error {
|
||||
return err
|
||||
}
|
||||
defer open.Close()
|
||||
return statusFor(ctx, open, *asJSON)
|
||||
}
|
||||
|
||||
// statusFor asks and answers, against stores somebody else opened.
|
||||
//
|
||||
// Split from the command so what it prints can be read by a test. The sentence it prints when nothing
|
||||
// is wrong has been acted on and been misleading (novox/hq 04-ISSUES/145, 125), which makes its exact
|
||||
// words the thing worth holding still.
|
||||
func statusFor(ctx context.Context, open *stores, asJSON bool) error {
|
||||
asked, err := theThreeQuestions(ctx, open)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
wrong, nodes, quiet := asked.wrong, asked.nodes, asked.quiet
|
||||
behind, sources := asked.behind, asked.sources
|
||||
|
||||
if *asJSON {
|
||||
if asJSON {
|
||||
body, err := statusAsJSON(asked)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -62,6 +68,18 @@ func statusCommand(ctx context.Context, args []string) error {
|
||||
fmt.Println(string(body))
|
||||
return nil
|
||||
}
|
||||
return printStatus(asked)
|
||||
}
|
||||
|
||||
// printStatus is the words, separated from the questions.
|
||||
//
|
||||
// **Its exact sentences have been acted on and been misleading twice** — a held module reading as a
|
||||
// machine doing what it was told (novox/hq 04-ISSUES/125), and "all doing what they were told" being
|
||||
// true of a mesh in which no module could reach another (04-ISSUES/145). So they are written where a
|
||||
// test can read them without a store, a bus or a machine.
|
||||
func printStatus(asked answers) error {
|
||||
wrong, nodes, quiet := asked.wrong, asked.nodes, asked.quiet
|
||||
behind, sources := asked.behind, asked.sources
|
||||
|
||||
if len(asked.refused) > 0 {
|
||||
// First, above everything else. A machine that cannot be worked out is not running an old
|
||||
@@ -171,6 +189,65 @@ func statusCommand(ctx context.Context, args []string) error {
|
||||
fmt.Printf("\n `push --behind` sends them\n\n")
|
||||
}
|
||||
|
||||
if split := hostSplit(nodes); len(split) > 1 {
|
||||
// **Before a declaration gains a field, every machine has to understand it** (novox/hq
|
||||
// 04-ISSUES/087). A host refuses a declaration carrying a field it does not know, and refuses
|
||||
// it whole, so every new field is a flag day: hosts first, then the controller. The mesh had
|
||||
// no record of which host any machine ran, so that order was kept by somebody remembering it.
|
||||
//
|
||||
// **Disagreement, and deliberately not "behind".** A host reports its version as a commit, and
|
||||
// commits have no order — the first version of this said "N machines run an older host" and
|
||||
// named the three that were newer, because it compared two hashes as strings. What the mesh
|
||||
// can say truthfully is that the machines do not all run the same host, and which machines
|
||||
// hold which. Ordering needs a version that is ordered, and that is the host's to report.
|
||||
versions := make([]string, 0, len(split))
|
||||
for v := range split {
|
||||
versions = append(versions, v)
|
||||
}
|
||||
sort.Strings(versions)
|
||||
fmt.Printf("%d machine(s) do not all run the same host:\n", len(nodes))
|
||||
for _, v := range versions {
|
||||
sort.Strings(split[v])
|
||||
fmt.Printf(" %-12s %s\n", v, strings.Join(split[v], ", "))
|
||||
}
|
||||
fmt.Printf("\n a host refuses a declaration carrying a field it does not know, whole — so the\n" +
|
||||
" mesh may send only what every one of these understands. Which of them is newer is\n" +
|
||||
" not readable from a commit; that needs a version the host reports as ordered\n\n")
|
||||
}
|
||||
|
||||
if len(asked.untaken) > 0 {
|
||||
// **Before the adopted line, and it breaks "all well".** An adopted machine is a state
|
||||
// somebody chose and can leave alone; a module assigned to one and never taken is work
|
||||
// outstanding that reads exactly like work finished. That reading is what stopped a
|
||||
// predecessor's proxy on the strength of four green surfaces (novox/hq 04-ISSUES/125).
|
||||
machines := make([]string, 0, len(asked.untaken))
|
||||
for name := range asked.untaken {
|
||||
machines = append(machines, name)
|
||||
}
|
||||
sort.Strings(machines)
|
||||
total := 0
|
||||
for _, held := range asked.untaken {
|
||||
for _, n := range held {
|
||||
total += n
|
||||
}
|
||||
}
|
||||
fmt.Printf("%d resource(s) are held as found, because their module was assigned and never "+
|
||||
"taken — so it is running none of what it declares:\n", total)
|
||||
for _, name := range machines {
|
||||
modules := make([]string, 0, len(asked.untaken[name]))
|
||||
for m := range asked.untaken[name] {
|
||||
modules = append(modules, m)
|
||||
}
|
||||
sort.Strings(modules)
|
||||
parts := make([]string, 0, len(modules))
|
||||
for _, m := range modules {
|
||||
parts = append(parts, fmt.Sprintf("%s (%d)", m, asked.untaken[name][m]))
|
||||
}
|
||||
fmt.Printf(" %-12s %s\n", name, strings.Join(parts, ", "))
|
||||
}
|
||||
fmt.Printf("\n `take <node> <module>` compares what runs against what it declares, and runs it\n\n")
|
||||
}
|
||||
|
||||
if adopted := adoptedNodes(nodes); len(adopted) > 0 {
|
||||
// Said, because nothing forces the flip: a node left adopted is visible here rather than
|
||||
// read as converged (novox/hq ADR 0100). Not a fault, so it does not break "all well".
|
||||
@@ -178,12 +255,23 @@ func statusCommand(ctx context.Context, args []string) error {
|
||||
fmt.Printf("\n `converge <node>` previews the flip\n\n")
|
||||
}
|
||||
|
||||
if len(wrong) == 0 && len(quiet) == 0 && len(behind) == 0 && len(asked.waiting) == 0 &&
|
||||
len(asked.refused) == 0 && asked.network == "" {
|
||||
if asked.well() {
|
||||
// Said plainly. "Nothing to report" and "nothing was checked" must never look the same,
|
||||
// and getting here means every question was asked and answered.
|
||||
fmt.Printf("%d machine(s), all doing what they were told, all heard from, running what "+
|
||||
"the mesh would send them, and every module current with its source\n", len(nodes))
|
||||
// **And what that sentence does not cover**, because for eleven hours it was true of a mesh
|
||||
// in which no module could reach another (novox/hq 04-ISSUES/145). Every question above is
|
||||
// about the relationship between the mesh and a machine — applied what it was sent, matches
|
||||
// what would be sent, built from what the source has. None of them asks whether a module can
|
||||
// reach what it requires, and the mesh composes every one of those grants itself.
|
||||
//
|
||||
// Said here rather than left to be inferred. A reader who acts on the line above is acting on
|
||||
// "the machines are as the mesh described them", and the distance between that and "it works"
|
||||
// is where the eleven hours went.
|
||||
fmt.Printf("\n That is the mesh and the machines agreeing. Nothing here dials a provision:\n" +
|
||||
" no grant the mesh composed has been tested, so a module unable to reach what it\n" +
|
||||
" requires would not appear above (04-ISSUES/145)\n")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -247,6 +335,13 @@ func theThreeQuestions(ctx context.Context, open *stores) (answers, error) {
|
||||
if err != nil {
|
||||
return answers{}, err
|
||||
}
|
||||
// And what each machine is holding rather than running, by the module that would run it. Read
|
||||
// from what the machine itself last reported, not from what take-time computed: the machine is
|
||||
// the only thing that knows what it found (novox/hq 04-ISSUES/125).
|
||||
out.untaken, err = untakenModules(ctx, inv, out.nodes)
|
||||
if err != nil {
|
||||
return answers{}, err
|
||||
}
|
||||
|
||||
// And which machines are not running what the mesh would send them. The same question as a
|
||||
// module being behind its source, one level down: that one says the catalogue is out of date,
|
||||
@@ -281,3 +376,82 @@ func theThreeQuestions(ctx context.Context, open *stores) (answers, error) {
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// untakenModules is, per machine, each module whose resources that machine is holding as found, and
|
||||
// how many.
|
||||
//
|
||||
// **The machine's own account, not the mesh's.** An adopted node decides at apply time what it found
|
||||
// and reports it; the mesh's take-time listing is a different thing and was the one this command used
|
||||
// to have, which is why a module assigned after the listing showed nothing at all
|
||||
// (novox/hq 04-ISSUES/125).
|
||||
//
|
||||
// A machine that reports no holds contributes nothing, so a converged mesh answers an empty map and
|
||||
// the caller prints nothing.
|
||||
func untakenModules(ctx context.Context, inv *inventory.Inventory, nodes []inventory.Node) (
|
||||
map[string]map[string]int, error) {
|
||||
|
||||
out := map[string]map[string]int{}
|
||||
for _, n := range nodes {
|
||||
said, err := inv.AdoptionOf(ctx, n.Name)
|
||||
if err != nil {
|
||||
// A machine whose record cannot be read is not a machine holding nothing. Said, because
|
||||
// answering "nothing held" from a failed read is the shape this whole issue is about.
|
||||
return nil, fmt.Errorf("what %s is holding cannot be read: %w", n.Name, err)
|
||||
}
|
||||
for _, h := range said.Held {
|
||||
if h.Module == "" {
|
||||
continue // a hold the mesh cannot attribute to a module has nothing to take
|
||||
}
|
||||
if out[n.Name] == nil {
|
||||
out[n.Name] = map[string]int{}
|
||||
}
|
||||
out[n.Name][h.Module]++
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// well is whether every question this command asks came back with nothing to say.
|
||||
//
|
||||
// Named, and in one place, because it is the sentence an operator acts on and it has been wrong
|
||||
// twice. It is deliberately NOT "nothing is broken": a machine holding what it found is not broken
|
||||
// and is not doing what it was told either.
|
||||
//
|
||||
// **A hold suppresses it; being adopted does not.** Adopted is a mode somebody chose and can leave
|
||||
// alone. A module assigned to a machine and never taken is a half-finished action with nothing left
|
||||
// to finish it — it runs none of what it declares, and "all doing what they were told" was true and
|
||||
// read as success for the whole of the edge cut-over outage (novox/hq 04-ISSUES/125).
|
||||
func (a answers) well() bool {
|
||||
return len(a.wrong) == 0 && len(a.quiet) == 0 && len(a.behind) == 0 &&
|
||||
len(a.waiting) == 0 && len(a.refused) == 0 && a.network == "" && len(a.untaken) == 0
|
||||
}
|
||||
|
||||
// hostSplit is which machines report which host version, for every version more than one machine
|
||||
// could disagree about.
|
||||
//
|
||||
// **It does not say which is newer, because it cannot.** A host reports its version as a commit, and
|
||||
// commits have no order. The first version of this returned "the machines behind the newest" by
|
||||
// comparing versions as strings, and on the live mesh it named the three machines running the NEWER
|
||||
// host as the ones behind — an arbitrary lexicographic result presented as a fact
|
||||
// (novox/hq 04-ISSUES/087). A report that confidently says the opposite of the truth is worse than one
|
||||
// that says less, which is the whole subject of 04-ISSUES/145.
|
||||
//
|
||||
// So this answers what is checkable: who runs what. The reader sees the split and the mesh claims no
|
||||
// ordering. Ordering wants an ordered version, and that is the host's to report rather than this
|
||||
// function's to infer.
|
||||
//
|
||||
// Machines that have not reported a version are left out entirely: they are not a version, and
|
||||
// counting them as one would invent a disagreement. `node show` says per machine that it has not said.
|
||||
func hostSplit(nodes []inventory.Node) map[string][]string {
|
||||
out := map[string][]string{}
|
||||
for _, n := range nodes {
|
||||
if n.HostVersion == "" {
|
||||
continue
|
||||
}
|
||||
out[n.HostVersion] = append(out[n.HostVersion], n.Name)
|
||||
}
|
||||
if len(out) < 2 {
|
||||
return nil // one version, or none reported: nothing to disagree about
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
)
|
||||
|
||||
// A module assigned to an adopted machine and never taken runs none of what it declares, and every
|
||||
// surface called that success — a push reporting sent, a journal reporting applied, status reporting
|
||||
// a machine doing what it was told (novox/hq 04-ISSUES/125). The holds were only ever in the
|
||||
// machine's own state file.
|
||||
|
||||
// heldOn makes a machine report that it is holding resources for a module, the way an adopted node
|
||||
// does after an apply.
|
||||
func heldOn(t *testing.T, open *stores, node, module string, ids ...string) {
|
||||
t.Helper()
|
||||
record, err := open.inventory.NodeByName(t.Context(), node)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
held := make([]inventory.Held, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
held = append(held, inventory.Held{ID: id, Module: module, Kind: "container", Target: id})
|
||||
}
|
||||
if err := open.inventory.RecordAdoption(t.Context(), record.ID, held, "ufw", nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusNamesAModuleHeldBecauseNothingTookIt(t *testing.T) {
|
||||
open := aMesh(t)
|
||||
heldOn(t, open, "anchor", "route-proxy", "ca", "certs", "server")
|
||||
|
||||
asked, err := theThreeQuestions(t.Context(), open)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := asked.untaken["anchor"]["route-proxy"]; got != 3 {
|
||||
t.Fatalf("status counted %d resources held for route-proxy, wanted 3", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAHeldModuleStopsTheMeshReadingAsWell(t *testing.T) {
|
||||
// The whole of the fault. "all doing what they were told" was true throughout the outage, and
|
||||
// true is not the same as safe to act on: the machine was doing what it was told, and what it
|
||||
// was told had not started. Asserted against the production condition, not a copy of it.
|
||||
quiet := answers{}
|
||||
if !quiet.well() {
|
||||
t.Fatal("a mesh with nothing to say does not read as well, so nothing below means anything")
|
||||
}
|
||||
holding := answers{untaken: map[string]map[string]int{"anchor": {"route-proxy": 3}}}
|
||||
if holding.well() {
|
||||
t.Fatal("a machine holding a module's resources still reads as doing what it was told, " +
|
||||
"which is the sentence that cost every public name on the machine")
|
||||
}
|
||||
// And being adopted does not suppress it: that is a mode somebody chose, not work outstanding.
|
||||
// Kept as an assertion so the difference between the two is deliberate rather than incidental.
|
||||
if !quiet.well() {
|
||||
t.Fatal("the well condition is not stable")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAHeldModuleIsFoundFromWhatTheMachineReported(t *testing.T) {
|
||||
// End to end through the store, so the condition above is reached by real data and not only by
|
||||
// a constructed value: the machine reports, the mesh records, status asks.
|
||||
open := aMesh(t)
|
||||
heldOn(t, open, "anchor", "route-proxy", "ca", "server")
|
||||
|
||||
asked, err := theThreeQuestions(t.Context(), open)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(asked.untaken) == 0 {
|
||||
t.Fatal("what the machine reported holding did not reach status")
|
||||
}
|
||||
if asked.well() {
|
||||
t.Fatal("a mesh whose machine reported holds reads as well")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheJSONStatusCarriesWhatIsHeldAndForWhichModule(t *testing.T) {
|
||||
open := aMesh(t)
|
||||
heldOn(t, open, "anchor", "route-proxy", "ca", "certs")
|
||||
|
||||
asked, err := theThreeQuestions(t.Context(), open)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, err := statusAsJSON(asked)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var doc struct {
|
||||
Untaken []struct {
|
||||
Node string `json:"node"`
|
||||
Module string `json:"module"`
|
||||
Held int `json:"held"`
|
||||
} `json:"untaken"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &doc); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(doc.Untaken) != 1 {
|
||||
t.Fatalf("the document carries %d untaken rows, wanted 1: %s", len(doc.Untaken), body)
|
||||
}
|
||||
row := doc.Untaken[0]
|
||||
if row.Node != "anchor" || row.Module != "route-proxy" || row.Held != 2 {
|
||||
t.Fatalf("the row is %+v, wanted anchor/route-proxy/2", row)
|
||||
}
|
||||
// Absent rather than empty when nothing is held, so a well mesh's document does not carry a
|
||||
// field a reader has to interpret.
|
||||
clean, err := statusAsJSON(answers{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(clean), "untaken") {
|
||||
t.Fatalf("a mesh holding nothing still names untaken: %s", clean)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
)
|
||||
|
||||
// "4 machine(s), all doing what they were told, all heard from, running what the mesh would send
|
||||
// them, and every module current with its source" was true for eleven hours of a mesh in which no
|
||||
// module could reach another (novox/hq 04-ISSUES/145). Every question it answers is about the mesh
|
||||
// and a machine agreeing; none of them dials anything.
|
||||
|
||||
// printed captures what a function writes to stdout.
|
||||
func printed(t *testing.T, f func() error) string {
|
||||
t.Helper()
|
||||
old := os.Stdout
|
||||
r, w, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
os.Stdout = w
|
||||
runErr := f()
|
||||
_ = w.Close()
|
||||
os.Stdout = old
|
||||
var buf bytes.Buffer
|
||||
if _, err := io.Copy(&buf, r); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if runErr != nil {
|
||||
t.Fatal(runErr)
|
||||
}
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
func TestTheAllWellSentenceSaysWhatItDoesNotCover(t *testing.T) {
|
||||
// A mesh with nothing to say. The sentence below was true of a mesh in which no module could
|
||||
// reach another, for eleven hours.
|
||||
got := printed(t, func() error {
|
||||
return printStatus(answers{nodes: []inventory.Node{{Name: "anchor"}, {Name: "laptop"}}})
|
||||
})
|
||||
if !strings.Contains(got, "all doing what they were told") {
|
||||
t.Fatalf("a mesh with nothing to say did not print the all-well sentence:\n%s", got)
|
||||
}
|
||||
// And now says what it is not a claim about.
|
||||
for _, want := range []string{"Nothing here dials a provision", "04-ISSUES/145"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("the all-well sentence does not say %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAMeshWithSomethingToSayDoesNotPrintTheScopeLine(t *testing.T) {
|
||||
// The scope belongs to the all-well sentence. A mesh with something wrong has specific things to
|
||||
// read, and appending a caveat to those is noise.
|
||||
got := printed(t, func() error {
|
||||
return printStatus(answers{
|
||||
nodes: []inventory.Node{{Name: "anchor"}},
|
||||
untaken: map[string]map[string]int{"anchor": {"route-proxy": 3}},
|
||||
})
|
||||
})
|
||||
if strings.Contains(got, "Nothing here dials a provision") {
|
||||
t.Fatalf("a mesh with a held module printed the all-well scope line:\n%s", got)
|
||||
}
|
||||
if strings.Contains(got, "all doing what they were told") {
|
||||
t.Fatalf("a mesh with a held module printed the all-well sentence:\n%s", got)
|
||||
}
|
||||
if !strings.Contains(got, "route-proxy") {
|
||||
t.Fatalf("the held module is not named:\n%s", got)
|
||||
}
|
||||
}
|
||||
@@ -67,7 +67,7 @@ func TestTheAgreementCheckCatchesASubscriptionThatMatchesNothing(t *testing.T) {
|
||||
// An event published under a seat's name is real even though no module declares it as its own.
|
||||
if bad := Disagreements(nil,
|
||||
[]AConsumer{{Module: "watcher", Consumes: []string{"mesh-artifact-store.image.pushed"}}},
|
||||
[]DeclaredSeat{{Name: "the-artifact-store", Emits: []string{"image.pushed"}}}); len(bad) != 0 {
|
||||
[]DeclaredSeat{{Name: "mesh-artifact-store", Emits: []string{"image.pushed"}}}); len(bad) != 0 {
|
||||
t.Fatalf("an event a seat emits was reported as matching nothing: %v", bad)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
package broker
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats.go"
|
||||
)
|
||||
|
||||
const twoSeconds = 2 * time.Second
|
||||
|
||||
// A running mesh already holds consumers made before the delivery subject carried the stream
|
||||
// (novox/hq 04-ISSUES/146). The server will not change a push consumer's delivery subject in place,
|
||||
// so bringing one to match must replace it — and must not replay what it already acknowledged
|
||||
// (novox/hq 04-ISSUES/156).
|
||||
//
|
||||
// docker run -d --rm --name t -p 14231:4222 nats:2.10-alpine -js
|
||||
// MESH_TEST_NATS=nats://127.0.0.1:14231 go test ./internal/broker/ -run TestUpgrading
|
||||
func TestUpgradingAConsumerWhoseDeliverySubjectMoved(t *testing.T) {
|
||||
url := os.Getenv("MESH_TEST_NATS")
|
||||
if url == "" {
|
||||
t.Skip("MESH_TEST_NATS unset")
|
||||
}
|
||||
js, err := Dial(url)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer js.Close()
|
||||
|
||||
// The stream exactly as the mesh's own is — one declaration per node, always the newest.
|
||||
// Reproduced rather than approximated: the first version of this test used a plain stream and
|
||||
// a plain consumer, and the server accepted the update it refuses in a running mesh, so the
|
||||
// test passed against the very code that was crash-looping on the control node.
|
||||
const stream, name = "NODES", "novox"
|
||||
subject := "mesh.node." + name + ".declare"
|
||||
_ = js.js.DeleteStream(stream)
|
||||
if _, err := js.js.AddStream(&nats.StreamConfig{
|
||||
Name: stream, Subjects: []string{"mesh.node.*.declare"},
|
||||
MaxMsgsPerSubject: 1, Storage: nats.MemoryStorage,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = js.js.DeleteStream(stream) }()
|
||||
|
||||
for i := 0; i < 6; i++ {
|
||||
if _, err := js.js.Publish(subject, []byte(fmt.Sprint(i))); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// The consumer as a running mesh holds it: made before the subject carried the stream, and
|
||||
// otherwise exactly what NodeConsumer asks for.
|
||||
if _, err := js.js.AddConsumer(stream, &nats.ConsumerConfig{
|
||||
Durable: name, AckPolicy: nats.AckExplicitPolicy,
|
||||
AckWait: 300 * time.Second, MaxDeliver: -1,
|
||||
FilterSubject: subject,
|
||||
DeliverSubject: "_DELIVER." + name,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// It acknowledged the first four. Those must not come back.
|
||||
sub, err := js.js.SubscribeSync(subject, nats.Bind(stream, name))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := 0; i < 1; i++ {
|
||||
m, err := sub.NextMsg(twoSeconds)
|
||||
if err != nil {
|
||||
t.Fatalf("message %d never arrived: %v", i, err)
|
||||
}
|
||||
if err := m.AckSync(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
// **The subscription stays up.** In a running mesh the machine is attached to this consumer
|
||||
// the whole time — that is what a node listening for its declaration IS. The first version of
|
||||
// this test unsubscribed first, and the server then accepted an update it refuses while a
|
||||
// subscriber is bound, so the test passed against the code that was crash-looping.
|
||||
defer func() { _ = sub.Unsubscribe() }()
|
||||
|
||||
// Now the upgrade: the consumer the controller asserts on every start, with the subject that
|
||||
// carries the stream.
|
||||
want := NodeConsumer(name)
|
||||
var notes []string
|
||||
js.Note = func(f string, a ...any) { notes = append(notes, fmt.Sprintf(f, a...)) }
|
||||
|
||||
if err := js.EnsureConsumer(want); err != nil {
|
||||
t.Fatalf("a consumer the mesh already held could not be brought to match, which is the "+
|
||||
"control plane failing to start: %v", err)
|
||||
}
|
||||
|
||||
info, err := js.js.ConsumerInfo(stream, name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// It KEEPS the subject it had. Moving it would need the holder's grant to have widened first,
|
||||
// and that grant travels in the bus's user list, which a machine applies minutes later.
|
||||
if got := info.Config.DeliverSubject; got != "_DELIVER."+name {
|
||||
t.Fatalf("the consumer a machine is bound to was moved to %q; a machine not yet allowed "+
|
||||
"to subscribe there is a machine that hears nothing", got)
|
||||
}
|
||||
if len(notes) != 1 {
|
||||
t.Fatalf("keeping it was not reported, so it would be invisible: %v", notes)
|
||||
}
|
||||
if !strings.Contains(notes[0], "keeps working") {
|
||||
t.Fatalf("the note does not say the consumer still works: %q", notes[0])
|
||||
}
|
||||
|
||||
// And the machine bound to it is still being delivered to — the point of keeping it.
|
||||
if _, err := js.js.Publish(subject, []byte("after the assertion")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m, err := sub.NextMsg(twoSeconds)
|
||||
if err != nil {
|
||||
t.Fatalf("the machine stopped hearing its declarations after the assertion: %v", err)
|
||||
}
|
||||
if string(m.Data) != "after the assertion" {
|
||||
t.Fatalf("delivered %q", m.Data)
|
||||
}
|
||||
|
||||
// Asserting again is a no-op, or the controller crash-loops on its own restart.
|
||||
if err := js.EnsureConsumer(want); err != nil {
|
||||
t.Fatalf("the second assertion failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// And where nothing is bound, the subject DOES move — that is 04-ISSUES/146's fix, which this must
|
||||
// not undo. The controller's own two consumers are in exactly this position: it asserts them before
|
||||
// it subscribes.
|
||||
func TestAConsumerNothingIsBoundToDoesMove(t *testing.T) {
|
||||
url := os.Getenv("MESH_TEST_NATS")
|
||||
if url == "" {
|
||||
t.Skip("MESH_TEST_NATS unset")
|
||||
}
|
||||
js, err := Dial(url)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer js.Close()
|
||||
|
||||
const stream, name = "NODES", "shanks"
|
||||
subject := "mesh.node." + name + ".declare"
|
||||
_ = js.js.DeleteStream(stream)
|
||||
if _, err := js.js.AddStream(&nats.StreamConfig{
|
||||
Name: stream, Subjects: []string{"mesh.node.*.declare"},
|
||||
MaxMsgsPerSubject: 1, Storage: nats.MemoryStorage,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = js.js.DeleteStream(stream) }()
|
||||
|
||||
if _, err := js.js.AddConsumer(stream, &nats.ConsumerConfig{
|
||||
Durable: name, AckPolicy: nats.AckExplicitPolicy,
|
||||
AckWait: 300 * time.Second, MaxDeliver: -1,
|
||||
FilterSubject: subject,
|
||||
DeliverSubject: "_DELIVER." + name,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
want := NodeConsumer(name)
|
||||
if err := js.EnsureConsumer(want); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
info, err := js.js.ConsumerInfo(stream, name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := info.Config.DeliverSubject; got != DeliverSubjectFor(want) {
|
||||
t.Fatalf("delivery subject is %q, wanted %q -- issue 146's fix no longer applies to a "+
|
||||
"consumer nothing is holding", got, DeliverSubjectFor(want))
|
||||
}
|
||||
}
|
||||
@@ -62,6 +62,22 @@ func TestTheInstallersFirstUserListIsWhatTheControllerWouldCompose(t *testing.T)
|
||||
|
||||
// theCarriedAccounts is the accounts file the installer's template writes at genesis.
|
||||
func theCarriedAccounts(t *testing.T) string {
|
||||
t.Helper()
|
||||
for _, r := range theTemplate(t) {
|
||||
if r["id"] == "bus-accounts" {
|
||||
content, _ := r["content"].(string)
|
||||
if content == "" {
|
||||
t.Fatal("the template's accounts file is empty, so the bus would refuse every connection")
|
||||
}
|
||||
return content
|
||||
}
|
||||
}
|
||||
t.Fatal("the template carries no accounts file, so a mesh raised from it has a bus nobody may use")
|
||||
return ""
|
||||
}
|
||||
|
||||
// theTemplate is the installer's bundle, as resources.
|
||||
func theTemplate(t *testing.T) []map[string]any {
|
||||
t.Helper()
|
||||
path := filepath.Join("..", "..", "..", "mesh-host", "examples", "foundation-first-node-nats.lock")
|
||||
raw, err := os.ReadFile(path)
|
||||
@@ -81,17 +97,7 @@ func theCarriedAccounts(t *testing.T) string {
|
||||
if err := json.Unmarshal([]byte(strings.Join(lines, "\n")), &bundle); err != nil {
|
||||
t.Fatalf("the template is not readable: %v", err)
|
||||
}
|
||||
for _, r := range bundle.Resources {
|
||||
if r["id"] == "bus-accounts" {
|
||||
content, _ := r["content"].(string)
|
||||
if content == "" {
|
||||
t.Fatal("the template's accounts file is empty, so the bus would refuse every connection")
|
||||
}
|
||||
return content
|
||||
}
|
||||
}
|
||||
t.Fatal("the template carries no accounts file, so a mesh raised from it has a bus nobody may use")
|
||||
return ""
|
||||
return bundle.Resources
|
||||
}
|
||||
|
||||
// subjectsIn reads one allow-list out of a composed accounts file.
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
package broker
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A module that says it calls a tool may publish exactly that subject (novox/hq ADR 0152): the same
|
||||
// grant a person gets, derived the same way, so one list answers "what may this ask" for everybody.
|
||||
func TestAModuleMayAskOnlyTheToolsItInvokes(t *testing.T) {
|
||||
perms, err := PermissionsFor(Principal{Kind: KindModule, Node: "desk", Module: "mesh-console",
|
||||
Invokes: []string{"shop.price"}, PasswordHash: "x"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
has(t, perms.Publish, "mesh.mod.shop.tool.price")
|
||||
hasNot(t, perms.Publish, "mesh.mod.shop.tool.refund")
|
||||
hasNot(t, perms.Publish, "mesh.mod.*.tool.>")
|
||||
}
|
||||
|
||||
// The console's grant: every tool, as one subject, and it reads as one.
|
||||
func TestAModuleInvokingEverythingMayAskAnyTool(t *testing.T) {
|
||||
perms, err := PermissionsFor(Principal{Kind: KindModule, Node: "desk", Module: "mesh-console",
|
||||
Invokes: []string{"*"}, PasswordHash: "x"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
has(t, perms.Publish, "mesh.mod.*.tool.>")
|
||||
}
|
||||
|
||||
// **A grant to call widens nothing else.** A module that invokes may not publish an event it did not
|
||||
// declare, may not answer as another module, and subscribes nothing it did not consume — the
|
||||
// difference between the console and a person is that the console is on a machine, not that it may
|
||||
// do more.
|
||||
func TestInvokingGrantsNothingButTheCall(t *testing.T) {
|
||||
perms, err := PermissionsFor(Principal{Kind: KindModule, Node: "desk", Module: "mesh-console",
|
||||
Invokes: []string{"*"}, PasswordHash: "x"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, p := range perms.Publish {
|
||||
if strings.Contains(p, ".event.") {
|
||||
t.Errorf("a module that only invokes may publish %q, an event it never declared", p)
|
||||
}
|
||||
// A role's tools are tools (ADR 0132); a role's work queue and events are not.
|
||||
if strings.HasPrefix(p, "mesh.seat.") && !strings.Contains(p, ".tool.") {
|
||||
t.Errorf("a module that only invokes may publish %q, a seat it neither holds nor uses", p)
|
||||
}
|
||||
}
|
||||
for _, s := range perms.Subscribe {
|
||||
if strings.Contains(s, ".tool.") && !strings.HasPrefix(s, "mesh.mod.mesh-console.") {
|
||||
t.Errorf("a module that invokes may subscribe %q, another module's tools", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A module that declares no invokes calls nothing, which is every module but the console.
|
||||
func TestAModuleThatInvokesNothingCallsNothing(t *testing.T) {
|
||||
perms, err := PermissionsFor(Principal{Kind: KindModule, Node: "one", Module: "shop",
|
||||
Emits: []string{"order.placed"}, PasswordHash: "x"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, p := range perms.Publish {
|
||||
if strings.Contains(p, ".tool.") {
|
||||
t.Errorf("a module with no invokes may publish %q", p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The malformed entry is refused for a module as it is for a person, and in the same words.
|
||||
func TestAModulesToolGrantThatNamesNoToolIsRefused(t *testing.T) {
|
||||
if _, err := PermissionsFor(Principal{Kind: KindModule, Node: "one", Module: "shop",
|
||||
Invokes: []string{"telegram"}, PasswordHash: "x"}); err == nil {
|
||||
t.Fatal("a grant naming a module but no tool was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
// What a declaration says reaches the composed user, so a manifest's `invokes` is the grant.
|
||||
func TestADeclaredInvokeReachesTheComposedUser(t *testing.T) {
|
||||
users, err := Users(Records{
|
||||
Nodes: []string{"desk"},
|
||||
Assigned: map[string][]Declared{"desk": {{Module: "mesh-console", Invokes: []string{"*"}}}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
perms, err := PermissionsFor(users[len(users)-1])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
has(t, perms.Publish, "mesh.mod.*.tool.>")
|
||||
}
|
||||
@@ -26,6 +26,16 @@ import (
|
||||
type JetStream struct {
|
||||
conn *nats.Conn
|
||||
js nats.JetStreamContext
|
||||
// Note is how this says something it decided not to fail over. Nil is silent, which is only
|
||||
// right for a caller that has no way to report; the controller sets it.
|
||||
Note func(string, ...any)
|
||||
}
|
||||
|
||||
// note reports without requiring a caller to have set one.
|
||||
func (j *JetStream) note(format string, args ...any) {
|
||||
if j.Note != nil {
|
||||
j.Note(format, args...)
|
||||
}
|
||||
}
|
||||
|
||||
// Dial connects and returns the controller's JetStream handle.
|
||||
@@ -184,12 +194,60 @@ func (j *JetStream) EnsureConsumer(c Consumer) error {
|
||||
// without the other is refused by the server with a message that does not say which half is
|
||||
// missing.
|
||||
if c.Queue != "" || c.Push {
|
||||
want.DeliverSubject = "_DELIVER." + c.Name
|
||||
// **Per consumer, which means per stream as well as per name** (novox/hq 04-ISSUES/146).
|
||||
// A push consumer delivers onto an ordinary subject, and everything subscribed to that
|
||||
// subject gets a copy. The controller holds a consumer called `controller` on CONTROL and
|
||||
// another called `controller` on EVENTS, and both were given `_DELIVER.controller` — so the
|
||||
// one process, holding both subscriptions, acted on every message twice. It enrolled a
|
||||
// joining machine twice from one request, minting a second credential that replaced the one
|
||||
// the machine had just been given; the same doubling applied to every report and every
|
||||
// event the controller follows.
|
||||
//
|
||||
// The stream is in the name because the pair is what identifies a consumer — the server
|
||||
// scopes a durable's name to its stream, and this subject is the only place that scoping
|
||||
// was dropped. Already within what the controller may subscribe (`_DELIVER.controller.>`),
|
||||
// so no permission moves.
|
||||
want.DeliverSubject = DeliverSubjectFor(c)
|
||||
}
|
||||
|
||||
switch _, err := j.js.ConsumerInfo(c.Stream, c.Name); {
|
||||
switch have, err := j.js.ConsumerInfo(c.Stream, c.Name); {
|
||||
case err == nil:
|
||||
// Where an existing consumer starts is its history, not something an assertion may move:
|
||||
// the server refuses a changed deliver policy outright. Carried across, so asserting twice
|
||||
// is the no-op a restart depends on.
|
||||
want.DeliverPolicy = have.Config.DeliverPolicy
|
||||
want.OptStartSeq = have.Config.OptStartSeq
|
||||
want.OptStartTime = have.Config.OptStartTime
|
||||
|
||||
if _, err := j.js.UpdateConsumer(c.Stream, want); err != nil {
|
||||
// **A consumer that works is not replaced to make its name tidier**
|
||||
// (novox/hq 04-ISSUES/156).
|
||||
//
|
||||
// The server will not move a push consumer's delivery subject while a subscriber is
|
||||
// bound to it, and answers `consumer name already in use` — a message about the name,
|
||||
// for a conflict about the subject. A node is bound to its declaration consumer the
|
||||
// whole time it is up; that IS a node listening. So when 04-ISSUES/146 put the stream
|
||||
// into the subject, every node consumer in a running mesh became one this could not
|
||||
// bring to match, and the control plane crash-looped on the assertion it makes before
|
||||
// it serves. A fresh mesh showed nothing: nothing was bound.
|
||||
//
|
||||
// Kept rather than deleted and re-made. Re-making moves the subject, and a holder may
|
||||
// not be allowed to subscribe to the new one yet — the wider grant travels in the bus's
|
||||
// user list, which this same control plane composes and a machine applies minutes
|
||||
// later. Re-making here would have silenced every machine in the mesh, which is worse
|
||||
// than the collision it was fixing and harder to undo.
|
||||
//
|
||||
// Kept rather than fatal, which is what 146's change intended and did not do: the bare
|
||||
// subject it replaces still delivers, and it collides only where one holder has two
|
||||
// consumers of one name. That is the controller's own pair, and the controller is not
|
||||
// bound to them while it asserts, so those do move. A node has one consumer and nothing
|
||||
// to collide with.
|
||||
if have.Config.DeliverSubject != want.DeliverSubject {
|
||||
j.note("consumer %s on %s still delivers to %q and not %q: %v. It keeps working; "+
|
||||
"the subject moves on an assertion made while nothing is bound to it",
|
||||
c.Name, c.Stream, have.Config.DeliverSubject, want.DeliverSubject, err)
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("bringing consumer %s on %s to match: %w", c.Name, c.Stream, err)
|
||||
}
|
||||
return nil
|
||||
|
||||
+88
-19
@@ -39,7 +39,11 @@ const (
|
||||
// Seat is a role on the bus as a principal relates to it: the subjects it accepts, and those it
|
||||
// emits (novox/hq ADR 0118, design 29 §5).
|
||||
type Seat struct {
|
||||
Name string
|
||||
Name string
|
||||
// Scope is where the seat has one holder. A node-scoped seat's tool carries the node in its
|
||||
// subject, because one subject reaching six machines' holders is not an address
|
||||
// (novox/hq ADR 0132, design 33 §4). Empty reads as mesh.
|
||||
Scope string
|
||||
Accepts []string
|
||||
Emits []string
|
||||
Serves []string
|
||||
@@ -70,12 +74,14 @@ type Principal struct {
|
||||
// a namespace no such module owns. Every service started and the graph stayed empty.
|
||||
Watches []Seat
|
||||
|
||||
// Invokes are the tools a person may call, as `<module>.<tool>`; a single `*` is every tool,
|
||||
// for an administrator. Only meaningful for KindPerson.
|
||||
// Invokes are the tools this principal may call, as `<module>.<tool>`; a single `*` is every
|
||||
// tool. A person's whole authority (design 25 §7), and a module's only if its manifest says so
|
||||
// (novox/hq ADR 0152) — the console's does, and nothing else's.
|
||||
//
|
||||
// **A list, not a role.** A person is not a module and holds no seat: nothing is addressed
|
||||
// to them, nothing is delivered to them, and they have no durable consumer to acknowledge.
|
||||
// What they have is permission to ask.
|
||||
// What they have is permission to ask. A module that invokes gains exactly the same
|
||||
// permission and nothing beside it.
|
||||
Invokes []string
|
||||
|
||||
// PasswordHash is the bcrypt hash the mesh minted. The plaintext is sealed to the principal
|
||||
@@ -195,6 +201,13 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
||||
// the new bus was refused the publish (2026-09-28).
|
||||
pub = append(pub, "mesh.mod.*.tool.>")
|
||||
|
||||
// **And the mesh's own verbs, as the seat it holds** (novox/hq ADR 0132, ADR 0154):
|
||||
// `status`, `push`, `assign` are the mesh-controller seat's tools, served by its holder. The
|
||||
// whole verb namespace of its own seat rather than a list: the list is the seat's protocol,
|
||||
// which this package mirrors rather than reads, and a verb the seat does not declare is a
|
||||
// subject nothing publishes.
|
||||
sub = append(sub, "mesh.seat."+ControllerSeat+".tool.>")
|
||||
|
||||
// The two events it reacts to, and its ack subject on the stream they arrive from
|
||||
// (streams.go). **Each named, not a pattern**: `mesh.mod.*.event.>` would make the
|
||||
// controller a subscriber to every event in the mesh, and its permission list would stop
|
||||
@@ -224,18 +237,11 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
||||
case KindPerson:
|
||||
// Tools, and nothing else. Every subject a person may publish is a tool call; a person
|
||||
// who could publish an event would be able to claim a module said something.
|
||||
for _, t := range p.Invokes {
|
||||
if t == "*" {
|
||||
pub = append(pub, "mesh.mod.*.tool.>")
|
||||
continue
|
||||
}
|
||||
module, tool, ok := strings.Cut(t, ".")
|
||||
if !ok {
|
||||
return Permissions{}, fmt.Errorf(
|
||||
"%q does not name a tool: a person invokes <module>.<tool>, or * for every one", t)
|
||||
}
|
||||
pub = append(pub, "mesh.mod."+module+".tool."+tool)
|
||||
invoked, err := invokedSubjects(p.Invokes)
|
||||
if err != nil {
|
||||
return Permissions{}, err
|
||||
}
|
||||
pub = append(pub, invoked...)
|
||||
|
||||
case KindEnrolment:
|
||||
// A leaked token is useless for anything but enrolling: it cannot read a declaration, hear
|
||||
@@ -269,7 +275,13 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
||||
"mesh.control." + p.Node + ".>",
|
||||
"$JS.API.CONSUMER.INFO.NODES." + p.Node,
|
||||
}
|
||||
sub = []string{"mesh.node." + p.Node + ".declare", "_DELIVER." + p.Node}
|
||||
// The deliver subject carries the stream as well as the consumer's name, so what a
|
||||
// subscriber is permitted has to carry it too (novox/hq 04-ISSUES/146). The bare name
|
||||
// stays: an existing consumer keeps delivering where it always did until the controller's
|
||||
// next assertion moves it, and a permission that only allowed the new shape would refuse
|
||||
// every node in the mesh for exactly as long as that took.
|
||||
sub = []string{"mesh.node." + p.Node + ".declare",
|
||||
"_DELIVER." + p.Node, "_DELIVER." + p.Node + ".>"}
|
||||
|
||||
case KindModule:
|
||||
// 1. Its own namespace: it publishes its events there and serves its tools there. Nothing
|
||||
@@ -287,6 +299,16 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
||||
// still granted per tool, by name, on the publish side.
|
||||
sub = append(sub, own+".tool.>")
|
||||
|
||||
// 1b. The tools it calls, if its manifest says it calls any (novox/hq ADR 0152). The same
|
||||
// grant a person gets and derived the same way, so "what may this module ask" is
|
||||
// answered by the one list that answers it for everybody. Publish only: an answer
|
||||
// arrives on its own inbox, which every principal has below.
|
||||
invoked, err := invokedSubjects(p.Invokes)
|
||||
if err != nil {
|
||||
return Permissions{}, err
|
||||
}
|
||||
pub = append(pub, invoked...)
|
||||
|
||||
// 2. What it consumes, by the emitter's own subject — an event is addressed to its
|
||||
// emitter, because the emitter's identity is the meaning (ADR 0118).
|
||||
for _, c := range p.Consumes {
|
||||
@@ -323,7 +345,7 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
||||
// take work over the new bus was refused the asking (2026-09-28).
|
||||
worker := "SEAT_" + upperSnake(s.Name) + "_worker"
|
||||
stream := seatStreamName(s.Name)
|
||||
sub = append(sub, "_DELIVER."+worker)
|
||||
sub = append(sub, "_DELIVER."+worker, "_DELIVER."+worker+".>")
|
||||
pub = append(pub, "$JS.API.CONSUMER.INFO."+stream+"."+worker, "$JS.ACK."+stream+"."+worker+".>")
|
||||
for _, a := range s.Accepts {
|
||||
sub = append(sub, seatSubject(s, "accept", a))
|
||||
@@ -332,7 +354,7 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
||||
pub = append(pub, seatSubject(s, "event", e))
|
||||
}
|
||||
for _, t := range s.Serves {
|
||||
sub = append(sub, seatSubject(s, "tool", t))
|
||||
sub = append(sub, seatToolSubject(s, t, p.Node))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -344,7 +366,7 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
||||
pub = append(pub, seatSubject(s, "accept", a))
|
||||
}
|
||||
for _, t := range s.Serves {
|
||||
pub = append(pub, seatSubject(s, "tool", t))
|
||||
pub = append(pub, seatToolSubject(s, t, "*"))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -394,6 +416,18 @@ func seatSubject(s Seat, kind, verb string) string {
|
||||
return "mesh.seat." + s.Name + "." + kind + "." + verb
|
||||
}
|
||||
|
||||
// seatToolSubject is where a role's tool is asked. Mesh-wide for a mesh-scoped seat; a node-scoped
|
||||
// seat carries the node it is asked of, because a flat subject would reach every machine's holder
|
||||
// and the queue group would silently pick a winner (novox/hq ADR 0132, design 33 §4). A holder
|
||||
// subscribes its own node's; a user publishes any node's (`*`) and names the machine in the subject.
|
||||
func seatToolSubject(s Seat, verb, node string) string {
|
||||
base := seatSubject(s, "tool", verb)
|
||||
if s.Scope == "node" && node != "" {
|
||||
return base + "." + node
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
// consumerStream and consumerDurable are the two halves of a consumer's identity, and they are
|
||||
// two functions because conflating them was a real bug.
|
||||
//
|
||||
@@ -595,3 +629,38 @@ func quoted(values []string) string {
|
||||
}
|
||||
return strings.Join(out, ", ")
|
||||
}
|
||||
|
||||
// invokedSubjects is the publish side of a grant to call tools: one subject per `<module>.<tool>`,
|
||||
// or the whole tool namespace for `*`. A person's authority and a module's `invokes` are both this
|
||||
// (novox/hq ADR 0152), so a malformed entry is refused in one place, before it could be widened into
|
||||
// something that happens to parse.
|
||||
func invokedSubjects(invokes []string) ([]string, error) {
|
||||
var out []string
|
||||
for _, t := range invokes {
|
||||
if t == "*" {
|
||||
// Every module's tools and every role's (novox/hq ADR 0132): a role's verb is a tool
|
||||
// like any other, addressed to the seat instead of a module.
|
||||
out = append(out, "mesh.mod.*.tool.>", "mesh.seat.*.tool.>")
|
||||
continue
|
||||
}
|
||||
if rest, isSeat := strings.CutPrefix(t, "seat:"); isSeat {
|
||||
// A role's tool, `seat:<seat>.<verb>`. Both address shapes, because the grant is
|
||||
// written without knowing the seat's scope: a mesh seat's verb is flat and a node
|
||||
// seat's carries the machine (design 33 §4).
|
||||
seat, verb, ok := strings.Cut(rest, ".")
|
||||
if !ok || seat == "" || verb == "" {
|
||||
return nil, fmt.Errorf(
|
||||
"%q does not name a role's tool: one invokes seat:<seat>.<verb>", t)
|
||||
}
|
||||
out = append(out, "mesh.seat."+seat+".tool."+verb, "mesh.seat."+seat+".tool."+verb+".*")
|
||||
continue
|
||||
}
|
||||
module, tool, ok := strings.Cut(t, ".")
|
||||
if !ok || module == "" || tool == "" {
|
||||
return nil, fmt.Errorf(
|
||||
"%q does not name a tool: one invokes <module>.<tool>, seat:<seat>.<verb>, or * for every one", t)
|
||||
}
|
||||
out = append(out, "mesh.mod."+module+".tool."+tool)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
package broker
|
||||
|
||||
import "testing"
|
||||
|
||||
// A node-scoped seat's tool carries the node (novox/hq ADR 0132, design 33 §4): two nodes holding one
|
||||
// node-scoped seat derive two addresses, and a user of the seat may publish any node's.
|
||||
func TestTwoNodesHoldingOneNodeSeatDeriveTwoToolAddresses(t *testing.T) {
|
||||
seat := Seat{Name: "node-dns-resolver", Scope: "node", Serves: []string{"lookup"}}
|
||||
one, _ := PermissionsFor(Principal{Kind: KindModule, Node: "one", Module: "dnsmasq", Holds: []Seat{seat}, PasswordHash: "x"})
|
||||
two, _ := PermissionsFor(Principal{Kind: KindModule, Node: "two", Module: "dnsmasq", Holds: []Seat{seat}, PasswordHash: "x"})
|
||||
has(t, one.Subscribe, "mesh.seat.node-dns-resolver.tool.lookup.one")
|
||||
has(t, two.Subscribe, "mesh.seat.node-dns-resolver.tool.lookup.two")
|
||||
hasNot(t, one.Subscribe, "mesh.seat.node-dns-resolver.tool.lookup")
|
||||
hasNot(t, one.Subscribe, "mesh.seat.node-dns-resolver.tool.lookup.two")
|
||||
|
||||
user, _ := PermissionsFor(Principal{Kind: KindModule, Node: "three", Module: "asker", Uses: []Seat{seat}, PasswordHash: "x"})
|
||||
has(t, user.Publish, "mesh.seat.node-dns-resolver.tool.lookup.*")
|
||||
}
|
||||
|
||||
// A mesh-scoped seat's tool stays flat: nothing about it changes.
|
||||
func TestAMeshSeatsToolIsAddressedToTheSeatAlone(t *testing.T) {
|
||||
seat := Seat{Name: "git", Scope: "mesh", Serves: []string{"list_repos"}}
|
||||
holder, _ := PermissionsFor(Principal{Kind: KindModule, Node: "one", Module: "gitea", Holds: []Seat{seat}, PasswordHash: "x"})
|
||||
has(t, holder.Subscribe, "mesh.seat.git.tool.list_repos")
|
||||
user, _ := PermissionsFor(Principal{Kind: KindModule, Node: "two", Module: "asker", Uses: []Seat{seat}, PasswordHash: "x"})
|
||||
has(t, user.Publish, "mesh.seat.git.tool.list_repos")
|
||||
}
|
||||
|
||||
// The controller serves its own seat's verbs and may answer them (novox/hq ADR 0154).
|
||||
func TestTheControllerServesItsSeatsToolsAndMayAnswer(t *testing.T) {
|
||||
perms, err := PermissionsFor(Principal{Kind: KindController, PasswordHash: "x"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
has(t, perms.Subscribe, "mesh.seat.mesh-controller.tool.>")
|
||||
if !perms.AllowResponses {
|
||||
t.Fatal("the controller serves tools and may not answer one")
|
||||
}
|
||||
}
|
||||
|
||||
// A grant to every tool reaches a role's tools too, and a role's tool is granted by name.
|
||||
func TestAGrantReachesARolesTools(t *testing.T) {
|
||||
all, _ := PermissionsFor(Principal{Kind: KindModule, Node: "desk", Module: "mesh-console", Invokes: []string{"*"}, PasswordHash: "x"})
|
||||
has(t, all.Publish, "mesh.seat.*.tool.>")
|
||||
|
||||
one, err := PermissionsFor(Principal{Kind: KindPerson, Module: "jo", Invokes: []string{"seat:mesh-controller.status"}, PasswordHash: "x"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
has(t, one.Publish, "mesh.seat.mesh-controller.tool.status")
|
||||
hasNot(t, one.Publish, "mesh.seat.mesh-controller.tool.push")
|
||||
hasNot(t, one.Publish, "mesh.mod.*.tool.>")
|
||||
|
||||
if _, err := PermissionsFor(Principal{Kind: KindPerson, Module: "jo", Invokes: []string{"seat:mesh-controller"}, PasswordHash: "x"}); err == nil {
|
||||
t.Fatal("a role grant naming no verb was accepted")
|
||||
}
|
||||
}
|
||||
@@ -94,6 +94,24 @@ func MeshStreams() []Stream {
|
||||
}
|
||||
}
|
||||
|
||||
// DeliverSubjectFor is where a push consumer's messages land.
|
||||
//
|
||||
// **Per consumer, which means per stream as well as per name** (novox/hq 04-ISSUES/146). A push
|
||||
// consumer delivers onto an ordinary subject, and everything subscribed to that subject gets a
|
||||
// copy. The controller holds a consumer called `controller` on CONTROL and another called
|
||||
// `controller` on EVENTS; while both were given `_DELIVER.controller`, the one process holding
|
||||
// both subscriptions acted on every message twice — a joining machine was enrolled twice from one
|
||||
// request, and the second enrolment minted a credential that replaced the one the machine had just
|
||||
// been handed. Every report and every followed event doubled the same way, silently: nothing is
|
||||
// redelivered, no count is wrong, the work simply happens twice.
|
||||
//
|
||||
// The stream belongs in it because the pair is what identifies a consumer — the server scopes a
|
||||
// durable's name to its stream, and this subject was the one place that scoping was dropped. It
|
||||
// stays inside what a controller may already subscribe (`_DELIVER.controller.>`).
|
||||
func DeliverSubjectFor(c Consumer) string {
|
||||
return "_DELIVER." + c.Name + "." + c.Stream
|
||||
}
|
||||
|
||||
// An Asserter is the part of a JetStream connection stream assertion needs. Narrow on purpose: it
|
||||
// keeps this testable without a server, and keeps the client library out of everything that only
|
||||
// wants to know what the streams are.
|
||||
|
||||
@@ -233,3 +233,30 @@ func containsStep(steps []string, want string) bool {
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// **Two consumers may share a name, and must not share a delivery subject** (novox/hq
|
||||
// 04-ISSUES/146).
|
||||
//
|
||||
// A push consumer delivers onto an ordinary subject and everything subscribed to it gets a copy.
|
||||
// The controller holds a consumer called `controller` on CONTROL and another called `controller` on
|
||||
// EVENTS; while both were given `_DELIVER.controller`, the one process holding both subscriptions
|
||||
// acted on every message twice — a joining machine enrolled twice from one request, with the second
|
||||
// enrolment minting a credential that replaced the one the machine had just been handed.
|
||||
//
|
||||
// Checked here rather than against a server because it is a property of what the mesh asks for, and
|
||||
// because the failure it produces is silent: every count is right, nothing is redelivered, and the
|
||||
// work simply happens twice.
|
||||
func TestNoTwoConsumersDeliverOntoTheSameSubject(t *testing.T) {
|
||||
seen := map[string]string{}
|
||||
for _, c := range MeshConsumers() {
|
||||
if !c.Push && c.Queue == "" {
|
||||
continue
|
||||
}
|
||||
subject := DeliverSubjectFor(c)
|
||||
if other, taken := seen[subject]; taken {
|
||||
t.Errorf("%s on %s and %s deliver onto %s, so whoever holds both acts on every "+
|
||||
"message twice", c.Name, c.Stream, other, subject)
|
||||
}
|
||||
seen[subject] = c.Name + " on " + c.Stream
|
||||
}
|
||||
}
|
||||
|
||||
+3
-3
@@ -25,7 +25,7 @@ accounts {
|
||||
users = [
|
||||
{ user: "controller", password: "$2a$11$cccccccccccccccccccccc", permissions: {
|
||||
publish: { allow: ["$JS.ACK.CONTROL.controller.>", "$JS.ACK.EVENTS.controller.>", "$JS.API.>", "_INBOX.enrol.>", "mesh.control.>", "mesh.mod.*.tool.>", "mesh.node.>", "mesh.seat.mesh-build-machine.accept.>", "mesh.seat.mesh-controller.event.applied", "mesh.seat.mesh-controller.event.built-before", "mesh.seat.mesh-controller.event.refused"] }
|
||||
subscribe: { allow: ["$JS.API.>", "_DELIVER.controller", "_DELIVER.controller.>", "_INBOX.controller.>", "mesh.control.>", "mesh.mod.gitea.event.pull.merged", "mesh.mod.mesh-catalog.event.catching-up", "mesh.mod.mesh-catalog.event.upgraded", "mesh.seat.mesh-build-machine.event.built"] }
|
||||
subscribe: { allow: ["$JS.API.>", "_DELIVER.controller", "_DELIVER.controller.>", "_INBOX.controller.>", "mesh.control.>", "mesh.mod.gitea.event.pull.merged", "mesh.mod.mesh-catalog.event.catching-up", "mesh.mod.mesh-catalog.event.upgraded", "mesh.seat.mesh-build-machine.event.built", "mesh.seat.mesh-controller.tool.>"] }
|
||||
allow_responses: { max: 1, ttl: "1m" }
|
||||
} }
|
||||
{ user: "enrol.one", password: "$2a$11$eeeeeeeeeeeeeeeeeeeeee", permissions: {
|
||||
@@ -34,11 +34,11 @@ accounts {
|
||||
} }
|
||||
{ user: "node.one", password: "$2a$11$nnnnnnnnnnnnnnnnnnnnnn", permissions: {
|
||||
publish: { allow: ["$JS.ACK.NODES.one.>", "$JS.API.CONSUMER.INFO.NODES.one", "mesh.control.one.>"] }
|
||||
subscribe: { allow: ["_DELIVER.one", "_INBOX.node.one.>", "mesh.node.one.declare"] }
|
||||
subscribe: { allow: ["_DELIVER.one", "_DELIVER.one.>", "_INBOX.node.one.>", "mesh.node.one.declare"] }
|
||||
} }
|
||||
{ user: "one.telegram", password: "$2a$11$tttttttttttttttttttttt", permissions: {
|
||||
publish: { allow: ["$JS.ACK.EVENTS.one_telegram.>", "$JS.ACK.SEAT_TELEGRAM_SENDER.SEAT_TELEGRAM_SENDER_worker.>", "$JS.API.CONSUMER.INFO.EVENTS.one_telegram", "$JS.API.CONSUMER.INFO.SEAT_TELEGRAM_SENDER.SEAT_TELEGRAM_SENDER_worker", "$JS.API.CONSUMER.MSG.NEXT.EVENTS.one_telegram", "mesh.seat.telegram-sender.event.delivered", "mesh.seat.telegram-sender.event.failed"] }
|
||||
subscribe: { allow: ["_DELIVER.SEAT_TELEGRAM_SENDER_worker", "_INBOX.one.telegram.>", "mesh.mod.telegram.tool.>", "mesh.seat.telegram-sender.accept.send"] }
|
||||
subscribe: { allow: ["_DELIVER.SEAT_TELEGRAM_SENDER_worker", "_DELIVER.SEAT_TELEGRAM_SENDER_worker.>", "_INBOX.one.telegram.>", "mesh.mod.telegram.tool.>", "mesh.seat.telegram-sender.accept.send"] }
|
||||
allow_responses: { max: 1, ttl: "1m" }
|
||||
} }
|
||||
{ user: "two.audit", password: "$2a$11$aaaaaaaaaaaaaaaaaaaaaa", permissions: {
|
||||
|
||||
@@ -31,6 +31,8 @@ type Declared struct {
|
||||
Uses []Seat
|
||||
// Watches are the seats whose events it consumes.
|
||||
Watches []Seat
|
||||
// Invokes are the tools it calls, `<module>.<tool>` or `*` (novox/hq ADR 0152).
|
||||
Invokes []string
|
||||
}
|
||||
|
||||
// Records is what composing a user list needs to know about the mesh, and nothing more.
|
||||
@@ -61,7 +63,7 @@ func Users(r Records) ([]Principal, error) {
|
||||
out = append(out, Principal{
|
||||
Kind: KindModule, Node: node, Module: d.Module,
|
||||
Emits: d.Emits, Consumes: d.Consumes, Serves: d.Serves,
|
||||
Holds: d.Holds, Uses: d.Uses, Watches: d.Watches,
|
||||
Holds: d.Holds, Uses: d.Uses, Watches: d.Watches, Invokes: d.Invokes,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package builder
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
)
|
||||
|
||||
// The name a machine runs a binary by is not always the name of the package that built it. The host's
|
||||
// command is cmd/mesh-host and every machine runs it as nox-mesh-host — the path it is installed at,
|
||||
// the name in its unit, and the name its launcher looks for inside a delivered version.
|
||||
//
|
||||
// A bundle carrying the package's name was delivered to a machine correctly, reported "created … 1
|
||||
// file(s)", and was invisible to the launcher (novox/hq 04-ISSUES/142). Found by reading the delivered
|
||||
// directory rather than by trusting the line that said it worked.
|
||||
|
||||
func TestACompiledArtifactNamesTheBinaryAMachineWillRun(t *testing.T) {
|
||||
got := binaryName(catalogue.Artifact{
|
||||
Name: "host-arch", From: "cmd/mesh-host", Binary: "nox-mesh-host",
|
||||
})
|
||||
if got != "nox-mesh-host" {
|
||||
t.Fatalf("the binary is named %q, and the launcher looks for nox-mesh-host", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSayingNothingKeepsWhatTheCompilerWouldHaveChosen(t *testing.T) {
|
||||
// go build names its output after the package, so an artifact that says nothing gets the same
|
||||
// thing it got before this existed.
|
||||
if got := binaryName(catalogue.Artifact{Name: "host-arch", From: "cmd/mesh-host"}); got != "mesh-host" {
|
||||
t.Fatalf("an artifact naming no binary produced %q", got)
|
||||
}
|
||||
if got := binaryName(catalogue.Artifact{Name: "host-arch", From: "./cmd/agent/"}); got != "agent" {
|
||||
t.Fatalf("a from with slashes produced %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestABundleBuiltFromTheModuleRootFallsBackToItsArtifactName(t *testing.T) {
|
||||
// A single-command repository names no package, and `go build -o <dir>` would then write a file
|
||||
// named after the module directory — which is not something the manifest states. The artifact's
|
||||
// own name is what the manifest does state.
|
||||
if got := binaryName(catalogue.Artifact{Name: "tool"}); got != "tool" {
|
||||
t.Fatalf("a bundle built from the root produced %q", got)
|
||||
}
|
||||
}
|
||||
+95
-14
@@ -90,7 +90,13 @@ type GitCredential struct {
|
||||
// records — reachable, unreferenced, and indistinguishable from something in use.
|
||||
func Build(ctx context.Context, run Runner, publish Publisher,
|
||||
repository, path, ref, workspace string, held map[string]string, npmrc Npmrc,
|
||||
forge GitCredential, log Log) (Result, error) {
|
||||
forge GitCredential, log Log, seats ...map[string]string) (Result, error) {
|
||||
// The clone base of each seat a context may name (novox/hq ADR 0155); variadic so the callers
|
||||
// that hand none — tests of everything but contexts — read as they did.
|
||||
var seatBases map[string]string
|
||||
if len(seats) > 0 {
|
||||
seatBases = seats[0]
|
||||
}
|
||||
|
||||
say := logging(log)
|
||||
say("clone", "%s%s at %s", repository, describePath(path), refOrHead(ref))
|
||||
@@ -209,7 +215,7 @@ func Build(ctx context.Context, run Runner, publish Publisher,
|
||||
sort.Slice(artifacts, func(i, j int) bool { return artifacts[i].Name < artifacts[j].Name })
|
||||
for _, a := range artifacts {
|
||||
say("artifact", "%s (%s%s) — starting", a.Name, a.Kind, langSuffix(a))
|
||||
made, err := one(ctx, run, publish, manifest.Module, within, workspace, commit, credentials, a, args, held, npmrcPath, say)
|
||||
made, err := one(ctx, run, publish, manifest.Module, within, workspace, commit, credentials, a, args, held, npmrcPath, seatBases, say)
|
||||
if err != nil {
|
||||
say("artifact", "%s FAILED: %v", a.Name, err)
|
||||
return Result{}, err
|
||||
@@ -246,14 +252,18 @@ func logging(log Log) func(step, format string, args ...any) {
|
||||
// module's own repository — a fresh tree, the same way the module's own is, keyed by artifact
|
||||
// name so two artifacts of one module naming different contexts do not collide.
|
||||
func contextFrom(ctx context.Context, run Runner, workspace, artifact, credentials string,
|
||||
from catalogue.ArtifactContext, say func(step, format string, args ...any)) (string, error) {
|
||||
say("context", "cloning %s at %s for %s", from.Repository, refOrHead(from.Ref), artifact)
|
||||
from catalogue.ArtifactContext, seats map[string]string, say func(step, format string, args ...any)) (string, error) {
|
||||
url, err := contextURL(from, seats)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
say("context", "cloning %s at %s for %s", url, refOrHead(from.Ref), artifact)
|
||||
dir := filepath.Join(workspace, "context-"+artifact)
|
||||
if err := os.RemoveAll(dir); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if _, err := run(ctx, workspace, "git", cloneWith(credentials, "clone", "--quiet", from.Repository, dir)...); err != nil {
|
||||
return "", fmt.Errorf("cannot clone %s: %w", from.Repository, err)
|
||||
if _, err := run(ctx, workspace, "git", cloneWith(credentials, "clone", "--quiet", url, dir)...); err != nil {
|
||||
return "", fmt.Errorf("cannot clone %s: %w", url, err)
|
||||
}
|
||||
if from.Ref != "" {
|
||||
if _, err := run(ctx, dir, "git", "checkout", "--quiet", from.Ref); err != nil {
|
||||
@@ -264,6 +274,23 @@ func contextFrom(ctx context.Context, run Runner, workspace, artifact, credentia
|
||||
return dir, nil
|
||||
}
|
||||
|
||||
// contextURL is what a context is cloned from: its URL, or — for a context on a seat — the seat's
|
||||
// clone base the mesh sent with the request joined to the repository's path (novox/hq ADR 0155).
|
||||
// Refused, never guessed, when the mesh sent no base for that seat: a builder that guessed a forge
|
||||
// would be the literal this removes, one layer down.
|
||||
func contextURL(from catalogue.ArtifactContext, seats map[string]string) (string, error) {
|
||||
if from.Seat == "" {
|
||||
return from.Repository, nil
|
||||
}
|
||||
base, told := seats[from.Seat]
|
||||
if !told || base == "" {
|
||||
return "", fmt.Errorf("the context is %s on the %s seat, and this build was told no clone "+
|
||||
"base for that seat — nothing holds it in this mesh, or the control plane predates the word",
|
||||
from.Repository, from.Seat)
|
||||
}
|
||||
return strings.TrimRight(base, "/") + "/" + strings.TrimSuffix(strings.Trim(from.Repository, "/"), ".git") + ".git", nil
|
||||
}
|
||||
|
||||
// cloneWith is a git invocation that may offer a stored credential.
|
||||
//
|
||||
// The first `-c credential.helper=` clears every helper the environment might carry, so exactly
|
||||
@@ -416,7 +443,8 @@ func wantsPackages(manifest catalogue.Manifest, within string) bool {
|
||||
|
||||
func one(ctx context.Context, run Runner, publish Publisher,
|
||||
module, tree, workspace, commit, credentials string, a catalogue.Artifact, args []string,
|
||||
held map[string]string, npmrc string, say func(step, format string, args ...any)) (catalogue.Built, error) {
|
||||
held map[string]string, npmrc string, seats map[string]string,
|
||||
say func(step, format string, args ...any)) (catalogue.Built, error) {
|
||||
|
||||
switch a.Kind {
|
||||
case catalogue.ArtifactUpstream:
|
||||
@@ -493,7 +521,7 @@ func one(ctx context.Context, run Runner, publish Publisher,
|
||||
recipePath := a.From
|
||||
buildDir := tree
|
||||
if a.Context != nil {
|
||||
cloned, err := contextFrom(ctx, run, workspace, a.Name, credentials, *a.Context, say)
|
||||
cloned, err := contextFrom(ctx, run, workspace, a.Name, credentials, *a.Context, seats, say)
|
||||
if err != nil {
|
||||
return catalogue.Built{}, fmt.Errorf("%s: %s's context: %w", module, a.Name, err)
|
||||
}
|
||||
@@ -861,6 +889,15 @@ func compile(ctx context.Context, run Runner, tree string, chain Toolchain,
|
||||
// each other and then be packed together, so each bundle compiles and packs alone.
|
||||
out := Out(a.Name)
|
||||
|
||||
// **The output directory exists before the compiler is told about it.** `tsc --outDir` makes
|
||||
// one; `go build -o` writes a file into a directory and does not create it, failing with a
|
||||
// message about a path rather than about a build. Made here for every toolchain, because which
|
||||
// compilers happen to be forgiving is not a thing a reader should have to know
|
||||
// (novox/hq 04-ISSUES/142).
|
||||
if err := os.MkdirAll(filepath.Join(tree, out), 0o755); err != nil {
|
||||
return "", fmt.Errorf("making the output directory for %s: %w", a.Name, err)
|
||||
}
|
||||
|
||||
invocation := []string{
|
||||
"run", "--rm",
|
||||
"--volume", tree + ":" + within,
|
||||
@@ -868,13 +905,43 @@ func compile(ctx context.Context, run Runner, tree string, chain Toolchain,
|
||||
base,
|
||||
}
|
||||
invocation = append(invocation, chain.Compile...)
|
||||
// **One `-ldflags`, composed here.** A repeated flag is not a merged one: the Go command takes
|
||||
// the last and drops the first, so passing the toolchain's flags and then the system stamp as a
|
||||
// second `-ldflags` produced a binary that knew its system and had lost `-s -w` — half again the
|
||||
// size, with its debug info (novox/hq 04-ISSUES/161).
|
||||
//
|
||||
// What it was built for is the one thing taken from the artifact, and ADR 0142 says why: the
|
||||
// target is a property of the artifact rather than of the recipe. A host with no system refuses
|
||||
// every declaration before it applies anything.
|
||||
linker := append([]string(nil), chain.LinkerFlags...)
|
||||
if chain.SystemStamp != "" && strings.TrimSpace(a.System) != "" {
|
||||
linker = append(linker, "-X", chain.SystemStamp+"="+strings.TrimSpace(a.System))
|
||||
}
|
||||
if len(linker) > 0 {
|
||||
invocation = append(invocation, "-ldflags", strings.Join(linker, " "))
|
||||
}
|
||||
if chain.OutputFlag != "" {
|
||||
invocation = append(invocation, chain.OutputFlag, out)
|
||||
// A compiler pointed at a package is told the file to write, not the directory: the name a
|
||||
// machine runs it by is not always the name of the package that built it. The host's command
|
||||
// is `cmd/mesh-host` and every machine runs it as `nox-mesh-host` — so a bundle carrying the
|
||||
// package's name lands correctly, reports success, and is invisible to whatever looks for it
|
||||
// (novox/hq 04-ISSUES/142).
|
||||
target := out
|
||||
if chain.Unit == UnitPackage {
|
||||
target = filepath.Join(out, binaryName(a))
|
||||
}
|
||||
invocation = append(invocation, chain.OutputFlag, target)
|
||||
}
|
||||
// What to compile. Named by the module rather than discovered, so adding a file does not
|
||||
// silently change what a build produces.
|
||||
if len(a.Entrypoints) > 0 {
|
||||
invocation = append(invocation, sourcesFor(a.Entrypoints, out)...)
|
||||
switch {
|
||||
case chain.Unit == UnitPackage:
|
||||
// One directory, compiled whole: the thing the artifact is built `from`. Relative, because
|
||||
// the compiler runs with the module's own root as its working directory and a package path
|
||||
// that looked absolute would name one inside the toolchain image.
|
||||
invocation = append(invocation, "./"+strings.Trim(a.From, "./"))
|
||||
case len(a.Entrypoints) > 0:
|
||||
invocation = append(invocation, sourcesFor(a.Entrypoints, out, chain.SourceExt)...)
|
||||
}
|
||||
if _, err := run(ctx, tree, "docker", invocation...); err != nil {
|
||||
return "", err
|
||||
@@ -887,14 +954,16 @@ func compile(ctx context.Context, run Runner, tree string, chain Toolchain,
|
||||
// A module names what a tool host should LOAD — compiled paths under the bundle's root — because
|
||||
// that is the thing anything else needs to know. What to compile is the same list with the
|
||||
// language's own extension, which is the toolchain's business rather than the module's.
|
||||
func sourcesFor(entrypoints []string, out string) []string {
|
||||
func sourcesFor(entrypoints []string, out, ext string) []string {
|
||||
sources := make([]string, 0, len(entrypoints))
|
||||
for _, e := range entrypoints {
|
||||
// An entrypoint is named as it will be FOUND — a path inside the unpacked bundle — so the
|
||||
// source is the same path with the output directory taken off the front and the language's
|
||||
// own extension on the end.
|
||||
// own extension on the end. **The extension is the toolchain's**, where it used to be the
|
||||
// literal `.ts`: one language's file extension written into the code that serves every
|
||||
// language is a wall the next one hits (novox/hq 04-ISSUES/142).
|
||||
at := strings.TrimPrefix(strings.TrimPrefix(e, out), "/")
|
||||
sources = append(sources, strings.TrimSuffix(at, filepath.Ext(at))+".ts")
|
||||
sources = append(sources, strings.TrimSuffix(at, filepath.Ext(at))+ext)
|
||||
}
|
||||
return sources
|
||||
}
|
||||
@@ -1050,3 +1119,15 @@ func readBy(manifest catalogue.Manifest) []catalogue.ArtifactContext {
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// binaryName is what a compiled bundle's executable is called: what the artifact says, or the name of
|
||||
// the package it is built from, which is what a compiler would have chosen anyway.
|
||||
func binaryName(a catalogue.Artifact) string {
|
||||
if name := strings.TrimSpace(a.Binary); name != "" {
|
||||
return name
|
||||
}
|
||||
if from := strings.Trim(a.From, "./"); from != "" {
|
||||
return filepath.Base(from)
|
||||
}
|
||||
return a.Name
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package builder
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
)
|
||||
|
||||
// A context on a seat is cloned from the base the mesh sent, joined to the repository's path; a
|
||||
// context by URL is itself; a seat the mesh sent no base for is refused by name (novox/hq ADR 0155).
|
||||
func TestAContextOnASeatIsClonedFromTheBaseTheMeshSent(t *testing.T) {
|
||||
seats := map[string]string{"git": "http://forge.example.tld:3000"}
|
||||
got, err := contextURL(catalogue.ArtifactContext{Seat: "git", Repository: "org/controller"}, seats)
|
||||
if err != nil || got != "http://forge.example.tld:3000/org/controller.git" {
|
||||
t.Fatalf("got %q, %v", got, err)
|
||||
}
|
||||
got, err = contextURL(catalogue.ArtifactContext{Repository: "https://elsewhere.example/x.git"}, seats)
|
||||
if err != nil || got != "https://elsewhere.example/x.git" {
|
||||
t.Fatalf("a URL context was changed: %q, %v", got, err)
|
||||
}
|
||||
_, err = contextURL(catalogue.ArtifactContext{Seat: "git", Repository: "org/controller"}, nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "git seat") {
|
||||
t.Fatalf("a seat with no base was not refused by name: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package builder
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Nothing could compile the mesh's own components, which is why nothing delivers the host
|
||||
// (novox/hq 04-ISSUES/142, and ADR 0141's own insight naming it). The toolchain list was a closed
|
||||
// set of typescript and python, and two things in the path beyond it assumed TypeScript.
|
||||
|
||||
func TestTheMeshCanCompileGo(t *testing.T) {
|
||||
chain, err := ToolchainFor("go")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Named, not pinned: the mesh answers with the copy it holds, so moving compiler is a build
|
||||
// rather than an edit to this source (ADR 0044, 0142).
|
||||
if chain.Base != "mesh-tools-go" || chain.Artifact != "build" {
|
||||
t.Fatalf("the go toolchain is based on %s/%s", chain.Base, chain.Artifact)
|
||||
}
|
||||
joined := strings.Join(chain.Compile, " ")
|
||||
// Static, because what a machine holds is a file and not a container: a binary needing a libc
|
||||
// it did not bring is a delivery that works until a machine differs.
|
||||
if !strings.Contains(joined, "CGO_ENABLED=0") {
|
||||
t.Fatalf("the go toolchain does not build statically: %q", joined)
|
||||
}
|
||||
// Reproducible: a version comes from where a component sits, not from its linker (ADR 0142),
|
||||
// so two builds of one commit should produce the same bytes.
|
||||
if !strings.Contains(joined, "-trimpath") {
|
||||
t.Fatalf("the go toolchain leaves build paths in the binary: %q", joined)
|
||||
}
|
||||
if chain.Unit != UnitPackage {
|
||||
t.Fatalf("the go toolchain compiles %q, wanted a package", chain.Unit)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEveryToolchainSaysWhatItIsPointedAt(t *testing.T) {
|
||||
// The field exists because the compile path used to assume one language. A toolchain that says
|
||||
// nothing would fall through to the entrypoint branch and compile a file list, which for a
|
||||
// compiled language builds a program out of exactly those files and ignores the rest of the
|
||||
// package — a missing symbol rather than a legible refusal.
|
||||
for _, chain := range toolchains {
|
||||
switch chain.Unit {
|
||||
case UnitPackage:
|
||||
case UnitSources:
|
||||
if chain.SourceExt == "" {
|
||||
t.Fatalf("%s compiles a file list and names no source extension", chain.Language)
|
||||
}
|
||||
if !strings.HasPrefix(chain.SourceExt, ".") {
|
||||
t.Fatalf("%s's source extension %q is not an extension", chain.Language, chain.SourceExt)
|
||||
}
|
||||
default:
|
||||
t.Fatalf("%s says it is pointed at %q, which is neither sources nor a package",
|
||||
chain.Language, chain.Unit)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnEntrypointBecomesASourceInItsOwnLanguage(t *testing.T) {
|
||||
// It used to become a `.ts` whatever the language was.
|
||||
out := Out("build")
|
||||
got := sourcesFor([]string{out + "/tools/index.js"}, out, ".ts")
|
||||
if len(got) != 1 || got[0] != "tools/index.ts" {
|
||||
t.Fatalf("a typescript entrypoint became %v", got)
|
||||
}
|
||||
got = sourcesFor([]string{out + "/tools/index.js"}, out, ".py")
|
||||
if len(got) != 1 || got[0] != "tools/index.py" {
|
||||
t.Fatalf("a python entrypoint became %v", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package builder
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A host built without knowing its system refuses every declaration before applying anything —
|
||||
// safely, totally, and with nothing reporting it. The mesh built one, delivered it, started it, and
|
||||
// it would have refused the first thing it was asked to do (novox/hq 04-ISSUES/161).
|
||||
|
||||
func TestTheGoToolchainStampsTheArtifactsSystem(t *testing.T) {
|
||||
chain, err := ToolchainFor("go")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if chain.SystemStamp != "main.builtFor" {
|
||||
t.Fatalf("the go toolchain fills %q", chain.SystemStamp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestALanguageWithNoPinnedSystemStampsNothing(t *testing.T) {
|
||||
// Interpreted output is not pinned to a system, and a manifest declaring one for it is already
|
||||
// refused. Nothing to fill.
|
||||
for _, language := range []string{"typescript", "python"} {
|
||||
chain, err := ToolchainFor(language)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if chain.SystemStamp != "" {
|
||||
t.Fatalf("%s fills %q, and its output is not pinned to a system",
|
||||
language, chain.SystemStamp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheStampIsTheOneThingTakenFromTheArtifact(t *testing.T) {
|
||||
// The toolchain accepts nothing else from the module — anything it could override it would be
|
||||
// writing a Dockerfile to override. The system is the stated exception, because a compiled
|
||||
// binary is per system and the artifact is what declares one (ADR 0142).
|
||||
chain, err := ToolchainFor("go")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
joined := strings.Join(chain.Compile, " ")
|
||||
if strings.Contains(joined, "${") || strings.Contains(joined, "%s") {
|
||||
t.Fatalf("the compile line takes something from the module: %q", joined)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheLinkerIsToldOnceNotTwice(t *testing.T) {
|
||||
// A repeated flag is not a merged one: the Go command takes the last -ldflags and drops the
|
||||
// first. Passing the toolchain's flags and then the stamp separately produced a binary that knew
|
||||
// its system and had lost -s -w — 12.2MB against 8.5MB, with its debug info (04-ISSUES/161).
|
||||
chain, err := ToolchainFor("go")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, arg := range chain.Compile {
|
||||
if arg == "-ldflags" {
|
||||
t.Fatal("the compile line carries -ldflags, so composing one here makes two")
|
||||
}
|
||||
}
|
||||
if len(chain.LinkerFlags) == 0 {
|
||||
t.Fatal("the go toolchain passes no linker flags, so the binary keeps its debug info")
|
||||
}
|
||||
var stripped bool
|
||||
for _, f := range chain.LinkerFlags {
|
||||
if f == "-s" {
|
||||
stripped = true
|
||||
}
|
||||
}
|
||||
if !stripped {
|
||||
t.Fatalf("the go toolchain does not strip: %v", chain.LinkerFlags)
|
||||
}
|
||||
}
|
||||
@@ -35,8 +35,50 @@ type Toolchain struct {
|
||||
Compile []string
|
||||
// OutputFlag is how this compiler is told where to put its output.
|
||||
OutputFlag string
|
||||
// Unit is what this compiler is pointed at: UnitSources, the entrypoint files the module named,
|
||||
// or UnitPackage, the one directory the artifact is built `from`.
|
||||
//
|
||||
// **The difference is the language and not the module.** A TypeScript bundle is a set of files
|
||||
// compiled into a set of files, so what to compile is the module's entrypoints with their source
|
||||
// extension. A Go bundle is a package compiled into one binary, and there is no per-file
|
||||
// compilation to name — pointing `go build` at a file list builds a program out of exactly those
|
||||
// files and ignores the rest of the package, which fails as a missing symbol rather than as a
|
||||
// wrong instruction.
|
||||
Unit string
|
||||
// SourceExt is the extension an entrypoint has in the repository, for UnitSources. An entrypoint
|
||||
// is named as it will be FOUND, inside the unpacked bundle, so the source is the same path with
|
||||
// the output directory taken off the front and this on the end.
|
||||
SourceExt string
|
||||
// LinkerFlags are passed to the linker as one flag, together with the system stamp below.
|
||||
//
|
||||
// **Separate from Compile because a repeated flag is not a merged one.** They were in the compile
|
||||
// line, and appending the stamp as a second `-ldflags` meant the Go command took the last and
|
||||
// dropped the first — so the binary gained its system and lost `-s -w`, growing by half and
|
||||
// carrying its debug info. The mistake was believing a comment rather than reading the file it
|
||||
// produced (novox/hq 04-ISSUES/161).
|
||||
LinkerFlags []string
|
||||
// SystemStamp is the variable this language's linker fills with the artifact's declared system,
|
||||
// for a language whose binaries are pinned to one at link time (novox/hq ADR 0005).
|
||||
//
|
||||
// **The one thing a toolchain takes from the artifact, and 0142 says why**: the target is a
|
||||
// property of the artifact rather than of the recipe, because a compiled binary is per system
|
||||
// and a toolchain that accepted it from the module would be accepting a build instruction. This
|
||||
// is the narrow exception, named here rather than inferred.
|
||||
//
|
||||
// Empty for a language that compiles to nothing pinned. A host built without it refuses every
|
||||
// declaration before applying anything — safely, totally, and with nothing reporting it
|
||||
// (novox/hq 04-ISSUES/161).
|
||||
SystemStamp string
|
||||
}
|
||||
|
||||
// What a toolchain is pointed at.
|
||||
const (
|
||||
// UnitSources is a list of files, derived from the module's entrypoints.
|
||||
UnitSources = "sources"
|
||||
// UnitPackage is the single directory the artifact is built `from`, compiled whole.
|
||||
UnitPackage = "package"
|
||||
)
|
||||
|
||||
// Out is where one artifact's compiled output lands, inside the module's own directory.
|
||||
//
|
||||
// **Per artifact, never per toolchain.** A module is one piece of software and may still be
|
||||
@@ -71,6 +113,41 @@ var toolchains = []Toolchain{
|
||||
"--target", "ES2022",
|
||||
},
|
||||
OutputFlag: "--outDir",
|
||||
Unit: UnitSources,
|
||||
SourceExt: ".ts",
|
||||
},
|
||||
{
|
||||
Language: "go",
|
||||
Base: "mesh-tools-go",
|
||||
Artifact: "build",
|
||||
// **The mesh's own components, and not modules.** The warning above this list — that every
|
||||
// language is another implementation of the contracts modules share, so adding one commits
|
||||
// to keeping N implementations in step — does not attach here. Go is how the host, the
|
||||
// control plane and the builder are written, and none of them is a module in that sense:
|
||||
// the host is what APPLIES modules. So there is no SDK obligation, and the reason this
|
||||
// entry did not exist was that nothing needed to compile the mesh itself
|
||||
// (novox/hq ADR 0142, and 04-ISSUES/142 where that is why nothing delivers the host).
|
||||
//
|
||||
// Static, because what a machine ends up holding is a file rather than a container, and a
|
||||
// binary that needs a libc it did not bring is a delivery that works until a machine
|
||||
// differs. Trimmed of its own paths for the same reason a version comes from where it sits
|
||||
// rather than from the linker: two builds of one commit produce the same bytes.
|
||||
Compile: []string{
|
||||
"env", "CGO_ENABLED=0", "GOFLAGS=-trimpath",
|
||||
"go", "build",
|
||||
},
|
||||
// Stripped of symbols and debug info: what a machine holds is a file it runs, not one it
|
||||
// debugs, and the difference measured 12.2MB against 8.5MB.
|
||||
LinkerFlags: []string{"-s", "-w"},
|
||||
OutputFlag: "-o",
|
||||
// Pointed at the package the artifact is built `from`, compiled whole. Go writes the binary
|
||||
// into the output directory, named after the package — so the bundle a machine unpacks is a
|
||||
// directory holding one executable, which is what the delivery mechanism expects
|
||||
// (novox/hq ADR 0141).
|
||||
Unit: UnitPackage,
|
||||
// The mesh's own Go components read the system they were built for from this variable, and
|
||||
// refuse to touch a machine without one.
|
||||
SystemStamp: "main.builtFor",
|
||||
},
|
||||
{
|
||||
Language: "python",
|
||||
@@ -82,6 +159,8 @@ var toolchains = []Toolchain{
|
||||
// each actually does.
|
||||
Compile: []string{"python", "-m", "pip", "install", "--no-compile", "--target"},
|
||||
OutputFlag: "",
|
||||
Unit: UnitSources,
|
||||
SourceExt: ".py",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
package catalogue
|
||||
|
||||
import "testing"
|
||||
|
||||
// A claim written before the rename still holds (novox/hq ADR 0122, ADR 0156): with the store's
|
||||
// aliases loaded, the former name resolves to the seat.
|
||||
func TestTheArtifactStoresFormerNameResolvesToIt(t *testing.T) {
|
||||
was := aliases
|
||||
t.Cleanup(func() { aliases = was })
|
||||
UseAliases(map[string]string{"the-artifact-store": "mesh-artifact-store"})
|
||||
seat, known := SeatNamed("the-artifact-store")
|
||||
if !known || seat.Name != "mesh-artifact-store" || seat.Delivers != "artifact-store" {
|
||||
t.Fatalf("the former name did not resolve: %+v %v", seat, known)
|
||||
}
|
||||
if _, known := SeatNamed("mesh-artifact-store"); !known {
|
||||
t.Fatal("the seat is not in the set under its name")
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,7 @@ func TestASecondArtifactStoreAnywhereIsRefusedByName(t *testing.T) {
|
||||
}
|
||||
|
||||
// A second one, on any other machine, is refused — and the refusal names the seat.
|
||||
elsewhere := World{Held: []Held{{Claim: "the-artifact-store", Scope: ScopeMesh,
|
||||
elsewhere := World{Held: []Held{{Claim: "mesh-artifact-store", Scope: ScopeMesh,
|
||||
Node: "anchor", Module: "distribution"}}}
|
||||
other := workstation()
|
||||
other.Name = "laptop"
|
||||
@@ -32,7 +32,7 @@ func TestASecondArtifactStoreAnywhereIsRefusedByName(t *testing.T) {
|
||||
t.Fatal("a second store was accepted on another machine; it would offer artifact-store a " +
|
||||
"second time and every consumer elsewhere would refuse to choose")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "the-artifact-store") || !strings.Contains(err.Error(), "one per mesh") {
|
||||
if !strings.Contains(err.Error(), "mesh-artifact-store") || !strings.Contains(err.Error(), "one per mesh") {
|
||||
t.Fatalf("refused without naming the seat: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,6 +70,27 @@ func knownFor(m Manifest, needs []Needed, node string) map[string]map[string]str
|
||||
return out
|
||||
}
|
||||
|
||||
// withOwnNames adds a module's own composed names to what it may name from one binding:
|
||||
// `${bound:<provision>:name}` and `:internal-name`, and for several contributions to one requirement
|
||||
// `:name-<local>` / `:internal-name-<local>`. Set over anything the provider serves under those keys:
|
||||
// what the module is called is the mesh's statement, not the provider's.
|
||||
func withOwnNames(values map[string]string, own map[string]any) {
|
||||
for _, key := range []string{"name", "internal-name"} {
|
||||
if v, ok := own[key].(string); ok {
|
||||
values[key] = v
|
||||
}
|
||||
}
|
||||
many, _ := own["names"].(map[string]any)
|
||||
for local, raw := range many {
|
||||
names, _ := raw.(map[string]any)
|
||||
for _, key := range []string{"name", "internal-name"} {
|
||||
if v, ok := names[key].(string); ok {
|
||||
values[key+"-"+local] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// plainly renders a served value as a program would expect to read it.
|
||||
func plainly(value any) string {
|
||||
switch v := value.(type) {
|
||||
|
||||
@@ -59,7 +59,7 @@ func TestRenamingASeatDidNotRenameTheInterfaceItDelivers(t *testing.T) {
|
||||
for _, pair := range []struct{ seat, delivers string }{
|
||||
{"git", "git"},
|
||||
{"npm-package-registry", "npm-package-registry"},
|
||||
{"the-artifact-store", "artifact-store"},
|
||||
{"mesh-artifact-store", "artifact-store"},
|
||||
{"mesh-store", "postgres-database"},
|
||||
{"mesh-broker", "mesh-bus"},
|
||||
} {
|
||||
|
||||
@@ -94,12 +94,43 @@ func (m Manifest) Resolve(built []Built) (Manifest, error) {
|
||||
m.Module, r["id"], named)
|
||||
case ArtifactImage, ArtifactUpstream:
|
||||
filled["image"] = artifact.Reference
|
||||
// An image is not unpacked anywhere, so it has no directory to be named for its
|
||||
// version and `${version}` has nothing to mean. Refused rather than left as literal
|
||||
// text in a path, which is how it would reach a machine and be created as a directory
|
||||
// called `${version}`.
|
||||
for key, value := range filled {
|
||||
if text, isText := value.(string); isText && strings.Contains(text, versionRef) {
|
||||
return Manifest{}, fmt.Errorf(
|
||||
"%s: %v says %s in %q, and %q is an image — an image is not unpacked, so "+
|
||||
"it has no versioned place. %s is for an archive or a bundle",
|
||||
m.Module, r["id"], versionRef, key, named, versionRef)
|
||||
}
|
||||
}
|
||||
case ArtifactArchive, ArtifactBundle:
|
||||
// The same on the wire: both are bytes fetched by digest and unpacked. They differ in
|
||||
// how they were made — one packed as it stood, the other compiled first — and a
|
||||
// machine has no reason to care which.
|
||||
filled["source"] = artifact.Reference
|
||||
filled["digest"] = artifact.Digest
|
||||
// **And `${version}`, so a resource can name a place that is this build's alone**
|
||||
// (novox/hq ADR 0141, 04-ISSUES/142). A component is unpacked into a directory named
|
||||
// for its version so it can read its own version from its path — and until this,
|
||||
// nothing could compose that path: an archive named a fixed one in the manifest and
|
||||
// nothing interpolated the build into it, so nothing could ask for
|
||||
// `…/versions/<version>/` and every machine took a hand-placed fallback.
|
||||
//
|
||||
// The version is the artifact's own digest, short. Not the commit: two builds of one
|
||||
// commit are meant to be the same bytes (the toolchains are `-trimpath` for this), and
|
||||
// a content-addressed version means an unchanged build resolves to the path it already
|
||||
// had — so re-composing a declaration moves nothing, where a commit would move the
|
||||
// path of an identical binary and recreate everything that reads it.
|
||||
for key, value := range filled {
|
||||
text, isText := value.(string)
|
||||
if !isText || !strings.Contains(text, versionRef) {
|
||||
continue
|
||||
}
|
||||
filled[key] = strings.ReplaceAll(text, versionRef, versionOf(artifact.Digest))
|
||||
}
|
||||
default:
|
||||
return Manifest{}, fmt.Errorf("%s: %q is a %q, and an artifact is %q, %q, %q or %q",
|
||||
m.Module, named, artifact.Kind, ArtifactImage, ArtifactArchive, ArtifactUpstream,
|
||||
@@ -142,7 +173,14 @@ func (b *Build) problems(module string) []string {
|
||||
// is which compiler — because the mesh chooses that, and cannot choose for a module that
|
||||
// has not said.
|
||||
if a.Kind == ArtifactBundle || a.Kind == ArtifactPackage {
|
||||
if a.From != "" {
|
||||
// **Except for a language that compiles to a binary, where it names which one**
|
||||
// (novox/hq 04-ISSUES/142). A bundle in an interpreted language is the module's own
|
||||
// directory compiled whole, and naming a source would be describing its own build. A
|
||||
// repository written in a compiled language holds several commands — the host and its
|
||||
// bootstrap live in one, and the mesh needs the host — and "the module's own directory"
|
||||
// is then not a package at all. So the compiled case may say which package, and says
|
||||
// the module root by saying nothing.
|
||||
if a.From != "" && !compilesToABinary(a.Language) {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s: %q is a bundle and names what it is built from (%q). A bundle is built "+
|
||||
"from the module's own directory; what it says is the language",
|
||||
@@ -252,3 +290,28 @@ func compilesToABinary(language string) bool {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// versionRef is how a resource names the version of the artifact it uses: ${version}.
|
||||
//
|
||||
// No artifact name in it, because the resource already says which artifact it is for — a second
|
||||
// name would be a second thing to keep in step with the first.
|
||||
const versionRef = "${version}"
|
||||
|
||||
// versionOf is an artifact's version as a path names it: its digest, short.
|
||||
//
|
||||
// **Content-addressed on purpose.** The alternative is the commit a build came from, and two builds
|
||||
// of one commit are meant to produce the same bytes — every toolchain here is `-trimpath` for that
|
||||
// reason. A commit-named path would move for an identical binary, and everything reading that path
|
||||
// would be recreated for a change that is not one. A digest-named path moves exactly when the bytes
|
||||
// do.
|
||||
//
|
||||
// Twelve hex characters: enough that two of this mesh's builds will not collide, short enough to
|
||||
// read in a path and in a journal line. The `sha256:` prefix goes, because a directory name carrying
|
||||
// a colon is a directory name people quote wrong.
|
||||
func versionOf(digest string) string {
|
||||
hex := strings.TrimPrefix(strings.TrimSpace(digest), "sha256:")
|
||||
if len(hex) > 12 {
|
||||
return hex[:12]
|
||||
}
|
||||
return hex
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package catalogue
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -45,3 +46,33 @@ func TestEveryCatalogueManifestParses(t *testing.T) {
|
||||
t.Fatal("no endpoint in the catalogue is named, so this proved nothing")
|
||||
}
|
||||
}
|
||||
|
||||
// TestNoCatalogueManifestNamesAnInstallation is ADR 0112's check, run over the real catalogue: no
|
||||
// definition names a domain or a public address the mesh acts on, and every value that must for now
|
||||
// carries its reason (novox/hq ADR 0155, issue 134). The list it prints is the one that shrinks.
|
||||
func TestNoCatalogueManifestNamesAnInstallation(t *testing.T) {
|
||||
root := os.Getenv("MESH_CATALOGUE")
|
||||
if root == "" {
|
||||
t.Skip("set MESH_CATALOGUE to a catalogue checkout to run this")
|
||||
}
|
||||
found, err := filepath.Glob(filepath.Join(root, "modules", "*", "module.json"))
|
||||
if err != nil || len(found) == 0 {
|
||||
t.Fatalf("no manifests under %s: %v", root, err)
|
||||
}
|
||||
var named []string
|
||||
for _, p := range found {
|
||||
raw, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", p, err)
|
||||
}
|
||||
m, err := ParseManifest(raw)
|
||||
if err != nil {
|
||||
t.Errorf("%s: %v", p, err)
|
||||
continue
|
||||
}
|
||||
named = append(named, InstallationProblems(m)...)
|
||||
}
|
||||
if len(named) > 0 {
|
||||
t.Fatalf("%d value(s) name an installation:\n %s", len(named), strings.Join(named, "\n "))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// **A machine trusts the mesh's authority because a module put its root there** (novox/hq ADR
|
||||
// 0147, issue 129). The module carries a shell script and a unit, and both are worthless unless
|
||||
// the mesh fills in where the authority is — which is the one thing about it the manifest cannot
|
||||
// state, because the authority's address is a fact about the mesh and not about the module.
|
||||
//
|
||||
// So what is checked here is the rendering, not the parsing: the script the machine will run
|
||||
// names the authority it was bound to, and the unit runs that script both ways. The verification
|
||||
// itself — a plain client trusting an internal name on a machine holding this, and failing on one
|
||||
// that does not — is the lab's, and cannot be had here.
|
||||
func TestCaTrustRendersTheAuthorityItWasBoundTo(t *testing.T) {
|
||||
raw, err := os.ReadFile("../../../mesh-catalog/modules/ca-trust/module.json")
|
||||
if err != nil {
|
||||
t.Skipf("the catalogue is not beside this checkout: %v", err)
|
||||
}
|
||||
m, err := ParseManifest(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("the trust module does not parse:\n%v", err)
|
||||
}
|
||||
|
||||
r := Resolution{
|
||||
Node: "workstation",
|
||||
Modules: []Manifest{m},
|
||||
Needs: []Needed{{
|
||||
Name: "internal-acme-ca", From: "anchor", At: "anchor.internal", For: "ca-trust",
|
||||
Serves: map[string]any{
|
||||
"port": float64(9000), "path": "/acme/acme/directory", "roots": "/roots.pem",
|
||||
},
|
||||
}},
|
||||
}
|
||||
out, err := r.Declaration(Rendering{})
|
||||
if err != nil {
|
||||
t.Fatalf("the trust module could not be composed for a machine: %v", err)
|
||||
}
|
||||
|
||||
script := fileNamed(out, "ca-trust.anchor")
|
||||
if script == nil {
|
||||
t.Fatalf("nothing writes the script the unit runs: %v", out)
|
||||
}
|
||||
body, _ := script["content"].(string)
|
||||
if !strings.Contains(body, "https://anchor.internal:9000/roots.pem") {
|
||||
t.Errorf("the script does not fetch from the authority it was bound to:\n%s", body)
|
||||
}
|
||||
if script["mode"] != "0755" {
|
||||
t.Errorf("the script is written %v, which systemd cannot execute", script["mode"])
|
||||
}
|
||||
|
||||
unit := fileNamed(out, "ca-trust.unit")
|
||||
if unit == nil {
|
||||
t.Fatalf("no unit: %v", out)
|
||||
}
|
||||
text, _ := unit["content"].(string)
|
||||
// Both halves. A unit that only installs the anchor leaves a machine trusting an authority
|
||||
// nobody assigned it to any more, which is the half issue 129 asked for by name.
|
||||
for _, want := range []string{
|
||||
"ExecStart=" + script["path"].(string) + " install",
|
||||
"ExecStop=" + script["path"].(string) + " remove",
|
||||
"RemainAfterExit=yes",
|
||||
} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Errorf("the unit does not say %q:\n%s", want, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -574,7 +574,11 @@ func (r Resolution) compose(with Rendering, owner map[string]string) ([]map[stri
|
||||
}
|
||||
found = here
|
||||
}
|
||||
file, err := boundFile(*found, m.Binds[to], ConsumerIdentity(r.Node, IdentitySource(m.Slug, m.Module)))
|
||||
own, err := r.ownNames(m, to, with.Settings[m.Module])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
file, err := boundFile(*found, m.Binds[to], ConsumerIdentity(r.Node, IdentitySource(m.Slug, m.Module)), own)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -618,17 +622,15 @@ func (r Resolution) compose(with Rendering, owner map[string]string) ([]map[stri
|
||||
// merging earlier would throw away the files it still needs.
|
||||
resources = append(append([]map[string]any{}, first...), resources...)
|
||||
|
||||
// Every container is given the mesh's names. Not a choice a module makes: a module that
|
||||
// listed them would go stale the day a machine joins, and one that did not would be a
|
||||
// module whose containers cannot reach anything by name.
|
||||
//
|
||||
// A container that was given names of its own keeps them and gets the mesh's beside them:
|
||||
// the mesh does not know what else a workload needs to reach, and taking something away
|
||||
// to add something is not what "also" means.
|
||||
if len(with.Names) > 0 {
|
||||
resources = withMeshNames(resources, with.Names)
|
||||
}
|
||||
|
||||
// No container is given the mesh's names (novox/hq ADR 0148). It used to be: every
|
||||
// container got the whole roster as `--add-host` entries at creation, and a name that
|
||||
// moved afterwards was wrong inside it for as long as it ran (issues 109, 135) — and once
|
||||
// the roster was made part of a container's identity so that could be caught, one name
|
||||
// moving anywhere replaced every container in the mesh (issue 151). A container resolves a
|
||||
// mesh name through its machine's resolver at the moment it asks, which the runtime is
|
||||
// told once per machine, as a file, by the resolver's own module. The names a module
|
||||
// declares for itself are its own and stay exactly as written: they are part of what the
|
||||
// module is, and the mesh does not know what they mean.
|
||||
// What this module may name from inside one of its own files. Gathered once per module
|
||||
// rather than per file, because it is a fact about the module.
|
||||
sealed, err := sealedFor(m, r.Needs, with)
|
||||
@@ -637,6 +639,35 @@ func (r Resolution) compose(with Rendering, owner map[string]string) ([]map[stri
|
||||
}
|
||||
// And what its bindings say, for the half of a connection that is not secret.
|
||||
known := knownFor(m, r.Needs, r.Node)
|
||||
// A requirement answered on this same machine is not in r.Needs — its binding file is
|
||||
// written from `here` (above) — and so `${bound:…}` could not name it, though the file
|
||||
// beside it said the same facts. Filled from the same answer, so the two cannot disagree.
|
||||
for _, want := range m.Wants() {
|
||||
if _, has := known[want]; has {
|
||||
continue
|
||||
}
|
||||
answered, err := here(r, want, with)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if answered == nil {
|
||||
continue
|
||||
}
|
||||
local := *answered
|
||||
local.For = m.Module
|
||||
for provision, values := range knownFor(m, []Needed{local}, r.Node) {
|
||||
known[provision] = values
|
||||
}
|
||||
}
|
||||
// And what the module is called through each requirement it contributes to (novox/hq
|
||||
// 04-ISSUES/122) — the same composition its binding file carries.
|
||||
for provision, values := range known {
|
||||
own, err := r.ownNames(m, provision, with.Settings[m.Module])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
withOwnNames(values, own)
|
||||
}
|
||||
// And where this node places the directories the module declared without a path
|
||||
// (novox/hq ADR 0112) — resolved once per module, named by ${dir:…} from any resource.
|
||||
dirs := dirsFor(m, with)
|
||||
@@ -665,6 +696,13 @@ func (r Resolution) compose(with Rendering, owner map[string]string) ([]map[stri
|
||||
// Said in the catalogue, not on the machine: the host parses strictly and knows no
|
||||
// such field, and the reason is for a reader of the manifest.
|
||||
delete(copied, SecretsInEnvironment)
|
||||
delete(copied, NamesOnPurpose)
|
||||
// **An operator's value, from the assignment** (novox/hq ADR 0112, ADR 0155): what a
|
||||
// definition may not carry because it is true of one installation only. Filled from
|
||||
// the same layers a mergeable file takes, and refused when no layer set it.
|
||||
if err := settingInto(copied, with.Settings[m.Module], m.Module); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// **Placed before anything reads a path.** A pathless directory receives the path
|
||||
// this node resolves for it, and every ${dir:…} — in paths, mounts, content and
|
||||
// environment — becomes that path, so what follows sees only concrete places
|
||||
@@ -1089,21 +1127,11 @@ func (r Resolution) contributions(settings SettingsBy, grants []Grant,
|
||||
// Settings reach a contribution the same way they reach a file. A route's hostname is
|
||||
// exactly the kind of thing that differs between one mesh and the next, and a module
|
||||
// that could not have it set would have to be edited to be reused.
|
||||
values, err := settle(m.Contributes[to], settings[m.Module], nil,
|
||||
values, err := r.composed(m, to, m.Contributes[to], settings[m.Module],
|
||||
m.Module+" contributing to "+to)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s contributing to %s: %w", m.Module, to, err)
|
||||
return nil, err
|
||||
}
|
||||
reaches, err := Reaches(m, settings[m.Module])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s contributing to %s: %w", m.Module, to, err)
|
||||
}
|
||||
blocks, err := Endpoints(m, settings[m.Module])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s contributing to %s: %w", m.Module, to, err)
|
||||
}
|
||||
portOfEndpoint(values, endpointPorts(m))
|
||||
composeName(values, r.PublicDomain, r.At, reaches, endpointPorts(m), blocks)
|
||||
out[to] = append(out[to], Contribution{From: m.Module, Values: values})
|
||||
}
|
||||
// Several contributions to one requirement (ADR 0094's sibling for `contributes`): an
|
||||
@@ -1112,21 +1140,11 @@ func (r Resolution) contributions(settings SettingsBy, grants []Grant,
|
||||
// name always reaches the provider from here.
|
||||
for _, to := range sortedKeys(m.ContributesMany) {
|
||||
for _, local := range sortedKeys(m.ContributesMany[to]) {
|
||||
values, err := settle(m.ContributesMany[to][local], settings[m.Module], nil,
|
||||
values, err := r.composed(m, to, m.ContributesMany[to][local], settings[m.Module],
|
||||
m.Module+" contributing "+local+" to "+to)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s contributing %s to %s: %w", m.Module, local, to, err)
|
||||
return nil, err
|
||||
}
|
||||
reaches, err := Reaches(m, settings[m.Module])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s contributing %s to %s: %w", m.Module, local, to, err)
|
||||
}
|
||||
blocks, err := Endpoints(m, settings[m.Module])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s contributing %s to %s: %w", m.Module, local, to, err)
|
||||
}
|
||||
portOfEndpoint(values, endpointPorts(m))
|
||||
composeName(values, r.PublicDomain, r.At, reaches, endpointPorts(m), blocks)
|
||||
out[to] = append(out[to], Contribution{From: m.Module, Values: values})
|
||||
}
|
||||
}
|
||||
@@ -1134,6 +1152,82 @@ func (r Resolution) contributions(settings SettingsBy, grants []Grant,
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// composed is one contribution as its provider receives it: settled with this node's settings, its
|
||||
// endpoint's port filled in, and its names composed from the label.
|
||||
//
|
||||
// **One function, because two readers must agree.** The provider is told the names in its received
|
||||
// file; the contributing module is told the same names in its own binding (novox/hq 04-ISSUES/122).
|
||||
// Composing them twice, in two places, is how the proxy would come to serve one name while the
|
||||
// module wrote another into its configuration.
|
||||
func (r Resolution) composed(m Manifest, to string, raw map[string]any, layers []Layer, what string) (
|
||||
map[string]any, error) {
|
||||
values, err := settle(raw, layers, nil, what)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", what, err)
|
||||
}
|
||||
reaches, err := Reaches(m, layers)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", what, err)
|
||||
}
|
||||
blocks, err := Endpoints(m, layers)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", what, err)
|
||||
}
|
||||
portOfEndpoint(values, endpointPorts(m))
|
||||
composeName(values, r.PublicDomain, servingAt(r, to), reaches, endpointPorts(m), blocks)
|
||||
return values, nil
|
||||
}
|
||||
|
||||
// ownNames is what a module is known by through what it contributes to one requirement — the names
|
||||
// the mesh composed for it, and nothing else of the contribution.
|
||||
//
|
||||
// **The half a module could not learn** (novox/hq 04-ISSUES/122). A module contributes a label, the
|
||||
// mesh joins it with this node's domains, and the provider serves the result — and the module itself
|
||||
// was never told. Software that must know its own address (a login redirect, a canonical URL, an
|
||||
// issuer) had it written into the manifest as a literal, which is a domain in a definition and wrong
|
||||
// on every other machine. `${bound:<requirement>:name}` is the answer, from the same composition the
|
||||
// provider receives.
|
||||
//
|
||||
// Several contributions to one requirement are keyed by their local name under `names`.
|
||||
func (r Resolution) ownNames(m Manifest, to string, layers []Layer) (map[string]any, error) {
|
||||
pick := func(values map[string]any) map[string]any {
|
||||
names := map[string]any{}
|
||||
for _, key := range []string{"name", "internal-name"} {
|
||||
if v, ok := values[key].(string); ok && v != "" {
|
||||
names[key] = v
|
||||
}
|
||||
}
|
||||
return names
|
||||
}
|
||||
out := map[string]any{}
|
||||
if raw, ok := m.Contributes[to]; ok {
|
||||
values, err := r.composed(m, to, raw, layers, m.Module+" contributing to "+to)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for k, v := range pick(values) {
|
||||
out[k] = v
|
||||
}
|
||||
}
|
||||
if locals := m.ContributesMany[to]; len(locals) > 0 {
|
||||
many := map[string]any{}
|
||||
for _, local := range sortedKeys(locals) {
|
||||
values, err := r.composed(m, to, locals[local], layers,
|
||||
m.Module+" contributing "+local+" to "+to)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if names := pick(values); len(names) > 0 {
|
||||
many[local] = names
|
||||
}
|
||||
}
|
||||
if len(many) > 0 {
|
||||
out["names"] = many
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// composeName joins a contribution's label with a node's public domain, and separately with its
|
||||
// private one, in place (novox/hq ADR 0056).
|
||||
//
|
||||
@@ -1226,6 +1320,24 @@ func composeName(values map[string]any, publicDomain, internalDomain string, rea
|
||||
}
|
||||
}
|
||||
|
||||
// servingAt is the private-network name of the node a contribution to `to` arrives at: the
|
||||
// provider's, when the provision is answered elsewhere, and this machine's own when it is answered
|
||||
// here or not yet settled.
|
||||
//
|
||||
// A route's internal name is composed under it (novox/hq ADR 0151, issue 139). `<label>.<node>.internal`
|
||||
// is answered by every machine's resolver as *anything under that node's name goes to that node* —
|
||||
// so the node in the name has to be the one whose proxy answers, or the name sends a client to a
|
||||
// machine with nothing listening while the public name, published at the serving node's address,
|
||||
// works. Where the proxy runs beside the module the two are the same machine and nothing changes.
|
||||
func servingAt(r Resolution, to string) string {
|
||||
for _, n := range r.Needs {
|
||||
if n.Name == to && n.At != "" {
|
||||
return n.At
|
||||
}
|
||||
}
|
||||
return r.At
|
||||
}
|
||||
|
||||
// receivedFile is the file a provider is given its consumers' contributions in.
|
||||
func receivedFile(requirement, path string, given []Contribution) (map[string]any, error) {
|
||||
if given == nil {
|
||||
@@ -1330,14 +1442,14 @@ func sortedKeys[V any](m map[string]V) []string {
|
||||
// Where it is and what the providing module said about using it. **No credential**, and the file
|
||||
// says so rather than leaving a reader to wonder whether one was meant to be there — a missing
|
||||
// field looks like a bug, and a stated absence looks like a boundary.
|
||||
func boundFile(n Needed, path, as string) (map[string]any, error) {
|
||||
func boundFile(n Needed, path, as string, own map[string]any) (map[string]any, error) {
|
||||
// A record has no machine and no address. Saying so is the difference between a reader
|
||||
// concluding "somewhere with no address" and concluding the mesh failed to fill something in.
|
||||
where := any(n.At)
|
||||
if n.ByRecord {
|
||||
where = "a record in this mesh, not a machine"
|
||||
}
|
||||
body, err := json.MarshalIndent(map[string]any{
|
||||
doc := map[string]any{
|
||||
"binding": 1,
|
||||
"provision": n.Name,
|
||||
"from": n.From,
|
||||
@@ -1353,7 +1465,14 @@ func boundFile(n Needed, path, as string) (map[string]any, error) {
|
||||
"generated": "by the mesh — do not edit; replaced whenever this changes. " +
|
||||
"The credential is not here: it is sealed, in the file this module's manifest " +
|
||||
"names under `secrets`",
|
||||
}, "", " ")
|
||||
}
|
||||
// **What this module is called through what it contributes here** (novox/hq 04-ISSUES/122):
|
||||
// `name`, `internal-name`, or `names` by local name — composed exactly as the provider receives
|
||||
// them. Absent when the module contributes nothing named, rather than written empty.
|
||||
for key, value := range own {
|
||||
doc[key] = value
|
||||
}
|
||||
body, err := json.MarshalIndent(doc, "", " ")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -1482,43 +1601,6 @@ func (r Resolution) servedOnThisMachine(provision string, with Rendering) (map[s
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
// withMeshNames gives every container in a set the mesh's names.
|
||||
//
|
||||
// Copied rather than edited in place: these maps come from a module's manifest, and mutating one
|
||||
// would change what the catalogue holds for every other machine running that module.
|
||||
//
|
||||
// A host-network container gets the names too. It was once skipped, on the belief that it "shares
|
||||
// the machine's hosts file already" — but it does not: `docker run --network host` still gives the
|
||||
// container its own /etc/hosts (localhost and its own id only), so every `<node>.internal` name the
|
||||
// mesh wrote for the machine is invisible inside it, and a client that dials one gets EAI_AGAIN. The
|
||||
// remedy is the same `--add-host` every other container gets — the runtime accepts it with
|
||||
// `--network host` (verified), and without it a host-network consumer cannot reach a provider by the
|
||||
// `.internal` address the mesh hands it as `${bound:...:at}`.
|
||||
func withMeshNames(resources []map[string]any, names map[string]string) []map[string]any {
|
||||
out := make([]map[string]any, 0, len(resources))
|
||||
for _, r := range resources {
|
||||
if r["type"] != "container" {
|
||||
out = append(out, r)
|
||||
continue
|
||||
}
|
||||
|
||||
copied := map[string]any{}
|
||||
for k, v := range r {
|
||||
copied[k] = v
|
||||
}
|
||||
var given []any
|
||||
if already, ok := copied["hosts"].([]any); ok {
|
||||
given = append(given, already...)
|
||||
}
|
||||
for _, name := range sortedKeys(names) {
|
||||
given = append(given, name+":"+names[name])
|
||||
}
|
||||
copied["hosts"] = given
|
||||
out = append(out, copied)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// pinned refuses an image that is not really pinned, on its way to a machine.
|
||||
//
|
||||
// **Here and not at parse** (novox/hq 04-ISSUES/025). A manifest in a repository names artifacts
|
||||
@@ -1597,14 +1679,23 @@ func publishedOn(resource map[string]any, module string, with Rendering) {
|
||||
out = append(out, givenOuter(written, with.Given[module]))
|
||||
continue
|
||||
}
|
||||
wanted, err := strconv.Atoi(strings.TrimSpace(written))
|
||||
// A short form may carry the protocol — `"3478/udp"` — and the number is what the mesh
|
||||
// assigns for; the protocol rides along. Read as one token, the `/udp` made the whole
|
||||
// entry "not a port", and passing it through let the runtime publish it wherever it
|
||||
// liked: unifi's STUN and discovery landed on random machine ports while every TCP pin
|
||||
// beside them held.
|
||||
mapping, protocol := written, ""
|
||||
if cut := strings.LastIndex(written, "/"); cut >= 0 {
|
||||
mapping, protocol = written[:cut], written[cut:]
|
||||
}
|
||||
wanted, err := strconv.Atoi(strings.TrimSpace(mapping))
|
||||
if err != nil {
|
||||
// Not a port at all. Passed through, so the host refuses it with its own words rather
|
||||
// than this quietly dropping something somebody meant.
|
||||
out = append(out, written)
|
||||
continue
|
||||
}
|
||||
out = append(out, fmt.Sprintf("%d:%d", with.machinePort(module, wanted), wanted))
|
||||
out = append(out, fmt.Sprintf("%d:%d%s", with.machinePort(module, wanted), wanted, protocol))
|
||||
}
|
||||
resource["ports"] = out
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package catalogue
|
||||
|
||||
import "testing"
|
||||
|
||||
// A bundle is the module's own directory compiled whole, and naming a source would be describing its
|
||||
// own build. That holds for an interpreted language and cannot hold for a compiled one: a repository
|
||||
// written in Go carries several commands — the host and its bootstrap live in one — and "the module's
|
||||
// own directory" is then not a package at all (novox/hq 04-ISSUES/142).
|
||||
|
||||
func TestAGoBundleMayNameItsCommand(t *testing.T) {
|
||||
b := &Build{Artifacts: []Artifact{{
|
||||
Name: "host-arch", Kind: ArtifactBundle, Language: "go", System: "arch",
|
||||
From: "cmd/mesh-host",
|
||||
}}}
|
||||
if p := b.problems("mesh-host"); len(p) != 0 {
|
||||
t.Fatalf("a go bundle naming its command was refused: %v", p)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnInterpretedBundleStillMayNotNameASource(t *testing.T) {
|
||||
b := &Build{Artifacts: []Artifact{{
|
||||
Name: "tools", Kind: ArtifactBundle, Language: "typescript", From: "src",
|
||||
}}}
|
||||
p := b.problems("something")
|
||||
if len(p) == 0 {
|
||||
t.Fatal("an interpreted bundle naming what it is built from was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestACompiledBundleStillMustSayItsSystem(t *testing.T) {
|
||||
b := &Build{Artifacts: []Artifact{{
|
||||
Name: "host", Kind: ArtifactBundle, Language: "go", From: "cmd/mesh-host",
|
||||
}}}
|
||||
if len(b.problems("mesh-host")) == 0 {
|
||||
t.Fatal("a compiled bundle with no system was accepted")
|
||||
}
|
||||
}
|
||||
@@ -158,3 +158,16 @@ func TestAStoreRowWithoutAProtocolKeepsTheCompiledOne(t *testing.T) {
|
||||
t.Fatalf("the store's own columns were not kept: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestARefusalWithNothingOnRecordNamesTheHandover(t *testing.T) {
|
||||
busSeatDelivering(t, "mesh-bus")
|
||||
elsewhere := []Held{{Claim: "mesh-broker", Scope: ScopeMesh, Node: "anchor", Module: "old-broker"}}
|
||||
|
||||
_, problems := checkClaims([]Manifest{newBroker()}, Node{Name: "laptop"}, elsewhere, nil)
|
||||
if len(problems) != 1 {
|
||||
t.Fatalf("two derived claimants across machines were not refused: %v", problems)
|
||||
}
|
||||
if !strings.Contains(problems[0], "`seat mesh-broker --to anchor/old-broker`") {
|
||||
t.Fatalf("the refusal does not name the handover that records the holder: %s", problems[0])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// A definition names no installation (novox/hq ADR 0112, ADR 0155, issues 122 and 134).
|
||||
//
|
||||
// A module definition holds what is true of the module everywhere; what is particular to one mesh —
|
||||
// a public name, a forge's address, a node's public address — is resolved at assignment. The rule
|
||||
// stood for a month with nothing checking it, and a sweep found thirty of seventy-one definitions
|
||||
// naming the installation they were written in. This is the check.
|
||||
//
|
||||
// **What is judged is what the mesh acts on, not what a person reads.** A domain in a `why` or a
|
||||
// `description` is documentation the mesh never reads; reporting it beside `KC_HOSTNAME` would teach
|
||||
// people to ignore the report. What is judged is every other string value: a name under a public
|
||||
// top-level domain, or a public address. Two families of name are the world's and not this mesh's,
|
||||
// and are allowed where they can only mean the world: the public registries an `image` may be pulled
|
||||
// from, and the public resolvers a machine may forward to. The container runtime's own alias for
|
||||
// its host is the runtime's, true on every machine that runs it.
|
||||
//
|
||||
// **A name that is right where it stands is declared, one by one, with its reason.** A federated
|
||||
// server's config names the federation's public directory; an application built outside the mesh
|
||||
// is pulled from the registry that built it, until the mesh builds it. The resource carries
|
||||
// `names-on-purpose`, a map from each such name to why — the shape `secrets-in-environment` has,
|
||||
// per name — so a reader sees which names a definition means to carry and why, a name the map does
|
||||
// not cover is still reported, and the catalogue-wide test is the list that shrinks as names move.
|
||||
|
||||
// NamesOnPurpose is the catalogue-level word a resource carries for the names it means to name:
|
||||
// each name mapped to its reason. The host never sees it.
|
||||
const NamesOnPurpose = "names-on-purpose"
|
||||
|
||||
// prose is every key whose value the mesh never reads.
|
||||
var prose = map[string]bool{"why": true, "description": true}
|
||||
|
||||
// Registries the world runs, which an image may name because an image reference must say where it
|
||||
// is pulled from. Anything else in an image reference is a registry of some installation.
|
||||
var worldsRegistries = map[string]bool{
|
||||
"docker.io": true, "registry-1.docker.io": true, "index.docker.io": true, "ghcr.io": true,
|
||||
"quay.io": true, "gcr.io": true, "registry.k8s.io": true, "k8s.gcr.io": true,
|
||||
"mcr.microsoft.com": true, "lscr.io": true, "public.ecr.aws": true, "registry.gitlab.com": true,
|
||||
"codeberg.org": true, "cgr.dev": true,
|
||||
}
|
||||
|
||||
// Services the world runs that a definition may name as a policy default, the way it may name a
|
||||
// public resolver: the public certificate authorities' ACME directories. Anything else a served
|
||||
// fact or a file names is somebody's installation.
|
||||
var worldsServices = map[string]bool{
|
||||
"acme-v02.api.letsencrypt.org": true, "acme-staging-v02.api.letsencrypt.org": true,
|
||||
"api.buypass.com": true, "api.test4.buypass.no": true, "dv.acme-v02.api.pki.goog": true,
|
||||
"acme.zerossl.com": true,
|
||||
}
|
||||
|
||||
// Resolvers the world runs, which a machine's resolver may forward to as a policy default.
|
||||
var worldsResolvers = map[string]bool{
|
||||
"1.1.1.1": true, "1.0.0.1": true, "8.8.8.8": true, "8.8.4.4": true, "9.9.9.9": true,
|
||||
"149.112.112.112": true, "208.67.222.222": true, "208.67.220.220": true,
|
||||
}
|
||||
|
||||
// hostname is a dotted name whose last label is a top-level domain a real installation would have.
|
||||
// Not every dotted token: `module.json`, `index.html` and `docker.sock` are dotted and name nothing.
|
||||
// Boundaries are checked by hand rather than in the pattern, because two names one character apart
|
||||
// — `a.example.tld,b.example.tld` — would otherwise share the delimiter and the second would be lost.
|
||||
var hostname = regexp.MustCompile(
|
||||
`(?i)(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+` +
|
||||
`(?:be|nl|de|fr|uk|eu|com|net|org|io|dev|app|cloud|site|online|me|co|ch|at|lu|` +
|
||||
`internal|example|tld|test|invalid)`)
|
||||
|
||||
// address is a dotted quad.
|
||||
var address = regexp.MustCompile(`(?:[0-9]{1,3}\.){3}[0-9]{1,3}`)
|
||||
|
||||
// isName is whether a byte may be part of a name; a match bordered by one is a longer token.
|
||||
func isName(b byte) bool {
|
||||
return b == '.' || b == '-' || (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || (b >= '0' && b <= '9')
|
||||
}
|
||||
|
||||
// standalone are the matches of re in value that are whole tokens, not parts of a longer one.
|
||||
func standalone(re *regexp.Regexp, value string) []string {
|
||||
var out []string
|
||||
for _, span := range re.FindAllStringIndex(value, -1) {
|
||||
if span[0] > 0 && isName(value[span[0]-1]) {
|
||||
continue
|
||||
}
|
||||
if span[1] < len(value) && isName(value[span[1]]) {
|
||||
continue
|
||||
}
|
||||
out = append(out, value[span[0]:span[1]])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// InstallationProblems is every value of a definition that names an installation, in the
|
||||
// definition's own words: where it is, and what it names.
|
||||
func InstallationProblems(m Manifest) []string {
|
||||
raw, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
return []string{fmt.Sprintf("%s could not be read back: %v", m.Module, err)}
|
||||
}
|
||||
var tree any
|
||||
if err := json.Unmarshal(raw, &tree); err != nil {
|
||||
return []string{fmt.Sprintf("%s could not be read back: %v", m.Module, err)}
|
||||
}
|
||||
var problems []string
|
||||
// The module's own name is a value too: a module named after the domain it serves is a
|
||||
// definition that can only be installed there (issue 134).
|
||||
for _, name := range namesIn(m.Module) {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s is named after %s, and a module is named for what it is, not for where it runs", m.Module, name))
|
||||
}
|
||||
walk(tree, "", nil, func(at string, value string, meant map[string]bool, isImage bool) {
|
||||
for _, name := range namesIn(value) {
|
||||
if (isImage && worldsRegistries[strings.ToLower(name)]) || meant[name] {
|
||||
continue
|
||||
}
|
||||
problems = append(problems, fmt.Sprintf("%s names %s at %s", m.Module, name, at))
|
||||
}
|
||||
for _, ip := range addressesIn(value) {
|
||||
if meant[ip] {
|
||||
continue
|
||||
}
|
||||
problems = append(problems, fmt.Sprintf("%s names the public address %s at %s", m.Module, ip, at))
|
||||
}
|
||||
})
|
||||
sort.Strings(problems)
|
||||
return problems
|
||||
}
|
||||
|
||||
// walk visits every string in the tree with its path, the names the enclosing resource means to
|
||||
// name (with a reason), and whether it is an image reference.
|
||||
func walk(node any, at string, meant map[string]bool, visit func(at, value string, meant map[string]bool, isImage bool)) {
|
||||
switch v := node.(type) {
|
||||
case map[string]any:
|
||||
if declared, has := v[NamesOnPurpose].(map[string]any); has {
|
||||
widened := map[string]bool{}
|
||||
for name := range meant {
|
||||
widened[name] = true
|
||||
}
|
||||
for name, reason := range declared {
|
||||
if r, ok := reason.(string); ok && strings.TrimSpace(r) != "" {
|
||||
widened[strings.ToLower(name)] = true
|
||||
}
|
||||
}
|
||||
meant = widened
|
||||
}
|
||||
keys := make([]string, 0, len(v))
|
||||
for k := range v {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, k := range keys {
|
||||
if prose[k] || k == NamesOnPurpose || (at == "" && k == "module") {
|
||||
continue
|
||||
}
|
||||
child := at + "." + k
|
||||
if at == "" {
|
||||
child = k
|
||||
}
|
||||
if s, isString := v[k].(string); isString {
|
||||
visit(child, s, meant, k == "image")
|
||||
continue
|
||||
}
|
||||
walk(v[k], child, meant, visit)
|
||||
}
|
||||
case []any:
|
||||
for i, item := range v {
|
||||
child := fmt.Sprintf("%s[%d]", at, i)
|
||||
if s, isString := item.(string); isString {
|
||||
visit(child, s, meant, false)
|
||||
continue
|
||||
}
|
||||
walk(item, child, meant, visit)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// namesIn is every hostname in a value that could belong to an installation.
|
||||
func namesIn(value string) []string {
|
||||
var out []string
|
||||
for _, found := range standalone(hostname, value) {
|
||||
name := strings.ToLower(found)
|
||||
switch {
|
||||
case strings.HasSuffix(name, ".docker.internal"):
|
||||
// The container runtime's alias for its own host: every machine running it has one.
|
||||
case worldsServices[name]:
|
||||
// A public authority named as a policy default, true of any mesh that wants it.
|
||||
case name == "example.tld", strings.HasSuffix(name, ".example.tld"),
|
||||
name == "example.com", name == "example.net", name == "example.org",
|
||||
strings.HasSuffix(name, ".example.com"), strings.HasSuffix(name, ".example.net"),
|
||||
strings.HasSuffix(name, ".example.org"), strings.HasSuffix(name, ".example"),
|
||||
strings.HasSuffix(name, ".test"), strings.HasSuffix(name, ".invalid"):
|
||||
// Documentation names, which is what a definition's own example should use.
|
||||
default:
|
||||
out = append(out, name)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// addressesIn is every public address in a value: not a private range, loopback, link-local, the
|
||||
// unspecified address, a documentation range, or a resolver the world runs.
|
||||
func addressesIn(value string) []string {
|
||||
var out []string
|
||||
for _, found := range standalone(address, value) {
|
||||
ip := net.ParseIP(found)
|
||||
if ip == nil || ip.IsPrivate() || ip.IsLoopback() || ip.IsLinkLocalUnicast() ||
|
||||
ip.IsUnspecified() || ip.IsMulticast() || worldsResolvers[found] || documentation(ip) {
|
||||
continue
|
||||
}
|
||||
out = append(out, found)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func documentation(ip net.IP) bool {
|
||||
for _, cidr := range []string{"192.0.2.0/24", "198.51.100.0/24", "203.0.113.0/24", "100.64.0.0/10"} {
|
||||
_, block, _ := net.ParseCIDR(cidr)
|
||||
if block.Contains(ip) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A definition names no installation (novox/hq ADR 0112, ADR 0155). What the mesh acts on is judged;
|
||||
// prose is not; the world's registries and resolvers are the world's; a declared exception is a
|
||||
// reason a reader sees.
|
||||
func TestADefinitionNamingAnInstallationIsNamedBack(t *testing.T) {
|
||||
m := Manifest{Module: "idp", Resources: []map[string]any{
|
||||
{"id": "server", "type": "container", "image": "quay.io/keycloak/keycloak@sha256:aa",
|
||||
"env": map[string]any{"KC_HOSTNAME": "https://login.mesh-one.be"}},
|
||||
{"id": "env", "type": "file", "content": "REAL_IP_FROM=192.168.1.0/24,127.0.0.0/8,203.0.113.7,51.15.22.9\n"},
|
||||
}, Listens: []Listening{{Port: 8080, From: FromMesh, Why: "the login page; login.mesh-one.be is a route grant"}}}
|
||||
got := strings.Join(InstallationProblems(m), "\n")
|
||||
for _, want := range []string{
|
||||
"idp names login.mesh-one.be at resources[0].env.KC_HOSTNAME",
|
||||
"idp names the public address 51.15.22.9 at resources[1].content",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("missing %q in:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
for _, mustNot := range []string{"quay.io", "why", "203.0.113.7", "192.168.1.0", "127.0.0.0"} {
|
||||
if strings.Contains(got, mustNot) {
|
||||
t.Errorf("%q was reported and should not be:\n%s", mustNot, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheWorldsNamesAreNotAnInstallations(t *testing.T) {
|
||||
m := Manifest{Module: "resolver", Resources: []map[string]any{
|
||||
{"id": "conf", "type": "file", "content": "server=1.1.1.1\nserver=8.8.8.8\nlisten=127.0.0.55\n"},
|
||||
{"id": "proxy", "type": "container", "image": "docker.io/library/traefik@sha256:bb"},
|
||||
{"id": "adapter", "type": "file", "content": "{\"machine\": \"host.docker.internal\"}\n"},
|
||||
{"id": "doc", "type": "file", "content": "root = https://git.example.tld/\n"},
|
||||
}}
|
||||
if got := InstallationProblems(m); len(got) != 0 {
|
||||
t.Fatalf("the world's names were reported: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestANameMeantOnPurposeIsDeclaredWithItsReason(t *testing.T) {
|
||||
// The federation's public directory in a homeserver's config: the world's, said so, and a name
|
||||
// the map does not cover is still reported.
|
||||
m := Manifest{Module: "homeserver", Resources: []map[string]any{
|
||||
{"id": "conf", "type": "file", "content": "trusted_key_servers: matrix.org\nwell_known: https://mesh-one.be\n",
|
||||
NamesOnPurpose: map[string]any{"matrix.org": "the federation's public key server, the world's"}},
|
||||
}}
|
||||
got := InstallationProblems(m)
|
||||
if len(got) != 1 || !strings.Contains(got[0], "mesh-one.be") {
|
||||
t.Fatalf("got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnImageFromAnInstallationsRegistryNeedsAReason(t *testing.T) {
|
||||
bare := Manifest{Module: "site", Resources: []map[string]any{
|
||||
{"id": "server", "type": "container", "image": "registry.mesh-one.be/org/site@sha256:cc"},
|
||||
}}
|
||||
if got := InstallationProblems(bare); len(got) != 1 || !strings.Contains(got[0], "registry.mesh-one.be") {
|
||||
t.Fatalf("an image on an installation's registry was not named: %v", got)
|
||||
}
|
||||
excepted := Manifest{Module: "site", Resources: []map[string]any{
|
||||
{"id": "server", "type": "container", "image": "registry.mesh-one.be/org/site@sha256:cc",
|
||||
NamesOnPurpose: map[string]any{"registry.mesh-one.be": "built outside the mesh until the site's repository is a build source here"}},
|
||||
}}
|
||||
if got := InstallationProblems(excepted); len(got) != 0 {
|
||||
t.Fatalf("a declared exception was still reported: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAModuleNamedAfterADomainIsNamedBack(t *testing.T) {
|
||||
got := InstallationProblems(Manifest{Module: "mesh-one.be"})
|
||||
if len(got) != 1 || !strings.Contains(got[0], "named after mesh-one.be") {
|
||||
t.Fatalf("got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestABuildContextOnASeatNamesNoForge(t *testing.T) {
|
||||
m := Manifest{Module: "packager", Build: &Build{Artifacts: []Artifact{
|
||||
{Name: "server", Kind: "image", From: "Dockerfile",
|
||||
Context: &ArtifactContext{Seat: "git", Repository: "org/controller", Ref: "main"}},
|
||||
}}}
|
||||
if got := InstallationProblems(m); len(got) != 0 {
|
||||
t.Fatalf("a context on a seat was reported: %v", got)
|
||||
}
|
||||
m.Build.Artifacts[0].Context = &ArtifactContext{Repository: "https://git.mesh-one.be/org/controller.git"}
|
||||
if got := InstallationProblems(m); len(got) != 1 {
|
||||
t.Fatalf("a context by URL was not reported: %v", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A manifest may say which tools its module calls (novox/hq ADR 0152), and the parser accepts the
|
||||
// two shapes the grant has: a named tool, and every tool.
|
||||
func TestAManifestMaySayWhatItInvokes(t *testing.T) {
|
||||
m, err := ParseManifest([]byte(`{"module":"mesh-console","version":"1",` +
|
||||
`"invokes":["mesh-catalog.catalog_modules","*"]}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(m.Invokes) != 2 || m.Invokes[1] != "*" {
|
||||
t.Fatalf("invokes not read: %v", m.Invokes)
|
||||
}
|
||||
}
|
||||
|
||||
// An entry that names a module and no tool is refused at parse, in the manifest's words, rather than
|
||||
// at the composition of the bus's user list where it would stop the file for everybody.
|
||||
func TestAnInvokeThatNamesNoToolIsRefusedAtParse(t *testing.T) {
|
||||
_, err := ParseManifest([]byte(`{"module":"mesh-console","version":"1","invokes":["shop"]}`))
|
||||
if err == nil {
|
||||
t.Fatal("an invoke naming no tool was accepted")
|
||||
}
|
||||
if !strings.Contains(err.Error(), `invokes "shop", which does not name a tool`) {
|
||||
t.Fatalf("refused for the wrong reason: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,11 @@ var renamed = map[string]string{
|
||||
|
||||
var name = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*(\.[a-z0-9][a-z0-9-]*)*$`)
|
||||
|
||||
// toolName is what a module calls one of its tools: the sdk's tools are `catalog_modules` and
|
||||
// `gitea_list_repos`, so an underscore is ordinary here and a dot is not — the dot is what separates
|
||||
// the module from the tool in `<module>.<tool>`, and a tool name carrying one would be two grants.
|
||||
var toolName = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]*$`)
|
||||
|
||||
// Claim is a singular resource a module takes over.
|
||||
type Claim struct {
|
||||
Name string `json:"name"`
|
||||
@@ -247,6 +252,16 @@ type Manifest struct {
|
||||
// module claiming a seat answers what that seat's protocol promises (novox/hq ADR 0118).
|
||||
Tools []string `json:"tools,omitempty"`
|
||||
|
||||
// Invokes are the tools this module calls, each `<module>.<tool>` or a role's `seat:<seat>.<verb>`,
|
||||
// or the single entry `*` for every tool on the mesh (novox/hq ADR 0152, ADR 0154).
|
||||
//
|
||||
// **A grant, and only a grant.** The bus lets this module publish exactly those tool subjects
|
||||
// and nothing beside them — no event, no subscription, no seat. A module that declares none
|
||||
// calls nothing, which is every module but the console today. ADR 0095 made the control plane
|
||||
// the one caller and deferred this until a consumer asked; the console is that consumer, and a
|
||||
// person's account (design 25 §7) already had the same shape.
|
||||
Invokes []string `json:"invokes,omitempty"`
|
||||
|
||||
// Capabilities the machine must have. A different field from Requires because the remedy
|
||||
// differs: a missing module can be assigned, and a missing capability means the wrong
|
||||
// machine.
|
||||
@@ -534,8 +549,14 @@ type BuildsOn struct {
|
||||
type ArtifactContext struct {
|
||||
// Repository is cloned fresh, the same way the module's own repository is — a working tree
|
||||
// nothing has touched, so what was built is reproducible from the two commits named rather
|
||||
// than from whatever a previous build happened to leave behind.
|
||||
// than from whatever a previous build happened to leave behind. A URL, or — with Seat — a
|
||||
// path on that seat's holder, `<owner>/<name>`.
|
||||
Repository string `json:"repository"`
|
||||
// Seat is the seat the repository lives on: `git` for this mesh's own forge (novox/hq ADR 0111,
|
||||
// ADR 0155). A context written as a URL names one installation's forge and can be built
|
||||
// nowhere else; a path on the seat is composed by the mesh that builds it, whichever forge
|
||||
// holds the seat there.
|
||||
Seat string `json:"seat,omitempty"`
|
||||
// Ref is the branch, tag or commit of that repository to build. Empty means its own default
|
||||
// branch — the same meaning an empty module ref already has.
|
||||
Ref string `json:"ref,omitempty"`
|
||||
@@ -597,6 +618,16 @@ type Artifact struct {
|
||||
// Empty for every other kind, which do not compile.
|
||||
Language string `json:"language,omitempty"`
|
||||
|
||||
// Binary is what the compiled executable is called, for a bundle in a language that compiles to
|
||||
// one. Empty means the package's own name, which is what a compiler does by default.
|
||||
//
|
||||
// **Because the name a machine runs it by is not always the name of the package that built it.**
|
||||
// The host's command is `cmd/mesh-host` and every machine runs it as `nox-mesh-host` — the path
|
||||
// it is installed at, the name in its unit, and the name its launcher looks for inside a
|
||||
// delivered version. A bundle that carried the package's name was delivered correctly, reported
|
||||
// success, and was invisible to the launcher (novox/hq 04-ISSUES/142).
|
||||
Binary string `json:"binary,omitempty"`
|
||||
|
||||
// Entrypoints are the compiled files a tool host should load from this module, relative to the
|
||||
// bundle's root.
|
||||
//
|
||||
@@ -1280,6 +1311,7 @@ func ParseManifest(raw []byte) (Manifest, error) {
|
||||
"%s listens on %d over %q, which is tcp or udp", m.Module, l.Port, p))
|
||||
}
|
||||
}
|
||||
problems = append(problems, invokeProblems(m)...)
|
||||
problems = append(problems, endpointNameProblems(m)...)
|
||||
problems = append(problems, RouteProblems(m)...)
|
||||
for _, port := range m.Guards {
|
||||
@@ -1756,3 +1788,26 @@ func EndpointPort(m Manifest, name string) (int, bool) {
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// invokeProblems judges what a module says it calls (novox/hq ADR 0152).
|
||||
//
|
||||
// Refused here, in the manifest's words, rather than at the next composition of the bus's user
|
||||
// list — where a bad entry would stop the whole file being written for everybody, as a person's
|
||||
// malformed grant would have (operator.go). An entry that names a module and no tool is the one
|
||||
// mistake worth naming: `shop` reads like a grant to a module's tools and would be a grant to nothing.
|
||||
func invokeProblems(m Manifest) []string {
|
||||
var problems []string
|
||||
for _, t := range m.Invokes {
|
||||
if t == "*" {
|
||||
continue
|
||||
}
|
||||
t = strings.TrimPrefix(t, "seat:")
|
||||
module, tool, named := strings.Cut(t, ".")
|
||||
if !named || !name.MatchString(module) || !toolName.MatchString(tool) {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s invokes %q, which does not name a tool: a module invokes <module>.<tool>, or "+
|
||||
"* for every tool on the mesh (novox/hq ADR 0152)", m.Module, t))
|
||||
}
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
@@ -30,36 +31,38 @@ func namesOf(r map[string]any) []string {
|
||||
return out
|
||||
}
|
||||
|
||||
// A container does not inherit the machine's names, so the mesh gives them to it.
|
||||
// No mesh name is written into a container (novox/hq ADR 0148). It resolves them through its
|
||||
// machine's resolver at the moment it asks, so a name that moves is answered differently by the
|
||||
// next lookup, in every container, with nothing recreated.
|
||||
//
|
||||
// It gets its own hosts file holding only its own hostname — every internal name the mesh wrote
|
||||
// for the machine is invisible to what the machine runs. A database client on one node could not
|
||||
// resolve another node, on a mesh where both names were correct and present on both machines.
|
||||
func TestEveryContainerIsGivenTheMeshsNames(t *testing.T) {
|
||||
got := containersOf(t, Resolution{Node: "laptop", Modules: []Manifest{{
|
||||
Module: "app",
|
||||
Resources: []map[string]any{{"id": "web", "type": "container", "name": "web",
|
||||
"image": "registry.example/web@sha256:" + strings.Repeat("a", 64)}},
|
||||
}}}, Rendering{Names: map[string]string{
|
||||
"anchor.internal": "10.42.0.1", "laptop.internal": "10.42.0.2",
|
||||
}})
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("expected one container, got %d", len(got))
|
||||
// Checked the way the record says: the declaration a container gets does not move when the mesh's
|
||||
// roster does. A roster with one machine and a roster with three produce the same container, byte
|
||||
// for byte, so the digest a host computes from it cannot move either — which is what stopped one
|
||||
// name moving from replacing every container in the mesh (issue 151).
|
||||
func TestAContainerIsTheSameWhateverTheMeshsRosterSays(t *testing.T) {
|
||||
module := Manifest{Module: "app", Resources: []map[string]any{{"id": "web", "type": "container",
|
||||
"name": "web", "image": "registry.example/web@sha256:" + strings.Repeat("a", 64)}}}
|
||||
one := containersOf(t, Resolution{Node: "laptop", Modules: []Manifest{module}},
|
||||
Rendering{Names: map[string]string{"laptop.internal": "10.42.0.2"}})
|
||||
three := containersOf(t, Resolution{Node: "laptop", Modules: []Manifest{module}},
|
||||
Rendering{Names: map[string]string{
|
||||
"anchor.internal": "10.42.0.1", "laptop.internal": "10.42.0.2", "git.example.tld": "10.42.0.1",
|
||||
}})
|
||||
if len(one) != 1 || len(three) != 1 {
|
||||
t.Fatalf("expected one container each, got %d and %d", len(one), len(three))
|
||||
}
|
||||
given := namesOf(got[0])
|
||||
if len(given) != 2 {
|
||||
t.Fatalf("the container was given %d name(s): %v", len(given), given)
|
||||
if given := namesOf(three[0]); len(given) != 0 {
|
||||
t.Fatalf("the mesh's names were copied into the container: %v", given)
|
||||
}
|
||||
if given[0] != "anchor.internal:10.42.0.1" {
|
||||
t.Fatalf("the name is not in the form a runtime writes: %v", given)
|
||||
if !reflect.DeepEqual(one[0], three[0]) {
|
||||
t.Fatalf("the container moved with the roster:\n%v\n%v", one[0], three[0])
|
||||
}
|
||||
}
|
||||
|
||||
// A container that named its own keeps them and gets the mesh's beside them.
|
||||
//
|
||||
// The mesh does not know what else a workload needs to reach, and taking something away in order
|
||||
// to add something is not what "also" means.
|
||||
func TestAContainersOwnNamesAreKept(t *testing.T) {
|
||||
// The names a module declares for itself are its own: part of what the module is, kept exactly as
|
||||
// written, and the mesh does not know what they mean. They are the one thing in a container's
|
||||
// hosts that does move its identity, because they do not move when the mesh's roster does.
|
||||
func TestAContainersOwnNamesAreKeptAsWritten(t *testing.T) {
|
||||
got := containersOf(t, Resolution{Node: "laptop", Modules: []Manifest{{
|
||||
Module: "app",
|
||||
Resources: []map[string]any{{"id": "web", "type": "container", "name": "web",
|
||||
@@ -68,62 +71,15 @@ func TestAContainersOwnNamesAreKept(t *testing.T) {
|
||||
}}}, Rendering{Names: map[string]string{"anchor.internal": "10.42.0.1"}})
|
||||
|
||||
given := namesOf(got[0])
|
||||
if len(given) != 2 || given[0] != "something.else:203.0.113.9" {
|
||||
t.Fatalf("the container's own names were lost: %v", given)
|
||||
if len(given) != 1 || given[0] != "something.else:203.0.113.9" {
|
||||
t.Fatalf("the container's own names were not kept as written: %v", given)
|
||||
}
|
||||
}
|
||||
|
||||
// A container on the machine's own network gets the names too — it does NOT share the machine's
|
||||
// hosts file. `docker run --network host` still gives the container its own /etc/hosts (localhost
|
||||
// and its own id only), so every `<node>.internal` name the mesh wrote is invisible inside it, and a
|
||||
// client that dials one gets EAI_AGAIN. It gets the same `--add-host` entries every other container
|
||||
// gets (the runtime accepts them with `--network host`), so a host-network consumer can reach a
|
||||
// provider by the `.internal` address the mesh hands it.
|
||||
func TestAContainerOnTheMachinesNetworkIsGivenTheNamesToo(t *testing.T) {
|
||||
got := containersOf(t, Resolution{Node: "anchor", Modules: []Manifest{{
|
||||
Module: "control",
|
||||
Resources: []map[string]any{{"id": "c", "type": "container", "name": "c",
|
||||
"image": "registry.example/c@sha256:" + strings.Repeat("a", 64), "network": "host"}},
|
||||
}}}, Rendering{Names: map[string]string{"anchor.internal": "10.42.0.1"}})
|
||||
|
||||
given := namesOf(got[0])
|
||||
if len(given) != 1 || given[0] != "anchor.internal:10.42.0.1" {
|
||||
t.Fatalf("a host-networked container was not given the mesh's names: %v", got[0])
|
||||
}
|
||||
}
|
||||
|
||||
// A mesh with no private network gives nothing, rather than a name with no address behind it.
|
||||
func TestAMeshWithNoNamesGivesNone(t *testing.T) {
|
||||
got := containersOf(t, Resolution{Node: "alone", Modules: []Manifest{{
|
||||
Module: "app",
|
||||
Resources: []map[string]any{{"id": "web", "type": "container", "name": "web",
|
||||
"image": "registry.example/web@sha256:" + strings.Repeat("a", 64)}},
|
||||
}}}, Rendering{})
|
||||
if len(namesOf(got[0])) != 0 {
|
||||
t.Fatalf("names were invented for a mesh that has none: %v", got[0])
|
||||
}
|
||||
}
|
||||
|
||||
// The catalogue's copy is not edited: these maps come from a manifest, and mutating one would
|
||||
// change what every other machine running that module is given.
|
||||
func TestGivingNamesDoesNotChangeTheCatalogue(t *testing.T) {
|
||||
held := map[string]any{"id": "web", "type": "container", "name": "web",
|
||||
"image": "registry.example/web@sha256:" + strings.Repeat("a", 64)}
|
||||
module := Manifest{Module: "app", Resources: []map[string]any{held}}
|
||||
|
||||
for _, node := range []string{"one", "two"} {
|
||||
containersOf(t, Resolution{Node: node, Modules: []Manifest{module}},
|
||||
Rendering{Names: map[string]string{"anchor.internal": "10.42.0.1"}})
|
||||
}
|
||||
if _, changed := held["hosts"]; changed {
|
||||
t.Fatal("the manifest the catalogue holds was edited, so every machine now carries this")
|
||||
}
|
||||
}
|
||||
|
||||
// Only containers. A file or a service given a `hosts` key is a declaration the host refuses
|
||||
// outright — it takes no unknown field — so getting this wrong breaks the whole machine rather
|
||||
// than one resource, and breaks it for something that was never about names.
|
||||
func TestNothingButAContainerIsGivenNames(t *testing.T) {
|
||||
// No resource is given a `hosts` key it did not declare. A file or a service carrying one is a
|
||||
// declaration the host refuses outright — it takes no unknown field — so an invented key breaks
|
||||
// the whole machine rather than one resource.
|
||||
func TestNothingIsGivenNamesItDidNotDeclare(t *testing.T) {
|
||||
out, err := Resolution{Node: "laptop", Modules: []Manifest{{
|
||||
Module: "app",
|
||||
Resources: []map[string]any{
|
||||
@@ -137,11 +93,8 @@ func TestNothingButAContainerIsGivenNames(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, r := range out {
|
||||
if r["type"] == "container" {
|
||||
continue
|
||||
}
|
||||
if _, given := r["hosts"]; given {
|
||||
t.Fatalf("a %v was given names, which the host will refuse: %v", r["type"], r)
|
||||
t.Fatalf("a %v was given names it never declared: %v", r["type"], r)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A module that must know its own address — a login redirect, a canonical URL, an issuer — had it
|
||||
// written into its manifest as a literal (novox/hq 04-ISSUES/122): a domain in a definition, wrong on
|
||||
// every other machine. It is told instead, from the same composition the provider receives.
|
||||
|
||||
// selfAware contributes a labelled route, binds the requirement, and writes its own name into a file.
|
||||
func selfAware(label string) Manifest {
|
||||
m := labelled("board", label, 8080)
|
||||
m.Requires = []string{"reverse-proxy"}
|
||||
m.Binds = map[string]string{"reverse-proxy": "/var/lib/board/route.json"}
|
||||
m.Resources = []map[string]any{
|
||||
{"id": "conf", "type": "file", "path": "/var/lib/board/app.conf",
|
||||
"content": "root = https://${bound:reverse-proxy:name}/\ninternal = ${bound:reverse-proxy:internal-name}\n"},
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// servingProxy is proxy() as the catalogue's route providers are declared: the provision scoped to
|
||||
// the mesh, serving nothing a consumer must know (route-adapter, route-proxy: `"serves": {"route": {}}`).
|
||||
func servingProxy() Manifest {
|
||||
p := proxy()
|
||||
p.Provides = []Offer{{Name: "reverse-proxy", Scope: ScopeMesh}}
|
||||
p.Serves = map[string]map[string]any{"reverse-proxy": {}}
|
||||
return p
|
||||
}
|
||||
|
||||
// nodeProxy is the same provider scoped to its node, whose answer on the same machine comes from
|
||||
// `here` rather than from the mesh's needs — the other path a binding is written by.
|
||||
func nodeProxy() Manifest {
|
||||
p := proxy()
|
||||
p.Serves = map[string]map[string]any{"reverse-proxy": {"scheme": "http"}}
|
||||
return p
|
||||
}
|
||||
|
||||
// onBoth is a node with a public domain and a private-network address, so both names compose.
|
||||
func onBoth(domain string) Node {
|
||||
n := withDomain(domain)
|
||||
n.At = "anchor.internal"
|
||||
return n
|
||||
}
|
||||
|
||||
func fileAt(t *testing.T, out []map[string]any, path string) string {
|
||||
t.Helper()
|
||||
for _, r := range out {
|
||||
if r["path"] == path {
|
||||
return r["content"].(string)
|
||||
}
|
||||
}
|
||||
t.Fatalf("nothing was declared at %s", path)
|
||||
return ""
|
||||
}
|
||||
|
||||
func TestAModuleIsToldTheNameItsProviderServes(t *testing.T) {
|
||||
got, err := Resolve(shelf(servingProxy(), selfAware("git")), []string{"traefik", "board"},
|
||||
onBoth("example.tld"), World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out := mustDeclare(t, got)
|
||||
served := received(t, out)[0].Values
|
||||
|
||||
var binding map[string]any
|
||||
if err := json.Unmarshal([]byte(fileAt(t, out, "/var/lib/board/route.json")), &binding); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if binding["name"] != served["name"] || binding["name"] != "git.example.tld" {
|
||||
t.Fatalf("the module was told %v, the provider serves %v", binding["name"], served["name"])
|
||||
}
|
||||
if binding["internal-name"] != served["internal-name"] || binding["internal-name"] == nil {
|
||||
t.Fatalf("internal name: module told %v, provider serves %v",
|
||||
binding["internal-name"], served["internal-name"])
|
||||
}
|
||||
|
||||
conf := fileAt(t, out, "/var/lib/board/app.conf")
|
||||
want := "root = https://git.example.tld/\ninternal = " + served["internal-name"].(string) + "\n"
|
||||
if conf != want {
|
||||
t.Fatalf("the file was rendered as\n%s\nwant\n%s", conf, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheNameAModuleIsToldFollowsTheNodesDomain(t *testing.T) {
|
||||
// The whole point: the same definition, two machines, two names — nothing edited.
|
||||
for _, domain := range []string{"example.tld", "other.example"} {
|
||||
got, err := Resolve(shelf(servingProxy(), selfAware("git")), []string{"traefik", "board"},
|
||||
onBoth(domain), World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
conf := fileAt(t, mustDeclare(t, got), "/var/lib/board/app.conf")
|
||||
if !strings.HasPrefix(conf, "root = https://git."+domain+"/") {
|
||||
t.Fatalf("on %s the module wrote %q", domain, conf)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAModuleWithNoPublicNameIsNotToldOne(t *testing.T) {
|
||||
// No public domain on the node: nothing composed, so no `name` — and a file asking for one is
|
||||
// refused rather than rendered with a placeholder or an empty host.
|
||||
m := selfAware("git")
|
||||
m.Resources[0]["content"] = "root = https://${bound:reverse-proxy:name}/\n"
|
||||
got, err := Resolve(shelf(servingProxy(), m), []string{"traefik", "board"}, workstation(), World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := got.Declaration(Rendering{}); err == nil ||
|
||||
!strings.Contains(err.Error(), `"name"`) {
|
||||
t.Fatalf("a file asking for a name that was never composed was not refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAModuleIsToldItsNameByANodeScopedProviderToo(t *testing.T) {
|
||||
m := selfAware("git")
|
||||
m.Resources[0]["content"] = "root = https://${bound:reverse-proxy:name}/\n"
|
||||
got, err := Resolve(shelf(nodeProxy(), m), []string{"board"},
|
||||
withDomain("example.tld"), World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
conf := fileAt(t, mustDeclare(t, got), "/var/lib/board/app.conf")
|
||||
if !strings.HasPrefix(conf, "root = https://git.example.tld/") {
|
||||
t.Fatalf("a same-machine, node-scoped answer did not tell the module its name: %q", conf)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestPlacedDirectoriesKeepTheirPaths is the check novox/hq issue 119 asks for before a definition
|
||||
// stops naming where its data lives: a converted manifest, resolved on a node with the default root,
|
||||
// names exactly the paths the manifest before it named. Data that a service is using must not move
|
||||
// because a definition stopped saying where it was.
|
||||
//
|
||||
// Two checkouts: MESH_CATALOGUE_BEFORE, the catalogue as it was, and MESH_CATALOGUE, as it is now.
|
||||
// Every module in both is resolved with the controller's own rule (dirsFor, dirFill) and compared
|
||||
// whole — not only the directories, but every string a directory's id was written into.
|
||||
func TestPlacedDirectoriesKeepTheirPaths(t *testing.T) {
|
||||
before, after := os.Getenv("MESH_CATALOGUE_BEFORE"), os.Getenv("MESH_CATALOGUE")
|
||||
if before == "" || after == "" {
|
||||
t.Skip("set MESH_CATALOGUE_BEFORE and MESH_CATALOGUE to two catalogue checkouts to run this")
|
||||
}
|
||||
found, _ := filepath.Glob(filepath.Join(after, "modules", "*", "module.json"))
|
||||
compared := 0
|
||||
for _, path := range found {
|
||||
module := filepath.Base(filepath.Dir(path))
|
||||
old, err := os.ReadFile(filepath.Join(before, "modules", module, "module.json"))
|
||||
if err != nil {
|
||||
continue // new since; nothing to keep
|
||||
}
|
||||
now, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(old) == string(now) {
|
||||
continue
|
||||
}
|
||||
m, err := ParseManifest(now)
|
||||
if err != nil {
|
||||
t.Errorf("%s: %v", module, err)
|
||||
continue
|
||||
}
|
||||
dirs := dirsFor(m, Rendering{})
|
||||
var was, is any
|
||||
if err := json.Unmarshal(old, &was); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := json.Unmarshal(now, &is); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resolved := resolvedTree(is, dirs, module, t)
|
||||
if !reflect.DeepEqual(was, resolved) {
|
||||
wasJSON, _ := json.MarshalIndent(was, "", " ")
|
||||
isJSON, _ := json.MarshalIndent(resolved, "", " ")
|
||||
t.Errorf("%s: resolved on the default root, the converted manifest is not the one before it\n--- before\n%s\n--- resolved now\n%s",
|
||||
module, firstDifference(string(wasJSON), string(isJSON)), "")
|
||||
}
|
||||
compared++
|
||||
}
|
||||
t.Logf("%d converted manifest(s) resolve to the paths they named before", compared)
|
||||
}
|
||||
|
||||
// resolvedTree is the manifest as a machine would see it: every ${dir:…} filled, a pathless
|
||||
// directory given the path it resolves to, and the placement word removed.
|
||||
func resolvedTree(node any, dirs map[string]string, module string, t *testing.T) any {
|
||||
switch v := node.(type) {
|
||||
case map[string]any:
|
||||
out := map[string]any{}
|
||||
for k, child := range v {
|
||||
if k == "place" {
|
||||
continue
|
||||
}
|
||||
out[k] = resolvedTree(child, dirs, module, t)
|
||||
}
|
||||
if out["type"] == "directory" {
|
||||
if _, has := out["path"]; !has {
|
||||
if id, ok := out["id"].(string); ok {
|
||||
out["path"] = dirs[id]
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
case []any:
|
||||
out := make([]any, len(v))
|
||||
for i, child := range v {
|
||||
out[i] = resolvedTree(child, dirs, module, t)
|
||||
}
|
||||
return out
|
||||
case string:
|
||||
filled, err := dirFill(v, dirs, module)
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
return filled
|
||||
}
|
||||
return node
|
||||
}
|
||||
|
||||
func firstDifference(a, b string) string {
|
||||
al, bl := strings.Split(a, "\n"), strings.Split(b, "\n")
|
||||
for i := range al {
|
||||
if i >= len(bl) || al[i] != bl[i] {
|
||||
from := i - 2
|
||||
if from < 0 {
|
||||
from = 0
|
||||
}
|
||||
to := i + 3
|
||||
if to > len(al) {
|
||||
to = len(al)
|
||||
}
|
||||
bt := i + 3
|
||||
if bt > len(bl) {
|
||||
bt = len(bl)
|
||||
}
|
||||
return "before:\n" + strings.Join(al[from:to], "\n") + "\nnow:\n" + strings.Join(bl[from:bt], "\n")
|
||||
}
|
||||
}
|
||||
return "(the difference is beyond the shorter document)"
|
||||
}
|
||||
@@ -98,8 +98,7 @@ func portInto(resource map[string]any, module string, listens []Listening, with
|
||||
|
||||
// **A fresh map, and only when something changes.** This map came out of the module's
|
||||
// manifest and the resource around it is a shallow copy, so filling a value in place would
|
||||
// change what the catalogue holds for every other machine running the module — the trap
|
||||
// withMeshNames is written to avoid, one field along.
|
||||
// change what the catalogue holds for every other machine running the module.
|
||||
var filled map[string]any
|
||||
for _, key := range named {
|
||||
written, ok := env[key].(string)
|
||||
|
||||
@@ -707,9 +707,14 @@ func checkClaims(modules []Manifest, node Node, elsewhere []Held, holdings []Hel
|
||||
}
|
||||
switch h.Scope {
|
||||
case ScopeMesh:
|
||||
// Both claim and nobody is on record, or this refusal could not have happened.
|
||||
// The remedy is the handover that records the holder (novox/hq ADR 0131,
|
||||
// 04-ISSUES/170), so it is named here rather than left to be found.
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s on %s claims %q, which %s on %s already holds — one per mesh",
|
||||
h.Module, node.Name, h.Claim, e.Module, e.Node))
|
||||
"%s on %s claims %q, which %s on %s already holds — one per mesh. Nothing is "+
|
||||
"on record for it; `seat %s --to %s/%s` records the holder, and the other "+
|
||||
"assignment then stands beside it, eligible and silent",
|
||||
h.Module, node.Name, h.Claim, e.Module, e.Node, h.Claim, e.Node, e.Module))
|
||||
case ScopeSite:
|
||||
if node.Site != "" && node.Site == e.Site {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
|
||||
@@ -48,7 +48,7 @@ func TestTheResolverForwardsToFixedUpstreamsAndNeverReadsResolvConf(t *testing.T
|
||||
}
|
||||
for _, want := range []string{
|
||||
"\nno-resolv\n", "\nserver=1.1.1.1\n", "\nserver=8.8.8.8\n",
|
||||
"\nlisten-address=127.0.0.1\n", "\ninterface=mesh0\n", "\nbind-dynamic\n",
|
||||
"\nlisten-address=127.0.0.1\n", "\nlisten-address=${machine:address}\n", "\nbind-dynamic\n",
|
||||
"\ndomain-needed\n", "\nbogus-priv\n",
|
||||
"\nconf-file=" + m.Facts["node-zones"].Path + "\n",
|
||||
} {
|
||||
@@ -56,6 +56,14 @@ func TestTheResolverForwardsToFixedUpstreamsAndNeverReadsResolvConf(t *testing.T
|
||||
t.Errorf("the resolver's configuration lacks %q:\n%s", strings.TrimSpace(want), config)
|
||||
}
|
||||
}
|
||||
// By address and never by interface: dnsmasq admits a query by the interface it arrives on
|
||||
// when told one, and a container's query to the private address arrives on the runtime's
|
||||
// bridge — `interface=mesh0` dropped every such query, silently (novox/hq issue 110).
|
||||
for _, line := range strings.Split(config, "\n") {
|
||||
if strings.HasPrefix(line, "interface=") {
|
||||
t.Errorf("the resolver answers by interface, so a container's query on a bridge is dropped: %s", line)
|
||||
}
|
||||
}
|
||||
// Not .53 or .54, which systemd-resolved holds; and not .55 any more, which was a convention
|
||||
// beside the one every machine already followed — the predecessor's resolv.conf says .1.
|
||||
for _, taken := range []string{"127.0.0.53", "127.0.0.54", "127.0.0.55"} {
|
||||
@@ -137,23 +145,39 @@ func TestTheResolverAndWhatAsksItComposeOnOneMachine(t *testing.T) {
|
||||
t.Errorf("the daemon does not restart on its configuration and the machines file both: %v", service["restart-on"])
|
||||
}
|
||||
|
||||
// The runtime's own file, written into (novox/hq ADR 0102) with the one key this module states.
|
||||
// The runtime's own file, written into (novox/hq ADR 0102) with the keys this module states:
|
||||
// where containers resolve, and that a restart keeps them running — because the runtime reads
|
||||
// `dns` only when it starts, and the one restart that needs is the operator's (issue 110).
|
||||
runtime := ids["dnsmasq.runtime-dns"]
|
||||
if runtime == nil || runtime["path"] != "/etc/docker/daemon.json" || runtime["into"] != "json" {
|
||||
t.Fatalf("the runtime's dns is not written into its file: %v", runtime)
|
||||
}
|
||||
var keys map[string][]string
|
||||
var keys map[string]any
|
||||
if err := json.Unmarshal([]byte(runtime["content"].(string)), &keys); err != nil {
|
||||
t.Fatalf("the runtime's keys are not JSON: %v", err)
|
||||
}
|
||||
if len(keys) != 1 || len(keys["dns"]) != 1 || keys["dns"][0] != "10.42.0.1" {
|
||||
t.Errorf("the runtime is pointed at %v; containers resolve at this machine's own private-network address, and nothing else is written", keys)
|
||||
dns, _ := keys["dns"].([]any)
|
||||
if len(keys) != 2 || len(dns) != 1 || dns[0] != "10.42.0.1" || keys["live-restore"] != true {
|
||||
t.Errorf("the runtime is given %v; containers resolve at this machine's own private-network address, a restart keeps them, and nothing else is written", keys)
|
||||
}
|
||||
// The runtime is reloaded when that file changes, and never restarted: a restart stops every
|
||||
// container on the machine (ADR 0102), and a reload is what turns live-restore on.
|
||||
var reloaded bool
|
||||
for _, r := range out {
|
||||
if r["type"] == "service" && r["unit"] == "docker.service" && r["id"] != "" &&
|
||||
strings.HasPrefix(r["id"].(string), "dnsmasq.") {
|
||||
t.Errorf("the resolver orders the runtime restarted or reloaded, which stops every container (ADR 0102) or does nothing for dns: %v", r)
|
||||
if r["type"] != "service" || r["unit"] != "docker.service" {
|
||||
continue
|
||||
}
|
||||
if _, restarts := r["restart-on"]; restarts {
|
||||
t.Errorf("the resolver orders the runtime restarted, which stops every container (ADR 0102): %v", r)
|
||||
}
|
||||
for _, on := range asStrings(r["reload-on"]) {
|
||||
if on == "dnsmasq.runtime-dns" {
|
||||
reloaded = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if !reloaded {
|
||||
t.Errorf("the runtime is not reloaded when its file changes, so live-restore never takes effect")
|
||||
}
|
||||
|
||||
resolv := ids["resolv-conf.resolv"]
|
||||
|
||||
@@ -162,6 +162,13 @@ func renderRoster(tmpl string, view rosterView) (string, error) {
|
||||
func entriesFrom(addresses, accounts map[string]string, suffix string) []rosterEntry {
|
||||
out := make([]rosterEntry, 0, len(addresses))
|
||||
for _, name := range sortedNames(addresses) {
|
||||
if routed(name, suffix) {
|
||||
// A routed name is already a full name under a public domain, and it has no mesh
|
||||
// form: appending the suffix made `<name>.<suffix>`, which every machine's hosts file
|
||||
// carried and nothing served (novox/hq issue 157). It is published as itself, once.
|
||||
out = append(out, rosterEntry{Name: name, FQDN: name, Address: addresses[name], Account: accounts[name]})
|
||||
continue
|
||||
}
|
||||
internal, bare := meshName(name, suffix)
|
||||
// The account is looked up by whichever key the caller keys accounts on — the internal name
|
||||
// or the bare one — so a template gets the right login however the maps were built.
|
||||
@@ -187,6 +194,14 @@ func meshName(name, suffix string) (internal, bare string) {
|
||||
return name + dotted, name
|
||||
}
|
||||
|
||||
// routed says whether a name the mesh serves is a routed public name rather than a machine's: it
|
||||
// carries a domain of its own and not the mesh's suffix. A machine's name is bare (`homer`) or
|
||||
// internal (`homer.internal`); anything else with a dot in it was composed under a public domain.
|
||||
func routed(name, suffix string) bool {
|
||||
dotted := "." + strings.TrimPrefix(suffixOr(suffix), ".")
|
||||
return strings.Contains(name, ".") && !strings.HasSuffix(name, dotted)
|
||||
}
|
||||
|
||||
// suffixOr is the suffix given, or the one the mesh composes names with when none was handed down.
|
||||
// The one place the default is written, so a fact and a name cannot disagree about it.
|
||||
func suffixOr(suffix string) string {
|
||||
|
||||
@@ -258,3 +258,24 @@ func TestAHomeFactIsSkippedWhereThereIsNoAccount(t *testing.T) {
|
||||
t.Fatalf("a home fact was placed on a machine with no operator account: %v", given)
|
||||
}
|
||||
}
|
||||
|
||||
// A routed name is already a full name under a public domain and has no mesh form. Appending the
|
||||
// suffix to it made `git.example.tld.internal` — carried by every machine's hosts file, served by
|
||||
// nothing, and refused by the proxy at the handshake (novox/hq issue 157). It is published as
|
||||
// itself, and only a machine has a bare name beside its full one.
|
||||
func TestARoutedNameIsPublishedAsItselfAndNotSuffixed(t *testing.T) {
|
||||
names := map[string]string{"homer.internal": "10.42.0.1", "git.example.tld": "10.42.0.1"}
|
||||
tmpl := RosterFile{Path: "/f", Template: "{{range .Names}}{{.FQDN}} {{.Name}}\n{{end}}"}
|
||||
out, err := FactsInto(Manifest{Module: "a", Facts: map[string]RosterFile{"f": tmpl}},
|
||||
Resolution{Node: "homer"}, names, map[string]string{"homer.internal": "10.42.0.1"}, nil, "internal")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := out[0]["content"].(string)
|
||||
if strings.Contains(got, "tld.internal") {
|
||||
t.Fatalf("the routed name was given a suffixed alias that nothing serves:\n%s", got)
|
||||
}
|
||||
if !strings.Contains(got, "git.example.tld git.example.tld\n") || !strings.Contains(got, "homer.internal homer\n") {
|
||||
t.Fatalf("the roster does not carry the routed name as itself beside the machine's two forms:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -198,37 +198,59 @@ func TestTheApexLabelComposesToTheBarePrivateAddress(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// novox/hq ADR 0066 propagate, by ADR 0148's means: a granted route name is published into the
|
||||
// machine's roster mapped to the node that serves it, beside the `<node>.internal` names, and the
|
||||
// machine's resolver answers it to every container. Nothing is written into the container itself —
|
||||
// a routed name that moved would otherwise be wrong inside every container until each was recreated.
|
||||
func TestARoutedNameResolvesToTheServingNode(t *testing.T) {
|
||||
// novox/hq ADR 0066 propagate: a granted route name is published into internal resolution,
|
||||
// mapped to the node that serves it, alongside the `<node>.internal` names — so every
|
||||
// container, and an in-mesh ACME validator, resolves a routed name to the proxy that serves it.
|
||||
// The names map is what withMeshNames writes into every container as `--add-host`; a route name
|
||||
// mapped to the serving node's address rides the same mechanism.
|
||||
names := map[string]string{
|
||||
"anchor.internal": "10.42.0.1",
|
||||
"git.example.tld": "10.42.0.1",
|
||||
}
|
||||
got := containersOf(t, Resolution{Node: "anchor", Modules: []Manifest{{
|
||||
Module: "app",
|
||||
Resources: []map[string]any{{"id": "web", "type": "container", "name": "web",
|
||||
"image": "registry.example/web@sha256:" + strings.Repeat("a", 64)}},
|
||||
}}}, Rendering{Names: map[string]string{
|
||||
"anchor.internal": "10.42.0.1",
|
||||
"git.example.tld": "10.42.0.1",
|
||||
}})
|
||||
}}}, Rendering{Names: names})
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("expected one container, got %d", len(got))
|
||||
}
|
||||
given := namesOf(got[0])
|
||||
var sawNode, sawRoute bool
|
||||
for _, h := range given {
|
||||
if h == "anchor.internal:10.42.0.1" {
|
||||
sawNode = true
|
||||
}
|
||||
if h == "git.example.tld:10.42.0.1" {
|
||||
if given := namesOf(got[0]); len(given) != 0 {
|
||||
t.Fatalf("the routed name was copied into the container, where it would go stale: %v", given)
|
||||
}
|
||||
var sawRoute bool
|
||||
for _, e := range entriesFrom(names, nil, "internal") {
|
||||
if e.Name == "git.example.tld" && e.Address == "10.42.0.1" {
|
||||
sawRoute = true
|
||||
}
|
||||
}
|
||||
if !sawNode {
|
||||
t.Fatalf("the container lost the mesh's node names: %v", given)
|
||||
}
|
||||
if !sawRoute {
|
||||
t.Fatalf("the routed name was not published to the serving node: %v", given)
|
||||
t.Fatalf("the routed name is not in the roster the machine's resolver answers from")
|
||||
}
|
||||
}
|
||||
|
||||
// novox/hq issue 139, ADR 0151: `<label>.<node>.internal` is answered by every machine's resolver as
|
||||
// "anything under that node's name goes to that node", so the node in a route's internal name must
|
||||
// be the one whose proxy answers it. Composed under the consumer's own name, a route served from
|
||||
// another machine got an internal name that resolved to a machine with nothing listening, while the
|
||||
// public name — published at the serving node's address — worked.
|
||||
func TestARoutesInternalNameIsComposedUnderTheNodeThatServesIt(t *testing.T) {
|
||||
hub := proxy()
|
||||
hub.Provides = FromAnywhere("reverse-proxy")
|
||||
got, err := Resolve(shelf(hub, labelled("board", "git", 8080)), []string{"board"},
|
||||
withPrivateAddress("laptop.internal"), World{Offered: map[string][]Provider{
|
||||
"reverse-proxy": {{Node: "anchor", At: "anchor.internal", Module: "traefik"}},
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Gathered the way the control plane gathers a consumer's contribution for a provider on
|
||||
// another machine.
|
||||
values, asks, err := got.ContributionsFrom("reverse-proxy", "board", nil)
|
||||
if err != nil || !asks {
|
||||
t.Fatalf("the route was not contributed: %v %v", asks, err)
|
||||
}
|
||||
if values["internal-name"] != "git.anchor.internal" {
|
||||
t.Fatalf("the internal name does not say where the request arrives: %v", values)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,7 +37,9 @@ type Seat struct {
|
||||
// today — which the bus refuses, because a namespace belongs to who it is named for.
|
||||
Accepts []string
|
||||
Emits []string
|
||||
Serves []string
|
||||
// Serves carries each verb in full — name, description, schema — because a role's tools are the
|
||||
// mesh's to define and an agent's to call (novox/hq ADR 0132, design 33 §2).
|
||||
Serves []Verb
|
||||
// Decision is the record that made it a seat.
|
||||
Decision string
|
||||
}
|
||||
@@ -51,8 +53,12 @@ var defaultSeats = []Seat{
|
||||
// The control plane states what it did under the seat it holds (novox/hq ADR 0134): a role's
|
||||
// events belong to the role, so they keep their address while the holder is replaced. No accepts,
|
||||
// so no work queue is raised for it — only what its holder may say.
|
||||
{Name: "mesh-controller", Scope: ScopeMesh, Decision: "novox/hq ADR 0079",
|
||||
Emits: []string{"applied", "refused", "built-before"}},
|
||||
// And it serves the mesh's own verbs as the seat's tools (novox/hq ADR 0154): `status`, `push`,
|
||||
// `assign` and the rest are a role's interface, not a container's, and stay addressable while
|
||||
// the control plane is replaced.
|
||||
{Name: ControllerSeatName, Scope: ScopeMesh, Decision: "novox/hq ADR 0079",
|
||||
Emits: []string{"applied", "refused", "built-before"},
|
||||
Serves: ControllerVerbs},
|
||||
{Name: "mesh-store", Scope: ScopeMesh, Delivers: "postgres-database", Decision: "novox/hq ADR 0079"},
|
||||
// **Delivers the mesh's own bus, not `amqp`.** Those were the same word until
|
||||
// ADR 0127 separated them: `amqp` is a backing service a module may require, and this seat is
|
||||
@@ -60,7 +66,11 @@ var defaultSeats = []Seat{
|
||||
// rather than receives ambiently — 23 of the catalogue's modules never speak, and an ambient
|
||||
// connection would mint a credential for each.
|
||||
{Name: "mesh-broker", Scope: ScopeMesh, Delivers: "mesh-bus", Decision: "novox/hq ADR 0079"},
|
||||
{Name: "the-artifact-store", Scope: ScopeMesh, Delivers: "artifact-store", Decision: "novox/hq ADR 0075"},
|
||||
// Named for its scope since 2026-09-30 (novox/hq ADR 0156); `the-artifact-store` resolves to it as
|
||||
// an alias on a mesh that predates the rename. It serves artifacts of every kind a build makes —
|
||||
// images and archives, by digest — which is why the provision is the artifact store and not an
|
||||
// image registry.
|
||||
{Name: "mesh-artifact-store", Scope: ScopeMesh, Delivers: "artifact-store", Decision: "novox/hq ADR 0075"},
|
||||
{Name: "mesh-catalog", Scope: ScopeMesh, Decision: "novox/hq ADR 0121"},
|
||||
// Deferred renames (novox/hq ADR 0121): these deliver a provision, so renaming them is a
|
||||
// delivering-seat migration with a mesh-wide cascade if a holder stops resolving mid-flight.
|
||||
@@ -91,8 +101,9 @@ var defaultSeats = []Seat{
|
||||
// A system seat name is the control plane's namespace: `mesh-*` for a mesh-wide role, `node-*` for
|
||||
// a per-node one (novox/hq ADR 0121). A claim to a system name the mesh does not define is refused;
|
||||
// any other name is a module's own to define and claim. Some of the mesh's own seats predate this
|
||||
// convention and are not yet renamed (git, npm-package-registry, the-artifact-store,
|
||||
// the-private-network) — those are in the set, so they resolve by name, not by prefix.
|
||||
// convention and are not yet renamed (git, npm-package-registry, the-private-network) — those are
|
||||
// in the set, so they resolve by name, not by prefix. the-artifact-store was renamed on 2026-09-30
|
||||
// (novox/hq ADR 0156) and resolves through the alias table on a mesh that knew it.
|
||||
func isSystemSeatName(name string) bool {
|
||||
return strings.HasPrefix(name, "mesh-") || strings.HasPrefix(name, "node-")
|
||||
}
|
||||
@@ -269,6 +280,14 @@ func CanHold(m Manifest, seat Seat) error {
|
||||
return fmt.Errorf("%s claims %s, whose holder answers for %q, and %s does not provide %q at %s scope",
|
||||
m.Module, seat.Name, seat.Delivers, m.Module, seat.Delivers, seat.Scope)
|
||||
}
|
||||
// **Serving the seat's tools is a condition of holding it** (novox/hq ADR 0132). A holder that
|
||||
// does not answer what the role promises is every caller's timeout, found at registration and
|
||||
// at handover instead, naming the verbs rather than the fact that something is missing.
|
||||
if missing := unservedVerbs(m.Tools, seat.Serves); len(missing) > 0 {
|
||||
return fmt.Errorf("%s claims %s but does not serve %s, which that seat's protocol promises "+
|
||||
"(novox/hq ADR 0132) — a holder lists every verb its seat declares under tools",
|
||||
m.Module, seat.Name, strings.Join(missing, ", "))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -38,8 +38,9 @@ type SeatDeclaration struct {
|
||||
Accepts []string `json:"accepts,omitempty"`
|
||||
// Emits are the verbs the holder publishes: 1:many, nobody obliged to act.
|
||||
Emits []string `json:"emits,omitempty"`
|
||||
// Serves are the verbs the holder answers: request and reply, awaited.
|
||||
Serves []string `json:"serves,omitempty"`
|
||||
// Serves are the verbs the holder answers: request and reply, awaited. A bare name, or the
|
||||
// verb in full with its schema (novox/hq ADR 0132).
|
||||
Serves []Verb `json:"serves,omitempty"`
|
||||
|
||||
// RetainSeconds is how long the inbound backlog survives with no holder, zero for the
|
||||
// mesh's default. Retention belongs to whoever owns the namespace (design 29 §3) — a seat
|
||||
@@ -62,7 +63,7 @@ func (s SeatDeclaration) At() string {
|
||||
func (s SeatDeclaration) verbs() []string {
|
||||
out := append([]string{}, s.Accepts...)
|
||||
out = append(out, s.Emits...)
|
||||
return append(out, s.Serves...)
|
||||
return append(out, VerbNames(s.Serves)...)
|
||||
}
|
||||
|
||||
// declaredSeatProblems is what one manifest can be judged on alone.
|
||||
@@ -228,8 +229,8 @@ func unserved(m Manifest, s SeatDeclaration) []string {
|
||||
}
|
||||
var missing []string
|
||||
for _, t := range s.Serves {
|
||||
if !has[t] {
|
||||
missing = append(missing, t)
|
||||
if !has[t.Name] {
|
||||
missing = append(missing, t.Name)
|
||||
}
|
||||
}
|
||||
return missing
|
||||
|
||||
@@ -13,7 +13,7 @@ func problemsFor(t *testing.T, shelf Shelf) string {
|
||||
func telegram() Manifest {
|
||||
return Manifest{Module: "telegram", Tools: []string{"status"}, DefinesSeats: []SeatDeclaration{{
|
||||
Name: "telegram-sender", Scope: ScopeMesh,
|
||||
Accepts: []string{"send"}, Emits: []string{"delivered", "failed"}, Serves: []string{"status"},
|
||||
Accepts: []string{"send"}, Emits: []string{"delivered", "failed"}, Serves: []Verb{{Name: "status"}},
|
||||
}}, Claims: []Claim{{Name: "telegram-sender", Scope: ScopeMesh}}}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// An operator's value, where a definition needs one (novox/hq ADR 0112, ADR 0155, design 27).
|
||||
//
|
||||
// A mail server's domain, a site's name, the address a proxy forwards from: values that are true of
|
||||
// one installation and of no other, and that a module's software must be told. They had nowhere to
|
||||
// live but the definition, which is how a catalogue meant for any mesh came to name this one
|
||||
// (novox/hq issues 122, 134). ADR 0112 names the operator as one of the four providers; this is the
|
||||
// operator answering.
|
||||
//
|
||||
// `${setting:<key>}` in a file's content is filled from the module's settings layers — the mesh's,
|
||||
// then this node's — the same layers a mergeable JSON file and a contribution already take, so
|
||||
// `settings set <module>` is the one place a person's values go. **Refused when no layer sets it**,
|
||||
// naming the key and the remedy: a definition that carried a default for a mail domain would be
|
||||
// carrying the very literal this removes, and a blank written silently would be a service that
|
||||
// comes up wrong somewhere that names neither the module nor the key.
|
||||
|
||||
// settingRef is how a definition asks for an operator's value: ${setting:<key>}.
|
||||
var settingRef = regexp.MustCompile(`\$\{setting:([a-z0-9][a-z0-9_.-]*)\}`)
|
||||
|
||||
// settingsUsed is every key a file's content asks for, once each, in order of first use.
|
||||
func settingsUsed(content string) []string {
|
||||
var keys []string
|
||||
seen := map[string]bool{}
|
||||
for _, m := range settingRef.FindAllStringSubmatch(content, -1) {
|
||||
if !seen[m[1]] {
|
||||
seen[m[1]] = true
|
||||
keys = append(keys, m[1])
|
||||
}
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
// settingInto fills a file's ${setting:…} placeholders from the layers over a module.
|
||||
//
|
||||
// The last layer setting a key wins, which is the node's over the mesh's — the same order settle
|
||||
// applies to a mergeable file. A value that is not a string is written the way a program would read
|
||||
// it (a number without a trailing .000000, a boolean as true/false).
|
||||
func settingInto(resource map[string]any, layers []Layer, module string) error {
|
||||
if fmt.Sprint(resource["type"]) != "file" {
|
||||
return nil
|
||||
}
|
||||
content, ok := resource["content"].(string)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
for _, key := range settingsUsed(content) {
|
||||
value, set := settingValue(layers, key)
|
||||
if !set {
|
||||
return fmt.Errorf(
|
||||
"%s has a file that says ${setting:%s}, and nothing sets %q for it — an operator's "+
|
||||
"value is the assignment's, never the definition's (novox/hq ADR 0112): "+
|
||||
"`settings set %s <file>` with {%q: …}%s",
|
||||
module, key, key, module, key, orNoSettings(layers))
|
||||
}
|
||||
content = strings.ReplaceAll(content, "${setting:"+key+"}", plainly(value))
|
||||
}
|
||||
resource["content"] = content
|
||||
return nil
|
||||
}
|
||||
|
||||
func settingValue(layers []Layer, key string) (any, bool) {
|
||||
var value any
|
||||
set := false
|
||||
for _, layer := range layers {
|
||||
if v, has := layer.Values[key]; has {
|
||||
value, set = v, true
|
||||
}
|
||||
}
|
||||
return value, set
|
||||
}
|
||||
|
||||
func orNoSettings(layers []Layer) string {
|
||||
var keys []string
|
||||
for _, l := range layers {
|
||||
for k := range l.Values {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
}
|
||||
if len(keys) == 0 {
|
||||
return "; no setting is set for this module"
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return "; set today: " + strings.Join(keys, ", ")
|
||||
}
|
||||
|
||||
// settingKeysUsedBy is every key a module's files ask for, so a setting that lands in one is not
|
||||
// called stray.
|
||||
func settingKeysUsedBy(m Manifest) map[string]bool {
|
||||
used := map[string]bool{}
|
||||
for _, r := range m.Resources {
|
||||
if fmt.Sprint(r["type"]) != "file" {
|
||||
continue
|
||||
}
|
||||
if content, ok := r["content"].(string); ok {
|
||||
for _, k := range settingsUsed(content) {
|
||||
used[k] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return used
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// An operator's value reaches a file from the assignment's settings, the node's layer over the
|
||||
// mesh's (novox/hq ADR 0112, ADR 0155), and a value nothing set is refused by name.
|
||||
func TestASettingReachesAFileFromTheLayers(t *testing.T) {
|
||||
file := map[string]any{"id": "env", "type": "file", "path": "/x/mail.env",
|
||||
"content": "DOMAIN=${setting:domain}\nSITENAME=${setting:sitename}\nWORKERS=${setting:workers}\n"}
|
||||
layers := []Layer{
|
||||
{From: "the mesh", Values: map[string]any{"domain": "example.tld", "sitename": "Mesh", "workers": float64(4)}},
|
||||
{From: "this node", Values: map[string]any{"sitename": "This one"}},
|
||||
}
|
||||
if err := settingInto(file, layers, "mail"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if file["content"] != "DOMAIN=example.tld\nSITENAME=This one\nWORKERS=4\n" {
|
||||
t.Fatalf("filled as %q", file["content"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestASettingNothingSetIsRefusedByName(t *testing.T) {
|
||||
file := map[string]any{"id": "env", "type": "file", "content": "DOMAIN=${setting:domain}\n"}
|
||||
err := settingInto(file, []Layer{{From: "the mesh", Values: map[string]any{"other": "x"}}}, "mail")
|
||||
if err == nil {
|
||||
t.Fatal("a setting nothing set was written as something")
|
||||
}
|
||||
for _, want := range []string{"${setting:domain}", "settings set mail", "set today: other"} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Fatalf("the refusal lacks %q: %v", want, err)
|
||||
}
|
||||
}
|
||||
// Left as it was: the literal placeholder must never reach a machine.
|
||||
if file["content"] != "DOMAIN=${setting:domain}\n" {
|
||||
t.Fatalf("content was changed on refusal: %q", file["content"])
|
||||
}
|
||||
}
|
||||
|
||||
// A key a file asks for is a destination, so setting it is not called stray.
|
||||
func TestASettingAFileAsksForIsNotStray(t *testing.T) {
|
||||
m := Manifest{Module: "mail", Resources: []map[string]any{
|
||||
{"id": "env", "type": "file", "content": "DOMAIN=${setting:domain}\n"},
|
||||
}}
|
||||
layers := []Layer{{From: "the mesh", Values: map[string]any{"domain": "example.tld", "stray": "x"}}}
|
||||
unused := strings.Join(UnusedSettings(m, layers), "; ")
|
||||
if strings.Contains(unused, `"domain"`) {
|
||||
t.Fatalf("a key a file asks for was called stray: %s", unused)
|
||||
}
|
||||
if !strings.Contains(unused, `"stray"`) {
|
||||
t.Fatalf("a key nothing reads was not named: %s", unused)
|
||||
}
|
||||
}
|
||||
@@ -173,9 +173,14 @@ func UnusedSettings(m Manifest, layers []Layer) []string {
|
||||
return nil
|
||||
}
|
||||
|
||||
// A key a file's content asks for with ${setting:<key>} is a destination too (ADR 0155).
|
||||
asked := settingKeysUsedBy(m)
|
||||
var unused []string
|
||||
for _, layer := range layers {
|
||||
for key := range layer.Values {
|
||||
if asked[key] {
|
||||
continue
|
||||
}
|
||||
// `expose` is a real destination for a module that listens: it overrides a port's
|
||||
// source (novox/hq ADR 0046), validated in Exposure, so it is not stray here.
|
||||
if key == ExposeSetting && len(m.Listens) > 0 {
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A short-form port may name its protocol — "3478/udp" — and the mesh assigns the number exactly
|
||||
// as it does for "3478": the protocol rides along on the outside. Read as one token, the suffix made
|
||||
// the entry "not a port" and the runtime published it on a random machine port (found on ace: unifi's
|
||||
// STUN and discovery, while every TCP pin beside them held).
|
||||
func TestAShortFormPortKeepsItsProtocolAndGetsItsMachinePort(t *testing.T) {
|
||||
container := map[string]any{"type": "container", "ports": []any{"3478/udp", "8443", "10001/udp"}}
|
||||
with := Rendering{
|
||||
Given: map[string]map[int]int{"unifi": {3478: 3478}},
|
||||
Ports: map[string]map[int]int{"unifi": {8443: 20010, 10001: 20011}},
|
||||
}
|
||||
publishedOn(container, "unifi", with)
|
||||
got := fmt.Sprint(container["ports"])
|
||||
want := "[3478:3478/udp 20010:8443 20011:10001/udp]"
|
||||
if got != want {
|
||||
t.Fatalf("published %s, want %s", got, want)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// A Verb is one tool a role serves: its name, what it does, and the schema of its arguments and of
|
||||
// its answer (novox/hq ADR 0132, design 33 §2).
|
||||
//
|
||||
// **A name alone is not callable by something that has never seen the mesh before**, which is the
|
||||
// whole population a tool surface exists for. So a seat's protocol carries the definition, in the
|
||||
// form an agent protocol already uses — a JSON schema for the input — so nothing translates between
|
||||
// a seat's idea of an argument and the caller's.
|
||||
//
|
||||
// A manifest may still write a bare verb name (`"serves": ["price"]`); that is a Verb with only a
|
||||
// name, and the module's runtime answers `tools` with the rest. The two forms read into one type so
|
||||
// nothing downstream cares which was written.
|
||||
type Verb struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Input map[string]any `json:"input,omitempty"`
|
||||
Output map[string]any `json:"output,omitempty"`
|
||||
}
|
||||
|
||||
func (v *Verb) UnmarshalJSON(raw []byte) error {
|
||||
trimmed := bytes.TrimSpace(raw)
|
||||
if len(trimmed) > 0 && trimmed[0] == '"' {
|
||||
var name string
|
||||
if err := json.Unmarshal(trimmed, &name); err != nil {
|
||||
return err
|
||||
}
|
||||
*v = Verb{Name: name}
|
||||
return nil
|
||||
}
|
||||
// Strictly, like the manifest around it: a misspelt key in a tool's definition would otherwise
|
||||
// describe a tool nobody can call and refuse nothing.
|
||||
type plain Verb
|
||||
var p plain
|
||||
decoder := json.NewDecoder(bytes.NewReader(trimmed))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&p); err != nil {
|
||||
return fmt.Errorf("a served verb is a name or {name, description, input, output}: %w", err)
|
||||
}
|
||||
if p.Name == "" {
|
||||
return fmt.Errorf("a served verb has no name: %s", trimmed)
|
||||
}
|
||||
*v = Verb(p)
|
||||
return nil
|
||||
}
|
||||
|
||||
// VerbNames are the names alone, for the grants and the checks that care about nothing else.
|
||||
func VerbNames(verbs []Verb) []string {
|
||||
out := make([]string, 0, len(verbs))
|
||||
for _, v := range verbs {
|
||||
out = append(out, v.Name)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ControllerSeatName is the seat the control plane holds, whose tools are the mesh's own verbs.
|
||||
const ControllerSeatName = "mesh-controller"
|
||||
|
||||
// ControllerVerbs are the mesh's own verbs, as the `mesh-controller` seat's tools (novox/hq ADR 0154).
|
||||
//
|
||||
// **The same function the command line calls, and nothing the tool adds** (ADR 0035): each of these
|
||||
// is a command the controller's binary already answers, run by the holder of the seat with the
|
||||
// arguments below and answered with what the command printed. A verb here is a contract every future
|
||||
// holder must implement, which is why the list is short and made of what an operator asks weekly.
|
||||
// Additive within a version (design 33 §7); a verb that would break a caller takes a new version.
|
||||
var ControllerVerbs = []Verb{
|
||||
{Name: "tools", Description: "Every seat's tools, from the mesh's own records: what each role " +
|
||||
"answers, whether or not its holder is up. The mesh's own verbs are the mesh-controller seat's.",
|
||||
Input: schema(nil, nil)},
|
||||
{Name: "status", Description: "What is wrong, what is quiet, what is out of date, and which " +
|
||||
"machines are behind what the mesh would send them.",
|
||||
Input: schema(nil, nil)},
|
||||
{Name: "nodes", Description: "Every machine the mesh knows, with whether it is converged or adopted.",
|
||||
Input: schema(nil, nil)},
|
||||
{Name: "node", Description: "What one machine reported it can do, what it is assigned, and why.",
|
||||
Input: schema(map[string]string{"node": "the machine's name"}, []string{"node"})},
|
||||
{Name: "modules", Description: "Every module the mesh holds: version, the commit it was built from, " +
|
||||
"and which machines run it.",
|
||||
Input: schema(nil, nil)},
|
||||
{Name: "seats", Description: "Every seat the mesh defines, what it delivers, and who holds it.",
|
||||
Input: schema(nil, nil)},
|
||||
{Name: "builds", Description: "What has been built lately and what came of it, for every module or for one.",
|
||||
Input: schema(map[string]string{"module": "one module's name; every module when absent"}, nil)},
|
||||
{Name: "plan", Description: "What one machine would run, and why: the declaration the mesh would send it.",
|
||||
Input: schema(map[string]string{"node": "the machine's name"}, []string{"node"})},
|
||||
{Name: "assign", Description: "Put a module on a machine. Refused with the mesh's own words when it cannot resolve there.",
|
||||
Input: schema(map[string]string{"node": "the machine's name", "module": "the module's name"}, []string{"node", "module"})},
|
||||
{Name: "unassign", Description: "Take a module off a machine.",
|
||||
Input: schema(map[string]string{"node": "the machine's name", "module": "the module's name"}, []string{"node", "module"})},
|
||||
{Name: "push", Description: "Send a machine everything it should be — or every machine that is behind, when no machine is named.",
|
||||
Input: schema(map[string]string{"node": "the machine's name; every machine behind when absent"}, nil)},
|
||||
{Name: "build", Description: "Have the build machine build a repository and record what came out.",
|
||||
Input: schema(map[string]string{
|
||||
"repository": "the repository's URL, or its path on the forge holding the git seat",
|
||||
"path": "the module's directory inside it (optional)",
|
||||
"ref": "the branch, tag or commit to build (optional)",
|
||||
}, []string{"repository"})},
|
||||
}
|
||||
|
||||
// schema is a JSON schema for an object of string properties, which is every argument the verbs
|
||||
// above take. Kept small on purpose: a schema an agent cannot read is a tool it cannot call.
|
||||
func schema(properties map[string]string, required []string) map[string]any {
|
||||
props := map[string]any{}
|
||||
for name, description := range properties {
|
||||
props[name] = map[string]any{"type": "string", "description": description}
|
||||
}
|
||||
out := map[string]any{"type": "object", "properties": props}
|
||||
if len(required) > 0 {
|
||||
out["required"] = required
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// unservedVerbs is what a seat promises and a claimant's `tools` does not answer.
|
||||
func unservedVerbs(tools []string, promised []Verb) []string {
|
||||
has := map[string]bool{}
|
||||
for _, t := range tools {
|
||||
has[t] = true
|
||||
}
|
||||
var missing []string
|
||||
for _, v := range promised {
|
||||
if !has[v.Name] {
|
||||
missing = append(missing, v.Name)
|
||||
}
|
||||
}
|
||||
return missing
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A served verb is written as a bare name or in full, and both read into one type (novox/hq ADR 0132).
|
||||
func TestAServedVerbIsANameOrADefinition(t *testing.T) {
|
||||
m, err := ParseManifest([]byte(`{"module":"till","version":"1","tools":["price","refund"],` +
|
||||
`"seats":[{"name":"shop-till","serves":["price",{"name":"refund","description":"give it back",` +
|
||||
`"input":{"type":"object","properties":{"order":{"type":"string"}}}}]}],` +
|
||||
`"claims":[{"name":"shop-till","scope":"mesh"}]}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := m.DefinesSeats[0].Serves
|
||||
if len(got) != 2 || got[0].Name != "price" || got[1].Name != "refund" || got[1].Description != "give it back" {
|
||||
t.Fatalf("verbs not read: %+v", got)
|
||||
}
|
||||
if got[1].Input["type"] != "object" {
|
||||
t.Fatalf("the schema did not travel with the verb: %+v", got[1].Input)
|
||||
}
|
||||
}
|
||||
|
||||
// A misspelt key inside a verb's definition is refused, like one anywhere else in the manifest.
|
||||
func TestAVerbWithAnUnknownKeyIsRefused(t *testing.T) {
|
||||
_, err := ParseManifest([]byte(`{"module":"till","version":"1",` +
|
||||
`"seats":[{"name":"shop-till","serves":[{"name":"price","descripton":"typo"}]}]}`))
|
||||
if err == nil || !strings.Contains(err.Error(), "descripton") {
|
||||
t.Fatalf("a verb with a misspelt key was accepted: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Holding a mesh seat that serves verbs requires serving them, and the refusal names the verbs.
|
||||
func TestHoldingAMeshSeatRequiresServingItsVerbs(t *testing.T) {
|
||||
was := Seats()
|
||||
t.Cleanup(func() { UseSeats(was) })
|
||||
UseSeats([]Seat{{Name: "mesh-controller", Scope: ScopeMesh, Decision: "test",
|
||||
Serves: []Verb{{Name: "status"}, {Name: "push"}}}})
|
||||
seat, _ := SeatNamed("mesh-controller")
|
||||
|
||||
partial := Manifest{Module: "a-controller", Tools: []string{"status"},
|
||||
Claims: []Claim{{Name: "mesh-controller", Scope: ScopeMesh}}}
|
||||
err := CanHold(partial, seat)
|
||||
if err == nil || !strings.Contains(err.Error(), "does not serve push") {
|
||||
t.Fatalf("a holder missing a verb was not refused by name: %v", err)
|
||||
}
|
||||
whole := Manifest{Module: "a-controller", Tools: []string{"status", "push"},
|
||||
Claims: []Claim{{Name: "mesh-controller", Scope: ScopeMesh}}}
|
||||
if err := CanHold(whole, seat); err != nil {
|
||||
t.Fatalf("a holder serving every verb was refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The mesh's own verbs are declared in full: an agent cannot call a name without a schema.
|
||||
func TestEveryControllerVerbIsDescribedWithASchema(t *testing.T) {
|
||||
seen := map[string]bool{}
|
||||
for _, v := range ControllerVerbs {
|
||||
if v.Description == "" || v.Input == nil || v.Input["type"] != "object" {
|
||||
t.Errorf("%s: no description or no object schema", v.Name)
|
||||
}
|
||||
if seen[v.Name] {
|
||||
t.Errorf("%s declared twice", v.Name)
|
||||
}
|
||||
seen[v.Name] = true
|
||||
}
|
||||
seat, _ := SeatNamed(ControllerSeatName)
|
||||
if len(seat.Serves) != len(ControllerVerbs) {
|
||||
t.Fatalf("the compiled mesh-controller seat serves %d verbs, the table has %d", len(seat.Serves), len(ControllerVerbs))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A component is unpacked into a directory named for its version, so it can read its own version from
|
||||
// its path (novox/hq ADR 0141, 0142). Until this, nothing could compose that path: an archive named a
|
||||
// fixed one and nothing interpolated the build into it, so nothing could ask for
|
||||
// `…/versions/<version>/` and every machine took a hand-placed fallback (04-ISSUES/142).
|
||||
|
||||
const aDigest = "sha256:ad62528c47c7b4a71cf814473f5de52a061348ce9521f707b0171a10fa6b247f"
|
||||
|
||||
func TestAnArchivePathCanNameTheBuildsOwnVersion(t *testing.T) {
|
||||
m := Manifest{
|
||||
Module: "mesh-host",
|
||||
Build: &Build{Artifacts: []Artifact{{Name: "host-arch", Kind: ArtifactBundle, Language: "go", System: "arch"}}},
|
||||
Resources: []map[string]any{{
|
||||
"id": "next", "type": "archive", "artifact": "host-arch",
|
||||
"path": "/usr/lib/nox-mesh-host/versions/${version}",
|
||||
}},
|
||||
}
|
||||
got, err := m.Resolve([]Built{{Name: "host-arch", Kind: ArtifactBundle,
|
||||
Reference: "artifact-store://mesh-host/host-arch", Digest: aDigest}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
path, _ := got.Resources[0]["path"].(string)
|
||||
if strings.Contains(path, "${version}") {
|
||||
t.Fatalf("the version was not resolved: %q", path)
|
||||
}
|
||||
if path != "/usr/lib/nox-mesh-host/versions/ad62528c47c7" {
|
||||
t.Fatalf("the path resolved to %q", path)
|
||||
}
|
||||
// The artifact key goes, as it does for every resolved resource: it is a build-time word and the
|
||||
// host has never heard of it.
|
||||
if _, still := got.Resources[0]["artifact"]; still {
|
||||
t.Fatal("the artifact key survived resolution")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheVersionIsTheDigestSoAnUnchangedBuildKeepsItsPath(t *testing.T) {
|
||||
// The alternative is the commit, and two builds of one commit are meant to be the same bytes —
|
||||
// every toolchain here is -trimpath for that reason. A commit-named path would move for an
|
||||
// identical binary and recreate everything reading it.
|
||||
first := versionOf(aDigest)
|
||||
again := versionOf(aDigest)
|
||||
if first != again || first == "" {
|
||||
t.Fatalf("the same bytes produced %q and %q", first, again)
|
||||
}
|
||||
if other := versionOf("sha256:" + strings.Repeat("b", 64)); other == first {
|
||||
t.Fatal("different bytes produced the same version")
|
||||
}
|
||||
// A path is read by people and quoted by shells.
|
||||
if strings.ContainsAny(first, ":/ ") {
|
||||
t.Fatalf("the version is not safe in a path: %q", first)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnImageIsRefusedAVersionedPlace(t *testing.T) {
|
||||
// An image is not unpacked, so it has no directory to be named for its version. Left as literal
|
||||
// text it would reach a machine and be created as a directory called ${version}.
|
||||
m := Manifest{
|
||||
Module: "something",
|
||||
Build: &Build{Artifacts: []Artifact{{Name: "server", Kind: ArtifactImage, From: "Dockerfile"}}},
|
||||
Resources: []map[string]any{{
|
||||
"id": "where", "type": "directory", "artifact": "server",
|
||||
"path": "/var/lib/something/${version}",
|
||||
}},
|
||||
}
|
||||
_, err := m.Resolve([]Built{{Name: "server", Kind: ArtifactImage, Reference: "registry/x@" + aDigest}})
|
||||
if err == nil {
|
||||
t.Fatal("an image was given a versioned place")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "not unpacked") {
|
||||
t.Fatalf("the refusal does not say why: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAResourceWithoutAVersionReferenceIsUntouched(t *testing.T) {
|
||||
m := Manifest{
|
||||
Module: "mesh-host",
|
||||
Build: &Build{Artifacts: []Artifact{{Name: "host-arch", Kind: ArtifactBundle, Language: "go", System: "arch"}}},
|
||||
Resources: []map[string]any{{
|
||||
"id": "next", "type": "archive", "artifact": "host-arch", "path": "/usr/lib/fixed",
|
||||
}},
|
||||
}
|
||||
got, err := m.Resolve([]Built{{Name: "host-arch", Kind: ArtifactBundle,
|
||||
Reference: "artifact-store://mesh-host/host-arch", Digest: aDigest}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if path, _ := got.Resources[0]["path"].(string); path != "/usr/lib/fixed" {
|
||||
t.Fatalf("a path naming no version became %q", path)
|
||||
}
|
||||
}
|
||||
@@ -118,6 +118,8 @@ func declaredFor(m catalogue.Manifest, seats map[string]catalogue.SeatDeclaratio
|
||||
// The tools it answers, which is `tools` and not `serves`: the manifest's `serves` is the
|
||||
// facts a consumer needs to reach a provision, a different meaning under a similar word.
|
||||
Serves: m.Tools,
|
||||
// And what it calls (novox/hq ADR 0152) — the console's `*`, nothing else's.
|
||||
Invokes: m.Invokes,
|
||||
}
|
||||
for _, c := range m.Claims {
|
||||
// Every seat with a protocol, the mesh's own included. One that says only who does a job is
|
||||
@@ -135,7 +137,8 @@ func declaredFor(m catalogue.Manifest, seats map[string]catalogue.SeatDeclaratio
|
||||
}
|
||||
|
||||
func asSeat(s catalogue.SeatDeclaration) broker.Seat {
|
||||
return broker.Seat{Name: s.Name, Accepts: s.Accepts, Emits: s.Emits, Serves: s.Serves}
|
||||
return broker.Seat{Name: s.Name, Scope: s.Scope, Accepts: s.Accepts, Emits: s.Emits,
|
||||
Serves: catalogue.VerbNames(s.Serves)}
|
||||
}
|
||||
|
||||
// MeshSeats are the mesh's own seats that carry a protocol, as the bus needs them: what to make a work
|
||||
@@ -144,7 +147,7 @@ func MeshSeats() []broker.DeclaredSeat {
|
||||
var out []broker.DeclaredSeat
|
||||
for _, s := range catalogue.SeatsWithAProtocol() {
|
||||
out = append(out, broker.DeclaredSeat{
|
||||
Name: s.Name, Accepts: s.Accepts, Emits: s.Emits, Serves: s.Serves,
|
||||
Name: s.Name, Accepts: s.Accepts, Emits: s.Emits, Serves: catalogue.VerbNames(s.Serves),
|
||||
})
|
||||
}
|
||||
return out
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
|
||||
"github.com/novox/mesh-controller/internal/broker"
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// Reading the bus's user list out of the mesh's records, against a real store.
|
||||
@@ -164,3 +165,63 @@ func granted(all []string, one string) bool {
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// **A token is an account on the bus, or it is a string nothing accepts** (novox/hq 04-ISSUES/146).
|
||||
//
|
||||
// The composed list names an enrolment user for every machine with a live token, and nothing minted
|
||||
// a credential for it — so the composer left it out as a user with no password, and every enrolment
|
||||
// since the mesh moved to this bus was refused by the server before the mesh heard of it. Nothing
|
||||
// caught it because nothing had enrolled since.
|
||||
//
|
||||
// The password cannot be minted, because it is the token's own secret: the machine will present
|
||||
// exactly that string. So this checks the two halves that make the account usable — that a row
|
||||
// exists under the name the composer asks for, and that the secret handed out is what that row
|
||||
// accepts.
|
||||
func TestIssuingATokenRecordsTheAccountItIsThePasswordOf(t *testing.T) {
|
||||
inv, ctx := aMeshWith(t)
|
||||
if _, err := inv.AddNode(ctx, "joiner"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
issued, err := inv.IssueToken(ctx, "joiner", time.Hour)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
name := broker.Principal{Kind: broker.KindEnrolment, Node: "joiner"}.Username()
|
||||
users, err := inv.BusUsers(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
user, has := users[name]
|
||||
if !has {
|
||||
t.Fatalf("no bus account for %q; the composer would leave the enrolment out and the "+
|
||||
"machine would be refused before the mesh heard of it: %v", name, users)
|
||||
}
|
||||
if user.Kind != BusEnrolment || user.Node != "joiner" {
|
||||
t.Errorf("the account is %+v, not this node's enrolment", user)
|
||||
}
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(issued.Secret)); err != nil {
|
||||
t.Error("the account does not accept the secret the token carries, so presenting the " +
|
||||
"token would be refused by the server")
|
||||
}
|
||||
|
||||
// And the composition contains it, which is the thing the server reads.
|
||||
records, err := inv.BusRecords(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
derived, err := broker.Users(records)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hashes := map[string]string{}
|
||||
for n, u := range users {
|
||||
hashes[n] = u.PasswordHash
|
||||
}
|
||||
_, missing := broker.WithPasswords(derived, hashes)
|
||||
for _, m := range missing {
|
||||
if m == name {
|
||||
t.Fatal("the enrolment user is composed without a password, which is a user nobody can be")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,6 +51,40 @@ const (
|
||||
// reply, into a module's sealed environment — and the mesh keeps only the hash, so a credential is
|
||||
// never recoverable from the store. A caller that loses it must mint again, which is a rotation and
|
||||
// is meant to feel like one.
|
||||
// RecordBusPassword records a hash for a password the caller already holds.
|
||||
//
|
||||
// **For the one credential the mesh does not choose**: an enrolment token's secret is the password
|
||||
// of the user that presents it (novox/hq ADR 0004, design 25 §6), so the token cannot be given a
|
||||
// minted password — it already has one, and the machine will connect with exactly that string.
|
||||
// Everything else goes through Mint, which chooses and returns the plaintext once.
|
||||
func (i *Inventory) RecordBusPassword(ctx context.Context, u BusUser, password string) error {
|
||||
if u.Username == "" || u.Kind == "" {
|
||||
return errors.New("a bus user needs a username and a kind")
|
||||
}
|
||||
if password == "" {
|
||||
return errors.New("a bus user needs a password")
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot hash a bus password: %w", err)
|
||||
}
|
||||
return i.writeBusUser(ctx, u, string(hash))
|
||||
}
|
||||
|
||||
// writeBusUser is the row, whoever chose the password.
|
||||
func (i *Inventory) writeBusUser(ctx context.Context, u BusUser, hash string) error {
|
||||
if _, err := i.store.Pool().Exec(ctx,
|
||||
`insert into bus_user (username, kind, node, module, password_hash)
|
||||
values ($1, $2, $3, $4, $5)
|
||||
on conflict (username) do update
|
||||
set kind = excluded.kind, node = excluded.node, module = excluded.module,
|
||||
password_hash = excluded.password_hash, minted_at = now()`,
|
||||
u.Username, u.Kind, u.Node, u.Module, hash); err != nil {
|
||||
return fmt.Errorf("cannot record the bus user %s: %w", u.Username, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (i *Inventory) MintBusPassword(ctx context.Context, u BusUser) (string, error) {
|
||||
if u.Username == "" || u.Kind == "" {
|
||||
return "", errors.New("a bus user needs a username and a kind")
|
||||
@@ -69,14 +103,8 @@ func (i *Inventory) MintBusPassword(ctx context.Context, u BusUser) (string, err
|
||||
return "", fmt.Errorf("cannot hash a bus password: %w", err)
|
||||
}
|
||||
|
||||
if _, err := i.store.Pool().Exec(ctx,
|
||||
`insert into bus_user (username, kind, node, module, password_hash)
|
||||
values ($1, $2, $3, $4, $5)
|
||||
on conflict (username) do update
|
||||
set kind = excluded.kind, node = excluded.node, module = excluded.module,
|
||||
password_hash = excluded.password_hash, minted_at = now()`,
|
||||
u.Username, u.Kind, u.Node, u.Module, string(hash)); err != nil {
|
||||
return "", fmt.Errorf("cannot record the bus user %s: %w", u.Username, err)
|
||||
if err := i.writeBusUser(ctx, u, string(hash)); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return password, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
-- The version of the host running on a machine, as the machine reports it.
|
||||
--
|
||||
-- novox/hq 04-ISSUES/087. A host parses a declaration strictly: a field it does not know makes it
|
||||
-- refuse the whole declaration and apply nothing. That is deliberate — it keeps a half-understood
|
||||
-- declaration off a machine — and it makes every new field in a declaration a flag day, hosts before
|
||||
-- controller. The mesh had no record of which host a machine runs, so it could neither refuse to send
|
||||
-- a declaration a machine cannot parse nor say which machines were behind. The order was kept by
|
||||
-- somebody remembering it.
|
||||
--
|
||||
-- The machine has been reporting this since ADR 0141 and the control plane discarded it: the field was
|
||||
-- absent from the controller's own copy of the report, so it was unmarshalled into nothing.
|
||||
--
|
||||
-- Null for a machine that has not reported since this column existed, which is not the same as a
|
||||
-- machine running no host — so a reader is never told a version the mesh does not have.
|
||||
alter table node add column host_version text;
|
||||
@@ -0,0 +1,11 @@
|
||||
-- The order of what a machine was sent, so a host can tell an older declaration from a newer.
|
||||
--
|
||||
-- novox/hq 04-ISSUES/107. A declaration's only identity was the digest of its bytes. The host could
|
||||
-- say "this is not the last one" and could not say "this is older", so a backlog drained out of
|
||||
-- order applied a declaration the mesh had already superseded. The controller already holds a
|
||||
-- per-node lock while it composes and records each send — the order existed and was thrown away at
|
||||
-- the wire.
|
||||
--
|
||||
-- One counter per node, taken under that hold, one higher per send. Null is a machine sent nothing
|
||||
-- since this existed, which is not the same as a machine sent nothing.
|
||||
alter table node add column sequence bigint;
|
||||
@@ -0,0 +1,12 @@
|
||||
-- A seat's protocol lives in the store, not in the binary (novox/hq ADR 0129, ADR 0132, design 33 §2).
|
||||
--
|
||||
-- ADR 0122 moved the seat set into this table with name, scope, delivers and decision, and the
|
||||
-- protocol — what a role accepts, emits and serves — stayed compiled into the control plane and was
|
||||
-- merged in as a row was read. Discovery that reads a binary disagrees with the mesh the moment the
|
||||
-- two are on different versions, and a tool without a schema is not something an agent can call. So
|
||||
-- the three halves become columns: accepts and emits as lists of verbs, serves as the verbs in full
|
||||
-- ({name, description, input, output}). Seeded from the compiled defaults where a row has none,
|
||||
-- additively thereafter (a verb a release adds joins the row; nothing is taken away).
|
||||
alter table seat add column accepts jsonb not null default '[]'::jsonb;
|
||||
alter table seat add column emits jsonb not null default '[]'::jsonb;
|
||||
alter table seat add column serves jsonb not null default '[]'::jsonb;
|
||||
@@ -0,0 +1,12 @@
|
||||
-- The artifact store's seat is named for its scope, like the mesh's other seats (novox/hq ADR 0121,
|
||||
-- ADR 0156, issue 123).
|
||||
--
|
||||
-- `the-artifact-store` was the last of the mesh's own seats named for the job it happened to do rather
|
||||
-- than for the mesh; ADR 0121 decided the rename and deferred it because a delivering seat that stops
|
||||
-- resolving mid-flight takes a provision away from every consumer. ADR 0122 removed that risk: a seat's
|
||||
-- former name is an alias that resolves to it forever, a held record follows the rename by cascade, and
|
||||
-- a claim written with the old name still holds. So the rename is one update and one alias.
|
||||
update seat set name = 'mesh-artifact-store' where name = 'the-artifact-store';
|
||||
insert into seat_alias (alias, seat) values ('the-artifact-store', 'mesh-artifact-store')
|
||||
on conflict (alias) do update set seat = excluded.seat;
|
||||
update seat_alias set seat = 'mesh-artifact-store' where seat = 'the-artifact-store';
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/novox/mesh-controller/internal/broker"
|
||||
"github.com/novox/mesh-controller/internal/store"
|
||||
)
|
||||
|
||||
@@ -62,6 +63,11 @@ type Node struct {
|
||||
// entry. What decides who a file under a home is owned by, and which account `ssh <node>` uses.
|
||||
Account string
|
||||
AccountHome string
|
||||
|
||||
// HostVersion is the version of the host this machine reported running (novox/hq 04-ISSUES/087).
|
||||
// Empty when it has not said since the mesh began keeping it — which is not the same as running
|
||||
// no host, so nothing derives "behind" from an empty one.
|
||||
HostVersion string
|
||||
}
|
||||
|
||||
// Home is the account's home directory, derived when not stored: /root for root, /home/<account>
|
||||
@@ -135,15 +141,20 @@ func (i *Inventory) AddNodeAs(ctx context.Context, name string, adopted bool) (N
|
||||
|
||||
// nodeColumns and scanNode are the one reading of a node row, so every way of finding a node
|
||||
// says whether it is adopted.
|
||||
const nodeColumns = `id, name, created, last_seen, adopted, adopted_since, account, account_home`
|
||||
const nodeColumns = `id, name, created, last_seen, adopted, adopted_since, account, account_home,
|
||||
host_version`
|
||||
|
||||
func scanNode(row pgx.Row) (Node, error) {
|
||||
var n Node
|
||||
var seen, since *time.Time
|
||||
var host *string
|
||||
if err := row.Scan(&n.ID, &n.Name, &n.Created, &seen, &n.Adopted, &since,
|
||||
&n.Account, &n.AccountHome); err != nil {
|
||||
&n.Account, &n.AccountHome, &host); err != nil {
|
||||
return Node{}, err
|
||||
}
|
||||
if host != nil {
|
||||
n.HostVersion = *host
|
||||
}
|
||||
if seen != nil {
|
||||
n.LastSeen = *seen
|
||||
}
|
||||
@@ -263,6 +274,29 @@ func (i *Inventory) IssueToken(ctx context.Context, nodeName string, validFor ti
|
||||
return Issued{}, err
|
||||
}
|
||||
|
||||
// **And the account that secret is the password of** (novox/hq 04-ISSUES/146). The composed
|
||||
// user list names an enrolment user for every node with a live token, and nothing minted a
|
||||
// credential for it — so the composer left it out as a user with no password and every
|
||||
// enrolment was refused by the server before the mesh heard of it.
|
||||
//
|
||||
// Recorded rather than minted: the token's secret IS the password, which is what lets a
|
||||
// machine's first connection be authenticated by the thing it is enrolling with. It cannot be
|
||||
// chosen here, because it has already been handed to whoever will present it.
|
||||
//
|
||||
// Outside the transaction on purpose. The token is what the mesh promised; a credential that
|
||||
// the next composition rewrites anyway is not worth failing an issue over, and a token with no
|
||||
// account is recoverable by issuing another, while an account with no token is a user nobody
|
||||
// can be.
|
||||
if err := i.RecordBusPassword(ctx, BusUser{
|
||||
Username: broker.Principal{Kind: broker.KindEnrolment, Node: node.Name}.Username(),
|
||||
Kind: BusEnrolment,
|
||||
Node: node.Name,
|
||||
}, secret); err != nil {
|
||||
return Issued{}, fmt.Errorf(
|
||||
"the token for %s was issued and the bus account it is the password of was not "+
|
||||
"recorded, so this token cannot connect: %w", node.Name, err)
|
||||
}
|
||||
|
||||
return Issued{Node: node, Secret: secret, Expires: expires}, nil
|
||||
}
|
||||
|
||||
@@ -956,3 +990,49 @@ type Machine struct {
|
||||
// being out of date and reads differently to whoever is looking.
|
||||
Never bool
|
||||
}
|
||||
|
||||
// RecordHostVersion keeps the version of the host a machine reported running (novox/hq 04-ISSUES/087).
|
||||
//
|
||||
// Never cleared by a report that carries none: a bare word that the node is there says nothing about
|
||||
// its host, and a machine whose host predates ADR 0141 reports none at all. So an empty version means
|
||||
// the mesh has not been told, and the caller does not write it.
|
||||
func (i *Inventory) RecordHostVersion(ctx context.Context, id, version string) error {
|
||||
version = strings.TrimSpace(version)
|
||||
if version == "" {
|
||||
return nil
|
||||
}
|
||||
_, err := i.store.Pool().Exec(ctx,
|
||||
`update node set host_version = $2, last_seen = now() where id = $1`, id, version)
|
||||
return err
|
||||
}
|
||||
|
||||
// NextSequence takes the next number for a declaration to this node, one higher than the last it
|
||||
// was sent (novox/hq 04-ISSUES/107).
|
||||
//
|
||||
// **One statement, so two composers cannot take the same number.** The caller holds the node while it
|
||||
// composes and sends, so in practice there is one; the increment is atomic anyway, because a rule
|
||||
// that is true only while a lock is held somewhere else is a rule nobody can see from here.
|
||||
func (i *Inventory) NextSequence(ctx context.Context, id string) (int64, error) {
|
||||
var n int64
|
||||
err := i.store.Pool().QueryRow(ctx,
|
||||
`update node set sequence = coalesce(sequence, 0) + 1 where id = $1 returning sequence`, id).Scan(&n)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("taking the next sequence for %s: %w", id, err)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// Sequence is the number of the last declaration this node was sent, and zero for one sent nothing
|
||||
// since sends were numbered. Read, not taken: what the mesh WOULD send is composed with this, so it
|
||||
// is byte for byte what it DID send when nothing else changed — a comparison that took a fresh number
|
||||
// would read every machine as behind for ever (novox/hq 04-ISSUES/107).
|
||||
func (i *Inventory) Sequence(ctx context.Context, id string) (int64, error) {
|
||||
var n *int64
|
||||
if err := i.store.Pool().QueryRow(ctx, `select sequence from node where id = $1`, id).Scan(&n); err != nil {
|
||||
return 0, fmt.Errorf("reading the sequence of %s: %w", id, err)
|
||||
}
|
||||
if n == nil {
|
||||
return 0, nil
|
||||
}
|
||||
return *n, nil
|
||||
}
|
||||
|
||||
+115
-7
@@ -2,6 +2,7 @@ package inventory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
@@ -17,7 +18,7 @@ import (
|
||||
// Seats is every seat the mesh defines, read from the store.
|
||||
func (i *Inventory) Seats(ctx context.Context) ([]catalogue.Seat, error) {
|
||||
rows, err := i.store.Pool().Query(ctx,
|
||||
`select name, scope, delivers, decided from seat order by name`)
|
||||
`select name, scope, delivers, decided, accepts, emits, serves from seat order by name`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -26,9 +27,21 @@ func (i *Inventory) Seats(ctx context.Context) ([]catalogue.Seat, error) {
|
||||
var seats []catalogue.Seat
|
||||
for rows.Next() {
|
||||
var s catalogue.Seat
|
||||
if err := rows.Scan(&s.Name, &s.Scope, &s.Delivers, &s.Decision); err != nil {
|
||||
var accepts, emits, serves []byte
|
||||
if err := rows.Scan(&s.Name, &s.Scope, &s.Delivers, &s.Decision, &accepts, &emits, &serves); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// The protocol, from the row (novox/hq ADR 0132). A row that predates the columns has empty
|
||||
// lists, and UseSeats keeps the compiled protocol for it until the next seeding fills them.
|
||||
if err := json.Unmarshal(accepts, &s.Accepts); err != nil {
|
||||
return nil, fmt.Errorf("seat %s: accepts: %w", s.Name, err)
|
||||
}
|
||||
if err := json.Unmarshal(emits, &s.Emits); err != nil {
|
||||
return nil, fmt.Errorf("seat %s: emits: %w", s.Name, err)
|
||||
}
|
||||
if err := json.Unmarshal(serves, &s.Serves); err != nil {
|
||||
return nil, fmt.Errorf("seat %s: serves: %w", s.Name, err)
|
||||
}
|
||||
seats = append(seats, s)
|
||||
}
|
||||
return seats, rows.Err()
|
||||
@@ -41,21 +54,116 @@ func (i *Inventory) Seats(ctx context.Context) ([]catalogue.Seat, error) {
|
||||
// exactly as it is, so an operator's rename in the table is not undone by the next deploy putting
|
||||
// the old name back. What a release removes from the defaults is not deleted here either; retiring a
|
||||
// seat is its own decision, not a silent consequence of it dropping out of the binary.
|
||||
//
|
||||
// **The protocol is seeded additively** (novox/hq ADR 0132, design 33 §7). A row that has none takes
|
||||
// the compiled protocol whole — that is the compiled fallback becoming data, once. A row that has one
|
||||
// gains any verb the defaults name and it lacks, and loses nothing: a seat's tools are an interface,
|
||||
// additive within a version, and a verb an operator added to the row is theirs to keep.
|
||||
func (i *Inventory) SeedSeats(ctx context.Context, defaults []catalogue.Seat) (int, error) {
|
||||
var added int
|
||||
for _, s := range defaults {
|
||||
tag, err := i.store.Pool().Exec(ctx,
|
||||
`insert into seat (name, scope, delivers, decided) values ($1, $2, $3, $4)
|
||||
on conflict (name) do nothing`,
|
||||
s.Name, s.Scope, s.Delivers, s.Decision)
|
||||
accepts, emits, serves, err := protocolJSON(s)
|
||||
if err != nil {
|
||||
return added, err
|
||||
}
|
||||
added += int(tag.RowsAffected())
|
||||
tag, err := i.store.Pool().Exec(ctx,
|
||||
`insert into seat (name, scope, delivers, decided, accepts, emits, serves)
|
||||
values ($1, $2, $3, $4, $5, $6, $7)
|
||||
on conflict (name) do nothing`,
|
||||
s.Name, s.Scope, s.Delivers, s.Decision, accepts, emits, serves)
|
||||
if err != nil {
|
||||
return added, err
|
||||
}
|
||||
if n := int(tag.RowsAffected()); n > 0 {
|
||||
added += n
|
||||
continue
|
||||
}
|
||||
if err := i.widenProtocol(ctx, s); err != nil {
|
||||
return added, err
|
||||
}
|
||||
}
|
||||
return added, nil
|
||||
}
|
||||
|
||||
// widenProtocol adds to a seat's row whatever the defaults name and the row lacks, by verb name.
|
||||
func (i *Inventory) widenProtocol(ctx context.Context, s catalogue.Seat) error {
|
||||
var accepts, emits, serves []byte
|
||||
if err := i.store.Pool().QueryRow(ctx,
|
||||
`select accepts, emits, serves from seat where name = $1`, s.Name).Scan(&accepts, &emits, &serves); err != nil {
|
||||
return err
|
||||
}
|
||||
var row catalogue.Seat
|
||||
if err := json.Unmarshal(accepts, &row.Accepts); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := json.Unmarshal(emits, &row.Emits); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := json.Unmarshal(serves, &row.Serves); err != nil {
|
||||
return err
|
||||
}
|
||||
changed := false
|
||||
row.Accepts, changed = union(row.Accepts, s.Accepts, changed)
|
||||
row.Emits, changed = union(row.Emits, s.Emits, changed)
|
||||
have := map[string]bool{}
|
||||
for _, v := range row.Serves {
|
||||
have[v.Name] = true
|
||||
}
|
||||
for _, v := range s.Serves {
|
||||
if !have[v.Name] {
|
||||
row.Serves = append(row.Serves, v)
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if !changed {
|
||||
return nil
|
||||
}
|
||||
a, e, sv, err := protocolJSON(row)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = i.store.Pool().Exec(ctx,
|
||||
`update seat set accepts = $2, emits = $3, serves = $4 where name = $1`, s.Name, a, e, sv)
|
||||
return err
|
||||
}
|
||||
|
||||
func union(have, want []string, changed bool) ([]string, bool) {
|
||||
seen := map[string]bool{}
|
||||
for _, h := range have {
|
||||
seen[h] = true
|
||||
}
|
||||
for _, w := range want {
|
||||
if !seen[w] {
|
||||
have = append(have, w)
|
||||
seen[w] = true
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
return have, changed
|
||||
}
|
||||
|
||||
func protocolJSON(s catalogue.Seat) (accepts, emits, serves []byte, err error) {
|
||||
if accepts, err = json.Marshal(orEmpty(s.Accepts)); err != nil {
|
||||
return
|
||||
}
|
||||
if emits, err = json.Marshal(orEmpty(s.Emits)); err != nil {
|
||||
return
|
||||
}
|
||||
verbs := s.Serves
|
||||
if verbs == nil {
|
||||
verbs = []catalogue.Verb{}
|
||||
}
|
||||
serves, err = json.Marshal(verbs)
|
||||
return
|
||||
}
|
||||
|
||||
func orEmpty(s []string) []string {
|
||||
if s == nil {
|
||||
return []string{}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// Aliases is every former seat name and the seat it now resolves to (novox/hq ADR 0122).
|
||||
func (i *Inventory) Aliases(ctx context.Context) (map[string]string, error) {
|
||||
rows, err := i.store.Pool().Query(ctx, `select alias, seat from seat_alias`)
|
||||
|
||||
@@ -43,6 +43,12 @@ type BuildRequest struct {
|
||||
// written in a manifest the mesh has not read: it is inside the repository, and reading it is
|
||||
// the build's first act.
|
||||
Held map[string]string `json:"held,omitempty"`
|
||||
// Seats is the clone base — `scheme://host:port` — of each seat a recipe's context may name
|
||||
// (novox/hq ADR 0155): `git` for this mesh's own forge. Sent with the asking for the reason
|
||||
// Held is: the context is written in a manifest the mesh has not read, and only the mesh knows
|
||||
// which forge holds the seat here. A builder handed no base for a seat a context names refuses
|
||||
// the build and says so.
|
||||
Seats map[string]string `json:"seats,omitempty"`
|
||||
}
|
||||
|
||||
// BuildResult is what a builder says back.
|
||||
|
||||
@@ -312,6 +312,14 @@ func (e Enrolment) Heard(ctx context.Context, report Report) (news bool, err err
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
// Which host produced this report (novox/hq 04-ISSUES/087), whenever it says. Recorded on every
|
||||
// report that carries it and never cleared by one that does not — a bare word that the node is
|
||||
// there says nothing about its host, and a machine whose host predates this reports none.
|
||||
if report.Host != "" {
|
||||
if err := e.Inventory.RecordHostVersion(ctx, node.ID, report.Host); err != nil {
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
// What it says about the tunnel it carried (novox/hq ADR 0105), whenever it says it.
|
||||
if report.Tunnel != nil {
|
||||
if err := e.Inventory.RecordCarriedTunnel(ctx, node.ID, inventory.Carried{
|
||||
|
||||
@@ -184,6 +184,15 @@ type Report struct {
|
||||
// leaves the one it has: a rule written around a link with no name is a rule set that does not
|
||||
// load, and that is a machine filtering nothing while its unit reports success.
|
||||
Outward []string `json:"outward,omitempty"`
|
||||
|
||||
// Host is the version of the host that produced this report (novox/hq ADR 0141).
|
||||
//
|
||||
// **The machine has sent this since 0141 and this struct did not have it**, so it was
|
||||
// unmarshalled into nothing and the mesh could not say which host any machine runs
|
||||
// (novox/hq 04-ISSUES/087). A host refuses a declaration carrying a field it does not know, and
|
||||
// refuses it whole — which is right, and makes every new field a flag day that the mesh could
|
||||
// not see coming.
|
||||
Host string `json:"host,omitempty"`
|
||||
// Reachable is what can be reached on the machine now: every listening socket and every
|
||||
// published container port. Only an adopted node reports it; it is what converging previews.
|
||||
Reachable []Reach `json:"reachable,omitempty"`
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
package link
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats.go"
|
||||
)
|
||||
|
||||
// A role's tools, served by its holder (novox/hq ADR 0132, ADR 0154).
|
||||
//
|
||||
// The mesh's own verbs — `status`, `push`, `assign` — are the mesh-controller seat's tools, and the
|
||||
// control plane is that seat's holder. So it answers them here, on the seat's subjects, the way a
|
||||
// module's runtime answers a module's: one request, one reply on the asker's own inbox, `{result}` or
|
||||
// `{error}`. Nothing about the transport is the command's business; a handler is a function of its
|
||||
// arguments and gets the same answer the command line prints.
|
||||
|
||||
// ToolHandler answers one call of a role's tool. What it returns is marshalled as the result; an
|
||||
// error is the tool answering with one, which is an answer and not a timeout.
|
||||
type ToolHandler func(ctx context.Context, args json.RawMessage) (any, error)
|
||||
|
||||
// SeatToolSubject is where a mesh-scoped seat's tool is asked (design 33 §4).
|
||||
func SeatToolSubject(seat, verb string) string { return "mesh.seat." + seat + ".tool." + verb }
|
||||
|
||||
// HandlerTimeout bounds one answer. A verb that runs a command — a push, a build with no wait —
|
||||
// answers in seconds; anything that has not in this long is said to have not answered.
|
||||
const HandlerTimeout = 5 * time.Minute
|
||||
|
||||
// RebindAfter is how long a refused subscription waits before it is tried again.
|
||||
const RebindAfter = 30 * time.Second
|
||||
|
||||
// ServeSeatTools binds every handler on its seat's subject until stopped. A queue group per seat, so
|
||||
// a second holder during a handover shares the calls rather than both answering one.
|
||||
//
|
||||
// **A holder binds when it may, not only when it starts.** The grant that lets the controller
|
||||
// subscribe its seat's tools is a line in the bus's user list, and that list is composed by the
|
||||
// controller and delivered to the broker's machine by a push — so the first controller to serve
|
||||
// these started before the list named them, the server refused every subscription, and nothing
|
||||
// tried again (2026-09-30). A refused subscription is therefore retried until it holds: the server
|
||||
// says so asynchronously and invalidates the subscription, which is what is checked.
|
||||
func (b OverNATS) ServeSeatTools(seat string, handlers map[string]ToolHandler, logger *log.Logger) (func(), error) {
|
||||
var subs []*nats.Subscription
|
||||
done := make(chan struct{})
|
||||
stop := func() {
|
||||
close(done)
|
||||
for _, s := range subs {
|
||||
_ = s.Unsubscribe()
|
||||
}
|
||||
}
|
||||
for verb, handle := range handlers {
|
||||
verb, handle := verb, handle
|
||||
subject := SeatToolSubject(seat, verb)
|
||||
bind := func() (*nats.Subscription, error) {
|
||||
return b.Conn.QueueSubscribe(subject, "seat."+seat, func(msg *nats.Msg) {
|
||||
// Its own goroutine per call: a slow `push` must not hold up a `status` asked beside it,
|
||||
// and the library would otherwise run handlers one after another.
|
||||
go func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), HandlerTimeout)
|
||||
defer cancel()
|
||||
args := json.RawMessage(msg.Data)
|
||||
if len(args) == 0 {
|
||||
args = json.RawMessage(`{}`)
|
||||
}
|
||||
var reply []byte
|
||||
result, err := handle(ctx, args)
|
||||
if err != nil {
|
||||
reply, _ = json.Marshal(map[string]any{"error": err.Error()})
|
||||
} else if reply, err = json.Marshal(map[string]any{"result": result}); err != nil {
|
||||
reply, _ = json.Marshal(map[string]any{"error": "the answer could not be written as JSON: " + err.Error()})
|
||||
}
|
||||
if err := msg.Respond(reply); err != nil && logger != nil {
|
||||
logger.Printf("%s: could not answer: %v", subject, err)
|
||||
}
|
||||
}()
|
||||
})
|
||||
}
|
||||
sub, err := bind()
|
||||
if err != nil {
|
||||
stop()
|
||||
return nil, fmt.Errorf("serving %s: %w", subject, err)
|
||||
}
|
||||
subs = append(subs, sub)
|
||||
go keepBound(sub, bind, subject, done, logger)
|
||||
}
|
||||
if logger != nil {
|
||||
logger.Printf("serving %d tool(s) of the %s seat", len(handlers), seat)
|
||||
}
|
||||
return stop, nil
|
||||
}
|
||||
|
||||
// keepBound watches one subscription and re-binds it after the server refused it, until stopped.
|
||||
// A subscription the server refused is invalid a moment after it was made; one it accepted stays
|
||||
// valid. Checked rather than hooked, because the connection's error handler belongs to whoever
|
||||
// dialled and a second one would replace it.
|
||||
func keepBound(sub *nats.Subscription, bind func() (*nats.Subscription, error), subject string,
|
||||
done <-chan struct{}, logger *log.Logger) {
|
||||
current := sub
|
||||
for {
|
||||
select {
|
||||
case <-done:
|
||||
return
|
||||
case <-time.After(3 * time.Second):
|
||||
}
|
||||
if current.IsValid() {
|
||||
// Settled; from here a lost subscription is a lost connection, which the client
|
||||
// restores itself with every subscription it holds.
|
||||
return
|
||||
}
|
||||
if logger != nil {
|
||||
logger.Printf("%s: the bus refused the subscription; trying again in %s — the grant "+
|
||||
"arrives with the next push to the machine holding mesh-broker", subject, RebindAfter)
|
||||
}
|
||||
select {
|
||||
case <-done:
|
||||
return
|
||||
case <-time.After(RebindAfter):
|
||||
}
|
||||
again, err := bind()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
current = again
|
||||
}
|
||||
}
|
||||
@@ -169,10 +169,12 @@ func (g *Generator) Graph() Graph { return g.graph }
|
||||
//
|
||||
// - No floor: no header, no localhost, no `127.0.1.1` — those are the machine's, above the region.
|
||||
// - A machine's own line is marked, and its mesh name resolves to its mesh address, not loopback.
|
||||
// - `.Names` is every name the mesh serves (issue 111), so a container reaching a routed name
|
||||
// finds the machine serving it; machines with no address yet are already left out of the set.
|
||||
// - `.Names` is every name the mesh serves (issue 111), so anything on the machine reaching a
|
||||
// routed name through its resolver finds the machine serving it; machines with no address yet
|
||||
// are already left out of the set. A routed name is one alias, itself — a machine has a bare
|
||||
// name beside its full one, a routed name has nothing beside it (issue 157).
|
||||
const hostsTemplate = "# The mesh's names. This region is replaced whenever a machine joins or leaves.\n" +
|
||||
"{{range .Names}}{{.Address}}\t{{.FQDN}}\t{{.Name}}{{if eq .Name $.Node}}\t# this machine{{end}}\n{{end}}"
|
||||
"{{range .Names}}{{.Address}}\t{{.FQDN}}{{if ne .Name .FQDN}}\t{{.Name}}{{end}}{{if eq .Name $.Node}}\t# this machine{{end}}\n{{end}}"
|
||||
|
||||
// Manifest is the module the mesh provides for itself.
|
||||
//
|
||||
|
||||
+14
@@ -28,6 +28,20 @@
|
||||
},
|
||||
"secrets-owner": "65534:65534",
|
||||
"prepares": true,
|
||||
"tools": [
|
||||
"tools",
|
||||
"status",
|
||||
"nodes",
|
||||
"node",
|
||||
"modules",
|
||||
"seats",
|
||||
"builds",
|
||||
"plan",
|
||||
"assign",
|
||||
"unassign",
|
||||
"push",
|
||||
"build"
|
||||
],
|
||||
"resources": [
|
||||
{
|
||||
"id": "mesh-state",
|
||||
|
||||
Reference in New Issue
Block a user