Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4d2003d77b | ||
|
|
c68d3a7432 | ||
|
|
a5209bd849 | ||
|
|
bdf965dab6 | ||
|
|
b4da20ecc0 | ||
|
|
4b33b72160 | ||
|
|
d5505fe3d4 | ||
|
|
264c9e41e9 | ||
|
|
76ac3c99bd | ||
|
|
fe5988c536 | ||
|
|
ed5d467d90 | ||
|
|
228d0226dd | ||
|
|
6215ff0760 | ||
|
|
54812306be | ||
|
|
ce9e20fbbc | ||
|
|
878690697e | ||
|
|
ad97297576 | ||
|
|
683b1ed693 | ||
|
|
04f9f378b0 | ||
|
|
1c3f44a526 | ||
|
|
89e152dfe2 | ||
|
|
1ebad3786c | ||
|
|
f2f526a60a | ||
|
|
4b4c7e0e0d | ||
|
|
cec792ce9d | ||
|
|
338d033632 | ||
|
|
1be926cec4 | ||
|
|
2134768dfe | ||
|
|
ef825688ee | ||
|
|
77a14360df | ||
|
|
9be2fb4750 | ||
|
|
5a963aec10 | ||
|
|
45d1c28a28 | ||
|
|
a3e7683c63 | ||
|
|
da394b45e6 | ||
|
|
2f3bfda8c0 | ||
|
|
208388978a | ||
|
|
aa771616bb | ||
|
|
1513bbaac9 | ||
|
|
0014984116 | ||
|
|
d6e49dbd68 | ||
|
|
3756bb3460 | ||
|
|
60be9c5360 | ||
|
|
da31bcb11e | ||
|
|
f03e7b33c9 |
@@ -194,6 +194,9 @@ func answer(ctx context.Context, publisher builder.Publisher, on, workspace stri
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
result.Against = built.Against
|
result.Against = built.Against
|
||||||
|
for _, r := range built.Read {
|
||||||
|
result.Read = append(result.Read, link.ReadRepository{Repository: r.Repository, Ref: r.Ref})
|
||||||
|
}
|
||||||
fmt.Fprintf(os.Stderr, " built %s from %s\n", built.Manifest.Module, short(built.Commit))
|
fmt.Fprintf(os.Stderr, " built %s from %s\n", built.Manifest.Module, short(built.Commit))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -106,6 +106,9 @@ func buildOnce(ctx context.Context, args []string) error {
|
|||||||
Manifest: built.Manifest,
|
Manifest: built.Manifest,
|
||||||
Against: built.Against,
|
Against: built.Against,
|
||||||
}
|
}
|
||||||
|
for _, r := range built.Read {
|
||||||
|
out.Read = append(out.Read, readRepository{Repository: r.Repository, Ref: r.Ref})
|
||||||
|
}
|
||||||
for _, made := range built.Built {
|
for _, made := range built.Built {
|
||||||
out.Made = append(out.Made, madeArtifact{Name: made.Name, Kind: made.Kind, Reference: made.Reference})
|
out.Made = append(out.Made, madeArtifact{Name: made.Name, Kind: made.Kind, Reference: made.Reference})
|
||||||
}
|
}
|
||||||
@@ -123,14 +126,21 @@ func buildOnce(ctx context.Context, args []string) error {
|
|||||||
// The same fields the mesh records for a build, so a reader comparing a genesis build against an
|
// The same fields the mesh records for a build, so a reader comparing a genesis build against an
|
||||||
// ordinary one is comparing the same thing said the same way.
|
// ordinary one is comparing the same thing said the same way.
|
||||||
type onceResult struct {
|
type onceResult struct {
|
||||||
Module string `json:"module"`
|
Module string `json:"module"`
|
||||||
Commit string `json:"commit"`
|
Commit string `json:"commit"`
|
||||||
Repository string `json:"repository"`
|
Repository string `json:"repository"`
|
||||||
Path string `json:"path,omitempty"`
|
Path string `json:"path,omitempty"`
|
||||||
Ref string `json:"ref,omitempty"`
|
Ref string `json:"ref,omitempty"`
|
||||||
Manifest any `json:"manifest"`
|
Manifest any `json:"manifest"`
|
||||||
Made []madeArtifact `json:"made"`
|
Made []madeArtifact `json:"made"`
|
||||||
Against []string `json:"against,omitempty"`
|
Against []string `json:"against,omitempty"`
|
||||||
|
Read []readRepository `json:"read,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// readRepository is a repository this build read source from besides the module's own.
|
||||||
|
type readRepository struct {
|
||||||
|
Repository string `json:"repository"`
|
||||||
|
Ref string `json:"ref,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type madeArtifact struct {
|
type madeArtifact struct {
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ func reportsReaching(t *testing.T, open *stores, reachable []link.Reach, held ..
|
|||||||
if err := open.inventory.RecordSent(ctx, record.ID, digestOf(body)); err != nil {
|
if err := open.inventory.RecordSent(ctx, record.ID, digestOf(body)); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if err := (link.Enrolment{Inventory: open.inventory}).Heard(ctx, link.Report{
|
if _, err := (link.Enrolment{Inventory: open.inventory}).Heard(ctx, link.Report{
|
||||||
Node: "anchor", Applied: []string{"hello-web.x"}, Declared: digestOf(body),
|
Node: "anchor", Applied: []string{"hello-web.x"}, Declared: digestOf(body),
|
||||||
Firewall: "ufw", Held: held, Reachable: reachable,
|
Firewall: "ufw", Held: held, Reachable: reachable,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
|
|||||||
@@ -309,7 +309,7 @@ func converge(ctx context.Context, open *stores, node string, yes bool, digest s
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
derived := derivedFilter{rules: rules, foundation: with.Foundation, mesh: with.Mesh,
|
derived := derivedFilter{rules: rules, foundation: with.Foundation, mesh: with.Mesh,
|
||||||
outward: plan.PublicDomain != ""}
|
outward: plan.PublicDomain != "", outwardLinks: with.OutwardLinks}
|
||||||
preview, saw := previewOf(node, reported, derived, plan, taken, filter, runs[filter])
|
preview, saw := previewOf(node, reported, derived, plan, taken, filter, runs[filter])
|
||||||
preview += "\n\n preview " + saw
|
preview += "\n\n preview " + saw
|
||||||
if !yes {
|
if !yes {
|
||||||
@@ -414,6 +414,18 @@ func previewOf(node string, reported inventory.Adoption, derived derivedFilter,
|
|||||||
b.WriteString(" not previewed: traffic the machine routes that is not a published port " +
|
b.WriteString(" not previewed: traffic the machine routes that is not a published port " +
|
||||||
"(a tunnel, NAT in the found firewall) — the derived filter drops it unless a module " +
|
"(a tunnel, NAT in the found firewall) — the derived filter drops it unless a module " +
|
||||||
"declares it\n")
|
"declares it\n")
|
||||||
|
// Which links the filter constrains, said rather than left to the sentence above (novox/hq ADR
|
||||||
|
// 0140). Everything arriving anywhere else is this machine's own guest and keeps working — which
|
||||||
|
// is what a reader most wants to know, because the previous shape of this filter cut a machine's
|
||||||
|
// guests off at the flip without saying so, and that is how this was found.
|
||||||
|
if len(derived.outwardLinks) > 0 {
|
||||||
|
b.WriteString(fmt.Sprintf(" it filters what arrives on: %s, and on the private network "+
|
||||||
|
"— everything its own guests send keeps working\n",
|
||||||
|
strings.Join(derived.outwardLinks, ", ")))
|
||||||
|
} else {
|
||||||
|
b.WriteString(" it has reported no link facing outside, so no filter can be composed " +
|
||||||
|
"for it — the flip is refused until it reports one\n")
|
||||||
|
}
|
||||||
|
|
||||||
isTaken := map[string]bool{}
|
isTaken := map[string]bool{}
|
||||||
for _, m := range taken {
|
for _, m := range taken {
|
||||||
@@ -473,6 +485,10 @@ type derivedFilter struct {
|
|||||||
// mesh is every address on the private network; outward says the machine faces outside.
|
// mesh is every address on the private network; outward says the machine faces outside.
|
||||||
mesh []string
|
mesh []string
|
||||||
outward bool
|
outward bool
|
||||||
|
// outwardLinks is the links this machine reported as facing outside it (novox/hq ADR 0140).
|
||||||
|
// The filter constrains what arrives on them; everything arriving elsewhere is this machine's
|
||||||
|
// own guest and is not filtered.
|
||||||
|
outwardLinks []string
|
||||||
}
|
}
|
||||||
|
|
||||||
// closesOutside is what a narrowing from everywhere to the private network is called: it closes.
|
// closesOutside is what a narrowing from everywhere to the private network is called: it closes.
|
||||||
@@ -498,6 +514,12 @@ func (d derivedFilter) fate(r inventory.Reach) string {
|
|||||||
return "stays open — the mesh's own, from anywhere"
|
return "stays open — the mesh's own, from anywhere"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// This machine's own guests ask it for an address and for names, and those two arrive here
|
||||||
|
// (novox/hq ADR 0140). Admitted by the link they arrive on, so a listener bound anywhere but an
|
||||||
|
// outward link keeps answering them.
|
||||||
|
if (r.Protocol == "udp" && (r.Port == 53 || r.Port == 67)) || (r.Protocol == "tcp" && r.Port == 53) {
|
||||||
|
return "stays open — this machine's own guests asking it for an address and for names"
|
||||||
|
}
|
||||||
for _, rule := range d.rules {
|
for _, rule := range d.rules {
|
||||||
if rule.Port != r.Port || rule.Protocol != r.Protocol {
|
if rule.Port != r.Port || rule.Protocol != r.Protocol {
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -149,6 +149,9 @@ func buildFrom(result link.BuildResult) inventory.Build {
|
|||||||
for _, ref := range result.Against {
|
for _, ref := range result.Against {
|
||||||
kept.Against = append(kept.Against, catalogue.Recorded(ref))
|
kept.Against = append(kept.Against, catalogue.Recorded(ref))
|
||||||
}
|
}
|
||||||
|
for _, r := range result.Read {
|
||||||
|
kept.Read = append(kept.Read, inventory.ReadRepository{Repository: r.Repository, Ref: r.Ref})
|
||||||
|
}
|
||||||
var announced []inventory.Artifact
|
var announced []inventory.Artifact
|
||||||
for _, made := range result.Made {
|
for _, made := range result.Made {
|
||||||
announced = append(announced, inventory.Artifact{
|
announced = append(announced, inventory.Artifact{
|
||||||
|
|||||||
@@ -76,7 +76,10 @@ func run() error {
|
|||||||
return pinCommand(ctx, args[1:], true)
|
return pinCommand(ctx, args[1:], true)
|
||||||
case "unpin":
|
case "unpin":
|
||||||
return pinCommand(ctx, args[1:], false)
|
return pinCommand(ctx, args[1:], false)
|
||||||
case "migrate":
|
// `prepare` is how the mesh asks any module to bring its state to the shape this version needs
|
||||||
|
// (novox/hq ADR 0135), and the control plane answers it the same way as everything else — its
|
||||||
|
// own schema is not a special case. `migrate` remains the word a person types.
|
||||||
|
case "prepare", "migrate":
|
||||||
return migrate(ctx)
|
return migrate(ctx)
|
||||||
case "node":
|
case "node":
|
||||||
return nodeCommand(ctx, args[1:])
|
return nodeCommand(ctx, args[1:])
|
||||||
@@ -138,12 +141,16 @@ func usage() {
|
|||||||
fmt.Fprint(os.Stderr, `mesh-controller — the control plane
|
fmt.Fprint(os.Stderr, `mesh-controller — the control plane
|
||||||
|
|
||||||
migrate bring each context's schema up to date
|
migrate bring each context's schema up to date
|
||||||
|
prepare the same, asked the way the mesh asks any module (ADR 0135)
|
||||||
node add <name> [--adopted] create a node record; --adopted: the machine is in use
|
node add <name> [--adopted] create a node record; --adopted: the machine is in use
|
||||||
node list the nodes this mesh knows about
|
node list the nodes this mesh knows about
|
||||||
node show <name> what one machine reported it can do, and why
|
node show <name> what one machine reported it can do, and why
|
||||||
node public-domain <name> the domain it composes its routed names under
|
node public-domain <name> the domain it composes its routed names under
|
||||||
node public-domain <name> <d> ...set it to d
|
node public-domain <name> <d> ...set it to d
|
||||||
node public-domain <name> --clear ...it faces the outside no longer
|
node public-domain <name> --clear ...it faces the outside no longer
|
||||||
|
node networks <name> the networks it routes for what it hosts
|
||||||
|
node networks <name> <cidr>... ...set them; its filter forwards these too
|
||||||
|
node networks <name> --clear ...only the container runtime's own
|
||||||
token issue --node <name> a one-time right to join, for an existing record
|
token issue --node <name> a one-time right to join, for an existing record
|
||||||
token issue --new <name> create the record and issue for it
|
token issue --new <name> create the record and issue for it
|
||||||
token issue ... --adopted ...for a machine in use, which joins adopted
|
token issue ... --adopted ...for a machine in use, which joins adopted
|
||||||
|
|||||||
@@ -69,12 +69,20 @@ func moduleCommand(ctx context.Context, args []string) error {
|
|||||||
repo := set.String("source", "", "where this module comes from")
|
repo := set.String("source", "", "where this module comes from")
|
||||||
ref := set.String("ref", "", "the branch followed there")
|
ref := set.String("ref", "", "the branch followed there")
|
||||||
commit := set.String("commit", "", "the commit this manifest was read at")
|
commit := set.String("commit", "", "the commit this manifest was read at")
|
||||||
|
// **Where inside the repository the module is** (novox/hq ADR 0069). A module is a
|
||||||
|
// repository *and* a directory, and a record that carries only the repository names a
|
||||||
|
// module.json at its root — so every later build of it looks in the wrong place and fails
|
||||||
|
// with "no module.json at its root". Nine modules on this mesh were registered that way
|
||||||
|
// and none of them could be rebuilt (2026-09-28).
|
||||||
|
path := set.String("path", "", "the module's directory inside that repository")
|
||||||
|
self := set.Bool("self", false, "the source is a path on the forge holding the git seat")
|
||||||
positionals, err := parseAround(set, args[1:])
|
positionals, err := parseAround(set, args[1:])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if len(positionals) != 1 {
|
if len(positionals) != 1 {
|
||||||
return errors.New("module add <manifest.json> [--source <repo> --ref <branch> --commit <sha>]")
|
return errors.New("module add <manifest.json> [--source <repo> [--self] [--path P] " +
|
||||||
|
"--ref <branch> --commit <sha>]")
|
||||||
}
|
}
|
||||||
raw, err := os.ReadFile(positionals[0])
|
raw, err := os.ReadFile(positionals[0])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -84,23 +92,24 @@ func moduleCommand(ctx context.Context, args []string) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
// Provenance together or not at all. A source with no commit cannot be compared against
|
from, err := whereItComesFrom(*repo, *ref, *commit, *path, *self)
|
||||||
// anything, so it would record where the module came from and still never be able to say
|
if err != nil {
|
||||||
// the mesh is behind it — which is the one thing recording it is for.
|
return err
|
||||||
if (*repo == "") != (*commit == "") {
|
|
||||||
return errors.New("--source and --commit go together: a source with no commit " +
|
|
||||||
"cannot be compared against anything, and a commit with no source has nothing " +
|
|
||||||
"to be compared with")
|
|
||||||
}
|
}
|
||||||
if err := inv.RegisterModule(ctx, m, inventory.Source{
|
if err := inv.RegisterModule(ctx, m, from); err != nil {
|
||||||
Repository: *repo, Ref: *ref, BuiltFrom: *commit,
|
|
||||||
}); err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
fmt.Printf("%s registered", m.Module)
|
fmt.Printf("%s registered", m.Module)
|
||||||
if *commit != "" {
|
if *commit != "" {
|
||||||
fmt.Printf(" from %s", short(*commit))
|
fmt.Printf(" from %s", short(*commit))
|
||||||
}
|
}
|
||||||
|
if *repo != "" && *path == "" {
|
||||||
|
// Said, not refused: a module really at the root is the ordinary case for a repository
|
||||||
|
// of its own. But a repository holding many modules and a record naming none of them is
|
||||||
|
// a module nothing can rebuild, and the person adding it is the one who knows which.
|
||||||
|
fmt.Printf("\n no directory inside %s, so it is built from that repository's root — "+
|
||||||
|
"`--path` if the module lives in a directory there", *repo)
|
||||||
|
}
|
||||||
if len(m.Provides) > 0 {
|
if len(m.Provides) > 0 {
|
||||||
fmt.Printf(", providing %s", describeOffers(m.Provides))
|
fmt.Printf(", providing %s", describeOffers(m.Provides))
|
||||||
}
|
}
|
||||||
@@ -602,3 +611,37 @@ func issueWith(ctx context.Context, inv *inventory.Inventory, m catalogue.Manife
|
|||||||
"machine holding mesh-broker\n")
|
"machine holding mesh-broker\n")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// whereItComesFrom is the provenance a module handed over by hand records, and what a record must
|
||||||
|
// say to be worth anything later.
|
||||||
|
//
|
||||||
|
// **A module is a repository and a directory inside it** (novox/hq ADR 0069). A record carrying only
|
||||||
|
// the repository names a module.json at its root, so every later build of it looks in the wrong
|
||||||
|
// place — nine modules on this mesh were registered that way and none of them could be rebuilt
|
||||||
|
// (2026-09-28). The directory cannot be checked from here, because the control plane does not clone;
|
||||||
|
// what can be checked is that the record is whole.
|
||||||
|
func whereItComesFrom(repository, ref, commit, path string, self bool) (inventory.Source, error) {
|
||||||
|
// Provenance together or not at all. A source with no commit cannot be compared against
|
||||||
|
// anything, so it would record where the module came from and still never be able to say the
|
||||||
|
// mesh is behind it — which is the one thing recording it is for.
|
||||||
|
if (repository == "") != (commit == "") {
|
||||||
|
return inventory.Source{}, errors.New("--source and --commit go together: a source with " +
|
||||||
|
"no commit cannot be compared against anything, and a commit with no source has " +
|
||||||
|
"nothing to be compared with")
|
||||||
|
}
|
||||||
|
// A directory or a forge with no repository is half a location, and the half it keeps is the
|
||||||
|
// half nothing can be found with.
|
||||||
|
if repository == "" && (path != "" || self) {
|
||||||
|
return inventory.Source{}, errors.New("--path and --self say where inside a source and " +
|
||||||
|
"which forge holds it, so they need --source: without one there is nothing for them " +
|
||||||
|
"to be part of")
|
||||||
|
}
|
||||||
|
from := inventory.Source{Repository: repository, Ref: ref, BuiltFrom: commit, Path: path}
|
||||||
|
if self {
|
||||||
|
if err := onASeat(repository); err != nil {
|
||||||
|
return inventory.Source{}, err
|
||||||
|
}
|
||||||
|
from.Seat = gitSeat
|
||||||
|
}
|
||||||
|
return from, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -66,6 +66,18 @@ func nodeCommand(ctx context.Context, args []string) error {
|
|||||||
// because the damage is already done by the time it prints.
|
// because the damage is already done by the time it prints.
|
||||||
return publicDomain(ctx, inv, args[1:])
|
return publicDomain(ctx, inv, args[1:])
|
||||||
|
|
||||||
|
case "networks":
|
||||||
|
// Removed by novox/hq ADR 0140, which superseded the record that added it. The filter no
|
||||||
|
// longer names any network: it constrains what arrives from outside the machine and says
|
||||||
|
// nothing about what did not, so there is no list to keep. Answered rather than met with
|
||||||
|
// "unknown command", because this was the documented way to stop a flip cutting a machine's
|
||||||
|
// containers off and somebody will reasonably still type it.
|
||||||
|
return errors.New("`node networks` is gone (novox/hq ADR 0140). The filter constrains what " +
|
||||||
|
"arrives from outside this machine and says nothing about traffic that did not, so no " +
|
||||||
|
"network is named anywhere and nothing needs to be said to keep a machine's own " +
|
||||||
|
"containers reaching outward. The machine reports which of its links face outside; see " +
|
||||||
|
"`node show <name>`")
|
||||||
|
|
||||||
case "account":
|
case "account":
|
||||||
// The operator's login on this machine (novox/hq to-be 29): what a home-scoped file is
|
// The operator's login on this machine (novox/hq to-be 29): what a home-scoped file is
|
||||||
// owned by and which account `ssh <node>` uses. Reports with no argument; sets with one;
|
// owned by and which account `ssh <node>` uses. Reports with no argument; sets with one;
|
||||||
|
|||||||
@@ -189,13 +189,17 @@ func readPrivateKey(path string) (string, error) {
|
|||||||
func personIssue(ctx context.Context, args []string) error {
|
func personIssue(ctx context.Context, args []string) error {
|
||||||
set := flag.NewFlagSet("operator issue", flag.ContinueOnError)
|
set := flag.NewFlagSet("operator issue", flag.ContinueOnError)
|
||||||
invokes := set.String("invokes", "", "the tools this person may call, comma-separated, or * for every one")
|
invokes := set.String("invokes", "", "the tools this person may call, comma-separated, or * for every one")
|
||||||
if err := set.Parse(args); err != nil {
|
// Flags on either side of the name, because the usage this command prints puts them after it —
|
||||||
|
// and the standard parser stops at the first thing that is not a flag, so the order the command
|
||||||
|
// documents was the one order it refused (2026-09-28).
|
||||||
|
positionals, err := parseAround(set, args)
|
||||||
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if set.NArg() != 1 {
|
if len(positionals) != 1 {
|
||||||
return errors.New("operator issue <name> --invokes <tool,tool|*>")
|
return errors.New("operator issue <name> --invokes <tool,tool|*>")
|
||||||
}
|
}
|
||||||
name := set.Arg(0)
|
name := positionals[0]
|
||||||
if *invokes == "" {
|
if *invokes == "" {
|
||||||
return errors.New(
|
return errors.New(
|
||||||
"say what this person may call: --invokes mesh-catalog.catalog_tools,gitea.repo_create, " +
|
"say what this person may call: --invokes mesh-catalog.catalog_tools,gitea.repo_create, " +
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package main
|
|||||||
import (
|
import (
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/novox/mesh-controller/internal/catalogue"
|
"github.com/novox/mesh-controller/internal/catalogue"
|
||||||
"github.com/novox/mesh-controller/internal/inventory"
|
"github.com/novox/mesh-controller/internal/inventory"
|
||||||
@@ -90,3 +91,122 @@ func TestAMergeMatchesTheSourcesBuiltFromIt(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A merge made before the source was last seen is history: it does not move the source, and a
|
||||||
|
// merge that says nothing about when it was made is taken as news.
|
||||||
|
func TestAMergeOlderThanTheLastLookIsHistory(t *testing.T) {
|
||||||
|
seen := time.Date(2026, 9, 28, 3, 0, 0, 0, time.UTC)
|
||||||
|
if !isHistory("2026-09-28T02:00:00Z", seen) {
|
||||||
|
t.Fatal("an older merge was taken as news")
|
||||||
|
}
|
||||||
|
if isHistory("2026-09-28T04:00:00Z", seen) {
|
||||||
|
t.Fatal("a newer merge was taken as history")
|
||||||
|
}
|
||||||
|
if isHistory("", seen) || isHistory("2026-09-28T02:00:00Z", time.Time{}) {
|
||||||
|
t.Fatal("a merge or a source with no time on it was refused")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A module as the catalogue holds it: built from a repository, at a directory inside it.
|
||||||
|
func fromRepo(module, repository, path string) inventory.Entry {
|
||||||
|
return inventory.Entry{
|
||||||
|
Manifest: catalogue.Manifest{Module: module},
|
||||||
|
Source: inventory.Source{Repository: repository, Path: path, Ref: "main"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A merge rebuilds the modules whose own directories it changed, and everything when what it changed
|
||||||
|
// is shared. One repository holding many modules is the ordinary case here, and rebuilding all of
|
||||||
|
// them for a change to one is what exhausted a registry's pull limit the first night this ran.
|
||||||
|
func TestAMergeRebuildsTheModulesItChanged(t *testing.T) {
|
||||||
|
const repo = "http://forge.internal:20000/novox/mesh-catalog.git"
|
||||||
|
gitea := fromRepo("gitea", repo, "modules/gitea")
|
||||||
|
keycloak := fromRepo("keycloak", repo, "modules/keycloak")
|
||||||
|
known := []inventory.Entry{gitea, keycloak, fromRepo("plex", repo, "modules/plex")}
|
||||||
|
candidates := []inventory.Entry{gitea, keycloak}
|
||||||
|
merge := func(paths []string, truncated bool) link.SourceMoved {
|
||||||
|
return link.SourceMoved{Owner: "novox", Repo: "mesh-catalog", Base: "main",
|
||||||
|
Paths: paths, PathsTruncated: truncated}
|
||||||
|
}
|
||||||
|
named := func(entries []inventory.Entry) string {
|
||||||
|
var names []string
|
||||||
|
for _, e := range entries {
|
||||||
|
names = append(names, e.Manifest.Module)
|
||||||
|
}
|
||||||
|
return strings.Join(names, ",")
|
||||||
|
}
|
||||||
|
for _, c := range []struct {
|
||||||
|
what string
|
||||||
|
m link.SourceMoved
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"one module's own files", merge([]string{"modules/gitea/index.ts", "modules/gitea/client.ts"}, false), "gitea"},
|
||||||
|
{"two modules' files", merge([]string{"modules/gitea/index.ts", "modules/keycloak/module.json"}, false), "gitea,keycloak"},
|
||||||
|
{"a file they share", merge([]string{"tsconfig.json"}, false), "gitea,keycloak"},
|
||||||
|
{"a module the mesh does not hold", merge([]string{"modules/plex/index.ts"}, false), ""},
|
||||||
|
{"nothing said about the files", merge(nil, false), "gitea,keycloak"},
|
||||||
|
{"more files than were listed", merge([]string{"modules/gitea/index.ts"}, true), "gitea,keycloak"},
|
||||||
|
} {
|
||||||
|
if got := named(whatTheMergeTouched(candidates, known, c.m)); got != c.want {
|
||||||
|
t.Errorf("%s: rebuilt %q, wanted %q", c.what, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A module whose recipe packages source from another repository is affected when that repository
|
||||||
|
// moves — the manifest the mesh keeps says nothing about it, so the record of what the build read is
|
||||||
|
// the only thing that can say so.
|
||||||
|
func TestAModuleIsAffectedByTheRepositoryItPackages(t *testing.T) {
|
||||||
|
m := link.SourceMoved{Owner: "novox", Repo: "mesh-controller", Base: "main",
|
||||||
|
CloneURL: "http://forge.internal:20000/novox/mesh-controller.git"}
|
||||||
|
for _, read := range [][]inventory.ReadRepository{
|
||||||
|
{{Repository: "http://forge.internal:20000/novox/mesh-controller.git", Ref: "main"}},
|
||||||
|
{{Repository: "novox/mesh-controller"}},
|
||||||
|
{{Repository: "https://elsewhere.example/novox/other"}, {Repository: "novox/mesh-controller.git", Ref: "main"}},
|
||||||
|
} {
|
||||||
|
if !readsFrom(read, m) {
|
||||||
|
t.Errorf("%+v was not matched by the merge", read)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, read := range [][]inventory.ReadRepository{
|
||||||
|
nil,
|
||||||
|
{{Repository: "novox/mesh-host", Ref: "main"}},
|
||||||
|
{{Repository: "novox/mesh-controller", Ref: "release"}},
|
||||||
|
} {
|
||||||
|
if readsFrom(read, m) {
|
||||||
|
t.Errorf("%+v was matched by a merge that is not its", read)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// What a module handed over by hand records about where it came from, and what is refused.
|
||||||
|
func TestWhatAHandedOverModuleRecordsAboutItsSource(t *testing.T) {
|
||||||
|
// The whole location: a repository on the mesh's own forge, the directory inside it, the branch
|
||||||
|
// and the commit the manifest was read at.
|
||||||
|
from, err := whereItComesFrom("novox/mesh-catalog", "main", "c0ffee", "modules/gitea", true)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if from.Path != "modules/gitea" || from.Seat != "git" || from.Repository != "novox/mesh-catalog" {
|
||||||
|
t.Fatalf("the source records as %+v", from)
|
||||||
|
}
|
||||||
|
// A manifest with no provenance at all is legitimate: fixing something in a hurry.
|
||||||
|
if from, err := whereItComesFrom("", "", "", "", false); err != nil || from != (inventory.Source{}) {
|
||||||
|
t.Fatalf("a manifest handed over with no provenance was refused: %+v, %v", from, err)
|
||||||
|
}
|
||||||
|
for _, c := range []struct {
|
||||||
|
what string
|
||||||
|
repository, ref, commit, path string
|
||||||
|
self bool
|
||||||
|
}{
|
||||||
|
{what: "a source with no commit", repository: "novox/mesh-catalog", commit: ""},
|
||||||
|
{what: "a commit with no source", commit: "c0ffee"},
|
||||||
|
{what: "a directory inside nothing", path: "modules/gitea"},
|
||||||
|
{what: "a forge holding nothing", self: true},
|
||||||
|
{what: "an address given as a path on the forge", repository: "http://forge.internal:20000/novox/x.git", commit: "c0ffee", self: true},
|
||||||
|
} {
|
||||||
|
if _, err := whereItComesFrom(c.repository, c.ref, c.commit, c.path, c.self); err == nil {
|
||||||
|
t.Errorf("%s was recorded as a source", c.what)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -640,13 +640,19 @@ func renderingFor(ctx context.Context, open *stores, node string,
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return catalogue.Rendering{}, inventory.Node{}, err
|
return catalogue.Rendering{}, inventory.Node{}, err
|
||||||
}
|
}
|
||||||
|
// Which of this machine's links face outside, which is what the derived filter is written
|
||||||
|
// around (novox/hq ADR 0140). Reported by the machine, never set.
|
||||||
|
outwardLinks, err := inv.OutwardLinksOf(ctx, node)
|
||||||
|
if err != nil {
|
||||||
|
return catalogue.Rendering{}, inventory.Node{}, err
|
||||||
|
}
|
||||||
return catalogue.Rendering{
|
return catalogue.Rendering{
|
||||||
BusMembership: memberships[node],
|
BusMembership: memberships[node],
|
||||||
Settings: settings, Generators: gens, Grants: grants, Needed: needed, Ports: ports,
|
Settings: settings, Generators: gens, Grants: grants, Needed: needed, Ports: ports,
|
||||||
Certificate: certificate, Authority: authority, Mesh: private, Names: names,
|
Certificate: certificate, Authority: authority, Mesh: private, Names: names,
|
||||||
Machines: machines,
|
Machines: machines,
|
||||||
Suffix: overlay.Suffix(), MeshRange: meshRange, Accounts: accounts, Foundation: foundation,
|
Suffix: overlay.Suffix(), MeshRange: meshRange, TunnelInterface: overlay.Interface, Accounts: accounts, Foundation: foundation,
|
||||||
Kept: kept, Adopted: record.Adopted,
|
Kept: kept, Adopted: record.Adopted, OutwardLinks: outwardLinks,
|
||||||
Given: given, Taken: taken, Seats: seats, ArtifactStore: artifactStore, Built: built,
|
Given: given, Taken: taken, Seats: seats, ArtifactStore: artifactStore, Built: built,
|
||||||
BusUsers: busUsers,
|
BusUsers: busUsers,
|
||||||
}, record, nil
|
}, record, nil
|
||||||
|
|||||||
@@ -162,7 +162,13 @@ func declare(ctx context.Context, args []string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
server, err := connectLink(ctx, nil, nil, nil)
|
// **With the inventory, so the bus is raised** (novox/hq ADR 0134, design 30). A module's
|
||||||
|
// declaration and how it hears what it consumes move together: its consumer is derived from the
|
||||||
|
// same records this declaration is composed from. Raised only when the control plane started
|
||||||
|
// serving, a module that gained a `consumes` was sent a declaration it could act on and a
|
||||||
|
// consumer that never delivered the event — and nothing anywhere said the two disagreed
|
||||||
|
// (found on review, 2026-09-28). Everything the raise does is idempotent.
|
||||||
|
server, err := connectLink(ctx, inv, nil, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -753,8 +759,31 @@ func raiseTheBus(ctx context.Context, inv *inventory.Inventory, address string)
|
|||||||
if err := broker.RaiseSeats(js, inventory.MeshSeats(), holders); err != nil {
|
if err := broker.RaiseSeats(js, inventory.MeshSeats(), holders); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
fmt.Printf("the bus at %s has its streams, and %d machine(s) can hear a declaration\n",
|
// And how every module hears what it consumes. Derived from the same records the user list is
|
||||||
broker.BareAddress(address), len(names))
|
// composed from, so a module the mesh grants a consumer's subjects has that consumer waiting.
|
||||||
|
// Done on every raise, not only when a credential is issued: every module moved onto this bus
|
||||||
|
// by the rollout was issued on the old one, and came up with nothing to bind to (2026-09-28).
|
||||||
|
records, err := inv.BusRecords(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
users, err := broker.Users(records)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
hearing := 0
|
||||||
|
for _, p := range users {
|
||||||
|
consumer, needed := broker.ConsumerFor(p)
|
||||||
|
if !needed {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := js.EnsureConsumer(consumer); err != nil {
|
||||||
|
return fmt.Errorf("how %s on %s hears what it consumes: %w", p.Module, p.Node, err)
|
||||||
|
}
|
||||||
|
hearing++
|
||||||
|
}
|
||||||
|
fmt.Printf("the bus at %s has its streams, %d machine(s) can hear a declaration, and %d module(s) "+
|
||||||
|
"can hear what they consume\n", broker.BareAddress(address), len(names), hearing)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+173
-13
@@ -232,22 +232,67 @@ func (f following) SourceMoved(ctx context.Context, m link.SourceMoved) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return notNow(err)
|
return notNow(err)
|
||||||
}
|
}
|
||||||
var moved []inventory.Entry
|
read, err := inv.ReadRepositories(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return notNow(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Two kinds of module are affected by one merge, and they are affected differently.
|
||||||
|
//
|
||||||
|
// A module **built from** this repository and branch has moved: the mesh records the new commit
|
||||||
|
// as what its source now has, and only what the merge actually changed is rebuilt. A module that
|
||||||
|
// only **packages source from** it has not moved — its own source is somewhere else, at the
|
||||||
|
// commit it already records — so it is rebuilt and its record left alone. Writing this commit as
|
||||||
|
// its source would make it permanently behind a repository its manifest does not come from.
|
||||||
|
var from, packaging []inventory.Entry
|
||||||
|
already := 0
|
||||||
for _, e := range entries {
|
for _, e := range entries {
|
||||||
if !sourceIs(e.Source, m) {
|
switch {
|
||||||
continue
|
case sourceIs(e.Source, m):
|
||||||
|
if e.Source.BuiltFrom == m.Commit {
|
||||||
|
already++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// **A merge older than the last look at the source is history, not a move.** The forge
|
||||||
|
// announces what it finds merged, and an old merge surfacing late would otherwise move
|
||||||
|
// the recorded head backwards and rebuild everything built from that repository, once
|
||||||
|
// per old merge (2026-09-28).
|
||||||
|
if isHistory(m.MergedAt, e.Source.Seen) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
from = append(from, e)
|
||||||
|
case readsFrom(read[e.Manifest.Module], m):
|
||||||
|
packaging = append(packaging, e)
|
||||||
}
|
}
|
||||||
if e.Source.BuiltFrom == m.Commit {
|
}
|
||||||
continue
|
if len(from) == 0 && len(packaging) == 0 {
|
||||||
|
// "Already built from it" and "nothing reads it" are different facts, and reading the first
|
||||||
|
// as the second sends somebody looking for a broken trigger when the mesh is up to date.
|
||||||
|
if already > 0 {
|
||||||
|
fmt.Printf("%s/%s merged into %s (%.8s); %d module(s) the mesh holds are already built "+
|
||||||
|
"from it\n", m.Owner, m.Repo, m.Base, m.Commit, already)
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
fmt.Printf("%s/%s merged into %s (%.8s); nothing the mesh holds reads it\n",
|
||||||
|
m.Owner, m.Repo, m.Base, m.Commit)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// The same judgement for the packaging kind, against the newest look at that repository by
|
||||||
|
// anything built from it: they keep no record of it themselves, and a replayed old merge should
|
||||||
|
// not rebuild them either.
|
||||||
|
if isHistory(m.MergedAt, lastLookAt(entries, m)) {
|
||||||
|
packaging = nil
|
||||||
|
}
|
||||||
|
touched := whatTheMergeTouched(from, entries, m)
|
||||||
|
for _, e := range touched {
|
||||||
if err := inv.SourceMoved(ctx, e.Manifest.Module, m.Commit); err != nil {
|
if err := inv.SourceMoved(ctx, e.Manifest.Module, m.Commit); err != nil {
|
||||||
return notNow(err)
|
return notNow(err)
|
||||||
}
|
}
|
||||||
moved = append(moved, e)
|
|
||||||
}
|
}
|
||||||
|
moved := append(append([]inventory.Entry{}, touched...), packaging...)
|
||||||
if len(moved) == 0 {
|
if len(moved) == 0 {
|
||||||
fmt.Printf("%s/%s merged into %s (%.8s); nothing the mesh holds is built from it\n",
|
fmt.Printf("%s/%s merged into %s (%.8s); it changed nothing any module the mesh holds is "+
|
||||||
m.Owner, m.Repo, m.Base, m.Commit)
|
"built from\n", m.Owner, m.Repo, m.Base, m.Commit)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
against, err := inv.BuiltAgainst(ctx)
|
against, err := inv.BuiltAgainst(ctx)
|
||||||
@@ -261,6 +306,14 @@ func (f following) SourceMoved(ctx context.Context, m link.SourceMoved) error {
|
|||||||
}
|
}
|
||||||
fmt.Printf("%s/%s merged into %s (%.8s); building %s\n",
|
fmt.Printf("%s/%s merged into %s (%.8s); building %s\n",
|
||||||
m.Owner, m.Repo, m.Base, m.Commit, strings.Join(names, ", "))
|
m.Owner, m.Repo, m.Base, m.Commit, strings.Join(names, ", "))
|
||||||
|
if len(packaging) > 0 {
|
||||||
|
var also []string
|
||||||
|
for _, e := range packaging {
|
||||||
|
also = append(also, e.Manifest.Module)
|
||||||
|
}
|
||||||
|
fmt.Printf(" %s package source from it, so they are rebuilt and their own source record "+
|
||||||
|
"is left where it is\n", strings.Join(also, ", "))
|
||||||
|
}
|
||||||
var failed []string
|
var failed []string
|
||||||
for _, e := range ordered {
|
for _, e := range ordered {
|
||||||
source := buildSource{Repository: e.Source.Repository, Seat: e.Source.Seat}
|
source := buildSource{Repository: e.Source.Repository, Seat: e.Source.Seat}
|
||||||
@@ -286,16 +339,110 @@ func (f following) SourceMoved(ctx context.Context, m link.SourceMoved) error {
|
|||||||
// An empty recorded ref is the repository's default branch, which is what a merge into the base
|
// An empty recorded ref is the repository's default branch, which is what a merge into the base
|
||||||
// branch of the forge's default means.
|
// branch of the forge's default means.
|
||||||
func sourceIs(s inventory.Source, m link.SourceMoved) bool {
|
func sourceIs(s inventory.Source, m link.SourceMoved) bool {
|
||||||
want := strings.ToLower(m.Owner + "/" + m.Repo)
|
if !sameRepository(s.Repository, m) {
|
||||||
repo := strings.ToLower(strings.TrimSuffix(s.Repository, ".git"))
|
|
||||||
matches := repo == want || strings.HasSuffix(repo, "/"+want) ||
|
|
||||||
(m.CloneURL != "" && strings.EqualFold(strings.TrimSuffix(s.Repository, ".git"), strings.TrimSuffix(m.CloneURL, ".git")))
|
|
||||||
if !matches {
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return s.Ref == "" || s.Ref == m.Base
|
return s.Ref == "" || s.Ref == m.Base
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// sameRepository is whether a recorded repository is the one a merge names, in either spelling it
|
||||||
|
// may have been recorded in: a path on the git seat, or the URL it was cloned from.
|
||||||
|
func sameRepository(repository string, m link.SourceMoved) bool {
|
||||||
|
want := strings.ToLower(m.Owner + "/" + m.Repo)
|
||||||
|
repo := strings.ToLower(strings.TrimSuffix(repository, ".git"))
|
||||||
|
return repo == want || strings.HasSuffix(repo, "/"+want) ||
|
||||||
|
(m.CloneURL != "" && repo == strings.ToLower(strings.TrimSuffix(m.CloneURL, ".git")))
|
||||||
|
}
|
||||||
|
|
||||||
|
// readsFrom is whether a module's build read the repository a merge names: the second repository its
|
||||||
|
// recipe packages source from. Its ref must be the branch that moved, or unset — the same rule a
|
||||||
|
// module's own source follows.
|
||||||
|
func readsFrom(read []inventory.ReadRepository, m link.SourceMoved) bool {
|
||||||
|
for _, r := range read {
|
||||||
|
if sameRepository(r.Repository, m) && (r.Ref == "" || r.Ref == m.Base) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// lastLookAt is the most recent look at this repository by anything built from it.
|
||||||
|
func lastLookAt(entries []inventory.Entry, m link.SourceMoved) time.Time {
|
||||||
|
var newest time.Time
|
||||||
|
for _, e := range entries {
|
||||||
|
if sameRepository(e.Source.Repository, m) && e.Source.Seen.After(newest) {
|
||||||
|
newest = e.Source.Seen
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return newest
|
||||||
|
}
|
||||||
|
|
||||||
|
// whatTheMergeTouched narrows the modules built from a repository to the ones the merge changed.
|
||||||
|
//
|
||||||
|
// **A change inside no module's own directory is a change to what they share.** The forge lists the
|
||||||
|
// files a merge changed; a module is affected when one of them is inside its own directory, when it
|
||||||
|
// is built from the repository's root — everything there is its source — or when some changed file
|
||||||
|
// belongs to no module's directory at all, which is how a shared file, a build recipe or a
|
||||||
|
// dependency at the root rebuilds everything built from that repository.
|
||||||
|
//
|
||||||
|
// A change inside *another* module's directory is that module's business and not this one's, even
|
||||||
|
// when the mesh does not hold that module: `known` is every module this repository is known to hold,
|
||||||
|
// whatever branch it was registered from. That is also the limit of this — a repository whose shared
|
||||||
|
// code sits inside a directory the mesh has never seen a module in reads as shared, and everything
|
||||||
|
// is rebuilt. Rebuilding too much is the safe direction: the fault this whole path exists for is a
|
||||||
|
// mesh that believes it is current and is not (novox/hq 04-ISSUES/131).
|
||||||
|
func whatTheMergeTouched(candidates, known []inventory.Entry, m link.SourceMoved) []inventory.Entry {
|
||||||
|
// Nothing said about the files, or not all of them said: everything built from it is affected.
|
||||||
|
if len(m.Paths) == 0 || m.PathsTruncated {
|
||||||
|
return candidates
|
||||||
|
}
|
||||||
|
var dirs []string
|
||||||
|
for _, e := range known {
|
||||||
|
if e.Source.Path != "" && sameRepository(e.Source.Repository, m) {
|
||||||
|
dirs = append(dirs, e.Source.Path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, p := range m.Paths {
|
||||||
|
if !insideAny(p, dirs) {
|
||||||
|
return candidates
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var out []inventory.Entry
|
||||||
|
for _, e := range candidates {
|
||||||
|
if e.Source.Path == "" || anyInside(m.Paths, e.Source.Path) {
|
||||||
|
out = append(out, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// inside is whether a changed file is in a directory: that directory itself, or under it.
|
||||||
|
func inside(path, dir string) bool {
|
||||||
|
dir = strings.Trim(dir, "/")
|
||||||
|
path = strings.TrimPrefix(path, "/")
|
||||||
|
return path == dir || strings.HasPrefix(path, dir+"/")
|
||||||
|
}
|
||||||
|
|
||||||
|
// insideAny is whether a changed file is in any of these directories.
|
||||||
|
func insideAny(path string, dirs []string) bool {
|
||||||
|
for _, dir := range dirs {
|
||||||
|
if inside(path, dir) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// anyInside is whether any of these changed files is in a directory.
|
||||||
|
func anyInside(paths []string, dir string) bool {
|
||||||
|
for _, p := range paths {
|
||||||
|
if inside(p, dir) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// orderByBases is the entries with every base before what stands on it: a module whose build stood
|
// orderByBases is the entries with every base before what stands on it: a module whose build stood
|
||||||
// on another's artifact comes after that module. Entries outside the set are not waited for — they
|
// on another's artifact comes after that module. Entries outside the set are not waited for — they
|
||||||
// are not being rebuilt. Stable for what has no order between it.
|
// are not being rebuilt. Stable for what has no order between it.
|
||||||
@@ -362,3 +509,16 @@ func standsOnModule(e inventory.Entry, module string, against map[string][]strin
|
|||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// isHistory is whether a merge made at mergedAt predates the last time the source was seen. A merge
|
||||||
|
// with no time on it is taken as news: refusing it would silence a forge that says less.
|
||||||
|
func isHistory(mergedAt string, seen time.Time) bool {
|
||||||
|
if mergedAt == "" || seen.IsZero() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
at, err := time.Parse(time.RFC3339, mergedAt)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return at.Before(seen)
|
||||||
|
}
|
||||||
|
|||||||
@@ -66,6 +66,19 @@ func FromEnvironment() (Broker, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return Broker{}, err
|
return Broker{}, err
|
||||||
}
|
}
|
||||||
|
// **One bus, one address** (novox/hq ADR 0131). Everything the mesh hands out — a token, a
|
||||||
|
// machine's membership, a person's credential — must name the bus the control plane itself is
|
||||||
|
// connected to; the setting above predates the move and, on a mesh that has moved, still names
|
||||||
|
// the broker it moved from. The first person issued after the move was handed the retired
|
||||||
|
// broker's port and could not connect to anything (2026-09-28).
|
||||||
|
//
|
||||||
|
// Read from the credential rather than from a second setting somebody keeps in step: the
|
||||||
|
// control plane cannot be wrong about where it is connected.
|
||||||
|
if bus, on, err := OnNATS(); err == nil && on {
|
||||||
|
if where := strings.TrimPrefix(BareAddress(bus), "nats://"); where != "" {
|
||||||
|
address = where
|
||||||
|
}
|
||||||
|
}
|
||||||
return Broker{Address: address, Fingerprint: fingerprint}, nil
|
return Broker{Address: address, Fingerprint: fingerprint}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+19
-4
@@ -181,6 +181,14 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
|||||||
for _, seat := range meshSeatsTheControllerUses {
|
for _, seat := range meshSeatsTheControllerUses {
|
||||||
pub = append(pub, "mesh.seat."+seat+".accept.>")
|
pub = append(pub, "mesh.seat."+seat+".accept.>")
|
||||||
}
|
}
|
||||||
|
// **And what the mesh says it did** (novox/hq ADR 0134). The control plane states its own
|
||||||
|
// facts under the seat it holds, because a role's events belong to the role and keep their
|
||||||
|
// address while the holder is replaced. Named one by one rather than as a whole namespace:
|
||||||
|
// least authority, and a fact nothing states is authority nobody uses.
|
||||||
|
for _, event := range ControllerStates {
|
||||||
|
pub = append(pub, seatEventSubject(ControllerSeat, event))
|
||||||
|
}
|
||||||
|
|
||||||
// Every module's tools: **the control plane is the way in** (novox/hq ADR 0095). A person
|
// Every module's tools: **the control plane is the way in** (novox/hq ADR 0095). A person
|
||||||
// or an agent asks through it and every question passes one process where an audit
|
// or an agent asks through it and every question passes one process where an audit
|
||||||
// belongs — so it, alone among principals, may call any tool by name. The first `ask` on
|
// belongs — so it, alone among principals, may call any tool by name. The first `ask` on
|
||||||
@@ -297,11 +305,18 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 2c. Its own consumer, which it **pulls**: the runtime asks for the next message and is
|
||||||
|
// answered on its own inbox, so what it needs is to ask about the consumer and to ask it
|
||||||
|
// for messages — its own consumer's name, and no other's. Pulled rather than pushed
|
||||||
|
// because that is the one shape a runtime's client binds without creating anything; the
|
||||||
|
// controller and the hosts are pushed to. Named here rather than through ConsumerFor,
|
||||||
|
// which asks for these permissions to build the consumer and would ask forever. A
|
||||||
|
// subject for a consumer that turns out not to exist grants nothing anybody can use.
|
||||||
|
pub = append(pub,
|
||||||
|
"$JS.API.CONSUMER.INFO."+consumerStream(p)+"."+consumerDurable(p),
|
||||||
|
"$JS.API.CONSUMER.MSG.NEXT."+consumerStream(p)+"."+consumerDurable(p))
|
||||||
|
|
||||||
// 3. Seats it holds: full participation.
|
// 3. Seats it holds: full participation.
|
||||||
// Its consumer's name, not ConsumerFor: that asks for these permissions to build the
|
|
||||||
// consumer, and would ask forever. A subject for a consumer that turns out not to exist
|
|
||||||
// grants nothing anybody can use.
|
|
||||||
sub = append(sub, "_DELIVER."+consumerDurable(p))
|
|
||||||
for _, s := range p.Holds {
|
for _, s := range p.Holds {
|
||||||
// Taking work from the role's queue: the worker consumer it binds (asked about,
|
// Taking work from the role's queue: the worker consumer it binds (asked about,
|
||||||
// delivered on, acknowledged), each on the seat's own stream. The first machine to
|
// delivered on, acknowledged), each on the seat's own stream. The first machine to
|
||||||
|
|||||||
@@ -338,3 +338,24 @@ func admits(pattern, subject []string) bool {
|
|||||||
}
|
}
|
||||||
return len(pattern) == len(subject)
|
return len(pattern) == len(subject)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A module pulls its own consumer — asks about it, asks it for messages — and no other module's.
|
||||||
|
func TestAModulePullsItsOwnConsumerAndNoOthers(t *testing.T) {
|
||||||
|
p, _ := PermissionsFor(Principal{Kind: KindModule, Node: "one", Module: "audit",
|
||||||
|
Consumes: []string{"shop.order.placed"}, PasswordHash: "x"})
|
||||||
|
for _, want := range []string{"$JS.API.CONSUMER.INFO.EVENTS.one_audit", "$JS.API.CONSUMER.MSG.NEXT.EVENTS.one_audit"} {
|
||||||
|
if !slices.Contains(p.Publish, want) {
|
||||||
|
t.Errorf("a module cannot bind its own consumer: %v lacks %s", p.Publish, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, s := range p.Publish {
|
||||||
|
if strings.Contains(s, "CONSUMER.") && !strings.HasSuffix(s, ".one_audit") {
|
||||||
|
t.Errorf("a module may reach another consumer: %s", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, s := range p.Subscribe {
|
||||||
|
if strings.HasPrefix(s, "_DELIVER.") {
|
||||||
|
t.Errorf("a module is granted a push delivery it never binds: %s", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package broker_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"slices"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/novox/mesh-controller/internal/broker"
|
||||||
|
"github.com/novox/mesh-controller/internal/catalogue"
|
||||||
|
"github.com/novox/mesh-controller/internal/link"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The facts the control plane states are named twice — in the grant that permits them and in the code
|
||||||
|
// that states them — because `link` imports `broker` and the dependency cannot go the other way. So a
|
||||||
|
// test keeps them agreeing: a subject the grant omits is refused at the moment the mesh has something
|
||||||
|
// to say, and one the grant adds that nothing states is authority nobody uses.
|
||||||
|
//
|
||||||
|
// An external test package, because it may import both while neither imports the other.
|
||||||
|
func TestTheFactsTheGrantPermitsAreTheFactsTheMeshStates(t *testing.T) {
|
||||||
|
if broker.ControllerSeat != link.MeshControllerSeat {
|
||||||
|
t.Fatalf("the grant is written for the %q seat and the mesh states its facts under %q",
|
||||||
|
broker.ControllerSeat, link.MeshControllerSeat)
|
||||||
|
}
|
||||||
|
for _, event := range []string{link.KeyApplied, link.KeyRefused, link.KeyBuiltBefore} {
|
||||||
|
if !slices.Contains(broker.ControllerStates, event) {
|
||||||
|
t.Errorf("the mesh states %q and its account may not publish it", event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(broker.ControllerStates) != 3 {
|
||||||
|
t.Errorf("the grant permits %v, which is more than the mesh states", broker.ControllerStates)
|
||||||
|
}
|
||||||
|
// **And the seat says it.** A seat carries the protocol of its role (novox/hq ADR 0129), so the
|
||||||
|
// facts the control plane states are the seat's `emits` — which is what lets anything else declare
|
||||||
|
// that it consumes them, and what the subject-agreement check reads to know they have an owner.
|
||||||
|
var declared []string
|
||||||
|
for _, seat := range catalogue.SeatsWithAProtocol() {
|
||||||
|
if seat.Name == broker.ControllerSeat {
|
||||||
|
declared = seat.Emits
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !slices.Equal(declared, broker.ControllerStates) {
|
||||||
|
t.Errorf("the %s seat emits %v and the grant permits %v", broker.ControllerSeat,
|
||||||
|
declared, broker.ControllerStates)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -160,6 +160,17 @@ func Overlaps() []string {
|
|||||||
// ack subject is derived from (nats.go: `$JS.ACK.<stream>.controller.>`).
|
// ack subject is derived from (nats.go: `$JS.ACK.<stream>.controller.>`).
|
||||||
const ControllerName = "controller"
|
const ControllerName = "controller"
|
||||||
|
|
||||||
|
// ControllerSeat is the role the control plane holds, and ControllerStates are the facts it states
|
||||||
|
// under it (novox/hq ADR 0134).
|
||||||
|
//
|
||||||
|
// **Written here as well as in `link`, and a test keeps them agreeing.** `link` imports `broker`, so
|
||||||
|
// `broker` cannot import `link`; a grant naming a subject the controller never publishes is authority
|
||||||
|
// nobody uses, and a controller publishing one the grant omits is refused at the moment it has
|
||||||
|
// something to say.
|
||||||
|
const ControllerSeat = "mesh-controller"
|
||||||
|
|
||||||
|
var ControllerStates = []string{"applied", "refused", "built-before"}
|
||||||
|
|
||||||
// ControllerFollows are the events the controller reacts to: the catalogue saying a module's
|
// ControllerFollows are the events the controller reacts to: the catalogue saying a module's
|
||||||
// current version moved, and a catalogue that has just started saying it may have missed builds.
|
// current version moved, and a catalogue that has just started saying it may have missed builds.
|
||||||
//
|
//
|
||||||
|
|||||||
+7
-7
@@ -24,7 +24,7 @@ accounts {
|
|||||||
jetstream: enabled
|
jetstream: enabled
|
||||||
users = [
|
users = [
|
||||||
{ user: "controller", password: "$2a$11$cccccccccccccccccccccc", permissions: {
|
{ user: "controller", password: "$2a$11$cccccccccccccccccccccc", permissions: {
|
||||||
publish: { allow: ["$JS.ACK.CONTROL.controller.>", "$JS.ACK.EVENTS.controller.>", "$JS.API.>", "_INBOX.enrol.>", "mesh.control.>", "mesh.mod.*.tool.>", "mesh.node.>", "mesh.seat.mesh-build-machine.accept.>"] }
|
publish: { allow: ["$JS.ACK.CONTROL.controller.>", "$JS.ACK.EVENTS.controller.>", "$JS.API.>", "_INBOX.enrol.>", "mesh.control.>", "mesh.mod.*.tool.>", "mesh.node.>", "mesh.seat.mesh-build-machine.accept.>", "mesh.seat.mesh-controller.event.applied", "mesh.seat.mesh-controller.event.built-before", "mesh.seat.mesh-controller.event.refused"] }
|
||||||
subscribe: { allow: ["$JS.API.>", "_DELIVER.controller", "_DELIVER.controller.>", "_INBOX.controller.>", "mesh.control.>", "mesh.mod.gitea.event.pull.merged", "mesh.mod.mesh-catalog.event.catching-up", "mesh.mod.mesh-catalog.event.upgraded", "mesh.seat.mesh-build-machine.event.built"] }
|
subscribe: { allow: ["$JS.API.>", "_DELIVER.controller", "_DELIVER.controller.>", "_INBOX.controller.>", "mesh.control.>", "mesh.mod.gitea.event.pull.merged", "mesh.mod.mesh-catalog.event.catching-up", "mesh.mod.mesh-catalog.event.upgraded", "mesh.seat.mesh-build-machine.event.built"] }
|
||||||
allow_responses: { max: 1, ttl: "1m" }
|
allow_responses: { max: 1, ttl: "1m" }
|
||||||
} }
|
} }
|
||||||
@@ -37,18 +37,18 @@ accounts {
|
|||||||
subscribe: { allow: ["_DELIVER.one", "_INBOX.node.one.>", "mesh.node.one.declare"] }
|
subscribe: { allow: ["_DELIVER.one", "_INBOX.node.one.>", "mesh.node.one.declare"] }
|
||||||
} }
|
} }
|
||||||
{ user: "one.telegram", password: "$2a$11$tttttttttttttttttttttt", permissions: {
|
{ user: "one.telegram", password: "$2a$11$tttttttttttttttttttttt", permissions: {
|
||||||
publish: { allow: ["$JS.ACK.EVENTS.one_telegram.>", "$JS.ACK.SEAT_TELEGRAM_SENDER.SEAT_TELEGRAM_SENDER_worker.>", "$JS.API.CONSUMER.INFO.SEAT_TELEGRAM_SENDER.SEAT_TELEGRAM_SENDER_worker", "mesh.seat.telegram-sender.event.delivered", "mesh.seat.telegram-sender.event.failed"] }
|
publish: { allow: ["$JS.ACK.EVENTS.one_telegram.>", "$JS.ACK.SEAT_TELEGRAM_SENDER.SEAT_TELEGRAM_SENDER_worker.>", "$JS.API.CONSUMER.INFO.EVENTS.one_telegram", "$JS.API.CONSUMER.INFO.SEAT_TELEGRAM_SENDER.SEAT_TELEGRAM_SENDER_worker", "$JS.API.CONSUMER.MSG.NEXT.EVENTS.one_telegram", "mesh.seat.telegram-sender.event.delivered", "mesh.seat.telegram-sender.event.failed"] }
|
||||||
subscribe: { allow: ["_DELIVER.SEAT_TELEGRAM_SENDER_worker", "_DELIVER.one_telegram", "_INBOX.one.telegram.>", "mesh.mod.telegram.tool.>", "mesh.seat.telegram-sender.accept.send"] }
|
subscribe: { allow: ["_DELIVER.SEAT_TELEGRAM_SENDER_worker", "_INBOX.one.telegram.>", "mesh.mod.telegram.tool.>", "mesh.seat.telegram-sender.accept.send"] }
|
||||||
allow_responses: { max: 1, ttl: "1m" }
|
allow_responses: { max: 1, ttl: "1m" }
|
||||||
} }
|
} }
|
||||||
{ user: "two.audit", password: "$2a$11$aaaaaaaaaaaaaaaaaaaaaa", permissions: {
|
{ user: "two.audit", password: "$2a$11$aaaaaaaaaaaaaaaaaaaaaa", permissions: {
|
||||||
publish: { allow: ["$JS.ACK.EVENTS.two_audit.>"] }
|
publish: { allow: ["$JS.ACK.EVENTS.two_audit.>", "$JS.API.CONSUMER.INFO.EVENTS.two_audit", "$JS.API.CONSUMER.MSG.NEXT.EVENTS.two_audit"] }
|
||||||
subscribe: { allow: ["_DELIVER.two_audit", "_INBOX.two.audit.>", "mesh.mod.audit.tool.>", "mesh.mod.shop.event.order.placed"] }
|
subscribe: { allow: ["_INBOX.two.audit.>", "mesh.mod.audit.tool.>", "mesh.mod.shop.event.order.placed"] }
|
||||||
allow_responses: { max: 1, ttl: "1m" }
|
allow_responses: { max: 1, ttl: "1m" }
|
||||||
} }
|
} }
|
||||||
{ user: "two.shop", password: "$2a$11$ssssssssssssssssssssss", permissions: {
|
{ user: "two.shop", password: "$2a$11$ssssssssssssssssssssss", permissions: {
|
||||||
publish: { allow: ["$JS.ACK.EVENTS.two_shop.>", "mesh.mod.shop.event.order.placed", "mesh.seat.telegram-sender.accept.send"] }
|
publish: { allow: ["$JS.ACK.EVENTS.two_shop.>", "$JS.API.CONSUMER.INFO.EVENTS.two_shop", "$JS.API.CONSUMER.MSG.NEXT.EVENTS.two_shop", "mesh.mod.shop.event.order.placed", "mesh.seat.telegram-sender.accept.send"] }
|
||||||
subscribe: { allow: ["_DELIVER.two_shop", "_INBOX.two.shop.>", "mesh.mod.shop.tool.>"] }
|
subscribe: { allow: ["_INBOX.two.shop.>", "mesh.mod.shop.tool.>"] }
|
||||||
allow_responses: { max: 1, ttl: "1m" }
|
allow_responses: { max: 1, ttl: "1m" }
|
||||||
} }
|
} }
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -61,6 +61,12 @@ type Result struct {
|
|||||||
Commit string
|
Commit string
|
||||||
// Built is each artifact, for reporting.
|
// Built is each artifact, for reporting.
|
||||||
Built []catalogue.Built
|
Built []catalogue.Built
|
||||||
|
|
||||||
|
// Read is every repository this build read source from besides the module's own — the second
|
||||||
|
// repository an artifact's recipe names (ArtifactContext). Reported because the manifest the
|
||||||
|
// mesh keeps carries no build section, so nothing else could say that a merge there is a
|
||||||
|
// change to this module (novox/hq 04-ISSUES/131).
|
||||||
|
Read []catalogue.ArtifactContext
|
||||||
}
|
}
|
||||||
|
|
||||||
// GitCredential is the forge credential a clone may present when the server asks for one.
|
// GitCredential is the forge credential a clone may present when the server asks for one.
|
||||||
@@ -220,7 +226,7 @@ func Build(ctx context.Context, run Runner, publish Publisher,
|
|||||||
}
|
}
|
||||||
say("done", "%s at %s — %d artifact(s) pinned", manifest.Module, short(commit), len(built))
|
say("done", "%s at %s — %d artifact(s) pinned", manifest.Module, short(commit), len(built))
|
||||||
return Result{Manifest: resolved, Commit: commit, Built: built,
|
return Result{Manifest: resolved, Commit: commit, Built: built,
|
||||||
Against: against(within, manifest, stoodOn)}, nil
|
Against: against(within, manifest, stoodOn), Read: readBy(manifest)}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log is where a build says what it is doing, step by step. Nil is silent — the tests pass none,
|
// Log is where a build says what it is doing, step by step. Nil is silent — the tests pass none,
|
||||||
@@ -1016,3 +1022,31 @@ func instructions(recipe string) []string {
|
|||||||
flush()
|
flush()
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// readBy is every repository other than the module's own that this build's recipes read source from,
|
||||||
|
// each once and in a fixed order, so two builds of one commit report the same thing the same way.
|
||||||
|
func readBy(manifest catalogue.Manifest) []catalogue.ArtifactContext {
|
||||||
|
if manifest.Build == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
seen := map[string]bool{}
|
||||||
|
var out []catalogue.ArtifactContext
|
||||||
|
for _, a := range manifest.Build.Artifacts {
|
||||||
|
if a.Context == nil || a.Context.Repository == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key := a.Context.Repository + "#" + a.Context.Ref
|
||||||
|
if seen[key] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[key] = true
|
||||||
|
out = append(out, *a.Context)
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(i, j int) bool {
|
||||||
|
if out[i].Repository != out[j].Repository {
|
||||||
|
return out[i].Repository < out[j].Repository
|
||||||
|
}
|
||||||
|
return out[i].Ref < out[j].Ref
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|||||||
@@ -180,6 +180,20 @@ func (r Registry) MirrorImage(ctx context.Context, from, repository string) (str
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
// **Already held is already mirrored.** A base is named by digest, and a digest this registry
|
||||||
|
// holds under the module's repository is the same bytes whatever upstream would say — so
|
||||||
|
// upstream is not asked. Asked every build, the public hub's anonymous pull limit was reached
|
||||||
|
// on the first merge that rebuilt a whole catalogue (2026-09-28), and every module whose base
|
||||||
|
// lives there failed on a copy it did not need.
|
||||||
|
if strings.HasPrefix(where.reference, "sha256:") {
|
||||||
|
held, err := r.has(ctx, "http://"+r.Address+"/v2/"+repository+"/manifests/"+where.reference, manifestAccept)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("asking %s whether it holds %s: %w", r.Address, from, err)
|
||||||
|
}
|
||||||
|
if held {
|
||||||
|
return r.Address + "/" + repository + "@" + where.reference, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
src := &source{client: r.client()}
|
src := &source{client: r.client()}
|
||||||
digest, err := r.copyManifest(ctx, src, where, where.reference, repository)
|
digest, err := r.copyManifest(ctx, src, where, where.reference, repository)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -103,6 +103,20 @@ func (m *theMeshsRegistry) handler() http.Handler {
|
|||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
switch {
|
switch {
|
||||||
|
case r.Method == http.MethodHead && strings.Contains(r.URL.Path, "/manifests/"):
|
||||||
|
// **As strictly as a real registry.** A manifest is answered only in a media type the
|
||||||
|
// caller named; a request with no Accept is answered as if nothing were there. The fake
|
||||||
|
// used to answer regardless, which is why it could not catch a check that asked without
|
||||||
|
// one — and the mesh copied every base again (2026-09-28).
|
||||||
|
if !strings.Contains(r.Header.Get("Accept"), "manifest") && !strings.Contains(r.Header.Get("Accept"), "index") {
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, ok := m.manifests[r.URL.Path[strings.LastIndex(r.URL.Path, "/")+1:]]; ok {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
} else {
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
}
|
||||||
case r.Method == http.MethodHead && strings.Contains(r.URL.Path, "/blobs/"):
|
case r.Method == http.MethodHead && strings.Contains(r.URL.Path, "/blobs/"):
|
||||||
if _, ok := m.blobs[r.URL.Path[strings.LastIndex(r.URL.Path, "/")+1:]]; ok {
|
if _, ok := m.blobs[r.URL.Path[strings.LastIndex(r.URL.Path, "/")+1:]]; ok {
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
@@ -219,3 +233,27 @@ func TestATagBeforeTheDigestIsNotPartOfTheRepository(t *testing.T) {
|
|||||||
t.Fatalf("got %+v", got)
|
t.Fatalf("got %+v", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A base this registry already holds by digest is not asked of upstream at all: the public hub
|
||||||
|
// limits anonymous pulls, and a catalogue rebuilt on one merge asked it once per module.
|
||||||
|
func TestABaseAlreadyHeldIsNotAskedOfUpstream(t *testing.T) {
|
||||||
|
src, indexDigest, _ := anUpstreamRegistry(t)
|
||||||
|
dst := &theMeshsRegistry{blobs: map[string][]byte{}, manifests: map[string][]byte{}}
|
||||||
|
dstServer := httptest.NewServer(dst.handler())
|
||||||
|
defer dstServer.Close()
|
||||||
|
address := strings.TrimPrefix(dstServer.URL, "http://")
|
||||||
|
r := Registry{Address: address, HTTP: src.Client()}
|
||||||
|
host := strings.TrimPrefix(src.URL, "http://")
|
||||||
|
if _, err := r.MirrorImage(context.Background(), host+"/library/thing:latest", "hello-web/server"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// Upstream gone: the pinned base is answered from what the mesh holds.
|
||||||
|
src.Close()
|
||||||
|
reference, err := r.MirrorImage(context.Background(), host+"/library/thing@"+indexDigest, "hello-web/server")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("a base the registry holds was asked of an upstream that is gone: %v", err)
|
||||||
|
}
|
||||||
|
if reference != address+"/hello-web/server@"+indexDigest {
|
||||||
|
t.Fatalf("pinned as %q", reference)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -122,11 +122,23 @@ func (r Registry) PublishArchive(ctx context.Context, repository string, body []
|
|||||||
return final, nil
|
return final, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r Registry) has(ctx context.Context, url string) (bool, error) {
|
// has is whether this registry already holds what is at that URL.
|
||||||
|
//
|
||||||
|
// **A manifest HEAD must say what it accepts.** A registry answers a manifest request only in a media
|
||||||
|
// type the caller named, and a bare HEAD — no Accept at all — is answered 404 for a manifest it holds
|
||||||
|
// perfectly well. Measured against the mesh's own registry (2026-09-28): the same digest answered 200
|
||||||
|
// with the manifest media types and 404 without them, so a check written without them concluded the
|
||||||
|
// registry held nothing, copied every base again, and exhausted the public hub's pull limit. A blob
|
||||||
|
// needs no Accept, which is why this went unnoticed: the same helper was right for blobs and wrong
|
||||||
|
// for manifests.
|
||||||
|
func (r Registry) has(ctx context.Context, url string, accept ...string) (bool, error) {
|
||||||
request, err := http.NewRequestWithContext(ctx, http.MethodHead, url, nil)
|
request, err := http.NewRequestWithContext(ctx, http.MethodHead, url, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
|
for _, media := range accept {
|
||||||
|
request.Header.Add("Accept", media)
|
||||||
|
}
|
||||||
response, err := r.client().Do(request)
|
response, err := r.client().Do(request)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, fmt.Errorf("cannot reach the registry at %s: %w", r.Address, err)
|
return false, fmt.Errorf("cannot reach the registry at %s: %w", r.Address, err)
|
||||||
|
|||||||
@@ -179,3 +179,24 @@ func TestTheBasesABuildWasHandedAreWhatItStoodOn(t *testing.T) {
|
|||||||
t.Fatalf("the bases the build was handed were not what it stood on: %v", got)
|
t.Fatalf("the bases the build was handed were not what it stood on: %v", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// What a build read besides its module's own repository is the second repository its recipes name,
|
||||||
|
// each once: a module that packages source living elsewhere is affected when that source moves.
|
||||||
|
func TestWhatABuildReadIsTheRepositoriesItsRecipesName(t *testing.T) {
|
||||||
|
elsewhere := catalogue.ArtifactContext{Repository: "http://forge.internal:20000/novox/mesh-controller.git", Ref: "main"}
|
||||||
|
manifest := catalogue.Manifest{
|
||||||
|
Module: "builder",
|
||||||
|
Build: &catalogue.Build{Artifacts: []catalogue.Artifact{
|
||||||
|
{Name: "server", Kind: catalogue.ArtifactImage, From: "Dockerfile", Context: &elsewhere},
|
||||||
|
{Name: "tools", Kind: catalogue.ArtifactImage, From: "Dockerfile", Context: &elsewhere},
|
||||||
|
{Name: "config", Kind: catalogue.ArtifactArchive, From: "etc"},
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
read := readBy(manifest)
|
||||||
|
if len(read) != 1 || read[0] != elsewhere {
|
||||||
|
t.Fatalf("the repositories this build read are %+v", read)
|
||||||
|
}
|
||||||
|
if readBy(catalogue.Manifest{Module: "gitea", Build: &catalogue.Build{}}) != nil {
|
||||||
|
t.Fatal("a module whose recipes name no other repository read one")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -59,7 +59,11 @@ func anchorRendering(adopted bool) Rendering {
|
|||||||
Values: map[string]any{ExposeSetting: map[string]any{"5000": FromEverywhere}}}}},
|
Values: map[string]any{ExposeSetting: map[string]any{"5000": FromEverywhere}}}}},
|
||||||
Mesh: []string{"10.42.0.1"},
|
Mesh: []string{"10.42.0.1"},
|
||||||
Foundation: []int{5671},
|
Foundation: []int{5671},
|
||||||
Adopted: adopted,
|
// What the machine reported faces outside, which every rule in the filter is written
|
||||||
|
// around (novox/hq ADR 0140).
|
||||||
|
OutwardLinks: []string{"eth0"},
|
||||||
|
TunnelInterface: "mesh0",
|
||||||
|
Adopted: adopted,
|
||||||
// Genesis takes the foundation's modules.
|
// Genesis takes the foundation's modules.
|
||||||
Taken: map[string]bool{"postgres": true, "lavinmq": true},
|
Taken: map[string]bool{"postgres": true, "lavinmq": true},
|
||||||
}
|
}
|
||||||
@@ -575,11 +579,13 @@ func TestAGivenMachineSideReachesTheFilterTheOpeningAndTheConsumer(t *testing.T)
|
|||||||
}
|
}
|
||||||
r := Resolution{Node: "anchor", Modules: []Manifest{forge}}
|
r := Resolution{Node: "anchor", Modules: []Manifest{forge}}
|
||||||
with := Rendering{
|
with := Rendering{
|
||||||
Ports: map[string]map[int]int{"forge": portsAsThePlanWould(forge, given)},
|
Ports: map[string]map[int]int{"forge": portsAsThePlanWould(forge, given)},
|
||||||
Given: map[string]map[int]int{"forge": given},
|
Given: map[string]map[int]int{"forge": given},
|
||||||
Mesh: []string{"10.77.0.1"},
|
Mesh: []string{"10.77.0.1"},
|
||||||
Adopted: true,
|
Adopted: true,
|
||||||
Taken: map[string]bool{"forge": true},
|
OutwardLinks: []string{"eth0"},
|
||||||
|
TunnelInterface: "mesh0",
|
||||||
|
Taken: map[string]bool{"forge": true},
|
||||||
}
|
}
|
||||||
|
|
||||||
// What the runtime is handed: the machine's own port on the outside, the container's within.
|
// What the runtime is handed: the machine's own port on the outside, the container's within.
|
||||||
@@ -660,9 +666,11 @@ func TestALongFormPortIsOpenedWhereTheManifestPublishesIt(t *testing.T) {
|
|||||||
forge := aForge()
|
forge := aForge()
|
||||||
r := Resolution{Node: "anchor", Modules: []Manifest{forge}}
|
r := Resolution{Node: "anchor", Modules: []Manifest{forge}}
|
||||||
composed, err := r.Compose(Rendering{
|
composed, err := r.Compose(Rendering{
|
||||||
Ports: map[string]map[int]int{"forge": portsAsThePlanWould(forge, nil)},
|
Ports: map[string]map[int]int{"forge": portsAsThePlanWould(forge, nil)},
|
||||||
Mesh: []string{"10.77.0.1"},
|
Mesh: []string{"10.77.0.1"},
|
||||||
Adopted: true,
|
Adopted: true,
|
||||||
|
OutwardLinks: []string{"eth0"},
|
||||||
|
TunnelInterface: "mesh0",
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
|
|||||||
@@ -153,6 +153,26 @@ func (b *Build) problems(module string) []string {
|
|||||||
"%s: %q is a bundle and says no language, so nothing can choose a compiler "+
|
"%s: %q is a bundle and says no language, so nothing can choose a compiler "+
|
||||||
"for it", module, a.Name))
|
"for it", module, a.Name))
|
||||||
}
|
}
|
||||||
|
// **A system, for a language that compiles to a binary** (novox/hq ADR 0142). A binary
|
||||||
|
// is pinned to one operating system at link time so a host refuses to touch a machine
|
||||||
|
// it was not built for (novox/hq ADR 0005); an artifact that says nothing would be
|
||||||
|
// compiled for whatever the build machine happened to be, which reads as portable and
|
||||||
|
// is not.
|
||||||
|
if compiled := compilesToABinary(a.Language); compiled && strings.TrimSpace(a.System) == "" {
|
||||||
|
problems = append(problems, fmt.Sprintf(
|
||||||
|
"%s: %q is compiled to a binary and says no system, so it would be built for "+
|
||||||
|
"whatever the build machine happens to be. Declare one artifact per "+
|
||||||
|
"system: %s", module, a.Name, spokenSystems()))
|
||||||
|
} else if !compiled && strings.TrimSpace(a.System) != "" {
|
||||||
|
problems = append(problems, fmt.Sprintf(
|
||||||
|
"%s: %q names the system %q and is written in %q, which compiles to code that "+
|
||||||
|
"runs anywhere — a system that decides nothing reads as though it did",
|
||||||
|
module, a.Name, a.System, a.Language))
|
||||||
|
} else if compiled && !knownSystem(a.System) {
|
||||||
|
problems = append(problems, fmt.Sprintf(
|
||||||
|
"%s: %q is built for %q, and a system is %s",
|
||||||
|
module, a.Name, a.System, spokenSystems()))
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
if a.From == "" {
|
if a.From == "" {
|
||||||
problems = append(problems, fmt.Sprintf(
|
problems = append(problems, fmt.Sprintf(
|
||||||
@@ -193,3 +213,42 @@ func oneOrOther(n int) string {
|
|||||||
}
|
}
|
||||||
return "them"
|
return "them"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Systems the mesh builds binaries for, which is the set a host may be pinned to (novox/hq ADR 0005).
|
||||||
|
//
|
||||||
|
// **A closed list, and the host's own, not the compiler's.** These are not the values a Go toolchain
|
||||||
|
// would call an operating system — the difference between two of them is a C library, not a kernel.
|
||||||
|
// They are what a machine reports itself to be and what a host is linked to refuse, so the list that
|
||||||
|
// matters is the one the host understands.
|
||||||
|
var systems = []string{"alpine", "android", "arch"}
|
||||||
|
|
||||||
|
// knownSystem is whether the mesh builds for it.
|
||||||
|
func knownSystem(system string) bool {
|
||||||
|
want := strings.ToLower(strings.TrimSpace(system))
|
||||||
|
for _, s := range systems {
|
||||||
|
if s == want {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// spokenSystems is the list as a refusal says it, so a reader is one edit from right.
|
||||||
|
func spokenSystems() string {
|
||||||
|
return strings.Join(systems, ", ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// compilesToABinary is whether this language's bundle is a binary for one operating system rather
|
||||||
|
// than code that runs wherever its interpreter does.
|
||||||
|
//
|
||||||
|
// **Asked of the language, not of the artifact.** A module says what it is written in; what that
|
||||||
|
// implies is the mesh's to know, exactly as the compiler is (novox/hq ADR 0142). Asking the artifact
|
||||||
|
// would let two artifacts in one language disagree about whether they are portable.
|
||||||
|
func compilesToABinary(language string) bool {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(language)) {
|
||||||
|
case "go":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
package catalogue
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// bundleFor is a manifest whose one artifact is a bundle in the given language and system.
|
||||||
|
func bundleFor(language, system string) Manifest {
|
||||||
|
return Manifest{Module: "a-component", Build: &Build{Artifacts: []Artifact{
|
||||||
|
{Name: "binary", Kind: ArtifactBundle, Language: language, System: system},
|
||||||
|
}}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func problemsOf(t *testing.T, m Manifest) string {
|
||||||
|
t.Helper()
|
||||||
|
return strings.Join(m.Build.problems(m.Module), "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// **A language that compiles to a binary must say which system.**
|
||||||
|
//
|
||||||
|
// A binary is pinned to one operating system at link time, so a host refuses to touch a machine it
|
||||||
|
// was not built for. An artifact that says nothing would be compiled for whatever the build machine
|
||||||
|
// happened to be — which reads as portable and is not, and is the fault this check exists for.
|
||||||
|
func TestABinaryMustSayWhichSystemItIsFor(t *testing.T) {
|
||||||
|
got := problemsOf(t, bundleFor("go", ""))
|
||||||
|
if !strings.Contains(got, "says no system") {
|
||||||
|
t.Fatalf("a compiled bundle with no system was accepted:\n%s", got)
|
||||||
|
}
|
||||||
|
// And the refusal names what it could have said, so a reader is one edit from right.
|
||||||
|
for _, system := range []string{"alpine", "android", "arch"} {
|
||||||
|
if !strings.Contains(got, system) {
|
||||||
|
t.Fatalf("the refusal does not name %q as a choice:\n%s", system, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestABinaryThatNamesASystemIsAccepted(t *testing.T) {
|
||||||
|
if got := problemsOf(t, bundleFor("go", "arch")); got != "" {
|
||||||
|
t.Fatalf("a compiled bundle naming a system was refused:\n%s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A system the mesh does not build for is refused where it is written. These are the host's own
|
||||||
|
// names, not a compiler's: the difference between two of them is a C library rather than a kernel,
|
||||||
|
// so a value that looks like an operating system to a toolchain is still wrong here.
|
||||||
|
func TestASystemTheMeshDoesNotBuildForIsRefused(t *testing.T) {
|
||||||
|
for _, wrong := range []string{"linux", "debian", "darwin"} {
|
||||||
|
got := problemsOf(t, bundleFor("go", wrong))
|
||||||
|
if !strings.Contains(got, "and a system is") {
|
||||||
|
t.Fatalf("%q was accepted as a system:\n%s", wrong, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// **And a language that runs anywhere must not name one.** A system that decides nothing reads as
|
||||||
|
// though it did, which is the same fault as a restriction that restricts nothing (novox/hq ADR 0045).
|
||||||
|
func TestAPortableBundleMayNotNameASystem(t *testing.T) {
|
||||||
|
got := problemsOf(t, bundleFor("typescript", "arch"))
|
||||||
|
if !strings.Contains(got, "runs anywhere") {
|
||||||
|
t.Fatalf("a portable bundle was allowed to name a system:\n%s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPortableBundleNamingNoSystemIsAccepted(t *testing.T) {
|
||||||
|
if got := problemsOf(t, bundleFor("typescript", "")); got != "" {
|
||||||
|
t.Fatalf("an ordinary bundle was refused:\n%s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// One component, one artifact per system: the shape the mesh's own binaries are declared in, and the
|
||||||
|
// reason the target is the artifact's rather than the recipe's.
|
||||||
|
func TestOneArtifactPerSystemIsAccepted(t *testing.T) {
|
||||||
|
m := Manifest{Module: "the-host", Build: &Build{Artifacts: []Artifact{
|
||||||
|
{Name: "arch", Kind: ArtifactBundle, Language: "go", System: "arch"},
|
||||||
|
{Name: "alpine", Kind: ArtifactBundle, Language: "go", System: "alpine"},
|
||||||
|
{Name: "android", Kind: ArtifactBundle, Language: "go", System: "android"},
|
||||||
|
}}}
|
||||||
|
if got := problemsOf(t, m); got != "" {
|
||||||
|
t.Fatalf("one artifact per system was refused:\n%s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package catalogue
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestEveryCatalogueManifestParses runs the real catalogue through the real gate.
|
||||||
|
//
|
||||||
|
// Not a fixture: the point is whether the manifests as written are accepted by the control plane that
|
||||||
|
// will read them, and a copy of one manifest proves nothing about the other seventy-one.
|
||||||
|
func TestEveryCatalogueManifestParses(t *testing.T) {
|
||||||
|
root := os.Getenv("MESH_CATALOGUE")
|
||||||
|
if root == "" {
|
||||||
|
t.Skip("set MESH_CATALOGUE to a catalogue checkout to run this")
|
||||||
|
}
|
||||||
|
found, err := filepath.Glob(filepath.Join(root, "modules", "*", "module.json"))
|
||||||
|
if err != nil || len(found) == 0 {
|
||||||
|
t.Fatalf("no manifests under %s: %v", root, err)
|
||||||
|
}
|
||||||
|
named, routed := 0, 0
|
||||||
|
for _, p := range found {
|
||||||
|
raw, err := os.ReadFile(p)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%s: %v", p, err)
|
||||||
|
}
|
||||||
|
m, err := ParseManifest(raw)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("%s: %v", filepath.Base(filepath.Dir(p)), err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, l := range m.Listens {
|
||||||
|
if l.Name != "" {
|
||||||
|
named++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for port := range RoutedPorts(m) {
|
||||||
|
_ = port
|
||||||
|
routed++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Logf("%d manifests, %d named endpoints, %d routed endpoints resolved", len(found), named, routed)
|
||||||
|
if named == 0 {
|
||||||
|
t.Fatal("no endpoint in the catalogue is named, so this proved nothing")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -124,6 +124,16 @@ type Rendering struct {
|
|||||||
// nothing on this node keeps them, or the mesh has no operator key.
|
// nothing on this node keeps them, or the mesh has no operator key.
|
||||||
Kept *KeptExport
|
Kept *KeptExport
|
||||||
|
|
||||||
|
// OutwardLinks is the links this machine reported as facing outside it, which the filter is
|
||||||
|
// written around (novox/hq ADR 0140). Empty means the machine has not said, and the mesh
|
||||||
|
// composes no filter for it rather than writing a rule around a link with no name.
|
||||||
|
OutwardLinks []string
|
||||||
|
|
||||||
|
// TunnelInterface is the interface the mesh's private network runs on, named here rather than
|
||||||
|
// imported because the overlay package rests on this one. Traffic arriving on it is the mesh's,
|
||||||
|
// not this machine's own guest, so the filter admits it only by a rule.
|
||||||
|
TunnelInterface string
|
||||||
|
|
||||||
// Foundation is the ports the mesh itself needs reachable on every machine, which no module
|
// Foundation is the ports the mesh itself needs reachable on every machine, which no module
|
||||||
// declares because the foundation is not a module (novox/hq 04-ISSUES/051 and 052). The broker
|
// declares because the foundation is not a module (novox/hq 04-ISSUES/051 and 052). The broker
|
||||||
// is the one that matters: a machine dials it to enrol, and a firewall derived only from
|
// is the one that matters: a machine dials it to enrol, and a firewall derived only from
|
||||||
@@ -345,7 +355,21 @@ func (r Resolution) compose(with Rendering, owner map[string]string) ([]map[stri
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
filtering := AsNftables(rules, with.Mesh, r.PublicDomain != "", with.Foundation)
|
// **A machine that has not said which links face outside is sent no filter** (novox/hq ADR
|
||||||
|
// 0140). The whole chain is written around those links: with none, the rule that lets this
|
||||||
|
// machine's own guests keep working would name an empty set, which nftables refuses, and a rule
|
||||||
|
// set that does not load is a machine filtering nothing while its unit reports success. Refused
|
||||||
|
// here, where a person reads it, rather than on the machine — and the machine keeps the filter
|
||||||
|
// it already has.
|
||||||
|
if filters := r.filtersHere(); filters != "" && len(with.OutwardLinks) == 0 {
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"%s cannot be sent a filter: it has not reported which of its links face outside, and "+
|
||||||
|
"every rule in the chain is written around them. It reports that on each apply; "+
|
||||||
|
"`node show %s` says whether it has. Until then %s is not sent, and the machine "+
|
||||||
|
"keeps the filter it has", r.Node, r.Node, filters)
|
||||||
|
}
|
||||||
|
filtering := AsNftables(rules, with.Mesh, r.PublicDomain != "", with.Foundation,
|
||||||
|
with.OutwardLinks, with.TunnelInterface)
|
||||||
|
|
||||||
var out []map[string]any
|
var out []map[string]any
|
||||||
for _, m := range r.Modules {
|
for _, m := range r.Modules {
|
||||||
@@ -623,6 +647,9 @@ func (r Resolution) compose(with Rendering, owner map[string]string) ([]map[stri
|
|||||||
// one of them as its environment without saying so (ADR 0086, issue 041).
|
// one of them as its environment without saying so (ADR 0086, issue 041).
|
||||||
secretFiles := secretFilesOf(resources)
|
secretFiles := secretFilesOf(resources)
|
||||||
|
|
||||||
|
// Which of this module's resources its preparation runs before, if it prepares anything.
|
||||||
|
prepareBefore := preparationTarget(m)
|
||||||
|
|
||||||
for _, unsettled := range resources {
|
for _, unsettled := range resources {
|
||||||
resource, err := ApplySettings(unsettled, with.Settings[m.Module])
|
resource, err := ApplySettings(unsettled, with.Settings[m.Module])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -708,6 +735,18 @@ func (r Resolution) compose(with Rendering, owner map[string]string) ([]map[stri
|
|||||||
if renamed := reflectsRenamed(m.Module, resource["reload-on"]); renamed != nil {
|
if renamed := reflectsRenamed(m.Module, resource["reload-on"]); renamed != nil {
|
||||||
copied["reload-on"] = renamed
|
copied["reload-on"] = renamed
|
||||||
}
|
}
|
||||||
|
// **A version prepares its state before it runs** (novox/hq ADR 0135). Derived from the
|
||||||
|
// module's own resource rather than declared beside it: what prepares the state is the
|
||||||
|
// module's own code, so what it is given has to be what that code is given — and a
|
||||||
|
// second resource written by hand is a second copy to drift from the first. Placed
|
||||||
|
// immediately before it, because a run-once step stops everything the declaration
|
||||||
|
// places after it (ADR 0052), which is how a version whose preparation failed does not
|
||||||
|
// serve.
|
||||||
|
if prepareBefore != "" && fmt.Sprint(resource["id"]) == prepareBefore {
|
||||||
|
step := prepared(copied)
|
||||||
|
owner[fmt.Sprint(step["id"])] = m.Module
|
||||||
|
out = append(out, step)
|
||||||
|
}
|
||||||
owner[fmt.Sprint(copied["id"])] = m.Module
|
owner[fmt.Sprint(copied["id"])] = m.Module
|
||||||
out = append(out, copied)
|
out = append(out, copied)
|
||||||
}
|
}
|
||||||
@@ -837,6 +876,17 @@ func mapping(written string) (outer, inner int, address string, ok bool) {
|
|||||||
return outer, inner, strings.Join(parts[:len(parts)-2], ":"), true
|
return outer, inner, strings.Join(parts[:len(parts)-2], ":"), true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// filtersHere is the module on this node that loads the machine's packet filter, or empty when none
|
||||||
|
// does. Named rather than counted: a refusal that says which module is one step from acted on.
|
||||||
|
func (r Resolution) filtersHere() string {
|
||||||
|
for _, m := range r.Modules {
|
||||||
|
if m.Filtering != nil {
|
||||||
|
return m.Module
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
// Rules is the rule set this node's filter is derived from: every module's listens, what was
|
// Rules is the rule set this node's filter is derived from: every module's listens, what was
|
||||||
// computed for this machine, and each module's per-node exposure. The same answer whether the node
|
// computed for this machine, and each module's per-node exposure. The same answer whether the node
|
||||||
// is adopted or converged — the one loads it as a filter, the other declares it as openings.
|
// is adopted or converged — the one loads it as a filter, the other declares it as openings.
|
||||||
@@ -847,6 +897,35 @@ func (r Resolution) Rules(with Rendering) ([]Rule, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
// And how far each endpoint reaches, which says the same thing to the filter and more
|
||||||
|
// besides (novox/hq ADR 0138). Folded in here rather than beside: the filter has one
|
||||||
|
// question — from where — and a reach answers it, so giving it two inputs would let them
|
||||||
|
// disagree. Reaches refuses a port that both name, so this cannot silently prefer one.
|
||||||
|
reaches, err := Reaches(m, with.Settings[m.Module])
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// **Only for an endpoint the proxy does not serve.** A routed endpoint's port is how the
|
||||||
|
// proxy reaches it and nothing else (ADR 0045), so `public` there asks for a public name and
|
||||||
|
// says nothing about the port — opening it to the world as well would undo the arrangement
|
||||||
|
// the proxy exists for, and would silently reopen a port an operator had narrowed.
|
||||||
|
//
|
||||||
|
// Found by trying to express a real module: one whose routed name must be public and whose
|
||||||
|
// machine-side port must not be. Under one value for both, there was no way to say it.
|
||||||
|
routed := RoutedPorts(m)
|
||||||
|
for port, reach := range reaches {
|
||||||
|
if routed[port] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
source, ok := FilterSource(reach)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("%s: %q is not a reach the filter can read", m.Module, reach)
|
||||||
|
}
|
||||||
|
if e == nil {
|
||||||
|
e = map[int]string{}
|
||||||
|
}
|
||||||
|
e[port] = source
|
||||||
|
}
|
||||||
if e != nil {
|
if e != nil {
|
||||||
exposure[m.Module] = e
|
exposure[m.Module] = e
|
||||||
}
|
}
|
||||||
@@ -1015,7 +1094,15 @@ func (r Resolution) contributions(settings SettingsBy, grants []Grant,
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("%s contributing to %s: %w", m.Module, to, err)
|
return nil, fmt.Errorf("%s contributing to %s: %w", m.Module, to, err)
|
||||||
}
|
}
|
||||||
composeName(values, r.PublicDomain, r.At)
|
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)
|
||||||
|
}
|
||||||
|
composeName(values, r.PublicDomain, r.At, reaches, endpointPorts(m), blocks)
|
||||||
out[to] = append(out[to], Contribution{From: m.Module, Values: values})
|
out[to] = append(out[to], Contribution{From: m.Module, Values: values})
|
||||||
}
|
}
|
||||||
// Several contributions to one requirement (ADR 0094's sibling for `contributes`): an
|
// Several contributions to one requirement (ADR 0094's sibling for `contributes`): an
|
||||||
@@ -1029,7 +1116,15 @@ func (r Resolution) contributions(settings SettingsBy, grants []Grant,
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("%s contributing %s to %s: %w", m.Module, local, to, err)
|
return nil, fmt.Errorf("%s contributing %s to %s: %w", m.Module, local, to, err)
|
||||||
}
|
}
|
||||||
composeName(values, r.PublicDomain, r.At)
|
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)
|
||||||
|
}
|
||||||
|
composeName(values, r.PublicDomain, r.At, reaches, endpointPorts(m), blocks)
|
||||||
out[to] = append(out[to], Contribution{From: m.Module, Values: values})
|
out[to] = append(out[to], Contribution{From: m.Module, Values: values})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1060,10 +1155,44 @@ func (r Resolution) contributions(settings SettingsBy, grants []Grant,
|
|||||||
// the running mesh keeps serving the full names it has. And a labelled contribution on a node with
|
// the running mesh keeps serving the full names it has. And a labelled contribution on a node with
|
||||||
// no public domain composes nothing — there is nothing to join it to — which reads downstream as a
|
// no public domain composes nothing — there is nothing to join it to — which reads downstream as a
|
||||||
// route that named no host, the same as it would have before this existed.
|
// route that named no host, the same as it would have before this existed.
|
||||||
func composeName(values map[string]any, publicDomain, internalDomain string) {
|
func composeName(values map[string]any, publicDomain, internalDomain string, reaches map[int]string,
|
||||||
|
ports map[string]int, blocks map[string]Endpoint) {
|
||||||
if values == nil {
|
if values == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// **The subdomain an assignment gave this endpoint**, before the name is joined (novox/hq ADR
|
||||||
|
// 0138). The module contributes a label because it names its own parts; an assignment may say a
|
||||||
|
// different one, because where a thing lives under a domain is the operator's to choose and used
|
||||||
|
// to require editing the module to change.
|
||||||
|
if name, ok := values[RouteEndpoint].(string); ok {
|
||||||
|
if ep, said := blocks[strings.TrimSpace(name)]; said && ep.Label != "" {
|
||||||
|
values["label"] = ep.Label
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// **How far the endpoint this route serves reaches decides which names exist** (novox/hq ADR
|
||||||
|
// 0138). Both were composed whenever the node had both domains, so every routed module got a
|
||||||
|
// public name and an internal one whether anybody wanted them or not — and a certificate for
|
||||||
|
// each, because the proxy certifies the names it is given.
|
||||||
|
//
|
||||||
|
// Joined by the port: a route entry names the port it serves and the module declares a listen on
|
||||||
|
// it. An entry with no port is not an endpoint's route but a rule about a name — a path-level
|
||||||
|
// refusal shadowing another route — and it inherits whatever that route's names turned out to
|
||||||
|
// be, which is why it is left alone here.
|
||||||
|
//
|
||||||
|
// Nothing said is both names, as before. That is what keeps every mesh already running identical
|
||||||
|
// until an assignment speaks.
|
||||||
|
wantPublic, wantInternal := true, true
|
||||||
|
if port, ok := endpointPortOf(values, ports); ok {
|
||||||
|
if reach, said := reaches[port]; said {
|
||||||
|
wantPublic, wantInternal = WantsPublicName(reach), WantsInternalName(reach)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !wantPublic {
|
||||||
|
publicDomain = ""
|
||||||
|
}
|
||||||
|
if !wantInternal {
|
||||||
|
internalDomain = ""
|
||||||
|
}
|
||||||
if _, already := values["name"]; already {
|
if _, already := values["name"]; already {
|
||||||
// A full name was given rather than a label. Left as-is: this is the legacy shape, and the
|
// A full name was given rather than a label. Left as-is: this is the legacy shape, and the
|
||||||
// point of the label is to not have to write the full name — a contribution that wrote both
|
// point of the label is to not have to write the full name — a contribution that wrote both
|
||||||
@@ -1610,3 +1739,101 @@ func atMachinePort(serves map[string]any, module string, ports map[string]map[in
|
|||||||
func AtPublishedPort(values map[string]any, module string, published map[int]int) map[string]any {
|
func AtPublishedPort(values map[string]any, module string, published map[int]int) map[string]any {
|
||||||
return atMachinePort(values, module, map[string]map[int]int{module: published})
|
return atMachinePort(values, module, map[string]map[int]int{module: published})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PreparationArgument is how the mesh asks a module to prepare its state: one word, to the module's
|
||||||
|
// own program, whatever that program is (novox/hq ADR 0135).
|
||||||
|
//
|
||||||
|
// **One word for every kind of module.** A module built as a Go binary receives it as its argument;
|
||||||
|
// one built as a bundle receives it through the runtime, whose entry takes the same word. So the
|
||||||
|
// mesh has one way of asking and a module has one way of answering, and neither learns the other's
|
||||||
|
// shape.
|
||||||
|
const PreparationArgument = "prepare"
|
||||||
|
|
||||||
|
// preparationTarget is the resource a module's preparation runs before: its own workload.
|
||||||
|
//
|
||||||
|
// The first container carrying an artifact this module built, and not itself a step — that is the
|
||||||
|
// thing that runs the module's code, and therefore the thing whose state must be ready. Empty when
|
||||||
|
// the module prepares nothing, or when nothing it declares could run its code.
|
||||||
|
//
|
||||||
|
// **A module with two own workloads gates the first of them.** Five modules in the catalogue declare
|
||||||
|
// more than one container of their own, none of them preparing anything today. If one ever does and
|
||||||
|
// its second workload shares the state, the gate is in front of the first — stated here because the
|
||||||
|
// alternative is a field asking an author to restate what the mesh can see.
|
||||||
|
func preparationTarget(m Manifest) string {
|
||||||
|
if !m.Prepares {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
for _, r := range m.Resources {
|
||||||
|
if fmt.Sprint(r["type"]) != "container" || !ownArtifact(r, m.Module) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if once, _ := r["run-once"].(bool); once {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return fmt.Sprint(r["id"])
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// ownArtifact is whether a resource runs something this module built, in either spelling a manifest
|
||||||
|
// may be in: naming the artifact, before a build resolved it, or carrying the reference a build
|
||||||
|
// recorded — this mesh's own store, under this module's name.
|
||||||
|
func ownArtifact(resource map[string]any, module string) bool {
|
||||||
|
if named, _ := resource["artifact"].(string); named != "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
image, _ := resource["image"].(string)
|
||||||
|
return strings.HasPrefix(image, ArtifactStoreScheme+module+"/")
|
||||||
|
}
|
||||||
|
|
||||||
|
// prepared is the module's own resource as the step that prepares its state: the same image, the same
|
||||||
|
// context, run to completion with the mesh's preparation argument.
|
||||||
|
//
|
||||||
|
// Three things are taken away rather than copied, each because the step runs while the version it
|
||||||
|
// prepares for is still running. A published port cannot be bound twice, and a step that tried would
|
||||||
|
// fail for a reason that has nothing to do with the state. A fixed address cannot be held twice, for
|
||||||
|
// the same reason. And a cadence is what a step is the opposite of: a container runs once and gates,
|
||||||
|
// or on a schedule, or stays up, never two (ADR 0053).
|
||||||
|
func prepared(from map[string]any) map[string]any {
|
||||||
|
step := map[string]any{}
|
||||||
|
for k, v := range from {
|
||||||
|
step[k] = v
|
||||||
|
}
|
||||||
|
// **A hyphen, not a dot.** A resource's id is `<module>.<its own id>`, and a module's name may
|
||||||
|
// itself contain a dot (`novox.be`), so the module is everything before the *last* dot — which
|
||||||
|
// only works if what the mesh derives adds no dot of its own.
|
||||||
|
step["id"] = fmt.Sprint(from["id"]) + "-prepare"
|
||||||
|
step["name"] = fmt.Sprint(from["name"]) + "-prepare"
|
||||||
|
step["run-once"] = true
|
||||||
|
step["args"] = []any{PreparationArgument}
|
||||||
|
delete(step, "ports")
|
||||||
|
delete(step, "ip")
|
||||||
|
delete(step, "schedule")
|
||||||
|
delete(step, "reload-on")
|
||||||
|
return step
|
||||||
|
}
|
||||||
|
|
||||||
|
// endpointPortOf is the port the endpoint a route serves listens on: looked up by the name the route
|
||||||
|
// gives, or read from the port it repeats (novox/hq ADR 0138).
|
||||||
|
//
|
||||||
|
// `ports` maps this module's endpoint names to their ports, computed once per module rather than
|
||||||
|
// re-scanned per contribution.
|
||||||
|
func endpointPortOf(values map[string]any, ports map[string]int) (int, bool) {
|
||||||
|
if name, ok := values[RouteEndpoint].(string); ok {
|
||||||
|
if port, found := ports[strings.TrimSpace(name)]; found {
|
||||||
|
return port, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return asPort(values["port"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// endpointPorts is a module's endpoint names against the ports they listen on.
|
||||||
|
func endpointPorts(m Manifest) map[string]int {
|
||||||
|
out := map[string]int{}
|
||||||
|
for _, l := range m.Listens {
|
||||||
|
if name := strings.TrimSpace(l.Name); name != "" {
|
||||||
|
out[name] = l.Port
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
package catalogue
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// aMediaServer is the shape one port number per key cannot express: two endpoints of different kinds.
|
||||||
|
// A web surface a proxy serves under a subdomain, and a protocol port clients dial directly because
|
||||||
|
// the client expects that number.
|
||||||
|
func aMediaServer() Manifest {
|
||||||
|
return Manifest{
|
||||||
|
Module: "media",
|
||||||
|
Listens: []Listening{
|
||||||
|
{Name: "web", Port: 80, From: FromMesh, Why: "the app, behind the proxy"},
|
||||||
|
{Name: "stream", Port: 32400, From: FromEverywhere, Fixed: true,
|
||||||
|
Why: "the client dials this number; the protocol chose it"},
|
||||||
|
},
|
||||||
|
Contributes: map[string]map[string]any{
|
||||||
|
"route": {"label": "media", RouteEndpoint: "web"},
|
||||||
|
},
|
||||||
|
// Both endpoints are published by its container, which is what lets a machine port be given
|
||||||
|
// for either: the mesh moves a port the module publishes, never one it merely listens on.
|
||||||
|
Resources: []map[string]any{
|
||||||
|
{"id": "server", "type": "container", "name": "media",
|
||||||
|
"ports": []any{"80", "32400"}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// **A route names the endpoint it serves.** A route and a listen both carried a port and nothing said
|
||||||
|
// they were the same thing; now one of them says so.
|
||||||
|
func TestARouteNamesTheEndpointItServes(t *testing.T) {
|
||||||
|
m := aMediaServer()
|
||||||
|
if port, ok := EndpointPort(m, "web"); !ok || port != 80 {
|
||||||
|
t.Fatalf("the web endpoint resolves to %d (%v), want 80", port, ok)
|
||||||
|
}
|
||||||
|
if port, ok := EndpointPort(m, "stream"); !ok || port != 32400 {
|
||||||
|
t.Fatalf("the stream endpoint resolves to %d (%v), want 32400", port, ok)
|
||||||
|
}
|
||||||
|
if _, ok := EndpointPort(m, "absent"); ok {
|
||||||
|
t.Fatal("an endpoint the module does not declare resolved to a port")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// And the routed set is read through the name, so the endpoint the proxy serves is known without a
|
||||||
|
// reader joining two numbers.
|
||||||
|
func TestTheRoutedEndpointIsFoundByName(t *testing.T) {
|
||||||
|
routed := RoutedPorts(aMediaServer())
|
||||||
|
if !routed[80] {
|
||||||
|
t.Fatalf("the routed endpoint was not found by name: %v", routed)
|
||||||
|
}
|
||||||
|
// And the directly-dialled one is not routed, which is what lets its reach govern its port.
|
||||||
|
if routed[32400] {
|
||||||
|
t.Fatalf("the endpoint clients dial directly reads as routed: %v", routed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// **Two endpoints of different shapes, configured as themselves.** The web endpoint's reach asks for
|
||||||
|
// names and leaves its port to the proxy; the stream endpoint's reach governs its port, because
|
||||||
|
// clients dial it and there is no name.
|
||||||
|
func TestTwoEndpointsOfDifferentShapesAreConfiguredSeparately(t *testing.T) {
|
||||||
|
m := aMediaServer()
|
||||||
|
settings := SettingsBy{"media": {{From: "node anchor", Values: map[string]any{
|
||||||
|
ReachSetting: map[string]any{"80": ReachBoth, "32400": ReachPublic},
|
||||||
|
}}}}
|
||||||
|
|
||||||
|
r := Resolution{Node: "anchor", Modules: []Manifest{m},
|
||||||
|
PublicDomain: "example.test", At: "anchor.internal"}
|
||||||
|
rules, err := r.Rules(Rendering{Settings: settings})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, rule := range rules {
|
||||||
|
switch rule.Port {
|
||||||
|
case 80:
|
||||||
|
if rule.From != FromMesh {
|
||||||
|
t.Fatalf("the routed endpoint's port opened to %q; the proxy is how it is reached",
|
||||||
|
rule.From)
|
||||||
|
}
|
||||||
|
case 32400:
|
||||||
|
if rule.From != FromEverywhere {
|
||||||
|
t.Fatalf("the directly-dialled endpoint's port is %q, want anywhere", rule.From)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// And the routed one carries both names, asked for by the same statement.
|
||||||
|
given, err := r.contributions(settings, nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var public, internal string
|
||||||
|
for _, c := range given["route"] {
|
||||||
|
public, _ = c.Values["name"].(string)
|
||||||
|
internal, _ = c.Values["internal-name"].(string)
|
||||||
|
}
|
||||||
|
if public != "media.example.test" || internal != "media.anchor.internal" {
|
||||||
|
t.Fatalf("names are %q and %q, want both", public, internal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A route naming an endpoint the module does not declare reaches nothing, and is refused where it is
|
||||||
|
// written rather than resolving to no port and serving nothing.
|
||||||
|
func TestARouteNamingAnEndpointTheModuleLacksIsRefused(t *testing.T) {
|
||||||
|
m := aMediaServer()
|
||||||
|
m.Contributes["route"][RouteEndpoint] = "absent"
|
||||||
|
got := strings.Join(RouteProblems(m), "\n")
|
||||||
|
if !strings.Contains(got, "does not declare") {
|
||||||
|
t.Fatalf("a route naming an absent endpoint was accepted:\n%s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// **Two endpoints called the same would make an assignment configure whichever was read last.** The
|
||||||
|
// point of a name is that it identifies one thing.
|
||||||
|
func TestTwoEndpointsWithOneNameAreRefused(t *testing.T) {
|
||||||
|
m := Manifest{Module: "twice", Listens: []Listening{
|
||||||
|
{Name: "web", Port: 80, From: FromMesh},
|
||||||
|
{Name: "web", Port: 8080, From: FromMesh},
|
||||||
|
}}
|
||||||
|
got := strings.Join(endpointNameProblems(m), "\n")
|
||||||
|
if !strings.Contains(got, "could mean either") {
|
||||||
|
t.Fatalf("two endpoints with one name were accepted:\n%s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A name that is not a name is refused where it is written: it ends up in something a person types.
|
||||||
|
func TestAnEndpointNameIsHeldToItsShape(t *testing.T) {
|
||||||
|
for _, wrong := range []string{"Web", "web port", "3000", "-web", "web_surface"} {
|
||||||
|
m := Manifest{Module: "odd", Listens: []Listening{{Name: wrong, Port: 80, From: FromMesh}}}
|
||||||
|
if got := strings.Join(endpointNameProblems(m), "\n"); !strings.Contains(got, "a name is lowercase") {
|
||||||
|
t.Fatalf("%q was accepted as an endpoint name:\n%s", wrong, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// **Every endpoint in the catalogue is unnamed today, and must stay valid.** The word ships one
|
||||||
|
// release before anything uses it.
|
||||||
|
func TestAnUnnamedEndpointIsStillValid(t *testing.T) {
|
||||||
|
m := Manifest{Module: "ordinary", Listens: []Listening{{Port: 443, From: FromEverywhere}}}
|
||||||
|
if got := endpointNameProblems(m); len(got) != 0 {
|
||||||
|
t.Fatalf("an unnamed endpoint was refused: %v", got)
|
||||||
|
}
|
||||||
|
if got := RouteProblems(m); len(got) != 0 {
|
||||||
|
t.Fatalf("a module with no route was refused: %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
package catalogue
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func configured(block map[string]any) SettingsBy {
|
||||||
|
return SettingsBy{"media": {{From: "node anchor",
|
||||||
|
Values: map[string]any{EndpointsSetting: block}}}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// **One block per endpoint, saying all three things.** The machine port, the subdomain and the reach
|
||||||
|
// were `ports`, the route's label and `reach`, each keyed by a port number, so configuring a module
|
||||||
|
// with two endpoints of different shapes meant knowing which number was which.
|
||||||
|
func TestAnEndpointsBlockSaysPortLabelAndReach(t *testing.T) {
|
||||||
|
m := aMediaServer()
|
||||||
|
// stream's reach NARROWS what the manifest says — the manifest has it from anywhere, the
|
||||||
|
// assignment says internal. Chosen deliberately: a reach that agrees with the manifest proves
|
||||||
|
// nothing about whether the block was read at all.
|
||||||
|
settings := configured(map[string]any{
|
||||||
|
"web": map[string]any{"port": 20009, "label": "cinema", "reach": ReachBoth},
|
||||||
|
"stream": map[string]any{"reach": ReachInternal},
|
||||||
|
})
|
||||||
|
|
||||||
|
// The machine port, where the mapping is read.
|
||||||
|
given, err := GivenPorts(m, settings["media"])
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if given[80] != 20009 {
|
||||||
|
t.Fatalf("the web endpoint is on machine port %d, want 20009: %v", given[80], given)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The reach, where the filter reads it.
|
||||||
|
r := Resolution{Node: "anchor", Modules: []Manifest{m},
|
||||||
|
PublicDomain: "example.test", At: "anchor.internal"}
|
||||||
|
rules, err := r.Rules(Rendering{Settings: settings})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, rule := range rules {
|
||||||
|
if rule.Port == 32400 && rule.From != FromMesh {
|
||||||
|
t.Fatalf("the directly-dialled endpoint is %q; the assignment narrowed it to the private "+
|
||||||
|
"network and the manifest's 'anywhere' should not win", rule.From)
|
||||||
|
}
|
||||||
|
if rule.Port == 80 && rule.From != FromMesh {
|
||||||
|
t.Fatalf("the routed endpoint's port opened to %q; the proxy is how it is reached", rule.From)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// And the subdomain, where the name is composed — the assignment's, not the module's.
|
||||||
|
nodes, err := r.contributions(settings, nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, c := range nodes["route"] {
|
||||||
|
if got, _ := c.Values["name"].(string); got != "cinema.example.test" {
|
||||||
|
t.Fatalf("the public name is %q, want the label the assignment gave", got)
|
||||||
|
}
|
||||||
|
if got, _ := c.Values["internal-name"].(string); got != "cinema.anchor.internal" {
|
||||||
|
t.Fatalf("the internal name is %q, want the label the assignment gave", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A block that says only a reach leaves the port to the mesh and the label to the module, which is the
|
||||||
|
// ordinary case and must not require writing the other two.
|
||||||
|
func TestABlockMaySayOnlyAReach(t *testing.T) {
|
||||||
|
m := aMediaServer()
|
||||||
|
settings := configured(map[string]any{"web": map[string]any{"reach": ReachInternal}})
|
||||||
|
r := Resolution{Node: "anchor", Modules: []Manifest{m},
|
||||||
|
PublicDomain: "example.test", At: "anchor.internal"}
|
||||||
|
nodes, err := r.contributions(settings, nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, c := range nodes["route"] {
|
||||||
|
if got, _ := c.Values["name"].(string); got != "" {
|
||||||
|
t.Fatalf("an internal endpoint composed the public name %q", got)
|
||||||
|
}
|
||||||
|
// The module's own label, untouched.
|
||||||
|
if got, _ := c.Values["internal-name"].(string); got != "media.anchor.internal" {
|
||||||
|
t.Fatalf("the internal name is %q, want the module's own label", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An endpoint the module does not declare reaches nothing, and the refusal says what it does declare.
|
||||||
|
func TestConfiguringAnEndpointTheModuleLacksIsRefused(t *testing.T) {
|
||||||
|
_, err := Endpoints(aMediaServer(), configured(map[string]any{
|
||||||
|
"admin": map[string]any{"reach": ReachInternal}})["media"])
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "does not declare") {
|
||||||
|
t.Fatalf("configuring an absent endpoint was accepted: %v", err)
|
||||||
|
}
|
||||||
|
if err != nil && !strings.Contains(err.Error(), "stream") {
|
||||||
|
t.Fatalf("the refusal does not name what the module declares: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAReachInABlockIsHeldToTheFourValues(t *testing.T) {
|
||||||
|
_, err := Endpoints(aMediaServer(), configured(map[string]any{
|
||||||
|
"web": map[string]any{"reach": "mesh"}})["media"])
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "a reach is") {
|
||||||
|
t.Fatalf("a filter word was accepted as a reach: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// **Two places giving one endpoint a machine port is the confusion this key exists to end.**
|
||||||
|
func TestAnEndpointGivenAPortTwiceIsRefused(t *testing.T) {
|
||||||
|
m := aMediaServer()
|
||||||
|
_, err := GivenPorts(m, []Layer{{From: "node anchor", Values: map[string]any{
|
||||||
|
EndpointsSetting: map[string]any{"web": map[string]any{"port": 20009}},
|
||||||
|
PortsSetting: map[string]any{"80": 30000},
|
||||||
|
}}})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "published once") {
|
||||||
|
t.Fatalf("an endpoint given two machine ports was accepted: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// And the same for its reach, said once here and once through the older key.
|
||||||
|
func TestAnEndpointWhoseReachIsAlsoExposedIsRefused(t *testing.T) {
|
||||||
|
_, err := Endpoints(aMediaServer(), []Layer{{From: "node anchor", Values: map[string]any{
|
||||||
|
EndpointsSetting: map[string]any{"web": map[string]any{"reach": ReachInternal}},
|
||||||
|
ExposeSetting: map[string]any{"80": FromEverywhere},
|
||||||
|
}}})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "same thing in different words") {
|
||||||
|
t.Fatalf("a reach said two ways was accepted: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A module whose endpoints are unnamed cannot be configured this way, and is told so rather than
|
||||||
|
// having a block silently reach nothing — which is every module in the catalogue today.
|
||||||
|
func TestAModuleWithNoNamedEndpointsIsToldSo(t *testing.T) {
|
||||||
|
m := Manifest{Module: "media", Listens: []Listening{{Port: 80, From: FromMesh}}}
|
||||||
|
_, err := Endpoints(m, configured(map[string]any{"web": map[string]any{"reach": ReachBoth}})["media"])
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "no endpoints by name") {
|
||||||
|
t.Fatalf("a module with no named endpoints accepted a block: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
+413
-21
@@ -230,7 +230,23 @@ const SSHPort = 22
|
|||||||
// It is a floor for the same reason ssh is. A machine nobody can reach is a machine nobody can
|
// It is a floor for the same reason ssh is. A machine nobody can reach is a machine nobody can
|
||||||
// repair; a machine the mesh cannot reach is a machine the mesh cannot manage. Neither is a thing
|
// repair; a machine the mesh cannot reach is a machine the mesh cannot manage. Neither is a thing
|
||||||
// any module asks for, and neither may be derived away.
|
// any module asks for, and neither may be derived away.
|
||||||
func AsNftables(rules []Rule, mesh []string, outward bool, foundation []int) string {
|
func AsNftables(rules []Rule, mesh []string, outward bool, foundation []int,
|
||||||
|
outwardLinks []string, tunnel string) string {
|
||||||
|
// The links that are not this machine's own: the ones facing outside, and the mesh's tunnel.
|
||||||
|
// Traffic arriving on any of them is admitted only by a rule below; traffic arriving anywhere
|
||||||
|
// else is this machine's own guest and is not something the mesh has a position on.
|
||||||
|
//
|
||||||
|
// The tunnel is named here deliberately. Treating it as "not outside" would make a port nothing
|
||||||
|
// declares reachable from every machine in the mesh, which is the derivation abandoned.
|
||||||
|
quoted := make([]string, 0, len(outwardLinks)+1)
|
||||||
|
for _, link := range outwardLinks {
|
||||||
|
quoted = append(quoted, fmt.Sprintf("%q", link))
|
||||||
|
}
|
||||||
|
if tunnel != "" {
|
||||||
|
quoted = append(quoted, fmt.Sprintf("%q", tunnel))
|
||||||
|
}
|
||||||
|
inward := strings.Join(quoted, ", ")
|
||||||
|
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
b.WriteString("# Computed by the mesh from what is assigned to this node.\n")
|
b.WriteString("# Computed by the mesh from what is assigned to this node.\n")
|
||||||
b.WriteString("# Edits are lost on the next declaration; change a module's listens instead.\n\n")
|
b.WriteString("# Edits are lost on the next declaration; change a module's listens instead.\n\n")
|
||||||
@@ -252,6 +268,22 @@ func AsNftables(rules []Rule, mesh []string, outward bool, foundation []int) str
|
|||||||
b.WriteString("\t\ticmp type echo-request accept\n")
|
b.WriteString("\t\ticmp type echo-request accept\n")
|
||||||
b.WriteString("\t\ticmpv6 type { echo-request, nd-neighbor-solicit, nd-neighbor-advert, nd-router-advert } accept\n")
|
b.WriteString("\t\ticmpv6 type { echo-request, nd-neighbor-solicit, nd-neighbor-advert, nd-router-advert } accept\n")
|
||||||
|
|
||||||
|
// **What this machine's own guests must be able to ask it** (novox/hq ADR 0140). A guest gets
|
||||||
|
// its address and its names from this machine, over the link it is on, and those two questions
|
||||||
|
// arrive at the input chain like any other. Denied, the guest never gets an address and never
|
||||||
|
// resolves a name — which is not "a closed port" but a network that does not work at all, and it
|
||||||
|
// is this machine's own guest asking.
|
||||||
|
//
|
||||||
|
// Asked for by the link it arrives on rather than by the address it comes from, for the reason
|
||||||
|
// the forward chain below no longer names an address: a range describes one machine and goes
|
||||||
|
// stale in silence. Anything arriving from outside, or over the tunnel, is not a guest of this
|
||||||
|
// machine and asks through a port somebody declared, like everything else.
|
||||||
|
if len(inward) > 0 {
|
||||||
|
b.WriteString("\t\t# this machine's own guests asking it for an address and for names\n")
|
||||||
|
b.WriteString(fmt.Sprintf("\t\tiifname != { %s } udp dport { 53, 67 } accept\n", inward))
|
||||||
|
b.WriteString(fmt.Sprintf("\t\tiifname != { %s } tcp dport 53 accept\n", inward))
|
||||||
|
}
|
||||||
|
|
||||||
// **ssh, always, and not because a module asked.**
|
// **ssh, always, and not because a module asked.**
|
||||||
//
|
//
|
||||||
// Every other line in this chain is derived from what is assigned here, which is the whole
|
// Every other line in this chain is derived from what is assigned here, which is the whole
|
||||||
@@ -361,19 +393,36 @@ func AsNftables(rules []Rule, mesh []string, outward bool, foundation []int) str
|
|||||||
// about the ports most worth protecting. Rehearsed on three machines: loading these rules
|
// about the ports most worth protecting. Rehearsed on three machines: loading these rules
|
||||||
// refused a port on the host and left a published container port reachable (novox/hq issue 047).
|
// refused a port on the host and left a published container port reachable (novox/hq issue 047).
|
||||||
//
|
//
|
||||||
// The way through is the one the system being replaced already used: deny by default here, and
|
// **What it constrains is traffic arriving from OUTSIDE this machine, and nothing else**
|
||||||
// then explicitly allow the runtime's own networks, so containers keep working while everything
|
// (novox/hq ADR 0140).
|
||||||
// else has to be asked for.
|
//
|
||||||
|
// It used to deny everything here and then allow the machine's own containers back by naming
|
||||||
|
// the address ranges they sit on — two ranges fixed in this file and the rest recorded per
|
||||||
|
// machine. Every way of keeping that list correct failed. A constant describes one machine. A
|
||||||
|
// recorded range goes stale in silence and cannot tell a network the mesh made from one a
|
||||||
|
// predecessor left behind. Generating it from the modules would have put half this rule set on
|
||||||
|
// the machine.
|
||||||
|
//
|
||||||
|
// The list should not exist, because the mesh has no position on a container reaching outward:
|
||||||
|
// that is not a port opened to anybody. So traffic that did not arrive from outside is accepted
|
||||||
|
// in one line, and what did arrive from outside is allowed only where a rule below admits it.
|
||||||
|
//
|
||||||
|
// The tunnel is not "not outside". Accepting everything off it would make a port nothing
|
||||||
|
// declares reachable from any machine in the mesh, which is the derivation abandoned — so it is
|
||||||
|
// named here beside the outward links, and traffic arriving on it meets the rules below like
|
||||||
|
// anything else.
|
||||||
b.WriteString("\tchain forward {\n")
|
b.WriteString("\tchain forward {\n")
|
||||||
b.WriteString("\t\ttype filter hook forward priority filter; policy drop;\n")
|
b.WriteString("\t\ttype filter hook forward priority filter; policy drop;\n")
|
||||||
b.WriteString("\t\tct state established,related accept\n")
|
b.WriteString("\t\tct state established,related accept\n")
|
||||||
b.WriteString("\t\tct state invalid drop\n")
|
b.WriteString("\t\tct state invalid drop\n")
|
||||||
b.WriteString("\n")
|
b.WriteString("\n")
|
||||||
// What the container runtime created. Without these, denying by default stops every container
|
// Only when there is a link to name. An empty set is a line nftables refuses, and a rule set
|
||||||
// on the machine — which is exactly the failure the absent chain was avoiding, avoided properly.
|
// that does not load is a machine filtering nothing while its unit reports success — so the
|
||||||
for _, network := range runtimeNetworks {
|
// chain denies rather than renders nonsense. Composing a declaration for a machine that has
|
||||||
b.WriteString(fmt.Sprintf("\t\t# %s\n", network.why))
|
// named none is refused upstream, so this is a floor and not a path anything travels.
|
||||||
b.WriteString(fmt.Sprintf("\t\tip saddr %s accept\n", network.cidr))
|
if inward != "" {
|
||||||
|
b.WriteString("\t\t# this machine's own guests reaching outward: not a port opened to anybody\n")
|
||||||
|
b.WriteString(fmt.Sprintf("\t\tiifname != { %s } accept\n", inward))
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(rules) > 0 {
|
if len(rules) > 0 {
|
||||||
@@ -430,18 +479,6 @@ func AsNftables(rules []Rule, mesh []string, outward bool, foundation []int) str
|
|||||||
return b.String()
|
return b.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
// runtimeNetworks are the container runtime's own networks, which must keep working when the
|
|
||||||
// forward chain denies by default.
|
|
||||||
//
|
|
||||||
// Taken from what the system being replaced allows, which has been carrying this machine's traffic
|
|
||||||
// for months: the runtime's bridge range and the range its compose files are given. A machine whose
|
|
||||||
// runtime is configured with something else needs this to say so — which is a thing the mesh cannot
|
|
||||||
// derive and a reason this list is named here rather than computed.
|
|
||||||
var runtimeNetworks = []struct{ cidr, why string }{
|
|
||||||
{"172.16.0.0/12", "the container runtime's bridge networks"},
|
|
||||||
{"192.168.128.0/17", "the networks its compose files are given"},
|
|
||||||
}
|
|
||||||
|
|
||||||
// byFamily splits addresses into the two nftables understands separately.
|
// byFamily splits addresses into the two nftables understands separately.
|
||||||
//
|
//
|
||||||
// `ip saddr` and `ip6 saddr` are different matches, and one set holding both families is a syntax
|
// `ip saddr` and `ip6 saddr` are different matches, and one set holding both families is a syntax
|
||||||
@@ -499,6 +536,21 @@ const MeshWideLayer = "the mesh"
|
|||||||
// two mappings share a number, it is an entry one of them writes over the other's, and the reader
|
// two mappings share a number, it is an entry one of them writes over the other's, and the reader
|
||||||
// that finds the survivor disagrees with the reader that recomputes it.
|
// that finds the survivor disagrees with the reader that recomputes it.
|
||||||
func GivenPorts(m Manifest, layers []Layer) (map[int]int, error) {
|
func GivenPorts(m Manifest, layers []Layer) (map[int]int, error) {
|
||||||
|
// An endpoint's own block may put it on a machine port, which is the same thing `ports` says about
|
||||||
|
// the number rather than about the endpoint (novox/hq ADR 0138). Collected first and then let the
|
||||||
|
// older key be read, which refuses a port said twice.
|
||||||
|
byName, err := Endpoints(m, layers)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
named := map[int]int{}
|
||||||
|
for name, ep := range byName {
|
||||||
|
if ep.Port == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
named[endpointPorts(m)[name]] = ep.Port
|
||||||
|
}
|
||||||
|
|
||||||
// Every name a setting may use, and the mapping it names.
|
// Every name a setting may use, and the mapping it names.
|
||||||
names := map[int][]publishing{}
|
names := map[int][]publishing{}
|
||||||
for _, p := range publishedPorts(m) {
|
for _, p := range publishedPorts(m) {
|
||||||
@@ -597,6 +649,17 @@ func GivenPorts(m Manifest, layers []Layer) (map[int]int, error) {
|
|||||||
out[key], by[key] = at, port
|
out[key], by[key] = at, port
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// And what the endpoints' own blocks put them on. Refused rather than merged where both keys name
|
||||||
|
// one endpoint: two places giving a port is the confusion this key exists to end.
|
||||||
|
for wanted, at := range named {
|
||||||
|
if was, twice := out[wanted]; twice && was != at {
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"%s puts its port %d on %d through %s and on %d through %s — one endpoint, two "+
|
||||||
|
"machine ports, and it is published once. Keep the endpoint's own block",
|
||||||
|
m.Module, wanted, at, EndpointsSetting, was, PortsSetting)
|
||||||
|
}
|
||||||
|
out[wanted] = at
|
||||||
|
}
|
||||||
if len(out) == 0 {
|
if len(out) == 0 {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
@@ -664,3 +727,332 @@ func sortedPorts(of map[int]int) []int {
|
|||||||
sort.Ints(out)
|
sort.Ints(out)
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ReachSetting is the settings key that says how far one of a module's endpoints reaches, per node
|
||||||
|
// (novox/hq ADR 0138):
|
||||||
|
//
|
||||||
|
// {"reach": {"3000": "internal"}}
|
||||||
|
//
|
||||||
|
// **One value, three readers.** Reachability used to be settled three times over: the filter read a
|
||||||
|
// listen's source, which `expose` could override; the proxy composed a public name and an internal
|
||||||
|
// name for every route it was given, because it could; and the certificate authority followed from
|
||||||
|
// which names existed. Each was defensible and the combination was unstated, so "this endpoint must
|
||||||
|
// not be public" could not be written and was therefore enforced by nothing — while a public
|
||||||
|
// certificate for that very name was obtained anyway.
|
||||||
|
//
|
||||||
|
// It keys on the port the module declares, the same key `ports` and `expose` use. A route names that
|
||||||
|
// port too, which is what lets one statement reach the names as well as the filter: of the 36 route
|
||||||
|
// entries in the catalogue, 35 name a port that the same module declares a listen on, and the one
|
||||||
|
// that does not is a path-level refusal — a rule about a name rather than an endpoint.
|
||||||
|
const ReachSetting = "reach"
|
||||||
|
|
||||||
|
// How far an endpoint reaches. Four values, because they have to cover everything `expose` could say
|
||||||
|
// as well as the two names.
|
||||||
|
const (
|
||||||
|
// ReachMachine is this machine only: not the private network, not the world, and no name.
|
||||||
|
ReachMachine = "machine"
|
||||||
|
// ReachInternal is the private network, under the internal name and not the public one.
|
||||||
|
ReachInternal = "internal"
|
||||||
|
// ReachPublic is the world, under the public name and not the internal one.
|
||||||
|
ReachPublic = "public"
|
||||||
|
// ReachBoth is the world, under both names — each certified by its own authority.
|
||||||
|
//
|
||||||
|
// The filter cannot distinguish this from ReachPublic, and should not try: the mesh's addresses
|
||||||
|
// are a subset of anywhere. What differs is the names, which is the whole reason reach is not
|
||||||
|
// simply the filter's vocabulary with nicer words.
|
||||||
|
ReachBoth = "both"
|
||||||
|
)
|
||||||
|
|
||||||
|
// reaches is every value, in the order a refusal lists them.
|
||||||
|
var reaches = []string{ReachMachine, ReachInternal, ReachPublic, ReachBoth}
|
||||||
|
|
||||||
|
// RoutedPorts are the ports a module serves through a proxy, taken from its route contributions.
|
||||||
|
//
|
||||||
|
// **A routed endpoint's port is how the proxy reaches it, and nothing else.** That is ADR 0045's
|
||||||
|
// decision and it is older than reach: a public service listens `from: mesh`, only the proxy reaches
|
||||||
|
// it, and it is exposed by name. So `public` on a routed endpoint asks for a public *name*; opening
|
||||||
|
// that port to the world as well would undo the arrangement the proxy exists for.
|
||||||
|
//
|
||||||
|
// Measured before this was written, not reasoned: a module's routed name answered from the internet
|
||||||
|
// over TLS while its machine-side port was refused from the same place. The port is not the path.
|
||||||
|
func RoutedPorts(m Manifest) map[int]bool {
|
||||||
|
out := map[int]bool{}
|
||||||
|
note := func(values map[string]any) {
|
||||||
|
// **The endpoint it serves, by name where it says one.** A route repeating a port number is
|
||||||
|
// the older shape and still read: 35 of the catalogue's 36 route entries name a port their
|
||||||
|
// module declares a listen on (novox/hq ADR 0138).
|
||||||
|
if name, ok := values[RouteEndpoint].(string); ok {
|
||||||
|
if port, found := EndpointPort(m, name); found {
|
||||||
|
out[port] = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if port, ok := asPort(values["port"]); ok {
|
||||||
|
out[port] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if values, ok := m.Contributes["route"]; ok {
|
||||||
|
note(values)
|
||||||
|
}
|
||||||
|
for _, values := range m.ContributesMany["route"] {
|
||||||
|
note(values)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// FilterSource is the source a reach means to the packet filter.
|
||||||
|
//
|
||||||
|
// `public` and `both` are the same here. A reach that opened a port to the mesh and not to the world
|
||||||
|
// would be `internal`; there is no reach that opens it to the world and *not* to the mesh, because a
|
||||||
|
// filter cannot express "everyone except these" and nobody has asked for it.
|
||||||
|
func FilterSource(reach string) (string, bool) {
|
||||||
|
switch reach {
|
||||||
|
case ReachMachine:
|
||||||
|
return FromMachine, true
|
||||||
|
case ReachInternal:
|
||||||
|
return FromMesh, true
|
||||||
|
case ReachPublic, ReachBoth:
|
||||||
|
return FromEverywhere, true
|
||||||
|
default:
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WantsPublicName is whether a reach asks for the route's public name to be composed.
|
||||||
|
func WantsPublicName(reach string) bool { return reach == ReachPublic || reach == ReachBoth }
|
||||||
|
|
||||||
|
// WantsInternalName is whether a reach asks for the route's internal name to be composed.
|
||||||
|
func WantsInternalName(reach string) bool { return reach == ReachInternal || reach == ReachBoth }
|
||||||
|
|
||||||
|
// Reaches reads a module's per-node reach settings: declared port → how far it reaches.
|
||||||
|
//
|
||||||
|
// It refuses a reach for a port the module does not listen on, or a value that is not one of the
|
||||||
|
// four — the "reads as a restriction and is none" fault this whole mechanism exists to prevent
|
||||||
|
// (novox/hq ADR 0043/0045). It also refuses a port that `expose` names as well: the two say the same
|
||||||
|
// thing in different words, and a module whose reach and exposure disagree would have the filter
|
||||||
|
// following one and the names following the other, which is the very confusion ADR 0138 removes.
|
||||||
|
//
|
||||||
|
// A module with no `reach` setting yields nothing, and everything behaves exactly as before: the
|
||||||
|
// filter follows the manifest's `from`, and both names are composed. That is what keeps every machine
|
||||||
|
// already running unchanged until an assignment says otherwise.
|
||||||
|
func Reaches(m Manifest, layers []Layer) (map[int]string, error) {
|
||||||
|
listened := make(map[int]bool, len(m.Listens))
|
||||||
|
for _, l := range m.Listens {
|
||||||
|
listened[l.Port] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
exposed, err := Exposure(m, layers)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
out := map[int]string{}
|
||||||
|
// What an endpoint's own block says, which is the same statement in the shape that names the
|
||||||
|
// endpoint rather than its port (novox/hq ADR 0138). Read first so the older key, which says less,
|
||||||
|
// cannot quietly win over the newer one that says more.
|
||||||
|
blocks, err := Endpoints(m, layers)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
declared := endpointPorts(m)
|
||||||
|
for name, ep := range blocks {
|
||||||
|
if ep.Reach == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out[declared[name]] = ep.Reach
|
||||||
|
}
|
||||||
|
for _, layer := range layers {
|
||||||
|
raw, ok := layer.Values[ReachSetting]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
entries, ok := raw.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("%s: %s is a { port: reach } map, and %q set it to something else",
|
||||||
|
m.Module, ReachSetting, layer.From)
|
||||||
|
}
|
||||||
|
for portText, value := range entries {
|
||||||
|
port, err := strconv.Atoi(portText)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%s says how far %q reaches, which is not a port", m.Module, portText)
|
||||||
|
}
|
||||||
|
if !listened[port] {
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"%s says how far port %d reaches, which it does not listen on — the setting "+
|
||||||
|
"reaches nothing", m.Module, port)
|
||||||
|
}
|
||||||
|
reach, ok := value.(string)
|
||||||
|
if !ok || !slices.Contains(reaches, reach) {
|
||||||
|
return nil, fmt.Errorf("%s says port %d reaches %v; a reach is %s",
|
||||||
|
m.Module, port, value, strings.Join(reaches, ", "))
|
||||||
|
}
|
||||||
|
if _, both := exposed[port]; both {
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"%s sets both %s and %s for port %d. They say the same thing in different "+
|
||||||
|
"words, and the filter would follow one while its names followed the other "+
|
||||||
|
"— which is what %s exists to stop. Keep %s",
|
||||||
|
m.Module, ReachSetting, ExposeSetting, port, ReachSetting, ReachSetting)
|
||||||
|
}
|
||||||
|
out[port] = reach
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(out) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RouteEndpoint is the key a route contribution names the endpoint it serves with, instead of
|
||||||
|
// repeating that endpoint's port (novox/hq ADR 0138).
|
||||||
|
//
|
||||||
|
// **A route and a listen both carried a port, and nothing said they were the same thing.** They
|
||||||
|
// always were — a route serves one of the module's own endpoints — but a reader had to join two
|
||||||
|
// numbers, and an assignment configuring "the web endpoint" had to know which number that was. A
|
||||||
|
// route that names the endpoint says what it means, and the mesh looks the port up.
|
||||||
|
const RouteEndpoint = "endpoint"
|
||||||
|
|
||||||
|
// RouteProblems holds a module's route contributions to naming an endpoint it actually has.
|
||||||
|
//
|
||||||
|
// A route naming an endpoint the module does not declare reaches nothing, and is refused where it is
|
||||||
|
// written rather than resolving to no port and serving nothing — the fault this repository names most
|
||||||
|
// often, a declaration that reads as though it did something.
|
||||||
|
func RouteProblems(m Manifest) []string {
|
||||||
|
var problems []string
|
||||||
|
check := func(where string, values map[string]any) {
|
||||||
|
name, ok := values[RouteEndpoint].(string)
|
||||||
|
if !ok || strings.TrimSpace(name) == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, found := EndpointPort(m, name); !found {
|
||||||
|
problems = append(problems, fmt.Sprintf(
|
||||||
|
"%s routes %s to the endpoint %q, which it does not declare", m.Module, where, name))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if values, ok := m.Contributes["route"]; ok {
|
||||||
|
check("a name", values)
|
||||||
|
}
|
||||||
|
for local, values := range m.ContributesMany["route"] {
|
||||||
|
check(local, values)
|
||||||
|
}
|
||||||
|
return problems
|
||||||
|
}
|
||||||
|
|
||||||
|
// EndpointsSetting is the settings key that configures a module's endpoints by name, per node
|
||||||
|
// (novox/hq ADR 0138):
|
||||||
|
//
|
||||||
|
// {"endpoints": {"web": {"port": 20009, "label": "media", "reach": "both"},
|
||||||
|
// "stream": {"reach": "public"}}}
|
||||||
|
//
|
||||||
|
// **One block per endpoint, instead of three keys joined by a number.** Which machine port it lands
|
||||||
|
// on, the subdomain a proxy serves it under, and how far it reaches are the three things an operator
|
||||||
|
// says when a module is assigned, and they were said in `ports`, in the route's label and in `reach`,
|
||||||
|
// each keyed by the port. A module with two endpoints of different shapes — a web surface behind the
|
||||||
|
// proxy and a protocol port clients dial directly — could only be configured by a reader who knew
|
||||||
|
// which number was which.
|
||||||
|
//
|
||||||
|
// Every field is optional. A block that says only a reach leaves the port to the mesh and the label to
|
||||||
|
// the module, which is the ordinary case.
|
||||||
|
const EndpointsSetting = "endpoints"
|
||||||
|
|
||||||
|
// Endpoint is what an assignment says about one of a module's endpoints.
|
||||||
|
type Endpoint struct {
|
||||||
|
// Port is the machine-side port it is published on. Zero means the mesh assigns one, which it
|
||||||
|
// does anyway — a fixed port is the module's claim and is honoured without being said here.
|
||||||
|
Port int
|
||||||
|
// Label is the subdomain a proxy serves it under, overriding the one the module contributes.
|
||||||
|
Label string
|
||||||
|
// Reach is how far it reaches: machine, internal, public or both.
|
||||||
|
Reach string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Endpoints reads a module's per-node endpoint configuration, by endpoint name.
|
||||||
|
//
|
||||||
|
// It refuses a name the module does not declare — the setting would reach nothing — and a reach that
|
||||||
|
// is not one of the four. It also refuses an endpoint whose port or reach is said twice, once here and
|
||||||
|
// once through the older key: two places saying the same thing is what this key exists to end, and
|
||||||
|
// letting both stand would mean the mesh followed whichever it read last.
|
||||||
|
func Endpoints(m Manifest, layers []Layer) (map[string]Endpoint, error) {
|
||||||
|
declared := endpointPorts(m)
|
||||||
|
exposed, err := Exposure(m, layers)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
out := map[string]Endpoint{}
|
||||||
|
for _, layer := range layers {
|
||||||
|
raw, ok := layer.Values[EndpointsSetting]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
blocks, ok := raw.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("%s: %s is a { endpoint: { … } } map, and %q set it to something else",
|
||||||
|
m.Module, EndpointsSetting, layer.From)
|
||||||
|
}
|
||||||
|
for name, body := range blocks {
|
||||||
|
port, known := declared[name]
|
||||||
|
if !known {
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"%s configures the endpoint %q, which it does not declare — the setting reaches "+
|
||||||
|
"nothing. It declares %s", m.Module, name, spokenEndpoints(m))
|
||||||
|
}
|
||||||
|
values, ok := body.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("%s: the endpoint %q is configured with something that is not a "+
|
||||||
|
"block of settings", m.Module, name)
|
||||||
|
}
|
||||||
|
ep := out[name]
|
||||||
|
if reach, said := values["reach"]; said {
|
||||||
|
text, ok := reach.(string)
|
||||||
|
if !ok || !slices.Contains(reaches, text) {
|
||||||
|
return nil, fmt.Errorf("%s says the endpoint %q reaches %v; a reach is %s",
|
||||||
|
m.Module, name, reach, strings.Join(reaches, ", "))
|
||||||
|
}
|
||||||
|
if _, also := exposed[port]; also {
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"%s says how far %q reaches and also exposes port %d. They say the same thing "+
|
||||||
|
"in different words; keep the endpoint's own block",
|
||||||
|
m.Module, name, port)
|
||||||
|
}
|
||||||
|
ep.Reach = text
|
||||||
|
}
|
||||||
|
if at, said := values["port"]; said {
|
||||||
|
machine, ok := asPort(at)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("%s puts the endpoint %q on %v, which is not a port",
|
||||||
|
m.Module, name, at)
|
||||||
|
}
|
||||||
|
ep.Port = machine
|
||||||
|
}
|
||||||
|
if label, said := values["label"]; said {
|
||||||
|
text, ok := label.(string)
|
||||||
|
if !ok || strings.TrimSpace(text) == "" {
|
||||||
|
return nil, fmt.Errorf("%s gives the endpoint %q a label that is not a name: %v",
|
||||||
|
m.Module, name, label)
|
||||||
|
}
|
||||||
|
ep.Label = strings.TrimSpace(text)
|
||||||
|
}
|
||||||
|
out[name] = ep
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(out) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// spokenEndpoints is what a module's endpoints are called, as a refusal lists them — so a reader who
|
||||||
|
// named one wrongly is one edit from right, and a module that has named none is told so.
|
||||||
|
func spokenEndpoints(m Manifest) string {
|
||||||
|
names := make([]string, 0, len(m.Listens))
|
||||||
|
for _, l := range m.Listens {
|
||||||
|
if name := strings.TrimSpace(l.Name); name != "" {
|
||||||
|
names = append(names, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(names) == 0 {
|
||||||
|
return "no endpoints by name"
|
||||||
|
}
|
||||||
|
sort.Strings(names)
|
||||||
|
return strings.Join(names, ", ")
|
||||||
|
}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ func TestTheBrokersPortIsOpenedThoughNoModuleDeclaresIt(t *testing.T) {
|
|||||||
// A machine on the private network, with one ordinary module rule, and nothing that mentions
|
// A machine on the private network, with one ordinary module rule, and nothing that mentions
|
||||||
// the broker — which is every machine.
|
// the broker — which is every machine.
|
||||||
rules := []Rule{{Port: 8080, From: FromMesh, Because: []string{"some-module"}}}
|
rules := []Rule{{Port: 8080, From: FromMesh, Because: []string{"some-module"}}}
|
||||||
out := AsNftables(rules, []string{"10.42.0.1"}, false, []int{brokerPort})
|
out := AsNftables(rules, []string{"10.42.0.1"}, false, []int{brokerPort}, nil, "mesh0")
|
||||||
|
|
||||||
if !strings.Contains(out, "tcp dport 5671 accept") {
|
if !strings.Contains(out, "tcp dport 5671 accept") {
|
||||||
t.Fatalf("the broker's port is not opened, so no machine could enrol:\n%s", out)
|
t.Fatalf("the broker's port is not opened, so no machine could enrol:\n%s", out)
|
||||||
@@ -48,7 +48,7 @@ func TestTheBrokersPortIsOpenedThoughNoModuleDeclaresIt(t *testing.T) {
|
|||||||
// And a mesh that was never told about a broker still gets a ruleset, rather than an empty one or
|
// And a mesh that was never told about a broker still gets a ruleset, rather than an empty one or
|
||||||
// a panic. A control plane in that state cannot issue tokens either, which is where it surfaces.
|
// a panic. A control plane in that state cannot issue tokens either, which is where it surfaces.
|
||||||
func TestNoBrokerMeansNoFoundationRuleRatherThanNoRuleset(t *testing.T) {
|
func TestNoBrokerMeansNoFoundationRuleRatherThanNoRuleset(t *testing.T) {
|
||||||
out := AsNftables(nil, []string{"10.42.0.1"}, false, nil)
|
out := AsNftables(nil, []string{"10.42.0.1"}, false, nil, nil, "mesh0")
|
||||||
if !strings.Contains(out, "table inet mesh") {
|
if !strings.Contains(out, "table inet mesh") {
|
||||||
t.Fatalf("no ruleset at all:\n%s", out)
|
t.Fatalf("no ruleset at all:\n%s", out)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ func TestTwoModulesWantingOnePortAreBothNamed(t *testing.T) {
|
|||||||
t.Fatalf("a module that wanted this port open is not named: %+v", rules[0])
|
t.Fatalf("a module that wanted this port open is not named: %+v", rules[0])
|
||||||
}
|
}
|
||||||
// The consequence, which is the reason this matters: removing web must not read as closing 443.
|
// The consequence, which is the reason this matters: removing web must not read as closing 443.
|
||||||
nft := AsNftables(rules, nil, false, nil)
|
nft := AsNftables(rules, nil, false, nil, nil, "mesh0")
|
||||||
if !strings.Contains(nft, "web") || !strings.Contains(nft, "board") {
|
if !strings.Contains(nft, "web") || !strings.Contains(nft, "board") {
|
||||||
t.Fatalf("the rendered rule set does not name both sources:\n%s", nft)
|
t.Fatalf("the rendered rule set does not name both sources:\n%s", nft)
|
||||||
}
|
}
|
||||||
@@ -107,7 +107,7 @@ func TestAPortOpenToEveryoneIsNotAlsoRestrictedToTheMesh(t *testing.T) {
|
|||||||
func TestWhatNoModuleDeclaredIsClosed(t *testing.T) {
|
func TestWhatNoModuleDeclaredIsClosed(t *testing.T) {
|
||||||
nft := AsNftables(mustFilter(t, Resolution{Modules: []Manifest{
|
nft := AsNftables(mustFilter(t, Resolution{Modules: []Manifest{
|
||||||
{Module: "web", Listens: []Listening{{Port: 443, From: FromEverywhere}}},
|
{Module: "web", Listens: []Listening{{Port: 443, From: FromEverywhere}}},
|
||||||
}}, nil), []string{"198.51.100.2"}, false, nil)
|
}}, nil), []string{"198.51.100.2"}, false, nil, nil, "mesh0")
|
||||||
// Naming the chain, not just the policy: the forward chain drops too, and an assertion on
|
// Naming the chain, not just the policy: the forward chain drops too, and an assertion on
|
||||||
// "policy drop" alone passes while the input chain accepts everything. It did, once, here.
|
// "policy drop" alone passes while the input chain accepts everything. It did, once, here.
|
||||||
if !strings.Contains(nft, "type filter hook input priority filter; policy drop;") {
|
if !strings.Contains(nft, "type filter hook input priority filter; policy drop;") {
|
||||||
@@ -134,7 +134,7 @@ func TestWhatNoModuleDeclaredIsClosed(t *testing.T) {
|
|||||||
// `flush ruleset` would do the first and not the second: it empties every table on the machine,
|
// `flush ruleset` would do the first and not the second: it empties every table on the machine,
|
||||||
// including the ones the container runtime writes for its bridges.
|
// including the ones the container runtime writes for its bridges.
|
||||||
func TestReloadingReplacesOnlyTheMeshsOwnRules(t *testing.T) {
|
func TestReloadingReplacesOnlyTheMeshsOwnRules(t *testing.T) {
|
||||||
nft := AsNftables(nil, nil, false, nil)
|
nft := AsNftables(nil, nil, false, nil, nil, "mesh0")
|
||||||
if strings.Contains(nft, "flush ruleset") {
|
if strings.Contains(nft, "flush ruleset") {
|
||||||
t.Fatalf("loading the rule set empties every table on the machine:\n%s", nft)
|
t.Fatalf("loading the rule set empties every table on the machine:\n%s", nft)
|
||||||
}
|
}
|
||||||
@@ -160,22 +160,122 @@ func TestReloadingReplacesOnlyTheMeshsOwnRules(t *testing.T) {
|
|||||||
// So the chain exists and denies by default, and the runtime's own networks are allowed explicitly
|
// So the chain exists and denies by default, and the runtime's own networks are allowed explicitly
|
||||||
// — which is how the system being replaced has been doing it on these machines for months.
|
// — which is how the system being replaced has been doing it on these machines for months.
|
||||||
func TestWhatIsForwardedIsGovernedToo(t *testing.T) {
|
func TestWhatIsForwardedIsGovernedToo(t *testing.T) {
|
||||||
nft := AsNftables(nil, []string{"198.51.100.2"}, false, nil)
|
nft := AsNftables(nil, []string{"198.51.100.2"}, false, nil, nil, "mesh0")
|
||||||
if !strings.Contains(nft, "hook forward priority filter; policy drop") {
|
if !strings.Contains(nft, "hook forward priority filter; policy drop") {
|
||||||
t.Fatalf("forwarded traffic is not governed, so container ports are open:\n%s", nft)
|
t.Fatalf("forwarded traffic is not governed, so container ports are open:\n%s", nft)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// And containers keep working, which is the whole reason the chain was left out before.
|
// And this machine's own guests keep working, which is the whole reason the chain was left out
|
||||||
func TestTheRuntimesOwnNetworksKeepWorking(t *testing.T) {
|
// before — by not being mentioned (novox/hq ADR 0140).
|
||||||
nft := AsNftables(nil, []string{"198.51.100.2"}, false, nil)
|
//
|
||||||
for _, network := range []string{"172.16.0.0/12", "192.168.128.0/17"} {
|
// It used to be done by naming the address ranges they sit on: two fixed here and the rest recorded
|
||||||
if !strings.Contains(nft, "ip saddr "+network+" accept") {
|
// per machine. That list broke a workstation's containers at a flip and could not be made correct,
|
||||||
t.Fatalf("%s is not allowed, so denying by default stops every container:\n%s", network, nft)
|
// because a range describes one machine and cannot tell a network the mesh made from one a
|
||||||
|
// predecessor left behind. What replaced it is a single line about the links traffic arrives on.
|
||||||
|
func TestThisMachinesOwnGuestsKeepWorkingWithoutBeingNamed(t *testing.T) {
|
||||||
|
nft := AsNftables(nil, []string{"198.51.100.2"}, false, nil, []string{"eth0"}, "mesh0")
|
||||||
|
if !strings.Contains(nft, `iifname != { "eth0", "mesh0" } accept`) {
|
||||||
|
t.Fatalf("what did not arrive from outside is not accepted, so this machine's own guests "+
|
||||||
|
"reach nothing:\n%s", nft)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No address of a machine's own networks appears anywhere in a rendered filter.
|
||||||
|
//
|
||||||
|
// This is the assertion that fails against the previous behaviour, and it is why it is written on
|
||||||
|
// the text rather than on an outcome: the two ranges were a constant in this file, so nothing but
|
||||||
|
// reading the output catches one creeping back in.
|
||||||
|
func TestNoNetworkOfTheMachinesOwnIsNamed(t *testing.T) {
|
||||||
|
nft := AsNftables(nil, []string{"198.51.100.2"}, false, nil, []string{"eth0"}, "mesh0")
|
||||||
|
for _, gone := range []string{"172.16.0.0/12", "192.168.128.0/17", "saddr 192.168", "saddr 172."} {
|
||||||
|
if strings.Contains(nft, gone) {
|
||||||
|
t.Fatalf("%q is named, and a range describes one machine and goes stale in silence:\n%s",
|
||||||
|
gone, nft)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// **The tunnel is constrained, not treated as inside.**
|
||||||
|
//
|
||||||
|
// Accepting everything arriving over the private network would make a port nothing declares
|
||||||
|
// reachable from every machine in the mesh — the derivation abandoned, and a rule that reads as a
|
||||||
|
// restriction while restricting nothing. So the tunnel is named beside the outward links, and
|
||||||
|
// traffic arriving on it meets the declared rules like anything else.
|
||||||
|
func TestTheTunnelIsConstrainedLikeAnOutwardLink(t *testing.T) {
|
||||||
|
nft := AsNftables(nil, []string{"198.51.100.2"}, false, nil, []string{"eth0"}, "mesh0")
|
||||||
|
line := `iifname != { "eth0", "mesh0" } accept`
|
||||||
|
if !strings.Contains(nft, line) {
|
||||||
|
t.Fatalf("the tunnel is not constrained, so an undeclared port is reachable from any "+
|
||||||
|
"machine in the mesh:\n%s", nft)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A machine with two links facing outside has both constrained. Asserted on the one line, because a
|
||||||
|
// rule covering one and not the other would leave a machine filtering half of what reaches it.
|
||||||
|
func TestEveryOutwardLinkIsConstrained(t *testing.T) {
|
||||||
|
nft := AsNftables(nil, []string{"198.51.100.2"}, false, nil, []string{"eth0", "wlan0"}, "mesh0")
|
||||||
|
if !strings.Contains(nft, `iifname != { "eth0", "wlan0", "mesh0" } accept`) {
|
||||||
|
t.Fatalf("not every outward link is constrained:\n%s", nft)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A guest asks its host for an address and for names, and those two arrive at the input chain. Asked
|
||||||
|
// for by the link they arrive on, so a resolver bound anywhere but an outward link keeps answering.
|
||||||
|
func TestGuestsMayAskTheirHostForAnAddressAndNames(t *testing.T) {
|
||||||
|
nft := AsNftables(nil, []string{"198.51.100.2"}, false, nil, []string{"eth0"}, "mesh0")
|
||||||
|
for _, want := range []string{
|
||||||
|
`iifname != { "eth0", "mesh0" } udp dport { 53, 67 } accept`,
|
||||||
|
`iifname != { "eth0", "mesh0" } tcp dport 53 accept`,
|
||||||
|
} {
|
||||||
|
if !strings.Contains(nft, want) {
|
||||||
|
t.Fatalf("a guest cannot ask its host for an address or a name, which is not a closed "+
|
||||||
|
"port but a network that does not work:\n%s", nft)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// With no link named at all the chain denies rather than rendering an empty set, which nftables
|
||||||
|
// refuses — and a rule set that does not load is a machine filtering nothing while its unit reports
|
||||||
|
// success. Composing a declaration for such a machine is refused upstream; this is the floor.
|
||||||
|
func TestNoLinkNamedRendersNoCatchAllRatherThanAnEmptySet(t *testing.T) {
|
||||||
|
nft := AsNftables(nil, []string{"198.51.100.2"}, false, nil, nil, "")
|
||||||
|
if strings.Contains(nft, "{ }") || strings.Contains(nft, "iifname != {}") {
|
||||||
|
t.Fatalf("an empty set is rendered, which nftables refuses:\n%s", nft)
|
||||||
|
}
|
||||||
|
if !strings.Contains(nft, "hook forward priority filter; policy drop") {
|
||||||
|
t.Fatalf("the forward chain does not deny:\n%s", nft)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A machine that has not said which links face outside is sent no filter, and the refusal names the
|
||||||
|
// module that would have loaded it so the reader knows what is being withheld.
|
||||||
|
func TestAMachineThatNamedNoOutwardLinkIsSentNoFilter(t *testing.T) {
|
||||||
|
r := Resolution{Node: "anchor", Modules: []Manifest{
|
||||||
|
{Module: "nftables", Filtering: &Filtering{Into: "/etc/mesh/filter.nft"}},
|
||||||
|
{Module: "web", Listens: []Listening{{Port: 443, From: FromEverywhere}}},
|
||||||
|
}}
|
||||||
|
_, err := r.Declaration(Rendering{Mesh: []string{"198.51.100.2"}, TunnelInterface: "mesh0"})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("a machine that named no outward link was sent a filter written around none")
|
||||||
|
}
|
||||||
|
for _, want := range []string{"anchor", "nftables", "face outside"} {
|
||||||
|
if !strings.Contains(err.Error(), want) {
|
||||||
|
t.Fatalf("the refusal does not say %q: %v", want, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// And a machine that names none but loads no filter is not refused: there is nothing to write.
|
||||||
|
func TestAMachineWithNoFilterModuleIsNotRefused(t *testing.T) {
|
||||||
|
r := Resolution{Node: "anchor", Modules: []Manifest{
|
||||||
|
{Module: "web", Listens: []Listening{{Port: 443, From: FromEverywhere}}},
|
||||||
|
}}
|
||||||
|
if _, err := r.Declaration(Rendering{Mesh: []string{"198.51.100.2"}}); err != nil {
|
||||||
|
t.Fatalf("a machine that loads no filter was refused one: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// A published port is matched by what the client asked for, not by where the packet ends up.
|
// A published port is matched by what the client asked for, not by where the packet ends up.
|
||||||
//
|
//
|
||||||
// The runtime rewrites the destination before this chain sees it, so a rule naming the published
|
// The runtime rewrites the destination before this chain sees it, so a rule naming the published
|
||||||
@@ -183,7 +283,7 @@ func TestTheRuntimesOwnNetworksKeepWorking(t *testing.T) {
|
|||||||
func TestAPublishedPortIsMatchedByWhatWasAskedFor(t *testing.T) {
|
func TestAPublishedPortIsMatchedByWhatWasAskedFor(t *testing.T) {
|
||||||
nft := AsNftables(mustFilter(t, Resolution{Modules: []Manifest{
|
nft := AsNftables(mustFilter(t, Resolution{Modules: []Manifest{
|
||||||
{Module: "web", Listens: []Listening{{Port: 8080, From: FromEverywhere}}},
|
{Module: "web", Listens: []Listening{{Port: 8080, From: FromEverywhere}}},
|
||||||
}}, nil), []string{"198.51.100.2"}, false, nil)
|
}}, nil), []string{"198.51.100.2"}, false, nil, nil, "mesh0")
|
||||||
if !strings.Contains(nft, "ct original proto-dst 8080 accept") {
|
if !strings.Contains(nft, "ct original proto-dst 8080 accept") {
|
||||||
t.Fatalf("the forwarded rule does not match the port a client asked for:\n%s", nft)
|
t.Fatalf("the forwarded rule does not match the port a client asked for:\n%s", nft)
|
||||||
}
|
}
|
||||||
@@ -193,7 +293,7 @@ func TestAPublishedPortIsMatchedByWhatWasAskedFor(t *testing.T) {
|
|||||||
func TestAMeshScopedPortIsMeshScopedWhenForwarded(t *testing.T) {
|
func TestAMeshScopedPortIsMeshScopedWhenForwarded(t *testing.T) {
|
||||||
nft := AsNftables(mustFilter(t, Resolution{Modules: []Manifest{
|
nft := AsNftables(mustFilter(t, Resolution{Modules: []Manifest{
|
||||||
{Module: "store", Listens: []Listening{{Port: 5432, From: FromMesh}}},
|
{Module: "store", Listens: []Listening{{Port: 5432, From: FromMesh}}},
|
||||||
}}, nil), []string{"198.51.100.2"}, false, nil)
|
}}, nil), []string{"198.51.100.2"}, false, nil, nil, "mesh0")
|
||||||
if !strings.Contains(nft, "ip saddr { 198.51.100.2 } ct original proto-dst 5432 accept") {
|
if !strings.Contains(nft, "ip saddr { 198.51.100.2 } ct original proto-dst 5432 accept") {
|
||||||
t.Fatalf("a mesh-only port is reachable from anywhere once forwarded:\n%s", nft)
|
t.Fatalf("a mesh-only port is reachable from anywhere once forwarded:\n%s", nft)
|
||||||
}
|
}
|
||||||
@@ -203,7 +303,7 @@ func TestAMeshScopedPortIsMeshScopedWhenForwarded(t *testing.T) {
|
|||||||
func TestFromTheMeshIsTheNodesTheMeshKnows(t *testing.T) {
|
func TestFromTheMeshIsTheNodesTheMeshKnows(t *testing.T) {
|
||||||
nft := AsNftables(mustFilter(t, Resolution{Modules: []Manifest{
|
nft := AsNftables(mustFilter(t, Resolution{Modules: []Manifest{
|
||||||
{Module: "store", Listens: []Listening{{Port: 5432, From: FromMesh}}},
|
{Module: "store", Listens: []Listening{{Port: 5432, From: FromMesh}}},
|
||||||
}}, nil), []string{"198.51.100.2", "198.51.100.3"}, false, nil)
|
}}, nil), []string{"198.51.100.2", "198.51.100.3"}, false, nil, nil, "mesh0")
|
||||||
if !strings.Contains(nft, "ip saddr { 198.51.100.2, 198.51.100.3 } tcp dport 5432 accept") {
|
if !strings.Contains(nft, "ip saddr { 198.51.100.2, 198.51.100.3 } tcp dport 5432 accept") {
|
||||||
t.Fatalf("a mesh-scoped port was not restricted to the mesh's addresses:\n%s", nft)
|
t.Fatalf("a mesh-scoped port was not restricted to the mesh's addresses:\n%s", nft)
|
||||||
}
|
}
|
||||||
@@ -213,7 +313,7 @@ func TestFromTheMeshIsTheNodesTheMeshKnows(t *testing.T) {
|
|||||||
func TestAMeshPortOnANodeWithNoMeshIsClosedAndSaysSo(t *testing.T) {
|
func TestAMeshPortOnANodeWithNoMeshIsClosedAndSaysSo(t *testing.T) {
|
||||||
nft := AsNftables(mustFilter(t, Resolution{Modules: []Manifest{
|
nft := AsNftables(mustFilter(t, Resolution{Modules: []Manifest{
|
||||||
{Module: "store", Listens: []Listening{{Port: 5432, From: FromMesh}}},
|
{Module: "store", Listens: []Listening{{Port: 5432, From: FromMesh}}},
|
||||||
}}, nil), nil, false, nil)
|
}}, nil), nil, false, nil, nil, "mesh0")
|
||||||
if strings.Contains(nft, "dport 5432 accept") {
|
if strings.Contains(nft, "dport 5432 accept") {
|
||||||
t.Fatalf("a port meant for the mesh was opened to everything:\n%s", nft)
|
t.Fatalf("a port meant for the mesh was opened to everything:\n%s", nft)
|
||||||
}
|
}
|
||||||
@@ -226,7 +326,7 @@ func TestAMeshPortOnANodeWithNoMeshIsClosedAndSaysSo(t *testing.T) {
|
|||||||
func TestAMachineScopedPortIsNotOpened(t *testing.T) {
|
func TestAMachineScopedPortIsNotOpened(t *testing.T) {
|
||||||
nft := AsNftables(mustFilter(t, Resolution{Modules: []Manifest{
|
nft := AsNftables(mustFilter(t, Resolution{Modules: []Manifest{
|
||||||
{Module: "cache", Listens: []Listening{{Port: 6379, From: FromMachine}}},
|
{Module: "cache", Listens: []Listening{{Port: 6379, From: FromMachine}}},
|
||||||
}}, nil), []string{"198.51.100.2"}, false, nil)
|
}}, nil), []string{"198.51.100.2"}, false, nil, nil, "mesh0")
|
||||||
if strings.Contains(nft, "dport 6379 accept") {
|
if strings.Contains(nft, "dport 6379 accept") {
|
||||||
t.Fatalf("a port for this machine only was opened to the network:\n%s", nft)
|
t.Fatalf("a port for this machine only was opened to the network:\n%s", nft)
|
||||||
}
|
}
|
||||||
@@ -238,7 +338,8 @@ func TestTheModuleAskingForTheRuleSetGetsEveryModulesPorts(t *testing.T) {
|
|||||||
{Module: "firewall", Filtering: &Filtering{Into: "/etc/mesh/filter.nft"}},
|
{Module: "firewall", Filtering: &Filtering{Into: "/etc/mesh/filter.nft"}},
|
||||||
{Module: "web", Listens: []Listening{{Port: 443, From: FromEverywhere}}},
|
{Module: "web", Listens: []Listening{{Port: 443, From: FromEverywhere}}},
|
||||||
}}
|
}}
|
||||||
out, err := r.Declaration(Rendering{Mesh: []string{"198.51.100.2"}})
|
out, err := r.Declaration(Rendering{Mesh: []string{"198.51.100.2"},
|
||||||
|
OutwardLinks: []string{"eth0"}, TunnelInterface: "mesh0"})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("declaration: %v", err)
|
t.Fatalf("declaration: %v", err)
|
||||||
}
|
}
|
||||||
@@ -268,7 +369,7 @@ func TestAskingForTheRuleSetWithNowhereToPutItIsRefused(t *testing.T) {
|
|||||||
func TestAMeshOnBothAddressFamiliesRendersBoth(t *testing.T) {
|
func TestAMeshOnBothAddressFamiliesRendersBoth(t *testing.T) {
|
||||||
nft := AsNftables(mustFilter(t, Resolution{Modules: []Manifest{
|
nft := AsNftables(mustFilter(t, Resolution{Modules: []Manifest{
|
||||||
{Module: "store", Listens: []Listening{{Port: 5432, From: FromMesh}}},
|
{Module: "store", Listens: []Listening{{Port: 5432, From: FromMesh}}},
|
||||||
}}, nil), []string{"198.51.100.2", "2001:db8::2"}, false, nil)
|
}}, nil), []string{"198.51.100.2", "2001:db8::2"}, false, nil, nil, "mesh0")
|
||||||
if !strings.Contains(nft, "ip saddr { 198.51.100.2 } tcp dport 5432 accept") {
|
if !strings.Contains(nft, "ip saddr { 198.51.100.2 } tcp dport 5432 accept") {
|
||||||
t.Fatalf("the machines with v4 addresses were dropped:\n%s", nft)
|
t.Fatalf("the machines with v4 addresses were dropped:\n%s", nft)
|
||||||
}
|
}
|
||||||
@@ -296,7 +397,8 @@ func TestWhatTheMeshComputesIsAppliedBeforeWhatTheModuleDeclared(t *testing.T) {
|
|||||||
"restart-on": []any{"filtering"}},
|
"restart-on": []any{"filtering"}},
|
||||||
},
|
},
|
||||||
}}}
|
}}}
|
||||||
out, err := r.Declaration(Rendering{Mesh: []string{"198.51.100.2"}})
|
out, err := r.Declaration(Rendering{Mesh: []string{"198.51.100.2"},
|
||||||
|
OutwardLinks: []string{"eth0"}, TunnelInterface: "mesh0"})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("declaration: %v", err)
|
t.Fatalf("declaration: %v", err)
|
||||||
}
|
}
|
||||||
@@ -672,7 +774,7 @@ func TestExposureRefusesAPortNotListenedOnAndABadSource(t *testing.T) {
|
|||||||
// loading the rules lives on conntrack until it drops, and then the machine is reached from a
|
// loading the rules lives on conntrack until it drops, and then the machine is reached from a
|
||||||
// rescue console (novox/hq issue 047).
|
// rescue console (novox/hq issue 047).
|
||||||
func TestSSHIsOpenFromTheMeshEvenWhenNothingIsAssigned(t *testing.T) {
|
func TestSSHIsOpenFromTheMeshEvenWhenNothingIsAssigned(t *testing.T) {
|
||||||
nft := AsNftables(nil, []string{"198.51.100.2", "198.51.100.3"}, false, nil)
|
nft := AsNftables(nil, []string{"198.51.100.2", "198.51.100.3"}, false, nil, nil, "mesh0")
|
||||||
if !strings.Contains(nft, "ip saddr { 198.51.100.2, 198.51.100.3 } tcp dport 22 accept") {
|
if !strings.Contains(nft, "ip saddr { 198.51.100.2, 198.51.100.3 } tcp dport 22 accept") {
|
||||||
t.Fatalf("ssh is not open to the mesh, so a machine can lock everyone out:\n%s", nft)
|
t.Fatalf("ssh is not open to the mesh, so a machine can lock everyone out:\n%s", nft)
|
||||||
}
|
}
|
||||||
@@ -685,7 +787,7 @@ func TestSSHIsOpenFromTheMeshEvenWhenNothingIsAssigned(t *testing.T) {
|
|||||||
// And from outside as well, on a machine that faces outward — because that is the way in when the
|
// And from outside as well, on a machine that faces outward — because that is the way in when the
|
||||||
// private network is the thing that broke.
|
// private network is the thing that broke.
|
||||||
func TestSSHIsOpenFromOutsideOnAMachineThatFacesIt(t *testing.T) {
|
func TestSSHIsOpenFromOutsideOnAMachineThatFacesIt(t *testing.T) {
|
||||||
nft := AsNftables(nil, []string{"198.51.100.2"}, true, nil)
|
nft := AsNftables(nil, []string{"198.51.100.2"}, true, nil, nil, "mesh0")
|
||||||
if !strings.Contains(nft, "\t\ttcp dport 22 accept") {
|
if !strings.Contains(nft, "\t\ttcp dport 22 accept") {
|
||||||
t.Fatalf("a machine reachable from outside does not answer ssh there:\n%s", nft)
|
t.Fatalf("a machine reachable from outside does not answer ssh there:\n%s", nft)
|
||||||
}
|
}
|
||||||
@@ -697,7 +799,7 @@ func TestSSHIsOpenFromOutsideOnAMachineThatFacesIt(t *testing.T) {
|
|||||||
// to narrow the rule to, so narrowing it shuts the port entirely — on the first machine anybody
|
// to narrow the rule to, so narrowing it shuts the port entirely — on the first machine anybody
|
||||||
// adopts, reached over the network, closed by the act of adopting it.
|
// adopts, reached over the network, closed by the act of adopting it.
|
||||||
func TestSSHIsNeverLeftWithoutARule(t *testing.T) {
|
func TestSSHIsNeverLeftWithoutARule(t *testing.T) {
|
||||||
nft := AsNftables(nil, nil, false, nil)
|
nft := AsNftables(nil, nil, false, nil, nil, "mesh0")
|
||||||
if !strings.Contains(nft, "tcp dport 22 accept") {
|
if !strings.Contains(nft, "tcp dport 22 accept") {
|
||||||
t.Fatalf("a machine with no mesh addresses has no ssh rule, so adopting it locks it:\n%s", nft)
|
t.Fatalf("a machine with no mesh addresses has no ssh rule, so adopting it locks it:\n%s", nft)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -225,6 +225,19 @@ type Manifest struct {
|
|||||||
// subscription to the queue it writes to (design 29 §2).
|
// subscription to the queue it writes to (design 29 §2).
|
||||||
Uses []string `json:"uses,omitempty"`
|
Uses []string `json:"uses,omitempty"`
|
||||||
|
|
||||||
|
// Prepares says this module has state that must be brought to the shape this version needs
|
||||||
|
// before this version runs, and that the module's own code does it (novox/hq ADR 0135).
|
||||||
|
//
|
||||||
|
// **A word, not an arrangement.** The mesh runs the module's own program in its preparation
|
||||||
|
// mode, in the module's own context — every binding, credential and setting its code receives,
|
||||||
|
// because it *is* its code. Nothing here names a container, a command, a mount or a variable:
|
||||||
|
// the module already said all of that once, and a second copy is a second thing to drift.
|
||||||
|
//
|
||||||
|
// **Declared, never inferred.** The control plane cannot read what is inside an artifact, so a
|
||||||
|
// module that ships a migration and does not say this breaks on its first upgrade. That is
|
||||||
|
// stated in the record rather than guarded here, because nothing mechanical can guard it.
|
||||||
|
Prepares bool `json:"prepares,omitempty"`
|
||||||
|
|
||||||
// Tools are the tools this module answers — request and reply, awaited.
|
// Tools are the tools this module answers — request and reply, awaited.
|
||||||
//
|
//
|
||||||
// **New, and not `serves`**, which this manifest already uses for the facts a consumer needs
|
// **New, and not `serves`**, which this manifest already uses for the facts a consumer needs
|
||||||
@@ -558,6 +571,21 @@ type Artifact struct {
|
|||||||
// image built from this same module's own repository, the same as every other artifact.
|
// image built from this same module's own repository, the same as every other artifact.
|
||||||
Context *ArtifactContext `json:"context,omitempty"`
|
Context *ArtifactContext `json:"context,omitempty"`
|
||||||
|
|
||||||
|
// System is the operating system this artifact is compiled for, for a bundle whose output is a
|
||||||
|
// binary rather than portable code (novox/hq ADR 0142).
|
||||||
|
//
|
||||||
|
// **Named by the artifact, not by the recipe.** A toolchain deliberately accepts nothing from
|
||||||
|
// the module — anything a module could override there it would be writing a Dockerfile to
|
||||||
|
// override — and yet a compiled binary is per operating system, pinned at link time so a host
|
||||||
|
// refuses to touch a machine it was not built for (novox/hq ADR 0005). The way out is that the
|
||||||
|
// target is a property of the artifact: one artifact declared per system, one build each, and
|
||||||
|
// the recipe stays the mesh's.
|
||||||
|
//
|
||||||
|
// Empty for a bundle whose output runs anywhere, which is every interpreted language, and for
|
||||||
|
// every other kind. A bundle in a language that compiles to a binary must say one, because
|
||||||
|
// "compiled for whatever the build machine happened to be" is the fault this exists to prevent.
|
||||||
|
System string `json:"system,omitempty"`
|
||||||
|
|
||||||
// Language is what this module's code is written in, for a bundle.
|
// Language is what this module's code is written in, for a bundle.
|
||||||
//
|
//
|
||||||
// **Declared, never guessed.** Inferring it from what files happen to be present makes a
|
// **Declared, never guessed.** Inferring it from what files happen to be present makes a
|
||||||
@@ -629,8 +657,23 @@ const (
|
|||||||
// on is a fact, and it should be written once.
|
// on is a fact, and it should be written once.
|
||||||
const ArtifactStoreProvision = "artifact-store"
|
const ArtifactStoreProvision = "artifact-store"
|
||||||
|
|
||||||
// Listening is one port a module accepts connections on.
|
// Listening is one endpoint a module serves: a port it accepts connections on, and what may be said
|
||||||
|
// about that port from outside the module.
|
||||||
type Listening struct {
|
type Listening struct {
|
||||||
|
// Name is what this endpoint is called, so an assignment and a route can refer to it as one thing
|
||||||
|
// (novox/hq ADR 0138).
|
||||||
|
//
|
||||||
|
// **Because a port number is not a name.** Three facts have to be said about an endpoint when a
|
||||||
|
// module is assigned — which machine port it lands on, the subdomain a proxy serves it under, and
|
||||||
|
// how far it reaches — and they were said in three places keyed by the port. A module with two
|
||||||
|
// endpoints of different shapes, a web surface behind a proxy and a protocol port clients dial
|
||||||
|
// directly, cannot be configured that way without a reader joining numbers by hand.
|
||||||
|
//
|
||||||
|
// The module's to choose, like the route's label: it names its own parts. Lowercase, and unique
|
||||||
|
// within the module, so a reference to it is unambiguous. Empty is allowed and means an endpoint
|
||||||
|
// nothing refers to by name, which is every endpoint in the catalogue until they are named.
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
|
||||||
Port int `json:"port"`
|
Port int `json:"port"`
|
||||||
// Protocol is "tcp" or "udp". Absent means tcp, which is what almost everything is — and a
|
// Protocol is "tcp" or "udp". Absent means tcp, which is what almost everything is — and a
|
||||||
// field that had to be written every time would be written wrongly some of the time.
|
// field that had to be written every time would be written wrongly some of the time.
|
||||||
@@ -1237,6 +1280,8 @@ func ParseManifest(raw []byte) (Manifest, error) {
|
|||||||
"%s listens on %d over %q, which is tcp or udp", m.Module, l.Port, p))
|
"%s listens on %d over %q, which is tcp or udp", m.Module, l.Port, p))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
problems = append(problems, endpointNameProblems(m)...)
|
||||||
|
problems = append(problems, RouteProblems(m)...)
|
||||||
for _, port := range m.Guards {
|
for _, port := range m.Guards {
|
||||||
if port < 1 || port > 65535 {
|
if port < 1 || port > 65535 {
|
||||||
problems = append(problems, fmt.Sprintf(
|
problems = append(problems, fmt.Sprintf(
|
||||||
@@ -1276,6 +1321,17 @@ func ParseManifest(raw []byte) (Manifest, error) {
|
|||||||
"program that reads what the mesh delivered and reconciles",
|
"program that reads what the mesh delivered and reconciles",
|
||||||
m.Module, r["id"]))
|
m.Module, r["id"]))
|
||||||
}
|
}
|
||||||
|
// **A module that prepares its state must have code the mesh can run** (novox/hq ADR 0135). The
|
||||||
|
// preparation is the module's own program in its preparation mode, so it is derived from the
|
||||||
|
// resource that runs that program — and a module declaring none has asked for something the mesh
|
||||||
|
// cannot compose. Said here, where the manifest is read, rather than by a declaration that
|
||||||
|
// quietly prepares nothing.
|
||||||
|
if m.Prepares && preparationTarget(m) == "" {
|
||||||
|
problems = append(problems, fmt.Sprintf(
|
||||||
|
"%s says it prepares its state, and declares no container running an artifact it built — "+
|
||||||
|
"the preparation is this module's own program, so there has to be one for the mesh to "+
|
||||||
|
"run it in", m.Module))
|
||||||
|
}
|
||||||
// **A run-once container is a step the host runs to completion** (novox/hq ADR 0052). It is a
|
// **A run-once container is a step the host runs to completion** (novox/hq ADR 0052). It is a
|
||||||
// boolean modifier on the container shape — the host runs the container, requires it to exit 0,
|
// boolean modifier on the container shape — the host runs the container, requires it to exit 0,
|
||||||
// and starts whatever the declaration places after it only once it has. A value that is not a
|
// and starts whatever the declaration places after it only once it has. A value that is not a
|
||||||
@@ -1650,3 +1706,53 @@ func (m Manifest) undeclaredMounts() []string {
|
|||||||
}
|
}
|
||||||
return problems
|
return problems
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// endpointName is what an endpoint may be called: lowercase letters, digits and dashes, starting
|
||||||
|
// with a letter. The same shape a label has, because both end up in something a person types.
|
||||||
|
var endpointName = regexp.MustCompile(`^[a-z][a-z0-9-]*$`)
|
||||||
|
|
||||||
|
// endpointNameProblems holds a module's endpoint names to being usable as references (novox/hq ADR
|
||||||
|
// 0138).
|
||||||
|
//
|
||||||
|
// **Unique, because the point of a name is that it identifies one thing.** Two endpoints called the
|
||||||
|
// same would make an assignment that configures one silently configure whichever the mesh read last
|
||||||
|
// — the shape of fault this repository keeps finding, where a declaration appears to say something
|
||||||
|
// and says something else.
|
||||||
|
func endpointNameProblems(m Manifest) []string {
|
||||||
|
var problems []string
|
||||||
|
seen := map[string]int{}
|
||||||
|
for _, l := range m.Listens {
|
||||||
|
name := strings.TrimSpace(l.Name)
|
||||||
|
if name == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !endpointName.MatchString(name) {
|
||||||
|
problems = append(problems, fmt.Sprintf(
|
||||||
|
"%s calls the endpoint on port %d %q; a name is lowercase letters, digits and "+
|
||||||
|
"dashes, starting with a letter", m.Module, l.Port, l.Name))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if before, already := seen[name]; already {
|
||||||
|
problems = append(problems, fmt.Sprintf(
|
||||||
|
"%s calls both port %d and port %d %q, so anything naming that endpoint could mean "+
|
||||||
|
"either", m.Module, before, l.Port, name))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[name] = l.Port
|
||||||
|
}
|
||||||
|
return problems
|
||||||
|
}
|
||||||
|
|
||||||
|
// EndpointPort is the port of the endpoint a module calls this, and whether it has one.
|
||||||
|
func EndpointPort(m Manifest, name string) (int, bool) {
|
||||||
|
want := strings.TrimSpace(name)
|
||||||
|
if want == "" {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
for _, l := range m.Listens {
|
||||||
|
if strings.TrimSpace(l.Name) == want {
|
||||||
|
return l.Port, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
package catalogue
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A module version prepares its state before it runs (novox/hq ADR 0135).
|
||||||
|
//
|
||||||
|
// What the mesh derives is the module's own resource, run once with one word, placed immediately in
|
||||||
|
// front of the thing it prepares for. What matters in these tests is that the derivation is a copy
|
||||||
|
// rather than a second description: the failure it replaces was a hand-written step repeating six
|
||||||
|
// fields of the resource it preceded, each free to drift from it.
|
||||||
|
|
||||||
|
func aPreparingModule() Manifest {
|
||||||
|
return Manifest{
|
||||||
|
Module: "gitea",
|
||||||
|
Prepares: true,
|
||||||
|
Resources: []map[string]any{
|
||||||
|
{"id": "state", "type": "directory", "path": "/var/lib/gitea", "mode": "0700"},
|
||||||
|
{"id": "server", "type": "container", "name": "mesh-gitea-server",
|
||||||
|
"image": "gitea/gitea@" + digest, "ports": []any{"3000:3000"}},
|
||||||
|
{"id": "runtime", "type": "container", "name": "mesh-gitea",
|
||||||
|
"image": ArtifactStoreScheme + "gitea/runtime@" + digest, "network": "host",
|
||||||
|
"env": map[string]any{"MESH_GITEA_STATE_DIR": "/run/state"},
|
||||||
|
"volumes": []any{"/var/lib/gitea:/run/state:ro"},
|
||||||
|
"ports": []any{"9000:9000"}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func declaredFor(t *testing.T, m Manifest) []map[string]any {
|
||||||
|
t.Helper()
|
||||||
|
// The manifest as the mesh holds it: a build resolved the module's own artifact into the
|
||||||
|
// reference it recorded, which is also how the composition knows whose code a resource runs.
|
||||||
|
out, err := Resolution{Node: "anchor", Modules: []Manifest{m}}.Declaration(
|
||||||
|
Rendering{ArtifactStore: "anchor.internal:5100"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func idsOf(resources []map[string]any) []string {
|
||||||
|
var ids []string
|
||||||
|
for _, r := range resources {
|
||||||
|
ids = append(ids, fmt.Sprint(r["id"]))
|
||||||
|
}
|
||||||
|
return ids
|
||||||
|
}
|
||||||
|
|
||||||
|
// The step runs the module's own code, and comes immediately before it — not before the upstream
|
||||||
|
// server the module packages, which may be the very thing the state lives in.
|
||||||
|
func TestThePreparationRunsTheModulesOwnCodeAndComesRightBeforeIt(t *testing.T) {
|
||||||
|
out := declaredFor(t, aPreparingModule())
|
||||||
|
ids := idsOf(out)
|
||||||
|
at := -1
|
||||||
|
for i, id := range ids {
|
||||||
|
if id == "gitea.runtime-prepare" {
|
||||||
|
at = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if at < 0 {
|
||||||
|
t.Fatalf("nothing prepares this module's state: %v", ids)
|
||||||
|
}
|
||||||
|
// A module's name may contain a dot, so a resource's module is everything before the last one —
|
||||||
|
// which the derived id must not add to, or a machine reads the wrong owner from it.
|
||||||
|
if strings.Count("gitea.runtime-prepare", ".") != 1 {
|
||||||
|
t.Fatal("the derived id adds a dot, so what owns it cannot be read from it")
|
||||||
|
}
|
||||||
|
if ids[at+1] != "gitea.runtime" {
|
||||||
|
t.Fatalf("the preparation is not immediately before the module's own code: %v", ids)
|
||||||
|
}
|
||||||
|
for _, id := range ids[:at] {
|
||||||
|
if id == "gitea.runtime" {
|
||||||
|
t.Fatalf("the module's own code runs before its state is prepared: %v", ids)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// It is given exactly what the module's own code is given. Asserted field by field against the
|
||||||
|
// resource it was derived from, because writing it twice is the fault this replaces.
|
||||||
|
func TestThePreparationIsGivenWhatTheModuleIsGiven(t *testing.T) {
|
||||||
|
out := declaredFor(t, aPreparingModule())
|
||||||
|
declared := byID(out)
|
||||||
|
step, workload := declared["gitea.runtime-prepare"], declared["gitea.runtime"]
|
||||||
|
if step == nil || workload == nil {
|
||||||
|
t.Fatalf("expected both, got %v", idsOf(out))
|
||||||
|
}
|
||||||
|
for _, field := range []string{"image", "network", "env", "volumes", "type"} {
|
||||||
|
if fmt.Sprint(step[field]) != fmt.Sprint(workload[field]) {
|
||||||
|
t.Errorf("the preparation's %s is %v and the module's is %v", field, step[field], workload[field])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if once, _ := step["run-once"].(bool); !once {
|
||||||
|
t.Error("the preparation is not a step, so nothing waits for it and nothing is gated by it")
|
||||||
|
}
|
||||||
|
if fmt.Sprint(step["args"]) != fmt.Sprint([]any{PreparationArgument}) {
|
||||||
|
t.Errorf("the preparation is asked for as %v", step["args"])
|
||||||
|
}
|
||||||
|
if fmt.Sprint(step["name"]) == fmt.Sprint(workload["name"]) {
|
||||||
|
t.Error("the preparation and the workload have one name, so one removes the other")
|
||||||
|
}
|
||||||
|
// A published port cannot be bound twice, and the version being replaced is still running.
|
||||||
|
if _, published := step["ports"]; published {
|
||||||
|
t.Errorf("the preparation publishes a port the running version holds: %v", step["ports"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A module that says nothing about preparing gets nothing, which is most modules.
|
||||||
|
func TestAModuleThatPreparesNothingGetsNoStep(t *testing.T) {
|
||||||
|
m := aPreparingModule()
|
||||||
|
m.Prepares = false
|
||||||
|
for _, id := range idsOf(declaredFor(t, m)) {
|
||||||
|
if id == "gitea.runtime-prepare" {
|
||||||
|
t.Fatal("a module that prepares nothing was given a preparation")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A module whose own code the mesh cannot find has nothing to ask, and saying so where the manifest
|
||||||
|
// is read beats a declaration that quietly prepares nothing.
|
||||||
|
func TestAModuleThatPreparesAndRunsNoneOfItsOwnCodeIsRefused(t *testing.T) {
|
||||||
|
m := Manifest{
|
||||||
|
Module: "gitea",
|
||||||
|
Prepares: true,
|
||||||
|
Resources: []map[string]any{
|
||||||
|
// Only the upstream server it packages: nothing here runs gitea's own code.
|
||||||
|
{"id": "server", "type": "container", "name": "mesh-gitea-server", "image": "gitea/gitea@" + digest},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
raw, err := json.Marshal(m)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := ParseManifest(raw); err == nil {
|
||||||
|
t.Fatal("a module that prepares its state with nothing of its own to run was accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,5 +12,5 @@ func TestPrintRehearsalRuleset(t *testing.T) {
|
|||||||
rules := mustFilter(t, Resolution{Modules: []Manifest{
|
rules := mustFilter(t, Resolution{Modules: []Manifest{
|
||||||
{Module: "pub", Listens: []Listening{{Port: 8099, From: FromMesh, Why: "the thing it serves"}}},
|
{Module: "pub", Listens: []Listening{{Port: 8099, From: FromMesh, Why: "the thing it serves"}}},
|
||||||
}}, nil)
|
}}, nil)
|
||||||
t.Log("\n" + AsNftables(rules, []string{"192.0.2.20"}, true, nil))
|
t.Log("\n" + AsNftables(rules, []string{"192.0.2.20"}, true, nil, nil, "mesh0"))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,206 @@
|
|||||||
|
package catalogue
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// a web module with one routed endpoint, the shape almost every routed module in the catalogue has.
|
||||||
|
func aRoutedWeb() Manifest {
|
||||||
|
return Manifest{
|
||||||
|
Module: "web",
|
||||||
|
Listens: []Listening{{Port: 3000, From: FromMesh}},
|
||||||
|
Contributes: map[string]map[string]any{
|
||||||
|
"route": {"label": "app", "port": 3000},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func reachSet(reach string) SettingsBy {
|
||||||
|
return SettingsBy{"web": {{From: "node anchor",
|
||||||
|
Values: map[string]any{ReachSetting: map[string]any{"3000": reach}}}}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// namesFor renders the contribution a routed module makes and returns the two names it carries.
|
||||||
|
func namesFor(t *testing.T, m Manifest, settings SettingsBy) (public, internal string) {
|
||||||
|
t.Helper()
|
||||||
|
r := Resolution{Node: "anchor", Modules: []Manifest{m},
|
||||||
|
PublicDomain: "example.test", At: "anchor.internal"}
|
||||||
|
given, err := r.contributions(settings, nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("contributions: %v", err)
|
||||||
|
}
|
||||||
|
for _, c := range given["route"] {
|
||||||
|
p, _ := c.Values["name"].(string)
|
||||||
|
i, _ := c.Values["internal-name"].(string)
|
||||||
|
return p, i
|
||||||
|
}
|
||||||
|
t.Fatal("the module contributed no route")
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// **Nothing said composes both names, exactly as before.** This is the assertion that keeps every
|
||||||
|
// mesh already running identical until an assignment speaks, and it is the one that would break first
|
||||||
|
// if reach were read where it should not be.
|
||||||
|
func TestAnEndpointWithNoReachKeepsBothNames(t *testing.T) {
|
||||||
|
public, internal := namesFor(t, aRoutedWeb(), nil)
|
||||||
|
if public != "app.example.test" || internal != "app.anchor.internal" {
|
||||||
|
t.Fatalf("names are %q and %q, want both composed as before", public, internal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An internal endpoint has an internal name and no public one — so the proxy serves it inside, and
|
||||||
|
// the public authority is never asked for a name nobody wanted. This is what "must not be public"
|
||||||
|
// could not say before.
|
||||||
|
func TestAnInternalEndpointHasNoPublicName(t *testing.T) {
|
||||||
|
public, internal := namesFor(t, aRoutedWeb(), reachSet(ReachInternal))
|
||||||
|
if public != "" {
|
||||||
|
t.Fatalf("an internal endpoint composed the public name %q", public)
|
||||||
|
}
|
||||||
|
if internal != "app.anchor.internal" {
|
||||||
|
t.Fatalf("internal name is %q, want app.anchor.internal", internal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// And the mirror: a public endpoint gets the public name and not the internal one, so the mesh's own
|
||||||
|
// authority is not asked to certify a name the service is not reached by.
|
||||||
|
func TestAPublicEndpointHasNoInternalName(t *testing.T) {
|
||||||
|
public, internal := namesFor(t, aRoutedWeb(), reachSet(ReachPublic))
|
||||||
|
if internal != "" {
|
||||||
|
t.Fatalf("a public endpoint composed the internal name %q", internal)
|
||||||
|
}
|
||||||
|
if public != "app.example.test" {
|
||||||
|
t.Fatalf("public name is %q, want app.example.test", public)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBothComposesBothNames(t *testing.T) {
|
||||||
|
public, internal := namesFor(t, aRoutedWeb(), reachSet(ReachBoth))
|
||||||
|
if public == "" || internal == "" {
|
||||||
|
t.Fatalf("both should compose both names, got %q and %q", public, internal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// **The filter reads the same value — for an endpoint the proxy does not serve.**
|
||||||
|
//
|
||||||
|
// A routed endpoint's port is how the proxy reaches it and nothing else (ADR 0045): a public service
|
||||||
|
// listens from the mesh, only the proxy reaches it, and it is exposed by name. So on a routed
|
||||||
|
// endpoint the reach asks for a name and the port keeps what the manifest said.
|
||||||
|
func TestAnUnroutedEndpointsPortFollowsItsReach(t *testing.T) {
|
||||||
|
// The same module with its route taken away: now the port is the only way in, so reach governs it.
|
||||||
|
bare := aRoutedWeb()
|
||||||
|
bare.Contributes = nil
|
||||||
|
|
||||||
|
for _, c := range []struct{ reach, want string }{
|
||||||
|
{ReachInternal, FromMesh},
|
||||||
|
{ReachPublic, FromEverywhere},
|
||||||
|
{ReachBoth, FromEverywhere},
|
||||||
|
{ReachMachine, FromMachine},
|
||||||
|
} {
|
||||||
|
r := Resolution{Node: "anchor", Modules: []Manifest{bare}}
|
||||||
|
rules, err := r.Rules(Rendering{Settings: reachSet(c.reach)})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%s: rules: %v", c.reach, err)
|
||||||
|
}
|
||||||
|
found := false
|
||||||
|
for _, rule := range rules {
|
||||||
|
if rule.Port == 3000 {
|
||||||
|
found = true
|
||||||
|
if rule.From != c.want {
|
||||||
|
t.Fatalf("reach %q made the filter say %q, want %q", c.reach, rule.From, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatalf("reach %q produced no rule for the port", c.reach)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// **A public name does not open the machine's port**, which is the case that found this.
|
||||||
|
//
|
||||||
|
// A module whose routed name must be public and whose machine-side port must not be had no way to say
|
||||||
|
// so while one value drove both. Under one value it could not be expressed; the port would reopen.
|
||||||
|
func TestAPublicNameLeavesARoutedPortAsTheManifestSaid(t *testing.T) {
|
||||||
|
r := Resolution{Node: "anchor", Modules: []Manifest{aRoutedWeb()},
|
||||||
|
PublicDomain: "example.test", At: "anchor.internal"}
|
||||||
|
rules, err := r.Rules(Rendering{Settings: reachSet(ReachPublic)})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, rule := range rules {
|
||||||
|
if rule.Port == 3000 && rule.From != FromMesh {
|
||||||
|
t.Fatalf("a public reach opened a routed port to %q; the proxy is how it is reached",
|
||||||
|
rule.From)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// And the name it asked for is there, so the reach was not simply ignored.
|
||||||
|
public, internal := namesFor(t, aRoutedWeb(), reachSet(ReachPublic))
|
||||||
|
if public != "app.example.test" || internal != "" {
|
||||||
|
t.Fatalf("names are %q and %q, want the public one only", public, internal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A reach for a port the module does not listen on reaches nothing, and is refused where it is
|
||||||
|
// written rather than accepted and ignored.
|
||||||
|
func TestAReachForAPortTheModuleDoesNotListenOnIsRefused(t *testing.T) {
|
||||||
|
_, err := Reaches(aRoutedWeb(), []Layer{{From: "node anchor",
|
||||||
|
Values: map[string]any{ReachSetting: map[string]any{"9999": ReachInternal}}}})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "reaches nothing") {
|
||||||
|
t.Fatalf("a reach naming an undeclared port was accepted: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A value that is not a reach is refused, and the refusal names the four so a reader is one edit from
|
||||||
|
// right. "mesh" is the tempting wrong answer, because that is the filter's word for nearly the same
|
||||||
|
// thing.
|
||||||
|
func TestAValueThatIsNotAReachIsRefused(t *testing.T) {
|
||||||
|
for _, wrong := range []string{"mesh", "anywhere", "private", "true"} {
|
||||||
|
_, err := Reaches(aRoutedWeb(), []Layer{{From: "node anchor",
|
||||||
|
Values: map[string]any{ReachSetting: map[string]any{"3000": wrong}}}})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "a reach is") {
|
||||||
|
t.Fatalf("%q was accepted as a reach: %v", wrong, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// **A port that says both reach and expose is refused.** They say the same thing in different words,
|
||||||
|
// and accepting both would have the filter follow one while the names followed the other — the
|
||||||
|
// disagreement ADR 0138 exists to remove, reintroduced by the migration away from the older word.
|
||||||
|
func TestReachAndExposeForOnePortAreRefused(t *testing.T) {
|
||||||
|
_, err := Reaches(aRoutedWeb(), []Layer{{From: "node anchor", Values: map[string]any{
|
||||||
|
ReachSetting: map[string]any{"3000": ReachInternal},
|
||||||
|
ExposeSetting: map[string]any{"3000": FromEverywhere},
|
||||||
|
}}})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "same thing in different") {
|
||||||
|
t.Fatalf("a port set both ways was accepted: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A path-level refusal carries no port: it is a rule about a name, not an endpoint, and it inherits
|
||||||
|
// whatever that name turned out to be. Narrowing the endpoint must not silently drop it.
|
||||||
|
func TestARuleWithNoPortIsLeftAlone(t *testing.T) {
|
||||||
|
m := aRoutedWeb()
|
||||||
|
m.ContributesMany = map[string]map[string]map[string]any{
|
||||||
|
"route": {"refused": {"label": "app", "path": "/internal", "deny": true}},
|
||||||
|
}
|
||||||
|
r := Resolution{Node: "anchor", Modules: []Manifest{m},
|
||||||
|
PublicDomain: "example.test", At: "anchor.internal"}
|
||||||
|
given, err := r.contributions(reachSet(ReachInternal), nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var sawDeny bool
|
||||||
|
for _, c := range given["route"] {
|
||||||
|
if deny, _ := c.Values["deny"].(bool); deny {
|
||||||
|
sawDeny = true
|
||||||
|
// It keeps both, because it named no endpoint to be narrowed by.
|
||||||
|
if c.Values["name"] == nil || c.Values["internal-name"] == nil {
|
||||||
|
t.Fatalf("the path rule lost a name it shadows: %v", c.Values)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !sawDeny {
|
||||||
|
t.Fatal("the path rule was dropped")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -48,7 +48,11 @@ type Seat struct {
|
|||||||
//
|
//
|
||||||
// In the order a person reads it: the mesh's own, then a node's.
|
// In the order a person reads it: the mesh's own, then a node's.
|
||||||
var defaultSeats = []Seat{
|
var defaultSeats = []Seat{
|
||||||
{Name: "mesh-controller", Scope: ScopeMesh, Decision: "novox/hq ADR 0079"},
|
// The control plane states what it did under the seat it holds (novox/hq ADR 0134): a role's
|
||||||
|
// events belong to the role, so they keep their address while the holder is replaced. No accepts,
|
||||||
|
// so no work queue is raised for it — only what its holder may say.
|
||||||
|
{Name: "mesh-controller", Scope: ScopeMesh, Decision: "novox/hq ADR 0079",
|
||||||
|
Emits: []string{"applied", "refused", "built-before"}},
|
||||||
{Name: "mesh-store", Scope: ScopeMesh, Delivers: "postgres-database", Decision: "novox/hq ADR 0079"},
|
{Name: "mesh-store", Scope: ScopeMesh, Delivers: "postgres-database", Decision: "novox/hq ADR 0079"},
|
||||||
// **Delivers the mesh's own bus, not `amqp`.** Those were the same word until
|
// **Delivers the mesh's own bus, not `amqp`.** Those were the same word until
|
||||||
// ADR 0127 separated them: `amqp` is a backing service a module may require, and this seat is
|
// ADR 0127 separated them: `amqp` is a backing service a module may require, and this seat is
|
||||||
|
|||||||
@@ -186,6 +186,17 @@ func UnusedSettings(m Manifest, layers []Layer) []string {
|
|||||||
if key == PortsSetting {
|
if key == PortsSetting {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
// `reach` says how far one of this module's endpoints reaches (novox/hq ADR 0138) — the
|
||||||
|
// filter's source, which names are composed, and therefore which authority certifies
|
||||||
|
// them. Validated in Reaches, so not stray.
|
||||||
|
if key == ReachSetting && len(m.Listens) > 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// `endpoints` configures a module's endpoints by name — the machine port, the subdomain and
|
||||||
|
// the reach as one block each (novox/hq ADR 0138). Validated in Endpoints, so not stray.
|
||||||
|
if key == EndpointsSetting && len(m.Listens) > 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
unused = append(unused, fmt.Sprintf(
|
unused = append(unused, fmt.Sprintf(
|
||||||
"%s sets %q, and %s has no file or contribution to merge it into",
|
"%s sets %q, and %s has no file or contribution to merge it into",
|
||||||
layer.From, key, m.Module))
|
layer.From, key, m.Module))
|
||||||
|
|||||||
@@ -36,12 +36,21 @@ type Build struct {
|
|||||||
// Against is every artifact this build stood on, as references rather than module names —
|
// Against is every artifact this build stood on, as references rather than module names —
|
||||||
// what makes a build edge derived rather than declared (ADR 0009).
|
// what makes a build edge derived rather than declared (ADR 0009).
|
||||||
Against []string
|
Against []string
|
||||||
|
// Read is every repository this build read source from besides the module's own (novox/hq
|
||||||
|
// 04-ISSUES/131), at the ref it read.
|
||||||
|
Read []ReadRepository
|
||||||
// Failed is the builder's own words, empty when it worked.
|
// Failed is the builder's own words, empty when it worked.
|
||||||
Failed string
|
Failed string
|
||||||
Made []Artifact
|
Made []Artifact
|
||||||
At time.Time
|
At time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ReadRepository is a repository a build read source from besides the module's own.
|
||||||
|
type ReadRepository struct {
|
||||||
|
Repository string `json:"repository"`
|
||||||
|
Ref string `json:"ref,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
// Artifact is one thing a build published.
|
// Artifact is one thing a build published.
|
||||||
type Artifact struct {
|
type Artifact struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
@@ -66,17 +75,21 @@ func (i *Inventory) RecordBuild(ctx context.Context, b Build) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
read, err := json.Marshal(b.Read)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
var module *string
|
var module *string
|
||||||
if b.Module != "" {
|
if b.Module != "" {
|
||||||
module = &b.Module
|
module = &b.Module
|
||||||
}
|
}
|
||||||
_, err = i.store.Pool().Exec(ctx,
|
_, err = i.store.Pool().Exec(ctx,
|
||||||
`insert into build (id, repository, ref, module, commit_hash, built_on, failed, made,
|
`insert into build (id, repository, ref, module, commit_hash, built_on, failed, made,
|
||||||
source_path, manifest, built_against)
|
source_path, manifest, built_against, built_contexts)
|
||||||
values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||||
on conflict (id) do nothing`,
|
on conflict (id) do nothing`,
|
||||||
b.ID, b.Repository, b.Ref, module, b.Commit, b.On, b.Failed, made,
|
b.ID, b.Repository, b.Ref, module, b.Commit, b.On, b.Failed, made,
|
||||||
b.Path, manifestOrNil(b.Manifest), against)
|
b.Path, manifestOrNil(b.Manifest), against, read)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -199,6 +212,44 @@ func (i *Inventory) BuiltAgainst(ctx context.Context) (map[string][]string, erro
|
|||||||
return against, rows.Err()
|
return against, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ReadRepositories is what each module's newest successful build read source from besides its own
|
||||||
|
// repository, by module name.
|
||||||
|
//
|
||||||
|
// The mirror of BuiltAgainst, and derived the same way and for the same reason: a merge into a
|
||||||
|
// repository a module only packages is a change to that module, and the manifest the mesh keeps
|
||||||
|
// carries nothing that would say so (novox/hq 04-ISSUES/131).
|
||||||
|
func (i *Inventory) ReadRepositories(ctx context.Context) (map[string][]ReadRepository, error) {
|
||||||
|
rows, err := i.store.Pool().Query(ctx,
|
||||||
|
`select distinct on (module) module, built_contexts
|
||||||
|
from build
|
||||||
|
where module is not null and module <> '' and failed = ''
|
||||||
|
order by module, at desc`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
read := map[string][]ReadRepository{}
|
||||||
|
for rows.Next() {
|
||||||
|
var module string
|
||||||
|
var raw []byte
|
||||||
|
if err := rows.Scan(&module, &raw); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(raw) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var of []ReadRepository
|
||||||
|
if err := json.Unmarshal(raw, &of); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(of) > 0 {
|
||||||
|
read[module] = of
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return read, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
// manifestOrNil keeps the difference between "declared nothing" and "predates this being kept".
|
// manifestOrNil keeps the difference between "declared nothing" and "predates this being kept".
|
||||||
//
|
//
|
||||||
// A build recorded before the mesh kept manifests has no manifest, and that is not the same as one
|
// A build recorded before the mesh kept manifests has no manifest, and that is not the same as one
|
||||||
|
|||||||
@@ -136,3 +136,36 @@ func ids(builds []Build) []string {
|
|||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// What a build read besides its module's own repository comes back for the newest build of each
|
||||||
|
// module, and only for builds that worked. Nothing recorded is absent rather than empty, which is how
|
||||||
|
// a build made before the mesh kept this is told from one that read nothing (novox/hq 04-ISSUES/131).
|
||||||
|
func TestWhatABuildReadComesBackForTheNewestBuildOfEachModule(t *testing.T) {
|
||||||
|
inv := fresh(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
older := aBuild("older", "builder", "")
|
||||||
|
older.Read = []ReadRepository{{Repository: "novox/mesh-controller", Ref: "release"}}
|
||||||
|
newer := aBuild("newer", "builder", "")
|
||||||
|
newer.Read = []ReadRepository{{Repository: "novox/mesh-controller", Ref: "main"}}
|
||||||
|
plain := aBuild("plain", "gitea", "")
|
||||||
|
failed := aBuild("failed", "route-proxy", "cannot clone")
|
||||||
|
failed.Read = []ReadRepository{{Repository: "novox/mesh-controller", Ref: "main"}}
|
||||||
|
for _, b := range []Build{older, newer, plain, failed} {
|
||||||
|
if err := inv.RecordBuild(ctx, b); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
read, err := inv.ReadRepositories(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(read["builder"]) != 1 || read["builder"][0].Ref != "main" {
|
||||||
|
t.Fatalf("the newest build's reading is %+v", read["builder"])
|
||||||
|
}
|
||||||
|
if _, has := read["gitea"]; has {
|
||||||
|
t.Fatalf("a build that read nothing but its own repository reads as %+v", read["gitea"])
|
||||||
|
}
|
||||||
|
if _, has := read["route-proxy"]; has {
|
||||||
|
t.Fatal("a failed build's reading was kept as what that module reads")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -86,9 +86,11 @@ func TestAnAssignedModuleBecomesAUserWithWhatItDeclared(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
// Its tools are every one under its own name — the list in the manifest is a person's
|
||||||
|
// vocabulary for asking, not the module's permission to answer.
|
||||||
if !granted(perms.Publish, "mesh.mod.shop.event.order.placed") ||
|
if !granted(perms.Publish, "mesh.mod.shop.event.order.placed") ||
|
||||||
!granted(perms.Publish, "mesh.seat.telegram-sender.accept.send") ||
|
!granted(perms.Publish, "mesh.seat.telegram-sender.accept.send") ||
|
||||||
!granted(perms.Subscribe, "mesh.mod.shop.tool.price") {
|
!granted(perms.Subscribe, "mesh.mod.shop.tool.>") {
|
||||||
t.Fatalf("one.shop's authority is not what it declared: %+v", perms)
|
t.Fatalf("one.shop's authority is not what it declared: %+v", perms)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
"github.com/novox/mesh-controller/internal/catalogue"
|
"github.com/novox/mesh-controller/internal/catalogue"
|
||||||
@@ -38,6 +39,9 @@ type Source struct {
|
|||||||
BuiltFrom string
|
BuiltFrom string
|
||||||
// Head is the newest commit the source is known to have.
|
// Head is the newest commit the source is known to have.
|
||||||
Head string
|
Head string
|
||||||
|
// Seen is when the source was last looked at — by a build, by hand, or by the forge saying it
|
||||||
|
// moved. What a late report of an older move is judged against.
|
||||||
|
Seen time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// Current reports whether what the mesh holds is what the source last had.
|
// Current reports whether what the mesh holds is what the source last had.
|
||||||
@@ -936,12 +940,13 @@ func (i *Inventory) Catalogued(ctx context.Context) ([]Entry, error) {
|
|||||||
`select m.name, m.manifest,
|
`select m.name, m.manifest,
|
||||||
coalesce(m.source, ''), m.source_path, m.source_seat, coalesce(m.ref, ''),
|
coalesce(m.source, ''), m.source_path, m.source_seat, coalesce(m.ref, ''),
|
||||||
coalesce(m.built_from, ''), coalesce(m.source_head, ''),
|
coalesce(m.built_from, ''), coalesce(m.source_head, ''),
|
||||||
|
coalesce(m.source_seen, to_timestamp(0)),
|
||||||
coalesce(array_agg(n.name order by n.name) filter (where n.name is not null), '{}')
|
coalesce(array_agg(n.name order by n.name) filter (where n.name is not null), '{}')
|
||||||
from module m
|
from module m
|
||||||
left join assignment a on a.module = m.name
|
left join assignment a on a.module = m.name
|
||||||
left join node n on n.id = a.node
|
left join node n on n.id = a.node
|
||||||
group by m.name, m.manifest, m.source, m.source_path, m.source_seat, m.ref, m.built_from,
|
group by m.name, m.manifest, m.source, m.source_path, m.source_seat, m.ref, m.built_from,
|
||||||
m.source_head
|
m.source_head, m.source_seen
|
||||||
order by m.name`)
|
order by m.name`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -955,9 +960,12 @@ func (i *Inventory) Catalogued(ctx context.Context) ([]Entry, error) {
|
|||||||
var source Source
|
var source Source
|
||||||
var on []string
|
var on []string
|
||||||
if err := rows.Scan(&name, &raw, &source.Repository, &source.Path, &source.Seat, &source.Ref,
|
if err := rows.Scan(&name, &raw, &source.Repository, &source.Path, &source.Seat, &source.Ref,
|
||||||
&source.BuiltFrom, &source.Head, &on); err != nil {
|
&source.BuiltFrom, &source.Head, &source.Seen, &on); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if source.Seen.Unix() == 0 {
|
||||||
|
source.Seen = time.Time{}
|
||||||
|
}
|
||||||
var m catalogue.Manifest
|
var m catalogue.Manifest
|
||||||
if err := json.Unmarshal(raw, &m); err != nil {
|
if err := json.Unmarshal(raw, &m); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -30,12 +30,12 @@ func TestRefusedAndFailedAreDifferentSituations(t *testing.T) {
|
|||||||
refuser := nodeNamed(t, inv, "refuser")
|
refuser := nodeNamed(t, inv, "refuser")
|
||||||
failer := nodeNamed(t, inv, "failer")
|
failer := nodeNamed(t, inv, "failer")
|
||||||
|
|
||||||
if err := inv.RecordDoing(ctx, refuser, Doing{
|
if _, err := inv.RecordDoing(ctx, refuser, Doing{
|
||||||
Outcome: OutcomeRefused, Refused: "resource \"x\": a file needs a path",
|
Outcome: OutcomeRefused, Refused: "resource \"x\": a file needs a path",
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if err := inv.RecordDoing(ctx, failer, Doing{
|
if _, err := inv.RecordDoing(ctx, failer, Doing{
|
||||||
Outcome: OutcomeFailed,
|
Outcome: OutcomeFailed,
|
||||||
Failed: []FailedResource{{ID: "svc", Error: "unit not found"}},
|
Failed: []FailedResource{{ID: "svc", Error: "unit not found"}},
|
||||||
Applied: 4,
|
Applied: 4,
|
||||||
@@ -70,7 +70,7 @@ func TestAMachineDoingWhatItWasToldIsNotOnTheList(t *testing.T) {
|
|||||||
inv := fresh(t)
|
inv := fresh(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
id := nodeNamed(t, inv, "fine")
|
id := nodeNamed(t, inv, "fine")
|
||||||
if err := inv.RecordDoing(ctx, id, Doing{Outcome: OutcomeApplied, Applied: 6}); err != nil {
|
if _, err := inv.RecordDoing(ctx, id, Doing{Outcome: OutcomeApplied, Applied: 6}); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
wrong, err := inv.NotDoingWhatTheyWereTold(ctx)
|
wrong, err := inv.NotDoingWhatTheyWereTold(ctx)
|
||||||
@@ -97,12 +97,12 @@ func TestTheLastReportReplacesTheOneBefore(t *testing.T) {
|
|||||||
inv := fresh(t)
|
inv := fresh(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
id := nodeNamed(t, inv, "recovered")
|
id := nodeNamed(t, inv, "recovered")
|
||||||
if err := inv.RecordDoing(ctx, id, Doing{
|
if _, err := inv.RecordDoing(ctx, id, Doing{
|
||||||
Outcome: OutcomeFailed, Failed: []FailedResource{{ID: "a", Error: "no"}},
|
Outcome: OutcomeFailed, Failed: []FailedResource{{ID: "a", Error: "no"}},
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if err := inv.RecordDoing(ctx, id, Doing{Outcome: OutcomeApplied, Applied: 3}); err != nil {
|
if _, err := inv.RecordDoing(ctx, id, Doing{Outcome: OutcomeApplied, Applied: 3}); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
wrong, err := inv.NotDoingWhatTheyWereTold(ctx)
|
wrong, err := inv.NotDoingWhatTheyWereTold(ctx)
|
||||||
@@ -141,7 +141,7 @@ func TestWhatANodeSaidGoesWhenTheNodeDoes(t *testing.T) {
|
|||||||
inv := fresh(t)
|
inv := fresh(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
id := nodeNamed(t, inv, "leaving")
|
id := nodeNamed(t, inv, "leaving")
|
||||||
if err := inv.RecordDoing(ctx, id, Doing{Outcome: OutcomeFailed}); err != nil {
|
if _, err := inv.RecordDoing(ctx, id, Doing{Outcome: OutcomeFailed}); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if _, err := inv.store.Pool().Exec(ctx, `delete from node where name = 'leaving'`); err != nil {
|
if _, err := inv.store.Pool().Exec(ctx, `delete from node where name = 'leaving'`); err != nil {
|
||||||
@@ -257,7 +257,7 @@ func TestTheSameFailureReportedAgainIsCountedNotRestarted(t *testing.T) {
|
|||||||
id := nodeNamed(t, inv, "looping")
|
id := nodeNamed(t, inv, "looping")
|
||||||
same := Doing{Outcome: OutcomeFailed, Failed: []FailedResource{{ID: "img", Error: "no such image"}}}
|
same := Doing{Outcome: OutcomeFailed, Failed: []FailedResource{{ID: "img", Error: "no such image"}}}
|
||||||
|
|
||||||
if err := inv.RecordDoing(ctx, id, same); err != nil {
|
if _, err := inv.RecordDoing(ctx, id, same); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
first, _, err := inv.DoingOf(ctx, "looping")
|
first, _, err := inv.DoingOf(ctx, "looping")
|
||||||
@@ -269,7 +269,7 @@ func TestTheSameFailureReportedAgainIsCountedNotRestarted(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for range StuckAfter - 1 {
|
for range StuckAfter - 1 {
|
||||||
if err := inv.RecordDoing(ctx, id, same); err != nil {
|
if _, err := inv.RecordDoing(ctx, id, same); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -287,7 +287,7 @@ func TestTheSameFailureReportedAgainIsCountedNotRestarted(t *testing.T) {
|
|||||||
// The same resource failing with different words — a duration, a counter — is still the same
|
// The same resource failing with different words — a duration, a counter — is still the same
|
||||||
// failure: it is the resource that loops, not the sentence.
|
// failure: it is the resource that loops, not the sentence.
|
||||||
reworded := Doing{Outcome: OutcomeFailed, Failed: []FailedResource{{ID: "img", Error: "no such image (after 31s)"}}}
|
reworded := Doing{Outcome: OutcomeFailed, Failed: []FailedResource{{ID: "img", Error: "no such image (after 31s)"}}}
|
||||||
if err := inv.RecordDoing(ctx, id, reworded); err != nil {
|
if _, err := inv.RecordDoing(ctx, id, reworded); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
still, _, err := inv.DoingOf(ctx, "looping")
|
still, _, err := inv.DoingOf(ctx, "looping")
|
||||||
@@ -300,7 +300,7 @@ func TestTheSameFailureReportedAgainIsCountedNotRestarted(t *testing.T) {
|
|||||||
|
|
||||||
// A different failure is a new situation, not a longer one.
|
// A different failure is a new situation, not a longer one.
|
||||||
other := Doing{Outcome: OutcomeFailed, Failed: []FailedResource{{ID: "svc", Error: "unit not found"}}}
|
other := Doing{Outcome: OutcomeFailed, Failed: []FailedResource{{ID: "svc", Error: "unit not found"}}}
|
||||||
if err := inv.RecordDoing(ctx, id, other); err != nil {
|
if _, err := inv.RecordDoing(ctx, id, other); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
changed, _, err := inv.DoingOf(ctx, "looping")
|
changed, _, err := inv.DoingOf(ctx, "looping")
|
||||||
@@ -312,7 +312,7 @@ func TestTheSameFailureReportedAgainIsCountedNotRestarted(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// And a clean apply clears it: the machine is doing what it was told, since nothing.
|
// And a clean apply clears it: the machine is doing what it was told, since nothing.
|
||||||
if err := inv.RecordDoing(ctx, id, Doing{Outcome: OutcomeApplied, Applied: 2}); err != nil {
|
if _, err := inv.RecordDoing(ctx, id, Doing{Outcome: OutcomeApplied, Applied: 2}); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
fine, _, err := inv.DoingOf(ctx, "looping")
|
fine, _, err := inv.DoingOf(ctx, "looping")
|
||||||
@@ -324,7 +324,7 @@ func TestTheSameFailureReportedAgainIsCountedNotRestarted(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// The list of what is wrong carries the count, so `status` can say it.
|
// The list of what is wrong carries the count, so `status` can say it.
|
||||||
if err := inv.RecordDoing(ctx, id, same); err != nil {
|
if _, err := inv.RecordDoing(ctx, id, same); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
wrong, err := inv.NotDoingWhatTheyWereTold(ctx)
|
wrong, err := inv.NotDoingWhatTheyWereTold(ctx)
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
-- A build says which repositories it read, so a merge can find everything it affects.
|
||||||
|
--
|
||||||
|
-- A module is built from the one repository the mesh records — where its module.json lives — and some
|
||||||
|
-- modules' recipes reach into a second for the source they package: the packaging and the source are
|
||||||
|
-- allowed to live apart (catalogue's ArtifactContext). That second repository is named in the manifest
|
||||||
|
-- the build read, and the manifest the mesh *keeps* carries no build section, so nothing on the mesh
|
||||||
|
-- could say that a merge into the other repository is a change to this module at all. Two modules are
|
||||||
|
-- built from the control plane's own repository, and neither had ever been rebuilt when it moved
|
||||||
|
-- (novox/hq 04-ISSUES/131).
|
||||||
|
--
|
||||||
|
-- Nullable, like the two derived columns beside it: null is a build recorded before the mesh kept
|
||||||
|
-- this, which is not the same as a build that read nothing but its module's own repository.
|
||||||
|
alter table build add column built_contexts jsonb;
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
-- The networks a machine routes for what it hosts, beyond the container runtime's own defaults.
|
||||||
|
--
|
||||||
|
-- novox/hq ADR 0137. The derived packet filter denies forwarding by default and then allows the
|
||||||
|
-- container runtime's two default pools, named in the controller's code with a comment saying that
|
||||||
|
-- a machine configured otherwise "needs this to say so" — and no way to say it. So the filter was
|
||||||
|
-- correct only on a machine whose runtime used the defaults, and silently wrong on any other.
|
||||||
|
--
|
||||||
|
-- Measured on 2026-09-28: flipping a workstation to the derived filter cut egress for five of its
|
||||||
|
-- container networks and for every network its test beds create, because those are allocated from
|
||||||
|
-- ranges the two defaults do not cover. Nothing reported a fault; the containers simply could not
|
||||||
|
-- reach anything.
|
||||||
|
--
|
||||||
|
-- A node-level fact, beside the node's public domain and for the same reason: it is a property of
|
||||||
|
-- the machine, not of whichever module happens to load the filter today. Swapping that module must
|
||||||
|
-- not lose it.
|
||||||
|
--
|
||||||
|
-- Null for a machine that routes nothing but the runtime's defaults, which is the ordinary case and
|
||||||
|
-- what every machine held before this column existed.
|
||||||
|
alter table node add column routed_networks jsonb;
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
-- Which of a machine's links face outside it, replacing the networks it was told to say it routes.
|
||||||
|
--
|
||||||
|
-- novox/hq ADR 0140, superseding 0137 and 0139. The derived filter blocked everything passing
|
||||||
|
-- through a machine and then allowed the machine's own containers back by naming the address ranges
|
||||||
|
-- they sit on: two ranges fixed in the controller's source, the rest recorded by 0043's column.
|
||||||
|
--
|
||||||
|
-- Every route to a correct list fails. A constant describes one machine. A recorded range goes stale
|
||||||
|
-- in silence, and cannot tell a network the mesh made from one a predecessor left behind — measured
|
||||||
|
-- on the control-node, where six ranges fall outside the constants and two of the six belong to
|
||||||
|
-- services the mesh does not run. Generating the list from the modules put half the rule set on the
|
||||||
|
-- machine.
|
||||||
|
--
|
||||||
|
-- The list should not exist, because the mesh has no position on a container reaching outward: that
|
||||||
|
-- is not a port opened to anybody. The filter constrains what arrives from OUTSIDE the machine and
|
||||||
|
-- says nothing about what did not, which needs one fact instead of a list — which links "outside"
|
||||||
|
-- arrives on.
|
||||||
|
--
|
||||||
|
-- Reported by the machine on every apply, never recorded by hand, so it cannot go stale. Null for a
|
||||||
|
-- machine that has not reported yet; the mesh composes no filter for such a machine and leaves the
|
||||||
|
-- one it has, because a rule written around a link with no name is a rule set that does not load.
|
||||||
|
alter table node add column outward_links jsonb;
|
||||||
|
|
||||||
|
-- What 0043 recorded is not migrated into it. The ranges answered a question that no longer exists,
|
||||||
|
-- and every machine that named one keeps working without it: the traffic those ranges allowed is now
|
||||||
|
-- allowed by not having arrived from outside.
|
||||||
|
alter table node drop column routed_networks;
|
||||||
+84
-18
@@ -518,6 +518,61 @@ func (i *Inventory) PublicDomainOf(ctx context.Context, name string) (string, er
|
|||||||
return *domain, nil
|
return *domain, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RecordOutwardLinks keeps the links a machine reported as facing outside it.
|
||||||
|
//
|
||||||
|
// A reported fact, not a setting (novox/hq ADR 0140). It replaces the networks a machine used to be
|
||||||
|
// told to say it routes: the filter blocked everything passing through and then allowed the machine's
|
||||||
|
// own containers back by naming their address ranges, and every way of keeping that list correct
|
||||||
|
// failed — a constant describes one machine, and a recorded range goes stale in silence. The filter
|
||||||
|
// now constrains what arrives from outside and says nothing about what did not, and the one thing it
|
||||||
|
// needs is which links "outside" arrives on. The machine reads that from its own routing table on
|
||||||
|
// every apply, so it cannot go stale and nobody types it.
|
||||||
|
//
|
||||||
|
// An empty list clears it, which is what a machine with no route off itself reports. The mesh then
|
||||||
|
// composes no filter for that machine at all.
|
||||||
|
func (i *Inventory) RecordOutwardLinks(ctx context.Context, id string, links []string) error {
|
||||||
|
var kept []string
|
||||||
|
for _, name := range links {
|
||||||
|
if name = strings.TrimSpace(name); name != "" {
|
||||||
|
kept = append(kept, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(kept) == 0 {
|
||||||
|
_, err := i.store.Pool().Exec(ctx,
|
||||||
|
`update node set outward_links = null where id = $1`, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(kept)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = i.store.Pool().Exec(ctx,
|
||||||
|
`update node set outward_links = $2 where id = $1`, id, string(body))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// OutwardLinksOf is the links a machine reported as facing outside it, empty when it has reported
|
||||||
|
// none — which is a machine the mesh composes no filter for.
|
||||||
|
func (i *Inventory) OutwardLinksOf(ctx context.Context, name string) ([]string, error) {
|
||||||
|
var body []byte
|
||||||
|
err := i.store.Pool().QueryRow(ctx,
|
||||||
|
`select outward_links from node where name = $1`, name).Scan(&body)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, fmt.Errorf("%w: %s", ErrNoSuchNode, name)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(body) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
var links []string
|
||||||
|
if err := json.Unmarshal(body, &links); err != nil {
|
||||||
|
return nil, fmt.Errorf("the outward links recorded for %s are not a list: %w", name, err)
|
||||||
|
}
|
||||||
|
return links, nil
|
||||||
|
}
|
||||||
|
|
||||||
// RecordOverlayKey keeps the public half a node generated.
|
// RecordOverlayKey keeps the public half a node generated.
|
||||||
func (i *Inventory) RecordOverlayKey(ctx context.Context, node, key string) error {
|
func (i *Inventory) RecordOverlayKey(ctx context.Context, node, key string) error {
|
||||||
if strings.TrimSpace(key) == "" {
|
if strings.TrimSpace(key) == "" {
|
||||||
@@ -670,31 +725,39 @@ func sameFailure(a, b Doing) bool {
|
|||||||
// a clean apply clears both (novox/hq 04-ISSUES/065). The previous row is read first and the
|
// a clean apply clears both (novox/hq 04-ISSUES/065). The previous row is read first and the
|
||||||
// comparison made here, so "the same" is a rule this package states rather than a jsonb equality
|
// comparison made here, so "the same" is a rule this package states rather than a jsonb equality
|
||||||
// that would restart the count on a changed word in an error.
|
// that would restart the count on a changed word in an error.
|
||||||
func (i *Inventory) RecordDoing(ctx context.Context, node string, d Doing) error {
|
// **And whether this report was news**, which is what makes a fact about it worth stating (novox/hq
|
||||||
|
// ADR 0134). A machine reconciles continuously and reports each time; the same outcome about the same
|
||||||
|
// declaration is the same state said again, and a fact per report would be a fact per minute per
|
||||||
|
// machine that tells nobody anything. Read here because the previous row is read here anyway.
|
||||||
|
func (i *Inventory) RecordDoing(ctx context.Context, node string, d Doing) (news bool, err error) {
|
||||||
failed, err := json.Marshal(d.Failed)
|
failed, err := json.Marshal(d.Failed)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return false, err
|
||||||
|
}
|
||||||
|
var before Doing
|
||||||
|
var beforeFailed []byte
|
||||||
|
found := i.store.Pool().QueryRow(ctx,
|
||||||
|
`select outcome, refused, failed, failing_since, failures, coalesce(declared,'')
|
||||||
|
from node_report where node = $1`,
|
||||||
|
node).Scan(&before.Outcome, &before.Refused, &beforeFailed, &before.Since, &before.Times,
|
||||||
|
&before.Declared)
|
||||||
|
switch {
|
||||||
|
case errors.Is(found, pgx.ErrNoRows):
|
||||||
|
news = true
|
||||||
|
case found != nil:
|
||||||
|
return false, found
|
||||||
|
default:
|
||||||
|
if err := json.Unmarshal(beforeFailed, &before.Failed); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
news = before.Outcome != d.Outcome || before.Declared != d.Declared || !sameFailure(before, d)
|
||||||
}
|
}
|
||||||
var since *time.Time
|
var since *time.Time
|
||||||
times := 0
|
times := 0
|
||||||
if d.Outcome != OutcomeApplied {
|
if d.Outcome != OutcomeApplied {
|
||||||
var before Doing
|
|
||||||
var beforeFailed []byte
|
|
||||||
err := i.store.Pool().QueryRow(ctx,
|
|
||||||
`select outcome, refused, failed, failing_since, failures from node_report where node = $1`,
|
|
||||||
node).Scan(&before.Outcome, &before.Refused, &beforeFailed, &before.Since, &before.Times)
|
|
||||||
switch {
|
|
||||||
case errors.Is(err, pgx.ErrNoRows):
|
|
||||||
case err != nil:
|
|
||||||
return err
|
|
||||||
default:
|
|
||||||
if err := json.Unmarshal(beforeFailed, &before.Failed); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
since, times = &now, 1
|
since, times = &now, 1
|
||||||
if err == nil && sameFailure(before, d) && before.Since != nil {
|
if found == nil && sameFailure(before, d) && before.Since != nil {
|
||||||
since, times = before.Since, before.Times+1
|
since, times = before.Since, before.Times+1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -707,7 +770,10 @@ func (i *Inventory) RecordDoing(ctx context.Context, node string, d Doing) error
|
|||||||
declared = excluded.declared,
|
declared = excluded.declared,
|
||||||
failing_since = excluded.failing_since, failures = excluded.failures`,
|
failing_since = excluded.failing_since, failures = excluded.failures`,
|
||||||
node, d.Outcome, d.Refused, failed, d.Applied, d.Declared, since, times)
|
node, d.Outcome, d.Refused, failed, d.Applied, d.Declared, since, times)
|
||||||
return err
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return news, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// NotDoingWhatTheyWereTold is every machine whose last report was not a clean apply.
|
// NotDoingWhatTheyWereTold is every machine whose last report was not a clean apply.
|
||||||
|
|||||||
@@ -88,10 +88,23 @@ type BuildResult struct {
|
|||||||
// (novox/hq ADR 0009). The catalogue turns these into edges; nothing else need care.
|
// (novox/hq ADR 0009). The catalogue turns these into edges; nothing else need care.
|
||||||
Against []string `json:"against,omitempty"`
|
Against []string `json:"against,omitempty"`
|
||||||
|
|
||||||
|
// Read is every repository this build read source from besides the module's own. A module whose
|
||||||
|
// recipe packages source that lives elsewhere is affected when that repository moves, and the
|
||||||
|
// manifest the mesh keeps says nothing about it (novox/hq 04-ISSUES/131).
|
||||||
|
Read []ReadRepository `json:"read,omitempty"`
|
||||||
|
|
||||||
// Failed is why, when it did.
|
// Failed is why, when it did.
|
||||||
Failed string `json:"failed,omitempty"`
|
Failed string `json:"failed,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ReadRepository is a repository a build read source from besides the module's own, at the branch,
|
||||||
|
// tag or commit it read. Spelled here as well as in the catalogue and the inventory, for the reason
|
||||||
|
// MadeArtifact is: one direction of dependency.
|
||||||
|
type ReadRepository struct {
|
||||||
|
Repository string `json:"repository"`
|
||||||
|
Ref string `json:"ref,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
// MadeArtifact is one thing a build produced, as a person would want it reported.
|
// MadeArtifact is one thing a build produced, as a person would want it reported.
|
||||||
type MadeArtifact struct {
|
type MadeArtifact struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
|
|||||||
@@ -30,6 +30,11 @@ type Bus interface {
|
|||||||
// (design 29 §4, the *state* shape).
|
// (design 29 §4, the *state* shape).
|
||||||
PublishDeclaration(ctx context.Context, node string, body []byte) error
|
PublishDeclaration(ctx context.Context, node string, body []byte) error
|
||||||
|
|
||||||
|
// PublishSeatEvent states a fact under a role's own name, for the holder of that role. A
|
||||||
|
// module's event is addressed to the module; a role's is addressed to the role, so it keeps
|
||||||
|
// meaning when the holder changes (novox/hq ADR 0121, ADR 0129).
|
||||||
|
PublishSeatEvent(ctx context.Context, seat, event string, body []byte) error
|
||||||
|
|
||||||
// AskTool sends one question to a module's tool and awaits one answer. A tool nobody serves
|
// AskTool sends one question to a module's tool and awaits one answer. A tool nobody serves
|
||||||
// must say so **at once** rather than after the whole wait: the difference between "that
|
// must say so **at once** rather than after the whole wait: the difference between "that
|
||||||
// module is down" and "that tool is slow" is the first thing a person asking wants.
|
// module is down" and "that tool is slow" is the first thing a person asking wants.
|
||||||
@@ -81,6 +86,13 @@ func EventSubject(source, key string) string {
|
|||||||
return "mesh.mod." + source + ".event." + key
|
return "mesh.mod." + source + ".event." + key
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SeatEventSubject is where a role's own event lands. Derived from the role, never from its holder:
|
||||||
|
// a fact about the build machine or about the control plane keeps its address when the module holding
|
||||||
|
// that role is replaced (novox/hq ADR 0121, ADR 0129).
|
||||||
|
func SeatEventSubject(seat, event string) string {
|
||||||
|
return "mesh.seat." + seat + ".event." + event
|
||||||
|
}
|
||||||
|
|
||||||
// DeclareSubject is where one node's declaration lands. Last-per-subject on the NODES stream, so
|
// DeclareSubject is where one node's declaration lands. Last-per-subject on the NODES stream, so
|
||||||
// a node that was away gets exactly the current one and a replayed older one is refused by
|
// a node that was away gets exactly the current one and a replayed older one is refused by
|
||||||
// sequence — the wire-level answer to novox/hq issue 107.
|
// sequence — the wire-level answer to novox/hq issue 107.
|
||||||
@@ -112,6 +124,27 @@ func (b OverNATS) PublishEvent(ctx context.Context, key, source, node string, bo
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PublishSeatEvent states a role's own fact. Same envelope as a module's event and a different
|
||||||
|
// address: the source header is the role, because that is what the fact is about.
|
||||||
|
func (b OverNATS) PublishSeatEvent(ctx context.Context, seat, event string, body []byte) error {
|
||||||
|
id, err := eventID()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
h := nats.Header{}
|
||||||
|
h.Set("x-event-id", id)
|
||||||
|
h.Set("x-source", seat)
|
||||||
|
_, err = b.JS.PublishMsg(&nats.Msg{
|
||||||
|
Subject: SeatEventSubject(seat, event),
|
||||||
|
Header: h,
|
||||||
|
Data: body,
|
||||||
|
}, nats.MsgId(id), nats.Context(ctx))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("stating %s of the %s seat: %w", event, seat, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (b OverNATS) PublishDeclaration(ctx context.Context, node string, body []byte) error {
|
func (b OverNATS) PublishDeclaration(ctx context.Context, node string, body []byte) error {
|
||||||
_, err := b.JS.Publish(DeclareSubject(node), body, nats.Context(ctx))
|
_, err := b.JS.Publish(DeclareSubject(node), body, nats.Context(ctx))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
+27
-13
@@ -265,7 +265,7 @@ func (e Enrolment) Outstanding(ctx context.Context, node string) (string, error)
|
|||||||
return e.Inventory.Outstanding(ctx, node)
|
return e.Inventory.Outstanding(ctx, node)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e Enrolment) Heard(ctx context.Context, report Report) (err error) {
|
func (e Enrolment) Heard(ctx context.Context, report Report) (news bool, err error) {
|
||||||
// A store that could not be asked right now is said as such, so the report is kept for
|
// A store that could not be asked right now is said as such, so the report is kept for
|
||||||
// another attempt rather than acknowledged and lost (novox/hq issue 082).
|
// another attempt rather than acknowledged and lost (novox/hq issue 082).
|
||||||
defer func() {
|
defer func() {
|
||||||
@@ -274,11 +274,11 @@ func (e Enrolment) Heard(ctx context.Context, report Report) (err error) {
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
if report.Node == "" {
|
if report.Node == "" {
|
||||||
return errors.New("a report named no node")
|
return false, errors.New("a report named no node")
|
||||||
}
|
}
|
||||||
node, err := e.Inventory.NodeByName(ctx, report.Node)
|
node, err := e.Inventory.NodeByName(ctx, report.Node)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return false, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// What an adopted node holds, which firewall it found, and what is reachable on it (novox/hq
|
// What an adopted node holds, which firewall it found, and what is reachable on it (novox/hq
|
||||||
@@ -298,7 +298,18 @@ func (e Enrolment) Heard(ctx context.Context, report Report) (err error) {
|
|||||||
Port: r.Port, By: r.By, Published: r.Published, ContainerPort: r.ContainerPort})
|
Port: r.Port, By: r.By, Published: r.Published, ContainerPort: r.ContainerPort})
|
||||||
}
|
}
|
||||||
if err := e.Inventory.RecordAdoption(ctx, node.ID, held, report.Firewall, reachable); err != nil {
|
if err := e.Inventory.RecordAdoption(ctx, node.ID, held, report.Firewall, reachable); err != nil {
|
||||||
return err
|
return false, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Which of its links face outside (novox/hq ADR 0140), whenever it says so. Recorded on every
|
||||||
|
// report that carries it, adopted or converged, because the filter the mesh composes is written
|
||||||
|
// around it — and never cleared by a report that carries none, which is every bare word that the
|
||||||
|
// node is there. A machine whose routing table it could not read reports nothing rather than
|
||||||
|
// guessing, and keeps whatever it last said; a machine with genuinely no route off itself is one
|
||||||
|
// the mesh composes no filter for at all.
|
||||||
|
if len(report.Outward) > 0 {
|
||||||
|
if err := e.Inventory.RecordOutwardLinks(ctx, node.ID, report.Outward); 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.
|
||||||
@@ -308,7 +319,7 @@ func (e Enrolment) Heard(ctx context.Context, report Report) (err error) {
|
|||||||
Peers: report.Tunnel.Peers, State: report.Tunnel.State, Note: report.Tunnel.Note,
|
Peers: report.Tunnel.Peers, State: report.Tunnel.State, Note: report.Tunnel.Note,
|
||||||
Kept: report.Tunnel.Kept,
|
Kept: report.Tunnel.Kept,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
return err
|
return false, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// A node taking a found tunnel's key after enrolment (novox/hq ADR 0105). Verified against the
|
// A node taking a found tunnel's key after enrolment (novox/hq ADR 0105). Verified against the
|
||||||
@@ -317,9 +328,9 @@ func (e Enrolment) Heard(ctx context.Context, report Report) (err error) {
|
|||||||
// does not verify or is stale — a refusal, not "not now", so the node hears why.
|
// does not verify or is stale — a refusal, not "not now", so the node hears why.
|
||||||
if report.Rekey != nil {
|
if report.Rekey != nil {
|
||||||
if err := e.rekey(ctx, node, *report.Rekey); err != nil {
|
if err := e.rekey(ctx, node, *report.Rekey); err != nil {
|
||||||
return err
|
return false, err
|
||||||
}
|
}
|
||||||
return e.Inventory.Seen(ctx, node.ID)
|
return false, e.Inventory.Seen(ctx, node.ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// A bare word that a node is there is not an account of what the machine did or holds: it
|
// A bare word that a node is there is not an account of what the machine did or holds: it
|
||||||
@@ -334,7 +345,7 @@ func (e Enrolment) Heard(ctx context.Context, report Report) (err error) {
|
|||||||
if report.Superseded != "" {
|
if report.Superseded != "" {
|
||||||
log.Printf("%s set aside declaration %s for the newer %s", report.Node, report.Declared, report.Superseded)
|
log.Printf("%s set aside declaration %s for the newer %s", report.Node, report.Declared, report.Superseded)
|
||||||
}
|
}
|
||||||
return e.Inventory.Seen(ctx, node.ID)
|
return false, e.Inventory.Seen(ctx, node.ID)
|
||||||
}
|
}
|
||||||
// What it did is kept whichever way it went. Until this, a refusal or a failure moved
|
// What it did is kept whichever way it went. Until this, a refusal or a failure moved
|
||||||
// last_seen and the reason went to a log line, so "which machine is not doing what it was
|
// last_seen and the reason went to a log line, so "which machine is not doing what it was
|
||||||
@@ -361,17 +372,20 @@ func (e Enrolment) Heard(ctx context.Context, report Report) (err error) {
|
|||||||
// on top of it (novox/hq ADR 0038). Kept even when the declaration was refused: what the
|
// on top of it (novox/hq ADR 0038). Kept even when the declaration was refused: what the
|
||||||
// machine carries is true regardless of what it thought of the last thing it was sent.
|
// machine carries is true regardless of what it thought of the last thing it was sent.
|
||||||
if err := e.Inventory.RecordCarried(ctx, report.Node, report.Carried); err != nil {
|
if err := e.Inventory.RecordCarried(ctx, report.Node, report.Carried); err != nil {
|
||||||
return err
|
return false, err
|
||||||
}
|
}
|
||||||
if err := e.Inventory.RecordDoing(ctx, node.ID, doing); err != nil {
|
// **Whether this is news** is the store's answer: it holds the previous report, and a machine
|
||||||
return err
|
// that reconciles every minute says the same thing until something changes (novox/hq ADR 0134).
|
||||||
|
news, err = e.Inventory.RecordDoing(ctx, node.ID, doing)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// A refusal, a failure, or a bare word that the node is there — none of them is an account of
|
// A refusal, a failure, or a bare word that the node is there — none of them is an account of
|
||||||
// what the machine holds, so each moves last_seen and nothing else. Recording a partial list
|
// what the machine holds, so each moves last_seen and nothing else. Recording a partial list
|
||||||
// as though it were the whole would tell a rebuilding node to remove what it still has.
|
// as though it were the whole would tell a rebuilding node to remove what it still has.
|
||||||
if report.Refused != "" || len(report.Failed) > 0 || report.Applied == nil {
|
if report.Refused != "" || len(report.Failed) > 0 || report.Applied == nil {
|
||||||
return e.Inventory.Seen(ctx, node.ID)
|
return news, e.Inventory.Seen(ctx, node.ID)
|
||||||
}
|
}
|
||||||
return e.Inventory.RecordOwned(ctx, node.ID, report.Applied)
|
return news, e.Inventory.RecordOwned(ctx, node.ID, report.Applied)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,6 +49,39 @@ func eventID() (string, error) {
|
|||||||
return hex.EncodeToString(raw), nil
|
return hex.EncodeToString(raw), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MeshControllerSeat is the role the control plane holds, and therefore where its own facts live: a
|
||||||
|
// role's events belong to the role, not to whichever container is holding it today (novox/hq ADR 0121,
|
||||||
|
// ADR 0129). It is what makes them addressable while the control plane itself is being replaced.
|
||||||
|
const MeshControllerSeat = "mesh-controller"
|
||||||
|
|
||||||
|
// The facts the mesh states about its own work (novox/hq ADR 0134).
|
||||||
|
const (
|
||||||
|
// KeyApplied: a machine now runs what it was sent.
|
||||||
|
KeyApplied = "applied"
|
||||||
|
// KeyRefused: a machine did not take what it was sent, and why.
|
||||||
|
KeyRefused = "refused"
|
||||||
|
// KeyBuiltBefore: a build the mesh already held, for a catalogue that asked what it missed. Not
|
||||||
|
// `built` — that is the build machine's, said as it happens, and a replay is neither.
|
||||||
|
KeyBuiltBefore = "built-before"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Applied is what a machine now runs, as the mesh states it.
|
||||||
|
type Applied struct {
|
||||||
|
Node string `json:"node"`
|
||||||
|
Declared string `json:"declared,omitempty"`
|
||||||
|
// Resources is how many the machine applied, not which: the list is the machine's own account
|
||||||
|
// of itself and belongs in the records, not in a fact every listener has to read past.
|
||||||
|
Resources int `json:"resources"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refused is a machine that would not take what it was sent.
|
||||||
|
type Refused struct {
|
||||||
|
Node string `json:"node"`
|
||||||
|
Declared string `json:"declared,omitempty"`
|
||||||
|
Refused string `json:"refused,omitempty"`
|
||||||
|
Failed map[string]string `json:"failed,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
// KeyModuleBuilt is what the builder announces when it has built something. The catalogue places
|
// KeyModuleBuilt is what the builder announces when it has built something. The catalogue places
|
||||||
// it in the module graph; nothing else need care.
|
// it in the module graph; nothing else need care.
|
||||||
const KeyModuleBuilt = "module.builder.built"
|
const KeyModuleBuilt = "module.builder.built"
|
||||||
@@ -128,6 +161,18 @@ type SourceMoved struct {
|
|||||||
Commit string `json:"merge_commit_sha"`
|
Commit string `json:"merge_commit_sha"`
|
||||||
CloneURL string `json:"clone_url"`
|
CloneURL string `json:"clone_url"`
|
||||||
HTMLURL string `json:"html_url"`
|
HTMLURL string `json:"html_url"`
|
||||||
|
// MergedAt is when the forge merged it, RFC 3339. What decides whether this is news.
|
||||||
|
MergedAt string `json:"merged_at"`
|
||||||
|
|
||||||
|
// Paths are the files the merge changed, from the repository's root. Empty means the forge said
|
||||||
|
// nothing about them, and every module built from the repository is treated as affected.
|
||||||
|
Paths []string `json:"paths,omitempty"`
|
||||||
|
|
||||||
|
// PathsTruncated says the merge changed more files than the forge was asked to list, so Paths is
|
||||||
|
// a beginning rather than the whole change — and again, everything is treated as affected. Said
|
||||||
|
// rather than inferred from a round number, because "this is all of it" and "this is as much as
|
||||||
|
// I asked for" are the difference between rebuilding a module and leaving it stale.
|
||||||
|
PathsTruncated bool `json:"paths_truncated,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Upgraded struct {
|
type Upgraded struct {
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ func heardFrom(t *testing.T, report link.Report) (*inventory.Inventory, inventor
|
|||||||
if _, err := inv.AddNode(ctx, report.Node); err != nil {
|
if _, err := inv.AddNode(ctx, report.Node); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if err := (link.Enrolment{Inventory: inv}).Heard(ctx, report); err != nil {
|
if _, err := (link.Enrolment{Inventory: inv}).Heard(ctx, report); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
doing, said, err := inv.DoingOf(ctx, report.Node)
|
doing, said, err := inv.DoingOf(ctx, report.Node)
|
||||||
@@ -103,7 +103,7 @@ func TestABareAliveDoesNotWipeTheDeclarationThatSaysANodeIsCurrent(t *testing.T)
|
|||||||
if err := inv.RecordSent(ctx, node.ID, digest); err != nil {
|
if err := inv.RecordSent(ctx, node.ID, digest); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if err := (link.Enrolment{Inventory: inv}).Heard(ctx, link.Report{
|
if _, err := (link.Enrolment{Inventory: inv}).Heard(ctx, link.Report{
|
||||||
Node: "anchor", Applied: []string{"a", "b"}, Declared: digest, Carried: []int{5432},
|
Node: "anchor", Applied: []string{"a", "b"}, Declared: digest, Carried: []int{5432},
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -126,7 +126,7 @@ func TestABareAliveDoesNotWipeTheDeclarationThatSaysANodeIsCurrent(t *testing.T)
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Now the node says only that it is there, as it does every minute.
|
// Now the node says only that it is there, as it does every minute.
|
||||||
if err := (link.Enrolment{Inventory: inv}).Heard(ctx, link.Report{Node: "anchor"}); err != nil {
|
if _, err := (link.Enrolment{Inventory: inv}).Heard(ctx, link.Report{Node: "anchor"}); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if !currentOf("anchor") {
|
if !currentOf("anchor") {
|
||||||
@@ -162,7 +162,7 @@ func TestAFailureDoesNotBecomeTheAccountOfWhatTheMachineHolds(t *testing.T) {
|
|||||||
if err := inv.RecordOwned(ctx, node.ID, []string{"one", "two", "three"}); err != nil {
|
if err := inv.RecordOwned(ctx, node.ID, []string{"one", "two", "three"}); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if err := (link.Enrolment{Inventory: inv}).Heard(ctx, link.Report{
|
if _, err := (link.Enrolment{Inventory: inv}).Heard(ctx, link.Report{
|
||||||
Node: "workstation", Applied: []string{"one"}, Failed: map[string]string{"two": "no"},
|
Node: "workstation", Applied: []string{"one"}, Failed: map[string]string{"two": "no"},
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -200,13 +200,13 @@ func TestWhatAnAdoptedNodeHoldsIsKeptAndAnAliveWordDoesNotWipeIt(t *testing.T) {
|
|||||||
}
|
}
|
||||||
check("after the report")
|
check("after the report")
|
||||||
|
|
||||||
if err := (link.Enrolment{Inventory: inv}).Heard(ctx, link.Report{Node: "anchor"}); err != nil {
|
if _, err := (link.Enrolment{Inventory: inv}).Heard(ctx, link.Report{Node: "anchor"}); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
check("after an alive word")
|
check("after an alive word")
|
||||||
|
|
||||||
// A reconcile report carrying only adoption is recorded, though it applied nothing.
|
// A reconcile report carrying only adoption is recorded, though it applied nothing.
|
||||||
if err := (link.Enrolment{Inventory: inv}).Heard(ctx, link.Report{Node: "anchor",
|
if _, err := (link.Enrolment{Inventory: inv}).Heard(ctx, link.Report{Node: "anchor",
|
||||||
Firewall: "ufw"}); err != nil {
|
Firewall: "ufw"}); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -170,6 +170,20 @@ type Report struct {
|
|||||||
// Firewall is the firewall found on the machine — "ufw" or "none" — and empty on a node that
|
// Firewall is the firewall found on the machine — "ufw" or "none" — and empty on a node that
|
||||||
// was never asked, which is every converged one.
|
// was never asked, which is every converged one.
|
||||||
Firewall string `json:"firewall,omitempty"`
|
Firewall string `json:"firewall,omitempty"`
|
||||||
|
|
||||||
|
// Outward is the links on this machine that face outside it — the ones carrying a default route
|
||||||
|
// (novox/hq ADR 0140). Every node reports it, adopted or converged, because the filter the mesh
|
||||||
|
// composes for it is written around these and nothing else.
|
||||||
|
//
|
||||||
|
// **It replaces a list of addresses.** The filter used to block everything passing through the
|
||||||
|
// machine and then allow the machine's own containers back by naming the ranges they sit on. A
|
||||||
|
// range describes one machine and goes stale in silence; the link carrying the default route is
|
||||||
|
// read afresh on every report and does not change when a module is added or removed.
|
||||||
|
//
|
||||||
|
// Empty means the machine has not said. The mesh composes no filter for such a machine and
|
||||||
|
// 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"`
|
||||||
// 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"`
|
||||||
|
|||||||
@@ -154,10 +154,47 @@ func (n *natsInbound) deliver(ctx context.Context, act func(context.Context, Con
|
|||||||
m.seq = meta.Sequence.Stream
|
m.seq = meta.Sequence.Stream
|
||||||
m.delivered = meta.NumDelivered
|
m.delivered = meta.NumDelivered
|
||||||
}
|
}
|
||||||
|
// **Work that outlives the acknowledgement window says so while it runs.**
|
||||||
|
//
|
||||||
|
// The bus waits a fixed time to be told a message was taken, and then hands it to whoever
|
||||||
|
// consumes next — which is right for a consumer that died and wrong for one that is busy.
|
||||||
|
// Acting on a merge builds every module the merge changed: minutes of work against a
|
||||||
|
// thirty-second window. So the same merge was handed over again while the first build was
|
||||||
|
// still running, and again after that — on 2026-09-28 one merge ran the mesh's whole
|
||||||
|
// catalogue five times over and exhausted a public registry's pull limit.
|
||||||
|
//
|
||||||
|
// Here rather than in each handler, because the window belongs to the transport and every
|
||||||
|
// handler would otherwise have to remember it. It changes nothing about a handler that
|
||||||
|
// dies: a message is kept alive only while this goroutine is, so a controller that stops
|
||||||
|
// stops saying so, and the bus redelivers exactly as it should.
|
||||||
|
working := make(chan struct{})
|
||||||
|
defer close(working)
|
||||||
|
go stillWorking(msg, working)
|
||||||
}
|
}
|
||||||
act(ctx, m)
|
act(ctx, m)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// heartbeatWhileWorking is how often a handler still running tells the bus so — comfortably inside
|
||||||
|
// the shortest acknowledgement window the mesh gives any of its consumers.
|
||||||
|
const heartbeatWhileWorking = 10 * time.Second
|
||||||
|
|
||||||
|
// stillWorking keeps one message alive until the work on it returns.
|
||||||
|
//
|
||||||
|
// An error is not worth reporting: what the bus does when it is not told is redeliver, which is
|
||||||
|
// exactly what happens if this fails, and the handler's own outcome is the thing worth logging.
|
||||||
|
func stillWorking(msg *nats.Msg, done <-chan struct{}) {
|
||||||
|
tick := time.NewTicker(heartbeatWhileWorking)
|
||||||
|
defer tick.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
return
|
||||||
|
case <-tick.C:
|
||||||
|
_ = msg.InProgress()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// kindOfSubject is how this transport's addressing becomes what the mesh calls a message.
|
// kindOfSubject is how this transport's addressing becomes what the mesh calls a message.
|
||||||
//
|
//
|
||||||
// By subject, which is the only thing the server enforces: a body claiming to be a report does not
|
// By subject, which is the only thing the server enforces: a body claiming to be a report does not
|
||||||
|
|||||||
@@ -86,14 +86,15 @@ type counted struct {
|
|||||||
heard []Report
|
heard []Report
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *counted) Heard(_ context.Context, r Report) error {
|
func (c *counted) Heard(_ context.Context, r Report) (bool, error) {
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
defer c.mu.Unlock()
|
defer c.mu.Unlock()
|
||||||
if c.err != nil {
|
if c.err != nil {
|
||||||
return c.err
|
return false, c.err
|
||||||
}
|
}
|
||||||
c.heard = append(c.heard, r)
|
c.heard = append(c.heard, r)
|
||||||
return nil
|
// News, so what the mesh states about a report is exercised wherever a report is.
|
||||||
|
return true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *counted) refusing(err error) {
|
func (c *counted) refusing(err error) {
|
||||||
@@ -207,11 +208,11 @@ type sentAndHeardSafely struct {
|
|||||||
heard []Report
|
heard []Report
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *sentAndHeardSafely) Heard(_ context.Context, r Report) error {
|
func (s *sentAndHeardSafely) Heard(_ context.Context, r Report) (bool, error) {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
s.heard = append(s.heard, r)
|
s.heard = append(s.heard, r)
|
||||||
return nil
|
return true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *sentAndHeardSafely) Outstanding(context.Context, string) (string, error) {
|
func (s *sentAndHeardSafely) Outstanding(context.Context, string) (string, error) {
|
||||||
@@ -395,3 +396,164 @@ func TestEverySubjectTheControllerFollowsDecodesToAKind(t *testing.T) {
|
|||||||
t.Error("a subject nobody follows decoded to a kind")
|
t.Error("a subject nobody follows decoded to a kind")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// **A handler slower than the acknowledgement window is not handed its message again.**
|
||||||
|
//
|
||||||
|
// The bus waits a fixed time to be told a message was taken and then redelivers, which is right for
|
||||||
|
// a consumer that died and wrong for one that is busy. Acting on a merge builds modules — minutes
|
||||||
|
// against a thirty-second window — and the same merge was handed over five times while the first
|
||||||
|
// build was still running (2026-09-28). Here the window is two seconds and the work takes six.
|
||||||
|
func TestNatsWorkSlowerThanTheWindowIsNotHandedOverAgain(t *testing.T) {
|
||||||
|
js := aBus(t)
|
||||||
|
// The controller's own consumer, with a window short enough to outlive in a test.
|
||||||
|
if err := js.EnsureConsumer(broker.Consumer{
|
||||||
|
Name: broker.ControllerName, Stream: "CONTROL", Push: true, AckWaitSeconds: 2,
|
||||||
|
Why: "a window short enough to outlive in a test",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var mu sync.Mutex
|
||||||
|
handled := 0
|
||||||
|
slow := make(chan struct{})
|
||||||
|
s, stop := servingOn(t, js, nil)
|
||||||
|
defer stop()
|
||||||
|
s.listener = slowly{func() {
|
||||||
|
mu.Lock()
|
||||||
|
handled++
|
||||||
|
first := handled == 1
|
||||||
|
mu.Unlock()
|
||||||
|
if first {
|
||||||
|
time.Sleep(6 * time.Second)
|
||||||
|
close(slow)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
|
||||||
|
body, err := json.Marshal(Report{Node: "anchor", Declared: "d1", Applied: []string{"store"}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := js.Context().Publish(ReportSubject("anchor"), body); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-slow:
|
||||||
|
case <-time.After(30 * time.Second):
|
||||||
|
t.Fatal("the slow work never finished")
|
||||||
|
}
|
||||||
|
// A moment for a redelivery to arrive, if the bus were going to send one.
|
||||||
|
time.Sleep(3 * time.Second)
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
if handled != 1 {
|
||||||
|
t.Fatalf("one report was handled %d times, so slow work is run again while it is running", handled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// slowly is a listener that runs whatever it was given.
|
||||||
|
type slowly struct{ work func() }
|
||||||
|
|
||||||
|
func (s slowly) Heard(context.Context, Report) (bool, error) { s.work(); return true, nil }
|
||||||
|
|
||||||
|
// **The mesh says what it applied** (novox/hq ADR 0134), under the seat the control plane holds — and
|
||||||
|
// says nothing when a report is the same state said again, which is what a machine reconciling every
|
||||||
|
// minute sends.
|
||||||
|
func TestNatsTheMeshSaysWhatAMachineApplied(t *testing.T) {
|
||||||
|
js := aBus(t)
|
||||||
|
heard := make(chan *nats.Msg, 4)
|
||||||
|
sub, err := js.Conn().Subscribe(SeatEventSubject(MeshControllerSeat, ">"), func(m *nats.Msg) {
|
||||||
|
heard <- m
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer sub.Unsubscribe() //nolint:errcheck // the subscription dies with the connection
|
||||||
|
|
||||||
|
_, stop := servingOn(t, js, &counted{})
|
||||||
|
defer stop()
|
||||||
|
|
||||||
|
// A report that changed something: the store says it was news.
|
||||||
|
body, err := json.Marshal(Report{Node: "anchor", Declared: "d1", Applied: []string{"store", "broker"}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := js.Context().Publish(ReportSubject("anchor"), body); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case m := <-heard:
|
||||||
|
if m.Subject != SeatEventSubject(MeshControllerSeat, KeyApplied) {
|
||||||
|
t.Fatalf("the mesh stated %q", m.Subject)
|
||||||
|
}
|
||||||
|
var said Applied
|
||||||
|
if err := json.Unmarshal(m.Data, &said); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if said.Node != "anchor" || said.Declared != "d1" || said.Resources != 2 {
|
||||||
|
t.Fatalf("it said %+v", said)
|
||||||
|
}
|
||||||
|
case <-time.After(10 * time.Second):
|
||||||
|
t.Fatal("the mesh said nothing about a machine that now runs something else")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A refusal is its own fact, with the reason in it rather than only in a log.
|
||||||
|
refusal, err := json.Marshal(Report{Node: "anchor", Declared: "d2",
|
||||||
|
Failed: map[string]string{"gitea.server": "no such image"}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := js.Context().Publish(ReportSubject("anchor"), refusal); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case m := <-heard:
|
||||||
|
if m.Subject != SeatEventSubject(MeshControllerSeat, KeyRefused) {
|
||||||
|
t.Fatalf("a refusal was stated as %q", m.Subject)
|
||||||
|
}
|
||||||
|
var said Refused
|
||||||
|
if err := json.Unmarshal(m.Data, &said); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if said.Failed["gitea.server"] == "" {
|
||||||
|
t.Fatalf("the refusal does not say which resource or why: %+v", said)
|
||||||
|
}
|
||||||
|
case <-time.After(10 * time.Second):
|
||||||
|
t.Fatal("the mesh said nothing about a machine that refused what it was sent")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// And a report that is not news is not a fact. A machine reconciles every minute; a fact per report
|
||||||
|
// would be a fact per minute per machine, which is a stream nobody reads.
|
||||||
|
func TestNatsAReportThatIsNotNewsIsNotStated(t *testing.T) {
|
||||||
|
js := aBus(t)
|
||||||
|
heard := make(chan *nats.Msg, 4)
|
||||||
|
sub, err := js.Conn().Subscribe(SeatEventSubject(MeshControllerSeat, ">"), func(m *nats.Msg) {
|
||||||
|
heard <- m
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer sub.Unsubscribe() //nolint:errcheck // the subscription dies with the connection
|
||||||
|
|
||||||
|
// A store that records the report and says it was nothing new — which is what the mesh's own
|
||||||
|
// store says about a machine repeating itself.
|
||||||
|
_, stop := servingOn(t, js, sameAgain{})
|
||||||
|
defer stop()
|
||||||
|
|
||||||
|
body, err := json.Marshal(Report{Node: "anchor", Declared: "d1", Applied: []string{"store"}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := js.Context().Publish(ReportSubject("anchor"), body); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case m := <-heard:
|
||||||
|
t.Fatalf("the mesh stated %q about a machine that changed nothing", m.Subject)
|
||||||
|
case <-time.After(3 * time.Second):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sameAgain records a report and says it was the same state said again.
|
||||||
|
type sameAgain struct{}
|
||||||
|
|
||||||
|
func (sameAgain) Heard(context.Context, Report) (bool, error) { return false, nil }
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ func TestASignedRekeyMovesTheHubOntoItsTunnel(t *testing.T) {
|
|||||||
rekey := &link.Rekey{Previous: ownKey, OverlayKey: tunnelKey, Tunnel: theTunnel()}
|
rekey := &link.Rekey{Previous: ownKey, OverlayKey: tunnelKey, Tunnel: theTunnel()}
|
||||||
rekey.Proof = ed25519.Sign(private, link.RekeyProof("anchor", ownKey, tunnelKey, theTunnel()))
|
rekey.Proof = ed25519.Sign(private, link.RekeyProof("anchor", ownKey, tunnelKey, theTunnel()))
|
||||||
|
|
||||||
if err := e.Heard(ctx, link.Report{Node: "anchor", Rekey: rekey}); err != nil {
|
if _, err := e.Heard(ctx, link.Report{Node: "anchor", Rekey: rekey}); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
placed, err := e.Inventory.Overlays(ctx)
|
placed, err := e.Inventory.Overlays(ctx)
|
||||||
@@ -77,7 +77,7 @@ func TestASignedRekeyMovesTheHubOntoItsTunnel(t *testing.T) {
|
|||||||
_ = hub
|
_ = hub
|
||||||
|
|
||||||
// Replayed, it is stale: the previous key it names is no longer the node's.
|
// Replayed, it is stale: the previous key it names is no longer the node's.
|
||||||
err = e.Heard(ctx, link.Report{Node: "anchor", Rekey: rekey})
|
_, err = e.Heard(ctx, link.Report{Node: "anchor", Rekey: rekey})
|
||||||
if err == nil || !strings.Contains(err.Error(), "previous overlay key") {
|
if err == nil || !strings.Contains(err.Error(), "previous overlay key") {
|
||||||
t.Fatalf("a replayed rekey was accepted: %v", err)
|
t.Fatalf("a replayed rekey was accepted: %v", err)
|
||||||
}
|
}
|
||||||
@@ -93,7 +93,7 @@ func TestARekeySignedByAnotherKeyIsRefusedAndChangesNothing(t *testing.T) {
|
|||||||
rekey := &link.Rekey{Previous: ownKey, OverlayKey: tunnelKey, Tunnel: theTunnel()}
|
rekey := &link.Rekey{Previous: ownKey, OverlayKey: tunnelKey, Tunnel: theTunnel()}
|
||||||
rekey.Proof = ed25519.Sign(stranger, link.RekeyProof("anchor", ownKey, tunnelKey, theTunnel()))
|
rekey.Proof = ed25519.Sign(stranger, link.RekeyProof("anchor", ownKey, tunnelKey, theTunnel()))
|
||||||
|
|
||||||
err = e.Heard(ctx, link.Report{Node: "anchor", Rekey: rekey})
|
_, err = e.Heard(ctx, link.Report{Node: "anchor", Rekey: rekey})
|
||||||
if err == nil || !strings.Contains(err.Error(), "not signed by anchor's identity key") {
|
if err == nil || !strings.Contains(err.Error(), "not signed by anchor's identity key") {
|
||||||
t.Fatalf("a rekey signed by a stranger was accepted: %v", err)
|
t.Fatalf("a rekey signed by a stranger was accepted: %v", err)
|
||||||
}
|
}
|
||||||
@@ -111,7 +111,7 @@ func TestARekeySignedByAnotherKeyIsRefusedAndChangesNothing(t *testing.T) {
|
|||||||
other := theTunnel()
|
other := theTunnel()
|
||||||
other.Port = 51820
|
other.Port = 51820
|
||||||
moved.Proof = ed25519.Sign(mustPrivate(t, e, "anchor"), link.RekeyProof("anchor", ownKey, tunnelKey, other))
|
moved.Proof = ed25519.Sign(mustPrivate(t, e, "anchor"), link.RekeyProof("anchor", ownKey, tunnelKey, other))
|
||||||
if err := e.Heard(ctx, link.Report{Node: "anchor", Rekey: moved}); err == nil {
|
if _, err := e.Heard(ctx, link.Report{Node: "anchor", Rekey: moved}); err == nil {
|
||||||
t.Fatal("a proof over another tunnel was accepted")
|
t.Fatal("a proof over another tunnel was accepted")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,12 +9,12 @@ import (
|
|||||||
|
|
||||||
type heardWith struct{ err error }
|
type heardWith struct{ err error }
|
||||||
|
|
||||||
func (h heardWith) Heard(context.Context, Report) error { return h.err }
|
func (h heardWith) Heard(context.Context, Report) (bool, error) { return h.err == nil, h.err }
|
||||||
|
|
||||||
// switchable answers with whatever it is set to — the store away, then back.
|
// switchable answers with whatever it is set to — the store away, then back.
|
||||||
type switchable struct{ err error }
|
type switchable struct{ err error }
|
||||||
|
|
||||||
func (h *switchable) Heard(context.Context, Report) error { return h.err }
|
func (h *switchable) Heard(context.Context, Report) (bool, error) { return h.err == nil, h.err }
|
||||||
|
|
||||||
func aReport(node, declared string) Report {
|
func aReport(node, declared string) Report {
|
||||||
return Report{Node: node, Declared: declared, Applied: []string{"store"}}
|
return Report{Node: node, Declared: declared, Applied: []string{"store"}}
|
||||||
|
|||||||
+64
-5
@@ -29,7 +29,12 @@ type Enroller interface {
|
|||||||
// Listener is what the controller does with a report. Separate from Enroller so the two can be
|
// Listener is what the controller does with a report. Separate from Enroller so the two can be
|
||||||
// given independently, and so a server that only sends declarations needs neither.
|
// given independently, and so a server that only sends declarations needs neither.
|
||||||
type Listener interface {
|
type Listener interface {
|
||||||
Heard(ctx context.Context, report Report) error
|
// Heard records what a node said, and says whether it was **news** — a machine that now runs
|
||||||
|
// something else, or refuses something it did not refuse before. A machine reconciles
|
||||||
|
// continuously and reports each time, so what is news is the store's answer rather than the
|
||||||
|
// bus's: only this side has the previous report to compare with. What the mesh states about it
|
||||||
|
// is the server's (novox/hq ADR 0134).
|
||||||
|
Heard(ctx context.Context, report Report) (news bool, err error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Recorder keeps what builders say.
|
// Recorder keeps what builders say.
|
||||||
@@ -257,7 +262,7 @@ func (s *Server) heartbeat(m Control) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if s.listener != nil {
|
if s.listener != nil {
|
||||||
if err := s.listener.Heard(context.Background(), Report{Node: alive.Node}); err != nil {
|
if _, err := s.listener.Heard(context.Background(), Report{Node: alive.Node}); err != nil {
|
||||||
s.log.Printf("could not record that %s is here: %v", alive.Node, err)
|
s.log.Printf("could not record that %s is here: %v", alive.Node, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -291,7 +296,7 @@ func (s *Server) reported(ctx context.Context, m Control) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
err := s.listener.Heard(context.Background(), report)
|
news, err := s.listener.Heard(context.Background(), report)
|
||||||
switch s.decide(ctx, m, what, declaredIn, outstanding, err) {
|
switch s.decide(ctx, m, what, declaredIn, outstanding, err) {
|
||||||
case Hold:
|
case Hold:
|
||||||
// Held, not settled, while the store cannot take it: the node reports an apply once,
|
// Held, not settled, while the store cannot take it: the node reports an apply once,
|
||||||
@@ -307,6 +312,13 @@ func (s *Server) reported(ctx context.Context, m Control) {
|
|||||||
// node whose recovery copy is silently older than it looks.
|
// node whose recovery copy is silently older than it looks.
|
||||||
s.log.Printf("could not record %s's report: %v", report.Node, err)
|
s.log.Printf("could not record %s's report: %v", report.Node, err)
|
||||||
}
|
}
|
||||||
|
// **And the mesh says what it did** (novox/hq ADR 0134). Only when the report was news: a
|
||||||
|
// machine reports every convergence, and a fact per report would be a fact per minute per
|
||||||
|
// machine saying nothing. Stated after it is recorded, so nothing is announced that the
|
||||||
|
// mesh does not hold.
|
||||||
|
if err == nil && news {
|
||||||
|
s.saysWhatItDid(ctx, report)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
switch {
|
switch {
|
||||||
@@ -334,7 +346,8 @@ func (s *Server) reported(ctx context.Context, m Control) {
|
|||||||
// whenever it arrives, which is the behaviour the mesh has had all along.
|
// whenever it arrives, which is the behaviour the mesh has had all along.
|
||||||
func staleAgainst(report Report) string {
|
func staleAgainst(report Report) string {
|
||||||
if report.Rekey != nil || report.Tunnel != nil || len(report.Held) > 0 ||
|
if report.Rekey != nil || report.Tunnel != nil || len(report.Held) > 0 ||
|
||||||
report.Firewall != "" || len(report.Reachable) > 0 || len(report.Carried) > 0 {
|
report.Firewall != "" || len(report.Reachable) > 0 || len(report.Carried) > 0 ||
|
||||||
|
len(report.Outward) > 0 {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
return report.Declared
|
return report.Declared
|
||||||
@@ -460,7 +473,19 @@ func (s *Server) catchingUp(ctx context.Context, m Control) {
|
|||||||
sent := 0
|
sent := 0
|
||||||
for _, a := range announcements {
|
for _, a := range announcements {
|
||||||
a.Replay = true
|
a.Replay = true
|
||||||
if err := EmitEvent(ctx, s.bus, KeyModuleBuilt, "control-plane", "", a); err != nil {
|
// Under the control plane's own seat (novox/hq ADR 0134). It used to be published as a
|
||||||
|
// module's event from a module called "control-plane", which does not exist — so the
|
||||||
|
// controller's own account refused it, every catalogue that asked what it missed was
|
||||||
|
// answered with nothing, and its graph kept the gap (found 2026-09-28).
|
||||||
|
body, err := json.Marshal(a)
|
||||||
|
if err != nil {
|
||||||
|
// A body that cannot be written is this program's fault, not the bus's, and publishing
|
||||||
|
// an empty one would put a fact on the mesh that says nothing.
|
||||||
|
s.log.Printf("cannot re-announce %s at %s: %v", a.Module, short(a.Commit), err)
|
||||||
|
_ = m.Took()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.bus.PublishSeatEvent(ctx, MeshControllerSeat, KeyBuiltBefore, body); err != nil {
|
||||||
// Said and abandoned rather than retried: the catalogue asks again every time it
|
// Said and abandoned rather than retried: the catalogue asks again every time it
|
||||||
// starts, and half a graph delivered twice is no better than half delivered once.
|
// starts, and half a graph delivered twice is no better than half delivered once.
|
||||||
s.log.Printf("replaying %s at %s failed, and the rest is abandoned: %v",
|
s.log.Printf("replaying %s at %s failed, and the rest is abandoned: %v",
|
||||||
@@ -551,3 +576,37 @@ func (s *Server) sourceMoved(ctx context.Context, m Control) {
|
|||||||
}
|
}
|
||||||
_ = m.Took()
|
_ = m.Took()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// saysWhatItDid states what a machine now runs, or what it would not take, as a fact on the bus
|
||||||
|
// (novox/hq ADR 0134).
|
||||||
|
//
|
||||||
|
// **The control plane speaks, as the holder of its seat.** A node's report is control traffic only
|
||||||
|
// this process may read, so the chain from a merge to a machine went dark exactly where it touched
|
||||||
|
// one: nothing said which version a machine runs, or that it refused to. The facts are second-hand
|
||||||
|
// on purpose — one emitter, one ordering — and a machine that cannot reach the bus produces none, so
|
||||||
|
// absence is not health.
|
||||||
|
//
|
||||||
|
// A failure to state a fact is logged and nothing else: the report is recorded, which is the part
|
||||||
|
// that must not be lost, and the next change says the same thing again.
|
||||||
|
func (s *Server) saysWhatItDid(ctx context.Context, report Report) {
|
||||||
|
if s.bus == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
event, body := KeyApplied, any(Applied{
|
||||||
|
Node: report.Node, Declared: report.Declared, Resources: len(report.Applied),
|
||||||
|
})
|
||||||
|
if report.Refused != "" || len(report.Failed) > 0 {
|
||||||
|
event, body = KeyRefused, Refused{
|
||||||
|
Node: report.Node, Declared: report.Declared,
|
||||||
|
Refused: report.Refused, Failed: report.Failed,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
raw, err := json.Marshal(body)
|
||||||
|
if err != nil {
|
||||||
|
s.log.Printf("could not say what %s did: %v", report.Node, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.bus.PublishSeatEvent(ctx, MeshControllerSeat, event, raw); err != nil {
|
||||||
|
s.log.Printf("could not say that %s %s: %v", report.Node, event, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -23,12 +23,12 @@ type sentAndHeard struct {
|
|||||||
err error
|
err error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *sentAndHeard) Heard(_ context.Context, r Report) error {
|
func (s *sentAndHeard) Heard(_ context.Context, r Report) (bool, error) {
|
||||||
if s.err != nil {
|
if s.err != nil {
|
||||||
return s.err
|
return false, s.err
|
||||||
}
|
}
|
||||||
s.heard = append(s.heard, r)
|
s.heard = append(s.heard, r)
|
||||||
return nil
|
return true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *sentAndHeard) Outstanding(context.Context, string) (string, error) { return s.sent, nil }
|
func (s *sentAndHeard) Outstanding(context.Context, string) (string, error) { return s.sent, nil }
|
||||||
|
|||||||
@@ -27,6 +27,7 @@
|
|||||||
"bus": "/var/lib/mesh/mesh-controller/bus"
|
"bus": "/var/lib/mesh/mesh-controller/bus"
|
||||||
},
|
},
|
||||||
"secrets-owner": "65534:65534",
|
"secrets-owner": "65534:65534",
|
||||||
|
"prepares": true,
|
||||||
"resources": [
|
"resources": [
|
||||||
{
|
{
|
||||||
"id": "mesh-state",
|
"id": "mesh-state",
|
||||||
|
|||||||
Reference in New Issue
Block a user