Design 47's last milestones: the keeper, self-check, healers, hand-acts, provisioner, vault, artifact sweep, disk readings, credential forms (issues 774, 1952, 1953, 1954, 1955) #560

Open
mesh-tools wants to merge 90 commits from feat/774-l3-reland into main
57 changed files with 1058 additions and 175 deletions
Showing only changes of commit d276452d46 - Show all commits
+5
View File
@@ -0,0 +1,5 @@
module checks/credentialform
go 1.26.0
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261029214732-6b8c6943811a
+2
View File
@@ -0,0 +1,2 @@
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261029214732-6b8c6943811a h1:elC2IjB+qbeXG+pWItLz71DenegbrdsqbcH5kyGo56Q=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261029214732-6b8c6943811a/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
+36
View File
@@ -0,0 +1,36 @@
// Package credentialform refuses an offer that does not say the form of its credential: an own secret, an issuer, or
// `form` pair or none (the lead, 2026-10-29). It reads every modules/*/module.json through mesh-sdk's
// CredentialFormProblems.
package credentialform
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"git.novox.be/novox/mesh-sdk/go/catalogue"
)
// Problems is every offer of the manifests under root/modules that says no credential form, one line each.
func Problems(root string) ([]string, error) {
files, err := filepath.Glob(filepath.Join(root, "modules", "*", "module.json"))
if err != nil {
return nil, err
}
sort.Strings(files)
var out []string
for _, f := range files {
raw, err := os.ReadFile(f)
if err != nil {
return nil, err
}
var m catalogue.Manifest
if err := json.Unmarshal(raw, &m); err != nil {
return nil, fmt.Errorf("%s: %w", f, err)
}
out = append(out, catalogue.CredentialFormProblems(m)...)
}
return out, nil
}
+46
View File
@@ -0,0 +1,46 @@
package credentialform
import (
"os"
"path/filepath"
"strings"
"testing"
)
func aCatalogue(t *testing.T, manifests map[string]string) string {
t.Helper()
root := t.TempDir()
for module, body := range manifests {
dir := filepath.Join(root, "modules", module)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "module.json"), []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
return root
}
// An offer that says no form is refused and named; one that says pair, none or an own secret passes; and the real
// catalogue is clean.
func TestEveryOfferSaysTheFormOfItsCredential(t *testing.T) {
root := aCatalogue(t, map[string]string{
"silent": `{"module":"silent","provides":[{"name":"thing","scope":"mesh"}]}`,
"says": `{"module":"says","provides":[{"name":"a","credential":{"form":"pair"}},{"name":"b","credential":{"form":"none"}},{"name":"c","credential":{"own":"jwt"}}]}`,
})
got, err := Problems(root)
if err != nil {
t.Fatal(err)
}
if len(got) != 1 || !strings.Contains(got[0], `silent provides "thing"`) {
t.Fatalf("refused %v, wanted the one silent offer", got)
}
real, err := Problems(filepath.Join("..", ".."))
if err != nil {
t.Fatal(err)
}
if len(real) != 0 {
t.Fatalf("the catalogue has offers with no credential form:\n%s", strings.Join(real, "\n"))
}
}
+3
View File
@@ -0,0 +1,3 @@
# What these tests read outside their package, as paths from the repository's root (issue 1936).
# every manifest, for what each offer says of its credential's form.
modules/*/module.json
+4 -1
View File
@@ -8,7 +8,10 @@
"provides": [
{
"name": "rootless-image-build",
"reach": "machine"
"reach": "machine",
"credential": {
"form": "none"
}
}
],
"resources": [
+4 -1
View File
@@ -4,7 +4,10 @@
"provides": [
{
"name": "artifact-store",
"scope": "mesh"
"scope": "mesh",
"credential": {
"form": "none"
}
}
],
"claims": [
+4 -1
View File
@@ -9,7 +9,10 @@
{
"name": "wildcard-resolution",
"scope": "mesh",
"identity": false
"identity": false,
"credential": {
"form": "none"
}
}
],
"requires": [
+4 -1
View File
@@ -7,7 +7,10 @@
"provides": [
{
"name": "go-toolchain",
"reach": "machine"
"reach": "machine",
"credential": {
"form": "none"
}
}
],
"resources": [
+3
View File
@@ -7,6 +7,9 @@
"scope": "mesh",
"identity": {
"in": "an InfluxDB v1 authorization"
},
"credential": {
"form": "pair"
}
}
],
+11 -1
View File
@@ -665,7 +665,14 @@
"path": "${dir:mesh-state}/config.json",
"mode": "0600",
"template": "files/config.json",
"takes-config": ["domain", "proxy-address", "sitename", "website", "url", "container"],
"takes-config": [
"domain",
"proxy-address",
"sitename",
"website",
"url",
"container"
],
"merge": "json"
},
{
@@ -758,6 +765,9 @@
"identity": {
"max": 64,
"in": "a mailbox's local part"
},
"credential": {
"form": "pair"
}
}
],
+1 -1
View File
@@ -3,7 +3,7 @@ module meshbuildgo
go 1.26.0
require (
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261028190821-9e5666b4d6f5
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261029214732-6b8c6943811a
github.com/distribution/distribution/v3 v3.1.2
github.com/nats-io/nats-server/v2 v2.11.17
github.com/nats-io/nats.go v1.54.0
+2 -2
View File
@@ -1,5 +1,5 @@
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261028190821-9e5666b4d6f5 h1:L7EJbCPZfJRCYHyco5Ck3ui+pWc//sp+h5nnPqSarng=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261028190821-9e5666b4d6f5/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261029214732-6b8c6943811a h1:elC2IjB+qbeXG+pWItLz71DenegbrdsqbcH5kyGo56Q=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261029214732-6b8c6943811a/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
github.com/alicebob/miniredis/v2 v2.35.0 h1:QwLphYqCEAo1eu1TqPRN2jgVMPBweeQcR21jeqDCONI=
github.com/alicebob/miniredis/v2 v2.35.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
+1 -1
View File
@@ -3,7 +3,7 @@ module meshbuildimage
go 1.26.0
require (
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261028190821-9e5666b4d6f5
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261029214732-6b8c6943811a
github.com/nats-io/nats-server/v2 v2.11.17
github.com/nats-io/nats.go v1.54.0
)
+2 -2
View File
@@ -1,5 +1,5 @@
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261028190821-9e5666b4d6f5 h1:L7EJbCPZfJRCYHyco5Ck3ui+pWc//sp+h5nnPqSarng=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261028190821-9e5666b4d6f5/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261029214732-6b8c6943811a h1:elC2IjB+qbeXG+pWItLz71DenegbrdsqbcH5kyGo56Q=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261029214732-6b8c6943811a/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
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/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
Binary file not shown.
+1 -1
View File
@@ -3,7 +3,7 @@ module meshbuildnode
go 1.26.0
require (
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261028190821-9e5666b4d6f5
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261029214732-6b8c6943811a
github.com/distribution/distribution/v3 v3.1.2
github.com/nats-io/nats.go v1.54.0
)
+2 -2
View File
@@ -1,5 +1,5 @@
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261028190821-9e5666b4d6f5 h1:L7EJbCPZfJRCYHyco5Ck3ui+pWc//sp+h5nnPqSarng=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261028190821-9e5666b4d6f5/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261029214732-6b8c6943811a h1:elC2IjB+qbeXG+pWItLz71DenegbrdsqbcH5kyGo56Q=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261029214732-6b8c6943811a/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
github.com/alicebob/miniredis/v2 v2.35.0 h1:QwLphYqCEAo1eu1TqPRN2jgVMPBweeQcR21jeqDCONI=
github.com/alicebob/miniredis/v2 v2.35.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
Binary file not shown.
@@ -15,6 +15,7 @@ import (
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/health"
)
func logf(format string, a ...any) { fmt.Fprintf(os.Stderr, format+"\n", a...) }
@@ -98,8 +99,10 @@ func run(w *Watcher, l *listening, node string) {
time.Sleep(10 * time.Minute)
}
}()
follow := newTakeover()
go followTakeover(follow)
handle := func(e stdio.Envelope) error {
if !isHeartbeat(e.Key) {
if !follow.heard(e.Key) {
return nil
}
b, err := DecodeBeat(e.Body)
@@ -113,6 +116,9 @@ func run(w *Watcher, l *listening, node string) {
time.Sleep(500 * time.Millisecond)
for wait := 2 * time.Second; ; wait = min(wait*2, time.Minute) {
err := stdio.Subscribe(ControllerSeat+".*", handle)
if err == nil {
err = stdio.Subscribe(health.SelfCheckModule+".*", handle)
}
if err == nil {
l.set("listening")
logf("[mesh-watcher] listening for the self-check's heartbeat; watching from %s", node)
@@ -34,7 +34,7 @@ func TestTheManifestSaysWhatTheCodeDoes(t *testing.T) {
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(m.Consumes, []string{ControllerSeat + "." + HeartbeatEvent}) {
if !reflect.DeepEqual(m.Consumes, []string{ControllerSeat + "." + HeartbeatEvent, "mesh-self-check.checked"}) {
t.Fatalf("consumes %v", m.Consumes)
}
if !reflect.DeepEqual(m.Invokes, []string{m.Module + ".watcher_ping", "seat:mesh-controller.seats"}) {
@@ -0,0 +1,59 @@
package main
import (
"sync"
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/health"
)
// takeover is which self-check run the watcher hears: the controller's `doctor-heartbeat` until the keeper's
// take-over key names the keeper, then mesh-self-check's `checked`, whose body is the same run (novox/hq ADR 0564
// clause 9, issue 1952 W1). An absent key follows the controller.
type takeover struct {
mu sync.Mutex
t health.Takeover
}
func newTakeover() *takeover {
return &takeover{t: health.Takeover{V: health.TakeoverVersion, Follow: health.FollowController}}
}
// heard says whether key is the followed self-check event.
func (f *takeover) heard(key string) bool {
f.mu.Lock()
defer f.mu.Unlock()
seat, event := f.t.SelfCheckEvent()
return key == seat+"."+event
}
func (f *takeover) set(value []byte, found bool) error {
t, err := health.DecodeTakeover(value, found)
if err != nil {
return err
}
f.mu.Lock()
f.t = t
f.mu.Unlock()
return nil
}
// followTakeover watches the key for the life of the process; one it cannot read leaves the event it heard.
func followTakeover(f *takeover) {
for wait := 2 * time.Second; ; wait = min(wait*2, time.Minute) {
err := stdio.State(health.KeeperModule+"."+health.TakeoverState).Watch(health.TakeoverKey, func(c stdio.StateChange) error {
if err := f.set(c.Value, c.Op != "delete" && len(c.Value) > 0); err != nil {
logf("[mesh-watcher] the take-over key is unreadable (%v); no change in what it hears", err)
} else {
logf("[mesh-watcher] the take-over key now follows %s", f.t.Follow)
}
return nil
})
if err == nil {
return
}
logf("[mesh-watcher] not watching the take-over key yet (%v)", err)
time.Sleep(wait)
}
}
+2 -2
View File
@@ -1,5 +1,5 @@
module mesh-watcher
go 1.22
go 1.26.0
require git.novox.be/novox/mesh-sdk/go v0.1.7
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261029214732-6b8c6943811a
+2 -2
View File
@@ -1,2 +1,2 @@
git.novox.be/novox/mesh-sdk/go v0.1.7 h1:C0sTQmtTiyYH7bnqZb7PusXnqA37gKuT7Nqjn9gG47w=
git.novox.be/novox/mesh-sdk/go v0.1.7/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261029214732-6b8c6943811a h1:elC2IjB+qbeXG+pWItLz71DenegbrdsqbcH5kyGo56Q=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261029214732-6b8c6943811a/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
+5 -1
View File
@@ -3,7 +3,11 @@
"version": "1",
"slug": "watch",
"consumes": [
"mesh-controller.doctor-heartbeat"
"mesh-controller.doctor-heartbeat",
"mesh-self-check.checked"
],
"reads": [
"mesh-conditions.takeover"
],
"invokes": [
"mesh-watcher.watcher_ping",
+5 -2
View File
@@ -122,9 +122,12 @@ func (c Condition) SilencedAt(now time.Time) bool {
// eventOf is the local event name of an envelope's key: `mesh-controller.condition-raised` is
// `condition-raised`. Anything not of the controller's seat is not a condition event.
func eventOf(key string) (string, bool) {
func eventOf(key string) (string, bool) { return eventOfSeat(key, ControllerSeat) }
// eventOfSeat is eventOf for the seat the take-over key says is followed: the controller's, or the keeper's (ADR 0564 clause 9).
func eventOfSeat(key, seat string) (string, bool) {
emitter, event, ok := strings.Cut(key, ".")
if !ok || emitter != ControllerSeat {
if !ok || emitter != seat {
return "", false
}
switch event {
+13 -5
View File
@@ -18,6 +18,7 @@ import (
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/asks"
"git.novox.be/novox/mesh-sdk/go/health"
)
func errorf(format string, a ...any) error { return fmt.Errorf(format, a...) }
@@ -415,8 +416,9 @@ func run(h *Holder, l *listening) {
// The state first: what is open now is read from the controller before any event is taken, so
// what the stream replays is history (novox/hq issue 271). Not reachable yet, it is asked for
// again in the loop below, and old events meanwhile are kept as state, never said.
follow := newTakeover()
h.Start()
syncFromController(h)
syncFromController(h, follow)
go func() {
tick := time.NewTicker(5 * time.Second)
defer tick.Stop()
@@ -429,10 +431,11 @@ func run(h *Holder, l *listening) {
}
if h.WantsSync() && now.Sub(lastAsk) >= time.Minute {
lastAsk = now
syncFromController(h)
syncFromController(h, follow)
}
}
}()
go followTakeover(follow, h)
handle := func(e stdio.Envelope) error {
if event, ok := strings.CutPrefix(e.Key, ViewModule+"."); ok {
// A module in motion on a node, settled or stuck: a walk's start or end now that plans are gone (ADR 0308 §1, ADR 0539 §17).
@@ -446,7 +449,9 @@ func run(h *Holder, l *listening) {
}
return nil
}
event, ok := eventOf(e.Key)
// Only the seat the take-over key names is heard: the other's events are dropped, so the keeper's conditions
// are never held a second time beside the controller's (ADR 0564 clause 9).
event, ok := eventOfSeat(e.Key, follow.seat())
if !ok {
return nil
}
@@ -476,6 +481,9 @@ func run(h *Holder, l *listening) {
}()
for wait := 2 * time.Second; ; wait = min(wait*2, time.Minute) {
err := stdio.Subscribe(ControllerSeat+".*", handle)
if err == nil {
err = stdio.Subscribe(health.KeeperModule+".*", handle)
}
if err == nil {
l.set("listening")
logf("[messenger] listening for the controller's condition events")
@@ -625,7 +633,7 @@ func followKinds(h *Holder) {
}
// syncFromController reads the controller's open conditions and gives them to the holder.
func syncFromController(h *Holder) {
func syncFromController(h *Holder, follow *takeover) {
asked := h.Now()
type answer struct {
raw json.RawMessage
@@ -633,7 +641,7 @@ func syncFromController(h *Holder) {
}
done := make(chan answer, 1)
go func() {
raw, err := stdio.Ask("seat:"+ControllerSeat+".conditions", map[string]any{})
raw, err := stdio.Ask("seat:"+follow.seat()+".conditions", map[string]any{})
done <- answer{raw, err}
}()
var a answer
@@ -81,13 +81,14 @@ func TestItDeclaresTheSeatsOfAnAskAndHoldsTheOperatorChannel(t *testing.T) { //
}
if !reflect.DeepEqual(m.Consumes, []string{
ControllerSeat + "." + EventRaised, ControllerSeat + "." + EventChanged, ControllerSeat + "." + EventCleared,
"mesh-conditions." + EventRaised, "mesh-conditions." + EventChanged, "mesh-conditions." + EventCleared,
ViewModule + "." + EventMoving, ViewModule + "." + EventSettled, ViewModule + "." + EventStuck, "intake.choice", "intake.link", "intake.failed", "intake.standing"}) {
t.Fatalf("consumes %v", m.Consumes)
}
// It performs nothing: the verbs it calls read the open conditions, whether root is free, and which issues
// track which conditions.
if !reflect.DeepEqual(m.Emits, []string{"refused"}) || !reflect.DeepEqual(m.Invokes, []string{"seat:" + ControllerSeat + ".conditions",
"seat:" + EngineSeat + ".root-free", TrackingVerb}) {
"seat:mesh-conditions.conditions", "seat:" + EngineSeat + ".root-free", TrackingVerb}) {
t.Fatalf("emits %v invokes %v", m.Emits, m.Invokes)
}
var state []string
+8
View File
@@ -180,3 +180,11 @@ func (h *Holder) WantsSync() bool {
h.init()
return h.wantSync || h.syncedAt.IsZero() || h.Now().Sub(h.syncedAt) >= ResyncEvery
}
// AskSync has the next pass of the loop read the open conditions again (the take-over key named another seat).
func (h *Holder) AskSync() {
h.mu.Lock()
h.init()
h.wantSync = true
h.mu.Unlock()
}
@@ -0,0 +1,59 @@
package main
import (
"sync"
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/health"
)
// takeover is which seat's condition events the messenger hears: the controller's until the keeper's take-over key
// says otherwise (novox/hq ADR 0564 clause 9, issue 1952 R1). An absent key follows the controller.
type takeover struct {
mu sync.Mutex
t health.Takeover
}
func newTakeover() *takeover {
return &takeover{t: health.Takeover{V: health.TakeoverVersion, Follow: health.FollowController}}
}
func (f *takeover) seat() string { f.mu.Lock(); defer f.mu.Unlock(); return f.t.Seat() }
// set takes the key as a watch handed it and says whether the followed seat changed.
func (f *takeover) set(value []byte, found bool) (changed bool, err error) {
t, err := health.DecodeTakeover(value, found)
if err != nil {
return false, err
}
f.mu.Lock()
defer f.mu.Unlock()
changed = f.t.Seat() != t.Seat()
f.t = t
return changed, nil
}
// followTakeover watches the take-over key for the life of the process, and asks the holder to read the followed
// seat's open conditions again when it changes. A key it cannot read leaves the seat it followed.
func followTakeover(f *takeover, h *Holder) {
for wait := 2 * time.Second; ; wait = min(wait*2, time.Minute) {
err := stdio.State(health.KeeperModule+"."+health.TakeoverState).Watch(health.TakeoverKey, func(c stdio.StateChange) error {
changed, err := f.set(c.Value, c.Op != "delete" && len(c.Value) > 0)
if err != nil {
logf("[messenger] the take-over key is unreadable (%v); still hearing %s", err, f.seat())
return nil
}
if changed {
logf("[messenger] the take-over key now names %s", f.seat())
h.AskSync()
}
return nil
})
if err == nil {
return
}
logf("[messenger] not watching the take-over key yet (%v); hearing %s", err, f.seat())
time.Sleep(wait)
}
}
+1 -1
View File
@@ -3,7 +3,7 @@ module messenger
go 1.26.0
require (
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261019091011-aa68b67389c9
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261029214732-6b8c6943811a
github.com/nats-io/nats-server/v2 v2.11.17
github.com/nats-io/nats.go v1.54.0
)
+2 -2
View File
@@ -1,5 +1,5 @@
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261019091011-aa68b67389c9 h1:qHpN/fpTDqijfMVoepxUT6mPkfcydb4LiPKsIokv6iM=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261019091011-aa68b67389c9/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261029214732-6b8c6943811a h1:elC2IjB+qbeXG+pWItLz71DenegbrdsqbcH5kyGo56Q=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261029214732-6b8c6943811a/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
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/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
+7
View File
@@ -77,6 +77,9 @@
"mesh-controller.condition-raised",
"mesh-controller.condition-changed",
"mesh-controller.condition-cleared",
"mesh-conditions.condition-raised",
"mesh-conditions.condition-changed",
"mesh-conditions.condition-cleared",
"mesh-delivery-view.moving",
"mesh-delivery-view.settled",
"mesh-delivery-view.stuck",
@@ -90,9 +93,13 @@
],
"invokes": [
"seat:mesh-controller.conditions",
"seat:mesh-conditions.conditions",
"seat:node-engine.root-free",
"seat:issue-tracker.tracking"
],
"reads": [
"mesh-conditions.takeover"
],
"state": [
"open",
"sent",
+3
View File
@@ -12,6 +12,9 @@
"identity": {
"max": 20,
"in": "an S3 access key"
},
"credential": {
"form": "pair"
}
}
],
+3
View File
@@ -12,6 +12,9 @@
"identity": {
"max": 63,
"in": "a MongoDB database name"
},
"credential": {
"form": "pair"
}
}
],
+3
View File
@@ -8,6 +8,9 @@
"scope": "mesh",
"identity": {
"in": "a Mosquitto client and topic prefix"
},
"credential": {
"form": "pair"
}
}
],
+3
View File
@@ -12,6 +12,9 @@
"identity": {
"max": 128,
"in": "a SQL Server login and database name"
},
"credential": {
"form": "pair"
}
}
],
+5 -94
View File
@@ -1,105 +1,16 @@
package main
import (
"crypto/sha256"
"crypto/tls"
"crypto/x509"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"strings"
"time"
"nats-tools/internal/busconn"
"github.com/nats-io/nats.go"
)
// Credential is what the mesh seals to a module so it can reach the bus (mesh-controller
// cmd/mesh-controller issueWith): where, the server's certificate by fingerprint, who, and the
// password. The address is not used here — this runs beside the server, and the server is reached on
// loopback — but the fingerprint is: loopback or not, the certificate is the mesh's own.
type Credential struct {
URL string `json:"url"`
Fingerprint string `json:"fingerprint,omitempty"`
User string `json:"user"`
Password string `json:"password"`
}
// Credential is what the mesh seals to a module so it can reach the bus; see busconn.
type Credential = busconn.Credential
func readCredential(path string) (Credential, error) {
var raw []byte
var err error
if path == "-" {
raw, err = io.ReadAll(io.LimitReader(os.Stdin, 1<<20))
} else {
raw, err = os.ReadFile(path)
}
if err != nil {
return Credential{}, fmt.Errorf("cannot read the credential: %w", err)
}
var c Credential
if err := json.Unmarshal(raw, &c); err != nil {
return Credential{}, errors.New("the credential is not the JSON the mesh seals to a module (url, fingerprint, user, password)")
}
if c.User == "" || c.Password == "" {
return Credential{}, errors.New("the credential names no user or password, so the bus would refuse it")
}
return c, nil
}
func readCredential(path string) (Credential, error) { return busconn.Read(path) }
// connect dials the bus as the credential's user. A `tls://` server is held to the credential's
// fingerprint and nothing else — the mesh's bus presents a certificate of its own, in no trust store,
// so a name check could only fail or be skipped. A plain `nats://` server is accepted only on
// loopback, and is what a restore's own private server is.
func connect(server string, c Credential) (*nats.Conn, error) {
opts := []nats.Option{
nats.UserInfo(c.User, c.Password),
nats.Name("mesh-nats-snapshot"),
// Answers come to the user's own inbox and no other: the bus grants every user its own
// `_INBOX.<user>.>` and nothing wider (novox/hq design 25 §4).
nats.CustomInboxPrefix("_INBOX." + c.User),
nats.Timeout(10 * time.Second),
// One attempt: a snapshot that cannot reach the bus fails the night, loudly, rather than
// waiting for it.
nats.NoReconnect(),
}
switch {
case strings.HasPrefix(server, "tls://"):
if c.Fingerprint == "" {
return nil, errors.New("the credential carries no fingerprint, so the bus's certificate cannot be checked")
}
opts = append(opts, nats.Secure(pinnedTo(c.Fingerprint)))
case strings.HasPrefix(server, "nats://127.0.0.1:"), strings.HasPrefix(server, "nats://localhost:"):
if c.Fingerprint != "" {
// The mesh's bus requires TLS; a plain connection to it would be refused anyway.
opts = append(opts, nats.Secure(pinnedTo(c.Fingerprint)))
}
default:
return nil, fmt.Errorf("%s: the bus is reached over tls:// (or plain nats:// on loopback, for a restore's own server)", server)
}
nc, err := nats.Connect(server, opts...)
if err != nil {
return nil, fmt.Errorf("cannot reach the bus at %s as %s: %w", server, c.User, err)
}
return nc, nil
}
// pinnedTo accepts exactly the certificate with this SHA-256 — the same pin every machine holds
// (mesh-controller internal/broker PinnedToFingerprint).
func pinnedTo(want string) *tls.Config {
return &tls.Config{
InsecureSkipVerify: true, //nolint:gosec // replaced by the pin below, which is stricter
MinVersion: tls.VersionTLS12,
VerifyPeerCertificate: func(rawCerts [][]byte, _ [][]*x509.Certificate) error {
if len(rawCerts) == 0 {
return errors.New("the bus presented no certificate")
}
sum := sha256.Sum256(rawCerts[0])
if got := "sha256:" + hex.EncodeToString(sum[:]); got != want {
return fmt.Errorf("the bus presented a certificate this mesh does not know (%s)", got)
}
return nil
},
}
return busconn.Connect(server, c, "mesh-nats-snapshot")
}
+41
View File
@@ -0,0 +1,41 @@
package main
import (
"context"
"errors"
"fmt"
stdio "git.novox.be/novox/mesh-sdk/go"
"github.com/nats-io/nats.go/jetstream"
"nats-tools/internal/busconn"
)
// LiveBus is the bus as the module's own credential reaches it. Nothing connects until a tool that changes the
// bus is called, so the tools that read (through the monitoring endpoint) answer as they did with no credential.
// NATS_TOOLS_CREDENTIAL names the credential (a word of the bundle's own: the runtime strips MESH_BROKER_FILE) (the process nats-tools's own secret `broker-nats-tools`); MESH_NATS_SERVER where the bus is as
// seen from this machine (default: its own port on loopback).
func LiveBus() Bus {
return func(ctx context.Context) (jetstream.JetStream, func(), error) {
file := env("NATS_TOOLS_CREDENTIAL", "")
if file == "" {
return nil, nil, errors.New("NATS_TOOLS_CREDENTIAL is not set, so this tool has no credential to reach the bus with (the module's manifest sets it)")
}
c, err := busconn.Read(file)
if err != nil {
return nil, nil, fmt.Errorf("the credential at %s (NATS_TOOLS_CREDENTIAL) cannot be read: %w", file, err)
}
nc, err := busconn.Connect(env("MESH_NATS_SERVER", "tls://127.0.0.1:4222"), c, "nats-tools")
if err != nil {
return nil, nil, err
}
js, err := jetstream.New(nc)
if err != nil {
nc.Close()
return nil, nil, fmt.Errorf("JetStream on the bus: %w", err)
}
return js, nc.Close, nil
}
}
// SaidByTheRuntime is the audit line: an event of this module, published through the runtime that launched it.
func SaidByTheRuntime(event string, body any) error { return stdio.Emit(event, body) }
+4 -3
View File
@@ -1,6 +1,7 @@
// nats-tools: the mesh bus's own tools (novox/hq design 25), a Go bundle the node's runtime launches on the
// machine that runs the bus. Everything here reads — the server's monitoring API and the user list the
// controller composed — and nothing changes the bus. stdout is the MCP channel; this says nothing else.
// machine that runs the bus. Almost everything here reads — the server's monitoring API and the user list the
// controller composed. The two that change the bus (removal.go) are the only ones that connect to it, on the
// module's own credential, and say what they did. stdout is the MCP channel; this says nothing else.
package main
import (
@@ -151,7 +152,7 @@ func tools(monitor Monitor, users func(context.Context) ([]byte, error), written
}
func main() {
if err := stdio.Serve("", tools(LiveMonitor(), ReadUsers, UsersWritten)); err != nil {
if err := stdio.Serve("", append(tools(LiveMonitor(), ReadUsers, UsersWritten), removalTools(LiveBus(), SaidByTheRuntime)...)); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
+25 -1
View File
@@ -3,6 +3,7 @@ package main
import (
"encoding/json"
"os"
"slices"
"strings"
"testing"
)
@@ -24,11 +25,14 @@ func TestTheBussManifestMountsItsProgramsIntoTheUpstreamImage(t *testing.T) {
} `json:"backup"`
} `json:"own"`
} `json:"data"`
Tools []string `json:"tools"`
Emits []string `json:"emits"`
Resources []map[string]any `json:"resources"`
Build struct {
On any `json:"on"`
Artifacts []struct {
Name, Kind, From, Language, System, Binary string
Env map[string]string
} `json:"artifacts"`
} `json:"build"`
}
@@ -38,7 +42,10 @@ func TestTheBussManifestMountsItsProgramsIntoTheUpstreamImage(t *testing.T) {
if m.Build.On != nil {
t.Error("build.on is declared: nothing is built on a base image any more")
}
artifacts := map[string]struct{ Name, Kind, From, Language, System, Binary string }{}
artifacts := map[string]struct {
Name, Kind, From, Language, System, Binary string
Env map[string]string
}{}
for _, a := range m.Build.Artifacts {
artifacts[a.Name] = a
if a.Kind == "image" {
@@ -103,6 +110,23 @@ func TestTheBussManifestMountsItsProgramsIntoTheUpstreamImage(t *testing.T) {
t.Errorf("%s is not in the container's restart-on: a new program would leave the old one running", p.resource)
}
}
// The tools that change the bus (issue 1952 N1-N3): listed, their audit events declared, and the credential
// they dial with the module's own secret `broker`.
for _, want := range []string{"nats_consumer_remove", "nats_bucket_remove"} {
if !slices.Contains(m.Tools, want) {
t.Errorf("tools do not list %s", want)
}
}
for _, want := range []string{"consumer-removed", "bucket-keys-removed"} {
if !slices.Contains(m.Emits, want) {
t.Errorf("emits do not list %s: the removal tools say it as the audit line", want)
}
}
for _, a := range m.Build.Artifacts {
if a.Name == "tools" && a.Env["NATS_TOOLS_CREDENTIAL"] != "${dir:tools-secret}/broker-nats-tools" {
t.Errorf("the tools' credential is %q: the process nats-tools' own secret broker-nats-tools, under a word of the bundle's own (the runtime strips MESH_BROKER_FILE)", a.Env["NATS_TOOLS_CREDENTIAL"])
}
}
args, _ := server["args"].([]any)
if len(args) != 2 || args[0] != "sh" || args[1] != "/opt/mesh-entrypoint/entrypoint.sh" {
t.Errorf("args are %v: the entrypoint run through sh, so no execute bit is relied on", args)
+246
View File
@@ -0,0 +1,246 @@
package main
// The two tools of this module that change the bus (novox/hq ADR 0564 clause 5, issue 1952 notes 6, 7 and 9):
// remove one named consumer, and remove one node's keys under a prefix of a key-value bucket. Everything else
// here reads.
//
// **Narrow by what the tool needs, and narrower by what the bus grants.** The tools reach the bus on the
// credential the module's manifest names (NATS_TOOLS_CREDENTIAL); the bus refuses whatever that credential is not
// granted, so a wrong stream, consumer or bucket is refused by the server and said here, not trusted to the
// arguments. Each tool refuses without `why`, reads what it would remove first (and says it, so a person can ask
// with dry_run and then for real), removes, reads that it is gone, and says what it did as an event of the module
// (the audit trail: who asked is in the call the controller audits, why and what are in the event).
import (
"context"
"errors"
"fmt"
"regexp"
"sort"
"strings"
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
"github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream"
)
// Bus opens JetStream on the bus as the module's credential for one call; close ends the connection.
type Bus func(ctx context.Context) (js jetstream.JetStream, close func(), err error)
// Said keeps the audit line: an event of this module.
type Said func(event string, body any) error
const (
consumerRemoved = "consumer-removed"
bucketKeysRemoved = "bucket-keys-removed"
// maxKeysShown bounds the keys an answer lists; the count is always whole.
maxKeysShown = 200
)
var (
// A stream, consumer or bucket is one subject token: no dot, no wildcard, no space.
name = regexp.MustCompile(`^[A-Za-z0-9_-]+$`)
// A key prefix is subject tokens and their dots; no wildcard, so it names keys and not a pattern.
keyPrefix = regexp.MustCompile(`^[A-Za-z0-9_./=-]+$`)
)
func why(a map[string]any) (string, error) {
w := strings.TrimSpace(arg(a, "why"))
if w == "" {
return "", errors.New("why is required: say why this is removed, for the audit trail (novox/hq ADR 0564 clause 5); nothing was removed")
}
return w, nil
}
// answerWithin is how long the bus is given to answer one request. **The bus does not refuse a request its
// credential is not granted: it drops it** (a permissions violation is told to the connection's error handler,
// never answered), so an unanswered request is how a refusal shows here.
var answerWithin = 10 * time.Second
// unanswered says what a request with no answer most likely is.
func unanswered(err error, what string) error {
if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, nats.ErrTimeout) {
return fmt.Errorf("%s: the bus did not answer within %s; it drops, without an answer, what the module's credential is not granted, "+
"so this is most likely outside its grant (or the bus is not reachable): %w", what, answerWithin, err)
}
return fmt.Errorf("%s: %w", what, err)
}
func step(ctx context.Context) (context.Context, context.CancelFunc) {
return context.WithTimeout(ctx, answerWithin)
}
// ConsumerRemoved is what nats_consumer_remove answers and says.
type ConsumerRemoved struct {
Stream string `json:"stream"`
Consumer string `json:"consumer"`
Why string `json:"why"`
Was map[string]any `json:"was"`
Removed bool `json:"removed"`
At time.Time `json:"at"`
}
// RemoveConsumer removes one named consumer of one stream, after reading it, and says so.
func RemoveConsumer(ctx context.Context, bus Bus, said Said, a map[string]any) (any, error) {
w, err := why(a)
if err != nil {
return nil, err
}
stream, consumer := arg(a, "stream"), arg(a, "consumer")
if !name.MatchString(stream) || !name.MatchString(consumer) {
return nil, errors.New("stream and consumer are required, each one name (letters, digits, - and _): nothing was removed")
}
js, closeBus, err := bus(ctx)
if err != nil {
return nil, err
}
defer closeBus()
sctx, cancel := step(ctx)
c, err := js.Consumer(sctx, stream, consumer)
cancel()
if err != nil {
if errors.Is(err, jetstream.ErrConsumerNotFound) || errors.Is(err, jetstream.ErrStreamNotFound) {
return nil, fmt.Errorf("%s has no consumer %s (%v): nothing was removed", stream, consumer, err)
}
return nil, unanswered(err, fmt.Sprintf("the consumer %s of %s could not be read, so it was not removed", consumer, stream))
}
info := c.CachedInfo()
was := map[string]any{
"durable": info.Config.Durable, "filter_subject": info.Config.FilterSubject, "filter_subjects": info.Config.FilterSubjects,
"deliver_subject": info.Config.DeliverSubject, "pending": info.NumPending, "ack_pending": info.NumAckPending,
"redelivered": info.NumRedelivered, "created": info.Created,
}
sctx, cancel = step(ctx)
err = js.DeleteConsumer(sctx, stream, consumer)
cancel()
if err != nil {
return nil, unanswered(err, fmt.Sprintf("consumer %s of %s was not removed", consumer, stream))
}
sctx, cancel = step(ctx)
_, err = js.Consumer(sctx, stream, consumer)
cancel()
if err == nil {
return nil, fmt.Errorf("consumer %s of %s is still there after the removal was accepted", consumer, stream)
}
out := ConsumerRemoved{Stream: stream, Consumer: consumer, Why: w, Was: was, Removed: true, At: time.Now().UTC()}
if err := said(consumerRemoved, out); err != nil {
return nil, fmt.Errorf("consumer %s of %s WAS removed (%s) but the audit event could not be said: %w", consumer, stream, w, err)
}
return out, nil
}
// KeysRemoved is what nats_bucket_remove answers and says.
type KeysRemoved struct {
Bucket string `json:"bucket"`
Prefix string `json:"prefix"`
Why string `json:"why"`
DryRun bool `json:"dry_run"`
Count int `json:"count"`
Keys []string `json:"keys"`
Cut bool `json:"keys_cut,omitempty"`
Removed int `json:"removed"`
At time.Time `json:"at"`
}
// RemoveKeys removes the keys of a key-value bucket under a prefix: counted first, and only those.
func RemoveKeys(ctx context.Context, bus Bus, said Said, a map[string]any) (any, error) {
w, err := why(a)
if err != nil {
return nil, err
}
bucket, node, prefix := arg(a, "bucket"), arg(a, "node"), arg(a, "prefix")
if !name.MatchString(bucket) {
return nil, errors.New("bucket is required, a bucket's name as nats_buckets lists it: nothing was removed")
}
if (node == "") == (prefix == "") {
return nil, errors.New("give exactly one of node (that node's keys: those starting `<node>.`) or prefix (keys starting so): " +
"a removal of a whole bucket is not this tool's; nothing was removed")
}
if node != "" {
if !name.MatchString(node) {
return nil, errors.New("node is one machine's name: nothing was removed")
}
prefix = node + "."
}
if !keyPrefix.MatchString(prefix) {
return nil, errors.New("prefix is key text without wildcards or spaces: nothing was removed")
}
dry, _ := a["dry_run"].(bool)
js, closeBus, err := bus(ctx)
if err != nil {
return nil, err
}
defer closeBus()
sctx, cancel := step(ctx)
kv, err := js.KeyValue(sctx, bucket)
cancel()
if err != nil {
return nil, unanswered(err, fmt.Sprintf("bucket %s could not be opened, nothing was removed", bucket))
}
sctx, cancel = step(ctx)
stream, err := js.Stream(sctx, "KV_"+bucket)
cancel()
if err != nil {
return nil, unanswered(err, fmt.Sprintf("bucket %s has no stream to read its keys from", bucket))
}
// The server narrows to the prefix's tokens when the prefix ends on a token; any other prefix is cut here.
filter := "$KV." + bucket + ".>"
if strings.HasSuffix(prefix, ".") {
filter = "$KV." + bucket + "." + prefix + ">"
}
sctx, cancel = step(ctx)
info, err := stream.Info(sctx, jetstream.WithSubjectFilter(filter))
cancel()
if err != nil {
return nil, unanswered(err, fmt.Sprintf("the keys of %s could not be read, so none were removed", bucket))
}
var keys []string
for subject := range info.State.Subjects {
if key := strings.TrimPrefix(subject, "$KV."+bucket+"."); strings.HasPrefix(key, prefix) {
keys = append(keys, key)
}
}
sort.Strings(keys)
out := KeysRemoved{Bucket: bucket, Prefix: prefix, Why: w, DryRun: dry, Count: len(keys), Keys: keys, At: time.Now().UTC()}
if len(keys) > maxKeysShown {
out.Keys, out.Cut = keys[:maxKeysShown], true
}
if want, ok := a["expect"].(float64); ok && int(want) != len(keys) {
return nil, fmt.Errorf("%d keys of %s start with %q, not the %d expected: nothing was removed", len(keys), bucket, prefix, int(want))
}
if dry || len(keys) == 0 {
return out, nil
}
for _, k := range keys {
sctx, cancel := step(ctx)
err := kv.Purge(sctx, k)
cancel()
if err != nil {
return nil, unanswered(err, fmt.Sprintf("removing %q from %s failed after %d of %d keys, the rest were left", k, bucket, out.Removed, len(keys)))
}
out.Removed++
}
if err := said(bucketKeysRemoved, out); err != nil {
return nil, fmt.Errorf("%d keys of %s under %q WERE removed (%s) but the audit event could not be said: %w", out.Removed, bucket, prefix, w, err)
}
return out, nil
}
// removalTools are the module's two tools that change the bus; every other tool here reads.
func removalTools(bus Bus, said Said) []stdio.Tool {
run := func(f func(context.Context, Bus, Said, map[string]any) (any, error)) func(map[string]any) (any, error) {
return func(a map[string]any) (any, error) { return f(context.Background(), bus, said, a) }
}
return []stdio.Tool{
{Name: "nats_consumer_remove",
Description: "Removes (deletes) one named JetStream consumer of one stream, e.g. the consumer `controller` of DEAD_LETTER_NOTICES at the lease write (novox/hq ADR 0564 clause 5). Needs `why`, kept in the audit trail; refuses an unknown stream or consumer; reads the consumer first and answers what it was (filters, pending, unacknowledged); the stream and its other consumers are not touched. The bus refuses any consumer the module's credential is not granted.",
Input: map[string]any{"stream": str("the stream, e.g. DEAD_LETTER_NOTICES"), "consumer": str("the consumer's name, e.g. controller"), "why": str("why it is removed, for the audit trail (required)")},
Run: run(RemoveConsumer)},
{Name: "nats_bucket_remove",
Description: "Removes (purges) keys from a key-value bucket: one node's keys (`node`, the keys starting `<node>.`) or the keys under a `prefix`, e.g. a full acts bucket of the node-engine. Needs `why`, kept in the audit trail; never the whole bucket. Counts the keys first and lists them (`dry_run` only counts; `expect` refuses when the count differs); removes only those keys, and their history. See the buckets and their keys with nats_buckets. The bus refuses any bucket the module's credential is not granted.",
Input: map[string]any{"bucket": str("the bucket as nats_buckets lists it, e.g. mesh-host_acts"), "node": str("a node whose keys (`<node>.`) go"), "prefix": str("key prefix instead of node"),
"why": str("why they are removed, for the audit trail (required)"), "dry_run": map[string]any{"type": "boolean", "description": "count and list the keys, remove none"}, "expect": num("the count of keys expected; refuses when it differs")},
Run: run(RemoveKeys)},
}
}
+173
View File
@@ -0,0 +1,173 @@
//go:build integration
package main
import (
"context"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"testing"
"time"
"github.com/nats-io/nats-server/v2/server"
"github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream"
)
// The two tools that change the bus, against a server shaped like the mesh's, on the credential the module's
// grant proposal gives them and nothing wider (issue 1952 N2, N3): the bus itself refuses what the grant leaves out,
// so this is also the proof that the proposed grant is enough and no wider than it needs to be.
func TestTheRemovalToolsChangeOnlyWhatTheyAreAskedAndTheGrantAllows(t *testing.T) {
dir := t.TempDir()
conf := filepath.Join(dir, "nats.conf")
if err := os.WriteFile(conf, []byte(fmt.Sprintf(`listen: "127.0.0.1:-1"
jetstream { store_dir: %q }
accounts { MESH { jetstream: enabled, users: [
{ user: "admin", password: "admin" },
{ user: "nats-tools", password: "tools", permissions: {
publish: { allow: [
"$JS.API.CONSUMER.INFO.DEAD_LETTER_NOTICES.controller",
"$JS.API.CONSUMER.DELETE.DEAD_LETTER_NOTICES.controller",
"$JS.API.STREAM.INFO.KV_mesh-host_acts",
"$KV.mesh-host_acts.>" ] },
subscribe: { allow: ["_INBOX.nats-tools.>"] } } }
] } }
`, filepath.Join(dir, "js"))), 0o600); err != nil {
t.Fatal(err)
}
opts, err := server.ProcessConfigFile(conf)
if err != nil {
t.Fatal(err)
}
opts.NoLog, opts.NoSigs = true, true
s, err := server.NewServer(opts)
if err != nil {
t.Fatal(err)
}
go s.Start()
if !s.ReadyForConnections(10 * time.Second) {
t.Fatal("the server did not start")
}
defer s.Shutdown()
answerWithin = 2 * time.Second
ctx := context.Background()
admin, err := nats.Connect(s.ClientURL(), nats.UserInfo("admin", "admin"))
if err != nil {
t.Fatal(err)
}
defer admin.Close()
ajs, _ := jetstream.New(admin)
if _, err := ajs.CreateStream(ctx, jetstream.StreamConfig{Name: "DEAD_LETTER_NOTICES", Subjects: []string{"dl.>"}, Retention: jetstream.WorkQueuePolicy}); err != nil {
t.Fatal(err)
}
for _, c := range []struct{ name, filter string }{{"controller", "dl.a"}, {"watchman", "dl.b"}} {
if _, err := ajs.CreateConsumer(ctx, "DEAD_LETTER_NOTICES", jetstream.ConsumerConfig{Durable: c.name, FilterSubject: c.filter, AckPolicy: jetstream.AckExplicitPolicy}); err != nil {
t.Fatal(err)
}
}
acts, err := ajs.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: "mesh-host_acts"})
if err != nil {
t.Fatal(err)
}
other, _ := ajs.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: "other_state"})
for _, k := range []string{"g14.1", "g14.2", "g14.sub.3", "g14x.4", "ace.5"} {
acts.Put(ctx, k, []byte("v"))
}
other.Put(ctx, "g14.1", []byte("v"))
bus := func(context.Context) (jetstream.JetStream, func(), error) {
nc, err := nats.Connect(s.ClientURL(), nats.UserInfo("nats-tools", "tools"), nats.CustomInboxPrefix("_INBOX.nats-tools"))
if err != nil {
return nil, nil, err
}
js, err := jetstream.New(nc)
return js, nc.Close, err
}
var said []string
say := func(event string, body any) error { said = append(said, event); return nil }
keys := func(kv jetstream.KeyValue) string {
ks, _ := kv.Keys(ctx)
sort.Strings(ks)
return strings.Join(ks, ",")
}
// N2: the consumer goes, the stream and its other consumer stay; refused without why and for an unknown one.
if _, err := RemoveConsumer(ctx, bus, say, map[string]any{"stream": "DEAD_LETTER_NOTICES", "consumer": "controller"}); err == nil || !strings.Contains(err.Error(), "why is required") {
t.Fatalf("without why: %v", err)
}
if _, err := ajs.Consumer(ctx, "DEAD_LETTER_NOTICES", "controller"); err != nil {
t.Fatal("refused without why, but the consumer is gone")
}
// An unknown consumer: the granted name that does not exist is answered "not found"; an ungranted one is dropped.
if _, err := RemoveConsumer(ctx, bus, say, map[string]any{"stream": "DEAD_LETTER_NOTICES", "consumer": "nobody", "why": "x"}); err == nil || !strings.Contains(err.Error(), "not grant") && !strings.Contains(err.Error(), "no consumer") {
t.Fatalf("an unknown consumer: %v", err)
}
if _, err := RemoveConsumer(ctx, bus, say, map[string]any{"stream": "DEAD_LETTER_NOTICES", "consumer": "watchman", "why": "x"}); err == nil {
t.Fatal("a consumer outside the grant was removed")
} else if !strings.Contains(err.Error(), "grant") {
t.Fatalf("a consumer outside the grant: the refusal does not say so: %v", err)
}
if _, err := ajs.Consumer(ctx, "DEAD_LETTER_NOTICES", "watchman"); err != nil {
t.Fatalf("the grant did not hold, the watchman's consumer: %v", err)
}
got, err := RemoveConsumer(ctx, bus, say, map[string]any{"stream": "DEAD_LETTER_NOTICES", "consumer": "controller", "why": "the lease write"})
if err != nil {
t.Fatal(err)
}
if r := got.(ConsumerRemoved); !r.Removed || r.Was["filter_subject"] != "dl.a" || r.Why != "the lease write" {
t.Fatalf("%+v", r)
}
if _, err := ajs.Consumer(ctx, "DEAD_LETTER_NOTICES", "controller"); err == nil {
t.Fatal("the consumer is still there")
}
if _, err := ajs.Consumer(ctx, "DEAD_LETTER_NOTICES", "watchman"); err != nil {
t.Fatalf("the other consumer went too: %v", err)
}
if _, err := RemoveConsumer(ctx, bus, say, map[string]any{"stream": "DEAD_LETTER_NOTICES", "consumer": "controller", "why": "again"}); err == nil || !strings.Contains(err.Error(), "no consumer") {
t.Fatalf("a consumer already gone: %v", err)
}
// N3: only the node's keys go; counted first; refused without why, for a whole bucket, and outside the grant.
if _, err := RemoveKeys(ctx, bus, say, map[string]any{"bucket": "mesh-host_acts", "node": "g14"}); err == nil || !strings.Contains(err.Error(), "why is required") {
t.Fatalf("without why: %v", err)
}
if _, err := RemoveKeys(ctx, bus, say, map[string]any{"bucket": "mesh-host_acts", "why": "x"}); err == nil {
t.Fatal("a removal naming neither node nor prefix was not refused")
}
if _, err := RemoveKeys(ctx, bus, say, map[string]any{"bucket": "other_state", "node": "g14", "why": "x"}); err == nil {
t.Fatal("a bucket outside the grant was opened")
}
if k := keys(acts); k != "ace.5,g14.1,g14.2,g14.sub.3,g14x.4" {
t.Fatalf("keys after the refusals: %s", k)
}
dry, err := RemoveKeys(ctx, bus, say, map[string]any{"bucket": "mesh-host_acts", "node": "g14", "why": "look", "dry_run": true})
if err != nil || dry.(KeysRemoved).Count != 3 || dry.(KeysRemoved).Removed != 0 {
t.Fatalf("dry run: %+v %v", dry, err)
}
if p, err := RemoveKeys(ctx, bus, say, map[string]any{"bucket": "mesh-host_acts", "prefix": "g14.s", "why": "look", "dry_run": true}); err != nil || p.(KeysRemoved).Count != 1 {
t.Fatalf("a prefix that ends inside a token: %+v %v", p, err)
}
if _, err := RemoveKeys(ctx, bus, say, map[string]any{"bucket": "mesh-host_acts", "node": "g14", "why": "x", "expect": 2.0}); err == nil {
t.Fatal("a count that differs from the expected was not refused")
}
got, err = RemoveKeys(ctx, bus, say, map[string]any{"bucket": "mesh-host_acts", "node": "g14", "why": "the acts bucket is full", "expect": 3.0})
if err != nil {
t.Fatal(err)
}
if r := got.(KeysRemoved); r.Removed != 3 || r.Count != 3 {
t.Fatalf("%+v", r)
}
if k := keys(acts); k != "ace.5,g14x.4" {
t.Fatalf("keys left: %s", k)
}
if k := keys(other); k != "g14.1" {
t.Fatalf("another bucket changed: %s", k)
}
if strings.Join(said, ",") != "consumer-removed,bucket-keys-removed" {
t.Fatalf("the audit events said: %v", said)
}
}
+47
View File
@@ -1,12 +1,15 @@
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"testing"
"time"
"github.com/nats-io/nats.go/jetstream"
)
const connz = `{"connections":[
@@ -208,3 +211,47 @@ func TestAGrantIsInForceOnlyWhenTheServerReloadedAfterTheListWasWritten(t *testi
t.Error("a write time that is not one was read")
}
}
// **The tools that change the bus are two, each refuses before it reaches the bus, and every other tool reads**
// (issue 1952 N1-N3). A row per refusal: the bus is never opened for them.
func TestTheRemovalToolsRefuseBeforeTheyReachTheBus(t *testing.T) {
bus := func(context.Context) (jetstream.JetStream, func(), error) {
t.Fatal("the bus was opened for a call that must be refused")
return nil, nil, nil
}
said := func(string, any) error { t.Fatal("an audit event was said for a refused call"); return nil }
for _, c := range []struct {
tool string
args map[string]any
want string
}{
{"nats_consumer_remove", map[string]any{"stream": "DEAD_LETTER_NOTICES", "consumer": "controller"}, "why is required"},
{"nats_consumer_remove", map[string]any{"stream": "DEAD_LETTER_NOTICES", "consumer": "controller", "why": " "}, "why is required"},
{"nats_consumer_remove", map[string]any{"stream": "A.B", "consumer": "c", "why": "x"}, "stream and consumer are required"},
{"nats_consumer_remove", map[string]any{"stream": "S", "consumer": ">", "why": "x"}, "stream and consumer are required"},
{"nats_bucket_remove", map[string]any{"bucket": "mesh-host_acts", "node": "g14"}, "why is required"},
{"nats_bucket_remove", map[string]any{"bucket": "mesh-host_acts", "why": "x"}, "exactly one of node"},
{"nats_bucket_remove", map[string]any{"bucket": "mesh-host_acts", "node": "g14", "prefix": "g", "why": "x"}, "exactly one of node"},
{"nats_bucket_remove", map[string]any{"bucket": "mesh-host_acts", "prefix": "g*", "why": "x"}, "without wildcards"},
{"nats_bucket_remove", map[string]any{"bucket": "", "node": "g14", "why": "x"}, "bucket is required"},
} {
var run func(map[string]any) (any, error)
for _, tl := range removalTools(bus, said) {
if tl.Name == c.tool {
run = tl.Run
}
}
if run == nil {
t.Fatalf("no tool %s", c.tool)
}
if _, err := run(c.args); err == nil || !strings.Contains(err.Error(), c.want) {
t.Errorf("%s %v: %v, want a refusal saying %q", c.tool, c.args, err, c.want)
}
}
// The read tools stay read tools: none of the first ten takes a `why`, and the list is these ten.
for _, tl := range tools(nil, nil, nil) {
if _, ok := tl.Input["why"]; ok || strings.Contains(tl.Name, "remove") {
t.Errorf("%s changes the bus; only the removal tools may", tl.Name)
}
}
}
+7 -7
View File
@@ -1,12 +1,12 @@
module nats-tools
go 1.25.0
go 1.26.0
require (
git.novox.be/novox/mesh-sdk/go v0.1.7
github.com/klauspost/compress v1.18.5
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261029214732-6b8c6943811a
github.com/klauspost/compress v1.20.0
github.com/nats-io/nats-server/v2 v2.11.17
github.com/nats-io/nats.go v1.51.0
github.com/nats-io/nats.go v1.54.0
)
require (
@@ -14,9 +14,9 @@ require (
github.com/google/go-tpm v0.9.8 // indirect
github.com/minio/highwayhash v1.0.4 // indirect
github.com/nats-io/jwt/v2 v2.8.1 // indirect
github.com/nats-io/nkeys v0.4.15 // indirect
github.com/nats-io/nkeys v0.4.16 // indirect
github.com/nats-io/nuid v1.0.1 // indirect
golang.org/x/crypto v0.50.0 // indirect
golang.org/x/sys v0.43.0 // indirect
golang.org/x/crypto v0.57.0 // indirect
golang.org/x/sys v0.48.0 // indirect
golang.org/x/time v0.15.0 // indirect
)
+12 -12
View File
@@ -1,27 +1,27 @@
git.novox.be/novox/mesh-sdk/go v0.1.7 h1:C0sTQmtTiyYH7bnqZb7PusXnqA37gKuT7Nqjn9gG47w=
git.novox.be/novox/mesh-sdk/go v0.1.7/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261029214732-6b8c6943811a h1:elC2IjB+qbeXG+pWItLz71DenegbrdsqbcH5kyGo56Q=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261029214732-6b8c6943811a/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
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/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA=
github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
github.com/minio/highwayhash v1.0.4 h1:asJizugGgchQod2ja9NJlGOWq4s7KsAWr5XUc9Clgl4=
github.com/minio/highwayhash v1.0.4/go.mod h1:GGYsuwP/fPD6Y9hMiXuapVvlIUEhFhMTh0rxU3ik1LQ=
github.com/nats-io/jwt/v2 v2.8.1 h1:V0xpGuD/N8Mi+fQNDynXohVvp7ZztevW5io8CUWlPmU=
github.com/nats-io/jwt/v2 v2.8.1/go.mod h1:nWnOEEiVMiKHQpnAy4eXlizVEtSfzacZ1Q43LIRavZg=
github.com/nats-io/nats-server/v2 v2.11.17 h1:GKEghcFK6A+aFx11Yf1LjgLC3txAwvyhnYzhBIQZA8I=
github.com/nats-io/nats-server/v2 v2.11.17/go.mod h1:B1sFVz4StNosQ903ak4N1G01Fl/9f8e06mXpFIE2K24=
github.com/nats-io/nats.go v1.51.0 h1:ByW84XTz6W03GSSsygsZcA+xgKK8vPGaa/FCAAEHnAI=
github.com/nats-io/nats.go v1.51.0/go.mod h1:26HypzazeOkyO3/mqd1zZd53STJN0EjCYF9Uy2ZOBno=
github.com/nats-io/nkeys v0.4.15 h1:JACV5jRVO9V856KOapQ7x+EY8Jo3qw1vJt/9Jpwzkk4=
github.com/nats-io/nkeys v0.4.15/go.mod h1:CpMchTXC9fxA5zrMo4KpySxNjiDVvr8ANOSZdiNfUrs=
github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA=
github.com/nats-io/nats.go v1.54.0/go.mod h1:y+DZoD1oBOYfZTU681eTUiUjI0vbqYGixNVFHcjHJ0k=
github.com/nats-io/nkeys v0.4.16 h1:rd5oAuLOb8mnAycB0xleuEBNS1pVVnN0fv/FF34Eypg=
github.com/nats-io/nkeys v0.4.16/go.mod h1:llLgWoI0o4z/Q57q2R1kHfmocyhGV6VG/U18Glg1Afs=
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
+106
View File
@@ -0,0 +1,106 @@
// Package busconn dials the bus as a credential the mesh sealed to a module: the one place the module's
// programs learn how (the snapshot, and the tools that change the bus).
package busconn
import (
"crypto/sha256"
"crypto/tls"
"crypto/x509"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"strings"
"time"
"github.com/nats-io/nats.go"
)
// Credential is what the mesh seals to a module so it can reach the bus (mesh-controller
// cmd/mesh-controller issueWith): where, the server's certificate by fingerprint, who, and the
// password. The address is not used here — this runs beside the server, and the server is reached on
// loopback — but the fingerprint is: loopback or not, the certificate is the mesh's own.
type Credential struct {
URL string `json:"url"`
Fingerprint string `json:"fingerprint,omitempty"`
User string `json:"user"`
Password string `json:"password"`
}
func Read(path string) (Credential, error) {
var raw []byte
var err error
if path == "-" {
raw, err = io.ReadAll(io.LimitReader(os.Stdin, 1<<20))
} else {
raw, err = os.ReadFile(path)
}
if err != nil {
return Credential{}, fmt.Errorf("cannot read the credential: %w", err)
}
var c Credential
if err := json.Unmarshal(raw, &c); err != nil {
return Credential{}, errors.New("the credential is not the JSON the mesh seals to a module (url, fingerprint, user, password)")
}
if c.User == "" || c.Password == "" {
return Credential{}, errors.New("the credential names no user or password, so the bus would refuse it")
}
return c, nil
}
// Connect dials the bus as the credential's user. A `tls://` server is held to the credential's
// fingerprint and nothing else — the mesh's bus presents a certificate of its own, in no trust store,
// so a name check could only fail or be skipped. A plain `nats://` server is accepted only on
// loopback, and is what a restore's own private server is.
func Connect(server string, c Credential, name string) (*nats.Conn, error) {
opts := []nats.Option{
nats.UserInfo(c.User, c.Password),
nats.Name(name),
// Answers come to the user's own inbox and no other: the bus grants every user its own
// `_INBOX.<user>.>` and nothing wider (novox/hq design 25 §4).
nats.CustomInboxPrefix("_INBOX." + c.User),
nats.Timeout(10 * time.Second),
// One attempt: a program that cannot reach the bus fails, loudly, rather than waiting for it.
nats.NoReconnect(),
}
switch {
case strings.HasPrefix(server, "tls://"):
if c.Fingerprint == "" {
return nil, errors.New("the credential carries no fingerprint, so the bus's certificate cannot be checked")
}
opts = append(opts, nats.Secure(PinnedTo(c.Fingerprint)))
case strings.HasPrefix(server, "nats://127.0.0.1:"), strings.HasPrefix(server, "nats://localhost:"):
if c.Fingerprint != "" {
// The mesh's bus requires TLS; a plain connection to it would be refused anyway.
opts = append(opts, nats.Secure(PinnedTo(c.Fingerprint)))
}
default:
return nil, fmt.Errorf("%s: the bus is reached over tls:// (or plain nats:// on loopback, for a restore's own server)", server)
}
nc, err := nats.Connect(server, opts...)
if err != nil {
return nil, fmt.Errorf("cannot reach the bus at %s as %s: %w", server, c.User, err)
}
return nc, nil
}
// PinnedTo accepts exactly the certificate with this SHA-256 — the same pin every machine holds
// (mesh-controller internal/broker PinnedToFingerprint).
func PinnedTo(want string) *tls.Config {
return &tls.Config{
InsecureSkipVerify: true, //nolint:gosec // replaced by the pin below, which is stricter
MinVersion: tls.VersionTLS12,
VerifyPeerCertificate: func(rawCerts [][]byte, _ [][]*x509.Certificate) error {
if len(rawCerts) == 0 {
return errors.New("the bus presented no certificate")
}
sum := sha256.Sum256(rawCerts[0])
if got := "sha256:" + hex.EncodeToString(sum[:]); got != want {
return fmt.Errorf("the bus presented a certificate this mesh does not know (%s)", got)
}
return nil
},
}
}
+28 -5
View File
@@ -8,7 +8,10 @@
"provides": [
{
"name": "mesh-bus",
"scope": "mesh"
"scope": "mesh",
"credential": {
"form": "none"
}
}
],
"claims": [
@@ -19,12 +22,16 @@
],
"bus-users": "/var/lib/nats-module/conf/accounts.conf",
"own-secrets": {
"broker": "${dir:mesh-state}/broker"
"broker": "${dir:mesh-state}/broker",
"broker-nats-tools": "${dir:tools-secret}/broker-nats-tools"
},
"capabilities": [
"container-runtime"
],
"emits": [],
"emits": [
"consumer-removed",
"bucket-keys-removed"
],
"consumes": [],
"listens": [
{
@@ -45,7 +52,9 @@
"nats_buckets",
"nats_users",
"nats_user_can",
"nats_snapshot_verify"
"nats_snapshot_verify",
"nats_consumer_remove",
"nats_bucket_remove"
],
"guards": [
8222
@@ -86,6 +95,13 @@
"mode": "0700",
"unknown": "program"
},
{
"id": "tools-secret",
"type": "directory",
"path": "/var/lib/mesh/nats-tools",
"mode": "0711",
"unknown": "program"
},
{
"id": "snapshots",
"type": "directory",
@@ -190,11 +206,18 @@
"loads": [
"nats-tools"
],
"bus": {
"removes": [
"DEAD_LETTER_NOTICES.controller",
"mesh-host_acts"
]
},
"env": {
"MESH_NATS_MONITOR": "http://127.0.0.1:8222",
"MESH_NATS_CONTAINER": "mesh-broker-nats",
"MESH_NATS_USERS_FILE": "/etc/nats/accounts.conf",
"MESH_NATS_SNAPSHOTS_DIR": "${dir:snapshots}"
"MESH_NATS_SNAPSHOTS_DIR": "${dir:snapshots}",
"NATS_TOOLS_CREDENTIAL": "${dir:tools-secret}/broker-nats-tools"
}
},
{
+17 -14
View File
@@ -13,7 +13,10 @@
{
"name": "nfs-share",
"scope": "mesh",
"identity": false
"identity": false,
"credential": {
"form": "none"
}
}
],
"data": {
@@ -51,20 +54,20 @@
"seat:mesh-controller.seats",
"seat:mesh-controller.data"
],
"config": {
"shares": {
"type": "string",
"description": "The folders this machine exports, as space-separated name=path; the operator's, with no default (novox/hq ADR 0263).",
"trusted": true,
"required": true
},
"grants": {
"type": "string",
"description": "Which nodes may mount which share, and how: share=node:rw|ro[,node:rw|ro], entries separated by spaces, or none to export to no node whatever a node wants (novox/hq ADR 0263).",
"trusted": true,
"default": "none"
}
"config": {
"shares": {
"type": "string",
"description": "The folders this machine exports, as space-separated name=path; the operator's, with no default (novox/hq ADR 0263).",
"trusted": true,
"required": true
},
"grants": {
"type": "string",
"description": "Which nodes may mount which share, and how: share=node:rw|ro[,node:rw|ro], entries separated by spaces, or none to export to no node whatever a node wants (novox/hq ADR 0263).",
"trusted": true,
"default": "none"
}
},
"facts": {
"machines": {
"path": "/etc/nfs-server/machines",
+4 -1
View File
@@ -7,7 +7,10 @@
"provides": [
{
"name": "node-toolchain",
"reach": "machine"
"reach": "machine",
"credential": {
"form": "none"
}
}
],
"resources": [
+4 -1
View File
@@ -7,7 +7,10 @@
"provides": [
{
"name": "model-access",
"scope": "node"
"scope": "node",
"credential": {
"form": "none"
}
}
],
"listens": [
+3
View File
@@ -7,6 +7,9 @@
"scope": "mesh",
"identity": {
"in": "a Redis ACL user and key prefix"
},
"credential": {
"form": "pair"
}
}
],
+4 -1
View File
@@ -9,7 +9,10 @@
"provides": [
{
"name": "greeting",
"scope": "mesh"
"scope": "mesh",
"credential": {
"form": "none"
}
}
],
"serves": {
+4 -1
View File
@@ -7,7 +7,10 @@
"provides": [
{
"name": "internal-acme-ca",
"scope": "mesh"
"scope": "mesh",
"credential": {
"form": "none"
}
}
],
"serves": {
+4 -1
View File
@@ -8,7 +8,10 @@
"provides": [
{
"name": "split-dns",
"reach": "machine"
"reach": "machine",
"credential": {
"form": "none"
}
}
],
"requires": [
+4 -1
View File
@@ -32,7 +32,10 @@
"provides": [
{
"name": "analytics",
"scope": "mesh"
"scope": "mesh",
"credential": {
"form": "none"
}
}
],
"serves": {
+4 -1
View File
@@ -9,7 +9,10 @@
"provides": [
{
"name": "x11-display",
"reach": "machine"
"reach": "machine",
"credential": {
"form": "none"
}
}
],
"claims": [