Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
19173aeb07 |
@@ -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()
|
|
||||||
}
|
|
||||||
@@ -203,9 +203,8 @@ func seatDependenciesOnAssign(ctx context.Context, open *stores, node string, mo
|
|||||||
if _, err := catalogue.AssignRefusal(shelf, node, assigned, adding); err != nil {
|
if _, err := catalogue.AssignRefusal(shelf, node, assigned, adding); err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
// Two modules declaring one path or unit, or one package present and absent, is refused before anything
|
// Two modules declaring one package, path or unit is refused before anything is recorded
|
||||||
// is recorded (novox/hq ADR 0210, ADR 0303, 04-ISSUES/235): kept, the node would not resolve until one came
|
// (novox/hq ADR 0210, 04-ISSUES/235): kept, the node would not resolve until one came off again.
|
||||||
// off again. Two modules declaring one package present is not a collision (ADR 0303, issue 628).
|
|
||||||
if err := catalogue.CollisionRefusal(shelf, node, assigned, adding); err != nil {
|
if err := catalogue.CollisionRefusal(shelf, node, assigned, adding); err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -722,7 +722,7 @@ func converge(ctx context.Context, open *stores, node string, yes bool, digest s
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
derived := derivedFilter{rules: rules, foundation: with.Foundation, mesh: with.Mesh,
|
derived := derivedFilter{rules: rules, foundation: with.Foundation, mesh: with.Mesh,
|
||||||
outward: plan.PublicDomain != "", outwardLinks: with.OutwardLinks, local: with.LocalNetworks}
|
outward: plan.PublicDomain != "", outwardLinks: with.OutwardLinks}
|
||||||
filtering, err := inv.FilteringOf(ctx, node)
|
filtering, err := inv.FilteringOf(ctx, node)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
@@ -930,9 +930,6 @@ type derivedFilter struct {
|
|||||||
// The filter constrains what arrives on them; everything arriving elsewhere is this machine's
|
// The filter constrains what arrives on them; everything arriving elsewhere is this machine's
|
||||||
// own guest and is not filtered.
|
// own guest and is not filtered.
|
||||||
outwardLinks []string
|
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.
|
// 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 {
|
if !onMesh {
|
||||||
return fmt.Sprintf("%s — declared by %s from the mesh only", closesOutside, by)
|
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)
|
return fmt.Sprintf("declared by %s (from %s)", by, rule.From)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ func TestARepairByHandCarriesItsReason(t *testing.T) {
|
|||||||
want string
|
want string
|
||||||
}{
|
}{
|
||||||
{"push", map[string]any{"node": "anchor", "why": "stuck", "cause": "sent-not-reported"},
|
{"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", map[string]any{"close": "plan-1", "why": "the report will not come"},
|
||||||
"plans 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"},
|
{"plans", map[string]any{"retry": "plan-1"}, "plans retry plan-1"},
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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,
|
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}
|
Check: r.Check, Needs: r.Needs, Account: r.Account, Root: r.Root}
|
||||||
for _, w := range r.Waits {
|
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)
|
resources = append(resources, kept)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ import (
|
|||||||
"github.com/novox/mesh-controller/internal/conditions"
|
"github.com/novox/mesh-controller/internal/conditions"
|
||||||
"github.com/novox/mesh-controller/internal/inventory"
|
"github.com/novox/mesh-controller/internal/inventory"
|
||||||
"github.com/novox/mesh-controller/internal/link"
|
"github.com/novox/mesh-controller/internal/link"
|
||||||
"github.com/novox/mesh-controller/internal/overlay"
|
|
||||||
"github.com/novox/mesh-controller/internal/token"
|
"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()) {
|
for _, line := range machineLines(account, had, time.Now()) {
|
||||||
fmt.Println(line)
|
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)
|
held, err := inv.Profile(ctx, name)
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package main
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"slices"
|
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -45,28 +44,6 @@ func needsOperatorKey(module, node string) string {
|
|||||||
type operatorWaitFacts struct {
|
type operatorWaitFacts struct {
|
||||||
manifests map[string]catalogue.Manifest
|
manifests map[string]catalogue.Manifest
|
||||||
given map[string]map[string]time.Time
|
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.
|
// checkWait is nil when a wait is excused, and otherwise why not, in words. Pure.
|
||||||
@@ -75,18 +52,9 @@ func checkWait(module, machine string, w inventory.Wait, f operatorWaitFacts) er
|
|||||||
if !known {
|
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)
|
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 {
|
switch {
|
||||||
case named != 1:
|
case w.Secret != "" && w.Setting != "", w.Secret == "" && w.Setting == "":
|
||||||
return fmt.Errorf("says it waits, naming %s, where a wait names one secret, one setting or one provision",
|
return fmt.Errorf("says it waits, naming %s, where a wait names one secret or one setting", waitNames(w))
|
||||||
waitNames(w))
|
|
||||||
case w.Provision != "":
|
|
||||||
return checkProvisionWait(module, machine, w, m, f)
|
|
||||||
case w.Setting != "":
|
case w.Setting != "":
|
||||||
if _, declared := m.Settings[w.Setting]; !declared {
|
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 fmt.Errorf("says it waits for the setting %s, which %s does not declare", w.Setting, module)
|
||||||
@@ -112,46 +80,20 @@ func checkWait(module, machine string, w inventory.Wait, f operatorWaitFacts) er
|
|||||||
return nil
|
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.
|
// 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"
|
const waitRefusedPrefix = "says it waits"
|
||||||
|
|
||||||
// waitNames is what a wait names, as "the secret x" or "the setting y".
|
// waitNames is what a wait names, as "the secret x" or "the setting y".
|
||||||
func waitNames(w inventory.Wait) string {
|
func waitNames(w inventory.Wait) string {
|
||||||
var names []string
|
switch {
|
||||||
if w.Secret != "" {
|
case w.Secret != "" && w.Setting != "":
|
||||||
names = append(names, "the secret "+w.Secret)
|
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 != "" {
|
return "nothing"
|
||||||
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 ")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkWaiting reads one machine's resources against the facts: every waiting resource whose waits all check out is
|
// 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 {
|
if len(r.Waits) == 0 {
|
||||||
why = fmt.Errorf("says it waits, and names nothing it waits for")
|
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 {
|
for _, w := range r.Waits {
|
||||||
if why == nil {
|
if why == nil {
|
||||||
why = checkWait(r.Module, machine, w, f)
|
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 {
|
if why != nil {
|
||||||
r.State, r.Reason = link.StateUnhealthy, why.Error()
|
r.State, r.Reason = link.StateUnhealthy, why.Error()
|
||||||
}
|
}
|
||||||
@@ -214,10 +150,6 @@ func operatorWaitSaid(module, machine string, waits []inventory.Wait) string {
|
|||||||
if w.Secret != "" {
|
if w.Secret != "" {
|
||||||
part += fmt.Sprintf(", given with `nox secret ask %s %s %s`", machine, module, 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+")")
|
parts = append(parts, part+")")
|
||||||
}
|
}
|
||||||
return fmt.Sprintf("%s on %s waits for the operator: %s", module, machine, strings.Join(parts, "; "))
|
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 {
|
func needsOperatorWords(module, node string, waits []inventory.Wait) words {
|
||||||
var acts []string
|
var acts []string
|
||||||
seen := map[string]bool{}
|
seen := map[string]bool{}
|
||||||
secret, provision := false, false
|
secret := false
|
||||||
for _, w := range waits {
|
for _, w := range waits {
|
||||||
var act string
|
var act string
|
||||||
switch {
|
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
|
act, secret = fmt.Sprintf("type %s at %s's desk prompt", w.What, node), true
|
||||||
case w.Setting != "":
|
case w.Setting != "":
|
||||||
act = fmt.Sprintf("approve %s of %s on %s when it is proposed to you", w.Setting, module, node)
|
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] {
|
if act != "" && !seen[act] {
|
||||||
seen[act] = true
|
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 " +
|
explanation += " A hidden prompt opens at the desk when the secret is asked for, and what you type there " +
|
||||||
"is sealed to the machine."
|
"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."
|
explanation += " Its update is in place and nothing was undone; it carries on by itself once it is given."
|
||||||
return words{
|
return words{
|
||||||
Headline: fmt.Sprintf("%s waits for you on %s", module, node),
|
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 {
|
if f.given == nil {
|
||||||
f.given = map[string]map[string]time.Time{}
|
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 {
|
if inv == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -319,59 +238,15 @@ func readWaitFacts(ctx context.Context, inv *inventory.Inventory, machine string
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if _, read := f.given[module+"@"+machine]; !read {
|
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 {
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
for _, provision := range m.Requires {
|
if given, err := inv.GivenOwnSecrets(ctx, machine, module); err == nil {
|
||||||
if _, read := f.outside[provision]; read {
|
f.given[module+"@"+machine] = given
|
||||||
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)}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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.
|
// waitingModules is every module with a waiting resource in a statement.
|
||||||
func waitingModules(rs []inventory.ResourceHealth) []string {
|
func waitingModules(rs []inventory.ResourceHealth) []string {
|
||||||
seen := map[string]bool{}
|
seen := map[string]bool{}
|
||||||
|
|||||||
@@ -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"},
|
{"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"},
|
{"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"},
|
{"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"},
|
{"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, one setting or one provision"},
|
{"neither", inventory.Wait{Part: "p", What: "w"}, factsGiven(nil), "one secret or one setting"},
|
||||||
{"no manifest known", passwordWait, operatorWaitFacts{}, "no manifest"},
|
{"no manifest known", passwordWait, operatorWaitFacts{}, "no manifest"},
|
||||||
{"what was given cannot be read", passwordWait,
|
{"what was given cannot be read", passwordWait,
|
||||||
operatorWaitFacts{manifests: map[string]catalogue.Manifest{"mounts": mountsManifest}}, "could not be read"},
|
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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -182,21 +182,12 @@ func planFor(ctx context.Context, open *stores, nodeName string) (catalogue.Reso
|
|||||||
}
|
}
|
||||||
var secret inventory.Secret
|
var secret inventory.Secret
|
||||||
var err error
|
var err error
|
||||||
if outsideModule, outside := issuedOutsideBy(shelf, n); outside {
|
if n.SharedOwn != "" {
|
||||||
// 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 != "" {
|
|
||||||
// The provider's one credential, sealed to this consumer too (novox/hq ADR 0158).
|
// 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)
|
secret, err = inv.SharedSecretFor(ctx, n.Name, nodeName, n.For, n.From, providerModuleOf(resolved, open, ctx, n), n.Local, n.SharedOwn)
|
||||||
} else {
|
} else {
|
||||||
secret, err = inv.SecretFor(ctx, n.Name, nodeName, n.For, n.From, n.Local)
|
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 {
|
if err != nil {
|
||||||
// Said rather than skipped. A machine that resolves cleanly and receives no
|
// 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
|
// 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 {
|
if err != nil {
|
||||||
return sendable{}, err
|
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.
|
// 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)
|
adoption, err := adoptionOf(ctx, open.inventory, record, plan, composed)
|
||||||
if err != nil {
|
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,
|
Received: composed.Received, Mesh: with.Mesh, BusUsers: with.BusUsers,
|
||||||
LeftOut: sortedKeysOf(composed.LeftOut), leftOutWhy: composed.LeftOut, withheld: with.Withheld,
|
LeftOut: sortedKeysOf(composed.LeftOut), leftOutWhy: composed.LeftOut, withheld: with.Withheld,
|
||||||
unbound: with.Unbound, foreseen: composed.Foreseen, unplaced: composed.Unplaced,
|
unbound: with.Unbound, foreseen: composed.Foreseen, unplaced: composed.Unplaced,
|
||||||
modules: namedModules(plan, composed.LeftOut),
|
modules: namedModules(plan, composed.LeftOut)}
|
||||||
localNetwork: catalogue.LocalNetworkPorts(rules, with.LocalNetworks)}
|
|
||||||
// And which build of each module it carries, for the send to record (novox/hq issue 259, ADR
|
// 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.
|
// 0221). Read only on the send path: a question about what would be sent records nothing.
|
||||||
if choosing == Allocating {
|
if choosing == Allocating {
|
||||||
@@ -976,12 +960,6 @@ func renderingFor(ctx context.Context, open *stores, node string,
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return catalogue.Rendering{}, inventory.Node{}, err
|
return catalogue.Rendering{}, inventory.Node{}, err
|
||||||
}
|
}
|
||||||
// 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
|
// 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
|
// 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.
|
// 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,
|
Machines: machines, Zones: zones, Holders: replicas,
|
||||||
Suffix: overlay.Suffix(), MeshRange: meshRange, TunnelInterface: overlay.Interface, Accounts: accounts, Foundation: foundation,
|
Suffix: overlay.Suffix(), MeshRange: meshRange, TunnelInterface: overlay.Interface, Accounts: accounts, Foundation: foundation,
|
||||||
Kept: kept, Adopted: record.Adopted, OutwardLinks: outwardLinks,
|
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,
|
BusUsers: busUsers, Withheld: withheld, Unbound: unbound,
|
||||||
}, record, nil
|
}, record, nil
|
||||||
}
|
}
|
||||||
@@ -1440,11 +1417,6 @@ func planCommand(ctx context.Context, args []string) error {
|
|||||||
fmt.Printf(" %-20s settings from %s\n", module, layer.From)
|
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))
|
fmt.Printf("\n%d resource(s)\n", len(resources))
|
||||||
|
|
||||||
if *show {
|
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
|
// 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.
|
// 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 {
|
func providerModuleOf(resolved catalogue.Resolution, open *stores, ctx context.Context, n catalogue.Needed) string {
|
||||||
for _, m := range resolved.Modules {
|
for _, m := range resolved.Modules {
|
||||||
if _, shared := m.SharedCredentialOf(n.Name); shared {
|
if _, shared := m.SharedCredentialOf(n.Name); shared {
|
||||||
|
|||||||
@@ -27,13 +27,6 @@ package main
|
|||||||
// ask says so, and the whole is read with `settings proposals <id>` — whose fingerprint must be the one on the
|
// ask says so, and the whole is read with `settings proposals <id>` — whose fingerprint must be the one on the
|
||||||
// phone. Fail closed: a proposal nothing can carry to the operator is refused at once, in words, and never left
|
// phone. Fail closed: a proposal nothing can carry to the operator is refused at once, in words, and never left
|
||||||
// waiting for an answer that cannot come.
|
// waiting for an answer that cannot come.
|
||||||
//
|
|
||||||
// **On a channel that proves who answers, the values are shown WHOLE** (novox/hq issue 383, the operator's
|
|
||||||
// decision of 2026-10-10): a mount point, a share name or a private address is the thing being approved and must
|
|
||||||
// be readable, so the ask carries them whole beside the explanation (asks.Ask.Whole), the router shows that only
|
|
||||||
// on a kind that verifies its sender, and only a value shaped like a secret is withheld there. The message is the
|
|
||||||
// headline, one line per key, who proposed it and when; the fingerprint and how to read the proposal whole are
|
|
||||||
// the Details answer's (asks.Ask.Details). The masking stays for conditions and for channels that prove nothing.
|
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
@@ -161,39 +154,39 @@ func (p settingsProposal) ask(id string, machines []string) (asks.Ask, map[strin
|
|||||||
if len([]rune(headline)) > asks.HeadlineLength {
|
if len([]rune(headline)) > asks.HeadlineLength {
|
||||||
headline = verb + " settings?"
|
headline = verb + " settings?"
|
||||||
}
|
}
|
||||||
// The message reads at a glance (issues 383 and 639): a summary of what changes where, then at most three
|
shown, whole := p.change(machines)
|
||||||
// points, a key each; every key with its whole value, who proposed it, the fingerprint and how to read it at the
|
var b strings.Builder
|
||||||
// terminal are its Details, which the router notes after the answers.
|
if p.Clear {
|
||||||
shown, shownAll, shownWhole := p.change(machines, false)
|
fmt.Fprintf(&b, "Clear the settings of %s on %s, back to what the module says?\n\n", p.Module, p.where())
|
||||||
whole, wholeAll, _ := p.change(machines, true)
|
} else {
|
||||||
summary := p.summary()
|
fmt.Fprintf(&b, "Set the settings of %s on %s to these values?\n\n", p.Module, p.where())
|
||||||
compose := func(points []string) string {
|
|
||||||
return strings.Join(append([]string{summary}, points...), "\n")
|
|
||||||
}
|
}
|
||||||
// The Details: every setting it sets, one line each, uncut — whole where the sender is proven (WholeDetails),
|
fmt.Fprintf(&b, "Proposed by %s, at %s. ", sayable(p.From, machines), p.At.Local().Format("15:04 on 2 Jan"))
|
||||||
// masked elsewhere — then who proposed it, the fingerprint and how to read it at the terminal.
|
switch {
|
||||||
details := func(lines []string) string {
|
case p.Clear && p.HadLayer:
|
||||||
var d strings.Builder
|
b.WriteString("The layer it removes:\n\n")
|
||||||
d.WriteString(strings.Join(lines, "\n") + "\n")
|
case p.Clear:
|
||||||
fmt.Fprintf(&d, "Proposed by %s at %s. Fingerprint %s.\n", sayable(p.From, machines), p.At.Local().Format("15:04 on 2 Jan"),
|
b.WriteString("There is no layer to remove; approving changes nothing.")
|
||||||
fingerprint(p.Digest))
|
case !p.HadLayer:
|
||||||
if !shownWhole {
|
b.WriteString("There is no layer yet; this is the whole of it:\n\n")
|
||||||
d.WriteString("Where who answers is not proven, its values are shown as ‹address›, ‹path› or ‹withheld›.\n")
|
default:
|
||||||
}
|
b.WriteString("The layer is replaced whole; what changes against it:\n\n")
|
||||||
fmt.Fprintf(&d, "Read it whole, with this fingerprint: settings proposals %s at the controller's terminal, or the "+
|
|
||||||
"controller's settings verb with proposal %s through the mesh MCP server.", id, id)
|
|
||||||
return d.String()
|
|
||||||
}
|
}
|
||||||
q := asks.Ask{ID: id, Headline: headline, Explanation: compose(shown), Who: asks.Operator,
|
b.WriteString(shown)
|
||||||
|
fmt.Fprintf(&b, "\n\nFingerprint %s.", fingerprint(p.Digest))
|
||||||
|
if !whole {
|
||||||
|
b.WriteString(" Parts shown as ‹address›, ‹path› or ‹withheld› may not leave the mesh: read it whole, with " +
|
||||||
|
"this fingerprint, through the mesh MCP server (mesh-controller.settings, proposal " + id + ") or with " +
|
||||||
|
"mesh-cli settings proposals " + id + ".")
|
||||||
|
}
|
||||||
|
if p.Clear {
|
||||||
|
b.WriteString(" Approved, the layer is removed at once and the machine takes it at its next push.")
|
||||||
|
} else {
|
||||||
|
b.WriteString(" Approved, the layer is set at once and the machine takes it at its next push.")
|
||||||
|
}
|
||||||
|
q := asks.Ask{ID: id, Headline: headline, Explanation: b.String(), Who: asks.Operator,
|
||||||
Expires: p.At.Add(askApproveFor), OnExpiry: "the proposal is discarded; nothing changes",
|
Expires: p.At.Add(askApproveFor), OnExpiry: "the proposal is discarded; nothing changes",
|
||||||
About: p.about(), Details: details(shownAll)}
|
About: p.about()}
|
||||||
// Only where a value was masked: an ask whose values all pass the content rule shows the same everywhere.
|
|
||||||
if w := compose(whole); w != q.Explanation {
|
|
||||||
q.Whole = w
|
|
||||||
}
|
|
||||||
if w := details(wholeAll); w != q.Details {
|
|
||||||
q.WholeDetails = w
|
|
||||||
}
|
|
||||||
options := map[string]int{}
|
options := map[string]int{}
|
||||||
for i, act := range p.actions(id) {
|
for i, act := range p.actions(id) {
|
||||||
binds, _ := asks.ActDigest(boundAct(act))
|
binds, _ := asks.ActDigest(boundAct(act))
|
||||||
@@ -212,95 +205,55 @@ func (p settingsProposal) ask(id string, machines []string) (asks.Ask, map[strin
|
|||||||
return q, options
|
return q, options
|
||||||
}
|
}
|
||||||
|
|
||||||
// summary is the proposal's first line, what changes where: how many settings, how many stay, and that the machine
|
// shownMost is the most of a change the phone is shown, in bytes; the rest is read whole with `settings proposals`.
|
||||||
// takes the layer at its next push.
|
const shownMost = 1400
|
||||||
func (p settingsProposal) summary() string {
|
|
||||||
takes := p.Node
|
|
||||||
if p.Node == "" {
|
|
||||||
takes = "every machine"
|
|
||||||
}
|
|
||||||
if p.Clear {
|
|
||||||
if !p.HadLayer {
|
|
||||||
return "There is no layer to remove; approving changes nothing."
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("Removes the %d setting(s) of %s set on %s; approved, the layer goes at once and %s takes it at its next push.",
|
|
||||||
len(leaves(p.Before, "")), p.Module, p.where(), takes)
|
|
||||||
}
|
|
||||||
added, changed, removed := settingsChange(p.Before, p.Values)
|
|
||||||
n := len(added) + len(changed) + len(removed)
|
|
||||||
unchanged := len(leaves(p.Values, "")) - len(added) - len(changed)
|
|
||||||
if n == 0 {
|
|
||||||
return fmt.Sprintf("Nothing changes: the %d setting(s) of %s on %s are as they stand.", unchanged, p.Module, p.where())
|
|
||||||
}
|
|
||||||
stay := ""
|
|
||||||
if unchanged > 0 {
|
|
||||||
stay = fmt.Sprintf(", and %d stay as they are", unchanged)
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("Changes %d setting(s) of %s on %s%s; approved, the layer is set at once and %s takes it at its next push.",
|
|
||||||
n, p.Module, p.where(), stay, takes)
|
|
||||||
}
|
|
||||||
|
|
||||||
// change is what changes: as at most asks.MostPoints points for the glance, as every line whole for the note, and
|
// change is what changes, line by line, each value shown under the content rule, and whether every value was
|
||||||
// whether every value was shown exactly: "+ key: value"
|
// shown whole: "+ key: value" added, "~ key: value (was: old)" changed, "- key (was: old)" removed, and the
|
||||||
// added, "~ key: value (was: old)" changed, "- key (was: old)" removed. Each value is shown under the content rule
|
// count of keys unchanged.
|
||||||
// (sayableValue), or — exact, for a channel that proves who answers — as it is, withheld only when shaped like a
|
func (p settingsProposal) change(machines []string) (string, bool) {
|
||||||
// secret (wholeValue). More changes than points: the first two, and the keys of the rest; a point longer than a
|
|
||||||
// phone shows at a glance is cut and says so. The note carries every line uncut (the review of #304: Approve sets
|
|
||||||
// exactly what the operator can read).
|
|
||||||
func (p settingsProposal) change(machines []string, exact bool) ([]string, []string, bool) {
|
|
||||||
whole := true
|
whole := true
|
||||||
say := func(v any) string {
|
say := func(v any) string {
|
||||||
s, w := sayableValue(v, machines)
|
s, w := sayableValue(v, machines)
|
||||||
if exact {
|
|
||||||
s, w = wholeValue(v, machines)
|
|
||||||
}
|
|
||||||
whole = whole && w
|
whole = whole && w
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
type line struct{ key, text string }
|
var lines []string
|
||||||
var lines []line
|
|
||||||
if p.Clear {
|
if p.Clear {
|
||||||
was := leaves(p.Before, "")
|
was := leaves(p.Before, "")
|
||||||
for _, k := range layerKeys(was) {
|
for _, k := range layerKeys(was) {
|
||||||
lines = append(lines, line{k, fmt.Sprintf("- %s: %s", k, say(was[k]))})
|
lines = append(lines, fmt.Sprintf("- %s: %s", k, say(was[k])))
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
added, changed, removed := settingsChange(p.Before, p.Values)
|
added, changed, removed := settingsChange(p.Before, p.Values)
|
||||||
was, now := leaves(p.Before, ""), leaves(p.Values, "")
|
was, now := leaves(p.Before, ""), leaves(p.Values, "")
|
||||||
for _, k := range added {
|
for _, k := range added {
|
||||||
lines = append(lines, line{k, fmt.Sprintf("+ %s: %s", k, say(now[k]))})
|
lines = append(lines, fmt.Sprintf("+ %s: %s", k, say(now[k])))
|
||||||
}
|
}
|
||||||
for _, k := range changed {
|
for _, k := range changed {
|
||||||
lines = append(lines, line{k, fmt.Sprintf("~ %s: %s (was: %s)", k, say(now[k]), say(was[k]))})
|
lines = append(lines, fmt.Sprintf("~ %s: %s (was: %s)", k, say(now[k]), say(was[k])))
|
||||||
}
|
}
|
||||||
for _, k := range removed {
|
for _, k := range removed {
|
||||||
lines = append(lines, line{k, fmt.Sprintf("- %s (was: %s)", k, say(was[k]))})
|
lines = append(lines, fmt.Sprintf("- %s (was: %s)", k, say(was[k])))
|
||||||
|
}
|
||||||
|
unchanged := len(now) - len(added) - len(changed)
|
||||||
|
switch {
|
||||||
|
case len(lines) == 0 && unchanged > 0:
|
||||||
|
lines = append(lines, fmt.Sprintf("= nothing changes: the %d key(s) are as they stand", unchanged))
|
||||||
|
case unchanged > 0:
|
||||||
|
lines = append(lines, fmt.Sprintf("= %d key(s) unchanged", unchanged))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
all := make([]string, len(lines))
|
out := strings.Join(lines, "\n")
|
||||||
for i, l := range lines {
|
if len(out) > shownMost {
|
||||||
all[i] = l.text
|
cut := shownMost
|
||||||
}
|
for cut > 0 && out[cut] != '\n' {
|
||||||
var out []string
|
cut--
|
||||||
for i, l := range lines {
|
|
||||||
if len(lines) > asks.MostPoints && i == asks.MostPoints-1 {
|
|
||||||
var rest []string
|
|
||||||
for _, r := range lines[i:] {
|
|
||||||
rest = append(rest, r.key)
|
|
||||||
}
|
|
||||||
out = append(out, fmt.Sprintf("and %d more, each whole in the note: %s", len(rest), strings.Join(rest, ", ")))
|
|
||||||
break
|
|
||||||
}
|
}
|
||||||
out = append(out, l.text)
|
out = out[:cut] + fmt.Sprintf("\n… NOT SHOWN IN FULL here: %d more bytes", len(strings.Join(lines, "\n"))-cut)
|
||||||
|
whole = false
|
||||||
}
|
}
|
||||||
// A point too long for a glance is cut, and says so: its whole value is the note's.
|
return out, whole
|
||||||
const cut = "… (cut: whole in the note)"
|
|
||||||
for i, l := range out {
|
|
||||||
if r := []rune(l); len(r) > asks.PointLength {
|
|
||||||
out[i] = string(r[:asks.PointLength-len([]rune(cut))]) + cut
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out, all, whole
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func layerKeys(m map[string]any) []string {
|
func layerKeys(m map[string]any) []string {
|
||||||
@@ -347,27 +300,6 @@ func sayableValue(v any, machines []string) (string, bool) {
|
|||||||
return out, out == text
|
return out, out == text
|
||||||
}
|
}
|
||||||
|
|
||||||
// wholeValue is a value as a channel that proves who answers is shown it (asks.Ask.Whole), and whether it was
|
|
||||||
// shown whole: as it is, unless it is shaped like a secret (outward.Secret), which is withheld whole — never in
|
|
||||||
// part, so no half of a key reaches the phone.
|
|
||||||
func wholeValue(v any, machines []string) (string, bool) {
|
|
||||||
text, ok := v.(string)
|
|
||||||
if !ok {
|
|
||||||
raw, err := json.Marshal(v)
|
|
||||||
if err != nil {
|
|
||||||
return markWithheld, false
|
|
||||||
}
|
|
||||||
text = string(raw)
|
|
||||||
}
|
|
||||||
if text == "" {
|
|
||||||
return `""`, true
|
|
||||||
}
|
|
||||||
if _, ok := outward.Secret(text, machines...); !ok {
|
|
||||||
return markWithheld, false
|
|
||||||
}
|
|
||||||
return text, true
|
|
||||||
}
|
|
||||||
|
|
||||||
// sayable is a text with what may not leave the mesh replaced in place by a marker; what the markers cannot make
|
// sayable is a text with what may not leave the mesh replaced in place by a marker; what the markers cannot make
|
||||||
// pass is withheld whole.
|
// pass is withheld whole.
|
||||||
func sayable(text string, machines []string) string {
|
func sayable(text string, machines []string) string {
|
||||||
@@ -500,12 +432,6 @@ func (pr proposer) propose(ctx context.Context, in proposeInput) (string, error)
|
|||||||
return refuse("the ask's words would carry %s, which may not leave the mesh, and the change could not be "+
|
return refuse("the ask's words would carry %s, which may not leave the mesh, and the change could not be "+
|
||||||
"shown without it; %s", refusal, atTheTerminalInstead(in))
|
"shown without it; %s", refusal, atTheTerminalInstead(in))
|
||||||
}
|
}
|
||||||
// The whole words are shown only where the sender is proven, and never a secret's shape: wholeValue withholds
|
|
||||||
// one, and the router would refuse the ask if one were left, so it is refused here first, in words.
|
|
||||||
if refusal, ok := outward.Secret(q.Whole, machines...); !ok {
|
|
||||||
return refuse("the change shown whole would carry %s, which may not leave the mesh on any channel; %s",
|
|
||||||
refusal, atTheTerminalInstead(in))
|
|
||||||
}
|
|
||||||
// Fail closed, before anything is kept: no router, no grant, no channel means no ask.
|
// Fail closed, before anything is kept: no router, no grant, no channel means no ask.
|
||||||
if pr.routerHere != nil {
|
if pr.routerHere != nil {
|
||||||
here, err := pr.routerHere(ctx)
|
here, err := pr.routerHere(ctx)
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"slices"
|
"slices"
|
||||||
@@ -117,43 +116,24 @@ func TestAProposalAsksAtTheLevelApproveWithTheExactChange(t *testing.T) {
|
|||||||
if q.Options[0].Binds == q.Options[1].Binds {
|
if q.Options[0].Binds == q.Options[1].Binds {
|
||||||
t.Error("Approve and Decline bind the same act")
|
t.Error("Approve and Decline bind the same act")
|
||||||
}
|
}
|
||||||
// The message reads at a glance (issues 383 and 639): a summary, then one point per key — and nothing else:
|
for _, line := range []string{
|
||||||
// who proposed it, the fingerprint and the how-to are the Details, which the router notes after the answers.
|
"Set the settings of mounts on shanks to these values?",
|
||||||
summary := "Changes 3 setting(s) of mounts on shanks; approved, the layer is set at once and shanks takes it at its next push."
|
"Proposed by g14/claude-code, through the mesh-controller seat, at " + r.now.Local().Format("15:04 on 2 Jan") + ".",
|
||||||
if q.Explanation != summary+"\n+ sources: recalbox=‹address›@‹path›:ro\n~ shares: library=‹path› (was: none)\n- old (was: x)" {
|
"+ sources: recalbox=‹address›@‹path›:ro",
|
||||||
t.Errorf("the explanation:\n%s", q.Explanation)
|
"~ shares: library=‹path› (was: none)",
|
||||||
}
|
"- old (was: x)",
|
||||||
if err := asks.Glance(q.Explanation); err != nil {
|
"Fingerprint " + fingerprint(layerDigest(mountsSources)) + ".",
|
||||||
t.Error(err)
|
"read it whole, with this fingerprint",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(q.Explanation, line) {
|
||||||
|
t.Errorf("the explanation lacks %q:\n%s", line, q.Explanation)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
for _, leak := range []string{"nas.lan", "/mnt/recalbox", "/mnt/library", "smb://"} {
|
for _, leak := range []string{"nas.lan", "/mnt/recalbox", "/mnt/library", "smb://"} {
|
||||||
if strings.Contains(q.Explanation, leak) {
|
if strings.Contains(q.Explanation, leak) {
|
||||||
t.Errorf("the explanation carries %q, which may not leave the mesh", leak)
|
t.Errorf("the explanation carries %q, which may not leave the mesh", leak)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Where the sender is proven, the values whole: the mount point and the share are what is approved.
|
|
||||||
if q.Whole != summary+"\n+ sources: recalbox=smb://nas.lan/recalbox@/mnt/recalbox:ro\n~ shares: library=/mnt/library (was: none)\n- old (was: x)" ||
|
|
||||||
asks.Glance(q.Whole) != nil {
|
|
||||||
t.Errorf("the whole words:\n%s", q.Whole)
|
|
||||||
}
|
|
||||||
for _, line := range []string{
|
|
||||||
"Proposed by g14/claude-code, through the mesh-controller seat at " + r.now.Local().Format("15:04 on 2 Jan") +
|
|
||||||
". Fingerprint " + fingerprint(layerDigest(mountsSources)) + ".",
|
|
||||||
"Where who answers is not proven, its values are shown as ‹address›, ‹path› or ‹withheld›.",
|
|
||||||
"Read it whole, with this fingerprint: settings proposals " + kept(r).ID + " at the controller's terminal",
|
|
||||||
} {
|
|
||||||
if !strings.Contains(q.Details, line) {
|
|
||||||
t.Errorf("Details lack %q:\n%s", line, q.Details)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if refusal, ok := outward.Check(q.Details, "anchor", "laptop", "shanks"); !ok {
|
|
||||||
t.Errorf("the router would withhold a line of the details: %s", refusal)
|
|
||||||
}
|
|
||||||
for _, howTo := range []string{"ingerprint", "settings proposals", "settings verb", "not proven", "Proposed by"} {
|
|
||||||
if strings.Contains(q.Explanation, howTo) || strings.Contains(q.Whole, howTo) {
|
|
||||||
t.Errorf("the message carries the how-to %q", howTo)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
all := []string{q.Headline, q.Explanation, q.OnExpiry}
|
all := []string{q.Headline, q.Explanation, q.OnExpiry}
|
||||||
for _, o := range q.Options {
|
for _, o := range q.Options {
|
||||||
all = append(all, o.Label, o.Does)
|
all = append(all, o.Label, o.Does)
|
||||||
@@ -161,9 +141,6 @@ func TestAProposalAsksAtTheLevelApproveWithTheExactChange(t *testing.T) {
|
|||||||
if refusal, ok := outward.Check(strings.Join(all, "\n"), "anchor", "laptop", "shanks"); !ok {
|
if refusal, ok := outward.Check(strings.Join(all, "\n"), "anchor", "laptop", "shanks"); !ok {
|
||||||
t.Errorf("the router would refuse the ask: %s", refusal)
|
t.Errorf("the router would refuse the ask: %s", refusal)
|
||||||
}
|
}
|
||||||
if refusal, ok := outward.Secret(q.Whole, "anchor", "laptop", "shanks"); !ok {
|
|
||||||
t.Errorf("the router would refuse the whole words: %s", refusal)
|
|
||||||
}
|
|
||||||
// Kept as the controller's own ask, about no condition, with the proposal whole and its digests.
|
// Kept as the controller's own ask, about no condition, with the proposal whole and its digests.
|
||||||
kept := r.theProposal(t)
|
kept := r.theProposal(t)
|
||||||
p := kept.Proposal
|
p := kept.Proposal
|
||||||
@@ -196,98 +173,11 @@ func TestAMeshWideLayerIsProposed(t *testing.T) {
|
|||||||
}
|
}
|
||||||
q := r.askSent(t)
|
q := r.askSent(t)
|
||||||
if q.Headline != "Set notes on the whole mesh?" || q.About != "settings.notes.mesh" ||
|
if q.Headline != "Set notes on the whole mesh?" || q.About != "settings.notes.mesh" ||
|
||||||
q.Explanation != "Changes 1 setting(s) of notes on the whole mesh; approved, the layer is set at once and every machine takes it at its next push.\n+ x: 1" ||
|
!strings.Contains(q.Explanation, "Set the settings of notes on the whole mesh to these values?") {
|
||||||
q.Whole != "" {
|
|
||||||
t.Errorf("%+v", q)
|
t.Errorf("%+v", q)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// kept is the one proposal the rig keeps.
|
|
||||||
func kept(r *proposerRig) asked {
|
|
||||||
for _, a := range r.store {
|
|
||||||
if a.Proposal != nil {
|
|
||||||
return a
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return asked{}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The switch between the masked and the whole change (issue 383): the same change, under the content rule and
|
|
||||||
// exact, differs in the masked values alone; a value shaped like a secret is withheld in both, whole, with its
|
|
||||||
// key still named; and a change no value of which is masked carries no whole words of its own.
|
|
||||||
func TestAProposalShowsItsValuesWholeOnlyWhereTheSenderIsProvenAndNeverASecret(t *testing.T) {
|
|
||||||
machines := []string{"anchor", "laptop", "shanks"}
|
|
||||||
r := newProposerRig(t)
|
|
||||||
values := map[string]any{"shares": "media=/storage/media", "sources": "recalbox=smb://nas.lan/recalbox@/mnt/recalbox", "font": "Inter 13"}
|
|
||||||
if _, err := r.pr.propose(context.Background(), proposeInput{module: "mounts", node: "shanks", values: values}); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
q := r.askSent(t)
|
|
||||||
masked, _, _ := kept(r).Proposal.change(machines, false)
|
|
||||||
whole, _, _ := kept(r).Proposal.change(machines, true)
|
|
||||||
if !strings.HasSuffix(q.Explanation, "\n"+strings.Join(masked, "\n")) || !strings.HasSuffix(q.Whole, "\n"+strings.Join(whole, "\n")) {
|
|
||||||
t.Fatalf("the ask does not carry the change masked and whole:\n%s\n--\n%s", q.Explanation, q.Whole)
|
|
||||||
}
|
|
||||||
if want := []string{"+ font: Inter 13", "+ shares: media=‹path›", "+ sources: recalbox=‹address›@‹path›"}; strings.Join(masked, "\n") != strings.Join(want, "\n") {
|
|
||||||
t.Errorf("masked: %q", masked)
|
|
||||||
}
|
|
||||||
if want := []string{"+ font: Inter 13", "+ shares: media=/storage/media", "+ sources: recalbox=smb://nas.lan/recalbox@/mnt/recalbox"}; strings.Join(whole, "\n") != strings.Join(want, "\n") {
|
|
||||||
t.Errorf("whole: %q", whole)
|
|
||||||
}
|
|
||||||
if _, ok := outward.Secret(q.Whole, "shanks"); !ok {
|
|
||||||
t.Error("the router would refuse the whole words")
|
|
||||||
}
|
|
||||||
// Mutation: the masked and the whole change differ in exactly the lines whose value was masked.
|
|
||||||
differ := 0
|
|
||||||
for i := range masked {
|
|
||||||
if masked[i] != whole[i] {
|
|
||||||
differ++
|
|
||||||
if !strings.Contains(masked[i], "‹") || strings.Contains(whole[i], "‹") {
|
|
||||||
t.Errorf("the lines differ otherwise than by the mask:\n%s\n%s", masked[i], whole[i])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if differ != 2 {
|
|
||||||
t.Errorf("%d lines differ, and the mask covered 2", differ)
|
|
||||||
}
|
|
||||||
// A value shaped like a secret is withheld in both, whole, with its key still named.
|
|
||||||
r1 := newProposerRig(t)
|
|
||||||
secrets := map[string]any{"github": "ghp_abcdefghijklmnopqrstuvwxyz0123456789", "cert": "-----BEGIN CERTIFICATE-----"}
|
|
||||||
if _, err := r1.pr.propose(context.Background(), proposeInput{module: "mounts", node: "shanks", values: secrets}); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
q1 := r1.askSent(t)
|
|
||||||
for _, line := range []string{"+ cert: ‹withheld›", "+ github: ‹withheld›"} {
|
|
||||||
if !strings.Contains(q1.Explanation, line) {
|
|
||||||
t.Errorf("lacks %q:\n%s", line, q1.Explanation)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, secret := range []string{"ghp_", "BEGIN CERTIFICATE"} {
|
|
||||||
if strings.Contains(q1.Explanation, secret) || strings.Contains(q1.Whole, secret) || strings.Contains(q1.Details, secret) {
|
|
||||||
t.Errorf("the secret %q reaches the phone", secret)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// More keys than points (issue 639): the first two, then the keys of the rest, read whole at the terminal.
|
|
||||||
r3 := newProposerRig(t)
|
|
||||||
five := map[string]any{"a": "1", "b": "2", "c": "3", "d": "4", "e": "5"}
|
|
||||||
if _, err := r3.pr.propose(context.Background(), proposeInput{module: "notes", node: "laptop", values: five}); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if q3 := r3.askSent(t); !strings.HasSuffix(q3.Explanation, "\n+ a: 1\n+ b: 2\nand 3 more, each whole in the note: c, d, e") || asks.Glance(q3.Explanation) != nil ||
|
|
||||||
!strings.Contains(q3.Details, "settings proposals") {
|
|
||||||
t.Errorf("five keys:\n%s", q3.Explanation)
|
|
||||||
}
|
|
||||||
// No value masked: the message is the same everywhere, and the ask says no whole words.
|
|
||||||
r2 := newProposerRig(t)
|
|
||||||
if _, err := r2.pr.propose(context.Background(), proposeInput{module: "dunst", node: "laptop", values: map[string]any{"font-size": 13, "width": 500}}); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if q2 := r2.askSent(t); q2.Whole != "" || !strings.HasSuffix(q2.Explanation, "\n+ font-size: 13\n+ width: 500") ||
|
|
||||||
strings.Contains(q2.Details, "not proven") {
|
|
||||||
t.Errorf("%+v", q2)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A proposal expired unanswered is kept so by the reconciling, and a warrant for it afterwards sets nothing.
|
// A proposal expired unanswered is kept so by the reconciling, and a warrant for it afterwards sets nothing.
|
||||||
func TestAnExpiredProposalIsKeptExpired(t *testing.T) {
|
func TestAnExpiredProposalIsKeptExpired(t *testing.T) {
|
||||||
r := newAskerRig(t)
|
r := newAskerRig(t)
|
||||||
@@ -315,8 +205,7 @@ func TestAClearIsProposedAndShowsWhatItRemoves(t *testing.T) {
|
|||||||
}
|
}
|
||||||
q := r.askSent(t)
|
q := r.askSent(t)
|
||||||
if q.Headline != "Clear notes on laptop?" || !strings.Contains(q.Explanation, "- places.data.owner: 1001:1001") ||
|
if q.Headline != "Clear notes on laptop?" || !strings.Contains(q.Explanation, "- places.data.owner: 1001:1001") ||
|
||||||
!strings.Contains(q.Explanation, "- places.data.path: ‹path›") || !strings.Contains(q.Whole, "- places.data.path: /srv/notes") ||
|
!strings.Contains(q.Explanation, "- places.data.path: ‹path›") {
|
||||||
!strings.HasPrefix(q.Explanation, "Removes the 2 setting(s) of notes set on laptop; approved, the layer goes at once") {
|
|
||||||
t.Errorf("%+v", q)
|
t.Errorf("%+v", q)
|
||||||
}
|
}
|
||||||
if p := r.theProposal(t).Proposal; !p.Clear || p.Digest != layerDigest(r.before) || len(r.judged) != 0 {
|
if p := r.theProposal(t).Proposal; !p.Clear || p.Digest != layerDigest(r.before) || len(r.judged) != 0 {
|
||||||
@@ -855,60 +744,3 @@ func captureStdout(t *testing.T, f func()) string {
|
|||||||
_ = w.Close()
|
_ = w.Close()
|
||||||
return <-done
|
return <-done
|
||||||
}
|
}
|
||||||
|
|
||||||
// The review of #304: Approve sets exactly what the operator can read. A proposal of five keys, one value 300
|
|
||||||
// characters long, shows two keys at a glance, says what it cut, and carries every key with its whole value in its
|
|
||||||
// Details for a channel that proves who answers (WholeDetails), and every key masked for one that does not.
|
|
||||||
func TestEveryKeyAndItsWholeValueReachTheOperator(t *testing.T) {
|
|
||||||
var long strings.Builder
|
|
||||||
long.WriteString("library=/storage")
|
|
||||||
for i := 0; long.Len() < 300; i++ {
|
|
||||||
long.WriteString(fmt.Sprintf("/album-%02d", i))
|
|
||||||
}
|
|
||||||
value := long.String()[:300]
|
|
||||||
values := map[string]any{"a": "1", "b": "2", "c": "3", "d": "4", "shares": value}
|
|
||||||
r := newProposerRig(t)
|
|
||||||
if _, err := r.pr.propose(context.Background(), proposeInput{module: "mounts", node: "shanks", values: values}); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
q := r.askSent(t)
|
|
||||||
if err := q.Check(r.now); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
// What the operator reads on a proving channel: the whole words, then the note's whole details.
|
|
||||||
visible := q.Whole + "\n" + q.WholeDetails
|
|
||||||
if q.Whole == "" {
|
|
||||||
visible = q.Explanation + "\n" + q.WholeDetails
|
|
||||||
}
|
|
||||||
for k, v := range values {
|
|
||||||
if !strings.Contains(visible, "+ "+k+": "+v.(string)) {
|
|
||||||
t.Errorf("the operator never reads %s whole:\n%s", k, visible)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if asks.Glance(q.Explanation) != nil || !strings.HasSuffix(q.Explanation, "\nand 3 more, each whole in the note: c, d, shares") {
|
|
||||||
t.Errorf("the glance:\n%s", q.Explanation)
|
|
||||||
}
|
|
||||||
for k := range values {
|
|
||||||
if !strings.Contains(q.Details, "+ "+k+": ") {
|
|
||||||
t.Errorf("the masked details lack %s:\n%s", k, q.Details)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if strings.Contains(q.Details, "/storage") {
|
|
||||||
t.Errorf("the masked details show a path:\n%s", q.Details)
|
|
||||||
}
|
|
||||||
if refusal, ok := outward.Check(q.Details, "shanks"); !ok {
|
|
||||||
t.Errorf("the router would withhold the masked details: %s", refusal)
|
|
||||||
}
|
|
||||||
if _, ok := outward.Secret(q.WholeDetails, "shanks"); !ok {
|
|
||||||
t.Error("the router would refuse the whole details")
|
|
||||||
}
|
|
||||||
// A value too long for a glance is cut there, and says so.
|
|
||||||
r2 := newProposerRig(t)
|
|
||||||
if _, err := r2.pr.propose(context.Background(), proposeInput{module: "mounts", node: "shanks", values: map[string]any{"shares": value}}); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
q2 := r2.askSent(t)
|
|
||||||
if !strings.HasSuffix(q2.Whole, "… (cut: whole in the note)") || !strings.Contains(q2.WholeDetails, "+ shares: "+value) || asks.Glance(q2.Whole) != nil {
|
|
||||||
t.Errorf("the cut is not said, or the note lacks the whole:\n%s\n--\n%s", q2.Whole, q2.WholeDetails)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -740,14 +740,7 @@ func waitForApplied(ctx context.Context, inv *inventory.Inventory, node, digest
|
|||||||
fmt.Printf("%s applied it\n", node)
|
fmt.Printf("%s applied it\n", node)
|
||||||
return nil
|
return nil
|
||||||
case inventory.OutcomeFailed:
|
case inventory.OutcomeFailed:
|
||||||
// Each failed resource by id with the machine's own words, so the answer says what to fix
|
return fmt.Errorf("%s applied what it was sent but %d resource(s) failed", node, len(doing.Failed))
|
||||||
// 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, "; "))
|
|
||||||
case inventory.OutcomeRefused:
|
case inventory.OutcomeRefused:
|
||||||
return fmt.Errorf("%s refused what it was sent: %s", node, doing.Refused)
|
return fmt.Errorf("%s refused what it was sent: %s", node, doing.Refused)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -13,7 +13,6 @@ import (
|
|||||||
"slices"
|
"slices"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/novox/mesh-controller/internal/broker"
|
"github.com/novox/mesh-controller/internal/broker"
|
||||||
"github.com/novox/mesh-controller/internal/catalogue"
|
"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
|
return []string{"unpin", str("node"), str("provision")}, nil
|
||||||
case "push":
|
case "push":
|
||||||
// A push to a named machine waits for that machine's own answer, so the call's record says
|
// Sent and not waited for: the asker reads `status` for what the machine did, which is
|
||||||
// how it went: applied, refused with the machine's reason, or which resources failed and why
|
// what a person at a shell does too. A tool call that blocked for a push's whole apply would
|
||||||
// (novox/hq issue 629). How long is the call's `wait`, else the controller's setting `push-wait`;
|
// time out on every machine that takes a minute, and say nothing about the ones that did not.
|
||||||
// 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.
|
|
||||||
// A push through the seat is a push by hand, and says why (novox/hq to-be 45 §7).
|
// 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 {
|
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)
|
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
|
// 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
|
// 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.
|
// would push a machine nobody named, or not push one somebody did.
|
||||||
wait, err := pushWait(str("wait"))
|
argv := []string{"push", n, "--wait", "0"}
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
argv := []string{"push", n, "--wait", wait.String()}
|
|
||||||
// A running module's data moving is sent only when said, per module (novox/hq ADR 0217).
|
// A running module's data moving is sent only when said, per module (novox/hq ADR 0217).
|
||||||
if m := str("move"); m != "" {
|
if m := str("move"); m != "" {
|
||||||
argv = append(argv, "--move", m)
|
argv = append(argv, "--move", m)
|
||||||
@@ -1337,47 +1328,6 @@ var inProcess = map[string]bool{"tools": true, "calls": true, "doctor": true,
|
|||||||
// 0259 §8): the router asks it before an approval.
|
// 0259 §8): the router asks it before an approval.
|
||||||
"root-free": true}
|
"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
|
// 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
|
// 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
|
// broker reloads, and a reload forgets every answer the bus was about to permit — so an answer
|
||||||
|
|||||||
@@ -146,7 +146,7 @@ func TestAnArgumentAVerbDoesNotDeclareIsRefused(t *testing.T) {
|
|||||||
// naming one beside behind is refused rather than one of the two guessed.
|
// naming one beside behind is refused rather than one of the two guessed.
|
||||||
func TestAPushIsOneMachineOrSaysItIsTheWholeMesh(t *testing.T) {
|
func TestAPushIsOneMachineOrSaysItIsTheWholeMesh(t *testing.T) {
|
||||||
argv, err := argvFor("push", map[string]any{"node": "g1", "why": "w"})
|
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)
|
t.Fatalf("a named push: %v %v", argv, err)
|
||||||
}
|
}
|
||||||
for _, args := range []map[string]any{{"why": "w"}, {"behind": "true", "why": "w"}} {
|
for _, args := range []map[string]any{{"why": "w"}, {"behind": "true", "why": "w"}} {
|
||||||
|
|||||||
@@ -5,8 +5,6 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/novox/mesh-controller/internal/link"
|
"github.com/novox/mesh-controller/internal/link"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"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 and a build are sent, not waited for: the asker reads status, or the build's log by its
|
||||||
// A push answers its caller before it starts (answersFirst), so a named push waits for the machine's own
|
// id, for what happened. A repository given as a forge path is said to be one (issue 176).
|
||||||
// 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).
|
|
||||||
func TestActsDoNotBlockTheCall(t *testing.T) {
|
func TestActsDoNotBlockTheCall(t *testing.T) {
|
||||||
argv, _ := argvFor("push", map[string]any{"node": "one", "why": "w"})
|
argv, _ := argvFor("push", map[string]any{"node": "one", "why": "w"})
|
||||||
if strings.Join(argv, " ") != "push one --wait 0s --why w" || !answersFirst(argv) {
|
if strings.Join(argv, " ") != "push one --wait 0 --why w" {
|
||||||
t.Fatalf("a named push with no setting waits, or holds its caller: %v", argv)
|
t.Fatalf("push waits: %v", argv)
|
||||||
}
|
}
|
||||||
argv, _ = argvFor("build", map[string]any{"repository": "novox/x", "path": "modules/x"})
|
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" {
|
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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 "+
|
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)
|
"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 *desk != "" {
|
||||||
if *from != "" || *provider != "" {
|
if *from != "" || *provider != "" {
|
||||||
return errors.New("--at-desk gives a module's own secret, and takes neither --from nor --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 ask <node> <module> <name> [--at <machine>]\n" +
|
||||||
"secret issued <provision>\n" +
|
"secret issued <provision>\n" +
|
||||||
"secret accept <node> <module> <name> [--from <file> | --at-desk <machine>] [--provider <node> [--local <name>]]\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 recover <node> <module> <name> --key <operator-key> [--out <file>] [--from-export <file>] [--provider <node>]\n" +
|
||||||
"secret export [--out <file>]"
|
"secret export [--out <file>]"
|
||||||
|
|
||||||
|
|||||||
@@ -40,9 +40,6 @@ type sendable struct {
|
|||||||
actingEpoch uint64
|
actingEpoch uint64
|
||||||
// modules are the modules this declaration names, for the record of the send; never on the wire.
|
// modules are the modules this declaration names, for the record of the send; never on the wire.
|
||||||
modules []string
|
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 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 existed: an older host parses the envelope strictly and would refuse the key.
|
||||||
Adoption *adoptionEnvelope
|
Adoption *adoptionEnvelope
|
||||||
|
|||||||
@@ -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", "history": "true"}, []string{"settings", "show", "plex", "--history"}},
|
||||||
{"settings", map[string]any{"module": "plex", "values": "{}", "replace": "true"}, []string{"settings", "set", "plex", "{}", "--replace"}},
|
{"settings", map[string]any{"module": "plex", "values": "{}", "replace": "true"}, []string{"settings", "set", "plex", "{}", "--replace"}},
|
||||||
{"push", map[string]any{"node": "home", "move": "plex", "why": "w"},
|
{"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"}},
|
{"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"}},
|
{"plan", map[string]any{"node": "home", "diff": "true"}, []string{"plan", "home", "--diff"}},
|
||||||
} {
|
} {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ module github.com/novox/mesh-controller
|
|||||||
go 1.26.0
|
go 1.26.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013145622-f4745447ba6d
|
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261010191001-33917f91ac3c
|
||||||
github.com/jackc/pgx/v5 v5.10.0
|
github.com/jackc/pgx/v5 v5.10.0
|
||||||
github.com/nats-io/nats-server/v2 v2.11.17
|
github.com/nats-io/nats-server/v2 v2.11.17
|
||||||
github.com/nats-io/nats.go v1.54.0
|
github.com/nats-io/nats.go v1.54.0
|
||||||
|
|||||||
@@ -2,10 +2,6 @@ git.novox.be/novox/mesh-host v0.0.0-20261011092230-20d5af9b2d5f h1:8N5OW2mdTNIck
|
|||||||
git.novox.be/novox/mesh-host v0.0.0-20261011092230-20d5af9b2d5f/go.mod h1:tcTK4LMs1d6JpZUwy3hSHMFG/MIuDr/XFs7/nbeaW/c=
|
git.novox.be/novox/mesh-host v0.0.0-20261011092230-20d5af9b2d5f/go.mod h1:tcTK4LMs1d6JpZUwy3hSHMFG/MIuDr/XFs7/nbeaW/c=
|
||||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261010191001-33917f91ac3c h1:l5onJwoIeH8yE/PjVlEeoPyXBsHp2NQiWLllz2fwX7s=
|
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261010191001-33917f91ac3c h1:l5onJwoIeH8yE/PjVlEeoPyXBsHp2NQiWLllz2fwX7s=
|
||||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261010191001-33917f91ac3c/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261010191001-33917f91ac3c/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
||||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013141520-9588871d6629 h1:7TYFV1Q6Mcz+lsLP/sds87tysET4rKCn3rRIQV8gNmA=
|
|
||||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013141520-9588871d6629/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
|
||||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013145622-f4745447ba6d h1:HEJyqorKktrkJPeeJdwEo0kdrJjH3fm/YVS9MGftCqY=
|
|
||||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013145622-f4745447ba6d/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
|
||||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
|
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
|
||||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
|
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
|||||||
@@ -87,11 +87,6 @@ func Openings(rules []Rule, foundation []int, published map[string]map[int]int)
|
|||||||
widen(key{rule.Protocol, rule.Port}, OpeningFromEverywhere)
|
widen(key{rule.Protocol, rule.Port}, OpeningFromEverywhere)
|
||||||
case FromMesh:
|
case FromMesh:
|
||||||
widen(key{rule.Protocol, rule.Port}, OpeningFromMesh)
|
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 {
|
for _, port := range foundation {
|
||||||
|
|||||||
@@ -135,12 +135,6 @@ type Rendering struct {
|
|||||||
// composes no filter for it rather than writing a rule around a link with no name.
|
// composes no filter for it rather than writing a rule around a link with no name.
|
||||||
OutwardLinks []string
|
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
|
// 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,
|
// 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.
|
// 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 "+
|
"`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)
|
"keeps the filter it has", r.Node, r.Node, filters)
|
||||||
}
|
}
|
||||||
filtering := AsNftablesWithLocal(rules, with.Mesh, r.PublicDomain != "", with.Foundation,
|
filtering := AsNftables(rules, with.Mesh, r.PublicDomain != "", with.Foundation,
|
||||||
with.OutwardLinks, with.TunnelInterface, with.LocalNetworks)
|
with.OutwardLinks, with.TunnelInterface)
|
||||||
|
|
||||||
// **A variable two modules set is refused whether or not anything places it** (novox/hq ADR
|
// **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
|
// 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 —
|
// 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
|
// 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
|
// module is held to: no two modules on a machine declare one path, unit, name or package (novox/hq
|
||||||
// and absent (novox/hq issue 190, step 5; ADR 0222, ADR 0303).
|
// issue 190, step 5; ADR 0222).
|
||||||
//
|
//
|
||||||
// Resolution checks the catalogue's manifests, and a computed module's manifest has none of the
|
// 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,
|
// resources it will declare — they exist only once its generator has answered for this machine,
|
||||||
|
|||||||
@@ -152,10 +152,9 @@ func Exposure(m Manifest, layers []Layer) (map[int]string, error) {
|
|||||||
m.Module, port)
|
m.Module, port)
|
||||||
}
|
}
|
||||||
source, ok := value.(string)
|
source, ok := value.(string)
|
||||||
if !ok || (source != FromMesh && source != FromEverywhere && source != FromMachine &&
|
if !ok || (source != FromMesh && source != FromEverywhere && source != FromMachine) {
|
||||||
source != FromLocalNetwork) {
|
return nil, fmt.Errorf("%s exposes port %d as %v; it is %q, %q or %q",
|
||||||
return nil, fmt.Errorf("%s exposes port %d as %v; it is %q, %q, %q or %q",
|
m.Module, port, value, FromMesh, FromEverywhere, FromMachine)
|
||||||
m.Module, port, value, FromMesh, FromEverywhere, FromMachine, FromLocalNetwork)
|
|
||||||
}
|
}
|
||||||
out[port] = source
|
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
|
// 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.
|
// carried onto the wider one, so the module that wanted it is still named.
|
||||||
func widest(rules []Rule) []Rule {
|
func widest(rules []Rule) []Rule {
|
||||||
// The local network is the private network and the machine's own local networks (novox/hq ADR
|
reach := map[string]int{FromMachine: 0, FromMesh: 1, FromEverywhere: 2}
|
||||||
// 0340 §4), so it covers `mesh` and is covered by `anywhere`.
|
|
||||||
reach := map[string]int{FromMachine: 0, FromMesh: 1, FromLocalNetwork: 2, FromEverywhere: 3}
|
|
||||||
var out []Rule
|
var out []Rule
|
||||||
for _, rule := range rules {
|
for _, rule := range rules {
|
||||||
covered := false
|
covered := false
|
||||||
@@ -235,15 +232,6 @@ const SSHPort = 22
|
|||||||
// any module asks for, and neither may be derived away.
|
// any module asks for, and neither may be derived away.
|
||||||
func AsNftables(rules []Rule, mesh []string, outward bool, foundation []int,
|
func AsNftables(rules []Rule, mesh []string, outward bool, foundation []int,
|
||||||
outwardLinks []string, tunnel string) string {
|
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.
|
// 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
|
// 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.
|
// 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",
|
b.WriteString(fmt.Sprintf("\t\tip6 saddr { %s } %s dport %d accept\n",
|
||||||
strings.Join(six, ", "), rule.Protocol, rule.Port))
|
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:
|
case FromEverywhere:
|
||||||
b.WriteString(fmt.Sprintf("\t\t%s dport %d accept\n", rule.Protocol, rule.Port))
|
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",
|
b.WriteString(fmt.Sprintf("\t\tip6 saddr { %s } ct original proto-dst %d accept\n",
|
||||||
strings.Join(six, ", "), rule.Port))
|
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:
|
case FromEverywhere:
|
||||||
b.WriteString(fmt.Sprintf("\t\tct original proto-dst %d accept\n", rule.Port))
|
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
|
// 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.
|
// simply the filter's vocabulary with nicer words.
|
||||||
ReachBoth = "both"
|
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.
|
// 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.
|
// 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
|
return FromMesh, true
|
||||||
case ReachPublic, ReachBoth:
|
case ReachPublic, ReachBoth:
|
||||||
return FromEverywhere, true
|
return FromEverywhere, true
|
||||||
case ReachLocalNetwork:
|
|
||||||
return FromLocalNetwork, true
|
|
||||||
default:
|
default:
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
@@ -909,9 +854,7 @@ func FilterSource(reach string) (string, bool) {
|
|||||||
func WantsPublicName(reach string) bool { return reach == ReachPublic || reach == ReachBoth }
|
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.
|
// WantsInternalName is whether a reach asks for the route's internal name to be composed.
|
||||||
func WantsInternalName(reach string) bool {
|
func WantsInternalName(reach string) bool { return reach == ReachInternal || reach == ReachBoth }
|
||||||
return reach == ReachInternal || reach == ReachBoth || reach == ReachLocalNetwork
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reaches reads a module's per-node reach settings: declared port → how far it reaches.
|
// 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
|
// 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
|
// every module is — no two modules on a machine declare one path, unit, name or package. The
|
||||||
// several, novox/hq ADR 0303). The
|
|
||||||
// private network once declared the container runtime's file and service beside the runtime's own
|
// 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.
|
// module, and nothing refused it, because the collision check only ever saw catalogue manifests.
|
||||||
|
|
||||||
|
|||||||
@@ -25,13 +25,13 @@ func TestAnOfferMaySayItsSoftwareIssuesTheCredential(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for want, raw := range map[string]string{
|
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"}}]}`,
|
"provides":[{"name":"d-api","credential":{"issued-by":"vendor","tool":"d_token"}}]}`,
|
||||||
"names no tool that answers it": `{"module":"d","version":"1",
|
"names no tool that answers it": `{"module":"d","version":"1",
|
||||||
"provides":[{"name":"d-api","credential":{"issued-by":"software"}}]}`,
|
"provides":[{"name":"d-api","credential":{"issued-by":"software"}}]}`,
|
||||||
"names its own secret": `{"module":"d","version":"1","own-secrets":{"token":{"path":"/p","taken":"at-start"}},
|
"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"}}]}`,
|
"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"}}]}`,
|
"provides":[{"name":"d-api","credential":{"tool":"d_token"}}]}`,
|
||||||
} {
|
} {
|
||||||
want = strings.TrimSpace(want)
|
want = strings.TrimSpace(want)
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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, ", "))
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -269,8 +269,7 @@ func KeepsConsumerData(catalogue map[string]Manifest, provision string) bool {
|
|||||||
func (o Offer) MachineReach() bool { return o.Reach == ReachMachine }
|
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
|
// 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,
|
// IssuedBy, that the provider's software issues the provision's one credential itself (novox/hq ADR 0305,
|
||||||
// with IssuedBy "software", 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.
|
// 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
|
// 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.
|
// one call, and to the controller alone.
|
||||||
@@ -294,34 +293,6 @@ func (m Manifest) IssuedCredentialOf(provision string) (string, bool) {
|
|||||||
return "", false
|
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,
|
// SharedCredentialOf is the own secret an offer of this module names as the provision's credential,
|
||||||
// and whether it names one.
|
// and whether it names one.
|
||||||
func (m Manifest) SharedCredentialOf(provision string) (string, bool) {
|
func (m Manifest) SharedCredentialOf(provision string) (string, bool) {
|
||||||
@@ -1165,12 +1136,6 @@ const (
|
|||||||
FromEverywhere = "anywhere"
|
FromEverywhere = "anywhere"
|
||||||
// FromMachine is this machine only — a port bound for something else on the same host.
|
// FromMachine is this machine only — a port bound for something else on the same host.
|
||||||
FromMachine = "machine"
|
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.
|
// At is this port's protocol, with the default applied.
|
||||||
@@ -1682,28 +1647,14 @@ func ParseManifest(raw []byte) (Manifest, error) {
|
|||||||
"makes is %d", m.Module, p, id.Max, shortest))
|
"makes is %d", m.Module, p, id.Max, shortest))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if c := offer.Credential; c != nil && c.IssuedBy == IssuedOutside {
|
if c := offer.Credential; c != nil && (c.IssuedBy != "" || c.Tool != "") {
|
||||||
// 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 != "") {
|
|
||||||
// The software issues it (novox/hq ADR 0305): named by the tool that answers it, never by an own
|
// 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.
|
// secret, which would be a second copy for the mesh to keep in step with the software's.
|
||||||
switch {
|
switch {
|
||||||
case c.IssuedBy != IssuedBySoftware:
|
case c.IssuedBy != IssuedBySoftware:
|
||||||
problems = append(problems, fmt.Sprintf(
|
problems = append(problems, fmt.Sprintf(
|
||||||
"%s provides %q with a credential issued by %q; the issuers an offer may name are %q and %q "+
|
"%s provides %q with a credential issued by %q; the one issuer an offer may name is %q (ADR 0305)",
|
||||||
"(ADR 0305, ADR 0337)", m.Module, p, c.IssuedBy, IssuedBySoftware, IssuedOutside))
|
m.Module, p, c.IssuedBy, IssuedBySoftware))
|
||||||
case c.Own != "":
|
case c.Own != "":
|
||||||
problems = append(problems, fmt.Sprintf(
|
problems = append(problems, fmt.Sprintf(
|
||||||
"%s provides %q with a credential its software issues and names its own secret %q too; the "+
|
"%s provides %q with a credential its software issues and names its own secret %q too; the "+
|
||||||
@@ -1935,17 +1886,17 @@ func ParseManifest(raw []byte) (Manifest, error) {
|
|||||||
"%s listens on port %d, which is not a port", m.Module, l.Port))
|
"%s listens on port %d, which is not a port", m.Module, l.Port))
|
||||||
}
|
}
|
||||||
switch l.From {
|
switch l.From {
|
||||||
case FromMesh, FromEverywhere, FromMachine, FromLocalNetwork:
|
case FromMesh, FromEverywhere, FromMachine:
|
||||||
case "":
|
case "":
|
||||||
// The fault this field exists to prevent. A rule with no source is open, and a
|
// 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.
|
// manifest that omitted it would read as a restriction and be none.
|
||||||
problems = append(problems, fmt.Sprintf(
|
problems = append(problems, fmt.Sprintf(
|
||||||
"%s listens on %d and does not say from where; it is %q, %q, %q or %q",
|
"%s listens on %d and does not say from where; it is %q, %q or %q",
|
||||||
m.Module, l.Port, FromMesh, FromEverywhere, FromMachine, FromLocalNetwork))
|
m.Module, l.Port, FromMesh, FromEverywhere, FromMachine))
|
||||||
default:
|
default:
|
||||||
problems = append(problems, fmt.Sprintf(
|
problems = append(problems, fmt.Sprintf(
|
||||||
"%s listens on %d from %q; it is %q, %q, %q or %q",
|
"%s listens on %d from %q; it is %q, %q or %q",
|
||||||
m.Module, l.Port, l.From, FromMesh, FromEverywhere, FromMachine, FromLocalNetwork))
|
m.Module, l.Port, l.From, FromMesh, FromEverywhere, FromMachine))
|
||||||
}
|
}
|
||||||
if p := l.At(); p != "tcp" && p != "udp" {
|
if p := l.At(); p != "tcp" && p != "udp" {
|
||||||
problems = append(problems, fmt.Sprintf(
|
problems = append(problems, fmt.Sprintf(
|
||||||
|
|||||||
@@ -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
|
// 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
|
// would both declare, or nothing (novox/hq ADR 0210 §1, 04-ISSUES/235).
|
||||||
// declares present and another absent. A package several modules declare present is no collision (ADR 0303).
|
|
||||||
//
|
//
|
||||||
// **Refused, not kept like an unresolved provision.** An assignment is otherwise kept when the
|
// **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.
|
// 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
|
// 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
|
// 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.
|
// 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 {
|
func CollisionRefusal(catalogue map[string]Manifest, node string, assigned, adding []string) error {
|
||||||
|
|||||||
@@ -322,6 +322,9 @@ var defaultSeats = append([]Seat{
|
|||||||
// node-mounts writes a mount and an automount unit per share on a machine that asks (shares.go).
|
// node-mounts writes a mount and an automount unit per share on a machine that asks (shares.go).
|
||||||
{Name: NFSServerSeat, Scope: ScopeNode, Decision: "novox/hq ADR 0263", Serves: nfsServerVerbs()},
|
{Name: NFSServerSeat, Scope: ScopeNode, Decision: "novox/hq ADR 0263", Serves: nfsServerVerbs()},
|
||||||
{Name: MountsSeat, Scope: ScopeNode, Decision: "novox/hq ADR 0263", Serves: mountsVerbs()},
|
{Name: MountsSeat, Scope: ScopeNode, Decision: "novox/hq ADR 0263", Serves: mountsVerbs()},
|
||||||
|
// A machine's SMB shares for the devices on its own network (novox/hq ADR 0341, smb.go): its holder
|
||||||
|
// writes the SMB configuration, serves 445 alone and maps every client to the shared folder's owner.
|
||||||
|
{Name: SMBServerSeat, Scope: ScopeNode, Decision: "novox/hq ADR 0341", Serves: smbServerVerbs()},
|
||||||
// A node's files and accounts, read (novox/hq ADR 0310, reads.go): by an account of the holder's own that holds
|
// A node's files and accounts, read (novox/hq ADR 0310, reads.go): by an account of the holder's own that holds
|
||||||
// no secret, on every node, so the reads ADR 0268 §5 takes over from the login shell's execute have a tool.
|
// no secret, on every node, so the reads ADR 0268 §5 takes over from the login shell's execute have a tool.
|
||||||
{Name: ReadsSeat, Scope: ScopeNode, Decision: "novox/hq ADR 0310", Serves: readsVerbs()},
|
{Name: ReadsSeat, Scope: ScopeNode, Decision: "novox/hq ADR 0310", Serves: readsVerbs()},
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ func TestTheSeatsAreAClosedSetAndEachNamesItsDecision(t *testing.T) {
|
|||||||
delivered[s.Delivers] = s.Name
|
delivered[s.Delivers] = s.Name
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Forty-one with node-reads (novox/hq ADR 0310); forty with node-nfs-server and node-mounts (novox/hq ADR 0263); thirty-eight with node-resolver (novox/hq ADR 0247); thirty-seven with mesh-delivery (novox/hq ADR 0239); thirty-six since node-resolver-config retired
|
// Forty-two with node-smb-server (novox/hq ADR 0341); forty-one with node-reads (novox/hq ADR 0310); forty with node-nfs-server and node-mounts (novox/hq ADR 0263); thirty-eight with node-resolver (novox/hq ADR 0247); thirty-seven with mesh-delivery (novox/hq ADR 0239); thirty-six since node-resolver-config retired
|
||||||
// into node-uplink (novox/hq ADR 0223); thirty-seven
|
// into node-uplink (novox/hq ADR 0223); thirty-seven
|
||||||
// since the retired node-dns-resolver went (novox/hq ADR 0220); thirty-eight with
|
// since the retired node-dns-resolver went (novox/hq ADR 0220); thirty-eight with
|
||||||
// node-backup (novox/hq ADR 0214); thirty-seven with node-message-bus (novox/hq ADR 0215);
|
// node-backup (novox/hq ADR 0214); thirty-seven with node-message-bus (novox/hq ADR 0215);
|
||||||
@@ -59,8 +59,8 @@ func TestTheSeatsAreAClosedSetAndEachNamesItsDecision(t *testing.T) {
|
|||||||
// node-container-runtime (ADR 0207); nineteen with node-environment and node-login-shell (ADR 0203,
|
// node-container-runtime (ADR 0207); nineteen with node-environment and node-login-shell (ADR 0203,
|
||||||
// ADR 0204); seventeen with node-build-agent (ADR 0190). One fewer once the retired
|
// ADR 0204); seventeen with node-build-agent (ADR 0190). One fewer once the retired
|
||||||
// mesh-build-machine row goes, when no registered manifest claims it.
|
// mesh-build-machine row goes, when no registered manifest claims it.
|
||||||
if len(Seats()) != 41 {
|
if len(Seats()) != 42 {
|
||||||
t.Errorf("the mesh defines %d seats rather than 41; the set is closed, so a change here is "+
|
t.Errorf("the mesh defines %d seats rather than 42; the set is closed, so a change here is "+
|
||||||
"a decision (novox/hq ADR 0110): %s", len(Seats()), seatNames())
|
"a decision (novox/hq ADR 0110): %s", len(Seats()), seatNames())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,9 +31,10 @@ func nfsServerVerbs() []Verb {
|
|||||||
{Name: "exports", Description: "Every share this machine exports: its name, its path, read-write or " +
|
{Name: "exports", Description: "Every share this machine exports: its name, its path, read-write or " +
|
||||||
"read-only, the owner every client is mapped to (uid and gid), each node it is exported to with that " +
|
"read-only, the owner every client is mapped to (uid and gid), each node it is exported to with that " +
|
||||||
"node's private address and access (only the nodes the server grants it to and that ask for it), " +
|
"node's private address and access (only the nodes the server grants it to and that ask for it), " +
|
||||||
"what is granted and not asked for or asked for and not granted, and whether the kernel holds it " +
|
"the subnets of this machine's local network it is exported to when the server grants it " +
|
||||||
"now. Also the exports found that are not the mesh's: a dataset's sharenfs property, a line in " +
|
"local-network (novox/hq ADR 0341), what is granted and not asked for or asked for and not " +
|
||||||
"/etc/exports.",
|
"granted, and whether the kernel holds it now. Also the exports found that are not the mesh's: a " +
|
||||||
|
"dataset's sharenfs property, a line in /etc/exports.",
|
||||||
Input: schema(map[string]string{}, nil),
|
Input: schema(map[string]string{}, nil),
|
||||||
Replaces: []string{"exportfs -v", "cat /etc/exports", "zfs get sharenfs"}},
|
Replaces: []string{"exportfs -v", "cat /etc/exports", "zfs get sharenfs"}},
|
||||||
{Name: "clients", Description: "Which machines have mounted which share now, as the NFS server " +
|
{Name: "clients", Description: "Which machines have mounted which share now, as the NFS server " +
|
||||||
@@ -55,8 +56,10 @@ func nfsServerVerbs() []Verb {
|
|||||||
Input: schema(map[string]string{}, nil),
|
Input: schema(map[string]string{}, nil),
|
||||||
Replaces: []string{"exportfs -ra"}},
|
Replaces: []string{"exportfs -ra"}},
|
||||||
{Name: "adopt", Description: "Take over an export a person made by hand: clear a dataset's sharenfs " +
|
{Name: "adopt", Description: "Take over an export a person made by hand: clear a dataset's sharenfs " +
|
||||||
"property for a path the mesh's own export now serves — only when that export is live. Without " +
|
"property for a path the mesh's own export now serves — only when that export is live and covers " +
|
||||||
"confirm, says what it would do and changes nothing.",
|
"every client the property exported to: for a property exporting to the local network, the " +
|
||||||
|
"mesh's local-network export of the path (novox/hq ADR 0341). Without confirm, says what it " +
|
||||||
|
"would do and changes nothing.",
|
||||||
Input: schema(map[string]string{
|
Input: schema(map[string]string{
|
||||||
"path": "the shared path whose hand-made export is taken over",
|
"path": "the shared path whose hand-made export is taken over",
|
||||||
"confirm": "\"true\": change it (needs why); anything else is a dry run",
|
"confirm": "\"true\": change it (needs why); anything else is a dry run",
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package catalogue
|
||||||
|
|
||||||
|
// A machine's SMB shares, served to the devices on its own network that are not nodes (novox/hq
|
||||||
|
// ADR 0341).
|
||||||
|
//
|
||||||
|
// **One holder per machine.** Two modules writing one machine's Samba configuration is the conflict a
|
||||||
|
// seat exists to refuse, so the role is a node seat. It delivers nothing: its clients are devices that
|
||||||
|
// are not nodes, and a node that wants the same files mounts the NFS share (ADR 0263), so no provision
|
||||||
|
// is answered through it.
|
||||||
|
//
|
||||||
|
// **The data is the operator's** (ADR 0051). The holder never creates, chowns or removes anything under
|
||||||
|
// a shared path, and every client acts as the shared folder's owner. Its verbs read, and the one that
|
||||||
|
// takes over the configuration a person wrote by hand does it only on a person's word, keeping a dated
|
||||||
|
// copy of what it replaces.
|
||||||
|
|
||||||
|
// SMBServerSeat is the role of the machine that shares folders over SMB with its local network
|
||||||
|
// (novox/hq ADR 0341).
|
||||||
|
const SMBServerSeat = "node-smb-server"
|
||||||
|
|
||||||
|
// smbServerVerbs is the contract every holder of node-smb-server serves (novox/hq ADR 0341).
|
||||||
|
func smbServerVerbs() []Verb {
|
||||||
|
return []Verb{
|
||||||
|
{Name: "shares", Description: "Every SMB share this machine serves: its name, its path, read-write " +
|
||||||
|
"or read-only, the owner every client acts as (uid and gid), the logins allowed and whether each " +
|
||||||
|
"login's password was given or is kept as found, and whether the service serves it now. Also " +
|
||||||
|
"whether the configuration is the mesh's or still the hand-written one, and each hand-written " +
|
||||||
|
"share with whether the settings name it. A password is never shown.",
|
||||||
|
Input: schema(map[string]string{}, nil),
|
||||||
|
Replaces: []string{"testparm -s", "cat /etc/samba/smb.conf", "pdbedit -L"}},
|
||||||
|
{Name: "sessions", Description: "Which devices are connected to which share now, with the login and " +
|
||||||
|
"the protocol version each uses, as the SMB service knows them.",
|
||||||
|
Input: schema(map[string]string{}, nil),
|
||||||
|
Replaces: []string{"smbstatus"}},
|
||||||
|
{Name: "test", Description: "Whether this machine serves one share now and whether its SMB service " +
|
||||||
|
"is up and listening on 445: what a device on the local network is answered.",
|
||||||
|
Input: schema(map[string]string{"share": "the share, by its name"}, []string{"share"}),
|
||||||
|
Replaces: []string{"smbclient -L"}},
|
||||||
|
{Name: "reload", Description: "Have the SMB service read its configuration again now (smbcontrol " +
|
||||||
|
"reload-config) and answer the shares as it then serves them. The module's own process writes the " +
|
||||||
|
"configuration; this is for after a change made outside it. Changes no shared path.",
|
||||||
|
Input: schema(map[string]string{}, nil),
|
||||||
|
Replaces: []string{"smbcontrol smbd reload-config", "systemctl reload smb"}},
|
||||||
|
{Name: "adopt", Description: "Take over the SMB configuration a person wrote by hand: keep a dated " +
|
||||||
|
"copy of it beside it and write the mesh's own in its place — only when every hand-written share " +
|
||||||
|
"has its counterpart in the settings, so no share disappears. Without confirm, says what it would " +
|
||||||
|
"do and changes nothing: each hand-written share beside the mesh's rendering of it.",
|
||||||
|
Input: schema(map[string]string{
|
||||||
|
"confirm": "\"true\": change it (needs why); anything else is a dry run",
|
||||||
|
"why": "why, for the record",
|
||||||
|
}, nil, "confirm"),
|
||||||
|
Replaces: []string{"edit /etc/samba/smb.conf"}},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
package catalogue
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Defends novox/hq ADR 0341: a machine's SMB shares are one node seat, node-smb-server, with its verbs
|
||||||
|
// required of every holder, delivering nothing — its clients are devices that are not nodes.
|
||||||
|
|
||||||
|
func TestTheSMBServerIsANodeSeatWithItsVerbs(t *testing.T) {
|
||||||
|
s, ok := SeatNamed(SMBServerSeat)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("%s is not in the mesh's set", SMBServerSeat)
|
||||||
|
}
|
||||||
|
if s.Scope != ScopeNode || s.Decision != "novox/hq ADR 0341" || s.Delivers != "" || s.Replicated {
|
||||||
|
t.Errorf("%s is %+v; a node seat under ADR 0341 that delivers nothing", SMBServerSeat, s)
|
||||||
|
}
|
||||||
|
var got []string
|
||||||
|
for _, v := range s.Serves {
|
||||||
|
got = append(got, v.Name)
|
||||||
|
if v.Optional {
|
||||||
|
t.Errorf("%s.%s is optional; its first holder serves it", SMBServerSeat, v.Name)
|
||||||
|
}
|
||||||
|
if v.Description == "" || v.Input["type"] != "object" || len(v.Replaces) == 0 {
|
||||||
|
t.Errorf("%s.%s has no description, no object schema or says it replaces nothing", SMBServerSeat, v.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Join(got, " ") != "shares sessions test reload adopt" {
|
||||||
|
t.Errorf("%s serves %v", SMBServerSeat, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Taking over the hand-written configuration is a person's act (ADR 0341 clause 7): a dry run unless the
|
||||||
|
// string switch says "true", and it says it keeps a dated copy and refuses to drop a share.
|
||||||
|
func TestTheSMBAdoptIsADryRunUnlessConfirmed(t *testing.T) {
|
||||||
|
s, _ := SeatNamed(SMBServerSeat)
|
||||||
|
for _, v := range s.Serves {
|
||||||
|
if v.Name != "adopt" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
props, _ := v.Input["properties"].(map[string]any)
|
||||||
|
confirm, _ := props["confirm"].(map[string]any)
|
||||||
|
if confirm == nil || confirm["enum"] == nil || confirm["type"] != "string" {
|
||||||
|
t.Errorf("adopt has no string confirm switch: %v", v.Input)
|
||||||
|
}
|
||||||
|
for _, says := range []string{"Without confirm, says what it would do and changes nothing", "dated copy", "no share disappears"} {
|
||||||
|
if !strings.Contains(v.Description, says) {
|
||||||
|
t.Errorf("adopt does not say %q: %s", says, v.Description)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.Error("node-smb-server has no adopt")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A holder serving every verb holds the seat; a verb too many, one too few or the mesh's scope is refused,
|
||||||
|
// as the catalogue's smb-server module claims it.
|
||||||
|
func TestAHolderOfTheSMBSeatServesEveryVerbAndNothingElse(t *testing.T) {
|
||||||
|
seat, _ := SeatNamed(SMBServerSeat)
|
||||||
|
verbs := []string{"shares", "sessions", "test", "reload", "adopt"}
|
||||||
|
holder := Manifest{Module: "smb-server", Version: "1",
|
||||||
|
Claims: []Claim{{Name: SMBServerSeat, Scope: ScopeNode, Serves: verbs}}}
|
||||||
|
if err := CanHold(holder, seat); err != nil {
|
||||||
|
t.Errorf("smb-server serving every verb is refused: %v", err)
|
||||||
|
}
|
||||||
|
short := holder
|
||||||
|
short.Claims = []Claim{{Name: SMBServerSeat, Scope: ScopeNode, Serves: verbs[:4]}}
|
||||||
|
if err := CanHold(short, seat); err == nil || !strings.Contains(err.Error(), "adopt") {
|
||||||
|
t.Errorf("leaving adopt out was accepted: %v", err)
|
||||||
|
}
|
||||||
|
mesh := holder
|
||||||
|
mesh.Claims = []Claim{{Name: SMBServerSeat, Scope: ScopeMesh, Serves: verbs}}
|
||||||
|
if err := CanHold(mesh, seat); err == nil {
|
||||||
|
t.Error("claiming a node seat at the mesh's scope was accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// node-nfs-server's adopt clears sharenfs only once the mesh's export covers every client the property
|
||||||
|
// exported to, the local network included (ADR 0341 clause 10), and exports names the local-network grant.
|
||||||
|
func TestTheNFSVerbsSayTheLocalNetworkGrant(t *testing.T) {
|
||||||
|
s, _ := SeatNamed(NFSServerSeat)
|
||||||
|
for _, v := range s.Serves {
|
||||||
|
switch v.Name {
|
||||||
|
case "adopt":
|
||||||
|
if !strings.Contains(v.Description, "covers every client the property exported to") {
|
||||||
|
t.Errorf("nfs adopt does not wait for the export to cover every client: %s", v.Description)
|
||||||
|
}
|
||||||
|
case "exports":
|
||||||
|
if !strings.Contains(v.Description, "local-network") {
|
||||||
|
t.Errorf("nfs exports does not say the local-network grant: %s", v.Description)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -217,16 +217,14 @@ var ControllerVerbs = []Verb{
|
|||||||
Input: schema(map[string]string{"node": "the machine's name", "provision": "the provision"}, []string{"node", "provision"})},
|
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 " +
|
{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. " +
|
"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 " +
|
"Answers at once that it is running, with a call id: `calls` with that id says what it sent " +
|
||||||
"machine named, how that machine applied it — applied, refused with its reason, or each failed resource — " +
|
"(a push can reload the bus, which then refuses any answer still to come). A push by hand is a repair, " +
|
||||||
"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, " +
|
|
||||||
"and says why: recorded in the hand-act log (novox/hq to-be 45 §7). A machine whose push would give a " +
|
"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 " +
|
"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.",
|
"`plan` with diff, and name the machine with move to send it.",
|
||||||
Input: schema(map[string]string{
|
Input: schema(map[string]string{
|
||||||
"node": "the machine's name; without it, every machine that is behind",
|
"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)",
|
"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",
|
"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",
|
"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)",
|
"cause": "the cause in a word, or a condition's kind — the word a second push for the same reason uses (optional)",
|
||||||
|
|||||||
@@ -58,42 +58,7 @@ func (i *Inventory) Dependencies(ctx context.Context) ([]Edge, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
held, err := i.Held(ctx)
|
return dependenciesOf(entries, against, nil), nil
|
||||||
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]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// dependenciesOf is Dependencies over what was read, so a test can hand it a catalogue.
|
// 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
|
// `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.
|
// the planner, never through the relation.
|
||||||
func dependenciesOf(entries []Entry, against map[string][]string, _ map[string][]ReadRepository) []Edge {
|
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{}
|
known := map[string]bool{}
|
||||||
var builders []string
|
var builders []string
|
||||||
for _, e := range entries {
|
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] {
|
for _, ref := range against[name] {
|
||||||
if rest, ok := strings.CutPrefix(ref, catalogue.ArtifactStoreScheme); ok {
|
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
|
// 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 {
|
if base, _, found := strings.Cut(rest, "/"); found {
|
||||||
add(name, base, EdgeStandsOn)
|
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 != "" {
|
if e.Source.Repository != "" {
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package inventory
|
package inventory
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"strings"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/novox/mesh-controller/internal/catalogue"
|
"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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -44,13 +44,7 @@ type Wait struct {
|
|||||||
Part string `json:"part"`
|
Part string `json:"part"`
|
||||||
Secret string `json:"secret,omitempty"`
|
Secret string `json:"secret,omitempty"`
|
||||||
Setting string `json:"setting,omitempty"`
|
Setting string `json:"setting,omitempty"`
|
||||||
// Provision is a provision the module requires whose credential only a person can give and nobody gave yet
|
What string `json:"what"`
|
||||||
// (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"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// NodeHealth is a machine's newest statement, as kept.
|
// NodeHealth is a machine's newest statement, as kept.
|
||||||
|
|||||||
@@ -36,16 +36,7 @@ type MadeOf struct {
|
|||||||
BootedAt *time.Time `json:"booted_at"`
|
BootedAt *time.Time `json:"booted_at"`
|
||||||
Timezone string `json:"timezone"`
|
Timezone string `json:"timezone"`
|
||||||
Filesystems []MountedFilesystem `json:"filesystems"`
|
Filesystems []MountedFilesystem `json:"filesystems"`
|
||||||
// Networks is every network the machine is directly attached to on a link facing outside it, as
|
Unknown map[string]string `json:"unknown"`
|
||||||
// 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"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MountedFilesystem is one local filesystem a machine has mounted.
|
// MountedFilesystem is one local filesystem a machine has mounted.
|
||||||
|
|||||||
@@ -40,18 +40,3 @@ func TestAProfileWithoutTheFactsIsAnOlderNodeEngineNotNothing(t *testing.T) {
|
|||||||
t.Error("a node that never reported read as having reported")
|
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,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()
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -104,21 +104,6 @@ func (i *Inventory) SecretFor(ctx context.Context, name, consumer, consumerModul
|
|||||||
consumerModule, name, provider, consumer, consumerModule, name, provider, localFlag(local))
|
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
|
// 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.
|
// makes a vault-provided secret recoverable, and nothing the mesh can open.
|
||||||
operator, err := i.OperatorKey(ctx)
|
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
|
`select origin from secret where name = $1 and consumer = $2 and consumer_module = $3
|
||||||
and provider = $4 and local = $5`,
|
and provider = $4 and local = $5`,
|
||||||
name, consumerNode.ID, consumerModule, providerNode.ID, local).Scan(&origin)
|
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 {
|
if err == nil && origin == OriginAccepted {
|
||||||
return fmt.Errorf(
|
return fmt.Errorf(
|
||||||
"%s's %q credential from %s was accepted from a person, and the mesh cannot make "+
|
"%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
|
held.ConsumerModule, held.Local = consumerModule, local
|
||||||
return held, nil
|
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 {
|
if ownOrigin != nil && *ownOrigin == OriginAccepted {
|
||||||
return Secret{}, fmt.Errorf(
|
return Secret{}, fmt.Errorf(
|
||||||
"%s on %s needs %s from %s, whose credential is %s's own secret %q — a value given to the "+
|
"%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 {
|
if len(provisions) == 0 {
|
||||||
return fmt.Errorf("%s names no provision whose credential is its own secret %q", providerModule, own)
|
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)
|
operator, err := i.OperatorKey(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -577,10 +577,7 @@ type Wait struct {
|
|||||||
Part string `json:"part"`
|
Part string `json:"part"`
|
||||||
Secret string `json:"secret,omitempty"`
|
Secret string `json:"secret,omitempty"`
|
||||||
Setting string `json:"setting,omitempty"`
|
Setting string `json:"setting,omitempty"`
|
||||||
// Provision is a provision the module requires whose credential only a person can give and nobody gave yet
|
What string `json:"what"`
|
||||||
// (novox/hq ADR 0337 §5): a wait names exactly one of Secret, Setting or Provision.
|
|
||||||
Provision string `json:"provision,omitempty"`
|
|
||||||
What string `json:"what"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ResourceHealth is one long-running resource's state.
|
// ResourceHealth is one long-running resource's state.
|
||||||
|
|||||||
+11
-56
@@ -80,57 +80,6 @@ func Check(text string, machines ...string) (Refusal, bool) {
|
|||||||
return Refusal{"address", "a hardware address"}, false
|
return Refusal{"address", "a hardware address"}, false
|
||||||
case reIPv6.MatchString(text):
|
case reIPv6.MatchString(text):
|
||||||
return Refusal{"address", "an IPv6 address"}, false
|
return Refusal{"address", "an IPv6 address"}, false
|
||||||
}
|
|
||||||
if refusal, ok := secretShape(text); !ok {
|
|
||||||
return refusal, false
|
|
||||||
}
|
|
||||||
if reWinPath.MatchString(text) {
|
|
||||||
return Refusal{"path", "a drive path"}, false
|
|
||||||
}
|
|
||||||
if refusal, ok := randomRun(text); !ok {
|
|
||||||
return refusal, false
|
|
||||||
}
|
|
||||||
for _, word := range strings.FieldsFunc(text, isSeparator) {
|
|
||||||
w := strings.TrimRight(word, ".:!?")
|
|
||||||
if isPath(w) {
|
|
||||||
return Refusal{"path", "a file path"}, false
|
|
||||||
}
|
|
||||||
if isHostName(w) {
|
|
||||||
return Refusal{"address", "a host name"}, false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return Refusal{}, true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Secret says whether a text carries a secret's shape, the one class that leaves the mesh nowhere — the
|
|
||||||
// messenger's CheckSecret, one for one. It is what an ask's whole words (asks.Ask.Whole, novox/hq issue 383)
|
|
||||||
// are held to: on a channel that proves who answers, a path or an address is what the operator approves and
|
|
||||||
// is shown; a secret never is. A path is read as one: a run with a slash in it is judged piece by piece
|
|
||||||
// between the slashes, so "/mnt/Photos_2024/Jochen" is a path and not a random string (the review of
|
|
||||||
// 2026-10-10); the named shapes hold whatever the run holds.
|
|
||||||
func Secret(text string, machines ...string) (Refusal, bool) {
|
|
||||||
text = withoutMachines(text, machines)
|
|
||||||
if refusal, ok := secretShape(text); !ok {
|
|
||||||
return refusal, false
|
|
||||||
}
|
|
||||||
return randomRunOutsidePaths(text)
|
|
||||||
}
|
|
||||||
|
|
||||||
// randomRunOutsidePaths is randomRun with each run that holds a slash judged by its pieces between the slashes.
|
|
||||||
func randomRunOutsidePaths(text string) (Refusal, bool) {
|
|
||||||
for _, run := range reRun.FindAllString(text, -1) {
|
|
||||||
for _, piece := range strings.Split(run, "/") {
|
|
||||||
if len(piece) >= 20 && looksRandom(piece) {
|
|
||||||
return Refusal{"secret", "a long random-looking string"}, false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return Refusal{}, true
|
|
||||||
}
|
|
||||||
|
|
||||||
// secretShape is the secret shapes a pattern names.
|
|
||||||
func secretShape(text string) (Refusal, bool) {
|
|
||||||
switch {
|
|
||||||
case rePEM.MatchString(text):
|
case rePEM.MatchString(text):
|
||||||
return Refusal{"secret", "a key block"}, false
|
return Refusal{"secret", "a key block"}, false
|
||||||
case reJWT.MatchString(text):
|
case reJWT.MatchString(text):
|
||||||
@@ -143,17 +92,23 @@ func secretShape(text string) (Refusal, bool) {
|
|||||||
return Refusal{"secret", "a value given to a secret's name"}, false
|
return Refusal{"secret", "a value given to a secret's name"}, false
|
||||||
case reHex.MatchString(text):
|
case reHex.MatchString(text):
|
||||||
return Refusal{"secret", "a long hexadecimal string"}, false
|
return Refusal{"secret", "a long hexadecimal string"}, false
|
||||||
|
case reWinPath.MatchString(text):
|
||||||
|
return Refusal{"path", "a drive path"}, false
|
||||||
}
|
}
|
||||||
return Refusal{}, true
|
|
||||||
}
|
|
||||||
|
|
||||||
// randomRun is a long unbroken run of characters spread as a random string's are.
|
|
||||||
func randomRun(text string) (Refusal, bool) {
|
|
||||||
for _, run := range reRun.FindAllString(text, -1) {
|
for _, run := range reRun.FindAllString(text, -1) {
|
||||||
if looksRandom(run) {
|
if looksRandom(run) {
|
||||||
return Refusal{"secret", "a long random-looking string"}, false
|
return Refusal{"secret", "a long random-looking string"}, false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for _, word := range strings.FieldsFunc(text, isSeparator) {
|
||||||
|
w := strings.TrimRight(word, ".:!?")
|
||||||
|
if isPath(w) {
|
||||||
|
return Refusal{"path", "a file path"}, false
|
||||||
|
}
|
||||||
|
if isHostName(w) {
|
||||||
|
return Refusal{"address", "a host name"}, false
|
||||||
|
}
|
||||||
|
}
|
||||||
return Refusal{}, true
|
return Refusal{}, true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -86,40 +86,3 @@ func TestScrubAlwaysGivesWhatMayLeave(t *testing.T) {
|
|||||||
t.Errorf("a text that passes was changed: %q", got)
|
t.Errorf("a text that passes was changed: %q", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Secret is the one class that leaves the mesh nowhere (novox/hq issue 383): it refuses every secret's shape
|
|
||||||
// Check refuses, and nothing Check refuses as an address or a path — those an ask's whole words may carry.
|
|
||||||
func TestSecretRefusesOnlyASecretsShape(t *testing.T) {
|
|
||||||
for _, text := range []string{
|
|
||||||
"-----BEGIN RSA PRIVATE KEY-----",
|
|
||||||
"eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.abc",
|
|
||||||
"123456789:ABCdefGHIjklMNOpqrSTUvwxYZ0123456789ab",
|
|
||||||
"ghp_abcdefghijklmnopqrstuvwxyz0123456789",
|
|
||||||
"password=hunter2",
|
|
||||||
"0123456789abcdef0123456789abcdef",
|
|
||||||
"a key xK9mQ2vL8pR4tW7yB3nF6hJ1dG5sA0zC",
|
|
||||||
} {
|
|
||||||
r, ok := Secret(text)
|
|
||||||
if ok || r.Class != "secret" {
|
|
||||||
t.Errorf("not refused as a secret: %q (%s)", text, r)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, text := range []string{
|
|
||||||
"recalbox=smb://nas.lan/recalbox@/mnt/recalbox:ro",
|
|
||||||
"library=/mnt/library",
|
|
||||||
"photos=/mnt/Photos_2024/Jochen",
|
|
||||||
"games=smb://nas.lan/Recalbox_Games2024",
|
|
||||||
"/srv/media/Series_Archive/Season01",
|
|
||||||
"10.77.0.9:53",
|
|
||||||
"jochen@example.com",
|
|
||||||
"C:\\Users\\jo",
|
|
||||||
"anchor and the laptop",
|
|
||||||
} {
|
|
||||||
if r, ok := Secret(text, "anchor"); !ok {
|
|
||||||
t.Errorf("refused as %s: %q", r, text)
|
|
||||||
}
|
|
||||||
if _, ok := Check(text, "anchor"); ok && text != "anchor and the laptop" {
|
|
||||||
t.Errorf("Check lets %q leave, so Secret is not the narrower rule", text)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
+1
-16
@@ -35,11 +35,6 @@
|
|||||||
"kind": "preference",
|
"kind": "preference",
|
||||||
"default": "10m",
|
"default": "10m",
|
||||||
"why": "a busy period never holds a batch longer than this after its first merge (novox/hq ADR 0276)"
|
"why": "a busy period never holds a batch longer than this after its first merge (novox/hq ADR 0276)"
|
||||||
},
|
|
||||||
"push-wait": {
|
|
||||||
"kind": "preference",
|
|
||||||
"default": "2m",
|
|
||||||
"why": "how long a push to a named machine waits for that machine's answer, so its call says how it went; a call's own wait overrides it, and 0 waits for nothing (novox/hq issue 629)"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"tools": [
|
"tools": [
|
||||||
@@ -125,15 +120,6 @@
|
|||||||
"owner": "mesh-controller",
|
"owner": "mesh-controller",
|
||||||
"content": "{\n \"merge-window\": \"${setting:merge-window}\",\n \"merge-window-at-most\": \"${setting:merge-window-at-most}\"\n}\n"
|
"content": "{\n \"merge-window\": \"${setting:merge-window}\",\n \"merge-window-at-most\": \"${setting:merge-window-at-most}\"\n}\n"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"id": "push",
|
|
||||||
"trusted": false,
|
|
||||||
"type": "file",
|
|
||||||
"path": "${dir:mesh-state}/push.json",
|
|
||||||
"mode": "0600",
|
|
||||||
"owner": "mesh-controller",
|
|
||||||
"content": "{\n \"push-wait\": \"${setting:push-wait}\"\n}\n"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"id": "controller",
|
"id": "controller",
|
||||||
"type": "process",
|
"type": "process",
|
||||||
@@ -158,8 +144,7 @@
|
|||||||
"MESH_BROKER_ADDRESS_PORT": "${seat:mesh-broker:5671}",
|
"MESH_BROKER_ADDRESS_PORT": "${seat:mesh-broker:5671}",
|
||||||
"MESH_BUS_NATS_FILE": "${dir:mesh-state}/bus",
|
"MESH_BUS_NATS_FILE": "${dir:mesh-state}/bus",
|
||||||
"MESH_CONTROLLER_VERSION": "${version}",
|
"MESH_CONTROLLER_VERSION": "${version}",
|
||||||
"MESH_MERGE_WINDOW_FILE": "${dir:mesh-state}/merge-window.json",
|
"MESH_MERGE_WINDOW_FILE": "${dir:mesh-state}/merge-window.json"
|
||||||
"MESH_PUSH_FILE": "${dir:mesh-state}/push.json"
|
|
||||||
},
|
},
|
||||||
"replaces": [
|
"replaces": [
|
||||||
"server"
|
"server"
|
||||||
|
|||||||
Vendored
+1
-1
@@ -9,7 +9,7 @@ a second place to clean besides the catalogue itself.
|
|||||||
|
|
||||||
mesh-catalog e0bc919d4945bb5f13f971f74643a0b714cf45da modules/*/module.json, modules/nats/Dockerfile
|
mesh-catalog e0bc919d4945bb5f13f971f74643a0b714cf45da modules/*/module.json, modules/nats/Dockerfile
|
||||||
mesh-host 7a41e120635b3f7b6cb94cca8b3fc4093e4e6110 examples/foundation-first-node.lock
|
mesh-host 7a41e120635b3f7b6cb94cca8b3fc4093e4e6110 examples/foundation-first-node.lock
|
||||||
mesh-sdk f4745447ba6d0c4fd7dc6b1071e26ce012806233 conformance/events/module-event.json
|
mesh-sdk 33917f91ac3cc213b879b32f3c70e2aa70395f98 conformance/events/module-event.json
|
||||||
|
|
||||||
To move them, from this repository's root, with the two repositories checked out beside it:
|
To move them, from this repository's root, with the two repositories checked out beside it:
|
||||||
|
|
||||||
|
|||||||
Vendored
-6
@@ -45,9 +45,3 @@
|
|||||||
./cmd/mesh-controller TestReplay348AFaultFromBeforeTheSendFailsNoGate
|
./cmd/mesh-controller TestReplay348AFaultFromBeforeTheSendFailsNoGate
|
||||||
./cmd/mesh-controller TestReplay239AModuleNameIsNotTakenOverByAnotherRepository
|
./cmd/mesh-controller TestReplay239AModuleNameIsNotTakenOverByAnotherRepository
|
||||||
./cmd/mesh-controller TestAWalkThatMovedNothingSaysWhichModulesMatchedWhatRuns
|
./cmd/mesh-controller TestAWalkThatMovedNothingSaysWhichModulesMatchedWhatRuns
|
||||||
./cmd/mesh-controller TestReplay614ANewSDKPlansTheToolchainAndThenItsBundles
|
|
||||||
./internal/inventory TestACredentialIssuedOutsideIsNeverMade
|
|
||||||
./internal/inventory TestOneAcceptForAProviderIsSealedToEveryConsumer
|
|
||||||
./internal/inventory TestASharedOwnSecretIssuedOutsideIsNeverMade
|
|
||||||
./cmd/mesh-controller TestOneAcceptForAProviderIsPlacedForEveryConsumer
|
|
||||||
./cmd/mesh-controller TestReplay164
|
|
||||||
|
|||||||
+4
-64
@@ -174,12 +174,6 @@ func (a Ask) Digest() string {
|
|||||||
canonical(&b, a.Whole)
|
canonical(&b, a.Whole)
|
||||||
canonical(&b, a.Details)
|
canonical(&b, a.Details)
|
||||||
}
|
}
|
||||||
// WholeDetails (novox/hq issue 383, the review of mesh-controller #304) follows only when given, so every
|
|
||||||
// ask without it digests as it did before it existed.
|
|
||||||
if a.WholeDetails != "" {
|
|
||||||
canonical(&b, "whole-details")
|
|
||||||
canonical(&b, a.WholeDetails)
|
|
||||||
}
|
|
||||||
sum := sha256.Sum256([]byte(b.String()))
|
sum := sha256.Sum256([]byte(b.String()))
|
||||||
return "sha256:" + hex.EncodeToString(sum[:])
|
return "sha256:" + hex.EncodeToString(sum[:])
|
||||||
}
|
}
|
||||||
@@ -208,59 +202,9 @@ type Ask struct {
|
|||||||
// The asker withholds a value shaped like a secret in it, and the router refuses the ask when one is left;
|
// The asker withholds a value shaped like a secret in it, and the router refuses the ask when one is left;
|
||||||
// Explanation stays under the whole content rule everywhere else. Empty, Explanation is shown everywhere.
|
// Explanation stays under the whole content rule everywhere else. Empty, Explanation is shown everywhere.
|
||||||
Whole string `json:"whole,omitempty"`
|
Whole string `json:"whole,omitempty"`
|
||||||
// Details are the ask's secondary words, line by line under the content rule: who proposed it, a
|
// Details is what the Details answer on the ask's message shows, line by line under the content rule: a
|
||||||
// fingerprint, how to read it whole. The router shows them in the note of the ask's own message
|
// fingerprint, how to read the proposal whole at the terminal. Empty, an ask's own message offers no Details.
|
||||||
// (Message.Note), after what the ask's options do and when it expires — never in its body (novox/hq
|
|
||||||
// issue 639).
|
|
||||||
Details string `json:"details,omitempty"`
|
Details string `json:"details,omitempty"`
|
||||||
// WholeDetails are the Details with their exact values whole, shown in place of Details where Whole is shown
|
|
||||||
// in place of the explanation (a kind that proves who answers, given the ask's answers): every value an
|
|
||||||
// approval sets, whole, so the person reads exactly what they approve (novox/hq issue 383). Held, like
|
|
||||||
// Whole, to the secret rule alone; the router refuses the ask when a secret's shape is left in it. Empty,
|
|
||||||
// Details are shown everywhere.
|
|
||||||
WholeDetails string `json:"whole-details,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// What an ask's explanation is held to, so it reads at a glance on a phone (novox/hq issue 639): a summary
|
|
||||||
// of two or three short sentences on its first line — what changes, why, what it costs or risks — then at
|
|
||||||
// most three points, a line each, only where they add something. Whole is held to the same. Who asked, when,
|
|
||||||
// the fingerprint and how to read it whole are Details, never the explanation.
|
|
||||||
const (
|
|
||||||
SummaryLength = 300
|
|
||||||
MostPoints = 3
|
|
||||||
PointLength = 160
|
|
||||||
)
|
|
||||||
|
|
||||||
// Glance says why an explanation does not read at a glance, or nil: its first line, the summary, longer than
|
|
||||||
// SummaryLength; more than MostPoints lines after it; a point longer than PointLength. Blank lines are not
|
|
||||||
// counted. An asker holds its asks to it in its own tests; the router says, without refusing, an ask that
|
|
||||||
// does not hold to it.
|
|
||||||
func Glance(explanation string) error {
|
|
||||||
var lines []string
|
|
||||||
for _, l := range strings.Split(explanation, "\n") {
|
|
||||||
if strings.TrimSpace(l) != "" {
|
|
||||||
lines = append(lines, strings.TrimSpace(l))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
var problems []string
|
|
||||||
switch {
|
|
||||||
case len(lines) == 0:
|
|
||||||
problems = append(problems, "it has no summary")
|
|
||||||
case len([]rune(lines[0])) > SummaryLength:
|
|
||||||
problems = append(problems, fmt.Sprintf("its summary is %d characters, more than %d", len([]rune(lines[0])), SummaryLength))
|
|
||||||
}
|
|
||||||
if len(lines) > 1+MostPoints {
|
|
||||||
problems = append(problems, fmt.Sprintf("it has %d points after its summary, more than %d", len(lines)-1, MostPoints))
|
|
||||||
}
|
|
||||||
for i, l := range lines {
|
|
||||||
if i > 0 && len([]rune(l)) > PointLength {
|
|
||||||
problems = append(problems, fmt.Sprintf("its point %d is %d characters, more than %d", i, len([]rune(l)), PointLength))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(problems) > 0 {
|
|
||||||
return errors.New("the explanation does not read at a glance: " + strings.Join(problems, "; "))
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// The bounds of an ask (novox/hq ADR 0234 §8, ADR 0259 §4).
|
// The bounds of an ask (novox/hq ADR 0234 §8, ADR 0259 §4).
|
||||||
@@ -491,12 +435,8 @@ type Message struct {
|
|||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Body string `json:"body"`
|
Body string `json:"body"`
|
||||||
Buttons []Button `json:"buttons,omitempty"`
|
Buttons []Button `json:"buttons,omitempty"`
|
||||||
// Note is the message's secondary words, after its body and its buttons: who asked and when, what each
|
Urgent bool `json:"urgent,omitempty"`
|
||||||
// answer does, when it expires, and the ask's Details. A channel shows it smaller, collapsed or set apart
|
Silent bool `json:"silent,omitempty"`
|
||||||
// where it can, and after the body where it cannot; it is never left out (novox/hq issue 639).
|
|
||||||
Note string `json:"note,omitempty"`
|
|
||||||
Urgent bool `json:"urgent,omitempty"`
|
|
||||||
Silent bool `json:"silent,omitempty"`
|
|
||||||
// Reply is the Choice or LinkAsked this answers, by its ID: the channel shows it where that was made.
|
// Reply is the Choice or LinkAsked this answers, by its ID: the channel shows it where that was made.
|
||||||
Reply string `json:"reply,omitempty"`
|
Reply string `json:"reply,omitempty"`
|
||||||
// To is the account linked as the operator on this kind, for a channel that verifies its sender: where
|
// To is the account linked as the operator on this kind, for a channel that verifies its sender: where
|
||||||
|
|||||||
Vendored
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
# git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013145622-f4745447ba6d
|
# git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261010191001-33917f91ac3c
|
||||||
## explicit; go 1.22
|
## explicit; go 1.22
|
||||||
git.novox.be/novox/mesh-sdk/go/asks
|
git.novox.be/novox/mesh-sdk/go/asks
|
||||||
# github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op
|
# github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op
|
||||||
|
|||||||
Reference in New Issue
Block a user