Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1747899419 | ||
|
|
85fa8511c4 | ||
|
|
79649c3fa9 | ||
|
|
c3544b6a17 | ||
|
|
136fa8b2a8 | ||
|
|
8f6ee2fd18 | ||
|
|
095d41fa3e | ||
|
|
7ac4493ce9 | ||
|
|
3db051be57 | ||
|
|
e0d1de0475 |
+9
-10
@@ -208,6 +208,9 @@ func (s AskSteps) level(ctx context.Context, job delivery.CheckJob, level string
|
||||
_ = json.Unmarshal(r, &t)
|
||||
return t.Passed, nil
|
||||
}
|
||||
// Every test step of the level runs, also after one that did not pass, so one check names every failing test of the
|
||||
// level and not only the first (issue 1921); the level as a whole passes only when each of its steps did.
|
||||
all := true
|
||||
if s.Digest == nil {
|
||||
// A module with no digest (Node) tests each module directory of the tree, those whose tests changed first.
|
||||
for _, priority := range []bool{true, false} {
|
||||
@@ -219,14 +222,12 @@ func (s AskSteps) level(ctx context.Context, job delivery.CheckJob, level string
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("the %s level of %s: %w", level, dir, err)
|
||||
}
|
||||
if !passed {
|
||||
return false, nil
|
||||
}
|
||||
all = all && passed
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
return all, nil
|
||||
}
|
||||
todo, all, keepRunner := s.plan(ctx, job, level, f, runner, withRunner, la)
|
||||
todo, whole, keepRunner := s.plan(ctx, job, level, f, runner, withRunner, la)
|
||||
if !keepRunner {
|
||||
runner = "" // an installed module that ignores the runner judges the level as it judges any other (ADR 0535 §2)
|
||||
}
|
||||
@@ -235,7 +236,7 @@ func (s AskSteps) level(ctx context.Context, job delivery.CheckJob, level string
|
||||
if job.Run != "" {
|
||||
args["run"] = job.Run
|
||||
}
|
||||
if all {
|
||||
if whole {
|
||||
if priority {
|
||||
continue // nothing to put first: the level runs whole after
|
||||
}
|
||||
@@ -255,11 +256,9 @@ func (s AskSteps) level(ctx context.Context, job delivery.CheckJob, level string
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("the %s level: %w", level, err)
|
||||
}
|
||||
if !passed {
|
||||
return false, nil
|
||||
}
|
||||
all = all && passed // the packages left still run (issue 1921)
|
||||
}
|
||||
return true, nil
|
||||
return all, nil
|
||||
}
|
||||
|
||||
// release removes an ask's workspace, saying a failure: the module's sweep removes it later.
|
||||
|
||||
@@ -109,9 +109,9 @@ func TestRunAskRefusesAnotherToolchain(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 1887/A3, A5a, issue 1887 design limit 1: a check ask runs the levels of one check after one fetch: it digests the unit
|
||||
// level, takes the kept verdicts, tests the packages whose tests changed first, and stops at the first failure, running
|
||||
// no later level.
|
||||
// 1887/A3, A5a: a check ask runs the levels of one check after one fetch: it digests the unit level, takes the kept
|
||||
// verdicts, tests the packages whose tests changed first, then the rest also after a failure (issue 1921), and runs no
|
||||
// level after one that did not pass (ADR 0484 clause 13).
|
||||
func TestRunAskChecksTheLevelsOfOneCheck(t *testing.T) {
|
||||
r := &recorder{
|
||||
fetch: map[string]any{"workspace": "/w", "changed": []string{"pkg/b/b_test.go"}},
|
||||
@@ -126,7 +126,8 @@ func TestRunAskChecksTheLevelsOfOneCheck(t *testing.T) {
|
||||
got := strings.Join(r.calls, "\n")
|
||||
want := `fetch {"base":"` + runCommit + `","commit":"` + runCommit + `","repository":"novox/x"}
|
||||
digest {"level":"unit","workspace":"/w"}
|
||||
test {"level":"unit","packages":["x/pkg/b"],"workspace":"/w"}`
|
||||
test {"level":"unit","packages":["x/pkg/b"],"workspace":"/w"}
|
||||
test {"level":"unit","packages":["x/pkg/a"],"workspace":"/w"}`
|
||||
if got != want {
|
||||
t.Fatalf("calls:\n%s\nwant:\n%s", got, want)
|
||||
}
|
||||
@@ -135,7 +136,7 @@ test {"level":"unit","packages":["x/pkg/b"],"workspace":"/w"}`
|
||||
t.Fatalf("levels %+v, want the unit level alone", a.Levels)
|
||||
}
|
||||
u := a.Levels[0]
|
||||
if u.Level != delivery.LevelUnit || len(u.Kept) != 1 || u.Kept[0].Package != "x/pkg/k" || u.Kept[0].Tests != 7 || len(u.Tests) != 1 || u.Digest == nil {
|
||||
if u.Level != delivery.LevelUnit || len(u.Kept) != 1 || u.Kept[0].Package != "x/pkg/k" || u.Kept[0].Tests != 7 || len(u.Tests) != 2 || u.Digest == nil {
|
||||
t.Fatalf("the unit level %+v", u)
|
||||
}
|
||||
}
|
||||
@@ -203,3 +204,42 @@ func TestRunAskChecksNodeModulesAndConflicts(t *testing.T) {
|
||||
t.Fatalf("a conflict: %v %v", err, r.calls)
|
||||
}
|
||||
}
|
||||
|
||||
// Issue 1921: a failure in the packages whose tests changed does not stop the level: the other packages still run, so two
|
||||
// failing packages, one changed and one not, are two test results of the one level, and the level does not pass. A Node
|
||||
// check runs every module directory after one fails too.
|
||||
func TestRunAskRunsEveryPackageAfterAFailure(t *testing.T) {
|
||||
r := &recorder{
|
||||
fetch: map[string]any{"workspace": "/w", "changed": []string{"pkg/b/b_test.go"}},
|
||||
digests: map[string]string{"x/pkg/a": "d-a", "x/pkg/b": "d-b"},
|
||||
tests: map[string]bool{"x/pkg/a": false, "x/pkg/b": false},
|
||||
}
|
||||
out, err := RunAsk(context.Background(), r.steps(), ask(delivery.CheckJob{Levels: []string{delivery.LevelIntegration, delivery.LevelUnit},
|
||||
Fetch: map[string]any{"repository": "novox/x", "commit": runCommit}}))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a := out.(delivery.CheckAnswer)
|
||||
if len(a.Levels) != 1 || len(a.Levels[0].Tests) != 2 {
|
||||
t.Fatalf("levels %+v, want the one level with both packages' results", a.Levels)
|
||||
}
|
||||
for i, raw := range a.Levels[0].Tests {
|
||||
var res struct{ Passed bool }
|
||||
if err := json.Unmarshal(raw, &res); err != nil || res.Passed {
|
||||
t.Errorf("result %d %s, want a failure", i, raw)
|
||||
}
|
||||
}
|
||||
|
||||
r = &recorder{fetch: map[string]any{"workspace": "/w", "modules": []string{"a", "b"}, "changed": []string{"b/test/x.test.ts"}},
|
||||
tests: map[string]bool{"a": true, "b": false}}
|
||||
s := r.steps()
|
||||
s.Digest = nil
|
||||
out, err = RunAsk(context.Background(), s, ask(delivery.CheckJob{Levels: []string{delivery.LevelUnit, delivery.LevelIntegration},
|
||||
Fetch: map[string]any{"repository": "novox/x", "commit": runCommit}}))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if a := out.(delivery.CheckAnswer); len(r.calls) != 3 || len(a.Levels) != 1 || len(a.Levels[0].Tests) != 2 {
|
||||
t.Fatalf("calls %v, levels %+v: want both directories of the unit level and no integration level", r.calls, a.Levels)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
|
||||
// Defends issue 1377 and novox/hq ADR 0547 §5: this is the controller's bus composer copied, and it composes byte for
|
||||
// byte what the controller's composes. testdata/controller-user-lists.json was written by the controller's own
|
||||
// internal/broker (mesh-controller branch fix/1945-mesh-grants-publishes-memberships), lists and memberships, from fixtures_test.go compiled there unchanged; composed.conf is the
|
||||
// internal/broker (mesh-controller branch fix/1945-mesh-grants-publishes-memberships, and fix/1790-grants-create-buckets for the grants part creating declared streams), lists and memberships, from fixtures_test.go compiled there unchanged; composed.conf is the
|
||||
// controller's golden of the whole server configuration. A row that differs is the copy drifting from the controller,
|
||||
// which is a fault while both compose (until the controller loses its lease, ADR 0547 §13).
|
||||
func TestTheCopiedComposerComposesAsTheControllers(t *testing.T) {
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
package buscompose
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
)
|
||||
|
||||
// The streams a manifest declares, as the server is asked to create them (novox/hq ADR 0547 §5, issue 1790): the
|
||||
// grants part creates each one the bus does not hold yet, with what the controller asserts at its start, so the two
|
||||
// make the same stream. Pure. Only a create is ever asked with these: a stream the bus holds is never updated by the
|
||||
// grants part (CreateStreamGrants grants no update).
|
||||
|
||||
// BucketStreamConfig is a module's bucket as the stream the controller's CreateOrUpdateKeyValue makes of its options
|
||||
// (mesh-controller internal/broker BucketConfig, with no marker age held): the client library's key-value shape
|
||||
// (rollup, no delete, direct reads, discard new, a duplicate window of two minutes or the age when shorter) with the
|
||||
// mesh's caps, file storage and one replica.
|
||||
func BucketStreamConfig(b Bucket) jetstream.StreamConfig {
|
||||
history := b.History
|
||||
if history < 1 {
|
||||
history = 1
|
||||
}
|
||||
age := time.Duration(b.TTLSeconds) * time.Second
|
||||
dupes := 2 * time.Minute
|
||||
if age > 0 && age < dupes {
|
||||
dupes = age
|
||||
}
|
||||
cfg := jetstream.StreamConfig{
|
||||
Name: "KV_" + b.Bucket(),
|
||||
Description: b.Why(),
|
||||
Subjects: []string{"$KV." + b.Bucket() + ".>"},
|
||||
MaxMsgsPerSubject: int64(history),
|
||||
MaxBytes: StateMaxBytes,
|
||||
MaxAge: age,
|
||||
MaxMsgSize: StateMaxValueBytes,
|
||||
Storage: jetstream.FileStorage,
|
||||
Replicas: 1,
|
||||
AllowRollup: true,
|
||||
DenyDelete: true,
|
||||
Duplicates: dupes,
|
||||
MaxMsgs: -1,
|
||||
MaxConsumers: -1,
|
||||
AllowDirect: true,
|
||||
Discard: jetstream.DiscardNew,
|
||||
}
|
||||
if b.KeyTTL {
|
||||
cfg.AllowMsgTTL, cfg.SubjectDeleteMarkerTTL = true, KeyTTLMarkerTTL
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
// ModuleStreamConfig is a module's own stream as the controller asserts it (mesh-controller internal/broker
|
||||
// StreamConfigFor of ModuleStream.Stream).
|
||||
func ModuleStreamConfig(m ModuleStream) jetstream.StreamConfig {
|
||||
s := m.Stream()
|
||||
cfg := jetstream.StreamConfig{
|
||||
Name: s.Name,
|
||||
Subjects: s.Subjects,
|
||||
Retention: jetstream.LimitsPolicy,
|
||||
MaxAge: time.Duration(s.MaxAge) * time.Second,
|
||||
MaxMsgsPerSubject: int64(s.MaxMsgsPerSubject),
|
||||
Description: s.Why,
|
||||
AllowDirect: s.Direct,
|
||||
AllowMsgTTL: s.AllowMsgTTL,
|
||||
}
|
||||
if s.Retention == RetentionWorkQueue {
|
||||
cfg.Retention = jetstream.WorkQueuePolicy
|
||||
}
|
||||
if s.Restricted || s.WorkQueue {
|
||||
cfg.Metadata = map[string]string{}
|
||||
if s.Restricted {
|
||||
cfg.Metadata[RestrictedMetadata] = "true"
|
||||
}
|
||||
if s.WorkQueue {
|
||||
cfg.Metadata[WorkQueueMetadata] = "true"
|
||||
}
|
||||
}
|
||||
if s.MaxBytes > 0 {
|
||||
cfg.MaxBytes = s.MaxBytes
|
||||
}
|
||||
if s.DiscardNew {
|
||||
cfg.Discard = jetstream.DiscardNew
|
||||
}
|
||||
if s.DuplicatesSeconds > 0 {
|
||||
cfg.Duplicates = time.Duration(s.DuplicatesSeconds) * time.Second
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package buscompose
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
)
|
||||
|
||||
// Defends issue 1790 (ADR 0547 §5): the grants part creates a declared bucket and stream as the controller asserts them.
|
||||
func TestDeclaredStreamsAreCreatedAsTheControllerAssertsThem(t *testing.T) {
|
||||
plain := BucketStreamConfig(Bucket{Module: "mesh-vault", Name: "bus-accounts"})
|
||||
keyed := BucketStreamConfig(Bucket{Module: "m", Name: "presence", History: 5, TTLSeconds: 60, KeyTTL: true})
|
||||
queue := ModuleStreamConfig(ModuleStream{Module: "b", Name: "ASKS", Events: []string{"ask"}, MaxAgeDays: 7, MaxMiB: 32,
|
||||
WorkQueue: true, Duplicates: 1})
|
||||
for _, row := range []struct {
|
||||
what string
|
||||
got, want any
|
||||
}{
|
||||
{"bucket name", plain.Name, "KV_mesh-vault_bus-accounts"},
|
||||
{"bucket subjects", plain.Subjects[0], "$KV.mesh-vault_bus-accounts.>"},
|
||||
{"bucket history defaults to one", plain.MaxMsgsPerSubject, int64(1)},
|
||||
{"bucket cap", plain.MaxBytes, int64(StateMaxBytes)},
|
||||
{"bucket value cap", plain.MaxMsgSize, int32(StateMaxValueBytes)},
|
||||
{"bucket duplicate window", plain.Duplicates, 2 * time.Minute},
|
||||
{"bucket discards new", plain.Discard, jetstream.DiscardNew},
|
||||
{"bucket direct and rollup, no delete", plain.AllowDirect && plain.AllowRollup && plain.DenyDelete, true},
|
||||
{"bucket without key ttl", plain.AllowMsgTTL, false},
|
||||
{"history", keyed.MaxMsgsPerSubject, int64(5)},
|
||||
{"age", keyed.MaxAge, time.Minute},
|
||||
{"duplicate window under the age", keyed.Duplicates, time.Minute},
|
||||
{"key ttl markers", keyed.SubjectDeleteMarkerTTL, KeyTTLMarkerTTL},
|
||||
{"key ttl", keyed.AllowMsgTTL, true},
|
||||
{"queue retention", queue.Retention, jetstream.WorkQueuePolicy},
|
||||
{"queue subjects", queue.Subjects[0], "mesh.stream.b.event.ask"},
|
||||
{"queue age", queue.MaxAge, 7 * 24 * time.Hour},
|
||||
{"queue cap", queue.MaxBytes, int64(32 << 20)},
|
||||
{"queue discards new", queue.Discard, jetstream.DiscardNew},
|
||||
{"queue duplicates", queue.Duplicates, time.Hour},
|
||||
{"queue metadata", queue.Metadata[WorkQueueMetadata] + queue.Metadata[RestrictedMetadata], "truetrue"},
|
||||
} {
|
||||
if row.got != row.want {
|
||||
t.Errorf("%s: %v, want %v", row.what, row.got, row.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package buscompose
|
||||
|
||||
import "testing"
|
||||
|
||||
// Defends issue 1790 and novox/hq ADR 0547 §5 (the operator, 2026-10-26 08:54): the grants part may create the stream
|
||||
// of each bucket, log and stream a placed module's manifest declares, by its own name, and read it back; nothing
|
||||
// wider, nothing updated or deleted, and no other principal so. Held byte-equal in the controller's copy.
|
||||
func TestTheGrantsPartCreatesOnlyDeclaredStreams(t *testing.T) {
|
||||
records := Records{
|
||||
Nodes: []string{"novox"},
|
||||
Assigned: map[string][]Declared{"novox": {
|
||||
{Module: "mesh-delivery-builder",
|
||||
State: []Bucket{{Module: "mesh-delivery-builder", Name: "steps", History: 5}},
|
||||
Logs: []Log{{Module: "mesh-delivery-builder", Name: "builds"}},
|
||||
Streams: []ModuleStream{{Module: "mesh-delivery-builder", Name: "BUILDASKS", Events: []string{"ask"}}}},
|
||||
{Module: GrantsModule, State: []Bucket{{Module: GrantsModule, Name: "assertions"}}},
|
||||
}},
|
||||
}
|
||||
users, err := Users(records)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
perms := map[string]Permissions{}
|
||||
for _, u := range users {
|
||||
if u.Kind != KindModule {
|
||||
continue
|
||||
}
|
||||
p, err := PermissionsFor(u)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", u.Username(), err)
|
||||
}
|
||||
perms[u.Module] = p
|
||||
}
|
||||
for _, row := range []struct {
|
||||
who, subject string
|
||||
may bool
|
||||
}{
|
||||
{GrantsModule, "$JS.API.STREAM.CREATE.KV_mesh-delivery-builder_steps", true},
|
||||
{GrantsModule, "$JS.API.STREAM.INFO.KV_mesh-delivery-builder_steps", true},
|
||||
{GrantsModule, "$JS.API.STREAM.CREATE.LOG_mesh-delivery-builder_builds", true},
|
||||
{GrantsModule, "$JS.API.STREAM.CREATE.BUILDASKS", true},
|
||||
{GrantsModule, "$JS.API.STREAM.CREATE.KV_mesh-grants_assertions", true},
|
||||
{GrantsModule, "$JS.API.STREAM.UPDATE.KV_mesh-delivery-builder_steps", false},
|
||||
{GrantsModule, "$JS.API.STREAM.DELETE.KV_mesh-delivery-builder_steps", false},
|
||||
{GrantsModule, "$JS.API.STREAM.PURGE.KV_mesh-delivery-builder_steps", false},
|
||||
{GrantsModule, "$JS.API.STREAM.CREATE.KV_mesh-delivery-builder_undeclared", false},
|
||||
{GrantsModule, "$JS.API.STREAM.CREATE.EVENTS", false},
|
||||
{GrantsModule, "$JS.API.STREAM.CREATE.ASSIGNMENTS", false},
|
||||
{GrantsModule, "$JS.API.STREAM.INFO.EVENTS", false},
|
||||
{"mesh-delivery-builder", "$JS.API.STREAM.CREATE.KV_mesh-delivery-builder_steps", false},
|
||||
} {
|
||||
if got := MayPublish(perms[row.who], row.subject); got != row.may {
|
||||
t.Errorf("%s may publish %s: %v, want %v", row.who, row.subject, got, row.may)
|
||||
}
|
||||
}
|
||||
// A name that is no plain token is never granted, and a module other than the grants part is never granted one.
|
||||
if got := CreateStreamGrants([]string{"*", ">", "KV_a.b", "KV_a_b"}); len(got) != 2 {
|
||||
t.Errorf("grants for wildcard names: %v", got)
|
||||
}
|
||||
other, err := PermissionsFor(Principal{Kind: KindModule, Node: "novox", Module: "shop", CreatesStreams: []string{"KV_shop_x"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if MayPublish(other, "$JS.API.STREAM.CREATE.KV_shop_x") {
|
||||
t.Error("a module other than the grants part was granted a create")
|
||||
}
|
||||
// The writers table refuses the grants part an update, and a create of a stream it was not given.
|
||||
g := Principal{Kind: KindModule, Node: "novox", Module: GrantsModule, CreatesStreams: []string{"KV_a_b"}}
|
||||
if err := CheckWriters(g, []string{"$JS.API.STREAM.CREATE.KV_a_b"}); err != nil {
|
||||
t.Errorf("the declared create refused: %v", err)
|
||||
}
|
||||
for _, refused := range []string{"$JS.API.STREAM.UPDATE.KV_a_b", "$JS.API.STREAM.CREATE.KV_a_c", "$JS.API.STREAM.CREATE.*"} {
|
||||
if CheckWriters(g, []string{refused}) == nil {
|
||||
t.Errorf("%s was not refused", refused)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
// streams, state and logs it composes them from.
|
||||
//
|
||||
// **A copy of the controller's internal/broker, composer half** (issue 1377, novox/hq ADR 0547 §5), taken at
|
||||
// mesh-controller 7cd4d18b with the grants part's membership grant of f67d1553 (ADR 0547 §5): the bus client (jetstream.go), the broker's address from the environment (broker.go, onnats.go
|
||||
// mesh-controller 7cd4d18b with the grants part's membership grant of f67d1553 (ADR 0547 §5) and its grant to create the streams manifests declare (issue 1790): the bus client (jetstream.go), the broker's address from the environment (broker.go, onnats.go
|
||||
// but its one constant) and the two methods on that client are left out; the lease's two subjects are copied in
|
||||
// (nats.go). The controller keeps its own copy, unchanged, until it loses its lease at ADR 0547's third milestone, so
|
||||
// for that time two composers state who may say what and must say the same: TestTheCopiedComposerComposesAsTheControllers
|
||||
|
||||
@@ -62,6 +62,9 @@ func UserListFixtures() []UserListFixture {
|
||||
},
|
||||
{Module: "mesh-delivery-view", Reads: []string{"mesh-delivery-builder.steps"},
|
||||
KeyedReads: []KeyedRead{{Read: "mesh-delivery-builder.presence", Key: "novox"}}},
|
||||
// The grants part, which may create every stream declared here (ADR 0547 §5, issue 1790).
|
||||
{Module: "mesh-grants", Emits: []string{"membership-changed"},
|
||||
State: []Bucket{{Module: "mesh-grants", Name: "assertions", History: 5}}},
|
||||
},
|
||||
"ace": {
|
||||
{Module: "plex", Serves: []string{"plex_status"}, Checks: []string{"plex.plex_status"}},
|
||||
|
||||
@@ -216,6 +216,11 @@ type Principal struct {
|
||||
// BoundBuckets are the providers' buckets of entries, `<module>_bound`, a provisioner client reads its own
|
||||
// machine's keys of (mesh-sdk go/provider, issue 679): every bucket named bound of a module the mesh places.
|
||||
BoundBuckets []string
|
||||
// CreatesStreams are the streams the grants part's principal alone may create (novox/hq ADR 0547 §5; the operator,
|
||||
// 2026-10-26 08:54): the stream of every bucket, log and stream a manifest of a module placed anywhere declares,
|
||||
// by name. Created only, never updated or deleted: a new module state is on the bus when the grants part hears its
|
||||
// manifest, not at the controller's next start (issue 1790).
|
||||
CreatesStreams []string
|
||||
|
||||
// PasswordHash is the bcrypt hash the mesh minted. The plaintext is sealed to the principal
|
||||
// and never appears here: this file is written to a node's disk and read by a server, and a
|
||||
@@ -919,6 +924,9 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
||||
// first so it publishes only what changed, and no stream it defines.
|
||||
if p.Module == GrantsModule {
|
||||
pub = append(pub, "mesh.assignment.*.*", "$JS.API.DIRECT.GET."+AssignmentsStream+".mesh.assignment.*.*")
|
||||
// And creating each stream a manifest declares, by its own name (the operator, 2026-10-26 08:54; issue
|
||||
// 1790): the create and the read of that one stream, never an update, a delete or a stream not declared.
|
||||
pub = append(pub, CreateStreamGrants(p.CreatesStreams)...)
|
||||
}
|
||||
// The view alone reads the build seats' workers for the backlog of asks (issue 1887 A8a).
|
||||
if p.Module == ViewModule {
|
||||
@@ -1586,6 +1594,20 @@ func ComposeAccounts(principals []Principal) (string, error) {
|
||||
// from them. No other principal is granted the subjects below, and nothing is a wildcard over them.
|
||||
const WatchmanModule = "mesh-delivery-watchman"
|
||||
|
||||
// CreateStreamGrants is what creating each named stream needs, and nothing wider (novox/hq ADR 0547 §5; the operator,
|
||||
// 2026-10-26 08:54): its create, and its information to read back what it holds. Each by its own name: a name that
|
||||
// is not one plain subject token is left out, since a wildcard there would be every stream.
|
||||
func CreateStreamGrants(streams []string) []string {
|
||||
var out []string
|
||||
for _, s := range streams {
|
||||
if !safeSubject.MatchString(s) {
|
||||
continue
|
||||
}
|
||||
out = append(out, "$JS.API.STREAM.CREATE."+s, "$JS.API.STREAM.INFO."+s)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// GrantsModule is the one module whose principal publishes the modules' memberships (novox/hq ADR 0547 §5): the grants
|
||||
// part, which composes them from the manifests and placement. It defines no stream and is granted no other subject here.
|
||||
const GrantsModule = "mesh-grants"
|
||||
|
||||
+23
-1
File diff suppressed because one or more lines are too long
@@ -207,6 +207,11 @@ func Users(r Records) ([]Principal, error) {
|
||||
PerMachine: perMachineNames(d.State), KeyedReads: d.KeyedReads,
|
||||
Logs: logNames(d.Logs), Streams: d.Streams, Hears: ComposedHears(d), Queues: d.Queues,
|
||||
})
|
||||
// **The grants part creates what a manifest declares** (novox/hq ADR 0547 §5; the operator, 2026-10-26
|
||||
// 08:54): every placed module's buckets, logs and streams, each by its stream's name.
|
||||
if d.Module == GrantsModule {
|
||||
out[len(out)-1].CreatesStreams = declaredStreamsOf(r)
|
||||
}
|
||||
// **The vault's holder asks the desks and the issuing providers itself** (issue 723), under the
|
||||
// account its own runtime runs on (ADR 0259 §8's runs-as): the one module granted it.
|
||||
if holdsSeat(d, VaultSeat) {
|
||||
@@ -418,3 +423,29 @@ func boundBucketsOf(r Records) []string {
|
||||
func runnerServes(d Declared) bool {
|
||||
return d.Module == RuntimeModule || d.NoAccount || d.Loads || len(d.Serves) > 0 || len(d.Holds) > 0 || len(d.Waits) > 0 || len(d.Checks) > 0
|
||||
}
|
||||
|
||||
// declaredStreamsOf is the stream of every bucket, log and stream a module placed anywhere declares, once each, in name
|
||||
// order (novox/hq ADR 0547 §5, issue 1790): `KV_<module>_<state>`, `LOG_<module>_<log>` and each module stream's own
|
||||
// name. What the grants part may create, and nothing beside.
|
||||
func declaredStreamsOf(r Records) []string {
|
||||
seen := map[string]bool{}
|
||||
for _, mods := range r.Assigned {
|
||||
for _, d := range mods {
|
||||
for _, b := range d.State {
|
||||
seen["KV_"+b.Bucket()] = true
|
||||
}
|
||||
for _, l := range d.Logs {
|
||||
seen[l.Stream()] = true
|
||||
}
|
||||
for _, s := range d.Streams {
|
||||
seen[s.Name] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
out := make([]string, 0, len(seen))
|
||||
for s := range seen {
|
||||
out = append(out, s)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -37,6 +37,17 @@ type WriterRow struct {
|
||||
// isController, and the other writers' tests, are who a row's writer is as a principal.
|
||||
func isController(p Principal, _ string) bool { return p.Kind == KindController }
|
||||
|
||||
// createsDeclared is the controller, or the grants part creating one stream a manifest declares, by its own name: the
|
||||
// create alone, never an update or a delete (novox/hq ADR 0547 §5; the operator, 2026-10-26 08:54).
|
||||
func createsDeclared(p Principal, pattern string) bool {
|
||||
if isController(p, pattern) {
|
||||
return true
|
||||
}
|
||||
name, ok := strings.CutPrefix(pattern, "$JS.API.STREAM.CREATE.")
|
||||
return ok && p.Kind == KindModule && p.Module == GrantsModule && slices.Contains(p.CreatesStreams, name) &&
|
||||
safeSubject.MatchString(name)
|
||||
}
|
||||
|
||||
// ownMachine is a node writing a subject whose third token is its own name, and no wildcard there.
|
||||
func ownMachine(p Principal, pattern string) bool {
|
||||
tokens := strings.Split(pattern, ".")
|
||||
@@ -170,7 +181,8 @@ var WritersTable = []WriterRow{
|
||||
// bucket's stream (ADR 0201), which defines nothing the mesh keeps.
|
||||
Subjects: []string{"$JS.API.STREAM.CREATE.>", "$JS.API.STREAM.UPDATE.>", "$JS.API.STREAM.DELETE.>",
|
||||
"$JS.API.CONSUMER.DURABLE.CREATE.>"},
|
||||
Writes: isController},
|
||||
Writes: createsDeclared,
|
||||
Shared: "the grants part creates a stream a manifest declares, by its name, and never updates or deletes one (novox/hq ADR 0547 §5; the operator, 2026-10-26 08:54)"},
|
||||
{State: "builds and their outcomes", Writer: "the build seat's holder", KeptIn: "its own state",
|
||||
Others: "the controller asks",
|
||||
Subjects: []string{"mesh.seat.node-build-agent.event.built", "mesh.seat.mesh-build-machine.event.built"},
|
||||
|
||||
+21
-6
@@ -139,16 +139,25 @@ type FailedFirst struct {
|
||||
Words string `json:"words,omitempty"`
|
||||
}
|
||||
|
||||
// MaxFailures is the most entries a verdict's Failures holds (issue 1921): fifty tests named one by one, and an entry
|
||||
// after them saying how many more failed.
|
||||
const MaxFailures = 50
|
||||
|
||||
// Verdict is the value of a verdict key: the group and its heads hash, the trunk head of each repository it was judged
|
||||
// against (repository to full sha), the State (running, pass or fail), what failed first, the tests run and kept per
|
||||
// level, each part's time, the machines composed and the mesh-sdk version the composition ran with (ADR 0446 clause 5).
|
||||
type Verdict struct {
|
||||
V int `json:"v"`
|
||||
Group string `json:"group"`
|
||||
Heads string `json:"heads"`
|
||||
Trunk map[string]string `json:"trunk,omitempty"`
|
||||
State string `json:"state"`
|
||||
Failed FailedFirst `json:"failed,omitzero"`
|
||||
V int `json:"v"`
|
||||
Group string `json:"group"`
|
||||
Heads string `json:"heads"`
|
||||
Trunk map[string]string `json:"trunk,omitempty"`
|
||||
State string `json:"state"`
|
||||
Failed FailedFirst `json:"failed,omitzero"`
|
||||
// Failures names every test that failed, in the order judged, each with the first line of its words (issue 1921):
|
||||
// Failed keeps the first one's words whole, and a check that ran every test says all it found here. At most
|
||||
// MaxFailures entries, the last of a longer list saying how many more failed. Absent in a value written before it,
|
||||
// and from a verdict whose failure is no test.
|
||||
Failures []FailedFirst `json:"failures,omitempty"`
|
||||
Levels map[string]LevelResult `json:"levels,omitempty"`
|
||||
Took Took `json:"took,omitzero"`
|
||||
Machines []string `json:"machines,omitempty"`
|
||||
@@ -222,6 +231,12 @@ func (v Verdict) check() error {
|
||||
}
|
||||
}
|
||||
named := v.Failed.Test != "" || v.Failed.Machine != ""
|
||||
if len(v.Failures) > 0 && v.State != VerdictFail {
|
||||
return fmt.Errorf("delivery: a %s verdict names no failures", v.State)
|
||||
}
|
||||
if len(v.Failures) > MaxFailures+1 {
|
||||
return fmt.Errorf("delivery: the verdict names %d failures, more than %d and the count of the rest", len(v.Failures), MaxFailures)
|
||||
}
|
||||
switch v.State {
|
||||
case VerdictRunning:
|
||||
if v.Failed != (FailedFirst{}) {
|
||||
|
||||
@@ -3,6 +3,7 @@ package delivery
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -600,3 +601,38 @@ func TestPreviewOfAHeadInConflict(t *testing.T) {
|
||||
t.Errorf("the old reader: %+v %v", old, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Issue 1921: a failed verdict names every failed test in Failures and reads back the same; an older reader's value
|
||||
// without it reads too; Failures on a verdict that is no fail, and more than MaxFailures and the count of the rest, are
|
||||
// refused.
|
||||
func TestVerdictNamesEveryFailure(t *testing.T) {
|
||||
v := passVerdict()
|
||||
v.State = VerdictFail
|
||||
v.Failed = FailedFirst{Test: "integration: x/a.TestA", Words: "a_test.go:9: want 1, got 2\nmore"}
|
||||
v.Failures = []FailedFirst{{Test: "integration: x/a.TestA", Words: "a_test.go:9: want 1, got 2"}, {Test: "integration: x/b.TestB", Words: "b_test.go:3: boom"}}
|
||||
data, err := EncodeVerdict(v)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := DecodeVerdict(data)
|
||||
v.V = Version
|
||||
if err != nil || !SameVerdict(got, v) || !strings.Contains(string(data), `"failures":[{"test":"integration: x/a.TestA"`) {
|
||||
t.Fatalf("%s: %+v %v", data, got, err)
|
||||
}
|
||||
old := strings.Replace(string(data), string(data[strings.Index(string(data), `,"failures":`):strings.Index(string(data), `,"levels":`)]), "", 1)
|
||||
if got, err := DecodeVerdict([]byte(old)); err != nil || len(got.Failures) != 0 || got.Failed != v.Failed {
|
||||
t.Fatalf("a value without failures: %+v %v (%s)", got, err, old)
|
||||
}
|
||||
p := passVerdict()
|
||||
p.Failures = v.Failures
|
||||
if _, err := EncodeVerdict(p); err == nil {
|
||||
t.Error("a pass with failures was written")
|
||||
}
|
||||
v.Failures = make([]FailedFirst, MaxFailures+2)
|
||||
for i := range v.Failures {
|
||||
v.Failures[i] = FailedFirst{Test: "unit: x.T" + strconv.Itoa(i)}
|
||||
}
|
||||
if _, err := EncodeVerdict(v); err == nil {
|
||||
t.Error("a verdict naming more than MaxFailures and the rest was written")
|
||||
}
|
||||
}
|
||||
|
||||
+10
-3
@@ -34,9 +34,12 @@
|
||||
// **The release 4 layouts** (issues 1356 to 1359; ADR 0453 clauses 4, 6 and 16): the new fingerprint form (fingerprint2.go),
|
||||
// the indexer's manifests by digest and the new fields of its modules and candidates (manifests.go, source.go), the
|
||||
// builder's build keys (builder.go) and the checker's verdicts and kept verdicts (checker.go). A manifests value is the
|
||||
// one exception to the size above: a manifest is up to about 21 KB, whole, byte for byte.
|
||||
// one exception to the size above: a manifest is up to about 21 KB, whole, byte for byte. The bus module's have on the
|
||||
// node holding mesh-broker is the other: its user list's handing carries a sealed box of every loaded user's hash, some
|
||||
// kilobytes (handed.go).
|
||||
//
|
||||
// Standard library only, and this module's own asks and declaration packages (a composition holds declaration resources).
|
||||
// Standard library only, and this module's own asks, declaration and seal packages (a composition holds declaration
|
||||
// resources; the adoption handing is a sealed box, handed.go).
|
||||
package delivery
|
||||
|
||||
import (
|
||||
@@ -352,6 +355,9 @@ type UserListWritten struct {
|
||||
Users int `json:"users"`
|
||||
At time.Time `json:"at"`
|
||||
Error string `json:"error,omitempty"`
|
||||
// Handed is the hashes of the users of the list on the machine, sealed to the vault, for its adoption of the
|
||||
// accounts it holds no value for (ADR 0547 clause 9, handed.go); nil while the vault's key is not known.
|
||||
Handed *AccountsHanded `json:"handed,omitempty"`
|
||||
}
|
||||
|
||||
// HaveNode is a node's own facts, written under HaveNodeKey. Each field is the node-engine's own JSON, carried whole:
|
||||
@@ -502,7 +508,8 @@ func SameHave(a, b Have) bool {
|
||||
}
|
||||
x, y := *ua, *ub
|
||||
x.At, y.At = time.Time{}, time.Time{}
|
||||
return x == y && ua.At.Equal(ub.At)
|
||||
x.Handed, y.Handed = nil, nil
|
||||
return x == y && ua.At.Equal(ub.At) && sameHanded(ua.Handed, ub.Handed)
|
||||
}
|
||||
|
||||
// SameHaveNode says whether two node facts are the same value, comparing each carried JSON without its spacing.
|
||||
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/seal"
|
||||
)
|
||||
|
||||
// 1131/AC1: the have key forms and parses back; bad names are refused, naming them.
|
||||
@@ -167,6 +169,47 @@ func TestRoundTrip(t *testing.T) {
|
||||
if !reflect.DeepEqual(out, in) || !out.UserList.At.Equal(at) {
|
||||
t.Fatalf("got %+v want %+v", out, in)
|
||||
}
|
||||
// And with the hashes handed to the vault (ADR 0547 clause 9): sealed to its key, opened by it alone, every
|
||||
// listed user with a bcrypt hash once, and refused to another key.
|
||||
hash := "$2a$11$" + strings.Repeat("a", 53)
|
||||
list := []byte("{ user: \"controller\", password: \"" + hash + "\", permissions: {\n" +
|
||||
"{ user: \"node.ace\", password: \"" + hash + "\", permissions: {\n" +
|
||||
"{ user: \"controller\", password: \"" + hash + "\" }\n{ user: \"nkey\", password: \"plain\" }\n")
|
||||
accounts := HandedAccountsOf(list)
|
||||
if len(accounts) != 2 || accounts[0].User != "controller" || accounts[1].User != "node.ace" || accounts[1].Hash != hash {
|
||||
t.Fatalf("handed accounts: %+v", accounts)
|
||||
}
|
||||
vault, err := seal.GenerateKeyPair()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
other, _ := seal.GenerateKeyPair()
|
||||
handed, err := HandAccounts(accounts, vault.PublicKey, "sha256:ab", at)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
in.UserList.Handed = &handed
|
||||
b, err = EncodeHave(in)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(b), "node.ace") || strings.Contains(string(b), hash) {
|
||||
t.Fatalf("a user or a hash is in the clear: %s", b)
|
||||
}
|
||||
out, err = DecodeHave(b)
|
||||
if err != nil || !SameHave(out, in) {
|
||||
t.Fatalf("handed round trip: %v %+v", err, out.UserList)
|
||||
}
|
||||
opened, err := OpenHanded(*out.UserList.Handed, vault.PublicKey, vault.PrivateKey)
|
||||
if err != nil || !reflect.DeepEqual(opened, accounts) {
|
||||
t.Fatalf("opened %+v, %v", opened, err)
|
||||
}
|
||||
if _, err := OpenHanded(*out.UserList.Handed, other.PublicKey, other.PrivateKey); err == nil {
|
||||
t.Fatal("a handing opened for another key")
|
||||
}
|
||||
if _, err := HandAccounts(accounts, "", "sha256:ab", at); err == nil {
|
||||
t.Fatal("handed with no vault key")
|
||||
}
|
||||
})
|
||||
t.Run("have node", func(t *testing.T) {
|
||||
in := HaveNode{Windows: json.RawMessage(`{"a":1}`), Busy: json.RawMessage(`true`)}
|
||||
@@ -306,6 +349,20 @@ func TestSame(t *testing.T) {
|
||||
if SameHave(ua, ub) {
|
||||
t.Error("different user lists same")
|
||||
}
|
||||
// A handing is compared by what it says: one said and one not differ, as do two to other keys.
|
||||
ub.UserList.Revision = 1
|
||||
ua.UserList.Handed = &AccountsHanded{From: "sha256:a", To: "sha256:k", Users: 2, At: at}
|
||||
if SameHave(ua, ub) {
|
||||
t.Error("a handing said and one not are the same")
|
||||
}
|
||||
ub.UserList.Handed = &AccountsHanded{From: "sha256:a", To: "sha256:k", Users: 2, At: at.In(time.Local)}
|
||||
if !SameHave(ua, ub) {
|
||||
t.Error("equal handings differ")
|
||||
}
|
||||
ub.UserList.Handed.To = "sha256:j"
|
||||
if SameHave(ua, ub) {
|
||||
t.Error("handings to other keys same")
|
||||
}
|
||||
n := HaveNode{V: 1, Busy: json.RawMessage(`true`)}
|
||||
if !SameHaveNode(n, HaveNode{V: 1, Busy: json.RawMessage(`true`)}) || SameHaveNode(n, HaveNode{V: 1}) {
|
||||
t.Error("have node")
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
package delivery
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/seal"
|
||||
)
|
||||
|
||||
// The adoption handing of novox/hq ADR 0547 clause 9 (issue 1945): the vault adopts, once and before `take-over`, the
|
||||
// bcrypt hashes of the bus accounts it holds no value for (the controller's own, the node accounts, the enrolled
|
||||
// devices) from the list the bus has loaded. The node-engine of the node holding mesh-broker is the one part that has
|
||||
// that list, so it hands the hashes in the bus module's have, beside what it says of the list it wrote: sealed to the
|
||||
// vault's own key, which it learns from the vault's answer to `node-key`, so that of every reader of the have bucket
|
||||
// the vault alone can open them. The have says in the clear only how many users it hands, from which list and to which
|
||||
// key; the users' names stay sealed, as the list itself is (ADR 0547 clause 7: it names every account).
|
||||
//
|
||||
// It hands every user of the loaded list; the vault takes from it only the users it holds no value for, and never
|
||||
// replaces a hash it made from a value it holds, so the node-engine never needs to read the vault's state (which the
|
||||
// grants part alone is to read, clause 9).
|
||||
|
||||
// AccountsHanded is the hashes of the loaded list's users, sealed to the vault, in UserListWritten.
|
||||
type AccountsHanded struct {
|
||||
// From is the digest of the list on the machine the hashes are taken from, `sha256:<hex>` (UserListWritten.Digest).
|
||||
From string `json:"from"`
|
||||
// To is the fingerprint of the vault's key the box is sealed to (KeyFingerprint).
|
||||
To string `json:"to"`
|
||||
// Users is how many users the box holds.
|
||||
Users int `json:"users"`
|
||||
// Box is the JSON of []HandedAccount, sealed to the vault's key (ADR 0206).
|
||||
Box seal.Box `json:"box"`
|
||||
At time.Time `json:"at"`
|
||||
}
|
||||
|
||||
// HandedAccount is one user of an AccountsHanded box, opened: the bus user and the bcrypt hash the loaded list
|
||||
// carries for it.
|
||||
type HandedAccount struct {
|
||||
User string `json:"user"`
|
||||
Hash string `json:"hash"`
|
||||
}
|
||||
|
||||
// KeyFingerprint is a public key's fingerprint as a handing names it: `sha256:<hex>` of its PEM, without the space
|
||||
// around it.
|
||||
func KeyFingerprint(publicPEM string) string {
|
||||
sum := sha256.Sum256([]byte(strings.TrimSpace(publicPEM)))
|
||||
return "sha256:" + hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// listedAccount is one user of a bus user list as the composer writes it (buscompose), with its password's hash.
|
||||
var listedAccount = regexp.MustCompile(`\{\s*user:\s*"([^"]+)",\s*password:\s*"([^"]+)"`)
|
||||
|
||||
// HandedAccountsOf is every user of a bus user list that carries a bcrypt hash, in the list's order. A user listed
|
||||
// twice is handed once, as the list first names it.
|
||||
func HandedAccountsOf(list []byte) []HandedAccount {
|
||||
var out []HandedAccount
|
||||
seen := map[string]bool{}
|
||||
for _, m := range listedAccount.FindAllSubmatch(list, -1) {
|
||||
user, hash := string(m[1]), string(m[2])
|
||||
if seen[user] || !IsBcrypt(hash) {
|
||||
continue
|
||||
}
|
||||
seen[user] = true
|
||||
out = append(out, HandedAccount{User: user, Hash: hash})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// bcryptHash is the form of a bcrypt hash: version, cost, and 53 characters of salt and digest.
|
||||
var bcryptHash = regexp.MustCompile(`^\$2[abxy]?\$[0-9]{2}\$[./A-Za-z0-9]{53}$`)
|
||||
|
||||
// IsBcrypt says whether s has the form of a bcrypt hash.
|
||||
func IsBcrypt(s string) bool { return bcryptHash.MatchString(s) }
|
||||
|
||||
// HandAccounts seals the accounts to the vault's public key (SPKI PEM) as the handing of the list whose digest is from.
|
||||
func HandAccounts(accounts []HandedAccount, vaultPEM, from string, at time.Time) (AccountsHanded, error) {
|
||||
if strings.TrimSpace(vaultPEM) == "" {
|
||||
return AccountsHanded{}, errors.New("delivery: no vault key to seal the handed accounts to")
|
||||
}
|
||||
body, err := json.Marshal(accounts)
|
||||
if err != nil {
|
||||
return AccountsHanded{}, err
|
||||
}
|
||||
box, err := seal.Seal(string(body), vaultPEM)
|
||||
if err != nil {
|
||||
return AccountsHanded{}, fmt.Errorf("delivery: the handed accounts could not be sealed to the vault's key: %w", err)
|
||||
}
|
||||
return AccountsHanded{From: from, To: KeyFingerprint(vaultPEM), Users: len(accounts), Box: box, At: at.UTC()}, nil
|
||||
}
|
||||
|
||||
// OpenHanded opens a handing with the vault's private key (PEM), refusing one sealed to another key than publicPEM's
|
||||
// and a box that does not hold as many users as it says.
|
||||
func OpenHanded(h AccountsHanded, publicPEM, privatePEM string) ([]HandedAccount, error) {
|
||||
if h.To != KeyFingerprint(publicPEM) {
|
||||
return nil, fmt.Errorf("delivery: the handing is sealed to the key %s, not to this one (%s)", h.To,
|
||||
KeyFingerprint(publicPEM))
|
||||
}
|
||||
plain, err := seal.Open(h.Box, privatePEM)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("delivery: the handing does not open: %w", err)
|
||||
}
|
||||
var out []HandedAccount
|
||||
if err := json.Unmarshal([]byte(plain), &out); err != nil {
|
||||
return nil, fmt.Errorf("delivery: the handing holds no accounts: %w", err)
|
||||
}
|
||||
if len(out) != h.Users {
|
||||
return nil, fmt.Errorf("delivery: the handing says %d users and holds %d", h.Users, len(out))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// sameHanded says whether two handings say the same: the same list, key, count and box, whatever zone their time is in.
|
||||
func sameHanded(a, b *AccountsHanded) bool {
|
||||
if a == nil || b == nil {
|
||||
return a == b
|
||||
}
|
||||
x, y := *a, *b
|
||||
x.At, y.At = time.Time{}, time.Time{}
|
||||
return x == y && a.At.Equal(b.At)
|
||||
}
|
||||
@@ -1,11 +1,16 @@
|
||||
package process
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
)
|
||||
|
||||
// 1171/AC2: given an account file broker-<process id> in the controller's form {url, fingerprint, node,
|
||||
@@ -46,6 +51,28 @@ func TestReadAccountToleratesNoClaimsAtAll(t *testing.T) {
|
||||
// 1171/AC2: a file missing url, user or password is refused, naming the field; the password never appears
|
||||
// in the error even when it is the field present and another is missing.
|
||||
func TestReadAccountRefusesAFileMissingAField(t *testing.T) {
|
||||
t.Run("1945: a refused bucket stops a start only when the part cannot work without it", func(t *testing.T) {
|
||||
refusal := errors.New("nats: permissions violation for publish to $JS.API.STREAM.INFO.KV_mesh-delivery-gate_source")
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
err error
|
||||
}{{"refused", refusal}, {"not answered", context.DeadlineExceeded}} {
|
||||
var said []string
|
||||
logf := func(f string, a ...any) { said = append(said, fmt.Sprintf(f, a...)) }
|
||||
if kv := OptionalBucket(context.Background(), refusingBus{c.err}, "b", "b is not heard", logf); kv != nil {
|
||||
t.Errorf("%s: an optional bucket answered %v", c.name, kv)
|
||||
}
|
||||
NotWatched(logf, "b", c.err, "b is not heard")
|
||||
if len(said) != 2 || !strings.Contains(said[0], c.err.Error()) || !strings.Contains(said[1], "cannot be watched") {
|
||||
t.Errorf("%s: said %q", c.name, said)
|
||||
}
|
||||
if _, err := EssentialBucket(context.Background(), refusingBus{c.err}, "builder", "b", "build"); err == nil ||
|
||||
!strings.Contains(err.Error(), "grant of b") || !strings.Contains(err.Error(), "the builder cannot build") {
|
||||
t.Errorf("%s: an essential bucket: %v", c.name, err)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
for _, tc := range []struct {
|
||||
field string
|
||||
values map[string]any
|
||||
@@ -82,3 +109,8 @@ func writeAccount(t *testing.T, fields map[string]any) string {
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
// refusingBus refuses every bucket with err.
|
||||
type refusingBus struct{ err error }
|
||||
|
||||
func (r refusingBus) KeyValue(context.Context, string) (jetstream.KeyValue, error) { return nil, r.err }
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
package process
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
)
|
||||
|
||||
// **A part does not stop for a bucket it can work without** (novox/hq issue 1945, met twice on 2026-10-25: the delivery
|
||||
// builder exited on a refused read of the gate's source and crash-looped). A grant reaches the bus with a delivery, so a
|
||||
// bucket, a watch or a consumer the bus refuses, or times out on, at start is often only not granted yet: exiting for it
|
||||
// stops the part until a person's hand. A part opens what it cannot do its job at all without with EssentialBucket, which
|
||||
// stops its start with an error naming the bucket and its grant, and everything else with OptionalBucket, which says what
|
||||
// is not heard and goes on; a watch the bus refuses on an optional bucket is said with NotWatched. Here, beside State,
|
||||
// because this is where a process opens what it keeps and reads on the bus; the parts that open buckets with their own
|
||||
// JetStream (the delivery parts) take the same three.
|
||||
|
||||
// BucketOpener is the part of the bus a start opens buckets with: a jetstream.JetStream, or a test's fake.
|
||||
type BucketOpener interface {
|
||||
KeyValue(ctx context.Context, bucket string) (jetstream.KeyValue, error)
|
||||
}
|
||||
|
||||
// EssentialBucket opens a bucket part cannot work without (it cannot `does` without it); its error is EssentialErr's.
|
||||
func EssentialBucket(ctx context.Context, js BucketOpener, part, name, does string) (jetstream.KeyValue, error) {
|
||||
kv, err := js.KeyValue(ctx, name)
|
||||
if err != nil {
|
||||
return nil, EssentialErr(part, name, does, err)
|
||||
}
|
||||
return kv, nil
|
||||
}
|
||||
|
||||
// EssentialErr is the error of a bucket, consumer or stream named name that part cannot work without: it says what part
|
||||
// cannot do, that it stops, and the grant to look at.
|
||||
func EssentialErr(part, name, does string, err error) error {
|
||||
return fmt.Errorf("%s cannot be read (%v): the %s cannot %s without it, so it stops; where the bus refused it or did not "+
|
||||
"answer, this module's grant of %s (its manifest's reads, state or consumes, issued with its credential) has not "+
|
||||
"reached the bus yet", name, err, part, does, name)
|
||||
}
|
||||
|
||||
// OptionalBucket opens a bucket the part works without: any error is said with what is not heard (without), and nil
|
||||
// answered.
|
||||
func OptionalBucket(ctx context.Context, js BucketOpener, name, without string, logf func(string, ...any)) jetstream.KeyValue {
|
||||
kv, err := js.KeyValue(ctx, name)
|
||||
if err != nil {
|
||||
logf("the bucket %s cannot be read (%v): %s", name, err, without)
|
||||
return nil
|
||||
}
|
||||
return kv
|
||||
}
|
||||
|
||||
// NotWatched says that an optional bucket opened but its watch was refused or not answered: what is not heard.
|
||||
func NotWatched(logf func(string, ...any), name string, err error, without string) {
|
||||
logf("the bucket %s cannot be watched (%v): %s", name, err, without)
|
||||
}
|
||||
Reference in New Issue
Block a user