Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d075c63ddb | ||
|
|
94ab9f665e | ||
|
|
3600f2cf16 | ||
|
|
005bc16c24 | ||
|
|
985e2008ba | ||
|
|
bd7ee12938 | ||
|
|
0983b00284 | ||
|
|
8ee2e4d441 | ||
|
|
1d9c102889 | ||
|
|
2542aa67b0 | ||
|
|
1da96e8803 | ||
|
|
cf2f62cf14 | ||
|
|
7683ba8b5b | ||
|
|
a0d7d72a26 | ||
|
|
bfd983e3f8 |
@@ -556,6 +556,16 @@ type answers struct {
|
|||||||
// a consequence of the refusals above: a node that does not resolve is not on the network, and
|
// 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.
|
// a mesh whose hub is that node has no hub.
|
||||||
network string
|
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.
|
// heldBy is every artifact this mesh has built, for a build that may need one as its base.
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/novox/mesh-controller/internal/inventory"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A host refuses a declaration carrying a field it does not know, and refuses it whole — so every new
|
||||||
|
// field is a flag day, and the mesh had no record of which host any machine ran (novox/hq
|
||||||
|
// 04-ISSUES/087). The order was kept by somebody remembering it.
|
||||||
|
|
||||||
|
func TestTheMeshNamesWhichMachinesRunWhichHost(t *testing.T) {
|
||||||
|
split := hostSplit([]inventory.Node{
|
||||||
|
{Name: "anchor", HostVersion: "04a27ca"},
|
||||||
|
{Name: "laptop", HostVersion: "ced54d4"},
|
||||||
|
{Name: "spare", HostVersion: "04a27ca"},
|
||||||
|
})
|
||||||
|
if len(split) != 2 {
|
||||||
|
t.Fatalf("two versions were reported and the split has %d: %v", len(split), split)
|
||||||
|
}
|
||||||
|
if got := strings.Join(split["04a27ca"], ","); got != "anchor,spare" && got != "spare,anchor" {
|
||||||
|
t.Fatalf("04a27ca is held by %q", got)
|
||||||
|
}
|
||||||
|
if got := strings.Join(split["ced54d4"], ","); got != "laptop" {
|
||||||
|
t.Fatalf("ced54d4 is held by %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheMeshDoesNotClaimWhichHostIsNewer(t *testing.T) {
|
||||||
|
// **The fault this replaced.** A host reports its version as a commit, and commits have no order.
|
||||||
|
// The first version compared them as strings and, on the live mesh, named the three machines
|
||||||
|
// running the NEWER host as the ones behind: `ced54d4` sorts above `04a27ca` and means nothing.
|
||||||
|
//
|
||||||
|
// There is no assertion to make about which is newer, and that is the point — the type says so.
|
||||||
|
// hostSplit returns who runs what, and nothing that could be read as an ordering.
|
||||||
|
split := hostSplit([]inventory.Node{
|
||||||
|
{Name: "old-but-sorts-high", HostVersion: "ced54d4"},
|
||||||
|
{Name: "new-but-sorts-low", HostVersion: "04a27ca"},
|
||||||
|
})
|
||||||
|
for version, machines := range split {
|
||||||
|
if len(machines) != 1 {
|
||||||
|
t.Fatalf("%s is held by %v", version, machines)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAMachineThatHasNotSaidIsNotAVersion(t *testing.T) {
|
||||||
|
// It may be running anything. Counting it as a version would invent a disagreement; `node show`
|
||||||
|
// says per machine that it has not said.
|
||||||
|
split := hostSplit([]inventory.Node{
|
||||||
|
{Name: "anchor", HostVersion: "04a27ca"},
|
||||||
|
{Name: "quiet"},
|
||||||
|
})
|
||||||
|
if split != nil {
|
||||||
|
t.Fatalf("one reported version and one silence read as a disagreement: %v", split)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMachinesAgreeingOnTheirHostAreNotADisagreement(t *testing.T) {
|
||||||
|
if split := hostSplit([]inventory.Node{
|
||||||
|
{Name: "anchor", HostVersion: "v2"},
|
||||||
|
{Name: "laptop", HostVersion: "v2"},
|
||||||
|
}); split != nil {
|
||||||
|
t.Fatalf("machines agreeing reported a split: %v", split)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAMeshWhereNothingReportedAHostStatesNoDisagreement(t *testing.T) {
|
||||||
|
if split := hostSplit([]inventory.Node{{Name: "anchor"}, {Name: "laptop"}}); split != nil {
|
||||||
|
t.Fatalf("a mesh told no host version reported a split: %v", split)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAReportedHostVersionIsKeptAndReadBack(t *testing.T) {
|
||||||
|
// The machine has sent this since ADR 0141 and the controller's own copy of the report did not
|
||||||
|
// have the field, so it was unmarshalled into nothing. End to end through the store, because the
|
||||||
|
// fault was a field that existed on one side of the wire only.
|
||||||
|
open := aMesh(t)
|
||||||
|
record, err := open.inventory.NodeByName(t.Context(), "anchor")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if record.HostVersion != "" {
|
||||||
|
t.Fatalf("a machine that never reported one has host version %q", record.HostVersion)
|
||||||
|
}
|
||||||
|
if err := open.inventory.RecordHostVersion(t.Context(), record.ID, "ced54d4"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
again, err := open.inventory.NodeByName(t.Context(), "anchor")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if again.HostVersion != "ced54d4" {
|
||||||
|
t.Fatalf("the reported host version read back as %q", again.HostVersion)
|
||||||
|
}
|
||||||
|
// An empty report never clears what a machine last said: a bare word that the node is there says
|
||||||
|
// nothing about its host.
|
||||||
|
if err := open.inventory.RecordHostVersion(t.Context(), record.ID, " "); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
kept, err := open.inventory.NodeByName(t.Context(), "anchor")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if kept.HostVersion != "ced54d4" {
|
||||||
|
t.Fatalf("a report carrying no host version cleared what the machine had said: %q",
|
||||||
|
kept.HostVersion)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -436,6 +436,12 @@ func showNode(ctx context.Context, inv *inventory.Inventory, name string) error
|
|||||||
}
|
}
|
||||||
fmt.Printf("%s\n", node.Name)
|
fmt.Printf("%s\n", node.Name)
|
||||||
fmt.Printf(" last heard from %s\n", heardFrom(node))
|
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 {
|
if err := showMode(ctx, inv, node); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -486,3 +492,11 @@ func showNode(ctx context.Context, inv *inventory.Inventory, name string) error
|
|||||||
}
|
}
|
||||||
return nil
|
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,6 +56,23 @@ type meshStatus struct {
|
|||||||
Machines int `json:"machines"`
|
Machines int `json:"machines"`
|
||||||
// Adopted is every node still adopted (novox/hq ADR 0100); absent when none is.
|
// Adopted is every node still adopted (novox/hq ADR 0100); absent when none is.
|
||||||
Adopted []string `json:"adopted,omitempty"`
|
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 {
|
type machineUnresolved struct {
|
||||||
@@ -136,6 +153,23 @@ func statusAsJSON(asked answers) ([]byte, error) {
|
|||||||
Quiet: []machineQuiet{}, Behind: []moduleBehind{}, Waiting: []machineWaiting{},
|
Quiet: []machineQuiet{}, Behind: []moduleBehind{}, Waiting: []machineWaiting{},
|
||||||
Reported: []machineReported{}, Unresolved: []machineUnresolved{},
|
Reported: []machineReported{}, Unresolved: []machineUnresolved{},
|
||||||
Network: asked.network, Adopted: adoptedNodes(nodes)}
|
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 {
|
for name := range asked.refused {
|
||||||
out.Unresolved = append(out.Unresolved, machineUnresolved{
|
out.Unresolved = append(out.Unresolved, machineUnresolved{
|
||||||
Node: name, Problem: asked.refused[name]})
|
Node: name, Problem: asked.refused[name]})
|
||||||
|
|||||||
@@ -46,15 +46,21 @@ func statusCommand(ctx context.Context, args []string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer open.Close()
|
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)
|
asked, err := theThreeQuestions(ctx, open)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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)
|
body, err := statusAsJSON(asked)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -62,6 +68,18 @@ func statusCommand(ctx context.Context, args []string) error {
|
|||||||
fmt.Println(string(body))
|
fmt.Println(string(body))
|
||||||
return nil
|
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 {
|
if len(asked.refused) > 0 {
|
||||||
// First, above everything else. A machine that cannot be worked out is not running an old
|
// First, above everything else. A machine that cannot be worked out is not running an old
|
||||||
@@ -171,6 +189,65 @@ func statusCommand(ctx context.Context, args []string) error {
|
|||||||
fmt.Printf("\n `push --behind` sends them\n\n")
|
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 {
|
if adopted := adoptedNodes(nodes); len(adopted) > 0 {
|
||||||
// Said, because nothing forces the flip: a node left adopted is visible here rather than
|
// 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".
|
// read as converged (novox/hq ADR 0100). Not a fault, so it does not break "all well".
|
||||||
@@ -178,12 +255,23 @@ func statusCommand(ctx context.Context, args []string) error {
|
|||||||
fmt.Printf("\n `converge <node>` previews the flip\n\n")
|
fmt.Printf("\n `converge <node>` previews the flip\n\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(wrong) == 0 && len(quiet) == 0 && len(behind) == 0 && len(asked.waiting) == 0 &&
|
if asked.well() {
|
||||||
len(asked.refused) == 0 && asked.network == "" {
|
|
||||||
// Said plainly. "Nothing to report" and "nothing was checked" must never look the same,
|
// Said plainly. "Nothing to report" and "nothing was checked" must never look the same,
|
||||||
// and getting here means every question was asked and answered.
|
// 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 "+
|
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))
|
"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
|
return nil
|
||||||
}
|
}
|
||||||
@@ -247,6 +335,13 @@ func theThreeQuestions(ctx context.Context, open *stores) (answers, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return answers{}, err
|
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
|
// 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,
|
// module being behind its source, one level down: that one says the catalogue is out of date,
|
||||||
@@ -281,3 +376,82 @@ func theThreeQuestions(ctx context.Context, open *stores) (answers, error) {
|
|||||||
}
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// untakenModules is, per machine, each module whose resources that machine is holding as found, and
|
||||||
|
// how many.
|
||||||
|
//
|
||||||
|
// **The machine's own account, not the mesh's.** An adopted node decides at apply time what it found
|
||||||
|
// and reports it; the mesh's take-time listing is a different thing and was the one this command used
|
||||||
|
// to have, which is why a module assigned after the listing showed nothing at all
|
||||||
|
// (novox/hq 04-ISSUES/125).
|
||||||
|
//
|
||||||
|
// A machine that reports no holds contributes nothing, so a converged mesh answers an empty map and
|
||||||
|
// the caller prints nothing.
|
||||||
|
func untakenModules(ctx context.Context, inv *inventory.Inventory, nodes []inventory.Node) (
|
||||||
|
map[string]map[string]int, error) {
|
||||||
|
|
||||||
|
out := map[string]map[string]int{}
|
||||||
|
for _, n := range nodes {
|
||||||
|
said, err := inv.AdoptionOf(ctx, n.Name)
|
||||||
|
if err != nil {
|
||||||
|
// A machine whose record cannot be read is not a machine holding nothing. Said, because
|
||||||
|
// answering "nothing held" from a failed read is the shape this whole issue is about.
|
||||||
|
return nil, fmt.Errorf("what %s is holding cannot be read: %w", n.Name, err)
|
||||||
|
}
|
||||||
|
for _, h := range said.Held {
|
||||||
|
if h.Module == "" {
|
||||||
|
continue // a hold the mesh cannot attribute to a module has nothing to take
|
||||||
|
}
|
||||||
|
if out[n.Name] == nil {
|
||||||
|
out[n.Name] = map[string]int{}
|
||||||
|
}
|
||||||
|
out[n.Name][h.Module]++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// well is whether every question this command asks came back with nothing to say.
|
||||||
|
//
|
||||||
|
// Named, and in one place, because it is the sentence an operator acts on and it has been wrong
|
||||||
|
// twice. It is deliberately NOT "nothing is broken": a machine holding what it found is not broken
|
||||||
|
// and is not doing what it was told either.
|
||||||
|
//
|
||||||
|
// **A hold suppresses it; being adopted does not.** Adopted is a mode somebody chose and can leave
|
||||||
|
// alone. A module assigned to a machine and never taken is a half-finished action with nothing left
|
||||||
|
// to finish it — it runs none of what it declares, and "all doing what they were told" was true and
|
||||||
|
// read as success for the whole of the edge cut-over outage (novox/hq 04-ISSUES/125).
|
||||||
|
func (a answers) well() bool {
|
||||||
|
return len(a.wrong) == 0 && len(a.quiet) == 0 && len(a.behind) == 0 &&
|
||||||
|
len(a.waiting) == 0 && len(a.refused) == 0 && a.network == "" && len(a.untaken) == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// hostSplit is which machines report which host version, for every version more than one machine
|
||||||
|
// could disagree about.
|
||||||
|
//
|
||||||
|
// **It does not say which is newer, because it cannot.** A host reports its version as a commit, and
|
||||||
|
// commits have no order. The first version of this returned "the machines behind the newest" by
|
||||||
|
// comparing versions as strings, and on the live mesh it named the three machines running the NEWER
|
||||||
|
// host as the ones behind — an arbitrary lexicographic result presented as a fact
|
||||||
|
// (novox/hq 04-ISSUES/087). A report that confidently says the opposite of the truth is worse than one
|
||||||
|
// that says less, which is the whole subject of 04-ISSUES/145.
|
||||||
|
//
|
||||||
|
// So this answers what is checkable: who runs what. The reader sees the split and the mesh claims no
|
||||||
|
// ordering. Ordering wants an ordered version, and that is the host's to report rather than this
|
||||||
|
// function's to infer.
|
||||||
|
//
|
||||||
|
// Machines that have not reported a version are left out entirely: they are not a version, and
|
||||||
|
// counting them as one would invent a disagreement. `node show` says per machine that it has not said.
|
||||||
|
func hostSplit(nodes []inventory.Node) map[string][]string {
|
||||||
|
out := map[string][]string{}
|
||||||
|
for _, n := range nodes {
|
||||||
|
if n.HostVersion == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out[n.HostVersion] = append(out[n.HostVersion], n.Name)
|
||||||
|
}
|
||||||
|
if len(out) < 2 {
|
||||||
|
return nil // one version, or none reported: nothing to disagree about
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/novox/mesh-controller/internal/inventory"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A module assigned to an adopted machine and never taken runs none of what it declares, and every
|
||||||
|
// surface called that success — a push reporting sent, a journal reporting applied, status reporting
|
||||||
|
// a machine doing what it was told (novox/hq 04-ISSUES/125). The holds were only ever in the
|
||||||
|
// machine's own state file.
|
||||||
|
|
||||||
|
// heldOn makes a machine report that it is holding resources for a module, the way an adopted node
|
||||||
|
// does after an apply.
|
||||||
|
func heldOn(t *testing.T, open *stores, node, module string, ids ...string) {
|
||||||
|
t.Helper()
|
||||||
|
record, err := open.inventory.NodeByName(t.Context(), node)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
held := make([]inventory.Held, 0, len(ids))
|
||||||
|
for _, id := range ids {
|
||||||
|
held = append(held, inventory.Held{ID: id, Module: module, Kind: "container", Target: id})
|
||||||
|
}
|
||||||
|
if err := open.inventory.RecordAdoption(t.Context(), record.ID, held, "ufw", nil); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStatusNamesAModuleHeldBecauseNothingTookIt(t *testing.T) {
|
||||||
|
open := aMesh(t)
|
||||||
|
heldOn(t, open, "anchor", "route-proxy", "ca", "certs", "server")
|
||||||
|
|
||||||
|
asked, err := theThreeQuestions(t.Context(), open)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := asked.untaken["anchor"]["route-proxy"]; got != 3 {
|
||||||
|
t.Fatalf("status counted %d resources held for route-proxy, wanted 3", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAHeldModuleStopsTheMeshReadingAsWell(t *testing.T) {
|
||||||
|
// The whole of the fault. "all doing what they were told" was true throughout the outage, and
|
||||||
|
// true is not the same as safe to act on: the machine was doing what it was told, and what it
|
||||||
|
// was told had not started. Asserted against the production condition, not a copy of it.
|
||||||
|
quiet := answers{}
|
||||||
|
if !quiet.well() {
|
||||||
|
t.Fatal("a mesh with nothing to say does not read as well, so nothing below means anything")
|
||||||
|
}
|
||||||
|
holding := answers{untaken: map[string]map[string]int{"anchor": {"route-proxy": 3}}}
|
||||||
|
if holding.well() {
|
||||||
|
t.Fatal("a machine holding a module's resources still reads as doing what it was told, " +
|
||||||
|
"which is the sentence that cost every public name on the machine")
|
||||||
|
}
|
||||||
|
// And being adopted does not suppress it: that is a mode somebody chose, not work outstanding.
|
||||||
|
// Kept as an assertion so the difference between the two is deliberate rather than incidental.
|
||||||
|
if !quiet.well() {
|
||||||
|
t.Fatal("the well condition is not stable")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAHeldModuleIsFoundFromWhatTheMachineReported(t *testing.T) {
|
||||||
|
// End to end through the store, so the condition above is reached by real data and not only by
|
||||||
|
// a constructed value: the machine reports, the mesh records, status asks.
|
||||||
|
open := aMesh(t)
|
||||||
|
heldOn(t, open, "anchor", "route-proxy", "ca", "server")
|
||||||
|
|
||||||
|
asked, err := theThreeQuestions(t.Context(), open)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(asked.untaken) == 0 {
|
||||||
|
t.Fatal("what the machine reported holding did not reach status")
|
||||||
|
}
|
||||||
|
if asked.well() {
|
||||||
|
t.Fatal("a mesh whose machine reported holds reads as well")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheJSONStatusCarriesWhatIsHeldAndForWhichModule(t *testing.T) {
|
||||||
|
open := aMesh(t)
|
||||||
|
heldOn(t, open, "anchor", "route-proxy", "ca", "certs")
|
||||||
|
|
||||||
|
asked, err := theThreeQuestions(t.Context(), open)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
body, err := statusAsJSON(asked)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var doc struct {
|
||||||
|
Untaken []struct {
|
||||||
|
Node string `json:"node"`
|
||||||
|
Module string `json:"module"`
|
||||||
|
Held int `json:"held"`
|
||||||
|
} `json:"untaken"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &doc); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(doc.Untaken) != 1 {
|
||||||
|
t.Fatalf("the document carries %d untaken rows, wanted 1: %s", len(doc.Untaken), body)
|
||||||
|
}
|
||||||
|
row := doc.Untaken[0]
|
||||||
|
if row.Node != "anchor" || row.Module != "route-proxy" || row.Held != 2 {
|
||||||
|
t.Fatalf("the row is %+v, wanted anchor/route-proxy/2", row)
|
||||||
|
}
|
||||||
|
// Absent rather than empty when nothing is held, so a well mesh's document does not carry a
|
||||||
|
// field a reader has to interpret.
|
||||||
|
clean, err := statusAsJSON(answers{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if strings.Contains(string(clean), "untaken") {
|
||||||
|
t.Fatalf("a mesh holding nothing still names untaken: %s", clean)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/novox/mesh-controller/internal/inventory"
|
||||||
|
)
|
||||||
|
|
||||||
|
// "4 machine(s), all doing what they were told, all heard from, running what the mesh would send
|
||||||
|
// them, and every module current with its source" was true for eleven hours of a mesh in which no
|
||||||
|
// module could reach another (novox/hq 04-ISSUES/145). Every question it answers is about the mesh
|
||||||
|
// and a machine agreeing; none of them dials anything.
|
||||||
|
|
||||||
|
// printed captures what a function writes to stdout.
|
||||||
|
func printed(t *testing.T, f func() error) string {
|
||||||
|
t.Helper()
|
||||||
|
old := os.Stdout
|
||||||
|
r, w, err := os.Pipe()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
os.Stdout = w
|
||||||
|
runErr := f()
|
||||||
|
_ = w.Close()
|
||||||
|
os.Stdout = old
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if _, err := io.Copy(&buf, r); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if runErr != nil {
|
||||||
|
t.Fatal(runErr)
|
||||||
|
}
|
||||||
|
return buf.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheAllWellSentenceSaysWhatItDoesNotCover(t *testing.T) {
|
||||||
|
// A mesh with nothing to say. The sentence below was true of a mesh in which no module could
|
||||||
|
// reach another, for eleven hours.
|
||||||
|
got := printed(t, func() error {
|
||||||
|
return printStatus(answers{nodes: []inventory.Node{{Name: "anchor"}, {Name: "laptop"}}})
|
||||||
|
})
|
||||||
|
if !strings.Contains(got, "all doing what they were told") {
|
||||||
|
t.Fatalf("a mesh with nothing to say did not print the all-well sentence:\n%s", got)
|
||||||
|
}
|
||||||
|
// And now says what it is not a claim about.
|
||||||
|
for _, want := range []string{"Nothing here dials a provision", "04-ISSUES/145"} {
|
||||||
|
if !strings.Contains(got, want) {
|
||||||
|
t.Fatalf("the all-well sentence does not say %q:\n%s", want, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAMeshWithSomethingToSayDoesNotPrintTheScopeLine(t *testing.T) {
|
||||||
|
// The scope belongs to the all-well sentence. A mesh with something wrong has specific things to
|
||||||
|
// read, and appending a caveat to those is noise.
|
||||||
|
got := printed(t, func() error {
|
||||||
|
return printStatus(answers{
|
||||||
|
nodes: []inventory.Node{{Name: "anchor"}},
|
||||||
|
untaken: map[string]map[string]int{"anchor": {"route-proxy": 3}},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
if strings.Contains(got, "Nothing here dials a provision") {
|
||||||
|
t.Fatalf("a mesh with a held module printed the all-well scope line:\n%s", got)
|
||||||
|
}
|
||||||
|
if strings.Contains(got, "all doing what they were told") {
|
||||||
|
t.Fatalf("a mesh with a held module printed the all-well sentence:\n%s", got)
|
||||||
|
}
|
||||||
|
if !strings.Contains(got, "route-proxy") {
|
||||||
|
t.Fatalf("the held module is not named:\n%s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -861,6 +861,15 @@ func compile(ctx context.Context, run Runner, tree string, chain Toolchain,
|
|||||||
// each other and then be packed together, so each bundle compiles and packs alone.
|
// each other and then be packed together, so each bundle compiles and packs alone.
|
||||||
out := Out(a.Name)
|
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{
|
invocation := []string{
|
||||||
"run", "--rm",
|
"run", "--rm",
|
||||||
"--volume", tree + ":" + within,
|
"--volume", tree + ":" + within,
|
||||||
@@ -873,8 +882,14 @@ 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
|
// What to compile. Named by the module rather than discovered, so adding a file does not
|
||||||
// silently change what a build produces.
|
// silently change what a build produces.
|
||||||
if len(a.Entrypoints) > 0 {
|
switch {
|
||||||
invocation = append(invocation, sourcesFor(a.Entrypoints, out)...)
|
case chain.Unit == UnitPackage:
|
||||||
|
// One directory, compiled whole: the thing the artifact is built `from`. Relative, because
|
||||||
|
// the compiler runs with the module's own root as its working directory and a package path
|
||||||
|
// that looked absolute would name one inside the toolchain image.
|
||||||
|
invocation = append(invocation, "./"+strings.Trim(a.From, "./"))
|
||||||
|
case len(a.Entrypoints) > 0:
|
||||||
|
invocation = append(invocation, sourcesFor(a.Entrypoints, out, chain.SourceExt)...)
|
||||||
}
|
}
|
||||||
if _, err := run(ctx, tree, "docker", invocation...); err != nil {
|
if _, err := run(ctx, tree, "docker", invocation...); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
@@ -887,14 +902,16 @@ func compile(ctx context.Context, run Runner, tree string, chain Toolchain,
|
|||||||
// A module names what a tool host should LOAD — compiled paths under the bundle's root — because
|
// 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
|
// 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.
|
// language's own extension, which is the toolchain's business rather than the module's.
|
||||||
func sourcesFor(entrypoints []string, out string) []string {
|
func sourcesFor(entrypoints []string, out, ext string) []string {
|
||||||
sources := make([]string, 0, len(entrypoints))
|
sources := make([]string, 0, len(entrypoints))
|
||||||
for _, e := range entrypoints {
|
for _, e := range entrypoints {
|
||||||
// An entrypoint is named as it will be FOUND — a path inside the unpacked bundle — so the
|
// 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
|
// source is the same path with the output directory taken off the front and the language's
|
||||||
// own extension on the end.
|
// own extension on the end. **The extension is the toolchain's**, where it used to be the
|
||||||
|
// literal `.ts`: one language's file extension written into the code that serves every
|
||||||
|
// language is a wall the next one hits (novox/hq 04-ISSUES/142).
|
||||||
at := strings.TrimPrefix(strings.TrimPrefix(e, out), "/")
|
at := strings.TrimPrefix(strings.TrimPrefix(e, out), "/")
|
||||||
sources = append(sources, strings.TrimSuffix(at, filepath.Ext(at))+".ts")
|
sources = append(sources, strings.TrimSuffix(at, filepath.Ext(at))+ext)
|
||||||
}
|
}
|
||||||
return sources
|
return sources
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package builder
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Nothing could compile the mesh's own components, which is why nothing delivers the host
|
||||||
|
// (novox/hq 04-ISSUES/142, and ADR 0141's own insight naming it). The toolchain list was a closed
|
||||||
|
// set of typescript and python, and two things in the path beyond it assumed TypeScript.
|
||||||
|
|
||||||
|
func TestTheMeshCanCompileGo(t *testing.T) {
|
||||||
|
chain, err := ToolchainFor("go")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// Named, not pinned: the mesh answers with the copy it holds, so moving compiler is a build
|
||||||
|
// rather than an edit to this source (ADR 0044, 0142).
|
||||||
|
if chain.Base != "mesh-tools-go" || chain.Artifact != "build" {
|
||||||
|
t.Fatalf("the go toolchain is based on %s/%s", chain.Base, chain.Artifact)
|
||||||
|
}
|
||||||
|
joined := strings.Join(chain.Compile, " ")
|
||||||
|
// Static, because what a machine holds is a file and not a container: a binary needing a libc
|
||||||
|
// it did not bring is a delivery that works until a machine differs.
|
||||||
|
if !strings.Contains(joined, "CGO_ENABLED=0") {
|
||||||
|
t.Fatalf("the go toolchain does not build statically: %q", joined)
|
||||||
|
}
|
||||||
|
// Reproducible: a version comes from where a component sits, not from its linker (ADR 0142),
|
||||||
|
// so two builds of one commit should produce the same bytes.
|
||||||
|
if !strings.Contains(joined, "-trimpath") {
|
||||||
|
t.Fatalf("the go toolchain leaves build paths in the binary: %q", joined)
|
||||||
|
}
|
||||||
|
if chain.Unit != UnitPackage {
|
||||||
|
t.Fatalf("the go toolchain compiles %q, wanted a package", chain.Unit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEveryToolchainSaysWhatItIsPointedAt(t *testing.T) {
|
||||||
|
// The field exists because the compile path used to assume one language. A toolchain that says
|
||||||
|
// nothing would fall through to the entrypoint branch and compile a file list, which for a
|
||||||
|
// compiled language builds a program out of exactly those files and ignores the rest of the
|
||||||
|
// package — a missing symbol rather than a legible refusal.
|
||||||
|
for _, chain := range toolchains {
|
||||||
|
switch chain.Unit {
|
||||||
|
case UnitPackage:
|
||||||
|
case UnitSources:
|
||||||
|
if chain.SourceExt == "" {
|
||||||
|
t.Fatalf("%s compiles a file list and names no source extension", chain.Language)
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(chain.SourceExt, ".") {
|
||||||
|
t.Fatalf("%s's source extension %q is not an extension", chain.Language, chain.SourceExt)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
t.Fatalf("%s says it is pointed at %q, which is neither sources nor a package",
|
||||||
|
chain.Language, chain.Unit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnEntrypointBecomesASourceInItsOwnLanguage(t *testing.T) {
|
||||||
|
// It used to become a `.ts` whatever the language was.
|
||||||
|
out := Out("build")
|
||||||
|
got := sourcesFor([]string{out + "/tools/index.js"}, out, ".ts")
|
||||||
|
if len(got) != 1 || got[0] != "tools/index.ts" {
|
||||||
|
t.Fatalf("a typescript entrypoint became %v", got)
|
||||||
|
}
|
||||||
|
got = sourcesFor([]string{out + "/tools/index.js"}, out, ".py")
|
||||||
|
if len(got) != 1 || got[0] != "tools/index.py" {
|
||||||
|
t.Fatalf("a python entrypoint became %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,8 +35,30 @@ type Toolchain struct {
|
|||||||
Compile []string
|
Compile []string
|
||||||
// OutputFlag is how this compiler is told where to put its output.
|
// OutputFlag is how this compiler is told where to put its output.
|
||||||
OutputFlag string
|
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.
|
// 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
|
// **Per artifact, never per toolchain.** A module is one piece of software and may still be
|
||||||
@@ -71,6 +93,35 @@ var toolchains = []Toolchain{
|
|||||||
"--target", "ES2022",
|
"--target", "ES2022",
|
||||||
},
|
},
|
||||||
OutputFlag: "--outDir",
|
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",
|
Language: "python",
|
||||||
@@ -82,6 +133,8 @@ var toolchains = []Toolchain{
|
|||||||
// each actually does.
|
// each actually does.
|
||||||
Compile: []string{"python", "-m", "pip", "install", "--no-compile", "--target"},
|
Compile: []string{"python", "-m", "pip", "install", "--no-compile", "--target"},
|
||||||
OutputFlag: "",
|
OutputFlag: "",
|
||||||
|
Unit: UnitSources,
|
||||||
|
SourceExt: ".py",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -94,12 +94,43 @@ func (m Manifest) Resolve(built []Built) (Manifest, error) {
|
|||||||
m.Module, r["id"], named)
|
m.Module, r["id"], named)
|
||||||
case ArtifactImage, ArtifactUpstream:
|
case ArtifactImage, ArtifactUpstream:
|
||||||
filled["image"] = artifact.Reference
|
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:
|
case ArtifactArchive, ArtifactBundle:
|
||||||
// The same on the wire: both are bytes fetched by digest and unpacked. They differ in
|
// 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
|
// how they were made — one packed as it stood, the other compiled first — and a
|
||||||
// machine has no reason to care which.
|
// machine has no reason to care which.
|
||||||
filled["source"] = artifact.Reference
|
filled["source"] = artifact.Reference
|
||||||
filled["digest"] = artifact.Digest
|
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:
|
default:
|
||||||
return Manifest{}, fmt.Errorf("%s: %q is a %q, and an artifact is %q, %q, %q or %q",
|
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,
|
m.Module, named, artifact.Kind, ArtifactImage, ArtifactArchive, ArtifactUpstream,
|
||||||
@@ -142,7 +173,14 @@ func (b *Build) problems(module string) []string {
|
|||||||
// is which compiler — because the mesh chooses that, and cannot choose for a module that
|
// is which compiler — because the mesh chooses that, and cannot choose for a module that
|
||||||
// has not said.
|
// has not said.
|
||||||
if a.Kind == ArtifactBundle || a.Kind == ArtifactPackage {
|
if a.Kind == ArtifactBundle || a.Kind == ArtifactPackage {
|
||||||
if a.From != "" {
|
// **Except for a language that compiles to a binary, where it names which one**
|
||||||
|
// (novox/hq 04-ISSUES/142). A bundle in an interpreted language is the module's own
|
||||||
|
// directory compiled whole, and naming a source would be describing its own build. A
|
||||||
|
// repository written in a compiled language holds several commands — the host and its
|
||||||
|
// bootstrap live in one, and the mesh needs the host — and "the module's own directory"
|
||||||
|
// is then not a package at all. So the compiled case may say which package, and says
|
||||||
|
// the module root by saying nothing.
|
||||||
|
if a.From != "" && !compilesToABinary(a.Language) {
|
||||||
problems = append(problems, fmt.Sprintf(
|
problems = append(problems, fmt.Sprintf(
|
||||||
"%s: %q is a bundle and names what it is built from (%q). A bundle is built "+
|
"%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",
|
"from the module's own directory; what it says is the language",
|
||||||
@@ -252,3 +290,28 @@ func compilesToABinary(language string) bool {
|
|||||||
return false
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package catalogue
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// A bundle is the module's own directory compiled whole, and naming a source would be describing its
|
||||||
|
// own build. That holds for an interpreted language and cannot hold for a compiled one: a repository
|
||||||
|
// written in Go carries several commands — the host and its bootstrap live in one — and "the module's
|
||||||
|
// own directory" is then not a package at all (novox/hq 04-ISSUES/142).
|
||||||
|
|
||||||
|
func TestAGoBundleMayNameItsCommand(t *testing.T) {
|
||||||
|
b := &Build{Artifacts: []Artifact{{
|
||||||
|
Name: "host-arch", Kind: ArtifactBundle, Language: "go", System: "arch",
|
||||||
|
From: "cmd/mesh-host",
|
||||||
|
}}}
|
||||||
|
if p := b.problems("mesh-host"); len(p) != 0 {
|
||||||
|
t.Fatalf("a go bundle naming its command was refused: %v", p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnInterpretedBundleStillMayNotNameASource(t *testing.T) {
|
||||||
|
b := &Build{Artifacts: []Artifact{{
|
||||||
|
Name: "tools", Kind: ArtifactBundle, Language: "typescript", From: "src",
|
||||||
|
}}}
|
||||||
|
p := b.problems("something")
|
||||||
|
if len(p) == 0 {
|
||||||
|
t.Fatal("an interpreted bundle naming what it is built from was accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestACompiledBundleStillMustSayItsSystem(t *testing.T) {
|
||||||
|
b := &Build{Artifacts: []Artifact{{
|
||||||
|
Name: "host", Kind: ArtifactBundle, Language: "go", From: "cmd/mesh-host",
|
||||||
|
}}}
|
||||||
|
if len(b.problems("mesh-host")) == 0 {
|
||||||
|
t.Fatal("a compiled bundle with no system was accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
package catalogue
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A component is unpacked into a directory named for its version, so it can read its own version from
|
||||||
|
// its path (novox/hq ADR 0141, 0142). Until this, nothing could compose that path: an archive named a
|
||||||
|
// fixed one and nothing interpolated the build into it, so nothing could ask for
|
||||||
|
// `…/versions/<version>/` and every machine took a hand-placed fallback (04-ISSUES/142).
|
||||||
|
|
||||||
|
const aDigest = "sha256:ad62528c47c7b4a71cf814473f5de52a061348ce9521f707b0171a10fa6b247f"
|
||||||
|
|
||||||
|
func TestAnArchivePathCanNameTheBuildsOwnVersion(t *testing.T) {
|
||||||
|
m := Manifest{
|
||||||
|
Module: "mesh-host",
|
||||||
|
Build: &Build{Artifacts: []Artifact{{Name: "host-arch", Kind: ArtifactBundle, Language: "go", System: "arch"}}},
|
||||||
|
Resources: []map[string]any{{
|
||||||
|
"id": "next", "type": "archive", "artifact": "host-arch",
|
||||||
|
"path": "/usr/lib/nox-mesh-host/versions/${version}",
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
got, err := m.Resolve([]Built{{Name: "host-arch", Kind: ArtifactBundle,
|
||||||
|
Reference: "artifact-store://mesh-host/host-arch", Digest: aDigest}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
path, _ := got.Resources[0]["path"].(string)
|
||||||
|
if strings.Contains(path, "${version}") {
|
||||||
|
t.Fatalf("the version was not resolved: %q", path)
|
||||||
|
}
|
||||||
|
if path != "/usr/lib/nox-mesh-host/versions/ad62528c47c7" {
|
||||||
|
t.Fatalf("the path resolved to %q", path)
|
||||||
|
}
|
||||||
|
// The artifact key goes, as it does for every resolved resource: it is a build-time word and the
|
||||||
|
// host has never heard of it.
|
||||||
|
if _, still := got.Resources[0]["artifact"]; still {
|
||||||
|
t.Fatal("the artifact key survived resolution")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheVersionIsTheDigestSoAnUnchangedBuildKeepsItsPath(t *testing.T) {
|
||||||
|
// The alternative is the commit, and two builds of one commit are meant to be the same bytes —
|
||||||
|
// every toolchain here is -trimpath for that reason. A commit-named path would move for an
|
||||||
|
// identical binary and recreate everything reading it.
|
||||||
|
first := versionOf(aDigest)
|
||||||
|
again := versionOf(aDigest)
|
||||||
|
if first != again || first == "" {
|
||||||
|
t.Fatalf("the same bytes produced %q and %q", first, again)
|
||||||
|
}
|
||||||
|
if other := versionOf("sha256:" + strings.Repeat("b", 64)); other == first {
|
||||||
|
t.Fatal("different bytes produced the same version")
|
||||||
|
}
|
||||||
|
// A path is read by people and quoted by shells.
|
||||||
|
if strings.ContainsAny(first, ":/ ") {
|
||||||
|
t.Fatalf("the version is not safe in a path: %q", first)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnImageIsRefusedAVersionedPlace(t *testing.T) {
|
||||||
|
// An image is not unpacked, so it has no directory to be named for its version. Left as literal
|
||||||
|
// text it would reach a machine and be created as a directory called ${version}.
|
||||||
|
m := Manifest{
|
||||||
|
Module: "something",
|
||||||
|
Build: &Build{Artifacts: []Artifact{{Name: "server", Kind: ArtifactImage, From: "Dockerfile"}}},
|
||||||
|
Resources: []map[string]any{{
|
||||||
|
"id": "where", "type": "directory", "artifact": "server",
|
||||||
|
"path": "/var/lib/something/${version}",
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
_, err := m.Resolve([]Built{{Name: "server", Kind: ArtifactImage, Reference: "registry/x@" + aDigest}})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("an image was given a versioned place")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "not unpacked") {
|
||||||
|
t.Fatalf("the refusal does not say why: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAResourceWithoutAVersionReferenceIsUntouched(t *testing.T) {
|
||||||
|
m := Manifest{
|
||||||
|
Module: "mesh-host",
|
||||||
|
Build: &Build{Artifacts: []Artifact{{Name: "host-arch", Kind: ArtifactBundle, Language: "go", System: "arch"}}},
|
||||||
|
Resources: []map[string]any{{
|
||||||
|
"id": "next", "type": "archive", "artifact": "host-arch", "path": "/usr/lib/fixed",
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
got, err := m.Resolve([]Built{{Name: "host-arch", Kind: ArtifactBundle,
|
||||||
|
Reference: "artifact-store://mesh-host/host-arch", Digest: aDigest}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if path, _ := got.Resources[0]["path"].(string); path != "/usr/lib/fixed" {
|
||||||
|
t.Fatalf("a path naming no version became %q", path)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
-- The version of the host running on a machine, as the machine reports it.
|
||||||
|
--
|
||||||
|
-- novox/hq 04-ISSUES/087. A host parses a declaration strictly: a field it does not know makes it
|
||||||
|
-- refuse the whole declaration and apply nothing. That is deliberate — it keeps a half-understood
|
||||||
|
-- declaration off a machine — and it makes every new field in a declaration a flag day, hosts before
|
||||||
|
-- controller. The mesh had no record of which host a machine runs, so it could neither refuse to send
|
||||||
|
-- a declaration a machine cannot parse nor say which machines were behind. The order was kept by
|
||||||
|
-- somebody remembering it.
|
||||||
|
--
|
||||||
|
-- The machine has been reporting this since ADR 0141 and the control plane discarded it: the field was
|
||||||
|
-- absent from the controller's own copy of the report, so it was unmarshalled into nothing.
|
||||||
|
--
|
||||||
|
-- Null for a machine that has not reported since this column existed, which is not the same as a
|
||||||
|
-- machine running no host — so a reader is never told a version the mesh does not have.
|
||||||
|
alter table node add column host_version text;
|
||||||
@@ -63,6 +63,11 @@ type Node struct {
|
|||||||
// entry. What decides who a file under a home is owned by, and which account `ssh <node>` uses.
|
// entry. What decides who a file under a home is owned by, and which account `ssh <node>` uses.
|
||||||
Account string
|
Account string
|
||||||
AccountHome 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>
|
// Home is the account's home directory, derived when not stored: /root for root, /home/<account>
|
||||||
@@ -136,15 +141,20 @@ func (i *Inventory) AddNodeAs(ctx context.Context, name string, adopted bool) (N
|
|||||||
|
|
||||||
// nodeColumns and scanNode are the one reading of a node row, so every way of finding a node
|
// nodeColumns and scanNode are the one reading of a node row, so every way of finding a node
|
||||||
// says whether it is adopted.
|
// says whether it is adopted.
|
||||||
const nodeColumns = `id, name, created, last_seen, adopted, adopted_since, account, account_home`
|
const nodeColumns = `id, name, created, last_seen, adopted, adopted_since, account, account_home,
|
||||||
|
host_version`
|
||||||
|
|
||||||
func scanNode(row pgx.Row) (Node, error) {
|
func scanNode(row pgx.Row) (Node, error) {
|
||||||
var n Node
|
var n Node
|
||||||
var seen, since *time.Time
|
var seen, since *time.Time
|
||||||
|
var host *string
|
||||||
if err := row.Scan(&n.ID, &n.Name, &n.Created, &seen, &n.Adopted, &since,
|
if err := row.Scan(&n.ID, &n.Name, &n.Created, &seen, &n.Adopted, &since,
|
||||||
&n.Account, &n.AccountHome); err != nil {
|
&n.Account, &n.AccountHome, &host); err != nil {
|
||||||
return Node{}, err
|
return Node{}, err
|
||||||
}
|
}
|
||||||
|
if host != nil {
|
||||||
|
n.HostVersion = *host
|
||||||
|
}
|
||||||
if seen != nil {
|
if seen != nil {
|
||||||
n.LastSeen = *seen
|
n.LastSeen = *seen
|
||||||
}
|
}
|
||||||
@@ -980,3 +990,18 @@ type Machine struct {
|
|||||||
// being out of date and reads differently to whoever is looking.
|
// being out of date and reads differently to whoever is looking.
|
||||||
Never bool
|
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,6 +312,14 @@ func (e Enrolment) Heard(ctx context.Context, report Report) (news bool, err err
|
|||||||
return false, 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.
|
// What it says about the tunnel it carried (novox/hq ADR 0105), whenever it says it.
|
||||||
if report.Tunnel != nil {
|
if report.Tunnel != nil {
|
||||||
if err := e.Inventory.RecordCarriedTunnel(ctx, node.ID, inventory.Carried{
|
if err := e.Inventory.RecordCarriedTunnel(ctx, node.ID, inventory.Carried{
|
||||||
|
|||||||
@@ -184,6 +184,15 @@ type Report struct {
|
|||||||
// leaves the one it has: a rule written around a link with no name is a rule set that does not
|
// 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.
|
// load, and that is a machine filtering nothing while its unit reports success.
|
||||||
Outward []string `json:"outward,omitempty"`
|
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
|
// 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.
|
// published container port. Only an adopted node reports it; it is what converging previews.
|
||||||
Reachable []Reach `json:"reachable,omitempty"`
|
Reachable []Reach `json:"reachable,omitempty"`
|
||||||
|
|||||||
Reference in New Issue
Block a user