catalogue keeps only what a reader or the node-engine's composition uses; the judge's rules are the indexer's own (ADR 0578 §7, ADR 0593 §1)
Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
This commit is contained in:
@@ -7,13 +7,6 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// What an adopted node is declared in place of a filter (novox/hq ADR 0100).
|
||||
//
|
||||
// On an adopted node the firewall found on the machine stays in force: the mesh loads no table
|
||||
// that drops by default or holds an accept. What the mesh needs reachable is declared as
|
||||
// openings, which the host converges through the found firewall in its own terms; and the mesh
|
||||
// guards its own foundation ports itself, in a table that only refuses.
|
||||
|
||||
// AdoptionPrefix is the id prefix of what the mesh declares of its own on an adopted node. It is
|
||||
// never a module's, so none of it is ever held as found.
|
||||
const AdoptionPrefix = "adoption."
|
||||
|
||||
@@ -298,38 +298,6 @@ func TestAGivenPortIsUsedEverywhereThePortIs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAGivenPortIsTheNodesAndReachesSomething(t *testing.T) {
|
||||
store := anAdoptedAnchor().Modules[1]
|
||||
node := func(v any) []Layer {
|
||||
return []Layer{{From: "anchor", Placement: map[string]any{PortsSetting: v}}}
|
||||
}
|
||||
if got, err := GivenPorts(store, node(map[string]any{"5432": float64(5433)})); err != nil ||
|
||||
got[5432] != 5433 {
|
||||
t.Fatalf("a node's given port was not read: %v %v", got, err)
|
||||
}
|
||||
if _, err := GivenPorts(store, []Layer{{From: MeshWideLayer,
|
||||
Placement: map[string]any{PortsSetting: map[string]any{"5432": float64(5433)}}}}); err == nil {
|
||||
t.Fatal("a port given for the whole mesh was accepted")
|
||||
}
|
||||
if _, err := GivenPorts(store, node(map[string]any{"6000": float64(6001)})); err == nil {
|
||||
t.Fatal("a port the module neither listens on, publishes nor guards was given")
|
||||
}
|
||||
if _, err := GivenPorts(store, node(map[string]any{"5432": float64(70000)})); err == nil {
|
||||
t.Fatal("a machine port that is not a port was given")
|
||||
}
|
||||
if _, err := GivenPorts(store, node(map[string]any{"5432": float64(22)})); err == nil {
|
||||
t.Fatal("ssh's port was given")
|
||||
}
|
||||
broker := anAdoptedAnchor().Modules[2]
|
||||
if _, err := GivenPorts(broker, node(map[string]any{"5671": float64(5700),
|
||||
"5672": float64(5700)})); err == nil {
|
||||
t.Fatal("one machine port was given for two of the module's ports")
|
||||
}
|
||||
if stray := UnusedSettings(store, node(map[string]any{"5432": float64(5433)})); len(stray) != 0 {
|
||||
t.Fatalf("a given port is called stray: %v", stray)
|
||||
}
|
||||
}
|
||||
|
||||
// novox/hq ADR 0103: the guard is derived, and from taken modules only — every machine port a taken
|
||||
// module publishes that the filter admits from the private network only, and the ports its
|
||||
// manifest guards. A module assigned but not taken is not guarded: its port may still be the
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import "testing"
|
||||
|
||||
// A claim written before the rename still holds (novox/hq ADR 0122, ADR 0156): with the store's
|
||||
// aliases loaded, the former name resolves to the seat.
|
||||
func TestTheArtifactStoresFormerNameResolvesToIt(t *testing.T) {
|
||||
was := aliases
|
||||
t.Cleanup(func() { aliases = was })
|
||||
UseAliases(map[string]string{"the-artifact-store": "mesh-artifact-store"})
|
||||
seat, known := SeatNamed("the-artifact-store")
|
||||
if !known || seat.Name != "mesh-artifact-store" || seat.Delivers != "artifact-store" {
|
||||
t.Fatalf("the former name did not resolve: %+v %v", seat, known)
|
||||
}
|
||||
if _, known := SeatNamed("mesh-artifact-store"); !known {
|
||||
t.Fatal("the seat is not in the set under its name")
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,5 @@
|
||||
package catalogue
|
||||
|
||||
// **The artifact sweep, out of the controller** (novox/hq ADR 0571 clause 2, issue 1955): what the artifact store keeps
|
||||
// and lets go of, the seat `mesh-artifact-sweep` (named for its scope, ADR 0121 §1; the operator, 2026-10-29), held
|
||||
// once for the mesh by artifact-store-tools, serves what the controller served of its store (artifacts, held, mirrors, images, collect) and says `artifacts-gone`. Its keep set is the store's own listing joined
|
||||
// with every want's fingerprint and each module's five newest builds from the builder's log (ADR 0526 §1); it lets go
|
||||
// of nothing until the controller's lease names a holder that is no controller.
|
||||
|
||||
// ArtifactSweepSeat is the seat artifact-store-tools holds. Not the seat mesh-artifact-store, the store itself held by
|
||||
// distribution, and not the provision artifact-store it delivers.
|
||||
const ArtifactSweepSeat = "mesh-artifact-sweep"
|
||||
|
||||
@@ -5,26 +5,6 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// What the mesh built, named by what it is rather than by where it was pushed.
|
||||
//
|
||||
// **An image is recorded by its digest and its path; the registry's address is a route to it**
|
||||
// (novox/hq 04-ISSUES/102). A build used to be recorded as `<registry>:<port>/<module>/<artifact>@sha256:…`
|
||||
// — the reference the builder pushed to, kept whole — and every declaration carried that literal.
|
||||
// Move the registry's port, or the registry, and every fresh pull of a mesh image fails: a new
|
||||
// node, a recreate after eviction. The digest is the identity; the address is the node's setting
|
||||
// for the module that serves the artifact store, and it is read from there when a reference is
|
||||
// composed, never written into a record.
|
||||
//
|
||||
// So a kept reference has a scheme of the mesh's own, named after the provision that answers it:
|
||||
//
|
||||
// artifact-store://<module>/<artifact>@sha256:<hex> an image
|
||||
// artifact-store://<module>/<artifact>/blobs/sha256:<hex> an archive
|
||||
//
|
||||
// No runtime knows the scheme. That is the point of it being one: a reference that leaks to a
|
||||
// machine uncomposed is refused by the runtime as malformed, in front of whoever sent it, rather
|
||||
// than pulled from a public registry that happens to have a repository by that name — which is
|
||||
// what an address-less `<module>/<artifact>@sha256:…` would be.
|
||||
|
||||
// ArtifactStoreScheme marks a reference to something in the mesh's artifact store, kept without
|
||||
// the store's address.
|
||||
const ArtifactStoreScheme = ArtifactStoreProvision + "://"
|
||||
@@ -78,15 +58,6 @@ func Routed(reference, address string) string {
|
||||
return address + "/" + path
|
||||
}
|
||||
|
||||
// Rerouted is a reference the mesh recorded, whichever way it was recorded, as a machine fetches
|
||||
// it now: a kept one composed with the store's address, and one recorded before references were
|
||||
// kept without their address — the builder's own `<host>/<path>@sha256:…` — re-routed to where
|
||||
// the store is now. Only for references that are the mesh's own: everything a build record
|
||||
// holds is, by construction.
|
||||
func Rerouted(reference, address string) string {
|
||||
return Routed(Recorded(reference), address)
|
||||
}
|
||||
|
||||
// artifactsInto composes the artifact store's address into a resource's `image` and `source`.
|
||||
//
|
||||
// **Composed here, at the last moment before a machine, and stored nowhere.** A kept reference is
|
||||
|
||||
@@ -28,22 +28,6 @@ func TestAReferenceIsRecordedWithoutTheStoresAddress(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestARecordedReferenceIsRoutedThroughTheStoreAsItIsNow(t *testing.T) {
|
||||
if got := Routed(ArtifactStoreScheme+"gitea/server@"+digest, "anchor.internal:5101"); got != "anchor.internal:5101/gitea/server@"+digest {
|
||||
t.Errorf("an image is fetched as %q", got)
|
||||
}
|
||||
if got := Routed(ArtifactStoreScheme+"gitea/config/blobs/"+digest, "anchor.internal:5101"); got != "http://anchor.internal:5101/v2/gitea/config/blobs/"+digest {
|
||||
t.Errorf("an archive is fetched as %q", got)
|
||||
}
|
||||
if got := Routed("gitea/gitea@"+digest, "anchor.internal:5101"); got != "gitea/gitea@"+digest {
|
||||
t.Errorf("a reference that is not the store's was routed: %q", got)
|
||||
}
|
||||
// One recorded before references were kept without their address follows the store too.
|
||||
if got := Rerouted("anchor.internal:5100/gitea/server@"+digest, "anchor.internal:5101"); got != "anchor.internal:5101/gitea/server@"+digest {
|
||||
t.Errorf("a reference recorded with the old address stays there: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// **The address is composed into a declaration, and the record never carries it.**
|
||||
func TestAnImageTheMeshBuiltIsRoutedThroughTheStoreWhenDeclared(t *testing.T) {
|
||||
m := Manifest{Module: "gitea", Version: "1", Resources: []map[string]any{
|
||||
|
||||
@@ -5,12 +5,6 @@ import (
|
||||
"regexp"
|
||||
)
|
||||
|
||||
// What the machine's modules back up is placement (design 57 L3, issue 794). The holder of node-backup names it as
|
||||
// `${backup:lines}` (what to run and which paths to keep) and `${backup:data}` (every item with its class and
|
||||
// protection): the text `${contribution:node-backup:backup}` and `${contribution:node-backup:data}` render, from the
|
||||
// same function, derived from each placed module's declared data (novox/hq ADR 0233). A handed holder has the text in
|
||||
// its plan's placement for its client to fill; one not handed has the controller fill it where it stands.
|
||||
|
||||
// BackupNamespace is the placeholder kind of the machine's backup lines and data.
|
||||
const BackupNamespace = "backup"
|
||||
|
||||
|
||||
@@ -6,21 +6,6 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// A consumer of a provision that keeps its data stays bound where its data is (novox/hq ADR 0232).
|
||||
//
|
||||
// **What a resolution chooses is not where a consumer's data is.** Resolving answers "which provider
|
||||
// would I pick now", from the seats' holders, the pins and what is assigned where, and every one of
|
||||
// those can change under a consumer without anybody meaning to move it. For a resolver that is the
|
||||
// point: any provider answers alike. For a database it is the consumer's whole state: on 2026-10-05
|
||||
// one change to how a seat's holder answers (issue 258) re-bound five database consumers on one
|
||||
// machine to the store on another, each was given a fresh, empty database there, and nothing warned
|
||||
// for twenty hours (issue 273). Nothing was lost only because the old provider kept everything.
|
||||
//
|
||||
// So the mesh records where each such consumer was last sent (the store's `binding` table), and a
|
||||
// resolution that would answer it from anywhere else keeps the recorded provider instead and says so.
|
||||
// **Only a pin moves it**, because a pin is a person: the one act that says "answer this machine's
|
||||
// consumers from there", taken knowing the data must go first.
|
||||
|
||||
// KeptBinding is one consumer this resolution would have moved, and did not.
|
||||
type KeptBinding struct {
|
||||
// Machine is where the consumer runs, and Consumer the module there that is bound.
|
||||
|
||||
@@ -7,24 +7,6 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// The half of a connection that is not secret, put where the program reading it can find it.
|
||||
//
|
||||
// **The asymmetry this removes was backwards** (novox/hq 04-ISSUES/023). A sealed credential can
|
||||
// be placed inside any configuration file a module writes: the module leaves a hole, the mesh
|
||||
// delivers the value sealed beside it, and the host — the only thing that sees both — fills it in.
|
||||
// The host and the port and the name to present are ordinary facts the mesh holds in the clear,
|
||||
// and they were the ones stuck: readable only inside a JSON binding, which a program reading
|
||||
// `KEY=value` cannot use.
|
||||
//
|
||||
// So the same shape, and simpler. These values are not secret, so **the control plane substitutes
|
||||
// them itself** before the declaration is sent. Nothing new reaches the host, which learns no
|
||||
// formats and gains no fields.
|
||||
//
|
||||
// **It stays name-agnostic** ([ADR 0027]). The mesh does not learn what a `postgres-database` is:
|
||||
// `at`, `as`, `from` and `address` (the providing machine's private address, novox/hq ADR 0194) are
|
||||
// facts about any provision at all, and everything else comes from what the provider said it
|
||||
// serves — whose keys are agreed by the requirement's name, not by this file.
|
||||
|
||||
// bound is where a module says a value from one of its bindings belongs:
|
||||
// ${bound:<provision>:<key>}, and for an optional requirement the value it takes while absent,
|
||||
// ${bound:<provision>:<key>|default:<value>} (novox/hq ADR 0330 §4). A default is never empty: an
|
||||
|
||||
@@ -7,17 +7,6 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Turning a manifest that names artifacts into one that names digests.
|
||||
//
|
||||
// **Two documents, deliberately.** The manifest in a repository says *this resource uses the
|
||||
// archive called `config`*; the manifest the mesh holds says *this resource is sha256:…*. A digest
|
||||
// is not knowable until something is built, so a repository carrying one would be a repository
|
||||
// whose file is wrong the moment anybody edits anything — and the mesh would be pinning a value
|
||||
// nobody could have checked.
|
||||
//
|
||||
// So the built manifest is **derived**, and the record of which commit it was derived from is what
|
||||
// makes "is this current?" answerable without building (novox/hq ADR 0009).
|
||||
|
||||
// Built is one artifact after it exists: where it is and what it hashes to.
|
||||
type Built struct {
|
||||
// Name is what the manifest called it.
|
||||
|
||||
@@ -7,18 +7,6 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// A long-running container may say it is busy (novox/hq ADR 0335 §1).
|
||||
//
|
||||
// **Beside its health, on the container**: a command run inside it, by its shell, as an exec health check
|
||||
// is, that exits 0 while the container holds work a replacement would lose and non-zero when it is idle;
|
||||
// and how long one ask may take. Being asked is also its notice that it is about to be replaced: from the
|
||||
// first ask it takes no new work. The node-engine asks before it replaces the container, leaves it running
|
||||
// while it says busy, and replaces it on the first pass it says idle, or at 15 minutes — the engine's bound,
|
||||
// never the module's.
|
||||
//
|
||||
// Sent only to a node-engine that reads it (link.BusyContract): an older, strict engine refuses a field it
|
||||
// does not know, whole, and to it the container is replaced at once, as before.
|
||||
|
||||
// BusyField is the resource field.
|
||||
const BusyField = "busy"
|
||||
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
package catalogue
|
||||
|
||||
// The delivery's checker (novox/hq ADR 0547 §10, issue 774): one holder for the mesh, the module
|
||||
// mesh-delivery-checker, which judges a pull request's head and a pushed head the same way. The try the
|
||||
// controller forwarded to it is a verb of this seat, answered by the checker with the verdict it gives a pull
|
||||
// request, so the try outlives the controller.
|
||||
|
||||
// CheckerSeat is the seat mesh-delivery-checker holds.
|
||||
const CheckerSeat = "mesh-delivery-checker"
|
||||
|
||||
|
||||
@@ -10,28 +10,6 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Collections (novox/hq ADR 0349 clause 13, design 57 L1, issue 684): a provision with many providers.
|
||||
//
|
||||
// **A collector requires a collection; a contributor provides an item from a template.** A module taking additions
|
||||
// from others names a collection in a file of its own, `${collected:<collection>}`, and declares it under
|
||||
// `collections`, with the comment its tool writes a line in. Each contributor ships its item as a template of its
|
||||
// own repository and declares it under `items`: `{"collection": …, "template": "files/<name>"}`. Its build packs the
|
||||
// template with the module's others, and the mesh holds its text as it holds a file's (template.go).
|
||||
//
|
||||
// **The node's provisioner client fills both.** An item is filled on the node by the client of the node its
|
||||
// contributor is placed on, from the same owners as a file, and recorded in mesh-provisioner_collect; a collector's
|
||||
// file is filled by the same client from every item of its collection recorded for that node, so an item goes when
|
||||
// its contributor leaves. So a module contributing an item is a moved module (handed.go), and the controller renders
|
||||
// no item: the plan of a contributor names its items and their templates, and the plan of a collector names each
|
||||
// collection it reads and its comment. Neither carries a contributor's text.
|
||||
//
|
||||
// **The three old namespaces read as collections.** `${shell:<shell>:<slot>}`, `${environment:<format>}` and
|
||||
// `${contribution:<seat>:<kind>}` name, in a handed collector's file, the collection of the same name —
|
||||
// `shell:zsh:normal` — with the comment the controller wrote before each module's piece. An item is contributed to
|
||||
// one by that name, by a module the old form would have let contribute it. Each file is filled one way, never
|
||||
// both (ADR 0349 clause 17): a handed collector is filled from items alone and an unhanded one from the old forms
|
||||
// alone, and a piece of the other form on the same machine is said as not placed, never dropped quietly.
|
||||
|
||||
// Item is one item a module contributes to a collection: its template in the module's repository, or, in the
|
||||
// manifest the mesh holds, the template's text, read back from the build's templates archive like a file's.
|
||||
//
|
||||
@@ -222,21 +200,6 @@ func oldCollection(name string, forItem bool) (comment string, old bool, wrong e
|
||||
return r.Comment, true, nil
|
||||
}
|
||||
|
||||
// placerOfOld is the seat whose holder alone may collect an old namespace's collection (ADR 0203, 0204, 0208,
|
||||
// 0211, 0212): the same seat that places it the old way.
|
||||
func placerOfOld(name string) string {
|
||||
namespace, key, _ := strings.Cut(name, ":")
|
||||
switch namespace {
|
||||
case "shell":
|
||||
target, _, _ := strings.Cut(key, ":")
|
||||
return placerOf(target)
|
||||
case "environment":
|
||||
return EnvironmentSeat
|
||||
}
|
||||
seat, _, _ := strings.Cut(key, ":")
|
||||
return canonicalSeat(seat)
|
||||
}
|
||||
|
||||
// itemTemplateProblem is what is wrong with an item's template path, or "".
|
||||
func itemTemplateProblem(m Manifest, name string) string {
|
||||
dir := m.TemplatesDir()
|
||||
@@ -299,13 +262,6 @@ func collectionComment(m Manifest, name string) (string, error) {
|
||||
return "", fmt.Errorf("%s names ${collected:%s} and does not collect it", m.Module, name)
|
||||
}
|
||||
|
||||
// itemsLeftOut says each item of a collection a file is filled with the old way that the controller could not render
|
||||
// there (heldItemText): the collector is not handed to the provisioner on this machine (ADR 0349 clause 17). Said,
|
||||
// never dropped quietly.
|
||||
func itemsLeftOut(modules []Manifest, holder Manifest, collection string, facts map[string]string) []string {
|
||||
return itemsLeftOutFor(modules, holder, collection, nil, facts, nil)
|
||||
}
|
||||
|
||||
// itemsLeftOutFor is itemsLeftOut for a placeholder that selects by attributes (where), on a machine with these
|
||||
// capabilities: an item the selector does not take, or whose capability the machine lacks, is not left out but not
|
||||
// wanted here.
|
||||
@@ -358,22 +314,6 @@ func oldFormsLeftOut(modules []Manifest, holder Manifest, collections []PlanColl
|
||||
return out
|
||||
}
|
||||
|
||||
// heldItemText is a moved contributor's items of an old namespace's collection as the controller renders them into a
|
||||
// collector not handed on this machine (design 57 L2, issue 681): the text the mesh holds, read back from the
|
||||
// contributor's templates archive, with the machine's facts filled, each ending its line. As a moved module's file
|
||||
// keeps its text for a machine that cannot take it handed (handed.go), an item keeps its place in a collector filled
|
||||
// the old way, so the file stays what its old form made it. Only `shell:` and `contribution:` collections, which the
|
||||
// controller renders as text; false for a module with no such item, or an item whose text the mesh does not hold or
|
||||
// which names anything but a machine fact this machine has, which is then said as not placed.
|
||||
func heldItemText(m Manifest, collection string, facts map[string]string) (string, bool) {
|
||||
items := heldItems(m, collection, nil, facts, nil)
|
||||
var b strings.Builder
|
||||
for _, h := range items {
|
||||
b.WriteString(h.text)
|
||||
}
|
||||
return b.String(), len(items) > 0
|
||||
}
|
||||
|
||||
// renderedOldWay is whether the controller renders a collection's items into a collector not handed.
|
||||
func renderedOldWay(collection string) bool {
|
||||
namespace, _, _ := strings.Cut(collection, ":")
|
||||
|
||||
@@ -94,50 +94,3 @@ func TestACollectorFilledTheOldWayHoldsTheOldFormsAndEachHeldItem(t *testing.T)
|
||||
t.Fatalf("unplaced: %v", c.Unplaced)
|
||||
}
|
||||
}
|
||||
|
||||
// An item the controller cannot render the old way (it names a value but a machine fact) is said as not placed.
|
||||
func TestAHeldItemNamingMoreThanAFactIsSaidNotPlacedTheOldWay(t *testing.T) {
|
||||
zsh := held(t, collectorZsh, builtWith("zsh", map[string]string{"zshrc": zshrcText}))
|
||||
if _, ok := heldItemText(Manifest{Module: "p", Items: []Item{{Collection: "shell:zsh:normal",
|
||||
Content: "export THEME=${config:theme}\n"}}}, "shell:zsh:normal", map[string]string{}); ok {
|
||||
t.Fatal("an item naming a config value was rendered by the controller")
|
||||
}
|
||||
if _, ok := heldItemText(Manifest{Module: "p", Items: []Item{{Collection: "shell:zsh:normal",
|
||||
Content: "cd ${machine:account-home}\n"}}}, "shell:zsh:normal", map[string]string{}); ok {
|
||||
t.Fatal("an item naming a fact this machine lacks was rendered")
|
||||
}
|
||||
if _, ok := heldItemText(Manifest{Module: "p", Items: []Item{{Collection: "widgets", Content: "x\n"}}},
|
||||
"widgets", nil); ok {
|
||||
t.Fatal("an item of a collection of the new form was rendered the old way")
|
||||
}
|
||||
left := itemsLeftOut([]Manifest{zsh, {Module: "p", Items: []Item{{Collection: "shell:zsh:normal",
|
||||
Content: "export THEME=${config:theme}\n"}}}}, zsh, "shell:zsh:normal", map[string]string{})
|
||||
if len(left) != 1 || !strings.Contains(left[0], "p's item of shell:zsh:normal is not placed") {
|
||||
t.Fatalf("left out: %v", left)
|
||||
}
|
||||
}
|
||||
|
||||
// Each old namespace L2 moves renders a moved contributor's item exactly as its old form did, where the collector is
|
||||
// not handed: the same contributor written both ways composes the same bytes (design 57 L2, issue 681).
|
||||
func TestAMovedContributorsItemRendersAsItsOldFormDid(t *testing.T) {
|
||||
code := "# two lines\nalias a=b"
|
||||
asOld := Manifest{Module: "laptop", Shell: []ShellCode{{For: "zsh", Slot: "normal", Code: code}},
|
||||
Contributions: []SeatContribution{{Seat: HotkeysSeat, Kind: "trigger", Content: "KEY_F21 1 touchpad\n"}}}
|
||||
asItem := Manifest{Module: "laptop", Moved: true, Items: []Item{{Collection: "shell:zsh:normal", Content: code},
|
||||
{Collection: "contribution:node-hotkeys:trigger", Content: "KEY_F21 1 touchpad\n"}}}
|
||||
other := Manifest{Module: "zz-other", Shell: []ShellCode{{For: "zsh", Slot: "normal", Code: "x=1\n"}},
|
||||
Contributions: []SeatContribution{{Seat: HotkeysSeat, Kind: "trigger", Content: "KEY_MUTE 1 mute\n"}}}
|
||||
first := Manifest{Module: "aa-first", Shell: []ShellCode{{For: "zsh", Slot: "normal", Code: "y=2\n"}}}
|
||||
for _, mods := range [][]Manifest{{other, first}, {}} {
|
||||
was := append(append([]Manifest(nil), mods...), asOld)
|
||||
is := append(append([]Manifest(nil), mods...), asItem)
|
||||
if a, b := shellCodeOn(was, "zsh", "normal", nil), shellCodeOn(is, "zsh", "normal", nil); a != b || a == "" {
|
||||
t.Errorf("shell: the old form made %q, the item %q", a, b)
|
||||
}
|
||||
a, _, errA := seatContributions(was, hotkeysHolder(), "node-hotkeys:trigger", Rendering{}, nil, nil)
|
||||
b, left, errB := seatContributions(is, hotkeysHolder(), "node-hotkeys:trigger", Rendering{}, nil, nil)
|
||||
if errA != nil || errB != nil || a != b || a == "" || len(left) != 0 {
|
||||
t.Errorf("hotkeys: the old form made %q, the item %q (%v %v %v)", a, b, errA, errB, left)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,28 +7,6 @@ import (
|
||||
"slices"
|
||||
)
|
||||
|
||||
// A module declares its config (novox/hq ADR 0348 §1, §4, §5, §6, §8, §11; design 57 package K1, issue 660).
|
||||
//
|
||||
// **One concept, two spellings for a while.** "Config" replaces "settings": the manifest says `config` for
|
||||
// `settings`, `takes-config` for `takes-settings` and `${config:<key>}` for `${setting:<key>}`. The old names stay
|
||||
// accepted as aliases until every module has moved (ADR 0348 §1), and a module uses one form or the other, never
|
||||
// both. The config form is read into the very fields the settings form fills — Settings, a mergeable file's
|
||||
// takes-settings and `${setting:<key>}` — so composition has one path and `${config:<key>}` fills exactly as
|
||||
// `${setting:<key>}` does: a module rewritten from settings to config composes byte-identically. It is written
|
||||
// back in the form it was read in, so what the mesh holds is what its author wrote.
|
||||
//
|
||||
// **A moved module is held to the whole declaration.** Under `config` every value has a type (number, string or
|
||||
// boolean; an object or a list only when trusted), a description including its unit, its validation (enum,
|
||||
// minimum and maximum, pattern), whether it is trusted, optionally the predefined key it falls back to, and either
|
||||
// a default or `required`; it lands in a file or is read at runtime (`runtime: true`); every mergeable file names
|
||||
// the keys it takes; a `reload-on` or `restart-on` names a file the module declares. A module not yet moved keeps
|
||||
// today's form and today's rules (SettingProblems, TakenSettingsProblems) until the catalogue's list of such
|
||||
// modules is empty (ADR 0348 §11).
|
||||
//
|
||||
// **A required value nothing sets** is the operator's own: it composes as an operator's setting nobody gave does
|
||||
// today, refused by name at composition (UnsetSettingError). Who reports it once mesh-config answers values is the
|
||||
// provisioner's (ADR 0349), not this file's.
|
||||
|
||||
// ConfigField is the manifest key a module declares its config values under.
|
||||
const ConfigField = "config"
|
||||
|
||||
@@ -150,22 +128,6 @@ func (d SettingDeclaration) MarshalJSON() ([]byte, error) {
|
||||
Default: d.Default, Required: d.Required, Runtime: d.Runtime})
|
||||
}
|
||||
|
||||
// renamedTaken is every value a mergeable file takes under a name of its own, with that name (issue 751).
|
||||
func renamedTaken(m Manifest) map[string]string {
|
||||
out := map[string]string{}
|
||||
for _, r := range m.Resources {
|
||||
if how, _ := r["merge"].(string); how == "" {
|
||||
continue
|
||||
}
|
||||
for _, e := range takenEntries(r[TakesSettingsField]) {
|
||||
if e.renamed {
|
||||
out[e.key] = e.as
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// validationProblems is what is wrong with a declaration's validation, and its default against it.
|
||||
func validationProblems(d SettingDeclaration) []string {
|
||||
var problems []string
|
||||
|
||||
@@ -2,29 +2,6 @@ package catalogue
|
||||
|
||||
import "strings"
|
||||
|
||||
// What the move of config reads of a module's declaration (novox/hq ADR 0348 §9, design 57 K5, issue 664): where a
|
||||
// layer's key goes, how two layers' values become one, and whether a value is one its declaration takes.
|
||||
|
||||
// TakenAs is each name a mergeable file takes a config value under that is not the value's config key, with that
|
||||
// key (issue 751): a layer set before the module moved may still say the file's name, and the move sets it under
|
||||
// the config key.
|
||||
func TakenAs(m Manifest) map[string]string {
|
||||
out := map[string]string{}
|
||||
for key, as := range renamedTaken(m) {
|
||||
out[as] = key
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// AskedKeys is every value a placeholder of the module asks for: a file, a unit, a directory's owner, a
|
||||
// contribution or a served fact. Such a value is taken whole from the last layer that sets it, where a mergeable
|
||||
// file lays one object over another key by key.
|
||||
func AskedKeys(m Manifest) map[string]bool { return settingKeysUsedBy(m) }
|
||||
|
||||
// LaidOver is a value laid over another as a later layer is laid over an earlier: two objects key by key, anything
|
||||
// else replaced whole.
|
||||
func LaidOver(under, over any) any { return mergeValue(under, over) }
|
||||
|
||||
// ValueProblems is what is wrong with a value for a declaration: its type, and its enum, bounds or pattern.
|
||||
func ValueProblems(d SettingDeclaration, v any) []string {
|
||||
if v == nil {
|
||||
|
||||
@@ -83,45 +83,6 @@ func TestAnUnsetRenamedValueLeavesTheFileAsItWas(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A layer said before its module moved names the file's own spelling, such as nodered's timeZone on one machine.
|
||||
// Until K5 moves the layer and renames the key, that spelling still reaches the file, so the move changes no
|
||||
// machine's file; the config key wins where one layer says both.
|
||||
func TestALayerStillSayingTheFilesNameReachesItUntilItsMove(t *testing.T) {
|
||||
m, err := parseRaw(t, renaming())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, said := range []string{"url", "NOTIFIER_URL"} {
|
||||
if stray := UnusedSettings(m, []Layer{{From: "ace", Values: map[string]any{said: "https://x.test"}}}); len(stray) > 0 {
|
||||
t.Fatalf("%s, which the rules file takes, is said stray: %v", said, stray)
|
||||
}
|
||||
got := rulesComposed(t, m, Layer{From: "ace", Values: map[string]any{said: "https://x.test"}})
|
||||
var doc map[string]any
|
||||
if err := json.Unmarshal([]byte(got), &doc); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if doc["NOTIFIER_URL"] != "https://x.test" || doc["url"] != nil {
|
||||
t.Fatalf("a layer saying %s made the file %s", said, got)
|
||||
}
|
||||
}
|
||||
both := rulesComposed(t, m, Layer{From: "ace", Values: map[string]any{"url": "https://key.test", "NOTIFIER_URL": "https://old.test"}})
|
||||
if !strings.Contains(both, "https://key.test") || strings.Contains(both, "old.test") {
|
||||
t.Fatalf("where one layer says both, the config key did not win: %s", both)
|
||||
}
|
||||
later := rulesComposed(t, m, Layer{From: "mesh", Values: map[string]any{"url": "https://mesh.test"}},
|
||||
Layer{From: "ace", Values: map[string]any{"NOTIFIER_URL": "https://ace.test"}})
|
||||
if !strings.Contains(later, "https://ace.test") {
|
||||
t.Fatalf("a later layer's old spelling did not beat an earlier layer's key: %s", later)
|
||||
}
|
||||
// Who may set it is the same under either spelling: the rules file is trusted, so both are the terminal's.
|
||||
terminal := TerminalKeys(m)
|
||||
for _, k := range []string{"url", "NOTIFIER_URL"} {
|
||||
if !listHolds(terminal, k) {
|
||||
t.Fatalf("%s is not the terminal's in a trusted file: %v", k, terminal)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func listHolds(list []string, s string) bool {
|
||||
for _, x := range list {
|
||||
if x == s {
|
||||
|
||||
@@ -7,21 +7,6 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// What a provider derives for one consumer, said once in the provider's definition and delivered
|
||||
// to both ends (novox/hq ADR 0202, issue 124).
|
||||
//
|
||||
// A `serves` block is otherwise literal: the same values for every consumer. Where the provider
|
||||
// *names the resource* — a bucket, a database, a vhost — the name is derived from who is asking,
|
||||
// and before this the mesh had no channel for it. The provider recomputed it in its own code and
|
||||
// every consumer transcribed it into its own definition by hand, which is a copy of somebody
|
||||
// else's rule kept in agreement by nobody. One of three transcriptions was wrong for months.
|
||||
//
|
||||
// **The mesh learns no protocol here; it spells its own name in an alphabet it already knows.**
|
||||
// The only fact a served value may name is the identity the mesh itself minted for the consumer,
|
||||
// in one of two alphabets: as it was minted, and as a DNS label. Everything a provider wants
|
||||
// around it — a prefix, a suffix, a separator — it writes around the placeholder, because a
|
||||
// served value is a string.
|
||||
|
||||
// consumerFact is `${consumer:<fact>}` or `${consumer:<fact>:<alphabet>}`.
|
||||
var consumerFact = regexp.MustCompile(`\$\{consumer:([a-z][a-z0-9-]*)(?::([a-z][a-z0-9-]*))?\}`)
|
||||
|
||||
@@ -124,15 +109,6 @@ func asDNSLabel(as string) string {
|
||||
return strings.ReplaceAll(as, "_", "-")
|
||||
}
|
||||
|
||||
func sortedServes(serves map[string]map[string]any) []string {
|
||||
out := make([]string, 0, len(serves))
|
||||
for k := range serves {
|
||||
out = append(out, k)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func sortedAnyKeys(values map[string]any) []string {
|
||||
out := make([]string, 0, len(values))
|
||||
for k := range values {
|
||||
|
||||
@@ -6,15 +6,6 @@ import (
|
||||
"slices"
|
||||
)
|
||||
|
||||
// A provider's consumers' credentials, in a file of the provider's own (novox/hq ADR 0373, issue 679).
|
||||
//
|
||||
// A provider in the tool runner may not ask the vault (ADR 0349 clause 11, ADR 0370 clause 2), so its template names
|
||||
// `${consumers:<provision>}` and its machine's provisioner client fills it: one entry per consumer bound to the
|
||||
// provider for that provision, each carrying the credential the vault keeps for the pair. Only the client fills it,
|
||||
// so the placeholder is a provider's alone, for a provision it provides, in a file only the provider reads (mode 0600
|
||||
// or 0400, owned at runtime, so a change on disk is written back); and a module not handed to the client on a machine
|
||||
// is refused there, never written the placeholder's text, nor an empty list the provider would read as no consumers.
|
||||
|
||||
// ConsumersNamespace is a provider's consumers' credentials' placeholder namespace, `${consumers:<provision>}`.
|
||||
const ConsumersNamespace = "consumers"
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ package catalogue
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -44,27 +43,6 @@ func TestAContributionDependsOnTheSeatThatPlacesIt(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAContributionIsMetByAHolderOnTheNodeAndRefusedWithout(t *testing.T) {
|
||||
holder := mod("node-env", nil, nil, nil, Claim{Name: EnvironmentSeat})
|
||||
contributor := mod("theme", nil, nil, nil)
|
||||
contributor.Environment = &Environment{Variables: map[string]string{"GTK_THEME": "Adwaita:dark"}}
|
||||
catalogue := map[string]Manifest{"node-env": holder, "theme": contributor}
|
||||
|
||||
if _, err := AssignRefusal(catalogue, "laptop", []string{"node-env"}, []string{"theme"}); err != nil {
|
||||
t.Fatalf("a contributor beside the holder is refused: %v", err)
|
||||
}
|
||||
_, err := AssignRefusal(catalogue, "laptop", nil, []string{"theme"})
|
||||
if err == nil {
|
||||
t.Fatal("a contributor on a node without the holder was accepted, and its contribution would be written nowhere")
|
||||
}
|
||||
if !strings.Contains(err.Error(), EnvironmentSeat) || !strings.Contains(err.Error(), "node-env") {
|
||||
t.Errorf("the refusal names neither the seat nor its holder: %v", err)
|
||||
}
|
||||
if _, err := AssignRefusal(catalogue, "laptop", nil, []string{"theme", "node-env"}); err != nil {
|
||||
t.Errorf("the contributor and the holder assigned together are refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAHolderMeetsItsOwnContribution(t *testing.T) {
|
||||
// The display server's module contributes nothing to its own seat today, but the zsh module's
|
||||
// environment contributions do go to another seat: a claim meets only the seat it names.
|
||||
@@ -78,38 +56,3 @@ func TestAHolderMeetsItsOwnContribution(t *testing.T) {
|
||||
t.Errorf("zsh alone: unheld %v, want only %s (its shell code is its own seat's)", unheld, EnvironmentSeat)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTwoModulesMayDeclareOnePackage(t *testing.T) {
|
||||
pacman := withResources(mod("pacman", nil, nil, nil, Claim{Name: PackageManagerSeat}),
|
||||
res("package", "pacman-contrib"))
|
||||
bar := withResources(mod("bar", nil, nil, nil), res("package", "bar"), res("package", "pacman-contrib"))
|
||||
catalogue := map[string]Manifest{"pacman": pacman, "bar": bar}
|
||||
if err := CollisionRefusal(catalogue, "laptop", []string{"pacman"}, []string{"bar"}); err != nil {
|
||||
t.Fatalf("a second declaration of a package was refused (novox/hq ADR 0303): %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPackageDeclaredPresentAndAbsentIsRefusedNamingBoth(t *testing.T) {
|
||||
gone := res("package", "ufw")
|
||||
gone["absent"] = true
|
||||
filter := withResources(mod("nftables", nil, nil, nil), gone)
|
||||
wants := withResources(mod("legacy", nil, nil, nil), res("package", "ufw"))
|
||||
other := withResources(mod("other", nil, nil, nil), res("package", "other"))
|
||||
catalogue := map[string]Manifest{"nftables": filter, "legacy": wants, "other": other}
|
||||
|
||||
err := CollisionRefusal(catalogue, "laptop", []string{"nftables"}, []string{"legacy"})
|
||||
if err == nil || !strings.Contains(err.Error(), "ufw") || !strings.Contains(err.Error(), "nftables") ||
|
||||
!strings.Contains(err.Error(), "legacy") || !strings.Contains(err.Error(), "ADR 0303") {
|
||||
t.Fatalf("a package declared present and absent was not refused naming both: %v", err)
|
||||
}
|
||||
if strings.Contains(err.Error(), "one owner per node") {
|
||||
t.Errorf("the refusal gives the one-owner rule, which no longer holds for packages: %v", err)
|
||||
}
|
||||
if err := CollisionRefusal(catalogue, "laptop", []string{"nftables"}, []string{"other"}); err != nil {
|
||||
t.Errorf("a module declaring nothing contradictory is refused: %v", err)
|
||||
}
|
||||
// A contradiction already on the node is status's, not a reason to refuse an unrelated assignment.
|
||||
if err := CollisionRefusal(catalogue, "laptop", []string{"nftables", "legacy"}, []string{"other"}); err != nil {
|
||||
t.Errorf("an unrelated assignment is refused for a contradiction already there: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,5 @@
|
||||
package catalogue
|
||||
|
||||
// **The control records, out of the controller** (novox/hq ADR 0565 clause 1, issue 1953): two seats at mesh scope,
|
||||
// each held once, and the node seat's verb `packages`.
|
||||
//
|
||||
// - mesh-wants, held by the assigner (mesh-delivery-assigner): a person's upgrade policy and the bus step (clauses 7,
|
||||
// 8, 9);
|
||||
// - mesh-hand-acts, held by the module mesh-hand-acts: the hand-act log and the warrants recorded (clause 10).
|
||||
//
|
||||
// Their events are their holders' to say on the seat; their layouts are go/handacts' and go/delivery's.
|
||||
|
||||
// The two seats.
|
||||
const (
|
||||
WantsSeat = "mesh-wants"
|
||||
|
||||
@@ -7,12 +7,6 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// **Nothing may name the controller** (novox/hq ADR 0590 clause 5, issue 1126): the controller ended by its unassignment
|
||||
// on 2026-11-02 and its code left the mesh, so a manifest that reads one of its states (its lease too), invokes one of its
|
||||
// verbs or tools, consumes or waits on one of its events, uses, claims or requires it, or is it, depends on nothing that
|
||||
// answers. ManifestRefusals runs the check over a repository's manifests, in the test each repository of manifests keeps
|
||||
// (mesh-catalog, mesh-delivery, mesh-tools); a later decision that places a controller again must first lift it.
|
||||
|
||||
// EndedControllerName is the name the ended controller had, as its module and as its seat: no manifest may name it.
|
||||
const EndedControllerName = "mesh-controller"
|
||||
|
||||
|
||||
@@ -5,11 +5,6 @@ import (
|
||||
"slices"
|
||||
)
|
||||
|
||||
// The core's own credentials (novox/hq ADR 0349 §16, issue 755). The bus, the node-engine, the controller and
|
||||
// mesh-provisioner need their files before any provisioner client runs, so their files stay placement and their
|
||||
// credentials travel in the declaration as values the vault sealed to the machine they run on, which the controller
|
||||
// carries and cannot open; the node-engine opens them there (mesh-host's VaultBoxContract).
|
||||
|
||||
// CoreCredentialModules are the modules whose own secrets are the core's credentials: the bus, mesh-provisioner and
|
||||
// its client. The node-engine and the controller hold no own secret of a module's. **Fixed here, in the controller's
|
||||
// code, as the clause says** — and the vault keeps the same list (mesh-vault's CoreModules), answering the controller
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
// A five-field cron validator, enough to refuse a malformed schedule near its author
|
||||
// (novox/hq ADR 0053).
|
||||
//
|
||||
// **Validation only, and written rather than pulled in.** The control plane's part in a scheduled
|
||||
// step is small and exact: carry the field to the host unchanged, and refuse a `schedule` that is
|
||||
// not a well-formed cron here rather than on the machine — the same near-versus-far discipline the
|
||||
// manifest keeps for an action a module may not declare and a run-once that is not a boolean. What
|
||||
// *when to run it again* means is the host's to evaluate; the control plane only checks the string
|
||||
// is one it could. A cron library would be a dependency carried for evaluation nobody does here.
|
||||
//
|
||||
// The ordinary five fields — minute, hour, day-of-month, month, day-of-week — with `*`, lists
|
||||
// (`,`), ranges (`-`) and steps (`/`). No names (jan, mon): a numeric cron is what the host
|
||||
// evaluates, and refusing a name here is refusing it near whoever wrote it.
|
||||
@@ -10,24 +10,6 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// A module declares the data it holds, and the mesh protects and watches it from that declaration
|
||||
// (novox/hq ADR 0233).
|
||||
//
|
||||
// **One section, `data`, for every kind of data a module keeps:** its own, by directory — a store's
|
||||
// files, a mail spool, an application's uploads — or by an operator's path it was given; what it keeps
|
||||
// for its consumers, by the provision they reach it through; and what of its own lives with a
|
||||
// provider, by the provision it requires. Each entry has a class — how precious it is — and, for its
|
||||
// own data, how it is protected; everything the mesh does is derived from those, never written per
|
||||
// module:
|
||||
//
|
||||
// - a binding to a consumer's data does not move (ADR 0232) where the provision's class keeps data;
|
||||
// - the backup holder's lines are composed from it — a module no longer writes them by hand;
|
||||
// - what an unassignment leaves behind of an irreplaceable or valuable item is retired, listed by
|
||||
// `cleanup list` and deleted only by `cleanup delete` (ADR 0230);
|
||||
// - the self-check measures every item and says when one shrinks, disappears, stops being written,
|
||||
// goes without its backup, sits on a degraded array, or is replaced by an empty copy of itself —
|
||||
// urgent for what is irreplaceable, a warning for what is valuable.
|
||||
|
||||
// The classes: how precious the data is. A fixed vocabulary, ranked by the operator (2026-10-06): a
|
||||
// class is what the protections are derived from, so a new one is a decision, not a manifest's choice.
|
||||
const (
|
||||
@@ -225,10 +207,6 @@ func (it DataItem) MeasuredBy() string {
|
||||
return it.Measure
|
||||
}
|
||||
|
||||
// OwnedByModule is whether the item is in one of the module's own directories — the mesh's to retire —
|
||||
// rather than an operator's path it was given, which the mesh never retires and never deletes.
|
||||
func (it DataItem) OwnedByModule() bool { return strings.HasPrefix(it.Path, "${dir:") }
|
||||
|
||||
// BackupWithin is the item's bound on its last good backup.
|
||||
func (it DataItem) BackupWithin() time.Duration {
|
||||
if d, err := ParseDataDuration(it.Within); err == nil && d > 0 {
|
||||
@@ -290,15 +268,6 @@ func (m Manifest) ConsumerDataOf(provision string) (ConsumerData, bool) {
|
||||
return c, ok
|
||||
}
|
||||
|
||||
// KeptByOf is how precious what this module keeps with a provision's provider is, and whether it says.
|
||||
func (m Manifest) KeptByOf(provision string) (KeptData, bool) {
|
||||
if m.Data == nil {
|
||||
return KeptData{}, false
|
||||
}
|
||||
k, ok := m.Data.KeptBy[provision]
|
||||
return k, ok
|
||||
}
|
||||
|
||||
// keepsByClass is whether a class keeps something a binding must not move away from.
|
||||
func keepsByClass(class string) bool {
|
||||
return class == ClassIrreplaceable || class == ClassValuable || class == ClassRebuildable
|
||||
@@ -339,8 +308,6 @@ func (m Manifest) NeedsBackupHolder() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// --- derived: the backup holder's lines ---------------------------------------------------------
|
||||
|
||||
// The node-backup seat's kinds (novox/hq to-be 43, ADR 0233): `backup` is what to run and which paths
|
||||
// to keep, `data` every item with its class and protection, for the holder to measure and watch.
|
||||
const (
|
||||
@@ -417,8 +384,6 @@ func (m Manifest) allContributions() []SeatContribution {
|
||||
return append(out, derived...)
|
||||
}
|
||||
|
||||
// --- the catalogue check ------------------------------------------------------------------------
|
||||
|
||||
// volumeMount is a container's volume, `<source>:<target>[:<options>]`, its source possibly a
|
||||
// `${dir:<id>}` — whose own colon is not the separator.
|
||||
var volumeMount = regexp.MustCompile(`^(\$\{(?:dir|access):[a-z0-9][a-z0-9-]*\}[^:]*|[^:]+):([^:]+)(?::(.*))?$`)
|
||||
|
||||
@@ -6,12 +6,6 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// **The data items of a node's modules, placed, read from the composition** (novox/hq ADR 0571 clause 3, ADR 0233 §22 to
|
||||
// §25): disk-load measures each item every module on its node declares, and the node-backup holder, mounts and nfs-server
|
||||
// read the readings. Each item is its module's declaration with its path resolved to this node's place, its protection
|
||||
// and how it is measured: the same lines the node-backup holder is handed as `${backup:data}`, composed by the same
|
||||
// function, so a reader that composes from the bus and a holder handed the file read one list.
|
||||
|
||||
// PlacedDataItem is one data item a module on a node declares, as placed on that node.
|
||||
type PlacedDataItem struct {
|
||||
Module string `json:"module"`
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -56,63 +55,3 @@ func TestKeepingConsumerDataFollowsTheClass(t *testing.T) {
|
||||
t.Fatal("an older definition that grants no longer keeps its consumers' data")
|
||||
}
|
||||
}
|
||||
|
||||
// The backup holder's lines are derived: the dump runs and the directory it writes into is kept,
|
||||
// anything but a cache is copied by default — the standard nightly plan — an item on redundancy is not
|
||||
// copied, and every item is listed with its class and protection for the holder to measure and watch:
|
||||
// directories filled, a home directory filled from the machine, an operator's path from where the
|
||||
// assignment placed it. A backup line still written by hand beside a data section is not placed.
|
||||
func TestTheBackupHoldersLinesAreDerivedFromTheData(t *testing.T) {
|
||||
pg := mustParse(t, declaredStore)
|
||||
pg.Contributions = append(pg.Contributions, SeatContribution{Seat: BackupSeat, Kind: "backup", Content: "path /stale"})
|
||||
agent := mustParse(t, `{"module":"agent","version":"1",
|
||||
"data":{"own":[{"id":"home","path":"${dir:home}","class":"valuable"},{"id":"tmp","path":"${dir:home}/tmp","class":"cache"}]},
|
||||
"resources":[{"id":"home","type":"directory","path":"${machine:account-home}/.agent","mode":"0700"}]}`)
|
||||
media := mustParse(t, `{"module":"media","version":"1",
|
||||
"accesses":[{"id":"films","mode":"read"}],
|
||||
"data":{"own":[{"id":"films","path":"${access:films}","class":"irreplaceable","redundancy":"on a redundant array; no room for a copy","measure":"dataset"},
|
||||
{"id":"meta","path":"${dir:meta}","class":"rebuildable"}]},
|
||||
"resources":[{"id":"meta","type":"directory","mode":"0700"}]}`)
|
||||
facts := map[string]string{"account-home": "/home/op"}
|
||||
with := Rendering{Settings: SettingsBy{"media": {LayerOf("node", map[string]any{AccessesSetting: map[string]any{"films": "/tank/films"}})}}}
|
||||
backup, _, err := seatContributions([]Manifest{pg, agent, media}, Manifest{}, BackupSeat+":"+BackupKindBackup, with, facts, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := "# agent\npath /home/op/.agent\n# media\npath /var/lib/media/meta\n# pg\nrun dump-all > /srv/store/dumps/all\npath /srv/store/dumps\n"
|
||||
if backup != want {
|
||||
t.Fatalf("backup lines:\n%s\nwant:\n%s", backup, want)
|
||||
}
|
||||
data, _, err := seatContributions([]Manifest{pg, agent, media}, Manifest{}, BackupSeat+":"+BackupKindData, with, facts, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want = "# agent\nitem home valuable /home/op/.agent /home/op/.agent backup walk\nitem tmp cache /home/op/.agent/tmp - none walk\n" +
|
||||
"# media\nitem films irreplaceable /tank/films - redundancy dataset\nitem meta rebuildable /var/lib/media/meta /var/lib/media/meta backup walk\n" +
|
||||
"# pg\nitem store irreplaceable /srv/store /srv/store/dumps backup walk\nitem dumps rebuildable /srv/store/dumps - none walk\n"
|
||||
if data != want {
|
||||
t.Fatalf("data lines:\n%s\nwant:\n%s", data, want)
|
||||
}
|
||||
// ADR 0571 clause 3: the same text, read into items, as disk-load and the backup holder read it; a path a shell
|
||||
// would split is refused.
|
||||
items, err := ParseDataItems(data)
|
||||
if err != nil || len(items) != 3 || items["media"][0] != (PlacedDataItem{Module: "media", ID: "films", Class: "irreplaceable",
|
||||
Path: "/tank/films", Protection: "redundancy", Measure: MeasureDataset}) || items["pg"][0].CoveredBy != "/srv/store/dumps" {
|
||||
t.Fatalf("the data lines read as %+v: %v", items, err)
|
||||
}
|
||||
if _, err := ParseDataItems("# a\nitem x cache /a b - none walk\n"); err == nil {
|
||||
t.Fatal("a path with a space was read")
|
||||
}
|
||||
if _, _, err := seatContributions([]Manifest{agent}, Manifest{}, BackupSeat+":"+BackupKindData, Rendering{}, nil, nil); err == nil {
|
||||
t.Fatal("a home directory with no account on the machine reached the holder as a placeholder")
|
||||
}
|
||||
// What keeps something irreplaceable depends on the machine's backup holder — it backs it up or
|
||||
// watches its array; valuable data alone is backed up where a holder is, and refused nowhere.
|
||||
if !slices.Contains(DependsOn(nil, media), BackupSeat) || !slices.Contains(DependsOn(nil, pg), BackupSeat) ||
|
||||
slices.Contains(DependsOn(nil, agent), BackupSeat) {
|
||||
t.Fatalf("depends: media %v, pg %v, agent %v", DependsOn(nil, media), DependsOn(nil, pg), DependsOn(nil, agent))
|
||||
}
|
||||
if it, _ := media.DataItem("films"); it.OwnedByModule() || it.Protection() != "redundancy" {
|
||||
t.Fatalf("an operator's path reads %+v", it)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,5 @@
|
||||
package catalogue
|
||||
|
||||
// Turning a resolution into the declaration a node is sent.
|
||||
//
|
||||
// Separate from resolving because they answer different questions. Resolving asks *what should
|
||||
// this machine run*; this asks *what does that look like as resources*, and the second is where
|
||||
// settings are applied, generators are called, contributions are collected and credentials are
|
||||
// placed. Both lived in one file until it was doing four jobs at once — which is the shape the
|
||||
// system this replaces failed in, one import at a time.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
package catalogue
|
||||
|
||||
// The delivery's owner (novox/hq ADR 0239, to-be 47): one holder for the mesh, the module mesh-delivery,
|
||||
// which owns a delivery — one commit in one repository, from its pull request's head to every machine —
|
||||
// and a delivery group, and asks the controller for every act. A seat of the mesh's own, so the role has
|
||||
// one name whoever holds it, and the controller can tell whether anything holds it: while nothing does,
|
||||
// the controller starts every walk itself, as it did before there was a delivery to own.
|
||||
|
||||
// DeliverySeat is the seat mesh-delivery holds.
|
||||
const DeliverySeat = "mesh-delivery"
|
||||
|
||||
|
||||
@@ -7,29 +7,6 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// A directory the mesh places (novox/hq ADR 0112, to-be 27, issue 119).
|
||||
//
|
||||
// **A module definition names no host path.** A directory resource may omit `path`; the mesh
|
||||
// resolves where it lands when the declaration is composed — `<root>/<module>/<id>`, the root a
|
||||
// node's own setting with /var/lib as the default. From then on the module's own files, mounts
|
||||
// and environment name the place as `${dir:<id>}`, the same shape as `${bound:…}` and
|
||||
// `${secret:…}`: a fact the module asks for by name and never states.
|
||||
//
|
||||
// **A directory that states a path keeps it, and still answers `${dir:<id>}`.** That is the
|
||||
// placement for an adopted machine: data that must sit where the predecessor already put it is
|
||||
// declared with the path as the exception it is, and everything else in the module names it by
|
||||
// id — so moving it later is one line, not a search.
|
||||
//
|
||||
// **The mesh's own files for a module are placed too** (novox/hq issue 174). What the mesh writes
|
||||
// *for* a module — its sealed bus credential, its merged configuration, its bindings — is the
|
||||
// mesh's plumbing, not the module's data, and sits under `<root>/mesh/<module>`. A directory
|
||||
// saying `"place": "mesh"` is that place; the definition names the files beneath it by
|
||||
// `${dir:<id>}` and states no path.
|
||||
//
|
||||
// **Resolved here, not on the machine.** The host receives concrete paths exactly as it always
|
||||
// has; nothing new reaches it and it learns no field. Which also means a resolved path changing
|
||||
// is a spec change like any other — and the spec comparison must see it (novox/hq issue 126).
|
||||
|
||||
// defaultDataRoot is where module data lands when a node states no root of its own.
|
||||
const defaultDataRoot = "/var/lib"
|
||||
|
||||
|
||||
@@ -1,13 +1,5 @@
|
||||
package catalogue
|
||||
|
||||
// The node seat node-engine (novox/hq ADR 0563 §1): held on every node by that node's engine (mesh-host), which serves
|
||||
// its verbs on its own bus account. No module claims it and no assignment holds it: what the mesh knows of its holder
|
||||
// is the node's record and the engine's haves (ADR 0563 §1 changes ADR 0110 §6 and §7 for this one seat).
|
||||
//
|
||||
// Its verbs carry no signature (§7): the runtime proves the caller, which matters only for the record. The read-only
|
||||
// ones change nothing or are bounded in the engine's own state (§2); hand-over and access-act perform nothing on the
|
||||
// call, and act only on the operator's warrant to the engine's own ask (§3).
|
||||
|
||||
// EngineSeat is the node seat every node's engine holds.
|
||||
const EngineSeat = "node-engine"
|
||||
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
package catalogue
|
||||
|
||||
// **Joining a machine is the seat mesh-enrolment, held once** (novox/hq ADR 0587 §1, issue 774): held for the mesh by
|
||||
// the module mesh-enrolment, the only caller the vault's manifest names for its enrolment-token and node-account verbs
|
||||
// and a caller it names for `sign`. Six verbs, five events, none carrying a secret or anything derived from the token:
|
||||
// the one hash an event carries is the digest of the public keys a node presented. It mints and keeps no secret and
|
||||
// holds no signing key. Its state and its key layout are go/delivery's (enrolment.go).
|
||||
|
||||
// EnrolmentSeat is the seat mesh-enrolment.
|
||||
const EnrolmentSeat = "mesh-enrolment"
|
||||
|
||||
|
||||
@@ -7,23 +7,6 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// The account's environment and the login shell's code, composed from the modules a node runs
|
||||
// (novox/hq ADR 0203, ADR 0204).
|
||||
//
|
||||
// **The same shape as the jails.** Every module may contribute — a toolchain its directory on PATH,
|
||||
// a version manager a variable naming its home, a prompt the code that loads it — naming no node, no
|
||||
// path and no file of the shell's (ADR 0112). The one module holding the matching seat places the
|
||||
// result with a placeholder in its own file, and the controller fills it from every module on the
|
||||
// node. A node not running a module has none of its contribution, and unassigning one takes its
|
||||
// lines away at the next composition.
|
||||
//
|
||||
// **Two kinds of contribution, kept apart on purpose.** The environment is facts, which the
|
||||
// controller writes in two standard formats — POSIX assignment and the service manager's
|
||||
// environment.d — so a terminal, a script, the login shell's `execute` and a graphical session all
|
||||
// read the same values (ADR 0203). Shell code is not a fact: it is text in one shell's syntax, which
|
||||
// the controller sorts into a slot and pastes without reading, as it pastes a jail's stanza (ADR
|
||||
// 0204).
|
||||
|
||||
// EnvironmentSeat and LoginShellSeat are the seats whose holders may place what the modules
|
||||
// contributed: the account's environment, and the login shell's code.
|
||||
const (
|
||||
@@ -454,19 +437,6 @@ func entriesOf(entries []placedOn) string {
|
||||
return strings.Join(out, ":")
|
||||
}
|
||||
|
||||
// shellCode is every module's code for one shell and one slot (novox/hq ADR 0204 §3): in module
|
||||
// order, each module's pieces in the order it declared them, each preceded by a line naming the
|
||||
// module, and empty when nothing is contributed.
|
||||
func shellCode(modules []Manifest, shell, slot string) string {
|
||||
return shellCodeOn(modules, shell, slot, nil)
|
||||
}
|
||||
|
||||
// shellCodeOn is shellCode on a machine with these facts: a moved contributor's item of the slot's collection is
|
||||
// rendered in its module's place as its old form was (heldItemText), so a collector not handed keeps every line.
|
||||
func shellCodeOn(modules []Manifest, shell, slot string, facts map[string]string) string {
|
||||
return shellCodeFor(modules, shell, slot, facts, nil)
|
||||
}
|
||||
|
||||
// oldFormOfCollected is a file's text with each `${collected:contribution:<seat>:<kind>:…}` of a kind received as data
|
||||
// read as the old `${contribution:<seat>:<kind>:…}`: where the collector is not handed, the controller renders the
|
||||
// kind's items as it rendered its contributions, so the two forms place the same text (design 57 L3, issue 781).
|
||||
|
||||
@@ -170,28 +170,6 @@ func shells() []Manifest {
|
||||
}
|
||||
}
|
||||
|
||||
// ADR 0204 §3: a slot holds that shell's code only, in module order, each module's pieces in the
|
||||
// order it declared them under a line naming it; empty when nothing is contributed.
|
||||
func TestShellCodeLandsInItsSlotInModuleOrderForItsShellOnly(t *testing.T) {
|
||||
if got, want := shellCode(shells(), "zsh", "normal"), "# powerlevel10k\n"+
|
||||
"source ~/.local/share/powerlevel10k/powerlevel10k.zsh-theme\n"+
|
||||
"[[ -f ~/.local/share/powerlevel10k/p10k.zsh ]] && source ~/.local/share/powerlevel10k/p10k.zsh\n"+
|
||||
"# zsh-autosuggestions\n"+
|
||||
"source /usr/share/zsh/plugins/zsh-autosuggestions/zsh-autosuggestions.zsh\n"; got != want {
|
||||
t.Fatalf("zsh's normal slot is\n%s\nnot\n%s", got, want)
|
||||
}
|
||||
if got, want := shellCode(shells(), "zsh", "last"), "# zsh-syntax-highlighting\n"+
|
||||
"source /usr/share/zsh/plugins/zsh-syntax-highlighting/zsh-syntax-highlighting.zsh\n"; got != want {
|
||||
t.Fatalf("zsh's last slot is\n%s\nnot\n%s", got, want)
|
||||
}
|
||||
if got, want := shellCode(shells(), "bash", "last"), "# direnv\neval \"$(direnv hook bash)\"\n"; got != want {
|
||||
t.Fatalf("bash's last slot is %q, not %q", got, want)
|
||||
}
|
||||
if got := shellCode(shells(), "fish", "first"); got != "" {
|
||||
t.Fatalf("a slot nobody contributed to holds %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The holder of node-login-shell, as WP3's zsh module writes its block, with its own zsh around the
|
||||
// slots — which holds `${…}` of the shell's own that no mesh pass may touch either.
|
||||
func zshHolder() Manifest {
|
||||
|
||||
@@ -5,19 +5,6 @@ import (
|
||||
"slices"
|
||||
)
|
||||
|
||||
// What a module may call an event, and what a consumer may ask for.
|
||||
//
|
||||
// A module names an event **locally**: `order.placed`, not a subject and not a routing key
|
||||
// (design 29 §1). A consumer names the emitter and the event: `billing.order.placed`. The mesh
|
||||
// derives the subject from those, so reorganising the subject space leaves every manifest correct.
|
||||
//
|
||||
// **Nothing checked this until every manifest in the catalogue was wrong the same way**
|
||||
// (novox/hq 04-ISSUES/127). All thirty-seven kept the old bus's routing key —
|
||||
// `module.<module>.<verb>` — which the derivation read as "a module called `module`", so every
|
||||
// cross-module subscription in the mesh pointed at a namespace nobody publishes to. Nothing failed:
|
||||
// the services started and none of them reacted. The documentation on these fields taught the old
|
||||
// form too, which is why the drift was uniform rather than scattered.
|
||||
|
||||
// eventName is one name in a local event: lower-case, and no wildcard.
|
||||
var eventName = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*$`)
|
||||
|
||||
|
||||
@@ -7,14 +7,6 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// A node's files, read and managed (novox/hq ADR 0394, issue 900; superseding ADR 0310; hq to-be 41 WP9).
|
||||
//
|
||||
// **One seat for every file on a node.** The holder runs as the tool runner's account, the node's operator
|
||||
// account (ADR 0394 §2), and reaches further through `sudo -n` where that account's rights do not. It refuses by
|
||||
// name the places a secret is kept (reads only, ADR 0394 §7) and the paths a module owns, unless a write names a reason (`override`).
|
||||
// node-reads stays beside it until mesh-files is assigned on every node (ADR 0394 §9); it goes in the next
|
||||
// delivery.
|
||||
|
||||
// FilesSeat is the role that reads and manages a node's files and accounts (novox/hq ADR 0394).
|
||||
const FilesSeat = "node-files"
|
||||
|
||||
|
||||
@@ -216,28 +216,6 @@ func widest(rules []Rule) []Rule {
|
||||
// SSHPort is the one port a machine may never lose, whatever else it is told.
|
||||
const SSHPort = 22
|
||||
|
||||
// AsNftables renders a rule set as a complete nftables configuration.
|
||||
//
|
||||
// `outward` says this machine is reachable from outside the mesh, which is the only thing that
|
||||
// decides whether ssh is answered there as well as on the private network.
|
||||
//
|
||||
// `foundation` is the ports the MESH ITSELF needs reachable, which no module declares.
|
||||
//
|
||||
// **Everything else in this file is derived from what modules say they listen on, and the
|
||||
// foundation is not a module** (novox/hq 04-ISSUES/051). So the broker — the port every machine
|
||||
// dials to enrol and to receive every declaration it is ever sent — was absent from the ruleset,
|
||||
// and nothing noticed: a mesh of one never dials its own broker across the network. The first
|
||||
// machine to join a firewalled anchor is refused by the packet filter during enrolment, before
|
||||
// the mesh can report anything about it.
|
||||
//
|
||||
// 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
|
||||
// any module asks for, and neither may be derived away.
|
||||
func AsNftables(rules []Rule, mesh []string, outward bool, foundation []int,
|
||||
outwardLinks []string, tunnel string) string {
|
||||
return AsNftablesWithLocal(rules, mesh, outward, foundation, outwardLinks, tunnel, nil)
|
||||
}
|
||||
|
||||
// AsNftablesWithLocal is AsNftables with the machine's local networks: the networks it reported on a
|
||||
// link facing outside that count (novox/hq ADR 0340, JudgeLocalNetworks), which a rule from
|
||||
// `local-network` admits on its own port alone, beside the private network. None is not widened to
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The mesh's own ports survive a ruleset derived from modules that do not mention them.
|
||||
//
|
||||
// **The firewall is computed from what modules declare they listen on, and the foundation is not a
|
||||
// module** (novox/hq 04-ISSUES/052). So the broker's port — the one every machine dials to enrol
|
||||
// and to receive every declaration it is ever sent — was absent from every ruleset the mesh ever
|
||||
// generated, and nothing caught it: a mesh of one never dials its own broker across the network,
|
||||
// so the ruleset looks complete right up until a second machine tries to join and is refused by
|
||||
// the packet filter, during enrolment, before the mesh can report anything about it.
|
||||
func TestTheBrokersPortIsOpenedThoughNoModuleDeclaresIt(t *testing.T) {
|
||||
const brokerPort = 5671
|
||||
|
||||
// A machine on the private network, with one ordinary module rule, and nothing that mentions
|
||||
// the broker — which is every machine.
|
||||
rules := []Rule{{Port: 8080, From: FromMesh, Because: []string{"some-module"}}}
|
||||
out := AsNftables(rules, []string{"10.42.0.1"}, false, []int{brokerPort}, nil, "mesh0")
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// From anywhere, deliberately: a node enrols BEFORE it has an address on the private network,
|
||||
// so a rule narrowed to that network would close the door being knocked on.
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
if strings.Contains(line, "5671") && strings.Contains(line, "saddr") {
|
||||
t.Fatalf("the broker's port is narrowed to the private network, which a machine that "+
|
||||
"has not yet enrolled is not on:\n%s", line)
|
||||
}
|
||||
}
|
||||
|
||||
// And forwarded, not only accepted on input: the broker is a published container port, so a
|
||||
// cross-node dial is redirected and forwarded and never reaches the input chain. Without this
|
||||
// a joined node reaches the broker only until its first connection's conntrack entry drops —
|
||||
// which a broker restart (adopting the foundation's broker) causes — and then never receives
|
||||
// another declaration (novox/hq issue 063).
|
||||
if !strings.Contains(out, "ct original proto-dst 5671 accept") {
|
||||
t.Fatalf("the broker's port is not forwarded, so a DNAT'd broker is unreachable "+
|
||||
"cross-node after it restarts:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
func TestNoBrokerMeansNoFoundationRuleRatherThanNoRuleset(t *testing.T) {
|
||||
out := AsNftables(nil, []string{"10.42.0.1"}, false, nil, nil, "mesh0")
|
||||
if !strings.Contains(out, "table inet mesh") {
|
||||
t.Fatalf("no ruleset at all:\n%s", out)
|
||||
}
|
||||
if strings.Contains(out, "never derived, never closed\n\t\ttcp dport") {
|
||||
t.Fatalf("a foundation rule was written for a mesh with no broker:\n%s", out)
|
||||
}
|
||||
}
|
||||
@@ -34,26 +34,6 @@ func TestTheRuleSetIsEveryAssignedModulesPorts(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Every rule names what caused it, and both causes survive.
|
||||
func TestTwoModulesWantingOnePortAreBothNamed(t *testing.T) {
|
||||
r := Resolution{Modules: []Manifest{
|
||||
{Module: "web", Listens: []Listening{{Port: 443, From: FromEverywhere, Why: "the site"}}},
|
||||
{Module: "board", Listens: []Listening{{Port: 443, From: FromEverywhere, Why: "the board"}}},
|
||||
}}
|
||||
rules := mustFilter(t, r, nil)
|
||||
if len(rules) != 1 {
|
||||
t.Fatalf("one port became %d rules", len(rules))
|
||||
}
|
||||
if len(rules[0].Because) != 2 {
|
||||
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.
|
||||
nft := AsNftables(rules, nil, false, nil, nil, "mesh0")
|
||||
if !strings.Contains(nft, "web") || !strings.Contains(nft, "board") {
|
||||
t.Fatalf("the rendered rule set does not name both sources:\n%s", nft)
|
||||
}
|
||||
}
|
||||
|
||||
// Widening, and only widening.
|
||||
func TestAPortOpenToEveryoneIsNotAlsoRestrictedToTheMesh(t *testing.T) {
|
||||
r := Resolution{Modules: []Manifest{
|
||||
@@ -72,151 +52,6 @@ func TestAPortOpenToEveryoneIsNotAlsoRestrictedToTheMesh(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// What is not declared is closed.
|
||||
func TestWhatNoModuleDeclaredIsClosed(t *testing.T) {
|
||||
nft := AsNftables(mustFilter(t, Resolution{Modules: []Manifest{
|
||||
{Module: "web", Listens: []Listening{{Port: 443, From: FromEverywhere}}},
|
||||
}}, 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
|
||||
// "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;") {
|
||||
t.Fatalf("the input chain does not drop by default, so nothing is filtered:\n%s", nft)
|
||||
}
|
||||
// Some port nothing asked for. **Not 22**, which this used to use: ssh is now the one line in
|
||||
// the chain that nothing derives, because a mesh that has been assigned almost nothing computes
|
||||
// a firewall that shuts the port used to fix it (novox/hq issue 047).
|
||||
if strings.Contains(nft, "dport 9999") {
|
||||
t.Fatalf("a port no module declared was opened:\n%s", nft)
|
||||
}
|
||||
// Dropping by default must not mean unreachable: the node's own outbound link to the broker
|
||||
// is the one connection the whole mesh runs on, and it is a reply to something it started.
|
||||
if !strings.Contains(nft, "ct state established,related accept") {
|
||||
t.Fatalf("a node applying this would lose its own link to the mesh:\n%s", nft)
|
||||
}
|
||||
if !strings.Contains(nft, "iif lo accept") {
|
||||
t.Fatalf("loopback is filtered, which breaks everything on the machine:\n%s", nft)
|
||||
}
|
||||
}
|
||||
|
||||
// Loading it twice must be the same as loading it once, and must not disturb anything else.
|
||||
//
|
||||
// `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.
|
||||
func TestReloadingReplacesOnlyTheMeshsOwnRules(t *testing.T) {
|
||||
nft := AsNftables(nil, nil, false, nil, nil, "mesh0")
|
||||
if strings.Contains(nft, "flush ruleset") {
|
||||
t.Fatalf("loading the rule set empties every table on the machine:\n%s", nft)
|
||||
}
|
||||
if !strings.Contains(nft, "delete table inet mesh") {
|
||||
t.Fatalf("loading it a second time would add to what is there rather than replace it:\n%s", nft)
|
||||
}
|
||||
// The delete must not fail on a machine that has never loaded one, or the service does not
|
||||
// start and the machine filters nothing while reporting a configuration error.
|
||||
if !strings.Contains(nft, "table inet mesh {}\ndelete table inet mesh") {
|
||||
t.Fatalf("the delete has nothing to delete on a machine loading this for the first time:\n%s", nft)
|
||||
}
|
||||
}
|
||||
|
||||
// The rule set governs what is forwarded too, or it does not govern container ports at all.
|
||||
//
|
||||
// **This reverses an earlier decision, and keeps the thing that decision was protecting.** There was
|
||||
// no forward chain, on the reasoning that dropping there stops container traffic the runtime
|
||||
// allowed — every container on the node, control plane included. That much is true. What it missed
|
||||
// is that a published port is redirected and then forwarded, so it never reaches the input chain,
|
||||
// and a firewall without a forward chain is silent about exactly the ports worth protecting
|
||||
// (novox/hq issue 047).
|
||||
//
|
||||
// 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.
|
||||
func TestWhatIsForwardedIsGovernedToo(t *testing.T) {
|
||||
nft := AsNftables(nil, []string{"198.51.100.2"}, false, nil, nil, "mesh0")
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// And this machine's own guests keep working, which is the whole reason the chain was left out
|
||||
// before — by not being mentioned (novox/hq ADR 0140).
|
||||
//
|
||||
// It used to be done by naming the address ranges they sit on: two fixed here and the rest recorded
|
||||
// per machine. That list broke a workstation's containers at a flip and could not be made correct,
|
||||
// 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) {
|
||||
@@ -245,62 +80,6 @@ func TestAMachineWithNoFilterModuleIsNotRefused(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
// port would match nothing and the port would stay shut while reporting itself open.
|
||||
func TestAPublishedPortIsMatchedByWhatWasAskedFor(t *testing.T) {
|
||||
nft := AsNftables(mustFilter(t, Resolution{Modules: []Manifest{
|
||||
{Module: "web", Listens: []Listening{{Port: 8080, From: FromEverywhere}}},
|
||||
}}, nil), []string{"198.51.100.2"}, false, nil, nil, "mesh0")
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// And a mesh-scoped port stays mesh-scoped when it is reached through the runtime.
|
||||
func TestAMeshScopedPortIsMeshScopedWhenForwarded(t *testing.T) {
|
||||
nft := AsNftables(mustFilter(t, Resolution{Modules: []Manifest{
|
||||
{Module: "store", Listens: []Listening{{Port: 5432, From: FromMesh}}},
|
||||
}}, 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") {
|
||||
t.Fatalf("a mesh-only port is reachable from anywhere once forwarded:\n%s", nft)
|
||||
}
|
||||
}
|
||||
|
||||
// "From the mesh" is the addresses the mesh actually has.
|
||||
func TestFromTheMeshIsTheNodesTheMeshKnows(t *testing.T) {
|
||||
nft := AsNftables(mustFilter(t, Resolution{Modules: []Manifest{
|
||||
{Module: "store", Listens: []Listening{{Port: 5432, From: FromMesh}}},
|
||||
}}, 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") {
|
||||
t.Fatalf("a mesh-scoped port was not restricted to the mesh's addresses:\n%s", nft)
|
||||
}
|
||||
}
|
||||
|
||||
// The case that must not be widened silently.
|
||||
func TestAMeshPortOnANodeWithNoMeshIsClosedAndSaysSo(t *testing.T) {
|
||||
nft := AsNftables(mustFilter(t, Resolution{Modules: []Manifest{
|
||||
{Module: "store", Listens: []Listening{{Port: 5432, From: FromMesh}}},
|
||||
}}, nil), nil, false, nil, nil, "mesh0")
|
||||
if strings.Contains(nft, "dport 5432 accept") {
|
||||
t.Fatalf("a port meant for the mesh was opened to everything:\n%s", nft)
|
||||
}
|
||||
if !strings.Contains(nft, "closed") {
|
||||
t.Fatalf("it was closed silently, which is a fault nobody can find:\n%s", nft)
|
||||
}
|
||||
}
|
||||
|
||||
// A port bound for something else on the same machine must not reach the network.
|
||||
func TestAMachineScopedPortIsNotOpened(t *testing.T) {
|
||||
nft := AsNftables(mustFilter(t, Resolution{Modules: []Manifest{
|
||||
{Module: "cache", Listens: []Listening{{Port: 6379, From: FromMachine}}},
|
||||
}}, nil), []string{"198.51.100.2"}, false, nil, nil, "mesh0")
|
||||
if strings.Contains(nft, "dport 6379 accept") {
|
||||
t.Fatalf("a port for this machine only was opened to the network:\n%s", nft)
|
||||
}
|
||||
}
|
||||
|
||||
// The rule set is the machine's, not the asking module's.
|
||||
func TestTheModuleAskingForTheRuleSetGetsEveryModulesPorts(t *testing.T) {
|
||||
r := Resolution{Modules: []Manifest{
|
||||
@@ -326,24 +105,6 @@ func TestTheModuleAskingForTheRuleSetGetsEveryModulesPorts(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// nftables matches the two address families separately, and one set holding both is a syntax
|
||||
// error — so the file fails to load, the service reports a fault, and the machine filters nothing.
|
||||
func TestAMeshOnBothAddressFamiliesRendersBoth(t *testing.T) {
|
||||
nft := AsNftables(mustFilter(t, Resolution{Modules: []Manifest{
|
||||
{Module: "store", Listens: []Listening{{Port: 5432, From: FromMesh}}},
|
||||
}}, 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") {
|
||||
t.Fatalf("the machines with v4 addresses were dropped:\n%s", nft)
|
||||
}
|
||||
if !strings.Contains(nft, "ip6 saddr { 2001:db8::2 } tcp dport 5432 accept") {
|
||||
t.Fatalf("the machines with v6 addresses were dropped:\n%s", nft)
|
||||
}
|
||||
// And never in one set, which is the syntax error this exists to avoid.
|
||||
if strings.Contains(nft, "198.51.100.2, 2001:db8::2") {
|
||||
t.Fatalf("both families are in one set, so the file will not load:\n%s", nft)
|
||||
}
|
||||
}
|
||||
|
||||
// The host does not sort, so the order the mesh writes is the order a machine applies.
|
||||
//
|
||||
// A module's service or container routinely depends on a file the mesh computed — a certificate,
|
||||
@@ -728,42 +489,6 @@ func TestExposureRefusesAPortNotListenedOnAndABadSource(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// ssh survives whatever the mesh does or does not know about this machine.
|
||||
//
|
||||
// **The one rule here that nothing derives.** Every other line comes from what is assigned, which
|
||||
// is the point of the mechanism — and a mesh part-way through adopting a machine has been assigned
|
||||
// almost nothing, so what it computes is a chain that drops the port used to fix it. The session
|
||||
// loading the rules lives on conntrack until it drops, and then the machine is reached from a
|
||||
// rescue console (novox/hq issue 047).
|
||||
func TestSSHIsOpenFromTheMeshEvenWhenNothingIsAssigned(t *testing.T) {
|
||||
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") {
|
||||
t.Fatalf("ssh is not open to the mesh, so a machine can lock everyone out:\n%s", nft)
|
||||
}
|
||||
// And not to the world, on a machine that does not face it.
|
||||
if strings.Contains(nft, "\t\ttcp dport 22 accept") {
|
||||
t.Fatalf("ssh is open to everywhere on a machine that faces inward only:\n%s", nft)
|
||||
}
|
||||
// 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.
|
||||
nft = AsNftables(nil, []string{"198.51.100.2"}, true, nil, nil, "mesh0")
|
||||
if !strings.Contains(nft, "\t\ttcp dport 22 accept") {
|
||||
t.Fatalf("a machine reachable from outside does not answer ssh there:\n%s", nft)
|
||||
}
|
||||
}
|
||||
|
||||
// A machine the mesh knows no addresses for still answers ssh.
|
||||
//
|
||||
// **This is the machine it matters most for.** Before the private network exists there is nothing
|
||||
// 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.
|
||||
func TestSSHIsNeverLeftWithoutARule(t *testing.T) {
|
||||
nft := AsNftables(nil, nil, false, nil, nil, "mesh0")
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// chainBody is one chain's own lines, so an assertion cannot be satisfied by an identical line in
|
||||
// another chain.
|
||||
//
|
||||
@@ -785,95 +510,3 @@ func chainBody(t *testing.T, nft, chain string) string {
|
||||
}
|
||||
return rest[:j]
|
||||
}
|
||||
|
||||
// **Anything on this machine may call anything on this machine.**
|
||||
//
|
||||
// Local is not a boundary this mesh draws, and whether a caller sits in a container was never meant to
|
||||
// change the answer. It did, because the chain asked about addresses: a caller on the machine carries
|
||||
// the machine's address and a caller in one of its containers carries a bridge address, so a rule
|
||||
// naming the machines' own addresses silently refused every container on them.
|
||||
//
|
||||
// Measured: a module reaching its database on its own machine's name timed out for eleven hours while
|
||||
// the machine itself could reach it (novox/hq 04-ISSUES/145).
|
||||
func TestAnythingOnThisMachineMayCallAnythingOnIt(t *testing.T) {
|
||||
nft := AsNftables(mustFilter(t, Resolution{Modules: []Manifest{
|
||||
{Module: "store", Listens: []Listening{{Port: 5432, From: FromMesh}}},
|
||||
{Module: "private", Listens: []Listening{{Port: 9999, From: FromMachine}}},
|
||||
}}, nil), []string{"10.10.0.1", "10.10.0.2"}, false, nil, []string{"eth0"}, "mesh0")
|
||||
|
||||
// In the INPUT chain, which is where a call to a service on this machine arrives. The forward
|
||||
// chain carries the same line in the same words, so asserting on the whole file proves nothing.
|
||||
input := chainBody(t, nft, "input")
|
||||
if !strings.Contains(input, `iifname != { "eth0", "mesh0" } accept`) {
|
||||
t.Fatalf("a caller on this machine cannot reach a service on it:\n%s", input)
|
||||
}
|
||||
// One rule, for every service here — not a line per port that only covers the ports somebody
|
||||
// remembered to think about.
|
||||
if strings.Contains(input, `iifname != { "eth0", "mesh0" } tcp dport 5432`) {
|
||||
t.Fatalf("the local allowance is still written per port:\n%s", input)
|
||||
}
|
||||
// And the private network still reaches what is exposed to it, which is a different question.
|
||||
if !strings.Contains(input, "ip saddr { 10.10.0.1, 10.10.0.2 } tcp dport 5432 accept") {
|
||||
t.Fatalf("the private network no longer reaches a service exposed to it:\n%s", input)
|
||||
}
|
||||
}
|
||||
|
||||
// The three reaches, as three lines. This is the whole of what the filter says about who may call what.
|
||||
func TestTheThreeReachesAreThreeLines(t *testing.T) {
|
||||
nft := AsNftables(mustFilter(t, Resolution{Modules: []Manifest{
|
||||
{Module: "internal-only", Listens: []Listening{{Port: 5432, From: FromMesh}}},
|
||||
{Module: "public", Listens: []Listening{{Port: 443, From: FromEverywhere}}},
|
||||
}}, nil), []string{"10.10.0.1"}, false, nil, []string{"eth0"}, "mesh0")
|
||||
|
||||
input := chainBody(t, nft, "input")
|
||||
for what, want := range map[string]string{
|
||||
"on this machine": `iifname != { "eth0", "mesh0" } accept`,
|
||||
"over the private network": "ip saddr { 10.10.0.1 } tcp dport 5432 accept",
|
||||
"from anywhere": "tcp dport 443 accept",
|
||||
} {
|
||||
if !strings.Contains(input, want) {
|
||||
t.Fatalf("a caller %s cannot reach what is exposed to it (%q):\n%s", what, want, input)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A port open to everything needs no such line — it is already open to a guest.
|
||||
func TestAPublicPortNeedsNoGuestLine(t *testing.T) {
|
||||
nft := AsNftables(mustFilter(t, Resolution{Modules: []Manifest{
|
||||
{Module: "web", Listens: []Listening{{Port: 443, From: FromEverywhere}}},
|
||||
}}, nil), []string{"10.10.0.1"}, false, nil, []string{"eth0"}, "mesh0")
|
||||
if strings.Count(nft, `iifname != { "eth0", "mesh0" } tcp dport 443`) != 0 {
|
||||
t.Fatalf("a public port was given a guest line it does not need:\n%s", nft)
|
||||
}
|
||||
}
|
||||
|
||||
// **The hub relays the mesh** (novox/hq ADR 0007, issue 196). Two machines that are not co-located
|
||||
// reach each other through the hub, so the hub forwards a packet that arrives on the tunnel and
|
||||
// leaves on it. The forward chain judged that packet by the hub's own published ports, and two
|
||||
// machines behind the hub reached each other only on the ports the hub happened to publish.
|
||||
//
|
||||
// Measured: from one home machine to another through the hub, 17 of 55 ports answered, and they
|
||||
// were exactly the hub's own; the SYN for the rest never left the hub.
|
||||
func TestTheMeshPassingThroughIsRelayedNotJudgedAsThisMachines(t *testing.T) {
|
||||
nft := AsNftables(nil, []string{"10.42.0.1", "10.42.0.2"}, false, nil, []string{"eth0"}, "mesh0")
|
||||
relay := `iifname "mesh0" oifname "mesh0" accept`
|
||||
if !strings.Contains(chainBody(t, nft, "forward"), relay) {
|
||||
t.Errorf("the forward chain does not relay the mesh through this machine:\n%s", chainBody(t, nft, "forward"))
|
||||
}
|
||||
// Relaying is not receiving: nothing in the input chain opens because of it.
|
||||
if strings.Contains(chainBody(t, nft, "input"), "oifname") {
|
||||
t.Errorf("the input chain names an outgoing interface, which no packet for this machine has:\n%s",
|
||||
chainBody(t, nft, "input"))
|
||||
}
|
||||
// And off the tunnel into this machine's own containers is still judged: the tunnel is not
|
||||
// accepted wholesale, only in and out on it.
|
||||
if strings.Contains(chainBody(t, nft, "forward"), `iifname "mesh0" accept`) {
|
||||
t.Errorf("the forward chain accepts everything off the tunnel:\n%s", chainBody(t, nft, "forward"))
|
||||
}
|
||||
|
||||
// A machine with no tunnel relays nothing, and names no interface it does not have.
|
||||
alone := AsNftables(nil, nil, false, nil, []string{"eth0"}, "")
|
||||
if strings.Contains(alone, "oifname") {
|
||||
t.Errorf("a machine with no tunnel was given a relay rule:\n%s", alone)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,28 +11,6 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// A module handed to the provisioner (novox/hq ADR 0349 clauses 2, 4, 6 and 17, design 57 P6, issue 672).
|
||||
//
|
||||
// **Marked in its manifest, one module at a time.** A module whose manifest says `"moved": true` has moved: each
|
||||
// file of it that names a template is filled by the provisioner's client on the node, from the templates archive
|
||||
// its build packed, and the controller fills nothing in it. Every other resource of the module, and every file of
|
||||
// a module not marked, is composed exactly as before (clause 17: each file the old way or the new way, never both).
|
||||
//
|
||||
// **The build keeps what the client needs.** Resolving a moved module's build keeps, beside the files' text the
|
||||
// mesh holds, where its templates archive is and what it hashes to, and which template each file is filled from
|
||||
// (Handed). The text stays, so a machine whose node-engine cannot take a handed file is still sent the module the
|
||||
// old way, byte for byte.
|
||||
//
|
||||
// **Handed only where the machine can take it.** A node's declaration hands a moved module's files over only when
|
||||
// its node-engine writes a filled file on the client's word alone (Rendering.Hands) and the node runs a provisioner
|
||||
// client (Rendering.Provisioner): then each such file is declared with its path, mode, owner and who owns it at
|
||||
// runtime and without its text, the declaration names it among the filled files, and the module's plan says the
|
||||
// client where its templates are and the value of every placement placeholder they name. Anywhere else the module
|
||||
// composes as if it were not marked.
|
||||
|
||||
// MovedField is the manifest's key marking a module moved.
|
||||
const MovedField = "moved"
|
||||
|
||||
// Handed is what a moved module's build kept for the provisioner (novox/hq ADR 0349 §2): its templates archive, as
|
||||
// recorded (store-relative, routed at composition like any archive), and its digest; and, for each file resource
|
||||
// filled from a template, that template's path inside the archive. Written by Resolve, never by hand.
|
||||
|
||||
@@ -3,30 +3,10 @@ package catalogue
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// A module says how each long-running resource is ready (novox/hq ADR 0240 rule 2, to-be 48 §2 and §8,
|
||||
// Phase B).
|
||||
//
|
||||
// **On the resource, beside its other fields**: one kind and its timing. The node-engine judges every
|
||||
// long-running resource alive with no declaration (Phase A); this is how a module says what *ready* means
|
||||
// for one — the image's own check adopted by name, an HTTP request to a declared endpoint, a TCP connect,
|
||||
// a command in the container, the unit's own readiness, or one of the module's own tools.
|
||||
//
|
||||
// **An endpoint is named, never a port or an address**: the check follows the machine's port for that
|
||||
// endpoint as the endpoint does, so a port this machine gave elsewhere moves the check with it. The
|
||||
// controller composes the name into the port the machine published it on, and sends the field only to a
|
||||
// node-engine that reads it (link.ReadinessContract): an older, strict engine refuses a field it does not
|
||||
// know, whole.
|
||||
//
|
||||
// **The bounds are the record's**: an interval not under ten seconds, a timeout under the interval, at
|
||||
// least two failing looks in a row, and a grace plus the failing looks within five minutes — so a
|
||||
// resource broken from its start is said within the gate's ten. Refused here, near the author, and again
|
||||
// by the node-engine, far away, in the same words.
|
||||
|
||||
// HealthField is the resource field.
|
||||
const HealthField = "health"
|
||||
|
||||
@@ -264,31 +244,3 @@ func healthInto(resource map[string]any, m Manifest, with Rendering) {
|
||||
}
|
||||
resource[HealthField] = out
|
||||
}
|
||||
|
||||
// HealthWords is a declared check in a line, for `module check` and `node show`.
|
||||
func HealthWords(h Health) string {
|
||||
var what string
|
||||
switch h.Kind {
|
||||
case HealthHTTP:
|
||||
what = "http " + orSlash(h.Path) + " on " + h.Endpoint
|
||||
if h.Status != 0 {
|
||||
what += " expecting " + strconv.Itoa(h.Status)
|
||||
}
|
||||
case HealthTCP:
|
||||
what = "tcp on " + h.Endpoint
|
||||
case HealthTool:
|
||||
what = "its tool " + h.Tool
|
||||
case HealthRuntime:
|
||||
what = "its image's own check"
|
||||
default:
|
||||
what = h.Kind
|
||||
}
|
||||
return fmt.Sprintf("%s every %s", what, h.Interval)
|
||||
}
|
||||
|
||||
func orSlash(p string) string {
|
||||
if p == "" {
|
||||
return "/"
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
@@ -1,17 +1,5 @@
|
||||
package catalogue
|
||||
|
||||
// **The mesh's health, out of the controller** (novox/hq ADR 0564 clause 1, issue 1952): four seats at mesh scope, each
|
||||
// held once, each holder a process on its own bus credential that writes only its own state.
|
||||
//
|
||||
// - mesh-conditions, held by the module mesh-conditions (the condition keeper): the one writer of the open conditions,
|
||||
// raised from the observers' findings and cleared when they go (clauses 2, 3, 9, 13);
|
||||
// - mesh-watchdogs, held by the watchman (mesh-delivery-watchman): the signals as keys that expire, its stuck keys and
|
||||
// presence as findings, and the dead letters from the lease write (clauses 4, 5);
|
||||
// - mesh-self-check, held by the module mesh-self-check: the probes, every five minutes (clause 6);
|
||||
// - mesh-healers, held by the module mesh-healers: the healers, acting on the keeper's events (clauses 7, 8).
|
||||
//
|
||||
// Their events keep the controller's names and bodies (go/health), so a reader changes only the seat it hears.
|
||||
|
||||
// The four health seats.
|
||||
const (
|
||||
ConditionsSeat = "mesh-conditions"
|
||||
|
||||
@@ -71,20 +71,6 @@ func TestAHolderOnRecordElsewhereLeavesThisMachinesClaimantSilent(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
// A record naming a seat's former name still applies to it after a rename (ADR 0122).
|
||||
func TestAHolderRecordedUnderAFormerNameStillHolds(t *testing.T) {
|
||||
busSeatDelivering(t, "mesh-bus")
|
||||
wasAliases := aliases
|
||||
t.Cleanup(func() { UseAliases(wasAliases) })
|
||||
UseAliases(map[string]string{"the-broker": "mesh-broker"})
|
||||
record := []Held{{Claim: "the-broker", Scope: ScopeMesh, Node: "anchor", Module: "new-broker"}}
|
||||
|
||||
held, problems := checkClaims([]Manifest{oldBroker(), newBroker()}, Node{Name: "anchor"}, nil, record)
|
||||
if len(problems) != 0 || len(held) != 1 || held[0].Module != "new-broker" {
|
||||
t.Fatalf("a record under the former name did not settle the seat: held=%v problems=%v", held, problems)
|
||||
}
|
||||
}
|
||||
|
||||
// CanHold is the one judgement registration and the handover share, against the store's row.
|
||||
func TestCanHoldJudgesClaimScopeAndWhatTheSeatDelivers(t *testing.T) {
|
||||
busSeatDelivering(t, "mesh-bus")
|
||||
|
||||
@@ -10,48 +10,6 @@ import (
|
||||
// machine's own lines in /etc/hosts. The seat was renamed (ADR 0122), so what claims the old name — a
|
||||
// manifest registered before the rename — still holds the one seat.
|
||||
|
||||
func withHostnameAlias(t *testing.T) {
|
||||
t.Helper()
|
||||
was := aliases
|
||||
t.Cleanup(func() { aliases = was })
|
||||
UseAliases(map[string]string{"node-hosts-file": "node-hostname"})
|
||||
}
|
||||
|
||||
func TestTheHostsFilesFormerNameResolvesToTheHostnameSeat(t *testing.T) {
|
||||
if _, known := SeatNamed("node-hostname"); !known {
|
||||
t.Fatal("node-hostname is not in the mesh's set")
|
||||
}
|
||||
withHostnameAlias(t)
|
||||
seat, known := SeatNamed("node-hosts-file")
|
||||
if !known || seat.Name != "node-hostname" || seat.Scope != ScopeNode {
|
||||
t.Fatalf("the former name did not resolve: %+v %v", seat, known)
|
||||
}
|
||||
var verbs []string
|
||||
for _, v := range seat.Serves {
|
||||
verbs = append(verbs, v.Name)
|
||||
}
|
||||
if strings.Join(verbs, " ") != "entries add remove" {
|
||||
t.Errorf("the renamed seat serves %v; its verbs are unchanged", verbs)
|
||||
}
|
||||
}
|
||||
|
||||
// One seat under either name: the module registered before the rename and the one after cannot both
|
||||
// hold it on one machine.
|
||||
func TestTheOldAndTheNewClaimantAreOneSeatOnAMachine(t *testing.T) {
|
||||
withHostnameAlias(t)
|
||||
cat := shelf(
|
||||
mod("hosts", nil, nil, nil, Claim{Name: "node-hosts-file"}),
|
||||
mod("hostname", nil, nil, nil, Claim{Name: "node-hostname"}),
|
||||
)
|
||||
_, err := Resolve(cat, []string{"hosts", "hostname"}, workstation(), World{})
|
||||
if err == nil || !strings.Contains(err.Error(), "node-hostname") {
|
||||
t.Errorf("hosts and hostname both held the machine's names on one machine: %v", err)
|
||||
}
|
||||
if _, err := Resolve(cat, []string{"hosts"}, workstation(), World{}); err != nil {
|
||||
t.Errorf("a machine still assigned hosts under the old name does not resolve: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The catalogue's module: /etc/hostname is the operator's `hostname` setting. Without it the module is
|
||||
// left out, naming the key — the mesh never renames a machine on its own — and a mesh-wide setting
|
||||
// naming ${machine:name} gives every machine its mesh name.
|
||||
|
||||
@@ -7,23 +7,6 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Who a consumer is, said once by the mesh (novox/hq 04-ISSUES/023).
|
||||
//
|
||||
// **The provisioner used to invent this and nothing else could derive it.** It made a role called
|
||||
// `mesh_<node>_<module>`, which is a reasonable name and is knowable nowhere else: not by the
|
||||
// control plane, not by the binding, and above all not by the consumer — which has to present it
|
||||
// in order to authenticate. The one identifier needed to connect was the one thing no part of the
|
||||
// mesh would say.
|
||||
//
|
||||
// So the mesh says it. It goes to the provider in the grant and to the consumer in its binding,
|
||||
// from **one derivation**, which is what makes the two ends agree by construction rather than by
|
||||
// two conventions that were the same on the day they were written.
|
||||
//
|
||||
// **It is still name-agnostic.** The mesh does not know what a role or an access key or a client
|
||||
// is; it says who is asking, and each provisioner makes that true in whatever its own system
|
||||
// calls an identity. What a provider does with it is the provider's business, as everything about
|
||||
// a provision is.
|
||||
|
||||
// identityUnusable is every character that is not safe unquoted in the systems these names reach.
|
||||
//
|
||||
// Conservative on purpose: lower-case letters, digits and underscore reach a PostgreSQL role, a
|
||||
|
||||
@@ -1,27 +1,5 @@
|
||||
package catalogue
|
||||
|
||||
// A definition names no installation (novox/hq ADR 0112, ADR 0155, issues 122 and 134).
|
||||
//
|
||||
// A module definition holds what is true of the module everywhere; what is particular to one mesh —
|
||||
// a public name, a forge's address, a node's public address — is resolved at assignment. The rule
|
||||
// stood for a month with nothing checking it, and a sweep found thirty of seventy-one definitions
|
||||
// naming the installation they were written in. This is the check.
|
||||
//
|
||||
// **What is judged is what the mesh acts on, not what a person reads.** A domain in a `why` or a
|
||||
// `description` is documentation the mesh never reads; reporting it beside `KC_HOSTNAME` would teach
|
||||
// people to ignore the report. What is judged is every other string value: a name under a public
|
||||
// top-level domain, or a public address. Two families of name are the world's and not this mesh's,
|
||||
// and are allowed where they can only mean the world: the public registries an `image` may be pulled
|
||||
// from, and the public resolvers a machine may forward to. The container runtime's own alias for
|
||||
// its host is the runtime's, true on every machine that runs it.
|
||||
//
|
||||
// **A name that is right where it stands is declared, one by one, with its reason.** A federated
|
||||
// server's config names the federation's public directory; an application built outside the mesh
|
||||
// is pulled from the registry that built it, until the mesh builds it. The resource carries
|
||||
// `names-on-purpose`, a map from each such name to why — the shape `secrets-in-environment` has,
|
||||
// per name — so a reader sees which names a definition means to carry and why, a name the map does
|
||||
// not cover is still reported, and the catalogue-wide test is the list that shrinks as names move.
|
||||
|
||||
// NamesOnPurpose is the catalogue-level word a resource carries for the names it means to name:
|
||||
// each name mapped to its reason. The host never sees it.
|
||||
const NamesOnPurpose = "names-on-purpose"
|
||||
|
||||
@@ -8,14 +8,6 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// A node's fail2ban jails, composed from the modules it runs (novox/hq to-be 31).
|
||||
//
|
||||
// **The same shape as the firewall.** Every module's `listens` become the node's rule set; every
|
||||
// module's `jails` become the node's fail2ban config. A module that runs an authenticating service
|
||||
// declares what a break-in on it looks like and how to ban it, naming no node and no path (ADR
|
||||
// 0112); the intrusion-prevention holder — the one module with `jailing` — gathers them and writes
|
||||
// them where it owns. A node not running a module has none of its jails.
|
||||
|
||||
// jailsInto composes every jail declared by the modules on a node into the files the holder writes:
|
||||
// one jail file (all stanzas, so the fail2ban service restarts on a single resource) and one filter
|
||||
// file per jail (its failregex, which fail2ban references by the jail's name).
|
||||
|
||||
@@ -2,16 +2,6 @@ package catalogue
|
||||
|
||||
import "slices"
|
||||
|
||||
// The one rule of which provisions the seat mesh-provisioner keys and a want waits for (novox/hq ADR 0570 §1, §2, §4;
|
||||
// issue 774). The plan composition puts a need in a consumer's plan by it, the holder of mesh-provisioner keys what the
|
||||
// plans name, and the assigner's rule 8 waits for what Awaited answers: three parts, one rule, so they cannot drift
|
||||
// again (on 2026-10-30 the assigner waited for every `requires` while the plans named only some, and every new build of
|
||||
// grafana, dnsmasq, mesh-knowledge, mesh-mail and the builders waited for a key nobody would make).
|
||||
//
|
||||
// A need a module answers (not a record, not nothing) is keyed when its consumer is handed to the provisioner, or the
|
||||
// provider's offer says the provider helper answers it. A need of a consumer not handed whose provider's helper does
|
||||
// not answer it is still answered by the controller's composition: it is neither keyed nor waited for.
|
||||
|
||||
// HandedByManifest is whether a module is handed to the provisioner as far as its manifest says (Rendering.Handing
|
||||
// without the machine's own words, and begin's): marked moved, not a held module's stub, with a template its build
|
||||
// kept or an item.
|
||||
|
||||
@@ -5,11 +5,9 @@ import "encoding/json"
|
||||
// The sources mesh-config says a resolved value came from, in mesh-config_resolved (novox/hq ADR 0348 §9): a key of
|
||||
// it holds {"value": …, "source": …}.
|
||||
const (
|
||||
SourceMesh = "mesh"
|
||||
SourceNode = "node"
|
||||
SourceModule = "module"
|
||||
SourceDefault = "default"
|
||||
SourceUnset = "unset"
|
||||
SourceMesh = "mesh"
|
||||
SourceNode = "node"
|
||||
SourceModule = "module"
|
||||
)
|
||||
|
||||
// LaidSource says whether a value resolved from source is laid over a module's files: mesh config, node config and the
|
||||
|
||||
@@ -6,14 +6,6 @@ import (
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// What a module may call its logs (novox/hq ADR 0297).
|
||||
//
|
||||
// A log is a module's record of operations: entries appended under a key, each kept as long as the
|
||||
// log is, in the order they came. A module names each log it owns **locally** — `changes`, never a
|
||||
// stream or a subject (ADR 0201 §4) — and the mesh derives the stream from the module and the local
|
||||
// name, as it derives a bucket. So the rule for a log's name is a state name's: one plain token, and
|
||||
// a module that keeps a log has a name that is one plain token too.
|
||||
|
||||
// The mesh's caps on a log, in MiB: what a module may ask, and what it gets when it asks nothing.
|
||||
const (
|
||||
LogLeastMiB = 1
|
||||
|
||||
@@ -7,28 +7,6 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// What a module may say about the machine it is running on.
|
||||
//
|
||||
// **A module cannot know where it will be assigned, and sometimes it must say so anyway.** Every
|
||||
// other name in a declaration is either the module's own — which it wrote — or something it
|
||||
// requires, which arrives as a binding. The machine underneath is neither: it is chosen when the
|
||||
// module is assigned, long after the manifest was written, and until now nothing carried it into a
|
||||
// file.
|
||||
//
|
||||
// The case that found this is a certificate authority inside the mesh (novox/hq ADR 0066). A proxy
|
||||
// reaches it at the address the mesh handed over, `<machine>.internal` — so the authority's own
|
||||
// certificate has to be issued for that name, or the first thing that happens is the proxy refusing
|
||||
// to talk to it. The authority is the one thing that cannot be told its name by a binding: it
|
||||
// provides, it does not require. Written as a literal it would be a manifest carrying one
|
||||
// deployment's machine name, which is the shape [ADR 0066] exists to remove.
|
||||
//
|
||||
// Three facts, all the mesh's own vocabulary — the same `node` and `at` a contribution already
|
||||
// carries, and the address behind `at`, for software that takes an address and not a name. The
|
||||
// case that found the third is a resolver pointing the container runtime at itself: the runtime's
|
||||
// list of resolvers is addresses, because a name there would have to be resolved by the resolver
|
||||
// it names. Nothing about what a machine is *for*: that would be the mesh learning what a module
|
||||
// means, which it does not do.
|
||||
|
||||
// ofMachine is where a module says a fact about the machine underneath it belongs:
|
||||
// ${machine:<key>}.
|
||||
var ofMachine = regexp.MustCompile(`\$\{machine:([a-z0-9][a-z0-9_-]*)\}`)
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
package catalogue
|
||||
@@ -2,25 +2,6 @@ package catalogue
|
||||
|
||||
import "path"
|
||||
|
||||
// The machine's own resolver (novox/hq ADR 0247).
|
||||
//
|
||||
// **Most machines have none.** Every machine lists the mesh's resolvers in /etc/resolv.conf and nothing
|
||||
// else, and the module holding its uplink writes that file (ADR 0223). A machine with a VPN client that
|
||||
// pushes its own resolvers for its own domains needs a third answer: those domains to the VPN's servers,
|
||||
// over the VPN's link, and every other name to the mesh's resolvers as before. One file cannot list both
|
||||
// sets of servers — musl takes the first reply, glibc the first server's "no such name" — so the domains
|
||||
// are routed by a resolver on the machine itself: the `node-resolver` seat's holder.
|
||||
//
|
||||
// **Where it is held, it owns the resolver file.** The file then names the machine's own resolver, which
|
||||
// routes; the uplink's holder steps back from writing it on that machine, by the rule below and not by
|
||||
// two modules writing one path. There are two uplink holders (NetworkManager's and systemd-networkd's),
|
||||
// and the resolver is its own module so it is written once, not once in each.
|
||||
//
|
||||
// **It knows nothing of any VPN.** Its verbs route a set of domains to a set of servers over one link,
|
||||
// list what is routed and remove a route. A module wrapping a VPN client that writes /etc/resolv.conf
|
||||
// itself carries its own adapter and calls those verbs; a VPN that tells systemd-resolved its link's DNS
|
||||
// itself needs none.
|
||||
|
||||
// ResolverSeat is the machine's own resolver (novox/hq ADR 0247).
|
||||
const ResolverSeat = "node-resolver"
|
||||
|
||||
|
||||
@@ -6,14 +6,6 @@ import (
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// A module's own object bucket (novox/hq ADR 0592 §5, §9).
|
||||
//
|
||||
// A module declares an object bucket as it declares a state (ADR 0201) or a stream (ADR 0363): named locally,
|
||||
// with its maximum size. The mesh creates it once, keeps every object with no age limit, and refuses a new object
|
||||
// when it is full rather than dropping one; only its module writes it. Another module reads it only as a restricted
|
||||
// stream is heard: a `reads` entry `<module>.<name>` naming it, and a module the owner's trusted config value of the
|
||||
// bucket's grant names (ADR 0363 §4, §5), read-only. This is the shape only: its checks are the indexer's judge's.
|
||||
|
||||
// The mesh's bounds on a module's object bucket: its size in MiB (ADR 0592 §5, §25; the bound ADR 0363 §1 sets for a
|
||||
// stream). A bucket says its size: there is no default.
|
||||
const (
|
||||
|
||||
@@ -8,21 +8,6 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// A requirement a module can run without (novox/hq ADR 0330, issue 166).
|
||||
//
|
||||
// **Hard stays the default.** A requirement written as a bare name is hard, exactly as before: it
|
||||
// resolves, or the assignment is refused, and a single catalogue candidate is installed for it. One
|
||||
// written `{"name": "influxdb-api", "optional": true}` resolves the same way when a provider is
|
||||
// already assigned in the mesh or reachable from the consumer (§2), and is otherwise **absent**: not
|
||||
// refused, and nothing is installed for it, on this machine or elsewhere (§3).
|
||||
//
|
||||
// **An absent requirement is never rendered as an empty value** (§4–§6). Its binding, its pair
|
||||
// credential and its grant are not composed, so a file that names one of them is delivered only while
|
||||
// the requirement is bound (`only-when-bound`), and a `${bound:…}` placeholder of it outside such a
|
||||
// file names the value it takes while absent: `${bound:<provision>:<key>|default:<value>}`. The
|
||||
// module check refuses anything else, so the gap is found when the manifest is written, not on a
|
||||
// machine where it reads as a blank.
|
||||
|
||||
// OnlyWhenBound is a resource's field naming the optional requirement it is delivered only while
|
||||
// bound (ADR 0330 §5). Said in the catalogue and dropped before the declaration is sent: the
|
||||
// node-engine parses strictly and knows no such field.
|
||||
|
||||
@@ -8,40 +8,12 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Where a module's data is on THIS machine is the assignment's (novox/hq ADR 0112, issue 153).
|
||||
//
|
||||
// A definition names no host path. It declares the directories it owns by id, and the operator's
|
||||
// shared data it needs by id too (an `access`, ADR 0051). A node has a default layout for the
|
||||
// former — <root>/<module>/<id> — and nothing at all for the latter, because shared data is
|
||||
// wherever the operator keeps it. An adopted machine keeps its data where the predecessor put it:
|
||||
// a 40 TB library on its own pool, a configuration on a second disk. Both halves are said on the
|
||||
// assignment, validated the way `endpoints` is — an id the module does not declare is refused,
|
||||
// because a setting that reaches nothing is a mistake — and resolved here, so the host receives
|
||||
// concrete paths exactly as it always has and learns no field.
|
||||
//
|
||||
// {"places": {"config": "/services/sonarr/config",
|
||||
// "data": {"path": "/mnt/plex/data", "owner": "1000:1000"}},
|
||||
// "accesses": {"series": "/storage/media/series",
|
||||
// "downloads": "/storage/downloads"}}
|
||||
//
|
||||
// A placed directory is still the mesh's: created, chowned to the owner the assignment says (or
|
||||
// the manifest's), removed when empty and no longer declared. A placed access is still the
|
||||
// operator's: mounted, never created, chowned or removed.
|
||||
|
||||
// PlacesSetting is the settings key that places a module's declared directories, by id.
|
||||
const PlacesSetting = "places"
|
||||
|
||||
// AccessesSetting is the settings key that says where a module's accesses are on this node, by id.
|
||||
const AccessesSetting = "accesses"
|
||||
|
||||
// **Placement is not config** (novox/hq ADR 0348 clause 11, issues 651 and 736). `places`, `accesses` and
|
||||
// `networks` — where an assignment's things are — and `endpoints`, `expose`, `reach` and `ports` — where it
|
||||
// is reached and from how far — are facts of an assignment the controller decides, held beside the
|
||||
// assignment and apart from its settings: a Layer carries them in Placement, never in Values, and only
|
||||
// their readers (Places, AccessPlaces, KeptNetworks, Endpoints, Exposure, Reaches, GivenPorts) read them.
|
||||
// A person still says them as keys of a layer through `settings`; LayerOf splits such a layer, so what
|
||||
// they type is routed to the placement and nothing they do changes.
|
||||
|
||||
// placementKeys are the keys of a layer that are placement, not config.
|
||||
var placementKeys = map[string]bool{PlacesSetting: true, AccessesSetting: true, NetworksSetting: true,
|
||||
EndpointsSetting: true, ExposeSetting: true, ReachSetting: true, PortsSetting: true}
|
||||
@@ -57,34 +29,6 @@ func PlacementKeys() []string {
|
||||
return keys
|
||||
}
|
||||
|
||||
// SplitPlacement divides a layer as a person says it into its config values and its placement. Each is
|
||||
// nil when it holds nothing; the values themselves are not copied.
|
||||
func SplitPlacement(values map[string]any) (config, placement map[string]any) {
|
||||
for key, value := range values {
|
||||
if placementKeys[key] {
|
||||
if placement == nil {
|
||||
placement = map[string]any{}
|
||||
}
|
||||
placement[key] = value
|
||||
continue
|
||||
}
|
||||
if config == nil {
|
||||
config = map[string]any{}
|
||||
}
|
||||
config[key] = value
|
||||
}
|
||||
return config, placement
|
||||
}
|
||||
|
||||
// LayerOf is a layer as a person says it — config values and placement in one map — split into the two.
|
||||
func LayerOf(from string, values map[string]any) Layer {
|
||||
config, placement := SplitPlacement(values)
|
||||
if config == nil {
|
||||
config = map[string]any{}
|
||||
}
|
||||
return Layer{From: from, Values: config, Placement: placement}
|
||||
}
|
||||
|
||||
// Whole is the layer as a person says it: its values and its placement in one map, a new map.
|
||||
func (l Layer) Whole() map[string]any {
|
||||
out := make(map[string]any, len(l.Values)+len(l.Placement))
|
||||
|
||||
@@ -1,14 +1,5 @@
|
||||
package catalogue
|
||||
|
||||
// The seat of placement (novox/hq ADR 0567): which module runs on which node, with its host ports, bindings, the
|
||||
// node's pins and holdings, the config placement and values, and what a person decides of the node, is state on the
|
||||
// bus held by this seat, changed only by a proposal the nodes it touches answer and the operator approves. Held once
|
||||
// for the mesh by the assigner (mesh-delivery-assigner), beside its wants. Its states are named after the holding
|
||||
// module, as every module state is (delivery.PlacementBucket, PlacementProposalsBucket, PlacementCandidatesBucket);
|
||||
// they are the seat's contract, so every reader takes their names from mesh-sdk, and a change of holder is a migration
|
||||
// the new holder's record decides (ADR 0567 §1). Its events are the holder's too, carried on the holder's stream
|
||||
// (delivery.PlacementStream): the names are in delivery (PlacementProposedEvent and the rest).
|
||||
|
||||
// PlacementSeat is the seat the assigner holds for placement.
|
||||
const PlacementSeat = "mesh-placement"
|
||||
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The case novox/hq issue 153 records: an adopted machine keeps its data where the predecessor put
|
||||
// it — a library on its own pool that must never move, a configuration on a second disk owned by
|
||||
// whoever the predecessor ran as. A definition may name none of that (ADR 0112); the assignment
|
||||
@@ -21,243 +16,3 @@ func placeable() Manifest {
|
||||
m.Accesses = []Access{{ID: "series", Mode: AccessReadWrite}, {ID: "spool"}}
|
||||
return m
|
||||
}
|
||||
|
||||
func placedBy(values map[string]any) Rendering {
|
||||
return Rendering{Settings: SettingsBy{"arr": {LayerOf("node anchor", values)}}}
|
||||
}
|
||||
|
||||
func TestAnAssignmentPlacesDirectoriesAndAccesses(t *testing.T) {
|
||||
got, err := Resolve(shelf(placeable()), []string{"arr"}, workstation(), World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err := got.Declaration(placedBy(map[string]any{
|
||||
PlacesSetting: map[string]any{
|
||||
"config": map[string]any{"path": "/services/arr/config/", "owner": "1001:2000"},
|
||||
},
|
||||
AccessesSetting: map[string]any{
|
||||
"series": "/storage/media/series",
|
||||
"spool": "/storage/downloads",
|
||||
},
|
||||
}))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
seen := map[string]map[string]any{}
|
||||
for _, r := range out {
|
||||
seen[r["id"].(string)] = r
|
||||
}
|
||||
config := seen["arr.config"]
|
||||
if config["path"] != "/services/arr/config" || config["owner"] != "1001:2000" {
|
||||
t.Fatalf("the placed directory is %v %v; want the assignment's path and owner", config["path"], config["owner"])
|
||||
}
|
||||
if seen["arr.state"]["path"] != "/var/lib/arr" {
|
||||
t.Fatalf("an unplaced directory left the default layout: %v", seen["arr.state"]["path"])
|
||||
}
|
||||
var accesses []string
|
||||
for _, r := range out {
|
||||
if r["type"] == "access" {
|
||||
accesses = append(accesses, r["path"].(string)+" "+r["mode"].(string))
|
||||
}
|
||||
}
|
||||
if strings.Join(accesses, ",") != "/storage/media/series read-write,/storage/downloads read" {
|
||||
t.Fatalf("the accesses reached the machine as %v", accesses)
|
||||
}
|
||||
server := seen["arr.server"]
|
||||
mounts := server["volumes"].([]any)
|
||||
if mounts[0] != "/services/arr/config:/config" || mounts[1] != "/storage/media/series:/series" ||
|
||||
mounts[2] != "/storage/downloads:/downloads:ro" {
|
||||
t.Fatalf("the mounts were not filled with the placed paths: %v", mounts)
|
||||
}
|
||||
if server["env"].(map[string]any)["SPOOL"] != "/storage/downloads" {
|
||||
t.Fatalf("the environment was not filled: %v", server["env"])
|
||||
}
|
||||
}
|
||||
|
||||
// An access declared by id and placed by nobody resolves to nowhere, and that is refused with the
|
||||
// setting to write — not mounted as the literal, not skipped. The refusal costs the module its
|
||||
// place in the declaration, not the machine its declaration (novox/hq ADR 0163, rule 6).
|
||||
func TestAnUnplacedAccessIsRefusedByName(t *testing.T) {
|
||||
got, err := Resolve(shelf(placeable()), []string{"arr"}, workstation(), World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
with := placedBy(map[string]any{
|
||||
AccessesSetting: map[string]any{"series": "/storage/media/series"},
|
||||
})
|
||||
composed, err := got.Compose(with)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
why := composed.LeftOut["arr"]
|
||||
if why == "" || !strings.Contains(why, `"spool"`) || !strings.Contains(why, AccessesSetting) {
|
||||
t.Fatalf("an access nobody placed was not refused by name: %v", composed.LeftOut)
|
||||
}
|
||||
if _, declared := byID(composed.Resources)["arr.server"]; declared {
|
||||
t.Fatal("the module with the unplaced access was declared anyway")
|
||||
}
|
||||
}
|
||||
|
||||
// Validated like endpoints: an id the module does not declare reaches nothing, and the refusal
|
||||
// says what it does declare; a relative path and a non-numeric owner are refused too.
|
||||
func TestPlacementsAreValidated(t *testing.T) {
|
||||
m := placeable()
|
||||
layers := func(values map[string]any) []Layer { return placedBy(values).Settings["arr"] }
|
||||
|
||||
_, err := Places(m, layers(map[string]any{PlacesSetting: map[string]any{"data": "/mnt/data"}}))
|
||||
if err == nil || !strings.Contains(err.Error(), "does not declare") || !strings.Contains(err.Error(), `"config"`) {
|
||||
t.Fatalf("placing an undeclared directory was accepted: %v", err)
|
||||
}
|
||||
_, err = Places(m, layers(map[string]any{PlacesSetting: map[string]any{"config": "services/arr"}}))
|
||||
if err == nil || !strings.Contains(err.Error(), "absolute") {
|
||||
t.Fatalf("a relative placement was accepted: %v", err)
|
||||
}
|
||||
_, err = Places(m, layers(map[string]any{PlacesSetting: map[string]any{
|
||||
"config": map[string]any{"path": "/services/arr", "owner": "media"}}}))
|
||||
if err == nil || !strings.Contains(err.Error(), "uid:gid") {
|
||||
t.Fatalf("a non-numeric owner was accepted: %v", err)
|
||||
}
|
||||
_, err = AccessPlaces(m, layers(map[string]any{AccessesSetting: map[string]any{"movies": "/storage/media/movies"}}))
|
||||
if err == nil || !strings.Contains(err.Error(), "does not declare") || !strings.Contains(err.Error(), `"series"`) {
|
||||
t.Fatalf("placing an undeclared access was accepted: %v", err)
|
||||
}
|
||||
_, err = AccessPlaces(m, layers(map[string]any{AccessesSetting: map[string]any{"series": "media/series"}}))
|
||||
if err == nil || !strings.Contains(err.Error(), "absolute") {
|
||||
t.Fatalf("a relative access was accepted: %v", err)
|
||||
}
|
||||
// And the two keys are never stray: they are validated here, not merged into a file.
|
||||
if stray := UnusedSettings(m, layers(map[string]any{
|
||||
PlacesSetting: map[string]any{"config": "/services/arr/config"},
|
||||
AccessesSetting: map[string]any{"series": "/storage/media/series"},
|
||||
})); len(stray) != 0 {
|
||||
t.Fatalf("the placement keys were reported as unused: %v", stray)
|
||||
}
|
||||
}
|
||||
|
||||
// A definition that carries a path still works, as the default the assignment may replace — and
|
||||
// the assignment's placement wins where both say.
|
||||
func TestADefinitionsPathIsTheDefaultTheAssignmentReplaces(t *testing.T) {
|
||||
m := placeable()
|
||||
m.Accesses = []Access{{ID: "series", Path: "/services/media/series", Mode: AccessReadWrite}, {ID: "spool", Path: "/services/media/downloads"}}
|
||||
got, err := Resolve(shelf(m), []string{"arr"}, workstation(), World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err := got.Declaration(placedBy(map[string]any{
|
||||
PlacesSetting: map[string]any{"config": "/services/arr/config"},
|
||||
AccessesSetting: map[string]any{"series": "/storage/media/series"},
|
||||
}))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var paths []string
|
||||
for _, r := range out {
|
||||
if r["type"] == "access" {
|
||||
paths = append(paths, r["path"].(string))
|
||||
}
|
||||
}
|
||||
if strings.Join(paths, ",") != "/storage/media/series,/services/media/downloads" {
|
||||
t.Fatalf("placed one, defaulted the other: got %v", paths)
|
||||
}
|
||||
}
|
||||
|
||||
// **Placement is read from the placement, never from a layer's values** (novox/hq ADR 0348 clause 11, issue
|
||||
// 651). A `places` or `accesses` among the config values places nothing; the same in Placement does. A
|
||||
// mergeable file of today's form still takes every key of the layer as it is said, placement too, so it
|
||||
// composes as it did while placement was kept in the layer.
|
||||
func TestPlacementIsReadFromThePlacementOnly(t *testing.T) {
|
||||
m := placeable()
|
||||
said := map[string]any{
|
||||
PlacesSetting: map[string]any{"config": "/services/arr/config"},
|
||||
AccessesSetting: map[string]any{"series": "/storage/media/series", "spool": "/storage/downloads"},
|
||||
"log": "warn",
|
||||
}
|
||||
among := []Layer{{From: "anchor", Values: said}}
|
||||
if placed, err := Places(m, among); err != nil || placed != nil {
|
||||
t.Fatalf("a place among the config values placed %v (%v)", placed, err)
|
||||
}
|
||||
if _, _, err := accessesFor(m, among); err == nil {
|
||||
t.Fatal("an access among the config values placed it")
|
||||
}
|
||||
beside := []Layer{LayerOf("anchor", said)}
|
||||
if beside[0].Values["log"] != "warn" || beside[0].Values[PlacesSetting] != nil || beside[0].Placement[PlacesSetting] == nil {
|
||||
t.Fatalf("LayerOf split %+v", beside[0])
|
||||
}
|
||||
if placed, err := Places(m, beside); err != nil || placed["config"].Path != "/services/arr/config" {
|
||||
t.Fatalf("the placement did not place: %v (%v)", placed, err)
|
||||
}
|
||||
|
||||
file := map[string]any{"id": "runtime", "type": "file", "path": "/x", "content": `{"a":1}`, "merge": "json"}
|
||||
whole, err := ApplySettings(file, among)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
split, err := ApplySettings(file, beside)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if whole["content"] != split["content"] || !strings.Contains(split["content"].(string), `"places"`) {
|
||||
t.Fatalf("today's mergeable file composes %q, was %q", split["content"], whole["content"])
|
||||
}
|
||||
}
|
||||
|
||||
// **The four words of where an assignment is reached are placement too** (novox/hq ADR 0348 clause 11, issue 736).
|
||||
// `endpoints`, `expose`, `reach` and `ports` among a layer's config values say nothing to the filter, the ports or
|
||||
// the names; the same in Placement do, and LayerOf puts them there. A mergeable file of today's form still takes
|
||||
// every key of the layer as it is said, so it composes as it did while they were kept among the values.
|
||||
func TestTheFourWordsAreReadFromThePlacementOnly(t *testing.T) {
|
||||
m := Manifest{Module: "forge", Version: "1",
|
||||
Listens: []Listening{{Name: "web", Port: 3000, Protocol: "tcp", From: FromMesh}, {Name: "ssh", Port: 22, Protocol: "tcp", From: FromMesh},
|
||||
{Name: "api", Port: 8086, Protocol: "tcp", From: FromMesh}},
|
||||
Resources: []map[string]any{{"id": "server", "type": "container", "image": "x", "ports": []any{"3000", "222:22", "8086"}}}}
|
||||
said := map[string]any{
|
||||
ExposeSetting: map[string]any{"22": FromEverywhere},
|
||||
PortsSetting: map[string]any{"22": float64(2222)},
|
||||
ReachSetting: map[string]any{"3000": "internal"},
|
||||
EndpointsSetting: map[string]any{"api": map[string]any{"label": "influx", "port": float64(18086), "reach": "public"}},
|
||||
"log": "warn",
|
||||
}
|
||||
among := []Layer{{From: "anchor", Values: said}}
|
||||
if e, err := Exposure(m, among); err != nil || e != nil {
|
||||
t.Fatalf("an expose among the config values exposed %v (%v)", e, err)
|
||||
}
|
||||
if g, err := GivenPorts(m, among); err != nil || g != nil {
|
||||
t.Fatalf("a ports among the config values gave %v (%v)", g, err)
|
||||
}
|
||||
if r, err := Reaches(m, among); err != nil || r != nil {
|
||||
t.Fatalf("a reach among the config values reached %v (%v)", r, err)
|
||||
}
|
||||
if b, err := Endpoints(m, among); err != nil || len(b) != 0 {
|
||||
t.Fatalf("an endpoints among the config values configured %v (%v)", b, err)
|
||||
}
|
||||
|
||||
beside := []Layer{LayerOf("anchor", said)}
|
||||
if len(beside[0].Values) != 1 || beside[0].Values["log"] != "warn" || len(beside[0].Placement) != 4 {
|
||||
t.Fatalf("LayerOf split %+v", beside[0])
|
||||
}
|
||||
if e, err := Exposure(m, beside); err != nil || e[22] != FromEverywhere {
|
||||
t.Fatalf("the placement did not expose: %v (%v)", e, err)
|
||||
}
|
||||
if g, err := GivenPorts(m, beside); err != nil || g[22] != 2222 || g[8086] != 18086 {
|
||||
t.Fatalf("the placement did not give the ports: %v (%v)", g, err)
|
||||
}
|
||||
if r, err := Reaches(m, beside); err != nil || r[3000] != "internal" || r[8086] != "public" {
|
||||
t.Fatalf("the placement did not reach: %v (%v)", r, err)
|
||||
}
|
||||
if b, err := Endpoints(m, beside); err != nil || b["api"].Label != "influx" {
|
||||
t.Fatalf("the placement did not configure the endpoint: %v (%v)", b, err)
|
||||
}
|
||||
|
||||
file := map[string]any{"id": "runtime", "type": "file", "path": "/x", "content": `{"a":1}`, "merge": "json"}
|
||||
whole, err := ApplySettings(file, among)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
split, err := ApplySettings(file, beside)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if whole["content"] != split["content"] || !strings.Contains(split["content"].(string), `"endpoints"`) {
|
||||
t.Fatalf("today's mergeable file composes %q, was %q", split["content"], whole["content"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,43 +8,6 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Telling a module which port it was given.
|
||||
//
|
||||
// **The mesh assigns the machine-side port and a module does not choose one**
|
||||
// ([ADR 0038](../../02-DECISIONS/0038-the-mesh-assigns-the-port.md)); on a node given one for a
|
||||
// module it is the operator's number rather than the mesh's
|
||||
// ([ADR 0100](../../02-DECISIONS/0100-a-node-in-use-is-adopted-before-it-is-converged.md)). For a
|
||||
// container's own listening socket that is invisible: the mesh rewrites `ports` into
|
||||
// `assigned:wanted`, the software inside binds the number it has always bound, and the machine
|
||||
// publishes a different one.
|
||||
//
|
||||
// **Two kinds of resource have no such layer.**
|
||||
//
|
||||
// - **A process** runs on the machine, there is nothing to rewrite, and it binds whatever its
|
||||
// configuration says — so without this, every process binds the number written in its own
|
||||
// config, two modules declaring the same one collide, and the mesh's whole reason for
|
||||
// assigning ports is defeated by the resource kind that most needs it.
|
||||
// - **A container that DIALS the machine** — a module's own sidecar reaching the service beside
|
||||
// it over the machine's loopback — is told that address in its environment, and the mapping
|
||||
// that saves the listener does nothing for the caller: what it must dial is the machine-side
|
||||
// number, which is exactly the one the module cannot know (novox/hq 04-ISSUES/088).
|
||||
//
|
||||
// So a module asks. `${port:8080}` is "the machine-side port you gave me for the 8080 I said I
|
||||
// listen on", and the module writes that where it would otherwise have written a literal — in a
|
||||
// file's content, or in a value of a container's or a process's `env`.
|
||||
//
|
||||
// **The environment is filled by the control plane, exactly as a bound value is.** A port is not
|
||||
// secret — the mesh holds it in the clear — so there is nothing for the host to be the only
|
||||
// witness of, and the host learns no new field. That is what separates this from
|
||||
// [ADR 0086](../../02-DECISIONS/0086-a-secret-reaches-a-process-as-a-file.md), which refuses a
|
||||
// `${secret:…}` in an `env` outright: the objection there is to the value being in an environment
|
||||
// at all, not to who fills it in.
|
||||
//
|
||||
// **It answers with the machine's number, wherever it is written.** A container reaching a sibling
|
||||
// over the runtime's own network reaches it on the port inside that container and goes on writing
|
||||
// that number literally — it is a number the module does control. This is for the machine side,
|
||||
// which is the side nobody but the mesh can know.
|
||||
|
||||
// ofPort is where a module asks which port it was given: ${port:<the port its software uses>}.
|
||||
var ofPort = regexp.MustCompile(`\$\{port:([0-9]+)\}`)
|
||||
|
||||
|
||||
@@ -13,27 +13,9 @@ import (
|
||||
"sync"
|
||||
)
|
||||
|
||||
// The list of predefined keys (novox/hq ADR 0348 §7, design 57 package K2, issue 661).
|
||||
//
|
||||
// **A fixed, reviewable list, owned by the build.** Mesh config and node config hold values only for the keys
|
||||
// in the catalogue's `predefined.json`, at the root of its repository: each key with its type, a description
|
||||
// including its unit, its validation (enum, minimum and maximum, pattern) and whether it is trusted. A module's
|
||||
// value may fall back to one of them, of the same type (ADR 0348 §2, §4).
|
||||
//
|
||||
// **Read where a manifest is judged, from the tree it is judged in.** A build reads the file at the root of the
|
||||
// repository at the commit it builds, `module check` at the root of the repository above each manifest, the
|
||||
// merge gate at the root of the tree under check; each refuses a file that is not exactly a list of keys, and
|
||||
// judges every manifest it reads against it (ParseManifestWith). Everywhere else — registration, a stored build
|
||||
// read again — a manifest is judged against the list the mesh published (PublishedPredefined): the newest trunk
|
||||
// build's of the catalogue, which the controller keeps and loads with its store.
|
||||
|
||||
// PredefinedFile is the list's file at the root of the catalogue's repository.
|
||||
const PredefinedFile = "predefined.json"
|
||||
|
||||
// CatalogueRepository is the name of the repository whose list the mesh publishes: the catalogue's, by the name
|
||||
// a check finds it beside under (internal/builder's check, ADR 0121).
|
||||
const CatalogueRepository = "mesh-catalog"
|
||||
|
||||
// PredefinedKey is one key of the list: its type, its description, its validation and whether it is trusted
|
||||
// (ADR 0348 §7). It has no default: a module's value falling back to it has its own.
|
||||
type PredefinedKey struct {
|
||||
@@ -142,45 +124,6 @@ func ReadPredefined(root string) (PredefinedList, error) {
|
||||
return ParsePredefined(raw)
|
||||
}
|
||||
|
||||
// RepositoryRoot is the nearest directory at or above dir holding `.git`: the root of the repository a manifest
|
||||
// is in, where `module check` reads the list. Empty when none is: a manifest outside a repository is judged
|
||||
// against no list.
|
||||
func RepositoryRoot(dir string) string {
|
||||
at, err := filepath.Abs(dir)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
for {
|
||||
if _, err := os.Lstat(filepath.Join(at, ".git")); err == nil {
|
||||
return at
|
||||
}
|
||||
up := filepath.Dir(at)
|
||||
if up == at {
|
||||
return ""
|
||||
}
|
||||
at = up
|
||||
}
|
||||
}
|
||||
|
||||
// PredefinedAbove is the list at the root of the repository above dir (RepositoryRoot), nil when there is no
|
||||
// repository or it holds no list.
|
||||
func PredefinedAbove(dir string) (PredefinedList, error) {
|
||||
root := RepositoryRoot(dir)
|
||||
if root == "" {
|
||||
return nil, nil
|
||||
}
|
||||
return ReadPredefined(root)
|
||||
}
|
||||
|
||||
// Canonical is the list as JSON with its keys sorted: what a build carries and the controller keeps, so one list
|
||||
// is one text.
|
||||
func (l PredefinedList) Canonical() ([]byte, error) {
|
||||
if l == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return json.Marshal(map[string]PredefinedKey(l))
|
||||
}
|
||||
|
||||
// Keys is the list's keys, sorted.
|
||||
func (l PredefinedList) Keys() []string {
|
||||
out := make([]string, 0, len(l))
|
||||
@@ -191,34 +134,12 @@ func (l PredefinedList) Keys() []string {
|
||||
return out
|
||||
}
|
||||
|
||||
// NamesPredefined is whether any value of the manifest falls back to a predefined key.
|
||||
func (m Manifest) NamesPredefined() bool {
|
||||
for _, d := range m.Settings {
|
||||
if d.Predefined != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// publishedPredefined is the list the mesh published, which a manifest is judged against where no tree is read.
|
||||
var publishedPredefined struct {
|
||||
sync.RWMutex
|
||||
list PredefinedList
|
||||
}
|
||||
|
||||
// UsePredefined makes list the one a manifest is judged against where no tree is read: what the controller
|
||||
// loads from its store, and what it keeps when a newer trunk build of the catalogue publishes one. Nil leaves
|
||||
// the list it has: a store that holds none yet never empties one already loaded.
|
||||
func UsePredefined(list PredefinedList) {
|
||||
if list == nil {
|
||||
return
|
||||
}
|
||||
publishedPredefined.Lock()
|
||||
defer publishedPredefined.Unlock()
|
||||
publishedPredefined.list = list
|
||||
}
|
||||
|
||||
// PublishedPredefined is the list a manifest is judged against where no tree is read; nil when none is loaded.
|
||||
func PublishedPredefined() PredefinedList {
|
||||
publishedPredefined.RLock()
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
package catalogue
|
||||
@@ -17,29 +17,6 @@ const theFirstKeys = `{
|
||||
"agent-managed-settings": {"type": "object", "description": "the managed settings of every agent session, a JSON object", "trusted": true}
|
||||
}`
|
||||
|
||||
func TestTheFirstListIsRead(t *testing.T) {
|
||||
list, err := ParsePredefined([]byte(theFirstKeys))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := strings.Join(list.Keys(), ","); got != "agent-managed-settings,font-size" {
|
||||
t.Fatalf("keys %s", got)
|
||||
}
|
||||
if k := list["font-size"]; k.Type != ConfigNumber || *k.Minimum != 6 || *k.Maximum != 72 || k.Trusted {
|
||||
t.Fatalf("font-size read as %+v", k)
|
||||
}
|
||||
if k := list["agent-managed-settings"]; k.Type != ConfigObject || !k.Trusted {
|
||||
t.Fatalf("agent-managed-settings read as %+v", k)
|
||||
}
|
||||
// One list is one text, however it was written.
|
||||
a, _ := list.Canonical()
|
||||
again, _ := ParsePredefined(a)
|
||||
b, _ := again.Canonical()
|
||||
if string(a) != string(b) {
|
||||
t.Fatalf("canonical text moved:\n%s\n%s", a, b)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAListIsRefusedWholeForAnyWrongKey(t *testing.T) {
|
||||
for _, c := range []struct{ file, says string }{
|
||||
{`[]`, "not a JSON object of keys"},
|
||||
@@ -103,42 +80,3 @@ func TestTemplatesFilledKeepTheListTheManifestWasJudgedAgainst(t *testing.T) {
|
||||
t.Fatalf("filled from the tree, it was judged against another list: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// module check reads the list at the root of the repository above the manifest; no repository, no list.
|
||||
func TestTheListIsFoundAtTheRepositoryRootAboveAManifest(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
module := filepath.Join(root, "modules", "dunst")
|
||||
if err := os.MkdirAll(module, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if list, err := PredefinedAbove(module); err != nil || list != nil {
|
||||
t.Fatalf("outside a repository: %v %v", list, err)
|
||||
}
|
||||
if err := os.Mkdir(filepath.Join(root, ".git"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if list, err := PredefinedAbove(module); err != nil || list != nil {
|
||||
t.Fatalf("a repository without the file: %v %v", list, err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, PredefinedFile), []byte(theFirstKeys), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
list, err := PredefinedAbove(module)
|
||||
if err != nil || len(list) != 2 {
|
||||
t.Fatalf("the list above: %v %v", list, err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, PredefinedFile), []byte(`{"font-size": {}}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := PredefinedAbove(module); err == nil {
|
||||
t.Fatal("a wrong list above was read")
|
||||
}
|
||||
if err := os.Remove(filepath.Join(root, PredefinedFile)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink("/etc/hostname", filepath.Join(root, PredefinedFile)); err == nil {
|
||||
if _, err := PredefinedAbove(module); err == nil || !strings.Contains(err.Error(), "not a plain file") {
|
||||
t.Fatalf("a link read as the list: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPrintRehearsalRuleset(t *testing.T) {
|
||||
if os.Getenv("PRINT_RULESET") == "" {
|
||||
t.Skip("set PRINT_RULESET")
|
||||
}
|
||||
rules := mustFilter(t, Resolution{Modules: []Manifest{
|
||||
{Module: "pub", Listens: []Listening{{Port: 8099, From: FromMesh, Why: "the thing it serves"}}},
|
||||
}}, nil)
|
||||
t.Log("\n" + AsNftables(rules, []string{"192.0.2.20"}, true, nil, nil, "mesh0"))
|
||||
}
|
||||
@@ -8,20 +8,6 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// A node's files and accounts, read (novox/hq ADR 0310, extending ADR 0268 §5; hq to-be 41 WP8).
|
||||
//
|
||||
// **A seat of its own on every node.** Reading a file, searching one, listing a directory, the owner and mode
|
||||
// of each component of a path, and looking up an account or a group are the reads ADR 0268 §5 has narrow tools
|
||||
// take over from the login shell's `execute`, so an approval is asked for an act and never for a read. Every
|
||||
// node needs them, the control-node included, and another module could offer them instead of the tool
|
||||
// runner's (ADR 0126 §9): a seat, so the mesh finds them on every node by one name.
|
||||
//
|
||||
// **Read as an account that holds no secret.** The holder runs as an account of its own (runs-as, ADR 0259
|
||||
// §8), so the node's file modes bound what it sees — never the operator's account, which sees the
|
||||
// operator's keys and the agent's credentials. On top, it refuses by name the places a secret is kept,
|
||||
// which the controller names for it (secretPlaces), and every answer passes the content rule's secret class
|
||||
// (mesh-sdk's outward, ADR 0310 §4).
|
||||
|
||||
// ReadsSeat is the role that reads a node's files and accounts (novox/hq ADR 0310).
|
||||
const ReadsSeat = "node-reads"
|
||||
|
||||
|
||||
@@ -8,15 +8,6 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// What a move does to a module's containers (novox/hq ADR 0245).
|
||||
//
|
||||
// A container whose declaration changes is recreated, whatever changed in it: an image, an environment
|
||||
// variable, a health check adopted. A module whose containers are recreated in one send is down while
|
||||
// they start again — on 2026-10-07 a catalogue change that only adopted the images' own health checks
|
||||
// recreated nine of mail's containers at once, and the operator's phone could not reach the mail. So a
|
||||
// send says, per module, how many of its containers it recreates and whether any image changed, before
|
||||
// it is sent and in the plan that sent it.
|
||||
|
||||
// Recreation is what moving a module from one build's manifest to another's does to its containers.
|
||||
type Recreation struct {
|
||||
// Containers is how many containers the new build declares.
|
||||
@@ -110,15 +101,6 @@ func sameDeclaration(a, b any) bool {
|
||||
return reflect.DeepEqual(na, nb)
|
||||
}
|
||||
|
||||
// What a move under a module's own `record` may do without a person (novox/hq ADR 0377).
|
||||
//
|
||||
// A module records because its manifest says so or because it keeps irreplaceable data: mail, whose containers
|
||||
// recreated together interrupt everyone's mail, and the database server, whose restart drops every consumer. That
|
||||
// hold also held the moves that interrupt nothing — on 2026-10-15 the database server moved with only its tools
|
||||
// bundle changed, its walk said it "recreates none of its containers", and the machine stayed behind until a
|
||||
// person pushed. So such a move rolls out like any other, and only a move that recreates a container or touches
|
||||
// data still waits.
|
||||
|
||||
// Holds is why a move from one build's manifest to another's still waits for a person under a module's own
|
||||
// `record` (ADR 0377 §3), empty when it recreates none of its containers and moves none of its data: no container
|
||||
// changed, added or gone; no file or directory changed, added or gone that a container mounts, or that holds or
|
||||
|
||||
@@ -1,18 +1,11 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Resolving is turning "these modules are assigned here" into "this is what the node runs".
|
||||
//
|
||||
// It refuses rather than guesses, everywhere. novox/hq ADR 0009: a requirement with several
|
||||
// answers is refused and named, because counting candidates has no surprising behaviour and a
|
||||
// solver that picks has to be understood before its answer can be trusted.
|
||||
|
||||
// Node is what resolution needs to know about the machine.
|
||||
type Node struct {
|
||||
Name string
|
||||
@@ -279,9 +272,6 @@ func (r *Refusal) Error() string {
|
||||
return "these assignments cannot be applied:\n - " + strings.Join(r.Problems, "\n - ")
|
||||
}
|
||||
|
||||
// ErrAmbiguous is returned inside a Refusal when a requirement has more than one answer.
|
||||
var ErrAmbiguous = errors.New("more than one module provides that")
|
||||
|
||||
// Resolve works out everything a node runs, from what was assigned to it.
|
||||
//
|
||||
// The catalogue is every module the mesh knows about; assigned is what a person put on this node.
|
||||
@@ -877,21 +867,6 @@ func Resolve(catalogue map[string]Manifest, assigned []string, node Node, world
|
||||
return resolution, nil
|
||||
}
|
||||
|
||||
// servedHere is *whether* a provider in this node's own set says a consumer needs to know
|
||||
// anything, and provisionally what.
|
||||
//
|
||||
// **Provisional, and deliberately so.** Resolving runs before a machine's ports are assigned and
|
||||
// before any settings are in view, so nothing said here can be the final answer: it is the module's
|
||||
// manifest and nothing else. What reaches the consumer is re-derived from the resolved closure by
|
||||
// Declaration (servedOnThisMachine), with this machine's port assignments and the provider's
|
||||
// settings layers — the same two steps the mesh applies to a provider on any other machine.
|
||||
//
|
||||
// It still matters that this is right about *emptiness*: a keyless same-node provider is only
|
||||
// delivered as a need at all when it serves something (novox/hq 04-ISSUES/038's sibling), and a
|
||||
// need that is never created is a binding the consumer never gets. It is right about that from the
|
||||
// manifest alone, which is why walking the catalogue mid-resolution is enough here and is not
|
||||
// enough for the values.
|
||||
|
||||
// isModule reports whether a name is a module in its own right rather than only something
|
||||
// modules provide.
|
||||
//
|
||||
|
||||
@@ -8,24 +8,6 @@ import (
|
||||
"text/template"
|
||||
)
|
||||
|
||||
// What only the mesh knows, written where a module asks for it — in the module's own format.
|
||||
//
|
||||
// **The graph is the control plane's; the format is the module's.** The mesh knows which machines
|
||||
// exist, what they are called and where they are. Turning that into a hosts file, a resolver's
|
||||
// zones, an ssh known_hosts is somebody's configuration language, and the mesh has no business
|
||||
// knowing it. So the mesh hands the roster to a template the module wrote and renders it; it never
|
||||
// learns what the file means.
|
||||
//
|
||||
// This used to be a closed list of fact names, each with its format written in Go here — a hosts
|
||||
// file, a resolver's zones. Every new consumer meant a new formatter in the control plane, in the
|
||||
// consumer's configuration language. Now the data is the mesh's and the format is a template the
|
||||
// module ships: the two built-in cases (the network module's `/etc/hosts`, dnsmasq's zones) render
|
||||
// the same way any module's would, and the control plane holds no format at all.
|
||||
//
|
||||
// It replaced three modules that existed only because computed output needed somewhere to live —
|
||||
// they ran no software, could not be swapped for anything, and appeared in the graph as modules
|
||||
// while being a data channel wearing a costume (novox/hq ADR 0040).
|
||||
|
||||
// A RosterFile is a file the mesh renders from the roster of machines, in the format the module
|
||||
// gives as a Go text/template. The template sees a rosterView: `.Node` (this machine's bare name),
|
||||
// `.Suffix` (what its mesh name ends in), and two sets of `{Name, FQDN, Address}` — `.Names` and
|
||||
|
||||
@@ -7,14 +7,6 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// The node's tool runtime, as the catalogue knows it (novox/hq ADR 0175, to-be 38).
|
||||
//
|
||||
// **One module is the runtime.** Where it is assigned, one process per machine serves every assigned
|
||||
// module's tools and every held seat's verbs, on the host side, from the bundles each module's build
|
||||
// produced — and no module needs a container to reach the bus with its tools. The name is a constant
|
||||
// rather than a manifest field because a rule turns on it: the composer places the runtime's process
|
||||
// where this module is, and registration refuses the old pattern once this module exists.
|
||||
|
||||
// RuntimeModule is the module that is the node's tool runtime. Mirrored in the broker package,
|
||||
// which composes a principal of its own for it; the agreement test there holds the two to one string.
|
||||
const RuntimeModule = "node-tools"
|
||||
@@ -377,60 +369,6 @@ func toAny(in []string) []any {
|
||||
return out
|
||||
}
|
||||
|
||||
// RuntimeImageModule and RuntimeImageArtifact name the image every per-module tool container was
|
||||
// built on: the tool runtime's own runtime image. With the runtime a module of its own, that image
|
||||
// stays the way a module's SERVICE may be built and stops being the way tools reach a node (ADR 0175).
|
||||
const (
|
||||
RuntimeImageModule = "mesh-tools"
|
||||
RuntimeImageArtifact = "runtime"
|
||||
)
|
||||
|
||||
// ToolContainerOnTheRuntime says why a manifest is the pattern ADR 0175 retires — a module whose tools
|
||||
// are served from a container built on the tool runtime's image — or nothing when it is not. Judged
|
||||
// from the manifest's own `build.on` when it is a repository manifest, and from what its build stood
|
||||
// on when it is a built one, because a resolved manifest carries no build. The gate itself is
|
||||
// registration's (to-be 38 WP2.4): once the runtime module is in the catalogue, this is refused.
|
||||
//
|
||||
// Three things must hold, and each alone is fine: declaring tools (a bundle does that); a container
|
||||
// (a module's service may well be one); building on the runtime's image (a service written against
|
||||
// the SDK may). All three is a container whose purpose is tools, which the runtime now serves.
|
||||
func ToolContainerOnTheRuntime(m Manifest, against []string) string {
|
||||
if len(m.Tools) == 0 {
|
||||
return ""
|
||||
}
|
||||
container := false
|
||||
for _, r := range m.Resources {
|
||||
if fmt.Sprint(r["type"]) == "container" {
|
||||
container = true
|
||||
}
|
||||
}
|
||||
if !container {
|
||||
return ""
|
||||
}
|
||||
onTheRuntime := false
|
||||
if m.Build != nil {
|
||||
for _, on := range m.Build.On {
|
||||
if on.Module == RuntimeImageModule && on.Artifact == RuntimeImageArtifact {
|
||||
onTheRuntime = true
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, ref := range against {
|
||||
path, kept := InArtifactStore(Recorded(ref))
|
||||
if kept && strings.HasPrefix(path, RuntimeImageModule+"/"+RuntimeImageArtifact+"@") {
|
||||
onTheRuntime = true
|
||||
}
|
||||
}
|
||||
if !onTheRuntime {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf(
|
||||
"%s declares tools and a container built on %s's %s image — a container whose purpose is "+
|
||||
"serving tools. The node's tool runtime (%s) serves every module's tools from its bundle "+
|
||||
"now (novox/hq ADR 0175, to-be 38); declare the tools as a bundle and drop the container",
|
||||
m.Module, RuntimeImageModule, RuntimeImageArtifact, RuntimeModule)
|
||||
}
|
||||
|
||||
// bundleWords is what one module's tools bundles are given on this machine (novox/hq ADR 0192):
|
||||
// each loaded bundle's env, its ${dir:…} resolved to where this machine places the module's
|
||||
// directories and its ${port:…} to the port this machine gave it — the same resolution a
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The packet-filter manifest as it was the day the runtime was decided (novox/hq ADR 0175): tools,
|
||||
// served from a container built on the tool runtime's image, with NET_ADMIN so the container could
|
||||
// reach the filter. The exact pattern to-be 38 WP4 moves it off, and the one the gate refuses.
|
||||
@@ -37,52 +32,3 @@ const thePacketFilterAsItWas = `{
|
||||
"artifacts": [{"name": "runtime", "kind": "image", "from": "Dockerfile"}]
|
||||
}
|
||||
}`
|
||||
|
||||
func TestAToolContainerOnTheRuntimeImageIsNamedForWhatItIs(t *testing.T) {
|
||||
m, err := decodeForTest([]byte(thePacketFilterAsItWas))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// From the repository: the manifest says what it builds on.
|
||||
why := ToolContainerOnTheRuntime(m, nil)
|
||||
if why == "" {
|
||||
t.Fatal("the packet filter's tool container was not recognised from its build")
|
||||
}
|
||||
for _, word := range []string{"nftables", "mesh-tools", "runtime", "ADR 0175", "bundle"} {
|
||||
if !strings.Contains(why, word) {
|
||||
t.Errorf("the refusal does not say %q: %s", word, why)
|
||||
}
|
||||
}
|
||||
|
||||
// Built: the manifest carries no build, and what it stood on says the same.
|
||||
built, err := m.Resolve([]Built{{Name: "runtime", Kind: ArtifactImage,
|
||||
Reference: ArtifactStoreScheme + "nftables/runtime@" + digest}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stoodOn := []string{"anchor.internal:5100/mesh-tools/build@" + digest, "anchor.internal:5100/mesh-tools/runtime@" + digest}
|
||||
if ToolContainerOnTheRuntime(built, stoodOn) == "" {
|
||||
t.Error("the packet filter's tool container was not recognised from what its build stood on")
|
||||
}
|
||||
if ToolContainerOnTheRuntime(built, nil) != "" {
|
||||
t.Error("a built manifest with no record of its base was judged to be on the runtime")
|
||||
}
|
||||
|
||||
// Each of the three alone is an ordinary module.
|
||||
bundle := m
|
||||
bundle.Resources = m.Resources[:len(m.Resources)-1]
|
||||
if ToolContainerOnTheRuntime(bundle, nil) != "" {
|
||||
t.Error("a module with tools and no container is the pattern the runtime serves, and was refused")
|
||||
}
|
||||
service := m
|
||||
service.Tools = nil
|
||||
if ToolContainerOnTheRuntime(service, nil) != "" {
|
||||
t.Error("a service built against the SDK, declaring no tools, was refused")
|
||||
}
|
||||
elsewhere := m
|
||||
elsewhere.Build = &Build{On: []BuildsOn{{Arg: "NODE_BASE", Image: "node@" + digest}},
|
||||
Artifacts: m.Build.Artifacts}
|
||||
if ToolContainerOnTheRuntime(elsewhere, nil) != "" {
|
||||
t.Error("a tool container on a public base was refused as though it were on the runtime's")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,14 +6,6 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// A module contributes to a seat it does not hold (novox/hq ADR 0212).
|
||||
//
|
||||
// ADR 0210 made a tool's configuration its seat holder's, and every other module's way in a
|
||||
// contribution to the seat. The environment, the shell's slots and the power moments each became a
|
||||
// field of their own; this is the general form, so a new seat that takes contributions is a row in
|
||||
// the seat table rather than a change to the manifest: a contribution names a seat, a kind that
|
||||
// seat receives, and text in the tool's own grammar, which the controller never reads.
|
||||
|
||||
// HotkeysSeat is the machine's hotkey daemon (novox/hq ADR 0212 §5).
|
||||
const HotkeysSeat = "node-hotkeys"
|
||||
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -64,21 +62,6 @@ func TestNoContributionPlacesNothing(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAContributionDependsOnItsSeat(t *testing.T) {
|
||||
m := Manifest{Module: "laptop", Contributions: []SeatContribution{
|
||||
{Seat: HotkeysSeat, Kind: "trigger", Content: "x"},
|
||||
{Seat: DisplaySessionSeat, Kind: "config", Content: "y"},
|
||||
}}
|
||||
if got, want := DependsOn(nil, m), []string{DisplaySessionSeat, HotkeysSeat}; !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("depends on %v, want %v", got, want)
|
||||
}
|
||||
catalogue := map[string]Manifest{"laptop": m, "triggerhappy": hotkeysHolder()}
|
||||
if _, err := AssignRefusal(catalogue, "laptop", nil, []string{"laptop"}); err == nil ||
|
||||
!strings.Contains(err.Error(), HotkeysSeat) {
|
||||
t.Errorf("a contributor without the holder was accepted: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheHotkeysSeatIsTheMeshsAndReceivesTriggers(t *testing.T) {
|
||||
s, ok := SeatNamed(HotkeysSeat)
|
||||
if !ok || s.Scope != ScopeNode || len(s.Receives) != 1 || s.Receives[0].Kind != "trigger" {
|
||||
|
||||
@@ -11,21 +11,6 @@ import (
|
||||
"text/template"
|
||||
)
|
||||
|
||||
// A kind a seat receives as data, rendered by its holder (novox/hq ADR 0255).
|
||||
//
|
||||
// ADR 0212 made a contribution text in the tool's own grammar, which the controller places and never
|
||||
// reads. That is right where the tool is the seat's for good — a hotkey daemon's trigger lines — and
|
||||
// wrong where the seat's whole point is that its tool can be replaced: a module adding a battery to the
|
||||
// bar would write i3status-rust's TOML, and a second bar would have to read it. So a seat may define a
|
||||
// kind as data instead. The seat says the data's shape; a contributor gives data in that shape; the
|
||||
// holder gives a template that renders one piece of it into its tool's grammar, as a module's facts
|
||||
// template renders the roster (`facts`): the data is the mesh's, the format is the holder's.
|
||||
//
|
||||
// What the holder places is narrowed by the shape's placing fields — the bar's `bar` and `place` — in
|
||||
// the placeholder, `${contribution:<seat>:<kind>:<field>=<value>,…}`, and every combination of them is
|
||||
// placed by the holder exactly once, so nothing a module contributes is dropped by a holder that forgot
|
||||
// a corner of the shape.
|
||||
|
||||
// Shape is what a kind received as data looks like.
|
||||
type Shape struct {
|
||||
// Places are the fields a holder narrows its placeholders by, each with every value it takes.
|
||||
|
||||
@@ -6,20 +6,6 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// A module depends on the node seats that apply its resources (novox/hq ADR 0207), on the
|
||||
// seats it contributes to (novox/hq ADR 0210), and on the seats a seat it holds needs beside it
|
||||
// (novox/hq ADR 0220). The third source once served only compiled seats and had one user,
|
||||
// node-resolver-config needing node-uplink, which went with that seat when the resolver file became
|
||||
// the uplink's own (novox/hq ADR 0223) — and now serves module-declared seats instead: mail-notifier
|
||||
// needing node-notifier, and the like (novox/hq ADR 0378 §8).
|
||||
//
|
||||
// Some of what a module declares is applied through software on the machine that is itself a
|
||||
// module: a service through the service manager, a package through the package manager, a container
|
||||
// through the container runtime. A *capability* only says that software is installed; it does not
|
||||
// say that a module of the mesh holds the role and answers for it. So the dependency is derived from
|
||||
// the resources — never stated in a manifest, because a module that adds a service and forgets a
|
||||
// field would pass — and is met when some module assigned to the same node holds the seat.
|
||||
|
||||
// The three seats that apply resources (novox/hq ADR 0207 §1). Named once, because the derivation,
|
||||
// the seed and the messages all turn on these strings.
|
||||
const (
|
||||
@@ -50,14 +36,6 @@ var appliedThrough = map[string]string{
|
||||
// A variable rather than a constant only so a test can hold both behaviours; nothing else sets it.
|
||||
var enforceSeatDependencies = true
|
||||
|
||||
// EnforcingSeatDependencies sets the switch and returns what puts it back. For tests in other
|
||||
// packages that hold the behaviour from before the switch; nothing else calls it.
|
||||
func EnforcingSeatDependencies(on bool) (restore func()) {
|
||||
was := enforceSeatDependencies
|
||||
enforceSeatDependencies = on
|
||||
return func() { enforceSeatDependencies = was }
|
||||
}
|
||||
|
||||
// foundationModules are the pieces genesis lays before any module exists (novox/hq ADR 0207 §5):
|
||||
// the host and the private network. Registered as modules so they can be assigned, but what they
|
||||
// declare is the installation's, not a module's, so it is never judged. The third piece, the
|
||||
@@ -265,128 +243,3 @@ func nodeSeatsClaimed(m Manifest) []string {
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// manifestsOf is the catalogue's definitions of the named modules; a name the catalogue does not
|
||||
// know contributes nothing, as it does to a resolution.
|
||||
func manifestsOf(catalogue map[string]Manifest, names []string) []Manifest {
|
||||
var out []Manifest
|
||||
seen := map[string]bool{}
|
||||
for _, n := range names {
|
||||
if m, known := catalogue[n]; known && !seen[n] {
|
||||
seen[n] = true
|
||||
out = append(out, m)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// AssignRefusal is why assigning `adding` to a node already running `assigned` is refused, or
|
||||
// nothing (novox/hq ADR 0207 §4): each new module's dependency the node's assignments, the new ones
|
||||
// included, leave unmet.
|
||||
//
|
||||
// **Only the new modules are judged.** A node already short of a holder is reported by `status`;
|
||||
// refusing an unrelated assignment for it would make the remedy — assigning the holder — refused too.
|
||||
//
|
||||
// **A dependency nothing in the catalogue can meet is said, not refused.** A refusal names the
|
||||
// module that would meet it; with none registered there is no remedy to name, and refusing would
|
||||
// stop every assignment of that kind until a module that does not exist yet is written. The answer
|
||||
// still says it, and `status` reports it — before the switch and after it alike: there is never a
|
||||
// remedy to name for it. The first return is those lines.
|
||||
func AssignRefusal(catalogue map[string]Manifest, node string, assigned, adding []string) ([]string, error) {
|
||||
set := manifestsOf(catalogue, append(append([]string(nil), assigned...), adding...))
|
||||
judged := map[string]bool{}
|
||||
for _, a := range adding {
|
||||
judged[a] = true
|
||||
}
|
||||
var refused, said []string
|
||||
for _, u := range UnheldDependencies(catalogue, node, set, judged) {
|
||||
if len(u.Holders) == 0 {
|
||||
said = append(said, u.String())
|
||||
continue
|
||||
}
|
||||
refused = append(refused, u.String())
|
||||
}
|
||||
if len(refused) > 0 {
|
||||
return said, &Refusal{Problems: append(refused,
|
||||
fmt.Sprintf("holders that depend on each other are assigned together: `assign %s <module> <module>…`", node))}
|
||||
}
|
||||
return said, nil
|
||||
}
|
||||
|
||||
// UnassignRefusal is why taking `removing` off a node running `assigned` is refused, or nothing
|
||||
// (novox/hq ADR 0207): a seat the removed modules hold that nothing left on the node holds, while
|
||||
// a module left there depends on it. Names the dependents, because they are what must go first —
|
||||
// or the holder's replacement come.
|
||||
func UnassignRefusal(catalogue map[string]Manifest, node string, assigned, removing []string) error {
|
||||
gone := map[string]bool{}
|
||||
for _, r := range removing {
|
||||
gone[r] = true
|
||||
}
|
||||
var left []string
|
||||
for _, a := range assigned {
|
||||
if !gone[a] {
|
||||
left = append(left, a)
|
||||
}
|
||||
}
|
||||
before := map[string]bool{}
|
||||
for _, u := range UnheldDependencies(catalogue, node, manifestsOf(catalogue, assigned), nil) {
|
||||
before[u.Module+"\x00"+u.Seat] = true
|
||||
}
|
||||
dependents := map[string][]string{}
|
||||
for _, u := range UnheldDependencies(catalogue, node, manifestsOf(catalogue, left), nil) {
|
||||
if before[u.Module+"\x00"+u.Seat] {
|
||||
continue // unmet already; not this removal's doing
|
||||
}
|
||||
dependents[u.Seat] = append(dependents[u.Seat], u.Module)
|
||||
}
|
||||
if len(dependents) == 0 {
|
||||
return nil
|
||||
}
|
||||
seats := make([]string, 0, len(dependents))
|
||||
for s := range dependents {
|
||||
seats = append(seats, s)
|
||||
}
|
||||
sort.Strings(seats)
|
||||
var problems []string
|
||||
for _, s := range seats {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s holds %s on %s, and %s depend on it (novox/hq ADR 0207) — unassign them with it, "+
|
||||
"or assign another holder first", strings.Join(removing, ", "), s, node,
|
||||
strings.Join(dependents[s], ", ")))
|
||||
}
|
||||
return &Refusal{Problems: problems}
|
||||
}
|
||||
|
||||
// CollisionRefusal is why assigning `adding` beside `assigned` is refused for what two modules
|
||||
// would both declare, or nothing (novox/hq ADR 0210 §1, 04-ISSUES/235): one path, unit or name, or a package one
|
||||
// declares present and another absent. A package several modules declare present is no collision (ADR 0303).
|
||||
//
|
||||
// **Refused, not kept like an unresolved provision.** An assignment is otherwise kept when the
|
||||
// node does not resolve, because assignment is not an ordering: a consumer's provider can follow.
|
||||
// A collision is not an order anything can complete — no further assignment makes two owners of one
|
||||
// file one owner — and kept, it leaves the node unresolvable, so the next push of anything drops
|
||||
// it from the mesh. Only collisions involving a module being added are refused; one already on the
|
||||
// node is `status`'s, and refusing an unrelated assignment for it would block its own remedy.
|
||||
func CollisionRefusal(catalogue map[string]Manifest, node string, assigned, adding []string) error {
|
||||
before := map[string]bool{}
|
||||
for _, p := range checkResources(manifestsOf(catalogue, assigned)) {
|
||||
before[p] = true
|
||||
}
|
||||
var problems []string
|
||||
for _, p := range checkResources(manifestsOf(catalogue, append(append([]string(nil), assigned...), adding...))) {
|
||||
if before[p] {
|
||||
continue // on the node already; not this assignment's doing
|
||||
}
|
||||
if strings.Contains(p, "ADR 0303") {
|
||||
problems = append(problems, p) // a package declared present and absent says its own rule
|
||||
continue
|
||||
}
|
||||
problems = append(problems, p+" (novox/hq ADR 0210: one owner per node; the other module "+
|
||||
"depends on the owner's seat instead)")
|
||||
}
|
||||
if len(problems) == 0 {
|
||||
return nil
|
||||
}
|
||||
sort.Strings(problems)
|
||||
return &Refusal{Problems: append(problems, fmt.Sprintf("nothing was assigned to %s", node))}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -85,86 +84,6 @@ func TestTheSeatsThatApplyResourcesAreTheMeshsOwnAtNodeScope(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestANodeWhoseAssignmentsHoldTheSeatsResolvesWithNothingUnheld(t *testing.T) {
|
||||
web := withResources(mod("web", nil, nil, nil), res("container", "web"), res("service", "web-timer"))
|
||||
cat := shelf(append(coreThree(), web)...)
|
||||
got, err := Resolve(cat, []string{"systemd", "pacman", "docker", "web"}, workstation(), World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got.Unheld) != 0 {
|
||||
t.Errorf("a node holding all three seats reports %v", got.Unheld)
|
||||
}
|
||||
if _, err := AssignRefusal(cat, "workstation", []string{"systemd", "pacman", "docker"}, []string{"web"}); err != nil {
|
||||
t.Errorf("assigning beside the three holders was refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnAssignmentMissingAHolderIsRefusedNamingTheSeatAndItsPossibleHolders(t *testing.T) {
|
||||
web := withResources(mod("web", nil, nil, nil), res("container", "web"), res("service", "web-timer"))
|
||||
cat := shelf(append(coreThree(), web)...)
|
||||
_, err := AssignRefusal(cat, "workstation", []string{"systemd", "pacman"}, []string{"web"})
|
||||
var refusal *Refusal
|
||||
if !errors.As(err, &refusal) {
|
||||
t.Fatalf("web assigned to a node without a container runtime was not refused: %v", err)
|
||||
}
|
||||
msg := err.Error()
|
||||
for _, want := range []string{"web on workstation depends on " + ContainerRuntimeSeat, "docker", "ADR 0207"} {
|
||||
if !strings.Contains(msg, want) {
|
||||
t.Errorf("the refusal does not say %q:\n%s", want, msg)
|
||||
}
|
||||
}
|
||||
// What the node does hold is not named as missing.
|
||||
if strings.Contains(msg, "depends on "+ServiceManagerSeat) {
|
||||
t.Errorf("the refusal names a seat systemd already holds:\n%s", msg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestADependencyNoCatalogueModuleCanMeetIsSaidNeverRefused(t *testing.T) {
|
||||
// No runtime module in the catalogue: refusing would stop every container's assignment until one
|
||||
// is written, with no remedy to name — so it is said, with the switch on as with it off.
|
||||
web := withResources(mod("web", nil, nil, nil), res("container", "web"))
|
||||
cat := shelf(web)
|
||||
for _, on := range []bool{false, true} {
|
||||
enforceSeatDependencies = on
|
||||
said, err := AssignRefusal(cat, "workstation", nil, []string{"web"})
|
||||
if err != nil {
|
||||
t.Fatalf("switch %v: a dependency nothing could meet was refused: %v", on, err)
|
||||
}
|
||||
if len(said) != 1 || !strings.Contains(said[0], "no module in the catalogue claims it yet") {
|
||||
t.Errorf("switch %v: the assignment does not say what it depends on: %v", on, said)
|
||||
}
|
||||
if _, err := Resolve(cat, []string{"web"}, workstation(), World{}); err != nil {
|
||||
t.Errorf("switch %v: a dependency nothing could meet refused the node: %v", on, err)
|
||||
}
|
||||
}
|
||||
enforceSeatDependencies = true
|
||||
}
|
||||
|
||||
func TestTheHoldersMutualDependenceResolvesWhenAssignedTogether(t *testing.T) {
|
||||
cat := shelf(coreThree()...)
|
||||
// Alone, each needs the other.
|
||||
if _, err := AssignRefusal(cat, "workstation", nil, []string{"systemd"}); err == nil ||
|
||||
!strings.Contains(err.Error(), "pacman") {
|
||||
t.Errorf("systemd alone was not refused naming pacman: %v", err)
|
||||
}
|
||||
if _, err := AssignRefusal(cat, "workstation", nil, []string{"pacman"}); err == nil ||
|
||||
!strings.Contains(err.Error(), "systemd") {
|
||||
t.Errorf("pacman alone was not refused naming systemd: %v", err)
|
||||
}
|
||||
// Together, in one act, they meet each other — and docker meets its own seat.
|
||||
if _, err := AssignRefusal(cat, "workstation", nil, []string{"systemd", "pacman", "docker"}); err != nil {
|
||||
t.Errorf("the three holders assigned together were refused: %v", err)
|
||||
}
|
||||
got, err := Resolve(cat, []string{"systemd", "pacman"}, workstation(), World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got.Unheld) != 0 {
|
||||
t.Errorf("systemd and pacman together report %v", got.Unheld)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusIsToldOfAnUnmetDependencyAndTheNodeStillResolves(t *testing.T) {
|
||||
// Before the switch (the state the mesh ran in until every node held the three seats).
|
||||
enforceSeatDependencies = false
|
||||
@@ -196,33 +115,6 @@ func TestWithTheSwitchFlippedAnUnmetDependencyRefusesTheNode(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheFoundationsDeclarationsAreNotJudged(t *testing.T) {
|
||||
// The private network, as the controller computes it: its tools' package and its service are
|
||||
// the mesh's, written per node, and so are never a module's dependency.
|
||||
network := withResources(mod("mesh-wireguard", []string{"private-network"}, nil, nil),
|
||||
res("package", "wireguard-tools"), res("service", "overlay-up"))
|
||||
network.Computed = "mesh-wireguard"
|
||||
// The host, whatever it declares.
|
||||
host := withResources(mod("mesh-host", nil, nil, nil), res("file", "launcher"), res("service", "nox-mesh-host"))
|
||||
cat := shelf(append(coreThree(), network, host)...)
|
||||
|
||||
for _, m := range []Manifest{network, host} {
|
||||
if d := DependsOn(cat, m); len(d) != 0 {
|
||||
t.Errorf("%s, the foundation's, depends on %v", m.Module, d)
|
||||
}
|
||||
}
|
||||
got, err := Resolve(cat, []string{"mesh-wireguard", "mesh-host"}, workstation(), World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got.Unheld) != 0 {
|
||||
t.Errorf("the foundation on a node with no holders reports %v", got.Unheld)
|
||||
}
|
||||
if _, err := AssignRefusal(cat, "workstation", nil, []string{"mesh-wireguard", "mesh-host"}); err != nil {
|
||||
t.Errorf("assigning the foundation was refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A module-declared seat's needs are judged exactly as a compiled seat's once were (novox/hq ADR
|
||||
// 0220, ADR 0378 §8): "mail" declares the node seat "mail-notifier" needing node-notifier, the
|
||||
// mesh's own (desktop notifications); "notifier" claims mail-notifier; "dunst" claims node-notifier,
|
||||
@@ -236,42 +128,3 @@ func mailNotifierAndDunst() map[string]Manifest {
|
||||
mod("dunst", nil, nil, nil, Claim{Name: NotifierSeat}),
|
||||
)
|
||||
}
|
||||
|
||||
func TestAModuleDeclaredSeatsNeedsAreJudgedLikeACompiledSeatsOnceWere(t *testing.T) {
|
||||
cat := mailNotifierAndDunst()
|
||||
_, err := AssignRefusal(cat, "workstation", nil, []string{"notifier"})
|
||||
var refusal *Refusal
|
||||
if !errors.As(err, &refusal) || !strings.Contains(err.Error(), "node-notifier") {
|
||||
t.Fatalf("notifier assigned without dunst was not refused naming node-notifier: %v", err)
|
||||
}
|
||||
if _, err := AssignRefusal(cat, "workstation", []string{"dunst"}, []string{"notifier"}); err != nil {
|
||||
t.Errorf("notifier beside dunst was refused: %v", err)
|
||||
}
|
||||
if err := UnassignRefusal(cat, "workstation", []string{"dunst", "notifier"}, []string{"dunst"}); err == nil ||
|
||||
!strings.Contains(err.Error(), "node-notifier") {
|
||||
t.Errorf("taking dunst from under notifier was not refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnassigningTheLastHolderWhileDependentsRemainIsRefused(t *testing.T) {
|
||||
sshd := withResources(mod("sshd", nil, nil, nil), res("service", "sshd"))
|
||||
cat := shelf(append(coreThree(), sshd)...)
|
||||
on := []string{"systemd", "pacman", "docker", "sshd"}
|
||||
|
||||
err := UnassignRefusal(cat, "workstation", on, []string{"systemd"})
|
||||
if err == nil {
|
||||
t.Fatal("the last service manager came off a node still running services")
|
||||
}
|
||||
for _, want := range []string{ServiceManagerSeat, "sshd", "pacman", "docker"} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("the refusal does not name %q:\n%v", want, err)
|
||||
}
|
||||
}
|
||||
// A dependent comes off freely, and the holders with everything depending on them in one act.
|
||||
if err := UnassignRefusal(cat, "workstation", on, []string{"sshd"}); err != nil {
|
||||
t.Errorf("a dependent's unassignment was refused: %v", err)
|
||||
}
|
||||
if err := UnassignRefusal(cat, "workstation", on, on); err != nil {
|
||||
t.Errorf("unassigning everything together was refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,37 +9,6 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Telling a module where this machine put the holder of a seat.
|
||||
//
|
||||
// **The foundation's ports are the node's** (novox/hq ADR 0100): the port a foundation server was
|
||||
// given at genesis becomes that node's setting for the module that serves it, and every reader
|
||||
// follows the setting. Every consumer's binding did. The control plane's own connections did not
|
||||
// (04-ISSUES/102): they are written at genesis, before any module exists to bind to — full
|
||||
// connection strings, sealed, with the port inside — so when the node moved the store, the
|
||||
// control plane went on dialling where genesis had written and the mesh was headless.
|
||||
//
|
||||
// The control plane cannot open its own sealed connection to move the port, and it cannot bind
|
||||
// the store as a consumer would: a binding mints a credential, and what the control plane holds
|
||||
// is the foundation's superuser, made before the mesh. What it can do is read the node's settings
|
||||
// when it composes its own declaration — it is the thing that composes every other module's — and
|
||||
// say in its own environment which port this machine put the store at.
|
||||
//
|
||||
// So a module may ask about a **seat** (ADR 0079: a foundation seat is named after the server it
|
||||
// guards — `mesh-store`, `mesh-broker`). `${seat:mesh-store:5432}` is "the port this machine put
|
||||
// the holder of the mesh-store seat's 5432 at". Not a provision: nothing is required, nothing is
|
||||
// granted, no credential is minted. A seat is the mesh's own vocabulary for the store and the
|
||||
// broker, which is what makes this the control plane's way of naming them and not a way for a
|
||||
// module to reach a server it was not granted — the answer is a port number the mesh holds in the
|
||||
// clear, and the credential to use it is still the module's own to have.
|
||||
//
|
||||
// **The answer may be empty, and that is the one place a placeholder answers with nothing.** The
|
||||
// store and the broker are raised at genesis, before the mesh knows them as modules; a mesh raised
|
||||
// on the catalogue's own ports never gives them a setting at all. In both, the port genesis wrote
|
||||
// into the connection string is the right one, and the mesh has nothing to add. An empty answer
|
||||
// says exactly that, and what reads it — the control plane's `_PORT` twin — treats an empty value
|
||||
// as no value. Answering with the software's own port instead would override what genesis wrote
|
||||
// with a number the mesh never checked, on the one machine where that is a headless mesh.
|
||||
|
||||
// ofSeat is where a module asks about a seat: ${seat:<seat>:<the port its holder's software uses>}.
|
||||
var ofSeat = regexp.MustCompile(`\$\{seat:([a-z0-9][a-z0-9-]*):([0-9]+)\}`)
|
||||
|
||||
|
||||
@@ -4,12 +4,6 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// A seat verb a module invokes is one some holder serves (issue 774, sweep item 1). messenger invoked
|
||||
// `seat:node-engine.root-free`, which nothing in the mesh answered, so every ask that offers an approval was held on
|
||||
// Telegram, silently. A manifest names what it calls (novox/hq ADR 0152); that a seat by that name exists, promises
|
||||
// that verb, and has a holder is only known over the whole catalogue, so it is judged here, beside the seats' other
|
||||
// rules (CatalogueProblems).
|
||||
|
||||
// UnheldSeat ends the problem of a `seat:<seat>.<verb>` whose seat no module given claims: over the whole catalogue a
|
||||
// refusal, over some manifests alone a seat whose holder was not given (IsUndeclaredSeat says both).
|
||||
const UnheldSeat = "which no module claims, so nothing answers it"
|
||||
|
||||
@@ -6,23 +6,6 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// The paths a seat's holder owns (novox/hq ADR 0287).
|
||||
//
|
||||
// **Who owns a machine-wide file is the role's question, answered once.** Until this, which module owned
|
||||
// /etc/hosts or /etc/resolv.conf was decided record by record (ADRs 0199, 0222, 0223, 0241) and enforced
|
||||
// only after the fact, by checkResources finding two modules on one machine declaring the same path: a
|
||||
// module that did not hold node-hostname could declare /etc/hosts on a machine where nobody held it, and
|
||||
// nothing refused it. Now a seat's definition names the paths the role owns on any machine, and three
|
||||
// refusals follow from it, each where the mesh can still say no:
|
||||
//
|
||||
// - a claim whose module does not declare every path its seat names, by CanHold, at registration and at
|
||||
// handover;
|
||||
// - a module declaring a path a seat names while claiming none of the seats that name it, at
|
||||
// registration (CatalogueProblems), and what the controller generates for a machine, at composition;
|
||||
// - a module's own seat naming a path another seat already names, at registration — the second loses.
|
||||
//
|
||||
// Reading a seat's path is access, never a resource, so it stays allowed.
|
||||
|
||||
// SeatPath is one path a seat's holder owns: absolute, literal, on the machine where the holder runs.
|
||||
type SeatPath struct {
|
||||
Path string `json:"path"`
|
||||
@@ -143,66 +126,6 @@ func unclaimedSeatPaths(m Manifest, named map[string][]string) []string {
|
||||
return problems
|
||||
}
|
||||
|
||||
// PathOwner is the module that owns one seat's path on one machine (novox/hq ADR 0287): the seat naming
|
||||
// the path, and that seat's holder there, with the stepping back applied.
|
||||
type PathOwner struct {
|
||||
Path string `json:"path"`
|
||||
Node string `json:"node"`
|
||||
Seat string `json:"seat"`
|
||||
Module string `json:"module"`
|
||||
}
|
||||
|
||||
// PathOwners is, for every path a seat in the set names and every machine where a seat naming it is held,
|
||||
// who owns the path there. A seat that steps back for another held on the same machine is passed over.
|
||||
func PathOwners(set []Seat, held []Held) []PathOwner {
|
||||
// node → seat → holding module
|
||||
holders := map[string]map[string]string{}
|
||||
for _, h := range held {
|
||||
s, ok := SeatNamed(h.Claim)
|
||||
if !ok || h.Scope != ScopeNode || s.Scope != ScopeNode {
|
||||
continue
|
||||
}
|
||||
if holders[h.Node] == nil {
|
||||
holders[h.Node] = map[string]string{}
|
||||
}
|
||||
if _, taken := holders[h.Node][s.Name]; !taken {
|
||||
holders[h.Node][s.Name] = h.Module
|
||||
}
|
||||
}
|
||||
naming := map[string][]Seat{} // path → the seats naming it
|
||||
steps := map[string]map[string]string{} // path → seat → the seat it steps back for
|
||||
for _, s := range set {
|
||||
for _, p := range s.Paths {
|
||||
naming[p.Path] = append(naming[p.Path], s)
|
||||
if p.StepsBackFor != "" {
|
||||
if steps[p.Path] == nil {
|
||||
steps[p.Path] = map[string]string{}
|
||||
}
|
||||
steps[p.Path][s.Name] = p.StepsBackFor
|
||||
}
|
||||
}
|
||||
}
|
||||
var out []PathOwner
|
||||
for _, p := range sortedKeys(naming) {
|
||||
for _, node := range sortedKeys(holders) {
|
||||
for _, s := range naming[p] {
|
||||
module, holds := holders[node][s.Name]
|
||||
if !holds {
|
||||
continue
|
||||
}
|
||||
if forSeat := steps[p][s.Name]; forSeat != "" {
|
||||
if _, there := holders[node][forSeat]; there {
|
||||
continue
|
||||
}
|
||||
}
|
||||
out = append(out, PathOwner{Path: p, Node: node, Seat: s.Name, Module: module})
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// seatPathsOnMachine is each path the modules of one machine declare that a seat names, for a module
|
||||
// claiming none of the seats naming it (novox/hq ADR 0287). Registration refuses the same of a manifest;
|
||||
// this holds what the controller generates for a machine to it too, because a computed module's resources
|
||||
|
||||
@@ -79,30 +79,6 @@ func TestAGeneratedResourceOnASeatsPathIsRefused(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Who owns a seat's path on a machine: the seat naming it and its holder there, with the stepping back
|
||||
// applied — the resolver's holder where node-resolver is held, else the uplink's.
|
||||
func TestPathOwnersApplyTheSteppingBack(t *testing.T) {
|
||||
held := []Held{
|
||||
{Claim: "node-uplink", Scope: ScopeNode, Node: "laptop", Module: "networkmanager"},
|
||||
{Claim: ResolverSeat, Scope: ScopeNode, Node: "laptop", Module: "systemd-resolved"},
|
||||
{Claim: "node-uplink", Scope: ScopeNode, Node: "server", Module: "systemd-networkd"},
|
||||
{Claim: "node-hostname", Scope: ScopeNode, Node: "server", Module: "hostname"},
|
||||
}
|
||||
var got []string
|
||||
for _, o := range PathOwners(Seats(), held) {
|
||||
got = append(got, o.Path+" "+o.Node+" "+o.Seat+" "+o.Module)
|
||||
}
|
||||
want := []string{
|
||||
"/etc/hostname server node-hostname hostname",
|
||||
"/etc/hosts server node-hostname hostname",
|
||||
"/etc/resolv.conf laptop node-resolver systemd-resolved",
|
||||
"/etc/resolv.conf server node-uplink systemd-networkd",
|
||||
}
|
||||
if strings.Join(got, "\n") != strings.Join(want, "\n") {
|
||||
t.Errorf("the owners are\n%s\nnot\n%s", strings.Join(got, "\n"), strings.Join(want, "\n"))
|
||||
}
|
||||
}
|
||||
|
||||
// The stepping back comes from the seat, and is of a rendered file only: an uplink holder declaring the
|
||||
// resolver file as a resource beside a node-resolver holder is two writers of one path, refused when the
|
||||
// machine is resolved — never composed twice — as before the seat named its paths.
|
||||
|
||||
@@ -5,17 +5,6 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// The seats a mesh can have (novox/hq ADR 0110).
|
||||
//
|
||||
// **A closed set, defined here rather than by whoever claims one.** Until this, a well-formed name
|
||||
// became a seat by being claimed, so nothing could say which seats a mesh has or who fills them:
|
||||
// `the-showcase` and `the-build-machine` were each invented by the module claiming it. The set is
|
||||
// what a person reads to learn what a mesh can have, so an entry nobody argued for is an entry
|
||||
// nobody can explain — the same reason every shape in the host's vocabulary names its decision.
|
||||
//
|
||||
// A seat is held by a module assignment. What the mesh knows about a holder is what it knows about
|
||||
// that assignment; nothing about holders is kept here or anywhere else.
|
||||
|
||||
// Seat is one role the mesh defines.
|
||||
type Seat struct {
|
||||
// Name is what a manifest claims.
|
||||
@@ -604,10 +593,6 @@ func optionalAsCompiled(row, compiled []Verb) []Verb {
|
||||
// held record — still resolves to it after a rename, and nothing downstream has to change.
|
||||
var aliases = map[string]string{}
|
||||
|
||||
// UseAliases replaces the former-name map with the one the control plane read from its store. Empty
|
||||
// is fine and ordinary: a mesh whose seats have never been renamed has no aliases.
|
||||
func UseAliases(m map[string]string) { aliases = m }
|
||||
|
||||
// Seats is every seat the mesh defines, in reading order.
|
||||
func Seats() []Seat {
|
||||
return append([]Seat(nil), seats...)
|
||||
|
||||
@@ -5,20 +5,6 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Seats a module declares of its own (novox/hq ADR 0118).
|
||||
//
|
||||
// The set of seats a mesh has is **derived**: the mesh's own, in seats.go, plus those declared by
|
||||
// every module it has registered. Still closed — a seat named nowhere is refused — but computed
|
||||
// from the catalogue rather than written in the controller, which is what ADR 0110 actually
|
||||
// needed and a hand-maintained table could not keep. Its own evidence: the enumeration done by
|
||||
// hand while that record was written reported eleven claims where there were thirteen.
|
||||
//
|
||||
// **What can be checked from one manifest and what cannot.** A declaration's shape, its scope,
|
||||
// and the reserved prefix are facts about the manifest in front of you. Whether a seat anybody
|
||||
// names actually exists, whether two modules declared the same one, and whether a holder
|
||||
// satisfies the protocol are facts about the *catalogue* — so they are checked at registration,
|
||||
// by CatalogueProblems, which is the last moment the mesh can still say no.
|
||||
|
||||
// A SeatDeclaration is a role a module offers on the bus: what may be sent to it, what it says,
|
||||
// and what it answers. A caller declares that it uses the *seat*, never the module, so the
|
||||
// implementation can be replaced under it.
|
||||
|
||||
@@ -102,23 +102,3 @@ func TestUseSeatsReplacesTheSetButNeverEmptiesIt(t *testing.T) {
|
||||
t.Fatalf("the working set is %d seats, not the one that was loaded", len(Seats()))
|
||||
}
|
||||
}
|
||||
|
||||
// A former name resolves to the seat it was renamed from (novox/hq ADR 0122), so a manifest's claim
|
||||
// and a held record naming the old name break nothing after a rename.
|
||||
func TestAFormerNameResolvesAfterARename(t *testing.T) {
|
||||
defer func() { UseSeats(DefaultSeats()); UseAliases(nil) }()
|
||||
UseSeats([]Seat{{Name: "git", Scope: ScopeMesh, Delivers: "git", Decision: "novox/hq ADR 0121"}})
|
||||
UseAliases(map[string]string{"git": "git"})
|
||||
|
||||
// The old name resolves to the renamed seat.
|
||||
if s, ok := SeatNamed("git"); !ok || s.Name != "git" {
|
||||
t.Fatalf("the former name did not resolve to the renamed seat: %+v ok=%v", s, ok)
|
||||
}
|
||||
// And a holder recorded under the old name is still found for the provision the seat delivers.
|
||||
providers := []Provider{{Node: "anchor", At: "anchor.internal", Module: "gitea"}}
|
||||
held := []Held{{Claim: "git", Scope: ScopeMesh, Node: "anchor", Module: "gitea"}}
|
||||
holder, found := HolderAmong("git", providers, held)
|
||||
if !found || holder.Module != "gitea" {
|
||||
t.Fatalf("the holder recorded under the former name was not matched: %+v found=%v", holder, found)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,22 +7,6 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// A credential and a configuration file meeting.
|
||||
//
|
||||
// **The gap this closes.** A granted credential arrives as a file whose entire content is the
|
||||
// password. That is what a program reading a password file wants — and most programs do not read
|
||||
// one. They read `KEY=value`, or a JSON document with the password at some path inside it, or a
|
||||
// YAML file in a home directory. Before this, a module in that position could be handed the bare
|
||||
// value or nothing, and both are useless.
|
||||
//
|
||||
// The mesh cannot compose the document, because it discarded the value (novox/hq ADR 0024). So
|
||||
// the module supplies the document with a hole in it, the mesh delivers the value sealed beside
|
||||
// it, and the host — the only thing that ever sees both — puts one into the other on the machine.
|
||||
//
|
||||
// The host has always been able to do this. Nothing filled the values in, so the hole could be
|
||||
// written and never closed, and the host refused the file. That refusal was correct and the
|
||||
// feature was unreachable.
|
||||
|
||||
// placeholder is what a module's file content says where a sealed value belongs: ${secret:name}.
|
||||
//
|
||||
// The same expression the host matches, written out again rather than shared: they are separate
|
||||
|
||||
@@ -138,41 +138,6 @@ func TestGrafanaComposesTheSameOnceInfluxdbServesItsSettings(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// What a provider serves is its definition's, filled from settings only where it asks with
|
||||
// ${setting:<key>} (novox/hq issue 696, ADR 0348 clause 11): a layer that sets a key the provider
|
||||
// serves as a literal replaces nothing, and a key it does not serve is never added.
|
||||
func TestALayerNoLongerReplacesAServedKey(t *testing.T) {
|
||||
served, err := Settle(map[string]any{"host": "mail", "port": 25},
|
||||
[]Layer{{From: "the mesh", Values: map[string]any{"host": "post", "sitename": "Mail"}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if served["host"] != "mail" {
|
||||
t.Errorf("a layer replaced the served host: %v", served)
|
||||
}
|
||||
if _, leaked := served["sitename"]; leaked {
|
||||
t.Errorf("a setting for a file reached the consumers: %v", served)
|
||||
}
|
||||
|
||||
// And a layer setting it is said to reach nothing, rather than taken for a change.
|
||||
m := Manifest{Module: "mail", Serves: map[string]map[string]any{"smtp": {"host": "mail"}}}
|
||||
if unused := UnusedSettings(m, []Layer{{From: "the mesh", Values: map[string]any{"host": "post"}}}); len(unused) != 1 {
|
||||
t.Errorf("a layer setting a literal served key was not said to reach nothing: %v", unused)
|
||||
}
|
||||
|
||||
// influxdb as it was before issue 696, serving literals, under a layer like ace's: the layer no
|
||||
// longer reaches grafana — which is why the catalogue moves influxdb first.
|
||||
got := composedLikeAce(t, influxdbServing(false), aceLikeLayer())
|
||||
for _, id := range []string{"grafana.influxdb-datasource", "grafana.bound-influxdb-api"} {
|
||||
if strings.Contains(got[id], "made-up") {
|
||||
t.Errorf("%s still carries the layer's value over a literal served key: %s", id, got[id])
|
||||
}
|
||||
}
|
||||
if !strings.Contains(got["grafana.influxdb-datasource"], `dbName: default\n`) {
|
||||
t.Errorf("the data source does not name the served literal: %s", got["grafana.influxdb-datasource"])
|
||||
}
|
||||
}
|
||||
|
||||
// influxdb's own runtime configuration takes the keys it names: org and bucket from a layer like
|
||||
// ace's, and not the endpoint block the mesh reads itself, which today's form wrote there too.
|
||||
func TestInfluxdbsConfigTakesItsOrgAndBucket(t *testing.T) {
|
||||
|
||||
@@ -5,22 +5,6 @@ import (
|
||||
"sort"
|
||||
)
|
||||
|
||||
// A provider may tell its consumers its own composed names (novox/hq ADR 0346, issue 624).
|
||||
//
|
||||
// **The name is already the mesh's.** A module contributes a label to a requirement — a route — and
|
||||
// the mesh joins it with the providing machine's domains; the module reads the result in its own files
|
||||
// as `${bound:<requirement>:name}` (novox/hq 04-ISSUES/122). Its consumers could not be told it: a
|
||||
// served value named only the consumer (ADR 0202) and settings. The forge's package registry writes
|
||||
// every tarball at its public name, and a consumer that must trust that name had no source for it but a
|
||||
// second copy kept by hand. So a served value may say `${bound:<requirement>:<name>}` for a requirement
|
||||
// the provider contributes to, and the mesh fills it from the same composition the provider's own files
|
||||
// and its proxy receive.
|
||||
//
|
||||
// **A name the providing machine does not compose leaves the key out** (ADR 0346 §3): a machine with
|
||||
// no public domain has no public name to give, and an empty value or a refusal would be worse than a
|
||||
// consumer told nothing — a refused provider machine takes every provision it offers down with it. The
|
||||
// plan says which key was left out, and why, on the provider's machine and under each consumer's need.
|
||||
|
||||
// requirementsContributedTo is every requirement m contributes to, sorted.
|
||||
func requirementsContributedTo(m Manifest) []string {
|
||||
seen := map[string]bool{}
|
||||
@@ -101,22 +85,3 @@ func orHere(node string) string {
|
||||
}
|
||||
return node
|
||||
}
|
||||
|
||||
// ServedLeftOut is what the providers in this node's own set leave out of what they serve, because
|
||||
// this machine composes no name a served value asks for (ADR 0346 §3): said on the provider's machine,
|
||||
// where the remedy is.
|
||||
func (r Resolution) ServedLeftOut(settings SettingsBy) []string {
|
||||
var said []string
|
||||
for _, m := range r.Modules {
|
||||
for _, provision := range sortedServes(m.Serves) {
|
||||
_, lines, err := r.ServedOwnNames(m, provision, m.Serves[provision], WithDefaults(m, settings[m.Module]))
|
||||
if err != nil {
|
||||
said = append(said, fmt.Sprintf("%s serves %s, and its own names could not be composed: %v",
|
||||
m.Module, provision, err))
|
||||
continue
|
||||
}
|
||||
said = append(said, lines...)
|
||||
}
|
||||
}
|
||||
return said
|
||||
}
|
||||
|
||||
@@ -43,31 +43,6 @@ func TestAProviderServesItsOwnComposedName(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestANameTheMachineDoesNotComposeLeavesTheKeyOutAndSaysSo(t *testing.T) {
|
||||
m := aForgeManifest(t)
|
||||
r := Resolution{Node: "home"} // no public domain
|
||||
got, said, err := r.ServedOwnNames(m, "npm-package-registry", m.Serves["npm-package-registry"], nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, there := got["public-root"]; there {
|
||||
t.Fatalf("a name not composed is left out, never written as the placeholder or empty: %v", got)
|
||||
}
|
||||
if got["npm-path"] == nil {
|
||||
t.Fatalf("only the key that names it is left out: %v", got)
|
||||
}
|
||||
if len(said) != 1 || !strings.Contains(said[0], `forge serves npm-package-registry without "public-root"`) ||
|
||||
!strings.Contains(said[0], "home composes no such name") {
|
||||
t.Fatalf("said: %v", said)
|
||||
}
|
||||
if lines := (Resolution{Node: "home", Modules: []Manifest{m}}).ServedLeftOut(nil); len(lines) != 1 {
|
||||
t.Fatalf("the plan on the provider's machine says it: %v", lines)
|
||||
}
|
||||
if lines := (Resolution{Node: "home", PublicDomain: "example.org", Modules: []Manifest{m}}).ServedLeftOut(nil); len(lines) != 0 {
|
||||
t.Fatalf("nothing to say when the name is composed: %v", lines)
|
||||
}
|
||||
}
|
||||
|
||||
// On the provider's own machine the consumer is served the same name (ADR 0346 §2).
|
||||
func TestAConsumerBesideTheProviderIsServedItsName(t *testing.T) {
|
||||
m := aForgeManifest(t)
|
||||
|
||||
@@ -4,22 +4,6 @@ import (
|
||||
"sort"
|
||||
)
|
||||
|
||||
// A preference has a default; the operator's own value has none (novox/hq ADR 0262, extending ADR
|
||||
// 0112 and taking the default half of ADR 0164).
|
||||
//
|
||||
// `${setting:<key>}` was refused whenever no layer set the key, and `settings set` refused a key no
|
||||
// file asked for yet. Together they meant a running module could never gain a setting: the file that
|
||||
// asks for it fails to compose until somebody sets it, and nobody can set it until the file asks.
|
||||
// A definition may now give a key a default, with why, when the value is a **preference** — a font
|
||||
// size, a width, a number of workers: something true of the software that a machine may tune. A
|
||||
// value that is inherently the operator's — a domain, a public name, an identity, a secret — gets no
|
||||
// default and stays refused by name until a layer sets it, which is what ADR 0112 and ADR 0155 were
|
||||
// written for.
|
||||
//
|
||||
// The default is the lowest layer. The mesh's layer, then the node's, override it. It fills only
|
||||
// `${setting:<key>}`: a mergeable file's content already is its defaults, and laying a default over
|
||||
// it would reach every mergeable file of the module (novox/hq issue 168).
|
||||
|
||||
// SettingDeclaration is one key's default, as the definition gives it.
|
||||
type SettingDeclaration struct {
|
||||
// Kind says what sort of value this is. `preference` is the only kind with a default; it is
|
||||
|
||||
@@ -97,18 +97,6 @@ func TestAComposedMachineGetsTheDefaultAndTheNodesValue(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Setting a key the module gives a default is not refused as reaching nothing: it overrides the
|
||||
// default, which is how a running module gains a setting with no gap between.
|
||||
func TestAKeyWithADefaultIsNotStray(t *testing.T) {
|
||||
m := notifier()
|
||||
m.Resources[0]["content"] = "font = Inter ${setting:font-size}\nwidth = ${setting:width}\n"
|
||||
stray := UnusedSettings(m, []Layer{{From: "laptop", Values: map[string]any{"font-size": float64(13), "colour": "red"}}})
|
||||
joined := strings.Join(stray, "; ")
|
||||
if strings.Contains(joined, `"font-size"`) || !strings.Contains(joined, `"colour"`) {
|
||||
t.Fatalf("stray: %s", joined)
|
||||
}
|
||||
}
|
||||
|
||||
// A default fills ${setting:…} only: it never becomes a key of a mergeable file (issue 168).
|
||||
func TestADefaultIsNotMergedIntoAJSONFile(t *testing.T) {
|
||||
m := notifier()
|
||||
@@ -274,42 +262,3 @@ func aModuleWithAFieldSetAsideIsComposedWhole(t *testing.T) {
|
||||
t.Fatalf("the left-out module's data is no longer backed up:\n%s", list)
|
||||
}
|
||||
}
|
||||
|
||||
// A left-out module's backup lines are best effort: a line that cannot be placed — an access nobody
|
||||
// placed, a placement setting that does not read — is said among what could not be placed, and the
|
||||
// machine is declared (novox/hq ADR 0262).
|
||||
func TestALeftOutModulesUnplaceableBackupLineCostsOnlyThatLine(t *testing.T) {
|
||||
holder := Manifest{Module: "backups", Version: "1",
|
||||
Claims: []Claim{{Name: BackupSeat, Scope: ScopeNode}},
|
||||
Resources: []map[string]any{{"id": "list", "type": "file", "path": "/etc/backups.list", "mode": "0644",
|
||||
"content": "${contribution:" + BackupSeat + ":backup}"}}}
|
||||
media := Manifest{Module: "media", Version: "1",
|
||||
Accesses: []Access{{ID: "library", Mode: "read"}},
|
||||
Data: &Data{Own: []DataItem{{ID: "library", Path: "${access:library}", Class: "valuable"}}}}
|
||||
placedBadly := Manifest{Module: "notes", Version: "1",
|
||||
Resources: []map[string]any{{"id": "d", "type": "directory", "path": "/srv/notes", "mode": "0700"}},
|
||||
Data: &Data{Own: []DataItem{{ID: "d", Path: "${dir:d}", Class: "valuable"}}}}
|
||||
r := anAdoptedAnchor()
|
||||
r.Modules = append(r.Modules, holder, media, placedBadly)
|
||||
with := anchorRendering(false)
|
||||
with.Settings["notes"] = []Layer{LayerOf("anchor", map[string]any{PlacesSetting: "not a map"})}
|
||||
composed, err := r.Compose(with)
|
||||
if err != nil {
|
||||
t.Fatalf("an unplaceable line of a left-out module failed the machine: %v", err)
|
||||
}
|
||||
for _, m := range []string{"media", "notes"} {
|
||||
if _, left := composed.LeftOut[m]; !left {
|
||||
t.Errorf("%s is not left out: %v", m, composed.LeftOut)
|
||||
}
|
||||
}
|
||||
unplaced := strings.Join(composed.Unplaced, "\n")
|
||||
for _, want := range []string{"media's backup for node-backup", "notes's backup for node-backup", "placement setting does not read"} {
|
||||
if !strings.Contains(unplaced, want) {
|
||||
t.Errorf("not said among what could not be placed: %q in\n%s", want, unplaced)
|
||||
}
|
||||
}
|
||||
list, _ := byID(composed.Resources)["backups.list"]["content"].(string)
|
||||
if strings.Contains(list, "/srv/notes") || strings.Contains(list, "${") {
|
||||
t.Fatalf("a line was placed from the definition's default or unfilled:\n%s", list)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,16 +95,6 @@ func TestAnOwnerSettingThatIsNoNumberIsRefused(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A key a directory's owner asks for is a destination, so setting it is not called stray.
|
||||
func TestASettingAnOwnerAsksForIsNotStray(t *testing.T) {
|
||||
m := recycler()
|
||||
m.Resources = m.Resources[:1]
|
||||
stray := strings.Join(UnusedSettings(m, []Layer{{From: "ace", Values: map[string]any{"puid": "1001", "pgid": "2000"}}}), "; ")
|
||||
if strings.Contains(stray, "puid") || strings.Contains(stray, "pgid") {
|
||||
t.Errorf("called stray: %s", stray)
|
||||
}
|
||||
}
|
||||
|
||||
// An owner is written as the number parsed: "0001" and "00" reach the node-engine as 1 and 0, never as text.
|
||||
// Uid 0 is allowed, because a key an owner asks for is the terminal's alone.
|
||||
func TestAnOwnerSettingIsWrittenAsTheNumber(t *testing.T) {
|
||||
|
||||
@@ -95,19 +95,6 @@ func TestASettingThatMakesNoUnitNameIsRefused(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A key a unit's name asks for is a destination, so setting it is not called stray, and judging the
|
||||
// settings sees it.
|
||||
func TestASettingAUnitAsksForIsNotStrayAndIsJudged(t *testing.T) {
|
||||
m := scrubber()
|
||||
stray := strings.Join(UnusedSettings(m, []Layer{{From: "ace", Values: map[string]any{"scrub-pool": "storage"}}}), "; ")
|
||||
if strings.Contains(stray, "scrub-pool") {
|
||||
t.Errorf("called stray: %s", stray)
|
||||
}
|
||||
if err := JudgeSettings(m, nil, false); err == nil || !strings.Contains(err.Error(), "scrub-pool") {
|
||||
t.Errorf("a unit's setting nothing sets is not refused when judged: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnInstanceValueMayNotLeadWithADashNorBeLong(t *testing.T) {
|
||||
m := scrubber()
|
||||
for _, bad := range []string{"-storage", "--help", strings.Repeat("a", 65)} {
|
||||
|
||||
@@ -9,22 +9,6 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// An operator's value, where a definition needs one (novox/hq ADR 0112, ADR 0155, design 27).
|
||||
//
|
||||
// A mail server's domain, a site's name, the address a proxy forwards from: values that are true of
|
||||
// one installation and of no other, and that a module's software must be told. They had nowhere to
|
||||
// live but the definition, which is how a catalogue meant for any mesh came to name this one
|
||||
// (novox/hq issues 122, 134). ADR 0112 names the operator as one of the four providers; this is the
|
||||
// operator answering.
|
||||
//
|
||||
// `${setting:<key>}` in a file's content, in a service's unit name and in a directory's owner (a uid or
|
||||
// gid only, issue 279) is filled from the module's settings layers — the mesh's,
|
||||
// then this node's — the same layers a mergeable JSON file and a contribution already take, so
|
||||
// `settings set <module>` is the one place a person's values go. **Refused when no layer sets it**,
|
||||
// naming the key and the remedy: a definition that carried a default for a mail domain would be
|
||||
// carrying the very literal this removes, and a blank written silently would be a service that
|
||||
// comes up wrong somewhere that names neither the module nor the key.
|
||||
|
||||
// settingRef is how a definition asks for an operator's value: ${setting:<key>}.
|
||||
var settingRef = regexp.MustCompile(`\$\{setting:([a-z0-9][a-z0-9_.-]*)\}`)
|
||||
|
||||
@@ -150,24 +134,6 @@ func settingIntoUnit(resource map[string]any, layers []Layer, module string) err
|
||||
return nil
|
||||
}
|
||||
|
||||
// A declared directory may be owned by the account its module runs as (novox/hq issue 279).
|
||||
//
|
||||
// A containerised module runs as the identity its assignment sets per node — its puid and pgid
|
||||
// settings, written into the environment file its image reads (ADR 0112, issue 153) — and a directory
|
||||
// its app must write, beyond the one its image re-owns at start, was owned by whatever the manifest
|
||||
// spelt: right on a machine whose identity matched the image's default, refused by the app on one whose
|
||||
// did not. So a directory's `owner` may name the settings that identity is: `${setting:<key>}` for the
|
||||
// user alone, `${setting:<user>}:${setting:<group>}` for both, filled from the same layers at
|
||||
// composition.
|
||||
//
|
||||
// **Whole numbers only.** An owner a setting fills is a uid or a gid — never an account's name, never
|
||||
// text — so no value can put anything into the ownership the node-engine applies but a number; one that
|
||||
// is not is refused at composition, naming the key, and the directory is left as it was. The shape is
|
||||
// judged where the manifest is read (OwnerSettingProblems): each side of the colon a whole placeholder
|
||||
// or a whole number, and every key one the module itself asks for elsewhere — in a file, a unit, a
|
||||
// contribution or a served fact — or declares a default for, so the owner follows the identity its
|
||||
// process is told it runs as, never a key that reaches nothing else.
|
||||
|
||||
// wholeID is a value as a uid or a gid, and whether it is one: a whole number below the kernel's "no id"
|
||||
// (2^32-1). Uid 0 is one: a key an owner asks for is the controller's terminal's alone (TerminalKeys).
|
||||
func wholeID(v string) (uint64, bool) {
|
||||
@@ -238,79 +204,6 @@ func orNoSettings(layers []Layer) string {
|
||||
return "; set today: " + strings.Join(keys, ", ")
|
||||
}
|
||||
|
||||
// settingKeysUsedBy is every key a module's files, units, directory owners, contributions and served
|
||||
// facts ask for, so a setting that lands in one is not called stray.
|
||||
func settingKeysUsedBy(m Manifest) map[string]bool {
|
||||
used := settingKeysAskedOutsideOwners(m)
|
||||
for _, r := range m.Resources {
|
||||
if fmt.Sprint(r["type"]) == "directory" {
|
||||
if owner, ok := r["owner"].(string); ok {
|
||||
for _, k := range settingsUsed(owner) {
|
||||
used[k] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return used
|
||||
}
|
||||
|
||||
// settingKeysAskedOutsideOwners is every key a module's files, units, items, contributions and served facts
|
||||
// ask for: what a directory's owner may follow (OwnerSettingProblems).
|
||||
func settingKeysAskedOutsideOwners(m Manifest) map[string]bool {
|
||||
used := map[string]bool{}
|
||||
note := func(s string) {
|
||||
for _, k := range settingsUsed(s) {
|
||||
used[k] = true
|
||||
}
|
||||
}
|
||||
for _, r := range m.Resources {
|
||||
switch fmt.Sprint(r["type"]) {
|
||||
case "file":
|
||||
if content, ok := r["content"].(string); ok {
|
||||
note(content)
|
||||
}
|
||||
case "service":
|
||||
if unit, ok := r["unit"].(string); ok {
|
||||
note(unit)
|
||||
}
|
||||
case "container":
|
||||
for _, g := range groupsOf(r) {
|
||||
if s, ok := g.(string); ok {
|
||||
note(s)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// An item's text, filled from its template (WithTemplatesFrom), places a key as a file's does (issue 1658).
|
||||
for _, it := range m.Items {
|
||||
note(it.Content)
|
||||
}
|
||||
inValues := func(values map[string]any) {
|
||||
for _, v := range values {
|
||||
if s, ok := v.(string); ok {
|
||||
note(s)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, values := range m.Contributes {
|
||||
inValues(values)
|
||||
}
|
||||
for _, locals := range m.ContributesMany {
|
||||
for _, values := range locals {
|
||||
inValues(values)
|
||||
}
|
||||
}
|
||||
for _, values := range m.Serves {
|
||||
inValues(values)
|
||||
}
|
||||
return used
|
||||
}
|
||||
|
||||
// A container's supplementary groups may be an operator's value (novox/hq ADR 0480 clause 7, ADR 0348 §11, issue 1636):
|
||||
// n8n joins the media library's group, whose number is the machine's, never the definition's (ADR 0112 §1). Each
|
||||
// element of `groups` is a whole placeholder or a whole number; a placeholder is filled with the number the
|
||||
// config value holds, and the list reaches the node-engine as numbers.
|
||||
|
||||
// groupsOf is a container resource's groups as the manifest wrote them (text or numbers), none when it names none.
|
||||
func groupsOf(r map[string]any) []any {
|
||||
groups, _ := r["groups"].([]any)
|
||||
|
||||
@@ -38,18 +38,3 @@ func TestASettingNothingSetIsRefusedByName(t *testing.T) {
|
||||
t.Fatalf("content was changed on refusal: %q", file["content"])
|
||||
}
|
||||
}
|
||||
|
||||
// A key a file asks for is a destination, so setting it is not called stray.
|
||||
func TestASettingAFileAsksForIsNotStray(t *testing.T) {
|
||||
m := Manifest{Module: "mail", Resources: []map[string]any{
|
||||
{"id": "env", "type": "file", "content": "DOMAIN=${setting:domain}\n"},
|
||||
}}
|
||||
layers := []Layer{{From: "the mesh", Values: map[string]any{"domain": "example.tld", "stray": "x"}}}
|
||||
unused := strings.Join(UnusedSettings(m, layers), "; ")
|
||||
if strings.Contains(unused, `"domain"`) {
|
||||
t.Fatalf("a key a file asks for was called stray: %s", unused)
|
||||
}
|
||||
if !strings.Contains(unused, `"stray"`) {
|
||||
t.Fatalf("a key nothing reads was not named: %s", unused)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,25 +6,9 @@ import (
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// A mergeable file names the setting keys it takes (novox/hq ADR 0326, issue 168).
|
||||
//
|
||||
// Before this, every key of a module's layers merged into every one of its mergeable files: the
|
||||
// mesh's own words, a key meant for a `${setting:<key>}` in another file, a key for a second
|
||||
// configuration file. So a module could not own two configurable files, and every receiver had to
|
||||
// ignore what it did not know. ADR 0164 §11 decided that a setting names the files that read it; this
|
||||
// is that relation, stated from the file's side, so the operator's own values stay declared nowhere
|
||||
// (ADR 0262 §2).
|
||||
|
||||
// TakesSettingsField is the key a mergeable file carries to name the top-level setting keys it takes.
|
||||
const TakesSettingsField = "takes-settings"
|
||||
|
||||
// **A taken value may be laid under the file's own name** (issue 751). An entry of takes-config is a key, laid
|
||||
// under that key, or {"key": <the config key>, "as": <the file's name for it>}: the key stays a config key,
|
||||
// lower-case, and the file is written in its software's spelling, `atlassian-url` as ATLASSIAN_URL. Like every
|
||||
// taken value it is laid only when a layer sets it, never from a default, so an unset one leaves the file's bytes
|
||||
// and the code's own fallback (its environment) as they were; for that reason it is never `required`
|
||||
// (ConfigProblems). The settings form has no such entry: it is the config form's alone.
|
||||
|
||||
// Fields of a renamed takes-config entry.
|
||||
const (
|
||||
takenKeyField = "key"
|
||||
@@ -66,15 +50,6 @@ func asTakenEntry(item any) (takenEntry, bool) {
|
||||
return takenEntry{}, false
|
||||
}
|
||||
|
||||
// takenKeyList is the keys a resource takes, in order: the config keys, never the file's names for them.
|
||||
func takenKeyList(resource map[string]any) []string {
|
||||
var keys []string
|
||||
for _, e := range takenEntries(resource[TakesSettingsField]) {
|
||||
keys = append(keys, e.key)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
// takenKeys is the keys a resource names in takes-settings, each with the name the file has for it, and whether
|
||||
// it names any list at all. An empty list is a list: that file takes no setting.
|
||||
func takenKeys(resource map[string]any) (map[string]string, bool) {
|
||||
@@ -136,23 +111,6 @@ func takenNames(resource map[string]any) []string {
|
||||
return names
|
||||
}
|
||||
|
||||
// namesItsKeys says whether a module's mergeable files name the keys they take (ADR 0326 §2): true
|
||||
// only when it has one and every one carries takes-settings. A module with a silent mergeable file
|
||||
// keeps today's form (§3), where every key reaches every mergeable file.
|
||||
func namesItsKeys(m Manifest) bool {
|
||||
merging := 0
|
||||
for _, r := range m.Resources {
|
||||
if how, _ := r["merge"].(string); how == "" {
|
||||
continue
|
||||
}
|
||||
merging++
|
||||
if _, said := r[TakesSettingsField]; !said {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return merging > 0
|
||||
}
|
||||
|
||||
// takenSettingsProblems is every rule of ADR 0326 §2 and §4, judged where a definition is checked and
|
||||
// where a stored one is composed.
|
||||
func takenSettingsProblems(m Manifest) []string {
|
||||
@@ -272,24 +230,3 @@ func usableKey(key string) bool {
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// takenSaid is what a stray key's message adds for a module whose files name their keys: which keys
|
||||
// each file takes, so the person sees where the key would have had to be named.
|
||||
func takenSaid(m Manifest) string {
|
||||
if !namesItsKeys(m) {
|
||||
return ""
|
||||
}
|
||||
var files []string
|
||||
for _, r := range m.Resources {
|
||||
if how, _ := r["merge"].(string); how == "" {
|
||||
continue
|
||||
}
|
||||
keys := takenKeyList(r)
|
||||
if len(keys) == 0 {
|
||||
files = append(files, fmt.Sprintf("%v takes none", r["id"]))
|
||||
continue
|
||||
}
|
||||
files = append(files, fmt.Sprintf("%v takes %s", r["id"], strings.Join(keys, ", ")))
|
||||
}
|
||||
return " (" + strings.Join(files, "; ") + ")"
|
||||
}
|
||||
|
||||
@@ -86,32 +86,6 @@ func TestAnEmptyListTakesNoSetting(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAFileThatNamesNothingKeepsTodaysForm(t *testing.T) {
|
||||
// ADR 0326 §3, ADR 0164 §15: a module none of whose files names keys is left as it was.
|
||||
m := twoFiles(nil, nil)
|
||||
app := settledContent(t, m.Resources[0], []Layer{{From: "the mesh", Values: map[string]any{"url": "x"}}})
|
||||
if app["url"] != "x" {
|
||||
t.Errorf("a silent file should still take every key until its module names them: %v", app)
|
||||
}
|
||||
if unused := UnusedSettings(m, []Layer{{From: "the mesh", Values: map[string]any{"anything": 1}}}); len(unused) != 0 {
|
||||
t.Errorf("today's form has nothing stray, and said: %v", unused)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAKeyNoFileNamesIsStray(t *testing.T) {
|
||||
// ADR 0326 §5: once the files name their keys, a key none of them names — and nothing else
|
||||
// takes — is stray, and is said with what each file takes.
|
||||
m := twoFiles([]any{"server", "search"}, []any{"url"})
|
||||
unused := UnusedSettings(m, []Layer{{From: "the mesh", Values: map[string]any{
|
||||
"server": 1, "url": "x", "site-name": "Search", "ui": map[string]any{"theme": "dark"}}}})
|
||||
if len(unused) != 1 || !strings.Contains(unused[0], `"ui"`) {
|
||||
t.Fatalf("only ui reaches nothing; named: %v", unused)
|
||||
}
|
||||
if !strings.Contains(unused[0], "app takes server, search") || !strings.Contains(unused[0], "sidecar takes url") {
|
||||
t.Errorf("the stray key's message does not say what each file takes: %s", unused[0])
|
||||
}
|
||||
}
|
||||
|
||||
func containsText(list []string, text string) bool {
|
||||
for _, s := range list {
|
||||
if strings.Contains(s, text) {
|
||||
|
||||
@@ -13,17 +13,6 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Settings are how somebody changes a config file a module owns, without editing it.
|
||||
//
|
||||
// Managed files are generated and never edited (novox/hq ADR 0011), so a person's intention has
|
||||
// to be expressed somewhere the generator can see it. Then the file is produced from the module's
|
||||
// defaults and that intention together, and upstream can rewrite its half freely.
|
||||
//
|
||||
// **An override beats a default, so there is nothing to resolve.** When the module changes a key
|
||||
// somebody has set, the setting wins — it was a statement about that key, made deliberately, and
|
||||
// the default was only ever what to do in the absence of one. This is the same shape as the rest
|
||||
// of the mesh refusing to guess: there is no guess to make.
|
||||
|
||||
// Merge is how a file's content is combined with settings. A module names one per resource.
|
||||
const (
|
||||
// MergeJSON treats the content as a JSON document and merges settings into it by key.
|
||||
@@ -389,107 +378,6 @@ func sortedKeysAny(m map[string]any) []string {
|
||||
return out
|
||||
}
|
||||
|
||||
// UnusedSettings names settings that reach nothing.
|
||||
//
|
||||
// Somebody who sets a key on a module with nothing mergeable, or misspells one, has changed
|
||||
// nothing — and would find out by the machine not behaving differently, which is the slowest
|
||||
// way there is. This is what makes that visible at the moment they set it.
|
||||
//
|
||||
// Where a key can land: any mergeable file takes any key; a file asking for `${setting:<key>}`
|
||||
// takes that key (ADR 0155); a contribution takes a key it declares, and no other (novox/hq
|
||||
// 04-ISSUES/173); a served fact takes only the keys it asks for as `${setting:<key>}`, since a layer
|
||||
// replaces no served key (novox/hq issue 696); and the mesh's own words — `expose`, `ports`, `reach`, `endpoints` —
|
||||
// are read by the mesh. A key none of those takes is stray, and is said so rather than dropped.
|
||||
func UnusedSettings(m Manifest, layers []Layer) []string {
|
||||
named := namesItsKeys(m)
|
||||
for _, r := range m.Resources {
|
||||
if how, _ := r["merge"].(string); how != "" && !named {
|
||||
// Today's form (novox/hq ADR 0326 §3, ADR 0164 §15): a mergeable file that names no
|
||||
// keys takes every key, so nothing is stray.
|
||||
return nil
|
||||
}
|
||||
}
|
||||
// A computed module has no resources here to look at — they are worked out per node, and
|
||||
// whether a setting lands is not knowable until then. Silence rather than a wrong answer:
|
||||
// claiming every setting on the private network is stray would be worse than saying nothing.
|
||||
if m.Computed != "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
lands := settingKeysUsedBy(m)
|
||||
// A mergeable file takes the keys it names (novox/hq ADR 0326 §5).
|
||||
for _, r := range m.Resources {
|
||||
for _, key := range takenNames(r) {
|
||||
lands[key] = true
|
||||
}
|
||||
}
|
||||
// A key the module gives a default reaches what asks for it (novox/hq ADR 0262): setting it
|
||||
// overrides the default, which is how a running module gains a setting without a gap between.
|
||||
for key := range m.Settings {
|
||||
lands[key] = true
|
||||
}
|
||||
for _, values := range m.Contributes {
|
||||
for key := range values {
|
||||
lands[key] = true
|
||||
}
|
||||
}
|
||||
for _, locals := range m.ContributesMany {
|
||||
for _, values := range locals {
|
||||
for key := range values {
|
||||
lands[key] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
var unused []string
|
||||
for _, layer := range layers {
|
||||
for key := range layer.Whole() {
|
||||
if lands[key] {
|
||||
continue
|
||||
}
|
||||
// `expose` is a real destination for a module that listens: it overrides a port's
|
||||
// source (novox/hq ADR 0046), validated in Exposure, so it is not stray here.
|
||||
if key == ExposeSetting && len(m.Listens) > 0 {
|
||||
continue
|
||||
}
|
||||
// `ports` gives a module's port a machine port on one node (novox/hq ADR 0100),
|
||||
// validated in GivenPorts, so it is not stray here either.
|
||||
if key == PortsSetting {
|
||||
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
|
||||
}
|
||||
// `places` puts a declared directory where this machine keeps it, `accesses` says where
|
||||
// the operator's data is (novox/hq issue 153). Validated in Places and AccessPlaces.
|
||||
if key == PlacesSetting && len(directoriesOf(m)) > 0 {
|
||||
continue
|
||||
}
|
||||
if key == AccessesSetting && len(m.Accesses) > 0 {
|
||||
continue
|
||||
}
|
||||
// `networks` keeps a found network for a taken container on one adopted machine
|
||||
// (novox/hq ADR 0163). Validated in KeptNetworks, so not stray.
|
||||
if key == NetworksSetting {
|
||||
continue
|
||||
}
|
||||
unused = append(unused, fmt.Sprintf(
|
||||
"%s sets %q, and %s has no file that merges it, asks for no ${setting:%s}, and "+
|
||||
"declares no %q in what it contributes%s",
|
||||
layer.From, key, m.Module, key, key, takenSaid(m)))
|
||||
}
|
||||
}
|
||||
sort.Strings(unused)
|
||||
return unused
|
||||
}
|
||||
|
||||
func stringsOf(v any) []string {
|
||||
raw, ok := v.([]any)
|
||||
if !ok {
|
||||
|
||||
@@ -1,127 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A setting is judged where it is stored, and an impossible one costs a module, not a machine
|
||||
// (novox/hq ADR 0163, rule 6): the one judgement, used by SetSettings before storing and by
|
||||
// Compose when a definition has moved under a stored setting.
|
||||
func TestASettingThatCannotComposeIsRefusedByNameAndLeavesOnlyItsModuleOut(t *testing.T) {
|
||||
web := Manifest{Module: "hello-web",
|
||||
Listens: []Listening{{Port: 8080, From: FromEverywhere}},
|
||||
Resources: []map[string]any{{"id": "server", "type": "container", "name": "hello-web",
|
||||
"ports": []any{"8080"}}}}
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
layer map[string]any
|
||||
refuse string
|
||||
}{
|
||||
{"a port the module does not publish", map[string]any{PortsSetting: map[string]any{"9999": 10000}},
|
||||
"hello-web gives port 9999 a machine port, and no container of its publishes 9999"},
|
||||
{"a mesh-wide port", map[string]any{PortsSetting: map[string]any{"8080": 10000}},
|
||||
"a port is a fact about one machine"},
|
||||
} {
|
||||
from := "anchor"
|
||||
if c.name == "a mesh-wide port" {
|
||||
from = MeshWideLayer
|
||||
}
|
||||
err := JudgeSettings(web, []Layer{LayerOf(from, c.layer)}, true)
|
||||
if err == nil || !strings.Contains(err.Error(), c.refuse) {
|
||||
t.Errorf("%s: judged %v, want %q", c.name, err, c.refuse)
|
||||
}
|
||||
}
|
||||
if err := JudgeSettings(web, []Layer{{From: "anchor", Placement: map[string]any{PortsSetting: map[string]any{"8080": 10000}}}}, true); err != nil {
|
||||
t.Fatalf("a port the module publishes was refused: %v", err)
|
||||
}
|
||||
|
||||
// Composed, a module whose stored setting no longer works is left out by name; the rest of
|
||||
// the machine is declared.
|
||||
r := anAdoptedAnchor()
|
||||
with := anchorRendering(false)
|
||||
with.Settings = SettingsBy{"hello-web": {{From: "anchor", Placement: map[string]any{PortsSetting: map[string]any{"9999": 10000}}}}}
|
||||
composed, err := r.Compose(with)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
why, left := composed.LeftOut["hello-web"]
|
||||
if !left || !strings.Contains(why, "no container of its publishes 9999") {
|
||||
t.Fatalf("hello-web is not left out by name: %v", composed.LeftOut)
|
||||
}
|
||||
if len(composed.LeftOut) != 1 {
|
||||
t.Fatalf("more than hello-web is left out: %v", composed.LeftOut)
|
||||
}
|
||||
got := byID(composed.Resources)
|
||||
if _, declared := got["hello-web.server"]; declared {
|
||||
t.Fatal("the left-out module's container is still declared")
|
||||
}
|
||||
if _, declared := got["distribution.store"]; !declared {
|
||||
t.Fatal("the rest of the machine was not declared")
|
||||
}
|
||||
if left := r.LeftOut(with.Settings, false); len(left) != 1 || left["hello-web"] == "" {
|
||||
t.Fatalf("the judgement a plan reads differs from what compose did: %v", left)
|
||||
}
|
||||
}
|
||||
|
||||
// A taken container keeps a found network by a per-machine setting (novox/hq ADR 0163, rule 4):
|
||||
// on an adopted machine only, for a container the module declares, and it reaches the container's
|
||||
// declaration as the networks it also joins.
|
||||
func TestAKeptNetworkReachesTheContainerOnAnAdoptedMachineOnly(t *testing.T) {
|
||||
r := anAdoptedAnchor()
|
||||
keep := SettingsBy{"hello-web": {LayerOf("anchor",
|
||||
map[string]any{NetworksSetting: map[string]any{"server": []any{"predecessor_default"}}})}}
|
||||
|
||||
with := anchorRendering(true)
|
||||
with.Settings = keep
|
||||
composed, err := r.Compose(with)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(composed.LeftOut) != 0 {
|
||||
t.Fatalf("a kept network left a module out: %v", composed.LeftOut)
|
||||
}
|
||||
server := byID(composed.Resources)["hello-web.server"]
|
||||
networks, _ := server["networks"].([]any)
|
||||
if len(networks) != 1 || networks[0] != "predecessor_default" {
|
||||
t.Fatalf("the container does not join the kept network: %v", server)
|
||||
}
|
||||
if _, has := byID(composed.Resources)["distribution.store"]["networks"]; has {
|
||||
t.Fatal("another container joins a network nobody kept for it")
|
||||
}
|
||||
|
||||
// Converged, the setting reaches nothing it was for, and the module is left out saying so.
|
||||
with = anchorRendering(false)
|
||||
with.Settings = keep
|
||||
composed, err = r.Compose(with)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if why := composed.LeftOut["hello-web"]; !strings.Contains(why, "anchor is converged") {
|
||||
t.Fatalf("a kept network on a converged machine: %v", composed.LeftOut)
|
||||
}
|
||||
|
||||
web := r.Modules[4]
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
layer Layer
|
||||
want string
|
||||
}{
|
||||
{"mesh-wide", LayerOf(MeshWideLayer, map[string]any{NetworksSetting: map[string]any{"server": []any{"x"}}}),
|
||||
"a found network is a fact about one machine"},
|
||||
{"an unknown container", LayerOf("anchor", map[string]any{NetworksSetting: map[string]any{"db": []any{"x"}}}),
|
||||
`names the container "db", which it does not declare`},
|
||||
{"not a list", LayerOf("anchor", map[string]any{NetworksSetting: map[string]any{"server": "x"}}),
|
||||
"is a list of network names"},
|
||||
{"not a network name", LayerOf("anchor", map[string]any{NetworksSetting: map[string]any{"server": []any{"a/b"}}}),
|
||||
"which is not a network name"},
|
||||
} {
|
||||
_, err := KeptNetworks(web, []Layer{c.layer}, true)
|
||||
if err == nil || !strings.Contains(err.Error(), c.want) {
|
||||
t.Errorf("%s: %v, want %q", c.name, err, c.want)
|
||||
}
|
||||
}
|
||||
if kept, err := KeptNetworks(web, nil, false); err != nil || kept != nil {
|
||||
t.Fatalf("no setting: %v %v", kept, err)
|
||||
}
|
||||
}
|
||||
@@ -159,37 +159,6 @@ func TestAFileThatDoesNotMergeIsLeftAlone(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSettingsThatReachNothingAreNamed(t *testing.T) {
|
||||
// Somebody who misspells a module, or sets a key on one with nothing mergeable, has changed
|
||||
// nothing — and would otherwise find out by the machine not behaving differently, which is
|
||||
// the slowest way there is.
|
||||
m := Manifest{Module: "thing", Resources: []map[string]any{
|
||||
{"id": "conf", "type": "file", "path": "/etc/thing", "content": "plain"},
|
||||
}}
|
||||
unused := UnusedSettings(m, []Layer{{From: "node", Values: map[string]any{"port": 1}}})
|
||||
if len(unused) != 1 || !strings.Contains(unused[0], "no file that merges it") {
|
||||
t.Errorf("settings that reached nothing were not named: %v", unused)
|
||||
}
|
||||
}
|
||||
|
||||
func TestASettingLandsOnlyWhereSomethingDeclaresIt(t *testing.T) {
|
||||
// novox/hq 04-ISSUES/173. A module that contributes a route and serves a provision takes a
|
||||
// setting for a key either declares, and a setting for a key neither declares is stray — it
|
||||
// would not reach the route or the served fact, so it must be said rather than dropped.
|
||||
m := Manifest{Module: "mail",
|
||||
Contributes: map[string]map[string]any{"reverse-proxy": {"host": "mail", "port": 8080}},
|
||||
Serves: map[string]map[string]any{"smtp": {"host": "mail", "port": 25}},
|
||||
Resources: []map[string]any{
|
||||
{"id": "env", "type": "file", "path": "/etc/mail.env", "content": "SITE=${setting:sitename}\n"},
|
||||
}}
|
||||
layers := []Layer{{From: "the mesh", Values: map[string]any{
|
||||
"host": "post", "sitename": "Mail", "website": "https://www.example.tld"}}}
|
||||
unused := UnusedSettings(m, layers)
|
||||
if len(unused) != 1 || !strings.Contains(unused[0], `"website"`) {
|
||||
t.Errorf("only website reaches nothing; named: %v", unused)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContentThatIsNotJSONIsRefusedWhereSomebodyIsLooking(t *testing.T) {
|
||||
// Rather than on the machine, at apply time, as a file the program cannot read.
|
||||
_, err := ApplySettings(file(`this is not json`), nil)
|
||||
@@ -198,27 +167,6 @@ func TestContentThatIsNotJSONIsRefusedWhereSomebodyIsLooking(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAServedValueMayBeTheOperators(t *testing.T) {
|
||||
// A mail provider serves its domain and an identity provider its issuer; neither is the
|
||||
// definition's to state (ADR 0155). Filled from the layers, refused by name when unset.
|
||||
served, err := Settle(map[string]any{"port": 587, "domain": "${setting:domain}"},
|
||||
[]Layer{{From: "the mesh", Values: map[string]any{"domain": "example.tld"}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if served["domain"] != "example.tld" {
|
||||
t.Errorf("the operator's value did not fill the served key: %v", served)
|
||||
}
|
||||
_, err = Settle(map[string]any{"domain": "${setting:domain}"}, nil)
|
||||
if err == nil || !strings.Contains(err.Error(), `"domain"`) {
|
||||
t.Errorf("a served value nothing sets must be refused by name; got %v", err)
|
||||
}
|
||||
m := Manifest{Module: "mail", Serves: map[string]map[string]any{"smtp": {"domain": "${setting:domain}"}}}
|
||||
if unused := UnusedSettings(m, []Layer{{From: "the mesh", Values: map[string]any{"domain": "x"}}}); len(unused) != 0 {
|
||||
t.Errorf("a setting a served fact asks for is not stray: %v", unused)
|
||||
}
|
||||
}
|
||||
|
||||
// No layer replaces a value the vault fills, at any depth (novox/hq issue 610): searxng's server.secret_key
|
||||
// sits under `server`, a key its file takes, and a layer reaching it was let through by a top-level-only check.
|
||||
func TestNoLayerReplacesAVaultSecretAtAnyDepth(t *testing.T) {
|
||||
|
||||
@@ -1,24 +1,5 @@
|
||||
package catalogue
|
||||
|
||||
// A machine's shares, and the shares a machine mounts (novox/hq ADR 0263).
|
||||
//
|
||||
// **Two roles, one per side of the wire.** A machine that shares a directory with the mesh does it
|
||||
// through the holder of `node-nfs-server`: it writes the machine's export file, runs the NFS service,
|
||||
// opens its port to the private network only, and provides each share as the provision `nfs-share`. A
|
||||
// machine that wants the files gets them through the holder of `node-mounts`: it writes a mount unit and
|
||||
// an automount unit per share, so nothing mounts at boot and nothing can fail a boot, and it says a
|
||||
// device that comes and goes is absent rather than failed.
|
||||
//
|
||||
// **One holder per machine on each side.** Two modules writing one machine's export file, or two writing
|
||||
// mount units for one mount point, is the conflict a seat exists to refuse. Both seats deliver nothing:
|
||||
// `nfs-share` is provided at the mesh's scope, by the module holding `node-nfs-server` on the machine that
|
||||
// shares, and a seat at a node's scope cannot be the answer for a provision at the mesh's.
|
||||
//
|
||||
// **The data is the operator's** (ADR 0051). Neither holder creates, chowns or removes anything under a
|
||||
// shared path; the export maps every client to the path's owner, so no client acts as another account on
|
||||
// the server. Their verbs read, and the ones that act take over what a person wrote by hand only on a
|
||||
// person's word: a dataset's export property, an fstab line.
|
||||
|
||||
// NFSServerSeat is the role of the machine that shares directories over NFS (novox/hq ADR 0263).
|
||||
const NFSServerSeat = "node-nfs-server"
|
||||
|
||||
|
||||
@@ -8,14 +8,6 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// What a module may call its state, and whose state it may ask to read (novox/hq ADR 0201).
|
||||
//
|
||||
// A module names its state **locally** — `servers`, never a bucket or a subject — and another
|
||||
// module's as `<module>.<name>`, the way a consumed event names its emitter (design 32 §1). The
|
||||
// mesh derives the bucket from the two names, so the module and the local name must each be one
|
||||
// token: the bucket joins them with an underscore, which neither may contain, so two modules can
|
||||
// never derive one bucket.
|
||||
|
||||
// stateName is one local name of a module's state: lower-case, no dot, no underscore.
|
||||
var stateName = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*$`)
|
||||
|
||||
|
||||
@@ -8,14 +8,6 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// A module's own stream (novox/hq ADR 0363).
|
||||
//
|
||||
// A module declares a stream as it declares a log (ADR 0297) or a bucket (ADR 0201): named locally, the
|
||||
// events it carries named locally as `emits` names them, its age and its size within the mesh's bounds.
|
||||
// The controller creates it and stays the only writer of stream definitions. A stream may be restricted
|
||||
// by a trusted config value of its own module: then a consumer hears a part of it only where its own
|
||||
// `consumes` asks and that value names it.
|
||||
|
||||
// The mesh's bounds on a module's stream: its age in days and its size in MiB, and what it gets when it
|
||||
// says nothing.
|
||||
const (
|
||||
|
||||
@@ -12,27 +12,6 @@ import (
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// A file's text kept as a template file in its module's repository (novox/hq ADR 0349 §2, design 57 package T1,
|
||||
// issue 654).
|
||||
//
|
||||
// **Two forms, one composition.** A file resource says its text inline (`content`) or names a template file
|
||||
// beside its manifest (`"template": "files/<name>"`), never both. The module's build packs `files/` into an
|
||||
// archive artifact of its own — an `archive` artifact built `from: "files"` — and resolving the build reads each
|
||||
// template's text back from that packed archive into `content` and drops `template`. So the manifest the mesh
|
||||
// holds, composition and the node-engine are exactly as they were: a module with its text in `files/` composes
|
||||
// byte-identically to the same module with it inline. Where there is no build — `module check`, the merge gate,
|
||||
// `module add` — the text is read from the repository tree beside the manifest (WithTemplatesFrom).
|
||||
//
|
||||
// **files/ unless the manifest names another directory** (issue 731). A module whose files/ is already an archive
|
||||
// unpacked onto the machine names its own (`"templates": "templates"`), packed into an archive no resource names, so
|
||||
// its templates neither land beside what it unpacks nor change that archive's digest.
|
||||
//
|
||||
// **Read back by the builder, carried in the resolved manifest.** The builder reads each template from the very
|
||||
// bytes it hashed into the archive's digest, so the text the mesh holds is the archive's. The controller
|
||||
// registers the resolved manifest as the build result carries it and fetches nothing from the artifact store; a
|
||||
// manifest that still names a template when it would be held — built by a builder that predates templates — is
|
||||
// refused at registration (HeldTemplateProblem).
|
||||
|
||||
// TemplateField is the key a file resource names its template file by.
|
||||
const TemplateField = "template"
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user