Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
22845a5296 |
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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]})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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"`
|
||||
|
||||
Reference in New Issue
Block a user