Compare commits

..
Author SHA1 Message Date
jochen 6f34f8efea Add the replay of issue 340: a verb set what every agent session obeys (hq issue 340)
mesh/delivery-group group fix/340-replay rejected: a member's own check failed
mesh/merge-gate out of date: judged against trunk eb7ee921b533, the trunk is now 49102e1645c9; judged again on a recheck or when it is merged
mesh/repo-check out of date: judged against trunk eb7ee921b533, the trunk is now 49102e1645c9; judged again on a recheck or when it is merged
mesh/delivery superseded: a newer head of the same pull request
The settings verb took the agent module's managed settings and an empty
runtime configuration's url from any caller. The replay uses only what the
controller had before #172, so mesh-lab's prover can show it fails there.
2026-10-13 02:31:47 +02:00
120 changed files with 259 additions and 6638 deletions
+1 -3
View File
@@ -27,11 +27,9 @@ WORKDIR /src
COPY go.mod go.sum ./
COPY vendor/ vendor/
# `COPY . .` brings `.git` along, so Go is told not to stamp the commit: the version is said by -X, and a
# rebuild of an unchanged program is the same bytes (novox/hq ADR 0267 rule 6, issue 363).
COPY . .
ARG VERSION=development
RUN CGO_ENABLED=0 GOFLAGS=-mod=vendor GOPROXY=off go build -trimpath -buildvcs=false \
RUN CGO_ENABLED=0 GOFLAGS=-mod=vendor GOPROXY=off go build -trimpath \
-ldflags "-s -w -X main.version=${VERSION}" \
-o /mesh-controller ./cmd/mesh-controller
+1 -1
View File
@@ -11,7 +11,7 @@ WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -trimpath -buildvcs=false -ldflags '-s -w' -o /mesh-builder ./cmd/mesh-builder
RUN CGO_ENABLED=0 go build -trimpath -ldflags '-s -w' -o /mesh-builder ./cmd/mesh-builder
FROM ${ALPINE_BASE}
# git to clone what it is asked to build, and the docker client to build and push it. The daemon
+58 -158
View File
@@ -18,13 +18,11 @@ package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"log"
"net/url"
"os"
"os/signal"
"sort"
"strings"
"syscall"
"time"
@@ -56,8 +54,6 @@ is dialled except the broker.
MESH_BINDING a file the mesh wrote saying where the artifact store is
MESH_PACKAGE_BINDING a file the mesh wrote saying where the package registry is
MESH_NPM_TOKEN_FILE a file the mesh sealed holding the token for it
MESH_GENESIS_PACKAGE_REGISTRY the document the installer sealed at genesis, read while no
binding is present (novox/hq ADR 0336)
MESH_NPM_REGISTRY a package registry URL, when the mesh has not said (a person, the bootstrap)
MESH_NPM_TOKEN the token for it, likewise
MESH_NPM_SCOPE the scope it answers for (default: @novox)
@@ -258,10 +254,7 @@ func answer(ctx context.Context, publisher builder.Publisher, on, workspace stri
}
say("build", what)
npmrc, err := packagesFrom(on)
if err == nil {
say("packages", saysRegistry(npmrc))
}
npmrc, err := packagesFrom()
var built builder.Result
if request.Check != nil {
// **A pull request's merge check, not a build** (novox/hq to-be 45 §9): nothing is built,
@@ -327,7 +320,6 @@ func answer(ctx context.Context, publisher builder.Publisher, on, workspace stri
}
result.Against = built.Against
result.SourceFingerprint = built.Source
result.Predefined = built.Predefined
result.Trunk, result.OnTrunk, result.Branches = built.Trunk, built.OnTrunk, built.Branches
for _, r := range built.Read {
result.Read = append(result.Read, link.ReadRepository{Repository: r.Repository, Ref: r.Ref})
@@ -394,171 +386,79 @@ func layerOf(l *builder.Layer) *link.CheckLayer {
}
// packagesFrom is where a build resolves the mesh's own published packages — the SDK above all
// (novox/hq ADR 0076, issue 053) — on this machine.
// (novox/hq ADR 0076, issue 053).
//
// **The binding wins, then the genesis document** (novox/hq ADR 0336 §3, §4). The build agent's
// npm-package-registry requirement is optional: on genesis's node, before the gitea module is assigned,
// nothing provides it, and the installer sealed the forge it raised to this agent as one document
// instead. Once the requirement binds, the binding names the endpoint the way the artifact store's
// does and a sealed token file the credential, and the genesis document is left unread. A binding
// file named and absent is a requirement not bound here, never a fault: the environment names the
// path whether or not the mesh wrote it.
//
// The environment variables remain for a builder run by a person. With none of the three the result
// is disabled and says this machine, so a build that needs the registry is refused naming it (§6),
// and a build that needs none builds anyway.
func packagesFrom(machine string) (builder.Npmrc, error) {
// Preferably from the mesh: a package-registry binding names the endpoint the way the artifact
// store's binding does, and a sealed token file the credential the way the broker's does. The
// environment variables remain for a builder run by a person, and for the bootstrap, where there is
// no registry yet — there the result is disabled and a build that needs no mesh-published dependency
// builds anyway.
func packagesFrom() (builder.Npmrc, error) {
scope := strings.TrimSpace(os.Getenv("MESH_NPM_SCOPE"))
if scope == "" {
scope = "@novox"
}
none := builder.Npmrc{Machine: machine}
registry := strings.TrimSpace(os.Getenv("MESH_NPM_REGISTRY"))
var username string
if path := strings.TrimSpace(os.Getenv("MESH_PACKAGE_BINDING")); path != "" {
raw, err := os.ReadFile(path)
switch {
case errors.Is(err, os.ErrNotExist):
// Not bound on this machine: the genesis document, or nothing.
case err != nil:
return none, fmt.Errorf("cannot read what the mesh said about the package registry: %w", err)
default:
return fromBinding(path, raw, scope, machine)
}
}
if path := strings.TrimSpace(os.Getenv("MESH_GENESIS_PACKAGE_REGISTRY")); path != "" {
raw, err := os.ReadFile(path)
switch {
case errors.Is(err, os.ErrNotExist):
// Not this mesh's genesis node, or the installer has not sealed it yet.
case err != nil:
return none, fmt.Errorf("cannot read the genesis document for the package registry: %w", err)
default:
return fromGenesis(path, raw, scope, machine)
}
}
// A builder run by a person.
registry := strings.TrimSpace(os.Getenv("MESH_NPM_REGISTRY"))
secret := strings.TrimSpace(os.Getenv("MESH_NPM_TOKEN"))
username := strings.TrimSpace(os.Getenv("MESH_NPM_USER"))
if registry == "" && secret == "" {
return none, nil
}
n := builder.Npmrc{Scope: scope, Registry: registry, From: builder.FromEnvironment, Machine: machine}
if username != "" {
n.Username, n.Password = username, secret
} else {
n.Token = secret
}
return n, nil
}
// fromBinding composes the registry from the npm-package-registry binding the mesh wrote and the
// credential it sealed beside it. The mesh authenticates the ordinary way — a generated password the
// provider only applies (novox/hq ADR 0048) — so with a username the secret is a password (basic
// auth); without one it is a bearer token a provider minted.
func fromBinding(path string, raw []byte, scope, machine string) (builder.Npmrc, error) {
none := builder.Npmrc{Machine: machine}
var told struct {
From string `json:"from"`
At string `json:"at"`
As string `json:"as"`
Serves map[string]any `json:"serves"`
}
if err := json.Unmarshal(raw, &told); err != nil {
return none, fmt.Errorf("%s is not a binding: %w", path, err)
}
if told.At == "" {
return none, fmt.Errorf(
"%s says the package registry is on %q and gives no address for it", path, told.From)
}
// Composed from what the provider serves, so nothing here knows gitea's URL shape from
// another registry's: it states its port, the path its registry answers on, and the scheme.
scheme := "https"
if s, ok := told.Serves["scheme"]; ok {
scheme = fmt.Sprintf("%v", s)
}
port, ok := told.Serves["port"]
if !ok {
return none, fmt.Errorf("%s says nothing about which port the package registry answers on", path)
}
npmPath, ok := told.Serves["npm-path"]
if !ok {
return none, fmt.Errorf("%s says nothing about the path the package registry answers on", path)
}
secret := strings.TrimSpace(os.Getenv("MESH_NPM_TOKEN"))
if file := strings.TrimSpace(os.Getenv("MESH_NPM_TOKEN_FILE")); file != "" {
raw, err := os.ReadFile(file)
if err != nil {
return none, fmt.Errorf("cannot read this builder's package-registry credential: %w", err)
return builder.Npmrc{}, fmt.Errorf("cannot read what the mesh said about the package registry: %w", err)
}
var told struct {
From string `json:"from"`
At string `json:"at"`
As string `json:"as"`
Serves map[string]any `json:"serves"`
}
if err := json.Unmarshal(raw, &told); err != nil {
return builder.Npmrc{}, fmt.Errorf("%s is not a binding: %w", path, err)
}
if told.At == "" {
return builder.Npmrc{}, fmt.Errorf(
"%s says the package registry is on %q and gives no address for it", path, told.From)
}
// Composed from what the provider serves, so nothing here knows gitea's URL shape from
// another registry's: it states its port, the path its registry answers on, and the scheme.
scheme := "https"
if s, ok := told.Serves["scheme"]; ok {
scheme = fmt.Sprintf("%v", s)
}
port, ok := told.Serves["port"]
if !ok {
return builder.Npmrc{}, fmt.Errorf("%s says nothing about which port the package registry answers on", path)
}
npmPath, ok := told.Serves["npm-path"]
if !ok {
return builder.Npmrc{}, fmt.Errorf("%s says nothing about the path the package registry answers on", path)
}
registry = fmt.Sprintf("%s://%s:%v%v", scheme, told.At, port, npmPath)
username = told.As
}
// The credential the mesh sealed to this machine. The mesh authenticates the ordinary way — a
// generated password the provider only applies (novox/hq ADR 0048) — so with a username this is
// a password (basic auth); without one it is a bearer token a provider minted.
secret := strings.TrimSpace(os.Getenv("MESH_NPM_TOKEN"))
if path := strings.TrimSpace(os.Getenv("MESH_NPM_TOKEN_FILE")); path != "" {
raw, err := os.ReadFile(path)
if err != nil {
return builder.Npmrc{}, fmt.Errorf("cannot read this builder's package-registry credential: %w", err)
}
secret = strings.TrimSpace(string(raw))
}
n := builder.Npmrc{Scope: scope, Registry: fmt.Sprintf("%s://%s:%v%v", scheme, told.At, port, npmPath),
From: builder.FromBinding, Machine: machine}
if told.As != "" {
n.Username, n.Password = told.As, secret
} else {
n.Token = secret
if u := strings.TrimSpace(os.Getenv("MESH_NPM_USER")); u != "" {
username = u
}
return n, nil
}
// GenesisRegistry is the genesis document (novox/hq ADR 0336 §3): what the installer sealed to the
// build agent on its node at genesis's packages step, as the `genesis-package-registry` own-secret —
// the forge it raised, the port this node gave the package registry, its npm path, and the account it
// made with that account's password. mesh-host writes it; the field names are the two repositories'
// agreement.
type GenesisRegistry struct {
Scheme string `json:"scheme"`
At string `json:"at"`
Port int `json:"port"`
NpmPath string `json:"npm-path"`
As string `json:"as"`
Password string `json:"password"`
}
// fromGenesis composes the registry from the genesis document.
func fromGenesis(path string, raw []byte, scope, machine string) (builder.Npmrc, error) {
none := builder.Npmrc{Machine: machine}
var g GenesisRegistry
if err := json.Unmarshal(raw, &g); err != nil {
return none, fmt.Errorf("%s is not the genesis document for the package registry: %w", path, err)
if registry == "" && secret == "" {
return builder.Npmrc{}, nil
}
var missing []string
for name, value := range map[string]string{"at": g.At, "npm-path": g.NpmPath, "as": g.As, "password": g.Password} {
if strings.TrimSpace(value) == "" {
missing = append(missing, name)
}
if username != "" {
return builder.Npmrc{Scope: scope, Registry: registry, Username: username, Password: secret}, nil
}
if g.Port <= 0 {
missing = append(missing, "port")
}
if len(missing) > 0 {
sort.Strings(missing)
return none, fmt.Errorf("%s, the genesis document for the package registry, says nothing of %s",
path, strings.Join(missing, ", "))
}
scheme := g.Scheme
if scheme == "" {
scheme = "http"
}
return builder.Npmrc{Scope: scope, Registry: fmt.Sprintf("%s://%s:%d%s", scheme, g.At, g.Port, g.NpmPath),
Username: g.As, Password: g.Password, From: builder.FromGenesis, Machine: machine}, nil
}
// saysRegistry is the line each build's log carries about the package registry (novox/hq ADR 0336
// §4): which of the binding and the genesis document it resolved from, or that it has neither here.
func saysRegistry(n builder.Npmrc) string {
if n.Enabled() {
return "the package registry is resolved from " + n.From
}
machine := n.Machine
if machine == "" {
machine = "this machine"
}
return fmt.Sprintf("no package registry: %s is unbound on %s and no genesis document is here; "+
"a build needing it is refused", builder.RegistryRequirement, machine)
return builder.Npmrc{Scope: scope, Registry: registry, Token: secret}, nil
}
// forgeFrom is the git credential this builder may offer a clone, composed from the same binding
+1 -6
View File
@@ -84,15 +84,10 @@ func buildOnce(ctx context.Context, args []string) error {
}
fmt.Fprintln(os.Stderr)
machine := os.Getenv("MESH_NODE")
if machine == "" {
machine, _ = os.Hostname()
}
npmrc, err := packagesFrom(machine)
npmrc, err := packagesFrom()
if err != nil {
return err
}
fmt.Fprintf(os.Stderr, " [packages] %s\n", saysRegistry(npmrc))
built, buildErr := builder.Build(ctx, builder.Command, publisher, repository, *path, *ref, where, bases, npmrc,
forgeFrom(),
func(step, message string) { fmt.Fprintf(os.Stderr, " [%s] %s\n", step, message) })
-102
View File
@@ -1,102 +0,0 @@
package main
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/novox/mesh-controller/internal/builder"
)
// The build agent's package registry (novox/hq ADR 0336, issue 121): its npm-package-registry
// requirement is optional, so the binding may be absent, and on genesis's node the installer seals
// the forge it raised as one genesis document instead.
// registryFiles names the three paths the build agent's environment always names, writing only those
// given.
func registryFiles(t *testing.T, binding, token, genesis string) {
t.Helper()
dir := t.TempDir()
for env, content := range map[string]string{
"MESH_PACKAGE_BINDING": binding,
"MESH_NPM_TOKEN_FILE": token,
"MESH_GENESIS_PACKAGE_REGISTRY": genesis,
} {
path := filepath.Join(dir, env)
t.Setenv(env, path)
if content != "" {
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatal(err)
}
}
}
for _, env := range []string{"MESH_NPM_REGISTRY", "MESH_NPM_TOKEN", "MESH_NPM_USER", "MESH_NPM_SCOPE"} {
t.Setenv(env, "")
}
}
const (
aBinding = `{"from":"gitea","at":"anchor.mesh","as":"anchor-agent",
"serves":{"scheme":"http","port":3000,"npm-path":"/api/packages/novox/npm/"}}`
aGenesisDocument = `{"scheme":"http","at":"127.0.0.1","port":3100,"npm-path":"/api/packages/novox/npm/",
"as":"mesh-builder","password":"genesis-secret"}`
)
func TestUntilTheRequirementBindsTheGenesisDocumentIsRead(t *testing.T) {
registryFiles(t, "", "", aGenesisDocument)
n, err := packagesFrom("anchor")
if err != nil {
t.Fatalf("an absent binding beside a genesis document was refused: %v", err)
}
if n.From != builder.FromGenesis || n.Registry != "http://127.0.0.1:3100/api/packages/novox/npm/" ||
n.Username != "mesh-builder" || n.Password != "genesis-secret" || n.Scope != "@novox" {
t.Fatalf("the genesis document was not what the registry came from: %+v", n)
}
file, err := n.File()
if err != nil || !strings.Contains(file, "//127.0.0.1:3100/api/packages/novox/npm/:username=mesh-builder") {
t.Fatalf("the .npmrc is not written from the genesis document: %v\n%s", err, file)
}
if got := saysRegistry(n); !strings.Contains(got, "genesis document") {
t.Errorf("the log does not say the genesis document: %s", got)
}
}
func TestARealBindingWinsOverTheGenesisDocument(t *testing.T) {
registryFiles(t, aBinding, "bound-secret\n", aGenesisDocument)
n, err := packagesFrom("anchor")
if err != nil {
t.Fatal(err)
}
if n.From != builder.FromBinding || n.Registry != "http://anchor.mesh:3000/api/packages/novox/npm/" ||
n.Username != "anchor-agent" || n.Password != "bound-secret" {
t.Fatalf("the binding did not win over the genesis document: %+v", n)
}
if got := saysRegistry(n); !strings.Contains(got, "binding") {
t.Errorf("the log does not say the binding: %s", got)
}
}
func TestWithNeitherTheAgentHasNoRegistryAndSaysWhere(t *testing.T) {
registryFiles(t, "", "", "")
n, err := packagesFrom("workstation")
if err != nil {
t.Fatalf("an unbound optional requirement was a fault: %v", err)
}
if n.Enabled() || n.Machine != "workstation" {
t.Fatalf("a registry came from nowhere: %+v", n)
}
got := saysRegistry(n)
for _, want := range []string{"npm-package-registry", "unbound", "workstation"} {
if !strings.Contains(got, want) {
t.Errorf("the log does not say %q: %s", want, got)
}
}
}
func TestAGenesisDocumentMissingAFieldIsRefusedByName(t *testing.T) {
registryFiles(t, "", "", `{"at":"127.0.0.1","port":3000,"npm-path":"/api/packages/novox/npm/","as":"mesh-builder"}`)
if _, err := packagesFrom("anchor"); err == nil || !strings.Contains(err.Error(), "password") {
t.Fatalf("a genesis document with no password was taken: %v", err)
}
}
-145
View File
@@ -1,145 +0,0 @@
package main
import (
"context"
"fmt"
"sort"
"strings"
"github.com/novox/mesh-controller/internal/catalogue"
"github.com/novox/mesh-controller/internal/inventory"
)
// A provision whose credential only a person outside the mesh can give (novox/hq ADR 0337, issue 164, issue 165):
// given once for its provider, at the controller's terminal, and sealed in one act to every consumer that binds the
// provision from that provider now. The value is never read back, printed or kept.
// outsideOffer is the provider module's own name when `secret accept <node> <module> <name>` names a provision that
// module offers with a credential issued outside the mesh, and "" when it names anything else (an own secret, or a
// pair with --provider).
func outsideOffer(ctx context.Context, module, name, provider string) (string, error) {
if provider != "" {
return "", nil
}
open, err := openStores(ctx)
if err != nil {
return "", err
}
defer open.Close()
shelf, err := open.inventory.Catalogue(ctx)
if err != nil {
return "", err
}
if m, known := shelf[module]; known && m.IssuedOutsideOf(name) {
return module, nil
}
return "", nil
}
// offeredOutsideBy is a module that offers the provision with a credential issued outside the mesh, or "".
func offeredOutsideBy(ctx context.Context, provision string) (string, error) {
open, err := openStores(ctx)
if err != nil {
return "", err
}
defer open.Close()
shelf, err := open.inventory.Catalogue(ctx)
if err != nil {
return "", err
}
module, _ := catalogue.OffersIssuedOutside(shelf, provision)
return module, nil
}
// consumersFrom is every consumer that binds a provision from one provider now, found by resolving every machine as
// its next send would, and the machines that could not be resolved, with why.
func consumersFrom(ctx context.Context, open *stores, provider, providerModule, provision string) (
[]inventory.PairEnd, []string, error) {
nodes, err := open.inventory.Nodes(ctx)
if err != nil {
return nil, nil, err
}
var out []inventory.PairEnd
var unresolved []string
for _, n := range nodes {
plan, _, err := planFor(ctx, open, n.Name)
if err != nil {
unresolved = append(unresolved, fmt.Sprintf("%s: %s", n.Name, oneLine(err.Error())))
continue
}
out = append(out, bindsFrom(plan, n.Name, provider, providerModule, provision)...)
}
return out, unresolved, nil
}
// bindsFrom is every consumer on one machine whose resolution answers the provision from that provider. Pure.
func bindsFrom(plan catalogue.Resolution, machine, provider, providerModule, provision string) []inventory.PairEnd {
var out []inventory.PairEnd
for _, need := range plan.Needs {
if need.ByRecord || need.Name != provision || need.From != provider {
continue
}
if need.Module != "" && need.Module != providerModule {
continue
}
out = append(out, inventory.PairEnd{Consumer: machine, ConsumerModule: need.For, Local: need.Local})
}
sort.Slice(out, func(i, j int) bool {
return out[i].ConsumerModule+"\x00"+out[i].Local < out[j].ConsumerModule+"\x00"+out[j].Local
})
return out
}
// acceptForProvision gives a provision's credential issued outside the mesh, once for its provider (ADR 0337 §3).
func acceptForProvision(ctx context.Context, provider, providerModule, provision, value string) error {
open, err := openStores(ctx)
if err != nil {
return err
}
defer open.Close()
consumers, unresolved, err := consumersFrom(ctx, open, provider, providerModule, provision)
if err != nil {
return err
}
accepted, err := open.inventory.AcceptSecretForProvision(ctx, provision, provider, providerModule, value, consumers)
value = ""
if err != nil {
return err
}
fmt.Print(acceptedSaid(provider, providerModule, provision, accepted, unresolved))
if len(accepted.Sealed) == 0 {
return nil
}
if err := sendTo(ctx, open, accepted.Machines); err != nil {
return fmt.Errorf("%s is sealed and could not be sent: %w; `push %s` sends it", provision, err,
strings.Join(accepted.Machines, "`, `push "))
}
fmt.Printf(" sent %s; each consumer starts again on it\n", strings.Join(accepted.Machines, ", "))
return nil
}
// acceptedSaid is what one accept for a provider did, in words that never carry the value. Pure.
func acceptedSaid(provider, providerModule, provision string, a inventory.ProvisionAccepted, unresolved []string) string {
var b strings.Builder
if len(a.Sealed) == 0 {
fmt.Fprintf(&b, "%s from %s on %s: no consumer binds it now, so nothing was sealed; a consumer that binds "+
"later waits for it, and this line given again seals it to every consumer then\n", provision,
providerModule, provider)
} else {
var names []string
for _, c := range a.Sealed {
names = append(names, fmt.Sprintf("%s on %s%s", c.ConsumerModule, c.Consumer, asLocal(c.Local)))
}
fmt.Fprintf(&b, "%s from %s on %s is given, sealed to %s and to %s\n", provision, providerModule, provider,
strings.Join(names, ", "), provider)
fmt.Fprintf(&b, " the mesh cannot read it back, never makes one of its own for it, and will not rotate it\n")
}
for _, c := range a.Unsealable {
fmt.Fprintf(&b, " not sealed to %s on %s: no sealing key on that machine; give it again once it has one\n",
c.ConsumerModule, c.Consumer)
}
for _, u := range unresolved {
fmt.Fprintf(&b, " not known whether a consumer on %s binds it, which did not resolve; give it again once it does\n", u)
}
return b.String()
}
+2 -3
View File
@@ -203,9 +203,8 @@ func seatDependenciesOnAssign(ctx context.Context, open *stores, node string, mo
if _, err := catalogue.AssignRefusal(shelf, node, assigned, adding); err != nil {
return nil, nil, err
}
// Two modules declaring one path or unit, or one package present and absent, is refused before anything
// is recorded (novox/hq ADR 0210, ADR 0303, 04-ISSUES/235): kept, the node would not resolve until one came
// off again. Two modules declaring one package present is not a collision (ADR 0303, issue 628).
// Two modules declaring one package, path or unit is refused before anything is recorded
// (novox/hq ADR 0210, 04-ISSUES/235): kept, the node would not resolve until one came off again.
if err := catalogue.CollisionRefusal(shelf, node, assigned, adding); err != nil {
return nil, nil, err
}
+1 -15
View File
@@ -722,7 +722,7 @@ func converge(ctx context.Context, open *stores, node string, yes bool, digest s
return "", err
}
derived := derivedFilter{rules: rules, foundation: with.Foundation, mesh: with.Mesh,
outward: plan.PublicDomain != "", outwardLinks: with.OutwardLinks, local: with.LocalNetworks}
outward: plan.PublicDomain != "", outwardLinks: with.OutwardLinks}
filtering, err := inv.FilteringOf(ctx, node)
if err != nil {
return "", err
@@ -930,9 +930,6 @@ type derivedFilter struct {
// The filter constrains what arrives on them; everything arriving elsewhere is this machine's
// own guest and is not filtered.
outwardLinks []string
// local is the machine's local networks, which a port from `local-network` stays open to (novox/hq
// ADR 0340).
local []string
}
// closesOutside is what a narrowing from everywhere to the private network is called: it closes.
@@ -980,17 +977,6 @@ func (d derivedFilter) fate(r inventory.Reach) string {
if !onMesh {
return fmt.Sprintf("%s — declared by %s from the mesh only", closesOutside, by)
}
case catalogue.FromLocalNetwork:
// The private network and this machine's local networks (novox/hq ADR 0340): it closes to
// everything else outside, and says which local networks stay.
if !onMesh {
if len(d.local) == 0 {
return fmt.Sprintf("%s — declared by %s from the local network, and this machine "+
"reported none", closesOutside, by)
}
return fmt.Sprintf("WILL CLOSE to everything outside the private network and %s — declared "+
"by %s from the local network", strings.Join(d.local, ", "), by)
}
}
return fmt.Sprintf("declared by %s (from %s)", by, rule.From)
}
-34
View File
@@ -939,16 +939,11 @@ func planBatch(ctx context.Context, open *stores, batch *inventory.Plan, carry [
commits, named := carriedOf(merges)
var moved []string
var members []batchMember
unchanged := map[string]string{}
for _, m := range combinedMerges(merges) {
// **News, whatever was seen since** (review of this change): each merge was judged when it was heard, and
// a cut, a fold or a failed walk since marks its modules seen — so a merge walked alone after a failed
// walk, or folded, would read as history and move nothing. When it was made is not asked again here.
m.MergedAt = ""
// **Unchanged, said** (novox/hq issue 611): read before the merge is acted on, from the same records.
for n, why := range unchangedOfMerge(m, entries, read) {
unchanged[n] = why
}
names, err := movesOfMerge(ctx, inv, m, entries, read)
if err != nil {
return err
@@ -1000,16 +995,12 @@ func planBatch(ctx context.Context, open *stores, batch *inventory.Plan, carry [
plan.Delivery.Merges = named
plan.Delivery.Alone = batch.Delivery != nil && batch.Delivery.Alone
plan.Delivery.Withheld = withheld
plan.Delivery.Unchanged = unchangedLeft(unchanged, plan.Modules)
// **Its moments and its class** (novox/hq ADR 0282 decision 6): measured, never acted on.
plan.Times = walkTimesAtCut(*batch, plan, entries, now)
if len(moved) == 0 {
plan.State = inventory.PlanDone
plan.Tiers = [][]string{}
plan.Note = "moved nothing the mesh holds: nothing to walk"
if len(plan.Delivery.Unchanged) > 0 {
plan.Note = "moved nothing the mesh holds: nothing to walk; " + unchangedSaid(plan.Delivery.Unchanged)
}
if err := inv.SavePlan(ctx, &plan); err != nil {
return err
}
@@ -1054,31 +1045,6 @@ func planBatch(ctx context.Context, open *stores, batch *inventory.Plan, carry [
return nil
}
// unchangedSaid names the modules a walk says matched what runs, for its note.
func unchangedSaid(unchanged map[string]string) string {
names := make([]string, 0, len(unchanged))
for n := range unchanged {
names = append(names, n)
}
sort.Strings(names)
return "unchanged, the build the mesh holds is theirs: " + strings.Join(names, ", ")
}
// unchangedLeft is what a walk says matched what runs: every module a merge left unchanged that the walk does not
// build anyway, as another merge's module or a dependent (novox/hq issue 611). Nil when none is.
func unchangedLeft(unchanged map[string]string, builds map[string]*inventory.PlanModule) map[string]string {
out := map[string]string{}
for n, why := range unchanged {
if _, built := builds[n]; !built {
out[n] = why
}
}
if len(out) == 0 {
return nil
}
return out
}
// withhold takes from a plan every module named that its merges do not move, and the tiers left empty: the
// modules taken.
func withhold(plan *inventory.Plan, names, moved []string) []string {
-27
View File
@@ -696,20 +696,6 @@ func takeIn(ctx context.Context, inv *inventory.Inventory, result link.BuildResu
}
}
}
// **The list of predefined keys arrives with a build of the catalogue** (novox/hq issue 630): the
// builder reads predefined.json at the catalogue repository's root at the commit built, and it is kept
// before the module is registered, so a preference falling back to a key added in the same commit is
// judged against the list that adds it. Only from the catalogue: another repository names no list.
if len(result.Predefined) > 0 && isCatalogueRepository(result.Repository) {
list, err := catalogue.ParsePredefined(result.Predefined)
if err != nil {
return manifest, kept, fmt.Errorf("%s built %s (%s), recorded and not registered: %w", result.On,
manifest.Module, short(result.Commit), err)
}
if err := inv.SetPredefined(ctx, list, result.Repository, result.Commit); err != nil {
return manifest, kept, err
}
}
if err := inv.RegisterModule(ctx, manifest, recorded); err != nil {
if errors.Is(err, inventory.ErrSuperseded) {
return manifest, kept, fmt.Errorf("%s built %s (%s), recorded and not registered: %w",
@@ -1039,16 +1025,3 @@ func buildLog(ctx context.Context, id string) error {
}
return nil
}
// catalogueRepository is the repository the catalogue is: the one whose predefined.json is the list of
// predefined keys (novox/hq issue 630).
const catalogueRepository = "mesh-catalog"
// isCatalogueRepository is whether a repository, as a path on the forge or a URL, is the catalogue.
func isCatalogueRepository(repository string) bool {
trimmed := strings.TrimSuffix(strings.TrimSuffix(strings.TrimSpace(repository), "/"), ".git")
if i := strings.LastIndex(trimmed, "/"); i >= 0 {
trimmed = trimmed[i+1:]
}
return trimmed == catalogueRepository
}
-45
View File
@@ -47,13 +47,6 @@ func moduleCheckFor(paths []string, longestMachine int, out io.Writer) error {
shelf := catalogue.Shelf{}
faulted := map[string]bool{}
failed := 0
// The list of predefined keys a preference may fall back to (novox/hq issue 630): the predefined.json
// at the root of the repository the manifests are in, found above the first of them.
list, listAt, listErr := predefinedAbove(paths[0])
if listErr != nil {
fmt.Fprintf(out, "%s: %v\n", listAt, listErr)
failed++
}
for _, path := range paths {
raw, err := os.ReadFile(path)
if err != nil {
@@ -99,20 +92,6 @@ func moduleCheckFor(paths []string, longestMachine int, out io.Writer) error {
failed += len(named)
faulted[m.Module] = true
}
// A preference falls back only to a key on the list, with its type (novox/hq issue 630).
if named := catalogue.PredefinedNamed(m); len(named) > 0 {
if listAt == "" {
fmt.Fprintf(out, "note: %s: its preferences fall back to %s, and no %s was found above it; "+
"registration judges them against the list the mesh holds\n", path, strings.Join(named, ", "),
catalogue.PredefinedFile)
} else if wrong := catalogue.PredefinedProblems(m, list); len(wrong) > 0 {
for _, p := range wrong {
fmt.Fprintf(out, "%s: %s\n", path, p)
}
failed += len(wrong)
faulted[m.Module] = true
}
}
shelf[m.Module] = m
}
@@ -306,27 +285,3 @@ func manifestsUnder(dir string) ([]string, error) {
sort.Strings(found)
return found, err
}
// predefinedAbove reads the predefined.json found in the directory of a manifest or one above it, up to
// the repository's root (the directory holding .git). The path it was found at is "" when there is none.
func predefinedAbove(manifest string) (catalogue.Predefined, string, error) {
dir, err := filepath.Abs(filepath.Dir(manifest))
if err != nil {
return nil, "", nil
}
for {
at := filepath.Join(dir, catalogue.PredefinedFile)
if raw, err := os.ReadFile(at); err == nil {
list, err := catalogue.ParsePredefined(raw)
return list, at, err
}
if _, err := os.Stat(filepath.Join(dir, ".git")); err == nil {
return nil, "", nil
}
up := filepath.Dir(dir)
if up == dir {
return nil, "", nil
}
dir = up
}
}
-383
View File
@@ -1,383 +0,0 @@
package main
import (
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"sort"
"strings"
"github.com/novox/mesh-controller/internal/catalogue"
"github.com/novox/mesh-controller/internal/inventory"
)
// configCommand reads and sets config values (novox/hq issue 630). A value resolves from four sources, the
// last set winning: the module's default, mesh config, node config, and the module's own value on a node
// (its module-assignment config). Mesh and node config hold values only for the predefined keys, the
// catalogue's list; a module's own value is set on one node, never for the whole mesh.
//
// config [list] [--node <node>] [--module <module>]
// config set <key> <value> [--node <node> [--module <module>]] --why <text>
// config clear <key> [--node <node> [--module <module>]] --why <text>
// config history <key> [--node <node>]
//
// A set or clear is recorded in the hand-act log with the caller and why, kept in the history, and takes
// effect at the next push of the machines it reaches, which it names — as `settings set` does.
func configCommand(ctx context.Context, args []string) error {
sub := "list"
if len(args) > 0 && !strings.HasPrefix(args[0], "-") {
sub, args = args[0], args[1:]
}
set := flag.NewFlagSet("config", flag.ContinueOnError)
node := set.String("node", "", "one node: its node config, or with --module the module's own value there")
module := set.String("module", "", "a module: its own value on --node (module-assignment config)")
acts := addHandActFlags(set)
positionals, err := parseAround(set, args)
if err != nil {
return err
}
if *module != "" && *node == "" && sub != "list" {
return fmt.Errorf("config: %s names no node, and there is no module layer for the whole mesh — an "+
"assignment is one module on one node (novox/hq issue 630). Give node, or set mesh config for a "+
"predefined key without module. Nothing was changed", *module)
}
open, err := openStores(ctx)
if err != nil {
return err
}
defer open.Close()
inv := open.inventory
switch sub {
case "list":
if len(positionals) > 0 {
return errors.New("config list [--node <node>] [--module <module>]")
}
return configList(ctx, inv, *node, *module)
case "history":
if len(positionals) != 1 || *module != "" {
return errors.New("config history <key> [--node <node>] — a module's own values are in `settings show <module> --node <node> --history`")
}
past, err := inv.ConfigHistory(ctx, positionals[0], *node)
if err != nil {
return err
}
where := configWhere(*node, "")
if len(past) == 0 {
fmt.Printf("%s, %s: no value has been replaced\n", positionals[0], where)
return nil
}
for _, p := range past {
v, _ := json.Marshal(p.Value)
fmt.Printf("%s, %s: %s until %s (%s", positionals[0], where, v,
p.ReplacedAt.Local().Format("2006-01-02 15:04:05"), p.ReplacedBy)
if p.ReplacedWhy != "" {
fmt.Printf(": %s", p.ReplacedWhy)
}
fmt.Printf("); %s, because %s\n", orUnknown(p.SetBy), orUnknown(p.Why))
}
return nil
case "set", "clear":
default:
return fmt.Errorf("config has no %q; it has list, set, clear and history", sub)
}
// set and clear.
if err := acts.require("config " + sub); err != nil {
return err
}
why := strings.TrimSpace(*acts.why)
var key string
var value any
switch {
case sub == "set" && len(positionals) == 2:
key = positionals[0]
if err := json.Unmarshal([]byte(positionals[1]), &value); err != nil {
// A bare word is a string: `config set theme dark` means "dark".
value = positionals[1]
}
if value == nil {
return errors.New("config set takes a value; to remove one, config clear")
}
case sub == "clear" && len(positionals) == 1:
key = positionals[0]
default:
return errors.New("config set <key> <value> [--node <node> [--module <module>]] --why <text>, or " +
"config clear <key> [--node <node> [--module <module>]] --why <text>")
}
if *node != "" {
if _, err := inv.NodeByName(ctx, *node); err != nil {
return err
}
}
where := configWhere(*node, *module)
// The module's own value on one node: a preference it declares, of its type, judged as `settings set`
// judges a layer, its trusted keys refused through a verb exactly as there (novox/hq issue 339, 340).
if *module != "" {
shelf, err := inv.Catalogue(ctx)
if err != nil {
return err
}
m, known := shelf[*module]
if !known {
return fmt.Errorf("%w: %s", inventory.ErrNoSuchModule, *module)
}
p, declared := catalogue.PreferencesOf(m)[key]
if !declared {
return fmt.Errorf("config: %s declares no preference %q; config sets a module's preferences (%s) — "+
"any other setting is the settings verb's. Nothing was changed", *module, key,
preferencesOrNone(sortedPreferenceKeys(m)))
}
if value != nil {
if err := catalogue.ConfigValueProblem(fmt.Sprintf("%s's preference %q", *module, key), p.Type, value); err != nil {
return err
}
}
before, _, err := inv.Layer(ctx, *node, *module)
if err != nil {
return err
}
after := map[string]any{}
for k, v := range before {
after[k] = v
}
if value == nil {
delete(after, key)
} else {
after[key] = value
}
if err := refuseTerminalSettingsThroughAVerb(ctx, inv, before, after, *module, *node); err != nil {
return err
}
acts.record(ctx, "config", configActArgs(sub, positionals, *node, *module))
was, had, err := inv.SetModuleValue(ctx, *node, *module, key, value, setByWords()+": "+why)
if err != nil {
return err
}
sayConfigChange(key, where, value, was, had)
fmt.Printf(" takes effect at the next push: run `plan %s --diff` to see it, `push %s` to send it\n", *node, *node)
return nil
}
// Mesh config or node config: a predefined key only, judged before the act is recorded.
if value != nil {
if err := inv.JudgeConfig(ctx, key, *node, value); err != nil {
return err
}
}
acts.record(ctx, "config", configActArgs(sub, positionals, *node, ""))
before, had := configValueOf(ctx, inv, key, *node)
if value == nil {
removed, err := inv.ClearConfig(ctx, key, *node, setByWords(), why)
if err != nil {
return err
}
if !removed {
fmt.Printf("%s, %s: no value was set; nothing changed\n", key, where)
return nil
}
} else if err := inv.SetConfig(ctx, key, *node, value, setByWords(), why); err != nil {
return err
}
sayConfigChange(key, where, value, before, had)
reached, err := reachedBy(ctx, inv, key, *node)
if err != nil {
return err
}
if len(reached) == 0 {
fmt.Println(" no module assigned there falls back to it yet; nothing to push")
return nil
}
fmt.Printf(" takes effect at the next push of %s: run `push %s` to send it\n", strings.Join(reached, ", "),
strings.Join(reached, " "))
return nil
}
// configList lists the predefined keys with their mesh and node config, then each module's preferences
// with the value on each node it is assigned to and where it comes from.
func configList(ctx context.Context, inv *inventory.Inventory, node, module string) error {
if node != "" {
if _, err := inv.NodeByName(ctx, node); err != nil {
return err
}
}
list, err := inv.Predefined(ctx)
if err != nil {
return err
}
values, err := inv.Config(ctx)
if err != nil {
return err
}
var b strings.Builder
if len(list) == 0 {
fmt.Fprintf(&b, "no predefined keys: the list arrives with a build of the catalogue carrying %s\n",
catalogue.PredefinedFile)
} else {
fmt.Fprintf(&b, "predefined keys (the catalogue's %s), with mesh config and node config:\n", catalogue.PredefinedFile)
}
for _, key := range list.Keys() {
k := list[key]
fmt.Fprintf(&b, " %s (%s): %s\n", key, k.Type, k.Why)
held := false
for _, v := range values {
if v.Key != key || (node != "" && v.Node != "" && v.Node != node) {
continue
}
held = true
shown, _ := json.Marshal(v.Value)
fmt.Fprintf(&b, " %s: %s — %s, because %s\n", configWhere(v.Node, ""), shown, orUnknown(v.SetBy), v.Why)
}
if !held {
b.WriteString(" no mesh or node config: each module's default\n")
}
}
for _, key := range inventory.ConfigKeysSorted(values) {
if _, on := list[key]; on {
continue
}
for _, v := range values {
if v.Key == key {
shown, _ := json.Marshal(v.Value)
fmt.Fprintf(&b, " %s is no longer a predefined key, and %s still holds %s for it: it reaches nothing; "+
"config clear %s%s\n", key, configWhere(v.Node, ""), shown, key, nodeFlag(v.Node))
}
}
}
entries, err := inv.Catalogued(ctx)
if err != nil {
return err
}
var listed []preferencesOf
for _, e := range entries {
m := e.Manifest
if len(catalogue.PreferencesOf(m)) == 0 || (module != "" && m.Module != module) {
continue
}
if node != "" && !containsString(e.On, node) {
continue
}
p := preferencesOf{Manifest: m, On: map[string][]catalogue.SettingSource{}}
for _, n := range e.On {
if node != "" && n != node {
continue
}
p.Nodes = append(p.Nodes, n)
layers, err := inv.SettingsFor(ctx, n, m.Module)
if err != nil {
return err
}
p.On[n] = catalogue.Effective(m, layers)
}
listed = append(listed, p)
}
b.WriteString("\npreferences, and on each node the value and where it comes from (default, mesh config, " +
"node config, or the module on the node):\n")
b.WriteString(describePreferences(listed))
fmt.Print(b.String())
return nil
}
// configWhere is how a config value's place is said.
func configWhere(node, module string) string {
switch {
case module != "":
return module + " on " + node
case node != "":
return catalogue.NodeConfigLayer(node)
}
return catalogue.MeshConfigLayer
}
// configValueOf is the value mesh config (node "") or a node's config holds for a key.
func configValueOf(ctx context.Context, inv *inventory.Inventory, key, node string) (any, bool) {
values, err := inv.Config(ctx)
if err != nil {
return nil, false
}
for _, v := range values {
if v.Key == key && v.Node == node {
return v.Value, true
}
}
return nil, false
}
// reachedBy is the nodes a value of mesh config (node "") or a node's config for a key reaches: those
// running a module whose preference falls back to it.
func reachedBy(ctx context.Context, inv *inventory.Inventory, key, node string) ([]string, error) {
entries, err := inv.Catalogued(ctx)
if err != nil {
return nil, err
}
nodes := map[string]bool{}
for _, e := range entries {
if !containsString(catalogue.PredefinedNamed(e.Manifest), key) {
continue
}
for _, n := range e.On {
if node == "" || n == node {
nodes[n] = true
}
}
}
out := make([]string, 0, len(nodes))
for n := range nodes {
out = append(out, n)
}
sort.Strings(out)
return out, nil
}
func sayConfigChange(key, where string, value, before any, had bool) {
was := "nothing"
if had {
w, _ := json.Marshal(before)
was = string(w)
}
if value == nil {
fmt.Printf("%s, %s: cleared (was %s); the next source down applies\n", key, where, was)
return
}
now, _ := json.Marshal(value)
fmt.Printf("%s, %s: %s (was %s); the value it replaced is kept\n", key, where, now, was)
}
func configActArgs(sub string, positionals []string, node, module string) []string {
args := append([]string{sub}, positionals...)
if node != "" {
args = append(args, "--node", node)
}
if module != "" {
args = append(args, "--module", module)
}
return args
}
func sortedPreferenceKeys(m catalogue.Manifest) []string {
declared := catalogue.PreferencesOf(m)
keys := make([]string, 0, len(declared))
for k := range declared {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}
func orUnknown(s string) string {
if strings.TrimSpace(s) == "" {
return "not kept"
}
return s
}
func preferencesOrNone(keys []string) string {
if len(keys) == 0 {
return "it declares none"
}
return strings.Join(keys, ", ")
}
-187
View File
@@ -1,187 +0,0 @@
package main
import (
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"github.com/novox/mesh-controller/internal/catalogue"
)
// The config verb's line: a listing without key, a set or clear with why, and a module value refused
// without a node (novox/hq issue 630).
func TestTheConfigVerbsLine(t *testing.T) {
for _, c := range []struct {
args map[string]any
want string
}{
{map[string]any{}, "config list"},
{map[string]any{"node": "g14", "module": "dunst"}, "config list --node g14 --module dunst"},
{map[string]any{"key": "font-size", "value": "12", "why": "bigger"}, "config set font-size 12 --why bigger"},
{map[string]any{"key": "font-size", "value": "13", "node": "g14", "why": "dense"},
"config set font-size 13 --node g14 --why dense"},
{map[string]any{"key": "font-size", "value": "14", "node": "g14", "module": "dunst", "why": "x"},
"config set font-size 14 --node g14 --module dunst --why x"},
{map[string]any{"key": "font-size", "clear": "true", "node": "g14", "why": "back"},
"config clear font-size --node g14 --why back"},
{map[string]any{"key": "font-size", "history": "true"}, "config history font-size"},
} {
argv, err := argvFor("config", c.args)
if err != nil || strings.Join(argv, " ") != c.want {
t.Errorf("%v: %v %v, want %s", c.args, argv, err, c.want)
}
}
for want, args := range map[string]map[string]any{
"no module layer for the whole mesh": {"key": "font-size", "value": "12", "module": "dunst", "why": "x"},
"why": {"key": "font-size", "value": "12"},
"names its key": {"value": "12"},
"not both": {"key": "font-size", "value": "12", "clear": "true", "why": "x"},
} {
if _, err := argvFor("config", args); err == nil || !strings.Contains(err.Error(), want) {
t.Errorf("%v: %v, want %q", args, err, want)
}
}
}
// End to end through the verb, as the operator's acceptance reads it: cleared → the default; mesh font-size,
// then the node's, then dunst's own on the node — each in the node's dunstrc as composed (novox/hq issue 630).
func TestConfigReachesTheComposedFileFromEachSource(t *testing.T) {
open := aMesh(t)
ctx := t.Context()
if err := open.inventory.SetPredefined(ctx, catalogue.Predefined{
"font-size": {Type: catalogue.TypeNumber, Why: "the size text is drawn at"}}, "", ""); err != nil {
t.Fatal(err)
}
register(t, open, catalogue.Manifest{Module: "dunst", Version: "1",
Preferences: map[string]catalogue.Preference{
"font-size": {Type: catalogue.TypeNumber, Default: float64(10), Predefined: "font-size", Why: "100 DPI"},
},
Resources: []map[string]any{{"id": "dunstrc", "type": "file", "path": "/etc/dunst/dunstrc", "mode": "0644", "trusted": false,
"content": "font = Inter ${setting:font-size}\n"}}})
for _, n := range []string{"laptop", "anchor"} {
if _, err := assign(ctx, open, n, "dunst"); err != nil {
t.Fatal(err)
}
}
dunstrc := func(node string) string {
t.Helper()
gens, err := generators(ctx, open)
if err != nil {
t.Fatal(err)
}
declared, problems, err := composedAndValidated(ctx, open, node, gens, Allocating)
if err != nil {
t.Fatalf("%s does not compose: %v %v", node, err, problems)
}
for _, r := range declared.Resources {
if r["path"] == "/etc/dunst/dunstrc" {
return fmt.Sprint(r["content"])
}
}
t.Fatalf("%s has no dunstrc: %v", node, problems)
return ""
}
want := func(node, size string) {
t.Helper()
if got := dunstrc(node); got != "font = Inter "+size+"\n" {
t.Fatalf("%s's dunstrc: %q, want size %s", node, got, size)
}
}
verb := func(args map[string]any) error {
t.Helper()
return throughVerb(t, "config", args)
}
want("laptop", "10")
if err := verb(map[string]any{"key": "font-size", "value": "11", "why": "a larger default"}); err != nil {
t.Fatal(err)
}
want("laptop", "11")
want("anchor", "11")
if err := verb(map[string]any{"key": "font-size", "value": "13", "node": "laptop", "why": "a dense screen"}); err != nil {
t.Fatal(err)
}
want("laptop", "13")
want("anchor", "11")
if err := verb(map[string]any{"key": "font-size", "value": "15", "node": "laptop", "module": "dunst",
"why": "dunst alone larger"}); err != nil {
t.Fatal(err)
}
want("laptop", "15")
listing := stdoutOf(t, func() error { return verb(map[string]any{}) })
for _, said := range []string{"font-size (number): the size text is drawn at", "mesh config: 11",
"node config (laptop): 13", "laptop: 15 (dunst on laptop)", "anchor: 11 (mesh config)",
"falls back to the predefined key font-size"} {
if !strings.Contains(listing, said) {
t.Errorf("the listing does not say %q:\n%s", said, listing)
}
}
// Cleared, each falls back to the next source down.
for _, step := range []struct {
args map[string]any
size string
}{
{map[string]any{"key": "font-size", "clear": "true", "node": "laptop", "module": "dunst", "why": "x"}, "13"},
{map[string]any{"key": "font-size", "clear": "true", "node": "laptop", "why": "x"}, "11"},
{map[string]any{"key": "font-size", "clear": "true", "why": "x"}, "10"},
} {
if err := verb(step.args); err != nil {
t.Fatal(err)
}
want("laptop", step.size)
}
// Refused: a key not on the list for mesh config, a module value without a node, a type not the key's.
if err := verb(map[string]any{"key": "colour", "value": "blue", "why": "x"}); err == nil ||
!strings.Contains(err.Error(), "predefined") {
t.Fatalf("mesh config for a key not on the list: %v", err)
}
if err := runLine(t, []string{"config", "set", "font-size", "12", "--module", "dunst", "--why", "x"}); err == nil ||
!strings.Contains(err.Error(), "no module layer for the whole mesh") {
t.Fatalf("a module value without a node: %v", err)
}
if err := verb(map[string]any{"key": "font-size", "value": `"large"`, "node": "laptop", "module": "dunst",
"why": "x"}); err == nil || !strings.Contains(err.Error(), "is a number") {
t.Fatalf("a string for a number: %v", err)
}
if err := verb(map[string]any{"key": "nothing", "value": "1", "node": "laptop", "module": "dunst",
"why": "x"}); err == nil || !strings.Contains(err.Error(), "declares no preference") {
t.Fatalf("a module value for a key it does not declare: %v", err)
}
}
// The module check reads the catalogue's predefined.json at the repository's root and refuses a preference
// falling back to a key not on it (novox/hq issue 630).
func TestTheModuleCheckJudgesPreferencesAgainstTheCataloguesList(t *testing.T) {
root := t.TempDir()
for _, dir := range []string{".git", "modules/dunst"} {
if err := os.MkdirAll(filepath.Join(root, dir), 0o755); err != nil {
t.Fatal(err)
}
}
manifest := filepath.Join(root, "modules/dunst/module.json")
write := func(path, body string) {
t.Helper()
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
write(manifest, `{"module": "dunst", "version": "1",
"preferences": {"font-size": {"type": "number", "default": 10, "predefined": "font-size", "why": "100 DPI"}},
"resources": [{"id": "rc", "type": "file", "path": "/etc/rc", "mode": "0644", "trusted": false,
"content": "font ${setting:font-size}"}]}`)
write(filepath.Join(root, "predefined.json"), `{"theme": {"type": "string", "why": "light or dark"}}`)
var out strings.Builder
if err := moduleCheck([]string{manifest}, &out); err == nil || !strings.Contains(out.String(), "not on the catalogue's list") {
t.Fatalf("a key not on the list passed: %v\n%s", err, out.String())
}
write(filepath.Join(root, "predefined.json"), `{"font-size": {"type": "number", "why": "the size text is drawn at"}}`)
out.Reset()
if err := moduleCheck([]string{manifest}, &out); err != nil {
t.Fatalf("a key on the list was refused: %v\n%s", err, out.String())
}
}
-38
View File
@@ -263,37 +263,6 @@ func gatherFacts(ctx context.Context, open *stores, busVersion string) (snapshot
f.Controller.Commit = c.Commit
}
// The list of predefined keys, and mesh and node config (novox/hq issue 630).
predefined, err := inv.Predefined(ctx)
if err != nil {
return snapshot.Facts{}, err
}
for key, k := range predefined {
if f.Predefined == nil {
f.Predefined = map[string]snapshot.PredefinedKey{}
}
f.Predefined[key] = snapshot.PredefinedKey{Type: k.Type, Why: k.Why}
}
config, err := inv.Config(ctx)
if err != nil {
return snapshot.Facts{}, err
}
nodeConfig := map[string]map[string]any{}
meshConfig := map[string]any{}
for _, c := range config {
if c.Node == "" {
meshConfig[c.Key] = c.Value
continue
}
if nodeConfig[c.Node] == nil {
nodeConfig[c.Node] = map[string]any{}
}
nodeConfig[c.Node][c.Key] = c.Value
}
if len(meshConfig) > 0 {
f.Config = scrub.Values(meshConfig)
}
gens, gensErr := generators(ctx, open)
hostShelf := shelf[hostModule]
meshWide := map[string]bool{}
@@ -311,9 +280,6 @@ func gatherFacts(ctx context.Context, open *stores, busVersion string) (snapshot
if n.HostVersion != "" {
engines[n.HostVersion] = true
}
if values := nodeConfig[n.Name]; len(values) > 0 {
m.Config = scrub.Values(values)
}
m.System = systemOf(hostShelf, n.HostVersion)
m.Libc = libcOf(m.System)
reported, err := inv.DescribedOf(ctx, n.Name)
@@ -379,10 +345,6 @@ func gatherFacts(ctx context.Context, open *stores, busVersion string) (snapshot
return snapshot.Facts{}, err
}
for _, layer := range layers {
if layer.Config {
// Mesh and node config are said once, beside the machine and the mesh (novox/hq issue 630).
continue
}
if layer.From == catalogue.MeshWideLayer {
if !meshWide[module] {
meshWide[module] = true
+1 -1
View File
@@ -31,7 +31,7 @@ func aMeshWithSecrets(t *testing.T) (*stores, []string) {
}
}
secrets := []string{"Hunter2-Is-Not-A-Password-9f8e7d", "0123456789abcdefABCDEF0123456789zz"}
if err := open.inventory.KeepSettings(ctx, "", "files", map[string]any{
if err := open.inventory.SetSettings(ctx, "", "files", map[string]any{
"admin_password": secrets[0], "upstream": "10.77.0.9", "hub": "anchor"}); err != nil {
t.Fatal(err)
}
-2
View File
@@ -89,8 +89,6 @@ var handActVerbs = []handActVerb{
{Verb: handActIssued, Decision: "a credential the provider's software issued, kept for its consumers (ADR 0305)"},
{Verb: "hand-act drill", Decision: "a drill is a person's deliberate test of the mesh, never a repair"},
// A person's decisions by design.
// A config value is what a person chooses the mesh's modules use (novox/hq issue 630), never a repair.
{Verb: "config", Decision: "a config value is a person's choice of what the modules use, not a repair (issue 630)"},
{Verb: "retire approve", Decision: "nothing is retired past its bound without a person (ADR 0230)"},
{Verb: "retire reject", Decision: "keeping a consumer active is a person's word (ADR 0230)"},
{Verb: "cleanup delete", Decision: "nothing retired is deleted without a person (ADR 0230)"},
+1 -1
View File
@@ -55,7 +55,7 @@ func TestARepairByHandCarriesItsReason(t *testing.T) {
want string
}{
{"push", map[string]any{"node": "anchor", "why": "stuck", "cause": "sent-not-reported"},
"push anchor --wait 0s --why stuck --cause sent-not-reported"},
"push anchor --wait 0 --why stuck --cause sent-not-reported"},
{"plans", map[string]any{"close": "plan-1", "why": "the report will not come"},
"plans close plan-1 --why the report will not come"},
{"plans", map[string]any{"retry": "plan-1"}, "plans retry plan-1"},
-145
View File
@@ -1,145 +0,0 @@
package main
import (
"reflect"
"sort"
"strings"
"testing"
"time"
"github.com/novox/mesh-controller/internal/inventory"
"github.com/novox/mesh-controller/internal/link"
)
// novox/hq issue 611: mesh-controller #293 changed one test file. Its delivery plan, made while another walk built
// build-agent and route-proxy (issue 606), read them whole and named them; at the cut their recorded build sources
// held none of its files, so its walk moved nothing and its record named no module. mesh-delivery could not tell
// "matched what runs" from "never built" and stopped the delivery, asking the operator about a change already on
// every machine. The walk now says, per module, that its build matched what runs (delivery.unchanged, ADR 0267 §5).
// The shape of #293: a test file of the controller's repository. The controller, built from the root, and the two
// modules packaging the repository each recorded a build source holding none of it: each is said unchanged, with
// why; a module of another repository is not named.
func TestAMergeSaysTheModulesWhoseBuildSourceItLeftUnchanged(t *testing.T) {
const catalogueRepo = "http://forge.internal:20000/novox/mesh-catalog.git"
const controllerRepo = "http://forge.internal:20000/novox/mesh-controller.git"
entries := []inventory.Entry{
fromRepo("mesh-controller", controllerRepo, ""),
fromRepo("build-agent", catalogueRepo, "modules/build-agent"),
fromRepo("route-proxy", catalogueRepo, "modules/route-proxy"),
fromRepo("gitea", catalogueRepo, "modules/gitea"),
}
read := map[string][]inventory.ReadRepository{
"mesh-controller": {{Own: true, Paths: []string{"go.mod", "module.json", "cmd/mesh-controller/", "internal/builder/"}}},
"build-agent": {
{Repository: "novox/mesh-controller", Ref: "main", Paths: []string{"go.mod", "cmd/mesh-builder/", "internal/builder/"}},
{Own: true, Paths: []string{"modules/build-agent/Dockerfile", "modules/build-agent/module.json"}},
},
"route-proxy": {
{Repository: "novox/mesh-controller", Ref: "main", Paths: []string{"go.mod", "examples/route-proxy/"}},
{Own: true, Paths: []string{"modules/route-proxy/Dockerfile", "modules/route-proxy/module.json"}},
},
}
m := link.SourceMoved{Owner: "novox", Repo: "mesh-controller", Base: "main", Commit: "8f1aec0",
Paths: []string{"internal/builder/replay363_test.go"}}
if moved := reachOfMerge(m, entries, read, sharedRepositoryEdges).Moved(); len(moved) != 0 {
t.Fatalf("the fixture moves %v: a test file is in no build source", moved)
}
got := unchangedOfMerge(m, entries, read)
var names []string
for n, why := range got {
names = append(names, n)
if !strings.Contains(why, "holds none of") || !strings.Contains(why, "internal/builder/replay363_test.go") ||
!strings.Contains(why, "the build the mesh holds is its build") {
t.Errorf("%s is said unchanged as %q", n, why)
}
}
if want := []string{"build-agent", "mesh-controller", "route-proxy"}; !reflect.DeepEqual(sortedOf(names), want) {
t.Fatalf("said unchanged: %v; want %v", sortedOf(names), want)
}
// A module whose build source holds a changed file is moved, never said unchanged.
touched := m
touched.Paths = []string{"internal/builder/builder.go"}
if got := unchangedOfMerge(touched, entries, read); got["mesh-controller"] != "" || got["build-agent"] != "" {
t.Errorf("a module the merge moves is said unchanged: %v", got)
}
if got := unchangedOfMerge(touched, entries, read); got["route-proxy"] == "" {
t.Errorf("the route proxy, whose build source holds none of it, is not said unchanged: %v", got)
}
// With no build source recorded, the module is read whole and moved: nothing says it unchanged.
unsaid := map[string][]inventory.ReadRepository{"build-agent": {{Repository: "novox/mesh-controller", Ref: "main"}}}
if got := unchangedOfMerge(m, entries, unsaid); len(got) != 0 {
t.Errorf("modules with no recorded build source are said unchanged: %v", got)
}
// Files not all said: every module is moved, none said unchanged.
truncated := m
truncated.PathsTruncated = true
if got := unchangedOfMerge(truncated, entries, read); len(got) != 0 {
t.Errorf("a merge whose files were not all said says %v unchanged", got)
}
}
// A module the walk builds anyway — another merge's, or a dependent — is not said unchanged: the modules map keeps
// meaning what the walk builds, and the two never name one module.
func TestAWalkSaysUnchangedOnlyWhatItDoesNotBuild(t *testing.T) {
unchanged := map[string]string{"build-agent": "why a", "route-proxy": "why b"}
got := unchangedLeft(unchanged, map[string]*inventory.PlanModule{"build-agent": {}})
if !reflect.DeepEqual(got, map[string]string{"route-proxy": "why b"}) {
t.Fatalf("left %v", got)
}
if got := unchangedLeft(map[string]string{"x": "y"}, map[string]*inventory.PlanModule{"x": {}}); got != nil {
t.Fatalf("left %v; want none", got)
}
}
// Against the store, the path a real merge takes: a merge whose module recorded a build source that holds none
// of its files is cut into a walk done with nothing to walk, whose record names the module unchanged and why.
func TestAWalkThatMovedNothingSaysWhichModulesMatchedWhatRuns(t *testing.T) {
open := windowed(t)
endsItsLine(t)
asksRecorded(t)
ctx := t.Context()
inv := open.inventory
if err := inv.RecordBuild(ctx, inventory.Build{ID: "build-one", Repository: "novox/one", Ref: "main",
Module: "one", Commit: "c0", On: "builder", Asked: t0.Add(-time.Hour),
Sources: []inventory.BuildSource{{Paths: []string{"main.go", "go.mod"}}}}); err != nil {
t.Fatal(err)
}
readme := repoMerge("one", "one1aaaaaaaa", t0)
readme.Paths = []string{"README.md"}
hear(t, open, readme, t0.Add(time.Second))
cutAt(t, open, t0.Add(2*time.Minute))
ws, _ := walks(t, open)
if len(ws) != 1 {
t.Fatalf("walks: %+v", ws)
}
w := ws[0]
if w.State != inventory.PlanDone || len(w.Tiers) != 0 || len(w.Modules) != 0 {
t.Fatalf("the walk reads %s, tiers %v, modules %v; want done with nothing to walk", w.State, w.Tiers, w.Modules)
}
if w.Delivery == nil || !strings.Contains(w.Delivery.Unchanged["one"], "holds none of the 1 changed file(s)") {
t.Fatalf("the walk does not say one matched what runs: %+v", w.Delivery)
}
if !strings.Contains(w.Note, "unchanged") || !strings.Contains(w.Note, "one") {
t.Errorf("the walk's note does not name it: %q", w.Note)
}
// A merge that changes the build source is walked, and says nothing unchanged.
code := repoMerge("one", "one2bbbbbbbb", t0.Add(3*time.Minute))
hear(t, open, code, t0.Add(3*time.Minute+time.Second))
cutAt(t, open, t0.Add(6*time.Minute))
ws, _ = walks(t, open)
if len(ws) != 2 {
t.Fatalf("walks: %+v", ws)
}
if _, built := ws[0].Modules["one"]; !built || ws[0].Delivery == nil || len(ws[0].Delivery.Unchanged) != 0 {
t.Fatalf("the walk of a change to main.go reads modules %v, delivery %+v", ws[0].Modules, ws[0].Delivery)
}
}
func sortedOf(names []string) []string {
out := append([]string{}, names...)
sort.Strings(out)
return out
}
-67
View File
@@ -1,67 +0,0 @@
package main
import (
"context"
"fmt"
"github.com/novox/mesh-controller/internal/catalogue"
"github.com/novox/mesh-controller/internal/inventory"
"github.com/novox/mesh-controller/internal/overlay"
)
// localNetworksOf is a machine's attached networks judged (novox/hq ADR 0340): which count as its local
// network, from what it last said of itself and the links it said face outside it. A machine that has
// said none judges to nothing, and a port reached from `local-network` is then admitted from the private
// network only, and said so.
func localNetworksOf(ctx context.Context, inv *inventory.Inventory, node string) ([]catalogue.LocalNetwork, error) {
account, had, err := inv.MachineOf(ctx, node)
if err != nil {
return nil, err
}
outward, err := inv.OutwardLinksOf(ctx, node)
if err != nil {
return nil, err
}
return judgedNetworks(account, had, outward, overlay.Interface), nil
}
// judgedNetworks is the judging alone, from what was read.
func judgedNetworks(account inventory.MachineAccount, had bool, outward []string, tunnel string) []catalogue.LocalNetwork {
if !had || account.Machine == nil {
return nil
}
attached := make([]catalogue.AttachedNetwork, 0, len(account.Machine.Networks))
for _, n := range account.Machine.Networks {
attached = append(attached, catalogue.AttachedNetwork{Link: n.Link, Network: n.Network})
}
return catalogue.JudgeLocalNetworks(attached, outward, tunnel)
}
// localNetworkLines is what `node` shows of a machine's attached networks (ADR 0340 §8): each one, with
// whether it counts as the local network and why, or why none is shown.
func localNetworkLines(account inventory.MachineAccount, had bool, judged []catalogue.LocalNetwork) []string {
out := []string{" its local network (what a port reached from local-network is admitted from):"}
switch {
case !had || account.Machine == nil:
return append(out, " not said — this machine has not reported what it is attached to")
case len(account.Machine.Networks) == 0:
why := account.Machine.Unknown["networks"]
if why == "" {
why = "its node-engine is older than the one that says it"
}
return append(out, " not said — "+why)
}
counted := 0
for _, j := range judged {
verdict := "not counted"
if j.Counts {
verdict = "counts"
counted++
}
out = append(out, fmt.Sprintf(" %-22s on %-10s %s: %s", j.Network, j.Link, verdict, j.Why))
}
if counted == 0 {
out = append(out, " none counts: a port reached from local-network is admitted from the private network only")
}
return out
}
-65
View File
@@ -1,65 +0,0 @@
package main
import (
"strings"
"testing"
"github.com/novox/mesh-controller/internal/catalogue"
"github.com/novox/mesh-controller/internal/inventory"
)
// **node shows what the local network resolved to** (novox/hq ADR 0340 §8): every attached network,
// whether it counts, and why the others do not — the public subnet named as never counted.
func TestNodeShowsWhichAttachedNetworksCountAndWhy(t *testing.T) {
account := inventory.MachineAccount{Architecture: "amd64", Kernel: "linux", Machine: &inventory.MadeOf{
Networks: []inventory.AttachedNetwork{
{Link: "eth0", Network: "192.168.1.10/24"},
{Link: "eth1", Network: "203.0.113.7/24"},
{Link: "wg-mesh", Network: "10.10.0.4/24"},
}}}
judged := judgedNetworks(account, true, []string{"eth0", "eth1"}, "wg-mesh")
text := strings.Join(localNetworkLines(account, true, judged), "\n")
for _, want := range []string{
"192.168.1.0/24", "counts: in a private range (RFC 1918, 192.168.0.0/16)",
"203.0.113.0/24", "not counted: public, never counted",
"10.10.0.0/24", "the private network's own link",
} {
if !strings.Contains(text, want) {
t.Errorf("node does not say %q:\n%s", want, text)
}
}
}
// A machine that said none, or whose node-engine is older, is said as that — never as an empty list
// that reads like a machine on no network.
func TestNodeSaysWhenNoNetworkWasSaid(t *testing.T) {
older := inventory.MachineAccount{Machine: &inventory.MadeOf{}}
if text := strings.Join(localNetworkLines(older, true, nil), "\n"); !strings.Contains(text, "older than the one that says it") {
t.Errorf("an older node-engine is not said:\n%s", text)
}
public := inventory.MachineAccount{Machine: &inventory.MadeOf{
Networks: []inventory.AttachedNetwork{{Link: "eth0", Network: "198.51.100.9/24"}}}}
judged := judgedNetworks(public, true, []string{"eth0"}, "wg-mesh")
if text := strings.Join(localNetworkLines(public, true, judged), "\n"); !strings.Contains(text, "none counts") {
t.Errorf("a machine on a public network only is not said to have no local network:\n%s", text)
}
if text := strings.Join(localNetworkLines(inventory.MachineAccount{}, false, nil), "\n"); !strings.Contains(text, "not said") {
t.Errorf("a machine that never reported is not said:\n%s", text)
}
}
// The preview of a flip says what a port from the local network keeps: the local networks it stays
// open to, or that the machine reported none (ADR 0340 §6).
func TestTheFlipPreviewSaysWhatALocalNetworkPortKeeps(t *testing.T) {
rules := []catalogue.Rule{{Port: 1883, Protocol: "tcp", From: catalogue.FromLocalNetwork, Because: []string{"mosquitto"}}}
at := inventory.Reach{Protocol: "tcp", Address: "0.0.0.0", Port: 1883}
with := derivedFilter{rules: rules, mesh: []string{"10.10.0.1"}, outwardLinks: []string{"eth0"},
local: []string{"192.168.1.0/24"}}
if fate := with.fate(at); !strings.Contains(fate, "192.168.1.0/24") {
t.Errorf("the preview says %q, without the local network it stays open to", fate)
}
with.local = nil
if fate := with.fate(at); !strings.Contains(fate, "reported none") {
t.Errorf("the preview says %q, without saying no local network was reported", fate)
}
}
-2
View File
@@ -171,8 +171,6 @@ func run() error {
return adoptCommand(ctx, args[1:])
case "settings":
return settingsCommand(ctx, args[1:])
case "config":
return configCommand(ctx, args[1:])
case "secret":
return secretCommand(ctx, args[1:])
case "operator":
+5 -60
View File
@@ -296,24 +296,6 @@ func judgeChange(ctx context.Context, in mergeCheckInput) (mergeVerdict, error)
}
v.Failures = append(v.Failures, failures...)
}
// The list of predefined keys a head's catalogue tree carries, which the change composes with
// (novox/hq issue 630); the mesh's own list otherwise.
var changedList catalogue.Predefined
for _, h := range heads {
if h.Tree == "" {
continue
}
raw, err := os.ReadFile(filepath.Join(h.Tree, catalogue.PredefinedFile))
if err != nil {
continue
}
list, err := catalogue.ParsePredefined(raw)
if err != nil {
v.Failures = append(v.Failures, err.Error())
continue
}
changedList = list
}
if len(in.group) > 0 {
say("a delivery group: %d head(s) composed together", len(heads))
}
@@ -337,7 +319,7 @@ func judgeChange(ctx context.Context, in mergeCheckInput) (mergeVerdict, error)
// Both compositions, each in a store of its own.
say("composing every machine as the mesh is")
before, _, notes, err := composeEveryMachine(ctx, in, base, sources, nil, nil)
before, _, notes, err := composeEveryMachine(ctx, in, base, sources, nil)
if err != nil {
return v, fmt.Errorf("the mesh as the snapshot says it is could not be raised: %w", err)
}
@@ -350,7 +332,7 @@ func judgeChange(ctx context.Context, in mergeCheckInput) (mergeVerdict, error)
}
}
sort.Strings(added)
after, tried, notes, err := composeEveryMachine(ctx, in, change, sources, added, changedList)
after, tried, notes, err := composeEveryMachine(ctx, in, change, sources, added)
if err != nil {
return v, fmt.Errorf("the mesh with the change could not be raised: %w", err)
}
@@ -766,13 +748,13 @@ func standInBuild(m catalogue.Manifest) (catalogue.Manifest, error) {
// grant to compose — and answers each machine's composition. Notes are what of the snapshot could not
// be raised: a refusal to register a module, keep a setting or a pin.
func composeEveryMachine(ctx context.Context, in mergeCheckInput, shelf map[string]catalogue.Manifest,
sources map[string]inventory.Source, trials []string, list catalogue.Predefined) (map[string]mergeComposed, map[string]mergeTrial, []string, error) {
sources map[string]inventory.Source, trials []string) (map[string]mergeComposed, map[string]mergeTrial, []string, error) {
open, drop, err := throwawayStores(ctx, in.admin)
if err != nil {
return nil, nil, nil, err
}
defer drop()
notes, err := raiseFromFacts(ctx, open, in.facts, shelf, sources, list)
notes, err := raiseFromFacts(ctx, open, in.facts, shelf, sources)
if err != nil {
return nil, nil, notes, err
}
@@ -968,22 +950,9 @@ func throwawayStores(ctx context.Context, admin string) (*stores, func(), error)
// for each standing in for the one it holds, its assignments, the seats it holds, its pins and its
// settings, and a stand-in for every secret a person gave.
func raiseFromFacts(ctx context.Context, open *stores, f snapshot.Facts, shelf map[string]catalogue.Manifest,
sources map[string]inventory.Source, list catalogue.Predefined) ([]string, error) {
sources map[string]inventory.Source) ([]string, error) {
inv := open.inventory
var notes []string
// The list of predefined keys before any module, which registration judges its preferences against
// (novox/hq issue 630): the change's own when its catalogue tree carries one, the mesh's otherwise.
if list == nil && len(f.Predefined) > 0 {
list = catalogue.Predefined{}
for key, k := range f.Predefined {
list[key] = catalogue.PredefinedKey{Type: k.Type, Why: k.Why}
}
}
if len(list) > 0 {
if err := inv.SetPredefined(ctx, list, "", ""); err != nil {
notes = append(notes, fmt.Sprintf("the list of predefined keys is not kept: %s", oneLine(err.Error())))
}
}
names := make([]string, 0, len(shelf))
for name := range shelf {
names = append(names, name)
@@ -1076,20 +1045,6 @@ func raiseFromFacts(ctx context.Context, open *stores, f snapshot.Facts, shelf m
}
}
}
// Mesh and node config, as the mesh holds them (novox/hq issue 630).
for _, key := range sortedAnyKeys(f.Config) {
if err := inv.SetConfig(ctx, key, "", f.Config[key], "the facts snapshot", "raised from the facts snapshot"); err != nil {
notes = append(notes, fmt.Sprintf("the mesh config of %s is not kept: %s", key, oneLine(err.Error())))
}
}
for _, m := range f.Machines {
for _, key := range sortedAnyKeys(m.Config) {
if err := inv.SetConfig(ctx, key, m.Name, m.Config[key], "the facts snapshot", "raised from the facts snapshot"); err != nil {
notes = append(notes, fmt.Sprintf("%s's node config of %s is not kept: %s", m.Described(), key,
oneLine(err.Error())))
}
}
}
for _, s := range f.Settings {
if err := inv.KeepSettings(ctx, "", s.Module, standInPaths(s.Values)); err != nil {
notes = append(notes, fmt.Sprintf("the mesh's settings of %s are not kept: %s", s.Module, oneLine(err.Error())))
@@ -1357,13 +1312,3 @@ func firstOr(items, otherwise []string) string {
}
return "it says nothing more"
}
// sortedAnyKeys is a map's keys, sorted.
func sortedAnyKeys(in map[string]any) []string {
keys := make([]string, 0, len(in))
for k := range in {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}
+1 -1
View File
@@ -75,7 +75,7 @@ func stateHealth(ctx context.Context, inv *inventory.Inventory, k *conditions.Ke
State: r.State, Reason: r.Reason, Since: r.Since, Streak: r.Streak, Restarts: r.Restarts,
Check: r.Check, Needs: r.Needs, Account: r.Account, Root: r.Root}
for _, w := range r.Waits {
kept.Waits = append(kept.Waits, inventory.Wait{Part: w.Part, Secret: w.Secret, Setting: w.Setting, Provision: w.Provision, What: w.What})
kept.Waits = append(kept.Waits, inventory.Wait{Part: w.Part, Secret: w.Secret, Setting: w.Setting, What: w.What})
}
resources = append(resources, kept)
}
+20 -31
View File
@@ -571,14 +571,18 @@ func settingsCommand(ctx context.Context, args []string) error {
return err
}
m, ok := known[positionals[0]]
if !ok || len(catalogue.PreferencesOf(m)) == 0 {
if !ok || len(m.Settings) == 0 {
return nil
}
var layers []catalogue.Layer
if *node != "" {
// Every source on the node: mesh config, node config, then the module's own (novox/hq issue 630).
if layers, err = inv.SettingsFor(ctx, *node, positionals[0]); err != nil {
if mesh, has, err := inv.Layer(ctx, "", positionals[0]); err != nil {
return err
} else if has {
layers = append(layers, catalogue.Layer{From: catalogue.MeshWideLayer, Values: mesh})
}
if has {
layers = append(layers, catalogue.Layer{From: *node, Values: values})
}
} else if has {
layers = append(layers, catalogue.Layer{From: catalogue.MeshWideLayer, Values: values})
@@ -608,7 +612,7 @@ func settingsCommand(ctx context.Context, args []string) error {
var listed []preferencesOf
for _, e := range entries {
m := e.Manifest
if len(catalogue.PreferencesOf(m)) == 0 || (only != "" && m.Module != only) {
if len(m.Settings) == 0 || (only != "" && m.Module != only) {
continue
}
if *node != "" && !containsString(e.On, *node) {
@@ -678,11 +682,7 @@ func describeEffective(module, where string, values []catalogue.SettingSource) s
fmt.Fprintf(&b, "%s on %s, every value and where it comes from:\n", module, where)
for _, v := range values {
value, _ := json.Marshal(v.Value)
from := v.From
if v.From == where && where != "the whole mesh" {
from = valueSource(module, where, v)
}
fmt.Fprintf(&b, " %s = %s (%s", v.Key, value, from)
fmt.Fprintf(&b, " %s = %s (%s", v.Key, value, v.From)
if v.HasDefault && !v.FromDefault {
d, _ := json.Marshal(v.Default)
fmt.Fprintf(&b, "; the default is %s", d)
@@ -723,27 +723,28 @@ func describePreferences(modules []preferencesOf) string {
on = "on " + strings.Join(p.Nodes, ", ")
}
fmt.Fprintf(&b, "%s (%s)\n", p.Manifest.Module, on)
declared := catalogue.PreferencesOf(p.Manifest)
keys := make([]string, 0, len(declared))
for k := range declared {
keys := make([]string, 0, len(p.Manifest.Settings))
for k := range p.Manifest.Settings {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
d := declared[k]
d := p.Manifest.Settings[k]
def, _ := json.Marshal(d.Default)
falls := ""
if d.Predefined != "" {
falls = ", falls back to the predefined key " + d.Predefined
}
fmt.Fprintf(&b, " %s, default %s%s: %s\n", k, def, falls, d.Why)
fmt.Fprintf(&b, " %s, default %s: %s\n", k, def, d.Why)
for _, n := range p.Nodes {
for _, s := range p.On[n] {
if s.Key != k {
continue
}
v, _ := json.Marshal(s.Value)
fmt.Fprintf(&b, " %s: %s (%s)\n", n, v, valueSource(p.Manifest.Module, n, s))
from := s.From
if s.FromDefault {
from = catalogue.DefaultLayer
} else if s.From != catalogue.MeshWideLayer {
from = "the node"
}
fmt.Fprintf(&b, " %s: %s (%s)\n", n, v, from)
}
}
}
@@ -751,18 +752,6 @@ func describePreferences(modules []preferencesOf) string {
return b.String()
}
// valueSource is where a value on a node comes from, as the four sources are said (novox/hq issue 630):
// "default", "mesh config", "node config (<node>)", or "<module> on <node>" for the module's own value.
func valueSource(module, node string, s catalogue.SettingSource) string {
switch {
case s.FromDefault:
return catalogue.DefaultLayer
case s.From == catalogue.MeshConfigLayer, s.From == catalogue.NodeConfigLayer(node), s.From == catalogue.MeshWideLayer:
return s.From
}
return module + " on " + node
}
// nodeFlag is ` --node <node>` for a machine's layer, nothing for the whole mesh's.
func nodeFlag(node string) string {
if node == "" {
-11
View File
@@ -19,7 +19,6 @@ import (
"github.com/novox/mesh-controller/internal/conditions"
"github.com/novox/mesh-controller/internal/inventory"
"github.com/novox/mesh-controller/internal/link"
"github.com/novox/mesh-controller/internal/overlay"
"github.com/novox/mesh-controller/internal/token"
)
@@ -885,16 +884,6 @@ func showNode(ctx context.Context, stored *stores, name string) error {
for _, line := range machineLines(account, had, time.Now()) {
fmt.Println(line)
}
// And which of the networks it is attached to count as its local network, and why the others
// do not (novox/hq ADR 0340 §8).
outward, err := inv.OutwardLinksOf(ctx, name)
if err != nil {
fmt.Printf(" its local network could NOT be judged: %v\n", err)
} else {
for _, line := range localNetworkLines(account, had, judgedNetworks(account, had, outward, overlay.Interface)) {
fmt.Println(line)
}
}
}
held, err := inv.Profile(ctx, name)
+15 -140
View File
@@ -3,7 +3,6 @@ package main
import (
"context"
"fmt"
"slices"
"sort"
"strings"
"time"
@@ -45,28 +44,6 @@ func needsOperatorKey(module, node string) string {
type operatorWaitFacts struct {
manifests map[string]catalogue.Manifest
given map[string]map[string]time.Time
// outside is, per provision some module offers with a credential issued outside the mesh, that provider module
// and the machines it is assigned to (novox/hq ADR 0337); pairsGiven, per "<module>@<machine>", the provisions
// it holds a value a person gave for, sealed to the keys both ends have now. Absent is not known: no wait for a
// provision of it is excused.
outside map[string]outsideProvider
pairsGiven map[string]map[string]bool
}
// outsideProvider is the module offering a provision whose credential only a person gives, and where it runs.
type outsideProvider struct {
Module string
Machines []string
}
// acceptLine is the one line that gives an outside provision's credential for every consumer of its provider: on
// the provider's machine when it runs on one, named as <machine> when on several or none.
func (p outsideProvider) acceptLine(provision string) string {
machine := "<its machine>"
if len(p.Machines) == 1 {
machine = p.Machines[0]
}
return inventory.AcceptLine(machine, p.Module, provision)
}
// checkWait is nil when a wait is excused, and otherwise why not, in words. Pure.
@@ -75,20 +52,11 @@ func checkWait(module, machine string, w inventory.Wait, f operatorWaitFacts) er
if !known {
return fmt.Errorf("says it waits for %s, and the mesh holds no manifest of %s to check it against", waitNames(w), module)
}
named := 0
for _, n := range []string{w.Secret, w.Setting, w.Provision} {
if n != "" {
named++
}
}
switch {
case named != 1:
return fmt.Errorf("says it waits, naming %s, where a wait names one secret, one setting or one provision",
waitNames(w))
case w.Provision != "":
return checkProvisionWait(module, machine, w, m, f)
case w.Secret != "" && w.Setting != "", w.Secret == "" && w.Setting == "":
return fmt.Errorf("says it waits, naming %s, where a wait names one secret or one setting", waitNames(w))
case w.Setting != "":
if !catalogue.DeclaresPreference(m, w.Setting) {
if _, declared := m.Settings[w.Setting]; !declared {
return fmt.Errorf("says it waits for the setting %s, which %s does not declare", w.Setting, module)
}
return nil
@@ -112,46 +80,20 @@ func checkWait(module, machine string, w inventory.Wait, f operatorWaitFacts) er
return nil
}
// checkProvisionWait excuses a wait for a provision's credential only when the store bears it out (novox/hq ADR 0337
// §6): the module requires the provision, a provider offers it with a credential issued outside the mesh, and the
// module on this machine holds no value a person gave for it under the keys both ends have now.
func checkProvisionWait(module, machine string, w inventory.Wait, m catalogue.Manifest, f operatorWaitFacts) error {
if !slices.Contains(m.Requires, w.Provision) {
return fmt.Errorf("says it waits for the provision %s, which %s does not require", w.Provision, module)
}
if _, outside := f.outside[w.Provision]; !outside {
return fmt.Errorf("says it waits for the provision %s, whose credential the mesh gives itself: only a "+
"credential issued outside the mesh waits for the operator", w.Provision)
}
given, readable := f.pairsGiven[module+"@"+machine]
if !readable {
return fmt.Errorf("says it waits for the provision %s, and what was given on %s could not be read", w.Provision, machine)
}
if given[w.Provision] {
return fmt.Errorf("says it waits for the provision %s, whose credential was given", w.Provision)
}
return nil
}
// waitRefusedPrefix opens every reason checkWait gives, so the words of a refused wait are told from a check's own.
const waitRefusedPrefix = "says it waits"
// waitNames is what a wait names, as "the secret x" or "the setting y".
func waitNames(w inventory.Wait) string {
var names []string
if w.Secret != "" {
names = append(names, "the secret "+w.Secret)
switch {
case w.Secret != "" && w.Setting != "":
return "the secret " + w.Secret + " and the setting " + w.Setting
case w.Secret != "":
return "the secret " + w.Secret
case w.Setting != "":
return "the setting " + w.Setting
}
if w.Setting != "" {
names = append(names, "the setting "+w.Setting)
}
if w.Provision != "" {
names = append(names, "the provision "+w.Provision)
}
if len(names) == 0 {
return "nothing"
}
return strings.Join(names, " and ")
return "nothing"
}
// checkWaiting reads one machine's resources against the facts: every waiting resource whose waits all check out is
@@ -165,17 +107,11 @@ func checkWaiting(machine string, rs []inventory.ResourceHealth, f operatorWaitF
if len(r.Waits) == 0 {
why = fmt.Errorf("says it waits, and names nothing it waits for")
}
waits := make([]inventory.Wait, 0, len(r.Waits))
for _, w := range r.Waits {
if why == nil {
why = checkWait(r.Module, machine, w, f)
}
if p, outside := f.outside[w.Provision]; outside && w.Provision != "" {
w.Act = p.acceptLine(w.Provision)
}
waits = append(waits, w)
}
r.Waits = waits
if why != nil {
r.State, r.Reason = link.StateUnhealthy, why.Error()
}
@@ -214,10 +150,6 @@ func operatorWaitSaid(module, machine string, waits []inventory.Wait) string {
if w.Secret != "" {
part += fmt.Sprintf(", given with `nox secret ask %s %s %s`", machine, module, w.Secret)
}
if w.Provision != "" && w.Act != "" {
// One line for the provider, which seals it to every consumer of it (ADR 0337 §3, §7).
part += fmt.Sprintf(", given for every consumer with `%s`", w.Act)
}
parts = append(parts, part+")")
}
return fmt.Sprintf("%s on %s waits for the operator: %s", module, machine, strings.Join(parts, "; "))
@@ -240,7 +172,7 @@ func needsOperatorObservation(module, node string, waits []inventory.Wait, rs []
func needsOperatorWords(module, node string, waits []inventory.Wait) words {
var acts []string
seen := map[string]bool{}
secret, provision := false, false
secret := false
for _, w := range waits {
var act string
switch {
@@ -248,9 +180,6 @@ func needsOperatorWords(module, node string, waits []inventory.Wait) words {
act, secret = fmt.Sprintf("type %s at %s's desk prompt", w.What, node), true
case w.Setting != "":
act = fmt.Sprintf("approve %s of %s on %s when it is proposed to you", w.Setting, module, node)
case w.Provision != "":
// ADR 0337 §7: "give <what> of <provider> on <machine>"; the line is in the summary.
act, provision = "give "+w.What, true
}
if act != "" && !seen[act] {
seen[act] = true
@@ -268,10 +197,6 @@ func needsOperatorWords(module, node string, waits []inventory.Wait) words {
explanation += " A hidden prompt opens at the desk when the secret is asked for, and what you type there " +
"is sealed to the machine."
}
if provision {
explanation += " A key only you can give is given once for the app that issued it, and reaches every " +
"module that uses it."
}
explanation += " Its update is in place and nothing was undone; it carries on by itself once it is given."
return words{
Headline: fmt.Sprintf("%s waits for you on %s", module, node),
@@ -292,12 +217,6 @@ func readWaitFacts(ctx context.Context, inv *inventory.Inventory, machine string
if f.given == nil {
f.given = map[string]map[string]time.Time{}
}
if f.outside == nil {
f.outside = map[string]outsideProvider{}
}
if f.pairsGiven == nil {
f.pairsGiven = map[string]map[string]bool{}
}
if inv == nil {
return
}
@@ -319,59 +238,15 @@ func readWaitFacts(ctx context.Context, inv *inventory.Inventory, machine string
}
}
}
if _, read := f.given[module+"@"+machine]; !read {
if given, err := inv.GivenOwnSecrets(ctx, machine, module); err == nil {
f.given[module+"@"+machine] = given
}
}
if _, read := f.pairsGiven[module+"@"+machine]; !read {
if given, err := inv.GivenPairsOf(ctx, machine, module); err == nil {
f.pairsGiven[module+"@"+machine] = given
}
}
// The provisions it requires whose credential only a person gives, and where their provider runs (ADR 0337).
m, known := f.manifests[module]
if !known {
if _, read := f.given[module+"@"+machine]; read {
continue
}
for _, provision := range m.Requires {
if _, read := f.outside[provision]; read {
continue
}
if shelf == nil {
var err error
if shelf, err = inv.Catalogue(ctx); err != nil {
shelf = map[string]catalogue.Manifest{}
}
}
provider, outside := catalogue.OffersIssuedOutside(shelf, provision)
if !outside {
continue
}
f.outside[provision] = outsideProvider{Module: provider, Machines: machinesRunning(ctx, inv, provider)}
if given, err := inv.GivenOwnSecrets(ctx, machine, module); err == nil {
f.given[module+"@"+machine] = given
}
}
}
// machinesRunning is every machine a module is assigned to, by name; a read that fails is left out.
func machinesRunning(ctx context.Context, inv *inventory.Inventory, module string) []string {
nodes, err := inv.Nodes(ctx)
if err != nil {
// Only the summary's accept line reads it, which then names the provider's machine "<its machine>".
// empty-on-error: no wait is excused or refused by it
return nil
}
var out []string
for _, n := range nodes {
assigned, err := inv.Assigned(ctx, n.Name)
if err == nil && slices.Contains(assigned, module) {
out = append(out, n.Name)
}
}
sort.Strings(out)
return out
}
// waitingModules is every module with a waiting resource in a statement.
func waitingModules(rs []inventory.ResourceHealth) []string {
seen := map[string]bool{}
+2 -2
View File
@@ -55,8 +55,8 @@ func TestAWaitIsExcusedOnlyWhenItChecksOut(t *testing.T) {
{"a secret the mesh makes", inventory.Wait{Part: "p", Secret: "made", What: "w"}, factsGiven(nil), "mesh makes itself"},
{"the bus account", inventory.Wait{Part: "p", Secret: "broker", What: "w"}, factsGiven(nil), "mesh makes itself"},
{"a setting not declared", inventory.Wait{Part: "p", Setting: "logins", What: "w"}, factsGiven(nil), "does not declare"},
{"both", inventory.Wait{Part: "p", Secret: "licence", Setting: "smb-users", What: "w"}, factsGiven(nil), "one secret, one setting or one provision"},
{"neither", inventory.Wait{Part: "p", What: "w"}, factsGiven(nil), "one secret, one setting or one provision"},
{"both", inventory.Wait{Part: "p", Secret: "licence", Setting: "smb-users", What: "w"}, factsGiven(nil), "one secret or one setting"},
{"neither", inventory.Wait{Part: "p", What: "w"}, factsGiven(nil), "one secret or one setting"},
{"no manifest known", passwordWait, operatorWaitFacts{}, "no manifest"},
{"what was given cannot be read", passwordWait,
operatorWaitFacts{manifests: map[string]catalogue.Manifest{"mounts": mountsManifest}}, "could not be read"},
@@ -1,83 +0,0 @@
package main
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/novox/mesh-controller/internal/catalogue"
)
// One accept for a provider reaches every consumer that binds the provision from it, and each consumer's send
// places it; the per-pair form naming a consumer is refused for such a provision, naming the provider's form; a
// value the mesh made before the rule is not placed (novox/hq ADR 0337 §2, §3, §4, issue 164, issue 165). Needs the
// store (MESH_TEST_POSTGRES), and is named in testdata/store-tests.
func TestOneAcceptForAProviderIsPlacedForEveryConsumer(t *testing.T) {
open := aMesh(t)
ctx := t.Context()
// Before the rule: tv's offer said nothing of its credential, and the send made one per pair.
register(t, open, catalogue.Manifest{Module: "tv", Version: "1",
Provides: []catalogue.Offer{{Name: "tv-api", Scope: catalogue.ScopeMesh}}})
for _, m := range []string{"requests", "indexer"} {
register(t, open, catalogue.Manifest{Module: m, Version: "1", Requires: []string{"tv-api"},
Secrets: map[string]string{"tv-api": "/var/lib/mesh/" + m + "/tv-api.secret"}})
}
for _, a := range [][2]string{{"anchor", "tv"}, {"laptop", "requests"}, {"laptop", "indexer"}} {
if _, err := assign(ctx, open, a[0], a[1]); err != nil {
t.Fatal(err)
}
}
placed := func(module string) bool {
t.Helper()
plan, settings, err := planFor(ctx, open, "laptop")
if err != nil {
t.Fatalf("laptop was refused: %v", err)
}
declared, err := declarationFor(ctx, open, "laptop", plan, settings)
if err != nil {
t.Fatalf("laptop did not compose: %v", err)
}
for _, r := range declared.Resources {
if r["path"] == "/var/lib/mesh/"+module+"/tv-api.secret" && r["sealed"] != nil {
return true
}
}
return false
}
if !placed("requests") || !placed("indexer") {
t.Fatal("before the rule, the send made and placed a credential per pair")
}
// The offer now says only a person gives it: the values the mesh made are not placed.
register(t, open, catalogue.Manifest{Module: "tv", Version: "2",
Provides: []catalogue.Offer{{Name: "tv-api", Scope: catalogue.ScopeMesh,
Credential: &catalogue.OfferCredential{IssuedBy: catalogue.IssuedOutside}}}})
if placed("requests") || placed("indexer") {
t.Fatal("a credential nobody gave was placed")
}
// The per-pair form is refused, naming the provider's form, and reads nothing.
file := filepath.Join(t.TempDir(), "key")
if err := os.WriteFile(file, []byte("the app's key\n"), 0o600); err != nil {
t.Fatal(err)
}
err := secretCommand(ctx, []string{"accept", "laptop", "requests", "tv-api", "--provider", "anchor", "--from", file})
if err == nil || !strings.Contains(err.Error(), "secret accept anchor tv tv-api") {
t.Fatalf("the per-pair form was not refused naming the provider's: %v", err)
}
consumers, unresolved, err := consumersFrom(ctx, open, "anchor", "tv", "tv-api")
if err != nil || len(unresolved) != 0 {
t.Fatalf("the consumers of tv on anchor could not be found: %v %v", err, unresolved)
}
if len(consumers) != 2 {
t.Fatalf("the consumers of tv on anchor are %+v; want requests and indexer on laptop", consumers)
}
if _, err := open.inventory.AcceptSecretForProvision(ctx, "tv-api", "anchor", "tv", "the app's key", consumers); err != nil {
t.Fatal(err)
}
if !placed("requests") || !placed("indexer") {
t.Fatal("the value given once for the provider was not placed for every consumer")
}
}
-113
View File
@@ -1,113 +0,0 @@
package main
import (
"strings"
"testing"
"github.com/novox/mesh-controller/internal/catalogue"
"github.com/novox/mesh-controller/internal/conditions"
"github.com/novox/mesh-controller/internal/inventory"
"github.com/novox/mesh-controller/internal/link"
)
// A consumer may wait for a provision's credential only a person gives (novox/hq ADR 0337 §5–§7, issue 164).
var requestsManifest = catalogue.Manifest{Module: "requests", Version: "1", Requires: []string{"tv-api", "db"},
Secrets: map[string]string{"tv-api": "/s/tv-api", "db": "/s/db"}}
var tvKeyWait = inventory.Wait{Part: "the tv library", Provision: "tv-api", What: "the API key of tv"}
func outsideFacts(given map[string]bool) operatorWaitFacts {
return operatorWaitFacts{manifests: map[string]catalogue.Manifest{"requests": requestsManifest},
outside: map[string]outsideProvider{"tv-api": {Module: "tv", Machines: []string{"media"}}},
pairsGiven: map[string]map[string]bool{"requests@laptop": given}}
}
// §6: excused only when the module requires the provision, its provider says it is issued outside, and no value a
// person gave is held for the pair under the keys both ends have now.
func TestAWaitForAProvisionIsExcusedOnlyWhenTheStoreBearsItOut(t *testing.T) {
for _, c := range []struct {
name string
w inventory.Wait
f operatorWaitFacts
says string
}{
{"required, issued outside, not given", tvKeyWait, outsideFacts(nil), ""},
{"given", tvKeyWait, outsideFacts(map[string]bool{"tv-api": true}), "was given"},
{"not required", inventory.Wait{Part: "p", Provision: "music-api", What: "w"}, outsideFacts(nil), "does not require"},
{"a credential the mesh gives itself", inventory.Wait{Part: "p", Provision: "db", What: "w"}, outsideFacts(nil),
"mesh gives itself"},
{"what was given cannot be read", tvKeyWait, operatorWaitFacts{
manifests: map[string]catalogue.Manifest{"requests": requestsManifest},
outside: map[string]outsideProvider{"tv-api": {Module: "tv"}}}, "could not be read"},
{"a provision and a secret", inventory.Wait{Part: "p", Provision: "tv-api", Secret: "x", What: "w"},
outsideFacts(nil), "one secret, one setting or one provision"},
} {
err := checkWait("requests", "laptop", c.w, c.f)
switch {
case c.says == "" && err != nil:
t.Errorf("%s: refused: %v", c.name, err)
case c.says != "" && (err == nil || !strings.Contains(err.Error(), c.says)):
t.Errorf("%s: %v; want a refusal saying %q", c.name, err, c.says)
}
}
}
// §7: an excused wait is the consumer's needs-operator condition, in plain words, its summary naming the one line
// that gives the credential for every consumer of the provider; the wait as kept is not changed.
func TestAWaitForAProvisionNamesTheOneAcceptForItsProvider(t *testing.T) {
r := inventory.ResourceHealth{Module: "requests", Resource: "requests.app", Kind: "container", Target: "requests",
State: link.StateWaiting, Check: "tool", Waits: []inventory.Wait{tvKeyWait}}
kept := []inventory.ResourceHealth{r}
got := checkWaiting("laptop", kept, outsideFacts(nil))
if got[0].State != link.StateWaiting {
t.Fatalf("an excused wait for a provision was judged: %+v", got[0])
}
if kept[0].Waits[0].Act != "" {
t.Fatal("checking the wait changed the statement as kept")
}
waits, waiting := operatorWait("requests", got)
if !waiting {
t.Fatal("the module does not read as waiting for the operator")
}
o := needsOperatorObservation("requests", "laptop", waits, got)
if !strings.Contains(o.Summary, "secret accept media tv tv-api") {
t.Fatalf("the summary does not name the accept for the provider: %q", o.Summary)
}
if !strings.HasPrefix(o.Needs, "give the API key of tv") {
t.Fatalf("the needs do not name the act: %q", o.Needs)
}
if why, ok := conditions.PlainWords(conditions.Words{Headline: o.Headline, Explanation: o.Explanation,
Resolved: o.Resolved, Needs: o.Needs, Actions: o.Actions}, "laptop"); !ok {
t.Fatalf("the condition is not in plain words: %s", why)
}
// A provider on several machines, or none: the line names its machine as a place to fill.
if line := (outsideProvider{Module: "tv", Machines: []string{"a", "b"}}).acceptLine("tv-api"); line !=
"secret accept <its machine> tv tv-api" {
t.Fatalf("a provider on two machines reads %q", line)
}
}
// §3: the consumers one accept reaches are those whose resolution answers the provision from that provider.
func TestTheConsumersOfAProviderAreThoseItAnswers(t *testing.T) {
plan := catalogue.Resolution{Needs: []catalogue.Needed{
{Name: "tv-api", From: "media", Module: "tv", For: "requests"},
{Name: "tv-api", From: "media", Module: "tv", For: "indexer", Local: "main"},
{Name: "tv-api", From: "other", Module: "tv", For: "elsewhere"},
{Name: "tv-api", From: "media", Module: "another-tv", For: "not-this-module"},
{Name: "db", From: "media", Module: "tv", For: "requests"},
{Name: "tv-api", ByRecord: true, For: "by-record"},
}}
got := bindsFrom(plan, "laptop", "media", "tv", "tv-api")
if len(got) != 2 || got[0].ConsumerModule != "indexer" || got[0].Local != "main" || got[1].ConsumerModule != "requests" {
t.Fatalf("the consumers from tv on media are %+v", got)
}
said := acceptedSaid("media", "tv", "tv-api", inventory.ProvisionAccepted{
Sealed: []inventory.PairEnd{{Consumer: "laptop", ConsumerModule: "requests"}},
Unsealable: []inventory.PairEnd{{Consumer: "new", ConsumerModule: "indexer"}}}, []string{"down: refused"})
for _, want := range []string{"sealed to requests on laptop", "no sealing key", "down: refused"} {
if !strings.Contains(said, want) {
t.Errorf("what the accept says lacks %q:\n%s", want, said)
}
}
}
+3 -42
View File
@@ -182,21 +182,12 @@ func planFor(ctx context.Context, open *stores, nodeName string) (catalogue.Reso
}
var secret inventory.Secret
var err error
if outsideModule, outside := issuedOutsideBy(shelf, n); outside {
// Only a person can give it (novox/hq ADR 0337, issue 164): read as given, never made.
secret, err = inv.GivenPairSecret(ctx, n.Name, nodeName, n.For, n.From, outsideModule, n.Local)
} else if n.SharedOwn != "" {
if n.SharedOwn != "" {
// The provider's one credential, sealed to this consumer too (novox/hq ADR 0158).
secret, err = inv.SharedSecretFor(ctx, n.Name, nodeName, n.For, n.From, providerModuleOf(resolved, open, ctx, n), n.Local, n.SharedOwn)
} else {
secret, err = inv.SecretFor(ctx, n.Name, nodeName, n.For, n.From, n.Local)
}
if inventory.IsNotGiven(err) {
// **Not given, and the rest of the consumer runs** (ADR 0337 §4): no credential file, so the module can
// tell it was not given and says it waits for it; the machine is not refused for what only a person can
// give, and nothing the mesh made stands in for it.
continue
}
if err != nil {
// Said rather than skipped. A machine that resolves cleanly and receives no
// credential is one that will fail to authenticate at some later, less obvious
@@ -451,12 +442,6 @@ func declarationWith(ctx context.Context, open *stores, node string,
if err != nil {
return sendable{}, err
}
// Which ports the filter admits from the local network, and from which networks, for plan to say
// (novox/hq ADR 0340 §8). The same rules the composition rendered, never sent.
rules, err := plan.Rules(with)
if err != nil {
return sendable{}, err
}
// And what was taken on it, said in every declaration it is sent from this one place.
adoption, err := adoptionOf(ctx, open.inventory, record, plan, composed)
if err != nil {
@@ -466,8 +451,7 @@ func declarationWith(ctx context.Context, open *stores, node string,
Received: composed.Received, Mesh: with.Mesh, BusUsers: with.BusUsers,
LeftOut: sortedKeysOf(composed.LeftOut), leftOutWhy: composed.LeftOut, withheld: with.Withheld,
unbound: with.Unbound, foreseen: composed.Foreseen, unplaced: composed.Unplaced,
modules: namedModules(plan, composed.LeftOut),
localNetwork: catalogue.LocalNetworkPorts(rules, with.LocalNetworks)}
modules: namedModules(plan, composed.LeftOut)}
// And which build of each module it carries, for the send to record (novox/hq issue 259, ADR
// 0221). Read only on the send path: a question about what would be sent records nothing.
if choosing == Allocating {
@@ -976,12 +960,6 @@ func renderingFor(ctx context.Context, open *stores, node string,
if err != nil {
return catalogue.Rendering{}, inventory.Node{}, err
}
// And which of the networks it is attached to count as its local network (novox/hq ADR 0340):
// private ranges only, on a link facing outside it. Reported by the machine, never set.
local, err := localNetworksOf(ctx, inv, node)
if err != nil {
return catalogue.Rendering{}, inventory.Node{}, err
}
// Where this machine reaches each mesh seat's holder, for ${seat:<seat>:reach} (novox/hq ADR
// 0222): the artifact store as this network reaches it, which the container runtime is told to
// trust (ADR 0082). The same address composed into every reference the mesh built.
@@ -1006,8 +984,7 @@ func renderingFor(ctx context.Context, open *stores, node string,
Machines: machines, Zones: zones, Holders: replicas,
Suffix: overlay.Suffix(), MeshRange: meshRange, TunnelInterface: overlay.Interface, Accounts: accounts, Foundation: foundation,
Kept: kept, Adopted: record.Adopted, OutwardLinks: outwardLinks,
LocalNetworks: catalogue.CountedLocalNetworks(local),
Given: given, Taken: taken, Seats: seats, ArtifactStore: artifactStore, SeatReach: reach, Built: built,
Given: given, Taken: taken, Seats: seats, ArtifactStore: artifactStore, SeatReach: reach, Built: built,
BusUsers: busUsers, Withheld: withheld, Unbound: unbound,
}, record, nil
}
@@ -1440,11 +1417,6 @@ func planCommand(ctx context.Context, args []string) error {
fmt.Printf(" %-20s settings from %s\n", module, layer.From)
}
}
// What each port reached from the local network resolved to on this machine (novox/hq ADR 0340 §8),
// or that it resolved to none.
for _, p := range declared.localNetwork {
fmt.Println(" " + p.Said())
}
fmt.Printf("\n%d resource(s)\n", len(resources))
if *show {
@@ -1770,17 +1742,6 @@ func busUserList(ctx context.Context, inv *inventory.Inventory,
// providerModuleOf is which module answers a need on the providing node: the one in this node's
// own set when the provider is here, else the one the catalogue says offers it.
// issuedOutsideBy is whether a need's provision is offered by its provider with a credential issued outside the
// mesh (novox/hq ADR 0337), and that provider module: the one answering it when known, else any that offers it so.
func issuedOutsideBy(shelf map[string]catalogue.Manifest, n catalogue.Needed) (string, bool) {
if n.Module != "" {
if m, known := shelf[n.Module]; known {
return n.Module, m.IssuedOutsideOf(n.Name)
}
}
return catalogue.OffersIssuedOutside(shelf, n.Name)
}
func providerModuleOf(resolved catalogue.Resolution, open *stores, ctx context.Context, n catalogue.Needed) string {
for _, m := range resolved.Modules {
if _, shared := m.SharedCredentialOf(n.Name); shared {
+1 -1
View File
@@ -211,7 +211,7 @@ func (r *rootReader) executeServed(ctx context.Context, machine string) (bool, s
}
holders++
var setting *any
if catalogue.DeclaresPreference(e.Manifest, loginShellVerb) {
if _, declared := e.Manifest.Settings[loginShellVerb]; declared {
layers, err := r.settings(ctx, machine, e.Manifest.Module)
if err != nil {
served = true
-5
View File
@@ -542,11 +542,6 @@ const (
// proposeCommand is `settings propose`, on this controller's stores and bus.
func proposeCommand(ctx context.Context, module, node, valuesArg string, clear, replace bool) error {
if node == "" && !clear {
// There is no module layer for the whole mesh (novox/hq issue 630); clearing one left over still works.
return fmt.Errorf("%s: there is no module layer for the whole mesh — an assignment is one module on one "+
"node (novox/hq issue 630). Propose it for one node with --node. Nothing was proposed", module)
}
var values map[string]any
if !clear {
if valuesArg == "" {
+1 -8
View File
@@ -740,14 +740,7 @@ func waitForApplied(ctx context.Context, inv *inventory.Inventory, node, digest
fmt.Printf("%s applied it\n", node)
return nil
case inventory.OutcomeFailed:
// Each failed resource by id with the machine's own words, so the answer says what to fix
// without a second look (novox/hq issue 629).
var each []string
for _, f := range doing.Failed {
each = append(each, f.ID+": "+f.Error)
}
return fmt.Errorf("%s applied what it was sent but %d resource(s) failed: %s", node, len(doing.Failed),
strings.Join(each, "; "))
return fmt.Errorf("%s applied what it was sent but %d resource(s) failed", node, len(doing.Failed))
case inventory.OutcomeRefused:
return fmt.Errorf("%s refused what it was sent: %s", node, doing.Refused)
}
-74
View File
@@ -1,74 +0,0 @@
package main
import (
"testing"
"github.com/novox/mesh-controller/internal/catalogue"
)
// TestReplay164 replays novox/hq issue 164 (found provisioning the media machine's modules, re-checked
// 2026-10-08): a Servarr app's API key, an indexer's key and a downloader's password are values the provider's
// software holds and only a person can give, and for a consumer of such a provision the send made a random pair
// credential, sealed it to both ends and placed it; the provider refused it, and a consumer retrying it could be
// banned by the provider. The outcome asserted (ADR 0337): for a provision whose offer says its credential is
// issued outside the mesh, the send makes no value and places none, the consumer's machine still composes, and a
// value a person gave for the pair is placed. Written with only what the controller had before its fix —
// register, assign, planFor, declarationFor, AcceptSecretForPair and the inventory's secret reads — so it is laid
// over the commit before. Needs the store (MESH_TEST_POSTGRES), and is named in testdata/store-tests.
func TestReplay164(t *testing.T) {
open := aMesh(t)
ctx := t.Context()
const secretFile = "/var/lib/mesh/requests/tv-api.secret"
register(t, open, catalogue.Manifest{Module: "tv", Version: "1",
Provides: []catalogue.Offer{{Name: "tv-api", Scope: catalogue.ScopeMesh,
Credential: &catalogue.OfferCredential{IssuedBy: catalogue.IssuedOutside}}},
Serves: map[string]map[string]any{"tv-api": {"port": 8989}}})
register(t, open, catalogue.Manifest{Module: "requests", Version: "1", Requires: []string{"tv-api"},
Secrets: map[string]string{"tv-api": secretFile}})
for _, a := range [][2]string{{"anchor", "tv"}, {"laptop", "requests"}} {
if _, err := assign(ctx, open, a[0], a[1]); err != nil {
t.Fatal(err)
}
}
sent := func() []map[string]any {
t.Helper()
plan, settings, err := planFor(ctx, open, "laptop")
if err != nil {
t.Fatalf("the consumer's machine was refused for a credential only a person gives: %v", err)
}
declared, err := declarationFor(ctx, open, "laptop", plan, settings)
if err != nil {
t.Fatalf("the send to laptop did not compose: %v", err)
}
return declared.Resources
}
placed := func(resources []map[string]any) map[string]any {
for _, r := range resources {
if r["path"] == secretFile && r["sealed"] != nil {
return r
}
}
return nil
}
if got := placed(sent()); got != nil {
t.Errorf("the send placed a credential for tv-api (%v), which only a person can give and nobody gave", got["id"])
}
held, err := open.inventory.SecretsOf(ctx, "laptop", "requests")
if err != nil {
t.Fatal(err)
}
for _, s := range held {
if s.Name == "tv-api" {
t.Fatalf("the mesh holds a value for tv-api that nobody gave (origin %q)", s.Origin)
}
}
// Given by a person for the pair, it is placed, as given.
if err := open.inventory.AcceptSecretForPair(ctx, "tv-api", "laptop", "requests", "anchor", "", "the app's key"); err != nil {
t.Fatal(err)
}
if placed(sent()) == nil {
t.Fatal("the value a person gave for tv-api was not placed")
}
}
-80
View File
@@ -1,80 +0,0 @@
package main
import (
"encoding/json"
"strings"
"testing"
"github.com/novox/mesh-controller/internal/catalogue"
"github.com/novox/mesh-controller/internal/inventory"
"github.com/novox/mesh-controller/internal/link"
)
// Replay of novox/hq issue 239 (2026-10-04): the photo app's module `photos` was built from the app's own
// repository, and the catalogue held a stub of the same name in `modules/photos` that had never run. Rebuilding
// `photos` "to main" built the catalogue's main instead; the controller registered the outcome and recorded the
// catalogue as the module's new source, and the next push replaced the app with the stub: two sites' containers
// and five of its six routes went. Nothing compared the build's source with the source recorded before it.
//
// Written only with what the controller had before its fix (mesh-controller #174, hq ADR 0266 §7): a mesh, the
// inventory's RegisterModule and SourceOf, a kept build request, and takeIn for the build's outcome — so the
// prover (mesh-lab replays/cmd/prove) lays it over the commit before. Its outcome is the issue's acceptance: a
// build asked through a verb, of a module's name from a repository other than the one the module is registered
// from, is refused, says both sources, and leaves the module and its recorded source as they were. Needs a
// store: MESH_TEST_POSTGRES.
func TestReplay239AModuleNameIsNotTakenOverByAnotherRepository(t *testing.T) {
open := aMesh(t)
ctx := t.Context()
inv := open.inventory
// Every verb's command runs marked as the serving controller's: never the terminal.
t.Setenv("MESH_SERVED_BY_THE_CONTROLLER", "1")
register := func(raw, repository, path string) {
t.Helper()
m, err := catalogue.ParseManifest([]byte(raw))
if err != nil {
t.Fatal(err)
}
if err := inv.RegisterModule(ctx, m, inventory.Source{Repository: repository, Seat: "git", Path: path,
Ref: "main", BuiltFrom: "1111111111111111"}); err != nil {
t.Fatal(err)
}
}
// The app, built from its own repository; and another module of the catalogue, so the catalogue is a
// repository the mesh builds from.
register(`{"module": "photos", "version": "app"}`, "novox/photos", "")
register(`{"module": "sudo", "version": "1"}`, "novox/mesh-catalog", "modules/sudo")
// The rebuild "to main", asked through the build verb: the catalogue's stub of the same name.
const id = "build-photos-from-the-catalogue"
if err := inv.RecordBuildRequest(ctx, inventory.BuildRequest{ID: id, Repository: "novox/mesh-catalog",
Seat: "git", Path: "modules/photos", Ref: "main", For: "build"}); err != nil {
t.Fatal(err)
}
stub, _ := json.Marshal(map[string]any{"module": "photos", "version": "stub"})
_, _, err := takeIn(ctx, inv, link.BuildResult{ID: id,
Repository: "http://forge.internal:20000/novox/mesh-catalog.git", Path: "modules/photos", Ref: "main",
On: "anchor", Commit: "2222222222222222", Manifest: stub, Trunk: "main", OnTrunk: true,
Branches: []string{"main"}, Source: &link.SourceOnSeat{Seat: "git", Repository: "novox/mesh-catalog"}})
if err == nil {
t.Error("a build of photos from the catalogue was registered over the app's own repository")
} else if !strings.Contains(err.Error(), "novox/photos") || !strings.Contains(err.Error(), "novox/mesh-catalog") {
t.Errorf("the refusal does not say both sources: %v", err)
}
if src, err := inv.SourceOf(ctx, "photos"); err != nil {
t.Fatal(err)
} else if src.Repository != "novox/photos" {
t.Errorf("photos is now recorded as built from %q, not its own repository", src.Repository)
}
shelf, err := inv.Catalogue(ctx)
if err != nil {
t.Fatal(err)
}
if got := shelf["photos"].Version; got != "app" {
t.Errorf("photos is now %q: the next push sends the catalogue's stub", got)
}
if _, found, _ := inv.BuildByID(ctx, id); !found {
t.Error("the refused build is not recorded")
}
}
-94
View File
@@ -1,94 +0,0 @@
package main
import (
"testing"
"time"
"github.com/novox/mesh-controller/internal/conditions"
"github.com/novox/mesh-controller/internal/link"
)
// Replay of novox/hq issue 348 (2026-10-09): the control node's resolver stopped answering on its private
// address at 10:57:57 UTC, and the machine's network condition was raised at 10:58:45. The controller and the
// node-engine were sent to their first machines at 10:59:34. The restarted node-engine's first statement judged
// the names part once — unknown, "one look failed; a second decides" — and that statement cleared the condition,
// though the resolver still refused. Its next look raised it again at 11:00:23, after the send, and at 11:10:29
// both builds failed their gate with "raised since it was sent: machine.<control node>.network" and were put
// back, for a fault that began before they were sent.
//
// Written only with what the controller had before its fix (mesh-controller #179): a mesh, the machine's
// statements as stateHealth keeps them, the keeper's open conditions and the gate's aboutTheMachine — so the
// prover (mesh-lab replays/cmd/prove) lays it over the commit before. Its outcome is the operator's acceptance:
// a build is never put back for a problem that started before it was sent; and the fault is not forgotten
// while the resolver still refuses. Needs a store: MESH_TEST_POSTGRES.
func TestReplay348AFaultFromBeforeTheSendFailsNoGate(t *testing.T) {
open := aMesh(t)
ctx := t.Context()
inv, k := open.inventory, conditionsFrom
const key = "machine.anchor.network"
names := func(state string, streak int) link.NetworkPart {
p := link.NetworkPart{Part: link.PartNames, State: state, Since: h0, Streak: streak}
if state == link.StateUnhealthy {
p.Reason = "1 of its 2 resolvers do not answer as the mesh's do"
p.Said = "10.77.0.1 — anchor.internal (IPv4): read udp 10.77.0.1:35244->10.77.0.1:53: read: connection refused"
p.Toward = []string{"10.77.0.1"}
}
return p
}
say := func(at time.Time, state string, streak int) {
t.Helper()
n := &link.NetworkHealth{State: state, Since: h0, Parts: []link.NetworkPart{names(state, streak)}}
if err := stateHealth(ctx, inv, k, "anchor", link.Health{Contract: link.ReadinessContract, At: at, Network: n}, at); err != nil {
t.Fatal(err)
}
}
network := func() (conditions.Condition, bool) {
t.Helper()
list, err := k.Open(ctx)
if err != nil {
t.Fatal(err)
}
for _, c := range list {
if c.Key == key {
return c, true
}
}
return conditions.Condition{}, false
}
// 10:58:45 — the node-engine's second failing look: raised.
say(h0, link.StateUnhealthy, 2)
if _, ok := network(); !ok {
t.Fatal("the resolver refusing every question on the control node raised nothing")
}
// 10:59:34 — the controller and the node-engine are sent.
time.Sleep(10 * time.Millisecond)
sent := time.Now().UTC()
time.Sleep(10 * time.Millisecond)
// 10:59:42 — the restarted engine's first statement: one look failed, a second decides.
say(h0.Add(time.Minute), link.StateUnknown, 1)
if _, ok := network(); !ok {
t.Error("the restarted node-engine's first, undecided statement cleared the network condition while " +
"the resolver still refused")
}
// 11:00:23 — its second look: unhealthy again.
time.Sleep(10 * time.Millisecond)
say(h0.Add(2*time.Minute), link.StateUnhealthy, 2)
if _, ok := network(); !ok {
t.Fatal("the resolver still refusing raised nothing")
}
// 11:10:29 — the gate on the control node, for the builds sent after the fault began.
list, err := k.Open(ctx)
if err != nil {
t.Fatal(err)
}
for _, moved := range [][]string{{"mesh-host"}, {"mesh-controller"}} {
w := aboutTheMachine("anchor", moved, sent, gateFacts{judged: true, open: list})
if w.whole != "" || len(w.on) != 0 {
t.Errorf("%s, sent after the resolver stopped answering, was held for that fault: whole %q, on %v",
moved[0], w.whole, w.on)
}
}
}
-106
View File
@@ -1,106 +0,0 @@
package main
import (
"testing"
"time"
"github.com/novox/mesh-controller/internal/catalogue"
"github.com/novox/mesh-controller/internal/inventory"
"github.com/novox/mesh-controller/internal/link"
)
// Replay of novox/hq issue 349 (2026-10-09): two merges into the catalogue's main were acted on in reverse order.
// The later one (a082615b, a security fix to the forge's module) was planned first; the earlier one (8ff8197a,
// another module), handed over late, was planned after it, and its plan superseded the later merge's at tier 0
// with "gitea planned there again": the forge's module was to be built for the commit without the fix. Written
// only with what the controller had before its fix (mesh-controller #179): a mesh, two registered modules of the
// catalogue, the merges as the forge announces them, the plans the inventory keeps — so it is laid over the
// commit before. Since novox/hq ADR 0276 the earlier merge waits in a batch behind the later merge's walk and is
// planned only when that walk ends and the cutter looks again; the replay ends the walk and has a merge of another
// repository heard, which looks, and a plan of the two merges left standing with nothing planned fails it.
func TestReplay349(t *testing.T) {
open := aMesh(t)
ctx := t.Context()
inv := open.inventory
asksWithPaths(t)
for _, module := range []string{"app", "notes"} {
if err := inv.RegisterModule(ctx, catalogue.Manifest{Module: module, Version: "1"},
inventory.Source{Repository: "novox/mesh-catalog", Seat: "git", Path: "modules/" + module, Ref: "main",
BuiltFrom: "c0", Head: "c0"}); err != nil {
t.Fatal(err)
}
}
if err := inv.RegisterModule(ctx, catalogue.Manifest{Module: "other", Version: "1"},
inventory.Source{Repository: "novox/other", Seat: "git", Ref: "main", BuiltFrom: "c0", Head: "c0"}); err != nil {
t.Fatal(err)
}
// Both made after the modules were last looked at, so neither is history.
made := time.Now().UTC().Add(time.Minute)
merge := func(commit, module string, at time.Time) link.SourceMoved {
return link.SourceMoved{Owner: "novox", Repo: "mesh-catalog", Base: "main", Commit: commit,
MergedAt: at.Format(time.RFC3339Nano), Paths: []string{"modules/" + module + "/module.json"},
ModuleDirs: []string{"modules/" + module}, ModuleDirsSaid: true}
}
const fix, earlier = "a082615bfix", "8ff8197aearlier"
// The later merge, the fix to notes, is acted on first; the earlier one, moving app, after it.
if err := (following{open: open}).SourceMoved(ctx, merge(fix, "notes", made.Add(time.Minute))); err != nil {
t.Fatal(err)
}
if err := (following{open: open}).SourceMoved(ctx, merge(earlier, "app", made)); err != nil {
t.Fatal(err)
}
// The later merge's plan builds and ends; then a merge of another repository comes. Whatever holds the earlier
// merge back while that plan runs (a batch queued behind it, novox/hq ADR 0276) is decided by now: every plan
// of the two merges left standing says what it builds.
plans, err := inv.RecentPlans(ctx, 20)
if err != nil {
t.Fatal(err)
}
for _, p := range plans {
if p.Commit == fix && p.Open() {
p.State = inventory.PlanDone
if err := inv.SavePlan(ctx, &p); err != nil {
t.Fatal(err)
}
}
}
if err := (following{open: open}).SourceMoved(ctx, link.SourceMoved{Owner: "novox", Repo: "other", Base: "main",
Commit: "c1other", MergedAt: made.Add(2 * time.Minute).Format(time.RFC3339Nano),
Paths: []string{"main.go"}}); err != nil {
t.Fatal(err)
}
if plans, err = inv.RecentPlans(ctx, 20); err != nil {
t.Fatal(err)
}
// A plan superseded hands its modules to the plan that took it over; the plans left standing are what the mesh
// builds. Each plan of the two merges left standing plans modules; one builds app, the earlier merge's module;
// one builds notes, the module the later merge fixed, and every one that does builds it at the commit holding
// the fix.
app, notes := 0, 0
for _, p := range plans {
t.Logf("%s %s at %.8s: %v %s", p.ID, p.State, p.Commit, p.Tiers, p.Note)
if p.State == inventory.PlanSuperseded || (p.Commit != fix && p.Commit != earlier) {
continue
}
if len(p.Modules) == 0 {
t.Errorf("%s, of the merge at %s, is left standing and plans nothing: %s", p.ID, p.Commit, p.Note)
}
if _, holds := p.Modules["app"]; holds {
app++
}
if _, holds := p.Modules["notes"]; holds {
notes++
if p.Commit != fix {
t.Errorf("%s plans the fixed module notes at %s, a commit without the fix %s: %s", p.ID, p.Commit, fix,
p.Note)
}
}
}
if app == 0 {
t.Error("no plan left standing builds app, the module the earlier merge moved")
}
if notes == 0 {
t.Error("no plan left standing builds notes, the module the later merge fixed")
}
}
-71
View File
@@ -1,71 +0,0 @@
package main
import (
"reflect"
"strings"
"testing"
"time"
"github.com/novox/mesh-controller/internal/catalogue"
"github.com/novox/mesh-controller/internal/inventory"
)
// **A new SDK plans the toolchain, and then every bundle compiled in it** (novox/hq issue 614), reasoned
// as `mesh-controller.delivery-plan` reasons it: the modules registered as a build registers them —
// resolved, so carrying no `build` — their builds recorded with what they stood on and what they made,
// the graph read by theGraph and the change scoped by pullScope. On 2026-10-13 mesh-sdk 0.1.14 planned
// "builds mesh-sdk, sent nowhere": mesh-tools' `build.on` naming the SDK's package and the providers'
// TypeScript bundles were edges only while their manifests were unbuilt.
//
// **The replay of issue 614** (mesh-lab R614): written only with what the controller had before its fix — a
// store, modules registered and builds recorded, theGraph and pullScope — so it is laid over the commit before
// and fails there with the plan of that day, [[mesh-sdk]]. Needs a store: MESH_TEST_POSTGRES.
func TestReplay614ANewSDKPlansTheToolchainAndThenItsBundles(t *testing.T) {
inv := inventory.ForTest(t)
ctx := t.Context()
asked := time.Now().Add(-time.Hour)
digest := func(c string) string { return "@sha256:" + strings.Repeat(c, 64) }
register := func(m catalogue.Manifest, repository, path string, against []string, made []inventory.Artifact) {
t.Helper()
if err := inv.RegisterModule(ctx, m, inventory.Source{Repository: repository, Seat: "git", Path: path,
Ref: "main", BuiltFrom: "old", Asked: asked}); err != nil {
t.Fatal(err)
}
if err := inv.RecordBuild(ctx, inventory.Build{ID: "build-" + m.Module, Repository: repository, Ref: "main",
Module: m.Module, Commit: "old", On: "builder", Path: path, Against: against, Made: made, Asked: asked}); err != nil {
t.Fatal(err)
}
}
bundle := func(module, language string) catalogue.Manifest {
return catalogue.Manifest{Module: module, Version: "1", Bundles: []catalogue.Bundle{{
Name: "code", Language: language, Source: catalogue.ArtifactStoreScheme + module + "/code" + digest("2"),
Entrypoints: []string{"tools/index.js"}}}}
}
register(catalogue.Manifest{Module: "build-agent", Version: "1",
Claims: []catalogue.Claim{{Name: "node-build-agent", Scope: catalogue.ScopeNode}}},
"novox/mesh-catalog", "modules/build-agent", nil, nil)
register(catalogue.Manifest{Module: "mesh-sdk", Version: "1"}, "novox/mesh-sdk", "", nil,
[]inventory.Artifact{{Name: "lib", Kind: "package", Reference: "@novox/mesh-sdk@0.1.13"}})
register(catalogue.Manifest{Module: "mesh-tools", Version: "1"}, "novox/mesh-tools", "",
[]string{"@novox/mesh-sdk@0.1.13", catalogue.ArtifactStoreScheme + "upstream/docker.io/library/node" + digest("0")},
[]inventory.Artifact{{Name: "build", Kind: "image", Reference: catalogue.ArtifactStoreScheme + "mesh-tools/build" + digest("1")}})
register(catalogue.Manifest{Module: "mesh-tools-go", Version: "1"}, "novox/mesh-tools", "go", nil, nil)
register(bundle("minio", "typescript"), "novox/mesh-catalog", "modules/minio", nil, nil)
register(bundle("umami", "typescript"), "novox/mesh-catalog", "modules/umami", nil, nil)
register(bundle("dnsmasq", "go"), "novox/mesh-catalog", "modules/dnsmasq", nil, nil)
entries, read, edges, err := theGraph(ctx, inv)
if err != nil {
t.Fatal(err)
}
m := orderMember{ID: "novox/mesh-sdk@new", Repository: "novox/mesh-sdk", Head: "new",
Paths: []string{"src/provisioner/index.ts", "package.json"}}
s := pullScope(m.pull(), entries, read, edges)
want := [][]string{{"mesh-sdk"}, {"mesh-tools"}, {"minio", "umami"}}
if !reflect.DeepEqual(s.Reach.Plan.Tiers, want) {
t.Fatalf("a new SDK plans %v, want %v (edges %v)", s.Reach.Plan.Tiers, want, edges)
}
if !reflect.DeepEqual(s.Dependents, []string{"mesh-tools", "minio", "umami"}) {
t.Errorf("dependents %v", s.Dependents)
}
}
+4 -97
View File
@@ -13,7 +13,6 @@ import (
"slices"
"sort"
"strings"
"time"
"github.com/novox/mesh-controller/internal/broker"
"github.com/novox/mesh-controller/internal/catalogue"
@@ -520,13 +519,9 @@ func (a *verbArguments) commandLine() ([]string, error) {
}
return []string{"unpin", str("node"), str("provision")}, nil
case "push":
// A push to a named machine waits for that machine's own answer, so the call's record says
// how it went: applied, refused with the machine's reason, or which resources failed and why
// (novox/hq issue 629). How long is the call's `wait`, else the controller's setting `push-wait`;
// neither given waits for nothing, as before. The caller is answered before the push starts
// (answersFirst) and the machines are released once sent, so the wait holds nobody; it is what
// `calls` then shows. A sweep (--behind) does not wait: the slowest machine would hold back
// what is said of the others.
// Sent and not waited for: the asker reads `status` for what the machine did, which is
// what a person at a shell does too. A tool call that blocked for a push's whole apply would
// time out on every machine that takes a minute, and say nothing about the ones that did not.
// A push through the seat is a push by hand, and says why (novox/hq to-be 45 §7).
if err := need("why"); err != nil {
return nil, fmt.Errorf("%w: a push by hand is a repair, recorded in the hand-act log with why", err)
@@ -539,11 +534,7 @@ func (a *verbArguments) commandLine() ([]string, error) {
// behind is not read here: given with a machine, it is refused as passed over — naming
// a machine and asking for every machine behind are two requests, and guessing one
// would push a machine nobody named, or not push one somebody did.
wait, err := pushWait(str("wait"))
if err != nil {
return nil, err
}
argv := []string{"push", n, "--wait", wait.String()}
argv := []string{"push", n, "--wait", "0"}
// A running module's data moving is sent only when said, per module (novox/hq ADR 0217).
if m := str("move"); m != "" {
argv = append(argv, "--move", m)
@@ -881,48 +872,6 @@ func (a *verbArguments) commandLine() ([]string, error) {
argv = append(argv, "--adopted")
}
return argv, nil
case "config":
// Config values from four sources (novox/hq issue 630): mesh config and node config for a predefined
// key, and a module's own value on one node. A listing without key; a set or clear says why.
key := str("key")
if str("module") != "" && str("node") == "" && (key != "" || on("clear")) {
return nil, errors.New("config: there is no module layer for the whole mesh — an assignment is one module " +
"on one node (novox/hq issue 630); give node, or leave module out to set mesh config for a predefined key")
}
place := func(argv []string) []string {
if n := str("node"); n != "" {
argv = append(argv, "--node", n)
}
if m := str("module"); m != "" {
argv = append(argv, "--module", m)
}
return argv
}
switch {
case key == "":
if str("value") != "" || on("clear") || on("history") || str("why") != "" {
return nil, errors.New("config: a set, a clear or a history names its key")
}
return place([]string{"config", "list"}), nil
case on("history"):
if str("value") != "" || on("clear") || str("module") != "" || str("why") != "" {
return nil, errors.New("config: history reads a key's mesh or node config, and takes only node")
}
return place([]string{"config", "history", key}), nil
case on("clear") && str("value") != "":
return nil, errors.New("config: a value or clear, not both")
case on("clear"):
if err := need("why"); err != nil {
return nil, err
}
return append(place([]string{"config", "clear", key}), "--why", str("why")), nil
case str("value") != "":
if err := need("why"); err != nil {
return nil, err
}
return append(place([]string{"config", "set", key, str("value")}), "--why", str("why")), nil
}
return nil, errors.New("config: with key, give value, clear or history")
case "settings":
// Every module's preferences, their defaults and each machine's value (novox/hq ADR 0262):
// the one interface for them, so no module builds a settings tool of its own. Asked for by
@@ -1379,47 +1328,6 @@ var inProcess = map[string]bool{"tools": true, "calls": true, "doctor": true,
// 0259 §8): the router asks it before an approval.
"root-free": true}
// pushFileVar names the file the controller's push settings are written to (module.json, `push-wait`).
const pushFileVar = "MESH_PUSH_FILE"
// pushWait is how long a push to a named machine waits for its answer (novox/hq issue 629): the call's own
// `wait` when given, else the controller's setting `push-wait`, else nothing. No length is written here: the
// setting's default is the module's, and a setting that cannot be read waits for nothing, as a push did
// before, and is said.
func pushWait(given string) (time.Duration, error) {
if strings.TrimSpace(given) != "" {
d, ok := durationSetting(given)
if !ok {
return 0, fmt.Errorf("push: wait %q is no duration: say it as \"90s\", \"2m\", or \"0\" to wait for nothing", given)
}
return d, nil
}
path := os.Getenv(pushFileVar)
if path == "" {
return 0, nil
}
raw, err := os.ReadFile(path)
if err != nil {
fmt.Fprintf(os.Stderr, "the push settings cannot be read (%v); a push waits for nothing\n", err)
return 0, nil
}
var values map[string]any
if err := json.Unmarshal(raw, &values); err != nil {
fmt.Fprintf(os.Stderr, "the push settings are not JSON (%v); a push waits for nothing\n", err)
return 0, nil
}
v, has := values["push-wait"]
if !has || v == "" {
return 0, nil
}
d, ok := durationSetting(v)
if !ok {
fmt.Fprintf(os.Stderr, "the setting push-wait=%v is no duration; a push waits for nothing\n", v)
return 0, nil
}
return d, nil
}
// answersFirst is a command line whose caller is answered before it runs: a push, by its verb or
// through `command`. A push sends the machine holding the bus first when its user list changed, the
// broker reloads, and a reload forgets every answer the bus was about to permit — so an answer
@@ -1656,7 +1564,6 @@ var commandReadForms = map[string]func(rest []string) bool{
"node": func(r []string) bool { return subIn(r, "list", "show") },
"module": func(r []string) bool { return subIn(r, "list") },
"settings": func(r []string) bool { return subIn(r, "show", "preferences", "proposals") },
"config": func(r []string) bool { return flagsOnly(r) || subIn(r, "list", "history") },
"retire": func(r []string) bool { return subIn(r, "list") },
"cleanup": func(r []string) bool { return subIn(r, "list") },
"delivery": func(r []string) bool { return subIn(r, "plan", "walks") },
+1 -1
View File
@@ -146,7 +146,7 @@ func TestAnArgumentAVerbDoesNotDeclareIsRefused(t *testing.T) {
// naming one beside behind is refused rather than one of the two guessed.
func TestAPushIsOneMachineOrSaysItIsTheWholeMesh(t *testing.T) {
argv, err := argvFor("push", map[string]any{"node": "g1", "why": "w"})
if err != nil || strings.Join(argv, " ") != "push g1 --wait 0s --why w" {
if err != nil || strings.Join(argv, " ") != "push g1 --wait 0 --why w" {
t.Fatalf("a named push: %v %v", argv, err)
}
for _, args := range []map[string]any{{"why": "w"}, {"behind": "true", "why": "w"}} {
+4 -45
View File
@@ -5,8 +5,6 @@ import (
"errors"
"fmt"
"github.com/novox/mesh-controller/internal/link"
"os"
"path/filepath"
"strings"
"testing"
@@ -166,14 +164,12 @@ func TestAVerbMissingWhatItNeedsIsRefused(t *testing.T) {
}
}
// Acts do not block the call. A build is sent, not waited for: the asker reads the build's log by its id.
// A push answers its caller before it starts (answersFirst), so a named push waits for the machine's own
// answer without holding anybody, and the call's record says how it went (novox/hq issue 629). A
// repository given as a forge path is said to be one (issue 176).
// A push and a build are sent, not waited for: the asker reads status, or the build's log by its
// id, for what happened. A repository given as a forge path is said to be one (issue 176).
func TestActsDoNotBlockTheCall(t *testing.T) {
argv, _ := argvFor("push", map[string]any{"node": "one", "why": "w"})
if strings.Join(argv, " ") != "push one --wait 0s --why w" || !answersFirst(argv) {
t.Fatalf("a named push with no setting waits, or holds its caller: %v", argv)
if strings.Join(argv, " ") != "push one --wait 0 --why w" {
t.Fatalf("push waits: %v", argv)
}
argv, _ = argvFor("build", map[string]any{"repository": "novox/x", "path": "modules/x"})
if strings.Join(argv, " ") != "build novox/x --wait 0 --self --path modules/x" {
@@ -464,40 +460,3 @@ func TestAQuotedValueRoundTrips(t *testing.T) {
}
}
}
// A named push waits as long as the call says, else as long as the controller's setting push-wait says, else
// not at all: no length is the code's own (novox/hq issue 629).
func TestAPushWaitsAsTheCallOrTheSettingSays(t *testing.T) {
file := filepath.Join(t.TempDir(), "push.json")
if err := os.WriteFile(file, []byte(`{"push-wait": "2m"}`), 0o600); err != nil {
t.Fatal(err)
}
t.Setenv(pushFileVar, file)
argv, err := argvFor("push", map[string]any{"node": "one", "why": "w"})
if err != nil || strings.Join(argv, " ") != "push one --wait 2m0s --why w" {
t.Fatalf("the setting was not the default: %v %v", argv, err)
}
argv, err = argvFor("push", map[string]any{"node": "one", "why": "w", "wait": "30s"})
if err != nil || strings.Join(argv, " ") != "push one --wait 30s --why w" {
t.Fatalf("the call's wait did not win over the setting: %v %v", argv, err)
}
argv, err = argvFor("push", map[string]any{"node": "one", "why": "w", "wait": "0"})
if err != nil || strings.Join(argv, " ") != "push one --wait 0s --why w" {
t.Fatalf("a wait of 0 did not wait for nothing: %v %v", argv, err)
}
if _, err := argvFor("push", map[string]any{"node": "one", "why": "w", "wait": "soon"}); err == nil ||
!strings.Contains(err.Error(), "no duration") {
t.Fatalf("a wait that is no duration was taken: %v", err)
}
if err := os.WriteFile(file, []byte(`{"push-wait": "later"}`), 0o600); err != nil {
t.Fatal(err)
}
argv, err = argvFor("push", map[string]any{"node": "one", "why": "w"})
if err != nil || strings.Join(argv, " ") != "push one --wait 0s --why w" {
t.Fatalf("an unreadable setting did not fall back to waiting for nothing: %v %v", argv, err)
}
argv, _ = argvFor("push", map[string]any{"why": "w"})
if strings.Join(argv, " ") != "push --behind --wait 0 --why w" {
t.Fatalf("a sweep waits: %v", argv)
}
}
-32
View File
@@ -79,37 +79,6 @@ func secretCommand(ctx context.Context, args []string) error {
return fmt.Errorf("a secret's value is given at the controller's terminal or at the desk (`give`), never "+
"through a verb (this line came through %q): nothing was read or sealed", verb)
}
// **A provision's credential issued outside the mesh is given once, for its provider** (novox/hq ADR 0337 §3,
// issue 165): `secret accept <provider's machine> <provider module> <provision>`, sealed to every consumer of
// that provider; the per-pair form naming a consumer is refused for it, naming this one.
outsideAt, err := outsideOffer(ctx, module, name, *provider)
if err != nil {
return err
}
if outsideAt == "" && *provider != "" {
if offered, err := offeredOutsideBy(ctx, name); err != nil {
return err
} else if offered != "" {
return fmt.Errorf("%s is issued outside the mesh and given once for its provider, which seals it to "+
"every consumer of it (ADR 0337): `secret accept %s %s %s`; nothing was read or sealed",
name, *provider, offered, name)
}
}
if outsideAt != "" {
if *desk != "" || *local != "" {
return errors.New("a provision's credential issued outside the mesh is given at the controller's " +
"terminal, for every consumer of its provider; it takes neither --at-desk nor --local")
}
value, err := valueFor(node, module, name, *from)
if err != nil {
return err
}
value = asSupplied(value)
if value == "" {
return errors.New("there is nothing to seal")
}
return acceptForProvision(ctx, node, module, name, value)
}
if *desk != "" {
if *from != "" || *provider != "" {
return errors.New("--at-desk gives a module's own secret, and takes neither --from nor --provider")
@@ -204,7 +173,6 @@ const secretUsage = "secret rotate <node> <module> <name> [--why <text> [--cause
"secret ask <node> <module> <name> [--at <machine>]\n" +
"secret issued <provision>\n" +
"secret accept <node> <module> <name> [--from <file> | --at-desk <machine>] [--provider <node> [--local <name>]]\n" +
"secret accept <provider's node> <provider module> <provision> [--from <file>] (a credential issued outside the mesh, for every consumer)\n" +
"secret recover <node> <module> <name> --key <operator-key> [--out <file>] [--from-export <file>] [--provider <node>]\n" +
"secret export [--out <file>]"
-3
View File
@@ -40,9 +40,6 @@ type sendable struct {
actingEpoch uint64
// modules are the modules this declaration names, for the record of the send; never on the wire.
modules []string
// localNetwork is every port admitted from the local network and what it resolved to (novox/hq ADR
// 0340 §8), for plan to say; never on the wire.
localNetwork []catalogue.LocalNetworkPort
// Adoption is nil for a converged node, and then the body is byte for byte what it was before
// adoption existed: an older host parses the envelope strictly and would refuse the key.
Adoption *adoptionEnvelope
@@ -14,7 +14,7 @@ func TestSettingsSayWhereEachValueComesFrom(t *testing.T) {
{Key: "width", Value: float64(250), From: catalogue.DefaultLayer, FromDefault: true, Default: float64(250), HasDefault: true},
})
want := "dunst on laptop, every value and where it comes from:\n" +
" font-size = 13 (dunst on laptop; the default is 10)\n" +
" font-size = 13 (laptop; the default is 10)\n" +
" width = 250 (default)\n"
if got != want {
t.Fatalf("said\n%s\nwant\n%s", got, want)
@@ -66,7 +66,7 @@ func TestPreferencesSayEachMachinesValueAndItsSource(t *testing.T) {
want := "dunst (on desk, laptop)\n" +
" font-size, default 10: readable at 100 DPI\n" +
" desk: 10 (default)\n" +
" laptop: 16 (dunst on laptop)\n" +
" laptop: 16 (the node)\n" +
" width, default 250: forty characters\n" +
" desk: 300 (the mesh)\n" +
" laptop: 250 (default)\n"
@@ -96,7 +96,7 @@ func TestPreferencesListedFromTheStores(t *testing.T) {
t.Fatal(err)
}
all := stdoutOf(t, func() error { return settingsCommand(ctx, []string{"preferences"}) })
if !strings.Contains(all, "notes (on laptop)") || !strings.Contains(all, "laptop: 16 (notes on laptop)") ||
if !strings.Contains(all, "notes (on laptop)") || !strings.Contains(all, "laptop: 16 (the node)") ||
!strings.Contains(all, "font-size, default 10: readable at 100 DPI") {
t.Fatalf("the listing:\n%s", all)
}
+2 -43
View File
@@ -263,18 +263,8 @@ func TestTheAgentsManagedSettingsAreRefusedThroughAVerb(t *testing.T) {
if err := atTheTerminal(t, "settings", "set", "claude-code", allow, "--node", "laptop"); err != nil {
t.Fatalf("the managed settings at the terminal: %v", err)
}
// There is no module layer for the whole mesh (novox/hq issue 630), at the terminal either; one the mesh
// still holds from before is kept as it is, and is the terminal's as before.
if err := atTheTerminal(t, "settings", "set", "claude-code", server); err == nil ||
!strings.Contains(err.Error(), "no module layer for the whole mesh") {
t.Fatalf("a tool server for the whole mesh at the terminal: %v", err)
}
var held map[string]any
if err := json.Unmarshal([]byte(server), &held); err != nil {
t.Fatal(err)
}
if err := open.inventory.KeepSettings(ctx, "", "claude-code", held); err != nil {
t.Fatalf("a whole-mesh layer held from before: %v", err)
if err := atTheTerminal(t, "settings", "set", "claude-code", server); err != nil {
t.Fatalf("a tool server for the mesh at the terminal: %v", err)
}
kept := layer("laptop")
// Through a verb they are neither changed, dropped nor cleared.
@@ -355,37 +345,6 @@ func TestAnyKeyOfAModuleWithATrustedMergeableFileIsRefusedThroughAVerb(t *testin
}
}
// A module whose mergeable files name the keys they take (ADR 0326): a key of a trusted file is refused through a
// verb, and a key of a file nothing trusts is set through it (novox/hq ADR 0329 §5, issue 586: betterbird's tags).
func TestOnlyATrustedFilesNamedKeyIsRefusedThroughAVerb(t *testing.T) {
open := aMesh(t)
ctx := t.Context()
register(t, open, catalogue.Manifest{Module: "mailer", Version: "1",
Resources: []map[string]any{
{"id": "accounts", "type": "file", "path": "/var/lib/mailer/accounts.json", "mode": "0600", "merge": "json",
"trusted": true, "takes-settings": []any{"accounts"}, "content": `{"accounts": {}}`},
{"id": "tags", "type": "file", "path": "/var/lib/mailer/tags.json", "mode": "0644", "merge": "json",
"trusted": false, "takes-settings": []any{"tags"}, "content": `{"tags": {}}`}}})
if _, err := assign(ctx, open, "anchor", "mailer"); err != nil {
t.Fatal(err)
}
if err := throughVerb(t, "settings", map[string]any{"module": "mailer", "node": "anchor", "values": `{"tags":{"todo":{"name":"To do"}}}`}); err != nil {
t.Fatalf("a key of a file nothing trusts is the verb's: %v", err)
}
err := throughVerb(t, "settings", map[string]any{"module": "mailer", "node": "anchor", "replace": "true",
"values": `{"tags":{"todo":{"name":"To do"}},"accounts":{"mail":{"address":"me@mail.example"}}}`})
if err == nil || !strings.Contains(err.Error(), "controller's terminal") || !strings.Contains(err.Error(), "accounts") {
t.Fatalf("a key of a trusted file is the terminal's: %v", err)
}
values, _, err := open.inventory.Layer(ctx, "anchor", "mailer")
if err != nil {
t.Fatal(err)
}
if raw, _ := json.Marshal(values); string(raw) != `{"tags":{"todo":{"name":"To do"}}}` {
t.Fatalf("the layer holds the tags alone: %s", raw)
}
}
// Who owns a module's directory is said at the terminal alone (novox/hq issue 279): a directory's owner may name
// the settings the module runs as, root chowns the folder to them at the next push, and through the settings verb
// any caller could have set puid to 0 or to its own uid. The key is refused through every verb route even where a
+1 -1
View File
@@ -160,7 +160,7 @@ func TestTheVerbsCarryReadReplaceAndMove(t *testing.T) {
{"settings", map[string]any{"module": "plex", "history": "true"}, []string{"settings", "show", "plex", "--history"}},
{"settings", map[string]any{"module": "plex", "values": "{}", "replace": "true"}, []string{"settings", "set", "plex", "{}", "--replace"}},
{"push", map[string]any{"node": "home", "move": "plex", "why": "w"},
[]string{"push", "home", "--wait", "0s", "--move", "plex", "--why", "w"}},
[]string{"push", "home", "--wait", "0", "--move", "plex", "--why", "w"}},
{"push", map[string]any{"why": "w"}, []string{"push", "--behind", "--wait", "0", "--why", "w"}},
{"plan", map[string]any{"node": "home", "diff": "true"}, []string{"plan", "home", "--diff"}},
} {
-64
View File
@@ -692,73 +692,9 @@ func readsFile(e inventory.Entry, read []inventory.ReadRepository, p string) boo
if own := ownSource(read); own != nil {
return builder.SourceHolds(own, p)
}
return readsDirectory(e, p)
}
// readsDirectory is whether a changed file is in a module's directory, or anywhere for a module built from its
// repository's root: the reading of a module with no recorded build source.
func readsDirectory(e inventory.Entry, p string) bool {
return strings.Trim(e.Source.Path, "/") == "" || inside(p, e.Source.Path)
}
// unchangedOfMerge are the modules a merge changed files for under a reading without recorded build sources, but
// whose recorded build source holds none of them, each with why (novox/hq issue 611): not built, because the build
// the mesh holds is theirs (ADR 0267 §5). That reading — a delivery plan made while another walk built the module
// (issue 606) — would have moved them; the walk says they matched what runs rather than leaving them unnamed.
// A module built from the repository whose directory, or whole repository when built from its root, holds a
// changed file, and a module packaging the repository's branch, are each read so. A merge whose changed files
// were not all said moves every one of them: none is said here. Without acting.
func unchangedOfMerge(m link.SourceMoved, entries []inventory.Entry,
read map[string][]inventory.ReadRepository) map[string]string {
if len(m.Paths) == 0 || m.PathsTruncated {
return nil
}
from, _, _ := mergeCandidates(m, entries, read)
touched, _, _ := touchedBy(from, entries, m, read)
out := map[string]string{}
for _, e := range from {
name := e.Manifest.Module
if ownSource(read[name]) == nil || slices.ContainsFunc(touched, func(t inventory.Entry) bool {
return t.Manifest.Module == name
}) {
continue
}
var in []string
for _, p := range m.Paths {
if readsDirectory(e, p) {
in = append(in, p)
}
}
if len(in) > 0 {
out[name] = fmt.Sprintf("its build source holds none of the %d changed file(s) in its directory (e.g. %s): "+
"the build the mesh holds is its build", len(in), in[0])
}
}
for _, e := range entries {
name := e.Manifest.Module
if sourceIs(e.Source, m) || !packagesSaid(read[name], m) || readsFrom(read[name], m) {
continue
}
out[name] = fmt.Sprintf("its build source in %s/%s holds none of the %d changed file(s) (e.g. %s): "+
"the build the mesh holds is its build", m.Owner, m.Repo, len(m.Paths), m.Paths[0])
}
if len(out) == 0 {
return nil
}
return out
}
// packagesSaid is whether a module's newest trunk build said the build source it read in a merge's repository and
// branch, as a context beside its own.
func packagesSaid(read []inventory.ReadRepository, m link.SourceMoved) bool {
for _, r := range read {
if !r.Own && sameRepository(r.Repository, m) && (r.Ref == "" || r.Ref == m.Base) && len(r.Paths) > 0 {
return true
}
}
return false
}
// staleIn is the modules whose recorded build source a plan has overtaken (novox/hq ADR 0267): a plan still
// working that has yet to build one, or a plan made after that build which never built it — failed, stopped
// or superseded. What such a module is built from is changing, or changed without a build to say so: a merge
+1 -1
View File
@@ -18,7 +18,7 @@ WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -trimpath -buildvcs=false -ldflags '-s -w' \
RUN CGO_ENABLED=0 go build -trimpath -ldflags '-s -w' \
-o /mesh-provision-objectstore ./examples/objectstore-provisioner
# Pinned like everything else the mesh runs (novox/hq ADR 0006): a tag moves and a digest does not.
+1 -1
View File
@@ -11,7 +11,7 @@ WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -trimpath -buildvcs=false -ldflags '-s -w' \
RUN CGO_ENABLED=0 go build -trimpath -ldflags '-s -w' \
-o /mesh-provision-postgres ./examples/postgres-provisioner
FROM scratch
+1 -1
View File
@@ -10,7 +10,7 @@ WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -trimpath -buildvcs=false -ldflags '-s -w' \
RUN CGO_ENABLED=0 go build -trimpath -ldflags '-s -w' \
-o /mesh-provision-redis ./examples/redis-provisioner
FROM scratch
+1 -1
View File
@@ -10,7 +10,7 @@ WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -trimpath -buildvcs=false -ldflags '-s -w' -o /mesh-route-proxy ./examples/route-proxy
RUN CGO_ENABLED=0 go build -trimpath -ldflags '-s -w' -o /mesh-route-proxy ./examples/route-proxy
FROM scratch
COPY --from=build /mesh-route-proxy /mesh-route-proxy
+2 -48
View File
@@ -98,11 +98,6 @@ type Result struct {
// recipe and the package it compiles; for any other, nothing is said and the whole of what the build
// sees stays its source, as before.
Sources []BuildSource
// Predefined is the repository's list of predefined keys (catalogue.PredefinedFile at its root) as
// read at the commit built, when it has one (novox/hq issue 630): what the controller keeps as the
// list mesh and node config hold values for. Empty for a repository without one.
Predefined json.RawMessage
}
// BuildSource is the build source a build read in one repository (novox/hq ADR 0267).
@@ -238,25 +233,6 @@ func build(ctx context.Context, run Runner, publish Publisher,
say("manifest", "INVALID: %v", err)
return Result{}, err
}
// The repository's list of predefined keys, at its root (novox/hq issue 630): read and judged here,
// where a changed list is first read, and handed on for the controller to keep. A preference that
// falls back to a key the list does not hold, or with another type, is refused here as at registration.
var predefined json.RawMessage
if raw, err := os.ReadFile(filepath.Join(tree, catalogue.PredefinedFile)); err == nil {
list, err := catalogue.ParsePredefined(raw)
if err != nil {
say("manifest", "INVALID: %v", err)
return Result{}, err
}
if problems := catalogue.PredefinedProblems(manifest, list); len(problems) > 0 {
err := fmt.Errorf("%s", strings.Join(problems, "; "))
say("manifest", "INVALID: %v", err)
return Result{}, err
}
predefined, _ = json.Marshal(list)
} else if !errors.Is(err, os.ErrNotExist) {
return Result{}, fmt.Errorf("%s's %s cannot be read: %w", repository, catalogue.PredefinedFile, err)
}
say("manifest", "%s v%s — %d artifact(s)", manifest.Module, manifest.Version, artifactCount(manifest))
// What it is made from, for its source fingerprint: the module's own tree first.
@@ -275,20 +251,6 @@ func build(ctx context.Context, run Runner, publish Publisher,
// never resolve a mesh package — making their image non-deterministic (a needless rollout every
// build) and leaking the credential into a build stage. Absent entirely with no registry, which
// is the bootstrap case (novox/hq ADR 0076).
// **A build that needs the registry and has none is refused before anything is built or
// published** (novox/hq ADR 0336 §6) — before a base is copied into the store, and never built
// without the credential or against the public registry.
if !npmrc.Enabled() {
why, err := needsRegistry(manifest, within)
if err != nil {
return Result{}, err
}
if why != "" {
err := npmrc.unbound(manifest.Module, why)
say("packages", "REFUSED: %v", err)
return Result{}, err
}
}
var npmrcPath string
if npmrc.Enabled() && manifest.Build != nil && wantsPackages(manifest, within) {
content, err := npmrc.File()
@@ -301,7 +263,7 @@ func build(ctx context.Context, run Runner, publish Publisher,
}
// Only for as long as the build: a later check's container mounts this workspace (novox/hq issue 462).
defer os.Remove(npmrcPath)
say("packages", "resolving %s from the mesh's package registry, from %s", npmrc.Scope, fromOr(npmrc.From))
say("packages", "resolving %s from the mesh's package registry", npmrc.Scope)
src.notPinned("it resolves packages from the mesh's registry at build time")
}
@@ -389,7 +351,7 @@ func build(ctx context.Context, run Runner, publish Publisher,
}
return Result{Manifest: resolved, Commit: commit, Built: built,
Against: against(within, manifest, stoodOn), Read: readBy(manifest), Source: fingerprint,
Trunk: trunk, OnTrunk: onTrunk, Branches: branches, Sources: sources, Predefined: predefined}, nil
Trunk: trunk, OnTrunk: onTrunk, Branches: branches, Sources: sources}, nil
}
// branchesHolding is every branch of a fresh clone's origin the commit is on, without `origin/`.
@@ -739,14 +701,6 @@ func against(within string, manifest catalogue.Manifest, resolved []string) []st
return out
}
// fromOr is where a registry was resolved from, as a build's log says it.
func fromOr(from string) string {
if from == "" {
return "what this build was handed"
}
return from
}
// ManifestName is the one file a module repository must have.
//
// At the root, and named the same in every repository. A convention somebody can look for beats a
-106
View File
@@ -2,16 +2,9 @@ package builder
import (
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"net/url"
"os"
"path/filepath"
"sort"
"strings"
"github.com/novox/mesh-controller/internal/catalogue"
)
// Npmrc is what a build needs to resolve the mesh's own shared library — and any other package the
@@ -44,36 +37,8 @@ type Npmrc struct {
// username is the consumer's mesh identity. Ignored when Token is set.
Username string
Password string
// From says where the build agent resolved this registry from (novox/hq ADR 0336 §4): its
// npm-package-registry binding, the genesis document the installer sealed to it, or the
// environment of a builder run by a person. Empty when it resolved none.
From string
// Machine is the machine the build agent runs on, named when a build that needs the registry is
// refused for having none (ADR 0336 §6).
Machine string
}
// Where a build agent resolves the mesh's package registry from (novox/hq ADR 0336 §3, §4).
const (
// FromBinding is the npm-package-registry binding the mesh wrote, with the credential it sealed.
FromBinding = "the npm-package-registry binding"
// FromGenesis is the genesis document the installer sealed at genesis's packages step, read only
// while no binding is present.
FromGenesis = "the genesis document"
// FromEnvironment is the environment of a builder run by a person.
FromEnvironment = "the environment"
)
// RegistryRequirement is the build agent's optional requirement the registry is bound through, and
// RegistryProvider the catalogue module that provides it (novox/hq ADR 0336 §1, §6): named when a
// build that needs the registry is refused, so the outcome says what to assign. The controller's
// catalogue test holds the provider to the catalogue's.
const (
RegistryRequirement = "npm-package-registry"
RegistryProvider = "gitea"
)
// Enabled reports whether there is a registry to resolve against at all. A bootstrap build that
// runs before any package registry exists has none, and must still build whatever needs no
// mesh-published dependency.
@@ -125,77 +90,6 @@ func (n Npmrc) File() (string, error) {
return fmt.Sprintf("%s:registry=%s\n%s", scope, reg, auth), nil
}
// needsRegistry says why this module's build needs the mesh's package registry, or "" when it needs
// none (novox/hq ADR 0336 §5): a `package` artifact, an image whose Dockerfile names .npmrc, or a
// TypeScript artifact with an `@novox/` dependency. Every Go artifact, every image that does not name
// .npmrc, and every image pulled upstream builds without it.
func needsRegistry(manifest catalogue.Manifest, within string) (string, error) {
if manifest.Build == nil {
return "", nil
}
for _, a := range manifest.Build.Artifacts {
switch {
case a.Kind == catalogue.ArtifactPackage:
return fmt.Sprintf("%s is a package, published to the registry", a.Name), nil
case a.Kind == catalogue.ArtifactImage:
raw, err := os.ReadFile(filepath.Join(within, a.From))
if err == nil && strings.Contains(string(raw), ".npmrc") {
return fmt.Sprintf("%s's Dockerfile names .npmrc", a.Name), nil
}
case a.Language == "typescript":
scoped, err := scopedDependencies(within)
if err != nil {
return "", err
}
if len(scoped) > 0 {
return fmt.Sprintf("%s is TypeScript and depends on %s", a.Name, strings.Join(scoped, ", ")), nil
}
}
}
return "", nil
}
// scopedDependencies is every `@novox/` package the module's package.json depends on, the SDK
// included.
func scopedDependencies(tree string) ([]string, error) {
raw, err := os.ReadFile(filepath.Join(tree, "package.json"))
if errors.Is(err, os.ErrNotExist) {
return nil, nil
}
if err != nil {
return nil, err
}
var p struct {
Dependencies map[string]string `json:"dependencies"`
}
if err := json.Unmarshal(raw, &p); err != nil {
return nil, fmt.Errorf("the module's package.json is not JSON: %w", err)
}
var names []string
for name := range p.Dependencies {
if strings.HasPrefix(name, "@novox/") {
names = append(names, name)
}
}
sort.Strings(names)
return names, nil
}
// unbound is the refusal of a build that needs the registry on a build agent that has none (novox/hq
// ADR 0336 §6): it names the machine, the requirement left unbound there and the module that provides
// it. Nothing is built, nothing is published, and the `@novox` scope is never resolved from the
// public registry instead.
func (n Npmrc) unbound(module, why string) error {
machine := n.Machine
if machine == "" {
machine = "this machine"
}
return fmt.Errorf("%s needs the mesh's package registry (%s), and %s is unbound on %s: the build "+
"agent there has neither its binding nor the genesis document. It is provided by the %s module; "+
"once %s is assigned the requirement binds (novox/hq ADR 0336 §6)",
module, why, RegistryRequirement, machine, RegistryProvider, RegistryProvider)
}
// packageRecipe is how a `package` artifact is built and published: on a PUBLIC base image, never
// the mesh toolchain, because the toolchain is built from the package this produces (the SDK). The
// script builds the module, then publishes it to the mesh's package registry unless that exact
-122
View File
@@ -1,122 +0,0 @@
package builder
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"github.com/novox/mesh-controller/internal/beside"
"github.com/novox/mesh-controller/internal/catalogue"
)
// **A build that needs the registry fails loudly while the build agent has none** (novox/hq ADR 0336
// §5, §6, issue 121). On a holder with neither the npm-package-registry binding nor the genesis
// document, a `package` artifact, an image whose Dockerfile names .npmrc and a TypeScript artifact
// with an `@novox/` dependency each fail before anything is built or published, naming the machine,
// the unbound requirement and the module that provides it. A Go artifact, and an image that does not
// name .npmrc, build there as before.
func TestABuildNeedingTheRegistryIsRefusedOnAnAgentWithoutOne(t *testing.T) {
none := Npmrc{Machine: "workstation"}
cases := map[string]struct {
manifest string
files map[string]string
held map[string]string
}{
"a package": {aPackage, map[string]string{"package.json": `{"name":"@novox/mesh-sdk","version":"0.1.0"}`}, nil},
"an image naming .npmrc": {withBoth, map[string]string{
"Dockerfile": "FROM scratch\nCOPY .npmrc /root/.npmrc\n", "files/x": "y"}, nil},
"a TypeScript bundle depending on the SDK": {aBundle, map[string]string{
"index.ts": "console.log(1)", "package.json": `{"dependencies":{"@novox/mesh-sdk":"^0.1.0"}}`},
map[string]string{"mesh-tools/build": "registry.invalid/mesh-tools/build@sha256:" + strings.Repeat("b", 64)}},
}
for name, c := range cases {
t.Run(name, func(t *testing.T) {
r, workspace := aRepository(t, c.manifest, c.files)
var said []string
_, err := Build(context.Background(), compiling{r}.run, r,
"https://forge.invalid/m.git", "", "", workspace, c.held, none, GitCredential{},
func(step, message string) { said = append(said, step+": "+message) })
if err == nil {
t.Fatal("built with no package registry")
}
for _, want := range []string{"workstation", "npm-package-registry", "unbound", "gitea"} {
if !strings.Contains(err.Error(), want) {
t.Errorf("the refusal does not say %q: %v", want, err)
}
}
for _, line := range r.ran {
if strings.HasPrefix(line, "docker build") || strings.HasPrefix(line, "docker run") ||
strings.HasPrefix(line, "docker pull") {
t.Errorf("something was built after the refusal: %s", line)
}
}
if len(r.images) != 0 || len(r.archives) != 0 {
t.Errorf("something was published after the refusal: %v %v", r.images, r.archives)
}
if !strings.Contains(strings.Join(said, "\n"), "REFUSED") {
t.Errorf("the build's log does not say it was refused:\n%s", strings.Join(said, "\n"))
}
})
}
// A Go artifact builds on the same agent.
files := aSharedRepository("one", "1")
manifest := `{"module":"ctl","version":"1","build":{"artifacts":[
{"name":"controller","kind":"bundle","language":"go","system":"arch","from":"cmd/ctl","binary":"ctl"}]},
"resources":[{"id":"controller","type":"process","name":"ctl","artifact":"controller","run":["./ctl"]}]}`
r := &recorded{contents: map[string]string{ManifestName: manifest}}
for k, v := range files {
r.contents[k] = v
}
held := map[string]string{"mesh-tools-go/build": "registry.invalid/mesh-tools-go/build@sha256:" + strings.Repeat("b", 64)}
if _, err := Build(context.Background(), onTrunk{recorded: r}.run, r,
"https://forge.invalid/ctl.git", "", "", t.TempDir(), held, none, GitCredential{}, nil); err != nil {
t.Fatalf("a Go artifact did not build on an agent without the registry: %v", err)
}
// And an image that does not name .npmrc.
r, workspace := aRepository(t, withBoth, map[string]string{"Dockerfile": "FROM scratch\nCOPY . .", "files/x": "y"})
if _, err := Build(context.Background(), r.run, r,
"https://forge.invalid/meshboard.git", "", "", workspace, nil, none, GitCredential{}, nil); err != nil {
t.Fatalf("an image not naming .npmrc did not build on an agent without the registry: %v", err)
}
}
// **The build's log says which registry it resolved from** (novox/hq ADR 0336 §4).
func TestABuildSaysWhichRegistryItResolvedFrom(t *testing.T) {
for _, from := range []string{FromBinding, FromGenesis} {
r, workspace := aRepository(t, aPackage, map[string]string{
"package.json": `{"name":"@novox/mesh-sdk","version":"0.1.0"}`,
})
n := Npmrc{Scope: "@novox", Registry: "http://127.0.0.1:3000/api/packages/novox/npm/",
Username: "mesh-builder", Password: "p", From: from, Machine: "anchor"}
var said []string
if _, err := Build(context.Background(), r.run, r, "https://forge.invalid/mesh-sdk.git", "", "",
workspace, nil, n, GitCredential{}, func(step, message string) { said = append(said, step+": "+message) }); err != nil {
t.Fatalf("%s: %v", from, err)
}
if !strings.Contains(strings.Join(said, "\n"), "from "+from) {
t.Errorf("the log does not say the registry came from %s:\n%s", from, strings.Join(said, "\n"))
}
}
}
// The refusal names the module that provides the registry; it is the catalogue's provider.
func TestTheRegistryABuildIsRefusedForNamesItsProvider(t *testing.T) {
raw, err := os.ReadFile(filepath.Join(beside.Catalogue(t), RegistryProvider, "module.json"))
if err != nil {
t.Fatal(err)
}
forge, err := catalogue.ParseManifest(raw)
if err != nil {
t.Fatal(err)
}
provides := false
for _, p := range forge.Provides {
provides = provides || p.Name == RegistryRequirement
}
if !provides {
t.Fatalf("%s does not provide %s, which a refused build names it for", RegistryProvider, RegistryRequirement)
}
}
-152
View File
@@ -1,152 +0,0 @@
package builder
import (
"context"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
// TestReplay363 is the replay of novox/hq issue 363's built part (ADR 0267 rule 6), written with only what the
// builder had before its fix (mesh-controller #292), so mesh-lab's prover can lay it over the commit before.
//
// A Go module built from its repository's root — the controller, the node-engine — is compiled with the clone's
// `.git` in view, and Go stamped the commit, its time and whether the tree was modified into the binary. On
// 2026-10-13 the node-engine running on the laptop carried `vcs.revision=7a41e120…` in its build info, so a merge
// that changed only its README made a new digest of an unchanged program. Before the fix the two builds below give
// two digests; after it (`-buildvcs=false` in the Go toolchain) they give one.
//
// It runs the builder whole, Build to the published bundle, with a container runtime that runs the toolchain's
// command on this machine: the module's directory mounted where the toolchain would see it, the real git and the
// real Go compiler. Two builds of one module at two commits that differ only in README.md, outside its build source.
func TestReplay363(t *testing.T) {
for _, tool := range []string{"go", "git"} {
if _, err := exec.LookPath(tool); err != nil {
t.Skipf("%s is not on this machine: %v", tool, err)
}
}
root := t.TempDir()
origin := filepath.Join(root, "origin")
files := map[string]string{
"module.json": `{"module":"ctl","version":"1","build":{"artifacts":[` +
`{"name":"runtime","kind":"bundle","language":"go","system":"arch","from":"cmd/ctl","binary":"ctl"}]},` +
`"resources":[{"id":"runtime","type":"process","name":"ctl","artifact":"runtime","run":["./ctl"]}]}`,
"go.mod": "module example.com/ctl\n\ngo 1.22\n",
"cmd/ctl/main.go": "package main\n\nfunc main() { println(\"ctl\") }\n",
"README.md": "one\n",
}
for name, body := range files {
if err := os.MkdirAll(filepath.Dir(filepath.Join(origin, name)), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(origin, name), []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
git := func(dir string, args ...string) string {
t.Helper()
cmd := exec.Command("git", append([]string{"-c", "user.name=t", "-c", "user.email=t@example.invalid",
"-c", "commit.gpgsign=false", "-c", "init.defaultBranch=main"}, args...)...)
cmd.Dir = dir
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, out)
}
return strings.TrimSpace(string(out))
}
git(origin, "init", "-q")
git(origin, "add", ".")
git(origin, "commit", "-q", "-m", "one")
first := git(origin, "rev-parse", "HEAD")
if err := os.WriteFile(filepath.Join(origin, "README.md"), []byte("two\n"), 0o644); err != nil {
t.Fatal(err)
}
git(origin, "commit", "-q", "-am", "only the README")
second := git(origin, "rev-parse", "HEAD")
held := map[string]string{"mesh-tools-go/build": "registry.invalid/mesh-tools-go/build@sha256:" + strings.Repeat("b", 64)}
build := func(commit, at string) string {
t.Helper()
got, err := Build(context.Background(), replay363Runtime(t), replay363Store{}, origin, "", commit,
filepath.Join(root, at), held, Npmrc{}, GitCredential{}, nil)
if err != nil {
t.Fatalf("the build at %s: %v", commit[:12], err)
}
if len(got.Built) != 1 || got.Built[0].Digest == "" {
t.Fatalf("the build at %s made %+v", commit[:12], got.Built)
}
return got.Built[0].Digest
}
a, b := build(first, "one"), build(second, "elsewhere/two")
if a != b {
t.Fatalf("one build source, two digests: %s at %s, %s at %s — a README-only merge is a new program",
a, first[:12], b, second[:12])
}
}
// replay363Store publishes nothing: the digest is the builder's own, over the packed bundle.
type replay363Store struct{}
func (replay363Store) PublishImage(_ context.Context, local, repository string) (string, error) {
return "registry.invalid/" + repository + "@sha256:" + strings.Repeat("c", 64), nil
}
func (replay363Store) PublishArchive(_ context.Context, repository string, _ []byte, digest string) (string, error) {
return "artifact-store://" + repository + "@" + digest, nil
}
// replay363Runtime runs every command on this machine, and a toolchain container's command in the directory
// mounted for it, as the container would see it: nothing above the mounted directory.
func replay363Runtime(t *testing.T) Runner {
t.Helper()
return func(ctx context.Context, dir, name string, args ...string) (string, error) {
if name == "docker" && len(args) > 0 && args[0] == "run" {
mounts := map[string]string{}
workdir := ""
i := 1
for i < len(args) {
switch args[i] {
case "--rm":
i++
continue
case "--volume":
host, inside, _ := strings.Cut(args[i+1], ":")
mounts[inside] = host
i += 2
continue
case "--workdir":
workdir = args[i+1]
i += 2
continue
case "--network", "--user", "--env", "-e", "--label", "--name":
i += 2
continue
}
break
}
command := args[i+1:] // the image is args[i]
where := dir
for inside, host := range mounts {
if workdir == inside {
where = host
} else if rest, ok := strings.CutPrefix(workdir, inside+"/"); ok {
where = filepath.Join(host, rest)
}
}
cmd := exec.CommandContext(ctx, command[0], command[1:]...)
cmd.Dir = where
cmd.Env = append(os.Environ(), "GOTOOLCHAIN=local", "GOPROXY=off", "GOWORK=off", "GOFLAGS=")
out, err := cmd.CombinedOutput()
if err != nil {
return string(out), &exec.ExitError{ProcessState: cmd.ProcessState, Stderr: out}
}
return string(out), nil
}
cmd := exec.CommandContext(ctx, name, args...)
cmd.Dir = dir
out, err := cmd.Output()
return string(out), err
}
}
-145
View File
@@ -1,145 +0,0 @@
package builder
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
// **A Go bundle does not vary with the commit it was built at** (novox/hq ADR 0267 rule 6, issue 363). A
// module built from its repository's root — the controller — is compiled with the clone's `.git` in what
// the toolchain sees, and Go stamps the commit, its time and whether the tree was modified into every
// binary built there unless told not to. Two builds of one program at commits that differ only in a
// README then made two binaries, so an unchanged build source was a new digest.
//
// The toolchain's own compile line is run here, as the build seat runs it inside the toolchain image: in
// two clones at two paths, of two commits that differ outside the program. One digest is wanted.
func TestAGoBundleBuiltTwiceFromOneBuildSourceIsOneDigest(t *testing.T) {
for _, tool := range []string{"go", "git"} {
if _, err := exec.LookPath(tool); err != nil {
t.Skipf("%s is not on this machine: %v", tool, err)
}
}
chain, err := ToolchainFor("go")
if err != nil {
t.Fatal(err)
}
root := t.TempDir()
repo := filepath.Join(root, "origin")
files := map[string]string{
"go.mod": "module example.com/ctl\n\ngo 1.22\n",
"cmd/ctl/main.go": "package main\n\nfunc main() { println(\"ctl\") }\n",
"README.md": "one\n",
}
for name, body := range files {
if err := os.MkdirAll(filepath.Dir(filepath.Join(repo, name)), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(repo, name), []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
git := func(dir string, args ...string) string {
t.Helper()
cmd := exec.Command("git", append([]string{"-c", "user.name=t", "-c", "user.email=t@example.invalid",
"-c", "commit.gpgsign=false", "-c", "init.defaultBranch=main"}, args...)...)
cmd.Dir = dir
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, out)
}
return strings.TrimSpace(string(out))
}
git(repo, "init", "-q")
git(repo, "add", ".")
git(repo, "commit", "-q", "-m", "one")
first := git(repo, "rev-parse", "HEAD")
if err := os.WriteFile(filepath.Join(repo, "README.md"), []byte("two\n"), 0o644); err != nil {
t.Fatal(err)
}
git(repo, "commit", "-q", "-am", "only the README")
second := git(repo, "rev-parse", "HEAD")
build := func(commit, at string) string {
t.Helper()
clone := filepath.Join(root, at)
git(root, "clone", "-q", repo, clone)
git(clone, "checkout", "-q", commit)
out := filepath.Join(clone, Out("server"), "ctl")
line := append([]string(nil), chain.Compile...)
line = append(line, "-ldflags", strings.Join(chain.LinkerFlags, " "), chain.OutputFlag, out, "./cmd/ctl")
cmd := exec.Command(line[0], line[1:]...)
cmd.Dir = clone
cmd.Env = append(os.Environ(), "GOTOOLCHAIN=local", "GOPROXY=off", "GOWORK=off")
if b, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("%s: %v\n%s", strings.Join(line, " "), err, b)
}
body, err := os.ReadFile(out)
if err != nil {
t.Fatal(err)
}
if bytes.Contains(body, []byte(commit)) {
t.Errorf("the binary built at %s carries the commit it was built at", commit[:12])
}
sum := sha256.Sum256(body)
return hex.EncodeToString(sum[:])
}
a, b := build(first, "a"), build(second, "elsewhere/b")
if a != b {
t.Fatalf("one build source, two digests: %s at %s, %s at %s (compiled with %q)",
a[:12], first[:12], b[:12], second[:12], strings.Join(chain.Compile, " "))
}
}
// The toolchain says so itself, so a reader of the compile line sees it without running a build.
func TestTheGoToolchainStampsNoVersionControl(t *testing.T) {
chain, err := ToolchainFor("go")
if err != nil {
t.Fatal(err)
}
if joined := strings.Join(chain.Compile, " "); !strings.Contains(joined, "-buildvcs=false") {
t.Fatalf("the go toolchain stamps the commit into what it compiles: %q", joined)
}
}
// **And so does every recipe in this repository that compiles Go** (review of issue 363's fix): the
// controller's genesis image copies the whole clone, `.git` with it, and the reference images beside it
// are built the same way by the Makefile.
func TestEveryGoRecipeInThisRepositoryStampsNoVersionControl(t *testing.T) {
root := filepath.Join("..", "..")
var recipes []string
err := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() && (d.Name() == "vendor" || d.Name() == "testdata" || d.Name() == ".git") {
return filepath.SkipDir
}
if !d.IsDir() && d.Name() == "Dockerfile" {
recipes = append(recipes, path)
}
return nil
})
if err != nil {
t.Fatal(err)
}
if len(recipes) == 0 {
t.Fatal("no recipe found in this repository")
}
for _, path := range recipes {
body, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
for _, line := range strings.Split(string(body), "\n") {
if strings.Contains(line, "go build") && !strings.Contains(line, "-buildvcs=false") {
t.Errorf("%s compiles Go and stamps the commit into it: %s", path, strings.TrimSpace(line))
}
}
}
}
+1 -6
View File
@@ -167,13 +167,8 @@ var toolchains = []Toolchain{
// binary that needs a libc it did not bring is a delivery that works until a machine
// differs. Trimmed of its own paths for the same reason a version comes from where it sits
// rather than from the linker: two builds of one commit produce the same bytes.
//
// **And of one build source at two commits** (novox/hq ADR 0267 rule 6, issue 363): a module built
// from its repository's root is compiled with the clone's `.git` in view, and Go stamps the commit,
// its time and whether the tree was modified into the binary unless told not to — so a merge that
// changed only a README made a new digest of an unchanged program.
Compile: []string{
"env", "CGO_ENABLED=0", "GOFLAGS=-trimpath -buildvcs=false",
"env", "CGO_ENABLED=0", "GOFLAGS=-trimpath",
"go", "build",
},
// Stripped of symbols and debug info: what a machine holds is a file it runs, not one it
-5
View File
@@ -87,11 +87,6 @@ func Openings(rules []Rule, foundation []int, published map[string]map[int]int)
widen(key{rule.Protocol, rule.Port}, OpeningFromEverywhere)
case FromMesh:
widen(key{rule.Protocol, rule.Port}, OpeningFromMesh)
case 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}, OpeningFromMesh)
}
}
for _, port := range foundation {
+4 -10
View File
@@ -135,12 +135,6 @@ type Rendering struct {
// composes no filter for it rather than writing a rule around a link with no name.
OutwardLinks []string
// LocalNetworks is this machine's local networks (novox/hq ADR 0340): the networks it reported on a
// link facing outside it that lie wholly in a private range (JudgeLocalNetworks), which a port
// reached from `local-network` is admitted from beside the private network. Empty when it reported
// none, and the rule set then says so rather than widening the port or dropping it.
LocalNetworks []string
// TunnelInterface is the interface the mesh's private network runs on, named here rather than
// imported because the overlay package rests on this one. Traffic arriving on it is the mesh's,
// not this machine's own guest, so the filter admits it only by a rule.
@@ -533,8 +527,8 @@ func (r Resolution) compose(with Rendering, owner map[string]string,
"`node show %s` says whether it has. Until then %s is not sent, and the machine "+
"keeps the filter it has", r.Node, r.Node, filters)
}
filtering := AsNftablesWithLocal(rules, with.Mesh, r.PublicDomain != "", with.Foundation,
with.OutwardLinks, with.TunnelInterface, with.LocalNetworks)
filtering := AsNftables(rules, with.Mesh, r.PublicDomain != "", with.Foundation,
with.OutwardLinks, with.TunnelInterface)
// **A variable two modules set is refused whether or not anything places it** (novox/hq ADR
// 0203 §5): the account has one environment, and a machine whose holder arrives later should not
@@ -2557,8 +2551,8 @@ func onlyGroups(r map[string]any) bool {
// generatedHere is what each computed module's generator answers for this machine, by module —
// absent for a module whose machine is not yet part of what it generates — held to the rule every
// module is held to: no two modules on a machine declare one path, unit or name, nor one package present
// and absent (novox/hq issue 190, step 5; ADR 0222, ADR 0303).
// module is held to: no two modules on a machine declare one path, unit, name or package (novox/hq
// issue 190, step 5; ADR 0222).
//
// Resolution checks the catalogue's manifests, and a computed module's manifest has none of the
// resources it will declare — they exist only once its generator has answered for this machine,
+6 -63
View File
@@ -152,10 +152,9 @@ func Exposure(m Manifest, layers []Layer) (map[int]string, error) {
m.Module, port)
}
source, ok := value.(string)
if !ok || (source != FromMesh && source != FromEverywhere && source != FromMachine &&
source != FromLocalNetwork) {
return nil, fmt.Errorf("%s exposes port %d as %v; it is %q, %q, %q or %q",
m.Module, port, value, FromMesh, FromEverywhere, FromMachine, FromLocalNetwork)
if !ok || (source != FromMesh && source != FromEverywhere && source != FromMachine) {
return nil, fmt.Errorf("%s exposes port %d as %v; it is %q, %q or %q",
m.Module, port, value, FromMesh, FromEverywhere, FromMachine)
}
out[port] = source
}
@@ -173,9 +172,7 @@ func Exposure(m Manifest, layers []Layer) (map[int]string, error) {
// 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 []Rule) []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{FromMachine: 0, FromMesh: 1, FromLocalNetwork: 2, FromEverywhere: 3}
reach := map[string]int{FromMachine: 0, FromMesh: 1, FromEverywhere: 2}
var out []Rule
for _, rule := range rules {
covered := false
@@ -235,15 +232,6 @@ const SSHPort = 22
// any module asks for, and neither may be derived away.
func AsNftables(rules []Rule, mesh []string, outward bool, foundation []int,
outwardLinks []string, tunnel string) string {
return AsNftablesWithLocal(rules, mesh, outward, foundation, outwardLinks, tunnel, nil)
}
// AsNftablesWithLocal is AsNftables with the machine's local networks: the networks it reported on a
// link facing outside that count (novox/hq ADR 0340, JudgeLocalNetworks), which a rule from
// `local-network` admits on its own port alone, beside the private network. None is not widened to
// anywhere and not dropped silently: the rule set says the port wanted the local network and found none.
func AsNftablesWithLocal(rules []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.
@@ -406,31 +394,6 @@ func AsNftablesWithLocal(rules []Rule, mesh []string, outward bool, foundation [
b.WriteString(fmt.Sprintf("\t\tip6 saddr { %s } %s dport %d accept\n",
strings.Join(six, ", "), rule.Protocol, rule.Port))
}
case 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 FromEverywhere:
b.WriteString(fmt.Sprintf("\t\t%s dport %d accept\n", rule.Protocol, rule.Port))
}
@@ -524,18 +487,6 @@ func AsNftablesWithLocal(rules []Rule, mesh []string, outward bool, foundation [
b.WriteString(fmt.Sprintf("\t\tip6 saddr { %s } ct original proto-dst %d accept\n",
strings.Join(six, ", "), rule.Port))
}
case 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 FromEverywhere:
b.WriteString(fmt.Sprintf("\t\tct original proto-dst %d accept\n", rule.Port))
}
@@ -842,14 +793,10 @@ const (
// are a subset of anywhere. What differs is the names, which is the whole reason reach is not
// simply the filter's vocabulary with nicer words.
ReachBoth = "both"
// ReachLocalNetwork is `internal` with the machine's own local networks added to what the filter
// opens (novox/hq ADR 0340 §4): the internal name and not the public one, the mesh's own authority,
// and the port opened to the private network and to the private networks the machine is attached to.
ReachLocalNetwork = "local-network"
)
// reaches is every value, in the order a refusal lists them.
var reaches = []string{ReachMachine, ReachInternal, ReachPublic, ReachBoth, ReachLocalNetwork}
var reaches = []string{ReachMachine, ReachInternal, ReachPublic, ReachBoth}
// RoutedPorts are the ports a module serves through a proxy, taken from its route contributions.
//
@@ -898,8 +845,6 @@ func FilterSource(reach string) (string, bool) {
return FromMesh, true
case ReachPublic, ReachBoth:
return FromEverywhere, true
case ReachLocalNetwork:
return FromLocalNetwork, true
default:
return "", false
}
@@ -909,9 +854,7 @@ func FilterSource(reach string) (string, bool) {
func WantsPublicName(reach string) bool { return reach == ReachPublic || reach == ReachBoth }
// WantsInternalName is whether a reach asks for the route's internal name to be composed.
func WantsInternalName(reach string) bool {
return reach == ReachInternal || reach == ReachBoth || reach == ReachLocalNetwork
}
func WantsInternalName(reach string) bool { return reach == ReachInternal || reach == ReachBoth }
// Reaches reads a module's per-node reach settings: declared port → how far it reaches.
//
@@ -6,8 +6,7 @@ import (
)
// novox/hq issue 190, step 5 (ADR 0222): what the mesh computes for a module is held to the rule
// every module is — no two modules on a machine declare one path, unit or name (a package may be declared by
// several, novox/hq ADR 0303). The
// every module is — no two modules on a machine declare one path, unit, name or package. The
// private network once declared the container runtime's file and service beside the runtime's own
// module, and nothing refused it, because the collision check only ever saw catalogue manifests.
@@ -1,85 +0,0 @@
package catalogue
import (
"strings"
"testing"
)
// Genesis assigns the build agent before the package registry exists (novox/hq ADR 0336, issue 121),
// judged over the real catalogue. Genesis's step 13 was refused with "nothing provides
// \"npm-package-registry\", wanted by build-agent": the forge genesis raises at its packages step is a
// container, not the gitea module, and provides nothing.
// agentBuilt is the build agent as the mesh holds it once built: its image pinned.
func agentBuilt(t *testing.T) Manifest {
t.Helper()
agent := catalogueManifest(t, "build-agent")
built, err := agent.Resolve([]Built{{Name: "server", Kind: ArtifactImage,
Reference: "mesh-builder@sha256:" + strings.Repeat("a", 64)}})
if err != nil {
t.Fatalf("the build agent does not resolve against its own build: %v", err)
}
return built
}
func TestGenesisAssignsTheBuildAgentWithNothingProvidingTheRegistry(t *testing.T) {
agent := agentBuilt(t)
if !agent.IsOptional("npm-package-registry") {
t.Fatalf("the build agent's npm-package-registry is hard (ADR 0336 §1): %v", agent.Requires)
}
if agent.IsOptional("artifact-store") {
t.Fatal("the build agent's artifact-store is optional; it stays hard")
}
store := map[string][]Provider{"artifact-store": {
{Node: "anchor", At: "anchor.internal", Module: "distribution", Serves: map[string]any{"port": float64(5000)}}}}
books := shelf(agent, catalogueManifest(t, "gitea"), catalogueManifest(t, "distribution"))
// Genesis's node at step 13: the store answers, nothing provides the registry.
got, err := Resolve(books, []string{"build-agent"}, reachable(), World{Offered: store})
if err != nil {
t.Fatalf("assigning the build agent with no registry was refused: %v", err)
}
if have := strings.Join(names(got), " "); have != "build-agent" {
t.Fatalf("assigning the build agent put %s on the machine; nothing is installed for the registry", have)
}
if len(got.Absent) != 1 || got.Absent[0] != (Absent{Consumer: "build-agent", Requirement: "npm-package-registry"}) {
t.Fatalf("the registry is not listed unbound for the build agent: %+v", got.Absent)
}
out, err := got.Declaration(Rendering{ArtifactStore: "anchor.internal:5000",
Needed: map[string]map[string]string{"build-agent": {"broker": "sealed-broker"}}})
if err != nil {
t.Fatalf("the build agent does not compose with the registry unbound: %v", err)
}
for _, id := range []string{"build-agent." + BoundID("npm-package-registry"), "build-agent." + SecretID("npm-package-registry"),
"build-agent." + NeedID("genesis-package-registry")} {
if fileNamed(out, id) != nil {
t.Errorf("%s was delivered with the registry unbound and no genesis document given", id)
}
}
// The genesis document, once the installer seals it at the packages step (ADR 0336 §3).
out, err = got.Declaration(Rendering{ArtifactStore: "anchor.internal:5000",
Needed: map[string]map[string]string{"build-agent": {"broker": "sealed-broker", "genesis-package-registry": "sealed-genesis"}}})
if err != nil {
t.Fatal(err)
}
doc := fileNamed(out, "build-agent."+NeedID("genesis-package-registry"))
if doc == nil || doc["sealed"] != "sealed-genesis" || !strings.HasSuffix(doc["path"].(string), "/genesis-package-registry.json") {
t.Fatalf("the genesis document is not delivered where the agent reads it: %v", doc)
}
// The gitea module assigned: the requirement binds, with no act on the build agent (ADR 0336 §2).
registry := map[string][]Provider{"artifact-store": store["artifact-store"], "npm-package-registry": {
{Node: "anchor", At: "anchor.internal", Module: "gitea",
Serves: map[string]any{"port": float64(3000), "scheme": "http", "npm-path": "/api/packages/novox/npm/"}}}}
bound, err := Resolve(books, []string{"build-agent"}, reachable(), World{Offered: registry})
if err != nil {
t.Fatal(err)
}
if n := boundFrom(t, bound, "build-agent", "npm-package-registry"); n.Module != "gitea" {
t.Fatalf("bound to %s", n.Module)
}
if len(bound.Absent) != 0 {
t.Fatalf("the registry is still listed unbound once gitea is assigned: %+v", bound.Absent)
}
}
+2 -2
View File
@@ -25,13 +25,13 @@ func TestAnOfferMaySayItsSoftwareIssuesTheCredential(t *testing.T) {
}
for want, raw := range map[string]string{
"the issuers an offer may name": `{"module":"d","version":"1",
"the one issuer an offer may name": `{"module":"d","version":"1",
"provides":[{"name":"d-api","credential":{"issued-by":"vendor","tool":"d_token"}}]}`,
"names no tool that answers it": `{"module":"d","version":"1",
"provides":[{"name":"d-api","credential":{"issued-by":"software"}}]}`,
"names its own secret": `{"module":"d","version":"1","own-secrets":{"token":{"path":"/p","taken":"at-start"}},
"provides":[{"name":"d-api","credential":{"issued-by":"software","tool":"d_token","own":"token"}}]}`,
"the issuers an offer may name ": `{"module":"d","version":"1",
"the one issuer an offer may name ": `{"module":"d","version":"1",
"provides":[{"name":"d-api","credential":{"tool":"d_token"}}]}`,
} {
want = strings.TrimSpace(want)
-46
View File
@@ -1,46 +0,0 @@
package catalogue
import (
"strings"
"testing"
)
// A provision's credential may be one only a person outside the mesh can give (novox/hq ADR 0337 §1, issue 164):
// the offer says `issued-by: outside` and names neither an own secret nor a tool, and the parser refuses it beside
// either. An offer that says `software` keeps ADR 0305's shape.
func TestAnOfferMaySayAPersonOutsideTheMeshIssuesItsCredential(t *testing.T) {
m, err := ParseManifest([]byte(`{"module":"tv","version":"1",
"provides":[{"name":"tv-api","scope":"mesh","credential":{"issued-by":"outside"}}],
"serves":{"tv-api":{"port":8989}}}`))
if err != nil {
t.Fatal(err)
}
if !m.IssuedOutsideOf("tv-api") {
t.Fatal("the offer's credential issued outside was not read")
}
if m.IssuedOutsideOf("other") {
t.Fatal("a provision the module does not offer reads as issued outside")
}
if _, issued := m.IssuedCredentialOf("tv-api"); issued {
t.Fatal("a credential issued outside was read as issued by the software")
}
if _, shared := m.SharedCredentialOf("tv-api"); shared {
t.Fatal("a credential issued outside was read as an own secret shared")
}
if who, offered := OffersIssuedOutside(map[string]Manifest{"tv": m}, "tv-api"); !offered || who != "tv" {
t.Fatalf("the catalogue does not say tv offers tv-api issued outside: %q %v", who, offered)
}
for want, raw := range map[string]string{
"names its own secret": `{"module":"d","version":"1","own-secrets":{"key":{"path":"/p","taken":"at-start"}},
"provides":[{"name":"d-api","credential":{"issued-by":"outside","own":"key"}}]}`,
"names the tool": `{"module":"d","version":"1",
"provides":[{"name":"d-api","credential":{"issued-by":"outside","tool":"d_key"}}]}`,
"the issuers an offer may name": `{"module":"d","version":"1",
"provides":[{"name":"d-api","credential":{"issued-by":"a-person"}}]}`,
} {
if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), want) {
t.Errorf("expected a refusal saying %q, got %v", want, err)
}
}
}
-154
View File
@@ -1,154 +0,0 @@
package catalogue
import (
"fmt"
"net/netip"
"slices"
"sort"
"strings"
)
// AttachedNetwork is one network a machine said it is directly attached to: the link it is on and the
// network as address and prefix (novox/hq ADR 0340 §7), as the node-engine reports it among its facts.
type AttachedNetwork struct {
Link string `json:"link"`
Network string `json:"network"`
}
// LocalNetwork is one attached network judged: whether it counts as this machine's local network for a
// port reached from `local-network`, and why — said for one that counts as well as for one that does
// not, so `node` can show which counted and why the others did not (ADR 0340 §8).
type LocalNetwork struct {
Link string
Network string
Counts bool
Why string
}
// privateRanges are the only ranges a local network may lie in (ADR 0340 §2): RFC 1918 for IPv4 and the
// unique local addresses for IPv6. A network counts only when it lies wholly inside one of them.
var privateRanges = []struct {
prefix netip.Prefix
name string
}{
{netip.MustParsePrefix("10.0.0.0/8"), "a private range (RFC 1918, 10.0.0.0/8)"},
{netip.MustParsePrefix("172.16.0.0/12"), "a private range (RFC 1918, 172.16.0.0/12)"},
{netip.MustParsePrefix("192.168.0.0/16"), "a private range (RFC 1918, 192.168.0.0/16)"},
{netip.MustParsePrefix("fc00::/7"), "the IPv6 unique local range (fc00::/7)"},
}
// JudgeLocalNetworks says, for each network a machine reported, whether it counts as its local network.
//
// **A public subnet never counts** (ADR 0340 §2): a network counts only when it lies wholly in a private
// range, so `local-network` can never open a port to the internet — a machine sitting on a public /24 has
// that /24 judged and refused, never rendered. A link-local network does not count either. And only a
// link the machine reported as facing outside it counts (§3): the private network's own link and a
// container bridge are this machine's own, not its local network, whatever their addresses.
//
// Sorted by link and then network, one entry per (link, network), so what `node` shows and what the
// filter is rendered from do not depend on the order a machine happened to say them in.
func JudgeLocalNetworks(attached []AttachedNetwork, outward []string, tunnel string) []LocalNetwork {
seen := map[[2]string]bool{}
var out []LocalNetwork
for _, a := range attached {
key := [2]string{a.Link, a.Network}
if seen[key] {
continue
}
seen[key] = true
out = append(out, judgeOne(a, outward, tunnel))
}
sort.Slice(out, func(i, j int) bool {
if out[i].Link != out[j].Link {
return out[i].Link < out[j].Link
}
return out[i].Network < out[j].Network
})
return out
}
func judgeOne(a AttachedNetwork, outward []string, tunnel string) LocalNetwork {
j := LocalNetwork{Link: a.Link, Network: a.Network}
prefix, err := netip.ParsePrefix(strings.TrimSpace(a.Network))
if err != nil {
j.Why = "not a network the mesh can read (" + err.Error() + ")"
return j
}
prefix = prefix.Masked()
j.Network = prefix.String()
switch {
case tunnel != "" && a.Link == tunnel:
j.Why = "the private network's own link, not the local network"
return j
case !slices.Contains(outward, a.Link):
j.Why = fmt.Sprintf("%s does not face outside this machine (a bridge or a link of its own)", a.Link)
return j
case prefix.Addr().IsLoopback():
j.Why = "loopback"
return j
case prefix.Addr().IsLinkLocalUnicast():
j.Why = "link-local, never counted"
return j
}
for _, r := range privateRanges {
if r.prefix.Contains(prefix.Addr()) && prefix.Bits() >= r.prefix.Bits() {
j.Counts = true
j.Why = "in " + r.name
return j
}
}
if prefix.Addr().IsPrivate() {
// Overlaps a private range without lying wholly inside it (a /7 around 10.0.0.0/8, say).
j.Why = "not wholly in a private range, so never counted"
return j
}
j.Why = "public, never counted: local-network may not open a port to the internet"
return j
}
// CountedLocalNetworks is the networks that count, as the filter renders them: sorted, without repeats.
func CountedLocalNetworks(judged []LocalNetwork) []string {
var out []string
for _, j := range judged {
if j.Counts && !slices.Contains(out, j.Network) {
out = append(out, j.Network)
}
}
sort.Strings(out)
return out
}
// LocalNetworkPort is one port the filter admits from `local-network`, and what that resolved to on this
// machine (ADR 0340 §8): what `plan` shows for it.
type LocalNetworkPort struct {
Port int
Protocol string
Because []string
// Networks is the local networks it is admitted from; empty when the machine reported none (§6).
Networks []string
}
// LocalNetworkPorts is every rule admitted from `local-network`, with the networks it resolved to.
func LocalNetworkPorts(rules []Rule, networks []string) []LocalNetworkPort {
var out []LocalNetworkPort
for _, rule := range rules {
if rule.From != FromLocalNetwork {
continue
}
out = append(out, LocalNetworkPort{Port: rule.Port, Protocol: rule.Protocol,
Because: rule.Because, Networks: networks})
}
return out
}
// Said is how `plan` says one such port: the networks, or that there are none and the port is admitted
// from the private network only.
func (p LocalNetworkPort) Said() string {
by := strings.Join(p.Because, ", ")
if len(p.Networks) == 0 {
return fmt.Sprintf("%s/%d (%s) from the local network: none — this machine reported no private "+
"network on a link facing outside, so it is admitted from the private network only", p.Protocol, p.Port, by)
}
return fmt.Sprintf("%s/%d (%s) from the local network: %s, and the private network", p.Protocol, p.Port, by,
strings.Join(p.Networks, ", "))
}
-195
View File
@@ -1,195 +0,0 @@
package catalogue
import (
"strings"
"testing"
)
// The home server's case (novox/hq issue 154): it sits on 192.168.1.0/24, and also on a public /24 a
// provider handed it, beside the private network's own link and a container bridge.
var homeServer = []AttachedNetwork{
{Link: "eth0", Network: "192.168.1.10/24"},
{Link: "eth1", Network: "203.0.113.7/24"},
{Link: "eth0", Network: "fe80::1/64"},
{Link: "eth0", Network: "fd12:3456:789a::5/64"},
{Link: "wg-mesh", Network: "10.10.0.4/24"},
{Link: "docker0", Network: "172.17.0.1/16"},
}
func judged(t *testing.T) []LocalNetwork {
t.Helper()
return JudgeLocalNetworks(homeServer, []string{"eth0", "eth1"}, "wg-mesh")
}
// **A public subnet never counts** (ADR 0340 §2), and only a private one on a link facing outside does
// (§3): the tunnel's and a bridge's are this machine's own, whatever their addresses.
func TestOnlyAPrivateNetworkOnALinkFacingOutsideCounts(t *testing.T) {
got := CountedLocalNetworks(judged(t))
want := []string{"192.168.1.0/24", "fd12:3456:789a::/64"}
if strings.Join(got, " ") != strings.Join(want, " ") {
t.Fatalf("counted %v, want %v", got, want)
}
for _, j := range judged(t) {
if j.Why == "" {
t.Errorf("%s on %s is judged without a why", j.Network, j.Link)
}
switch j.Network {
case "203.0.113.0/24":
if j.Counts || !strings.Contains(j.Why, "public") {
t.Errorf("the public subnet is judged %v (%s), want never counted as public", j.Counts, j.Why)
}
case "fe80::/64":
if j.Counts || !strings.Contains(j.Why, "link-local") {
t.Errorf("a link-local network is judged %v (%s)", j.Counts, j.Why)
}
case "10.10.0.0/24", "172.17.0.0/16":
if j.Counts {
t.Errorf("%s on %s counts, and it is not the local network", j.Network, j.Link)
}
}
}
}
// A network overlapping a private range without lying wholly inside it does not count either, so a
// wide prefix around a private range cannot carry the internet with it.
func TestANetworkNotWhollyInAPrivateRangeDoesNotCount(t *testing.T) {
for _, n := range []string{"10.0.0.0/7", "172.0.0.0/8", "100.64.0.0/10", "0.0.0.0/0", "::/0"} {
got := JudgeLocalNetworks([]AttachedNetwork{{Link: "eth0", Network: n}}, []string{"eth0"}, "")
if len(got) != 1 || got[0].Counts {
t.Errorf("%s counts as a local network: %+v", n, got)
}
}
}
// **The filter admits the local network on that port's rule alone** (§5), beside the private network,
// in both chains, and never the public subnet.
func TestALocalNetworkPortIsAdmittedFromItsNetworksAndTheMesh(t *testing.T) {
rules := []Rule{
{Port: 1883, Protocol: "tcp", From: FromLocalNetwork, Because: []string{"mosquitto"}},
{Port: 5432, Protocol: "tcp", From: FromMesh, Because: []string{"postgres"}},
}
local := CountedLocalNetworks(judged(t))
out := AsNftablesWithLocal(rules, []string{"10.10.0.1", "10.10.0.4"}, false, nil,
[]string{"eth0", "eth1"}, "wg-mesh", local)
for _, want := range []string{
"ip saddr { 10.10.0.1, 10.10.0.4, 192.168.1.0/24 } tcp dport 1883 accept",
"ip6 saddr { fd12:3456:789a::/64 } tcp dport 1883 accept",
"ip saddr { 10.10.0.1, 10.10.0.4, 192.168.1.0/24 } ct original proto-dst 1883 accept",
// The mesh-only port is not widened by the machine having a local network.
"ip saddr { 10.10.0.1, 10.10.0.4 } tcp dport 5432 accept",
} {
if !strings.Contains(out, want) {
t.Errorf("the rule set lacks %q:\n%s", want, out)
}
}
if strings.Contains(out, "203.0.113") {
t.Errorf("the public subnet reached the rule set:\n%s", out)
}
if strings.Contains(out, "tcp dport 1883 accept\n") && !strings.Contains(out, "saddr") {
t.Errorf("1883 is open to anywhere:\n%s", out)
}
for _, line := range strings.Split(out, "\n") {
if strings.TrimSpace(line) == "tcp dport 1883 accept" {
t.Fatalf("1883 is open to anywhere:\n%s", out)
}
}
}
// **Nothing resolved is closed and said** (§6): a machine that reported no private network admits the
// port from the private network only, and the rule set says it found none — never widened to anywhere.
func TestNoLocalNetworkIsSaidAndNotWidened(t *testing.T) {
rules := []Rule{{Port: 1883, Protocol: "tcp", From: FromLocalNetwork, Because: []string{"mosquitto"}}}
out := AsNftablesWithLocal(rules, []string{"10.10.0.1"}, false, nil, []string{"eth0"}, "wg-mesh", nil)
if !strings.Contains(out, "1883 wanted the local network, and this machine reported no private network") {
t.Errorf("the rule set does not say the local network resolved to nothing:\n%s", out)
}
if !strings.Contains(out, "ip saddr { 10.10.0.1 } tcp dport 1883 accept") {
t.Errorf("the port is not admitted from the private network:\n%s", out)
}
for _, line := range strings.Split(out, "\n") {
if strings.TrimSpace(line) == "tcp dport 1883 accept" {
t.Fatalf("an unresolved local network was widened to anywhere:\n%s", out)
}
}
said := LocalNetworkPorts(rules, nil)[0].Said()
if !strings.Contains(said, "none") {
t.Errorf("plan says %q, want it to say no local network was found", said)
}
}
// `local-network` is a source a manifest may name (§1, changing ADR 0045 §3), and an unknown source is
// still refused.
func TestAManifestMayListenFromTheLocalNetwork(t *testing.T) {
if _, err := ParseManifest([]byte(`{"module":"mosquitto","version":"1",
"listens":[{"port":1883,"from":"local-network"}]}`)); err != nil {
t.Fatalf("a listen from the local network is refused: %v", err)
}
_, err := ParseManifest([]byte(`{"module":"mosquitto","version":"1",
"listens":[{"port":1883,"from":"lan"}]}`))
if err == nil || !strings.Contains(err.Error(), `from "lan"`) {
t.Fatalf("a source the mesh cannot render is not refused: %v", err)
}
}
// **A fourth reach** (§4, changing ADR 0138 §4): `internal` with the local networks added to what the
// filter opens — the internal name, not the public one.
func TestTheLocalNetworkReachIsInternalWithTheLocalNetworkAdded(t *testing.T) {
public, internal := namesFor(t, aRoutedWeb(), reachSet(ReachLocalNetwork))
if public != "" || internal != "app.anchor.internal" {
t.Fatalf("names are %q and %q, want the internal name only", public, internal)
}
bare := aRoutedWeb()
bare.Contributes = nil
r := Resolution{Node: "anchor", Modules: []Manifest{bare}}
rules, err := r.Rules(Rendering{Settings: reachSet(ReachLocalNetwork)})
if err != nil {
t.Fatalf("rules: %v", err)
}
if len(rules) != 1 || rules[0].From != FromLocalNetwork {
t.Fatalf("reach local-network made the filter say %+v", rules)
}
}
// Two modules wanting one port, one from the mesh and one from the local network, render one rule from
// the local network: it covers the mesh, and anywhere covers it.
func TestTheLocalNetworkSitsBetweenTheMeshAndAnywhere(t *testing.T) {
got := widest([]Rule{
{Port: 53, Protocol: "udp", From: FromMesh, Because: []string{"a"}},
{Port: 53, Protocol: "udp", From: FromLocalNetwork, Because: []string{"b"}},
})
if len(got) != 1 || got[0].From != FromLocalNetwork || len(got[0].Because) != 2 {
t.Fatalf("got %+v", got)
}
got = widest(append(got, Rule{Port: 53, Protocol: "udp", From: FromEverywhere, Because: []string{"c"}}))
if len(got) != 1 || got[0].From != FromEverywhere {
t.Fatalf("got %+v", got)
}
}
// On an adopted node the opening is the private network's part only: the found firewall admits the
// local network, and an opening that widened to everywhere would open the port to the internet.
func TestAnAdoptedNodeOpensTheLocalNetworkPortToTheMeshOnly(t *testing.T) {
out := Openings([]Rule{{Port: 1883, Protocol: "tcp", From: FromLocalNetwork}}, nil, nil)
if len(out) != 1 || out[0]["from"] != OpeningFromMesh {
t.Fatalf("openings %v", out)
}
}
// A global unicast IPv6 subnet on a link facing outside is public, and never counts: the IPv6 side of the
// rule that `local-network` cannot open a port to the internet (ADR 0340 §2).
func TestAGlobalIPv6SubnetIsPublicAndNeverCounts(t *testing.T) {
got := JudgeLocalNetworks([]AttachedNetwork{{Link: "eth0", Network: "2001:db8:1234::7/48"}}, []string{"eth0"}, "wg-mesh")
if len(got) != 1 {
t.Fatalf("judged %+v", got)
}
if got[0].Counts {
t.Fatalf("the global IPv6 subnet %s counts as a local network", got[0].Network)
}
if got[0].Network != "2001:db8:1234::/48" || !strings.Contains(got[0].Why, "public, never counted") {
t.Fatalf("judged %s with why %q, want it named public and never counted", got[0].Network, got[0].Why)
}
if n := CountedLocalNetworks(got); len(n) != 0 {
t.Fatalf("counted %v", n)
}
}
+9 -64
View File
@@ -269,8 +269,7 @@ func KeepsConsumerData(catalogue map[string]Manifest, provision string) bool {
func (o Offer) MachineReach() bool { return o.Reach == ReachMachine }
// OfferCredential names which of the provider's own secrets a provision's consumers receive — or, with
// IssuedBy "outside", that only a person can give the provision's credential (novox/hq ADR 0337, issue 164) — or,
// with IssuedBy "software", that the provider's software issues the provision's one credential itself (novox/hq ADR 0305,
// IssuedBy, that the provider's software issues the provision's one credential itself (novox/hq ADR 0305,
// issue 526): a media server's sign-in token from its vendor, which the mesh can neither make nor rotate.
// Tool is then the provider's own tool that answers that value, sealed to a key made for the controller's
// one call, and to the controller alone.
@@ -294,34 +293,6 @@ func (m Manifest) IssuedCredentialOf(provision string) (string, bool) {
return "", false
}
// IssuedOutsideOf is whether this module's offer of a provision says only a person outside the mesh can give its
// credential (novox/hq ADR 0337): the mesh never makes a value for it, and a consumer holds one only once a person
// gave it with `secret accept`.
func (m Manifest) IssuedOutsideOf(provision string) bool {
for _, o := range m.Provides {
if o.Name == provision && o.Credential != nil && o.Credential.IssuedBy == IssuedOutside {
return true
}
}
return false
}
// OffersIssuedOutside is whether any module of a catalogue offers the provision with a credential issued outside
// the mesh (novox/hq ADR 0337), and the first such module by name.
func OffersIssuedOutside(shelf map[string]Manifest, provision string) (string, bool) {
var names []string
for name, m := range shelf {
if m.IssuedOutsideOf(provision) {
names = append(names, name)
}
}
if len(names) == 0 {
return "", false
}
sort.Strings(names)
return names[0], true
}
// SharedCredentialOf is the own secret an offer of this module names as the provision's credential,
// and whether it names one.
func (m Manifest) SharedCredentialOf(provision string) (string, bool) {
@@ -530,12 +501,6 @@ type Manifest struct {
// stays refused by name until a layer sets it. The mesh's layer, then the node's, override it.
Settings map[string]SettingDeclaration `json:"settings,omitempty"`
// Preferences are the values this module reads that a machine may tune (novox/hq issue 630): each
// key's type, default and why, and the predefined key it falls back to, if any. The value resolves
// from the default, then mesh config and node config for the predefined key, then this module's own
// value on the node. `settings` entries of kind preference are read as preferences while they move.
Preferences map[string]Preference `json:"preferences,omitempty"`
// unknown is the first key this manifest has that this controller does not know, at any depth, when
// it was read from the store (novox/hq ADR 0262): a manifest a newer controller registered.
// ParseManifest refuses it; the store's catalogue still loads, and the module is left out of every
@@ -1171,12 +1136,6 @@ const (
FromEverywhere = "anywhere"
// FromMachine is this machine only — a port bound for something else on the same host.
FromMachine = "machine"
// FromLocalNetwork is the private network and the networks this machine is directly attached to
// through its uplink, as it reports them, and only those wholly in a private range (novox/hq ADR
// 0340): a local service reached by devices that will never be nodes, said as exactly that rather
// than as `anywhere`. A public subnet the machine sits on never counts, so it cannot open a port to
// the internet.
FromLocalNetwork = "local-network"
)
// At is this port's protocol, with the default applied.
@@ -1688,28 +1647,14 @@ func ParseManifest(raw []byte) (Manifest, error) {
"makes is %d", m.Module, p, id.Max, shortest))
}
}
if c := offer.Credential; c != nil && c.IssuedBy == IssuedOutside {
// Only a person can give it (novox/hq ADR 0337 §1, issue 164): the software holds a value the mesh can
// neither make nor fetch, so the offer names nothing that would answer it — no own secret, no tool.
switch {
case c.Own != "":
problems = append(problems, fmt.Sprintf(
"%s provides %q with a credential issued outside the mesh and names its own secret %q too; a "+
"person gives the value with `secret accept`, so the offer names no own secret (ADR 0337)",
m.Module, p, c.Own))
case c.Tool != "":
problems = append(problems, fmt.Sprintf(
"%s provides %q with a credential issued outside the mesh and names the tool %q; only a "+
"person gives it, so the offer names no tool (ADR 0337)", m.Module, p, c.Tool))
}
} else if c := offer.Credential; c != nil && (c.IssuedBy != "" || c.Tool != "") {
if c := offer.Credential; c != nil && (c.IssuedBy != "" || c.Tool != "") {
// The software issues it (novox/hq ADR 0305): named by the tool that answers it, never by an own
// secret, which would be a second copy for the mesh to keep in step with the software's.
switch {
case c.IssuedBy != IssuedBySoftware:
problems = append(problems, fmt.Sprintf(
"%s provides %q with a credential issued by %q; the issuers an offer may name are %q and %q "+
"(ADR 0305, ADR 0337)", m.Module, p, c.IssuedBy, IssuedBySoftware, IssuedOutside))
"%s provides %q with a credential issued by %q; the one issuer an offer may name is %q (ADR 0305)",
m.Module, p, c.IssuedBy, IssuedBySoftware))
case c.Own != "":
problems = append(problems, fmt.Sprintf(
"%s provides %q with a credential its software issues and names its own secret %q too; the "+
@@ -1941,17 +1886,17 @@ func ParseManifest(raw []byte) (Manifest, error) {
"%s listens on port %d, which is not a port", m.Module, l.Port))
}
switch l.From {
case FromMesh, FromEverywhere, FromMachine, FromLocalNetwork:
case FromMesh, FromEverywhere, FromMachine:
case "":
// The fault this field exists to prevent. A rule with no source is open, and a
// manifest that omitted it would read as a restriction and be none.
problems = append(problems, fmt.Sprintf(
"%s listens on %d and does not say from where; it is %q, %q, %q or %q",
m.Module, l.Port, FromMesh, FromEverywhere, FromMachine, FromLocalNetwork))
"%s listens on %d and does not say from where; it is %q, %q or %q",
m.Module, l.Port, FromMesh, FromEverywhere, FromMachine))
default:
problems = append(problems, fmt.Sprintf(
"%s listens on %d from %q; it is %q, %q, %q or %q",
m.Module, l.Port, l.From, FromMesh, FromEverywhere, FromMachine, FromLocalNetwork))
"%s listens on %d from %q; it is %q, %q or %q",
m.Module, l.Port, l.From, FromMesh, FromEverywhere, FromMachine))
}
if p := l.At(); p != "tcp" && p != "udp" {
problems = append(problems, fmt.Sprintf(
-270
View File
@@ -1,270 +0,0 @@
package catalogue
import (
"bytes"
"encoding/json"
"fmt"
"regexp"
"sort"
"strings"
)
// A config value resolves from four sources, the last set winning (novox/hq issue 630):
//
// 1. the default in the module's manifest;
// 2. mesh config: a mesh-wide value for a predefined key, such as `font-size`;
// 3. node config: a value for a predefined key for one node;
// 4. module-assignment config: that module's own value on that node, the most specific.
//
// An assignment is one module on one node: there is no module layer for the whole mesh. Mesh and node
// config hold values only for the keys on a fixed, reviewable list — the catalogue's `predefined.json` —
// that any module may fall back to, by naming one as its preference's `predefined`. A preference without
// `predefined` reads only its default and its module-assignment config. A preference is never trusted.
// Preference is one value a module reads that a machine may tune, as the manifest declares it under
// `preferences`.
type Preference struct {
// Type is "number", "string" or "boolean".
Type string `json:"type"`
// Default is the value when no source sets it, of Type.
Default any `json:"default"`
// Predefined names the key on the catalogue's list this preference falls back to: mesh config,
// then node config, for that key. Empty reads only the default and the module-assignment config.
Predefined string `json:"predefined,omitempty"`
// Why this default: one sentence for whoever wonders whether to change it.
Why string `json:"why"`
}
// The types a preference and a predefined key may have.
const (
TypeNumber = "number"
TypeString = "string"
TypeBoolean = "boolean"
)
// PredefinedFile is the catalogue repository's root file holding the predefined keys.
const PredefinedFile = "predefined.json"
// PredefinedKey is one key mesh and node config may hold a value for.
type PredefinedKey struct {
Type string `json:"type"`
Why string `json:"why"`
}
// Predefined is the list of predefined keys, by key.
type Predefined map[string]PredefinedKey
// Keys is the list's keys, sorted.
func (p Predefined) Keys() []string {
keys := make([]string, 0, len(p))
for k := range p {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}
// predefinedName is what a predefined key looks like: the shape of a setting key.
var predefinedName = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]*$`)
// ParsePredefined reads `predefined.json` strictly, and refuses a key whose name, type or why is wrong.
func ParsePredefined(raw []byte) (Predefined, error) {
decoder := json.NewDecoder(bytes.NewReader(raw))
decoder.DisallowUnknownFields()
var list Predefined
if err := decoder.Decode(&list); err != nil {
return nil, fmt.Errorf("%s is not a list of predefined keys ({\"<key>\": {\"type\": …, \"why\": …}}): %w",
PredefinedFile, err)
}
var problems []string
for _, key := range list.Keys() {
k := list[key]
if !predefinedName.MatchString(key) {
problems = append(problems, fmt.Sprintf("%q is not a usable key: lower-case letters, digits, dots, dashes and underscores", key))
}
if meshWords[key] {
problems = append(problems, fmt.Sprintf("%q is a key the mesh reads itself", key))
}
if !knownType(k.Type) {
problems = append(problems, fmt.Sprintf("%q has the type %q; a type is %q, %q or %q", key, k.Type,
TypeNumber, TypeString, TypeBoolean))
}
if strings.TrimSpace(k.Why) == "" {
problems = append(problems, fmt.Sprintf("%q has no why: say in one sentence what the key is for", key))
}
}
if len(problems) > 0 {
return nil, fmt.Errorf("%s: %s", PredefinedFile, strings.Join(problems, "; "))
}
if list == nil {
list = Predefined{}
}
return list, nil
}
func knownType(t string) bool {
return t == TypeNumber || t == TypeString || t == TypeBoolean
}
// TypeOf is the type a JSON value has, as a preference names it, or "" for one that is none of them.
func TypeOf(v any) string {
switch v.(type) {
case float64, int, int64, json.Number:
return TypeNumber
case string:
return TypeString
case bool:
return TypeBoolean
}
return ""
}
// PreferencesOf is every preference a module declares: those under `preferences`, and — while the
// catalogue moves to them — every `settings` entry of kind preference, its type read from its default.
func PreferencesOf(m Manifest) map[string]Preference {
if len(m.Preferences) == 0 && len(m.Settings) == 0 {
return nil
}
out := make(map[string]Preference, len(m.Preferences)+len(m.Settings))
for key, d := range m.Settings {
out[key] = Preference{Type: TypeOf(d.Default), Default: d.Default, Why: d.Why}
}
for key, p := range m.Preferences {
out[key] = p
}
return out
}
// DeclaresPreference is whether a module declares a preference by this key.
func DeclaresPreference(m Manifest, key string) bool {
_, has := m.Preferences[key]
if !has {
_, has = m.Settings[key]
}
return has
}
// PredefinedProblems is every way a module's preferences disagree with the list of predefined keys: a
// `predefined` not on it, or a type other than the list's.
func PredefinedProblems(m Manifest, list Predefined) []string {
var problems []string
keys := make([]string, 0, len(m.Preferences))
for k := range m.Preferences {
keys = append(keys, k)
}
sort.Strings(keys)
for _, key := range keys {
p := m.Preferences[key]
if p.Predefined == "" {
continue
}
k, on := list[p.Predefined]
if !on {
listed := "the list holds none"
if len(list) > 0 {
listed = "the list holds " + strings.Join(list.Keys(), ", ")
}
problems = append(problems, fmt.Sprintf("%s's preference %q falls back to the predefined key %q, which "+
"is not on the catalogue's list of predefined keys (%s; %s)", m.Module, key, p.Predefined, PredefinedFile,
listed))
continue
}
if k.Type != p.Type {
problems = append(problems, fmt.Sprintf("%s's preference %q is a %s, and the predefined key %q it falls "+
"back to is a %s", m.Module, key, p.Type, p.Predefined, k.Type))
}
}
return problems
}
// Where a value from mesh or node config is said to come from.
const MeshConfigLayer = "mesh config"
// NodeConfigLayer is what the node config of one node is called where a value's source is said.
func NodeConfigLayer(node string) string { return "node config (" + node + ")" }
// ConfigLayers are the layers mesh config and node config make for one module: each of its preferences
// that names a predefined key, given that key's value. Laid between the module's defaults and its
// module-assignment config; like the defaults they fill `${setting:<key>}` only.
func ConfigLayers(m Manifest, mesh, node map[string]any, nodeName string) []Layer {
var out []Layer
for _, source := range []struct {
from string
values map[string]any
}{{MeshConfigLayer, mesh}, {NodeConfigLayer(nodeName), node}} {
values := map[string]any{}
for key, p := range m.Preferences {
if p.Predefined == "" {
continue
}
if v, set := source.values[p.Predefined]; set {
values[key] = v
}
}
if len(values) > 0 {
out = append(out, Layer{From: source.from, Values: values, Config: true})
}
}
return out
}
// ConfigValueProblem refuses a value of the wrong type for a predefined key or a preference.
func ConfigValueProblem(what string, want string, v any) error {
if got := TypeOf(v); got != want {
shown, _ := json.Marshal(v)
return fmt.Errorf("%s is a %s, and %s is %s", what, want, shown, aOrNothing(got))
}
return nil
}
func aOrNothing(t string) string {
if t == "" {
return "none of a number, a string or a boolean"
}
return "a " + t
}
// preferenceProblems is every way a module's `preferences` are wrong in themselves.
func preferenceProblems(m Manifest) []string {
var problems []string
keys := make([]string, 0, len(m.Preferences))
for k := range m.Preferences {
keys = append(keys, k)
}
sort.Strings(keys)
for _, key := range keys {
p := m.Preferences[key]
say := func(format string, args ...any) {
problems = append(problems, fmt.Sprintf("%s's preference %q: ", m.Module, key)+fmt.Sprintf(format, args...))
}
if _, twice := m.Settings[key]; twice {
say("declared under settings too; declare it once, under preferences")
}
if !knownType(p.Type) {
say("the type is %q; a type is %q, %q or %q", p.Type, TypeNumber, TypeString, TypeBoolean)
} else if p.Default == nil {
say("no default: a preference has one")
} else if got := TypeOf(p.Default); got != p.Type {
shown, _ := json.Marshal(p.Default)
say("it is a %s, and its default %s is %s", p.Type, shown, aOrNothing(got))
}
if p.Predefined != "" && !predefinedName.MatchString(p.Predefined) {
say("%q is not a predefined key's name", p.Predefined)
}
}
return problems
}
// PredefinedNamed is every predefined key a module's preferences fall back to, sorted.
func PredefinedNamed(m Manifest) []string {
seen := map[string]bool{}
var out []string
for _, p := range m.Preferences {
if p.Predefined != "" && !seen[p.Predefined] {
seen[p.Predefined] = true
out = append(out, p.Predefined)
}
}
sort.Strings(out)
return out
}
-117
View File
@@ -1,117 +0,0 @@
package catalogue
import (
"strings"
"testing"
)
// The list of predefined keys is read strictly: a type and a why for each key (novox/hq issue 630).
func TestThePredefinedListIsReadStrictly(t *testing.T) {
list, err := ParsePredefined([]byte(`{"font-size": {"type": "number", "why": "the size text is drawn at"},
"theme": {"type": "string", "why": "light or dark"}}`))
if err != nil || len(list) != 2 || list["font-size"].Type != TypeNumber {
t.Fatalf("a good list: %v %v", list, err)
}
for raw, want := range map[string]string{
`{"font-size": {"type": "integer", "why": "x"}}`: `the type "integer"`,
`{"font-size": {"type": "number"}}`: "no why",
`{"Font Size": {"type": "number", "why": "x"}}`: "not a usable key",
`{"font-size": {"type": "number", "why": "x", "a": 1}}`: "unknown field",
`{"ports": {"type": "number", "why": "x"}}`: "the mesh reads itself",
} {
if _, err := ParsePredefined([]byte(raw)); err == nil || !strings.Contains(err.Error(), want) {
t.Errorf("%s: %v, want %q", raw, err, want)
}
}
}
// A manifest declares preferences with a type, a default of that type, a why and maybe a predefined key;
// a `settings` entry of kind preference is read as one, its type from its default.
func TestAManifestDeclaresPreferences(t *testing.T) {
file := `"resources": [{"id": "rc", "type": "file", "path": "/etc/rc", "mode": "0644", "trusted": false,
"content": "size = ${setting:font-size} width = ${setting:width}"}]`
m, err := ParseManifest([]byte(`{"module": "dunst", "version": "1",
"preferences": {"font-size": {"type": "number", "default": 10, "predefined": "font-size", "why": "100 DPI"}},
"settings": {"width": {"kind": "preference", "default": 250, "why": "forty characters"}}, ` + file + `}`))
if err != nil {
t.Fatal(err)
}
prefs := PreferencesOf(m)
if prefs["font-size"].Predefined != "font-size" || prefs["width"].Type != TypeNumber || prefs["width"].Default != float64(250) {
t.Fatalf("the preferences: %+v", prefs)
}
if got := PredefinedNamed(m); len(got) != 1 || got[0] != "font-size" {
t.Fatalf("the predefined keys named: %v", got)
}
for body, want := range map[string]string{
`"preferences": {"font-size": {"type": "number", "default": "10", "why": "x"}, "width": {"type": "number", "default": 1, "why": "x"}}`: `its default "10" is a string`,
`"preferences": {"font-size": {"type": "float", "default": 10, "why": "x"}, "width": {"type": "number", "default": 1, "why": "x"}}`: `the type is "float"`,
`"preferences": {"font-size": {"type": "number", "why": "x"}, "width": {"type": "number", "default": 1, "why": "x"}}`: "no default",
`"preferences": {"font-size": {"type": "number", "default": 10}, "width": {"type": "number", "default": 1, "why": "x"}}`: "no why",
} {
if _, err := ParseManifest([]byte(`{"module": "dunst", "version": "1", ` + body + `, ` + file + `}`)); err == nil ||
!strings.Contains(err.Error(), want) {
t.Errorf("%s: %v, want %q", body, err, want)
}
}
}
// A preference falls back only to a key on the list, with the list's type.
func TestAPreferencesPredefinedKeyIsOnTheListWithItsType(t *testing.T) {
m := Manifest{Module: "dunst", Preferences: map[string]Preference{
"font-size": {Type: TypeNumber, Default: float64(10), Predefined: "font-size", Why: "x"}}}
if p := PredefinedProblems(m, Predefined{"font-size": {Type: TypeNumber, Why: "x"}}); len(p) != 0 {
t.Fatalf("on the list: %v", p)
}
if p := PredefinedProblems(m, Predefined{"theme": {Type: TypeString, Why: "x"}}); len(p) != 1 ||
!strings.Contains(p[0], "not on the catalogue's list") || !strings.Contains(p[0], "theme") {
t.Fatalf("not on the list: %v", p)
}
if p := PredefinedProblems(m, Predefined{"font-size": {Type: TypeString, Why: "x"}}); len(p) != 1 ||
!strings.Contains(p[0], "is a string") {
t.Fatalf("another type: %v", p)
}
}
// Mesh and node config fill `${setting:<key>}` between the default and the module's own layer, and — like
// the default — never reach a mergeable file.
func TestConfigLayersFillAPlaceholderAndNoMergeableFile(t *testing.T) {
m := Manifest{Module: "dunst", Preferences: map[string]Preference{
"font-size": {Type: TypeNumber, Default: float64(10), Predefined: "font-size", Why: "x"},
"width": {Type: TypeNumber, Default: float64(250), Why: "x"}}}
fill := func(layers []Layer) string {
t.Helper()
r := map[string]any{"id": "rc", "type": "file", "content": "font ${setting:font-size} width ${setting:width}"}
if err := settingInto(r, WithDefaults(m, layers), m.Module); err != nil {
t.Fatal(err)
}
return r["content"].(string)
}
mesh := map[string]any{"font-size": float64(11), "width": float64(999)}
node := map[string]any{"font-size": float64(13)}
if got := fill(nil); got != "font 10 width 250" {
t.Fatalf("the defaults: %s", got)
}
if got := fill(ConfigLayers(m, mesh, nil, "g14")); got != "font 11 width 250" {
t.Fatalf("mesh config, which a preference without predefined does not read: %s", got)
}
both := ConfigLayers(m, mesh, node, "g14")
if got := fill(both); got != "font 13 width 250" {
t.Fatalf("node config over mesh config: %s", got)
}
own := append(both, Layer{From: "g14", Values: map[string]any{"font-size": float64(15)}})
if got := fill(own); got != "font 15 width 250" {
t.Fatalf("the module's own value over both: %s", got)
}
sources := map[string]string{}
for _, s := range Effective(m, both) {
sources[s.Key] = s.From
}
if sources["font-size"] != NodeConfigLayer("g14") || sources["width"] != DefaultLayer {
t.Fatalf("where each comes from: %v", sources)
}
merged, err := ApplySettings(map[string]any{"id": "conf", "type": "file", "merge": MergeJSON, "content": "{}"}, both)
if err != nil || merged["content"] != "{}\n" {
t.Fatalf("config reached a mergeable file: %v %v", merged["content"], err)
}
}
-44
View File
@@ -1,44 +0,0 @@
package catalogue
import (
"strings"
"testing"
)
// Replay of novox/hq issue 166: every soft dependency had become a hard requirement. grafana runs without a
// data source, yet a manifest could not say so: a mesh without influxdb could not register a grafana that
// said its influxdb-api was optional, and with influxdb only in the catalogue, assigning grafana installed
// influxdb for it. Written only with what the catalogue had before its fix (mesh-controller #281):
// ParseManifest, Resolve, and the resolve tests' shelf, reachable machine and names, so it is laid over the
// commit before.
func TestReplay166(t *testing.T) {
grafana, err := ParseManifest([]byte(`{"module":"grafana","version":"1",
"requires":[{"name":"influxdb-api","optional":true}]}`))
if err != nil {
t.Fatalf("a manifest cannot say grafana runs without influxdb-api: %v", err)
}
catalogue := shelf(
Manifest{Module: "influxdb", Version: "1", Provides: FromAnywhere("influxdb-api")},
grafana,
)
// Nothing in the mesh runs influxdb; it is only in the catalogue.
got, err := Resolve(catalogue, []string{"grafana"}, reachable(), World{})
if err != nil {
t.Fatalf("grafana could not be assigned on a mesh without influxdb: %v", err)
}
if have := strings.Join(names(got), " "); have != "grafana" {
t.Fatalf("assigning grafana put %s on the machine; nothing is installed for an optional requirement", have)
}
if len(got.Needs) != 0 {
t.Fatalf("grafana was given a binding with nothing providing it: %+v", got.Needs)
}
// And once influxdb runs somewhere, grafana binds to it as before.
got, err = Resolve(catalogue, []string{"grafana"}, reachable(), World{Offered: map[string][]Provider{
"influxdb-api": {{Node: "anchor", At: "anchor.internal", Module: "influxdb"}}}})
if err != nil {
t.Fatal(err)
}
if len(got.Needs) != 1 || got.Needs[0].From != "anchor" || got.Needs[0].For != "grafana" {
t.Fatalf("grafana did not bind to influxdb on anchor: %+v", got.Needs)
}
}
-36
View File
@@ -1,36 +0,0 @@
package catalogue
import "testing"
// Replay of novox/hq issue 279 (2026-10-06): the media managers' recycle-bin folders were declared owned by
// a literal user and group, and on a machine whose puid and pgid settings named another account the apps
// refused them as not writable. A directory's owner could not follow the account its module runs as. Written
// only with what the catalogue had before its fix (mesh-controller #260): Compose, LeftOut, a setting layer,
// and the adoption tests' anchor, so it is laid over the commit before.
func TestReplay279(t *testing.T) {
r := anAdoptedAnchor()
r.Modules = append(r.Modules, Manifest{Module: "sonarr",
Resources: []map[string]any{
{"id": "recycle-bin", "type": "directory", "path": "/srv/sonarr/recycle", "mode": "0755",
"owner": "${setting:puid}:${setting:pgid}"},
{"id": "identity", "type": "file", "path": "/srv/sonarr/identity.env", "mode": "0644",
"content": "PUID=${setting:puid}\nPGID=${setting:pgid}\n"},
},
})
with := anchorRendering(false)
with.Settings["sonarr"] = []Layer{{From: "node anchor", Values: map[string]any{"puid": "1001", "pgid": "2000"}}}
composed, err := r.Compose(with)
if err != nil {
t.Fatalf("the machine did not compose: %v", err)
}
if why, left := composed.LeftOut["sonarr"]; left {
t.Fatalf("the module was left out: %s", why)
}
got, ok := byID(composed.Resources)["sonarr.recycle-bin"]
if !ok {
t.Fatal("the recycle bin was not declared")
}
if got["owner"] != "1001:2000" {
t.Fatalf("the recycle bin reaches the node-engine owned by %v, not the account the module runs as (1001:2000)", got["owner"])
}
}
-36
View File
@@ -1,36 +0,0 @@
package catalogue
import (
"strings"
"testing"
)
// Replay of novox/hq issue 412 (2026-10-10): which module owns /etc/hosts was enforced only after the fact,
// when two modules on one machine declared it; a module that did not claim node-hostname could declare
// /etc/hosts, and a module could claim node-hostname declaring none of its paths, and nothing refused either.
// Written only with what the catalogue had before its fix (mesh-controller #267): CatalogueProblems, Shelf,
// SeatNamed and CanHold, so it is laid over the commit before.
func TestReplay412(t *testing.T) {
stranger := Manifest{Module: "stranger", Version: "1", Resources: []map[string]any{
{"id": "hosts", "type": "file", "path": "/etc/hosts", "into": "block", "content": "10.0.0.1 x\n"}}}
got := strings.Join(CatalogueProblems(Shelf{"stranger": stranger}), "; ")
if !strings.Contains(got, "node-hostname") {
t.Errorf("a module declaring /etc/hosts without claiming node-hostname was not refused: %q", got)
}
seat, ok := SeatNamed("node-hostname")
if !ok {
t.Fatal("no node-hostname seat")
}
holder := Manifest{Module: "names", Version: "1", Claims: []Claim{{Name: "node-hostname", Scope: ScopeNode,
Serves: []string{"entries", "add", "remove"}}}}
err := CanHold(holder, seat)
if err == nil {
t.Fatal("a holder of node-hostname declaring neither /etc/hosts nor /etc/hostname was accepted")
}
for _, want := range []string{"/etc/hosts", "/etc/hostname"} {
if !strings.Contains(err.Error(), want) {
t.Errorf("the refusal does not name %s: %v", want, err)
}
}
}
+7 -11
View File
@@ -63,17 +63,13 @@ func TestTheResolverForwardsToFixedUpstreamsAndNeverReadsResolvConf(t *testing.T
t.Errorf("the resolver's configuration lacks %q:\n%s", strings.TrimSpace(want), config)
}
}
// Bound to each address once, at start, and never by bind-dynamic (novox/hq issue 348): bind-dynamic
// re-reads the machine's addresses on every change of any link and closes each listener that reading
// did not find, and a reading made while the container runtime tears down a bridge's virtual links
// fails, so the private address was left unbound for twenty minutes, the daemon running and silent.
// The catalogue's main binds once since mesh-catalog PR 161 (105cc7c0).
if !strings.Contains(config, "\nbind-interfaces\n") {
t.Errorf("the resolver's configuration does not bind its addresses once (bind-interfaces):\n%s", config)
}
if strings.Contains(config, "\nbind-dynamic\n") {
t.Errorf("the resolver's configuration says bind-dynamic, which closes its listeners when a bridge is "+
"torn down (novox/hq issue 348):\n%s", config)
// Bound to its addresses, one way or the other. mesh-catalog PR 161 (novox/hq issue 348) moves it from
// bind-dynamic, which closed the private address's listener when a bridge teardown failed its re-read
// of the machine's addresses, to bind-interfaces. This test reads the catalogue beside it, which may be
// on either side of that merge, so it takes both; once the catalogue's main has it, bind-dynamic is
// refused here.
if !strings.Contains(config, "\nbind-interfaces\n") && !strings.Contains(config, "\nbind-dynamic\n") {
t.Errorf("the resolver's configuration binds neither by bind-interfaces nor by bind-dynamic:\n%s", config)
}
// By address and never by interface: dnsmasq admits a query by the interface it arrives on
// when told one, and a container's query to the private address arrives on the runtime's
+2 -3
View File
@@ -331,13 +331,12 @@ func UnassignRefusal(catalogue map[string]Manifest, node string, assigned, remov
}
// CollisionRefusal is why assigning `adding` beside `assigned` is refused for what two modules
// would both declare, or nothing (novox/hq ADR 0210 §1, 04-ISSUES/235): one path, unit or name, or a package one
// declares present and another absent. A package several modules declare present is no collision (ADR 0303).
// would both declare, or nothing (novox/hq ADR 0210 §1, 04-ISSUES/235).
//
// **Refused, not kept like an unresolved provision.** An assignment is otherwise kept when the
// node does not resolve, because assignment is not an ordering: a consumer's provider can follow.
// A collision is not an order anything can complete — no further assignment makes two owners of one
// file one owner — and kept, it leaves the node unresolvable, so the next push of anything drops
// package one owner — and kept, it leaves the node unresolvable, so the next push of anything drops
// it from the mesh. Only collisions involving a module being added are refused; one already on the
// node is `status`'s, and refusing an unrelated assignment for it would block its own remedy.
func CollisionRefusal(catalogue map[string]Manifest, node string, assigned, adding []string) error {
+21 -35
View File
@@ -114,23 +114,15 @@ func operatorsWord(key string) string {
return ""
}
// SettingProblems is every way a definition's setting defaults and preferences are wrong, in its own
// words. The list of predefined keys is not needed here; PredefinedProblems judges against it.
// SettingProblems is every way a definition's setting defaults are wrong, in its own words.
func SettingProblems(m Manifest) []string {
declared := PreferencesOf(m)
if len(declared) == 0 {
if len(m.Settings) == 0 {
return nil
}
used := settingKeysUsedBy(m)
problems := preferenceProblems(m)
keys := make([]string, 0, len(declared))
for k := range declared {
keys = append(keys, k)
}
sort.Strings(keys)
for _, key := range keys {
d := declared[key]
_, old := m.Settings[key]
var problems []string
for _, key := range sortedSettingKeys(m.Settings) {
d := m.Settings[key]
say := func(format string, args ...any) {
problems = append(problems, fmt.Sprintf("%s's setting %q: ", m.Module, key)+fmt.Sprintf(format, args...))
}
@@ -142,27 +134,23 @@ func SettingProblems(m Manifest) []string {
say("the mesh reads %q itself, and a module gives it no default", key)
continue
}
if old && m.Settings[key].Kind != KindPreference {
say("kind is %q, and only a %q has a default (novox/hq ADR 0262)", m.Settings[key].Kind, KindPreference)
if d.Kind != KindPreference {
say("kind is %q, and only a %q has a default (novox/hq ADR 0262)", d.Kind, KindPreference)
}
if word := operatorsWord(key); word != "" {
say("a key naming %q is the operator's value, and has no default — it is the "+
"assignment's, never the definition's (novox/hq ADR 0112, ADR 0262)", word)
}
if old {
switch v := d.Default.(type) {
case string:
if strings.TrimSpace(v) == "" {
say("an empty default is no default; give the value, or declare nothing")
}
case float64, bool:
case nil:
say("no default: a key without one is the operator's, and is not declared here")
default:
say("a default is a string, a number or a boolean, and this is %T", d.Default)
switch v := d.Default.(type) {
case string:
if strings.TrimSpace(v) == "" {
say("an empty default is no default; give the value, or declare nothing")
}
} else if v, isString := d.Default.(string); isString && strings.TrimSpace(v) == "" {
say("an empty default is no default; give the value, or declare nothing")
case float64, bool:
case nil:
say("no default: a key without one is the operator's, and is not declared here")
default:
say("a default is a string, a number or a boolean, and this is %T", d.Default)
}
if strings.TrimSpace(d.Why) == "" {
say("no why: say in one sentence why this default")
@@ -176,12 +164,11 @@ func SettingProblems(m Manifest) []string {
// Defaults is the layer a module's own defaults make, or nothing when it gives none.
func Defaults(m Manifest) (Layer, bool) {
declared := PreferencesOf(m)
if len(declared) == 0 {
if len(m.Settings) == 0 {
return Layer{}, false
}
values := map[string]any{}
for key, d := range declared {
for key, d := range m.Settings {
if d.Default != nil {
values[key] = d.Default
}
@@ -202,7 +189,7 @@ func WithDefaults(m Manifest, layers []Layer) []Layer {
type SettingSource struct {
Key string
Value any
// From is DefaultLayer, MeshConfigLayer, NodeConfigLayer(node), MeshWideLayer or the node's name.
// From is DefaultLayer, MeshWideLayer or the node's name.
From string
// FromDefault is whether the value is the module's default, whatever From reads.
FromDefault bool
@@ -212,11 +199,10 @@ type SettingSource struct {
}
// Effective is every key a module gives a default or a layer sets, with its value and where it came
// from: the default, then mesh config, then node config, then the module's own layer on the node —
// later wins (novox/hq issue 630).
// from: the default, then the mesh's layer, then the node's — later wins.
func Effective(m Manifest, layers []Layer) []SettingSource {
byKey := map[string]*SettingSource{}
for key, d := range PreferencesOf(m) {
for key, d := range m.Settings {
byKey[key] = &SettingSource{Key: key, Value: d.Default, From: DefaultLayer, FromDefault: true,
Default: d.Default, HasDefault: true}
}
+1 -1
View File
@@ -188,7 +188,7 @@ func wholeID(v string) (uint64, bool) {
func OwnerSettingProblems(m Manifest) []string {
var problems []string
asked := settingKeysAskedOutsideOwners(m)
for k := range PreferencesOf(m) {
for k := range m.Settings {
asked[k] = true
}
for _, r := range m.Resources {
+2 -2
View File
@@ -43,7 +43,7 @@ func onlyTaken(layers []Layer, taken map[string]bool) []Layer {
values[key] = value
}
}
kept = append(kept, Layer{From: layer.From, Values: values, Default: layer.Default, Config: layer.Config})
kept = append(kept, Layer{From: layer.From, Values: values, Default: layer.Default})
}
return kept
}
@@ -83,7 +83,7 @@ func TakenSettingsProblems(m Manifest) []string {
// Said only when none of the module's mergeable files names its keys: when one does, §2 already
// names every file that does not, and a file is not reported twice.
func WrittenSettingsProblems(m Manifest) []string {
if len(PreferencesOf(m)) == 0 {
if len(m.Settings) == 0 {
return nil
}
var silent []string
+3 -6
View File
@@ -37,9 +37,6 @@ type Layer struct {
// Default marks the layer a module's own defaults make (novox/hq ADR 0262). Marked rather than
// recognised by From, which is a node's name for a node's layer, and a node may be called anything.
Default bool
// Config marks a layer of mesh config or node config (novox/hq issue 630): values for predefined keys,
// given to the preferences that fall back to them. Like the defaults, it fills `${setting:<key>}` only.
Config bool
}
// ApplySettings produces a resource's final content from the module's own and the layers over it.
@@ -139,7 +136,7 @@ func overridden(base map[string]any, layers []Layer, what string) (map[string]an
values[key] = value
}
}
kept = append(kept, Layer{From: layer.From, Values: values, Default: layer.Default, Config: layer.Config})
kept = append(kept, Layer{From: layer.From, Values: values, Default: layer.Default})
}
merged, err := settle(base, kept, nil, what)
if err != nil {
@@ -176,7 +173,7 @@ func settle(base map[string]any, layers []Layer, protected map[string]bool, what
map[string]any, error) {
merged := deepCopy(base)
for _, layer := range layers {
if layer.Default || layer.Config {
if layer.Default {
// A module's own defaults fill ${setting:<key>} only (novox/hq ADR 0262). A mergeable
// file's content already is its defaults, and a contribution or served fact declares its
// own; laid on here, a default would reach every mergeable file of its module (issue 168).
@@ -366,7 +363,7 @@ func UnusedSettings(m Manifest, layers []Layer) []string {
}
// A key the module gives a default reaches what asks for it (novox/hq ADR 0262): setting it
// overrides the default, which is how a running module gains a setting without a gap between.
for key := range PreferencesOf(m) {
for key := range m.Settings {
lands[key] = true
}
for _, values := range m.Contributes {
+1 -20
View File
@@ -33,10 +33,6 @@ import (
// 4. **A module's whole layer, when it has a mergeable file not marked `"trusted": false`** (novox/hq issue 340,
// TrustedMergeable). A mergeable file takes any key a layer sets, not only those its content names, so no list
// of keys covers it: an empty runtime configuration a provider reads would take a `url` of the caller's.
// **Unless the module's files name the keys they take** (novox/hq ADR 0326, ADR 0329 §5): then a mergeable
// file takes exactly its `takes-settings` and no other key, so those keys of a trusted file are terminal keys
// and the rest of the layer is not. A key no file names is stray and refused where it is set. So betterbird's
// `tags`, in a file nothing trusts, is set through the verb, while its `accounts` and `identities` are not.
// 5. **Every setting a directory's owner asks for** (novox/hq issue 279): root chowns the directory to the number
// at the next push, so through a verb any caller could hand itself — or uid 0 — a module's folder. Whatever
// else asks for the key, a file marked `"trusted": false` included, the owner makes it the terminal's.
@@ -113,13 +109,8 @@ func UnsaidTrust(m Manifest) []string {
// TrustedMergeable is every mergeable file of a module not marked `"trusted": false`, by id (novox/hq issue 340).
// A mergeable file takes any key a layer sets, not only those its content names: an empty runtime configuration a
// provider reads takes a `url` of a caller's as surely as a declared one. So a module holding one has its whole
// layer set at the controller's terminal; a verb may read it and change nothing. A module whose mergeable files
// name the keys they take (ADR 0326) has none here: each such file takes only its named keys, which TerminalKeys
// holds for a trusted one (ADR 0329 §5).
// layer set at the controller's terminal; a verb may read it and change nothing.
func TrustedMergeable(m Manifest) []string {
if namesItsKeys(m) {
return nil
}
var out []string
for _, r := range m.Resources {
if fmt.Sprint(r["type"]) != "file" {
@@ -144,16 +135,6 @@ func TrustedMergeable(m Manifest) []string {
func asksFor(r map[string]any) []string {
content, _ := r["content"].(string)
asked := settingsUsed(content)
if how, _ := r["merge"].(string); how != "" {
if taken, names := takenKeys(r); names {
// The file takes exactly the keys it names (ADR 0326), whatever its content holds.
for key := range taken {
asked = append(asked, key)
}
sort.Strings(asked)
return asked
}
}
if how, _ := r["merge"].(string); how != "" && strings.TrimSpace(content) != "" {
var base map[string]any
if json.Unmarshal([]byte(content), &base) == nil {
@@ -166,33 +166,6 @@ func TestTerminalKeysAreDerived(t *testing.T) {
}
}
// A module whose mergeable files name the keys they take (ADR 0326) has no whole layer at the terminal: a
// trusted file's named keys are terminal keys, and a file nothing trusts leaves its keys to the verb (novox/hq
// ADR 0329 §5, issue 586: betterbird's tags beside its trusted accounts, identities, calendars, address books
// and filters). A module whose files name no keys keeps the whole layer at the terminal (issue 340).
func TestOnlyANamedKeyOfATrustedFileIsTheTerminals(t *testing.T) {
betterbird := Manifest{Module: "betterbird", Resources: []map[string]any{
{"id": "prefs", "type": "file", "path": "/home/p/prefs.json", "merge": MergeJSON, "trusted": true,
TakesSettingsField: []any{"prefs"}, "content": `{"prefs": {}}`},
{"id": "accounts", "type": "file", "path": "/home/p/accounts.json", "merge": MergeJSON, "trusted": true,
TakesSettingsField: []any{"accounts"}, "content": `{"accounts": {}}`},
{"id": "configuration", "type": "file", "path": "/home/p/configuration.json", "merge": MergeJSON,
TakesSettingsField: []any{"identities", "calendars", "address-books", "filters"}, "content": `{}`},
{"id": "tags", "type": "file", "path": "/home/p/tags.json", "merge": MergeJSON, "trusted": false,
TakesSettingsField: []any{"tags"}, "content": `{"tags": {}}`}}}
if got := TrustedMergeable(betterbird); len(got) != 0 {
t.Fatalf("files that name their keys put no whole layer at the terminal: %v", got)
}
if got := strings.Join(TerminalKeys(betterbird), ","); got != "places,accesses,accounts,address-books,calendars,filters,identities,prefs" {
t.Fatalf("the named keys of the trusted files, unmarked counting as trusted, and not tags: %s", got)
}
silent := Manifest{Module: "keycloak", Resources: []map[string]any{{"id": "runtime", "type": "file",
"path": "/var/lib/kc/runtime.json", "merge": MergeJSON, "content": `{}`}}}
if got := TrustedMergeable(silent); len(got) != 1 {
t.Fatalf("a file that names no keys still takes any key, so its module's layer stays the terminal's: %v", got)
}
}
// A file that asks for a setting says whether what it asks is trusted (novox/hq issue 339): `trusted` is a
// boolean, on a file alone, and a file asking for a setting without it is named.
func TestAFileSaysWhetherItsSettingsAreTrusted(t *testing.T) {
+6 -31
View File
@@ -217,16 +217,14 @@ var ControllerVerbs = []Verb{
Input: schema(map[string]string{"node": "the machine's name", "provision": "the provision"}, []string{"node", "provision"})},
{Name: "push", Description: "Send one machine everything it should be. With no machine named it is a push of the " +
"WHOLE mesh — every machine that is behind — and the answer says so first; behind says that outright. " +
"Answers at once that it is running, with a call id: `calls` with that id says what it sent and, for a " +
"machine named, how that machine applied it — applied, refused with its reason, or each failed resource — " +
"once it reports within wait (a push can reload the bus, which then refuses any answer still to come). A push by hand is a repair, " +
"Answers at once that it is running, with a call id: `calls` with that id says what it sent " +
"(a push can reload the bus, which then refuses any answer still to come). A push by hand is a repair, " +
"and says why: recorded in the hand-act log (novox/hq to-be 45 §7). A machine whose push would give a " +
"running module's data another directory is held and named, the others sent (novox/hq ADR 0217): read " +
"`plan` with diff, and name the machine with move to send it.",
Input: schema(map[string]string{
"node": "the machine's name; without it, every machine that is behind",
"move": "with node: the modules whose data may move with this push, comma-separated (optional)",
"wait": "with node: how long the call waits for the machine to report how it applied what it was sent, as \"90s\" or \"2m\"; \"0\" waits for nothing; without it, the controller's setting push-wait (optional)",
"behind": "\"true\": every machine that is behind, the whole mesh — the same as naming none, said outright; not with node",
"why": "why this is pushed by hand: recorded in the hand-act log",
"cause": "the cause in a word, or a condition's kind — the word a second push for the same reason uses (optional)",
@@ -299,33 +297,10 @@ var ControllerVerbs = []Verb{
"adopted": "\"true\" when the machine is in use and joins adopted",
}, nil, "adopted"),
Replaces: []string{"mesh-controller token issue", "wg set"}},
{Name: "config", Description: "Read or set config values (novox/hq issue 630). A module's preference resolves from " +
"four sources, the last set winning: its default; mesh config, a mesh-wide value for a predefined key; node " +
"config, a value for a predefined key on one node; and the module's own value on that node. Mesh and node " +
"config hold values only for the predefined keys, the catalogue's predefined.json, which a preference names " +
"as its predefined; there is no module layer for the whole mesh. Without key: the predefined keys with their " +
"mesh and node config, then each module's preferences with the value on each node and its source (default, " +
"mesh config, node config (<node>), <module> on <node>); node and module narrow it. With key and value (JSON; " +
"a bare word is a string): sets mesh config, or node config with node, or with module and node the module's own " +
"value on that node — refused for a key not on the list, a value of the wrong type, and a module without " +
"node. With clear instead of value: removes it, and the next source down applies. A set or clear says why, " +
"is recorded in the hand-act log with the caller, keeps the value it replaced in the history, and takes effect " +
"at the next push of the machines it reaches, which the answer names. With history: a key's past mesh or node " +
"config values.",
Input: schema(map[string]string{
"key": "a predefined key (mesh or node config), or with module one of that module's preferences",
"value": "the value as JSON (12, \"dark\", true); a bare word is a string",
"node": "one node: its node config, or with module that module's value there",
"module": "a module: its own value on node; needs node",
"clear": "\"true\" to remove the value instead of setting it",
"history": "\"true\": the key's past mesh config values, or with node its node config's",
"why": "why it is set or cleared, kept with the value and in the hand-act log; required to set or clear",
}, nil, "clear", "history")},
{Name: "settings", Description: "Read or set what an assignment is configured with: a module's settings on one " +
"machine; there is no module layer for the whole mesh (novox/hq issue 630) — a set or proposal without node is " +
"refused, and a mesh-wide value of a predefined key is the config verb's. Without values or clear, answers the layer as it stands — read it before setting it — " +
{Name: "settings", Description: "Read or set what an assignment is configured with: a module's settings for the whole " +
"mesh, or for one machine. Without values or clear, answers the layer as it stands — read it before setting it — " +
"then every value the module gives a default or a layer sets, with where it comes from: the module's default, " +
"mesh config, node config, or the module on the machine (novox/hq ADR 0262, issue 630); with list \"preferences\", or with no module, every " +
"the mesh, or the machine (novox/hq ADR 0262); with list \"preferences\", or with no module, every " +
"module's preferences and each machine's value; " +
"with history, the layers it replaced. Setting replaces that layer whole and answers each key it adds (+), " +
"changes (~) and removes (-); a set that would remove a key is refused unless replace says it is meant " +
@@ -340,7 +315,7 @@ var ControllerVerbs = []Verb{
Input: schema(map[string]string{
"module": "the module's name; with list, only that module's",
"values": "the settings as a JSON object, for set or propose",
"node": "one machine; needed to set or propose values (no module layer for the whole mesh); without it, clear removes a whole-mesh layer left over",
"node": "one machine; the whole mesh when absent",
"clear": "\"true\" to remove the layer instead of setting it, or to propose its removal; not with values",
"replace": "\"true\": with values, the set (or the proposal) is meant to remove the keys the layer had and it does not name",
"history": "\"true\": without values or clear, the layers this one replaced, the latest first",
-13
View File
@@ -59,10 +59,6 @@ type Facts struct {
Modules []Module `json:"modules"`
// Settings are the mesh-wide layer of every module's settings, scrubbed.
Settings []Settings `json:"settings,omitempty"`
// Predefined is the list of predefined keys the mesh holds, and Config the mesh config's value for
// each it sets, scrubbed (novox/hq issue 630). A machine's node config is on the machine.
Predefined map[string]PredefinedKey `json:"predefined,omitempty"`
Config map[string]any `json:"config,omitempty"`
// Edges are the build dependencies between modules, as the mesh recorded them — what a merge's
// rebuild width is computed from.
Edges []Edge `json:"edges,omitempty"`
@@ -147,9 +143,6 @@ type Machine struct {
Pins []Pin `json:"pins,omitempty"`
// Settings are its own layer of each module's settings, scrubbed.
Settings []Settings `json:"settings,omitempty"`
// Config is the node config of the machine: its value for each predefined key it sets, scrubbed
// (novox/hq issue 630).
Config map[string]any `json:"config,omitempty"`
// Accepted are the secrets a person gave the mesh for modules here, by name only: the mesh cannot
// make them, so a check composing this machine gives each a stand-in instead of refusing it.
Accepted []Accepted `json:"accepted,omitempty"`
@@ -180,12 +173,6 @@ type Accepted struct {
Local string `json:"local,omitempty"`
}
// PredefinedKey is one key of the list of predefined keys.
type PredefinedKey struct {
Type string `json:"type"`
Why string `json:"why"`
}
// Settings is one layer of one module's settings.
type Settings struct {
Module string `json:"module"`
+2 -28
View File
@@ -117,18 +117,6 @@ func (i *Inventory) RegisterModule(ctx context.Context, m catalogue.Manifest, fr
}
}
}
// **A preference falls back only to a key on the catalogue's list, and with its type** (novox/hq issue
// 630): mesh and node config hold values for the predefined keys alone, so a `predefined` the list does
// not hold is a fallback nobody can ever set.
if names := catalogue.PredefinedNamed(m); len(names) > 0 {
list, err := i.Predefined(ctx)
if err != nil {
return err
}
if problems := catalogue.PredefinedProblems(m, list); len(problems) > 0 {
return fmt.Errorf("%s is not registered: %s", m.Module, strings.Join(problems, "; "))
}
}
raw, err := json.Marshal(m)
if err != nil {
return err
@@ -865,14 +853,6 @@ func (i *Inventory) setSettings(ctx context.Context, nodeName, module string, va
} else if _, err := i.declared(ctx, module); err != nil {
return fmt.Errorf("%w: %s", ErrNoSuchModule, module)
}
if nodeName == "" && judge {
// **There is no module layer for the whole mesh** (novox/hq issue 630): an assignment is one module
// on one node. What every node should share is mesh config, for a predefined key. A layer the mesh
// already holds is still kept as it is (KeepSettings, the merge gate raising a store from facts).
return fmt.Errorf("%s: there is no module layer for the whole mesh — an assignment is one module on "+
"one node (novox/hq issue 630). Set it on each node with --node, or, for a predefined key, as mesh "+
"config with the config verb. Nothing was changed", module)
}
if nodeName == "" {
// A port is a fact about one machine (novox/hq ADR 0100). Refused here, in composition's
// words: stored, it refuses every node running the module at composition, and the mesh
@@ -1153,8 +1133,7 @@ func (i *Inventory) ClearSettingsBy(ctx context.Context, nodeName, module, clear
return tx.Commit(ctx)
}
// SettingsFor is the layers that apply to one module on one node, in the order they are applied: mesh
// config, node config (both marked Config), then the module's own layer on the node.
// SettingsFor is the layers that apply to one module on one node, in the order they are applied.
//
// The mesh's first, then the node's, so a node that differs is expressed by differing rather
// than by restating everything the rest of the mesh already says.
@@ -1172,12 +1151,7 @@ func (i *Inventory) SettingsFor(ctx context.Context, nodeName, module string) ([
}
defer rows.Close()
// Mesh config, then node config, for the predefined keys the module's preferences fall back to
// (novox/hq issue 630): under the module's own layers, over its defaults.
layers, err := i.configLayers(ctx, node.ID, nodeName, module)
if err != nil {
return nil, err
}
var layers []catalogue.Layer
for rows.Next() {
var meshWide bool
var raw []byte
-339
View File
@@ -1,339 +0,0 @@
package inventory
import (
"context"
"encoding/json"
"errors"
"fmt"
"sort"
"strings"
"time"
"github.com/jackc/pgx/v5"
"github.com/novox/mesh-controller/internal/catalogue"
)
// Mesh config and node config (novox/hq issue 630): a value for a predefined key, for the whole mesh or for
// one node, that every module whose preference names the key falls back to. The list of predefined keys is
// the catalogue's predefined.json, kept here as the last build of the catalogue carried it.
// ErrNotPredefined is a key mesh or node config was asked to hold that is not on the list.
var ErrNotPredefined = errors.New("not a predefined key")
// SetPredefined replaces the list of predefined keys with the one a build of `repository` at `commit`
// carried. Replaced whole: the file is a statement of the whole list.
func (i *Inventory) SetPredefined(ctx context.Context, list catalogue.Predefined, repository, commit string) error {
tx, err := i.store.Pool().Begin(ctx)
if err != nil {
return err
}
defer func() { _ = tx.Rollback(context.WithoutCancel(ctx)) }()
if _, err := tx.Exec(ctx, `delete from predefined_key`); err != nil {
return err
}
for _, key := range list.Keys() {
k := list[key]
if _, err := tx.Exec(ctx,
`insert into predefined_key (key, type, why, repository, commit) values ($1, $2, $3, $4, $5)`,
key, k.Type, k.Why, nullable(repository), nullable(commit)); err != nil {
return err
}
}
return tx.Commit(ctx)
}
// Predefined is the list of predefined keys the mesh holds; empty before a build of the catalogue carried one.
func (i *Inventory) Predefined(ctx context.Context) (catalogue.Predefined, error) {
rows, err := i.store.Pool().Query(ctx, `select key, type, why from predefined_key`)
if err != nil {
return nil, err
}
defer rows.Close()
out := catalogue.Predefined{}
for rows.Next() {
var key string
var k catalogue.PredefinedKey
if err := rows.Scan(&key, &k.Type, &k.Why); err != nil {
return nil, err
}
out[key] = k
}
return out, rows.Err()
}
// ConfigValue is one value of mesh config (Node "") or node config.
type ConfigValue struct {
Key string
Node string
Value any
SetAt time.Time
SetBy string
Why string
}
// Config is every value of mesh config and node config, by key, the mesh's first, then by node.
func (i *Inventory) Config(ctx context.Context) ([]ConfigValue, error) {
rows, err := i.store.Pool().Query(ctx,
`select c.key, coalesce(n.name, ''), c.value, c.set_at, coalesce(c.set_by, ''), c.why
from config c left join node n on n.id = c.node
order by c.key, c.node is not null, n.name`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []ConfigValue
for rows.Next() {
var v ConfigValue
var raw []byte
if err := rows.Scan(&v.Key, &v.Node, &raw, &v.SetAt, &v.SetBy, &v.Why); err != nil {
return nil, err
}
if err := json.Unmarshal(raw, &v.Value); err != nil {
return nil, err
}
out = append(out, v)
}
return out, rows.Err()
}
// SetConfig sets a predefined key's value for the whole mesh (node "") or for one node. Refused for a key not
// on the list and for a value not of the key's type. The value it replaces is kept in config_history.
func (i *Inventory) SetConfig(ctx context.Context, key, nodeName string, value any, setBy, why string) error {
if strings.TrimSpace(why) == "" {
return errors.New("config says why: one sentence, kept with the value")
}
if err := i.JudgeConfig(ctx, key, nodeName, value); err != nil {
return err
}
raw, err := json.Marshal(value)
if err != nil {
return err
}
nodeID, err := i.layerNode(ctx, nodeName)
if err != nil {
return err
}
tx, err := i.store.Pool().Begin(ctx)
if err != nil {
return err
}
defer func() { _ = tx.Rollback(context.WithoutCancel(ctx)) }()
if _, err := tx.Exec(ctx, keepConfigSQL, key, nodeID, "set", why); err != nil {
return err
}
if _, err := tx.Exec(ctx,
`delete from config where key = $1 and node is not distinct from $2::uuid`, key, nodeID); err != nil {
return err
}
if _, err := tx.Exec(ctx,
`insert into config (key, node, value, set_by, why) values ($1, $2::uuid, $3, $4, $5)`,
key, nodeID, raw, nullable(setBy), why); err != nil {
return err
}
return tx.Commit(ctx)
}
// JudgeConfig refuses, before anything is done, a value mesh or node config may not hold: a key not on the
// list of predefined keys, or a value not of the key's type.
func (i *Inventory) JudgeConfig(ctx context.Context, key, nodeName string, value any) error {
list, err := i.Predefined(ctx)
if err != nil {
return err
}
k, on := list[key]
if !on {
return notPredefined(key, nodeName, list)
}
return catalogue.ConfigValueProblem(fmt.Sprintf("the predefined key %q", key), k.Type, value)
}
// ClearConfig removes a predefined key's value for the whole mesh (node "") or for one node, keeping it in
// config_history; false when there was none. A key no longer on the list may still be cleared.
func (i *Inventory) ClearConfig(ctx context.Context, key, nodeName, clearedBy, why string) (bool, error) {
if strings.TrimSpace(why) == "" {
return false, errors.New("config says why: one sentence, kept with the history")
}
nodeID, err := i.layerNode(ctx, nodeName)
if err != nil {
return false, err
}
tx, err := i.store.Pool().Begin(ctx)
if err != nil {
return false, err
}
defer func() { _ = tx.Rollback(context.WithoutCancel(ctx)) }()
if _, err := tx.Exec(ctx, keepConfigSQL, key, nodeID, "clear", why); err != nil {
return false, err
}
if clearedBy != "" {
if _, err := tx.Exec(ctx,
`update config_history set replaced_by = 'clear by ' || $3
where key = $1 and node is not distinct from $2::uuid
and replaced_at = (select max(replaced_at) from config_history
where key = $1 and node is not distinct from $2::uuid)`,
key, nodeID, clearedBy); err != nil {
return false, err
}
}
tag, err := tx.Exec(ctx, `delete from config where key = $1 and node is not distinct from $2::uuid`, key, nodeID)
if err != nil {
return false, err
}
return tag.RowsAffected() > 0, tx.Commit(ctx)
}
// PastConfig is one value of mesh or node config that was replaced or cleared.
type PastConfig struct {
Value any
SetAt time.Time
SetBy string
Why string
ReplacedAt time.Time
ReplacedBy string
ReplacedWhy string
}
// ConfigHistory is the values a predefined key had for the whole mesh (node "") or one node, latest first.
func (i *Inventory) ConfigHistory(ctx context.Context, key, nodeName string) ([]PastConfig, error) {
nodeID, err := i.layerNode(ctx, nodeName)
if err != nil {
return nil, err
}
rows, err := i.store.Pool().Query(ctx,
`select value, coalesce(set_at, 'epoch'), coalesce(set_by, ''), coalesce(why, ''), replaced_at, replaced_by,
coalesce(replaced_why, '')
from config_history where key = $1 and node is not distinct from $2::uuid order by replaced_at desc`,
key, nodeID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []PastConfig
for rows.Next() {
var p PastConfig
var raw []byte
if err := rows.Scan(&raw, &p.SetAt, &p.SetBy, &p.Why, &p.ReplacedAt, &p.ReplacedBy, &p.ReplacedWhy); err != nil {
return nil, err
}
if err := json.Unmarshal(raw, &p.Value); err != nil {
return nil, err
}
out = append(out, p)
}
return out, rows.Err()
}
// keepConfigSQL copies the value about to be replaced or cleared into the history, with why it goes.
const keepConfigSQL = `insert into config_history (key, node, value, set_at, set_by, why, replaced_by, replaced_why)
select key, node, value, set_at, set_by, why, $3, $4 from config
where key = $1 and node is not distinct from $2::uuid`
func notPredefined(key, nodeName string, list catalogue.Predefined) error {
where := "mesh config"
if nodeName != "" {
where = "node config"
}
listed := "the list holds none yet: it arrives with a build of the catalogue carrying " + catalogue.PredefinedFile
if len(list) > 0 {
listed = "the list holds " + strings.Join(list.Keys(), ", ")
}
return fmt.Errorf("%w: %s holds values only for the predefined keys, and %q is not one (%s); a module's own "+
"value is set with module and node", ErrNotPredefined, where, key, listed)
}
// configLayers are the layers mesh config and node config make for one module on one node: nothing when
// neither holds a value, or the module names no predefined key.
func (i *Inventory) configLayers(ctx context.Context, nodeID, nodeName, module string) ([]catalogue.Layer, error) {
rows, err := i.store.Pool().Query(ctx,
`select key, node is null, value from config where node is null or node = $1`, nodeID)
if err != nil {
return nil, err
}
defer rows.Close()
mesh, node := map[string]any{}, map[string]any{}
for rows.Next() {
var key string
var meshWide bool
var raw []byte
if err := rows.Scan(&key, &meshWide, &raw); err != nil {
return nil, err
}
var v any
if err := json.Unmarshal(raw, &v); err != nil {
return nil, err
}
if meshWide {
mesh[key] = v
} else {
node[key] = v
}
}
if err := rows.Err(); err != nil {
return nil, err
}
if len(mesh)+len(node) == 0 {
return nil, nil
}
// Only the module's preferences, read from its stored definition: a whole catalogue per module per
// node would be read for every machine planned.
var raw []byte
err = i.store.Pool().QueryRow(ctx, `select manifest -> 'preferences' from module where name = $1`, module).Scan(&raw)
if errors.Is(err, pgx.ErrNoRows) || len(raw) == 0 || string(raw) == "null" {
return nil, nil
}
if err != nil {
return nil, err
}
var prefs map[string]catalogue.Preference
if err := json.Unmarshal(raw, &prefs); err != nil {
return nil, fmt.Errorf("%s's preferences cannot be read: %w", module, err)
}
return catalogue.ConfigLayers(catalogue.Manifest{Module: module, Preferences: prefs}, mesh, node, nodeName), nil
}
// SetModuleValue sets or clears one key of a module's own layer on one node — its module-assignment config —
// keeping the rest of the layer, judged as `settings set` judges it, with the layer it replaces kept.
// value nil clears the key; the layer goes when no key is left.
func (i *Inventory) SetModuleValue(ctx context.Context, nodeName, module, key string, value any, setBy string) (
before any, had bool, err error) {
if nodeName == "" {
return nil, false, errors.New("there is no module layer for the whole mesh: a module's own value is " +
"set on one node (novox/hq issue 630); for every node, set mesh config for a predefined key")
}
layer, has, err := i.Layer(ctx, nodeName, module)
if err != nil {
return nil, false, err
}
values := map[string]any{}
for k, v := range layer {
values[k] = v
}
before, had = values[key]
if value == nil {
if !had {
return nil, false, nil
}
delete(values, key)
if len(values) == 0 && has {
return before, had, i.ClearSettingsBy(ctx, nodeName, module, setBy)
}
} else {
values[key] = value
}
return before, had, i.SetSettingsBy(ctx, nodeName, module, values, setBy)
}
// ConfigKeysSorted is the keys of a set of config values, sorted, for saying them.
func ConfigKeysSorted(values []ConfigValue) []string {
seen := map[string]bool{}
var out []string
for _, v := range values {
if !seen[v.Key] {
seen[v.Key] = true
out = append(out, v.Key)
}
}
sort.Strings(out)
return out
}
-274
View File
@@ -1,274 +0,0 @@
package inventory
import (
"encoding/json"
"errors"
"strings"
"testing"
"github.com/novox/mesh-controller/internal/catalogue"
)
// dunstLike is a module whose font-size falls back to the predefined key font-size (novox/hq issue 630).
func dunstLike() catalogue.Manifest {
return catalogue.Manifest{Module: "dunst", Version: "1",
Preferences: map[string]catalogue.Preference{
"font-size": {Type: catalogue.TypeNumber, Default: float64(10), Predefined: "font-size", Why: "readable at 100 DPI"},
"width": {Type: catalogue.TypeNumber, Default: float64(250), Why: "forty characters"},
},
Resources: []map[string]any{{"id": "rc", "type": "file", "path": "/etc/dunstrc", "mode": "0644",
"content": "font = Inter ${setting:font-size}\nwidth = ${setting:width}\n"}}}
}
func fontList() catalogue.Predefined {
return catalogue.Predefined{"font-size": {Type: catalogue.TypeNumber, Why: "the size text is drawn at"}}
}
// effectiveOn is a module's value for a key on a node, and its source, as composition reads them.
func effectiveOn(t *testing.T, inv *Inventory, node, key string) (any, string) {
t.Helper()
layers, err := inv.SettingsFor(t.Context(), node, "dunst")
if err != nil {
t.Fatal(err)
}
for _, s := range catalogue.Effective(dunstLike(), layers) {
if s.Key == key {
if s.FromDefault {
return s.Value, catalogue.DefaultLayer
}
return s.Value, s.From
}
}
t.Fatalf("no %s", key)
return nil, ""
}
// Each of the four sources in turn, the most specific winning, and clearing each falls back to the next
// one down (novox/hq issue 630).
func TestAConfigValueComesFromFourSourcesTheMostSpecificWinning(t *testing.T) {
inv := fresh(t)
ctx := t.Context()
for _, n := range []string{"g14", "shanks"} {
if _, err := inv.AddNode(ctx, n); err != nil {
t.Fatal(err)
}
}
if err := inv.SetPredefined(ctx, fontList(), "novox/mesh-catalog", "c1"); err != nil {
t.Fatal(err)
}
if err := inv.RegisterModule(ctx, dunstLike(), Source{}); err != nil {
t.Fatal(err)
}
for _, n := range []string{"g14", "shanks"} {
if _, err := inv.Assign(ctx, n, "dunst"); err != nil {
t.Fatal(err)
}
}
want := func(node string, value float64, from string) {
t.Helper()
v, f := effectiveOn(t, inv, node, "font-size")
if v != value || f != from {
t.Fatalf("font-size on %s: %v from %q, want %v from %q", node, v, f, value, from)
}
}
want("g14", 10, "default")
if err := inv.SetConfig(ctx, "font-size", "", float64(11), "the operator", "a larger default everywhere"); err != nil {
t.Fatal(err)
}
want("g14", 11, "mesh config")
want("shanks", 11, "mesh config")
if err := inv.SetConfig(ctx, "font-size", "g14", float64(13), "the operator", "a dense screen"); err != nil {
t.Fatal(err)
}
want("g14", 13, "node config (g14)")
want("shanks", 11, "mesh config")
if _, _, err := inv.SetModuleValue(ctx, "g14", "dunst", "font-size", float64(15), "the operator"); err != nil {
t.Fatal(err)
}
want("g14", 15, "g14")
want("shanks", 11, "mesh config")
// A preference without predefined reads only its default and the module's own value.
if v, f := effectiveOn(t, inv, "g14", "width"); v != float64(250) || f != "default" {
t.Fatalf("width: %v from %s", v, f)
}
// Clearing each falls back to the next source down.
if _, _, err := inv.SetModuleValue(ctx, "g14", "dunst", "font-size", nil, "the operator"); err != nil {
t.Fatal(err)
}
want("g14", 13, "node config (g14)")
if removed, err := inv.ClearConfig(ctx, "font-size", "g14", "the operator", "back to the mesh's"); err != nil || !removed {
t.Fatalf("clear node config: %v %v", removed, err)
}
want("g14", 11, "mesh config")
if removed, err := inv.ClearConfig(ctx, "font-size", "", "the operator", "back to the default"); err != nil || !removed {
t.Fatalf("clear mesh config: %v %v", removed, err)
}
want("g14", 10, "default")
if _, has, _ := inv.Layer(ctx, "g14", "dunst"); has {
t.Fatal("the module's layer was left empty rather than removed")
}
// What each replaced is kept, with why.
past, err := inv.ConfigHistory(ctx, "font-size", "g14")
if err != nil || len(past) != 1 || past[0].Value != float64(13) || past[0].Why != "a dense screen" ||
past[0].ReplacedBy != "clear by the operator" || past[0].ReplacedWhy != "back to the mesh's" {
t.Fatalf("node config history: %+v %v", past, err)
}
}
// Mesh and node config hold values for predefined keys only, of their type; a module value needs a node.
func TestConfigRefusesAKeyNotPredefinedAWrongTypeAndAModuleValueForTheWholeMesh(t *testing.T) {
inv := fresh(t)
ctx := t.Context()
if _, err := inv.AddNode(ctx, "g14"); err != nil {
t.Fatal(err)
}
err := inv.SetConfig(ctx, "font-size", "", float64(12), "the operator", "bigger")
if !errors.Is(err, ErrNotPredefined) || !strings.Contains(err.Error(), "the list holds none yet") {
t.Fatalf("a key before any list: %v", err)
}
if err := inv.SetPredefined(ctx, fontList(), "", ""); err != nil {
t.Fatal(err)
}
err = inv.SetConfig(ctx, "colour", "g14", "blue", "the operator", "blue")
if !errors.Is(err, ErrNotPredefined) || !strings.Contains(err.Error(), "node config") ||
!strings.Contains(err.Error(), "font-size") {
t.Fatalf("a key not on the list: %v", err)
}
if err := inv.SetConfig(ctx, "font-size", "", "large", "the operator", "bigger"); err == nil ||
!strings.Contains(err.Error(), "is a number") {
t.Fatalf("a string for a number: %v", err)
}
if err := inv.SetConfig(ctx, "font-size", "", float64(12), "the operator", ""); err == nil {
t.Fatal("a value without why was kept")
}
if err := inv.SetConfig(ctx, "font-size", "nowhere", float64(12), "the operator", "x"); err == nil {
t.Fatal("node config for a node the mesh does not know was kept")
}
if _, _, err := inv.SetModuleValue(ctx, "", "dunst", "font-size", float64(12), "the operator"); err == nil ||
!strings.Contains(err.Error(), "no module layer for the whole mesh") {
t.Fatalf("a module value for the whole mesh: %v", err)
}
if values, err := inv.Config(ctx); err != nil || len(values) != 0 {
t.Fatalf("a refused value was kept: %v %v", values, err)
}
}
// Registration refuses a preference whose predefined key is not on the list, or whose type differs.
func TestRegistrationJudgesPreferencesAgainstThePredefinedList(t *testing.T) {
inv := fresh(t)
ctx := t.Context()
err := inv.RegisterModule(ctx, dunstLike(), Source{})
if err == nil || !strings.Contains(err.Error(), `predefined key "font-size"`) {
t.Fatalf("a predefined key before any list: %v", err)
}
if err := inv.SetPredefined(ctx, catalogue.Predefined{"font-size": {Type: catalogue.TypeString, Why: "x"}}, "", ""); err != nil {
t.Fatal(err)
}
err = inv.RegisterModule(ctx, dunstLike(), Source{})
if err == nil || !strings.Contains(err.Error(), "is a number") || !strings.Contains(err.Error(), "is a string") {
t.Fatalf("a type other than the list's: %v", err)
}
if err := inv.SetPredefined(ctx, fontList(), "", ""); err != nil {
t.Fatal(err)
}
if err := inv.RegisterModule(ctx, dunstLike(), Source{}); err != nil {
t.Fatal(err)
}
}
// The module layer for the whole mesh is moved into each assigned node's layer by migration 0096: the mesh's
// values under the node's, maps merged key by key, every replaced layer kept in the history (novox/hq issue 630).
func TestTheWholeMeshModuleLayerMovesIntoEachAssignedNode(t *testing.T) {
inv := fresh(t)
ctx := t.Context()
for _, n := range []string{"novox", "g14", "shanks"} {
if _, err := inv.AddNode(ctx, n); err != nil {
t.Fatal(err)
}
}
m := catalogue.Manifest{Module: "artifact-store-tools", Version: "1",
Resources: []map[string]any{{"id": "conf", "type": "file", "path": "/etc/t.json", "content": "{}", "merge": "json"}}}
if err := inv.RegisterModule(ctx, m, Source{}); err != nil {
t.Fatal(err)
}
for _, n := range []string{"novox", "g14"} {
if _, err := inv.Assign(ctx, n, "artifact-store-tools"); err != nil {
t.Fatal(err)
}
}
// As held before the migration: a whole-mesh layer, and g14's own layer over it.
if err := inv.KeepSettings(ctx, "", "artifact-store-tools", map[string]any{
"older-store-names": "registry-api.novox.be", "nested": map[string]any{"a": 1, "b": 1}}); err != nil {
t.Fatal(err)
}
if err := inv.SetSettings(ctx, "g14", "artifact-store-tools", map[string]any{
"nested": map[string]any{"b": 2}, "own": true}); err != nil {
t.Fatal(err)
}
migrations, err := Migrations()
if err != nil {
t.Fatal(err)
}
var moving string
for _, mig := range migrations {
if mig.Number == 96 {
moving = mig.SQL[strings.Index(mig.SQL, "create function pg_temp"):]
}
}
if moving == "" {
t.Fatal("migration 0096 moves no whole-mesh layer")
}
tx, err := inv.store.Pool().Begin(ctx)
if err != nil {
t.Fatal(err)
}
if _, err := tx.Exec(ctx, moving); err != nil {
t.Fatal(err)
}
if err := tx.Commit(ctx); err != nil {
t.Fatal(err)
}
layer := func(node string) string {
t.Helper()
values, _, err := inv.Layer(ctx, node, "artifact-store-tools")
if err != nil {
t.Fatal(err)
}
raw, _ := json.Marshal(values)
return string(raw)
}
if got := layer("novox"); got != `{"nested":{"a":1,"b":1},"older-store-names":"registry-api.novox.be"}` {
t.Fatalf("novox's layer: %s", got)
}
if got := layer("g14"); got != `{"nested":{"a":1,"b":2},"older-store-names":"registry-api.novox.be","own":true}` {
t.Fatalf("g14's layer, the mesh's under its own: %s", got)
}
if got := layer("shanks"); got != "null" && got != "{}" {
t.Fatalf("a node the module is not assigned to gained a layer: %s", got)
}
if _, has, _ := inv.Layer(ctx, "", "artifact-store-tools"); has {
t.Fatal("the whole-mesh layer is still there")
}
mesh, err := inv.SettingsHistory(ctx, "", "artifact-store-tools")
if err != nil || len(mesh) != 1 || mesh[0].ReplacedBy != "moved-to-nodes" ||
mesh[0].Values["older-store-names"] != "registry-api.novox.be" {
t.Fatalf("the whole-mesh layer's history: %+v %v", mesh, err)
}
past, err := inv.SettingsHistory(ctx, "g14", "artifact-store-tools")
if err != nil || len(past) != 1 || past[0].ReplacedBy != "moved-to-nodes" || past[0].Values["own"] != true {
t.Fatalf("g14's replaced layer: %+v %v", past, err)
}
// And composition reads the same values on each node as before.
layers, err := inv.SettingsFor(ctx, "g14", "artifact-store-tools")
if err != nil || len(layers) != 1 {
t.Fatalf("g14's layers: %+v %v", layers, err)
}
}
+1 -64
View File
@@ -58,42 +58,7 @@ func (i *Inventory) Dependencies(ctx context.Context) ([]Edge, error) {
if err != nil {
return nil, err
}
held, err := i.Held(ctx)
if err != nil {
return nil, err
}
return dependenciesWith(entries, against, held), nil
}
// packagesHeld is, by package name, the module whose artifact the mesh holds as that package: a
// `package` artifact's reference is `<name>@<version>` (the builder's publishPackage), never an
// artifact-store reference, so a build that stood on it recorded the package's name and version.
func packagesHeld(held map[string]string) map[string]string {
out := map[string]string{}
for key, reference := range held {
module, _, ok := strings.Cut(key, "/")
if !ok {
continue
}
if name := packageName(reference); name != "" {
out[name] = module
}
}
return out
}
// packageName is the name of a `<name>@<version>` package reference, or "" for anything else: a
// kept or pinned image (`…@sha256:…`), a URL, or a reference with no version.
func packageName(reference string) string {
if strings.HasPrefix(reference, catalogue.ArtifactStoreScheme) || strings.Contains(reference, "@sha256:") ||
strings.Contains(reference, "://") {
return ""
}
at := strings.LastIndex(reference, "@")
if at <= 0 || at == len(reference)-1 {
return ""
}
return reference[:at]
return dependenciesOf(entries, against, nil), nil
}
// dependenciesOf is Dependencies over what was read, so a test can hand it a catalogue.
@@ -101,20 +66,6 @@ func packageName(reference string) string {
// `read` draws no edge (novox/hq ADR 0267 rule 4): what a build read moves it through its build source, in
// the planner, never through the relation.
func dependenciesOf(entries []Entry, against map[string][]string, _ map[string][]ReadRepository) []Edge {
return dependenciesWith(entries, against, nil)
}
// dependenciesWith is the relation with the artifacts the mesh holds, "<module>/<artifact>" to its
// reference (Held), so a package a build stood on is read as its module's.
//
// **A built manifest carries no `build`** (catalogue.Resolve drops it), so what a module declared it
// is built on is read from what is kept of it once it is built (novox/hq issue 614): the bases its
// newest build was handed (`against`) — an artifact-store reference, or a package's `<name>@<version>`
// — and the language of every bundle it compiled (`Bundles`). Read only from records the mesh already
// keeps, so it holds for every module built before it, and a controller rolled back to one without it
// reads the same records as before.
func dependenciesWith(entries []Entry, against map[string][]string, held map[string]string) []Edge {
packages := packagesHeld(held)
known := map[string]bool{}
var builders []string
for _, e := range entries {
@@ -160,13 +111,6 @@ func dependenciesWith(entries []Entry, against map[string][]string, held map[str
}
}
}
// **And once it is built**, from the bundles its resolved manifest keeps (novox/hq issue 614): the
// `build` it was declared with is gone by then, and a toolchain that moved rebuilt none of them.
for _, b := range e.Manifest.Bundles {
if chain, err := builder.ToolchainFor(b.Language); err == nil {
add(name, chain.Base, EdgeStandsOn)
}
}
for _, ref := range against[name] {
if rest, ok := strings.CutPrefix(ref, catalogue.ArtifactStoreScheme); ok {
// A copy of an upstream image is no module's artifact (novox/hq ADR 0257): its
@@ -177,13 +121,6 @@ func dependenciesWith(entries []Entry, against map[string][]string, held map[str
if base, _, found := strings.Cut(rest, "/"); found {
add(name, base, EdgeStandsOn)
}
continue
}
// A package base (`build.on` naming a module's `package` artifact) is recorded as the
// package's name and version (novox/hq issue 614): the module that publishes that package
// is what it stood on, whichever version the mesh holds now.
if base, ok := packages[packageName(ref)]; ok {
add(name, base, EdgeStandsOn)
}
}
if e.Source.Repository != "" {
-72
View File
@@ -1,7 +1,6 @@
package inventory
import (
"strings"
"testing"
"github.com/novox/mesh-controller/internal/catalogue"
@@ -175,74 +174,3 @@ func TestACopyOfAnUpstreamImageIsNoEdge(t *testing.T) {
}
}
}
// **A built module keeps its build edges** (novox/hq issue 614). A manifest the mesh registers from a
// build carries no `build` (catalogue.Resolve drops it), so mesh-tools' `build.on` naming mesh-sdk's
// `lib` package and a provider's TypeScript bundle drew no edge once built, and a new SDK rebuilt
// neither. The package base is read from what the build was handed (`@novox/mesh-sdk@0.1.13`, the
// package's name whichever version the mesh holds now), the toolchain from the bundles it keeps.
func TestABuiltModuleKeepsItsBuildEdges(t *testing.T) {
built := func(name string, bundles ...catalogue.Bundle) Entry {
return Entry{Manifest: catalogue.Manifest{Module: name, Bundles: bundles},
Source: Source{Repository: "http://forge/novox/" + name + ".git"}}
}
entries := []Entry{
built("mesh-sdk"),
built("mesh-tools"),
built("mesh-tools-go"),
built("minio", catalogue.Bundle{Name: "code", Language: "typescript"}),
built("umami", catalogue.Bundle{Name: "code", Language: "TypeScript"}),
built("dnsmasq", catalogue.Bundle{Name: "tools", Language: "go"}),
built("plain"),
}
against := map[string][]string{
"mesh-tools": {"@novox/mesh-sdk@0.1.13",
catalogue.ArtifactStoreScheme + "upstream/docker.io/library/node@sha256:" + strings.Repeat("0", 64)},
// A package nobody in the mesh publishes is no edge.
"plain": {"left-pad@1.3.0"},
}
held := map[string]string{
"mesh-sdk/lib": "@novox/mesh-sdk@0.1.14",
"mesh-tools/runtime": catalogue.ArtifactStoreScheme + "mesh-tools/runtime@sha256:" + strings.Repeat("1", 64),
}
got := dependenciesWith(entries, against, held)
has := func(from, to string) bool {
for _, e := range got {
if e.From == from && e.To == to && e.Kind == EdgeStandsOn {
return true
}
}
return false
}
for _, want := range [][2]string{
{"mesh-tools", "mesh-sdk"},
{"minio", "mesh-tools"},
{"umami", "mesh-tools"},
{"dnsmasq", "mesh-tools-go"},
} {
if !has(want[0], want[1]) {
t.Errorf("no %s stands-on %s: %+v", want[0], want[1], got)
}
}
for _, e := range got {
if e.From == "plain" && e.Kind != EdgeBuiltBy {
t.Errorf("an unknown package drew an edge: %+v", e)
}
}
}
func TestAPackageNameIsReadOnlyFromAPackageReference(t *testing.T) {
for ref, want := range map[string]string{
"@novox/mesh-sdk@0.1.14": "@novox/mesh-sdk",
"left-pad@1.3.0": "left-pad",
"@novox/mesh-sdk": "",
"novox.internal:5100/mesh-tools/build@sha256:" + strings.Repeat("0", 64): "",
catalogue.ArtifactStoreScheme + "mesh-tools/build@sha256:0": "",
"http://store/v2/x/blobs/sha256:0": "",
"": "",
} {
if got := packageName(ref); got != want {
t.Errorf("packageName(%q) = %q, want %q", ref, got, want)
}
}
}
+2 -2
View File
@@ -39,7 +39,7 @@ func TestRegisteringAModuleAgainKeepsWhatTheMeshHoldsForIt(t *testing.T) {
if err := inv.RegisterModule(ctx, m, Source{}); err != nil {
t.Fatal(err)
}
if err := inv.KeepSettings(ctx, "", "step-ca", map[string]any{"issuer": "the mesh"}); err != nil {
if err := inv.SetSettings(ctx, "", "step-ca", map[string]any{"issuer": "the mesh"}); err != nil {
t.Fatal(err)
}
if err := inv.SetSettings(ctx, "anchor", "step-ca", map[string]any{"port": 9000}); err != nil {
@@ -162,7 +162,7 @@ func TestDiscardingAModuleSaysWhatWentWithIt(t *testing.T) {
if err := inv.RegisterModule(ctx, withOwnSecret(manifest("step-ca", nil, nil), "password"), Source{}); err != nil {
t.Fatal(err)
}
if err := inv.KeepSettings(ctx, "", "step-ca", map[string]any{"issuer": "the mesh"}); err != nil {
if err := inv.SetSettings(ctx, "", "step-ca", map[string]any{"issuer": "the mesh"}); err != nil {
t.Fatal(err)
}
if err := inv.AcceptSecretForModule(ctx, "anchor", "step-ca", "password", "sealed"); err != nil {
+1 -7
View File
@@ -44,13 +44,7 @@ type Wait struct {
Part string `json:"part"`
Secret string `json:"secret,omitempty"`
Setting string `json:"setting,omitempty"`
// Provision is a provision the module requires whose credential only a person can give and nobody gave yet
// (novox/hq ADR 0337 §5): a wait names exactly one of Secret, Setting or Provision.
Provision string `json:"provision,omitempty"`
What string `json:"what"`
// Act is the line that gives a provision's credential, filled by the controller when it excuses the wait
// (ADR 0337 §7); never said by a machine.
Act string `json:"act,omitempty"`
What string `json:"what"`
}
// NodeHealth is a machine's newest statement, as kept.
+1 -10
View File
@@ -36,16 +36,7 @@ type MadeOf struct {
BootedAt *time.Time `json:"booted_at"`
Timezone string `json:"timezone"`
Filesystems []MountedFilesystem `json:"filesystems"`
// Networks is every network the machine is directly attached to on a link facing outside it, as
// link and network (novox/hq ADR 0340 §7): what a port reached from `local-network` resolves from.
Networks []AttachedNetwork `json:"networks"`
Unknown map[string]string `json:"unknown"`
}
// AttachedNetwork is one network a machine said it is attached to, and the link it is on.
type AttachedNetwork struct {
Link string `json:"link"`
Network string `json:"network"`
Unknown map[string]string `json:"unknown"`
}
// MountedFilesystem is one local filesystem a machine has mounted.
-15
View File
@@ -40,18 +40,3 @@ func TestAProfileWithoutTheFactsIsAnOlderNodeEngineNotNothing(t *testing.T) {
t.Error("a node that never reported read as having reported")
}
}
// The networks a machine is attached to (novox/hq ADR 0340 §7), in the node-engine's words: what a port
// reached from `local-network` resolves from.
func TestAMachinesAttachedNetworksAreRead(t *testing.T) {
a, had, err := machineFrom([]byte(`{"architecture":"amd64","kernel":"linux",
"machine":{"observed_at":"2026-10-13T09:00:00Z",
"networks":[{"link":"eth0","network":"192.168.1.0/24"},{"link":"eth1","network":"203.0.113.0/24"}]}}`))
if err != nil || !had || a.Machine == nil {
t.Fatalf("%+v had %v err %v", a, had, err)
}
n := a.Machine.Networks
if len(n) != 2 || n[0].Link != "eth0" || n[0].Network != "192.168.1.0/24" || n[1].Network != "203.0.113.0/24" {
t.Fatalf("networks read as %+v", n)
}
}
@@ -1,92 +0,0 @@
-- A config value comes from four sources, the last set winning (novox/hq issue 630): the module's
-- default, mesh config, node config, and the module's own value on the node. Mesh config and node config
-- hold values only for the predefined keys, a fixed list kept in the catalogue (`predefined.json`). An
-- assignment is one module on one node: there is no module layer for the whole mesh any more.
-- The list of predefined keys, as the catalogue's predefined.json last said it. Replaced whole when a
-- build of the catalogue carries the file; `repository` and `commit` say which.
create table predefined_key (
key text primary key,
type text not null check (type in ('number', 'string', 'boolean')),
why text not null,
repository text,
commit text,
listed_at timestamptz not null default now()
);
-- Mesh config (node null) and node config: one value per predefined key, and per node. Not a foreign key
-- to predefined_key: a key the list drops keeps its value, said as reaching nothing, rather than losing it.
create table config (
key text not null,
node uuid references node(id) on delete cascade,
value jsonb not null,
set_at timestamptz not null default now(),
set_by text,
why text not null
);
create unique index config_for_the_mesh on config (key) where node is null;
create unique index config_for_a_node on config (key, node) where node is not null;
-- Every value that was replaced or cleared, with who set it and why, and who replaced it and why.
create table config_history (
key text not null,
node uuid references node(id) on delete cascade,
value jsonb not null,
set_at timestamptz,
set_by text,
why text,
replaced_at timestamptz not null default now(),
-- set · clear
replaced_by text not null,
replaced_why text
);
create index config_history_by_key on config_history (key, node, replaced_at desc);
-- The module layer for the whole mesh goes: each one is laid under the layer of every node the module is
-- assigned to — the mesh's values first, the node's over them, maps merged key by key as composition
-- merged them — so every machine composes what it did. Every layer replaced, and the mesh-wide layer
-- itself, is kept in settings_history (ADR 0217) with the reason `moved-to-nodes`. A mesh-wide layer of
-- a module assigned nowhere is kept in the history only: no machine read it.
create function pg_temp.config_630_merge(under jsonb, over jsonb) returns jsonb language plpgsql immutable as $$
declare
merged jsonb;
k text;
begin
if jsonb_typeof(under) is distinct from 'object' or jsonb_typeof(over) is distinct from 'object' then
return over;
end if;
merged := under;
for k in select jsonb_object_keys(over) loop
if merged ? k then
merged := jsonb_set(merged, array[k], pg_temp.config_630_merge(merged -> k, over -> k));
else
merged := merged || jsonb_build_object(k, over -> k);
end if;
end loop;
return merged;
end
$$;
insert into settings_history (node, module, values, set_at, replaced_by, set_by)
select t.node, t.module, t.values, t.set_at, 'moved-to-nodes', t.set_by
from settings t
join settings s on s.module = t.module and s.node is null
where t.node is not null
and exists (select 1 from assignment a where a.node = t.node and a.module = t.module);
insert into settings_history (node, module, values, set_at, replaced_by, set_by)
select null, module, values, set_at, 'moved-to-nodes', set_by from settings where node is null;
insert into settings (node, module, values, set_at, set_by)
select a.node, s.module, s.values, now(),
coalesce(s.set_by || '; ', '') || 'moved from the whole mesh''s layer to this node''s (novox/hq issue 630)'
from settings s
join assignment a on a.module = s.module
where s.node is null
on conflict (node, module) where node is not null
do update set values = pg_temp.config_630_merge(excluded.values, settings.values),
set_at = now(),
set_by = coalesce(settings.set_by || '; ', '') ||
'the whole mesh''s layer laid under it (novox/hq issue 630)';
delete from settings where node is null;
-252
View File
@@ -1,252 +0,0 @@
package inventory
import (
"context"
"errors"
"fmt"
"sort"
"github.com/jackc/pgx/v5"
"github.com/novox/mesh-controller/internal/catalogue"
"github.com/novox/mesh-controller/internal/secrets"
)
// A provision whose credential only a person outside the mesh can give (novox/hq ADR 0337, issue 164): a Servarr
// app's API key, an indexer's key, a downloader's password. The provider's software holds the value; the mesh can
// neither make one that would work nor fetch it. So the mesh never makes one (§2), a person gives it once per
// provider and it is sealed to every consumer of that provider (§3), and until then a consumer holds no credential
// for it and is told so (§4), and the rest of it runs.
// NotGivenError is a pair credential only a person can give, which no person gave for this pair under the keys its
// two ends have now: the consumer receives no credential for it, and waits (ADR 0337 §4, §8). It is not a fault of
// the machine, so the plan does not refuse the machine for it.
type NotGivenError struct {
Provision, Consumer, ConsumerModule, Provider, ProviderModule, Local string
// Own is set when the credential is the provider's own secret issued outside (ADR 0158, ADR 0228), and names it.
Own string
// Why is why no value is held, in words that never carry one.
Why string
}
func (e *NotGivenError) Error() string {
if e.Own != "" {
return fmt.Sprintf("%s on %s holds no %s from %s: %s; give it with `secret accept %s %s %s`",
e.ConsumerModule, e.Consumer, e.Provision, e.Provider, e.Why, e.Provider, e.ProviderModule, e.Own)
}
return fmt.Sprintf("%s on %s holds no %s from %s: %s; it is issued outside the mesh and given with `%s`",
e.ConsumerModule, e.Consumer, e.Provision, e.Provider, e.Why, acceptLine(e.Provider, e.ProviderModule, e.Provision))
}
// IsNotGiven is whether an error is a credential only a person gives, not given yet.
func IsNotGiven(err error) bool {
var ng *NotGivenError
return errors.As(err, &ng)
}
// acceptLine is the one line that gives a provision's credential issued outside, for every consumer of a provider.
func acceptLine(provider, providerModule, provision string) string {
if providerModule == "" {
providerModule = "<provider module>"
}
return fmt.Sprintf("secret accept %s %s %s", provider, providerModule, provision)
}
// AcceptLine is acceptLine, for the controller's words.
func AcceptLine(provider, providerModule, provision string) string {
return acceptLine(provider, providerModule, provision)
}
// issuedOutside is whether a module in the catalogue offers the provision with a credential issued outside the
// mesh, and which.
func (i *Inventory) issuedOutside(ctx context.Context, provision string) (string, bool, error) {
shelf, err := i.Catalogue(ctx)
if err != nil {
return "", false, err
}
module, outside := catalogue.OffersIssuedOutside(shelf, provision)
return module, outside, nil
}
// ownIssuedOutside is whether a provider's own secret says only an outside party issues it (ADR 0228).
func (i *Inventory) ownIssuedOutside(ctx context.Context, providerModule, own string) (string, bool, error) {
if providerModule == "" {
return own, false, nil
}
m, err := i.declared(ctx, providerModule)
if err != nil {
return own, false, err
}
return own, m.OwnSecrets[own].IssuedBy == catalogue.IssuedOutside, nil
}
// GivenPairSecret is the credential of a pair whose provision is issued outside the mesh, as a person gave it, and
// never one the mesh makes (ADR 0337 §2, §4). It writes nothing. A value is answered only when a person gave it
// (`accepted`) and it is sealed to the keys both ends have now; otherwise a NotGivenError says why — never given, or
// given under a key one end no longer has (§8), or a value the mesh made before the rule, which is not given and
// never delivered again (§2).
func (i *Inventory) GivenPairSecret(ctx context.Context, name, consumer, consumerModule, provider, providerModule,
local string) (Secret, error) {
consumerKey, err := i.SealingKeyOf(ctx, consumer)
if err != nil {
return Secret{}, err
}
providerKey, err := i.SealingKeyOf(ctx, provider)
if err != nil {
return Secret{}, err
}
consumerNode, err := i.NodeByName(ctx, consumer)
if err != nil {
return Secret{}, err
}
providerNode, err := i.NodeByName(ctx, provider)
if err != nil {
return Secret{}, err
}
notGiven := func(why string) (Secret, error) {
return Secret{}, &NotGivenError{Provision: name, Consumer: consumer, ConsumerModule: consumerModule,
Provider: provider, ProviderModule: providerModule, Local: local, Why: why}
}
var held Secret
err = i.store.Pool().QueryRow(ctx,
`select for_consumer, for_provider, consumer_key, provider_key, origin from secret
where name = $1 and consumer = $2 and consumer_module = $3 and provider = $4 and local = $5`,
name, consumerNode.ID, consumerModule, providerNode.ID, local).
Scan(&held.ForConsumer, &held.ForProvider, &held.ConsumerKey, &held.ProviderKey, &held.Origin)
switch {
case errors.Is(err, pgx.ErrNoRows):
return notGiven("nobody gave it")
case err != nil:
return Secret{}, err
case held.Origin != OriginAccepted:
return notGiven("the value held was made by the mesh, which " + providerModule + "'s software would refuse")
case consumerKey == "" || providerKey == "" || held.ConsumerKey != consumerKey || held.ProviderKey != providerKey:
return notGiven("it was given under a sealing key one end no longer has")
}
held.Name, held.Consumer, held.Provider = name, consumer, provider
held.ConsumerModule, held.Local = consumerModule, local
return held, nil
}
// PairEnd is one consumer of a provision from one provider: the machine, the module there and its local name.
type PairEnd struct {
Consumer, ConsumerModule, Local string
}
// ProvisionAccepted is what one accept for a provider sealed (ADR 0337 §3): the consumers it reached, those it could
// not (no sealing key on their machine), and the machines to send.
type ProvisionAccepted struct {
Sealed, Unsealable []PairEnd
Machines []string
}
// AcceptSecretForProvision seals one value a person gave for a provision issued outside the mesh to the provider's
// machine, to every consumer named, and to the operator, in one act under one generation, each row `accepted`
// (ADR 0337 §3, issue 165); the plaintext is not kept. The consumers are every one that binds the provision from
// this provider now, which the caller finds by resolving every machine. A consumer whose machine has no sealing key
// is not sealed to, and said.
func (i *Inventory) AcceptSecretForProvision(ctx context.Context, provision, provider, providerModule, value string,
consumers []PairEnd) (ProvisionAccepted, error) {
m, err := i.declared(ctx, providerModule)
if err != nil {
return ProvisionAccepted{}, err
}
if !m.IssuedOutsideOf(provision) {
return ProvisionAccepted{}, fmt.Errorf("%s does not offer %q with a credential issued outside the mesh "+
"(\"credential\": {\"issued-by\": \"outside\"}, ADR 0337), so its consumers' credentials are not given "+
"once for the provider", providerModule, provision)
}
if value == "" {
return ProvisionAccepted{}, errors.New("there is nothing to seal")
}
providerNode, err := i.NodeByName(ctx, provider)
if err != nil {
return ProvisionAccepted{}, err
}
providerKey, err := i.SealingKeyOf(ctx, provider)
if err != nil {
return ProvisionAccepted{}, err
}
if providerKey == "" {
return ProvisionAccepted{}, fmt.Errorf("%s has no sealing key, so nothing can be sealed to it; a node joins to get one", provider)
}
forOperator, operatorKey, err := i.operatorSeal(ctx, value)
if err != nil {
return ProvisionAccepted{}, err
}
generation := secrets.Stamp()
var out ProvisionAccepted
machines := map[string]bool{provider: true}
tx, err := i.store.Pool().Begin(ctx)
if err != nil {
return ProvisionAccepted{}, err
}
defer func() { _ = tx.Rollback(ctx) }()
for _, c := range consumers {
consumerNode, err := i.NodeByName(ctx, c.Consumer)
if err != nil {
return ProvisionAccepted{}, err
}
consumerKey, err := i.SealingKeyOf(ctx, c.Consumer)
if err != nil {
return ProvisionAccepted{}, err
}
if consumerKey == "" {
out.Unsealable = append(out.Unsealable, c)
continue
}
sealed, err := secrets.Accept(value, consumerKey, providerKey)
if err != nil {
return ProvisionAccepted{}, err
}
if _, err := tx.Exec(ctx,
`insert into secret (name, consumer, consumer_module, provider, for_consumer, for_provider,
consumer_key, provider_key, operator_sealed, operator_key, origin, local, generation)
values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
on conflict (name, local, consumer, consumer_module, provider) do update set
for_consumer = excluded.for_consumer, for_provider = excluded.for_provider,
consumer_key = excluded.consumer_key, provider_key = excluded.provider_key,
created_at = now(), origin = excluded.origin, generation = excluded.generation,
operator_sealed = excluded.operator_sealed, operator_key = excluded.operator_key`,
provision, consumerNode.ID, c.ConsumerModule, providerNode.ID,
sealed.ForConsumer, sealed.ForProvider, sealed.ConsumerKey, sealed.ProviderKey,
forOperator, operatorKey, OriginAccepted, c.Local, generation); err != nil {
return ProvisionAccepted{}, err
}
out.Sealed = append(out.Sealed, c)
machines[c.Consumer] = true
}
if err := tx.Commit(ctx); err != nil {
return ProvisionAccepted{}, err
}
for n := range machines {
out.Machines = append(out.Machines, n)
}
sort.Strings(out.Machines)
return out, nil
}
// GivenPairsOf is every provision a module on a machine holds a value a person gave for, sealed to the keys both
// ends have now: what a wait for a provision's credential is checked against (ADR 0337 §6).
func (i *Inventory) GivenPairsOf(ctx context.Context, machine, module string) (map[string]bool, error) {
rows, err := i.store.Pool().Query(ctx,
`select s.name
from secret s
join node c on c.id = s.consumer
join node p on p.id = s.provider
where c.name = $1 and s.consumer_module = $2 and s.origin = 'accepted'
and s.consumer_key = c.sealing_key and s.provider_key = p.sealing_key`, machine, module)
if err != nil {
return nil, err
}
defer rows.Close()
out := map[string]bool{}
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
return nil, err
}
out[name] = true
}
return out, rows.Err()
}
-195
View File
@@ -1,195 +0,0 @@
package inventory
import (
"strings"
"testing"
"github.com/novox/mesh-controller/internal/catalogue"
)
// outsideProvision registers a provider offering tv-api with a credential issued outside the mesh (novox/hq ADR
// 0337 §1) and two consumers requiring it, on twoNodesWithKeys' machines.
func outsideProvision(t *testing.T, inv *Inventory) {
t.Helper()
ctx := t.Context()
if err := inv.RegisterModule(ctx, catalogue.Manifest{Module: "tv", Version: "1",
Provides: []catalogue.Offer{{Name: "tv-api", Scope: catalogue.ScopeMesh,
Credential: &catalogue.OfferCredential{IssuedBy: catalogue.IssuedOutside}}}}, Source{}); err != nil {
t.Fatal(err)
}
for _, m := range []string{"requests", "indexer"} {
if err := inv.RegisterModule(ctx, catalogue.Manifest{Module: m, Version: "1", Requires: []string{"tv-api"},
Secrets: map[string]string{"tv-api": "/run/tv-api.secret"}}, Source{}); err != nil {
t.Fatal(err)
}
}
}
// pairRows is how many pair rows the store holds for tv-api.
func pairRows(t *testing.T, inv *Inventory) int {
t.Helper()
var n int
if err := inv.store.Pool().QueryRow(t.Context(), `select count(*) from secret where name = 'tv-api'`).Scan(&n); err != nil {
t.Fatal(err)
}
return n
}
// A credential only a person can give is never made (ADR 0337 §2, issue 164): not for a pair with no row, which is
// what a newly bound consumer has, and not after a key change; a value the mesh made before the rule is not given.
func TestACredentialIssuedOutsideIsNeverMade(t *testing.T) {
inv, ctx := twoNodesWithKeys(t)
outsideProvision(t, inv)
_, err := inv.SecretFor(ctx, "tv-api", "consumer", "requests", "provider", "")
if !IsNotGiven(err) {
t.Fatalf("SecretFor made or refused a credential only a person gives, rather than saying it was not given: %v", err)
}
if !strings.Contains(err.Error(), "secret accept provider tv tv-api") {
t.Fatalf("not given does not name the one line that gives it: %v", err)
}
if n := pairRows(t, inv); n != 0 {
t.Fatalf("the store holds %d rows for tv-api, which nobody gave", n)
}
if _, err := inv.GivenPairSecret(ctx, "tv-api", "consumer", "requests", "provider", "tv", ""); !IsNotGiven(err) {
t.Fatalf("a pair nobody gave read as given: %v", err)
}
if n := pairRows(t, inv); n != 0 {
t.Fatalf("reading the pair wrote %d rows", n)
}
// A value the mesh made before the rule (the minted value of issue 164) is not given.
if _, err := inv.store.Pool().Exec(ctx, `insert into secret (name, consumer, consumer_module, provider,
for_consumer, for_provider, consumer_key, provider_key, local)
select 'tv-api', c.id, 'indexer', p.id, 'x', 'y', c.sealing_key, p.sealing_key, ''
from node c, node p where c.name = 'consumer' and p.name = 'provider'`); err != nil {
t.Fatal(err)
}
if _, err := inv.GivenPairSecret(ctx, "tv-api", "consumer", "indexer", "provider", "tv", ""); !IsNotGiven(err) ||
!strings.Contains(err.Error(), "made by the mesh") {
t.Fatalf("a value the mesh made before the rule read as given: %v", err)
}
// Given, then a key changes: not given, and nothing is made in its place (ADR 0337 §8).
if _, err := inv.AcceptSecretForProvision(ctx, "tv-api", "provider", "tv", "the app's key",
[]PairEnd{{Consumer: "consumer", ConsumerModule: "requests"}}); err != nil {
t.Fatal(err)
}
if _, err := inv.GivenPairSecret(ctx, "tv-api", "consumer", "requests", "provider", "tv", ""); err != nil {
t.Fatalf("the value given does not read as given: %v", err)
}
cn, _ := inv.NodeByName(ctx, "consumer")
key, _ := aSealingKey(t)
if err := inv.RecordSealingKey(ctx, cn.ID, key); err != nil {
t.Fatal(err)
}
if _, err := inv.GivenPairSecret(ctx, "tv-api", "consumer", "requests", "provider", "tv", ""); !IsNotGiven(err) ||
!strings.Contains(err.Error(), "sealing key") {
t.Fatalf("a value given under a key the consumer no longer has read as given: %v", err)
}
var origin string
if err := inv.store.Pool().QueryRow(ctx, `select origin from secret where name = 'tv-api' and consumer_module = 'requests'`).
Scan(&origin); err != nil || origin != OriginAccepted {
t.Fatalf("the given row was replaced (origin %q, %v)", origin, err)
}
// Rotate refuses it, naming the accept (ADR 0337 §9).
err = inv.RotateSecret(ctx, "tv-api", "consumer", "requests", "provider", "")
if err == nil || !strings.Contains(err.Error(), "secret accept provider tv tv-api") {
t.Fatalf("rotate of a credential issued outside was not refused naming the accept: %v", err)
}
}
// One accept for a provider is sealed to every consumer named, to the provider and to the operator, under one
// generation, each row accepted (ADR 0337 §3, issue 165); a consumer with no sealing key is said, not sealed to.
func TestOneAcceptForAProviderIsSealedToEveryConsumer(t *testing.T) {
inv, ctx := twoNodesWithKeys(t)
outsideProvision(t, inv)
operatorKey, operatorOpens := aSealingKey(t)
if _, err := inv.SetOperatorKey(ctx, operatorKey); err != nil {
t.Fatal(err)
}
consumerKey, consumerOpens := aSealingKey(t)
cn, _ := inv.NodeByName(ctx, "consumer")
if err := inv.RecordSealingKey(ctx, cn.ID, consumerKey); err != nil {
t.Fatal(err)
}
if _, err := inv.AddNode(ctx, "keyless"); err != nil {
t.Fatal(err)
}
accepted, err := inv.AcceptSecretForProvision(ctx, "tv-api", "provider", "tv", "the app's key", []PairEnd{
{Consumer: "consumer", ConsumerModule: "requests"}, {Consumer: "consumer", ConsumerModule: "indexer"},
{Consumer: "keyless", ConsumerModule: "requests"}})
if err != nil {
t.Fatal(err)
}
if len(accepted.Sealed) != 2 || len(accepted.Unsealable) != 1 || accepted.Unsealable[0].Consumer != "keyless" {
t.Fatalf("the accept did not seal to both consumers with keys and say the keyless one: %+v", accepted)
}
if strings.Join(accepted.Machines, ",") != "consumer,provider" {
t.Fatalf("the machines to send are the provider's and every consumer's sealed to: %v", accepted.Machines)
}
rows, err := inv.store.Pool().Query(ctx, `select consumer_module, origin, coalesce(generation, ''), for_consumer,
coalesce(operator_sealed, '') from secret where name = 'tv-api'`)
if err != nil {
t.Fatal(err)
}
defer rows.Close()
generations := map[string]bool{}
seen := 0
for rows.Next() {
var module, origin, generation, forConsumer, forOperator string
if err := rows.Scan(&module, &origin, &generation, &forConsumer, &forOperator); err != nil {
t.Fatal(err)
}
seen++
if origin != OriginAccepted || generation == "" {
t.Fatalf("%s's row is not accepted under a generation (origin %q)", module, origin)
}
generations[generation] = true
if opened, ok := consumerOpens(forConsumer); !ok || string(opened) != "the app's key" {
t.Fatalf("%s's copy does not open to the value given", module)
}
if opened, ok := operatorOpens(forOperator); !ok || string(opened) != "the app's key" {
t.Fatalf("%s's operator copy does not open to the value given", module)
}
}
if seen != 2 || len(generations) != 1 {
t.Fatalf("%d rows under %d generations; want 2 under one", seen, len(generations))
}
given, err := inv.GivenPairsOf(ctx, "consumer", "indexer")
if err != nil || !given["tv-api"] {
t.Fatalf("the provision given does not read as given for indexer (%v)", err)
}
// Only an offer that says so is given this way.
if _, err := inv.AcceptSecretForProvision(ctx, "secret", "provider", "gitea", "x", nil); err == nil {
t.Fatal("a provision not issued outside was given once for its provider")
}
}
// The provider's own secret issued outside the mesh, named as a provision's shared credential (ADR 0158, ADR 0228),
// is never made for a consumer that binds: the consumer is told it was not given (ADR 0337 §2, §4).
func TestASharedOwnSecretIssuedOutsideIsNeverMade(t *testing.T) {
inv, ctx := twoNodesWithKeys(t)
if err := inv.RegisterModule(ctx, catalogue.Manifest{Module: "downloader", Version: "1",
OwnSecrets: catalogue.OwnSecrets{"password": {Path: "/run/password", Taken: catalogue.TakenAtStart,
IssuedBy: catalogue.IssuedOutside}},
Provides: []catalogue.Offer{{Name: "downloader-api", Scope: catalogue.ScopeMesh,
Credential: &catalogue.OfferCredential{Own: "password"}}}}, Source{}); err != nil {
t.Fatal(err)
}
_, err := inv.SharedSecretFor(ctx, "downloader-api", "consumer", "gitea", "provider", "downloader", "", "password")
if !IsNotGiven(err) {
t.Fatalf("a shared own secret issued outside was made or refused rather than not given: %v", err)
}
var n int
if err := inv.store.Pool().QueryRow(ctx, `select count(*) from module_secret where module = 'downloader'`).Scan(&n); err != nil || n != 0 {
t.Fatalf("the mesh made %d values of an own secret issued outside (%v)", n, err)
}
if err := inv.remakeShared(ctx, nil, "", "downloader", "password", "", nil, "", "", ""); err == nil ||
!strings.Contains(err.Error(), "issued outside") {
t.Fatalf("remakeShared made a value of an own secret issued outside: %v", err)
}
}
-5
View File
@@ -220,11 +220,6 @@ type PlanDelivery struct {
// Withheld are, on a walk cut from a batch a stop walked merges again in, the modules it did not build: a
// stopped merge moves them, and built only as dependents at the branch they would deliver it (ADR 0299).
Withheld []string `json:"withheld,omitempty"`
// Unchanged are the modules a merge it answers changed files in the directory of, whose recorded build source
// holds none of them, each with why (novox/hq issue 611, ADR 0267 §5): not built, because the build the mesh
// holds is their build. A delivery plan that read them whole named them; this says they matched what runs,
// rather than that the walk left them unbuilt. Modules keeps meaning what the walk builds.
Unchanged map[string]string `json:"unchanged,omitempty"`
// Batch is the window of a batch; nil once it is cut into a walk.
Batch *PlanBatch `json:"batch,omitempty"`
// Alone says the walk walks one merge on its own commit, after a failed walk carried it in a later one:
-39
View File
@@ -104,21 +104,6 @@ func (i *Inventory) SecretFor(ctx context.Context, name, consumer, consumerModul
consumerModule, name, provider, consumer, consumerModule, name, provider, localFlag(local))
}
// **Never made where only a person can give it** (novox/hq ADR 0337 §2, issue 164): a provision whose offer
// says `issued-by: outside` gets no value of the mesh's, which its provider's software would refuse. Asked
// here, on the one path that makes a value, so no caller can have one made by not knowing; the plan reads
// such a pair with GivenPairSecret, which never makes one and delivers only a value a person gave.
why := "nobody gave it"
if err == nil {
why = "the value held was sealed to a key one end no longer has"
}
if providerModule, outside, oerr := i.issuedOutside(ctx, name); oerr != nil {
return Secret{}, oerr
} else if outside {
return Secret{}, &NotGivenError{Provision: name, Consumer: consumer, ConsumerModule: consumerModule,
Provider: provider, ProviderModule: providerModule, Local: local, Why: why}
}
// And to the operator, when the mesh has one (novox/hq ADR 0085, amended): the third copy that
// makes a vault-provided secret recoverable, and nothing the mesh can open.
operator, err := i.OperatorKey(ctx)
@@ -254,16 +239,6 @@ func (i *Inventory) RotateSecret(ctx context.Context, name, consumer, consumerMo
`select origin from secret where name = $1 and consumer = $2 and consumer_module = $3
and provider = $4 and local = $5`,
name, consumerNode.ID, consumerModule, providerNode.ID, local).Scan(&origin)
if providerModule, outside, oerr := i.issuedOutside(ctx, name); oerr != nil {
return oerr
} else if outside {
// Only a person can give it, so only a person can give its next value (novox/hq ADR 0337 §9, ADR 0092 §7):
// deleting the row would leave the consumer waiting for nothing new.
return fmt.Errorf(
"%s is issued outside the mesh: %s's software holds it and the mesh cannot make its replacement. "+
"Give the new value with `%s`, which seals it to every consumer of %s on %s",
name, providerModule, acceptLine(provider, providerModule, name), providerModule, provider)
}
if err == nil && origin == OriginAccepted {
return fmt.Errorf(
"%s's %q credential from %s was accepted from a person, and the mesh cannot make "+
@@ -911,15 +886,6 @@ func (i *Inventory) SharedSecretFor(ctx context.Context, provision, consumer, co
held.ConsumerModule, held.Local = consumerModule, local
return held, nil
}
if own, outside, err := i.ownIssuedOutside(ctx, providerModule, own); err != nil {
return Secret{}, err
} else if outside && (ownOrigin == nil || *ownOrigin != OriginAccepted) {
// The provider's own secret only an outside party issues, which nobody gave (ADR 0228, issue 401): the
// mesh makes no value for it here either, and the consumer is told it was not given (ADR 0337 §4).
return Secret{}, &NotGivenError{Provision: provision, Consumer: consumer, ConsumerModule: consumerModule,
Provider: provider, ProviderModule: providerModule, Local: local, Own: own,
Why: "nobody gave " + providerModule + "'s own secret " + own + ", which is issued outside the mesh"}
}
if ownOrigin != nil && *ownOrigin == OriginAccepted {
return Secret{}, fmt.Errorf(
"%s on %s needs %s from %s, whose credential is %s's own secret %q — a value given to the "+
@@ -946,11 +912,6 @@ func (i *Inventory) remakeShared(ctx context.Context, providerID any, providerKe
if len(provisions) == 0 {
return fmt.Errorf("%s names no provision whose credential is its own secret %q", providerModule, own)
}
if m.OwnSecrets[own].IssuedBy == catalogue.IssuedOutside {
// Never made (novox/hq ADR 0228, ADR 0337 §2): only a person can give it, with `secret accept`.
return fmt.Errorf("%s's own secret %q is issued outside the mesh, so the mesh makes no value for it; a "+
"person gives it with `secret accept <machine> %s %s`", providerModule, own, providerModule, own)
}
operator, err := i.OperatorKey(ctx)
if err != nil {
return err
+5 -7
View File
@@ -40,17 +40,15 @@ func TestASettingIsJudgedWhereItIsStored(t *testing.T) {
}
// A key that reaches nothing is refused too, where the spelling can still be fixed; a module
// with a mergeable file takes any key.
err = inv.SetSettings(ctx, "anchor", "plain", map[string]any{"colour": "blue"})
err = inv.SetSettings(ctx, "", "plain", map[string]any{"colour": "blue"})
if err == nil || !strings.Contains(err.Error(), "reach nothing") || !strings.Contains(err.Error(), `"colour"`) {
t.Fatalf("a stray key was stored: %v", err)
}
// There is no module layer for the whole mesh (novox/hq issue 630): a new one is refused.
err = inv.SetSettings(ctx, "", "web", map[string]any{"colour": "blue"})
if err == nil || !strings.Contains(err.Error(), "no module layer for the whole mesh") {
t.Fatalf("a module layer for the whole mesh was stored: %v", err)
if err := inv.SetSettings(ctx, "", "web", map[string]any{"colour": "blue"}); err != nil {
t.Fatal(err)
}
if err := inv.SetSettings(ctx, "anchor", "web", map[string]any{"colour": "blue",
catalogue.PortsSetting: map[string]any{"8080": 10000}}); err != nil {
// The mesh-wide layer is under the node's when the node's is judged.
if err := inv.SetSettings(ctx, "anchor", "web", map[string]any{catalogue.PortsSetting: map[string]any{"8080": 10000}}); err != nil {
t.Fatal(err)
}
// A kept network is for an adopted machine only (rule 4).
+2 -2
View File
@@ -56,10 +56,10 @@ func TestTheLayerASetReplacesIsKeptAndReadable(t *testing.T) {
t.Errorf("the replaced layer still has what the set dropped: %+v", past[1])
}
// The mesh-wide layer keeps its own history, apart from the node's.
if err := inv.KeepSettings(ctx, "", "web", map[string]any{"colour": "green"}); err != nil {
if err := inv.SetSettings(ctx, "", "web", map[string]any{"colour": "green"}); err != nil {
t.Fatal(err)
}
if err := inv.KeepSettings(ctx, "", "web", map[string]any{"colour": "grey"}); err != nil {
if err := inv.SetSettings(ctx, "", "web", map[string]any{"colour": "grey"}); err != nil {
t.Fatal(err)
}
mesh, err := inv.SettingsHistory(ctx, "", "web")
-5
View File
@@ -250,11 +250,6 @@ type BuildResult struct {
// the repository's default is the branch it follows.
Branches []string `json:"branches,omitempty"`
// Predefined is the built repository's list of predefined keys, its root predefined.json at the commit
// built, when it has one (novox/hq issue 630). The controller keeps it, when it registers the build,
// as the list mesh and node config hold values for. Empty from a builder that predates it.
Predefined json.RawMessage `json:"predefined,omitempty"`
// SourceFingerprint is what the build was made from, hashed (novox/hq issue 280): the module's
// tree at the commit, the trees of the contexts it read, its bases and toolchains by digest. Two
// builds with one fingerprint are one build, however their digests differ — an image is not
+1 -4
View File
@@ -577,10 +577,7 @@ type Wait struct {
Part string `json:"part"`
Secret string `json:"secret,omitempty"`
Setting string `json:"setting,omitempty"`
// Provision is a provision the module requires whose credential only a person can give and nobody gave yet
// (novox/hq ADR 0337 §5): a wait names exactly one of Secret, Setting or Provision.
Provision string `json:"provision,omitempty"`
What string `json:"what"`
What string `json:"what"`
}
// ResourceHealth is one long-running resource's state.

Some files were not shown because too many files have changed in this diff Show More