diff --git a/contracts/go/seats/meshcatalog/contract.go b/contracts/go/seats/meshcatalog/contract.go index 34b65f16..f11367b9 100644 --- a/contracts/go/seats/meshcatalog/contract.go +++ b/contracts/go/seats/meshcatalog/contract.go @@ -950,6 +950,7 @@ type RegistrationAsked struct { const ( RegistrationAskedRefusedUnprotectedBranch = "unprotected branch" RegistrationAskedRefusedReservedBranchName = "reserved branch name" + RegistrationAskedRefusedPlaced = "placed" RegistrationAskedRefusedReviewRequired = "review required" RegistrationAskedRefusedUnknownModule = "unknown module" RegistrationAskedRefusedNothingMoves = "nothing moves" diff --git a/contracts/go/seats/meshcatalog/contract_test.go b/contracts/go/seats/meshcatalog/contract_test.go index 37c240b1..b3ad08e4 100644 --- a/contracts/go/seats/meshcatalog/contract_test.go +++ b/contracts/go/seats/meshcatalog/contract_test.go @@ -151,6 +151,8 @@ func TestFixtures(t *testing.T) { decode: func(b []byte) (any, error) { var v RegisterInput; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil}, {name: "register refused for an unprotected branch", subject: "", given: "{\"module\":\"nats\",\"refused\":\"unprotected branch\",\"words\":\"the branch beta of novox/mesh-catalog admits a direct push\"}", wire: "{\"module\":\"nats\",\"refused\":\"unprotected branch\",\"words\":\"the branch beta of novox/mesh-catalog admits a direct push\"}", decode: func(b []byte) (any, error) { var v RegisterAnswer; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil}, + {name: "unwatch refused for a branch a placement names", subject: "", given: "{\"module\":\"nats\",\"refused\":\"placed\",\"words\":\"the branch beta of nats is placed: n1 and n2 run it from that branch\"}", wire: "{\"module\":\"nats\",\"refused\":\"placed\",\"words\":\"the branch beta of nats is placed: n1 and n2 run it from that branch\"}", + decode: func(b []byte) (any, error) { var v UnwatchAnswer; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil}, {name: "register refused for a reserved branch name", subject: "", given: "{\"module\":\"nats\",\"refused\":\"reserved branch name\",\"words\":\"the branch candidate of novox/mesh-catalog is a name the catalog keeps for its own revisions\"}", wire: "{\"module\":\"nats\",\"refused\":\"reserved branch name\",\"words\":\"the branch candidate of novox/mesh-catalog is a name the catalog keeps for its own revisions\"}", decode: func(b []byte) (any, error) { var v RegisterAnswer; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil}, {name: "policy moves one artifact's unit step", subject: "", given: "{\"artifact\":\"program\",\"module\":\"nats\",\"steps\":{\"unit\":\"bypass\"},\"why\":\"its tests need hardware\"}", wire: "{\"artifact\":\"program\",\"module\":\"nats\",\"steps\":{\"unit\":\"bypass\"},\"why\":\"its tests need hardware\"}", diff --git a/contracts/seats/mesh-catalog/fixtures/verbs.json b/contracts/seats/mesh-catalog/fixtures/verbs.json index 3c0c7d5a..9b84c76a 100644 --- a/contracts/seats/mesh-catalog/fixtures/verbs.json +++ b/contracts/seats/mesh-catalog/fixtures/verbs.json @@ -296,6 +296,15 @@ "words": "the branch beta of novox/mesh-catalog admits a direct push" } }, + { + "name": "unwatch refused for a branch a placement names", + "of": "verb:unwatch.answer", + "given": { + "module": "nats", + "refused": "placed", + "words": "the branch beta of nats is placed: n1 and n2 run it from that branch" + } + }, { "name": "register refused for a reserved branch name", "of": "verb:register.answer", diff --git a/contracts/seats/mesh-catalog/seat.json b/contracts/seats/mesh-catalog/seat.json index 40fba93d..a6698c01 100644 --- a/contracts/seats/mesh-catalog/seat.json +++ b/contracts/seats/mesh-catalog/seat.json @@ -2433,6 +2433,7 @@ "enum": [ "unprotected branch", "reserved branch name", + "placed", "review required", "unknown module", "nothing moves" diff --git a/contracts/ts/seats/meshcatalog/index.d.ts b/contracts/ts/seats/meshcatalog/index.d.ts index dc83114e..e69c45d5 100644 --- a/contracts/ts/seats/meshcatalog/index.d.ts +++ b/contracts/ts/seats/meshcatalog/index.d.ts @@ -448,7 +448,7 @@ export interface RegistrationAsked { "lines"?: string[]; "module": string; /** why no ask was raised */ - "refused"?: "unprotected branch" | "reserved branch name" | "review required" | "unknown module" | "nothing moves"; + "refused"?: "unprotected branch" | "reserved branch name" | "placed" | "review required" | "unknown module" | "nothing moves"; /** the refusal in words, naming the branch, the step or the module */ "words"?: string; } diff --git a/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/registry.go b/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/registry.go index 5dbfe01a..8778e00b 100644 --- a/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/registry.go +++ b/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/registry.go @@ -98,6 +98,17 @@ func newRegistry(js jetstream.JetStream, conn *nats.Conn, cred process.Account, Say: func(_ context.Context, e meshcatalog.RegistrationChangedEvent) error { return publishAs(conn, cred, seatEventSubject(meshcatalog.EventRegistrationChanged), fmt.Sprintf("%s-%s-%d", meshcatalog.EventRegistrationChanged, e.Module, e.Version), e) }, + Placement: func(module string) ([]mma.ModulePlacement, error) { + raw, err := call("seat:"+mma.Seat+"."+mma.VerbPlacement, mma.PlacementInput{Module: module}) + if err != nil { + return nil, err + } + var a mma.PlacementAnswer + if err := json.Unmarshal(raw, &a); err != nil { + return nil, fmt.Errorf("the answer of %s.%s is not its seat's: %w", mma.Seat, mma.VerbPlacement, err) + } + return a.Placements, nil + }, Branch: func(repository, branch string) (gitseat.BranchAnswer, error) { var a gitseat.BranchAnswer raw, err := call("seat:"+gitseat.Seat+"."+gitseat.VerbBranch, gitseat.BranchInput{Repository: repository, Branch: branch}) diff --git a/modules/mesh-delivery-indexer/internal/index/registrations.go b/modules/mesh-delivery-indexer/internal/index/registrations.go index 3c0f3eae..64fc23c7 100644 --- a/modules/mesh-delivery-indexer/internal/index/registrations.go +++ b/modules/mesh-delivery-indexer/internal/index/registrations.go @@ -11,6 +11,7 @@ import ( "sync" "time" + mma "git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner" occ "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient" oc "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel" gitseat "git.novox.be/novox/mesh-delivery/contracts/go/seats/git" @@ -123,6 +124,9 @@ type Registry struct { Say func(ctx context.Context, e meshcatalog.RegistrationChangedEvent) error // Branch reads a branch through the seat git; an empty branch asks the repository's default branch. Branch func(repository, branch string) (gitseat.BranchAnswer, error) + // Placement reads a module's placements through the seat mesh-module-assigner's verb placement: one per watched branch a row + // of its assignments names, with the nodes that run the module from it (design 47 G §704, A-62). + Placement func(module string) ([]mma.ModulePlacement, error) // Ask publishes an ask under the indexer's own name, Cancel takes it back, Where reads the router's own record of an // ask, read under the indexer's module's name (state, and the warrant when it holds one; none when it holds no ask). Ask func(a oc.Ask) error @@ -417,6 +421,22 @@ func (r *Registry) Propose(ctx context.Context, verb, caller string, change json if verb != verbRegister && !found { return refused(module, meshcatalog.RegistrationAskedRefusedUnknownModule, module+" is not registered: register it first"), nil } + // An unwatch of a branch a placement names is refused, naming the placement (§704, A-62). + if verb == verbUnwatch { + if r.Placement == nil { + return meshcatalog.RegistrationAsked{}, errors.New("the placements of a module are not asked of the seat mesh-module-assigner, so no branch is unwatched") + } + placed, err := r.Placement(module) + if err != nil { + return meshcatalog.RegistrationAsked{}, fmt.Errorf("the placements of %s are not read: %w", module, err) + } + for _, p := range placed { + if p.Branch == branch { + return refused(module, meshcatalog.RegistrationAskedRefusedPlaced, + fmt.Sprintf("the branch %s of %s is placed: %s run it from that branch", branch, module, strings.Join(p.Nodes, ", "))), nil + } + } + } // register with no branch takes the repository's default branch (§342). if verb == verbRegister { var in meshcatalog.RegisterInput diff --git a/modules/mesh-delivery-indexer/internal/index/registrations_test.go b/modules/mesh-delivery-indexer/internal/index/registrations_test.go index 64a20d0a..40e931f0 100644 --- a/modules/mesh-delivery-indexer/internal/index/registrations_test.go +++ b/modules/mesh-delivery-indexer/internal/index/registrations_test.go @@ -8,6 +8,7 @@ import ( "testing" "time" + mma "git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner" occ "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient" oc "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel" gitseat "git.novox.be/novox/mesh-delivery/contracts/go/seats/git" @@ -26,6 +27,7 @@ type regRig struct { whereErr error chosen map[string]string // ask id -> option the router's record holds missing map[string]bool + placed map[string][]mma.ModulePlacement // module -> its placements, as the seat mesh-module-assigner's placement answers } func newRegRig() *regRig { @@ -45,8 +47,9 @@ func newRegRig() *regRig { } return gitseat.BranchAnswer{Branch: branch, Repository: repo, Protected: !g.unprotect[branch]}, nil }, - Ask: func(a oc.Ask) error { g.asks[a.ID] = a; return nil }, - Cancel: func(id string) error { g.cancelled = append(g.cancelled, id); return nil }, + Placement: func(module string) ([]mma.ModulePlacement, error) { return g.placed[module], nil }, + Ask: func(a oc.Ask) error { g.asks[a.ID] = a; return nil }, + Cancel: func(id string) error { g.cancelled = append(g.cancelled, id); return nil }, Where: func(id string) (string, *oc.Warrant, error) { if g.whereErr != nil { return "", nil, g.whereErr @@ -176,6 +179,13 @@ func TestRegistrationsAreWrittenOnTheirWarrantOnly(t *testing.T) { delete(g.unprotect, "beta") w, _ = g.r.Propose(ctx, "watch", "s@n", mustJSON(meshcatalog.WatchInput{Module: "a", Branch: "beta"})) g.decide(t, w.Ask, "approve") + // A branch a placement names is not unwatched: refused as placed, naming the placement, and no ask is raised (§704, A-62). + g.placed = map[string][]mma.ModulePlacement{"a": {{Branch: "beta", Nodes: []string{"n1", "n2"}}}} + if p, err := g.r.Propose(ctx, "unwatch", "s@n", mustJSON(meshcatalog.WatchInput{Module: "a", Branch: "beta"})); err != nil || p.Refused != meshcatalog.RegistrationAskedRefusedPlaced || + p.Ask != "" || !strings.Contains(p.Words, "beta") || !strings.Contains(p.Words, "n1, n2") { + t.Fatalf("unwatch of a placed branch: %+v, %v; want refused placed naming the placement", p, err) + } + g.placed = nil u, _ := g.r.Propose(ctx, "unwatch", "s@n", mustJSON(meshcatalog.WatchInput{Module: "a", Branch: "beta"})) g.decide(t, u.Ask, "approve") if now, _, _ := g.r.Registration(ctx, "a"); now.Version != 4 || len(now.Branches) != 1 { diff --git a/modules/mesh-delivery-indexer/manifest_test.go b/modules/mesh-delivery-indexer/manifest_test.go index ae628c6a..12d612f9 100644 --- a/modules/mesh-delivery-indexer/manifest_test.go +++ b/modules/mesh-delivery-indexer/manifest_test.go @@ -122,7 +122,7 @@ func TestManifestDeclarations(t *testing.T) { // 1238/AC1, 1444 (reads the build } // Design 47 G §700, A-74: the router's record of an ask is read through the seat operator-channel, which the indexer uses, // and its holder is asked of the seat mesh-module-assigner. - if !slices.Equal(m.Uses, []string{"operator-channel"}) || !slices.Contains(m.Invokes, "seat:mesh-module-assigner.assignments") { + if !slices.Equal(m.Uses, []string{"operator-channel"}) || !slices.Contains(m.Invokes, "seat:mesh-module-assigner.assignments") || !slices.Contains(m.Invokes, "seat:mesh-module-assigner.placement") { t.Errorf("uses = %v, invokes = %v, want the seat operator-channel used and seat:mesh-module-assigner.assignments invoked", m.Uses, m.Invokes) } // 1126 note 76: heard keeps the last 64 FORGE messages the indexer handled, for indexer_heard. @@ -178,7 +178,7 @@ func TestManifestDeclarations(t *testing.T) { // 1238/AC1, 1444 (reads the build // Three invokes: the file of a registered module's manifest and lock files (registered.go), the forge's trees (the named paths' objects, since the indexer is the one reader of a manifest's paths: // ADR 0499 clauses 2 and 3, ADR 0495 clause 1; and the module directories of the trunk, issue 1900) and whether a // commit is in another (issue 1900). - if want := []string{"mesh-delivery-assigner.assignments", "seat:git.trees", "seat:git.file", "seat:git.contains", "seat:git.open-pulls", "seat:git.branch", "mesh-delivery-checker.checker_tries", "mesh-delivery-forge-bridge.bridge_merged_message", "seat:node-engine.composition", "seat:mesh-delivery.source", "seat:mesh-module-assigner.assignments", "seat:mesh-hand-acts.hand-act", "seat:mesh-watchdogs.presence"}; !reflect.DeepEqual(m.Invokes, want) { + if want := []string{"mesh-delivery-assigner.assignments", "seat:git.trees", "seat:git.file", "seat:git.contains", "seat:git.open-pulls", "seat:git.branch", "mesh-delivery-checker.checker_tries", "mesh-delivery-forge-bridge.bridge_merged_message", "seat:node-engine.composition", "seat:mesh-delivery.source", "seat:mesh-module-assigner.assignments", "seat:mesh-module-assigner.placement", "seat:mesh-hand-acts.hand-act", "seat:mesh-watchdogs.presence"}; !reflect.DeepEqual(m.Invokes, want) { t.Errorf("invokes = %v, want %v", m.Invokes, want) } // 1945: indexer_take_in, served by its tools bundle through the takeins state; the process serves none. The same bundle diff --git a/modules/mesh-delivery-indexer/module.json b/modules/mesh-delivery-indexer/module.json index c3dc5d00..6a63d42a 100644 --- a/modules/mesh-delivery-indexer/module.json +++ b/modules/mesh-delivery-indexer/module.json @@ -153,6 +153,7 @@ "seat:node-engine.composition", "seat:mesh-delivery.source", "seat:mesh-module-assigner.assignments", + "seat:mesh-module-assigner.placement", "seat:mesh-hand-acts.hand-act", "seat:mesh-watchdogs.presence" ],