Compare commits

..
1 Commits
Author SHA1 Message Date
jschoubben 22845a5296 A module is told the name it is served under (hq 122)
A module contributes a label; the mesh joins it with the 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: a domain in a
definition, wrong on every other machine (ADR 0112). Found converting
grafana's keycloak login for ace, where it forced GF_SERVER_ROOT_URL and
keycloak's issuer back into manifests.

The binding for a requirement a module contributes to now carries `name`
and `internal-name` (or `names` by local name for several contributions),
and `${bound:<requirement>:name}` / `:internal-name` (`:name-<local>`) fill
files from it. Both come from the one function the provider's received
file is composed by, so the proxy and the module cannot disagree about the
name. Absent when nothing was composed, so a file asking for a name on a
node with no public domain is refused, not rendered empty.

Also: `${bound:…}` could not name a requirement answered by a node-scoped
provider on the same machine — its binding file was written (from `here`)
but the placeholders only looked at the mesh's needs. Filled from the same
answer now.
2026-09-30 00:47:22 +02:00
20 changed files with 288 additions and 976 deletions
-10
View File
@@ -556,16 +556,6 @@ 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.
-111
View File
@@ -1,111 +0,0 @@
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)
}
}
-14
View File
@@ -436,12 +436,6 @@ 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
}
@@ -492,11 +486,3 @@ 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
}
-34
View File
@@ -56,23 +56,6 @@ 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 {
@@ -153,23 +136,6 @@ 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]})
+5 -179
View File
@@ -46,21 +46,15 @@ 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
@@ -68,18 +62,6 @@ func statusFor(ctx context.Context, open *stores, asJSON bool) 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
@@ -189,65 +171,6 @@ func printStatus(asked answers) 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".
@@ -255,23 +178,12 @@ func printStatus(asked answers) error {
fmt.Printf("\n `converge <node>` previews the flip\n\n")
}
if asked.well() {
if len(wrong) == 0 && len(quiet) == 0 && len(behind) == 0 && len(asked.waiting) == 0 &&
len(asked.refused) == 0 && asked.network == "" {
// 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
}
@@ -335,13 +247,6 @@ 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,
@@ -376,82 +281,3 @@ 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
}
-122
View File
@@ -1,122 +0,0 @@
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)
}
}
-75
View File
@@ -1,75 +0,0 @@
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)
}
}
+5 -22
View File
@@ -861,15 +861,6 @@ 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,
@@ -882,14 +873,8 @@ func compile(ctx context.Context, run Runner, tree string, chain Toolchain,
}
// What to compile. Named by the module rather than discovered, so adding a file does not
// silently change what a build produces.
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 len(a.Entrypoints) > 0 {
invocation = append(invocation, sourcesFor(a.Entrypoints, out)...)
}
if _, err := run(ctx, tree, "docker", invocation...); err != nil {
return "", err
@@ -902,16 +887,14 @@ 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, ext string) []string {
func sourcesFor(entrypoints []string, out 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. **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).
// own extension on the end.
at := strings.TrimPrefix(strings.TrimPrefix(e, out), "/")
sources = append(sources, strings.TrimSuffix(at, filepath.Ext(at))+ext)
sources = append(sources, strings.TrimSuffix(at, filepath.Ext(at))+".ts")
}
return sources
}
-71
View File
@@ -1,71 +0,0 @@
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)
}
}
-53
View File
@@ -35,30 +35,8 @@ 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
}
// 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
@@ -93,35 +71,6 @@ 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", "-ldflags", "-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,
},
{
Language: "python",
@@ -133,8 +82,6 @@ var toolchains = []Toolchain{
// each actually does.
Compile: []string{"python", "-m", "pip", "install", "--no-compile", "--target"},
OutputFlag: "",
Unit: UnitSources,
SourceExt: ".py",
},
}
+21
View File
@@ -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) {
+1 -64
View File
@@ -94,43 +94,12 @@ 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,
@@ -173,14 +142,7 @@ 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 {
// **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) {
if a.From != "" {
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",
@@ -290,28 +252,3 @@ 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
}
+124 -28
View File
@@ -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
}
@@ -637,6 +641,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)
@@ -1089,21 +1122,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, 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 +1135,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, 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 +1147,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, 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, r.At, 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, 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, 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).
//
@@ -1330,14 +1419,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 +1442,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
}
-37
View File
@@ -1,37 +0,0 @@
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")
}
}
+130
View File
@@ -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)
}
}
@@ -1,97 +0,0 @@
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)
}
}
@@ -1,15 +0,0 @@
-- 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;
+2 -27
View File
@@ -63,11 +63,6 @@ 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>
@@ -141,20 +136,15 @@ 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,
host_version`
const nodeColumns = `id, name, created, last_seen, adopted, adopted_since, account, account_home`
func scanNode(row pgx.Row) (Node, error) {
var n Node
var seen, since *time.Time
var host *string
if err := row.Scan(&n.ID, &n.Name, &n.Created, &seen, &n.Adopted, &since,
&n.Account, &n.AccountHome, &host); err != nil {
&n.Account, &n.AccountHome); err != nil {
return Node{}, err
}
if host != nil {
n.HostVersion = *host
}
if seen != nil {
n.LastSeen = *seen
}
@@ -990,18 +980,3 @@ 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
}
-8
View File
@@ -312,14 +312,6 @@ 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{
-9
View File
@@ -184,15 +184,6 @@ 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"`