Merge pull request 'R4e (mesh-controller): deliver from the builder's keys, ask build-agent only for its own modules, follow the forge's protection for checks, announce the controller's states' (#441) from feat/r4e into main
This commit was merged in pull request #441.
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
package artifacts
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// blobBound is the most GetBlob reads: a templates archive is a few kilobytes; anything near this is no archive a
|
||||
// take-in reads text from.
|
||||
const blobBound = 64 << 20
|
||||
|
||||
// GetBlob is the archive a recorded reference names (`artifact-store://<repository>/blobs/sha256:<hex>`), read whole
|
||||
// and checked against the digest it names: bytes that hash to another digest are refused, never read (R4e: the
|
||||
// controller reads a templates archive by the reference of a builder's done key). Gone when the store does not hold
|
||||
// it; ErrNotOurs for a reference outside the store or one that is not an archive.
|
||||
func (s Store) GetBlob(ctx context.Context, reference string) ([]byte, error) {
|
||||
repository, digest, archive, err := s.archive(reference)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !archive {
|
||||
return nil, fmt.Errorf("%w: %s is not an archive", ErrNotOurs, reference)
|
||||
}
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, s.url(repository, "blobs", digest), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
response, err := s.client().Do(request)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot reach the artifact store at %s: %w", s.Address, err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
switch response.StatusCode {
|
||||
case http.StatusOK:
|
||||
case http.StatusNotFound:
|
||||
return nil, Gone
|
||||
default:
|
||||
return nil, fmt.Errorf("the artifact store answered %s for %s/blobs/%s", response.Status, repository, digest)
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(response.Body, blobBound+1))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading %s/blobs/%s: %w", repository, digest, err)
|
||||
}
|
||||
if len(body) > blobBound {
|
||||
return nil, fmt.Errorf("%s/blobs/%s is larger than %d bytes, which no archive read here is", repository, digest, blobBound)
|
||||
}
|
||||
sum := sha256.Sum256(body)
|
||||
if got := "sha256:" + hex.EncodeToString(sum[:]); got != digest {
|
||||
return nil, fmt.Errorf("the artifact store answered %s/blobs/%s with bytes of the digest %s: refused", repository,
|
||||
digest, got)
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package artifacts
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A take-in reads a templates archive by its reference, and only bytes of the digest the reference names.
|
||||
func TestGetBlobReadsOnlyTheBytesOfItsDigest(t *testing.T) {
|
||||
good := []byte("the archive")
|
||||
sum := sha256.Sum256(good)
|
||||
digest := "sha256:" + hex.EncodeToString(sum[:])
|
||||
liar := "sha256:" + strings.Repeat("0", 64)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case strings.HasSuffix(r.URL.Path, "/x/files/blobs/"+digest), strings.HasSuffix(r.URL.Path, "/x/files/blobs/"+liar):
|
||||
_, _ = w.Write(good)
|
||||
default:
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
s := Store{Address: strings.TrimPrefix(server.URL, "http://")}
|
||||
ctx := context.Background()
|
||||
body, err := s.GetBlob(ctx, "artifact-store://x/files/blobs/"+digest)
|
||||
if err != nil || string(body) != string(good) {
|
||||
t.Fatalf("the archive: %q %v", body, err)
|
||||
}
|
||||
if _, err := s.GetBlob(ctx, "artifact-store://x/files/blobs/"+liar); err == nil || !strings.Contains(err.Error(), "refused") {
|
||||
t.Fatalf("bytes of another digest are read: %v", err)
|
||||
}
|
||||
if _, err := s.GetBlob(ctx, "artifact-store://x/other/blobs/"+digest); !errors.Is(err, Gone) {
|
||||
t.Fatalf("a blob the store does not hold: %v", err)
|
||||
}
|
||||
if _, err := s.GetBlob(ctx, "registry.example/x@"+digest); !errors.Is(err, ErrNotOurs) {
|
||||
t.Fatalf("a reference outside the store: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package broker
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/announce"
|
||||
)
|
||||
|
||||
// Who may read a module's state is what the module announced (novox/hq ADR 0495 clauses 2 and 3, ADR 0513 §3; issue
|
||||
// 1790). A module says on the bus which states it keeps and which others may read (announce.Subject); the controller
|
||||
// keeps the last word of each (announce.KeptBucket) and grants a reader's `reads: <owner>.<name>` only when the owner
|
||||
// announced that state readable. An owner that has announced nothing yet is read as before, from the reader's `reads`
|
||||
// (the transition, until every module announces), and is named as not yet announced.
|
||||
|
||||
// StatesAnnouncedBucket is the controller's bucket of the last announcement of each module (announce.KeptBucket).
|
||||
const StatesAnnouncedBucket = announce.KeptBucket
|
||||
|
||||
// controllerReadable are the controller's states others may read: the record mirrors (the packages each node declares
|
||||
// among them, which the pacman module reads, ADR 0507 §5), the chain's buckets, the checks running and the plans building.
|
||||
var controllerReadable = []string{MirrorAssignments, MirrorDeclaredConfig, MirrorPredefined, ChainBuilds, ChainRollouts,
|
||||
ChecksRunning, PlansBuilding, MirrorDeclaredPackages}
|
||||
|
||||
// ControllerAnnouncement is what the controller announces it keeps (ADR 0495 clause 2): every bucket of its own by its local
|
||||
// name, the ones others may read marked readable. It announces itself, as any module does from its code.
|
||||
func ControllerAnnouncement(node string) announce.States {
|
||||
s := announce.States{Module: ControllerSeat, Node: node}
|
||||
readable := map[string]bool{}
|
||||
for _, n := range controllerReadable {
|
||||
readable[n] = true
|
||||
}
|
||||
for _, bucket := range ControllerBuckets() {
|
||||
local, mine := strings.CutPrefix(bucket, ControllerSeat+"_")
|
||||
if !mine {
|
||||
continue
|
||||
}
|
||||
s.States = append(s.States, announce.State{Name: local, Readable: readable[local]})
|
||||
}
|
||||
sort.Slice(s.States, func(i, j int) bool { return s.States[i].Name < s.States[j].Name })
|
||||
return s
|
||||
}
|
||||
|
||||
// ReadsAnnounced splits a reader's `reads` (`<owner>.<name>`) by what each owner announced: granted when the owner
|
||||
// announced the state readable, or announced nothing yet (the transition); refused when the owner announced and did
|
||||
// not announce that state readable.
|
||||
func ReadsAnnounced(reads []string, announced map[string]announce.States) (granted, refused []string) {
|
||||
for _, r := range reads {
|
||||
owner, name, ok := strings.Cut(r, ".")
|
||||
s, said := announced[owner]
|
||||
switch {
|
||||
case !ok || !said || s.Readable(name):
|
||||
granted = append(granted, r)
|
||||
default:
|
||||
refused = append(refused, r)
|
||||
}
|
||||
}
|
||||
return granted, refused
|
||||
}
|
||||
|
||||
// NotAnnounced are the owners some module reads the state of and that have announced nothing yet, sorted: read from the
|
||||
// readers' `reads` as before, until they announce.
|
||||
func NotAnnounced(r Records) []string {
|
||||
seen := map[string]bool{}
|
||||
var out []string
|
||||
for _, ds := range r.Assigned {
|
||||
for _, d := range ds {
|
||||
for _, read := range append(append([]string(nil), d.Reads...), d.ReadsRefused...) {
|
||||
owner, _, ok := strings.Cut(read, ".")
|
||||
if !ok || seen[owner] {
|
||||
continue
|
||||
}
|
||||
if _, said := r.Announced[owner]; !said {
|
||||
seen[owner] = true
|
||||
out = append(out, owner)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package broker
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/announce"
|
||||
)
|
||||
|
||||
// D3 (ADR 0495 clause 3, ADR 0513 §3; issue 1790): a reader's `reads` is granted where the owner announced the state
|
||||
// readable, refused where it announced and did not, and granted as before where the owner announced nothing yet.
|
||||
func TestAReadIsGrantedFromItsOwnersAnnouncement(t *testing.T) {
|
||||
announced := map[string]announce.States{
|
||||
"owner": {Module: "owner", States: []announce.State{{Name: "open", Readable: true}, {Name: "closed"}}},
|
||||
}
|
||||
granted, refused := ReadsAnnounced([]string{"owner.open", "owner.closed", "owner.unsaid", "quiet.any"}, announced)
|
||||
if !slices.Equal(granted, []string{"owner.open", "quiet.any"}) || !slices.Equal(refused, []string{"owner.closed", "owner.unsaid"}) {
|
||||
t.Fatalf("granted %v, refused %v", granted, refused)
|
||||
}
|
||||
r := Records{Announced: announced, Assigned: map[string][]Declared{"one": {{Module: "reader", Reads: granted,
|
||||
ReadsRefused: refused}}}}
|
||||
if got := NotAnnounced(r); !slices.Equal(got, []string{"quiet"}) {
|
||||
t.Fatalf("not yet announced: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// D1 (ADR 0495 clause 2): the controller announces every bucket of its own, and readable exactly the record mirrors (the
|
||||
// declared packages the pacman module reads among them, ADR 0507 §5), the chain's buckets, the checks running and the
|
||||
// plans building.
|
||||
func TestTheControllerAnnouncesWhatItKeeps(t *testing.T) {
|
||||
s := ControllerAnnouncement("novox")
|
||||
if _, err := announce.Encode(s); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var readable []string
|
||||
for _, k := range s.States {
|
||||
if k.Readable {
|
||||
readable = append(readable, k.Name)
|
||||
}
|
||||
}
|
||||
want := []string{ChainBuilds, ChecksRunning, MirrorDeclaredConfig, PlansBuilding, MirrorPredefined, ChainRollouts,
|
||||
MirrorAssignments, MirrorDeclaredPackages}
|
||||
slices.Sort(want)
|
||||
if !slices.Equal(readable, want) {
|
||||
t.Fatalf("readable %v, want %v", readable, want)
|
||||
}
|
||||
for _, name := range []string{"lease", "calls", "states-announced", "merge-window"} {
|
||||
if !s.Keeps(name) || s.Readable(name) {
|
||||
t.Errorf("%s: kept %v, readable %v", name, s.Keeps(name), s.Readable(name))
|
||||
}
|
||||
}
|
||||
perms, err := PermissionsFor(Principal{Kind: KindController})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !slices.Contains(perms.Publish, announce.Subject(ControllerSeat)) {
|
||||
t.Fatal("the controller may not say what it keeps")
|
||||
}
|
||||
}
|
||||
|
||||
// D2: every account that keeps state may say so on its own subject, a process account included, and no other's.
|
||||
func TestAnAccountKeepingStateMaySayWhatItKeeps(t *testing.T) {
|
||||
keeper, err := PermissionsFor(Principal{Kind: KindModule, Node: "one", Module: "keeper", PasswordHash: "x",
|
||||
State: []string{"things"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !slices.Contains(keeper.Publish, announce.Subject("keeper")) || slices.Contains(keeper.Publish, announce.Subject("other")) {
|
||||
t.Fatalf("the keeper's announcement grants: %v", keeper.Publish)
|
||||
}
|
||||
process, err := PermissionsFor(Principal{Kind: KindProcess, Node: "one", Module: "keeper", Process: "send",
|
||||
PasswordHash: "x", State: []string{"sends"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !slices.Contains(process.Publish, announce.Subject("keeper")) {
|
||||
t.Fatal("a process keeping state may not say what it keeps")
|
||||
}
|
||||
none, err := PermissionsFor(Principal{Kind: KindModule, Node: "one", Module: "stateless", PasswordHash: "x"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if slices.Contains(none.Publish, announce.Subject("stateless")) {
|
||||
t.Fatal("a module keeping nothing may say it keeps something")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package broker
|
||||
|
||||
// controllerReadableStates are the controller's states its announcement makes readable (ADR 0495 clause 2): what a
|
||||
// module may name in `reads` after `mesh-controller.`.
|
||||
func controllerReadableStates() []string {
|
||||
var out []string
|
||||
for _, s := range ControllerAnnouncement("").States {
|
||||
if s.Readable {
|
||||
out = append(out, s.Name)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -8,8 +8,8 @@ import (
|
||||
// 1155/AC1: a module whose manifest reads mesh-controller.checks-running may watch and get every key of the bucket and
|
||||
// write none.
|
||||
func TestAModuleReadingChecksRunningReadsAndWritesNone(t *testing.T) { // 1155/AC1
|
||||
if !slices.Contains(ControllerMirrors(), "checks-running") || BucketName(ControllerSeat, "checks-running") != CheckRunningBucket {
|
||||
t.Fatalf("a module cannot name %s in `reads`: %v", CheckRunningBucket, ControllerMirrors())
|
||||
if !slices.Contains(controllerReadableStates(), "checks-running") || BucketName(ControllerSeat, "checks-running") != CheckRunningBucket {
|
||||
t.Fatalf("a module cannot name %s in `reads`: %v", CheckRunningBucket, controllerReadableStates())
|
||||
}
|
||||
perms, err := PermissionsFor(Principal{Kind: KindModule, Node: "one", Module: "mesh-delivery-watchman", PasswordHash: "x",
|
||||
Reads: []string{"mesh-controller.checks-running"}})
|
||||
|
||||
@@ -155,12 +155,6 @@ const (
|
||||
// ChainBuckets are the three buckets of the chain: builds, rollouts and assignments.
|
||||
func ChainBuckets() []string { return []string{BuildsBucket, RolloutsBucket, AssignmentsBucket} }
|
||||
|
||||
// ControllerMirrors are the local names of the controller's buckets a module may read: the record mirrors and the
|
||||
// chain's buckets.
|
||||
func ControllerMirrors() []string {
|
||||
return []string{MirrorAssignments, MirrorDeclaredConfig, MirrorPredefined, ChainBuilds, ChainRollouts, ChecksRunning, PlansBuilding, MirrorDeclaredPackages}
|
||||
}
|
||||
|
||||
// MirrorsWrittenByAnother are the controller's mirrors whose writer is not the controller, each with its reason: the rule
|
||||
// that a mirror is written by the controller alone (TestTheMirrorsListAndTheWritersTableAgree) has these exceptions and no
|
||||
// other.
|
||||
@@ -230,14 +224,14 @@ func IsControllerBucket(bucket string) bool {
|
||||
bucket == DeclaredPackagesBucket ||
|
||||
bucket == MergeWindowBucket || bucket == RolloutSilentBucket || bucket == CheckRunningBucket ||
|
||||
bucket == DeliveryMergesBucket || bucket == BuildsBucket || bucket == RolloutsBucket ||
|
||||
bucket == PlansBuildingBucket || bucket == delivery.HaveBucket
|
||||
bucket == PlansBuildingBucket || bucket == delivery.HaveBucket || bucket == StatesAnnouncedBucket
|
||||
}
|
||||
|
||||
// ControllerBuckets are the controller's own buckets, in the order they are asserted.
|
||||
func ControllerBuckets() []string {
|
||||
return []string{LeaseBucket, CallsBucket, HandActsBucket, ConditionsBucket, ConditionHistoryBucket, AskedBucket,
|
||||
AssignmentsBucket, DeclaredConfigBucket, PredefinedBucket, DeclaredPackagesBucket, MergeWindowBucket, RolloutSilentBucket, CheckRunningBucket,
|
||||
DeliveryMergesBucket, PlansBuildingBucket, BuildsBucket, RolloutsBucket, delivery.HaveBucket}
|
||||
DeliveryMergesBucket, PlansBuildingBucket, BuildsBucket, RolloutsBucket, delivery.HaveBucket, StatesAnnouncedBucket}
|
||||
}
|
||||
|
||||
// ControllerBucketsAsserter is what raising the controller's buckets needs of a connection.
|
||||
@@ -396,6 +390,18 @@ func (j *JetStream) EnsureControllerBuckets() error {
|
||||
}); err != nil {
|
||||
return fmt.Errorf("asserting bucket %s: %w", PlansBuildingBucket, err)
|
||||
}
|
||||
// **The states each module announced** (novox/hq ADR 0495 clauses 2 and 3, issue 1790): one key a module, its last word.
|
||||
if _, err := js.CreateOrUpdateKeyValue(ctx, jetstream.KeyValueConfig{
|
||||
Bucket: StatesAnnouncedBucket,
|
||||
Description: "the states each module announced it keeps and which of them others may read (novox/hq ADR 0495): " +
|
||||
"`<module>` holds its last announcement; what a reader's `reads` is granted from; written by the controller alone",
|
||||
History: 1,
|
||||
MaxValueSize: 64 << 10,
|
||||
MaxBytes: 16 << 20,
|
||||
Storage: jetstream.FileStorage,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("asserting bucket %s: %w", StatesAnnouncedBucket, err)
|
||||
}
|
||||
// **The merges mesh-delivery announced: keys that age** (ADR 0420 §7, issue 1025). One message a key; a week.
|
||||
if _, err := js.CreateOrUpdateKeyValue(ctx, jetstream.KeyValueConfig{
|
||||
Bucket: DeliveryMergesBucket,
|
||||
|
||||
@@ -100,7 +100,7 @@ func TestTheWatchedSignalsMayBeSaidAndHeard(t *testing.T) {
|
||||
// stands for their declaration, so the report of state nothing declares never names one.
|
||||
func TestAListedMirrorIsNotReportedAsUndeclared(t *testing.T) {
|
||||
names := []string{"gone_state"}
|
||||
for _, m := range ControllerMirrors() {
|
||||
for _, m := range controllerReadableStates() {
|
||||
names = append(names, BucketName(ControllerSeat, m))
|
||||
}
|
||||
undeclared, err := RaiseBuckets(fakeBuckets{names: names}, nil)
|
||||
@@ -132,7 +132,7 @@ func TestTheMirrorsListAndTheWritersTableAgree(t *testing.T) {
|
||||
}
|
||||
}
|
||||
listed := map[string]bool{}
|
||||
for _, m := range ControllerMirrors() {
|
||||
for _, m := range controllerReadableStates() {
|
||||
bucket := BucketName(ControllerSeat, m)
|
||||
listed[bucket] = true
|
||||
if why, other := MirrorsWrittenByAnother[m]; other { // 1155/AC3: the exception, with its reason
|
||||
@@ -292,7 +292,7 @@ func TestEveryChainBucketIsOnTheControllersListAndHasAWriterRow(t *testing.T) {
|
||||
t.Errorf("%s is not on the controller's list of buckets", bucket)
|
||||
}
|
||||
local := strings.TrimPrefix(bucket, ControllerSeat+"_")
|
||||
if !slices.Contains(ControllerMirrors(), local) {
|
||||
if !slices.Contains(controllerReadableStates(), local) {
|
||||
t.Errorf("a module cannot name %s in `reads`", bucket)
|
||||
}
|
||||
rows := 0
|
||||
|
||||
@@ -9,9 +9,9 @@ import (
|
||||
// `reads`, and reads the bucket and writes none; the controller writes it, so it is no mirror written by another, and it is
|
||||
// one of the controller's own buckets, asserted at its start.
|
||||
func TestAModuleReadingDeclaredPackagesReadsAndWritesNone(t *testing.T) {
|
||||
if !slices.Contains(ControllerMirrors(), "declared-packages") ||
|
||||
if !slices.Contains(controllerReadableStates(), "declared-packages") ||
|
||||
BucketName(ControllerSeat, MirrorDeclaredPackages) != DeclaredPackagesBucket {
|
||||
t.Fatalf("a module cannot name %s in `reads`: %v", DeclaredPackagesBucket, ControllerMirrors())
|
||||
t.Fatalf("a module cannot name %s in `reads`: %v", DeclaredPackagesBucket, controllerReadableStates())
|
||||
}
|
||||
if _, other := MirrorsWrittenByAnother[MirrorDeclaredPackages]; other {
|
||||
t.Error("the controller writes the declared packages itself")
|
||||
|
||||
@@ -2,6 +2,7 @@ package broker
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"git.novox.be/novox/mesh-sdk/go/announce"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
@@ -407,7 +408,8 @@ func withPrefix(patterns []string, prefixes ...string) []string {
|
||||
func eventsSaid(p Permissions) []string {
|
||||
var out []string
|
||||
for _, s := range withPrefix(p.Publish, "mesh.mod.", "mesh.stream.") {
|
||||
if strings.Contains(s, ".event.") {
|
||||
// A module keeping state says what it keeps (ADR 0495 clause 2): no event of its declaration.
|
||||
if strings.Contains(s, ".event.") && !strings.HasSuffix(s, ".event."+announce.Event) {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ package broker
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"git.novox.be/novox/mesh-sdk/go/announce"
|
||||
"regexp"
|
||||
"slices"
|
||||
"sort"
|
||||
@@ -389,6 +390,16 @@ type DegradableGrant struct {
|
||||
// works. Every other grant stays required — missing one of those is a controller that comes up and is
|
||||
// refused on the first thing it tries, with no fallback that covers it.
|
||||
var DegradableGrants = []DegradableGrant{
|
||||
{
|
||||
Subject: announce.Subject(ControllerSeat),
|
||||
Reason: "refused: the controller's own announcement waits for its first push, and until it is kept every reader of " +
|
||||
"its states is granted from its `reads` as before (novox/hq ADR 0495, issue 1790)",
|
||||
},
|
||||
{
|
||||
Subject: "$KV." + StatesAnnouncedBucket + ".>",
|
||||
Reason: "refused: no announcement is kept until the controller's first push, and until then every reader is " +
|
||||
"granted from its `reads` as before (novox/hq ADR 0495, issue 1790)",
|
||||
},
|
||||
{
|
||||
Subject: "mesh.seat." + ControllerSeat + ".tool.push",
|
||||
Reason: "refused: the ask of a held move (Release) is answered, and the refusal told, while the operator's own " +
|
||||
@@ -756,6 +767,8 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
||||
// **And its own buckets** (novox/hq to-be 45 §1): the calls it served and the acts done by
|
||||
// hand, which it alone writes. A put is a publish to the bucket's subject, which `$JS.API.>`
|
||||
// does not cover; each bucket named, not `$KV.>`, which would let it write any module's state.
|
||||
// And says what it keeps, as every module does (novox/hq ADR 0495 clause 2, issue 1790).
|
||||
pub = append(pub, announce.Subject(ControllerSeat))
|
||||
for _, bucket := range ControllerBuckets() {
|
||||
if bucket == delivery.HaveBucket {
|
||||
continue // each node's node-engine writes its own keys of it, the controller none (ADR 0429 §1)
|
||||
|
||||
@@ -8,8 +8,8 @@ import (
|
||||
// Issue 1687 (ADR 0483 clause 2): a module whose manifest reads mesh-controller.plans-building may name it in `reads`, and
|
||||
// reads the bucket and writes none; the controller writes it, so it is no mirror written by another.
|
||||
func TestAModuleReadingPlansBuildingReadsAndWritesNone(t *testing.T) {
|
||||
if !slices.Contains(ControllerMirrors(), "plans-building") || BucketName(ControllerSeat, PlansBuilding) != PlansBuildingBucket {
|
||||
t.Fatalf("a module cannot name %s in `reads`: %v", PlansBuildingBucket, ControllerMirrors())
|
||||
if !slices.Contains(controllerReadableStates(), "plans-building") || BucketName(ControllerSeat, PlansBuilding) != PlansBuildingBucket {
|
||||
t.Fatalf("a module cannot name %s in `reads`: %v", PlansBuildingBucket, controllerReadableStates())
|
||||
}
|
||||
if _, other := MirrorsWrittenByAnother[PlansBuilding]; other {
|
||||
t.Error("the controller writes the plans building itself")
|
||||
|
||||
@@ -48,6 +48,8 @@ func TestAProcessIsGrantedExactlyItsBusDeclaration(t *testing.T) {
|
||||
"$KV.mesh-mail_sends.>",
|
||||
"mesh.mod.mesh-mail.event.mail.send.failed",
|
||||
"mesh.mod.mesh-mail.event.mail.send.sent",
|
||||
// It keeps state, so it says what it keeps (novox/hq ADR 0495 clause 2, issue 1790).
|
||||
"mesh.mod.mesh-mail.event.states",
|
||||
"mesh.seat.operator-channel.call.tell.*.novox~mesh-mail~send",
|
||||
"mesh.seat.operator-channel.call.tell.novox~mesh-mail~send",
|
||||
"mesh.seat.operator-channel.tool.tell",
|
||||
|
||||
@@ -2,6 +2,7 @@ package broker
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"git.novox.be/novox/mesh-sdk/go/announce"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -147,6 +148,11 @@ func stateGrants(a stateAccess) []string {
|
||||
read(bucket, "")
|
||||
out = append(out, "$KV."+bucket+".>")
|
||||
}
|
||||
// **And it says what it keeps** (novox/hq ADR 0495 clause 2, issue 1790): a module keeping state announces it on its
|
||||
// own event subject, from its code; refused, its readers are read as before.
|
||||
if len(a.Keeps) > 0 && safeSubject.MatchString(a.Module) {
|
||||
out = append(out, announce.Subject(a.Module))
|
||||
}
|
||||
for _, r := range a.Reads {
|
||||
if bucket, ok := bucketOfRead(r); ok {
|
||||
read(bucket, "")
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
package broker_test
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/broker"
|
||||
"github.com/novox/mesh-controller/internal/conditions"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
)
|
||||
|
||||
// The facts the control plane states are named twice — in the grant that permits them and in the code
|
||||
// that states them — because `link` imports `broker` and the dependency cannot go the other way. So a
|
||||
// test keeps them agreeing: a subject the grant omits is refused at the moment the mesh has something
|
||||
// to say, and one the grant adds that nothing states is authority nobody uses.
|
||||
//
|
||||
// An external test package, because it may import both while neither imports the other.
|
||||
func TestTheFactsTheGrantPermitsAreTheFactsTheMeshStates(t *testing.T) {
|
||||
if broker.ControllerSeat != link.MeshControllerSeat {
|
||||
t.Fatalf("the grant is written for the %q seat and the mesh states its facts under %q",
|
||||
broker.ControllerSeat, link.MeshControllerSeat)
|
||||
}
|
||||
// And what is wrong, as it changes, and the self-check's heartbeat (novox/hq to-be 45 §2, §4).
|
||||
states := append([]string{link.KeyApplied, link.KeyRefused, link.KeyBuiltBefore}, conditions.Events...)
|
||||
states = append(states, conditions.HeartbeatEvent)
|
||||
// And a value given by hand, replaced after its module's first good start (novox/hq ADR 0228).
|
||||
states = append(states, link.KeySecretReplaced)
|
||||
// And every act a healer takes (novox/hq to-be 45 §7).
|
||||
states = append(states, link.KeyHealerActed)
|
||||
// And a build put back after its gate failed (novox/hq ADR 0236).
|
||||
states = append(states, link.KeyRolledBack)
|
||||
// And a pull request's merge check, judged (novox/hq to-be 45 §9).
|
||||
states = append(states, link.KeyChecked)
|
||||
// And a walk kept in a new state, for the delivery it walks (novox/hq ADR 0239).
|
||||
states = append(states, link.KeyPlanMoved)
|
||||
// And a tier sent to a machine, once the send is kept (novox/hq ADR 0406 §5, issue 974).
|
||||
states = append(states, link.KeySent)
|
||||
// And a key of a record mirror written or removed (novox/hq ADR 0289, design 54 §3; design 57 F3).
|
||||
states = append(states, link.KeyAssignmentChanged, link.KeyDeclaredConfigChanged, link.KeyPredefinedChanged)
|
||||
for _, event := range states {
|
||||
if !slices.Contains(broker.ControllerStates, event) {
|
||||
t.Errorf("the mesh states %q and its account may not publish it", event)
|
||||
}
|
||||
}
|
||||
if len(broker.ControllerStates) != len(states) {
|
||||
t.Errorf("the grant permits %v, which is more than the mesh states", broker.ControllerStates)
|
||||
}
|
||||
// The record mirrors a module may read are the ones the bus holds (design 54 §5, design 57 F3).
|
||||
if !slices.Equal(catalogue.ControllerMirrors, broker.ControllerMirrors()) {
|
||||
t.Errorf("the catalogue lets a module read the controller's mirrors %v and the bus holds %v",
|
||||
catalogue.ControllerMirrors, broker.ControllerMirrors())
|
||||
}
|
||||
// **And the seat says it.** A seat carries the protocol of its role (novox/hq ADR 0129), so the
|
||||
// facts the control plane states are the seat's `emits` — which is what lets anything else declare
|
||||
// that it consumes them, and what the subject-agreement check reads to know they have an owner.
|
||||
var declared []string
|
||||
for _, seat := range catalogue.SeatsWithAProtocol() {
|
||||
if seat.Name == broker.ControllerSeat {
|
||||
declared = seat.Emits
|
||||
}
|
||||
}
|
||||
if !slices.Equal(declared, broker.ControllerStates) {
|
||||
t.Errorf("the %s seat emits %v and the grant permits %v", broker.ControllerSeat,
|
||||
declared, broker.ControllerStates)
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -29,7 +29,7 @@ accounts {
|
||||
]
|
||||
users = [
|
||||
{ user: "controller", password: "$2a$11$cccccccccccccccccccccc", permissions: {
|
||||
publish: { allow: ["$JS.ACK.CONTROL.controller.>", "$JS.ACK.DEAD_LETTER_NOTICES.controller.>", "$JS.ACK.EVENTS.controller-merges.>", "$JS.ACK.EVENTS.controller.>", "$JS.API.>", "$KV.SEAT_MESH_BUILD_MACHINE_cancelled.>", "$KV.SEAT_NODE_BUILD_AGENT_cancelled.>", "$KV.mesh-controller_asked.>", "$KV.mesh-controller_assignments.>", "$KV.mesh-controller_builds.>", "$KV.mesh-controller_calls.>", "$KV.mesh-controller_checks-running.>", "$KV.mesh-controller_condition-history.>", "$KV.mesh-controller_conditions.>", "$KV.mesh-controller_declared-config.>", "$KV.mesh-controller_declared-packages.>", "$KV.mesh-controller_delivery-merges.>", "$KV.mesh-controller_hand-acts.>", "$KV.mesh-controller_lease.>", "$KV.mesh-controller_merge-window.>", "$KV.mesh-controller_plans-building.>", "$KV.mesh-controller_predefined.>", "$KV.mesh-controller_rollout-silent.>", "$KV.mesh-controller_rollouts.>", "$SRV.INFO", "_INBOX.enrol.>", "mesh.again.>", "mesh.assignment.>", "mesh.events.dead.>", "mesh.mod.*.tool.>", "mesh.node.>", "mesh.seat.mesh-build-machine.accept.>", "mesh.seat.mesh-build-machine.tool.>", "mesh.seat.mesh-controller.event.applied", "mesh.seat.mesh-controller.event.assignment-changed", "mesh.seat.mesh-controller.event.built-before", "mesh.seat.mesh-controller.event.checked", "mesh.seat.mesh-controller.event.condition-changed", "mesh.seat.mesh-controller.event.condition-cleared", "mesh.seat.mesh-controller.event.condition-raised", "mesh.seat.mesh-controller.event.declared-config-changed", "mesh.seat.mesh-controller.event.doctor-heartbeat", "mesh.seat.mesh-controller.event.healer-acted", "mesh.seat.mesh-controller.event.plan-moved", "mesh.seat.mesh-controller.event.predefined-changed", "mesh.seat.mesh-controller.event.refused", "mesh.seat.mesh-controller.event.rolled-back", "mesh.seat.mesh-controller.event.secret-replaced", "mesh.seat.mesh-controller.event.sent", "mesh.seat.mesh-controller.tool.plans", "mesh.seat.mesh-controller.tool.push", "mesh.seat.mesh-controller.tool.upgrade", "mesh.seat.mesh-delivery.tool.close", "mesh.seat.mesh-delivery.tool.groups", "mesh.seat.mesh-delivery.tool.release", "mesh.seat.mesh-delivery.tool.stalled", "mesh.seat.mesh-delivery.tool.stop", "mesh.seat.mesh-delivery.tool.times", "mesh.seat.node-backup.tool.backed-up.*", "mesh.seat.node-backup.tool.now.*", "mesh.seat.node-build-agent.accept.>", "mesh.seat.node-build-agent.check.>", "mesh.seat.node-build-agent.tool.>", "mesh.seat.node-intrusion-prevention.tool.banned.*", "mesh.seat.node-launcher.tool.secret.*", "mesh.seat.node-service-manager.tool.restart.*", "mesh.seat.operator-channel.tool.tell"] }
|
||||
publish: { allow: ["$JS.ACK.CONTROL.controller.>", "$JS.ACK.DEAD_LETTER_NOTICES.controller.>", "$JS.ACK.EVENTS.controller-merges.>", "$JS.ACK.EVENTS.controller.>", "$JS.API.>", "$KV.SEAT_MESH_BUILD_MACHINE_cancelled.>", "$KV.SEAT_NODE_BUILD_AGENT_cancelled.>", "$KV.mesh-controller_asked.>", "$KV.mesh-controller_assignments.>", "$KV.mesh-controller_builds.>", "$KV.mesh-controller_calls.>", "$KV.mesh-controller_checks-running.>", "$KV.mesh-controller_condition-history.>", "$KV.mesh-controller_conditions.>", "$KV.mesh-controller_declared-config.>", "$KV.mesh-controller_declared-packages.>", "$KV.mesh-controller_delivery-merges.>", "$KV.mesh-controller_hand-acts.>", "$KV.mesh-controller_lease.>", "$KV.mesh-controller_merge-window.>", "$KV.mesh-controller_plans-building.>", "$KV.mesh-controller_predefined.>", "$KV.mesh-controller_rollout-silent.>", "$KV.mesh-controller_rollouts.>", "$KV.mesh-controller_states-announced.>", "$SRV.INFO", "_INBOX.enrol.>", "mesh.again.>", "mesh.assignment.>", "mesh.events.dead.>", "mesh.mod.*.tool.>", "mesh.mod.mesh-controller.event.states", "mesh.node.>", "mesh.seat.mesh-build-machine.accept.>", "mesh.seat.mesh-build-machine.tool.>", "mesh.seat.mesh-controller.event.applied", "mesh.seat.mesh-controller.event.assignment-changed", "mesh.seat.mesh-controller.event.built-before", "mesh.seat.mesh-controller.event.checked", "mesh.seat.mesh-controller.event.condition-changed", "mesh.seat.mesh-controller.event.condition-cleared", "mesh.seat.mesh-controller.event.condition-raised", "mesh.seat.mesh-controller.event.declared-config-changed", "mesh.seat.mesh-controller.event.doctor-heartbeat", "mesh.seat.mesh-controller.event.healer-acted", "mesh.seat.mesh-controller.event.plan-moved", "mesh.seat.mesh-controller.event.predefined-changed", "mesh.seat.mesh-controller.event.refused", "mesh.seat.mesh-controller.event.rolled-back", "mesh.seat.mesh-controller.event.secret-replaced", "mesh.seat.mesh-controller.event.sent", "mesh.seat.mesh-controller.tool.plans", "mesh.seat.mesh-controller.tool.push", "mesh.seat.mesh-controller.tool.upgrade", "mesh.seat.mesh-delivery.tool.close", "mesh.seat.mesh-delivery.tool.groups", "mesh.seat.mesh-delivery.tool.release", "mesh.seat.mesh-delivery.tool.stalled", "mesh.seat.mesh-delivery.tool.stop", "mesh.seat.mesh-delivery.tool.times", "mesh.seat.node-backup.tool.backed-up.*", "mesh.seat.node-backup.tool.now.*", "mesh.seat.node-build-agent.accept.>", "mesh.seat.node-build-agent.check.>", "mesh.seat.node-build-agent.tool.>", "mesh.seat.node-intrusion-prevention.tool.banned.*", "mesh.seat.node-launcher.tool.secret.*", "mesh.seat.node-service-manager.tool.restart.*", "mesh.seat.operator-channel.tool.tell"] }
|
||||
subscribe: { allow: ["$JS.API.>", "$JS.EVENT.ADVISORY.CONSUMER.DELETED.>", "$JS.EVENT.ADVISORY.CONSUMER.MAX_DELIVERIES.>", "$SRV.INFO", "$SRV.INFO.mesh-controller", "$SRV.INFO.mesh-controller.>", "$SRV.PING", "$SRV.PING.mesh-controller", "$SRV.PING.mesh-controller.>", "$SRV.STATS", "$SRV.STATS.mesh-controller", "$SRV.STATS.mesh-controller.>", "_DELIVER.controller", "_DELIVER.controller.>", "_INBOX.controller.>", "mesh.control.>", "mesh.mod.*.event.provisioner.failing", "mesh.mod.*.event.provisioner.recovered", "mesh.mod.*.event.provisioner.retirement", "mesh.mod.gitea.event.pull.merged", "mesh.mod.gitea.event.pull.updated", "mesh.mod.mesh-catalog.event.catching-up", "mesh.mod.mesh-catalog.event.upgraded", "mesh.seat.mesh-build-machine.event.built", "mesh.seat.mesh-controller.tool.>", "mesh.seat.node-build-agent.event.built", "mesh.seat.operator-channel.event.decided.mesh-controller"] }
|
||||
allow_responses: { max: 1, ttl: "1m" }
|
||||
} }
|
||||
|
||||
@@ -2,6 +2,7 @@ package broker
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"git.novox.be/novox/mesh-sdk/go/announce"
|
||||
"slices"
|
||||
"sort"
|
||||
)
|
||||
@@ -56,6 +57,9 @@ type Declared struct {
|
||||
HearsAStream bool
|
||||
// Reads are other modules' state it reads, each `<module>.<name>` (novox/hq ADR 0201).
|
||||
Reads []string
|
||||
// ReadsRefused are the reads its manifest names that the owner's announcement does not make readable (novox/hq ADR
|
||||
// 0495 clause 3, issue 1790): granted nothing, kept to be said.
|
||||
ReadsRefused []string
|
||||
// KeyedReads are keys of other modules' state it reads, each one key alone: what a seat's holder is
|
||||
// granted for the pieces the modules beside it offer (novox/hq ADR 0260).
|
||||
KeyedReads []KeyedRead
|
||||
@@ -126,6 +130,9 @@ type Records struct {
|
||||
// account agents run as, judged unable to become root by its node-engine, and serves no login shell
|
||||
// execute. A machine absent is not free: no record of a pass is no pass.
|
||||
RootFree map[string]bool
|
||||
// Announced is each module's last announcement of the states it keeps (novox/hq ADR 0495, issue 1790), as the
|
||||
// controller keeps it; a module absent has announced nothing yet. Nil where none was read.
|
||||
Announced map[string]announce.States
|
||||
}
|
||||
|
||||
// Users is every user the composed file should contain, in the order it will be written.
|
||||
|
||||
@@ -87,6 +87,22 @@ func ownModule(p Principal, pattern string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// ownAnnouncer is a module, a process of it or the node tools carrying it saying what that module keeps, or the
|
||||
// controller saying what it keeps itself (novox/hq ADR 0495 clause 2): each under its own name alone.
|
||||
func ownAnnouncer(p Principal, pattern string) bool {
|
||||
tokens := strings.Split(pattern, ".")
|
||||
if len(tokens) < 3 {
|
||||
return false
|
||||
}
|
||||
switch p.Kind {
|
||||
case KindController:
|
||||
return tokens[2] == ControllerSeat
|
||||
case KindProcess:
|
||||
return p.Module == tokens[2]
|
||||
}
|
||||
return ownModule(p, pattern)
|
||||
}
|
||||
|
||||
// ownProvisioner is a machine's provisioner client saying a report about its own machine: the third token of
|
||||
// `prov.applied.<node>.…` is its machine (novox/hq ADR 0349 §4, design 57 P3).
|
||||
func ownProvisioner(p Principal, pattern string) bool {
|
||||
@@ -181,6 +197,14 @@ var WritersTable = []WriterRow{
|
||||
KeptIn: "key-value " + PlansBuildingBucket + ", aged out when not refreshed",
|
||||
Others: "the build seat's holders read it and take no new check while a key is there (ADR 0483)",
|
||||
Subjects: kvOf(PlansBuildingBucket), Writes: isController},
|
||||
// What a module keeps and who may read it (novox/hq ADR 0495 clauses 2 and 3, issue 1790): said by the module, kept by
|
||||
// the controller.
|
||||
{State: "a module's states announced", Writer: "the module itself, from its code, under its own name",
|
||||
KeptIn: "the bus (EVENTS)", Others: "the controller keeps the last of each and grants readers from it",
|
||||
Subjects: []string{"mesh.mod.*.event.states"}, Writes: ownAnnouncer},
|
||||
{State: "the states announced", Writer: "controller, from each module's announcement",
|
||||
KeptIn: "key-value " + StatesAnnouncedBucket, Others: "the composition of the bus's users reads it",
|
||||
Subjects: kvOf(StatesAnnouncedBucket), Writes: isController},
|
||||
{State: "a merge announced", Writer: "one announcer per forge (the hook, or the poll when the hook is absent — never both)",
|
||||
KeptIn: "the bus", Others: "—", Subjects: []string{"mesh.mod.*.event.pull.merged"}, Writes: ownModule},
|
||||
// A pull request's head, before it merges (novox/hq to-be 45 §9): the same one announcer.
|
||||
|
||||
@@ -31,6 +31,9 @@ var designRows = []string{
|
||||
"a pull request's check running",
|
||||
// The plans in their build phase, a bucket the build seat's holders read (ADR 0483 clause 2, issue 1687).
|
||||
"the plans-building mirror",
|
||||
// What each module keeps and who may read it, said by the module and kept by the controller (ADR 0495, issue 1790).
|
||||
"a module's states announced",
|
||||
"the states announced",
|
||||
"a merge announced",
|
||||
"a pull request's head announced",
|
||||
"a pull request's merge check",
|
||||
|
||||
@@ -23,6 +23,7 @@ import (
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"github.com/novox/mesh-controller/internal/buildreads"
|
||||
)
|
||||
|
||||
// Turning a repository into artifacts the mesh can pin.
|
||||
@@ -2002,33 +2003,9 @@ func instructions(recipe string) []string {
|
||||
return out
|
||||
}
|
||||
|
||||
// readBy is every repository other than the module's own that this build's recipes read source from,
|
||||
// each once and in a fixed order, so two builds of one commit report the same thing the same way.
|
||||
func readBy(manifest catalogue.Manifest) []catalogue.ArtifactContext {
|
||||
if manifest.Build == nil {
|
||||
return nil
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
var out []catalogue.ArtifactContext
|
||||
for _, a := range manifest.Build.Artifacts {
|
||||
if a.Context == nil || a.Context.Repository == "" {
|
||||
continue
|
||||
}
|
||||
key := a.Context.Repository + "#" + a.Context.Ref
|
||||
if seen[key] {
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
out = append(out, *a.Context)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
if out[i].Repository != out[j].Repository {
|
||||
return out[i].Repository < out[j].Repository
|
||||
}
|
||||
return out[i].Ref < out[j].Ref
|
||||
})
|
||||
return out
|
||||
}
|
||||
// readBy is every repository other than the module's own that this build's recipes read source from: the rule
|
||||
// build-agent and the controller share (internal/buildreads).
|
||||
func readBy(manifest catalogue.Manifest) []catalogue.ArtifactContext { return buildreads.Of(manifest) }
|
||||
|
||||
// binaryName is what a compiled bundle's executable is called: what the artifact says, or the name of
|
||||
// the package it is built from, which is what a compiler would have chosen anyway.
|
||||
|
||||
@@ -234,6 +234,9 @@ func spoken() string {
|
||||
}
|
||||
|
||||
// Languages is every language the mesh can build, for anything that wants to say so.
|
||||
// AgentModule is build-agent's own module: the machine's builder, whose image is built from another repository's context.
|
||||
const AgentModule = "build-agent"
|
||||
|
||||
func Languages() []string {
|
||||
names := make([]string, 0, len(toolchains))
|
||||
for _, t := range toolchains {
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
// Package builderkeys is how the controller reads what the builder and the indexer wrote (novox/hq ADR 0501, ADR 0498
|
||||
// §8, ADR 0453 §5): a module's build at a fingerprint, in the builder's bucket; the indexer's trunk value of a module,
|
||||
// which names that fingerprint; and a manifest by its digest. From R4e the controller asks no build but the builder's
|
||||
// own module's: it takes every other module from the builder's done key, and it reads them here.
|
||||
//
|
||||
// It only reads. The builder writes its keys and the indexer its values; the controller's account reaches every
|
||||
// bucket through the JetStream API it is granted (internal/broker: KindController).
|
||||
package builderkeys
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
)
|
||||
|
||||
// Key is a module's build at a fingerprint as the builder's bucket holds it. Found is false when there is no key (or
|
||||
// no bucket yet): nothing built or queued that fingerprint. Revision is the key's revision, a new one for every
|
||||
// rebuild of the same fingerprint (ADR 0429 §18).
|
||||
type Key struct {
|
||||
Module string
|
||||
Fingerprint string
|
||||
Found bool
|
||||
Revision uint64
|
||||
Build delivery.BuilderBuild
|
||||
}
|
||||
|
||||
// Done says the key is done.
|
||||
func (k Key) Done() bool { return k.Found && k.Build.State == delivery.BuildDone }
|
||||
|
||||
// Failed says the key failed; its Error is the builder's own words (`digests differ:`, `not stored:`, …).
|
||||
func (k Key) Failed() bool { return k.Found && k.Build.State == delivery.BuildFailed }
|
||||
|
||||
// Unreferenced are the artifacts of a done key that name no reference (ADR 0498 §8): a key done before R4e's
|
||||
// delivery, or a candidate's image or package not yet built again for the trunk. Such a key is waited for, never
|
||||
// delivered from.
|
||||
func (k Key) Unreferenced() []string {
|
||||
var out []string
|
||||
for _, a := range k.Build.Artifacts {
|
||||
if strings.TrimSpace(a.Reference) == "" {
|
||||
out = append(out, a.Name)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Says is the key's state in words: what a plan waiting on it says.
|
||||
func (k Key) Says() string {
|
||||
if !k.Found {
|
||||
return fmt.Sprintf("no build of %s at %s yet", k.Module, short(k.Fingerprint))
|
||||
}
|
||||
switch k.Build.State {
|
||||
case delivery.BuildFailed:
|
||||
return fmt.Sprintf("the build of %s at %s failed: %s", k.Module, short(k.Fingerprint), firstLine(k.Build.Error))
|
||||
case delivery.BuildDone:
|
||||
if missing := k.Unreferenced(); len(missing) > 0 {
|
||||
return fmt.Sprintf("the build of %s at %s is done and waits for the reference of %s", k.Module,
|
||||
short(k.Fingerprint), strings.Join(missing, ", "))
|
||||
}
|
||||
return fmt.Sprintf("the build of %s at %s is done", k.Module, short(k.Fingerprint))
|
||||
}
|
||||
if k.Build.By != "" {
|
||||
return fmt.Sprintf("the build of %s at %s is %s on %s", k.Module, short(k.Fingerprint), k.Build.State, k.Build.By)
|
||||
}
|
||||
return fmt.Sprintf("the build of %s at %s is %s", k.Module, short(k.Fingerprint), k.Build.State)
|
||||
}
|
||||
|
||||
// ErrNoValue is a module the indexer holds no value of at the commit asked: not written yet, or older than the
|
||||
// bucket's history.
|
||||
var ErrNoValue = errors.New("the indexer holds no value of the module at that commit")
|
||||
|
||||
// Reader reads the builder's and the indexer's buckets.
|
||||
type Reader struct {
|
||||
JS jetstream.JetStream
|
||||
}
|
||||
|
||||
// bucket is the named bucket, or nil when it is not there yet (the part that writes it not delivered).
|
||||
func (r Reader) bucket(ctx context.Context, name string) (jetstream.KeyValue, error) {
|
||||
kv, err := r.JS.KeyValue(ctx, name)
|
||||
if errors.Is(err, jetstream.ErrBucketNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the bucket %s: %w", name, err)
|
||||
}
|
||||
return kv, nil
|
||||
}
|
||||
|
||||
// Build is the builder's key of module at fingerprint fp (`src2:` and 64 hex digits). A missing key, or a missing
|
||||
// bucket, is a Key not Found and no error; a value that does not decode is an error naming the key.
|
||||
func (r Reader) Build(ctx context.Context, module, fp string) (Key, error) {
|
||||
out := Key{Module: module, Fingerprint: fp}
|
||||
key, err := delivery.BuilderBuildKey(module, fp)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
kv, err := r.bucket(ctx, delivery.BuilderBuildsBucket)
|
||||
if err != nil || kv == nil {
|
||||
return out, err
|
||||
}
|
||||
e, err := kv.Get(ctx, key)
|
||||
if errors.Is(err, jetstream.ErrKeyNotFound) {
|
||||
return out, nil
|
||||
}
|
||||
if err != nil {
|
||||
return out, fmt.Errorf("the builder's key %s: %w", key, err)
|
||||
}
|
||||
b, err := delivery.DecodeBuilderBuild(e.Value())
|
||||
if err != nil {
|
||||
return out, fmt.Errorf("the builder's key %s is not read: %w", key, err)
|
||||
}
|
||||
out.Found, out.Revision, out.Build = true, e.Revision(), b
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ModuleAt is the indexer's trunk value of module written for commit (ADR 0501 §2): the newest revision of
|
||||
// modules.<module> whose Commit is commit, read from the key's history; with commit empty, the value it holds now.
|
||||
// None held is ErrNoValue.
|
||||
func (r Reader) ModuleAt(ctx context.Context, module, commit string) (delivery.Module, error) {
|
||||
key, err := delivery.ModulesKey(module)
|
||||
if err != nil {
|
||||
return delivery.Module{}, err
|
||||
}
|
||||
kv, err := r.bucket(ctx, delivery.ModulesBucket)
|
||||
if err != nil {
|
||||
return delivery.Module{}, err
|
||||
}
|
||||
if kv == nil {
|
||||
return delivery.Module{}, fmt.Errorf("%w (%s: the indexer's bucket is not there)", ErrNoValue, module)
|
||||
}
|
||||
var values [][]byte
|
||||
if commit == "" {
|
||||
e, err := kv.Get(ctx, key)
|
||||
if errors.Is(err, jetstream.ErrKeyNotFound) {
|
||||
return delivery.Module{}, fmt.Errorf("%w (%s)", ErrNoValue, module)
|
||||
}
|
||||
if err != nil {
|
||||
return delivery.Module{}, fmt.Errorf("the indexer's value of %s: %w", module, err)
|
||||
}
|
||||
values = [][]byte{e.Value()}
|
||||
} else {
|
||||
entries, err := kv.History(ctx, key)
|
||||
if errors.Is(err, jetstream.ErrKeyNotFound) {
|
||||
return delivery.Module{}, fmt.Errorf("%w (%s at %s)", ErrNoValue, module, short(commit))
|
||||
}
|
||||
if err != nil {
|
||||
return delivery.Module{}, fmt.Errorf("the indexer's values of %s: %w", module, err)
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e.Operation() == jetstream.KeyValuePut {
|
||||
values = append(values, e.Value())
|
||||
}
|
||||
}
|
||||
}
|
||||
return pickAt(module, commit, values)
|
||||
}
|
||||
|
||||
// Modules is every module the indexer holds a trunk value of, by module: the value it holds now. A value that does not
|
||||
// decode is left out and said in unread.
|
||||
func (r Reader) Modules(ctx context.Context) (values map[string]delivery.Module, unread map[string]error, err error) {
|
||||
kv, err := r.bucket(ctx, delivery.ModulesBucket)
|
||||
if err != nil || kv == nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
keys, err := kv.Keys(ctx)
|
||||
if errors.Is(err, jetstream.ErrNoKeysFound) {
|
||||
return nil, nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("listing the indexer's modules: %w", err)
|
||||
}
|
||||
values, unread = map[string]delivery.Module{}, map[string]error{}
|
||||
for _, key := range keys {
|
||||
e, err := kv.Get(ctx, key)
|
||||
if errors.Is(err, jetstream.ErrKeyNotFound) {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("the indexer's value of %s: %w", key, err)
|
||||
}
|
||||
m, err := delivery.DecodeModule(e.Value())
|
||||
if err != nil {
|
||||
unread[key] = err
|
||||
continue
|
||||
}
|
||||
values[key] = m
|
||||
}
|
||||
return values, unread, nil
|
||||
}
|
||||
|
||||
// pickAt is, of a key's values oldest first, the newest whose commit is commit (any, with commit empty); values that
|
||||
// do not decode are passed over, and one of them is said when nothing else is found.
|
||||
func pickAt(module, commit string, values [][]byte) (delivery.Module, error) {
|
||||
var unread error
|
||||
for i := len(values) - 1; i >= 0; i-- {
|
||||
m, err := delivery.DecodeModule(values[i])
|
||||
if err != nil {
|
||||
if unread == nil {
|
||||
unread = err
|
||||
}
|
||||
continue
|
||||
}
|
||||
if commit == "" || sameCommit(m.Commit, commit) {
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
where := module
|
||||
if commit != "" {
|
||||
where += " at " + short(commit)
|
||||
}
|
||||
if unread != nil {
|
||||
return delivery.Module{}, fmt.Errorf("%w (%s; a value is not read: %v)", ErrNoValue, where, unread)
|
||||
}
|
||||
return delivery.Module{}, fmt.Errorf("%w (%s)", ErrNoValue, where)
|
||||
}
|
||||
|
||||
// sameCommit is two commit ids naming one commit: equal, or one a prefix of the other of at least 7 hex digits.
|
||||
func sameCommit(a, b string) bool {
|
||||
if a == b {
|
||||
return a != ""
|
||||
}
|
||||
if len(a) < 7 || len(b) < 7 {
|
||||
return false
|
||||
}
|
||||
return strings.HasPrefix(a, b) || strings.HasPrefix(b, a)
|
||||
}
|
||||
|
||||
// Manifest is the indexer's manifest of digest: the module and its module.json byte for byte.
|
||||
func (r Reader) Manifest(ctx context.Context, digest string) (delivery.Manifest, error) {
|
||||
key, err := delivery.ManifestsKey(digest)
|
||||
if err != nil {
|
||||
return delivery.Manifest{}, err
|
||||
}
|
||||
kv, err := r.bucket(ctx, delivery.ManifestsBucket)
|
||||
if err != nil {
|
||||
return delivery.Manifest{}, err
|
||||
}
|
||||
if kv == nil {
|
||||
return delivery.Manifest{}, fmt.Errorf("the manifest %s: the indexer's manifests bucket is not there", short(digest))
|
||||
}
|
||||
e, err := kv.Get(ctx, key)
|
||||
if errors.Is(err, jetstream.ErrKeyNotFound) {
|
||||
return delivery.Manifest{}, fmt.Errorf("the indexer holds no manifest %s", short(digest))
|
||||
}
|
||||
if err != nil {
|
||||
return delivery.Manifest{}, fmt.Errorf("the manifest %s: %w", short(digest), err)
|
||||
}
|
||||
m, err := delivery.DecodeManifest(e.Value())
|
||||
if err != nil {
|
||||
return delivery.Manifest{}, fmt.Errorf("the manifest %s is not read: %w", short(digest), err)
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// Watch calls wake with the module of every key the builder or the indexer writes from now on, until ctx ends: what
|
||||
// wakes a plan waiting on a key, with no timer (ADR 0501; the R4e context's A7). A bucket not there yet is watched once
|
||||
// it is: the watch looks again each time wake would have been called for the other bucket, and at every retry.
|
||||
func Watch(ctx context.Context, js jetstream.JetStream, wake func(module string)) error {
|
||||
for _, name := range []string{delivery.BuilderBuildsBucket, delivery.ModulesBucket} {
|
||||
if err := watchOne(ctx, js, name, wake); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func watchOne(ctx context.Context, js jetstream.JetStream, name string, wake func(module string)) error {
|
||||
kv, err := js.KeyValue(ctx, name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("the bucket %s: %w", name, err)
|
||||
}
|
||||
w, err := kv.WatchAll(ctx, jetstream.UpdatesOnly())
|
||||
if err != nil {
|
||||
return fmt.Errorf("watching %s: %w", name, err)
|
||||
}
|
||||
go func() {
|
||||
defer w.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case e, ok := <-w.Updates():
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if e == nil || e.Operation() != jetstream.KeyValuePut {
|
||||
continue
|
||||
}
|
||||
module, _, _ := strings.Cut(e.Key(), ".")
|
||||
wake(module)
|
||||
}
|
||||
}
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
func short(s string) string {
|
||||
s = strings.TrimPrefix(s, "src2:")
|
||||
if len(s) > 12 {
|
||||
return s[:12]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func firstLine(s string) string {
|
||||
line, _, _ := strings.Cut(strings.TrimSpace(s), "\n")
|
||||
return line
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
//go:build integration
|
||||
|
||||
package builderkeys
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
"github.com/novox/mesh-controller/internal/broker"
|
||||
"github.com/novox/mesh-controller/internal/testbus"
|
||||
)
|
||||
|
||||
func aBus(t *testing.T) jetstream.JetStream {
|
||||
t.Helper()
|
||||
conn, err := nats.Connect(testbus.URL(t))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(conn.Close)
|
||||
js, err := jetstream.New(conn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return js
|
||||
}
|
||||
|
||||
func put(t *testing.T, kv jetstream.KeyValue, key string, value []byte) {
|
||||
t.Helper()
|
||||
if _, err := kv.Put(context.Background(), key, value); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// The controller reads the builder's keys, the indexer's values by commit and its manifests (A1, A2), and a key the
|
||||
// builder writes wakes it with the key's module (A1's watch, no timer).
|
||||
func TestTheControllerReadsTheBuildersKeysAndTheIndexersValues(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||
defer cancel()
|
||||
js := aBus(t)
|
||||
r := Reader{JS: js}
|
||||
|
||||
// Nothing there yet: no bucket is no key and no error; no value is ErrNoValue.
|
||||
if k, err := r.Build(ctx, "x", fp); err != nil || k.Found {
|
||||
t.Fatalf("no bucket: %+v %v", k, err)
|
||||
}
|
||||
if _, err := r.ModuleAt(ctx, "x", "abcdef1"); !errors.Is(err, ErrNoValue) {
|
||||
t.Fatalf("no bucket: %v", err)
|
||||
}
|
||||
|
||||
builds, err := js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: delivery.BuilderBuildsBucket,
|
||||
History: delivery.BuilderBuildsHistory})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
modules, err := js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: delivery.ModulesBucket,
|
||||
History: delivery.ModulesHistory})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
manifests, err := js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: delivery.ManifestsBucket})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
woke := make(chan string, 8)
|
||||
if err := Watch(ctx, js, func(m string) { woke <- m }); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
raw := `{"module":"x","version":"1"}`
|
||||
digest := delivery.ManifestDigest([]byte(raw))
|
||||
value, err := delivery.EncodeManifest(delivery.Manifest{Module: "x", JSON: raw})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
put(t, manifests, digest, value)
|
||||
|
||||
key, _ := delivery.BuilderBuildKey("x", fp)
|
||||
building, _ := delivery.EncodeBuilderBuild(delivery.BuilderBuild{State: delivery.BuildBuilding, By: "ace"})
|
||||
put(t, builds, key, building)
|
||||
select {
|
||||
case m := <-woke:
|
||||
if m != "x" {
|
||||
t.Fatalf("woken for %q", m)
|
||||
}
|
||||
case <-ctx.Done():
|
||||
t.Fatal("the builder's write woke nothing")
|
||||
}
|
||||
done, _ := delivery.EncodeBuilderBuild(delivery.BuilderBuild{State: delivery.BuildDone, Manifest: digest,
|
||||
Artifacts: []delivery.BuiltArtifact{{Name: "a", Kind: "bundle", Digest: "sha256:1",
|
||||
Reference: "artifact-store://x/a/blobs/sha256:1"}}})
|
||||
put(t, builds, key, done)
|
||||
k, err := r.Build(ctx, "x", fp)
|
||||
if err != nil || !k.Done() || len(k.Unreferenced()) != 0 || k.Build.Artifacts[0].Reference == "" {
|
||||
t.Fatalf("the done key: %+v %v", k, err)
|
||||
}
|
||||
m, err := r.Manifest(ctx, k.Build.Manifest)
|
||||
if err != nil || m.JSON != raw {
|
||||
t.Fatalf("the manifest: %+v %v", m, err)
|
||||
}
|
||||
|
||||
other := "src2:" + strings.Repeat("e", 64)
|
||||
for _, v := range []struct{ commit, fp2 string }{{"1111111111", other}, {"2222222222", fp}, {"3333333333", other}} {
|
||||
raw, err := delivery.EncodeModule(delivery.Module{FP: "src1:" + strings.Repeat("a", 64), FP2: v.fp2,
|
||||
Manifest: digest, Repository: "novox/r", Commit: v.commit})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
put(t, modules, "x", raw)
|
||||
}
|
||||
at, err := r.ModuleAt(ctx, "x", "2222222222")
|
||||
if err != nil || at.FP2 != fp {
|
||||
t.Fatalf("FP2 at the plan's commit, overwritten by a later merge: %+v %v", at, err)
|
||||
}
|
||||
if now, err := r.ModuleAt(ctx, "x", ""); err != nil || now.Commit != "3333333333" {
|
||||
t.Fatalf("the value now: %+v %v", now, err)
|
||||
}
|
||||
if _, err := r.ModuleAt(ctx, "x", "9999999999"); !errors.Is(err, ErrNoValue) {
|
||||
t.Fatalf("a commit the indexer never wrote: %v", err)
|
||||
}
|
||||
put(t, modules, "broken", []byte("{not json"))
|
||||
all, unread, err := r.Modules(ctx)
|
||||
if err != nil || len(all) != 1 || all["x"].Commit != "3333333333" || unread["broken"] == nil {
|
||||
t.Fatalf("every module's value now: %+v %v %v", all, unread, err)
|
||||
}
|
||||
}
|
||||
|
||||
// The controller's account reaches the builder's and the indexer's buckets: it is granted the JetStream API whole.
|
||||
func TestTheControllersAccountReachesTheBuckets(t *testing.T) {
|
||||
perms, err := broker.PermissionsFor(broker.Principal{Kind: broker.KindController, PasswordHash: "x"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !slices.Contains(perms.Publish, "$JS.API.>") || !slices.Contains(perms.Subscribe, "$JS.API.>") {
|
||||
t.Fatalf("the controller is not granted the JetStream API: %v / %v", perms.Publish, perms.Subscribe)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package builderkeys
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
const fp = "src2:" + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
||||
|
||||
func moduleValue(t *testing.T, commit, fp2 string) []byte {
|
||||
t.Helper()
|
||||
raw, err := delivery.EncodeModule(delivery.Module{FP: "src1:" + strings.Repeat("a", 64), FP2: fp2,
|
||||
Manifest: strings.Repeat("b", 64), Repository: "novox/mesh-catalog", Commit: commit})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
// A plan's module is the indexer's value written for the merge the plan carries, not a later merge's (ADR 0501 §2).
|
||||
func TestPickAtIsTheNewestValueOfTheCommit(t *testing.T) {
|
||||
other := "src2:" + strings.Repeat("c", 64)
|
||||
values := [][]byte{
|
||||
moduleValue(t, "1111111111", other),
|
||||
moduleValue(t, "2222222222", other),
|
||||
moduleValue(t, "2222222222", fp), // the same merge, computed again when a toolchain appeared
|
||||
moduleValue(t, "3333333333", other),
|
||||
}
|
||||
m, err := pickAt("x", "2222222222", values)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if m.FP2 != fp {
|
||||
t.Fatalf("FP2 at 2222222222 is %s, want the newest of that commit %s", m.FP2, fp)
|
||||
}
|
||||
// An abbreviated commit names the same one.
|
||||
if m, err := pickAt("x", "2222222", values); err != nil || m.FP2 != fp {
|
||||
t.Fatalf("an abbreviated commit: %v %v", m.FP2, err)
|
||||
}
|
||||
// No commit is the newest value.
|
||||
if m, err := pickAt("x", "", values); err != nil || m.Commit != "3333333333" {
|
||||
t.Fatalf("the newest: %v %v", m.Commit, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPickAtSaysNoValueOfACommitNotWritten(t *testing.T) {
|
||||
_, err := pickAt("x", "4444444444", [][]byte{moduleValue(t, "1111111111", fp), []byte("{not json")})
|
||||
if !errors.Is(err, ErrNoValue) {
|
||||
t.Fatalf("want ErrNoValue, got %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "x at 4444444444") || !strings.Contains(err.Error(), "not read") {
|
||||
t.Fatalf("the words name neither the module and commit nor the unread value: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A done key with references is delivered from; a done key with an artifact without one is waited for, said
|
||||
// (ADR 0498 §8); a failed key says the builder's own words.
|
||||
func TestAKeySaysWhatItIs(t *testing.T) {
|
||||
missing := Key{Module: "x", Fingerprint: fp}
|
||||
if missing.Done() || missing.Failed() || !strings.Contains(missing.Says(), "no build of x") {
|
||||
t.Fatalf("a missing key: %q", missing.Says())
|
||||
}
|
||||
done := Key{Module: "x", Fingerprint: fp, Found: true, Build: delivery.BuilderBuild{State: delivery.BuildDone,
|
||||
Artifacts: []delivery.BuiltArtifact{
|
||||
{Name: "a", Digest: "sha256:1", Reference: "artifact-store://x/a/blobs/sha256:1"},
|
||||
{Name: "img", Kind: "image", Digest: "sha256:2"},
|
||||
}}}
|
||||
if !done.Done() || len(done.Unreferenced()) != 1 || done.Unreferenced()[0] != "img" {
|
||||
t.Fatalf("unreferenced: %v", done.Unreferenced())
|
||||
}
|
||||
if !strings.Contains(done.Says(), "waits for the reference of img") {
|
||||
t.Fatalf("a done key without a reference: %q", done.Says())
|
||||
}
|
||||
failed := Key{Module: "x", Fingerprint: fp, Found: true, Build: delivery.BuilderBuild{State: delivery.BuildFailed,
|
||||
Error: "digests differ: ace sha256:1, g14 sha256:2\nmore"}}
|
||||
if !failed.Failed() || !strings.HasSuffix(failed.Says(), "failed: digests differ: ace sha256:1, g14 sha256:2") {
|
||||
t.Fatalf("a failed key: %q", failed.Says())
|
||||
}
|
||||
building := Key{Module: "x", Fingerprint: fp, Found: true, Build: delivery.BuilderBuild{State: delivery.BuildBuilding, By: "ace"}}
|
||||
if building.Done() || !strings.Contains(building.Says(), "building on ace") {
|
||||
t.Fatalf("a building key: %q", building.Says())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
// Package buildreads is which repositories other than a module's own its build reads source from, as its manifest
|
||||
// names them (novox/hq 04-ISSUES/131): the artifacts' `context` repositories. One rule for build-agent, which says it
|
||||
// in a build's outcome, and for the controller, which reads it from a done key's manifest (R4e, issue 1851): it lives
|
||||
// here, not in internal/builder, so it outlasts build-agent (R4g).
|
||||
package buildreads
|
||||
|
||||
import (
|
||||
"sort"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
|
||||
// Of is every repository other than the module's own that this build's recipes read source from, each once and in a
|
||||
// fixed order, so two builds of one commit report the same thing the same way.
|
||||
func Of(manifest catalogue.Manifest) []catalogue.ArtifactContext {
|
||||
if manifest.Build == nil {
|
||||
return nil
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
var out []catalogue.ArtifactContext
|
||||
for _, a := range manifest.Build.Artifacts {
|
||||
if a.Context == nil || a.Context.Repository == "" {
|
||||
continue
|
||||
}
|
||||
key := a.Context.Repository + "#" + a.Context.Ref
|
||||
if seen[key] {
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
out = append(out, *a.Context)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
if out[i].Repository != out[j].Repository {
|
||||
return out[i].Repository < out[j].Repository
|
||||
}
|
||||
return out[i].Ref < out[j].Ref
|
||||
})
|
||||
return out
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package inventory
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"git.novox.be/novox/mesh-sdk/go/announce"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
@@ -16,6 +17,10 @@ import (
|
||||
// apart for the reason that package keeps its own types: a permission must be a function of what a
|
||||
// module declared, and a query that decided anything would be a second place authority came from.
|
||||
|
||||
// Announcements reads the last announcement of each module (novox/hq ADR 0495, issue 1790), set by the controller that
|
||||
// reaches the bus; nil, nothing is read and every read is granted from `reads`.
|
||||
var Announcements func(ctx context.Context) (map[string]announce.States, error)
|
||||
|
||||
// BusRecords is every fact the composer needs about who may reach the bus.
|
||||
//
|
||||
// **A module's authority comes from the manifest, not from the assignment.** The assignment says
|
||||
@@ -110,6 +115,29 @@ func (i *Inventory) BusRecords(ctx context.Context) (broker.Records, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// **Who may read a state is what its owner announced** (novox/hq ADR 0495 clause 3, issue 1790): a read the owner's
|
||||
// announcement does not make readable is granted nothing, and said; an owner that announced nothing yet is read as
|
||||
// before. Announcements that cannot be read are said, and every read is granted as before: the bus's users are
|
||||
// composed either way.
|
||||
if Announcements != nil {
|
||||
announced, err := Announcements(ctx)
|
||||
if err != nil {
|
||||
fmt.Printf("the states the modules announced cannot be read, so every read is granted from `reads` as before: %v\n", err)
|
||||
} else {
|
||||
out.Announced = announced
|
||||
for node, ds := range out.Assigned {
|
||||
for k := range ds {
|
||||
granted, refused := broker.ReadsAnnounced(ds[k].Reads, announced)
|
||||
ds[k].Reads, ds[k].ReadsRefused = granted, refused
|
||||
for _, r := range refused {
|
||||
fmt.Printf("%s on %s reads %s, which its owner does not announce readable: not granted\n",
|
||||
ds[k].Module, node, r)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enrolling, err := i.NodesWithALiveToken(ctx)
|
||||
if err != nil {
|
||||
return broker.Records{}, err
|
||||
|
||||
@@ -388,6 +388,15 @@ type PlanModule struct {
|
||||
// data, or "waits: …" with the recreate words, the data change named, the person's or the bus's hold, or why it
|
||||
// could not be judged.
|
||||
Recorded map[string]string `json:"recorded,omitempty"`
|
||||
// Key says the walk takes this module from the builder's done key and asked no build (novox/hq ADR 0501, ADR 0453
|
||||
// §5; issue 1851): from R4e every module but the builder's own. Its outcome is the key's, never a build-agent outcome.
|
||||
Key bool `json:"key,omitempty"`
|
||||
// KeyAt is the commit of its repository whose indexer's trunk value names the fingerprint taken (ADR 0501 §2); empty
|
||||
// for a module of a repository the walk carries no commit of (a dependent through a base), taken at the value now.
|
||||
KeyAt string `json:"key_at,omitempty"`
|
||||
// Fingerprint is the fingerprint of the new form (`src2:`) the walk takes the module at, read from that trunk value;
|
||||
// empty until the indexer has written it.
|
||||
Fingerprint string `json:"fingerprint,omitempty"`
|
||||
}
|
||||
|
||||
// PlanGate is one module's rollout record at its gate (to-be 45 §8): the component, the first machine,
|
||||
|
||||
@@ -230,48 +230,3 @@ func TestASeatsVerbGainsTheArgumentsTheBinaryNames(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// **A stored row's build verb takes on and behind** (novox/hq ADR 0246: arguments added to a verb are
|
||||
// additive): a row seeded before them described build with a repository it required, so the console
|
||||
// would refuse both and a call naming neither would be refused for want of a repository. Re-seeding
|
||||
// gives the row this binary's build.
|
||||
func TestAStoredRowsBuildVerbTakesOnAndBehind(t *testing.T) {
|
||||
inv := ForTest(t)
|
||||
ctx := t.Context()
|
||||
if _, err := inv.SeedSeats(ctx, catalogue.DefaultSeats()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
old := `[{"name":"build","description":"Have the build machine build a repository.","input":{"type":"object",
|
||||
"properties":{"repository":{"type":"string"},"path":{"type":"string"},"ref":{"type":"string"}},
|
||||
"required":["repository"]}}]`
|
||||
if _, err := inv.store.Pool().Exec(ctx, `update seat set serves = $1 where name = $2`,
|
||||
[]byte(old), catalogue.ControllerSeatName); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := inv.SeedSeats(ctx, catalogue.DefaultSeats()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
seats, err := inv.Seats(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var build catalogue.Verb
|
||||
for _, s := range seats {
|
||||
if s.Name == catalogue.ControllerSeatName {
|
||||
for _, v := range s.Serves {
|
||||
if v.Name == "build" {
|
||||
build = v
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
props, _ := build.Input["properties"].(map[string]any)
|
||||
for _, arg := range []string{"repository", "path", "ref", "on", "behind"} {
|
||||
if _, has := props[arg]; !has {
|
||||
t.Errorf("build in the row does not take %s: %v", arg, props)
|
||||
}
|
||||
}
|
||||
if req, _ := build.Input["required"].([]any); len(req) != 0 {
|
||||
t.Errorf("build in the row still requires %v, so on and behind alone are refused", req)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ package inventory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/chain"
|
||||
)
|
||||
@@ -43,6 +45,11 @@ func (i *Inventory) sourceRows(ctx context.Context, module string) (map[string][
|
||||
|
||||
// standing is, of a module's builds newest first, the index of the build that stands for the build at
|
||||
// `from`: the oldest of the unbroken run of builds behind it with its source fingerprint.
|
||||
//
|
||||
// **Across the switch to the new form** (novox/hq ADR 0453 §6, ADR 0499; R4e A9): a build of the new form (`src2:`, taken
|
||||
// from the builder's key) continues the run of the build before it of the old form (`src1:` or none, build-agent's)
|
||||
// when every artifact of the two has the same digest: the fingerprints cannot be compared, the programs can, and equal
|
||||
// digests are no move. Past it the run goes on by the old fingerprint.
|
||||
func standing(builds []sourceRow, from int) int {
|
||||
at := from
|
||||
fp := builds[from].fingerprint
|
||||
@@ -50,14 +57,53 @@ func standing(builds []sourceRow, from int) int {
|
||||
return at
|
||||
}
|
||||
for j := from + 1; j < len(builds); j++ {
|
||||
if builds[j].fingerprint != fp || builds[j].failedGate {
|
||||
if builds[j].failedGate {
|
||||
break
|
||||
}
|
||||
if builds[j].fingerprint != fp {
|
||||
if !newForm(fp) || newForm(builds[j].fingerprint) || !sameDigests(builds[at].made, builds[j].made) {
|
||||
break
|
||||
}
|
||||
fp = builds[j].fingerprint
|
||||
}
|
||||
at = j
|
||||
}
|
||||
return at
|
||||
}
|
||||
|
||||
// newForm says a source fingerprint is of the new form (ADR 0453 §6).
|
||||
func newForm(fp string) bool { return strings.HasPrefix(fp, "src2:") }
|
||||
|
||||
// sameDigests says two builds' artifacts, as recorded (JSON of []Artifact), are the same programs: the same names, each
|
||||
// with the same digest, wherever each was pushed. A build naming an artifact with no digest is never the same.
|
||||
func sameDigests(a, b []byte) bool {
|
||||
digests := func(raw []byte) map[string]string {
|
||||
var made []Artifact
|
||||
if json.Unmarshal(raw, &made) != nil || len(made) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := map[string]string{}
|
||||
for _, m := range made {
|
||||
d := digestIn(m.Reference)
|
||||
if d == "" {
|
||||
return nil
|
||||
}
|
||||
out[m.Name] = d
|
||||
}
|
||||
return out
|
||||
}
|
||||
da, db := digests(a), digests(b)
|
||||
if da == nil || db == nil || len(da) != len(db) {
|
||||
return false
|
||||
}
|
||||
for name, d := range da {
|
||||
if db[name] != d {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// StandingBuild is the build that stands for a module's build (novox/hq issue 280): the oldest build of
|
||||
// the unbroken run of builds with its source fingerprint, at or before it — itself when its fingerprint
|
||||
// is empty or it starts the run. Answers its id and the manifest it was recorded with; empty when the
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
package inventory
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func madeOf(t *testing.T, refs map[string]string) []byte {
|
||||
t.Helper()
|
||||
var made []Artifact
|
||||
for name, ref := range refs {
|
||||
made = append(made, Artifact{Name: name, Kind: "bundle", Reference: ref})
|
||||
}
|
||||
raw, err := json.Marshal(made)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
// R4e A9 (ADR 0453 §6, ADR 0499): at the switch, a build taken from the builder's key (`src2:`) whose every artifact has
|
||||
// the digest of the build-agent build before it (`src1:`) is no move: it stands on that build, wherever each was pushed.
|
||||
// One digest that differs is a move, as is a build of the old form after one of the new.
|
||||
func TestAKeysBuildWithTheOldBuildsDigestsIsNoMove(t *testing.T) {
|
||||
const d1, d2 = "sha256:1111111111111111111111111111111111111111111111111111111111111111",
|
||||
"sha256:2222222222222222222222222222222222222222222222222222222222222222"
|
||||
old := sourceRow{id: "build-1", fingerprint: "src1:aa", made: madeOf(t, map[string]string{
|
||||
"tools": "artifact-store://x/tools/blobs/" + d1, "img": "artifact-store://x/img@" + d2})}
|
||||
key := sourceRow{id: "key-bb-1", fingerprint: "src2:bb", made: madeOf(t, map[string]string{
|
||||
"tools": "artifact-store://x/tools/blobs/" + d1, "img": "registry.internal:5000/x/img@" + d2})}
|
||||
if at := standing([]sourceRow{key, old}, 0); at != 1 {
|
||||
t.Fatalf("the key's build with the same digests stands on %d, want the old build", at)
|
||||
}
|
||||
moved := key
|
||||
moved.made = madeOf(t, map[string]string{"tools": "artifact-store://x/tools/blobs/" + d2, "img": "x/img@" + d2})
|
||||
if at := standing([]sourceRow{moved, old}, 0); at != 0 {
|
||||
t.Fatalf("a key's build with another digest stands on %d, want itself (a move)", at)
|
||||
}
|
||||
// Two key builds of one fingerprint are one run, as two builds of one fingerprint always were.
|
||||
again := key
|
||||
again.id = "key-bb-2"
|
||||
if at := standing([]sourceRow{again, key, old}, 0); at != 2 {
|
||||
t.Fatalf("a run of one new fingerprint on an old build of the same digests stands on %d", at)
|
||||
}
|
||||
// An old build after a new one never continues the new one's run.
|
||||
if at := standing([]sourceRow{old, key}, 0); at != 0 {
|
||||
t.Fatalf("an old build continues a new one's run: %d", at)
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,9 @@ var mayNameAMirror = []string{
|
||||
// The grant of a machine's own keys of the assignments mirror to its provisioner client (design 57 P3).
|
||||
"internal/broker/provisioner.go",
|
||||
"internal/broker/writers.go",
|
||||
// The controller's announcement of what it keeps, the mirrors readable (novox/hq ADR 0495, issue 1790): it names them
|
||||
// to announce them, never reads them.
|
||||
"internal/broker/announced.go",
|
||||
}
|
||||
|
||||
// The packages whose names stand for a mirror, by import path, and the names in each.
|
||||
@@ -38,7 +41,7 @@ const (
|
||||
|
||||
var mirrorNames = map[string][]string{
|
||||
brokerPath: {"AssignmentsBucket", "DeclaredConfigBucket", "MirrorAssignments", "MirrorDeclaredConfig",
|
||||
"ControllerMirrors"},
|
||||
"controllerReadable"},
|
||||
recordmirrorPath: {"Assignments", "DeclaredConfig", "Mirrors"},
|
||||
}
|
||||
|
||||
@@ -54,8 +57,10 @@ var mirrorNames = map[string][]string{
|
||||
// allowed file. Those are deliberate evasions rather than ordinary code; a reviewer judges them.
|
||||
func TestOnlyTheAuditAndAMirrorsWriterReadAMirror(t *testing.T) {
|
||||
var buckets []string
|
||||
for _, m := range broker.ControllerMirrors() {
|
||||
buckets = append(buckets, broker.BucketName(broker.ControllerSeat, m))
|
||||
for _, s := range broker.ControllerAnnouncement("").States {
|
||||
if s.Readable {
|
||||
buckets = append(buckets, broker.BucketName(broker.ControllerSeat, s.Name))
|
||||
}
|
||||
}
|
||||
root := filepath.Join("..", "..")
|
||||
found := mirrorReaders(t, root, buckets)
|
||||
|
||||
Reference in New Issue
Block a user