Take the catalogue's composition rules into the node-engine
ADR 0593 §7: the composer with its refusals leaves mesh-sdk, so mesh-sdk carries no composition rule. The catalogue functions only the node-engine's composition reaches (Resolve, Resolution's Compose, Declaration, DataItems, LeftOut, Rules and the rest, the settings, placeholder and template refusals, the rendering helpers) are copied with their tests into internal/compose/resolution; a method of a mesh-sdk shape becomes a function taking it first. The composition calls its own copies, and the captured catalogue its tests read lives here now, shared by mesh-compose's tests. Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
This commit is contained in:
@@ -4,7 +4,6 @@ import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"go/build"
|
||||
"io/fs"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
@@ -16,41 +15,15 @@ import (
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"github.com/novox/mesh-host/internal/beside"
|
||||
"github.com/novox/mesh-host/internal/compose"
|
||||
"github.com/novox/mesh-host/internal/composer"
|
||||
)
|
||||
|
||||
// captured is every manifest of the catalogue, raw, by module name: the clone beside a merge check (MESH_CHECK_BESIDE),
|
||||
// or the copy captured in testdata/mesh-catalog from mesh-sdk's testdata/beside (mesh-catalog b5dbfbb7, its
|
||||
// module.json files alone), each kept as module.json.captured so this repository holds no module of the catalogue.
|
||||
// captured is every manifest of the catalogue captured in testdata/beside (internal/beside), raw, by module name.
|
||||
func captured(t *testing.T) map[string]composer.Manifest {
|
||||
t.Helper()
|
||||
dir := ""
|
||||
if root := os.Getenv("MESH_CHECK_BESIDE"); root != "" {
|
||||
dir = filepath.Join(root, "mesh-catalog")
|
||||
} else {
|
||||
dir = t.TempDir()
|
||||
from, _ := filepath.Abs("testdata/mesh-catalog")
|
||||
err := filepath.WalkDir(from, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil || d.IsDir() {
|
||||
return err
|
||||
}
|
||||
rel, _ := filepath.Rel(from, path)
|
||||
into := filepath.Join(dir, strings.TrimSuffix(rel, ".captured"))
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(into), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(into, raw, 0o644)
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
read, err := readCatalogue(dir)
|
||||
read, err := readCatalogue(filepath.Dir(beside.Catalogue(t)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
// Package beside is where a test finds another repository of the mesh it reads: the catalogue's manifests,
|
||||
// the node-engine's genesis template (novox/hq issue 432), the SDK's conformance fixtures (issue 449).
|
||||
//
|
||||
// Copied from mesh-controller internal/beside at commit 6c2a8d62 (R4b, issue 1379); the one change is that Dir
|
||||
// lays out the captured copy of a repository once per process, not once per test (see laidOut). In mesh-sdk the
|
||||
// catalogue's tests read mesh-catalog and mesh-host only; the captured copy holds just what they read.
|
||||
//
|
||||
// A test used to read the checkout beside this one, `../../../mesh-catalog`, so its verdict depended on
|
||||
// whatever sat on the machine running it: a stale or dirty checkout failed it on a desktop, and where none
|
||||
// was beside it skipped and said nothing. Now there are two inputs, both named, and no third:
|
||||
//
|
||||
// - In a merge check, the build seat clones each core repository beside the one checked (the catalogue
|
||||
// at its main, the node-engine at the commit the mesh runs) and says where in MESH_CHECK_BESIDE. A
|
||||
// test judges against those clones, so agreement with the other repository is checked where
|
||||
// `mesh/repo-check` runs; a repository missing there fails the test, never skips it. Which clones a
|
||||
// check gets is chosen from the inventory: mesh-catalog by the source of the `nats` module, mesh-host
|
||||
// by the source of `mesh-host`, and mesh-sdk as the controller's sibling at the commit the controller's
|
||||
// go.mod pins. Each repository's tests read these siblings, and the checker places exactly these
|
||||
// (mesh-delivery's checker, besideRules, follows this list): mesh-controller's read mesh-catalog,
|
||||
// mesh-host and mesh-sdk; mesh-host's read mesh-catalog, mesh-sdk and mesh-controller (its step-nine
|
||||
// test reads the controller's own manifest, novox/hq issue 1295); mesh-sdk's read mesh-catalog and
|
||||
// mesh-host; mesh-catalog's read mesh-sdk and mesh-controller; mesh-delivery's read mesh-sdk and
|
||||
// mesh-catalog. In a mesh where either module has no source repository nothing is
|
||||
// cloned, and these tests fail loudly with "not beside this check": a cause in the setup, not in the
|
||||
// change. And in a delivery group that holds a mesh-catalog pull request, these tests read the
|
||||
// catalogue's main, not the group's head.
|
||||
// - Anywhere else, the test judges against the copy captured in this repository's testdata/beside, at the
|
||||
// commit testdata/beside/CAPTURED names. Never against a developer's own checkout: to judge one, set
|
||||
// MESH_CHECK_BESIDE to the directory holding it, as the check does.
|
||||
//
|
||||
// The captured manifests are named module.json.captured, and put back as module.json in a directory of
|
||||
// the test's own: a module.json anywhere in this repository is a module of it to the forge's announcer and
|
||||
// the planner, and a merge would build and register a hundred copies of the catalogue's.
|
||||
package beside
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Env is where a merge check says the repositories cloned beside the one checked are (internal/builder's
|
||||
// EnvBeside, repeated here so a test does not import the builder).
|
||||
const Env = "MESH_CHECK_BESIDE"
|
||||
|
||||
// CapturedSuffix is what a captured file's name carries that the repository's own does not.
|
||||
const CapturedSuffix = ".captured"
|
||||
|
||||
// Dir is the named repository a test reads — the clone beside a merge check, or the captured copy — and
|
||||
// says which in the test's log.
|
||||
func Dir(t testing.TB, repository string) string {
|
||||
t.Helper()
|
||||
if root := os.Getenv(Env); root != "" {
|
||||
dir := filepath.Join(root, repository)
|
||||
if _, err := os.Stat(dir); err != nil {
|
||||
t.Fatalf("%s is not beside this check in %s=%s, so its agreement with this repository cannot be "+
|
||||
"judged here: %v", repository, Env, root, err)
|
||||
}
|
||||
t.Logf("judged against %s as cloned beside this check", dir)
|
||||
return dir
|
||||
}
|
||||
from := filepath.Join(Captured(), repository)
|
||||
if _, err := os.Stat(from); err != nil {
|
||||
t.Fatalf("no captured copy of %s at %s: %v", repository, from, err)
|
||||
}
|
||||
dir, err := layOut(repository, from)
|
||||
if err != nil {
|
||||
t.Fatalf("the captured copy of %s could not be laid out: %v", repository, err)
|
||||
}
|
||||
t.Logf("judged against the copy of %s captured in testdata/beside (see CAPTURED); a merge check "+
|
||||
"judges it against the repository's own clone", repository)
|
||||
return dir
|
||||
}
|
||||
|
||||
// laidOut is the captured copies already put back in place in this process, by repository. A copy is
|
||||
// read-only to every test, so it is laid out once per process and not once per test: with a copy per test
|
||||
// the catalogue's tests alone took 2.14 s, over mesh-sdk's unit bound of 2 s, and with one 0.81 s (measured
|
||||
// 2026-10-20). The directories are in the temp directory, which the system clears; a process cannot remove
|
||||
// them while another test may still read them.
|
||||
var (
|
||||
laidOutMu sync.Mutex
|
||||
laidOut = map[string]string{}
|
||||
)
|
||||
|
||||
func layOut(repository, from string) (string, error) {
|
||||
laidOutMu.Lock()
|
||||
defer laidOutMu.Unlock()
|
||||
if dir, ok := laidOut[repository]; ok {
|
||||
return dir, nil
|
||||
}
|
||||
root, err := os.MkdirTemp("", "beside-")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
dir := filepath.Join(root, repository)
|
||||
err = filepath.WalkDir(from, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rel, _ := filepath.Rel(from, path)
|
||||
into := filepath.Join(dir, strings.TrimSuffix(rel, CapturedSuffix))
|
||||
if d.IsDir() {
|
||||
return os.MkdirAll(into, 0o755)
|
||||
}
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(into, raw, 0o644)
|
||||
})
|
||||
if err != nil {
|
||||
os.RemoveAll(root)
|
||||
return "", err
|
||||
}
|
||||
laidOut[repository] = dir
|
||||
return dir, nil
|
||||
}
|
||||
|
||||
// Catalogue is the catalogue's modules directory, as Dir finds the catalogue.
|
||||
func Catalogue(t testing.TB) string {
|
||||
t.Helper()
|
||||
return filepath.Join(Dir(t, "mesh-catalog"), "modules")
|
||||
}
|
||||
|
||||
// Captured is this repository's testdata/beside, found from this file rather than from the working
|
||||
// directory, so a test in any package reaches it.
|
||||
func Captured() string {
|
||||
_, file, _, _ := runtime.Caller(0)
|
||||
return filepath.Join(filepath.Dir(file), "..", "..", "testdata", "beside")
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
These tests read files outside this directory (the whole catalogue, the repository, or its root files): mesh-build-go never keeps this package's verdict (issue 1935).
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/declaration"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"github.com/novox/mesh-host/internal/compose/resolution"
|
||||
"github.com/novox/mesh-host/internal/overlay"
|
||||
)
|
||||
|
||||
@@ -315,7 +316,7 @@ func RunsAnyOf(runs *delivery.Composed, module string) bool {
|
||||
// nothing of has no Loads: the runtime dropped it, and the composition reads its loads again from the module's build
|
||||
// (issue 1126, review: shanks had already applied the drop of systemd-resolved). Nil when running holds no runtime process.
|
||||
func ToolsRunning(running []delivery.Composed) map[string][]catalogue.Bundle {
|
||||
runtimeID := catalogue.RuntimeModule + "." + catalogue.RuntimeProcessID()
|
||||
runtimeID := catalogue.RuntimeModule + "." + resolution.RuntimeProcessID()
|
||||
type resource struct {
|
||||
ID string `json:"id"`
|
||||
Module string `json:"module"`
|
||||
@@ -340,9 +341,9 @@ func ToolsRunning(running []delivery.Composed) map[string][]catalogue.Bundle {
|
||||
switch {
|
||||
case r.Type == "process" && r.ID == runtimeID && owner == catalogue.RuntimeModule:
|
||||
env = r.Env
|
||||
case r.Type == "archive" && owner != "" && strings.HasPrefix(r.ID, owner+"."+catalogue.BundleID("")):
|
||||
name := strings.TrimPrefix(r.ID, owner+"."+catalogue.BundleID(""))
|
||||
if r.Path == catalogue.BundlePath(owner, name) {
|
||||
case r.Type == "archive" && owner != "" && strings.HasPrefix(r.ID, owner+"."+resolution.BundleID("")):
|
||||
name := strings.TrimPrefix(r.ID, owner+"."+resolution.BundleID(""))
|
||||
if r.Path == resolution.BundlePath(owner, name) {
|
||||
out[owner] = append(out[owner], catalogue.Bundle{Name: name, Source: catalogue.Recorded(r.Source), Digest: r.Digest})
|
||||
}
|
||||
}
|
||||
@@ -396,7 +397,7 @@ func runningBundles(runs []catalogue.Bundle, man catalogue.Manifest) []catalogue
|
||||
}
|
||||
}
|
||||
if man.Build != nil {
|
||||
run := catalogue.RunByAResource(man)
|
||||
run := resolution.RunByAResource(man)
|
||||
for _, a := range man.Build.Artifacts {
|
||||
if a.Kind == catalogue.ArtifactBundle && a.Name == b.Name {
|
||||
b.Loads, b.Env, b.Binary = catalogue.BundleLoads(man, a, run), a.Env, catalogue.BinaryOf(a)
|
||||
@@ -431,7 +432,7 @@ func NamesBuild(m catalogue.Manifest) bool {
|
||||
func Stub(m catalogue.Manifest) catalogue.Manifest {
|
||||
// The paths it owns stay its manifest's, as the controller tells the holder of node-files them (issue 1126, seed-8).
|
||||
if m.OwnsPaths == nil {
|
||||
m.OwnsPaths = catalogue.DeclaredPaths(m)
|
||||
m.OwnsPaths = resolution.DeclaredPaths(m)
|
||||
}
|
||||
m.Build = nil
|
||||
// What it hands the provisioner is left out as it runs, not refused for want of a plan (catalogue.Manifest.Stubbed).
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"github.com/novox/mesh-host/internal/compose/resolution"
|
||||
"github.com/novox/mesh-host/internal/overlay"
|
||||
)
|
||||
|
||||
@@ -118,7 +119,7 @@ func TestCompositionsAreTheDeclarationSplitByModule(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
served := "lamp=" + catalogue.BundlePath("lamp", "tools") + "/lamp-tools"
|
||||
served := "lamp=" + resolution.BundlePath("lamp", "tools") + "/lamp-tools"
|
||||
runtimeRuns := func(dropped bool) delivery.Composed {
|
||||
env := `"MESH_TOOL_MODULES":"` + served + `","MESH_TOOL_ENV":"{\"lamp\":{\"LAMP_CONF\":\"/var/lib/lamp/conf\"}}"`
|
||||
if dropped {
|
||||
@@ -128,7 +129,7 @@ func TestCompositionsAreTheDeclarationSplitByModule(t *testing.T) {
|
||||
`{"id":"node-tools.runtime","module":"node-tools","type":"process","name":"node-tools","env":{` + env + `}}`)}}
|
||||
}
|
||||
lampRuns := `{"id":"lamp.bundle-tools","type":"archive","source":"` + catalogue.ArtifactStoreScheme + `lamp/tools/blobs/` + dg('b') +
|
||||
`","digest":"` + dg('b') + `","path":"` + catalogue.BundlePath("lamp", "tools") + `"}`
|
||||
`","digest":"` + dg('b') + `","path":"` + resolution.BundlePath("lamp", "tools") + `"}`
|
||||
for _, row := range []struct {
|
||||
name string
|
||||
set func(n *Node) delivery.Composed // and what runs of lamp
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"github.com/novox/mesh-host/internal/compose/resolution"
|
||||
)
|
||||
|
||||
// Mode says what a composition does about what a send would make. Both modes read what the Input holds and make nothing; they
|
||||
@@ -64,7 +65,7 @@ func (c *composer) grantsFor(node string) ([]catalogue.Grant, []catalogue.Overfl
|
||||
return nil, nil, nil, nil, fmt.Errorf("what %s asked of %s cannot be read: %w", s.Consumer, s.Provision, err)
|
||||
}
|
||||
plan := resolved.Plan
|
||||
values, asks, err := plan.ContributionsFrom(s.Provision, s.ConsumerModule, resolved.Settings)
|
||||
values, asks, err := resolution.ContributionsFrom(plan, s.Provision, s.ConsumerModule, resolved.Settings)
|
||||
if err != nil {
|
||||
return nil, nil, nil, nil, err
|
||||
}
|
||||
@@ -73,13 +74,13 @@ func (c *composer) grantsFor(node string) ([]catalogue.Grant, []catalogue.Overfl
|
||||
if err != nil {
|
||||
return nil, nil, nil, nil, err
|
||||
}
|
||||
values = catalogue.AtPublishedPort(values, s.ConsumerModule, consumer.Ports[s.ConsumerModule])
|
||||
values = resolution.AtPublishedPort(values, s.ConsumerModule, consumer.Ports[s.ConsumerModule])
|
||||
from := s.ConsumerModule
|
||||
if !asks {
|
||||
// That module no longer wants this: left empty, which is how a login is withdrawn rather than kept working for ever.
|
||||
from = ""
|
||||
}
|
||||
if bound := plan.BindsFrom(s.Provision, s.ConsumerModule, s.Local); from != "" && !slices.Contains(bound, node) {
|
||||
if bound := resolution.BindsFrom(plan, s.Provision, s.ConsumerModule, s.Local); from != "" && !slices.Contains(bound, node) {
|
||||
// It still asks, and not of this node: withdrawn exactly as a credential nobody asks for, and said.
|
||||
from = ""
|
||||
unbound = append(unbound, catalogue.Unbound{Provision: s.Provision, Provider: node,
|
||||
@@ -294,7 +295,7 @@ func (c *composer) ownSecretsFor(node Node, plan catalogue.Resolution, handing c
|
||||
foreseen[module][name] = true
|
||||
}
|
||||
for _, m := range plan.Modules {
|
||||
if handing.Handing(m) {
|
||||
if resolution.Handing(handing, m) {
|
||||
continue
|
||||
}
|
||||
// A secret family is never made (novox/hq ADR 0283): each member a person gave on this machine is placed, and one not
|
||||
@@ -391,7 +392,7 @@ type CoreBoxWanted struct {
|
||||
func coreModulesOf(modules []catalogue.Manifest, handed func(catalogue.Manifest) bool) map[string][]string {
|
||||
out := map[string][]string{}
|
||||
for _, m := range modules {
|
||||
if !catalogue.CarriesVaultSealed(m.Module) || (handed != nil && handed(m)) {
|
||||
if !resolution.CarriesVaultSealed(m.Module) || (handed != nil && handed(m)) {
|
||||
continue
|
||||
}
|
||||
var names []string
|
||||
@@ -424,7 +425,7 @@ func sealingKeyValid(raw string) error {
|
||||
// sealing key, or one whose key is not a key, leaves its module out, said.
|
||||
func (c *composer) coreBoxesFor(node Node, modules []catalogue.Manifest, handing catalogue.Rendering) (
|
||||
sealed map[string]map[string]json.RawMessage, notPlaced map[string]string, wanted []CoreBoxWanted) {
|
||||
core := coreModulesOf(modules, func(m catalogue.Manifest) bool { return handing.Handing(m) })
|
||||
core := coreModulesOf(modules, func(m catalogue.Manifest) bool { return resolution.Handing(handing, m) })
|
||||
sealed, notPlaced = map[string]map[string]json.RawMessage{}, map[string]string{}
|
||||
mods := make([]string, 0, len(core))
|
||||
for m := range core {
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"github.com/novox/mesh-host/internal/compose"
|
||||
"github.com/novox/mesh-host/internal/compose/resolution"
|
||||
)
|
||||
|
||||
// Answer is a node's answer to its proposal's placement candidates (novox/hq ADR 0567 §3, ADR 0559 §4): s.Node composed in shadow with
|
||||
@@ -410,7 +411,7 @@ func blames(m string, again, err error) bool {
|
||||
if m == catalogue.RuntimeModule {
|
||||
// Or where its stub fails only for the modules of their own account it would run (issue 774): they are held
|
||||
// after it, each for its own words, and the node composes the rest.
|
||||
var none *catalogue.NoRuntimeToRunError
|
||||
var none *resolution.NoRuntimeToRunError
|
||||
return again == nil || errors.As(again, &none) && none.Held
|
||||
}
|
||||
return again == nil || again.Error() != err.Error()
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"github.com/novox/mesh-host/internal/compose"
|
||||
"github.com/novox/mesh-host/internal/compose/resolution"
|
||||
"github.com/novox/mesh-host/internal/overlay"
|
||||
)
|
||||
|
||||
@@ -260,7 +261,7 @@ func answerNamesEveryRefusalAndLeavesAnOldBuildToTheNode(t *testing.T) {
|
||||
switch r.ID {
|
||||
case "nft." + catalogue.FilteringID():
|
||||
rules = r.Content
|
||||
case "vault." + catalogue.KeptID():
|
||||
case "vault." + resolution.KeptID():
|
||||
keptComposed = raw
|
||||
}
|
||||
}
|
||||
@@ -291,7 +292,7 @@ func answerNamesEveryRefusalAndLeavesAnOldBuildToTheNode(t *testing.T) {
|
||||
json.RawMessage(`{"id":"f1-circuits.cache","module":"f1-circuits","type":"directory","path":"/var/lib/f1-circuits/cache","mode":"0700"}`),
|
||||
json.RawMessage(`{"id":"f1-circuits.site-env","module":"f1-circuits","type":"file","path":"/var/lib/f1-circuits/site.env","mode":"0644","content":"F1_CIRCUITS_BASE_URL=https://f1.example\n"}`),
|
||||
json.RawMessage(`{"id":"f1-circuits.server","module":"f1-circuits","type":"container","name":"f1-circuits","image":"registry.example/f1@sha256:` + strings.Repeat("c", 64) + `"}`)}}
|
||||
if got, want := catalogue.DeclaredPaths(compose.Carried(f1, "f1-circuits", &f1runs)), catalogue.DeclaredPaths(f1); !reflect.DeepEqual(got, want) {
|
||||
if got, want := resolution.DeclaredPaths(compose.Carried(f1, "f1-circuits", &f1runs)), resolution.DeclaredPaths(f1); !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("f1-circuits carried owns %v, want what the controller composes from its manifest, %v", got, want)
|
||||
}
|
||||
// Issue 1126: a held module hands no template its build kept for a file it no longer carries (novox's node:
|
||||
|
||||
@@ -45,6 +45,7 @@ import (
|
||||
"git.novox.be/novox/mesh-sdk/go/declaration"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"github.com/novox/mesh-host/internal/compose"
|
||||
"github.com/novox/mesh-host/internal/compose/resolution"
|
||||
"github.com/novox/mesh-host/internal/overlay"
|
||||
"github.com/novox/mesh-host/internal/takein"
|
||||
)
|
||||
@@ -666,7 +667,7 @@ func plansIn(in compose.Input, node string, mode compose.Mode) (map[string]catal
|
||||
}
|
||||
with := rendered.Rendering
|
||||
with.Generators = gens
|
||||
composed, err := resolved.Plan.Compose(with)
|
||||
composed, err := resolution.Compose(resolved.Plan, with)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("frombus: the plans of %s: %w", node, err)
|
||||
}
|
||||
@@ -1095,7 +1096,7 @@ func storeSought(in compose.Input) string {
|
||||
func keptOf(c delivery.Composed) map[string]any {
|
||||
for _, raw := range c.Resources {
|
||||
var r map[string]any
|
||||
if json.Unmarshal(raw, &r) == nil && r["id"] == c.Module+"."+catalogue.KeptID() {
|
||||
if json.Unmarshal(raw, &r) == nil && r["id"] == c.Module+"."+resolution.KeptID() {
|
||||
return r
|
||||
}
|
||||
}
|
||||
@@ -1110,7 +1111,7 @@ func membershipOf(own delivery.Composed) string {
|
||||
ID string `json:"id"`
|
||||
Sealed string `json:"sealed"`
|
||||
}
|
||||
if json.Unmarshal(raw, &r) == nil && r.ID == catalogue.BusMembershipID() {
|
||||
if json.Unmarshal(raw, &r) == nil && r.ID == resolution.BusMembershipID() {
|
||||
return r.Sealed
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"github.com/novox/mesh-host/internal/compose"
|
||||
"github.com/novox/mesh-host/internal/compose/resolution"
|
||||
)
|
||||
|
||||
// aNode is a bus with n1 the hub, each of manifests placed on it and held at the indexer, in the order given.
|
||||
@@ -101,8 +102,8 @@ func theRuntimeIsBlamedOnlyWhereItsStubComposes(t *testing.T) {
|
||||
{"lamp", errors.New("lamp refuses"), false},
|
||||
{catalogue.RuntimeModule, nil, true},
|
||||
{catalogue.RuntimeModule, errors.New("node-tools is assigned to n1 and its build produced 0 bundle(s)"), false},
|
||||
{catalogue.RuntimeModule, fmt.Errorf("compose: %w", &catalogue.NoRuntimeToRunError{Held: true}), true},
|
||||
{catalogue.RuntimeModule, &catalogue.NoRuntimeToRunError{}, false},
|
||||
{catalogue.RuntimeModule, fmt.Errorf("compose: %w", &resolution.NoRuntimeToRunError{Held: true}), true},
|
||||
{catalogue.RuntimeModule, &resolution.NoRuntimeToRunError{}, false},
|
||||
} {
|
||||
if got := blames(row.module, row.again, err); got != row.blamed {
|
||||
t.Errorf("%s with %v: blamed %v, want %v", row.module, row.again, got, row.blamed)
|
||||
@@ -125,7 +126,7 @@ func aRunningModuleWaitsForItsBuild(t *testing.T) {
|
||||
running := []delivery.Composed{{Node: "n1", Module: catalogue.RuntimeModule, FP: old, Resources: []json.RawMessage{
|
||||
json.RawMessage(`{"id":"node-tools.runtime","module":"node-tools","type":"process","name":"node-tools","env":{"MESH_TOOL_MODULES":""}}`),
|
||||
json.RawMessage(`{"id":"node-tools.bundle-runtime","module":"node-tools","type":"archive","source":"artifact-store://node-tools/runtime/blobs/` +
|
||||
dg("1") + `","digest":"` + dg("1") + `","path":"` + catalogue.BundlePath("node-tools", "runtime") + `"}`)}}}
|
||||
dg("1") + `","digest":"` + dg("1") + `","path":"` + resolution.BundlePath("node-tools", "runtime") + `"}`)}}}
|
||||
for _, row := range []struct {
|
||||
name string
|
||||
want string // the build node-tools' want names
|
||||
|
||||
@@ -15,9 +15,9 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"github.com/novox/mesh-host/internal/compose"
|
||||
"github.com/novox/mesh-host/internal/compose/resolution"
|
||||
)
|
||||
|
||||
// The states a module names in its manifest's `reads` to compose through StatesReader.
|
||||
@@ -135,7 +135,7 @@ func (r statesReader) Values(_ context.Context, node, module string) (map[string
|
||||
if !ok || strings.Contains(name, ".") {
|
||||
return
|
||||
}
|
||||
if value, laid := catalogue.LaidValue(v); laid {
|
||||
if value, laid := resolution.LaidValue(v); laid {
|
||||
if out == nil {
|
||||
out = map[string]any{}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"github.com/novox/mesh-host/internal/compose/resolution"
|
||||
"github.com/novox/mesh-host/internal/overlay"
|
||||
)
|
||||
|
||||
@@ -282,7 +283,7 @@ func (c *composer) onTheNetworkNow() []Node {
|
||||
if len(n.Assigned) == 0 {
|
||||
continue
|
||||
}
|
||||
got, err := catalogue.Resolve(c.in.Catalogue, n.Assigned,
|
||||
got, err := resolution.Resolve(c.in.Catalogue, n.Assigned,
|
||||
catalogue.Node{Name: n.Name, Site: n.Place.Site, Capabilities: n.Capabilities},
|
||||
catalogue.World{Unchecked: true, Holdings: c.in.Holdings})
|
||||
if err != nil {
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"github.com/novox/mesh-host/internal/compose/resolution"
|
||||
"github.com/novox/mesh-host/internal/validate"
|
||||
)
|
||||
|
||||
@@ -178,13 +179,13 @@ func (c *composer) declaration(nodeName string, mode Mode) (Declared, error) {
|
||||
with := rendered.Rendering
|
||||
with.Generators = gens
|
||||
plan := resolved.Plan
|
||||
composed, err := plan.Compose(with)
|
||||
composed, err := resolution.Compose(plan, with)
|
||||
if err != nil {
|
||||
return Declared{}, err
|
||||
}
|
||||
// Which ports the filter admits from the local network, and from which networks (novox/hq ADR 0340 clause 8): the same rules the
|
||||
// composition rendered, never sent.
|
||||
rules, err := plan.Rules(with)
|
||||
rules, err := resolution.Rules(plan, with)
|
||||
if err != nil {
|
||||
return Declared{}, err
|
||||
}
|
||||
@@ -198,7 +199,7 @@ func (c *composer) declaration(nodeName string, mode Mode) (Declared, error) {
|
||||
Withheld: rendered.Withheld, Unbound: rendered.Unbound,
|
||||
Foreseen: composed.Foreseen, StoodIn: rendered.Foreseen, ForeseenPorts: rendered.ForeseenPorts,
|
||||
Unplaced: composed.Unplaced, HandedOnly: composed.HandedOnly, Modules: namedModules(plan, composed.LeftOut),
|
||||
Filled: composed.Filled, LocalNetwork: catalogue.LocalNetworkPorts(rules, with.LocalNetworks),
|
||||
Filled: composed.Filled, LocalNetwork: resolution.LocalNetworkPorts(rules, with.LocalNetworks),
|
||||
CoreBoxesWanted: rendered.CoreBoxesWanted, Said: rendered.Said,
|
||||
Sequence: node.Sequence, Epoch: node.Epoch, Generation: node.Generation}
|
||||
if len(composed.Filled) > 0 {
|
||||
@@ -206,7 +207,7 @@ func (c *composer) declaration(nodeName string, mode Mode) (Declared, error) {
|
||||
}
|
||||
// A handed module only pulled in by another keeps what requires it, for its value of its own in the assignments mirror (issue 765).
|
||||
for module := range composed.Plans {
|
||||
if by := plan.RequiredBy(module); by != "" {
|
||||
if by := resolution.RequiredBy(plan, module); by != "" {
|
||||
if out.RequiredBy == nil {
|
||||
out.RequiredBy = map[string]string{}
|
||||
}
|
||||
@@ -347,5 +348,5 @@ func DataItems(in Input, node string) (map[string][]catalogue.PlacedDataItem, []
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return resolved.Plan.DataItems(rendered.Rendering)
|
||||
return resolution.DataItems(resolved.Plan, rendered.Rendering)
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"github.com/novox/mesh-host/internal/compose/resolution"
|
||||
"github.com/novox/mesh-host/internal/overlay"
|
||||
)
|
||||
|
||||
@@ -156,7 +157,7 @@ func (c *composer) places(nodeName string, resolved Resolved) (Placed, error) {
|
||||
// Which of this machine's links face outside (novox/hq ADR 0140), and which of its networks count as its local network
|
||||
// (ADR 0340).
|
||||
r.OutwardLinks = node.OutwardLinks
|
||||
r.LocalNetworks = catalogue.CountedLocalNetworks(localNetworksOf(node))
|
||||
r.LocalNetworks = resolution.CountedLocalNetworks(localNetworksOf(node))
|
||||
|
||||
// Where this machine reaches each mesh seat's holder, for ${seat:<seat>:reach} (novox/hq ADR 0222).
|
||||
if store != "" {
|
||||
@@ -205,7 +206,7 @@ func (c *composer) portsFor(node Node, plan catalogue.Resolution, settings catal
|
||||
|
||||
given := map[string]map[int]int{}
|
||||
for _, m := range plan.Modules {
|
||||
g, err := catalogue.GivenPorts(m, settings[m.Module])
|
||||
g, err := resolution.GivenPorts(m, settings[m.Module])
|
||||
if err != nil {
|
||||
// A given port its definition no longer publishes: the module is left out of the declaration, by name, when it is
|
||||
// composed (novox/hq ADR 0163, rule 6) -- never the machine refused here for it.
|
||||
@@ -403,14 +404,14 @@ func (c *composer) zonesInTheMesh() ([]catalogue.ZoneAt, error) {
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the zone %s declares on %s cannot be read: %w", m.Module, n.Name, err)
|
||||
}
|
||||
z, err := catalogue.ZoneOn(m, layers, published, n.Name, at)
|
||||
z, err := resolution.ZoneOn(m, layers, published, n.Name, at)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
zones = append(zones, *z)
|
||||
}
|
||||
}
|
||||
if problems := catalogue.ZonesProblems(zones, c.suffix(), public); len(problems) > 0 {
|
||||
if problems := resolution.ZonesProblems(zones, c.suffix(), public); len(problems) > 0 {
|
||||
return nil, fmt.Errorf("the mesh's zones cannot be forwarded:\n - %s", strings.Join(problems, "\n - "))
|
||||
}
|
||||
return zones, nil
|
||||
@@ -511,7 +512,7 @@ func (c *composer) seatsOn(node Node, inSet []catalogue.Manifest, adopted bool,
|
||||
if adopted && !taken[name] {
|
||||
layers := node.Settings[m.Module]
|
||||
ports = map[int]int{}
|
||||
if given, err := catalogue.GivenPorts(m, layers); err == nil {
|
||||
if given, err := resolution.GivenPorts(m, layers); err == nil {
|
||||
ports = given
|
||||
}
|
||||
}
|
||||
@@ -539,5 +540,5 @@ func localNetworksOf(n Node) []catalogue.LocalNetwork {
|
||||
if !n.NetworksSaid {
|
||||
return nil
|
||||
}
|
||||
return catalogue.JudgeLocalNetworks(n.Networks, n.OutwardLinks, overlay.Interface)
|
||||
return resolution.JudgeLocalNetworks(n.Networks, n.OutwardLinks, overlay.Interface)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
package resolution
|
||||
|
||||
func indexOf(out []map[string]any, id string) int {
|
||||
for i, r := range out {
|
||||
if r["id"] == id {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package resolution
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
|
||||
// The fault 04-ISSUES/036 records: a media stack is several modules — a library server, the
|
||||
// acquisition managers, a download client — that must share directories on one machine. Written
|
||||
// as owned `directory` resources, two of them on one node are refused as rivalrous owners, which
|
||||
// is right in general and wrong here: sharing those directories is the whole point of the stack.
|
||||
//
|
||||
// Declared as accesses to an operator-owned path (novox/hq ADR 0051), they co-resolve. An access
|
||||
// is not a resource and never enters the duplicate-owner map, so this is the exact case the
|
||||
// resolver refuses above when the same path is owned — and does not, when it is merely accessed.
|
||||
func TestTwoModulesAccessingOnePathCoResolve(t *testing.T) {
|
||||
a := mod("a", nil, nil, nil)
|
||||
a.Accesses = []mcat.Access{{Path: "/services/media/downloads", Mode: mcat.AccessReadWrite}}
|
||||
b := mod("b", nil, nil, nil)
|
||||
b.Accesses = []mcat.Access{{Path: "/services/media/downloads", Mode: mcat.AccessRead}}
|
||||
|
||||
got, err := Resolve(shelf(a, b), []string{"a", "b"}, workstation(), mcat.World{})
|
||||
if err != nil {
|
||||
t.Fatalf("two modules accessing one operator-owned path were refused: %v", err)
|
||||
}
|
||||
|
||||
// And each accessing module's grant reaches the machine as its own `access` resource, so the
|
||||
// host can find the path present or refuse — both naming the one operator-owned path.
|
||||
var accesses int
|
||||
for _, r := range mustDeclare(t, got) {
|
||||
if r["type"] == "access" && r["path"] == "/services/media/downloads" {
|
||||
accesses++
|
||||
}
|
||||
}
|
||||
if accesses != 2 {
|
||||
t.Errorf("expected both modules' accesses in the declaration, got %d", accesses)
|
||||
}
|
||||
}
|
||||
|
||||
// The mirror of the case above, so the sharing vocabulary does not weaken the rule it sits beside:
|
||||
// two modules OWNING one path is still refused, exactly as before accesses existed.
|
||||
func TestTwoModulesOwningOnePathAreStillRefused(t *testing.T) {
|
||||
a := mod("a", nil, nil, nil)
|
||||
a.Resources = []map[string]any{
|
||||
{"id": "lib", "type": "directory", "path": "/services/media/movies", "mode": "0755"}}
|
||||
b := mod("b", nil, nil, nil)
|
||||
b.Resources = []map[string]any{
|
||||
{"id": "lib", "type": "directory", "path": "/services/media/movies", "mode": "0755"}}
|
||||
|
||||
_, err := Resolve(shelf(a, b), []string{"a", "b"}, workstation(), mcat.World{})
|
||||
if err == nil {
|
||||
t.Fatal("two modules owning the same directory were both assigned")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "/services/media/movies") {
|
||||
t.Errorf("the refusal does not name the path: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Shared data is the operator's, owned by no module (novox/hq ADR 0051). A module owning the path
|
||||
// another module was told to expect the operator to provide would create and chown it — and the
|
||||
// two intentions cannot both hold, so it is refused by name. This is what keeps the distinction
|
||||
// machine-checkable rather than a convention nobody enforces.
|
||||
func TestAModuleMayNotOwnWhatAnotherAccesses(t *testing.T) {
|
||||
owns := mod("owns", nil, nil, nil)
|
||||
owns.Resources = []map[string]any{
|
||||
{"id": "lib", "type": "directory", "path": "/services/media/movies", "mode": "0755"}}
|
||||
uses := mod("uses", nil, nil, nil)
|
||||
uses.Accesses = []mcat.Access{{Path: "/services/media/movies", Mode: mcat.AccessRead}}
|
||||
|
||||
_, err := Resolve(shelf(owns, uses), []string{"owns", "uses"}, workstation(), mcat.World{})
|
||||
if err == nil {
|
||||
t.Fatal("a module owning a path another accesses was allowed")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "/services/media/movies") ||
|
||||
!strings.Contains(err.Error(), "the operator's") {
|
||||
t.Errorf("the refusal does not explain the ownership conflict: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
package resolution
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
|
||||
// GuardID, GuardUnitID and GuardRunningID are the guard's resource identities. The installer
|
||||
// raises the same three on an adopted genesis, so the first push finds them already there.
|
||||
func GuardID() string { return mcat.AdoptionPrefix + "guard" }
|
||||
|
||||
func GuardUnitID() string { return mcat.AdoptionPrefix + "guard-unit" }
|
||||
|
||||
func GuardRunningID() string { return mcat.AdoptionPrefix + "guard-running" }
|
||||
|
||||
// GuardPackageID is the tool that loads the guard, declared first: a node joining adopted has
|
||||
// no filter module and may have no nft at all, and a table nothing can load guards nothing.
|
||||
func GuardPackageID() string { return mcat.AdoptionPrefix + "guard-package" }
|
||||
|
||||
// OpeningID is an opening's resource identity: its protocol, port and path say what it is.
|
||||
func OpeningID(protocol string, port int, path string) string {
|
||||
return fmt.Sprintf("%sopening-%s-%d-%s", mcat.AdoptionPrefix, protocol, port, path)
|
||||
}
|
||||
|
||||
// Openings are what the mesh needs reachable on an adopted node, from the same inputs as the
|
||||
// filter it would load were the node converged, each from where that filter would admit it.
|
||||
//
|
||||
// `rules` is Filtering's answer — every module's listens, the hub's port, the per-node exposure —
|
||||
// and `foundation` is the ports the mesh itself needs, from everywhere. A rule for this machine
|
||||
// only opens nothing. `published` maps a machine port a container publishes to the container's
|
||||
// port: a published port is forwarded, not received, so its opening names the forwarded path and
|
||||
// the port the packet is forwarded to.
|
||||
func Openings(rules []mcat.Rule, foundation []int, published map[string]map[int]int) []map[string]any {
|
||||
type key struct {
|
||||
protocol string
|
||||
port int
|
||||
}
|
||||
from := map[key]string{}
|
||||
var order []key
|
||||
widen := func(k key, f string) {
|
||||
was, seen := from[k]
|
||||
if !seen {
|
||||
order = append(order, k)
|
||||
}
|
||||
if !seen || was != mcat.OpeningFromEverywhere {
|
||||
from[k] = f
|
||||
}
|
||||
}
|
||||
for _, rule := range rules {
|
||||
switch rule.From {
|
||||
case mcat.FromEverywhere:
|
||||
widen(key{rule.Protocol, rule.Port}, mcat.OpeningFromEverywhere)
|
||||
case mcat.FromMesh:
|
||||
widen(key{rule.Protocol, rule.Port}, mcat.OpeningFromMesh)
|
||||
case mcat.FromLocalNetwork:
|
||||
// The private network's part only (novox/hq ADR 0340). An adopted node's found firewall
|
||||
// stays, and it is what admits the machine's local network; an opening has no word for a
|
||||
// local network, and widening it to everywhere would open the port to the internet.
|
||||
widen(key{rule.Protocol, rule.Port}, mcat.OpeningFromMesh)
|
||||
}
|
||||
}
|
||||
for _, port := range foundation {
|
||||
widen(key{"tcp", port}, mcat.OpeningFromEverywhere)
|
||||
}
|
||||
sort.Slice(order, func(a, b int) bool {
|
||||
if order[a].port != order[b].port {
|
||||
return order[a].port < order[b].port
|
||||
}
|
||||
return order[a].protocol < order[b].protocol
|
||||
})
|
||||
out := make([]map[string]any, 0, len(order))
|
||||
for _, k := range order {
|
||||
opening := map[string]any{"type": "opening", "port": k.port, "protocol": k.protocol,
|
||||
"from": from[k]}
|
||||
if to, forwarded := published[k.protocol][k.port]; forwarded {
|
||||
opening["id"] = OpeningID(k.protocol, k.port, mcat.PathForwarded)
|
||||
opening["path"] = mcat.PathForwarded
|
||||
opening["to"] = to
|
||||
} else {
|
||||
opening["id"] = OpeningID(k.protocol, k.port, mcat.PathIncoming)
|
||||
opening["path"] = mcat.PathIncoming
|
||||
}
|
||||
out = append(out, opening)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// AsGuard renders the mesh's refusal-only table for the given machine ports.
|
||||
//
|
||||
// It passes everything by default and holds nothing but a refusal, so it cannot close anything
|
||||
// the machine serves; and it is the mesh's own table, so the found firewall reloading does not
|
||||
// touch it. It refuses only packets addressed to this machine, and the ports except from the
|
||||
// machine itself — its loopback and the container runtime's own networks — and from the private
|
||||
// network, known by the interface a packet arrives on and never by its source address. At
|
||||
// prerouting, ahead of the runtime's destination translation, so it matches the port the packet
|
||||
// was sent to; in the inet family, so both address families.
|
||||
//
|
||||
// **The machine's own interfaces are named, where the derived filter names address ranges.** The
|
||||
// filter accepts the container runtime's networks by CIDR; this excludes its bridges by name — lo,
|
||||
// docker0, the br-* a compose network gets, and the mesh's own mesh0. A runtime whose bridge is
|
||||
// named anything else (a podman or libvirt bridge, or a docker network created with a fixed name)
|
||||
// would have its containers' traffic to a guarded port refused, which reads as the port being
|
||||
// down. Names rather than addresses is deliberate: a source address can be claimed by whoever
|
||||
// sends the packet, and this table exists to refuse what the found firewall never sees. Widening
|
||||
// it means adding names here and in the installer's copy together, which the golden test holds to
|
||||
// one text.
|
||||
//
|
||||
// The same text the installer raises on an adopted genesis; a test holds both to it.
|
||||
func AsGuard(ports []int) string {
|
||||
sorted := append([]int{}, ports...)
|
||||
sort.Ints(sorted)
|
||||
listed := make([]string, len(sorted))
|
||||
for i, p := range sorted {
|
||||
listed[i] = strconv.Itoa(p)
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString("table inet mesh_guard {}\n")
|
||||
b.WriteString("delete table inet mesh_guard\n")
|
||||
b.WriteString("table inet mesh_guard {\n")
|
||||
b.WriteString("\tchain prerouting {\n")
|
||||
b.WriteString("\t\ttype filter hook prerouting priority raw; policy accept;\n")
|
||||
// Only packets addressed to this machine: traffic it routes for others — a predecessor's hub,
|
||||
// say — is never the guard's business (novox/hq ADR 0103).
|
||||
fmt.Fprintf(&b, "\t\tfib daddr type local iifname != \"lo\" iifname != \"docker0\" "+
|
||||
"iifname != \"br-*\" iifname != \"mesh0\" tcp dport { %s } drop\n",
|
||||
strings.Join(listed, ", "))
|
||||
b.WriteString("\t}\n")
|
||||
b.WriteString("}\n")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// GuardUnitText is the unit that loads the guard. Stopping it deletes only its own table: never
|
||||
// a flush, which would take the container runtime's rules and the found firewall with it.
|
||||
func GuardUnitText() string {
|
||||
return "[Unit]\n" +
|
||||
"Description=The mesh's guard: refuses its own ports from outside (novox/hq ADR 0100)\n" +
|
||||
// Early, before the network is up, and without the default dependencies that would
|
||||
// order it after the network; stopped at shutdown like any unit.
|
||||
"DefaultDependencies=no\n" +
|
||||
"Wants=network-pre.target\n" +
|
||||
"Before=network-pre.target shutdown.target\n" +
|
||||
"Conflicts=shutdown.target\n" +
|
||||
"\n" +
|
||||
"[Service]\n" +
|
||||
"Type=oneshot\n" +
|
||||
"RemainAfterExit=yes\n" +
|
||||
"ExecStart=nft -f " + mcat.GuardPath + "\n" +
|
||||
"ExecReload=nft -f " + mcat.GuardPath + "\n" +
|
||||
"ExecStop=nft delete table inet mesh_guard\n" +
|
||||
"\n" +
|
||||
"[Install]\n" +
|
||||
"WantedBy=multi-user.target\n"
|
||||
}
|
||||
|
||||
// GuardResources are the guard as four resources of the existing kinds: the tool that loads it,
|
||||
// the table, the unit, and the unit running — reloaded when the table changes, so the new table
|
||||
// replaces the old in one `nft -f` through the unit's ExecReload with no moment unguarded, and
|
||||
// restarted only when the unit itself changes. Nothing when there is nothing to guard: an empty
|
||||
// set is not a table nft loads.
|
||||
func GuardResources(ports []int) []map[string]any {
|
||||
if len(ports) == 0 {
|
||||
return nil
|
||||
}
|
||||
return []map[string]any{
|
||||
{"id": GuardPackageID(), "type": "package", "package": mcat.GuardPackage},
|
||||
{"id": GuardID(), "type": "file", "path": mcat.GuardPath, "content": AsGuard(ports),
|
||||
"mode": "0644"},
|
||||
{"id": GuardUnitID(), "type": "file", "path": mcat.GuardUnitPath, "content": GuardUnitText(),
|
||||
"mode": "0644"},
|
||||
{"id": GuardRunningID(), "type": "service", "unit": mcat.GuardUnit, "state": "running",
|
||||
"boot": "enabled", "restart-on": []any{GuardUnitID()}, "reload-on": []any{GuardID()}},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,629 @@
|
||||
package resolution
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
|
||||
// hub is the private network's generator on the hub: it opens the hub's port from anywhere.
|
||||
type hub struct{}
|
||||
|
||||
func (hub) Resources(string) ([]map[string]any, bool, error) {
|
||||
return []map[string]any{{"id": "config", "type": "file", "path": "/etc/wireguard/mesh0.conf",
|
||||
"content": "[Interface]\n"}}, true, nil
|
||||
}
|
||||
|
||||
func (hub) Listens(string) ([]mcat.Listening, error) {
|
||||
return []mcat.Listening{{Port: 51820, Protocol: "udp", From: mcat.FromEverywhere}}, nil
|
||||
}
|
||||
|
||||
// anAdoptedAnchor is the control-node's set: the store, the bus, the registry, the private network,
|
||||
// a served module and the filter module.
|
||||
func anAdoptedAnchor() mcat.Resolution {
|
||||
return mcat.Resolution{Node: "anchor", Modules: []mcat.Manifest{
|
||||
{Module: "network", Computed: "overlay"},
|
||||
{Module: "postgres", Guards: []int{5432},
|
||||
Listens: []mcat.Listening{{Port: 5432, From: mcat.FromMesh}},
|
||||
Resources: []map[string]any{{"id": "server", "type": "container", "name": "mesh-store",
|
||||
"ports": []any{"5432:5432"}}}},
|
||||
{Module: "lavinmq", Guards: []int{15672},
|
||||
Listens: []mcat.Listening{{Port: 5671, From: mcat.FromMesh}, {Port: 5672, From: mcat.FromMesh}},
|
||||
Resources: []map[string]any{{"id": "server", "type": "container", "name": "mesh-broker",
|
||||
"ports": []any{"5671:5671", "5672:5672", "127.0.0.1:15672:15672"}}}},
|
||||
{Module: "distribution",
|
||||
Listens: []mcat.Listening{{Port: 5000, From: mcat.FromMesh}},
|
||||
Resources: []map[string]any{{"id": "store", "type": "container", "name": "registry",
|
||||
"ports": []any{"5000"}}}},
|
||||
{Module: "hello-web",
|
||||
Listens: []mcat.Listening{{Port: 8080, From: mcat.FromEverywhere}},
|
||||
Resources: []map[string]any{{"id": "server", "type": "container", "name": "hello-web",
|
||||
"ports": []any{"8080"}}}},
|
||||
{Module: "helper", Listens: []mcat.Listening{{Port: 9000, From: mcat.FromMachine}}},
|
||||
{Module: "nftables", Filtering: &mcat.Filtering{Into: "/etc/nftables.conf"},
|
||||
Resources: []map[string]any{{"id": "load", "type": "service", "unit": "mesh-filter.service",
|
||||
"state": "running", "restart-on": []any{"filtering"}}}},
|
||||
}}
|
||||
}
|
||||
|
||||
func anchorRendering(adopted bool) mcat.Rendering {
|
||||
return mcat.Rendering{
|
||||
Generators: map[string]mcat.Generator{"overlay": hub{}},
|
||||
Ports: map[string]map[int]int{"distribution": {5000: 5000}, "hello-web": {8080: 20001}},
|
||||
Settings: mcat.SettingsBy{"distribution": {{From: "node anchor",
|
||||
Placement: map[string]any{mcat.ExposeSetting: map[string]any{"5000": mcat.FromEverywhere}}}}},
|
||||
Mesh: []string{"10.42.0.1"},
|
||||
Foundation: []int{5671},
|
||||
// What the machine reported faces outside, which every rule in the filter is written
|
||||
// around (novox/hq ADR 0140).
|
||||
OutwardLinks: []string{"eth0"},
|
||||
TunnelInterface: "mesh0",
|
||||
Adopted: adopted,
|
||||
// Genesis takes the foundation's modules.
|
||||
Taken: map[string]bool{"postgres": true, "lavinmq": true},
|
||||
}
|
||||
}
|
||||
|
||||
func byID(resources []map[string]any) map[string]map[string]any {
|
||||
out := map[string]map[string]any{}
|
||||
for _, r := range resources {
|
||||
out[r["id"].(string)] = r
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestAnAdoptedNodeIsDeclaredOpeningsFromTheSameInputsAsTheFilter(t *testing.T) {
|
||||
composed, err := Compose(anAdoptedAnchor(), anchorRendering(true))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := byID(composed.Resources)
|
||||
want := map[string]map[string]any{
|
||||
// The hub's port, from anywhere, received.
|
||||
"adoption.opening-udp-51820-incoming": {"port": 51820, "protocol": "udp",
|
||||
"from": "everywhere", "path": "incoming"},
|
||||
// The store's port from the private network only, and forwarded: a container publishes it.
|
||||
"adoption.opening-tcp-5432-forwarded": {"port": 5432, "protocol": "tcp", "from": "mesh",
|
||||
"path": "forwarded", "to": 5432},
|
||||
// The bus from anywhere: a node enrols over it before it has a private address.
|
||||
"adoption.opening-tcp-5671-forwarded": {"port": 5671, "protocol": "tcp",
|
||||
"from": "everywhere", "path": "forwarded", "to": 5671},
|
||||
"adoption.opening-tcp-5672-forwarded": {"port": 5672, "protocol": "tcp", "from": "mesh",
|
||||
"path": "forwarded", "to": 5672},
|
||||
// The registry from anywhere, by its node's exposure setting.
|
||||
"adoption.opening-tcp-5000-forwarded": {"port": 5000, "protocol": "tcp",
|
||||
"from": "everywhere", "path": "forwarded", "to": 5000},
|
||||
// A published port names the machine port and the container port it is forwarded to.
|
||||
"adoption.opening-tcp-20001-forwarded": {"port": 20001, "protocol": "tcp",
|
||||
"from": "everywhere", "path": "forwarded", "to": 8080},
|
||||
}
|
||||
for id, fields := range want {
|
||||
opening, ok := got[id]
|
||||
if !ok {
|
||||
t.Errorf("no %s among %v", id, keys(got))
|
||||
continue
|
||||
}
|
||||
if opening["type"] != "opening" {
|
||||
t.Errorf("%s is a %v", id, opening["type"])
|
||||
}
|
||||
for k, v := range fields {
|
||||
if opening[k] != v {
|
||||
t.Errorf("%s: %s is %v, want %v", id, k, opening[k], v)
|
||||
}
|
||||
}
|
||||
}
|
||||
for id := range got {
|
||||
if strings.HasPrefix(id, "adoption.opening-") && want[id] == nil {
|
||||
t.Errorf("an opening nothing asked for: %s", id)
|
||||
}
|
||||
}
|
||||
// A port for this machine only opens nothing, and the management port is not opened at all.
|
||||
for id := range got {
|
||||
if strings.Contains(id, "-9000-") || strings.Contains(id, "-15672-") {
|
||||
t.Errorf("%s is opened", id)
|
||||
}
|
||||
}
|
||||
|
||||
// And openings come first, in the order the machine applies them.
|
||||
if !strings.HasPrefix(composed.Resources[0]["id"].(string), "adoption.opening-") {
|
||||
t.Errorf("openings are not first: %v", composed.Resources[0]["id"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnAdoptedNodeLoadsNoFilterOfTheMeshs(t *testing.T) {
|
||||
composed, err := Compose(anAdoptedAnchor(), anchorRendering(true))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, r := range composed.Resources {
|
||||
if r["path"] == "/etc/nftables.conf" || strings.HasPrefix(r["id"].(string), "nftables.") {
|
||||
t.Fatalf("an adopted node is declared the filter module's %v", r["id"])
|
||||
}
|
||||
if content, _ := r["content"].(string); strings.Contains(content, "policy drop") {
|
||||
t.Fatalf("an adopted node is declared a table that drops by default: %v", r["id"])
|
||||
}
|
||||
// Nothing but refusals: the only accept in the guard is its policy.
|
||||
if content, _ := r["content"].(string); r["id"] == GuardID() &&
|
||||
strings.Count(content, "accept") != 1 {
|
||||
t.Fatalf("the guard holds an accept:\n%s", content)
|
||||
}
|
||||
}
|
||||
got := byID(composed.Resources)
|
||||
guard := got[GuardID()]
|
||||
if guard == nil || got[GuardUnitID()] == nil || got[GuardRunningID()] == nil {
|
||||
t.Fatalf("no guard: %v", keys(got))
|
||||
}
|
||||
if guard["content"] != AsGuard([]int{5432, 5672, 15672}) {
|
||||
t.Fatalf("the guard does not guard the store, the broker and its management port:\n%s",
|
||||
guard["content"])
|
||||
}
|
||||
// The tool that loads it comes first, and the table after it: a node joining adopted has no
|
||||
// filter module and may have no nft.
|
||||
pkg, table := -1, -1
|
||||
for i, r := range composed.Resources {
|
||||
switch r["id"] {
|
||||
case GuardPackageID():
|
||||
pkg = i
|
||||
if r["type"] != "package" || r["package"] != "nftables" {
|
||||
t.Fatalf("the guard's package is %v", r)
|
||||
}
|
||||
case GuardID():
|
||||
table = i
|
||||
}
|
||||
}
|
||||
if pkg < 0 || pkg > table {
|
||||
t.Fatalf("nftables is not declared before the guard's table (%d, %d)", pkg, table)
|
||||
}
|
||||
// A changed table is reloaded — one `nft -f`, atomic — never restarted, which would delete the
|
||||
// table and leave the ports unguarded until it is loaded again. Only a changed unit restarts.
|
||||
if !reflect.DeepEqual(got[GuardRunningID()]["reload-on"], []any{GuardID()}) ||
|
||||
!reflect.DeepEqual(got[GuardRunningID()]["restart-on"], []any{GuardUnitID()}) {
|
||||
t.Fatalf("the guard is not reloaded on its table and restarted on its unit: %v",
|
||||
got[GuardRunningID()])
|
||||
}
|
||||
// Nothing of the mesh's own is anybody's to hold.
|
||||
for id, module := range composed.Owner {
|
||||
if strings.HasPrefix(id, mcat.AdoptionPrefix) {
|
||||
t.Fatalf("%s is owned by %s", id, module)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAConvergedNodeIsDeclaredItsFilterAndNoOpenings(t *testing.T) {
|
||||
composed, err := Compose(anAdoptedAnchor(), anchorRendering(false))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := byID(composed.Resources)
|
||||
if got["nftables.filtering"] == nil || got["nftables.load"] == nil {
|
||||
t.Fatalf("a converged node lost its filter: %v", keys(got))
|
||||
}
|
||||
for id := range got {
|
||||
if strings.HasPrefix(id, mcat.AdoptionPrefix) {
|
||||
t.Fatalf("a converged node is declared %s", id)
|
||||
}
|
||||
}
|
||||
plain, err := Declaration(anAdoptedAnchor(), anchorRendering(false))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a, _ := json.Marshal(plain)
|
||||
b, _ := json.Marshal(composed.Resources)
|
||||
if string(a) != string(b) {
|
||||
t.Fatal("Compose and Declaration disagree on a converged node")
|
||||
}
|
||||
}
|
||||
|
||||
// The table the installer raises and the controller declares, character for character.
|
||||
func TestTheGuardIsExactlyThisTable(t *testing.T) {
|
||||
const golden = `table inet mesh_guard {}
|
||||
delete table inet mesh_guard
|
||||
table inet mesh_guard {
|
||||
chain prerouting {
|
||||
type filter hook prerouting priority raw; policy accept;
|
||||
fib daddr type local iifname != "lo" iifname != "docker0" iifname != "br-*" iifname != "mesh0" tcp dport { 5432, 15672 } drop
|
||||
}
|
||||
}
|
||||
`
|
||||
if got := AsGuard([]int{15672, 5432}); got != golden {
|
||||
t.Fatalf("the guard changed:\n%s", got)
|
||||
}
|
||||
const unit = `[Unit]
|
||||
Description=The mesh's guard: refuses its own ports from outside (novox/hq ADR 0100)
|
||||
DefaultDependencies=no
|
||||
Wants=network-pre.target
|
||||
Before=network-pre.target shutdown.target
|
||||
Conflicts=shutdown.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
RemainAfterExit=yes
|
||||
ExecStart=nft -f /etc/mesh/guard.nft
|
||||
ExecReload=nft -f /etc/mesh/guard.nft
|
||||
ExecStop=nft delete table inet mesh_guard
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
`
|
||||
if got := GuardUnitText(); got != unit {
|
||||
t.Fatalf("the guard's unit changed:\n%s", got)
|
||||
}
|
||||
if GuardResources(nil) != nil {
|
||||
t.Fatal("a guard with nothing to guard is an empty set nft refuses to load")
|
||||
}
|
||||
}
|
||||
|
||||
func keys[V any](m map[string]V) []string {
|
||||
return sortedKeys(m)
|
||||
}
|
||||
|
||||
// novox/hq ADR 0100: the foundation's ports are the node's. Given 5433 for the store, every place
|
||||
// that uses the port reads it from there: the container, the filter, the openings, the guard.
|
||||
func TestAGivenPortIsUsedEverywhereThePortIs(t *testing.T) {
|
||||
given := map[string]map[int]int{"postgres": {5432: 5433}, "lavinmq": {15672: 15673}}
|
||||
for _, adopted := range []bool{true, false} {
|
||||
with := anchorRendering(adopted)
|
||||
with.Given = given
|
||||
with.Ports["postgres"] = map[int]int{5432: 5433}
|
||||
composed, err := Compose(anAdoptedAnchor(), with)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := byID(composed.Resources)
|
||||
if ports := got["postgres.server"]["ports"]; !reflect.DeepEqual(ports, []any{"5433:5432"}) {
|
||||
t.Fatalf("the store's container publishes %v", ports)
|
||||
}
|
||||
if ports := got["lavinmq.server"]["ports"]; !reflect.DeepEqual(ports,
|
||||
[]any{"5671:5671", "5672:5672", "127.0.0.1:15673:15672"}) {
|
||||
t.Fatalf("the broker's container publishes %v", ports)
|
||||
}
|
||||
if !adopted {
|
||||
filter, _ := got["nftables.filtering"]["content"].(string)
|
||||
if !strings.Contains(filter, "tcp dport 5433 accept") || strings.Contains(filter, "5432") {
|
||||
t.Fatalf("the filter does not use the given port:\n%s", filter)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if o := got["adoption.opening-tcp-5433-forwarded"]; o == nil || o["to"] != 5432 {
|
||||
t.Fatalf("no opening for the given port: %v", keys(got))
|
||||
}
|
||||
if guard := got[GuardID()]["content"]; guard != AsGuard([]int{5433, 5672, 15673}) {
|
||||
t.Fatalf("the guard does not guard the given ports:\n%s", guard)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
// predecessor's.
|
||||
func TestTheGuardIsDerivedFromTakenModulesOnly(t *testing.T) {
|
||||
guardOf := func(with mcat.Rendering) string {
|
||||
t.Helper()
|
||||
composed, err := Compose(anAdoptedAnchor(), with)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
content, _ := byID(composed.Resources)[GuardID()]["content"].(string)
|
||||
return content
|
||||
}
|
||||
|
||||
// The broker taken, the store not: the broker's plain port follows from its listens (from
|
||||
// the mesh, published), its management port from its manifest; the store is not guarded, and
|
||||
// neither is the bus, which the mesh needs from everywhere.
|
||||
with := anchorRendering(true)
|
||||
with.Taken = map[string]bool{"lavinmq": true}
|
||||
if got := guardOf(with); got != AsGuard([]int{5672, 15672}) {
|
||||
t.Fatalf("the guard is not the taken broker's ports:\n%s", got)
|
||||
}
|
||||
|
||||
// A taken module publishing a port admitted from everywhere is not guarded; one admitted from
|
||||
// the mesh is. The registry is exposed everywhere on this node, and hello-web listens from
|
||||
// everywhere.
|
||||
with.Taken = map[string]bool{"distribution": true, "hello-web": true}
|
||||
if got := guardOf(with); got != "" {
|
||||
t.Fatalf("a port admitted from everywhere is guarded:\n%s", got)
|
||||
}
|
||||
with.Settings = nil
|
||||
if got := guardOf(with); got != AsGuard([]int{5000}) {
|
||||
t.Fatalf("the registry, from the mesh only, is not guarded:\n%s", got)
|
||||
}
|
||||
|
||||
// Nothing taken, nothing guarded — and no guard at all rather than an empty set.
|
||||
with = anchorRendering(true)
|
||||
with.Taken = nil
|
||||
if got := guardOf(with); got != "" {
|
||||
t.Fatalf("an untaken store is guarded:\n%s", got)
|
||||
}
|
||||
|
||||
// A given port is followed: where the machine put it is what is refused.
|
||||
with = anchorRendering(true)
|
||||
with.Given = map[string]map[int]int{"lavinmq": {5672: 5682, 15672: 15673}}
|
||||
with.Ports["lavinmq"] = map[int]int{5671: 5671, 5672: 5682}
|
||||
if got := guardOf(with); got != AsGuard([]int{5432, 5682, 15673}) {
|
||||
t.Fatalf("the guard does not follow the given ports:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// novox/hq ADR 0038: only a published port is the mesh's to move. A module that binds the machine
|
||||
// itself listens where its software was told to, so giving it a machine port is refused.
|
||||
func TestAGivenPortIsRefusedForAPortNoContainerPublishes(t *testing.T) {
|
||||
onTheMachine := mcat.Manifest{Module: "daemon",
|
||||
Listens: []mcat.Listening{{Port: 9000, From: mcat.FromMesh}}, Guards: []int{9000}}
|
||||
layers := []mcat.Layer{{From: "node anchor",
|
||||
Placement: map[string]any{mcat.PortsSetting: map[string]any{"9000": float64(9100)}}}}
|
||||
_, err := GivenPorts(onTheMachine, layers)
|
||||
if err == nil || !strings.Contains(err.Error(), "does not publish") {
|
||||
t.Fatalf("a port no container publishes was given: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// novox/hq ADR 0103: a guarded port this node is told to open to everyone is opened, not guarded.
|
||||
// An opening from everywhere beside a guard dropping it is one statement refusing the other.
|
||||
func TestAGuardedPortOpenedToEveryoneIsNotGuarded(t *testing.T) {
|
||||
with := anchorRendering(true)
|
||||
with.Settings["postgres"] = []mcat.Layer{{From: "node anchor",
|
||||
Placement: map[string]any{mcat.ExposeSetting: map[string]any{"5432": mcat.FromEverywhere}}}}
|
||||
composed, err := Compose(anAdoptedAnchor(), with)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := byID(composed.Resources)
|
||||
if o := got["adoption.opening-tcp-5432-forwarded"]; o == nil || o["from"] != mcat.OpeningFromEverywhere {
|
||||
t.Fatalf("the store's port is not opened to everyone: %v", o)
|
||||
}
|
||||
if guard, _ := got[GuardID()]["content"].(string); guard != AsGuard([]int{5672, 15672}) {
|
||||
t.Fatalf("a port opened to everyone is still guarded:\n%s", guard)
|
||||
}
|
||||
}
|
||||
|
||||
// A module that publishes its ssh port the long way — `2222:22`, because the machine's own daemon
|
||||
// holds 22 — and says it listens on the machine side of that mapping, which is what anything
|
||||
// reaching it dials.
|
||||
func aForge() mcat.Manifest {
|
||||
return mcat.Manifest{Module: "forge",
|
||||
Provides: []mcat.Offer{{Name: "git-over-ssh", Scope: mcat.ScopeMesh}},
|
||||
Serves: map[string]map[string]any{"git-over-ssh": {"port": 2222}},
|
||||
Listens: []mcat.Listening{{Port: 3000, From: mcat.FromMesh}, {Port: 2222, From: mcat.FromMesh}},
|
||||
Resources: []map[string]any{{"id": "server", "type": "container", "name": "forge",
|
||||
"ports": []any{"3000", "2222:22"}}}}
|
||||
}
|
||||
|
||||
// portsAsThePlanWould is where this machine puts each of a module's ports, derived the way
|
||||
// cmd/mesh-controller/plan.go derives it: a given port first, looked up by the port the module
|
||||
// says it listens on, and otherwise wherever the manifest's own mapping already put it. Written
|
||||
// here because everything below — the filter, the openings, the guard, what a consumer is told —
|
||||
// reads that map, and a given port that the lookup does not find moves the container's mapping
|
||||
// and nothing else.
|
||||
//
|
||||
// It stands in for the plan only where the plan does not allocate: a port the manifest already
|
||||
// placed, or one a node was given. For a short form with no given port the real plan asks the
|
||||
// inventory for a machine port and may come back with one from the pool, which needs a store and
|
||||
// is what cmd/mesh-controller's own tests exercise. So an assertion here about such a port asserts
|
||||
// this helper, not the mesh; keep the assertions to the ports under test.
|
||||
func portsAsThePlanWould(m mcat.Manifest, given map[int]int) map[int]int {
|
||||
out := map[int]int{}
|
||||
for _, l := range m.Listens {
|
||||
if at, is := given[l.Port]; is {
|
||||
out[l.Port] = at
|
||||
continue
|
||||
}
|
||||
at, _ := m.MachineSide(l.Port)
|
||||
out[l.Port] = at
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// novox/hq ADR 0100 and 0038: the machine side of a long-form mapping is a port the module
|
||||
// publishes, so a node may move it — and a module may name a mapping by either end.
|
||||
func TestAGivenPortNamesEitherEndOfWhatTheModulePublishes(t *testing.T) {
|
||||
forge := aForge()
|
||||
node := func(v any) []mcat.Layer {
|
||||
return []mcat.Layer{{From: "node anchor", Placement: map[string]any{mcat.PortsSetting: v}}}
|
||||
}
|
||||
|
||||
// The machine side — the number this module says it listens on, and the one the predecessor
|
||||
// had somewhere else. Answered under 2222, the end the module itself names, which is what
|
||||
// every reader of this map asks for. One entry, not two: a second key for the same answer is
|
||||
// an entry another mapping's reader could find instead.
|
||||
given, err := GivenPorts(forge, node(map[string]any{"2222": float64(222)}))
|
||||
if err != nil {
|
||||
t.Fatalf("the machine side of a mapping cannot be given a port: %v", err)
|
||||
}
|
||||
if want := map[int]int{2222: 222}; !reflect.DeepEqual(given, want) {
|
||||
t.Fatalf("the forge was given %v; the mapping it names is filed under %v", given, want)
|
||||
}
|
||||
|
||||
// The container's own port names the same mapping and means the same thing — and is filed
|
||||
// under the same name, because the module's name for it has not changed.
|
||||
if inside, err := GivenPorts(forge, node(map[string]any{"22": float64(222)})); err != nil ||
|
||||
!reflect.DeepEqual(inside, map[int]int{2222: 222}) {
|
||||
t.Fatalf("the container's end of the mapping was given %v: %v", inside, err)
|
||||
}
|
||||
|
||||
// A short form is published on the number it names, and is unchanged by any of this.
|
||||
if short, err := GivenPorts(forge, node(map[string]any{"3000": float64(2999)})); err != nil ||
|
||||
short[3000] != 2999 {
|
||||
t.Fatalf("the short form was given %v: %v", short, err)
|
||||
}
|
||||
|
||||
// A port no container publishes is still refused, in the same words.
|
||||
if _, err := GivenPorts(forge, node(map[string]any{"9000": float64(9100)})); err == nil ||
|
||||
!strings.Contains(err.Error(), "does not publish") {
|
||||
t.Fatalf("a port the forge does not publish was given: %v", err)
|
||||
}
|
||||
|
||||
// And the two ends of one mapping given two different numbers is one setting contradicting
|
||||
// the other: the machine publishes it once.
|
||||
if _, err := GivenPorts(forge, node(map[string]any{
|
||||
"2222": float64(222), "22": float64(300)})); err == nil ||
|
||||
!strings.Contains(err.Error(), "one mapping") {
|
||||
t.Fatalf("the two ends of one mapping were given different ports: %v", err)
|
||||
}
|
||||
// Said at both ends with the same number, it is still said twice, and refused where every
|
||||
// other repeated machine port is — as the inventory refuses it when the setting is stored,
|
||||
// which is the layer that sees it first.
|
||||
if _, err := GivenPorts(forge, node(map[string]any{
|
||||
"2222": float64(222), "22": float64(222)})); err == nil ||
|
||||
!strings.Contains(err.Error(), "to both its 22 and its 2222") {
|
||||
t.Fatalf("one mapping given one machine port at both ends: %v", err)
|
||||
}
|
||||
// A number that names two different mappings names neither: which one to move is not said.
|
||||
twice := aForge()
|
||||
twice.Resources[0]["ports"] = []any{"22", "2222:22"}
|
||||
if _, err := GivenPorts(twice, node(map[string]any{"22": float64(222)})); err == nil ||
|
||||
!strings.Contains(err.Error(), "twice") {
|
||||
t.Fatalf("a number naming two of the module's mappings was accepted: %v", err)
|
||||
}
|
||||
|
||||
// And the same refusal when the number naming two mappings is not the one the setting used
|
||||
// but the one the answer would be filed under. Here `80` is the module's own name for a
|
||||
// mapping, and two mappings wear it; filing an answer there is one container's port standing
|
||||
// where the other's is read, and both containers then publish it.
|
||||
shared := mcat.Manifest{Module: "gallery",
|
||||
Listens: []mcat.Listening{{Port: 80, From: mcat.FromMesh}},
|
||||
Resources: []map[string]any{
|
||||
{"id": "a", "type": "container", "name": "a", "ports": []any{"4001:80"}},
|
||||
{"id": "b", "type": "container", "name": "b", "ports": []any{"4002:80"}}}}
|
||||
if _, err := GivenPorts(shared, node(map[string]any{"4001": float64(1234)})); err == nil ||
|
||||
!strings.Contains(err.Error(), "twice") {
|
||||
t.Fatalf("two containers were put on one machine port: %v", err)
|
||||
}
|
||||
|
||||
// A module whose mappings chain — one's machine side is another's container port — keeps them
|
||||
// apart, because each is filed under the port the module names it by and neither name is
|
||||
// shared. Given both, each moves on its own and neither overwrites the other.
|
||||
chained := mcat.Manifest{Module: "chain",
|
||||
Listens: []mcat.Listening{{Port: 80, From: mcat.FromMesh}, {Port: 9090, From: mcat.FromMesh}},
|
||||
Resources: []map[string]any{{"id": "server", "type": "container", "name": "chain",
|
||||
"ports": []any{"8080:80", "9090:8080"}}}}
|
||||
both, err := GivenPorts(chained, node(map[string]any{"80": float64(1234), "9090": float64(5678)}))
|
||||
if err != nil || !reflect.DeepEqual(both, map[int]int{80: 1234, 9090: 5678}) {
|
||||
t.Fatalf("chained mappings were given %v: %v", both, err)
|
||||
}
|
||||
if moved := givenOuter("8080:80", both); moved != "1234:80" {
|
||||
t.Fatalf("the first mapping moved to %q, and it was given 1234", moved)
|
||||
}
|
||||
if moved := givenOuter("9090:8080", both); moved != "5678:8080" {
|
||||
t.Fatalf("the second mapping moved to %q, and it was given 5678", moved)
|
||||
}
|
||||
}
|
||||
|
||||
// And the number reaches everything derived from it. The fault this is written against moved the
|
||||
// container's mapping alone: the filter opened the port the software had left, the adopted node's
|
||||
// opening named it too, the guard refused it, and a consumer was sent to it.
|
||||
func TestAGivenMachineSideReachesTheFilterTheOpeningAndTheConsumer(t *testing.T) {
|
||||
forge := aForge()
|
||||
given, err := GivenPorts(forge, []mcat.Layer{{From: "node anchor",
|
||||
Placement: map[string]any{mcat.PortsSetting: map[string]any{"2222": float64(222)}}}})
|
||||
if err != nil {
|
||||
t.Fatalf("the machine side of a mapping cannot be given a port: %v", err)
|
||||
}
|
||||
r := mcat.Resolution{Node: "anchor", Modules: []mcat.Manifest{forge}}
|
||||
with := mcat.Rendering{
|
||||
Ports: map[string]map[int]int{"forge": portsAsThePlanWould(forge, given)},
|
||||
Given: map[string]map[int]int{"forge": given},
|
||||
Mesh: []string{"10.77.0.1"},
|
||||
Adopted: true,
|
||||
OutwardLinks: []string{"eth0"},
|
||||
TunnelInterface: "mesh0",
|
||||
Taken: map[string]bool{"forge": true},
|
||||
}
|
||||
|
||||
// What the runtime is handed: the machine's own port on the outside, the container's within.
|
||||
composed, err := Compose(r, with)
|
||||
if err != nil {
|
||||
t.Fatalf("the forge does not compose: %v", err)
|
||||
}
|
||||
got := byID(composed.Resources)
|
||||
if ports := got["forge.server"]["ports"]; !reflect.DeepEqual(ports, []any{"3000:3000", "222:22"}) {
|
||||
t.Fatalf("the forge's container publishes %v", ports)
|
||||
}
|
||||
|
||||
// What the filter would open, were the node converged.
|
||||
rules, err := Rules(r, with)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var opened []int
|
||||
for _, rule := range rules {
|
||||
opened = append(opened, rule.Port)
|
||||
}
|
||||
// Only the moved port is asserted: the forge's other port is a short form the real plan would
|
||||
// allocate rather than read off the manifest, so its number here is portsAsThePlanWould's and
|
||||
// not the mesh's.
|
||||
if !slices.Contains(opened, 222) || slices.Contains(opened, 2222) {
|
||||
t.Fatalf("the filter opens %v, not where the machine puts the forge", opened)
|
||||
}
|
||||
|
||||
// And what it is declared instead, adopted: an opening on the machine's port, naming the
|
||||
// container's port on the forwarded path — a published port is forwarded, never received.
|
||||
opening := got[OpeningID("tcp", 222, mcat.PathForwarded)]
|
||||
if opening == nil || opening["to"] != 22 || opening["from"] != mcat.OpeningFromMesh {
|
||||
t.Fatalf("no opening for the port this node gave the forge: %v", keys(got))
|
||||
}
|
||||
for id := range got {
|
||||
if strings.HasPrefix(id, "adoption.opening-tcp-2222-") {
|
||||
t.Errorf("an opening for the port the forge was moved off: %s", id)
|
||||
}
|
||||
}
|
||||
|
||||
// The guard refuses it where the machine put it, and nothing where it used to be.
|
||||
guard, _ := got[GuardID()]["content"].(string)
|
||||
if !strings.Contains(guard, "222") || strings.Contains(guard, "2222") {
|
||||
t.Fatalf("the guard does not follow the given port:\n%s", guard)
|
||||
}
|
||||
|
||||
// And a consumer is sent to the same number, which is read from what the module serves.
|
||||
if told := ServedOn(forge, "git-over-ssh", with.Ports["forge"])["port"]; told != 222 {
|
||||
t.Fatalf("a consumer is told the forge answers on %v", told)
|
||||
}
|
||||
}
|
||||
|
||||
// And the mapping itself moves under either name, because Rendering.Given is a map anybody
|
||||
// composing a declaration hands in: keyed by the machine side, which is what a module declaring
|
||||
// 2222 calls its port, only the outside moves and the container's own port stays as written.
|
||||
func TestAMappingIsMovedUnderEitherOfItsNames(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
written string
|
||||
given map[int]int
|
||||
want string
|
||||
}{
|
||||
{"2222:22", map[int]int{2222: 222}, "222:22"},
|
||||
{"2222:22", map[int]int{22: 222}, "222:22"},
|
||||
{"127.0.0.1:15672:15672/tcp", map[int]int{15672: 15673}, "127.0.0.1:15673:15672/tcp"},
|
||||
{"2222:22", map[int]int{3000: 2999}, "2222:22"},
|
||||
{"2222:22", nil, "2222:22"},
|
||||
} {
|
||||
if got := givenOuter(c.written, c.given); got != c.want {
|
||||
t.Errorf("%s given %v is published as %s, want %s", c.written, c.given, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The same on a node that was given nothing, which is where the derivation was never at fault:
|
||||
// a long-form mapping is published where the manifest put it, and an adopted node opens that port
|
||||
// on the forwarded path like any other. What broke it was the number reaching only the mapping.
|
||||
func TestALongFormPortIsOpenedWhereTheManifestPublishesIt(t *testing.T) {
|
||||
forge := aForge()
|
||||
r := mcat.Resolution{Node: "anchor", Modules: []mcat.Manifest{forge}}
|
||||
composed, err := Compose(r, mcat.Rendering{
|
||||
Ports: map[string]map[int]int{"forge": portsAsThePlanWould(forge, nil)},
|
||||
Mesh: []string{"10.77.0.1"},
|
||||
Adopted: true,
|
||||
OutwardLinks: []string{"eth0"},
|
||||
TunnelInterface: "mesh0",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := byID(composed.Resources)
|
||||
opening := got[OpeningID("tcp", 2222, mcat.PathForwarded)]
|
||||
if opening == nil || opening["to"] != 22 {
|
||||
t.Fatalf("no opening for the forge's published ssh port: %v", keys(got))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package resolution
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
|
||||
func TestTheAgentAccountFactFallsBackToTheOperatorAndIsNeverRootOnlyWhenItsOwn(t *testing.T) {
|
||||
facts := machineFacts(mcat.Resolution{Node: "anchor", Account: "ops"}, nil, "")
|
||||
if facts["agent-account"] != "ops" || facts["agent-home"] != "/home/ops" || facts["agent-root"] != "" {
|
||||
t.Errorf("with no agent account named, agents run as the operator: %v", facts)
|
||||
}
|
||||
facts = machineFacts(mcat.Resolution{Node: "anchor", Account: "ops", AccountHome: "/srv/ops"}, nil, "")
|
||||
if facts["agent-home"] != "/srv/ops" {
|
||||
t.Errorf("the operator's stated home is the agent's home when they are one account: %v", facts)
|
||||
}
|
||||
facts = machineFacts(mcat.Resolution{Node: "anchor", Account: "ops", AgentAccount: "agent"}, nil, "")
|
||||
if facts["agent-account"] != "agent" || facts["agent-home"] != "/home/agent" || facts["agent-root"] != mcat.RootNever {
|
||||
t.Errorf("a named agent account is the agents', never root: %v", facts)
|
||||
}
|
||||
if facts["account"] != "ops" {
|
||||
t.Errorf("the operator account is still the operator's: %v", facts)
|
||||
}
|
||||
facts = machineFacts(mcat.Resolution{Node: "anchor", AgentAccount: "agent", AgentAccountHome: "/var/lib/agent"}, nil, "")
|
||||
if facts["agent-home"] != "/var/lib/agent" || facts["agent-root"] != mcat.RootNever {
|
||||
t.Errorf("an agent account with a stated home on a machine with no operator: %v", facts)
|
||||
}
|
||||
if _, has := machineFacts(mcat.Resolution{Node: "anchor"}, nil, "")["agent-account"]; has {
|
||||
t.Error("a machine with no account at all names an agent account")
|
||||
}
|
||||
}
|
||||
|
||||
// The agent's module, in the shape the catalogue's declares it: the account, never root where it is its
|
||||
// own; its directory under that home, owned by it.
|
||||
const agentModule = `{"module": "agent", "version": "1", "resources": [
|
||||
{"id": "account", "type": "user", "name": "${machine:agent-account}", "home": "${machine:agent-home}",
|
||||
"root": "${machine:agent-root}"},
|
||||
{"id": "home", "type": "directory", "path": "${machine:agent-home}/.agent", "mode": "0700",
|
||||
"owner": "${machine:agent-account}"}
|
||||
]}`
|
||||
|
||||
func TestTheAgentAccountIsDeclaredNeverRootOnlyToAnEngineThatJudgesIt(t *testing.T) {
|
||||
m, err := decodeForTest([]byte(agentModule))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
compose := func(r mcat.Resolution, with mcat.Rendering) (user, home map[string]any) {
|
||||
t.Helper()
|
||||
r.Node, r.Modules = "anchor", []mcat.Manifest{m}
|
||||
out, err := Declaration(r, with)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return fileNamed(out, "agent.account"), fileNamed(out, "agent.home")
|
||||
}
|
||||
|
||||
user, home := compose(mcat.Resolution{Account: "ops", AgentAccount: "agent"}, mcat.Rendering{JudgesRoot: true})
|
||||
if user["name"] != "agent" || user[mcat.RootField] != mcat.RootNever || user["home"] != "/home/agent" {
|
||||
t.Errorf("an engine that judges root is sent the agent account never to become root: %v", user)
|
||||
}
|
||||
if home["path"] != "/home/agent/.agent" || home["owner"] != "agent" {
|
||||
t.Errorf("the agent's directory is under its own home, its own: %v", home)
|
||||
}
|
||||
|
||||
user, _ = compose(mcat.Resolution{Account: "ops", AgentAccount: "agent", AgentAccountHome: "/srv/agent"}, mcat.Rendering{JudgesRoot: true})
|
||||
if user["home"] != "/srv/agent" {
|
||||
t.Errorf("an agent account named with a home of its own is made there: %v", user)
|
||||
}
|
||||
|
||||
user, _ = compose(mcat.Resolution{Account: "ops", AgentAccount: "agent"}, mcat.Rendering{})
|
||||
if _, sent := user[mcat.RootField]; sent || user["name"] != "agent" {
|
||||
t.Errorf("an older engine, which parses strictly, is sent root: %v", user)
|
||||
}
|
||||
|
||||
user, home = compose(mcat.Resolution{Account: "ops"}, mcat.Rendering{JudgesRoot: true})
|
||||
if _, sent := user[mcat.RootField]; sent || user["name"] != "ops" {
|
||||
t.Errorf("where agents run as the operator, root asserts nothing and is not sent: %v", user)
|
||||
}
|
||||
if home["path"] != "/home/ops/.agent" || home["owner"] != "ops" {
|
||||
t.Errorf("with no agent account, the agent's directory is the operator's: %v", home)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package resolution
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
|
||||
// The artifact store's seat is one per mesh, read from the catalogue beside this checkout.
|
||||
//
|
||||
// **A second store anywhere is refused by name, not discovered as a consumer failure.** The seat
|
||||
// was node-scoped, so a second `distribution` on another machine resolved cleanly there — and a
|
||||
// node-scoped requirement with one candidate installs that candidate on the node, so anything that
|
||||
// required the store's presence beside it would have raised a fresh, empty store on the wrong
|
||||
// machine. Only afterwards did the mesh notice: `artifact-store` offered by two nodes, and every
|
||||
// consumer elsewhere refusing to choose. The claim says it first, where the second store is
|
||||
// assigned.
|
||||
func TestASecondArtifactStoreAnywhereIsRefusedByName(t *testing.T) {
|
||||
store := catalogueManifest(t, "distribution")
|
||||
|
||||
// The first store resolves as it always has.
|
||||
if _, err := Resolve(shelf(store), []string{"distribution"}, workstation(), mcat.World{}); err != nil {
|
||||
t.Fatalf("the store alone does not resolve: %v", err)
|
||||
}
|
||||
|
||||
// A second one, on any other machine, is refused — and the refusal names the seat.
|
||||
elsewhere := mcat.World{Held: []mcat.Held{{Claim: "mesh-artifact-store", Scope: mcat.ScopeMesh,
|
||||
Node: "anchor", Module: "distribution"}}}
|
||||
other := workstation()
|
||||
other.Name = "laptop"
|
||||
_, err := Resolve(shelf(store), []string{"distribution"}, other, elsewhere)
|
||||
if err == nil {
|
||||
t.Fatal("a second store was accepted on another machine; it would offer artifact-store a " +
|
||||
"second time and every consumer elsewhere would refuse to choose")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "mesh-artifact-store") || !strings.Contains(err.Error(), "one per mesh") {
|
||||
t.Fatalf("refused without naming the seat: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package resolution
|
||||
|
||||
import (
|
||||
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
|
||||
// artifactSweepVerbs are the seat's verbs, with the controller's arguments so a caller changes only the seat it asks.
|
||||
func artifactSweepVerbs() []mcat.Verb {
|
||||
return []mcat.Verb{
|
||||
{Name: "artifacts", Description: "Every artifact the mesh made that lives in its artifact store, each with its " +
|
||||
"repository, digest, kind (image or archive) and state: kept, and why (a want names it, or one of the five " +
|
||||
"newest builds of its module in the builder's log), eligible (made, kept for no reason, not yet let go), or " +
|
||||
"unrecorded (in the store and named by nothing the mesh made: never removed). Reads only.",
|
||||
Input: schema(map[string]string{
|
||||
"repository": "only this repository's references, as <module>/<artifact> (the counts stay whole)",
|
||||
"collected": "\"true\": list the collected references too (counted always)",
|
||||
}, nil, "collected")},
|
||||
{Name: "held", Description: "Whether the artifact store holds each reference given, asked with one HEAD each: " +
|
||||
"held, gone (the store answered 404) or unknown with why. The builder asks it before it reuses a done key. " +
|
||||
"Reads only.",
|
||||
Input: listed(schema(map[string]string{
|
||||
"references": "the artifacts' references, artifact-store://<module>/<artifact>/blobs/sha256:<hex>: a list, " +
|
||||
"or one text separated by commas",
|
||||
}, []string{"references"}), "references")},
|
||||
{Name: "collect", Description: "The artifact store's sweep, now: what is eligible is let go of. Without confirm a " +
|
||||
"dry run: what would be let go, nothing held or deleted. With confirm and why: every kept archive held first, " +
|
||||
"then each eligible artifact let go of, oldest first, an index with the platform manifests no kept index " +
|
||||
"names, each deletion recorded in the hand-act log. Bounded by most (500 by default, at most 5000) and 45 " +
|
||||
"seconds; what is left is said. Never touches a digest the mesh did not make.",
|
||||
Input: schema(map[string]string{
|
||||
"confirm": "\"true\": let go of what is eligible (needs why); without it nothing is done",
|
||||
"why": "with confirm: why, required and recorded",
|
||||
"most": "let go of at most this many (500 by default, at most 5000)",
|
||||
}, nil, "confirm")},
|
||||
{Name: "mirrors", Description: "The bases the mesh copied into its artifact store, each kept (a kept build stood on " +
|
||||
"it), eligible (only builds no longer kept stood on it) or unrecorded (no build the builder's log holds stood on " +
|
||||
"it: never removed), by what the builder's log says each build stood on. Records nothing by hand.",
|
||||
Input: schema(map[string]string{}, nil)},
|
||||
{Name: "images", Description: "Every container image one machine's want and have name, each with the resources " +
|
||||
"naming it: what the machine keeps when it prunes its images. Reads only.",
|
||||
Input: schema(map[string]string{"node": "the machine's name"}, []string{"node"})},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package resolution
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
|
||||
// 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
|
||||
// routed through the store as this network reaches it now. A reference recorded with an address
|
||||
// before references were kept without one is re-routed the same way — but only when the mesh
|
||||
// built it (`with.Built` names every `<module>/<artifact>` it has), because a module may run an
|
||||
// image from a public registry under its own name and that one is exactly where it says.
|
||||
//
|
||||
// A kept reference with no store to route it through is refused: sent as it is, the runtime would
|
||||
// refuse the scheme on the machine, one push away from the reason.
|
||||
//
|
||||
// **What this does not reach: the images genesis pinned.** The installer builds the control plane
|
||||
// and the builder before the mesh exists, pushes them itself and pins their manifests to
|
||||
// `<registry>:<port>/mesh-controller@…` and `<registry>:<port>/mesh-builder@…` — single-segment
|
||||
// repositories with no build record, so `with.Built` does not name them and they are left as
|
||||
// written until each is rebuilt through the mesh, which records it by digest and path. Until then
|
||||
// a registry that moves strands exactly those two on a recreate, and the control plane's is the
|
||||
// one that cannot be repaired through the mesh. Rebuild both through `build` before moving the
|
||||
// store (novox/hq 04-ISSUES/102, finding F4).
|
||||
func artifactsInto(resource map[string]any, module string, with mcat.Rendering) error {
|
||||
for _, key := range []string{"image", "source"} {
|
||||
written, ok := resource[key].(string)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if _, kept := mcat.InArtifactStore(written); kept {
|
||||
if with.ArtifactStore == "" {
|
||||
return fmt.Errorf(
|
||||
"%s's %v names %s, which is in the mesh's artifact store, and this mesh has no "+
|
||||
"artifact store on its network to fetch it from — nothing assigned offers "+
|
||||
"%s, or the machine offering it is not on the private network",
|
||||
module, resource["id"], written, mcat.ArtifactStoreProvision)
|
||||
}
|
||||
resource[key] = mcat.Routed(written, with.ArtifactStore)
|
||||
continue
|
||||
}
|
||||
if with.ArtifactStore == "" {
|
||||
continue
|
||||
}
|
||||
recorded := mcat.Recorded(written)
|
||||
if recorded == written {
|
||||
continue
|
||||
}
|
||||
path, _ := mcat.InArtifactStore(recorded)
|
||||
repository := path
|
||||
if at := strings.IndexAny(path, "@"); at >= 0 {
|
||||
repository = path[:at]
|
||||
} else if blobs := strings.Index(path, "/blobs/"); blobs >= 0 {
|
||||
repository = path[:blobs]
|
||||
}
|
||||
if with.Built[repository] {
|
||||
resource[key] = mcat.Routed(recorded, with.ArtifactStore)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package resolution
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
|
||||
var digest = "sha256:" + strings.Repeat("d", 64)
|
||||
|
||||
// **The address is composed into a declaration, and the record never carries it.**
|
||||
func TestAnImageTheMeshBuiltIsRoutedThroughTheStoreWhenDeclared(t *testing.T) {
|
||||
m := mcat.Manifest{Module: "gitea", Version: "1", Resources: []map[string]any{
|
||||
{"id": "server", "type": "container", "name": "mesh-gitea", "artifact": "server"},
|
||||
{"id": "config", "type": "archive", "path": "/etc/gitea", "artifact": "config"},
|
||||
{"id": "cache", "type": "container", "name": "mesh-gitea-cache", "image": "valkey/valkey@" + digest},
|
||||
}, Build: &mcat.Build{Artifacts: []mcat.Artifact{
|
||||
{Name: "server", Kind: mcat.ArtifactImage, From: "Dockerfile"},
|
||||
{Name: "config", Kind: mcat.ArtifactArchive, From: "config"},
|
||||
}}}
|
||||
resolved, err := m.Resolve([]mcat.Built{
|
||||
{Name: "server", Kind: mcat.ArtifactImage, Reference: mcat.ArtifactStoreScheme + "gitea/server@" + digest},
|
||||
{Name: "config", Kind: mcat.ArtifactArchive, Reference: mcat.ArtifactStoreScheme + "gitea/config/blobs/" + digest, Digest: digest},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r := mcat.Resolution{Node: "anchor", Modules: []mcat.Manifest{resolved}}
|
||||
|
||||
out, err := Declaration(r, mcat.Rendering{ArtifactStore: "anchor.internal:5101"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := fileNamed(out, "gitea.server")["image"]; got != "anchor.internal:5101/gitea/server@"+digest {
|
||||
t.Errorf("the image the mesh built is fetched as %v", got)
|
||||
}
|
||||
if got := fileNamed(out, "gitea.config")["source"]; got != "http://anchor.internal:5101/v2/gitea/config/blobs/"+digest {
|
||||
t.Errorf("the archive the mesh built is fetched from %v", got)
|
||||
}
|
||||
if got := fileNamed(out, "gitea.cache")["image"]; got != "valkey/valkey@"+digest {
|
||||
t.Errorf("an image from a public registry was routed through the store: %v", got)
|
||||
}
|
||||
// The manifest the mesh holds still says what it is, not where it was fetched from.
|
||||
if got := resolved.Resources[0]["image"]; got != mcat.ArtifactStoreScheme+"gitea/server@"+digest {
|
||||
t.Errorf("composing wrote the address into the catalogue's copy: %v", got)
|
||||
}
|
||||
|
||||
// And the store moves: the same record, another address, without a rebuild.
|
||||
out, err = Declaration(r, mcat.Rendering{ArtifactStore: "laptop.internal:5000"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := fileNamed(out, "gitea.server")["image"]; got != "laptop.internal:5000/gitea/server@"+digest {
|
||||
t.Errorf("after the store moved, the image is still fetched as %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnImageInTheStoreWithNoStoreToFetchItFromIsRefused(t *testing.T) {
|
||||
m := mcat.Manifest{Module: "gitea", Version: "1", Resources: []map[string]any{
|
||||
{"id": "server", "type": "container", "name": "mesh-gitea",
|
||||
"image": mcat.ArtifactStoreScheme + "gitea/server@" + digest},
|
||||
}}
|
||||
_, err := Declaration(mcat.Resolution{Node: "anchor", Modules: []mcat.Manifest{m}}, mcat.Rendering{})
|
||||
if err == nil || !strings.Contains(err.Error(), "no artifact store") {
|
||||
t.Fatalf("a reference nothing can fetch was sent to a machine: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// **A reference recorded with an address before this follows the store too** — when the mesh
|
||||
// built it. A module running an image straight from a public registry under its own name is left
|
||||
// exactly where it says: `quay.io/keycloak/keycloak` is not the mesh's, whatever it is called.
|
||||
func TestAReferenceRecordedWithAnAddressFollowsTheStoreWhenTheMeshBuiltIt(t *testing.T) {
|
||||
m := mcat.Manifest{Module: "keycloak", Version: "1", Resources: []map[string]any{
|
||||
{"id": "server", "type": "container", "name": "mesh-keycloak",
|
||||
"image": "anchor.internal:5100/keycloak/server@" + digest},
|
||||
{"id": "upstream", "type": "container", "name": "mesh-keycloak-upstream",
|
||||
"image": "quay.io/keycloak/keycloak@" + digest},
|
||||
}}
|
||||
r := mcat.Resolution{Node: "anchor", Modules: []mcat.Manifest{m}}
|
||||
out, err := Declaration(r, mcat.Rendering{
|
||||
ArtifactStore: "anchor.internal:5101",
|
||||
Built: map[string]bool{"keycloak/server": true},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := fileNamed(out, "keycloak.server")["image"]; got != "anchor.internal:5101/keycloak/server@"+digest {
|
||||
t.Errorf("an image the mesh built, recorded with the old address, is fetched as %v", got)
|
||||
}
|
||||
if got := fileNamed(out, "keycloak.upstream")["image"]; got != "quay.io/keycloak/keycloak@"+digest {
|
||||
t.Errorf("a public image was re-routed through the store: %v", got)
|
||||
}
|
||||
// Nothing known to be built: nothing re-routed, nothing refused.
|
||||
out, err = Declaration(r, mcat.Rendering{ArtifactStore: "anchor.internal:5101"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := fileNamed(out, "keycloak.server")["image"]; got != "anchor.internal:5100/keycloak/server@"+digest {
|
||||
t.Errorf("with no build record, a reference was rewritten: %v", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package resolution
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
|
||||
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
|
||||
// ofBackup is where a holder places them: ${backup:<key>}. Loose inside the braces, so a misspelt key is found and
|
||||
// refused rather than written out as text.
|
||||
var ofBackup = regexp.MustCompile(`\$\{backup:([^}]*)\}`)
|
||||
|
||||
// backupKinds is the contribution kind each key of the backup placement renders.
|
||||
var backupKinds = map[string]string{"lines": mcat.BackupKindBackup, "data": mcat.BackupKindData}
|
||||
|
||||
// backupPlacer is the machine's modules the backup placement is composed over, as the old contributions are.
|
||||
type backupPlacer struct {
|
||||
modules []mcat.Manifest
|
||||
with mcat.Rendering
|
||||
facts map[string]string
|
||||
caps map[string]bool
|
||||
unplaced *[]string
|
||||
}
|
||||
|
||||
// placed is the text of one key for a holder, or what is wrong with naming it.
|
||||
func (b backupPlacer) placed(holder mcat.Manifest, key string, item bool) (string, string) {
|
||||
kind, known := backupKinds[key]
|
||||
switch {
|
||||
case item:
|
||||
return "", "an item is kept per item; the machine's backup lines are placed only in a file"
|
||||
case !known:
|
||||
return "", fmt.Sprintf("the backup placement has `lines` and `data`, not %q", key)
|
||||
case !holder.ClaimsSeat(mcat.BackupSeat):
|
||||
return "", fmt.Sprintf("%s does not claim %s; the machine's backup lines are placed by its holder alone "+
|
||||
"(novox/hq ADR 0212)", holder.Module, mcat.BackupSeat)
|
||||
}
|
||||
text, left, err := seatContributions(b.modules, holder, mcat.BackupSeat+":"+kind, b.with, b.facts, b.caps)
|
||||
if b.unplaced != nil {
|
||||
*b.unplaced = append(*b.unplaced, left...)
|
||||
}
|
||||
if err != nil {
|
||||
return "", err.Error()
|
||||
}
|
||||
return text, ""
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package resolution
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
|
||||
const oldBackupText = "# lines\n${contribution:node-backup:backup}"
|
||||
|
||||
const oldDataText = "# data\n${contribution:node-backup:data}"
|
||||
|
||||
const placedBackupTxt = "# lines\n${backup:lines}"
|
||||
|
||||
const placedDataTxt = "# data\n${backup:data}"
|
||||
|
||||
const backupHolder = `{"module":"restic","version":"1","moved":true,"claims":[{"name":"node-backup","scope":"node"}],
|
||||
"build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]},
|
||||
"resources":[{"id":"declared","type":"file","path":"/etc/backups.conf","template":"files/backups"},
|
||||
{"id":"data-declared","type":"file","path":"/etc/data.conf","template":"files/data"}]}`
|
||||
|
||||
func dataModule(name string) string {
|
||||
return `{"module":"` + name + `","version":"1","resources":[{"id":"d","type":"directory","mode":"0700"}],
|
||||
"data":{"own":[{"id":"d","path":"${dir:d}","class":"irreplaceable","why":"the ` + name + ` data"}]}}`
|
||||
}
|
||||
|
||||
// backupsOn is a machine of a holder whose files say these texts and the modules that declare data.
|
||||
func backupsOn(t *testing.T, lines, data string, modules ...string) mcat.Resolution {
|
||||
t.Helper()
|
||||
all := []mcat.Manifest{held(t, backupHolder, builtWith("restic", map[string]string{"backups": lines, "data": data}))}
|
||||
names := []string{"restic"}
|
||||
for _, name := range modules {
|
||||
all = append(all, mustParse(t, dataModule(name)))
|
||||
names = append(names, name)
|
||||
}
|
||||
r, err := Resolve(shelf(all...), names, workstation(), mcat.World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r.Account = "op"
|
||||
return r
|
||||
}
|
||||
|
||||
var backupModules = []string{"alpha", "beta", "gamma"}
|
||||
|
||||
// gammaLeftOut is a machine whose gamma does not place: its lines are kept, best effort.
|
||||
func gammaLeftOut(with mcat.Rendering) mcat.Rendering {
|
||||
with.NotPlaced = map[string]string{"gamma": "its placement setting does not read"}
|
||||
return with
|
||||
}
|
||||
|
||||
func TestTheBackupPlacementIsTheTextTheOldPlaceholdersRender(t *testing.T) {
|
||||
// The old form is an unmoved holder: its placeholders are the controller's alone.
|
||||
oldHolder := strings.Replace(backupHolder, `"moved":true,`, "", 1)
|
||||
all := []mcat.Manifest{held(t, oldHolder, builtWith("restic", map[string]string{"backups": oldBackupText, "data": oldDataText}))}
|
||||
for _, name := range backupModules {
|
||||
all = append(all, mustParse(t, dataModule(name)))
|
||||
}
|
||||
old, err := Resolve(shelf(all...), append([]string{"restic"}, backupModules...), workstation(), mcat.World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
old.Account = "op"
|
||||
wantLines, wantData := fileOf(t, old, gammaLeftOut(mcat.Rendering{}), "restic.declared"), fileOf(t, old, gammaLeftOut(mcat.Rendering{}), "restic.data-declared")
|
||||
if !strings.Contains(wantLines, "# alpha\npath ") || !strings.Contains(wantLines, "# gamma\n") || !strings.Contains(wantData, "item d") {
|
||||
t.Fatalf("the old form renders\n%s\n%s", wantLines, wantData)
|
||||
}
|
||||
moved := backupsOn(t, placedBackupTxt, placedDataTxt, backupModules...)
|
||||
// Not handed: the controller fills the new placeholders with the old text.
|
||||
if got := fileOf(t, moved, gammaLeftOut(mcat.Rendering{}), "restic.declared"); got != wantLines {
|
||||
t.Errorf("not handed, the lines are\n%s\nwant\n%s", got, wantLines)
|
||||
}
|
||||
if got := fileOf(t, moved, gammaLeftOut(mcat.Rendering{}), "restic.data-declared"); got != wantData {
|
||||
t.Errorf("not handed, the data is\n%s\nwant\n%s", got, wantData)
|
||||
}
|
||||
// Handed: the plan carries the same text as placement, and the files are the client's.
|
||||
c, _ := composeWith(t, moved, gammaLeftOut(handingTo("store:5000")))
|
||||
p := c.Plans["restic"].Placement
|
||||
if got := "# lines\n" + p["backup:lines"]; got != wantLines {
|
||||
t.Errorf("handed, the plan's lines are\n%s\nwant\n%s", got, wantLines)
|
||||
}
|
||||
if got := "# data\n" + p["backup:data"]; got != wantData {
|
||||
t.Errorf("handed, the plan's data is\n%s\nwant\n%s", got, wantData)
|
||||
}
|
||||
if len(p) != 2 {
|
||||
t.Errorf("the plan's placement is %v", p)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnUnknownBackupKeyIsRefused(t *testing.T) {
|
||||
r := backupsOn(t, "${backup:rows}", placedDataTxt, backupModules...)
|
||||
if _, err := Compose(r, handingTo("store:5000")); err == nil || !strings.Contains(err.Error(), "backup:rows") {
|
||||
t.Errorf("handed, an unknown key is %v", err)
|
||||
}
|
||||
if _, err := Declaration(r, mcat.Rendering{}); err == nil || !strings.Contains(err.Error(), "backup:rows") {
|
||||
t.Errorf("not handed, an unknown key is %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func fileOf(t *testing.T, r mcat.Resolution, with mcat.Rendering, id string) string {
|
||||
t.Helper()
|
||||
out, err := Declaration(r, with)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, res := range out {
|
||||
if res["id"] == id {
|
||||
return res["content"].(string)
|
||||
}
|
||||
}
|
||||
t.Fatalf("%s was not composed", id)
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package resolution
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
|
||||
// A module on the shelf from before ADR 0233, which writes its backup lines by hand and declares no
|
||||
// data, is still backed up: its lines are placed as written, each module's under a comment naming it.
|
||||
func TestHandWrittenBackupLinesOfAnOlderModuleArePlaced(t *testing.T) {
|
||||
pg, err := decodeForTest([]byte(`{"module":"pg","version":"1",
|
||||
"resources":[{"id":"dumps","type":"directory","path":"/srv/pg/dumps","mode":"0700"}],
|
||||
"contributions":[{"seat":"node-backup","kind":"backup","content":"run pg-dump-all\npath ${dir:dumps}"}]}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mail, err := decodeForTest([]byte(`{"module":"mail","version":"1",
|
||||
"resources":[{"id":"spool","type":"directory","mode":"0700"}],
|
||||
"contributions":[{"seat":"node-backup","kind":"backup","content":"path ${dir:spool}"}]}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
placed, _, err := seatContributions([]mcat.Manifest{pg, mail}, mcat.Manifest{}, mcat.BackupSeat+":"+"backup", mcat.Rendering{}, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, want := range []string{"# pg\nrun pg-dump-all\npath /srv/pg/dumps\n", "# mail\npath /var/lib/mail/spool\n"} {
|
||||
if !strings.Contains(placed, want) {
|
||||
t.Errorf("placed contributions lack %q:\n%s", want, placed)
|
||||
}
|
||||
}
|
||||
if strings.Contains(placed, "${dir:") {
|
||||
t.Errorf("a directory reached the holder unfilled:\n%s", placed)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package resolution
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
|
||||
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
|
||||
// keepBound holds every need for a provision that keeps its consumers' data at the provider its
|
||||
// consumer was bound to, where the resolution chose another.
|
||||
//
|
||||
// A need with no record is a binding being made, and is left as resolved: it is recorded when it is
|
||||
// first sent. A pin naming the provider the resolution chose is a person moving it, and is left too.
|
||||
// Otherwise the recorded provider answers, if it still provides the provision — beside the consumer,
|
||||
// or offered from elsewhere — and the move is returned as kept. **One that no longer does is refused,
|
||||
// never answered by the provider chosen**: answering it there is exactly the silent move this exists
|
||||
// to stop, and the consumer's data is still wherever it was.
|
||||
func keepBound(needs []mcat.Needed, catalogue map[string]mcat.Manifest, node mcat.Node, world mcat.World,
|
||||
keeps map[string]bool, here func(string) bool) ([]mcat.Needed, []mcat.KeptBinding, []string) {
|
||||
var kept []mcat.KeptBinding
|
||||
var problems []string
|
||||
refused := map[[2]string]bool{}
|
||||
out := make([]mcat.Needed, 0, len(needs))
|
||||
for _, n := range needs {
|
||||
if n.ByRecord || !keeps[n.Name] {
|
||||
out = append(out, n)
|
||||
continue
|
||||
}
|
||||
n.KeepsData = true
|
||||
bound, recorded := world.Bound[n.For][n.Name]
|
||||
chose := mcat.Chosen{Node: n.From, Module: n.Module}
|
||||
if !recorded || sameProvider(bound, chose) {
|
||||
out = append(out, n)
|
||||
continue
|
||||
}
|
||||
if pin, pinned := world.Pinned[n.Name]; pinned && chosenMatches(pin, mcat.Provider{Node: chose.Node, Module: chose.Module}) {
|
||||
out = append(out, n)
|
||||
continue
|
||||
}
|
||||
held, ok, why := boundNeed(n, bound, catalogue, node, world, here)
|
||||
if !ok {
|
||||
if key := [2]string{n.For, n.Name}; !refused[key] {
|
||||
refused[key] = true
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s on %s is bound to %s for %q, which keeps its data, and %s — it would move to %s, "+
|
||||
"which holds none of it. Move its data and say so with `pin %s %s %s %s`, or give "+
|
||||
"%s back what it provided",
|
||||
n.For, node.Name, bound, n.Name, why, chose, node.Name, n.Name, chose.Node, chose.Module,
|
||||
bound.Node))
|
||||
}
|
||||
continue
|
||||
}
|
||||
out = append(out, held)
|
||||
kept = append(kept, mcat.KeptBinding{Machine: node.Name, Consumer: n.For, Provision: n.Name, Bound: bound, Would: chose})
|
||||
}
|
||||
return out, kept, problems
|
||||
}
|
||||
|
||||
// sameProvider is whether a recorded provider is the one chosen. A record naming no module (none
|
||||
// is written without one, but a person's hand might) matches any module on its node.
|
||||
func sameProvider(bound, chose mcat.Chosen) bool {
|
||||
return bound.Node == chose.Node && (bound.Module == "" || bound.Module == chose.Module)
|
||||
}
|
||||
|
||||
// boundNeed is the need answered by the recorded provider, or why it cannot be.
|
||||
func boundNeed(n mcat.Needed, bound mcat.Chosen, catalogue map[string]mcat.Manifest, node mcat.Node, world mcat.World,
|
||||
here func(string) bool) (mcat.Needed, bool, string) {
|
||||
held := mcat.Needed{Name: n.Name, For: n.For, Local: n.Local, KeepsData: true}
|
||||
if bound.Node == node.Name {
|
||||
m, known := catalogue[bound.Module]
|
||||
if !known || !here(bound.Module) || !providesAt(m, n.Name, mcat.ScopeMesh) {
|
||||
return mcat.Needed{}, false, fmt.Sprintf("%s no longer runs on %s", bound.Module, node.Name)
|
||||
}
|
||||
at := node.At
|
||||
if at == "" {
|
||||
at = "127.0.0.1"
|
||||
}
|
||||
held.From, held.At, held.Module = node.Name, at, m.Module
|
||||
held.Serves, held.SharedOwn, held.Identity = servedByOne(m, n.Name), sharedByOne(m, n.Name), m.IdentityBoundOf(n.Name)
|
||||
held.CredentialForm, held.CredentialTool = m.CredentialFormOf(n.Name), m.CredentialToolOf(n.Name)
|
||||
return held, true, ""
|
||||
}
|
||||
matching := chosenAmong(bound, world.Offered[n.Name])
|
||||
if len(matching) != 1 {
|
||||
return mcat.Needed{}, false, fmt.Sprintf("%s no longer provides it", bound)
|
||||
}
|
||||
p := matching[0]
|
||||
if node.At == "" || p.At == "" {
|
||||
return mcat.Needed{}, false, fmt.Sprintf("%s and %s are not both on the private network", node.Name, p.Node)
|
||||
}
|
||||
identity := mcat.DefaultIdentityBound
|
||||
if pm, known := catalogue[p.Module]; known {
|
||||
held.SharedOwn, _ = pm.SharedCredentialOf(n.Name)
|
||||
held.CredentialForm, held.CredentialTool = pm.CredentialFormOf(n.Name), pm.CredentialToolOf(n.Name)
|
||||
identity = pm.IdentityBoundOf(n.Name)
|
||||
}
|
||||
held.From, held.At, held.Module, held.Serves, held.Identity = p.Node, p.At, p.Module, p.Serves, identity
|
||||
held.LeftOut = p.LeftOut
|
||||
return held, true, ""
|
||||
}
|
||||
|
||||
// BindsFrom is the providers this resolution binds a pair credential's need to — the provision, the
|
||||
// module that requires it and the credential's local name — answered by a machine rather than by a
|
||||
// record. None means this machine states no such binding.
|
||||
func BindsFrom(r mcat.Resolution, provision, module, local string) []string {
|
||||
var from []string
|
||||
for _, n := range r.Needs {
|
||||
if n.ByRecord || n.Name != provision || n.For != module || n.Local != local {
|
||||
continue
|
||||
}
|
||||
if !slices.Contains(from, n.From) {
|
||||
from = append(from, n.From)
|
||||
}
|
||||
}
|
||||
return from
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
package resolution
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
|
||||
// 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
|
||||
// absent requirement is never rendered as an empty string.
|
||||
var bound = regexp.MustCompile(`\$\{bound:([a-z0-9][a-z0-9.-]*[a-z0-9]):([a-z0-9][a-z0-9_-]*)(?:\|default:([^}\n]+))?\}`)
|
||||
|
||||
// knownFor is everything a module may name from one of its bindings.
|
||||
//
|
||||
// Three facts the mesh states about any provision, plus whatever the provider said it serves. A
|
||||
// module may not reach a binding it does not have — the same boundary as a secret, for the same
|
||||
// reason.
|
||||
func knownFor(m mcat.Manifest, needs []mcat.Needed, node string) (map[string]map[string]string, error) {
|
||||
out := map[string]map[string]string{}
|
||||
for _, want := range m.Wants() {
|
||||
for i := range needs {
|
||||
n := needs[i]
|
||||
if n.Name != want || n.For != m.Module {
|
||||
continue
|
||||
}
|
||||
as := mcat.ConsumerIdentity(node, mcat.IdentitySource(m.Slug, m.Module))
|
||||
values := map[string]string{
|
||||
"at": n.At,
|
||||
"from": n.From,
|
||||
"as": as,
|
||||
}
|
||||
// What the provider derives for this consumer rather than for all of them
|
||||
// (novox/hq ADR 0202). Filled here, the one place a provision and the module
|
||||
// requiring it are both in hand.
|
||||
served, err := ServedTo(n.Serves, as)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s requires %s: %w", m.Module, want, err)
|
||||
}
|
||||
for key, value := range served {
|
||||
// The provider's own vocabulary. Rendered plainly: a port is 5432, not 5432.000000,
|
||||
// which is what a float would write and what a connection string would refuse.
|
||||
values[key] = plainly(value)
|
||||
}
|
||||
out[want] = values
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// withOwnNames adds a module's own composed names to what it may name from one binding:
|
||||
// `${bound:<provision>:name}` and `:internal-name`, and for several contributions to one requirement
|
||||
// `:name-<local>` / `:internal-name-<local>`. Set over anything the provider serves under those keys:
|
||||
// what the module is called is the mesh's statement, not the provider's.
|
||||
func withOwnNames(values map[string]string, own map[string]any) {
|
||||
for _, key := range []string{"name", "internal-name"} {
|
||||
if v, ok := own[key].(string); ok {
|
||||
values[key] = v
|
||||
}
|
||||
}
|
||||
many, _ := own["names"].(map[string]any)
|
||||
for local, raw := range many {
|
||||
names, _ := raw.(map[string]any)
|
||||
for _, key := range []string{"name", "internal-name"} {
|
||||
if v, ok := names[key].(string); ok {
|
||||
values[key+"-"+local] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// plainly renders a served value as a program would expect to read it.
|
||||
func plainly(value any) string {
|
||||
switch v := value.(type) {
|
||||
case string:
|
||||
return v
|
||||
case float64:
|
||||
if v == float64(int64(v)) {
|
||||
return fmt.Sprintf("%d", int64(v))
|
||||
}
|
||||
return strings.TrimRight(strings.TrimRight(fmt.Sprintf("%f", v), "0"), ".")
|
||||
case bool:
|
||||
return fmt.Sprintf("%t", v)
|
||||
case nil:
|
||||
return ""
|
||||
default:
|
||||
return fmt.Sprint(v)
|
||||
}
|
||||
}
|
||||
|
||||
// boundInto replaces a file's ${bound:…} placeholders with what the mesh knows.
|
||||
//
|
||||
// A placeholder naming something the module does not require, or a key the provider does not
|
||||
// serve, is refused. Left as it was, the literal `${bound:x:y}` would be written into a
|
||||
// configuration file and read as a value — a connection to a host called `${bound:x:y}`, failing
|
||||
// somewhere that names neither the module nor the mesh.
|
||||
//
|
||||
// **An optional requirement left absent takes the default its placeholder names** (novox/hq ADR
|
||||
// 0330 §4), and one that names none is refused rather than rendered empty. The module check refuses
|
||||
// that already; this is the same rule where the file is made, for a manifest registered before it.
|
||||
func boundInto(resource map[string]any, known map[string]map[string]string, module string, absent map[string]bool) error {
|
||||
if fmt.Sprint(resource["type"]) != "file" {
|
||||
return nil
|
||||
}
|
||||
content, ok := resource["content"].(string)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
var refused error
|
||||
filled := bound.ReplaceAllStringFunc(content, func(token string) string {
|
||||
if refused != nil {
|
||||
return token
|
||||
}
|
||||
m := bound.FindStringSubmatch(token)
|
||||
provision, key, fallback := m[1], m[2], m[3]
|
||||
values, has := known[provision]
|
||||
if !has {
|
||||
if absent[provision] {
|
||||
if fallback != "" {
|
||||
return fallback
|
||||
}
|
||||
refused = fmt.Errorf(
|
||||
"%s has a file that says ${bound:%s:%s}, and %q is optional and absent here: it names no "+
|
||||
"value for then (${bound:%s:%s|default:<value>}), so it would be written empty "+
|
||||
"(novox/hq ADR 0330 §4)", module, provision, key, provision, provision, key)
|
||||
return token
|
||||
}
|
||||
refused = fmt.Errorf(
|
||||
"%s has a file that says ${bound:%s:%s}, and %s does not require %q. It may name %s",
|
||||
module, provision, key, module, provision, orNothing(namesOfBindings(known)))
|
||||
return token
|
||||
}
|
||||
value, said := values[key]
|
||||
if !said {
|
||||
refused = fmt.Errorf(
|
||||
"%s asks its %s binding for %q, and what answers it says %s",
|
||||
module, provision, key, orNothing(namesOfKeys(values)))
|
||||
return token
|
||||
}
|
||||
return value
|
||||
})
|
||||
if refused != nil {
|
||||
return refused
|
||||
}
|
||||
resource["content"] = filled
|
||||
return nil
|
||||
}
|
||||
|
||||
func namesOfBindings(known map[string]map[string]string) []string {
|
||||
var names []string
|
||||
for name := range known {
|
||||
names = append(names, fmt.Sprintf("%q", name))
|
||||
}
|
||||
sort.Strings(names)
|
||||
return names
|
||||
}
|
||||
|
||||
func namesOfKeys(values map[string]string) []string {
|
||||
var names []string
|
||||
for key := range values {
|
||||
names = append(names, fmt.Sprintf("%q", key))
|
||||
}
|
||||
sort.Strings(names)
|
||||
return names
|
||||
}
|
||||
|
||||
func orNothing(names []string) string {
|
||||
if len(names) == 0 {
|
||||
return "nothing"
|
||||
}
|
||||
return join(names)
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
package resolution
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
|
||||
// A consumer writes its own connection string (novox/hq 04-ISSUES/023).
|
||||
//
|
||||
// Everything needed for one now reaches the file that needs it: the address and port from what the
|
||||
// provider serves, the name to present from what the mesh decided, the database name from what the
|
||||
// module itself contributed, and the password through the sealed hole the host fills.
|
||||
func TestAConsumerCanWriteAConnectionString(t *testing.T) {
|
||||
r := mcat.Resolution{
|
||||
Node: "workstation",
|
||||
Modules: []mcat.Manifest{{
|
||||
Module: "keycloak",
|
||||
Requires: []string{"postgres-database"},
|
||||
Secrets: map[string]string{"postgres-database": "/var/lib/keycloak/db.secret"},
|
||||
Resources: []map[string]any{{
|
||||
"id": "dbenv", "type": "file", "path": "/var/lib/keycloak/database.env",
|
||||
"mode": "0600",
|
||||
"content": "KC_DB_URL=jdbc:postgresql://${bound:postgres-database:at}:" +
|
||||
"${bound:postgres-database:port}/keycloak\n" +
|
||||
"KC_DB_USERNAME=${bound:postgres-database:as}\n" +
|
||||
"KC_DB_PASSWORD=${secret:postgres-database}\n",
|
||||
}},
|
||||
}},
|
||||
Needs: []mcat.Needed{{
|
||||
Name: "postgres-database", From: "anchor", At: "anchor.internal", For: "keycloak",
|
||||
Sealed: "sealed-db", Serves: map[string]any{"port": float64(5432)},
|
||||
}},
|
||||
}
|
||||
out, err := Declaration(r, mcat.Rendering{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
file := fileNamed(out, "keycloak.dbenv")
|
||||
if file == nil {
|
||||
t.Fatalf("no file in %v", out)
|
||||
}
|
||||
got := file["content"].(string)
|
||||
|
||||
want := "KC_DB_URL=jdbc:postgresql://anchor.internal:5432/keycloak\n" +
|
||||
"KC_DB_USERNAME=mesh_workstation_keycloak\n" +
|
||||
"KC_DB_PASSWORD=${secret:postgres-database}\n"
|
||||
if got != want {
|
||||
t.Errorf("the consumer cannot connect with what it was given:\n got %q\nwant %q", got, want)
|
||||
}
|
||||
// The password is still a hole. Only the host may fill that one, and only on the machine.
|
||||
if sealed, _ := file["secrets"].(map[string]any); sealed["postgres-database"] != "sealed-db" {
|
||||
t.Errorf("the credential did not travel with the file: %v", file)
|
||||
}
|
||||
}
|
||||
|
||||
// A provider answered on its consumer's own machine is announced at the port the machine PUBLISHES
|
||||
// it on, not the port the module declared (novox/hq 04-ISSUES/038).
|
||||
//
|
||||
// Issue 018 made the same-node provider announced at all; this is the promise after it — that the
|
||||
// port a co-located consumer is told is the one actually listening at the address it is handed. The
|
||||
// mesh assigns a bare `ports` mapping a host port (here the container's 5432 becomes 15432 on this
|
||||
// machine), and it must announce 15432, or the consumer dials workstation.internal:5432 where the
|
||||
// provider is not published. Cross-node consumers already got this (the plan re-derives the served
|
||||
// facts with the provider's assignment); the same-node need was settled before the assignment and
|
||||
// carried the declared port until now.
|
||||
func TestASameNodeProviderIsAnnouncedAtThePortItIsPublishedOn(t *testing.T) {
|
||||
provider := mcat.Manifest{
|
||||
Module: "postgres", Version: "1",
|
||||
Provides: mcat.FromAnywhere("postgres-database"),
|
||||
Listens: []mcat.Listening{{Port: 5432, Protocol: "tcp", From: "mesh"}},
|
||||
Serves: map[string]map[string]any{"postgres-database": {"port": 5432}},
|
||||
Resources: []map[string]any{{
|
||||
"id": "server", "type": "container", "name": "postgres",
|
||||
"ports": []any{"5432"},
|
||||
}},
|
||||
}
|
||||
consumer := mcat.Manifest{
|
||||
Module: "keycloak", Version: "1", Requires: []string{"postgres-database"},
|
||||
Resources: []map[string]any{{
|
||||
"id": "dbenv", "type": "file", "path": "/var/lib/keycloak/database.env", "mode": "0600",
|
||||
"content": "KC_DB_URL=jdbc:postgresql://${bound:postgres-database:at}:" +
|
||||
"${bound:postgres-database:port}/keycloak\n",
|
||||
}},
|
||||
}
|
||||
r, err := Resolve(shelf(provider, consumer),
|
||||
[]string{"postgres", "keycloak"}, reachable(), mcat.World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The mesh assigned the container's 5432 to 15432 on this machine.
|
||||
out, err := Declaration(r, mcat.Rendering{Ports: map[string]map[int]int{"postgres": {5432: 15432}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Where the provider is actually published: the assigned host port, on the machine at large.
|
||||
server := fileNamed(out, "postgres.server")
|
||||
if server == nil {
|
||||
t.Fatalf("the provider container is not in the declaration: %v", out)
|
||||
}
|
||||
if published := fmt.Sprint(server["ports"]); !strings.Contains(published, "15432:5432") {
|
||||
t.Fatalf("the provider was not published on the assigned port: %v", server["ports"])
|
||||
}
|
||||
|
||||
// What the consumer was told to dial: the SAME port, at the announced address — not the 5432 the
|
||||
// module declared, which is closed on that address.
|
||||
dbenv := fileNamed(out, "keycloak.dbenv")
|
||||
if dbenv == nil {
|
||||
t.Fatalf("the consumer's binding file is not in the declaration: %v", out)
|
||||
}
|
||||
got := dbenv["content"].(string)
|
||||
want := "KC_DB_URL=jdbc:postgresql://workstation.internal:15432/keycloak\n"
|
||||
if got != want {
|
||||
t.Errorf("the consumer dials a port the provider does not listen on:\n got %q\nwant %q",
|
||||
got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// A port is 5432, not 5432.000000 — which is what a number decoded from JSON would write, and what
|
||||
// every connection string in the world would refuse.
|
||||
func TestAPortIsWrittenAsAPort(t *testing.T) {
|
||||
if got := plainly(float64(5432)); got != "5432" {
|
||||
t.Errorf("a port was rendered as %q", got)
|
||||
}
|
||||
if got := plainly("postgres"); got != "postgres" {
|
||||
t.Errorf("a string was rendered as %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The name the provider will create is the name the consumer is told to present. One derivation,
|
||||
// so the two agree by construction rather than by two conventions written on the same day.
|
||||
func TestTheProviderAndTheConsumerAgreeOnTheName(t *testing.T) {
|
||||
provider, err := Resolve(shelf(mcat.Manifest{
|
||||
Module: "postgres", Version: "1", Provides: mcat.FromAnywhere("postgres-database"),
|
||||
Grants: map[string]string{"postgres-database": "/var/lib/postgres/grants"},
|
||||
Receives: map[string]string{"postgres-database": "/var/lib/postgres/grants/mesh.json"},
|
||||
}), []string{"postgres"}, reachable(), mcat.World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err := Declaration(provider, mcat.Rendering{Grants: []mcat.Grant{{
|
||||
Provision: "postgres-database", Consumer: "workstation", From: "keycloak",
|
||||
Values: map[string]any{"name": "keycloak"}, Sealed: "sealed",
|
||||
}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var told string
|
||||
for _, res := range out {
|
||||
if res["path"] == "/var/lib/postgres/grants/mesh.json" {
|
||||
told = res["content"].(string)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(told, `"as": "mesh_workstation_keycloak"`) {
|
||||
t.Errorf("the provider was not told what to call the login: %s", told)
|
||||
}
|
||||
if told == "" {
|
||||
t.Fatal("the provider was told nothing at all")
|
||||
}
|
||||
// Which is exactly what the consumer, on its own machine, is told to present.
|
||||
if mcat.ConsumerIdentity("workstation", "keycloak") != "mesh_workstation_keycloak" {
|
||||
t.Errorf("got %q", mcat.ConsumerIdentity("workstation", "keycloak"))
|
||||
}
|
||||
}
|
||||
|
||||
// A binding the module does not have is a typo, refused where it was written.
|
||||
func TestAFileCannotNameABindingTheModuleDoesNotHave(t *testing.T) {
|
||||
r := mcat.Resolution{
|
||||
Node: "workstation",
|
||||
Modules: []mcat.Manifest{{
|
||||
Module: "keycloak", Requires: []string{"postgres-database"},
|
||||
Resources: []map[string]any{{
|
||||
"id": "conf", "type": "file", "path": "/etc/keycloak.conf",
|
||||
"content": "host=${bound:s3-bucket:at}\n",
|
||||
}},
|
||||
}},
|
||||
Needs: []mcat.Needed{{Name: "postgres-database", From: "anchor", At: "a", For: "keycloak"}},
|
||||
}
|
||||
_, err := Declaration(r, mcat.Rendering{})
|
||||
if err == nil {
|
||||
t.Fatal("a module read a binding it does not have")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "postgres-database") {
|
||||
t.Errorf("the refusal does not say what it could have named: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A key the provider does not serve is refused too. Written through, the literal placeholder
|
||||
// becomes a hostname, and the failure names neither the module nor the mesh.
|
||||
func TestAKeyTheProviderDoesNotServeIsRefused(t *testing.T) {
|
||||
r := mcat.Resolution{
|
||||
Node: "workstation",
|
||||
Modules: []mcat.Manifest{{
|
||||
Module: "keycloak", Requires: []string{"postgres-database"},
|
||||
Resources: []map[string]any{{
|
||||
"id": "conf", "type": "file", "path": "/etc/keycloak.conf",
|
||||
"content": "sock=${bound:postgres-database:socket}\n",
|
||||
}},
|
||||
}},
|
||||
Needs: []mcat.Needed{{
|
||||
Name: "postgres-database", From: "anchor", At: "a", For: "keycloak",
|
||||
Serves: map[string]any{"port": float64(5432)},
|
||||
}},
|
||||
}
|
||||
_, err := Declaration(r, mcat.Rendering{})
|
||||
if err == nil {
|
||||
t.Fatal("a module read a key nothing serves, and the placeholder became a value")
|
||||
}
|
||||
if !strings.Contains(err.Error(), `"port"`) {
|
||||
t.Errorf("the refusal does not say what is served: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A machine's resolver configuration must name its resolver by address — it cannot resolve the name
|
||||
// of the thing it resolves names with (novox/hq ADR 0194). So a binding offers the providing
|
||||
// machine's private address beside its name, and only when the machine has one.
|
||||
func TestABindingOffersTheProvidersAddress(t *testing.T) {
|
||||
consumer := func() mcat.Resolution {
|
||||
return mcat.Resolution{
|
||||
Node: "workstation",
|
||||
Modules: []mcat.Manifest{{
|
||||
Module: "resolv-conf",
|
||||
Requires: []string{"wildcard-resolution"},
|
||||
Resources: []map[string]any{{
|
||||
"id": "resolv", "type": "file", "path": "/etc/resolv.conf", "mode": "0644",
|
||||
"content": "nameserver ${bound:wildcard-resolution:address}\n",
|
||||
}},
|
||||
}},
|
||||
Needs: []mcat.Needed{{Name: "wildcard-resolution", From: "anchor", At: "anchor.internal", For: "resolv-conf"}},
|
||||
}
|
||||
}
|
||||
out, err := Declaration(consumer(), mcat.Rendering{Machines: map[string]string{"anchor.internal": "10.77.0.1"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := fileNamed(out, "resolv-conf.resolv")["content"]; got != "nameserver 10.77.0.1\n" {
|
||||
t.Fatalf("the resolver is not named by its address: %q", got)
|
||||
}
|
||||
// A machine with no address yet: refused, never written with a blank where the address belongs.
|
||||
if _, err := Declaration(consumer(), mcat.Rendering{}); err == nil {
|
||||
t.Fatal("a file naming an address the mesh does not have was composed")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
package resolution
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
|
||||
var incidentConsumers = []string{"board", "listings", "workflows", "agents", "game"}
|
||||
|
||||
func storeMesh() map[string]mcat.Manifest {
|
||||
shelf := map[string]mcat.Manifest{
|
||||
"store": {Module: "store", Version: "1",
|
||||
Provides: []mcat.Offer{{Name: "postgres-database", Scope: mcat.ScopeMesh}},
|
||||
Claims: []mcat.Claim{{Name: "mesh-store", Scope: mcat.ScopeMesh}},
|
||||
Serves: map[string]map[string]any{"postgres-database": {"port": 5432}},
|
||||
Grants: map[string]string{"postgres-database": "/grants"}},
|
||||
"resolver": {Module: "resolver", Version: "1",
|
||||
Provides: []mcat.Offer{{Name: "wildcard-resolution", Scope: mcat.ScopeMesh}},
|
||||
Claims: []mcat.Claim{{Name: "mesh-dns-resolver", Scope: mcat.ScopeMesh}}},
|
||||
"network": {Module: "network", Version: "1", Requires: []string{"wildcard-resolution"}},
|
||||
}
|
||||
for _, c := range incidentConsumers {
|
||||
shelf[c] = mcat.Manifest{Module: c, Version: "1", Requires: []string{"postgres-database"}}
|
||||
}
|
||||
return shelf
|
||||
}
|
||||
|
||||
// storeWorld is the rest of the mesh as the home server's plan sees it: the anchor runs a store and a
|
||||
// resolver and holds both seats; the home server runs its own of each.
|
||||
func storeWorld() mcat.World {
|
||||
w := mcat.World{Offered: map[string][]mcat.Provider{
|
||||
"postgres-database": {
|
||||
{Node: "anchor", At: "anchor.internal", Module: "store", Serves: map[string]any{"port": 5432}},
|
||||
{Node: "home", At: "home.internal", Module: "store", Serves: map[string]any{"port": 5434}},
|
||||
},
|
||||
"wildcard-resolution": {
|
||||
{Node: "anchor", At: "anchor.internal", Module: "resolver"},
|
||||
{Node: "home", At: "home.internal", Module: "resolver"},
|
||||
},
|
||||
}}
|
||||
w.Held = []mcat.Held{
|
||||
{Claim: "mesh-store", Scope: mcat.ScopeMesh, Node: "anchor", Module: "store"},
|
||||
{Claim: "mesh-dns-resolver", Scope: mcat.ScopeMesh, Node: "anchor", Module: "resolver"},
|
||||
}
|
||||
w.Holdings = w.Held
|
||||
return w
|
||||
}
|
||||
|
||||
var home = mcat.Node{Name: "home", At: "home.internal"}
|
||||
|
||||
func homeAssigned() []string {
|
||||
return append([]string{"store", "resolver", "network"}, incidentConsumers...)
|
||||
}
|
||||
|
||||
func boundFrom(t *testing.T, r mcat.Resolution, consumer, provision string) mcat.Needed {
|
||||
t.Helper()
|
||||
for _, n := range r.Needs {
|
||||
if n.For == consumer && n.Name == provision {
|
||||
return n
|
||||
}
|
||||
}
|
||||
t.Fatalf("no binding of %s for %s: %+v", consumer, provision, r.Needs)
|
||||
return mcat.Needed{}
|
||||
}
|
||||
|
||||
func TestTheIncidentAMachinesOwnStoreKeepsItsConsumersWhenTheSeatIsHeldElsewhere(t *testing.T) {
|
||||
got, err := Resolve(storeMesh(), homeAssigned(), home, storeWorld())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, c := range incidentConsumers {
|
||||
if n := boundFrom(t, got, c, "postgres-database"); n.From != "home" || n.Module != "store" {
|
||||
t.Errorf("%s bound to %s/%s; its data is on the store beside it (issue 273)", c, n.From, n.Module)
|
||||
}
|
||||
}
|
||||
// And the resolver, which keeps nothing of anybody's, still answers from the seat's holder (258).
|
||||
if n := boundFrom(t, got, "network", "wildcard-resolution"); n.From != "anchor" {
|
||||
t.Errorf("the resolver bound to %s; the seat is held on anchor (issue 258)", n.From)
|
||||
}
|
||||
if len(got.Kept) != 0 {
|
||||
t.Errorf("nothing was moved, and %d kept: %+v", len(got.Kept), got.Kept)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheIncidentWithEveryConsumerOnRecordStillMovesNothing(t *testing.T) {
|
||||
w := storeWorld()
|
||||
w.Bound = map[string]map[string]mcat.Chosen{}
|
||||
for _, c := range incidentConsumers {
|
||||
w.Bound[c] = map[string]mcat.Chosen{"postgres-database": {Node: "home", Module: "store"}}
|
||||
}
|
||||
got, err := Resolve(storeMesh(), homeAssigned(), home, w)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, c := range incidentConsumers {
|
||||
if n := boundFrom(t, got, c, "postgres-database"); n.From != "home" {
|
||||
t.Errorf("%s bound to %s", c, n.From)
|
||||
}
|
||||
}
|
||||
if len(got.Kept) != 0 {
|
||||
t.Errorf("kept %+v", got.Kept)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPinElsewhereStillMovesAStoresConsumers(t *testing.T) {
|
||||
w := storeWorld()
|
||||
w.Pinned = map[string]mcat.Chosen{"postgres-database": {Node: "anchor", Module: "store"}}
|
||||
w.Bound = map[string]map[string]mcat.Chosen{"board": {"postgres-database": {Node: "home", Module: "store"}}}
|
||||
got, err := Resolve(storeMesh(), homeAssigned(), home, w)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n := boundFrom(t, got, "board", "postgres-database"); n.From != "anchor" || n.Module != "store" {
|
||||
t.Errorf("bound to %s/%s; a person pinned it to anchor", n.From, n.Module)
|
||||
}
|
||||
if len(got.Kept) != 0 {
|
||||
t.Errorf("a pin is a person's move, not one to keep: %+v", got.Kept)
|
||||
}
|
||||
}
|
||||
|
||||
// A consumer on a machine with no store of its own, bound to one store, when the seat moves to
|
||||
// another: the holder would answer, and the binding stays.
|
||||
func laptopWorld(holder string) mcat.World {
|
||||
w := storeWorld()
|
||||
w.Held = []mcat.Held{{Claim: "mesh-store", Scope: mcat.ScopeMesh, Node: holder, Module: "store"}}
|
||||
w.Holdings = w.Held
|
||||
return w
|
||||
}
|
||||
|
||||
var laptop = mcat.Node{Name: "laptop", At: "laptop.internal"}
|
||||
|
||||
func TestABoundConsumerStaysWhenTheSeatsHolderChanges(t *testing.T) {
|
||||
w := laptopWorld("home")
|
||||
w.Bound = map[string]map[string]mcat.Chosen{"board": {"postgres-database": {Node: "anchor", Module: "store"}}}
|
||||
got, err := Resolve(storeMesh(), []string{"board"}, laptop, w)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
n := boundFrom(t, got, "board", "postgres-database")
|
||||
if n.From != "anchor" || n.At != "anchor.internal" || n.Serves["port"] != 5432 {
|
||||
t.Fatalf("bound to %s at %s %v; its data is on anchor", n.From, n.At, n.Serves)
|
||||
}
|
||||
if len(got.Kept) != 1 {
|
||||
t.Fatalf("the move was not said: %+v", got.Kept)
|
||||
}
|
||||
k := got.Kept[0]
|
||||
if k.Bound != (mcat.Chosen{Node: "anchor", Module: "store"}) || k.Would != (mcat.Chosen{Node: "home", Module: "store"}) || k.Consumer != "board" {
|
||||
t.Fatalf("kept %+v", k)
|
||||
}
|
||||
for _, want := range []string{"would move board's postgres-database from anchor/store to home/store",
|
||||
"its data is on anchor/store", "pin laptop postgres-database home store", "move the data first"} {
|
||||
if !strings.Contains(k.String(), want) {
|
||||
t.Errorf("%q does not say %q", k.String(), want)
|
||||
}
|
||||
}
|
||||
// Without a record it is a binding being made, and the holder answers it as before.
|
||||
w.Bound = nil
|
||||
got, err = Resolve(storeMesh(), []string{"board"}, laptop, w)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n := boundFrom(t, got, "board", "postgres-database"); n.From != "home" {
|
||||
t.Errorf("a new consumer bound to %s; the seat is held on home", n.From)
|
||||
}
|
||||
}
|
||||
|
||||
func TestABoundConsumerWhoseProviderIsGoneIsRefusedNotMoved(t *testing.T) {
|
||||
w := laptopWorld("anchor")
|
||||
w.Offered["postgres-database"] = w.Offered["postgres-database"][:1] // only anchor's store is left
|
||||
w.Bound = map[string]map[string]mcat.Chosen{"board": {"postgres-database": {Node: "home", Module: "store"}}}
|
||||
_, err := Resolve(storeMesh(), []string{"board"}, laptop, w)
|
||||
if err == nil {
|
||||
t.Fatal("bound to home's store, which is gone, and answered by anchor's — the silent move")
|
||||
}
|
||||
for _, want := range []string{"board on laptop is bound to home/store", "home/store no longer provides it",
|
||||
"pin laptop postgres-database anchor store"} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("%q does not say %q", err, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAMachinesOwnStoreUnassignedRefusesItsBoundConsumers(t *testing.T) {
|
||||
w := storeWorld()
|
||||
w.Bound = map[string]map[string]mcat.Chosen{"board": {"postgres-database": {Node: "home", Module: "store"}}}
|
||||
_, err := Resolve(storeMesh(), []string{"board"}, home, w)
|
||||
if err == nil || !strings.Contains(err.Error(), "store no longer runs on home") {
|
||||
t.Fatalf("got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The first pass asks only what a machine offers, and is never refused by a binding.
|
||||
func TestTheFirstPassKeepsNoBinding(t *testing.T) {
|
||||
w := storeWorld()
|
||||
w.Unchecked = true
|
||||
w.Bound = map[string]map[string]mcat.Chosen{"board": {"postgres-database": {Node: "gone", Module: "store"}}}
|
||||
if _, err := Resolve(storeMesh(), []string{"board"}, laptop, w); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// An offer saying it keeps nothing is answered by the seat's holder as the resolver is, even where it
|
||||
// grants a credential.
|
||||
func TestAStoreSayingItKeepsNothingFollowsTheSeat(t *testing.T) {
|
||||
shelf := storeMesh()
|
||||
no := false
|
||||
s := shelf["store"]
|
||||
s.Provides = []mcat.Offer{{Name: "postgres-database", Scope: mcat.ScopeMesh, KeepsConsumerData: &no}}
|
||||
shelf["store"] = s
|
||||
got, err := Resolve(shelf, homeAssigned(), home, storeWorld())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n := boundFrom(t, got, "board", "postgres-database"); n.From != "anchor" {
|
||||
t.Errorf("bound to %s; it keeps nothing, and the seat is held on anchor", n.From)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package resolution
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
|
||||
// The mesh's bus is one per mesh, read from the catalogue beside this checkout.
|
||||
//
|
||||
// **This is step 2's claim, and it is checked here rather than in a bed** (novox/hq ADR 0116):
|
||||
// adoption puts the NATS server into the `mesh-broker` seat on a mesh that is already running,
|
||||
// and the property that matters is that a second one anywhere is refused *when it is assigned*,
|
||||
// not discovered later as two servers holding different halves of the mesh's traffic. A second
|
||||
// bus is not a degraded mesh; it is two meshes that both believe they are the one.
|
||||
func TestASecondMeshBusAnywhereIsRefusedByName(t *testing.T) {
|
||||
nats := catalogueManifest(t, "nats")
|
||||
|
||||
if _, err := Resolve(shelf(nats), []string{"nats"}, workstation(), mcat.World{}); err != nil {
|
||||
t.Fatalf("the bus alone does not resolve: %v", err)
|
||||
}
|
||||
|
||||
elsewhere := mcat.World{Held: []mcat.Held{{Claim: "mesh-broker", Scope: mcat.ScopeMesh,
|
||||
Node: "anchor", Module: "nats"}}}
|
||||
other := workstation()
|
||||
other.Name = "laptop"
|
||||
_, err := Resolve(shelf(nats), []string{"nats"}, other, elsewhere)
|
||||
if err == nil {
|
||||
t.Fatal("a second bus was accepted on another machine")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "mesh-broker") || !strings.Contains(err.Error(), "one per mesh") {
|
||||
t.Fatalf("refused without naming the seat: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The seat is the server's role, not the product's name (novox/hq ADR 0079). A different
|
||||
// implementation of the bus claims the same seat, and the mesh refuses it for the same reason —
|
||||
// which is the property that lets the bus be replaced at all.
|
||||
func TestTheSeatRefusesADifferentBusToo(t *testing.T) {
|
||||
nats := catalogueManifest(t, "nats")
|
||||
held := mcat.World{Held: []mcat.Held{{Claim: "mesh-broker", Scope: mcat.ScopeMesh,
|
||||
Node: "anchor", Module: "some-other-broker"}}}
|
||||
other := workstation()
|
||||
other.Name = "laptop"
|
||||
if _, err := Resolve(shelf(nats), []string{"nats"}, other, held); err == nil {
|
||||
t.Fatal("the seat admitted a second holder because the module's name differed")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
package resolution
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
|
||||
// onNetwork is a set of providers, all of them reachable. Written as a helper because a machine
|
||||
// that cannot reach the one answering its requirement is its own case, tested separately.
|
||||
// reachable is this machine, on the private network.
|
||||
func reachable() mcat.Node {
|
||||
n := workstation()
|
||||
n.At = "workstation.internal"
|
||||
return n
|
||||
}
|
||||
|
||||
func onNetwork(nodes ...string) map[string][]mcat.Provider {
|
||||
out := make([]mcat.Provider, 0, len(nodes))
|
||||
for _, n := range nodes {
|
||||
out = append(out, mcat.Provider{Node: n, At: n + ".internal", Module: "postgres"})
|
||||
}
|
||||
return map[string][]mcat.Provider{"postgres-database": out}
|
||||
}
|
||||
|
||||
func brokeredShelf() map[string]mcat.Manifest {
|
||||
return shelf(mcat.Manifest{Module: "postgres", Version: "1", Provides: mcat.FromAnywhere("postgres-database")}, mcat.Manifest{Module: "meshboard", Version: "1", Requires: []string{"postgres-database"}})
|
||||
}
|
||||
|
||||
func TestADatabaseIsNotInstalledOnEveryMachineThatUsesOne(t *testing.T) {
|
||||
// The fault this whole distinction exists for.
|
||||
got, err := Resolve(brokeredShelf(), []string{"meshboard"}, reachable(), mcat.World{Offered: onNetwork("anchor")})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if have := strings.Join(names(got), " "); strings.Contains(have, "postgres") {
|
||||
t.Fatalf("using a database installed one here: %s", have)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhatAMachineTakesFromElsewhereIsRecorded(t *testing.T) {
|
||||
// It is the only part of a node's set that stops working when a *different* machine goes
|
||||
// away, and it is where a credential will have to be handed back.
|
||||
got, err := Resolve(brokeredShelf(), []string{"meshboard"}, reachable(), mcat.World{Offered: onNetwork("anchor")})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got.Needs) != 1 {
|
||||
t.Fatalf("got %v", got.Needs)
|
||||
}
|
||||
if got.Needs[0].Name != "postgres-database" || got.Needs[0].From != "anchor" {
|
||||
t.Fatalf("got %v", got.Needs[0])
|
||||
}
|
||||
if got.Needs[0].For != "meshboard" {
|
||||
t.Fatalf("it does not say what wanted it: %v", got.Needs[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestNothingInTheMeshProvidingItIsRefusedWithSomewhereToPutIt(t *testing.T) {
|
||||
// Refused rather than installed here. Choosing a machine to put a database on is a decision
|
||||
// with consequences, and nothing resolving a web application should make it silently.
|
||||
_, err := Resolve(brokeredShelf(), []string{"meshboard"}, workstation(), mcat.World{})
|
||||
if err == nil {
|
||||
t.Fatal("a database was found in a mesh that has none")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "assign") || !strings.Contains(err.Error(), "postgres") {
|
||||
t.Fatalf("the refusal does not say what to do: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTwoNodesProvidingItIsRefusedRatherThanPicked(t *testing.T) {
|
||||
// Same rule as everywhere else. Picking one would be a guess about which database a person
|
||||
// meant, and the wrong guess is somebody's data in the wrong place.
|
||||
_, err := Resolve(brokeredShelf(), []string{"meshboard"}, reachable(), mcat.World{Offered: onNetwork("anchor", "archive")})
|
||||
if err == nil {
|
||||
t.Fatal("one of two databases was picked silently")
|
||||
}
|
||||
for _, want := range []string{"anchor", "archive", "pin"} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Fatalf("the refusal does not name %s: %v", want, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSayingWhichOneSettlesIt(t *testing.T) {
|
||||
got, err := Resolve(brokeredShelf(), []string{"meshboard"}, reachable(), mcat.World{
|
||||
Offered: onNetwork("anchor", "archive"),
|
||||
Pinned: map[string]mcat.Chosen{"postgres-database": {Node: "archive", Module: "postgres"}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got.Needs) != 1 || got.Needs[0].From != "archive" {
|
||||
t.Fatalf("the choice was not taken: %v", got.Needs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBeingPointedAtAMachineThatDoesNotProvideItIsRefused(t *testing.T) {
|
||||
// Rather than falling back to one that does. A fallback would quietly move somebody's data to
|
||||
// a machine they did not choose, which is the whole reason the question is asked.
|
||||
_, err := Resolve(brokeredShelf(), []string{"meshboard"}, reachable(), mcat.World{
|
||||
Offered: onNetwork("anchor", "archive"),
|
||||
Pinned: map[string]mcat.Chosen{"postgres-database": {Node: "somewhere-else", Module: "postgres"}},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("a machine was silently given a different database from the one chosen")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "somewhere-else") {
|
||||
t.Fatalf("the refusal does not say what was chosen: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOneProviderDoesNotOverruleAChoice(t *testing.T) {
|
||||
// A single answer is normally taken silently. Not when somebody said they wanted a different
|
||||
// one -- that is the mesh overruling a person, which it does nowhere else.
|
||||
_, err := Resolve(brokeredShelf(), []string{"meshboard"}, reachable(), mcat.World{
|
||||
Offered: onNetwork("anchor"),
|
||||
Pinned: map[string]mcat.Chosen{"postgres-database": {Node: "archive", Module: "postgres"}},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("the only database was used although another was chosen")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "only anchor/postgres provides it") {
|
||||
t.Fatalf("the refusal does not say what is available: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestACatalogueThatDisagreesAboutScopeIsRefused(t *testing.T) {
|
||||
// If one module says a database is local and another says it is anywhere, the same
|
||||
// requirement means two things depending on which one happens to answer it.
|
||||
_, err := Resolve(shelf(mcat.Manifest{Module: "postgres", Version: "1", Provides: mcat.FromAnywhere("postgres-database")}, mcat.Manifest{Module: "sqlite", Version: "1", Provides: mcat.Offers("postgres-database")}, mcat.Manifest{Module: "meshboard", Version: "1", Requires: []string{"postgres-database"}}), []string{"meshboard"}, workstation(), mcat.World{})
|
||||
if err == nil {
|
||||
t.Fatal("a catalogue that disagrees about where a database lives was accepted")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "two things") {
|
||||
t.Fatalf("unhelpful refusal: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestALocalRequirementIsStillAnsweredLocally(t *testing.T) {
|
||||
// The change must not have made everything brokered. A shell is still installed here.
|
||||
got, err := Resolve(shelf(mcat.Manifest{Module: "zsh", Version: "1", Provides: mcat.Offers("shell")}, mcat.Manifest{Module: "tools", Version: "1", Requires: []string{"shell"}}), []string{"tools"}, workstation(), mcat.World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if have := strings.Join(names(got), " "); !strings.Contains(have, "zsh") {
|
||||
t.Fatalf("a shell was not installed on the machine that needs one: %s", have)
|
||||
}
|
||||
}
|
||||
|
||||
func boundShelf() map[string]mcat.Manifest {
|
||||
return shelf(mcat.Manifest{Module: "postgres", Version: "1", Provides: mcat.FromAnywhere("postgres-database"),
|
||||
Serves: map[string]map[string]any{"postgres-database": {"port": 5432, "driver": "postgres"}}}, mcat.Manifest{Module: "meshboard", Version: "1", Requires: []string{"postgres-database"},
|
||||
Binds: map[string]string{"postgres-database": "/etc/meshboard/database.json"}},
|
||||
)
|
||||
}
|
||||
|
||||
func binding(t *testing.T, out []map[string]any) map[string]any {
|
||||
t.Helper()
|
||||
for _, r := range out {
|
||||
if r["path"] != "/etc/meshboard/database.json" {
|
||||
continue
|
||||
}
|
||||
var parsed map[string]any
|
||||
if err := json.Unmarshal([]byte(r["content"].(string)), &parsed); err != nil {
|
||||
t.Fatalf("what the app is told is not readable: %v", err)
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
t.Fatalf("the app was told nothing: %v", out)
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestAnAppIsToldWhereItsDatabaseIs(t *testing.T) {
|
||||
// Knowing it needs the anchor's database is useless to the program that needs it unless the
|
||||
// program is told. This is the whole point of the field.
|
||||
got, err := Resolve(boundShelf(), []string{"meshboard"}, reachable(), mcat.World{Offered: map[string][]mcat.Provider{"postgres-database": {{Node: "anchor", At: "anchor.internal",
|
||||
Serves: map[string]any{"port": 5432, "driver": "postgres"}}}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
told := binding(t, mustDeclare(t, got))
|
||||
if told["from"] != "anchor" || told["at"] != "anchor.internal" {
|
||||
t.Fatalf("it was not told where: %v", told)
|
||||
}
|
||||
serves, _ := told["serves"].(map[string]any)
|
||||
if serves["port"] != float64(5432) || serves["driver"] != "postgres" {
|
||||
t.Fatalf("it was not told how: %v", serves)
|
||||
}
|
||||
}
|
||||
|
||||
func TestItSaysWhereTheCredentialIsInstead(t *testing.T) {
|
||||
// A missing field looks like a bug; a stated absence looks like a boundary. Somebody wiring
|
||||
// this up must not spend an afternoon looking for the password field.
|
||||
//
|
||||
// It used to say only that the mesh had no way to issue one, which stopped being true when
|
||||
// 021 was fixed. A stated absence is only useful while it is accurate — once it is not, it
|
||||
// sends the reader somewhere there is nothing to find.
|
||||
got, _ := Resolve(boundShelf(), []string{"meshboard"}, reachable(), mcat.World{Offered: map[string][]mcat.Provider{"postgres-database": {{Node: "anchor", At: "anchor.internal"}}}})
|
||||
told := binding(t, mustDeclare(t, got))
|
||||
note, _ := told["generated"].(string)
|
||||
if !strings.Contains(note, "not here") || !strings.Contains(note, "secrets") {
|
||||
t.Fatalf("the file does not say where the credential is instead: %v", note)
|
||||
}
|
||||
for key := range told {
|
||||
if strings.Contains(key, "password") || strings.Contains(key, "secret") {
|
||||
t.Fatalf("something that looks like a credential appeared: %q", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTwoMachinesThatCannotReachEachOtherAreRefused(t *testing.T) {
|
||||
// An app on one machine and a database on another that share no private network is a mesh
|
||||
// that reports itself configured and does not work. Said here rather than discovered as a
|
||||
// connection timing out.
|
||||
_, err := Resolve(boundShelf(), []string{"meshboard"}, workstation(), mcat. // not on the network
|
||||
World{Offered: map[string][]mcat.Provider{"postgres-database": {{Node: "anchor", At: "anchor.internal"}}}})
|
||||
if err == nil {
|
||||
t.Fatal("an app was pointed at a database it has no path to")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "private network") {
|
||||
t.Fatalf("the refusal does not say what is wrong: %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), meshNetwork) {
|
||||
t.Fatalf("the refusal does not say what to assign: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheProviderBeingOffTheNetworkIsAlsoRefused(t *testing.T) {
|
||||
// Both directions, because the failure is identical from either end and the remedy differs.
|
||||
_, err := Resolve(boundShelf(), []string{"meshboard"}, reachable(), mcat.World{Offered: map[string][]mcat.Provider{"postgres-database": {{Node: "anchor"}}}})
|
||||
if err == nil {
|
||||
t.Fatal("an app was pointed at a database that is not on the private network")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "anchor") {
|
||||
t.Fatalf("the refusal does not name the unreachable end: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBindingSomethingAnsweredHereWritesNothing(t *testing.T) {
|
||||
// A file saying "it is on this node" is a fact nobody needs and one more thing to keep true.
|
||||
got, err := Resolve(shelf(mcat.Manifest{Module: "zsh", Version: "1", Provides: mcat.Offers("shell")}, mcat.Manifest{Module: "tools", Version: "1", Requires: []string{"shell"},
|
||||
Binds: map[string]string{"shell": "/etc/tools/shell.json"}},
|
||||
), []string{"tools"}, reachable(), mcat.World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, r := range mustDeclare(t, got) {
|
||||
if r["path"] == "/etc/tools/shell.json" {
|
||||
t.Fatalf("a binding was written for something on this machine: %v", r)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package resolution
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
|
||||
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
|
||||
// sortedShares is a requirement's shares as they are handed to the provider: sorted by `to` (a file whose lines move
|
||||
// looks changed), each entry's methods sorted, and a copy so the manifest is not reordered.
|
||||
func sortedShares(shares []mcat.BucketShare) []mcat.BucketShare {
|
||||
out := make([]mcat.BucketShare, 0, len(shares))
|
||||
for _, s := range shares {
|
||||
methods := append([]string(nil), s.Methods...)
|
||||
sort.Strings(methods)
|
||||
out = append(out, mcat.BucketShare{To: s.To, Methods: methods})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].To < out[j].To })
|
||||
return out
|
||||
}
|
||||
|
||||
// withShares adds this module's shares of the requirement to the values it gives the provider. A module whose own
|
||||
// `contributes` already names `shares` is refused: the field is the only way to say it (ADR 0468 §8).
|
||||
func withShares(m mcat.Manifest, requirement string, values map[string]any) (map[string]any, error) {
|
||||
shares := m.RequireShares[requirement]
|
||||
if _, named := values[mcat.SharesValue]; named && requirement == mcat.BucketRequirement {
|
||||
return nil, fmt.Errorf("%s contributing to %s names `shares`; write it on its requirement instead (novox/hq ADR 0468 §8)",
|
||||
m.Module, requirement)
|
||||
}
|
||||
if len(shares) == 0 {
|
||||
return values, nil
|
||||
}
|
||||
out := make(map[string]any, len(values)+1)
|
||||
for k, v := range values {
|
||||
out[k] = v
|
||||
}
|
||||
listed := []any{}
|
||||
for _, s := range sortedShares(shares) {
|
||||
methods := []any{}
|
||||
for _, method := range s.Methods {
|
||||
methods = append(methods, method)
|
||||
}
|
||||
listed = append(listed, map[string]any{"to": s.To, "methods": methods})
|
||||
}
|
||||
out[mcat.SharesValue] = listed
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package resolution
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
|
||||
const filesWithShares = `{"module":"files","version":"1","requires":["shell",
|
||||
{"name":"s3-bucket","shares":[{"to":"seat:node-files","methods":["put"]},{"to":"print-and-scan","methods":["get","put"]}]}]}`
|
||||
|
||||
func minioLike() mcat.Manifest {
|
||||
return mcat.Manifest{Module: "minio", Version: "1", Provides: mcat.Offers("s3-bucket"),
|
||||
Receives: map[string]string{"s3-bucket": "/grants/mesh.json"}}
|
||||
}
|
||||
|
||||
func givenToMinio(t *testing.T, out []map[string]any) []mcat.Contribution {
|
||||
t.Helper()
|
||||
for _, r := range out {
|
||||
if r["path"] != "/grants/mesh.json" {
|
||||
continue
|
||||
}
|
||||
var parsed struct {
|
||||
Given []mcat.Contribution `json:"given"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(r["content"].(string)), &parsed); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return parsed.Given
|
||||
}
|
||||
t.Fatalf("minio was given no file: %v", out)
|
||||
return nil
|
||||
}
|
||||
|
||||
func sharesOf(t *testing.T, values map[string]any) string {
|
||||
t.Helper()
|
||||
raw, err := json.Marshal(values["shares"])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
const sortedSharesJSON = `[{"methods":["get","put"],"to":"print-and-scan"},{"methods":["put"],"to":"seat:node-files"}]`
|
||||
|
||||
// 1591/AC3: the values a consumer gives carry the shares sorted by `to`, remote (grant) and co-located alike.
|
||||
func TestTheGrantCarriesTheSharesSorted(t *testing.T) {
|
||||
m := mustParse(t, filesWithShares)
|
||||
remote := mcat.Resolution{Node: "laptop", Modules: []mcat.Manifest{m}}
|
||||
values, asks, err := ContributionsFrom(remote, "s3-bucket", "files", mcat.SettingsBy{})
|
||||
if err != nil || !asks {
|
||||
t.Fatalf("asks %v, err %v", asks, err)
|
||||
}
|
||||
if got := sharesOf(t, values); got != sortedSharesJSON {
|
||||
t.Fatalf("the grant values carry %s", got)
|
||||
}
|
||||
// As the provider's file shows it, through a grant.
|
||||
provider, err := Resolve(shelf(minioLike()), []string{"minio"}, workstation(), mcat.World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err := Declaration(provider, mcat.Rendering{Grants: []mcat.Grant{{Provision: "s3-bucket", Consumer: "laptop", From: "files", Values: values}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
given := givenToMinio(t, out)
|
||||
if len(given) != 1 || sharesOf(t, given[0].Values) != sortedSharesJSON {
|
||||
t.Fatalf("the remote consumer's shares did not reach the file: %+v", given)
|
||||
}
|
||||
|
||||
// Co-located: the consumer contributes to s3-bucket too, and no grant is passed.
|
||||
co := mustParse(t, `{"module":"files","version":"1","contributes":{"s3-bucket":{"name":"files"}},
|
||||
"requires":[{"name":"s3-bucket","shares":[{"to":"seat:node-files","methods":["put"]},{"to":"print-and-scan","methods":["put","get"]}]}]}`)
|
||||
both, err := Resolve(shelf(minioLike(), co), []string{"files"}, workstation(), mcat.World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err = Declaration(both, mcat.Rendering{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
given = givenToMinio(t, out)
|
||||
if len(given) != 1 || sharesOf(t, given[0].Values) != sortedSharesJSON || given[0].Values["name"] != "files" {
|
||||
t.Fatalf("the co-located consumer's shares did not reach the file: %+v", given)
|
||||
}
|
||||
}
|
||||
|
||||
// 1591/AC5: a consumer whose contributes to s3-bucket names `shares` itself is refused.
|
||||
func TestAConsumerCannotContributeSharesItself(t *testing.T) {
|
||||
_, err := decodeForTest([]byte(`{"module":"files","version":"1",
|
||||
"contributes":{"s3-bucket":{"shares":"x"}},
|
||||
"requires":[{"name":"s3-bucket","shares":[{"to":"a","methods":["get"]}]}]}`))
|
||||
if err == nil || !strings.Contains(err.Error(), "ADR 0468 §8") {
|
||||
t.Errorf("both written, accepted: %v", err)
|
||||
}
|
||||
// Without the field the grant is still refused, so the provider is never told something the manifest did not say.
|
||||
m := mcat.Manifest{Module: "files", Requires: []string{"s3-bucket"},
|
||||
Contributes: map[string]map[string]any{"s3-bucket": {"shares": "x"}}}
|
||||
r := mcat.Resolution{Node: "laptop", Modules: []mcat.Manifest{m}}
|
||||
if _, _, err := ContributionsFrom(r, "s3-bucket", "files", mcat.SettingsBy{}); err == nil {
|
||||
t.Error("a contributed `shares` reached the grant")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package resolution
|
||||
|
||||
import (
|
||||
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
|
||||
// runByAResource is the artifacts one of a module's own resources names — a process that runs it,
|
||||
// a step, an archive that unpacks it — by name.
|
||||
func runByAResource(m mcat.Manifest) map[string]bool {
|
||||
named := map[string]bool{}
|
||||
for _, r := range m.Resources {
|
||||
if a, ok := r["artifact"].(string); ok && a != "" {
|
||||
named[a] = true
|
||||
}
|
||||
}
|
||||
return named
|
||||
}
|
||||
|
||||
// RunByAResource is the bundles of m one of its own resources runs (its service, not its tools).
|
||||
func RunByAResource(m mcat.Manifest) map[string]bool { return runByAResource(m) }
|
||||
@@ -0,0 +1,20 @@
|
||||
package resolution
|
||||
|
||||
import (
|
||||
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
|
||||
// busyInto composes a container's `busy` into the node-engine's words, or takes it away: for an engine that
|
||||
// reads it, the command and its timeout written out; for one that does not, the field is not sent, and the
|
||||
// container is replaced at once, as before.
|
||||
func busyInto(resource map[string]any, with mcat.Rendering) {
|
||||
if _, has := resource[mcat.BusyField]; !has {
|
||||
return
|
||||
}
|
||||
if !with.ReadsBusy {
|
||||
delete(resource, mcat.BusyField)
|
||||
return
|
||||
}
|
||||
b, _, _ := mcat.ReadBusy(resource)
|
||||
resource[mcat.BusyField] = map[string]any{"command": b.Command, "timeout": b.Timeout.String()}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package resolution
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
|
||||
func busyManifest(t *testing.T, on string, busy any) []byte {
|
||||
t.Helper()
|
||||
extra := map[string]any{"id": "agent", "type": "container", "name": "app-agent",
|
||||
"image": "registry.example/app" + healthDigest}
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal(healthManifest(t, "", nil, extra), &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, r := range m["resources"].([]any) {
|
||||
if r := r.(map[string]any); r["id"] == on {
|
||||
r[mcat.BusyField] = busy
|
||||
}
|
||||
}
|
||||
out, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestABusyIsSentOnlyToAnEngineThatReadsIt(t *testing.T) {
|
||||
m, err := decodeForTest(busyManifest(t, "agent", map[string]any{"command": "mesh-builder busy"}))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
compose := func(with mcat.Rendering) []map[string]any {
|
||||
out, err := Declaration(mcat.Resolution{Node: "laptop", Modules: []mcat.Manifest{m}}, with)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
older := compose(mcat.Rendering{})
|
||||
if b, sent := older[indexOfID(older, "app.agent")][mcat.BusyField]; sent {
|
||||
t.Fatalf("busy was sent to an engine that does not read it: %v", b)
|
||||
}
|
||||
newer := compose(mcat.Rendering{ReadsBusy: true})
|
||||
got, _ := newer[indexOfID(newer, "app.agent")][mcat.BusyField].(map[string]any)
|
||||
if got["command"] != "mesh-builder busy" || got["timeout"] != "10s" {
|
||||
t.Fatalf("busy composed as %v", got)
|
||||
}
|
||||
if _, has := m.Resources[len(m.Resources)-1][mcat.BusyField].(map[string]any)["timeout"]; has {
|
||||
t.Error("composing wrote the timeout into the catalogue's own manifest")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package resolution
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-host/internal/beside"
|
||||
)
|
||||
|
||||
// TestEveryCatalogueManifestParses runs the real catalogue through the real gate.
|
||||
//
|
||||
// Not a fixture: the point is whether the manifests as written are accepted by the control plane that
|
||||
// will read them, and a copy of one manifest proves nothing about the other seventy-one.
|
||||
// catalogueRoot is the catalogue these checks run over: in a merge check the clone the build seat put
|
||||
// beside this one, elsewhere the copy captured in testdata/beside (internal/beside). A check that only
|
||||
// ran when somebody remembered a variable was a check nobody ran (novox/hq issue 134), and one that read
|
||||
// whatever checkout sat beside judged the machine, not the change (novox/hq issue 432): it never skips.
|
||||
func catalogueRoot(t *testing.T) string {
|
||||
t.Helper()
|
||||
return beside.Dir(t, "mesh-catalog")
|
||||
}
|
||||
|
||||
func TestEveryCatalogueManifestParses(t *testing.T) {
|
||||
_, found := catalogueManifests(t)
|
||||
named, routed := 0, 0
|
||||
for _, r := range found {
|
||||
m, err := r.manifest, r.err
|
||||
if err != nil {
|
||||
t.Errorf("%s: %v", filepath.Base(filepath.Dir(r.path)), err)
|
||||
continue
|
||||
}
|
||||
for _, l := range m.Listens {
|
||||
if l.Name != "" {
|
||||
named++
|
||||
}
|
||||
}
|
||||
for port := range RoutedPorts(m) {
|
||||
_ = port
|
||||
routed++
|
||||
}
|
||||
}
|
||||
t.Logf("%d manifests, %d named endpoints, %d routed endpoints resolved", len(found), named, routed)
|
||||
if named == 0 {
|
||||
t.Fatal("no endpoint in the catalogue is named, so this proved nothing")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package resolution
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
|
||||
// readManifest is one manifest of the catalogue as ReadTreeManifest read it.
|
||||
type readManifest struct {
|
||||
path string // the module.json
|
||||
manifest mcat.Manifest
|
||||
err error
|
||||
}
|
||||
|
||||
var readOnce sync.Once
|
||||
|
||||
var readAll []readManifest
|
||||
|
||||
var readRoot string
|
||||
|
||||
// catalogueManifests is every manifest of the catalogue (catalogueRoot), read through ReadTreeManifest once
|
||||
// per test binary and not once per test: the checks over the real catalogue each read and parsed every
|
||||
// manifest again, and together took 1.5 s of mesh-sdk's unit bound of 2 s (issue 1756). What it returns is
|
||||
// shared and read-only: a test that changes a manifest copies it first.
|
||||
func catalogueManifests(t *testing.T) (string, []readManifest) {
|
||||
t.Helper()
|
||||
root := catalogueRoot(t)
|
||||
readOnce.Do(func() {
|
||||
readRoot = root
|
||||
found, err := filepath.Glob(filepath.Join(root, "modules", "*", "module.json"))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for _, p := range found {
|
||||
m, err := mcat.ReadTreeManifest(root, filepath.Dir(p))
|
||||
readAll = append(readAll, readManifest{path: p, manifest: m, err: err})
|
||||
}
|
||||
})
|
||||
if len(readAll) == 0 {
|
||||
t.Fatalf("no manifests under %s", root)
|
||||
}
|
||||
return readRoot, readAll
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package resolution
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"github.com/novox/mesh-host/internal/beside"
|
||||
)
|
||||
|
||||
// **A machine trusts the mesh's authority because a module put its root there** (novox/hq ADR
|
||||
// 0147, issue 129). The module carries a shell script and a unit, and both are worthless unless
|
||||
// the mesh fills in where the authority is — which is the one thing about it the manifest cannot
|
||||
// state, because the authority's address is a fact about the mesh and not about the module.
|
||||
//
|
||||
// So what is checked here is the rendering, not the parsing: the script the machine will run
|
||||
// names the authority it was bound to, and the unit runs that script both ways. The verification
|
||||
// itself — a plain client trusting an internal name on a machine holding this, and failing on one
|
||||
// that does not — is the lab's, and cannot be had here.
|
||||
func TestCaTrustRendersTheAuthorityItWasBoundTo(t *testing.T) {
|
||||
m, err := mcat.ReadTreeManifest(beside.Dir(t, "mesh-catalog"), filepath.Join(beside.Catalogue(t), "ca-trust"))
|
||||
if err != nil {
|
||||
t.Fatalf("the trust module does not parse:\n%v", err)
|
||||
}
|
||||
|
||||
r := mcat.Resolution{
|
||||
Node: "workstation",
|
||||
Modules: []mcat.Manifest{m},
|
||||
Needs: []mcat.Needed{{
|
||||
Name: "internal-acme-ca", From: "anchor", At: "anchor.internal", For: "ca-trust",
|
||||
Serves: map[string]any{
|
||||
"port": float64(9000), "path": "/acme/acme/directory", "roots": "/roots.pem",
|
||||
},
|
||||
}},
|
||||
}
|
||||
out, err := Declaration(r, mcat.Rendering{})
|
||||
if err != nil {
|
||||
t.Fatalf("the trust module could not be composed for a machine: %v", err)
|
||||
}
|
||||
|
||||
script := fileNamed(out, "ca-trust.anchor")
|
||||
if script == nil {
|
||||
t.Fatalf("nothing writes the script the unit runs: %v", out)
|
||||
}
|
||||
body, _ := script["content"].(string)
|
||||
if !strings.Contains(body, "https://anchor.internal:9000/roots.pem") {
|
||||
t.Errorf("the script does not fetch from the authority it was bound to:\n%s", body)
|
||||
}
|
||||
if script["mode"] != "0755" {
|
||||
t.Errorf("the script is written %v, which systemd cannot execute", script["mode"])
|
||||
}
|
||||
|
||||
unit := fileNamed(out, "ca-trust.unit")
|
||||
if unit == nil {
|
||||
t.Fatalf("no unit: %v", out)
|
||||
}
|
||||
text, _ := unit["content"].(string)
|
||||
// Both halves. A unit that only installs the anchor leaves a machine trusting an authority
|
||||
// nobody assigned it to any more, which is the half issue 129 asked for by name.
|
||||
for _, want := range []string{
|
||||
"ExecStart=" + script["path"].(string) + " install",
|
||||
"ExecStop=" + script["path"].(string) + " remove",
|
||||
"RemainAfterExit=yes",
|
||||
} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Errorf("the unit does not say %q:\n%s", want, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package resolution
|
||||
|
||||
import (
|
||||
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
|
||||
// checkerVerbs are the checker seat's verbs: ask a try, read one, end one by hand. Optional until the checker serves them
|
||||
// everywhere.
|
||||
func checkerVerbs() []mcat.Verb {
|
||||
return []mcat.Verb{
|
||||
{Name: "try", Optional: true, Description: "Try a pushed head before its pull request is opened: the checker " +
|
||||
"judges it as it judges a pull request's head, merged onto its trunk's head, its unit and integration " +
|
||||
"levels through the build modules, and owes the forge nothing. Answers the try's id at once; try-read " +
|
||||
"reads where it stands. A try of the same head that waits or runs is the answer to a second ask; at most " +
|
||||
"4 wait or run at once, mesh-wide.",
|
||||
Input: schema(map[string]string{"repository": "<owner>/<name>",
|
||||
"head": "the full commit to try, 40 or 64 hex digits",
|
||||
"trunk": "the trunk commit to merge it onto, or the trunk's branch for its head now; the trunk's head when absent",
|
||||
"tests": "a go test -run pattern: only those tests run, and nothing is kept",
|
||||
"level": "unit or integration: only this level; both when absent"},
|
||||
[]string{"repository", "head"}),
|
||||
Replaces: []string{"sh merge-check.sh", "docker run postgres"}},
|
||||
{Name: "try-read", Optional: true, Description: "Where a try stands, as stored: queued, running, or its end " +
|
||||
"(pass, fail, or error when it could not run), with what was said of it, the head and the trunk commit " +
|
||||
"it was merged onto, the time it took and what failed first. Read-only.",
|
||||
Input: schema(map[string]string{"id": "the try's id, try-<digits>"}, []string{"id"})},
|
||||
{Name: "try-end", Optional: true, Description: "End a try that waits or runs, as error, by hand: say why (kept in " +
|
||||
"what it says). Its place among the tries is free at once and the next try of its head asks anew with a new " +
|
||||
"id. A try that ended already is refused.",
|
||||
Input: schema(map[string]string{"id": "the try's id, try-<digits>", "why": "why it is ended, in words"},
|
||||
[]string{"id", "why"})},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package resolution
|
||||
|
||||
import (
|
||||
"sort"
|
||||
|
||||
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
|
||||
// matches is whether this provider is the one chosen.
|
||||
func chosenMatches(c mcat.Chosen, p mcat.Provider) bool {
|
||||
return p.Node == c.Node && (c.Module == "" || p.Module == c.Module)
|
||||
}
|
||||
|
||||
// among is every offered provider the choice names — one, when the choice is whole.
|
||||
func chosenAmong(c mcat.Chosen, where []mcat.Provider) []mcat.Provider {
|
||||
var out []mcat.Provider
|
||||
for _, p := range where {
|
||||
if chosenMatches(c, p) {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// nameOf is how a refusal names a provider: the node and the module on it.
|
||||
func nameOf(p mcat.Provider) string {
|
||||
return mcat.Chosen{Node: p.Node, Module: p.Module}.String()
|
||||
}
|
||||
|
||||
// providerNames is every provider named, sorted, for a refusal to list.
|
||||
func providerNames(where []mcat.Provider) []string {
|
||||
out := make([]string, 0, len(where))
|
||||
for _, p := range where {
|
||||
out = append(out, nameOf(p))
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// providersHere is which modules in this node's own set offer a provision, sorted.
|
||||
func providersHere(catalogue map[string]mcat.Manifest, here func(string) bool, want string) []string {
|
||||
var out []string
|
||||
for name, m := range catalogue {
|
||||
if !here(name) {
|
||||
continue
|
||||
}
|
||||
for _, o := range m.Offers() {
|
||||
if o == want {
|
||||
out = append(out, name)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// servedByOne is what one provider beside the consumer says a consumer needs to know, or nothing.
|
||||
//
|
||||
// Serving is *whether* a need is created at all when the provider is on this same machine (novox/hq
|
||||
// 04-ISSUES/038's sibling): a need never created is a binding the consumer never gets. The manifest
|
||||
// alone answers that; the values are settled later, with the node's settings.
|
||||
func servedByOne(m mcat.Manifest, want string) map[string]any {
|
||||
if _, ok := m.Serves[want]; ok {
|
||||
return ServedOn(m, want, nil)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// sharedByOne is the own secret that provider names as its credential (ADR 0158), or "" when it
|
||||
// gives each consumer its own.
|
||||
func sharedByOne(m mcat.Manifest, want string) string {
|
||||
if own, shared := m.SharedCredentialOf(want); shared {
|
||||
return own
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func oneOf(list []string, s string) bool {
|
||||
for _, x := range list {
|
||||
if x == s {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
package resolution
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
|
||||
// A root certificate, as a certificate authority serves one: a real, self-signed one made for these tests (its key
|
||||
// thrown away), because the one setting that may hold lines must parse as a certificate (novox/hq issue 339).
|
||||
const servedRoot = `-----BEGIN CERTIFICATE-----
|
||||
MIIBPjCB8aADAgECAhRZG3p93hUUB5bz00uxhYo/nJnTQjAFBgMrZXAwFDESMBAG
|
||||
A1UEAwwJdGVzdCByb290MCAXDTI2MTAwODIyMjE0NloYDzIxMjYwOTE0MjIyMTQ2
|
||||
WjAUMRIwEAYDVQQDDAl0ZXN0IHJvb3QwKjAFBgMrZXADIQA0pt/ld+W0MXwBhPfO
|
||||
cuAt56kIW6Qcn+4vqWpuvHTiqaNTMFEwHQYDVR0OBBYEFIjgaLk4OVGIOeZQiqnN
|
||||
p9vhciFjMB8GA1UdIwQYMBaAFIjgaLk4OVGIOeZQiqnNp9vhciFjMA8GA1UdEwEB
|
||||
/wQFMAMBAf8wBQYDK2VwA0EAl9uWeSM2XAV8u0reyV3BLRxNVik+4FCRO1QKPs2k
|
||||
IlB1rK9oAOYManH+VFuBMI/JJ31ajSti81q4E0CSw8r5DQ==
|
||||
-----END CERTIFICATE-----
|
||||
`
|
||||
|
||||
// stepCA is an internal ACME authority: it answers `acme-ca` from anywhere in the mesh, and what a
|
||||
// consumer must know is the directory URL and the root to trust. **The root is not knowable when
|
||||
// the module is written** — it exists only once the CA has been initialised — so the manifest
|
||||
// declares the key and the operator supplies the value as a setting, which is exactly the shape the
|
||||
// cross-node path settles and the same-node path did not.
|
||||
func stepCA() mcat.Manifest {
|
||||
return mcat.Manifest{
|
||||
Module: "step-ca", Version: "1",
|
||||
Provides: mcat.FromAnywhere("acme-ca"),
|
||||
Listens: []mcat.Listening{{Port: 9000, Protocol: "tcp", From: mcat.FromMesh}},
|
||||
Serves: map[string]map[string]any{"acme-ca": {
|
||||
"directory": "https://anchor.internal/acme/acme/directory",
|
||||
// Declared empty: a manifest is the same on every mesh, and this mesh's root is not.
|
||||
"root": "",
|
||||
}},
|
||||
Resources: []map[string]any{{
|
||||
"id": "server", "type": "container", "name": "step-ca", "ports": []any{"9000"},
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
// stepCAWithItsRoot is the authority whose root the operator supplies: the served key asks for it as
|
||||
// `${setting:root}` (ADR 0155), the only way a setting reaches what a provider serves since a layer
|
||||
// stopped replacing a served key (novox/hq issue 696; before it, the empty `root` was replaced whole).
|
||||
func stepCAWithItsRoot() mcat.Manifest {
|
||||
m := stepCA()
|
||||
m.Serves["acme-ca"]["root"] = "${setting:root}"
|
||||
return m
|
||||
}
|
||||
|
||||
// routeProxy issues from that authority, so it must be told the root to verify it with.
|
||||
func routeProxy() mcat.Manifest {
|
||||
return mcat.Manifest{
|
||||
Module: "route-proxy", Version: "1",
|
||||
Requires: []string{"acme-ca"},
|
||||
Provides: mcat.FromAnywhere("route"),
|
||||
Binds: map[string]string{"acme-ca": "/var/lib/route-proxy/acme-ca.json"},
|
||||
Receives: map[string]string{"route": "/var/lib/route-proxy/routes.json"},
|
||||
Resources: []map[string]any{{
|
||||
"id": "bundle", "type": "file", "path": "/var/lib/route-proxy/ca.pem", "mode": "0644",
|
||||
"content": "${bound:acme-ca:root}",
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
// A co-located provider's served VALUES reach its consumer, not just its served keys.
|
||||
//
|
||||
// The mesh walks a provider on ANOTHER machine and settles what it serves with that node's settings
|
||||
// layers before offering it (cmd/mesh-controller plan.go, theRestOfTheMesh). A provider on the
|
||||
// consumer's own machine was never settled at all: resolve.go's servedHere and declaration.go's
|
||||
// here() both read the manifest and stop there. So a served value the operator supplied — the one
|
||||
// kind of value a manifest cannot carry, because it is different on every mesh — arrived as the
|
||||
// manifest's empty default.
|
||||
//
|
||||
// The consequence is silent and total: route-proxy wrote an empty CA bundle, fell back to the
|
||||
// system trust store, could not verify the internal authority, and no certificate was ever issued.
|
||||
func TestACoLocatedProvidersServedValuesReachItsConsumer(t *testing.T) {
|
||||
r, err := Resolve(shelf(stepCAWithItsRoot(), routeProxy()),
|
||||
[]string{"step-ca", "route-proxy"}, reachable(), mcat.World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// What the operator set on the provider, on this node — the CA's root, which only exists once
|
||||
// the CA has been initialised.
|
||||
out, err := Declaration(r, mcat.Rendering{Settings: mcat.SettingsBy{
|
||||
"step-ca": {{From: "the operator", Values: map[string]any{"root": servedRoot}}},
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
bundle := fileNamed(out, "route-proxy.bundle")
|
||||
if bundle == nil {
|
||||
t.Fatalf("the consumer was given no bundle at all: %v", out)
|
||||
}
|
||||
if got := bundle["content"]; got != servedRoot {
|
||||
t.Errorf("the co-located consumer was not told the root it must trust:\n got %q\nwant %q",
|
||||
got, servedRoot)
|
||||
}
|
||||
}
|
||||
|
||||
// And the binding file says the same thing, for a consumer that reads the binding rather than a
|
||||
// substituted placeholder. The two are one fact and must not be able to disagree.
|
||||
func TestACoLocatedConsumersBindingCarriesTheSettledValues(t *testing.T) {
|
||||
r, err := Resolve(shelf(stepCAWithItsRoot(), routeProxy()),
|
||||
[]string{"step-ca", "route-proxy"}, reachable(), mcat.World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err := Declaration(r, mcat.Rendering{
|
||||
Settings: mcat.SettingsBy{
|
||||
"step-ca": {{From: "the operator", Values: map[string]any{"root": servedRoot}}},
|
||||
},
|
||||
// And the machine moved the provider's port while it was at it, so both halves of the
|
||||
// cross-node derivation are exercised at once.
|
||||
Ports: map[string]map[int]int{"step-ca": {9000: 19000}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
binding := fileNamed(out, "route-proxy.bound-acme-ca")
|
||||
if binding == nil {
|
||||
t.Fatalf("the consumer was given no binding at all: %v", out)
|
||||
}
|
||||
var said struct {
|
||||
Serves map[string]any `json:"serves"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(binding["content"].(string)), &said); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if said.Serves["root"] != servedRoot {
|
||||
t.Errorf("the binding does not carry the settled root: %q", said.Serves["root"])
|
||||
}
|
||||
if port, _ := asPort(said.Serves["port"]); port != 19000 {
|
||||
t.Errorf("the binding does not carry the port the machine publishes: %v", said.Serves["port"])
|
||||
}
|
||||
}
|
||||
|
||||
// A provider PULLED IN rather than assigned is settled the same way.
|
||||
//
|
||||
// The resolver's same-node need is built during the walk, from whichever modules had been chosen by
|
||||
// the time the requirement came up — so which path a co-located binding took depended on the order
|
||||
// a person happened to assign things in. Settling after the closure is known removes that: both
|
||||
// orders now produce the same file.
|
||||
func TestACoLocatedProviderIsSettledWhicheverWayItWasPulledIn(t *testing.T) {
|
||||
for _, assigned := range [][]string{
|
||||
{"step-ca", "route-proxy"},
|
||||
{"route-proxy", "step-ca"},
|
||||
} {
|
||||
r, err := Resolve(shelf(stepCAWithItsRoot(), routeProxy()), assigned, reachable(), mcat.World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err := Declaration(r, mcat.Rendering{Settings: mcat.SettingsBy{
|
||||
"step-ca": {{From: "the operator", Values: map[string]any{"root": servedRoot}}},
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bundle := fileNamed(out, "route-proxy.bundle")
|
||||
if bundle == nil || bundle["content"] != servedRoot {
|
||||
t.Errorf("assigned %v: the consumer was not told the root", assigned)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A same-node contribution names the port the MACHINE publishes, not the one the module declared.
|
||||
//
|
||||
// 04-ISSUES/038 fixed this for what a consumer is TOLD about a provider. This is the other
|
||||
// direction: what a workload TELLS the provider about itself. gitea declares a bare container port
|
||||
// 3000, the mesh publishes it as 20000:3000 — and gitea's route contribution still said 3000, so
|
||||
// the proxy beside it dialled a port nothing listens on and answered 502 for every request.
|
||||
//
|
||||
// The redirect uses the CONTRIBUTING module's assignment: the port belongs to the workload, and
|
||||
// using the provider's map would move it to wherever the proxy happens to be published.
|
||||
func TestASameNodeContributionNamesThePortTheMachinePublishes(t *testing.T) {
|
||||
gitea := mcat.Manifest{
|
||||
Module: "gitea", Version: "1",
|
||||
Listens: []mcat.Listening{{Port: 3000, Protocol: "tcp", From: mcat.FromMesh}},
|
||||
Contributes: map[string]map[string]any{"route": {"label": "git", "port": 3000}},
|
||||
Resources: []map[string]any{{
|
||||
"id": "server", "type": "container", "name": "gitea", "ports": []any{"3000"},
|
||||
}},
|
||||
}
|
||||
r, err := Resolve(shelf(gitea, routeProxy(), stepCA()),
|
||||
[]string{"gitea", "route-proxy", "step-ca"}, reachable(), mcat.World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err := Declaration(r, mcat.Rendering{
|
||||
// The machine put gitea's 3000 on 20000, and published it that way.
|
||||
Ports: map[string]map[int]int{"gitea": {3000: 20000}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Where the workload is actually published.
|
||||
server := fileNamed(out, "gitea.server")
|
||||
if published := strings.Join(asStrings(server["ports"]), ","); published != "20000:3000" {
|
||||
t.Fatalf("the workload was not published on the assigned port: %v", server["ports"])
|
||||
}
|
||||
|
||||
// What the proxy beside it was told to dial: the SAME port.
|
||||
routes := fileNamed(out, "route-proxy.received-route")
|
||||
if routes == nil {
|
||||
t.Fatalf("the proxy was given no routes file at all: %v", out)
|
||||
}
|
||||
var given struct {
|
||||
Given []mcat.Contribution `json:"given"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(routes["content"].(string)), &given); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(given.Given) != 1 {
|
||||
t.Fatalf("expected one route, got %v", given.Given)
|
||||
}
|
||||
port, ok := asPort(given.Given[0].Values["port"])
|
||||
if !ok || port != 20000 {
|
||||
t.Errorf("the proxy was told to dial a port nothing listens on: %v",
|
||||
given.Given[0].Values["port"])
|
||||
}
|
||||
}
|
||||
|
||||
// A contribution from ANOTHER machine is left exactly as it was.
|
||||
//
|
||||
// Its port belongs to that machine's assignment, which this node's map knows nothing about —
|
||||
// applying this node's map to it would move a remote workload's port to wherever a local module of
|
||||
// the same name happens to be published, which is worse than the fault being fixed.
|
||||
func TestAContributionFromAnotherMachineKeepsItsOwnPort(t *testing.T) {
|
||||
r, err := Resolve(shelf(routeProxy(), stepCA()),
|
||||
[]string{"route-proxy", "step-ca"}, reachable(), mcat.World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err := Declaration(r, mcat.Rendering{
|
||||
// A same-named module on this machine, moved. The grant below is a laptop's, and must not
|
||||
// be dragged along with it.
|
||||
Ports: map[string]map[int]int{"gitea": {3000: 20000}},
|
||||
Grants: []mcat.Grant{{
|
||||
Provision: "route", Consumer: "laptop", From: "gitea", At: "laptop.internal",
|
||||
Values: map[string]any{"name": "git.example", "port": 3000},
|
||||
}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
routes := fileNamed(out, "route-proxy.received-route")
|
||||
var given struct {
|
||||
Given []mcat.Contribution `json:"given"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(routes["content"].(string)), &given); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(given.Given) != 1 {
|
||||
t.Fatalf("expected one route, got %v", given.Given)
|
||||
}
|
||||
if port, _ := asPort(given.Given[0].Values["port"]); port != 3000 {
|
||||
t.Errorf("another machine's contribution was rewritten with this machine's ports: %v",
|
||||
given.Given[0].Values["port"])
|
||||
}
|
||||
}
|
||||
|
||||
// The cross-node half of the same fault: a grant assembled on the providing machine carries the
|
||||
// consumer's declared port until the CONSUMER's machine's assignments are applied to it. The
|
||||
// declaration layer cannot do it — those assignments are not this machine's, which is the line the
|
||||
// test above holds — so the grant layer is handed them and does it there.
|
||||
func TestAContributionIsRedirectedToWhereItsOwnMachinePublishedIt(t *testing.T) {
|
||||
published := map[int]int{3000: 20000}
|
||||
|
||||
got := AtPublishedPort(map[string]any{"name": "git.example.tld", "port": 3000}, "forge", published)
|
||||
if port, _ := asPort(got["port"]); port != 20000 {
|
||||
t.Errorf("the proxy would dial a port that machine never published: %v", got["port"])
|
||||
}
|
||||
if got["name"] != "git.example.tld" {
|
||||
t.Errorf("redirecting the port disturbed the rest of the contribution: %v", got)
|
||||
}
|
||||
|
||||
// Idempotent, and silent about a port nobody moved: a module that pinned its own mapping has no
|
||||
// assignment, and must come back exactly as it was written.
|
||||
again := AtPublishedPort(got, "forge", published)
|
||||
if port, _ := asPort(again["port"]); port != 20000 {
|
||||
t.Errorf("applying it twice moved the port again: %v", again["port"])
|
||||
}
|
||||
pinned := AtPublishedPort(map[string]any{"port": 9070}, "office", published)
|
||||
if port, _ := asPort(pinned["port"]); port != 9070 {
|
||||
t.Errorf("a port the mesh never assigned was rewritten: %v", pinned["port"])
|
||||
}
|
||||
}
|
||||
|
||||
func asStrings(v any) []string {
|
||||
listed, ok := v.([]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0, len(listed))
|
||||
for _, one := range listed {
|
||||
out = append(out, strings.TrimSpace(plainly(one)))
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
package resolution
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"slices"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
|
||||
func itemKey(it mcat.Item) string { return mcat.ItemKey(it.Collection, it.Name) }
|
||||
|
||||
// order is the item's place among the items of one placeholder: its `order` attribute, or OrderDefault.
|
||||
func itemOrder(it mcat.Item) int {
|
||||
if n, err := strconv.Atoi(it.Attributes[mcat.OrderAttribute]); err == nil && n >= 0 && n <= 99 {
|
||||
return n
|
||||
}
|
||||
return mcat.OrderDefault
|
||||
}
|
||||
|
||||
// matches is whether the item's attributes carry every pair of the selector.
|
||||
func itemMatches(it mcat.Item, where map[string]string) bool {
|
||||
for k, v := range where {
|
||||
if got, has := it.Attributes[k]; !has || got != v {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// itemsInOrder is the module's items by order, then name; the order the old form placed a module's pieces in.
|
||||
func itemsInOrder(items []mcat.Item) []mcat.Item {
|
||||
out := append([]mcat.Item(nil), items...)
|
||||
sort.SliceStable(out, func(a, b int) bool {
|
||||
if oa, ob := itemOrder(out[a]), itemOrder(out[b]); oa != ob {
|
||||
return oa < ob
|
||||
}
|
||||
return out[a].Name < out[b].Name
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// oldCollectionNamespaces are the namespaces read as collections of their own name.
|
||||
var oldCollectionNamespaces = []string{"shell", "environment", "contribution"}
|
||||
|
||||
// ofCollected is where a collector places a collection. Loose inside the braces, so a misspelt name is refused.
|
||||
var ofCollected = regexp.MustCompile(`\$\{collected:([^}]*)\}`)
|
||||
|
||||
var attributeKey = regexp.MustCompile(`^[a-z][a-z0-9-]*$`)
|
||||
|
||||
var attributeValue = regexp.MustCompile(`^[a-z0-9][a-z0-9_.-]*$`)
|
||||
|
||||
// splitSelector is a placeholder's name without its selector, and the selector: `widgets:bar=top,place=left` is
|
||||
// `widgets` and `bar=top,place=left`. A collection's name holds no `=`, so the selector begins at the last colon
|
||||
// before the first one.
|
||||
func splitSelector(name string) (collection, selector string) {
|
||||
eq := strings.Index(name, "=")
|
||||
if eq < 0 {
|
||||
return name, ""
|
||||
}
|
||||
colon := strings.LastIndex(name[:eq], ":")
|
||||
if colon < 0 {
|
||||
return name, ""
|
||||
}
|
||||
return name[:colon], name[colon+1:]
|
||||
}
|
||||
|
||||
// selectorOf is a selector's pairs, or why it is none.
|
||||
func selectorOf(selector string) (map[string]string, error) {
|
||||
out := map[string]string{}
|
||||
if selector == "" {
|
||||
return out, nil
|
||||
}
|
||||
for _, pair := range strings.Split(selector, ",") {
|
||||
k, v, ok := strings.Cut(pair, "=")
|
||||
switch {
|
||||
case !ok || !attributeKey.MatchString(k) || !attributeValue.MatchString(v):
|
||||
return nil, fmt.Errorf("%q in %q is not <attribute>=<value>", pair, selector)
|
||||
case out[k] != "":
|
||||
return nil, fmt.Errorf("%q names %s twice", selector, k)
|
||||
}
|
||||
out[k] = v
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// collectionOfPlaceholder is the collection a placeholder's namespace and key name, and whether they name one.
|
||||
func collectionOfPlaceholder(namespace, key string) (string, bool) {
|
||||
key, _ = splitSelector(key)
|
||||
switch {
|
||||
case namespace == mcat.CollectedNamespace && strings.HasPrefix(key, mcat.EnvironmentCollection+":"):
|
||||
// The environment is one collection, and the rest of the name picks the rendering (issue 795).
|
||||
return mcat.EnvironmentCollection, true
|
||||
case namespace == mcat.CollectedNamespace:
|
||||
return key, true
|
||||
case namespace == "environment":
|
||||
return mcat.EnvironmentCollection, true
|
||||
case slices.Contains(oldCollectionNamespaces, namespace):
|
||||
return namespace + ":" + key, true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// oldCollection is an old namespace's collection by its name: its comment, or why no item may be contributed to it.
|
||||
// ok is false for a name that is not an old namespace's.
|
||||
//
|
||||
// forItem is whether an item is to be contributed to it: an item may be contributed to a kind received as data,
|
||||
// which a collector may place as a collection only where its shape says its pieces are text (design 57 L3).
|
||||
func oldCollection(name string, forItem bool) (comment string, old bool, wrong error) {
|
||||
namespace, key, _ := strings.Cut(name, ":")
|
||||
if !slices.Contains(oldCollectionNamespaces, namespace) {
|
||||
return "", false, nil
|
||||
}
|
||||
switch namespace {
|
||||
case "shell":
|
||||
target, slot, two := strings.Cut(key, ":")
|
||||
if !two || !oneOf(contributionTargets(), target) || !oneOf(knownSlots, slot) {
|
||||
return "", true, fmt.Errorf("%s is no slot: shell code is shell:<shell>:<slot>, the shell one of %s, "+
|
||||
"the session's file one of %s or the power moment one of %s, and the slot one of %s", name,
|
||||
strings.Join(knownShells, ", "), strings.Join(sessionFiles, ", "), strings.Join(powerMoments, ", "),
|
||||
strings.Join(knownSlots, ", "))
|
||||
}
|
||||
// shellCode's line before each module's code.
|
||||
return "#", true, nil
|
||||
case "environment":
|
||||
if key == "" {
|
||||
// The collection itself, which an item is contributed to; a placeholder names a rendering.
|
||||
return "#", true, nil
|
||||
}
|
||||
if key != mcat.EnvironmentPOSIX && key != mcat.EnvironmentSystemd {
|
||||
return "", true, fmt.Errorf("%s is no format: the environment is environment:%s or environment:%s",
|
||||
name, mcat.EnvironmentPOSIX, mcat.EnvironmentSystemd)
|
||||
}
|
||||
return "#", true, nil
|
||||
}
|
||||
seat, kind, _ := strings.Cut(key, ":")
|
||||
s, r, ok := receivable(seat, kind)
|
||||
switch {
|
||||
case !ok && s.Name == "":
|
||||
return "", true, fmt.Errorf("%s names the seat %q, which the mesh does not define", name, seat)
|
||||
case !ok:
|
||||
return "", true, fmt.Errorf("%s: %s; %s", name, s.Name, kindsOf(s))
|
||||
case r.Shape != nil && (forItem || collectableAsText(r)):
|
||||
return r.Comment, true, nil
|
||||
case r.Shape != nil:
|
||||
return "", true, fmt.Errorf("%s is received by %s as data in its own shape, which no item of text is "+
|
||||
"(novox/hq ADR 0255)", name, s.Name)
|
||||
case r.Dirs:
|
||||
return "", true, fmt.Errorf("%s names its contributors' directories, which the holder reads as paths "+
|
||||
"the controller fills; it is not yet a collection (design 57 L1)", name)
|
||||
case s.Name != canonicalSeat(seat):
|
||||
return "", true, fmt.Errorf("%s names the seat by another name; a collection names it %s", name, s.Name)
|
||||
}
|
||||
return r.Comment, true, nil
|
||||
}
|
||||
|
||||
// isOld is whether a collection's name is an old namespace's.
|
||||
func isOld(name string) bool {
|
||||
_, old, _ := oldCollection(name, true)
|
||||
return old
|
||||
}
|
||||
|
||||
// collectedPlaceholderProblems is what is wrong with one resource's ${collected:…}: placed only in a file's content,
|
||||
// and naming a collection the module declares it collects.
|
||||
func collectedPlaceholderProblems(m mcat.Manifest, r map[string]any) []string {
|
||||
var problems []string
|
||||
for _, field := range sortedKeys(r) {
|
||||
v, ok := r[field].(string)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for _, f := range ofCollected.FindAllStringSubmatch(v, -1) {
|
||||
base, selector := splitSelector(f[1])
|
||||
if _, err := selectorOf(selector); err != nil {
|
||||
problems = append(problems, fmt.Sprintf("%s's resource %v names %s: %v", m.Module, r["id"], f[0], err))
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case field != "content":
|
||||
problems = append(problems, fmt.Sprintf("%s's resource %v names %s in its %s; a collection is placed "+
|
||||
"only in a file's content", m.Module, r["id"], f[0], field))
|
||||
case isOld(base):
|
||||
if _, _, wrong := oldCollection(base, false); wrong != nil {
|
||||
problems = append(problems, fmt.Sprintf("%s's resource %v names %s: %v", m.Module, r["id"], f[0], wrong))
|
||||
}
|
||||
case !slices.ContainsFunc(m.Collections, func(c mcat.Collection) bool { return c.Name == base }):
|
||||
problems = append(problems, fmt.Sprintf("%s's resource %v names %s, and %s does not collect %q: "+
|
||||
"declare it under collections (novox/hq ADR 0349 §13)", m.Module, r["id"], f[0], m.Module, base))
|
||||
}
|
||||
}
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
// collectionComment is the comment of a collection a module's file names: declared, or the old namespace's.
|
||||
func collectionComment(m mcat.Manifest, name string) (string, error) {
|
||||
if comment, old, wrong := oldCollection(name, false); old {
|
||||
return comment, wrong
|
||||
}
|
||||
for _, c := range m.Collections {
|
||||
if c.Name == name {
|
||||
return c.Comment, nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("%s names ${collected:%s} and does not collect it", m.Module, name)
|
||||
}
|
||||
|
||||
// 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.
|
||||
func itemsLeftOutFor(modules []mcat.Manifest, holder mcat.Manifest, collection string, where map[string]string,
|
||||
facts map[string]string, caps map[string]bool) []string {
|
||||
var out []string
|
||||
for _, m := range inModuleOrder(modules) {
|
||||
for _, it := range itemsInOrder(m.Items) {
|
||||
if it.Collection != collection || !itemMatches(it, where) || (it.IfCapability != "" && !caps[it.IfCapability]) {
|
||||
continue
|
||||
}
|
||||
if _, rendered := heldText(it, facts); rendered && renderedOldWay(collection) {
|
||||
continue
|
||||
}
|
||||
out = append(out, fmt.Sprintf("%s's item of %s is not placed: %s is filled the old way on this "+
|
||||
"machine, from contributions, and an item only by the provisioner's client (design 57 L1)",
|
||||
m.Module, itemKey(it), holder.Module))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// oldFormsLeftOut says each piece of an old form a handed collector's collections would have held: the collector is
|
||||
// filled from items alone, and the piece's module contributes none (ADR 0349 clause 17).
|
||||
func oldFormsLeftOut(modules []mcat.Manifest, holder mcat.Manifest, collections []mcat.PlanCollection) []string {
|
||||
var out []string
|
||||
for _, c := range collections {
|
||||
namespace, key, _ := strings.Cut(c.Name, ":")
|
||||
for _, m := range inModuleOrder(modules) {
|
||||
old := false
|
||||
switch namespace {
|
||||
case "shell":
|
||||
target, slot, _ := strings.Cut(key, ":")
|
||||
old = slices.ContainsFunc(m.Shell, func(s mcat.ShellCode) bool { return s.For == target && s.Slot == slot })
|
||||
case "environment":
|
||||
old = m.Environment != nil && (len(m.Environment.Variables) > 0 || len(m.Environment.Path) > 0)
|
||||
case "contribution":
|
||||
seat, kind, _ := strings.Cut(key, ":")
|
||||
old = slices.ContainsFunc(manifestAllContributions(m), func(s mcat.SeatContribution) bool {
|
||||
return canonicalSeat(s.Seat) == canonicalSeat(seat) && s.Kind == kind
|
||||
})
|
||||
}
|
||||
if old {
|
||||
out = append(out, fmt.Sprintf("%s's %s is not placed: %s is filled by the provisioner's client on "+
|
||||
"this machine, from items, and %s contributes it the old way (design 57 L1)", m.Module, c.Name,
|
||||
holder.Module, m.Module))
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// renderedOldWay is whether the controller renders a collection's items into a collector not handed.
|
||||
func renderedOldWay(collection string) bool {
|
||||
namespace, _, _ := strings.Cut(collection, ":")
|
||||
return namespace == "shell" || namespace == "contribution" || namespace == mcat.EnvironmentCollection
|
||||
}
|
||||
|
||||
// heldItem is one item of a module rendered the old way.
|
||||
type heldItem struct {
|
||||
item mcat.Item
|
||||
text string
|
||||
}
|
||||
|
||||
// heldItems is a module's items of the collection that the selector takes and the machine's capabilities allow, by
|
||||
// order then name, each rendered as heldText does; the ones it cannot render are left out (itemsLeftOutFor says so).
|
||||
func heldItems(m mcat.Manifest, collection string, where map[string]string, facts map[string]string,
|
||||
caps map[string]bool) []heldItem {
|
||||
if !renderedOldWay(collection) {
|
||||
return nil
|
||||
}
|
||||
var out []heldItem
|
||||
for _, it := range itemsInOrder(m.Items) {
|
||||
if it.Collection != collection || !itemMatches(it, where) || (it.IfCapability != "" && !caps[it.IfCapability]) {
|
||||
continue
|
||||
}
|
||||
if text, ok := heldText(it, facts); ok {
|
||||
out = append(out, heldItem{item: it, text: text})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// heldText is an item's text with the machine's facts filled, ending its line; false for an item whose text the mesh
|
||||
// does not hold or which names anything but a machine fact this machine has.
|
||||
func heldText(it mcat.Item, facts map[string]string) (string, bool) {
|
||||
if it.Content == "" {
|
||||
return "", false
|
||||
}
|
||||
text := it.Content
|
||||
for _, f := range templatePlaceholder.FindAllStringSubmatch(text, -1) {
|
||||
value, has := facts[f[2]]
|
||||
if f[1] != "machine" || !has {
|
||||
return "", false
|
||||
}
|
||||
text = strings.ReplaceAll(text, f[0], value)
|
||||
}
|
||||
if !strings.HasSuffix(text, "\n") {
|
||||
text += "\n"
|
||||
}
|
||||
return text, true
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package resolution
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
|
||||
const collectedBarsText = "top\n${collected:contribution:node-bar:block:bar=top,place=status}" +
|
||||
"bottom\n${collected:contribution:node-bar:block:bar=bottom,place=status}"
|
||||
|
||||
const collectedBars = `{"module":"bars","version":"1","moved":true,
|
||||
"claims":[{"name":"node-bar","scope":"node","renders":{"block":"{{.shows}}"}}],
|
||||
"build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]},
|
||||
"resources":[{"id":"conf","type":"file","path":"/etc/bars.conf","template":"files/bars"}]}`
|
||||
|
||||
const itemsOfPower = `{"module":"power","version":"1","moved":true,
|
||||
"items":[{"collection":"contribution:node-bar:block","name":"battery","if-capability":"battery","attributes":{"bar":"bottom","place":"status","order":"40"},"template":"files/battery"},
|
||||
{"collection":"contribution:node-bar:block","name":"draw","attributes":{"bar":"bottom","place":"status","order":"45"},"template":"files/draw"}],
|
||||
"build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]}}`
|
||||
|
||||
func collectedBarsOn(t *testing.T, caps map[string]bool) mcat.Resolution {
|
||||
t.Helper()
|
||||
bars := held(t, collectedBars, builtWith("bars", map[string]string{"bars": collectedBarsText}))
|
||||
power := held(t, itemsOfPower, builtWith("power", map[string]string{"battery": "B\n", "draw": "D ${machine:name}\n"}))
|
||||
r, err := Resolve(shelf(bars, power), []string{"bars", "power"}, workstation(), mcat.World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r.Account, r.Capabilities = "op", caps
|
||||
return r
|
||||
}
|
||||
|
||||
func TestACollectorMayNameTheBarsBlocksAsACollectionAndNoOtherDataKind(t *testing.T) {
|
||||
bars := held(t, collectedBars, builtWith("bars", map[string]string{"bars": collectedBarsText}))
|
||||
if _, err := Compose(collectedBarsOn(t, nil), handingTo("store:5000")); err != nil {
|
||||
t.Fatalf("the handed collector is refused: %v", err)
|
||||
}
|
||||
if got := collectedPlaceholderProblems(bars, map[string]any{"id": "x",
|
||||
"content": "${collected:contribution:node-bar:block:bar=top,place=status}"}); len(got) != 0 {
|
||||
t.Fatalf("the check refuses it: %v", got)
|
||||
}
|
||||
// A data kind whose shape does not say its items are text stays refused.
|
||||
_, plain, _ := receivable(mcat.BarSeat, mcat.BarKindBlock)
|
||||
plain.Shape = &mcat.Shape{}
|
||||
if collectableAsText(plain) {
|
||||
t.Fatal("a shape not marked as text is collectable")
|
||||
}
|
||||
if _, _, err := oldCollection("contribution:node-bar:block", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// The bars of the catalogue's i3status-rust, with the blocks of power's items, compose to the files the old shaped
|
||||
// contribution made (issue 781). testdata/bars holds the catalogue's bars and block texts as of the move and the bars as
|
||||
// composed before it, for a machine with neither capability, the battery, the power meter and both. Where the
|
||||
// collector is not handed the controller fills the files; where it is, it names the items for the client, which
|
||||
// fills the same files (mesh-catalog's provisioner client holds that half against the same golden files).
|
||||
func TestTheBarsOfTheCatalogueAreTheSameHandedOrNot(t *testing.T) {
|
||||
read := func(name string) string {
|
||||
b, err := os.ReadFile(filepath.Join("testdata", "bars", name))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
collector := `{"module":"bars","version":"1","moved":true,"claims":[{"name":"node-bar","scope":"node"}],
|
||||
"build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]},
|
||||
"resources":[{"id":"top","type":"file","path":"/t","template":"files/top"},
|
||||
{"id":"bottom","type":"file","path":"/b","template":"files/bottom"}]}`
|
||||
power := `{"module":"power","version":"1","moved":true,
|
||||
"items":[{"collection":"contribution:node-bar:block","name":"battery","if-capability":"battery","attributes":{"bar":"bottom","place":"status","order":"40"},"template":"files/battery"},
|
||||
{"collection":"contribution:node-bar:block","name":"power-draw","if-capability":"power-meter","attributes":{"bar":"bottom","place":"status","order":"45"},"template":"files/draw"}],
|
||||
"build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]}}`
|
||||
for name, caps := range map[string]map[string]bool{"none": nil, "battery": {"battery": true},
|
||||
"power-meter": {"power-meter": true}, "both": {"battery": true, "power-meter": true}} {
|
||||
bars := held(t, collector, builtWith("bars", map[string]string{"top": read("top-bar.toml"), "bottom": read("bottom-bar.toml")}))
|
||||
blocks := held(t, power, builtWith("power", map[string]string{"battery": read("bar-battery"), "draw": read("bar-power-draw")}))
|
||||
r, err := Resolve(shelf(bars, blocks), []string{"bars", "power"}, workstation(), mcat.World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r.Account, r.Capabilities = "op", caps
|
||||
// Not handed: the controller fills them.
|
||||
for id, which := range map[string]string{"bars.top": "top", "bars.bottom": "bottom"} {
|
||||
if got := composedFile(t, r, id); got != read(name+"."+which+"-bar.toml") {
|
||||
t.Errorf("%s, %s bar, not handed:\n%s\nwant\n%s", name, which, got, read(name+"."+which+"-bar.toml"))
|
||||
}
|
||||
}
|
||||
// Handed: the plan names the items the machine's capabilities allow, for the client to fill.
|
||||
c, _ := composeWith(t, r, handingTo("store:5000"))
|
||||
var names []string
|
||||
for _, it := range c.Plans["power"].Items {
|
||||
names = append(names, it.Name)
|
||||
}
|
||||
want := []string{}
|
||||
if caps["battery"] {
|
||||
want = append(want, "battery")
|
||||
}
|
||||
if caps["power-meter"] {
|
||||
want = append(want, "power-draw")
|
||||
}
|
||||
if !reflect.DeepEqual(append([]string{}, names...), want) {
|
||||
t.Errorf("%s: the plan names %v, want %v", name, names, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package resolution
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
|
||||
func l2Moved(t *testing.T, parts ...string) string {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(filepath.Join(append([]string{"testdata", "l2-moved"}, parts...)...))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
func TestTheCollectorsL2MovedComposeAsBeforeWhereNothingIsHanded(t *testing.T) {
|
||||
// Each moved contributor, its items read back from its templates archive as the mesh holds them.
|
||||
items := map[string][]string{
|
||||
"asus-zephyrus-g14": {"shell:after-wake:normal", "contribution:node-hotkeys:trigger"},
|
||||
"claude-code": {"shell:zsh:normal"},
|
||||
"mesh-cli": {"shell:zsh:normal"},
|
||||
"powerlevel10k": {"shell:zsh:normal"},
|
||||
"zsh-autosuggestions": {"shell:zsh:normal"},
|
||||
"zsh-syntax-highlighting": {"shell:zsh:last"},
|
||||
}
|
||||
var modules []mcat.Manifest
|
||||
for _, module := range sortedKeys(items) {
|
||||
m := mcat.Manifest{Module: module, Moved: true}
|
||||
for _, collection := range items[module] {
|
||||
m.Items = append(m.Items, mcat.Item{Collection: collection,
|
||||
Content: l2Moved(t, "items", module+"."+strings.ReplaceAll(collection, ":", "-"))})
|
||||
}
|
||||
modules = append(modules, m)
|
||||
}
|
||||
for _, c := range []struct{ module, seat string }{{"zsh", mcat.LoginShellSeat}, {"triggerhappy", mcat.HotkeysSeat},
|
||||
{"power", mcat.PowerSeat}} {
|
||||
holder := mcat.Manifest{Module: c.module, Moved: true, Claims: []mcat.Claim{{Name: c.seat}}}
|
||||
resource := map[string]any{"id": "file", "type": "file", "path": "/etc/" + c.module,
|
||||
"content": l2Moved(t, "templates", c.module)}
|
||||
var unplaced []string
|
||||
if err := contributionsInto(resource, holder, append([]mcat.Manifest{holder}, modules...),
|
||||
map[string]string{"name": "g14"}, mcat.Rendering{}, map[string]bool{}, &unplaced); err != nil {
|
||||
t.Fatalf("%s: %v", c.module, err)
|
||||
}
|
||||
if got, want := resource["content"], l2Moved(t, "want", c.module); got != want {
|
||||
t.Errorf("%s's file is not what the old forms made:\n%s\nwant:\n%s", c.module, got, want)
|
||||
}
|
||||
if len(unplaced) != 0 {
|
||||
t.Errorf("%s: unplaced %v", c.module, unplaced)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package resolution
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
|
||||
const barCollector = `{"module":"bars","version":"1","moved":true,
|
||||
"collections":[{"name":"blocks","comment":"#"}],
|
||||
"build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]},
|
||||
"resources":[{"id":"conf","type":"file","path":"/etc/bars.conf","template":"files/bars"}]}`
|
||||
|
||||
func TestASelectorIsRefusedWhenMalformedOrNamingACollectionNotCollected(t *testing.T) {
|
||||
for placeholder, want := range map[string]string{
|
||||
"${collected:blocks:bar}": "", // no equals sign: a collection named blocks:bar
|
||||
"${collected:blocks:bar=top,bar=bottom}": "names bar twice",
|
||||
"${collected:blocks:bar=}": "is not <attribute>=<value>",
|
||||
"${collected:other:bar=top}": `does not collect "other"`,
|
||||
} {
|
||||
raw := strings.Replace(barCollector, `"template":"files/bars"`, `"content":"`+placeholder+`"`, 1)
|
||||
raw = strings.Replace(raw, `"build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]},`, "", 1)
|
||||
raw = strings.Replace(raw, `"moved":true,`, "", 1)
|
||||
m, err := decodeForTest([]byte(raw))
|
||||
if err == nil {
|
||||
_, err = Declaration((mcat.Resolution{Node: "n", Modules: []mcat.Manifest{m}}), mcat.Rendering{})
|
||||
}
|
||||
if want == "" {
|
||||
continue
|
||||
}
|
||||
if err == nil || !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("%s: %v, want %q", placeholder, err, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package resolution
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
|
||||
func builtWith(module string, texts map[string]string) []mcat.Built {
|
||||
return []mcat.Built{{Name: "files", Kind: mcat.ArtifactArchive,
|
||||
Reference: mcat.ArtifactStoreScheme + module + "/files/blobs/sha256:" + strings.Repeat("e", 64),
|
||||
Digest: "sha256:" + strings.Repeat("e", 64), Texts: texts}}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
package resolution
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
|
||||
type fake struct {
|
||||
on map[string]bool
|
||||
asked []string
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fake) Resources(node string) ([]map[string]any, bool, error) {
|
||||
f.asked = append(f.asked, node)
|
||||
if f.err != nil {
|
||||
return nil, false, f.err
|
||||
}
|
||||
if !f.on[node] {
|
||||
return nil, false, nil
|
||||
}
|
||||
return []map[string]any{{"id": "peers", "type": "file", "path": "/etc/x.conf",
|
||||
"merge": mcat.MergeJSON, "content": `{"peer":"` + node + `"}`}}, true, nil
|
||||
}
|
||||
|
||||
func computedShelf() map[string]mcat.Manifest {
|
||||
return shelf(mcat.Manifest{Module: "mesh-network", Computed: "mesh-network",
|
||||
Provides: mcat.Offers("private-network")})
|
||||
}
|
||||
|
||||
func TestAComputedModuleIsAskedAboutTheNodeItIsFor(t *testing.T) {
|
||||
// The generator gets a node name, not a plan. Everything it needs is the whole mesh, which
|
||||
// it was built with — this is the one thing a node could never work out for itself.
|
||||
got, err := Resolve(computedShelf(), []string{"mesh-network"}, workstation(), mcat.World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gen := &fake{on: map[string]bool{"workstation": true}}
|
||||
out, err := Declaration(got, mcat.Rendering{Generators: map[string]mcat.Generator{"mesh-network": gen}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(gen.asked) != 1 || gen.asked[0] != "workstation" {
|
||||
t.Fatalf("asked about %v, wanted workstation once", gen.asked)
|
||||
}
|
||||
if len(out) != 1 || !strings.Contains(out[0]["content"].(string), `"peer": "workstation"`) {
|
||||
t.Fatalf("got %v", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAMachineNobodyGaveItGetsNothing(t *testing.T) {
|
||||
// The whole point of making the network a module. Before this, every machine with an address
|
||||
// was on the private network and there was no way to say one should stay off.
|
||||
got, err := Resolve(computedShelf(), nil, workstation(), mcat.World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gen := &fake{on: map[string]bool{"workstation": true}}
|
||||
out, err := Declaration(got, mcat.Rendering{Generators: map[string]mcat.Generator{"mesh-network": gen}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(out) != 0 {
|
||||
t.Fatalf("a machine that was assigned nothing got %d resource(s)", len(out))
|
||||
}
|
||||
if len(gen.asked) != 0 {
|
||||
t.Fatalf("the generator was asked about %v, and nobody had asked for it", gen.asked)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssignedAndNotYetPartOfItIsNotAFailure(t *testing.T) {
|
||||
// A machine given the network module before it has a place on it. Brief and ordinary — the
|
||||
// answer is "nothing yet", and treating it as an error would make an ordering a fault.
|
||||
got, _ := Resolve(computedShelf(), []string{"mesh-network"}, workstation(), mcat.World{})
|
||||
gen := &fake{on: map[string]bool{}}
|
||||
out, err := Declaration(got, mcat.Rendering{Generators: map[string]mcat.Generator{"mesh-network": gen}})
|
||||
if err != nil {
|
||||
t.Fatalf("refused a node that is not on the network yet: %v", err)
|
||||
}
|
||||
if len(out) != 0 {
|
||||
t.Fatalf("got %v", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAGeneratorThisControlPlaneDoesNotHaveIsRefused(t *testing.T) {
|
||||
// Sending a machine a module with no files would look like it worked. Named in the message,
|
||||
// because the only fix is a control plane that has it.
|
||||
got, _ := Resolve(shelf(mcat.Manifest{Module: "weather", Computed: "the-weather"}), []string{"weather"}, workstation(), mcat.World{})
|
||||
|
||||
_, err := Declaration(got, mcat.Rendering{Generators: map[string]mcat.Generator{"mesh-network": &fake{}}})
|
||||
if err == nil {
|
||||
t.Fatal("a module computed by nothing was accepted")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "the-weather") {
|
||||
t.Fatalf("the refusal does not name what is missing: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSettingsApplyToAComputedModuleToo(t *testing.T) {
|
||||
// Being computed is about where the files come from, not about whether they are configurable.
|
||||
// A line drawn there would be arbitrary and nobody could predict it.
|
||||
got, _ := Resolve(computedShelf(), []string{"mesh-network"}, workstation(), mcat.World{})
|
||||
gen := &fake{on: map[string]bool{"workstation": true}}
|
||||
out, err := Declaration(got, mcat.Rendering{
|
||||
Generators: map[string]mcat.Generator{"mesh-network": gen},
|
||||
Settings: mcat.SettingsBy{"mesh-network": {{From: "the mesh",
|
||||
Values: map[string]any{"keepalive": 25}}}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
content := out[0]["content"].(string)
|
||||
if !strings.Contains(content, `"keepalive": 25`) {
|
||||
t.Fatalf("the setting did not reach a computed file: %s", content)
|
||||
}
|
||||
if !strings.Contains(content, `"peer": "workstation"`) {
|
||||
t.Fatalf("the setting replaced what the generator computed: %s", content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTwoWaysToBeOnAPrivateNetworkRefuseAndNameBoth(t *testing.T) {
|
||||
// WireGuard is one way. The refusing rule is what makes a second one safe to add: assigning
|
||||
// both is caught rather than producing a machine on two networks that each half-work.
|
||||
_, err := Resolve(shelf(mcat.Manifest{Module: "mesh-network", Computed: "mesh-network",
|
||||
Provides: mcat.Offers("private-network")}, mcat.Manifest{Module: "tailscale", Provides: mcat.Offers("private-network")}, mcat.Manifest{Module: "backups", Requires: []string{"private-network"}},
|
||||
), []string{"backups"}, workstation(), mcat.World{})
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("two answers to one requirement were taken silently")
|
||||
}
|
||||
for _, want := range []string{"mesh-network", "tailscale", "private-network"} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Fatalf("the refusal does not name %s: %v", want, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// reloading answers the runtime's trust as the networking module once did (novox/hq ADR 0102; it no
|
||||
// longer does, ADR 0222 — and beside the runtime's module it is refused, generated_collision_test): a file
|
||||
// written into, and the runtime reloaded on it.
|
||||
type reloading struct{}
|
||||
|
||||
func (reloading) Resources(node string) ([]map[string]any, bool, error) {
|
||||
return []map[string]any{
|
||||
{"id": "registry-trust", "type": "file", "path": "/etc/docker/daemon.json",
|
||||
"merge": mcat.MergeJSON, "into": "json", "content": `{"insecure-registries":["r:5000"]}`},
|
||||
{"id": "registry-trust-reload", "type": "service", "unit": "docker.service",
|
||||
"state": "running", "reload-on": []string{"registry-trust"}},
|
||||
}, true, nil
|
||||
}
|
||||
|
||||
func TestWhatAServiceIsReloadedOnIsNamedAsTheHostWillSeeIt(t *testing.T) {
|
||||
// Ids are prefixed with their module on the way out. An unprefixed reload-on would name a
|
||||
// resource the host never sees, and the runtime would never be reloaded for its trust.
|
||||
got, err := Resolve(computedShelf(), []string{"mesh-network"}, workstation(), mcat.World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err := Declaration(got, mcat.Rendering{Generators: map[string]mcat.Generator{"mesh-network": reloading{}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var file, service map[string]any
|
||||
for _, r := range out {
|
||||
switch r["type"] {
|
||||
case "file":
|
||||
file = r
|
||||
case "service":
|
||||
service = r
|
||||
}
|
||||
}
|
||||
if file == nil || service == nil {
|
||||
t.Fatalf("got %v", out)
|
||||
}
|
||||
if file["into"] != "json" {
|
||||
t.Errorf("into did not reach the host: %v", file)
|
||||
}
|
||||
if want := "[" + file["id"].(string) + "]"; fmt.Sprint(service["reload-on"]) != want {
|
||||
t.Errorf("reload-on names %v, the file is %v", service["reload-on"], file["id"])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
package resolution
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
|
||||
// inConfigForm is a module in the config form that registers: every value typed, described, defaulted or
|
||||
// required, placed in a file or read at runtime, and its mergeable file naming its keys.
|
||||
func inConfigForm() map[string]any {
|
||||
return map[string]any{
|
||||
"module": "notifier",
|
||||
"version": "1",
|
||||
"config": map[string]any{
|
||||
"font-size": map[string]any{"type": "number", "description": "the notification's font size, in points",
|
||||
"minimum": 6, "maximum": 40, "default": 10},
|
||||
"width": map[string]any{"type": "number", "description": "the notification's width, in pixels",
|
||||
"default": 250},
|
||||
"position": map[string]any{"type": "string", "description": "the corner it shows in, a word",
|
||||
"enum": []any{"top-right", "top-left"}, "default": "top-right"},
|
||||
"theme": map[string]any{"type": "string", "description": "the colour theme, a lower-case name",
|
||||
"pattern": "[a-z]+", "default": "dark"},
|
||||
"history": map[string]any{"type": "number", "description": "how many notifications it keeps, a count",
|
||||
"default": 20, "runtime": true},
|
||||
"sender": map[string]any{"type": "string", "description": "the address it mails a digest from",
|
||||
"required": true},
|
||||
},
|
||||
"resources": []any{
|
||||
map[string]any{"id": "configuration", "type": "file", "path": "/x/notifierrc",
|
||||
"content": "font = Inter ${config:font-size}\nwidth = ${config:width}\nfrom = ${config:sender}\n"},
|
||||
map[string]any{"id": "rules", "type": "file", "path": "/x/rules.json", "merge": "json",
|
||||
"takes-config": []any{"position", "theme"}, "content": "{\"position\": \"top-right\"}\n"},
|
||||
map[string]any{"id": "notifier", "type": "service", "unit": "notifier.service",
|
||||
"reload-on": []any{"configuration"}, "restart-on": []any{"rules"}},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func parseRaw(t *testing.T, m map[string]any) (mcat.Manifest, error) {
|
||||
t.Helper()
|
||||
raw, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return decodeForTest(raw)
|
||||
}
|
||||
|
||||
// settingsDunst is the dunst module as it is written today; configDunst the same module rewritten from settings to
|
||||
// config.
|
||||
const settingsDunst = `{"module": "dunst", "version": "1",
|
||||
"settings": {
|
||||
"font-size": {"kind": "preference", "default": 10, "why": "10 points of Inter reads well on a screen of about 100 DPI"},
|
||||
"width": {"kind": "preference", "default": 250, "why": "250 pixels fits a title of about forty characters at 10 points"}
|
||||
},
|
||||
"resources": [
|
||||
{"id": "package", "type": "package", "package": "dunst"},
|
||||
{"id": "configuration", "type": "file", "path": "/home/me/.config/dunst/dunstrc", "mode": "0644",
|
||||
"content": "[global]\n font = Inter ${setting:font-size}\n width = ${setting:width}\n"},
|
||||
{"id": "rules", "type": "file", "path": "/home/me/.config/dunst/rules.json", "merge": "json",
|
||||
"takes-settings": [], "content": "{\"urgency\": \"low\"}\n"},
|
||||
{"id": "notifier", "type": "service", "unit": "dunst.service", "reload-on": ["configuration"]}
|
||||
]}`
|
||||
|
||||
const configDunst = `{"module": "dunst", "version": "1",
|
||||
"config": {
|
||||
"font-size": {"type": "number", "description": "the font size of a notification, in points", "minimum": 6,
|
||||
"maximum": 40, "default": 10},
|
||||
"width": {"type": "number", "description": "the width of a notification, in pixels", "minimum": 100,
|
||||
"default": 250}
|
||||
},
|
||||
"resources": [
|
||||
{"id": "package", "type": "package", "package": "dunst"},
|
||||
{"id": "configuration", "type": "file", "path": "/home/me/.config/dunst/dunstrc", "mode": "0644",
|
||||
"content": "[global]\n font = Inter ${config:font-size}\n width = ${config:width}\n"},
|
||||
{"id": "rules", "type": "file", "path": "/home/me/.config/dunst/rules.json", "merge": "json",
|
||||
"takes-config": [], "content": "{\"urgency\": \"low\"}\n"},
|
||||
{"id": "notifier", "type": "service", "unit": "dunst.service", "reload-on": ["configuration"]}
|
||||
]}`
|
||||
|
||||
// ${config:<key>} fills exactly as ${setting:<key>} does: the module rewritten from settings to config composes
|
||||
// to the same bytes, with no layer, with the node's, and with the mesh's under the node's.
|
||||
func TestAModuleRewrittenFromSettingsToConfigComposesByteIdentically(t *testing.T) {
|
||||
composed := func(manifest string, layers []mcat.Layer) []byte {
|
||||
t.Helper()
|
||||
m, err := decodeForTest([]byte(manifest))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r := anAdoptedAnchor()
|
||||
r.Modules = append(r.Modules, m)
|
||||
with := anchorRendering(false)
|
||||
if layers != nil {
|
||||
with.Settings["dunst"] = layers
|
||||
}
|
||||
c, err := Compose(r, with)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if why, left := c.LeftOut["dunst"]; left {
|
||||
t.Fatalf("dunst was left out: %s", why)
|
||||
}
|
||||
raw, err := json.Marshal(c)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return raw
|
||||
}
|
||||
for _, layers := range [][]mcat.Layer{
|
||||
nil,
|
||||
{{From: "anchor", Values: map[string]any{"font-size": float64(13)}}},
|
||||
{{From: mcat.MeshWideLayer, Values: map[string]any{"width": float64(300)}},
|
||||
{From: "anchor", Values: map[string]any{"font-size": float64(12), "width": float64(320)}}},
|
||||
} {
|
||||
was, is := composed(settingsDunst, layers), composed(configDunst, layers)
|
||||
if !bytes.Equal(was, is) {
|
||||
t.Fatalf("composed differently with %v:\nsettings: %s\nconfig: %s", layers, was, is)
|
||||
}
|
||||
if !strings.Contains(string(is), "font = Inter 1") || strings.Contains(string(is), "${config:") {
|
||||
t.Fatalf("not filled: %s", is)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A required value nothing sets is refused at composition in the config form's words: ${config:<key>} and the
|
||||
// config verb, never `settings set` (review of mesh-controller#320). The settings form's words are unchanged.
|
||||
func TestAnUnsetRequiredValueIsRefusedInTheConfigFormsWords(t *testing.T) {
|
||||
m, err := parseRaw(t, inConfigForm())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r := anAdoptedAnchor()
|
||||
r.Modules = append(r.Modules, m)
|
||||
composed, err := Compose(r, anchorRendering(false))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
why, left := composed.LeftOut["notifier"]
|
||||
if !left {
|
||||
t.Fatal("a module whose required value nothing sets was not left out")
|
||||
}
|
||||
for _, said := range []string{"${config:sender}", `required config value "sender"`, "config verb"} {
|
||||
if !strings.Contains(why, said) {
|
||||
t.Fatalf("left out without %q: %s", said, why)
|
||||
}
|
||||
}
|
||||
for _, unsaid := range []string{"${setting:", "settings set"} {
|
||||
if strings.Contains(why, unsaid) {
|
||||
t.Fatalf("left out in the settings form's words (%q): %s", unsaid, why)
|
||||
}
|
||||
}
|
||||
var unset *UnsetSettingError
|
||||
if err := unsetInConfigWords(settingInto(map[string]any{"type": "file", "content": "${setting:sender}"}, nil,
|
||||
"notifier"), m); !errors.As(err, &unset) || unset.Setting != "sender" {
|
||||
t.Fatalf("not typed as a value nobody gave: %v", err)
|
||||
}
|
||||
|
||||
// A contribution's value too (issue 1126, seed-8: de-spiegel's route names were refused with ${setting:…}).
|
||||
withRoute := inConfigForm()
|
||||
withRoute["contributes"] = map[string]any{"route": map[string]any{"site": map[string]any{"name": "${config:sender}", "port": 80}}}
|
||||
routed, err := parseRaw(t, withRoute)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = resolutionComposed(mcat.Resolution{}, routed, "route", routed.ContributesMany["route"]["site"], nil, "notifier contributing site to route")
|
||||
if !errors.As(err, &unset) || unset.Module != "notifier" || !strings.Contains(err.Error(),
|
||||
`notifier has a contribution site to route, in "name", that says ${config:sender}`) || strings.Contains(err.Error(), "${setting:") {
|
||||
t.Fatalf("a contribution's unset value is not said in the config form's words: %v", err)
|
||||
}
|
||||
|
||||
settingsForm := notifier()
|
||||
settingsForm.Resources[0]["content"] = "from = ${setting:domain}\n"
|
||||
err = unsetInConfigWords(settingInto(settingsForm.Resources[0], nil, "notifier"), settingsForm)
|
||||
if err == nil || !strings.Contains(err.Error(), "`settings set notifier <file>`") {
|
||||
t.Fatalf("the settings form's refusal changed: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
package resolution
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
|
||||
// consumerFact is `${consumer:<fact>}` or `${consumer:<fact>:<alphabet>}`.
|
||||
var consumerFact = regexp.MustCompile(`\$\{consumer:([a-z][a-z0-9-]*)(?::([a-z][a-z0-9-]*))?\}`)
|
||||
|
||||
// consumerFacts are what a served value may name about the consumer it is being derived for.
|
||||
// One entry, deliberately: the identity is the one thing about a consumer the mesh itself chose,
|
||||
// so it is the one thing the mesh can hand to a provider without either end guessing.
|
||||
var consumerFacts = []string{"as"}
|
||||
|
||||
// consumerAlphabets are the ways the mesh will write that identity. `dns` is the mesh's own
|
||||
// identifier with its separator written `-` instead of `_` — the whole of the difference between
|
||||
// the alphabet the mesh mints in and the one buckets, vhosts and hostnames accept.
|
||||
var consumerAlphabets = []string{"dns"}
|
||||
|
||||
// ServedTo fills a provider's served values for one consumer.
|
||||
//
|
||||
// `as` is the identity the mesh minted for that consumer — the same string it is told to present
|
||||
// as a login. Values with no placeholder are returned exactly as they were, and a block with no
|
||||
// placeholder at all is returned unchanged, so this costs nothing for the providers that derive
|
||||
// nothing.
|
||||
//
|
||||
// Only strings carry placeholders. A number, a boolean or a nested object is a value the provider
|
||||
// stated outright, and is left alone.
|
||||
func ServedTo(serves map[string]any, as string) (map[string]any, error) {
|
||||
if len(serves) == 0 {
|
||||
return serves, nil
|
||||
}
|
||||
var out map[string]any
|
||||
for _, key := range sortedAnyKeys(serves) {
|
||||
text, ok := serves[key].(string)
|
||||
if !ok || !strings.Contains(text, "${consumer:") {
|
||||
continue
|
||||
}
|
||||
filled, err := consumerInto(text, as)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the value served as %q: %w", key, err)
|
||||
}
|
||||
if out == nil {
|
||||
// Copied only once something actually changes: the caller's map is the manifest's,
|
||||
// and a provider that derives nothing must not have it rewritten underneath it.
|
||||
out = make(map[string]any, len(serves))
|
||||
for k, v := range serves {
|
||||
out[k] = v
|
||||
}
|
||||
}
|
||||
out[key] = filled
|
||||
}
|
||||
if out == nil {
|
||||
return serves, nil
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// consumerInto replaces every `${consumer:…}` in one value.
|
||||
//
|
||||
// **A fact or an alphabet the mesh does not have is refused, not left standing.** Written through,
|
||||
// the literal `${consumer:as}` would reach a configuration file and be read as a bucket name,
|
||||
// failing somewhere that names neither the module nor the mesh — the same reasoning `${bound:…}`
|
||||
// is refused by (boundInto).
|
||||
func consumerInto(value, as string) (string, error) {
|
||||
var failed error
|
||||
out := consumerFact.ReplaceAllStringFunc(value, func(match string) string {
|
||||
parts := consumerFact.FindStringSubmatch(match)
|
||||
fact, alphabet := parts[1], parts[2]
|
||||
if fact != "as" {
|
||||
if failed == nil {
|
||||
failed = fmt.Errorf(
|
||||
"says %s, and the mesh states %s about a consumer", match, orNothing(consumerFacts))
|
||||
}
|
||||
return match
|
||||
}
|
||||
switch alphabet {
|
||||
case "":
|
||||
return as
|
||||
case "dns":
|
||||
return asDNSLabel(as)
|
||||
default:
|
||||
if failed == nil {
|
||||
failed = fmt.Errorf(
|
||||
"says %s, and the mesh writes an identity as %s", match, orNothing(consumerAlphabets))
|
||||
}
|
||||
return match
|
||||
}
|
||||
})
|
||||
if failed != nil {
|
||||
return "", failed
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// asDNSLabel writes a minted identity as a DNS label.
|
||||
//
|
||||
// The mesh's identities are already lower-case letters, digits and `_` (ConsumerIdentity), and
|
||||
// already inside the bound of the provision serving them: a provider that serves one as a DNS label
|
||||
// bounds it at 63 or less (CheckServes, novox/hq ADR 0225), and one that serves it at all without
|
||||
// saying is held to twenty (IdentityBoundOf). So
|
||||
// this is the separator and nothing else — no lower-casing of what is already lower case, no
|
||||
// truncation to a limit the identity is already inside, no padding of a name that is already long
|
||||
// enough. Each of those would be the mesh guessing at a rule it has not been given.
|
||||
func asDNSLabel(as string) string {
|
||||
return strings.ReplaceAll(as, "_", "-")
|
||||
}
|
||||
|
||||
func sortedAnyKeys(values map[string]any) []string {
|
||||
out := make([]string, 0, len(values))
|
||||
for k := range values {
|
||||
out = append(out, k)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// derivedFor is what the provider on this machine derives for one consumer of one provision
|
||||
// (novox/hq ADR 0202).
|
||||
//
|
||||
// Settled first, then derived: an operator may set a prefix on what the provider serves and the
|
||||
// mesh still fills the consumer's half of it ([ADR 0174]). Only the keys that actually name the
|
||||
// consumer are returned — the rest of a `serves` block is the same for every consumer and is
|
||||
// already in the provider's own definition, so repeating it here would be a second copy to go
|
||||
// stale.
|
||||
//
|
||||
// The first module in the resolved order that says it serves the provision answers, which is the
|
||||
// choice servedOnThisMachine makes for the consumer's half. Nothing serving it on this machine is
|
||||
// not an error: a contribution can reach a machine whose provider is a record or an adapter, and
|
||||
// then there is nothing derived to tell.
|
||||
func resolutionDerivedFor(r mcat.Resolution, provision, as, consumer, local string, settings mcat.SettingsBy) (map[string]any, error) {
|
||||
for _, m := range r.Modules {
|
||||
serves, said := m.Serves[provision]
|
||||
if !said {
|
||||
continue
|
||||
}
|
||||
var names map[string]any
|
||||
for key, value := range serves {
|
||||
if text, ok := value.(string); ok && strings.Contains(text, "${consumer:") {
|
||||
if names == nil {
|
||||
names = map[string]any{}
|
||||
}
|
||||
names[key] = value
|
||||
}
|
||||
}
|
||||
if names == nil {
|
||||
return nil, nil
|
||||
}
|
||||
// **A consumer that keeps several holders of this provision is refused** — this is issue
|
||||
// 124's own failure one case to the side, and it would be just as quiet.
|
||||
//
|
||||
// Each holder gets its own login, `…_<local>` (ADR 0094), and a provider derives from the
|
||||
// login, so it would make one resource per holder. The consumer's side has no such
|
||||
// dimension: one binding file per provision, one `${bound:<provision>:<key>}`, both
|
||||
// derived from the un-suffixed identity. So the provider would create the holder's
|
||||
// resource and the consumer would be configured against a name nothing made — it would
|
||||
// authenticate successfully and be refused on every object, which reads like a credential
|
||||
// fault and is not one.
|
||||
//
|
||||
// Lifting this means giving the consumer's side a local dimension. That is a decision,
|
||||
// not an omission, and until it is taken the mesh says so rather than guessing.
|
||||
if local != "" {
|
||||
return nil, fmt.Errorf(
|
||||
"%s keeps several holders of %s (this one is %q), and %s derives %s for each "+
|
||||
"consumer from the login the mesh minted. Each holder has its own login, and a "+
|
||||
"consumer is told one value per requirement — so the two ends would name "+
|
||||
"different things and nothing would compare them (novox/hq ADR 0202)",
|
||||
consumer, local, provision, m.Module, orNothing(sortedAnyKeys(names)))
|
||||
}
|
||||
settled, err := Settle(names, WithDefaults(m, settings[m.Module]))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s serving %s: %w", m.Module, provision, err)
|
||||
}
|
||||
derived, err := ServedTo(settled, as)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s serving %s to %s: %w", m.Module, provision, as, err)
|
||||
}
|
||||
return derived, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// notTranscribed refuses a consumer's file that writes out the value its provider derives for it,
|
||||
// instead of asking for it (novox/hq ADR 0202, issue 124).
|
||||
//
|
||||
// **What would have caught the one wrong instance.** The object store's three consumers each wrote
|
||||
// their bucket into their own configuration by hand. One of them named a predecessor's bucket, and
|
||||
// nothing compared it to what the provider would actually create: the module would have
|
||||
// authenticated successfully and been refused on every object, which reads like a credential fault
|
||||
// and is not one. It looked authoritative for months.
|
||||
//
|
||||
// The test is exact and costs one string search: a definition whose file already contains the
|
||||
// value the mesh is about to derive for it has written down somebody else's rule. It cannot be a
|
||||
// coincidence — a derived value carries the identity the mesh minted for this very consumer on
|
||||
// this very machine, which nothing else would spell out — and it cannot be checked afterwards,
|
||||
// because after substitution every consumer's file contains it legitimately.
|
||||
//
|
||||
// Only values that actually name the consumer are judged. A provider that serves a constant under
|
||||
// the same key serves the same constant to everyone, and a consumer repeating it is redundant
|
||||
// rather than wrong.
|
||||
func notTranscribed(resource map[string]any, known map[string]map[string]string, module string) error {
|
||||
if fmt.Sprint(resource["type"]) != "file" {
|
||||
return nil
|
||||
}
|
||||
content, ok := resource["content"].(string)
|
||||
if !ok || content == "" {
|
||||
return nil
|
||||
}
|
||||
for _, provision := range sortedKnown(known) {
|
||||
values := known[provision]
|
||||
identity := values["as"]
|
||||
if identity == "" {
|
||||
continue
|
||||
}
|
||||
for _, key := range sortedStringKeys(values) {
|
||||
if key == "as" {
|
||||
// The login is not derived from itself, and a consumer that must present it in a
|
||||
// connection string legitimately has it from `${bound:…}` — which is what it will
|
||||
// be after substitution, so this would judge the substitution, not the module.
|
||||
continue
|
||||
}
|
||||
value := values[key]
|
||||
if value == "" || !namesTheConsumer(value, identity) {
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(content, value) {
|
||||
continue
|
||||
}
|
||||
return fmt.Errorf(
|
||||
"%s writes %q into %v, and that is exactly what %s derives for it — a definition "+
|
||||
"keeping its own copy of somebody else's naming rule is one that can disagree "+
|
||||
"with it, silently. Say ${bound:%s:%s} and be told",
|
||||
module, value, resource["id"], provision, provision, key)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// namesTheConsumer is whether a derived value was built from this consumer's identity — in the
|
||||
// alphabet it was minted in, or as a DNS label. A value that does not contain it was not derived
|
||||
// from it, whatever else it may be.
|
||||
func namesTheConsumer(value, identity string) bool {
|
||||
return strings.Contains(value, identity) || strings.Contains(value, asDNSLabel(identity))
|
||||
}
|
||||
|
||||
func sortedKnown(known map[string]map[string]string) []string {
|
||||
out := make([]string, 0, len(known))
|
||||
for k := range known {
|
||||
out = append(out, k)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func sortedStringKeys(values map[string]string) []string {
|
||||
out := make([]string, 0, len(values))
|
||||
for k := range values {
|
||||
out = append(out, k)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package resolution
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"slices"
|
||||
|
||||
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
|
||||
// ofConsumers is where a provider places its consumers' credentials. Loose inside the braces, so a misspelt name is
|
||||
// refused rather than passed over.
|
||||
var ofConsumers = regexp.MustCompile(`\$\{consumers:([^}]*)\}`)
|
||||
|
||||
// consumersPlaceholderProblems is what is wrong with one resource's ${consumers:…}: placed only in a file's content,
|
||||
// for a provision the module provides, in a file only the provider reads and the client writes back.
|
||||
func consumersPlaceholderProblems(m mcat.Manifest, r map[string]any) []string {
|
||||
var problems []string
|
||||
for _, field := range sortedKeys(r) {
|
||||
v, ok := r[field].(string)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for _, f := range ofConsumers.FindAllStringSubmatch(v, -1) {
|
||||
switch {
|
||||
case field != "content" || fmt.Sprint(r["type"]) != "file":
|
||||
problems = append(problems, fmt.Sprintf("%s's resource %v names %s in its %s; a provider's consumers "+
|
||||
"are placed only in a file's content (novox/hq ADR 0373)", m.Module, r["id"], f[0], field))
|
||||
continue
|
||||
case !slices.ContainsFunc(m.Provides, func(o mcat.Offer) bool { return o.Name == f[1] }):
|
||||
problems = append(problems, fmt.Sprintf("%s's resource %v names %s, and %s does not provide %q: only a "+
|
||||
"provider's template holds its consumers' credentials (novox/hq ADR 0373)", m.Module, r["id"], f[0],
|
||||
m.Module, f[1]))
|
||||
}
|
||||
if mode := fmt.Sprint(r["mode"]); mode != "0600" && mode != "0400" {
|
||||
problems = append(problems, fmt.Sprintf("%s's resource %v holds its consumers' credentials (%s) with "+
|
||||
"mode %q: the provider alone reads it, 0600 or 0400 (novox/hq ADR 0373)", m.Module, r["id"], f[0],
|
||||
r["mode"]))
|
||||
}
|
||||
if owner := ownershipOf(r); owner != mcat.RuntimeOwned {
|
||||
problems = append(problems, fmt.Sprintf("%s's resource %v holds its consumers' credentials (%s) and is "+
|
||||
"%s at runtime: it is owned, written back whenever it differs (novox/hq ADR 0373)", m.Module,
|
||||
r["id"], f[0], owner))
|
||||
}
|
||||
}
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
// consumersHandedOnly says what a file naming ${consumers:…} is where it would be written the old way: the provisioner's
|
||||
// client alone fills it, so on a machine that does not hand the module it is **left out** of the declaration, never
|
||||
// written as that text or as an empty list the provider would read as no consumers, and never the reason the module is
|
||||
// refused: a provider not handed there keeps working as it did, from its grant files (novox/hq ADR 0373, issue 780).
|
||||
// A moved module judged from its repository's tree (WithTemplatesFrom: `module check`), on a machine
|
||||
// that would hand it, has no templates archive to hand and is judged as it composes; there the placeholder is judged
|
||||
// and taken out of the text, since what is composed so is never sent (judged answers whether the file is so judged).
|
||||
func consumersHandedOnly(m mcat.Manifest, r map[string]any, with mcat.Rendering) (judged, leftOut bool) {
|
||||
content, _ := r["content"].(string)
|
||||
switch {
|
||||
case ofConsumers.FindString(content) == "":
|
||||
return false, false
|
||||
case m.Moved && m.Handed == nil && with.Hands && with.Provisioner != "":
|
||||
return true, false
|
||||
}
|
||||
return false, true
|
||||
}
|
||||
|
||||
// consumersLeftOutLine is the plan's one line for a file left out because the module is not handed.
|
||||
func consumersLeftOutLine(m mcat.Manifest, r map[string]any) string {
|
||||
return fmt.Sprintf("%s's %v names ${consumers:…}, which the provisioner's client alone fills: left out, handed only "+
|
||||
"(novox/hq ADR 0373)", m.Module, r["id"])
|
||||
}
|
||||
|
||||
// consumersNotHanded is consumersHandedOnly as a refusal, for a caller that has no way to leave the file out.
|
||||
func consumersNotHanded(m mcat.Manifest, r map[string]any, with mcat.Rendering) (judged bool, err error) {
|
||||
judged, left := consumersHandedOnly(m, r, with)
|
||||
if left {
|
||||
return false, fmt.Errorf("%s's resource %v names %s, which the provisioner's client alone fills, and %s is not "+
|
||||
"handed to it on this machine: it would be written as that text, or empty, and the provider would read no "+
|
||||
"consumers (novox/hq ADR 0373)", m.Module, r["id"], ofConsumers.FindString(fmt.Sprint(r["content"])), m.Module)
|
||||
}
|
||||
return judged, nil
|
||||
}
|
||||
|
||||
// consumersOfTemplate is what is wrong with a handed file's template naming ${consumers:…}, judged on the file's
|
||||
// resource as if its text were inline.
|
||||
func consumersOfTemplate(m mcat.Manifest, id, text string) []string {
|
||||
for _, r := range m.Resources {
|
||||
if fmt.Sprint(r["id"]) != id {
|
||||
continue
|
||||
}
|
||||
withText := map[string]any{}
|
||||
for k, v := range r {
|
||||
withText[k] = v
|
||||
}
|
||||
withText["content"] = text
|
||||
return consumersPlaceholderProblems(m, withText)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
package resolution
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
|
||||
func published(module, host string, port int) mcat.Manifest {
|
||||
return mcat.Manifest{Module: module, Version: "1",
|
||||
Contributes: map[string]map[string]any{
|
||||
"reverse-proxy": {"host": host, "port": port},
|
||||
}}
|
||||
}
|
||||
|
||||
func proxy() mcat.Manifest {
|
||||
return mcat.Manifest{Module: "traefik", Version: "1",
|
||||
Provides: mcat.Offers("reverse-proxy"),
|
||||
Receives: map[string]string{"reverse-proxy": "/etc/traefik/mesh.json"},
|
||||
Resources: []map[string]any{
|
||||
{"id": "up", "type": "service", "unit": "traefik", "state": "running",
|
||||
"restart-on": []any{mcat.ReceivedID("reverse-proxy")}},
|
||||
}}
|
||||
}
|
||||
|
||||
// received digs the delivered contributions back out of a declaration.
|
||||
func received(t *testing.T, out []map[string]any) []mcat.Contribution {
|
||||
t.Helper()
|
||||
for _, r := range out {
|
||||
if r["path"] != "/etc/traefik/mesh.json" {
|
||||
continue
|
||||
}
|
||||
body := r["content"].(string)
|
||||
var parsed struct {
|
||||
Requirement string `json:"requirement"`
|
||||
Given []mcat.Contribution `json:"given"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(body), &parsed); err != nil {
|
||||
t.Fatalf("the file the proxy is given is not readable: %v\n%s", err, body)
|
||||
}
|
||||
if parsed.Requirement != "reverse-proxy" {
|
||||
t.Fatalf("the file does not say what it is about: %q", parsed.Requirement)
|
||||
}
|
||||
return parsed.Given
|
||||
}
|
||||
t.Fatalf("the provider was given no file at all: %v", out)
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestTheFileTheProviderGetsIsMachineReadable(t *testing.T) {
|
||||
// It is written for a program, and the first version put a `//` header above the JSON — a
|
||||
// file that says "do not edit" to a person and fails to parse for the thing meant to read it.
|
||||
got, _ := Resolve(shelf(proxy(), published("board", "board", 8080)), []string{"board"}, workstation(), mcat.World{})
|
||||
|
||||
for _, r := range mustDeclare(t, got) {
|
||||
if r["path"] != "/etc/traefik/mesh.json" {
|
||||
continue
|
||||
}
|
||||
var any map[string]any
|
||||
if err := json.Unmarshal([]byte(r["content"].(string)), &any); err != nil {
|
||||
t.Fatalf("the file is not parseable: %v\n%s", err, r["content"])
|
||||
}
|
||||
if note, _ := any["generated"].(string); !strings.Contains(note, "do not edit") {
|
||||
// Inside the document rather than above it, so it reaches a person without
|
||||
// breaking the parse.
|
||||
t.Fatalf("the file does not say it is generated: %v", any["generated"])
|
||||
}
|
||||
return
|
||||
}
|
||||
t.Fatal("no received file")
|
||||
}
|
||||
|
||||
func TestAModuleTellsItsProviderWhatItNeeds(t *testing.T) {
|
||||
got, err := Resolve(shelf(proxy(), published("board", "board", 8080)), []string{"board"}, workstation(), mcat.World{})
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err := Declaration(got, mcat.Rendering{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
given := received(t, out)
|
||||
if len(given) != 1 || given[0].From != "board" {
|
||||
t.Fatalf("the proxy was not told about board: %v", given)
|
||||
}
|
||||
if given[0].Values["host"] != "board" || given[0].Values["port"] != float64(8080) {
|
||||
t.Fatalf("the contribution did not survive: %v", given[0].Values)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContributingToSomethingIsRequiringIt(t *testing.T) {
|
||||
// Asking to be published means a publisher must exist. A module that had to say both would
|
||||
// eventually say one, and the failure would be a machine where nothing serves the route.
|
||||
got, err := Resolve(shelf(proxy(), published("board", "board", 8080)), []string{"board"}, workstation(), mcat.World{})
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(strings.Join(names(got), " "), "traefik") {
|
||||
t.Fatalf("contributing to reverse-proxy did not bring one in: %v", names(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestNothingToContributeToIsRefused(t *testing.T) {
|
||||
_, err := Resolve(shelf(published("board", "board", 8080)), []string{"board"}, workstation(), mcat.World{})
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("a module was published through a proxy that does not exist")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "reverse-proxy") {
|
||||
t.Fatalf("the refusal does not name what is missing: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEveryPublisherOnTheMachineIsInOneFile(t *testing.T) {
|
||||
got, err := Resolve(shelf(proxy(),
|
||||
published("board", "board", 8080),
|
||||
published("archive", "archive", 9000),
|
||||
), []string{"board", "archive"}, workstation(), mcat.World{})
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
given := received(t, mustDeclare(t, got))
|
||||
if len(given) != 2 {
|
||||
t.Fatalf("the proxy was told about %d of 2: %v", len(given), given)
|
||||
}
|
||||
// Ordered by module, because this becomes a file and a file whose lines move about looks
|
||||
// changed when nothing changed — which would restart the proxy for ever.
|
||||
if given[0].From != "archive" || given[1].From != "board" {
|
||||
t.Fatalf("the order is not stable: %v", given)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAProviderWithNoConsumersStillGetsTheFile(t *testing.T) {
|
||||
// Empty rather than absent. A provider that finds no file cannot tell "nothing asked for me"
|
||||
// from "the mesh never wrote it", and those want completely different responses — the same
|
||||
// rule the host follows about a service that does not exist.
|
||||
got, err := Resolve(shelf(proxy()), []string{"traefik"}, workstation(), mcat.World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if given := received(t, mustDeclare(t, got)); len(given) != 0 {
|
||||
t.Fatalf("got %v", given)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheProviderCanReloadWhenTheRoutesChange(t *testing.T) {
|
||||
// A proxy that got a new route and did not reload is a route that silently does not work.
|
||||
// The same fault the private network had when a peer list changed under a running interface,
|
||||
// which is why the received file has a name a module can point at.
|
||||
got, _ := Resolve(shelf(proxy(), published("board", "board", 8080)), []string{"board"}, workstation(), mcat.World{})
|
||||
|
||||
out := mustDeclare(t, got)
|
||||
for _, r := range out {
|
||||
if r["type"] != "service" {
|
||||
continue
|
||||
}
|
||||
reflects, ok := r["restart-on"].([]any)
|
||||
if !ok || len(reflects) != 1 {
|
||||
t.Fatalf("the proxy does not reflect anything: %v", r)
|
||||
}
|
||||
if reflects[0] != "traefik."+mcat.ReceivedID("reverse-proxy") {
|
||||
t.Fatalf("it reflects %v, which is not the file it was given", reflects[0])
|
||||
}
|
||||
return
|
||||
}
|
||||
t.Fatal("no service in the declaration")
|
||||
}
|
||||
|
||||
func TestARouteCanBeSetPerMesh(t *testing.T) {
|
||||
// The hostname is exactly the kind of thing that differs between one mesh and the next. A
|
||||
// module whose route could not be set would have to be edited to be reused anywhere.
|
||||
got, _ := Resolve(shelf(proxy(), published("board", "board", 8080)), []string{"board"}, workstation(), mcat.World{})
|
||||
|
||||
out, err := Declaration(got, mcat.Rendering{Settings: mcat.SettingsBy{
|
||||
"board": {{From: "the mesh", Values: map[string]any{"host": "dashboard"}}},
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
given := received(t, out)
|
||||
if given[0].Values["host"] != "dashboard" {
|
||||
t.Fatalf("the setting did not reach the route: %v", given[0].Values)
|
||||
}
|
||||
if given[0].Values["port"] != float64(8080) {
|
||||
t.Fatalf("setting the host dropped the port: %v", given[0].Values)
|
||||
}
|
||||
}
|
||||
|
||||
func TestASettingReachesAContributionOnlyWhereItDeclaresTheKey(t *testing.T) {
|
||||
// novox/hq 04-ISSUES/173: the mail module's site name, set so its environment file could read
|
||||
// it, arrived in every route it contributed. A setting overrides a key the contribution
|
||||
// declares and adds none — the provider reads the contribution as a contract.
|
||||
got, _ := Resolve(shelf(proxy(), published("board", "board", 8080)), []string{"board"}, workstation(), mcat.World{})
|
||||
|
||||
out, err := Declaration(got, mcat.Rendering{Settings: mcat.SettingsBy{
|
||||
"board": {{From: "the mesh", Values: map[string]any{"host": "dashboard", "sitename": "Board"}}},
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
given := received(t, out)
|
||||
if given[0].Values["host"] != "dashboard" {
|
||||
t.Fatalf("the setting did not override the route's host: %v", given[0].Values)
|
||||
}
|
||||
if _, leaked := given[0].Values["sitename"]; leaked {
|
||||
t.Fatalf("a setting the route never declared reached the proxy: %v", given[0].Values)
|
||||
}
|
||||
}
|
||||
|
||||
func provider() mcat.Manifest {
|
||||
return mcat.Manifest{Module: "postgres", Version: "1",
|
||||
Provides: mcat.FromAnywhere("postgres-database"),
|
||||
Receives: map[string]string{"postgres-database": "/var/lib/postgres/grants/mesh.json"},
|
||||
Grants: map[string]string{"postgres-database": "/var/lib/postgres/grants"},
|
||||
}
|
||||
}
|
||||
|
||||
func grantedTo(t *testing.T, out []map[string]any) []mcat.Contribution {
|
||||
t.Helper()
|
||||
for _, r := range out {
|
||||
if r["path"] != "/var/lib/postgres/grants/mesh.json" {
|
||||
continue
|
||||
}
|
||||
var parsed struct {
|
||||
Given []mcat.Contribution `json:"given"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(r["content"].(string)), &parsed); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return parsed.Given
|
||||
}
|
||||
t.Fatalf("the provider was given no manifest: %v", out)
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestAConsumersOwnContributionsAreStillThere(t *testing.T) {
|
||||
// Cross-node grants are merged in with this machine's own, because from the provider's side
|
||||
// they are the same thing — somebody wanting something — and a provider that had to read two
|
||||
// lists would read one of them.
|
||||
got, err := Resolve(shelf(proxy(), published("board", "board", 8080)),
|
||||
[]string{"board"}, reachable(), mcat.World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
given := received(t, mustDeclare(t, got))
|
||||
if len(given) != 1 || given[0].Node != "" {
|
||||
t.Fatalf("a local contribution grew a node: %v", given)
|
||||
}
|
||||
}
|
||||
|
||||
func TestARequireOnlyConsumerOfAParameterlessProvisionStillAsks(t *testing.T) {
|
||||
// A consumer that requires a parameterless provision (one whose serves names no consumer key —
|
||||
// redis-cache, amqp) contributes no payload, but it still ASKS for the provision and must be
|
||||
// granted a credential. Keying "asks" on contributions alone gave its grant From="" — read as
|
||||
// withdrawn — so the provider never created the account and the consumer authenticated nowhere.
|
||||
r := mcat.Resolution{
|
||||
Node: "laptop",
|
||||
Modules: []mcat.Manifest{{
|
||||
Module: "amqp-ping",
|
||||
Requires: []string{"amqp"},
|
||||
}},
|
||||
}
|
||||
values, asks, err := ContributionsFrom(r, "amqp", "amqp-ping", mcat.SettingsBy{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !asks {
|
||||
t.Fatal("a require-only consumer was treated as not asking; its grant would be withdrawn and it would never be provisioned")
|
||||
}
|
||||
if values == nil {
|
||||
t.Fatalf("asks is true but values is nil; expected an empty contribution, got %v", values)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAConsumerThatStoppedAskingIsWithdrawn(t *testing.T) {
|
||||
// Withdrawal is how a credential is taken away. The provisioner removes what nobody asks for,
|
||||
// and it can only do that if the mesh stops asking — a consumer that was unassigned would
|
||||
// otherwise keep a working login for ever, and nothing would say so.
|
||||
//
|
||||
// A grant with no asking module is exactly that state: the mesh still holds the secret,
|
||||
// because it is sealed and unusable to the mesh anyway, and the machine no longer wants it.
|
||||
got, err := Resolve(shelf(provider()), []string{"postgres"}, reachable(), mcat.World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err := Declaration(got, mcat.Rendering{Grants: []mcat.Grant{
|
||||
{Provision: "postgres-database", Consumer: "still-here", From: "meshboard",
|
||||
Values: map[string]any{"name": "meshboard"}, Sealed: "c2VhbGVk"},
|
||||
{Provision: "postgres-database", Consumer: "gone-away", Sealed: "c3RhbGU="},
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
given := grantedTo(t, out)
|
||||
if len(given) != 1 || given[0].Node != "still-here" {
|
||||
t.Fatalf("the provider is still told about a machine that stopped asking: %v", given)
|
||||
}
|
||||
// And no credential is written for it either, or the provisioner would find a file for a
|
||||
// consumer its manifest does not mention and have to guess what that means.
|
||||
for _, r := range out {
|
||||
if path, _ := r["path"].(string); strings.Contains(path, "gone-away") {
|
||||
t.Fatalf("a withdrawn consumer's credential is still delivered: %v", r)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package resolution
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
|
||||
func TestAContributionDependsOnTheSeatThatPlacesIt(t *testing.T) {
|
||||
env := mod("theme", nil, nil, nil)
|
||||
env.Environment = &mcat.Environment{Variables: map[string]string{"GTK_THEME": "Adwaita:dark"}}
|
||||
path := mod("toolchain", nil, nil, nil)
|
||||
path.Environment = &mcat.Environment{Path: []mcat.PathEntry{{Entry: "/opt/x/bin"}}}
|
||||
shell := mod("prompt", nil, nil, nil)
|
||||
shell.Shell = []mcat.ShellCode{{For: "zsh", Slot: "first", Code: "true"}}
|
||||
session := mod("wallpaper", nil, nil, nil)
|
||||
session.Shell = []mcat.ShellCode{{For: "xinitrc", Slot: "normal", Code: "true"}}
|
||||
resources := mod("bar", nil, nil, nil)
|
||||
resources.Shell = []mcat.ShellCode{{For: "xresources", Slot: "normal", Code: "x: y"}}
|
||||
empty := mod("nothing", nil, nil, nil)
|
||||
empty.Environment = &mcat.Environment{}
|
||||
|
||||
cases := map[string]struct {
|
||||
m mcat.Manifest
|
||||
want []string
|
||||
}{
|
||||
"a variable": {env, []string{mcat.EnvironmentSeat}},
|
||||
"a path entry": {path, []string{mcat.EnvironmentSeat}},
|
||||
"shell code": {shell, []string{mcat.LoginShellSeat}},
|
||||
"the session's start": {session, []string{mcat.DisplayServerSeat}},
|
||||
"the session's X resources": {resources, []string{mcat.DisplayServerSeat}},
|
||||
"an empty environment": {empty, nil},
|
||||
}
|
||||
for name, c := range cases {
|
||||
got := DependsOn(nil, c.m)
|
||||
if len(got) == 0 {
|
||||
got = nil
|
||||
}
|
||||
if !reflect.DeepEqual(got, c.want) {
|
||||
t.Errorf("%s depends on %v, want %v", name, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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.
|
||||
zsh := mod("zsh", nil, nil, nil, mcat.Claim{Name: mcat.LoginShellSeat})
|
||||
zsh.Shell = []mcat.ShellCode{{For: "zsh", Slot: "normal", Code: "true"}}
|
||||
zsh.Environment = &mcat.Environment{Variables: map[string]string{"EDITOR": "vim"}}
|
||||
catalogue := map[string]mcat.Manifest{"zsh": zsh,
|
||||
"node-env": mod("node-env", nil, nil, nil, mcat.Claim{Name: mcat.EnvironmentSeat})}
|
||||
unheld := UnheldDependencies(catalogue, "laptop", []mcat.Manifest{zsh}, nil)
|
||||
if len(unheld) != 1 || unheld[0].Seat != mcat.EnvironmentSeat {
|
||||
t.Errorf("zsh alone: unheld %v, want only %s (its shell code is its own seat's)", unheld, mcat.EnvironmentSeat)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package resolution
|
||||
|
||||
import (
|
||||
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
|
||||
// controlRecordsDecision is the record that made the two seats.
|
||||
const controlRecordsDecision = "novox/hq ADR 0565"
|
||||
|
||||
// wantsVerbs are the assigner's verbs on mesh-wants (ADR 0565 clauses 7, 8, 9, 15).
|
||||
func wantsVerbs() []mcat.Verb {
|
||||
return []mcat.Verb{
|
||||
{Name: "upgrade", Description: "Whether each module's new builds wait for a person: its policy (roll, together or record) " +
|
||||
"and where it comes from (a person, the module, the bus, its irreplaceable data, the default); the bus is never " +
|
||||
"rolled. With module, that one; with module, policy and why, a person's choice for it, recorded as a hand act; " +
|
||||
"with policy default, the choice taken back. With compare, each node's own composition and the assigner's findings.",
|
||||
Input: schema(map[string]string{
|
||||
"module": "one module",
|
||||
"policy": "with module: roll, together, record, or default to take a person's choice back",
|
||||
"why": "with policy: why, required and kept",
|
||||
"compare": "\"true\": each node's own composition, by its digest, and the assigner's findings",
|
||||
}, nil, "compare")},
|
||||
{Name: "bus", Description: "What a bus upgrade would do: the fingerprint of the bus module each node runs against the " +
|
||||
"indexer's newest, and the last bus steps from the hand-act log and the assigner's puts of the bus. Read-only: " +
|
||||
"the step itself is the put on the operator's warrant.",
|
||||
Input: schema(map[string]string{}, nil)},
|
||||
}
|
||||
}
|
||||
|
||||
// handActsVerbs are mesh-hand-acts' verbs (ADR 0565 clause 10), as the controller's took them.
|
||||
func handActsVerbs() []mcat.Verb {
|
||||
return []mcat.Verb{
|
||||
{Name: "hand-act", Description: "Record one act done by hand: what, why, its cause in a word or a condition's kind " +
|
||||
"(the word a second act for the same reason uses) and the condition it addresses. Who did it is the caller the " +
|
||||
"runtime proves, never said by the caller. Said as act-recorded.",
|
||||
Input: schema(map[string]string{
|
||||
"what": "what was done, in a line",
|
||||
"why": "why it had to be done by hand",
|
||||
"cause": "the cause in a word, or a condition's kind",
|
||||
"condition": "the key of the condition it addressed, if any (optional)",
|
||||
"verb": "the act's verb in the log: a repair when absent, `hand-act drill` for a drill, or a recorder's own",
|
||||
}, []string{"what", "why"})},
|
||||
{Name: "hand-acts", Description: "What was done by hand lately, over this log and the controller's: who, why and the " +
|
||||
"cause of each, and the causes recorded twice within a fortnight (a healer is wanted).",
|
||||
Input: schema(map[string]string{"days": "how many days back (default 14)", "cause": "only this cause",
|
||||
"who": "only acts whose who contains this", "warrants": "\"true\": every warrant recorded"}, nil, "warrants")},
|
||||
{Name: "warranted", Description: "Record what a module did on the operator's warrant: who chose, through which channel, " +
|
||||
"with which proofs and which answer read from the router's own record of that module's ask, never from the " +
|
||||
"caller; once per ask. Refused for an ask still open, ended without a choice, or not that module's.",
|
||||
Input: schema(map[string]string{"asker": "the module that asked", "ask": "its ask's id"}, []string{"asker", "ask"})},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package resolution
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"slices"
|
||||
|
||||
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
|
||||
// CarriesVaultSealed says whether module's own secrets are the core's credentials.
|
||||
func CarriesVaultSealed(module string) bool {
|
||||
return slices.Contains(mcat.CoreCredentialModules, module)
|
||||
}
|
||||
|
||||
// vaultSealedOf is the vault's box for one of module's own secrets, if the rendering was given one.
|
||||
func renderingVaultSealedOf(with mcat.Rendering, module, name string) (json.RawMessage, bool) {
|
||||
box, ok := with.VaultSealed[module][name]
|
||||
return box, ok && len(box) > 0
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package resolution
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
|
||||
func TestTheCoresOwnCredentialsAreTheBusTheProvisionerAndItsClient(t *testing.T) {
|
||||
if want := []string{"mesh-provisioner", "mesh-provisioner-client", "nats"}; !reflect.DeepEqual(mcat.CoreCredentialModules, want) {
|
||||
t.Fatalf("the core's modules are %v, want %v (mesh-vault's CoreModules is the same list)", mcat.CoreCredentialModules, want)
|
||||
}
|
||||
for _, m := range []string{"nats", "mesh-provisioner", "mesh-provisioner-client"} {
|
||||
if !CarriesVaultSealed(m) {
|
||||
t.Errorf("%s is not on the list", m)
|
||||
}
|
||||
}
|
||||
for _, m := range []string{"postgres", "mesh-vault", "mesh-controller", ""} {
|
||||
if CarriesVaultSealed(m) {
|
||||
t.Errorf("%s is on the list", m)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package resolution
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
|
||||
// derivedContributions is what a module's data section gives the backup holder: its backup lines and
|
||||
// its items. Every item is listed, so a valuable one on a machine is measured whether or not it is
|
||||
// copied; a cache is listed and not measured.
|
||||
func manifestDerivedContributions(m mcat.Manifest) []mcat.SeatContribution {
|
||||
items := m.DataItems()
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
var lines []string
|
||||
kept := map[string]bool{}
|
||||
keep := func(path string) {
|
||||
if !kept[path] {
|
||||
kept[path] = true
|
||||
lines = append(lines, "path "+path)
|
||||
}
|
||||
}
|
||||
for _, it := range items {
|
||||
if !it.BackedUp() {
|
||||
continue
|
||||
}
|
||||
if it.Backup.IsDump() {
|
||||
lines = append(lines, "run "+strings.TrimSpace(it.Backup.Dump))
|
||||
if into, ok := m.DataItem(it.Backup.Into); ok {
|
||||
keep(into.Path)
|
||||
}
|
||||
continue
|
||||
}
|
||||
keep(it.Path)
|
||||
}
|
||||
var described []string
|
||||
for _, it := range items {
|
||||
covered := "-"
|
||||
switch {
|
||||
case it.Backup.IsDump():
|
||||
if into, ok := m.DataItem(it.Backup.Into); ok {
|
||||
covered = into.Path
|
||||
}
|
||||
case it.BackedUp():
|
||||
covered = it.Path
|
||||
}
|
||||
described = append(described, fmt.Sprintf("item %s %s %s %s %s %s", it.ID, it.Class, it.Path, covered,
|
||||
it.Protection(), it.MeasuredBy()))
|
||||
}
|
||||
var out []mcat.SeatContribution
|
||||
if len(lines) > 0 {
|
||||
out = append(out, mcat.SeatContribution{Seat: mcat.BackupSeat, Kind: mcat.BackupKindBackup, Content: strings.Join(lines, "\n") + "\n"})
|
||||
}
|
||||
return append(out, mcat.SeatContribution{Seat: mcat.BackupSeat, Kind: mcat.BackupKindData, Content: strings.Join(described, "\n") + "\n"})
|
||||
}
|
||||
|
||||
// allContributions is every contribution a module makes to a seat's holder: what it wrote, and what
|
||||
// its data section derives. **One list**: a module that declares its data has its backup lines
|
||||
// composed from it, and a backup line it still writes by hand is not placed — the catalogue check
|
||||
// refuses it — so the holder never copies two lists that disagree.
|
||||
func manifestAllContributions(m mcat.Manifest) []mcat.SeatContribution {
|
||||
if m.Data == nil {
|
||||
return m.Contributions
|
||||
}
|
||||
derived := manifestDerivedContributions(m)
|
||||
out := make([]mcat.SeatContribution, 0, len(m.Contributions)+len(derived))
|
||||
for _, c := range m.Contributions {
|
||||
if s, known := mcat.SeatNamed(c.Seat); known && s.Name == mcat.BackupSeat {
|
||||
continue
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
return append(out, derived...)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package resolution
|
||||
|
||||
import (
|
||||
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
|
||||
// DataItems is every data item the resolution's modules declare, placed on its node with the rendering given: the text
|
||||
// the node-backup holder is handed, read. A module left out of the node's declaration keeps its items, as its data is
|
||||
// still on the node (ADR 0262); an item that cannot be placed is said in unplaced and left out.
|
||||
func DataItems(r mcat.Resolution, with mcat.Rendering) (items map[string][]mcat.PlacedDataItem, unplaced []string, err error) {
|
||||
left := LeftOut(r, with.Settings, with.Adopted)
|
||||
for module, why := range with.NotPlaced {
|
||||
if _, already := left[module]; !already {
|
||||
left[module] = why
|
||||
}
|
||||
}
|
||||
modules := make([]mcat.Manifest, 0, len(r.Modules))
|
||||
for _, m := range r.Modules {
|
||||
if _, isLeft := left[m.Module]; isLeft {
|
||||
m = backupView(m)
|
||||
}
|
||||
modules = append(modules, m)
|
||||
}
|
||||
text, unplaced, err := seatContributions(modules, mcat.Manifest{}, mcat.BackupSeat+":"+mcat.BackupKindData, with,
|
||||
machineFacts(r, with.Names, with.MeshRange), r.Capabilities)
|
||||
if err != nil {
|
||||
return nil, unplaced, err
|
||||
}
|
||||
items, err = mcat.ParseDataItems(text)
|
||||
return items, unplaced, err
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package resolution
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
|
||||
func mustParse(t *testing.T, raw string) mcat.Manifest {
|
||||
t.Helper()
|
||||
m, err := decodeForTest([]byte(raw))
|
||||
if err != nil {
|
||||
t.Fatalf("refused: %v", err)
|
||||
}
|
||||
return m
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,84 @@
|
||||
package resolution
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
|
||||
// The mesh's user list reaches the module holding the bus, and nothing else.
|
||||
//
|
||||
// Three refusals and one delivery, because each of the refusals would be silent in a different way:
|
||||
// a module that asked and was given it could read every credential on the bus; a bus given an empty
|
||||
// file refuses every connection in the mesh and looks like a machine problem; and a bus that never
|
||||
// asked gets nothing rather than a file it does not read.
|
||||
func TestTheMeshsUserListGoesOnlyToTheModuleHoldingTheBus(t *testing.T) {
|
||||
theBus := func() mcat.Manifest {
|
||||
return mcat.Manifest{
|
||||
Module: "nats", Version: "1",
|
||||
Claims: []mcat.Claim{{Name: "mesh-broker", Scope: mcat.ScopeMesh}},
|
||||
BusUsers: "/var/lib/nats-module/conf/accounts.conf",
|
||||
Resources: []map[string]any{},
|
||||
}
|
||||
}
|
||||
|
||||
on := func(t *testing.T, m mcat.Manifest, with mcat.Rendering) ([]map[string]any, error) {
|
||||
t.Helper()
|
||||
return Declaration(mcat.Resolution{Node: "anchor", Modules: []mcat.Manifest{m}}, with)
|
||||
}
|
||||
|
||||
t.Run("the holder is given it", func(t *testing.T) {
|
||||
resources, err := on(t, theBus(), mcat.Rendering{BusUsers: "accounts { MESH { users = [] } }"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Prefixed with the module it came from, like every resource: two modules may reasonably
|
||||
// both call something "config", and without the prefix the second would silently replace
|
||||
// the first.
|
||||
var found map[string]any
|
||||
for _, r := range resources {
|
||||
if r["id"] == "nats."+mcat.BusUsersID() {
|
||||
found = r
|
||||
}
|
||||
}
|
||||
if found == nil {
|
||||
t.Fatalf("the bus was given no user list: %+v", resources)
|
||||
}
|
||||
if found["path"] != "/var/lib/nats-module/conf/accounts.conf" {
|
||||
t.Errorf("written to %v rather than where the module asked", found["path"])
|
||||
}
|
||||
if found["mode"] != "0600" {
|
||||
t.Errorf("mode %v: a list of every user in the mesh belongs to the one process that "+
|
||||
"needs it", found["mode"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a module that does not claim the seat is refused", func(t *testing.T) {
|
||||
m := theBus()
|
||||
m.Claims = nil
|
||||
if _, err := on(t, m, mcat.Rendering{BusUsers: "accounts {}"}); err == nil {
|
||||
t.Fatal("a module that claims nothing was handed every user's password hash")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("the holder with nothing composed is refused", func(t *testing.T) {
|
||||
if _, err := on(t, theBus(), mcat.Rendering{}); err == nil {
|
||||
t.Fatal("the bus was given an empty user list, so it would refuse every connection in " +
|
||||
"the mesh and look like a machine problem")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a module that did not ask gets nothing", func(t *testing.T) {
|
||||
m := theBus()
|
||||
m.BusUsers = ""
|
||||
resources, err := on(t, m, mcat.Rendering{BusUsers: "accounts {}"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, r := range resources {
|
||||
if r["id"] == "nats."+mcat.BusUsersID() {
|
||||
t.Fatal("a module that asked for no user list was given one")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package resolution
|
||||
|
||||
import (
|
||||
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
|
||||
// decodeForTest builds a manifest for a test as every reader reads one (Decode); the judge's own tests are in
|
||||
// mesh-delivery's judge (novox/hq ADR 0578 §4).
|
||||
func decodeForTest(raw []byte) (mcat.Manifest, error) { return mcat.Decode(raw) }
|
||||
@@ -0,0 +1,74 @@
|
||||
package resolution
|
||||
|
||||
import (
|
||||
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
|
||||
// deliveryRemovedInR6 is the row of a verb release 6 removed from the delivery seat.
|
||||
func deliveryRemovedInR6(name string) mcat.Verb {
|
||||
return mcat.Verb{Name: name, Description: "Removed in R6 (ADR 0539): refused by name, naming its replacement: " + mcat.RemovedDeliveryVerbs[name], Input: schema(nil, nil), Optional: true}
|
||||
}
|
||||
|
||||
// deliveryVerbs are the delivery seat's tools: what reads the merges and checks, and the acts of a person and healer H2
|
||||
// on a merge (ADR 0539 clause 17).
|
||||
func deliveryVerbs() []mcat.Verb {
|
||||
return []mcat.Verb{
|
||||
{Name: "deliveries", Description: "Every delivery not final, and those that ended in the last day, one line " +
|
||||
"each: its id (owner/repository@commit), its state, its group, what it waits for and since when. Narrowed " +
|
||||
"by state, repository or group.",
|
||||
Input: schema(map[string]string{"state": "one state, e.g. delivering or held",
|
||||
"repository": "owner/repository", "group": "a group's id (its branch name)",
|
||||
"all": "\"true\": the final ones of the last thirty days too"}, nil, "all")},
|
||||
deliveryRemovedInR6("times"),
|
||||
{Name: "show", Description: "One delivery or group whole: its delivery plan (what it builds, what each " +
|
||||
"machine receives, what is not an ordinary send), every transition with when and why, the machine " +
|
||||
"steps of its walk, its group and its order.",
|
||||
Input: schema(map[string]string{"id": "a delivery's id, or a group's"}, []string{"id"})},
|
||||
{Name: "groups", Description: "Every delivery group: its members in order, why each pair is ordered " +
|
||||
"(declared, built by, version skew, engine before controller, by name), its composed check and its " +
|
||||
"state, derived from its members.",
|
||||
Input: schema(map[string]string{"all": "\"true\": the groups that ended too"}, nil, "all")},
|
||||
deliveryRemovedInR6("what-if"),
|
||||
{Name: "checks", Description: "What the mesh's checks said of a pull request's head or of one commit: each " +
|
||||
"of the commit's mesh statuses (mesh/check, the one a main requires from R4e, hq's mesh/repo-check, " +
|
||||
"mesh/delivery, …) with its state, " +
|
||||
"description and when it was set; the merge check's full verdict as the controller said it — each " +
|
||||
"layer's summary, the machine that ran it, when, its build id and its report; and whether the branch's " +
|
||||
"protection would let it merge, every required status being success.",
|
||||
Input: schema(map[string]string{"repository": "owner/repository",
|
||||
"number": "a pull request's number: its head is read",
|
||||
"commit": "a commit's sha, or the start of one, instead of a pull request"}, []string{"repository"}),
|
||||
// Added after the seat's first holder shipped: optional until mesh-delivery serves it everywhere.
|
||||
Optional: true},
|
||||
{Name: "table", Description: "The state table every delivery runs by: each transition with its guard, " +
|
||||
"each state's bound and what healer H2 may do once it has passed; and the machine steps' table.",
|
||||
Input: schema(map[string]string{}, nil)},
|
||||
{Name: "stalled", Description: "Every delivery held past its state's bound, with the transition the table " +
|
||||
"lets healer H2 take for it, or none.",
|
||||
Input: schema(map[string]string{}, nil)},
|
||||
{Name: "recheck", Description: "Check a rejected or ready delivery again: it goes back to proposed and the " +
|
||||
"controller is asked for its check. With why.",
|
||||
Input: schema(map[string]string{"id": "the delivery's id", "why": "why, kept with the transition"},
|
||||
[]string{"id", "why"})},
|
||||
deliveryRemovedInR6("release"),
|
||||
deliveryRemovedInR6("stop"),
|
||||
{Name: "close", Description: "Healer H2's verb: take the one transition the table names for a delivery held " +
|
||||
"past its bound. Refused for any other.",
|
||||
Input: schema(map[string]string{"id": "the delivery's id", "why": "the condition it answers"},
|
||||
[]string{"id", "why"})},
|
||||
deliveryRemovedInR6("retire-history"),
|
||||
{Name: "settle", Description: "Ask the operator to settle deliveries held because their merge reached the " +
|
||||
"trunk without a passing check: one ask on the operator's channel for all of them, as an Approve is asked. " +
|
||||
"The verb moves nothing; on the operator's answer each goes to merged, and whether it is on the machines " +
|
||||
"is derived as for any merge (novox/hq ADR 0556). Refused for a delivery not held, or held for another " +
|
||||
"reason, naming why.",
|
||||
Input: map[string]any{"type": "object", "additionalProperties": false, "required": []string{"ids", "why"},
|
||||
"properties": map[string]any{
|
||||
"ids": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "minItems": 1,
|
||||
"description": "the held deliveries' ids (owner/repository@commit)"},
|
||||
"why": map[string]any{"type": "string", "description": "why they may be settled, shown to the operator"},
|
||||
}},
|
||||
// Added after the seat's release 6 holder shipped: optional until the gate serves it everywhere.
|
||||
Optional: true},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
package resolution
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
|
||||
// store is an object store in the shape minio has: it serves a region and a port to everyone, and
|
||||
// a bucket named for whoever is asking.
|
||||
func store() mcat.Manifest {
|
||||
return mcat.Manifest{
|
||||
Module: "store", Version: "1",
|
||||
Provides: mcat.FromAnywhere("s3-bucket"),
|
||||
Listens: []mcat.Listening{{Port: 9000, Protocol: "tcp", From: mcat.FromMesh}},
|
||||
Serves: map[string]map[string]any{"s3-bucket": {
|
||||
"region": "eu-west",
|
||||
"bucket": "${consumer:as:dns}",
|
||||
}},
|
||||
Receives: map[string]string{"s3-bucket": "/var/lib/store/grants/mesh.json"},
|
||||
Grants: map[string]string{"s3-bucket": "/var/lib/store/grants"},
|
||||
Resources: []map[string]any{{
|
||||
"id": "server", "type": "container", "name": "store", "ports": []any{"9000"},
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
// files is a consumer that writes the bucket into its own configuration — which is the thing it
|
||||
// could not do before, and had to transcribe.
|
||||
func files() mcat.Manifest {
|
||||
return mcat.Manifest{
|
||||
Module: "files", Version: "1", Slug: "files",
|
||||
Requires: []string{"s3-bucket"},
|
||||
Binds: map[string]string{"s3-bucket": "/var/lib/files/store.json"},
|
||||
Secrets: map[string]string{"s3-bucket": "/var/lib/files/store.secret"},
|
||||
Resources: []map[string]any{{
|
||||
"id": "env", "type": "file", "path": "/var/lib/files/env", "mode": "0600",
|
||||
"content": "BUCKET=${bound:s3-bucket:bucket}\nREGION=${bound:s3-bucket:region}\n",
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
// pics is a second consumer of the same provider on the same machine: two derivations, neither
|
||||
// the other's.
|
||||
func pics() mcat.Manifest {
|
||||
return mcat.Manifest{
|
||||
Module: "pics", Version: "1", Slug: "pics",
|
||||
Requires: []string{"s3-bucket"},
|
||||
Binds: map[string]string{"s3-bucket": "/var/lib/pics/store.json"},
|
||||
Secrets: map[string]string{"s3-bucket": "/var/lib/pics/store.secret"},
|
||||
Resources: []map[string]any{{
|
||||
"id": "env", "type": "file", "path": "/var/lib/pics/env", "mode": "0600",
|
||||
"content": "BUCKET=${bound:s3-bucket:bucket}\n",
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
// The three places the derived value lands must agree, because agreeing is the whole point: the
|
||||
// consumer's own file, the binding it reads as JSON, and the provider's contributions entry.
|
||||
func TestADerivedValueReachesBothEndsAndAgrees(t *testing.T) {
|
||||
r, err := Resolve(shelf(store(), files()), []string{"store", "files"}, reachable(), mcat.World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err := Declaration(r, mcat.Rendering{Grants: []mcat.Grant{{
|
||||
Provision: "s3-bucket", Consumer: "workstation", From: "files", Slug: "files",
|
||||
Values: map[string]any{}, Sealed: "c2VhbGVk",
|
||||
}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// The mesh minted this identity for the consumer; the bucket is that identity as a DNS label.
|
||||
// Derived here with the mesh's own function, so the test cannot agree with a wrong rule.
|
||||
as := mcat.ConsumerIdentity("workstation", mcat.IdentitySource("files", "files"))
|
||||
want := strings.ReplaceAll(as, "_", "-")
|
||||
if want == as || !strings.Contains(as, "_") {
|
||||
t.Fatalf("the mesh's identity %q has no separator to rewrite; this test proves nothing", as)
|
||||
}
|
||||
|
||||
env := fileNamed(out, "files.env")
|
||||
if env == nil {
|
||||
t.Fatalf("the consumer was given no file: %v", out)
|
||||
}
|
||||
if got := env["content"].(string); !strings.Contains(got, "BUCKET="+want+"\n") {
|
||||
t.Errorf("the consumer's own file was not told the bucket:\n%s\nwant BUCKET=%s", got, want)
|
||||
}
|
||||
|
||||
binding := fileNamed(out, "files.bound-s3-bucket")
|
||||
if binding == nil {
|
||||
t.Fatalf("the consumer was given no binding: %v", out)
|
||||
}
|
||||
var said struct {
|
||||
Serves map[string]any `json:"serves"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(binding["content"].(string)), &said); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if said.Serves["bucket"] != want {
|
||||
t.Errorf("the binding says the bucket is %q, want %q", said.Serves["bucket"], want)
|
||||
}
|
||||
// And what is the same for everybody is still the same for everybody.
|
||||
if said.Serves["region"] != "eu-west" {
|
||||
t.Errorf("the binding lost what the provider serves to all: %v", said.Serves)
|
||||
}
|
||||
|
||||
given := storeGrants(t, out)
|
||||
if len(given) != 1 {
|
||||
t.Fatalf("the provider was told about %d consumer(s): %v", len(given), given)
|
||||
}
|
||||
if given[0].Derived["bucket"] != want {
|
||||
t.Errorf("the provider was told the bucket is %v, and the consumer was told %q — "+
|
||||
"the two ends disagree, which is the whole failure", given[0].Derived["bucket"], want)
|
||||
}
|
||||
// Only the per-consumer half. The region is the same for everyone and is already in the
|
||||
// provider's own definition; repeating it here would be a copy to go stale.
|
||||
if _, carried := given[0].Derived["region"]; carried {
|
||||
t.Errorf("the provider was handed back what it already says for everyone: %v", given[0].Derived)
|
||||
}
|
||||
}
|
||||
|
||||
// Two consumers of one provider on one machine get two buckets, and neither gets the other's.
|
||||
func TestTwoConsumersOfOneProviderGetTheirOwnDerivation(t *testing.T) {
|
||||
r, err := Resolve(shelf(store(), files(), pics()),
|
||||
[]string{"store", "files", "pics"}, reachable(), mcat.World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err := Declaration(r, mcat.Rendering{Grants: []mcat.Grant{
|
||||
{Provision: "s3-bucket", Consumer: "workstation", From: "files", Slug: "files",
|
||||
Values: map[string]any{}, Sealed: "c2VhbGVk"},
|
||||
{Provision: "s3-bucket", Consumer: "workstation", From: "pics", Slug: "pics",
|
||||
Values: map[string]any{}, Sealed: "c2VhbGVk"},
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
forFiles := strings.ReplaceAll(mcat.ConsumerIdentity("workstation", mcat.IdentitySource("files", "files")), "_", "-")
|
||||
forPics := strings.ReplaceAll(mcat.ConsumerIdentity("workstation", mcat.IdentitySource("pics", "pics")), "_", "-")
|
||||
if forFiles == forPics {
|
||||
t.Fatal("the two consumers were given the same identity; this test proves nothing")
|
||||
}
|
||||
if got := fileNamed(out, "files.env")["content"].(string); !strings.Contains(got, "BUCKET="+forFiles+"\n") {
|
||||
t.Errorf("files was not given its own bucket:\n%s", got)
|
||||
}
|
||||
if got := fileNamed(out, "pics.env")["content"].(string); !strings.Contains(got, "BUCKET="+forPics+"\n") {
|
||||
t.Errorf("pics was not given its own bucket:\n%s", got)
|
||||
}
|
||||
var buckets []any
|
||||
for _, g := range storeGrants(t, out) {
|
||||
buckets = append(buckets, g.Derived["bucket"])
|
||||
}
|
||||
if len(buckets) != 2 || buckets[0] == buckets[1] {
|
||||
t.Errorf("the provider was told %v; it must be told one bucket per consumer", buckets)
|
||||
}
|
||||
}
|
||||
|
||||
// An operator may still set what the provider serves where its definition asks (${setting:…}), and
|
||||
// the mesh still derives the rest: the setting is filled first, then the consumer's half. (Until
|
||||
// novox/hq issue 696 the layer replaced the served bucket whole; a layer replaces no served key now.)
|
||||
func TestASettingComposesWithADerivedValue(t *testing.T) {
|
||||
prefixed := store()
|
||||
prefixed.Serves["s3-bucket"]["bucket"] = "${setting:bucket-prefix}${consumer:as:dns}"
|
||||
r, err := Resolve(shelf(prefixed, files()), []string{"store", "files"}, reachable(), mcat.World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err := Declaration(r, mcat.Rendering{
|
||||
Settings: mcat.SettingsBy{"store": {{From: "the operator",
|
||||
Values: map[string]any{"bucket-prefix": "team-"}}}},
|
||||
Grants: []mcat.Grant{{Provision: "s3-bucket", Consumer: "workstation", From: "files", Slug: "files",
|
||||
Values: map[string]any{}, Sealed: "c2VhbGVk"}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := "team-" + strings.ReplaceAll(mcat.ConsumerIdentity("workstation", mcat.IdentitySource("files", "files")), "_", "-")
|
||||
if got := fileNamed(out, "files.env")["content"].(string); !strings.Contains(got, "BUCKET="+want+"\n") {
|
||||
t.Errorf("the operator's prefix did not survive the derivation:\n%s\nwant BUCKET=%s", got, want)
|
||||
}
|
||||
if given := storeGrants(t, out); given[0].Derived["bucket"] != want {
|
||||
t.Errorf("the provider was told %v, the consumer %q", given[0].Derived["bucket"], want)
|
||||
}
|
||||
}
|
||||
|
||||
// `dns` is checked against an identity the mesh actually mints, not an invented string.
|
||||
func TestTheDNSAlphabetIsTheMintedIdentityWithItsSeparatorRewritten(t *testing.T) {
|
||||
as := mcat.ConsumerIdentity("anchor", mcat.IdentitySource("ncloud", "nextcloud"))
|
||||
if err := mcat.CheckIdentity("anchor", mcat.IdentitySource("ncloud", "nextcloud")); err != nil {
|
||||
t.Fatalf("the mesh would not mint this identity at all: %v", err)
|
||||
}
|
||||
label := asDNSLabel(as)
|
||||
if strings.Contains(label, "_") {
|
||||
t.Errorf("%q is not a DNS label", label)
|
||||
}
|
||||
if strings.ReplaceAll(label, "-", "_") != as {
|
||||
t.Errorf("%q is not %q with its separator rewritten", label, as)
|
||||
}
|
||||
}
|
||||
|
||||
// The check that would have caught the one wrong instance: a consumer that writes the derived
|
||||
// value into its own definition instead of asking for it is refused, whether it transcribed the
|
||||
// right answer or a predecessor's.
|
||||
func TestAConsumerThatTranscribesWhatItsProviderDerivesIsRefused(t *testing.T) {
|
||||
as := mcat.ConsumerIdentity("workstation", mcat.IdentitySource("files", "files"))
|
||||
transcribed := strings.ReplaceAll(as, "_", "-")
|
||||
|
||||
m := files()
|
||||
m.Resources = []map[string]any{{
|
||||
"id": "env", "type": "file", "path": "/var/lib/files/env", "mode": "0600",
|
||||
// Exactly what the provider will create — correct today, and a copy of a rule that is
|
||||
// not this module's.
|
||||
"content": "BUCKET=" + transcribed + "\n",
|
||||
}}
|
||||
r, err := Resolve(shelf(store(), m), []string{"store", "files"}, reachable(), mcat.World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = Declaration(r, mcat.Rendering{Grants: []mcat.Grant{{
|
||||
Provision: "s3-bucket", Consumer: "workstation", From: "files", Slug: "files",
|
||||
Values: map[string]any{}, Sealed: "c2VhbGVk",
|
||||
}}})
|
||||
if err == nil {
|
||||
t.Fatal("a definition holding its own copy of the provider's naming rule was accepted")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "${bound:s3-bucket:bucket}") {
|
||||
t.Errorf("the refusal does not say what to write instead: %v", err)
|
||||
}
|
||||
|
||||
// And a constant the provider serves to everyone is not a transcription: repeating it is
|
||||
// redundant, not wrong, and refusing it would be the mesh policing style.
|
||||
m.Resources = []map[string]any{{
|
||||
"id": "env", "type": "file", "path": "/var/lib/files/env", "mode": "0600",
|
||||
"content": "REGION=eu-west\n",
|
||||
}}
|
||||
r, err = Resolve(shelf(store(), m), []string{"store", "files"}, reachable(), mcat.World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := Declaration(r, mcat.Rendering{Grants: []mcat.Grant{{
|
||||
Provision: "s3-bucket", Consumer: "workstation", From: "files", Slug: "files",
|
||||
Values: map[string]any{}, Sealed: "c2VhbGVk",
|
||||
}}}); err != nil {
|
||||
t.Errorf("a value the provider serves to everyone was judged a transcription: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func storeGrants(t *testing.T, out []map[string]any) []mcat.Contribution {
|
||||
t.Helper()
|
||||
for _, r := range out {
|
||||
if r["path"] != "/var/lib/store/grants/mesh.json" {
|
||||
continue
|
||||
}
|
||||
var parsed struct {
|
||||
Given []mcat.Contribution `json:"given"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(r["content"].(string)), &parsed); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return parsed.Given
|
||||
}
|
||||
t.Fatalf("the provider was given no contributions file: %v", out)
|
||||
return nil
|
||||
}
|
||||
|
||||
// A consumer that keeps SEVERAL holders of one provision is refused, rather than told one thing
|
||||
// while its provider is told another.
|
||||
//
|
||||
// **This is issue 124's own failure, one case to the side.** The mesh gives each holder its own
|
||||
// login — `mesh_node_mod_<local>` (ADR 0094) — and the provider derives from the login, so it
|
||||
// would make one resource per holder. The consumer's side has no such dimension: there is one
|
||||
// binding file per provision and one `${bound:<provision>:<key>}`, both derived from the
|
||||
// un-suffixed identity. So the provider would create `…-mod-cold` and the consumer would be
|
||||
// configured against `…-mod`: it would authenticate successfully and be refused on every object,
|
||||
// which is exactly the fault this whole record exists to end.
|
||||
//
|
||||
// Refused, loudly, at the one place that can see both halves. Lifting it means giving the
|
||||
// consumer's side a local dimension, which is a decision and not an omission.
|
||||
func TestAConsumerWithSeveralHoldersOfADerivingProviderIsRefused(t *testing.T) {
|
||||
m := files()
|
||||
// Two holders of the one provision, the shape ADR 0094 gives a module that keeps several.
|
||||
m.Secrets = nil
|
||||
m.SecretsMany = map[string]map[string]string{"s3-bucket": {
|
||||
"hot": "/var/lib/files/hot.secret",
|
||||
"cold": "/var/lib/files/cold.secret",
|
||||
}}
|
||||
m.Resources = []map[string]any{{
|
||||
"id": "env", "type": "file", "path": "/var/lib/files/env", "mode": "0600",
|
||||
"content": "BUCKET=${bound:s3-bucket:bucket}\n",
|
||||
}}
|
||||
r, err := Resolve(shelf(store(), m), []string{"store", "files"}, reachable(), mcat.World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = Declaration(r, mcat.Rendering{Grants: []mcat.Grant{
|
||||
{Provision: "s3-bucket", Consumer: "workstation", From: "files", Slug: "files",
|
||||
Local: "hot", Values: map[string]any{}, Sealed: "c2VhbGVk"},
|
||||
{Provision: "s3-bucket", Consumer: "workstation", From: "files", Slug: "files",
|
||||
Local: "cold", Values: map[string]any{}, Sealed: "c2VhbGVk"},
|
||||
}})
|
||||
if err == nil {
|
||||
t.Fatal("a consumer with several holders of a deriving provider was accepted; " +
|
||||
"its two ends would have disagreed in silence")
|
||||
}
|
||||
for _, want := range []string{"files", "s3-bucket", "bucket"} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("the refusal does not name %q: %v", want, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
package resolution
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
|
||||
// defaultDataRoot is where module data lands when a node states no root of its own.
|
||||
const defaultDataRoot = "/var/lib"
|
||||
|
||||
// placeOwn is the assignment's own root, <root>/<module> — to-be 27's one directory per
|
||||
// assignment, which every other placed thing of the module sits beneath.
|
||||
const placeOwn = "."
|
||||
|
||||
// placeMesh is where the mesh keeps what it writes for the module, <root>/mesh/<module>.
|
||||
const placeMesh = "mesh"
|
||||
|
||||
// dirRef is how a module names one of its placed directories: ${dir:<id>}.
|
||||
var dirRef = regexp.MustCompile(`\$\{dir:([a-z0-9][a-z0-9-]*)\}`)
|
||||
|
||||
// dataRoot is the root this node keeps placed directories under.
|
||||
func dataRoot(with mcat.Rendering) string {
|
||||
if root := strings.TrimRight(strings.TrimSpace(with.DataRoot), "/"); root != "" {
|
||||
return root
|
||||
}
|
||||
return defaultDataRoot
|
||||
}
|
||||
|
||||
// dirsFor is every placed directory of a module, id → the path it resolves to on this node.
|
||||
//
|
||||
// A pathless directory saying `"place": "."` is the assignment's own root, <root>/<module>; one
|
||||
// saying `"place": "mesh"` is the mesh's directory for the module, <root>/mesh/<module>; one
|
||||
// saying neither is <root>/<module>/<id>. At most one of each place makes sense; nothing enforces
|
||||
// one, because two ids resolving to one path is a mistake the module's own files make visible
|
||||
// immediately.
|
||||
//
|
||||
// A stated path may itself begin with a placed reference — `${dir:mesh-state}/state` — and is
|
||||
// filled after the directories it can name are resolved; one level, because a directory beneath
|
||||
// a placed one is the whole of what an adopted layout needs (issue 174's `state` and `out`).
|
||||
func dirsFor(m mcat.Manifest, with mcat.Rendering) map[string]string {
|
||||
dirs := map[string]string{}
|
||||
var beneath []map[string]any
|
||||
// The assignment's placement wins over both (novox/hq issue 153). Refused elsewhere when
|
||||
// malformed; here an invalid setting simply places nothing.
|
||||
placed, _ := mcat.Places(m, with.Settings[m.Module])
|
||||
for _, r := range m.Resources {
|
||||
if fmt.Sprint(r["type"]) != "directory" {
|
||||
continue
|
||||
}
|
||||
id := fmt.Sprint(r["id"])
|
||||
if p, said := placed[id]; said {
|
||||
dirs[id] = p.Path
|
||||
continue
|
||||
}
|
||||
if path, stated := r["path"].(string); stated && path != "" {
|
||||
if strings.HasPrefix(path, "${dir:") {
|
||||
beneath = append(beneath, r)
|
||||
continue
|
||||
}
|
||||
dirs[id] = strings.TrimRight(path, "/")
|
||||
continue
|
||||
}
|
||||
switch place, _ := r["place"].(string); place {
|
||||
case placeOwn:
|
||||
dirs[id] = dataRoot(with) + "/" + m.Module
|
||||
case placeMesh:
|
||||
dirs[id] = dataRoot(with) + "/mesh/" + m.Module
|
||||
default:
|
||||
dirs[id] = dataRoot(with) + "/" + m.Module + "/" + id
|
||||
}
|
||||
}
|
||||
for _, r := range beneath {
|
||||
path := strings.TrimRight(r["path"].(string), "/")
|
||||
// A reference to no directory is left as written and refused where the resource is
|
||||
// placed (dirInto), with the message that names what exists.
|
||||
filled, _ := dirFill(path, dirs, m.Module)
|
||||
dirs[fmt.Sprint(r["id"])] = filled
|
||||
}
|
||||
return dirs
|
||||
}
|
||||
|
||||
// dirFill resolves every ${dir:…} in one string, or refuses a reference naming no directory.
|
||||
func dirFill(s string, dirs map[string]string, module string) (string, error) {
|
||||
var missing error
|
||||
out := dirRef.ReplaceAllStringFunc(s, func(ref string) string {
|
||||
id := dirRef.FindStringSubmatch(ref)[1]
|
||||
path, has := dirs[id]
|
||||
if !has {
|
||||
missing = fmt.Errorf(
|
||||
"%s says ${dir:%s}, and %s declares no directory %q. It declares %s",
|
||||
module, id, module, id, orNothing(namesOfDirs(dirs)))
|
||||
return ref
|
||||
}
|
||||
return path
|
||||
})
|
||||
return out, missing
|
||||
}
|
||||
|
||||
// placedManifest is the manifest with every path the mesh resolves already resolved: the maps
|
||||
// naming where bindings, credentials and contributions land are filled against this node's
|
||||
// placed directories, so everything downstream — the generated binding files, the sealed
|
||||
// secrets, the grant directories — reads a concrete place and learns nothing new.
|
||||
func placedManifest(m mcat.Manifest, with mcat.Rendering) (mcat.Manifest, error) {
|
||||
dirs := dirsFor(m, with)
|
||||
fillMap := func(in map[string]string) (map[string]string, error) {
|
||||
if len(in) == 0 {
|
||||
return in, nil
|
||||
}
|
||||
out := make(map[string]string, len(in))
|
||||
for key, value := range in {
|
||||
filled, err := dirFill(value, dirs, m.Module)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[key] = filled
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
var err error
|
||||
if m.Receives, err = fillMap(m.Receives); err != nil {
|
||||
return m, err
|
||||
}
|
||||
if m.Binds, err = fillMap(m.Binds); err != nil {
|
||||
return m, err
|
||||
}
|
||||
if m.Secrets, err = fillMap(m.Secrets); err != nil {
|
||||
return m, err
|
||||
}
|
||||
if len(m.OwnSecrets) > 0 {
|
||||
own := make(mcat.OwnSecrets, len(m.OwnSecrets))
|
||||
for name, s := range m.OwnSecrets {
|
||||
filled, err := dirFill(s.Path, dirs, m.Module)
|
||||
if err != nil {
|
||||
return m, err
|
||||
}
|
||||
own[name] = mcat.OwnSecret{Path: filled, Taken: s.Taken, IssuedBy: s.IssuedBy, Prompt: s.Prompt, Owner: s.Owner}
|
||||
}
|
||||
m.OwnSecrets = own
|
||||
}
|
||||
if m.Grants, err = fillMap(m.Grants); err != nil {
|
||||
return m, err
|
||||
}
|
||||
if len(m.SecretsMany) > 0 {
|
||||
many := make(map[string]map[string]string, len(m.SecretsMany))
|
||||
for to, locals := range m.SecretsMany {
|
||||
if many[to], err = fillMap(locals); err != nil {
|
||||
return m, err
|
||||
}
|
||||
}
|
||||
m.SecretsMany = many
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// dirInto places a resource: a pathless directory is given the path the mesh resolved for it,
|
||||
// and every ${dir:…} the resource carries — in its path, its content, its mounts, its
|
||||
// environment and its env-files — becomes that path.
|
||||
//
|
||||
// A reference naming no directory of this module is refused. Left as written, the literal
|
||||
// `${dir:x}` would reach the machine as a path, and the runtime would create and mount a
|
||||
// directory called `${dir:x}` — real, wrong, and named after the mistake.
|
||||
func dirInto(resource map[string]any, dirs map[string]string, module string) error {
|
||||
fill := func(s string) (string, error) { return dirFill(s, dirs, module) }
|
||||
|
||||
if fmt.Sprint(resource["type"]) == "directory" {
|
||||
id := fmt.Sprint(resource["id"])
|
||||
if path, stated := resource["path"].(string); !stated || path == "" {
|
||||
resource["path"] = dirs[id]
|
||||
}
|
||||
// Said in the catalogue, not on the machine: the host parses strictly and knows no
|
||||
// such field — resolved, the place IS the path.
|
||||
delete(resource, "place")
|
||||
}
|
||||
|
||||
var err error
|
||||
if path, ok := resource["path"].(string); ok {
|
||||
if resource["path"], err = fill(path); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if content, ok := resource["content"].(string); ok {
|
||||
if resource["content"], err = fill(content); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
// Nested values are rebuilt, never written into: the resource is a shallow copy of the
|
||||
// manifest's own map, and the manifest is composed once per node — a fill written in place
|
||||
// would leave the first node's paths inside every later composition.
|
||||
if volumes, ok := resource["volumes"].([]any); ok {
|
||||
filled := make([]any, len(volumes))
|
||||
for i, v := range volumes {
|
||||
filled[i] = v
|
||||
if mount, ok := v.(string); ok {
|
||||
if filled[i], err = fill(mount); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
resource["volumes"] = filled
|
||||
}
|
||||
if env, ok := resource["env"].(map[string]any); ok {
|
||||
filled := make(map[string]any, len(env))
|
||||
for key, v := range env {
|
||||
filled[key] = v
|
||||
if value, ok := v.(string); ok {
|
||||
if filled[key], err = fill(value); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
resource["env"] = filled
|
||||
}
|
||||
if files, ok := resource["env-file"].([]any); ok {
|
||||
filled := make([]any, len(files))
|
||||
for i, v := range files {
|
||||
filled[i] = v
|
||||
if path, ok := v.(string); ok {
|
||||
if filled[i], err = fill(path); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
resource["env-file"] = filled
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func namesOfDirs(dirs map[string]string) []string {
|
||||
var names []string
|
||||
for id := range dirs {
|
||||
names = append(names, fmt.Sprintf("%q", id))
|
||||
}
|
||||
sort.Strings(names)
|
||||
return names
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
package resolution
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
|
||||
func placedModule() mcat.Manifest {
|
||||
return mcat.Manifest{
|
||||
Module: "photos",
|
||||
Resources: []map[string]any{
|
||||
{"id": "data", "type": "directory", "mode": "0700"},
|
||||
{"id": "server-env", "type": "file", "path": "${dir:data}/server.env",
|
||||
"content": "STORE=${dir:data}/objects\n"},
|
||||
{"id": "server", "type": "container", "name": "photos-server",
|
||||
"volumes": []any{"${dir:data}:/data"},
|
||||
"env": map[string]any{"DATA": "${dir:data}/objects"},
|
||||
"env-file": []any{"${dir:data}/server.env"}},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPathlessDirectoryResolvesUnderTheNodesRoot(t *testing.T) {
|
||||
m := placedModule()
|
||||
dirs := dirsFor(m, mcat.Rendering{})
|
||||
if dirs["data"] != "/var/lib/photos/data" {
|
||||
t.Fatalf("the default root is /var/lib and the shape is <root>/<module>/<id>; got %q", dirs["data"])
|
||||
}
|
||||
dirs = dirsFor(m, mcat.Rendering{DataRoot: "/tank/nox/"})
|
||||
if dirs["data"] != "/tank/nox/photos/data" {
|
||||
t.Fatalf("a node's own root is honoured, trailing slash and all; got %q", dirs["data"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDirReferencesBecomeThePlaceInEveryField(t *testing.T) {
|
||||
m := placedModule()
|
||||
dirs := dirsFor(m, mcat.Rendering{})
|
||||
|
||||
directory := shallowCopy(m.Resources[0])
|
||||
if err := dirInto(directory, dirs, m.Module); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if directory["path"] != "/var/lib/photos/data" {
|
||||
t.Fatalf("a pathless directory receives its resolved path; got %v", directory["path"])
|
||||
}
|
||||
|
||||
file := shallowCopy(m.Resources[1])
|
||||
if err := dirInto(file, dirs, m.Module); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if file["path"] != "/var/lib/photos/data/server.env" {
|
||||
t.Fatalf("a file's path names the place; got %v", file["path"])
|
||||
}
|
||||
if file["content"] != "STORE=/var/lib/photos/data/objects\n" {
|
||||
t.Fatalf("a file's content names the place; got %v", file["content"])
|
||||
}
|
||||
|
||||
container := shallowCopy(m.Resources[2])
|
||||
if err := dirInto(container, dirs, m.Module); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if container["volumes"].([]any)[0] != "/var/lib/photos/data:/data" {
|
||||
t.Fatalf("a mount names the place; got %v", container["volumes"])
|
||||
}
|
||||
if container["env"].(map[string]any)["DATA"] != "/var/lib/photos/data/objects" {
|
||||
t.Fatalf("an environment value names the place; got %v", container["env"])
|
||||
}
|
||||
if container["env-file"].([]any)[0] != "/var/lib/photos/data/server.env" {
|
||||
t.Fatalf("an env-file names the place; got %v", container["env-file"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAStatedPathIsThePlacementAndStillAnswersByName(t *testing.T) {
|
||||
m := placedModule()
|
||||
// The adopted-machine case: data that must sit where the predecessor already put it.
|
||||
m.Resources[0]["path"] = "/services/mssql/data/"
|
||||
dirs := dirsFor(m, mcat.Rendering{})
|
||||
if dirs["data"] != "/services/mssql/data" {
|
||||
t.Fatalf("a stated path wins over the root, trimmed; got %q", dirs["data"])
|
||||
}
|
||||
container := shallowCopy(m.Resources[2])
|
||||
if err := dirInto(container, dirs, m.Module); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if container["volumes"].([]any)[0] != "/services/mssql/data:/data" {
|
||||
t.Fatalf("references follow the placement; got %v", container["volumes"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestFillingForOneNodeLeaksIntoNoOther(t *testing.T) {
|
||||
m := placedModule()
|
||||
first := shallowCopy(m.Resources[2])
|
||||
if err := dirInto(first, dirsFor(m, mcat.Rendering{DataRoot: "/first"}), m.Module); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second := shallowCopy(m.Resources[2])
|
||||
if err := dirInto(second, dirsFor(m, mcat.Rendering{DataRoot: "/second"}), m.Module); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := second["volumes"].([]any)[0]; got != "/second/photos/data:/data" {
|
||||
t.Fatalf("the second composition must see the manifest, not the first fill; got %v", got)
|
||||
}
|
||||
if m.Resources[2]["volumes"].([]any)[0] != "${dir:data}:/data" {
|
||||
t.Fatalf("the manifest itself stays a template; got %v", m.Resources[2]["volumes"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAReferenceToNoDirectoryRefusesAtCompositionToo(t *testing.T) {
|
||||
m := placedModule()
|
||||
container := shallowCopy(m.Resources[2])
|
||||
container["env"] = map[string]any{"DATA": "${dir:date}"}
|
||||
err := dirInto(container, dirsFor(m, mcat.Rendering{}), m.Module)
|
||||
if err == nil || !strings.Contains(err.Error(), `"date"`) || !strings.Contains(err.Error(), `"data"`) {
|
||||
t.Fatalf("the refusal names the mistake and what exists; got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheAssignmentsOwnRootIsAPlace(t *testing.T) {
|
||||
m := mcat.Manifest{Module: "mailu", Resources: []map[string]any{
|
||||
{"id": "state", "type": "directory", "place": ".", "mode": "0700"},
|
||||
{"id": "data-mail", "type": "directory"},
|
||||
}}
|
||||
dirs := dirsFor(m, mcat.Rendering{})
|
||||
if dirs["state"] != "/var/lib/mailu" {
|
||||
t.Fatalf("place %q is the assignment's root; got %q", ".", dirs["state"])
|
||||
}
|
||||
if dirs["data-mail"] != "/var/lib/mailu/data-mail" {
|
||||
t.Fatalf("everything else sits beneath it; got %q", dirs["data-mail"])
|
||||
}
|
||||
root := shallowCopy(m.Resources[0])
|
||||
if err := dirInto(root, dirs, m.Module); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if root["path"] != "/var/lib/mailu" {
|
||||
t.Fatalf("the root receives its path; got %v", root["path"])
|
||||
}
|
||||
if _, still := root["place"]; still {
|
||||
t.Fatal("place must never reach the host, which parses strictly")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheManifestsMapsArePlaced(t *testing.T) {
|
||||
m := mcat.Manifest{
|
||||
Module: "photos",
|
||||
Resources: []map[string]any{
|
||||
{"id": "state", "type": "directory", "place": "."},
|
||||
},
|
||||
Binds: map[string]string{"route": "${dir:state}/route.json"},
|
||||
Secrets: map[string]string{"mongodb-database": "${dir:state}/database.secret"},
|
||||
OwnSecrets: mcat.OwnSecrets{"admin-key": {Path: "${dir:state}/admin-key.secret"}},
|
||||
Receives: map[string]string{"route": "${dir:state}/grants/mesh.json"},
|
||||
}
|
||||
placed, err := placedManifest(m, mcat.Rendering{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if placed.Binds["route"] != "/var/lib/photos/route.json" {
|
||||
t.Fatalf("binds are placed; got %v", placed.Binds)
|
||||
}
|
||||
if placed.Secrets["mongodb-database"] != "/var/lib/photos/database.secret" {
|
||||
t.Fatalf("secrets are placed; got %v", placed.Secrets)
|
||||
}
|
||||
if placed.OwnSecrets["admin-key"].Path != "/var/lib/photos/admin-key.secret" {
|
||||
t.Fatalf("own-secrets are placed; got %v", placed.OwnSecrets)
|
||||
}
|
||||
if placed.Receives["route"] != "/var/lib/photos/grants/mesh.json" {
|
||||
t.Fatalf("receives are placed; got %v", placed.Receives)
|
||||
}
|
||||
if m.Binds["route"] != "${dir:state}/route.json" {
|
||||
t.Fatalf("the manifest itself stays a template; got %v", m.Binds)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheMeshsDirectoryForAModuleIsAPlace(t *testing.T) {
|
||||
// novox/hq issue 174. What the mesh writes for a module — its bus credential, its bindings —
|
||||
// is the mesh's plumbing under <root>/mesh/<module>, and the definition names it by id.
|
||||
m := mcat.Manifest{Module: "umami",
|
||||
Resources: []map[string]any{
|
||||
{"id": "mesh-state", "type": "directory", "place": "mesh"},
|
||||
{"id": "state", "type": "directory", "place": "."},
|
||||
{"id": "server", "type": "container", "image": "x@sha256:aa",
|
||||
"volumes": []any{"${dir:mesh-state}/broker:/run/secrets/broker:ro"}},
|
||||
},
|
||||
OwnSecrets: mcat.OwnSecrets{"broker": {Path: "${dir:mesh-state}/broker"}},
|
||||
Binds: map[string]string{"route": "${dir:state}/route.json"},
|
||||
}
|
||||
dirs := dirsFor(m, mcat.Rendering{})
|
||||
if dirs["mesh-state"] != "/var/lib/mesh/umami" || dirs["state"] != "/var/lib/umami" {
|
||||
t.Fatalf("the mesh's directory sits beside the module's, not in it; got %v", dirs)
|
||||
}
|
||||
dirs = dirsFor(m, mcat.Rendering{DataRoot: "/srv"})
|
||||
if dirs["mesh-state"] != "/srv/mesh/umami" {
|
||||
t.Fatalf("a node's root moves the mesh's files with the module's; got %v", dirs)
|
||||
}
|
||||
placed, err := placedManifest(m, mcat.Rendering{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if placed.OwnSecrets["broker"].Path != "/var/lib/mesh/umami/broker" {
|
||||
t.Fatalf("own-secrets are placed under the mesh's directory; got %v", placed.OwnSecrets)
|
||||
}
|
||||
container := shallowCopy(m.Resources[2])
|
||||
if err := dirInto(container, dirsFor(m, mcat.Rendering{}), m.Module); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if container["volumes"].([]any)[0] != "/var/lib/mesh/umami/broker:/run/secrets/broker:ro" {
|
||||
t.Fatalf("the mount's host side is placed; got %v", container["volumes"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestTwoModulesPlacedRootsAreNoCollision(t *testing.T) {
|
||||
a := mcat.Manifest{Module: "gitea", Resources: []map[string]any{
|
||||
{"id": "state", "type": "directory", "place": "."},
|
||||
{"id": "env", "type": "file", "path": "${dir:state}/server.env", "content": ""},
|
||||
}}
|
||||
b := mcat.Manifest{Module: "nextcloud", Resources: []map[string]any{
|
||||
{"id": "state", "type": "directory", "place": "."},
|
||||
{"id": "env", "type": "file", "path": "${dir:state}/server.env", "content": ""},
|
||||
}}
|
||||
if got := checkResources([]mcat.Manifest{a, b}); len(got) != 0 {
|
||||
t.Fatalf("alike templates are different places; got %v", got)
|
||||
}
|
||||
// And the real collision is still caught: a module stating another's placed root.
|
||||
c := mcat.Manifest{Module: "squatter", Resources: []map[string]any{
|
||||
{"id": "nest", "type": "directory", "path": "/var/lib/gitea"},
|
||||
}}
|
||||
got := checkResources([]mcat.Manifest{a, c})
|
||||
if len(got) != 1 || !strings.Contains(got[0], `"/var/lib/gitea"`) {
|
||||
t.Fatalf("a stated path on a placed root collides; got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func shallowCopy(resource map[string]any) map[string]any {
|
||||
copied := map[string]any{}
|
||||
for k, v := range resource {
|
||||
copied[k] = v
|
||||
}
|
||||
return copied
|
||||
}
|
||||
|
||||
func TestADirectoryBeneathAPlacedOneIsPlacedWithIt(t *testing.T) {
|
||||
// An adopted layout keeps a subdirectory the predecessor made under the mesh's directory
|
||||
// (issue 174: a forge's runtime state, a manager's output). Stated as beneath the placed one,
|
||||
// it moves with it — a node's root moves both, and the definition names no host path.
|
||||
m := mcat.Manifest{Module: "gitea", Resources: []map[string]any{
|
||||
{"id": "mesh-state", "type": "directory", "place": "mesh"},
|
||||
{"id": "runtime-state", "type": "directory", "path": "${dir:mesh-state}/state"},
|
||||
{"id": "server", "type": "container", "image": "x@sha256:aa",
|
||||
"volumes": []any{"${dir:runtime-state}:/data"}},
|
||||
}}
|
||||
dirs := dirsFor(m, mcat.Rendering{DataRoot: "/srv"})
|
||||
if dirs["runtime-state"] != "/srv/mesh/gitea/state" {
|
||||
t.Fatalf("the subdirectory follows the placed one; got %v", dirs)
|
||||
}
|
||||
sub := shallowCopy(m.Resources[1])
|
||||
if err := dirInto(sub, dirs, m.Module); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if sub["path"] != "/srv/mesh/gitea/state" {
|
||||
t.Fatalf("the directory resource itself is resolved; got %v", sub["path"])
|
||||
}
|
||||
container := shallowCopy(m.Resources[2])
|
||||
if err := dirInto(container, dirs, m.Module); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if container["volumes"].([]any)[0] != "/srv/mesh/gitea/state:/data" {
|
||||
t.Fatalf("a reference to the subdirectory resolves whole; got %v", container["volumes"])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
// Package resolution is the node-engine's resolution and rendering of a machine's modules from their manifests: which
|
||||
// modules a node runs and why (Resolve), what each composes to (Compose, Declaration, DataItems), what is left out and
|
||||
// why, and every refusal of a composition, settings and templates included. It moved here from mesh-sdk's catalogue
|
||||
// package (novox/hq ADR 0593 §1, §7): only the node-engine composes, so mesh-sdk carries no composition rule. The
|
||||
// manifest's shapes and the indexer's judgement stay in mesh-sdk; a method of a mesh-sdk shape that composes is a
|
||||
// function here taking that shape first (Compose(r, with) for Resolution.Compose).
|
||||
package resolution
|
||||
@@ -0,0 +1,119 @@
|
||||
package resolution
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
|
||||
func networkingShelf(extra ...mcat.Manifest) map[string]mcat.Manifest {
|
||||
base := []mcat.Manifest{
|
||||
{Module: "networking", Requires: []string{"private-network", "name-resolution"}},
|
||||
{Module: "mesh-wireguard", Computed: "mesh-wireguard",
|
||||
Provides: mcat.Offers("private-network", "mesh-addressing"),
|
||||
Claims: []mcat.Claim{{Name: "the-private-network", Scope: mcat.ScopeNode}}},
|
||||
{Module: "mesh-names", Computed: "mesh-names",
|
||||
Provides: mcat.Offers("name-resolution"), Requires: []string{"mesh-addressing"}},
|
||||
}
|
||||
return shelf(append(base, extra...)...)
|
||||
}
|
||||
|
||||
func TestOneWordBringsUpTheNetwork(t *testing.T) {
|
||||
// The case that has to stay easy. Nothing is asked, because with one answer to each
|
||||
// requirement there was never a question.
|
||||
got, err := Resolve(networkingShelf(), []string{"networking"}, workstation(), mcat.World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
have := strings.Join(names(got), " ")
|
||||
for _, want := range []string{"networking", "mesh-wireguard", "mesh-names"} {
|
||||
if !strings.Contains(have, want) {
|
||||
t.Fatalf("assigning networking did not bring in %s: %s", want, have)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestASecondVPNTurnsItIntoAChoice(t *testing.T) {
|
||||
// And the choice is offered rather than made. A default here would be the flavor field coming
|
||||
// back under another name.
|
||||
_, err := Resolve(networkingShelf(mcat.Manifest{Module: "tailscale", Provides: mcat.Offers("private-network"),
|
||||
Claims: []mcat.Claim{{Name: "the-private-network", Scope: mcat.ScopeNode}}},
|
||||
), []string{"networking"}, workstation(), mcat.World{})
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("two VPNs and one was picked silently")
|
||||
}
|
||||
for _, want := range []string{"mesh-wireguard", "tailscale"} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Fatalf("the refusal does not name %s: %v", want, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestChoosingIsAssigning(t *testing.T) {
|
||||
// No second verb. Assigning the one you want answers the requirement, and the bundle takes it.
|
||||
got, err := Resolve(networkingShelf(mcat.Manifest{Module: "tailscale",
|
||||
Provides: mcat.Offers("private-network", "name-resolution"),
|
||||
Claims: []mcat.Claim{{Name: "the-private-network", Scope: mcat.ScopeNode}}},
|
||||
), []string{"networking", "tailscale"}, workstation(), mcat.World{})
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
have := strings.Join(names(got), " ")
|
||||
if !strings.Contains(have, "tailscale") {
|
||||
t.Fatalf("the chosen VPN is not in the set: %s", have)
|
||||
}
|
||||
if strings.Contains(have, "mesh-wireguard") {
|
||||
t.Fatalf("choosing tailscale still installed WireGuard: %s", have)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChoosingOneVPNCannotDragTheOtherBackIn(t *testing.T) {
|
||||
// This happened. The person chose tailscale; the names module required the mesh's own
|
||||
// addressing; only WireGuard provides that; so both were installed and nobody was told.
|
||||
//
|
||||
// The claim is what catches it. Providing a private network is not the singular part — a
|
||||
// machine could run two VPNs for two purposes — but being *the* one the mesh runs over is.
|
||||
_, err := Resolve(networkingShelf(mcat.Manifest{Module: "tailscale", Provides: mcat.Offers("private-network"),
|
||||
Claims: []mcat.Claim{{Name: "the-private-network", Scope: mcat.ScopeNode}}},
|
||||
), []string{"networking", "tailscale"}, workstation(), mcat.World{})
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("a machine was given two private networks without being told")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "the-private-network") {
|
||||
t.Fatalf("the refusal does not say what collided: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNamesNeedTheMeshsOwnAddresses(t *testing.T) {
|
||||
// Names are computed from addresses the mesh handed out. Over a VPN that hands out its own,
|
||||
// the mesh has nothing to write, so the names module requires the addressing rather than a
|
||||
// private network in general — otherwise a machine gets a hosts file full of addresses that
|
||||
// mean nothing on it.
|
||||
_, err := Resolve(shelf(mcat.Manifest{Module: "mesh-names", Computed: "mesh-names",
|
||||
Provides: mcat.Offers("name-resolution"), Requires: []string{"mesh-addressing"}}, mcat.Manifest{Module: "tailscale", Provides: mcat.Offers("private-network")},
|
||||
), []string{"mesh-names", "tailscale"}, workstation(), mcat.World{})
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("the mesh's names were installed over a VPN whose addresses it does not hand out")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "mesh-addressing") {
|
||||
t.Fatalf("the refusal does not say what is missing: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestARequirementWantedTwiceIsReportedOnce(t *testing.T) {
|
||||
// Two identical lines make a person hunt for the difference between them before realising
|
||||
// there is none.
|
||||
_, err := Resolve(shelf(mcat.Manifest{Module: "one", Requires: []string{"shell"}}, mcat.Manifest{Module: "two", Requires: []string{"shell"}}, mcat.Manifest{Module: "bash", Provides: mcat.Offers("shell")}, mcat.Manifest{Module: "zsh", Provides: mcat.Offers("shell")}), []string{"one", "two"}, workstation(), mcat.World{})
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("two shells and one was picked silently")
|
||||
}
|
||||
if n := strings.Count(err.Error(), `"shell" is wanted by`); n != 1 {
|
||||
t.Fatalf("the same requirement was reported %d times:\n%v", n, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package resolution
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
|
||||
// aMediaServer is the shape one port number per key cannot express: two endpoints of different kinds.
|
||||
// A web surface a proxy serves under a subdomain, and a protocol port clients dial directly because
|
||||
// the client expects that number.
|
||||
func aMediaServer() mcat.Manifest {
|
||||
return mcat.Manifest{
|
||||
Module: "media",
|
||||
Listens: []mcat.Listening{
|
||||
{Name: "web", Port: 80, From: mcat.FromMesh, Why: "the app, behind the proxy"},
|
||||
{Name: "stream", Port: 32400, From: mcat.FromEverywhere, Fixed: true,
|
||||
Why: "the client dials this number; the protocol chose it"},
|
||||
},
|
||||
Contributes: map[string]map[string]any{
|
||||
"route": {"label": "media", mcat.RouteEndpoint: "web"},
|
||||
},
|
||||
// Both endpoints are published by its container, which is what lets a machine port be given
|
||||
// for either: the mesh moves a port the module publishes, never one it merely listens on.
|
||||
Resources: []map[string]any{
|
||||
{"id": "server", "type": "container", "name": "media",
|
||||
"ports": []any{"80", "32400"}},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// And the routed set is read through the name, so the endpoint the proxy serves is known without a
|
||||
// reader joining two numbers.
|
||||
func TestTheRoutedEndpointIsFoundByName(t *testing.T) {
|
||||
routed := RoutedPorts(aMediaServer())
|
||||
if !routed[80] {
|
||||
t.Fatalf("the routed endpoint was not found by name: %v", routed)
|
||||
}
|
||||
// And the directly-dialled one is not routed, which is what lets its reach govern its port.
|
||||
if routed[32400] {
|
||||
t.Fatalf("the endpoint clients dial directly reads as routed: %v", routed)
|
||||
}
|
||||
}
|
||||
|
||||
// **Two endpoints of different shapes, configured as themselves.** The web endpoint's reach asks for
|
||||
// names and leaves its port to the proxy; the stream endpoint's reach governs its port, because
|
||||
// clients dial it and there is no name.
|
||||
func TestTwoEndpointsOfDifferentShapesAreConfiguredSeparately(t *testing.T) {
|
||||
m := aMediaServer()
|
||||
settings := mcat.SettingsBy{"media": {{From: "node anchor", Values: map[string]any{mcat.ReachSetting: map[string]any{"80": mcat.ReachBoth, "32400": mcat.ReachPublic}}}}}
|
||||
|
||||
r := mcat.Resolution{Node: "anchor", Modules: []mcat.Manifest{m},
|
||||
PublicDomain: "example.test", At: "anchor.internal"}
|
||||
rules, err := Rules(r, mcat.Rendering{Settings: settings})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, rule := range rules {
|
||||
switch rule.Port {
|
||||
case 80:
|
||||
if rule.From != mcat.FromMesh {
|
||||
t.Fatalf("the routed endpoint's port opened to %q; the proxy is how it is reached",
|
||||
rule.From)
|
||||
}
|
||||
case 32400:
|
||||
if rule.From != mcat.FromEverywhere {
|
||||
t.Fatalf("the directly-dialled endpoint's port is %q, want anywhere", rule.From)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// And the routed one carries both names, asked for by the same statement.
|
||||
given, err := resolutionContributions(r, settings, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var public, internal string
|
||||
for _, c := range given["route"] {
|
||||
public, _ = c.Values["name"].(string)
|
||||
internal, _ = c.Values["internal-name"].(string)
|
||||
}
|
||||
if public != "media.example.test" || internal != "media.anchor.internal" {
|
||||
t.Fatalf("names are %q and %q, want both", public, internal)
|
||||
}
|
||||
}
|
||||
|
||||
// **A route that names an endpoint still carries that endpoint's port.**
|
||||
//
|
||||
// Everything downstream reads the port: the provider is told where to reach the consumer, and the
|
||||
// redirection that turns a declared port into the number the machine published is keyed on it. A route
|
||||
// naming only its endpoint left the proxy with no port, and a proxy with no port has nothing to dial.
|
||||
//
|
||||
// Caught after the catalogue had already been changed to name endpoints, and before the mesh picked
|
||||
// those manifests up — which is the only reason nothing broke.
|
||||
func TestARouteNamingAnEndpointStillCarriesItsPort(t *testing.T) {
|
||||
m := aMediaServer()
|
||||
r := mcat.Resolution{Node: "anchor", Modules: []mcat.Manifest{m},
|
||||
PublicDomain: "example.test", At: "anchor.internal"}
|
||||
given, err := resolutionContributions(r, nil, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var saw bool
|
||||
for _, c := range given["route"] {
|
||||
saw = true
|
||||
port, ok := asPort(c.Values["port"])
|
||||
if !ok {
|
||||
t.Fatalf("the route carries no port, so the proxy has nothing to dial: %v", c.Values)
|
||||
}
|
||||
if port != 80 {
|
||||
t.Fatalf("the route carries port %d, want the web endpoint's 80", port)
|
||||
}
|
||||
}
|
||||
if !saw {
|
||||
t.Fatal("the module contributed no route")
|
||||
}
|
||||
}
|
||||
|
||||
// And the declared port, not the machine one: the redirection to where the machine published it
|
||||
// happens later and is keyed on the declared number, so filling the machine port in here would be
|
||||
// redirected twice or not at all.
|
||||
func TestTheEndpointsDeclaredPortIsFilledInNotTheMachineOne(t *testing.T) {
|
||||
m := aMediaServer()
|
||||
values := map[string]any{mcat.RouteEndpoint: "web", "label": "media"}
|
||||
portOfEndpoint(values, endpointPorts(m))
|
||||
if got, _ := asPort(values["port"]); got != 80 {
|
||||
t.Fatalf("filled in port %d, want the declared 80", got)
|
||||
}
|
||||
// Then the ordinary redirection puts it where the machine published it.
|
||||
moved := atMachinePort(values, m.Module, map[string]map[int]int{"media": {80: 20009}})
|
||||
if got, _ := asPort(moved["port"]); got != 20009 {
|
||||
t.Fatalf("after redirection the port is %d, want the machine's 20009", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A route that repeats a port keeps it, because that is the older shape and still read.
|
||||
func TestARouteThatRepeatsItsPortKeepsIt(t *testing.T) {
|
||||
values := map[string]any{mcat.RouteEndpoint: "web", "port": 8080}
|
||||
portOfEndpoint(values, map[string]int{"web": 80})
|
||||
if got, _ := asPort(values["port"]); got != 8080 {
|
||||
t.Fatalf("the port it stated was overwritten with %d", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package resolution
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
|
||||
func configured(block map[string]any) mcat.SettingsBy {
|
||||
return mcat.SettingsBy{"media": {{From: "node anchor",
|
||||
Placement: map[string]any{mcat.EndpointsSetting: block}}}}
|
||||
}
|
||||
|
||||
// **One block per endpoint, saying all three things.** The machine port, the subdomain and the reach
|
||||
// were `ports`, the route's label and `reach`, each keyed by a port number, so configuring a module
|
||||
// with two endpoints of different shapes meant knowing which number was which.
|
||||
func TestAnEndpointsBlockSaysPortLabelAndReach(t *testing.T) {
|
||||
m := aMediaServer()
|
||||
// stream's reach NARROWS what the manifest says — the manifest has it from anywhere, the
|
||||
// assignment says internal. Chosen deliberately: a reach that agrees with the manifest proves
|
||||
// nothing about whether the block was read at all.
|
||||
settings := configured(map[string]any{
|
||||
"web": map[string]any{"port": 20009, "label": "cinema", "reach": mcat.ReachBoth},
|
||||
"stream": map[string]any{"reach": mcat.ReachInternal},
|
||||
})
|
||||
|
||||
// The machine port, where the mapping is read.
|
||||
given, err := GivenPorts(m, settings["media"])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if given[80] != 20009 {
|
||||
t.Fatalf("the web endpoint is on machine port %d, want 20009: %v", given[80], given)
|
||||
}
|
||||
|
||||
// The reach, where the filter reads it.
|
||||
r := mcat.Resolution{Node: "anchor", Modules: []mcat.Manifest{m},
|
||||
PublicDomain: "example.test", At: "anchor.internal"}
|
||||
rules, err := Rules(r, mcat.Rendering{Settings: settings})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, rule := range rules {
|
||||
if rule.Port == 32400 && rule.From != mcat.FromMesh {
|
||||
t.Fatalf("the directly-dialled endpoint is %q; the assignment narrowed it to the private "+
|
||||
"network and the manifest's 'anywhere' should not win", rule.From)
|
||||
}
|
||||
if rule.Port == 80 && rule.From != mcat.FromMesh {
|
||||
t.Fatalf("the routed endpoint's port opened to %q; the proxy is how it is reached", rule.From)
|
||||
}
|
||||
}
|
||||
|
||||
// And the subdomain, where the name is composed — the assignment's, not the module's.
|
||||
nodes, err := resolutionContributions(r, settings, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, c := range nodes["route"] {
|
||||
if got, _ := c.Values["name"].(string); got != "cinema.example.test" {
|
||||
t.Fatalf("the public name is %q, want the label the assignment gave", got)
|
||||
}
|
||||
if got, _ := c.Values["internal-name"].(string); got != "cinema.anchor.internal" {
|
||||
t.Fatalf("the internal name is %q, want the label the assignment gave", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A block that says only a reach leaves the port to the mesh and the label to the module, which is the
|
||||
// ordinary case and must not require writing the other two.
|
||||
func TestABlockMaySayOnlyAReach(t *testing.T) {
|
||||
m := aMediaServer()
|
||||
settings := configured(map[string]any{"web": map[string]any{"reach": mcat.ReachInternal}})
|
||||
r := mcat.Resolution{Node: "anchor", Modules: []mcat.Manifest{m},
|
||||
PublicDomain: "example.test", At: "anchor.internal"}
|
||||
nodes, err := resolutionContributions(r, settings, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, c := range nodes["route"] {
|
||||
if got, _ := c.Values["name"].(string); got != "" {
|
||||
t.Fatalf("an internal endpoint composed the public name %q", got)
|
||||
}
|
||||
// The module's own label, untouched.
|
||||
if got, _ := c.Values["internal-name"].(string); got != "media.anchor.internal" {
|
||||
t.Fatalf("the internal name is %q, want the module's own label", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// **Two places giving one endpoint a machine port is the confusion this key exists to end.**
|
||||
func TestAnEndpointGivenAPortTwiceIsRefused(t *testing.T) {
|
||||
m := aMediaServer()
|
||||
_, err := GivenPorts(m, []mcat.Layer{{From: "node anchor", Placement: map[string]any{mcat.EndpointsSetting: map[string]any{"web": map[string]any{"port": 20009}}, mcat.PortsSetting: map[string]any{"80": 30000}}}})
|
||||
if err == nil || !strings.Contains(err.Error(), "published once") {
|
||||
t.Fatalf("an endpoint given two machine ports was accepted: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package resolution
|
||||
|
||||
import (
|
||||
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
|
||||
// engineSeat is the seat's row in the closed set.
|
||||
func engineSeat() mcat.Seat {
|
||||
return mcat.Seat{Name: mcat.EngineSeat, Scope: mcat.ScopeNode, Serves: mcat.EngineVerbs(), Emits: append([]string(nil), mcat.EngineEvents...),
|
||||
HeldByTheEngine: true, Decision: "novox/hq ADR 0563"}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package resolution
|
||||
|
||||
import (
|
||||
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
|
||||
// enrolmentDecision is the record that made the seat.
|
||||
const enrolmentDecision = "novox/hq ADR 0587"
|
||||
|
||||
// enrolmentVerbs are the seat's verbs: what reads all it holds and does by hand all it does (ADR 0587 §8). Not optional:
|
||||
// the seat is new with its holder, so no holder of before serves fewer.
|
||||
func enrolmentVerbs() []mcat.Verb {
|
||||
str := func(description string) map[string]any {
|
||||
return map[string]any{"type": "string", "description": description}
|
||||
}
|
||||
return []mcat.Verb{
|
||||
{Name: mcat.EnrolmentVerbIssue, Description: "Issue a one-time token for a new machine, on the operator's Approve: " +
|
||||
"refused at once, naming every finding, for a name that is not a node name or that a node record, a placement " +
|
||||
"key or an open enrolment holds, and for every call before the controller's lease is handed away. Otherwise " +
|
||||
"reserves the lowest free private-network address, asks the operator under the seat's own name and, on the " +
|
||||
"warrant, adds the node to placement with its tunnel key, asks the vault for the token and says enrolment-issued. " +
|
||||
"The token itself is read only through tokens.",
|
||||
Input: schema(map[string]string{
|
||||
"node": "the new machine's name in the mesh",
|
||||
"why": "why it joins, shown to the operator with the ask",
|
||||
"expires": "how long the token lasts, a duration such as 1h or 24h: one hour when not given, at most 24 hours",
|
||||
// The operator's decision of 2026-11-02 01:05: the tunnel key is given at issue, so on the Approve the node
|
||||
// enters placement with it and the hub peers it before redeem.
|
||||
"overlay_key": "the new machine's private network (tunnel) public key, as `nox-mesh-host key` prints it",
|
||||
}, []string{"node", "why", "overlay_key"})},
|
||||
{Name: mcat.EnrolmentVerbRedeem, Description: "Spend a token: asked by a joining node-engine dialled as enrol.<node> " +
|
||||
"alone, with the public halves of its keys and its identity key's proof over them. Spends the token, asks the " +
|
||||
"vault for the node's account and certificate, writes the node into placement, and answers the account sealed " +
|
||||
"to the node, its certificate and the vault's public half, signed by the vault. Asked again with the same keys " +
|
||||
"it finishes what is not done and answers the same; with other keys it is refused.",
|
||||
Input: map[string]any{"type": "object", "additionalProperties": false,
|
||||
"required": []string{"node", "public_key", "sealing_key", "proof"},
|
||||
"properties": map[string]any{
|
||||
"node": str("the node the token was issued for"),
|
||||
"public_key": str("the identity key's public half, base64"),
|
||||
"overlay_key": str("the private network key's public half"),
|
||||
"sealing_key": str("the sealing key's public half, which the account is sealed to"),
|
||||
"serving_key": str("the serving key's public half, which the certificate binds"),
|
||||
"profile": map[string]any{"type": "object", "description": "what the machine is, as its record says it"},
|
||||
"tunnel": map[string]any{"type": "object", "description": "a tunnel found on the machine: everything but its private key"},
|
||||
"proof": str("the identity key's signature over the redeem's proof bytes (delivery.RedeemProof), base64"),
|
||||
}}},
|
||||
{Name: mcat.EnrolmentVerbConfirm, Description: "Confirm an enrolment: asked by the node-engine dialled as its own " +
|
||||
"node login alone, the runtime's proof of that caller being the confirmation. Says node-enrolled and ends the " +
|
||||
"enrolment.",
|
||||
Input: schema(map[string]string{}, nil)},
|
||||
{Name: mcat.EnrolmentVerbRevoke, Description: "End an enrolment at any stage, with why, without an Approve: withdraws " +
|
||||
"an open ask, deletes the node's key and its address, has the vault retire an account it already minted for " +
|
||||
"it, records the act as a hand act and says enrolment-revoked.",
|
||||
Input: schema(map[string]string{"node": "the node", "why": "why, recorded with the hand act"},
|
||||
[]string{"node", "why"})},
|
||||
{Name: mcat.EnrolmentVerbTokens, Description: "Read every open enrolment, or one: its stage, expiry, ask and revision, " +
|
||||
"the bucket's fill against its cap. For a node at live, its " +
|
||||
"token sealed to the operator key, asked of the vault at that moment and relayed as the vault sealed it. With " +
|
||||
"history, the ended enrolments from the seat's events, as far back as the events are kept.",
|
||||
Input: schema(map[string]string{"node": "one node", "history": "\"true\": the ended enrolments too"},
|
||||
nil, "history")},
|
||||
{Name: mcat.EnrolmentVerbAgain, Description: "Raise an issue's ask again when the operator's channel does not hold it, " +
|
||||
"and nothing more.",
|
||||
Input: schema(map[string]string{"node": "the node whose ask is raised again"}, []string{"node"})},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,554 @@
|
||||
package resolution
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
|
||||
var knownShells = []string{"zsh", "bash", "fish"}
|
||||
|
||||
var knownSlots = []string{"first", "normal", "last"}
|
||||
|
||||
// sessionFiles are the two files of the graphical session's start that read no directory, so a
|
||||
// contribution to them is a slot rather than a drop-in (novox/hq ADR 0208 §4): `xinitrc` is POSIX
|
||||
// code the session's start runs, `xresources` X resources merged at its start. Placed by the
|
||||
// display server's holder, as a shell's slots are placed by the login shell's.
|
||||
var sessionFiles = []string{"xinitrc", "xresources"}
|
||||
|
||||
// powerMoments are the moments of a machine's power a module may run code at (novox/hq ADR
|
||||
// 0211 §3): POSIX code run as root by node-power's holder, in module order, each piece bounded.
|
||||
var powerMoments = []string{"after-boot", "before-sleep", "after-wake", "before-shutdown", "on-mains", "on-battery"}
|
||||
|
||||
// contributionTargets is every name a contribution's `for` may take.
|
||||
func contributionTargets() []string {
|
||||
out := append(append([]string(nil), knownShells...), sessionFiles...)
|
||||
return append(out, powerMoments...)
|
||||
}
|
||||
|
||||
// placerOf is the seat whose holder places a contribution for this target (novox/hq ADR 0204,
|
||||
// ADR 0208 §4).
|
||||
func placerOf(target string) string {
|
||||
if oneOf(sessionFiles, target) {
|
||||
return mcat.DisplayServerSeat
|
||||
}
|
||||
if oneOf(powerMoments, target) {
|
||||
return mcat.PowerSeat
|
||||
}
|
||||
return mcat.LoginShellSeat
|
||||
}
|
||||
|
||||
var ofEnvironment = regexp.MustCompile(`\$\{environment:([^}]*)\}`)
|
||||
|
||||
var ofShell = regexp.MustCompile(`\$\{shell:([^}]*)\}`)
|
||||
|
||||
// ofContributed is either kind of contributed text, matched together so both fill in one pass.
|
||||
var ofContributed = regexp.MustCompile(`\$\{(shell|contribution|backup):([^}]*)\}`)
|
||||
|
||||
// literalRule is why a value must be literal, said with every refusal of one.
|
||||
const literalRule = "a value is literal, so a POSIX shell and the service manager read it alike, and " +
|
||||
"names the machine only through the mesh's own ${machine:…} facts (novox/hq ADR 0203)"
|
||||
|
||||
// literalProblem is why a value cannot be written, unquoted by either reader, as the same string in
|
||||
// both formats — or nothing. A `$` would expand differently in each; a quote or a backslash is
|
||||
// quoting in one and a character in the other; a line break ends the line in both.
|
||||
func literalProblem(v string) string {
|
||||
switch {
|
||||
case strings.ContainsAny(v, `'"`):
|
||||
return "holds a quote"
|
||||
case strings.Contains(v, `\`):
|
||||
return "holds a backslash"
|
||||
case strings.ContainsAny(v, "\n\r"):
|
||||
return "holds a line break"
|
||||
case strings.ContainsRune(v, 0):
|
||||
return "holds a NUL"
|
||||
case strings.Contains(ofMachine.ReplaceAllString(v, ""), "$"):
|
||||
return "holds a $ that is not one of the machine's ${machine:…} facts"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// placeholderProblems is what is wrong with one resource's ${environment:…} and ${shell:…}.
|
||||
//
|
||||
// **The seat authorises it, not the placeholder** (novox/hq ADR 0203 §5, ADR 0204 §3), as the seat
|
||||
// authorises the bus's user list: a module that does not hold the account's environment writing it
|
||||
// would be a second writer of a file there is one of, and a module that does not hold the login
|
||||
// shell writing every module's shell code would be a second shell.
|
||||
func placeholderProblems(m mcat.Manifest, r map[string]any) []string {
|
||||
var problems []string
|
||||
for _, field := range sortedKeys(r) {
|
||||
s, ok := r[field].(string)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
env := ofEnvironment.FindAllStringSubmatch(s, -1)
|
||||
code := ofShell.FindAllStringSubmatch(s, -1)
|
||||
if len(env)+len(code) == 0 {
|
||||
continue
|
||||
}
|
||||
if field != "content" {
|
||||
// Placed only where a file's bytes are, which is where every one of them is meant to go:
|
||||
// a path or an owner holding several lines of shell is nothing the host could act on.
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s's resource %v names %s in its %s; the environment and the shell's code are placed "+
|
||||
"only in a file's content", m.Module, r["id"], placeholderOf(env, code), field))
|
||||
continue
|
||||
}
|
||||
for _, e := range env {
|
||||
if e[1] != mcat.EnvironmentPOSIX && e[1] != mcat.EnvironmentSystemd {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s's resource %v names %s; the environment is ${environment:%s} or ${environment:%s}",
|
||||
m.Module, r["id"], e[0], mcat.EnvironmentPOSIX, mcat.EnvironmentSystemd))
|
||||
}
|
||||
}
|
||||
if len(env) > 0 && !m.ClaimsSeat(mcat.EnvironmentSeat) {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s's resource %v names %s and %s does not claim %s; the account's environment is "+
|
||||
"written by that seat's holder alone (novox/hq ADR 0203)",
|
||||
m.Module, r["id"], env[0][0], m.Module, mcat.EnvironmentSeat))
|
||||
}
|
||||
// Each placeholder judged by its own target: a shell's code is the login shell's holder's to
|
||||
// place (ADR 0204), the session's files the display server's (ADR 0208 §4) — and a holder of
|
||||
// one placing the other's would be a second writer of a file there is one of.
|
||||
refusedFor := map[string]bool{}
|
||||
for _, c := range code {
|
||||
target, slot, two := strings.Cut(c[1], ":")
|
||||
if !two || !oneOf(contributionTargets(), target) || !oneOf(knownSlots, slot) {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s's resource %v names %s; shell code is ${shell:<shell>:<slot>}, the shell one of "+
|
||||
"%s, the session's file one of %s or the power moment one of %s, and the slot one of %s",
|
||||
m.Module, r["id"], c[0], strings.Join(knownShells, ", "), strings.Join(sessionFiles, ", "),
|
||||
strings.Join(powerMoments, ", "), strings.Join(knownSlots, ", ")))
|
||||
continue
|
||||
}
|
||||
seat := placerOf(target)
|
||||
if m.ClaimsSeat(seat) || refusedFor[seat] {
|
||||
continue
|
||||
}
|
||||
refusedFor[seat] = true
|
||||
if seat == mcat.PowerSeat {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s's resource %v names %s and %s does not claim %s; every module's code for a power "+
|
||||
"moment is placed by the power seat's holder alone (novox/hq ADR 0211)",
|
||||
m.Module, r["id"], c[0], m.Module, seat))
|
||||
continue
|
||||
}
|
||||
if seat == mcat.DisplayServerSeat {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s's resource %v names %s and %s does not claim %s; every module's %s is placed by "+
|
||||
"the display server's holder alone (novox/hq ADR 0208)",
|
||||
m.Module, r["id"], c[0], m.Module, seat, target))
|
||||
continue
|
||||
}
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s's resource %v names %s and %s does not claim %s; every module's shell code is "+
|
||||
"placed by the login shell's holder alone (novox/hq ADR 0204)",
|
||||
m.Module, r["id"], c[0], m.Module, seat))
|
||||
}
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
func placeholderOf(env, code [][]string) string {
|
||||
if len(env) > 0 {
|
||||
return env[0][0]
|
||||
}
|
||||
return code[0][0]
|
||||
}
|
||||
|
||||
// contributedEnvironment is one node's environment, gathered and in the order it is written.
|
||||
type contributedEnvironment struct {
|
||||
// variables is by module in name order, each module's sorted by name.
|
||||
variables []setBy
|
||||
// start and end are PATH's entries in their final order, each once.
|
||||
start, end []placedOn
|
||||
}
|
||||
|
||||
type setBy struct {
|
||||
module string
|
||||
names []string
|
||||
values map[string]string
|
||||
}
|
||||
|
||||
type placedOn struct {
|
||||
module, entry string
|
||||
}
|
||||
|
||||
// inModuleOrder is the modules sorted by name — the order contributions are written in (novox/hq
|
||||
// ADR 0203, ADR 0204), so the same set composes byte for byte whatever order they were assigned in.
|
||||
func inModuleOrder(modules []mcat.Manifest) []mcat.Manifest {
|
||||
out := append([]mcat.Manifest(nil), modules...)
|
||||
sort.SliceStable(out, func(a, b int) bool { return out[a].Module < out[b].Module })
|
||||
return out
|
||||
}
|
||||
|
||||
// variablesSetOnce refuses a variable two modules on one node both set (novox/hq ADR 0203 §5),
|
||||
// naming both. Neither is chosen: whichever was written last would win in one reader and not
|
||||
// necessarily in the other, and the module that lost would not be told.
|
||||
func variablesSetOnce(modules []mcat.Manifest) error {
|
||||
setter := map[string]string{}
|
||||
for _, m := range inModuleOrder(modules) {
|
||||
e := environmentOf(m)
|
||||
if e == nil {
|
||||
continue
|
||||
}
|
||||
for _, n := range sortedKeys(e.Variables) {
|
||||
if first, taken := setter[n]; taken {
|
||||
return fmt.Errorf(
|
||||
"%s and %s both set %s on this machine; the account has one environment, so one "+
|
||||
"of them must stop setting it (novox/hq ADR 0203)", first, m.Module, n)
|
||||
}
|
||||
setter[n] = m.Module
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// environmentOn gathers every module's environment on a node, with the machine's facts in place.
|
||||
//
|
||||
// A PATH entry two modules both add is written once, where the first puts it: two toolchains
|
||||
// sharing ~/.local/bin is ordinary, and nothing about it is in conflict.
|
||||
func environmentOn(modules []mcat.Manifest, facts map[string]string) (contributedEnvironment, error) {
|
||||
var env contributedEnvironment
|
||||
if err := variablesSetOnce(modules); err != nil {
|
||||
return env, err
|
||||
}
|
||||
placed := map[string]bool{}
|
||||
for _, m := range inModuleOrder(modules) {
|
||||
e := environmentOf(m)
|
||||
if e == nil {
|
||||
continue
|
||||
}
|
||||
if len(e.Variables) > 0 {
|
||||
set := setBy{module: m.Module, values: map[string]string{}}
|
||||
for _, n := range sortedKeys(e.Variables) {
|
||||
v, err := factsIn(e.Variables[n], facts, m.Module, n)
|
||||
if err != nil {
|
||||
return env, err
|
||||
}
|
||||
set.names = append(set.names, n)
|
||||
set.values[n] = v
|
||||
}
|
||||
env.variables = append(env.variables, set)
|
||||
}
|
||||
for _, p := range e.Path {
|
||||
entry, err := factsIn(p.Entry, facts, m.Module, "a PATH entry")
|
||||
if err != nil {
|
||||
return env, err
|
||||
}
|
||||
if strings.Contains(entry, ":") {
|
||||
return env, fmt.Errorf("%s puts %q on PATH on this machine, which holds a colon, PATH's own separator",
|
||||
m.Module, entry)
|
||||
}
|
||||
if placed[entry] {
|
||||
continue
|
||||
}
|
||||
placed[entry] = true
|
||||
if p.At == mcat.PathAtEnd {
|
||||
env.end = append(env.end, placedOn{m.Module, entry})
|
||||
} else {
|
||||
env.start = append(env.start, placedOn{m.Module, entry})
|
||||
}
|
||||
}
|
||||
}
|
||||
return env, nil
|
||||
}
|
||||
|
||||
// environmentOf is a module's environment as the old form and the items of the environment collection say it: an
|
||||
// item's template is the variable's value or the PATH entry, and its attributes the kind, the name and where on PATH
|
||||
// (design 57 L3, issue 795). A moved contributor's items are rendered here into a collector not handed, as the
|
||||
// provisioner's client renders them into one that is. An item whose text the mesh does not hold is not here; it is
|
||||
// said as not placed (itemsLeftOutFor).
|
||||
func environmentOf(m mcat.Manifest) *mcat.Environment {
|
||||
var out *mcat.Environment
|
||||
if m.Environment != nil {
|
||||
out = &mcat.Environment{Variables: map[string]string{}, Path: append([]mcat.PathEntry(nil), m.Environment.Path...)}
|
||||
for k, v := range m.Environment.Variables {
|
||||
out.Variables[k] = v
|
||||
}
|
||||
}
|
||||
for _, it := range itemsInOrder(m.Items) {
|
||||
if it.Collection != mcat.EnvironmentCollection || it.Content == "" {
|
||||
continue
|
||||
}
|
||||
if out == nil {
|
||||
out = &mcat.Environment{Variables: map[string]string{}}
|
||||
}
|
||||
value := strings.TrimSuffix(it.Content, "\n")
|
||||
switch it.Attributes["kind"] {
|
||||
case "variable":
|
||||
out.Variables[it.Attributes["name"]] = value
|
||||
case "path":
|
||||
out.Path = append(out.Path, mcat.PathEntry{Entry: value, At: it.Attributes["at"]})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// factsIn resolves a contributed value's ${machine:…} facts with this machine's — first, before
|
||||
// either format is written, so both say the same thing (novox/hq ADR 0203).
|
||||
func factsIn(v string, facts map[string]string, module, what string) (string, error) {
|
||||
for _, key := range machineUsed(v) {
|
||||
value, has := facts[key]
|
||||
if !has {
|
||||
return "", fmt.Errorf("%s sets %s to a value that says ${machine:%s}, and this machine says %s",
|
||||
module, what, key, orNothing(namesOfFacts(facts)))
|
||||
}
|
||||
v = strings.ReplaceAll(v, fmt.Sprintf("${machine:%s}", key), value)
|
||||
}
|
||||
// Judged again once filled: a fact is the mesh's, and still has to be a literal both readers
|
||||
// take alike.
|
||||
if why := literalProblem(v); why != "" {
|
||||
return "", fmt.Errorf("%s sets %s to %q on this machine, which %s — %s", module, what, v, why, literalRule)
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// posix is the environment as lines a POSIX shell sources (novox/hq ADR 0203 §3): every variable
|
||||
// exported, every PATH entry added only when it is missing, so sourcing the file twice — a login
|
||||
// shell that starts another — changes nothing. POSIX sh only, because sh, bash and zsh all read it.
|
||||
//
|
||||
// The start entries are written last-first: each is put in front of PATH, so the last written ends
|
||||
// up first, and the result reads in module order, then the order each module declared.
|
||||
func (e contributedEnvironment) posix() string {
|
||||
var b strings.Builder
|
||||
for _, set := range e.variables {
|
||||
fmt.Fprintf(&b, "# %s\n", set.module)
|
||||
for _, n := range set.names {
|
||||
fmt.Fprintf(&b, "export %s='%s'\n", n, set.values[n])
|
||||
}
|
||||
}
|
||||
named := ""
|
||||
for i := len(e.start) - 1; i >= 0; i-- {
|
||||
p := e.start[i]
|
||||
if p.module != named {
|
||||
fmt.Fprintf(&b, "# %s\n", p.module)
|
||||
named = p.module
|
||||
}
|
||||
fmt.Fprintf(&b, "case \":${PATH}:\" in *':%s:'*) ;; *) PATH='%s'\"${PATH:+:${PATH}}\" ;; esac\n",
|
||||
p.entry, p.entry)
|
||||
}
|
||||
named = ""
|
||||
for _, p := range e.end {
|
||||
if p.module != named {
|
||||
fmt.Fprintf(&b, "# %s\n", p.module)
|
||||
named = p.module
|
||||
}
|
||||
fmt.Fprintf(&b, "case \":${PATH}:\" in *':%s:'*) ;; *) PATH=\"${PATH:+${PATH}:}\"'%s' ;; esac\n",
|
||||
p.entry, p.entry)
|
||||
}
|
||||
if len(e.start)+len(e.end) > 0 {
|
||||
b.WriteString("export PATH\n")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// systemd is the same environment as the service manager's environment.d reads it (novox/hq ADR
|
||||
// 0203 §3), for the account's user manager and so for everything a graphical session starts. Read
|
||||
// once per manager start, so it needs no guard against running twice; the account's existing PATH
|
||||
// sits between the start and the end entries.
|
||||
func (e contributedEnvironment) systemd() string {
|
||||
var b strings.Builder
|
||||
for _, set := range e.variables {
|
||||
fmt.Fprintf(&b, "# %s\n", set.module)
|
||||
for _, n := range set.names {
|
||||
fmt.Fprintf(&b, "%s=%s\n", n, set.values[n])
|
||||
}
|
||||
}
|
||||
if len(e.start) > 0 {
|
||||
fmt.Fprintf(&b, "# %s\nPATH=%s${PATH:+:$PATH}\n", modulesOf(e.start), entriesOf(e.start))
|
||||
}
|
||||
if len(e.end) > 0 {
|
||||
fmt.Fprintf(&b, "# %s\nPATH=${PATH:+$PATH:}%s\n", modulesOf(e.end), entriesOf(e.end))
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// modulesOf names who contributed a line holding several modules' entries, in the order they appear.
|
||||
func modulesOf(entries []placedOn) string {
|
||||
var names []string
|
||||
seen := map[string]bool{}
|
||||
for _, p := range entries {
|
||||
if !seen[p.module] {
|
||||
seen[p.module] = true
|
||||
names = append(names, p.module)
|
||||
}
|
||||
}
|
||||
return strings.Join(names, ", ")
|
||||
}
|
||||
|
||||
func entriesOf(entries []placedOn) string {
|
||||
out := make([]string, len(entries))
|
||||
for i, p := range entries {
|
||||
out[i] = p.entry
|
||||
}
|
||||
return strings.Join(out, ":")
|
||||
}
|
||||
|
||||
// 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).
|
||||
func oldFormOfCollected(content string) string {
|
||||
return ofCollected.ReplaceAllStringFunc(content, func(placeholder string) string {
|
||||
name := ofCollected.FindStringSubmatch(placeholder)[1]
|
||||
base, _ := splitSelector(name)
|
||||
if format, is := strings.CutPrefix(base, mcat.EnvironmentCollection+":"); is {
|
||||
return "${environment:" + format + "}"
|
||||
}
|
||||
if !strings.HasPrefix(base, "contribution:") {
|
||||
return placeholder
|
||||
}
|
||||
seat, kind, _ := strings.Cut(strings.TrimPrefix(base, "contribution:"), ":")
|
||||
if _, r, ok := receivable(seat, kind); !ok || !collectableAsText(r) {
|
||||
return placeholder
|
||||
}
|
||||
return "${" + name + "}"
|
||||
})
|
||||
}
|
||||
|
||||
// shellCodeFor is shellCodeOn on a machine with these capabilities, which an item's `if-capability` is judged by. The
|
||||
// pieces are placed by order (an item's `order` attribute, the default for the old form), then module, as the
|
||||
// provisioner's client places them.
|
||||
func shellCodeFor(modules []mcat.Manifest, shell, slot string, facts map[string]string, caps map[string]bool) string {
|
||||
var blocks []textBlock
|
||||
for _, m := range inModuleOrder(modules) {
|
||||
var old strings.Builder
|
||||
for _, c := range m.Shell {
|
||||
if c.For != shell || c.Slot != slot {
|
||||
continue
|
||||
}
|
||||
if old.Len() == 0 {
|
||||
fmt.Fprintf(&old, "# %s\n", m.Module)
|
||||
}
|
||||
old.WriteString(c.Code)
|
||||
if !strings.HasSuffix(c.Code, "\n") {
|
||||
old.WriteString("\n")
|
||||
}
|
||||
}
|
||||
blocks = append(blocks, moduleBlocks(m, "#", old.String(), heldItems(m, "shell:"+shell+":"+slot, nil, facts, caps))...)
|
||||
}
|
||||
return joinBlocks(blocks)
|
||||
}
|
||||
|
||||
// textBlock is a stretch of a collector's text and the order it is placed by.
|
||||
type textBlock struct {
|
||||
order int
|
||||
text string
|
||||
}
|
||||
|
||||
// moduleBlocks is one module's pieces of a placeholder: what its old form made (placed by the default order), and
|
||||
// each of its held items under a line naming the module, as the provisioner's client writes one per item. An item
|
||||
// of the default order that follows the old form's text shares its line, as a module's item always did.
|
||||
func moduleBlocks(m mcat.Manifest, comment, old string, items []heldItem) []textBlock {
|
||||
var blocks []textBlock
|
||||
if old != "" {
|
||||
blocks = append(blocks, textBlock{mcat.OrderDefault, old})
|
||||
}
|
||||
for i, h := range items {
|
||||
text := h.text
|
||||
if i == 0 && old != "" && itemOrder(h.item) == mcat.OrderDefault {
|
||||
blocks[0].text += text
|
||||
continue
|
||||
}
|
||||
blocks = append(blocks, textBlock{itemOrder(h.item), fmt.Sprintf("%s %s\n", comment, m.Module) + text})
|
||||
}
|
||||
return blocks
|
||||
}
|
||||
|
||||
// joinBlocks is the blocks by order, those of one order in the order given (module order).
|
||||
func joinBlocks(blocks []textBlock) string {
|
||||
sort.SliceStable(blocks, func(a, b int) bool { return blocks[a].order < blocks[b].order })
|
||||
var b strings.Builder
|
||||
for _, blk := range blocks {
|
||||
b.WriteString(blk.text)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// contributionsInto fills a holder's file with the node's environment and its shell code.
|
||||
//
|
||||
// **Last, after every other placeholder pass, and in one pass each.** Shell code is contributed text
|
||||
// in a shell's own syntax — `${XDG_CACHE_HOME:-$HOME/.cache}`, `${(%):-%n}` — and the rendered
|
||||
// environment holds `${PATH:+…}`: a scanner for the mesh's own placeholders that ran after these
|
||||
// were in place would read the shell's expansions as the mesh's and refuse them, or fill a
|
||||
// `${machine:…}` some module wrote for its shell to see. So nothing runs after them, the environment
|
||||
// is filled before the shell's code is, and each is replaced in a single pass over what the holder
|
||||
// wrote, so a contributed piece is never scanned again.
|
||||
func contributionsInto(resource map[string]any, m mcat.Manifest, modules []mcat.Manifest, facts map[string]string, with mcat.Rendering, caps map[string]bool, unplaced *[]string) error {
|
||||
problems := append(placeholderProblems(m, resource), seatPlaceholderProblems(m, resource)...)
|
||||
if problems = append(problems, collectedPlaceholderProblems(m, resource)...); len(problems) > 0 {
|
||||
return fmt.Errorf("%s", problems[0])
|
||||
}
|
||||
judged, err := consumersNotHanded(m, resource, with)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if judged {
|
||||
resource["content"] = ofConsumers.ReplaceAllString(fmt.Sprint(resource["content"]), "")
|
||||
}
|
||||
content, ok := resource["content"].(string)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
content = oldFormOfCollected(content)
|
||||
// **An item is the provisioner client's** (design 57 L1): a collector filled here, the old way, holds the old
|
||||
// forms alone, and each item of its collections is said as not placed. A collection of the new form is empty.
|
||||
if unplaced != nil {
|
||||
for _, f := range templatePlaceholder.FindAllStringSubmatch(content, -1) {
|
||||
if collection, is := collectionOfPlaceholder(f[1], f[2]); is {
|
||||
_, selector := splitSelector(f[2])
|
||||
where, _ := selectorOf(selector)
|
||||
*unplaced = append(*unplaced, itemsLeftOutFor(modules, m, collection, where, facts, caps)...)
|
||||
}
|
||||
}
|
||||
}
|
||||
content = ofCollected.ReplaceAllString(content, "")
|
||||
if ofEnvironment.MatchString(content) {
|
||||
env, err := environmentOn(modules, facts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
content = ofEnvironment.ReplaceAllStringFunc(content, func(placeholder string) string {
|
||||
if ofEnvironment.FindStringSubmatch(placeholder)[1] == mcat.EnvironmentSystemd {
|
||||
return env.systemd()
|
||||
}
|
||||
return env.posix()
|
||||
})
|
||||
}
|
||||
// Shell code and seat contributions in one pass (novox/hq ADR 0212): both are contributed text
|
||||
// the controller does not read, so neither may be scanned after the other is in place — a
|
||||
// contributed line that happened to spell the other's placeholder would be filled.
|
||||
if ofContributed.MatchString(content) {
|
||||
var failed error
|
||||
content = ofContributed.ReplaceAllStringFunc(content, func(placeholder string) string {
|
||||
found := ofContributed.FindStringSubmatch(placeholder)
|
||||
first, second, _ := strings.Cut(found[2], ":")
|
||||
if found[1] == mcat.BackupNamespace {
|
||||
placed, problem := backupPlacer{modules, with, facts, caps, unplaced}.placed(m, found[2], false)
|
||||
if problem != "" && failed == nil {
|
||||
failed = fmt.Errorf("%s names %s: %s", m.Module, placeholder, problem)
|
||||
}
|
||||
return placed
|
||||
}
|
||||
if found[1] == "contribution" {
|
||||
placed, left, err := seatContributions(modules, m, found[2], with, facts, caps)
|
||||
if err != nil && failed == nil {
|
||||
failed = err
|
||||
}
|
||||
if unplaced != nil {
|
||||
*unplaced = append(*unplaced, left...)
|
||||
}
|
||||
return placed
|
||||
}
|
||||
return shellCodeFor(modules, first, second, facts, caps)
|
||||
})
|
||||
if failed != nil {
|
||||
return failed
|
||||
}
|
||||
}
|
||||
resource["content"] = content
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
package resolution
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
|
||||
// contributors is a fixed set of contributions, in no particular order: what the renderings are
|
||||
// asserted against byte for byte. go-toolchain and zsh both put ~/.local/bin on PATH, which is the
|
||||
// ordinary case of two modules sharing a directory, and is written once.
|
||||
func contributors() []mcat.Manifest {
|
||||
return []mcat.Manifest{
|
||||
{Module: "zsh", Environment: &mcat.Environment{
|
||||
Variables: map[string]string{"XDG_CONFIG_HOME": "${machine:account-home}/.config", "EDITOR": "vim"},
|
||||
Path: []mcat.PathEntry{
|
||||
{Entry: "${machine:account-home}/.local/bin", At: mcat.PathAtStart},
|
||||
{Entry: "${machine:account-home}/bin", At: mcat.PathAtStart},
|
||||
{Entry: "/opt/scripts", At: mcat.PathAtEnd},
|
||||
},
|
||||
}},
|
||||
{Module: "go-toolchain", Environment: &mcat.Environment{
|
||||
Variables: map[string]string{"GOPATH": "${machine:account-home}/go"},
|
||||
Path: []mcat.PathEntry{
|
||||
{Entry: "${machine:account-home}/go/bin", At: mcat.PathAtStart},
|
||||
{Entry: "/usr/local/go/bin", At: mcat.PathAtStart},
|
||||
{Entry: "${machine:account-home}/.local/bin", At: mcat.PathAtStart},
|
||||
},
|
||||
}},
|
||||
{Module: "agent", Environment: &mcat.Environment{
|
||||
Variables: map[string]string{"DISABLE_AUTOUPDATER": "1"},
|
||||
Path: []mcat.PathEntry{{Entry: "/opt/agent/bin", At: mcat.PathAtEnd}},
|
||||
}},
|
||||
// A module contributing nothing is in the set and writes nothing.
|
||||
{Module: "postgres"},
|
||||
}
|
||||
}
|
||||
|
||||
var operatorFacts = map[string]string{"name": "workstation", "account": "op", "account-home": "/home/op"}
|
||||
|
||||
// The final PATH this set composes, around whatever the account had: the start entries in module
|
||||
// order and then declared order, the account's own, then the end entries.
|
||||
const composedPOSIX = `# agent
|
||||
export DISABLE_AUTOUPDATER='1'
|
||||
# go-toolchain
|
||||
export GOPATH='/home/op/go'
|
||||
# zsh
|
||||
export EDITOR='vim'
|
||||
export XDG_CONFIG_HOME='/home/op/.config'
|
||||
# zsh
|
||||
case ":${PATH}:" in *':/home/op/bin:'*) ;; *) PATH='/home/op/bin'"${PATH:+:${PATH}}" ;; esac
|
||||
# go-toolchain
|
||||
case ":${PATH}:" in *':/home/op/.local/bin:'*) ;; *) PATH='/home/op/.local/bin'"${PATH:+:${PATH}}" ;; esac
|
||||
case ":${PATH}:" in *':/usr/local/go/bin:'*) ;; *) PATH='/usr/local/go/bin'"${PATH:+:${PATH}}" ;; esac
|
||||
case ":${PATH}:" in *':/home/op/go/bin:'*) ;; *) PATH='/home/op/go/bin'"${PATH:+:${PATH}}" ;; esac
|
||||
# agent
|
||||
case ":${PATH}:" in *':/opt/agent/bin:'*) ;; *) PATH="${PATH:+${PATH}:}"'/opt/agent/bin' ;; esac
|
||||
# zsh
|
||||
case ":${PATH}:" in *':/opt/scripts:'*) ;; *) PATH="${PATH:+${PATH}:}"'/opt/scripts' ;; esac
|
||||
export PATH
|
||||
`
|
||||
|
||||
const composedSystemd = `# agent
|
||||
DISABLE_AUTOUPDATER=1
|
||||
# go-toolchain
|
||||
GOPATH=/home/op/go
|
||||
# zsh
|
||||
EDITOR=vim
|
||||
XDG_CONFIG_HOME=/home/op/.config
|
||||
# go-toolchain, zsh
|
||||
PATH=/home/op/go/bin:/usr/local/go/bin:/home/op/.local/bin:/home/op/bin${PATH:+:$PATH}
|
||||
# agent, zsh
|
||||
PATH=${PATH:+$PATH:}/opt/agent/bin:/opt/scripts
|
||||
`
|
||||
|
||||
func TestTheEnvironmentRendersForAPOSIXShellByteForByte(t *testing.T) {
|
||||
env, err := environmentOn(contributors(), operatorFacts)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := env.posix(); got != composedPOSIX {
|
||||
t.Fatalf("the POSIX rendering is\n%s\nnot\n%s", got, composedPOSIX)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheEnvironmentRendersForTheServiceManagerByteForByte(t *testing.T) {
|
||||
env, err := environmentOn(contributors(), operatorFacts)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := env.systemd(); got != composedSystemd {
|
||||
t.Fatalf("the environment.d rendering is\n%s\nnot\n%s", got, composedSystemd)
|
||||
}
|
||||
}
|
||||
|
||||
// Nothing contributed renders nothing, in both formats — not an empty `export PATH`.
|
||||
func TestNoContributionsRenderNothing(t *testing.T) {
|
||||
env, err := environmentOn([]mcat.Manifest{{Module: "postgres"}}, operatorFacts)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if env.posix() != "" || env.systemd() != "" {
|
||||
t.Fatalf("an empty environment rendered %q and %q", env.posix(), env.systemd())
|
||||
}
|
||||
}
|
||||
|
||||
// A ${machine:…} fact the machine does not have is refused naming the module, as a file's is.
|
||||
func TestAContributedFactTheMachineLacksIsRefused(t *testing.T) {
|
||||
_, err := environmentOn(contributors(), map[string]string{"name": "server"})
|
||||
if err == nil || !strings.Contains(err.Error(), "go-toolchain sets GOPATH") ||
|
||||
!strings.Contains(err.Error(), "${machine:account-home}") {
|
||||
t.Fatalf("a missing account home was not refused by name: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ADR 0203 §5: two modules setting one variable are refused, both named — neither silently wins.
|
||||
func TestAVariableTwoModulesSetIsRefusedNamingBoth(t *testing.T) {
|
||||
modules := append(contributors(), mcat.Manifest{Module: "neovim", Environment: &mcat.Environment{
|
||||
Variables: map[string]string{"EDITOR": "nvim"}}})
|
||||
_, err := environmentOn(modules, operatorFacts)
|
||||
if err == nil || err.Error() != "neovim and zsh both set EDITOR on this machine; the account has one "+
|
||||
"environment, so one of them must stop setting it (novox/hq ADR 0203)" {
|
||||
t.Fatalf("a variable set twice was not refused naming both: %v", err)
|
||||
}
|
||||
// And at composition, whether or not the node holds the environment.
|
||||
r := mcat.Resolution{Node: "workstation", Account: "op", Modules: modules}
|
||||
if _, err := Declaration(r, mcat.Rendering{}); err == nil || !strings.Contains(err.Error(), "neovim and zsh both set EDITOR") {
|
||||
t.Fatalf("composition accepted a variable set twice: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// shells contributes code for several shells and slots, in no order.
|
||||
func shells() []mcat.Manifest {
|
||||
return []mcat.Manifest{
|
||||
{Module: "zsh-syntax-highlighting", Shell: []mcat.ShellCode{
|
||||
{For: "zsh", Slot: "last", Code: "source /usr/share/zsh/plugins/zsh-syntax-highlighting/zsh-syntax-highlighting.zsh"},
|
||||
}},
|
||||
{Module: "powerlevel10k", Shell: []mcat.ShellCode{
|
||||
{For: "zsh", Slot: "first", Code: "if [[ -r \"${XDG_CACHE_HOME:-$HOME/.cache}/p10k-instant-prompt-${(%):-%n}.zsh\" ]]; then\n" +
|
||||
" source \"${XDG_CACHE_HOME:-$HOME/.cache}/p10k-instant-prompt-${(%):-%n}.zsh\"\nfi\n"},
|
||||
{For: "zsh", Slot: "normal", Code: "source ~/.local/share/powerlevel10k/powerlevel10k.zsh-theme"},
|
||||
{For: "zsh", Slot: "normal", Code: "[[ -f ~/.local/share/powerlevel10k/p10k.zsh ]] && source ~/.local/share/powerlevel10k/p10k.zsh"},
|
||||
}},
|
||||
{Module: "zsh-autosuggestions", Shell: []mcat.ShellCode{
|
||||
{For: "zsh", Slot: "normal", Code: "source /usr/share/zsh/plugins/zsh-autosuggestions/zsh-autosuggestions.zsh"},
|
||||
{For: "bash", Slot: "normal", Code: "echo not for zsh"},
|
||||
}},
|
||||
{Module: "direnv", Shell: []mcat.ShellCode{
|
||||
{For: "fish", Slot: "last", Code: "direnv hook fish | source"},
|
||||
{For: "bash", Slot: "last", Code: "eval \"$(direnv hook bash)\""},
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
// 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() mcat.Manifest {
|
||||
return mcat.Manifest{Module: "zsh", Claims: []mcat.Claim{{Name: mcat.LoginShellSeat, Scope: mcat.ScopeNode}},
|
||||
Resources: []map[string]any{
|
||||
{"id": "zshrc", "type": "file", "path": "${machine:account-home}/.zshrc", "content": "" +
|
||||
"${shell:zsh:first}" +
|
||||
"PROMPT='%n@%m ${PWD/#$HOME/~} '\n" +
|
||||
"${shell:zsh:normal}" +
|
||||
"alias ll='ls -l'\n" +
|
||||
"${shell:zsh:last}"},
|
||||
}}
|
||||
}
|
||||
|
||||
// The case the ordering exists for: contributed zsh code full of `${…}` reaches the file byte for
|
||||
// byte, because the shell's code is placed after every other placeholder pass and in one pass — a
|
||||
// scanner for the mesh's placeholders that ran after it would read `${XDG_CACHE_HOME:-…}` and
|
||||
// `${(%):-%n}` as the mesh's, or fill a `${machine:…}` some module wrote for its shell to see.
|
||||
func TestShellCodeReachesTheHoldersFileByteForByte(t *testing.T) {
|
||||
modules := append(shells(), zshHolder(), mcat.Manifest{Module: "sly", Shell: []mcat.ShellCode{
|
||||
{For: "zsh", Slot: "last", Code: "echo ${machine:account-home} ${secret:x} ${shell:zsh:first} ${environment:posix}"},
|
||||
}})
|
||||
r := mcat.Resolution{Node: "workstation", Account: "op", Modules: modules}
|
||||
out, err := Declaration(r, mcat.Rendering{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var zshrc map[string]any
|
||||
for _, res := range out {
|
||||
if res["id"] == "zsh.zshrc" {
|
||||
zshrc = res
|
||||
}
|
||||
}
|
||||
if zshrc == nil {
|
||||
t.Fatalf("the holder's file was not composed: %v", out)
|
||||
}
|
||||
if zshrc["path"] != "/home/op/.zshrc" {
|
||||
t.Fatalf("the holder's own placeholders were not filled first: %v", zshrc["path"])
|
||||
}
|
||||
want := "# powerlevel10k\n" +
|
||||
"if [[ -r \"${XDG_CACHE_HOME:-$HOME/.cache}/p10k-instant-prompt-${(%):-%n}.zsh\" ]]; then\n" +
|
||||
" source \"${XDG_CACHE_HOME:-$HOME/.cache}/p10k-instant-prompt-${(%):-%n}.zsh\"\nfi\n" +
|
||||
"PROMPT='%n@%m ${PWD/#$HOME/~} '\n" +
|
||||
"# 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" +
|
||||
"alias ll='ls -l'\n" +
|
||||
"# sly\n" +
|
||||
"echo ${machine:account-home} ${secret:x} ${shell:zsh:first} ${environment:posix}\n" +
|
||||
"# zsh-syntax-highlighting\n" +
|
||||
"source /usr/share/zsh/plugins/zsh-syntax-highlighting/zsh-syntax-highlighting.zsh\n"
|
||||
if got := zshrc["content"]; got != want {
|
||||
t.Fatalf("the holder's .zshrc is\n%s\nnot\n%s", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// The holder of node-environment places both renderings, and they are the same as rendered alone.
|
||||
func TestTheEnvironmentHolderPlacesBothRenderings(t *testing.T) {
|
||||
holder := mcat.Manifest{Module: "node-env", Claims: []mcat.Claim{{Name: mcat.EnvironmentSeat, Scope: mcat.ScopeNode}},
|
||||
Resources: []map[string]any{
|
||||
{"id": "posix", "type": "file", "path": "${machine:account-home}/.config/mesh/environment.sh",
|
||||
"content": "# The mesh's environment.\n${environment:posix}"},
|
||||
{"id": "systemd", "type": "file", "path": "${machine:account-home}/.config/environment.d/50-mesh.conf",
|
||||
"content": "${environment:systemd}"},
|
||||
}}
|
||||
r := mcat.Resolution{Node: "workstation", Account: "op", Modules: append(contributors(), holder)}
|
||||
out, err := Declaration(r, mcat.Rendering{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
by := map[string]any{}
|
||||
for _, res := range out {
|
||||
by[res["id"].(string)] = res["content"]
|
||||
}
|
||||
if by["node-env.posix"] != "# The mesh's environment.\n"+composedPOSIX {
|
||||
t.Fatalf("the POSIX file is\n%v", by["node-env.posix"])
|
||||
}
|
||||
if by["node-env.systemd"] != composedSystemd {
|
||||
t.Fatalf("the environment.d file is\n%v", by["node-env.systemd"])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package resolution
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
|
||||
func TestTwoModulesSettingOneVariableAreRefused(t *testing.T) {
|
||||
// A module that sets a variable twice, in two modules, is refused as the old form refused it.
|
||||
a := mcat.Manifest{Module: "a", Items: []mcat.Item{{Collection: "environment", Content: "1\n",
|
||||
Attributes: map[string]string{"kind": "variable", "name": "X"}}}}
|
||||
b := mcat.Manifest{Module: "b", Items: []mcat.Item{{Collection: "environment", Content: "2\n",
|
||||
Attributes: map[string]string{"kind": "variable", "name": "X"}}}}
|
||||
if err := variablesSetOnce([]mcat.Manifest{a, b}); err == nil || !strings.Contains(err.Error(), "both set X") {
|
||||
t.Errorf("two modules setting X: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package resolution
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
|
||||
const filesAsAccount = "as the node's operator account, through sudo -n where its rights do not reach; a place a " +
|
||||
"secret is kept, or a known credential file, is refused by name"
|
||||
|
||||
const overrideWord = "a reason, to change a path a module owns anyway (optional)"
|
||||
|
||||
// filesVerbs is the contract every holder of node-files serves (novox/hq ADR 0394 §1): the reads of node-reads,
|
||||
// with their descriptions restated for the operator's account, and the writes.
|
||||
func filesVerbs() []mcat.Verb {
|
||||
var verbs []mcat.Verb
|
||||
for _, v := range readsVerbs() {
|
||||
v.Description = strings.Replace(v.Description,
|
||||
"Read as the holder's own account, which holds no secret: a component it may not reach says so.",
|
||||
"", 1)
|
||||
v.Description = strings.TrimSpace(v.Description) + " Read " + filesAsAccount + "."
|
||||
verbs = append(verbs, v)
|
||||
}
|
||||
write := func(name, description string, in map[string]string, required []string, optional []string, replaces ...string) mcat.Verb {
|
||||
in["reason"] = "why the file is changed, for the audit trail"
|
||||
in["override"] = overrideWord
|
||||
return mcat.Verb{Name: name, Description: description + " Refuses a path a module owns " +
|
||||
"unless override names a reason; done as the node's operator account, through sudo -n where its rights do not reach.",
|
||||
Input: schema(in, append(required, "reason"), append(optional, "override")...), Replaces: replaces}
|
||||
}
|
||||
return append(verbs,
|
||||
write("write", "Write one text file whole, replacing what it holds; the old content goes to the node's trash.",
|
||||
map[string]string{"path": "an absolute path", "content": "the text to write",
|
||||
"mode": "the file's mode, such as 0644 (optional)"},
|
||||
[]string{"path", "content"}, []string{"mode"}, "tee", ">"),
|
||||
write("copy", "Copy a file or directory to a path on this node. A target on another node (<node>:/path) is "+
|
||||
"refused: copying between nodes comes in step 2 (issue 903). An existing target is overwritten, the old one "+
|
||||
"kept in the node's trash for 7 days.",
|
||||
map[string]string{"from": "an absolute path", "to": "an absolute path on this node"},
|
||||
[]string{"from", "to"}, nil, "cp", "cp -a", "ssh <node> cp"),
|
||||
write("move", "Move or rename a file or directory. A target on another node is refused: copying between nodes "+
|
||||
"comes in step 2 (issue 903). An existing target is overwritten, the old one kept in the node's trash for 7 days.",
|
||||
map[string]string{"from": "an absolute path", "to": "an absolute path"},
|
||||
[]string{"from", "to"}, nil, "mv"),
|
||||
write("delete", "Delete a file or directory into the node's trash, where restore can bring it back.",
|
||||
map[string]string{"path": "an absolute path", "recursive": "\"true\": delete a directory with what it holds"},
|
||||
[]string{"path"}, []string{"recursive"}, "rm", "rm -r"),
|
||||
write("mkdir", "Make a directory.",
|
||||
map[string]string{"path": "an absolute path", "parents": "\"true\": make the missing parents too"},
|
||||
[]string{"path"}, []string{"parents"}, "mkdir", "mkdir -p"),
|
||||
write("chmod", "Change the mode of a file or directory.",
|
||||
map[string]string{"path": "an absolute path", "mode": "the mode, such as 0644 or u+x",
|
||||
"recursive": "\"true\": also everything under a directory"},
|
||||
[]string{"path", "mode"}, []string{"recursive"}, "chmod"),
|
||||
write("chown", "Change the owner and group of a file or directory.",
|
||||
map[string]string{"path": "an absolute path", "owner": "an account's name or number",
|
||||
"group": "a group's name or number (optional)", "recursive": "\"true\": also everything under a directory"},
|
||||
[]string{"path", "owner"}, []string{"group", "recursive"}, "chown", "chgrp"), mcat.Verb{Name: "upload", Description: "Send one file of this node into a bucket of the object store, over a short signed link " +
|
||||
"and never over the bus; answers the key (the file's SHA-256 when none is given), the size and the SHA-256. " +
|
||||
"At most 100 MiB. A place a secret is kept, a known credential file, or a link to one is refused by name, " +
|
||||
"before anything is sent. Read " + filesAsAccount + ".",
|
||||
Input: schema(map[string]string{"path": "an absolute path to a file", "bucket": "the bucket the file goes into",
|
||||
"key": "the object's key (optional; the file's SHA-256 when not given)"}, []string{"path", "bucket"})}, mcat.
|
||||
// download is a plain Verb, not through write(): write() types its optional arguments as true/false
|
||||
// switches, which a hash and a reason are not (issue 1719).
|
||||
Verb{Name: "download", Description: "Write one object of a bucket to a file of this node, over a short signed link and " +
|
||||
"never over the bus; the old file goes to the node's trash. At most 100 MiB. The bytes must hash to sha256, or to " +
|
||||
"the key when the key is a SHA-256; otherwise no file is written. Refuses a path a module owns " +
|
||||
"unless override names a reason; done as the node's operator account, through sudo -n where its rights do not reach.",
|
||||
Input: schema(map[string]string{"bucket": "the bucket", "key": "the object's key", "path": "an absolute path",
|
||||
"sha256": "the SHA-256 the bytes must have (optional; the key when that is a SHA-256)",
|
||||
"reason": "why the file is changed, for the audit trail",
|
||||
"override": overrideWord}, []string{"bucket", "key", "path", "reason"})}, mcat.Verb{Name: "trash", Description: "What the node's trash holds: each deleted or replaced item with its id, " +
|
||||
"original path and when it went.",
|
||||
Input: schema(map[string]string{}, nil), Replaces: []string{"ls ~/.local/share/Trash"}}, write("restore", "Put an item of the node's trash back where it was; refuses when something is there now.",
|
||||
map[string]string{"id": "an id from trash"}, []string{"id"}, nil, "trash-restore"),
|
||||
)
|
||||
}
|
||||
|
||||
// ownedPaths is every path a module owns on this node, as the holder is told (novox/hq ADR 0394 §5): the
|
||||
// paths every seat declares (ADR 0287) with the module holding the seat here, and the paths and files each
|
||||
// module's manifest places (declaredPaths). A seat's path wins where both name one.
|
||||
func resolutionOwnedPaths(r mcat.Resolution) map[string]string {
|
||||
owned := map[string]string{}
|
||||
for _, m := range r.Modules {
|
||||
for _, p := range sortedKeys(declaredPaths(m)) {
|
||||
owned[p] = m.Module + " (its own tools)"
|
||||
}
|
||||
}
|
||||
for _, s := range mcat.Seats() {
|
||||
for _, sp := range s.Paths {
|
||||
for _, m := range r.Modules {
|
||||
if m.ClaimsSeat(s.Name) && declaresPath(m, path.Clean(sp.Path)) && !resolutionStepsBackHere(r, m, sp) {
|
||||
owned[sp.Path] = m.Module + " (" + s.Name + ")"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return owned
|
||||
}
|
||||
|
||||
// stepsBackHere says m's seat path steps back on this machine (novox/hq ADR 0287): it names a seat whose holder here
|
||||
// declares the path too, so that holder owns it, as stepsBack leaves m's own rendering of it out. Without it the owner
|
||||
// told was the last seat in the order of the seat table, which the controller reads sorted by name and a node in the
|
||||
// order compiled (issue 1126, C3: /etc/resolv.conf, node-uplink against node-resolver).
|
||||
func resolutionStepsBackHere(r mcat.Resolution, m mcat.Manifest, sp mcat.SeatPath) bool {
|
||||
if sp.StepsBackFor == "" {
|
||||
return false
|
||||
}
|
||||
for _, o := range r.Modules {
|
||||
if o.Module != m.Module && holdsSeat(o, sp.StepsBackFor) && declaresPath(o, path.Clean(sp.Path)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// filesWords is what the holder of node-files is given beside its own words: the node's secret places and the
|
||||
// paths modules own here.
|
||||
func resolutionFilesWords(r mcat.Resolution, m mcat.Manifest, words map[string]string, with mcat.Rendering) (map[string]string, error) {
|
||||
if !m.ClaimsSeat(mcat.FilesSeat) {
|
||||
return words, nil
|
||||
}
|
||||
places, err := resolutionSecretPlaces(r, with)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
places2, err := json.Marshal(places)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
owned, err := json.Marshal(resolutionOwnedPaths(r))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if words == nil {
|
||||
words = map[string]string{}
|
||||
}
|
||||
for _, w := range []string{mcat.ReadsSecretPlaces, mcat.FilesOwnedPaths} {
|
||||
if _, has := words[w]; has {
|
||||
return nil, fmt.Errorf("%s gives its own %s; the node's secret places and owned paths are the mesh's to "+
|
||||
"say (novox/hq ADR 0394 §5)", m.Module, w)
|
||||
}
|
||||
}
|
||||
words[mcat.ReadsSecretPlaces] = string(places2)
|
||||
words[mcat.FilesOwnedPaths] = string(owned)
|
||||
return words, nil
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package resolution
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
|
||||
// issue 900, ADR 0394 §4: the holder is told the secret places and the paths modules own on its node.
|
||||
func TestTheHolderOfNodeFilesIsToldSecretPlacesAndOwnedPaths(t *testing.T) {
|
||||
files := mcat.Manifest{Module: "files", Version: "1", Claims: []mcat.Claim{{Name: mcat.FilesSeat}},
|
||||
OwnSecrets: mcat.OwnSecrets{"broker": {Path: "/var/lib/files/broker"}}}
|
||||
host := mcat.Manifest{Module: "hostnames", Version: "1", Claims: []mcat.Claim{{Name: "node-hostname"}},
|
||||
Resources: []map[string]any{{"id": "hosts", "type": "file", "path": "/etc/hosts"}}}
|
||||
app := mcat.Manifest{Module: "app", Version: "1",
|
||||
Resources: []map[string]any{{"id": "conf", "type": "file", "path": "/etc/app/app.conf"}}}
|
||||
r := mcat.Resolution{Node: "anchor", Modules: []mcat.Manifest{files, host, app}}
|
||||
words, err := resolutionFilesWords(r, files, nil, mcat.Rendering{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var places []string
|
||||
if err := json.Unmarshal([]byte(words[mcat.ReadsSecretPlaces]), &places); err != nil || len(places) == 0 {
|
||||
t.Errorf("no secret places: %v %v", err, words)
|
||||
}
|
||||
var owned map[string]string
|
||||
if err := json.Unmarshal([]byte(words[mcat.FilesOwnedPaths]), &owned); err != nil {
|
||||
t.Fatalf("no owned paths: %v (%v)", err, words)
|
||||
}
|
||||
if !strings.HasPrefix(owned["/etc/hosts"], "hostnames") {
|
||||
t.Errorf("/etc/hosts is owned by %q, want hostnames", owned["/etc/hosts"])
|
||||
}
|
||||
if !strings.HasPrefix(owned["/etc/app/app.conf"], "app") {
|
||||
t.Errorf("a placed file is owned by %q, want app", owned["/etc/app/app.conf"])
|
||||
}
|
||||
// Issue 1126, C3: a seat path that steps back for a seat held here is that holder's, whatever the order of the seat
|
||||
// table (the controller reads it sorted by name, a node in the order compiled).
|
||||
resolver := mcat.Manifest{Module: "systemd-resolved", Version: "1", Claims: []mcat.Claim{{Name: mcat.ResolverSeat}},
|
||||
Resources: []map[string]any{{"id": "stub", "type": "file", "path": mcat.ResolverFile}}}
|
||||
uplink := mcat.Manifest{Module: "networkmanager", Version: "1", Claims: []mcat.Claim{{Name: "node-uplink"}},
|
||||
Resources: []map[string]any{{"id": "resolv", "type": "file", "path": mcat.ResolverFile}}}
|
||||
compiled := seats
|
||||
t.Cleanup(func() { seats = compiled })
|
||||
for _, order := range []string{"compiled", "reversed"} {
|
||||
if order == "reversed" {
|
||||
seats = append([]mcat.Seat(nil), compiled...)
|
||||
slices.Reverse(seats)
|
||||
}
|
||||
both := mcat.Resolution{Node: "anchor", Modules: []mcat.Manifest{files, uplink, resolver}}
|
||||
w, err := resolutionFilesWords(both, files, nil, mcat.Rendering{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var o map[string]string
|
||||
if err := json.Unmarshal([]byte(w[mcat.FilesOwnedPaths]), &o); err != nil || o[mcat.ResolverFile] != "systemd-resolved ("+mcat.ResolverSeat+")" {
|
||||
t.Errorf("seat table %s: %s is owned by %q, want systemd-resolved (%s)", order, mcat.ResolverFile, o[mcat.ResolverFile], mcat.ResolverSeat)
|
||||
}
|
||||
}
|
||||
seats = compiled
|
||||
// Issue 1126, C5 and seed-8: a module carried as it runs (compose.Carried) owns its manifest's paths (OwnsPaths), as
|
||||
// the controller tells them, and not the paths its running resources are at (a directory the mesh placed).
|
||||
carried := mcat.Manifest{Module: "de-spiegel", Version: "1", OwnsPaths: []string{"/var/lib/de-spiegel/server.env"},
|
||||
Resources: []map[string]any{{"id": "state", "type": "directory", "path": "/var/lib/de-spiegel"}}}
|
||||
w, err := resolutionFilesWords(mcat.Resolution{Node: "anchor", Modules: []mcat.Manifest{files, carried}}, files, nil, mcat.Rendering{})
|
||||
if err != nil || !strings.Contains(w[mcat.FilesOwnedPaths], `"/var/lib/de-spiegel/server.env":"de-spiegel (its own tools)"`) ||
|
||||
strings.Contains(w[mcat.FilesOwnedPaths], `"/var/lib/de-spiegel":`) {
|
||||
t.Errorf("a carried module does not own its manifest's paths alone: %v %v", w[mcat.FilesOwnedPaths], err)
|
||||
}
|
||||
if w, _ := resolutionFilesWords(r, app, nil, mcat.Rendering{}); w[mcat.FilesOwnedPaths] != "" {
|
||||
t.Error("a module not holding node-files is told the owned paths")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,894 @@
|
||||
package resolution
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
|
||||
// Filtering is the whole rule set for the node this resolved for.
|
||||
//
|
||||
// **Derived from what is assigned here, and from the overlay's shape** — a node's open ports are
|
||||
// a consequence of what runs on it, not a second list kept in step by hand. Nothing else opens a
|
||||
// port: **what is not declared is closed**, which is the property that makes the derivation worth
|
||||
// having rather than merely tidy.
|
||||
func Filtering(r mcat.Resolution, computed map[string]mcat.Generator, ports map[string]map[int]int, exposure map[string]map[int]string) ([]mcat.Rule, error) {
|
||||
// Keyed by what actually distinguishes an opening. Two modules wanting :443 from the mesh is
|
||||
// one rule with two sources; one wanting it from the mesh and another from anywhere is two,
|
||||
// and they are collapsed below -- deliberately, and only in the widening direction.
|
||||
type opening struct {
|
||||
port int
|
||||
protocol string
|
||||
from string
|
||||
}
|
||||
found := map[opening]*mcat.Rule{}
|
||||
for _, m := range r.Modules {
|
||||
// What a module says, and what was computed for it on this machine. The second is how a
|
||||
// hub's own port is derived: it is a fact about this machine's place in the mesh, which
|
||||
// the module cannot know and the mesh cannot avoid knowing.
|
||||
opens := m.Listens
|
||||
if m.Computed != "" {
|
||||
if generator, known := computed[m.Computed].(mcat.OpensPorts); known {
|
||||
also, err := generator.Listens(r.Node)
|
||||
if err != nil {
|
||||
// **Refused, not treated as nothing.** A generator that cannot say what a
|
||||
// machine opens is not one that says it opens nothing, and closing a port on
|
||||
// the evidence of a failure to look is how a machine is severed by a fault
|
||||
// somewhere else entirely.
|
||||
return nil, fmt.Errorf(
|
||||
"%s could not say what %s opens, so no rule set can be computed for it: %w",
|
||||
m.Module, r.Node, err)
|
||||
}
|
||||
opens = append(append([]mcat.Listening{}, opens...), also...)
|
||||
}
|
||||
}
|
||||
for _, l := range opens {
|
||||
// **The port the machine actually publishes on** (novox/hq ADR 0038). A module says
|
||||
// the port its software uses; the mesh chooses where the machine puts it. A rule
|
||||
// naming the first would open a port nothing listens on and leave the real one shut,
|
||||
// which is a firewall that reports success and blocks the service.
|
||||
port := l.Port
|
||||
if at, known := ports[m.Module][l.Port]; known {
|
||||
port = at
|
||||
}
|
||||
// The source, with any per-node exposure setting applied. The override names the port
|
||||
// the module declares, so it travels with that port to wherever the machine publishes
|
||||
// it (novox/hq ADR 0046): the same module is internal on one node and public on another.
|
||||
from := l.From
|
||||
if override, set := exposure[m.Module][l.Port]; set {
|
||||
from = override
|
||||
}
|
||||
at := opening{port: port, protocol: l.At(), from: from}
|
||||
rule, seen := found[at]
|
||||
if !seen {
|
||||
rule = &mcat.Rule{Port: port, Protocol: l.At(), From: from}
|
||||
found[at] = rule
|
||||
}
|
||||
rule.Because = append(rule.Because, m.Module)
|
||||
rule.Why = append(rule.Why, l.Why)
|
||||
}
|
||||
}
|
||||
|
||||
out := make([]mcat.Rule, 0, len(found))
|
||||
for _, rule := range found {
|
||||
out = append(out, *rule)
|
||||
}
|
||||
// By port, then protocol, then source. A rule set that reorders itself between two runs of an
|
||||
// unchanged mesh is a diff nobody can read, and every consumer of this compares one to the
|
||||
// last.
|
||||
sort.Slice(out, func(a, b int) bool {
|
||||
if out[a].Port != out[b].Port {
|
||||
return out[a].Port < out[b].Port
|
||||
}
|
||||
if out[a].Protocol != out[b].Protocol {
|
||||
return out[a].Protocol < out[b].Protocol
|
||||
}
|
||||
return out[a].From < out[b].From
|
||||
})
|
||||
return widest(out), nil
|
||||
}
|
||||
|
||||
// Exposure reads a module's per-node exposure overrides from its settings: declared-port → source.
|
||||
//
|
||||
// It refuses an override for a port the module does not listen on, or to a source that is not a
|
||||
// real one — an exposure setting that reaches no port, or names a source nothing enforces, is the
|
||||
// "reads as a restriction and is none" fault this whole mechanism exists to prevent (novox/hq
|
||||
// ADR 0043/0045). A module with no `expose` setting yields nothing and keeps its manifest defaults.
|
||||
func Exposure(m mcat.Manifest, layers []mcat.Layer) (map[int]string, error) {
|
||||
listened := make(map[int]bool, len(m.Listens))
|
||||
for _, l := range m.Listens {
|
||||
listened[l.Port] = true
|
||||
}
|
||||
|
||||
out := map[int]string{}
|
||||
for _, layer := range layers {
|
||||
raw, ok := layer.Placement[mcat.ExposeSetting]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
entries, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%s: %s is a { port: source } map, and %q set it to something else",
|
||||
m.Module, mcat.ExposeSetting, layer.From)
|
||||
}
|
||||
for portText, value := range entries {
|
||||
port, err := strconv.Atoi(portText)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s exposes %q, which is not a port", m.Module, portText)
|
||||
}
|
||||
if !listened[port] {
|
||||
return nil, fmt.Errorf(
|
||||
"%s exposes port %d, which it does not listen on — the setting reaches nothing",
|
||||
m.Module, port)
|
||||
}
|
||||
source, ok := value.(string)
|
||||
if !ok || (source != mcat.FromMesh && source != mcat.FromEverywhere && source != mcat.FromMachine &&
|
||||
source != mcat.FromLocalNetwork) {
|
||||
return nil, fmt.Errorf("%s exposes port %d as %v; it is %q, %q, %q or %q",
|
||||
m.Module, port, value, mcat.FromMesh, mcat.FromEverywhere, mcat.FromMachine, mcat.FromLocalNetwork)
|
||||
}
|
||||
out[port] = source
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// widest drops a rule that another already covers.
|
||||
//
|
||||
// A port open to anywhere is not additionally restricted by a second rule opening it to the mesh:
|
||||
// keeping both would render two lines where the narrower one restricts nothing, which is exactly
|
||||
// the appearance-of-restriction this mechanism exists to remove. The narrower rule's sources are
|
||||
// carried onto the wider one, so the module that wanted it is still named.
|
||||
func widest(rules []mcat.Rule) []mcat.Rule {
|
||||
// The local network is the private network and the machine's own local networks (novox/hq ADR
|
||||
// 0340 §4), so it covers `mesh` and is covered by `anywhere`.
|
||||
reach := map[string]int{mcat.FromMachine: 0, mcat.FromMesh: 1, mcat.FromLocalNetwork: 2, mcat.FromEverywhere: 3}
|
||||
var out []mcat.Rule
|
||||
for _, rule := range rules {
|
||||
covered := false
|
||||
for i := range out {
|
||||
if out[i].Port != rule.Port || out[i].Protocol != rule.Protocol {
|
||||
continue
|
||||
}
|
||||
if reach[out[i].From] >= reach[rule.From] {
|
||||
out[i].Because = append(out[i].Because, rule.Because...)
|
||||
out[i].Why = append(out[i].Why, rule.Why...)
|
||||
covered = true
|
||||
break
|
||||
}
|
||||
// The other way round: this one is wider, so it replaces what is there and takes its
|
||||
// sources with it.
|
||||
rule.Because = append(rule.Because, out[i].Because...)
|
||||
rule.Why = append(rule.Why, out[i].Why...)
|
||||
out = append(out[:i], out[i+1:]...)
|
||||
break
|
||||
}
|
||||
if !covered {
|
||||
out = append(out, rule)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// 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
|
||||
// anywhere and not dropped silently: the rule set says the port wanted the local network and found none.
|
||||
func AsNftablesWithLocal(rules []mcat.Rule, mesh []string, outward bool, foundation []int,
|
||||
outwardLinks []string, tunnel string, local []string) string {
|
||||
// The links that are not this machine's own: the ones facing outside, and the mesh's tunnel.
|
||||
// Traffic arriving on any of them is admitted only by a rule below; traffic arriving anywhere
|
||||
// else is this machine's own guest and is not something the mesh has a position on.
|
||||
//
|
||||
// The tunnel is named here deliberately. Treating it as "not outside" would make a port nothing
|
||||
// declares reachable from every machine in the mesh, which is the derivation abandoned.
|
||||
quoted := make([]string, 0, len(outwardLinks)+1)
|
||||
for _, link := range outwardLinks {
|
||||
quoted = append(quoted, fmt.Sprintf("%q", link))
|
||||
}
|
||||
if tunnel != "" {
|
||||
quoted = append(quoted, fmt.Sprintf("%q", tunnel))
|
||||
}
|
||||
inward := strings.Join(quoted, ", ")
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString("# Computed by the mesh from what is assigned to this node.\n")
|
||||
b.WriteString("# Edits are lost on the next declaration; change a module's listens instead.\n\n")
|
||||
// Replacing this table and nothing else, every time it is loaded. Declared as an empty table
|
||||
// first so the delete cannot fail on a machine that has never loaded one — the alternative
|
||||
// idiom, `flush ruleset`, empties every table on the machine, including the ones the container
|
||||
// runtime writes for its bridges. Reloading the mesh's rules must not stop every container.
|
||||
b.WriteString("table inet mesh {}\ndelete table inet mesh\n\n")
|
||||
b.WriteString("table inet mesh {\n")
|
||||
b.WriteString("\tchain input {\n")
|
||||
// Dropping by default is the whole point, and the three lines under it are what keep dropping
|
||||
// by default from meaning "unreachable": a reply to something this node started, its own
|
||||
// loopback, and the diagnostic that tells an operator the machine is up and refusing rather
|
||||
// than gone.
|
||||
b.WriteString("\t\ttype filter hook input priority filter; policy drop;\n")
|
||||
b.WriteString("\t\tct state established,related accept\n")
|
||||
b.WriteString("\t\tct state invalid drop\n")
|
||||
b.WriteString("\t\tiif lo accept\n")
|
||||
// **Anything on this machine may call anything on this machine.**
|
||||
//
|
||||
// Local is not a boundary this mesh draws. A service running here is callable by everything else
|
||||
// running here, whatever form either takes — a package with a unit, a binary, a container. Whether
|
||||
// a caller sits in a container was never meant to change the answer, and the only reason it did was
|
||||
// that this chain asked about addresses: a caller on the machine carries the machine's address, a
|
||||
// caller in one of its containers carries a bridge address, and a rule naming the former silently
|
||||
// refused the latter.
|
||||
//
|
||||
// Measured: a module reaching its database on this machine's own name timed out for eleven hours
|
||||
// while the machine itself could reach it, and the mesh called the machine healthy throughout
|
||||
// (novox/hq 04-ISSUES/145).
|
||||
//
|
||||
// Asked by the link it arrives on rather than the address it comes from: anything that did not
|
||||
// arrive from outside this machine, and did not arrive over the private network, is this machine's
|
||||
// own. One rule for every service here, in place of a line per port that only ever covered the
|
||||
// ports somebody remembered to think about.
|
||||
if inward != "" {
|
||||
b.WriteString(fmt.Sprintf("\t\tiifname != { %s } accept\n", inward))
|
||||
}
|
||||
b.WriteString("\t\ticmp type echo-request accept\n")
|
||||
b.WriteString("\t\ticmpv6 type { echo-request, nd-neighbor-solicit, nd-neighbor-advert, nd-router-advert } accept\n")
|
||||
|
||||
// **What this machine's own guests must be able to ask it** (novox/hq ADR 0140). A guest gets
|
||||
// its address and its names from this machine, over the link it is on, and those two questions
|
||||
// arrive at the input chain like any other. Denied, the guest never gets an address and never
|
||||
// resolves a name — which is not "a closed port" but a network that does not work at all, and it
|
||||
// is this machine's own guest asking.
|
||||
//
|
||||
// Asked for by the link it arrives on rather than by the address it comes from, for the reason
|
||||
// the forward chain below no longer names an address: a range describes one machine and goes
|
||||
// stale in silence. Anything arriving from outside, or over the tunnel, is not a guest of this
|
||||
// machine and asks through a port somebody declared, like everything else.
|
||||
if len(inward) > 0 {
|
||||
b.WriteString("\t\t# this machine's own guests asking it for an address and for names\n")
|
||||
b.WriteString(fmt.Sprintf("\t\tiifname != { %s } udp dport { 53, 67 } accept\n", inward))
|
||||
b.WriteString(fmt.Sprintf("\t\tiifname != { %s } tcp dport 53 accept\n", inward))
|
||||
}
|
||||
|
||||
// **ssh, always, and not because a module asked.**
|
||||
//
|
||||
// Every other line in this chain is derived from what is assigned here, which is the whole
|
||||
// point of the mechanism. This one is not, and the exception is deliberate: the rules are
|
||||
// computed from what the mesh knows, a mesh part-way through adopting a machine knows almost
|
||||
// nothing, and the first port to close is the one used to fix it. The session that loads these
|
||||
// rules survives on conntrack until it drops, and then the machine is reached from a rescue
|
||||
// console (novox/hq issue 047).
|
||||
//
|
||||
// From the private network always, because that is how machines reach each other. From
|
||||
// everywhere when this machine faces outward, because that is the only way in on the day the
|
||||
// private network is what broke.
|
||||
if four, six := byFamily(mesh); len(four) > 0 || len(six) > 0 {
|
||||
b.WriteString("\n\t\t# ssh, from the mesh — never derived, never closed\n")
|
||||
if len(four) > 0 {
|
||||
b.WriteString(fmt.Sprintf("\t\tip saddr { %s } tcp dport %d accept\n",
|
||||
strings.Join(four, ", "), mcat.SSHPort))
|
||||
}
|
||||
if len(six) > 0 {
|
||||
b.WriteString(fmt.Sprintf("\t\tip6 saddr { %s } tcp dport %d accept\n",
|
||||
strings.Join(six, ", "), mcat.SSHPort))
|
||||
}
|
||||
}
|
||||
// From everywhere when this machine faces outward — and also when the mesh has no addresses to
|
||||
// name yet.
|
||||
//
|
||||
// **A machine early in being adopted is the one this matters most for, and the one where the
|
||||
// rule above emits nothing.** Before the private network exists there is no mesh address to
|
||||
// allow from, so restricting to it restricts to nothing: the chain drops by default and ssh is
|
||||
// simply shut. That is the first machine anybody adopts, reached over the network, with the
|
||||
// port needed to fix it closed by the act of adopting it. Never leaving this chain without an
|
||||
// ssh rule is worth more than the narrower rule it would have had.
|
||||
if four, six := byFamily(mesh); outward || (len(four) == 0 && len(six) == 0) {
|
||||
why := "and from outside, because this machine faces it"
|
||||
if !outward {
|
||||
why = "and from anywhere, because this mesh has no addresses to narrow it to yet"
|
||||
}
|
||||
b.WriteString("\t\t# " + why + "\n")
|
||||
b.WriteString(fmt.Sprintf("\t\ttcp dport %d accept\n", mcat.SSHPort))
|
||||
}
|
||||
|
||||
// The mesh's own ports, from anywhere.
|
||||
//
|
||||
// Not narrowed to the private network, because the machines that need this most are the ones
|
||||
// not on it yet: a node enrols BEFORE it has an address here, over the ordinary network, and a
|
||||
// rule allowing only the private network would close the door being knocked on.
|
||||
if len(foundation) > 0 {
|
||||
b.WriteString("\n\t\t# the mesh's own — never derived, never closed\n")
|
||||
for _, port := range foundation {
|
||||
b.WriteString(fmt.Sprintf("\t\ttcp dport %d accept\n", port))
|
||||
}
|
||||
}
|
||||
|
||||
if len(rules) > 0 {
|
||||
b.WriteString("\n")
|
||||
}
|
||||
for _, rule := range rules {
|
||||
for i, module := range rule.Because {
|
||||
why := ""
|
||||
if i < len(rule.Why) && rule.Why[i] != "" {
|
||||
why = " — " + rule.Why[i]
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("\t\t# %s%s\n", module, why))
|
||||
}
|
||||
switch rule.From {
|
||||
case mcat.FromMachine:
|
||||
// Nothing. A port bound for something else on the same machine is reached over
|
||||
// loopback, which is already accepted above -- and rendering a rule for it would open
|
||||
// it to the network, which is the opposite of what the manifest asked for.
|
||||
b.WriteString(fmt.Sprintf("\t\t# %s/%d is this machine only; loopback already covers it\n",
|
||||
rule.Protocol, rule.Port))
|
||||
case mcat.FromMesh:
|
||||
if len(mesh) == 0 {
|
||||
// Said, and impossible to render. Left as a comment rather than silently widened
|
||||
// to anywhere or silently dropped: one of those opens a port nobody asked to
|
||||
// open, and the other closes one somebody did.
|
||||
b.WriteString(fmt.Sprintf(
|
||||
"\t\t# %s/%d wanted the mesh, and this node knows no mesh addresses; closed\n",
|
||||
rule.Protocol, rule.Port))
|
||||
break
|
||||
}
|
||||
four, six := byFamily(mesh)
|
||||
if len(four) > 0 {
|
||||
b.WriteString(fmt.Sprintf("\t\tip saddr { %s } %s dport %d accept\n",
|
||||
strings.Join(four, ", "), rule.Protocol, rule.Port))
|
||||
}
|
||||
if len(six) > 0 {
|
||||
b.WriteString(fmt.Sprintf("\t\tip6 saddr { %s } %s dport %d accept\n",
|
||||
strings.Join(six, ", "), rule.Protocol, rule.Port))
|
||||
}
|
||||
case mcat.FromLocalNetwork:
|
||||
// The private network and the machine's own local networks, on this port alone (novox/hq
|
||||
// ADR 0340 §5).
|
||||
four, six := byFamily(mesh)
|
||||
lfour, lsix := byFamily(local)
|
||||
four, six = append(four, lfour...), append(six, lsix...)
|
||||
if len(lfour) == 0 && len(lsix) == 0 {
|
||||
b.WriteString(fmt.Sprintf(
|
||||
"\t\t# %s/%d wanted the local network, and this machine reported no private network "+
|
||||
"on a link facing outside; admitted from no local network\n", rule.Protocol, rule.Port))
|
||||
}
|
||||
if len(four) == 0 && len(six) == 0 {
|
||||
b.WriteString(fmt.Sprintf(
|
||||
"\t\t# %s/%d wanted the local network, and this node knows neither mesh addresses nor a "+
|
||||
"local network; closed\n", rule.Protocol, rule.Port))
|
||||
break
|
||||
}
|
||||
if len(four) > 0 {
|
||||
b.WriteString(fmt.Sprintf("\t\tip saddr { %s } %s dport %d accept\n",
|
||||
strings.Join(four, ", "), rule.Protocol, rule.Port))
|
||||
}
|
||||
if len(six) > 0 {
|
||||
b.WriteString(fmt.Sprintf("\t\tip6 saddr { %s } %s dport %d accept\n",
|
||||
strings.Join(six, ", "), rule.Protocol, rule.Port))
|
||||
}
|
||||
case mcat.FromEverywhere:
|
||||
b.WriteString(fmt.Sprintf("\t\t%s dport %d accept\n", rule.Protocol, rule.Port))
|
||||
}
|
||||
}
|
||||
|
||||
b.WriteString("\t}\n")
|
||||
// Nothing this node sends is filtered, and it is stated rather than left to nftables' default
|
||||
// so that reading this file answers the question instead of requiring the reader to know it.
|
||||
b.WriteString("\tchain output {\n\t\ttype filter hook output priority filter; policy accept;\n\t}\n")
|
||||
|
||||
// **A forward chain, because without one this firewall does not cover container ports.**
|
||||
//
|
||||
// It used to have none, on the reasoning that a drop here would drop container traffic the
|
||||
// runtime explicitly allowed and stop every container on the node. The first half of that is
|
||||
// true. The conclusion was not: a published port is redirected and then *forwarded*, so it
|
||||
// never reaches the input chain above, and a firewall with no forward chain says nothing at all
|
||||
// about the ports most worth protecting. Rehearsed on three machines: loading these rules
|
||||
// refused a port on the host and left a published container port reachable (novox/hq issue 047).
|
||||
//
|
||||
// **What it constrains is traffic arriving from OUTSIDE this machine, and nothing else**
|
||||
// (novox/hq ADR 0140).
|
||||
//
|
||||
// It used to deny everything here and then allow the machine's own containers back by naming
|
||||
// the address ranges they sit on — two ranges fixed in this file and the rest recorded per
|
||||
// machine. Every way of keeping that list correct failed. A constant describes one machine. A
|
||||
// recorded range goes stale in silence and cannot tell a network the mesh made from one a
|
||||
// predecessor left behind. Generating it from the modules would have put half this rule set on
|
||||
// the machine.
|
||||
//
|
||||
// The list should not exist, because the mesh has no position on a container reaching outward:
|
||||
// that is not a port opened to anybody. So traffic that did not arrive from outside is accepted
|
||||
// in one line, and what did arrive from outside is allowed only where a rule below admits it.
|
||||
//
|
||||
// The tunnel is not "not outside". Accepting everything off it would make a port nothing
|
||||
// declares reachable from any machine in the mesh, which is the derivation abandoned — so it is
|
||||
// named here beside the outward links, and traffic arriving on it meets the rules below like
|
||||
// anything else.
|
||||
b.WriteString("\tchain forward {\n")
|
||||
b.WriteString("\t\ttype filter hook forward priority filter; policy drop;\n")
|
||||
b.WriteString("\t\tct state established,related accept\n")
|
||||
b.WriteString("\t\tct state invalid drop\n")
|
||||
b.WriteString("\n")
|
||||
// Only when there is a link to name. An empty set is a line nftables refuses, and a rule set
|
||||
// that does not load is a machine filtering nothing while its unit reports success — so the
|
||||
// chain denies rather than renders nonsense. Composing a declaration for a machine that has
|
||||
// named none is refused upstream, so this is a floor and not a path anything travels.
|
||||
if inward != "" {
|
||||
b.WriteString("\t\t# this machine's own guests reaching outward: not a port opened to anybody\n")
|
||||
b.WriteString(fmt.Sprintf("\t\tiifname != { %s } accept\n", inward))
|
||||
}
|
||||
// **The mesh passing through, not arriving.** A machine the mesh routes through — the hub, for
|
||||
// every path between machines that are not co-located (novox/hq ADR 0007) — relays a packet that
|
||||
// came in on the tunnel and leaves on it again, addressed to another machine of the mesh. That is
|
||||
// no port of this machine's: the machine it is for filters it against its own rules. Without
|
||||
// this, the chain below judged a relayed packet by this machine's own published ports, so two
|
||||
// machines behind the hub reached each other only on ports the hub happened to publish for itself
|
||||
// (novox/hq issue 196). In and out on the tunnel both: a packet off the tunnel for this machine's
|
||||
// own containers leaves by a bridge, and still meets the rules below.
|
||||
if tunnel != "" {
|
||||
b.WriteString("\t\t# the mesh passing through to another of its machines, which filters it itself\n")
|
||||
b.WriteString(fmt.Sprintf("\t\tiifname %q oifname %q accept\n", tunnel, tunnel))
|
||||
}
|
||||
|
||||
if len(rules) > 0 {
|
||||
b.WriteString("\n")
|
||||
}
|
||||
for _, rule := range rules {
|
||||
for i, module := range rule.Because {
|
||||
why := ""
|
||||
if i < len(rule.Why) && rule.Why[i] != "" {
|
||||
why = " — " + rule.Why[i]
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("\t\t# %s%s\n", module, why))
|
||||
}
|
||||
// **Matched on where the packet was originally addressed, not where it is going now.**
|
||||
// By the time a packet reaches this chain the runtime has already rewritten its
|
||||
// destination to the container's own port, so matching the port a rule names would match
|
||||
// nothing. Conntrack remembers what was asked for, which is the port the machine
|
||||
// publishes and the only one a client ever knew.
|
||||
switch rule.From {
|
||||
case mcat.FromMachine:
|
||||
b.WriteString(fmt.Sprintf("\t\t# %s/%d is this machine only, and is not forwarded\n",
|
||||
rule.Protocol, rule.Port))
|
||||
case mcat.FromMesh:
|
||||
four, six := byFamily(mesh)
|
||||
if len(four) > 0 {
|
||||
b.WriteString(fmt.Sprintf("\t\tip saddr { %s } ct original proto-dst %d accept\n",
|
||||
strings.Join(four, ", "), rule.Port))
|
||||
}
|
||||
if len(six) > 0 {
|
||||
b.WriteString(fmt.Sprintf("\t\tip6 saddr { %s } ct original proto-dst %d accept\n",
|
||||
strings.Join(six, ", "), rule.Port))
|
||||
}
|
||||
case mcat.FromLocalNetwork:
|
||||
four, six := byFamily(mesh)
|
||||
lfour, lsix := byFamily(local)
|
||||
four, six = append(four, lfour...), append(six, lsix...)
|
||||
if len(four) > 0 {
|
||||
b.WriteString(fmt.Sprintf("\t\tip saddr { %s } ct original proto-dst %d accept\n",
|
||||
strings.Join(four, ", "), rule.Port))
|
||||
}
|
||||
if len(six) > 0 {
|
||||
b.WriteString(fmt.Sprintf("\t\tip6 saddr { %s } ct original proto-dst %d accept\n",
|
||||
strings.Join(six, ", "), rule.Port))
|
||||
}
|
||||
case mcat.FromEverywhere:
|
||||
b.WriteString(fmt.Sprintf("\t\tct original proto-dst %d accept\n", rule.Port))
|
||||
}
|
||||
}
|
||||
|
||||
// The foundation ports, forwarded — for the same reason they are in the input chain, and the
|
||||
// same reason the module rules above are here too: the broker is a published container port,
|
||||
// so a cross-node dial to it is redirected and *forwarded*, never reaching the input chain
|
||||
// (novox/hq issue 047's lesson, applied to the foundation this time). Without this a joined
|
||||
// node reaches the broker only while its first connection's conntrack entry survives — and a
|
||||
// broker restart, which adopting the foundation's own broker causes, drops the entry and the
|
||||
// node can never receive another declaration (novox/hq issue 063). From anywhere, matching
|
||||
// the input rule: a node enrolling has no overlay address yet.
|
||||
if len(foundation) > 0 {
|
||||
b.WriteString("\n\t\t# the mesh's own — never derived, never closed\n")
|
||||
for _, port := range foundation {
|
||||
b.WriteString(fmt.Sprintf("\t\tct original proto-dst %d accept\n", port))
|
||||
}
|
||||
}
|
||||
b.WriteString("\t}\n")
|
||||
b.WriteString("}\n")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// byFamily splits addresses into the two nftables understands separately.
|
||||
//
|
||||
// `ip saddr` and `ip6 saddr` are different matches, and one set holding both families is a syntax
|
||||
// error — which means the whole file fails to load and the machine filters nothing while the
|
||||
// service reports a configuration fault. A mesh that is entirely one family renders one line, and
|
||||
// a mixed one renders both rather than dropping half its nodes.
|
||||
func byFamily(addresses []string) (four []string, six []string) {
|
||||
for _, a := range addresses {
|
||||
if strings.Contains(a, ":") {
|
||||
six = append(six, a)
|
||||
continue
|
||||
}
|
||||
four = append(four, a)
|
||||
}
|
||||
return four, six
|
||||
}
|
||||
|
||||
// GivenPorts reads a module's given machine ports from its settings: software port → machine port.
|
||||
//
|
||||
// Refused from a mesh-wide layer — a port is a fact about one machine, and one number for every
|
||||
// machine is the collision this exists to avoid — and for a port the module's containers do not
|
||||
// publish.
|
||||
//
|
||||
// **Only a published port is the mesh's to move** (novox/hq ADR 0038). A container's mapping is
|
||||
// what translates; a module binding the machine's network directly binds the number its software
|
||||
// was configured with, and moving that number would put it in the filter, in the openings and in
|
||||
// what consumers are told while the software still listens on the old one — a port that reads as
|
||||
// moved and is not.
|
||||
//
|
||||
// **Either name of a mapping names it; the module's own name answers.** A short form publishes one
|
||||
// number, which is the container's port and the machine's at once. A mapping written the long way
|
||||
// — `"2222:22"` — has two, and a module reasonably declares it listens on either: the port its
|
||||
// software uses, or the port the machine already serves on. A setting may name either end, because
|
||||
// both are true of the same mapping. The answer comes back under the end the **module** names in
|
||||
// its `listens`, which is the number every reader of this map holds: the ports map, the filter, the
|
||||
// openings, what a consumer is told, and the mapping the runtime is handed. Keyed one way and read
|
||||
// the other, the setting moved the container's mapping and nothing else — a firewall, a set of
|
||||
// openings and a consumer all pointing at a port the software had left.
|
||||
//
|
||||
// One entry per mapping, never two. A second key for the same answer is not a convenience: where
|
||||
// two mappings share a number, it is an entry one of them writes over the other's, and the reader
|
||||
// that finds the survivor disagrees with the reader that recomputes it.
|
||||
func GivenPorts(m mcat.Manifest, layers []mcat.Layer) (map[int]int, error) {
|
||||
// An endpoint's own block may put it on a machine port, which is the same thing `ports` says about
|
||||
// the number rather than about the endpoint (novox/hq ADR 0138). Collected first and then let the
|
||||
// older key be read, which refuses a port said twice.
|
||||
byName, err := mcat.Endpoints(m, layers)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
named := map[int]int{}
|
||||
for name, ep := range byName {
|
||||
if ep.Port == 0 {
|
||||
continue
|
||||
}
|
||||
named[endpointPorts(m)[name]] = ep.Port
|
||||
}
|
||||
|
||||
// Every name a setting may use, and the mapping it names.
|
||||
names := map[int][]publishing{}
|
||||
for _, p := range publishedPorts(m) {
|
||||
names[p.inner] = append(names[p.inner], p)
|
||||
if p.machine != p.inner {
|
||||
names[p.machine] = append(names[p.machine], p)
|
||||
}
|
||||
}
|
||||
// And the names the module itself uses. A mapping's answer is filed under these, because they
|
||||
// are what every reader asks for; a mapping the module names at neither end is filed under its
|
||||
// machine side, where nothing looks, which is correct — nothing serves it.
|
||||
declares := map[int]bool{}
|
||||
for _, l := range m.Listens {
|
||||
declares[l.Port] = true
|
||||
}
|
||||
chose := map[int]int{} // the port a setting named → the machine port it gave it
|
||||
out := map[int]int{} // the port the module names → the machine port it is on
|
||||
by := map[int]int{} // and which of the setting's ports put it there, for the refusal
|
||||
for _, layer := range layers {
|
||||
raw, ok := layer.Placement[mcat.PortsSetting]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if layer.From == mcat.MeshWideLayer {
|
||||
return nil, fmt.Errorf("%s: %s is given per node — a port is a fact about one "+
|
||||
"machine; set it with --node", m.Module, mcat.PortsSetting)
|
||||
}
|
||||
entries, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%s: %s is a { port: machine-port } map, and %q set it to "+
|
||||
"something else", m.Module, mcat.PortsSetting, layer.From)
|
||||
}
|
||||
for portText, value := range entries {
|
||||
port, err := strconv.Atoi(portText)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s gives %q a port, which is not a port", m.Module, portText)
|
||||
}
|
||||
publishes, known := names[port]
|
||||
if !known {
|
||||
return nil, fmt.Errorf("%s gives port %d a machine port, and no container of its "+
|
||||
"publishes %d — the mesh cannot move a port the module does not publish; the "+
|
||||
"software would go on listening where it was told to", m.Module, port, port)
|
||||
}
|
||||
if err := oneMapping(m.Module, port, publishes); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
at, ok := asPort(value)
|
||||
if !ok || at < 1 || at > 65535 {
|
||||
return nil, fmt.Errorf("%s gives port %d the machine port %v, which is not a port",
|
||||
m.Module, port, value)
|
||||
}
|
||||
if at == mcat.SSHPort {
|
||||
return nil, fmt.Errorf("%s gives port %d the machine port %d, which is ssh's — the "+
|
||||
"one port a machine may never lose", m.Module, port, at)
|
||||
}
|
||||
chose[port] = at
|
||||
}
|
||||
}
|
||||
// One holder per machine port, within the module too.
|
||||
holder := map[int]int{}
|
||||
for _, port := range sortedPorts(chose) {
|
||||
at := chose[port]
|
||||
if other, twice := holder[at]; twice {
|
||||
return nil, fmt.Errorf("%s gives machine port %d to both its %d and its %d", m.Module,
|
||||
at, min(port, other), max(port, other))
|
||||
}
|
||||
holder[at] = port
|
||||
}
|
||||
// Filed under the module's own names for the mapping — every one it uses, so a module that
|
||||
// says it listens on both ends is answered at both, and under the machine side when it names
|
||||
// neither.
|
||||
for _, port := range sortedPorts(chose) {
|
||||
at, mapping := chose[port], names[port][0]
|
||||
keys := []int{}
|
||||
for _, end := range []int{mapping.machine, mapping.inner} {
|
||||
if declares[end] && !slices.Contains(keys, end) {
|
||||
keys = append(keys, end)
|
||||
}
|
||||
}
|
||||
if len(keys) == 0 {
|
||||
keys = []int{mapping.machine}
|
||||
}
|
||||
for _, key := range keys {
|
||||
// The key must name this mapping and no other, or the entry is one mapping's answer
|
||||
// standing where another's is read.
|
||||
if err := oneMapping(m.Module, key, names[key]); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// And one machine port per mapping: `{"22": 222, "2222": 300}` is two numbers for the
|
||||
// one thing the machine publishes, and neither is more right.
|
||||
if was, twice := out[key]; twice && was != at {
|
||||
return nil, fmt.Errorf("%s gives %s the machine ports %d and %d — its %d and its "+
|
||||
"%d are the two ends of one mapping, and it is published once", m.Module,
|
||||
mapping, min(was, at), max(was, at), min(by[key], port), max(by[key], port))
|
||||
}
|
||||
out[key], by[key] = at, port
|
||||
}
|
||||
}
|
||||
// And what the endpoints' own blocks put them on. Refused rather than merged where both keys name
|
||||
// one endpoint: two places giving a port is the confusion this key exists to end.
|
||||
for wanted, at := range named {
|
||||
if was, twice := out[wanted]; twice && was != at {
|
||||
return nil, fmt.Errorf(
|
||||
"%s puts its port %d on %d through %s and on %d through %s — one endpoint, two "+
|
||||
"machine ports, and it is published once. Keep the endpoint's own block",
|
||||
m.Module, wanted, at, mcat.EndpointsSetting, was, mcat.PortsSetting)
|
||||
}
|
||||
out[wanted] = at
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// oneMapping refuses a number that names two of a module's mappings — `"22"` beside `"2222:22"`,
|
||||
// say, or `"4001:80"` beside `"4002:80"` read by their shared `80`. Refused rather than picked:
|
||||
// moving one of them and leaving the other is a mapping the operator did not ask for and cannot
|
||||
// see, and the two readings differ.
|
||||
func oneMapping(module string, port int, publishes []publishing) error {
|
||||
for _, other := range publishes[1:] {
|
||||
if other != publishes[0] {
|
||||
return fmt.Errorf("%s gives port %d a machine port, and its containers publish %d "+
|
||||
"twice — as %s and as %s; which one to move is not said", module, port, port,
|
||||
publishes[0], other)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// publishing is one mapping a module's container writes: the port the container itself uses, and
|
||||
// the machine port the manifest put it on — the same number for a short form, which the runtime
|
||||
// publishes on the port it names.
|
||||
type publishing struct{ machine, inner int }
|
||||
|
||||
func (p publishing) String() string {
|
||||
if p.machine == p.inner {
|
||||
return strconv.Itoa(p.inner)
|
||||
}
|
||||
return fmt.Sprintf("%d:%d", p.machine, p.inner)
|
||||
}
|
||||
|
||||
// publishedPorts is every mapping a module's containers publish, whichever form it is written in.
|
||||
func publishedPorts(m mcat.Manifest) []publishing {
|
||||
var out []publishing
|
||||
for _, r := range m.Resources {
|
||||
if fmt.Sprint(r["type"]) != "container" {
|
||||
continue
|
||||
}
|
||||
listed, _ := r["ports"].([]any)
|
||||
for _, entry := range listed {
|
||||
written := strings.TrimSpace(fmt.Sprint(entry))
|
||||
if machine, inner, _, ok := mapping(written); ok {
|
||||
out = append(out, publishing{machine: machine, inner: inner})
|
||||
continue
|
||||
}
|
||||
if cut := strings.LastIndex(written, "/"); cut >= 0 {
|
||||
written = written[:cut]
|
||||
}
|
||||
if n, err := strconv.Atoi(strings.TrimSpace(written)); err == nil {
|
||||
out = append(out, publishing{machine: n, inner: n})
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// sortedPorts is a settings map's ports in order, so a refusal reads the same on every run.
|
||||
func sortedPorts(of map[int]int) []int {
|
||||
out := make([]int, 0, len(of))
|
||||
for port := range of {
|
||||
out = append(out, port)
|
||||
}
|
||||
sort.Ints(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// reaches is every value, in the order a refusal lists them.
|
||||
var reaches = []string{mcat.ReachMachine, mcat.ReachInternal, mcat.ReachPublic, mcat.ReachBoth, mcat.ReachLocalNetwork}
|
||||
|
||||
// RoutedPorts are the ports a module serves through a proxy, taken from its route contributions.
|
||||
//
|
||||
// **A routed endpoint's port is how the proxy reaches it, and nothing else.** That is ADR 0045's
|
||||
// decision and it is older than reach: a public service listens `from: mesh`, only the proxy reaches
|
||||
// it, and it is exposed by name. So `public` on a routed endpoint asks for a public *name*; opening
|
||||
// that port to the world as well would undo the arrangement the proxy exists for.
|
||||
//
|
||||
// Measured before this was written, not reasoned: a module's routed name answered from the internet
|
||||
// over TLS while its machine-side port was refused from the same place. The port is not the path.
|
||||
func RoutedPorts(m mcat.Manifest) map[int]bool {
|
||||
out := map[int]bool{}
|
||||
note := func(values map[string]any) {
|
||||
// **The endpoint it serves, by name where it says one.** A route repeating a port number is
|
||||
// the older shape and still read: 35 of the catalogue's 36 route entries name a port their
|
||||
// module declares a listen on (novox/hq ADR 0138).
|
||||
if name, ok := values[mcat.RouteEndpoint].(string); ok {
|
||||
if port, found := mcat.EndpointPort(m, name); found {
|
||||
out[port] = true
|
||||
return
|
||||
}
|
||||
}
|
||||
if port, ok := asPort(values["port"]); ok {
|
||||
out[port] = true
|
||||
}
|
||||
}
|
||||
if values, ok := m.Contributes["route"]; ok {
|
||||
note(values)
|
||||
}
|
||||
for _, values := range m.ContributesMany["route"] {
|
||||
note(values)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// FilterSource is the source a reach means to the packet filter.
|
||||
//
|
||||
// `public` and `both` are the same here. A reach that opened a port to the mesh and not to the world
|
||||
// would be `internal`; there is no reach that opens it to the world and *not* to the mesh, because a
|
||||
// filter cannot express "everyone except these" and nobody has asked for it.
|
||||
func FilterSource(reach string) (string, bool) {
|
||||
switch reach {
|
||||
case mcat.ReachMachine:
|
||||
return mcat.FromMachine, true
|
||||
case mcat.ReachInternal:
|
||||
return mcat.FromMesh, true
|
||||
case mcat.ReachPublic, mcat.ReachBoth:
|
||||
return mcat.FromEverywhere, true
|
||||
case mcat.ReachLocalNetwork:
|
||||
return mcat.FromLocalNetwork, true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
// WantsPublicName is whether a reach asks for the route's public name to be composed.
|
||||
func WantsPublicName(reach string) bool { return reach == mcat.ReachPublic || reach == mcat.ReachBoth }
|
||||
|
||||
// WantsInternalName is whether a reach asks for the route's internal name to be composed.
|
||||
func WantsInternalName(reach string) bool {
|
||||
return reach == mcat.ReachInternal || reach == mcat.ReachBoth || reach == mcat.ReachLocalNetwork
|
||||
}
|
||||
|
||||
// Reaches reads a module's per-node reach settings: declared port → how far it reaches.
|
||||
//
|
||||
// It refuses a reach for a port the module does not listen on, or a value that is not one of the
|
||||
// four — the "reads as a restriction and is none" fault this whole mechanism exists to prevent
|
||||
// (novox/hq ADR 0043/0045). It also refuses a port that `expose` names as well: the two say the same
|
||||
// thing in different words, and a module whose reach and exposure disagree would have the filter
|
||||
// following one and the names following the other, which is the very confusion ADR 0138 removes.
|
||||
//
|
||||
// A module with no `reach` setting yields nothing, and everything behaves exactly as before: the
|
||||
// filter follows the manifest's `from`, and both names are composed. That is what keeps every machine
|
||||
// already running unchanged until an assignment says otherwise.
|
||||
func Reaches(m mcat.Manifest, layers []mcat.Layer) (map[int]string, error) {
|
||||
listened := make(map[int]bool, len(m.Listens))
|
||||
for _, l := range m.Listens {
|
||||
listened[l.Port] = true
|
||||
}
|
||||
|
||||
exposed, err := Exposure(m, layers)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out := map[int]string{}
|
||||
// What an endpoint's own block says, which is the same statement in the shape that names the
|
||||
// endpoint rather than its port (novox/hq ADR 0138). Read first so the older key, which says less,
|
||||
// cannot quietly win over the newer one that says more.
|
||||
blocks, err := mcat.Endpoints(m, layers)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
declared := endpointPorts(m)
|
||||
for name, ep := range blocks {
|
||||
if ep.Reach == "" {
|
||||
continue
|
||||
}
|
||||
out[declared[name]] = ep.Reach
|
||||
}
|
||||
for _, layer := range layers {
|
||||
raw, ok := layer.Placement[mcat.ReachSetting]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
entries, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%s: %s is a { port: reach } map, and %q set it to something else",
|
||||
m.Module, mcat.ReachSetting, layer.From)
|
||||
}
|
||||
for portText, value := range entries {
|
||||
port, err := strconv.Atoi(portText)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s says how far %q reaches, which is not a port", m.Module, portText)
|
||||
}
|
||||
if !listened[port] {
|
||||
return nil, fmt.Errorf(
|
||||
"%s says how far port %d reaches, which it does not listen on — the setting "+
|
||||
"reaches nothing", m.Module, port)
|
||||
}
|
||||
reach, ok := value.(string)
|
||||
if !ok || !slices.Contains(reaches, reach) {
|
||||
return nil, fmt.Errorf("%s says port %d reaches %v; a reach is %s",
|
||||
m.Module, port, value, strings.Join(reaches, ", "))
|
||||
}
|
||||
if _, both := exposed[port]; both {
|
||||
return nil, fmt.Errorf(
|
||||
"%s sets both %s and %s for port %d. They say the same thing in different "+
|
||||
"words, and the filter would follow one while its names followed the other "+
|
||||
"— which is what %s exists to stop. Keep %s",
|
||||
m.Module, mcat.ReachSetting, mcat.ExposeSetting, port, mcat.ReachSetting, mcat.ReachSetting)
|
||||
}
|
||||
out[port] = reach
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// spokenEndpoints is what a module's endpoints are called, as a refusal lists them — so a reader who
|
||||
// named one wrongly is one edit from right, and a module that has named none is told so.
|
||||
func spokenEndpoints(m mcat.Manifest) string {
|
||||
names := make([]string, 0, len(m.Listens))
|
||||
for _, l := range m.Listens {
|
||||
if name := strings.TrimSpace(l.Name); name != "" {
|
||||
names = append(names, name)
|
||||
}
|
||||
}
|
||||
if len(names) == 0 {
|
||||
return "no endpoints by name"
|
||||
}
|
||||
sort.Strings(names)
|
||||
return strings.Join(names, ", ")
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
package resolution
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
|
||||
// A machine that has not said which links face outside is sent no filter, and the refusal names the
|
||||
// module that would have loaded it so the reader knows what is being withheld.
|
||||
func TestAMachineThatNamedNoOutwardLinkIsSentNoFilter(t *testing.T) {
|
||||
r := mcat.Resolution{Node: "anchor", Modules: []mcat.Manifest{
|
||||
{Module: "nftables", Filtering: &mcat.Filtering{Into: "/etc/mesh/filter.nft"}},
|
||||
{Module: "web", Listens: []mcat.Listening{{Port: 443, From: mcat.FromEverywhere}}},
|
||||
}}
|
||||
_, err := Declaration(r, mcat.Rendering{Mesh: []string{"198.51.100.2"}, TunnelInterface: "mesh0"})
|
||||
if err == nil {
|
||||
t.Fatal("a machine that named no outward link was sent a filter written around none")
|
||||
}
|
||||
for _, want := range []string{"anchor", "nftables", "face outside"} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Fatalf("the refusal does not say %q: %v", want, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// And a machine that names none but loads no filter is not refused: there is nothing to write.
|
||||
func TestAMachineWithNoFilterModuleIsNotRefused(t *testing.T) {
|
||||
r := mcat.Resolution{Node: "anchor", Modules: []mcat.Manifest{
|
||||
{Module: "web", Listens: []mcat.Listening{{Port: 443, From: mcat.FromEverywhere}}},
|
||||
}}
|
||||
if _, err := Declaration(r, mcat.Rendering{Mesh: []string{"198.51.100.2"}}); err != nil {
|
||||
t.Fatalf("a machine that loads no filter was refused one: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The rule set is the machine's, not the asking module's.
|
||||
func TestTheModuleAskingForTheRuleSetGetsEveryModulesPorts(t *testing.T) {
|
||||
r := mcat.Resolution{Modules: []mcat.Manifest{
|
||||
{Module: "firewall", Filtering: &mcat.Filtering{Into: "/etc/mesh/filter.nft"}},
|
||||
{Module: "web", Listens: []mcat.Listening{{Port: 443, From: mcat.FromEverywhere}}},
|
||||
}}
|
||||
out, err := Declaration(r, mcat.Rendering{Mesh: []string{"198.51.100.2"},
|
||||
OutwardLinks: []string{"eth0"}, TunnelInterface: "mesh0"})
|
||||
if err != nil {
|
||||
t.Fatalf("declaration: %v", err)
|
||||
}
|
||||
var content string
|
||||
for _, res := range out {
|
||||
if res["path"] == "/etc/mesh/filter.nft" {
|
||||
content, _ = res["content"].(string)
|
||||
}
|
||||
}
|
||||
if content == "" {
|
||||
t.Fatal("the module that asked for the rule set did not receive one")
|
||||
}
|
||||
if !strings.Contains(content, "dport 443 accept") {
|
||||
t.Fatalf("the rule set holds only the asking module's ports:\n%s", content)
|
||||
}
|
||||
}
|
||||
|
||||
// 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,
|
||||
// a credential, a rule set. Written after the service, the service is applied first, fails, and
|
||||
// the next reconcile fixes it. A fault that repairs itself on the second attempt is worse than one
|
||||
// that does not: what gets remembered is that it works.
|
||||
func TestWhatTheMeshComputesIsAppliedBeforeWhatTheModuleDeclared(t *testing.T) {
|
||||
r := mcat.Resolution{Modules: []mcat.Manifest{{
|
||||
Module: "firewall",
|
||||
Filtering: &mcat.Filtering{Into: "/etc/nftables.conf"},
|
||||
Resources: []map[string]any{
|
||||
{"id": "load", "type": "service", "unit": "nftables.service", "state": "running",
|
||||
"restart-on": []any{"filtering"}},
|
||||
},
|
||||
}}}
|
||||
out, err := Declaration(r, mcat.Rendering{Mesh: []string{"198.51.100.2"},
|
||||
OutwardLinks: []string{"eth0"}, TunnelInterface: "mesh0"})
|
||||
if err != nil {
|
||||
t.Fatalf("declaration: %v", err)
|
||||
}
|
||||
var order []string
|
||||
for _, res := range out {
|
||||
order = append(order, fmt.Sprint(res["id"]))
|
||||
}
|
||||
if len(order) != 2 {
|
||||
t.Fatalf("expected the rule set and the service, got %v", order)
|
||||
}
|
||||
if order[0] != "firewall.filtering" {
|
||||
t.Fatalf("the service is applied before the file it reflects: %v", order)
|
||||
}
|
||||
}
|
||||
|
||||
// The same, for a module whose resources are computed elsewhere — that branch replaces the
|
||||
// module's resources wholesale, and merging in the wrong place would throw away its credentials.
|
||||
func TestAComputedModuleStillGetsWhatTheMeshMadeForIt(t *testing.T) {
|
||||
r := mcat.Resolution{Modules: []mcat.Manifest{{
|
||||
Module: "networking", Computed: "mesh-network",
|
||||
OwnSecrets: mcat.OwnSecrets{"key": {Path: "/var/lib/mesh/key"}},
|
||||
}}}
|
||||
out, err := Declaration(r, mcat.Rendering{
|
||||
Needed: map[string]map[string]string{"networking": {"key": "sealed"}},
|
||||
Generators: map[string]mcat.Generator{"mesh-network": computedOnce{}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("declaration: %v", err)
|
||||
}
|
||||
var ids []string
|
||||
for _, res := range out {
|
||||
ids = append(ids, fmt.Sprint(res["id"]))
|
||||
}
|
||||
if len(ids) == 0 || ids[0] != "networking."+mcat.NeedID("key") {
|
||||
t.Fatalf("a computed module lost the secret the mesh made for it, or applies it late: %v", ids)
|
||||
}
|
||||
}
|
||||
|
||||
type computedOnce struct{}
|
||||
|
||||
func (computedOnce) Resources(string) ([]map[string]any, bool, error) {
|
||||
return []map[string]any{{"id": "wg", "type": "file", "path": "/etc/wireguard/wg0.conf",
|
||||
"content": "x", "mode": "0600"}}, true, nil
|
||||
}
|
||||
|
||||
// A consumer bound to something answered on its own machine still has to be told where it is.
|
||||
//
|
||||
// The location is a fact nobody needs — "it is here" — and everything beside it is not: the
|
||||
// binding carries what the provider said a consumer must know, which is the port. A build machine
|
||||
// sharing a node with the registry it pushes to sat in a loop saying it could not read its own
|
||||
// binding, because none was written.
|
||||
func TestSomethingAnsweredOnThisMachineIsStillBound(t *testing.T) {
|
||||
r := mcat.Resolution{
|
||||
Node: "anchor", At: "anchor.internal",
|
||||
Modules: []mcat.Manifest{
|
||||
{Module: "registry", Provides: []mcat.Offer{{Name: "artifact-store", Scope: mcat.ScopeMesh}},
|
||||
Serves: map[string]map[string]any{"artifact-store": {"port": 5000}}},
|
||||
{Module: "builder", Requires: []string{"artifact-store"},
|
||||
Binds: map[string]string{"artifact-store": "/var/lib/builder/store.json"}},
|
||||
},
|
||||
}
|
||||
out, err := Declaration(r, mcat.Rendering{})
|
||||
if err != nil {
|
||||
t.Fatalf("declaration: %v", err)
|
||||
}
|
||||
var content string
|
||||
for _, res := range out {
|
||||
if res["path"] == "/var/lib/builder/store.json" {
|
||||
content, _ = res["content"].(string)
|
||||
}
|
||||
}
|
||||
if content == "" {
|
||||
t.Fatal("nothing was written, so the consumer cannot find a provider on its own machine")
|
||||
}
|
||||
if !strings.Contains(content, "5000") {
|
||||
t.Fatalf("the binding does not carry the port, which is the part nobody can guess:\n%s", content)
|
||||
}
|
||||
if !strings.Contains(content, "anchor.internal") {
|
||||
t.Fatalf("the binding does not say where to reach it:\n%s", content)
|
||||
}
|
||||
}
|
||||
|
||||
// And a machine not on the private network still reaches itself.
|
||||
func TestAMachineOffTheNetworkIsBoundToItselfByAnAddressThatWorks(t *testing.T) {
|
||||
r := mcat.Resolution{
|
||||
Node: "alone",
|
||||
Modules: []mcat.Manifest{
|
||||
{Module: "registry", Provides: []mcat.Offer{{Name: "artifact-store", Scope: mcat.ScopeMesh}},
|
||||
Serves: map[string]map[string]any{"artifact-store": {"port": 5000}}},
|
||||
{Module: "builder", Requires: []string{"artifact-store"},
|
||||
Binds: map[string]string{"artifact-store": "/var/lib/builder/store.json"}},
|
||||
},
|
||||
}
|
||||
out, err := Declaration(r, mcat.Rendering{})
|
||||
if err != nil {
|
||||
t.Fatalf("declaration: %v", err)
|
||||
}
|
||||
for _, res := range out {
|
||||
if res["path"] != "/var/lib/builder/store.json" {
|
||||
continue
|
||||
}
|
||||
content, _ := res["content"].(string)
|
||||
if !strings.Contains(content, "127.0.0.1") {
|
||||
t.Fatalf("bound by a name nothing on this machine resolves:\n%s", content)
|
||||
}
|
||||
return
|
||||
}
|
||||
t.Fatal("nothing was written")
|
||||
}
|
||||
|
||||
// cannotSay is a generator that does not know what a machine opens.
|
||||
type cannotSay struct{}
|
||||
|
||||
func (cannotSay) Resources(string) ([]map[string]any, bool, error) { return nil, true, nil }
|
||||
|
||||
func (cannotSay) Listens(string) ([]mcat.Listening, error) {
|
||||
return nil, errors.New("this machine's endpoint has no port in it")
|
||||
}
|
||||
|
||||
// A generator that cannot say is refused, not read as silence.
|
||||
//
|
||||
// Closing a port on the evidence of a failure to look is how a machine is severed by a fault
|
||||
// somewhere else entirely — and the machine it would sever is the hub.
|
||||
func TestAGeneratorThatCannotSayRefusesTheRuleSet(t *testing.T) {
|
||||
_, err := Filtering(mcat.Resolution{Node: "anchor",
|
||||
Modules: []mcat.Manifest{{Module: "networking", Computed: "mesh-network"}},
|
||||
}, map[string]mcat.Generator{"mesh-network": cannotSay{}}, nil, nil)
|
||||
if err == nil {
|
||||
t.Fatal("a machine whose open ports could not be computed was given a rule set anyway")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "anchor") {
|
||||
t.Fatalf("the refusal does not name the machine: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A module may reflect a file another module put on the machine.
|
||||
//
|
||||
// The case this exists for: a resolver restarting when the mesh rewrites the names. Those are
|
||||
// computed by the mesh and belong to its module, not to the daemon's — so without this the
|
||||
// daemon would serve the names it started with for ever, while every check reported success.
|
||||
func TestAServiceCanReflectAnotherModulesFile(t *testing.T) {
|
||||
out, err := Declaration(mcat.Resolution{Node: "anchor", Modules: []mcat.Manifest{{
|
||||
Module: "dnsmasq",
|
||||
Resources: []map[string]any{
|
||||
{"id": "config", "type": "file", "path": "/etc/dnsmasq.conf",
|
||||
"content": "x", "mode": "0644"},
|
||||
{"id": "run", "type": "service", "unit": "dnsmasq.service", "state": "running",
|
||||
"restart-on": []any{"config", "mesh-resolver.nodes"}},
|
||||
},
|
||||
}}}, mcat.Rendering{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, r := range out {
|
||||
if r["type"] != "service" {
|
||||
continue
|
||||
}
|
||||
reflects := r["restart-on"].([]any)
|
||||
var own, other bool
|
||||
for _, id := range reflects {
|
||||
switch id.(string) {
|
||||
case "dnsmasq.config":
|
||||
own = true
|
||||
case "mesh-resolver.nodes":
|
||||
other = true
|
||||
}
|
||||
}
|
||||
if !own {
|
||||
t.Fatalf("its own file was not prefixed with its module: %v", reflects)
|
||||
}
|
||||
if !other {
|
||||
t.Fatalf("a file it named in full was prefixed again and now points at nothing: %v",
|
||||
reflects)
|
||||
}
|
||||
return
|
||||
}
|
||||
t.Fatal("no service in the declaration")
|
||||
}
|
||||
|
||||
// What answers a requirement is applied before what asked for it.
|
||||
//
|
||||
// The host does not sort, so the order written here is the order a machine applies. Selection
|
||||
// walks outward from what was assigned, which puts a consumer first — and a service that reads a
|
||||
// file another module writes then starts before the file exists, fails, and is fixed by the next
|
||||
// reconcile. That is the worst shape a fault can take: what gets remembered is that it works.
|
||||
func TestWhatAnsweredARequirementIsAppliedFirst(t *testing.T) {
|
||||
got, err := Resolve(map[string]mcat.Manifest{
|
||||
"dnsmasq": {Module: "dnsmasq", Requires: []string{"resolver-data"}},
|
||||
"data": {Module: "data", Provides: []mcat.Offer{{Name: "resolver-data"}}},
|
||||
}, []string{"dnsmasq"}, mcat.Node{Name: "anchor"}, mcat.World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Modules[0].Module != "data" {
|
||||
t.Fatalf("the consumer is applied before what it requires: %v", named(got))
|
||||
}
|
||||
}
|
||||
|
||||
// Through a chain, not only one step: a machine applies what the thing it needs needs, first.
|
||||
func TestAChainOfRequirementsIsAppliedFromTheBottom(t *testing.T) {
|
||||
got, err := Resolve(map[string]mcat.Manifest{
|
||||
"top": {Module: "top", Requires: []string{"middle-thing"}},
|
||||
"middle": {Module: "middle", Provides: []mcat.Offer{{Name: "middle-thing"}}, Requires: []string{"bottom-thing"}},
|
||||
"bottom": {Module: "bottom", Provides: []mcat.Offer{{Name: "bottom-thing"}}},
|
||||
}, []string{"top"}, mcat.Node{Name: "anchor"}, mcat.World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
order := named(got)
|
||||
if order[0] != "bottom" || order[1] != "middle" || order[2] != "top" {
|
||||
t.Fatalf("a chain was not applied from the bottom: %v", order)
|
||||
}
|
||||
}
|
||||
|
||||
// Modules with no relation keep the order selection gave them — assigned first, then what they
|
||||
// pulled in. A sort that reshuffled unrelated modules would make every declaration's diff
|
||||
// unreadable for no gain.
|
||||
func TestUnrelatedModulesKeepTheirOrder(t *testing.T) {
|
||||
got, err := Resolve(map[string]mcat.Manifest{
|
||||
"one": {Module: "one"},
|
||||
"two": {Module: "two"},
|
||||
"three": {Module: "three"},
|
||||
}, []string{"one", "two", "three"}, mcat.Node{Name: "anchor"}, mcat.World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
order := named(got)
|
||||
if order[0] != "one" || order[1] != "two" || order[2] != "three" {
|
||||
t.Fatalf("unrelated modules were reshuffled: %v", order)
|
||||
}
|
||||
}
|
||||
|
||||
// Two modules requiring each other are both applied rather than refused: a cycle here is not a
|
||||
// machine that cannot work, and refusing would make a cooperating pair impossible to assign.
|
||||
func TestTwoModulesRequiringEachOtherAreStillApplied(t *testing.T) {
|
||||
got, err := Resolve(map[string]mcat.Manifest{
|
||||
"a": {Module: "a", Provides: []mcat.Offer{{Name: "from-a"}}, Requires: []string{"from-b"}},
|
||||
"b": {Module: "b", Provides: []mcat.Offer{{Name: "from-b"}}, Requires: []string{"from-a"}},
|
||||
}, []string{"a"}, mcat.Node{Name: "anchor"}, mcat.World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got.Modules) != 2 {
|
||||
t.Fatalf("a cooperating pair was not applied: %v", named(got))
|
||||
}
|
||||
}
|
||||
|
||||
func named(r mcat.Resolution) []string {
|
||||
var out []string
|
||||
for _, m := range r.Modules {
|
||||
out = append(out, m.Module)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// A port's source is a per-node setting, not a manifest constant: the same module is internal on
|
||||
// one machine and public on another (novox/hq ADR 0046). postgres listens from the mesh by default;
|
||||
// a setting on one node exposes it to anywhere, and the rule set follows.
|
||||
func TestExposureSettingOverridesAListensSource(t *testing.T) {
|
||||
postgres := mcat.Manifest{Module: "postgres", Version: "1",
|
||||
Listens: []mcat.Listening{{Port: 5432, From: mcat.FromMesh, Why: "the database"}}}
|
||||
|
||||
base, err := Filtering(mcat.Resolution{Node: "ace", Modules: []mcat.Manifest{postgres}}, nil, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("filtering: %v", err)
|
||||
}
|
||||
if len(base) != 1 || base[0].From != mcat.FromMesh {
|
||||
t.Fatalf("without a setting, the default source is the mesh: %+v", base)
|
||||
}
|
||||
|
||||
expose := map[string]map[int]string{"postgres": {5432: mcat.FromEverywhere}}
|
||||
exposed, err := Filtering(mcat.Resolution{Node: "novox", Modules: []mcat.Manifest{postgres}}, nil, nil, expose)
|
||||
if err != nil {
|
||||
t.Fatalf("filtering: %v", err)
|
||||
}
|
||||
if len(exposed) != 1 || exposed[0].From != mcat.FromEverywhere {
|
||||
t.Fatalf("the setting did not open the port to anywhere: %+v", exposed)
|
||||
}
|
||||
}
|
||||
|
||||
// Exposure refuses a setting that names a port the module does not listen on, or a source that is
|
||||
// not a real one — a setting reaching nothing is worse than none (novox/hq ADR 0043/0046).
|
||||
func TestExposureRefusesAPortNotListenedOnAndABadSource(t *testing.T) {
|
||||
postgres := mcat.Manifest{Module: "postgres", Listens: []mcat.Listening{{Port: 5432, From: mcat.FromMesh}}}
|
||||
layer := func(port, source string) []mcat.Layer {
|
||||
return []mcat.Layer{{From: "node", Placement: map[string]any{mcat.ExposeSetting: map[string]any{port: source}}}}
|
||||
}
|
||||
|
||||
if _, err := Exposure(postgres, layer("5432", mcat.FromEverywhere)); err != nil {
|
||||
t.Fatalf("a valid exposure was refused: %v", err)
|
||||
}
|
||||
if _, err := Exposure(postgres, layer("6379", mcat.FromEverywhere)); err == nil {
|
||||
t.Error("a port the module does not listen on was accepted")
|
||||
}
|
||||
if _, err := Exposure(postgres, layer("5432", "everyone")); err == nil {
|
||||
t.Error("a source that is not mesh/anywhere/machine was accepted")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
package resolution
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"github.com/novox/mesh-host/internal/beside"
|
||||
)
|
||||
|
||||
// The catalogue's foundation modules as they are — in a merge check the catalogue cloned beside it,
|
||||
// elsewhere the copy captured in testdata/beside (internal/beside, novox/hq issue 432) — parsed by the
|
||||
// real parser (novox/hq ADR 0100):
|
||||
// the store and the broker say which of their ports the mesh guards on an adopted node, and the
|
||||
// filter module loads its table through a unit of its own whose stop deletes only that table.
|
||||
// moduleTree is where a module is read from: the catalogue beside, with the catalogue as the root. In
|
||||
// mesh-controller it first looked in the repository's own modules/<module>, where build-agent and
|
||||
// route-proxy live whole (novox/hq ADR 0420 §1, issue 1019); mesh-sdk has no such directory, and the
|
||||
// tests that read those two modules stay in mesh-controller (issue 1379).
|
||||
func moduleTree(t *testing.T, module string) (root, dir string) {
|
||||
t.Helper()
|
||||
return beside.Dir(t, "mesh-catalog"), filepath.Join(beside.Catalogue(t), module)
|
||||
}
|
||||
|
||||
func catalogueManifest(t *testing.T, module string) mcat.Manifest {
|
||||
t.Helper()
|
||||
root, dir := moduleTree(t, module)
|
||||
m, err := mcat.ReadTreeManifest(root, dir)
|
||||
if err != nil {
|
||||
t.Fatalf("%s does not parse:\n%v", module, err)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func TestTheForgesPortIsGivenLikeAnyOtherProvidersPort(t *testing.T) {
|
||||
forge := catalogueManifest(t, "gitea")
|
||||
|
||||
// The catalogue's number is a default and the node's setting moves it.
|
||||
given, err := GivenPorts(forge, []mcat.Layer{{From: "anchor",
|
||||
Placement: map[string]any{mcat.PortsSetting: map[string]any{"3000": float64(3100)}}}})
|
||||
if err != nil {
|
||||
t.Fatalf("the forge's port cannot be given on a node: %v", err)
|
||||
}
|
||||
if given[3000] != 3100 {
|
||||
t.Fatalf("the forge was given %v", given)
|
||||
}
|
||||
|
||||
// And every consumer of the package registry is told where the machine actually put it,
|
||||
// because that is read from what the forge serves rather than written in the consumer.
|
||||
if got := ServedOn(forge, "npm-package-registry", given)["port"]; got != 3100 {
|
||||
t.Errorf("the package registry is served on %v, not the port this node gave it", got)
|
||||
}
|
||||
if got := ServedOn(forge, "npm-package-registry", nil)["port"]; got != float64(3000) {
|
||||
t.Errorf("without a setting the forge serves %v, not the catalogue's port", got)
|
||||
}
|
||||
// And so is where a repository on it is cloned from (novox/hq ADR 0111), for the same reason:
|
||||
// a build composes the URL from what the forge serves, so a given port is a followed port.
|
||||
if got := ServedOn(forge, "git", given)["port"]; got != 3100 {
|
||||
t.Errorf("git is served on %v, not the port this node gave the forge", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The forge holds the seats it answers for (novox/hq ADR 0110, 0111), parsed by the real parser —
|
||||
// which refuses a delivering seat claimed by a module that does not provide what it delivers.
|
||||
func TestTheForgeHoldsTheNpmAndGitSeats(t *testing.T) {
|
||||
forge := catalogueManifest(t, "gitea")
|
||||
holds := map[string]bool{}
|
||||
for _, c := range forge.Claims {
|
||||
holds[c.Name] = true
|
||||
}
|
||||
for _, seat := range []string{"npm-package-registry", "git"} {
|
||||
if !holds[seat] {
|
||||
t.Errorf("gitea does not claim the %s seat: %+v", seat, forge.Claims)
|
||||
}
|
||||
}
|
||||
git := ServedOn(forge, "git", nil)
|
||||
if git["scheme"] != "http" || git["port"] != float64(3000) {
|
||||
t.Errorf("gitea serves nothing a clone URL can be composed from: %v", git)
|
||||
}
|
||||
npm := ServedOn(forge, "npm-package-registry", nil)
|
||||
if npm["npm-path"] != "/api/packages/novox/npm/" {
|
||||
t.Errorf("gitea no longer says where its npm registry is: %v", npm)
|
||||
}
|
||||
}
|
||||
|
||||
// **And the forge's own address follows it**, composed from the manifest in the catalogue beside
|
||||
// this checkout (novox/hq 04-ISSUES/088).
|
||||
//
|
||||
// The forge is reached a third way that neither test above covers: by its own code, over the
|
||||
// machine's loopback, told where to go in its environment. The `2999:3000` mapping that lets the
|
||||
// forge go on binding 3000 does nothing for a caller dialling the machine — so a literal there is
|
||||
// wrong on every node whose assignment differs, and wrong for a second reason on a node given the
|
||||
// port (ADR 0100). Composed through the whole path, because what proves the placeholder resolves
|
||||
// in an `env` at all is a declaration, not a manifest.
|
||||
func TestTheForgesOwnAddressFollowsThePortTheNodeGaveIt(t *testing.T) {
|
||||
forge, err := catalogueManifest(t, "gitea").Resolve(forgeBuilt(catalogueManifest(t, "gitea")))
|
||||
if err != nil {
|
||||
t.Fatalf("the forge's manifest does not resolve against its own build: %v", err)
|
||||
}
|
||||
r := mcat.Resolution{Node: "anchor", Account: "op", PublicDomain: "example.org",
|
||||
Modules: []mcat.Manifest{forge, theRuntime(t)}, Needs: []mcat.Needed{
|
||||
{Name: "postgres-database", For: "gitea", From: "anchor", At: "127.0.0.1",
|
||||
Serves: map[string]any{"port": float64(5432)}, Sealed: "sealed-db"},
|
||||
{Name: "route", For: "gitea", From: "anchor"},
|
||||
{Name: "secret", For: "gitea", From: "anchor", Local: "internal-token", Sealed: "sealed-token"},
|
||||
{Name: "secret", For: "gitea", From: "anchor", Local: "admin", Sealed: "sealed-admin"},
|
||||
}}
|
||||
|
||||
// The number this node was given for the forge — the one the machine it is about to run on
|
||||
// already publishes.
|
||||
out, err := Declaration(r, mcat.Rendering{ArtifactStore: "anchor.internal:5101",
|
||||
Needed: map[string]map[string]string{mcat.RuntimeModule: {"broker": "sealed-broker"}},
|
||||
Given: map[string]map[int]int{"gitea": {3000: 2999}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("the forge does not compose: %v", err)
|
||||
}
|
||||
|
||||
// What the machine publishes, and what the forge's sidecar is told to dial: one number.
|
||||
server := fileNamed(out, "gitea.server")
|
||||
if server == nil {
|
||||
t.Fatalf("the forge's own container is not in the declaration: %v", out)
|
||||
}
|
||||
if published := fmt.Sprint(server["ports"]); !strings.Contains(published, "2999:3000") {
|
||||
t.Fatalf("the forge is not published on the port this node gave it: %v", server["ports"])
|
||||
}
|
||||
// The forge's own code runs in the node's runtime (novox/hq ADR 0198), given its words there.
|
||||
runtime := fileNamed(out, mcat.RuntimeModule+"."+RuntimeProcessID())
|
||||
if runtime == nil {
|
||||
t.Fatalf("the node's runtime is not in the declaration: %v", ids(out))
|
||||
}
|
||||
env, _ := runtime["env"].(map[string]string)
|
||||
var given map[string]map[string]string
|
||||
if err := json.Unmarshal([]byte(env[mcat.RuntimeToolEnv]), &given); err != nil {
|
||||
t.Fatalf("the runtime's %s is not JSON: %q", mcat.RuntimeToolEnv, env[mcat.RuntimeToolEnv])
|
||||
}
|
||||
if given["gitea"]["MESH_GITEA_URL"] != "http://127.0.0.1:2999" {
|
||||
t.Fatalf("the forge's code dials %v while the machine publishes the forge on 2999 — "+
|
||||
"whatever reads it dials a dead port", given["gitea"]["MESH_GITEA_URL"])
|
||||
}
|
||||
}
|
||||
|
||||
// gitea's own sshd is unmodified — the module's own internal port is 22, the number in
|
||||
// `listens`, the same convention every other module in the catalogue uses (its internal port,
|
||||
// not an invented identity). Composed from the manifest in the catalogue beside this checkout,
|
||||
// because what the mesh publishes is a fact about what the module actually writes.
|
||||
func declaredGiteaSsh(t *testing.T, given map[int]int) map[string]any {
|
||||
t.Helper()
|
||||
forge := catalogueManifest(t, "gitea")
|
||||
resolved, err := forge.Resolve(forgeBuilt(forge))
|
||||
if err != nil {
|
||||
t.Fatalf("the forge's manifest does not resolve against its own build: %v", err)
|
||||
}
|
||||
r := mcat.Resolution{Node: "anchor", Account: "op", PublicDomain: "example.org",
|
||||
Modules: []mcat.Manifest{resolved, theRuntime(t)}, Needs: []mcat.Needed{
|
||||
{Name: "postgres-database", For: "gitea", From: "anchor", At: "127.0.0.1",
|
||||
Serves: map[string]any{"port": float64(5432)}, Sealed: "sealed-db"},
|
||||
{Name: "route", For: "gitea", From: "anchor"},
|
||||
{Name: "secret", For: "gitea", From: "anchor", Local: "internal-token", Sealed: "sealed-token"},
|
||||
{Name: "secret", For: "gitea", From: "anchor", Local: "admin", Sealed: "sealed-admin"},
|
||||
}}
|
||||
givenPorts := map[int]int{3000: 3000}
|
||||
for k, v := range given {
|
||||
givenPorts[k] = v
|
||||
}
|
||||
out, err := Declaration(r, mcat.Rendering{ArtifactStore: "anchor.internal:5101",
|
||||
Needed: map[string]map[string]string{mcat.RuntimeModule: {"broker": "sealed-broker"}},
|
||||
Ports: map[string]map[int]int{"gitea": givenPorts},
|
||||
Given: map[string]map[int]int{"gitea": given},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("the forge does not compose: %v", err)
|
||||
}
|
||||
server := fileNamed(out, "gitea.server")
|
||||
if server == nil {
|
||||
t.Fatalf("the forge's own container is not in the declaration: %v", out)
|
||||
}
|
||||
return server
|
||||
}
|
||||
|
||||
// **The forge publishes ssh at the mesh's own fixed convention by default** (novox/hq ADR 0100).
|
||||
//
|
||||
// `222` is the mesh's own public convention for the forge's ssh, written directly in the
|
||||
// manifest's `ports` — every node the forge has run on used the same number, so it needs no
|
||||
// per-node setting to reach it.
|
||||
func TestTheForgesSshPortIsTheMeshsFixedConventionByDefault(t *testing.T) {
|
||||
forge := catalogueManifest(t, "gitea")
|
||||
// Nothing was given — no node moved this port — which is the ordinary answer: the mesh only
|
||||
// reports what a setting moved, and the manifest's own `222:22` needs no move to be reached.
|
||||
given, err := GivenPorts(forge, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("the forge's ssh port cannot be given on a node: %v", err)
|
||||
}
|
||||
if len(given) != 0 {
|
||||
t.Fatalf("nothing moved the forge's ssh port, yet it was given %v", given)
|
||||
}
|
||||
server := declaredGiteaSsh(t, given)
|
||||
if published := fmt.Sprint(server["ports"]); !strings.Contains(published, "222:22") {
|
||||
t.Fatalf("the forge is published on %v, not its own fixed convention", server["ports"])
|
||||
}
|
||||
}
|
||||
|
||||
// **A node whose predecessor served git on a different number can still be told to leave it
|
||||
// there.** The setting names the port the module itself listens on — 22, gitea's own sshd, the
|
||||
// same number `listens` uses — not the mesh's own default machine-side number, so moving it does
|
||||
// not require guessing what the manifest happens to default to.
|
||||
func TestANodeMayGiveTheForgesSshPortADifferentNumber(t *testing.T) {
|
||||
forge := catalogueManifest(t, "gitea")
|
||||
given, err := GivenPorts(forge, []mcat.Layer{{From: "anchor",
|
||||
Placement: map[string]any{mcat.PortsSetting: map[string]any{"22": float64(9022)}}}})
|
||||
if err != nil {
|
||||
t.Fatalf("the forge's ssh port cannot be moved on a node: %v", err)
|
||||
}
|
||||
if want := map[int]int{22: 9022}; !reflect.DeepEqual(given, want) {
|
||||
t.Fatalf("the forge was given %v, and the setting named %v", given, want)
|
||||
}
|
||||
server := declaredGiteaSsh(t, given)
|
||||
if published := fmt.Sprint(server["ports"]); !strings.Contains(published, "9022:22") ||
|
||||
strings.Contains(published, "222:22") {
|
||||
t.Fatalf("the forge is published on %v, not the port this node gave it", server["ports"])
|
||||
}
|
||||
}
|
||||
|
||||
// forgeBuilt is a build of every artifact the forge's manifest declares: its code bundle and, since the
|
||||
// catalogue added it (novox/hq ADR 0251 §4), its npm registry's. Named from the manifest, so a third
|
||||
// artifact does not break every forge test again.
|
||||
func forgeBuilt(forge mcat.Manifest) []mcat.Built {
|
||||
var out []mcat.Built
|
||||
for _, a := range forge.Build.Artifacts {
|
||||
out = append(out, mcat.Built{Name: a.Name, Kind: mcat.ArtifactBundle,
|
||||
Reference: mcat.ArtifactStoreScheme + "gitea/" + a.Name + "/blobs/" + bundleDigest, Digest: bundleDigest})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// catalogueResolved is a catalogue module as the mesh holds it once built: the manifest as written, resolved against
|
||||
// what the build produced besides the archive its templates are packed into, which carries the text read back from the
|
||||
// module's files/ directory as the real build reads it (novox/hq ADR 0349 §2) — so a module whose file text moved into
|
||||
// templates composes with it, and one that did not is resolved as it was.
|
||||
func catalogueResolved(t *testing.T, module string, built ...mcat.Built) mcat.Manifest {
|
||||
t.Helper()
|
||||
root, dir := moduleTree(t, module)
|
||||
m, err := mcat.ReadTreeManifestWritten(root, dir)
|
||||
if err != nil {
|
||||
t.Fatalf("%s does not parse:\n%v", module, err)
|
||||
}
|
||||
if name := mcat.TemplatesArtifact(m); name != "" {
|
||||
read, err := mcat.ReadTemplates(m, dir)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", module, err)
|
||||
}
|
||||
// By the path inside the archive, as the build reads it back, not by the path in the module's directory.
|
||||
texts := map[string]string{}
|
||||
for path, text := range read {
|
||||
texts[strings.TrimPrefix(path, m.TemplatesDir()+"/")] = text
|
||||
}
|
||||
built = append(built, mcat.Built{Name: name, Kind: mcat.ArtifactArchive,
|
||||
Reference: mcat.ArtifactStoreScheme + module + "/" + name + "/blobs/" + bundleDigest, Digest: bundleDigest,
|
||||
Texts: texts})
|
||||
}
|
||||
held, err := m.Resolve(built)
|
||||
if err != nil {
|
||||
t.Fatalf("%s does not resolve against its own build: %v", module, err)
|
||||
}
|
||||
return held
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package resolution
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
|
||||
func runtimesOwn() mcat.Manifest {
|
||||
return mcat.Manifest{Module: "docker", Resources: []map[string]any{
|
||||
{"id": "daemon", "type": "file", "path": "/etc/docker/daemon.json", "into": "json",
|
||||
"content": `{"live-restore": true}`},
|
||||
{"id": "runtime", "type": "service", "unit": "docker.service", "state": "running",
|
||||
"reload-on": []any{"daemon"}},
|
||||
}}
|
||||
}
|
||||
|
||||
func TestAGeneratedResourceCollidingWithAModulesIsRefused(t *testing.T) {
|
||||
r := mcat.Resolution{Node: "workstation", Modules: []mcat.Manifest{
|
||||
{Module: "mesh-network", Computed: "mesh-network", Provides: mcat.Offers("private-network")},
|
||||
runtimesOwn(),
|
||||
}}
|
||||
_, err := Declaration(r, mcat.Rendering{Generators: map[string]mcat.Generator{"mesh-network": reloading{}}})
|
||||
if err == nil {
|
||||
t.Fatal("a generated resource declaring the runtime's file beside the runtime's module was accepted")
|
||||
}
|
||||
for _, want := range []string{"mesh-network", "docker", "/etc/docker/daemon.json", "docker.service"} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("the refusal does not name %s: %v", want, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAGeneratedResourceBesideAModulesOwnIsComposed(t *testing.T) {
|
||||
r := mcat.Resolution{Node: "workstation", Modules: []mcat.Manifest{
|
||||
{Module: "mesh-network", Computed: "mesh-network", Provides: mcat.Offers("private-network")},
|
||||
runtimesOwn(),
|
||||
}}
|
||||
gen := &fake{on: map[string]bool{"workstation": true}}
|
||||
out, err := Declaration(r, mcat.Rendering{Generators: map[string]mcat.Generator{"mesh-network": gen}})
|
||||
if err != nil {
|
||||
t.Fatalf("disjoint resources were refused: %v", err)
|
||||
}
|
||||
if fileNamed(out, "docker.daemon") == nil {
|
||||
t.Fatalf("the runtime's own file is missing: %v", out)
|
||||
}
|
||||
// And a machine not on the network yet generates nothing, which collides with nothing.
|
||||
if _, err := Declaration(r, mcat.Rendering{Generators: map[string]mcat.Generator{"mesh-network": &fake{}}}); err != nil {
|
||||
t.Fatalf("a machine off the network was refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The catalogue's container runtime module states the mesh's registry itself (ADR 0222).
|
||||
func TestTheRuntimesModuleTrustsTheMeshsRegistry(t *testing.T) {
|
||||
docker := catalogueManifest(t, "docker")
|
||||
r := mcat.Resolution{Node: "workstation", Modules: []mcat.Manifest{docker}}
|
||||
out, err := Declaration(r, mcat.Rendering{
|
||||
SeatReach: map[string]string{"mesh-artifact-store": "anchor.internal:5100"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
daemon := fileNamed(out, "docker.daemon")
|
||||
if daemon == nil || daemon["into"] != "json" {
|
||||
t.Fatalf("the runtime's file is not written into: %v", daemon)
|
||||
}
|
||||
content, _ := daemon["content"].(string)
|
||||
if !strings.Contains(content, `"insecure-registries": ["anchor.internal:5100"]`) ||
|
||||
!strings.Contains(content, `"live-restore": true`) {
|
||||
t.Fatalf("the runtime's file says %q", content)
|
||||
}
|
||||
|
||||
out, err = Declaration(r, mcat.Rendering{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if content, _ := fileNamed(out, "docker.daemon")["content"].(string); strings.Contains(content, "insecure-registries") {
|
||||
t.Fatalf("with no store on the network, the runtime is told %q", content)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package resolution
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
|
||||
// aProviderWithABundle is a provider whose code is a bundle the node's runtime runs — the shape
|
||||
// every TypeScript provisioner has since ADR 0198.
|
||||
func aProviderWithABundle() mcat.Manifest {
|
||||
return mcat.Manifest{
|
||||
Module: "mongodb", Version: "1",
|
||||
Provides: mcat.FromAnywhere("mongodb-database"),
|
||||
Receives: map[string]string{"mongodb-database": "/var/lib/mongodb/grants/mesh.json"},
|
||||
Grants: map[string]string{"mongodb-database": "/var/lib/mongodb/grants"},
|
||||
Bundles: []mcat.Bundle{{Name: "code", Language: "typescript"}},
|
||||
Resources: []map[string]any{{
|
||||
"id": "server", "type": "container", "name": "mongodb-server",
|
||||
"image": "mongo@sha256:" + strings.Repeat("a", 64),
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
func TestAGrantSecretIsOwnedByTheAccountThatProvisions(t *testing.T) {
|
||||
r, err := Resolve(shelf(aProviderWithABundle()), []string{"mongodb"}, reachable(), mcat.World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r.Account = "operator"
|
||||
out, err := Declaration(r, mcat.Rendering{Grants: []mcat.Grant{{
|
||||
Provision: "mongodb-database", Consumer: "workstation", From: "photos", Slug: "photos",
|
||||
Values: map[string]any{}, Sealed: "c2VhbGVk",
|
||||
}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var secret map[string]any
|
||||
for _, res := range out {
|
||||
if res["type"] == "file" && strings.HasSuffix(fmtPath(res), ".secret") {
|
||||
secret = res
|
||||
}
|
||||
}
|
||||
if secret == nil {
|
||||
t.Fatalf("no grant secret was composed at all: %v", out)
|
||||
}
|
||||
if got := secret["owner"]; got != "operator" {
|
||||
t.Fatalf("the grant secret at %v belongs to %v; the provisioner runs as %q and a "+
|
||||
"root-owned 0600 file is one it cannot read — which is silent, because the harness "+
|
||||
"calls it \"not readable yet\"", fmtPath(secret), got, "operator")
|
||||
}
|
||||
// A grant whose offer presents no credential (form none, issue 1126) carries nothing sealed: no secret file is
|
||||
// written for it, as none is for a need with no credential, and the provider still learns who asked.
|
||||
out, err = Declaration(r, mcat.Rendering{Grants: []mcat.Grant{{
|
||||
Provision: "mongodb-database", Consumer: "workstation", From: "photos", Slug: "photos", Values: map[string]any{}}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
asked := false
|
||||
for _, res := range out {
|
||||
if res["type"] == "file" && strings.HasSuffix(fmtPath(res), ".secret") {
|
||||
t.Errorf("a grant with no credential writes %v", res)
|
||||
}
|
||||
asked = asked || fmtPath(res) == "/var/lib/mongodb/grants/mesh.json"
|
||||
}
|
||||
if !asked {
|
||||
t.Errorf("the provider no longer learns who asked: %v", out)
|
||||
}
|
||||
}
|
||||
|
||||
// And a provider whose code still runs in a container keeps the owner it declares, so this
|
||||
// changes nothing for the modules the runtime has not taken.
|
||||
func TestAContainerProvidersGrantSecretKeepsItsDeclaredOwner(t *testing.T) {
|
||||
m := aProviderWithABundle()
|
||||
m.Bundles = nil
|
||||
m.SecretsOwner = "65534:65534"
|
||||
r, err := Resolve(shelf(m), []string{"mongodb"}, reachable(), mcat.World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r.Account = "operator"
|
||||
out, err := Declaration(r, mcat.Rendering{Grants: []mcat.Grant{{
|
||||
Provision: "mongodb-database", Consumer: "workstation", From: "photos", Slug: "photos",
|
||||
Values: map[string]any{}, Sealed: "c2VhbGVk",
|
||||
}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, res := range out {
|
||||
if res["type"] == "file" && strings.HasSuffix(fmtPath(res), ".secret") {
|
||||
if got := res["owner"]; got != "65534:65534" {
|
||||
t.Fatalf("a container provider's grant secret belongs to %v, not what it declares", got)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatal("no grant secret was composed")
|
||||
}
|
||||
|
||||
func fmtPath(r map[string]any) string {
|
||||
p, _ := r["path"].(string)
|
||||
return p
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
package resolution
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
|
||||
// graphicalSessionSeats are the eleven, in the order ADR 0208's table reads, each with the verbs
|
||||
// research 026/05 starts it with. Three have none yet: the bar (which receives blocks instead,
|
||||
// ADR 0255), the compositor and the secret
|
||||
// service are roles a second holder competes for, and nothing has needed to ask them anything.
|
||||
func graphicalSessionSeats() []mcat.Seat {
|
||||
const decided = "novox/hq ADR 0208"
|
||||
return []mcat.Seat{
|
||||
{Name: mcat.LoginManagerSeat, Scope: mcat.ScopeNode, Decision: decided, Serves: []mcat.Verb{
|
||||
{Name: "sessions", Description: "The sessions the login manager offers on this machine, and which " +
|
||||
"one the operator account starts by default.",
|
||||
Input: schema(map[string]string{}, nil)},
|
||||
}},
|
||||
{Name: mcat.DisplayServerSeat, Scope: mcat.ScopeNode, Decision: decided, Serves: []mcat.Verb{
|
||||
{Name: "displays", Description: "The monitors connected now, each with its identity, its modes and " +
|
||||
"where it is placed; and the layout profile in force, if one matches.",
|
||||
Input: schema(map[string]string{}, nil)},
|
||||
// Profiles are keyed by the monitors' identities and are the operator's data (ADR 0208 §6).
|
||||
{Name: "layout", Description: "The monitor layout profiles, keyed by the connected monitors' " +
|
||||
"identities: list them, save the current arrangement under a name, or apply one.",
|
||||
Input: withEnum(schema(map[string]string{
|
||||
"action": "list, save or apply",
|
||||
"name": "the profile to save or apply (save and apply only)",
|
||||
}, []string{"action"}), "action", "list", "save", "apply")},
|
||||
}},
|
||||
// It receives window-manager configuration lines from every other module (novox/hq ADR 0212):
|
||||
// bindings, start-up commands, rules — placed by the session's holder, never written into
|
||||
// its directory by the contributor.
|
||||
{Name: mcat.DisplaySessionSeat, Scope: mcat.ScopeNode, Decision: decided,
|
||||
Receives: []mcat.Receivable{{Kind: "config", Comment: "#"}}, Serves: []mcat.Verb{
|
||||
{Name: "reload", Description: "Reload the session's configuration in place, keeping its windows.",
|
||||
Input: schema(map[string]string{}, nil)},
|
||||
{Name: "workspaces", Description: "The session's workspaces: each one's name, output, and whether " +
|
||||
"it is visible or focused.",
|
||||
Input: schema(map[string]string{}, nil)},
|
||||
{Name: "windows", Description: "The session's windows: each one's title, class, workspace and " +
|
||||
"whether it has focus; narrowed to one workspace when named.",
|
||||
Input: schema(map[string]string{"workspace": "one workspace (optional)"}, nil)},
|
||||
}},
|
||||
{Name: mcat.TerminalEmulatorSeat, Scope: mcat.ScopeNode, Decision: decided, Serves: []mcat.Verb{
|
||||
{Name: "open", Description: "Open a terminal window in the operator's session, running a command " +
|
||||
"or the login shell, in a directory or the account's home.",
|
||||
Input: schema(map[string]string{
|
||||
"command": "what to run in it (optional; the login shell when absent)",
|
||||
"directory": "where it starts (optional; the account's home when absent)",
|
||||
}, nil)},
|
||||
}},
|
||||
{Name: mcat.LauncherSeat, Scope: mcat.ScopeNode, Decision: decided, Serves: []mcat.Verb{
|
||||
{Name: "menu", Description: "Put a menu of choices in front of the operator and answer with the " +
|
||||
"one chosen, or nothing when the menu was dismissed — the dmenu-compatible contract.",
|
||||
Input: map[string]any{"type": "object", "required": []string{"choices"},
|
||||
"properties": map[string]any{
|
||||
"choices": map[string]any{"type": "array", "items": map[string]any{"type": "string"},
|
||||
"description": "the lines to choose between, in order"},
|
||||
"prompt": map[string]any{"type": "string", "description": "what the menu asks (optional)"},
|
||||
}}},
|
||||
// A value the operator types and nobody sees (novox/hq ADR 0259 §10): a hidden prompt whose answer
|
||||
// is sealed to the asker's key, so it is never plaintext on the bus or in any call's record.
|
||||
// **Optional while its holders catch up** (ADR 0246): rofi serves it once this is live.
|
||||
{Name: "secret", Optional: true, Description: "Ask the operator for a value in a prompt that " +
|
||||
"does not show what is typed, and answer it sealed to the key the asker gives — never in " +
|
||||
"the clear — or cancelled when the prompt was dismissed or not answered in time.",
|
||||
// By name, never by words (the review of 2026-10-09, M4): the holder writes the prompt from the
|
||||
// module, the secret and the machine, and says the controller asks — the bus lets nobody else
|
||||
// ask it (broker.ControllerOnly) — so no caller puts words of its own before the operator.
|
||||
Input: schema(map[string]string{
|
||||
"module": "the module whose own secret is asked for",
|
||||
"secret": "the own secret's name",
|
||||
"node": "the machine the module runs on",
|
||||
"seal_to": "the asker's public sealing key: the answer is sealed to it",
|
||||
"timeout_seconds": "give up after this long (optional)",
|
||||
}, []string{"module", "secret", "node", "seal_to"})},
|
||||
}},
|
||||
{Name: mcat.NotifierSeat, Scope: mcat.ScopeNode, Decision: decided, Serves: []mcat.Verb{
|
||||
{Name: "send", Description: "Show the operator a notification.",
|
||||
Input: withEnum(schema(map[string]string{
|
||||
"title": "the notification's summary",
|
||||
"body": "its text (optional)",
|
||||
"urgency": "low, normal (the default) or critical",
|
||||
}, []string{"title"}), "urgency", "low", "normal", "critical")},
|
||||
{Name: "history", Description: "The notifications shown lately, newest first.",
|
||||
Input: schema(map[string]string{"limit": "how many (optional, default 20)"}, nil)},
|
||||
}},
|
||||
{Name: mcat.LockScreenSeat, Scope: mcat.ScopeNode, Decision: decided, Serves: []mcat.Verb{
|
||||
{Name: "lock", Description: "Lock the operator's session now.",
|
||||
Input: schema(map[string]string{}, nil)},
|
||||
}},
|
||||
{Name: mcat.ClipboardSeat, Scope: mcat.ScopeNode, Decision: decided, Serves: []mcat.Verb{
|
||||
{Name: "history", Description: "What the clipboard held lately, newest first.",
|
||||
Input: schema(map[string]string{"limit": "how many (optional, default 20)"}, nil)},
|
||||
{Name: "copy", Description: "Put text on the operator's clipboard.",
|
||||
Input: schema(map[string]string{"text": "the text"}, []string{"text"})},
|
||||
}},
|
||||
// It receives blocks as data (novox/hq ADR 0255): what a block shows, said for any bar, which
|
||||
// the holder renders in its own grammar. A module that knows something about the machine — the
|
||||
// power module its battery — gives the block; the bar knows no machine's hardware.
|
||||
{Name: mcat.BarSeat, Scope: mcat.ScopeNode, Decision: decided,
|
||||
Receives: []mcat.Receivable{{Kind: mcat.BarKindBlock, Comment: "#", Offered: true, Shape: barBlockShape()}}},
|
||||
{Name: mcat.CompositorSeat, Scope: mcat.ScopeNode, Decision: decided},
|
||||
{Name: mcat.SecretServiceSeat, Scope: mcat.ScopeNode, Decision: decided},
|
||||
}
|
||||
}
|
||||
|
||||
// withEnum narrows one string property of a schema to the values it may take, so a caller is told
|
||||
// the choices by the schema rather than by a refusal.
|
||||
func withEnum(s map[string]any, property string, values ...string) map[string]any {
|
||||
props := s["properties"].(map[string]any)
|
||||
p := props[property].(map[string]any)
|
||||
p["enum"] = values
|
||||
return s
|
||||
}
|
||||
|
||||
// barShows is what a contributed block may show, and the options each takes — the bar-neutral
|
||||
// vocabulary every holder of node-bar renders. A new entry is a decision, and every holder's template
|
||||
// renders it (the shape's examples) before it is accepted.
|
||||
var barShows = map[string]map[string]barOption{
|
||||
// The machine's battery: its charge, and how long it lasts or takes to fill.
|
||||
"battery": {"device": {kind: "text"}},
|
||||
// One line a command prints, run every interval seconds: what the bar cannot know itself.
|
||||
"command": {"command": {kind: "text", required: true}, "interval": {kind: "seconds", required: true}},
|
||||
// A value the contributor keeps on the bus (novox/hq ADR 0255): its state `<module>.<name>` and the
|
||||
// key, this machine's name when absent. The value is an object whose `text` is what is shown. The
|
||||
// holder is granted to read it and shows it however its bar shows a changing value; no file and
|
||||
// no command passes between the two modules.
|
||||
"state": {"state": {kind: "text", required: true}, "key": {kind: "text"}},
|
||||
}
|
||||
|
||||
// barBlockReads is the state and key a block shows, if any; an empty key is the machine's own.
|
||||
func barBlockReads(data map[string]any) []mcat.StateRead {
|
||||
if data["shows"] != "state" {
|
||||
return nil
|
||||
}
|
||||
options, _ := data["options"].(map[string]any)
|
||||
s, ok := options["state"].(string)
|
||||
if !ok || s == "" {
|
||||
return nil
|
||||
}
|
||||
key, _ := options["key"].(string)
|
||||
return []mcat.StateRead{{Read: s, Key: key}}
|
||||
}
|
||||
|
||||
type barOption struct {
|
||||
kind string // text, or seconds (a whole number from 1 to 86400)
|
||||
required bool
|
||||
}
|
||||
|
||||
// barBlockShape is the shape of a block (novox/hq ADR 0255): which bar, where on it, in what order,
|
||||
// what it shows, and that kind's options.
|
||||
//
|
||||
// - bar: top or bottom;
|
||||
// - place: resources (beside the processor, memory and disks) or status (beside the sound, before the
|
||||
// clock) — the two places every holder keeps for contributed blocks;
|
||||
// - order: 0–99 within the place, 50 when absent;
|
||||
// - shows: battery or command; options: what that kind takes.
|
||||
func barBlockShape() *mcat.Shape {
|
||||
return &mcat.Shape{
|
||||
Text: true,
|
||||
Places: []mcat.PlaceField{{Field: "bar", Values: []string{"bottom", "top"}},
|
||||
{Field: "place", Values: []string{"resources", "status"}}},
|
||||
Order: "order",
|
||||
Check: barBlockProblems,
|
||||
Reads: barBlockReads,
|
||||
Examples: []map[string]any{
|
||||
{"bar": "bottom", "place": "status", "shows": "battery"},
|
||||
{"bar": "bottom", "place": "status", "shows": "battery", "options": map[string]any{"device": "BAT0"}},
|
||||
{"bar": "bottom", "place": "status", "shows": "command",
|
||||
"options": map[string]any{"command": "echo \"a line\"", "interval": float64(5)}},
|
||||
{"bar": "bottom", "place": "status", "shows": "state", "options": map[string]any{"state": "power.draw"}},
|
||||
{"bar": "bottom", "place": "status", "shows": "state",
|
||||
"options": map[string]any{"state": "power.draw", "key": "a-machine"}},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func barBlockProblems(data map[string]any) []string {
|
||||
var problems []string
|
||||
for field := range data {
|
||||
if !oneOf([]string{"bar", "place", "order", "shows", "options"}, field) {
|
||||
problems = append(problems, fmt.Sprintf("a block has no field %q; it has bar, place, order, shows and options", field))
|
||||
}
|
||||
}
|
||||
shows, _ := data["shows"].(string)
|
||||
takes, known := barShows[shows]
|
||||
if !known {
|
||||
return append(problems, fmt.Sprintf("a block shows %v; it shows one of %s", data["shows"],
|
||||
strings.Join(sortedKeys(barShows), ", ")))
|
||||
}
|
||||
options := map[string]any{}
|
||||
if raw, has := data["options"]; has {
|
||||
o, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
return append(problems, "a block's options are an object")
|
||||
}
|
||||
options = o
|
||||
}
|
||||
for name, v := range options {
|
||||
opt, ok := takes[name]
|
||||
if !ok {
|
||||
problems = append(problems, fmt.Sprintf("a %s block has no option %q; it takes %s", shows, name,
|
||||
orNothing(sortedKeys(takes))))
|
||||
continue
|
||||
}
|
||||
switch opt.kind {
|
||||
case "text":
|
||||
if t, isText := v.(string); !isText || strings.TrimSpace(t) == "" {
|
||||
problems = append(problems, fmt.Sprintf("a %s block's %s is text", shows, name))
|
||||
}
|
||||
case "seconds":
|
||||
if _, whole := wholeIn(v, 1, 86400); !whole {
|
||||
problems = append(problems, fmt.Sprintf("a %s block's %s is a whole number of seconds from 1 to 86400", shows, name))
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, name := range sortedKeys(takes) {
|
||||
if _, has := options[name]; takes[name].required && !has {
|
||||
problems = append(problems, fmt.Sprintf("a %s block needs the option %s", shows, name))
|
||||
}
|
||||
}
|
||||
return problems
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package resolution
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
|
||||
func displayServer(name, display string, claims ...string) mcat.Manifest {
|
||||
m := mcat.Manifest{Module: name, Provides: []mcat.Offer{{Name: display, Reach: mcat.ReachMachine}}}
|
||||
for _, c := range claims {
|
||||
m.Claims = append(m.Claims, mcat.Claim{Name: c})
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func windowManager() mcat.Manifest {
|
||||
return mcat.Manifest{Module: "i3", Requires: []string{"x11-display"}}
|
||||
}
|
||||
|
||||
// §3: a display is resolved on the requiring module's own node.
|
||||
func TestAMachineReachRequirementResolvesToTheProviderOnItsOwnNode(t *testing.T) {
|
||||
cat := shelf(windowManager(), displayServer("xorg", "x11-display", mcat.DisplayServerSeat))
|
||||
got, err := Resolve(cat, []string{"xorg", "i3"}, workstation(), mcat.World{})
|
||||
if err != nil {
|
||||
t.Fatalf("i3 beside xorg did not resolve: %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(names(got), []string{"xorg", "i3"}) && !reflect.DeepEqual(names(got), []string{"i3", "xorg"}) {
|
||||
t.Errorf("resolved %v", names(got))
|
||||
}
|
||||
}
|
||||
|
||||
// §3: never answered by installing a provider, and never by another machine's.
|
||||
func TestAMachineReachRequirementIsNotPulledInNorAnsweredFromAnotherNode(t *testing.T) {
|
||||
cat := shelf(windowManager(),
|
||||
displayServer("xorg", "x11-display", mcat.DisplayServerSeat),
|
||||
displayServer("xwayland", "x11-display"))
|
||||
// Another machine runs xorg and says so to the world; it does not count.
|
||||
world := mcat.World{Offered: map[string][]mcat.Provider{
|
||||
"x11-display": {{Node: "laptop", At: "laptop.mesh", Module: "xorg"}}}}
|
||||
_, err := Resolve(cat, []string{"i3"}, workstation(), world)
|
||||
if err == nil {
|
||||
t.Fatal("i3 resolved on a machine with no display of its own")
|
||||
}
|
||||
for _, want := range []string{
|
||||
`"x11-display" is wanted by i3`, "usable only on the machine that provides it",
|
||||
"assign one to workstation", "xorg (holds node-display-server)", "xwayland",
|
||||
} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("the refusal does not say %q:\n%v", want, err)
|
||||
}
|
||||
}
|
||||
// With a single provider in the catalogue too: one candidate is still not a choice to make
|
||||
// for somebody, unlike a node-scoped provision without the machine's reach.
|
||||
_, err = Resolve(shelf(windowManager(), displayServer("xorg", "x11-display", mcat.DisplayServerSeat)),
|
||||
[]string{"i3"}, workstation(), mcat.World{})
|
||||
if err == nil {
|
||||
t.Fatal("xorg was pulled in for i3")
|
||||
}
|
||||
// Not in the first pass, whose refusals take the machine off the network.
|
||||
if _, err := Resolve(cat, []string{"i3"}, workstation(), mcat.World{Unchecked: true}); err != nil {
|
||||
t.Errorf("the first pass refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestACatalogueDisagreeingAboutAProvisionsReachIsRefused(t *testing.T) {
|
||||
cat := shelf(windowManager(), displayServer("xorg", "x11-display"), mcat.Manifest{Module: "fake-x", Provides: mcat.Offers("x11-display")})
|
||||
_, err := Resolve(cat, []string{"xorg", "i3"}, workstation(), mcat.World{})
|
||||
if err == nil || !strings.Contains(err.Error(), `the catalogue disagrees about "x11-display"`) {
|
||||
t.Fatalf("a provision with and without the machine's reach gave %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package resolution
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
|
||||
func aContainerWithGroups(groups ...any) map[string]any {
|
||||
return map[string]any{"id": "server", "type": "container", "name": "n8n", "image": "x@sha256:" + strings.Repeat("a", 64),
|
||||
"groups": groups}
|
||||
}
|
||||
|
||||
// 1636/AC3: a manifest container with groups: ["${setting:media-gid}"] resolves to the number the setting says.
|
||||
func TestAContainersGroupIsFilledFromTheSetting(t *testing.T) {
|
||||
r := aContainerWithGroups("${setting:media-gid}", float64(2001))
|
||||
layers := []mcat.Layer{{From: "ace", Values: map[string]any{"media-gid": float64(2000)}}}
|
||||
if err := settingInto(r, layers, "n8n"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if want := []any{2000, 2001}; !reflect.DeepEqual(r["groups"], want) {
|
||||
t.Fatalf("composed as %#v, want %#v", r["groups"], want)
|
||||
}
|
||||
// A value the operator wrote as text is read as the number it spells.
|
||||
r = aContainerWithGroups("${setting:media-gid}")
|
||||
if err := settingInto(r, []mcat.Layer{{From: "ace", Values: map[string]any{"media-gid": "2000"}}}, "n8n"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if want := []any{2000}; !reflect.DeepEqual(r["groups"], want) {
|
||||
t.Fatalf("composed as %#v, want %#v", r["groups"], want)
|
||||
}
|
||||
}
|
||||
|
||||
// 1636/AC3: an unset value refuses the module by name, and the key and the remedy are named; nothing is dropped.
|
||||
func TestAnUnsetGroupRefusesTheModuleByName(t *testing.T) {
|
||||
r := aContainerWithGroups("${setting:media-gid}")
|
||||
err := settingInto(r, nil, "n8n")
|
||||
if err == nil || !strings.Contains(err.Error(), "n8n") || !strings.Contains(err.Error(), "media-gid") {
|
||||
t.Fatalf("want a refusal naming n8n and media-gid, got %v", err)
|
||||
}
|
||||
if got, _ := r["groups"].([]any); len(got) != 1 || got[0] != "${setting:media-gid}" {
|
||||
t.Fatalf("the resource was changed: %#v", r["groups"])
|
||||
}
|
||||
}
|
||||
|
||||
// 1636/AC2: a value that is not a whole number above zero cannot be a group.
|
||||
func TestAGroupThatIsNotAWholeNumberAboveZeroIsRefused(t *testing.T) {
|
||||
for _, bad := range []any{"media", "-1", "0", float64(0), float64(2000.5), "2000:2000", "", true} {
|
||||
r := aContainerWithGroups("${setting:media-gid}")
|
||||
err := settingInto(r, []mcat.Layer{{From: "ace", Values: map[string]any{"media-gid": bad}}}, "n8n")
|
||||
if err == nil || !strings.Contains(err.Error(), "media-gid") {
|
||||
t.Errorf("value %#v: want a refusal naming the key, got %v", bad, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1636/AC3 composed for a machine, the way a push writes it: n8n's group is the number the machine's config value
|
||||
// says, written as a number, and the module is left out by name when the machine says none.
|
||||
func TestAComposedMachineGetsTheGroupTheConfigValueNames(t *testing.T) {
|
||||
manifest, err := parseRaw(t, map[string]any{
|
||||
"module": "n8n", "version": "1",
|
||||
"config": map[string]any{"media-gid": map[string]any{"type": "number", "minimum": 1, "required": true,
|
||||
"description": "the group that owns the media library on this machine"}},
|
||||
"resources": []any{aContainerWithGroups("${config:media-gid}")},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r := anAdoptedAnchor()
|
||||
r.Modules = append(r.Modules, manifest)
|
||||
with := anchorRendering(false)
|
||||
with.Settings["n8n"] = []mcat.Layer{{From: "anchor", Values: map[string]any{"media-gid": float64(2000)}}}
|
||||
composed, err := Compose(r, with)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if why, left := composed.LeftOut["n8n"]; left {
|
||||
t.Fatalf("left out: %s", why)
|
||||
}
|
||||
if got := byID(composed.Resources)["n8n.server"]["groups"]; !reflect.DeepEqual(got, []any{2000}) {
|
||||
t.Fatalf("composed groups %#v", got)
|
||||
}
|
||||
|
||||
without := anchorRendering(false)
|
||||
composed, err = Compose(r, without)
|
||||
if err == nil {
|
||||
why, left := composed.LeftOut["n8n"]
|
||||
if !left || !strings.Contains(why, "media-gid") {
|
||||
t.Fatalf("a machine that says no group kept n8n or left it out without the key: %q (%v)", why, left)
|
||||
}
|
||||
} else if !strings.Contains(err.Error(), "media-gid") {
|
||||
t.Fatalf("refused without the key: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The node-engine reads a container's groups only when it reports the capability (novox/hq ADR 0480 clause 7, issue 1638):
|
||||
// a module with a container that names groups is hosted on a machine that reports container-groups, and left out of one
|
||||
// that does not, the capability named, so the field is never sent to an engine that would refuse it.
|
||||
func TestAModuleWhoseContainerNamesGroupsIsHeldOnAMachineThatDoesNotReadThem(t *testing.T) {
|
||||
n8n := mcat.Manifest{Module: "n8n", Resources: []map[string]any{aContainerWithGroups("${config:media-gid}")}}
|
||||
plain := mcat.Manifest{Module: "plain", Resources: []map[string]any{
|
||||
{"id": "server", "type": "container", "name": "p", "image": "x@sha256:" + strings.Repeat("b", 64)}}}
|
||||
for _, m := range []mcat.Manifest{n8n, plain} {
|
||||
if got := impliedCapabilities(m); (m.Module == "n8n") != reflect.DeepEqual(got, []string{mcat.ContainerGroups}) {
|
||||
t.Errorf("%s implies %v", m.Module, got)
|
||||
}
|
||||
}
|
||||
shelf := shelf(n8n, plain)
|
||||
reads := mcat.Node{Name: "ace", Capabilities: map[string]bool{"container-runtime": true, mcat.ContainerGroups: true}}
|
||||
got, err := Resolve(shelf, []string{"n8n", "plain"}, reads, mcat.World{})
|
||||
if err != nil || len(got.Unhostable) != 0 || len(got.Modules) != 2 {
|
||||
t.Fatalf("a machine that reads groups: %v, held %+v, hosted %v", err, got.Unhostable, names(got))
|
||||
}
|
||||
old := mcat.Node{Name: "g14", Capabilities: map[string]bool{"container-runtime": true}}
|
||||
got, err = Resolve(shelf, []string{"n8n", "plain"}, old, mcat.World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got.Unhostable) != 1 || got.Unhostable[0].Module != "n8n" || !reflect.DeepEqual(got.Unhostable[0].Missing, []string{mcat.ContainerGroups}) {
|
||||
t.Fatalf("n8n was not held, naming the capability: %+v", got.Unhostable)
|
||||
}
|
||||
if h := names(got); len(h) != 1 || h[0] != "plain" {
|
||||
t.Errorf("the module without groups is not the one hosted: %v", h)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,665 @@
|
||||
package resolution
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"regexp"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
|
||||
// EncodeContributed is f as the bytes PlanProvision.Contributed carries, refused where DecodeContributed would refuse
|
||||
// them, so a plan never carries what its reader cannot take.
|
||||
func EncodeContributed(f mcat.ContributedForm) (json.RawMessage, error) {
|
||||
f.V = mcat.ContributedVersion
|
||||
raw, err := json.Marshal(f)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := mcat.DecodeContributed(raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
// provisionsOf is, from a resolution's needs, each provision module requires and its provider, by provision then
|
||||
// local name. A need answered by a record (a licence) or by no module names no provider on a machine and is left
|
||||
// out: the vault has no provider's value to make for it.
|
||||
func provisionsOf(needs []mcat.Needed, module string, handed bool, as string, contributes map[string]map[string]any,
|
||||
named map[string]bool) []mcat.PlanProvision {
|
||||
var out []mcat.PlanProvision
|
||||
for _, n := range needs {
|
||||
// **The one rule** (Keyed, issue 774): what the seat keys and the want waits for.
|
||||
if n.For != module || !Keyed(handed, n) {
|
||||
continue
|
||||
}
|
||||
p := mcat.PlanProvision{Provision: n.Name, Local: n.Local, Provider: n.From, ProviderModule: n.Module,
|
||||
CredentialForm: n.CredentialForm, CredentialTool: n.CredentialTool, Contributes: maps.Clone(contributes[n.Name]),
|
||||
At: n.At, From: n.From, As: as}
|
||||
// **A credential only a template names** (issue 788): named like a file's, so the client fills it from the
|
||||
// vault's row for this holder.
|
||||
if (named[n.Name] || named[mcat.SecretLocal(n.Name, n.Local)]) && n.IssuedToHolder {
|
||||
p.Name = mcat.SecretLocal(n.Name, n.Local)
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// sortProvisions orders a plan's provisions by provision, then local name.
|
||||
func sortProvisions(ps []mcat.PlanProvision) {
|
||||
sort.SliceStable(ps, func(i, j int) bool {
|
||||
if ps[i].Provision != ps[j].Provision {
|
||||
return ps[i].Provision < ps[j].Provision
|
||||
}
|
||||
return ps[i].Local < ps[j].Local
|
||||
})
|
||||
}
|
||||
|
||||
// ownershipOf is who owns a file at runtime, by its form, as the node-engine maps the old forms (mesh-host
|
||||
// declaration.File.Ownership): written into is managed, created once is a seed, any other is owned.
|
||||
func ownershipOf(r map[string]any) string {
|
||||
switch {
|
||||
case r["into"] != nil && fmt.Sprint(r["into"]) != "":
|
||||
return mcat.RuntimeManaged
|
||||
case r["create-once"] == true:
|
||||
return mcat.RuntimeSeed
|
||||
}
|
||||
return mcat.RuntimeOwned
|
||||
}
|
||||
|
||||
// takesOf is how the program reading a file takes a change, by what names the file among the module's resources:
|
||||
// restart-on wins over reload-on; empty when nothing names it (the program reads it when it uses it).
|
||||
func takesOf(m mcat.Manifest, id string) string {
|
||||
takes := ""
|
||||
for _, r := range m.Resources {
|
||||
if slices.Contains(stringsIn(r["restart-on"]), id) {
|
||||
return "restart-on"
|
||||
}
|
||||
if slices.Contains(stringsIn(r["reload-on"]), id) {
|
||||
takes = "reload-on"
|
||||
}
|
||||
}
|
||||
return takes
|
||||
}
|
||||
|
||||
// handing is whether this composition hands the module's templated files to the provisioner: handed (Handing), with
|
||||
// the templates its build kept. A module the vault judged handed whose build kept none is refused where its secrets
|
||||
// are placed ("nothing would fill it"), never composed with values the vault does not answer.
|
||||
func renderingHanding(with mcat.Rendering, m mcat.Manifest) bool {
|
||||
return Handing(with, m) && m.Handed != nil
|
||||
}
|
||||
|
||||
// Handing is whether a machine composed with this rendering hands the module to the provisioner, its files and
|
||||
// its own secrets (design 57 P6, V4): a moved module there, assigned or only pulled in by another (issue 765), on a
|
||||
// machine that can take it. The controller makes and seals none of such a module's secrets for that machine: its
|
||||
// client asks the vault.
|
||||
//
|
||||
// **Where the vault judged the module, its word is the rule** (Rendering.Assigned, issue 774): the vault leaves a
|
||||
// module's own secrets out of `own` exactly when the plan in its assignment hands it (AssignmentHands), so a node
|
||||
// composing itself hands exactly those, whatever its own reading of the manifest and the machine says. On 2026-10-31
|
||||
// postgres on novox was held: its plan handed its secrets, the vault answered none of them, and the node, judging by
|
||||
// the manifest and the machine, composed them itself and refused for want of `reader`. Two readers, one rule.
|
||||
func Handing(with mcat.Rendering, m mcat.Manifest) bool {
|
||||
if hands, judged := with.Assigned[m.Module]; judged {
|
||||
return hands
|
||||
}
|
||||
return m.Moved && m.Handed != nil && with.Hands && with.Provisioner != ""
|
||||
}
|
||||
|
||||
// requiredByWords begins why a module only pulled in by another is on a machine (Resolution.Because): `required by
|
||||
// <module>`.
|
||||
const requiredByWords = "required by "
|
||||
|
||||
// RequiredBy is the module that requires module on this machine when nothing assigned it there, only pulled it in
|
||||
// (issue 765): what its value in the assignments mirror names. Empty for a module assigned there, or not there.
|
||||
func RequiredBy(r mcat.Resolution, module string) string {
|
||||
by, _ := strings.CutPrefix(r.Because[module], requiredByWords)
|
||||
if by == r.Because[module] {
|
||||
return ""
|
||||
}
|
||||
return by
|
||||
}
|
||||
|
||||
// handedSecretFile is a handed module's own secret file as the declaration carries it: placed, the secret's
|
||||
// alone (0600 unless the manifest says otherwise), owned at runtime, without a value; the client fills it from the
|
||||
// vault.
|
||||
func handedSecretFile(r map[string]any, m mcat.Manifest, dirs, accessPaths, facts map[string]string,
|
||||
placed map[string]mcat.Placement) (map[string]any, mcat.PlanSecret, error) {
|
||||
name := fmt.Sprint(r[mcat.HandedSecretField])
|
||||
out := map[string]any{}
|
||||
for k, v := range r {
|
||||
out[k] = v
|
||||
}
|
||||
delete(out, mcat.HandedSecretField)
|
||||
delete(out, "sealed")
|
||||
if err := dirInto(out, dirs, m.Module); err != nil {
|
||||
return nil, mcat.PlanSecret{}, err
|
||||
}
|
||||
if err := accessInto(out, accessPaths, m.Module); err != nil {
|
||||
return nil, mcat.PlanSecret{}, err
|
||||
}
|
||||
ownerInto(out, placed)
|
||||
if err := machineInto(out, facts, m.Module); err != nil {
|
||||
return nil, mcat.PlanSecret{}, err
|
||||
}
|
||||
if out["mode"] == nil {
|
||||
out["mode"] = "0600"
|
||||
}
|
||||
out[mcat.OwnerAtRuntimeField] = mcat.RuntimeOwned
|
||||
return out, mcat.PlanSecret{Name: name, Path: fmt.Sprint(out["path"])}, nil
|
||||
}
|
||||
|
||||
// templatePlaceholder is a placeholder as a template writes it: its namespace and its key.
|
||||
var templatePlaceholder = regexp.MustCompile(`\$\{([a-z][a-z-]*):([^}\s$]+)\}`)
|
||||
|
||||
// configNamespaces are the namespaces the config domain answers: `config`, and `setting`, which the manifest's
|
||||
// reading writes it as (toSettingsForm).
|
||||
var configNamespaces = []string{"config", mcat.SettingField}
|
||||
|
||||
// mergesFile is whether a file resource says `merge` or `takes-settings`: the layers' settings are merged into it.
|
||||
func mergesFile(r map[string]any) bool {
|
||||
_, merges := r["merge"]
|
||||
_, takes := r[mcat.TakesSettingsField]
|
||||
return merges || takes
|
||||
}
|
||||
|
||||
// mergingIn is whether the module's resource id is a merging file (mergesFile).
|
||||
func mergingIn(m mcat.Manifest, id string) bool {
|
||||
for _, r := range m.Resources {
|
||||
if fmt.Sprint(r["id"]) == id {
|
||||
return mergesFile(r)
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// handedFile is a moved module's templated file as a handing declaration carries it: placed (its path and owner
|
||||
// filled by placement alone), saying who owns it at runtime, without its text. And its line of the plan.
|
||||
func handedFile(r map[string]any, m mcat.Manifest, dirs, accessPaths, facts map[string]string, placed map[string]mcat.Placement,
|
||||
with mcat.Rendering) (map[string]any, mcat.PlanFile, error) {
|
||||
id := fmt.Sprint(r["id"])
|
||||
out := map[string]any{}
|
||||
for k, v := range r {
|
||||
out[k] = v
|
||||
}
|
||||
// **No text, and nothing filled into it**: the client fills it from the template; the declaration says where
|
||||
// it goes and how it is written.
|
||||
delete(out, "content")
|
||||
delete(out, mcat.TrustedField)
|
||||
delete(out, mcat.NamesOnPurpose)
|
||||
delete(out, mcat.SecretsInEnvironment)
|
||||
if err := dirInto(out, dirs, m.Module); err != nil {
|
||||
return nil, mcat.PlanFile{}, err
|
||||
}
|
||||
if err := accessInto(out, accessPaths, m.Module); err != nil {
|
||||
return nil, mcat.PlanFile{}, err
|
||||
}
|
||||
ownerInto(out, placed)
|
||||
if err := machineInto(out, facts, m.Module); err != nil {
|
||||
return nil, mcat.PlanFile{}, err
|
||||
}
|
||||
out[mcat.OwnerAtRuntimeField] = ownershipOf(out)
|
||||
path := fmt.Sprint(out["path"])
|
||||
return out, mcat.PlanFile{ID: id, Path: path, Template: m.Handed.Templates[id],
|
||||
OwnerAtRuntime: fmt.Sprint(out[mcat.OwnerAtRuntimeField]), Takes: takesOf(m, id)}, nil
|
||||
}
|
||||
|
||||
// placementOf is the value of every placement placeholder the module's handed templates name, keyed as the
|
||||
// placeholder names it (`machine:account`), each filled by the same pass that fills it in a declaration. A
|
||||
// template naming a value only another domain answers — a secret, a binding, a contribution — is refused by
|
||||
// name: the client fills config and placement, and the rest move with their own packages (design 57).
|
||||
func placementOf(node string, m mcat.Manifest, texts, items map[string]string, dirs, accessPaths, facts map[string]string,
|
||||
with mcat.Rendering, moved map[string]bool, backups backupPlacer) (map[string]string, []string, []string, []mcat.PlanCollection, error) {
|
||||
out := map[string]string{}
|
||||
var secrets, required []string
|
||||
var problems []string
|
||||
var collections []mcat.PlanCollection
|
||||
all := map[string]string{}
|
||||
templateOf := map[string]string{}
|
||||
for id, text := range texts {
|
||||
all[id], templateOf[id] = text, m.Handed.Templates[id]
|
||||
}
|
||||
for key, text := range items {
|
||||
all["item:"+key], templateOf["item:"+key] = text, m.Handed.Items[key]
|
||||
}
|
||||
judgedConsumers := map[string]bool{}
|
||||
for _, id := range sortedKeys(all) {
|
||||
item := strings.HasPrefix(id, "item:")
|
||||
for _, found := range templatePlaceholder.FindAllStringSubmatch(all[id], -1) {
|
||||
kind, key := found[1], found[2]
|
||||
if collection, is := collectionOfPlaceholder(kind, key); is {
|
||||
// **A collection its file names, read by the client from the items there** (design 57 L1).
|
||||
comment, err := collectionComment(m, collection)
|
||||
_, selector := splitSelector(key)
|
||||
_, selErr := selectorOf(selector)
|
||||
switch {
|
||||
case selErr != nil && !item:
|
||||
problems = append(problems, fmt.Sprintf("%s: its template %s names %s: %v", m.Module,
|
||||
templateOf[id], found[0], selErr))
|
||||
case item:
|
||||
problems = append(problems, fmt.Sprintf("%s: its item's template %s names %s: an item is not a "+
|
||||
"collector", m.Module, templateOf[id], found[0]))
|
||||
case err != nil:
|
||||
problems = append(problems, fmt.Sprintf("%s: its template %s names %s: %v", m.Module,
|
||||
templateOf[id], found[0], err))
|
||||
case !slices.ContainsFunc(collections, func(c mcat.PlanCollection) bool { return c.Name == collection }):
|
||||
collections = append(collections, mcat.PlanCollection{Name: collection, Comment: comment})
|
||||
}
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case slices.Contains(configNamespaces, kind):
|
||||
continue
|
||||
case kind == mcat.ConsumersNamespace:
|
||||
// **A provider's consumers' credentials** (ADR 0373): its machine's client fills them from the vault,
|
||||
// for a provision the module provides, in a file only it reads (consumersPlaceholderProblems).
|
||||
if item {
|
||||
problems = append(problems, fmt.Sprintf("%s: its item's template %s names %s: an item is kept in a "+
|
||||
"bucket, where no secret is (novox/hq ADR 0349 §7)", m.Module, templateOf[id], found[0]))
|
||||
} else if !judgedConsumers[id] {
|
||||
judgedConsumers[id] = true
|
||||
problems = append(problems, consumersOfTemplate(m, id, all[id])...)
|
||||
}
|
||||
continue
|
||||
case item && (kind == "secret" || kind == "bound"):
|
||||
problems = append(problems, fmt.Sprintf("%s: its item's template %s names %s: an item is kept in a "+
|
||||
"bucket, where no secret is (novox/hq ADR 0349 §7)", m.Module, templateOf[id], found[0]))
|
||||
continue
|
||||
case kind == "secret":
|
||||
// **The module's own secret, by name** (design 57 V4): its machine's client asks the vault for it.
|
||||
// A requirement's credential is a binding's, and moves with the providers (design 57 B1–B3).
|
||||
if _, own := m.OwnSecrets.Plain()[key]; !own && moved[key] {
|
||||
// **A credential it requires, from a provider that has moved** (issue 788): the client fills it
|
||||
// from the vault's row for this holder, which the plan names.
|
||||
if !slices.Contains(required, key) {
|
||||
required = append(required, key)
|
||||
}
|
||||
} else if !own {
|
||||
problems = append(problems, fmt.Sprintf("%s: its template %s names %s, which is no secret of its "+
|
||||
"own; a credential it requires moves with its provider (design 57 B1–B3)", m.Module,
|
||||
templateOf[id], found[0]))
|
||||
} else if !slices.Contains(secrets, key) {
|
||||
secrets = append(secrets, key)
|
||||
}
|
||||
continue
|
||||
case kind == "bound" && moved[strings.SplitN(key, ":", 2)[0]]:
|
||||
// **What its moved provider told it** (issue 788): the client fills it from the provider's entry.
|
||||
continue
|
||||
case !slices.Contains(mcat.PlacementKinds, kind):
|
||||
if known(kind) {
|
||||
problems = append(problems, fmt.Sprintf("%s: its template %s names %s, and the provisioner's "+
|
||||
"client fills only config, secrets, collections and placement until that domain moves "+
|
||||
"(design 57)", m.Module, templateOf[id], found[0]))
|
||||
}
|
||||
continue
|
||||
}
|
||||
name := kind + ":" + key
|
||||
if _, done := out[name]; done {
|
||||
continue
|
||||
}
|
||||
if kind == mcat.BackupNamespace {
|
||||
// **What the machine's modules back up** (design 57 L3, issue 794): the text the old contribution
|
||||
// placeholders render, from the same function, over the modules placed on this machine.
|
||||
value, problem := backups.placed(m, key, item)
|
||||
if problem != "" {
|
||||
problems = append(problems, fmt.Sprintf("%s: its template %s names %s: %s", m.Module,
|
||||
templateOf[id], found[0], problem))
|
||||
continue
|
||||
}
|
||||
out[name] = value
|
||||
continue
|
||||
}
|
||||
value, err := placed(node, found[0], m, dirs, accessPaths, facts, with)
|
||||
if err != nil {
|
||||
problems = append(problems, err.Error())
|
||||
continue
|
||||
}
|
||||
out[name] = value
|
||||
}
|
||||
}
|
||||
if len(problems) > 0 {
|
||||
sort.Strings(problems)
|
||||
return nil, nil, nil, nil, errors.New(strings.Join(problems, "; "))
|
||||
}
|
||||
sort.Slice(collections, func(a, b int) bool { return collections[a].Name < collections[b].Name })
|
||||
return out, secrets, required, collections, nil
|
||||
}
|
||||
|
||||
// known is whether a namespace is one the mesh fills somewhere.
|
||||
func known(kind string) bool {
|
||||
for _, f := range fillers {
|
||||
if f.namespace == kind {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// placed is one placement placeholder's value, filled by the passes that fill it in a file's content.
|
||||
func placed(node, placeholder string, m mcat.Manifest, dirs, accessPaths, facts map[string]string, with mcat.Rendering) (string, error) {
|
||||
r := map[string]any{"id": "placement", "type": "file", "path": "/placement", "content": placeholder}
|
||||
if err := dirInto(r, dirs, m.Module); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := accessInto(r, accessPaths, m.Module); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := portInto(r, m.Module, m.Listens, with); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := seatInto(r, m.Module, with); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := machineInto(r, facts, m.Module); err != nil {
|
||||
return "", err
|
||||
}
|
||||
value := fmt.Sprint(r["content"])
|
||||
if value == placeholder {
|
||||
return "", fmt.Errorf("%s: %s is not answered by placement on %s", m.Module, placeholder, node)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
// routedTemplates is the templates archive as this node fetches it, through the artifact store it reaches.
|
||||
func routedTemplates(m mcat.Manifest, with mcat.Rendering) (mcat.PlanTemplates, error) {
|
||||
r := map[string]any{"id": "templates", "source": m.Handed.Source}
|
||||
if err := artifactsInto(r, m.Module, with); err != nil {
|
||||
return mcat.PlanTemplates{}, err
|
||||
}
|
||||
return mcat.PlanTemplates{Source: fmt.Sprint(r["source"]), Digest: m.Handed.Digest}, nil
|
||||
}
|
||||
|
||||
// handedFiles gathers, over one composition, the files handed to the provisioner and each handed module's plan.
|
||||
type handedFiles struct {
|
||||
paths []string
|
||||
plans map[string]mcat.Plan
|
||||
// secrets is each handed module's own secrets its declaration names, by name: the path of the file holding one
|
||||
// alone, or "" for one only its templates name.
|
||||
secrets map[string]map[string]string
|
||||
// provisions is each handed module's credentials of what it requires that the vault keeps for it (issue 757).
|
||||
provisions map[string][]mcat.PlanProvision
|
||||
// contributes is each handed module's manifest `contributes`, by the requirement it is about (issue 776).
|
||||
contributes map[string]map[string]map[string]any
|
||||
// identities is who each handed module is at its providers' ends (issue 788), and required the credentials of
|
||||
// what it requires that its templates name.
|
||||
identities map[string]string
|
||||
required map[string]map[string]bool
|
||||
// names is the names composed for each handed module through each requirement it contributes to (issue 792).
|
||||
names map[string]map[string]map[string]string
|
||||
// keyedOnly is each module with a plan only for what Keyed keys of a consumer not handed: a provision its
|
||||
// provider's helper answers (issue 774), such as a route.
|
||||
keyedOnly map[string]bool
|
||||
// contributed is what each handed module gives each requirement's provider, encoded (PlanProvision.Contributed).
|
||||
contributed map[string]map[string]json.RawMessage
|
||||
}
|
||||
|
||||
// contribute records what a handed module gives one requirement's provider, encoded; nothing for none.
|
||||
func (h *handedFiles) contribute(module, provision string, raw json.RawMessage) {
|
||||
if len(raw) == 0 {
|
||||
return
|
||||
}
|
||||
if h.contributed == nil {
|
||||
h.contributed = map[string]map[string]json.RawMessage{}
|
||||
}
|
||||
if h.contributed[module] == nil {
|
||||
h.contributed[module] = map[string]json.RawMessage{}
|
||||
}
|
||||
h.contributed[module][provision] = raw
|
||||
}
|
||||
|
||||
// keyed starts the plan of a module begin started none for, when the composition keys any of its needs as a consumer
|
||||
// not handed (Keyed, issue 774): one whose provider's helper answers a provision of it (grafana's route). A module
|
||||
// handed that keeps no template or item (a held one's stub, issue 1126) is not handed here either: what it keeps
|
||||
// stays the controller's but for what a helper answers. Its plan names provisions alone: no templates, files or
|
||||
// secrets. Answers whether it started one.
|
||||
func (h *handedFiles) keyed(node string, m mcat.Manifest, needs []mcat.Needed) bool {
|
||||
if _, started := h.plans[m.Module]; started {
|
||||
return false
|
||||
}
|
||||
if !slices.ContainsFunc(needs, func(n mcat.Needed) bool { return n.For == m.Module && Keyed(false, n) }) {
|
||||
return false
|
||||
}
|
||||
if h.identities == nil {
|
||||
h.identities = map[string]string{}
|
||||
h.required = map[string]map[string]bool{}
|
||||
}
|
||||
h.identities[m.Module] = mcat.ConsumerIdentity(node, mcat.IdentitySource(m.Slug, m.Module))
|
||||
h.plans[m.Module] = mcat.Plan{}
|
||||
if h.keyedOnly == nil {
|
||||
h.keyedOnly = map[string]bool{}
|
||||
}
|
||||
h.keyedOnly[m.Module] = true
|
||||
if len(m.Contributes) > 0 {
|
||||
if h.contributes == nil {
|
||||
h.contributes = map[string]map[string]map[string]any{}
|
||||
}
|
||||
h.contributes[m.Module] = m.Contributes
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// name records the names composed for a handed module through one requirement, as its binding file carries them.
|
||||
func (h *handedFiles) name(module, provision string, own map[string]any) {
|
||||
values := map[string]string{}
|
||||
withOwnNames(values, own)
|
||||
if len(values) == 0 {
|
||||
return
|
||||
}
|
||||
if h.names == nil {
|
||||
h.names = map[string]map[string]map[string]string{}
|
||||
}
|
||||
if h.names[module] == nil {
|
||||
h.names[module] = map[string]map[string]string{}
|
||||
}
|
||||
h.names[module][provision] = values
|
||||
}
|
||||
|
||||
// begin starts a module's plan where this composition hands it, and answers the ids of the files handed: none for
|
||||
// a module that is not handed here. The plan's templates and placement values are composed now; each file is added
|
||||
// as it is composed.
|
||||
//
|
||||
// **Assigned or only pulled in by another** (issue 765): the plan travels in the module's value of the assignments
|
||||
// mirror, `<node>.<module>`, which the client reads (inventory.Provisioning); a module pulled in has a value there of
|
||||
// its own, which says what pulled it in.
|
||||
func (h *handedFiles) begin(node string, m mcat.Manifest, dirs, accessPaths, facts map[string]string,
|
||||
with mcat.Rendering, caps map[string]bool, needs []mcat.Needed, backups backupPlacer) (map[string]bool, error) {
|
||||
if !renderingHanding(with, m) {
|
||||
return nil, nil
|
||||
}
|
||||
texts := map[string]string{}
|
||||
ids := map[string]bool{}
|
||||
for _, r := range m.Resources {
|
||||
id := fmt.Sprint(r["id"])
|
||||
if _, named := m.Handed.Templates[id]; !named {
|
||||
continue
|
||||
}
|
||||
if mergesFile(r) {
|
||||
// **A merging file is filled the old way** (issue 831, ADR 0349 clause 17): handing it needs the client
|
||||
// to merge the layers, which nothing builds yet. Its text stays, the controller fills and merges it.
|
||||
continue
|
||||
}
|
||||
if fmt.Sprint(r["type"]) != "file" {
|
||||
return nil, fmt.Errorf("%s: %s is a %v and its build kept a template for it; only a file is filled "+
|
||||
"from one", m.Module, id, r["type"])
|
||||
}
|
||||
text, _ := r["content"].(string)
|
||||
texts[id] = text
|
||||
ids[id] = true
|
||||
}
|
||||
for _, id := range sortedKeys(m.Handed.Templates) {
|
||||
if !ids[id] && !mergingIn(m, id) {
|
||||
return nil, fmt.Errorf("%s: its build kept a template for %s, which it does not declare", m.Module, id)
|
||||
}
|
||||
}
|
||||
// **Its items, by template** (design 57 L1): the client fills each and records it for this node.
|
||||
items := map[string]string{}
|
||||
var planned []mcat.PlanItem
|
||||
for _, it := range m.Items {
|
||||
template, kept := m.Handed.Items[itemKey(it)]
|
||||
if !kept {
|
||||
return nil, fmt.Errorf("%s: its build kept no template for its item of %s", m.Module, itemKey(it))
|
||||
}
|
||||
// **A capability the machine lacks leaves the item out of its plan** (design 57 L3): the client judges none.
|
||||
if it.IfCapability != "" && !caps[it.IfCapability] {
|
||||
continue
|
||||
}
|
||||
items[itemKey(it)] = it.Content
|
||||
planned = append(planned, mcat.PlanItem{Collection: it.Collection, Name: it.Name,
|
||||
Attributes: it.Attributes, Template: template})
|
||||
}
|
||||
if len(ids) == 0 && len(planned) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
templates, err := routedTemplates(m, with)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// What it requires from a provider that has moved (issue 788): its templates may name what that provider tells it.
|
||||
moved := map[string]bool{}
|
||||
for _, n := range needs {
|
||||
if n.For == m.Module && n.ProviderMoved {
|
||||
moved[n.Name] = true
|
||||
// A credential kept per local name (issue 793) is named by its local in a template.
|
||||
moved[mcat.SecretLocal(n.Name, n.Local)] = true
|
||||
}
|
||||
}
|
||||
placement, secrets, required, collections, err := placementOf(node, m, texts, items, dirs, accessPaths, facts,
|
||||
with, moved, backups)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if h.identities == nil {
|
||||
h.identities = map[string]string{}
|
||||
h.required = map[string]map[string]bool{}
|
||||
}
|
||||
h.identities[m.Module] = mcat.ConsumerIdentity(node, mcat.IdentitySource(m.Slug, m.Module))
|
||||
h.required[m.Module] = map[string]bool{}
|
||||
for _, name := range required {
|
||||
h.required[m.Module][name] = true
|
||||
}
|
||||
for _, name := range secrets {
|
||||
h.secret(m.Module, mcat.PlanSecret{Name: name})
|
||||
}
|
||||
plan := mcat.Plan{Templates: templates, Items: planned, Collections: collections}
|
||||
if len(placement) > 0 {
|
||||
plan.Placement = placement
|
||||
}
|
||||
h.plans[m.Module] = plan
|
||||
if len(m.Contributes) > 0 {
|
||||
if h.contributes == nil {
|
||||
h.contributes = map[string]map[string]map[string]any{}
|
||||
}
|
||||
h.contributes[m.Module] = m.Contributes
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
// add is one handed file of a module, composed. A path handed twice is refused: the node-engine writes a filled
|
||||
// file for exactly one resource, and the client's write names only the path (mesh-host internal/apply).
|
||||
func (h *handedFiles) add(module string, f mcat.PlanFile) error {
|
||||
if slices.Contains(h.paths, f.Path) {
|
||||
return fmt.Errorf("%s: %s is handed to the provisioner twice on this machine; a filled file has one "+
|
||||
"resource and one writer (novox/hq ADR 0349 §5)", module, f.Path)
|
||||
}
|
||||
plan := h.plans[module]
|
||||
plan.Files = append(plan.Files, f)
|
||||
h.plans[module] = plan
|
||||
h.paths = append(h.paths, f.Path)
|
||||
return nil
|
||||
}
|
||||
|
||||
// secret is one of a handed module's own secrets its declaration names; a file holding it alone is a filled file.
|
||||
func (h *handedFiles) secret(module string, sec mcat.PlanSecret) {
|
||||
if h.secrets == nil {
|
||||
h.secrets = map[string]map[string]string{}
|
||||
}
|
||||
if h.secrets[module] == nil {
|
||||
h.secrets[module] = map[string]string{}
|
||||
}
|
||||
if sec.Path != "" || h.secrets[module][sec.Name] == "" {
|
||||
h.secrets[module][sec.Name] = sec.Path
|
||||
}
|
||||
}
|
||||
|
||||
// addSecretFile is a handed module's own secret file, composed: a filled file, and its secret in the plan.
|
||||
func (h *handedFiles) addSecretFile(module string, sec mcat.PlanSecret) error {
|
||||
if slices.Contains(h.paths, sec.Path) {
|
||||
return fmt.Errorf("%s: %s is handed to the provisioner twice on this machine; a filled file has one "+
|
||||
"resource and one writer (novox/hq ADR 0349 §5)", module, sec.Path)
|
||||
}
|
||||
h.paths = append(h.paths, sec.Path)
|
||||
h.secret(module, sec)
|
||||
return nil
|
||||
}
|
||||
|
||||
// addProvisionFile is a handed module's credential file for something it requires, which the vault keeps for it
|
||||
// alone (issue 757): a filled file, and the credential in the plan.
|
||||
func (h *handedFiles) addProvisionFile(module string, pr mcat.PlanProvision) error {
|
||||
if slices.Contains(h.paths, pr.Path) {
|
||||
return fmt.Errorf("%s: %s is handed to the provisioner twice on this machine; a filled file has one "+
|
||||
"resource and one writer (novox/hq ADR 0349 §5)", module, pr.Path)
|
||||
}
|
||||
h.paths = append(h.paths, pr.Path)
|
||||
if h.provisions == nil {
|
||||
h.provisions = map[string][]mcat.PlanProvision{}
|
||||
}
|
||||
h.provisions[module] = append(h.provisions[module], pr)
|
||||
return nil
|
||||
}
|
||||
|
||||
// finished is every plan with its secrets, by name, and what its module requires, each with its provider.
|
||||
func (h *handedFiles) finished(needs []mcat.Needed) map[string]mcat.Plan {
|
||||
if len(h.plans) == 0 {
|
||||
return nil
|
||||
}
|
||||
for module, plan := range h.plans {
|
||||
// **Every provision it requires, with its provider** (issue 760); a credential the vault keeps for it alone
|
||||
// is that provision's entry, with its name and file (issue 757).
|
||||
provisions := provisionsOf(needs, module, !h.keyedOnly[module], h.identities[module], h.contributes[module],
|
||||
h.required[module])
|
||||
for _, credential := range h.provisions[module] {
|
||||
i := slices.IndexFunc(provisions, func(p mcat.PlanProvision) bool {
|
||||
return p.Provision == credential.Provision && p.Local == credential.Local
|
||||
})
|
||||
if i < 0 {
|
||||
credential.As = h.identities[module]
|
||||
provisions = append(provisions, credential)
|
||||
continue
|
||||
}
|
||||
provisions[i].Name, provisions[i].Path = credential.Name, credential.Path
|
||||
}
|
||||
for i := range provisions {
|
||||
provisions[i].Names = maps.Clone(h.names[module][provisions[i].Provision])
|
||||
}
|
||||
sortProvisions(provisions)
|
||||
// **What the consumer contributes, on the provision's first entry** (issue 774): a provider serving from the
|
||||
// plans alone reads each contribution once, however many local names the requirement has.
|
||||
gave := map[string]bool{}
|
||||
for i := range provisions {
|
||||
if raw := h.contributed[module][provisions[i].Provision]; len(raw) > 0 && !gave[provisions[i].Provision] {
|
||||
gave[provisions[i].Provision] = true
|
||||
provisions[i].Contributed = slices.Clone(raw)
|
||||
}
|
||||
}
|
||||
plan.Provisions = provisions
|
||||
h.plans[module] = plan
|
||||
}
|
||||
for module, named := range h.secrets {
|
||||
plan, ok := h.plans[module]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
plan.Secrets = nil
|
||||
for _, name := range sortedKeys(named) {
|
||||
plan.Secrets = append(plan.Secrets, mcat.PlanSecret{Name: name, Path: named[name]})
|
||||
}
|
||||
h.plans[module] = plan
|
||||
}
|
||||
return h.plans
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package resolution
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
|
||||
// gitea's server.env, as the catalogue keeps it: its database as the provider told it and the consumer's own login.
|
||||
const giteaServerEnv = "GITEA__security__INTERNAL_TOKEN=${secret:internal-token}\n" +
|
||||
"GITEA__database__DB_TYPE=postgres\n" +
|
||||
"GITEA__database__HOST=${bound:postgres-database:at}:${bound:postgres-database:port}\n" +
|
||||
"GITEA__database__NAME=${bound:postgres-database:as}\n" +
|
||||
"GITEA__database__USER=${bound:postgres-database:as}\n" +
|
||||
"GITEA__database__PASSWD=${secret:postgres-database}\n"
|
||||
|
||||
// databaseConsumer is a handed module, with the template gitea's server.env is, requiring a database from a provider
|
||||
// that has moved or not (issue 788); slug is the module's identity source when it declares one.
|
||||
func databaseConsumer(t *testing.T, slug string, moved bool) mcat.Resolution {
|
||||
t.Helper()
|
||||
raw := strings.Replace(botMoved(), `"own-secrets":{"token":"${dir:state}/token"}`,
|
||||
`"requires":["postgres-database"],"own-secrets":{"internal-token":"${dir:state}/internal-token"}`, 1)
|
||||
if slug != "" {
|
||||
raw = strings.Replace(raw, `"version":"1",`, `"version":"1","slug":"`+slug+`",`, 1)
|
||||
}
|
||||
return withDatabase(t, raw, giteaServerEnv, moved)
|
||||
}
|
||||
|
||||
// withDatabase resolves the module in raw, whose template is text, against a provider of postgres-database that has
|
||||
// moved or not.
|
||||
func withDatabase(t *testing.T, raw, text string, moved bool) mcat.Resolution {
|
||||
t.Helper()
|
||||
r, err := Resolve(shelf(held(t, raw, builtBot(text)), mcat.Manifest{Module: "postgres", Version: "1", Provides: []mcat.Offer{{Name: "postgres-database", Scope: mcat.ScopeNode}}}),
|
||||
[]string{"bot"}, workstation(), mcat.World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r.Account = "op"
|
||||
// Answered on this machine, it states no need; one from the provider's machine is stated, as the plan does.
|
||||
r.Needs = []mcat.Needed{{Name: "postgres-database", For: "bot", Module: "postgres", From: "novox", At: "10.7.0.1",
|
||||
CredentialForm: mcat.CredentialFormPair, IssuedToHolder: moved, ProviderMoved: moved}}
|
||||
return r
|
||||
}
|
||||
|
||||
// The forge's server.env composes for a handed module when what it requires has moved, its plan entry carrying the
|
||||
// provider's address, machine and the consumer's identity as today's binding file gives them; and is refused with the
|
||||
// text it has always had when the provider has not.
|
||||
func TestAHandedModuleMayNameWhatItRequiresWhereItsProviderHasMoved(t *testing.T) {
|
||||
c, got := composeWith(t, databaseConsumer(t, "", true), handingTo("store:5000"))
|
||||
if strings.Contains(got, "sealed") {
|
||||
t.Fatalf("a handed module's declaration carries a sealed value: %s", got)
|
||||
}
|
||||
want := []mcat.PlanProvision{{Name: "postgres-database", Provision: "postgres-database", Provider: "novox",
|
||||
ProviderModule: "postgres", CredentialForm: mcat.CredentialFormPair, At: "10.7.0.1", From: "novox",
|
||||
As: mcat.ConsumerIdentity("workstation", "bot")}}
|
||||
if !reflect.DeepEqual(c.Plans["bot"].Provisions, want) {
|
||||
t.Fatalf("the plan names %+v, want %+v", c.Plans["bot"].Provisions, want)
|
||||
}
|
||||
for _, unmoved := range []mcat.Resolution{databaseConsumer(t, "", false)} {
|
||||
_, err := Compose(unmoved, handingTo("store:5000"))
|
||||
if err == nil || !strings.Contains(err.Error(), "which is no secret of its own; a credential it requires moves "+
|
||||
"with its provider (design 57 B1–B3)") {
|
||||
t.Fatalf("a provider not moved, the credential: %v", err)
|
||||
}
|
||||
bound := strings.Replace(giteaServerEnv, "${secret:postgres-database}", "x", 1)
|
||||
r := withDatabase(t, strings.Replace(botMoved(), `"own-secrets":{"token":"${dir:state}/token"}`,
|
||||
`"requires":["postgres-database"],"own-secrets":{"internal-token":"${dir:state}/internal-token"}`, 1),
|
||||
bound, false)
|
||||
_, err = Compose(r, handingTo("store:5000"))
|
||||
if err == nil || !strings.Contains(err.Error(), "the provisioner's client fills only config, secrets, "+
|
||||
"collections and placement until that domain moves (design 57)") {
|
||||
t.Fatalf("a provider not moved, the binding: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A handed module's credentials the vault keeps as one login per local name (issue 793): gitea's internal token and
|
||||
// admin password, which the `secret` provision hands as `${secret:<local>}`. Each is a plan entry naming its local,
|
||||
// its file declared placed and without a value; the template naming the local is allowed.
|
||||
func TestAHandedModulesLocalCredentialsOfTheVaultAreNamedByLocalInItsPlan(t *testing.T) {
|
||||
raw := strings.Replace(botMoved(), `"own-secrets":{"token":"${dir:state}/token"}`,
|
||||
`"requires":["secret"],"secrets":{"secret":{"internal-token":"${dir:state}/internal-token.secret",`+
|
||||
`"admin":"${dir:state}/admin.secret"}},"own-secrets":{"token":"${dir:state}/token"}`, 1)
|
||||
r, err := Resolve(shelf(held(t, raw, builtBot("T=${secret:internal-token}\n")), mcat.Manifest{Module: "vault", Version: "1", Provides: []mcat.Offer{{Name: "secret", Scope: mcat.ScopeNode}}}),
|
||||
[]string{"bot"}, workstation(), mcat.World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r.Account = "op"
|
||||
r.Needs = []mcat.Needed{
|
||||
{Name: "secret", Local: "internal-token", For: "bot", Module: "vault", From: "novox",
|
||||
CredentialForm: mcat.CredentialFormPair, IssuedToHolder: true, ProviderMoved: true},
|
||||
{Name: "secret", Local: "admin", For: "bot", Module: "vault", From: "novox",
|
||||
CredentialForm: mcat.CredentialFormPair, IssuedToHolder: true, ProviderMoved: true},
|
||||
}
|
||||
c, got := composeWith(t, r, handingTo("store:5000"))
|
||||
if strings.Contains(got, "sealed") {
|
||||
t.Fatalf("the declaration carries a sealed value: %s", got)
|
||||
}
|
||||
var names []string
|
||||
for _, p := range c.Plans["bot"].Provisions {
|
||||
if p.Provision != "secret" || p.Provider != "novox" || p.Path == "" || p.Name != p.Local {
|
||||
t.Errorf("the plan entry is %+v", p)
|
||||
}
|
||||
names = append(names, p.Name)
|
||||
}
|
||||
if !reflect.DeepEqual(names, []string{"admin", "internal-token"}) {
|
||||
t.Fatalf("the plan names %v, want admin and internal-token", names)
|
||||
}
|
||||
}
|
||||
|
||||
// **Issue 969: a key its provider serves only at run time.** A moved module's file may name `${bound:…}` of a
|
||||
// provision whose provider tells it at run time (ssh-client's known_host of the forge). Where the machine takes
|
||||
// handed files the client fills it and the module composes; where it does not the controller must fill it from what
|
||||
// the provider serves statically, cannot, and refuses it with the very words an unmarked module is refused with
|
||||
// (ADR 0349 §17: the old way, byte for byte).
|
||||
func TestAHandedModuleNamingARunTimeBoundKeyComposesOnlyWhereItIsHanded(t *testing.T) {
|
||||
text := "Host ${bound:postgres-database:at}\n KnownHost ${bound:postgres-database:known_host}\n"
|
||||
moved := strings.Replace(botMoved(), `"own-secrets":{"token":"${dir:state}/token"}`,
|
||||
`"requires":["postgres-database"],"own-secrets":{"token":"${dir:state}/token"}`, 1)
|
||||
unmoved := strings.Replace(moved, `"moved":true,`, ``, 1)
|
||||
if unmoved == moved {
|
||||
t.Fatal("the twin is still marked moved")
|
||||
}
|
||||
|
||||
c, got := composeWith(t, withDatabase(t, moved, text, true), handingTo("store:5000"))
|
||||
if strings.Contains(got, "known_host") || len(c.Filled) == 0 {
|
||||
t.Fatalf("handed, the module is filled by the client and the controller names nothing: %v %s", c.Filled, got)
|
||||
}
|
||||
|
||||
for name, with := range map[string]mcat.Rendering{
|
||||
"an engine that does not take a filled file alone": {Provisioner: "mesh-provisioner-client", ArtifactStore: "store:5000"},
|
||||
"no provisioner client on the machine": {Hands: true, ArtifactStore: "store:5000"},
|
||||
} {
|
||||
_, err := Compose(withDatabase(t, moved, text, true), sealedForBot(with))
|
||||
if err == nil || !strings.Contains(err.Error(), `asks its postgres-database binding for "known_host"`) {
|
||||
t.Fatalf("%s: the key is not refused: %v", name, err)
|
||||
}
|
||||
_, before := Compose(withDatabase(t, unmoved, text, false), sealedForBot(with))
|
||||
if before == nil || before.Error() != err.Error() {
|
||||
t.Fatalf("%s: refused otherwise than the unmarked module: %v / %v", name, err, before)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package resolution
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
|
||||
const handedProvider = `{"module":"db","version":"1","moved":true,
|
||||
"provides":[{"name":"postgres-database","scope":"mesh","identity":{"max":63,"in":"a PostgreSQL role"}}],
|
||||
"build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]},
|
||||
"resources":[
|
||||
{"id":"state","type":"directory","mode":"0700","place":"."},
|
||||
{"id":"consumers","type":"file","path":"${dir:state}/consumers.json","mode":"0600","template":"files/consumers.json"}]}`
|
||||
|
||||
const consumersText = "${consumers:postgres-database}"
|
||||
|
||||
func builtProvider(text string) []mcat.Built {
|
||||
return []mcat.Built{{Name: "files", Kind: mcat.ArtifactArchive,
|
||||
Reference: mcat.ArtifactStoreScheme + "db/files/blobs/sha256:" + strings.Repeat("f", 64),
|
||||
Digest: "sha256:" + strings.Repeat("f", 64), Texts: map[string]string{"consumers.json": text}}}
|
||||
}
|
||||
|
||||
func TestWhatAConsumersPlaceholderMayNotBeIsRefused(t *testing.T) {
|
||||
for why, c := range map[string]struct{ raw, text, want string }{
|
||||
"a provision the module does not provide": {handedProvider, "${consumers:cache}", "does not provide"},
|
||||
"a module providing nothing": {strings.Replace(handedProvider, `"provides":[{"name":"postgres-database","scope":"mesh","identity":{"max":63,"in":"a PostgreSQL role"}}],`,
|
||||
"", 1), consumersText, "does not provide"},
|
||||
"a file others may read": {strings.Replace(handedProvider, `"mode":"0600","template"`,
|
||||
`"mode":"0640","template"`, 1), consumersText, "0600"},
|
||||
"a file the program keeps": {strings.Replace(handedProvider, `"mode":"0600","template"`,
|
||||
`"mode":"0600","create-once":true,"template"`, 1), consumersText, "owned"},
|
||||
} {
|
||||
m, err := decodeForTest([]byte(c.raw))
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", why, err)
|
||||
}
|
||||
resolved, err := m.Resolve(builtProvider(c.text))
|
||||
if err == nil {
|
||||
r := notifierOn(t, resolved)
|
||||
_, err = Compose(r, handingTo("store:5000"))
|
||||
}
|
||||
if err == nil || !strings.Contains(err.Error(), c.want) {
|
||||
t.Errorf("%s: %v, want a refusal saying %q", why, err, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package resolution
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
|
||||
// notifierConsumer is the notifier requiring a mesh-scoped database, marked moved when moved, with its credential
|
||||
// kept under two local names (ADR 0094) when locals is set.
|
||||
func notifierConsumer(t *testing.T, moved, locals bool) mcat.Manifest {
|
||||
t.Helper()
|
||||
raw := handedNotifier
|
||||
if moved {
|
||||
raw = notifierMoved()
|
||||
}
|
||||
add := `"requires":["postgres-database"],`
|
||||
if locals {
|
||||
add += `"secrets":{"postgres-database":{"hot":"/var/lib/notify/hot","cold":"/var/lib/notify/cold"}},`
|
||||
}
|
||||
raw = strings.Replace(raw, `"build":`, add+`"build":`, 1)
|
||||
return held(t, raw, builtNotifier())
|
||||
}
|
||||
|
||||
// twoStores is a mesh where two machines each run a store providing the database, the consumer pinned to one.
|
||||
func twoStores(pin string) mcat.World {
|
||||
return mcat.World{Offered: map[string][]mcat.Provider{"postgres-database": {
|
||||
{Node: "anchor", At: "anchor.internal", Module: "store", Serves: map[string]any{"port": 5432}},
|
||||
{Node: "home", At: "home.internal", Module: "store-two", Serves: map[string]any{"port": 5434}},
|
||||
}}, Pinned: map[string]mcat.Chosen{"postgres-database": {Node: pin}}}
|
||||
}
|
||||
|
||||
func consumerOn(t *testing.T, m mcat.Manifest, w mcat.World) mcat.Resolution {
|
||||
t.Helper()
|
||||
node := workstation()
|
||||
node.At = "workstation.internal"
|
||||
stores := shelf(m, mcat.Manifest{Module: "store", Version: "1", Provides: []mcat.Offer{{Name: "postgres-database", Scope: mcat.ScopeMesh,
|
||||
Credential: &mcat.OfferCredential{Form: mcat.CredentialFormPair}}},
|
||||
Serves: map[string]map[string]any{"postgres-database": {"port": 5432}}}, mcat.Manifest{Module: "store-two", Version: "1", Provides: []mcat.Offer{{Name: "postgres-database", Scope: mcat.ScopeMesh, Credential: &mcat.OfferCredential{Form: mcat.CredentialFormPair}}},
|
||||
Serves: map[string]map[string]any{"postgres-database": {"port": 5434}}})
|
||||
r, err := Resolve(stores, []string{m.Module}, node, w)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r.Account = "op"
|
||||
return r
|
||||
}
|
||||
|
||||
// A consumer's plan entry carries the names the mesh composed for it through that provision (issue 792): what its
|
||||
// binding file names as `${bound:<provision>:name|internal-name|name-<local>|internal-name-<local>}`, so a moved
|
||||
// provider's helper copies them into the consumer's entry and the client fills the same text. A consumer
|
||||
// contributing no label has the entry it had.
|
||||
func TestAHandedModulesPlanEntryCarriesTheNamesComposedForIt(t *testing.T) {
|
||||
m := notifierConsumer(t, true, false)
|
||||
m.ContributesMany = map[string]map[string]map[string]any{
|
||||
"postgres-database": {"web": {"label": "git"}, "api": {"label": "git-api"}}}
|
||||
r := consumerOn(t, m, twoStores("anchor"))
|
||||
r.PublicDomain = "example.org"
|
||||
c, _ := composeWith(t, r, handingTo("store:5000"))
|
||||
got := c.Plans["notify"].Provisions
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("the plan names %+v", got)
|
||||
}
|
||||
want := map[string]string{"name-web": "git.example.org", "name-api": "git-api.example.org"}
|
||||
own, err := resolutionOwnNames(r, m, "postgres-database", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
file := map[string]string{}
|
||||
withOwnNames(file, own)
|
||||
for k, v := range file {
|
||||
if strings.HasPrefix(k, "internal-") {
|
||||
want[k] = v
|
||||
}
|
||||
}
|
||||
if !reflect.DeepEqual(got[0].Names, want) {
|
||||
t.Fatalf("the entry carries %v, the binding file names %v", got[0].Names, want)
|
||||
}
|
||||
}
|
||||
|
||||
// A plan entry names the tool of an offer whose credential the provider's software issues (issue 928), so the vault
|
||||
// asks that tool for each holder; an offer of any other form names none.
|
||||
func TestAHandedModulesPlanNamesTheCredentialToolOfASoftwareIssuedOffer(t *testing.T) {
|
||||
for credential, want := range map[string]string{
|
||||
``: "",
|
||||
`,"credential":{"own":"admin"}`: "",
|
||||
`,"credential":{"issued-by":"outside"}`: "",
|
||||
`,"credential":{"issued-by":"software","tool":"gitea_agent_token"}`: "gitea_agent_token",
|
||||
} {
|
||||
raw := `{"module":"store","version":"1","provides":[{"name":"postgres-database","scope":"mesh"` +
|
||||
credential + `}],"serves":{"postgres-database":{"port":5432}}`
|
||||
if strings.Contains(credential, `"own"`) {
|
||||
raw += `,"own-secrets":{"admin":{"path":"/var/lib/store/admin","taken":"at-start"}}`
|
||||
}
|
||||
node := workstation()
|
||||
node.At = "workstation.internal"
|
||||
consumer := notifierConsumer(t, true, false)
|
||||
r, err := Resolve(shelf(consumer, mustParse(t, raw+`}`)), []string{consumer.Module}, node, twoStores("anchor"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r.Account = "op"
|
||||
c, _ := composeWith(t, r, handingTo("store:5000"))
|
||||
got := c.Plans["notify"].Provisions
|
||||
if len(got) != 1 || got[0].CredentialTool != want {
|
||||
t.Errorf("an offer with %q: the plan names %+v, want the tool %q", credential, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package resolution
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
|
||||
// pulledInBy is a module that requires what raw's module provides, so resolving it alone pulls that module in.
|
||||
const pulledInBy = `{"module":"desk","version":"1","requires":["pulled-in-here"]}`
|
||||
|
||||
// providing is raw's module providing what desk requires.
|
||||
func providing(raw string) string {
|
||||
return strings.Replace(raw, `"version":"1",`, `"version":"1","provides":[{"name":"pulled-in-here"}],`, 1)
|
||||
}
|
||||
|
||||
// onWorkstation resolves m on the workstation either assigned beside desk, or pulled in by desk alone.
|
||||
func onWorkstation(t *testing.T, m mcat.Manifest, assigned bool) mcat.Resolution {
|
||||
t.Helper()
|
||||
desk, err := decodeForTest([]byte(pulledInBy))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []string{"desk"}
|
||||
if assigned {
|
||||
want = append(want, m.Module)
|
||||
}
|
||||
r, err := Resolve(shelf(m, desk), want, workstation(), mcat.World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
why := map[bool]string{true: "assigned", false: "required by desk"}[assigned]
|
||||
if r.Because[m.Module] != why {
|
||||
t.Fatalf("%s is on the workstation because %q, want %q", m.Module, r.Because[m.Module], why)
|
||||
}
|
||||
r.Account = "op"
|
||||
return r
|
||||
}
|
||||
|
||||
// What requires a module pulled in is said by name, for its value in the assignments mirror; nothing for one
|
||||
// assigned.
|
||||
func TestAPulledInModuleSaysWhatRequiresIt(t *testing.T) {
|
||||
m := held(t, providing(notifierMoved()), builtNotifier())
|
||||
if by := RequiredBy(onWorkstation(t, m, false), "notify"); by != "desk" {
|
||||
t.Errorf("pulled in, notify is required by %q, want desk", by)
|
||||
}
|
||||
assigned := onWorkstation(t, m, true)
|
||||
if by := RequiredBy(assigned, "notify"); by != "" {
|
||||
t.Errorf("assigned, notify is required by %q", by)
|
||||
}
|
||||
if by := RequiredBy(assigned, "absent"); by != "" {
|
||||
t.Errorf("a module not on the machine is required by %q", by)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package resolution
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
|
||||
const handedBot = `{"module":"bot","version":"1",
|
||||
"own-secrets":{"token":"${dir:state}/token"},
|
||||
"build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]},
|
||||
"resources":[
|
||||
{"id":"state","type":"directory","mode":"0700","place":"."},
|
||||
{"id":"conf","type":"file","path":"/etc/bot/bot.conf","mode":"0640","template":"files/bot.conf"},
|
||||
{"id":"daemon","type":"process","name":"bot","run":["bot"],"env":{"BOT_TOKEN_FILE":"${dir:state}/token"}}]}`
|
||||
|
||||
const botText = "token = ${secret:token}\nhome = ${machine:account-home}\n"
|
||||
|
||||
func botMoved() string {
|
||||
return strings.Replace(handedBot, `"version":"1",`, `"version":"1","moved":true,`, 1)
|
||||
}
|
||||
|
||||
func builtBot(text string) []mcat.Built {
|
||||
return []mcat.Built{{Name: "files", Kind: mcat.ArtifactArchive,
|
||||
Reference: mcat.ArtifactStoreScheme + "bot/files/blobs/sha256:" + strings.Repeat("e", 64),
|
||||
Digest: "sha256:" + strings.Repeat("e", 64), Texts: map[string]string{"bot.conf": text}}}
|
||||
}
|
||||
|
||||
// What the controller seals for the bot when it fills it the old way: a stand-in sealed value.
|
||||
func sealedForBot(with mcat.Rendering) mcat.Rendering {
|
||||
with.Needed = map[string]map[string]string{"bot": {"token": "sealed-to-the-node"}}
|
||||
return with
|
||||
}
|
||||
|
||||
func TestAHandedModulesSecretsAreNamedAndNoValueIsSealed(t *testing.T) {
|
||||
r := notifierOn(t, held(t, botMoved(), builtBot(botText)))
|
||||
c, got := composeWith(t, r, handingTo("store:5000"))
|
||||
if strings.Contains(got, "sealed") || strings.Contains(got, "${secret:") {
|
||||
t.Fatalf("a handed module's declaration carries a sealed value or a secret placeholder: %s", got)
|
||||
}
|
||||
var token map[string]any
|
||||
for _, res := range c.Resources {
|
||||
if res["id"] == "bot.needs-token" {
|
||||
token = res
|
||||
}
|
||||
}
|
||||
if token == nil {
|
||||
t.Fatalf("the token's file is not declared: %s", got)
|
||||
}
|
||||
if token["path"] == nil || strings.Contains(token["path"].(string), "${") || token["mode"] != "0600" ||
|
||||
token["owner-at-runtime"] != "owned" || token[mcat.HandedSecretField] != nil {
|
||||
t.Fatalf("the token's file is declared %v: placed, 0600, owned at runtime, no value", token)
|
||||
}
|
||||
path := token["path"].(string)
|
||||
if !reflect.DeepEqual(c.Filled, []string{path, "/etc/bot/bot.conf"}) {
|
||||
t.Fatalf("the filled files are %v", c.Filled)
|
||||
}
|
||||
if want := []mcat.PlanSecret{{Name: "token", Path: path}}; !reflect.DeepEqual(c.Plans["bot"].Secrets, want) {
|
||||
t.Fatalf("the plan names secrets %+v, want %+v", c.Plans["bot"].Secrets, want)
|
||||
}
|
||||
// Issue 774 (postgres on novox, 2026-10-31): where the vault judged it handed, its secrets are the vault's even on a
|
||||
// machine the node reads as not taking a filled file, and nothing is refused for a value the vault never answers.
|
||||
judged, err := Compose(r, mcat.Rendering{ArtifactStore: "store:5000", Assigned: map[string]bool{"bot": true}})
|
||||
if err != nil {
|
||||
t.Fatalf("a module the vault judged handed is refused: %v", err)
|
||||
}
|
||||
if want := []mcat.PlanSecret{{Name: "token", Path: path}}; !reflect.DeepEqual(judged.Plans["bot"].Secrets, want) {
|
||||
t.Errorf("judged handed by the vault, the plan names secrets %+v, want %+v", judged.Plans["bot"].Secrets, want)
|
||||
}
|
||||
// A process naming the token's file still restarts when its client writes it.
|
||||
for _, res := range c.Resources {
|
||||
if res["id"] == "bot.daemon" && !strings.Contains(strings.Join(stringsIn(res["restart-on"]), " "), "bot.needs-token") {
|
||||
t.Errorf("the daemon does not restart on its token's file: %v", res["restart-on"])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package resolution
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
|
||||
const handedNotifier = `{"module":"notify","version":"1",
|
||||
"config":{"width":{"type":"number","description":"How wide a notification is, in pixels.","default":250}},
|
||||
"build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]},
|
||||
"resources":[
|
||||
{"id":"configuration-dir","type":"directory","path":"${machine:account-home}/.config/notify","owner":"${machine:account}","mode":"0755"},
|
||||
{"id":"configuration","type":"file","path":"${machine:account-home}/.config/notify/rc","owner":"${machine:account}","mode":"0644","template":"files/rc"},
|
||||
{"id":"legacy","type":"file","path":"/etc/notify.conf","content":"plain = yes\n"},
|
||||
{"id":"notifier","type":"service","unit":"notify.service","scope":"user","user":"${machine:account}","reload-on":["configuration"]}]}`
|
||||
|
||||
const notifierText = "width = ${config:width}\nhome = ${machine:account-home}\n"
|
||||
|
||||
func notifierMoved() string {
|
||||
return strings.Replace(handedNotifier, `"version":"1",`, `"version":"1","moved":true,`, 1)
|
||||
}
|
||||
|
||||
func builtNotifier() []mcat.Built {
|
||||
return []mcat.Built{{Name: "files", Kind: mcat.ArtifactArchive,
|
||||
Reference: mcat.ArtifactStoreScheme + "notify/files/blobs/sha256:" + strings.Repeat("d", 64),
|
||||
Digest: "sha256:" + strings.Repeat("d", 64), Texts: map[string]string{"rc": notifierText}}}
|
||||
}
|
||||
|
||||
func notifierOn(t *testing.T, m mcat.Manifest) mcat.Resolution {
|
||||
t.Helper()
|
||||
r, err := Resolve(shelf(m), []string{m.Module}, workstation(), mcat.World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r.Account = "op"
|
||||
return r
|
||||
}
|
||||
|
||||
func composeWith(t *testing.T, r mcat.Resolution, with mcat.Rendering) (mcat.Composed, string) {
|
||||
t.Helper()
|
||||
c, err := Compose(r, with)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, err := json.Marshal(c.Resources)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return c, string(raw)
|
||||
}
|
||||
|
||||
func handingTo(store string) mcat.Rendering {
|
||||
return mcat.Rendering{Hands: true, Provisioner: "mesh-provisioner-client", ArtifactStore: store}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package resolution
|
||||
|
||||
import (
|
||||
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
|
||||
// healthInto composes a resource's `health` into the node-engine's words, or takes it away (to-be 48 §2,
|
||||
// §3): for an engine that reads it, the endpoint becomes the port this machine published it on and the
|
||||
// defaults are written out; for one that does not — older and strict — the field is not sent, and the
|
||||
// resource is judged by liveness alone, as before.
|
||||
func healthInto(resource map[string]any, m mcat.Manifest, with mcat.Rendering) {
|
||||
if _, has := resource[mcat.HealthField]; !has {
|
||||
return
|
||||
}
|
||||
if !with.ReadsHealth {
|
||||
delete(resource, mcat.HealthField)
|
||||
return
|
||||
}
|
||||
h, _, _ := mcat.ReadHealth(resource)
|
||||
out := map[string]any{"kind": h.Kind, "interval": h.Interval.String(), "timeout": h.Timeout.String(),
|
||||
"looks": h.Looks, "grace": h.Grace.String()}
|
||||
if h.Endpoint != "" {
|
||||
out["endpoint"] = h.Endpoint
|
||||
if port, ok := mcat.EndpointPort(m, h.Endpoint); ok {
|
||||
out["port"] = renderingMachinePort(with, m.Module, port)
|
||||
}
|
||||
}
|
||||
if h.Kind == mcat.HealthHTTP {
|
||||
path := h.Path
|
||||
if path == "" {
|
||||
path = "/"
|
||||
}
|
||||
out["path"] = path
|
||||
if h.Status != 0 {
|
||||
out["status"] = h.Status
|
||||
}
|
||||
if h.Body != "" {
|
||||
out["body"] = h.Body
|
||||
}
|
||||
if h.Scheme != "" {
|
||||
out["scheme"] = h.Scheme
|
||||
}
|
||||
}
|
||||
if h.Command != "" {
|
||||
out["command"] = h.Command
|
||||
}
|
||||
if h.Tool != "" {
|
||||
out["tool"] = h.Tool
|
||||
}
|
||||
if h.Needs != "" {
|
||||
out["needs"] = h.Needs
|
||||
}
|
||||
resource[mcat.HealthField] = out
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package resolution
|
||||
|
||||
import (
|
||||
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
|
||||
// healthDecision is the record that made the four seats.
|
||||
const healthDecision = "novox/hq ADR 0564"
|
||||
|
||||
// conditionsVerbs are the condition keeper's verbs (ADR 0564 clauses 3, 9, 12).
|
||||
func conditionsVerbs() []mcat.Verb {
|
||||
return []mcat.Verb{
|
||||
{Name: "conditions", Description: "What is wrong with the mesh now: every open condition, urgent first, then oldest, " +
|
||||
"raised from the observers' findings (the watchman, the self-check, the healers, the assigner) and cleared when " +
|
||||
"the finding goes, never by hand. Given a key, that one whole with its evidence; with history, every transition " +
|
||||
"lately.",
|
||||
Input: schema(map[string]string{
|
||||
"key": "a condition's key: that one whole; with history, only its transitions",
|
||||
"scope": "only this scope: machine, plan, call, build, merge, provider, seat, bus, core, probe, mesh, delivery or module",
|
||||
"severity": "only urgent, or only warning",
|
||||
"machine": "only those about this machine",
|
||||
"history": "\"true\": every raising, change, silence and clearing lately, oldest first",
|
||||
"days": "with history: how many days back (default 7, at most 90)",
|
||||
}, nil, "history")},
|
||||
{Name: "silence", Description: "Stop one condition's messages for a while, a hand act with why: it stays open and in " +
|
||||
"status, and the silence is kept as its transition in history.",
|
||||
Input: schema(map[string]string{
|
||||
"key": "the condition's key",
|
||||
"for": "how long: 30m, 4h, 2d; at most 7d",
|
||||
"why": "why, required and recorded",
|
||||
"cause": "the cause in a word (the condition's kind when absent)",
|
||||
}, []string{"key", "for", "why"})},
|
||||
{Name: "resync", Description: "Read every observer's findings again and raise and clear what differs from the open " +
|
||||
"conditions: the recovery of a keeper that missed a change.",
|
||||
Input: schema(map[string]string{}, nil)},
|
||||
}
|
||||
}
|
||||
|
||||
// watchdogsVerbs are the watchman's verbs (ADR 0564 clauses 4, 5, 12).
|
||||
func watchdogsVerbs() []mcat.Verb {
|
||||
return []mcat.Verb{
|
||||
{Name: "signals", Description: "Every watchdog of the signals table the watchman keeps, each a key that expires unless " +
|
||||
"its signal renews it: its row, what renews it, its bound, the age of its newest signal, and the finding its " +
|
||||
"expiry wrote.",
|
||||
Input: schema(map[string]string{"row": "one row, e.g. S1"}, nil)},
|
||||
{Name: "stuck", Description: "Every want, build or step the watchman says stopped moving past its bound, with since " +
|
||||
"when and the words of its finding.",
|
||||
Input: schema(map[string]string{"machine": "only those on this machine"}, nil)},
|
||||
{Name: "presence", Description: "Every machine and tool runner the watchman sees connected, with its last word, and " +
|
||||
"those it says silent; a machine that said it sleeps is shown as sleeping, not silent.",
|
||||
Input: schema(map[string]string{"machine": "one machine"}, nil)},
|
||||
{Name: "dead-letters", Description: "Every message a consumer on the bus gave up on, kept in DEAD_LETTERS: whose " +
|
||||
"consumer, the subject, how often it was handed over and when it was given up, newest first. With id: that one " +
|
||||
"whole. With deliver or drop, a hand act with why.",
|
||||
Input: schema(map[string]string{
|
||||
"id": "a dead letter's id, as the list gives it: that one whole, with its message",
|
||||
"consumer": "a consumer's name, or <stream>.<consumer>: only what that one gave up on",
|
||||
"limit": "how many to list, newest first (default 50); only when listing",
|
||||
"deliver": "a dead letter's id: deliver it again to the consumer that gave it up (needs why)",
|
||||
"drop": "a dead letter's id: drop it for good (needs why)",
|
||||
"why": "with deliver or drop: why, required and recorded",
|
||||
"cause": "with deliver or drop: the cause in a word (dead-letter when absent)",
|
||||
}, nil)},
|
||||
{Name: "resync", Description: "Rebuild presence from the bus's connections, and the pulses and findings from the keys " +
|
||||
"the watchman watches, and take every dead-letter notice waiting: the recovery of a watchman that missed a change.",
|
||||
Input: schema(map[string]string{}, nil)},
|
||||
}
|
||||
}
|
||||
|
||||
// selfCheckVerbs are the self-check's verbs (ADR 0564 clause 6).
|
||||
func selfCheckVerbs() []mcat.Verb {
|
||||
return []mcat.Verb{
|
||||
{Name: "doctor", Description: "The self-check: the last run's verdict at once, each probe passed, failed, deferred " +
|
||||
"(its facts not on the bus yet, named) or could not run, and how long ago. With run, a run now; with probe, one " +
|
||||
"probe's verdict whole. Runs every five minutes on its own; each failure is a finding.",
|
||||
Input: schema(map[string]string{
|
||||
"run": "\"true\": run every probe now and answer the verdict",
|
||||
"probe": "one probe's id (D6): its verdict alone, with every finding",
|
||||
}, nil, "run")},
|
||||
{Name: "probes", Description: "The registry: what each probe asserts, where it reads its facts, and the finding it writes.",
|
||||
Input: schema(map[string]string{}, nil)},
|
||||
}
|
||||
}
|
||||
|
||||
// healersVerbs are the healers' verbs (ADR 0564 clauses 7, 8).
|
||||
func healersVerbs() []mcat.Verb {
|
||||
return []mcat.Verb{
|
||||
{Name: "healers", Description: "The healers: each registered response to one kind of condition, its repair, its " +
|
||||
"budget and what happens when it is spent; every act taken lately with its outcome, and the mesh-wide brake.",
|
||||
Input: schema(map[string]string{"days": "how many days of acts back (default 7)"}, nil)},
|
||||
{Name: "heal", Description: "Take one healer's act for one open condition now, within its budget and the brake.",
|
||||
Input: schema(map[string]string{
|
||||
"key": "the condition's key",
|
||||
"why": "why now, kept with the act",
|
||||
}, []string{"key", "why"})},
|
||||
{Name: "drill", Description: "Record a drill: something broken on purpose to see the mesh raise and clear it, with " +
|
||||
"what it tests. A drill is never a repair: no healer is wanted for it however often it is run.",
|
||||
Input: schema(map[string]string{
|
||||
"what": "what was done on purpose, in a line",
|
||||
"why": "what the drill tests",
|
||||
"condition": "the key of the condition the drill is meant to raise, if any (optional)",
|
||||
}, []string{"what", "why"})},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package resolution
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
|
||||
const healthDigest = "@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||
|
||||
// healthManifest is a module of one web container, one running service and one step, with the health
|
||||
// given on the container (or on the resource named by on).
|
||||
func healthManifest(t *testing.T, on string, health map[string]any, more ...map[string]any) []byte {
|
||||
t.Helper()
|
||||
resources := []map[string]any{
|
||||
{"id": "server", "type": "container", "name": "app-server", "image": "registry.example/app" + healthDigest,
|
||||
"ports": []any{"8080"}},
|
||||
{"id": "daemon", "type": "service", "unit": "app.service", "state": "running"},
|
||||
{"id": "seed", "type": "container", "name": "app-seed", "image": "registry.example/app" + healthDigest,
|
||||
"run-once": true},
|
||||
{"id": "sweep", "type": "container", "name": "app-sweep", "image": "registry.example/app" + healthDigest,
|
||||
"schedule": "0 3 * * *"},
|
||||
}
|
||||
resources = append(resources, more...)
|
||||
for _, r := range resources {
|
||||
if r["id"] == on && health != nil {
|
||||
r["health"] = health
|
||||
}
|
||||
}
|
||||
m := map[string]any{"module": "app", "requires": []any{"postgres-database"}, "tools": []any{"app_status"},
|
||||
"listens": []any{
|
||||
map[string]any{"name": "web", "port": 8080, "from": "mesh"},
|
||||
map[string]any{"name": "beacon", "port": 9999, "protocol": "udp", "from": "mesh"},
|
||||
},
|
||||
"resources": resources}
|
||||
raw, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
func TestHealthIsComposedAsThePortThisMachineGaveTheEndpoint(t *testing.T) {
|
||||
m, err := decodeForTest(healthManifest(t, "server", map[string]any{"kind": "http", "endpoint": "web", "path": "/healthz",
|
||||
"needs": "postgres-database"}))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
compose := func(with mcat.Rendering) map[string]any {
|
||||
out, err := Declaration(mcat.Resolution{Node: "laptop", Modules: []mcat.Manifest{m}}, with)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
at := indexOfID(out, "app.server")
|
||||
if at < 0 {
|
||||
t.Fatal("the container was lost")
|
||||
}
|
||||
return out[at]
|
||||
}
|
||||
// An engine older than the field is not sent it: it would refuse the whole declaration.
|
||||
if h, sent := compose(mcat.Rendering{Ports: map[string]map[int]int{"app": {8080: 31001}}})["health"]; sent {
|
||||
t.Fatalf("health was sent to an engine that does not read it: %v", h)
|
||||
}
|
||||
got := compose(mcat.Rendering{ReadsHealth: true, Ports: map[string]map[int]int{"app": {8080: 31001}}})["health"].(map[string]any)
|
||||
if got["port"] != 31001 || got["endpoint"] != "web" || got["path"] != "/healthz" || got["needs"] != "postgres-database" {
|
||||
t.Errorf("composed as %v", got)
|
||||
}
|
||||
if got["interval"] != "30s" || got["timeout"] != "5s" || got["looks"] != 3 || got["grace"] != "1m0s" {
|
||||
t.Errorf("the defaults were not written out: %v", got)
|
||||
}
|
||||
// The port this machine gives the endpoint moves, and the check moves with it.
|
||||
moved := compose(mcat.Rendering{ReadsHealth: true, Ports: map[string]map[int]int{"app": {8080: 31002}}})["health"].(map[string]any)
|
||||
if moved["port"] != 31002 {
|
||||
t.Errorf("after the port moved the check still dials %v", moved["port"])
|
||||
}
|
||||
// And the catalogue's manifest is untouched by composing it.
|
||||
if _, ok := m.Resources[0]["health"].(map[string]any)["port"]; ok {
|
||||
t.Error("composing wrote the port into the catalogue's own manifest")
|
||||
}
|
||||
}
|
||||
|
||||
// What the controller composes the node-engine takes: every kind, composed for an engine that reads it,
|
||||
// passes the engine's own validator (mesh-host/validate) — one set of words on both sides.
|
||||
func TestEveryComposedHealthIsOneTheNodeEngineTakes(t *testing.T) {
|
||||
for _, h := range []map[string]any{
|
||||
{"kind": "http", "endpoint": "web", "path": "/healthz", "status": 200, "body": "ok", "needs": "postgres-database"},
|
||||
{"kind": "tcp", "endpoint": "web", "interval": "10s", "timeout": "2s", "looks": 2, "grace": "0s"},
|
||||
{"kind": "runtime"},
|
||||
{"kind": "exec", "command": "pg_isready -q"},
|
||||
} {
|
||||
m, err := decodeForTest(healthManifest(t, "server", h,
|
||||
map[string]any{"id": "admin", "type": "service", "unit": "app-admin.service", "state": "running",
|
||||
"health": map[string]any{"kind": "tool", "tool": "app_status"}}))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m.Resources[1]["health"] = map[string]any{"kind": "unit"}
|
||||
if _, err := Declaration((mcat.Resolution{Node: "laptop", Modules: []mcat.Manifest{m}}), mcat.Rendering{ReadsHealth: true,
|
||||
Ports: map[string]map[int]int{"app": {8080: 31001}}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package resolution
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
|
||||
func busSeatDelivering(t *testing.T, delivers string) {
|
||||
t.Helper()
|
||||
was := mcat.Seats()
|
||||
t.Cleanup(func() { mcat.UseSeats(was) })
|
||||
mcat.UseSeats([]mcat.Seat{{Name: "mesh-broker", Scope: mcat.ScopeMesh, Delivers: delivers, Decision: "test"}})
|
||||
}
|
||||
|
||||
func oldBroker() mcat.Manifest {
|
||||
return mcat.Manifest{Module: "old-broker", Provides: []mcat.Offer{{Name: "mesh-bus", Scope: mcat.ScopeMesh}},
|
||||
Claims: []mcat.Claim{{Name: "mesh-broker", Scope: mcat.ScopeMesh}}}
|
||||
}
|
||||
|
||||
func newBroker() mcat.Manifest {
|
||||
return mcat.Manifest{Module: "new-broker", Provides: []mcat.Offer{{Name: "mesh-bus", Scope: mcat.ScopeMesh}},
|
||||
Claims: []mcat.Claim{{Name: "mesh-broker", Scope: mcat.ScopeMesh}}}
|
||||
}
|
||||
|
||||
// Nothing on record: exactly the old rule. One claimant holds; two are refused.
|
||||
func TestWithNoHolderOnRecordTheSoleClaimantHoldsAndTwoAreRefused(t *testing.T) {
|
||||
busSeatDelivering(t, "mesh-bus")
|
||||
node := mcat.Node{Name: "anchor"}
|
||||
|
||||
held, problems := checkClaims([]mcat.Manifest{oldBroker()}, node, nil, nil)
|
||||
if len(problems) != 0 || len(held) != 1 || held[0].Module != "old-broker" {
|
||||
t.Fatalf("a sole claimant did not hold the seat: held=%v problems=%v", held, problems)
|
||||
}
|
||||
_, problems = checkClaims([]mcat.Manifest{oldBroker(), newBroker()}, node, nil, nil)
|
||||
if len(problems) != 1 || !strings.Contains(problems[0], "both claim") {
|
||||
t.Fatalf("two claimants with nothing on record were not refused: %v", problems)
|
||||
}
|
||||
}
|
||||
|
||||
// With a holder on record, the other eligible assignment is silent: not refused, and not holding.
|
||||
func TestTheHolderOnRecordHoldsAndTheOtherClaimantStandsBesideIt(t *testing.T) {
|
||||
busSeatDelivering(t, "mesh-bus")
|
||||
node := mcat.Node{Name: "anchor"}
|
||||
record := []mcat.Held{{Claim: "mesh-broker", Scope: mcat.ScopeMesh, Node: "anchor", Module: "new-broker"}}
|
||||
|
||||
held, problems := checkClaims([]mcat.Manifest{oldBroker(), newBroker()}, node, nil, record)
|
||||
if len(problems) != 0 {
|
||||
t.Fatalf("the assignment beside the holder was refused: %v", problems)
|
||||
}
|
||||
if len(held) != 1 || held[0].Module != "new-broker" {
|
||||
t.Fatalf("the holder on record is not the one holding: %v", held)
|
||||
}
|
||||
}
|
||||
|
||||
// The record names a node too: an eligible module on another machine holds nothing, and its
|
||||
// machine's set still resolves.
|
||||
func TestAHolderOnRecordElsewhereLeavesThisMachinesClaimantSilent(t *testing.T) {
|
||||
busSeatDelivering(t, "mesh-bus")
|
||||
record := []mcat.Held{{Claim: "mesh-broker", Scope: mcat.ScopeMesh, Node: "anchor", Module: "new-broker"}}
|
||||
|
||||
held, problems := checkClaims([]mcat.Manifest{oldBroker()}, mcat.Node{Name: "laptop"}, nil, record)
|
||||
if len(problems) != 0 || len(held) != 0 {
|
||||
t.Fatalf("a claimant elsewhere than the recorded holder was not simply silent: held=%v problems=%v",
|
||||
held, problems)
|
||||
}
|
||||
}
|
||||
|
||||
// A machine being moved is handed its membership for the new bus as a sealed file in its own
|
||||
// declaration — the machine's, not any module's (design 28, task 5.2).
|
||||
func TestAMembershipForTheNewBusIsComposedAsASealedFile(t *testing.T) {
|
||||
r := mcat.Resolution{Node: "anchor"}
|
||||
got, err := Compose(r, mcat.Rendering{BusMembership: "sealed-blob"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var found map[string]any
|
||||
for _, res := range got.Resources {
|
||||
if res["id"] == BusMembershipID() {
|
||||
found = res
|
||||
}
|
||||
}
|
||||
if found == nil {
|
||||
t.Fatalf("no membership resource in %v", got.Resources)
|
||||
}
|
||||
if found["path"] != mcat.BusMembershipPath || found["sealed"] != "sealed-blob" || found["mode"] != "0600" {
|
||||
t.Fatalf("the membership is not a sealed 0600 file where the host reads it: %v", found)
|
||||
}
|
||||
// And a machine not being moved is handed nothing.
|
||||
got, _ = Compose(r, mcat.Rendering{})
|
||||
for _, res := range got.Resources {
|
||||
if res["id"] == BusMembershipID() {
|
||||
t.Fatal("a machine with no membership on record was handed one")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestARefusalWithNothingOnRecordNamesTheHandover(t *testing.T) {
|
||||
busSeatDelivering(t, "mesh-bus")
|
||||
elsewhere := []mcat.Held{{Claim: "mesh-broker", Scope: mcat.ScopeMesh, Node: "anchor", Module: "old-broker"}}
|
||||
|
||||
_, problems := checkClaims([]mcat.Manifest{newBroker()}, mcat.Node{Name: "laptop"}, elsewhere, nil)
|
||||
if len(problems) != 1 {
|
||||
t.Fatalf("two derived claimants across machines were not refused: %v", problems)
|
||||
}
|
||||
if !strings.Contains(problems[0], "`seat mesh-broker --to anchor/old-broker`") {
|
||||
t.Fatalf("the refusal does not name the handover that records the holder: %s", problems[0])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package resolution
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
|
||||
// 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.
|
||||
func TestTheMachinesNameIsItsSetting(t *testing.T) {
|
||||
cat := map[string]mcat.Manifest{"hostname": catalogueManifest(t, "hostname")}
|
||||
got, err := Resolve(cat, []string{"hostname"}, mcat.Node{Name: "ace", At: "ace.internal"}, mcat.World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
machines := map[string]string{"ace.internal": "10.42.0.2"}
|
||||
nameOf := func(settings mcat.SettingsBy) (string, string) {
|
||||
t.Helper()
|
||||
composed, err := Compose(got, mcat.Rendering{Names: machines, Machines: machines, Suffix: "internal",
|
||||
Settings: settings})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if why := composed.LeftOut["hostname"]; why != "" {
|
||||
return "", why
|
||||
}
|
||||
for _, r := range composed.Resources {
|
||||
if r["path"] == "/etc/hostname" {
|
||||
return r["content"].(string), ""
|
||||
}
|
||||
}
|
||||
t.Fatal("no /etc/hostname composed")
|
||||
return "", ""
|
||||
}
|
||||
|
||||
if _, why := nameOf(nil); !strings.Contains(why, "hostname") {
|
||||
t.Errorf("with no setting the machine's name was written, or left out for another reason: %q", why)
|
||||
}
|
||||
if name, why := nameOf(mcat.SettingsBy{"hostname": {{From: "ace", Values: map[string]any{"hostname": "Ace"}}}}); name != "Ace\n" {
|
||||
t.Errorf("the operator's name for the machine gave %q (%s)", name, why)
|
||||
}
|
||||
mesh := mcat.Layer{From: "the mesh", Values: map[string]any{"hostname": "${machine:name}"}}
|
||||
if name, why := nameOf(mcat.SettingsBy{"hostname": {mesh}}); name != "ace\n" {
|
||||
t.Errorf("a mesh-wide ${machine:name} gave %q (%s)", name, why)
|
||||
}
|
||||
node := mcat.Layer{From: "ace", Values: map[string]any{"hostname": "Ace"}}
|
||||
if name, why := nameOf(mcat.SettingsBy{"hostname": {mesh, node}}); name != "Ace\n" {
|
||||
t.Errorf("a machine's own name over the mesh-wide one gave %q (%s)", name, why)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package resolution
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
|
||||
// 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).
|
||||
//
|
||||
// Owned by the holder, because the directory is: two modules writing into one fail2ban is the
|
||||
// collision the holder model exists to prevent. Empty when nothing declares a jail — then the file
|
||||
// is written empty rather than absent, so removing the last jail is an ordinary change the service
|
||||
// restarts on rather than a file that vanishes.
|
||||
func jailsInto(modules []mcat.Manifest, j *mcat.Jailing) []map[string]any {
|
||||
type declared struct {
|
||||
module string
|
||||
jail mcat.Jail
|
||||
}
|
||||
var jails []declared
|
||||
for _, m := range modules {
|
||||
for _, jail := range m.Jails {
|
||||
jails = append(jails, declared{m.Module, jail})
|
||||
}
|
||||
}
|
||||
// A stable order the host applies as given (ADR 0005), and so the same set composes byte for
|
||||
// byte every time rather than differing by map iteration.
|
||||
sort.Slice(jails, func(a, b int) bool { return jails[a].jail.Name < jails[b].jail.Name })
|
||||
|
||||
var composed strings.Builder
|
||||
composed.WriteString("# The mesh's jails, composed from the modules this node runs. Do not edit —\n")
|
||||
composed.WriteString("# replaced whenever the node's modules change (novox/hq to-be 31).\n")
|
||||
|
||||
out := make([]map[string]any, 0, len(jails)+1)
|
||||
for _, d := range jails {
|
||||
// **The filter's digest rides in the jail file.** fail2ban is restarted when this file
|
||||
// changes, and the filter is a file of its own: a module that changed only what a failure
|
||||
// looks like rewrote the filter on disk and left the running jail on the old pattern, with
|
||||
// nothing said (novox/hq issue 191's rollout found it on gitea's sshd). Naming the filter's
|
||||
// digest here makes a changed pattern a changed jail file, so the restart the service
|
||||
// already takes on it covers the filter too.
|
||||
sum := sha256.Sum256([]byte(d.jail.Failregex))
|
||||
fmt.Fprintf(&composed, "\n# from %s, filter %s\n[%s]\nenabled = true\nfilter = %s\n%s\n",
|
||||
d.module, hex.EncodeToString(sum[:])[:12], d.jail.Name, d.jail.Name,
|
||||
strings.TrimRight(d.jail.Jail, "\n"))
|
||||
// The filter is a file of its own, named as the jail's filter= references it.
|
||||
out = append(out, map[string]any{
|
||||
"id": "filter-" + d.jail.Name,
|
||||
"type": "file", "path": strings.TrimRight(j.FilterInto, "/") + "/" + d.jail.Name + ".conf",
|
||||
"mode": "0644",
|
||||
"content": "# Generated by the mesh (from module " + d.module + "). Do not edit.\n" +
|
||||
"[Definition]\nfailregex = " + d.jail.Failregex + "\n",
|
||||
})
|
||||
}
|
||||
// The one jail file, first, with the fixed id the fail2ban service names in its restart-on.
|
||||
return append([]map[string]any{{
|
||||
"id": mcat.ComposedJailsID(), "type": "file", "path": j.Into, "mode": "0644",
|
||||
"content": composed.String(),
|
||||
}}, out...)
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package resolution
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
|
||||
// A node's fail2ban jails are composed from the modules it runs (novox/hq to-be 31): the holder
|
||||
// (jailing) gathers every module's declared jail into one jail file and a filter file per jail.
|
||||
func TestJailsAreComposedFromTheNodesModules(t *testing.T) {
|
||||
modules := []mcat.Manifest{
|
||||
{Module: "fail2ban", Jailing: &mcat.Jailing{Into: "/etc/fail2ban/jail.d/mesh-composed.conf", FilterInto: "/etc/fail2ban/filter.d"}},
|
||||
{Module: "postgres", Jails: []mcat.Jail{{Name: "postgres-auth", Failregex: "auth failed from <HOST>", Jail: "port = 5432\nmaxretry = 5"}}},
|
||||
}
|
||||
files := jailsInto(modules, modules[0].Jailing)
|
||||
|
||||
by := map[string]map[string]any{}
|
||||
for _, f := range files {
|
||||
by[f["id"].(string)] = f
|
||||
}
|
||||
jail := by[mcat.ComposedJailsID()]
|
||||
if jail == nil || jail["path"] != "/etc/fail2ban/jail.d/mesh-composed.conf" {
|
||||
t.Fatalf("the composed jail file was not written: %v", jail)
|
||||
}
|
||||
body := jail["content"].(string)
|
||||
if !strings.Contains(body, "[postgres-auth]") || !strings.Contains(body, "filter = postgres-auth") ||
|
||||
!strings.Contains(body, "port = 5432") {
|
||||
t.Fatalf("the postgres jail stanza was not composed in:\n%s", body)
|
||||
}
|
||||
filter := by["filter-postgres-auth"]
|
||||
if filter == nil || filter["path"] != "/etc/fail2ban/filter.d/postgres-auth.conf" {
|
||||
t.Fatalf("the jail's filter file was not written: %v", filter)
|
||||
}
|
||||
if !strings.Contains(filter["content"].(string), "failregex = auth failed from <HOST>") {
|
||||
t.Fatalf("the failregex was not written: %v", filter["content"])
|
||||
}
|
||||
}
|
||||
|
||||
// A holder whose node runs no jail-declaring module still gets the file, empty — so removing the
|
||||
// last jail is a change the service restarts on, not a file that vanishes.
|
||||
func TestTheComposedJailFileIsWrittenEvenWhenEmpty(t *testing.T) {
|
||||
files := jailsInto([]mcat.Manifest{{Module: "fail2ban"}}, &mcat.Jailing{Into: "/x", FilterInto: "/f"})
|
||||
if len(files) != 1 || files[0]["id"] != mcat.ComposedJailsID() {
|
||||
t.Fatalf("the empty composed jail file was not written alone: %v", files)
|
||||
}
|
||||
}
|
||||
|
||||
// A changed pattern restarts fail2ban (novox/hq issue 191's rollout): the service restarts when the
|
||||
// composed jail file changes, and the filter is a file of its own, so the jail file names the
|
||||
// filter's digest. Changing only the failregex must change the jail file; the same pattern must not.
|
||||
func TestAChangedFilterChangesTheJailFile(t *testing.T) {
|
||||
jailFile := func(failregex string) string {
|
||||
modules := []mcat.Manifest{
|
||||
{Module: "fail2ban", Jailing: &mcat.Jailing{Into: "/etc/fail2ban/jail.d/mesh.conf", FilterInto: "/etc/fail2ban/filter.d"}},
|
||||
{Module: "gitea", Jails: []mcat.Jail{{Name: "gitea", Failregex: failregex, Jail: "port = 222"}}},
|
||||
}
|
||||
for _, f := range jailsInto(modules, modules[0].Jailing) {
|
||||
if f["id"] == mcat.ComposedJailsID() {
|
||||
return f["content"].(string)
|
||||
}
|
||||
}
|
||||
t.Fatal("no composed jail file")
|
||||
return ""
|
||||
}
|
||||
before := jailFile("web login failed from <HOST>")
|
||||
if again := jailFile("web login failed from <HOST>"); again != before {
|
||||
t.Errorf("the same pattern composed a different jail file, which would restart fail2ban for nothing")
|
||||
}
|
||||
if after := jailFile("web login failed from <HOST>\n Invalid user .* from <HOST>"); after == before {
|
||||
t.Errorf("a changed pattern left the jail file as it was, so fail2ban keeps the old filter:\n%s", after)
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user