diff --git a/contracts/go/clients/containerimageregistryclient/client.go b/contracts/go/clients/containerimageregistryclient/client.go new file mode 100644 index 00000000..3616faf7 --- /dev/null +++ b/contracts/go/clients/containerimageregistryclient/client.go @@ -0,0 +1,63 @@ +// Package containerimageregistryclient is the container-image-registry seat's client (novox/hq ADR 0627 §1, §2; ADR 0611, design 51 +// *Publishing*): it publishes a file in parts to the seat's holder. `publish` opens the transfer naming every part, +// the holder answers the parts it lacks, and only those travel by `publish-part`, in chunks a bus message carries; a +// cut transfer goes on from what the holder answers when the same transfer is opened again. The sending in parts is +// mesh-sdk's seat-neutral runtime (parts); this client binds it to this seat's verbs and types alone. The call is the +// caller's own (a bundle's stdio.Ask, a process's call of a seat verb), so it holds no transport. +package containerimageregistryclient + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + + seat "git.novox.be/novox/mesh-delivery/contracts/go/seats/containerimageregistry" + "git.novox.be/novox/mesh-delivery/sdk/parts" +) + +// Call asks one verb of the seat, `seat:.`, with a body, and answers its JSON. +type Call func(key string, body any) (json.RawMessage, error) + +// Sent is what a Publish did: the transfer, whether the holder held its digest already, the parts it lacked and were +// sent, and the chunks they took. +type Sent struct { + Transfer string + parts.Sent +} + +// Publish opens (or opens again) the transfer in on the seat and sends the parts the holder answers it lacks, each in +// chunks of at most parts.MaxChunk, in sequence, the last marked. in.Parts is written from parts. A part whose bytes +// do not hash to its digest is refused before anything of it is sent; a part the holder does not name is never read. +// Its outcome, published or refused, is the holder's event, which the caller waits for. +func Publish(ctx context.Context, call Call, in seat.PublishInput, ps []parts.Part) (Sent, error) { + open := func(_ context.Context, ps []parts.Part) (parts.Opened, error) { + in.Parts = make([]seat.Part, 0, len(ps)) + for _, p := range ps { + in.Parts = append(in.Parts, seat.Part{Digest: p.Digest, Size: p.Size}) + } + raw, err := call("seat:"+seat.Seat+"."+seat.VerbPublish, in) + if err != nil { + return parts.Opened{}, fmt.Errorf("%s's publish of %s: %w", seat.Seat, in.Name, err) + } + var a seat.PublishAnswer + if err := json.Unmarshal(raw, &a); err != nil { + return parts.Opened{}, fmt.Errorf("%s's answer to publish: %w", seat.Seat, err) + } + return parts.Opened{Held: a.Held, Lacks: a.Lacks}, nil + } + chunk := func(_ context.Context, part string, sequence int, last bool, data []byte) error { + raw, err := call("seat:"+seat.Seat+"."+seat.VerbPublishPart, seat.PublishPartInput{Transfer: in.Transfer, Part: part, + Sequence: int64(sequence), Last: last, Data: base64.StdEncoding.EncodeToString(data)}) + if err != nil { + return fmt.Errorf("%s's publish-part: %w", seat.Seat, err) + } + var a seat.PublishPartAnswer + if err := json.Unmarshal(raw, &a); err != nil { + return fmt.Errorf("%s's answer to publish-part: %w", seat.Seat, err) + } + return nil + } + sent, err := parts.Send(ctx, open, chunk, ps) + return Sent{Transfer: in.Transfer, Sent: sent}, err +} diff --git a/contracts/go/clients/containerimageregistryclient/client_test.go b/contracts/go/clients/containerimageregistryclient/client_test.go new file mode 100644 index 00000000..7b77d5f5 --- /dev/null +++ b/contracts/go/clients/containerimageregistryclient/client_test.go @@ -0,0 +1,57 @@ +package containerimageregistryclient + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "io" + "testing" + + seat "git.novox.be/novox/mesh-delivery/contracts/go/seats/containerimageregistry" + "git.novox.be/novox/mesh-delivery/sdk/parts" +) + +// The client publishes over its own seat's verbs alone (ADR 0627 §1): publish names every part, and only the part the +// holder lacks travels by publish-part, its bytes whole, the last chunk marked. +func TestPublishTalksToItsOwnSeat(t *testing.T) { + held, lacked := []byte("held"), []byte("lacked") + part := func(b []byte) parts.Part { + sum := sha256.Sum256(b) + return parts.Part{Digest: "sha256:" + hex.EncodeToString(sum[:]), Size: int64(len(b)), + Open: func() (io.ReadCloser, error) { return io.NopCloser(bytes.NewReader(b)), nil }} + } + ps := []parts.Part{part(held), part(lacked)} + var asked []string + var got []byte + call := func(key string, body any) (json.RawMessage, error) { + asked = append(asked, key) + raw, _ := json.Marshal(body) + switch key { + case "seat:container-image-registry.publish": + var in seat.PublishInput + _ = json.Unmarshal(raw, &in) + if len(in.Parts) != 2 { + t.Errorf("publish names %d parts, want 2", len(in.Parts)) + } + return json.Marshal(seat.PublishAnswer{Transfer: in.Transfer, Lacks: []string{ps[1].Digest}}) + case "seat:container-image-registry.publish-part": + var in seat.PublishPartInput + _ = json.Unmarshal(raw, &in) + data, _ := base64.StdEncoding.DecodeString(in.Data) + if in.Part != ps[1].Digest || !in.Last || in.Transfer != "t1" { + t.Errorf("publish-part %+v", in) + } + got = append(got, data...) + return json.Marshal(seat.PublishPartAnswer{Transfer: in.Transfer, Part: in.Part}) + } + t.Fatalf("asked %s", key) + return nil, nil + } + sent, err := Publish(context.Background(), call, seat.PublishInput{Transfer: "t1", Name: "x"}, ps) + if err != nil || sent.Transfer != "t1" || sent.Chunks != 1 || len(sent.Parts) != 1 || !bytes.Equal(got, lacked) || len(asked) != 2 { + t.Errorf("sent %+v, %q arrived, asked %v, %v", sent, got, asked, err) + } +} diff --git a/contracts/go/clients/containerimageregistryclient/go.mod b/contracts/go/clients/containerimageregistryclient/go.mod new file mode 100644 index 00000000..17316125 --- /dev/null +++ b/contracts/go/clients/containerimageregistryclient/go.mod @@ -0,0 +1,18 @@ +module git.novox.be/novox/mesh-delivery/contracts/go/clients/containerimageregistryclient + +go 1.26.0 + +require ( + git.novox.be/novox/mesh-delivery/contracts/go/seats/containerimageregistry v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-delivery/sdk v0.0.0-00010101000000-000000000000 +) + +require ( + git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000 // indirect + github.com/klauspost/compress v1.20.1 // indirect + github.com/nats-io/nats.go v1.54.0 // indirect + github.com/nats-io/nkeys v0.4.16 // indirect + github.com/nats-io/nuid v1.0.1 // indirect + golang.org/x/crypto v0.57.0 // indirect + golang.org/x/sys v0.48.0 // indirect +) diff --git a/contracts/go/clients/containerimageregistryclient/go.sum b/contracts/go/clients/containerimageregistryclient/go.sum new file mode 100644 index 00000000..0daef11f --- /dev/null +++ b/contracts/go/clients/containerimageregistryclient/go.sum @@ -0,0 +1,12 @@ +github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ= +github.com/klauspost/compress v1.20.1/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= +github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA= +github.com/nats-io/nats.go v1.54.0/go.mod h1:y+DZoD1oBOYfZTU681eTUiUjI0vbqYGixNVFHcjHJ0k= +github.com/nats-io/nkeys v0.4.16 h1:rd5oAuLOb8mnAycB0xleuEBNS1pVVnN0fv/FF34Eypg= +github.com/nats-io/nkeys v0.4.16/go.mod h1:llLgWoI0o4z/Q57q2R1kHfmocyhGV6VG/U18Glg1Afs= +github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= +github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= +golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= +golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= +golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= +golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= diff --git a/contracts/go/clients/containerimageregistryclient/reference/reference.go b/contracts/go/clients/containerimageregistryclient/reference/reference.go new file mode 100644 index 00000000..80cdaf46 --- /dev/null +++ b/contracts/go/clients/containerimageregistryclient/reference/reference.go @@ -0,0 +1,116 @@ +// Package reference holds the form of an artifact's reference once a registry seat published it: `//@` +// (novox/hq ADR 0605 §9), and the older `artifact-store://` form read beside it. It is the rule the holders and callers of the two +// registry seats that front the artifact store share (the seats' client, novox/hq ADR 0625 §4, ADR 0634 §2); it names no seat itself (ADR 0634 §4, ADR 0635 §4): the +// caller gives the seats' names from their contracts (Seats), so a package here cannot drift from a contract. +package reference + +import "strings" + +// ArtifactStoreScheme marks a reference to something in the mesh's artifact store, kept without the store's address. +const ArtifactStoreScheme = "artifact-store://" + +// UpstreamPrefix opens the holder's own name for an upstream copy it keeps: `upstream//@`, after +// `/` in the seat's form and after `artifact-store://` in the older one. +const UpstreamPrefix = "upstream/" + +// Seats are the names of the two registry seats a reference of the registry form names first: Image the seat that fronts +// images and upstream copies (container-image-registry), Object the seat that fronts bundles and archives +// (oci-artifact-registry). The caller reads them from the seats' contracts. +type Seats struct{ Image, Object string } + +// All is the two names. +func (s Seats) All() []string { return []string{s.Image, s.Object} } + +// Recorded is a reference as the mesh records it: the artifact store's address, if the builder wrote one, taken off. +func (s Seats) Recorded(reference string) string { + if _, _, seat := s.InRegistrySeat(reference); seat || strings.HasPrefix(reference, ArtifactStoreScheme) { + return reference + } + if rest, isURL := strings.CutPrefix(reference, "http://"); isURL { + if _, path, ok := strings.Cut(rest, "/v2/"); ok && strings.Contains(path, "/blobs/") { + return ArtifactStoreScheme + path + } + return reference + } + host, path, ok := strings.Cut(reference, "/") + if !ok || !isRegistryHost(host) || !strings.Contains(path, "@sha256:") { + return reference + } + return ArtifactStoreScheme + path +} + +// isRegistryHost is the runtime's own rule for reading the first component of a reference as a registry rather than a +// namespace: it has a dot or a port in it, or it is localhost. +func isRegistryHost(component string) bool { + return component == "localhost" || strings.ContainsAny(component, ".:") +} + +// InArtifactStore reports whether a reference is a kept one, and what it names there: `/@` for an +// image, `//blobs/` for a bundle or an archive. It reads both forms (ADR 0605 §9). +func (s Seats) InArtifactStore(reference string) (path string, kept bool) { + if seat, rest, ok := s.InRegistrySeat(reference); ok { + if seat == s.Object { + name, digest, _ := strings.Cut(rest, "@") + return name + "/blobs/" + digest, true + } + return rest, true + } + return strings.CutPrefix(reference, ArtifactStoreScheme) +} + +// SeatReference is the reference a build names an image, bundle, archive or upstream copy by once it published it through +// a registry seat: `//@sha256:`, as InRegistrySeat reads it back. +func SeatReference(seat, module, artifact, digest string) string { + return seat + "/" + module + "/" + artifact + "@" + digest +} + +// InRegistrySeat reads a reference in the form a registry seat answers: the seat it came through and the rest. A seat's +// name holds no dot or port, so no image of an outside registry reads as one, and a kept `artifact-store://` reference is +// not one. It also reads the holder's own naming of an upstream copy through the image seat, whose rest InUpstream reads. +func (s Seats) InRegistrySeat(reference string) (seat, path string, ok bool) { + first, rest, found := strings.Cut(reference, "/") + if !found || !strings.Contains(rest, "@sha256:") { + return "", "", false + } + if _, _, upstream := InUpstream(rest); upstream && first == s.Image { + return first, rest, true + } + if strings.Count(rest, "/") != 1 { + return "", "", false + } + for _, name := range s.All() { + if first == name { + return name, rest, true + } + } + return "", "", false +} + +// InUpstream reads a path in the holder's naming of an upstream copy, `upstream//@sha256:`: the +// upstream registry host and the image's path there, with its digest. +func InUpstream(path string) (host, image string, ok bool) { + rest, found := strings.CutPrefix(path, UpstreamPrefix) + if !found { + return "", "", false + } + host, image, found = strings.Cut(rest, "/") + name, digest, pinned := strings.Cut(image, "@") + if !found || !isRegistryHost(host) || name == "" || strings.HasPrefix(name, "/") || strings.HasSuffix(name, "/") || + strings.Contains(name, "//") || !pinned || !strings.HasPrefix(digest, "sha256:") || strings.Contains(digest, "/") { + return "", "", false + } + return host, image, true +} + +// Routed is a kept reference as a machine fetches it, through the artifact store at `address` (host:port). A reference +// that is not a kept one is what it was. +func (s Seats) Routed(reference, address string) string { + path, kept := s.InArtifactStore(reference) + if !kept { + return reference + } + if strings.Contains(path, "/blobs/") { + return "http://" + address + "/v2/" + path + } + return address + "/" + path +} diff --git a/contracts/go/clients/containerimageregistryclient/reference/reference_test.go b/contracts/go/clients/containerimageregistryclient/reference/reference_test.go new file mode 100644 index 00000000..a76140d8 --- /dev/null +++ b/contracts/go/clients/containerimageregistryclient/reference/reference_test.go @@ -0,0 +1,69 @@ +package reference + +import ( + "strings" + "testing" +) + +var digest = "sha256:" + strings.Repeat("d", 64) + +var seats = Seats{Image: "container-image-registry", Object: "object-registry"} + +func TestAReferenceIsRecordedWithoutTheStoresAddress(t *testing.T) { + cases := map[string]string{ + "anchor.internal:5100/gitea/server@" + digest: ArtifactStoreScheme + "gitea/server@" + digest, + "localhost:5000/gitea/server@" + digest: ArtifactStoreScheme + "gitea/server@" + digest, + "http://anchor.internal:5100/v2/gitea/config/blobs/" + digest: ArtifactStoreScheme + "gitea/config/blobs/" + digest, + ArtifactStoreScheme + "gitea/server@" + digest: ArtifactStoreScheme + "gitea/server@" + digest, + digest: digest, + "@novox/sdk@1.2.3": "@novox/sdk@1.2.3", + "gitea/gitea@" + digest: "gitea/gitea@" + digest, + "https://registry.example/v2/gitea/config/blobs/" + digest: "https://registry.example/v2/gitea/config/blobs/" + digest, + // The form a registry seat answers is recorded as it is (ADR 0605 §9). + "container-image-registry/gitea/server@" + digest: "container-image-registry/gitea/server@" + digest, + "object-registry/gitea/config@" + digest: "object-registry/gitea/config@" + digest, + } + for announced, want := range cases { + if got := seats.Recorded(announced); got != want { + t.Errorf("Recorded(%q) = %q, want %q", announced, got, want) + } + } + // And every reader reads both forms: a kept reference and the seat's are fetched from the same place. + for reference, want := range map[string]string{ + ArtifactStoreScheme + "gitea/server@" + digest: "10.0.0.1:5000/gitea/server@" + digest, + "container-image-registry/gitea/server@" + digest: "10.0.0.1:5000/gitea/server@" + digest, + ArtifactStoreScheme + "gitea/config/blobs/" + digest: "http://10.0.0.1:5000/v2/gitea/config/blobs/" + digest, + "object-registry/gitea/config@" + digest: "http://10.0.0.1:5000/v2/gitea/config/blobs/" + digest, + "registry.example/gitea/server@" + digest: "registry.example/gitea/server@" + digest, + "mesh-artifact-store/gitea/server@" + digest: "mesh-artifact-store/gitea/server@" + digest, + // An upstream copy under the holder's own naming, in both forms. + "container-image-registry/upstream/quay.io:443/org/app@" + digest: "10.0.0.1:5000/upstream/quay.io:443/org/app@" + digest, + ArtifactStoreScheme + "upstream/quay.io:443/org/app@" + digest: "10.0.0.1:5000/upstream/quay.io:443/org/app@" + digest, + } { + if got := seats.Routed(reference, "10.0.0.1:5000"); got != want { + t.Errorf("Routed(%q) = %q, want %q", reference, got, want) + } + } + // A build module names what it published in the seat's form, and reads it back as the seat it came through. + for _, seat := range seats.All() { + ref := SeatReference(seat, "gitea", "config", digest) + if got, path, ok := seats.InRegistrySeat(ref); !ok || got != seat || path != "gitea/config@"+digest { + t.Errorf("SeatReference(%s) = %q, read back as %q %q %v", seat, ref, got, path, ok) + } + } + // The holder's naming of an upstream copy reads through the image seat only, and InUpstream reads its host and path. + for reference, want := range map[string]bool{ + "container-image-registry/upstream/quay.io:443/org/app@" + digest: true, + "object-registry/upstream/quay.io:443/org/app@" + digest: false, + "container-image-registry/upstream/org/app@" + digest: false, + "container-image-registry/upstream/quay.io/app": false, + } { + _, path, ok := seats.InRegistrySeat(reference) + if ok != want { + t.Errorf("InRegistrySeat(%q) read %v, want %v", reference, ok, want) + } + if host, image, up := InUpstream(path); ok && (!up || host != "quay.io:443" || image != "org/app@"+digest) { + t.Errorf("InUpstream(%q) = %q %q %v", path, host, image, up) + } + } +} diff --git a/contracts/go/clients/gopackageregistryclient/client.go b/contracts/go/clients/gopackageregistryclient/client.go new file mode 100644 index 00000000..39cdb75f --- /dev/null +++ b/contracts/go/clients/gopackageregistryclient/client.go @@ -0,0 +1,63 @@ +// Package gopackageregistryclient is the go-package-registry seat's client (novox/hq ADR 0627 §1, §2; ADR 0611, design 51 +// *Publishing*): it publishes a file in parts to the seat's holder. `publish` opens the transfer naming every part, +// the holder answers the parts it lacks, and only those travel by `publish-part`, in chunks a bus message carries; a +// cut transfer goes on from what the holder answers when the same transfer is opened again. The sending in parts is +// mesh-sdk's seat-neutral runtime (parts); this client binds it to this seat's verbs and types alone. The call is the +// caller's own (a bundle's stdio.Ask, a process's call of a seat verb), so it holds no transport. +package gopackageregistryclient + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + + seat "git.novox.be/novox/mesh-delivery/contracts/go/seats/gopackageregistry" + "git.novox.be/novox/mesh-delivery/sdk/parts" +) + +// Call asks one verb of the seat, `seat:.`, with a body, and answers its JSON. +type Call func(key string, body any) (json.RawMessage, error) + +// Sent is what a Publish did: the transfer, whether the holder held its digest already, the parts it lacked and were +// sent, and the chunks they took. +type Sent struct { + Transfer string + parts.Sent +} + +// Publish opens (or opens again) the transfer in on the seat and sends the parts the holder answers it lacks, each in +// chunks of at most parts.MaxChunk, in sequence, the last marked. in.Parts is written from parts. A part whose bytes +// do not hash to its digest is refused before anything of it is sent; a part the holder does not name is never read. +// Its outcome, published or refused, is the holder's event, which the caller waits for. +func Publish(ctx context.Context, call Call, in seat.PublishInput, ps []parts.Part) (Sent, error) { + open := func(_ context.Context, ps []parts.Part) (parts.Opened, error) { + in.Parts = make([]seat.Part, 0, len(ps)) + for _, p := range ps { + in.Parts = append(in.Parts, seat.Part{Digest: p.Digest, Size: p.Size}) + } + raw, err := call("seat:"+seat.Seat+"."+seat.VerbPublish, in) + if err != nil { + return parts.Opened{}, fmt.Errorf("%s's publish of %s: %w", seat.Seat, in.Name, err) + } + var a seat.PublishAnswer + if err := json.Unmarshal(raw, &a); err != nil { + return parts.Opened{}, fmt.Errorf("%s's answer to publish: %w", seat.Seat, err) + } + return parts.Opened{Held: a.Held, Lacks: a.Lacks}, nil + } + chunk := func(_ context.Context, part string, sequence int, last bool, data []byte) error { + raw, err := call("seat:"+seat.Seat+"."+seat.VerbPublishPart, seat.PublishPartInput{Transfer: in.Transfer, Part: part, + Sequence: int64(sequence), Last: last, Data: base64.StdEncoding.EncodeToString(data)}) + if err != nil { + return fmt.Errorf("%s's publish-part: %w", seat.Seat, err) + } + var a seat.PublishPartAnswer + if err := json.Unmarshal(raw, &a); err != nil { + return fmt.Errorf("%s's answer to publish-part: %w", seat.Seat, err) + } + return nil + } + sent, err := parts.Send(ctx, open, chunk, ps) + return Sent{Transfer: in.Transfer, Sent: sent}, err +} diff --git a/contracts/go/clients/gopackageregistryclient/client_test.go b/contracts/go/clients/gopackageregistryclient/client_test.go new file mode 100644 index 00000000..3405e705 --- /dev/null +++ b/contracts/go/clients/gopackageregistryclient/client_test.go @@ -0,0 +1,57 @@ +package gopackageregistryclient + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "io" + "testing" + + seat "git.novox.be/novox/mesh-delivery/contracts/go/seats/gopackageregistry" + "git.novox.be/novox/mesh-delivery/sdk/parts" +) + +// The client publishes over its own seat's verbs alone (ADR 0627 §1): publish names every part, and only the part the +// holder lacks travels by publish-part, its bytes whole, the last chunk marked. +func TestPublishTalksToItsOwnSeat(t *testing.T) { + held, lacked := []byte("held"), []byte("lacked") + part := func(b []byte) parts.Part { + sum := sha256.Sum256(b) + return parts.Part{Digest: "sha256:" + hex.EncodeToString(sum[:]), Size: int64(len(b)), + Open: func() (io.ReadCloser, error) { return io.NopCloser(bytes.NewReader(b)), nil }} + } + ps := []parts.Part{part(held), part(lacked)} + var asked []string + var got []byte + call := func(key string, body any) (json.RawMessage, error) { + asked = append(asked, key) + raw, _ := json.Marshal(body) + switch key { + case "seat:go-package-registry.publish": + var in seat.PublishInput + _ = json.Unmarshal(raw, &in) + if len(in.Parts) != 2 { + t.Errorf("publish names %d parts, want 2", len(in.Parts)) + } + return json.Marshal(seat.PublishAnswer{Transfer: in.Transfer, Lacks: []string{ps[1].Digest}}) + case "seat:go-package-registry.publish-part": + var in seat.PublishPartInput + _ = json.Unmarshal(raw, &in) + data, _ := base64.StdEncoding.DecodeString(in.Data) + if in.Part != ps[1].Digest || !in.Last || in.Transfer != "t1" { + t.Errorf("publish-part %+v", in) + } + got = append(got, data...) + return json.Marshal(seat.PublishPartAnswer{Transfer: in.Transfer, Part: in.Part}) + } + t.Fatalf("asked %s", key) + return nil, nil + } + sent, err := Publish(context.Background(), call, seat.PublishInput{Transfer: "t1", Name: "x"}, ps) + if err != nil || sent.Transfer != "t1" || sent.Chunks != 1 || len(sent.Parts) != 1 || !bytes.Equal(got, lacked) || len(asked) != 2 { + t.Errorf("sent %+v, %q arrived, asked %v, %v", sent, got, asked, err) + } +} diff --git a/contracts/go/clients/gopackageregistryclient/go.mod b/contracts/go/clients/gopackageregistryclient/go.mod new file mode 100644 index 00000000..0d0d1bb9 --- /dev/null +++ b/contracts/go/clients/gopackageregistryclient/go.mod @@ -0,0 +1,18 @@ +module git.novox.be/novox/mesh-delivery/contracts/go/clients/gopackageregistryclient + +go 1.26.0 + +require ( + git.novox.be/novox/mesh-delivery/contracts/go/seats/gopackageregistry v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-delivery/sdk v0.0.0-00010101000000-000000000000 +) + +require ( + git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000 // indirect + github.com/klauspost/compress v1.20.1 // indirect + github.com/nats-io/nats.go v1.54.0 // indirect + github.com/nats-io/nkeys v0.4.16 // indirect + github.com/nats-io/nuid v1.0.1 // indirect + golang.org/x/crypto v0.57.0 // indirect + golang.org/x/sys v0.48.0 // indirect +) diff --git a/contracts/go/clients/gopackageregistryclient/go.sum b/contracts/go/clients/gopackageregistryclient/go.sum new file mode 100644 index 00000000..0daef11f --- /dev/null +++ b/contracts/go/clients/gopackageregistryclient/go.sum @@ -0,0 +1,12 @@ +github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ= +github.com/klauspost/compress v1.20.1/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= +github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA= +github.com/nats-io/nats.go v1.54.0/go.mod h1:y+DZoD1oBOYfZTU681eTUiUjI0vbqYGixNVFHcjHJ0k= +github.com/nats-io/nkeys v0.4.16 h1:rd5oAuLOb8mnAycB0xleuEBNS1pVVnN0fv/FF34Eypg= +github.com/nats-io/nkeys v0.4.16/go.mod h1:llLgWoI0o4z/Q57q2R1kHfmocyhGV6VG/U18Glg1Afs= +github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= +github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= +golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= +golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= +golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= +golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= diff --git a/contracts/go/clients/meshcatalogclient/candidate.go b/contracts/go/clients/meshcatalogclient/candidate.go new file mode 100644 index 00000000..41d60d5a --- /dev/null +++ b/contracts/go/clients/meshcatalogclient/candidate.go @@ -0,0 +1,74 @@ +package meshcatalogclient + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "regexp" + "sort" + "strings" + + seat "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" + "git.novox.be/novox/mesh-sdk/go/keys" +) + +// FingerprintSet is the `fpset` of a candidate's pull requests: the first 12 hex digits of the sha256 over the lines +// `=` or `=none:` of every module with an entry in a pull's fps, sorted and joined by a newline, +// so it is the same for every order and changes with any fingerprint or reason. A manifest digest does not enter: the +// fingerprint already covers the manifest. It is the empty string when no pull request carries a fingerprint. The checker +// keys its verdict by it. +func FingerprintSet(pulls []seat.CandidatesByGroupValuePullsItem) string { + lines := map[string]bool{} + for _, p := range pulls { + for module, m := range p.Fps { + if m.Fp2 != "" { + lines[module+"="+m.Fp2] = true + } else { + lines[module+"=none:"+m.None2] = true + } + } + } + if len(lines) == 0 { + return "" + } + all := make([]string, 0, len(lines)) + for l := range lines { + all = append(all, l) + } + sort.Strings(all) + sum := sha256.Sum256([]byte(strings.Join(all, "\n"))) + return hex.EncodeToString(sum[:])[:12] +} + +// CandidatesKey is the key, in the state candidates, of a group: `` escaped (keys.EscapeName), the branch name the +// group's pull requests share (a slash is allowed); a lone pull request is a group of one under its branch name. +func CandidatesKey(group string) (string, error) { + if group == "" { + return "", fmt.Errorf("meshcatalogclient: the group name is empty") + } + return seat.CandidatesByGroupKey(keys.EscapeName(group)), nil +} + +// ParseCandidatesKey is the inverse of CandidatesKey. +func ParseCandidatesKey(key string) (string, error) { + part, ok := seat.ParseCandidatesByGroupKey(key) + if !ok { + return "", fmt.Errorf("meshcatalogclient: %q is not a candidates key", key) + } + group, err := keys.UnescapeName(part) + if err != nil { + return "", fmt.Errorf("meshcatalogclient: %q is not a candidates key: %w", key, err) + } + return group, nil +} + +var tryGroupOK = regexp.MustCompile(`^try-[0-9]{1,20}$`) + +// IsTryGroup says a candidates key is the group of a whole try, try-, which is also the try's id: the indexer makes the +// candidate of a try's group from its previews, and the checker judges it by its try, never as a pull request's check. +func IsTryGroup(key string) bool { return tryGroupOK.MatchString(key) } + +// NoneNoPreview is why a candidate's touched module has no fingerprint of the new form yet: its merge preview is not +// computed. The indexer writes it; a reader that waits for a try's candidate takes one that still says it as not yet the +// candidate of the heads previewed. +const NoneNoPreview = "no merge preview is computed" diff --git a/contracts/go/clients/meshcatalogclient/catalog_test.go b/contracts/go/clients/meshcatalogclient/catalog_test.go new file mode 100644 index 00000000..9ec9030c --- /dev/null +++ b/contracts/go/clients/meshcatalogclient/catalog_test.go @@ -0,0 +1,167 @@ +package meshcatalogclient + +import ( + "errors" + "strings" + "testing" + + seat "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" + "git.novox.be/novox/mesh-sdk/go/keys" +) + +func hex64(c string) string { return strings.Repeat(c, 64) } + +// The digest and the candidate's fingerprint set equal what the trunk's go/delivery computed for the same input (the +// expected strings are its output), so a key or a verdict written before is read after. +func TestDigestAndFingerprintSetKeepTheirForms(t *testing.T) { + if got := ManifestDigest([]byte(`{"module":"nats"}`)); got != "19a7450f557153c318558acac91cd6972321a192f6f95c3698a0e4c57edd4359" { + t.Errorf("ManifestDigest = %s", got) + } + pull := func(fps map[string]seat.CandidatesByGroupValuePullsItemFpsValue) []seat.CandidatesByGroupValuePullsItem { + return []seat.CandidatesByGroupValuePullsItem{{Fps: fps}} + } + fps := map[string]seat.CandidatesByGroupValuePullsItemFpsValue{"nats": {Fp2: SourcePrefix2 + hex64("a")}, "zsh": {None2: "no manifest"}} + set := FingerprintSet(pull(fps)) + if set != "50a3f57cc4e2" { + t.Errorf("FingerprintSet = %s", set) + } + fps["zsh"] = seat.CandidatesByGroupValuePullsItemFpsValue{None2: "another reason"} + if FingerprintSet(pull(fps)) == set { + t.Error("a changed reason leaves the set as it was") + } + if FingerprintSet(pull(nil)) != "" { + t.Error("no fingerprint gives a set") + } +} + +// A manifest and a module are read back as written, and each rule of the old values is kept: a refusal names its reason. +func TestManifestAndModuleValuesKeepTheirRules(t *testing.T) { + if _, err := ManifestsKey("abc"); err == nil || !strings.Contains(err.Error(), `"abc"`) { + t.Errorf("ManifestsKey(abc) = %v", err) + } + if k, err := ParseManifestsKey(hex64("b")); err != nil || k != hex64("b") { + t.Errorf("ParseManifestsKey = %q, %v", k, err) + } + raw, err := EncodeManifest(seat.ManifestsByDigestValue{Module: "nats", Manifest: `{"module":"nats"}`}) + if err != nil { + t.Fatal(err) + } + if m, err := DecodeManifest(raw); err != nil || m.Module != "nats" || m.V != keys.Version { + t.Errorf("DecodeManifest = %+v, %v", m, err) + } + for _, bad := range []seat.ManifestsByDigestValue{{Module: "a.b", Manifest: "{}"}, {Module: "nats"}, {Module: "nats", Manifest: "not json"}, {Module: "nats", Manifest: "{\"a\":\"\xff\"}"}} { + if _, err := EncodeManifest(bad); err == nil { + t.Errorf("EncodeManifest(%+v) is accepted", bad) + } + } + if _, err := DecodeManifest([]byte(`{"module":"nats","manifest":"{}"}`)); !errors.Is(err, keys.ErrUnknownVersion) { + t.Errorf("a manifest with no version: %v", err) + } + + good := seat.ModulesByModuleValue{Fp2: SourcePrefix2 + hex64("a"), Repository: "novox/mesh-catalog", Commit: "c", Manifest: hex64("b"), Path: RootPath} + raw, err = EncodeModule(good) + if err != nil { + t.Fatal(err) + } + if m, err := DecodeModule(raw); err != nil || m.Fp2 != good.Fp2 || m.Path != RootPath { + t.Errorf("DecodeModule = %+v, %v", m, err) + } + refused := good + refused.Verdict, refused.Problems = VerdictRefused, []string{"p"} + if _, err := EncodeModule(refused); err != nil { + t.Errorf("a refused module: %v", err) + } + for name, mutate := range map[string]func(m *seat.ModulesByModuleValue){ + "neither a fingerprint nor a reason": func(m *seat.ModulesByModuleValue) { m.Fp2 = "" }, + "both fp2 and none2": func(m *seat.ModulesByModuleValue) { m.None2 = "x" }, + "an old fingerprint of another form": func(m *seat.ModulesByModuleValue) { m.Fp = "src2:" + hex64("a") }, + "a fingerprint of the old key form": func(m *seat.ModulesByModuleValue) { m.Fp2 = "src1:" + hex64("a") }, + "inputs and no manifest": func(m *seat.ModulesByModuleValue) { + m.Manifest = "" + m.Inputs2 = &seat.ModulesByModuleValueInputs2{Tree: "t"} + }, + "a refusal with no problem": func(m *seat.ModulesByModuleValue) { m.Verdict = VerdictRefused }, + "problems with no refusal": func(m *seat.ModulesByModuleValue) { m.Problems = []string{"p"} }, + "unknown fields with no refusal": func(m *seat.ModulesByModuleValue) { m.Unknown = []string{"series"} }, + "a path outside the directory": func(m *seat.ModulesByModuleValue) { m.Paths = map[string][]string{"tools": {"../x"}} }, + "a toolchain with no recipe": func(m *seat.ModulesByModuleValue) { + m.Toolchains = map[string]seat.ToolchainUsed{"go": {Language: "go", Version: "1.26"}} + }, + "an import form with no imports": func(m *seat.ModulesByModuleValue) { + m.Inputs2 = &seat.ModulesByModuleValueInputs2{Tree: "t", ImportForm: SourcePrefix2 + hex64("a")} + }, + } { + m := good + mutate(&m) + if _, err := EncodeModule(m); err == nil { + t.Errorf("%s: EncodeModule accepts %+v", name, m) + } + } +} + +// A verdict is taken as the readers have always taken it: a missing one accepted, a refusal held with its problems, a wait +// held with the fields it waits on, and a module's own verdict field gives the same. +func TestVerdictsAreTakenAsTheReadersTookThem(t *testing.T) { + if refused, problems := TakeVerdict(nil); refused || problems != nil { + t.Errorf("no verdict: %v %v", refused, problems) + } + waits := seat.VerdictsByDigestValue{Module: "nats", State: VerdictManifestWaits, Unknown: []string{"series"}, Judge: "j"} + refused, problems := TakeVerdict(&waits) + if !refused || len(problems) != 1 || problems[0] != "it carries series, which the indexer's judge does not know yet" { + t.Errorf("a wait: %v %v", refused, problems) + } + if got := VerdictWhy(waits); got != "the indexer's judge (j) holds the manifest of nats: "+problems[0] { + t.Errorf("VerdictWhy(wait) = %q", got) + } + if as := VerdictAsRefused(waits); as.State != VerdictRefused || len(as.Unknown) != 0 || as.Problems[0] != problems[0] { + t.Errorf("VerdictAsRefused = %+v", as) + } + no := seat.VerdictsByDigestValue{Module: "nats", State: VerdictRefused, Problems: []string{"a", "b"}, Judge: "j"} + if got := VerdictWhy(no); got != "the indexer's judge (j) refused the manifest of nats: a; b" { + t.Errorf("VerdictWhy(refused) = %q", got) + } + if !SameVerdict(no, seat.VerdictsByDigestValue{Module: "nats", State: VerdictRefused, Problems: []string{"a", "b"}, Judge: "k"}) { + t.Error("another judge is another verdict") + } + fromModule := ModuleVerdict("nats", seat.ModulesByModuleValue{Verdict: VerdictRefused, Unknown: []string{"series"}, Problems: []string{"x"}}) + if fromModule == nil || fromModule.State != VerdictManifestWaits || fromModule.Unknown[0] != "series" { + t.Errorf("a refusal naming unknown fields is the wait it was written as: %+v", fromModule) + } + if ModuleVerdict("nats", seat.ModulesByModuleValue{}) != nil { + t.Error("a module with no verdict carries one") + } + for _, bad := range []seat.VerdictsByDigestValue{{Module: "nats", State: "accepted", Problems: []string{"p"}, Judge: "j"}, {Module: "nats", State: "refused", Judge: "j"}, {Module: "nats", State: "accepted"}, {Module: "nats", State: "odd", Judge: "j"}} { + if _, err := EncodeVerdict(bad); err == nil { + t.Errorf("EncodeVerdict(%+v) is accepted", bad) + } + } + raw, err := EncodeVerdict(no) + if err != nil { + t.Fatal(err) + } + if v, err := DecodeVerdict(raw); err != nil || !SameVerdict(v, no) { + t.Errorf("DecodeVerdict = %+v, %v", v, err) + } + if k, err := VerdictsKey(hex64("c")); err != nil || k != hex64("c") { + t.Errorf("VerdictsKey = %q %v", k, err) + } + if _, err := ModulesKey("a.b"); err == nil { + t.Error("ModulesKey accepts a dotted name") + } +} + +func TestCandidatesKeyEscapesTheGroupAndReadsBack(t *testing.T) { + key, err := CandidatesKey("fix/1.2") + if err != nil { + t.Fatal(err) + } + if key == "fix/1.2" { + t.Errorf("the group is not escaped: %q", key) + } + if group, err := ParseCandidatesKey(key); err != nil || group != "fix/1.2" { + t.Errorf("read back = %q, %v", group, err) + } + if _, err := CandidatesKey(""); err == nil { + t.Error("an empty group keys") + } +} diff --git a/contracts/go/clients/meshcatalogclient/fingerprint.go b/contracts/go/clients/meshcatalogclient/fingerprint.go new file mode 100644 index 00000000..0565fdb5 --- /dev/null +++ b/contracts/go/clients/meshcatalogclient/fingerprint.go @@ -0,0 +1,41 @@ +// Package meshcatalogclient holds the logic the holders and callers of the seat mesh-catalog share over its contract (novox/hq +// ADR 0625 §4, ADR 0634 §2): the form of a module's source fingerprint, `src2:` and 64 lowercase hex digits (ADR 0593 §2), +// over the `fp2` field of the seat's `modules` value. The indexer computes a fingerprint alone, in its own package; every part +// that keys a state by it, the node-engine among them, reads this form here. +package meshcatalogclient + +import ( + "fmt" + "strings" + + "git.novox.be/novox/mesh-sdk/go/keys" +) + +// SourcePrefix2 opens every source fingerprint of the form. +const SourcePrefix2 = "src2:" + +// Toolchain2 is what an artifact's toolchain enters the fingerprint as (novox/hq ADR 0405 clause 7): the artifact's +// language, the installed toolchain's version and the build module's recipe version. +type Toolchain2 struct { + Language string + Version string + Recipe string +} + +// KeyPart is a fingerprint as a key part: its 64 hex digits without the prefix, since a bus key may not hold ':'. It +// refuses a value that is not `src2:` and 64 lowercase hex digits, a src1 fingerprint included, naming it. +func KeyPart(fp string) (string, error) { + part, ok := strings.CutPrefix(fp, SourcePrefix2) + if !ok || !keys.IsHex64(part) { + return "", fmt.Errorf("fingerprint: %q is not a fingerprint of the form, %s and 64 lowercase hex digits", fp, SourcePrefix2) + } + return part, nil +} + +// ParseKeyPart is the inverse of KeyPart. It refuses a part that is not 64 lowercase hex digits, naming it. +func ParseKeyPart(part string) (string, error) { + if !keys.IsHex64(part) { + return "", fmt.Errorf("fingerprint: %q is not the key part of a fingerprint, 64 lowercase hex digits", part) + } + return SourcePrefix2 + part, nil +} diff --git a/contracts/go/clients/meshcatalogclient/fingerprint_test.go b/contracts/go/clients/meshcatalogclient/fingerprint_test.go new file mode 100644 index 00000000..ec575e0a --- /dev/null +++ b/contracts/go/clients/meshcatalogclient/fingerprint_test.go @@ -0,0 +1,34 @@ +package meshcatalogclient + +import ( + "strings" + "testing" +) + +// The key part holds only bus key characters and parsing gives the fingerprint back; a src1 or malformed value is refused, +// naming it. +func TestFingerprintKeyPart(t *testing.T) { + fp := SourcePrefix2 + strings.Repeat("ab", 32) + part, err := KeyPart(fp) + if err != nil { + t.Fatal(err) + } + if len(part) != 64 || strings.Trim(part, "0123456789abcdef") != "" { + t.Errorf("key part %q is not 64 lowercase hex characters", part) + } + back, err := ParseKeyPart(part) + if err != nil || back != fp { + t.Errorf("parse(%q) = %q, %v; want %q", part, back, err, fp) + } + src1 := "src1:" + strings.Repeat("c", 64) + for _, bad := range []string{src1, "src2:", "src2:xyz", "", "src2:" + strings.Repeat("A", 64), "src2:" + strings.Repeat("a", 63)} { + if _, err := KeyPart(bad); err == nil || !strings.Contains(err.Error(), "\""+bad+"\"") { + t.Errorf("KeyPart(%q) = %v, want a refusal naming it", bad, err) + } + } + for _, bad := range []string{src1, "src2:" + strings.Repeat("a", 64), strings.Repeat("a", 63), strings.Repeat("A", 64), "", "zz"} { + if _, err := ParseKeyPart(bad); err == nil || !strings.Contains(err.Error(), "\""+bad+"\"") { + t.Errorf("ParseKeyPart(%q) = %v, want a refusal naming it", bad, err) + } + } +} diff --git a/contracts/go/clients/meshcatalogclient/go.mod b/contracts/go/clients/meshcatalogclient/go.mod new file mode 100644 index 00000000..450b54a2 --- /dev/null +++ b/contracts/go/clients/meshcatalogclient/go.mod @@ -0,0 +1,8 @@ +module git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient + +go 1.26.0 + +require ( + git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000 +) diff --git a/contracts/go/clients/meshcatalogclient/go.sum b/contracts/go/clients/meshcatalogclient/go.sum new file mode 100644 index 00000000..0daef11f --- /dev/null +++ b/contracts/go/clients/meshcatalogclient/go.sum @@ -0,0 +1,12 @@ +github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ= +github.com/klauspost/compress v1.20.1/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= +github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA= +github.com/nats-io/nats.go v1.54.0/go.mod h1:y+DZoD1oBOYfZTU681eTUiUjI0vbqYGixNVFHcjHJ0k= +github.com/nats-io/nkeys v0.4.16 h1:rd5oAuLOb8mnAycB0xleuEBNS1pVVnN0fv/FF34Eypg= +github.com/nats-io/nkeys v0.4.16/go.mod h1:llLgWoI0o4z/Q57q2R1kHfmocyhGV6VG/U18Glg1Afs= +github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= +github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= +golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= +golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= +golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= +golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= diff --git a/contracts/go/clients/meshcatalogclient/manifest.go b/contracts/go/clients/meshcatalogclient/manifest.go new file mode 100644 index 00000000..38c46616 --- /dev/null +++ b/contracts/go/clients/meshcatalogclient/manifest.go @@ -0,0 +1,79 @@ +package meshcatalogclient + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "unicode/utf8" + + seat "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" + "git.novox.be/novox/mesh-sdk/go/keys" +) + +// ManifestDigest is the hex sha256 of a manifest's bytes exactly as read at the commit: the key of a `manifests` value and +// the digest a `modules` value and a candidate's module carry (novox/hq ADR 0429 clause 16). +func ManifestDigest(raw []byte) string { + sum := sha256.Sum256(raw) + return hex.EncodeToString(sum[:]) +} + +// ManifestsKey is the key of a `manifests` value: the manifest's digest, 64 lowercase hex digits. The indexer writes it +// once, by a create that fails when the key exists. +func ManifestsKey(digest string) (string, error) { + if !keys.IsHex64(digest) { + return "", fmt.Errorf("meshcatalogclient: %q is not a manifest digest, 64 lowercase hex digits", digest) + } + return seat.ManifestsByDigestKey(digest), nil +} + +// ParseManifestsKey is the inverse of ManifestsKey. +func ParseManifestsKey(key string) (string, error) { + digest, err := ManifestsKey(key) + if err != nil { + return "", fmt.Errorf("meshcatalogclient: %q is not a manifests key: %w", key, err) + } + return digest, nil +} + +func checkManifest(m seat.ManifestsByDigestValue) error { + if err := keys.CheckName("module", m.Module); err != nil { + return err + } + if m.Manifest == "" { + return fmt.Errorf("meshcatalogclient: the manifest of %s is empty", m.Module) + } + if !utf8.ValidString(m.Manifest) { + return fmt.Errorf("meshcatalogclient: the manifest of %s is not UTF-8 text, which a value cannot hold byte for byte", m.Module) + } + if !json.Valid([]byte(m.Manifest)) { + return fmt.Errorf("meshcatalogclient: the manifest of %s is not JSON", m.Module) + } + return nil +} + +// EncodeManifest is m as JSON at the kept values' version; it refuses a module that is not a name and a manifest that is +// empty, not UTF-8 or not JSON. +func EncodeManifest(m seat.ManifestsByDigestValue) ([]byte, error) { + if err := checkManifest(m); err != nil { + return nil, err + } + m.V = keys.Version + return json.Marshal(m) +} + +// DecodeManifest reads a `manifests` value; it refuses an unknown version (keys.ErrUnknownVersion) and what EncodeManifest +// refuses. +func DecodeManifest(data []byte) (seat.ManifestsByDigestValue, error) { + if err := keys.CheckVersion(data); err != nil { + return seat.ManifestsByDigestValue{}, err + } + var m seat.ManifestsByDigestValue + if err := json.Unmarshal(data, &m); err != nil { + return seat.ManifestsByDigestValue{}, fmt.Errorf("meshcatalogclient: value does not fit its shape: %w", err) + } + if err := checkManifest(m); err != nil { + return seat.ManifestsByDigestValue{}, err + } + return m, nil +} diff --git a/contracts/go/clients/meshcatalogclient/module.go b/contracts/go/clients/meshcatalogclient/module.go new file mode 100644 index 00000000..d07b3692 --- /dev/null +++ b/contracts/go/clients/meshcatalogclient/module.go @@ -0,0 +1,133 @@ +package meshcatalogclient + +import ( + "encoding/json" + "fmt" + "path" + "strings" + + seat "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" + "git.novox.be/novox/mesh-sdk/go/keys" +) + +// RootPath is a `modules` value's path when the module's manifest is its repository's root. +const RootPath = "." + +// SourcePrefix1 opens the fingerprint of the old form a `modules` value may still carry in `fp`. +const SourcePrefix1 = "src1:" + +// ModulesKey is the key of a `modules` value: the module's name. +func ModulesKey(module string) (string, error) { + if err := keys.CheckName("module", module); err != nil { + return "", err + } + return seat.ModulesByModuleKey(module), nil +} + +// ParseModulesKey is the inverse of ModulesKey. +func ParseModulesKey(key string) (string, error) { + module, err := ModulesKey(key) + if err != nil { + return "", fmt.Errorf("meshcatalogclient: %q is not a modules key: %w", key, err) + } + return module, nil +} + +// CheckModule refuses a `modules` value that contradicts itself: both or neither of a fingerprint and the reason for none, +// of either form, an old fingerprint not of the form `src1:`, kept inputs without a manifest digest, a verdict the indexer +// does not write, problems without a refusal (or a refusal without problems), a path that is not clean inside the module's +// directory, and a fingerprint or a digest not of its form. +func CheckModule(m seat.ModulesByModuleValue) error { + switch { + case m.Fp != "" && m.None != "": + return fmt.Errorf("meshcatalogclient: a module has both a fingerprint and a reason for none") + case m.Fp == "" && m.None == "" && m.Fp2 == "" && m.None2 == "": + return fmt.Errorf("meshcatalogclient: a module has neither a fingerprint nor a reason for none, of either form") + case m.Fp != "" && !strings.HasPrefix(m.Fp, SourcePrefix1): + return fmt.Errorf("meshcatalogclient: the module's fingerprint %q does not start with %s", m.Fp, SourcePrefix1) + case m.Fp2 != "" && m.None2 != "": + return fmt.Errorf("meshcatalogclient: a module has both fp2 and none2") + } + for language, t := range m.Toolchains { + if t.Version == "" || t.Recipe == "" { + return fmt.Errorf("meshcatalogclient: the module's toolchain of %q needs its version and its recipe version", language) + } + } + if m.Inputs2 != nil && m.Manifest == "" { + return fmt.Errorf("meshcatalogclient: a module keeps new-form inputs but has no manifest digest") + } + for artifact, paths := range m.Paths { + if artifact == "" || len(paths) == 0 { + return fmt.Errorf("meshcatalogclient: the module's named paths of the artifact %q are empty or name no artifact", artifact) + } + for _, p := range paths { + if !cleanInside(p) { + return fmt.Errorf("meshcatalogclient: the artifact %q names the path %q, not a clean path inside the module's directory", artifact, p) + } + } + } + if in := m.Inputs2; in != nil { + if f := in.ImportForm; f != "" && (len(in.Imports) == 0 || !strings.HasPrefix(f, SourcePrefix2) || !keys.IsHex64(f[len(SourcePrefix2):])) { + return fmt.Errorf("meshcatalogclient: the kept import form %q is set only with imports, and is %s and 64 hex digits", f, SourcePrefix2) + } + for p, object := range in.Paths { + if !cleanInside(p) { + return fmt.Errorf("meshcatalogclient: the named path %q is not a clean path inside the module's directory", p) + } + if object == "" { + return fmt.Errorf("meshcatalogclient: the named path %q names no git object", p) + } + } + } + switch { + case m.Verdict != "" && m.Verdict != VerdictAccepted && m.Verdict != VerdictRefused: + return fmt.Errorf("meshcatalogclient: the module's verdict %q is neither %s nor %s", m.Verdict, VerdictAccepted, VerdictRefused) + case len(m.Unknown) > 0 && m.Verdict != VerdictRefused: + return fmt.Errorf("meshcatalogclient: a module's unknown fields are named only with the refusal a wait is written as") + case m.Verdict != "" && m.Manifest == "": + return fmt.Errorf("meshcatalogclient: a module carries a verdict and no manifest digest") + case (m.Verdict == VerdictRefused) != (len(m.Problems) > 0): + return fmt.Errorf("meshcatalogclient: a module's problems are named only with a refusal, and a refusal names them") + case m.Verdict == VerdictRefused && len(m.Warnings) > 0: + return fmt.Errorf("meshcatalogclient: a refused module warns: a refusal names problems") + } + if m.Fp2 != "" { + if _, err := KeyPart(m.Fp2); err != nil { + return err + } + } + if m.Manifest != "" { + if _, err := ManifestsKey(m.Manifest); err != nil { + return err + } + } + return nil +} + +func cleanInside(p string) bool { + return p != "" && p == path.Clean(p) && !strings.HasPrefix(p, "/") && p != ".." && !strings.HasPrefix(p, "../") +} + +// EncodeModule is m as JSON at the kept values' version; it refuses what CheckModule refuses. +func EncodeModule(m seat.ModulesByModuleValue) ([]byte, error) { + if err := CheckModule(m); err != nil { + return nil, err + } + m.V = keys.Version + return json.Marshal(m) +} + +// DecodeModule reads a `modules` value; it refuses an unknown version (keys.ErrUnknownVersion) and what EncodeModule refuses. +func DecodeModule(data []byte) (seat.ModulesByModuleValue, error) { + if err := keys.CheckVersion(data); err != nil { + return seat.ModulesByModuleValue{}, err + } + var m seat.ModulesByModuleValue + if err := json.Unmarshal(data, &m); err != nil { + return seat.ModulesByModuleValue{}, fmt.Errorf("meshcatalogclient: value does not fit its shape: %w", err) + } + if err := CheckModule(m); err != nil { + return seat.ModulesByModuleValue{}, err + } + return m, nil +} diff --git a/contracts/go/clients/meshcatalogclient/verdict.go b/contracts/go/clients/meshcatalogclient/verdict.go new file mode 100644 index 00000000..0e398221 --- /dev/null +++ b/contracts/go/clients/meshcatalogclient/verdict.go @@ -0,0 +1,168 @@ +package meshcatalogclient + +import ( + "encoding/json" + "fmt" + "slices" + "strings" + + seat "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" + "git.novox.be/novox/mesh-sdk/go/keys" +) + +// The states of a verdict on a manifest (novox/hq ADR 0578 §1): the indexer judges every manifest it takes in once and +// every other part takes the verdict from the seat's state `verdicts`. +const ( + // VerdictAccepted is the state of a manifest the judge found no problem in. + VerdictAccepted = "accepted" + // VerdictRefused is the state of a manifest the judge found a problem in; the verdict's problems say each. + VerdictRefused = "refused" + // VerdictManifestWaits is the state of a manifest that carries a field the judge does not know: it is not refused, + // because a newer judge may know the field; the verdict's unknown names each. A reader holds the module as a refused + // one and keeps what runs. In a `modules` value it is written as a refusal naming the fields. + VerdictManifestWaits = "waits" +) + +// VerdictsKey is the key of a `verdicts` value: the digest of the manifest it judges. +func VerdictsKey(digest string) (string, error) { + if !keys.IsHex64(digest) { + return "", fmt.Errorf("meshcatalogclient: %q is not a manifest digest, 64 lowercase hex digits", digest) + } + return seat.VerdictsByDigestKey(digest), nil +} + +// VerdictRefuses says whether v refuses its manifest. +func VerdictRefuses(v seat.VerdictsByDigestValue) bool { return v.State == VerdictRefused } + +// VerdictWaits says whether v says the manifest waits for a judge that knows its fields. +func VerdictWaits(v seat.VerdictsByDigestValue) bool { return v.State == VerdictManifestWaits } + +// VerdictHeld says whether a reader holds the module as refused: the manifest is refused or waits. +func VerdictHeld(v seat.VerdictsByDigestValue) bool { return VerdictRefuses(v) || VerdictWaits(v) } + +// WaitsOn says, in words, what a waiting manifest waits on. +func WaitsOn(v seat.VerdictsByDigestValue) string { + return "it carries " + strings.Join(v.Unknown, ", ") + ", which the indexer's judge does not know yet" +} + +// TakeVerdict is how a reader takes the verdict on a manifest (ADR 0578 §3): held, with its problems, when the indexer +// wrote a refusal, or, for a manifest that waits, with the words that say which fields it waits on. A missing verdict (nil) +// is taken as accepted: every manifest the indexer holds before it has judged it was taken in under the judges that ran +// until then. A reader holds a refused module and names it with VerdictWhy, and reads the rest. +func TakeVerdict(v *seat.VerdictsByDigestValue) (refused bool, problems []string) { + if v == nil || !VerdictHeld(*v) { + return false, nil + } + if VerdictWaits(*v) { + return true, []string{WaitsOn(*v)} + } + return true, v.Problems +} + +// VerdictWhy is a refusal in a reader's words: the module, that the indexer's judge refused its manifest, and each problem. +func VerdictWhy(v seat.VerdictsByDigestValue) string { + if !VerdictHeld(v) { + return "" + } + judge := "the indexer's judge" + if v.Judge != "" { + judge += " (" + v.Judge + ")" + } + if VerdictWaits(v) { + return fmt.Sprintf("%s holds the manifest of %s: %s", judge, v.Module, WaitsOn(v)) + } + why := fmt.Sprintf("%s refused the manifest of %s:", judge, v.Module) + for _, p := range v.Problems { + why += " " + p + ";" + } + return why[:len(why)-1] +} + +// VerdictAsRefused is v as a reader that does not know the waiting state takes it: a waiting verdict is a refusal naming what +// it waits on, any other verdict is v. +func VerdictAsRefused(v seat.VerdictsByDigestValue) seat.VerdictsByDigestValue { + if !VerdictWaits(v) { + return v + } + v.State, v.Problems, v.Unknown = VerdictRefused, []string{WaitsOn(v)}, nil + return v +} + +// SameVerdict says whether v and o say the same of a manifest: the module, the state, the problems, the unknown fields and +// the warnings, whoever judged and when. The indexer writes a verdict only when it is not the same as the one it holds. +func SameVerdict(v, o seat.VerdictsByDigestValue) bool { + return v.Module == o.Module && v.State == o.State && slices.Equal(v.Problems, o.Problems) && slices.Equal(v.Unknown, o.Unknown) && slices.Equal(v.Warnings, o.Warnings) +} + +func checkVerdict(v seat.VerdictsByDigestValue) error { + if err := keys.CheckName("module", v.Module); err != nil { + return err + } + if v.State != VerdictManifestWaits && len(v.Unknown) > 0 { + return fmt.Errorf("meshcatalogclient: the verdict on %s names unknown fields and does not wait", v.Module) + } + switch v.State { + case VerdictAccepted: + if len(v.Problems) > 0 { + return fmt.Errorf("meshcatalogclient: the verdict on %s accepts its manifest and names problems", v.Module) + } + case VerdictRefused: + if len(v.Problems) == 0 { + return fmt.Errorf("meshcatalogclient: the verdict on %s refuses its manifest and names no problem", v.Module) + } + if len(v.Warnings) > 0 { + return fmt.Errorf("meshcatalogclient: the verdict on %s refuses its manifest and warns: a refusal names problems", v.Module) + } + case VerdictManifestWaits: + if len(v.Unknown) == 0 || len(v.Problems) > 0 || len(v.Warnings) > 0 { + return fmt.Errorf("meshcatalogclient: the verdict on %s waits and names no unknown field, or names problems or warnings: a wait names the fields", v.Module) + } + default: + return fmt.Errorf("meshcatalogclient: the verdict on %s is %q, none of %s, %s and %s", v.Module, v.State, VerdictAccepted, VerdictRefused, VerdictManifestWaits) + } + if v.Judge == "" { + return fmt.Errorf("meshcatalogclient: the verdict on %s names no judge", v.Module) + } + return nil +} + +// EncodeVerdict is v as JSON at the kept values' version; it refuses a verdict without a module or a judge, a state other +// than accepted, refused or waits, a refusal naming no problem and an acceptance naming one. +func EncodeVerdict(v seat.VerdictsByDigestValue) ([]byte, error) { + if err := checkVerdict(v); err != nil { + return nil, err + } + v.V = keys.Version + return json.Marshal(v) +} + +// DecodeVerdict reads a `verdicts` value; it refuses an unknown version (keys.ErrUnknownVersion) and what EncodeVerdict +// refuses. +func DecodeVerdict(data []byte) (seat.VerdictsByDigestValue, error) { + if err := keys.CheckVersion(data); err != nil { + return seat.VerdictsByDigestValue{}, err + } + var v seat.VerdictsByDigestValue + if err := json.Unmarshal(data, &v); err != nil { + return seat.VerdictsByDigestValue{}, fmt.Errorf("meshcatalogclient: value does not fit its shape: %w", err) + } + if err := checkVerdict(v); err != nil { + return seat.VerdictsByDigestValue{}, err + } + return v, nil +} + +// ModuleVerdict is the verdict a `modules` value carries, or nil when it carries none (taken as accepted, TakeVerdict). A +// refusal naming unknown fields is the wait it was written as. +func ModuleVerdict(module string, m seat.ModulesByModuleValue) *seat.VerdictsByDigestValue { + if m.Verdict == "" && len(m.Warnings) == 0 { + return nil + } + if m.Verdict == "" { + return &seat.VerdictsByDigestValue{Module: module, State: VerdictAccepted, Warnings: m.Warnings} + } + if m.Verdict == VerdictRefused && len(m.Unknown) > 0 { + return &seat.VerdictsByDigestValue{Module: module, State: VerdictManifestWaits, Unknown: m.Unknown} + } + return &seat.VerdictsByDigestValue{Module: module, State: m.Verdict, Problems: m.Problems} +} diff --git a/contracts/go/clients/meshdeliverycheckerclient/client.go b/contracts/go/clients/meshdeliverycheckerclient/client.go new file mode 100644 index 00000000..7e2acbf3 --- /dev/null +++ b/contracts/go/clients/meshdeliverycheckerclient/client.go @@ -0,0 +1,341 @@ +// Package meshdeliverycheckerclient holds the logic the holders and callers of the seat mesh-delivery-checker share over its +// contract (novox/hq ADR 0625 §4, ADR 0634 §2): the rules a verdict and a kept verdict must keep to be written or read. The +// checker writes them, and the view, the gate and the indexer read them; the contract's decoding is strict by schema only, so +// these rules are written once here and no caller copies them. +package meshdeliverycheckerclient + +import ( + "fmt" + pathpkg "path" + "strings" + + checker "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdeliverychecker" + "git.novox.be/novox/mesh-sdk/go/keys" +) + +// The states of a verdict, the levels of a check and the compositions' states. +const ( + VerdictRunning = "running" + VerdictPass = "pass" + VerdictFail = "fail" + + LevelUnit = "unit" + LevelIntegration = "integration" + + CompositionNotRun = "not-run" + CompositionPass = "pass" + CompositionFail = "fail" +) + +// MaxFailures is the most failures a verdict names; it names the count of the rest beside them. +const MaxFailures = 50 + +func checkLevel(level string) error { + if level != LevelUnit && level != LevelIntegration { + return fmt.Errorf("meshdeliverycheckerclient: the level %q is not %s or %s", level, LevelUnit, LevelIntegration) + } + return nil +} + +func isHex12(s string) bool { return len(s) == 12 && strings.Trim(s, "0123456789abcdef") == "" } + +// CheckRepository refuses a repository that is not / with exactly one slash, each part a key part. +func CheckRepository(repository string) error { + owner, name, ok := strings.Cut(repository, "/") + if !ok || strings.Contains(name, "/") { + return fmt.Errorf("meshdeliverycheckerclient: the repository %q is not /, with exactly one slash", repository) + } + if err := keys.CheckName("repository owner", owner); err != nil { + return fmt.Errorf("meshdeliverycheckerclient: the repository %q: %w", repository, err) + } + if err := keys.CheckName("repository name", name); err != nil { + return fmt.Errorf("meshdeliverycheckerclient: the repository %q: %w", repository, err) + } + return nil +} + +func checkComposition(c *checker.VerdictsByGroupHeadsValueComposition) error { + if c == nil { + return nil + } + switch c.State { + case CompositionNotRun: + if c.Sdk != "" || len(c.Machines) > 0 { + return fmt.Errorf("meshdeliverycheckerclient: the verdict's composition is not run and names no mesh-sdk commit or machines") + } + case CompositionPass: + if c.Sdk == "" { + return fmt.Errorf("meshdeliverycheckerclient: the verdict's composition passed and names no mesh-sdk commit") + } + case CompositionFail: + if c.Words == "" { + return fmt.Errorf("meshdeliverycheckerclient: the verdict's composition failed and gives no words") + } + default: + return fmt.Errorf("meshdeliverycheckerclient: the verdict's composition state %q is not %s, %s or %s", c.State, CompositionNotRun, CompositionPass, CompositionFail) + } + return nil +} + +// checkJudgedBy refuses a checking program named at no repository, full commit and package, kept under no program key, +// or over no head or a head at no full commit (novox/hq ADR 0644, ADR 0656; issue 1921). +func checkJudgedBy(j *checker.VerdictsByGroupHeadsValueJudgedBy) error { + const who = "meshdeliverycheckerclient: " + if j == nil { + return nil + } + if CheckRepository(j.Program.Repository) != nil || !isCommit(j.Program.Commit) || j.Program.Package == "" { + return fmt.Errorf(who+"the verdict is judged by a checking program built at no repository, full commit and package (%+v)", j.Program) + } + if !strings.HasPrefix(j.Key, "program-") { + return fmt.Errorf(who+"the verdict's checking program is kept under %q, which is no program key", j.Key) + } + if len(j.Heads) == 0 { + return fmt.Errorf(who + "the verdict's checking program read no head") + } + for _, h := range j.Heads { + if CheckRepository(h.Repository) != nil || !isCommit(h.Commit) { + return fmt.Errorf(who+"the verdict's checking program read %s at %q, which is no full commit", h.Repository, h.Commit) + } + } + return nil +} + +func isCommit(s string) bool { return len(s) == 40 && strings.Trim(s, "0123456789abcdef") == "" } + +// CheckVerdict refuses a verdict a reader could not place: no group, heads that are not 12 lowercase hex digits, a repository +// both merged and read beside, a carried-from that carries nothing, a series change from a series to itself or named twice, a +// level that is neither unit nor integration or counts a negative number, a time below zero, a pass that says nothing of a +// level, a fail that names neither a test nor a machine, and failures on a verdict that did not fail. +func CheckVerdict(v checker.VerdictsByGroupHeadsValue) error { + const who = "meshdeliverycheckerclient: " + if v.Group == "" { + return fmt.Errorf(who + "a verdict names no group") + } + if !isHex12(v.Heads) { + return fmt.Errorf(who+"the verdict's heads hash %q is not 12 lowercase hex digits", v.Heads) + } + for repository, head := range v.Trunk { + if head == "" { + return fmt.Errorf(who+"the verdict's trunk head of %s is empty", repository) + } + } + for repository, commit := range v.Beside { + if commit == "" { + return fmt.Errorf(who+"the verdict's sibling commit of %s is empty", repository) + } + if _, ok := v.Trunk[repository]; ok { + return fmt.Errorf(who+"the verdict names %s as a trunk it merged and as a sibling it read", repository) + } + } + if len(v.CarriedFrom) > 0 { + if v.State == VerdictRunning { + return fmt.Errorf(who + "a running verdict is carried from nothing: carried-from is written with its end") + } + moved := false + for repository, commit := range v.CarriedFrom { + now, inTrunk := v.Trunk[repository] + if !inTrunk { + now = v.Beside[repository] + } + if commit == "" || now == "" { + return fmt.Errorf(who+"the verdict's carried-from names %s, which it judged on no commit of", repository) + } + moved = moved || commit != now + } + if !moved { + return fmt.Errorf(who + "the verdict's carried-from names the commits it is judged on: nothing moved, so nothing was carried") + } + } + changed := map[string]bool{} + for _, c := range v.Series { + if c.Module == "" || c.Artifact == "" || c.From == "" || c.To == "" { + return fmt.Errorf(who+"the verdict's series change %+v names no module, artifact, from or to", c) + } + if c.From == c.To { + return fmt.Errorf(who+"the verdict's series change of %s/%s is from %s to the same series", c.Module, c.Artifact, c.From) + } + if changed[c.Module+"/"+c.Artifact] { + return fmt.Errorf(who+"the verdict names the series change of %s/%s twice", c.Module, c.Artifact) + } + changed[c.Module+"/"+c.Artifact] = true + } + for level, r := range v.Levels { + if err := checkLevel(level); err != nil { + return err + } + if r.Run < 0 || r.Kept < 0 || r.Skipped < 0 { + return fmt.Errorf(who+"the verdict's %s level counts a negative number of tests", level) + } + } + if err := checkComposition(v.Composition); err != nil { + return err + } + if err := checkJudgedBy(v.JudgedBy); err != nil { + return err + } + seen := map[string]bool{} + for _, level := range v.OverBound { + if err := checkLevel(level); err != nil { + return fmt.Errorf(who+"the verdict's over-bound names: %w", err) + } + if seen[level] { + return fmt.Errorf(who+"the verdict's over-bound names the level %s twice", level) + } + seen[level] = true + } + if t := v.Took; t != nil { + for part, ms := range map[string]int64{"queue": t.Queue, "clone": t.Clone, "unit": t.Unit, "lint": t.Lint, + "integration-setup": t.IntegrationSetup, "integration-run": t.IntegrationRun, "composition": t.Composition} { + if ms < 0 { + return fmt.Errorf(who+"the verdict's time of the part %s is negative", part) + } + } + } + named := v.Failed != nil && (v.Failed.Test != "" || v.Failed.Machine != "") + if len(v.Failures) > 0 && v.State != VerdictFail { + return fmt.Errorf(who+"a %s verdict names no failures", v.State) + } + if len(v.Failures) > MaxFailures+1 { + return fmt.Errorf(who+"the verdict names %d failures, more than %d and the count of the rest", len(v.Failures), MaxFailures) + } + switch v.State { + case VerdictRunning: + if v.Failed != nil && *v.Failed != (checker.VerdictsByGroupHeadsValueFailed{}) { + return fmt.Errorf(who + "a running verdict names no failure") + } + case VerdictPass: + if v.Failed != nil && *v.Failed != (checker.VerdictsByGroupHeadsValueFailed{}) { + return fmt.Errorf(who + "a pass names no failure") + } + for _, level := range []string{LevelUnit, LevelIntegration} { + if _, ok := v.Levels[level]; !ok { + return fmt.Errorf(who+"a pass says nothing of the %s level", level) + } + } + case VerdictFail: + if !named { + return fmt.Errorf(who + "a fail names neither a test nor a machine") + } + default: + return fmt.Errorf(who+"the verdict state %q is not running, pass or fail", v.State) + } + return nil +} + +// CheckKept refuses a kept verdict that is not a pass of a repository's package at a level, with its toolchain and its time: +// a failure is never kept. +func CheckKept(k checker.KeptByLevelDigestValue) error { + const who = "meshdeliverycheckerclient: " + if k.State != VerdictPass { + return fmt.Errorf(who+"a kept verdict is a pass, never %q: a failure is never kept", k.State) + } + if err := CheckRepository(k.Repository); err != nil { + return err + } + if k.Package == "" { + return fmt.Errorf(who + "a kept verdict names no package") + } + if err := checkLevel(k.Level); err != nil { + return err + } + if k.Tests < 0 || k.Took < 0 { + return fmt.Errorf(who + "a kept verdict counts a negative number of tests or a negative time") + } + if k.Toolchain == "" { + return fmt.Errorf(who + "a kept verdict names no toolchain version") + } + if k.At == "" { + return fmt.Errorf(who + "a kept verdict says no time") + } + return nil +} + +// CheckPreview refuses a preview a reader could not place (the checker writes it, the indexer reads it for the candidates' +// fingerprints): a repository, head or trunk commit that does not key, a head in conflict that names no file or has a merge +// commit or modules, a head that merged and names no merge commit, and a module with no directory, no tree, a manifest +// digest that is not 64 lowercase hex digits, a lock without path or hash, or a named path that is not clean and inside the +// module's directory or names no git object. +func CheckPreview(p checker.PreviewsByRepositoryHeadTrunkValue) error { + const who = "meshdeliverycheckerclient: " + if err := CheckRepository(p.Repository); err != nil { + return err + } + if err := keys.CheckName("head", p.Head); err != nil { + return fmt.Errorf(who+"the preview: %w", err) + } + if err := keys.CheckName("trunk commit", p.Trunk); err != nil { + return fmt.Errorf(who+"the preview: %w", err) + } + for _, f := range p.Conflicts { + if f == "" { + return fmt.Errorf(who + "a preview names a conflicting file with an empty path") + } + } + if len(p.Conflicts) > 0 { + if p.Merge != "" || len(p.Modules) > 0 { + return fmt.Errorf(who + "a preview of a head in conflict has no merge commit and no modules") + } + return nil + } + if p.Merge == "" { + return fmt.Errorf(who + "a preview names no merge commit") + } + for dir, m := range p.Modules { + if dir == "" { + return fmt.Errorf(who + "a preview names a module with an empty directory") + } + if m.Tree == "" { + return fmt.Errorf(who+"the preview's module %q has no tree", dir) + } + if !keys.IsHex64(m.Manifest) { + return fmt.Errorf(who+"the preview's module %q: %q is not a manifest digest, 64 lowercase hex digits", dir, m.Manifest) + } + for path, hash := range m.Locks { + if path == "" || hash == "" { + return fmt.Errorf(who+"the lock %q of the preview's module %q has an empty path or hash", path, dir) + } + } + for p, object := range m.Paths { + if p == "" || p != pathpkg.Clean(p) || strings.HasPrefix(p, "/") || p == ".." || strings.HasPrefix(p, "../") { + return fmt.Errorf(who+"the preview's module %q: the named path %q is not a clean path inside the module's directory", dir, p) + } + if object == "" { + return fmt.Errorf(who+"the preview's module %q: the named path %q names no git object", dir, p) + } + } + } + return nil +} + +// VerdictKey is the key, in the state verdicts, of a group's check at its heads: `.`, the +// group escaped so a branch such as `fix/1.2` keys, the heads hash as the mesh-delivery client's HeadsHash gives it +// (12 lowercase hex digits). +func VerdictKey(group, headsHash string) (string, error) { + if group == "" { + return "", fmt.Errorf("meshdeliverycheckerclient: the group name is empty") + } + if !isHex12(headsHash) { + return "", fmt.Errorf("meshdeliverycheckerclient: %q is not a heads hash, 12 lowercase hex digits", headsHash) + } + return keys.EscapeName(group) + "." + headsHash, nil +} + +// ParseVerdictKey is the inverse of VerdictKey. +func ParseVerdictKey(key string) (group, headsHash string, err error) { + i := strings.LastIndex(key, ".") + if i < 0 { + return "", "", fmt.Errorf("meshdeliverycheckerclient: %q is not a verdict key, .", key) + } + part, headsHash := key[:i], key[i+1:] + if !isHex12(headsHash) { + return "", "", fmt.Errorf("meshdeliverycheckerclient: %q is not a verdict key: %q is not a heads hash, 12 lowercase hex digits", key, headsHash) + } + if part == "" || strings.Contains(part, ".") { + return "", "", fmt.Errorf("meshdeliverycheckerclient: %q is not a verdict key: the group part is empty or holds a dot", key) + } + if group, err = keys.UnescapeName(part); err != nil { + return "", "", fmt.Errorf("meshdeliverycheckerclient: %q is not a verdict key: %w", key, err) + } + return group, headsHash, nil +} diff --git a/contracts/go/clients/meshdeliverycheckerclient/client_test.go b/contracts/go/clients/meshdeliverycheckerclient/client_test.go new file mode 100644 index 00000000..38c651c0 --- /dev/null +++ b/contracts/go/clients/meshdeliverycheckerclient/client_test.go @@ -0,0 +1,269 @@ +package meshdeliverycheckerclient + +import ( + "encoding/json" + "strings" + "testing" + + checker "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdeliverychecker" +) + +type verdict = checker.VerdictsByGroupHeadsValue + +func passing() verdict { + return verdict{Group: "g", Heads: "0123456789ab", State: VerdictPass, Trunk: map[string]string{"novox/a": "c1"}, + Levels: map[string]checker.VerdictsByGroupHeadsValueLevelsValue{LevelUnit: {Run: 3}, LevelIntegration: {Run: 1}}} +} + +// The rules a verdict keeps to be written or read: each case edits a passing verdict and names what the refusal says. +func TestAVerdictIsRefusedForWhatAReaderCouldNotPlace(t *testing.T) { + if err := CheckVerdict(passing()); err != nil { + t.Fatalf("a passing verdict is refused: %v", err) + } + for name, c := range map[string]struct { + edit func(*verdict) + says string + }{ + "no group": {func(v *verdict) { v.Group = "" }, "no group"}, + "heads of 11 digits": {func(v *verdict) { v.Heads = "0123456789a" }, "12 lowercase hex"}, + "an empty trunk head": {func(v *verdict) { v.Trunk["novox/a"] = "" }, "trunk head"}, + "merged and read": {func(v *verdict) { v.Beside = map[string]string{"novox/a": "c2"} }, "trunk it merged and as a sibling"}, + "nothing carried": {func(v *verdict) { v.CarriedFrom = map[string]string{"novox/a": "c1"} }, "nothing moved"}, + "a running carry": {func(v *verdict) { v.State, v.CarriedFrom = VerdictRunning, map[string]string{"novox/a": "c0"} }, "carried from nothing"}, + "a series to itself": {func(v *verdict) { + v.Series = []checker.VerdictsByGroupHeadsValueSeriesItem{{Module: "m", Artifact: "a", From: "0.1", To: "0.1"}} + }, "same series"}, + "an unknown level": {func(v *verdict) { v.Levels["smoke"] = checker.VerdictsByGroupHeadsValueLevelsValue{} }, "smoke"}, + "a negative count": {func(v *verdict) { v.Levels[LevelUnit] = checker.VerdictsByGroupHeadsValueLevelsValue{Run: -1} }, "negative"}, + "a negative time": {func(v *verdict) { v.Took = &checker.VerdictsByGroupHeadsValueTook{Unit: -1} }, "negative"}, + "a pass without a level": {func(v *verdict) { delete(v.Levels, LevelIntegration) }, "integration level"}, + "a pass that failed": {func(v *verdict) { v.Failed = &checker.VerdictsByGroupHeadsValueFailed{Test: "T"} }, "names no failure"}, + "failures of a pass": {func(v *verdict) { v.Failures = []checker.VerdictsByGroupHeadsValueFailuresItem{{Test: "T"}} }, "names no failures"}, + "a fail naming nothing": {func(v *verdict) { v.State = VerdictFail }, "neither a test nor a machine"}, + "an unknown state": {func(v *verdict) { v.State = "maybe" }, "maybe"}, + "a composition passed without a commit": {func(v *verdict) { + v.Composition = &checker.VerdictsByGroupHeadsValueComposition{State: CompositionPass} + }, "mesh-sdk commit"}, + "the same level over bound twice": {func(v *verdict) { v.OverBound = []string{LevelUnit, LevelUnit} }, "twice"}, + // 1921 (B0, G): the checking program that judged it. + "judged at no full commit": {func(v *verdict) { j := judgedBy(); j.Program.Commit = "0123456"; v.JudgedBy = &j }, "checking program built at no repository, full commit"}, + "judged under no key": {func(v *verdict) { j := judgedBy(); j.Key = "composer-1"; v.JudgedBy = &j }, "composer-1"}, + "judged over no head": {func(v *verdict) { j := judgedBy(); j.Heads = nil; v.JudgedBy = &j }, "no head"}, + "judged over a branch": {func(v *verdict) { j := judgedBy(); j.Heads[0].Commit = "main"; v.JudgedBy = &j }, "novox/mesh-delivery"}, + } { + v := passing() + c.edit(&v) + if err := CheckVerdict(v); err == nil || !strings.Contains(err.Error(), c.says) { + t.Errorf("%s: %v, want a refusal saying %q", name, err, c.says) + } + } + judged := passing() + j := judgedBy() + judged.JudgedBy = &j + if err := CheckVerdict(judged); err != nil { + t.Errorf("a pass judged by a checking program is refused: %v", err) + } + failed := passing() + failed.State, failed.Failed = VerdictFail, &checker.VerdictsByGroupHeadsValueFailed{Machine: "ace"} + if err := CheckVerdict(failed); err != nil { + t.Errorf("a fail naming a machine is refused: %v", err) + } +} + +func TestAKeptVerdictIsAPassOfAPackageAtALevel(t *testing.T) { + t.Run("a preview is refused for what a reader of candidates could not place", previewIsRefused) + ok := checker.KeptByLevelDigestValue{State: VerdictPass, Repository: "novox/mesh-sdk", Package: "./go/keys", Level: LevelUnit, Toolchain: "1.26.0", At: "2026-11-05T10:00:00Z"} + if err := CheckKept(ok); err != nil { + t.Fatalf("a kept pass is refused: %v", err) + } + for name, edit := range map[string]func(*checker.KeptByLevelDigestValue){ + "a fail": func(k *checker.KeptByLevelDigestValue) { k.State = VerdictFail }, + "no owner": func(k *checker.KeptByLevelDigestValue) { k.Repository = "mesh-sdk" }, + "no package": func(k *checker.KeptByLevelDigestValue) { k.Package = "" }, + "an unknown level": func(k *checker.KeptByLevelDigestValue) { k.Level = "smoke" }, + "negative tests": func(k *checker.KeptByLevelDigestValue) { k.Tests = -1 }, + "no toolchain": func(k *checker.KeptByLevelDigestValue) { k.Toolchain = "" }, + "no time": func(k *checker.KeptByLevelDigestValue) { k.At = "" }, + } { + k := ok + edit(&k) + if CheckKept(k) == nil { + t.Errorf("%s was taken", name) + } + } +} + +// A preview is refused for what a reader of candidates could not place. +func previewIsRefused(t *testing.T) { + good := func() checker.PreviewsByRepositoryHeadTrunkValue { + return checker.PreviewsByRepositoryHeadTrunkValue{Repository: "novox/a", Head: "h1", Trunk: "t1", Merge: "m1", + Modules: map[string]checker.PreviewsByRepositoryHeadTrunkValueModulesValue{"mod": {Tree: "tr", Manifest: strings.Repeat("a", 64), + Locks: map[string]string{"go.sum": "x"}, Paths: map[string]string{"src": "o1"}}}} + } + if err := CheckPreview(good()); err != nil { + t.Fatalf("a good preview is refused: %v", err) + } + for name, c := range map[string]struct { + edit func(*checker.PreviewsByRepositoryHeadTrunkValue) + says string + }{ + "a repository with no owner": {func(p *checker.PreviewsByRepositoryHeadTrunkValue) { p.Repository = "a" }, "/"}, + "a head with a dot": {func(p *checker.PreviewsByRepositoryHeadTrunkValue) { p.Head = "h.1" }, "head"}, + "a conflict with a merge": {func(p *checker.PreviewsByRepositoryHeadTrunkValue) { p.Conflicts = []string{"f"} }, "in conflict"}, + "no merge commit": {func(p *checker.PreviewsByRepositoryHeadTrunkValue) { p.Merge = "" }, "no merge commit"}, + "a module with no tree": {func(p *checker.PreviewsByRepositoryHeadTrunkValue) { + m := p.Modules["mod"] + m.Tree = "" + p.Modules["mod"] = m + }, "no tree"}, + "a short manifest digest": {func(p *checker.PreviewsByRepositoryHeadTrunkValue) { + m := p.Modules["mod"] + m.Manifest = "abc" + p.Modules["mod"] = m + }, "manifest digest"}, + "a path outside the module": {func(p *checker.PreviewsByRepositoryHeadTrunkValue) { + m := p.Modules["mod"] + m.Paths = map[string]string{"../x": "o"} + p.Modules["mod"] = m + }, "clean path"}, + } { + p := good() + c.edit(&p) + if err := CheckPreview(p); err == nil || !strings.Contains(err.Error(), c.says) { + t.Errorf("%s: CheckPreview = %v, want it to say %q", name, err, c.says) + } + } + if err := CheckPreview(checker.PreviewsByRepositoryHeadTrunkValue{Repository: "novox/a", Head: "h", Trunk: "t", Conflicts: []string{"f"}}); err != nil { + t.Errorf("a head in conflict is refused: %v", err) + } +} + +// A verdict's key as go/delivery's CheckerVerdictKey wrote it (golden): the group escaped, the heads hash after the +// last dot. +func TestVerdictKeyAsGoDeliveryWroteIt(t *testing.T) { + key, err := VerdictKey("fix/1.2", "0123456789ab") + if err != nil || key != "fix/1=2e2.0123456789ab" { + t.Fatalf("%q %v", key, err) + } + group, heads, err := ParseVerdictKey(key) + if err != nil || group != "fix/1.2" || heads != "0123456789ab" { + t.Fatalf("%q %q %v", group, heads, err) + } + for _, bad := range []string{"nodot", "g.XYZ", ".0123456789ab", "a.b.0123456789ab", "g=zz.0123456789ab"} { + if _, _, err := ParseVerdictKey(bad); err == nil { + t.Errorf("%q parses", bad) + } + } + if _, err := VerdictKey("", "0123456789ab"); err == nil { + t.Error("an empty group keys") + } + if _, err := VerdictKey("g", "0123"); err == nil { + t.Error("a short hash keys") + } +} + +// The codecs write a value at the kept values' version after its rules and read it back through them. +func TestTheCodecsWriteAtTheVersionAndReadBackThroughTheRules(t *testing.T) { + raw, err := EncodeVerdict(passing()) + if err != nil { + t.Fatal(err) + } + if got, err := DecodeVerdict(raw); err != nil || got.V == 0 || got.Group != "g" { + t.Errorf("verdict read back = %+v, %v", got, err) + } + bad := passing() + bad.Heads = "x" + if _, err := EncodeVerdict(bad); err == nil { + t.Error("a verdict whose heads are no hash is written") + } + if _, err := DecodeVerdict([]byte(`{"v":99,"group":"g","heads":"0123456789ab","state":"running"}`)); err == nil { + t.Error("a verdict of an unknown version is read") + } + k := checker.KeptByLevelDigestValue{State: VerdictPass, Repository: "novox/mesh-sdk", Package: "./go/keys", Level: LevelUnit, Toolchain: "1.26.0", At: "2026-11-05T10:00:00Z"} + raw, err = EncodeKept(k) + if err != nil { + t.Fatal(err) + } + if got, err := DecodeKept(raw); err != nil || got.Package != k.Package { + t.Errorf("kept read back = %+v, %v", got, err) + } + k.State = VerdictFail + if _, err := EncodeKept(k); err == nil { + t.Error("a failure is kept") + } + p := checker.PreviewsByRepositoryHeadTrunkValue{Repository: "novox/a", Head: "h1", Trunk: "t1", Conflicts: []string{"f.go"}} + raw, err = EncodePreview(p) + if err != nil { + t.Fatal(err) + } + if got, err := DecodePreview(raw); err != nil || got.Head != "h1" { + t.Errorf("preview read back = %+v, %v", got, err) + } + p.Conflicts = nil + if _, err := EncodePreview(p); err == nil { + t.Error("a preview with no merge commit and no conflict is written") + } + // 1921 (B0, G): a checking program's input is asked of the seat's verb input by its digest, and an answer for + // another digest, or bytes that are not the digest, is refused. + body := []byte(`{"v":1}`) + digest := DigestOf(body) + for name, c := range map[string]struct { + answer any + says string + }{ + "its own bytes": {InputAnswerOf(body), ""}, + "another digest": {checker.InputAnswer{Digest: DigestOf([]byte("x")), Input: InputAnswerOf(body).Input}, "answered the input"}, + "bytes that are not it": {checker.InputAnswer{Digest: digest, Input: InputAnswerOf([]byte(`{"v":2}`)).Input}, "whose digest is"}, + "bytes in no base64": {checker.InputAnswer{Digest: digest, Input: "%%"}, "no base64"}, + "no answer of the layout": {"nothing", "answered no input"}, + } { + got, err := ReadInput(func(key string, b any) (json.RawMessage, error) { + if key != "seat:mesh-delivery-checker.input" || b != (checker.InputInput{Digest: digest}) { + t.Errorf("%s: asked %s %+v", name, key, b) + } + return json.Marshal(c.answer) + }, digest) + switch { + case c.says == "" && (err != nil || string(got) != string(body)): + t.Errorf("%s: %q, %v", name, got, err) + case c.says != "" && (err == nil || !strings.Contains(err.Error(), c.says)): + t.Errorf("%s: %v, want a refusal saying %q", name, err, c.says) + } + } +} + +// The kept and preview keys: a key reads back to its parts, and a part that does not key is refused. +func TestKeptAndPreviewKeysReadBack(t *testing.T) { + digest := strings.Repeat("ab", 32) + key, err := KeptKey(LevelUnit, digest) + if err != nil || key != "unit."+digest { + t.Fatalf("kept key = %q, %v", key, err) + } + if level, d, err := ParseKeptKey(key); err != nil || level != LevelUnit || d != digest { + t.Errorf("kept key read back = %q %q, %v", level, d, err) + } + for _, bad := range []string{"fast." + digest, "unit.XY", "unit"} { + if _, _, err := ParseKeptKey(bad); err == nil { + t.Errorf("kept key %q is read", bad) + } + } + key, err = PreviewKey("novox/mesh-sdk", "h1", "t1") + if err != nil { + t.Fatal(err) + } + if repo, h, tr, err := ParsePreviewKey(key); err != nil || repo != "novox/mesh-sdk" || h != "h1" || tr != "t1" { + t.Errorf("preview key %q read back = %q %q %q, %v", key, repo, h, tr, err) + } + for _, bad := range []string{"novox.h1.t1", "a/b.h1", "a/b.h1.t1.x"} { + if _, _, _, err := ParsePreviewKey(bad); err == nil { + t.Errorf("preview key %q is read", bad) + } + } +} + +func judgedBy() checker.VerdictsByGroupHeadsValueJudgedBy { + const c = "0123456789abcdef0123456789abcdef01234567" + return checker.VerdictsByGroupHeadsValueJudgedBy{ + Program: checker.VerdictsByGroupHeadsValueJudgedByProgram{Repository: "novox/mesh-delivery", Commit: c, Dir: "modules/mesh-delivery-checker", Package: "p"}, + Key: "program-0123456789abcdef", Built: true, Heads: []checker.VerdictsByGroupHeadsValueJudgedByHeadsItem{{Repository: "novox/mesh-delivery", Commit: c}}} +} diff --git a/contracts/go/clients/meshdeliverycheckerclient/codec.go b/contracts/go/clients/meshdeliverycheckerclient/codec.go new file mode 100644 index 00000000..c684e73d --- /dev/null +++ b/contracts/go/clients/meshdeliverycheckerclient/codec.go @@ -0,0 +1,150 @@ +package meshdeliverycheckerclient + +import ( + "encoding/json" + "fmt" + "strings" + + checker "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdeliverychecker" + "git.novox.be/novox/mesh-sdk/go/keys" +) + +// The codecs of the three states the checker writes and others read (the view and the gate read verdicts, the indexer reads +// previews, the checker and the tools read kept verdicts): a value is written at the version of the kept values after its rules +// (CheckVerdict, CheckKept, CheckPreview) and read back through the same rules. The checker's owed state is read by no other +// part and keeps its own codec. + +// EncodeVerdict is v as JSON at the kept values' version, refused by CheckVerdict's rules. +func EncodeVerdict(v checker.VerdictsByGroupHeadsValue) ([]byte, error) { + if err := CheckVerdict(v); err != nil { + return nil, err + } + v.V = keys.Version + return json.Marshal(v) +} + +// DecodeVerdict reads a `verdicts` value; it refuses an unknown version (keys.ErrUnknownVersion) and what EncodeVerdict refuses. +func DecodeVerdict(data []byte) (checker.VerdictsByGroupHeadsValue, error) { + var v checker.VerdictsByGroupHeadsValue + if err := decode(data, &v); err != nil { + return v, err + } + if err := CheckVerdict(v); err != nil { + return checker.VerdictsByGroupHeadsValue{}, err + } + return v, nil +} + +// EncodeKept is k as JSON at the kept values' version, refused by CheckKept's rules. +func EncodeKept(k checker.KeptByLevelDigestValue) ([]byte, error) { + if err := CheckKept(k); err != nil { + return nil, err + } + k.V = keys.Version + return json.Marshal(k) +} + +// DecodeKept reads a `kept` value; it refuses an unknown version and what EncodeKept refuses. +func DecodeKept(data []byte) (checker.KeptByLevelDigestValue, error) { + var k checker.KeptByLevelDigestValue + if err := decode(data, &k); err != nil { + return k, err + } + if err := CheckKept(k); err != nil { + return checker.KeptByLevelDigestValue{}, err + } + return k, nil +} + +// EncodePreview is p as JSON at the kept values' version, refused by CheckPreview's rules. +func EncodePreview(p checker.PreviewsByRepositoryHeadTrunkValue) ([]byte, error) { + if err := CheckPreview(p); err != nil { + return nil, err + } + p.V = keys.Version + return json.Marshal(p) +} + +// DecodePreview reads a `previews` value; it refuses an unknown version and what EncodePreview refuses. +func DecodePreview(data []byte) (checker.PreviewsByRepositoryHeadTrunkValue, error) { + var p checker.PreviewsByRepositoryHeadTrunkValue + if err := decode(data, &p); err != nil { + return p, err + } + if err := CheckPreview(p); err != nil { + return checker.PreviewsByRepositoryHeadTrunkValue{}, err + } + return p, nil +} + +func decode(data []byte, into any) error { + if err := keys.CheckVersion(data); err != nil { + return err + } + if err := json.Unmarshal(data, into); err != nil { + return fmt.Errorf("meshdeliverycheckerclient: value does not fit its shape: %w", err) + } + return nil +} + +// KeptKey is the key, in the state kept, of a kept verdict: `.`, the level unit or integration and the digest, +// lowercase hex, of everything that package's tests read at that level as the language's build module names it. +func KeptKey(level, digest string) (string, error) { + if err := checkLevel(level); err != nil { + return "", err + } + if digest == "" || strings.Trim(digest, "0123456789abcdef") != "" { + return "", fmt.Errorf("meshdeliverycheckerclient: %q is not a digest, lowercase hex", digest) + } + return checker.KeptByLevelDigestKey(level, digest), nil +} + +// ParseKeptKey is the inverse of KeptKey. +func ParseKeptKey(key string) (level, digest string, err error) { + level, digest, ok := checker.ParseKeptByLevelDigestKey(key) + if !ok { + return "", "", fmt.Errorf("meshdeliverycheckerclient: %q is not a kept key, .", key) + } + if _, err = KeptKey(level, digest); err != nil { + return "", "", fmt.Errorf("meshdeliverycheckerclient: %q is not a kept key: %w", key, err) + } + return level, digest, nil +} + +// PreviewKey is the key, in the state previews, of a head merged onto a trunk commit: `..`, the +// repository's owner and name each escaped (keys.EscapeName). +func PreviewKey(repository, head, trunk string) (string, error) { + if err := CheckRepository(repository); err != nil { + return "", err + } + if err := keys.CheckName("head", head); err != nil { + return "", err + } + if err := keys.CheckName("trunk commit", trunk); err != nil { + return "", err + } + owner, name, _ := strings.Cut(repository, "/") + return checker.PreviewsByRepositoryHeadTrunkKey(keys.EscapeName(owner)+"/"+keys.EscapeName(name), head, trunk), nil +} + +// ParsePreviewKey is the inverse of PreviewKey. +func ParsePreviewKey(key string) (repository, head, trunk string, err error) { + repo, head, trunk, ok := checker.ParsePreviewsByRepositoryHeadTrunkKey(key) + if !ok || strings.Contains(trunk, ".") { + return "", "", "", fmt.Errorf("meshdeliverycheckerclient: %q is not a preview key, ..", key) + } + owner, name, found := strings.Cut(repo, "/") + if !found { + return "", "", "", fmt.Errorf("meshdeliverycheckerclient: %q is not a preview key: the repository has no slash", key) + } + owner, err1 := keys.UnescapeName(owner) + name, err2 := keys.UnescapeName(name) + if err1 != nil || err2 != nil { + return "", "", "", fmt.Errorf("meshdeliverycheckerclient: %q is not a preview key: the repository does not unescape", key) + } + repository = owner + "/" + name + if _, err := PreviewKey(repository, head, trunk); err != nil { + return "", "", "", fmt.Errorf("meshdeliverycheckerclient: %q is not a preview key: %w", key, err) + } + return repository, head, trunk, nil +} diff --git a/contracts/go/clients/meshdeliverycheckerclient/go.mod b/contracts/go/clients/meshdeliverycheckerclient/go.mod new file mode 100644 index 00000000..ba6062bf --- /dev/null +++ b/contracts/go/clients/meshdeliverycheckerclient/go.mod @@ -0,0 +1,8 @@ +module git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliverycheckerclient + +go 1.26.0 + +require ( + git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdeliverychecker v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000 +) diff --git a/contracts/go/clients/meshdeliverycheckerclient/input.go b/contracts/go/clients/meshdeliverycheckerclient/input.go new file mode 100644 index 00000000..1594df80 --- /dev/null +++ b/contracts/go/clients/meshdeliverycheckerclient/input.go @@ -0,0 +1,53 @@ +package meshdeliverycheckerclient + +import ( + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "fmt" + + checker "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdeliverychecker" +) + +// InputVerb is the seat's verb input as a manifest's `invokes` names it and a run ask's input names it: asked of the +// seat, on no one machine (novox/hq ADR 0644, ADR 0656; issue 1921). A checking program's input is kept by the checker +// by the sha256 of its exact bytes, and asked of it by the build module that runs the program (ADR 0596 §2). +const InputVerb = "seat:" + checker.Seat + "." + checker.VerbInput + +// InputAnswerOf is the verb input's answer for body: its digest and its exact bytes, base64. +func InputAnswerOf(body []byte) checker.InputAnswer { + return checker.InputAnswer{Digest: DigestOf(body), Input: base64.StdEncoding.EncodeToString(body)} +} + +// DigestOf is the digest a checking program's input is kept and asked by: the sha256 of its bytes, 64 lowercase hex +// digits. +func DigestOf(body []byte) string { + sum := sha256.Sum256(body) + return hex.EncodeToString(sum[:]) +} + +// ReadInput asks the verb input, with ask (a tools bundle's stdio.Ask, a process's Conn.Call), for the input kept by +// digest and answers its bytes, refusing an answer for another digest and bytes whose sha256 is not the digest. +func ReadInput(ask func(key string, body any) (json.RawMessage, error), digest string) ([]byte, error) { + const who = "meshdeliverycheckerclient: " + raw, err := ask(InputVerb, checker.InputInput{Digest: digest}) + if err != nil { + return nil, fmt.Errorf(who+"asking %s for the input %s: %w", InputVerb, digest, err) + } + var a checker.InputAnswer + if err := json.Unmarshal(raw, &a); err != nil { + return nil, fmt.Errorf(who+"%s answered no input for %s: %w", InputVerb, digest, err) + } + if a.Digest != digest { + return nil, fmt.Errorf(who+"%s answered the input %q, asked %s", InputVerb, a.Digest, digest) + } + body, err := base64.StdEncoding.DecodeString(a.Input) + if err != nil { + return nil, fmt.Errorf(who+"%s answered the input %s in no base64: %w", InputVerb, digest, err) + } + if got := DigestOf(body); got != digest { + return nil, fmt.Errorf(who+"the input %s answered %d bytes whose digest is %s", digest, len(body), got) + } + return body, nil +} diff --git a/contracts/go/clients/meshdeliveryclient/client.go b/contracts/go/clients/meshdeliveryclient/client.go new file mode 100644 index 00000000..5754e183 --- /dev/null +++ b/contracts/go/clients/meshdeliveryclient/client.go @@ -0,0 +1,323 @@ +// Package meshdeliveryclient holds the logic the holders and callers of the seat mesh-delivery share over its contract +// (novox/hq ADR 0625 §4, ADR 0634 §2, ADR 0635 §5): the keys of its states `merges`, `asks` and `owed` and the rules their +// values keep, the id the teller gives the router for an ask, and the act an ask's options bind. The gate writes them, the +// forge bridge, the teller and the assigner read them; the contract's decoding is strict by schema only, so these rules are +// written once here and no caller copies them. +package meshdeliveryclient + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "regexp" + "sort" + "strconv" + "strings" + + seat "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdelivery" + "git.novox.be/novox/mesh-sdk/go/keys" +) + +// AskKindMerge is the kind of the gate's ask for a group's merge, the first part of its key. +const AskKindMerge = "merge" + +// VerbMerge is the act an ask for a group's merge binds in its options, `mesh-delivery.merge` (Approve). +const VerbMerge = "merge" + +// Head is one pull request at one commit: the repository as `/` and the full sha. +type Head struct { + Repository string `json:"repository"` + Number int `json:"number"` + Head string `json:"head"` +} + +// HeadsHash is the first 12 hex digits of the sha256 over the lines `#=`, sorted and joined by a +// newline: the same for every order of heads, different when any head changes. An empty list is refused. +func HeadsHash(heads []Head) (string, error) { + if len(heads) == 0 { + return "", fmt.Errorf("meshdeliveryclient: a heads hash needs at least one pull request") + } + lines := make([]string, len(heads)) + for i, h := range heads { + lines[i] = fmt.Sprintf("%s#%d=%s", h.Repository, h.Number, h.Head) + } + sort.Strings(lines) + sum := sha256.Sum256([]byte(strings.Join(lines, "\n"))) + return hex.EncodeToString(sum[:])[:12], nil +} + +var hashOK = regexp.MustCompile(`^[0-9a-f]{12}$`) + +func groupKey(group string) (string, error) { + if group == "" { + return "", fmt.Errorf("meshdeliveryclient: the group name is empty") + } + return keys.EscapeName(group), nil +} + +// MergesKey is the key, in the state merges, of a group: ``, escaped so a branch such as `fix/1.2` keys. +func MergesKey(group string) (string, error) { return groupKey(group) } + +// MergeAskKey is the key, in the state asks, of the ask for merging a group at these heads: `merge..`. +// The teller's answer has the same key. +func MergeAskKey(group string, heads []Head) (string, error) { + part, err := groupKey(group) + if err != nil { + return "", err + } + hash, err := HeadsHash(heads) + if err != nil { + return "", err + } + return AskKindMerge + "." + part + "." + hash, nil +} + +// ParseAskKey is the inverse of MergeAskKey: the kind, the group and the heads hash. +func ParseAskKey(key string) (kind, group, hash string, err error) { + parts := strings.Split(key, ".") + if len(parts) != 3 || parts[0] != AskKindMerge { + return "", "", "", fmt.Errorf("meshdeliveryclient: %q is not an ask key, merge..", key) + } + if parts[1] == "" { + return "", "", "", fmt.Errorf("meshdeliveryclient: %q is not an ask key: the group name is empty", key) + } + if group, err = keys.UnescapeName(parts[1]); err != nil { + return "", "", "", fmt.Errorf("meshdeliveryclient: %q is not an ask key: %w", key, err) + } + if !hashOK.MatchString(parts[2]) { + return "", "", "", fmt.Errorf("meshdeliveryclient: %q is not an ask key: %q is not 12 hex digits", key, parts[2]) + } + return parts[0], group, parts[2], nil +} + +// SettleGroupPrefix begins the group of a settle ask (novox/hq ADR 0556 clause 5): a person's ask to settle deliveries held +// because their merge reached the trunk without a passing check. It is kept in the asks under MergeAskKey like a merge ask, +// so the teller carries it unchanged. Git forbids a colon in a branch name, so no pull request's group ever begins with it. +const SettleGroupPrefix = "settle:" + +// SettleGroup is the group of the settle ask of these delivery ids: the prefix and twelve hex digits of the sha256 over the +// ids, sorted and joined by a newline, so the same deliveries asked again keep one key. +func SettleGroup(ids []string) string { + sorted := append([]string(nil), ids...) + sort.Strings(sorted) + sum := sha256.Sum256([]byte(strings.Join(sorted, "\n"))) + return SettleGroupPrefix + hex.EncodeToString(sum[:])[:12] +} + +// AskVerb is the act the options of an ask bind, by its group: a settle ask's `mesh-delivery.settle`, any other +// `mesh-delivery.merge`. The gate composes the act with it and the teller checks a warrant against it. +func AskVerb(group string) string { + if strings.HasPrefix(group, SettleGroupPrefix) { + return seat.Seat + "." + seat.VerbSettle + } + return seat.Seat + "." + VerbMerge +} + +// AskID is the id the teller gives the router for the ask at askKey. The router's ids refuse dots, so in the readable part a +// dot becomes `-` and any other byte outside letters, digits, `_` and `-` becomes `_`; that part is cut to fit, and every id +// ends in `-` and the first 12 hex digits of the sha256 of the whole key, so two keys never give one id. It cannot be read +// back: the teller keeps the key beside it. +func AskID(askKey string) string { + const most = 64 // the router's bound on an id + const tail = 13 // "-" and 12 hex digits + readable := []byte(askKey) + for i, c := range readable { + switch { + case c >= 'A' && c <= 'Z', c >= 'a' && c <= 'z', c >= '0' && c <= '9': + case c == '.': + readable[i] = '-' + default: // a slash, an equals sign or any other byte the router's id refuses + readable[i] = '_' + } + } + if len(readable) > 0 && (readable[0] == '-' || readable[0] == '_') { + readable[0] = 'k' // an id starts with a letter or digit + } + if len(readable) > most-tail { + readable = readable[:most-tail] + } + sum := sha256.Sum256([]byte(askKey)) + return string(readable) + "-" + hex.EncodeToString(sum[:])[:12] +} + +// AskingID is the id of the asking-th writing of the ask at askKey. Asking 0 and 1 give AskID(askKey), so the first asking's +// id is unchanged; asking n of 2 or more gives the AskID form of askKey + "." + n, so each asking has an id of its own and the +// router, which drops an ask under an id it keeps as a redelivery, takes each as new and an answer to an earlier asking is +// never taken for a later one. +func AskingID(askKey string, asking int) string { + if asking < 2 { + return AskID(askKey) + } + return AskID(askKey + "." + strconv.Itoa(asking)) +} + +// The acts the gate owes the forge, the last part of an owed key. +const ( + OwedStatus = "status" + OwedView = "view" + OwedNote = "note" + OwedComment = "comment" +) + +// The status contexts the mesh owes the forge. +const ( + StatusContextMergeGate = "mesh/merge-gate" + StatusContextDelivery = "mesh/delivery" + StatusContextDeliveryGroup = "mesh/delivery-group" + StatusContextRepoCheck = "mesh/repo-check" + StatusContextCheck = "mesh/check" +) + +var statusContexts = []string{StatusContextMergeGate, StatusContextDelivery, StatusContextDeliveryGroup, StatusContextRepoCheck, StatusContextCheck} + +// StatusContexts is the contexts, in a new slice. +func StatusContexts() []string { return append([]string(nil), statusContexts...) } + +// The states a status the gate owes the forge may carry. +var statusStates = []string{"pending", "success", "failure", "error", "warning"} + +func checkStatusContext(context string) error { + for _, c := range statusContexts { + if context == c { + return nil + } + } + return fmt.Errorf("meshdeliveryclient: the status context %q is not one of %s", context, strings.Join(statusContexts, ", ")) +} + +// ParseOwedAct splits an owed act into its kind and, for a status, its context, for a comment its sequence as digits. The act +// is `status=`, `view`, `note` or `comment.`. +func ParseOwedAct(act string) (kind, context string, err error) { + kind, context, hasContext := strings.Cut(act, "=") + switch kind { + case OwedStatus: + if !hasContext { + return "", "", fmt.Errorf("meshdeliveryclient: the owed act %q is a status without its context, status=", act) + } + if err := checkStatusContext(context); err != nil { + return "", "", err + } + return kind, context, nil + case OwedView, OwedNote: + if hasContext { + return "", "", fmt.Errorf("meshdeliveryclient: the owed act %q takes no context", act) + } + return kind, "", nil + } + if kind, seq, ok := strings.Cut(act, "."); ok && kind == OwedComment { + if n, err := strconv.Atoi(seq); err != nil || n < 1 || strconv.Itoa(n) != seq { + return "", "", fmt.Errorf("meshdeliveryclient: the owed act %q is a comment without its sequence, comment. with seq of 1 or more", act) + } + return OwedComment, seq, nil + } + return "", "", fmt.Errorf("meshdeliveryclient: the owed act %q is not status=, view, note or comment.", act) +} + +// OwedCommentAct is the act of the seq-th comment on a head: `comment.`, seq of 1 or more, so a second comment has a key +// of its own and does not overwrite the first. +func OwedCommentAct(seq int) (string, error) { + if seq < 1 { + return "", fmt.Errorf("meshdeliveryclient: a comment's sequence is 1 or more, not %d", seq) + } + return OwedComment + "." + strconv.Itoa(seq), nil +} + +func escapeRepository(repository string) (string, error) { + owner, name, ok := strings.Cut(repository, "/") + if !ok || owner == "" || name == "" || strings.Contains(name, "/") { + return "", fmt.Errorf("meshdeliveryclient: the repository %q is not /, with exactly one slash", repository) + } + return keys.EscapeName(owner) + "/" + keys.EscapeName(name), nil +} + +func unescapeRepository(part string) (string, error) { + owner, name, ok := strings.Cut(part, "/") + if !ok || owner == "" || name == "" || strings.Contains(name, "/") { + return "", fmt.Errorf("meshdeliveryclient: the repository %q is not /, with exactly one slash", part) + } + o, err := keys.UnescapeName(owner) + if err != nil { + return "", err + } + n, err := keys.UnescapeName(name) + if err != nil { + return "", err + } + return o + "/" + n, nil +} + +// OwedKey is the key, in the state owed, of an act the gate owes the forge on a pull request at a head: +// `...`. The number is 0 for a commit no pull request names, and never negative. +func OwedKey(repository string, number int, head, act string) (string, error) { + repo, err := escapeRepository(repository) + if err != nil { + return "", err + } + if number < 0 { + return "", fmt.Errorf("meshdeliveryclient: an owed key's pull request number is 0 (none) or more, not %d", number) + } + if err := keys.CheckName("head", head); err != nil { + return "", err + } + if _, _, err := ParseOwedAct(act); err != nil { + return "", err + } + return repo + "." + strconv.Itoa(number) + "." + head + "." + act, nil +} + +// ParseOwedKey is the inverse of OwedKey; act is whole, as `status=` for a status. +func ParseOwedKey(key string) (repository string, number int, head, act string, err error) { + parts := strings.SplitN(key, ".", 4) // a comment's act holds a dot + if len(parts) != 4 { + return "", 0, "", "", fmt.Errorf("meshdeliveryclient: %q is not an owed key, ...", key) + } + number, convErr := strconv.Atoi(parts[1]) + if convErr != nil { + return "", 0, "", "", fmt.Errorf("meshdeliveryclient: %q is not an owed key: %q is not a pull request number", key, parts[1]) + } + if repository, err = unescapeRepository(parts[0]); err != nil { + return "", 0, "", "", fmt.Errorf("meshdeliveryclient: %q is not an owed key: %w", key, err) + } + if _, err := OwedKey(repository, number, parts[2], parts[3]); err != nil { + return "", 0, "", "", fmt.Errorf("meshdeliveryclient: %q is not an owed key: %w", key, err) + } + return repository, number, parts[2], parts[3], nil +} + +// CheckOwed refuses an owed value the forge bridge could not perform: a status needs its context (one the mesh owes), a +// state of the list and a target; a view and a comment need text; a note needs text and a ref. +func CheckOwed(o seat.OwedByRepositoryNumberHeadActValue) error { + const who = "meshdeliveryclient: " + switch o.Act { + case OwedStatus: + if o.Context == "" { + return fmt.Errorf(who + "an owed status has no context") + } + if err := checkStatusContext(o.Context); err != nil { + return err + } + if o.Target == "" { + return fmt.Errorf(who + "an owed status has no target") + } + for _, s := range statusStates { + if o.State == s { + return nil + } + } + return fmt.Errorf(who+"the owed status state %q is not %s", o.State, strings.Join(statusStates, ", ")) + case OwedView, OwedComment: + if o.Text == "" { + return fmt.Errorf(who+"an owed %s has no text", o.Act) + } + case OwedNote: + if o.Text == "" { + return fmt.Errorf(who + "an owed note has no text") + } + if o.Ref == "" { + return fmt.Errorf(who + "an owed note has no ref") + } + default: + return fmt.Errorf(who+"the owed act %q is not status, view, note or comment", o.Act) + } + return nil +} diff --git a/contracts/go/clients/meshdeliveryclient/client_test.go b/contracts/go/clients/meshdeliveryclient/client_test.go new file mode 100644 index 00000000..08718e85 --- /dev/null +++ b/contracts/go/clients/meshdeliveryclient/client_test.go @@ -0,0 +1,129 @@ +package meshdeliveryclient + +import ( + "encoding/json" + "os" + "strings" + "testing" + + seat "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdelivery" +) + +// The keys, ids and acts equal what the gate and the teller have always written (the expected strings are the trunk's own +// output for the same input), so a value written before is read after. +func TestTheAsksAndOwedActsKeepTheirFormsAndRules(t *testing.T) { + heads := []Head{{Repository: "novox/a", Number: 3, Head: "abc"}, {Repository: "novox/b", Number: 1, Head: "def"}} + key, err := MergeAskKey("fix/1.2", heads) + if err != nil || key != "merge.fix/1=2e2.d106c7950d28" { + t.Fatalf("MergeAskKey = %q, %v", key, err) + } + if rev, _ := MergeAskKey("fix/1.2", []Head{heads[1], heads[0]}); rev != key { + t.Errorf("the heads' order changes the key: %q", rev) + } + if kind, group, hash, err := ParseAskKey(key); err != nil || kind != "merge" || group != "fix/1.2" || hash != "d106c7950d28" { + t.Errorf("ParseAskKey = %q %q %q %v", kind, group, hash, err) + } + for _, bad := range []string{"merge.g", "settle.g.d106c7950d28", "merge..d106c7950d28", "merge.g.D106C7950D28", "merge.g.d106"} { + if _, _, _, err := ParseAskKey(bad); err == nil { + t.Errorf("ParseAskKey(%q) reads a wrong key", bad) + } + } + if _, err := MergeAskKey("", heads); err == nil { + t.Error("an empty group keys") + } + if _, err := MergeAskKey("g", nil); err == nil { + t.Error("no heads keys") + } + if id := AskID(key); id != "merge-fix_1_2e2-d106c7950d28-400030402c88" || AskingID(key, 1) != id || AskingID(key, 0) != id { + t.Errorf("AskID = %q", id) + } + if id := AskingID(key, 3); id != "merge-fix_1_2e2-d106c7950d28-3-ea06879e48d8" || len(id) > 64 { + t.Errorf("AskingID(3) = %q", id) + } + if g := SettleGroup([]string{"b", "a"}); g != "settle:7e18f737311b" || g != SettleGroup([]string{"a", "b"}) { + t.Errorf("SettleGroup = %q", g) + } + if AskVerb(SettleGroup([]string{"a"})) != "mesh-delivery.settle" || AskVerb("fix/1") != "mesh-delivery.merge" { + t.Error("AskVerb binds the wrong act") + } + + for act, want := range map[string]string{"status=mesh/check": "novox/a.3.abc.status=mesh/check", "view": "novox/a.3.abc.view", "comment.2": "novox/a.3.abc.comment.2"} { + k, err := OwedKey("novox/a", 3, "abc", act) + if err != nil || k != want { + t.Errorf("OwedKey(%q) = %q, %v", act, k, err) + } + if repo, n, head, a, err := ParseOwedKey(k); err != nil || repo != "novox/a" || n != 3 || head != "abc" || a != act { + t.Errorf("ParseOwedKey(%q) = %q %d %q %q %v", k, repo, n, head, a, err) + } + } + for _, act := range []string{"status", "status=mesh/other", "view=x", "comment", "comment.0", "comment.01", "other"} { + if _, err := OwedKey("novox/a", 3, "abc", act); err == nil { + t.Errorf("OwedKey takes the act %q", act) + } + } + if _, err := OwedKey("novox", 0, "abc", "view"); err == nil { + t.Error("a repository with no slash keys") + } + if _, err := OwedKey("novox/a", -1, "abc", "view"); err == nil { + t.Error("a negative number keys") + } + if act, err := OwedCommentAct(2); err != nil || act != "comment.2" { + t.Errorf("OwedCommentAct = %q, %v", act, err) + } + + type owed = seat.OwedByRepositoryNumberHeadActValue + for name, c := range map[string]struct { + v owed + says string + }{ + "a good status": {owed{Act: "status", Context: StatusContextCheck, Target: "http://x", State: "success"}, ""}, + "a good note": {owed{Act: "note", Text: "t", Ref: "mesh-plan"}, ""}, + "a status no target": {owed{Act: "status", Context: StatusContextCheck, State: "success"}, "no target"}, + "a status wrong state": {owed{Act: "status", Context: StatusContextCheck, Target: "x", State: "done"}, "state"}, + "a status no context": {owed{Act: "status", Target: "x", State: "success"}, "no context"}, + "a view no text": {owed{Act: "view"}, "no text"}, + "a note no ref": {owed{Act: "note", Text: "t"}, "no ref"}, + "an unknown act": {owed{Act: "merge"}, "not status"}, + } { + err := CheckOwed(c.v) + if (c.says == "") != (err == nil) || (err != nil && !strings.Contains(err.Error(), c.says)) { + t.Errorf("%s: CheckOwed = %v, want %q", name, err, c.says) + } + } +} + +// The removed verbs are the optional rows of the seat's definition that say they were removed, each with the replacement +// the definition gives: the list and the definition cannot drift apart. +func TestTheRemovedVerbsAreTheDefinitionsRemovedRows(t *testing.T) { + raw, err := os.ReadFile("../../../seats/mesh-delivery/seat.json") + if err != nil { + t.Fatal(err) + } + var def struct { + Verbs []struct { + Name string `json:"name"` + Description string `json:"description"` + Optional bool `json:"optional"` + } `json:"verbs"` + } + if err := json.Unmarshal(raw, &def); err != nil { + t.Fatal(err) + } + removed := RemovedVerbs() + rows := 0 + for _, v := range def.Verbs { + if !strings.HasPrefix(v.Description, RemovedPrefix) { + if _, in := removed[v.Name]; in { + t.Errorf("%s is in the removed list, the definition keeps it", v.Name) + } + continue + } + rows++ + if want, in := removed[v.Name]; !in || !v.Optional || v.Description != RemovedPrefix+want { + t.Errorf("%s: in the list %v, optional %v, described %q, want %q", v.Name, in, v.Optional, v.Description, RemovedPrefix+want) + } + } + if rows != len(removed) || rows == 0 { + t.Errorf("the definition removes %d verbs, the list names %d", rows, len(removed)) + } +} diff --git a/contracts/go/clients/meshdeliveryclient/gate.go b/contracts/go/clients/meshdeliveryclient/gate.go new file mode 100644 index 00000000..de79bd28 --- /dev/null +++ b/contracts/go/clients/meshdeliveryclient/gate.go @@ -0,0 +1,407 @@ +package meshdeliveryclient + +import ( + "bytes" + "encoding/json" + "fmt" + "reflect" + "strconv" + "strings" + "time" + + seat "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdelivery" + "git.novox.be/novox/mesh-sdk/go/keys" +) + +// The values of the seat's states `source`, `merges`, `asks`, `owed` and `turns`, in the seat's types: each is stamped +// with Version when encoded, read strictly (an unknown field refused) and its version checked when decoded, and its rules +// held on both sides, so the gate that writes them and the readers (the forge bridge, the teller, the assigner, the view) +// agree. What the schema cannot say is written here once (ADR 0625 §4). + +// Version is the layout version of the seat's values. +const Version = 1 + +// MergeState is the state of a pull request in a merge. +type MergeState string + +// The states a merge's pull request is in. +const ( + MergeMerging MergeState = "merging" + MergeMerged MergeState = "merged" + MergeRefused MergeState = "refused" +) + +// SourceMainKey is the one key of the state source. +const SourceMainKey = "main" + +func encodeValue(v any, check error, stamp func()) ([]byte, error) { + if check != nil { + return nil, check + } + stamp() + return json.Marshal(v) +} + +// ApprovedKey is the key, in a merge's approved heads, of a pull request: `@`. +func ApprovedKey(repository string, number int) string { + return repository + "@" + strconv.Itoa(number) +} + +// TurnsKey is the key, in the state turns, of a repository: `.`, lower-case and escaped. +func TurnsKey(repository string) (string, error) { + repository = strings.ToLower(repository) + part, err := escapeRepository(repository) + if err != nil { + return "", err + } + return strings.Replace(part, "/", ".", 1), nil +} + +// ParseTurnsKey is the inverse of TurnsKey; a key that does not name a lower-case / is refused. +func ParseTurnsKey(key string) (string, error) { + owner, name, ok := strings.Cut(key, ".") + if !ok || owner == "" || name == "" || strings.Contains(name, ".") { + return "", fmt.Errorf("meshdeliveryclient: %q is not a turns key, .", key) + } + repository, err := unescapeRepository(owner + "/" + name) + if err != nil { + return "", fmt.Errorf("meshdeliveryclient: %q is not a turns key: %w", key, err) + } + if again, err := TurnsKey(repository); err != nil || again != key { + return "", fmt.Errorf("meshdeliveryclient: %q is not a turns key: it does not name a lowercase /", key) + } + return repository, nil +} + +// ----- source + +// CheckSource refuses a source naming no repository, a repository with no name or an empty head. +func CheckSource(s seat.SourceByMainValue) error { + if len(s.Heads) == 0 { + return fmt.Errorf("meshdeliveryclient: a source names no repository") + } + for repository, head := range s.Heads { + if repository == "" { + return fmt.Errorf("meshdeliveryclient: a source names a repository with no name") + } + if head == "" { + return fmt.Errorf("meshdeliveryclient: the source's head of %s is empty", repository) + } + } + return nil +} + +// EncodeSource is s as the value of the key main. +func EncodeSource(s seat.SourceByMainValue) ([]byte, error) { + return encodeValue(&s, CheckSource(s), func() { s.V = Version }) +} + +// DecodeSource reads the value of the key main. +func DecodeSource(data []byte) (seat.SourceByMainValue, error) { + if err := keys.CheckVersion(data); err != nil { + return seat.SourceByMainValue{}, err + } + s, err := seat.DecodeSourceByMainValue(data) + if err != nil { + return s, fmt.Errorf("meshdeliveryclient: a source does not fit its shape: %w", err) + } + return s, CheckSource(s) +} + +// SameSource says whether two sources are the same. +func SameSource(a, b seat.SourceByMainValue) bool { return reflect.DeepEqual(a, b) } + +// ----- merge + +// CheckMerge refuses a merge naming no group, ask or pull request, a pull request outside the approved heads or at +// another head than the Approve was for, and a state that lacks what it needs: merged its commit, refused the forge's words. +func CheckMerge(m seat.MergesByGroupValue) error { + if m.Group == "" { + return fmt.Errorf("meshdeliveryclient: a merge names no group") + } + if m.Ask == "" { + return fmt.Errorf("meshdeliveryclient: a merge names no ask") + } + if len(m.Pulls) == 0 { + return fmt.Errorf("meshdeliveryclient: a merge names no pull request") + } + for key, sha := range m.Approved { + if sha == "" { + return fmt.Errorf("meshdeliveryclient: the approved head of %s is empty", key) + } + } + for _, p := range m.Pulls { + who := fmt.Sprintf("%s#%d", p.Repository, p.Number) + if len(m.Approved) > 0 { + sha, ok := m.Approved[ApprovedKey(p.Repository, int(p.Number))] + if !ok { + return fmt.Errorf("meshdeliveryclient: the pull request %s is handed to the merge but is not among the approved heads", who) + } + if sha != p.Head { + return fmt.Errorf("meshdeliveryclient: the pull request %s is handed at head %q but the Approve was for %q", who, p.Head, sha) + } + } + switch MergeState(p.State) { + case MergeMerging: + case MergeMerged: + if p.Commit == "" { + return fmt.Errorf("meshdeliveryclient: the merged pull request %s has no merge commit", who) + } + case MergeRefused: + if p.Words == "" { + return fmt.Errorf("meshdeliveryclient: the refused pull request %s has no words of the forge", who) + } + default: + return fmt.Errorf("meshdeliveryclient: the state %q of %s is not merging, merged or refused", p.State, who) + } + } + return nil +} + +// EncodeMerge is m as the value of a group's key. +func EncodeMerge(m seat.MergesByGroupValue) ([]byte, error) { + return encodeValue(&m, CheckMerge(m), func() { m.V = Version }) +} + +// DecodeMerge reads the value of a group's key. +func DecodeMerge(data []byte) (seat.MergesByGroupValue, error) { + if err := keys.CheckVersion(data); err != nil { + return seat.MergesByGroupValue{}, err + } + m, err := seat.DecodeMergesByGroupValue(data) + if err != nil { + return m, fmt.Errorf("meshdeliveryclient: a merge does not fit its shape: %w", err) + } + return m, CheckMerge(m) +} + +// SameMerge says whether two merges are the same. +func SameMerge(a, b seat.MergesByGroupValue) bool { return reflect.DeepEqual(a, b) } + +// ----- ask + +// HeadsOf are an ask's heads as Heads (for HeadsHash and MergeAskKey). +func HeadsOf(items []seat.AsksByMergeGroupHeadsValueHeadsItem) []Head { + out := make([]Head, len(items)) + for i, h := range items { + out[i] = Head{Repository: h.Repository, Number: int(h.Number), Head: h.Head} + } + return out +} + +// CheckAsk refuses an ask with no group or heads, a negative count, or an ask that is no object. +func CheckAsk(a seat.AsksByMergeGroupHeadsValue) error { + if a.Asking < 0 { + return fmt.Errorf("meshdeliveryclient: the ask's asking %d is negative", a.Asking) + } + if _, err := MergeAskKey(a.Group, HeadsOf(a.Heads)); err != nil { + return fmt.Errorf("meshdeliveryclient: an ask needs its group and at least one pull request's head: %w", err) + } + raw, err := json.Marshal(a.Ask) + var fields map[string]json.RawMessage + if err != nil || len(bytes.TrimSpace(raw)) == 0 || json.Unmarshal(raw, &fields) != nil || fields == nil { + return fmt.Errorf("meshdeliveryclient: the value carries no router's ask") + } + return nil +} + +// EncodeAsk is a as the value of a merge ask's key; a.Ask may be a json.RawMessage. +func EncodeAsk(a seat.AsksByMergeGroupHeadsValue) ([]byte, error) { + return encodeValue(&a, CheckAsk(a), func() { a.V = Version }) +} + +// DecodeAsk reads the value of a merge ask's key; its Ask is the router's ask as a decoded object. +func DecodeAsk(data []byte) (seat.AsksByMergeGroupHeadsValue, error) { + if err := keys.CheckVersion(data); err != nil { + return seat.AsksByMergeGroupHeadsValue{}, err + } + a, err := seat.DecodeAsksByMergeGroupHeadsValue(data) + if err != nil { + return a, fmt.Errorf("meshdeliveryclient: an ask does not fit its shape: %w", err) + } + return a, CheckAsk(a) +} + +// SameAsk says whether two asks are the same (the router's ask by its content). +func SameAsk(a, b seat.AsksByMergeGroupHeadsValue) bool { return reflect.DeepEqual(a, b) } + +// ----- owed + +// EncodeOwed is o as the value of an owed key, after CheckOwed. +func EncodeOwed(o seat.OwedByRepositoryNumberHeadActValue) ([]byte, error) { + return encodeValue(&o, CheckOwed(o), func() { o.V = Version }) +} + +// DecodeOwed reads the value of an owed key. +func DecodeOwed(data []byte) (seat.OwedByRepositoryNumberHeadActValue, error) { + if err := keys.CheckVersion(data); err != nil { + return seat.OwedByRepositoryNumberHeadActValue{}, err + } + o, err := seat.DecodeOwedByRepositoryNumberHeadActValue(data) + if err != nil { + return o, fmt.Errorf("meshdeliveryclient: an owed value does not fit its shape: %w", err) + } + return o, CheckOwed(o) +} + +// SameOwed says whether two owed values are the same. +func SameOwed(a, b seat.OwedByRepositoryNumberHeadActValue) bool { return a == b } + +// ----- turns + +// CheckTurn refuses a turn whose repository is not lower-case, that names no delivery, or whose deliveries repeat a heads +// hash, lack their times, are out of the order of the Approve, or name a wait without a verdict key. +func CheckTurn(t seat.TurnsByRepositoryValue) error { + repository := strings.ToLower(t.Repository) + if repository != t.Repository { + return fmt.Errorf("meshdeliveryclient: the turn's repository %q is not lowercase", t.Repository) + } + if _, err := escapeRepository(repository); err != nil { + return err + } + if len(t.Waiting) == 0 { + return fmt.Errorf("meshdeliveryclient: the turn of %s names no delivery: its key is deleted instead", t.Repository) + } + seen := map[string]bool{} + for i, w := range t.Waiting { + if w.Group == "" { + return fmt.Errorf("meshdeliveryclient: a delivery in the turn of %s names no group", t.Repository) + } + if !hashOK.MatchString(w.Heads) { + return fmt.Errorf("meshdeliveryclient: the heads hash %q in the turn of %s is not 12 lowercase hex digits", w.Heads, t.Repository) + } + if seen[w.Heads] { + return fmt.Errorf("meshdeliveryclient: the turn of %s names the heads %s twice", t.Repository, w.Heads) + } + seen[w.Heads] = true + if w.Approved.IsZero() || w.Since.IsZero() { + return fmt.Errorf("meshdeliveryclient: the delivery %s in the turn of %s says no time of its Approve or of its wait", w.Group, t.Repository) + } + if i > 0 && w.Approved.Before(t.Waiting[i-1].Approved) { + return fmt.Errorf("meshdeliveryclient: the turn of %s is not in the order of the Approve: %s was approved before %s", t.Repository, w.Group, t.Waiting[i-1].Group) + } + if w.WaitsFor != "" { + if _, _, err := parseVerdictKey(w.WaitsFor); err != nil { + return fmt.Errorf("meshdeliveryclient: the delivery %s in the turn of %s waits for what is not a verdict key: %w", w.Group, t.Repository, err) + } + } else if (w.WaitsSince != nil && !w.WaitsSince.IsZero()) || w.Step != "" { + return fmt.Errorf("meshdeliveryclient: the delivery %s in the turn of %s says when and where a check runs, and names no check", w.Group, t.Repository) + } + } + return nil +} + +// parseVerdictKey is the checker's verdict key `.` (the checker's client has the same rule over its +// own seat; a client uses its own seat's contract alone). +func parseVerdictKey(key string) (group, heads string, err error) { + i := strings.LastIndex(key, ".") + if i < 0 || !hashOK.MatchString(key[i+1:]) || i == 0 || strings.Contains(key[:i], ".") { + return "", "", fmt.Errorf("%q is not a verdict key, .", key) + } + if group, err = keys.UnescapeName(key[:i]); err != nil { + return "", "", err + } + return group, key[i+1:], nil +} + +// TurnHolds says whether a delivery at these heads is not in the turn while the turn names others: the delivery's +// check or merge holds back for the one before it. +func TurnHolds(t seat.TurnsByRepositoryValue, heads string) bool { + if len(t.Waiting) == 0 { + return false + } + for _, w := range t.Waiting { + if w.Heads == heads { + return false + } + } + return true +} + +// EncodeTurn is t as the value of a repository's key. +func EncodeTurn(t seat.TurnsByRepositoryValue) ([]byte, error) { + if err := CheckTurn(t); err != nil { + return nil, err + } + t.V = Version + return json.Marshal(t) +} + +// DecodeTurn reads the value of a repository's key. +func DecodeTurn(data []byte) (seat.TurnsByRepositoryValue, error) { + if err := keys.CheckVersion(data); err != nil { + return seat.TurnsByRepositoryValue{}, err + } + t, err := seat.DecodeTurnsByRepositoryValue(data) + if err != nil { + return t, fmt.Errorf("meshdeliveryclient: a turn does not fit its shape: %w", err) + } + return t, CheckTurn(t) +} + +// SameTurn says whether two turns are the same, a time by the instant it names. +func SameTurn(a, b seat.TurnsByRepositoryValue) bool { + if len(a.Waiting) != len(b.Waiting) || a.Repository != b.Repository || a.V != b.V { + return false + } + for i := range a.Waiting { + x, y := a.Waiting[i], b.Waiting[i] + if !x.Approved.Equal(y.Approved) || !x.Since.Equal(y.Since) || !sameTime(x.WaitsSince, y.WaitsSince) { + return false + } + x.Approved, y.Approved, x.Since, y.Since, x.WaitsSince, y.WaitsSince = time.Time{}, time.Time{}, time.Time{}, time.Time{}, nil, nil + if !reflect.DeepEqual(x, y) { + return false + } + } + return true +} + +func sameTime(a, b *time.Time) bool { + if a == nil || a.IsZero() { + return b == nil || b.IsZero() + } + return b != nil && a.Equal(*b) +} + +// Answer is the operator's answer to a merge ask as the gate acts on it: the option chosen, the warrant that proves who +// answered, and the words said. It is the teller's state `answers` under the ask's key (the teller's own record; this is +// its layout for the gate that reads it). +type Answer struct { + V int `json:"v"` + Option string `json:"option"` + Warrant string `json:"warrant"` + Words string `json:"words"` +} + +// CheckAnswer refuses an answer lacking its option, warrant or words. +func CheckAnswer(a Answer) error { + for _, f := range []struct{ name, value string }{{"option", a.Option}, {"warrant", a.Warrant}, {"words", a.Words}} { + if f.value == "" { + return fmt.Errorf("meshdeliveryclient: an answer has no %s", f.name) + } + } + return nil +} + +// EncodeAnswer is a as the value of an answer's key. +func EncodeAnswer(a Answer) ([]byte, error) { + return encodeValue(&a, CheckAnswer(a), func() { a.V = Version }) +} + +// DecodeAnswer reads the value of an answer's key. +func DecodeAnswer(data []byte) (Answer, error) { + if err := keys.CheckVersion(data); err != nil { + return Answer{}, err + } + var a Answer + dec := json.NewDecoder(bytes.NewReader(data)) + dec.DisallowUnknownFields() + if err := dec.Decode(&a); err != nil { + return Answer{}, fmt.Errorf("meshdeliveryclient: an answer does not fit its shape: %w", err) + } + return a, CheckAnswer(a) +} + +// SameAnswer says whether two answers are the same. +func SameAnswer(a, b Answer) bool { return a == b } diff --git a/contracts/go/clients/meshdeliveryclient/gate_test.go b/contracts/go/clients/meshdeliveryclient/gate_test.go new file mode 100644 index 00000000..4ec7b46e --- /dev/null +++ b/contracts/go/clients/meshdeliveryclient/gate_test.go @@ -0,0 +1,186 @@ +package meshdeliveryclient + +import ( + "encoding/json" + "errors" + "reflect" + "strings" + "testing" + "time" + + seat "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdelivery" + "git.novox.be/novox/mesh-sdk/go/keys" +) + +// sameJSON says whether two documents hold the same values (the generated types write their fields in alphabetical +// order, the old package's in declaration order: the bytes differ, the documents do not). +func sameJSON(t *testing.T, got []byte, want string) { + t.Helper() + var g, w any + if err := json.Unmarshal(got, &g); err != nil { + t.Fatalf("%s: %v", got, err) + } + if err := json.Unmarshal([]byte(want), &w); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(g, w) { + t.Errorf("got %s\nwant %s", got, want) + } +} + +// The goldens are the output of go/delivery's Encode* before it was deleted; each value must encode to the same +// document and decode back to what it was. +func TestGateValuesEncodeAsGoDeliveryDid(t *testing.T) { + ap := time.Date(2026, 11, 5, 10, 0, 0, 0, time.UTC) + wait := ap.Add(2 * time.Minute) + for _, c := range []struct { + name string + encode func() ([]byte, error) + decode func([]byte) (any, error) + value any + golden string + }{ + {"source", func() ([]byte, error) { + return EncodeSource(seat.SourceByMainValue{Group: "fix/1.2", Heads: map[string]string{"novox/a": "aaaa", "novox/b": "bbbb"}}) + }, func(b []byte) (any, error) { return DecodeSource(b) }, + seat.SourceByMainValue{V: 1, Group: "fix/1.2", Heads: map[string]string{"novox/a": "aaaa", "novox/b": "bbbb"}}, + `{"v":1,"group":"fix/1.2","heads":{"novox/a":"aaaa","novox/b":"bbbb"}}`}, + {"merge", func() ([]byte, error) { + return EncodeMerge(seat.MergesByGroupValue{Group: "g", Ask: "merge.g.0123456789ab", Approved: map[string]string{"novox/a@7": "h1"}, + Pulls: []seat.MergesByGroupValuePullsItem{{Repository: "novox/a", Number: 7, Head: "h1", State: "merged", Commit: "c1"}}}) + }, func(b []byte) (any, error) { return DecodeMerge(b) }, + seat.MergesByGroupValue{V: 1, Group: "g", Ask: "merge.g.0123456789ab", Approved: map[string]string{"novox/a@7": "h1"}, + Pulls: []seat.MergesByGroupValuePullsItem{{Repository: "novox/a", Number: 7, Head: "h1", State: "merged", Commit: "c1"}}}, + `{"v":1,"group":"g","ask":"merge.g.0123456789ab","approved":{"novox/a@7":"h1"},"pulls":[{"repository":"novox/a","number":7,"head":"h1","state":"merged","commit":"c1"}]}`}, + {"ask", func() ([]byte, error) { + return EncodeAsk(seat.AsksByMergeGroupHeadsValue{Group: "g", Asking: 2, Ask: json.RawMessage(`{"headline":"x"}`), + Heads: []seat.AsksByMergeGroupHeadsValueHeadsItem{{Repository: "novox/a", Number: 7, Head: "h1"}}}) + }, func(b []byte) (any, error) { return DecodeAsk(b) }, + seat.AsksByMergeGroupHeadsValue{V: 1, Group: "g", Asking: 2, Ask: map[string]any{"headline": "x"}, + Heads: []seat.AsksByMergeGroupHeadsValueHeadsItem{{Repository: "novox/a", Number: 7, Head: "h1"}}}, + `{"v":1,"group":"g","heads":[{"repository":"novox/a","number":7,"head":"h1"}],"ask":{"headline":"x"},"asking":2}`}, + {"owed", func() ([]byte, error) { + return EncodeOwed(seat.OwedByRepositoryNumberHeadActValue{Act: OwedStatus, Context: StatusContextCheck, Target: "t", State: "pending"}) + }, func(b []byte) (any, error) { return DecodeOwed(b) }, + seat.OwedByRepositoryNumberHeadActValue{V: 1, Act: OwedStatus, Context: StatusContextCheck, Target: "t", State: "pending"}, + `{"v":1,"act":"status","context":"mesh/check","target":"t","state":"pending"}`}, + {"turn", func() ([]byte, error) { + return EncodeTurn(seat.TurnsByRepositoryValue{Repository: "novox/a", Waiting: []seat.TurnsByRepositoryValueWaitingItem{{ + Group: "g", Heads: "0123456789ab", Ask: "k", Approved: ap, Since: ap.Add(time.Minute), WaitsFor: "g.0123456789ab", WaitsSince: &wait, Step: "s"}}}) + }, func(b []byte) (any, error) { return DecodeTurn(b) }, + seat.TurnsByRepositoryValue{V: 1, Repository: "novox/a", Waiting: []seat.TurnsByRepositoryValueWaitingItem{{ + Group: "g", Heads: "0123456789ab", Ask: "k", Approved: ap, Since: ap.Add(time.Minute), WaitsFor: "g.0123456789ab", WaitsSince: &wait, Step: "s"}}}, + `{"v":1,"repository":"novox/a","waiting":[{"group":"g","heads":"0123456789ab","ask":"k","approved":"2026-11-05T10:00:00Z","since":"2026-11-05T10:01:00Z","waits-for":"g.0123456789ab","waits-since":"2026-11-05T10:02:00Z","step":"s"}]}`}, + {"answer", func() ([]byte, error) { return EncodeAnswer(Answer{Option: "merge", Warrant: "w", Words: "ok"}) }, + func(b []byte) (any, error) { return DecodeAnswer(b) }, Answer{V: 1, Option: "merge", Warrant: "w", Words: "ok"}, + `{"v":1,"option":"merge","warrant":"w","words":"ok"}`}, + } { + raw, err := c.encode() + if err != nil { + t.Fatalf("%s: %v", c.name, err) + } + sameJSON(t, raw, c.golden) + back, err := c.decode([]byte(c.golden)) + if err != nil || !reflect.DeepEqual(back, c.value) { + t.Errorf("%s decoded %+v, %v; want %+v", c.name, back, err, c.value) + } + } +} + +func TestGateKeysAsGoDeliveryHadThem(t *testing.T) { + for in, want := range map[string]string{"novox/Mesh-sdk": "novox.mesh-sdk", "novox/mesh-sdk": "novox.mesh-sdk", "novox/mesh.x": "novox.mesh=2ex"} { + got, err := TurnsKey(in) + if err != nil || got != want { + t.Errorf("TurnsKey(%q) = %q, %v; want %q", in, got, err, want) + } + } + if r, err := ParseTurnsKey("novox.mesh=2ex"); err != nil || r != "novox/mesh.x" { + t.Errorf("%q %v", r, err) + } + for _, bad := range []string{"novox", "novox.Mesh", "a.b.c", ".x", "novox.mesh-sdk."} { + if _, err := ParseTurnsKey(bad); err == nil { + t.Errorf("%q parses as a turns key", bad) + } + } + if _, err := TurnsKey("novox"); err == nil { + t.Error("a repository with no slash keys") + } + if k := ApprovedKey("novox/a", 7); k != "novox/a@7" { + t.Error(k) + } + if SourceMainKey != seat.SourceByMainKey() { + t.Error("the source's key is the seat's") + } +} + +func TestGateValuesRefuseWhatTheirRulesRefuse(t *testing.T) { + ap := time.Date(2026, 11, 5, 10, 0, 0, 0, time.UTC) + pull := seat.MergesByGroupValuePullsItem{Repository: "novox/a", Number: 7, Head: "h1", State: "merging"} + merge := func(edit func(*seat.MergesByGroupValue)) error { + m := seat.MergesByGroupValue{Group: "g", Ask: "k", Pulls: []seat.MergesByGroupValuePullsItem{pull}} + edit(&m) + _, err := EncodeMerge(m) + return err + } + turn := func(edit func(*seat.TurnsByRepositoryValue)) error { + tv := seat.TurnsByRepositoryValue{Repository: "novox/a", Waiting: []seat.TurnsByRepositoryValueWaitingItem{{Group: "g", Heads: "0123456789ab", Approved: ap, Since: ap}}} + edit(&tv) + _, err := EncodeTurn(tv) + return err + } + for name, c := range map[string]struct { + err error + want string + }{ + "merge: no group": {merge(func(m *seat.MergesByGroupValue) { m.Group = "" }), "no group"}, + "merge: no pull": {merge(func(m *seat.MergesByGroupValue) { m.Pulls = nil }), "no pull request"}, + "merge: not approved": {merge(func(m *seat.MergesByGroupValue) { m.Approved = map[string]string{"novox/b@1": "x"} }), "not among the approved"}, + "merge: other head": {merge(func(m *seat.MergesByGroupValue) { m.Approved = map[string]string{"novox/a@7": "x"} }), "Approve was for"}, + "merge: merged no commit": {merge(func(m *seat.MergesByGroupValue) { m.Pulls[0].State = "merged" }), "no merge commit"}, + "merge: refused no words": {merge(func(m *seat.MergesByGroupValue) { m.Pulls[0].State = "refused" }), "no words"}, + "merge: unknown state": {merge(func(m *seat.MergesByGroupValue) { m.Pulls[0].State = "lost" }), "not merging, merged or refused"}, + "turn: upper case": {turn(func(v *seat.TurnsByRepositoryValue) { v.Repository = "Novox/a" }), "not lowercase"}, + "turn: empty": {turn(func(v *seat.TurnsByRepositoryValue) { v.Waiting = nil }), "names no delivery"}, + "turn: bad hash": {turn(func(v *seat.TurnsByRepositoryValue) { v.Waiting[0].Heads = "xyz" }), "12 lowercase hex"}, + "turn: no time": {turn(func(v *seat.TurnsByRepositoryValue) { v.Waiting[0].Since = time.Time{} }), "no time"}, + "turn: step with no wait": {turn(func(v *seat.TurnsByRepositoryValue) { v.Waiting[0].Step = "s" }), "names no check"}, + "turn: not a verdict key": {turn(func(v *seat.TurnsByRepositoryValue) { v.Waiting[0].WaitsFor = "nope" }), "not a verdict key"}, + } { + if c.err == nil || !strings.Contains(c.err.Error(), c.want) { + t.Errorf("%s: %v, want %q", name, c.err, c.want) + } + } + if _, err := EncodeSource(seat.SourceByMainValue{Group: "g"}); err == nil { + t.Error("an empty source encodes") + } + if _, err := EncodeAsk(seat.AsksByMergeGroupHeadsValue{Group: "g", Ask: "text", Heads: []seat.AsksByMergeGroupHeadsValueHeadsItem{{Repository: "novox/a", Number: 1, Head: "h"}}}); err == nil { + t.Error("an ask that is no object encodes") + } + if _, err := EncodeAnswer(Answer{Option: "merge"}); err == nil { + t.Error("an answer with no warrant encodes") + } + if _, err := DecodeSource([]byte(`{"v":2,"group":"g","heads":{"a":"b"}}`)); !errors.Is(err, keys.ErrUnknownVersion) { + t.Errorf("another version: %v", err) + } + if _, err := DecodeMerge([]byte(`{"v":1,"group":"g","ask":"k","pulls":[],"extra":1}`)); err == nil { + t.Error("an unknown field decodes") + } +} + +func TestTurnHoldsAndSameTurn(t *testing.T) { + ap := time.Date(2026, 11, 5, 10, 0, 0, 0, time.UTC) + tv := seat.TurnsByRepositoryValue{V: 1, Repository: "novox/a", Waiting: []seat.TurnsByRepositoryValueWaitingItem{{Group: "g", Heads: "0123456789ab", Approved: ap, Since: ap}}} + if TurnHolds(tv, "0123456789ab") || !TurnHolds(tv, "ba9876543210") || TurnHolds(seat.TurnsByRepositoryValue{}, "x") { + t.Error("a turn holds a delivery that is not in it while others wait") + } + other := tv + other.Waiting = []seat.TurnsByRepositoryValueWaitingItem{tv.Waiting[0]} + other.Waiting[0].Approved = ap.In(time.FixedZone("x", 3600)) + if !SameTurn(tv, other) { + t.Error("the same instant in another zone is another turn") + } + other.Waiting[0].Since = ap.Add(time.Second) + if SameTurn(tv, other) { + t.Error("another instant is the same turn") + } +} diff --git a/contracts/go/clients/meshdeliveryclient/go.mod b/contracts/go/clients/meshdeliveryclient/go.mod new file mode 100644 index 00000000..7b85ffc7 --- /dev/null +++ b/contracts/go/clients/meshdeliveryclient/go.mod @@ -0,0 +1,8 @@ +module git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliveryclient + +go 1.26.0 + +require ( + git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdelivery v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000 +) diff --git a/contracts/go/clients/meshdeliveryclient/go.sum b/contracts/go/clients/meshdeliveryclient/go.sum new file mode 100644 index 00000000..0daef11f --- /dev/null +++ b/contracts/go/clients/meshdeliveryclient/go.sum @@ -0,0 +1,12 @@ +github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ= +github.com/klauspost/compress v1.20.1/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= +github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA= +github.com/nats-io/nats.go v1.54.0/go.mod h1:y+DZoD1oBOYfZTU681eTUiUjI0vbqYGixNVFHcjHJ0k= +github.com/nats-io/nkeys v0.4.16 h1:rd5oAuLOb8mnAycB0xleuEBNS1pVVnN0fv/FF34Eypg= +github.com/nats-io/nkeys v0.4.16/go.mod h1:llLgWoI0o4z/Q57q2R1kHfmocyhGV6VG/U18Glg1Afs= +github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= +github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= +golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= +golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= +golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= +golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= diff --git a/contracts/go/clients/meshdeliveryclient/removed.go b/contracts/go/clients/meshdeliveryclient/removed.go new file mode 100644 index 00000000..8b390133 --- /dev/null +++ b/contracts/go/clients/meshdeliveryclient/removed.go @@ -0,0 +1,18 @@ +package meshdeliveryclient + +// RemovedPrefix opens the description of a verb release 6 removed from the seat mesh-delivery (novox/hq ADR 0539 clause 17): +// the definition keeps it as an optional row with no input, so the holder of before and the holder of release 6 both hold +// the seat, and a caller is answered by name with its replacement. +const RemovedPrefix = "Removed in R6 (ADR 0539): refused by name, naming its replacement: " + +// RemovedVerbs are the seat's verbs release 6 removed, each with its replacement in words: with no plan the gate keeps the +// merge's states and derives nothing after the merge. A new map each call. +func RemovedVerbs() map[string]string { + return map[string]string{ + "times": "the delivery view (nox-delivery): each machine's want and have, and since when", + "what-if": "the merge check's composition, which composes every machine a change touches", + "release": "the assigner's asks (hold, restore, backup, record), answered on the phone or in the delivery view", + "stop": "a bad fingerprint is held and its canary set back by the assigner (ADR 0429 §6, ADR 0539 clause 18)", + "retire-history": "nothing to retire: the gate keeps no delivery after its merge", + } +} diff --git a/contracts/go/clients/meshwantsclient/go.mod b/contracts/go/clients/meshwantsclient/go.mod new file mode 100644 index 00000000..470f8994 --- /dev/null +++ b/contracts/go/clients/meshwantsclient/go.mod @@ -0,0 +1,8 @@ +module git.novox.be/novox/mesh-delivery/contracts/go/clients/meshwantsclient + +go 1.26.0 + +require ( + git.novox.be/novox/mesh-delivery/contracts/go/seats/meshwants v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000 +) diff --git a/contracts/go/clients/meshwantsclient/wants.go b/contracts/go/clients/meshwantsclient/wants.go new file mode 100644 index 00000000..2e54a9f6 --- /dev/null +++ b/contracts/go/clients/meshwantsclient/wants.go @@ -0,0 +1,326 @@ +// Package meshwantsclient is the client of the seat mesh-wants: the keys of its state wants, and the check and the codec +// of a want, a node's own want and a hold, which every module that reads or writes wants shares (the assigner that writes +// them, the node-engine, the provisioner and the artifact store that read them). The layouts are the seat's contract; this +// file writes the version and refuses what the earlier codec refused (novox/hq ADR 0528, ADR 0539, ADR 0546, ADR 0634 §4). +package meshwantsclient + +import ( + "encoding/json" + "fmt" + "regexp" + "strings" + + seat "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshwants" + "git.novox.be/novox/mesh-sdk/go/keys" +) + +// The three values of the state wants, in the seat's types. +type ( + // Want is a node's want for a module: the fingerprint it should run and the digest of the one composition it allows, + // or Removed, or Self when the node composes it itself; Newest and Rule name what holds the node back. + Want = seat.WantsByWantNodeModuleValue + // NodeWant is a node's own want: the digest of the node's own composition it may apply. + NodeWant = seat.WantsByWantNodeNodeValue + // Held is a fingerprint the assigner holds: the node whose health check failed on it, and the check's words. + Held = seat.WantsByHeldModuleFingerprintValue +) + +// The rules of ADR 0429 §6 and ADR 0539 clause 6, by number, as a want or a difference names the one that holds a node back. +const ( + RuleBuildDone = 1 // the build of F is done + RuleNotHeld = 2 // F is not held + RuleCanary = 3 // the node is the module's canary, or the canary runs F healthy + RuleStep = 4 // at most step other nodes are ahead (step 0: no limit, ADR 0528 §3) + RuleProviders = 5 // every provider the module requires runs healthy on that node, or at its canary + RuleGrant = 6 // the module's credential for F exists (grants..) + RuleComposed = 7 // the module's composition for that node at F exists (ADR 0539 §6) + // RuleProvisions: every provision the module requires on that node is provided (novox/hq ADR 0570 §4). + RuleProvisions = 8 +) + +// ruleRead is the highest rule a reader takes (novox/hq ADR 0614 §2, §4): 9 and 10 are kept for the verb wait and the +// capability hold of a later release. +const ruleRead = 10 + +// The answers a want carries to a failed backup before an apply (ADR 0539 clause 16). +const ( + // BackupAgain is the answer to back the module up again before the apply. + BackupAgain = seat.WantsByWantNodeModuleValueBackupAgain + // BackupSkip is the answer to go on without a backup. + BackupSkip = seat.WantsByWantNodeModuleValueBackupSkip +) + +// NodeWord is the last part of a node's own key. +const NodeWord = "node" + +const ( + wantWord = "want" + heldWord = "held" + srcWord = "src2:" +) + +var ( + moduleNameOK = regexp.MustCompile(`^[-/_a-zA-Z0-9]+(\.[-/_a-zA-Z0-9]+)*$`) + digestForm = regexp.MustCompile(`^sha256:[0-9a-f]{64}$`) +) + +func moduleKeyPart(module string) (string, error) { + if module == "" { + return "", fmt.Errorf("wants: the module name is empty") + } + if !moduleNameOK.MatchString(module) { + return "", fmt.Errorf("wants: the module name %q holds a wildcard, whitespace, '=', an empty part between dots or a character a key refuses", module) + } + return keys.EscapeName(module), nil +} + +func parseModuleKeyPart(part string) (string, error) { + if err := keys.CheckName("module", part); err != nil { + return "", err + } + module, err := keys.UnescapeName(part) + if err != nil { + return "", err + } + if back, err := moduleKeyPart(module); err != nil || back != part { + return "", fmt.Errorf("wants: the module part %q is not a module name as a key writes it", part) + } + return module, nil +} + +func checkFingerprint(fp, of string) error { + if part, ok := strings.CutPrefix(fp, srcWord); !ok || !keys.IsHex64(part) { + return fmt.Errorf("wants: %s: %q is not a fingerprint of the new form, %s and 64 lowercase hex digits", of, fp, srcWord) + } + return nil +} + +func checkDigest(digest, of string) error { + if !digestForm.MatchString(digest) { + return fmt.Errorf("wants: %s %q is not sha256:<64 lowercase hex digits>", of, digest) + } + return nil +} + +func checkRule(rule int64, of string) error { + if rule < RuleBuildDone || rule > ruleRead { + return fmt.Errorf("wants: %s names the rule %d, which is not one of 1 to %d", of, rule, ruleRead) + } + return nil +} + +// WantKey is the key, in the state wants, of a node's want for a module: `want..`. A module named `node` is +// refused, since `want..node` is the node's own key. +func WantKey(node, module string) (string, error) { + if err := keys.CheckName("node", node); err != nil { + return "", err + } + part, err := moduleKeyPart(module) + if err != nil { + return "", err + } + if module == NodeWord { + return "", fmt.Errorf(`wants: the module name "node" is refused: want.%s.node is the node's own key`, node) + } + return seat.WantsByWantNodeModuleKey(node, part), nil +} + +// WantNodeKey is the key of a node's own want: `want..node`. +func WantNodeKey(node string) (string, error) { + if err := keys.CheckName("node", node); err != nil { + return "", err + } + return seat.WantsByWantNodeNodeKey(node), nil +} + +// ParseWantNodeKey is the inverse of WantNodeKey; it refuses a module's want key and any key of another form. +func ParseWantNodeKey(key string) (node string, err error) { + node, ok := seat.ParseWantsByWantNodeNodeKey(key) + if !ok || keys.CheckName("node", node) != nil { + return "", fmt.Errorf("wants: %q is not a node's own want key, want..node", key) + } + return node, nil +} + +// ParseWantKey is the inverse of WantKey; it refuses a held key, the node's own want key and any key of another form. +func ParseWantKey(key string) (node, module string, err error) { + refused := fmt.Errorf("wants: %q is not a want key, want..", key) + node, part, ok := seat.ParseWantsByWantNodeModuleKey(key) + if !ok || part == NodeWord || keys.CheckName("node", node) != nil { + return "", "", refused + } + if module, err = parseModuleKeyPart(part); err != nil { + return "", "", refused + } + return node, module, nil +} + +// HeldKey is the key of a module's fingerprint the assigner holds: `held..`, the hex being the +// fingerprint's key part. +func HeldKey(module, fingerprint string) (string, error) { + if err := keys.CheckName("module", module); err != nil { + return "", err + } + if err := checkFingerprint(fingerprint, "a held key's fingerprint"); err != nil { + return "", err + } + return seat.WantsByHeldModuleFingerprintKey(module, strings.TrimPrefix(fingerprint, srcWord)), nil +} + +// ParseHeldKey is the inverse of HeldKey: the module and the fingerprint (`src2:` and the hex). +func ParseHeldKey(key string) (module, fingerprint string, err error) { + module, part, ok := seat.ParseWantsByHeldModuleFingerprintKey(key) + if !ok || keys.CheckName("module", module) != nil || !keys.IsHex64(part) { + return "", "", fmt.Errorf("wants: %q is not a held key, held..", key) + } + return module, srcWord + part, nil +} + +func checkWant(w Want) error { + if w.Removed { + if w.Fp != "" || w.Composed != "" || w.Newest != "" || w.Rule != 0 || w.Backup != "" || w.Put || w.Hold || w.Self || w.Yes != "" || w.Release != "" { + return fmt.Errorf("wants: a removed want names no fingerprint, composition, rule, backup answer or put") + } + return nil + } + if w.Hold && !w.Put { + return fmt.Errorf("wants: only a put holds a want") + } + if w.Put && (w.Newest != "" || w.Rule != 0) { + return fmt.Errorf("wants: a put's want is held back by no rule") + } + if err := checkFingerprint(w.Fp, "a want's fingerprint"); err != nil { + return err + } + // A node the catalogue places composes its own modules (ADR 0547 §2): its want is Self and names the build alone. Any + // other want names the one composition it allows (ADR 0539 clause 1). + switch { + case w.Self && w.Composed != "": + return fmt.Errorf("wants: the want of %s says the node composes it, and names a composition", w.Fp) + case !w.Self && w.Composed == "": + return fmt.Errorf("wants: the want of %s names no composition: a want names the digest of the one composition it allows (ADR 0539 clause 1), unless the node composes it itself", w.Fp) + case w.Composed != "": + if err := checkDigest(w.Composed, "a want's composition digest"); err != nil { + return err + } + } + if w.Yes != "" { + if err := checkDigest(w.Yes, "the digest a want's yes allows"); err != nil { + return err + } + } else if w.Release != "" { + return fmt.Errorf("wants: the want of %s names the release %s and no yes it gave", w.Fp, w.Release) + } + switch w.Backup { + case "", BackupAgain, BackupSkip: + default: + return fmt.Errorf("wants: the backup answer %q is not %s or %s", w.Backup, BackupAgain, BackupSkip) + } + if w.Newest == "" && w.Rule == 0 { + return nil + } + if err := checkFingerprint(w.Newest, "a want's newest fingerprint"); err != nil { + return err + } + if w.Newest == w.Fp { + return fmt.Errorf("wants: a want at its newest fingerprint names no rule holding it back") + } + return checkRule(w.Rule, "a want held back") +} + +// CheckWant refuses a want the codec refuses. +func CheckWant(w Want) error { return checkWant(w) } + +// EncodeWant is w as JSON at the kept values' version; it refuses a fingerprint not of the new form, a removed want naming +// one, and a want that names no composition unless the node composes it itself. +func EncodeWant(w Want) ([]byte, error) { + if err := checkWant(w); err != nil { + return nil, err + } + w.V = keys.Version + return json.Marshal(w) +} + +// DecodeWant reads a want; it refuses an unknown version (keys.ErrUnknownVersion), a field the contract does not know and +// what EncodeWant refuses. +func DecodeWant(data []byte) (Want, error) { + if err := keys.CheckVersion(data); err != nil { + return Want{}, err + } + w, err := seat.DecodeWantsByWantNodeModuleValue(data) + if err != nil { + return Want{}, fmt.Errorf("wants: value does not fit its shape: %w", err) + } + if err := checkWant(w); err != nil { + return Want{}, err + } + return w, nil +} + +// SameWant says whether two wants are the same value, so the assigner writes only when they differ. +func SameWant(a, b Want) bool { return a == b } + +func checkNodeWant(w NodeWant) error { + switch { + case w.Self && w.Composed != "": + return fmt.Errorf("wants: the node's own want says the node composes it, and names a composition") + case w.Self: + return nil + } + return checkDigest(w.Composed, "the node's own want's composition digest") +} + +// EncodeNodeWant is w as JSON; it refuses a digest not of the sha256 form, and a want that names no composition unless it +// says the node composes its own (Self, ADR 0547 §2). +func EncodeNodeWant(w NodeWant) ([]byte, error) { + if err := checkNodeWant(w); err != nil { + return nil, err + } + w.V = keys.Version + return json.Marshal(w) +} + +// DecodeNodeWant reads a node's own want; it refuses an unknown version, a field a node's own want does not have (a +// fingerprint among them), and what EncodeNodeWant refuses. +func DecodeNodeWant(data []byte) (NodeWant, error) { + if err := keys.CheckVersion(data); err != nil { + return NodeWant{}, err + } + w, err := seat.DecodeWantsByWantNodeNodeValue(data) + if err != nil { + return NodeWant{}, fmt.Errorf("wants: a node's own want does not fit its shape: %w", err) + } + if err := checkNodeWant(w); err != nil { + return NodeWant{}, err + } + return w, nil +} + +func checkHeld(h Held) error { return keys.CheckName("node", h.Node) } + +// EncodeHeld is h as JSON; it refuses a node name a key refuses. +func EncodeHeld(h Held) ([]byte, error) { + if err := checkHeld(h); err != nil { + return nil, err + } + h.V = keys.Version + return json.Marshal(h) +} + +// DecodeHeld reads a hold; it refuses an unknown version and what EncodeHeld refuses. +func DecodeHeld(data []byte) (Held, error) { + if err := keys.CheckVersion(data); err != nil { + return Held{}, err + } + h, err := seat.DecodeWantsByHeldModuleFingerprintValue(data) + if err != nil { + return Held{}, fmt.Errorf("wants: value does not fit its shape: %w", err) + } + if err := checkHeld(h); err != nil { + return Held{}, err + } + return h, nil +} + +// SameHeld says whether two holds are the same value. +func SameHeld(a, b Held) bool { return a == b } diff --git a/contracts/go/clients/meshwantsclient/wants_test.go b/contracts/go/clients/meshwantsclient/wants_test.go new file mode 100644 index 00000000..5e2ecadb --- /dev/null +++ b/contracts/go/clients/meshwantsclient/wants_test.go @@ -0,0 +1,162 @@ +package meshwantsclient + +import ( + "encoding/json" + "errors" + "reflect" + "strings" + "testing" + + "git.novox.be/novox/mesh-sdk/go/keys" +) + +func rep(c string) string { return strings.Repeat(c, 64) } + +// TestWantsAreWhatTheEarlierCodecWrote holds the values and keys equal to what go/delivery's codec wrote for the same +// values (the golden texts are its output; the field order is the contract's now, so values are compared decoded), and the +// refusals it made. +func TestWantsAreWhatTheEarlierCodecWrote(t *testing.T) { + a, b, c, d := "src2:"+rep("a"), "src2:"+rep("b"), "sha256:"+rep("c"), "sha256:"+rep("d") + sameJSON := func(t *testing.T, got []byte, want string) { + t.Helper() + var g, w any + if err := json.Unmarshal(got, &g); err != nil { + t.Fatal(err) + } + if err := json.Unmarshal([]byte(want), &w); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(g, w) { + t.Fatalf("wrote %s, the earlier codec wrote %s", got, want) + } + } + t.Run("wants", func(t *testing.T) { + for name, row := range map[string]struct { + w Want + golden string + }{ + "composed": {Want{Fp: a, Composed: c, Words: "w"}, `{"v":1,"fp":"` + a + `","composed":"` + c + `","words":"w"}`}, + "held back": {Want{Fp: a, Self: true, Newest: b, Rule: RuleGrant, Backup: BackupAgain, Words: "held"}, `{"v":1,"fp":"` + a + `","newest":"` + b + `","rule":6,"backup":"again","words":"held","self":true}`}, + "put": {Want{Fp: a, Self: true, Put: true, Hold: true, Yes: d, Release: "r1"}, `{"v":1,"fp":"` + a + `","put":true,"hold":true,"yes":"` + d + `","release":"r1","self":true}`}, + "removed": {Want{Removed: true, Words: "gone"}, `{"v":1,"removed":true,"words":"gone"}`}, + } { + t.Run(name, func(t *testing.T) { + raw, err := EncodeWant(row.w) + if err != nil { + t.Fatal(err) + } + sameJSON(t, raw, row.golden) + back, err := DecodeWant(raw) + if err != nil || !SameWant(back, func() Want { w := row.w; w.V = keys.Version; return w }()) { + t.Fatalf("read back %+v, %v", back, err) + } + }) + } + }) + t.Run("refused", func(t *testing.T) { + for name, w := range map[string]Want{ + "old fingerprint": {Fp: "src1:abc", Composed: c}, + "no composition": {Fp: a}, + "self and composition": {Fp: a, Self: true, Composed: c}, + "hold without put": {Fp: a, Self: true, Hold: true}, + "put held back": {Fp: a, Self: true, Put: true, Newest: b, Rule: RuleStep}, + "removed names a rule": {Removed: true, Rule: 1}, + "release without yes": {Fp: a, Self: true, Release: "r"}, + "backup answer unknown": {Fp: a, Self: true, Backup: "later"}, + "newest equals fp": {Fp: a, Self: true, Newest: a, Rule: RuleStep}, + "rule 11": {Fp: a, Self: true, Newest: b, Rule: 11}, + "newest without a rule": {Fp: a, Self: true, Newest: b}, + "composition not sha256": {Fp: a, Composed: "sha256:xyz"}, + } { + if _, err := EncodeWant(w); err == nil { + t.Errorf("%s: written", name) + } + } + if _, err := EncodeWant(Want{Fp: a, Self: true, Newest: b, Rule: 10}); err != nil { + t.Errorf("rule 10 is read: %v", err) + } + if _, err := DecodeWant([]byte(`{"fp":"` + a + `","self":true}`)); !errors.Is(err, keys.ErrUnknownVersion) { + t.Errorf("no version: %v", err) + } + if _, err := DecodeWant([]byte(`{"v":1,"fp":"` + a + `","self":true,"extra":1}`)); err == nil { + t.Error("an unknown field is read") + } + }) + t.Run("node want and hold", func(t *testing.T) { + raw, err := EncodeNodeWant(NodeWant{Composed: c, Words: "n"}) + if err != nil { + t.Fatal(err) + } + sameJSON(t, raw, `{"v":1,"composed":"`+c+`","words":"n"}`) + raw, err = EncodeNodeWant(NodeWant{Self: true}) + if err != nil { + t.Fatal(err) + } + sameJSON(t, raw, `{"v":1,"composed":"","self":true}`) + if back, err := DecodeNodeWant(raw); err != nil || !back.Self { + t.Fatalf("%+v %v", back, err) + } + for name, w := range map[string]NodeWant{"none": {}, "both": {Self: true, Composed: c}, "bad": {Composed: "x"}} { + if _, err := EncodeNodeWant(w); err == nil { + t.Errorf("%s: written", name) + } + } + if _, err := DecodeNodeWant([]byte(`{"v":1,"composed":"` + c + `","fp":"` + a + `"}`)); err == nil { + t.Error("a fingerprint in a node's own want is read") + } + raw, err = EncodeHeld(Held{Node: "ace", Words: "unhealthy"}) + if err != nil { + t.Fatal(err) + } + sameJSON(t, raw, `{"v":1,"node":"ace","words":"unhealthy"}`) + if h, err := DecodeHeld(raw); err != nil || !SameHeld(h, Held{V: 1, Node: "ace", Words: "unhealthy"}) { + t.Fatalf("%+v %v", h, err) + } + if _, err := EncodeHeld(Held{Node: "a.b"}); err == nil { + t.Error("a node with a dot is held") + } + }) + t.Run("keys", func(t *testing.T) { + for _, row := range []struct{ module, want string }{ + {"mesh-delivery-assigner", "want.ace.mesh-delivery-assigner"}, + {"novox.be", "want.ace.novox=2ebe"}, + } { + k, err := WantKey("ace", row.module) + if err != nil || k != row.want { + t.Fatalf("WantKey %s = %q, %v", row.module, k, err) + } + if n, m, err := ParseWantKey(k); err != nil || n != "ace" || m != row.module { + t.Fatalf("ParseWantKey %q = %q %q %v", k, n, m, err) + } + } + if k, err := HeldKey("mesh-delivery-assigner", a); err != nil || k != "held.mesh-delivery-assigner."+rep("a") { + t.Fatalf("HeldKey = %q %v", k, err) + } + if m, fp, err := ParseHeldKey("held.mesh-delivery-assigner." + rep("a")); err != nil || m != "mesh-delivery-assigner" || fp != a { + t.Fatalf("ParseHeldKey = %q %q %v", m, fp, err) + } + if k, err := WantNodeKey("ace"); err != nil || k != "want.ace.node" { + t.Fatalf("WantNodeKey = %q %v", k, err) + } + if n, err := ParseWantNodeKey("want.ace.node"); err != nil || n != "ace" { + t.Fatalf("ParseWantNodeKey = %q %v", n, err) + } + if _, err := WantKey("ace", "node"); err == nil { + t.Error("a module named node has a want key") + } + if _, err := HeldKey("novox.be", a); err == nil { + t.Error("a module with a dot has a held key") + } + for _, key := range []string{"want.ace.node", "held.m." + rep("a"), "ace.m", "want.ace", "want..m"} { + if _, _, err := ParseWantKey(key); err == nil { + t.Errorf("%q is a want key", key) + } + } + if _, err := ParseWantNodeKey("want.ace.m"); err == nil { + t.Error("a module's want is a node's own") + } + if _, _, err := ParseHeldKey("want.ace.m"); err == nil { + t.Error("a want key is a held key") + } + }) +} diff --git a/contracts/go/clients/npmpackageregistryclient/client.go b/contracts/go/clients/npmpackageregistryclient/client.go new file mode 100644 index 00000000..82625d6e --- /dev/null +++ b/contracts/go/clients/npmpackageregistryclient/client.go @@ -0,0 +1,63 @@ +// Package npmpackageregistryclient is the npm-package-registry seat's client (novox/hq ADR 0627 §1, §2; ADR 0611, design 51 +// *Publishing*): it publishes a file in parts to the seat's holder. `publish` opens the transfer naming every part, +// the holder answers the parts it lacks, and only those travel by `publish-part`, in chunks a bus message carries; a +// cut transfer goes on from what the holder answers when the same transfer is opened again. The sending in parts is +// mesh-sdk's seat-neutral runtime (parts); this client binds it to this seat's verbs and types alone. The call is the +// caller's own (a bundle's stdio.Ask, a process's call of a seat verb), so it holds no transport. +package npmpackageregistryclient + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + + seat "git.novox.be/novox/mesh-delivery/contracts/go/seats/npmpackageregistry" + "git.novox.be/novox/mesh-delivery/sdk/parts" +) + +// Call asks one verb of the seat, `seat:.`, with a body, and answers its JSON. +type Call func(key string, body any) (json.RawMessage, error) + +// Sent is what a Publish did: the transfer, whether the holder held its digest already, the parts it lacked and were +// sent, and the chunks they took. +type Sent struct { + Transfer string + parts.Sent +} + +// Publish opens (or opens again) the transfer in on the seat and sends the parts the holder answers it lacks, each in +// chunks of at most parts.MaxChunk, in sequence, the last marked. in.Parts is written from parts. A part whose bytes +// do not hash to its digest is refused before anything of it is sent; a part the holder does not name is never read. +// Its outcome, published or refused, is the holder's event, which the caller waits for. +func Publish(ctx context.Context, call Call, in seat.PublishInput, ps []parts.Part) (Sent, error) { + open := func(_ context.Context, ps []parts.Part) (parts.Opened, error) { + in.Parts = make([]seat.Part, 0, len(ps)) + for _, p := range ps { + in.Parts = append(in.Parts, seat.Part{Digest: p.Digest, Size: p.Size}) + } + raw, err := call("seat:"+seat.Seat+"."+seat.VerbPublish, in) + if err != nil { + return parts.Opened{}, fmt.Errorf("%s's publish of %s: %w", seat.Seat, in.Name, err) + } + var a seat.PublishAnswer + if err := json.Unmarshal(raw, &a); err != nil { + return parts.Opened{}, fmt.Errorf("%s's answer to publish: %w", seat.Seat, err) + } + return parts.Opened{Held: a.Held, Lacks: a.Lacks}, nil + } + chunk := func(_ context.Context, part string, sequence int, last bool, data []byte) error { + raw, err := call("seat:"+seat.Seat+"."+seat.VerbPublishPart, seat.PublishPartInput{Transfer: in.Transfer, Part: part, + Sequence: int64(sequence), Last: last, Data: base64.StdEncoding.EncodeToString(data)}) + if err != nil { + return fmt.Errorf("%s's publish-part: %w", seat.Seat, err) + } + var a seat.PublishPartAnswer + if err := json.Unmarshal(raw, &a); err != nil { + return fmt.Errorf("%s's answer to publish-part: %w", seat.Seat, err) + } + return nil + } + sent, err := parts.Send(ctx, open, chunk, ps) + return Sent{Transfer: in.Transfer, Sent: sent}, err +} diff --git a/contracts/go/clients/npmpackageregistryclient/client_test.go b/contracts/go/clients/npmpackageregistryclient/client_test.go new file mode 100644 index 00000000..916ba073 --- /dev/null +++ b/contracts/go/clients/npmpackageregistryclient/client_test.go @@ -0,0 +1,57 @@ +package npmpackageregistryclient + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "io" + "testing" + + seat "git.novox.be/novox/mesh-delivery/contracts/go/seats/npmpackageregistry" + "git.novox.be/novox/mesh-delivery/sdk/parts" +) + +// The client publishes over its own seat's verbs alone (ADR 0627 §1): publish names every part, and only the part the +// holder lacks travels by publish-part, its bytes whole, the last chunk marked. +func TestPublishTalksToItsOwnSeat(t *testing.T) { + held, lacked := []byte("held"), []byte("lacked") + part := func(b []byte) parts.Part { + sum := sha256.Sum256(b) + return parts.Part{Digest: "sha256:" + hex.EncodeToString(sum[:]), Size: int64(len(b)), + Open: func() (io.ReadCloser, error) { return io.NopCloser(bytes.NewReader(b)), nil }} + } + ps := []parts.Part{part(held), part(lacked)} + var asked []string + var got []byte + call := func(key string, body any) (json.RawMessage, error) { + asked = append(asked, key) + raw, _ := json.Marshal(body) + switch key { + case "seat:npm-package-registry.publish": + var in seat.PublishInput + _ = json.Unmarshal(raw, &in) + if len(in.Parts) != 2 { + t.Errorf("publish names %d parts, want 2", len(in.Parts)) + } + return json.Marshal(seat.PublishAnswer{Transfer: in.Transfer, Lacks: []string{ps[1].Digest}}) + case "seat:npm-package-registry.publish-part": + var in seat.PublishPartInput + _ = json.Unmarshal(raw, &in) + data, _ := base64.StdEncoding.DecodeString(in.Data) + if in.Part != ps[1].Digest || !in.Last || in.Transfer != "t1" { + t.Errorf("publish-part %+v", in) + } + got = append(got, data...) + return json.Marshal(seat.PublishPartAnswer{Transfer: in.Transfer, Part: in.Part}) + } + t.Fatalf("asked %s", key) + return nil, nil + } + sent, err := Publish(context.Background(), call, seat.PublishInput{Transfer: "t1", Name: "x"}, ps) + if err != nil || sent.Transfer != "t1" || sent.Chunks != 1 || len(sent.Parts) != 1 || !bytes.Equal(got, lacked) || len(asked) != 2 { + t.Errorf("sent %+v, %q arrived, asked %v, %v", sent, got, asked, err) + } +} diff --git a/contracts/go/clients/npmpackageregistryclient/go.mod b/contracts/go/clients/npmpackageregistryclient/go.mod new file mode 100644 index 00000000..98e83727 --- /dev/null +++ b/contracts/go/clients/npmpackageregistryclient/go.mod @@ -0,0 +1,18 @@ +module git.novox.be/novox/mesh-delivery/contracts/go/clients/npmpackageregistryclient + +go 1.26.0 + +require ( + git.novox.be/novox/mesh-delivery/contracts/go/seats/npmpackageregistry v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-delivery/sdk v0.0.0-00010101000000-000000000000 +) + +require ( + git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000 // indirect + github.com/klauspost/compress v1.20.1 // indirect + github.com/nats-io/nats.go v1.54.0 // indirect + github.com/nats-io/nkeys v0.4.16 // indirect + github.com/nats-io/nuid v1.0.1 // indirect + golang.org/x/crypto v0.57.0 // indirect + golang.org/x/sys v0.48.0 // indirect +) diff --git a/contracts/go/clients/npmpackageregistryclient/go.sum b/contracts/go/clients/npmpackageregistryclient/go.sum new file mode 100644 index 00000000..0daef11f --- /dev/null +++ b/contracts/go/clients/npmpackageregistryclient/go.sum @@ -0,0 +1,12 @@ +github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ= +github.com/klauspost/compress v1.20.1/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= +github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA= +github.com/nats-io/nats.go v1.54.0/go.mod h1:y+DZoD1oBOYfZTU681eTUiUjI0vbqYGixNVFHcjHJ0k= +github.com/nats-io/nkeys v0.4.16 h1:rd5oAuLOb8mnAycB0xleuEBNS1pVVnN0fv/FF34Eypg= +github.com/nats-io/nkeys v0.4.16/go.mod h1:llLgWoI0o4z/Q57q2R1kHfmocyhGV6VG/U18Glg1Afs= +github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= +github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= +golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= +golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= +golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= +golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= diff --git a/contracts/go/clients/ociartifactregistryclient/client.go b/contracts/go/clients/ociartifactregistryclient/client.go new file mode 100644 index 00000000..2892757d --- /dev/null +++ b/contracts/go/clients/ociartifactregistryclient/client.go @@ -0,0 +1,63 @@ +// Package ociartifactregistryclient is the oci-artifact-registry seat's client (novox/hq ADR 0627 §1, §2; ADR 0611, design 51 +// *Publishing*): it publishes a file in parts to the seat's holder. `publish` opens the transfer naming every part, +// the holder answers the parts it lacks, and only those travel by `publish-part`, in chunks a bus message carries; a +// cut transfer goes on from what the holder answers when the same transfer is opened again. The sending in parts is +// mesh-sdk's seat-neutral runtime (parts); this client binds it to this seat's verbs and types alone. The call is the +// caller's own (a bundle's stdio.Ask, a process's call of a seat verb), so it holds no transport. +package ociartifactregistryclient + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + + seat "git.novox.be/novox/mesh-delivery/contracts/go/seats/ociartifactregistry" + "git.novox.be/novox/mesh-delivery/sdk/parts" +) + +// Call asks one verb of the seat, `seat:.`, with a body, and answers its JSON. +type Call func(key string, body any) (json.RawMessage, error) + +// Sent is what a Publish did: the transfer, whether the holder held its digest already, the parts it lacked and were +// sent, and the chunks they took. +type Sent struct { + Transfer string + parts.Sent +} + +// Publish opens (or opens again) the transfer in on the seat and sends the parts the holder answers it lacks, each in +// chunks of at most parts.MaxChunk, in sequence, the last marked. in.Parts is written from parts. A part whose bytes +// do not hash to its digest is refused before anything of it is sent; a part the holder does not name is never read. +// Its outcome, published or refused, is the holder's event, which the caller waits for. +func Publish(ctx context.Context, call Call, in seat.PublishInput, ps []parts.Part) (Sent, error) { + open := func(_ context.Context, ps []parts.Part) (parts.Opened, error) { + in.Parts = make([]seat.Part, 0, len(ps)) + for _, p := range ps { + in.Parts = append(in.Parts, seat.Part{Digest: p.Digest, Size: p.Size}) + } + raw, err := call("seat:"+seat.Seat+"."+seat.VerbPublish, in) + if err != nil { + return parts.Opened{}, fmt.Errorf("%s's publish of %s: %w", seat.Seat, in.Name, err) + } + var a seat.PublishAnswer + if err := json.Unmarshal(raw, &a); err != nil { + return parts.Opened{}, fmt.Errorf("%s's answer to publish: %w", seat.Seat, err) + } + return parts.Opened{Held: a.Held, Lacks: a.Lacks}, nil + } + chunk := func(_ context.Context, part string, sequence int, last bool, data []byte) error { + raw, err := call("seat:"+seat.Seat+"."+seat.VerbPublishPart, seat.PublishPartInput{Transfer: in.Transfer, Part: part, + Sequence: int64(sequence), Last: last, Data: base64.StdEncoding.EncodeToString(data)}) + if err != nil { + return fmt.Errorf("%s's publish-part: %w", seat.Seat, err) + } + var a seat.PublishPartAnswer + if err := json.Unmarshal(raw, &a); err != nil { + return fmt.Errorf("%s's answer to publish-part: %w", seat.Seat, err) + } + return nil + } + sent, err := parts.Send(ctx, open, chunk, ps) + return Sent{Transfer: in.Transfer, Sent: sent}, err +} diff --git a/contracts/go/clients/ociartifactregistryclient/client_test.go b/contracts/go/clients/ociartifactregistryclient/client_test.go new file mode 100644 index 00000000..6c340fd1 --- /dev/null +++ b/contracts/go/clients/ociartifactregistryclient/client_test.go @@ -0,0 +1,57 @@ +package ociartifactregistryclient + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "io" + "testing" + + seat "git.novox.be/novox/mesh-delivery/contracts/go/seats/ociartifactregistry" + "git.novox.be/novox/mesh-delivery/sdk/parts" +) + +// The client publishes over its own seat's verbs alone (ADR 0627 §1): publish names every part, and only the part the +// holder lacks travels by publish-part, its bytes whole, the last chunk marked. +func TestPublishTalksToItsOwnSeat(t *testing.T) { + held, lacked := []byte("held"), []byte("lacked") + part := func(b []byte) parts.Part { + sum := sha256.Sum256(b) + return parts.Part{Digest: "sha256:" + hex.EncodeToString(sum[:]), Size: int64(len(b)), + Open: func() (io.ReadCloser, error) { return io.NopCloser(bytes.NewReader(b)), nil }} + } + ps := []parts.Part{part(held), part(lacked)} + var asked []string + var got []byte + call := func(key string, body any) (json.RawMessage, error) { + asked = append(asked, key) + raw, _ := json.Marshal(body) + switch key { + case "seat:oci-artifact-registry.publish": + var in seat.PublishInput + _ = json.Unmarshal(raw, &in) + if len(in.Parts) != 2 { + t.Errorf("publish names %d parts, want 2", len(in.Parts)) + } + return json.Marshal(seat.PublishAnswer{Transfer: in.Transfer, Lacks: []string{ps[1].Digest}}) + case "seat:oci-artifact-registry.publish-part": + var in seat.PublishPartInput + _ = json.Unmarshal(raw, &in) + data, _ := base64.StdEncoding.DecodeString(in.Data) + if in.Part != ps[1].Digest || !in.Last || in.Transfer != "t1" { + t.Errorf("publish-part %+v", in) + } + got = append(got, data...) + return json.Marshal(seat.PublishPartAnswer{Transfer: in.Transfer, Part: in.Part}) + } + t.Fatalf("asked %s", key) + return nil, nil + } + sent, err := Publish(context.Background(), call, seat.PublishInput{Transfer: "t1", Name: "x"}, ps) + if err != nil || sent.Transfer != "t1" || sent.Chunks != 1 || len(sent.Parts) != 1 || !bytes.Equal(got, lacked) || len(asked) != 2 { + t.Errorf("sent %+v, %q arrived, asked %v, %v", sent, got, asked, err) + } +} diff --git a/contracts/go/clients/ociartifactregistryclient/go.mod b/contracts/go/clients/ociartifactregistryclient/go.mod new file mode 100644 index 00000000..41d70d47 --- /dev/null +++ b/contracts/go/clients/ociartifactregistryclient/go.mod @@ -0,0 +1,18 @@ +module git.novox.be/novox/mesh-delivery/contracts/go/clients/ociartifactregistryclient + +go 1.26.0 + +require ( + git.novox.be/novox/mesh-delivery/contracts/go/seats/ociartifactregistry v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-delivery/sdk v0.0.0-00010101000000-000000000000 +) + +require ( + git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000 // indirect + github.com/klauspost/compress v1.20.1 // indirect + github.com/nats-io/nats.go v1.54.0 // indirect + github.com/nats-io/nkeys v0.4.16 // indirect + github.com/nats-io/nuid v1.0.1 // indirect + golang.org/x/crypto v0.57.0 // indirect + golang.org/x/sys v0.48.0 // indirect +) diff --git a/contracts/go/clients/ociartifactregistryclient/go.sum b/contracts/go/clients/ociartifactregistryclient/go.sum new file mode 100644 index 00000000..0daef11f --- /dev/null +++ b/contracts/go/clients/ociartifactregistryclient/go.sum @@ -0,0 +1,12 @@ +github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ= +github.com/klauspost/compress v1.20.1/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= +github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA= +github.com/nats-io/nats.go v1.54.0/go.mod h1:y+DZoD1oBOYfZTU681eTUiUjI0vbqYGixNVFHcjHJ0k= +github.com/nats-io/nkeys v0.4.16 h1:rd5oAuLOb8mnAycB0xleuEBNS1pVVnN0fv/FF34Eypg= +github.com/nats-io/nkeys v0.4.16/go.mod h1:llLgWoI0o4z/Q57q2R1kHfmocyhGV6VG/U18Glg1Afs= +github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= +github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= +golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= +golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= +golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= +golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= diff --git a/contracts/go/seats/containerimageregistry/contract.go b/contracts/go/seats/containerimageregistry/contract.go new file mode 100644 index 00000000..80739294 --- /dev/null +++ b/contracts/go/seats/containerimageregistry/contract.go @@ -0,0 +1,528 @@ +// Code generated by seatgen from the seat definitions; DO NOT EDIT. + +// Package containerimageregistry is the contract of the seat "container-image-registry", defined whole (seats/container-image-registry/seat.json): The mesh's container images, in the one artifact store: the OCI distribution protocol's read side, an image's tags and mirroring an outside image. +package containerimageregistry + +import ( + "bytes" + "encoding/json" + "errors" + "strings" +) + +// Seat is the seat's name. +const Seat = "container-image-registry" + +const ( + VerbPublish = "publish" + VerbPublishPart = "publish-part" + VerbVersions = "versions" + VerbRemove = "remove" + VerbMirror = "mirror" + VerbTags = "tags" + VerbHold = "hold" + VerbRelease = "release" + VerbList = "list" +) + +const ( + EventPublished = "published" + EventRefused = "refused" + EventRemoved = "removed" + EventKept = "kept" + EventHeld = "held" + EventReleased = "released" +) + +// Verbs is every verb the seat serves, in its definition's order: a new copy each call. +func Verbs() []string { + return []string{VerbPublish, VerbPublishPart, VerbVersions, VerbRemove, VerbMirror, VerbTags, VerbHold, VerbRelease, VerbList} +} + +// Events is every event published on the seat's subject: a new copy each call. +func Events() []string { + return []string{EventPublished, EventRefused, EventRemoved, EventKept, EventHeld, EventReleased} +} + +// Family is where the seat's events are kept: the shared events stream, filtered to the seat's subjects. +const ( + Family = "container-image-registry.events" + FamilyMaxAgeDays = 7 +) + +// SubjectPublished is where published is published. +const SubjectPublished = "mesh.seat.container-image-registry.event.published" + +// SubjectRefused is where refused is published. +const SubjectRefused = "mesh.seat.container-image-registry.event.refused" + +// SubjectRemoved is where removed is published. +const SubjectRemoved = "mesh.seat.container-image-registry.event.removed" + +// SubjectKept is where kept is published. +const SubjectKept = "mesh.seat.container-image-registry.event.kept" + +// SubjectHeld is where held is published. +const SubjectHeld = "mesh.seat.container-image-registry.event.held" + +// SubjectReleased is where released is published. +const SubjectReleased = "mesh.seat.container-image-registry.event.released" + +const ( + StateHolds = "holds" + StateWindows = "windows" +) + +// HoldsBySeatNameDigestKey is the key of one value of the state holds under the form "..". +func HoldsBySeatNameDigestKey(seat, name, digest string) string { + return seat + "." + name + "." + digest +} + +// ParseHoldsBySeatNameDigestKey reads the parts of a key of the form "..", in the order of the form; the last result is false for a key of another form. +func ParseHoldsBySeatNameDigestKey(key string) (string, string, string, bool) { + rest := key + v0, after0, found0 := strings.Cut(rest, ".") + if !found0 || v0 == "" { + return "", "", "", false + } + rest = after0 + v2, after2, found2 := strings.Cut(rest, ".") + if !found2 || v2 == "" { + return "", "", "", false + } + rest = after2 + if rest == "" { + return "", "", "", false + } + v4 := rest + rest = "" + if rest != "" { + return "", "", "", false + } + return v0, v2, v4, true +} + +// DecodeHoldsBySeatNameDigestValue reads one value of the state holds under the form "..", refusing a field HoldsBySeatNameDigestValue does not know and any text after the value. +func DecodeHoldsBySeatNameDigestValue(raw []byte) (HoldsBySeatNameDigestValue, error) { + var v HoldsBySeatNameDigestValue + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.DisallowUnknownFields() + if err := dec.Decode(&v); err != nil { + return v, err + } + if dec.More() { + return v, errors.New("text after the value") + } + return v, nil +} + +// WindowsByCallerKey is the key of one value of the state windows under the form "". +func WindowsByCallerKey(caller string) string { return caller } + +// ParseWindowsByCallerKey reads the parts of a key of the form "", in the order of the form; the last result is false for a key of another form. +func ParseWindowsByCallerKey(key string) (string, bool) { + rest := key + if rest == "" { + return "", false + } + v0 := rest + rest = "" + if rest != "" { + return "", false + } + return v0, true +} + +// DecodeWindowsByCallerValue reads one value of the state windows under the form "", refusing a field WindowsByCallerValue does not know and any text after the value. +func DecodeWindowsByCallerValue(raw []byte) (WindowsByCallerValue, error) { + var v WindowsByCallerValue + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.DisallowUnknownFields() + if err := dec.Decode(&v); err != nil { + return v, err + } + if dec.More() { + return v, errors.New("text after the value") + } + return v, nil +} + +// Artifact is one published thing, as every registry event and answer names it. +type Artifact struct { + // the source commit it was built from + Commit string `json:"commit,omitempty"` + // its digest, sha256: (a Go package's h1: hash beside it in the version's record) + Digest string `json:"digest"` + // the build's fingerprint + Fingerprint string `json:"fingerprint,omitempty"` + // what it is + Kind string `json:"kind"` + // the package's, image's, bundle's or archive's name; a package's is its name in its own language (design 32): a Go package's the go.mod module path (git.novox.be/novox/mesh-sdk/go), an npm package's package.json's name (@novox/mesh-sdk) + Name string `json:"name"` + // the tag it carries, for a tagged version + Tag string `json:"tag,omitempty"` + // the version the builder gave it (design 51 *The versions*); empty for a bundle or archive published by its digest alone + Version string `json:"version,omitempty"` +} + +// The values of Artifact's kind. +const ( + ArtifactKindPackage = "package" + ArtifactKindImage = "image" + ArtifactKindBundle = "bundle" + ArtifactKindArchive = "archive" + ArtifactKindUpstream = "upstream" +) + +// Hold is the body of held and released. +type Hold struct { + // when, RFC 3339 + At string `json:"at,omitempty"` + // the caller the holder proved + By string `json:"by,omitempty"` + // the digest + Digest string `json:"digest"` + // the module holding the seat + Holder string `json:"holder"` + // the name + Name string `json:"name"` + // why + Reason string `json:"reason,omitempty"` +} + +// Listed is a type of the contract. +type Listed struct { + // for a manifest keeping one bundle or archive, the digest of that blob: what a reference names it by + Artifact string `json:"artifact,omitempty"` + // the manifest's digest, sha256: + Digest string `json:"digest"` + // a hold keeps it from retention + Held bool `json:"held"` + // it is an index, naming a manifest per platform + Index bool `json:"index"` + // for an index, the digests of the manifests it names + Platforms []string `json:"platforms,omitempty"` + // its bytes: its own content and every blob it names + Size int64 `json:"size"` + // the tags naming it, none for an untagged one + Tags []string `json:"tags,omitempty"` +} + +// Outcome is the body of published, refused and removed. +type Outcome struct { + // the source commit it was built from + Commit string `json:"commit,omitempty"` + // its digest, sha256: (a Go package's h1: hash beside it in the version's record) + Digest string `json:"digest"` + // the build's fingerprint + Fingerprint string `json:"fingerprint,omitempty"` + // the module holding the seat that said it + Holder string `json:"holder"` + // what it is + Kind string `json:"kind"` + // the Go module path a Go package is fetched under, equal to name for a Go package (go-package-registry's published); empty for every other kind of artifact + Module string `json:"module,omitempty"` + // the package's, image's, bundle's or archive's name; a package's is its name in its own language (design 32): a Go package's the go.mod module path (git.novox.be/novox/mesh-sdk/go), an npm package's package.json's name (@novox/mesh-sdk) + Name string `json:"name"` + // why: a refusal's cause, a removal's (retention, tag-gone, by-hand), or why a removal was kept (shared-digest) + Reason string `json:"reason,omitempty"` + // the reference the seat answers: //@sha256:, or @ for a package (ADR 0605 §9) + Reference string `json:"reference,omitempty"` + // the tag it carries, for a tagged version + Tag string `json:"tag,omitempty"` + // for kept: the other tags still naming its digest + Tags []string `json:"tags,omitempty"` + // the transfer it ends, when it ended one + Transfer string `json:"transfer,omitempty"` + // the version the builder gave it (design 51 *The versions*); empty for a bundle or archive published by its digest alone + Version string `json:"version,omitempty"` +} + +// The values of Outcome's kind. +const ( + OutcomeKindPackage = "package" + OutcomeKindImage = "image" + OutcomeKindBundle = "bundle" + OutcomeKindArchive = "archive" + OutcomeKindUpstream = "upstream" +) + +// Part is one part of a transfer. +type Part struct { + // the part's digest, sha256:: an image's layer or config, a package's file, a bundle's blob + Digest string `json:"digest"` + // its size in bytes + Size int64 `json:"size"` +} + +// Repository is a type of the contract. +type Repository struct { + // each manifest it holds, by digest + Manifests []Listed `json:"manifests"` + // the repository + Name string `json:"name"` +} + +// PublishInput is what publish takes. +type PublishInput struct { + // the source commit it was built from + Commit string `json:"commit,omitempty"` + // its digest, sha256: (a Go package's h1: hash beside it in the version's record) + Digest string `json:"digest"` + // the build's fingerprint + Fingerprint string `json:"fingerprint,omitempty"` + // what it is + Kind string `json:"kind"` + // the package's, image's, bundle's or archive's name; a package's is its name in its own language (design 32): a Go package's the go.mod module path (git.novox.be/novox/mesh-sdk/go), an npm package's package.json's name (@novox/mesh-sdk) + Name string `json:"name"` + // every part's digest and size; none when the digest is already held + Parts []Part `json:"parts,omitempty"` + // the tag it carries, for a tagged version + Tag string `json:"tag,omitempty"` + // the transfer's name, the caller's own; the same name opens the same transfer again + Transfer string `json:"transfer"` + // the version the builder gave it (design 51 *The versions*); empty for a bundle or archive published by its digest alone + Version string `json:"version,omitempty"` +} + +// The values of PublishInput's kind. +const ( + PublishInputKindPackage = "package" + PublishInputKindImage = "image" + PublishInputKindBundle = "bundle" + PublishInputKindArchive = "archive" + PublishInputKindUpstream = "upstream" +) + +// PublishAnswer is what publish answers. +type PublishAnswer struct { + // the digest is held already: nothing to send; the outcome follows as an event + Held bool `json:"held,omitempty"` + // the digests of the parts the holder lacks, to send by publish-part + Lacks []string `json:"lacks,omitempty"` + // the transfer + Transfer string `json:"transfer"` +} + +// PublishPartInput is what publish-part takes. +type PublishPartInput struct { + // the chunk's bytes, base64 (at most 760 KiB before encoding) + Data string `json:"data"` + // this chunk is the part's last + Last bool `json:"last"` + // the part's digest + Part string `json:"part"` + // the chunk's place in its part, from 0 + Sequence int64 `json:"sequence"` + // the transfer + Transfer string `json:"transfer"` +} + +// PublishPartAnswer is what publish-part answers. +type PublishPartAnswer struct { + // the part is whole and its bytes match its digest + Complete bool `json:"complete,omitempty"` + // the part + Part string `json:"part"` + // the chunks of the part held so far + Received int64 `json:"received,omitempty"` + // the transfer + Transfer string `json:"transfer"` +} + +// VersionsInput is what versions takes. +type VersionsInput struct { + // narrowed to one kind (optional) + Kind string `json:"kind,omitempty"` + // the name + Name string `json:"name"` +} + +// VersionsAnswer is what versions answers. +type VersionsAnswer struct { + // the name + Name string `json:"name"` + // newest first + Versions []Artifact `json:"versions"` +} + +// RemoveInput is what remove takes. +type RemoveInput struct { + // the digest (for a bundle, archive or copied base) + Digest string `json:"digest,omitempty"` + // the name + Name string `json:"name"` + // why it goes + Reason string `json:"reason"` + // the tag (for a tagged version) + Tag string `json:"tag,omitempty"` + // the version + Version string `json:"version,omitempty"` +} + +// The values of RemoveInput's reason. +const ( + RemoveInputReasonTagGone = "tag-gone" + RemoveInputReasonRetention = "retention" +) + +// RemoveAnswer is what remove answers. +type RemoveAnswer struct { + Artifact *Artifact `json:"artifact,omitempty"` + // it stays whole, its tag and its bytes: the store cannot remove the tag alone while another tag names its digest (reason, tags) + Kept bool `json:"kept,omitempty"` + // why it was kept: shared-digest, another tag names its digest + Reason string `json:"reason,omitempty"` + // it is gone + Removed bool `json:"removed"` + // the other tags still naming its digest, when kept + Tags []string `json:"tags,omitempty"` +} + +// The values of RemoveAnswer's reason. +const ( + RemoveAnswerReasonSharedDigest = "shared-digest" +) + +// MirrorInput is what mirror takes. +type MirrorInput struct { + // its pinned digest, sha256: + Digest string `json:"digest"` + // the name it is kept under + Name string `json:"name"` + // the outside image's reference + Source string `json:"source"` +} + +// MirrorAnswer is what mirror answers. +type MirrorAnswer struct { + // the digest stored + Digest string `json:"digest"` + // the reference the seat answers + Reference string `json:"reference"` +} + +// TagsInput is what tags takes. +type TagsInput struct { + // the image + Name string `json:"name"` +} + +// TagsAnswerTagsItem is a type of the contract. +type TagsAnswerTagsItem struct { + // its digest + Digest string `json:"digest"` + // the tag + Tag string `json:"tag"` +} + +// TagsAnswer is what tags answers. +type TagsAnswer struct { + // the image + Name string `json:"name"` + // every tag + Tags []TagsAnswerTagsItem `json:"tags"` +} + +// HoldInput is what hold takes. +type HoldInput struct { + // the digest + Digest string `json:"digest"` + // the name + Name string `json:"name"` + // which of the sweep's rules keeps it + Reason string `json:"reason"` +} + +// HoldAnswer is what hold answers. +type HoldAnswer struct { + // it is held + Held bool `json:"held"` + // when the hold began, RFC 3339 + Since string `json:"since,omitempty"` +} + +// ReleaseInput is what release takes. +type ReleaseInput struct { + // the digest + Digest string `json:"digest"` + // the name + Name string `json:"name"` + // why it is let go + Reason string `json:"reason"` +} + +// ReleaseAnswer is what release answers. +type ReleaseAnswer struct { + // a hold was let go; false when there was none + Released bool `json:"released"` +} + +// ListInput is what list takes. +type ListInput struct { + // the repository, /; every repository of the seat when not given + Name string `json:"name,omitempty"` +} + +// ListAnswer is what list answers. +type ListAnswer struct { + // each repository named, in order of its name + Repositories []Repository `json:"repositories"` +} + +// PublishedEvent is the body of the event published. +type PublishedEvent = Outcome + +// RefusedEvent is the body of the event refused. +type RefusedEvent = Outcome + +// RemovedEvent is the body of the event removed. +type RemovedEvent = Outcome + +// KeptEvent is the body of the event kept. +type KeptEvent = Outcome + +// HeldEvent is the body of the event held. +type HeldEvent = Hold + +// ReleasedEvent is the body of the event released. +type ReleasedEvent = Hold + +// HoldsBySeatNameDigestValue is one value of the state holds under the key ... +type HoldsBySeatNameDigestValue struct { + By string `json:"by"` + Digest string `json:"digest"` + Kind string `json:"kind"` + Name string `json:"name"` + Seat string `json:"seat"` + // when (RFC 3339 time) + Since string `json:"since"` + Why string `json:"why,omitempty"` +} + +// WindowsByCallerValueDroppedItem is a type of the contract. +type WindowsByCallerValueDroppedItem struct { + // when (RFC 3339 time) + At string `json:"at,omitempty"` + By string `json:"by,omitempty"` + Node string `json:"node"` + Why string `json:"why,omitempty"` +} + +// WindowsByCallerValue is one value of the state windows under the key . +type WindowsByCallerValue struct { + // when it last closed (RFC 3339 time) + Closed string `json:"closed,omitempty"` + // retired nodes dropped by hand + Dropped []WindowsByCallerValueDroppedItem `json:"dropped,omitempty"` + // the nodes holding it open + Holding []string `json:"holding,omitempty"` + LastHolding []string `json:"last_holding,omitempty"` + // the caller whose window it is + Name string `json:"name"` + Open bool `json:"open,omitempty"` + // node to when it began to hold + Since map[string]string `json:"since,omitempty"` + // of each node holding the window, why it holds it + Why map[string]string `json:"why,omitempty"` +} diff --git a/contracts/go/seats/containerimageregistry/contract_test.go b/contracts/go/seats/containerimageregistry/contract_test.go new file mode 100644 index 00000000..440e43fa --- /dev/null +++ b/contracts/go/seats/containerimageregistry/contract_test.go @@ -0,0 +1,82 @@ +// Code generated by seatgen from the seat definitions; DO NOT EDIT. + +package containerimageregistry + +import ( + "encoding/json" + "reflect" + "regexp" + "testing" +) + +// TestFixtures reads every fixture of seats/container-image-registry/fixtures: each decodes into its type, a field the type does not +// know is dropped (ADR 0608 §6), and it reads back as its wire says; each subject named is the contract's. +func TestFixtures(t *testing.T) { + cases := []struct { + name, subject, given, wire string + decode func([]byte) (any, error) + subjectIs *regexp.Regexp + }{ + {name: "published on the seat's subject", subject: "mesh.seat.container-image-registry.event.published", given: "{\"commit\":\"cccccccccccccccccccccccccccccccccccccccc\",\"digest\":\"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"fingerprint\":\"f1\",\"holder\":\"distribution\",\"kind\":\"image\",\"name\":\"mesh-sdk\",\"reference\":\"container-image-registry/mesh-sdk/lib@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"transfer\":\"t1\",\"version\":\"0.1.19\"}", wire: "{\"commit\":\"cccccccccccccccccccccccccccccccccccccccc\",\"digest\":\"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"fingerprint\":\"f1\",\"holder\":\"distribution\",\"kind\":\"image\",\"name\":\"mesh-sdk\",\"reference\":\"container-image-registry/mesh-sdk/lib@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"transfer\":\"t1\",\"version\":\"0.1.19\"}", + decode: func(b []byte) (any, error) { var v PublishedEvent; err := json.Unmarshal(b, &v); return v, err }, subjectIs: regexp.MustCompile("^mesh\\.seat\\.container-image-registry\\.event\\.published$")}, + {name: "refused on the seat's subject", subject: "mesh.seat.container-image-registry.event.refused", given: "{\"commit\":\"cccccccccccccccccccccccccccccccccccccccc\",\"digest\":\"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"fingerprint\":\"f1\",\"holder\":\"distribution\",\"kind\":\"image\",\"name\":\"mesh-sdk\",\"reason\":\"retention\",\"reference\":\"container-image-registry/mesh-sdk/lib@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"transfer\":\"t1\",\"version\":\"0.1.19\"}", wire: "{\"commit\":\"cccccccccccccccccccccccccccccccccccccccc\",\"digest\":\"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"fingerprint\":\"f1\",\"holder\":\"distribution\",\"kind\":\"image\",\"name\":\"mesh-sdk\",\"reason\":\"retention\",\"reference\":\"container-image-registry/mesh-sdk/lib@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"transfer\":\"t1\",\"version\":\"0.1.19\"}", + decode: func(b []byte) (any, error) { var v RefusedEvent; err := json.Unmarshal(b, &v); return v, err }, subjectIs: regexp.MustCompile("^mesh\\.seat\\.container-image-registry\\.event\\.refused$")}, + {name: "removed on the seat's subject", subject: "mesh.seat.container-image-registry.event.removed", given: "{\"commit\":\"cccccccccccccccccccccccccccccccccccccccc\",\"digest\":\"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"fingerprint\":\"f1\",\"holder\":\"distribution\",\"kind\":\"image\",\"name\":\"mesh-sdk\",\"reason\":\"retention\",\"reference\":\"container-image-registry/mesh-sdk/lib@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"transfer\":\"t1\",\"version\":\"0.1.19\"}", wire: "{\"commit\":\"cccccccccccccccccccccccccccccccccccccccc\",\"digest\":\"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"fingerprint\":\"f1\",\"holder\":\"distribution\",\"kind\":\"image\",\"name\":\"mesh-sdk\",\"reason\":\"retention\",\"reference\":\"container-image-registry/mesh-sdk/lib@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"transfer\":\"t1\",\"version\":\"0.1.19\"}", + decode: func(b []byte) (any, error) { var v RemovedEvent; err := json.Unmarshal(b, &v); return v, err }, subjectIs: regexp.MustCompile("^mesh\\.seat\\.container-image-registry\\.event\\.removed$")}, + {name: "kept on the seat's subject: a tagged version sharing its digest stays, naming the tags on it", subject: "mesh.seat.container-image-registry.event.kept", given: "{\"commit\":\"cccccccccccccccccccccccccccccccccccccccc\",\"digest\":\"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"fingerprint\":\"f1\",\"holder\":\"distribution\",\"kind\":\"image\",\"name\":\"mesh-sdk\",\"reason\":\"shared-digest\",\"reference\":\"container-image-registry/mesh-sdk/lib@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"tag\":\"0.1.19-feat-x.2\",\"tags\":[\"0.1.19\"],\"version\":\"0.1.19-feat-x.2\"}", wire: "{\"commit\":\"cccccccccccccccccccccccccccccccccccccccc\",\"digest\":\"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"fingerprint\":\"f1\",\"holder\":\"distribution\",\"kind\":\"image\",\"name\":\"mesh-sdk\",\"reason\":\"shared-digest\",\"reference\":\"container-image-registry/mesh-sdk/lib@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"tag\":\"0.1.19-feat-x.2\",\"tags\":[\"0.1.19\"],\"version\":\"0.1.19-feat-x.2\"}", + decode: func(b []byte) (any, error) { var v KeptEvent; err := json.Unmarshal(b, &v); return v, err }, subjectIs: regexp.MustCompile("^mesh\\.seat\\.container-image-registry\\.event\\.kept$")}, + {name: "held on the seat's subject", subject: "mesh.seat.container-image-registry.event.held", given: "{\"at\":\"2026-11-04T12:00:00Z\",\"by\":\"mesh-artifact-sweep\",\"digest\":\"sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\",\"holder\":\"distribution\",\"name\":\"mesh-sdk\",\"reason\":\"kept by the sweep's rule\"}", wire: "{\"at\":\"2026-11-04T12:00:00Z\",\"by\":\"mesh-artifact-sweep\",\"digest\":\"sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\",\"holder\":\"distribution\",\"name\":\"mesh-sdk\",\"reason\":\"kept by the sweep's rule\"}", + decode: func(b []byte) (any, error) { var v HeldEvent; err := json.Unmarshal(b, &v); return v, err }, subjectIs: regexp.MustCompile("^mesh\\.seat\\.container-image-registry\\.event\\.held$")}, + {name: "released on the seat's subject", subject: "mesh.seat.container-image-registry.event.released", given: "{\"at\":\"2026-11-04T12:00:00Z\",\"by\":\"mesh-artifact-sweep\",\"digest\":\"sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\",\"holder\":\"distribution\",\"name\":\"mesh-sdk\",\"reason\":\"kept by the sweep's rule\"}", wire: "{\"at\":\"2026-11-04T12:00:00Z\",\"by\":\"mesh-artifact-sweep\",\"digest\":\"sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\",\"holder\":\"distribution\",\"name\":\"mesh-sdk\",\"reason\":\"kept by the sweep's rule\"}", + decode: func(b []byte) (any, error) { var v ReleasedEvent; err := json.Unmarshal(b, &v); return v, err }, subjectIs: regexp.MustCompile("^mesh\\.seat\\.container-image-registry\\.event\\.released$")}, + {name: "a hold", subject: "", given: "{\"by\":\"operator@shanks\",\"digest\":\"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"kind\":\"base\",\"name\":\"base\",\"seat\":\"container-image-registry\",\"since\":\"2026-11-05T10:00:00Z\"}", wire: "{\"by\":\"operator@shanks\",\"digest\":\"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"kind\":\"base\",\"name\":\"base\",\"seat\":\"container-image-registry\",\"since\":\"2026-11-05T10:00:00Z\"}", + decode: func(b []byte) (any, error) { + var v HoldsBySeatNameDigestValue + err := json.Unmarshal(b, &v) + return v, err + }, subjectIs: nil}, + {name: "an open window", subject: "", given: "{\"holding\":[\"ace\"],\"name\":\"mesh-delivery-builder\",\"open\":true,\"since\":{\"ace\":\"2026-11-05T10:00:00Z\"}}", wire: "{\"holding\":[\"ace\"],\"name\":\"mesh-delivery-builder\",\"open\":true,\"since\":{\"ace\":\"2026-11-05T10:00:00Z\"}}", + decode: func(b []byte) (any, error) { var v WindowsByCallerValue; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil}, + {name: "publish opens a transfer", subject: "", given: "{\"commit\":\"cccccccccccccccccccccccccccccccccccccccc\",\"digest\":\"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"fingerprint\":\"f1\",\"kind\":\"image\",\"name\":\"mesh-sdk\",\"parts\":[{\"digest\":\"sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\",\"size\":1048576}],\"transfer\":\"t1\",\"version\":\"0.1.19\"}", wire: "{\"commit\":\"cccccccccccccccccccccccccccccccccccccccc\",\"digest\":\"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"fingerprint\":\"f1\",\"kind\":\"image\",\"name\":\"mesh-sdk\",\"parts\":[{\"digest\":\"sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\",\"size\":1048576}],\"transfer\":\"t1\",\"version\":\"0.1.19\"}", + decode: func(b []byte) (any, error) { var v PublishInput; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil}, + {name: "a field the reader does not know is dropped", subject: "", given: "{\"commit\":\"cccccccccccccccccccccccccccccccccccccccc\",\"digest\":\"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"fingerprint\":\"f1\",\"kind\":\"image\",\"later\":{\"x\":1},\"name\":\"mesh-sdk\",\"parts\":[{\"digest\":\"sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\",\"size\":1048576}],\"transfer\":\"t1\",\"version\":\"0.1.19\"}", wire: "{\"commit\":\"cccccccccccccccccccccccccccccccccccccccc\",\"digest\":\"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"fingerprint\":\"f1\",\"kind\":\"image\",\"name\":\"mesh-sdk\",\"parts\":[{\"digest\":\"sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\",\"size\":1048576}],\"transfer\":\"t1\",\"version\":\"0.1.19\"}", + decode: func(b []byte) (any, error) { var v PublishInput; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil}, + {name: "publish answers what it lacks", subject: "", given: "{\"lacks\":[\"sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\"],\"transfer\":\"t1\"}", wire: "{\"lacks\":[\"sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\"],\"transfer\":\"t1\"}", + decode: func(b []byte) (any, error) { var v PublishAnswer; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil}, + {name: "a chunk of a part", subject: "", given: "{\"data\":\"aGVsbG8=\",\"last\":true,\"part\":\"sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\",\"sequence\":0,\"transfer\":\"t1\"}", wire: "{\"data\":\"aGVsbG8=\",\"last\":true,\"part\":\"sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\",\"sequence\":0,\"transfer\":\"t1\"}", + decode: func(b []byte) (any, error) { var v PublishPartInput; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil}, + {name: "the versions held", subject: "", given: "{\"name\":\"mesh-sdk\",\"versions\":[{\"commit\":\"cccccccccccccccccccccccccccccccccccccccc\",\"digest\":\"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"fingerprint\":\"f1\",\"kind\":\"package\",\"name\":\"mesh-sdk\",\"version\":\"0.1.19\"}]}", wire: "{\"name\":\"mesh-sdk\",\"versions\":[{\"commit\":\"cccccccccccccccccccccccccccccccccccccccc\",\"digest\":\"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"fingerprint\":\"f1\",\"kind\":\"package\",\"name\":\"mesh-sdk\",\"version\":\"0.1.19\"}]}", + decode: func(b []byte) (any, error) { var v VersionsAnswer; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil}, + {name: "a release of what is not held answers released false", subject: "", given: "{\"released\":false}", wire: "{\"released\":false}", + decode: func(b []byte) (any, error) { var v ReleaseAnswer; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil}, + {name: "a tagged version sharing its digest with another tag is kept, not removed", subject: "", given: "{\"kept\":true,\"reason\":\"shared-digest\",\"removed\":false,\"tags\":[\"0.1.19\"]}", wire: "{\"kept\":true,\"reason\":\"shared-digest\",\"removed\":false,\"tags\":[\"0.1.19\"]}", + decode: func(b []byte) (any, error) { var v RemoveAnswer; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil}, + {name: "list answers an index with its platforms, its tag and its hold", subject: "", given: "{\"repositories\":[{\"manifests\":[{\"digest\":\"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"held\":false,\"index\":true,\"platforms\":[\"sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\"],\"size\":2048,\"tags\":[\"3\"]},{\"digest\":\"sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\",\"held\":false,\"index\":false,\"size\":1024}],\"name\":\"mail/server\"}]}", wire: "{\"repositories\":[{\"manifests\":[{\"digest\":\"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"held\":false,\"index\":true,\"platforms\":[\"sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\"],\"size\":2048,\"tags\":[\"3\"]},{\"digest\":\"sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\",\"held\":false,\"index\":false,\"size\":1024}],\"name\":\"mail/server\"}]}", + decode: func(b []byte) (any, error) { var v ListAnswer; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil}, + } + for _, c := range cases { + v, err := c.decode([]byte(c.given)) + if err != nil { + t.Errorf("%s: does not decode: %v", c.name, err) + continue + } + back, err := json.Marshal(v) + if err != nil { + t.Errorf("%s: does not encode: %v", c.name, err) + continue + } + var got, want any + if err := json.Unmarshal(back, &got); err != nil { + t.Fatal(err) + } + if err := json.Unmarshal([]byte(c.wire), &want); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(got, want) { + t.Errorf("%s: reads back as %s, want %s", c.name, back, c.wire) + } + if c.subjectIs != nil && !c.subjectIs.MatchString(c.subject) { + t.Errorf("%s: subject %q is not the contract's %s", c.name, c.subject, c.subjectIs) + } + } +} diff --git a/contracts/go/seats/containerimageregistry/go.mod b/contracts/go/seats/containerimageregistry/go.mod new file mode 100644 index 00000000..26ccf02f --- /dev/null +++ b/contracts/go/seats/containerimageregistry/go.mod @@ -0,0 +1,3 @@ +module git.novox.be/novox/mesh-delivery/contracts/go/seats/containerimageregistry + +go 1.26.0 diff --git a/contracts/go/seats/gopackageregistry/contract.go b/contracts/go/seats/gopackageregistry/contract.go new file mode 100644 index 00000000..eb534618 --- /dev/null +++ b/contracts/go/seats/gopackageregistry/contract.go @@ -0,0 +1,262 @@ +// Code generated by seatgen from the seat definitions; DO NOT EDIT. + +// Package gopackageregistry is the contract of the seat "go-package-registry", defined whole (seats/go-package-registry/seat.json): The mesh's Go modules: Go's module proxy protocol's read side, and publishing over the bus. +package gopackageregistry + +// Seat is the seat's name. +const Seat = "go-package-registry" + +const ( + VerbPublish = "publish" + VerbPublishPart = "publish-part" + VerbVersions = "versions" + VerbRemove = "remove" +) + +const ( + EventPublished = "published" + EventRefused = "refused" + EventRemoved = "removed" + EventKept = "kept" +) + +// Verbs is every verb the seat serves, in its definition's order: a new copy each call. +func Verbs() []string { + return []string{VerbPublish, VerbPublishPart, VerbVersions, VerbRemove} +} + +// Events is every event published on the seat's subject: a new copy each call. +func Events() []string { + return []string{EventPublished, EventRefused, EventRemoved, EventKept} +} + +// Family is where the seat's events are kept: the shared events stream, filtered to the seat's subjects. +const ( + Family = "go-package-registry.events" + FamilyMaxAgeDays = 7 +) + +// SubjectPublished is where published is published. +const SubjectPublished = "mesh.seat.go-package-registry.event.published" + +// SubjectRefused is where refused is published. +const SubjectRefused = "mesh.seat.go-package-registry.event.refused" + +// SubjectRemoved is where removed is published. +const SubjectRemoved = "mesh.seat.go-package-registry.event.removed" + +// SubjectKept is where kept is published. +const SubjectKept = "mesh.seat.go-package-registry.event.kept" + +// Artifact is one published thing, as every registry event and answer names it. +type Artifact struct { + // the source commit it was built from + Commit string `json:"commit,omitempty"` + // its digest, sha256: (a Go package's h1: hash beside it in the version's record) + Digest string `json:"digest"` + // the build's fingerprint + Fingerprint string `json:"fingerprint,omitempty"` + // what it is + Kind string `json:"kind"` + // the package's, image's, bundle's or archive's name; a package's is its name in its own language (design 32): a Go package's the go.mod module path (git.novox.be/novox/mesh-sdk/go), an npm package's package.json's name (@novox/mesh-sdk) + Name string `json:"name"` + // the tag it carries, for a tagged version + Tag string `json:"tag,omitempty"` + // the version the builder gave it (design 51 *The versions*); empty for a bundle or archive published by its digest alone + Version string `json:"version,omitempty"` +} + +// The values of Artifact's kind. +const ( + ArtifactKindPackage = "package" + ArtifactKindImage = "image" + ArtifactKindBundle = "bundle" + ArtifactKindArchive = "archive" + ArtifactKindUpstream = "upstream" +) + +// Outcome is the body of published, refused and removed. +type Outcome struct { + // the source commit it was built from + Commit string `json:"commit,omitempty"` + // its digest, sha256: (a Go package's h1: hash beside it in the version's record) + Digest string `json:"digest"` + // the build's fingerprint + Fingerprint string `json:"fingerprint,omitempty"` + // the module holding the seat that said it + Holder string `json:"holder"` + // what it is + Kind string `json:"kind"` + // the Go module path a Go package is fetched under, equal to name for a Go package (go-package-registry's published); empty for every other kind of artifact + Module string `json:"module,omitempty"` + // the package's, image's, bundle's or archive's name; a package's is its name in its own language (design 32): a Go package's the go.mod module path (git.novox.be/novox/mesh-sdk/go), an npm package's package.json's name (@novox/mesh-sdk) + Name string `json:"name"` + // why: a refusal's cause, a removal's (retention, tag-gone, by-hand), or why a removal was kept (shared-digest) + Reason string `json:"reason,omitempty"` + // the reference the seat answers: //@sha256:, or @ for a package (ADR 0605 §9) + Reference string `json:"reference,omitempty"` + // the tag it carries, for a tagged version + Tag string `json:"tag,omitempty"` + // for kept: the other tags still naming its digest + Tags []string `json:"tags,omitempty"` + // the transfer it ends, when it ended one + Transfer string `json:"transfer,omitempty"` + // the version the builder gave it (design 51 *The versions*); empty for a bundle or archive published by its digest alone + Version string `json:"version,omitempty"` +} + +// The values of Outcome's kind. +const ( + OutcomeKindPackage = "package" + OutcomeKindImage = "image" + OutcomeKindBundle = "bundle" + OutcomeKindArchive = "archive" + OutcomeKindUpstream = "upstream" +) + +// Part is one part of a transfer. +type Part struct { + // the part's digest, sha256:: an image's layer or config, a package's file, a bundle's blob + Digest string `json:"digest"` + // its size in bytes + Size int64 `json:"size"` +} + +// PublishInput is what publish takes. +type PublishInput struct { + // the source commit it was built from + Commit string `json:"commit,omitempty"` + // its digest, sha256: (a Go package's h1: hash beside it in the version's record) + Digest string `json:"digest"` + // the build's fingerprint + Fingerprint string `json:"fingerprint,omitempty"` + // what it is + Kind string `json:"kind"` + // the package's, image's, bundle's or archive's name; a package's is its name in its own language (design 32): a Go package's the go.mod module path (git.novox.be/novox/mesh-sdk/go), an npm package's package.json's name (@novox/mesh-sdk) + Name string `json:"name"` + // every part's digest and size; none when the digest is already held + Parts []Part `json:"parts,omitempty"` + // the tag it carries, for a tagged version + Tag string `json:"tag,omitempty"` + // the transfer's name, the caller's own; the same name opens the same transfer again + Transfer string `json:"transfer"` + // the version the builder gave it (design 51 *The versions*); empty for a bundle or archive published by its digest alone + Version string `json:"version,omitempty"` +} + +// The values of PublishInput's kind. +const ( + PublishInputKindPackage = "package" + PublishInputKindImage = "image" + PublishInputKindBundle = "bundle" + PublishInputKindArchive = "archive" + PublishInputKindUpstream = "upstream" +) + +// PublishAnswer is what publish answers. +type PublishAnswer struct { + // the digest is held already: nothing to send; the outcome follows as an event + Held bool `json:"held,omitempty"` + // the digests of the parts the holder lacks, to send by publish-part + Lacks []string `json:"lacks,omitempty"` + // the transfer + Transfer string `json:"transfer"` +} + +// PublishPartInput is what publish-part takes. +type PublishPartInput struct { + // the chunk's bytes, base64 (at most 760 KiB before encoding) + Data string `json:"data"` + // this chunk is the part's last + Last bool `json:"last"` + // the part's digest + Part string `json:"part"` + // the chunk's place in its part, from 0 + Sequence int64 `json:"sequence"` + // the transfer + Transfer string `json:"transfer"` +} + +// PublishPartAnswer is what publish-part answers. +type PublishPartAnswer struct { + // the part is whole and its bytes match its digest + Complete bool `json:"complete,omitempty"` + // the part + Part string `json:"part"` + // the chunks of the part held so far + Received int64 `json:"received,omitempty"` + // the transfer + Transfer string `json:"transfer"` +} + +// VersionsInput is what versions takes. +type VersionsInput struct { + // narrowed to one kind (optional) + Kind string `json:"kind,omitempty"` + // the name + Name string `json:"name"` +} + +// VersionsAnswer is what versions answers. +type VersionsAnswer struct { + // the name + Name string `json:"name"` + // newest first + Versions []Artifact `json:"versions"` +} + +// RemoveInput is what remove takes. +type RemoveInput struct { + // the digest (for a bundle, archive or copied base) + Digest string `json:"digest,omitempty"` + // the name + Name string `json:"name"` + // why it goes + Reason string `json:"reason"` + // the tag (for a tagged version) + Tag string `json:"tag,omitempty"` + // the version + Version string `json:"version,omitempty"` +} + +// The values of RemoveInput's reason. +const ( + RemoveInputReasonTagGone = "tag-gone" + RemoveInputReasonRetention = "retention" +) + +// RemoveAnswer is what remove answers. +type RemoveAnswer struct { + Artifact *Artifact `json:"artifact,omitempty"` + // it stays whole, its tag and its bytes: the store cannot remove the tag alone while another tag names its digest (reason, tags) + Kept bool `json:"kept,omitempty"` + // why it was kept: shared-digest, another tag names its digest + Reason string `json:"reason,omitempty"` + // it is gone + Removed bool `json:"removed"` + // the other tags still naming its digest, when kept + Tags []string `json:"tags,omitempty"` +} + +// The values of RemoveAnswer's reason. +const ( + RemoveAnswerReasonSharedDigest = "shared-digest" +) + +// PublishedEvent is the body of the event published. +type PublishedEvent = Outcome + +// RefusedEvent is the body of the event refused. +type RefusedEvent = Outcome + +// RemovedEvent is the body of the event removed. +type RemovedEvent = Outcome + +// KeptEvent is the body of the event kept. +type KeptEvent = Outcome + +// GoPackageRegistryForm is the form of the provision go-package-registry. +type GoPackageRegistryForm struct { + // the read side's address + URL string `json:"url"` +} diff --git a/contracts/go/seats/gopackageregistry/contract_test.go b/contracts/go/seats/gopackageregistry/contract_test.go new file mode 100644 index 00000000..e5f69ec1 --- /dev/null +++ b/contracts/go/seats/gopackageregistry/contract_test.go @@ -0,0 +1,66 @@ +// Code generated by seatgen from the seat definitions; DO NOT EDIT. + +package gopackageregistry + +import ( + "encoding/json" + "reflect" + "regexp" + "testing" +) + +// TestFixtures reads every fixture of seats/go-package-registry/fixtures: each decodes into its type, a field the type does not +// know is dropped (ADR 0608 §6), and it reads back as its wire says; each subject named is the contract's. +func TestFixtures(t *testing.T) { + cases := []struct { + name, subject, given, wire string + decode func([]byte) (any, error) + subjectIs *regexp.Regexp + }{ + {name: "published on the seat's subject", subject: "mesh.seat.go-package-registry.event.published", given: "{\"commit\":\"cccccccccccccccccccccccccccccccccccccccc\",\"digest\":\"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"fingerprint\":\"f1\",\"holder\":\"gitea\",\"kind\":\"package\",\"module\":\"git.novox.be/novox/mesh-sdk/go\",\"name\":\"git.novox.be/novox/mesh-sdk/go\",\"reference\":\"git.novox.be/novox/mesh-sdk/go@0.1.19\",\"transfer\":\"t1\",\"version\":\"0.1.19\"}", wire: "{\"commit\":\"cccccccccccccccccccccccccccccccccccccccc\",\"digest\":\"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"fingerprint\":\"f1\",\"holder\":\"gitea\",\"kind\":\"package\",\"module\":\"git.novox.be/novox/mesh-sdk/go\",\"name\":\"git.novox.be/novox/mesh-sdk/go\",\"reference\":\"git.novox.be/novox/mesh-sdk/go@0.1.19\",\"transfer\":\"t1\",\"version\":\"0.1.19\"}", + decode: func(b []byte) (any, error) { var v PublishedEvent; err := json.Unmarshal(b, &v); return v, err }, subjectIs: regexp.MustCompile("^mesh\\.seat\\.go-package-registry\\.event\\.published$")}, + {name: "refused on the seat's subject", subject: "mesh.seat.go-package-registry.event.refused", given: "{\"commit\":\"cccccccccccccccccccccccccccccccccccccccc\",\"digest\":\"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"fingerprint\":\"f1\",\"holder\":\"gitea\",\"kind\":\"package\",\"name\":\"git.novox.be/novox/mesh-sdk/go\",\"reason\":\"retention\",\"reference\":\"git.novox.be/novox/mesh-sdk/go@0.1.19\",\"transfer\":\"t1\",\"version\":\"0.1.19\"}", wire: "{\"commit\":\"cccccccccccccccccccccccccccccccccccccccc\",\"digest\":\"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"fingerprint\":\"f1\",\"holder\":\"gitea\",\"kind\":\"package\",\"name\":\"git.novox.be/novox/mesh-sdk/go\",\"reason\":\"retention\",\"reference\":\"git.novox.be/novox/mesh-sdk/go@0.1.19\",\"transfer\":\"t1\",\"version\":\"0.1.19\"}", + decode: func(b []byte) (any, error) { var v RefusedEvent; err := json.Unmarshal(b, &v); return v, err }, subjectIs: regexp.MustCompile("^mesh\\.seat\\.go-package-registry\\.event\\.refused$")}, + {name: "removed on the seat's subject", subject: "mesh.seat.go-package-registry.event.removed", given: "{\"commit\":\"cccccccccccccccccccccccccccccccccccccccc\",\"digest\":\"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"fingerprint\":\"f1\",\"holder\":\"gitea\",\"kind\":\"package\",\"name\":\"git.novox.be/novox/mesh-sdk/go\",\"reason\":\"retention\",\"reference\":\"git.novox.be/novox/mesh-sdk/go@0.1.19\",\"transfer\":\"t1\",\"version\":\"0.1.19\"}", wire: "{\"commit\":\"cccccccccccccccccccccccccccccccccccccccc\",\"digest\":\"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"fingerprint\":\"f1\",\"holder\":\"gitea\",\"kind\":\"package\",\"name\":\"git.novox.be/novox/mesh-sdk/go\",\"reason\":\"retention\",\"reference\":\"git.novox.be/novox/mesh-sdk/go@0.1.19\",\"transfer\":\"t1\",\"version\":\"0.1.19\"}", + decode: func(b []byte) (any, error) { var v RemovedEvent; err := json.Unmarshal(b, &v); return v, err }, subjectIs: regexp.MustCompile("^mesh\\.seat\\.go-package-registry\\.event\\.removed$")}, + {name: "kept on the seat's subject: a tagged version sharing its digest stays, naming the tags on it", subject: "mesh.seat.go-package-registry.event.kept", given: "{\"commit\":\"cccccccccccccccccccccccccccccccccccccccc\",\"digest\":\"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"fingerprint\":\"f1\",\"holder\":\"gitea\",\"kind\":\"package\",\"name\":\"git.novox.be/novox/mesh-sdk/go\",\"reason\":\"shared-digest\",\"reference\":\"git.novox.be/novox/mesh-sdk/go@0.1.19\",\"tag\":\"0.1.19-feat-x.2\",\"tags\":[\"0.1.19\"],\"version\":\"0.1.19-feat-x.2\"}", wire: "{\"commit\":\"cccccccccccccccccccccccccccccccccccccccc\",\"digest\":\"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"fingerprint\":\"f1\",\"holder\":\"gitea\",\"kind\":\"package\",\"name\":\"git.novox.be/novox/mesh-sdk/go\",\"reason\":\"shared-digest\",\"reference\":\"git.novox.be/novox/mesh-sdk/go@0.1.19\",\"tag\":\"0.1.19-feat-x.2\",\"tags\":[\"0.1.19\"],\"version\":\"0.1.19-feat-x.2\"}", + decode: func(b []byte) (any, error) { var v KeptEvent; err := json.Unmarshal(b, &v); return v, err }, subjectIs: regexp.MustCompile("^mesh\\.seat\\.go-package-registry\\.event\\.kept$")}, + {name: "publish opens a transfer", subject: "", given: "{\"commit\":\"cccccccccccccccccccccccccccccccccccccccc\",\"digest\":\"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"fingerprint\":\"f1\",\"kind\":\"package\",\"name\":\"git.novox.be/novox/mesh-sdk/go\",\"parts\":[{\"digest\":\"sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\",\"size\":1048576}],\"transfer\":\"t1\",\"version\":\"0.1.19\"}", wire: "{\"commit\":\"cccccccccccccccccccccccccccccccccccccccc\",\"digest\":\"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"fingerprint\":\"f1\",\"kind\":\"package\",\"name\":\"git.novox.be/novox/mesh-sdk/go\",\"parts\":[{\"digest\":\"sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\",\"size\":1048576}],\"transfer\":\"t1\",\"version\":\"0.1.19\"}", + decode: func(b []byte) (any, error) { var v PublishInput; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil}, + {name: "a field the reader does not know is dropped", subject: "", given: "{\"commit\":\"cccccccccccccccccccccccccccccccccccccccc\",\"digest\":\"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"fingerprint\":\"f1\",\"kind\":\"package\",\"later\":{\"x\":1},\"name\":\"git.novox.be/novox/mesh-sdk/go\",\"parts\":[{\"digest\":\"sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\",\"size\":1048576}],\"transfer\":\"t1\",\"version\":\"0.1.19\"}", wire: "{\"commit\":\"cccccccccccccccccccccccccccccccccccccccc\",\"digest\":\"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"fingerprint\":\"f1\",\"kind\":\"package\",\"name\":\"git.novox.be/novox/mesh-sdk/go\",\"parts\":[{\"digest\":\"sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\",\"size\":1048576}],\"transfer\":\"t1\",\"version\":\"0.1.19\"}", + decode: func(b []byte) (any, error) { var v PublishInput; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil}, + {name: "publish answers what it lacks", subject: "", given: "{\"lacks\":[\"sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\"],\"transfer\":\"t1\"}", wire: "{\"lacks\":[\"sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\"],\"transfer\":\"t1\"}", + decode: func(b []byte) (any, error) { var v PublishAnswer; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil}, + {name: "a chunk of a part", subject: "", given: "{\"data\":\"aGVsbG8=\",\"last\":true,\"part\":\"sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\",\"sequence\":0,\"transfer\":\"t1\"}", wire: "{\"data\":\"aGVsbG8=\",\"last\":true,\"part\":\"sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\",\"sequence\":0,\"transfer\":\"t1\"}", + decode: func(b []byte) (any, error) { var v PublishPartInput; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil}, + {name: "the versions held", subject: "", given: "{\"name\":\"git.novox.be/novox/mesh-sdk/go\",\"versions\":[{\"commit\":\"cccccccccccccccccccccccccccccccccccccccc\",\"digest\":\"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"fingerprint\":\"f1\",\"kind\":\"package\",\"name\":\"git.novox.be/novox/mesh-sdk/go\",\"version\":\"0.1.19\"}]}", wire: "{\"name\":\"git.novox.be/novox/mesh-sdk/go\",\"versions\":[{\"commit\":\"cccccccccccccccccccccccccccccccccccccccc\",\"digest\":\"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"fingerprint\":\"f1\",\"kind\":\"package\",\"name\":\"git.novox.be/novox/mesh-sdk/go\",\"version\":\"0.1.19\"}]}", + decode: func(b []byte) (any, error) { var v VersionsAnswer; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil}, + {name: "a tagged version sharing its digest with another tag is kept, not removed", subject: "", given: "{\"kept\":true,\"reason\":\"shared-digest\",\"removed\":false,\"tags\":[\"0.1.19\"]}", wire: "{\"kept\":true,\"reason\":\"shared-digest\",\"removed\":false,\"tags\":[\"0.1.19\"]}", + decode: func(b []byte) (any, error) { var v RemoveAnswer; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil}, + } + for _, c := range cases { + v, err := c.decode([]byte(c.given)) + if err != nil { + t.Errorf("%s: does not decode: %v", c.name, err) + continue + } + back, err := json.Marshal(v) + if err != nil { + t.Errorf("%s: does not encode: %v", c.name, err) + continue + } + var got, want any + if err := json.Unmarshal(back, &got); err != nil { + t.Fatal(err) + } + if err := json.Unmarshal([]byte(c.wire), &want); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(got, want) { + t.Errorf("%s: reads back as %s, want %s", c.name, back, c.wire) + } + if c.subjectIs != nil && !c.subjectIs.MatchString(c.subject) { + t.Errorf("%s: subject %q is not the contract's %s", c.name, c.subject, c.subjectIs) + } + } +} diff --git a/contracts/go/seats/gopackageregistry/go.mod b/contracts/go/seats/gopackageregistry/go.mod new file mode 100644 index 00000000..065f758b --- /dev/null +++ b/contracts/go/seats/gopackageregistry/go.mod @@ -0,0 +1,3 @@ +module git.novox.be/novox/mesh-delivery/contracts/go/seats/gopackageregistry + +go 1.26.0 diff --git a/contracts/go/seats/meshartifactsweep/contract.go b/contracts/go/seats/meshartifactsweep/contract.go new file mode 100644 index 00000000..8aa87d21 --- /dev/null +++ b/contracts/go/seats/meshartifactsweep/contract.go @@ -0,0 +1,129 @@ +// Code generated by seatgen from the seat definitions; DO NOT EDIT. + +// Package meshartifactsweep is the contract of the seat "mesh-artifact-sweep", defined whole (seats/mesh-artifact-sweep/seat.json): no description. +package meshartifactsweep + +import "time" + +// Seat is the seat's name. +const Seat = "mesh-artifact-sweep" + +const ( + VerbArtifacts = "artifacts" + VerbHeld = "held" + VerbCollect = "collect" + VerbMirrors = "mirrors" + VerbImages = "images" +) + +const ( + EventArtifactsGone = "artifacts-gone" +) + +// Verbs is every verb the seat serves, in its definition's order: a new copy each call. +func Verbs() []string { + return []string{VerbArtifacts, VerbHeld, VerbCollect, VerbMirrors, VerbImages} +} + +// Events is every event published on the seat's subject: a new copy each call. +func Events() []string { + return []string{EventArtifactsGone} +} + +// Family is where the seat's events are kept: the shared events stream, filtered to the seat's subjects. +const ( + Family = "mesh-artifact-sweep.events" + FamilyMaxAgeDays = 7 +) + +// SubjectArtifactsGone is where artifacts-gone is published. +const SubjectArtifactsGone = "mesh.seat.mesh-artifact-sweep.event.artifacts-gone" + +// ArtifactsGone is the body of artifacts-gone. +type ArtifactsGone struct { + // when the sweep found it + At *time.Time `json:"at,omitempty"` + // artifacts-gone + Event string `json:"event"` + // the key's fingerprint + Fingerprint string `json:"fingerprint"` + // every reference of the key the store does not hold, sorted + Gone []string `json:"gone"` + // the builder's done key + Key string `json:"key,omitempty"` + // the module whose builder's key names a gone artifact + Module string `json:"module"` +} + +// ArtifactsInput is what artifacts takes. +type ArtifactsInput struct { + // "true": list the collected references too (counted always) + Collected string `json:"collected,omitempty"` + // only this repository's references, as / (the counts stay whole) + Repository string `json:"repository,omitempty"` +} + +// The values of ArtifactsInput's collected. +const ( + ArtifactsInputCollectedTrue = "true" + ArtifactsInputCollectedFalse = "false" +) + +// ArtifactsAnswer is what artifacts answers. +type ArtifactsAnswer = map[string]any + +// HeldInput is what held takes. +type HeldInput struct { + // the artifacts' references, artifact-store:////blobs/sha256:: a list, or one text separated by commas + References []string `json:"references"` +} + +// HeldAnswer is what held answers. +type HeldAnswer struct { + // the references the store answered 404 for + Gone []string `json:"gone"` + // the references the store holds + Held []string `json:"held"` + // the store asked, host and port + Store string `json:"store,omitempty"` + // a reference the store could not be asked about, to why + Unknown map[string]string `json:"unknown,omitempty"` +} + +// CollectInput is what collect takes. +type CollectInput struct { + // "true": let go of what is eligible (needs why); without it nothing is done + Confirm string `json:"confirm,omitempty"` + // let go of at most this many (500 by default, at most 5000) + Most string `json:"most,omitempty"` + // with confirm: why, required and recorded + Why string `json:"why,omitempty"` +} + +// The values of CollectInput's confirm. +const ( + CollectInputConfirmTrue = "true" + CollectInputConfirmFalse = "false" +) + +// CollectAnswer is what collect answers. +type CollectAnswer = map[string]any + +// MirrorsInput is what mirrors takes. +type MirrorsInput struct { +} + +// MirrorsAnswer is what mirrors answers. +type MirrorsAnswer = map[string]any + +// ImagesInput is what images takes. +type ImagesInput struct { + // the machine's name + Node string `json:"node"` +} + +// ImagesAnswer is what images answers. +type ImagesAnswer = map[string]any + +// ArtifactsGoneEvent is the body of the event artifacts-gone. +type ArtifactsGoneEvent = ArtifactsGone diff --git a/contracts/go/seats/meshartifactsweep/contract_test.go b/contracts/go/seats/meshartifactsweep/contract_test.go new file mode 100644 index 00000000..ea7a5f1f --- /dev/null +++ b/contracts/go/seats/meshartifactsweep/contract_test.go @@ -0,0 +1,54 @@ +// Code generated by seatgen from the seat definitions; DO NOT EDIT. + +package meshartifactsweep + +import ( + "encoding/json" + "reflect" + "regexp" + "testing" +) + +// TestFixtures reads every fixture of seats/mesh-artifact-sweep/fixtures: each decodes into its type, a field the type does not +// know is dropped (ADR 0608 §6), and it reads back as its wire says; each subject named is the contract's. +func TestFixtures(t *testing.T) { + cases := []struct { + name, subject, given, wire string + decode func([]byte) (any, error) + subjectIs *regexp.Regexp + }{ + {name: "artifacts-gone on the seat's subject", subject: "mesh.seat.mesh-artifact-sweep.event.artifacts-gone", given: "{\"at\":\"2026-11-05T15:04:05Z\",\"event\":\"artifacts-gone\",\"fingerprint\":\"f1\",\"gone\":[\"artifact-store://ca-trust/archive/blobs/sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"artifact-store://ca-trust/archive/blobs/sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\"],\"key\":\"ca-trust.archive.f1\",\"module\":\"ca-trust\"}", wire: "{\"at\":\"2026-11-05T15:04:05Z\",\"event\":\"artifacts-gone\",\"fingerprint\":\"f1\",\"gone\":[\"artifact-store://ca-trust/archive/blobs/sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"artifact-store://ca-trust/archive/blobs/sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\"],\"key\":\"ca-trust.archive.f1\",\"module\":\"ca-trust\"}", + decode: func(b []byte) (any, error) { var v ArtifactsGoneEvent; err := json.Unmarshal(b, &v); return v, err }, subjectIs: regexp.MustCompile("^mesh\\.seat\\.mesh-artifact-sweep\\.event\\.artifacts-gone$")}, + {name: "held asks the references", subject: "", given: "{\"references\":[\"artifact-store://ca-trust/archive/blobs/sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"artifact-store://ca-trust/archive/blobs/sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\",\"artifact-store://ca-trust/archive/blobs/sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc\"]}", wire: "{\"references\":[\"artifact-store://ca-trust/archive/blobs/sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"artifact-store://ca-trust/archive/blobs/sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\",\"artifact-store://ca-trust/archive/blobs/sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc\"]}", + decode: func(b []byte) (any, error) { var v HeldInput; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil}, + {name: "held answers held, gone and unknown", subject: "", given: "{\"gone\":[\"artifact-store://ca-trust/archive/blobs/sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\"],\"held\":[\"artifact-store://ca-trust/archive/blobs/sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"],\"store\":\"store.test:5100\",\"unknown\":{\"artifact-store://ca-trust/archive/blobs/sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc\":\"the store answered 503\"}}", wire: "{\"gone\":[\"artifact-store://ca-trust/archive/blobs/sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\"],\"held\":[\"artifact-store://ca-trust/archive/blobs/sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"],\"store\":\"store.test:5100\",\"unknown\":{\"artifact-store://ca-trust/archive/blobs/sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc\":\"the store answered 503\"}}", + decode: func(b []byte) (any, error) { var v HeldAnswer; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil}, + {name: "held answers with nothing unknown", subject: "", given: "{\"gone\":[],\"held\":[],\"store\":\"store.test:5100\"}", wire: "{\"gone\":[],\"held\":[],\"store\":\"store.test:5100\"}", + decode: func(b []byte) (any, error) { var v HeldAnswer; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil}, + } + for _, c := range cases { + v, err := c.decode([]byte(c.given)) + if err != nil { + t.Errorf("%s: does not decode: %v", c.name, err) + continue + } + back, err := json.Marshal(v) + if err != nil { + t.Errorf("%s: does not encode: %v", c.name, err) + continue + } + var got, want any + if err := json.Unmarshal(back, &got); err != nil { + t.Fatal(err) + } + if err := json.Unmarshal([]byte(c.wire), &want); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(got, want) { + t.Errorf("%s: reads back as %s, want %s", c.name, back, c.wire) + } + if c.subjectIs != nil && !c.subjectIs.MatchString(c.subject) { + t.Errorf("%s: subject %q is not the contract's %s", c.name, c.subject, c.subjectIs) + } + } +} diff --git a/contracts/go/seats/meshartifactsweep/go.mod b/contracts/go/seats/meshartifactsweep/go.mod new file mode 100644 index 00000000..77f0649b --- /dev/null +++ b/contracts/go/seats/meshartifactsweep/go.mod @@ -0,0 +1,3 @@ +module git.novox.be/novox/mesh-delivery/contracts/go/seats/meshartifactsweep + +go 1.26.0 diff --git a/contracts/go/seats/meshcatalog/contract.go b/contracts/go/seats/meshcatalog/contract.go new file mode 100644 index 00000000..32870c7c --- /dev/null +++ b/contracts/go/seats/meshcatalog/contract.go @@ -0,0 +1,717 @@ +// Code generated by seatgen from the seat definitions; DO NOT EDIT. + +// Package meshcatalog is the contract of the seat "mesh-catalog", defined whole (seats/mesh-catalog/seat.json): The catalog of the mesh: every module the trunk holds with its manifest, fingerprint and verdict, the packages published, and the seat list (ADR 0636 §1, §4). +package meshcatalog + +import ( + "bytes" + "encoding/json" + "errors" + "strings" + "time" +) + +// Seat is the seat's name. +const Seat = "mesh-catalog" + +const ( + VerbModules = "modules" + VerbModule = "module" + VerbProvides = "provides" + VerbDependents = "dependents" + VerbSeatDefinitions = "seat-definitions" +) + +const ( + EventSeatDefinitionChanged = "seat-definition-changed" + EventSeatDefinitionRemoved = "seat-definition-removed" + EventManifestAccepted = "manifest-accepted" + EventManifestRefused = "manifest-refused" +) + +// Verbs is every verb the seat serves, in its definition's order: a new copy each call. +func Verbs() []string { + return []string{VerbModules, VerbModule, VerbProvides, VerbDependents, VerbSeatDefinitions} +} + +// Events is every event published on the seat's subject: a new copy each call. +func Events() []string { + return []string{EventSeatDefinitionChanged, EventSeatDefinitionRemoved, EventManifestAccepted, EventManifestRefused} +} + +// Family is where the seat's events are kept: the shared events stream, filtered to the seat's subjects. +const ( + Family = "mesh-catalog.events" + FamilyMaxAgeDays = 7 +) + +// SubjectSeatDefinitionChanged is where seat-definition-changed is published. +const SubjectSeatDefinitionChanged = "mesh.seat.mesh-catalog.event.seat-definition-changed" + +// SubjectSeatDefinitionRemoved is where seat-definition-removed is published. +const SubjectSeatDefinitionRemoved = "mesh.seat.mesh-catalog.event.seat-definition-removed" + +// SubjectManifestAccepted is where manifest-accepted is published. +const SubjectManifestAccepted = "mesh.seat.mesh-catalog.event.manifest-accepted" + +// SubjectManifestRefused is where manifest-refused is published. +const SubjectManifestRefused = "mesh.seat.mesh-catalog.event.manifest-refused" + +const ( + StateManifests = "manifests" + StateModules = "modules" + StateCandidates = "candidates" + StateVerdicts = "verdicts" + StatePredefined = "predefined" + StateConfigCandidates = "config-candidates" + StatePackages = "packages" +) + +// ManifestsByDigestKey is the key of one value of the state manifests under the form "". +func ManifestsByDigestKey(digest string) string { return digest } + +// ParseManifestsByDigestKey reads the parts of a key of the form "", in the order of the form; the last result is false for a key of another form. +func ParseManifestsByDigestKey(key string) (string, bool) { + rest := key + if rest == "" { + return "", false + } + v0 := rest + rest = "" + if rest != "" { + return "", false + } + return v0, true +} + +// DecodeManifestsByDigestValue reads one value of the state manifests under the form "", refusing a field ManifestsByDigestValue does not know and any text after the value. +func DecodeManifestsByDigestValue(raw []byte) (ManifestsByDigestValue, error) { + var v ManifestsByDigestValue + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.DisallowUnknownFields() + if err := dec.Decode(&v); err != nil { + return v, err + } + if dec.More() { + return v, errors.New("text after the value") + } + return v, nil +} + +// ModulesByModuleKey is the key of one value of the state modules under the form "". +func ModulesByModuleKey(module string) string { return module } + +// ParseModulesByModuleKey reads the parts of a key of the form "", in the order of the form; the last result is false for a key of another form. +func ParseModulesByModuleKey(key string) (string, bool) { + rest := key + if rest == "" { + return "", false + } + v0 := rest + rest = "" + if rest != "" { + return "", false + } + return v0, true +} + +// DecodeModulesByModuleValue reads one value of the state modules under the form "", refusing a field ModulesByModuleValue does not know and any text after the value. +func DecodeModulesByModuleValue(raw []byte) (ModulesByModuleValue, error) { + var v ModulesByModuleValue + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.DisallowUnknownFields() + if err := dec.Decode(&v); err != nil { + return v, err + } + if dec.More() { + return v, errors.New("text after the value") + } + return v, nil +} + +// CandidatesByGroupKey is the key of one value of the state candidates under the form "". +func CandidatesByGroupKey(group string) string { return group } + +// ParseCandidatesByGroupKey reads the parts of a key of the form "", in the order of the form; the last result is false for a key of another form. +func ParseCandidatesByGroupKey(key string) (string, bool) { + rest := key + if rest == "" { + return "", false + } + v0 := rest + rest = "" + if rest != "" { + return "", false + } + return v0, true +} + +// DecodeCandidatesByGroupValue reads one value of the state candidates under the form "", refusing a field CandidatesByGroupValue does not know and any text after the value. +func DecodeCandidatesByGroupValue(raw []byte) (CandidatesByGroupValue, error) { + var v CandidatesByGroupValue + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.DisallowUnknownFields() + if err := dec.Decode(&v); err != nil { + return v, err + } + if dec.More() { + return v, errors.New("text after the value") + } + return v, nil +} + +// VerdictsByDigestKey is the key of one value of the state verdicts under the form "". +func VerdictsByDigestKey(digest string) string { return digest } + +// ParseVerdictsByDigestKey reads the parts of a key of the form "", in the order of the form; the last result is false for a key of another form. +func ParseVerdictsByDigestKey(key string) (string, bool) { + rest := key + if rest == "" { + return "", false + } + v0 := rest + rest = "" + if rest != "" { + return "", false + } + return v0, true +} + +// DecodeVerdictsByDigestValue reads one value of the state verdicts under the form "", refusing a field VerdictsByDigestValue does not know and any text after the value. +func DecodeVerdictsByDigestValue(raw []byte) (VerdictsByDigestValue, error) { + var v VerdictsByDigestValue + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.DisallowUnknownFields() + if err := dec.Decode(&v); err != nil { + return v, err + } + if dec.More() { + return v, errors.New("text after the value") + } + return v, nil +} + +// PredefinedByListKey is the key of one value of the state predefined under the form "list". +func PredefinedByListKey() string { return "list" } + +// ParsePredefinedByListKey reads the parts of a key of the form "list", in the order of the form; the last result is false for a key of another form. +func ParsePredefinedByListKey(key string) bool { + rest := key + if !strings.HasPrefix(rest, "list") { + return false + } + rest = rest[4:] + if rest != "" { + return false + } + return true +} + +// DecodePredefinedByListValue reads one value of the state predefined under the form "list", refusing a field PredefinedByListValue does not know and any text after the value. +func DecodePredefinedByListValue(raw []byte) (PredefinedByListValue, error) { + var v PredefinedByListValue + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.DisallowUnknownFields() + if err := dec.Decode(&v); err != nil { + return v, err + } + if dec.More() { + return v, errors.New("text after the value") + } + return v, nil +} + +// ConfigCandidatesByPullKey is the key of one value of the state config-candidates under the form "". +func ConfigCandidatesByPullKey(pull string) string { return pull } + +// ParseConfigCandidatesByPullKey reads the parts of a key of the form "", in the order of the form; the last result is false for a key of another form. +func ParseConfigCandidatesByPullKey(key string) (string, bool) { + rest := key + if rest == "" { + return "", false + } + v0 := rest + rest = "" + if rest != "" { + return "", false + } + return v0, true +} + +// DecodeConfigCandidatesByPullValue reads one value of the state config-candidates under the form "", refusing a field ConfigCandidatesByPullValue does not know and any text after the value. +func DecodeConfigCandidatesByPullValue(raw []byte) (ConfigCandidatesByPullValue, error) { + var v ConfigCandidatesByPullValue + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.DisallowUnknownFields() + if err := dec.Decode(&v); err != nil { + return v, err + } + if dec.More() { + return v, errors.New("text after the value") + } + return v, nil +} + +// PackagesByModuleKey is the key of one value of the state packages under the form "". +func PackagesByModuleKey(module string) string { return module } + +// ParsePackagesByModuleKey reads the parts of a key of the form "", in the order of the form; the last result is false for a key of another form. +func ParsePackagesByModuleKey(key string) (string, bool) { + rest := key + if rest == "" { + return "", false + } + v0 := rest + rest = "" + if rest != "" { + return "", false + } + return v0, true +} + +// DecodePackagesByModuleValue reads one value of the state packages under the form "", refusing a field PackagesByModuleValue does not know and any text after the value. +func DecodePackagesByModuleValue(raw []byte) (PackagesByModuleValue, error) { + var v PackagesByModuleValue + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.DisallowUnknownFields() + if err := dec.Decode(&v); err != nil { + return v, err + } + if dec.More() { + return v, errors.New("text after the value") + } + return v, nil +} + +// PackagesByPackageSeriesKey is the key of one value of the state packages under the form ".". +func PackagesByPackageSeriesKey(packagePart, series string) string { return packagePart + "." + series } + +// ParsePackagesByPackageSeriesKey reads the parts of a key of the form ".", in the order of the form; the last result is false for a key of another form. +func ParsePackagesByPackageSeriesKey(key string) (string, string, bool) { + rest := key + v0, after0, found0 := strings.Cut(rest, ".") + if !found0 || v0 == "" { + return "", "", false + } + rest = after0 + if rest == "" { + return "", "", false + } + v2 := rest + rest = "" + if rest != "" { + return "", "", false + } + return v0, v2, true +} + +// DecodePackagesByPackageSeriesValue reads one value of the state packages under the form ".", refusing a field PackagesByPackageSeriesValue does not know and any text after the value. +func DecodePackagesByPackageSeriesValue(raw []byte) (PackagesByPackageSeriesValue, error) { + var v PackagesByPackageSeriesValue + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.DisallowUnknownFields() + if err := dec.Decode(&v); err != nil { + return v, err + } + if dec.More() { + return v, errors.New("text after the value") + } + return v, nil +} + +// ToolchainUsed is a toolchain a fingerprint was computed with (ADR 0405 clause 7). +type ToolchainUsed struct { + Language string `json:"language"` + // the build module's recipe version + Recipe string `json:"recipe"` + // the installed toolchain's version + Version string `json:"version"` +} + +// ModulesInput is what modules takes. +type ModulesInput struct { +} + +// ModulesAnswerModulesItem is a type of the contract. +type ModulesAnswerModulesItem struct { + // the merge commit it was last changed at + Commit string `json:"commit"` + // its fingerprint (src2: and 64 hex digits); absent when it has none + Fp string `json:"fp,omitempty"` + Module string `json:"module"` + // why it has none + None string `json:"none,omitempty"` + // the module's directory in its repository, '.' for a root + Path string `json:"path,omitempty"` + Repository string `json:"repository"` +} + +// ModulesAnswer is what modules answers. +type ModulesAnswer struct { + Modules []ModulesAnswerModulesItem `json:"modules"` +} + +// ModuleInput is what module takes. +type ModuleInput struct { + // the module's name + Module string `json:"module"` +} + +// ModuleAnswerModule is a type of the contract. +type ModuleAnswerModule struct { + // the merge commit it was last changed at + Commit string `json:"commit"` + // its fingerprint (src2: and 64 hex digits); absent when it has none + Fp string `json:"fp,omitempty"` + // the module's manifest as the trunk holds it + Manifest map[string]any `json:"manifest,omitempty"` + Module string `json:"module"` + // why it has none + None string `json:"none,omitempty"` + // the module's directory in its repository, '.' for a root + Path string `json:"path,omitempty"` + Repository string `json:"repository"` +} + +// ModuleAnswer is what module answers. +type ModuleAnswer struct { + Module ModuleAnswerModule `json:"module"` +} + +// ProvidesInput is what provides takes. +type ProvidesInput struct { + // the provision, such as postgres-database + Provision string `json:"provision"` +} + +// ProvidesAnswer is what provides answers. +type ProvidesAnswer = map[string]any + +// DependentsInput is what dependents takes. +type DependentsInput struct { + // the module's name + Module string `json:"module"` +} + +// DependentsAnswer is what dependents answers. +type DependentsAnswer = map[string]any + +// SeatDefinitionsInput is what seat-definitions takes. +type SeatDefinitionsInput struct { + // the seat's name; none lists every trunk seat + Seat string `json:"seat,omitempty"` +} + +// SeatDefinitionsAnswerEntry is a type of the contract. +type SeatDefinitionsAnswerEntry struct { + // the definition, resolved with the parts it includes + Definition map[string]any `json:"definition,omitempty"` + Digest string `json:"digest"` + Seat string `json:"seat"` + // trunk + Source string `json:"source"` + // why the definition could not be read, when it could not + Unparsed string `json:"unparsed,omitempty"` +} + +// SeatDefinitionsAnswerSeatsItem is a type of the contract. +type SeatDefinitionsAnswerSeatsItem struct { + Digest string `json:"digest"` + Seat string `json:"seat"` + // trunk + Source string `json:"source"` +} + +// SeatDefinitionsAnswer is what seat-definitions answers. +type SeatDefinitionsAnswer struct { + Entry *SeatDefinitionsAnswerEntry `json:"entry,omitempty"` + Found bool `json:"found,omitempty"` + Revision int64 `json:"revision,omitempty"` + Seat string `json:"seat,omitempty"` + Seats []SeatDefinitionsAnswerSeatsItem `json:"seats,omitempty"` +} + +// SeatDefinitionChangedEvent is the body of the event seat-definition-changed. +type SeatDefinitionChangedEvent struct { + Digest string `json:"digest"` + Seat string `json:"seat"` + // trunk + Source string `json:"source"` +} + +// SeatDefinitionRemovedEvent is the body of the event seat-definition-removed. +type SeatDefinitionRemovedEvent struct { + Seat string `json:"seat"` +} + +// ManifestAcceptedEventBusStateItem is a type of the contract. +type ManifestAcceptedEventBusStateItem struct { + Configuration map[string]any `json:"configuration,omitempty"` + Name string `json:"name"` + Options map[string]any `json:"options,omitempty"` + Storage string `json:"storage"` + Template string `json:"template,omitempty"` + Types []map[string]any `json:"types,omitempty"` +} + +// ManifestAcceptedEvent is the body of the event manifest-accepted. +type ManifestAcceptedEvent struct { + BusState []ManifestAcceptedEventBusStateItem `json:"bus-state"` + Digest string `json:"digest"` + Judge string `json:"judge"` + Module string `json:"module"` + // the indexer's revision of the verdict it says; a reader asks its first view again on a gap in it; zero from an indexer that does not say it + Revision int64 `json:"revision,omitempty"` + V int64 `json:"v"` +} + +// ManifestRefusedEvent is the body of the event manifest-refused. +type ManifestRefusedEvent struct { + Digest string `json:"digest"` + Judge string `json:"judge"` + Module string `json:"module"` + Problems []string `json:"problems"` + // the indexer's revision of the verdict it says; a reader asks its first view again on a gap in it; zero from an indexer that does not say it + Revision int64 `json:"revision,omitempty"` + V int64 `json:"v"` +} + +// ManifestsByDigestValue is one value of the state manifests under the key . +type ManifestsByDigestValue struct { + // the manifest, UTF-8 JSON text + Manifest string `json:"manifest"` + Module string `json:"module"` + // the layout version + V int64 `json:"v,omitempty"` +} + +// ModulesByModuleValueInputs2 is a type of the contract. +type ModulesByModuleValueInputs2 struct { + Bases []string `json:"bases,omitempty"` + Builder string `json:"builder,omitempty"` + Contexts map[string]string `json:"contexts,omitempty"` + ImportForm string `json:"import-form,omitempty"` + Imports []string `json:"imports,omitempty"` + Locks map[string]string `json:"locks,omitempty"` + Packages []string `json:"packages,omitempty"` + Paths map[string]string `json:"paths,omitempty"` + Tree string `json:"tree"` +} + +// ModulesByModuleValue is one value of the state modules under the key . +type ModulesByModuleValue struct { + Commit string `json:"commit"` + // the old-form fingerprint + Fp string `json:"fp,omitempty"` + FpSrc int64 `json:"fp-src,omitempty"` + // src2: fingerprint + Fp2 string `json:"fp2,omitempty"` + Inputs2 *ModulesByModuleValueInputs2 `json:"inputs2,omitempty"` + // manifest digest + Manifest string `json:"manifest,omitempty"` + Needs []string `json:"needs,omitempty"` + None string `json:"none,omitempty"` + // why it has none + None2 string `json:"none2,omitempty"` + Packages map[string]string `json:"packages,omitempty"` + Path string `json:"path,omitempty"` + Paths map[string][]string `json:"paths,omitempty"` + Problems []string `json:"problems,omitempty"` + Repository string `json:"repository"` + // the source revision + Src int64 `json:"src,omitempty"` + // by language: the toolchain FP2 was computed with + Toolchains map[string]ToolchainUsed `json:"toolchains,omitempty"` + Unknown []string `json:"unknown,omitempty"` + // the layout version + V int64 `json:"v,omitempty"` + Verdict string `json:"verdict,omitempty"` + Warnings []string `json:"warnings,omitempty"` +} + +// CandidatesByGroupValuePullsItemFpsValue is a type of the contract. +type CandidatesByGroupValuePullsItemFpsValue struct { + Fp2 string `json:"fp2,omitempty"` + Manifest string `json:"manifest,omitempty"` + None2 string `json:"none2,omitempty"` + Packages map[string]string `json:"packages,omitempty"` + Paths map[string]string `json:"paths,omitempty"` +} + +// CandidatesByGroupValuePullsItem is a type of the contract. +type CandidatesByGroupValuePullsItem struct { + Fps map[string]CandidatesByGroupValuePullsItemFpsValue `json:"fps,omitempty"` + Head string `json:"head"` + Modules []string `json:"modules"` + Number int64 `json:"number"` + Repository string `json:"repository"` +} + +// CandidatesByGroupValue is one value of the state candidates under the key . +type CandidatesByGroupValue struct { + // the source revision it is read on + Base int64 `json:"base"` + Fpset string `json:"fpset,omitempty"` + Group string `json:"group"` + Heads string `json:"heads"` + Pulls []CandidatesByGroupValuePullsItem `json:"pulls"` + // the layout version + V int64 `json:"v,omitempty"` +} + +// VerdictsByDigestValue is one value of the state verdicts under the key . +type VerdictsByDigestValue struct { + // when (RFC 3339 time) + At *time.Time `json:"at,omitempty"` + Judge string `json:"judge,omitempty"` + Module string `json:"module"` + Problems []string `json:"problems,omitempty"` + State string `json:"state"` + Unknown []string `json:"unknown,omitempty"` + // the layout version + V int64 `json:"v,omitempty"` + Warnings []string `json:"warnings,omitempty"` +} + +// PredefinedByListValue is one value of the state predefined under the key list. +type PredefinedByListValue struct { + // when (RFC 3339 time) + At *time.Time `json:"at,omitempty"` + Commit string `json:"commit"` + // the foundation's predefined list as kept + List any `json:"list"` + // the layout version + V int64 `json:"v,omitempty"` +} + +// ConfigCandidatesByPullValueDeclaresValueValue is a type of the contract. +type ConfigCandidatesByPullValueDeclaresValueValue struct { + // the default + Default any `json:"default"` + Description string `json:"description,omitempty"` + // the allowed values + Enum any `json:"enum,omitempty"` + Kind string `json:"kind"` + // a declared bound; 0 is a bound, absent is none + Maximum *float64 `json:"maximum,omitempty"` + // a declared bound; 0 is a bound, absent is none + Minimum *float64 `json:"minimum,omitempty"` + Pattern string `json:"pattern,omitempty"` + Predefined string `json:"predefined,omitempty"` + Required bool `json:"required,omitempty"` + Runtime bool `json:"runtime,omitempty"` + Trusted bool `json:"trusted,omitempty"` + Type string `json:"type,omitempty"` + Why string `json:"why"` +} + +// ConfigCandidatesByPullValue is one value of the state config-candidates under the key . +type ConfigCandidatesByPullValue struct { + // when (RFC 3339 time) + At *time.Time `json:"at,omitempty"` + // module to key to declaration + Declares map[string]map[string]ConfigCandidatesByPullValueDeclaresValueValue `json:"declares"` + Head string `json:"head"` + Pull string `json:"pull"` + // the layout version + V int64 `json:"v,omitempty"` +} + +// PackagesByModuleValuePackagesItem is a type of the contract. +type PackagesByModuleValuePackagesItem struct { + Digest string `json:"digest"` + Package string `json:"package"` +} + +// PackagesByModuleValue is one value of the state packages under the key . +type PackagesByModuleValue struct { + // when (RFC 3339 time) + At *time.Time `json:"at,omitempty"` + Commit string `json:"commit"` + Dir string `json:"dir,omitempty"` + // the Go module path + Module string `json:"module"` + Packages []PackagesByModuleValuePackagesItem `json:"packages"` + Repository string `json:"repository"` + // digest of go.mod and go.sum + Requires string `json:"requires,omitempty"` + // the layout version + V int64 `json:"v,omitempty"` +} + +// PackagesByPackageSeriesValueNewestContentsPackagesItem is a type of the contract. +type PackagesByPackageSeriesValueNewestContentsPackagesItem struct { + Digest string `json:"digest"` + Package string `json:"package"` +} + +// PackagesByPackageSeriesValueNewestContents is a type of the contract. +type PackagesByPackageSeriesValueNewestContents struct { + // when (RFC 3339 time) + At *time.Time `json:"at,omitempty"` + Commit string `json:"commit"` + Dir string `json:"dir,omitempty"` + // the Go module path + Module string `json:"module"` + Packages []PackagesByPackageSeriesValueNewestContentsPackagesItem `json:"packages"` + Repository string `json:"repository"` + // digest of go.mod and go.sum + Requires string `json:"requires,omitempty"` + // the layout version + V int64 `json:"v,omitempty"` +} + +// PackagesByPackageSeriesValueNewest is a type of the contract. +type PackagesByPackageSeriesValueNewest struct { + Commit string `json:"commit,omitempty"` + Contents *PackagesByPackageSeriesValueNewestContents `json:"contents,omitempty"` + Digest string `json:"digest,omitempty"` + Fingerprint string `json:"fingerprint,omitempty"` + Version string `json:"version"` +} + +// PackagesByPackageSeriesValueNumberedContentsPackagesItem is a type of the contract. +type PackagesByPackageSeriesValueNumberedContentsPackagesItem struct { + Digest string `json:"digest"` + Package string `json:"package"` +} + +// PackagesByPackageSeriesValueNumberedContents is a type of the contract. +type PackagesByPackageSeriesValueNumberedContents struct { + // when (RFC 3339 time) + At *time.Time `json:"at,omitempty"` + Commit string `json:"commit"` + Dir string `json:"dir,omitempty"` + // the Go module path + Module string `json:"module"` + Packages []PackagesByPackageSeriesValueNumberedContentsPackagesItem `json:"packages"` + Repository string `json:"repository"` + // digest of go.mod and go.sum + Requires string `json:"requires,omitempty"` + // the layout version + V int64 `json:"v,omitempty"` +} + +// PackagesByPackageSeriesValueNumbered is a type of the contract. +type PackagesByPackageSeriesValueNumbered struct { + Commit string `json:"commit,omitempty"` + Contents *PackagesByPackageSeriesValueNumberedContents `json:"contents,omitempty"` + Digest string `json:"digest,omitempty"` + Fingerprint string `json:"fingerprint,omitempty"` + Version string `json:"version"` +} + +// PackagesByPackageSeriesValue is one value of the state packages under the key .. +type PackagesByPackageSeriesValue struct { + // when (RFC 3339 time) + At *time.Time `json:"at,omitempty"` + GoModule string `json:"go-module,omitempty"` + Name string `json:"name"` + Newest *PackagesByPackageSeriesValueNewest `json:"newest,omitempty"` + Numbered *PackagesByPackageSeriesValueNumbered `json:"numbered,omitempty"` + Package string `json:"package,omitempty"` + Seat string `json:"seat,omitempty"` + Series string `json:"series"` + // the layout version + V int64 `json:"v,omitempty"` +} diff --git a/contracts/go/seats/meshcatalog/contract_test.go b/contracts/go/seats/meshcatalog/contract_test.go new file mode 100644 index 00000000..b6d95b15 --- /dev/null +++ b/contracts/go/seats/meshcatalog/contract_test.go @@ -0,0 +1,82 @@ +// Code generated by seatgen from the seat definitions; DO NOT EDIT. + +package meshcatalog + +import ( + "encoding/json" + "reflect" + "regexp" + "testing" +) + +// TestFixtures reads every fixture of seats/mesh-catalog/fixtures: each decodes into its type, a field the type does not +// know is dropped (ADR 0608 §6), and it reads back as its wire says; each subject named is the contract's. +func TestFixtures(t *testing.T) { + cases := []struct { + name, subject, given, wire string + decode func([]byte) (any, error) + subjectIs *regexp.Regexp + }{ + {name: "a manifest (xclip, read through mesh-catalog.module)", subject: "", given: "{\"manifest\":\"{\\\"build\\\":{\\\"artifacts\\\":[{\\\"binary\\\":\\\"xclip-tools\\\",\\\"from\\\":\\\"cmd/xclip-tools\\\",\\\"kind\\\":\\\"bundle\\\",\\\"language\\\":\\\"go\\\",\\\"loads\\\":[\\\"xclip-tools\\\"],\\\"name\\\":\\\"tools\\\",\\\"system\\\":\\\"arch\\\"}]},\\\"capabilities\\\":[\\\"package-manager\\\"],\\\"module\\\":\\\"xclip\\\",\\\"resources\\\":[{\\\"id\\\":\\\"package\\\",\\\"package\\\":\\\"xclip\\\",\\\"type\\\":\\\"package\\\"}],\\\"tools\\\":[\\\"xclip_copy\\\",\\\"xclip_paste\\\",\\\"xclip_targets\\\",\\\"xclip_session\\\"],\\\"version\\\":\\\"1\\\"}\",\"module\":\"xclip\",\"v\":1}", wire: "{\"manifest\":\"{\\\"build\\\":{\\\"artifacts\\\":[{\\\"binary\\\":\\\"xclip-tools\\\",\\\"from\\\":\\\"cmd/xclip-tools\\\",\\\"kind\\\":\\\"bundle\\\",\\\"language\\\":\\\"go\\\",\\\"loads\\\":[\\\"xclip-tools\\\"],\\\"name\\\":\\\"tools\\\",\\\"system\\\":\\\"arch\\\"}]},\\\"capabilities\\\":[\\\"package-manager\\\"],\\\"module\\\":\\\"xclip\\\",\\\"resources\\\":[{\\\"id\\\":\\\"package\\\",\\\"package\\\":\\\"xclip\\\",\\\"type\\\":\\\"package\\\"}],\\\"tools\\\":[\\\"xclip_copy\\\",\\\"xclip_paste\\\",\\\"xclip_targets\\\",\\\"xclip_session\\\"],\\\"version\\\":\\\"1\\\"}\",\"module\":\"xclip\",\"v\":1}", + decode: func(b []byte) (any, error) { var v ManifestsByDigestValue; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil}, + {name: "a module (xclip, read through mesh-catalog.modules; the inputs and toolchains no tool reads are not here)", subject: "", given: "{\"commit\":\"f469eb387a3015569d9b16c66ed418e9dd098f64\",\"fp2\":\"src2:b427bdde1f100e2f52490cfa9df0c5112dd3700226b4892349f0c14a15a4af56\",\"path\":\"modules/xclip\",\"repository\":\"novox/mesh-catalog\",\"v\":1}", wire: "{\"commit\":\"f469eb387a3015569d9b16c66ed418e9dd098f64\",\"fp2\":\"src2:b427bdde1f100e2f52490cfa9df0c5112dd3700226b4892349f0c14a15a4af56\",\"path\":\"modules/xclip\",\"repository\":\"novox/mesh-catalog\",\"v\":1}", + decode: func(b []byte) (any, error) { var v ModulesByModuleValue; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil}, + {name: "a candidate", subject: "", given: "{\"base\":3,\"group\":\"feat/registry\",\"heads\":\"0123456789ab\",\"pulls\":[{\"head\":\"cccccccccccccccccccccccccccccccccccccccc\",\"modules\":[\"nats\"],\"number\":1,\"repository\":\"novox/mesh-sdk\"}],\"v\":1}", wire: "{\"base\":3,\"group\":\"feat/registry\",\"heads\":\"0123456789ab\",\"pulls\":[{\"head\":\"cccccccccccccccccccccccccccccccccccccccc\",\"modules\":[\"nats\"],\"number\":1,\"repository\":\"novox/mesh-sdk\"}],\"v\":1}", + decode: func(b []byte) (any, error) { var v CandidatesByGroupValue; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil}, + {name: "a refused verdict (nats, read through the indexer's verdicts tool)", subject: "", given: "{\"at\":\"2026-11-04T18:51:12.614583047+01:00\",\"judge\":\"exe:661e6fb424d7ffea\",\"module\":\"nats\",\"problems\":[\"this is not a module manifest: a state is either a name or {name, history, ttl-seconds, per-machine, key-ttl}: json: unknown field \\\"read-only\\\" (read without state[2].read-only)\"],\"state\":\"refused\"}", wire: "{\"at\":\"2026-11-04T18:51:12.614583047+01:00\",\"judge\":\"exe:661e6fb424d7ffea\",\"module\":\"nats\",\"problems\":[\"this is not a module manifest: a state is either a name or {name, history, ttl-seconds, per-machine, key-ttl}: json: unknown field \\\"read-only\\\" (read without state[2].read-only)\"],\"state\":\"refused\"}", + decode: func(b []byte) (any, error) { var v VerdictsByDigestValue; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil}, + {name: "the predefined list", subject: "", given: "{\"commit\":\"cccccccccccccccccccccccccccccccccccccccc\",\"list\":{\"streams\":[]},\"v\":1}", wire: "{\"commit\":\"cccccccccccccccccccccccccccccccccccccccc\",\"list\":{\"streams\":[]},\"v\":1}", + decode: func(b []byte) (any, error) { var v PredefinedByListValue; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil}, + {name: "a pull request's configuration", subject: "", given: "{\"declares\":{\"nats\":{\"limit\":{\"default\":1,\"kind\":\"setting\",\"why\":\"a bound\"}}},\"head\":\"cccccccccccccccccccccccccccccccccccccccc\",\"pull\":\"novox/mesh-catalog#1\",\"v\":1}", wire: "{\"declares\":{\"nats\":{\"limit\":{\"default\":1,\"kind\":\"setting\",\"why\":\"a bound\"}}},\"head\":\"cccccccccccccccccccccccccccccccccccccccc\",\"pull\":\"novox/mesh-catalog#1\",\"v\":1}", + decode: func(b []byte) (any, error) { + var v ConfigCandidatesByPullValue + err := json.Unmarshal(b, &v) + return v, err + }, subjectIs: nil}, + {name: "a pull request's configuration state whose bounds are declared 0 (a zero is a bound, not none)", subject: "", given: "{\"declares\":{\"nats\":{\"limit\":{\"default\":0,\"kind\":\"setting\",\"maximum\":0,\"minimum\":0,\"why\":\"a bound\"}}},\"head\":\"cccccccccccccccccccccccccccccccccccccccc\",\"pull\":\"novox/mesh-catalog#1\",\"v\":1}", wire: "{\"declares\":{\"nats\":{\"limit\":{\"default\":0,\"kind\":\"setting\",\"maximum\":0,\"minimum\":0,\"why\":\"a bound\"}}},\"head\":\"cccccccccccccccccccccccccccccccccccccccc\",\"pull\":\"novox/mesh-catalog#1\",\"v\":1}", + decode: func(b []byte) (any, error) { + var v ConfigCandidatesByPullValue + err := json.Unmarshal(b, &v) + return v, err + }, subjectIs: nil}, + {name: "a published Go module", subject: "", given: "{\"commit\":\"cccccccccccccccccccccccccccccccccccccccc\",\"module\":\"git.novox.be/novox/mesh-sdk/go\",\"packages\":[{\"digest\":\"ab\",\"package\":\"git.novox.be/novox/mesh-sdk/go/keys\"}],\"repository\":\"novox/mesh-sdk\",\"v\":1}", wire: "{\"commit\":\"cccccccccccccccccccccccccccccccccccccccc\",\"module\":\"git.novox.be/novox/mesh-sdk/go\",\"packages\":[{\"digest\":\"ab\",\"package\":\"git.novox.be/novox/mesh-sdk/go/keys\"}],\"repository\":\"novox/mesh-sdk\",\"v\":1}", + decode: func(b []byte) (any, error) { var v PackagesByModuleValue; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil}, + {name: "a package series", subject: "", given: "{\"name\":\"go-keys\",\"series\":\"0.1\",\"v\":1}", wire: "{\"name\":\"go-keys\",\"series\":\"0.1\",\"v\":1}", + decode: func(b []byte) (any, error) { + var v PackagesByPackageSeriesValue + err := json.Unmarshal(b, &v) + return v, err + }, subjectIs: nil}, + {name: "a module with its toolchain", subject: "", given: "{\"commit\":\"f469eb387a3015569d9b16c66ed418e9dd098f64\",\"fp2\":\"src2:b427bdde1f100e2f52490cfa9df0c5112dd3700226b4892349f0c14a15a4af56\",\"repository\":\"novox/mesh-catalog\",\"toolchains\":{\"go\":{\"language\":\"go\",\"recipe\":\"1\",\"version\":\"1.26.0\"}},\"v\":1}", wire: "{\"commit\":\"f469eb387a3015569d9b16c66ed418e9dd098f64\",\"fp2\":\"src2:b427bdde1f100e2f52490cfa9df0c5112dd3700226b4892349f0c14a15a4af56\",\"repository\":\"novox/mesh-catalog\",\"toolchains\":{\"go\":{\"language\":\"go\",\"recipe\":\"1\",\"version\":\"1.26.0\"}},\"v\":1}", + decode: func(b []byte) (any, error) { var v ModulesByModuleValue; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil}, + {name: "the modules verb (captured: the first three modules)", subject: "", given: "{\"modules\":[{\"commit\":\"f469eb387a3015569d9b16c66ed418e9dd098f64\",\"fp\":\"src2:5e4a1b4ad4ea6175c05e4655ce58aeb957547149d4c9805eb27636efd50c1c87\",\"module\":\"adwaita\",\"path\":\"modules/adwaita\",\"repository\":\"novox/mesh-catalog\"},{\"commit\":\"b112adcecc41d18f54190cbd2fc864199b70c518\",\"fp\":\"src2:7dd0db8e98224f46121a5ae05c0b9c5a430eff531a5120aa40b1a59237853a3f\",\"module\":\"amqp-email-forwarder\",\"path\":\".\",\"repository\":\"jschoubben/amqp-email-forwarder\"},{\"commit\":\"dd458cb155f5f5fa3ed824a23728e444be5b156a\",\"fp\":\"src2:a98f8152f0a837dd5ba883a15efaf110ba88100193157e4bdb1a93ba55209371\",\"module\":\"artifact-store-tools\",\"path\":\"modules/artifact-store-tools\",\"repository\":\"novox/mesh-catalog\"}]}", wire: "{\"modules\":[{\"commit\":\"f469eb387a3015569d9b16c66ed418e9dd098f64\",\"fp\":\"src2:5e4a1b4ad4ea6175c05e4655ce58aeb957547149d4c9805eb27636efd50c1c87\",\"module\":\"adwaita\",\"path\":\"modules/adwaita\",\"repository\":\"novox/mesh-catalog\"},{\"commit\":\"b112adcecc41d18f54190cbd2fc864199b70c518\",\"fp\":\"src2:7dd0db8e98224f46121a5ae05c0b9c5a430eff531a5120aa40b1a59237853a3f\",\"module\":\"amqp-email-forwarder\",\"path\":\".\",\"repository\":\"jschoubben/amqp-email-forwarder\"},{\"commit\":\"dd458cb155f5f5fa3ed824a23728e444be5b156a\",\"fp\":\"src2:a98f8152f0a837dd5ba883a15efaf110ba88100193157e4bdb1a93ba55209371\",\"module\":\"artifact-store-tools\",\"path\":\"modules/artifact-store-tools\",\"repository\":\"novox/mesh-catalog\"}]}", + decode: func(b []byte) (any, error) { var v ModulesAnswer; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil}, + {name: "the module verb (captured: xclip)", subject: "", given: "{\"module\":{\"commit\":\"f469eb387a3015569d9b16c66ed418e9dd098f64\",\"fp\":\"src2:b427bdde1f100e2f52490cfa9df0c5112dd3700226b4892349f0c14a15a4af56\",\"manifest\":{\"build\":{\"artifacts\":[{\"binary\":\"xclip-tools\",\"from\":\"cmd/xclip-tools\",\"kind\":\"bundle\",\"language\":\"go\",\"loads\":[\"xclip-tools\"],\"name\":\"tools\",\"system\":\"arch\"}]},\"capabilities\":[\"package-manager\"],\"module\":\"xclip\",\"resources\":[{\"id\":\"package\",\"package\":\"xclip\",\"type\":\"package\"}],\"tools\":[\"xclip_copy\",\"xclip_paste\",\"xclip_targets\",\"xclip_session\"],\"version\":\"1\"},\"module\":\"xclip\",\"path\":\"modules/xclip\",\"repository\":\"novox/mesh-catalog\"}}", wire: "{\"module\":{\"commit\":\"f469eb387a3015569d9b16c66ed418e9dd098f64\",\"fp\":\"src2:b427bdde1f100e2f52490cfa9df0c5112dd3700226b4892349f0c14a15a4af56\",\"manifest\":{\"build\":{\"artifacts\":[{\"binary\":\"xclip-tools\",\"from\":\"cmd/xclip-tools\",\"kind\":\"bundle\",\"language\":\"go\",\"loads\":[\"xclip-tools\"],\"name\":\"tools\",\"system\":\"arch\"}]},\"capabilities\":[\"package-manager\"],\"module\":\"xclip\",\"resources\":[{\"id\":\"package\",\"package\":\"xclip\",\"type\":\"package\"}],\"tools\":[\"xclip_copy\",\"xclip_paste\",\"xclip_targets\",\"xclip_session\"],\"version\":\"1\"},\"module\":\"xclip\",\"path\":\"modules/xclip\",\"repository\":\"novox/mesh-catalog\"}}", + decode: func(b []byte) (any, error) { var v ModuleAnswer; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil}, + } + for _, c := range cases { + v, err := c.decode([]byte(c.given)) + if err != nil { + t.Errorf("%s: does not decode: %v", c.name, err) + continue + } + back, err := json.Marshal(v) + if err != nil { + t.Errorf("%s: does not encode: %v", c.name, err) + continue + } + var got, want any + if err := json.Unmarshal(back, &got); err != nil { + t.Fatal(err) + } + if err := json.Unmarshal([]byte(c.wire), &want); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(got, want) { + t.Errorf("%s: reads back as %s, want %s", c.name, back, c.wire) + } + if c.subjectIs != nil && !c.subjectIs.MatchString(c.subject) { + t.Errorf("%s: subject %q is not the contract's %s", c.name, c.subject, c.subjectIs) + } + } +} diff --git a/contracts/go/seats/meshcatalog/go.mod b/contracts/go/seats/meshcatalog/go.mod new file mode 100644 index 00000000..e6fd10a2 --- /dev/null +++ b/contracts/go/seats/meshcatalog/go.mod @@ -0,0 +1,3 @@ +module git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog + +go 1.26.0 diff --git a/contracts/go/seats/meshdelivery/contract.go b/contracts/go/seats/meshdelivery/contract.go new file mode 100644 index 00000000..1adc3e86 --- /dev/null +++ b/contracts/go/seats/meshdelivery/contract.go @@ -0,0 +1,455 @@ +// Code generated by seatgen from the seat definitions; DO NOT EDIT. + +// Package meshdelivery is the contract of the seat "mesh-delivery", defined whole (seats/mesh-delivery/seat.json): The delivery gate: reads and steers deliveries, merges and the checks that decide them; the merge is made on the operator's Approve once both checks passed. +package meshdelivery + +import ( + "bytes" + "encoding/json" + "errors" + "strings" + "time" +) + +// Seat is the seat's name. +const Seat = "mesh-delivery" + +const ( + VerbDeliveries = "deliveries" + VerbTimes = "times" + VerbShow = "show" + VerbGroups = "groups" + VerbWhatIf = "what-if" + VerbChecks = "checks" + VerbTable = "table" + VerbStalled = "stalled" + VerbRecheck = "recheck" + VerbRelease = "release" + VerbStop = "stop" + VerbClose = "close" + VerbRetireHistory = "retire-history" + VerbSettle = "settle" +) + +// Verbs is every verb the seat serves, in its definition's order: a new copy each call. +func Verbs() []string { + return []string{VerbDeliveries, VerbTimes, VerbShow, VerbGroups, VerbWhatIf, VerbChecks, VerbTable, VerbStalled, VerbRecheck, VerbRelease, VerbStop, VerbClose, VerbRetireHistory, VerbSettle} +} + +const ( + StateSource = "source" + StateMerges = "merges" + StateAsks = "asks" + StateOwed = "owed" + StateTurns = "turns" +) + +// SourceByMainKey is the key of one value of the state source under the form "main". +func SourceByMainKey() string { return "main" } + +// ParseSourceByMainKey reads the parts of a key of the form "main", in the order of the form; the last result is false for a key of another form. +func ParseSourceByMainKey(key string) bool { + rest := key + if !strings.HasPrefix(rest, "main") { + return false + } + rest = rest[4:] + if rest != "" { + return false + } + return true +} + +// DecodeSourceByMainValue reads one value of the state source under the form "main", refusing a field SourceByMainValue does not know and any text after the value. +func DecodeSourceByMainValue(raw []byte) (SourceByMainValue, error) { + var v SourceByMainValue + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.DisallowUnknownFields() + if err := dec.Decode(&v); err != nil { + return v, err + } + if dec.More() { + return v, errors.New("text after the value") + } + return v, nil +} + +// MergesByGroupKey is the key of one value of the state merges under the form "". +func MergesByGroupKey(group string) string { return group } + +// ParseMergesByGroupKey reads the parts of a key of the form "", in the order of the form; the last result is false for a key of another form. +func ParseMergesByGroupKey(key string) (string, bool) { + rest := key + if rest == "" { + return "", false + } + v0 := rest + rest = "" + if rest != "" { + return "", false + } + return v0, true +} + +// DecodeMergesByGroupValue reads one value of the state merges under the form "", refusing a field MergesByGroupValue does not know and any text after the value. +func DecodeMergesByGroupValue(raw []byte) (MergesByGroupValue, error) { + var v MergesByGroupValue + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.DisallowUnknownFields() + if err := dec.Decode(&v); err != nil { + return v, err + } + if dec.More() { + return v, errors.New("text after the value") + } + return v, nil +} + +// AsksByMergeGroupHeadsKey is the key of one value of the state asks under the form "merge..". +func AsksByMergeGroupHeadsKey(group, heads string) string { return "merge." + group + "." + heads } + +// ParseAsksByMergeGroupHeadsKey reads the parts of a key of the form "merge..", in the order of the form; the last result is false for a key of another form. +func ParseAsksByMergeGroupHeadsKey(key string) (string, string, bool) { + rest := key + if !strings.HasPrefix(rest, "merge.") { + return "", "", false + } + rest = rest[6:] + v1, after1, found1 := strings.Cut(rest, ".") + if !found1 || v1 == "" { + return "", "", false + } + rest = after1 + if rest == "" { + return "", "", false + } + v3 := rest + rest = "" + if rest != "" { + return "", "", false + } + return v1, v3, true +} + +// DecodeAsksByMergeGroupHeadsValue reads one value of the state asks under the form "merge..", refusing a field AsksByMergeGroupHeadsValue does not know and any text after the value. +func DecodeAsksByMergeGroupHeadsValue(raw []byte) (AsksByMergeGroupHeadsValue, error) { + var v AsksByMergeGroupHeadsValue + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.DisallowUnknownFields() + if err := dec.Decode(&v); err != nil { + return v, err + } + if dec.More() { + return v, errors.New("text after the value") + } + return v, nil +} + +// OwedByRepositoryNumberHeadActKey is the key of one value of the state owed under the form "...". +func OwedByRepositoryNumberHeadActKey(repository, number, head, act string) string { + return repository + "." + number + "." + head + "." + act +} + +// ParseOwedByRepositoryNumberHeadActKey reads the parts of a key of the form "...", in the order of the form; the last result is false for a key of another form. +func ParseOwedByRepositoryNumberHeadActKey(key string) (string, string, string, string, bool) { + rest := key + v0, after0, found0 := strings.Cut(rest, ".") + if !found0 || v0 == "" { + return "", "", "", "", false + } + rest = after0 + v2, after2, found2 := strings.Cut(rest, ".") + if !found2 || v2 == "" { + return "", "", "", "", false + } + rest = after2 + v4, after4, found4 := strings.Cut(rest, ".") + if !found4 || v4 == "" { + return "", "", "", "", false + } + rest = after4 + if rest == "" { + return "", "", "", "", false + } + v6 := rest + rest = "" + if rest != "" { + return "", "", "", "", false + } + return v0, v2, v4, v6, true +} + +// DecodeOwedByRepositoryNumberHeadActValue reads one value of the state owed under the form "...", refusing a field OwedByRepositoryNumberHeadActValue does not know and any text after the value. +func DecodeOwedByRepositoryNumberHeadActValue(raw []byte) (OwedByRepositoryNumberHeadActValue, error) { + var v OwedByRepositoryNumberHeadActValue + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.DisallowUnknownFields() + if err := dec.Decode(&v); err != nil { + return v, err + } + if dec.More() { + return v, errors.New("text after the value") + } + return v, nil +} + +// TurnsByRepositoryKey is the key of one value of the state turns under the form "". +func TurnsByRepositoryKey(repository string) string { return repository } + +// ParseTurnsByRepositoryKey reads the parts of a key of the form "", in the order of the form; the last result is false for a key of another form. +func ParseTurnsByRepositoryKey(key string) (string, bool) { + rest := key + if rest == "" { + return "", false + } + v0 := rest + rest = "" + if rest != "" { + return "", false + } + return v0, true +} + +// DecodeTurnsByRepositoryValue reads one value of the state turns under the form "", refusing a field TurnsByRepositoryValue does not know and any text after the value. +func DecodeTurnsByRepositoryValue(raw []byte) (TurnsByRepositoryValue, error) { + var v TurnsByRepositoryValue + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.DisallowUnknownFields() + if err := dec.Decode(&v); err != nil { + return v, err + } + if dec.More() { + return v, errors.New("text after the value") + } + return v, nil +} + +// DeliveriesInput is what deliveries takes. +type DeliveriesInput struct { + // "true": the final ones of the last thirty days too + All string `json:"all,omitempty"` + // a group's id (its branch name) + Group string `json:"group,omitempty"` + // owner/repository + Repository string `json:"repository,omitempty"` + // one state, e.g. delivering or held + State string `json:"state,omitempty"` +} + +// The values of DeliveriesInput's all. +const ( + DeliveriesInputAllTrue = "true" + DeliveriesInputAllFalse = "false" +) + +// DeliveriesAnswer is what deliveries answers. +type DeliveriesAnswer = map[string]any + +// TimesInput is what times takes. +type TimesInput struct { +} + +// TimesAnswer is what times answers. +type TimesAnswer = map[string]any + +// ShowInput is what show takes. +type ShowInput struct { + // a delivery's id, or a group's + ID string `json:"id"` +} + +// ShowAnswer is what show answers. +type ShowAnswer = map[string]any + +// GroupsInput is what groups takes. +type GroupsInput struct { + // "true": the groups that ended too + All string `json:"all,omitempty"` +} + +// The values of GroupsInput's all. +const ( + GroupsInputAllTrue = "true" + GroupsInputAllFalse = "false" +) + +// GroupsAnswer is what groups answers. +type GroupsAnswer = map[string]any + +// WhatIfInput is what what-if takes. +type WhatIfInput struct { +} + +// WhatIfAnswer is what what-if answers. +type WhatIfAnswer = map[string]any + +// ChecksInput is what checks takes. +type ChecksInput struct { + // a commit's sha, or the start of one, instead of a pull request + Commit string `json:"commit,omitempty"` + // a pull request's number: its head is read + Number string `json:"number,omitempty"` + // owner/repository + Repository string `json:"repository"` +} + +// ChecksAnswer is what checks answers. +type ChecksAnswer = map[string]any + +// TableInput is what table takes. +type TableInput struct { +} + +// TableAnswer is what table answers. +type TableAnswer = map[string]any + +// StalledInput is what stalled takes. +type StalledInput struct { +} + +// StalledAnswer is what stalled answers. +type StalledAnswer = map[string]any + +// RecheckInput is what recheck takes. +type RecheckInput struct { + // the delivery's id + ID string `json:"id"` + // why, kept with the transition + Why string `json:"why"` +} + +// RecheckAnswer is what recheck answers. +type RecheckAnswer = map[string]any + +// ReleaseInput is what release takes. +type ReleaseInput struct { +} + +// ReleaseAnswer is what release answers. +type ReleaseAnswer = map[string]any + +// StopInput is what stop takes. +type StopInput struct { +} + +// StopAnswer is what stop answers. +type StopAnswer = map[string]any + +// CloseInput is what close takes. +type CloseInput struct { + // the delivery's id + ID string `json:"id"` + // the condition it answers + Why string `json:"why"` +} + +// CloseAnswer is what close answers. +type CloseAnswer = map[string]any + +// RetireHistoryInput is what retire-history takes. +type RetireHistoryInput struct { +} + +// RetireHistoryAnswer is what retire-history answers. +type RetireHistoryAnswer = map[string]any + +// SettleInput is what settle takes. +type SettleInput struct { + // the held deliveries' ids (owner/repository@commit) + Ids []string `json:"ids"` + // why they may be settled, shown to the operator + Why string `json:"why"` +} + +// SettleAnswer is what settle answers. +type SettleAnswer = map[string]any + +// SourceByMainValue is one value of the state source under the key main. +type SourceByMainValue struct { + // the group that moved it + Group string `json:"group"` + // repository to the merged head, a full commit + Heads map[string]string `json:"heads"` + // the layout version + V int64 `json:"v,omitempty"` +} + +// MergesByGroupValuePullsItem is a type of the contract. +type MergesByGroupValuePullsItem struct { + Commit string `json:"commit,omitempty"` + Head string `json:"head"` + Number int64 `json:"number"` + Repository string `json:"repository"` + // merging, merged or refused + State string `json:"state"` + Words string `json:"words,omitempty"` +} + +// MergesByGroupValue is one value of the state merges under the key . +type MergesByGroupValue struct { + // @ to the approved head + Approved map[string]string `json:"approved,omitempty"` + // the ask key + Ask string `json:"ask"` + Group string `json:"group"` + Pulls []MergesByGroupValuePullsItem `json:"pulls"` + // the layout version + V int64 `json:"v,omitempty"` +} + +// AsksByMergeGroupHeadsValueHeadsItem is a type of the contract. +type AsksByMergeGroupHeadsValueHeadsItem struct { + // the full commit + Head string `json:"head"` + Number int64 `json:"number"` + // / + Repository string `json:"repository"` +} + +// AsksByMergeGroupHeadsValue is one value of the state asks under the key merge... +type AsksByMergeGroupHeadsValue struct { + // the operator-channel ask as raised + Ask any `json:"ask"` + // the number of times it was asked + Asking int64 `json:"asking,omitempty"` + Group string `json:"group"` + Heads []AsksByMergeGroupHeadsValueHeadsItem `json:"heads"` + // the layout version + V int64 `json:"v,omitempty"` +} + +// OwedByRepositoryNumberHeadActValue is one value of the state owed under the key .... +type OwedByRepositoryNumberHeadActValue struct { + // status=, view, note or comment. + Act string `json:"act"` + Context string `json:"context,omitempty"` + Ref string `json:"ref,omitempty"` + State string `json:"state,omitempty"` + Target string `json:"target,omitempty"` + Text string `json:"text,omitempty"` + // the layout version + V int64 `json:"v,omitempty"` +} + +// TurnsByRepositoryValueWaitingItem is a type of the contract. +type TurnsByRepositoryValueWaitingItem struct { + // when (RFC 3339 time) + Approved time.Time `json:"approved"` + Ask string `json:"ask,omitempty"` + Group string `json:"group"` + Heads string `json:"heads"` + // when (RFC 3339 time) + Since time.Time `json:"since"` + Step string `json:"step,omitempty"` + WaitsFor string `json:"waits-for,omitempty"` + // when (RFC 3339 time) + WaitsSince *time.Time `json:"waits-since,omitempty"` +} + +// TurnsByRepositoryValue is one value of the state turns under the key . +type TurnsByRepositoryValue struct { + Repository string `json:"repository"` + // the layout version + V int64 `json:"v,omitempty"` + Waiting []TurnsByRepositoryValueWaitingItem `json:"waiting"` +} diff --git a/contracts/go/seats/meshdelivery/contract_test.go b/contracts/go/seats/meshdelivery/contract_test.go new file mode 100644 index 00000000..f128120d --- /dev/null +++ b/contracts/go/seats/meshdelivery/contract_test.go @@ -0,0 +1,64 @@ +// Code generated by seatgen from the seat definitions; DO NOT EDIT. + +package meshdelivery + +import ( + "encoding/json" + "reflect" + "regexp" + "testing" +) + +// TestFixtures reads every fixture of seats/mesh-delivery/fixtures: each decodes into its type, a field the type does not +// know is dropped (ADR 0608 §6), and it reads back as its wire says; each subject named is the contract's. +func TestFixtures(t *testing.T) { + cases := []struct { + name, subject, given, wire string + decode func([]byte) (any, error) + subjectIs *regexp.Regexp + }{ + {name: "the source revision", subject: "", given: "{\"group\":\"feat/registry\",\"heads\":{\"novox/mesh-sdk\":\"cccccccccccccccccccccccccccccccccccccccc\"},\"v\":1}", wire: "{\"group\":\"feat/registry\",\"heads\":{\"novox/mesh-sdk\":\"cccccccccccccccccccccccccccccccccccccccc\"},\"v\":1}", + decode: func(b []byte) (any, error) { var v SourceByMainValue; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil}, + {name: "a merge", subject: "", given: "{\"ask\":\"merge.feat=registry.0123456789ab\",\"group\":\"feat/registry\",\"pulls\":[{\"head\":\"cccccccccccccccccccccccccccccccccccccccc\",\"number\":1,\"repository\":\"novox/mesh-sdk\",\"state\":\"merging\"}],\"v\":1}", wire: "{\"ask\":\"merge.feat=registry.0123456789ab\",\"group\":\"feat/registry\",\"pulls\":[{\"head\":\"cccccccccccccccccccccccccccccccccccccccc\",\"number\":1,\"repository\":\"novox/mesh-sdk\",\"state\":\"merging\"}],\"v\":1}", + decode: func(b []byte) (any, error) { var v MergesByGroupValue; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil}, + {name: "an ask", subject: "", given: "{\"ask\":{\"headline\":\"merge\"},\"group\":\"feat/registry\",\"heads\":[{\"head\":\"cccccccccccccccccccccccccccccccccccccccc\",\"number\":1,\"repository\":\"novox/mesh-sdk\"}],\"v\":1}", wire: "{\"ask\":{\"headline\":\"merge\"},\"group\":\"feat/registry\",\"heads\":[{\"head\":\"cccccccccccccccccccccccccccccccccccccccc\",\"number\":1,\"repository\":\"novox/mesh-sdk\"}],\"v\":1}", + decode: func(b []byte) (any, error) { + var v AsksByMergeGroupHeadsValue + err := json.Unmarshal(b, &v) + return v, err + }, subjectIs: nil}, + {name: "an act owed", subject: "", given: "{\"act\":\"view\",\"v\":1}", wire: "{\"act\":\"view\",\"v\":1}", + decode: func(b []byte) (any, error) { + var v OwedByRepositoryNumberHeadActValue + err := json.Unmarshal(b, &v) + return v, err + }, subjectIs: nil}, + {name: "a turn", subject: "", given: "{\"repository\":\"novox/mesh-sdk\",\"v\":1,\"waiting\":[{\"approved\":\"2026-11-05T10:00:00Z\",\"group\":\"feat/registry\",\"heads\":\"0123456789ab\",\"since\":\"2026-11-05T10:01:00Z\"}]}", wire: "{\"repository\":\"novox/mesh-sdk\",\"v\":1,\"waiting\":[{\"approved\":\"2026-11-05T10:00:00Z\",\"group\":\"feat/registry\",\"heads\":\"0123456789ab\",\"since\":\"2026-11-05T10:01:00Z\"}]}", + decode: func(b []byte) (any, error) { var v TurnsByRepositoryValue; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil}, + } + for _, c := range cases { + v, err := c.decode([]byte(c.given)) + if err != nil { + t.Errorf("%s: does not decode: %v", c.name, err) + continue + } + back, err := json.Marshal(v) + if err != nil { + t.Errorf("%s: does not encode: %v", c.name, err) + continue + } + var got, want any + if err := json.Unmarshal(back, &got); err != nil { + t.Fatal(err) + } + if err := json.Unmarshal([]byte(c.wire), &want); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(got, want) { + t.Errorf("%s: reads back as %s, want %s", c.name, back, c.wire) + } + if c.subjectIs != nil && !c.subjectIs.MatchString(c.subject) { + t.Errorf("%s: subject %q is not the contract's %s", c.name, c.subject, c.subjectIs) + } + } +} diff --git a/contracts/go/seats/meshdelivery/go.mod b/contracts/go/seats/meshdelivery/go.mod new file mode 100644 index 00000000..12356bcf --- /dev/null +++ b/contracts/go/seats/meshdelivery/go.mod @@ -0,0 +1,3 @@ +module git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdelivery + +go 1.26.0 diff --git a/contracts/go/seats/meshdeliverychecker/contract.go b/contracts/go/seats/meshdeliverychecker/contract.go new file mode 100644 index 00000000..00a8c8d8 --- /dev/null +++ b/contracts/go/seats/meshdeliverychecker/contract.go @@ -0,0 +1,490 @@ +// Code generated by seatgen from the seat definitions; DO NOT EDIT. + +// Package meshdeliverychecker is the contract of the seat "mesh-delivery-checker", defined whole (seats/mesh-delivery-checker/seat.json): The merge check's tries: start one, read one, end one. The checker's own events stay under its own subjects (ADR 0620 §2). +package meshdeliverychecker + +import ( + "bytes" + "encoding/json" + "errors" + "strings" +) + +// Seat is the seat's name. +const Seat = "mesh-delivery-checker" + +const ( + VerbTry = "try" + VerbTryRead = "try-read" + VerbTryEnd = "try-end" + VerbInput = "input" +) + +// Verbs is every verb the seat serves, in its definition's order: a new copy each call. +func Verbs() []string { + return []string{VerbTry, VerbTryRead, VerbTryEnd, VerbInput} +} + +// The holder's own events, listed beside the seat and published under the holder's subjects (ADR 0620 §2). +const ( + HolderEventVerdict = "verdict" + HolderEventTagWanted = "tag-wanted" + HolderEventPreviewWritten = "preview.written" +) + +// HolderSubjectVerdict is where the holder publishes verdict, kept in checker.asks. +func HolderSubjectVerdict(holder string) string { return "mesh.stream." + holder + ".event.verdict" } + +// HolderSubjectTagWanted is where the holder publishes tag-wanted, kept in checker.asks. +func HolderSubjectTagWanted(holder string) string { + return "mesh.stream." + holder + ".event.tag-wanted" +} + +// HolderSubjectPreviewWritten is where the holder publishes preview.written, kept in checks. +func HolderSubjectPreviewWritten(holder string) string { + return "mesh.stream." + holder + ".event.preview.written" +} + +const ( + StateVerdicts = "verdicts" + StateKept = "kept" + StateOwed = "owed" + StatePreviews = "previews" +) + +// VerdictsByGroupHeadsKey is the key of one value of the state verdicts under the form ".". +func VerdictsByGroupHeadsKey(group, heads string) string { return group + "." + heads } + +// ParseVerdictsByGroupHeadsKey reads the parts of a key of the form ".", in the order of the form; the last result is false for a key of another form. +func ParseVerdictsByGroupHeadsKey(key string) (string, string, bool) { + rest := key + v0, after0, found0 := strings.Cut(rest, ".") + if !found0 || v0 == "" { + return "", "", false + } + rest = after0 + if rest == "" { + return "", "", false + } + v2 := rest + rest = "" + if rest != "" { + return "", "", false + } + return v0, v2, true +} + +// DecodeVerdictsByGroupHeadsValue reads one value of the state verdicts under the form ".", refusing a field VerdictsByGroupHeadsValue does not know and any text after the value. +func DecodeVerdictsByGroupHeadsValue(raw []byte) (VerdictsByGroupHeadsValue, error) { + var v VerdictsByGroupHeadsValue + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.DisallowUnknownFields() + if err := dec.Decode(&v); err != nil { + return v, err + } + if dec.More() { + return v, errors.New("text after the value") + } + return v, nil +} + +// KeptByLevelDigestKey is the key of one value of the state kept under the form ".". +func KeptByLevelDigestKey(level, digest string) string { return level + "." + digest } + +// ParseKeptByLevelDigestKey reads the parts of a key of the form ".", in the order of the form; the last result is false for a key of another form. +func ParseKeptByLevelDigestKey(key string) (string, string, bool) { + rest := key + v0, after0, found0 := strings.Cut(rest, ".") + if !found0 || v0 == "" { + return "", "", false + } + rest = after0 + if rest == "" { + return "", "", false + } + v2 := rest + rest = "" + if rest != "" { + return "", "", false + } + return v0, v2, true +} + +// DecodeKeptByLevelDigestValue reads one value of the state kept under the form ".", refusing a field KeptByLevelDigestValue does not know and any text after the value. +func DecodeKeptByLevelDigestValue(raw []byte) (KeptByLevelDigestValue, error) { + var v KeptByLevelDigestValue + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.DisallowUnknownFields() + if err := dec.Decode(&v); err != nil { + return v, err + } + if dec.More() { + return v, errors.New("text after the value") + } + return v, nil +} + +// OwedByRepositoryNumberHeadActKey is the key of one value of the state owed under the form "...". +func OwedByRepositoryNumberHeadActKey(repository, number, head, act string) string { + return repository + "." + number + "." + head + "." + act +} + +// ParseOwedByRepositoryNumberHeadActKey reads the parts of a key of the form "...", in the order of the form; the last result is false for a key of another form. +func ParseOwedByRepositoryNumberHeadActKey(key string) (string, string, string, string, bool) { + rest := key + v0, after0, found0 := strings.Cut(rest, ".") + if !found0 || v0 == "" { + return "", "", "", "", false + } + rest = after0 + v2, after2, found2 := strings.Cut(rest, ".") + if !found2 || v2 == "" { + return "", "", "", "", false + } + rest = after2 + v4, after4, found4 := strings.Cut(rest, ".") + if !found4 || v4 == "" { + return "", "", "", "", false + } + rest = after4 + if rest == "" { + return "", "", "", "", false + } + v6 := rest + rest = "" + if rest != "" { + return "", "", "", "", false + } + return v0, v2, v4, v6, true +} + +// DecodeOwedByRepositoryNumberHeadActValue reads one value of the state owed under the form "...", refusing a field OwedByRepositoryNumberHeadActValue does not know and any text after the value. +func DecodeOwedByRepositoryNumberHeadActValue(raw []byte) (OwedByRepositoryNumberHeadActValue, error) { + var v OwedByRepositoryNumberHeadActValue + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.DisallowUnknownFields() + if err := dec.Decode(&v); err != nil { + return v, err + } + if dec.More() { + return v, errors.New("text after the value") + } + return v, nil +} + +// PreviewsByRepositoryHeadTrunkKey is the key of one value of the state previews under the form "..". +func PreviewsByRepositoryHeadTrunkKey(repository, head, trunk string) string { + return repository + "." + head + "." + trunk +} + +// ParsePreviewsByRepositoryHeadTrunkKey reads the parts of a key of the form "..", in the order of the form; the last result is false for a key of another form. +func ParsePreviewsByRepositoryHeadTrunkKey(key string) (string, string, string, bool) { + rest := key + v0, after0, found0 := strings.Cut(rest, ".") + if !found0 || v0 == "" { + return "", "", "", false + } + rest = after0 + v2, after2, found2 := strings.Cut(rest, ".") + if !found2 || v2 == "" { + return "", "", "", false + } + rest = after2 + if rest == "" { + return "", "", "", false + } + v4 := rest + rest = "" + if rest != "" { + return "", "", "", false + } + return v0, v2, v4, true +} + +// DecodePreviewsByRepositoryHeadTrunkValue reads one value of the state previews under the form "..", refusing a field PreviewsByRepositoryHeadTrunkValue does not know and any text after the value. +func DecodePreviewsByRepositoryHeadTrunkValue(raw []byte) (PreviewsByRepositoryHeadTrunkValue, error) { + var v PreviewsByRepositoryHeadTrunkValue + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.DisallowUnknownFields() + if err := dec.Decode(&v); err != nil { + return v, err + } + if dec.More() { + return v, errors.New("text after the value") + } + return v, nil +} + +// TryInputMembersItem is a type of the contract. +type TryInputMembersItem struct { + // the full commit to try, 40 or 64 hex digits + Head string `json:"head"` + // / + Repository string `json:"repository"` + // the trunk commit to merge it onto, or the trunk's branch for its head now; the trunk's head when absent + Trunk string `json:"trunk,omitempty"` +} + +// TryInput is what try takes. +type TryInput struct { + // the full commit to try, 40 or 64 hex digits, for a try of one member (instead of members) + Head string `json:"head,omitempty"` + // unit or integration: only this level; both when absent + Level string `json:"level,omitempty"` + // the group: one repository at most once, each at the exact commit asked and the trunk commit it is merged onto; instead of repository and head + Members []TryInputMembersItem `json:"members,omitempty"` + // /, for a try of one member (instead of members) + Repository string `json:"repository,omitempty"` + // a go test -run pattern: only those tests run, and nothing is kept + Tests string `json:"tests,omitempty"` + // the trunk commit to merge it onto, or the trunk's branch for its head now; the trunk's head when absent + Trunk string `json:"trunk,omitempty"` +} + +// TryAnswer is what try answers. +type TryAnswer struct { + // the try's id + ID string `json:"id"` + // its state + State string `json:"state,omitempty"` +} + +// TryReadInput is what try-read takes. +type TryReadInput struct { + // the try's id, try- + ID string `json:"id"` +} + +// TryReadAnswer is what try-read answers. +type TryReadAnswer = map[string]any + +// TryEndInput is what try-end takes. +type TryEndInput struct { + // the try's id, try- + ID string `json:"id"` + // why it is ended, in words + Why string `json:"why"` +} + +// TryEndAnswer is what try-end answers. +type TryEndAnswer struct { + // it is ended + Ended bool `json:"ended"` + // the try + ID string `json:"id"` +} + +// InputInput is what input takes. +type InputInput struct { + // the input's sha256, 64 lowercase hex digits, as the run ask names it + Digest string `json:"digest"` +} + +// InputAnswer is what input answers. +type InputAnswer struct { + // the digest asked + Digest string `json:"digest"` + // the input's exact bytes, base64, so no encoding on the way changes a byte the digest covers + Input string `json:"input"` +} + +// VerdictHolderEventSeriesItem is a type of the contract. +type VerdictHolderEventSeriesItem struct { + // its artifact, or the package's ecosystem name as the module's packages name it + Artifact string `json:"artifact"` + // the series on trunk + From string `json:"from"` + // the module whose artifact's series changes + Module string `json:"module"` + // the series the checked head gives it + To string `json:"to"` +} + +// VerdictHolderEvent is the body of the holder's event verdict. +type VerdictHolderEvent struct { + // the commit checked + Head string `json:"head"` + // the generated files out of step with their seat definitions, each path (design 26) + OutOfStep []string `json:"out-of-step,omitempty"` + // the pull request's number, for a pull request's check + Pull int64 `json:"pull,omitempty"` + // owner/repo + Repository string `json:"repository"` + // each change of an artifact's series the checked head makes against trunk's manifests, once each (design 32); absent when it changes none + Series []VerdictHolderEventSeriesItem `json:"series,omitempty"` + // pass or fail + Verdict string `json:"verdict"` +} + +// TagWantedHolderEvent is the body of the holder's event tag-wanted. +type TagWantedHolderEvent struct { + // the pull request's branch: its tag is removed when the branch ends + Branch string `json:"branch"` + // its head + Head string `json:"head"` + // the group member + Module string `json:"module"` + // the package it declares + Package string `json:"package"` + // the pull request + Pull int64 `json:"pull,omitempty"` + // owner/repo + Repository string `json:"repository"` + // the tag wanted + Tag string `json:"tag,omitempty"` +} + +// VerdictsByGroupHeadsValueComposition is a type of the contract. +type VerdictsByGroupHeadsValueComposition struct { + Fpset string `json:"fpset,omitempty"` + Machines []string `json:"machines,omitempty"` + Sdk string `json:"sdk,omitempty"` + // not-run, pass or fail + State string `json:"state"` + Words string `json:"words,omitempty"` +} + +// VerdictsByGroupHeadsValueFailed is a type of the contract. +type VerdictsByGroupHeadsValueFailed struct { + Machine string `json:"machine,omitempty"` + Test string `json:"test,omitempty"` + Words string `json:"words,omitempty"` +} + +// VerdictsByGroupHeadsValueFailuresItem is a type of the contract. +type VerdictsByGroupHeadsValueFailuresItem struct { + Machine string `json:"machine,omitempty"` + Test string `json:"test,omitempty"` + Words string `json:"words,omitempty"` +} + +// VerdictsByGroupHeadsValueJudgedByHeadsItem is a type of the contract. +type VerdictsByGroupHeadsValueJudgedByHeadsItem struct { + Commit string `json:"commit"` + Repository string `json:"repository"` +} + +// VerdictsByGroupHeadsValueJudgedByProgram is a type of the contract. +type VerdictsByGroupHeadsValueJudgedByProgram struct { + Commit string `json:"commit"` + Dir string `json:"dir"` + Package string `json:"package"` + Repository string `json:"repository"` +} + +// VerdictsByGroupHeadsValueJudgedBy is the checking program that judged the check: where it was built, the key its build is kept under, whether this check's run built it, and the heads it read (ADR 0644, ADR 0656). Absent in a value written before it. +type VerdictsByGroupHeadsValueJudgedBy struct { + Built bool `json:"built,omitempty"` + Heads []VerdictsByGroupHeadsValueJudgedByHeadsItem `json:"heads"` + // program- and 16 hex digits + Key string `json:"key"` + Program VerdictsByGroupHeadsValueJudgedByProgram `json:"program"` +} + +// VerdictsByGroupHeadsValueLevelsValue is a type of the contract. +type VerdictsByGroupHeadsValueLevelsValue struct { + Kept int64 `json:"kept"` + Run int64 `json:"run"` + Skipped int64 `json:"skipped,omitempty"` +} + +// VerdictsByGroupHeadsValueSeriesItem is a type of the contract. +type VerdictsByGroupHeadsValueSeriesItem struct { + Artifact string `json:"artifact"` + From string `json:"from"` + Module string `json:"module"` + To string `json:"to"` +} + +// VerdictsByGroupHeadsValueTook is milliseconds. +type VerdictsByGroupHeadsValueTook struct { + Clone int64 `json:"clone,omitempty"` + Composition int64 `json:"composition,omitempty"` + IntegrationRun int64 `json:"integration-run,omitempty"` + IntegrationSetup int64 `json:"integration-setup,omitempty"` + Lint int64 `json:"lint,omitempty"` + Queue int64 `json:"queue,omitempty"` + Unit int64 `json:"unit,omitempty"` +} + +// VerdictsByGroupHeadsValue is one value of the state verdicts under the key .. +type VerdictsByGroupHeadsValue struct { + Beside map[string]string `json:"beside,omitempty"` + CarriedFrom map[string]string `json:"carried-from,omitempty"` + Composition *VerdictsByGroupHeadsValueComposition `json:"composition,omitempty"` + Failed *VerdictsByGroupHeadsValueFailed `json:"failed,omitempty"` + Failures []VerdictsByGroupHeadsValueFailuresItem `json:"failures,omitempty"` + Group string `json:"group"` + // 12 hex digits + Heads string `json:"heads"` + // the checking program that judged the check: where it was built, the key its build is kept under, whether this check's run built it, and the heads it read (ADR 0644, ADR 0656). Absent in a value written before it + JudgedBy *VerdictsByGroupHeadsValueJudgedBy `json:"judged-by,omitempty"` + // by level, unit or integration + Levels map[string]VerdictsByGroupHeadsValueLevelsValue `json:"levels,omitempty"` + Machines []string `json:"machines,omitempty"` + OverBound []string `json:"over-bound,omitempty"` + Sdk string `json:"sdk,omitempty"` + Series []VerdictsByGroupHeadsValueSeriesItem `json:"series,omitempty"` + // running, pass or fail + State string `json:"state"` + // milliseconds + Took *VerdictsByGroupHeadsValueTook `json:"took,omitempty"` + // repository to the trunk head merged onto + Trunk map[string]string `json:"trunk,omitempty"` + // the layout version + V int64 `json:"v,omitempty"` +} + +// KeptByLevelDigestValue is one value of the state kept under the key .. +type KeptByLevelDigestValue struct { + // when (RFC 3339 time) + At string `json:"at,omitempty"` + Level string `json:"level"` + Package string `json:"package"` + Repository string `json:"repository"` + // pass: a failure is never kept + State string `json:"state"` + Tests int64 `json:"tests,omitempty"` + Took int64 `json:"took,omitempty"` + Toolchain string `json:"toolchain,omitempty"` + // the layout version + V int64 `json:"v,omitempty"` +} + +// OwedByRepositoryNumberHeadActValue is one value of the state owed under the key .... +type OwedByRepositoryNumberHeadActValue struct { + // status=, view, note or comment. + Act string `json:"act"` + Context string `json:"context,omitempty"` + Ref string `json:"ref,omitempty"` + State string `json:"state,omitempty"` + Target string `json:"target,omitempty"` + Text string `json:"text,omitempty"` + // the layout version + V int64 `json:"v,omitempty"` +} + +// PreviewsByRepositoryHeadTrunkValueModulesValue is a type of the contract. +type PreviewsByRepositoryHeadTrunkValueModulesValue struct { + Locks map[string]string `json:"locks,omitempty"` + // manifest digest + Manifest string `json:"manifest"` + Packages []string `json:"packages,omitempty"` + Paths map[string]string `json:"paths,omitempty"` + Tree string `json:"tree"` +} + +// PreviewsByRepositoryHeadTrunkValue is one value of the state previews under the key ... +type PreviewsByRepositoryHeadTrunkValue struct { + // files in conflict + Conflicts []string `json:"conflicts,omitempty"` + Head string `json:"head"` + // the merge commit + Merge string `json:"merge,omitempty"` + // by directory + Modules map[string]PreviewsByRepositoryHeadTrunkValueModulesValue `json:"modules,omitempty"` + Repository string `json:"repository"` + Trunk string `json:"trunk"` + // the layout version + V int64 `json:"v,omitempty"` +} diff --git a/contracts/go/seats/meshdeliverychecker/contract_test.go b/contracts/go/seats/meshdeliverychecker/contract_test.go new file mode 100644 index 00000000..65d9e0c4 --- /dev/null +++ b/contracts/go/seats/meshdeliverychecker/contract_test.go @@ -0,0 +1,82 @@ +// Code generated by seatgen from the seat definitions; DO NOT EDIT. + +package meshdeliverychecker + +import ( + "encoding/json" + "reflect" + "regexp" + "testing" +) + +// TestFixtures reads every fixture of seats/mesh-delivery-checker/fixtures: each decodes into its type, a field the type does not +// know is dropped (ADR 0608 §6), and it reads back as its wire says; each subject named is the contract's. +func TestFixtures(t *testing.T) { + cases := []struct { + name, subject, given, wire string + decode func([]byte) (any, error) + subjectIs *regexp.Regexp + }{ + {name: "verdict on the checker's own family", subject: "mesh.stream.mesh-delivery-checker.event.verdict", given: "{\"head\":\"cccccccccccccccccccccccccccccccccccccccc\",\"out-of-step\":[\"go/seats/meshbroker/contract.go\"],\"pull\":3,\"repository\":\"novox/mesh-contracts\",\"series\":[{\"artifact\":\"go\",\"from\":\"0.1\",\"module\":\"mesh-contracts\",\"to\":\"0.2\"}],\"verdict\":\"fail\"}", wire: "{\"head\":\"cccccccccccccccccccccccccccccccccccccccc\",\"out-of-step\":[\"go/seats/meshbroker/contract.go\"],\"pull\":3,\"repository\":\"novox/mesh-contracts\",\"series\":[{\"artifact\":\"go\",\"from\":\"0.1\",\"module\":\"mesh-contracts\",\"to\":\"0.2\"}],\"verdict\":\"fail\"}", + decode: func(b []byte) (any, error) { var v VerdictHolderEvent; err := json.Unmarshal(b, &v); return v, err }, subjectIs: regexp.MustCompile("^mesh\\.stream\\.[a-z0-9-]+\\.event\\.verdict$")}, + {name: "tag-wanted on the checker's own family", subject: "mesh.stream.mesh-delivery-checker.event.tag-wanted", given: "{\"branch\":\"feat/registry\",\"head\":\"cccccccccccccccccccccccccccccccccccccccc\",\"module\":\"mesh-sdk\",\"package\":\"go\",\"pull\":170,\"repository\":\"novox/mesh-sdk\",\"tag\":\"registry-1966\"}", wire: "{\"branch\":\"feat/registry\",\"head\":\"cccccccccccccccccccccccccccccccccccccccc\",\"module\":\"mesh-sdk\",\"package\":\"go\",\"pull\":170,\"repository\":\"novox/mesh-sdk\",\"tag\":\"registry-1966\"}", + decode: func(b []byte) (any, error) { var v TagWantedHolderEvent; err := json.Unmarshal(b, &v); return v, err }, subjectIs: regexp.MustCompile("^mesh\\.stream\\.[a-z0-9-]+\\.event\\.tag-wanted$")}, + {name: "preview.written on the checker's stream", subject: "mesh.stream.mesh-delivery-checker.event.preview.written", given: "{}", wire: "{}", + decode: func(b []byte) (any, error) { var v map[string]any; err := json.Unmarshal(b, &v); return v, err }, subjectIs: regexp.MustCompile("^mesh\\.stream\\.[a-z0-9-]+\\.event\\.preview\\.written$")}, + {name: "a running verdict", subject: "", given: "{\"group\":\"feat/registry\",\"heads\":\"0123456789ab\",\"state\":\"running\",\"v\":1}", wire: "{\"group\":\"feat/registry\",\"heads\":\"0123456789ab\",\"state\":\"running\",\"v\":1}", + decode: func(b []byte) (any, error) { + var v VerdictsByGroupHeadsValue + err := json.Unmarshal(b, &v) + return v, err + }, subjectIs: nil}, + {name: "a pass judged by the checking program built at a mesh-delivery head", subject: "", given: "{\"group\":\"feat/registry\",\"heads\":\"0123456789ab\",\"judged-by\":{\"built\":true,\"heads\":[{\"commit\":\"0123456789abcdef0123456789abcdef01234567\",\"repository\":\"novox/mesh-delivery\"}],\"key\":\"program-0123456789abcdef\",\"program\":{\"commit\":\"0123456789abcdef0123456789abcdef01234567\",\"dir\":\"modules/mesh-delivery-checker\",\"package\":\"./cmd/mesh-checking-program\",\"repository\":\"novox/mesh-delivery\"}},\"levels\":{\"integration\":{\"kept\":0,\"run\":3},\"unit\":{\"kept\":0,\"run\":12}},\"state\":\"pass\",\"v\":1}", wire: "{\"group\":\"feat/registry\",\"heads\":\"0123456789ab\",\"judged-by\":{\"built\":true,\"heads\":[{\"commit\":\"0123456789abcdef0123456789abcdef01234567\",\"repository\":\"novox/mesh-delivery\"}],\"key\":\"program-0123456789abcdef\",\"program\":{\"commit\":\"0123456789abcdef0123456789abcdef01234567\",\"dir\":\"modules/mesh-delivery-checker\",\"package\":\"./cmd/mesh-checking-program\",\"repository\":\"novox/mesh-delivery\"}},\"levels\":{\"integration\":{\"kept\":0,\"run\":3},\"unit\":{\"kept\":0,\"run\":12}},\"state\":\"pass\",\"v\":1}", + decode: func(b []byte) (any, error) { + var v VerdictsByGroupHeadsValue + err := json.Unmarshal(b, &v) + return v, err + }, subjectIs: nil}, + {name: "a kept pass", subject: "", given: "{\"at\":\"2026-11-05T10:00:00Z\",\"level\":\"unit\",\"package\":\"go/keys\",\"repository\":\"novox/mesh-sdk\",\"state\":\"pass\",\"tests\":4,\"took\":120,\"toolchain\":\"go1.26\",\"v\":1}", wire: "{\"at\":\"2026-11-05T10:00:00Z\",\"level\":\"unit\",\"package\":\"go/keys\",\"repository\":\"novox/mesh-sdk\",\"state\":\"pass\",\"tests\":4,\"took\":120,\"toolchain\":\"go1.26\",\"v\":1}", + decode: func(b []byte) (any, error) { var v KeptByLevelDigestValue; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil}, + {name: "the status owed for a head", subject: "", given: "{\"act\":\"status=mesh/check\",\"context\":\"mesh/check\",\"state\":\"success\",\"v\":1}", wire: "{\"act\":\"status=mesh/check\",\"context\":\"mesh/check\",\"state\":\"success\",\"v\":1}", + decode: func(b []byte) (any, error) { + var v OwedByRepositoryNumberHeadActValue + err := json.Unmarshal(b, &v) + return v, err + }, subjectIs: nil}, + {name: "a preview", subject: "", given: "{\"head\":\"cccccccccccccccccccccccccccccccccccccccc\",\"merge\":\"eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee\",\"repository\":\"novox/mesh-sdk\",\"trunk\":\"dddddddddddddddddddddddddddddddddddddddd\",\"v\":1}", wire: "{\"head\":\"cccccccccccccccccccccccccccccccccccccccc\",\"merge\":\"eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee\",\"repository\":\"novox/mesh-sdk\",\"trunk\":\"dddddddddddddddddddddddddddddddddddddddd\",\"v\":1}", + decode: func(b []byte) (any, error) { + var v PreviewsByRepositoryHeadTrunkValue + err := json.Unmarshal(b, &v) + return v, err + }, subjectIs: nil}, + {name: "a checking program asks its input by its digest", subject: "", given: "{\"digest\":\"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\"}", wire: "{\"digest\":\"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\"}", + decode: func(b []byte) (any, error) { var v InputInput; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil}, + {name: "the input answered as its exact bytes, base64", subject: "", given: "{\"digest\":\"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\",\"input\":\"eyJ2IjoxfQ==\"}", wire: "{\"digest\":\"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\",\"input\":\"eyJ2IjoxfQ==\"}", + decode: func(b []byte) (any, error) { var v InputAnswer; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil}, + } + for _, c := range cases { + v, err := c.decode([]byte(c.given)) + if err != nil { + t.Errorf("%s: does not decode: %v", c.name, err) + continue + } + back, err := json.Marshal(v) + if err != nil { + t.Errorf("%s: does not encode: %v", c.name, err) + continue + } + var got, want any + if err := json.Unmarshal(back, &got); err != nil { + t.Fatal(err) + } + if err := json.Unmarshal([]byte(c.wire), &want); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(got, want) { + t.Errorf("%s: reads back as %s, want %s", c.name, back, c.wire) + } + if c.subjectIs != nil && !c.subjectIs.MatchString(c.subject) { + t.Errorf("%s: subject %q is not the contract's %s", c.name, c.subject, c.subjectIs) + } + } +} diff --git a/contracts/go/seats/meshdeliverychecker/go.mod b/contracts/go/seats/meshdeliverychecker/go.mod new file mode 100644 index 00000000..d50dcda1 --- /dev/null +++ b/contracts/go/seats/meshdeliverychecker/go.mod @@ -0,0 +1,3 @@ +module git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdeliverychecker + +go 1.26.0 diff --git a/contracts/go/seats/meshwants/contract.go b/contracts/go/seats/meshwants/contract.go new file mode 100644 index 00000000..74860fa6 --- /dev/null +++ b/contracts/go/seats/meshwants/contract.go @@ -0,0 +1,337 @@ +// Code generated by seatgen from the seat definitions; DO NOT EDIT. + +// Package meshwants is the contract of the seat "mesh-wants", defined whole (seats/mesh-wants/seat.json): The seat that says what each node should run and whether each module's new builds wait for a person (ADR 0565, ADR 0539). +package meshwants + +import ( + "bytes" + "encoding/json" + "errors" + "strings" +) + +// Seat is the seat's name. +const Seat = "mesh-wants" + +const ( + VerbUpgrade = "upgrade" + VerbBus = "bus" +) + +const ( + EventPolicyChanged = "policy-changed" +) + +// Verbs is every verb the seat serves, in its definition's order: a new copy each call. +func Verbs() []string { + return []string{VerbUpgrade, VerbBus} +} + +// Events is every event published on the seat's subject: a new copy each call. +func Events() []string { + return []string{EventPolicyChanged} +} + +// Family is where the seat's events are kept: the shared events stream, filtered to the seat's subjects. +const ( + Family = "mesh-wants.events" + FamilyMaxAgeDays = 7 +) + +// SubjectPolicyChanged is where policy-changed is published. +const SubjectPolicyChanged = "mesh.seat.mesh-wants.event.policy-changed" + +const ( + StateWants = "wants" + StateDifferences = "differences" + StateCompositions = "compositions" +) + +// WantsByWantNodeModuleKey is the key of one value of the state wants under the form "want..". +func WantsByWantNodeModuleKey(node, module string) string { return "want." + node + "." + module } + +// ParseWantsByWantNodeModuleKey reads the parts of a key of the form "want..", in the order of the form; the last result is false for a key of another form. +func ParseWantsByWantNodeModuleKey(key string) (string, string, bool) { + rest := key + if !strings.HasPrefix(rest, "want.") { + return "", "", false + } + rest = rest[5:] + v1, after1, found1 := strings.Cut(rest, ".") + if !found1 || v1 == "" { + return "", "", false + } + rest = after1 + if rest == "" { + return "", "", false + } + v3 := rest + rest = "" + if rest != "" { + return "", "", false + } + return v1, v3, true +} + +// DecodeWantsByWantNodeModuleValue reads one value of the state wants under the form "want..", refusing a field WantsByWantNodeModuleValue does not know and any text after the value. +func DecodeWantsByWantNodeModuleValue(raw []byte) (WantsByWantNodeModuleValue, error) { + var v WantsByWantNodeModuleValue + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.DisallowUnknownFields() + if err := dec.Decode(&v); err != nil { + return v, err + } + if dec.More() { + return v, errors.New("text after the value") + } + return v, nil +} + +// WantsByWantNodeNodeKey is the key of one value of the state wants under the form "want..node". +func WantsByWantNodeNodeKey(node string) string { return "want." + node + ".node" } + +// ParseWantsByWantNodeNodeKey reads the parts of a key of the form "want..node", in the order of the form; the last result is false for a key of another form. +func ParseWantsByWantNodeNodeKey(key string) (string, bool) { + rest := key + if !strings.HasPrefix(rest, "want.") { + return "", false + } + rest = rest[5:] + v1, after1, found1 := strings.Cut(rest, ".node") + if !found1 || v1 == "" { + return "", false + } + rest = after1 + if rest != "" { + return "", false + } + return v1, true +} + +// DecodeWantsByWantNodeNodeValue reads one value of the state wants under the form "want..node", refusing a field WantsByWantNodeNodeValue does not know and any text after the value. +func DecodeWantsByWantNodeNodeValue(raw []byte) (WantsByWantNodeNodeValue, error) { + var v WantsByWantNodeNodeValue + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.DisallowUnknownFields() + if err := dec.Decode(&v); err != nil { + return v, err + } + if dec.More() { + return v, errors.New("text after the value") + } + return v, nil +} + +// WantsByHeldModuleFingerprintKey is the key of one value of the state wants under the form "held..". +func WantsByHeldModuleFingerprintKey(module, fingerprint string) string { + return "held." + module + "." + fingerprint +} + +// ParseWantsByHeldModuleFingerprintKey reads the parts of a key of the form "held..", in the order of the form; the last result is false for a key of another form. +func ParseWantsByHeldModuleFingerprintKey(key string) (string, string, bool) { + rest := key + if !strings.HasPrefix(rest, "held.") { + return "", "", false + } + rest = rest[5:] + v1, after1, found1 := strings.Cut(rest, ".") + if !found1 || v1 == "" { + return "", "", false + } + rest = after1 + if rest == "" { + return "", "", false + } + v3 := rest + rest = "" + if rest != "" { + return "", "", false + } + return v1, v3, true +} + +// DecodeWantsByHeldModuleFingerprintValue reads one value of the state wants under the form "held..", refusing a field WantsByHeldModuleFingerprintValue does not know and any text after the value. +func DecodeWantsByHeldModuleFingerprintValue(raw []byte) (WantsByHeldModuleFingerprintValue, error) { + var v WantsByHeldModuleFingerprintValue + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.DisallowUnknownFields() + if err := dec.Decode(&v); err != nil { + return v, err + } + if dec.More() { + return v, errors.New("text after the value") + } + return v, nil +} + +// DifferencesByNodeModuleKey is the key of one value of the state differences under the form ".". +func DifferencesByNodeModuleKey(node, module string) string { return node + "." + module } + +// ParseDifferencesByNodeModuleKey reads the parts of a key of the form ".", in the order of the form; the last result is false for a key of another form. +func ParseDifferencesByNodeModuleKey(key string) (string, string, bool) { + rest := key + v0, after0, found0 := strings.Cut(rest, ".") + if !found0 || v0 == "" { + return "", "", false + } + rest = after0 + if rest == "" { + return "", "", false + } + v2 := rest + rest = "" + if rest != "" { + return "", "", false + } + return v0, v2, true +} + +// DecodeDifferencesByNodeModuleValue reads one value of the state differences under the form ".", refusing a field DifferencesByNodeModuleValue does not know and any text after the value. +func DecodeDifferencesByNodeModuleValue(raw []byte) (DifferencesByNodeModuleValue, error) { + var v DifferencesByNodeModuleValue + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.DisallowUnknownFields() + if err := dec.Decode(&v); err != nil { + return v, err + } + if dec.More() { + return v, errors.New("text after the value") + } + return v, nil +} + +// CompositionsByGroupHeadsKey is the key of one value of the state compositions under the form ".". +func CompositionsByGroupHeadsKey(group, heads string) string { return group + "." + heads } + +// ParseCompositionsByGroupHeadsKey reads the parts of a key of the form ".", in the order of the form; the last result is false for a key of another form. +func ParseCompositionsByGroupHeadsKey(key string) (string, string, bool) { + rest := key + v0, after0, found0 := strings.Cut(rest, ".") + if !found0 || v0 == "" { + return "", "", false + } + rest = after0 + if rest == "" { + return "", "", false + } + v2 := rest + rest = "" + if rest != "" { + return "", "", false + } + return v0, v2, true +} + +// DecodeCompositionsByGroupHeadsValue reads one value of the state compositions under the form ".", refusing a field CompositionsByGroupHeadsValue does not know and any text after the value. +func DecodeCompositionsByGroupHeadsValue(raw []byte) (CompositionsByGroupHeadsValue, error) { + var v CompositionsByGroupHeadsValue + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.DisallowUnknownFields() + if err := dec.Decode(&v); err != nil { + return v, err + } + if dec.More() { + return v, errors.New("text after the value") + } + return v, nil +} + +// UpgradeInput is what upgrade takes. +type UpgradeInput struct { + // "true": each node's own composition, by its digest, and the assigner's findings + Compare string `json:"compare,omitempty"` + // one module + Module string `json:"module,omitempty"` + // with module: roll, together, record, or default to take a person's choice back + Policy string `json:"policy,omitempty"` + // with policy: why, required and kept + Why string `json:"why,omitempty"` +} + +// The values of UpgradeInput's compare. +const ( + UpgradeInputCompareTrue = "true" + UpgradeInputCompareFalse = "false" +) + +// UpgradeAnswer is what upgrade answers. +type UpgradeAnswer = map[string]any + +// BusInput is what bus takes. +type BusInput struct { +} + +// BusAnswer is what bus answers. +type BusAnswer = map[string]any + +// PolicyChangedEvent is the body of the event policy-changed. +type PolicyChangedEvent = map[string]any + +// WantsByWantNodeModuleValue is one value of the state wants under the key want... +type WantsByWantNodeModuleValue struct { + // again or skip + Backup string `json:"backup,omitempty"` + Composed string `json:"composed,omitempty"` + Fp string `json:"fp,omitempty"` + Hold bool `json:"hold,omitempty"` + Newest string `json:"newest,omitempty"` + Put bool `json:"put,omitempty"` + Release string `json:"release,omitempty"` + Removed bool `json:"removed,omitempty"` + // the rule that holds the node back, 1 to 10 + Rule int64 `json:"rule,omitempty"` + Self bool `json:"self,omitempty"` + V int64 `json:"v,omitempty"` + Words string `json:"words,omitempty"` + Yes string `json:"yes,omitempty"` +} + +// The values of WantsByWantNodeModuleValue's backup. +const ( + WantsByWantNodeModuleValueBackupAgain = "again" + WantsByWantNodeModuleValueBackupSkip = "skip" +) + +// WantsByWantNodeNodeValue is one value of the state wants under the key want..node. +type WantsByWantNodeNodeValue struct { + Composed string `json:"composed"` + Self bool `json:"self,omitempty"` + V int64 `json:"v,omitempty"` + Words string `json:"words,omitempty"` +} + +// WantsByHeldModuleFingerprintValue is one value of the state wants under the key held... +type WantsByHeldModuleFingerprintValue struct { + Node string `json:"node"` + V int64 `json:"v,omitempty"` + Words string `json:"words,omitempty"` +} + +// DifferencesByNodeModuleValue is one value of the state differences under the key .. +type DifferencesByNodeModuleValue struct { + Rule int64 `json:"rule,omitempty"` + Sent string `json:"sent,omitempty"` + SentBuild string `json:"sent-build,omitempty"` + Unexplained bool `json:"unexplained,omitempty"` + V int64 `json:"v,omitempty"` + Want string `json:"want,omitempty"` + WantRemoved bool `json:"want-removed,omitempty"` + Words string `json:"words,omitempty"` +} + +// CompositionsByGroupHeadsValue is one value of the state compositions under the key .. +type CompositionsByGroupHeadsValue struct { + Group string `json:"group"` + // 12 lowercase hex digits + Heads string `json:"heads"` + Machine string `json:"machine,omitempty"` + Machines []string `json:"machines,omitempty"` + Sdk string `json:"sdk,omitempty"` + // pass, fail or waits + State string `json:"state"` + // milliseconds + Took int64 `json:"took,omitempty"` + V int64 `json:"v,omitempty"` + Warnings []string `json:"warnings,omitempty"` + Words string `json:"words,omitempty"` +} diff --git a/contracts/go/seats/meshwants/contract_test.go b/contracts/go/seats/meshwants/contract_test.go new file mode 100644 index 00000000..1355a9b4 --- /dev/null +++ b/contracts/go/seats/meshwants/contract_test.go @@ -0,0 +1,76 @@ +// Code generated by seatgen from the seat definitions; DO NOT EDIT. + +package meshwants + +import ( + "encoding/json" + "reflect" + "regexp" + "testing" +) + +// TestFixtures reads every fixture of seats/mesh-wants/fixtures: each decodes into its type, a field the type does not +// know is dropped (ADR 0608 §6), and it reads back as its wire says; each subject named is the contract's. +func TestFixtures(t *testing.T) { + cases := []struct { + name, subject, given, wire string + decode func([]byte) (any, error) + subjectIs *regexp.Regexp + }{ + {name: "a want", subject: "", given: "{\"fp\":\"ab12\",\"rule\":3,\"v\":1}", wire: "{\"fp\":\"ab12\",\"rule\":3,\"v\":1}", + decode: func(b []byte) (any, error) { + var v WantsByWantNodeModuleValue + err := json.Unmarshal(b, &v) + return v, err + }, subjectIs: nil}, + {name: "a node's own want", subject: "", given: "{\"composed\":\"sha256:aa\",\"self\":true,\"v\":1}", wire: "{\"composed\":\"sha256:aa\",\"self\":true,\"v\":1}", + decode: func(b []byte) (any, error) { + var v WantsByWantNodeNodeValue + err := json.Unmarshal(b, &v) + return v, err + }, subjectIs: nil}, + {name: "a held fingerprint", subject: "", given: "{\"node\":\"ace\",\"v\":1}", wire: "{\"node\":\"ace\",\"v\":1}", + decode: func(b []byte) (any, error) { + var v WantsByHeldModuleFingerprintValue + err := json.Unmarshal(b, &v) + return v, err + }, subjectIs: nil}, + {name: "a difference", subject: "", given: "{\"unexplained\":true,\"v\":1,\"want\":\"ab12\"}", wire: "{\"unexplained\":true,\"v\":1,\"want\":\"ab12\"}", + decode: func(b []byte) (any, error) { + var v DifferencesByNodeModuleValue + err := json.Unmarshal(b, &v) + return v, err + }, subjectIs: nil}, + {name: "a composition", subject: "", given: "{\"group\":\"g1\",\"heads\":\"0123456789ab\",\"state\":\"pass\",\"v\":1}", wire: "{\"group\":\"g1\",\"heads\":\"0123456789ab\",\"state\":\"pass\",\"v\":1}", + decode: func(b []byte) (any, error) { + var v CompositionsByGroupHeadsValue + err := json.Unmarshal(b, &v) + return v, err + }, subjectIs: nil}, + } + for _, c := range cases { + v, err := c.decode([]byte(c.given)) + if err != nil { + t.Errorf("%s: does not decode: %v", c.name, err) + continue + } + back, err := json.Marshal(v) + if err != nil { + t.Errorf("%s: does not encode: %v", c.name, err) + continue + } + var got, want any + if err := json.Unmarshal(back, &got); err != nil { + t.Fatal(err) + } + if err := json.Unmarshal([]byte(c.wire), &want); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(got, want) { + t.Errorf("%s: reads back as %s, want %s", c.name, back, c.wire) + } + if c.subjectIs != nil && !c.subjectIs.MatchString(c.subject) { + t.Errorf("%s: subject %q is not the contract's %s", c.name, c.subject, c.subjectIs) + } + } +} diff --git a/contracts/go/seats/meshwants/go.mod b/contracts/go/seats/meshwants/go.mod new file mode 100644 index 00000000..edcaa257 --- /dev/null +++ b/contracts/go/seats/meshwants/go.mod @@ -0,0 +1,3 @@ +module git.novox.be/novox/mesh-delivery/contracts/go/seats/meshwants + +go 1.26.0 diff --git a/contracts/go/seats/nodebuildgo/contract.go b/contracts/go/seats/nodebuildgo/contract.go new file mode 100644 index 00000000..5deeb05e --- /dev/null +++ b/contracts/go/seats/nodebuildgo/contract.go @@ -0,0 +1,610 @@ +// Code generated by seatgen from the seat definitions; DO NOT EDIT. + +// Package nodebuildgo is the contract of the seat "node-build-go", defined whole (seats/node-build-go/seat.json): The Go build module on a node: it takes the asks of the builder and the checker, runs them as steps and answers. +package nodebuildgo + +import ( + "bytes" + "encoding/json" + "errors" + "strings" + "time" +) + +// Seat is the seat's name. +const Seat = "node-build-go" + +const ( + VerbCurrent = "current" + VerbPause = "pause" + VerbResume = "resume" +) + +const ( + AcceptDelivery = "delivery" + AcceptCheck = "check" + AcceptRecheck = "recheck" +) + +// Verbs is every verb the seat serves, in its definition's order: a new copy each call. +func Verbs() []string { + return []string{VerbCurrent, VerbPause, VerbResume} +} + +// AcceptSubjectDelivery is where delivery is sent. +const AcceptSubjectDelivery = "mesh.seat.node-build-go.accept.delivery" + +// AcceptSubjectCheck is where check is sent. +const AcceptSubjectCheck = "mesh.seat.node-build-go.accept.check" + +// AcceptSubjectRecheck is where recheck is sent. +const AcceptSubjectRecheck = "mesh.seat.node-build-go.accept.recheck" + +const ( + StateSteps = "steps" + StatePauses = "pauses" + StateToolchains = "toolchains" +) + +// StepsByNodeIDKey is the key of one value of the state steps under the form ".". +func StepsByNodeIDKey(node, id string) string { return node + "." + id } + +// ParseStepsByNodeIDKey reads the parts of a key of the form ".", in the order of the form; the last result is false for a key of another form. +func ParseStepsByNodeIDKey(key string) (string, string, bool) { + rest := key + v0, after0, found0 := strings.Cut(rest, ".") + if !found0 || v0 == "" { + return "", "", false + } + rest = after0 + if rest == "" { + return "", "", false + } + v2 := rest + rest = "" + if rest != "" { + return "", "", false + } + return v0, v2, true +} + +// DecodeStepsByNodeIDValue reads one value of the state steps under the form ".", refusing a field StepsByNodeIDValue does not know and any text after the value. +func DecodeStepsByNodeIDValue(raw []byte) (StepsByNodeIDValue, error) { + var v StepsByNodeIDValue + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.DisallowUnknownFields() + if err := dec.Decode(&v); err != nil { + return v, err + } + if dec.More() { + return v, errors.New("text after the value") + } + return v, nil +} + +// PausesByPausedNodeKey is the key of one value of the state pauses under the form "paused.". +func PausesByPausedNodeKey(node string) string { return "paused." + node } + +// ParsePausesByPausedNodeKey reads the parts of a key of the form "paused.", in the order of the form; the last result is false for a key of another form. +func ParsePausesByPausedNodeKey(key string) (string, bool) { + rest := key + if !strings.HasPrefix(rest, "paused.") { + return "", false + } + rest = rest[7:] + if rest == "" { + return "", false + } + v1 := rest + rest = "" + if rest != "" { + return "", false + } + return v1, true +} + +// DecodePausesByPausedNodeValue reads one value of the state pauses under the form "paused.", refusing a field PausesByPausedNodeValue does not know and any text after the value. +func DecodePausesByPausedNodeValue(raw []byte) (PausesByPausedNodeValue, error) { + var v PausesByPausedNodeValue + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.DisallowUnknownFields() + if err := dec.Decode(&v); err != nil { + return v, err + } + if dec.More() { + return v, errors.New("text after the value") + } + return v, nil +} + +// PausesByCapacityNodeKey is the key of one value of the state pauses under the form "capacity.". +func PausesByCapacityNodeKey(node string) string { return "capacity." + node } + +// ParsePausesByCapacityNodeKey reads the parts of a key of the form "capacity.", in the order of the form; the last result is false for a key of another form. +func ParsePausesByCapacityNodeKey(key string) (string, bool) { + rest := key + if !strings.HasPrefix(rest, "capacity.") { + return "", false + } + rest = rest[9:] + if rest == "" { + return "", false + } + v1 := rest + rest = "" + if rest != "" { + return "", false + } + return v1, true +} + +// DecodePausesByCapacityNodeValue reads one value of the state pauses under the form "capacity.", refusing a field PausesByCapacityNodeValue does not know and any text after the value. +func DecodePausesByCapacityNodeValue(raw []byte) (PausesByCapacityNodeValue, error) { + var v PausesByCapacityNodeValue + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.DisallowUnknownFields() + if err := dec.Decode(&v); err != nil { + return v, err + } + if dec.More() { + return v, errors.New("text after the value") + } + return v, nil +} + +// ToolchainsByNodeLanguageKey is the key of one value of the state toolchains under the form ".". +func ToolchainsByNodeLanguageKey(node, language string) string { return node + "." + language } + +// ParseToolchainsByNodeLanguageKey reads the parts of a key of the form ".", in the order of the form; the last result is false for a key of another form. +func ParseToolchainsByNodeLanguageKey(key string) (string, string, bool) { + rest := key + v0, after0, found0 := strings.Cut(rest, ".") + if !found0 || v0 == "" { + return "", "", false + } + rest = after0 + if rest == "" { + return "", "", false + } + v2 := rest + rest = "" + if rest != "" { + return "", "", false + } + return v0, v2, true +} + +// DecodeToolchainsByNodeLanguageValue reads one value of the state toolchains under the form ".", refusing a field ToolchainsByNodeLanguageValue does not know and any text after the value. +func DecodeToolchainsByNodeLanguageValue(raw []byte) (ToolchainsByNodeLanguageValue, error) { + var v ToolchainsByNodeLanguageValue + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.DisallowUnknownFields() + if err := dec.Decode(&v); err != nil { + return v, err + } + if dec.More() { + return v, errors.New("text after the value") + } + return v, nil +} + +// AskArtifact is one artifact of a build ask: built from the fetched tree, or copied from an upstream pin. +type AskArtifact struct { + Artifact string `json:"artifact"` + From string `json:"from,omitempty"` + Seat string `json:"seat,omitempty"` + Version string `json:"version,omitempty"` +} + +// BuildAnswer is a type of the contract. +type BuildAnswer struct { + Artifacts []BuiltArtifact `json:"artifacts"` + Imports []string `json:"imports,omitempty"` + OutOfStep []string `json:"out-of-step,omitempty"` + Toolchain *ToolchainUsed `json:"toolchain,omitempty"` +} + +// BuiltArtifact is one artifact a build made: its digest and the reference it was published under. +type BuiltArtifact struct { + Artifact string `json:"artifact"` + Digest string `json:"digest"` + Reference string `json:"reference,omitempty"` +} + +// Capacity is how many steps the node runs at once. +type Capacity struct { + // when (RFC 3339 time) + At *time.Time `json:"at,omitempty"` + BuildHours string `json:"build-hours,omitempty"` + BuildHoursFault string `json:"build-hours-fault,omitempty"` + ByCpu int64 `json:"by-cpu,omitempty"` + Configured int64 `json:"configured"` + ConfiguredFrom string `json:"configured-from,omitempty"` + CoresPerStep float64 `json:"cores-per-step,omitempty"` + Effective int64 `json:"effective"` + Ineligible string `json:"ineligible,omitempty"` + OutsideHours bool `json:"outside-hours,omitempty"` + QuotaCores float64 `json:"quota-cores,omitempty"` + QuotaFrom string `json:"quota-from,omitempty"` + // the layout version + V int64 `json:"v,omitempty"` + Why string `json:"why"` +} + +// CheckAnswer is a type of the contract. +type CheckAnswer struct { + Fetch FetchAnswer `json:"fetch"` + Levels []LevelAnswer `json:"levels,omitempty"` +} + +// ComposeAnswer is a type of the contract. +type ComposeAnswer struct { + Built bool `json:"built"` + Key string `json:"key,omitempty"` + // the compose-candidate verdict + Verdict any `json:"verdict,omitempty"` +} + +// FetchBesideItem is a type of the contract. +type FetchBesideItem struct { + Commit string `json:"commit"` + Repository string `json:"repository"` +} + +// FetchPackage is the TypeScript module's group package: the group's tagged version of the SDK ({name, version}), placed from the registry in each member whatever its lock names, or the group's package member ({repository, commit, ref}), packed by the fetch and served to the members in place of the registry's. +type FetchPackage struct { + Commit string `json:"commit,omitempty"` + Name string `json:"name,omitempty"` + Ref string `json:"ref,omitempty"` + Repository string `json:"repository,omitempty"` + Version string `json:"version,omitempty"` +} + +// Fetch is what a build module's fetch tool is asked: the repository and the full commit, the pull request's ref when only its head carries the commit, the trunk commit to merge onto for a merge preview, the module's directory and named paths, the mesh packages to build against, and the sibling repositories placed beside. +type Fetch struct { + // the trunk commit a merge preview merges onto + Base string `json:"base,omitempty"` + // the sibling repositories placed beside the fetched tree + Beside []FetchBesideItem `json:"beside,omitempty"` + // the full commit + Commit string `json:"commit"` + // the module's directory in the repository ("." for its root), with paths + Dir string `json:"dir,omitempty"` + // the TypeScript module's group package: the group's tagged version of the SDK ({name, version}), placed from the registry in each member whatever its lock names, or the group's package member ({repository, commit, ref}), packed by the fetch and served to the members in place of the registry's + Package *FetchPackage `json:"package,omitempty"` + // the version (a full commit) of each mesh package to build against, by its module path + Packages map[string]string `json:"packages,omitempty"` + // the paths the module names, relative to dir + Paths []string `json:"paths,omitempty"` + // the pull request's ref, when only its head carries the commit + Ref string `json:"ref,omitempty"` + // / + Repository string `json:"repository"` +} + +// FetchAnswer is what a build module's fetch tool answers, whole: the workspace it made, the commit in it, the module directories found; and, when the call gave a base, the merge preview (the trunk commit, the merge commit, the files that conflict, the paths the merge changes, the compiled programs among them, the tree of each module directory). A head that does not merge cleanly is an answer, not an error: merge is empty and conflicts names the files. The Go module adds the sibling repositories placed and the package versions taken; the TypeScript module the directories installed and refused and the group's package. +type FetchAnswer struct { + Base string `json:"base,omitempty"` + Beside []PlacedBeside `json:"beside,omitempty"` + Changed []string `json:"changed,omitempty"` + Commit string `json:"commit"` + Conflicts []string `json:"conflicts,omitempty"` + Group string `json:"group,omitempty"` + Installed []string `json:"installed,omitempty"` + Merge string `json:"merge,omitempty"` + Modules []string `json:"modules"` + Packages []TakenVersion `json:"packages,omitempty"` + Programs []Program `json:"programs,omitempty"` + Refused []FetchRefusal `json:"refused,omitempty"` + Trees map[string]ModuleTree `json:"trees,omitempty"` + Workspace string `json:"workspace"` +} + +// FetchRefusal is a TypeScript module whose packages the fetch did not install, with why. +type FetchRefusal struct { + Module string `json:"module"` + Reason string `json:"reason"` +} + +// LevelAnswerDigestNeedsItem is a type of the contract. +type LevelAnswerDigestNeedsItem struct { + Needs []string `json:"needs,omitempty"` + Package string `json:"package,omitempty"` +} + +// LevelAnswerDigestPackagesItem is a type of the contract. +type LevelAnswerDigestPackagesItem struct { + Digest string `json:"digest"` + Package string `json:"package"` +} + +// LevelAnswerDigest is a type of the contract. +type LevelAnswerDigest struct { + Beside []string `json:"beside,omitempty"` + JudgedBy string `json:"judged_by,omitempty"` + Level string `json:"level,omitempty"` + Needs []LevelAnswerDigestNeedsItem `json:"needs,omitempty"` + Packages []LevelAnswerDigestPackagesItem `json:"packages,omitempty"` + WithoutTests []string `json:"without_tests,omitempty"` +} + +// LevelAnswerKeptItem is a type of the contract. +type LevelAnswerKeptItem struct { + Digest string `json:"digest,omitempty"` + Package string `json:"package,omitempty"` + Tests int64 `json:"tests,omitempty"` +} + +// LevelAnswerUnmetItem is a type of the contract. +type LevelAnswerUnmetItem struct { + Needs []string `json:"needs,omitempty"` + Package string `json:"package,omitempty"` +} + +// LevelAnswer is one level of a check ask: the digest's answer, the kept verdicts taken, one test result per test step run, and the packages the node could not test. +type LevelAnswer struct { + Digest *LevelAnswerDigest `json:"digest,omitempty"` + Judged string `json:"judged,omitempty"` + Kept []LevelAnswerKeptItem `json:"kept,omitempty"` + Level string `json:"level"` + Tests []any `json:"tests,omitempty"` + Unmet []LevelAnswerUnmetItem `json:"unmet,omitempty"` +} + +// ModuleTree is the tree of one module directory in a merge preview: its git tree, the digest of its manifest, the lock files' digests and the digests of the named paths. +type ModuleTree struct { + Locks map[string]string `json:"locks,omitempty"` + Manifest string `json:"manifest"` + // no longer filled (ADR 0593 section 2); kept so a preview written before still reads + Packages []string `json:"packages,omitempty"` + Paths map[string]string `json:"paths,omitempty"` + Tree string `json:"tree"` +} + +// Pause is whether the node takes no new ask. +type Pause struct { + // when (RFC 3339 time) + At *time.Time `json:"at,omitempty"` + By string `json:"by,omitempty"` + Paused bool `json:"paused"` + // the layout version + V int64 `json:"v,omitempty"` +} + +// PlacedBeside is a sibling repository placed as a plain directory under the workspace's beside directory. +type PlacedBeside struct { + Commit string `json:"commit"` + Dir string `json:"dir"` + Repository string `json:"repository"` +} + +// Program is a changed file that is a compiled program (issue 1577): its path, its executable format, and the commit of the branch that added it when the merge's tree no longer holds it. +type Program struct { + Commit string `json:"commit,omitempty"` + // ELF, Mach-O or PE + Kind string `json:"kind"` + Path string `json:"path"` +} + +// RunAnswer is what a run ask's step answers: whether this run built the program or took the one kept under its key, the key, the program's exit, the last line of its standard output and the first of its standard error. +type RunAnswer struct { + Built bool `json:"built"` + Exit int64 `json:"exit"` + Key string `json:"key"` + // raw when it is JSON, else a JSON string + Output any `json:"output,omitempty"` + Said string `json:"said,omitempty"` +} + +// RunHead is one commit a run's program reads. +type RunHead struct { + Commit string `json:"commit"` + Repository string `json:"repository"` +} + +// RunInput is a run's input by reference: the seat verb the asker serves it on (seat:.), the sha256 of its bytes and its size, at most 256 KiB. +type RunInput struct { + // 64 lowercase hex digits + Digest string `json:"digest"` + // bytes + Size int64 `json:"size"` + Verb string `json:"verb"` +} + +// RunJob is a run ask's job: where its program is built, the heads it reads (each laid read-only at its repository's path), its input by reference, its arguments and what it is for. +type RunJob struct { + // the program's arguments, each a word or a flag + Args []string `json:"args,omitempty"` + // the commits the program reads, a repository at most once + Heads []RunHead `json:"heads"` + Input RunInput `json:"input"` + Program RunProgram `json:"program"` + // what the run is for, for a reader of the ask + Reader string `json:"reader,omitempty"` +} + +// RunProgram is where a run's program is built: the repository and full commit fetched, the Go module's directory in it and the program's import path. +type RunProgram struct { + Commit string `json:"commit"` + Dir string `json:"dir"` + Package string `json:"package"` + Repository string `json:"repository"` +} + +// Step is one step a build module runs on a node. +type Step struct { + // when (RFC 3339 time) + Ended *time.Time `json:"ended,omitempty"` + ID string `json:"id"` + Node string `json:"node"` + Owner string `json:"owner,omitempty"` + Phase string `json:"phase,omitempty"` + // when (RFC 3339 time) + PhaseAt *time.Time `json:"phase-at,omitempty"` + Reason string `json:"reason,omitempty"` + Result *StepResult `json:"result,omitempty"` + // when (RFC 3339 time) + Started time.Time `json:"started"` + // running, passed, failed or refused + State string `json:"state"` + Tool string `json:"tool"` + // the layout version + V int64 `json:"v,omitempty"` + What string `json:"what,omitempty"` +} + +// StepAskBuild is a type of the contract. +type StepAskBuild struct { + Artifacts []AskArtifact `json:"artifacts"` + Fetch *Fetch `json:"fetch,omitempty"` + Fp string `json:"fp,omitempty"` + Module string `json:"module"` + Path string `json:"path,omitempty"` + Publish bool `json:"publish,omitempty"` + Toolchain *ToolchainUsed `json:"toolchain,omitempty"` +} + +// StepAskCheck is a type of the contract. +type StepAskCheck struct { + Fetch Fetch `json:"fetch"` + Kept bool `json:"kept,omitempty"` + Levels []string `json:"levels"` + // the node names the check's tests run against + NodeNames []string `json:"node-names,omitempty"` + Packages []string `json:"packages,omitempty"` + Run string `json:"run,omitempty"` + Runner string `json:"runner,omitempty"` + RunnerWhen string `json:"runner_when,omitempty"` + // the toolchain the checker agreed for the language (ADR 0584 clause 2): a node on another declines the check + Toolchain *ToolchainUsed `json:"toolchain,omitempty"` +} + +// StepAskCompose is a type of the contract. +type StepAskCompose struct { + Dir string `json:"dir"` + Fetch Fetch `json:"fetch"` + Package string `json:"package"` + Reader string `json:"reader,omitempty"` + // the compose-candidate request + Request any `json:"request"` +} + +// StepAsk is one ask of a build module. +type StepAsk struct { + Asker string `json:"asker"` + // when (RFC 3339 time) + At *time.Time `json:"at,omitempty"` + Build *StepAskBuild `json:"build,omitempty"` + Check *StepAskCheck `json:"check,omitempty"` + Compose *StepAskCompose `json:"compose,omitempty"` + ID string `json:"id"` + // build, check or compose + Kind string `json:"kind"` + // go, typescript or image + Language string `json:"language"` + Needs []string `json:"needs,omitempty"` + // package to the tagged version a build pins + Packages map[string]string `json:"packages,omitempty"` + // a run: a program built from a change and run in the build seat's sandbox over the heads, its input asked of a verb of the asker's seat; the Go seat's check stream alone carries it, as a compose + Run *RunJob `json:"run,omitempty"` + // the layout version + V int64 `json:"v,omitempty"` +} + +// StepResult is what a step answered, by the kind of ask: a build's artifacts, toolchain, imports and out-of-step modules; a check's fetch and levels; a compose's built, key and verdict; a run's built, key, exit, output and said; a declined ask's declined, needs and lacks. The fields of the kinds do not meet, so one layout holds the five. +type StepResult struct { + Artifacts []BuiltArtifact `json:"artifacts,omitempty"` + Built bool `json:"built,omitempty"` + // the holder declined the ask: its node lacks what the ask needs + Declined bool `json:"declined,omitempty"` + // a run's program's exit status + Exit int64 `json:"exit,omitempty"` + Fetch *FetchAnswer `json:"fetch,omitempty"` + Imports []string `json:"imports,omitempty"` + Key string `json:"key,omitempty"` + // what the holder's node lacks of it + Lacks []string `json:"lacks,omitempty"` + Levels []LevelAnswer `json:"levels,omitempty"` + // what the declined ask needs + Needs []string `json:"needs,omitempty"` + OutOfStep []string `json:"out-of-step,omitempty"` + // a run's program's last line of standard output: raw when it is JSON, else a JSON string + Output any `json:"output,omitempty"` + // a run's program's first line of standard error, or why the step could not run it + Said string `json:"said,omitempty"` + Toolchain *ToolchainUsed `json:"toolchain,omitempty"` + // the compose-candidate verdict (the composer's own layout) + Verdict any `json:"verdict,omitempty"` +} + +// TakenVersion is of a directory whose go.mod requires an asked mesh package, the version it is built against: the asked one, or go.mod's when it names a newer one, said. +type TakenVersion struct { + Asked string `json:"asked"` + Built string `json:"built"` + Dir string `json:"dir"` + Named string `json:"named"` + Package string `json:"package"` + Said string `json:"said,omitempty"` + Sum string `json:"sum,omitempty"` +} + +// Toolchain is the toolchain a node reports. +type Toolchain struct { + // when (RFC 3339 time) + At *time.Time `json:"at,omitempty"` + // go or typescript + Language string `json:"language"` + Recipe string `json:"recipe"` + // the layout version + V int64 `json:"v,omitempty"` + Version string `json:"version"` +} + +// ToolchainUsed is the toolchain a build was made with: the language, the installed version and the build module's recipe. +type ToolchainUsed struct { + Language string `json:"language"` + Recipe string `json:"recipe"` + Version string `json:"version"` +} + +// CurrentInput is what current takes. +type CurrentInput struct { +} + +// CurrentAnswer is what current answers. +type CurrentAnswer = map[string]any + +// PauseInput is what pause takes. +type PauseInput struct { +} + +// PauseAnswer is what pause answers. +type PauseAnswer = map[string]any + +// ResumeInput is what resume takes. +type ResumeInput struct { +} + +// ResumeAnswer is what resume answers. +type ResumeAnswer = map[string]any + +// DeliveryAccept is the body of delivery. +type DeliveryAccept = StepAsk + +// CheckAccept is the body of check. +type CheckAccept = StepAsk + +// RecheckAccept is the body of recheck. +type RecheckAccept = StepAsk + +// StepsByNodeIDValue is one value of the state steps under the key .. +type StepsByNodeIDValue = Step + +// PausesByPausedNodeValue is one value of the state pauses under the key paused.. +type PausesByPausedNodeValue = Pause + +// PausesByCapacityNodeValue is one value of the state pauses under the key capacity.. +type PausesByCapacityNodeValue = Capacity + +// ToolchainsByNodeLanguageValue is one value of the state toolchains under the key .. +type ToolchainsByNodeLanguageValue = Toolchain diff --git a/contracts/go/seats/nodebuildgo/contract_test.go b/contracts/go/seats/nodebuildgo/contract_test.go new file mode 100644 index 00000000..f120459d --- /dev/null +++ b/contracts/go/seats/nodebuildgo/contract_test.go @@ -0,0 +1,82 @@ +// Code generated by seatgen from the seat definitions; DO NOT EDIT. + +package nodebuildgo + +import ( + "encoding/json" + "reflect" + "regexp" + "testing" +) + +// TestFixtures reads every fixture of seats/node-build-go/fixtures: each decodes into its type, a field the type does not +// know is dropped (ADR 0608 §6), and it reads back as its wire says; each subject named is the contract's. +func TestFixtures(t *testing.T) { + cases := []struct { + name, subject, given, wire string + decode func([]byte) (any, error) + subjectIs *regexp.Regexp + }{ + {name: "a running step", subject: "", given: "{\"id\":\"a1b2\",\"node\":\"ace\",\"started\":\"2026-11-05T10:00:00Z\",\"state\":\"running\",\"tool\":\"build\",\"v\":1}", wire: "{\"id\":\"a1b2\",\"node\":\"ace\",\"started\":\"2026-11-05T10:00:00Z\",\"state\":\"running\",\"tool\":\"build\",\"v\":1}", + decode: func(b []byte) (any, error) { var v StepsByNodeIDValue; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil}, + {name: "a paused node", subject: "", given: "{\"at\":\"2026-11-05T10:00:00Z\",\"by\":\"operator@shanks\",\"paused\":true,\"v\":1}", wire: "{\"at\":\"2026-11-05T10:00:00Z\",\"by\":\"operator@shanks\",\"paused\":true,\"v\":1}", + decode: func(b []byte) (any, error) { + var v PausesByPausedNodeValue + err := json.Unmarshal(b, &v) + return v, err + }, subjectIs: nil}, + {name: "a node's capacity", subject: "", given: "{\"configured\":2,\"effective\":2,\"v\":1,\"why\":\"the configured steps fit the cores\"}", wire: "{\"configured\":2,\"effective\":2,\"v\":1,\"why\":\"the configured steps fit the cores\"}", + decode: func(b []byte) (any, error) { + var v PausesByCapacityNodeValue + err := json.Unmarshal(b, &v) + return v, err + }, subjectIs: nil}, + {name: "a node's toolchain", subject: "", given: "{\"language\":\"go\",\"recipe\":\"1\",\"v\":1,\"version\":\"1.26.0\"}", wire: "{\"language\":\"go\",\"recipe\":\"1\",\"v\":1,\"version\":\"1.26.0\"}", + decode: func(b []byte) (any, error) { + var v ToolchainsByNodeLanguageValue + err := json.Unmarshal(b, &v) + return v, err + }, subjectIs: nil}, + {name: "a build ask", subject: "mesh.seat.node-build-go.accept.delivery", given: "{\"asker\":\"mesh-delivery-builder\",\"build\":{\"artifacts\":[{\"artifact\":\"go\"}],\"module\":\"nats\"},\"id\":\"a1b2\",\"kind\":\"build\",\"language\":\"go\"}", wire: "{\"asker\":\"mesh-delivery-builder\",\"build\":{\"artifacts\":[{\"artifact\":\"go\"}],\"module\":\"nats\"},\"id\":\"a1b2\",\"kind\":\"build\",\"language\":\"go\"}", + decode: func(b []byte) (any, error) { var v DeliveryAccept; err := json.Unmarshal(b, &v); return v, err }, subjectIs: regexp.MustCompile("^mesh\\.seat\\.node-build-go\\.accept\\.delivery$")}, + {name: "a check ask with its fetch, siblings and levels", subject: "mesh.seat.node-build-go.accept.check", given: "{\"asker\":\"mesh-delivery-checker\",\"at\":\"2026-11-05T10:00:00Z\",\"check\":{\"fetch\":{\"base\":\"89abcdef0123456789abcdef0123456789abcdef\",\"beside\":[{\"commit\":\"fedcba9876543210fedcba9876543210fedcba98\",\"repository\":\"novox/mesh-sdk\"}],\"commit\":\"0123456789abcdef0123456789abcdef01234567\",\"packages\":{\"git.novox.be/novox/mesh-sdk/go\":\"fedcba9876543210fedcba9876543210fedcba98\"},\"ref\":\"refs/pull/7/head\",\"repository\":\"novox/mesh-catalog\"},\"kept\":true,\"levels\":[\"unit\",\"integration\"],\"node-names\":[\"ace\",\"g14\"],\"toolchain\":{\"language\":\"go\",\"recipe\":\"2\",\"version\":\"1.26.0\"}},\"id\":\"c3d4\",\"kind\":\"check\",\"language\":\"go\",\"v\":1}", wire: "{\"asker\":\"mesh-delivery-checker\",\"at\":\"2026-11-05T10:00:00Z\",\"check\":{\"fetch\":{\"base\":\"89abcdef0123456789abcdef0123456789abcdef\",\"beside\":[{\"commit\":\"fedcba9876543210fedcba9876543210fedcba98\",\"repository\":\"novox/mesh-sdk\"}],\"commit\":\"0123456789abcdef0123456789abcdef01234567\",\"packages\":{\"git.novox.be/novox/mesh-sdk/go\":\"fedcba9876543210fedcba9876543210fedcba98\"},\"ref\":\"refs/pull/7/head\",\"repository\":\"novox/mesh-catalog\"},\"kept\":true,\"levels\":[\"unit\",\"integration\"],\"node-names\":[\"ace\",\"g14\"],\"toolchain\":{\"language\":\"go\",\"recipe\":\"2\",\"version\":\"1.26.0\"}},\"id\":\"c3d4\",\"kind\":\"check\",\"language\":\"go\",\"v\":1}", + decode: func(b []byte) (any, error) { var v CheckAccept; err := json.Unmarshal(b, &v); return v, err }, subjectIs: regexp.MustCompile("^mesh\\.seat\\.node-build-go\\.accept\\.check$")}, + {name: "a finished build step with its artifacts", subject: "", given: "{\"ended\":\"2026-11-05T10:03:00Z\",\"id\":\"a1b2\",\"node\":\"ace\",\"result\":{\"artifacts\":[{\"artifact\":\"go\",\"digest\":\"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"reference\":\"container-image-registry/nats/go@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}],\"imports\":[\"git.novox.be/novox/mesh-sdk/go\"],\"toolchain\":{\"language\":\"go\",\"recipe\":\"1\",\"version\":\"1.26.0\"}},\"started\":\"2026-11-05T10:00:00Z\",\"state\":\"passed\",\"tool\":\"build\",\"v\":1}", wire: "{\"ended\":\"2026-11-05T10:03:00Z\",\"id\":\"a1b2\",\"node\":\"ace\",\"result\":{\"artifacts\":[{\"artifact\":\"go\",\"digest\":\"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"reference\":\"container-image-registry/nats/go@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}],\"imports\":[\"git.novox.be/novox/mesh-sdk/go\"],\"toolchain\":{\"language\":\"go\",\"recipe\":\"1\",\"version\":\"1.26.0\"}},\"started\":\"2026-11-05T10:00:00Z\",\"state\":\"passed\",\"tool\":\"build\",\"v\":1}", + decode: func(b []byte) (any, error) { var v StepsByNodeIDValue; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil}, + {name: "a finished check step with its levels", subject: "", given: "{\"ended\":\"2026-11-05T10:06:00Z\",\"id\":\"c3d4\",\"node\":\"ace\",\"result\":{\"fetch\":{\"base\":\"abcdef0123456789abcdef0123456789abcdef01\",\"changed\":[\"a.go\",\"modules/m/main.go\"],\"commit\":\"0123456789abcdef0123456789abcdef01234567\",\"merge\":\"fedcba9876543210fedcba9876543210fedcba98\",\"modules\":[\".\"],\"trees\":{\"modules/m\":{\"locks\":{\"go.sum\":\"c5fc83c01e92404452b986527d239140ccf9a48b88e0c268fbf38c2e1429e9c9\"},\"manifest\":\"ca3d163bab055381827226140568f3bef7eaac187cebd76878e0b63e9e442356\",\"tree\":\"1111111111111111111111111111111111111111\"}},\"workspace\":\"mvii1iwu5414e4\"},\"levels\":[{\"digest\":{\"level\":\"unit\",\"packages\":[{\"digest\":\"sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\",\"package\":\"./modules/a\"}]},\"kept\":[{\"digest\":\"sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\",\"package\":\"./modules/a\",\"tests\":12}],\"level\":\"unit\",\"tests\":[{\"package\":\"./modules/b\",\"pass\":true}],\"unmet\":[{\"needs\":[\"docker\"],\"package\":\"./modules/c\"}]}]},\"started\":\"2026-11-05T10:00:00Z\",\"state\":\"passed\",\"tool\":\"check\",\"v\":1}", wire: "{\"ended\":\"2026-11-05T10:06:00Z\",\"id\":\"c3d4\",\"node\":\"ace\",\"result\":{\"fetch\":{\"base\":\"abcdef0123456789abcdef0123456789abcdef01\",\"changed\":[\"a.go\",\"modules/m/main.go\"],\"commit\":\"0123456789abcdef0123456789abcdef01234567\",\"merge\":\"fedcba9876543210fedcba9876543210fedcba98\",\"modules\":[\".\"],\"trees\":{\"modules/m\":{\"locks\":{\"go.sum\":\"c5fc83c01e92404452b986527d239140ccf9a48b88e0c268fbf38c2e1429e9c9\"},\"manifest\":\"ca3d163bab055381827226140568f3bef7eaac187cebd76878e0b63e9e442356\",\"tree\":\"1111111111111111111111111111111111111111\"}},\"workspace\":\"mvii1iwu5414e4\"},\"levels\":[{\"digest\":{\"level\":\"unit\",\"packages\":[{\"digest\":\"sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\",\"package\":\"./modules/a\"}]},\"kept\":[{\"digest\":\"sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\",\"package\":\"./modules/a\",\"tests\":12}],\"level\":\"unit\",\"tests\":[{\"package\":\"./modules/b\",\"pass\":true}],\"unmet\":[{\"needs\":[\"docker\"],\"package\":\"./modules/c\"}]}]},\"started\":\"2026-11-05T10:00:00Z\",\"state\":\"passed\",\"tool\":\"check\",\"v\":1}", + decode: func(b []byte) (any, error) { var v StepsByNodeIDValue; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil}, + {name: "a declined step", subject: "", given: "{\"id\":\"c3d5\",\"node\":\"ace\",\"result\":{\"declined\":true,\"lacks\":[\"docker\"],\"needs\":[\"docker\"]},\"started\":\"2026-11-05T10:00:00Z\",\"state\":\"refused\",\"tool\":\"check\",\"v\":1}", wire: "{\"id\":\"c3d5\",\"node\":\"ace\",\"result\":{\"declined\":true,\"lacks\":[\"docker\"],\"needs\":[\"docker\"]},\"started\":\"2026-11-05T10:00:00Z\",\"state\":\"refused\",\"tool\":\"check\",\"v\":1}", + decode: func(b []byte) (any, error) { var v StepsByNodeIDValue; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil}, + {name: "a finished check step whose head does not merge", subject: "", given: "{\"ended\":\"2026-11-05T10:01:00Z\",\"id\":\"c3d5\",\"node\":\"ace\",\"result\":{\"fetch\":{\"base\":\"abcdef0123456789abcdef0123456789abcdef01\",\"commit\":\"0123456789abcdef0123456789abcdef01234567\",\"conflicts\":[\"a.go\",\"sub/b.go\"],\"modules\":[\".\"],\"workspace\":\"mvii1iwz5050eb\"}},\"started\":\"2026-11-05T10:00:00Z\",\"state\":\"passed\",\"tool\":\"check\",\"v\":1}", wire: "{\"ended\":\"2026-11-05T10:01:00Z\",\"id\":\"c3d5\",\"node\":\"ace\",\"result\":{\"fetch\":{\"base\":\"abcdef0123456789abcdef0123456789abcdef01\",\"commit\":\"0123456789abcdef0123456789abcdef01234567\",\"conflicts\":[\"a.go\",\"sub/b.go\"],\"modules\":[\".\"],\"workspace\":\"mvii1iwz5050eb\"}},\"started\":\"2026-11-05T10:00:00Z\",\"state\":\"passed\",\"tool\":\"check\",\"v\":1}", + decode: func(b []byte) (any, error) { var v StepsByNodeIDValue; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil}, + {name: "a run ask of the checking program over two heads", subject: "mesh.seat.node-build-go.accept.check", given: "{\"asker\":\"mesh-delivery-checker\",\"at\":\"2026-11-05T10:00:00Z\",\"id\":\"r5e6\",\"kind\":\"check\",\"language\":\"go\",\"run\":{\"args\":[\"-verdict\"],\"heads\":[{\"commit\":\"0123456789abcdef0123456789abcdef01234567\",\"repository\":\"novox/mesh-delivery\"},{\"commit\":\"0123456789abcdef0123456789abcdef01234567\",\"repository\":\"novox/mesh-sdk\"}],\"input\":{\"digest\":\"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\",\"size\":7,\"verb\":\"seat:mesh-delivery-checker.input\"},\"program\":{\"commit\":\"0123456789abcdef0123456789abcdef01234567\",\"dir\":\"modules/mesh-delivery-checker\",\"package\":\"./cmd/mesh-checking-program\",\"repository\":\"novox/mesh-delivery\"},\"reader\":\"checking program of mesh-delivery head\"},\"v\":1}", wire: "{\"asker\":\"mesh-delivery-checker\",\"at\":\"2026-11-05T10:00:00Z\",\"id\":\"r5e6\",\"kind\":\"check\",\"language\":\"go\",\"run\":{\"args\":[\"-verdict\"],\"heads\":[{\"commit\":\"0123456789abcdef0123456789abcdef01234567\",\"repository\":\"novox/mesh-delivery\"},{\"commit\":\"0123456789abcdef0123456789abcdef01234567\",\"repository\":\"novox/mesh-sdk\"}],\"input\":{\"digest\":\"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\",\"size\":7,\"verb\":\"seat:mesh-delivery-checker.input\"},\"program\":{\"commit\":\"0123456789abcdef0123456789abcdef01234567\",\"dir\":\"modules/mesh-delivery-checker\",\"package\":\"./cmd/mesh-checking-program\",\"repository\":\"novox/mesh-delivery\"},\"reader\":\"checking program of mesh-delivery head\"},\"v\":1}", + decode: func(b []byte) (any, error) { var v CheckAccept; err := json.Unmarshal(b, &v); return v, err }, subjectIs: regexp.MustCompile("^mesh\\.seat\\.node-build-go\\.accept\\.check$")}, + {name: "a finished run step whose program exited non-zero", subject: "", given: "{\"ended\":\"2026-11-05T10:01:00Z\",\"id\":\"r5e6\",\"node\":\"ace\",\"result\":{\"built\":true,\"exit\":2,\"key\":\"program-0123456789abcdef\",\"said\":\"the facts name no head\"},\"started\":\"2026-11-05T10:00:00Z\",\"state\":\"failed\",\"tool\":\"check\",\"v\":1}", wire: "{\"ended\":\"2026-11-05T10:01:00Z\",\"id\":\"r5e6\",\"node\":\"ace\",\"result\":{\"built\":true,\"exit\":2,\"key\":\"program-0123456789abcdef\",\"said\":\"the facts name no head\"},\"started\":\"2026-11-05T10:00:00Z\",\"state\":\"failed\",\"tool\":\"check\",\"v\":1}", + decode: func(b []byte) (any, error) { var v StepsByNodeIDValue; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil}, + } + for _, c := range cases { + v, err := c.decode([]byte(c.given)) + if err != nil { + t.Errorf("%s: does not decode: %v", c.name, err) + continue + } + back, err := json.Marshal(v) + if err != nil { + t.Errorf("%s: does not encode: %v", c.name, err) + continue + } + var got, want any + if err := json.Unmarshal(back, &got); err != nil { + t.Fatal(err) + } + if err := json.Unmarshal([]byte(c.wire), &want); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(got, want) { + t.Errorf("%s: reads back as %s, want %s", c.name, back, c.wire) + } + if c.subjectIs != nil && !c.subjectIs.MatchString(c.subject) { + t.Errorf("%s: subject %q is not the contract's %s", c.name, c.subject, c.subjectIs) + } + } +} diff --git a/contracts/go/seats/nodebuildgo/go.mod b/contracts/go/seats/nodebuildgo/go.mod new file mode 100644 index 00000000..7d786c14 --- /dev/null +++ b/contracts/go/seats/nodebuildgo/go.mod @@ -0,0 +1,3 @@ +module git.novox.be/novox/mesh-delivery/contracts/go/seats/nodebuildgo + +go 1.26.0 diff --git a/contracts/go/seats/nodebuildimage/contract.go b/contracts/go/seats/nodebuildimage/contract.go new file mode 100644 index 00000000..dde300d1 --- /dev/null +++ b/contracts/go/seats/nodebuildimage/contract.go @@ -0,0 +1,610 @@ +// Code generated by seatgen from the seat definitions; DO NOT EDIT. + +// Package nodebuildimage is the contract of the seat "node-build-image", defined whole (seats/node-build-image/seat.json): The image build module on a node: it takes the asks of the builder and the checker, runs them as steps and answers. +package nodebuildimage + +import ( + "bytes" + "encoding/json" + "errors" + "strings" + "time" +) + +// Seat is the seat's name. +const Seat = "node-build-image" + +const ( + VerbCurrent = "current" + VerbPause = "pause" + VerbResume = "resume" +) + +const ( + AcceptDelivery = "delivery" + AcceptCheck = "check" + AcceptRecheck = "recheck" +) + +// Verbs is every verb the seat serves, in its definition's order: a new copy each call. +func Verbs() []string { + return []string{VerbCurrent, VerbPause, VerbResume} +} + +// AcceptSubjectDelivery is where delivery is sent. +const AcceptSubjectDelivery = "mesh.seat.node-build-image.accept.delivery" + +// AcceptSubjectCheck is where check is sent. +const AcceptSubjectCheck = "mesh.seat.node-build-image.accept.check" + +// AcceptSubjectRecheck is where recheck is sent. +const AcceptSubjectRecheck = "mesh.seat.node-build-image.accept.recheck" + +const ( + StateSteps = "steps" + StatePauses = "pauses" + StateToolchains = "toolchains" +) + +// StepsByNodeIDKey is the key of one value of the state steps under the form ".". +func StepsByNodeIDKey(node, id string) string { return node + "." + id } + +// ParseStepsByNodeIDKey reads the parts of a key of the form ".", in the order of the form; the last result is false for a key of another form. +func ParseStepsByNodeIDKey(key string) (string, string, bool) { + rest := key + v0, after0, found0 := strings.Cut(rest, ".") + if !found0 || v0 == "" { + return "", "", false + } + rest = after0 + if rest == "" { + return "", "", false + } + v2 := rest + rest = "" + if rest != "" { + return "", "", false + } + return v0, v2, true +} + +// DecodeStepsByNodeIDValue reads one value of the state steps under the form ".", refusing a field StepsByNodeIDValue does not know and any text after the value. +func DecodeStepsByNodeIDValue(raw []byte) (StepsByNodeIDValue, error) { + var v StepsByNodeIDValue + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.DisallowUnknownFields() + if err := dec.Decode(&v); err != nil { + return v, err + } + if dec.More() { + return v, errors.New("text after the value") + } + return v, nil +} + +// PausesByPausedNodeKey is the key of one value of the state pauses under the form "paused.". +func PausesByPausedNodeKey(node string) string { return "paused." + node } + +// ParsePausesByPausedNodeKey reads the parts of a key of the form "paused.", in the order of the form; the last result is false for a key of another form. +func ParsePausesByPausedNodeKey(key string) (string, bool) { + rest := key + if !strings.HasPrefix(rest, "paused.") { + return "", false + } + rest = rest[7:] + if rest == "" { + return "", false + } + v1 := rest + rest = "" + if rest != "" { + return "", false + } + return v1, true +} + +// DecodePausesByPausedNodeValue reads one value of the state pauses under the form "paused.", refusing a field PausesByPausedNodeValue does not know and any text after the value. +func DecodePausesByPausedNodeValue(raw []byte) (PausesByPausedNodeValue, error) { + var v PausesByPausedNodeValue + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.DisallowUnknownFields() + if err := dec.Decode(&v); err != nil { + return v, err + } + if dec.More() { + return v, errors.New("text after the value") + } + return v, nil +} + +// PausesByCapacityNodeKey is the key of one value of the state pauses under the form "capacity.". +func PausesByCapacityNodeKey(node string) string { return "capacity." + node } + +// ParsePausesByCapacityNodeKey reads the parts of a key of the form "capacity.", in the order of the form; the last result is false for a key of another form. +func ParsePausesByCapacityNodeKey(key string) (string, bool) { + rest := key + if !strings.HasPrefix(rest, "capacity.") { + return "", false + } + rest = rest[9:] + if rest == "" { + return "", false + } + v1 := rest + rest = "" + if rest != "" { + return "", false + } + return v1, true +} + +// DecodePausesByCapacityNodeValue reads one value of the state pauses under the form "capacity.", refusing a field PausesByCapacityNodeValue does not know and any text after the value. +func DecodePausesByCapacityNodeValue(raw []byte) (PausesByCapacityNodeValue, error) { + var v PausesByCapacityNodeValue + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.DisallowUnknownFields() + if err := dec.Decode(&v); err != nil { + return v, err + } + if dec.More() { + return v, errors.New("text after the value") + } + return v, nil +} + +// ToolchainsByNodeLanguageKey is the key of one value of the state toolchains under the form ".". +func ToolchainsByNodeLanguageKey(node, language string) string { return node + "." + language } + +// ParseToolchainsByNodeLanguageKey reads the parts of a key of the form ".", in the order of the form; the last result is false for a key of another form. +func ParseToolchainsByNodeLanguageKey(key string) (string, string, bool) { + rest := key + v0, after0, found0 := strings.Cut(rest, ".") + if !found0 || v0 == "" { + return "", "", false + } + rest = after0 + if rest == "" { + return "", "", false + } + v2 := rest + rest = "" + if rest != "" { + return "", "", false + } + return v0, v2, true +} + +// DecodeToolchainsByNodeLanguageValue reads one value of the state toolchains under the form ".", refusing a field ToolchainsByNodeLanguageValue does not know and any text after the value. +func DecodeToolchainsByNodeLanguageValue(raw []byte) (ToolchainsByNodeLanguageValue, error) { + var v ToolchainsByNodeLanguageValue + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.DisallowUnknownFields() + if err := dec.Decode(&v); err != nil { + return v, err + } + if dec.More() { + return v, errors.New("text after the value") + } + return v, nil +} + +// AskArtifact is one artifact of a build ask: built from the fetched tree, or copied from an upstream pin. +type AskArtifact struct { + Artifact string `json:"artifact"` + From string `json:"from,omitempty"` + Seat string `json:"seat,omitempty"` + Version string `json:"version,omitempty"` +} + +// BuildAnswer is a type of the contract. +type BuildAnswer struct { + Artifacts []BuiltArtifact `json:"artifacts"` + Imports []string `json:"imports,omitempty"` + OutOfStep []string `json:"out-of-step,omitempty"` + Toolchain *ToolchainUsed `json:"toolchain,omitempty"` +} + +// BuiltArtifact is one artifact a build made: its digest and the reference it was published under. +type BuiltArtifact struct { + Artifact string `json:"artifact"` + Digest string `json:"digest"` + Reference string `json:"reference,omitempty"` +} + +// Capacity is how many steps the node runs at once. +type Capacity struct { + // when (RFC 3339 time) + At *time.Time `json:"at,omitempty"` + BuildHours string `json:"build-hours,omitempty"` + BuildHoursFault string `json:"build-hours-fault,omitempty"` + ByCpu int64 `json:"by-cpu,omitempty"` + Configured int64 `json:"configured"` + ConfiguredFrom string `json:"configured-from,omitempty"` + CoresPerStep float64 `json:"cores-per-step,omitempty"` + Effective int64 `json:"effective"` + Ineligible string `json:"ineligible,omitempty"` + OutsideHours bool `json:"outside-hours,omitempty"` + QuotaCores float64 `json:"quota-cores,omitempty"` + QuotaFrom string `json:"quota-from,omitempty"` + // the layout version + V int64 `json:"v,omitempty"` + Why string `json:"why"` +} + +// CheckAnswer is a type of the contract. +type CheckAnswer struct { + Fetch FetchAnswer `json:"fetch"` + Levels []LevelAnswer `json:"levels,omitempty"` +} + +// ComposeAnswer is a type of the contract. +type ComposeAnswer struct { + Built bool `json:"built"` + Key string `json:"key,omitempty"` + // the compose-candidate verdict + Verdict any `json:"verdict,omitempty"` +} + +// FetchBesideItem is a type of the contract. +type FetchBesideItem struct { + Commit string `json:"commit"` + Repository string `json:"repository"` +} + +// FetchPackage is the TypeScript module's group package: the group's tagged version of the SDK ({name, version}), placed from the registry in each member whatever its lock names, or the group's package member ({repository, commit, ref}), packed by the fetch and served to the members in place of the registry's. +type FetchPackage struct { + Commit string `json:"commit,omitempty"` + Name string `json:"name,omitempty"` + Ref string `json:"ref,omitempty"` + Repository string `json:"repository,omitempty"` + Version string `json:"version,omitempty"` +} + +// Fetch is what a build module's fetch tool is asked: the repository and the full commit, the pull request's ref when only its head carries the commit, the trunk commit to merge onto for a merge preview, the module's directory and named paths, the mesh packages to build against, and the sibling repositories placed beside. +type Fetch struct { + // the trunk commit a merge preview merges onto + Base string `json:"base,omitempty"` + // the sibling repositories placed beside the fetched tree + Beside []FetchBesideItem `json:"beside,omitempty"` + // the full commit + Commit string `json:"commit"` + // the module's directory in the repository ("." for its root), with paths + Dir string `json:"dir,omitempty"` + // the TypeScript module's group package: the group's tagged version of the SDK ({name, version}), placed from the registry in each member whatever its lock names, or the group's package member ({repository, commit, ref}), packed by the fetch and served to the members in place of the registry's + Package *FetchPackage `json:"package,omitempty"` + // the version (a full commit) of each mesh package to build against, by its module path + Packages map[string]string `json:"packages,omitempty"` + // the paths the module names, relative to dir + Paths []string `json:"paths,omitempty"` + // the pull request's ref, when only its head carries the commit + Ref string `json:"ref,omitempty"` + // / + Repository string `json:"repository"` +} + +// FetchAnswer is what a build module's fetch tool answers, whole: the workspace it made, the commit in it, the module directories found; and, when the call gave a base, the merge preview (the trunk commit, the merge commit, the files that conflict, the paths the merge changes, the compiled programs among them, the tree of each module directory). A head that does not merge cleanly is an answer, not an error: merge is empty and conflicts names the files. The Go module adds the sibling repositories placed and the package versions taken; the TypeScript module the directories installed and refused and the group's package. +type FetchAnswer struct { + Base string `json:"base,omitempty"` + Beside []PlacedBeside `json:"beside,omitempty"` + Changed []string `json:"changed,omitempty"` + Commit string `json:"commit"` + Conflicts []string `json:"conflicts,omitempty"` + Group string `json:"group,omitempty"` + Installed []string `json:"installed,omitempty"` + Merge string `json:"merge,omitempty"` + Modules []string `json:"modules"` + Packages []TakenVersion `json:"packages,omitempty"` + Programs []Program `json:"programs,omitempty"` + Refused []FetchRefusal `json:"refused,omitempty"` + Trees map[string]ModuleTree `json:"trees,omitempty"` + Workspace string `json:"workspace"` +} + +// FetchRefusal is a TypeScript module whose packages the fetch did not install, with why. +type FetchRefusal struct { + Module string `json:"module"` + Reason string `json:"reason"` +} + +// LevelAnswerDigestNeedsItem is a type of the contract. +type LevelAnswerDigestNeedsItem struct { + Needs []string `json:"needs,omitempty"` + Package string `json:"package,omitempty"` +} + +// LevelAnswerDigestPackagesItem is a type of the contract. +type LevelAnswerDigestPackagesItem struct { + Digest string `json:"digest"` + Package string `json:"package"` +} + +// LevelAnswerDigest is a type of the contract. +type LevelAnswerDigest struct { + Beside []string `json:"beside,omitempty"` + JudgedBy string `json:"judged_by,omitempty"` + Level string `json:"level,omitempty"` + Needs []LevelAnswerDigestNeedsItem `json:"needs,omitempty"` + Packages []LevelAnswerDigestPackagesItem `json:"packages,omitempty"` + WithoutTests []string `json:"without_tests,omitempty"` +} + +// LevelAnswerKeptItem is a type of the contract. +type LevelAnswerKeptItem struct { + Digest string `json:"digest,omitempty"` + Package string `json:"package,omitempty"` + Tests int64 `json:"tests,omitempty"` +} + +// LevelAnswerUnmetItem is a type of the contract. +type LevelAnswerUnmetItem struct { + Needs []string `json:"needs,omitempty"` + Package string `json:"package,omitempty"` +} + +// LevelAnswer is one level of a check ask: the digest's answer, the kept verdicts taken, one test result per test step run, and the packages the node could not test. +type LevelAnswer struct { + Digest *LevelAnswerDigest `json:"digest,omitempty"` + Judged string `json:"judged,omitempty"` + Kept []LevelAnswerKeptItem `json:"kept,omitempty"` + Level string `json:"level"` + Tests []any `json:"tests,omitempty"` + Unmet []LevelAnswerUnmetItem `json:"unmet,omitempty"` +} + +// ModuleTree is the tree of one module directory in a merge preview: its git tree, the digest of its manifest, the lock files' digests and the digests of the named paths. +type ModuleTree struct { + Locks map[string]string `json:"locks,omitempty"` + Manifest string `json:"manifest"` + // no longer filled (ADR 0593 section 2); kept so a preview written before still reads + Packages []string `json:"packages,omitempty"` + Paths map[string]string `json:"paths,omitempty"` + Tree string `json:"tree"` +} + +// Pause is whether the node takes no new ask. +type Pause struct { + // when (RFC 3339 time) + At *time.Time `json:"at,omitempty"` + By string `json:"by,omitempty"` + Paused bool `json:"paused"` + // the layout version + V int64 `json:"v,omitempty"` +} + +// PlacedBeside is a sibling repository placed as a plain directory under the workspace's beside directory. +type PlacedBeside struct { + Commit string `json:"commit"` + Dir string `json:"dir"` + Repository string `json:"repository"` +} + +// Program is a changed file that is a compiled program (issue 1577): its path, its executable format, and the commit of the branch that added it when the merge's tree no longer holds it. +type Program struct { + Commit string `json:"commit,omitempty"` + // ELF, Mach-O or PE + Kind string `json:"kind"` + Path string `json:"path"` +} + +// RunAnswer is what a run ask's step answers: whether this run built the program or took the one kept under its key, the key, the program's exit, the last line of its standard output and the first of its standard error. +type RunAnswer struct { + Built bool `json:"built"` + Exit int64 `json:"exit"` + Key string `json:"key"` + // raw when it is JSON, else a JSON string + Output any `json:"output,omitempty"` + Said string `json:"said,omitempty"` +} + +// RunHead is one commit a run's program reads. +type RunHead struct { + Commit string `json:"commit"` + Repository string `json:"repository"` +} + +// RunInput is a run's input by reference: the seat verb the asker serves it on (seat:.), the sha256 of its bytes and its size, at most 256 KiB. +type RunInput struct { + // 64 lowercase hex digits + Digest string `json:"digest"` + // bytes + Size int64 `json:"size"` + Verb string `json:"verb"` +} + +// RunJob is a run ask's job: where its program is built, the heads it reads (each laid read-only at its repository's path), its input by reference, its arguments and what it is for. +type RunJob struct { + // the program's arguments, each a word or a flag + Args []string `json:"args,omitempty"` + // the commits the program reads, a repository at most once + Heads []RunHead `json:"heads"` + Input RunInput `json:"input"` + Program RunProgram `json:"program"` + // what the run is for, for a reader of the ask + Reader string `json:"reader,omitempty"` +} + +// RunProgram is where a run's program is built: the repository and full commit fetched, the Go module's directory in it and the program's import path. +type RunProgram struct { + Commit string `json:"commit"` + Dir string `json:"dir"` + Package string `json:"package"` + Repository string `json:"repository"` +} + +// Step is one step a build module runs on a node. +type Step struct { + // when (RFC 3339 time) + Ended *time.Time `json:"ended,omitempty"` + ID string `json:"id"` + Node string `json:"node"` + Owner string `json:"owner,omitempty"` + Phase string `json:"phase,omitempty"` + // when (RFC 3339 time) + PhaseAt *time.Time `json:"phase-at,omitempty"` + Reason string `json:"reason,omitempty"` + Result *StepResult `json:"result,omitempty"` + // when (RFC 3339 time) + Started time.Time `json:"started"` + // running, passed, failed or refused + State string `json:"state"` + Tool string `json:"tool"` + // the layout version + V int64 `json:"v,omitempty"` + What string `json:"what,omitempty"` +} + +// StepAskBuild is a type of the contract. +type StepAskBuild struct { + Artifacts []AskArtifact `json:"artifacts"` + Fetch *Fetch `json:"fetch,omitempty"` + Fp string `json:"fp,omitempty"` + Module string `json:"module"` + Path string `json:"path,omitempty"` + Publish bool `json:"publish,omitempty"` + Toolchain *ToolchainUsed `json:"toolchain,omitempty"` +} + +// StepAskCheck is a type of the contract. +type StepAskCheck struct { + Fetch Fetch `json:"fetch"` + Kept bool `json:"kept,omitempty"` + Levels []string `json:"levels"` + // the node names the check's tests run against + NodeNames []string `json:"node-names,omitempty"` + Packages []string `json:"packages,omitempty"` + Run string `json:"run,omitempty"` + Runner string `json:"runner,omitempty"` + RunnerWhen string `json:"runner_when,omitempty"` + // the toolchain the checker agreed for the language (ADR 0584 clause 2): a node on another declines the check + Toolchain *ToolchainUsed `json:"toolchain,omitempty"` +} + +// StepAskCompose is a type of the contract. +type StepAskCompose struct { + Dir string `json:"dir"` + Fetch Fetch `json:"fetch"` + Package string `json:"package"` + Reader string `json:"reader,omitempty"` + // the compose-candidate request + Request any `json:"request"` +} + +// StepAsk is one ask of a build module. +type StepAsk struct { + Asker string `json:"asker"` + // when (RFC 3339 time) + At *time.Time `json:"at,omitempty"` + Build *StepAskBuild `json:"build,omitempty"` + Check *StepAskCheck `json:"check,omitempty"` + Compose *StepAskCompose `json:"compose,omitempty"` + ID string `json:"id"` + // build, check or compose + Kind string `json:"kind"` + // go, typescript or image + Language string `json:"language"` + Needs []string `json:"needs,omitempty"` + // package to the tagged version a build pins + Packages map[string]string `json:"packages,omitempty"` + // a run: a program built from a change and run in the build seat's sandbox over the heads, its input asked of a verb of the asker's seat; the Go seat's check stream alone carries it, as a compose + Run *RunJob `json:"run,omitempty"` + // the layout version + V int64 `json:"v,omitempty"` +} + +// StepResult is what a step answered, by the kind of ask: a build's artifacts, toolchain, imports and out-of-step modules; a check's fetch and levels; a compose's built, key and verdict; a run's built, key, exit, output and said; a declined ask's declined, needs and lacks. The fields of the kinds do not meet, so one layout holds the five. +type StepResult struct { + Artifacts []BuiltArtifact `json:"artifacts,omitempty"` + Built bool `json:"built,omitempty"` + // the holder declined the ask: its node lacks what the ask needs + Declined bool `json:"declined,omitempty"` + // a run's program's exit status + Exit int64 `json:"exit,omitempty"` + Fetch *FetchAnswer `json:"fetch,omitempty"` + Imports []string `json:"imports,omitempty"` + Key string `json:"key,omitempty"` + // what the holder's node lacks of it + Lacks []string `json:"lacks,omitempty"` + Levels []LevelAnswer `json:"levels,omitempty"` + // what the declined ask needs + Needs []string `json:"needs,omitempty"` + OutOfStep []string `json:"out-of-step,omitempty"` + // a run's program's last line of standard output: raw when it is JSON, else a JSON string + Output any `json:"output,omitempty"` + // a run's program's first line of standard error, or why the step could not run it + Said string `json:"said,omitempty"` + Toolchain *ToolchainUsed `json:"toolchain,omitempty"` + // the compose-candidate verdict (the composer's own layout) + Verdict any `json:"verdict,omitempty"` +} + +// TakenVersion is of a directory whose go.mod requires an asked mesh package, the version it is built against: the asked one, or go.mod's when it names a newer one, said. +type TakenVersion struct { + Asked string `json:"asked"` + Built string `json:"built"` + Dir string `json:"dir"` + Named string `json:"named"` + Package string `json:"package"` + Said string `json:"said,omitempty"` + Sum string `json:"sum,omitempty"` +} + +// Toolchain is the toolchain a node reports. +type Toolchain struct { + // when (RFC 3339 time) + At *time.Time `json:"at,omitempty"` + // go or typescript + Language string `json:"language"` + Recipe string `json:"recipe"` + // the layout version + V int64 `json:"v,omitempty"` + Version string `json:"version"` +} + +// ToolchainUsed is the toolchain a build was made with: the language, the installed version and the build module's recipe. +type ToolchainUsed struct { + Language string `json:"language"` + Recipe string `json:"recipe"` + Version string `json:"version"` +} + +// CurrentInput is what current takes. +type CurrentInput struct { +} + +// CurrentAnswer is what current answers. +type CurrentAnswer = map[string]any + +// PauseInput is what pause takes. +type PauseInput struct { +} + +// PauseAnswer is what pause answers. +type PauseAnswer = map[string]any + +// ResumeInput is what resume takes. +type ResumeInput struct { +} + +// ResumeAnswer is what resume answers. +type ResumeAnswer = map[string]any + +// DeliveryAccept is the body of delivery. +type DeliveryAccept = StepAsk + +// CheckAccept is the body of check. +type CheckAccept = StepAsk + +// RecheckAccept is the body of recheck. +type RecheckAccept = StepAsk + +// StepsByNodeIDValue is one value of the state steps under the key .. +type StepsByNodeIDValue = Step + +// PausesByPausedNodeValue is one value of the state pauses under the key paused.. +type PausesByPausedNodeValue = Pause + +// PausesByCapacityNodeValue is one value of the state pauses under the key capacity.. +type PausesByCapacityNodeValue = Capacity + +// ToolchainsByNodeLanguageValue is one value of the state toolchains under the key .. +type ToolchainsByNodeLanguageValue = Toolchain diff --git a/contracts/go/seats/nodebuildimage/contract_test.go b/contracts/go/seats/nodebuildimage/contract_test.go new file mode 100644 index 00000000..75649a4a --- /dev/null +++ b/contracts/go/seats/nodebuildimage/contract_test.go @@ -0,0 +1,68 @@ +// Code generated by seatgen from the seat definitions; DO NOT EDIT. + +package nodebuildimage + +import ( + "encoding/json" + "reflect" + "regexp" + "testing" +) + +// TestFixtures reads every fixture of seats/node-build-image/fixtures: each decodes into its type, a field the type does not +// know is dropped (ADR 0608 §6), and it reads back as its wire says; each subject named is the contract's. +func TestFixtures(t *testing.T) { + cases := []struct { + name, subject, given, wire string + decode func([]byte) (any, error) + subjectIs *regexp.Regexp + }{ + {name: "a running step", subject: "", given: "{\"id\":\"a1b2\",\"node\":\"ace\",\"started\":\"2026-11-05T10:00:00Z\",\"state\":\"running\",\"tool\":\"build\",\"v\":1}", wire: "{\"id\":\"a1b2\",\"node\":\"ace\",\"started\":\"2026-11-05T10:00:00Z\",\"state\":\"running\",\"tool\":\"build\",\"v\":1}", + decode: func(b []byte) (any, error) { var v StepsByNodeIDValue; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil}, + {name: "a paused node", subject: "", given: "{\"at\":\"2026-11-05T10:00:00Z\",\"by\":\"operator@shanks\",\"paused\":true,\"v\":1}", wire: "{\"at\":\"2026-11-05T10:00:00Z\",\"by\":\"operator@shanks\",\"paused\":true,\"v\":1}", + decode: func(b []byte) (any, error) { + var v PausesByPausedNodeValue + err := json.Unmarshal(b, &v) + return v, err + }, subjectIs: nil}, + {name: "a node's capacity", subject: "", given: "{\"configured\":2,\"effective\":2,\"v\":1,\"why\":\"the configured steps fit the cores\"}", wire: "{\"configured\":2,\"effective\":2,\"v\":1,\"why\":\"the configured steps fit the cores\"}", + decode: func(b []byte) (any, error) { + var v PausesByCapacityNodeValue + err := json.Unmarshal(b, &v) + return v, err + }, subjectIs: nil}, + {name: "a node's toolchain", subject: "", given: "{\"language\":\"go\",\"recipe\":\"1\",\"v\":1,\"version\":\"1.26.0\"}", wire: "{\"language\":\"go\",\"recipe\":\"1\",\"v\":1,\"version\":\"1.26.0\"}", + decode: func(b []byte) (any, error) { + var v ToolchainsByNodeLanguageValue + err := json.Unmarshal(b, &v) + return v, err + }, subjectIs: nil}, + {name: "a build ask", subject: "mesh.seat.node-build-image.accept.delivery", given: "{\"asker\":\"mesh-delivery-builder\",\"build\":{\"artifacts\":[{\"artifact\":\"go\"}],\"module\":\"nats\"},\"id\":\"a1b2\",\"kind\":\"build\",\"language\":\"go\"}", wire: "{\"asker\":\"mesh-delivery-builder\",\"build\":{\"artifacts\":[{\"artifact\":\"go\"}],\"module\":\"nats\"},\"id\":\"a1b2\",\"kind\":\"build\",\"language\":\"go\"}", + decode: func(b []byte) (any, error) { var v DeliveryAccept; err := json.Unmarshal(b, &v); return v, err }, subjectIs: regexp.MustCompile("^mesh\\.seat\\.node-build-image\\.accept\\.delivery$")}, + } + for _, c := range cases { + v, err := c.decode([]byte(c.given)) + if err != nil { + t.Errorf("%s: does not decode: %v", c.name, err) + continue + } + back, err := json.Marshal(v) + if err != nil { + t.Errorf("%s: does not encode: %v", c.name, err) + continue + } + var got, want any + if err := json.Unmarshal(back, &got); err != nil { + t.Fatal(err) + } + if err := json.Unmarshal([]byte(c.wire), &want); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(got, want) { + t.Errorf("%s: reads back as %s, want %s", c.name, back, c.wire) + } + if c.subjectIs != nil && !c.subjectIs.MatchString(c.subject) { + t.Errorf("%s: subject %q is not the contract's %s", c.name, c.subject, c.subjectIs) + } + } +} diff --git a/contracts/go/seats/nodebuildimage/go.mod b/contracts/go/seats/nodebuildimage/go.mod new file mode 100644 index 00000000..33cb3f5b --- /dev/null +++ b/contracts/go/seats/nodebuildimage/go.mod @@ -0,0 +1,3 @@ +module git.novox.be/novox/mesh-delivery/contracts/go/seats/nodebuildimage + +go 1.26.0 diff --git a/contracts/go/seats/nodebuildnode/contract.go b/contracts/go/seats/nodebuildnode/contract.go new file mode 100644 index 00000000..ba461926 --- /dev/null +++ b/contracts/go/seats/nodebuildnode/contract.go @@ -0,0 +1,610 @@ +// Code generated by seatgen from the seat definitions; DO NOT EDIT. + +// Package nodebuildnode is the contract of the seat "node-build-node", defined whole (seats/node-build-node/seat.json): The Node build module on a node: it takes the asks of the builder and the checker, runs them as steps and answers. +package nodebuildnode + +import ( + "bytes" + "encoding/json" + "errors" + "strings" + "time" +) + +// Seat is the seat's name. +const Seat = "node-build-node" + +const ( + VerbCurrent = "current" + VerbPause = "pause" + VerbResume = "resume" +) + +const ( + AcceptDelivery = "delivery" + AcceptCheck = "check" + AcceptRecheck = "recheck" +) + +// Verbs is every verb the seat serves, in its definition's order: a new copy each call. +func Verbs() []string { + return []string{VerbCurrent, VerbPause, VerbResume} +} + +// AcceptSubjectDelivery is where delivery is sent. +const AcceptSubjectDelivery = "mesh.seat.node-build-node.accept.delivery" + +// AcceptSubjectCheck is where check is sent. +const AcceptSubjectCheck = "mesh.seat.node-build-node.accept.check" + +// AcceptSubjectRecheck is where recheck is sent. +const AcceptSubjectRecheck = "mesh.seat.node-build-node.accept.recheck" + +const ( + StateSteps = "steps" + StatePauses = "pauses" + StateToolchains = "toolchains" +) + +// StepsByNodeIDKey is the key of one value of the state steps under the form ".". +func StepsByNodeIDKey(node, id string) string { return node + "." + id } + +// ParseStepsByNodeIDKey reads the parts of a key of the form ".", in the order of the form; the last result is false for a key of another form. +func ParseStepsByNodeIDKey(key string) (string, string, bool) { + rest := key + v0, after0, found0 := strings.Cut(rest, ".") + if !found0 || v0 == "" { + return "", "", false + } + rest = after0 + if rest == "" { + return "", "", false + } + v2 := rest + rest = "" + if rest != "" { + return "", "", false + } + return v0, v2, true +} + +// DecodeStepsByNodeIDValue reads one value of the state steps under the form ".", refusing a field StepsByNodeIDValue does not know and any text after the value. +func DecodeStepsByNodeIDValue(raw []byte) (StepsByNodeIDValue, error) { + var v StepsByNodeIDValue + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.DisallowUnknownFields() + if err := dec.Decode(&v); err != nil { + return v, err + } + if dec.More() { + return v, errors.New("text after the value") + } + return v, nil +} + +// PausesByPausedNodeKey is the key of one value of the state pauses under the form "paused.". +func PausesByPausedNodeKey(node string) string { return "paused." + node } + +// ParsePausesByPausedNodeKey reads the parts of a key of the form "paused.", in the order of the form; the last result is false for a key of another form. +func ParsePausesByPausedNodeKey(key string) (string, bool) { + rest := key + if !strings.HasPrefix(rest, "paused.") { + return "", false + } + rest = rest[7:] + if rest == "" { + return "", false + } + v1 := rest + rest = "" + if rest != "" { + return "", false + } + return v1, true +} + +// DecodePausesByPausedNodeValue reads one value of the state pauses under the form "paused.", refusing a field PausesByPausedNodeValue does not know and any text after the value. +func DecodePausesByPausedNodeValue(raw []byte) (PausesByPausedNodeValue, error) { + var v PausesByPausedNodeValue + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.DisallowUnknownFields() + if err := dec.Decode(&v); err != nil { + return v, err + } + if dec.More() { + return v, errors.New("text after the value") + } + return v, nil +} + +// PausesByCapacityNodeKey is the key of one value of the state pauses under the form "capacity.". +func PausesByCapacityNodeKey(node string) string { return "capacity." + node } + +// ParsePausesByCapacityNodeKey reads the parts of a key of the form "capacity.", in the order of the form; the last result is false for a key of another form. +func ParsePausesByCapacityNodeKey(key string) (string, bool) { + rest := key + if !strings.HasPrefix(rest, "capacity.") { + return "", false + } + rest = rest[9:] + if rest == "" { + return "", false + } + v1 := rest + rest = "" + if rest != "" { + return "", false + } + return v1, true +} + +// DecodePausesByCapacityNodeValue reads one value of the state pauses under the form "capacity.", refusing a field PausesByCapacityNodeValue does not know and any text after the value. +func DecodePausesByCapacityNodeValue(raw []byte) (PausesByCapacityNodeValue, error) { + var v PausesByCapacityNodeValue + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.DisallowUnknownFields() + if err := dec.Decode(&v); err != nil { + return v, err + } + if dec.More() { + return v, errors.New("text after the value") + } + return v, nil +} + +// ToolchainsByNodeLanguageKey is the key of one value of the state toolchains under the form ".". +func ToolchainsByNodeLanguageKey(node, language string) string { return node + "." + language } + +// ParseToolchainsByNodeLanguageKey reads the parts of a key of the form ".", in the order of the form; the last result is false for a key of another form. +func ParseToolchainsByNodeLanguageKey(key string) (string, string, bool) { + rest := key + v0, after0, found0 := strings.Cut(rest, ".") + if !found0 || v0 == "" { + return "", "", false + } + rest = after0 + if rest == "" { + return "", "", false + } + v2 := rest + rest = "" + if rest != "" { + return "", "", false + } + return v0, v2, true +} + +// DecodeToolchainsByNodeLanguageValue reads one value of the state toolchains under the form ".", refusing a field ToolchainsByNodeLanguageValue does not know and any text after the value. +func DecodeToolchainsByNodeLanguageValue(raw []byte) (ToolchainsByNodeLanguageValue, error) { + var v ToolchainsByNodeLanguageValue + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.DisallowUnknownFields() + if err := dec.Decode(&v); err != nil { + return v, err + } + if dec.More() { + return v, errors.New("text after the value") + } + return v, nil +} + +// AskArtifact is one artifact of a build ask: built from the fetched tree, or copied from an upstream pin. +type AskArtifact struct { + Artifact string `json:"artifact"` + From string `json:"from,omitempty"` + Seat string `json:"seat,omitempty"` + Version string `json:"version,omitempty"` +} + +// BuildAnswer is a type of the contract. +type BuildAnswer struct { + Artifacts []BuiltArtifact `json:"artifacts"` + Imports []string `json:"imports,omitempty"` + OutOfStep []string `json:"out-of-step,omitempty"` + Toolchain *ToolchainUsed `json:"toolchain,omitempty"` +} + +// BuiltArtifact is one artifact a build made: its digest and the reference it was published under. +type BuiltArtifact struct { + Artifact string `json:"artifact"` + Digest string `json:"digest"` + Reference string `json:"reference,omitempty"` +} + +// Capacity is how many steps the node runs at once. +type Capacity struct { + // when (RFC 3339 time) + At *time.Time `json:"at,omitempty"` + BuildHours string `json:"build-hours,omitempty"` + BuildHoursFault string `json:"build-hours-fault,omitempty"` + ByCpu int64 `json:"by-cpu,omitempty"` + Configured int64 `json:"configured"` + ConfiguredFrom string `json:"configured-from,omitempty"` + CoresPerStep float64 `json:"cores-per-step,omitempty"` + Effective int64 `json:"effective"` + Ineligible string `json:"ineligible,omitempty"` + OutsideHours bool `json:"outside-hours,omitempty"` + QuotaCores float64 `json:"quota-cores,omitempty"` + QuotaFrom string `json:"quota-from,omitempty"` + // the layout version + V int64 `json:"v,omitempty"` + Why string `json:"why"` +} + +// CheckAnswer is a type of the contract. +type CheckAnswer struct { + Fetch FetchAnswer `json:"fetch"` + Levels []LevelAnswer `json:"levels,omitempty"` +} + +// ComposeAnswer is a type of the contract. +type ComposeAnswer struct { + Built bool `json:"built"` + Key string `json:"key,omitempty"` + // the compose-candidate verdict + Verdict any `json:"verdict,omitempty"` +} + +// FetchBesideItem is a type of the contract. +type FetchBesideItem struct { + Commit string `json:"commit"` + Repository string `json:"repository"` +} + +// FetchPackage is the TypeScript module's group package: the group's tagged version of the SDK ({name, version}), placed from the registry in each member whatever its lock names, or the group's package member ({repository, commit, ref}), packed by the fetch and served to the members in place of the registry's. +type FetchPackage struct { + Commit string `json:"commit,omitempty"` + Name string `json:"name,omitempty"` + Ref string `json:"ref,omitempty"` + Repository string `json:"repository,omitempty"` + Version string `json:"version,omitempty"` +} + +// Fetch is what a build module's fetch tool is asked: the repository and the full commit, the pull request's ref when only its head carries the commit, the trunk commit to merge onto for a merge preview, the module's directory and named paths, the mesh packages to build against, and the sibling repositories placed beside. +type Fetch struct { + // the trunk commit a merge preview merges onto + Base string `json:"base,omitempty"` + // the sibling repositories placed beside the fetched tree + Beside []FetchBesideItem `json:"beside,omitempty"` + // the full commit + Commit string `json:"commit"` + // the module's directory in the repository ("." for its root), with paths + Dir string `json:"dir,omitempty"` + // the TypeScript module's group package: the group's tagged version of the SDK ({name, version}), placed from the registry in each member whatever its lock names, or the group's package member ({repository, commit, ref}), packed by the fetch and served to the members in place of the registry's + Package *FetchPackage `json:"package,omitempty"` + // the version (a full commit) of each mesh package to build against, by its module path + Packages map[string]string `json:"packages,omitempty"` + // the paths the module names, relative to dir + Paths []string `json:"paths,omitempty"` + // the pull request's ref, when only its head carries the commit + Ref string `json:"ref,omitempty"` + // / + Repository string `json:"repository"` +} + +// FetchAnswer is what a build module's fetch tool answers, whole: the workspace it made, the commit in it, the module directories found; and, when the call gave a base, the merge preview (the trunk commit, the merge commit, the files that conflict, the paths the merge changes, the compiled programs among them, the tree of each module directory). A head that does not merge cleanly is an answer, not an error: merge is empty and conflicts names the files. The Go module adds the sibling repositories placed and the package versions taken; the TypeScript module the directories installed and refused and the group's package. +type FetchAnswer struct { + Base string `json:"base,omitempty"` + Beside []PlacedBeside `json:"beside,omitempty"` + Changed []string `json:"changed,omitempty"` + Commit string `json:"commit"` + Conflicts []string `json:"conflicts,omitempty"` + Group string `json:"group,omitempty"` + Installed []string `json:"installed,omitempty"` + Merge string `json:"merge,omitempty"` + Modules []string `json:"modules"` + Packages []TakenVersion `json:"packages,omitempty"` + Programs []Program `json:"programs,omitempty"` + Refused []FetchRefusal `json:"refused,omitempty"` + Trees map[string]ModuleTree `json:"trees,omitempty"` + Workspace string `json:"workspace"` +} + +// FetchRefusal is a TypeScript module whose packages the fetch did not install, with why. +type FetchRefusal struct { + Module string `json:"module"` + Reason string `json:"reason"` +} + +// LevelAnswerDigestNeedsItem is a type of the contract. +type LevelAnswerDigestNeedsItem struct { + Needs []string `json:"needs,omitempty"` + Package string `json:"package,omitempty"` +} + +// LevelAnswerDigestPackagesItem is a type of the contract. +type LevelAnswerDigestPackagesItem struct { + Digest string `json:"digest"` + Package string `json:"package"` +} + +// LevelAnswerDigest is a type of the contract. +type LevelAnswerDigest struct { + Beside []string `json:"beside,omitempty"` + JudgedBy string `json:"judged_by,omitempty"` + Level string `json:"level,omitempty"` + Needs []LevelAnswerDigestNeedsItem `json:"needs,omitempty"` + Packages []LevelAnswerDigestPackagesItem `json:"packages,omitempty"` + WithoutTests []string `json:"without_tests,omitempty"` +} + +// LevelAnswerKeptItem is a type of the contract. +type LevelAnswerKeptItem struct { + Digest string `json:"digest,omitempty"` + Package string `json:"package,omitempty"` + Tests int64 `json:"tests,omitempty"` +} + +// LevelAnswerUnmetItem is a type of the contract. +type LevelAnswerUnmetItem struct { + Needs []string `json:"needs,omitempty"` + Package string `json:"package,omitempty"` +} + +// LevelAnswer is one level of a check ask: the digest's answer, the kept verdicts taken, one test result per test step run, and the packages the node could not test. +type LevelAnswer struct { + Digest *LevelAnswerDigest `json:"digest,omitempty"` + Judged string `json:"judged,omitempty"` + Kept []LevelAnswerKeptItem `json:"kept,omitempty"` + Level string `json:"level"` + Tests []any `json:"tests,omitempty"` + Unmet []LevelAnswerUnmetItem `json:"unmet,omitempty"` +} + +// ModuleTree is the tree of one module directory in a merge preview: its git tree, the digest of its manifest, the lock files' digests and the digests of the named paths. +type ModuleTree struct { + Locks map[string]string `json:"locks,omitempty"` + Manifest string `json:"manifest"` + // no longer filled (ADR 0593 section 2); kept so a preview written before still reads + Packages []string `json:"packages,omitempty"` + Paths map[string]string `json:"paths,omitempty"` + Tree string `json:"tree"` +} + +// Pause is whether the node takes no new ask. +type Pause struct { + // when (RFC 3339 time) + At *time.Time `json:"at,omitempty"` + By string `json:"by,omitempty"` + Paused bool `json:"paused"` + // the layout version + V int64 `json:"v,omitempty"` +} + +// PlacedBeside is a sibling repository placed as a plain directory under the workspace's beside directory. +type PlacedBeside struct { + Commit string `json:"commit"` + Dir string `json:"dir"` + Repository string `json:"repository"` +} + +// Program is a changed file that is a compiled program (issue 1577): its path, its executable format, and the commit of the branch that added it when the merge's tree no longer holds it. +type Program struct { + Commit string `json:"commit,omitempty"` + // ELF, Mach-O or PE + Kind string `json:"kind"` + Path string `json:"path"` +} + +// RunAnswer is what a run ask's step answers: whether this run built the program or took the one kept under its key, the key, the program's exit, the last line of its standard output and the first of its standard error. +type RunAnswer struct { + Built bool `json:"built"` + Exit int64 `json:"exit"` + Key string `json:"key"` + // raw when it is JSON, else a JSON string + Output any `json:"output,omitempty"` + Said string `json:"said,omitempty"` +} + +// RunHead is one commit a run's program reads. +type RunHead struct { + Commit string `json:"commit"` + Repository string `json:"repository"` +} + +// RunInput is a run's input by reference: the seat verb the asker serves it on (seat:.), the sha256 of its bytes and its size, at most 256 KiB. +type RunInput struct { + // 64 lowercase hex digits + Digest string `json:"digest"` + // bytes + Size int64 `json:"size"` + Verb string `json:"verb"` +} + +// RunJob is a run ask's job: where its program is built, the heads it reads (each laid read-only at its repository's path), its input by reference, its arguments and what it is for. +type RunJob struct { + // the program's arguments, each a word or a flag + Args []string `json:"args,omitempty"` + // the commits the program reads, a repository at most once + Heads []RunHead `json:"heads"` + Input RunInput `json:"input"` + Program RunProgram `json:"program"` + // what the run is for, for a reader of the ask + Reader string `json:"reader,omitempty"` +} + +// RunProgram is where a run's program is built: the repository and full commit fetched, the Go module's directory in it and the program's import path. +type RunProgram struct { + Commit string `json:"commit"` + Dir string `json:"dir"` + Package string `json:"package"` + Repository string `json:"repository"` +} + +// Step is one step a build module runs on a node. +type Step struct { + // when (RFC 3339 time) + Ended *time.Time `json:"ended,omitempty"` + ID string `json:"id"` + Node string `json:"node"` + Owner string `json:"owner,omitempty"` + Phase string `json:"phase,omitempty"` + // when (RFC 3339 time) + PhaseAt *time.Time `json:"phase-at,omitempty"` + Reason string `json:"reason,omitempty"` + Result *StepResult `json:"result,omitempty"` + // when (RFC 3339 time) + Started time.Time `json:"started"` + // running, passed, failed or refused + State string `json:"state"` + Tool string `json:"tool"` + // the layout version + V int64 `json:"v,omitempty"` + What string `json:"what,omitempty"` +} + +// StepAskBuild is a type of the contract. +type StepAskBuild struct { + Artifacts []AskArtifact `json:"artifacts"` + Fetch *Fetch `json:"fetch,omitempty"` + Fp string `json:"fp,omitempty"` + Module string `json:"module"` + Path string `json:"path,omitempty"` + Publish bool `json:"publish,omitempty"` + Toolchain *ToolchainUsed `json:"toolchain,omitempty"` +} + +// StepAskCheck is a type of the contract. +type StepAskCheck struct { + Fetch Fetch `json:"fetch"` + Kept bool `json:"kept,omitempty"` + Levels []string `json:"levels"` + // the node names the check's tests run against + NodeNames []string `json:"node-names,omitempty"` + Packages []string `json:"packages,omitempty"` + Run string `json:"run,omitempty"` + Runner string `json:"runner,omitempty"` + RunnerWhen string `json:"runner_when,omitempty"` + // the toolchain the checker agreed for the language (ADR 0584 clause 2): a node on another declines the check + Toolchain *ToolchainUsed `json:"toolchain,omitempty"` +} + +// StepAskCompose is a type of the contract. +type StepAskCompose struct { + Dir string `json:"dir"` + Fetch Fetch `json:"fetch"` + Package string `json:"package"` + Reader string `json:"reader,omitempty"` + // the compose-candidate request + Request any `json:"request"` +} + +// StepAsk is one ask of a build module. +type StepAsk struct { + Asker string `json:"asker"` + // when (RFC 3339 time) + At *time.Time `json:"at,omitempty"` + Build *StepAskBuild `json:"build,omitempty"` + Check *StepAskCheck `json:"check,omitempty"` + Compose *StepAskCompose `json:"compose,omitempty"` + ID string `json:"id"` + // build, check or compose + Kind string `json:"kind"` + // go, typescript or image + Language string `json:"language"` + Needs []string `json:"needs,omitempty"` + // package to the tagged version a build pins + Packages map[string]string `json:"packages,omitempty"` + // a run: a program built from a change and run in the build seat's sandbox over the heads, its input asked of a verb of the asker's seat; the Go seat's check stream alone carries it, as a compose + Run *RunJob `json:"run,omitempty"` + // the layout version + V int64 `json:"v,omitempty"` +} + +// StepResult is what a step answered, by the kind of ask: a build's artifacts, toolchain, imports and out-of-step modules; a check's fetch and levels; a compose's built, key and verdict; a run's built, key, exit, output and said; a declined ask's declined, needs and lacks. The fields of the kinds do not meet, so one layout holds the five. +type StepResult struct { + Artifacts []BuiltArtifact `json:"artifacts,omitempty"` + Built bool `json:"built,omitempty"` + // the holder declined the ask: its node lacks what the ask needs + Declined bool `json:"declined,omitempty"` + // a run's program's exit status + Exit int64 `json:"exit,omitempty"` + Fetch *FetchAnswer `json:"fetch,omitempty"` + Imports []string `json:"imports,omitempty"` + Key string `json:"key,omitempty"` + // what the holder's node lacks of it + Lacks []string `json:"lacks,omitempty"` + Levels []LevelAnswer `json:"levels,omitempty"` + // what the declined ask needs + Needs []string `json:"needs,omitempty"` + OutOfStep []string `json:"out-of-step,omitempty"` + // a run's program's last line of standard output: raw when it is JSON, else a JSON string + Output any `json:"output,omitempty"` + // a run's program's first line of standard error, or why the step could not run it + Said string `json:"said,omitempty"` + Toolchain *ToolchainUsed `json:"toolchain,omitempty"` + // the compose-candidate verdict (the composer's own layout) + Verdict any `json:"verdict,omitempty"` +} + +// TakenVersion is of a directory whose go.mod requires an asked mesh package, the version it is built against: the asked one, or go.mod's when it names a newer one, said. +type TakenVersion struct { + Asked string `json:"asked"` + Built string `json:"built"` + Dir string `json:"dir"` + Named string `json:"named"` + Package string `json:"package"` + Said string `json:"said,omitempty"` + Sum string `json:"sum,omitempty"` +} + +// Toolchain is the toolchain a node reports. +type Toolchain struct { + // when (RFC 3339 time) + At *time.Time `json:"at,omitempty"` + // go or typescript + Language string `json:"language"` + Recipe string `json:"recipe"` + // the layout version + V int64 `json:"v,omitempty"` + Version string `json:"version"` +} + +// ToolchainUsed is the toolchain a build was made with: the language, the installed version and the build module's recipe. +type ToolchainUsed struct { + Language string `json:"language"` + Recipe string `json:"recipe"` + Version string `json:"version"` +} + +// CurrentInput is what current takes. +type CurrentInput struct { +} + +// CurrentAnswer is what current answers. +type CurrentAnswer = map[string]any + +// PauseInput is what pause takes. +type PauseInput struct { +} + +// PauseAnswer is what pause answers. +type PauseAnswer = map[string]any + +// ResumeInput is what resume takes. +type ResumeInput struct { +} + +// ResumeAnswer is what resume answers. +type ResumeAnswer = map[string]any + +// DeliveryAccept is the body of delivery. +type DeliveryAccept = StepAsk + +// CheckAccept is the body of check. +type CheckAccept = StepAsk + +// RecheckAccept is the body of recheck. +type RecheckAccept = StepAsk + +// StepsByNodeIDValue is one value of the state steps under the key .. +type StepsByNodeIDValue = Step + +// PausesByPausedNodeValue is one value of the state pauses under the key paused.. +type PausesByPausedNodeValue = Pause + +// PausesByCapacityNodeValue is one value of the state pauses under the key capacity.. +type PausesByCapacityNodeValue = Capacity + +// ToolchainsByNodeLanguageValue is one value of the state toolchains under the key .. +type ToolchainsByNodeLanguageValue = Toolchain diff --git a/contracts/go/seats/nodebuildnode/contract_test.go b/contracts/go/seats/nodebuildnode/contract_test.go new file mode 100644 index 00000000..fd58eeb3 --- /dev/null +++ b/contracts/go/seats/nodebuildnode/contract_test.go @@ -0,0 +1,72 @@ +// Code generated by seatgen from the seat definitions; DO NOT EDIT. + +package nodebuildnode + +import ( + "encoding/json" + "reflect" + "regexp" + "testing" +) + +// TestFixtures reads every fixture of seats/node-build-node/fixtures: each decodes into its type, a field the type does not +// know is dropped (ADR 0608 §6), and it reads back as its wire says; each subject named is the contract's. +func TestFixtures(t *testing.T) { + cases := []struct { + name, subject, given, wire string + decode func([]byte) (any, error) + subjectIs *regexp.Regexp + }{ + {name: "a running step", subject: "", given: "{\"id\":\"a1b2\",\"node\":\"ace\",\"started\":\"2026-11-05T10:00:00Z\",\"state\":\"running\",\"tool\":\"build\",\"v\":1}", wire: "{\"id\":\"a1b2\",\"node\":\"ace\",\"started\":\"2026-11-05T10:00:00Z\",\"state\":\"running\",\"tool\":\"build\",\"v\":1}", + decode: func(b []byte) (any, error) { var v StepsByNodeIDValue; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil}, + {name: "a paused node", subject: "", given: "{\"at\":\"2026-11-05T10:00:00Z\",\"by\":\"operator@shanks\",\"paused\":true,\"v\":1}", wire: "{\"at\":\"2026-11-05T10:00:00Z\",\"by\":\"operator@shanks\",\"paused\":true,\"v\":1}", + decode: func(b []byte) (any, error) { + var v PausesByPausedNodeValue + err := json.Unmarshal(b, &v) + return v, err + }, subjectIs: nil}, + {name: "a node's capacity", subject: "", given: "{\"configured\":2,\"effective\":2,\"v\":1,\"why\":\"the configured steps fit the cores\"}", wire: "{\"configured\":2,\"effective\":2,\"v\":1,\"why\":\"the configured steps fit the cores\"}", + decode: func(b []byte) (any, error) { + var v PausesByCapacityNodeValue + err := json.Unmarshal(b, &v) + return v, err + }, subjectIs: nil}, + {name: "a node's toolchain", subject: "", given: "{\"language\":\"go\",\"recipe\":\"1\",\"v\":1,\"version\":\"1.26.0\"}", wire: "{\"language\":\"go\",\"recipe\":\"1\",\"v\":1,\"version\":\"1.26.0\"}", + decode: func(b []byte) (any, error) { + var v ToolchainsByNodeLanguageValue + err := json.Unmarshal(b, &v) + return v, err + }, subjectIs: nil}, + {name: "a build ask", subject: "mesh.seat.node-build-node.accept.delivery", given: "{\"asker\":\"mesh-delivery-builder\",\"build\":{\"artifacts\":[{\"artifact\":\"go\"}],\"module\":\"nats\"},\"id\":\"a1b2\",\"kind\":\"build\",\"language\":\"go\"}", wire: "{\"asker\":\"mesh-delivery-builder\",\"build\":{\"artifacts\":[{\"artifact\":\"go\"}],\"module\":\"nats\"},\"id\":\"a1b2\",\"kind\":\"build\",\"language\":\"go\"}", + decode: func(b []byte) (any, error) { var v DeliveryAccept; err := json.Unmarshal(b, &v); return v, err }, subjectIs: regexp.MustCompile("^mesh\\.seat\\.node-build-node\\.accept\\.delivery$")}, + {name: "a check ask with the group's tagged package", subject: "mesh.seat.node-build-node.accept.check", given: "{\"asker\":\"mesh-delivery-checker\",\"at\":\"2026-11-05T10:00:00Z\",\"check\":{\"fetch\":{\"base\":\"89abcdef0123456789abcdef0123456789abcdef\",\"commit\":\"0123456789abcdef0123456789abcdef01234567\",\"package\":{\"name\":\"@novox/mesh-sdk\",\"version\":\"0.1.15-feat-x.1\"},\"ref\":\"refs/pull/7/head\",\"repository\":\"novox/mesh-delivery\"},\"kept\":true,\"levels\":[\"unit\",\"integration\"],\"node-names\":[\"ace\",\"g14\"],\"toolchain\":{\"language\":\"typescript\",\"recipe\":\"1\",\"version\":\"26.12.0\"}},\"id\":\"c3d5\",\"kind\":\"check\",\"language\":\"typescript\",\"v\":1}", wire: "{\"asker\":\"mesh-delivery-checker\",\"at\":\"2026-11-05T10:00:00Z\",\"check\":{\"fetch\":{\"base\":\"89abcdef0123456789abcdef0123456789abcdef\",\"commit\":\"0123456789abcdef0123456789abcdef01234567\",\"package\":{\"name\":\"@novox/mesh-sdk\",\"version\":\"0.1.15-feat-x.1\"},\"ref\":\"refs/pull/7/head\",\"repository\":\"novox/mesh-delivery\"},\"kept\":true,\"levels\":[\"unit\",\"integration\"],\"node-names\":[\"ace\",\"g14\"],\"toolchain\":{\"language\":\"typescript\",\"recipe\":\"1\",\"version\":\"26.12.0\"}},\"id\":\"c3d5\",\"kind\":\"check\",\"language\":\"typescript\",\"v\":1}", + decode: func(b []byte) (any, error) { var v CheckAccept; err := json.Unmarshal(b, &v); return v, err }, subjectIs: regexp.MustCompile("^mesh\\.seat\\.node-build-node\\.accept\\.check$")}, + {name: "a finished check step of a TypeScript merge preview", subject: "", given: "{\"ended\":\"2026-11-05T10:01:00Z\",\"id\":\"c3d6\",\"node\":\"ace\",\"result\":{\"fetch\":{\"base\":\"abcdef0123456789abcdef0123456789abcdef01\",\"changed\":[\"modules/m/package.json\"],\"commit\":\"0123456789abcdef0123456789abcdef01234567\",\"merge\":\"0123456789abcdef0123456789abcdef01234567\",\"modules\":[\"modules/m\"],\"trees\":{\"modules/m\":{\"manifest\":\"0581b699d9d05517e69b21f5cedbeb2eee8aba2b205280b561be731594d7feef\",\"tree\":\"0123456789abcdef0123456789abcdef01234567\"}},\"workspace\":\"mvii1sxt979f67\"}},\"started\":\"2026-11-05T10:00:00Z\",\"state\":\"passed\",\"tool\":\"check\",\"v\":1}", wire: "{\"ended\":\"2026-11-05T10:01:00Z\",\"id\":\"c3d6\",\"node\":\"ace\",\"result\":{\"fetch\":{\"base\":\"abcdef0123456789abcdef0123456789abcdef01\",\"changed\":[\"modules/m/package.json\"],\"commit\":\"0123456789abcdef0123456789abcdef01234567\",\"merge\":\"0123456789abcdef0123456789abcdef01234567\",\"modules\":[\"modules/m\"],\"trees\":{\"modules/m\":{\"manifest\":\"0581b699d9d05517e69b21f5cedbeb2eee8aba2b205280b561be731594d7feef\",\"tree\":\"0123456789abcdef0123456789abcdef01234567\"}},\"workspace\":\"mvii1sxt979f67\"}},\"started\":\"2026-11-05T10:00:00Z\",\"state\":\"passed\",\"tool\":\"check\",\"v\":1}", + decode: func(b []byte) (any, error) { var v StepsByNodeIDValue; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil}, + } + for _, c := range cases { + v, err := c.decode([]byte(c.given)) + if err != nil { + t.Errorf("%s: does not decode: %v", c.name, err) + continue + } + back, err := json.Marshal(v) + if err != nil { + t.Errorf("%s: does not encode: %v", c.name, err) + continue + } + var got, want any + if err := json.Unmarshal(back, &got); err != nil { + t.Fatal(err) + } + if err := json.Unmarshal([]byte(c.wire), &want); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(got, want) { + t.Errorf("%s: reads back as %s, want %s", c.name, back, c.wire) + } + if c.subjectIs != nil && !c.subjectIs.MatchString(c.subject) { + t.Errorf("%s: subject %q is not the contract's %s", c.name, c.subject, c.subjectIs) + } + } +} diff --git a/contracts/go/seats/nodebuildnode/go.mod b/contracts/go/seats/nodebuildnode/go.mod new file mode 100644 index 00000000..836a6dc8 --- /dev/null +++ b/contracts/go/seats/nodebuildnode/go.mod @@ -0,0 +1,3 @@ +module git.novox.be/novox/mesh-delivery/contracts/go/seats/nodebuildnode + +go 1.26.0 diff --git a/contracts/go/seats/npmpackageregistry/contract.go b/contracts/go/seats/npmpackageregistry/contract.go new file mode 100644 index 00000000..5f39cb8d --- /dev/null +++ b/contracts/go/seats/npmpackageregistry/contract.go @@ -0,0 +1,262 @@ +// Code generated by seatgen from the seat definitions; DO NOT EDIT. + +// Package npmpackageregistry is the contract of the seat "npm-package-registry", defined whole (seats/npm-package-registry/seat.json): The mesh's npm packages: the npm registry protocol's read side, and publishing over the bus. +package npmpackageregistry + +// Seat is the seat's name. +const Seat = "npm-package-registry" + +const ( + VerbPublish = "publish" + VerbPublishPart = "publish-part" + VerbVersions = "versions" + VerbRemove = "remove" +) + +const ( + EventPublished = "published" + EventRefused = "refused" + EventRemoved = "removed" + EventKept = "kept" +) + +// Verbs is every verb the seat serves, in its definition's order: a new copy each call. +func Verbs() []string { + return []string{VerbPublish, VerbPublishPart, VerbVersions, VerbRemove} +} + +// Events is every event published on the seat's subject: a new copy each call. +func Events() []string { + return []string{EventPublished, EventRefused, EventRemoved, EventKept} +} + +// Family is where the seat's events are kept: the shared events stream, filtered to the seat's subjects. +const ( + Family = "npm-package-registry.events" + FamilyMaxAgeDays = 7 +) + +// SubjectPublished is where published is published. +const SubjectPublished = "mesh.seat.npm-package-registry.event.published" + +// SubjectRefused is where refused is published. +const SubjectRefused = "mesh.seat.npm-package-registry.event.refused" + +// SubjectRemoved is where removed is published. +const SubjectRemoved = "mesh.seat.npm-package-registry.event.removed" + +// SubjectKept is where kept is published. +const SubjectKept = "mesh.seat.npm-package-registry.event.kept" + +// Artifact is one published thing, as every registry event and answer names it. +type Artifact struct { + // the source commit it was built from + Commit string `json:"commit,omitempty"` + // its digest, sha256: (a Go package's h1: hash beside it in the version's record) + Digest string `json:"digest"` + // the build's fingerprint + Fingerprint string `json:"fingerprint,omitempty"` + // what it is + Kind string `json:"kind"` + // the package's, image's, bundle's or archive's name; a package's is its name in its own language (design 32): a Go package's the go.mod module path (git.novox.be/novox/mesh-sdk/go), an npm package's package.json's name (@novox/mesh-sdk) + Name string `json:"name"` + // the tag it carries, for a tagged version + Tag string `json:"tag,omitempty"` + // the version the builder gave it (design 51 *The versions*); empty for a bundle or archive published by its digest alone + Version string `json:"version,omitempty"` +} + +// The values of Artifact's kind. +const ( + ArtifactKindPackage = "package" + ArtifactKindImage = "image" + ArtifactKindBundle = "bundle" + ArtifactKindArchive = "archive" + ArtifactKindUpstream = "upstream" +) + +// Outcome is the body of published, refused and removed. +type Outcome struct { + // the source commit it was built from + Commit string `json:"commit,omitempty"` + // its digest, sha256: (a Go package's h1: hash beside it in the version's record) + Digest string `json:"digest"` + // the build's fingerprint + Fingerprint string `json:"fingerprint,omitempty"` + // the module holding the seat that said it + Holder string `json:"holder"` + // what it is + Kind string `json:"kind"` + // the Go module path a Go package is fetched under, equal to name for a Go package (go-package-registry's published); empty for every other kind of artifact + Module string `json:"module,omitempty"` + // the package's, image's, bundle's or archive's name; a package's is its name in its own language (design 32): a Go package's the go.mod module path (git.novox.be/novox/mesh-sdk/go), an npm package's package.json's name (@novox/mesh-sdk) + Name string `json:"name"` + // why: a refusal's cause, a removal's (retention, tag-gone, by-hand), or why a removal was kept (shared-digest) + Reason string `json:"reason,omitempty"` + // the reference the seat answers: //@sha256:, or @ for a package (ADR 0605 §9) + Reference string `json:"reference,omitempty"` + // the tag it carries, for a tagged version + Tag string `json:"tag,omitempty"` + // for kept: the other tags still naming its digest + Tags []string `json:"tags,omitempty"` + // the transfer it ends, when it ended one + Transfer string `json:"transfer,omitempty"` + // the version the builder gave it (design 51 *The versions*); empty for a bundle or archive published by its digest alone + Version string `json:"version,omitempty"` +} + +// The values of Outcome's kind. +const ( + OutcomeKindPackage = "package" + OutcomeKindImage = "image" + OutcomeKindBundle = "bundle" + OutcomeKindArchive = "archive" + OutcomeKindUpstream = "upstream" +) + +// Part is one part of a transfer. +type Part struct { + // the part's digest, sha256:: an image's layer or config, a package's file, a bundle's blob + Digest string `json:"digest"` + // its size in bytes + Size int64 `json:"size"` +} + +// PublishInput is what publish takes. +type PublishInput struct { + // the source commit it was built from + Commit string `json:"commit,omitempty"` + // its digest, sha256: (a Go package's h1: hash beside it in the version's record) + Digest string `json:"digest"` + // the build's fingerprint + Fingerprint string `json:"fingerprint,omitempty"` + // what it is + Kind string `json:"kind"` + // the package's, image's, bundle's or archive's name; a package's is its name in its own language (design 32): a Go package's the go.mod module path (git.novox.be/novox/mesh-sdk/go), an npm package's package.json's name (@novox/mesh-sdk) + Name string `json:"name"` + // every part's digest and size; none when the digest is already held + Parts []Part `json:"parts,omitempty"` + // the tag it carries, for a tagged version + Tag string `json:"tag,omitempty"` + // the transfer's name, the caller's own; the same name opens the same transfer again + Transfer string `json:"transfer"` + // the version the builder gave it (design 51 *The versions*); empty for a bundle or archive published by its digest alone + Version string `json:"version,omitempty"` +} + +// The values of PublishInput's kind. +const ( + PublishInputKindPackage = "package" + PublishInputKindImage = "image" + PublishInputKindBundle = "bundle" + PublishInputKindArchive = "archive" + PublishInputKindUpstream = "upstream" +) + +// PublishAnswer is what publish answers. +type PublishAnswer struct { + // the digest is held already: nothing to send; the outcome follows as an event + Held bool `json:"held,omitempty"` + // the digests of the parts the holder lacks, to send by publish-part + Lacks []string `json:"lacks,omitempty"` + // the transfer + Transfer string `json:"transfer"` +} + +// PublishPartInput is what publish-part takes. +type PublishPartInput struct { + // the chunk's bytes, base64 (at most 760 KiB before encoding) + Data string `json:"data"` + // this chunk is the part's last + Last bool `json:"last"` + // the part's digest + Part string `json:"part"` + // the chunk's place in its part, from 0 + Sequence int64 `json:"sequence"` + // the transfer + Transfer string `json:"transfer"` +} + +// PublishPartAnswer is what publish-part answers. +type PublishPartAnswer struct { + // the part is whole and its bytes match its digest + Complete bool `json:"complete,omitempty"` + // the part + Part string `json:"part"` + // the chunks of the part held so far + Received int64 `json:"received,omitempty"` + // the transfer + Transfer string `json:"transfer"` +} + +// VersionsInput is what versions takes. +type VersionsInput struct { + // narrowed to one kind (optional) + Kind string `json:"kind,omitempty"` + // the name + Name string `json:"name"` +} + +// VersionsAnswer is what versions answers. +type VersionsAnswer struct { + // the name + Name string `json:"name"` + // newest first + Versions []Artifact `json:"versions"` +} + +// RemoveInput is what remove takes. +type RemoveInput struct { + // the digest (for a bundle, archive or copied base) + Digest string `json:"digest,omitempty"` + // the name + Name string `json:"name"` + // why it goes + Reason string `json:"reason"` + // the tag (for a tagged version) + Tag string `json:"tag,omitempty"` + // the version + Version string `json:"version,omitempty"` +} + +// The values of RemoveInput's reason. +const ( + RemoveInputReasonTagGone = "tag-gone" + RemoveInputReasonRetention = "retention" +) + +// RemoveAnswer is what remove answers. +type RemoveAnswer struct { + Artifact *Artifact `json:"artifact,omitempty"` + // it stays whole, its tag and its bytes: the store cannot remove the tag alone while another tag names its digest (reason, tags) + Kept bool `json:"kept,omitempty"` + // why it was kept: shared-digest, another tag names its digest + Reason string `json:"reason,omitempty"` + // it is gone + Removed bool `json:"removed"` + // the other tags still naming its digest, when kept + Tags []string `json:"tags,omitempty"` +} + +// The values of RemoveAnswer's reason. +const ( + RemoveAnswerReasonSharedDigest = "shared-digest" +) + +// PublishedEvent is the body of the event published. +type PublishedEvent = Outcome + +// RefusedEvent is the body of the event refused. +type RefusedEvent = Outcome + +// RemovedEvent is the body of the event removed. +type RemovedEvent = Outcome + +// KeptEvent is the body of the event kept. +type KeptEvent = Outcome + +// NpmPackageRegistryForm is the form of the provision npm-package-registry. +type NpmPackageRegistryForm struct { + // the read side's address + URL string `json:"url"` +} diff --git a/contracts/go/seats/npmpackageregistry/contract_test.go b/contracts/go/seats/npmpackageregistry/contract_test.go new file mode 100644 index 00000000..3092b370 --- /dev/null +++ b/contracts/go/seats/npmpackageregistry/contract_test.go @@ -0,0 +1,66 @@ +// Code generated by seatgen from the seat definitions; DO NOT EDIT. + +package npmpackageregistry + +import ( + "encoding/json" + "reflect" + "regexp" + "testing" +) + +// TestFixtures reads every fixture of seats/npm-package-registry/fixtures: each decodes into its type, a field the type does not +// know is dropped (ADR 0608 §6), and it reads back as its wire says; each subject named is the contract's. +func TestFixtures(t *testing.T) { + cases := []struct { + name, subject, given, wire string + decode func([]byte) (any, error) + subjectIs *regexp.Regexp + }{ + {name: "published on the seat's subject", subject: "mesh.seat.npm-package-registry.event.published", given: "{\"commit\":\"cccccccccccccccccccccccccccccccccccccccc\",\"digest\":\"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"fingerprint\":\"f1\",\"holder\":\"gitea\",\"kind\":\"package\",\"name\":\"@novox/mesh-sdk\",\"reference\":\"@novox/mesh-sdk@0.1.19\",\"transfer\":\"t1\",\"version\":\"0.1.19\"}", wire: "{\"commit\":\"cccccccccccccccccccccccccccccccccccccccc\",\"digest\":\"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"fingerprint\":\"f1\",\"holder\":\"gitea\",\"kind\":\"package\",\"name\":\"@novox/mesh-sdk\",\"reference\":\"@novox/mesh-sdk@0.1.19\",\"transfer\":\"t1\",\"version\":\"0.1.19\"}", + decode: func(b []byte) (any, error) { var v PublishedEvent; err := json.Unmarshal(b, &v); return v, err }, subjectIs: regexp.MustCompile("^mesh\\.seat\\.npm-package-registry\\.event\\.published$")}, + {name: "refused on the seat's subject", subject: "mesh.seat.npm-package-registry.event.refused", given: "{\"commit\":\"cccccccccccccccccccccccccccccccccccccccc\",\"digest\":\"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"fingerprint\":\"f1\",\"holder\":\"gitea\",\"kind\":\"package\",\"name\":\"@novox/mesh-sdk\",\"reason\":\"retention\",\"reference\":\"@novox/mesh-sdk@0.1.19\",\"transfer\":\"t1\",\"version\":\"0.1.19\"}", wire: "{\"commit\":\"cccccccccccccccccccccccccccccccccccccccc\",\"digest\":\"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"fingerprint\":\"f1\",\"holder\":\"gitea\",\"kind\":\"package\",\"name\":\"@novox/mesh-sdk\",\"reason\":\"retention\",\"reference\":\"@novox/mesh-sdk@0.1.19\",\"transfer\":\"t1\",\"version\":\"0.1.19\"}", + decode: func(b []byte) (any, error) { var v RefusedEvent; err := json.Unmarshal(b, &v); return v, err }, subjectIs: regexp.MustCompile("^mesh\\.seat\\.npm-package-registry\\.event\\.refused$")}, + {name: "removed on the seat's subject", subject: "mesh.seat.npm-package-registry.event.removed", given: "{\"commit\":\"cccccccccccccccccccccccccccccccccccccccc\",\"digest\":\"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"fingerprint\":\"f1\",\"holder\":\"gitea\",\"kind\":\"package\",\"name\":\"@novox/mesh-sdk\",\"reason\":\"retention\",\"reference\":\"@novox/mesh-sdk@0.1.19\",\"transfer\":\"t1\",\"version\":\"0.1.19\"}", wire: "{\"commit\":\"cccccccccccccccccccccccccccccccccccccccc\",\"digest\":\"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"fingerprint\":\"f1\",\"holder\":\"gitea\",\"kind\":\"package\",\"name\":\"@novox/mesh-sdk\",\"reason\":\"retention\",\"reference\":\"@novox/mesh-sdk@0.1.19\",\"transfer\":\"t1\",\"version\":\"0.1.19\"}", + decode: func(b []byte) (any, error) { var v RemovedEvent; err := json.Unmarshal(b, &v); return v, err }, subjectIs: regexp.MustCompile("^mesh\\.seat\\.npm-package-registry\\.event\\.removed$")}, + {name: "kept on the seat's subject: a tagged version sharing its digest stays, naming the tags on it", subject: "mesh.seat.npm-package-registry.event.kept", given: "{\"commit\":\"cccccccccccccccccccccccccccccccccccccccc\",\"digest\":\"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"fingerprint\":\"f1\",\"holder\":\"gitea\",\"kind\":\"package\",\"name\":\"@novox/mesh-sdk\",\"reason\":\"shared-digest\",\"reference\":\"@novox/mesh-sdk@0.1.19\",\"tag\":\"0.1.19-feat-x.2\",\"tags\":[\"0.1.19\"],\"version\":\"0.1.19-feat-x.2\"}", wire: "{\"commit\":\"cccccccccccccccccccccccccccccccccccccccc\",\"digest\":\"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"fingerprint\":\"f1\",\"holder\":\"gitea\",\"kind\":\"package\",\"name\":\"@novox/mesh-sdk\",\"reason\":\"shared-digest\",\"reference\":\"@novox/mesh-sdk@0.1.19\",\"tag\":\"0.1.19-feat-x.2\",\"tags\":[\"0.1.19\"],\"version\":\"0.1.19-feat-x.2\"}", + decode: func(b []byte) (any, error) { var v KeptEvent; err := json.Unmarshal(b, &v); return v, err }, subjectIs: regexp.MustCompile("^mesh\\.seat\\.npm-package-registry\\.event\\.kept$")}, + {name: "publish opens a transfer", subject: "", given: "{\"commit\":\"cccccccccccccccccccccccccccccccccccccccc\",\"digest\":\"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"fingerprint\":\"f1\",\"kind\":\"package\",\"name\":\"@novox/mesh-sdk\",\"parts\":[{\"digest\":\"sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\",\"size\":1048576}],\"transfer\":\"t1\",\"version\":\"0.1.19\"}", wire: "{\"commit\":\"cccccccccccccccccccccccccccccccccccccccc\",\"digest\":\"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"fingerprint\":\"f1\",\"kind\":\"package\",\"name\":\"@novox/mesh-sdk\",\"parts\":[{\"digest\":\"sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\",\"size\":1048576}],\"transfer\":\"t1\",\"version\":\"0.1.19\"}", + decode: func(b []byte) (any, error) { var v PublishInput; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil}, + {name: "a field the reader does not know is dropped", subject: "", given: "{\"commit\":\"cccccccccccccccccccccccccccccccccccccccc\",\"digest\":\"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"fingerprint\":\"f1\",\"kind\":\"package\",\"later\":{\"x\":1},\"name\":\"@novox/mesh-sdk\",\"parts\":[{\"digest\":\"sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\",\"size\":1048576}],\"transfer\":\"t1\",\"version\":\"0.1.19\"}", wire: "{\"commit\":\"cccccccccccccccccccccccccccccccccccccccc\",\"digest\":\"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"fingerprint\":\"f1\",\"kind\":\"package\",\"name\":\"@novox/mesh-sdk\",\"parts\":[{\"digest\":\"sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\",\"size\":1048576}],\"transfer\":\"t1\",\"version\":\"0.1.19\"}", + decode: func(b []byte) (any, error) { var v PublishInput; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil}, + {name: "publish answers what it lacks", subject: "", given: "{\"lacks\":[\"sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\"],\"transfer\":\"t1\"}", wire: "{\"lacks\":[\"sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\"],\"transfer\":\"t1\"}", + decode: func(b []byte) (any, error) { var v PublishAnswer; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil}, + {name: "a chunk of a part", subject: "", given: "{\"data\":\"aGVsbG8=\",\"last\":true,\"part\":\"sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\",\"sequence\":0,\"transfer\":\"t1\"}", wire: "{\"data\":\"aGVsbG8=\",\"last\":true,\"part\":\"sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\",\"sequence\":0,\"transfer\":\"t1\"}", + decode: func(b []byte) (any, error) { var v PublishPartInput; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil}, + {name: "the versions held", subject: "", given: "{\"name\":\"@novox/mesh-sdk\",\"versions\":[{\"commit\":\"cccccccccccccccccccccccccccccccccccccccc\",\"digest\":\"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"fingerprint\":\"f1\",\"kind\":\"package\",\"name\":\"@novox/mesh-sdk\",\"version\":\"0.1.19\"}]}", wire: "{\"name\":\"@novox/mesh-sdk\",\"versions\":[{\"commit\":\"cccccccccccccccccccccccccccccccccccccccc\",\"digest\":\"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"fingerprint\":\"f1\",\"kind\":\"package\",\"name\":\"@novox/mesh-sdk\",\"version\":\"0.1.19\"}]}", + decode: func(b []byte) (any, error) { var v VersionsAnswer; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil}, + {name: "a tagged version sharing its digest with another tag is kept, not removed", subject: "", given: "{\"kept\":true,\"reason\":\"shared-digest\",\"removed\":false,\"tags\":[\"0.1.19\"]}", wire: "{\"kept\":true,\"reason\":\"shared-digest\",\"removed\":false,\"tags\":[\"0.1.19\"]}", + decode: func(b []byte) (any, error) { var v RemoveAnswer; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil}, + } + for _, c := range cases { + v, err := c.decode([]byte(c.given)) + if err != nil { + t.Errorf("%s: does not decode: %v", c.name, err) + continue + } + back, err := json.Marshal(v) + if err != nil { + t.Errorf("%s: does not encode: %v", c.name, err) + continue + } + var got, want any + if err := json.Unmarshal(back, &got); err != nil { + t.Fatal(err) + } + if err := json.Unmarshal([]byte(c.wire), &want); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(got, want) { + t.Errorf("%s: reads back as %s, want %s", c.name, back, c.wire) + } + if c.subjectIs != nil && !c.subjectIs.MatchString(c.subject) { + t.Errorf("%s: subject %q is not the contract's %s", c.name, c.subject, c.subjectIs) + } + } +} diff --git a/contracts/go/seats/npmpackageregistry/go.mod b/contracts/go/seats/npmpackageregistry/go.mod new file mode 100644 index 00000000..acddbbfb --- /dev/null +++ b/contracts/go/seats/npmpackageregistry/go.mod @@ -0,0 +1,3 @@ +module git.novox.be/novox/mesh-delivery/contracts/go/seats/npmpackageregistry + +go 1.26.0 diff --git a/contracts/go/seats/ociartifactregistry/contract.go b/contracts/go/seats/ociartifactregistry/contract.go new file mode 100644 index 00000000..8d2a7e6a --- /dev/null +++ b/contracts/go/seats/ociartifactregistry/contract.go @@ -0,0 +1,486 @@ +// Code generated by seatgen from the seat definitions; DO NOT EDIT. + +// Package ociartifactregistry is the contract of the seat "oci-artifact-registry", defined whole (seats/oci-artifact-registry/seat.json): The mesh's bundles and archives, by digest, in the one artifact store: the OCI distribution protocol's read side. +package ociartifactregistry + +import ( + "bytes" + "encoding/json" + "errors" + "strings" +) + +// Seat is the seat's name. +const Seat = "oci-artifact-registry" + +const ( + VerbPublish = "publish" + VerbPublishPart = "publish-part" + VerbVersions = "versions" + VerbRemove = "remove" + VerbHold = "hold" + VerbRelease = "release" + VerbList = "list" +) + +const ( + EventPublished = "published" + EventRefused = "refused" + EventRemoved = "removed" + EventKept = "kept" + EventHeld = "held" + EventReleased = "released" +) + +// Verbs is every verb the seat serves, in its definition's order: a new copy each call. +func Verbs() []string { + return []string{VerbPublish, VerbPublishPart, VerbVersions, VerbRemove, VerbHold, VerbRelease, VerbList} +} + +// Events is every event published on the seat's subject: a new copy each call. +func Events() []string { + return []string{EventPublished, EventRefused, EventRemoved, EventKept, EventHeld, EventReleased} +} + +// Family is where the seat's events are kept: the shared events stream, filtered to the seat's subjects. +const ( + Family = "oci-artifact-registry.events" + FamilyMaxAgeDays = 7 +) + +// SubjectPublished is where published is published. +const SubjectPublished = "mesh.seat.oci-artifact-registry.event.published" + +// SubjectRefused is where refused is published. +const SubjectRefused = "mesh.seat.oci-artifact-registry.event.refused" + +// SubjectRemoved is where removed is published. +const SubjectRemoved = "mesh.seat.oci-artifact-registry.event.removed" + +// SubjectKept is where kept is published. +const SubjectKept = "mesh.seat.oci-artifact-registry.event.kept" + +// SubjectHeld is where held is published. +const SubjectHeld = "mesh.seat.oci-artifact-registry.event.held" + +// SubjectReleased is where released is published. +const SubjectReleased = "mesh.seat.oci-artifact-registry.event.released" + +const ( + StateHolds = "holds" + StateWindows = "windows" +) + +// HoldsBySeatNameDigestKey is the key of one value of the state holds under the form "..". +func HoldsBySeatNameDigestKey(seat, name, digest string) string { + return seat + "." + name + "." + digest +} + +// ParseHoldsBySeatNameDigestKey reads the parts of a key of the form "..", in the order of the form; the last result is false for a key of another form. +func ParseHoldsBySeatNameDigestKey(key string) (string, string, string, bool) { + rest := key + v0, after0, found0 := strings.Cut(rest, ".") + if !found0 || v0 == "" { + return "", "", "", false + } + rest = after0 + v2, after2, found2 := strings.Cut(rest, ".") + if !found2 || v2 == "" { + return "", "", "", false + } + rest = after2 + if rest == "" { + return "", "", "", false + } + v4 := rest + rest = "" + if rest != "" { + return "", "", "", false + } + return v0, v2, v4, true +} + +// DecodeHoldsBySeatNameDigestValue reads one value of the state holds under the form "..", refusing a field HoldsBySeatNameDigestValue does not know and any text after the value. +func DecodeHoldsBySeatNameDigestValue(raw []byte) (HoldsBySeatNameDigestValue, error) { + var v HoldsBySeatNameDigestValue + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.DisallowUnknownFields() + if err := dec.Decode(&v); err != nil { + return v, err + } + if dec.More() { + return v, errors.New("text after the value") + } + return v, nil +} + +// WindowsByCallerKey is the key of one value of the state windows under the form "". +func WindowsByCallerKey(caller string) string { return caller } + +// ParseWindowsByCallerKey reads the parts of a key of the form "", in the order of the form; the last result is false for a key of another form. +func ParseWindowsByCallerKey(key string) (string, bool) { + rest := key + if rest == "" { + return "", false + } + v0 := rest + rest = "" + if rest != "" { + return "", false + } + return v0, true +} + +// DecodeWindowsByCallerValue reads one value of the state windows under the form "", refusing a field WindowsByCallerValue does not know and any text after the value. +func DecodeWindowsByCallerValue(raw []byte) (WindowsByCallerValue, error) { + var v WindowsByCallerValue + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.DisallowUnknownFields() + if err := dec.Decode(&v); err != nil { + return v, err + } + if dec.More() { + return v, errors.New("text after the value") + } + return v, nil +} + +// Artifact is one published thing, as every registry event and answer names it. +type Artifact struct { + // the source commit it was built from + Commit string `json:"commit,omitempty"` + // its digest, sha256: (a Go package's h1: hash beside it in the version's record) + Digest string `json:"digest"` + // the build's fingerprint + Fingerprint string `json:"fingerprint,omitempty"` + // what it is + Kind string `json:"kind"` + // the package's, image's, bundle's or archive's name; a package's is its name in its own language (design 32): a Go package's the go.mod module path (git.novox.be/novox/mesh-sdk/go), an npm package's package.json's name (@novox/mesh-sdk) + Name string `json:"name"` + // the tag it carries, for a tagged version + Tag string `json:"tag,omitempty"` + // the version the builder gave it (design 51 *The versions*); empty for a bundle or archive published by its digest alone + Version string `json:"version,omitempty"` +} + +// The values of Artifact's kind. +const ( + ArtifactKindPackage = "package" + ArtifactKindImage = "image" + ArtifactKindBundle = "bundle" + ArtifactKindArchive = "archive" + ArtifactKindUpstream = "upstream" +) + +// Hold is the body of held and released. +type Hold struct { + // when, RFC 3339 + At string `json:"at,omitempty"` + // the caller the holder proved + By string `json:"by,omitempty"` + // the digest + Digest string `json:"digest"` + // the module holding the seat + Holder string `json:"holder"` + // the name + Name string `json:"name"` + // why + Reason string `json:"reason,omitempty"` +} + +// Listed is a type of the contract. +type Listed struct { + // for a manifest keeping one bundle or archive, the digest of that blob: what a reference names it by + Artifact string `json:"artifact,omitempty"` + // the manifest's digest, sha256: + Digest string `json:"digest"` + // a hold keeps it from retention + Held bool `json:"held"` + // it is an index, naming a manifest per platform + Index bool `json:"index"` + // for an index, the digests of the manifests it names + Platforms []string `json:"platforms,omitempty"` + // its bytes: its own content and every blob it names + Size int64 `json:"size"` + // the tags naming it, none for an untagged one + Tags []string `json:"tags,omitempty"` +} + +// Outcome is the body of published, refused and removed. +type Outcome struct { + // the source commit it was built from + Commit string `json:"commit,omitempty"` + // its digest, sha256: (a Go package's h1: hash beside it in the version's record) + Digest string `json:"digest"` + // the build's fingerprint + Fingerprint string `json:"fingerprint,omitempty"` + // the module holding the seat that said it + Holder string `json:"holder"` + // what it is + Kind string `json:"kind"` + // the Go module path a Go package is fetched under, equal to name for a Go package (go-package-registry's published); empty for every other kind of artifact + Module string `json:"module,omitempty"` + // the package's, image's, bundle's or archive's name; a package's is its name in its own language (design 32): a Go package's the go.mod module path (git.novox.be/novox/mesh-sdk/go), an npm package's package.json's name (@novox/mesh-sdk) + Name string `json:"name"` + // why: a refusal's cause, a removal's (retention, tag-gone, by-hand), or why a removal was kept (shared-digest) + Reason string `json:"reason,omitempty"` + // the reference the seat answers: //@sha256:, or @ for a package (ADR 0605 §9) + Reference string `json:"reference,omitempty"` + // the tag it carries, for a tagged version + Tag string `json:"tag,omitempty"` + // for kept: the other tags still naming its digest + Tags []string `json:"tags,omitempty"` + // the transfer it ends, when it ended one + Transfer string `json:"transfer,omitempty"` + // the version the builder gave it (design 51 *The versions*); empty for a bundle or archive published by its digest alone + Version string `json:"version,omitempty"` +} + +// The values of Outcome's kind. +const ( + OutcomeKindPackage = "package" + OutcomeKindImage = "image" + OutcomeKindBundle = "bundle" + OutcomeKindArchive = "archive" + OutcomeKindUpstream = "upstream" +) + +// Part is one part of a transfer. +type Part struct { + // the part's digest, sha256:: an image's layer or config, a package's file, a bundle's blob + Digest string `json:"digest"` + // its size in bytes + Size int64 `json:"size"` +} + +// Repository is a type of the contract. +type Repository struct { + // each manifest it holds, by digest + Manifests []Listed `json:"manifests"` + // the repository + Name string `json:"name"` +} + +// PublishInput is what publish takes. +type PublishInput struct { + // the source commit it was built from + Commit string `json:"commit,omitempty"` + // its digest, sha256: (a Go package's h1: hash beside it in the version's record) + Digest string `json:"digest"` + // the build's fingerprint + Fingerprint string `json:"fingerprint,omitempty"` + // what it is + Kind string `json:"kind"` + // the package's, image's, bundle's or archive's name; a package's is its name in its own language (design 32): a Go package's the go.mod module path (git.novox.be/novox/mesh-sdk/go), an npm package's package.json's name (@novox/mesh-sdk) + Name string `json:"name"` + // every part's digest and size; none when the digest is already held + Parts []Part `json:"parts,omitempty"` + // the tag it carries, for a tagged version + Tag string `json:"tag,omitempty"` + // the transfer's name, the caller's own; the same name opens the same transfer again + Transfer string `json:"transfer"` + // the version the builder gave it (design 51 *The versions*); empty for a bundle or archive published by its digest alone + Version string `json:"version,omitempty"` +} + +// The values of PublishInput's kind. +const ( + PublishInputKindPackage = "package" + PublishInputKindImage = "image" + PublishInputKindBundle = "bundle" + PublishInputKindArchive = "archive" + PublishInputKindUpstream = "upstream" +) + +// PublishAnswer is what publish answers. +type PublishAnswer struct { + // the digest is held already: nothing to send; the outcome follows as an event + Held bool `json:"held,omitempty"` + // the digests of the parts the holder lacks, to send by publish-part + Lacks []string `json:"lacks,omitempty"` + // the transfer + Transfer string `json:"transfer"` +} + +// PublishPartInput is what publish-part takes. +type PublishPartInput struct { + // the chunk's bytes, base64 (at most 760 KiB before encoding) + Data string `json:"data"` + // this chunk is the part's last + Last bool `json:"last"` + // the part's digest + Part string `json:"part"` + // the chunk's place in its part, from 0 + Sequence int64 `json:"sequence"` + // the transfer + Transfer string `json:"transfer"` +} + +// PublishPartAnswer is what publish-part answers. +type PublishPartAnswer struct { + // the part is whole and its bytes match its digest + Complete bool `json:"complete,omitempty"` + // the part + Part string `json:"part"` + // the chunks of the part held so far + Received int64 `json:"received,omitempty"` + // the transfer + Transfer string `json:"transfer"` +} + +// VersionsInput is what versions takes. +type VersionsInput struct { + // narrowed to one kind (optional) + Kind string `json:"kind,omitempty"` + // the name + Name string `json:"name"` +} + +// VersionsAnswer is what versions answers. +type VersionsAnswer struct { + // the name + Name string `json:"name"` + // newest first + Versions []Artifact `json:"versions"` +} + +// RemoveInput is what remove takes. +type RemoveInput struct { + // the digest (for a bundle, archive or copied base) + Digest string `json:"digest,omitempty"` + // the name + Name string `json:"name"` + // why it goes + Reason string `json:"reason"` + // the tag (for a tagged version) + Tag string `json:"tag,omitempty"` + // the version + Version string `json:"version,omitempty"` +} + +// The values of RemoveInput's reason. +const ( + RemoveInputReasonTagGone = "tag-gone" + RemoveInputReasonRetention = "retention" +) + +// RemoveAnswer is what remove answers. +type RemoveAnswer struct { + Artifact *Artifact `json:"artifact,omitempty"` + // it stays whole, its tag and its bytes: the store cannot remove the tag alone while another tag names its digest (reason, tags) + Kept bool `json:"kept,omitempty"` + // why it was kept: shared-digest, another tag names its digest + Reason string `json:"reason,omitempty"` + // it is gone + Removed bool `json:"removed"` + // the other tags still naming its digest, when kept + Tags []string `json:"tags,omitempty"` +} + +// The values of RemoveAnswer's reason. +const ( + RemoveAnswerReasonSharedDigest = "shared-digest" +) + +// HoldInput is what hold takes. +type HoldInput struct { + // the digest + Digest string `json:"digest"` + // the name + Name string `json:"name"` + // which of the sweep's rules keeps it + Reason string `json:"reason"` +} + +// HoldAnswer is what hold answers. +type HoldAnswer struct { + // it is held + Held bool `json:"held"` + // when the hold began, RFC 3339 + Since string `json:"since,omitempty"` +} + +// ReleaseInput is what release takes. +type ReleaseInput struct { + // the digest + Digest string `json:"digest"` + // the name + Name string `json:"name"` + // why it is let go + Reason string `json:"reason"` +} + +// ReleaseAnswer is what release answers. +type ReleaseAnswer struct { + // a hold was let go; false when there was none + Released bool `json:"released"` +} + +// ListInput is what list takes. +type ListInput struct { + // the repository, /; every repository of the seat when not given + Name string `json:"name,omitempty"` +} + +// ListAnswer is what list answers. +type ListAnswer struct { + // each repository named, in order of its name + Repositories []Repository `json:"repositories"` +} + +// PublishedEvent is the body of the event published. +type PublishedEvent = Outcome + +// RefusedEvent is the body of the event refused. +type RefusedEvent = Outcome + +// RemovedEvent is the body of the event removed. +type RemovedEvent = Outcome + +// KeptEvent is the body of the event kept. +type KeptEvent = Outcome + +// HeldEvent is the body of the event held. +type HeldEvent = Hold + +// ReleasedEvent is the body of the event released. +type ReleasedEvent = Hold + +// HoldsBySeatNameDigestValue is one value of the state holds under the key ... +type HoldsBySeatNameDigestValue struct { + By string `json:"by"` + Digest string `json:"digest"` + Kind string `json:"kind"` + Name string `json:"name"` + Seat string `json:"seat"` + // when (RFC 3339 time) + Since string `json:"since"` + Why string `json:"why,omitempty"` +} + +// WindowsByCallerValueDroppedItem is a type of the contract. +type WindowsByCallerValueDroppedItem struct { + // when (RFC 3339 time) + At string `json:"at,omitempty"` + By string `json:"by,omitempty"` + Node string `json:"node"` + Why string `json:"why,omitempty"` +} + +// WindowsByCallerValue is one value of the state windows under the key . +type WindowsByCallerValue struct { + // when it last closed (RFC 3339 time) + Closed string `json:"closed,omitempty"` + // retired nodes dropped by hand + Dropped []WindowsByCallerValueDroppedItem `json:"dropped,omitempty"` + // the nodes holding it open + Holding []string `json:"holding,omitempty"` + LastHolding []string `json:"last_holding,omitempty"` + // the caller whose window it is + Name string `json:"name"` + Open bool `json:"open,omitempty"` + // node to when it began to hold + Since map[string]string `json:"since,omitempty"` + // of each node holding the window, why it holds it + Why map[string]string `json:"why,omitempty"` +} diff --git a/contracts/go/seats/ociartifactregistry/contract_test.go b/contracts/go/seats/ociartifactregistry/contract_test.go new file mode 100644 index 00000000..afd6ee2a --- /dev/null +++ b/contracts/go/seats/ociartifactregistry/contract_test.go @@ -0,0 +1,82 @@ +// Code generated by seatgen from the seat definitions; DO NOT EDIT. + +package ociartifactregistry + +import ( + "encoding/json" + "reflect" + "regexp" + "testing" +) + +// TestFixtures reads every fixture of seats/oci-artifact-registry/fixtures: each decodes into its type, a field the type does not +// know is dropped (ADR 0608 §6), and it reads back as its wire says; each subject named is the contract's. +func TestFixtures(t *testing.T) { + cases := []struct { + name, subject, given, wire string + decode func([]byte) (any, error) + subjectIs *regexp.Regexp + }{ + {name: "published on the seat's subject", subject: "mesh.seat.oci-artifact-registry.event.published", given: "{\"commit\":\"cccccccccccccccccccccccccccccccccccccccc\",\"digest\":\"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"fingerprint\":\"f1\",\"holder\":\"distribution\",\"kind\":\"bundle\",\"name\":\"mesh-sdk\",\"reference\":\"oci-artifact-registry/mesh-sdk/lib@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"transfer\":\"t1\",\"version\":\"0.1.19\"}", wire: "{\"commit\":\"cccccccccccccccccccccccccccccccccccccccc\",\"digest\":\"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"fingerprint\":\"f1\",\"holder\":\"distribution\",\"kind\":\"bundle\",\"name\":\"mesh-sdk\",\"reference\":\"oci-artifact-registry/mesh-sdk/lib@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"transfer\":\"t1\",\"version\":\"0.1.19\"}", + decode: func(b []byte) (any, error) { var v PublishedEvent; err := json.Unmarshal(b, &v); return v, err }, subjectIs: regexp.MustCompile("^mesh\\.seat\\.oci-artifact-registry\\.event\\.published$")}, + {name: "refused on the seat's subject", subject: "mesh.seat.oci-artifact-registry.event.refused", given: "{\"commit\":\"cccccccccccccccccccccccccccccccccccccccc\",\"digest\":\"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"fingerprint\":\"f1\",\"holder\":\"distribution\",\"kind\":\"bundle\",\"name\":\"mesh-sdk\",\"reason\":\"retention\",\"reference\":\"oci-artifact-registry/mesh-sdk/lib@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"transfer\":\"t1\",\"version\":\"0.1.19\"}", wire: "{\"commit\":\"cccccccccccccccccccccccccccccccccccccccc\",\"digest\":\"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"fingerprint\":\"f1\",\"holder\":\"distribution\",\"kind\":\"bundle\",\"name\":\"mesh-sdk\",\"reason\":\"retention\",\"reference\":\"oci-artifact-registry/mesh-sdk/lib@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"transfer\":\"t1\",\"version\":\"0.1.19\"}", + decode: func(b []byte) (any, error) { var v RefusedEvent; err := json.Unmarshal(b, &v); return v, err }, subjectIs: regexp.MustCompile("^mesh\\.seat\\.oci-artifact-registry\\.event\\.refused$")}, + {name: "removed on the seat's subject", subject: "mesh.seat.oci-artifact-registry.event.removed", given: "{\"commit\":\"cccccccccccccccccccccccccccccccccccccccc\",\"digest\":\"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"fingerprint\":\"f1\",\"holder\":\"distribution\",\"kind\":\"bundle\",\"name\":\"mesh-sdk\",\"reason\":\"retention\",\"reference\":\"oci-artifact-registry/mesh-sdk/lib@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"transfer\":\"t1\",\"version\":\"0.1.19\"}", wire: "{\"commit\":\"cccccccccccccccccccccccccccccccccccccccc\",\"digest\":\"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"fingerprint\":\"f1\",\"holder\":\"distribution\",\"kind\":\"bundle\",\"name\":\"mesh-sdk\",\"reason\":\"retention\",\"reference\":\"oci-artifact-registry/mesh-sdk/lib@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"transfer\":\"t1\",\"version\":\"0.1.19\"}", + decode: func(b []byte) (any, error) { var v RemovedEvent; err := json.Unmarshal(b, &v); return v, err }, subjectIs: regexp.MustCompile("^mesh\\.seat\\.oci-artifact-registry\\.event\\.removed$")}, + {name: "kept on the seat's subject: a tagged version sharing its digest stays, naming the tags on it", subject: "mesh.seat.oci-artifact-registry.event.kept", given: "{\"commit\":\"cccccccccccccccccccccccccccccccccccccccc\",\"digest\":\"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"fingerprint\":\"f1\",\"holder\":\"distribution\",\"kind\":\"bundle\",\"name\":\"mesh-sdk\",\"reason\":\"shared-digest\",\"reference\":\"oci-artifact-registry/mesh-sdk/lib@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"tag\":\"0.1.19-feat-x.2\",\"tags\":[\"0.1.19\"],\"version\":\"0.1.19-feat-x.2\"}", wire: "{\"commit\":\"cccccccccccccccccccccccccccccccccccccccc\",\"digest\":\"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"fingerprint\":\"f1\",\"holder\":\"distribution\",\"kind\":\"bundle\",\"name\":\"mesh-sdk\",\"reason\":\"shared-digest\",\"reference\":\"oci-artifact-registry/mesh-sdk/lib@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"tag\":\"0.1.19-feat-x.2\",\"tags\":[\"0.1.19\"],\"version\":\"0.1.19-feat-x.2\"}", + decode: func(b []byte) (any, error) { var v KeptEvent; err := json.Unmarshal(b, &v); return v, err }, subjectIs: regexp.MustCompile("^mesh\\.seat\\.oci-artifact-registry\\.event\\.kept$")}, + {name: "held on the seat's subject", subject: "mesh.seat.oci-artifact-registry.event.held", given: "{\"at\":\"2026-11-04T12:00:00Z\",\"by\":\"mesh-artifact-sweep\",\"digest\":\"sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\",\"holder\":\"distribution\",\"name\":\"mesh-sdk\",\"reason\":\"kept by the sweep's rule\"}", wire: "{\"at\":\"2026-11-04T12:00:00Z\",\"by\":\"mesh-artifact-sweep\",\"digest\":\"sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\",\"holder\":\"distribution\",\"name\":\"mesh-sdk\",\"reason\":\"kept by the sweep's rule\"}", + decode: func(b []byte) (any, error) { var v HeldEvent; err := json.Unmarshal(b, &v); return v, err }, subjectIs: regexp.MustCompile("^mesh\\.seat\\.oci-artifact-registry\\.event\\.held$")}, + {name: "released on the seat's subject", subject: "mesh.seat.oci-artifact-registry.event.released", given: "{\"at\":\"2026-11-04T12:00:00Z\",\"by\":\"mesh-artifact-sweep\",\"digest\":\"sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\",\"holder\":\"distribution\",\"name\":\"mesh-sdk\",\"reason\":\"kept by the sweep's rule\"}", wire: "{\"at\":\"2026-11-04T12:00:00Z\",\"by\":\"mesh-artifact-sweep\",\"digest\":\"sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\",\"holder\":\"distribution\",\"name\":\"mesh-sdk\",\"reason\":\"kept by the sweep's rule\"}", + decode: func(b []byte) (any, error) { var v ReleasedEvent; err := json.Unmarshal(b, &v); return v, err }, subjectIs: regexp.MustCompile("^mesh\\.seat\\.oci-artifact-registry\\.event\\.released$")}, + {name: "a hold", subject: "", given: "{\"by\":\"operator@shanks\",\"digest\":\"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"kind\":\"base\",\"name\":\"base\",\"seat\":\"oci-artifact-registry\",\"since\":\"2026-11-05T10:00:00Z\"}", wire: "{\"by\":\"operator@shanks\",\"digest\":\"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"kind\":\"base\",\"name\":\"base\",\"seat\":\"oci-artifact-registry\",\"since\":\"2026-11-05T10:00:00Z\"}", + decode: func(b []byte) (any, error) { + var v HoldsBySeatNameDigestValue + err := json.Unmarshal(b, &v) + return v, err + }, subjectIs: nil}, + {name: "an open window", subject: "", given: "{\"holding\":[\"ace\"],\"name\":\"mesh-delivery-builder\",\"open\":true,\"since\":{\"ace\":\"2026-11-05T10:00:00Z\"}}", wire: "{\"holding\":[\"ace\"],\"name\":\"mesh-delivery-builder\",\"open\":true,\"since\":{\"ace\":\"2026-11-05T10:00:00Z\"}}", + decode: func(b []byte) (any, error) { var v WindowsByCallerValue; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil}, + {name: "publish opens a transfer", subject: "", given: "{\"commit\":\"cccccccccccccccccccccccccccccccccccccccc\",\"digest\":\"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"fingerprint\":\"f1\",\"kind\":\"bundle\",\"name\":\"mesh-sdk\",\"parts\":[{\"digest\":\"sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\",\"size\":1048576}],\"transfer\":\"t1\",\"version\":\"0.1.19\"}", wire: "{\"commit\":\"cccccccccccccccccccccccccccccccccccccccc\",\"digest\":\"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"fingerprint\":\"f1\",\"kind\":\"bundle\",\"name\":\"mesh-sdk\",\"parts\":[{\"digest\":\"sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\",\"size\":1048576}],\"transfer\":\"t1\",\"version\":\"0.1.19\"}", + decode: func(b []byte) (any, error) { var v PublishInput; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil}, + {name: "a field the reader does not know is dropped", subject: "", given: "{\"commit\":\"cccccccccccccccccccccccccccccccccccccccc\",\"digest\":\"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"fingerprint\":\"f1\",\"kind\":\"bundle\",\"later\":{\"x\":1},\"name\":\"mesh-sdk\",\"parts\":[{\"digest\":\"sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\",\"size\":1048576}],\"transfer\":\"t1\",\"version\":\"0.1.19\"}", wire: "{\"commit\":\"cccccccccccccccccccccccccccccccccccccccc\",\"digest\":\"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"fingerprint\":\"f1\",\"kind\":\"bundle\",\"name\":\"mesh-sdk\",\"parts\":[{\"digest\":\"sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\",\"size\":1048576}],\"transfer\":\"t1\",\"version\":\"0.1.19\"}", + decode: func(b []byte) (any, error) { var v PublishInput; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil}, + {name: "publish answers what it lacks", subject: "", given: "{\"lacks\":[\"sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\"],\"transfer\":\"t1\"}", wire: "{\"lacks\":[\"sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\"],\"transfer\":\"t1\"}", + decode: func(b []byte) (any, error) { var v PublishAnswer; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil}, + {name: "a chunk of a part", subject: "", given: "{\"data\":\"aGVsbG8=\",\"last\":true,\"part\":\"sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\",\"sequence\":0,\"transfer\":\"t1\"}", wire: "{\"data\":\"aGVsbG8=\",\"last\":true,\"part\":\"sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\",\"sequence\":0,\"transfer\":\"t1\"}", + decode: func(b []byte) (any, error) { var v PublishPartInput; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil}, + {name: "the versions held", subject: "", given: "{\"name\":\"mesh-sdk\",\"versions\":[{\"commit\":\"cccccccccccccccccccccccccccccccccccccccc\",\"digest\":\"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"fingerprint\":\"f1\",\"kind\":\"package\",\"name\":\"mesh-sdk\",\"version\":\"0.1.19\"}]}", wire: "{\"name\":\"mesh-sdk\",\"versions\":[{\"commit\":\"cccccccccccccccccccccccccccccccccccccccc\",\"digest\":\"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"fingerprint\":\"f1\",\"kind\":\"package\",\"name\":\"mesh-sdk\",\"version\":\"0.1.19\"}]}", + decode: func(b []byte) (any, error) { var v VersionsAnswer; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil}, + {name: "a release of what is not held answers released false", subject: "", given: "{\"released\":false}", wire: "{\"released\":false}", + decode: func(b []byte) (any, error) { var v ReleaseAnswer; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil}, + {name: "a tagged version sharing its digest with another tag is kept, not removed", subject: "", given: "{\"kept\":true,\"reason\":\"shared-digest\",\"removed\":false,\"tags\":[\"0.1.19\"]}", wire: "{\"kept\":true,\"reason\":\"shared-digest\",\"removed\":false,\"tags\":[\"0.1.19\"]}", + decode: func(b []byte) (any, error) { var v RemoveAnswer; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil}, + {name: "list answers an untagged archive by the blob it keeps", subject: "", given: "{\"repositories\":[{\"manifests\":[{\"artifact\":\"sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc\",\"digest\":\"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"held\":true,\"index\":false,\"size\":4096}],\"name\":\"mail/files\"}]}", wire: "{\"repositories\":[{\"manifests\":[{\"artifact\":\"sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc\",\"digest\":\"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"held\":true,\"index\":false,\"size\":4096}],\"name\":\"mail/files\"}]}", + decode: func(b []byte) (any, error) { var v ListAnswer; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil}, + } + for _, c := range cases { + v, err := c.decode([]byte(c.given)) + if err != nil { + t.Errorf("%s: does not decode: %v", c.name, err) + continue + } + back, err := json.Marshal(v) + if err != nil { + t.Errorf("%s: does not encode: %v", c.name, err) + continue + } + var got, want any + if err := json.Unmarshal(back, &got); err != nil { + t.Fatal(err) + } + if err := json.Unmarshal([]byte(c.wire), &want); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(got, want) { + t.Errorf("%s: reads back as %s, want %s", c.name, back, c.wire) + } + if c.subjectIs != nil && !c.subjectIs.MatchString(c.subject) { + t.Errorf("%s: subject %q is not the contract's %s", c.name, c.subject, c.subjectIs) + } + } +} diff --git a/contracts/go/seats/ociartifactregistry/go.mod b/contracts/go/seats/ociartifactregistry/go.mod new file mode 100644 index 00000000..f145223c --- /dev/null +++ b/contracts/go/seats/ociartifactregistry/go.mod @@ -0,0 +1,3 @@ +module git.novox.be/novox/mesh-delivery/contracts/go/seats/ociartifactregistry + +go 1.26.0 diff --git a/contracts/module.json b/contracts/module.json new file mode 100644 index 00000000..8bd9850e --- /dev/null +++ b/contracts/module.json @@ -0,0 +1,321 @@ +{ + "module": "mesh-delivery-contracts", + "version": "1", + "build": { + "artifacts": [ + { + "name": "go-containerimageregistry", + "kind": "package", + "language": "go", + "paths": [ + "go/seats/containerimageregistry" + ], + "series": "0.1", + "generated-from": "." + }, + { + "name": "go-gopackageregistry", + "kind": "package", + "language": "go", + "paths": [ + "go/seats/gopackageregistry" + ], + "series": "0.1", + "generated-from": "." + }, + { + "name": "go-meshartifactsweep", + "kind": "package", + "language": "go", + "paths": [ + "go/seats/meshartifactsweep" + ], + "series": "0.1", + "generated-from": "." + }, + { + "name": "go-meshcatalog", + "kind": "package", + "language": "go", + "paths": [ + "go/seats/meshcatalog" + ], + "series": "0.1", + "generated-from": "." + }, + { + "name": "go-meshdelivery", + "kind": "package", + "language": "go", + "paths": [ + "go/seats/meshdelivery" + ], + "series": "0.1", + "generated-from": "." + }, + { + "name": "go-meshdeliverychecker", + "kind": "package", + "language": "go", + "paths": [ + "go/seats/meshdeliverychecker" + ], + "series": "0.1", + "generated-from": "." + }, + { + "name": "go-nodebuildgo", + "kind": "package", + "language": "go", + "paths": [ + "go/seats/nodebuildgo" + ], + "series": "0.1", + "generated-from": "." + }, + { + "name": "go-nodebuildimage", + "kind": "package", + "language": "go", + "paths": [ + "go/seats/nodebuildimage" + ], + "series": "0.1", + "generated-from": "." + }, + { + "name": "go-nodebuildnode", + "kind": "package", + "language": "go", + "paths": [ + "go/seats/nodebuildnode" + ], + "series": "0.1", + "generated-from": "." + }, + { + "name": "go-npmpackageregistry", + "kind": "package", + "language": "go", + "paths": [ + "go/seats/npmpackageregistry" + ], + "series": "0.1", + "generated-from": "." + }, + { + "name": "go-ociartifactregistry", + "kind": "package", + "language": "go", + "paths": [ + "go/seats/ociartifactregistry" + ], + "series": "0.1", + "generated-from": "." + }, + { + "name": "go-containerimageregistryclient", + "kind": "package", + "language": "go", + "paths": [ + "go/clients/containerimageregistryclient" + ], + "series": "0.1" + }, + { + "name": "go-meshcatalogclient", + "kind": "package", + "language": "go", + "paths": [ + "go/clients/meshcatalogclient" + ], + "series": "0.1" + }, + { + "name": "go-meshdeliveryclient", + "kind": "package", + "language": "go", + "paths": [ + "go/clients/meshdeliveryclient" + ], + "series": "0.1" + }, + { + "name": "go-meshdeliverycheckerclient", + "kind": "package", + "language": "go", + "paths": [ + "go/clients/meshdeliverycheckerclient" + ], + "series": "0.1" + }, + { + "name": "go-gopackageregistryclient", + "kind": "package", + "language": "go", + "paths": [ + "go/clients/gopackageregistryclient" + ], + "series": "0.1" + }, + { + "name": "go-npmpackageregistryclient", + "kind": "package", + "language": "go", + "paths": [ + "go/clients/npmpackageregistryclient" + ], + "series": "0.1" + }, + { + "name": "go-ociartifactregistryclient", + "kind": "package", + "language": "go", + "paths": [ + "go/clients/ociartifactregistryclient" + ], + "series": "0.1" + }, + { + "name": "ts-containerimageregistry", + "kind": "package", + "language": "typescript", + "paths": [ + "ts/seats/containerimageregistry" + ], + "series": "0.1", + "generated-from": "." + }, + { + "name": "ts-gopackageregistry", + "kind": "package", + "language": "typescript", + "paths": [ + "ts/seats/gopackageregistry" + ], + "series": "0.1", + "generated-from": "." + }, + { + "name": "ts-meshartifactsweep", + "kind": "package", + "language": "typescript", + "paths": [ + "ts/seats/meshartifactsweep" + ], + "series": "0.1", + "generated-from": "." + }, + { + "name": "ts-meshcatalog", + "kind": "package", + "language": "typescript", + "paths": [ + "ts/seats/meshcatalog" + ], + "series": "0.1", + "generated-from": "." + }, + { + "name": "ts-meshdelivery", + "kind": "package", + "language": "typescript", + "paths": [ + "ts/seats/meshdelivery" + ], + "series": "0.1", + "generated-from": "." + }, + { + "name": "ts-meshdeliverychecker", + "kind": "package", + "language": "typescript", + "paths": [ + "ts/seats/meshdeliverychecker" + ], + "series": "0.1", + "generated-from": "." + }, + { + "name": "ts-nodebuildgo", + "kind": "package", + "language": "typescript", + "paths": [ + "ts/seats/nodebuildgo" + ], + "series": "0.1", + "generated-from": "." + }, + { + "name": "ts-nodebuildimage", + "kind": "package", + "language": "typescript", + "paths": [ + "ts/seats/nodebuildimage" + ], + "series": "0.1", + "generated-from": "." + }, + { + "name": "ts-nodebuildnode", + "kind": "package", + "language": "typescript", + "paths": [ + "ts/seats/nodebuildnode" + ], + "series": "0.1", + "generated-from": "." + }, + { + "name": "ts-npmpackageregistry", + "kind": "package", + "language": "typescript", + "paths": [ + "ts/seats/npmpackageregistry" + ], + "series": "0.1", + "generated-from": "." + }, + { + "name": "ts-ociartifactregistry", + "kind": "package", + "language": "typescript", + "paths": [ + "ts/seats/ociartifactregistry" + ], + "series": "0.1", + "generated-from": "." + }, + { + "name": "go-meshwants", + "kind": "package", + "language": "go", + "paths": [ + "go/seats/meshwants" + ], + "series": "0.1", + "generated-from": "." + }, + { + "name": "ts-meshwants", + "kind": "package", + "language": "typescript", + "paths": [ + "ts/seats/meshwants" + ], + "series": "0.1", + "generated-from": "." + }, + { + "name": "go-meshwantsclient", + "kind": "package", + "language": "go", + "paths": [ + "go/clients/meshwantsclient" + ], + "series": "0.1" + } + ] + }, + "resources": [] +} diff --git a/contracts/parts/build-step.json b/contracts/parts/build-step.json new file mode 100644 index 00000000..37c20cb4 --- /dev/null +++ b/contracts/parts/build-step.json @@ -0,0 +1,1086 @@ +{ + "format": 1, + "part": "build-step", + "description": "What the three build seats share: the verbs that read and steer a node's builds, and the layouts of the asks they accept and the steps they keep. A seat lists its states against these (a part carries no states).", + "verbs": [ + { + "name": "current", + "description": "The asks this node runs now (each ask's id, kind, module and when it started), its build capacity, and whether it is paused, taking no new ask.", + "input": { + "type": "object", + "properties": {} + }, + "answer": { + "type": "object", + "description": "the steps running, the capacity and whether paused" + } + }, + { + "name": "pause", + "description": "Take no new ask on this node until resumed; an ask running here ends in its place. Kept across a restart of the build module. A capacity of 0 is the lasting way to keep a node from building.", + "input": { + "type": "object", + "properties": {} + }, + "answer": { + "type": "object", + "description": "whether the node is paused" + } + }, + { + "name": "resume", + "description": "Take asks again on this node.", + "input": { + "type": "object", + "properties": {} + }, + "answer": { + "type": "object", + "description": "whether the node is paused" + } + } + ], + "defs": { + "StepAsk": { + "type": "object", + "properties": { + "v": { + "type": "integer", + "description": "the layout version" + }, + "id": { + "type": "string" + }, + "kind": { + "type": "string", + "description": "build, check or compose" + }, + "language": { + "type": "string", + "description": "go, typescript or image" + }, + "asker": { + "type": "string" + }, + "at": { + "type": "string", + "description": "when (RFC 3339 time)", + "format": "date-time" + }, + "build": { + "type": "object", + "properties": { + "module": { + "type": "string" + }, + "path": { + "type": "string" + }, + "fp": { + "type": "string" + }, + "fetch": { + "$ref": "#/defs/Fetch" + }, + "artifacts": { + "type": "array", + "items": { + "$ref": "#/defs/AskArtifact" + } + }, + "publish": { + "type": "boolean" + }, + "toolchain": { + "$ref": "#/defs/ToolchainUsed" + } + }, + "required": [ + "module", + "artifacts" + ] + }, + "check": { + "type": "object", + "properties": { + "fetch": { + "$ref": "#/defs/Fetch" + }, + "levels": { + "type": "array", + "items": { + "type": "string", + "description": "unit or integration" + } + }, + "kept": { + "type": "boolean" + }, + "runner": { + "type": "string" + }, + "runner_when": { + "type": "string" + }, + "run": { + "type": "string" + }, + "packages": { + "type": "array", + "items": { + "type": "string" + } + }, + "toolchain": { + "$ref": "#/defs/ToolchainUsed", + "description": "the toolchain the checker agreed for the language (ADR 0584 clause 2): a node on another declines the check" + }, + "node-names": { + "type": "array", + "items": { + "type": "string" + }, + "description": "the node names the check's tests run against" + } + }, + "required": [ + "fetch", + "levels" + ] + }, + "compose": { + "type": "object", + "properties": { + "fetch": { + "$ref": "#/defs/Fetch" + }, + "dir": { + "type": "string" + }, + "package": { + "type": "string" + }, + "request": { + "description": "the compose-candidate request" + }, + "reader": { + "type": "string" + } + }, + "required": [ + "fetch", + "dir", + "package", + "request" + ] + }, + "run": { + "$ref": "#/defs/RunJob", + "description": "a run: a program built from a change and run in the build seat's sandbox over the heads, its input asked of a verb of the asker's seat; the Go seat's check stream alone carries it, as a compose" + }, + "needs": { + "type": "array", + "items": { + "type": "string" + } + }, + "packages": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "description": "package to the tagged version a build pins" + } + }, + "required": [ + "id", + "kind", + "language", + "asker" + ], + "description": "one ask of a build module" + }, + "BuildAnswer": { + "type": "object", + "properties": { + "artifacts": { + "type": "array", + "items": { + "$ref": "#/defs/BuiltArtifact" + } + }, + "toolchain": { + "$ref": "#/defs/ToolchainUsed" + }, + "imports": { + "type": "array", + "items": { + "type": "string" + } + }, + "out-of-step": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "artifacts" + ] + }, + "CheckAnswer": { + "type": "object", + "properties": { + "fetch": { + "$ref": "#/defs/FetchAnswer" + }, + "levels": { + "type": "array", + "items": { + "$ref": "#/defs/LevelAnswer" + } + } + }, + "required": [ + "fetch" + ] + }, + "ComposeAnswer": { + "type": "object", + "properties": { + "built": { + "type": "boolean" + }, + "key": { + "type": "string" + }, + "verdict": { + "description": "the compose-candidate verdict" + } + }, + "required": [ + "built" + ] + }, + "RunJob": { + "type": "object", + "description": "a run ask's job: where its program is built, the heads it reads (each laid read-only at its repository's path), its input by reference, its arguments and what it is for", + "properties": { + "program": { + "$ref": "#/defs/RunProgram" + }, + "heads": { + "type": "array", + "items": { + "$ref": "#/defs/RunHead" + }, + "description": "the commits the program reads, a repository at most once" + }, + "input": { + "$ref": "#/defs/RunInput" + }, + "args": { + "type": "array", + "items": { + "type": "string" + }, + "description": "the program's arguments, each a word or a flag" + }, + "reader": { + "type": "string", + "description": "what the run is for, for a reader of the ask" + } + }, + "required": [ + "program", + "heads", + "input" + ] + }, + "RunProgram": { + "type": "object", + "description": "where a run's program is built: the repository and full commit fetched, the Go module's directory in it and the program's import path", + "properties": { + "repository": { + "type": "string" + }, + "commit": { + "type": "string" + }, + "dir": { + "type": "string" + }, + "package": { + "type": "string" + } + }, + "required": [ + "repository", + "commit", + "dir", + "package" + ] + }, + "RunHead": { + "type": "object", + "description": "one commit a run's program reads", + "properties": { + "repository": { + "type": "string" + }, + "commit": { + "type": "string" + } + }, + "required": [ + "repository", + "commit" + ] + }, + "RunInput": { + "type": "object", + "description": "a run's input by reference: the seat verb the asker serves it on (seat:.), the sha256 of its bytes and its size, at most 256 KiB", + "properties": { + "verb": { + "type": "string" + }, + "digest": { + "type": "string", + "description": "64 lowercase hex digits" + }, + "size": { + "type": "integer", + "description": "bytes" + } + }, + "required": [ + "verb", + "digest", + "size" + ] + }, + "RunAnswer": { + "type": "object", + "description": "what a run ask's step answers: whether this run built the program or took the one kept under its key, the key, the program's exit, the last line of its standard output and the first of its standard error", + "properties": { + "built": { + "type": "boolean" + }, + "key": { + "type": "string" + }, + "exit": { + "type": "integer" + }, + "output": { + "description": "raw when it is JSON, else a JSON string" + }, + "said": { + "type": "string" + } + }, + "required": [ + "built", + "key", + "exit" + ] + }, + "Step": { + "type": "object", + "properties": { + "v": { + "type": "integer", + "description": "the layout version" + }, + "id": { + "type": "string" + }, + "tool": { + "type": "string" + }, + "node": { + "type": "string" + }, + "state": { + "type": "string", + "description": "running, passed, failed or refused" + }, + "started": { + "type": "string", + "description": "when (RFC 3339 time)", + "format": "date-time" + }, + "ended": { + "type": "string", + "description": "when (RFC 3339 time)", + "format": "date-time" + }, + "reason": { + "type": "string" + }, + "result": { + "$ref": "#/defs/StepResult" + }, + "what": { + "type": "string" + }, + "phase": { + "type": "string" + }, + "phase-at": { + "type": "string", + "description": "when (RFC 3339 time)", + "format": "date-time" + }, + "owner": { + "type": "string" + } + }, + "required": [ + "id", + "tool", + "node", + "state", + "started" + ], + "description": "one step a build module runs on a node" + }, + "Toolchain": { + "type": "object", + "properties": { + "v": { + "type": "integer", + "description": "the layout version" + }, + "language": { + "type": "string", + "description": "go or typescript" + }, + "version": { + "type": "string" + }, + "recipe": { + "type": "string" + }, + "at": { + "type": "string", + "description": "when (RFC 3339 time)", + "format": "date-time" + } + }, + "required": [ + "language", + "version", + "recipe" + ], + "description": "the toolchain a node reports" + }, + "Pause": { + "type": "object", + "properties": { + "v": { + "type": "integer", + "description": "the layout version" + }, + "paused": { + "type": "boolean" + }, + "by": { + "type": "string" + }, + "at": { + "type": "string", + "description": "when (RFC 3339 time)", + "format": "date-time" + } + }, + "required": [ + "paused" + ], + "description": "whether the node takes no new ask" + }, + "Capacity": { + "type": "object", + "properties": { + "v": { + "type": "integer", + "description": "the layout version" + }, + "effective": { + "type": "integer" + }, + "configured": { + "type": "integer" + }, + "configured-from": { + "type": "string" + }, + "quota-cores": { + "type": "number" + }, + "quota-from": { + "type": "string" + }, + "cores-per-step": { + "type": "number" + }, + "by-cpu": { + "type": "integer" + }, + "build-hours": { + "type": "string" + }, + "outside-hours": { + "type": "boolean" + }, + "build-hours-fault": { + "type": "string" + }, + "ineligible": { + "type": "string" + }, + "why": { + "type": "string" + }, + "at": { + "type": "string", + "description": "when (RFC 3339 time)", + "format": "date-time" + } + }, + "required": [ + "effective", + "configured", + "why" + ], + "description": "how many steps the node runs at once" + }, + "Fetch": { + "type": "object", + "description": "what a build module's fetch tool is asked: the repository and the full commit, the pull request's ref when only its head carries the commit, the trunk commit to merge onto for a merge preview, the module's directory and named paths, the mesh packages to build against, and the sibling repositories placed beside", + "properties": { + "repository": { + "type": "string", + "description": "/" + }, + "commit": { + "type": "string", + "description": "the full commit" + }, + "ref": { + "type": "string", + "description": "the pull request's ref, when only its head carries the commit" + }, + "base": { + "type": "string", + "description": "the trunk commit a merge preview merges onto" + }, + "dir": { + "type": "string", + "description": "the module's directory in the repository (\".\" for its root), with paths" + }, + "paths": { + "type": "array", + "items": { + "type": "string" + }, + "description": "the paths the module names, relative to dir" + }, + "packages": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "description": "the version (a full commit) of each mesh package to build against, by its module path" + }, + "package": { + "type": "object", + "description": "the TypeScript module's group package: the group's tagged version of the SDK ({name, version}), placed from the registry in each member whatever its lock names, or the group's package member ({repository, commit, ref}), packed by the fetch and served to the members in place of the registry's", + "properties": { + "name": { "type": "string" }, + "version": { "type": "string" }, + "repository": { "type": "string" }, + "commit": { "type": "string" }, + "ref": { "type": "string" } + } + }, + "beside": { + "type": "array", + "items": { + "type": "object", + "properties": { + "repository": { + "type": "string" + }, + "commit": { + "type": "string" + } + }, + "required": [ + "repository", + "commit" + ] + }, + "description": "the sibling repositories placed beside the fetched tree" + } + }, + "required": [ + "repository", + "commit" + ] + }, + "ToolchainUsed": { + "type": "object", + "properties": { + "language": { + "type": "string" + }, + "version": { + "type": "string" + }, + "recipe": { + "type": "string" + } + }, + "required": [ + "language", + "version", + "recipe" + ], + "description": "the toolchain a build was made with: the language, the installed version and the build module's recipe" + }, + "AskArtifact": { + "type": "object", + "properties": { + "artifact": { + "type": "string" + }, + "from": { + "type": "string" + }, + "seat": { + "type": "string" + }, + "version": { + "type": "string" + } + }, + "required": [ + "artifact" + ], + "description": "one artifact of a build ask: built from the fetched tree, or copied from an upstream pin" + }, + "BuiltArtifact": { + "type": "object", + "properties": { + "artifact": { + "type": "string" + }, + "digest": { + "type": "string" + }, + "reference": { + "type": "string" + } + }, + "required": [ + "artifact", + "digest" + ], + "description": "one artifact a build made: its digest and the reference it was published under" + }, + "LevelAnswer": { + "type": "object", + "properties": { + "level": { + "type": "string" + }, + "judged": { + "type": "string" + }, + "digest": { + "type": "object", + "properties": { + "level": { + "type": "string" + }, + "packages": { + "type": "array", + "items": { + "type": "object", + "properties": { + "package": { + "type": "string" + }, + "digest": { + "type": "string" + } + }, + "required": [ + "package", + "digest" + ] + } + }, + "beside": { + "type": "array", + "items": { + "type": "string" + } + }, + "without_tests": { + "type": "array", + "items": { + "type": "string" + } + }, + "judged_by": { + "type": "string" + }, + "needs": { + "type": "array", + "items": { + "type": "object", + "properties": { + "package": { + "type": "string" + }, + "needs": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + } + }, + "kept": { + "type": "array", + "items": { + "type": "object", + "properties": { + "package": { + "type": "string" + }, + "digest": { + "type": "string" + }, + "tests": { + "type": "integer" + } + } + } + }, + "tests": { + "type": "array", + "items": { + "description": "one test's result" + } + }, + "unmet": { + "type": "array", + "items": { + "type": "object", + "properties": { + "package": { + "type": "string" + }, + "needs": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + }, + "required": [ + "level" + ], + "description": "one level of a check ask: the digest's answer, the kept verdicts taken, one test result per test step run, and the packages the node could not test" + }, + "StepResult": { + "type": "object", + "description": "what a step answered, by the kind of ask: a build's artifacts, toolchain, imports and out-of-step modules; a check's fetch and levels; a compose's built, key and verdict; a run's built, key, exit, output and said; a declined ask's declined, needs and lacks. The fields of the kinds do not meet, so one layout holds the five", + "properties": { + "artifacts": { + "type": "array", + "items": { + "$ref": "#/defs/BuiltArtifact" + } + }, + "toolchain": { + "$ref": "#/defs/ToolchainUsed" + }, + "imports": { + "type": "array", + "items": { + "type": "string" + } + }, + "out-of-step": { + "type": "array", + "items": { + "type": "string" + } + }, + "fetch": { + "$ref": "#/defs/FetchAnswer" + }, + "levels": { + "type": "array", + "items": { + "$ref": "#/defs/LevelAnswer" + } + }, + "built": { + "type": "boolean" + }, + "key": { + "type": "string" + }, + "verdict": { + "description": "the compose-candidate verdict (the composer's own layout)" + }, + "declined": { + "type": "boolean", + "description": "the holder declined the ask: its node lacks what the ask needs" + }, + "needs": { + "type": "array", + "items": { + "type": "string" + }, + "description": "what the declined ask needs" + }, + "lacks": { + "type": "array", + "items": { + "type": "string" + }, + "description": "what the holder's node lacks of it" + }, + "exit": { + "type": "integer", + "description": "a run's program's exit status" + }, + "output": { + "description": "a run's program's last line of standard output: raw when it is JSON, else a JSON string" + }, + "said": { + "type": "string", + "description": "a run's program's first line of standard error, or why the step could not run it" + } + } + }, + "ModuleTree": { + "type": "object", + "description": "the tree of one module directory in a merge preview: its git tree, the digest of its manifest, the lock files' digests and the digests of the named paths", + "properties": { + "tree": { + "type": "string" + }, + "manifest": { + "type": "string" + }, + "locks": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "paths": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "packages": { + "type": "array", + "items": { + "type": "string" + }, + "description": "no longer filled (ADR 0593 section 2); kept so a preview written before still reads" + } + }, + "required": [ + "tree", + "manifest" + ] + }, + "Program": { + "type": "object", + "description": "a changed file that is a compiled program (issue 1577): its path, its executable format, and the commit of the branch that added it when the merge's tree no longer holds it", + "properties": { + "path": { + "type": "string" + }, + "kind": { + "type": "string", + "description": "ELF, Mach-O or PE" + }, + "commit": { + "type": "string" + } + }, + "required": [ + "path", + "kind" + ] + }, + "PlacedBeside": { + "type": "object", + "description": "a sibling repository placed as a plain directory under the workspace's beside directory", + "properties": { + "repository": { + "type": "string" + }, + "commit": { + "type": "string" + }, + "dir": { + "type": "string" + } + }, + "required": [ + "repository", + "commit", + "dir" + ] + }, + "TakenVersion": { + "type": "object", + "description": "of a directory whose go.mod requires an asked mesh package, the version it is built against: the asked one, or go.mod's when it names a newer one, said", + "properties": { + "dir": { + "type": "string" + }, + "package": { + "type": "string" + }, + "asked": { + "type": "string" + }, + "named": { + "type": "string" + }, + "built": { + "type": "string" + }, + "sum": { + "type": "string" + }, + "said": { + "type": "string" + } + }, + "required": [ + "dir", + "package", + "asked", + "named", + "built" + ] + }, + "FetchRefusal": { + "type": "object", + "description": "a TypeScript module whose packages the fetch did not install, with why", + "properties": { + "module": { + "type": "string" + }, + "reason": { + "type": "string" + } + }, + "required": [ + "module", + "reason" + ] + }, + "FetchAnswer": { + "type": "object", + "description": "what a build module's fetch tool answers, whole: the workspace it made, the commit in it, the module directories found; and, when the call gave a base, the merge preview (the trunk commit, the merge commit, the files that conflict, the paths the merge changes, the compiled programs among them, the tree of each module directory). A head that does not merge cleanly is an answer, not an error: merge is empty and conflicts names the files. The Go module adds the sibling repositories placed and the package versions taken; the TypeScript module the directories installed and refused and the group's package", + "properties": { + "workspace": { + "type": "string" + }, + "commit": { + "type": "string" + }, + "modules": { + "type": "array", + "items": { + "type": "string" + } + }, + "base": { + "type": "string" + }, + "merge": { + "type": "string" + }, + "conflicts": { + "type": "array", + "items": { + "type": "string" + } + }, + "changed": { + "type": "array", + "items": { + "type": "string" + } + }, + "programs": { + "type": "array", + "items": { + "$ref": "#/defs/Program" + } + }, + "trees": { + "type": "object", + "additionalProperties": { + "$ref": "#/defs/ModuleTree" + } + }, + "beside": { + "type": "array", + "items": { + "$ref": "#/defs/PlacedBeside" + } + }, + "packages": { + "type": "array", + "items": { + "$ref": "#/defs/TakenVersion" + } + }, + "installed": { + "type": "array", + "items": { + "type": "string" + } + }, + "refused": { + "type": "array", + "items": { + "$ref": "#/defs/FetchRefusal" + } + }, + "group": { + "type": "string" + } + }, + "required": [ + "workspace", + "commit", + "modules" + ] + } + } +} diff --git a/contracts/parts/registry.json b/contracts/parts/registry.json new file mode 100644 index 00000000..ca0022f8 --- /dev/null +++ b/contracts/parts/registry.json @@ -0,0 +1,436 @@ +{ + "format": 1, + "part": "registry", + "description": "The verbs and events every registry seat shares, written once so the four cannot drift (design 51, design 26). Who may call each verb is the holder's trusted config value listing its callers (ADR 0363's form), checked against the caller the tool runner proved.", + "verbs": [ + { + "name": "publish", + "description": "Open a transfer, naming the artifact and the digest of every part; answers the digests the holder lacks, which the caller then sends by publish-part. A transfer already open answers the parts held, so a cut transfer goes on from there. With no parts and a version, the held digest is published under that version and nothing is stored (the trunk's claim).", + "callers": "the build modules", + "input": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "the package's, image's, bundle's or archive's name; a package's is its name in its own language (design 32): a Go package's the go.mod module path (git.novox.be/novox/mesh-sdk/go), an npm package's package.json's name (@novox/mesh-sdk)" + }, + "version": { + "type": "string", + "description": "the version the builder gave it (design 51 *The versions*); empty for a bundle or archive published by its digest alone" + }, + "kind": { + "type": "string", + "enum": [ + "package", + "image", + "bundle", + "archive", + "upstream" + ], + "description": "what it is" + }, + "tag": { + "type": "string", + "description": "the tag it carries, for a tagged version" + }, + "digest": { + "type": "string", + "description": "its digest, sha256: (a Go package's h1: hash beside it in the version's record)" + }, + "fingerprint": { + "type": "string", + "description": "the build's fingerprint" + }, + "commit": { + "type": "string", + "description": "the source commit it was built from" + }, + "transfer": { + "type": "string", + "description": "the transfer's name, the caller's own; the same name opens the same transfer again" + }, + "parts": { + "type": "array", + "items": { + "$ref": "#/defs/Part" + }, + "description": "every part's digest and size; none when the digest is already held" + } + }, + "required": [ + "transfer", + "name", + "kind", + "digest" + ] + }, + "answer": { + "type": "object", + "properties": { + "transfer": { + "type": "string", + "description": "the transfer" + }, + "lacks": { + "type": "array", + "items": { + "type": "string" + }, + "description": "the digests of the parts the holder lacks, to send by publish-part" + }, + "held": { + "type": "boolean", + "description": "the digest is held already: nothing to send; the outcome follows as an event" + } + }, + "required": [ + "transfer" + ] + }, + "optional": true + }, + { + "name": "publish-part", + "description": "Send one chunk of one part of an open transfer; the last chunk of the last part ends the transfer, and the holder hashes what it received, asks builder_versions, stores it and says published or refused. A chunk's message stays under the bus's largest message (1 MiB), so at most 760 KiB of data, base64.", + "callers": "the build modules, and only the proved caller that opened the transfer", + "input": { + "type": "object", + "properties": { + "transfer": { + "type": "string", + "description": "the transfer" + }, + "part": { + "type": "string", + "description": "the part's digest" + }, + "sequence": { + "type": "integer", + "description": "the chunk's place in its part, from 0" + }, + "last": { + "type": "boolean", + "description": "this chunk is the part's last" + }, + "data": { + "type": "string", + "description": "the chunk's bytes, base64 (at most 760 KiB before encoding)" + } + }, + "required": [ + "transfer", + "part", + "sequence", + "last", + "data" + ] + }, + "answer": { + "type": "object", + "properties": { + "transfer": { + "type": "string", + "description": "the transfer" + }, + "part": { + "type": "string", + "description": "the part" + }, + "received": { + "type": "integer", + "description": "the chunks of the part held so far" + }, + "complete": { + "type": "boolean", + "description": "the part is whole and its bytes match its digest" + } + }, + "required": [ + "transfer", + "part" + ] + }, + "optional": true + }, + { + "name": "versions", + "description": "The versions the seat holds of one name, newest first: each one's version, tag, digest, kind and when it was published.", + "callers": "any caller whose role grants it", + "input": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "the name" + }, + "kind": { + "type": "string", + "description": "narrowed to one kind (optional)" + } + }, + "required": [ + "name" + ] + }, + "answer": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "the name" + }, + "versions": { + "type": "array", + "items": { + "$ref": "#/defs/Artifact" + }, + "description": "newest first" + } + }, + "required": [ + "name", + "versions" + ] + }, + "optional": true + }, + { + "name": "remove", + "description": "Remove one tagged version; the holder refuses an untagged one but for the retention removal of a bundle, archive or copied base by the artifact sweep's holder. Every removal emits removed with its reason. A tagged version the store cannot remove alone, because another tag names its digest, stays whole, its tag and bytes kept: the answer says kept with the reason shared-digest and the tags still naming the digest, removed false, and kept is emitted, never removed; the caller ends there without a retry.", + "callers": "the builder, for a tagged version; the artifact sweep's holder, for retention", + "input": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "the name" + }, + "version": { + "type": "string", + "description": "the version" + }, + "tag": { + "type": "string", + "description": "the tag (for a tagged version)" + }, + "digest": { + "type": "string", + "description": "the digest (for a bundle, archive or copied base)" + }, + "reason": { + "type": "string", + "enum": [ + "tag-gone", + "retention" + ], + "description": "why it goes" + } + }, + "required": [ + "name", + "reason" + ] + }, + "answer": { + "type": "object", + "properties": { + "removed": { + "type": "boolean", + "description": "it is gone" + }, + "kept": { + "type": "boolean", + "description": "it stays whole, its tag and its bytes: the store cannot remove the tag alone while another tag names its digest (reason, tags)" + }, + "reason": { + "type": "string", + "enum": [ + "shared-digest" + ], + "description": "why it was kept: shared-digest, another tag names its digest" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "the other tags still naming its digest, when kept" + }, + "artifact": { + "$ref": "#/defs/Artifact" + } + }, + "required": [ + "removed" + ] + }, + "optional": true + } + ], + "events": [ + { + "name": "published", + "body": { + "$ref": "#/defs/Outcome" + } + }, + { + "name": "refused", + "body": { + "$ref": "#/defs/Outcome" + } + }, + { + "name": "removed", + "body": { + "$ref": "#/defs/Outcome" + } + }, + { + "name": "kept", + "body": { + "$ref": "#/defs/Outcome" + } + } + ], + "defs": { + "Artifact": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "the package's, image's, bundle's or archive's name; a package's is its name in its own language (design 32): a Go package's the go.mod module path (git.novox.be/novox/mesh-sdk/go), an npm package's package.json's name (@novox/mesh-sdk)" + }, + "version": { + "type": "string", + "description": "the version the builder gave it (design 51 *The versions*); empty for a bundle or archive published by its digest alone" + }, + "kind": { + "type": "string", + "enum": [ + "package", + "image", + "bundle", + "archive", + "upstream" + ], + "description": "what it is" + }, + "tag": { + "type": "string", + "description": "the tag it carries, for a tagged version" + }, + "digest": { + "type": "string", + "description": "its digest, sha256: (a Go package's h1: hash beside it in the version's record)" + }, + "fingerprint": { + "type": "string", + "description": "the build's fingerprint" + }, + "commit": { + "type": "string", + "description": "the source commit it was built from" + } + }, + "required": [ + "name", + "kind", + "digest" + ], + "description": "one published thing, as every registry event and answer names it" + }, + "Part": { + "type": "object", + "properties": { + "digest": { + "type": "string", + "description": "the part's digest, sha256:: an image's layer or config, a package's file, a bundle's blob" + }, + "size": { + "type": "integer", + "description": "its size in bytes" + } + }, + "required": [ + "digest", + "size" + ], + "description": "one part of a transfer" + }, + "Outcome": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "the package's, image's, bundle's or archive's name; a package's is its name in its own language (design 32): a Go package's the go.mod module path (git.novox.be/novox/mesh-sdk/go), an npm package's package.json's name (@novox/mesh-sdk)" + }, + "module": { + "type": "string", + "description": "the Go module path a Go package is fetched under, equal to name for a Go package (go-package-registry's published); empty for every other kind of artifact" + }, + "version": { + "type": "string", + "description": "the version the builder gave it (design 51 *The versions*); empty for a bundle or archive published by its digest alone" + }, + "kind": { + "type": "string", + "enum": [ + "package", + "image", + "bundle", + "archive", + "upstream" + ], + "description": "what it is" + }, + "tag": { + "type": "string", + "description": "the tag it carries, for a tagged version" + }, + "digest": { + "type": "string", + "description": "its digest, sha256: (a Go package's h1: hash beside it in the version's record)" + }, + "fingerprint": { + "type": "string", + "description": "the build's fingerprint" + }, + "commit": { + "type": "string", + "description": "the source commit it was built from" + }, + "holder": { + "type": "string", + "description": "the module holding the seat that said it" + }, + "transfer": { + "type": "string", + "description": "the transfer it ends, when it ended one" + }, + "reference": { + "type": "string", + "description": "the reference the seat answers: //@sha256:, or @ for a package (ADR 0605 §9)" + }, + "reason": { + "type": "string", + "description": "why: a refusal's cause, a removal's (retention, tag-gone, by-hand), or why a removal was kept (shared-digest)" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "for kept: the other tags still naming its digest" + } + }, + "required": [ + "name", + "kind", + "digest", + "holder" + ], + "description": "the body of published, refused and removed" + } + } +} diff --git a/contracts/seats/container-image-registry/fixtures/events.json b/contracts/seats/container-image-registry/fixtures/events.json new file mode 100644 index 00000000..65e98e58 --- /dev/null +++ b/contracts/seats/container-image-registry/fixtures/events.json @@ -0,0 +1,98 @@ +[ + { + "name": "published on the seat's subject", + "of": "event:published", + "subject": "mesh.seat.container-image-registry.event.published", + "given": { + "name": "mesh-sdk", + "version": "0.1.19", + "kind": "image", + "digest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "fingerprint": "f1", + "commit": "cccccccccccccccccccccccccccccccccccccccc", + "holder": "distribution", + "transfer": "t1", + "reference": "container-image-registry/mesh-sdk/lib@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + } + }, + { + "name": "refused on the seat's subject", + "of": "event:refused", + "subject": "mesh.seat.container-image-registry.event.refused", + "given": { + "name": "mesh-sdk", + "version": "0.1.19", + "kind": "image", + "digest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "fingerprint": "f1", + "commit": "cccccccccccccccccccccccccccccccccccccccc", + "holder": "distribution", + "transfer": "t1", + "reference": "container-image-registry/mesh-sdk/lib@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "reason": "retention" + } + }, + { + "name": "removed on the seat's subject", + "of": "event:removed", + "subject": "mesh.seat.container-image-registry.event.removed", + "given": { + "name": "mesh-sdk", + "version": "0.1.19", + "kind": "image", + "digest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "fingerprint": "f1", + "commit": "cccccccccccccccccccccccccccccccccccccccc", + "holder": "distribution", + "transfer": "t1", + "reference": "container-image-registry/mesh-sdk/lib@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "reason": "retention" + } + }, + { + "name": "kept on the seat's subject: a tagged version sharing its digest stays, naming the tags on it", + "of": "event:kept", + "subject": "mesh.seat.container-image-registry.event.kept", + "given": { + "name": "mesh-sdk", + "version": "0.1.19-feat-x.2", + "kind": "image", + "digest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "fingerprint": "f1", + "commit": "cccccccccccccccccccccccccccccccccccccccc", + "holder": "distribution", + "reference": "container-image-registry/mesh-sdk/lib@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "reason": "shared-digest", + "tag": "0.1.19-feat-x.2", + "tags": [ + "0.1.19" + ] + } + }, + { + "name": "held on the seat's subject", + "of": "event:held", + "subject": "mesh.seat.container-image-registry.event.held", + "given": { + "name": "mesh-sdk", + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reason": "kept by the sweep's rule", + "holder": "distribution", + "by": "mesh-artifact-sweep", + "at": "2026-11-04T12:00:00Z" + } + }, + { + "name": "released on the seat's subject", + "of": "event:released", + "subject": "mesh.seat.container-image-registry.event.released", + "given": { + "name": "mesh-sdk", + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reason": "kept by the sweep's rule", + "holder": "distribution", + "by": "mesh-artifact-sweep", + "at": "2026-11-04T12:00:00Z" + } + } +] diff --git a/contracts/seats/container-image-registry/fixtures/states.json b/contracts/seats/container-image-registry/fixtures/states.json new file mode 100644 index 00000000..70952e23 --- /dev/null +++ b/contracts/seats/container-image-registry/fixtures/states.json @@ -0,0 +1,28 @@ +[ + { + "name": "a hold", + "of": "state:holds/..", + "given": { + "seat": "container-image-registry", + "name": "base", + "kind": "base", + "digest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "by": "operator@shanks", + "since": "2026-11-05T10:00:00Z" + } + }, + { + "name": "an open window", + "of": "state:windows/", + "given": { + "name": "mesh-delivery-builder", + "open": true, + "holding": [ + "ace" + ], + "since": { + "ace": "2026-11-05T10:00:00Z" + } + } + } +] diff --git a/contracts/seats/container-image-registry/fixtures/verbs.json b/contracts/seats/container-image-registry/fixtures/verbs.json new file mode 100644 index 00000000..e024a813 --- /dev/null +++ b/contracts/seats/container-image-registry/fixtures/verbs.json @@ -0,0 +1,149 @@ +[ + { + "name": "publish opens a transfer", + "of": "verb:publish.input", + "given": { + "name": "mesh-sdk", + "version": "0.1.19", + "kind": "image", + "digest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "fingerprint": "f1", + "commit": "cccccccccccccccccccccccccccccccccccccccc", + "transfer": "t1", + "parts": [ + { + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "size": 1048576 + } + ] + } + }, + { + "name": "a field the reader does not know is dropped", + "of": "verb:publish.input", + "why": "ADR 0608 §6: a reader ignores a field it does not know", + "given": { + "name": "mesh-sdk", + "version": "0.1.19", + "kind": "image", + "digest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "fingerprint": "f1", + "commit": "cccccccccccccccccccccccccccccccccccccccc", + "transfer": "t1", + "parts": [ + { + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "size": 1048576 + } + ], + "later": { + "x": 1 + } + }, + "wire": { + "name": "mesh-sdk", + "version": "0.1.19", + "kind": "image", + "digest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "fingerprint": "f1", + "commit": "cccccccccccccccccccccccccccccccccccccccc", + "transfer": "t1", + "parts": [ + { + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "size": 1048576 + } + ] + } + }, + { + "name": "publish answers what it lacks", + "of": "verb:publish.answer", + "given": { + "transfer": "t1", + "lacks": [ + "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + ] + } + }, + { + "name": "a chunk of a part", + "of": "verb:publish-part.input", + "given": { + "transfer": "t1", + "part": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "sequence": 0, + "last": true, + "data": "aGVsbG8=" + } + }, + { + "name": "the versions held", + "of": "verb:versions.answer", + "given": { + "name": "mesh-sdk", + "versions": [ + { + "name": "mesh-sdk", + "version": "0.1.19", + "kind": "package", + "digest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "fingerprint": "f1", + "commit": "cccccccccccccccccccccccccccccccccccccccc" + } + ] + } + }, + { + "name": "a release of what is not held answers released false", + "of": "verb:release.answer", + "why": "A3t: the outcome asked holds already, so it is no error", + "given": { + "released": false + } + }, + { + "name": "a tagged version sharing its digest with another tag is kept, not removed", + "of": "verb:remove.answer", + "why": "G-sdk-contracts 3: the store cannot remove the tag alone, so the answer says what holds", + "given": { + "removed": false, + "kept": true, + "reason": "shared-digest", + "tags": [ + "0.1.19" + ] + } + }, + { + "name": "list answers an index with its platforms, its tag and its hold", + "of": "verb:list.answer", + "given": { + "repositories": [ + { + "name": "mail/server", + "manifests": [ + { + "digest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "index": true, + "platforms": [ + "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + ], + "tags": [ + "3" + ], + "size": 2048, + "held": false + }, + { + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "index": false, + "size": 1024, + "held": false + } + ] + } + ] + } + } +] diff --git a/contracts/seats/container-image-registry/seat.json b/contracts/seats/container-image-registry/seat.json new file mode 100644 index 00000000..bc116b4b --- /dev/null +++ b/contracts/seats/container-image-registry/seat.json @@ -0,0 +1,480 @@ +{ + "format": 1, + "seat": "container-image-registry", + "scope": "mesh", + "grain": "whole", + "domain": "Delivery", + "decision": "novox/hq ADR 0605", + "description": "The mesh's container images, in the one artifact store: the OCI distribution protocol's read side, an image's tags and mirroring an outside image.", + "includes": [ + "registry" + ], + "verbs": [ + { + "name": "mirror", + "description": "Copy an outside image into the store by its pinned digest; emits published or refused.", + "callers": "mesh-build-image's holder of the image build seat", + "input": { + "type": "object", + "properties": { + "source": { + "type": "string", + "description": "the outside image's reference" + }, + "digest": { + "type": "string", + "description": "its pinned digest, sha256:" + }, + "name": { + "type": "string", + "description": "the name it is kept under" + } + }, + "required": [ + "source", + "digest", + "name" + ] + }, + "answer": { + "type": "object", + "properties": { + "reference": { + "type": "string", + "description": "the reference the seat answers" + }, + "digest": { + "type": "string", + "description": "the digest stored" + } + }, + "required": [ + "reference", + "digest" + ] + }, + "optional": true + }, + { + "name": "tags", + "description": "The tags of one image and the digest each names.", + "callers": "any caller whose role grants it", + "input": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "the image" + } + }, + "required": [ + "name" + ] + }, + "answer": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "the image" + }, + "tags": { + "type": "array", + "items": { + "type": "object", + "properties": { + "tag": { + "type": "string", + "description": "the tag" + }, + "digest": { + "type": "string", + "description": "its digest" + } + }, + "required": [ + "tag", + "digest" + ] + }, + "description": "every tag" + } + }, + "required": [ + "name", + "tags" + ] + }, + "optional": true + }, + { + "name": "hold", + "description": "Keep one untagged bundle, archive or copied base from removal by retention, as the artifact sweep's rules keep it (ADR 0617 §2); never an image of the holder's own. Emits held.", + "callers": "the module that runs the artifact sweep alone", + "input": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "the name" + }, + "digest": { + "type": "string", + "description": "the digest" + }, + "reason": { + "type": "string", + "description": "which of the sweep's rules keeps it" + } + }, + "required": [ + "name", + "digest", + "reason" + ] + }, + "answer": { + "type": "object", + "properties": { + "held": { + "type": "boolean", + "description": "it is held" + }, + "since": { + "type": "string", + "description": "when the hold began, RFC 3339" + } + }, + "required": [ + "held" + ] + }, + "optional": true + }, + { + "name": "release", + "description": "Let a hold go; emits released. A release of what is not held answers released false and emits nothing, never an error: the outcome asked holds already. By hand only through the holder's own tool, the operator's session as the proved caller, recorded as a hand act.", + "callers": "the module that runs the artifact sweep; the operator's session through the holder's tool", + "input": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "the name" + }, + "digest": { + "type": "string", + "description": "the digest" + }, + "reason": { + "type": "string", + "description": "why it is let go" + } + }, + "required": [ + "name", + "digest", + "reason" + ] + }, + "answer": { + "type": "object", + "properties": { + "released": { + "type": "boolean", + "description": "a hold was let go; false when there was none" + } + }, + "required": [ + "released" + ] + }, + "optional": true + }, + { + "name": "list", + "description": "What the store holds of one repository, or of every repository the seat holds when none is named: every image manifest and index, each with its digest, whether it is an index (and the manifests an index names), the tags naming it, its size and whether it is held. Read from the store itself, so a manifest no tag names is listed too; it reads only (ADR 0632 §3).", + "callers": "any caller whose role grants it", + "input": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "the repository, /; every repository of the seat when not given" + } + } + }, + "answer": { + "type": "object", + "properties": { + "repositories": { + "type": "array", + "items": { + "$ref": "#/defs/Repository" + }, + "description": "each repository named, in order of its name" + } + }, + "required": [ + "repositories" + ] + }, + "optional": true + } + ], + "events": [ + { + "name": "held", + "body": { + "$ref": "#/defs/Hold" + } + }, + { + "name": "released", + "body": { + "$ref": "#/defs/Hold" + } + } + ], + "family": { + "name": "container-image-registry.events", + "max-age-days": 7 + }, + "defs": { + "Hold": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "the name" + }, + "digest": { + "type": "string", + "description": "the digest" + }, + "reason": { + "type": "string", + "description": "why" + }, + "holder": { + "type": "string", + "description": "the module holding the seat" + }, + "by": { + "type": "string", + "description": "the caller the holder proved" + }, + "at": { + "type": "string", + "description": "when, RFC 3339" + } + }, + "required": [ + "name", + "digest", + "holder" + ], + "description": "the body of held and released" + }, + "Repository": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "the repository" + }, + "manifests": { + "type": "array", + "items": { + "$ref": "#/defs/Listed" + }, + "description": "each manifest it holds, by digest" + } + }, + "required": [ + "name", + "manifests" + ] + }, + "Listed": { + "type": "object", + "properties": { + "digest": { + "type": "string", + "description": "the manifest's digest, sha256:" + }, + "index": { + "type": "boolean", + "description": "it is an index, naming a manifest per platform" + }, + "platforms": { + "type": "array", + "items": { + "type": "string" + }, + "description": "for an index, the digests of the manifests it names" + }, + "artifact": { + "type": "string", + "description": "for a manifest keeping one bundle or archive, the digest of that blob: what a reference names it by" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "the tags naming it, none for an untagged one" + }, + "size": { + "type": "integer", + "description": "its bytes: its own content and every blob it names" + }, + "held": { + "type": "boolean", + "description": "a hold keeps it from retention" + } + }, + "required": [ + "digest", + "index", + "size", + "held" + ] + } + }, + "states": [ + { + "name": "holds", + "template": "state", + "history": 1, + "description": "the bundles, archives and copied bases kept from retention's removal", + "key-forms": [ + { + "key": "..", + "value": { + "type": "object", + "description": "one hold: an untagged bundle, archive or copied base kept from retention's removal", + "properties": { + "seat": { + "type": "string" + }, + "name": { + "type": "string" + }, + "kind": { + "type": "string" + }, + "digest": { + "type": "string" + }, + "by": { + "type": "string" + }, + "why": { + "type": "string" + }, + "since": { + "type": "string", + "description": "when (RFC 3339 time)" + } + }, + "required": [ + "seat", + "name", + "kind", + "digest", + "by", + "since" + ] + }, + "description": "one hold" + } + ] + }, + { + "name": "windows", + "template": "state", + "history": 1, + "description": "the direct-write windows, one per caller", + "key-forms": [ + { + "key": "", + "value": { + "type": "object", + "description": "the direct-write window of one caller: open while a node holds it open", + "properties": { + "name": { + "type": "string", + "description": "the caller whose window it is" + }, + "open": { + "type": "boolean" + }, + "holding": { + "type": "array", + "items": { + "type": "string" + }, + "description": "the nodes holding it open" + }, + "since": { + "type": "object", + "additionalProperties": { + "type": "string", + "description": "when (RFC 3339 time)" + }, + "description": "node to when it began to hold" + }, + "closed": { + "type": "string", + "description": "when it last closed (RFC 3339 time)" + }, + "last_holding": { + "type": "array", + "items": { + "type": "string" + } + }, + "dropped": { + "type": "array", + "items": { + "type": "object", + "properties": { + "node": { + "type": "string" + }, + "by": { + "type": "string" + }, + "why": { + "type": "string" + }, + "at": { + "type": "string", + "description": "when (RFC 3339 time)" + } + }, + "required": [ + "node" + ] + }, + "description": "retired nodes dropped by hand" + }, + "why": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "description": "of each node holding the window, why it holds it" + } + }, + "required": [ + "name" + ] + }, + "description": "one caller's window" + } + ] + } + ] +} diff --git a/contracts/seats/git/seat.json b/contracts/seats/git/seat.json new file mode 100644 index 00000000..b7c55a10 --- /dev/null +++ b/contracts/seats/git/seat.json @@ -0,0 +1,9 @@ +{ + "format": 1, + "seat": "git", + "scope": "mesh", + "grain": "seat-list", + "domain": "Delivery", + "decision": "novox/hq ADR 0111", + "delivers": "git" +} diff --git a/contracts/seats/go-package-registry/fixtures/events.json b/contracts/seats/go-package-registry/fixtures/events.json new file mode 100644 index 00000000..c3a8b16d --- /dev/null +++ b/contracts/seats/go-package-registry/fixtures/events.json @@ -0,0 +1,73 @@ +[ + { + "name": "published on the seat's subject", + "of": "event:published", + "subject": "mesh.seat.go-package-registry.event.published", + "given": { + "name": "git.novox.be/novox/mesh-sdk/go", + "module": "git.novox.be/novox/mesh-sdk/go", + "version": "0.1.19", + "kind": "package", + "digest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "fingerprint": "f1", + "commit": "cccccccccccccccccccccccccccccccccccccccc", + "holder": "gitea", + "transfer": "t1", + "reference": "git.novox.be/novox/mesh-sdk/go@0.1.19" + } + }, + { + "name": "refused on the seat's subject", + "of": "event:refused", + "subject": "mesh.seat.go-package-registry.event.refused", + "given": { + "name": "git.novox.be/novox/mesh-sdk/go", + "version": "0.1.19", + "kind": "package", + "digest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "fingerprint": "f1", + "commit": "cccccccccccccccccccccccccccccccccccccccc", + "holder": "gitea", + "transfer": "t1", + "reference": "git.novox.be/novox/mesh-sdk/go@0.1.19", + "reason": "retention" + } + }, + { + "name": "removed on the seat's subject", + "of": "event:removed", + "subject": "mesh.seat.go-package-registry.event.removed", + "given": { + "name": "git.novox.be/novox/mesh-sdk/go", + "version": "0.1.19", + "kind": "package", + "digest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "fingerprint": "f1", + "commit": "cccccccccccccccccccccccccccccccccccccccc", + "holder": "gitea", + "transfer": "t1", + "reference": "git.novox.be/novox/mesh-sdk/go@0.1.19", + "reason": "retention" + } + }, + { + "name": "kept on the seat's subject: a tagged version sharing its digest stays, naming the tags on it", + "of": "event:kept", + "subject": "mesh.seat.go-package-registry.event.kept", + "given": { + "name": "git.novox.be/novox/mesh-sdk/go", + "version": "0.1.19-feat-x.2", + "kind": "package", + "digest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "fingerprint": "f1", + "commit": "cccccccccccccccccccccccccccccccccccccccc", + "holder": "gitea", + "reference": "git.novox.be/novox/mesh-sdk/go@0.1.19", + "reason": "shared-digest", + "tag": "0.1.19-feat-x.2", + "tags": [ + "0.1.19" + ] + } + } +] diff --git a/contracts/seats/go-package-registry/fixtures/verbs.json b/contracts/seats/go-package-registry/fixtures/verbs.json new file mode 100644 index 00000000..be6a53a3 --- /dev/null +++ b/contracts/seats/go-package-registry/fixtures/verbs.json @@ -0,0 +1,110 @@ +[ + { + "name": "publish opens a transfer", + "of": "verb:publish.input", + "given": { + "name": "git.novox.be/novox/mesh-sdk/go", + "version": "0.1.19", + "kind": "package", + "digest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "fingerprint": "f1", + "commit": "cccccccccccccccccccccccccccccccccccccccc", + "transfer": "t1", + "parts": [ + { + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "size": 1048576 + } + ] + } + }, + { + "name": "a field the reader does not know is dropped", + "of": "verb:publish.input", + "why": "ADR 0608 §6: a reader ignores a field it does not know", + "given": { + "name": "git.novox.be/novox/mesh-sdk/go", + "version": "0.1.19", + "kind": "package", + "digest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "fingerprint": "f1", + "commit": "cccccccccccccccccccccccccccccccccccccccc", + "transfer": "t1", + "parts": [ + { + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "size": 1048576 + } + ], + "later": { + "x": 1 + } + }, + "wire": { + "name": "git.novox.be/novox/mesh-sdk/go", + "version": "0.1.19", + "kind": "package", + "digest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "fingerprint": "f1", + "commit": "cccccccccccccccccccccccccccccccccccccccc", + "transfer": "t1", + "parts": [ + { + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "size": 1048576 + } + ] + } + }, + { + "name": "publish answers what it lacks", + "of": "verb:publish.answer", + "given": { + "transfer": "t1", + "lacks": [ + "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + ] + } + }, + { + "name": "a chunk of a part", + "of": "verb:publish-part.input", + "given": { + "transfer": "t1", + "part": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "sequence": 0, + "last": true, + "data": "aGVsbG8=" + } + }, + { + "name": "the versions held", + "of": "verb:versions.answer", + "given": { + "name": "git.novox.be/novox/mesh-sdk/go", + "versions": [ + { + "name": "git.novox.be/novox/mesh-sdk/go", + "version": "0.1.19", + "kind": "package", + "digest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "fingerprint": "f1", + "commit": "cccccccccccccccccccccccccccccccccccccccc" + } + ] + } + }, + { + "name": "a tagged version sharing its digest with another tag is kept, not removed", + "of": "verb:remove.answer", + "why": "G-sdk-contracts 3: the store cannot remove the tag alone, so the answer says what holds", + "given": { + "removed": false, + "kept": true, + "reason": "shared-digest", + "tags": [ + "0.1.19" + ] + } + } +] diff --git a/contracts/seats/go-package-registry/seat.json b/contracts/seats/go-package-registry/seat.json new file mode 100644 index 00000000..9d426368 --- /dev/null +++ b/contracts/seats/go-package-registry/seat.json @@ -0,0 +1,35 @@ +{ + "format": 1, + "seat": "go-package-registry", + "scope": "mesh", + "grain": "whole", + "domain": "Delivery", + "decision": "novox/hq ADR 0605", + "description": "The mesh's Go modules: Go's module proxy protocol's read side, and publishing over the bus.", + "delivers": "go-package-registry", + "includes": [ + "registry" + ], + "family": { + "name": "go-package-registry.events", + "max-age-days": 7 + }, + "provisions": [ + { + "name": "go-package-registry", + "form": { + "type": "object", + "properties": { + "url": { + "type": "string", + "description": "the read side's address" + } + }, + "required": [ + "url" + ], + "description": "form none: the Go module proxy's read side, answered anonymously, with no credential (design 51)" + } + } + ] +} diff --git a/contracts/seats/mesh-artifact-store/seat.json b/contracts/seats/mesh-artifact-store/seat.json new file mode 100644 index 00000000..d82ab333 --- /dev/null +++ b/contracts/seats/mesh-artifact-store/seat.json @@ -0,0 +1,8 @@ +{ + "format": 1, + "seat": "mesh-artifact-store", + "scope": "mesh", + "grain": "seat-list", + "domain": "Delivery", + "decision": "novox/hq ADR 0075" +} diff --git a/contracts/seats/mesh-artifact-sweep/fixtures/events.json b/contracts/seats/mesh-artifact-sweep/fixtures/events.json new file mode 100644 index 00000000..3e39336a --- /dev/null +++ b/contracts/seats/mesh-artifact-sweep/fixtures/events.json @@ -0,0 +1,15 @@ +[ + { + "name": "artifacts-gone on the seat's subject", + "of": "event:artifacts-gone", + "subject": "mesh.seat.mesh-artifact-sweep.event.artifacts-gone", + "given": { + "event": "artifacts-gone", + "module": "ca-trust", + "key": "ca-trust.archive.f1", + "fingerprint": "f1", + "gone": ["artifact-store://ca-trust/archive/blobs/sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "artifact-store://ca-trust/archive/blobs/sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"], + "at": "2026-11-05T15:04:05Z" + } + } +] diff --git a/contracts/seats/mesh-artifact-sweep/fixtures/states.json b/contracts/seats/mesh-artifact-sweep/fixtures/states.json new file mode 100644 index 00000000..fe51488c --- /dev/null +++ b/contracts/seats/mesh-artifact-sweep/fixtures/states.json @@ -0,0 +1 @@ +[] diff --git a/contracts/seats/mesh-artifact-sweep/fixtures/verbs.json b/contracts/seats/mesh-artifact-sweep/fixtures/verbs.json new file mode 100644 index 00000000..b70970ee --- /dev/null +++ b/contracts/seats/mesh-artifact-sweep/fixtures/verbs.json @@ -0,0 +1,24 @@ +[ + { + "name": "held asks the references", + "of": "verb:held.input", + "why": "the references are the holder's own test values (the tool refuses a call without them through the console)", + "given": {"references": ["artifact-store://ca-trust/archive/blobs/sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "artifact-store://ca-trust/archive/blobs/sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "artifact-store://ca-trust/archive/blobs/sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"]} + }, + { + "name": "held answers held, gone and unknown", + "of": "verb:held.answer", + "why": "the answer of the holder's test (cmd/artifact-store-tools): every reference in one of the three", + "given": { + "store": "store.test:5100", + "held": ["artifact-store://ca-trust/archive/blobs/sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"], + "gone": ["artifact-store://ca-trust/archive/blobs/sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"], + "unknown": {"artifact-store://ca-trust/archive/blobs/sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc": "the store answered 503"} + } + }, + { + "name": "held answers with nothing unknown", + "of": "verb:held.answer", + "given": {"store": "store.test:5100", "held": [], "gone": []} + } +] diff --git a/contracts/seats/mesh-artifact-sweep/seat.json b/contracts/seats/mesh-artifact-sweep/seat.json new file mode 100644 index 00000000..4763ce8d --- /dev/null +++ b/contracts/seats/mesh-artifact-sweep/seat.json @@ -0,0 +1,204 @@ +{ + "format": 1, + "seat": "mesh-artifact-sweep", + "scope": "mesh", + "grain": "whole", + "domain": "Delivery", + "decision": "novox/hq ADR 0571", + "verbs": [ + { + "name": "artifacts", + "description": "Every artifact the mesh made that lives in its artifact store, each with its repository, digest, kind (image or archive) and state: kept, and why (a want names it, or one of the five newest builds of its module in the builder's log), eligible (made, kept for no reason, not yet let go), or unrecorded (in the store and named by nothing the mesh made: never removed). Reads only.", + "input": { + "properties": { + "collected": { + "description": "\"true\": list the collected references too (counted always)", + "enum": [ + "true", + "false" + ], + "type": "string" + }, + "repository": { + "description": "only this repository's references, as \u003cmodule\u003e/\u003cartifact\u003e (the counts stay whole)", + "type": "string" + } + }, + "type": "object" + }, + "answer": { + "type": "object", + "description": "what artifacts answers, as its description says" + } + }, + { + "name": "held", + "description": "Whether the artifact store holds each reference given, asked with one HEAD each: held, gone (the store answered 404) or unknown with why. The builder asks it before it reuses a done key. Reads only.", + "input": { + "properties": { + "references": { + "description": "the artifacts' references, artifact-store://\u003cmodule\u003e/\u003cartifact\u003e/blobs/sha256:\u003chex\u003e: a list, or one text separated by commas", + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "references" + ], + "type": "object" + }, + "answer": { + "type": "object", + "properties": { + "store": { + "type": "string", + "description": "the store asked, host and port" + }, + "held": { + "type": "array", + "items": { + "type": "string" + }, + "description": "the references the store holds" + }, + "gone": { + "type": "array", + "items": { + "type": "string" + }, + "description": "the references the store answered 404 for" + }, + "unknown": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "description": "a reference the store could not be asked about, to why" + } + }, + "required": [ + "held", + "gone" + ], + "description": "what held answers: a reference is in held, gone or unknown, never none of them" + } + }, + { + "name": "collect", + "description": "The artifact store's sweep, now: what is eligible is let go of. Without confirm a dry run: what would be let go, nothing held or deleted. With confirm and why: every kept archive held first, then each eligible artifact let go of, oldest first, an index with the platform manifests no kept index names, each deletion recorded in the hand-act log. Bounded by most (500 by default, at most 5000) and 45 seconds; what is left is said. Never touches a digest the mesh did not make.", + "input": { + "properties": { + "confirm": { + "description": "\"true\": let go of what is eligible (needs why); without it nothing is done", + "enum": [ + "true", + "false" + ], + "type": "string" + }, + "most": { + "description": "let go of at most this many (500 by default, at most 5000)", + "type": "string" + }, + "why": { + "description": "with confirm: why, required and recorded", + "type": "string" + } + }, + "type": "object" + }, + "answer": { + "type": "object", + "description": "what collect answers, as its description says" + } + }, + { + "name": "mirrors", + "description": "The bases the mesh copied into its artifact store, each kept (a kept build stood on it), eligible (only builds no longer kept stood on it) or unrecorded (no build the builder's log holds stood on it: never removed), by what the builder's log says each build stood on. Records nothing by hand.", + "input": { + "properties": {}, + "type": "object" + }, + "answer": { + "type": "object", + "description": "what mirrors answers, as its description says" + } + }, + { + "name": "images", + "description": "Every container image one machine's want and have name, each with the resources naming it: what the machine keeps when it prunes its images. Reads only.", + "input": { + "properties": { + "node": { + "description": "the machine's name", + "type": "string" + } + }, + "required": [ + "node" + ], + "type": "object" + }, + "answer": { + "type": "object", + "description": "what images answers, as its description says" + } + } + ], + "family": { + "name": "mesh-artifact-sweep.events", + "max-age-days": 7 + }, + "events": [ + { + "name": "artifacts-gone", + "body": { + "$ref": "#/defs/ArtifactsGone" + } + } + ], + "defs": { + "ArtifactsGone": { + "type": "object", + "properties": { + "event": { + "type": "string", + "description": "artifacts-gone" + }, + "module": { + "type": "string", + "description": "the module whose builder's key names a gone artifact" + }, + "key": { + "type": "string", + "description": "the builder's done key" + }, + "fingerprint": { + "type": "string", + "description": "the key's fingerprint" + }, + "gone": { + "type": "array", + "items": { + "type": "string" + }, + "description": "every reference of the key the store does not hold, sorted" + }, + "at": { + "type": "string", + "format": "date-time", + "description": "when the sweep found it" + } + }, + "required": [ + "event", + "module", + "fingerprint", + "gone" + ], + "description": "the body of artifacts-gone" + } + } +} diff --git a/contracts/seats/mesh-catalog/fixtures/states.json b/contracts/seats/mesh-catalog/fixtures/states.json new file mode 100644 index 00000000..43af3306 --- /dev/null +++ b/contracts/seats/mesh-catalog/fixtures/states.json @@ -0,0 +1,223 @@ +[ + { + "name": "a manifest (xclip, read through mesh-catalog.module)", + "of": "state:manifests/", + "given": { + "v": 1, + "module": "xclip", + "manifest": "{\"build\":{\"artifacts\":[{\"binary\":\"xclip-tools\",\"from\":\"cmd/xclip-tools\",\"kind\":\"bundle\",\"language\":\"go\",\"loads\":[\"xclip-tools\"],\"name\":\"tools\",\"system\":\"arch\"}]},\"capabilities\":[\"package-manager\"],\"module\":\"xclip\",\"resources\":[{\"id\":\"package\",\"package\":\"xclip\",\"type\":\"package\"}],\"tools\":[\"xclip_copy\",\"xclip_paste\",\"xclip_targets\",\"xclip_session\"],\"version\":\"1\"}" + } + }, + { + "name": "a module (xclip, read through mesh-catalog.modules; the inputs and toolchains no tool reads are not here)", + "of": "state:modules/", + "given": { + "v": 1, + "repository": "novox/mesh-catalog", + "commit": "f469eb387a3015569d9b16c66ed418e9dd098f64", + "path": "modules/xclip", + "fp2": "src2:b427bdde1f100e2f52490cfa9df0c5112dd3700226b4892349f0c14a15a4af56" + } + }, + { + "name": "a candidate", + "of": "state:candidates/", + "given": { + "v": 1, + "group": "feat/registry", + "base": 3, + "heads": "0123456789ab", + "pulls": [ + { + "repository": "novox/mesh-sdk", + "number": 1, + "head": "cccccccccccccccccccccccccccccccccccccccc", + "modules": [ + "nats" + ] + } + ] + } + }, + { + "name": "a refused verdict (nats, read through the indexer's verdicts tool)", + "of": "state:verdicts/", + "given": { + "module": "nats", + "state": "refused", + "problems": [ + "this is not a module manifest: a state is either a name or {name, history, ttl-seconds, per-machine, key-ttl}: json: unknown field \"read-only\" (read without state[2].read-only)" + ], + "judge": "exe:661e6fb424d7ffea", + "at": "2026-11-04T18:51:12.614583047+01:00" + } + }, + { + "name": "the predefined list", + "of": "state:predefined/list", + "given": { + "v": 1, + "commit": "cccccccccccccccccccccccccccccccccccccccc", + "list": { + "streams": [] + } + } + }, + { + "name": "a pull request's configuration", + "of": "state:config-candidates/", + "given": { + "v": 1, + "pull": "novox/mesh-catalog#1", + "head": "cccccccccccccccccccccccccccccccccccccccc", + "declares": { + "nats": { + "limit": { + "kind": "setting", + "default": 1, + "why": "a bound" + } + } + } + } + }, + { + "name": "a pull request's configuration state whose bounds are declared 0 (a zero is a bound, not none)", + "of": "state:config-candidates/", + "given": { + "v": 1, + "pull": "novox/mesh-catalog#1", + "head": "cccccccccccccccccccccccccccccccccccccccc", + "declares": { + "nats": { + "limit": { + "kind": "setting", + "default": 0, + "why": "a bound", + "minimum": 0, + "maximum": 0 + } + } + } + } + }, + { + "name": "a published Go module", + "of": "state:packages/", + "given": { + "v": 1, + "module": "git.novox.be/novox/mesh-sdk/go", + "repository": "novox/mesh-sdk", + "commit": "cccccccccccccccccccccccccccccccccccccccc", + "packages": [ + { + "package": "git.novox.be/novox/mesh-sdk/go/keys", + "digest": "ab" + } + ] + } + }, + { + "name": "a package series", + "of": "state:packages/.", + "given": { + "v": 1, + "name": "go-keys", + "series": "0.1" + } + }, + { + "name": "a module with its toolchain", + "of": "state:modules/", + "given": { + "v": 1, + "repository": "novox/mesh-catalog", + "commit": "f469eb387a3015569d9b16c66ed418e9dd098f64", + "fp2": "src2:b427bdde1f100e2f52490cfa9df0c5112dd3700226b4892349f0c14a15a4af56", + "toolchains": { + "go": { + "language": "go", + "version": "1.26.0", + "recipe": "1" + } + } + } + }, + { + "name": "the modules verb (captured: the first three modules)", + "of": "verb:modules.answer", + "given": { + "modules": [ + { + "commit": "f469eb387a3015569d9b16c66ed418e9dd098f64", + "fp": "src2:5e4a1b4ad4ea6175c05e4655ce58aeb957547149d4c9805eb27636efd50c1c87", + "module": "adwaita", + "path": "modules/adwaita", + "repository": "novox/mesh-catalog" + }, + { + "commit": "b112adcecc41d18f54190cbd2fc864199b70c518", + "fp": "src2:7dd0db8e98224f46121a5ae05c0b9c5a430eff531a5120aa40b1a59237853a3f", + "module": "amqp-email-forwarder", + "path": ".", + "repository": "jschoubben/amqp-email-forwarder" + }, + { + "commit": "dd458cb155f5f5fa3ed824a23728e444be5b156a", + "fp": "src2:a98f8152f0a837dd5ba883a15efaf110ba88100193157e4bdb1a93ba55209371", + "module": "artifact-store-tools", + "path": "modules/artifact-store-tools", + "repository": "novox/mesh-catalog" + } + ] + } + }, + { + "name": "the module verb (captured: xclip)", + "of": "verb:module.answer", + "given": { + "module": { + "commit": "f469eb387a3015569d9b16c66ed418e9dd098f64", + "fp": "src2:b427bdde1f100e2f52490cfa9df0c5112dd3700226b4892349f0c14a15a4af56", + "manifest": { + "build": { + "artifacts": [ + { + "binary": "xclip-tools", + "from": "cmd/xclip-tools", + "kind": "bundle", + "language": "go", + "loads": [ + "xclip-tools" + ], + "name": "tools", + "system": "arch" + } + ] + }, + "capabilities": [ + "package-manager" + ], + "module": "xclip", + "resources": [ + { + "id": "package", + "package": "xclip", + "type": "package" + } + ], + "tools": [ + "xclip_copy", + "xclip_paste", + "xclip_targets", + "xclip_session" + ], + "version": "1" + }, + "module": "xclip", + "path": "modules/xclip", + "repository": "novox/mesh-catalog" + } + } + } +] diff --git a/contracts/seats/mesh-catalog/seat.json b/contracts/seats/mesh-catalog/seat.json new file mode 100644 index 00000000..13f861f1 --- /dev/null +++ b/contracts/seats/mesh-catalog/seat.json @@ -0,0 +1,1161 @@ +{ + "format": 1, + "seat": "mesh-catalog", + "scope": "mesh", + "grain": "whole", + "domain": "Delivery", + "decision": "novox/hq ADR 0604", + "verbs": [ + { + "name": "modules", + "description": "Every module the trunk holds, each with its repository, its directory there, the merge commit it was last changed at and its fingerprint (or why it has none): the catalog of the mesh, read from its holder's state. Reads only.", + "optional": true, + "input": { + "properties": {}, + "type": "object" + }, + "answer": { + "type": "object", + "description": "every module the trunk holds, by name", + "properties": { + "modules": { + "type": "array", + "items": { + "type": "object", + "properties": { + "module": { + "type": "string" + }, + "repository": { + "type": "string" + }, + "path": { + "type": "string", + "description": "the module's directory in its repository, '.' for a root" + }, + "commit": { + "type": "string", + "description": "the merge commit it was last changed at" + }, + "fp": { + "type": "string", + "description": "its fingerprint (src2: and 64 hex digits); absent when it has none" + }, + "none": { + "type": "string", + "description": "why it has none" + } + }, + "required": [ + "module", + "repository", + "commit" + ] + } + } + }, + "required": [ + "modules" + ] + } + }, + { + "name": "module", + "description": "One module whole: its repository, directory, commit and fingerprint, and its manifest as the trunk holds it. Older versions are in its repository's history. Reads only.", + "optional": true, + "input": { + "properties": { + "module": { + "description": "the module's name", + "type": "string" + } + }, + "required": [ + "module" + ], + "type": "object" + }, + "answer": { + "type": "object", + "description": "the module whole", + "properties": { + "module": { + "type": "object", + "properties": { + "module": { + "type": "string" + }, + "repository": { + "type": "string" + }, + "path": { + "type": "string", + "description": "the module's directory in its repository, '.' for a root" + }, + "commit": { + "type": "string", + "description": "the merge commit it was last changed at" + }, + "fp": { + "type": "string", + "description": "its fingerprint (src2: and 64 hex digits); absent when it has none" + }, + "none": { + "type": "string", + "description": "why it has none" + }, + "manifest": { + "type": "object", + "description": "the module's manifest as the trunk holds it" + } + }, + "required": [ + "module", + "repository", + "commit" + ] + } + }, + "required": [ + "module" + ] + } + }, + { + "name": "provides", + "description": "Which modules provide a provision (a module provides its own name too) and which require it, from the module graph's provision edges. Reads only.", + "optional": true, + "input": { + "properties": { + "provision": { + "description": "the provision, such as postgres-database", + "type": "string" + } + }, + "required": [ + "provision" + ], + "type": "object" + }, + "answer": { + "type": "object", + "description": "what provides answers, as its description says" + } + }, + { + "name": "dependents", + "description": "The modules whose build edges reach a module, directly or through others (a base, a mesh package it publishes, the build module that builds them): what a change of it builds again, from the module graph. Reads only.", + "optional": true, + "input": { + "properties": { + "module": { + "description": "the module's name", + "type": "string" + } + }, + "required": [ + "module" + ], + "type": "object" + }, + "answer": { + "type": "object", + "description": "what dependents answers, as its description says" + } + }, + { + "name": "seat-definitions", + "description": "The seat list (ADR 0636 §4): with no seat, every trunk seat with its digest and the revision of the list; with a seat, that trunk definition resolved with the parts it includes. Reads only.", + "optional": true, + "input": { + "properties": { + "seat": { + "description": "the seat's name; none lists every trunk seat", + "type": "string" + } + }, + "type": "object" + }, + "answer": { + "type": "object", + "description": "with no seat: the revision of the list and every trunk seat with its digest; with a seat: whether it was found and its entry, the definition resolved", + "properties": { + "revision": { + "type": "integer" + }, + "seats": { + "type": "array", + "items": { + "type": "object", + "properties": { + "seat": { + "type": "string" + }, + "source": { + "type": "string", + "description": "trunk" + }, + "digest": { + "type": "string" + } + }, + "required": [ + "seat", + "source", + "digest" + ] + } + }, + "seat": { + "type": "string" + }, + "found": { + "type": "boolean" + }, + "entry": { + "type": "object", + "properties": { + "seat": { + "type": "string" + }, + "source": { + "type": "string", + "description": "trunk" + }, + "digest": { + "type": "string" + }, + "definition": { + "type": "object", + "description": "the definition, resolved with the parts it includes" + }, + "unparsed": { + "type": "string", + "description": "why the definition could not be read, when it could not" + } + }, + "required": [ + "seat", + "source", + "digest" + ] + } + } + } + } + ], + "description": "The catalog of the mesh: every module the trunk holds with its manifest, fingerprint and verdict, the packages published, and the seat list (ADR 0636 §1, §4).", + "family": { + "name": "mesh-catalog.events", + "max-age-days": 7 + }, + "events": [ + { + "name": "seat-definition-changed", + "body": { + "type": "object", + "properties": { + "seat": { + "type": "string" + }, + "source": { + "type": "string", + "description": "trunk" + }, + "digest": { + "type": "string" + } + }, + "required": [ + "seat", + "source", + "digest" + ] + } + }, + { + "name": "seat-definition-removed", + "body": { + "type": "object", + "properties": { + "seat": { + "type": "string" + } + }, + "required": [ + "seat" + ] + } + }, + { + "name": "manifest-accepted", + "body": { + "type": "object", + "properties": { + "v": { + "type": "integer" + }, + "module": { + "type": "string" + }, + "digest": { + "type": "string" + }, + "judge": { + "type": "string" + }, + "revision": { + "type": "integer", + "description": "the indexer's revision of the verdict it says; a reader asks its first view again on a gap in it; zero from an indexer that does not say it" + }, + "bus-state": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "storage": { + "type": "string" + }, + "template": { + "type": "string" + }, + "options": { + "type": "object" + }, + "configuration": { + "type": "object" + }, + "types": { + "type": "array", + "items": { + "type": "object" + } + } + }, + "required": [ + "name", + "storage" + ] + } + } + }, + "required": [ + "v", + "module", + "digest", + "judge", + "bus-state" + ] + } + }, + { + "name": "manifest-refused", + "body": { + "type": "object", + "properties": { + "v": { + "type": "integer" + }, + "module": { + "type": "string" + }, + "digest": { + "type": "string" + }, + "judge": { + "type": "string" + }, + "revision": { + "type": "integer", + "description": "the indexer's revision of the verdict it says; a reader asks its first view again on a gap in it; zero from an indexer that does not say it" + }, + "problems": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "v", + "module", + "digest", + "judge", + "problems" + ] + } + } + ], + "states": [ + { + "name": "manifests", + "template": "state", + "key-forms": [ + { + "key": "", + "value": { + "type": "object", + "properties": { + "v": { + "type": "integer", + "description": "the layout version" + }, + "module": { + "type": "string" + }, + "manifest": { + "type": "string", + "description": "the manifest, UTF-8 JSON text" + } + }, + "required": [ + "module", + "manifest" + ] + }, + "description": "a manifest by its digest, written once" + } + ], + "description": "every manifest the indexer judged", + "history": 1 + }, + { + "name": "modules", + "template": "state", + "key-forms": [ + { + "key": "", + "value": { + "type": "object", + "properties": { + "v": { + "type": "integer", + "description": "the layout version" + }, + "fp": { + "type": "string", + "description": "the old-form fingerprint" + }, + "none": { + "type": "string" + }, + "fp2": { + "type": "string", + "description": "src2: fingerprint" + }, + "none2": { + "type": "string", + "description": "why it has none" + }, + "manifest": { + "type": "string", + "description": "manifest digest" + }, + "inputs2": { + "type": "object", + "properties": { + "tree": { + "type": "string" + }, + "bases": { + "type": "array", + "items": { + "type": "string" + } + }, + "builder": { + "type": "string" + }, + "contexts": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "locks": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "paths": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "packages": { + "type": "array", + "items": { + "type": "string" + } + }, + "imports": { + "type": "array", + "items": { + "type": "string" + } + }, + "import-form": { + "type": "string" + } + }, + "required": [ + "tree" + ] + }, + "src": { + "type": "integer", + "description": "the source revision" + }, + "fp-src": { + "type": "integer" + }, + "repository": { + "type": "string" + }, + "commit": { + "type": "string" + }, + "path": { + "type": "string" + }, + "toolchains": { + "type": "object", + "additionalProperties": { + "$ref": "#/defs/ToolchainUsed" + }, + "description": "by language: the toolchain FP2 was computed with" + }, + "paths": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "packages": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "verdict": { + "type": "string" + }, + "problems": { + "type": "array", + "items": { + "type": "string" + } + }, + "unknown": { + "type": "array", + "items": { + "type": "string" + } + }, + "warnings": { + "type": "array", + "items": { + "type": "string" + } + }, + "needs": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "repository", + "commit" + ], + "description": "one module as the trunk holds it" + }, + "description": "a module the trunk holds" + } + ], + "description": "the module catalog", + "history": 20 + }, + { + "name": "candidates", + "template": "state", + "key-forms": [ + { + "key": "", + "value": { + "type": "object", + "properties": { + "v": { + "type": "integer", + "description": "the layout version" + }, + "group": { + "type": "string" + }, + "base": { + "type": "integer", + "description": "the source revision it is read on" + }, + "heads": { + "type": "string" + }, + "pulls": { + "type": "array", + "items": { + "type": "object", + "properties": { + "repository": { + "type": "string" + }, + "number": { + "type": "integer" + }, + "head": { + "type": "string" + }, + "modules": { + "type": "array", + "items": { + "type": "string" + } + }, + "fps": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "fp2": { + "type": "string" + }, + "none2": { + "type": "string" + }, + "manifest": { + "type": "string" + }, + "paths": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "packages": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "required": [ + "repository", + "number", + "head", + "modules" + ] + } + }, + "fpset": { + "type": "string" + } + }, + "required": [ + "group", + "base", + "heads", + "pulls" + ], + "description": "the modules a group of open pull requests would change" + }, + "description": "a group's candidate" + } + ], + "description": "the candidates of open groups", + "history": 10 + }, + { + "name": "verdicts", + "template": "state", + "key-forms": [ + { + "key": "", + "value": { + "type": "object", + "properties": { + "v": { + "type": "integer", + "description": "the layout version" + }, + "module": { + "type": "string" + }, + "state": { + "type": "string" + }, + "problems": { + "type": "array", + "items": { + "type": "string" + } + }, + "unknown": { + "type": "array", + "items": { + "type": "string" + } + }, + "warnings": { + "type": "array", + "items": { + "type": "string" + } + }, + "judge": { + "type": "string" + }, + "at": { + "type": "string", + "description": "when (RFC 3339 time)", + "format": "date-time" + } + }, + "required": [ + "module", + "state" + ], + "description": "a manifest's verdict, by the judge named" + }, + "description": "a manifest's verdict by its digest" + } + ], + "description": "the manifest verdicts", + "history": 5 + }, + { + "name": "predefined", + "template": "state", + "key-forms": [ + { + "key": "list", + "value": { + "type": "object", + "properties": { + "v": { + "type": "integer", + "description": "the layout version" + }, + "commit": { + "type": "string" + }, + "at": { + "type": "string", + "description": "when (RFC 3339 time)", + "format": "date-time" + }, + "list": { + "description": "the foundation's predefined list as kept" + } + }, + "required": [ + "commit", + "list" + ] + }, + "description": "the one key" + } + ], + "description": "the kept predefined list", + "history": 5 + }, + { + "name": "config-candidates", + "template": "state", + "key-forms": [ + { + "key": "", + "value": { + "type": "object", + "properties": { + "v": { + "type": "integer", + "description": "the layout version" + }, + "pull": { + "type": "string" + }, + "head": { + "type": "string" + }, + "declares": { + "type": "object", + "additionalProperties": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "kind": { + "type": "string" + }, + "default": { + "description": "the default" + }, + "why": { + "type": "string" + }, + "type": { + "type": "string" + }, + "description": { + "type": "string" + }, + "enum": { + "description": "the allowed values" + }, + "minimum": { + "type": [ + "number", + "null" + ], + "description": "a declared bound; 0 is a bound, absent is none" + }, + "maximum": { + "type": [ + "number", + "null" + ], + "description": "a declared bound; 0 is a bound, absent is none" + }, + "pattern": { + "type": "string" + }, + "trusted": { + "type": "boolean" + }, + "predefined": { + "type": "string" + }, + "required": { + "type": "boolean" + }, + "runtime": { + "type": "boolean" + } + }, + "required": [ + "kind", + "default", + "why" + ] + } + }, + "description": "module to key to declaration" + }, + "at": { + "type": "string", + "description": "when (RFC 3339 time)", + "format": "date-time" + } + }, + "required": [ + "pull", + "head", + "declares" + ] + }, + "description": "the configuration a pull request declares" + } + ], + "description": "the configuration declared by open pull requests", + "history": 5 + }, + { + "name": "packages", + "template": "state", + "key-forms": [ + { + "key": "", + "value": { + "type": "object", + "properties": { + "v": { + "type": "integer", + "description": "the layout version" + }, + "module": { + "type": "string", + "description": "the Go module path" + }, + "repository": { + "type": "string" + }, + "dir": { + "type": "string" + }, + "commit": { + "type": "string" + }, + "requires": { + "type": "string", + "description": "digest of go.mod and go.sum" + }, + "packages": { + "type": "array", + "items": { + "type": "object", + "properties": { + "package": { + "type": "string" + }, + "digest": { + "type": "string" + } + }, + "required": [ + "package", + "digest" + ] + } + }, + "at": { + "type": "string", + "description": "when (RFC 3339 time)", + "format": "date-time" + } + }, + "required": [ + "module", + "repository", + "commit", + "packages" + ] + }, + "description": "a mesh Go module as its latest trunk merge published it" + }, + { + "key": ".", + "value": { + "type": "object", + "properties": { + "v": { + "type": "integer", + "description": "the layout version" + }, + "name": { + "type": "string" + }, + "package": { + "type": "string" + }, + "series": { + "type": "string" + }, + "seat": { + "type": "string" + }, + "go-module": { + "type": "string" + }, + "newest": { + "type": "object", + "properties": { + "version": { + "type": "string" + }, + "commit": { + "type": "string" + }, + "digest": { + "type": "string" + }, + "fingerprint": { + "type": "string" + }, + "contents": { + "type": "object", + "properties": { + "v": { + "type": "integer", + "description": "the layout version" + }, + "module": { + "type": "string", + "description": "the Go module path" + }, + "repository": { + "type": "string" + }, + "dir": { + "type": "string" + }, + "commit": { + "type": "string" + }, + "requires": { + "type": "string", + "description": "digest of go.mod and go.sum" + }, + "packages": { + "type": "array", + "items": { + "type": "object", + "properties": { + "package": { + "type": "string" + }, + "digest": { + "type": "string" + } + }, + "required": [ + "package", + "digest" + ] + } + }, + "at": { + "type": "string", + "description": "when (RFC 3339 time)", + "format": "date-time" + } + }, + "required": [ + "module", + "repository", + "commit", + "packages" + ] + } + }, + "required": [ + "version" + ] + }, + "numbered": { + "type": "object", + "properties": { + "version": { + "type": "string" + }, + "commit": { + "type": "string" + }, + "digest": { + "type": "string" + }, + "fingerprint": { + "type": "string" + }, + "contents": { + "type": "object", + "properties": { + "v": { + "type": "integer", + "description": "the layout version" + }, + "module": { + "type": "string", + "description": "the Go module path" + }, + "repository": { + "type": "string" + }, + "dir": { + "type": "string" + }, + "commit": { + "type": "string" + }, + "requires": { + "type": "string", + "description": "digest of go.mod and go.sum" + }, + "packages": { + "type": "array", + "items": { + "type": "object", + "properties": { + "package": { + "type": "string" + }, + "digest": { + "type": "string" + } + }, + "required": [ + "package", + "digest" + ] + } + }, + "at": { + "type": "string", + "description": "when (RFC 3339 time)", + "format": "date-time" + } + }, + "required": [ + "module", + "repository", + "commit", + "packages" + ] + } + }, + "required": [ + "version" + ] + }, + "at": { + "type": "string", + "description": "when (RFC 3339 time)", + "format": "date-time" + } + }, + "required": [ + "name", + "series" + ] + }, + "description": "a published package series, numbered and newest" + } + ], + "description": "the published packages", + "history": 5 + } + ], + "defs": { + "ToolchainUsed": { + "type": "object", + "description": "a toolchain a fingerprint was computed with (ADR 0405 clause 7)", + "properties": { + "language": { + "type": "string" + }, + "version": { + "type": "string", + "description": "the installed toolchain's version" + }, + "recipe": { + "type": "string", + "description": "the build module's recipe version" + } + }, + "required": [ + "language", + "version", + "recipe" + ] + } + } +} diff --git a/contracts/seats/mesh-delivery-checker/fixtures/events.json b/contracts/seats/mesh-delivery-checker/fixtures/events.json new file mode 100644 index 00000000..7df46f68 --- /dev/null +++ b/contracts/seats/mesh-delivery-checker/fixtures/events.json @@ -0,0 +1,44 @@ +[ + { + "name": "verdict on the checker's own family", + "of": "holder-event:verdict", + "subject": "mesh.stream.mesh-delivery-checker.event.verdict", + "given": { + "repository": "novox/mesh-contracts", + "pull": 3, + "head": "cccccccccccccccccccccccccccccccccccccccc", + "verdict": "fail", + "out-of-step": [ + "go/seats/meshbroker/contract.go" + ], + "series": [ + { + "module": "mesh-contracts", + "artifact": "go", + "from": "0.1", + "to": "0.2" + } + ] + } + }, + { + "name": "tag-wanted on the checker's own family", + "of": "holder-event:tag-wanted", + "subject": "mesh.stream.mesh-delivery-checker.event.tag-wanted", + "given": { + "repository": "novox/mesh-sdk", + "pull": 170, + "head": "cccccccccccccccccccccccccccccccccccccccc", + "branch": "feat/registry", + "module": "mesh-sdk", + "package": "go", + "tag": "registry-1966" + } + }, + { + "name": "preview.written on the checker's stream", + "of": "holder-event:preview.written", + "subject": "mesh.stream.mesh-delivery-checker.event.preview.written", + "given": {} + } +] diff --git a/contracts/seats/mesh-delivery-checker/fixtures/states.json b/contracts/seats/mesh-delivery-checker/fixtures/states.json new file mode 100644 index 00000000..a2666bc7 --- /dev/null +++ b/contracts/seats/mesh-delivery-checker/fixtures/states.json @@ -0,0 +1,84 @@ +[ + { + "name": "a running verdict", + "of": "state:verdicts/.", + "given": { + "v": 1, + "group": "feat/registry", + "heads": "0123456789ab", + "state": "running" + } + }, + { + "name": "a pass judged by the checking program built at a mesh-delivery head", + "of": "state:verdicts/.", + "given": { + "v": 1, + "group": "feat/registry", + "heads": "0123456789ab", + "state": "pass", + "levels": { + "unit": { + "run": 12, + "kept": 0 + }, + "integration": { + "run": 3, + "kept": 0 + } + }, + "judged-by": { + "program": { + "repository": "novox/mesh-delivery", + "commit": "0123456789abcdef0123456789abcdef01234567", + "dir": "modules/mesh-delivery-checker", + "package": "./cmd/mesh-checking-program" + }, + "key": "program-0123456789abcdef", + "built": true, + "heads": [ + { + "repository": "novox/mesh-delivery", + "commit": "0123456789abcdef0123456789abcdef01234567" + } + ] + } + } + }, + { + "name": "a kept pass", + "of": "state:kept/.", + "given": { + "v": 1, + "state": "pass", + "repository": "novox/mesh-sdk", + "package": "go/keys", + "level": "unit", + "tests": 4, + "took": 120, + "toolchain": "go1.26", + "at": "2026-11-05T10:00:00Z" + } + }, + { + "name": "the status owed for a head", + "of": "state:owed/...", + "given": { + "v": 1, + "act": "status=mesh/check", + "context": "mesh/check", + "state": "success" + } + }, + { + "name": "a preview", + "of": "state:previews/..", + "given": { + "v": 1, + "repository": "novox/mesh-sdk", + "head": "cccccccccccccccccccccccccccccccccccccccc", + "trunk": "dddddddddddddddddddddddddddddddddddddddd", + "merge": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" + } + } +] diff --git a/contracts/seats/mesh-delivery-checker/fixtures/verbs.json b/contracts/seats/mesh-delivery-checker/fixtures/verbs.json new file mode 100644 index 00000000..af54d39f --- /dev/null +++ b/contracts/seats/mesh-delivery-checker/fixtures/verbs.json @@ -0,0 +1,17 @@ +[ + { + "name": "a checking program asks its input by its digest", + "of": "verb:input.input", + "given": { + "digest": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + } + }, + { + "name": "the input answered as its exact bytes, base64", + "of": "verb:input.answer", + "given": { + "digest": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "input": "eyJ2IjoxfQ==" + } + } +] diff --git a/contracts/seats/mesh-delivery-checker/seat.json b/contracts/seats/mesh-delivery-checker/seat.json new file mode 100644 index 00000000..4354b5ca --- /dev/null +++ b/contracts/seats/mesh-delivery-checker/seat.json @@ -0,0 +1,750 @@ +{ + "format": 1, + "seat": "mesh-delivery-checker", + "scope": "mesh", + "grain": "whole", + "domain": "Delivery", + "decision": "novox/hq ADR 0547", + "verbs": [ + { + "name": "try", + "description": "Try pushed heads before their pull requests are opened: the checker judges them as it judges a delivery group's pull requests, each member merged onto its trunk, its unit and integration levels through the build modules, then the members' manifests, the module graph and the composition on every placed node, and owes the forge nothing. Name the group's members (each a repository and the exact commit), or one repository and its head for a try of one member. Answers the try's id at once; try-read reads where it stands. The same members at the same commits, in any order, are the answer to a second ask while that try waits or runs; at most 4 run on a build node, and 8 wait for one, mesh-wide.", + "optional": true, + "replaces": [ + "sh merge-check.sh", + "docker run postgres" + ], + "input": { + "properties": { + "head": { + "description": "the full commit to try, 40 or 64 hex digits, for a try of one member (instead of members)", + "type": "string" + }, + "level": { + "description": "unit or integration: only this level; both when absent", + "type": "string" + }, + "repository": { + "description": "/, for a try of one member (instead of members)", + "type": "string" + }, + "tests": { + "description": "a go test -run pattern: only those tests run, and nothing is kept", + "type": "string" + }, + "trunk": { + "description": "the trunk commit to merge it onto, or the trunk's branch for its head now; the trunk's head when absent", + "type": "string" + }, + "members": { + "description": "the group: one repository at most once, each at the exact commit asked and the trunk commit it is merged onto; instead of repository and head", + "type": "array", + "items": { + "type": "object", + "properties": { + "repository": { + "description": "/", + "type": "string" + }, + "head": { + "description": "the full commit to try, 40 or 64 hex digits", + "type": "string" + }, + "trunk": { + "description": "the trunk commit to merge it onto, or the trunk's branch for its head now; the trunk's head when absent", + "type": "string" + } + }, + "required": [ + "repository", + "head" + ] + } + } + }, + "type": "object" + }, + "answer": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "the try's id" + }, + "state": { + "type": "string", + "description": "its state" + } + }, + "required": [ + "id" + ] + } + }, + { + "name": "try-read", + "description": "Where a try stands, as stored: queued, running, or its end (pass, fail, or error when it could not run), with what was said of it, the head and the trunk commit it was merged onto, the time it took and what failed first. Read-only.", + "optional": true, + "input": { + "properties": { + "id": { + "description": "the try's id, try-", + "type": "string" + } + }, + "required": [ + "id" + ], + "type": "object" + }, + "answer": { + "type": "object", + "description": "the try: its state, its steps and its verdict when it has one" + } + }, + { + "name": "try-end", + "description": "End a try that waits or runs, as error, by hand: say why (kept in what it says). Its place among the tries is free at once and the next try of its head asks anew with a new id. A try that ended already is refused.", + "optional": true, + "input": { + "properties": { + "id": { + "description": "the try's id, try-", + "type": "string" + }, + "why": { + "description": "why it is ended, in words", + "type": "string" + } + }, + "required": [ + "id", + "why" + ], + "type": "object" + }, + "answer": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "the try" + }, + "ended": { + "type": "boolean", + "description": "it is ended" + } + }, + "required": [ + "id", + "ended" + ] + } + }, + { + "name": "input", + "description": "A checking program's input the checker keeps, by the sha256 of its exact bytes: the facts of a check its verdict is asked of, or the heads its seat list is asked of, as a run ask names it (its input's verb and digest). Answers the digest and the bytes, base64; a digest it keeps no input of is an error. Asked by the build module that runs the program, as its manifest's invokes grants it. Read-only.", + "optional": true, + "input": { + "type": "object", + "properties": { + "digest": { + "type": "string", + "description": "the input's sha256, 64 lowercase hex digits, as the run ask names it" + } + }, + "required": [ + "digest" + ] + }, + "answer": { + "type": "object", + "properties": { + "digest": { + "type": "string", + "description": "the digest asked" + }, + "input": { + "type": "string", + "description": "the input's exact bytes, base64, so no encoding on the way changes a byte the digest covers" + } + }, + "required": [ + "digest", + "input" + ] + } + } + ], + "description": "The merge check's tries: start one, read one, end one. The checker's own events stay under its own subjects (ADR 0620 §2).", + "holder-events": [ + { + "name": "verdict", + "subject": "mesh.stream..event.verdict", + "family": "checker.asks", + "body": { + "type": "object", + "properties": { + "repository": { + "type": "string", + "description": "owner/repo" + }, + "pull": { + "type": "integer", + "description": "the pull request's number, for a pull request's check" + }, + "head": { + "type": "string", + "description": "the commit checked" + }, + "verdict": { + "type": "string", + "description": "pass or fail" + }, + "out-of-step": { + "type": "array", + "items": { + "type": "string" + }, + "description": "the generated files out of step with their seat definitions, each path (design 26)" + }, + "series": { + "type": "array", + "items": { + "type": "object", + "properties": { + "module": { + "type": "string", + "description": "the module whose artifact's series changes" + }, + "artifact": { + "type": "string", + "description": "its artifact, or the package's ecosystem name as the module's packages name it" + }, + "from": { + "type": "string", + "description": "the series on trunk" + }, + "to": { + "type": "string", + "description": "the series the checked head gives it" + } + }, + "required": [ + "module", + "artifact", + "from", + "to" + ] + }, + "description": "each change of an artifact's series the checked head makes against trunk's manifests, once each (design 32); absent when it changes none" + } + }, + "required": [ + "repository", + "head", + "verdict" + ] + } + }, + { + "name": "tag-wanted", + "subject": "mesh.stream..event.tag-wanted", + "family": "checker.asks", + "body": { + "type": "object", + "properties": { + "repository": { + "type": "string", + "description": "owner/repo" + }, + "pull": { + "type": "integer", + "description": "the pull request" + }, + "head": { + "type": "string", + "description": "its head" + }, + "branch": { + "type": "string", + "description": "the pull request's branch: its tag is removed when the branch ends" + }, + "module": { + "type": "string", + "description": "the group member" + }, + "package": { + "type": "string", + "description": "the package it declares" + }, + "tag": { + "type": "string", + "description": "the tag wanted" + } + }, + "required": [ + "repository", + "head", + "branch", + "module", + "package" + ] + } + }, + { + "name": "preview.written", + "subject": "mesh.stream..event.preview.written", + "family": "checks" + } + ], + "states": [ + { + "name": "verdicts", + "template": "state", + "key-forms": [ + { + "key": ".", + "value": { + "type": "object", + "properties": { + "v": { + "type": "integer", + "description": "the layout version" + }, + "group": { + "type": "string" + }, + "heads": { + "type": "string", + "description": "12 hex digits" + }, + "trunk": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "description": "repository to the trunk head merged onto" + }, + "state": { + "type": "string", + "description": "running, pass or fail" + }, + "failed": { + "type": "object", + "properties": { + "test": { + "type": "string" + }, + "machine": { + "type": "string" + }, + "words": { + "type": "string" + } + } + }, + "failures": { + "type": "array", + "items": { + "type": "object", + "properties": { + "test": { + "type": "string" + }, + "machine": { + "type": "string" + }, + "words": { + "type": "string" + } + } + } + }, + "levels": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "run": { + "type": "integer" + }, + "kept": { + "type": "integer" + }, + "skipped": { + "type": "integer" + } + }, + "required": [ + "run", + "kept" + ] + }, + "description": "by level, unit or integration" + }, + "took": { + "type": "object", + "properties": { + "queue": { + "type": "integer" + }, + "clone": { + "type": "integer" + }, + "unit": { + "type": "integer" + }, + "lint": { + "type": "integer" + }, + "integration-setup": { + "type": "integer" + }, + "integration-run": { + "type": "integer" + }, + "composition": { + "type": "integer" + } + }, + "description": "milliseconds" + }, + "machines": { + "type": "array", + "items": { + "type": "string" + } + }, + "sdk": { + "type": "string" + }, + "composition": { + "type": "object", + "properties": { + "state": { + "type": "string", + "description": "not-run, pass or fail" + }, + "words": { + "type": "string" + }, + "sdk": { + "type": "string" + }, + "machines": { + "type": "array", + "items": { + "type": "string" + } + }, + "fpset": { + "type": "string" + } + }, + "required": [ + "state" + ] + }, + "over-bound": { + "type": "array", + "items": { + "type": "string" + } + }, + "beside": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "carried-from": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "series": { + "type": "array", + "items": { + "type": "object", + "properties": { + "module": { + "type": "string" + }, + "artifact": { + "type": "string" + }, + "from": { + "type": "string" + }, + "to": { + "type": "string" + } + }, + "required": [ + "module", + "artifact", + "from", + "to" + ] + } + }, + "judged-by": { + "type": "object", + "description": "the checking program that judged the check: where it was built, the key its build is kept under, whether this check's run built it, and the heads it read (ADR 0644, ADR 0656). Absent in a value written before it", + "properties": { + "program": { + "type": "object", + "properties": { + "repository": { + "type": "string" + }, + "commit": { + "type": "string" + }, + "dir": { + "type": "string" + }, + "package": { + "type": "string" + } + }, + "required": [ + "repository", + "commit", + "dir", + "package" + ] + }, + "key": { + "type": "string", + "description": "program- and 16 hex digits" + }, + "built": { + "type": "boolean" + }, + "heads": { + "type": "array", + "items": { + "type": "object", + "properties": { + "repository": { + "type": "string" + }, + "commit": { + "type": "string" + } + }, + "required": [ + "repository", + "commit" + ] + } + } + }, + "required": [ + "program", + "key", + "heads" + ] + } + }, + "required": [ + "group", + "heads", + "state" + ], + "description": "one check of a group at one set of heads" + }, + "description": "one check of a group; the group is an escaped branch name, heads 12 hex digits" + } + ], + "description": "the checker's verdict of each group at each set of heads", + "history": 5 + }, + { + "name": "kept", + "template": "state", + "key-forms": [ + { + "key": ".", + "value": { + "type": "object", + "properties": { + "v": { + "type": "integer", + "description": "the layout version" + }, + "state": { + "type": "string", + "description": "pass: a failure is never kept" + }, + "repository": { + "type": "string" + }, + "package": { + "type": "string" + }, + "level": { + "type": "string" + }, + "tests": { + "type": "integer" + }, + "took": { + "type": "integer" + }, + "toolchain": { + "type": "string" + }, + "at": { + "type": "string", + "description": "when (RFC 3339 time)" + } + }, + "required": [ + "state", + "repository", + "package", + "level" + ], + "description": "one passing level of one package, kept so the next check does not run it again" + }, + "description": "a passing level of one package by its content digest" + } + ], + "description": "the kept passing levels", + "history": 1 + }, + { + "name": "owed", + "template": "state", + "key-forms": [ + { + "key": "...", + "value": { + "type": "object", + "properties": { + "v": { + "type": "integer", + "description": "the layout version" + }, + "act": { + "type": "string", + "description": "status=, view, note or comment." + }, + "context": { + "type": "string" + }, + "target": { + "type": "string" + }, + "state": { + "type": "string" + }, + "text": { + "type": "string" + }, + "ref": { + "type": "string" + } + }, + "required": [ + "act" + ], + "description": "one act owed to the forge, written by its owner and done by the forge bridge" + }, + "description": "the status mesh/check the checker owes the forge for a head (its act is status=mesh/check)" + } + ], + "description": "the status the checker owes the forge", + "history": 5 + }, + { + "name": "previews", + "template": "state", + "key-forms": [ + { + "key": "..", + "value": { + "type": "object", + "properties": { + "v": { + "type": "integer", + "description": "the layout version" + }, + "repository": { + "type": "string" + }, + "head": { + "type": "string" + }, + "trunk": { + "type": "string" + }, + "merge": { + "type": "string", + "description": "the merge commit" + }, + "conflicts": { + "type": "array", + "items": { + "type": "string" + }, + "description": "files in conflict" + }, + "modules": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "tree": { + "type": "string" + }, + "manifest": { + "type": "string", + "description": "manifest digest" + }, + "locks": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "paths": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "packages": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "tree", + "manifest" + ] + }, + "description": "by directory" + } + }, + "required": [ + "repository", + "head", + "trunk" + ], + "description": "the merge of a head onto a trunk commit, read before a pull request is checked" + }, + "description": "the merge of a head onto a trunk commit" + } + ], + "description": "the previews of a head merged onto a trunk", + "history": 5 + } + ] +} diff --git a/contracts/seats/mesh-delivery/fixtures/states.json b/contracts/seats/mesh-delivery/fixtures/states.json new file mode 100644 index 00000000..0721e1da --- /dev/null +++ b/contracts/seats/mesh-delivery/fixtures/states.json @@ -0,0 +1,72 @@ +[ + { + "name": "the source revision", + "of": "state:source/main", + "given": { + "v": 1, + "group": "feat/registry", + "heads": { + "novox/mesh-sdk": "cccccccccccccccccccccccccccccccccccccccc" + } + } + }, + { + "name": "a merge", + "of": "state:merges/", + "given": { + "v": 1, + "group": "feat/registry", + "ask": "merge.feat=registry.0123456789ab", + "pulls": [ + { + "repository": "novox/mesh-sdk", + "number": 1, + "head": "cccccccccccccccccccccccccccccccccccccccc", + "state": "merging" + } + ] + } + }, + { + "name": "an ask", + "of": "state:asks/merge..", + "given": { + "v": 1, + "group": "feat/registry", + "heads": [ + { + "repository": "novox/mesh-sdk", + "number": 1, + "head": "cccccccccccccccccccccccccccccccccccccccc" + } + ], + "ask": { + "headline": "merge" + } + } + }, + { + "name": "an act owed", + "of": "state:owed/...", + "given": { + "v": 1, + "act": "view" + } + }, + { + "name": "a turn", + "of": "state:turns/", + "given": { + "v": 1, + "repository": "novox/mesh-sdk", + "waiting": [ + { + "group": "feat/registry", + "heads": "0123456789ab", + "approved": "2026-11-05T10:00:00Z", + "since": "2026-11-05T10:01:00Z" + } + ] + } + } +] diff --git a/contracts/seats/mesh-delivery/seat.json b/contracts/seats/mesh-delivery/seat.json new file mode 100644 index 00000000..dd617cc4 --- /dev/null +++ b/contracts/seats/mesh-delivery/seat.json @@ -0,0 +1,574 @@ +{ + "format": 1, + "seat": "mesh-delivery", + "scope": "mesh", + "grain": "whole", + "domain": "Delivery", + "decision": "novox/hq ADR 0239", + "verbs": [ + { + "name": "deliveries", + "description": "Every delivery not final, and those that ended in the last day, one line each: its id (owner/repository@commit), its state, its group, what it waits for and since when. Narrowed by state, repository or group.", + "input": { + "properties": { + "all": { + "description": "\"true\": the final ones of the last thirty days too", + "enum": [ + "true", + "false" + ], + "type": "string" + }, + "group": { + "description": "a group's id (its branch name)", + "type": "string" + }, + "repository": { + "description": "owner/repository", + "type": "string" + }, + "state": { + "description": "one state, e.g. delivering or held", + "type": "string" + } + }, + "type": "object" + }, + "answer": { + "type": "object", + "description": "what deliveries answers, as its description says" + } + }, + { + "name": "times", + "description": "Removed in R6 (ADR 0539): refused by name, naming its replacement: the delivery view (nox-delivery): each machine's want and have, and since when", + "optional": true, + "input": { + "properties": {}, + "type": "object" + }, + "answer": { + "type": "object", + "description": "what times answers, as its description says" + } + }, + { + "name": "show", + "description": "One delivery or group whole: its delivery plan (what it builds, what each machine receives, what is not an ordinary send), every transition with when and why, the machine steps of its walk, its group and its order.", + "input": { + "properties": { + "id": { + "description": "a delivery's id, or a group's", + "type": "string" + } + }, + "required": [ + "id" + ], + "type": "object" + }, + "answer": { + "type": "object", + "description": "what show answers, as its description says" + } + }, + { + "name": "groups", + "description": "Every delivery group: its members in order, why each pair is ordered (declared, built by, version skew, engine before controller, by name), its composed check and its state, derived from its members.", + "input": { + "properties": { + "all": { + "description": "\"true\": the groups that ended too", + "enum": [ + "true", + "false" + ], + "type": "string" + } + }, + "type": "object" + }, + "answer": { + "type": "object", + "description": "what groups answers, as its description says" + } + }, + { + "name": "what-if", + "description": "Removed in R6 (ADR 0539): refused by name, naming its replacement: the merge check's composition, which composes every machine a change touches", + "optional": true, + "input": { + "properties": {}, + "type": "object" + }, + "answer": { + "type": "object", + "description": "what what-if answers, as its description says" + } + }, + { + "name": "checks", + "description": "What the mesh's checks said of a pull request's head or of one commit: each of the commit's mesh statuses (mesh/check, the one a main requires from R4e, hq's mesh/repo-check, mesh/delivery, …) with its state, description and when it was set; the merge check's full verdict as the controller said it — each layer's summary, the machine that ran it, when, its build id and its report; and whether the branch's protection would let it merge, every required status being success.", + "optional": true, + "input": { + "properties": { + "commit": { + "description": "a commit's sha, or the start of one, instead of a pull request", + "type": "string" + }, + "number": { + "description": "a pull request's number: its head is read", + "type": "string" + }, + "repository": { + "description": "owner/repository", + "type": "string" + } + }, + "required": [ + "repository" + ], + "type": "object" + }, + "answer": { + "type": "object", + "description": "what checks answers, as its description says" + } + }, + { + "name": "table", + "description": "The state table every delivery runs by: each transition with its guard, each state's bound and what healer H2 may do once it has passed; and the machine steps' table.", + "input": { + "properties": {}, + "type": "object" + }, + "answer": { + "type": "object", + "description": "what table answers, as its description says" + } + }, + { + "name": "stalled", + "description": "Every delivery held past its state's bound, with the transition the table lets healer H2 take for it, or none.", + "input": { + "properties": {}, + "type": "object" + }, + "answer": { + "type": "object", + "description": "what stalled answers, as its description says" + } + }, + { + "name": "recheck", + "description": "Check a rejected or ready delivery again: it goes back to proposed and the controller is asked for its check. With why.", + "input": { + "properties": { + "id": { + "description": "the delivery's id", + "type": "string" + }, + "why": { + "description": "why, kept with the transition", + "type": "string" + } + }, + "required": [ + "id", + "why" + ], + "type": "object" + }, + "answer": { + "type": "object", + "description": "what recheck answers, as its description says" + } + }, + { + "name": "release", + "description": "Removed in R6 (ADR 0539): refused by name, naming its replacement: the assigner's asks (hold, restore, backup, record), answered on the phone or in the delivery view", + "optional": true, + "input": { + "properties": {}, + "type": "object" + }, + "answer": { + "type": "object", + "description": "what release answers, as its description says" + } + }, + { + "name": "stop", + "description": "Removed in R6 (ADR 0539): refused by name, naming its replacement: a bad fingerprint is held and its canary set back by the assigner (ADR 0429 §6, ADR 0539 clause 18)", + "optional": true, + "input": { + "properties": {}, + "type": "object" + }, + "answer": { + "type": "object", + "description": "what stop answers, as its description says" + } + }, + { + "name": "close", + "description": "Healer H2's verb: take the one transition the table names for a delivery held past its bound. Refused for any other.", + "input": { + "properties": { + "id": { + "description": "the delivery's id", + "type": "string" + }, + "why": { + "description": "the condition it answers", + "type": "string" + } + }, + "required": [ + "id", + "why" + ], + "type": "object" + }, + "answer": { + "type": "object", + "description": "what close answers, as its description says" + } + }, + { + "name": "retire-history", + "description": "Removed in R6 (ADR 0539): refused by name, naming its replacement: nothing to retire: the gate keeps no delivery after its merge", + "optional": true, + "input": { + "properties": {}, + "type": "object" + }, + "answer": { + "type": "object", + "description": "what retire-history answers, as its description says" + } + }, + { + "name": "settle", + "description": "Ask the operator to settle deliveries held because their merge reached the trunk without a passing check: one ask on the operator's channel for all of them, as an Approve is asked. The verb moves nothing; on the operator's answer each goes to merged, and whether it is on the machines is derived as for any merge (novox/hq ADR 0556). Refused for a delivery not held, or held for another reason, naming why.", + "optional": true, + "input": { + "additionalProperties": false, + "properties": { + "ids": { + "description": "the held deliveries' ids (owner/repository@commit)", + "items": { + "type": "string" + }, + "minItems": 1, + "type": "array" + }, + "why": { + "description": "why they may be settled, shown to the operator", + "type": "string" + } + }, + "required": [ + "ids", + "why" + ], + "type": "object" + }, + "answer": { + "type": "object", + "description": "what settle answers, as its description says" + } + } + ], + "description": "The delivery gate: reads and steers deliveries, merges and the checks that decide them; the merge is made on the operator's Approve once both checks passed.", + "states": [ + { + "name": "source", + "template": "state", + "key-forms": [ + { + "key": "main", + "value": { + "type": "object", + "properties": { + "v": { + "type": "integer", + "description": "the layout version" + }, + "group": { + "type": "string", + "description": "the group that moved it" + }, + "heads": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "description": "repository to the merged head, a full commit" + } + }, + "required": [ + "group", + "heads" + ], + "description": "every repository's merged head; its revision is the mesh's source revision" + }, + "description": "the one key, written by the holder alone by compare-and-set" + } + ], + "description": "the mesh's source revision", + "history": 64 + }, + { + "name": "merges", + "template": "state", + "key-forms": [ + { + "key": "", + "value": { + "type": "object", + "properties": { + "v": { + "type": "integer", + "description": "the layout version" + }, + "group": { + "type": "string" + }, + "ask": { + "type": "string", + "description": "the ask key" + }, + "approved": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "description": "@ to the approved head" + }, + "pulls": { + "type": "array", + "items": { + "type": "object", + "properties": { + "repository": { + "type": "string" + }, + "number": { + "type": "integer" + }, + "head": { + "type": "string" + }, + "state": { + "type": "string", + "description": "merging, merged or refused" + }, + "commit": { + "type": "string" + }, + "words": { + "type": "string" + } + }, + "required": [ + "repository", + "number", + "head", + "state" + ] + } + } + }, + "required": [ + "group", + "ask", + "pulls" + ], + "description": "one group's merge and how far each pull request is" + }, + "description": "a group's merge" + } + ], + "description": "the merges", + "history": 10 + }, + { + "name": "asks", + "template": "state", + "key-forms": [ + { + "key": "merge..", + "value": { + "type": "object", + "properties": { + "v": { + "type": "integer", + "description": "the layout version" + }, + "group": { + "type": "string" + }, + "heads": { + "type": "array", + "items": { + "type": "object", + "properties": { + "repository": { + "type": "string", + "description": "/" + }, + "number": { + "type": "integer" + }, + "head": { + "type": "string", + "description": "the full commit" + } + }, + "required": [ + "repository", + "number", + "head" + ] + } + }, + "ask": { + "description": "the operator-channel ask as raised" + }, + "asking": { + "type": "integer", + "description": "the number of times it was asked" + } + }, + "required": [ + "group", + "heads", + "ask" + ] + }, + "description": "the ask of the operator for one group at one set of heads" + } + ], + "description": "the asks of the operator for a merge", + "history": 5 + }, + { + "name": "owed", + "template": "state", + "key-forms": [ + { + "key": "...", + "value": { + "type": "object", + "properties": { + "v": { + "type": "integer", + "description": "the layout version" + }, + "act": { + "type": "string", + "description": "status=, view, note or comment." + }, + "context": { + "type": "string" + }, + "target": { + "type": "string" + }, + "state": { + "type": "string" + }, + "text": { + "type": "string" + }, + "ref": { + "type": "string" + } + }, + "required": [ + "act" + ], + "description": "one act owed to the forge, written by its owner and done by the forge bridge" + }, + "description": "one act owed to the forge" + } + ], + "description": "the acts the gate owes the forge", + "history": 5 + }, + { + "name": "turns", + "template": "state", + "key-forms": [ + { + "key": "", + "value": { + "type": "object", + "properties": { + "v": { + "type": "integer", + "description": "the layout version" + }, + "repository": { + "type": "string" + }, + "waiting": { + "type": "array", + "items": { + "type": "object", + "properties": { + "group": { + "type": "string" + }, + "heads": { + "type": "string" + }, + "ask": { + "type": "string" + }, + "approved": { + "type": "string", + "format": "date-time", + "description": "when (RFC 3339 time)" + }, + "since": { + "type": "string", + "format": "date-time", + "description": "when (RFC 3339 time)" + }, + "waits-for": { + "type": "string" + }, + "waits-since": { + "type": "string", + "format": "date-time", + "description": "when (RFC 3339 time)" + }, + "step": { + "type": "string" + } + }, + "required": [ + "group", + "heads", + "approved", + "since" + ] + } + } + }, + "required": [ + "repository", + "waiting" + ], + "description": "the merges of a repository in the order they wait for their turn" + }, + "description": "the merges waiting on a repository" + } + ], + "description": "which merge waits for which", + "history": 5 + } + ] +} diff --git a/contracts/seats/mesh-wants/fixtures/states.json b/contracts/seats/mesh-wants/fixtures/states.json new file mode 100644 index 00000000..cb38a399 --- /dev/null +++ b/contracts/seats/mesh-wants/fixtures/states.json @@ -0,0 +1,47 @@ +[ + { + "name": "a want", + "of": "state:wants/want..", + "given": { + "v": 1, + "fp": "ab12", + "rule": 3 + } + }, + { + "name": "a node's own want", + "of": "state:wants/want..node", + "given": { + "v": 1, + "composed": "sha256:aa", + "self": true + } + }, + { + "name": "a held fingerprint", + "of": "state:wants/held..", + "given": { + "v": 1, + "node": "ace" + } + }, + { + "name": "a difference", + "of": "state:differences/.", + "given": { + "v": 1, + "want": "ab12", + "unexplained": true + } + }, + { + "name": "a composition", + "of": "state:compositions/.", + "given": { + "v": 1, + "group": "g1", + "heads": "0123456789ab", + "state": "pass" + } + } +] diff --git a/contracts/seats/mesh-wants/seat.json b/contracts/seats/mesh-wants/seat.json new file mode 100644 index 00000000..a8834526 --- /dev/null +++ b/contracts/seats/mesh-wants/seat.json @@ -0,0 +1,284 @@ +{ + "format": 1, + "seat": "mesh-wants", + "scope": "mesh", + "grain": "whole", + "domain": "Delivery", + "decision": "novox/hq ADR 0565, ADR 0634, ADR 0635", + "description": "The seat that says what each node should run and whether each module's new builds wait for a person (ADR 0565, ADR 0539).", + "verbs": [ + { + "name": "upgrade", + "description": "Whether each module's new builds wait for a person: its policy (roll, together or record) and where it comes from (a person, the module, the bus, its irreplaceable data, the default); the bus is never rolled. With module, that one; with module, policy and why, a person's choice for it, recorded as a hand act; with policy default, the choice taken back. With compare, each node's own composition and the assigner's findings.", + "input": { + "properties": { + "compare": { + "description": "\"true\": each node's own composition, by its digest, and the assigner's findings", + "enum": [ + "true", + "false" + ], + "type": "string" + }, + "module": { + "description": "one module", + "type": "string" + }, + "policy": { + "description": "with module: roll, together, record, or default to take a person's choice back", + "type": "string" + }, + "why": { + "description": "with policy: why, required and kept", + "type": "string" + } + }, + "type": "object" + }, + "answer": { + "type": "object", + "description": "each module's policy, or the one chosen, or the comparison" + } + }, + { + "name": "bus", + "description": "What a bus upgrade would do: the fingerprint of the bus module each node runs against the indexer's newest, and the last bus steps from the hand-act log and the assigner's puts of the bus. Read-only: the step itself is the put on the operator's warrant.", + "input": { + "properties": {}, + "type": "object" + }, + "answer": { + "type": "object", + "description": "what a bus upgrade would do, read only" + } + } + ], + "events": [ + { + "name": "policy-changed", + "body": { + "type": "object" + } + } + ], + "family": { + "name": "mesh-wants.events", + "max-age-days": 7 + }, + "states": [ + { + "name": "wants", + "template": "state", + "key-forms": [ + { + "key": "want..", + "value": { + "type": "object", + "description": "a node's want for a module", + "properties": { + "v": { + "type": "integer" + }, + "fp": { + "type": "string" + }, + "composed": { + "type": "string" + }, + "removed": { + "type": "boolean" + }, + "newest": { + "type": "string" + }, + "rule": { + "type": "integer", + "description": "the rule that holds the node back, 1 to 10" + }, + "backup": { + "type": "string", + "description": "again or skip", + "enum": [ + "again", + "skip" + ] + }, + "words": { + "type": "string" + }, + "put": { + "type": "boolean" + }, + "hold": { + "type": "boolean" + }, + "yes": { + "type": "string" + }, + "release": { + "type": "string" + }, + "self": { + "type": "boolean" + } + } + }, + "description": "a node's want for a module" + }, + { + "key": "want..node", + "value": { + "type": "object", + "description": "a node's own want", + "properties": { + "v": { + "type": "integer" + }, + "composed": { + "type": "string" + }, + "words": { + "type": "string" + }, + "self": { + "type": "boolean" + } + }, + "required": [ + "composed" + ] + }, + "description": "a node's own want, naming the digest of its own composition" + }, + { + "key": "held..", + "value": { + "type": "object", + "description": "a fingerprint the assigner holds", + "properties": { + "v": { + "type": "integer" + }, + "node": { + "type": "string" + }, + "words": { + "type": "string" + } + }, + "required": [ + "node" + ] + }, + "description": "a module's fingerprint the assigner holds" + } + ], + "history": 20, + "description": "what each node should run, and the fingerprints held back" + }, + { + "name": "differences", + "template": "state", + "key-forms": [ + { + "key": ".", + "value": { + "type": "object", + "description": "the difference between a want and what is sent", + "properties": { + "v": { + "type": "integer" + }, + "want": { + "type": "string" + }, + "want-removed": { + "type": "boolean" + }, + "sent": { + "type": "string" + }, + "sent-build": { + "type": "string" + }, + "rule": { + "type": "integer" + }, + "unexplained": { + "type": "boolean" + }, + "words": { + "type": "string" + } + } + }, + "description": "a module on a node" + } + ], + "history": 64, + "description": "differences between a want and what the node-engine is sent" + }, + { + "name": "compositions", + "template": "state", + "key-forms": [ + { + "key": ".", + "value": { + "type": "object", + "description": "the composition verdict of a group's check at its heads", + "properties": { + "v": { + "type": "integer" + }, + "group": { + "type": "string" + }, + "heads": { + "type": "string", + "description": "12 lowercase hex digits" + }, + "state": { + "type": "string", + "description": "pass, fail or waits" + }, + "words": { + "type": "string" + }, + "sdk": { + "type": "string" + }, + "machines": { + "type": "array", + "items": { + "type": "string" + } + }, + "machine": { + "type": "string" + }, + "warnings": { + "type": "array", + "items": { + "type": "string" + } + }, + "took": { + "type": "integer", + "description": "milliseconds" + } + }, + "required": [ + "group", + "heads", + "state" + ] + }, + "description": "a group's check at its heads" + } + ], + "history": 5, + "description": "composition verdicts of a group's check; the checker reads them" + } + ] +} diff --git a/contracts/seats/node-build-go/fixtures/states.json b/contracts/seats/node-build-go/fixtures/states.json new file mode 100644 index 00000000..bde9d6d6 --- /dev/null +++ b/contracts/seats/node-build-go/fixtures/states.json @@ -0,0 +1,316 @@ +[ + { + "name": "a running step", + "of": "state:steps/.", + "given": { + "v": 1, + "id": "a1b2", + "tool": "build", + "node": "ace", + "state": "running", + "started": "2026-11-05T10:00:00Z" + } + }, + { + "name": "a paused node", + "of": "state:pauses/paused.", + "given": { + "v": 1, + "paused": true, + "by": "operator@shanks", + "at": "2026-11-05T10:00:00Z" + } + }, + { + "name": "a node's capacity", + "of": "state:pauses/capacity.", + "given": { + "v": 1, + "effective": 2, + "configured": 2, + "why": "the configured steps fit the cores" + } + }, + { + "name": "a node's toolchain", + "of": "state:toolchains/.", + "given": { + "v": 1, + "language": "go", + "version": "1.26.0", + "recipe": "1" + } + }, + { + "name": "a build ask", + "of": "accept:delivery", + "subject": "mesh.seat.node-build-go.accept.delivery", + "given": { + "id": "a1b2", + "kind": "build", + "language": "go", + "asker": "mesh-delivery-builder", + "build": { + "module": "nats", + "artifacts": [ + { + "artifact": "go" + } + ] + } + } + }, + { + "name": "a check ask with its fetch, siblings and levels", + "of": "accept:check", + "subject": "mesh.seat.node-build-go.accept.check", + "given": { + "v": 1, + "id": "c3d4", + "kind": "check", + "language": "go", + "asker": "mesh-delivery-checker", + "at": "2026-11-05T10:00:00Z", + "check": { + "fetch": { + "repository": "novox/mesh-catalog", + "commit": "0123456789abcdef0123456789abcdef01234567", + "ref": "refs/pull/7/head", + "base": "89abcdef0123456789abcdef0123456789abcdef", + "beside": [ + { + "repository": "novox/mesh-sdk", + "commit": "fedcba9876543210fedcba9876543210fedcba98" + } + ], + "packages": { + "git.novox.be/novox/mesh-sdk/go": "fedcba9876543210fedcba9876543210fedcba98" + } + }, + "levels": [ + "unit", + "integration" + ], + "kept": true, + "toolchain": { + "language": "go", + "version": "1.26.0", + "recipe": "2" + }, + "node-names": [ + "ace", + "g14" + ] + } + } + }, + { + "name": "a finished build step with its artifacts", + "of": "state:steps/.", + "given": { + "v": 1, + "id": "a1b2", + "tool": "build", + "node": "ace", + "state": "passed", + "started": "2026-11-05T10:00:00Z", + "ended": "2026-11-05T10:03:00Z", + "result": { + "artifacts": [ + { + "artifact": "go", + "digest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "reference": "container-image-registry/nats/go@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + } + ], + "toolchain": { + "language": "go", + "version": "1.26.0", + "recipe": "1" + }, + "imports": [ + "git.novox.be/novox/mesh-sdk/go" + ] + } + } + }, + { + "name": "a finished check step with its levels", + "of": "state:steps/.", + "given": { + "v": 1, + "id": "c3d4", + "tool": "check", + "node": "ace", + "state": "passed", + "started": "2026-11-05T10:00:00Z", + "ended": "2026-11-05T10:06:00Z", + "result": { + "fetch": { + "workspace": "mvii1iwu5414e4", + "commit": "0123456789abcdef0123456789abcdef01234567", + "modules": [ + "." + ], + "base": "abcdef0123456789abcdef0123456789abcdef01", + "merge": "fedcba9876543210fedcba9876543210fedcba98", + "changed": [ + "a.go", + "modules/m/main.go" + ], + "trees": { + "modules/m": { + "tree": "1111111111111111111111111111111111111111", + "manifest": "ca3d163bab055381827226140568f3bef7eaac187cebd76878e0b63e9e442356", + "locks": { + "go.sum": "c5fc83c01e92404452b986527d239140ccf9a48b88e0c268fbf38c2e1429e9c9" + } + } + } + }, + "levels": [ + { + "level": "unit", + "digest": { + "level": "unit", + "packages": [ + { + "package": "./modules/a", + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + } + ] + }, + "kept": [ + { + "package": "./modules/a", + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "tests": 12 + } + ], + "tests": [ + { + "package": "./modules/b", + "pass": true + } + ], + "unmet": [ + { + "package": "./modules/c", + "needs": [ + "docker" + ] + } + ] + } + ] + } + } + }, + { + "name": "a declined step", + "of": "state:steps/.", + "given": { + "v": 1, + "id": "c3d5", + "tool": "check", + "node": "ace", + "state": "refused", + "started": "2026-11-05T10:00:00Z", + "result": { + "declined": true, + "needs": [ + "docker" + ], + "lacks": [ + "docker" + ] + } + } + }, + { + "name": "a finished check step whose head does not merge", + "of": "state:steps/.", + "given": { + "v": 1, + "id": "c3d5", + "tool": "check", + "node": "ace", + "state": "passed", + "started": "2026-11-05T10:00:00Z", + "ended": "2026-11-05T10:01:00Z", + "result": { + "fetch": { + "workspace": "mvii1iwz5050eb", + "commit": "0123456789abcdef0123456789abcdef01234567", + "modules": [ + "." + ], + "base": "abcdef0123456789abcdef0123456789abcdef01", + "conflicts": [ + "a.go", + "sub/b.go" + ] + } + } + } + }, + { + "name": "a run ask of the checking program over two heads", + "of": "accept:check", + "subject": "mesh.seat.node-build-go.accept.check", + "given": { + "v": 1, + "id": "r5e6", + "kind": "check", + "language": "go", + "asker": "mesh-delivery-checker", + "at": "2026-11-05T10:00:00Z", + "run": { + "program": { + "repository": "novox/mesh-delivery", + "commit": "0123456789abcdef0123456789abcdef01234567", + "dir": "modules/mesh-delivery-checker", + "package": "./cmd/mesh-checking-program" + }, + "heads": [ + { + "repository": "novox/mesh-delivery", + "commit": "0123456789abcdef0123456789abcdef01234567" + }, + { + "repository": "novox/mesh-sdk", + "commit": "0123456789abcdef0123456789abcdef01234567" + } + ], + "input": { + "verb": "seat:mesh-delivery-checker.input", + "digest": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "size": 7 + }, + "args": [ + "-verdict" + ], + "reader": "checking program of mesh-delivery head" + } + } + }, + { + "name": "a finished run step whose program exited non-zero", + "of": "state:steps/.", + "given": { + "v": 1, + "id": "r5e6", + "tool": "check", + "node": "ace", + "state": "failed", + "started": "2026-11-05T10:00:00Z", + "ended": "2026-11-05T10:01:00Z", + "result": { + "built": true, + "key": "program-0123456789abcdef", + "exit": 2, + "said": "the facts name no head" + } + } + } +] diff --git a/contracts/seats/node-build-go/seat.json b/contracts/seats/node-build-go/seat.json new file mode 100644 index 00000000..a810a6bb --- /dev/null +++ b/contracts/seats/node-build-go/seat.json @@ -0,0 +1,88 @@ +{ + "format": 1, + "seat": "node-build-go", + "scope": "node", + "grain": "whole", + "domain": "Delivery", + "decision": "novox/hq ADR 0525, ADR 0536, ADR 0553", + "pulled-by-kind": true, + "yields": true, + "accepts": [ + { + "name": "delivery", + "body": { + "$ref": "#/defs/StepAsk" + } + }, + { + "name": "check", + "body": { + "$ref": "#/defs/StepAsk" + } + }, + { + "name": "recheck", + "body": { + "$ref": "#/defs/StepAsk" + } + } + ], + "description": "The Go build module on a node: it takes the asks of the builder and the checker, runs them as steps and answers.", + "includes": [ + "build-step" + ], + "states": [ + { + "name": "steps", + "template": "state", + "key-forms": [ + { + "key": ".", + "value": { + "$ref": "#/defs/Step" + }, + "description": "a step on a node" + } + ], + "description": "the steps this module runs", + "history": 5 + }, + { + "name": "pauses", + "template": "state", + "key-forms": [ + { + "key": "paused.", + "value": { + "$ref": "#/defs/Pause" + }, + "description": "whether a node is paused" + }, + { + "key": "capacity.", + "value": { + "$ref": "#/defs/Capacity" + }, + "description": "a node's capacity" + } + ], + "description": "the pause and the capacity of each node", + "history": 1 + }, + { + "name": "toolchains", + "template": "state", + "key-forms": [ + { + "key": ".", + "value": { + "$ref": "#/defs/Toolchain" + }, + "description": "the toolchain a node reports" + } + ], + "description": "the toolchains the nodes report", + "history": 1 + } + ] +} diff --git a/contracts/seats/node-build-image/fixtures/states.json b/contracts/seats/node-build-image/fixtures/states.json new file mode 100644 index 00000000..b49e8c53 --- /dev/null +++ b/contracts/seats/node-build-image/fixtures/states.json @@ -0,0 +1,63 @@ +[ + { + "name": "a running step", + "of": "state:steps/.", + "given": { + "v": 1, + "id": "a1b2", + "tool": "build", + "node": "ace", + "state": "running", + "started": "2026-11-05T10:00:00Z" + } + }, + { + "name": "a paused node", + "of": "state:pauses/paused.", + "given": { + "v": 1, + "paused": true, + "by": "operator@shanks", + "at": "2026-11-05T10:00:00Z" + } + }, + { + "name": "a node's capacity", + "of": "state:pauses/capacity.", + "given": { + "v": 1, + "effective": 2, + "configured": 2, + "why": "the configured steps fit the cores" + } + }, + { + "name": "a node's toolchain", + "of": "state:toolchains/.", + "given": { + "v": 1, + "language": "go", + "version": "1.26.0", + "recipe": "1" + } + }, + { + "name": "a build ask", + "of": "accept:delivery", + "subject": "mesh.seat.node-build-image.accept.delivery", + "given": { + "id": "a1b2", + "kind": "build", + "language": "go", + "asker": "mesh-delivery-builder", + "build": { + "module": "nats", + "artifacts": [ + { + "artifact": "go" + } + ] + } + } + } +] diff --git a/contracts/seats/node-build-image/seat.json b/contracts/seats/node-build-image/seat.json new file mode 100644 index 00000000..037a8907 --- /dev/null +++ b/contracts/seats/node-build-image/seat.json @@ -0,0 +1,88 @@ +{ + "format": 1, + "seat": "node-build-image", + "scope": "node", + "grain": "whole", + "domain": "Delivery", + "decision": "novox/hq ADR 0525, ADR 0536, ADR 0553", + "pulled-by-kind": true, + "yields": true, + "accepts": [ + { + "name": "delivery", + "body": { + "$ref": "#/defs/StepAsk" + } + }, + { + "name": "check", + "body": { + "$ref": "#/defs/StepAsk" + } + }, + { + "name": "recheck", + "body": { + "$ref": "#/defs/StepAsk" + } + } + ], + "description": "The image build module on a node: it takes the asks of the builder and the checker, runs them as steps and answers.", + "includes": [ + "build-step" + ], + "states": [ + { + "name": "steps", + "template": "state", + "key-forms": [ + { + "key": ".", + "value": { + "$ref": "#/defs/Step" + }, + "description": "a step on a node" + } + ], + "description": "the steps this module runs", + "history": 5 + }, + { + "name": "pauses", + "template": "state", + "key-forms": [ + { + "key": "paused.", + "value": { + "$ref": "#/defs/Pause" + }, + "description": "whether a node is paused" + }, + { + "key": "capacity.", + "value": { + "$ref": "#/defs/Capacity" + }, + "description": "a node's capacity" + } + ], + "description": "the pause and the capacity of each node", + "history": 1 + }, + { + "name": "toolchains", + "template": "state", + "key-forms": [ + { + "key": ".", + "value": { + "$ref": "#/defs/Toolchain" + }, + "description": "the toolchain a node reports" + } + ], + "description": "the toolchains the nodes report", + "history": 1 + } + ] +} diff --git a/contracts/seats/node-build-node/fixtures/states.json b/contracts/seats/node-build-node/fixtures/states.json new file mode 100644 index 00000000..125819d0 --- /dev/null +++ b/contracts/seats/node-build-node/fixtures/states.json @@ -0,0 +1,135 @@ +[ + { + "name": "a running step", + "of": "state:steps/.", + "given": { + "v": 1, + "id": "a1b2", + "tool": "build", + "node": "ace", + "state": "running", + "started": "2026-11-05T10:00:00Z" + } + }, + { + "name": "a paused node", + "of": "state:pauses/paused.", + "given": { + "v": 1, + "paused": true, + "by": "operator@shanks", + "at": "2026-11-05T10:00:00Z" + } + }, + { + "name": "a node's capacity", + "of": "state:pauses/capacity.", + "given": { + "v": 1, + "effective": 2, + "configured": 2, + "why": "the configured steps fit the cores" + } + }, + { + "name": "a node's toolchain", + "of": "state:toolchains/.", + "given": { + "v": 1, + "language": "go", + "version": "1.26.0", + "recipe": "1" + } + }, + { + "name": "a build ask", + "of": "accept:delivery", + "subject": "mesh.seat.node-build-node.accept.delivery", + "given": { + "id": "a1b2", + "kind": "build", + "language": "go", + "asker": "mesh-delivery-builder", + "build": { + "module": "nats", + "artifacts": [ + { + "artifact": "go" + } + ] + } + } + }, + { + "name": "a check ask with the group's tagged package", + "of": "accept:check", + "subject": "mesh.seat.node-build-node.accept.check", + "given": { + "v": 1, + "id": "c3d5", + "kind": "check", + "language": "typescript", + "asker": "mesh-delivery-checker", + "at": "2026-11-05T10:00:00Z", + "check": { + "fetch": { + "repository": "novox/mesh-delivery", + "commit": "0123456789abcdef0123456789abcdef01234567", + "ref": "refs/pull/7/head", + "base": "89abcdef0123456789abcdef0123456789abcdef", + "package": { + "name": "@novox/mesh-sdk", + "version": "0.1.15-feat-x.1" + } + }, + "levels": [ + "unit", + "integration" + ], + "kept": true, + "toolchain": { + "language": "typescript", + "version": "26.12.0", + "recipe": "1" + }, + "node-names": [ + "ace", + "g14" + ] + } + } + }, + { + "name": "a finished check step of a TypeScript merge preview", + "of": "state:steps/.", + "given": { + "v": 1, + "id": "c3d6", + "tool": "check", + "node": "ace", + "state": "passed", + "started": "2026-11-05T10:00:00Z", + "ended": "2026-11-05T10:01:00Z", + "result": { + "fetch": { + "workspace": "mvii1sxt979f67", + "commit": "0123456789abcdef0123456789abcdef01234567", + "modules": [ + "modules/m" + ], + "base": "abcdef0123456789abcdef0123456789abcdef01", + "merge": "0123456789abcdef0123456789abcdef01234567", + "changed": [ + "modules/m/package.json" + ], + "trees": { + "modules/m": { + "tree": "0123456789abcdef0123456789abcdef01234567", + "manifest": "0581b699d9d05517e69b21f5cedbeb2eee8aba2b205280b561be731594d7feef" + } + } + } + } + } + } +] diff --git a/contracts/seats/node-build-node/seat.json b/contracts/seats/node-build-node/seat.json new file mode 100644 index 00000000..6fce1468 --- /dev/null +++ b/contracts/seats/node-build-node/seat.json @@ -0,0 +1,88 @@ +{ + "format": 1, + "seat": "node-build-node", + "scope": "node", + "grain": "whole", + "domain": "Delivery", + "decision": "novox/hq ADR 0525, ADR 0536, ADR 0553", + "pulled-by-kind": true, + "yields": true, + "accepts": [ + { + "name": "delivery", + "body": { + "$ref": "#/defs/StepAsk" + } + }, + { + "name": "check", + "body": { + "$ref": "#/defs/StepAsk" + } + }, + { + "name": "recheck", + "body": { + "$ref": "#/defs/StepAsk" + } + } + ], + "description": "The Node build module on a node: it takes the asks of the builder and the checker, runs them as steps and answers.", + "includes": [ + "build-step" + ], + "states": [ + { + "name": "steps", + "template": "state", + "key-forms": [ + { + "key": ".", + "value": { + "$ref": "#/defs/Step" + }, + "description": "a step on a node" + } + ], + "description": "the steps this module runs", + "history": 5 + }, + { + "name": "pauses", + "template": "state", + "key-forms": [ + { + "key": "paused.", + "value": { + "$ref": "#/defs/Pause" + }, + "description": "whether a node is paused" + }, + { + "key": "capacity.", + "value": { + "$ref": "#/defs/Capacity" + }, + "description": "a node's capacity" + } + ], + "description": "the pause and the capacity of each node", + "history": 1 + }, + { + "name": "toolchains", + "template": "state", + "key-forms": [ + { + "key": ".", + "value": { + "$ref": "#/defs/Toolchain" + }, + "description": "the toolchain a node reports" + } + ], + "description": "the toolchains the nodes report", + "history": 1 + } + ] +} diff --git a/contracts/seats/npm-package-registry/fixtures/events.json b/contracts/seats/npm-package-registry/fixtures/events.json new file mode 100644 index 00000000..4cadc302 --- /dev/null +++ b/contracts/seats/npm-package-registry/fixtures/events.json @@ -0,0 +1,72 @@ +[ + { + "name": "published on the seat's subject", + "of": "event:published", + "subject": "mesh.seat.npm-package-registry.event.published", + "given": { + "name": "@novox/mesh-sdk", + "version": "0.1.19", + "kind": "package", + "digest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "fingerprint": "f1", + "commit": "cccccccccccccccccccccccccccccccccccccccc", + "holder": "gitea", + "transfer": "t1", + "reference": "@novox/mesh-sdk@0.1.19" + } + }, + { + "name": "refused on the seat's subject", + "of": "event:refused", + "subject": "mesh.seat.npm-package-registry.event.refused", + "given": { + "name": "@novox/mesh-sdk", + "version": "0.1.19", + "kind": "package", + "digest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "fingerprint": "f1", + "commit": "cccccccccccccccccccccccccccccccccccccccc", + "holder": "gitea", + "transfer": "t1", + "reference": "@novox/mesh-sdk@0.1.19", + "reason": "retention" + } + }, + { + "name": "removed on the seat's subject", + "of": "event:removed", + "subject": "mesh.seat.npm-package-registry.event.removed", + "given": { + "name": "@novox/mesh-sdk", + "version": "0.1.19", + "kind": "package", + "digest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "fingerprint": "f1", + "commit": "cccccccccccccccccccccccccccccccccccccccc", + "holder": "gitea", + "transfer": "t1", + "reference": "@novox/mesh-sdk@0.1.19", + "reason": "retention" + } + }, + { + "name": "kept on the seat's subject: a tagged version sharing its digest stays, naming the tags on it", + "of": "event:kept", + "subject": "mesh.seat.npm-package-registry.event.kept", + "given": { + "name": "@novox/mesh-sdk", + "version": "0.1.19-feat-x.2", + "kind": "package", + "digest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "fingerprint": "f1", + "commit": "cccccccccccccccccccccccccccccccccccccccc", + "holder": "gitea", + "reference": "@novox/mesh-sdk@0.1.19", + "reason": "shared-digest", + "tag": "0.1.19-feat-x.2", + "tags": [ + "0.1.19" + ] + } + } +] diff --git a/contracts/seats/npm-package-registry/fixtures/verbs.json b/contracts/seats/npm-package-registry/fixtures/verbs.json new file mode 100644 index 00000000..0b88f08e --- /dev/null +++ b/contracts/seats/npm-package-registry/fixtures/verbs.json @@ -0,0 +1,110 @@ +[ + { + "name": "publish opens a transfer", + "of": "verb:publish.input", + "given": { + "name": "@novox/mesh-sdk", + "version": "0.1.19", + "kind": "package", + "digest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "fingerprint": "f1", + "commit": "cccccccccccccccccccccccccccccccccccccccc", + "transfer": "t1", + "parts": [ + { + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "size": 1048576 + } + ] + } + }, + { + "name": "a field the reader does not know is dropped", + "of": "verb:publish.input", + "why": "ADR 0608 §6: a reader ignores a field it does not know", + "given": { + "name": "@novox/mesh-sdk", + "version": "0.1.19", + "kind": "package", + "digest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "fingerprint": "f1", + "commit": "cccccccccccccccccccccccccccccccccccccccc", + "transfer": "t1", + "parts": [ + { + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "size": 1048576 + } + ], + "later": { + "x": 1 + } + }, + "wire": { + "name": "@novox/mesh-sdk", + "version": "0.1.19", + "kind": "package", + "digest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "fingerprint": "f1", + "commit": "cccccccccccccccccccccccccccccccccccccccc", + "transfer": "t1", + "parts": [ + { + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "size": 1048576 + } + ] + } + }, + { + "name": "publish answers what it lacks", + "of": "verb:publish.answer", + "given": { + "transfer": "t1", + "lacks": [ + "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + ] + } + }, + { + "name": "a chunk of a part", + "of": "verb:publish-part.input", + "given": { + "transfer": "t1", + "part": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "sequence": 0, + "last": true, + "data": "aGVsbG8=" + } + }, + { + "name": "the versions held", + "of": "verb:versions.answer", + "given": { + "name": "@novox/mesh-sdk", + "versions": [ + { + "name": "@novox/mesh-sdk", + "version": "0.1.19", + "kind": "package", + "digest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "fingerprint": "f1", + "commit": "cccccccccccccccccccccccccccccccccccccccc" + } + ] + } + }, + { + "name": "a tagged version sharing its digest with another tag is kept, not removed", + "of": "verb:remove.answer", + "why": "G-sdk-contracts 3: the store cannot remove the tag alone, so the answer says what holds", + "given": { + "removed": false, + "kept": true, + "reason": "shared-digest", + "tags": [ + "0.1.19" + ] + } + } +] diff --git a/contracts/seats/npm-package-registry/seat.json b/contracts/seats/npm-package-registry/seat.json new file mode 100644 index 00000000..ae2ab3db --- /dev/null +++ b/contracts/seats/npm-package-registry/seat.json @@ -0,0 +1,35 @@ +{ + "format": 1, + "seat": "npm-package-registry", + "scope": "mesh", + "grain": "whole", + "domain": "Delivery", + "decision": "novox/hq ADR 0109", + "description": "The mesh's npm packages: the npm registry protocol's read side, and publishing over the bus.", + "delivers": "npm-package-registry", + "includes": [ + "registry" + ], + "family": { + "name": "npm-package-registry.events", + "max-age-days": 7 + }, + "provisions": [ + { + "name": "npm-package-registry", + "form": { + "type": "object", + "properties": { + "url": { + "type": "string", + "description": "the read side's address" + } + }, + "required": [ + "url" + ], + "description": "form none: the npm registry's read side, answered anonymously, with no credential (design 51)" + } + } + ] +} diff --git a/contracts/seats/oci-artifact-registry/fixtures/events.json b/contracts/seats/oci-artifact-registry/fixtures/events.json new file mode 100644 index 00000000..e0a8cde3 --- /dev/null +++ b/contracts/seats/oci-artifact-registry/fixtures/events.json @@ -0,0 +1,98 @@ +[ + { + "name": "published on the seat's subject", + "of": "event:published", + "subject": "mesh.seat.oci-artifact-registry.event.published", + "given": { + "name": "mesh-sdk", + "version": "0.1.19", + "kind": "bundle", + "digest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "fingerprint": "f1", + "commit": "cccccccccccccccccccccccccccccccccccccccc", + "holder": "distribution", + "transfer": "t1", + "reference": "oci-artifact-registry/mesh-sdk/lib@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + } + }, + { + "name": "refused on the seat's subject", + "of": "event:refused", + "subject": "mesh.seat.oci-artifact-registry.event.refused", + "given": { + "name": "mesh-sdk", + "version": "0.1.19", + "kind": "bundle", + "digest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "fingerprint": "f1", + "commit": "cccccccccccccccccccccccccccccccccccccccc", + "holder": "distribution", + "transfer": "t1", + "reference": "oci-artifact-registry/mesh-sdk/lib@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "reason": "retention" + } + }, + { + "name": "removed on the seat's subject", + "of": "event:removed", + "subject": "mesh.seat.oci-artifact-registry.event.removed", + "given": { + "name": "mesh-sdk", + "version": "0.1.19", + "kind": "bundle", + "digest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "fingerprint": "f1", + "commit": "cccccccccccccccccccccccccccccccccccccccc", + "holder": "distribution", + "transfer": "t1", + "reference": "oci-artifact-registry/mesh-sdk/lib@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "reason": "retention" + } + }, + { + "name": "kept on the seat's subject: a tagged version sharing its digest stays, naming the tags on it", + "of": "event:kept", + "subject": "mesh.seat.oci-artifact-registry.event.kept", + "given": { + "name": "mesh-sdk", + "version": "0.1.19-feat-x.2", + "kind": "bundle", + "digest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "fingerprint": "f1", + "commit": "cccccccccccccccccccccccccccccccccccccccc", + "holder": "distribution", + "reference": "oci-artifact-registry/mesh-sdk/lib@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "reason": "shared-digest", + "tag": "0.1.19-feat-x.2", + "tags": [ + "0.1.19" + ] + } + }, + { + "name": "held on the seat's subject", + "of": "event:held", + "subject": "mesh.seat.oci-artifact-registry.event.held", + "given": { + "name": "mesh-sdk", + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reason": "kept by the sweep's rule", + "holder": "distribution", + "by": "mesh-artifact-sweep", + "at": "2026-11-04T12:00:00Z" + } + }, + { + "name": "released on the seat's subject", + "of": "event:released", + "subject": "mesh.seat.oci-artifact-registry.event.released", + "given": { + "name": "mesh-sdk", + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reason": "kept by the sweep's rule", + "holder": "distribution", + "by": "mesh-artifact-sweep", + "at": "2026-11-04T12:00:00Z" + } + } +] diff --git a/contracts/seats/oci-artifact-registry/fixtures/states.json b/contracts/seats/oci-artifact-registry/fixtures/states.json new file mode 100644 index 00000000..f67f2d5d --- /dev/null +++ b/contracts/seats/oci-artifact-registry/fixtures/states.json @@ -0,0 +1,28 @@ +[ + { + "name": "a hold", + "of": "state:holds/..", + "given": { + "seat": "oci-artifact-registry", + "name": "base", + "kind": "base", + "digest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "by": "operator@shanks", + "since": "2026-11-05T10:00:00Z" + } + }, + { + "name": "an open window", + "of": "state:windows/", + "given": { + "name": "mesh-delivery-builder", + "open": true, + "holding": [ + "ace" + ], + "since": { + "ace": "2026-11-05T10:00:00Z" + } + } + } +] diff --git a/contracts/seats/oci-artifact-registry/fixtures/verbs.json b/contracts/seats/oci-artifact-registry/fixtures/verbs.json new file mode 100644 index 00000000..039c38b6 --- /dev/null +++ b/contracts/seats/oci-artifact-registry/fixtures/verbs.json @@ -0,0 +1,138 @@ +[ + { + "name": "publish opens a transfer", + "of": "verb:publish.input", + "given": { + "name": "mesh-sdk", + "version": "0.1.19", + "kind": "bundle", + "digest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "fingerprint": "f1", + "commit": "cccccccccccccccccccccccccccccccccccccccc", + "transfer": "t1", + "parts": [ + { + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "size": 1048576 + } + ] + } + }, + { + "name": "a field the reader does not know is dropped", + "of": "verb:publish.input", + "why": "ADR 0608 §6: a reader ignores a field it does not know", + "given": { + "name": "mesh-sdk", + "version": "0.1.19", + "kind": "bundle", + "digest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "fingerprint": "f1", + "commit": "cccccccccccccccccccccccccccccccccccccccc", + "transfer": "t1", + "parts": [ + { + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "size": 1048576 + } + ], + "later": { + "x": 1 + } + }, + "wire": { + "name": "mesh-sdk", + "version": "0.1.19", + "kind": "bundle", + "digest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "fingerprint": "f1", + "commit": "cccccccccccccccccccccccccccccccccccccccc", + "transfer": "t1", + "parts": [ + { + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "size": 1048576 + } + ] + } + }, + { + "name": "publish answers what it lacks", + "of": "verb:publish.answer", + "given": { + "transfer": "t1", + "lacks": [ + "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + ] + } + }, + { + "name": "a chunk of a part", + "of": "verb:publish-part.input", + "given": { + "transfer": "t1", + "part": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "sequence": 0, + "last": true, + "data": "aGVsbG8=" + } + }, + { + "name": "the versions held", + "of": "verb:versions.answer", + "given": { + "name": "mesh-sdk", + "versions": [ + { + "name": "mesh-sdk", + "version": "0.1.19", + "kind": "package", + "digest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "fingerprint": "f1", + "commit": "cccccccccccccccccccccccccccccccccccccccc" + } + ] + } + }, + { + "name": "a release of what is not held answers released false", + "of": "verb:release.answer", + "why": "A3t: the outcome asked holds already, so it is no error", + "given": { + "released": false + } + }, + { + "name": "a tagged version sharing its digest with another tag is kept, not removed", + "of": "verb:remove.answer", + "why": "G-sdk-contracts 3: the store cannot remove the tag alone, so the answer says what holds", + "given": { + "removed": false, + "kept": true, + "reason": "shared-digest", + "tags": [ + "0.1.19" + ] + } + }, + { + "name": "list answers an untagged archive by the blob it keeps", + "of": "verb:list.answer", + "given": { + "repositories": [ + { + "name": "mail/files", + "manifests": [ + { + "digest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "index": false, + "artifact": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "size": 4096, + "held": true + } + ] + } + ] + } + } +] diff --git a/contracts/seats/oci-artifact-registry/seat.json b/contracts/seats/oci-artifact-registry/seat.json new file mode 100644 index 00000000..ee6f5bf7 --- /dev/null +++ b/contracts/seats/oci-artifact-registry/seat.json @@ -0,0 +1,383 @@ +{ + "format": 1, + "seat": "oci-artifact-registry", + "scope": "mesh", + "grain": "whole", + "domain": "Delivery", + "decision": "novox/hq ADR 0605", + "description": "The mesh's bundles and archives, by digest, in the one artifact store: the OCI distribution protocol's read side.", + "includes": [ + "registry" + ], + "verbs": [ + { + "name": "hold", + "description": "Keep one untagged bundle, archive or copied base from removal by retention, as the artifact sweep's rules keep it (ADR 0617 §2); never an image of the holder's own. Emits held.", + "callers": "the module that runs the artifact sweep alone", + "input": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "the name" + }, + "digest": { + "type": "string", + "description": "the digest" + }, + "reason": { + "type": "string", + "description": "which of the sweep's rules keeps it" + } + }, + "required": [ + "name", + "digest", + "reason" + ] + }, + "answer": { + "type": "object", + "properties": { + "held": { + "type": "boolean", + "description": "it is held" + }, + "since": { + "type": "string", + "description": "when the hold began, RFC 3339" + } + }, + "required": [ + "held" + ] + }, + "optional": true + }, + { + "name": "release", + "description": "Let a hold go; emits released. A release of what is not held answers released false and emits nothing, never an error: the outcome asked holds already. By hand only through the holder's own tool, the operator's session as the proved caller, recorded as a hand act.", + "callers": "the module that runs the artifact sweep; the operator's session through the holder's tool", + "input": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "the name" + }, + "digest": { + "type": "string", + "description": "the digest" + }, + "reason": { + "type": "string", + "description": "why it is let go" + } + }, + "required": [ + "name", + "digest", + "reason" + ] + }, + "answer": { + "type": "object", + "properties": { + "released": { + "type": "boolean", + "description": "a hold was let go; false when there was none" + } + }, + "required": [ + "released" + ] + }, + "optional": true + }, + { + "name": "list", + "description": "What the store holds of one repository, or of every repository the seat holds when none is named: every manifest keeping one bundle or archive, each with its digest, whether it is an index (and the manifests an index names), the tags naming it, its size and whether it is held. Read from the store itself, so a manifest no tag names is listed too; it reads only (ADR 0632 §3).", + "callers": "any caller whose role grants it", + "input": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "the repository, /; every repository of the seat when not given" + } + } + }, + "answer": { + "type": "object", + "properties": { + "repositories": { + "type": "array", + "items": { + "$ref": "#/defs/Repository" + }, + "description": "each repository named, in order of its name" + } + }, + "required": [ + "repositories" + ] + }, + "optional": true + } + ], + "events": [ + { + "name": "held", + "body": { + "$ref": "#/defs/Hold" + } + }, + { + "name": "released", + "body": { + "$ref": "#/defs/Hold" + } + } + ], + "family": { + "name": "oci-artifact-registry.events", + "max-age-days": 7 + }, + "defs": { + "Hold": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "the name" + }, + "digest": { + "type": "string", + "description": "the digest" + }, + "reason": { + "type": "string", + "description": "why" + }, + "holder": { + "type": "string", + "description": "the module holding the seat" + }, + "by": { + "type": "string", + "description": "the caller the holder proved" + }, + "at": { + "type": "string", + "description": "when, RFC 3339" + } + }, + "required": [ + "name", + "digest", + "holder" + ], + "description": "the body of held and released" + }, + "Repository": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "the repository" + }, + "manifests": { + "type": "array", + "items": { + "$ref": "#/defs/Listed" + }, + "description": "each manifest it holds, by digest" + } + }, + "required": [ + "name", + "manifests" + ] + }, + "Listed": { + "type": "object", + "properties": { + "digest": { + "type": "string", + "description": "the manifest's digest, sha256:" + }, + "index": { + "type": "boolean", + "description": "it is an index, naming a manifest per platform" + }, + "platforms": { + "type": "array", + "items": { + "type": "string" + }, + "description": "for an index, the digests of the manifests it names" + }, + "artifact": { + "type": "string", + "description": "for a manifest keeping one bundle or archive, the digest of that blob: what a reference names it by" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "the tags naming it, none for an untagged one" + }, + "size": { + "type": "integer", + "description": "its bytes: its own content and every blob it names" + }, + "held": { + "type": "boolean", + "description": "a hold keeps it from retention" + } + }, + "required": [ + "digest", + "index", + "size", + "held" + ] + } + }, + "states": [ + { + "name": "holds", + "template": "state", + "history": 1, + "description": "the bundles, archives and copied bases kept from retention's removal", + "key-forms": [ + { + "key": "..", + "value": { + "type": "object", + "description": "one hold: an untagged bundle, archive or copied base kept from retention's removal", + "properties": { + "seat": { + "type": "string" + }, + "name": { + "type": "string" + }, + "kind": { + "type": "string" + }, + "digest": { + "type": "string" + }, + "by": { + "type": "string" + }, + "why": { + "type": "string" + }, + "since": { + "type": "string", + "description": "when (RFC 3339 time)" + } + }, + "required": [ + "seat", + "name", + "kind", + "digest", + "by", + "since" + ] + }, + "description": "one hold" + } + ] + }, + { + "name": "windows", + "template": "state", + "history": 1, + "description": "the direct-write windows, one per caller", + "key-forms": [ + { + "key": "", + "value": { + "type": "object", + "description": "the direct-write window of one caller: open while a node holds it open", + "properties": { + "name": { + "type": "string", + "description": "the caller whose window it is" + }, + "open": { + "type": "boolean" + }, + "holding": { + "type": "array", + "items": { + "type": "string" + }, + "description": "the nodes holding it open" + }, + "since": { + "type": "object", + "additionalProperties": { + "type": "string", + "description": "when (RFC 3339 time)" + }, + "description": "node to when it began to hold" + }, + "closed": { + "type": "string", + "description": "when it last closed (RFC 3339 time)" + }, + "last_holding": { + "type": "array", + "items": { + "type": "string" + } + }, + "dropped": { + "type": "array", + "items": { + "type": "object", + "properties": { + "node": { + "type": "string" + }, + "by": { + "type": "string" + }, + "why": { + "type": "string" + }, + "at": { + "type": "string", + "description": "when (RFC 3339 time)" + } + }, + "required": [ + "node" + ] + }, + "description": "retired nodes dropped by hand" + }, + "why": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "description": "of each node holding the window, why it holds it" + } + }, + "required": [ + "name" + ] + }, + "description": "one caller's window" + } + ] + } + ] +} diff --git a/contracts/ts/seats/containerimageregistry/package-lock.json b/contracts/ts/seats/containerimageregistry/package-lock.json new file mode 100644 index 00000000..91b0e989 --- /dev/null +++ b/contracts/ts/seats/containerimageregistry/package-lock.json @@ -0,0 +1,12 @@ +{ + "lockfileVersion": 3, + "name": "@novox/mesh-delivery-contracts-container-image-registry", + "packages": { + "": { + "name": "@novox/mesh-delivery-contracts-container-image-registry", + "version": "0.1.0" + } + }, + "requires": true, + "version": "0.1.0" +} diff --git a/contracts/ts/seats/containerimageregistry/package.json b/contracts/ts/seats/containerimageregistry/package.json new file mode 100644 index 00000000..0cbf3724 --- /dev/null +++ b/contracts/ts/seats/containerimageregistry/package.json @@ -0,0 +1,13 @@ +{ + "description": "The contract of the seat container-image-registry, generated from its definition.", + "exports": { + ".": "./src/index.ts" + }, + "files": [ + "src" + ], + "name": "@novox/mesh-delivery-contracts-container-image-registry", + "type": "module", + "types": "./src/index.ts", + "version": "0.1.0" +} diff --git a/contracts/ts/seats/containerimageregistry/src/index.ts b/contracts/ts/seats/containerimageregistry/src/index.ts new file mode 100644 index 00000000..d55310d4 --- /dev/null +++ b/contracts/ts/seats/containerimageregistry/src/index.ts @@ -0,0 +1,271 @@ +// Code generated by seatgen from the seat definitions; DO NOT EDIT. + +// The contract of the seat "container-image-registry", defined whole (seats/container-image-registry/seat.json): The mesh's container images, in the one artifact store: the OCI distribution protocol's read side, an image's tags and mirroring an outside image. + +import type * as registry from "./parts/registry.js"; + +export const seat = "container-image-registry"; + +export const verbs = { + publish: "publish", + publishPart: "publish-part", + versions: "versions", + remove: "remove", + mirror: "mirror", + tags: "tags", + hold: "hold", + release: "release", + list: "list", +} as const; + +export const events = { + published: "published", + refused: "refused", + removed: "removed", + kept: "kept", + held: "held", + released: "released", +} as const; + +export const family = { name: "container-image-registry.events", maxAgeDays: 7 } as const; + +export const publishedSubject = "mesh.seat.container-image-registry.event.published"; + +export const refusedSubject = "mesh.seat.container-image-registry.event.refused"; + +export const removedSubject = "mesh.seat.container-image-registry.event.removed"; + +export const keptSubject = "mesh.seat.container-image-registry.event.kept"; + +export const heldSubject = "mesh.seat.container-image-registry.event.held"; + +export const releasedSubject = "mesh.seat.container-image-registry.event.released"; + +export const states = { + holds: "holds", + windows: "windows", +} as const; + +/** The key of one value of the state holds under the form "..". */ +export function holdsBySeatNameDigestKey(seat: string, name: string, digest: string): string { + return seat + "." + name + "." + digest; +} + +/** The parts of a key of the form "..", or undefined for a key of another form. */ +export function parseHoldsBySeatNameDigestKey(_key: string): { seat: string; name: string; digest: string } | undefined { + let _rest = _key; + const _at0 = _rest.indexOf("."); + if (_at0 <= 0) return undefined; + const seat = _rest.slice(0, _at0); + _rest = _rest.slice(_at0 + 1); + const _at2 = _rest.indexOf("."); + if (_at2 <= 0) return undefined; + const name = _rest.slice(0, _at2); + _rest = _rest.slice(_at2 + 1); + if (_rest === "") return undefined; + const digest = _rest; + _rest = ""; + if (_rest !== "") return undefined; + return { seat, name, digest }; +} + +/** The key of one value of the state windows under the form "". */ +export function windowsByCallerKey(caller: string): string { + return caller; +} + +/** The parts of a key of the form "", or undefined for a key of another form. */ +export function parseWindowsByCallerKey(_key: string): { caller: string } | undefined { + let _rest = _key; + if (_rest === "") return undefined; + const caller = _rest; + _rest = ""; + if (_rest !== "") return undefined; + return { caller }; +} + +export type Artifact = registry.Artifact; +export type KeptEvent = registry.KeptEvent; +export type Outcome = registry.Outcome; +export type Part = registry.Part; +export type PublishAnswer = registry.PublishAnswer; +export type PublishInput = registry.PublishInput; +export type PublishPartAnswer = registry.PublishPartAnswer; +export type PublishPartInput = registry.PublishPartInput; +export type PublishedEvent = registry.PublishedEvent; +export type RefusedEvent = registry.RefusedEvent; +export type RemoveAnswer = registry.RemoveAnswer; +export type RemoveInput = registry.RemoveInput; +export type RemovedEvent = registry.RemovedEvent; +export type VersionsAnswer = registry.VersionsAnswer; +export type VersionsInput = registry.VersionsInput; + +/** the body of held and released */ +export interface Hold { + /** when, RFC 3339 */ + "at"?: string; + /** the caller the holder proved */ + "by"?: string; + /** the digest */ + "digest": string; + /** the module holding the seat */ + "holder": string; + /** the name */ + "name": string; + /** why */ + "reason"?: string; +} + +export interface Listed { + /** for a manifest keeping one bundle or archive, the digest of that blob: what a reference names it by */ + "artifact"?: string; + /** the manifest's digest, sha256: */ + "digest": string; + /** a hold keeps it from retention */ + "held": boolean; + /** it is an index, naming a manifest per platform */ + "index": boolean; + /** for an index, the digests of the manifests it names */ + "platforms"?: string[]; + /** its bytes: its own content and every blob it names */ + "size": number; + /** the tags naming it, none for an untagged one */ + "tags"?: string[]; +} + +export interface Repository { + /** each manifest it holds, by digest */ + "manifests": Listed[]; + /** the repository */ + "name": string; +} + +/** is what mirror takes. */ +export interface MirrorInput { + /** its pinned digest, sha256: */ + "digest": string; + /** the name it is kept under */ + "name": string; + /** the outside image's reference */ + "source": string; +} + +/** is what mirror answers. */ +export interface MirrorAnswer { + /** the digest stored */ + "digest": string; + /** the reference the seat answers */ + "reference": string; +} + +/** is what tags takes. */ +export interface TagsInput { + /** the image */ + "name": string; +} + +export interface TagsAnswerTagsItem { + /** its digest */ + "digest": string; + /** the tag */ + "tag": string; +} + +/** is what tags answers. */ +export interface TagsAnswer { + /** the image */ + "name": string; + /** every tag */ + "tags": TagsAnswerTagsItem[]; +} + +/** is what hold takes. */ +export interface HoldInput { + /** the digest */ + "digest": string; + /** the name */ + "name": string; + /** which of the sweep's rules keeps it */ + "reason": string; +} + +/** is what hold answers. */ +export interface HoldAnswer { + /** it is held */ + "held": boolean; + /** when the hold began, RFC 3339 */ + "since"?: string; +} + +/** is what release takes. */ +export interface ReleaseInput { + /** the digest */ + "digest": string; + /** the name */ + "name": string; + /** why it is let go */ + "reason": string; +} + +/** is what release answers. */ +export interface ReleaseAnswer { + /** a hold was let go; false when there was none */ + "released": boolean; +} + +/** is what list takes. */ +export interface ListInput { + /** the repository, /; every repository of the seat when not given */ + "name"?: string; +} + +/** is what list answers. */ +export interface ListAnswer { + /** each repository named, in order of its name */ + "repositories": Repository[]; +} + +/** is the body of the event held. */ +export type HeldEvent = Hold; + +/** is the body of the event released. */ +export type ReleasedEvent = Hold; + +/** is one value of the state holds under the key ... */ +export interface HoldsBySeatNameDigestValue { + "by": string; + "digest": string; + "kind": string; + "name": string; + "seat": string; + /** when (RFC 3339 time) */ + "since": string; + "why"?: string; +} + +export interface WindowsByCallerValueDroppedItem { + /** when (RFC 3339 time) */ + "at"?: string; + "by"?: string; + "node": string; + "why"?: string; +} + +/** is one value of the state windows under the key . */ +export interface WindowsByCallerValue { + /** when it last closed (RFC 3339 time) */ + "closed"?: string; + /** retired nodes dropped by hand */ + "dropped"?: WindowsByCallerValueDroppedItem[]; + /** the nodes holding it open */ + "holding"?: string[]; + "last_holding"?: string[]; + /** the caller whose window it is */ + "name": string; + "open"?: boolean; + /** node to when it began to hold */ + "since"?: Record; + /** of each node holding the window, why it holds it */ + "why"?: Record; +} + diff --git a/contracts/ts/seats/containerimageregistry/src/parts/registry.ts b/contracts/ts/seats/containerimageregistry/src/parts/registry.ts new file mode 100644 index 00000000..17a8de46 --- /dev/null +++ b/contracts/ts/seats/containerimageregistry/src/parts/registry.ts @@ -0,0 +1,187 @@ +// Code generated by seatgen from the seat definitions; DO NOT EDIT. + +// The part "registry" several seats include whole: The verbs and events every registry seat shares, written once so the four cannot drift (design 51, design 26). Who may call each verb is the holder's trusted config value listing its callers (ADR 0363's form), checked against the caller the tool runner proved. + +export const verbs = { + publish: "publish", + publishPart: "publish-part", + versions: "versions", + remove: "remove", +} as const; + +export const events = { + published: "published", + refused: "refused", + removed: "removed", + kept: "kept", +} as const; + +/** one published thing, as every registry event and answer names it */ +export interface Artifact { + /** the source commit it was built from */ + "commit"?: string; + /** its digest, sha256: (a Go package's h1: hash beside it in the version's record) */ + "digest": string; + /** the build's fingerprint */ + "fingerprint"?: string; + /** what it is */ + "kind": "package" | "image" | "bundle" | "archive" | "upstream"; + /** the package's, image's, bundle's or archive's name; a package's is its name in its own language (design 32): a Go package's the go.mod module path (git.novox.be/novox/mesh-sdk/go), an npm package's package.json's name (@novox/mesh-sdk) */ + "name": string; + /** the tag it carries, for a tagged version */ + "tag"?: string; + /** the version the builder gave it (design 51 *The versions*); empty for a bundle or archive published by its digest alone */ + "version"?: string; +} + +/** the body of published, refused and removed */ +export interface Outcome { + /** the source commit it was built from */ + "commit"?: string; + /** its digest, sha256: (a Go package's h1: hash beside it in the version's record) */ + "digest": string; + /** the build's fingerprint */ + "fingerprint"?: string; + /** the module holding the seat that said it */ + "holder": string; + /** what it is */ + "kind": "package" | "image" | "bundle" | "archive" | "upstream"; + /** the Go module path a Go package is fetched under, equal to name for a Go package (go-package-registry's published); empty for every other kind of artifact */ + "module"?: string; + /** the package's, image's, bundle's or archive's name; a package's is its name in its own language (design 32): a Go package's the go.mod module path (git.novox.be/novox/mesh-sdk/go), an npm package's package.json's name (@novox/mesh-sdk) */ + "name": string; + /** why: a refusal's cause, a removal's (retention, tag-gone, by-hand), or why a removal was kept (shared-digest) */ + "reason"?: string; + /** the reference the seat answers: //@sha256:, or @ for a package (ADR 0605 §9) */ + "reference"?: string; + /** the tag it carries, for a tagged version */ + "tag"?: string; + /** for kept: the other tags still naming its digest */ + "tags"?: string[]; + /** the transfer it ends, when it ended one */ + "transfer"?: string; + /** the version the builder gave it (design 51 *The versions*); empty for a bundle or archive published by its digest alone */ + "version"?: string; +} + +/** one part of a transfer */ +export interface Part { + /** the part's digest, sha256:: an image's layer or config, a package's file, a bundle's blob */ + "digest": string; + /** its size in bytes */ + "size": number; +} + +/** is what publish takes. */ +export interface PublishInput { + /** the source commit it was built from */ + "commit"?: string; + /** its digest, sha256: (a Go package's h1: hash beside it in the version's record) */ + "digest": string; + /** the build's fingerprint */ + "fingerprint"?: string; + /** what it is */ + "kind": "package" | "image" | "bundle" | "archive" | "upstream"; + /** the package's, image's, bundle's or archive's name; a package's is its name in its own language (design 32): a Go package's the go.mod module path (git.novox.be/novox/mesh-sdk/go), an npm package's package.json's name (@novox/mesh-sdk) */ + "name": string; + /** every part's digest and size; none when the digest is already held */ + "parts"?: Part[]; + /** the tag it carries, for a tagged version */ + "tag"?: string; + /** the transfer's name, the caller's own; the same name opens the same transfer again */ + "transfer": string; + /** the version the builder gave it (design 51 *The versions*); empty for a bundle or archive published by its digest alone */ + "version"?: string; +} + +/** is what publish answers. */ +export interface PublishAnswer { + /** the digest is held already: nothing to send; the outcome follows as an event */ + "held"?: boolean; + /** the digests of the parts the holder lacks, to send by publish-part */ + "lacks"?: string[]; + /** the transfer */ + "transfer": string; +} + +/** is what publish-part takes. */ +export interface PublishPartInput { + /** the chunk's bytes, base64 (at most 760 KiB before encoding) */ + "data": string; + /** this chunk is the part's last */ + "last": boolean; + /** the part's digest */ + "part": string; + /** the chunk's place in its part, from 0 */ + "sequence": number; + /** the transfer */ + "transfer": string; +} + +/** is what publish-part answers. */ +export interface PublishPartAnswer { + /** the part is whole and its bytes match its digest */ + "complete"?: boolean; + /** the part */ + "part": string; + /** the chunks of the part held so far */ + "received"?: number; + /** the transfer */ + "transfer": string; +} + +/** is what versions takes. */ +export interface VersionsInput { + /** narrowed to one kind (optional) */ + "kind"?: string; + /** the name */ + "name": string; +} + +/** is what versions answers. */ +export interface VersionsAnswer { + /** the name */ + "name": string; + /** newest first */ + "versions": Artifact[]; +} + +/** is what remove takes. */ +export interface RemoveInput { + /** the digest (for a bundle, archive or copied base) */ + "digest"?: string; + /** the name */ + "name": string; + /** why it goes */ + "reason": "tag-gone" | "retention"; + /** the tag (for a tagged version) */ + "tag"?: string; + /** the version */ + "version"?: string; +} + +/** is what remove answers. */ +export interface RemoveAnswer { + "artifact"?: Artifact; + /** it stays whole, its tag and its bytes: the store cannot remove the tag alone while another tag names its digest (reason, tags) */ + "kept"?: boolean; + /** why it was kept: shared-digest, another tag names its digest */ + "reason"?: "shared-digest"; + /** it is gone */ + "removed": boolean; + /** the other tags still naming its digest, when kept */ + "tags"?: string[]; +} + +/** is the body of the event published. */ +export type PublishedEvent = Outcome; + +/** is the body of the event refused. */ +export type RefusedEvent = Outcome; + +/** is the body of the event removed. */ +export type RemovedEvent = Outcome; + +/** is the body of the event kept. */ +export type KeptEvent = Outcome; + diff --git a/contracts/ts/seats/containerimageregistry/tsconfig.json b/contracts/ts/seats/containerimageregistry/tsconfig.json new file mode 100644 index 00000000..60274c6a --- /dev/null +++ b/contracts/ts/seats/containerimageregistry/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "noEmit": true, + "strict": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src/**/*.ts"] +} diff --git a/contracts/ts/seats/gopackageregistry/package-lock.json b/contracts/ts/seats/gopackageregistry/package-lock.json new file mode 100644 index 00000000..345a71a1 --- /dev/null +++ b/contracts/ts/seats/gopackageregistry/package-lock.json @@ -0,0 +1,12 @@ +{ + "lockfileVersion": 3, + "name": "@novox/mesh-delivery-contracts-go-package-registry", + "packages": { + "": { + "name": "@novox/mesh-delivery-contracts-go-package-registry", + "version": "0.1.0" + } + }, + "requires": true, + "version": "0.1.0" +} diff --git a/contracts/ts/seats/gopackageregistry/package.json b/contracts/ts/seats/gopackageregistry/package.json new file mode 100644 index 00000000..1ea96fb4 --- /dev/null +++ b/contracts/ts/seats/gopackageregistry/package.json @@ -0,0 +1,13 @@ +{ + "description": "The contract of the seat go-package-registry, generated from its definition.", + "exports": { + ".": "./src/index.ts" + }, + "files": [ + "src" + ], + "name": "@novox/mesh-delivery-contracts-go-package-registry", + "type": "module", + "types": "./src/index.ts", + "version": "0.1.0" +} diff --git a/contracts/ts/seats/gopackageregistry/src/index.ts b/contracts/ts/seats/gopackageregistry/src/index.ts new file mode 100644 index 00000000..f0720909 --- /dev/null +++ b/contracts/ts/seats/gopackageregistry/src/index.ts @@ -0,0 +1,54 @@ +// Code generated by seatgen from the seat definitions; DO NOT EDIT. + +// The contract of the seat "go-package-registry", defined whole (seats/go-package-registry/seat.json): The mesh's Go modules: Go's module proxy protocol's read side, and publishing over the bus. + +import type * as registry from "./parts/registry.js"; + +export const seat = "go-package-registry"; + +export const verbs = { + publish: "publish", + publishPart: "publish-part", + versions: "versions", + remove: "remove", +} as const; + +export const events = { + published: "published", + refused: "refused", + removed: "removed", + kept: "kept", +} as const; + +export const family = { name: "go-package-registry.events", maxAgeDays: 7 } as const; + +export const publishedSubject = "mesh.seat.go-package-registry.event.published"; + +export const refusedSubject = "mesh.seat.go-package-registry.event.refused"; + +export const removedSubject = "mesh.seat.go-package-registry.event.removed"; + +export const keptSubject = "mesh.seat.go-package-registry.event.kept"; + +export type Artifact = registry.Artifact; +export type KeptEvent = registry.KeptEvent; +export type Outcome = registry.Outcome; +export type Part = registry.Part; +export type PublishAnswer = registry.PublishAnswer; +export type PublishInput = registry.PublishInput; +export type PublishPartAnswer = registry.PublishPartAnswer; +export type PublishPartInput = registry.PublishPartInput; +export type PublishedEvent = registry.PublishedEvent; +export type RefusedEvent = registry.RefusedEvent; +export type RemoveAnswer = registry.RemoveAnswer; +export type RemoveInput = registry.RemoveInput; +export type RemovedEvent = registry.RemovedEvent; +export type VersionsAnswer = registry.VersionsAnswer; +export type VersionsInput = registry.VersionsInput; + +/** is the form of the provision go-package-registry. */ +export interface GoPackageRegistryForm { + /** the read side's address */ + "url": string; +} + diff --git a/contracts/ts/seats/gopackageregistry/src/parts/registry.ts b/contracts/ts/seats/gopackageregistry/src/parts/registry.ts new file mode 100644 index 00000000..17a8de46 --- /dev/null +++ b/contracts/ts/seats/gopackageregistry/src/parts/registry.ts @@ -0,0 +1,187 @@ +// Code generated by seatgen from the seat definitions; DO NOT EDIT. + +// The part "registry" several seats include whole: The verbs and events every registry seat shares, written once so the four cannot drift (design 51, design 26). Who may call each verb is the holder's trusted config value listing its callers (ADR 0363's form), checked against the caller the tool runner proved. + +export const verbs = { + publish: "publish", + publishPart: "publish-part", + versions: "versions", + remove: "remove", +} as const; + +export const events = { + published: "published", + refused: "refused", + removed: "removed", + kept: "kept", +} as const; + +/** one published thing, as every registry event and answer names it */ +export interface Artifact { + /** the source commit it was built from */ + "commit"?: string; + /** its digest, sha256: (a Go package's h1: hash beside it in the version's record) */ + "digest": string; + /** the build's fingerprint */ + "fingerprint"?: string; + /** what it is */ + "kind": "package" | "image" | "bundle" | "archive" | "upstream"; + /** the package's, image's, bundle's or archive's name; a package's is its name in its own language (design 32): a Go package's the go.mod module path (git.novox.be/novox/mesh-sdk/go), an npm package's package.json's name (@novox/mesh-sdk) */ + "name": string; + /** the tag it carries, for a tagged version */ + "tag"?: string; + /** the version the builder gave it (design 51 *The versions*); empty for a bundle or archive published by its digest alone */ + "version"?: string; +} + +/** the body of published, refused and removed */ +export interface Outcome { + /** the source commit it was built from */ + "commit"?: string; + /** its digest, sha256: (a Go package's h1: hash beside it in the version's record) */ + "digest": string; + /** the build's fingerprint */ + "fingerprint"?: string; + /** the module holding the seat that said it */ + "holder": string; + /** what it is */ + "kind": "package" | "image" | "bundle" | "archive" | "upstream"; + /** the Go module path a Go package is fetched under, equal to name for a Go package (go-package-registry's published); empty for every other kind of artifact */ + "module"?: string; + /** the package's, image's, bundle's or archive's name; a package's is its name in its own language (design 32): a Go package's the go.mod module path (git.novox.be/novox/mesh-sdk/go), an npm package's package.json's name (@novox/mesh-sdk) */ + "name": string; + /** why: a refusal's cause, a removal's (retention, tag-gone, by-hand), or why a removal was kept (shared-digest) */ + "reason"?: string; + /** the reference the seat answers: //@sha256:, or @ for a package (ADR 0605 §9) */ + "reference"?: string; + /** the tag it carries, for a tagged version */ + "tag"?: string; + /** for kept: the other tags still naming its digest */ + "tags"?: string[]; + /** the transfer it ends, when it ended one */ + "transfer"?: string; + /** the version the builder gave it (design 51 *The versions*); empty for a bundle or archive published by its digest alone */ + "version"?: string; +} + +/** one part of a transfer */ +export interface Part { + /** the part's digest, sha256:: an image's layer or config, a package's file, a bundle's blob */ + "digest": string; + /** its size in bytes */ + "size": number; +} + +/** is what publish takes. */ +export interface PublishInput { + /** the source commit it was built from */ + "commit"?: string; + /** its digest, sha256: (a Go package's h1: hash beside it in the version's record) */ + "digest": string; + /** the build's fingerprint */ + "fingerprint"?: string; + /** what it is */ + "kind": "package" | "image" | "bundle" | "archive" | "upstream"; + /** the package's, image's, bundle's or archive's name; a package's is its name in its own language (design 32): a Go package's the go.mod module path (git.novox.be/novox/mesh-sdk/go), an npm package's package.json's name (@novox/mesh-sdk) */ + "name": string; + /** every part's digest and size; none when the digest is already held */ + "parts"?: Part[]; + /** the tag it carries, for a tagged version */ + "tag"?: string; + /** the transfer's name, the caller's own; the same name opens the same transfer again */ + "transfer": string; + /** the version the builder gave it (design 51 *The versions*); empty for a bundle or archive published by its digest alone */ + "version"?: string; +} + +/** is what publish answers. */ +export interface PublishAnswer { + /** the digest is held already: nothing to send; the outcome follows as an event */ + "held"?: boolean; + /** the digests of the parts the holder lacks, to send by publish-part */ + "lacks"?: string[]; + /** the transfer */ + "transfer": string; +} + +/** is what publish-part takes. */ +export interface PublishPartInput { + /** the chunk's bytes, base64 (at most 760 KiB before encoding) */ + "data": string; + /** this chunk is the part's last */ + "last": boolean; + /** the part's digest */ + "part": string; + /** the chunk's place in its part, from 0 */ + "sequence": number; + /** the transfer */ + "transfer": string; +} + +/** is what publish-part answers. */ +export interface PublishPartAnswer { + /** the part is whole and its bytes match its digest */ + "complete"?: boolean; + /** the part */ + "part": string; + /** the chunks of the part held so far */ + "received"?: number; + /** the transfer */ + "transfer": string; +} + +/** is what versions takes. */ +export interface VersionsInput { + /** narrowed to one kind (optional) */ + "kind"?: string; + /** the name */ + "name": string; +} + +/** is what versions answers. */ +export interface VersionsAnswer { + /** the name */ + "name": string; + /** newest first */ + "versions": Artifact[]; +} + +/** is what remove takes. */ +export interface RemoveInput { + /** the digest (for a bundle, archive or copied base) */ + "digest"?: string; + /** the name */ + "name": string; + /** why it goes */ + "reason": "tag-gone" | "retention"; + /** the tag (for a tagged version) */ + "tag"?: string; + /** the version */ + "version"?: string; +} + +/** is what remove answers. */ +export interface RemoveAnswer { + "artifact"?: Artifact; + /** it stays whole, its tag and its bytes: the store cannot remove the tag alone while another tag names its digest (reason, tags) */ + "kept"?: boolean; + /** why it was kept: shared-digest, another tag names its digest */ + "reason"?: "shared-digest"; + /** it is gone */ + "removed": boolean; + /** the other tags still naming its digest, when kept */ + "tags"?: string[]; +} + +/** is the body of the event published. */ +export type PublishedEvent = Outcome; + +/** is the body of the event refused. */ +export type RefusedEvent = Outcome; + +/** is the body of the event removed. */ +export type RemovedEvent = Outcome; + +/** is the body of the event kept. */ +export type KeptEvent = Outcome; + diff --git a/contracts/ts/seats/gopackageregistry/tsconfig.json b/contracts/ts/seats/gopackageregistry/tsconfig.json new file mode 100644 index 00000000..60274c6a --- /dev/null +++ b/contracts/ts/seats/gopackageregistry/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "noEmit": true, + "strict": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src/**/*.ts"] +} diff --git a/contracts/ts/seats/meshartifactsweep/package-lock.json b/contracts/ts/seats/meshartifactsweep/package-lock.json new file mode 100644 index 00000000..2d3064d4 --- /dev/null +++ b/contracts/ts/seats/meshartifactsweep/package-lock.json @@ -0,0 +1,12 @@ +{ + "lockfileVersion": 3, + "name": "@novox/mesh-delivery-contracts-mesh-artifact-sweep", + "packages": { + "": { + "name": "@novox/mesh-delivery-contracts-mesh-artifact-sweep", + "version": "0.1.0" + } + }, + "requires": true, + "version": "0.1.0" +} diff --git a/contracts/ts/seats/meshartifactsweep/package.json b/contracts/ts/seats/meshartifactsweep/package.json new file mode 100644 index 00000000..14c46319 --- /dev/null +++ b/contracts/ts/seats/meshartifactsweep/package.json @@ -0,0 +1,13 @@ +{ + "description": "The contract of the seat mesh-artifact-sweep, generated from its definition.", + "exports": { + ".": "./src/index.ts" + }, + "files": [ + "src" + ], + "name": "@novox/mesh-delivery-contracts-mesh-artifact-sweep", + "type": "module", + "types": "./src/index.ts", + "version": "0.1.0" +} diff --git a/contracts/ts/seats/meshartifactsweep/src/index.ts b/contracts/ts/seats/meshartifactsweep/src/index.ts new file mode 100644 index 00000000..ad4d48a4 --- /dev/null +++ b/contracts/ts/seats/meshartifactsweep/src/index.ts @@ -0,0 +1,99 @@ +// Code generated by seatgen from the seat definitions; DO NOT EDIT. + +// The contract of the seat "mesh-artifact-sweep", defined whole (seats/mesh-artifact-sweep/seat.json): no description. + +export const seat = "mesh-artifact-sweep"; + +export const verbs = { + artifacts: "artifacts", + held: "held", + collect: "collect", + mirrors: "mirrors", + images: "images", +} as const; + +export const events = { + artifactsGone: "artifacts-gone", +} as const; + +export const family = { name: "mesh-artifact-sweep.events", maxAgeDays: 7 } as const; + +export const artifactsGoneSubject = "mesh.seat.mesh-artifact-sweep.event.artifacts-gone"; + +/** the body of artifacts-gone */ +export interface ArtifactsGone { + /** when the sweep found it */ + "at"?: string; + /** artifacts-gone */ + "event": string; + /** the key's fingerprint */ + "fingerprint": string; + /** every reference of the key the store does not hold, sorted */ + "gone": string[]; + /** the builder's done key */ + "key"?: string; + /** the module whose builder's key names a gone artifact */ + "module": string; +} + +/** is what artifacts takes. */ +export interface ArtifactsInput { + /** "true": list the collected references too (counted always) */ + "collected"?: "true" | "false"; + /** only this repository's references, as / (the counts stay whole) */ + "repository"?: string; +} + +/** is what artifacts answers. */ +export type ArtifactsAnswer = Record; + +/** is what held takes. */ +export interface HeldInput { + /** the artifacts' references, artifact-store:////blobs/sha256:: a list, or one text separated by commas */ + "references": string[]; +} + +/** is what held answers. */ +export interface HeldAnswer { + /** the references the store answered 404 for */ + "gone": string[]; + /** the references the store holds */ + "held": string[]; + /** the store asked, host and port */ + "store"?: string; + /** a reference the store could not be asked about, to why */ + "unknown"?: Record; +} + +/** is what collect takes. */ +export interface CollectInput { + /** "true": let go of what is eligible (needs why); without it nothing is done */ + "confirm"?: "true" | "false"; + /** let go of at most this many (500 by default, at most 5000) */ + "most"?: string; + /** with confirm: why, required and recorded */ + "why"?: string; +} + +/** is what collect answers. */ +export type CollectAnswer = Record; + +/** is what mirrors takes. */ +export interface MirrorsInput { +} + +/** is what mirrors answers. */ +export type MirrorsAnswer = Record; + +/** is what images takes. */ +export interface ImagesInput { + /** the machine's name */ + "node": string; +} + +/** is what images answers. */ +export type ImagesAnswer = Record; + +/** is the body of the event artifacts-gone. */ +export type ArtifactsGoneEvent = ArtifactsGone; + diff --git a/contracts/ts/seats/meshartifactsweep/tsconfig.json b/contracts/ts/seats/meshartifactsweep/tsconfig.json new file mode 100644 index 00000000..60274c6a --- /dev/null +++ b/contracts/ts/seats/meshartifactsweep/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "noEmit": true, + "strict": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src/**/*.ts"] +} diff --git a/contracts/ts/seats/meshcatalog/package-lock.json b/contracts/ts/seats/meshcatalog/package-lock.json new file mode 100644 index 00000000..2562cdaf --- /dev/null +++ b/contracts/ts/seats/meshcatalog/package-lock.json @@ -0,0 +1,12 @@ +{ + "lockfileVersion": 3, + "name": "@novox/mesh-delivery-contracts-mesh-catalog", + "packages": { + "": { + "name": "@novox/mesh-delivery-contracts-mesh-catalog", + "version": "0.1.0" + } + }, + "requires": true, + "version": "0.1.0" +} diff --git a/contracts/ts/seats/meshcatalog/package.json b/contracts/ts/seats/meshcatalog/package.json new file mode 100644 index 00000000..4ee7253e --- /dev/null +++ b/contracts/ts/seats/meshcatalog/package.json @@ -0,0 +1,13 @@ +{ + "description": "The contract of the seat mesh-catalog, generated from its definition.", + "exports": { + ".": "./src/index.ts" + }, + "files": [ + "src" + ], + "name": "@novox/mesh-delivery-contracts-mesh-catalog", + "type": "module", + "types": "./src/index.ts", + "version": "0.1.0" +} diff --git a/contracts/ts/seats/meshcatalog/src/index.ts b/contracts/ts/seats/meshcatalog/src/index.ts new file mode 100644 index 00000000..ac232102 --- /dev/null +++ b/contracts/ts/seats/meshcatalog/src/index.ts @@ -0,0 +1,545 @@ +// Code generated by seatgen from the seat definitions; DO NOT EDIT. + +// The contract of the seat "mesh-catalog", defined whole (seats/mesh-catalog/seat.json): The catalog of the mesh: every module the trunk holds with its manifest, fingerprint and verdict, the packages published, and the seat list (ADR 0636 §1, §4). + +export const seat = "mesh-catalog"; + +export const verbs = { + modules: "modules", + module: "module", + provides: "provides", + dependents: "dependents", + seatDefinitions: "seat-definitions", +} as const; + +export const events = { + seatDefinitionChanged: "seat-definition-changed", + seatDefinitionRemoved: "seat-definition-removed", + manifestAccepted: "manifest-accepted", + manifestRefused: "manifest-refused", +} as const; + +export const family = { name: "mesh-catalog.events", maxAgeDays: 7 } as const; + +export const seatDefinitionChangedSubject = "mesh.seat.mesh-catalog.event.seat-definition-changed"; + +export const seatDefinitionRemovedSubject = "mesh.seat.mesh-catalog.event.seat-definition-removed"; + +export const manifestAcceptedSubject = "mesh.seat.mesh-catalog.event.manifest-accepted"; + +export const manifestRefusedSubject = "mesh.seat.mesh-catalog.event.manifest-refused"; + +export const states = { + manifests: "manifests", + modules: "modules", + candidates: "candidates", + verdicts: "verdicts", + predefined: "predefined", + configCandidates: "config-candidates", + packages: "packages", +} as const; + +/** The key of one value of the state manifests under the form "". */ +export function manifestsByDigestKey(digest: string): string { + return digest; +} + +/** The parts of a key of the form "", or undefined for a key of another form. */ +export function parseManifestsByDigestKey(_key: string): { digest: string } | undefined { + let _rest = _key; + if (_rest === "") return undefined; + const digest = _rest; + _rest = ""; + if (_rest !== "") return undefined; + return { digest }; +} + +/** The key of one value of the state modules under the form "". */ +export function modulesByModuleKey(module: string): string { + return module; +} + +/** The parts of a key of the form "", or undefined for a key of another form. */ +export function parseModulesByModuleKey(_key: string): { module: string } | undefined { + let _rest = _key; + if (_rest === "") return undefined; + const module = _rest; + _rest = ""; + if (_rest !== "") return undefined; + return { module }; +} + +/** The key of one value of the state candidates under the form "". */ +export function candidatesByGroupKey(group: string): string { + return group; +} + +/** The parts of a key of the form "", or undefined for a key of another form. */ +export function parseCandidatesByGroupKey(_key: string): { group: string } | undefined { + let _rest = _key; + if (_rest === "") return undefined; + const group = _rest; + _rest = ""; + if (_rest !== "") return undefined; + return { group }; +} + +/** The key of one value of the state verdicts under the form "". */ +export function verdictsByDigestKey(digest: string): string { + return digest; +} + +/** The parts of a key of the form "", or undefined for a key of another form. */ +export function parseVerdictsByDigestKey(_key: string): { digest: string } | undefined { + let _rest = _key; + if (_rest === "") return undefined; + const digest = _rest; + _rest = ""; + if (_rest !== "") return undefined; + return { digest }; +} + +/** The key of one value of the state predefined under the form "list". */ +export function predefinedByListKey(): string { + return "list"; +} + +/** The parts of a key of the form "list", or undefined for a key of another form. */ +export function parsePredefinedByListKey(_key: string): { } | undefined { + let _rest = _key; + if (!_rest.startsWith("list")) return undefined; + _rest = _rest.slice(4); + if (_rest !== "") return undefined; + return { }; +} + +/** The key of one value of the state config-candidates under the form "". */ +export function configCandidatesByPullKey(pull: string): string { + return pull; +} + +/** The parts of a key of the form "", or undefined for a key of another form. */ +export function parseConfigCandidatesByPullKey(_key: string): { pull: string } | undefined { + let _rest = _key; + if (_rest === "") return undefined; + const pull = _rest; + _rest = ""; + if (_rest !== "") return undefined; + return { pull }; +} + +/** The key of one value of the state packages under the form "". */ +export function packagesByModuleKey(module: string): string { + return module; +} + +/** The parts of a key of the form "", or undefined for a key of another form. */ +export function parsePackagesByModuleKey(_key: string): { module: string } | undefined { + let _rest = _key; + if (_rest === "") return undefined; + const module = _rest; + _rest = ""; + if (_rest !== "") return undefined; + return { module }; +} + +/** The key of one value of the state packages under the form ".". */ +export function packagesByPackageSeriesKey(packagePart: string, series: string): string { + return packagePart + "." + series; +} + +/** The parts of a key of the form ".", or undefined for a key of another form. */ +export function parsePackagesByPackageSeriesKey(_key: string): { packagePart: string; series: string } | undefined { + let _rest = _key; + const _at0 = _rest.indexOf("."); + if (_at0 <= 0) return undefined; + const packagePart = _rest.slice(0, _at0); + _rest = _rest.slice(_at0 + 1); + if (_rest === "") return undefined; + const series = _rest; + _rest = ""; + if (_rest !== "") return undefined; + return { packagePart, series }; +} + +/** a toolchain a fingerprint was computed with (ADR 0405 clause 7) */ +export interface ToolchainUsed { + "language": string; + /** the build module's recipe version */ + "recipe": string; + /** the installed toolchain's version */ + "version": string; +} + +/** is what modules takes. */ +export interface ModulesInput { +} + +export interface ModulesAnswerModulesItem { + /** the merge commit it was last changed at */ + "commit": string; + /** its fingerprint (src2: and 64 hex digits); absent when it has none */ + "fp"?: string; + "module": string; + /** why it has none */ + "none"?: string; + /** the module's directory in its repository, '.' for a root */ + "path"?: string; + "repository": string; +} + +/** is what modules answers. */ +export interface ModulesAnswer { + "modules": ModulesAnswerModulesItem[]; +} + +/** is what module takes. */ +export interface ModuleInput { + /** the module's name */ + "module": string; +} + +export interface ModuleAnswerModule { + /** the merge commit it was last changed at */ + "commit": string; + /** its fingerprint (src2: and 64 hex digits); absent when it has none */ + "fp"?: string; + /** the module's manifest as the trunk holds it */ + "manifest"?: Record; + "module": string; + /** why it has none */ + "none"?: string; + /** the module's directory in its repository, '.' for a root */ + "path"?: string; + "repository": string; +} + +/** is what module answers. */ +export interface ModuleAnswer { + "module": ModuleAnswerModule; +} + +/** is what provides takes. */ +export interface ProvidesInput { + /** the provision, such as postgres-database */ + "provision": string; +} + +/** is what provides answers. */ +export type ProvidesAnswer = Record; + +/** is what dependents takes. */ +export interface DependentsInput { + /** the module's name */ + "module": string; +} + +/** is what dependents answers. */ +export type DependentsAnswer = Record; + +/** is what seat-definitions takes. */ +export interface SeatDefinitionsInput { + /** the seat's name; none lists every trunk seat */ + "seat"?: string; +} + +export interface SeatDefinitionsAnswerEntry { + /** the definition, resolved with the parts it includes */ + "definition"?: Record; + "digest": string; + "seat": string; + /** trunk */ + "source": string; + /** why the definition could not be read, when it could not */ + "unparsed"?: string; +} + +export interface SeatDefinitionsAnswerSeatsItem { + "digest": string; + "seat": string; + /** trunk */ + "source": string; +} + +/** is what seat-definitions answers. */ +export interface SeatDefinitionsAnswer { + "entry"?: SeatDefinitionsAnswerEntry; + "found"?: boolean; + "revision"?: number; + "seat"?: string; + "seats"?: SeatDefinitionsAnswerSeatsItem[]; +} + +/** is the body of the event seat-definition-changed. */ +export interface SeatDefinitionChangedEvent { + "digest": string; + "seat": string; + /** trunk */ + "source": string; +} + +/** is the body of the event seat-definition-removed. */ +export interface SeatDefinitionRemovedEvent { + "seat": string; +} + +export interface ManifestAcceptedEventBusStateItem { + "configuration"?: Record; + "name": string; + "options"?: Record; + "storage": string; + "template"?: string; + "types"?: Record[]; +} + +/** is the body of the event manifest-accepted. */ +export interface ManifestAcceptedEvent { + "bus-state": ManifestAcceptedEventBusStateItem[]; + "digest": string; + "judge": string; + "module": string; + /** the indexer's revision of the verdict it says; a reader asks its first view again on a gap in it; zero from an indexer that does not say it */ + "revision"?: number; + "v": number; +} + +/** is the body of the event manifest-refused. */ +export interface ManifestRefusedEvent { + "digest": string; + "judge": string; + "module": string; + "problems": string[]; + /** the indexer's revision of the verdict it says; a reader asks its first view again on a gap in it; zero from an indexer that does not say it */ + "revision"?: number; + "v": number; +} + +/** is one value of the state manifests under the key . */ +export interface ManifestsByDigestValue { + /** the manifest, UTF-8 JSON text */ + "manifest": string; + "module": string; + /** the layout version */ + "v"?: number; +} + +export interface ModulesByModuleValueInputs2 { + "bases"?: string[]; + "builder"?: string; + "contexts"?: Record; + "import-form"?: string; + "imports"?: string[]; + "locks"?: Record; + "packages"?: string[]; + "paths"?: Record; + "tree": string; +} + +/** is one value of the state modules under the key . */ +export interface ModulesByModuleValue { + "commit": string; + /** the old-form fingerprint */ + "fp"?: string; + "fp-src"?: number; + /** src2: fingerprint */ + "fp2"?: string; + "inputs2"?: ModulesByModuleValueInputs2; + /** manifest digest */ + "manifest"?: string; + "needs"?: string[]; + "none"?: string; + /** why it has none */ + "none2"?: string; + "packages"?: Record; + "path"?: string; + "paths"?: Record; + "problems"?: string[]; + "repository": string; + /** the source revision */ + "src"?: number; + /** by language: the toolchain FP2 was computed with */ + "toolchains"?: Record; + "unknown"?: string[]; + /** the layout version */ + "v"?: number; + "verdict"?: string; + "warnings"?: string[]; +} + +export interface CandidatesByGroupValuePullsItemFpsValue { + "fp2"?: string; + "manifest"?: string; + "none2"?: string; + "packages"?: Record; + "paths"?: Record; +} + +export interface CandidatesByGroupValuePullsItem { + "fps"?: Record; + "head": string; + "modules": string[]; + "number": number; + "repository": string; +} + +/** is one value of the state candidates under the key . */ +export interface CandidatesByGroupValue { + /** the source revision it is read on */ + "base": number; + "fpset"?: string; + "group": string; + "heads": string; + "pulls": CandidatesByGroupValuePullsItem[]; + /** the layout version */ + "v"?: number; +} + +/** is one value of the state verdicts under the key . */ +export interface VerdictsByDigestValue { + /** when (RFC 3339 time) */ + "at"?: string; + "judge"?: string; + "module": string; + "problems"?: string[]; + "state": string; + "unknown"?: string[]; + /** the layout version */ + "v"?: number; + "warnings"?: string[]; +} + +/** is one value of the state predefined under the key list. */ +export interface PredefinedByListValue { + /** when (RFC 3339 time) */ + "at"?: string; + "commit": string; + /** the foundation's predefined list as kept */ + "list": unknown; + /** the layout version */ + "v"?: number; +} + +export interface ConfigCandidatesByPullValueDeclaresValueValue { + /** the default */ + "default": unknown; + "description"?: string; + /** the allowed values */ + "enum"?: unknown; + "kind": string; + /** a declared bound; 0 is a bound, absent is none */ + "maximum"?: number | null; + /** a declared bound; 0 is a bound, absent is none */ + "minimum"?: number | null; + "pattern"?: string; + "predefined"?: string; + "required"?: boolean; + "runtime"?: boolean; + "trusted"?: boolean; + "type"?: string; + "why": string; +} + +/** is one value of the state config-candidates under the key . */ +export interface ConfigCandidatesByPullValue { + /** when (RFC 3339 time) */ + "at"?: string; + /** module to key to declaration */ + "declares": Record>; + "head": string; + "pull": string; + /** the layout version */ + "v"?: number; +} + +export interface PackagesByModuleValuePackagesItem { + "digest": string; + "package": string; +} + +/** is one value of the state packages under the key . */ +export interface PackagesByModuleValue { + /** when (RFC 3339 time) */ + "at"?: string; + "commit": string; + "dir"?: string; + /** the Go module path */ + "module": string; + "packages": PackagesByModuleValuePackagesItem[]; + "repository": string; + /** digest of go.mod and go.sum */ + "requires"?: string; + /** the layout version */ + "v"?: number; +} + +export interface PackagesByPackageSeriesValueNewestContentsPackagesItem { + "digest": string; + "package": string; +} + +export interface PackagesByPackageSeriesValueNewestContents { + /** when (RFC 3339 time) */ + "at"?: string; + "commit": string; + "dir"?: string; + /** the Go module path */ + "module": string; + "packages": PackagesByPackageSeriesValueNewestContentsPackagesItem[]; + "repository": string; + /** digest of go.mod and go.sum */ + "requires"?: string; + /** the layout version */ + "v"?: number; +} + +export interface PackagesByPackageSeriesValueNewest { + "commit"?: string; + "contents"?: PackagesByPackageSeriesValueNewestContents; + "digest"?: string; + "fingerprint"?: string; + "version": string; +} + +export interface PackagesByPackageSeriesValueNumberedContentsPackagesItem { + "digest": string; + "package": string; +} + +export interface PackagesByPackageSeriesValueNumberedContents { + /** when (RFC 3339 time) */ + "at"?: string; + "commit": string; + "dir"?: string; + /** the Go module path */ + "module": string; + "packages": PackagesByPackageSeriesValueNumberedContentsPackagesItem[]; + "repository": string; + /** digest of go.mod and go.sum */ + "requires"?: string; + /** the layout version */ + "v"?: number; +} + +export interface PackagesByPackageSeriesValueNumbered { + "commit"?: string; + "contents"?: PackagesByPackageSeriesValueNumberedContents; + "digest"?: string; + "fingerprint"?: string; + "version": string; +} + +/** is one value of the state packages under the key .. */ +export interface PackagesByPackageSeriesValue { + /** when (RFC 3339 time) */ + "at"?: string; + "go-module"?: string; + "name": string; + "newest"?: PackagesByPackageSeriesValueNewest; + "numbered"?: PackagesByPackageSeriesValueNumbered; + "package"?: string; + "seat"?: string; + "series": string; + /** the layout version */ + "v"?: number; +} + diff --git a/contracts/ts/seats/meshcatalog/tsconfig.json b/contracts/ts/seats/meshcatalog/tsconfig.json new file mode 100644 index 00000000..60274c6a --- /dev/null +++ b/contracts/ts/seats/meshcatalog/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "noEmit": true, + "strict": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src/**/*.ts"] +} diff --git a/contracts/ts/seats/meshdelivery/package-lock.json b/contracts/ts/seats/meshdelivery/package-lock.json new file mode 100644 index 00000000..8bfb16f6 --- /dev/null +++ b/contracts/ts/seats/meshdelivery/package-lock.json @@ -0,0 +1,12 @@ +{ + "lockfileVersion": 3, + "name": "@novox/mesh-delivery-contracts-mesh-delivery", + "packages": { + "": { + "name": "@novox/mesh-delivery-contracts-mesh-delivery", + "version": "0.1.0" + } + }, + "requires": true, + "version": "0.1.0" +} diff --git a/contracts/ts/seats/meshdelivery/package.json b/contracts/ts/seats/meshdelivery/package.json new file mode 100644 index 00000000..dda5c61b --- /dev/null +++ b/contracts/ts/seats/meshdelivery/package.json @@ -0,0 +1,13 @@ +{ + "description": "The contract of the seat mesh-delivery, generated from its definition.", + "exports": { + ".": "./src/index.ts" + }, + "files": [ + "src" + ], + "name": "@novox/mesh-delivery-contracts-mesh-delivery", + "type": "module", + "types": "./src/index.ts", + "version": "0.1.0" +} diff --git a/contracts/ts/seats/meshdelivery/src/index.ts b/contracts/ts/seats/meshdelivery/src/index.ts new file mode 100644 index 00000000..261fe53b --- /dev/null +++ b/contracts/ts/seats/meshdelivery/src/index.ts @@ -0,0 +1,338 @@ +// Code generated by seatgen from the seat definitions; DO NOT EDIT. + +// The contract of the seat "mesh-delivery", defined whole (seats/mesh-delivery/seat.json): The delivery gate: reads and steers deliveries, merges and the checks that decide them; the merge is made on the operator's Approve once both checks passed. + +export const seat = "mesh-delivery"; + +export const verbs = { + deliveries: "deliveries", + times: "times", + show: "show", + groups: "groups", + whatIf: "what-if", + checks: "checks", + table: "table", + stalled: "stalled", + recheck: "recheck", + release: "release", + stop: "stop", + close: "close", + retireHistory: "retire-history", + settle: "settle", +} as const; + +export const states = { + source: "source", + merges: "merges", + asks: "asks", + owed: "owed", + turns: "turns", +} as const; + +/** The key of one value of the state source under the form "main". */ +export function sourceByMainKey(): string { + return "main"; +} + +/** The parts of a key of the form "main", or undefined for a key of another form. */ +export function parseSourceByMainKey(_key: string): { } | undefined { + let _rest = _key; + if (!_rest.startsWith("main")) return undefined; + _rest = _rest.slice(4); + if (_rest !== "") return undefined; + return { }; +} + +/** The key of one value of the state merges under the form "". */ +export function mergesByGroupKey(group: string): string { + return group; +} + +/** The parts of a key of the form "", or undefined for a key of another form. */ +export function parseMergesByGroupKey(_key: string): { group: string } | undefined { + let _rest = _key; + if (_rest === "") return undefined; + const group = _rest; + _rest = ""; + if (_rest !== "") return undefined; + return { group }; +} + +/** The key of one value of the state asks under the form "merge..". */ +export function asksByMergeGroupHeadsKey(group: string, heads: string): string { + return "merge." + group + "." + heads; +} + +/** The parts of a key of the form "merge..", or undefined for a key of another form. */ +export function parseAsksByMergeGroupHeadsKey(_key: string): { group: string; heads: string } | undefined { + let _rest = _key; + if (!_rest.startsWith("merge.")) return undefined; + _rest = _rest.slice(6); + const _at1 = _rest.indexOf("."); + if (_at1 <= 0) return undefined; + const group = _rest.slice(0, _at1); + _rest = _rest.slice(_at1 + 1); + if (_rest === "") return undefined; + const heads = _rest; + _rest = ""; + if (_rest !== "") return undefined; + return { group, heads }; +} + +/** The key of one value of the state owed under the form "...". */ +export function owedByRepositoryNumberHeadActKey(repository: string, number: string, head: string, act: string): string { + return repository + "." + number + "." + head + "." + act; +} + +/** The parts of a key of the form "...", or undefined for a key of another form. */ +export function parseOwedByRepositoryNumberHeadActKey(_key: string): { repository: string; number: string; head: string; act: string } | undefined { + let _rest = _key; + const _at0 = _rest.indexOf("."); + if (_at0 <= 0) return undefined; + const repository = _rest.slice(0, _at0); + _rest = _rest.slice(_at0 + 1); + const _at2 = _rest.indexOf("."); + if (_at2 <= 0) return undefined; + const number = _rest.slice(0, _at2); + _rest = _rest.slice(_at2 + 1); + const _at4 = _rest.indexOf("."); + if (_at4 <= 0) return undefined; + const head = _rest.slice(0, _at4); + _rest = _rest.slice(_at4 + 1); + if (_rest === "") return undefined; + const act = _rest; + _rest = ""; + if (_rest !== "") return undefined; + return { repository, number, head, act }; +} + +/** The key of one value of the state turns under the form "". */ +export function turnsByRepositoryKey(repository: string): string { + return repository; +} + +/** The parts of a key of the form "", or undefined for a key of another form. */ +export function parseTurnsByRepositoryKey(_key: string): { repository: string } | undefined { + let _rest = _key; + if (_rest === "") return undefined; + const repository = _rest; + _rest = ""; + if (_rest !== "") return undefined; + return { repository }; +} + +/** is what deliveries takes. */ +export interface DeliveriesInput { + /** "true": the final ones of the last thirty days too */ + "all"?: "true" | "false"; + /** a group's id (its branch name) */ + "group"?: string; + /** owner/repository */ + "repository"?: string; + /** one state, e.g. delivering or held */ + "state"?: string; +} + +/** is what deliveries answers. */ +export type DeliveriesAnswer = Record; + +/** is what times takes. */ +export interface TimesInput { +} + +/** is what times answers. */ +export type TimesAnswer = Record; + +/** is what show takes. */ +export interface ShowInput { + /** a delivery's id, or a group's */ + "id": string; +} + +/** is what show answers. */ +export type ShowAnswer = Record; + +/** is what groups takes. */ +export interface GroupsInput { + /** "true": the groups that ended too */ + "all"?: "true" | "false"; +} + +/** is what groups answers. */ +export type GroupsAnswer = Record; + +/** is what what-if takes. */ +export interface WhatIfInput { +} + +/** is what what-if answers. */ +export type WhatIfAnswer = Record; + +/** is what checks takes. */ +export interface ChecksInput { + /** a commit's sha, or the start of one, instead of a pull request */ + "commit"?: string; + /** a pull request's number: its head is read */ + "number"?: string; + /** owner/repository */ + "repository": string; +} + +/** is what checks answers. */ +export type ChecksAnswer = Record; + +/** is what table takes. */ +export interface TableInput { +} + +/** is what table answers. */ +export type TableAnswer = Record; + +/** is what stalled takes. */ +export interface StalledInput { +} + +/** is what stalled answers. */ +export type StalledAnswer = Record; + +/** is what recheck takes. */ +export interface RecheckInput { + /** the delivery's id */ + "id": string; + /** why, kept with the transition */ + "why": string; +} + +/** is what recheck answers. */ +export type RecheckAnswer = Record; + +/** is what release takes. */ +export interface ReleaseInput { +} + +/** is what release answers. */ +export type ReleaseAnswer = Record; + +/** is what stop takes. */ +export interface StopInput { +} + +/** is what stop answers. */ +export type StopAnswer = Record; + +/** is what close takes. */ +export interface CloseInput { + /** the delivery's id */ + "id": string; + /** the condition it answers */ + "why": string; +} + +/** is what close answers. */ +export type CloseAnswer = Record; + +/** is what retire-history takes. */ +export interface RetireHistoryInput { +} + +/** is what retire-history answers. */ +export type RetireHistoryAnswer = Record; + +/** is what settle takes. */ +export interface SettleInput { + /** the held deliveries' ids (owner/repository@commit) */ + "ids": string[]; + /** why they may be settled, shown to the operator */ + "why": string; +} + +/** is what settle answers. */ +export type SettleAnswer = Record; + +/** is one value of the state source under the key main. */ +export interface SourceByMainValue { + /** the group that moved it */ + "group": string; + /** repository to the merged head, a full commit */ + "heads": Record; + /** the layout version */ + "v"?: number; +} + +export interface MergesByGroupValuePullsItem { + "commit"?: string; + "head": string; + "number": number; + "repository": string; + /** merging, merged or refused */ + "state": string; + "words"?: string; +} + +/** is one value of the state merges under the key . */ +export interface MergesByGroupValue { + /** @ to the approved head */ + "approved"?: Record; + /** the ask key */ + "ask": string; + "group": string; + "pulls": MergesByGroupValuePullsItem[]; + /** the layout version */ + "v"?: number; +} + +export interface AsksByMergeGroupHeadsValueHeadsItem { + /** the full commit */ + "head": string; + "number": number; + /** / */ + "repository": string; +} + +/** is one value of the state asks under the key merge... */ +export interface AsksByMergeGroupHeadsValue { + /** the operator-channel ask as raised */ + "ask": unknown; + /** the number of times it was asked */ + "asking"?: number; + "group": string; + "heads": AsksByMergeGroupHeadsValueHeadsItem[]; + /** the layout version */ + "v"?: number; +} + +/** is one value of the state owed under the key .... */ +export interface OwedByRepositoryNumberHeadActValue { + /** status=, view, note or comment. */ + "act": string; + "context"?: string; + "ref"?: string; + "state"?: string; + "target"?: string; + "text"?: string; + /** the layout version */ + "v"?: number; +} + +export interface TurnsByRepositoryValueWaitingItem { + /** when (RFC 3339 time) */ + "approved": string; + "ask"?: string; + "group": string; + "heads": string; + /** when (RFC 3339 time) */ + "since": string; + "step"?: string; + "waits-for"?: string; + /** when (RFC 3339 time) */ + "waits-since"?: string; +} + +/** is one value of the state turns under the key . */ +export interface TurnsByRepositoryValue { + "repository": string; + /** the layout version */ + "v"?: number; + "waiting": TurnsByRepositoryValueWaitingItem[]; +} + diff --git a/contracts/ts/seats/meshdelivery/tsconfig.json b/contracts/ts/seats/meshdelivery/tsconfig.json new file mode 100644 index 00000000..60274c6a --- /dev/null +++ b/contracts/ts/seats/meshdelivery/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "noEmit": true, + "strict": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src/**/*.ts"] +} diff --git a/contracts/ts/seats/meshdeliverychecker/package-lock.json b/contracts/ts/seats/meshdeliverychecker/package-lock.json new file mode 100644 index 00000000..c5d3fd93 --- /dev/null +++ b/contracts/ts/seats/meshdeliverychecker/package-lock.json @@ -0,0 +1,12 @@ +{ + "lockfileVersion": 3, + "name": "@novox/mesh-delivery-contracts-mesh-delivery-checker", + "packages": { + "": { + "name": "@novox/mesh-delivery-contracts-mesh-delivery-checker", + "version": "0.1.0" + } + }, + "requires": true, + "version": "0.1.0" +} diff --git a/contracts/ts/seats/meshdeliverychecker/package.json b/contracts/ts/seats/meshdeliverychecker/package.json new file mode 100644 index 00000000..25896a5e --- /dev/null +++ b/contracts/ts/seats/meshdeliverychecker/package.json @@ -0,0 +1,13 @@ +{ + "description": "The contract of the seat mesh-delivery-checker, generated from its definition.", + "exports": { + ".": "./src/index.ts" + }, + "files": [ + "src" + ], + "name": "@novox/mesh-delivery-contracts-mesh-delivery-checker", + "type": "module", + "types": "./src/index.ts", + "version": "0.1.0" +} diff --git a/contracts/ts/seats/meshdeliverychecker/src/index.ts b/contracts/ts/seats/meshdeliverychecker/src/index.ts new file mode 100644 index 00000000..91608f2d --- /dev/null +++ b/contracts/ts/seats/meshdeliverychecker/src/index.ts @@ -0,0 +1,390 @@ +// Code generated by seatgen from the seat definitions; DO NOT EDIT. + +// The contract of the seat "mesh-delivery-checker", defined whole (seats/mesh-delivery-checker/seat.json): The merge check's tries: start one, read one, end one. The checker's own events stay under its own subjects (ADR 0620 §2). + +export const seat = "mesh-delivery-checker"; + +export const verbs = { + try: "try", + tryRead: "try-read", + tryEnd: "try-end", + input: "input", +} as const; + +export const holderEvents = { + verdict: "verdict", + tagWanted: "tag-wanted", + previewWritten: "preview.written", +} as const; + +export function verdictHolderSubject(holder: string): string { + return "mesh.stream." + holder + ".event.verdict"; +} + +export function tagWantedHolderSubject(holder: string): string { + return "mesh.stream." + holder + ".event.tag-wanted"; +} + +export function previewWrittenHolderSubject(holder: string): string { + return "mesh.stream." + holder + ".event.preview.written"; +} + +export const states = { + verdicts: "verdicts", + kept: "kept", + owed: "owed", + previews: "previews", +} as const; + +/** The key of one value of the state verdicts under the form ".". */ +export function verdictsByGroupHeadsKey(group: string, heads: string): string { + return group + "." + heads; +} + +/** The parts of a key of the form ".", or undefined for a key of another form. */ +export function parseVerdictsByGroupHeadsKey(_key: string): { group: string; heads: string } | undefined { + let _rest = _key; + const _at0 = _rest.indexOf("."); + if (_at0 <= 0) return undefined; + const group = _rest.slice(0, _at0); + _rest = _rest.slice(_at0 + 1); + if (_rest === "") return undefined; + const heads = _rest; + _rest = ""; + if (_rest !== "") return undefined; + return { group, heads }; +} + +/** The key of one value of the state kept under the form ".". */ +export function keptByLevelDigestKey(level: string, digest: string): string { + return level + "." + digest; +} + +/** The parts of a key of the form ".", or undefined for a key of another form. */ +export function parseKeptByLevelDigestKey(_key: string): { level: string; digest: string } | undefined { + let _rest = _key; + const _at0 = _rest.indexOf("."); + if (_at0 <= 0) return undefined; + const level = _rest.slice(0, _at0); + _rest = _rest.slice(_at0 + 1); + if (_rest === "") return undefined; + const digest = _rest; + _rest = ""; + if (_rest !== "") return undefined; + return { level, digest }; +} + +/** The key of one value of the state owed under the form "...". */ +export function owedByRepositoryNumberHeadActKey(repository: string, number: string, head: string, act: string): string { + return repository + "." + number + "." + head + "." + act; +} + +/** The parts of a key of the form "...", or undefined for a key of another form. */ +export function parseOwedByRepositoryNumberHeadActKey(_key: string): { repository: string; number: string; head: string; act: string } | undefined { + let _rest = _key; + const _at0 = _rest.indexOf("."); + if (_at0 <= 0) return undefined; + const repository = _rest.slice(0, _at0); + _rest = _rest.slice(_at0 + 1); + const _at2 = _rest.indexOf("."); + if (_at2 <= 0) return undefined; + const number = _rest.slice(0, _at2); + _rest = _rest.slice(_at2 + 1); + const _at4 = _rest.indexOf("."); + if (_at4 <= 0) return undefined; + const head = _rest.slice(0, _at4); + _rest = _rest.slice(_at4 + 1); + if (_rest === "") return undefined; + const act = _rest; + _rest = ""; + if (_rest !== "") return undefined; + return { repository, number, head, act }; +} + +/** The key of one value of the state previews under the form "..". */ +export function previewsByRepositoryHeadTrunkKey(repository: string, head: string, trunk: string): string { + return repository + "." + head + "." + trunk; +} + +/** The parts of a key of the form "..", or undefined for a key of another form. */ +export function parsePreviewsByRepositoryHeadTrunkKey(_key: string): { repository: string; head: string; trunk: string } | undefined { + let _rest = _key; + const _at0 = _rest.indexOf("."); + if (_at0 <= 0) return undefined; + const repository = _rest.slice(0, _at0); + _rest = _rest.slice(_at0 + 1); + const _at2 = _rest.indexOf("."); + if (_at2 <= 0) return undefined; + const head = _rest.slice(0, _at2); + _rest = _rest.slice(_at2 + 1); + if (_rest === "") return undefined; + const trunk = _rest; + _rest = ""; + if (_rest !== "") return undefined; + return { repository, head, trunk }; +} + +export interface TryInputMembersItem { + /** the full commit to try, 40 or 64 hex digits */ + "head": string; + /** / */ + "repository": string; + /** the trunk commit to merge it onto, or the trunk's branch for its head now; the trunk's head when absent */ + "trunk"?: string; +} + +/** is what try takes. */ +export interface TryInput { + /** the full commit to try, 40 or 64 hex digits, for a try of one member (instead of members) */ + "head"?: string; + /** unit or integration: only this level; both when absent */ + "level"?: string; + /** the group: one repository at most once, each at the exact commit asked and the trunk commit it is merged onto; instead of repository and head */ + "members"?: TryInputMembersItem[]; + /** /, for a try of one member (instead of members) */ + "repository"?: string; + /** a go test -run pattern: only those tests run, and nothing is kept */ + "tests"?: string; + /** the trunk commit to merge it onto, or the trunk's branch for its head now; the trunk's head when absent */ + "trunk"?: string; +} + +/** is what try answers. */ +export interface TryAnswer { + /** the try's id */ + "id": string; + /** its state */ + "state"?: string; +} + +/** is what try-read takes. */ +export interface TryReadInput { + /** the try's id, try- */ + "id": string; +} + +/** is what try-read answers. */ +export type TryReadAnswer = Record; + +/** is what try-end takes. */ +export interface TryEndInput { + /** the try's id, try- */ + "id": string; + /** why it is ended, in words */ + "why": string; +} + +/** is what try-end answers. */ +export interface TryEndAnswer { + /** it is ended */ + "ended": boolean; + /** the try */ + "id": string; +} + +/** is what input takes. */ +export interface InputInput { + /** the input's sha256, 64 lowercase hex digits, as the run ask names it */ + "digest": string; +} + +/** is what input answers. */ +export interface InputAnswer { + /** the digest asked */ + "digest": string; + /** the input's exact bytes, base64, so no encoding on the way changes a byte the digest covers */ + "input": string; +} + +export interface VerdictHolderEventSeriesItem { + /** its artifact, or the package's ecosystem name as the module's packages name it */ + "artifact": string; + /** the series on trunk */ + "from": string; + /** the module whose artifact's series changes */ + "module": string; + /** the series the checked head gives it */ + "to": string; +} + +/** is the body of the holder's event verdict. */ +export interface VerdictHolderEvent { + /** the commit checked */ + "head": string; + /** the generated files out of step with their seat definitions, each path (design 26) */ + "out-of-step"?: string[]; + /** the pull request's number, for a pull request's check */ + "pull"?: number; + /** owner/repo */ + "repository": string; + /** each change of an artifact's series the checked head makes against trunk's manifests, once each (design 32); absent when it changes none */ + "series"?: VerdictHolderEventSeriesItem[]; + /** pass or fail */ + "verdict": string; +} + +/** is the body of the holder's event tag-wanted. */ +export interface TagWantedHolderEvent { + /** the pull request's branch: its tag is removed when the branch ends */ + "branch": string; + /** its head */ + "head": string; + /** the group member */ + "module": string; + /** the package it declares */ + "package": string; + /** the pull request */ + "pull"?: number; + /** owner/repo */ + "repository": string; + /** the tag wanted */ + "tag"?: string; +} + +export interface VerdictsByGroupHeadsValueComposition { + "fpset"?: string; + "machines"?: string[]; + "sdk"?: string; + /** not-run, pass or fail */ + "state": string; + "words"?: string; +} + +export interface VerdictsByGroupHeadsValueFailed { + "machine"?: string; + "test"?: string; + "words"?: string; +} + +export interface VerdictsByGroupHeadsValueFailuresItem { + "machine"?: string; + "test"?: string; + "words"?: string; +} + +export interface VerdictsByGroupHeadsValueJudgedByHeadsItem { + "commit": string; + "repository": string; +} + +export interface VerdictsByGroupHeadsValueJudgedByProgram { + "commit": string; + "dir": string; + "package": string; + "repository": string; +} + +/** the checking program that judged the check: where it was built, the key its build is kept under, whether this check's run built it, and the heads it read (ADR 0644, ADR 0656). Absent in a value written before it */ +export interface VerdictsByGroupHeadsValueJudgedBy { + "built"?: boolean; + "heads": VerdictsByGroupHeadsValueJudgedByHeadsItem[]; + /** program- and 16 hex digits */ + "key": string; + "program": VerdictsByGroupHeadsValueJudgedByProgram; +} + +export interface VerdictsByGroupHeadsValueLevelsValue { + "kept": number; + "run": number; + "skipped"?: number; +} + +export interface VerdictsByGroupHeadsValueSeriesItem { + "artifact": string; + "from": string; + "module": string; + "to": string; +} + +/** milliseconds */ +export interface VerdictsByGroupHeadsValueTook { + "clone"?: number; + "composition"?: number; + "integration-run"?: number; + "integration-setup"?: number; + "lint"?: number; + "queue"?: number; + "unit"?: number; +} + +/** is one value of the state verdicts under the key .. */ +export interface VerdictsByGroupHeadsValue { + "beside"?: Record; + "carried-from"?: Record; + "composition"?: VerdictsByGroupHeadsValueComposition; + "failed"?: VerdictsByGroupHeadsValueFailed; + "failures"?: VerdictsByGroupHeadsValueFailuresItem[]; + "group": string; + /** 12 hex digits */ + "heads": string; + /** the checking program that judged the check: where it was built, the key its build is kept under, whether this check's run built it, and the heads it read (ADR 0644, ADR 0656). Absent in a value written before it */ + "judged-by"?: VerdictsByGroupHeadsValueJudgedBy; + /** by level, unit or integration */ + "levels"?: Record; + "machines"?: string[]; + "over-bound"?: string[]; + "sdk"?: string; + "series"?: VerdictsByGroupHeadsValueSeriesItem[]; + /** running, pass or fail */ + "state": string; + /** milliseconds */ + "took"?: VerdictsByGroupHeadsValueTook; + /** repository to the trunk head merged onto */ + "trunk"?: Record; + /** the layout version */ + "v"?: number; +} + +/** is one value of the state kept under the key .. */ +export interface KeptByLevelDigestValue { + /** when (RFC 3339 time) */ + "at"?: string; + "level": string; + "package": string; + "repository": string; + /** pass: a failure is never kept */ + "state": string; + "tests"?: number; + "took"?: number; + "toolchain"?: string; + /** the layout version */ + "v"?: number; +} + +/** is one value of the state owed under the key .... */ +export interface OwedByRepositoryNumberHeadActValue { + /** status=, view, note or comment. */ + "act": string; + "context"?: string; + "ref"?: string; + "state"?: string; + "target"?: string; + "text"?: string; + /** the layout version */ + "v"?: number; +} + +export interface PreviewsByRepositoryHeadTrunkValueModulesValue { + "locks"?: Record; + /** manifest digest */ + "manifest": string; + "packages"?: string[]; + "paths"?: Record; + "tree": string; +} + +/** is one value of the state previews under the key ... */ +export interface PreviewsByRepositoryHeadTrunkValue { + /** files in conflict */ + "conflicts"?: string[]; + "head": string; + /** the merge commit */ + "merge"?: string; + /** by directory */ + "modules"?: Record; + "repository": string; + "trunk": string; + /** the layout version */ + "v"?: number; +} + diff --git a/contracts/ts/seats/meshdeliverychecker/tsconfig.json b/contracts/ts/seats/meshdeliverychecker/tsconfig.json new file mode 100644 index 00000000..60274c6a --- /dev/null +++ b/contracts/ts/seats/meshdeliverychecker/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "noEmit": true, + "strict": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src/**/*.ts"] +} diff --git a/contracts/ts/seats/meshwants/package-lock.json b/contracts/ts/seats/meshwants/package-lock.json new file mode 100644 index 00000000..e803a33d --- /dev/null +++ b/contracts/ts/seats/meshwants/package-lock.json @@ -0,0 +1,12 @@ +{ + "lockfileVersion": 3, + "name": "@novox/mesh-delivery-contracts-mesh-wants", + "packages": { + "": { + "name": "@novox/mesh-delivery-contracts-mesh-wants", + "version": "0.1.0" + } + }, + "requires": true, + "version": "0.1.0" +} diff --git a/contracts/ts/seats/meshwants/package.json b/contracts/ts/seats/meshwants/package.json new file mode 100644 index 00000000..6e614224 --- /dev/null +++ b/contracts/ts/seats/meshwants/package.json @@ -0,0 +1,13 @@ +{ + "description": "The contract of the seat mesh-wants, generated from its definition.", + "exports": { + ".": "./src/index.ts" + }, + "files": [ + "src" + ], + "name": "@novox/mesh-delivery-contracts-mesh-wants", + "type": "module", + "types": "./src/index.ts", + "version": "0.1.0" +} diff --git a/contracts/ts/seats/meshwants/src/index.ts b/contracts/ts/seats/meshwants/src/index.ts new file mode 100644 index 00000000..38dab791 --- /dev/null +++ b/contracts/ts/seats/meshwants/src/index.ts @@ -0,0 +1,211 @@ +// Code generated by seatgen from the seat definitions; DO NOT EDIT. + +// The contract of the seat "mesh-wants", defined whole (seats/mesh-wants/seat.json): The seat that says what each node should run and whether each module's new builds wait for a person (ADR 0565, ADR 0539). + +export const seat = "mesh-wants"; + +export const verbs = { + upgrade: "upgrade", + bus: "bus", +} as const; + +export const events = { + policyChanged: "policy-changed", +} as const; + +export const family = { name: "mesh-wants.events", maxAgeDays: 7 } as const; + +export const policyChangedSubject = "mesh.seat.mesh-wants.event.policy-changed"; + +export const states = { + wants: "wants", + differences: "differences", + compositions: "compositions", +} as const; + +/** The key of one value of the state wants under the form "want..". */ +export function wantsByWantNodeModuleKey(node: string, module: string): string { + return "want." + node + "." + module; +} + +/** The parts of a key of the form "want..", or undefined for a key of another form. */ +export function parseWantsByWantNodeModuleKey(_key: string): { node: string; module: string } | undefined { + let _rest = _key; + if (!_rest.startsWith("want.")) return undefined; + _rest = _rest.slice(5); + const _at1 = _rest.indexOf("."); + if (_at1 <= 0) return undefined; + const node = _rest.slice(0, _at1); + _rest = _rest.slice(_at1 + 1); + if (_rest === "") return undefined; + const module = _rest; + _rest = ""; + if (_rest !== "") return undefined; + return { node, module }; +} + +/** The key of one value of the state wants under the form "want..node". */ +export function wantsByWantNodeNodeKey(node: string): string { + return "want." + node + ".node"; +} + +/** The parts of a key of the form "want..node", or undefined for a key of another form. */ +export function parseWantsByWantNodeNodeKey(_key: string): { node: string } | undefined { + let _rest = _key; + if (!_rest.startsWith("want.")) return undefined; + _rest = _rest.slice(5); + const _at1 = _rest.indexOf(".node"); + if (_at1 <= 0) return undefined; + const node = _rest.slice(0, _at1); + _rest = _rest.slice(_at1 + 5); + if (_rest !== "") return undefined; + return { node }; +} + +/** The key of one value of the state wants under the form "held..". */ +export function wantsByHeldModuleFingerprintKey(module: string, fingerprint: string): string { + return "held." + module + "." + fingerprint; +} + +/** The parts of a key of the form "held..", or undefined for a key of another form. */ +export function parseWantsByHeldModuleFingerprintKey(_key: string): { module: string; fingerprint: string } | undefined { + let _rest = _key; + if (!_rest.startsWith("held.")) return undefined; + _rest = _rest.slice(5); + const _at1 = _rest.indexOf("."); + if (_at1 <= 0) return undefined; + const module = _rest.slice(0, _at1); + _rest = _rest.slice(_at1 + 1); + if (_rest === "") return undefined; + const fingerprint = _rest; + _rest = ""; + if (_rest !== "") return undefined; + return { module, fingerprint }; +} + +/** The key of one value of the state differences under the form ".". */ +export function differencesByNodeModuleKey(node: string, module: string): string { + return node + "." + module; +} + +/** The parts of a key of the form ".", or undefined for a key of another form. */ +export function parseDifferencesByNodeModuleKey(_key: string): { node: string; module: string } | undefined { + let _rest = _key; + const _at0 = _rest.indexOf("."); + if (_at0 <= 0) return undefined; + const node = _rest.slice(0, _at0); + _rest = _rest.slice(_at0 + 1); + if (_rest === "") return undefined; + const module = _rest; + _rest = ""; + if (_rest !== "") return undefined; + return { node, module }; +} + +/** The key of one value of the state compositions under the form ".". */ +export function compositionsByGroupHeadsKey(group: string, heads: string): string { + return group + "." + heads; +} + +/** The parts of a key of the form ".", or undefined for a key of another form. */ +export function parseCompositionsByGroupHeadsKey(_key: string): { group: string; heads: string } | undefined { + let _rest = _key; + const _at0 = _rest.indexOf("."); + if (_at0 <= 0) return undefined; + const group = _rest.slice(0, _at0); + _rest = _rest.slice(_at0 + 1); + if (_rest === "") return undefined; + const heads = _rest; + _rest = ""; + if (_rest !== "") return undefined; + return { group, heads }; +} + +/** is what upgrade takes. */ +export interface UpgradeInput { + /** "true": each node's own composition, by its digest, and the assigner's findings */ + "compare"?: "true" | "false"; + /** one module */ + "module"?: string; + /** with module: roll, together, record, or default to take a person's choice back */ + "policy"?: string; + /** with policy: why, required and kept */ + "why"?: string; +} + +/** is what upgrade answers. */ +export type UpgradeAnswer = Record; + +/** is what bus takes. */ +export interface BusInput { +} + +/** is what bus answers. */ +export type BusAnswer = Record; + +/** is the body of the event policy-changed. */ +export type PolicyChangedEvent = Record; + +/** is one value of the state wants under the key want... */ +export interface WantsByWantNodeModuleValue { + /** again or skip */ + "backup"?: "again" | "skip"; + "composed"?: string; + "fp"?: string; + "hold"?: boolean; + "newest"?: string; + "put"?: boolean; + "release"?: string; + "removed"?: boolean; + /** the rule that holds the node back, 1 to 10 */ + "rule"?: number; + "self"?: boolean; + "v"?: number; + "words"?: string; + "yes"?: string; +} + +/** is one value of the state wants under the key want..node. */ +export interface WantsByWantNodeNodeValue { + "composed": string; + "self"?: boolean; + "v"?: number; + "words"?: string; +} + +/** is one value of the state wants under the key held... */ +export interface WantsByHeldModuleFingerprintValue { + "node": string; + "v"?: number; + "words"?: string; +} + +/** is one value of the state differences under the key .. */ +export interface DifferencesByNodeModuleValue { + "rule"?: number; + "sent"?: string; + "sent-build"?: string; + "unexplained"?: boolean; + "v"?: number; + "want"?: string; + "want-removed"?: boolean; + "words"?: string; +} + +/** is one value of the state compositions under the key .. */ +export interface CompositionsByGroupHeadsValue { + "group": string; + /** 12 lowercase hex digits */ + "heads": string; + "machine"?: string; + "machines"?: string[]; + "sdk"?: string; + /** pass, fail or waits */ + "state": string; + /** milliseconds */ + "took"?: number; + "v"?: number; + "warnings"?: string[]; + "words"?: string; +} + diff --git a/contracts/ts/seats/meshwants/tsconfig.json b/contracts/ts/seats/meshwants/tsconfig.json new file mode 100644 index 00000000..60274c6a --- /dev/null +++ b/contracts/ts/seats/meshwants/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "noEmit": true, + "strict": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src/**/*.ts"] +} diff --git a/contracts/ts/seats/nodebuildgo/package-lock.json b/contracts/ts/seats/nodebuildgo/package-lock.json new file mode 100644 index 00000000..bc0af20e --- /dev/null +++ b/contracts/ts/seats/nodebuildgo/package-lock.json @@ -0,0 +1,12 @@ +{ + "lockfileVersion": 3, + "name": "@novox/mesh-delivery-contracts-node-build-go", + "packages": { + "": { + "name": "@novox/mesh-delivery-contracts-node-build-go", + "version": "0.1.0" + } + }, + "requires": true, + "version": "0.1.0" +} diff --git a/contracts/ts/seats/nodebuildgo/package.json b/contracts/ts/seats/nodebuildgo/package.json new file mode 100644 index 00000000..074cc833 --- /dev/null +++ b/contracts/ts/seats/nodebuildgo/package.json @@ -0,0 +1,13 @@ +{ + "description": "The contract of the seat node-build-go, generated from its definition.", + "exports": { + ".": "./src/index.ts" + }, + "files": [ + "src" + ], + "name": "@novox/mesh-delivery-contracts-node-build-go", + "type": "module", + "types": "./src/index.ts", + "version": "0.1.0" +} diff --git a/contracts/ts/seats/nodebuildgo/src/index.ts b/contracts/ts/seats/nodebuildgo/src/index.ts new file mode 100644 index 00000000..0c6834dc --- /dev/null +++ b/contracts/ts/seats/nodebuildgo/src/index.ts @@ -0,0 +1,167 @@ +// Code generated by seatgen from the seat definitions; DO NOT EDIT. + +// The contract of the seat "node-build-go", defined whole (seats/node-build-go/seat.json): The Go build module on a node: it takes the asks of the builder and the checker, runs them as steps and answers. + +import type * as buildStep from "./parts/build-step.js"; + +export const seat = "node-build-go"; + +export const verbs = { + current: "current", + pause: "pause", + resume: "resume", +} as const; + +export const accepts = { + delivery: "delivery", + check: "check", + recheck: "recheck", +} as const; + +export const deliverySubject = "mesh.seat.node-build-go.accept.delivery"; + +export const checkSubject = "mesh.seat.node-build-go.accept.check"; + +export const recheckSubject = "mesh.seat.node-build-go.accept.recheck"; + +export const states = { + steps: "steps", + pauses: "pauses", + toolchains: "toolchains", +} as const; + +/** The key of one value of the state steps under the form ".". */ +export function stepsByNodeIDKey(node: string, id: string): string { + return node + "." + id; +} + +/** The parts of a key of the form ".", or undefined for a key of another form. */ +export function parseStepsByNodeIDKey(_key: string): { node: string; id: string } | undefined { + let _rest = _key; + const _at0 = _rest.indexOf("."); + if (_at0 <= 0) return undefined; + const node = _rest.slice(0, _at0); + _rest = _rest.slice(_at0 + 1); + if (_rest === "") return undefined; + const id = _rest; + _rest = ""; + if (_rest !== "") return undefined; + return { node, id }; +} + +/** The key of one value of the state pauses under the form "paused.". */ +export function pausesByPausedNodeKey(node: string): string { + return "paused." + node; +} + +/** The parts of a key of the form "paused.", or undefined for a key of another form. */ +export function parsePausesByPausedNodeKey(_key: string): { node: string } | undefined { + let _rest = _key; + if (!_rest.startsWith("paused.")) return undefined; + _rest = _rest.slice(7); + if (_rest === "") return undefined; + const node = _rest; + _rest = ""; + if (_rest !== "") return undefined; + return { node }; +} + +/** The key of one value of the state pauses under the form "capacity.". */ +export function pausesByCapacityNodeKey(node: string): string { + return "capacity." + node; +} + +/** The parts of a key of the form "capacity.", or undefined for a key of another form. */ +export function parsePausesByCapacityNodeKey(_key: string): { node: string } | undefined { + let _rest = _key; + if (!_rest.startsWith("capacity.")) return undefined; + _rest = _rest.slice(9); + if (_rest === "") return undefined; + const node = _rest; + _rest = ""; + if (_rest !== "") return undefined; + return { node }; +} + +/** The key of one value of the state toolchains under the form ".". */ +export function toolchainsByNodeLanguageKey(node: string, language: string): string { + return node + "." + language; +} + +/** The parts of a key of the form ".", or undefined for a key of another form. */ +export function parseToolchainsByNodeLanguageKey(_key: string): { node: string; language: string } | undefined { + let _rest = _key; + const _at0 = _rest.indexOf("."); + if (_at0 <= 0) return undefined; + const node = _rest.slice(0, _at0); + _rest = _rest.slice(_at0 + 1); + if (_rest === "") return undefined; + const language = _rest; + _rest = ""; + if (_rest !== "") return undefined; + return { node, language }; +} + +export type AskArtifact = buildStep.AskArtifact; +export type BuildAnswer = buildStep.BuildAnswer; +export type BuiltArtifact = buildStep.BuiltArtifact; +export type Capacity = buildStep.Capacity; +export type CheckAnswer = buildStep.CheckAnswer; +export type ComposeAnswer = buildStep.ComposeAnswer; +export type CurrentAnswer = buildStep.CurrentAnswer; +export type CurrentInput = buildStep.CurrentInput; +export type Fetch = buildStep.Fetch; +export type FetchAnswer = buildStep.FetchAnswer; +export type FetchBesideItem = buildStep.FetchBesideItem; +export type FetchPackage = buildStep.FetchPackage; +export type FetchRefusal = buildStep.FetchRefusal; +export type LevelAnswer = buildStep.LevelAnswer; +export type LevelAnswerDigest = buildStep.LevelAnswerDigest; +export type LevelAnswerDigestNeedsItem = buildStep.LevelAnswerDigestNeedsItem; +export type LevelAnswerDigestPackagesItem = buildStep.LevelAnswerDigestPackagesItem; +export type LevelAnswerKeptItem = buildStep.LevelAnswerKeptItem; +export type LevelAnswerUnmetItem = buildStep.LevelAnswerUnmetItem; +export type ModuleTree = buildStep.ModuleTree; +export type Pause = buildStep.Pause; +export type PauseAnswer = buildStep.PauseAnswer; +export type PauseInput = buildStep.PauseInput; +export type PlacedBeside = buildStep.PlacedBeside; +export type Program = buildStep.Program; +export type ResumeAnswer = buildStep.ResumeAnswer; +export type ResumeInput = buildStep.ResumeInput; +export type RunAnswer = buildStep.RunAnswer; +export type RunHead = buildStep.RunHead; +export type RunInput = buildStep.RunInput; +export type RunJob = buildStep.RunJob; +export type RunProgram = buildStep.RunProgram; +export type Step = buildStep.Step; +export type StepAsk = buildStep.StepAsk; +export type StepAskBuild = buildStep.StepAskBuild; +export type StepAskCheck = buildStep.StepAskCheck; +export type StepAskCompose = buildStep.StepAskCompose; +export type StepResult = buildStep.StepResult; +export type TakenVersion = buildStep.TakenVersion; +export type Toolchain = buildStep.Toolchain; +export type ToolchainUsed = buildStep.ToolchainUsed; + +/** is the body of delivery. */ +export type DeliveryAccept = StepAsk; + +/** is the body of check. */ +export type CheckAccept = StepAsk; + +/** is the body of recheck. */ +export type RecheckAccept = StepAsk; + +/** is one value of the state steps under the key .. */ +export type StepsByNodeIDValue = Step; + +/** is one value of the state pauses under the key paused.. */ +export type PausesByPausedNodeValue = Pause; + +/** is one value of the state pauses under the key capacity.. */ +export type PausesByCapacityNodeValue = Capacity; + +/** is one value of the state toolchains under the key .. */ +export type ToolchainsByNodeLanguageValue = Toolchain; + diff --git a/contracts/ts/seats/nodebuildgo/src/parts/build-step.ts b/contracts/ts/seats/nodebuildgo/src/parts/build-step.ts new file mode 100644 index 00000000..c8843899 --- /dev/null +++ b/contracts/ts/seats/nodebuildgo/src/parts/build-step.ts @@ -0,0 +1,398 @@ +// Code generated by seatgen from the seat definitions; DO NOT EDIT. + +// The part "build-step" several seats include whole: What the three build seats share: the verbs that read and steer a node's builds, and the layouts of the asks they accept and the steps they keep. A seat lists its states against these (a part carries no states). + +export const verbs = { + current: "current", + pause: "pause", + resume: "resume", +} as const; + +/** one artifact of a build ask: built from the fetched tree, or copied from an upstream pin */ +export interface AskArtifact { + "artifact": string; + "from"?: string; + "seat"?: string; + "version"?: string; +} + +export interface BuildAnswer { + "artifacts": BuiltArtifact[]; + "imports"?: string[]; + "out-of-step"?: string[]; + "toolchain"?: ToolchainUsed; +} + +/** one artifact a build made: its digest and the reference it was published under */ +export interface BuiltArtifact { + "artifact": string; + "digest": string; + "reference"?: string; +} + +/** how many steps the node runs at once */ +export interface Capacity { + /** when (RFC 3339 time) */ + "at"?: string; + "build-hours"?: string; + "build-hours-fault"?: string; + "by-cpu"?: number; + "configured": number; + "configured-from"?: string; + "cores-per-step"?: number; + "effective": number; + "ineligible"?: string; + "outside-hours"?: boolean; + "quota-cores"?: number; + "quota-from"?: string; + /** the layout version */ + "v"?: number; + "why": string; +} + +export interface CheckAnswer { + "fetch": FetchAnswer; + "levels"?: LevelAnswer[]; +} + +export interface ComposeAnswer { + "built": boolean; + "key"?: string; + /** the compose-candidate verdict */ + "verdict"?: unknown; +} + +export interface FetchBesideItem { + "commit": string; + "repository": string; +} + +/** the TypeScript module's group package: the group's tagged version of the SDK ({name, version}), placed from the registry in each member whatever its lock names, or the group's package member ({repository, commit, ref}), packed by the fetch and served to the members in place of the registry's */ +export interface FetchPackage { + "commit"?: string; + "name"?: string; + "ref"?: string; + "repository"?: string; + "version"?: string; +} + +/** what a build module's fetch tool is asked: the repository and the full commit, the pull request's ref when only its head carries the commit, the trunk commit to merge onto for a merge preview, the module's directory and named paths, the mesh packages to build against, and the sibling repositories placed beside */ +export interface Fetch { + /** the trunk commit a merge preview merges onto */ + "base"?: string; + /** the sibling repositories placed beside the fetched tree */ + "beside"?: FetchBesideItem[]; + /** the full commit */ + "commit": string; + /** the module's directory in the repository ("." for its root), with paths */ + "dir"?: string; + /** the TypeScript module's group package: the group's tagged version of the SDK ({name, version}), placed from the registry in each member whatever its lock names, or the group's package member ({repository, commit, ref}), packed by the fetch and served to the members in place of the registry's */ + "package"?: FetchPackage; + /** the version (a full commit) of each mesh package to build against, by its module path */ + "packages"?: Record; + /** the paths the module names, relative to dir */ + "paths"?: string[]; + /** the pull request's ref, when only its head carries the commit */ + "ref"?: string; + /** / */ + "repository": string; +} + +/** what a build module's fetch tool answers, whole: the workspace it made, the commit in it, the module directories found; and, when the call gave a base, the merge preview (the trunk commit, the merge commit, the files that conflict, the paths the merge changes, the compiled programs among them, the tree of each module directory). A head that does not merge cleanly is an answer, not an error: merge is empty and conflicts names the files. The Go module adds the sibling repositories placed and the package versions taken; the TypeScript module the directories installed and refused and the group's package */ +export interface FetchAnswer { + "base"?: string; + "beside"?: PlacedBeside[]; + "changed"?: string[]; + "commit": string; + "conflicts"?: string[]; + "group"?: string; + "installed"?: string[]; + "merge"?: string; + "modules": string[]; + "packages"?: TakenVersion[]; + "programs"?: Program[]; + "refused"?: FetchRefusal[]; + "trees"?: Record; + "workspace": string; +} + +/** a TypeScript module whose packages the fetch did not install, with why */ +export interface FetchRefusal { + "module": string; + "reason": string; +} + +export interface LevelAnswerDigestNeedsItem { + "needs"?: string[]; + "package"?: string; +} + +export interface LevelAnswerDigestPackagesItem { + "digest": string; + "package": string; +} + +export interface LevelAnswerDigest { + "beside"?: string[]; + "judged_by"?: string; + "level"?: string; + "needs"?: LevelAnswerDigestNeedsItem[]; + "packages"?: LevelAnswerDigestPackagesItem[]; + "without_tests"?: string[]; +} + +export interface LevelAnswerKeptItem { + "digest"?: string; + "package"?: string; + "tests"?: number; +} + +export interface LevelAnswerUnmetItem { + "needs"?: string[]; + "package"?: string; +} + +/** one level of a check ask: the digest's answer, the kept verdicts taken, one test result per test step run, and the packages the node could not test */ +export interface LevelAnswer { + "digest"?: LevelAnswerDigest; + "judged"?: string; + "kept"?: LevelAnswerKeptItem[]; + "level": string; + "tests"?: unknown[]; + "unmet"?: LevelAnswerUnmetItem[]; +} + +/** the tree of one module directory in a merge preview: its git tree, the digest of its manifest, the lock files' digests and the digests of the named paths */ +export interface ModuleTree { + "locks"?: Record; + "manifest": string; + /** no longer filled (ADR 0593 section 2); kept so a preview written before still reads */ + "packages"?: string[]; + "paths"?: Record; + "tree": string; +} + +/** whether the node takes no new ask */ +export interface Pause { + /** when (RFC 3339 time) */ + "at"?: string; + "by"?: string; + "paused": boolean; + /** the layout version */ + "v"?: number; +} + +/** a sibling repository placed as a plain directory under the workspace's beside directory */ +export interface PlacedBeside { + "commit": string; + "dir": string; + "repository": string; +} + +/** a changed file that is a compiled program (issue 1577): its path, its executable format, and the commit of the branch that added it when the merge's tree no longer holds it */ +export interface Program { + "commit"?: string; + /** ELF, Mach-O or PE */ + "kind": string; + "path": string; +} + +/** what a run ask's step answers: whether this run built the program or took the one kept under its key, the key, the program's exit, the last line of its standard output and the first of its standard error */ +export interface RunAnswer { + "built": boolean; + "exit": number; + "key": string; + /** raw when it is JSON, else a JSON string */ + "output"?: unknown; + "said"?: string; +} + +/** one commit a run's program reads */ +export interface RunHead { + "commit": string; + "repository": string; +} + +/** a run's input by reference: the seat verb the asker serves it on (seat:.), the sha256 of its bytes and its size, at most 256 KiB */ +export interface RunInput { + /** 64 lowercase hex digits */ + "digest": string; + /** bytes */ + "size": number; + "verb": string; +} + +/** a run ask's job: where its program is built, the heads it reads (each laid read-only at its repository's path), its input by reference, its arguments and what it is for */ +export interface RunJob { + /** the program's arguments, each a word or a flag */ + "args"?: string[]; + /** the commits the program reads, a repository at most once */ + "heads": RunHead[]; + "input": RunInput; + "program": RunProgram; + /** what the run is for, for a reader of the ask */ + "reader"?: string; +} + +/** where a run's program is built: the repository and full commit fetched, the Go module's directory in it and the program's import path */ +export interface RunProgram { + "commit": string; + "dir": string; + "package": string; + "repository": string; +} + +/** one step a build module runs on a node */ +export interface Step { + /** when (RFC 3339 time) */ + "ended"?: string; + "id": string; + "node": string; + "owner"?: string; + "phase"?: string; + /** when (RFC 3339 time) */ + "phase-at"?: string; + "reason"?: string; + "result"?: StepResult; + /** when (RFC 3339 time) */ + "started": string; + /** running, passed, failed or refused */ + "state": string; + "tool": string; + /** the layout version */ + "v"?: number; + "what"?: string; +} + +export interface StepAskBuild { + "artifacts": AskArtifact[]; + "fetch"?: Fetch; + "fp"?: string; + "module": string; + "path"?: string; + "publish"?: boolean; + "toolchain"?: ToolchainUsed; +} + +export interface StepAskCheck { + "fetch": Fetch; + "kept"?: boolean; + "levels": string[]; + /** the node names the check's tests run against */ + "node-names"?: string[]; + "packages"?: string[]; + "run"?: string; + "runner"?: string; + "runner_when"?: string; + /** the toolchain the checker agreed for the language (ADR 0584 clause 2): a node on another declines the check */ + "toolchain"?: ToolchainUsed; +} + +export interface StepAskCompose { + "dir": string; + "fetch": Fetch; + "package": string; + "reader"?: string; + /** the compose-candidate request */ + "request": unknown; +} + +/** one ask of a build module */ +export interface StepAsk { + "asker": string; + /** when (RFC 3339 time) */ + "at"?: string; + "build"?: StepAskBuild; + "check"?: StepAskCheck; + "compose"?: StepAskCompose; + "id": string; + /** build, check or compose */ + "kind": string; + /** go, typescript or image */ + "language": string; + "needs"?: string[]; + /** package to the tagged version a build pins */ + "packages"?: Record; + /** a run: a program built from a change and run in the build seat's sandbox over the heads, its input asked of a verb of the asker's seat; the Go seat's check stream alone carries it, as a compose */ + "run"?: RunJob; + /** the layout version */ + "v"?: number; +} + +/** what a step answered, by the kind of ask: a build's artifacts, toolchain, imports and out-of-step modules; a check's fetch and levels; a compose's built, key and verdict; a run's built, key, exit, output and said; a declined ask's declined, needs and lacks. The fields of the kinds do not meet, so one layout holds the five */ +export interface StepResult { + "artifacts"?: BuiltArtifact[]; + "built"?: boolean; + /** the holder declined the ask: its node lacks what the ask needs */ + "declined"?: boolean; + /** a run's program's exit status */ + "exit"?: number; + "fetch"?: FetchAnswer; + "imports"?: string[]; + "key"?: string; + /** what the holder's node lacks of it */ + "lacks"?: string[]; + "levels"?: LevelAnswer[]; + /** what the declined ask needs */ + "needs"?: string[]; + "out-of-step"?: string[]; + /** a run's program's last line of standard output: raw when it is JSON, else a JSON string */ + "output"?: unknown; + /** a run's program's first line of standard error, or why the step could not run it */ + "said"?: string; + "toolchain"?: ToolchainUsed; + /** the compose-candidate verdict (the composer's own layout) */ + "verdict"?: unknown; +} + +/** of a directory whose go.mod requires an asked mesh package, the version it is built against: the asked one, or go.mod's when it names a newer one, said */ +export interface TakenVersion { + "asked": string; + "built": string; + "dir": string; + "named": string; + "package": string; + "said"?: string; + "sum"?: string; +} + +/** the toolchain a node reports */ +export interface Toolchain { + /** when (RFC 3339 time) */ + "at"?: string; + /** go or typescript */ + "language": string; + "recipe": string; + /** the layout version */ + "v"?: number; + "version": string; +} + +/** the toolchain a build was made with: the language, the installed version and the build module's recipe */ +export interface ToolchainUsed { + "language": string; + "recipe": string; + "version": string; +} + +/** is what current takes. */ +export interface CurrentInput { +} + +/** is what current answers. */ +export type CurrentAnswer = Record; + +/** is what pause takes. */ +export interface PauseInput { +} + +/** is what pause answers. */ +export type PauseAnswer = Record; + +/** is what resume takes. */ +export interface ResumeInput { +} + +/** is what resume answers. */ +export type ResumeAnswer = Record; + diff --git a/contracts/ts/seats/nodebuildgo/tsconfig.json b/contracts/ts/seats/nodebuildgo/tsconfig.json new file mode 100644 index 00000000..60274c6a --- /dev/null +++ b/contracts/ts/seats/nodebuildgo/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "noEmit": true, + "strict": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src/**/*.ts"] +} diff --git a/contracts/ts/seats/nodebuildimage/package-lock.json b/contracts/ts/seats/nodebuildimage/package-lock.json new file mode 100644 index 00000000..ca80ceb0 --- /dev/null +++ b/contracts/ts/seats/nodebuildimage/package-lock.json @@ -0,0 +1,12 @@ +{ + "lockfileVersion": 3, + "name": "@novox/mesh-delivery-contracts-node-build-image", + "packages": { + "": { + "name": "@novox/mesh-delivery-contracts-node-build-image", + "version": "0.1.0" + } + }, + "requires": true, + "version": "0.1.0" +} diff --git a/contracts/ts/seats/nodebuildimage/package.json b/contracts/ts/seats/nodebuildimage/package.json new file mode 100644 index 00000000..8e2ca24e --- /dev/null +++ b/contracts/ts/seats/nodebuildimage/package.json @@ -0,0 +1,13 @@ +{ + "description": "The contract of the seat node-build-image, generated from its definition.", + "exports": { + ".": "./src/index.ts" + }, + "files": [ + "src" + ], + "name": "@novox/mesh-delivery-contracts-node-build-image", + "type": "module", + "types": "./src/index.ts", + "version": "0.1.0" +} diff --git a/contracts/ts/seats/nodebuildimage/src/index.ts b/contracts/ts/seats/nodebuildimage/src/index.ts new file mode 100644 index 00000000..91e5d235 --- /dev/null +++ b/contracts/ts/seats/nodebuildimage/src/index.ts @@ -0,0 +1,167 @@ +// Code generated by seatgen from the seat definitions; DO NOT EDIT. + +// The contract of the seat "node-build-image", defined whole (seats/node-build-image/seat.json): The image build module on a node: it takes the asks of the builder and the checker, runs them as steps and answers. + +import type * as buildStep from "./parts/build-step.js"; + +export const seat = "node-build-image"; + +export const verbs = { + current: "current", + pause: "pause", + resume: "resume", +} as const; + +export const accepts = { + delivery: "delivery", + check: "check", + recheck: "recheck", +} as const; + +export const deliverySubject = "mesh.seat.node-build-image.accept.delivery"; + +export const checkSubject = "mesh.seat.node-build-image.accept.check"; + +export const recheckSubject = "mesh.seat.node-build-image.accept.recheck"; + +export const states = { + steps: "steps", + pauses: "pauses", + toolchains: "toolchains", +} as const; + +/** The key of one value of the state steps under the form ".". */ +export function stepsByNodeIDKey(node: string, id: string): string { + return node + "." + id; +} + +/** The parts of a key of the form ".", or undefined for a key of another form. */ +export function parseStepsByNodeIDKey(_key: string): { node: string; id: string } | undefined { + let _rest = _key; + const _at0 = _rest.indexOf("."); + if (_at0 <= 0) return undefined; + const node = _rest.slice(0, _at0); + _rest = _rest.slice(_at0 + 1); + if (_rest === "") return undefined; + const id = _rest; + _rest = ""; + if (_rest !== "") return undefined; + return { node, id }; +} + +/** The key of one value of the state pauses under the form "paused.". */ +export function pausesByPausedNodeKey(node: string): string { + return "paused." + node; +} + +/** The parts of a key of the form "paused.", or undefined for a key of another form. */ +export function parsePausesByPausedNodeKey(_key: string): { node: string } | undefined { + let _rest = _key; + if (!_rest.startsWith("paused.")) return undefined; + _rest = _rest.slice(7); + if (_rest === "") return undefined; + const node = _rest; + _rest = ""; + if (_rest !== "") return undefined; + return { node }; +} + +/** The key of one value of the state pauses under the form "capacity.". */ +export function pausesByCapacityNodeKey(node: string): string { + return "capacity." + node; +} + +/** The parts of a key of the form "capacity.", or undefined for a key of another form. */ +export function parsePausesByCapacityNodeKey(_key: string): { node: string } | undefined { + let _rest = _key; + if (!_rest.startsWith("capacity.")) return undefined; + _rest = _rest.slice(9); + if (_rest === "") return undefined; + const node = _rest; + _rest = ""; + if (_rest !== "") return undefined; + return { node }; +} + +/** The key of one value of the state toolchains under the form ".". */ +export function toolchainsByNodeLanguageKey(node: string, language: string): string { + return node + "." + language; +} + +/** The parts of a key of the form ".", or undefined for a key of another form. */ +export function parseToolchainsByNodeLanguageKey(_key: string): { node: string; language: string } | undefined { + let _rest = _key; + const _at0 = _rest.indexOf("."); + if (_at0 <= 0) return undefined; + const node = _rest.slice(0, _at0); + _rest = _rest.slice(_at0 + 1); + if (_rest === "") return undefined; + const language = _rest; + _rest = ""; + if (_rest !== "") return undefined; + return { node, language }; +} + +export type AskArtifact = buildStep.AskArtifact; +export type BuildAnswer = buildStep.BuildAnswer; +export type BuiltArtifact = buildStep.BuiltArtifact; +export type Capacity = buildStep.Capacity; +export type CheckAnswer = buildStep.CheckAnswer; +export type ComposeAnswer = buildStep.ComposeAnswer; +export type CurrentAnswer = buildStep.CurrentAnswer; +export type CurrentInput = buildStep.CurrentInput; +export type Fetch = buildStep.Fetch; +export type FetchAnswer = buildStep.FetchAnswer; +export type FetchBesideItem = buildStep.FetchBesideItem; +export type FetchPackage = buildStep.FetchPackage; +export type FetchRefusal = buildStep.FetchRefusal; +export type LevelAnswer = buildStep.LevelAnswer; +export type LevelAnswerDigest = buildStep.LevelAnswerDigest; +export type LevelAnswerDigestNeedsItem = buildStep.LevelAnswerDigestNeedsItem; +export type LevelAnswerDigestPackagesItem = buildStep.LevelAnswerDigestPackagesItem; +export type LevelAnswerKeptItem = buildStep.LevelAnswerKeptItem; +export type LevelAnswerUnmetItem = buildStep.LevelAnswerUnmetItem; +export type ModuleTree = buildStep.ModuleTree; +export type Pause = buildStep.Pause; +export type PauseAnswer = buildStep.PauseAnswer; +export type PauseInput = buildStep.PauseInput; +export type PlacedBeside = buildStep.PlacedBeside; +export type Program = buildStep.Program; +export type ResumeAnswer = buildStep.ResumeAnswer; +export type ResumeInput = buildStep.ResumeInput; +export type RunAnswer = buildStep.RunAnswer; +export type RunHead = buildStep.RunHead; +export type RunInput = buildStep.RunInput; +export type RunJob = buildStep.RunJob; +export type RunProgram = buildStep.RunProgram; +export type Step = buildStep.Step; +export type StepAsk = buildStep.StepAsk; +export type StepAskBuild = buildStep.StepAskBuild; +export type StepAskCheck = buildStep.StepAskCheck; +export type StepAskCompose = buildStep.StepAskCompose; +export type StepResult = buildStep.StepResult; +export type TakenVersion = buildStep.TakenVersion; +export type Toolchain = buildStep.Toolchain; +export type ToolchainUsed = buildStep.ToolchainUsed; + +/** is the body of delivery. */ +export type DeliveryAccept = StepAsk; + +/** is the body of check. */ +export type CheckAccept = StepAsk; + +/** is the body of recheck. */ +export type RecheckAccept = StepAsk; + +/** is one value of the state steps under the key .. */ +export type StepsByNodeIDValue = Step; + +/** is one value of the state pauses under the key paused.. */ +export type PausesByPausedNodeValue = Pause; + +/** is one value of the state pauses under the key capacity.. */ +export type PausesByCapacityNodeValue = Capacity; + +/** is one value of the state toolchains under the key .. */ +export type ToolchainsByNodeLanguageValue = Toolchain; + diff --git a/contracts/ts/seats/nodebuildimage/src/parts/build-step.ts b/contracts/ts/seats/nodebuildimage/src/parts/build-step.ts new file mode 100644 index 00000000..c8843899 --- /dev/null +++ b/contracts/ts/seats/nodebuildimage/src/parts/build-step.ts @@ -0,0 +1,398 @@ +// Code generated by seatgen from the seat definitions; DO NOT EDIT. + +// The part "build-step" several seats include whole: What the three build seats share: the verbs that read and steer a node's builds, and the layouts of the asks they accept and the steps they keep. A seat lists its states against these (a part carries no states). + +export const verbs = { + current: "current", + pause: "pause", + resume: "resume", +} as const; + +/** one artifact of a build ask: built from the fetched tree, or copied from an upstream pin */ +export interface AskArtifact { + "artifact": string; + "from"?: string; + "seat"?: string; + "version"?: string; +} + +export interface BuildAnswer { + "artifacts": BuiltArtifact[]; + "imports"?: string[]; + "out-of-step"?: string[]; + "toolchain"?: ToolchainUsed; +} + +/** one artifact a build made: its digest and the reference it was published under */ +export interface BuiltArtifact { + "artifact": string; + "digest": string; + "reference"?: string; +} + +/** how many steps the node runs at once */ +export interface Capacity { + /** when (RFC 3339 time) */ + "at"?: string; + "build-hours"?: string; + "build-hours-fault"?: string; + "by-cpu"?: number; + "configured": number; + "configured-from"?: string; + "cores-per-step"?: number; + "effective": number; + "ineligible"?: string; + "outside-hours"?: boolean; + "quota-cores"?: number; + "quota-from"?: string; + /** the layout version */ + "v"?: number; + "why": string; +} + +export interface CheckAnswer { + "fetch": FetchAnswer; + "levels"?: LevelAnswer[]; +} + +export interface ComposeAnswer { + "built": boolean; + "key"?: string; + /** the compose-candidate verdict */ + "verdict"?: unknown; +} + +export interface FetchBesideItem { + "commit": string; + "repository": string; +} + +/** the TypeScript module's group package: the group's tagged version of the SDK ({name, version}), placed from the registry in each member whatever its lock names, or the group's package member ({repository, commit, ref}), packed by the fetch and served to the members in place of the registry's */ +export interface FetchPackage { + "commit"?: string; + "name"?: string; + "ref"?: string; + "repository"?: string; + "version"?: string; +} + +/** what a build module's fetch tool is asked: the repository and the full commit, the pull request's ref when only its head carries the commit, the trunk commit to merge onto for a merge preview, the module's directory and named paths, the mesh packages to build against, and the sibling repositories placed beside */ +export interface Fetch { + /** the trunk commit a merge preview merges onto */ + "base"?: string; + /** the sibling repositories placed beside the fetched tree */ + "beside"?: FetchBesideItem[]; + /** the full commit */ + "commit": string; + /** the module's directory in the repository ("." for its root), with paths */ + "dir"?: string; + /** the TypeScript module's group package: the group's tagged version of the SDK ({name, version}), placed from the registry in each member whatever its lock names, or the group's package member ({repository, commit, ref}), packed by the fetch and served to the members in place of the registry's */ + "package"?: FetchPackage; + /** the version (a full commit) of each mesh package to build against, by its module path */ + "packages"?: Record; + /** the paths the module names, relative to dir */ + "paths"?: string[]; + /** the pull request's ref, when only its head carries the commit */ + "ref"?: string; + /** / */ + "repository": string; +} + +/** what a build module's fetch tool answers, whole: the workspace it made, the commit in it, the module directories found; and, when the call gave a base, the merge preview (the trunk commit, the merge commit, the files that conflict, the paths the merge changes, the compiled programs among them, the tree of each module directory). A head that does not merge cleanly is an answer, not an error: merge is empty and conflicts names the files. The Go module adds the sibling repositories placed and the package versions taken; the TypeScript module the directories installed and refused and the group's package */ +export interface FetchAnswer { + "base"?: string; + "beside"?: PlacedBeside[]; + "changed"?: string[]; + "commit": string; + "conflicts"?: string[]; + "group"?: string; + "installed"?: string[]; + "merge"?: string; + "modules": string[]; + "packages"?: TakenVersion[]; + "programs"?: Program[]; + "refused"?: FetchRefusal[]; + "trees"?: Record; + "workspace": string; +} + +/** a TypeScript module whose packages the fetch did not install, with why */ +export interface FetchRefusal { + "module": string; + "reason": string; +} + +export interface LevelAnswerDigestNeedsItem { + "needs"?: string[]; + "package"?: string; +} + +export interface LevelAnswerDigestPackagesItem { + "digest": string; + "package": string; +} + +export interface LevelAnswerDigest { + "beside"?: string[]; + "judged_by"?: string; + "level"?: string; + "needs"?: LevelAnswerDigestNeedsItem[]; + "packages"?: LevelAnswerDigestPackagesItem[]; + "without_tests"?: string[]; +} + +export interface LevelAnswerKeptItem { + "digest"?: string; + "package"?: string; + "tests"?: number; +} + +export interface LevelAnswerUnmetItem { + "needs"?: string[]; + "package"?: string; +} + +/** one level of a check ask: the digest's answer, the kept verdicts taken, one test result per test step run, and the packages the node could not test */ +export interface LevelAnswer { + "digest"?: LevelAnswerDigest; + "judged"?: string; + "kept"?: LevelAnswerKeptItem[]; + "level": string; + "tests"?: unknown[]; + "unmet"?: LevelAnswerUnmetItem[]; +} + +/** the tree of one module directory in a merge preview: its git tree, the digest of its manifest, the lock files' digests and the digests of the named paths */ +export interface ModuleTree { + "locks"?: Record; + "manifest": string; + /** no longer filled (ADR 0593 section 2); kept so a preview written before still reads */ + "packages"?: string[]; + "paths"?: Record; + "tree": string; +} + +/** whether the node takes no new ask */ +export interface Pause { + /** when (RFC 3339 time) */ + "at"?: string; + "by"?: string; + "paused": boolean; + /** the layout version */ + "v"?: number; +} + +/** a sibling repository placed as a plain directory under the workspace's beside directory */ +export interface PlacedBeside { + "commit": string; + "dir": string; + "repository": string; +} + +/** a changed file that is a compiled program (issue 1577): its path, its executable format, and the commit of the branch that added it when the merge's tree no longer holds it */ +export interface Program { + "commit"?: string; + /** ELF, Mach-O or PE */ + "kind": string; + "path": string; +} + +/** what a run ask's step answers: whether this run built the program or took the one kept under its key, the key, the program's exit, the last line of its standard output and the first of its standard error */ +export interface RunAnswer { + "built": boolean; + "exit": number; + "key": string; + /** raw when it is JSON, else a JSON string */ + "output"?: unknown; + "said"?: string; +} + +/** one commit a run's program reads */ +export interface RunHead { + "commit": string; + "repository": string; +} + +/** a run's input by reference: the seat verb the asker serves it on (seat:.), the sha256 of its bytes and its size, at most 256 KiB */ +export interface RunInput { + /** 64 lowercase hex digits */ + "digest": string; + /** bytes */ + "size": number; + "verb": string; +} + +/** a run ask's job: where its program is built, the heads it reads (each laid read-only at its repository's path), its input by reference, its arguments and what it is for */ +export interface RunJob { + /** the program's arguments, each a word or a flag */ + "args"?: string[]; + /** the commits the program reads, a repository at most once */ + "heads": RunHead[]; + "input": RunInput; + "program": RunProgram; + /** what the run is for, for a reader of the ask */ + "reader"?: string; +} + +/** where a run's program is built: the repository and full commit fetched, the Go module's directory in it and the program's import path */ +export interface RunProgram { + "commit": string; + "dir": string; + "package": string; + "repository": string; +} + +/** one step a build module runs on a node */ +export interface Step { + /** when (RFC 3339 time) */ + "ended"?: string; + "id": string; + "node": string; + "owner"?: string; + "phase"?: string; + /** when (RFC 3339 time) */ + "phase-at"?: string; + "reason"?: string; + "result"?: StepResult; + /** when (RFC 3339 time) */ + "started": string; + /** running, passed, failed or refused */ + "state": string; + "tool": string; + /** the layout version */ + "v"?: number; + "what"?: string; +} + +export interface StepAskBuild { + "artifacts": AskArtifact[]; + "fetch"?: Fetch; + "fp"?: string; + "module": string; + "path"?: string; + "publish"?: boolean; + "toolchain"?: ToolchainUsed; +} + +export interface StepAskCheck { + "fetch": Fetch; + "kept"?: boolean; + "levels": string[]; + /** the node names the check's tests run against */ + "node-names"?: string[]; + "packages"?: string[]; + "run"?: string; + "runner"?: string; + "runner_when"?: string; + /** the toolchain the checker agreed for the language (ADR 0584 clause 2): a node on another declines the check */ + "toolchain"?: ToolchainUsed; +} + +export interface StepAskCompose { + "dir": string; + "fetch": Fetch; + "package": string; + "reader"?: string; + /** the compose-candidate request */ + "request": unknown; +} + +/** one ask of a build module */ +export interface StepAsk { + "asker": string; + /** when (RFC 3339 time) */ + "at"?: string; + "build"?: StepAskBuild; + "check"?: StepAskCheck; + "compose"?: StepAskCompose; + "id": string; + /** build, check or compose */ + "kind": string; + /** go, typescript or image */ + "language": string; + "needs"?: string[]; + /** package to the tagged version a build pins */ + "packages"?: Record; + /** a run: a program built from a change and run in the build seat's sandbox over the heads, its input asked of a verb of the asker's seat; the Go seat's check stream alone carries it, as a compose */ + "run"?: RunJob; + /** the layout version */ + "v"?: number; +} + +/** what a step answered, by the kind of ask: a build's artifacts, toolchain, imports and out-of-step modules; a check's fetch and levels; a compose's built, key and verdict; a run's built, key, exit, output and said; a declined ask's declined, needs and lacks. The fields of the kinds do not meet, so one layout holds the five */ +export interface StepResult { + "artifacts"?: BuiltArtifact[]; + "built"?: boolean; + /** the holder declined the ask: its node lacks what the ask needs */ + "declined"?: boolean; + /** a run's program's exit status */ + "exit"?: number; + "fetch"?: FetchAnswer; + "imports"?: string[]; + "key"?: string; + /** what the holder's node lacks of it */ + "lacks"?: string[]; + "levels"?: LevelAnswer[]; + /** what the declined ask needs */ + "needs"?: string[]; + "out-of-step"?: string[]; + /** a run's program's last line of standard output: raw when it is JSON, else a JSON string */ + "output"?: unknown; + /** a run's program's first line of standard error, or why the step could not run it */ + "said"?: string; + "toolchain"?: ToolchainUsed; + /** the compose-candidate verdict (the composer's own layout) */ + "verdict"?: unknown; +} + +/** of a directory whose go.mod requires an asked mesh package, the version it is built against: the asked one, or go.mod's when it names a newer one, said */ +export interface TakenVersion { + "asked": string; + "built": string; + "dir": string; + "named": string; + "package": string; + "said"?: string; + "sum"?: string; +} + +/** the toolchain a node reports */ +export interface Toolchain { + /** when (RFC 3339 time) */ + "at"?: string; + /** go or typescript */ + "language": string; + "recipe": string; + /** the layout version */ + "v"?: number; + "version": string; +} + +/** the toolchain a build was made with: the language, the installed version and the build module's recipe */ +export interface ToolchainUsed { + "language": string; + "recipe": string; + "version": string; +} + +/** is what current takes. */ +export interface CurrentInput { +} + +/** is what current answers. */ +export type CurrentAnswer = Record; + +/** is what pause takes. */ +export interface PauseInput { +} + +/** is what pause answers. */ +export type PauseAnswer = Record; + +/** is what resume takes. */ +export interface ResumeInput { +} + +/** is what resume answers. */ +export type ResumeAnswer = Record; + diff --git a/contracts/ts/seats/nodebuildimage/tsconfig.json b/contracts/ts/seats/nodebuildimage/tsconfig.json new file mode 100644 index 00000000..60274c6a --- /dev/null +++ b/contracts/ts/seats/nodebuildimage/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "noEmit": true, + "strict": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src/**/*.ts"] +} diff --git a/contracts/ts/seats/nodebuildnode/package-lock.json b/contracts/ts/seats/nodebuildnode/package-lock.json new file mode 100644 index 00000000..4622abb2 --- /dev/null +++ b/contracts/ts/seats/nodebuildnode/package-lock.json @@ -0,0 +1,12 @@ +{ + "lockfileVersion": 3, + "name": "@novox/mesh-delivery-contracts-node-build-node", + "packages": { + "": { + "name": "@novox/mesh-delivery-contracts-node-build-node", + "version": "0.1.0" + } + }, + "requires": true, + "version": "0.1.0" +} diff --git a/contracts/ts/seats/nodebuildnode/package.json b/contracts/ts/seats/nodebuildnode/package.json new file mode 100644 index 00000000..de82c7d0 --- /dev/null +++ b/contracts/ts/seats/nodebuildnode/package.json @@ -0,0 +1,13 @@ +{ + "description": "The contract of the seat node-build-node, generated from its definition.", + "exports": { + ".": "./src/index.ts" + }, + "files": [ + "src" + ], + "name": "@novox/mesh-delivery-contracts-node-build-node", + "type": "module", + "types": "./src/index.ts", + "version": "0.1.0" +} diff --git a/contracts/ts/seats/nodebuildnode/src/index.ts b/contracts/ts/seats/nodebuildnode/src/index.ts new file mode 100644 index 00000000..faba1e6a --- /dev/null +++ b/contracts/ts/seats/nodebuildnode/src/index.ts @@ -0,0 +1,167 @@ +// Code generated by seatgen from the seat definitions; DO NOT EDIT. + +// The contract of the seat "node-build-node", defined whole (seats/node-build-node/seat.json): The Node build module on a node: it takes the asks of the builder and the checker, runs them as steps and answers. + +import type * as buildStep from "./parts/build-step.js"; + +export const seat = "node-build-node"; + +export const verbs = { + current: "current", + pause: "pause", + resume: "resume", +} as const; + +export const accepts = { + delivery: "delivery", + check: "check", + recheck: "recheck", +} as const; + +export const deliverySubject = "mesh.seat.node-build-node.accept.delivery"; + +export const checkSubject = "mesh.seat.node-build-node.accept.check"; + +export const recheckSubject = "mesh.seat.node-build-node.accept.recheck"; + +export const states = { + steps: "steps", + pauses: "pauses", + toolchains: "toolchains", +} as const; + +/** The key of one value of the state steps under the form ".". */ +export function stepsByNodeIDKey(node: string, id: string): string { + return node + "." + id; +} + +/** The parts of a key of the form ".", or undefined for a key of another form. */ +export function parseStepsByNodeIDKey(_key: string): { node: string; id: string } | undefined { + let _rest = _key; + const _at0 = _rest.indexOf("."); + if (_at0 <= 0) return undefined; + const node = _rest.slice(0, _at0); + _rest = _rest.slice(_at0 + 1); + if (_rest === "") return undefined; + const id = _rest; + _rest = ""; + if (_rest !== "") return undefined; + return { node, id }; +} + +/** The key of one value of the state pauses under the form "paused.". */ +export function pausesByPausedNodeKey(node: string): string { + return "paused." + node; +} + +/** The parts of a key of the form "paused.", or undefined for a key of another form. */ +export function parsePausesByPausedNodeKey(_key: string): { node: string } | undefined { + let _rest = _key; + if (!_rest.startsWith("paused.")) return undefined; + _rest = _rest.slice(7); + if (_rest === "") return undefined; + const node = _rest; + _rest = ""; + if (_rest !== "") return undefined; + return { node }; +} + +/** The key of one value of the state pauses under the form "capacity.". */ +export function pausesByCapacityNodeKey(node: string): string { + return "capacity." + node; +} + +/** The parts of a key of the form "capacity.", or undefined for a key of another form. */ +export function parsePausesByCapacityNodeKey(_key: string): { node: string } | undefined { + let _rest = _key; + if (!_rest.startsWith("capacity.")) return undefined; + _rest = _rest.slice(9); + if (_rest === "") return undefined; + const node = _rest; + _rest = ""; + if (_rest !== "") return undefined; + return { node }; +} + +/** The key of one value of the state toolchains under the form ".". */ +export function toolchainsByNodeLanguageKey(node: string, language: string): string { + return node + "." + language; +} + +/** The parts of a key of the form ".", or undefined for a key of another form. */ +export function parseToolchainsByNodeLanguageKey(_key: string): { node: string; language: string } | undefined { + let _rest = _key; + const _at0 = _rest.indexOf("."); + if (_at0 <= 0) return undefined; + const node = _rest.slice(0, _at0); + _rest = _rest.slice(_at0 + 1); + if (_rest === "") return undefined; + const language = _rest; + _rest = ""; + if (_rest !== "") return undefined; + return { node, language }; +} + +export type AskArtifact = buildStep.AskArtifact; +export type BuildAnswer = buildStep.BuildAnswer; +export type BuiltArtifact = buildStep.BuiltArtifact; +export type Capacity = buildStep.Capacity; +export type CheckAnswer = buildStep.CheckAnswer; +export type ComposeAnswer = buildStep.ComposeAnswer; +export type CurrentAnswer = buildStep.CurrentAnswer; +export type CurrentInput = buildStep.CurrentInput; +export type Fetch = buildStep.Fetch; +export type FetchAnswer = buildStep.FetchAnswer; +export type FetchBesideItem = buildStep.FetchBesideItem; +export type FetchPackage = buildStep.FetchPackage; +export type FetchRefusal = buildStep.FetchRefusal; +export type LevelAnswer = buildStep.LevelAnswer; +export type LevelAnswerDigest = buildStep.LevelAnswerDigest; +export type LevelAnswerDigestNeedsItem = buildStep.LevelAnswerDigestNeedsItem; +export type LevelAnswerDigestPackagesItem = buildStep.LevelAnswerDigestPackagesItem; +export type LevelAnswerKeptItem = buildStep.LevelAnswerKeptItem; +export type LevelAnswerUnmetItem = buildStep.LevelAnswerUnmetItem; +export type ModuleTree = buildStep.ModuleTree; +export type Pause = buildStep.Pause; +export type PauseAnswer = buildStep.PauseAnswer; +export type PauseInput = buildStep.PauseInput; +export type PlacedBeside = buildStep.PlacedBeside; +export type Program = buildStep.Program; +export type ResumeAnswer = buildStep.ResumeAnswer; +export type ResumeInput = buildStep.ResumeInput; +export type RunAnswer = buildStep.RunAnswer; +export type RunHead = buildStep.RunHead; +export type RunInput = buildStep.RunInput; +export type RunJob = buildStep.RunJob; +export type RunProgram = buildStep.RunProgram; +export type Step = buildStep.Step; +export type StepAsk = buildStep.StepAsk; +export type StepAskBuild = buildStep.StepAskBuild; +export type StepAskCheck = buildStep.StepAskCheck; +export type StepAskCompose = buildStep.StepAskCompose; +export type StepResult = buildStep.StepResult; +export type TakenVersion = buildStep.TakenVersion; +export type Toolchain = buildStep.Toolchain; +export type ToolchainUsed = buildStep.ToolchainUsed; + +/** is the body of delivery. */ +export type DeliveryAccept = StepAsk; + +/** is the body of check. */ +export type CheckAccept = StepAsk; + +/** is the body of recheck. */ +export type RecheckAccept = StepAsk; + +/** is one value of the state steps under the key .. */ +export type StepsByNodeIDValue = Step; + +/** is one value of the state pauses under the key paused.. */ +export type PausesByPausedNodeValue = Pause; + +/** is one value of the state pauses under the key capacity.. */ +export type PausesByCapacityNodeValue = Capacity; + +/** is one value of the state toolchains under the key .. */ +export type ToolchainsByNodeLanguageValue = Toolchain; + diff --git a/contracts/ts/seats/nodebuildnode/src/parts/build-step.ts b/contracts/ts/seats/nodebuildnode/src/parts/build-step.ts new file mode 100644 index 00000000..c8843899 --- /dev/null +++ b/contracts/ts/seats/nodebuildnode/src/parts/build-step.ts @@ -0,0 +1,398 @@ +// Code generated by seatgen from the seat definitions; DO NOT EDIT. + +// The part "build-step" several seats include whole: What the three build seats share: the verbs that read and steer a node's builds, and the layouts of the asks they accept and the steps they keep. A seat lists its states against these (a part carries no states). + +export const verbs = { + current: "current", + pause: "pause", + resume: "resume", +} as const; + +/** one artifact of a build ask: built from the fetched tree, or copied from an upstream pin */ +export interface AskArtifact { + "artifact": string; + "from"?: string; + "seat"?: string; + "version"?: string; +} + +export interface BuildAnswer { + "artifacts": BuiltArtifact[]; + "imports"?: string[]; + "out-of-step"?: string[]; + "toolchain"?: ToolchainUsed; +} + +/** one artifact a build made: its digest and the reference it was published under */ +export interface BuiltArtifact { + "artifact": string; + "digest": string; + "reference"?: string; +} + +/** how many steps the node runs at once */ +export interface Capacity { + /** when (RFC 3339 time) */ + "at"?: string; + "build-hours"?: string; + "build-hours-fault"?: string; + "by-cpu"?: number; + "configured": number; + "configured-from"?: string; + "cores-per-step"?: number; + "effective": number; + "ineligible"?: string; + "outside-hours"?: boolean; + "quota-cores"?: number; + "quota-from"?: string; + /** the layout version */ + "v"?: number; + "why": string; +} + +export interface CheckAnswer { + "fetch": FetchAnswer; + "levels"?: LevelAnswer[]; +} + +export interface ComposeAnswer { + "built": boolean; + "key"?: string; + /** the compose-candidate verdict */ + "verdict"?: unknown; +} + +export interface FetchBesideItem { + "commit": string; + "repository": string; +} + +/** the TypeScript module's group package: the group's tagged version of the SDK ({name, version}), placed from the registry in each member whatever its lock names, or the group's package member ({repository, commit, ref}), packed by the fetch and served to the members in place of the registry's */ +export interface FetchPackage { + "commit"?: string; + "name"?: string; + "ref"?: string; + "repository"?: string; + "version"?: string; +} + +/** what a build module's fetch tool is asked: the repository and the full commit, the pull request's ref when only its head carries the commit, the trunk commit to merge onto for a merge preview, the module's directory and named paths, the mesh packages to build against, and the sibling repositories placed beside */ +export interface Fetch { + /** the trunk commit a merge preview merges onto */ + "base"?: string; + /** the sibling repositories placed beside the fetched tree */ + "beside"?: FetchBesideItem[]; + /** the full commit */ + "commit": string; + /** the module's directory in the repository ("." for its root), with paths */ + "dir"?: string; + /** the TypeScript module's group package: the group's tagged version of the SDK ({name, version}), placed from the registry in each member whatever its lock names, or the group's package member ({repository, commit, ref}), packed by the fetch and served to the members in place of the registry's */ + "package"?: FetchPackage; + /** the version (a full commit) of each mesh package to build against, by its module path */ + "packages"?: Record; + /** the paths the module names, relative to dir */ + "paths"?: string[]; + /** the pull request's ref, when only its head carries the commit */ + "ref"?: string; + /** / */ + "repository": string; +} + +/** what a build module's fetch tool answers, whole: the workspace it made, the commit in it, the module directories found; and, when the call gave a base, the merge preview (the trunk commit, the merge commit, the files that conflict, the paths the merge changes, the compiled programs among them, the tree of each module directory). A head that does not merge cleanly is an answer, not an error: merge is empty and conflicts names the files. The Go module adds the sibling repositories placed and the package versions taken; the TypeScript module the directories installed and refused and the group's package */ +export interface FetchAnswer { + "base"?: string; + "beside"?: PlacedBeside[]; + "changed"?: string[]; + "commit": string; + "conflicts"?: string[]; + "group"?: string; + "installed"?: string[]; + "merge"?: string; + "modules": string[]; + "packages"?: TakenVersion[]; + "programs"?: Program[]; + "refused"?: FetchRefusal[]; + "trees"?: Record; + "workspace": string; +} + +/** a TypeScript module whose packages the fetch did not install, with why */ +export interface FetchRefusal { + "module": string; + "reason": string; +} + +export interface LevelAnswerDigestNeedsItem { + "needs"?: string[]; + "package"?: string; +} + +export interface LevelAnswerDigestPackagesItem { + "digest": string; + "package": string; +} + +export interface LevelAnswerDigest { + "beside"?: string[]; + "judged_by"?: string; + "level"?: string; + "needs"?: LevelAnswerDigestNeedsItem[]; + "packages"?: LevelAnswerDigestPackagesItem[]; + "without_tests"?: string[]; +} + +export interface LevelAnswerKeptItem { + "digest"?: string; + "package"?: string; + "tests"?: number; +} + +export interface LevelAnswerUnmetItem { + "needs"?: string[]; + "package"?: string; +} + +/** one level of a check ask: the digest's answer, the kept verdicts taken, one test result per test step run, and the packages the node could not test */ +export interface LevelAnswer { + "digest"?: LevelAnswerDigest; + "judged"?: string; + "kept"?: LevelAnswerKeptItem[]; + "level": string; + "tests"?: unknown[]; + "unmet"?: LevelAnswerUnmetItem[]; +} + +/** the tree of one module directory in a merge preview: its git tree, the digest of its manifest, the lock files' digests and the digests of the named paths */ +export interface ModuleTree { + "locks"?: Record; + "manifest": string; + /** no longer filled (ADR 0593 section 2); kept so a preview written before still reads */ + "packages"?: string[]; + "paths"?: Record; + "tree": string; +} + +/** whether the node takes no new ask */ +export interface Pause { + /** when (RFC 3339 time) */ + "at"?: string; + "by"?: string; + "paused": boolean; + /** the layout version */ + "v"?: number; +} + +/** a sibling repository placed as a plain directory under the workspace's beside directory */ +export interface PlacedBeside { + "commit": string; + "dir": string; + "repository": string; +} + +/** a changed file that is a compiled program (issue 1577): its path, its executable format, and the commit of the branch that added it when the merge's tree no longer holds it */ +export interface Program { + "commit"?: string; + /** ELF, Mach-O or PE */ + "kind": string; + "path": string; +} + +/** what a run ask's step answers: whether this run built the program or took the one kept under its key, the key, the program's exit, the last line of its standard output and the first of its standard error */ +export interface RunAnswer { + "built": boolean; + "exit": number; + "key": string; + /** raw when it is JSON, else a JSON string */ + "output"?: unknown; + "said"?: string; +} + +/** one commit a run's program reads */ +export interface RunHead { + "commit": string; + "repository": string; +} + +/** a run's input by reference: the seat verb the asker serves it on (seat:.), the sha256 of its bytes and its size, at most 256 KiB */ +export interface RunInput { + /** 64 lowercase hex digits */ + "digest": string; + /** bytes */ + "size": number; + "verb": string; +} + +/** a run ask's job: where its program is built, the heads it reads (each laid read-only at its repository's path), its input by reference, its arguments and what it is for */ +export interface RunJob { + /** the program's arguments, each a word or a flag */ + "args"?: string[]; + /** the commits the program reads, a repository at most once */ + "heads": RunHead[]; + "input": RunInput; + "program": RunProgram; + /** what the run is for, for a reader of the ask */ + "reader"?: string; +} + +/** where a run's program is built: the repository and full commit fetched, the Go module's directory in it and the program's import path */ +export interface RunProgram { + "commit": string; + "dir": string; + "package": string; + "repository": string; +} + +/** one step a build module runs on a node */ +export interface Step { + /** when (RFC 3339 time) */ + "ended"?: string; + "id": string; + "node": string; + "owner"?: string; + "phase"?: string; + /** when (RFC 3339 time) */ + "phase-at"?: string; + "reason"?: string; + "result"?: StepResult; + /** when (RFC 3339 time) */ + "started": string; + /** running, passed, failed or refused */ + "state": string; + "tool": string; + /** the layout version */ + "v"?: number; + "what"?: string; +} + +export interface StepAskBuild { + "artifacts": AskArtifact[]; + "fetch"?: Fetch; + "fp"?: string; + "module": string; + "path"?: string; + "publish"?: boolean; + "toolchain"?: ToolchainUsed; +} + +export interface StepAskCheck { + "fetch": Fetch; + "kept"?: boolean; + "levels": string[]; + /** the node names the check's tests run against */ + "node-names"?: string[]; + "packages"?: string[]; + "run"?: string; + "runner"?: string; + "runner_when"?: string; + /** the toolchain the checker agreed for the language (ADR 0584 clause 2): a node on another declines the check */ + "toolchain"?: ToolchainUsed; +} + +export interface StepAskCompose { + "dir": string; + "fetch": Fetch; + "package": string; + "reader"?: string; + /** the compose-candidate request */ + "request": unknown; +} + +/** one ask of a build module */ +export interface StepAsk { + "asker": string; + /** when (RFC 3339 time) */ + "at"?: string; + "build"?: StepAskBuild; + "check"?: StepAskCheck; + "compose"?: StepAskCompose; + "id": string; + /** build, check or compose */ + "kind": string; + /** go, typescript or image */ + "language": string; + "needs"?: string[]; + /** package to the tagged version a build pins */ + "packages"?: Record; + /** a run: a program built from a change and run in the build seat's sandbox over the heads, its input asked of a verb of the asker's seat; the Go seat's check stream alone carries it, as a compose */ + "run"?: RunJob; + /** the layout version */ + "v"?: number; +} + +/** what a step answered, by the kind of ask: a build's artifacts, toolchain, imports and out-of-step modules; a check's fetch and levels; a compose's built, key and verdict; a run's built, key, exit, output and said; a declined ask's declined, needs and lacks. The fields of the kinds do not meet, so one layout holds the five */ +export interface StepResult { + "artifacts"?: BuiltArtifact[]; + "built"?: boolean; + /** the holder declined the ask: its node lacks what the ask needs */ + "declined"?: boolean; + /** a run's program's exit status */ + "exit"?: number; + "fetch"?: FetchAnswer; + "imports"?: string[]; + "key"?: string; + /** what the holder's node lacks of it */ + "lacks"?: string[]; + "levels"?: LevelAnswer[]; + /** what the declined ask needs */ + "needs"?: string[]; + "out-of-step"?: string[]; + /** a run's program's last line of standard output: raw when it is JSON, else a JSON string */ + "output"?: unknown; + /** a run's program's first line of standard error, or why the step could not run it */ + "said"?: string; + "toolchain"?: ToolchainUsed; + /** the compose-candidate verdict (the composer's own layout) */ + "verdict"?: unknown; +} + +/** of a directory whose go.mod requires an asked mesh package, the version it is built against: the asked one, or go.mod's when it names a newer one, said */ +export interface TakenVersion { + "asked": string; + "built": string; + "dir": string; + "named": string; + "package": string; + "said"?: string; + "sum"?: string; +} + +/** the toolchain a node reports */ +export interface Toolchain { + /** when (RFC 3339 time) */ + "at"?: string; + /** go or typescript */ + "language": string; + "recipe": string; + /** the layout version */ + "v"?: number; + "version": string; +} + +/** the toolchain a build was made with: the language, the installed version and the build module's recipe */ +export interface ToolchainUsed { + "language": string; + "recipe": string; + "version": string; +} + +/** is what current takes. */ +export interface CurrentInput { +} + +/** is what current answers. */ +export type CurrentAnswer = Record; + +/** is what pause takes. */ +export interface PauseInput { +} + +/** is what pause answers. */ +export type PauseAnswer = Record; + +/** is what resume takes. */ +export interface ResumeInput { +} + +/** is what resume answers. */ +export type ResumeAnswer = Record; + diff --git a/contracts/ts/seats/nodebuildnode/tsconfig.json b/contracts/ts/seats/nodebuildnode/tsconfig.json new file mode 100644 index 00000000..60274c6a --- /dev/null +++ b/contracts/ts/seats/nodebuildnode/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "noEmit": true, + "strict": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src/**/*.ts"] +} diff --git a/contracts/ts/seats/npmpackageregistry/package-lock.json b/contracts/ts/seats/npmpackageregistry/package-lock.json new file mode 100644 index 00000000..321b0c94 --- /dev/null +++ b/contracts/ts/seats/npmpackageregistry/package-lock.json @@ -0,0 +1,12 @@ +{ + "lockfileVersion": 3, + "name": "@novox/mesh-delivery-contracts-npm-package-registry", + "packages": { + "": { + "name": "@novox/mesh-delivery-contracts-npm-package-registry", + "version": "0.1.0" + } + }, + "requires": true, + "version": "0.1.0" +} diff --git a/contracts/ts/seats/npmpackageregistry/package.json b/contracts/ts/seats/npmpackageregistry/package.json new file mode 100644 index 00000000..ca10dfc9 --- /dev/null +++ b/contracts/ts/seats/npmpackageregistry/package.json @@ -0,0 +1,13 @@ +{ + "description": "The contract of the seat npm-package-registry, generated from its definition.", + "exports": { + ".": "./src/index.ts" + }, + "files": [ + "src" + ], + "name": "@novox/mesh-delivery-contracts-npm-package-registry", + "type": "module", + "types": "./src/index.ts", + "version": "0.1.0" +} diff --git a/contracts/ts/seats/npmpackageregistry/src/index.ts b/contracts/ts/seats/npmpackageregistry/src/index.ts new file mode 100644 index 00000000..a704e8b6 --- /dev/null +++ b/contracts/ts/seats/npmpackageregistry/src/index.ts @@ -0,0 +1,54 @@ +// Code generated by seatgen from the seat definitions; DO NOT EDIT. + +// The contract of the seat "npm-package-registry", defined whole (seats/npm-package-registry/seat.json): The mesh's npm packages: the npm registry protocol's read side, and publishing over the bus. + +import type * as registry from "./parts/registry.js"; + +export const seat = "npm-package-registry"; + +export const verbs = { + publish: "publish", + publishPart: "publish-part", + versions: "versions", + remove: "remove", +} as const; + +export const events = { + published: "published", + refused: "refused", + removed: "removed", + kept: "kept", +} as const; + +export const family = { name: "npm-package-registry.events", maxAgeDays: 7 } as const; + +export const publishedSubject = "mesh.seat.npm-package-registry.event.published"; + +export const refusedSubject = "mesh.seat.npm-package-registry.event.refused"; + +export const removedSubject = "mesh.seat.npm-package-registry.event.removed"; + +export const keptSubject = "mesh.seat.npm-package-registry.event.kept"; + +export type Artifact = registry.Artifact; +export type KeptEvent = registry.KeptEvent; +export type Outcome = registry.Outcome; +export type Part = registry.Part; +export type PublishAnswer = registry.PublishAnswer; +export type PublishInput = registry.PublishInput; +export type PublishPartAnswer = registry.PublishPartAnswer; +export type PublishPartInput = registry.PublishPartInput; +export type PublishedEvent = registry.PublishedEvent; +export type RefusedEvent = registry.RefusedEvent; +export type RemoveAnswer = registry.RemoveAnswer; +export type RemoveInput = registry.RemoveInput; +export type RemovedEvent = registry.RemovedEvent; +export type VersionsAnswer = registry.VersionsAnswer; +export type VersionsInput = registry.VersionsInput; + +/** is the form of the provision npm-package-registry. */ +export interface NpmPackageRegistryForm { + /** the read side's address */ + "url": string; +} + diff --git a/contracts/ts/seats/npmpackageregistry/src/parts/registry.ts b/contracts/ts/seats/npmpackageregistry/src/parts/registry.ts new file mode 100644 index 00000000..17a8de46 --- /dev/null +++ b/contracts/ts/seats/npmpackageregistry/src/parts/registry.ts @@ -0,0 +1,187 @@ +// Code generated by seatgen from the seat definitions; DO NOT EDIT. + +// The part "registry" several seats include whole: The verbs and events every registry seat shares, written once so the four cannot drift (design 51, design 26). Who may call each verb is the holder's trusted config value listing its callers (ADR 0363's form), checked against the caller the tool runner proved. + +export const verbs = { + publish: "publish", + publishPart: "publish-part", + versions: "versions", + remove: "remove", +} as const; + +export const events = { + published: "published", + refused: "refused", + removed: "removed", + kept: "kept", +} as const; + +/** one published thing, as every registry event and answer names it */ +export interface Artifact { + /** the source commit it was built from */ + "commit"?: string; + /** its digest, sha256: (a Go package's h1: hash beside it in the version's record) */ + "digest": string; + /** the build's fingerprint */ + "fingerprint"?: string; + /** what it is */ + "kind": "package" | "image" | "bundle" | "archive" | "upstream"; + /** the package's, image's, bundle's or archive's name; a package's is its name in its own language (design 32): a Go package's the go.mod module path (git.novox.be/novox/mesh-sdk/go), an npm package's package.json's name (@novox/mesh-sdk) */ + "name": string; + /** the tag it carries, for a tagged version */ + "tag"?: string; + /** the version the builder gave it (design 51 *The versions*); empty for a bundle or archive published by its digest alone */ + "version"?: string; +} + +/** the body of published, refused and removed */ +export interface Outcome { + /** the source commit it was built from */ + "commit"?: string; + /** its digest, sha256: (a Go package's h1: hash beside it in the version's record) */ + "digest": string; + /** the build's fingerprint */ + "fingerprint"?: string; + /** the module holding the seat that said it */ + "holder": string; + /** what it is */ + "kind": "package" | "image" | "bundle" | "archive" | "upstream"; + /** the Go module path a Go package is fetched under, equal to name for a Go package (go-package-registry's published); empty for every other kind of artifact */ + "module"?: string; + /** the package's, image's, bundle's or archive's name; a package's is its name in its own language (design 32): a Go package's the go.mod module path (git.novox.be/novox/mesh-sdk/go), an npm package's package.json's name (@novox/mesh-sdk) */ + "name": string; + /** why: a refusal's cause, a removal's (retention, tag-gone, by-hand), or why a removal was kept (shared-digest) */ + "reason"?: string; + /** the reference the seat answers: //@sha256:, or @ for a package (ADR 0605 §9) */ + "reference"?: string; + /** the tag it carries, for a tagged version */ + "tag"?: string; + /** for kept: the other tags still naming its digest */ + "tags"?: string[]; + /** the transfer it ends, when it ended one */ + "transfer"?: string; + /** the version the builder gave it (design 51 *The versions*); empty for a bundle or archive published by its digest alone */ + "version"?: string; +} + +/** one part of a transfer */ +export interface Part { + /** the part's digest, sha256:: an image's layer or config, a package's file, a bundle's blob */ + "digest": string; + /** its size in bytes */ + "size": number; +} + +/** is what publish takes. */ +export interface PublishInput { + /** the source commit it was built from */ + "commit"?: string; + /** its digest, sha256: (a Go package's h1: hash beside it in the version's record) */ + "digest": string; + /** the build's fingerprint */ + "fingerprint"?: string; + /** what it is */ + "kind": "package" | "image" | "bundle" | "archive" | "upstream"; + /** the package's, image's, bundle's or archive's name; a package's is its name in its own language (design 32): a Go package's the go.mod module path (git.novox.be/novox/mesh-sdk/go), an npm package's package.json's name (@novox/mesh-sdk) */ + "name": string; + /** every part's digest and size; none when the digest is already held */ + "parts"?: Part[]; + /** the tag it carries, for a tagged version */ + "tag"?: string; + /** the transfer's name, the caller's own; the same name opens the same transfer again */ + "transfer": string; + /** the version the builder gave it (design 51 *The versions*); empty for a bundle or archive published by its digest alone */ + "version"?: string; +} + +/** is what publish answers. */ +export interface PublishAnswer { + /** the digest is held already: nothing to send; the outcome follows as an event */ + "held"?: boolean; + /** the digests of the parts the holder lacks, to send by publish-part */ + "lacks"?: string[]; + /** the transfer */ + "transfer": string; +} + +/** is what publish-part takes. */ +export interface PublishPartInput { + /** the chunk's bytes, base64 (at most 760 KiB before encoding) */ + "data": string; + /** this chunk is the part's last */ + "last": boolean; + /** the part's digest */ + "part": string; + /** the chunk's place in its part, from 0 */ + "sequence": number; + /** the transfer */ + "transfer": string; +} + +/** is what publish-part answers. */ +export interface PublishPartAnswer { + /** the part is whole and its bytes match its digest */ + "complete"?: boolean; + /** the part */ + "part": string; + /** the chunks of the part held so far */ + "received"?: number; + /** the transfer */ + "transfer": string; +} + +/** is what versions takes. */ +export interface VersionsInput { + /** narrowed to one kind (optional) */ + "kind"?: string; + /** the name */ + "name": string; +} + +/** is what versions answers. */ +export interface VersionsAnswer { + /** the name */ + "name": string; + /** newest first */ + "versions": Artifact[]; +} + +/** is what remove takes. */ +export interface RemoveInput { + /** the digest (for a bundle, archive or copied base) */ + "digest"?: string; + /** the name */ + "name": string; + /** why it goes */ + "reason": "tag-gone" | "retention"; + /** the tag (for a tagged version) */ + "tag"?: string; + /** the version */ + "version"?: string; +} + +/** is what remove answers. */ +export interface RemoveAnswer { + "artifact"?: Artifact; + /** it stays whole, its tag and its bytes: the store cannot remove the tag alone while another tag names its digest (reason, tags) */ + "kept"?: boolean; + /** why it was kept: shared-digest, another tag names its digest */ + "reason"?: "shared-digest"; + /** it is gone */ + "removed": boolean; + /** the other tags still naming its digest, when kept */ + "tags"?: string[]; +} + +/** is the body of the event published. */ +export type PublishedEvent = Outcome; + +/** is the body of the event refused. */ +export type RefusedEvent = Outcome; + +/** is the body of the event removed. */ +export type RemovedEvent = Outcome; + +/** is the body of the event kept. */ +export type KeptEvent = Outcome; + diff --git a/contracts/ts/seats/npmpackageregistry/tsconfig.json b/contracts/ts/seats/npmpackageregistry/tsconfig.json new file mode 100644 index 00000000..60274c6a --- /dev/null +++ b/contracts/ts/seats/npmpackageregistry/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "noEmit": true, + "strict": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src/**/*.ts"] +} diff --git a/contracts/ts/seats/ociartifactregistry/package-lock.json b/contracts/ts/seats/ociartifactregistry/package-lock.json new file mode 100644 index 00000000..0ff49c30 --- /dev/null +++ b/contracts/ts/seats/ociartifactregistry/package-lock.json @@ -0,0 +1,12 @@ +{ + "lockfileVersion": 3, + "name": "@novox/mesh-delivery-contracts-oci-artifact-registry", + "packages": { + "": { + "name": "@novox/mesh-delivery-contracts-oci-artifact-registry", + "version": "0.1.0" + } + }, + "requires": true, + "version": "0.1.0" +} diff --git a/contracts/ts/seats/ociartifactregistry/package.json b/contracts/ts/seats/ociartifactregistry/package.json new file mode 100644 index 00000000..7f05486f --- /dev/null +++ b/contracts/ts/seats/ociartifactregistry/package.json @@ -0,0 +1,13 @@ +{ + "description": "The contract of the seat oci-artifact-registry, generated from its definition.", + "exports": { + ".": "./src/index.ts" + }, + "files": [ + "src" + ], + "name": "@novox/mesh-delivery-contracts-oci-artifact-registry", + "type": "module", + "types": "./src/index.ts", + "version": "0.1.0" +} diff --git a/contracts/ts/seats/ociartifactregistry/src/index.ts b/contracts/ts/seats/ociartifactregistry/src/index.ts new file mode 100644 index 00000000..1ca6a452 --- /dev/null +++ b/contracts/ts/seats/ociartifactregistry/src/index.ts @@ -0,0 +1,230 @@ +// Code generated by seatgen from the seat definitions; DO NOT EDIT. + +// The contract of the seat "oci-artifact-registry", defined whole (seats/oci-artifact-registry/seat.json): The mesh's bundles and archives, by digest, in the one artifact store: the OCI distribution protocol's read side. + +import type * as registry from "./parts/registry.js"; + +export const seat = "oci-artifact-registry"; + +export const verbs = { + publish: "publish", + publishPart: "publish-part", + versions: "versions", + remove: "remove", + hold: "hold", + release: "release", + list: "list", +} as const; + +export const events = { + published: "published", + refused: "refused", + removed: "removed", + kept: "kept", + held: "held", + released: "released", +} as const; + +export const family = { name: "oci-artifact-registry.events", maxAgeDays: 7 } as const; + +export const publishedSubject = "mesh.seat.oci-artifact-registry.event.published"; + +export const refusedSubject = "mesh.seat.oci-artifact-registry.event.refused"; + +export const removedSubject = "mesh.seat.oci-artifact-registry.event.removed"; + +export const keptSubject = "mesh.seat.oci-artifact-registry.event.kept"; + +export const heldSubject = "mesh.seat.oci-artifact-registry.event.held"; + +export const releasedSubject = "mesh.seat.oci-artifact-registry.event.released"; + +export const states = { + holds: "holds", + windows: "windows", +} as const; + +/** The key of one value of the state holds under the form "..". */ +export function holdsBySeatNameDigestKey(seat: string, name: string, digest: string): string { + return seat + "." + name + "." + digest; +} + +/** The parts of a key of the form "..", or undefined for a key of another form. */ +export function parseHoldsBySeatNameDigestKey(_key: string): { seat: string; name: string; digest: string } | undefined { + let _rest = _key; + const _at0 = _rest.indexOf("."); + if (_at0 <= 0) return undefined; + const seat = _rest.slice(0, _at0); + _rest = _rest.slice(_at0 + 1); + const _at2 = _rest.indexOf("."); + if (_at2 <= 0) return undefined; + const name = _rest.slice(0, _at2); + _rest = _rest.slice(_at2 + 1); + if (_rest === "") return undefined; + const digest = _rest; + _rest = ""; + if (_rest !== "") return undefined; + return { seat, name, digest }; +} + +/** The key of one value of the state windows under the form "". */ +export function windowsByCallerKey(caller: string): string { + return caller; +} + +/** The parts of a key of the form "", or undefined for a key of another form. */ +export function parseWindowsByCallerKey(_key: string): { caller: string } | undefined { + let _rest = _key; + if (_rest === "") return undefined; + const caller = _rest; + _rest = ""; + if (_rest !== "") return undefined; + return { caller }; +} + +export type Artifact = registry.Artifact; +export type KeptEvent = registry.KeptEvent; +export type Outcome = registry.Outcome; +export type Part = registry.Part; +export type PublishAnswer = registry.PublishAnswer; +export type PublishInput = registry.PublishInput; +export type PublishPartAnswer = registry.PublishPartAnswer; +export type PublishPartInput = registry.PublishPartInput; +export type PublishedEvent = registry.PublishedEvent; +export type RefusedEvent = registry.RefusedEvent; +export type RemoveAnswer = registry.RemoveAnswer; +export type RemoveInput = registry.RemoveInput; +export type RemovedEvent = registry.RemovedEvent; +export type VersionsAnswer = registry.VersionsAnswer; +export type VersionsInput = registry.VersionsInput; + +/** the body of held and released */ +export interface Hold { + /** when, RFC 3339 */ + "at"?: string; + /** the caller the holder proved */ + "by"?: string; + /** the digest */ + "digest": string; + /** the module holding the seat */ + "holder": string; + /** the name */ + "name": string; + /** why */ + "reason"?: string; +} + +export interface Listed { + /** for a manifest keeping one bundle or archive, the digest of that blob: what a reference names it by */ + "artifact"?: string; + /** the manifest's digest, sha256: */ + "digest": string; + /** a hold keeps it from retention */ + "held": boolean; + /** it is an index, naming a manifest per platform */ + "index": boolean; + /** for an index, the digests of the manifests it names */ + "platforms"?: string[]; + /** its bytes: its own content and every blob it names */ + "size": number; + /** the tags naming it, none for an untagged one */ + "tags"?: string[]; +} + +export interface Repository { + /** each manifest it holds, by digest */ + "manifests": Listed[]; + /** the repository */ + "name": string; +} + +/** is what hold takes. */ +export interface HoldInput { + /** the digest */ + "digest": string; + /** the name */ + "name": string; + /** which of the sweep's rules keeps it */ + "reason": string; +} + +/** is what hold answers. */ +export interface HoldAnswer { + /** it is held */ + "held": boolean; + /** when the hold began, RFC 3339 */ + "since"?: string; +} + +/** is what release takes. */ +export interface ReleaseInput { + /** the digest */ + "digest": string; + /** the name */ + "name": string; + /** why it is let go */ + "reason": string; +} + +/** is what release answers. */ +export interface ReleaseAnswer { + /** a hold was let go; false when there was none */ + "released": boolean; +} + +/** is what list takes. */ +export interface ListInput { + /** the repository, /; every repository of the seat when not given */ + "name"?: string; +} + +/** is what list answers. */ +export interface ListAnswer { + /** each repository named, in order of its name */ + "repositories": Repository[]; +} + +/** is the body of the event held. */ +export type HeldEvent = Hold; + +/** is the body of the event released. */ +export type ReleasedEvent = Hold; + +/** is one value of the state holds under the key ... */ +export interface HoldsBySeatNameDigestValue { + "by": string; + "digest": string; + "kind": string; + "name": string; + "seat": string; + /** when (RFC 3339 time) */ + "since": string; + "why"?: string; +} + +export interface WindowsByCallerValueDroppedItem { + /** when (RFC 3339 time) */ + "at"?: string; + "by"?: string; + "node": string; + "why"?: string; +} + +/** is one value of the state windows under the key . */ +export interface WindowsByCallerValue { + /** when it last closed (RFC 3339 time) */ + "closed"?: string; + /** retired nodes dropped by hand */ + "dropped"?: WindowsByCallerValueDroppedItem[]; + /** the nodes holding it open */ + "holding"?: string[]; + "last_holding"?: string[]; + /** the caller whose window it is */ + "name": string; + "open"?: boolean; + /** node to when it began to hold */ + "since"?: Record; + /** of each node holding the window, why it holds it */ + "why"?: Record; +} + diff --git a/contracts/ts/seats/ociartifactregistry/src/parts/registry.ts b/contracts/ts/seats/ociartifactregistry/src/parts/registry.ts new file mode 100644 index 00000000..17a8de46 --- /dev/null +++ b/contracts/ts/seats/ociartifactregistry/src/parts/registry.ts @@ -0,0 +1,187 @@ +// Code generated by seatgen from the seat definitions; DO NOT EDIT. + +// The part "registry" several seats include whole: The verbs and events every registry seat shares, written once so the four cannot drift (design 51, design 26). Who may call each verb is the holder's trusted config value listing its callers (ADR 0363's form), checked against the caller the tool runner proved. + +export const verbs = { + publish: "publish", + publishPart: "publish-part", + versions: "versions", + remove: "remove", +} as const; + +export const events = { + published: "published", + refused: "refused", + removed: "removed", + kept: "kept", +} as const; + +/** one published thing, as every registry event and answer names it */ +export interface Artifact { + /** the source commit it was built from */ + "commit"?: string; + /** its digest, sha256: (a Go package's h1: hash beside it in the version's record) */ + "digest": string; + /** the build's fingerprint */ + "fingerprint"?: string; + /** what it is */ + "kind": "package" | "image" | "bundle" | "archive" | "upstream"; + /** the package's, image's, bundle's or archive's name; a package's is its name in its own language (design 32): a Go package's the go.mod module path (git.novox.be/novox/mesh-sdk/go), an npm package's package.json's name (@novox/mesh-sdk) */ + "name": string; + /** the tag it carries, for a tagged version */ + "tag"?: string; + /** the version the builder gave it (design 51 *The versions*); empty for a bundle or archive published by its digest alone */ + "version"?: string; +} + +/** the body of published, refused and removed */ +export interface Outcome { + /** the source commit it was built from */ + "commit"?: string; + /** its digest, sha256: (a Go package's h1: hash beside it in the version's record) */ + "digest": string; + /** the build's fingerprint */ + "fingerprint"?: string; + /** the module holding the seat that said it */ + "holder": string; + /** what it is */ + "kind": "package" | "image" | "bundle" | "archive" | "upstream"; + /** the Go module path a Go package is fetched under, equal to name for a Go package (go-package-registry's published); empty for every other kind of artifact */ + "module"?: string; + /** the package's, image's, bundle's or archive's name; a package's is its name in its own language (design 32): a Go package's the go.mod module path (git.novox.be/novox/mesh-sdk/go), an npm package's package.json's name (@novox/mesh-sdk) */ + "name": string; + /** why: a refusal's cause, a removal's (retention, tag-gone, by-hand), or why a removal was kept (shared-digest) */ + "reason"?: string; + /** the reference the seat answers: //@sha256:, or @ for a package (ADR 0605 §9) */ + "reference"?: string; + /** the tag it carries, for a tagged version */ + "tag"?: string; + /** for kept: the other tags still naming its digest */ + "tags"?: string[]; + /** the transfer it ends, when it ended one */ + "transfer"?: string; + /** the version the builder gave it (design 51 *The versions*); empty for a bundle or archive published by its digest alone */ + "version"?: string; +} + +/** one part of a transfer */ +export interface Part { + /** the part's digest, sha256:: an image's layer or config, a package's file, a bundle's blob */ + "digest": string; + /** its size in bytes */ + "size": number; +} + +/** is what publish takes. */ +export interface PublishInput { + /** the source commit it was built from */ + "commit"?: string; + /** its digest, sha256: (a Go package's h1: hash beside it in the version's record) */ + "digest": string; + /** the build's fingerprint */ + "fingerprint"?: string; + /** what it is */ + "kind": "package" | "image" | "bundle" | "archive" | "upstream"; + /** the package's, image's, bundle's or archive's name; a package's is its name in its own language (design 32): a Go package's the go.mod module path (git.novox.be/novox/mesh-sdk/go), an npm package's package.json's name (@novox/mesh-sdk) */ + "name": string; + /** every part's digest and size; none when the digest is already held */ + "parts"?: Part[]; + /** the tag it carries, for a tagged version */ + "tag"?: string; + /** the transfer's name, the caller's own; the same name opens the same transfer again */ + "transfer": string; + /** the version the builder gave it (design 51 *The versions*); empty for a bundle or archive published by its digest alone */ + "version"?: string; +} + +/** is what publish answers. */ +export interface PublishAnswer { + /** the digest is held already: nothing to send; the outcome follows as an event */ + "held"?: boolean; + /** the digests of the parts the holder lacks, to send by publish-part */ + "lacks"?: string[]; + /** the transfer */ + "transfer": string; +} + +/** is what publish-part takes. */ +export interface PublishPartInput { + /** the chunk's bytes, base64 (at most 760 KiB before encoding) */ + "data": string; + /** this chunk is the part's last */ + "last": boolean; + /** the part's digest */ + "part": string; + /** the chunk's place in its part, from 0 */ + "sequence": number; + /** the transfer */ + "transfer": string; +} + +/** is what publish-part answers. */ +export interface PublishPartAnswer { + /** the part is whole and its bytes match its digest */ + "complete"?: boolean; + /** the part */ + "part": string; + /** the chunks of the part held so far */ + "received"?: number; + /** the transfer */ + "transfer": string; +} + +/** is what versions takes. */ +export interface VersionsInput { + /** narrowed to one kind (optional) */ + "kind"?: string; + /** the name */ + "name": string; +} + +/** is what versions answers. */ +export interface VersionsAnswer { + /** the name */ + "name": string; + /** newest first */ + "versions": Artifact[]; +} + +/** is what remove takes. */ +export interface RemoveInput { + /** the digest (for a bundle, archive or copied base) */ + "digest"?: string; + /** the name */ + "name": string; + /** why it goes */ + "reason": "tag-gone" | "retention"; + /** the tag (for a tagged version) */ + "tag"?: string; + /** the version */ + "version"?: string; +} + +/** is what remove answers. */ +export interface RemoveAnswer { + "artifact"?: Artifact; + /** it stays whole, its tag and its bytes: the store cannot remove the tag alone while another tag names its digest (reason, tags) */ + "kept"?: boolean; + /** why it was kept: shared-digest, another tag names its digest */ + "reason"?: "shared-digest"; + /** it is gone */ + "removed": boolean; + /** the other tags still naming its digest, when kept */ + "tags"?: string[]; +} + +/** is the body of the event published. */ +export type PublishedEvent = Outcome; + +/** is the body of the event refused. */ +export type RefusedEvent = Outcome; + +/** is the body of the event removed. */ +export type RemovedEvent = Outcome; + +/** is the body of the event kept. */ +export type KeptEvent = Outcome; + diff --git a/contracts/ts/seats/ociartifactregistry/tsconfig.json b/contracts/ts/seats/ociartifactregistry/tsconfig.json new file mode 100644 index 00000000..60274c6a --- /dev/null +++ b/contracts/ts/seats/ociartifactregistry/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "noEmit": true, + "strict": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src/**/*.ts"] +} diff --git a/modules/mesh-delivery-assigner/asks/asks.go b/modules/mesh-delivery-assigner/asks/asks.go new file mode 100644 index 00000000..7fe58160 --- /dev/null +++ b/modules/mesh-delivery-assigner/asks/asks.go @@ -0,0 +1,257 @@ +// Package asks holds the layout of the assigner's state `asks`: the keys of the asks the assigner puts to a person and the +// value it keeps at each, which the assigner writes and the view and the teller read (novox/hq ADR 0634 §1, ADR 0635 §5). It +// is the assigner's own published package. The gate's merge ask (`merge..`) needs proof of who answered +// (ADR 0421 §1), so it is answered on the phone alone until issue 953 closes. The assigner's asks need no such proof, and the +// view may answer them: the hold of a fingerprint whose health check failed (`hold..`: release it or +// keep it held), the restore of a data module set back (`restore...`: restore and lose newer +// writes, or leave it stopped, ADR 0429 §6), a backup that failed before an apply, a move a person must allow for a module of +// policy record, and the authorising ask of a proposal of assignments. +package asks + +import ( + "bytes" + "encoding/json" + "fmt" + "strings" + + "git.novox.be/novox/mesh-contracts/go/clients/meshmoduleassignerclient" + "git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient" + "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" + "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliveryclient" + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds" + "git.novox.be/novox/mesh-delivery/sdk/kept" + "git.novox.be/novox/mesh-sdk/go/keys" +) + +const ( + // AskKindHold is the kind of the assigner's ask about a held fingerprint. + AskKindHold = "hold" + // AskKindRestore is the kind of the assigner's ask about a data module set back. + AskKindRestore = "restore" + // AskKindBackup is the kind of the assigner's ask about a backup before an apply that failed: back up again, or go on + // without one (novox/hq ADR 0539 clause 16). + AskKindBackup = "backup" + // AskKindRecord is the kind of the assigner's ask about a move a person must allow, for a module of policy record: a + // composition judged to recreate a container or move data, or one that cannot be judged (ADR 0539 clause 14). + AskKindRecord = "record" + // AskKindAssignments is the kind of the authorising ask about a proposal of assignments (novox/hq ADR 0567 §5): + // `assignments.`. The kind needs no proof, as the bus's put does not: its yes is an Approve, which only a + // channel proving who answered takes and the view never does (ViewAnswerFits). + AskKindAssignments = "assignments" +) + +// AskKey is an ask's key read whole: its Kind, and the parts its kind has: a merge's Group and heads Hash, a hold's Module +// and fingerprint FP, a restore's and a backup's Node, Module and FP, a record's Node, Module and the digest Composed of the +// composition it would allow, the assignments' Proposal. +type AskKey struct { + Proposal string + Kind string + Group string + Hash string + Node string + Module string + FP string + Composed string +} + +// AskNeedsProof says whether an ask of this kind must prove who answered it, so the view may not answer it: the merge's +// Approve does (ADR 0421 §1, issue 953), the assigner's hold and restore do not. An unknown kind is refused. +func AskNeedsProof(kind string) (bool, error) { + switch kind { + case meshdeliveryclient.AskKindMerge: + return true, nil + case AskKindHold, AskKindRestore, AskKindBackup, AskKindRecord, AskKindAssignments: + return false, nil + } + return false, fmt.Errorf("asks: %q is not a kind of ask, %s, %s, %s, %s, %s or %s", kind, meshdeliveryclient.AskKindMerge, AskKindHold, + AskKindRestore, AskKindBackup, AskKindRecord, AskKindAssignments) +} + +// HoldAskKey is the key of the ask about a module's held fingerprint: `hold..`. +func HoldAskKey(module, fingerprint string) (string, error) { + key, err := builds.BuilderBuildKey(module, fingerprint) + if err != nil { + return "", err + } + return AskKindHold + "." + key, nil +} + +// RestoreAskKey is the key of the ask about a data module set back on a node from a fingerprint: +// `restore...`. +func RestoreAskKey(node, module, fingerprint string) (string, error) { + if err := keys.CheckName("node", node); err != nil { + return "", err + } + key, err := builds.BuilderBuildKey(module, fingerprint) + if err != nil { + return "", err + } + return AskKindRestore + "." + node + "." + key, nil +} + +// BackupAskKey is the key of the ask about a module's failed backup on a node before it applies a fingerprint: +// `backup...`. +func BackupAskKey(node, module, fingerprint string) (string, error) { + part, err := meshcatalogclient.KeyPart(fingerprint) + if err != nil { + return "", err + } + key, err := nodeengineclient.ComposedKey(node, module, part) + if err != nil { + return "", err + } + return AskKindBackup + "." + key, nil +} + +// AssignmentsAskKey is the key of the authorising ask about a proposal of assignments: `assignments.` (ADR 0567 §5). +func AssignmentsAskKey(proposal string) (string, error) { + if err := meshmoduleassignerclient.CheckProposalID(proposal); err != nil { + return "", err + } + return AskKindAssignments + "." + proposal, nil +} + +// RecordAskKey is the key of the ask about a move of a module on a node to the composition whose digest it names: +// `record...`, so a new revision at the same fingerprint is asked again (ADR 0539 clause 14). +func RecordAskKey(node, module, composed string) (string, error) { + have, err := nodeengineclient.HaveKey(node, module) + if err != nil { + return "", err + } + if err := kept.CheckDigest(composed, "a record ask's composition digest"); err != nil { + return "", err + } + return AskKindRecord + "." + have + "." + strings.TrimPrefix(composed, "sha256:"), nil +} + +// ParseAnyAskKey reads an ask key of any kind (a merge, hold, restore, backup, record or assignments ask); it refuses an unknown +// kind and a key whose parts do not fit its kind, naming the key. +func ParseAnyAskKey(key string) (AskKey, error) { + kind, rest, _ := strings.Cut(key, ".") + refused := func(err error) (AskKey, error) { + return AskKey{}, fmt.Errorf("asks: %q is not an ask key: %w", key, err) + } + switch kind { + case meshdeliveryclient.AskKindMerge: + _, group, hash, err := meshdeliveryclient.ParseAskKey(key) + if err != nil { + return AskKey{}, err + } + return AskKey{Kind: kind, Group: group, Hash: hash}, nil + case AskKindHold: + module, fp, err := builds.ParseBuilderBuildKey(rest) + if err != nil { + return refused(err) + } + return AskKey{Kind: kind, Module: module, FP: fp}, nil + case AskKindRestore: + node, build, ok := strings.Cut(rest, ".") + if !ok { + return refused(fmt.Errorf("a restore ask is restore...")) + } + if err := keys.CheckName("node", node); err != nil { + return refused(err) + } + module, fp, err := builds.ParseBuilderBuildKey(build) + if err != nil { + return refused(err) + } + return AskKey{Kind: kind, Node: node, Module: module, FP: fp}, nil + case AskKindBackup: + node, module, part, isNode, err := nodeengineclient.ParseComposedKey(rest) + if err != nil || isNode { + return refused(fmt.Errorf("a backup ask is backup...")) + } + fp, err := meshcatalogclient.ParseKeyPart(part) + if err != nil { + return refused(err) + } + return AskKey{Kind: kind, Node: node, Module: module, FP: fp}, nil + case AskKindAssignments: + if err := meshmoduleassignerclient.CheckProposalID(rest); err != nil { + return refused(err) + } + return AskKey{Kind: kind, Proposal: rest}, nil + case AskKindRecord: + parts := strings.Split(rest, ".") + if len(parts) != 3 { + return refused(fmt.Errorf("a record ask is record...")) + } + node, module, isNode, err := nodeengineclient.ParseHaveKey(parts[0] + "." + parts[1]) + if err != nil || isNode { + return refused(fmt.Errorf("a record ask is record...")) + } + composed := "sha256:" + parts[2] + if err := kept.CheckDigest(composed, "a record ask's composition digest"); err != nil { + return refused(err) + } + return AskKey{Kind: kind, Node: node, Module: module, Composed: composed}, nil + } + _, err := AskNeedsProof(kind) + return refused(err) +} + +// Ask is the value of a key in the assigner's state asks: the router's ask whole, as the assigner composes it, so the teller +// publishes it unchanged. Asking counts the times the assigner wrote the ask at this key, as the gate's ask does: 0 or 1 for +// the first, n for the n-th, and the ask's id is meshdeliveryclient.AskingID(key, Asking), so each asking has its own id and +// an answer to an earlier asking is never taken for a later one. The ask is the operator-channel seat's as its JSON: its id, +// its options and its digest are checked by the assigner and the teller through operator-channel's client (novox/hq ADR +// 0627 §4). +type Ask struct { + V int `json:"v"` + Ask json.RawMessage `json:"ask"` + Asking int `json:"asking,omitempty"` +} + +func (a Ask) check(key string) error { + ask, err := ParseAnyAskKey(key) + if err != nil { + return err + } + if proof, _ := AskNeedsProof(ask.Kind); proof { + return fmt.Errorf("asks: the assigner asks no %s ask: it needs proof of who answered", ask.Kind) + } + if a.Asking < 0 { + return fmt.Errorf("asks: the ask's asking %d is negative", a.Asking) + } + var fields map[string]json.RawMessage + if len(bytes.TrimSpace(a.Ask)) == 0 || json.Unmarshal(a.Ask, &fields) != nil || fields == nil { + return fmt.Errorf("asks: the value carries no router's ask") + } + return nil +} + +// EncodeAsk is a, the ask at key, as JSON at keys.Version; it refuses a key of a kind that needs proof or of no kind, a +// negative asking, and a value carrying no router's ask. +func EncodeAsk(key string, a Ask) ([]byte, error) { + if err := a.check(key); err != nil { + return nil, err + } + a.V = keys.Version + return json.Marshal(a) +} + +// DecodeAsk reads the ask at key; it refuses an unknown version (keys.ErrUnknownVersion) and what EncodeAsk refuses. +func DecodeAsk(key string, data []byte) (Ask, error) { + var a Ask + if err := kept.Decode(data, &a); err != nil { + return Ask{}, err + } + if err := a.check(key); err != nil { + return Ask{}, err + } + return a, nil +} + +// SameAsk says whether two asks are the same value: the same version, asking and router's ask, its JSON compared compacted. +func SameAsk(a, b Ask) bool { + return a.V == b.V && a.Asking == b.Asking && bytes.Equal(compacted(a.Ask), compacted(b.Ask)) +} + +func compacted(raw json.RawMessage) []byte { + var b bytes.Buffer + if json.Compact(&b, raw) != nil { + return raw + } + return b.Bytes() +} diff --git a/modules/mesh-delivery-assigner/asks/asks_test.go b/modules/mesh-delivery-assigner/asks/asks_test.go new file mode 100644 index 00000000..c522584c --- /dev/null +++ b/modules/mesh-delivery-assigner/asks/asks_test.go @@ -0,0 +1,85 @@ +package asks + +import ( + "errors" + "strings" + "testing" + + "git.novox.be/novox/mesh-sdk/go/keys" +) + +// The keys equal what the assigner has always written (the expected strings are the trunk's own output for the same input), +// and each reads back to its parts. +func TestTheAssignersAskKeysKeepTheirFormsAndTheValueItsRules(t *testing.T) { + fp := "src2:" + strings.Repeat("ab", 32) + hex := strings.Repeat("ab", 32) + digest := "sha256:" + strings.Repeat("cd", 32) + hold, _ := HoldAskKey("gitea", fp) + restore, _ := RestoreAskKey("n1", "gitea", fp) + backup, _ := BackupAskKey("n1", "novox.be", fp) + assignments, _ := AssignmentsAskKey("p-20261028t183000-1a2b3c") + record, _ := RecordAskKey("n1", "gitea", digest) + for key, want := range map[string]struct { + got string + back AskKey + }{ + "hold": {hold, AskKey{Kind: "hold", Module: "gitea", FP: fp}}, + "restore": {restore, AskKey{Kind: "restore", Node: "n1", Module: "gitea", FP: fp}}, + "backup": {backup, AskKey{Kind: "backup", Node: "n1", Module: "novox.be", FP: fp}}, + "assignments": {assignments, AskKey{Kind: "assignments", Proposal: "p-20261028t183000-1a2b3c"}}, + "record": {record, AskKey{Kind: "record", Node: "n1", Module: "gitea", Composed: digest}}, + } { + back, err := ParseAnyAskKey(want.got) + if err != nil || back != want.back { + t.Errorf("%s: ParseAnyAskKey(%q) = %+v, %v; want %+v", key, want.got, back, err, want.back) + } + } + for got, want := range map[string]string{hold: "hold.gitea." + hex, restore: "restore.n1.gitea." + hex, backup: "backup.n1.novox=2ebe." + hex, + assignments: "assignments.p-20261028t183000-1a2b3c", record: "record.n1.gitea." + strings.Repeat("cd", 32)} { + if got != want { + t.Errorf("key %q, want %q", got, want) + } + } + merge, err := ParseAnyAskKey("merge.fix/1=2e2.d106c7950d28") + if err != nil || merge.Kind != "merge" || merge.Group != "fix/1.2" || merge.Hash != "d106c7950d28" { + t.Errorf("a merge ask key: %+v, %v", merge, err) + } + for _, bad := range []string{"", "other.x", "hold.gitea", "hold.gitea.src2:" + hex, "restore.n1", "backup.n1.node", "backup.n1.gitea." + hex[:60], + "assignment.AB", "record.n1.gitea." + hex[:10], "record.n1.node." + hex, "merge.g"} { + if _, err := ParseAnyAskKey(bad); err == nil { + t.Errorf("ParseAnyAskKey(%q) reads a wrong key", bad) + } + } + if proof, err := AskNeedsProof("merge"); err != nil || !proof { + t.Error("a merge ask needs proof") + } + if proof, err := AskNeedsProof("hold"); err != nil || proof { + t.Error("a hold ask needs no proof") + } + if _, err := AskNeedsProof("view"); err == nil { + t.Error("an unknown kind of ask is taken") + } + + // The value: the router's ask whole; an ask of a kind that needs proof, a negative asking and no ask are refused. + good := Ask{Ask: []byte(`{"id":"x"}`), Asking: 2} + data, err := EncodeAsk(hold, good) + if err != nil { + t.Fatalf("EncodeAsk: %v", err) + } + back, err := DecodeAsk(hold, data) + if err != nil || !SameAsk(back, Ask{V: keys.Version, Ask: []byte(` {"id": "x"} `), Asking: 2}) { + t.Errorf("DecodeAsk = %+v, %v", back, err) + } + for name, c := range map[string]struct { + key string + a Ask + }{"a merge ask": {"merge.g.d106c7950d28", good}, "a negative asking": {hold, Ask{Ask: good.Ask, Asking: -1}}, "no ask": {hold, Ask{}}, + "a null ask": {hold, Ask{Ask: []byte("null")}}, "an unknown kind": {"other.x", good}} { + if _, err := EncodeAsk(c.key, c.a); err == nil { + t.Errorf("EncodeAsk takes %s", name) + } + } + if _, err := DecodeAsk(hold, []byte(`{"v":2,"ask":{}}`)); !errors.Is(err, keys.ErrUnknownVersion) { + t.Errorf("DecodeAsk of another version = %v", err) + } +} diff --git a/modules/mesh-delivery-assigner/asks/go.mod b/modules/mesh-delivery-assigner/asks/go.mod new file mode 100644 index 00000000..6ed0f8c5 --- /dev/null +++ b/modules/mesh-delivery-assigner/asks/go.mod @@ -0,0 +1,23 @@ +module git.novox.be/novox/mesh-delivery/modules/mesh-delivery-assigner/asks + +go 1.26.0 + +require ( + git.novox.be/novox/mesh-contracts/go/clients/meshmoduleassignerclient v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliveryclient v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-delivery/sdk v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000 +) + +require ( + git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner v0.0.0-00010101000000-000000000000 // indirect + git.novox.be/novox/mesh-contracts/go/seats/nodeengine v0.0.0-00010101000000-000000000000 // indirect + git.novox.be/novox/mesh-delivery/contracts/go/clients/containerimageregistryclient v0.0.0-00010101000000-000000000000 // indirect + git.novox.be/novox/mesh-delivery/contracts/go/seats/containerimageregistry v0.0.0-00010101000000-000000000000 // indirect + git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog v0.0.0-00010101000000-000000000000 // indirect + git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdelivery v0.0.0-00010101000000-000000000000 // indirect + git.novox.be/novox/mesh-delivery/contracts/go/seats/ociartifactregistry v0.0.0-00010101000000-000000000000 // indirect +) diff --git a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/composed.go b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/composed.go index 60e975f8..b70b521c 100644 --- a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/composed.go +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/composed.go @@ -4,11 +4,12 @@ import ( "sort" "strings" - "git.novox.be/novox/mesh-sdk/go/delivery" + "git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient" + "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" ) // Every node's published compositions, read only to compare (novox/hq ADR 0593 §8): each node's engine writes its own -// `composed-` (delivery.ComposedRead, one bucket per node), and `mesh-wants.upgrade` with compare names, for every +// `composed-` (buckets.ComposedRead, one bucket per node), and `mesh-wants.upgrade` with compare names, for every // node, the composition its own have runs beside the newest it published of each module and of the node, so a person sees // where they part. The assigner composes nothing and never writes these. @@ -24,7 +25,7 @@ type publishedRow struct { } // published is, by node, every composition it published, sorted by key, from every key of the per-node state (a key -// names its node first): each value decoded and digested here, as the node-engine digests it (delivery.ComposedDigestOf). +// names its node first): each value decoded and digested here, as the node-engine digests it (nodeengineclient.ComposedDigest). // A value or key that does not read is named with why, never left out. func published(values map[string][]byte) map[string][]publishedRow { byNode := map[string]map[string][]byte{} @@ -40,7 +41,11 @@ func published(values map[string][]byte) map[string][]publishedRow { var rows []publishedRow for key, raw := range keys { row := publishedRow{Key: key} - _, module, fp, isNode, err := delivery.ParseComposedKey(key) + _, module, part, isNode, err := nodeengineclient.ParseComposedKey(key) + var fp string + if err == nil && !isNode { + fp, err = meshcatalogclient.ParseKeyPart(part) + } switch { case err != nil: row.Unread = err.Error() @@ -50,14 +55,14 @@ func published(values map[string][]byte) map[string][]publishedRow { row.Module, row.FP = module, fp } if err == nil { - c, derr := delivery.DecodeComposed(raw) + c, derr := nodeengineclient.DecodeComposition(raw) switch { case derr != nil: row.Unread = derr.Error() case c.Refused != "": row.Refused = c.Refused default: - if d, err := delivery.ComposedDigestOf(c); err != nil { + if d, err := nodeengineclient.ComposedDigest(c); err != nil { row.Unread = err.Error() } else { row.Digest = d diff --git a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/integration_test.go b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/integration_test.go index 32069148..6d77bef6 100644 --- a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/integration_test.go +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/integration_test.go @@ -5,32 +5,58 @@ package main import ( "context" "encoding/json" + "errors" "fmt" "io" "maps" + "mesh-delivery-assigner/internal/assignment" + "mesh-delivery-assigner/internal/buckets" + "mesh-delivery-assigner/internal/puts" "os" "path/filepath" "slices" "strings" + "sync" "testing" "time" + "git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient" + "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" + mdcc "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliverycheckerclient" + "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliveryclient" + mwc "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshwantsclient" + "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-assigner/asks" + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds" + "git.novox.be/novox/mesh-delivery/modules/mesh-grants/grantsstate" + "git.novox.be/novox/mesh-delivery/sdk/buildstep" + "github.com/nats-io/nats-server/v2/server" "github.com/nats-io/nats.go" "github.com/nats-io/nats.go/jetstream" - stdio "git.novox.be/novox/mesh-sdk/go" - "git.novox.be/novox/mesh-sdk/go/announce" - "git.novox.be/novox/mesh-sdk/go/asks" - "git.novox.be/novox/mesh-sdk/go/buscompose" - "git.novox.be/novox/mesh-sdk/go/catalogue" - "git.novox.be/novox/mesh-sdk/go/delivery" - "git.novox.be/novox/mesh-sdk/go/health" - "git.novox.be/novox/mesh-sdk/go/process" - "git.novox.be/novox/mesh-sdk/go/provisions" "mesh-delivery-assigner/internal/judge" + mmac "git.novox.be/novox/mesh-contracts/go/clients/meshmoduleassignerclient" + 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" + "git.novox.be/novox/mesh-contracts/provisioning/go/clients/meshprovisionerclient" + stdio "git.novox.be/novox/mesh-sdk/go" + "git.novox.be/novox/mesh-sdk/go/announce" + "git.novox.be/novox/mesh-sdk/go/buscompose" + "git.novox.be/novox/mesh-sdk/go/catalogue" + "git.novox.be/novox/mesh-sdk/go/process" + "git.novox.be/novox/mesh-sdk/go/seatdef" + + "git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient/enginereads" + "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" + "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient" + "git.novox.be/novox/mesh-delivery/sdk/graph" + "git.novox.be/novox/mesh-sdk/go/views" + "mesh-delivery-assigner/internal/assign" + plc "mesh-delivery-assigner/internal/assignment" ) // bed is an embedded bus with JetStream, every bucket the assigner reads and keeps, and the stream the mesh's events @@ -41,7 +67,7 @@ type bed struct { ctx context.Context dir string buckets map[string]jetstream.KeyValue - // placed is, by node, the modules the bed's placement names on it (place). + // placed is, by node, the modules the bed's assignments names on it (place). placed map[string][]string } @@ -78,7 +104,7 @@ func startBed(t *testing.T, leaveOut string) *bed { continue } cfg := jetstream.KeyValueConfig{Bucket: name, History: 5} - if name == delivery.PlacementPulsesBucket { + if name == plc.PulsesBucket { cfg.History, cfg.LimitMarkerTTL = 1, time.Minute // a time to live per key, as the controller makes it (key-ttl) } kv, err := js.CreateKeyValue(ctx, cfg) @@ -88,19 +114,34 @@ func startBed(t *testing.T, leaveOut string) *bed { b.buckets[name] = kv } // The builder's module log, as the runtime raises it (ADR 0297): the running commits are read from it (ADR 0571 clause 1). - if _, err := js.CreateStream(ctx, jetstream.StreamConfig{Name: buscompose.LogStreamName(delivery.BuilderModule, delivery.BuilderLog), - Subjects: []string{buscompose.LogSubject(delivery.BuilderModule, delivery.BuilderLog) + ".>"}, AllowDirect: true}); err != nil { + if _, err := js.CreateStream(ctx, jetstream.StreamConfig{Name: buscompose.LogStreamName(builds.BuilderModule, builds.BuilderLog), + Subjects: []string{buscompose.LogSubject(builds.BuilderModule, builds.BuilderLog) + ".>"}, AllowDirect: true}); err != nil { t.Fatal(err) } - if _, err := js.CreateStream(ctx, jetstream.StreamConfig{Name: "EVENTS", Subjects: []string{"mesh.mod.*.event.>"}}); err != nil { + if _, err := js.CreateStream(ctx, jetstream.StreamConfig{Name: "EVENTS", Subjects: []string{"mesh.mod.*.event.>", "mesh.seat.*.event.>"}}); err != nil { + t.Fatal(err) + } + // The assigner's durable consumer on EVENTS, as the grants part makes it from the manifest's consumes: a node-engine seat's + // event names the engine's machine last, so the filter is the event with every machine (mesh-grants buscompose + // machineEvent; the grants part's own test holds the derivation). + if _, err := js.CreateOrUpdateConsumer(ctx, "EVENTS", jetstream.ConsumerConfig{Durable: "n1_mesh-delivery-assigner", + FilterSubjects: []string{enginereads.SubjectPrefix + "*", "mesh.mod." + buckets.IndexerModule + ".event.views.>"}, AckPolicy: jetstream.AckExplicitPolicy}); err != nil { t.Fatal(err) } // The operator-channel seat's stream of conditions, which the assigner says its conditions on. if _, err := js.CreateStream(ctx, jetstream.StreamConfig{Name: "CONDITIONS", Subjects: []string{"mesh.seat.operator-channel.accept.condition.>"}}); err != nil { t.Fatal(err) } + // The indexer as the owner of its manifests and modules (DLV-F2r), with the real owner's side. + serveIndexer(t, b, nc) + // The assigner's views family is its ordinary events on EVENTS (ADR 0635 §5); its marks are its state. + if kv, err := js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: marksBucket, History: 1}); err != nil { + t.Fatal(err) + } else { + b.buckets[marksBucket] = kv + } // The Go build seat's work queue, as the broker derives it from the seat's accepted kinds (delivery.AskStream). - seat, _ := delivery.AskStream(delivery.AskGo) + seat, _ := buildstep.AskStream(buildstep.AskGo) if _, err := js.CreateStream(ctx, jetstream.StreamConfig{Name: seat, Subjects: []string{"mesh.seat.node-build-go.accept.>"}, Retention: jetstream.WorkQueuePolicy}); err != nil { t.Fatal(err) @@ -178,6 +219,9 @@ func saysWhatItKeeps(t *testing.T, b *bed, done <-chan error) { t.Fatalf("the assigner said %+v, want the %d states its manifest declares", said, len(m.State)) } for _, s := range m.State { + if s.Name == retiredState { + continue // read by the copy alone (migrate.go), by no reader + } if !said.Readable(s.Name) { t.Errorf("the assigner said %+v: its state %s is not readable", said, s.Name) } @@ -188,7 +232,7 @@ func saysWhatItKeeps(t *testing.T, b *bed, done <-chan error) { func TestTheAssignerDoesNotStartWithoutABucketItReads(t *testing.T) { for _, missing := range readBuckets { if startsWithout(missing) { - continue // it starts without it (the placement and the grants' source: main_test.go; the puts: below) + continue // it starts without it (the assignments and the grants' source: main_test.go; the puts: below) } b := startBed(t, missing) select { @@ -205,30 +249,30 @@ func TestTheAssignerDoesNotStartWithoutABucketItReads(t *testing.T) { was := PutsReopenEvery PutsReopenEvery = 0 defer func() { PutsReopenEvery = was }() - b := startBed(t, delivery.AssignerPutsBucket) + b := startBed(t, buckets.Puts) done := b.start(t) select { case err := <-done: - t.Fatalf("without %s the assigner stopped: %v", delivery.AssignerPutsBucket, err) + t.Fatalf("without %s the assigner stopped: %v", buckets.Puts, err) case <-time.After(2 * time.Second): } - puts, err := b.js.CreateKeyValue(b.ctx, jetstream.KeyValueConfig{Bucket: delivery.AssignerPutsBucket, History: 5}) + putsKV, err := b.js.CreateKeyValue(b.ctx, jetstream.KeyValueConfig{Bucket: buckets.Puts, History: 5}) if err != nil { t.Fatal(err) } - pkey, _ := delivery.PutKey("n1", "web") - asked := delivery.Put{FP: delivery.SourcePrefix2 + strings.Repeat("b", 64), Why: "a try", At: time.Now()}.Said(time.Now(), delivery.PutAsked, "asked") - raw, _ := delivery.EncodePut(asked) - if _, err := puts.Put(b.ctx, pkey, raw); err != nil { + pkey, _ := puts.Key("n1", "web") + asked := puts.Put{FP: meshcatalogclient.SourcePrefix2 + strings.Repeat("b", 64), Why: "a try", At: time.Now()}.Said(time.Now(), puts.Asked, "asked") + raw, _ := puts.Encode(asked) + if _, err := putsKV.Put(b.ctx, pkey, raw); err != nil { t.Fatal(err) } for deadline := time.Now().Add(15 * time.Second); ; time.Sleep(200 * time.Millisecond) { // A change of a bucket it watches starts a pass, which opens the puts' bucket. - if _, err := b.buckets[delivery.PresenceBucket].Put(b.ctx, "n1", []byte(time.Now().String())); err != nil { + if _, err := b.buckets[buckets.Presence].Put(b.ctx, "n1", []byte(time.Now().String())); err != nil { t.Fatal(err) } - if e, err := puts.Get(b.ctx, pkey); err == nil { - if got, err := delivery.DecodePut(e.Value()); err == nil && got.Step == delivery.PutRefused && strings.Contains(got.Words, "not assigned") { + if e, err := putsKV.Get(b.ctx, pkey); err == nil { + if got, err := puts.Decode(e.Value()); err == nil && got.Step == puts.Refused && strings.Contains(got.Words, "not assigned") { break } } @@ -237,26 +281,40 @@ func TestTheAssignerDoesNotStartWithoutABucketItReads(t *testing.T) { } } - // Without the seat's placement state it starts all the same, and once the bucket is made (the controller makes + // Without the seat's assignments state it starts all the same, and once the bucket is made (the controller makes // it from the manifest) it is read with no restart: a node it names gets its own want, the node composing its own. - pb := startBed(t, delivery.PlacementBucket) + pb := startBed(t, plc.AssignmentsBucket) pdone := pb.start(t) select { case err := <-pdone: - t.Fatalf("without %s the assigner stopped: %v", delivery.PlacementBucket, err) + t.Fatalf("without %s the assigner stopped: %v", plc.AssignmentsBucket, err) case <-time.After(2 * time.Second): } - placement, err := pb.js.CreateKeyValue(pb.ctx, jetstream.KeyValueConfig{Bucket: delivery.PlacementBucket, History: 5}) + // ADR 0641: the state the seat's assignments were kept in before holds n1's keys, and the form of one key per node; the + // assigner copies the keys into the state made after its start, not the second form, and leaves the old state as it is. + values, _ := mmac.AssignmentsValues(mma.Assignments{Node: "n1", Modules: []mma.PlacedModule{{Module: "web"}}}) + for _, k := range slices.Sorted(maps.Keys(values)) { // a key per module, then the node key + pb.putJSON(t, retiredBucket, k, values[k]) + } + pb.putJSON(t, retiredBucket, "placement.n1", values["n1.node"]) + assignments, err := pb.js.CreateKeyValue(pb.ctx, jetstream.KeyValueConfig{Bucket: plc.AssignmentsBucket, History: 5}) if err != nil { t.Fatal(err) } - pl, _ := delivery.EncodePlacement(delivery.Placement{Node: "n1", Modules: []delivery.PlacedModule{{Module: "web"}}}) - plkey, _ := delivery.LegacyPlacementKey("n1") // the form before, read beside the new (research 072 row 37) - if _, err := placement.Put(pb.ctx, plkey, pl); err != nil { - t.Fatal(err) + if nw, err := mwc.DecodeNodeWant(pb.get(t, buckets.Wants, "want.n1.node")); err != nil || !nw.Self { + t.Fatalf("the assignments state made after the start was not read: n1's own want = %+v, %v", nw, err) } - if nw, err := delivery.DecodeNodeWant(pb.get(t, delivery.AssignerWantsBucket, "want.n1.node")); err != nil || !nw.Self { - t.Fatalf("the placement state made after the start was not read: n1's own want = %+v, %v", nw, err) + for _, k := range slices.Sorted(maps.Keys(values)) { + if e, err := assignments.Get(pb.ctx, k); err != nil || !slices.Equal(e.Value(), values[k]) { + t.Errorf("%s was not copied into %s: %v", k, plc.AssignmentsBucket, err) + } + } + // The readers delivered before this one still read the retired state: nothing in it is deleted or written, and the end of the copy is marked in the state copies. + if _, err := pb.buckets[copiesBucket].Get(pb.ctx, copiedMarker); err != nil { + t.Errorf("the end of the copy is not marked in %s: %v", copiesBucket, err) + } + if left, err := pb.buckets[retiredBucket].Keys(pb.ctx); err != nil || len(left) != len(values)+1 { + t.Errorf("the retired state holds %v (%v), want its %d keys as they were", left, err, len(values)+1) } } @@ -268,8 +326,8 @@ func (b *bed) putJSON(t *testing.T, bucket, key string, value []byte) { } } -// place adds module to the seat's placement of node, written as the seat writes it: one node key and a key per module -// (research 072 row 37). The placement alone names where modules run (ADR 0567 §8). +// place adds module to the seat's assignments of node, written as the seat writes it: one node key and a key per module +// (research 072 row 37). The assignments alone names where modules run (ADR 0567 §8). func (b *bed) place(t *testing.T, node, module string) { t.Helper() if b.placed == nil { @@ -278,16 +336,16 @@ func (b *bed) place(t *testing.T, node, module string) { if !slices.Contains(b.placed[node], module) { b.placed[node] = append(b.placed[node], module) } - pl := delivery.Placement{Node: node} + pl := mma.Assignments{Node: node} for _, m := range b.placed[node] { - pl.Modules = append(pl.Modules, delivery.PlacedModule{Module: m}) + pl.Modules = append(pl.Modules, mma.PlacedModule{Module: m}) } - values, err := delivery.PlacementValues(pl) + values, err := mmac.AssignmentsValues(pl) if err != nil { t.Fatal(err) } for key, raw := range values { - b.putJSON(t, delivery.PlacementBucket, key, raw) + b.putJSON(t, plc.AssignmentsBucket, key, raw) } } @@ -295,29 +353,29 @@ func (b *bed) place(t *testing.T, node, module string) { // placed node composes its own (ADR 0571 §6), and the controller's composition is read no more. It answers the digest. func (b *bed) compose(t *testing.T, node string, d byte) string { t.Helper() - key, err := delivery.HaveNodeKey(node) + key, err := nodeengineclient.HaveNodeKey(node) if err != nil { t.Fatal(err) } digest := "sha256:" + strings.Repeat(string(d), 64) - v, err := delivery.EncodeHaveNode(delivery.HaveNode{Composed: digest}) + v, err := nodeengineclient.EncodeHaveNode(nodeengine.HaveByNodeNodeValue{Composed: digest}) if err != nil { t.Fatal(err) } - b.putJSON(t, delivery.HaveBucket, key, v) + b.putJSON(t, buckets.Have, key, v) return digest } // manifest keeps a manifest in the indexer's bucket by its digest and answers the digest. func (b *bed) manifest(t *testing.T, module, body string) string { t.Helper() - digest := delivery.ManifestDigest([]byte(body)) - key, _ := delivery.ManifestsKey(digest) - value, err := delivery.EncodeManifest(delivery.Manifest{Module: module, JSON: body}) + digest := meshcatalogclient.ManifestDigest([]byte(body)) + key, _ := meshcatalogclient.ManifestsKey(digest) + value, err := meshcatalogclient.EncodeManifest(meshcatalog.ManifestsByDigestValue{Module: module, Manifest: body}) if err != nil { t.Fatal(err) } - b.putJSON(t, delivery.ManifestsBucket, key, value) + b.putJSON(t, buckets.Manifests, key, value) return digest } @@ -340,95 +398,137 @@ func (b *bed) get(t *testing.T, bucket, key string) []byte { // raises the condition naming g14, web and the canary rule; once ace runs F healthy, g14's first want names F and the condition is // cleared. func TestWantsAreWrittenAndNoDifferenceIs(t *testing.T) { + // DLV-F5r (ADR 0621 §3): the indexer's runtime starts together with the assigner and answers its first views only + // after the assigner asked; within the bound the assigner writes the wants from what they answer. + up := make(chan struct{}) + indexerLate = up + // ADR 0631 §2: while the indexer does not answer, past the first-view bound the assigner raises the condition naming + // the indexer and graph, and reads nothing of the indexer's state; the answer clears it. + firstViewBound, firstViewWait = 300*time.Millisecond, 50*time.Millisecond + t.Cleanup(func() { indexerLate, firstViewBound, firstViewWait = nil, 0, 0 }) + for _, name := range append(append([]string{}, readBuckets...), watched...) { + if strings.HasPrefix(name, buckets.IndexerModule+"_") { + t.Errorf("the assigner reads the indexer's state %s itself", name) + } + } b := startBed(t, "") - fNew, fOld := delivery.SourcePrefix2+strings.Repeat("b", 64), delivery.SourcePrefix2+strings.Repeat("a", 64) + fNew, fOld := meshcatalogclient.SourcePrefix2+strings.Repeat("b", 64), meshcatalogclient.SourcePrefix2+strings.Repeat("a", 64) digest := b.manifest(t, "web", `{"module":"web","version":"1"}`) - mod, _ := delivery.EncodeModule(delivery.Module{FP2: fNew, Manifest: digest, Repository: "novox/web", Commit: strings.Repeat("1", 40), Path: delivery.RootPath}) - b.putJSON(t, delivery.ModulesBucket, "web", mod) - built, err := delivery.EncodeBuilderBuild(delivery.BuilderBuild{State: delivery.BuildDone, By: "builder.novox.1", Step: "pushed", Manifest: digest}) + mod, _ := meshcatalogclient.EncodeModule(meshcatalog.ModulesByModuleValue{Fp2: fNew, Manifest: digest, Repository: "novox/web", Commit: strings.Repeat("1", 40), Path: meshcatalogclient.RootPath}) + b.putJSON(t, buckets.Modules, "web", mod) + built, err := builds.EncodeBuilderBuild(builds.BuilderBuild{State: builds.BuildDone, By: "builder.novox.1", Step: "pushed", Manifest: digest}) if err != nil { t.Fatal(err) } - bkey, _ := delivery.BuilderBuildKey("web", fNew) - b.putJSON(t, delivery.BuilderBuildsBucket, bkey, built) + bkey, _ := builds.BuilderBuildKey("web", fNew) + b.putJSON(t, buckets.Builds, bkey, built) for _, fp := range []string{fNew, fOld} { - gkey, _ := delivery.PartGrantKey("web", fp) - grant, _ := delivery.EncodeGrant(delivery.Grant{}) - b.putJSON(t, delivery.PartGrantsBucket, gkey, grant) + gkey, _ := grantsstate.PartGrantKey("web", fp) + grant, _ := grantsstate.EncodeGrant(grantsstate.Grant{}) + b.putJSON(t, buckets.Grants, gkey, grant) } runs := map[string]string{"ace": fOld, "g14": "build-9", "shanks": fOld} for _, node := range []string{"ace", "g14", "shanks"} { b.compose(t, node, 'c') - on, _ := delivery.EncodePresence(delivery.Presence{Online: true}) - b.putJSON(t, delivery.PresenceBucket, node, on) - have, _ := delivery.EncodeHave(delivery.Have{FP: runs[node], Phase: delivery.PhaseRunning, Health: delivery.HealthOK}) - b.putJSON(t, delivery.HaveBucket, node+".web", have) + on, _ := presenceValue(true) + b.putJSON(t, buckets.Presence, node, on) + have, _ := nodeengineclient.EncodeHave(nodeengine.HaveByNodeModuleValue{Fp: runs[node], Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthOK}) + b.putJSON(t, buckets.Have, node+".web", have) b.place(t, node, "web") } b.compose(t, "shanks", 'a') b.start(t) + // DLV-F5r (ADR 0621 §1-§3, design 25 § 12 on a fresh mesh): the assigner asks nothing of the bus module (its manifest + // has no bus-state entry, the bed runs no bus module and holds no binding of one), and while it still waits on the + // indexer its runtime of its own answers each first-view tool to the readers its manifest's callers lists name, at + // its mark, and refuses every other account, the assigner's own included. + firstViewsBeforeAnyNewFormState(t, b, map[string][]string{ + "assignments": {"n1.mesh-grants", "n1.node-tools", "n1.mesh-delivery-indexer"}, + "seat-holders": {"n1.node-tools"}, + "wants": {"n1.mesh-grants"}, + }) + graphKey := meshconditionsclient.Key(meshconditionsclient.ScopeModule, assignment.Module+"."+buckets.IndexerModule+"."+graph.State, views.UnansweredKind) + graphRaised := func() bool { + e, err := b.buckets[assign.FindingsBucket].Get(b.ctx, graphKey) + return err == nil && strings.Contains(string(e.Value()), buckets.IndexerModule+".graph") + } + for deadline := time.Now().Add(10 * time.Second); !graphRaised(); time.Sleep(50 * time.Millisecond) { + if time.Now().After(deadline) { + t.Fatalf("with the indexer down, no condition names its graph (finding %s)", graphKey) + } + } + close(up) + for deadline := time.Now().Add(10 * time.Second); graphRaised(); time.Sleep(50 * time.Millisecond) { + if time.Now().After(deadline) { + t.Fatalf("the indexer answered, and the condition naming its graph stands (finding %s)", graphKey) + } + } - wkey, _ := delivery.WantKey("ace", "web") - want, err := delivery.DecodeWant(b.get(t, delivery.AssignerWantsBucket, wkey)) - if err != nil || want.FP != fNew { + wkey, _ := mwc.WantKey("ace", "web") + want, err := mwc.DecodeWant(b.get(t, buckets.Wants, wkey)) + if err != nil || want.Fp != fNew { t.Fatalf("the canary ace's want = %+v, %v", want, err) } - if w := b.wantNow(t, "shanks", "web", "shanks' first want at what it runs", func(w delivery.Want) bool { return w.FP != "" }); w.FP != fOld || w.Composed != "" || !w.Self || w.Newest != fNew { + if w := b.wantNow(t, "shanks", "web", "shanks' first want at what it runs", func(w mwc.Want) bool { return w.Fp != "" }); w.Fp != fOld || w.Composed != "" || !w.Self || w.Newest != fNew { t.Errorf("shanks' want = %+v, want the fingerprint it runs, the node composing it, held back from F", w) } - var neither asks.Condition + // DLV-O5 (ADR 0615 §5, ADR 0619 §2): each want is said on the assigner's views family, its value whole at its revision, + // and the first view at the mark followed by those events holds it as the bucket does; a waiter on put never wakes. + b.viewsHold(t, "wants", wkey) + var neither oc.Condition for deadline := time.Now().Add(10 * time.Second); time.Now().Before(deadline) && neither.Key == ""; time.Sleep(100 * time.Millisecond) { for k, c := range b.conditionsSaid(t) { - if strings.HasSuffix(k, ".neither") && c.State == asks.StateRaised { + if strings.HasSuffix(k, ".neither") && c.State == occ.StateRaised { neither = c } } } - if neither.Subject.Machine != "g14" || !strings.HasPrefix(neither.Subject.ID, "g14.web") || neither.Severity != asks.SeverityUrgent || !strings.Contains(neither.Explanation, "canary") { + if neither.Subject.Machine != "g14" || !strings.HasPrefix(neither.Subject.ID, "g14.web") || neither.Severity != occ.SeverityUrgent || !strings.Contains(neither.Explanation, "canary") { t.Errorf("the condition of web on neither list on g14 = %+v", neither) } - if keys, err := b.buckets[delivery.AssignerDifferencesBucket].Keys(b.ctx); err == nil && len(keys) != 0 { + if keys, err := b.buckets[buckets.Differences].Keys(b.ctx); err == nil && len(keys) != 0 { t.Errorf("differences were written: %v", keys) } - if e, err := b.buckets[delivery.AssignerWantsBucket].Get(b.ctx, "want.g14.web"); err == nil { + if e, err := b.buckets[buckets.Wants].Get(b.ctx, "want.g14.web"); err == nil { t.Errorf("g14, which runs an old-form build and is held back by the canary, was given a want: %s", e.Value()) } // 1945/AC21: once the canary ace runs F healthy, g14's first want names F by the rules as usual, and web leaves neither list // there by itself: the condition is cleared. - healthyF, _ := delivery.EncodeHave(delivery.Have{FP: fNew, Phase: delivery.PhaseRunning, Health: delivery.HealthOK}) - b.putJSON(t, delivery.HaveBucket, "ace.web", healthyF) - if w := b.wantNow(t, "g14", "web", "g14's first want at F once the canary runs it healthy", func(w delivery.Want) bool { return w.FP != "" }); w.FP != fNew || w.Composed != "" || !w.Self || w.Newest != "" { + healthyF, _ := nodeengineclient.EncodeHave(nodeengine.HaveByNodeModuleValue{Fp: fNew, Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthOK}) + b.putJSON(t, buckets.Have, "ace.web", healthyF) + if w := b.wantNow(t, "g14", "web", "g14's first want at F once the canary runs it healthy", func(w mwc.Want) bool { return w.Fp != "" }); w.Fp != fNew || w.Composed != "" || !w.Self || w.Newest != "" { t.Errorf("g14's want = %+v, want F, the node composing it", w) } cleared := false for deadline := time.Now().Add(10 * time.Second); time.Now().Before(deadline) && !cleared; time.Sleep(100 * time.Millisecond) { c, ok := b.conditionsSaid(t)[neither.Key] - cleared = ok && c.State == asks.StateCleared + cleared = ok && c.State == occ.StateCleared } if !cleared { t.Errorf("the condition of web on neither list on g14 was not cleared once g14 got its want") } - // Issue 1126 (ADR 0547 §2): the catalogue's placement reaches the indexer's state naming web on every node the + // Issue 1126 (ADR 0547 §2): the catalogue's assignments reaches the indexer's state naming web on every node the // assignments name it on. Each node then composes its own: its want names the build alone (Self, no composition), and // its own want says it composes its own, which the node agent reads (mesh-host's TestNats...ComposesAndAppliesItsOwn). for _, node := range []string{"ace", "g14", "shanks"} { - pl, err := delivery.EncodePlacement(delivery.Placement{Node: node, Modules: []delivery.PlacedModule{{Module: "web"}}}) + values, err := mmac.AssignmentsValues(mma.Assignments{Node: node, Modules: []mma.PlacedModule{{Module: "web"}}}) if err != nil { t.Fatal(err) } - pkey, _ := delivery.LegacyPlacementKey(node) - b.putJSON(t, delivery.PlacementBucket, pkey, pl) + for _, k := range slices.Sorted(maps.Keys(values)) { + b.putJSON(t, plc.AssignmentsBucket, k, values[k]) + } } - if w := b.wantNow(t, "ace", "web", "ace's want once placed: the build alone, composed by the node", func(w delivery.Want) bool { return w.Self }); w.Composed != "" || w.FP != fNew { + if w := b.wantNow(t, "ace", "web", "ace's want once placed: the build alone, composed by the node", func(w mwc.Want) bool { return w.Self }); w.Composed != "" || w.Fp != fNew { t.Errorf("ace's want once placed = %+v", w) } - nkey, _ := delivery.WantNodeKey("ace") + nkey, _ := mwc.WantNodeKey("ace") var self bool for deadline := time.Now().Add(10 * time.Second); time.Now().Before(deadline) && !self; time.Sleep(100 * time.Millisecond) { - if e, err := b.buckets[delivery.AssignerWantsBucket].Get(b.ctx, nkey); err == nil { - nw, err := delivery.DecodeNodeWant(e.Value()) + if e, err := b.buckets[buckets.Wants].Get(b.ctx, nkey); err == nil { + nw, err := mwc.DecodeNodeWant(e.Value()) self = err == nil && nw.Self } } @@ -439,7 +539,7 @@ func TestWantsAreWrittenAndNoDifferenceIs(t *testing.T) { // ADR 0603 (L3, D15): one source revision changes a provider (db), its new consumer (app) and a build module (tool, whose // recipe moved: ace runs its first build and the second, under the agreed toolchain, is the one the source revision names). // The keys are written as their writers write them: the indexer's modules and manifests, the builder's builds, the - // controller's grants, the placement seat's placement and the provisioner's provisions. The build module's want moves at + // controller's grants, the assignments seat's assignments and the provisioner's provisions. The build module's want moves at // once and the provider's with it; the consumer's waits for its provision, with the words naming it, and moves once the // provisioner provides it (rule 8, ADR 0570 §4): nothing waits for a turn or an order, only for a key. offer := `"provides":[{"name":"database","scope":"mesh","answered-by":"helper"}]` @@ -447,49 +547,175 @@ func TestWantsAreWrittenAndNoDifferenceIs(t *testing.T) { appDigest := b.manifest(t, "app", `{"module":"app","version":"1","requires":["database"]}`) toolDigest := b.manifest(t, "tool", `{"module":"tool","version":"1"}`) asked := func(state, why string) { - raw, err := provisions.Encode(provisions.Provision{V: provisions.Version, Provision: "database", Consumer: provisions.End{Module: "app", Node: "ace"}, - Provider: provisions.End{Module: "db", Node: "ace"}, State: state, Why: why, At: time.Now()}) + raw, err := meshprovisionerclient.Encode(meshprovisionerclient.Provision{V: meshprovisionerclient.Version, Provision: "database", Consumer: meshprovisionerclient.End{Module: "app", Node: "ace"}, + Provider: meshprovisionerclient.End{Module: "db", Node: "ace"}, State: state, Why: why, At: time.Now()}) if err != nil { t.Fatal(err) } - b.putJSON(t, provisions.ProvisionsBucket, "ace.app.database", raw) + b.putJSON(t, buckets.Provisions, "ace.app.database", raw) } // The provisioner keys it from the manifests, as soon as they are known and whether the provider is placed yet or not. - asked(provisions.Asked, "the provider's build is not done") - fpOf := func(c byte) string { return delivery.SourcePrefix2 + strings.Repeat(string(c), 64) } + asked(meshprovisionerclient.Asked, "the provider's build is not done") + fpOf := func(c byte) string { return meshcatalogclient.SourcePrefix2 + strings.Repeat(string(c), 64) } toolOld := fpOf('1') for module, row := range map[string]struct { fp, digest string }{"db": {fpOf('2'), dbDigest}, "app": {fpOf('3'), appDigest}, "tool": {fpOf('4'), toolDigest}} { - mod, _ := delivery.EncodeModule(delivery.Module{FP2: row.fp, Manifest: row.digest, Repository: "novox/" + module, Commit: strings.Repeat("2", 40), Path: delivery.RootPath}) - b.putJSON(t, delivery.ModulesBucket, module, mod) + mod, _ := meshcatalogclient.EncodeModule(meshcatalog.ModulesByModuleValue{Fp2: row.fp, Manifest: row.digest, Repository: "novox/" + module, Commit: strings.Repeat("2", 40), Path: meshcatalogclient.RootPath}) + b.putJSON(t, buckets.Modules, module, mod) fps := []string{row.fp} if module == "tool" { fps = append(fps, toolOld) } for _, fp := range fps { - built, _ := delivery.EncodeBuilderBuild(delivery.BuilderBuild{State: delivery.BuildDone, By: "builder.novox.1", Step: "pushed", Manifest: row.digest}) - bkey, _ := delivery.BuilderBuildKey(module, fp) - b.putJSON(t, delivery.BuilderBuildsBucket, bkey, built) - gkey, _ := delivery.PartGrantKey(module, fp) - grant, _ := delivery.EncodeGrant(delivery.Grant{}) - b.putJSON(t, delivery.PartGrantsBucket, gkey, grant) + built, _ := builds.EncodeBuilderBuild(builds.BuilderBuild{State: builds.BuildDone, By: "builder.novox.1", Step: "pushed", Manifest: row.digest}) + bkey, _ := builds.BuilderBuildKey(module, fp) + b.putJSON(t, buckets.Builds, bkey, built) + gkey, _ := grantsstate.PartGrantKey(module, fp) + grant, _ := grantsstate.EncodeGrant(grantsstate.Grant{}) + b.putJSON(t, buckets.Grants, gkey, grant) } b.place(t, "ace", module) } - runsOld, _ := delivery.EncodeHave(delivery.Have{FP: toolOld, Phase: delivery.PhaseRunning, Health: delivery.HealthOK}) - b.putJSON(t, delivery.HaveBucket, "ace.tool", runsOld) - if w := b.wantNow(t, "ace", "tool", "the build module's want at the second build", func(w delivery.Want) bool { return w.FP == fpOf('4') }); w.FP != fpOf('4') { + runsOld, _ := nodeengineclient.EncodeHave(nodeengine.HaveByNodeModuleValue{Fp: toolOld, Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthOK}) + b.putJSON(t, buckets.Have, "ace.tool", runsOld) + if w := b.wantNow(t, "ace", "tool", "the build module's want at the second build", func(w mwc.Want) bool { return w.Fp == fpOf('4') }); w.Fp != fpOf('4') { t.Errorf("tool's want = %+v", w) } - b.wantNow(t, "ace", "db", "the provider's want at its fingerprint", func(w delivery.Want) bool { return w.FP == fpOf('2') }) - if e, err := b.buckets[delivery.AssignerWantsBucket].Get(b.ctx, "want.ace.app"); err == nil { + b.wantNow(t, "ace", "db", "the provider's want at its fingerprint", func(w mwc.Want) bool { return w.Fp == fpOf('2') }) + if e, err := b.buckets[buckets.Wants].Get(b.ctx, "want.ace.app"); err == nil { t.Errorf("app was wanted while its provision was asked: %s", e.Value()) } - asked(provisions.Provided, "") - if w := b.wantNow(t, "ace", "app", "the consumer's want once its provision is provided", func(w delivery.Want) bool { return w.FP == fpOf('3') }); w.FP != fpOf('3') { + asked(meshprovisionerclient.Provided, "") + if w := b.wantNow(t, "ace", "app", "the consumer's want once its provision is provided", func(w mwc.Want) bool { return w.Fp == fpOf('3') }); w.Fp != fpOf('3') { t.Errorf("app's want = %+v", w) } + + // ADR 0618 §1, §3, §4, ADR 0624 §2: ace's engine answers the node-engine seat's manifest-reads; a build written only in a + // form it does not state is held on ace, naming the form, and moves once the engine says manifest-reads-changed on its + // subject and states the form; no timer moves it. g14's engine answers nothing and has no record: read as the baseline. + stated := catalogue.ManifestReads() + var statedMu sync.Mutex + nc, err := nats.Connect(b.srv.ClientURL()) + if err != nil { + t.Fatal(err) + } + defer nc.Close() + if _, err := nc.Subscribe("mesh.seat.node-engine.tool.manifest-reads.ace", func(m *nats.Msg) { + statedMu.Lock() + answer, _ := json.Marshal(nodeengine.ManifestReadsAnswer{V: 1, Node: "ace", ManifestReads: stated, StatedAt: time.Now().Format(time.RFC3339Nano)}) + statedMu.Unlock() + reply, _ := json.Marshal(map[string]any{"result": json.RawMessage(answer), "node": "ace"}) + _ = m.Respond(reply) + }); err != nil { + t.Fatal(err) + } + // The responder's interest is on the bus before the event that asks it is said: the assigner asks a node once per + // manifest-reads-changed and never on a timer, so an ask that lands before this subscription reached the server is + // answered by nobody and is never asked again (the group check of 616c42434ac2 on a loaded build node). + subscribed(t, nc) + say := func(why string) { + body, _ := json.Marshal(nodeengine.ManifestReadsChangedEvent{V: 1, Node: "ace", ManifestReads: stated, At: time.Now().Format(time.RFC3339Nano), Why: why}) + if _, err := b.js.Publish(b.ctx, enginereads.SubjectPrefix+"ace", body); err != nil { + t.Fatal(err) + } + } + say(enginereads.ReadsStarted) + formsOld, formsNew := fpOf('5'), fpOf('6') + formsDigest := b.manifest(t, "forms", `{"module":"forms","version":"1"}`) + formsAt := func(fp string) { + mod, _ := meshcatalogclient.EncodeModule(meshcatalog.ModulesByModuleValue{Fp2: fp, Manifest: formsDigest, Repository: "novox/forms", Commit: strings.Repeat("3", 40), Path: meshcatalogclient.RootPath}) + b.putJSON(t, buckets.Modules, "forms", mod) + } + for _, fp := range []string{formsOld, formsNew} { + build := builds.BuilderBuild{State: builds.BuildDone, By: "builder.novox.1", Step: "pushed", Manifest: formsDigest} + if fp == formsNew { + build.Needs = []string{"form:new"} + } + built, err := builds.EncodeBuilderBuild(build) + if err != nil { + t.Fatal(err) + } + bkey, _ := builds.BuilderBuildKey("forms", fp) + b.putJSON(t, buckets.Builds, bkey, built) + gkey, _ := grantsstate.PartGrantKey("forms", fp) + grant, _ := grantsstate.EncodeGrant(grantsstate.Grant{}) + b.putJSON(t, buckets.Grants, gkey, grant) + } + runsFormsOld, _ := nodeengineclient.EncodeHave(nodeengine.HaveByNodeModuleValue{Fp: formsOld, Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthOK}) + b.putJSON(t, buckets.Have, "ace.forms", runsFormsOld) + b.place(t, "ace", "forms") + formsAt(formsOld) + b.wantNow(t, "ace", "forms", "the want at what ace runs, a build in the first form", func(w mwc.Want) bool { return w.Fp == formsOld }) + formsAt(formsNew) + statement := func(node string, ok func(enginereads.Statement) bool) enginereads.Statement { + var last enginereads.Statement + for deadline := time.Now().Add(15 * time.Second); time.Now().Before(deadline); time.Sleep(50 * time.Millisecond) { + if e, err := b.buckets[engineReadsBucket].Get(b.ctx, node); err == nil { + if last, err = enginereads.Decode(e.Value()); err == nil && ok(last) { + return last + } + } + } + t.Fatalf("%s's engine reads are %+v", node, last) + return last + } + statement("ace", func(s enginereads.Statement) bool { return s.From == enginereads.FromVerb && s.Answered }) + if s := statement("g14", func(s enginereads.Statement) bool { return !s.Answered }); s.From != enginereads.FromNone { + t.Errorf("g14, whose engine answers nothing and which has no record, reads %+v", s) + } + if w := b.wantNow(t, "ace", "forms", "the want held on ace, naming the form", func(w mwc.Want) bool { + return w.Newest == formsNew && strings.Contains(w.Words, "form:new") + }); w.Fp != formsOld || w.Rule != mwc.RuleComposed { + t.Errorf("forms' want = %+v, want it held at what ace runs under rule 7", w) + } + statedMu.Lock() + stated = append(slices.Clone(stated), "form:new") + statedMu.Unlock() + say(enginereads.ReadsStarted) + b.wantNow(t, "ace", "forms", "the want moved once ace's engine said it reads the form", func(w mwc.Want) bool { return w.Fp == formsNew }) + // assigner_engine_reads asks ace's engine again through the state: the assigner answers there with what it states now. + again, err := enginereads.AskAgainOf(bedState{b, engineReadsBucket}, "ace", "n1.ada", "a person asks", time.Now(), 15*time.Second) + if err != nil || again.From != enginereads.FromVerb || !slices.Contains(again.ManifestReads, "form:new") || again.AskAgain != nil { + t.Errorf("the ask again of ace answered %+v, %v", again, err) + } +} + +// subscribed returns once the server holds every subscription nc made: nats.go sends a SUB from its flusher, after +// Subscribe has returned, so a responder or listener the test makes is not yet heard by a call or event another +// connection sends at once. A flush's round trip is the barrier; no sleep stands in for it. +func subscribed(t *testing.T, nc *nats.Conn) { + t.Helper() + if err := nc.Flush(); err != nil { + t.Fatalf("the test's subscriptions did not reach the bus: %v", err) + } +} + +// bedState is one of the bed's buckets as the engine reads' tool reads it. +type bedState struct { + b *bed + bucket string +} + +func (s bedState) Keys() ([]string, error) { return s.b.buckets[s.bucket].Keys(s.b.ctx) } +func (s bedState) Get(key string) ([]byte, uint64, bool, error) { + e, err := s.b.buckets[s.bucket].Get(s.b.ctx, key) + if err != nil { + return nil, 0, false, nil + } + return e.Value(), e.Revision(), true, nil +} +func (s bedState) Put(key string, value []byte) (uint64, error) { + return s.b.buckets[s.bucket].Put(s.b.ctx, key, value) +} +func (s bedState) Delete(key string) error { return s.b.buckets[s.bucket].Delete(s.b.ctx, key) } +func (s bedState) Await(key string, after uint64, done func([]byte) bool, bound time.Duration) ([]byte, error) { + for deadline := time.Now().Add(bound); time.Now().Before(deadline); time.Sleep(50 * time.Millisecond) { + if v, rev, found, _ := s.Get(key); found && rev > after && done(v) { + return v, nil + } + } + return nil, fmt.Errorf("no answer at %s within %s", key, bound) } // 1297/B9 (the assigner's half, ADR 0528 clause 6): started whole, the assigner holds a fingerprint whose health check @@ -497,25 +723,25 @@ func TestWantsAreWrittenAndNoDifferenceIs(t *testing.T) { // the one its manifest declares (ownBuckets, held equal to the manifest by TestOpensWhatItsManifestNames). func TestAHeldFingerprintIsAskedAbout(t *testing.T) { b := startBed(t, "") - fNew := delivery.SourcePrefix2 + strings.Repeat("c", 64) + fNew := meshcatalogclient.SourcePrefix2 + strings.Repeat("c", 64) digest := b.manifest(t, "web", `{"module":"web","version":"1"}`) - mod, _ := delivery.EncodeModule(delivery.Module{FP2: fNew, Manifest: digest, Repository: "novox/web", Commit: strings.Repeat("1", 40), Path: delivery.RootPath}) - b.putJSON(t, delivery.ModulesBucket, "web", mod) - on, _ := delivery.EncodePresence(delivery.Presence{Online: true}) - b.putJSON(t, delivery.PresenceBucket, "ace", on) - failed, _ := delivery.EncodeHave(delivery.Have{FP: fNew, Phase: delivery.PhaseRunning, Health: delivery.HealthFailed, Detail: "connection refused"}) - b.putJSON(t, delivery.HaveBucket, "ace.web", failed) + mod, _ := meshcatalogclient.EncodeModule(meshcatalog.ModulesByModuleValue{Fp2: fNew, Manifest: digest, Repository: "novox/web", Commit: strings.Repeat("1", 40), Path: meshcatalogclient.RootPath}) + b.putJSON(t, buckets.Modules, "web", mod) + on, _ := presenceValue(true) + b.putJSON(t, buckets.Presence, "ace", on) + failed, _ := nodeengineclient.EncodeHave(nodeengine.HaveByNodeModuleValue{Fp: fNew, Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthFailed, Detail: "connection refused"}) + b.putJSON(t, buckets.Have, "ace.web", failed) b.place(t, "ace", "web") b.start(t) - hkey, _ := delivery.HeldKey("web", fNew) + hkey, _ := mwc.HeldKey("web", fNew) // 1906/H3: the held record names the machine and keeps the health, when and the node's words. - if h, err := delivery.DecodeHeld(b.get(t, delivery.AssignerWantsBucket, hkey)); err != nil || h.Node != "ace" || + if h, err := mwc.DecodeHeld(b.get(t, buckets.Wants, hkey)); err != nil || h.Node != "ace" || !strings.HasPrefix(h.Words, "health failed, phase running, seen at ") || !strings.HasSuffix(h.Words, ": connection refused") { t.Fatalf("held = %+v, %v", h, err) } - akey, _ := delivery.HoldAskKey("web", fNew) - a, err := delivery.DecodeAssignerAsk(akey, b.get(t, delivery.AssignerAsksBucket, akey)) - if err != nil || a.Asking <= 0 || a.Ask.ID != delivery.AskingID(akey, a.Asking) || len(a.Ask.Options) != 2 { + akey, _ := asks.HoldAskKey("web", fNew) + a, err := decodeAsk(akey, b.get(t, buckets.Asks, akey)) + if err != nil || a.Asking <= 0 || a.Ask.ID != meshdeliveryclient.AskingID(akey, a.Asking) || len(a.Ask.Options) != 2 { t.Fatalf("the ask = %+v, %v", a, err) } } @@ -523,27 +749,27 @@ func TestAHeldFingerprintIsAskedAbout(t *testing.T) { // conditionsSaid reads what the assigner said of its conditions, by finding key: each a finding in its own bucket, which // the condition keeper carries (novox/hq ADR 0590 §3, issue 1126), raised while it stands and cleared once deleted, each // with the words its last finding said and the subject the finding names (`[.]`). -func (b *bed) conditionsSaid(t *testing.T) map[string]asks.Condition { +func (b *bed) conditionsSaid(t *testing.T) map[string]oc.Condition { t.Helper() w, err := b.buckets[assign.FindingsBucket].WatchAll(b.ctx, jetstream.IncludeHistory()) if err != nil { t.Fatal(err) } defer w.Stop() - out := map[string]asks.Condition{} + out := map[string]oc.Condition{} for e := range w.Updates() { if e == nil { return out } c := out[e.Key()] - c.Key, c.State = e.Key(), asks.StateCleared + c.Key, c.State = e.Key(), occ.StateCleared if e.Operation() == jetstream.KeyValuePut { - f, err := health.DecodeFinding(e.Value()) + f, err := meshconditionsclient.DecodeFinding(e.Value()) if err != nil { t.Fatal(err) } - c = asks.Condition{Key: e.Key(), State: asks.StateRaised, Severity: string(f.Severity), Explanation: f.Explanation, - Resolved: f.Resolved, Subject: asks.Scope{Scope: string(f.Subject.Scope), ID: f.Subject.ID, Machine: f.Subject.Machine}} + c = oc.Condition{Key: e.Key(), State: occ.StateRaised, Severity: string(f.Severity), Explanation: f.Explanation, + Resolved: f.Resolved, Subject: oc.ConditionSubject{Scope: string(f.Subject.Scope), ID: f.Subject.ID, Machine: f.Subject.Machine}} } out[e.Key()] = c } @@ -555,32 +781,32 @@ func (b *bed) conditionsSaid(t *testing.T) map[string]asks.Condition { // and the check's words. func TestAFailedDataModuleIsAskedToRestore(t *testing.T) { b := startBed(t, "") - fNew := delivery.SourcePrefix2 + strings.Repeat("d", 64) + fNew := meshcatalogclient.SourcePrefix2 + strings.Repeat("d", 64) digest := b.manifest(t, "mail", `{"module":"mail","version":"1","resources":[{"id":"data","type":"directory","path":"/var/lib/mail"}],"data":{"own":[{"id":"mail","path":"${dir:data}","class":"irreplaceable","backup":"copy"}]}}`) - mod, _ := delivery.EncodeModule(delivery.Module{FP2: fNew, Manifest: digest, Repository: "novox/mail", Commit: strings.Repeat("1", 40), Path: delivery.RootPath}) - b.putJSON(t, delivery.ModulesBucket, "mail", mod) - on, _ := delivery.EncodePresence(delivery.Presence{Online: true}) - b.putJSON(t, delivery.PresenceBucket, "ace", on) - failed, _ := delivery.EncodeHave(delivery.Have{FP: fNew, Phase: delivery.PhaseRunning, Health: delivery.HealthFailed, Detail: "connection refused"}) - b.putJSON(t, delivery.HaveBucket, "ace.mail", failed) - wantKey, _ := delivery.WantKey("ace", "mail") - want, _ := delivery.EncodeWant(delivery.Want{FP: fNew, Composed: "sha256:" + strings.Repeat("c", 64)}) - b.putJSON(t, delivery.AssignerWantsBucket, wantKey, want) + mod, _ := meshcatalogclient.EncodeModule(meshcatalog.ModulesByModuleValue{Fp2: fNew, Manifest: digest, Repository: "novox/mail", Commit: strings.Repeat("1", 40), Path: meshcatalogclient.RootPath}) + b.putJSON(t, buckets.Modules, "mail", mod) + on, _ := presenceValue(true) + b.putJSON(t, buckets.Presence, "ace", on) + failed, _ := nodeengineclient.EncodeHave(nodeengine.HaveByNodeModuleValue{Fp: fNew, Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthFailed, Detail: "connection refused"}) + b.putJSON(t, buckets.Have, "ace.mail", failed) + wantKey, _ := mwc.WantKey("ace", "mail") + want, _ := mwc.EncodeWant(mwc.Want{Fp: fNew, Composed: "sha256:" + strings.Repeat("c", 64)}) + b.putJSON(t, buckets.Wants, wantKey, want) b.place(t, "ace", "mail") b.start(t) - akey, _ := delivery.RestoreAskKey("ace", "mail", fNew) - a, err := delivery.DecodeAssignerAsk(akey, b.get(t, delivery.AssignerAsksBucket, akey)) + akey, _ := asks.RestoreAskKey("ace", "mail", fNew) + a, err := decodeAsk(akey, b.get(t, buckets.Asks, akey)) if err != nil || len(a.Ask.Options) != 2 || a.Ask.Options[0].ID != "restore" || a.Ask.Options[1].ID != "leave-stopped" { t.Fatalf("the restore ask = %+v, %v", a, err) } - if w, err := delivery.DecodeWant(b.get(t, delivery.AssignerWantsBucket, wantKey)); err != nil || w.FP != fNew { + if w, err := mwc.DecodeWant(b.get(t, buckets.Wants, wantKey)); err != nil || w.Fp != fNew { t.Errorf("the data module was set back: want = %+v, %v", w, err) } key := "" for deadline := time.Now().Add(10 * time.Second); time.Now().Before(deadline) && key == ""; time.Sleep(100 * time.Millisecond) { for k, c := range b.conditionsSaid(t) { - if c.State == asks.StateRaised && strings.HasPrefix(c.Subject.ID, "mail.") && strings.HasSuffix(k, ".held") { + if c.State == occ.StateRaised && strings.HasPrefix(c.Subject.ID, "mail.") && strings.HasSuffix(k, ".held") { if c.Subject.Machine != "ace" || !strings.Contains(c.Explanation, "connection refused") { t.Fatalf("the condition = %+v, want it to name ace and the check's words", c) } @@ -599,30 +825,30 @@ func TestAFailedDataModuleIsAskedToRestore(t *testing.T) { func TestTheAssignerReplacedResumesWithNothingLostOrRepeated(t *testing.T) { b := startBed(t, "") const modules = 40 - on, _ := delivery.EncodePresence(delivery.Presence{Online: true}) - b.putJSON(t, delivery.PresenceBucket, "ace", on) + on, _ := presenceValue(true) + b.putJSON(t, buckets.Presence, "ace", on) names := make([]string, modules) for i := range names { name := fmt.Sprintf("mod%02d", i) names[i] = name - fp := delivery.SourcePrefix2 + strings.Repeat(string(rune('a'+i%6)), 63) + fmt.Sprintf("%x", i%16) + fp := meshcatalogclient.SourcePrefix2 + strings.Repeat(string(rune('a'+i%6)), 63) + fmt.Sprintf("%x", i%16) digest := b.manifest(t, name, fmt.Sprintf(`{"module":%q,"version":"1"}`, name)) - mod, _ := delivery.EncodeModule(delivery.Module{FP2: fp, Manifest: digest, Repository: "novox/" + name, Commit: strings.Repeat("1", 40), Path: delivery.RootPath}) - b.putJSON(t, delivery.ModulesBucket, name, mod) - built, err := delivery.EncodeBuilderBuild(delivery.BuilderBuild{State: delivery.BuildDone, By: "builder.novox.1", Step: "pushed", Manifest: digest}) + mod, _ := meshcatalogclient.EncodeModule(meshcatalog.ModulesByModuleValue{Fp2: fp, Manifest: digest, Repository: "novox/" + name, Commit: strings.Repeat("1", 40), Path: meshcatalogclient.RootPath}) + b.putJSON(t, buckets.Modules, name, mod) + built, err := builds.EncodeBuilderBuild(builds.BuilderBuild{State: builds.BuildDone, By: "builder.novox.1", Step: "pushed", Manifest: digest}) if err != nil { t.Fatal(err) } - bkey, _ := delivery.BuilderBuildKey(name, fp) - b.putJSON(t, delivery.BuilderBuildsBucket, bkey, built) - gkey, _ := delivery.PartGrantKey(name, fp) - grant, _ := delivery.EncodeGrant(delivery.Grant{}) - b.putJSON(t, delivery.PartGrantsBucket, gkey, grant) + bkey, _ := builds.BuilderBuildKey(name, fp) + b.putJSON(t, buckets.Builds, bkey, built) + gkey, _ := grantsstate.PartGrantKey(name, fp) + grant, _ := grantsstate.EncodeGrant(grantsstate.Grant{}) + b.putJSON(t, buckets.Grants, gkey, grant) b.compose(t, "ace", 'c') b.place(t, "ace", name) } count := func() int { - keys, err := b.buckets[delivery.AssignerWantsBucket].Keys(b.ctx) + keys, err := b.buckets[buckets.Wants].Keys(b.ctx) if err != nil { return 0 } @@ -637,8 +863,8 @@ func TestTheAssignerReplacedResumesWithNothingLostOrRepeated(t *testing.T) { } time.Sleep(time.Second) // and no pass of the second writes any again for _, name := range names { - key, _ := delivery.WantKey("ace", name) - history, err := b.buckets[delivery.AssignerWantsBucket].History(b.ctx, key) + key, _ := mwc.WantKey("ace", name) + history, err := b.buckets[buckets.Wants].History(b.ctx, key) if err != nil { t.Errorf("the want of %s was never written: %v", name, err) continue @@ -653,37 +879,37 @@ func TestTheAssignerReplacedResumesWithNothingLostOrRepeated(t *testing.T) { func (b *bed) fixture(t *testing.T, name, fp, manifest string) { t.Helper() digest := b.manifest(t, name, manifest) - mod, _ := delivery.EncodeModule(delivery.Module{FP2: fp, Manifest: digest, Repository: "novox/" + name, Commit: strings.Repeat("1", 40), Path: delivery.RootPath}) - b.putJSON(t, delivery.ModulesBucket, name, mod) - built, _ := delivery.EncodeBuilderBuild(delivery.BuilderBuild{State: delivery.BuildDone, By: "builder.novox.1", Step: "pushed", Manifest: digest}) - bkey, _ := delivery.BuilderBuildKey(name, fp) - b.putJSON(t, delivery.BuilderBuildsBucket, bkey, built) - gkey, _ := delivery.PartGrantKey(name, fp) - grant, _ := delivery.EncodeGrant(delivery.Grant{}) - b.putJSON(t, delivery.PartGrantsBucket, gkey, grant) - on, _ := delivery.EncodePresence(delivery.Presence{Online: true}) - b.putJSON(t, delivery.PresenceBucket, "ace", on) + mod, _ := meshcatalogclient.EncodeModule(meshcatalog.ModulesByModuleValue{Fp2: fp, Manifest: digest, Repository: "novox/" + name, Commit: strings.Repeat("1", 40), Path: meshcatalogclient.RootPath}) + b.putJSON(t, buckets.Modules, name, mod) + built, _ := builds.EncodeBuilderBuild(builds.BuilderBuild{State: builds.BuildDone, By: "builder.novox.1", Step: "pushed", Manifest: digest}) + bkey, _ := builds.BuilderBuildKey(name, fp) + b.putJSON(t, buckets.Builds, bkey, built) + gkey, _ := grantsstate.PartGrantKey(name, fp) + grant, _ := grantsstate.EncodeGrant(grantsstate.Grant{}) + b.putJSON(t, buckets.Grants, gkey, grant) + on, _ := presenceValue(true) + b.putJSON(t, buckets.Presence, "ace", on) b.place(t, "ace", name) } -func (b *bed) putWant(t *testing.T, node, module string, w delivery.Want) { +func (b *bed) putWant(t *testing.T, node, module string, w mwc.Want) { t.Helper() - key, _ := delivery.WantKey(node, module) - v, err := delivery.EncodeWant(w) + key, _ := mwc.WantKey(node, module) + v, err := mwc.EncodeWant(w) if err != nil { t.Fatal(err) } - b.putJSON(t, delivery.AssignerWantsBucket, key, v) + b.putJSON(t, buckets.Wants, key, v) } // wantNow waits until the want of module on node satisfies ok and answers it. -func (b *bed) wantNow(t *testing.T, node, module, what string, ok func(delivery.Want) bool) delivery.Want { +func (b *bed) wantNow(t *testing.T, node, module, what string, ok func(mwc.Want) bool) mwc.Want { t.Helper() - key, _ := delivery.WantKey(node, module) - var last delivery.Want + key, _ := mwc.WantKey(node, module) + var last mwc.Want for deadline := time.Now().Add(15 * time.Second); time.Now().Before(deadline); time.Sleep(50 * time.Millisecond) { - if e, err := b.buckets[delivery.AssignerWantsBucket].Get(b.ctx, key); err == nil { - if w, err := delivery.DecodeWant(e.Value()); err == nil { + if e, err := b.buckets[buckets.Wants].Get(b.ctx, key); err == nil { + if w, err := mwc.DecodeWant(e.Value()); err == nil { last = w if ok(w) { return w @@ -698,7 +924,7 @@ func (b *bed) wantNow(t *testing.T, node, module, what string, ok func(delivery. // answer says the teller wrote the operator's answer to the ask that stands at key (its warrant names that asking's id). func (b *bed) answer(t *testing.T, key, option string) { t.Helper() - a, err := delivery.DecodeAssignerAsk(key, b.get(t, delivery.AssignerAsksBucket, key)) + a, err := decodeAsk(key, b.get(t, buckets.Asks, key)) if err != nil { t.Fatal(err) } @@ -708,11 +934,11 @@ func (b *bed) answer(t *testing.T, key, option string) { // answerTo is answer to the ask with this id (the router's warrant names it). func (b *bed) answerTo(t *testing.T, key, option, askID string) { t.Helper() - v, err := delivery.EncodeAnswer(delivery.Answer{Option: option, Warrant: askID + "@2026-10-24T12:00:00Z", Words: "the operator"}) + v, err := meshdeliveryclient.EncodeAnswer(meshdeliveryclient.Answer{Option: option, Warrant: askID + "@2026-10-24T12:00:00Z", Words: "the operator"}) if err != nil { t.Fatal(err) } - b.putJSON(t, delivery.AnswersBucket, key, v) + b.putJSON(t, buckets.Answers, key, v) } func (b *bed) hasKey(bucket, key string) bool { @@ -724,31 +950,31 @@ func (b *bed) hasKey(bucket, key string) bool { // and clears the condition. func TestAReleaseAnswerWritesTheWantAgain(t *testing.T) { b := startBed(t, "") - fNew, fOld := delivery.SourcePrefix2+strings.Repeat("b", 64), delivery.SourcePrefix2+strings.Repeat("a", 64) + fNew, fOld := meshcatalogclient.SourcePrefix2+strings.Repeat("b", 64), meshcatalogclient.SourcePrefix2+strings.Repeat("a", 64) b.fixture(t, "web", fNew, `{"module":"web","version":"1"}`) b.compose(t, "ace", 'b') b.compose(t, "ace", 'a') - healthy, _ := delivery.EncodeHave(delivery.Have{FP: fOld, Phase: delivery.PhaseRunning, Health: delivery.HealthOK}) - b.putJSON(t, delivery.HaveBucket, "ace.web", healthy) // it ran the older fingerprint healthy before - failed, _ := delivery.EncodeHave(delivery.Have{FP: fNew, Phase: delivery.PhaseRunning, Health: delivery.HealthFailed, Detail: "connection refused"}) - b.putJSON(t, delivery.HaveBucket, "ace.web", failed) - b.putWant(t, "ace", "web", delivery.Want{FP: fNew, Composed: "sha256:" + strings.Repeat("b", 64)}) + healthy, _ := nodeengineclient.EncodeHave(nodeengine.HaveByNodeModuleValue{Fp: fOld, Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthOK}) + b.putJSON(t, buckets.Have, "ace.web", healthy) // it ran the older fingerprint healthy before + failed, _ := nodeengineclient.EncodeHave(nodeengine.HaveByNodeModuleValue{Fp: fNew, Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthFailed, Detail: "connection refused"}) + b.putJSON(t, buckets.Have, "ace.web", failed) + b.putWant(t, "ace", "web", mwc.Want{Fp: fNew, Composed: "sha256:" + strings.Repeat("b", 64)}) b.start(t) - w := b.wantNow(t, "ace", "web", "set back to the older fingerprint", func(w delivery.Want) bool { return w.FP == fOld }) + w := b.wantNow(t, "ace", "web", "set back to the older fingerprint", func(w mwc.Want) bool { return w.Fp == fOld }) if w.Newest != fNew { t.Fatalf("the set-back want = %+v", w) } - hold, _ := delivery.HoldAskKey("web", fNew) - b.get(t, delivery.AssignerAsksBucket, hold) + hold, _ := asks.HoldAskKey("web", fNew) + b.get(t, buckets.Asks, hold) b.answer(t, hold, "release") - w = b.wantNow(t, "ace", "web", "the held fingerprint's want written again", func(w delivery.Want) bool { return w.FP == fNew }) + w = b.wantNow(t, "ace", "web", "the held fingerprint's want written again", func(w mwc.Want) bool { return w.Fp == fNew }) if w.Composed != "" || !w.Self || w.Rule != 0 { t.Errorf("the want = %+v, want F by the rules, the node composing it", w) } for deadline := time.Now().Add(10 * time.Second); time.Now().Before(deadline); time.Sleep(100 * time.Millisecond) { cleared := false for k, c := range b.conditionsSaid(t) { - cleared = cleared || (strings.HasPrefix(k, "module.web.") && c.State == asks.StateCleared && strings.HasSuffix(k, ".held")) + cleared = cleared || (strings.HasPrefix(k, "module.web.") && c.State == occ.StateCleared && strings.HasSuffix(k, ".held")) } if cleared { return @@ -758,17 +984,17 @@ func TestAReleaseAnswerWritesTheWantAgain(t *testing.T) { } // askNow waits for the backup ask at key to be the one numbered asking and answers it. -func (b *bed) askNow(t *testing.T, key string, asking int) delivery.AssignerAsk { +func (b *bed) askNow(t *testing.T, key string, asking int) decodedAsk { t.Helper() for deadline := time.Now().Add(15 * time.Second); time.Now().Before(deadline); time.Sleep(50 * time.Millisecond) { - if e, err := b.buckets[delivery.AssignerAsksBucket].Get(b.ctx, key); err == nil { - if a, err := delivery.DecodeAssignerAsk(key, e.Value()); err == nil && a.Asking == asking { + if e, err := b.buckets[buckets.Asks].Get(b.ctx, key); err == nil { + if a, err := decodeAsk(key, e.Value()); err == nil && a.Asking == asking { return a } } } t.Fatalf("the ask at %s never became asking %d", key, asking) - return delivery.AssignerAsk{} + return decodedAsk{} } // 1906/D4 (ADR 0539 §16), whole on the bus: a module held on a node because its backup failed is asked about, and the answer @@ -776,38 +1002,38 @@ func (b *bed) askNow(t *testing.T, key string, asking int) delivery.AssignerAsk // value, so the node retries. func TestABackupAnswerIsWrittenIntoTheNodesWant(t *testing.T) { b := startBed(t, "") - fNew := delivery.SourcePrefix2 + strings.Repeat("b", 64) + fNew := meshcatalogclient.SourcePrefix2 + strings.Repeat("b", 64) b.fixture(t, "web", fNew, `{"module":"web","version":"1"}`) b.compose(t, "ace", 'b') - b.putWant(t, "ace", "web", delivery.Want{FP: fNew, Self: true}) // a placed node's want names the build alone - held, _ := delivery.EncodeHave(delivery.Have{FP: fNew, Phase: delivery.PhaseHeld, Health: delivery.HealthWaits, Detail: "no space left"}) - b.putJSON(t, delivery.HaveBucket, "ace.web", held) + b.putWant(t, "ace", "web", mwc.Want{Fp: fNew, Self: true}) // a placed node's want names the build alone + held, _ := nodeengineclient.EncodeHave(nodeengine.HaveByNodeModuleValue{Fp: fNew, Phase: nodeengineclient.PhaseHeld, Health: nodeengineclient.HealthWaits, Detail: "no space left"}) + b.putJSON(t, buckets.Have, "ace.web", held) b.start(t) - akey, _ := delivery.BackupAskKey("ace", "web", fNew) + akey, _ := asks.BackupAskKey("ace", "web", fNew) a := b.askNow(t, akey, 0) if !strings.Contains(a.Ask.Explanation, "no space left") { t.Fatalf("the backup ask = %+v", a) } // an answer to another ask is no answer to this one - b.answerTo(t, akey, delivery.BackupSkip, "someone-else") + b.answerTo(t, akey, mwc.BackupSkip, "someone-else") time.Sleep(time.Second) - if w := b.wantNow(t, "ace", "web", "the want", func(delivery.Want) bool { return true }); w.Backup != "" { + if w := b.wantNow(t, "ace", "web", "the want", func(mwc.Want) bool { return true }); w.Backup != "" { t.Fatalf("an answer to another ask was taken: %+v", w) } - b.answerTo(t, akey, delivery.BackupAgain, a.Ask.ID) - w1 := b.wantNow(t, "ace", "web", "the answer in the want", func(w delivery.Want) bool { return w.Backup == delivery.BackupAgain }) - if w1.FP != fNew || w1.Composed != "" || !w1.Self { + b.answerTo(t, akey, mwc.BackupAgain, a.Ask.ID) + w1 := b.wantNow(t, "ace", "web", "the answer in the want", func(w mwc.Want) bool { return w.Backup == mwc.BackupAgain }) + if w1.Fp != fNew || w1.Composed != "" || !w1.Self { t.Errorf("the want = %+v", w1) } // the node retries and the backup fails again: a new have value, asked again under the next number - again, _ := delivery.EncodeHave(delivery.Have{FP: fNew, Phase: delivery.PhaseHeld, Health: delivery.HealthWaits, Detail: "no space left, again"}) - b.putJSON(t, delivery.HaveBucket, "ace.web", again) + again, _ := nodeengineclient.EncodeHave(nodeengine.HaveByNodeModuleValue{Fp: fNew, Phase: nodeengineclient.PhaseHeld, Health: nodeengineclient.HealthWaits, Detail: "no space left, again"}) + b.putJSON(t, buckets.Have, "ace.web", again) a2 := b.askNow(t, akey, 2) if a2.Ask.ID == a.Ask.ID { t.Fatalf("asked again under the same id") } - b.answerTo(t, akey, delivery.BackupAgain, a2.Ask.ID) - w2 := b.wantNow(t, "ace", "web", "the second answer another want value", func(w delivery.Want) bool { return w.Backup == delivery.BackupAgain && w.Words != w1.Words }) + b.answerTo(t, akey, mwc.BackupAgain, a2.Ask.ID) + w2 := b.wantNow(t, "ace", "web", "the second answer another want value", func(w mwc.Want) bool { return w.Backup == mwc.BackupAgain && w.Words != w1.Words }) if w2.Words == w1.Words { t.Errorf("the second answer left the want as it was: %+v", w2) } @@ -820,55 +1046,55 @@ const recorded = `{"module":"web","version":"1","upgrade":{"policy":"record","wh // cleared saying why, and the want stays at the fingerprint. func TestAHoldIsLiftedOnceEveryNodeRunsItHealthy(t *testing.T) { b := startBed(t, "") - fNew := delivery.SourcePrefix2 + strings.Repeat("b", 64) + fNew := meshcatalogclient.SourcePrefix2 + strings.Repeat("b", 64) b.fixture(t, "web", fNew, `{"module":"web","version":"1"}`) b.compose(t, "ace", 'b') - failed, _ := delivery.EncodeHave(delivery.Have{FP: fNew, Phase: delivery.PhaseRunning, Health: delivery.HealthFailed, Detail: "connection refused"}) - b.putJSON(t, delivery.HaveBucket, "ace.web", failed) - b.putWant(t, "ace", "web", delivery.Want{FP: fNew, Composed: "sha256:" + strings.Repeat("b", 64)}) + failed, _ := nodeengineclient.EncodeHave(nodeengine.HaveByNodeModuleValue{Fp: fNew, Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthFailed, Detail: "connection refused"}) + b.putJSON(t, buckets.Have, "ace.web", failed) + b.putWant(t, "ace", "web", mwc.Want{Fp: fNew, Composed: "sha256:" + strings.Repeat("b", 64)}) b.start(t) - hkey, _ := delivery.HeldKey("web", fNew) - akey, _ := delivery.HoldAskKey("web", fNew) - b.get(t, delivery.AssignerWantsBucket, hkey) - b.get(t, delivery.AssignerAsksBucket, akey) - healthy, _ := delivery.EncodeHave(delivery.Have{FP: fNew, Phase: delivery.PhaseRunning, Health: delivery.HealthOK}) - b.putJSON(t, delivery.HaveBucket, "ace.web", healthy) + hkey, _ := mwc.HeldKey("web", fNew) + akey, _ := asks.HoldAskKey("web", fNew) + b.get(t, buckets.Wants, hkey) + b.get(t, buckets.Asks, akey) + healthy, _ := nodeengineclient.EncodeHave(nodeengine.HaveByNodeModuleValue{Fp: fNew, Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthOK}) + b.putJSON(t, buckets.Have, "ace.web", healthy) for deadline := time.Now().Add(15 * time.Second); time.Now().Before(deadline); time.Sleep(100 * time.Millisecond) { - if b.hasKey(delivery.AssignerWantsBucket, hkey) || b.hasKey(delivery.AssignerAsksBucket, akey) { + if b.hasKey(buckets.Wants, hkey) || b.hasKey(buckets.Asks, akey) { continue } for k, c := range b.conditionsSaid(t) { - if strings.HasPrefix(k, "module.web.") && c.State == asks.StateCleared && strings.HasSuffix(k, ".held") { - if w := b.wantNow(t, "ace", "web", "the want at the lifted fingerprint", func(w delivery.Want) bool { return w.FP == fNew }); w.Rule != 0 { + if strings.HasPrefix(k, "module.web.") && c.State == occ.StateCleared && strings.HasSuffix(k, ".held") { + if w := b.wantNow(t, "ace", "web", "the want at the lifted fingerprint", func(w mwc.Want) bool { return w.Fp == fNew }); w.Rule != 0 { t.Errorf("the want = %+v", w) } return } } } - t.Fatalf("the hold was not lifted: held %v, ask %v", b.hasKey(delivery.AssignerWantsBucket, hkey), b.hasKey(delivery.AssignerAsksBucket, akey)) + t.Fatalf("the hold was not lifted: held %v, ask %v", b.hasKey(buckets.Wants, hkey), b.hasKey(buckets.Asks, akey)) } // 1906/H2, whole on the bus: a hold lifted by itself and written again after the assigner was started again is asked under a // new id, so the teller raises it; the answer to the first hold does not carry over (1906/H4). func TestAHoldWrittenAgainAfterALiftAndARestartIsAskedAgain(t *testing.T) { b := startBed(t, "") - fNew := delivery.SourcePrefix2 + strings.Repeat("b", 64) + fNew := meshcatalogclient.SourcePrefix2 + strings.Repeat("b", 64) b.fixture(t, "web", fNew, `{"module":"web","version":"1"}`) b.compose(t, "ace", 'b') - failed, _ := delivery.EncodeHave(delivery.Have{FP: fNew, Phase: delivery.PhaseRunning, Health: delivery.HealthFailed, Detail: "connection refused"}) - healthy, _ := delivery.EncodeHave(delivery.Have{FP: fNew, Phase: delivery.PhaseRunning, Health: delivery.HealthOK}) - b.putJSON(t, delivery.HaveBucket, "ace.web", failed) - b.putWant(t, "ace", "web", delivery.Want{FP: fNew, Composed: "sha256:" + strings.Repeat("b", 64)}) + failed, _ := nodeengineclient.EncodeHave(nodeengine.HaveByNodeModuleValue{Fp: fNew, Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthFailed, Detail: "connection refused"}) + healthy, _ := nodeengineclient.EncodeHave(nodeengine.HaveByNodeModuleValue{Fp: fNew, Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthOK}) + b.putJSON(t, buckets.Have, "ace.web", failed) + b.putWant(t, "ace", "web", mwc.Want{Fp: fNew, Composed: "sha256:" + strings.Repeat("b", 64)}) _, stop := b.startStoppable(t) - hkey, _ := delivery.HeldKey("web", fNew) - akey, _ := delivery.HoldAskKey("web", fNew) - first, err := delivery.DecodeAssignerAsk(akey, b.get(t, delivery.AssignerAsksBucket, akey)) + hkey, _ := mwc.HeldKey("web", fNew) + akey, _ := asks.HoldAskKey("web", fNew) + first, err := decodeAsk(akey, b.get(t, buckets.Asks, akey)) if err != nil { t.Fatal(err) } - b.putJSON(t, delivery.HaveBucket, "ace.web", healthy) - for deadline := time.Now().Add(15 * time.Second); b.hasKey(delivery.AssignerWantsBucket, hkey); time.Sleep(100 * time.Millisecond) { + b.putJSON(t, buckets.Have, "ace.web", healthy) + for deadline := time.Now().Add(15 * time.Second); b.hasKey(buckets.Wants, hkey); time.Sleep(100 * time.Millisecond) { if time.Now().After(deadline) { t.Fatal("the hold was not lifted") } @@ -876,17 +1102,17 @@ func TestAHoldWrittenAgainAfterALiftAndARestartIsAskedAgain(t *testing.T) { stop() time.Sleep(1100 * time.Millisecond) // the next hold is written in another second b.answerTo(t, akey, "keep", first.Ask.ID) // an answer to the first hold, which the later hold must not take - b.putJSON(t, delivery.HaveBucket, "ace.web", failed) + b.putJSON(t, buckets.Have, "ace.web", failed) b.start(t) - again, err := delivery.DecodeAssignerAsk(akey, b.get(t, delivery.AssignerAsksBucket, akey)) + again, err := decodeAsk(akey, b.get(t, buckets.Asks, akey)) if err != nil { t.Fatal(err) } if again.Ask.ID == first.Ask.ID { t.Fatalf("the hold written again is asked under the first id %s", first.Ask.ID) } - b.putJSON(t, delivery.HaveBucket, "ace.web", healthy) - for deadline := time.Now().Add(15 * time.Second); b.hasKey(delivery.AssignerWantsBucket, hkey); time.Sleep(100 * time.Millisecond) { + b.putJSON(t, buckets.Have, "ace.web", healthy) + for deadline := time.Now().Add(15 * time.Second); b.hasKey(buckets.Wants, hkey); time.Sleep(100 * time.Millisecond) { if time.Now().After(deadline) { t.Fatal("the stale \"keep\" of the first hold kept the second one held") } @@ -901,7 +1127,7 @@ func (s bedStore) kv(state string) jetstream.KeyValue { if strings.Contains(state, ".") { return s.b.buckets[strings.Replace(state, ".", "_", 1)] } - return s.b.buckets[delivery.AssignerModule+"_"+state] + return s.b.buckets[assignment.Module+"_"+state] } func (s bedStore) get(state, key string) ([]byte, uint64, bool, error) { @@ -929,66 +1155,67 @@ func (s bedStore) del(state, key string) error { return s.kv(state).Delete(s.b.c func (s bedStore) history(string, string) ([]stdio.StateRevision, error) { return nil, nil } -// 1126 (ADR 0567 §2-§6), the seat mesh-placement whole on the bus: a proposal moving web from the anchor to the laptop is +// 1126 (ADR 0567 §2-§6), the seat mesh-module-assigner whole on the bus: a proposal moving web from the anchor to the laptop is // proposed through the verb, the assigner finds both nodes must answer, they answer the same in their haves, the -// operator is asked and approves, and each key is written at a new revision while placement-changed is heard on the +// operator is asked and approves, and each key is written at a new revision while assignments-changed is heard on the // assigner's stream naming both nodes. A second proposal laid over the old revision of a key moved since is laid again, // and declined, writes nothing and is withdrawn. func TestAProposalIsAnsweredApprovedAndWritten(t *testing.T) { b := startBed(t, "") - if _, err := b.js.CreateStream(b.ctx, jetstream.StreamConfig{Name: "MOD_" + delivery.AssignerModule + "_placement", - Subjects: []string{process.ModuleStreamSubject(delivery.AssignerModule, ">")}}); err != nil { + if _, err := b.js.CreateStream(b.ctx, jetstream.StreamConfig{Name: "MOD_" + assignment.Module + "_assignments", + Subjects: assignmentsSubjects()}); err != nil { // the seat's four events: the views family has a stream of its own t.Fatal(err) } - heard := make(chan delivery.PlacementEvent, 16) + heard := make(chan mma.AssignmentsEvent, 16) nc, err := nats.Connect(b.srv.ClientURL()) if err != nil { t.Fatal(err) } t.Cleanup(nc.Close) - if _, err := nc.Subscribe(process.ModuleStreamSubject(delivery.AssignerModule, delivery.PlacementChangedEvent), func(m *nats.Msg) { - var e delivery.PlacementEvent + if _, err := nc.Subscribe(seatdef.EventSubject(mma.Seat, mma.EventAssignmentsChanged), func(m *nats.Msg) { + var e mma.AssignmentsEvent if json.Unmarshal(m.Data, &e) == nil { heard <- e } }); err != nil { t.Fatal(err) } + subscribed(t, nc) for _, node := range []string{"anchor", "laptop"} { - rec, _ := delivery.EncodeHaveNode(delivery.HaveNode{Record: &delivery.NodeRecord{Node: node}}) - b.putJSON(t, delivery.HaveBucket, node+".node", rec) + rec, _ := nodeengineclient.EncodeHaveNode(nodeengine.HaveByNodeNodeValue{Record: &nodeengine.NodeRecord{Node: node}}) + b.putJSON(t, buckets.Have, node+".node", rec) } - b.putJSON(t, delivery.ModulesBucket, "web", []byte(`{"v":1}`)) - b.putJSON(t, delivery.ModulesBucket, "shell", []byte(`{"v":1}`)) - // place writes a node's placement as another writer would, in its node and module keys (research 072 row 37), and - // answers its node key's revision; legacy writes it in the form before, placement., alone. - place := func(node string, legacy bool, modules ...string) uint64 { - p := delivery.Placement{Node: node, Modules: []delivery.PlacedModule{}} + b.putJSON(t, buckets.Modules, "web", []byte(`{"v":1}`)) + b.putJSON(t, buckets.Modules, "shell", []byte(`{"v":1}`)) + b.putJSON(t, buckets.Modules, "mesh-enrolment", []byte(`{"v":1}`)) + // place writes a node's assignments as another writer would, in its node and module keys (research 072 row 37), and + // answers its node key's revision. + place := func(node string, modules ...string) uint64 { + p := mma.Assignments{Node: node, Modules: []mma.PlacedModule{}} for _, m := range modules { - p.Modules = append(p.Modules, delivery.PlacedModule{Module: m}) - } - values, _ := delivery.PlacementValues(p) - key, _ := delivery.PlacementNodeKey(node) - if legacy { - key, _ = delivery.LegacyPlacementKey(node) - values = map[string][]byte{key: values[key]} + p.Modules = append(p.Modules, mma.PlacedModule{Module: m}) + if m == "mesh-enrolment" { // the module that holds the seat mesh-node-enroller: who may add a node is read from it + p.Holds = append(p.Holds, mma.Holding{Claim: "mesh-node-enroller", Scope: "mesh", Module: m}) + } } + values, _ := mmac.AssignmentsValues(p) + key, _ := mmac.NodeKey(node) for _, k := range slices.Sorted(maps.Keys(values)) { if k == key { continue } - if _, err := b.buckets[delivery.PlacementBucket].Put(b.ctx, k, values[k]); err != nil { + if _, err := b.buckets[plc.AssignmentsBucket].Put(b.ctx, k, values[k]); err != nil { t.Fatal(err) } } - rev, err := b.buckets[delivery.PlacementBucket].Put(b.ctx, key, values[key]) + rev, err := b.buckets[plc.AssignmentsBucket].Put(b.ctx, key, values[key]) if err != nil { t.Fatal(err) } return rev } - place("anchor", true, "web") - laptopRev := place("laptop", false, "shell") + place("anchor", "web") + laptopRev := place("laptop", "shell") bound := judge.AnswerBound t.Cleanup(func() { judge.AnswerBound = bound }) judge.AnswerBound = 2 * time.Second @@ -996,9 +1223,9 @@ func TestAProposalIsAnsweredApprovedAndWritten(t *testing.T) { // The holder converts anchor, held in the form before alone, to its node and module keys once at its start. var anchorRev uint64 for deadline := time.Now().Add(15 * time.Second); anchorRev == 0; time.Sleep(50 * time.Millisecond) { - if e, err := b.buckets[delivery.PlacementBucket].Get(b.ctx, "anchor.node"); err == nil { + if e, err := b.buckets[plc.AssignmentsBucket].Get(b.ctx, "anchor.node"); err == nil { anchorRev = e.Revision() - if _, err := b.buckets[delivery.PlacementBucket].Get(b.ctx, "anchor.web"); err != nil { + if _, err := b.buckets[plc.AssignmentsBucket].Get(b.ctx, "anchor.web"); err != nil { t.Fatalf("anchor converted without its module key: %v", err) } } @@ -1007,7 +1234,7 @@ func TestAProposalIsAnsweredApprovedAndWritten(t *testing.T) { } } verbs := newSeatTools(bedStore{b}, func(string, any) (json.RawMessage, error) { return json.RawMessage(`{"ok":true}`), nil }) - propose := func() delivery.PlacementProposal { + propose := func() mma.Proposal { t.Helper() got, err := verbs.doPropose(map[string]any{"why": "move web", "changes": `[{"op":"take-off","node":"anchor","module":"web"},` + `{"op":"place","node":"laptop","module":"web"}]`}, "operator@laptop") @@ -1016,12 +1243,12 @@ func TestAProposalIsAnsweredApprovedAndWritten(t *testing.T) { } return got.(proposed).Proposal } - proposalNow := func(id, what string, ok func(delivery.PlacementProposal) bool) delivery.PlacementProposal { + proposalNow := func(id, what string, ok func(mma.Proposal) bool) mma.Proposal { t.Helper() - var last delivery.PlacementProposal + var last mma.Proposal for deadline := time.Now().Add(15 * time.Second); time.Now().Before(deadline); time.Sleep(50 * time.Millisecond) { - if e, err := b.buckets[delivery.PlacementProposalsBucket].Get(b.ctx, id); err == nil { - if p, err := delivery.DecodePlacementProposal(e.Value()); err == nil { + if e, err := b.buckets[plc.ProposalsBucket].Get(b.ctx, id); err == nil { + if p, err := plc.DecodeProposal(e.Value()); err == nil { if last = p; ok(p) { return p } @@ -1031,56 +1258,52 @@ func TestAProposalIsAnsweredApprovedAndWritten(t *testing.T) { t.Fatalf("the proposal %s never was %s: %+v", id, what, last) return last } - answerAll := func(p delivery.PlacementProposal) { + answerAll := func(p mma.Proposal) { t.Helper() for _, node := range p.Must { - key, _ := delivery.PlacementCandidateKey(p.ID, node) - e, err := b.buckets[delivery.PlacementCandidatesBucket].Get(b.ctx, key) + key, _ := plc.CandidateKey(p.ID, node) + e, err := b.buckets[plc.CandidatesBucket].Get(b.ctx, key) if err != nil { t.Fatalf("no candidate %s: %v", key, err) } - have, _ := delivery.EncodeHaveNode(delivery.HaveNode{Record: &delivery.NodeRecord{Node: node}, Candidates: map[string]delivery.CandidateAnswer{ - p.ID: {Proposal: p.ID, Revision: e.Revision(), Verdict: delivery.CandidateSame, Same: []string{"node"}}}}) - b.putJSON(t, delivery.HaveBucket, node+".node", have) + have, _ := nodeengineclient.EncodeHaveNode(nodeengine.HaveByNodeNodeValue{Record: &nodeengine.NodeRecord{Node: node}, Candidates: map[string]nodeengine.CandidateAnswer{ + p.ID: {Proposal: p.ID, Revision: int64(e.Revision()), Verdict: nodeengine.CandidateAnswerVerdictSame, Same: []string{"node"}}}}) + b.putJSON(t, buckets.Have, node+".node", have) } } p := propose() - p = proposalNow(p.ID, "told who must answer", func(p delivery.PlacementProposal) bool { return len(p.Must) > 0 }) + p = proposalNow(p.ID, "told who must answer", func(p mma.Proposal) bool { return len(p.Must) > 0 }) if !slices.Equal(p.Must, []string{"anchor", "laptop"}) { t.Fatalf("must answer = %v, want both nodes", p.Must) } answerAll(p) - p = proposalNow(p.ID, "asked", func(p delivery.PlacementProposal) bool { return p.State == delivery.ProposalAsked }) - akey, _ := delivery.PlacementAskKey(p.ID) + p = proposalNow(p.ID, "asked", func(p mma.Proposal) bool { return p.State == mmac.ProposalAsked }) + akey, _ := asks.AssignmentsAskKey(p.ID) b.answer(t, akey, assign.OptionYes) select { case e := <-heard: - if e.Proposal.ID != p.ID || len(e.Nodes) != 2 || e.Nodes[0].Before != anchorRev || e.Nodes[1].Before != laptopRev || - e.Nodes[0].After <= anchorRev || e.Warrant == nil || !slices.Equal(e.Nodes[0].Removed, []string{"web"}) { - t.Errorf("placement-changed = %+v", e) + if e.Proposal.ID != p.ID || len(e.Nodes) != 2 || changeIn(e.Nodes[0]).Before != anchorRev || changeIn(e.Nodes[1]).Before != laptopRev || + changeIn(e.Nodes[0]).After <= anchorRev || e.Warrant == nil || !slices.Equal(changeIn(e.Nodes[0]).Removed, []string{"web"}) { + t.Errorf("assignments-changed = %+v", e) } case <-time.After(15 * time.Second): - t.Fatal("no placement-changed heard") + t.Fatal("no assignments-changed heard") } for node, want := range map[string][]string{"anchor": {}, "laptop": {"shell", "web"}} { - // The node key names the modules, each module key carries its own, and the mirror of the form before follows. + // The node key names the modules, each module key carries its own. values := map[string][]byte{} - for _, key := range []string{node + ".node", "placement." + node, node + ".web", node + ".shell"} { - if e, err := b.buckets[delivery.PlacementBucket].Get(b.ctx, key); err == nil { + for _, key := range []string{node + ".node", node + ".web", node + ".shell"} { + if e, err := b.buckets[plc.AssignmentsBucket].Get(b.ctx, key); err == nil { values[key] = e.Value() } } - got, err := delivery.PlacementsOf(values) - if err != nil || !slices.Equal(got[node].Assigned(), want) { - t.Errorf("%s = %v, %v, want %v", node, got[node].Assigned(), err, want) - } - mirror, _ := delivery.DecodePlacement(values["placement."+node]) - if !slices.Equal(mirror.Assigned(), want) { - t.Errorf("the mirror placement.%s = %v, want %v", node, mirror.Assigned(), want) + got, err := mmac.AssignmentsOf(values) + if err != nil || !slices.Equal(mmac.Assigned(got[node]), want) { + t.Errorf("%s = %v, %v, want %v", node, mmac.Assigned(got[node]), err, want) } } - if b.hasKey(delivery.PlacementProposalsBucket, p.ID) { + if b.hasKey(plc.ProposalsBucket, p.ID) { t.Error("an applied proposal stays in the state") } @@ -1090,21 +1313,21 @@ func TestAProposalIsAnsweredApprovedAndWritten(t *testing.T) { t.Fatal(err) } moved := q.(proposed).Proposal - place("laptop", false, "shell", "web") // another writer moved the key - relaid := proposalNow(moved.ID, "laid again", func(p delivery.PlacementProposal) bool { + place("laptop", "shell", "web") // another writer moved the key + relaid := proposalNow(moved.ID, "laid again", func(p mma.Proposal) bool { return strings.Contains(p.Words, "laid again") && len(p.Must) > 0 }) answerAll(relaid) - proposalNow(moved.ID, "asked", func(p delivery.PlacementProposal) bool { return p.State == delivery.ProposalAsked }) - akey, _ = delivery.PlacementAskKey(moved.ID) + proposalNow(moved.ID, "asked", func(p mma.Proposal) bool { return p.State == mmac.ProposalAsked }) + akey, _ = asks.AssignmentsAskKey(moved.ID) b.answer(t, akey, assign.OptionNo) - for deadline := time.Now().Add(15 * time.Second); b.hasKey(delivery.PlacementProposalsBucket, moved.ID); time.Sleep(50 * time.Millisecond) { + for deadline := time.Now().Add(15 * time.Second); b.hasKey(plc.ProposalsBucket, moved.ID); time.Sleep(50 * time.Millisecond) { if time.Now().After(deadline) { t.Fatal("a declined proposal was not ended") } } - key, _ := delivery.PlacementNodeKey("laptop") - if e, _ := b.buckets[delivery.PlacementBucket].Get(b.ctx, key); e == nil || !strings.Contains(string(e.Value()), "web") { + key, _ := mmac.NodeKey("laptop") + if e, _ := b.buckets[plc.AssignmentsBucket].Get(b.ctx, key); e == nil || !strings.Contains(string(e.Value()), "web") { t.Error("a declined proposal wrote its key") } @@ -1115,18 +1338,23 @@ func TestAProposalIsAnsweredApprovedAndWritten(t *testing.T) { t.Fatal(err) } silent := q.(proposed).Proposal - decided := proposalNow(silent.ID, "decided without its silent nodes", func(p delivery.PlacementProposal) bool { return p.State == delivery.ProposalAsked }) - if decided.Verdict == nil || decided.Verdict.State != delivery.VerdictPass || + decided := proposalNow(silent.ID, "decided without its silent nodes", func(p mma.Proposal) bool { return p.State == mmac.ProposalAsked }) + if decided.Verdict == nil || decided.Verdict.State != mdcc.VerdictPass || !strings.Contains(strings.Join(decided.Verdict.Notes, "; "), "no answer from") || !strings.Contains(strings.Join(decided.Verdict.Notes, "; "), "laptop (offline)") { t.Errorf("the silent node's verdict = %+v", decided.Verdict) } // 774/S59 (ADR 0587 §7): a joining node is written on the enrolment's warrant with no node to answer and no ask raised, - // and placement-changed carries that warrant. The assigner reads no lease (ADR 0567 §8): the enrolment's own issue waits + // and assignments-changed carries that warrant. The assigner reads no lease (ADR 0567 §8): the enrolment's own issue waits // for the lease write (ADR 0587 §4). for len(heard) > 0 { <-heard } + // The module holding the seat mesh-node-enroller is the one whose add-node is taken, whatever its name: it is placed + // now, on a node of its own. + hubRecord, _ := nodeengineclient.EncodeHaveNode(nodeengine.HaveByNodeNodeValue{Record: &nodeengine.NodeRecord{Node: "hub"}}) + b.putJSON(t, buckets.Have, "hub.node", hubRecord) + place("hub", "mesh-enrolment") joined, err := verbs.doPropose(map[string]any{"why": "enrolment of ace", "warrant": `{"node":"ace","address":"10.9.0.2","ask":"enrol-ace-1","warrant":"enrol-ace-1@2026-10-28T20:00:00Z"}`, "changes": `[{"op":"add-node","node":"ace","decided":{"address":"10.9.0.2"},"keys":{"overlay":"dHVubmVs"}}]`}, "novox.mesh-enrolment") if err != nil { @@ -1134,20 +1362,20 @@ func TestAProposalIsAnsweredApprovedAndWritten(t *testing.T) { } select { case e := <-heard: - if e.Proposal.ID != joined.(proposed).Proposal.ID || e.Warrant == nil || e.Warrant.Warrant != "enrol-ace-1@2026-10-28T20:00:00Z" || len(e.Nodes) != 1 || e.Nodes[0].Node != "ace" { - t.Errorf("placement-changed for the joining node = %+v", e) + if e.Proposal.ID != joined.(proposed).Proposal.ID || e.Warrant == nil || e.Warrant.Warrant != "enrol-ace-1@2026-10-28T20:00:00Z" || len(e.Nodes) != 1 || changeIn(e.Nodes[0]).Node != "ace" { + t.Errorf("assignments-changed for the joining node = %+v", e) } case <-time.After(15 * time.Second): - t.Fatal("no placement-changed heard for the joining node") + t.Fatal("no assignments-changed heard for the joining node") } - if ace, err := b.buckets[delivery.PlacementBucket].Get(b.ctx, "ace.node"); err != nil || !strings.Contains(string(ace.Value()), "10.9.0.2") { + if ace, err := b.buckets[plc.AssignmentsBucket].Get(b.ctx, "ace.node"); err != nil || !strings.Contains(string(ace.Value()), "10.9.0.2") { t.Errorf("the joining node's key = %v, %v", ace, err) } - if akey, _ := delivery.PlacementAskKey(joined.(proposed).Proposal.ID); b.hasKey(delivery.AssignerAsksBucket, akey) || b.hasKey(delivery.PlacementProposalsBucket, joined.(proposed).Proposal.ID) { + if akey, _ := asks.AssignmentsAskKey(joined.(proposed).Proposal.ID); b.hasKey(buckets.Asks, akey) || b.hasKey(plc.ProposalsBucket, joined.(proposed).Proposal.ID) { t.Error("an ask was raised for the joining node, or its proposal stays open") } // Its enrolment ends before it confirms: a remove-node on the same warrant deletes its key at once, with no ask, and - // placement-changed carries the warrant (the operator's decision of 2026-11-02 01:05). + // assignments-changed carries the warrant (the operator's decision of 2026-11-02 01:05). for len(heard) > 0 { <-heard } @@ -1157,13 +1385,284 @@ func TestAProposalIsAnsweredApprovedAndWritten(t *testing.T) { } select { case e := <-heard: - if e.Warrant == nil || len(e.Nodes) != 1 || e.Nodes[0].Node != "ace" { - t.Errorf("placement-changed for the removed node = %+v", e) + if e.Warrant == nil || len(e.Nodes) != 1 || changeIn(e.Nodes[0]).Node != "ace" { + t.Errorf("assignments-changed for the removed node = %+v", e) } case <-time.After(15 * time.Second): - t.Fatal("no placement-changed heard for the removed node") + t.Fatal("no assignments-changed heard for the removed node") } - if _, err := b.buckets[delivery.PlacementBucket].Get(b.ctx, "ace.node"); err == nil { + if _, err := b.buckets[plc.AssignmentsBucket].Get(b.ctx, "ace.node"); err == nil { t.Error("the removed node's key stays") } } + +// viewsHold waits until a follower of state, from a first view at its mark and the views family's events, holds key as +// the bucket does, and checks that no views event landed where a waiter on the assigner's put waits. +func (b *bed) viewsHold(t *testing.T, state, key string) { + t.Helper() + kv, marks := b.buckets[viewedStates[state]], b.buckets[marksBucket] + src := func(kv jetstream.KeyValue) views.Source { return bedSource{b.ctx, kv} } + st, err := b.js.Stream(b.ctx, "EVENTS") + if err != nil { + t.Fatal(err) + } + for deadline := time.Now().Add(20 * time.Second); ; time.Sleep(50 * time.Millisecond) { + f := &views.Follower{Owner: assignment.Module, Tool: state, State: state, Ask: func(context.Context, string) (views.Answer, error) { + return views.FirstView(assignment.Module, state, src(kv), src(marks), "", time.Now()) + }} + if err := f.Start(b.ctx); err != nil { + t.Fatal(err) + } + info, _ := st.Info(b.ctx) + gap := false + for seq := info.State.FirstSeq; seq > 0 && seq <= info.State.LastSeq; seq++ { + m, err := st.GetMsg(b.ctx, seq) + if owner, _ := views.OwnerOfEvent(m.Subject); err != nil || owner != assignment.Module || views.StateOfEvent(m.Subject) != state { + continue + } + e, err := views.DecodeEvent(m.Data) + if err != nil { + t.Fatal(err) + } + if g, _ := f.Apply(e); g { + gap = true + } + } + e, err := kv.Get(b.ctx, key) + v, ok := f.Get(key) + if err == nil && ok && !gap && string(v) == string(e.Value()) && f.Status().Revision >= e.Revision() { + break + } + if time.Now().After(deadline) { + t.Fatalf("the views family holds %s.%s as %s at %d (gap %v), the bucket %v", state, key, v, f.Status().Revision, gap, err) + } + } + ev, err := b.js.Stream(b.ctx, "EVENTS") + if err != nil { + t.Fatal(err) + } + if _, err := ev.GetLastMsgForSubject(b.ctx, "mesh.mod.mesh-delivery-assigner.event.put"); err == nil { + t.Errorf("a waiter on put woke: the bus holds a put") + } +} + +// bedSource is a bucket as a views.Source. +type bedSource struct { + ctx context.Context + kv jetstream.KeyValue +} + +func (s bedSource) Keys() ([]string, error) { + k, err := s.kv.Keys(s.ctx) + if errors.Is(err, jetstream.ErrNoKeysFound) { + return nil, nil + } + return k, err +} + +func (s bedSource) Get(key string) (*views.Entry, bool, error) { + e, err := s.kv.Get(s.ctx, key) + if errors.Is(err, jetstream.ErrKeyNotFound) { + return nil, false, nil + } + if err != nil { + return nil, false, err + } + return &views.Entry{Value: e.Value(), Revision: e.Revision()}, true, nil +} + +// assignmentsSubjects are the subjects of the seat's four events on the assigner's stream assignments. +func assignmentsSubjects() []string { + var out []string + for _, e := range mma.Events() { + out = append(out, process.ModuleStreamSubject(assignment.Module, e)) + } + return out +} + +// indexerLate, when set before the bed is made, fails every ask of the indexer's first views until it is closed: the +// indexer down, its runtime coming up after the assigner asked (DLV-F5r, ADR 0631 §2). +var indexerLate chan struct{} + +// serveIndexer is the indexer as the owner of its manifests, modules and module graph (ADR 0615, ADR 0621, ADR 0631): its +// buckets, which a test fills as the indexer writes them; its views family said by the views emitter as ordinary events on EVENTS, +// read by the assigner's durable consumer; and its first-view tools of the three answered from the buckets at their marks. +func serveIndexer(t *testing.T, b *bed, nc *nats.Conn) { + t.Helper() + marks, err := b.js.CreateKeyValue(b.ctx, jetstream.KeyValueConfig{Bucket: buckets.IndexerModule + "_" + views.MarksState, History: 1}) + if err != nil { + t.Fatal(err) + } + em := views.Emitter{Owner: buckets.IndexerModule, Subject: process.Account{Node: "n1", Module: buckets.IndexerModule}.EventSubject, + Publish: views.JetStreamPublish(b.js), Marks: marks} + for state, bucket := range indexerFirstViews { + kv, err := b.js.CreateKeyValue(b.ctx, jetstream.KeyValueConfig{Bucket: bucket, History: 5}) + if err != nil { + t.Fatal(err) + } + b.buckets[bucket] = kv + if err := em.Start(b.ctx, state, kv, nil); err != nil { + t.Fatal(err) + } + late := indexerLate + sub, err := nc.Subscribe("mesh.mod.mesh-delivery-indexer.call."+state+".>", func(m *nats.Msg) { + if late != nil { // its runtime comes up after the assigner started (DLV-F5r): until then every ask fails, as on a down indexer + select { + case <-late: + default: + _ = m.Respond([]byte(`{"error":"the indexer's runtime is not up"}`)) + return + } + } + var args struct { + After string `json:"after"` + } + _ = json.Unmarshal(m.Data, &args) + a, err := views.FirstView(buckets.IndexerModule, state, bedSource{b.ctx, kv}, bedSource{b.ctx, marks}, args.After, time.Now()) + out := map[string]any{"result": a, "node": "n0"} + if err != nil { + out = map[string]any{"error": err.Error()} + } + raw, _ := json.Marshal(out) + _ = m.Respond(raw) + }) + if err != nil { + t.Fatal(err) + } + subscribed(t, nc) + t.Cleanup(func() { _ = sub.Unsubscribe() }) + } +} + +// firstViewsBeforeAnyNewFormState serves the assigner's first-view tools as its runtime of its own does (the tools bundle's +// viewTools over the bed's buckets, the env its manifest's bundle writes, the caller the runtime proves from the subject's +// last token), before any new-form state exists, and asks each from every reader granted and from the others. +func firstViewsBeforeAnyNewFormState(t *testing.T, b *bed, granted map[string][]string) { + t.Helper() + raw, err := os.ReadFile(filepath.Join("..", "..", "module.json")) + if err != nil { + t.Fatal(err) + } + m, err := catalogue.Decode(raw) + if err != nil { + t.Fatal(err) + } + if len(m.BusState) != 0 { + t.Fatalf("the assigner asks bus state of the bus module: %+v", m.BusState) + } + var manifest struct { + Build struct { + Artifacts []struct { + Kind string `json:"kind"` + Env map[string]string `json:"env"` + } `json:"artifacts"` + } `json:"build"` + } + if err := json.Unmarshal(raw, &manifest); err != nil { + t.Fatal(err) + } + env := map[string]string{} + for _, a := range manifest.Build.Artifacts { + if a.Kind == "bundle" { + env = a.Env + } + } + state := func(name string) views.Source { + if name == views.MarksState { + return bedSource{b.ctx, b.buckets[marksBucket]} + } + return bedSource{b.ctx, b.buckets[viewedStates[name]]} + } + // The runtime: one bundle on a pair of pipes, a call on mesh.mod..call.. handed to it with + // the caller in _meta, its answer replied as the tool runner replies ({result, error, node}). + inR, inW := io.Pipe() + outR, outW := io.Pipe() + t.Cleanup(func() { inW.Close(); outW.Close() }) + go func() { + _ = stdio.ServeOn(assignment.Module, viewTools(func(k string) string { return env[k] }, state, time.Now), inR, outW) + }() + answers := make(chan map[string]any, 16) + go func() { + dec := json.NewDecoder(outR) + for { + var line map[string]any + if dec.Decode(&line) != nil { + return + } + if _, asked := line["method"]; !asked { + answers <- line + } + } + }() + one := make(chan struct{}, 1) // one call at a time on the pipe, so each answer is its call's + nc, err := nats.Connect(b.srv.ClientURL()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(nc.Close) + sub, err := nc.Subscribe("mesh.mod."+assignment.Module+".call.*.*", func(msg *nats.Msg) { + one <- struct{}{} + defer func() { <-one }() + tokens := strings.Split(msg.Subject, ".") + var args map[string]any + _ = json.Unmarshal(msg.Data, &args) + call, _ := json.Marshal(map[string]any{"jsonrpc": "2.0", "id": "c", "method": "tools/call", "params": map[string]any{"name": tokens[4], + "arguments": args, "_meta": map[string]any{stdio.MetaCaller: strings.ReplaceAll(tokens[5], "~", ".")}}}) + if _, err := inW.Write(append(call, '\n')); err != nil { + return + } + a := <-answers + res, _ := a["result"].(map[string]any) + content, _ := res["content"].([]any) + text := "" + if len(content) == 1 { + text, _ = content[0].(map[string]any)["text"].(string) + } + out := map[string]any{"result": json.RawMessage(text), "node": "n1"} + if isErr, _ := res["isError"].(bool); isErr || len(content) != 1 { + out = map[string]any{"error": text} + } + reply, _ := json.Marshal(out) + _ = msg.Respond(reply) + }) + if err != nil { + t.Fatal(err) + } + subscribed(t, nc) + t.Cleanup(func() { _ = sub.Unsubscribe() }) + ask := func(user, tool string) (views.Answer, error) { + return views.AskVia(func(key string, body any) (json.RawMessage, error) { + data, _ := json.Marshal(body) + msg, err := nc.Request("mesh.mod."+assignment.Module+".call."+tool+"."+strings.ReplaceAll(user, ".", "~"), data, 5*time.Second) + if err != nil { + return nil, err + } + return msg.Data, nil + }, tool)(b.ctx, "") + } + readers := []string{"n1.mesh-grants", "n1.node-tools", "n1.mesh-delivery-indexer", "n1.mesh-delivery-assigner", "n1.mesh-delivery-builder"} + for tool, allowed := range granted { + for _, user := range readers { + a, err := ask(user, tool) + switch { + case slices.Contains(allowed, user) && err != nil: + t.Errorf("%s's first view of %s before any new-form state: %v", user, tool, err) + case slices.Contains(allowed, user): + keys, _ := b.buckets[viewedStates[tool]].Keys(b.ctx) + if a.Owner != assignment.Module || a.State != tool || len(a.Values) != len(keys) { + t.Errorf("%s's first view of %s = %+v, the state holds %v", user, tool, a, keys) + } + case err == nil || !strings.Contains(err.Error(), "refused"): + t.Errorf("%s, granted no first view of %s, was answered %+v, %v", user, tool, a, err) + } + } + } +} + +// changeIn reads one entry of assignments-changed event's nodes. +func changeIn(body map[string]any) plc.Change { + raw, _ := json.Marshal(body) + var c plc.Change + _ = json.Unmarshal(raw, &c) + return c +} diff --git a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/main.go b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/main.go index ec894b3b..23cdfc37 100644 --- a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/main.go +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/main.go @@ -12,6 +12,8 @@ import ( "fmt" "io" "log" + "mesh-delivery-assigner/internal/assignment" + "mesh-delivery-assigner/internal/buckets" "os" "os/signal" "slices" @@ -21,16 +23,18 @@ import ( "github.com/nats-io/nats.go" "github.com/nats-io/nats.go/jetstream" + mma "git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner" + mcc "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient" + 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" "git.novox.be/novox/mesh-sdk/go/announce" - "git.novox.be/novox/mesh-sdk/go/asks" - "git.novox.be/novox/mesh-sdk/go/delivery" - "git.novox.be/novox/mesh-sdk/go/health" "git.novox.be/novox/mesh-sdk/go/process" - "git.novox.be/novox/mesh-sdk/go/provisions" - "git.novox.be/novox/mesh-delivery/sdk/graph" + "git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient/enginereads" + "git.novox.be/novox/mesh-sdk/go/views" "mesh-delivery-assigner/internal/assign" + plc "mesh-delivery-assigner/internal/assignment" ) func main() { @@ -54,80 +58,90 @@ func main() { // readBuckets are the buckets of other parts the assigner reads, each named in its manifest's reads (the manifest test // holds the two lists equal), so the controller's broker grants exactly these. var readBuckets = []string{ - delivery.PartGrantsBucket, - delivery.AnswersBucket, - delivery.BuilderBuildsBucket, - delivery.ManifestsBucket, - delivery.ModulesBucket, - delivery.PresenceBucket, - delivery.HaveBucket, - provisions.ProvisionsBucket, - delivery.SourceBucket, - delivery.MergesBucket, - graph.Bucket, + buckets.Grants, + buckets.Answers, + buckets.Builds, + buckets.Presence, + buckets.Have, + buckets.Provisions, + buckets.Source, + buckets.Merges, } // readFamilies are the states kept one bucket per node the assigner reads, each named in its manifest's reads: every // node's compositions, which mesh-wants.upgrade compares (novox/hq ADR 0593 §8). -var readFamilies = []string{delivery.ComposedRead} +var readFamilies = []string{buckets.ComposedRead} -// ownBuckets are the assigner's own states, as its manifest declares them: the seat mesh-placement's three among them +// ownBuckets are the assigner's own states, as its manifest declares them: the seat mesh-module-assigner's three among them // (ADR 0567 §1), named after the assigner as every module state is. -var ownBuckets = []string{delivery.AssignerAsksBucket, delivery.AssignerDifferencesBucket, - delivery.AssignerPutsBucket, delivery.AssignerWantsBucket, delivery.PlacementBucket, delivery.PlacementProposalsBucket, - delivery.PlacementCandidatesBucket, delivery.PlacementPulsesBucket, assign.FindingsBucket, assign.PoliciesBucket, delivery.SeatHoldersBucket} +var ownBuckets = []string{buckets.Asks, buckets.Differences, + buckets.Puts, buckets.Wants, plc.AssignmentsBucket, plc.ProposalsBucket, + plc.CandidatesBucket, plc.PulsesBucket, assign.FindingsBucket, assign.PoliciesBucket, plc.SeatHoldersBucket, + engineReadsBucket, retiredBucket, copiesBucket} + +// engineReadsState is the assigner's state of what each node's engine states it reads (ADR 0618 §3), one key ``, as +// the verb manifest-reads last answered it or the fallback read it: what assigner_engine_reads lists and asks again. +const engineReadsState = "engine-reads" + +// engineReadsBucket is that state's bucket. +const engineReadsBucket = assignment.Module + "_" + engineReadsState // seatBuckets are the seat's states, new with it: until the controller has made them from the manifest and the bus grants // them, the assigner starts without them and binds each once the bus says it was created (issue 1790's kind). -var seatBuckets = []string{delivery.PlacementBucket, delivery.PlacementProposalsBucket, delivery.PlacementCandidatesBucket, - delivery.PlacementPulsesBucket} +var seatBuckets = []string{plc.AssignmentsBucket, plc.ProposalsBucket, plc.CandidatesBucket, + plc.PulsesBucket, copiesBucket} // keptStates is what the assigner keeps and who may read it (ADR 0495 §2-§3): its wants by the watchman and the view // (and the node agent from release 6), its differences by the view, its asks by the view -// and the teller, its puts by whoever follows one (ADR 0546), and the seat mesh-placement's placement, proposals and -// candidates by the node agents, the grants part, mesh-config and the view (ADR 0567 §9), and its placement by +// and the teller, its puts by whoever follows one (ADR 0546), and the seat mesh-module-assigner's assignments, proposals and +// candidates by the node agents, the grants part, mesh-config and the view (ADR 0567 §9), and its assignments by // the builder, the checker and the indexer, through mesh-sdk's toolchain gate (issue 774, ADR 0549 §3). var keptStates = []announce.State{ - {Name: delivery.PlacementState, Readable: true}, - {Name: delivery.PlacementProposalsState, Readable: true}, - {Name: delivery.PlacementCandidatesState, Readable: true}, - {Name: delivery.PlacementPulsesState, Readable: true}, + {Name: mma.StateAssignments, Readable: true}, + {Name: mma.StateProposals, Readable: true}, + {Name: mma.StateCandidates, Readable: true}, + {Name: mma.StatePulses, Readable: true}, {Name: "asks", Readable: true}, {Name: "differences", Readable: true}, {Name: "puts", Readable: true}, {Name: "wants", Readable: true}, - {Name: health.FindingsState, Readable: true}, - {Name: delivery.AssignerPoliciesState, Readable: true}, - {Name: delivery.SeatHoldersState, Readable: true}, + {Name: mcc.FindingsState, Readable: true}, + {Name: buckets.PoliciesState, Readable: true}, + {Name: mma.StateSeatHolders, Readable: true}, + {Name: views.MarksState, Readable: true}, // what its views family said each state up to (views.go) + {Name: views.StatusState, Readable: true}, // its first views of the indexer's states (views.go) + {Name: engineReadsState, Readable: true}, + {Name: retiredState}, // read only by the copy into assignments (migrate.go) + {Name: copiesState, Readable: true}, } -// buckets is every bucket the assigner opened, by name. -type buckets map[string]jetstream.KeyValue +// kvSet is every bucket the assigner opened, by name. +type kvSet map[string]jetstream.KeyValue // open opens every bucket the assigner reads and writes; a bucket it cannot open stops the start, named with its grant (issue 1945), // since a pass without it would write wants from half the mesh. The puts' bucket is the exception: whatever keeps it from opening (it // does not exist yet, the credential is not granted it yet, the bus does not answer in time), the assigner writes wants // without it and takes no put further until it starts again (ADR 0546). -func open(ctx context.Context, js process.BucketOpener, logf func(string, ...any)) (buckets, error) { - out := buckets{} +func open(ctx context.Context, js process.BucketOpener, logf func(string, ...any)) (kvSet, error) { + out := kvSet{} for _, name := range append(append([]string{}, readBuckets...), ownBuckets...) { kv, err := js.KeyValue(ctx, name) - if name == delivery.AssignerPutsBucket && err != nil { + if name == buckets.Puts && err != nil { logf("the bucket %s cannot be opened (%v): no put is taken further until the assigner starts again", name, err) continue } if slices.Contains(seatBuckets, name) && err != nil { - // The seat's states come in the same delivery as this assigner (ADR 0567 §1): without placement no module is placed. + // The seat's states come in the same delivery as this assigner (ADR 0567 §1): without assignments no module is placed. logf("the bucket %s cannot be opened (%v): it is bound once the bus says it was made", name, err) continue } - if name == delivery.SeatHoldersBucket && err != nil { + if name == plc.SeatHoldersBucket && err != nil { // The seat's state, new with ADR 0571 §4: bound once the bus says it was made; until then nobody is told who // holds which seat here, and the controller's seats still answer. logf("the bucket %s cannot be opened (%v): it is bound once the bus says it was made", name, err) continue } - if name == provisions.ProvisionsBucket && err != nil { + if name == buckets.Provisions && err != nil { // The seat mesh-provisioner's state, with its own delivery (ADR 0570 §4): bound once the bus says it was made; // until then no want waits for a provision, as before. logf("the bucket %s cannot be opened (%v): it is bound once the bus says it was made", name, err) @@ -145,18 +159,25 @@ func open(ctx context.Context, js process.BucketOpener, logf func(string, ...any logf("the bucket %s cannot be opened (%v): the assigner's conditions are said straight to the router until it starts again", name, err) continue } - if (name == delivery.SourceBucket || name == delivery.MergesBucket) && err != nil { + if (name == buckets.Source || name == buckets.Merges) && err != nil { // The gate's source and merges (ADR 0601 §1): without them no release is known to carry a build, so every move of // a record module is asked of a person, as before. logf("the bucket %s cannot be opened (%v): no release's Approve answers a record ask until it is bound", name, err) continue } - if name == graph.Bucket && err != nil { - // The indexer's module graph (ADR 0614 §2, §3): without it no want waits for a verb it invokes until it is bound. - logf("the bucket %s cannot be opened (%v): no want waits for a verb it invokes until it is bound once the bus says it was made", name, err) + if name == engineReadsBucket && err != nil { + // New with ADR 0618 §3: without it the statements the verb answers are kept in memory only, and + // assigner_engine_reads lists nothing. + logf("the bucket %s cannot be opened (%v): what each node's engine reads is kept in memory only until it starts again", name, err) continue } - if name == delivery.PartGrantsBucket && err != nil { + if name == retiredBucket && err != nil { + // The retired state is read only for the copy into `assignments` (migrate.go): a mesh made after the switch has + // none, and nothing is copied. + logf("the bucket %s cannot be opened (%v): nothing is copied from it", name, err) + continue + } + if name == buckets.Grants && err != nil { // The grants part's grants (ADR 0571 clause 9): without them no module counts as granted until it is bound. logf("the bucket %s cannot be opened (%v): no grant is read until it is bound once the bus says it was made", name, err) continue @@ -173,11 +194,11 @@ func open(ctx context.Context, js process.BucketOpener, logf func(string, ...any // states and those new with a delivery of their own, bound once made, and the two that choose the grants' source. func startsWithout(name string) bool { switch name { - case delivery.SeatHoldersBucket, provisions.ProvisionsBucket, assign.PoliciesBucket, assign.FindingsBucket, - delivery.AssignerPutsBucket, delivery.SourceBucket, delivery.MergesBucket, graph.Bucket: + case plc.SeatHoldersBucket, buckets.Provisions, assign.PoliciesBucket, assign.FindingsBucket, + buckets.Puts, buckets.Source, buckets.Merges, engineReadsBucket, retiredBucket: return true } - return slices.Contains(seatBuckets, name) || name == delivery.PartGrantsBucket + return slices.Contains(seatBuckets, name) || name == buckets.Grants } // run is the program: it returns when ctx ends or the bus cannot be used. @@ -209,12 +230,26 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro thisNode = account.Node logger.Printf("started on the account %s, reading %d buckets", account.User, len(kvs)) findings := findingsOf(ctx, kvs, logger.Printf) - return follow(ctx, js, conn, kvs, findings.Say(sayVia(ctx, js)), findings, emitting{said}, logger) + // Each change of the states its readers read is said on its views family (ADR 0615 §5, ADR 0619 §2); a watch of it + // that ends stops the program, as a watch of what a pass reads does. + viewErrs := make(chan error, len(viewedStates)) + startViews(ctx, js, conn, account, kvs, findings, viewErrs, logger.Printf) + go func() { + select { + case <-ctx.Done(): + case err := <-viewErrs: + logger.Printf("%v: the assigner stops, and is started again", err) + conn.Close() + } + }() + iv := &indexerViews{js: js, account: account, call: said.Call, findings: findings, logf: logger.Printf, + status: process.AwaitBucket(ctx, js, conn, firstViewsBucket, "assigner_first_views reads nothing and assigner_first_view_again is not heard", logger.Printf)} + return follow(ctx, js, conn, kvs, findings.Say(sayVia(ctx, js)), findings, emitting{said}, iv, logger) } // findingsOf is where the assigner's said conditions go as findings (issue 1952 WM6): its findings bucket, which the // condition keeper carries (novox/hq ADR 0590 §3). Without it the conditions are said straight to the router. -func findingsOf(ctx context.Context, kvs buckets, logf func(string, ...any)) assign.Findings { +func findingsOf(ctx context.Context, kvs kvSet, logf func(string, ...any)) assign.Findings { var f assign.Findings if kv := kvs[assign.FindingsBucket]; kv != nil { f.Put = func(key string, value []byte) error { _, err := kv.Put(ctx, key, value); return err } @@ -241,30 +276,30 @@ func (e emitting) Emit(eventType string, body any) error { return e.Conn.Emit(ev // watched are the buckets whose every value a pass reads; the manifests are read by digest when a pass needs one, and // watched only for keys, so a manifest that arrives wakes a composition that waits for it. var watched = []string{ - delivery.PartGrantsBucket, - delivery.AnswersBucket, - delivery.BuilderBuildsBucket, - delivery.ModulesBucket, - delivery.PlacementBucket, - delivery.PlacementProposalsBucket, - delivery.PlacementCandidatesBucket, - delivery.PlacementPulsesBucket, - delivery.PresenceBucket, - delivery.HaveBucket, - delivery.AssignerAsksBucket, - delivery.AssignerDifferencesBucket, - delivery.AssignerWantsBucket, + buckets.Grants, + buckets.Answers, + buckets.Builds, + plc.AssignmentsBucket, + plc.ProposalsBucket, + plc.CandidatesBucket, + plc.PulsesBucket, + copiesBucket, + buckets.Presence, + buckets.Have, + buckets.Asks, + buckets.Differences, + buckets.Wants, assign.PoliciesBucket, - provisions.ProvisionsBucket, - delivery.SeatHoldersBucket, - delivery.SourceBucket, - delivery.MergesBucket, - graph.Bucket, + buckets.Provisions, + plc.SeatHoldersBucket, + buckets.Source, + buckets.Merges, + engineReadsBucket, } // follow watches every bucket a pass reads and makes a pass at start, once every watch handed what stands, and after // each change; changes that come during a pass make one more. It returns when ctx ends or a watch ends. -func follow(ctx context.Context, js jetstream.JetStream, nc process.Subscriber, kvs buckets, say func(asks.Condition) error, findings assign.Findings, bus caller, logger *log.Logger) error { +func follow(ctx context.Context, js jetstream.JetStream, nc process.Subscriber, kvs kvSet, say func(oc.Condition) error, findings assign.Findings, bus caller, iv *indexerViews, logger *log.Logger) error { w := newWorld() dirty := make(chan struct{}, 1) wake := func() { @@ -275,13 +310,23 @@ func follow(ctx context.Context, js jetstream.JetStream, nc process.Subscriber, } errs := make(chan error, len(watched)+2) settled := make(chan struct{}, len(watched)+2) + // What each node's engine reads (ADR 0618 §3, §4): asked through the seat's verb, kept in the state engine-reads, read + // from the node's record where the engine does not answer (the fallback, said in the journal). + reads := &enginereads.Keeper{Fallback: w.recordReads, Changed: wake, Logf: logger.Printf} + if bus != nil { + reads.Call = bus.CallOn + } + if kv := kvs[engineReadsBucket]; kv != nil { + reads.Put = func(node string, v []byte) error { _, err := kv.Put(ctx, node, v); return err } + reads.Delete = func(node string) error { return kv.Delete(ctx, node) } + } watch := func(name string, opts ...jetstream.WatchOpt) error { kw, err := kvs[name].WatchAll(ctx, opts...) if err != nil { return fmt.Errorf("the bucket %s cannot be watched: %w", name, err) } // The puts' watch never stops the wants (ADR 0546): when it ends, the puts are no longer heard, said in the journal. - optional := name == delivery.AssignerPutsBucket + optional := name == buckets.Puts go func() { defer kw.Stop() for { @@ -308,6 +353,10 @@ func follow(ctx context.Context, js jetstream.JetStream, nc process.Subscriber, if e.Operation() == jetstream.KeyValuePut { value = e.Value() } + if name == engineReadsBucket { + reads.Heard(e.Key(), value, value == nil) // it wakes a pass when a statement changed + continue + } w.putEntry(name, e.Key(), value, e.Revision(), e.Created()) wake() } @@ -317,10 +366,10 @@ func follow(ctx context.Context, js jetstream.JetStream, nc process.Subscriber, } // The puts' watch is not waited for before the first pass: a watch of them that ends before it handed what stands only // stops the puts, never the wants. - if kvs[delivery.AssignerPutsBucket] != nil { - if err := watch(delivery.AssignerPutsBucket); err != nil { + if kvs[buckets.Puts] != nil { + if err := watch(buckets.Puts); err != nil { logger.Printf("%v: no put is taken further until it is opened again", err) - delete(kvs, delivery.AssignerPutsBucket) + delete(kvs, buckets.Puts) } } // The puts' bucket is made by the controller from the manifest's states at its start (ADR 0201), and the assigner's @@ -329,24 +378,24 @@ func follow(ctx context.Context, js jetstream.JetStream, nc process.Subscriber, // further, without a restart. var triedPuts time.Time reopenPuts := func(p *passer) { - if kvs[delivery.AssignerPutsBucket] != nil || time.Since(triedPuts) < PutsReopenEvery { + if kvs[buckets.Puts] != nil || time.Since(triedPuts) < PutsReopenEvery { return } triedPuts = time.Now() - kv, err := js.KeyValue(ctx, delivery.AssignerPutsBucket) + kv, err := js.KeyValue(ctx, buckets.Puts) if err != nil { return } - kvs[delivery.AssignerPutsBucket] = kv - if err := watch(delivery.AssignerPutsBucket); err != nil { + kvs[buckets.Puts] = kv + if err := watch(buckets.Puts); err != nil { logger.Printf("%v: no put is taken further until it is opened again", err) - delete(kvs, delivery.AssignerPutsBucket) + delete(kvs, buckets.Puts) return } if bus != nil { p.acts = newPutActs(bus, wake, logger.Printf) } - logger.Printf("the bucket %s is open: puts are taken further", delivery.AssignerPutsBucket) + logger.Printf("the bucket %s is open: puts are taken further", buckets.Puts) } started := 0 for _, name := range watched { @@ -355,7 +404,7 @@ func follow(ctx context.Context, js jetstream.JetStream, nc process.Subscriber, } started++ var opts []jetstream.WatchOpt - if name == delivery.HaveBucket || name == delivery.SourceBucket { + if name == buckets.Have || name == buckets.Source { // what each node ran healthy before, for a set-back; and the release behind each source revision (ADR 0601 §1) opts = append(opts, jetstream.IncludeHistory()) } @@ -363,8 +412,14 @@ func follow(ctx context.Context, js jetstream.JetStream, nc process.Subscriber, return err } } - if err := watch(delivery.ManifestsBucket, jetstream.MetaOnly()); err != nil { - return err + // The assigner's one pull of its durable on the shared events stream: the indexer's views and the engines' reads changed + // are handlers of it, started once both are registered (process.EventHub). + hub := process.NewEventHub(logger.Printf) + // The indexer's manifests, modules and module graph, through its first views and views family, never its buckets + // (DLV-F2r, ADR 0631): the first pass waits for the three answers, as it waits for every watch to hand what stands. + if iv != nil { + iv.hub = hub + started += iv.start(ctx, w, wake, settled) } // The seat's states arrive with the same delivery as this assigner (ADR 0567 §1): one not on the bus at this start is // bound once the bus says it was created, and watched from then on, with no restart (issue 1790's kind). Until then no @@ -373,30 +428,34 @@ func follow(ctx context.Context, js jetstream.JetStream, nc process.Subscriber, name string kv jetstream.KeyValue } - placementAppeared := make(chan appeared, len(seatBuckets)+2) + assignmentsAppeared := make(chan appeared, len(seatBuckets)+2) for _, name := range seatBuckets { if kvs[name] == nil && js != nil { - process.AwaitBucket(ctx, js, nc, name, "the seat mesh-placement waits for it", - logger.Printf).Ready(func(kv jetstream.KeyValue) { placementAppeared <- appeared{name, kv} }) + process.AwaitBucket(ctx, js, nc, name, "the seat mesh-module-assigner waits for it", + logger.Printf).Ready(func(kv jetstream.KeyValue) { assignmentsAppeared <- appeared{name, kv} }) } } // And the two that choose where grants are read from, with the same delivery as the grants part that writes one. - for _, name := range []string{delivery.PartGrantsBucket, assign.PoliciesBucket, provisions.ProvisionsBucket, delivery.SeatHoldersBucket} { + for _, name := range []string{buckets.Grants, assign.PoliciesBucket, buckets.Provisions, plc.SeatHoldersBucket} { if kvs[name] == nil && js != nil { name := name process.AwaitBucket(ctx, js, nc, name, "until then the bucket is not read", - logger.Printf).Ready(func(kv jetstream.KeyValue) { placementAppeared <- appeared{name, kv} }) + logger.Printf).Ready(func(kv jetstream.KeyValue) { assignmentsAppeared <- appeared{name, kv} }) } } - takePlacement := func(a appeared) error { + takeAssignments := func(a appeared) error { if kvs[a.name] != nil { return nil // bound already, by the other of its two events } kvs[a.name] = a.kv logger.Printf("the bucket %s is open and watched", a.name) - return watch(a.name) + if err := watch(a.name); err != nil { + return err + } + wake() // a state made empty brings no change of its own: the pass copies the retired state into it (migrate.go) + return nil } - for range started + 1 { + for range started { select { case <-ctx.Done(): return nil @@ -405,15 +464,22 @@ func follow(ctx context.Context, js jetstream.JetStream, nc process.Subscriber, case <-settled: } } - p := &passer{kvs: kvs, w: w, logf: logger.Printf, say: say, findings: findings, said: map[string]bool{}, answeredAt: map[string]uint64{}} - if bus != nil && kvs[delivery.AssignerPutsBucket] != nil { + p := &passer{kvs: kvs, w: w, logf: logger.Printf, say: say, findings: findings, said: map[string]bool{}, answeredAt: map[string]uint64{}, + reads: reads} + // The node-engine seat's manifest-reads-changed, for one node (ADR 0618 §4): that node is asked again, with no timer. + // Heard on the assigner's durable consumer on EVENTS, which the grants part makes from the manifest's consumes; where it + // is not there yet, a node is asked again only when it comes back online. + if js != nil { + hearReadsChanged(ctx, js, hub, reads, findings, logger.Printf) + } + if bus != nil && kvs[buckets.Puts] != nil { p.acts = newPutActs(bus, wake, logger.Printf) } if bus != nil { p.seat = newSeatActs(bus, wake, logger.Printf) } if js != nil { - p.publish = streamPublisher(ctx, js, thisNode) + p.publish = seatPublisher(ctx, js, thisNode) } wake() for { @@ -423,8 +489,8 @@ func follow(ctx context.Context, js jetstream.JetStream, nc process.Subscriber, case err := <-errs: return err case <-settled: - case kv := <-placementAppeared: - if err := takePlacement(kv); err != nil { + case kv := <-assignmentsAppeared: + if err := takeAssignments(kv); err != nil { return err } case <-dirty: @@ -438,7 +504,7 @@ func follow(ctx context.Context, js jetstream.JetStream, nc process.Subscriber, continue } if kv, err := js.KeyValue(ctx, name); err == nil { - if err := takePlacement(appeared{name, kv}); err != nil { + if err := takeAssignments(appeared{name, kv}); err != nil { return err } continue @@ -456,13 +522,13 @@ var PutsReopenEvery = time.Minute // sayVia publishes a condition on the operator-channel seat's one subject for conditions, under the assigner's own name // (ADR 0437 clause 9). -func sayVia(ctx context.Context, js jetstream.JetStream) func(asks.Condition) error { - return func(c asks.Condition) error { - subject, err := asks.ConditionSubject(delivery.AssignerModule) +func sayVia(ctx context.Context, js jetstream.JetStream) func(oc.Condition) error { + return func(c oc.Condition) error { + subject, err := occ.ConditionSubject(assignment.Module) if err != nil { return err } - data, err := asks.EncodeCondition(c) + data, err := occ.EncodeCondition(c) if err != nil { return err } @@ -470,3 +536,38 @@ func sayVia(ctx context.Context, js jetstream.JetStream) func(asks.Condition) er return err } } + +// hearReadsChanged hears the node-engine seat's event manifest-reads-changed on the assigner's one hub of its durable +// consumer on EVENTS, and asks the node it names again; the hub also hands the indexer's views to the followers of +// indexerViews, and starts once both are registered. +func hearReadsChanged(ctx context.Context, js jetstream.JetStream, hub *process.EventHub, reads *enginereads.Keeper, findings assign.Findings, logf func(string, ...any)) { + account := process.Account{Node: thisNode, Module: assignment.Module} + w := notHeardWriter(findings, logf) + handle := func(m jetstream.Msg) { + if err := m.Ack(); err != nil { + logf("acknowledging %s: %v", m.Subject(), err) + } + node, ours, err := enginereads.NodeOf(m.Subject(), m.Data()) + switch { + case !ours: + case err != nil: + logf("%s holds what is not %s, and %s is asked again all the same: %v", m.Subject(), enginereads.Event, node, err) + reads.AskNode(node, enginereads.Event+" that does not read") + default: + reads.AskNode(node, enginereads.Event) + } + } + err := hub.Handle(enginereads.SubjectPrefix+"*", handle) + if err == nil { + err = hub.Start(ctx, js, account) + } + if err != nil { + w.NotHeard(process.EventsStream, enginereads.Event+" is not heard until the assigner starts again (a node is asked again when it comes back online)", err) + return + } + w.Heard(process.EventsStream) + go func() { + <-ctx.Done() + hub.Stop() + }() +} diff --git a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/main_test.go b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/main_test.go index 6adca56d..f1a66b44 100644 --- a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/main_test.go +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/main_test.go @@ -2,6 +2,8 @@ package main import ( "mesh-delivery-assigner/internal/assign" + plc "mesh-delivery-assigner/internal/assignment" + "mesh-delivery-assigner/internal/buckets" "context" "errors" @@ -18,14 +20,21 @@ import ( "github.com/nats-io/nats.go/jetstream" - "git.novox.be/novox/mesh-sdk/go/asks" + mmac "git.novox.be/novox/mesh-contracts/go/clients/meshmoduleassignerclient" + mma "git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner" + oc "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel" "git.novox.be/novox/mesh-sdk/go/catalogue" - "git.novox.be/novox/mesh-sdk/go/delivery" + "git.novox.be/novox/mesh-sdk/go/process" ) // 1297/B2a: the buckets the program opens are exactly the manifest's reads and states, as the broker names them // (`.` read as the bucket `_`), so it is granted what it reads and reads nothing else. func TestOpensWhatItsManifestNames(t *testing.T) { + // Review round 2: the assigner's views emitter writes its finding view-unsaid (Emitter.Findings set) when it keeps findings. + findings := assign.Findings{Put: func(string, []byte) error { return nil }, Delete: func(string) error { return nil }} + if viewsEmitter(nil, process.Account{}, nil, findings, nil, t.Logf).Findings == nil { + t.Error("the assigner's views emitter writes no view-unsaid finding") + } // 1297/B2a: without a credential the program does not start, and says why. if err := run(context.Background(), func(string) string { return "" }, io.Discard); err == nil || !strings.Contains(err.Error(), "MESH_BROKER_FILE") { t.Errorf("run without a credential = %v", err) @@ -57,7 +66,7 @@ func TestOpensWhatItsManifestNames(t *testing.T) { for _, pair := range []struct { what string code, want []string - }{{"reads", readBuckets, reads}, {"states", ownBuckets, states}} { + }{{"reads", readBuckets, reads}, {"states", append(slices.Clone(ownBuckets), marksBucket, firstViewsBucket), states}} { // the marks are bound by the views family (views.go) got, want := slices.Sorted(slices.Values(pair.code)), slices.Sorted(slices.Values(pair.want)) if !reflect.DeepEqual(got, want) { t.Errorf("the program's %s are %v, the manifest's %v", pair.what, got, want) @@ -67,7 +76,7 @@ func TestOpensWhatItsManifestNames(t *testing.T) { for _, s := range keptStates { kept = append(kept, m.Module+"_"+s.Name) } - if got, want := slices.Sorted(slices.Values(kept)), slices.Sorted(slices.Values(ownBuckets)); !reflect.DeepEqual(got, want) { + if got, want := slices.Sorted(slices.Values(kept)), slices.Sorted(slices.Values(append(slices.Clone(ownBuckets), marksBucket, firstViewsBucket))); !reflect.DeepEqual(got, want) { t.Errorf("the program announces %v, keeps %v", got, want) } // ADR 0546: whatever keeps the puts' bucket from opening (not there, not granted, no answer in time), the assigner opens @@ -78,12 +87,12 @@ func TestOpensWhatItsManifestNames(t *testing.T) { starts bool said string }{ - {delivery.AssignerPutsBucket, jetstream.ErrBucketNotFound, true, "no put is taken further"}, - {delivery.AssignerPutsBucket, errors.New("nats: permissions violation for subscription to \"$JS.API.STREAM.INFO.KV_mesh-delivery-assigner_puts\""), true, "no put is taken further"}, - {delivery.AssignerPutsBucket, context.DeadlineExceeded, true, "no put is taken further"}, - {delivery.AssignerWantsBucket, errors.New("nats: permissions violation"), false, ""}, + {buckets.Puts, jetstream.ErrBucketNotFound, true, "no put is taken further"}, + {buckets.Puts, errors.New("nats: permissions violation for subscription to \"$JS.API.STREAM.INFO.KV_mesh-delivery-assigner_puts\""), true, "no put is taken further"}, + {buckets.Puts, context.DeadlineExceeded, true, "no put is taken further"}, + {buckets.Wants, errors.New("nats: permissions violation"), false, ""}, // Issue 774: the grants part's grants not there do not stop the start. - {delivery.PartGrantsBucket, jetstream.ErrBucketNotFound, true, "no grant is read"}, + {buckets.Grants, jetstream.ErrBucketNotFound, true, "no grant is read"}, } { var logged string kvs, err := open(context.Background(), refusingJS{missing: c.missing, err: c.err}, func(f string, a ...any) { logged += fmt.Sprintf(f, a...) }) @@ -99,21 +108,21 @@ func TestOpensWhatItsManifestNames(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() wrote := make(chan string, 8) - kvs := buckets{} + kvs := kvSet{} for _, name := range append(append([]string{}, readBuckets...), ownBuckets...) { kvs[name] = &watchKV{memKV: &memKV{values: map[string][]byte{}}} } - placed, err := delivery.PlacementValues(delivery.Placement{Node: "ace"}) + placed, err := mmac.AssignmentsValues(mma.Assignments{Node: "ace"}) if err != nil { t.Fatal(err) } for key, raw := range placed { - kv := kvs[delivery.PlacementBucket].(*watchKV) + kv := kvs[plc.AssignmentsBucket].(*watchKV) kv.entries = append(kv.entries, kvEntry{key: key, value: raw}) } - kvs[delivery.AssignerPutsBucket].(*watchKV).closed = true - kvs[delivery.AssignerWantsBucket].(*watchKV).wrote = wrote - go follow(ctx, nil, nil, kvs, func(asks.Condition) error { return nil }, assign.Findings{}, nil, log.New(io.Discard, "", 0)) + kvs[buckets.Puts].(*watchKV).closed = true + kvs[buckets.Wants].(*watchKV).wrote = wrote + go follow(ctx, nil, nil, kvs, func(oc.Condition) error { return nil }, assign.Findings{}, nil, nil, log.New(io.Discard, "", 0)) select { case key := <-wrote: if key != "want.ace.node" { @@ -183,3 +192,8 @@ func (e kvEntry) Value() []byte { return e.value } func (e kvEntry) Operation() jetstream.KeyValueOp { return jetstream.KeyValuePut } func (e kvEntry) Revision() uint64 { return 1 } func (e kvEntry) Created() time.Time { return time.Time{} } + +// Keys lists nothing: the retired state this watch stands for holds no key. +func (w *watchKV) Keys(context.Context, ...jetstream.WatchOpt) ([]string, error) { + return nil, jetstream.ErrNoKeysFound +} diff --git a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/migrate.go b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/migrate.go new file mode 100644 index 00000000..f1d27e8b --- /dev/null +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/migrate.go @@ -0,0 +1,152 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "slices" + "strings" + "time" + + "github.com/nats-io/nats.go/jetstream" + + plc "mesh-delivery-assigner/internal/assignment" + + mmac "git.novox.be/novox/mesh-contracts/go/clients/meshmoduleassignerclient" +) + +// The state this seat's assignments were kept in before ADR 0641 gave it its name. The assigner reads it here and nowhere +// else: at its start it copies each key into `assignments` (copyRetired) and keeps no reader of it afterwards. The +// manifest still declares the state, so the assigner's account may read it for the copy; a later release drops it. +const ( + retiredState = "placement" + retiredBucket = plc.Module + "_" + retiredState +) + +// CopiedEvent is the module's event saying the copy was made, once: mesh.mod.mesh-delivery-assigner.event.assignments-copied. +const CopiedEvent = "assignments-copied" + +// copiedSaid is what the event carries: the keys copied, those already in `assignments` (kept as they are), and the keys of +// the form `placement.` that were dropped (their node key holds the value). +type copiedSaid struct { + Copied int `json:"copied"` + Present int `json:"present"` + Dropped int `json:"dropped"` + Skipped int `json:"skipped"` +} + +// copiesState is the assigner's own state of its copies, read by its tool assigner_read_copies, and copiesBucket its bucket. +// The retired state gets nothing written: readers of other modules still parse every key in it. +const ( + copiesState = "copies" + copiesBucket = plc.Module + "_" + copiesState +) + +// copiedMarker is the key, in the state copies, that says the copy ended: its time and the keys it copied. It is written +// after the last key, and the copy is made at every start until it is there and never after, so a copy cut short by a +// crash is completed at the next start and a key `assignments` dropped after the copy ended is never brought back. +const copiedMarker = "copied-from-retired" + +type copiedMark struct { + At time.Time `json:"at"` + Copied int `json:"copied"` +} + +// copyRetired is the holder's switch from the retired state to `assignments`, with no gap: until the marker is there, it +// creates in `assignments` each key of the retired state in the form `.node` or `.` that is missing +// there (never overwriting one), the module keys first and the node keys, which commit a node, last, and then writes the +// marker. It deletes nothing: the readers delivered before this one still read the retired state, and a delete of a +// `.node` key there is a node leaving (ADR 0641 section 4); the state is dropped in the next release. Keys of the +// form `placement.` are not copied, and a key that fits no form is left and counted. It answers whether the seat may +// go on: a copy that failed is tried again at the next pass. +func (p *passer) copyRetired(ctx context.Context) bool { + if p.copied { + return true + } + old, next, copies := p.kvs[retiredBucket], p.kvs[plc.AssignmentsBucket], p.kvs[copiesBucket] + if next == nil || copies == nil { + return false + } + if old == nil { + p.logf("assignments: the retired state %s cannot be read, so nothing is copied from it", retiredBucket) + p.copied = true + return true + } + switch _, err := copies.Get(ctx, copiedMarker); { + case err == nil: + p.copied = true // the copy ended at an earlier start: nothing is copied again + return true + case !errors.Is(err, jetstream.ErrKeyNotFound): + p.logf("assignments: the marker %s of %s cannot be read, and the copy is tried again at the next pass: %v", copiedMarker, copiesBucket, err) + return false + } + names, err := old.Keys(ctx) + if err != nil && !errors.Is(err, jetstream.ErrNoKeysFound) { + p.logf("assignments: the keys of %s cannot be listed, and are tried again at the next pass: %v", retiredBucket, err) + return false + } + // A node's module keys before its node key: the node key is the commit the readers wait for. + slices.SortStableFunc(names, func(a, b string) int { + return strings.Compare(sortForm(a), sortForm(b)) + }) + said := copiedSaid{} + for _, key := range names { + _, _, kind, perr := mmac.ParseKey(key) + switch { + case strings.HasPrefix(key, retiredState+"."): + // The node named placement could not exist before, so this is the form that held a whole node in one key. + said.Dropped++ + case perr != nil || (kind != mmac.NodeKind && kind != mmac.ModuleKind): + said.Skipped++ + continue + default: + entry, err := old.Get(ctx, key) + if errors.Is(err, jetstream.ErrKeyNotFound) { + continue + } + if err != nil { + p.logf("assignments: %s cannot be read, and the copy is tried again at the next pass: %v", key, err) + return false + } + if entry.Operation() != jetstream.KeyValuePut { + continue + } + rev, err := next.Create(ctx, key, entry.Value()) + switch { + case errors.Is(err, jetstream.ErrKeyExists): + said.Present++ + case err != nil: + p.logf("assignments: %s is not copied, and the copy is tried again at the next pass: %v", key, err) + return false + default: + said.Copied++ + p.w.putEntry(plc.AssignmentsBucket, key, entry.Value(), rev, p.clock()) + } + } + } + mark, _ := json.Marshal(copiedMark{At: p.clock(), Copied: said.Copied + said.Present}) + if _, err := copies.Put(ctx, copiedMarker, mark); err != nil { + p.logf("assignments: the end of the copy is not written as %s in %s, and the copy is tried again at the next pass: %v", copiedMarker, copiesBucket, err) + return false + } + p.copied = true + if said == (copiedSaid{}) { + return true // the retired state holds nothing + } + p.logf("assignments: copied %d keys of %s into %s (%d already there kept as they are, %d of the form placement. not copied, %d fitting no form left)", + said.Copied, retiredBucket, plc.AssignmentsBucket, said.Present, said.Dropped, said.Skipped) + if p.seat != nil { + if err := p.seat.bus.Emit(CopiedEvent, said); err != nil { + p.logf("assignments: the copy is not said as an event: %v", err) + } + } + return true +} + +// sortForm orders `.` keys before `.node`. +func sortForm(key string) string { + if node, ok := strings.CutSuffix(key, ".node"); ok { + return node + "\x7f" + } + return key +} diff --git a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/pass.go b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/pass.go index cc520b40..eac3a2b1 100644 --- a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/pass.go +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/pass.go @@ -6,28 +6,39 @@ import ( "encoding/json" "errors" "maps" + "mesh-delivery-assigner/internal/buckets" "slices" "strings" "time" - "git.novox.be/novox/mesh-sdk/go/asks" + "git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient" + mwc "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshwantsclient" + "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-assigner/asks" + "git.novox.be/novox/mesh-delivery/modules/mesh-grants/grantsstate" + "git.novox.be/novox/mesh-networking/modules/mesh-wireguard/go/overlay" + "git.novox.be/novox/mesh-sdk/go/motion" + + oc "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel" + "git.novox.be/novox/mesh-contracts/provisioning/go/clients/meshprovisionerclient" "git.novox.be/novox/mesh-sdk/go/catalogue" - "git.novox.be/novox/mesh-sdk/go/delivery" - "git.novox.be/novox/mesh-sdk/go/provisions" + + "git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient/enginereads" "mesh-delivery-assigner/internal/assign" + plc "mesh-delivery-assigner/internal/assignment" ) // passer makes the assigner's passes: from what the world holds now, it writes each key whose value differs from the // one written, and only those (the Same functions). type passer struct { - kvs buckets + kvs kvSet w *world logf func(string, ...any) sdk string // say publishes a condition to the operator; said is, by condition key and state, what this process has said, so a // pass says a condition once and a restart says it again (the router keeps one per key). - say func(asks.Condition) error + say func(oc.Condition) error said map[string]bool // findings is the assigner's findings bucket (its said conditions, ADR 0564 §2). findings assign.Findings @@ -58,16 +69,50 @@ type passer struct { // callsCleared says the first pass cleared the conditions of refused controller calls a previous process raised // (clearStaleCalls): the assigner makes no such call now (issue 774). callsCleared bool - // The seat mesh-placement's holder (seat.go, ADR 0567): its acts (nil: none is made), how it says + // The seat mesh-module-assigner's holder (seat.go, ADR 0567): its acts (nil: none is made), how it says // the seat's events (nil: none is said), the proposals it said, and what Intended computed by proposal and base. seat *seatActs publish publisher saidProposed map[string]bool - // convertedAll says every node in the form before was converted to its node and module keys (convertPlacement). - convertedAll bool + // copied says the retired state was copied into `assignments` by this process, or found empty (copyRetired). + copied bool // saidWaits is the last said of the seat's wait for its states (sayWaits). saidWaits string intended map[string]map[string]map[string]bool + // reads keeps what each node's engine states it reads, asked through the node-engine seat's verb (ADR 0618 §3); nil reads + // each node's record, as the rules did before the verb. online is, by node, whether the last pass saw it online. + reads *enginereads.Keeper + online map[string]bool +} + +// engineReads sets what the rules read of each node's engine (ADR 0618 §3, §4): the statement the verb manifest-reads +// answered, or its record where the verb was not answered (the fallback) or not asked yet. A node is asked when the +// assigner first hears of it, and again when it comes back online, since an engine built before the event says none. +func (p *passer) engineReads(n assign.Nodes) { + if p.reads == nil { + return + } + known := p.w.knownNodes() + first := p.online == nil + for _, node := range known { + switch { + case first: + p.reads.AskNode(node, "the assigner started") + case !p.reads.Known(node): + p.reads.AskNode(node, "first heard of") + case !first && n.Online[node] && !p.online[node]: + p.reads.AskNode(node, "back online") + } + } + p.online = maps.Clone(n.Online) + if p.online == nil { + p.online = map[string]bool{} + } + for node, reads := range p.reads.Reads(known) { + if p.reads.Known(node) { + n.Reads[node] = reads + } + } } func (p *passer) clock() time.Time { @@ -78,7 +123,7 @@ func (p *passer) clock() time.Time { } func (p *passer) pass(ctx context.Context) { - p.placementSeat(ctx) + p.assignmentsSeat(ctx) p.wants(ctx) p.puts(ctx) p.nodeWants(ctx) @@ -87,19 +132,19 @@ func (p *passer) pass(ctx context.Context) { // nodeWants writes each placed node's own want, `want..node`: the node composes its own (ADR 0547 §2, ADR 0571 §6). func (p *passer) nodeWants(ctx context.Context) { - written := p.w.read(delivery.AssignerWantsBucket) + written := p.w.read(buckets.Wants) placed := p.w.placedNodes() // A node the catalogue places composes its own composition (ADR 0547 §2): its own want names none. for node := range placed { - key, err := delivery.WantNodeKey(node) + key, err := mwc.WantNodeKey(node) if err != nil { continue } - if before, err := delivery.DecodeNodeWant(written[key]); err == nil && before.Self { + if before, err := mwc.DecodeNodeWant(written[key]); err == nil && before.Self { continue } - want := delivery.NodeWant{Self: true, Words: "the node composes its own composition (ADR 0547 §2)"} - p.put(ctx, delivery.AssignerWantsBucket, key, func() ([]byte, error) { return delivery.EncodeNodeWant(want) }) + want := mwc.NodeWant{Self: true, Words: "the node composes its own composition (ADR 0547 §2)"} + p.put(ctx, buckets.Wants, key, func() ([]byte, error) { return mwc.EncodeNodeWant(want) }) } } @@ -107,7 +152,7 @@ func (p *passer) nodeWants(ctx context.Context) { // the hold of a fingerprint whose health check failed. The assigner compares its wants with nothing the controller // sends: the differences bucket is kept read-only with its history (ADR 0539 §8). func (p *passer) wants(ctx context.Context) { - get := kvGetter(p.kvs[delivery.ManifestsBucket]) + get := p.w.getter(buckets.Manifests) // the indexer's manifests, as its first view and views family say them assigned := p.w.assigned() trunk := p.w.trunk() manifests := map[string]catalogue.Manifest{} @@ -120,6 +165,7 @@ func (p *passer) wants(ctx context.Context) { } } nodes := p.w.nodes(manifests, assigned) + p.engineReads(nodes) nodesOf := map[string][]string{} for node, modules := range assigned { for _, m := range modules { @@ -160,35 +206,35 @@ func (p *passer) wants(ctx context.Context) { served := p.served(ctx, get, moduleGraph, nodes) offering := p.offering(ctx, get, manifests, nodes) // bus state (ADR 0614 §5) for name := range names { - fp := trunk[name].FP2 + fp := trunk[name].Fp2 name := name - m := assign.Module{Name: name, FP: fp, Manifest: manifests[name], Needs: trunk[name].Needs, Chosen: chosenOf(chosen, name), Provisions: provisionsOf(name), Answering: answering(manifests, nodes), Offered: offered(manifests), Nodes: nodesOf[name], Now: time.Now(), Puts: puts[name], + m := assign.Module{Name: name, FP: fp, Manifest: manifests[name], Needs: trunk[name].Needs, Chosen: chosenOf(chosen, name), Provisions: provisionsOf(name), Answering: answering(manifests, nodes), Offered: offered(manifests), Nodes: nodesOf[name], Now: time.Now(), Puts: puts[name], FormNeeds: p.w.buildNeeds(name, fp), Self: func(node string) bool { return placed[node] }, - Composed: func(node, fp string) (delivery.ComposedDigest, bool) { + Composed: func(node, fp string) (assign.ComposedDigest, bool) { return p.w.composed(node, name, fp) // a placed node's own: the digest its own have names (ADR 0571 §6) }, GrantedAt: func(fp string) bool { - key, err := delivery.PartGrantKey(name, fp) + key, err := grantsstate.PartGrantKey(name, fp) return err == nil && p.w.has(grants, key) }, Restored: func(node string) bool { - key, err := delivery.RestoreAskKey(node, name, fp) + key, err := asks.RestoreAskKey(node, name, fp) return err == nil && p.answeredWith(key, assign.OptionRestore) }, Allowed: func(node, digest string) bool { - key, err := delivery.RecordAskKey(node, name, digest) + key, err := asks.RecordAskKey(node, name, digest) return err == nil && p.answeredWith(key, assign.OptionYes) }, Release: func(fp string) (string, bool) { return p.w.release(name, fp) }} if fp != "" { - held, _ := delivery.HeldKey(name, fp) - grant, _ := delivery.PartGrantKey(name, fp) - hold, _ := delivery.HoldAskKey(name, fp) + held, _ := mwc.HeldKey(name, fp) + grant, _ := grantsstate.PartGrantKey(name, fp) + hold, _ := asks.HoldAskKey(name, fp) m.Released = p.answeredWith(hold, "release") m.Kept = p.answeredWith(hold, "keep") - m.Built, m.Held, m.Granted = p.w.built(name, fp), p.w.has(delivery.AssignerWantsBucket, held) && !m.Released, p.w.has(grants, grant) + m.Built, m.Held, m.Granted = p.w.built(name, fp), p.w.has(buckets.Wants, held) && !m.Released, p.w.has(grants, grant) if raw := p.w.value(grants, grant); raw != nil { - if g, err := delivery.DecodeGrant(raw); err == nil { + if g, err := grantsstate.DecodeGrant(raw); err == nil { m.GrantedOn = func(node string) bool { return assign.GrantNamesNode(g, node, name) } } } @@ -223,10 +269,10 @@ func (p *passer) wants(ctx context.Context) { } } if out.Held != nil { - key, err := delivery.HeldKey(name, fp) + key, err := mwc.HeldKey(name, fp) if err == nil { held := *out.Held - p.put(ctx, delivery.AssignerWantsBucket, key, func() ([]byte, error) { return delivery.EncodeHeld(held) }) + p.put(ctx, buckets.Wants, key, func() ([]byte, error) { return mwc.EncodeHeld(held) }) } } if out.Lifted { @@ -243,13 +289,13 @@ func (p *passer) wants(ctx context.Context) { p.recordAsk(ctx, name, fp, need) } for node, want := range out.Wants { - key, err := delivery.WantKey(node, name) + key, err := mwc.WantKey(node, name) if err != nil { p.logf("no want of %s on %s is written: %v", name, node, err) continue } want := want - p.put(ctx, delivery.AssignerWantsBucket, key, func() ([]byte, error) { return delivery.EncodeWant(want) }) + p.put(ctx, buckets.Wants, key, func() ([]byte, error) { return mwc.EncodeWant(want) }) } } } @@ -257,7 +303,7 @@ func (p *passer) wants(ctx context.Context) { // sayNoWant says in the journal why a node of module gets no want after this pass, from the rule the outcome names for it // (issue 1945: a new assignment whose trunk build was not done was held back with nothing said), once per reason, and says // when the node has a want again. A node with a live want, written before and kept or written in this pass, has one. -func (p *passer) sayNoWant(module string, nodes []string, before map[string]delivery.Want, out assign.Outcome) { +func (p *passer) sayNoWant(module string, nodes []string, before map[string]mwc.Want, out assign.Outcome) { if p.noWant == nil { p.noWant = map[string]string{} } @@ -381,7 +427,7 @@ func gatherNeither(into map[string]neitherOne, module string, out assign.Outcome // want for it (written before and kept, or written in this pass), else why it has none. sayNeither clears a neither condition in // these words, so a clear never says a want the node does not hold (issue 1945: "nats on novox has a want again" while the bus bar // left it with none). -func leftNeither(into map[string]string, m assign.Module, before map[string]delivery.Want, out assign.Outcome) { +func leftNeither(into map[string]string, m assign.Module, before map[string]mwc.Want, out assign.Outcome) { for _, node := range m.Nodes { if _, on := out.Neither[node]; on { continue @@ -506,9 +552,9 @@ func (p *passer) put(ctx context.Context, bucket, key string, encode func() ([]b // holdAsk writes, once, the ask about a held fingerprint (ADR 0528 clause 6, ADR 0437 clause 10): the first ask that needs // no proof of who answered, which the view may answer and the teller fills. In release 5 an answer changes nothing on a // node (wants are only compared, ADR 0429 §7), and the options say so. -func (p *passer) holdAsk(ctx context.Context, module, fp string, fresh *delivery.Held) { - key, err := delivery.HoldAskKey(module, fp) - if err != nil || p.w.has(delivery.AssignerAsksBucket, key) { +func (p *passer) holdAsk(ctx context.Context, module, fp string, fresh *mwc.Held) { + key, err := asks.HoldAskKey(module, fp) + if err != nil || p.w.has(buckets.Asks, key) { return } // The asking is the hold's time in seconds: a hold of the same fingerprint written again after a lift is another asking, under @@ -517,18 +563,18 @@ func (p *passer) holdAsk(ctx context.Context, module, fp string, fresh *delivery asking := int(now.Unix()) h := p.heldHold(module, fp, fresh) a := assign.HoldAsk(key, module, fp, h.Node, h.Words, asking, now) - p.put(ctx, delivery.AssignerAsksBucket, key, func() ([]byte, error) { return delivery.EncodeAssignerAsk(key, a) }) + p.put(ctx, buckets.Asks, key, func() ([]byte, error) { return assign.EncodeAsk(key, a) }) } // lift takes away a hold the assigner lifted by itself, since every node that runs the module runs the held fingerprint healthy // (issue 1906): the held record and its ask (the teller then takes the ask back from the operator), and the held condition is // cleared saying why, once. A hold of the same fingerprint written later is said and asked again. func (p *passer) lift(ctx context.Context, module, fp string) { - held, err := delivery.HeldKey(module, fp) + held, err := mwc.HeldKey(module, fp) if err != nil { return } - ask, err := delivery.HoldAskKey(module, fp) + ask, err := asks.HoldAskKey(module, fp) if err != nil { return } @@ -545,13 +591,13 @@ func (p *passer) lift(ctx context.Context, module, fp string) { p.said[c.Key+".lifted"] = true delete(p.said, c.Key+".raised") // a hold written again is said again } - p.remove(ctx, delivery.AssignerWantsBucket, held) - p.remove(ctx, delivery.AssignerAsksBucket, ask) + p.remove(ctx, buckets.Wants, held) + p.remove(ctx, buckets.Asks, ask) // The restore asks of that fingerprint, on every node, go with the hold: every node runs it healthy, so no node waits to be // set back from it (issue 1906, note 28). - for key := range p.w.read(delivery.AssignerAsksBucket) { - if k, err := delivery.ParseAnyAskKey(key); err == nil && k.Kind == delivery.AskKindRestore && k.Module == module && k.FP == fp { - p.remove(ctx, delivery.AssignerAsksBucket, key) + for key := range p.w.read(buckets.Asks) { + if k, err := asks.ParseAnyAskKey(key); err == nil && k.Kind == asks.AskKindRestore && k.Module == module && k.FP == fp { + p.remove(ctx, buckets.Asks, key) } } } @@ -561,16 +607,16 @@ func (p *passer) lift(ctx context.Context, module, fp string) { // controller no longer composes for the node at that fingerprint. A backup ask and an ask of another kind are never stale here. // A module of the trunk whose fingerprint is not read yet, and a composition not read yet, are not stale: nothing known is // nothing taken away. -func (p *passer) staleAsk(key string, trunk map[string]delivery.Module) bool { - k, err := delivery.ParseAnyAskKey(key) +func (p *passer) staleAsk(key string, trunk map[string]meshcatalog.ModulesByModuleValue) bool { + k, err := asks.ParseAnyAskKey(key) if err != nil { return false } switch k.Kind { - case delivery.AskKindHold, delivery.AskKindRestore: + case asks.AskKindHold, asks.AskKindRestore: m, on := trunk[k.Module] - return !on || (m.FP2 != "" && m.FP2 != k.FP) - case delivery.AskKindRecord: + return !on || (m.Fp2 != "" && m.Fp2 != k.FP) + case asks.AskKindRecord: m, on := trunk[k.Module] if !on { return true @@ -578,8 +624,8 @@ func (p *passer) staleAsk(key string, trunk map[string]delivery.Module) bool { // A node that composes its own asks about its own composition, which its have names as the one it waits on (yeses): that // ask stands while the have waits on it, whatever the controller composes (issue 774, 2026-10-31: novox's asks of // postgres, keycloak and mongodb were taken away and made again under a new id every pass, so no answer could count). - if hkey, err := delivery.HaveKey(k.Node, k.Module); err == nil { - if h, err := delivery.DecodeHave(p.w.value(delivery.HaveBucket, hkey)); err == nil && h.Phase == delivery.PhaseWaitingYes && h.Waits == k.Composed { + if hkey, err := nodeengineclient.HaveKey(k.Node, k.Module); err == nil { + if h, err := nodeengineclient.DecodeHave(p.w.value(buckets.Have, hkey)); err == nil && h.Phase == nodeengineclient.PhaseWaitingYes && h.Waits == k.Composed { return false } } @@ -587,13 +633,13 @@ func (p *passer) staleAsk(key string, trunk map[string]delivery.Module) bool { // revised composition of the fingerprint it runs (ADR 0545), and a node's first want at the older fingerprint it runs. So a // record ask is stale only when its digest is the composition of none of the fingerprints the node can still want (the // trunk's, its want's, its have's), and only when every one of them is read. - for _, fp := range p.fingerprintsOf(k.Node, k.Module, m.FP2) { + for _, fp := range p.fingerprintsOf(k.Node, k.Module, m.Fp2) { d, ok := p.w.composed(k.Node, k.Module, fp) if !ok || d.Digest == k.Composed { return false } } - return len(p.fingerprintsOf(k.Node, k.Module, m.FP2)) > 0 + return len(p.fingerprintsOf(k.Node, k.Module, m.Fp2)) > 0 } return false } @@ -608,14 +654,14 @@ func (p *passer) fingerprintsOf(node, module, trunkFP string) []string { } } add(trunkFP) - if key, err := delivery.WantKey(node, module); err == nil { - if w, err := delivery.DecodeWant(p.w.value(delivery.AssignerWantsBucket, key)); err == nil { - add(w.FP) + if key, err := mwc.WantKey(node, module); err == nil { + if w, err := mwc.DecodeWant(p.w.value(buckets.Wants, key)); err == nil { + add(w.Fp) } } - if key, err := delivery.HaveKey(node, module); err == nil { - if h, err := delivery.DecodeHave(p.w.value(delivery.HaveBucket, key)); err == nil { - add(h.FP) + if key, err := nodeengineclient.HaveKey(node, module); err == nil { + if h, err := nodeengineclient.DecodeHave(p.w.value(buckets.Have, key)); err == nil { + add(h.Fp) } } return out @@ -624,21 +670,21 @@ func (p *passer) fingerprintsOf(node, module, trunkFP string) []string { // staleAsks takes away, at every pass, each hold, restore and record ask that staleAsk names (issue 1906, note 28): an ask // outlived its fingerprint, and the teller takes its router ask back from the operator when the key goes. With the trunk not // read at all (no module), nothing is taken away. -func (p *passer) staleAsks(ctx context.Context, trunk map[string]delivery.Module) { +func (p *passer) staleAsks(ctx context.Context, trunk map[string]meshcatalog.ModulesByModuleValue) { if len(trunk) == 0 { return } - for key := range p.w.read(delivery.AssignerAsksBucket) { + for key := range p.w.read(buckets.Asks) { if p.staleAsk(key, trunk) { // The condition is cleared first and the ask goes only once that was said: the ask is what brings this key back at the // next pass, so a clear that failed once is made again, with no state of its own (issue 1906, note 29). - k, kerr := delivery.ParseAnyAskKey(key) - held := kerr == nil && (k.Kind == delivery.AskKindHold || k.Kind == delivery.AskKindRestore) + k, kerr := asks.ParseAnyAskKey(key) + held := kerr == nil && (k.Kind == asks.AskKindHold || k.Kind == asks.AskKindRestore) if held && !p.clearHeld(k.Module, k.FP) { continue } p.logf("the ask %s is about a fingerprint or composition the trunk has left behind and is taken away", key) - p.remove(ctx, delivery.AssignerAsksBucket, key) + p.remove(ctx, buckets.Asks, key) // The held record stays: it is the memory that this fingerprint failed (ADR 0408 §4), and a revert returns the trunk to // exactly it. Only the condition, which would else ask for an answer that can no longer be given, is cleared, and it is // raised again if the trunk returns to the fingerprint. @@ -663,21 +709,21 @@ func (p *passer) remove(ctx context.Context, bucket, key string) { } // heldHold is the hold of fp as written: the node whose health check failed and its words. -func (p *passer) heldHold(module, fp string, fresh *delivery.Held) delivery.Held { +func (p *passer) heldHold(module, fp string, fresh *mwc.Held) mwc.Held { if fresh != nil { return *fresh } - key, err := delivery.HeldKey(module, fp) + key, err := mwc.HeldKey(module, fp) if err != nil { - return delivery.Held{} + return mwc.Held{} } - h, _ := delivery.DecodeHeld(p.w.read(delivery.AssignerWantsBucket)[key]) + h, _ := mwc.DecodeHeld(p.w.read(buckets.Wants)[key]) return h } // heldCondition raises, once, the condition for a fingerprint the assigner holds (ADR 0429 §6, ADR 0539 §19): the module, the // node whose health check failed and the check's words. A module holding data needs the operator's answer to its restore ask. -func (p *passer) heldCondition(ctx context.Context, module, fp string, fresh *delivery.Held, data bool) { +func (p *passer) heldCondition(ctx context.Context, module, fp string, fresh *mwc.Held, data bool) { h := p.heldHold(module, fp, fresh) c := assign.HeldCondition(module, fp, h.Node, h.Words, true, data) if p.say == nil || p.said[c.Key+".raised"] { @@ -694,13 +740,13 @@ func (p *passer) heldCondition(ctx context.Context, module, fp string, fresh *de // restoreAsk writes, once, the ask about a module holding data whose held fingerprint failed its health check on node. func (p *passer) restoreAsk(ctx context.Context, module, node, fp string) { - key, err := delivery.RestoreAskKey(node, module, fp) - if err != nil || p.w.has(delivery.AssignerAsksBucket, key) { + key, err := asks.RestoreAskKey(node, module, fp) + if err != nil || p.w.has(buckets.Asks, key) { return } words := p.heldHold(module, fp, nil).Words a := assign.RestoreAsk(key, node, module, fp, words, time.Now()) - p.put(ctx, delivery.AssignerAsksBucket, key, func() ([]byte, error) { return delivery.EncodeAssignerAsk(key, a) }) + p.put(ctx, buckets.Asks, key, func() ([]byte, error) { return assign.EncodeAsk(key, a) }) } // answeredWith says whether the teller wrote an answer to the ask at key as it stands now and the option chosen is option. The @@ -712,12 +758,12 @@ func (p *passer) answeredWith(key, option string) bool { if !ok || a.Option != option { return false } - ask, err := delivery.DecodeAssignerAsk(key, p.w.value(delivery.AssignerAsksBucket, key)) + _, ask, err := assign.DecodeAsk(key, p.w.value(buckets.Asks, key)) if err != nil { return false } id, _, _ := strings.Cut(a.Warrant, "@") - return id == ask.Ask.ID + return id == ask.ID } // clearHeld clears, once, the condition of a hold the operator released. @@ -737,12 +783,12 @@ func (p *passer) clearHeld(module, fp string) bool { // recordAsk writes, once, the ask about a move of a module of policy record that waits for the operator's yes. func (p *passer) recordAsk(ctx context.Context, module, fp string, need assign.RecordNeed) { - key, err := delivery.RecordAskKey(need.Node, module, need.Digest) - if err != nil || p.w.has(delivery.AssignerAsksBucket, key) { + key, err := asks.RecordAskKey(need.Node, module, need.Digest) + if err != nil || p.w.has(buckets.Asks, key) { return } a := assign.RecordAsk(key, need.Node, module, fp, need, time.Now()) - p.put(ctx, delivery.AssignerAsksBucket, key, func() ([]byte, error) { return delivery.EncodeAssignerAsk(key, a) }) + p.put(ctx, buckets.Asks, key, func() ([]byte, error) { return assign.EncodeAsk(key, a) }) } // backups sees to the modules held on a node because the backup before an apply failed (the have's phase held, ADR 0539 §16): @@ -756,24 +802,24 @@ func (p *passer) recordAsk(ctx context.Context, module, fp string, need assign.R // in a new have value after the answer was written. func (p *passer) backups(ctx context.Context) { wanted := p.w.wantsOf() - for key, value := range p.w.read(delivery.HaveBucket) { - node, module, isNode, err := delivery.ParseHaveKey(key) + for key, value := range p.w.read(buckets.Have) { + node, module, isNode, err := nodeengineclient.ParseHaveKey(key) if err != nil || isNode { continue } - have, err := delivery.DecodeHave(value) - if err != nil || have.Phase != delivery.PhaseHeld { + have, err := nodeengineclient.DecodeHave(value) + if err != nil || have.Phase != nodeengineclient.PhaseHeld { continue } want, ok := wanted[module][node] if !ok || want.Removed { continue } - akey, err := delivery.BackupAskKey(node, module, want.FP) + akey, err := asks.BackupAskKey(node, module, want.Fp) if err != nil { continue } - current, there := delivery.DecodeAssignerAsk(akey, p.w.read(delivery.AssignerAsksBucket)[akey]) + current, currentAsk, there := assign.DecodeAsk(akey, p.w.read(buckets.Asks)[akey]) if there != nil || assign.BackupAskDigest(current) != want.Composed { after := -1 if there == nil { @@ -783,27 +829,27 @@ func (p *passer) backups(ctx context.Context) { continue } answer, answered := p.w.answer(akey) - if router, _, _ := strings.Cut(answer.Warrant, "@"); !answered || router != current.Ask.ID || - (answer.Option != delivery.BackupAgain && answer.Option != delivery.BackupSkip) { + if router, _, _ := strings.Cut(answer.Warrant, "@"); !answered || router != currentAsk.ID || + (answer.Option != mwc.BackupAgain && answer.Option != mwc.BackupSkip) { continue // not answered yet: the ask stands } words := assign.BackupAnswerWords(answer.Option, current.Asking) - haveChanges := p.w.changed(delivery.HaveBucket, key) + haveChanges := p.w.changed(buckets.Have, key) if want.Backup != answer.Option || want.Words != words { want.Backup, want.Words = answer.Option, words - if wkey, err := delivery.WantKey(node, module); err == nil { + if wkey, err := mwc.WantKey(node, module); err == nil { w := want - p.put(ctx, delivery.AssignerWantsBucket, wkey, func() ([]byte, error) { return delivery.EncodeWant(w) }) - p.answeredAt[current.Ask.ID] = haveChanges + p.put(ctx, buckets.Wants, wkey, func() ([]byte, error) { return mwc.EncodeWant(w) }) + p.answeredAt[currentAsk.ID] = haveChanges } continue } - if answer.Option != delivery.BackupAgain { + if answer.Option != mwc.BackupAgain { continue } // The answer is in the want. The node retried and the backup failed again when the have took another value since. - if seen, known := p.answeredAt[current.Ask.ID]; !known { - p.answeredAt[current.Ask.ID] = haveChanges // after a restart: wait for the next failure + if seen, known := p.answeredAt[currentAsk.ID]; !known { + p.answeredAt[currentAsk.ID] = haveChanges // after a restart: wait for the next failure } else if haveChanges > seen { p.backupAsk(ctx, akey, node, module, want, have.Detail, assign.NextBackupAsking(current.Asking)) } @@ -811,10 +857,10 @@ func (p *passer) backups(ctx context.Context) { } // backupAsk writes the backup ask at its asking and raises the condition of the failed backup, once. -func (p *passer) backupAsk(ctx context.Context, key, node, module string, want delivery.Want, words string, asking int) { - a := assign.BackupAsk(key, node, module, want.FP, want.Composed, words, asking, time.Now()) - p.put(ctx, delivery.AssignerAsksBucket, key, func() ([]byte, error) { return delivery.EncodeAssignerAsk(key, a) }) - c := assign.BackupCondition(node, module, want.FP, words, true) +func (p *passer) backupAsk(ctx context.Context, key, node, module string, want mwc.Want, words string, asking int) { + a := assign.BackupAsk(key, node, module, want.Fp, want.Composed, words, asking, time.Now()) + p.put(ctx, buckets.Asks, key, func() ([]byte, error) { return assign.EncodeAsk(key, a) }) + c := assign.BackupCondition(node, module, want.Fp, words, true) if p.say == nil || p.said[c.Key] { return } @@ -831,7 +877,7 @@ func (p *passer) backupAsk(ctx context.Context, key, node, module string, want d // the want again naming it; a yes once written is kept while the want stays at its build, so the node is not asked again. A // move of the build alone (the have says so) to a build an approved release carries is given its yes by that Approve, the // want naming the release, with no ask (ADR 0601 §1). -func (p *passer) yeses(ctx context.Context, module string, m assign.Module, before map[string]delivery.Want, out *assign.Outcome) { +func (p *passer) yeses(ctx context.Context, module string, m assign.Module, before map[string]mwc.Want, out *assign.Outcome) { for _, node := range m.Nodes { if m.Self == nil || !m.Self(node) { continue @@ -848,27 +894,27 @@ func (p *passer) yeses(ctx context.Context, module string, m assign.Module, befo continue } yes, release := "", "" - if had && was.FP == w.FP { + if had && was.Fp == w.Fp { yes, release = was.Yes, was.Release } - key, err := delivery.HaveKey(node, module) + key, err := nodeengineclient.HaveKey(node, module) if err != nil { continue } - if h, err := delivery.DecodeHave(p.w.value(delivery.HaveBucket, key)); err == nil && h.Phase == delivery.PhaseWaitingYes && h.Waits != yes { + if h, err := nodeengineclient.DecodeHave(p.w.value(buckets.Have, key)); err == nil && h.Phase == nodeengineclient.PhaseWaitingYes && h.Waits != yes { switch { case m.Allowed != nil && m.Allowed(node, h.Waits): yes, release = h.Waits, "" - case h.BuildAlone && h.FP != w.FP && m.Release != nil: + case h.BuildAlone && h.Fp != w.Fp && m.Release != nil: // A move of the build alone, to the build an approved release carries: that Approve is the yes (ADR 0601 §1), // named in the want. A move that changes a setting or a secret too, or one the node could not tell, is asked. - if r, ok := m.Release(w.FP); ok { + if r, ok := m.Release(w.Fp); ok { yes, release = h.Waits, r break } - p.recordAsk(ctx, module, w.FP, assign.RecordNeed{Node: node, Digest: h.Waits, Judgement: h.Judgement, Words: h.Detail}) + p.recordAsk(ctx, module, w.Fp, assign.RecordNeed{Node: node, Digest: h.Waits, Judgement: h.Judgement, Words: h.Detail}) default: - p.recordAsk(ctx, module, w.FP, assign.RecordNeed{Node: node, Digest: h.Waits, Judgement: h.Judgement, Words: h.Detail}) + p.recordAsk(ctx, module, w.Fp, assign.RecordNeed{Node: node, Digest: h.Waits, Judgement: h.Judgement, Words: h.Detail}) } } // A want carried over from before the node composed its own may still name the controller's composition; a placed @@ -895,37 +941,37 @@ func chosenOf(chosen map[string]catalogue.PersonPolicy, module string) *catalogu // mesh-provisioner's state; nil while the state is not read (the seat not delivered yet), so no want waits on it. A failed // provision is also said as the assigner's finding `provider..provision-failed` (a waiting one is the watchman's // needs-operator), deleted once it is no longer failed. -func (p *passer) provisionsOf() func(module string) func(node string) map[string]provisions.Provision { - if p.kvs[provisions.ProvisionsBucket] == nil { - return func(string) func(string) map[string]provisions.Provision { return nil } +func (p *passer) provisionsOf() func(module string) func(node string) map[string]meshprovisionerclient.Provision { + if p.kvs[buckets.Provisions] == nil { + return func(string) func(string) map[string]meshprovisionerclient.Provision { return nil } } - by := map[string]map[string]map[string]provisions.Provision{} // module, node, key - failed := map[string]provisions.Provision{} - for key, raw := range p.w.read(provisions.ProvisionsBucket) { - pr, err := provisions.Decode(raw) + by := map[string]map[string]map[string]meshprovisionerclient.Provision{} // module, node, key + failed := map[string]meshprovisionerclient.Provision{} + for key, raw := range p.w.read(buckets.Provisions) { + pr, err := meshprovisionerclient.Decode(raw) if err != nil { continue } node, module := pr.Consumer.Node, pr.Consumer.Module if by[module] == nil { - by[module] = map[string]map[string]provisions.Provision{} + by[module] = map[string]map[string]meshprovisionerclient.Provision{} } if by[module][node] == nil { - by[module][node] = map[string]provisions.Provision{} + by[module][node] = map[string]meshprovisionerclient.Provision{} } by[module][node][key] = pr - if pr.State == provisions.Failed { + if pr.State == meshprovisionerclient.Failed { failed[key] = pr } } p.provisionFindings(failed) - return func(module string) func(string) map[string]provisions.Provision { - return func(node string) map[string]provisions.Provision { return by[module][node] } + return func(module string) func(string) map[string]meshprovisionerclient.Provision { + return func(node string) map[string]meshprovisionerclient.Provision { return by[module][node] } } } // provisionFindings writes the finding of each failed provision and deletes the ones no longer failed. -func (p *passer) provisionFindings(failed map[string]provisions.Provision) { +func (p *passer) provisionFindings(failed map[string]meshprovisionerclient.Provision) { if p.findings.Put == nil { return } @@ -964,7 +1010,7 @@ func (p *passer) provisionFindings(failed map[string]provisions.Provision) { // MissingBound is how long a required provision may have no key before it is said: a module's delivery bound, the default // when its manifest names none (ADR 0564 clause 13 (d)). -var MissingBound = delivery.DefaultDeliveryBound +var MissingBound = motion.DefaultDeliveryBound // missingPulse is the pulse key of a provision with no key, `provision-missing...`. func missingPulse(key string) string { return "provision-missing." + key } @@ -974,7 +1020,7 @@ func missingPulse(key string) string { return "provision-missing." + key } // `provider....provision-missing` is written; one keyed again ends both. The pulse's expiry, heard on // the watch, is the pass that writes it: no timer. func (p *passer) provisionsMissing(ctx context.Context, missing map[string]bool) { - pulses := p.kvs[delivery.PlacementPulsesBucket] + pulses := p.kvs[plc.PulsesBucket] if p.missing == nil { p.missing = map[string]bool{} } @@ -1010,7 +1056,7 @@ func (p *passer) provisionsMissing(ctx context.Context, missing map[string]bool) delete(p.missing, key) if pulses != nil && p.pulsing(missingPulse(key)) { if err := pulses.Delete(ctx, missingPulse(key)); err == nil { - p.w.put(delivery.PlacementPulsesBucket, missingPulse(key), nil) + p.w.put(plc.PulsesBucket, missingPulse(key), nil) } } if p.missingSaid[key] && p.findings.Delete != nil { @@ -1025,7 +1071,7 @@ func (p *passer) provisionsMissing(ctx context.Context, missing map[string]bool) } // unplacedFindings writes, from the lease write, the finding `machine..unplaced` for each node that runs but no -// placement names, which gets no want (ADR 0571 §6), and deletes it once the node is placed. +// assignments names, which gets no want (ADR 0571 §6), and deletes it once the node is placed. func (p *passer) unplacedFindings() { if p.findings.Put == nil { return @@ -1060,27 +1106,27 @@ func (p *passer) unplacedFindings() { } } -// seatHolders writes the seat mesh-placement's state seat-holders from every node's placement (ADR 0571 §4): one key per +// seatHolders writes the seat mesh-module-assigner's state seat-holders from every node's assignments (ADR 0571 §4): one key per // seat held once, ``, and one per node of a node seat, `.`, written only where it differs, and a key no -// placement holds any more deleted. A pass follows each placement change and each manifest the indexer takes in, so no +// assignments holds any more deleted. A pass follows each assignments change and each manifest the indexer takes in, so no // timer writes it. func (p *passer) seatHolders(ctx context.Context) { - kv := p.kvs[delivery.SeatHoldersBucket] + kv := p.kvs[plc.SeatHoldersBucket] if kv == nil { return } - placements := p.w.placements() - if len(placements) == 0 { - return // no placement read yet: nothing is known of who holds what, and nothing is deleted + assignments := p.w.assignments() + if len(assignments) == 0 { + return // no assignments read yet: nothing is known of who holds what, and nothing is deleted } - want := delivery.SeatHoldersOf(placements) - have := p.w.read(delivery.SeatHoldersBucket) + want := plc.SeatHoldersOf(assignments) + have := p.w.read(plc.SeatHoldersBucket) for key, sh := range want { value, err := json.Marshal(sh) if err != nil || string(have[key]) == string(value) { continue } - p.put(ctx, delivery.SeatHoldersBucket, key, func() ([]byte, error) { return value, nil }) + p.put(ctx, plc.SeatHoldersBucket, key, func() ([]byte, error) { return value, nil }) } for key := range have { if _, ok := want[key]; ok { @@ -1090,7 +1136,7 @@ func (p *passer) seatHolders(ctx context.Context) { p.logf("the seat holders %s are not deleted: %v", key, err) continue } - p.w.put(delivery.SeatHoldersBucket, key, nil) + p.w.put(plc.SeatHoldersBucket, key, nil) } } @@ -1106,8 +1152,8 @@ func offered(manifests map[string]catalogue.Manifest) func(string) bool { for _, m := range manifests { add(m) } - for _, module := range delivery.ProvidedModules() { - if m, _, err := delivery.ProvidedManifest(module); err == nil { + for _, module := range overlay.ProvidedModules() { + if m, _, err := overlay.ProvidedManifest(module); err == nil { add(m) } } diff --git a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/pass_test.go b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/pass_test.go index ec5f1bc0..7bf9d849 100644 --- a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/pass_test.go +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/pass_test.go @@ -6,20 +6,39 @@ import ( "encoding/json" "errors" "fmt" + "mesh-delivery-assigner/internal/buckets" + "mesh-delivery-assigner/internal/puts" "slices" "strings" "testing" "time" + "git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient" + wdc "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshwatchdogsclient" + "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" + mdc "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliveryclient" + mwc "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshwantsclient" + "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" + mdseat "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdelivery" + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-assigner/asks" + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds" + "git.novox.be/novox/mesh-delivery/modules/mesh-grants/grantsstate" + "github.com/nats-io/nats.go/jetstream" - "git.novox.be/novox/mesh-sdk/go/asks" + mmac "git.novox.be/novox/mesh-contracts/go/clients/meshmoduleassignerclient" + 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" + "git.novox.be/novox/mesh-sdk/go/busretry" "git.novox.be/novox/mesh-sdk/go/catalogue" - "git.novox.be/novox/mesh-sdk/go/delivery" + "git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient/enginereads" + "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" "git.novox.be/novox/mesh-delivery/sdk/graph" "mesh-delivery-assigner/internal/assign" + plc "mesh-delivery-assigner/internal/assignment" ) // memKV is the part of a bucket a pass writes: puts and deletes. @@ -46,52 +65,52 @@ func (m *memKV) Delete(_ context.Context, key string, _ ...jetstream.KVDeleteOpt } // newPasser is a pass on a fresh world, as after a start, writing to the buckets given, at the clock given. -func newPasser(kvs buckets, at time.Time) *passer { +func newPasser(kvs kvSet, at time.Time) *passer { return &passer{kvs: kvs, w: newWorld(), logf: func(string, ...any) {}, said: map[string]bool{}, answeredAt: map[string]uint64{}, now: func() time.Time { return at }} } -// placeOn puts the seat's placement of node, naming the modules, into the world: one node key and a key per module +// placeOn puts the seat's assignments of node, naming the modules, into the world: one node key and a key per module // (research 072 row 37). func placeOn(t *testing.T, w *world, node string, modules ...string) { t.Helper() - pl := delivery.Placement{Node: node} + pl := mma.Assignments{Node: node} for _, m := range modules { - pl.Modules = append(pl.Modules, delivery.PlacedModule{Module: m}) + pl.Modules = append(pl.Modules, mma.PlacedModule{Module: m}) } - values, err := delivery.PlacementValues(pl) + values, err := mmac.AssignmentsValues(pl) if err != nil { t.Fatal(err) } for key, raw := range values { - w.put(delivery.PlacementBucket, key, raw) + w.put(plc.AssignmentsBucket, key, raw) } } // composes puts the digest the node's own have names as its composition (`.node` of the haves). func composes(t *testing.T, w *world, node, digest string) { t.Helper() - raw, err := delivery.EncodeHaveNode(delivery.HaveNode{Composed: digest}) + raw, err := nodeengineclient.EncodeHaveNode(nodeengine.HaveByNodeNodeValue{Composed: digest}) if err != nil { t.Fatal(err) } - key, _ := delivery.HaveNodeKey(node) - w.put(delivery.HaveBucket, key, raw) + key, _ := nodeengineclient.HaveNodeKey(node) + w.put(buckets.Have, key, raw) } -var fpTest = delivery.SourcePrefix2 + strings.Repeat("b", 64) +var fpTest = meshcatalogclient.SourcePrefix2 + strings.Repeat("b", 64) // 1906/H2: given a hold lifted by itself, when the assigner is started again and the same fingerprint is held again, then the // hold ask is written under another asking, with an id of its own, so the teller raises it (the router drops a second ask under // an id it keeps). func TestAHoldAskedAgainAfterALiftAndARestartHasANewID(t *testing.T) { asksKV, wantsKV := &memKV{values: map[string][]byte{}}, &memKV{values: map[string][]byte{}} - kvs := buckets{delivery.AssignerAsksBucket: asksKV, delivery.AssignerWantsBucket: wantsKV} - key, _ := delivery.HoldAskKey("web", fpTest) - held := &delivery.Held{Node: "ace", Words: "health failed"} + kvs := kvSet{buckets.Asks: asksKV, buckets.Wants: wantsKV} + key, _ := asks.HoldAskKey("web", fpTest) + held := &mwc.Held{Node: "ace", Words: "health failed"} first := newPasser(kvs, time.Date(2026, 10, 25, 1, 0, 0, 0, time.UTC)) first.holdAsk(context.Background(), "web", fpTest, held) - a1, err := delivery.DecodeAssignerAsk(key, asksKV.values[key]) + a1, err := decodeAsk(key, asksKV.values[key]) if err != nil { t.Fatal(err) } @@ -101,7 +120,7 @@ func TestAHoldAskedAgainAfterALiftAndARestartHasANewID(t *testing.T) { } again := newPasser(kvs, time.Date(2026, 10, 25, 2, 0, 0, 0, time.UTC)) // started again: it remembers nothing again.holdAsk(context.Background(), "web", fpTest, held) - a2, err := delivery.DecodeAssignerAsk(key, asksKV.values[key]) + a2, err := decodeAsk(key, asksKV.values[key]) if err != nil { t.Fatal(err) } @@ -121,20 +140,20 @@ func TestAnAnswerCountsOnlyForTheAskingItAnswered(t *testing.T) { t.Errorf("offered(%s) = %v, want %v", provision, !want, want) } } - key, _ := delivery.HoldAskKey("web", fpTest) - p := newPasser(buckets{}, time.Now()) + key, _ := asks.HoldAskKey("web", fpTest) + p := newPasser(kvSet{}, time.Now()) put := func(asking int) string { a := assign.HoldAsk(key, "web", fpTest, "ace", "health failed", asking, time.Now()) - v, err := delivery.EncodeAssignerAsk(key, a) + v, err := assign.EncodeAsk(key, a) if err != nil { t.Fatal(err) } - p.w.put(delivery.AssignerAsksBucket, key, v) - return a.Ask.ID + p.w.put(buckets.Asks, key, v) + return mustAskID(a) } first := put(100) - v, _ := delivery.EncodeAnswer(delivery.Answer{Option: "release", Warrant: first + "@2026-10-25T01:00:00Z", Words: "the operator"}) - p.w.put(delivery.AnswersBucket, key, v) + v, _ := mdc.EncodeAnswer(mdc.Answer{Option: "release", Warrant: first + "@2026-10-25T01:00:00Z", Words: "the operator"}) + p.w.put(buckets.Answers, key, v) if !p.answeredWith(key, "release") { t.Fatal("the answer to the ask that stands is not taken") } @@ -145,7 +164,7 @@ func TestAnAnswerCountsOnlyForTheAskingItAnswered(t *testing.T) { if p.answeredWith(key, "release") { t.Error("the answer to an earlier hold carried over to a later one") } - p.w.put(delivery.AssignerAsksBucket, key, nil) // the ask taken away + p.w.put(buckets.Asks, key, nil) // the ask taken away if p.answeredWith(key, "release") { t.Error("an answer to an ask taken away was taken") } @@ -155,17 +174,83 @@ func TestAnAnswerCountsOnlyForTheAskingItAnswered(t *testing.T) { t.Run("the rules read what each node's engine reads", func(t *testing.T) { w := newWorld() for node, reads := range map[string][]string{"ace": {"word:module", "word:tool-runner"}, "g14": nil} { - raw, err := delivery.EncodeHaveNode(delivery.HaveNode{Record: &delivery.NodeRecord{Node: node, ManifestReads: reads}}) + raw, err := nodeengineclient.EncodeHaveNode(nodeengine.HaveByNodeNodeValue{Record: &nodeengine.NodeRecord{Node: node, ManifestReads: reads}}) if err != nil { t.Fatal(err) } - key, _ := delivery.HaveNodeKey(node) - w.put(delivery.HaveBucket, key, raw) + key, _ := nodeengineclient.HaveNodeKey(node) + w.put(buckets.Have, key, raw) } n := w.nodes(nil, nil) if !slices.Equal(n.Reads["ace"], []string{"word:module", "word:tool-runner"}) || len(n.Reads["g14"]) != 0 || len(n.Reads["novox"]) != 0 { t.Errorf("the nodes' reads are %v", n.Reads) } + // ADR 0618 §3: with the verb asked, a node's reads are what its engine answered; where the engine answers it does not + // serve the verb, its record is read once in its place (the fallback, issue 774); a node asked again when it comes + // back online, since an engine built before the event says none. + asked := map[string]int{} + call := func(node, key string, _ any) (json.RawMessage, error) { + asked[node]++ + if node == "g14" || key != enginereads.Invoke { + return nil, &busretry.Answered{Words: "the seat node-engine serves no verb manifest-reads"} + } + return json.Marshal(nodeengine.ManifestReadsAnswer{V: 1, Node: node, ManifestReads: []string{"form:new"}, StatedAt: time.Now().Format(time.RFC3339Nano)}) + } + p := &passer{w: w, reads: &enginereads.Keeper{Call: call, Fallback: w.recordReads, Sync: true}} + on, _ := presenceValue(false) + w.put(buckets.Presence, "ace", on) + n = w.nodes(nil, nil) + p.engineReads(n) + if !slices.Equal(n.Reads["ace"], []string{"form:new"}) || len(n.Reads["g14"]) != 0 || asked["ace"] != 1 { + t.Errorf("with the verb the nodes' reads are %v (asked %v)", n.Reads, asked) + } + if s := p.reads.Statements(); s["ace"].From != enginereads.FromVerb || s["g14"].From != enginereads.FromRecord { + t.Errorf("the statements are %+v", s) + } + p.engineReads(w.nodes(nil, nil)) + on, _ = presenceValue(true) + w.put(buckets.Presence, "ace", on) + p.engineReads(w.nodes(nil, nil)) + if asked["ace"] != 2 { + t.Errorf("ace, back online, was asked %d times, want 2", asked["ace"]) + } + // The contract carries no checks, so its reader judges what the engine says (mesh-contracts seats/nodeengine): an + // answer or an event that does not hold is no statement, and an event's node is its subject's last token. + now := time.Now().Format(time.RFC3339Nano) + for _, row := range []struct { + name string + raw string + stays bool + }{ + {"a whole answer", `{"v":1,"node":"ace","manifest-reads":["form:new"],"stated-at":"` + now + `"}`, true}, + {"an engine stating nothing", `{"v":1,"node":"ace","stated-at":"` + now + `"}`, true}, + {"another layout", `{"v":2,"node":"ace","stated-at":"` + now + `"}`, false}, + {"no node", `{"v":1,"node":"","stated-at":"` + now + `"}`, false}, + {"a node that is no token", `{"v":1,"node":"a.ce","stated-at":"` + now + `"}`, false}, + {"another node's answer", `{"v":1,"node":"g14","stated-at":"` + now + `"}`, false}, + {"an empty capability", `{"v":1,"node":"ace","manifest-reads":[""],"stated-at":"` + now + `"}`, false}, + {"a time that is no time", `{"v":1,"node":"ace","stated-at":"yesterday"}`, false}, + } { + s := enginereads.Ask(func(string, string, any) (json.RawMessage, error) { return json.RawMessage(row.raw), nil }, "ace", time.Now()) + if (s.From == enginereads.FromVerb) != row.stays || !s.Answered { + t.Errorf("answer %s: %+v", row.name, s) + } + } + for _, row := range []struct { + name, subject, raw string + ours, fails bool + }{ + {"the event", enginereads.SubjectPrefix + "ace", `{"v":1,"node":"ace","at":"` + now + `","why":"returned"}`, true, false}, + {"another reason", enginereads.SubjectPrefix + "ace", `{"v":1,"node":"ace","at":"` + now + `","why":"bored"}`, true, true}, + {"another node in the body", enginereads.SubjectPrefix + "ace", `{"v":1,"node":"g14","at":"` + now + `","why":"started"}`, true, true}, + {"a time that is no time", enginereads.SubjectPrefix + "ace", `{"v":1,"node":"ace","at":"x","why":"started"}`, true, true}, + {"another event", "mesh.seat.node-engine.event.done.ace", `{}`, false, false}, + } { + _, ours, err := enginereads.NodeOf(row.subject, []byte(row.raw)) + if ours != row.ours || (err != nil) != row.fails { + t.Errorf("event %s: ours %v, %v", row.name, ours, err) + } + } }) } @@ -240,11 +325,11 @@ func (b *fakeBus) Emit(eventType string, body any) error { func putOfTheBus(t *testing.T) { ctx := context.Background() now := time.Date(2026, 10, 25, 22, 0, 0, 0, time.UTC) - fp := delivery.SourcePrefix2 + "fd497ab8" + strings.Repeat("0", 56) + fp := meshcatalogclient.SourcePrefix2 + "fd497ab8" + strings.Repeat("0", 56) digest := "sha256:" + strings.Repeat("d", 64) mem := func() *memKV { return &memKV{values: map[string][]byte{}} } - puts, asksKV, wantsKV := mem(), mem(), mem() - kvs := buckets{delivery.AssignerPutsBucket: puts, delivery.AssignerAsksBucket: asksKV, delivery.AssignerWantsBucket: wantsKV} + putsKV, asksKV, wantsKV := mem(), mem(), mem() + kvs := kvSet{buckets.Puts: putsKV, buckets.Asks: asksKV, buckets.Wants: wantsKV} p := newPasser(kvs, now) bus := &fakeBus{calls: make(chan string, 8)} woke := make(chan struct{}, 8) // each act's goroutine wakes a pass once it is done @@ -265,21 +350,21 @@ func putOfTheBus(t *testing.T) { } placeOn(t, p.w, "novox", "nats") mdigest := strings.Repeat("e", 64) - mod, _ := delivery.EncodeModule(delivery.Module{FP2: fp, Manifest: mdigest, Repository: "novox/mesh-catalog", Commit: strings.Repeat("c", 40)}) - world(delivery.ModulesBucket, "nats", mod) + mod, _ := meshcatalogclient.EncodeModule(meshcatalog.ModulesByModuleValue{Fp2: fp, Manifest: mdigest, Repository: "novox/mesh-catalog", Commit: strings.Repeat("c", 40)}) + world(buckets.Modules, "nats", mod) p.w.manifests[mdigest] = parsed{module: "nats", manifest: catalogue.Manifest{Module: "nats", Provides: []catalogue.Offer{{Name: "mesh-bus"}}}} - bkey, _ := delivery.BuilderBuildKey("nats", fp) - built, _ := delivery.EncodeBuilderBuild(delivery.BuilderBuild{State: delivery.BuildDone, Manifest: mdigest}) - world(delivery.BuilderBuildsBucket, bkey, built) - hkey, _ := delivery.HaveKey("novox", "nats") - before, _ := delivery.EncodeHave(delivery.Have{FP: delivery.SourcePrefix2 + strings.Repeat("a", 64), Phase: delivery.PhaseRunning, Health: delivery.HealthOK}) - world(delivery.HaveBucket, hkey, before) + bkey, _ := builds.BuilderBuildKey("nats", fp) + built, _ := builds.EncodeBuilderBuild(builds.BuilderBuild{State: builds.BuildDone, Manifest: mdigest}) + world(buckets.Builds, bkey, built) + hkey, _ := nodeengineclient.HaveKey("novox", "nats") + before, _ := nodeengineclient.EncodeHave(nodeengine.HaveByNodeModuleValue{Fp: meshcatalogclient.SourcePrefix2 + strings.Repeat("a", 64), Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthOK}) + world(buckets.Have, hkey, before) - builds := mem() - builds.values[bkey] = built + buildKV := mem() + buildKV.values[bkey] = built modules := mem() modules.values["nats"] = mod - s := newPutTools(memStore{putsState: puts, delivery.BuilderModule + ".builds": builds, delivery.IndexerModule + ".modules": modules}) + s := newPutTools(memStore{putsState: putsKV, builds.BuilderModule + ".builds": buildKV, buckets.IndexerModule + ".modules": modules}) s.now = func() time.Time { return now } was := answerWithin answerWithin = 0 @@ -297,21 +382,21 @@ func putOfTheBus(t *testing.T) { if err != nil { t.Fatal(err) } - if a := ans.(putAnswer); a.Step != delivery.PutAsked || !a.Hold || !strings.Contains(a.Follow, "mesh_wait") { + if a := ans.(putAnswer); a.Step != puts.Asked || !a.Hold || !strings.Contains(a.Follow, "mesh_wait") { t.Fatalf("put answered %+v", a) } - pkey, _ := delivery.PutKey("novox", "nats") - step := func() delivery.Put { + pkey, _ := puts.Key("novox", "nats") + step := func() puts.Put { t.Helper() - world(delivery.AssignerPutsBucket, pkey, puts.values[pkey]) + world(buckets.Puts, pkey, putsKV.values[pkey]) p.pass(ctx) - got, err := delivery.DecodePut(puts.values[pkey]) + got, err := puts.Decode(putsKV.values[pkey]) if err != nil { t.Fatal(err) } return got } - if got := step(); got.Step != delivery.PutCompose || !strings.Contains(got.Words, "no composition") { + if got := step(); got.Step != puts.Compose || !strings.Contains(got.Words, "no composition") { t.Fatalf("the first pass left the put at %s: %s", got.Step, got.Words) } select { @@ -320,50 +405,50 @@ func putOfTheBus(t *testing.T) { default: } composes(t, p.w, "novox", digest) - gkey, _ := delivery.PartGrantKey("nats", fp) - g, _ := delivery.EncodeGrant(delivery.Grant{}) - world(delivery.PartGrantsBucket, gkey, g) + gkey, _ := grantsstate.PartGrantKey("nats", fp) + g, _ := grantsstate.EncodeGrant(grantsstate.Grant{}) + world(buckets.Grants, gkey, g) got := step() - akey, _ := delivery.RecordAskKey("novox", "nats", digest) - if got.Step != delivery.PutPerson || got.Ask != akey { + akey, _ := asks.RecordAskKey("novox", "nats", digest) + if got.Step != puts.Person || got.Ask != akey { t.Fatalf("composed, the put stands at %s (%s), ask %q", got.Step, got.Words, got.Ask) } - a, err := delivery.DecodeAssignerAsk(akey, asksKV.values[akey]) - if err != nil || a.Ask.Options[0].Level != asks.Approve { + a, err := decodeAsk(akey, asksKV.values[akey]) + if err != nil || a.Ask.Options[0].Level != occ.Approve { t.Fatalf("the ask is %+v, %v; want a yes that approves", a, err) } if _, there := wantsKV.values["want.novox.nats"]; there { t.Fatal("the bus's want was written before the operator's warrant") } - world(delivery.AssignerAsksBucket, akey, asksKV.values[akey]) - yes, _ := delivery.EncodeAnswer(delivery.Answer{Option: assign.OptionYes, Warrant: a.Ask.ID + "@2026-10-25T22:01:00Z", Words: "the operator, verified"}) - world(delivery.AnswersBucket, akey, yes) - if got := step(); got.Step != delivery.PutSnapshot { + world(buckets.Asks, akey, asksKV.values[akey]) + yes, _ := mdc.EncodeAnswer(mdc.Answer{Option: assign.OptionYes, Warrant: a.Ask.ID + "@2026-10-25T22:01:00Z", Words: "the operator, verified"}) + world(buckets.Answers, akey, yes) + if got := step(); got.Step != puts.Snapshot { t.Fatalf("on the warrant the put stands at %s: %s", got.Step, got.Words) } <-woke got = step() - w, err := delivery.DecodeWant(wantsKV.values["want.novox.nats"]) - if got.Step != delivery.PutWanted || err != nil || w.FP != fp || w.Composed != digest || !w.Put || !w.Hold { + w, err := mwc.DecodeWant(wantsKV.values["want.novox.nats"]) + if got.Step != puts.Wanted || err != nil || w.Fp != fp || w.Composed != digest || !w.Put || !w.Hold { t.Fatalf("snapshotted, the put stands at %s (%s); the want %+v, %v", got.Step, got.Words, w, err) } if call := <-bus.calls; !strings.HasPrefix(call, "seat:mesh-hand-acts.warranted ") || !strings.Contains(call, a.Ask.ID) { // ADR 0565 §11: into the seat t.Errorf("the warrant was recorded as %q", call) } - world(delivery.AssignerWantsBucket, "want.novox.nats", wantsKV.values["want.novox.nats"]) - running, _ := delivery.EncodeHave(delivery.Have{FP: fp, Composed: digest, Phase: delivery.PhaseRunning, Health: delivery.HealthOK}) - world(delivery.HaveBucket, hkey, running) - if got := step(); got.Step != delivery.PutHolding { + world(buckets.Wants, "want.novox.nats", wantsKV.values["want.novox.nats"]) + running, _ := nodeengineclient.EncodeHave(nodeengine.HaveByNodeModuleValue{Fp: fp, Composed: digest, Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthOK}) + world(buckets.Have, hkey, running) + if got := step(); got.Step != puts.Holding { t.Fatalf("running healthy, the put stands at %s: %s", got.Step, got.Words) } if _, err := s.doRelease(map[string]any{"node": "novox", "module": "nats", "why": "done"}, "shanks.node-tools"); err != nil { t.Fatal(err) } - if got := step(); got.Step != delivery.PutReleased { + if got := step(); got.Step != puts.Released { t.Fatalf("released, the put stands at %s", got.Step) } listed, _ := s.doPuts(map[string]any{"module": "nats"}) - if l := listed.(map[string]any)["puts"].([]putAnswer); len(l) != 1 || l[0].Step != delivery.PutReleased || len(l[0].Steps) < 6 { + if l := listed.(map[string]any)["puts"].([]putAnswer); len(l) != 1 || l[0].Step != puts.Released || len(l[0].Steps) < 6 { t.Errorf("puts lists %+v", l) } if len(bus.events) < 5 || !strings.Contains(strings.Join(bus.events, "\n"), "holding") { @@ -371,27 +456,27 @@ func putOfTheBus(t *testing.T) { } // The same bus put again at the same composition after the release is its own asking: its ask is written again under an // id of its own, and the earlier yes, still at the answers' key, does not count for it (ADR 0546 clause 8). - world(delivery.HaveBucket, hkey, before) + world(buckets.Have, hkey, before) s.now = func() time.Time { return now.Add(time.Minute) } if _, err := s.doPut(map[string]any{"node": "novox", "module": "nats", "version": fp, "hold": "true", "why": "again"}, "shanks.node-tools"); err != nil { t.Fatal(err) } again := step() - a2, err := delivery.DecodeAssignerAsk(akey, asksKV.values[akey]) - if again.Step != delivery.PutPerson || err != nil || a2.Ask.ID == a.Ask.ID { + a2, err := decodeAsk(akey, asksKV.values[akey]) + if again.Step != puts.Person || err != nil || a2.Ask.ID == a.Ask.ID { t.Fatalf("put again, it stands at %s (%s); its ask %q, %v; the first ask was %q", again.Step, again.Words, a2.Ask.ID, err, a.Ask.ID) } - world(delivery.AssignerAsksBucket, akey, asksKV.values[akey]) - yes2, _ := delivery.EncodeAnswer(delivery.Answer{Option: assign.OptionYes, Warrant: a2.Ask.ID + "@2026-10-25T22:03:00Z", Words: "the operator, verified"}) - world(delivery.AnswersBucket, akey, yes2) - if got := step(); got.Step != delivery.PutSnapshot { + world(buckets.Asks, akey, asksKV.values[akey]) + yes2, _ := mdc.EncodeAnswer(mdc.Answer{Option: assign.OptionYes, Warrant: a2.Ask.ID + "@2026-10-25T22:03:00Z", Words: "the operator, verified"}) + world(buckets.Answers, akey, yes2) + if got := step(); got.Step != puts.Snapshot { t.Fatalf("on the second warrant the put stands at %s: %s", got.Step, got.Words) } <-woke // Released between the pass's read and its write: the pass writes no want for the put that no longer stands. - oldWant, _ := delivery.EncodeWant(delivery.Want{FP: delivery.SourcePrefix2 + strings.Repeat("a", 64)}) + oldWant, _ := mwc.EncodeWant(mwc.Want{Fp: meshcatalogclient.SourcePrefix2 + strings.Repeat("a", 64)}) wantsKV.values["want.novox.nats"] = oldWant - world(delivery.AssignerWantsBucket, "want.novox.nats", oldWant) + world(buckets.Wants, "want.novox.nats", oldWant) if _, err := s.doRelease(map[string]any{"node": "novox", "module": "nats", "why": "changed my mind"}, "shanks.node-tools"); err != nil { t.Fatal(err) } @@ -406,14 +491,14 @@ func putOfTheBus(t *testing.T) { // the journal, so it is loud with no operator channel. func TestAModuleOnNeitherListIsRaisedOnceAndClearedOnce(t *testing.T) { // A pair only one source names is a condition of the same kind (issue 1126): folded here to hold the count. - aPlacementAloneNamesWhereModulesRun(t) + aAssignmentsAloneNamesWhereModulesRun(t) // The verb edges a want waits on come from the graph and what runs (ADR 0614 §2, §3): folded here to hold the count. aWantWaitsOnTheVerbEdgesTheGraphKeeps(t) t.Run("gathers", testThePassGathersTheModulesOnNeitherList) - p := newPasser(buckets{}, time.Date(2026, 10, 25, 15, 0, 0, 0, time.UTC)) - var said []asks.Condition + p := newPasser(kvSet{}, time.Date(2026, 10, 25, 15, 0, 0, 0, time.UTC)) + var said []oc.Condition failing := true - p.say = func(c asks.Condition) error { + p.say = func(c oc.Condition) error { if failing { failing = false return errors.New("the channel is away") @@ -424,11 +509,11 @@ func TestAModuleOnNeitherListIsRaisedOnceAndClearedOnce(t *testing.T) { var logged []string p.logf = func(f string, a ...any) { logged = append(logged, fmt.Sprintf(f, a...)) } one := map[string]neitherOne{"g14.screen-lock": {node: "g14", module: "screen-lock", - holding: assign.Holding{Rule: delivery.RuleProviders, Words: "no provider of x11-display runs healthy on g14 or at its canary"}}} + holding: assign.Holding{Rule: mwc.RuleProviders, Words: "no provider of x11-display runs healthy on g14 or at its canary"}}} p.sayNeither(one, nil, nil) // the say fails p.sayNeither(one, nil, nil) p.sayNeither(one, nil, nil) - if len(said) != 1 || said[0].State != asks.StateRaised || said[0].Subject.Machine != "g14" || said[0].Subject.ID != "screen-lock" { + if len(said) != 1 || said[0].State != occ.StateRaised || said[0].Subject.Machine != "g14" || said[0].Subject.ID != "screen-lock" { t.Fatalf("said = %+v, want one raised condition of screen-lock on g14", said) } if !strings.Contains(strings.Join(logged, "\n"), "screen-lock") { @@ -437,7 +522,7 @@ func TestAModuleOnNeitherListIsRaisedOnceAndClearedOnce(t *testing.T) { wants := map[string]string{"g14.screen-lock": ""} p.sayNeither(map[string]neitherOne{}, nil, wants) p.sayNeither(map[string]neitherOne{}, nil, wants) - if len(said) != 2 || said[1].State != asks.StateCleared || said[1].Key != said[0].Key { + if len(said) != 2 || said[1].State != occ.StateCleared || said[1].Key != said[0].Key { t.Errorf("said = %+v, want the condition cleared once under its key", said) } if last := logged[len(logged)-1]; last != "screen-lock on g14 has a want again" { @@ -445,13 +530,13 @@ func TestAModuleOnNeitherListIsRaisedOnceAndClearedOnce(t *testing.T) { } // 1945: a module that leaves neither list with no want is cleared in words saying so, never "has a want again". The bus is // on neither list by a rule as any module (ADR 0601 §1), nothing for the operator to do. - bus := map[string]neitherOne{"novox.nats": {node: "novox", module: "nats", holding: assign.Holding{Rule: delivery.RuleGrant, Words: "no credential"}}} + bus := map[string]neitherOne{"novox.nats": {node: "novox", module: "nats", holding: assign.Holding{Rule: mwc.RuleGrant, Words: "no credential"}}} p.sayNeither(bus, nil, nil) - if c := said[len(said)-1]; c.State != asks.StateRaised || !strings.HasPrefix(c.Explanation, asks.NothingToDo) || !strings.Contains(c.Explanation, "rule 6 holds its first want back: no credential") { + if c := said[len(said)-1]; c.State != occ.StateRaised || !strings.HasPrefix(c.Explanation, occ.NothingToDo) || !strings.Contains(c.Explanation, "rule 6 holds its first want back: no credential") { t.Errorf("the bus's neither condition = %+v", c) } p.sayNeither(map[string]neitherOne{}, nil, map[string]string{"novox.nats": "it is held, and its held condition says so"}) - if c := said[len(said)-1]; c.State != asks.StateCleared || strings.Contains(c.Resolved, "has a want") { + if c := said[len(said)-1]; c.State != occ.StateCleared || strings.Contains(c.Resolved, "has a want") { t.Errorf("a module that left neither list with no want was cleared as %+v", c) } if last := logged[len(logged)-1]; last != "nats on novox is no longer on neither list and has no want: it is held, and its held condition says so" { @@ -462,23 +547,23 @@ func TestAModuleOnNeitherListIsRaisedOnceAndClearedOnce(t *testing.T) { // 1945/AC21: the pass gathers the modules on neither list of every module's outcome, keyed by node and module. func testThePassGathersTheModulesOnNeitherList(t *testing.T) { got := map[string]neitherOne{} - gatherNeither(got, "screen-lock", assign.Outcome{Neither: map[string]assign.Holding{"g14": {Rule: delivery.RuleGrant, Words: "w"}}}) + gatherNeither(got, "screen-lock", assign.Outcome{Neither: map[string]assign.Holding{"g14": {Rule: mwc.RuleGrant, Words: "w"}}}) gatherNeither(got, "rofi", assign.Outcome{}) - if len(got) != 1 || got["g14.screen-lock"].node != "g14" || got["g14.screen-lock"].module != "screen-lock" || got["g14.screen-lock"].holding.Rule != delivery.RuleGrant { + if len(got) != 1 || got["g14.screen-lock"].node != "g14" || got["g14.screen-lock"].module != "screen-lock" || got["g14.screen-lock"].holding.Rule != mwc.RuleGrant { t.Errorf("gathered = %+v", got) } // 1945 (the bus bar): a node off neither list is cleared in words that say why it has no want; an operator's put running // through its steps owns the want, so it is named, never "the node does not run it". for _, c := range []struct { name string - puts map[string]delivery.Put - before map[string]delivery.Want + puts map[string]puts.Put + before map[string]mwc.Want why string }{ - {"a live want", nil, map[string]delivery.Want{"novox": {FP: fpTest}}, ""}, - {"a put on its steps", map[string]delivery.Put{"novox": {FP: fpTest, Step: delivery.PutPerson}}, nil, "its put waits on its own steps and the operator's answer"}, + {"a live want", nil, map[string]mwc.Want{"novox": {Fp: fpTest}}, ""}, + {"a put on its steps", map[string]puts.Put{"novox": {FP: fpTest, Step: puts.Person}}, nil, "its put waits on its own steps and the operator's answer"}, {"nothing", nil, nil, "the node does not run it"}, - {"an ended put", map[string]delivery.Put{"novox": {FP: fpTest, Step: delivery.PutRefused}}, nil, "the node does not run it"}, + {"an ended put", map[string]puts.Put{"novox": {FP: fpTest, Step: puts.Refused}}, nil, "the node does not run it"}, } { left := map[string]string{} leftNeither(left, assign.Module{Name: "nats", FP: fpTest, Nodes: []string{"novox"}, Puts: c.puts}, c.before, assign.Outcome{}) @@ -488,22 +573,22 @@ func testThePassGathersTheModulesOnNeitherList(t *testing.T) { } // 1945 (a first want held back silently): the journal says why a node gets no want when the reason appears or changes, and // when it has a want again: one line per change, not per pass. - p := newPasser(buckets{}, time.Date(2026, 10, 25, 21, 30, 0, 0, time.UTC)) + p := newPasser(kvSet{}, time.Date(2026, 10, 25, 21, 30, 0, 0, time.UTC)) var logged []string p.logf = func(f string, a ...any) { logged = append(logged, fmt.Sprintf(f, a...)) } - notDone := assign.Outcome{Rules: map[string]assign.Holding{"shanks": {Rule: delivery.RuleBuildDone, Words: "the build of rofi at 4e7b118e is not done"}}} - wanted := assign.Outcome{Wants: map[string]delivery.Want{"shanks": {FP: fpTest, Composed: "sha256:" + strings.Repeat("c", 64)}}} + notDone := assign.Outcome{Rules: map[string]assign.Holding{"shanks": {Rule: mwc.RuleBuildDone, Words: "the build of rofi at 4e7b118e is not done"}}} + wanted := assign.Outcome{Wants: map[string]mwc.Want{"shanks": {Fp: fpTest, Composed: "sha256:" + strings.Repeat("c", 64)}}} for i, c := range []struct { - before map[string]delivery.Want + before map[string]mwc.Want out assign.Outcome line string // the line said in this pass, "" for none }{ {nil, notDone, "rofi on shanks gets no want: rule 1: the build of rofi at 4e7b118e is not done"}, {nil, notDone, ""}, - {map[string]delivery.Want{"shanks": {Removed: true}}, notDone, ""}, - {nil, assign.Outcome{Rules: map[string]assign.Holding{"shanks": {Rule: delivery.RuleGrant, Words: "no credential"}}}, "rofi on shanks gets no want: rule 6: no credential"}, + {map[string]mwc.Want{"shanks": {Removed: true}}, notDone, ""}, + {nil, assign.Outcome{Rules: map[string]assign.Holding{"shanks": {Rule: mwc.RuleGrant, Words: "no credential"}}}, "rofi on shanks gets no want: rule 6: no credential"}, {nil, wanted, "rofi on shanks has a want"}, - {map[string]delivery.Want{"shanks": wanted.Wants["shanks"]}, assign.Outcome{}, ""}, + {map[string]mwc.Want{"shanks": wanted.Wants["shanks"]}, assign.Outcome{}, ""}, } { logged = nil p.sayNoWant("rofi", []string{"shanks"}, c.before, c.out) @@ -513,16 +598,16 @@ func testThePassGathersTheModulesOnNeitherList(t *testing.T) { } // 1496, ADR 0557 §1: a failure that is the node's own (its fingerprint healthy elsewhere, not held) raises the node's condition // once, naming the node, the module, F and the have's words, and clears it once when it ends. - var conds []asks.Condition - p.say = func(c asks.Condition) error { conds = append(conds, c); return nil } + var conds []oc.Condition + p.say = func(c oc.Condition) error { conds = append(conds, c); return nil } failing := map[string]assign.NodeFailure{"shanks": {Others: []string{"ace"}, Words: "health failed, phase running: w"}} for i, c := range []struct { failed map[string]assign.NodeFailure state string // the condition said in this pass, "" for none }{ - {failing, asks.StateRaised}, + {failing, occ.StateRaised}, {failing, ""}, - {nil, asks.StateCleared}, + {nil, occ.StateCleared}, {nil, ""}, } { conds = nil @@ -530,7 +615,7 @@ func testThePassGathersTheModulesOnNeitherList(t *testing.T) { switch { case c.state == "" && len(conds) != 0, c.state != "" && (len(conds) != 1 || conds[0].State != c.state): t.Errorf("node failed, pass %d said %+v, want %q", i, conds, c.state) - case c.state == asks.StateRaised && (conds[0].Key != "mesh-delivery-assigner.failed-alone.shanks.rofi" || + case c.state == occ.StateRaised && (conds[0].Key != "mesh-delivery-assigner.failed-alone.shanks.rofi" || !strings.Contains(conds[0].Explanation, "failed on shanks (health failed, phase running: w), while it runs healthy on ace")): t.Errorf("node failed, raised %+v", conds[0]) } @@ -541,11 +626,11 @@ func testThePassGathersTheModulesOnNeitherList(t *testing.T) { p.sayNodeFailed("rofi", fpTest, []string{"shanks"}, assign.Nodes{}, assign.Outcome{NodeFailed: failing}, false) p.sayNodeFailed("rofi", fpTest, nil, assign.Nodes{}, assign.Outcome{}, false) p.sayNodeFailed("rofi", fpTest, nil, assign.Nodes{}, assign.Outcome{}, false) - if len(conds) != 2 || conds[1].State != asks.StateCleared || conds[1].Key != "mesh-delivery-assigner.failed-alone.shanks.rofi" { + if len(conds) != 2 || conds[1].State != occ.StateCleared || conds[1].Key != "mesh-delivery-assigner.failed-alone.shanks.rofi" { t.Errorf("a node leaving the module said %+v, want one raise and one clear", conds) } known := assign.Nodes{Online: map[string]bool{"ace": true, "g14": false}, - Haves: map[string]map[string]delivery.Have{"g14": {"rofi": {FP: fpTest, Health: delivery.HealthFailed}}}} + Haves: map[string]map[string]nodeengine.HaveByNodeModuleValue{"g14": {"rofi": {Fp: fpTest, Health: nodeengineclient.HealthFailed}}}} for _, c := range []struct { name string known assign.Nodes @@ -555,13 +640,13 @@ func testThePassGathersTheModulesOnNeitherList(t *testing.T) { {"no failed have", assign.Nodes{Online: known.Online}, []string{"mesh-delivery-assigner.failed-alone.ace.rofi", "mesh-delivery-assigner.failed-alone.g14.rofi"}}, } { conds = nil - fresh := newPasser(buckets{}, time.Date(2026, 10, 26, 22, 0, 0, 0, time.UTC)) + fresh := newPasser(kvSet{}, time.Date(2026, 10, 26, 22, 0, 0, 0, time.UTC)) fresh.logf = func(string, ...any) {} - fresh.say = func(c asks.Condition) error { conds = append(conds, c); return nil } + fresh.say = func(c oc.Condition) error { conds = append(conds, c); return nil } fresh.sayNodeFailed("rofi", fpTest, nil, c.known, assign.Outcome{}, true) var keys []string for _, cond := range conds { - if cond.State == asks.StateCleared { + if cond.State == occ.StateCleared { keys = append(keys, cond.Key) } } @@ -577,23 +662,23 @@ func testThePassGathersTheModulesOnNeitherList(t *testing.T) { func TestTheFirstPassAfterAStartClearsWhatAPreviousProcessRaised(t *testing.T) { t.Run("a held module", testAHeldModuleRaisesNoNeitherAndClearsAStandingOne) wantsKV := &memKV{values: map[string][]byte{}} - p := newPasser(buckets{delivery.AssignerWantsBucket: wantsKV, delivery.AssignerAsksBucket: &memKV{values: map[string][]byte{}}}, time.Now()) - var said []asks.Condition - p.say = func(c asks.Condition) error { said = append(said, c); return nil } - mod, _ := delivery.EncodeModule(delivery.Module{FP2: fpTest, Repository: "novox/web", Commit: strings.Repeat("1", 40), Path: delivery.RootPath}) - p.w.put(delivery.ModulesBucket, "web", mod) + p := newPasser(kvSet{buckets.Wants: wantsKV, buckets.Asks: &memKV{values: map[string][]byte{}}}, time.Now()) + var said []oc.Condition + p.say = func(c oc.Condition) error { said = append(said, c); return nil } + mod, _ := meshcatalogclient.EncodeModule(meshcatalog.ModulesByModuleValue{Fp2: fpTest, Repository: "novox/web", Commit: strings.Repeat("1", 40), Path: meshcatalogclient.RootPath}) + p.w.put(buckets.Modules, "web", mod) placeOn(t, p.w, "g14", "web") - on, _ := delivery.EncodePresence(delivery.Presence{Online: true}) - p.w.put(delivery.PresenceBucket, "g14", on) - have, _ := delivery.EncodeHave(delivery.Have{FP: fpTest, Phase: delivery.PhaseRunning, Health: delivery.HealthOK}) - p.w.put(delivery.HaveBucket, "g14.web", have) - key, _ := delivery.WantKey("g14", "web") - want, _ := delivery.EncodeWant(delivery.Want{FP: fpTest, Composed: "sha256:" + strings.Repeat("c", 64)}) - p.w.put(delivery.AssignerWantsBucket, key, want) + on, _ := presenceValue(true) + p.w.put(buckets.Presence, "g14", on) + have, _ := nodeengineclient.EncodeHave(nodeengine.HaveByNodeModuleValue{Fp: fpTest, Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthOK}) + p.w.put(buckets.Have, "g14.web", have) + key, _ := mwc.WantKey("g14", "web") + want, _ := mwc.EncodeWant(mwc.Want{Fp: fpTest, Composed: "sha256:" + strings.Repeat("c", 64)}) + p.w.put(buckets.Wants, key, want) p.wants(context.Background()) cleared := map[string]bool{} for _, c := range said { - if c.State != asks.StateCleared { + if c.State != occ.StateCleared { t.Errorf("the first pass raised %+v", c) } cleared[c.Key] = true @@ -614,29 +699,29 @@ func TestTheFirstPassAfterAStartClearsWhatAPreviousProcessRaised(t *testing.T) { // neither condition for it, a neither condition this process raised for it before is cleared, and so is one a previous process // raised (the first pass after a start), each once. func testAHeldModuleRaisesNoNeitherAndClearsAStandingOne(t *testing.T) { - setup := func() (*passer, *[]asks.Condition) { - p := newPasser(buckets{delivery.AssignerWantsBucket: &memKV{values: map[string][]byte{}}, delivery.AssignerAsksBucket: &memKV{values: map[string][]byte{}}}, time.Now()) - said := &[]asks.Condition{} - p.say = func(c asks.Condition) error { *said = append(*said, c); return nil } - mod, _ := delivery.EncodeModule(delivery.Module{FP2: fpTest, Repository: "novox/resolved", Commit: strings.Repeat("1", 40), Path: delivery.RootPath}) - p.w.put(delivery.ModulesBucket, "systemd-resolved", mod) + setup := func() (*passer, *[]oc.Condition) { + p := newPasser(kvSet{buckets.Wants: &memKV{values: map[string][]byte{}}, buckets.Asks: &memKV{values: map[string][]byte{}}}, time.Now()) + said := &[]oc.Condition{} + p.say = func(c oc.Condition) error { *said = append(*said, c); return nil } + mod, _ := meshcatalogclient.EncodeModule(meshcatalog.ModulesByModuleValue{Fp2: fpTest, Repository: "novox/resolved", Commit: strings.Repeat("1", 40), Path: meshcatalogclient.RootPath}) + p.w.put(buckets.Modules, "systemd-resolved", mod) placeOn(t, p.w, "shanks", "systemd-resolved") - on, _ := delivery.EncodePresence(delivery.Presence{Online: true}) - p.w.put(delivery.PresenceBucket, "shanks", on) - have, _ := delivery.EncodeHave(delivery.Have{FP: fpTest, Phase: delivery.PhaseRunning, Health: delivery.HealthFailed}) - p.w.put(delivery.HaveBucket, "shanks.systemd-resolved", have) - hkey, _ := delivery.HeldKey("systemd-resolved", fpTest) - held, _ := delivery.EncodeHeld(delivery.Held{Node: "shanks", Words: "health failed"}) - p.w.put(delivery.AssignerWantsBucket, hkey, held) + on, _ := presenceValue(true) + p.w.put(buckets.Presence, "shanks", on) + have, _ := nodeengineclient.EncodeHave(nodeengine.HaveByNodeModuleValue{Fp: fpTest, Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthFailed}) + p.w.put(buckets.Have, "shanks.systemd-resolved", have) + hkey, _ := mwc.HeldKey("systemd-resolved", fpTest) + held, _ := mwc.EncodeHeld(mwc.Held{Node: "shanks", Words: "health failed"}) + p.w.put(buckets.Wants, hkey, held) return p, said } key := assign.NeitherCondition("shanks", "systemd-resolved", assign.Holding{}, false).Key - neithers := func(said []asks.Condition) (raised, cleared int) { + neithers := func(said []oc.Condition) (raised, cleared int) { for _, c := range said { if c.Key != key { continue } - if c.State == asks.StateRaised { + if c.State == occ.StateRaised { raised++ } else { cleared++ @@ -646,7 +731,7 @@ func testAHeldModuleRaisesNoNeitherAndClearsAStandingOne(t *testing.T) { } // a standing neither this process raised p, said := setup() - p.neither = map[string]neitherOne{"shanks.systemd-resolved": {node: "shanks", module: "systemd-resolved", holding: assign.Holding{Rule: delivery.RuleNotHeld}}} + p.neither = map[string]neitherOne{"shanks.systemd-resolved": {node: "shanks", module: "systemd-resolved", holding: assign.Holding{Rule: mwc.RuleNotHeld}}} p.wants(context.Background()) p.wants(context.Background()) if r, c := neithers(*said); r != 0 || c != 1 { @@ -665,24 +750,24 @@ func testAHeldModuleRaisesNoNeitherAndClearsAStandingOne(t *testing.T) { // fingerprint (or composition) is not the trunk's now, or whose module left the trunk; a trunk not read takes nothing away; a // backup ask is left; and a lift takes the restore asks of its fingerprint on every node. func TestAsksOfAFingerprintTheTrunkLeftBehindAreTakenAway(t *testing.T) { - fpOld, fpNew := delivery.SourcePrefix2+strings.Repeat("a", 64), delivery.SourcePrefix2+strings.Repeat("c", 64) + fpOld, fpNew := meshcatalogclient.SourcePrefix2+strings.Repeat("a", 64), meshcatalogclient.SourcePrefix2+strings.Repeat("c", 64) digOld, digNew := "sha256:"+strings.Repeat("d", 64), "sha256:"+strings.Repeat("e", 64) - trunkOf := func(fp string) map[string]delivery.Module { - return map[string]delivery.Module{"web": {FP2: fp, Manifest: "sha256:" + strings.Repeat("1", 64), Repository: "novox/web", Commit: strings.Repeat("1", 40), Path: delivery.RootPath}} + trunkOf := func(fp string) map[string]meshcatalog.ModulesByModuleValue { + return map[string]meshcatalog.ModulesByModuleValue{"web": {Fp2: fp, Manifest: "sha256:" + strings.Repeat("1", 64), Repository: "novox/web", Commit: strings.Repeat("1", 40), Path: meshcatalogclient.RootPath}} } type ask struct{ name, key string } - holdOld, _ := delivery.HoldAskKey("web", fpOld) - holdNew, _ := delivery.HoldAskKey("web", fpNew) - holdGone, _ := delivery.HoldAskKey("gone", fpOld) - restOld, _ := delivery.RestoreAskKey("novox", "web", fpOld) - restNew, _ := delivery.RestoreAskKey("novox", "web", fpNew) - recOld, _ := delivery.RecordAskKey("novox", "web", digOld) - recNew, _ := delivery.RecordAskKey("novox", "web", digNew) - backOld, _ := delivery.BackupAskKey("novox", "web", fpOld) + holdOld, _ := asks.HoldAskKey("web", fpOld) + holdNew, _ := asks.HoldAskKey("web", fpNew) + holdGone, _ := asks.HoldAskKey("gone", fpOld) + restOld, _ := asks.RestoreAskKey("novox", "web", fpOld) + restNew, _ := asks.RestoreAskKey("novox", "web", fpNew) + recOld, _ := asks.RecordAskKey("novox", "web", digOld) + recNew, _ := asks.RecordAskKey("novox", "web", digNew) + backOld, _ := asks.BackupAskKey("novox", "web", fpOld) all := []string{holdOld, holdNew, holdGone, restOld, restNew, recOld, recNew, backOld} for _, c := range []struct { name string - trunk map[string]delivery.Module + trunk map[string]meshcatalog.ModulesByModuleValue gone []string // the keys taken away noDigit bool // the node's own composition is not read }{ @@ -694,43 +779,43 @@ func TestAsksOfAFingerprintTheTrunkLeftBehindAreTakenAway(t *testing.T) { } { t.Run(c.name, func(t *testing.T) { asksKV, wantsKV := &memKV{values: map[string][]byte{}}, &memKV{values: map[string][]byte{}} - p := newPasser(buckets{delivery.AssignerAsksBucket: asksKV, delivery.AssignerWantsBucket: wantsKV}, time.Now()) - var said []asks.Condition - p.say = func(c asks.Condition) error { said = append(said, c); return nil } + p := newPasser(kvSet{buckets.Asks: asksKV, buckets.Wants: wantsKV}, time.Now()) + var said []oc.Condition + p.say = func(c oc.Condition) error { said = append(said, c); return nil } for _, key := range all { - k, _ := delivery.ParseAnyAskKey(key) - var a delivery.AssignerAsk + k, _ := asks.ParseAnyAskKey(key) + var a asks.Ask switch k.Kind { - case delivery.AskKindHold: + case asks.AskKindHold: a = assign.HoldAsk(key, k.Module, k.FP, "novox", "w", 1, time.Now()) - case delivery.AskKindRestore: + case asks.AskKindRestore: a = assign.RestoreAsk(key, k.Node, k.Module, k.FP, "w", time.Now()) - case delivery.AskKindRecord: + case asks.AskKindRecord: a = assign.RecordAsk(key, k.Node, k.Module, fpNew, assign.RecordNeed{Node: k.Node, Digest: k.Composed}, time.Now()) default: a = assign.BackupAsk(key, k.Node, k.Module, k.FP, digOld, "w", 2, time.Now()) } - v, err := delivery.EncodeAssignerAsk(key, a) + v, err := assign.EncodeAsk(key, a) if err != nil { t.Fatal(err) } asksKV.values[key] = v - p.w.put(delivery.AssignerAsksBucket, key, v) + p.w.put(buckets.Asks, key, v) } placeOn(t, p.w, "novox", "web") // a placed node composes its own: the digest its have names if !c.noDigit { composes(t, p.w, "novox", digNew) } - heldOld, _ := delivery.HeldKey("web", fpOld) + heldOld, _ := mwc.HeldKey("web", fpOld) wantsKV.values[heldOld] = []byte("x") - p.w.put(delivery.AssignerWantsBucket, heldOld, []byte("x")) - firstAsk, _ := delivery.DecodeAssignerAsk(holdOld, asksKV.values[holdOld]) + p.w.put(buckets.Wants, heldOld, []byte("x")) + firstAsk, _ := decodeAsk(holdOld, asksKV.values[holdOld]) p.staleAsks(context.Background(), c.trunk) // the held record and condition of a fingerprint whose hold ask went are taken away with it - heldOld, _ = delivery.HeldKey("web", fpOld) + heldOld, _ = mwc.HeldKey("web", fpOld) _, heldThere := wantsKV.values[heldOld] - cleared := slices.ContainsFunc(said, func(c asks.Condition) bool { - return c.Key == assign.HeldCondition("web", fpOld, "", "", false, false).Key && c.State == asks.StateCleared + cleared := slices.ContainsFunc(said, func(c oc.Condition) bool { + return c.Key == assign.HeldCondition("web", fpOld, "", "", false, false).Key && c.State == occ.StateCleared }) if gone := slices.Contains(c.gone, holdOld); !heldThere || cleared != gone { t.Errorf("hold ask of the old fingerprint gone = %v: its held record stays = %v (must), its condition cleared = %v", gone, heldThere, cleared) @@ -747,8 +832,8 @@ func TestAsksOfAFingerprintTheTrunkLeftBehindAreTakenAway(t *testing.T) { p.now = func() time.Time { return time.Now().Add(time.Hour) } p.holdAsk(context.Background(), "web", fpOld, nil) p.heldCondition(context.Background(), "web", fpOld, nil, false) - again, err := delivery.DecodeAssignerAsk(holdOld, asksKV.values[holdOld]) - raised := slices.ContainsFunc(said, func(c asks.Condition) bool { return c.State == asks.StateRaised }) + again, err := decodeAsk(holdOld, asksKV.values[holdOld]) + raised := slices.ContainsFunc(said, func(c oc.Condition) bool { return c.State == occ.StateRaised }) if err != nil || again.Ask.ID == firstAsk.Ask.ID || !raised { t.Errorf("the trunk returned to the held fingerprint: ask %q (first %q, %v), condition raised again = %v", again.Ask.ID, firstAsk.Ask.ID, err, raised) } @@ -758,10 +843,10 @@ func TestAsksOfAFingerprintTheTrunkLeftBehindAreTakenAway(t *testing.T) { t.Run("a clear that failed to be said is said at the next pass, and the ask goes after it", func(t *testing.T) { asksKV, wantsKV := &memKV{values: map[string][]byte{}}, &memKV{values: map[string][]byte{}} - p := newPasser(buckets{delivery.AssignerAsksBucket: asksKV, delivery.AssignerWantsBucket: wantsKV}, time.Now()) - var said []asks.Condition + p := newPasser(kvSet{buckets.Asks: asksKV, buckets.Wants: wantsKV}, time.Now()) + var said []oc.Condition fail := true - p.say = func(c asks.Condition) error { + p.say = func(c oc.Condition) error { if fail { fail = false return errors.New("router unreachable") @@ -770,33 +855,33 @@ func TestAsksOfAFingerprintTheTrunkLeftBehindAreTakenAway(t *testing.T) { return nil } a := assign.HoldAsk(holdOld, "web", fpOld, "novox", "w", 1, time.Now()) - v, err := delivery.EncodeAssignerAsk(holdOld, a) + v, err := assign.EncodeAsk(holdOld, a) if err != nil { t.Fatal(err) } asksKV.values[holdOld] = v - p.w.put(delivery.AssignerAsksBucket, holdOld, v) + p.w.put(buckets.Asks, holdOld, v) p.staleAsks(context.Background(), trunkOf(fpNew)) if _, there := asksKV.values[holdOld]; !there || len(said) != 0 { t.Fatalf("after a failed clear: ask left = %v, conditions said = %d; want the ask kept and nothing said", there, len(said)) } p.staleAsks(context.Background(), trunkOf(fpNew)) - if _, there := asksKV.values[holdOld]; there || len(said) != 1 || said[0].State != asks.StateCleared { + if _, there := asksKV.values[holdOld]; there || len(said) != 1 || said[0].State != occ.StateCleared { t.Errorf("at the next pass: ask left = %v, conditions said = %v; want the ask gone and the clear said", there, said) } }) t.Run("a lift takes the restore asks of its fingerprint on every node", func(t *testing.T) { asksKV, wantsKV := &memKV{values: map[string][]byte{}}, &memKV{values: map[string][]byte{}} - p := newPasser(buckets{delivery.AssignerAsksBucket: asksKV, delivery.AssignerWantsBucket: wantsKV}, time.Now()) - keep, _ := delivery.RestoreAskKey("ace", "web", fpNew) - keepOther, _ := delivery.RestoreAskKey("ace", "other", fpOld) - for _, key := range []string{restOld, mustKey(delivery.RestoreAskKey("ace", "web", fpOld)), keep, keepOther} { + p := newPasser(kvSet{buckets.Asks: asksKV, buckets.Wants: wantsKV}, time.Now()) + keep, _ := asks.RestoreAskKey("ace", "web", fpNew) + keepOther, _ := asks.RestoreAskKey("ace", "other", fpOld) + for _, key := range []string{restOld, mustKey(asks.RestoreAskKey("ace", "web", fpOld)), keep, keepOther} { asksKV.values[key] = []byte("x") - p.w.put(delivery.AssignerAsksBucket, key, []byte("x")) + p.w.put(buckets.Asks, key, []byte("x")) } p.lift(context.Background(), "web", fpOld) - for key, gone := range map[string]bool{restOld: true, mustKey(delivery.RestoreAskKey("ace", "web", fpOld)): true, keep: false, keepOther: false} { + for key, gone := range map[string]bool{restOld: true, mustKey(asks.RestoreAskKey("ace", "web", fpOld)): true, keep: false, keepOther: false} { if _, there := asksKV.values[key]; there == gone { t.Errorf("%s: left in the bucket = %v after the lift", key, there) } @@ -824,7 +909,7 @@ func TestARecordAskOfAHeldBackNodeSurvivesPassesAndItsYesMovesTheWant(t *testing // given its yes by that Approve, the want naming the release, and no record ask is raised; a move that changes a setting or a // secret too, one at the build it runs, and one no approved release carries are asked of a person as before. func aReleasesApproveAnswersTheMoveOfItsBuildAlone(t *testing.T) { - fpOld := delivery.SourcePrefix2 + strings.Repeat("a", 64) + fpOld := meshcatalogclient.SourcePrefix2 + strings.Repeat("a", 64) waiting, was := "sha256:"+strings.Repeat("e", 64), "sha256:"+strings.Repeat("d", 64) commit := strings.Repeat("1", 40) for _, c := range []struct { @@ -832,7 +917,7 @@ func aReleasesApproveAnswersTheMoveOfItsBuildAlone(t *testing.T) { alone bool at string // the fingerprint the have runs mergedAt string // the commit the release merged web at; "" for no merge of the release - fpSrc uint64 // the source revision at which web's fingerprint last changed + fpSrc int64 // the source revision at which web's fingerprint last changed yes, asked bool }{ {"the build alone, its release approved", true, fpOld, commit, 7, true, false}, @@ -847,58 +932,58 @@ func aReleasesApproveAnswersTheMoveOfItsBuildAlone(t *testing.T) { {"a fingerprint moved by a recompute after the release", true, fpOld, commit, 0, false, true}, } { asksKV := &memKV{values: map[string][]byte{}} - p := newPasser(buckets{delivery.AssignerAsksBucket: asksKV}, time.Now()) + p := newPasser(kvSet{buckets.Asks: asksKV}, time.Now()) p.logf = t.Logf - mod, _ := delivery.EncodeModule(delivery.Module{FP2: fpTest, Repository: "novox/web", Commit: commit, Src: 7, FPSrc: c.fpSrc, Path: delivery.RootPath}) - p.w.put(delivery.ModulesBucket, "web", mod) - src, _ := delivery.EncodeSource(delivery.Source{Group: "feat/web", Heads: map[string]string{"novox/web": commit}}) - p.w.putEntry(delivery.SourceBucket, delivery.SourceMainKey, src, 7, time.Now()) - later, _ := delivery.EncodeSource(delivery.Source{Group: "feat/other", Heads: map[string]string{"novox/web": commit, "novox/x": commit}}) - p.w.putEntry(delivery.SourceBucket, delivery.SourceMainKey, later, 8, time.Now()) // a later merge leaves revision 7's release + mod, _ := meshcatalogclient.EncodeModule(meshcatalog.ModulesByModuleValue{Fp2: fpTest, Repository: "novox/web", Commit: commit, Src: 7, FpSrc: c.fpSrc, Path: meshcatalogclient.RootPath}) + p.w.put(buckets.Modules, "web", mod) + src, _ := mdc.EncodeSource(mdseat.SourceByMainValue{Group: "feat/web", Heads: map[string]string{"novox/web": commit}}) + p.w.putEntry(buckets.Source, mdc.SourceMainKey, src, 7, time.Now()) + later, _ := mdc.EncodeSource(mdseat.SourceByMainValue{Group: "feat/other", Heads: map[string]string{"novox/web": commit, "novox/x": commit}}) + p.w.putEntry(buckets.Source, mdc.SourceMainKey, later, 8, time.Now()) // a later merge leaves revision 7's release if c.mergedAt != "" { - merge, err := delivery.EncodeMerge(delivery.Merge{Group: "feat/web", Ask: "merge.feat_web.0123456789ab", Pulls: []delivery.MergePull{ - {Head: delivery.Head{Repository: "novox/web", Number: 3, Head: strings.Repeat("3", 40)}, State: delivery.MergeMerged, Commit: c.mergedAt}}}) + merge, err := mdc.EncodeMerge(mdseat.MergesByGroupValue{Group: "feat/web", Ask: "merge.feat_web.0123456789ab", Pulls: []mdseat.MergesByGroupValuePullsItem{ + {Repository: "novox/web", Number: 3, Head: strings.Repeat("3", 40), State: string(mdc.MergeMerged), Commit: c.mergedAt}}}) if err != nil { t.Fatal(err) } - p.w.put(delivery.MergesBucket, "feat/web", merge) - other, err := delivery.EncodeMerge(delivery.Merge{Group: "feat/other", Ask: "merge.feat_other.0123456789ab", Pulls: []delivery.MergePull{ - {Head: delivery.Head{Repository: "novox/x", Number: 4, Head: strings.Repeat("4", 40)}, State: delivery.MergeMerged, Commit: commit}}}) + p.w.put(buckets.Merges, "feat/web", merge) + other, err := mdc.EncodeMerge(mdseat.MergesByGroupValue{Group: "feat/other", Ask: "merge.feat_other.0123456789ab", Pulls: []mdseat.MergesByGroupValuePullsItem{ + {Repository: "novox/x", Number: 4, Head: strings.Repeat("4", 40), State: string(mdc.MergeMerged), Commit: commit}}}) if err != nil { t.Fatal(err) } - p.w.put(delivery.MergesBucket, "feat/other", other) + p.w.put(buckets.Merges, "feat/other", other) } - have, _ := delivery.EncodeHave(delivery.Have{FP: c.at, Composed: was, Phase: delivery.PhaseWaitingYes, Health: delivery.HealthOK, - Judgement: delivery.JudgedRecreates, Waits: waiting, Detail: "it recreates web.server", BuildAlone: c.alone}) - p.w.put(delivery.HaveBucket, "g14.web", have) + have, _ := nodeengineclient.EncodeHave(nodeengine.HaveByNodeModuleValue{Fp: c.at, Composed: was, Phase: nodeengineclient.PhaseWaitingYes, Health: nodeengineclient.HealthOK, + Judgement: assign.JudgedRecreates, Waits: waiting, Detail: "it recreates web.server", BuildAlone: c.alone}) + p.w.put(buckets.Have, "g14.web", have) m := assign.Module{Name: "web", FP: fpTest, Nodes: []string{"g14"}, Self: func(string) bool { return true }, Release: func(fp string) (string, bool) { return p.w.release("web", fp) }} - out := assign.Outcome{Wants: map[string]delivery.Want{"g14": {FP: fpTest, Self: true}}} - p.yeses(context.Background(), "web", m, map[string]delivery.Want{"g14": {FP: fpOld, Self: true}}, &out) + out := assign.Outcome{Wants: map[string]mwc.Want{"g14": {Fp: fpTest, Self: true}}} + p.yeses(context.Background(), "web", m, map[string]mwc.Want{"g14": {Fp: fpOld, Self: true}}, &out) w := out.Wants["g14"] if got := w.Yes == waiting && w.Release == "feat/web"; got != c.yes || (!c.yes && (w.Yes != "" || w.Release != "")) { t.Errorf("%s: the want is %+v", c.name, w) } - akey, _ := delivery.RecordAskKey("g14", "web", waiting) + akey, _ := asks.RecordAskKey("g14", "web", waiting) if _, there := asksKV.values[akey]; there != c.asked { t.Errorf("%s: a record ask raised %v, want %v", c.name, there, c.asked) } } } -// 774/S26/AC1 (ADR 0549 §5, ADR 0567 §8): the seat's placement alone names where modules run: no controller state is read, so a -// module in an assignment only is no module of its node, and before any placement is read nothing is placed. -func aPlacementAloneNamesWhereModulesRun(t *testing.T) { - p := newPasser(buckets{}, time.Date(2026, 10, 26, 18, 0, 0, 0, time.UTC)) +// 774/S26/AC1 (ADR 0549 §5, ADR 0567 §8): the seat's assignments alone names where modules run: no controller state is read, so a +// module in an assignment only is no module of its node, and before any assignments is read nothing is placed. +func aAssignmentsAloneNamesWhereModulesRun(t *testing.T) { + p := newPasser(kvSet{}, time.Date(2026, 10, 26, 18, 0, 0, 0, time.UTC)) p.w.put("mesh-controller_assignments", "g14.b", []byte(`{"node":"g14","module":"b","build":"b-1"}`)) // read no more if got := p.w.assigned(); len(got) != 0 { - t.Fatalf("before any placement: assigned %v", got) + t.Fatalf("before any assignments: assigned %v", got) } placeOn(t, p.w, "g14", "c", "a") placeOn(t, p.w, "n2", "d") if got := p.w.assigned(); !slices.Equal(got["g14"], []string{"a", "c"}) || !slices.Equal(got["n2"], []string{"d"}) || len(got) != 2 { - t.Errorf("the placement alone: assigned %v", got) + t.Errorf("the assignments alone: assigned %v", got) } } @@ -906,47 +991,47 @@ func aPlacementAloneNamesWhereModulesRun(t *testing.T) { // names no composition; a have waiting for a yes raises the record ask once, and the yes is written into the want and // kept there while the want stays at its build. func aPlacedNodesWantsNameTheBuildAloneAndItsYesComesFromItsHave(t *testing.T) { - fpOld := delivery.SourcePrefix2 + strings.Repeat("a", 64) + fpOld := meshcatalogclient.SourcePrefix2 + strings.Repeat("a", 64) waiting, was := "sha256:"+strings.Repeat("e", 64), "sha256:"+strings.Repeat("d", 64) asksKV, wantsKV := &memKV{values: map[string][]byte{}}, &memKV{values: map[string][]byte{}} - p := newPasser(buckets{delivery.AssignerAsksBucket: asksKV, delivery.AssignerWantsBucket: wantsKV}, time.Now()) + p := newPasser(kvSet{buckets.Asks: asksKV, buckets.Wants: wantsKV}, time.Now()) p.logf = t.Logf put := func(bucket, key string, v []byte) { p.w.put(bucket, key, v) } - mod, _ := delivery.EncodeModule(delivery.Module{FP2: fpTest, Repository: "novox/web", Commit: strings.Repeat("1", 40), Path: delivery.RootPath}) - put(delivery.ModulesBucket, "web", mod) // the trunk's build is not done: rule 1 holds g14 at fpOld + mod, _ := meshcatalogclient.EncodeModule(meshcatalog.ModulesByModuleValue{Fp2: fpTest, Repository: "novox/web", Commit: strings.Repeat("1", 40), Path: meshcatalogclient.RootPath}) + put(buckets.Modules, "web", mod) // the trunk's build is not done: rule 1 holds g14 at fpOld placeOn(t, p.w, "g14", "web") - pl, _ := delivery.EncodePlacement(delivery.Placement{Node: "g14", Modules: []delivery.PlacedModule{{Module: "web"}}}) - put(delivery.PlacementBucket, "placement.g14", pl) - on, _ := delivery.EncodePresence(delivery.Presence{Online: true}) - put(delivery.PresenceBucket, "g14", on) - have, _ := delivery.EncodeHave(delivery.Have{FP: fpOld, Composed: was, Phase: delivery.PhaseRunning, Health: delivery.HealthOK}) - put(delivery.HaveBucket, "g14.web", have) - wkey, _ := delivery.WantKey("g14", "web") - want, _ := delivery.EncodeWant(delivery.Want{FP: fpOld, Composed: was}) + pl, _ := mmac.EncodeAssignments(mma.Assignments{Node: "g14", Modules: []mma.PlacedModule{{Module: "web"}}}) + put(plc.AssignmentsBucket, "assignments.g14", pl) + on, _ := presenceValue(true) + put(buckets.Presence, "g14", on) + have, _ := nodeengineclient.EncodeHave(nodeengine.HaveByNodeModuleValue{Fp: fpOld, Composed: was, Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthOK}) + put(buckets.Have, "g14.web", have) + wkey, _ := mwc.WantKey("g14", "web") + want, _ := mwc.EncodeWant(mwc.Want{Fp: fpOld, Composed: was}) wantsKV.values[wkey] = want - put(delivery.AssignerWantsBucket, wkey, want) + put(buckets.Wants, wkey, want) sync := func() { for k, v := range wantsKV.values { - put(delivery.AssignerWantsBucket, k, v) + put(buckets.Wants, k, v) } } p.wants(context.Background()) p.nodeWants(context.Background()) sync() - got, err := delivery.DecodeWant(wantsKV.values[wkey]) - if err != nil || got.FP != fpOld || got.Composed != "" || !got.Self { + got, err := mwc.DecodeWant(wantsKV.values[wkey]) + if err != nil || got.Fp != fpOld || got.Composed != "" || !got.Self { t.Fatalf("the placed node's want is %+v (%v), want it at %s, the node composing it", got, err, fpOld) } - nkey, _ := delivery.WantNodeKey("g14") - if nw, err := delivery.DecodeNodeWant(wantsKV.values[nkey]); err != nil || nw.Composed != "" || !nw.Self { + nkey, _ := mwc.WantNodeKey("g14") + if nw, err := mwc.DecodeNodeWant(wantsKV.values[nkey]); err != nil || nw.Composed != "" || !nw.Self { t.Fatalf("the placed node's own want is %+v (%v), want one naming no composition", nw, err) } - waits, _ := delivery.EncodeHave(delivery.Have{FP: fpOld, Composed: was, Phase: delivery.PhaseWaitingYes, Health: delivery.HealthOK, - Judgement: delivery.JudgedRecreates, Waits: waiting, Detail: "it recreates web.server"}) - put(delivery.HaveBucket, "g14.web", waits) - akey, _ := delivery.RecordAskKey("g14", "web", waiting) + waits, _ := nodeengineclient.EncodeHave(nodeengine.HaveByNodeModuleValue{Fp: fpOld, Composed: was, Phase: nodeengineclient.PhaseWaitingYes, Health: nodeengineclient.HealthOK, + Judgement: assign.JudgedRecreates, Waits: waiting, Detail: "it recreates web.server"}) + put(buckets.Have, "g14.web", waits) + akey, _ := asks.RecordAskKey("g14", "web", waiting) p.wants(context.Background()) if _, there := asksKV.values[akey]; !there { t.Fatal("a have waiting for a yes raised no record ask") @@ -966,49 +1051,49 @@ func aPlacedNodesWantsNameTheBuildAloneAndItsYesComesFromItsHave(t *testing.T) { t.Fatalf("a pass that changed nothing put %d wants and %d asks", wantsKV.puts-wantPuts, asksKV.puts-askPuts) } // Whichever rule asks it, a want put with the value the bucket holds reaches no bucket: each put is a change its node hears. - p.put(context.Background(), delivery.AssignerWantsBucket, wkey, func() ([]byte, error) { return wantsKV.values[wkey], nil }) + p.put(context.Background(), buckets.Wants, wkey, func() ([]byte, error) { return wantsKV.values[wkey], nil }) if wantsKV.puts != wantPuts { t.Fatalf("a want put unchanged reached the bucket") } - a, err := delivery.DecodeAssignerAsk(akey, asksKV.values[akey]) + a, err := decodeAsk(akey, asksKV.values[akey]) if err != nil { t.Fatal(err) } - put(delivery.AssignerAsksBucket, akey, asksKV.values[akey]) - yes, _ := delivery.EncodeAnswer(delivery.Answer{Option: assign.OptionYes, Warrant: a.Ask.ID + "@2026-10-26T19:00:00Z", Words: "the operator"}) - put(delivery.AnswersBucket, akey, yes) + put(buckets.Asks, akey, asksKV.values[akey]) + yes, _ := mdc.EncodeAnswer(mdc.Answer{Option: assign.OptionYes, Warrant: a.Ask.ID + "@2026-10-26T19:00:00Z", Words: "the operator"}) + put(buckets.Answers, akey, yes) p.wants(context.Background()) sync() - if got, err = delivery.DecodeWant(wantsKV.values[wkey]); err != nil || got.Yes != waiting || got.FP != fpOld { + if got, err = mwc.DecodeWant(wantsKV.values[wkey]); err != nil || got.Yes != waiting || got.Fp != fpOld { t.Fatalf("after the yes the want is %+v (%v), want it to name the yes %s", got, err, waiting) } // The node applied it: its have runs the composition; the yes stays while the want stays at its build. - ran, _ := delivery.EncodeHave(delivery.Have{FP: fpOld, Composed: waiting, Phase: delivery.PhaseRunning, Health: delivery.HealthOK}) - put(delivery.HaveBucket, "g14.web", ran) + ran, _ := nodeengineclient.EncodeHave(nodeengine.HaveByNodeModuleValue{Fp: fpOld, Composed: waiting, Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthOK}) + put(buckets.Have, "g14.web", ran) p.wants(context.Background()) - if got, _ = delivery.DecodeWant(wantsKV.values[wkey]); got.Yes != waiting { + if got, _ = mwc.DecodeWant(wantsKV.values[wkey]); got.Yes != waiting { t.Errorf("a pass after the move dropped the yes: %+v", got) } } -// 774/S25/AC1 (ADR 0567 §8): an approved placement change is written and followed with its warrant recorded and -// placement-changed said, and no verb of the controller is called; the refused-call conditions a previous process raised are +// 774/S25/AC1 (ADR 0567 §8): an approved assignments change is written and followed with its warrant recorded and +// assignments-changed said, and no verb of the controller is called; the refused-call conditions a previous process raised are // cleared once, and said again at a later pass while the findings cannot be read. -func TestAnApprovedPlacementCallsNoControllerVerb(t *testing.T) { - p := newPasser(buckets{}, time.Date(2026, 11, 2, 9, 0, 0, 0, time.UTC)) +func TestAnApprovedAssignmentsCallsNoControllerVerb(t *testing.T) { + p := newPasser(kvSet{}, time.Date(2026, 11, 2, 9, 0, 0, 0, time.UTC)) bus := &fakeBus{calls: make(chan string, 8)} p.seat = newSeatActs(bus, func() {}, func(string, ...any) {}) var said []string p.publish = func(event string, _ any, _ string) error { said = append(said, event); return nil } - prop := delivery.PlacementProposal{ID: "p-one", By: "operator@laptop", Why: "move web"} - change := delivery.ChangeOf(delivery.Placement{Node: "anchor"}, delivery.Placement{Node: "anchor", Modules: []delivery.PlacedModule{{Module: "web"}}}) - p.followWrite(prop, delivery.Answer{}, []delivery.PlacementChange{change}) - if len(bus.calls) != 0 || !slices.Equal(said, []string{delivery.PlacementChangedEvent}) { + prop := mma.Proposal{ID: "p-one", By: "operator@laptop", Why: "move web"} + change := plc.ChangeOf(mma.Assignments{Node: "anchor"}, mma.Assignments{Node: "anchor", Modules: []mma.PlacedModule{{Module: "web"}}}) + p.followWrite(prop, mdc.Answer{}, []plc.Change{change}) + if len(bus.calls) != 0 || !slices.Equal(said, []string{mma.EventAssignmentsChanged}) { t.Fatalf("calls = %d, said = %v", len(bus.calls), said) } - var conds []asks.Condition - p.say = func(c asks.Condition) error { conds = append(conds, c); return nil } + var conds []oc.Condition + p.say = func(c oc.Condition) error { conds = append(conds, c); return nil } fails := 1 p.findings.Keys = func() ([]string, error) { if fails > 0 { @@ -1020,7 +1105,7 @@ func TestAnApprovedPlacementCallsNoControllerVerb(t *testing.T) { p.clearStaleCalls() p.clearStaleCalls() p.clearStaleCalls() - if len(conds) != 2 || conds[0].State != asks.StateCleared || conds[0].Key != "mesh-delivery-assigner.controller-call.anchor.web" || + if len(conds) != 2 || conds[0].State != occ.StateCleared || conds[0].Key != "mesh-delivery-assigner.controller-call.anchor.web" || conds[1].Key != "mesh-delivery-assigner.controller-pin.laptop.db" { t.Fatalf("the old conditions cleared = %+v", conds) } @@ -1030,8 +1115,8 @@ func TestAnApprovedPlacementCallsNoControllerVerb(t *testing.T) { // module serves; a verb is served once a running healthy build's manifest (the builder's build, the indexer's manifest) // serves it, and a build whose manifest cannot be read counts what the graph says its module serves. No graph read: no wait. func aWantWaitsOnTheVerbEdgesTheGraphKeeps(t *testing.T) { - p := newPasser(buckets{}, time.Now()) - fp := func(c string) string { return delivery.SourcePrefix2 + strings.Repeat(c, 64) } + p := newPasser(kvSet{}, time.Now()) + fp := func(c string) string { return meshcatalogclient.SourcePrefix2 + strings.Repeat(c, 64) } if p.served(context.Background(), nil, graph.Graph{}, assign.Nodes{}) != nil { t.Error("with no graph read, a want waits on a verb") } @@ -1046,26 +1131,26 @@ func aWantWaitsOnTheVerbEdgesTheGraphKeeps(t *testing.T) { if err != nil { t.Fatal(err) } - p.w.put(graph.Bucket, module, raw) + p.w.put(buckets.Graph, module, raw) } g := p.moduleGraph() if got := keptInvokes(g, "caller"); !slices.Equal(got, []string{"seat:x.v"}) || keptInvokes(g, "unknown") != nil { t.Errorf("the kept verb edges of caller = %v", got) } digest := strings.Repeat("d", 64) - man, _ := delivery.EncodeManifest(delivery.Manifest{Module: "server", JSON: `{"module":"server","claims":[{"name":"x","scope":"mesh","serves":["w"]}]}`}) + man, _ := meshcatalogclient.EncodeManifest(meshcatalog.ManifestsByDigestValue{Module: "server", Manifest: `{"module":"server","claims":[{"name":"x","scope":"mesh","serves":["w"]}]}`}) get := func(_ context.Context, key string) ([]byte, error) { if key == digest { return man, nil } return nil, errNotThere } - build, _ := delivery.EncodeBuilderBuild(delivery.BuilderBuild{State: delivery.BuildDone, Manifest: digest}) - key, _ := delivery.BuilderBuildKey("server", fp("f")) - p.w.put(delivery.BuilderBuildsBucket, key, build) - running := delivery.Have{Phase: delivery.PhaseRunning, Health: delivery.HealthOK} - at := func(f string) delivery.Have { h := running; h.FP = f; return h } - served := p.served(context.Background(), get, g, assign.Nodes{Haves: map[string]map[string]delivery.Have{ + build, _ := builds.EncodeBuilderBuild(builds.BuilderBuild{State: builds.BuildDone, Manifest: digest}) + key, _ := builds.BuilderBuildKey("server", fp("f")) + p.w.put(buckets.Builds, key, build) + running := nodeengine.HaveByNodeModuleValue{Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthOK} + at := func(f string) nodeengine.HaveByNodeModuleValue { h := running; h.Fp = f; return h } + served := p.served(context.Background(), get, g, assign.Nodes{Haves: map[string]map[string]nodeengine.HaveByNodeModuleValue{ "g14": {"server": at(fp("f")), "late": at(fp("2"))}}}) for v, want := range map[string]bool{"seat:x.w": true, "seat:x.v": false, "late.t": true} { if served(v) != want { @@ -1077,9 +1162,33 @@ func aWantWaitsOnTheVerbEdgesTheGraphKeeps(t *testing.T) { bus := map[string]catalogue.Manifest{"bus": {Module: "bus", Provides: []catalogue.Offer{{Name: graph.BusState, Scope: catalogue.ScopeMesh}}}} for _, online := range []bool{false, true} { offering := p.offering(context.Background(), get, bus, assign.Nodes{Online: map[string]bool{"n2": online}, - Haves: map[string]map[string]delivery.Have{"n2": {"bus": at(fp("2"))}}}) + Haves: map[string]map[string]nodeengine.HaveByNodeModuleValue{"n2": {"bus": at(fp("2"))}}}) if offering(graph.BusState) != online { t.Errorf("with the bus module's node online %v, bus-state offered %v", online, offering(graph.BusState)) } } } + +// decodedAsk is an asks-state value with the router's ask it carries decoded (mesh-sdk keeps it as raw JSON). +type decodedAsk struct { + Asking int + Ask oc.Ask +} + +func decodeAsk(key string, data []byte) (decodedAsk, error) { + a, ask, err := assign.DecodeAsk(key, data) + return decodedAsk{Asking: a.Asking, Ask: ask}, err +} + +func mustAskID(a asks.Ask) string { + ask, err := assign.AskOf(a) + if err != nil { + panic(err) + } + return ask.ID +} + +// presenceValue is a node's presence as the watchman writes it. +func presenceValue(online bool) ([]byte, error) { + return json.Marshal(wdc.NodePresence{V: 1, Online: online}) +} diff --git a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/puts.go b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/puts.go index fbf20327..492f4219 100644 --- a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/puts.go +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/puts.go @@ -5,15 +5,23 @@ import ( "encoding/json" "errors" "fmt" + "mesh-delivery-assigner/internal/buckets" + "mesh-delivery-assigner/internal/puts" "sort" "strings" "sync" "time" - "git.novox.be/novox/mesh-sdk/go/asks" + "git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient" + "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliveryclient" + mwc "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshwantsclient" + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-assigner/asks" + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds" + "git.novox.be/novox/mesh-delivery/modules/mesh-grants/grantsstate" + + "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshhandactsclient" + occ "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient" "git.novox.be/novox/mesh-sdk/go/catalogue" - "git.novox.be/novox/mesh-sdk/go/delivery" - "git.novox.be/novox/mesh-sdk/go/handacts" "mesh-delivery-assigner/internal/assign" ) @@ -48,7 +56,7 @@ type putActs struct { snapNo map[string]string warranted map[string]bool // rec records the put's warrant in the hand-act log (ADR 0565 §11). - rec handacts.Recorder + rec meshhandactsclient.Recorder warrantNo map[string]string said map[string]string // snapshot takes the bus's snapshot on node; a variable so a test takes none. @@ -56,32 +64,32 @@ type putActs struct { } func newPutActs(bus caller, wake func(), logf func(string, ...any)) *putActs { - a := &putActs{rec: handacts.Recorder{Ask: bus.Call}, bus: bus, wake: wake, logf: logf, snapping: map[string]bool{}, snapped: map[string]string{}, snapNo: map[string]string{}, warranted: map[string]bool{}, warrantNo: map[string]string{}, + a := &putActs{rec: meshhandactsclient.Recorder{Ask: bus.Call}, bus: bus, wake: wake, logf: logf, snapping: map[string]bool{}, snapped: map[string]string{}, snapNo: map[string]string{}, warranted: map[string]bool{}, warrantNo: map[string]string{}, said: map[string]string{}} a.snapshot = a.snapshotTheBus return a } // identity names one put: its key and when it was asked, so a put asked again under the key is another. -func identity(key string, p delivery.Put) string { +func identity(key string, p puts.Put) string { return key + "@" + p.At.UTC().Format(time.RFC3339Nano) } // putsByModule is every put the assigner holds, by module then node. -func (p *passer) putsByModule() map[string]map[string]delivery.Put { - out := map[string]map[string]delivery.Put{} - for key, value := range p.w.read(delivery.AssignerPutsBucket) { - node, module, err := delivery.ParsePutKey(key) +func (p *passer) putsByModule() map[string]map[string]puts.Put { + out := map[string]map[string]puts.Put{} + for key, value := range p.w.read(buckets.Puts) { + node, module, err := puts.ParseKey(key) if err != nil { continue } - put, err := delivery.DecodePut(value) + put, err := puts.Decode(value) if err != nil { p.logf("the put %s cannot be read, and is left: %v", key, err) continue } if out[module] == nil { - out[module] = map[string]delivery.Put{} + out[module] = map[string]puts.Put{} } out[module][node] = put } @@ -99,7 +107,7 @@ func (p *passer) puts(ctx context.Context) { } assigned := p.w.assigned() trunk := p.w.trunk() - get := kvGetter(p.kvs[delivery.ManifestsBucket]) + get := p.w.getter(buckets.Manifests) // the indexer's manifests, as its first view and views family say them wanted := p.w.wantsOf() modules := make([]string, 0, len(all)) for m := range all { @@ -114,29 +122,29 @@ func (p *passer) puts(ctx context.Context) { } } for node, put := range all[module] { - if delivery.PutEnded(put.Step) { + if puts.Ended(put.Step) { continue } - key, _ := delivery.PutKey(node, module) + key, _ := puts.Key(node, module) id := identity(key, put) world := p.putWorld(node, module, put, manifest, assigned[node]) world.Snapshot, world.Snapshotting, world.SnapshotFailed = p.acts.state(id) next := assign.StepPut(world) // A held bus whose user list the rules moved on (ADR 0539 §7): the put follows the composition its want names. - if w, ok := wanted[module][node]; ok && w.Put && w.FP == next.Put.FP && w.Composed != "" && w.Composed != next.Put.Composed && - (next.Put.Step == delivery.PutWanted || next.Put.Step == delivery.PutHolding) { - if d, _ := p.composedFor(node, module, w.FP); d != nil && d.UserListOnly && d.Digest == w.Composed { + if w, ok := wanted[module][node]; ok && w.Put && w.Fp == next.Put.FP && w.Composed != "" && w.Composed != next.Put.Composed && + (next.Put.Step == puts.Wanted || next.Put.Step == puts.Holding) { + if d, _ := p.composedFor(node, module, w.Fp); d != nil && d.UserListOnly && d.Digest == w.Composed { next.Put = next.Put.Said(world.Now, next.Put.Step, fmt.Sprintf("the bus's user list changed: the want names composition %s", shortHex(w.Composed))) next.Put.Composed = w.Composed } } // The bus's want is written only for the act the operator's yes bound (ADR 0546 clause 8). - if next.Put.Step == delivery.PutWanted && put.Step != delivery.PutWanted && world.Bus && next.Put.Ask != "" { + if next.Put.Step == puts.Wanted && put.Step != puts.Wanted && world.Bus && next.Put.Ask != "" { if err := p.performs(next.Put.Ask, assign.PutAct(node, module, next.Put.FP, next.Put.Composed)); err != nil { - next = assign.PutNext{Put: next.Put.Said(world.Now, delivery.PutRefused, "the bus's want is not written: "+err.Error())} + next = assign.PutNext{Put: next.Put.Said(world.Now, puts.Refused, "the bus's want is not written: "+err.Error())} } } - if failed := p.acts.warrantFailed(id); failed != "" && (next.Put.Step == delivery.PutWanted || next.Put.Step == delivery.PutHolding) && + if failed := p.acts.warrantFailed(id); failed != "" && (next.Put.Step == puts.Wanted || next.Put.Step == puts.Holding) && !strings.Contains(next.Put.Words, failed) { next.Put = next.Put.Said(world.Now, next.Put.Step, next.Put.Words+"; "+failed) } @@ -145,38 +153,38 @@ func (p *passer) puts(ctx context.Context) { if !written { continue // released or put again meanwhile: nothing is done for the put that no longer stands } - if next.Put.Step == delivery.PutWanted && put.Step != delivery.PutWanted { + if next.Put.Step == puts.Wanted && put.Step != puts.Wanted { // The want is written in this pass too, before the next one would: Module.Puts writes the same. - if wkey, err := delivery.WantKey(node, module); err == nil { + if wkey, err := mwc.WantKey(node, module); err == nil { want := assign.PutWant(next.Put) - if before, ok := wanted[module][node]; !ok || !delivery.SameWant(normalWant(before), normalWant(want)) { - p.put(ctx, delivery.AssignerWantsBucket, wkey, func() ([]byte, error) { return delivery.EncodeWant(want) }) + if before, ok := wanted[module][node]; !ok || !mwc.SameWant(normalWant(before), normalWant(want)) { + p.put(ctx, buckets.Wants, wkey, func() ([]byte, error) { return mwc.EncodeWant(want) }) } } if next.Warranted { - p.acts.warrant(id, delivery.AskingID(next.Put.Ask, assign.PutAsking(next.Put))) + p.acts.warrant(id, meshdeliveryclient.AskingID(next.Put.Ask, assign.PutAsking(next.Put))) } } } } } -func normalWant(w delivery.Want) delivery.Want { +func normalWant(w mwc.Want) mwc.Want { w.V = 0 return w } // putWorld is what one step of a put reads from the world. -func (p *passer) putWorld(node, module string, put delivery.Put, manifest catalogue.Manifest, assignedHere []string) assign.PutWorld { - w := assign.PutWorld{Node: node, Module: module, Put: put, Now: p.clock(), Bus: catalogue.ProvidesBus(manifest), Newest: p.w.trunk()[module].FP2} +func (p *passer) putWorld(node, module string, put puts.Put, manifest catalogue.Manifest, assignedHere []string) assign.PutWorld { + w := assign.PutWorld{Node: node, Module: module, Put: put, Now: p.clock(), Bus: catalogue.ProvidesBus(manifest), Newest: p.w.trunk()[module].Fp2} for _, m := range assignedHere { if m == module { w.Assigned = true } } - if bkey, err := delivery.BuilderBuildKey(module, put.FP); err == nil { - if raw := p.w.value(delivery.BuilderBuildsBucket, bkey); raw != nil { - if b, err := delivery.DecodeBuilderBuild(raw); err == nil { + if bkey, err := builds.BuilderBuildKey(module, put.FP); err == nil { + if raw := p.w.value(buckets.Builds, bkey); raw != nil { + if b, err := builds.DecodeBuilderBuild(raw); err == nil { w.Build, w.BuildWords = b.State, b.Error } else { w.Build = "unreadable: " + err.Error() @@ -193,23 +201,24 @@ func (p *passer) putWorld(node, module string, put delivery.Put, manifest catalo policy, _, _ = catalogue.DerivedUpgrade(manifest) } w.Recorded = policy == catalogue.PolicyRecord - if akey, err := delivery.RecordAskKey(node, module, d.Digest); err == nil && put.Ask == akey { + if akey, err := asks.RecordAskKey(node, module, d.Digest); err == nil && put.Ask == akey { // Only this put's own ask counts: an ask (and its answer) of an earlier put under the same key is another asking. - if a, err := delivery.DecodeAssignerAsk(akey, p.w.value(delivery.AssignerAsksBucket, akey)); err == nil && a.Asking == assign.PutAsking(put) { + if a, ask, err := assign.DecodeAsk(akey, p.w.value(buckets.Asks, akey)); err == nil && a.Asking == assign.PutAsking(put) { switch { case p.answeredWith(akey, assign.OptionYes): w.Answer = assign.OptionYes case p.answeredWith(akey, assign.OptionNo): w.Answer = assign.OptionNo default: - w.AskExpired = !a.Ask.Expires.IsZero() && w.Now.After(a.Ask.Expires) + expires := assign.ExpiresAt(ask) + w.AskExpired = !expires.IsZero() && w.Now.After(expires) } } } } - if hkey, err := delivery.HaveKey(node, module); err == nil { - if raw := p.w.value(delivery.HaveBucket, hkey); raw != nil { - if h, err := delivery.DecodeHave(raw); err == nil { + if hkey, err := nodeengineclient.HaveKey(node, module); err == nil { + if raw := p.w.value(buckets.Have, hkey); raw != nil { + if h, err := nodeengineclient.DecodeHave(raw); err == nil { w.Have = &h } } @@ -220,15 +229,15 @@ func (p *passer) putWorld(node, module string, put delivery.Put, manifest catalo // composedFor is the composition of module for node at fp and whether the grant at fp names the node's accounts: the one // place a put reads them (ADR 0546 clause 4). Today their writer is the controller (mesh-controller_composed and // mesh-controller_grants); the grants are the grants part's while the controller's lease is handed away (grantsBucket). -func (p *passer) composedFor(node, module, fp string) (*delivery.ComposedDigest, bool) { - var out *delivery.ComposedDigest +func (p *passer) composedFor(node, module, fp string) (*assign.ComposedDigest, bool) { + var out *assign.ComposedDigest if d, ok := p.w.composed(node, module, fp); ok { out = &d } granted := false - if gkey, err := delivery.PartGrantKey(module, fp); err == nil { + if gkey, err := grantsstate.PartGrantKey(module, fp); err == nil { if raw := p.w.value(p.w.grantsBucket(), gkey); raw != nil { - if g, err := delivery.DecodeGrant(raw); err == nil { + if g, err := grantsstate.DecodeGrant(raw); err == nil { granted = g.NamesNode(node, module) } } @@ -238,17 +247,17 @@ func (p *passer) composedFor(node, module, fp string) (*delivery.ComposedDigest, // actOn does the acts a step asks for. func (p *passer) actOn(ctx context.Context, node, module, id string, w assign.PutWorld, next assign.PutNext) { - if next.Put.Step == delivery.PutPerson && next.Put.Ask != "" && w.Composed != nil { + if next.Put.Step == puts.Person && next.Put.Ask != "" && w.Composed != nil { // The put's own ask, written again over one an earlier put left under the key (its asking differs), so the teller // raises it anew under its own id. akey := next.Put.Ask - current, err := delivery.DecodeAssignerAsk(akey, p.w.value(delivery.AssignerAsksBucket, akey)) + current, _, err := assign.DecodeAsk(akey, p.w.value(buckets.Asks, akey)) if err != nil || current.Asking != assign.PutAsking(next.Put) { a, err := assign.PutAsk(akey, node, module, next.Put, *w.Composed, w.Bus, w.Now) if err != nil { p.logf("the ask about putting %s on %s is not written: %v", module, node, err) } else { - p.put(ctx, delivery.AssignerAsksBucket, akey, func() ([]byte, error) { return delivery.EncodeAssignerAsk(akey, a) }) + p.put(ctx, buckets.Asks, akey, func() ([]byte, error) { return assign.EncodeAsk(akey, a) }) } } } @@ -257,29 +266,29 @@ func (p *passer) actOn(ctx context.Context, node, module, id string, w assign.Pu } } -// performs checks that the act about to be performed is the one the yes of the ask at key bound (asks.Option.Performs). -func (p *passer) performs(key string, act asks.Act) error { - a, err := delivery.DecodeAssignerAsk(key, p.w.value(delivery.AssignerAsksBucket, key)) +// performs checks that the act about to be performed is the one the yes of the ask at key bound (occ.Performs). +func (p *passer) performs(key string, act occ.Act) error { + _, ask, err := assign.DecodeAsk(key, p.w.value(buckets.Asks, key)) if err != nil { return fmt.Errorf("the ask %s cannot be read: %w", key, err) } - for _, o := range a.Ask.Options { + for _, o := range ask.Options { if o.ID == assign.OptionYes { - return o.Performs(act) + return occ.Performs(o, act) } } return fmt.Errorf("the ask %s offers no yes", key) } // samePut says two puts say the same: their step, words and what they found. -func samePut(a, b delivery.Put) bool { +func samePut(a, b puts.Put) bool { return a.Step == b.Step && a.Words == b.Words && a.Composed == b.Composed && a.Ask == b.Ask && a.Snapshot == b.Snapshot } // writePut writes a put's next value over the one the pass read, by compare-and-set, unless the tool has put again or // released it since; and says it as the event put. It says whether it wrote. -func (p *passer) writePut(ctx context.Context, key string, read, next delivery.Put) bool { - kv := p.kvs[delivery.AssignerPutsBucket] +func (p *passer) writePut(ctx context.Context, key string, read, next puts.Put) bool { + kv := p.kvs[buckets.Puts] if kv == nil { return false } @@ -288,11 +297,11 @@ func (p *passer) writePut(ctx context.Context, key string, read, next delivery.P p.logf("the put %s cannot be read again, and is taken further at the next pass: %v", key, err) return false } - now, err := delivery.DecodePut(e.Value()) + now, err := puts.Decode(e.Value()) if err != nil || !now.At.Equal(read.At) || now.Step != read.Step { return false // asked again or released meanwhile: the next pass reads that } - data, err := delivery.EncodePut(next) + data, err := puts.Encode(next) if err != nil { p.logf("the put %s is not written: %v", key, err) return false @@ -303,7 +312,7 @@ func (p *passer) writePut(ctx context.Context, key string, read, next delivery.P } return false } - p.w.put(delivery.AssignerPutsBucket, key, data) + p.w.put(buckets.Puts, key, data) p.logf("put %s: %s: %s", key, next.Step, next.Words) p.acts.say(key, next) return true @@ -399,7 +408,7 @@ func (a *putActs) warrant(id, ask string) { a.warranted[id] = true a.mu.Unlock() go func() { - if _, err := a.rec.RecordWarrant(delivery.TellerModule, ask); err != nil { + if _, err := a.rec.RecordWarrant(buckets.TellerModule, ask); err != nil { a.logf("the put's warrant %s is not recorded in the hand-act log: %v", ask, err) a.mu.Lock() a.warrantNo[id] = fmt.Sprintf("the warrant %s is not recorded in the hand-act log: %v", ask, err) @@ -428,8 +437,8 @@ type putSaid struct { } // say says a put's step as the event put, once per step and words. -func (a *putActs) say(key string, p delivery.Put) { - node, module, err := delivery.ParsePutKey(key) +func (a *putActs) say(key string, p puts.Put) { + node, module, err := puts.ParseKey(key) if err != nil || a.bus == nil { return } diff --git a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/seat.go b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/seat.go index ceb25001..f9f47447 100644 --- a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/seat.go +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/seat.go @@ -6,46 +6,59 @@ import ( "errors" "fmt" "maps" + "mesh-delivery-assigner/internal/assignment" + "mesh-delivery-assigner/internal/buckets" "reflect" "slices" "sort" "strings" "time" + "git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient" + "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" + "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" + mdcc "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliverycheckerclient" + mdc "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliveryclient" + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-assigner/asks" + "github.com/nats-io/nats.go" "github.com/nats-io/nats.go/jetstream" - "git.novox.be/novox/mesh-sdk/go/asks" - "git.novox.be/novox/mesh-sdk/go/delivery" - "git.novox.be/novox/mesh-sdk/go/handacts" - "git.novox.be/novox/mesh-sdk/go/process" + mmac "git.novox.be/novox/mesh-contracts/go/clients/meshmoduleassignerclient" + mma "git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner" + "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshhandactsclient" + occ "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient" + "git.novox.be/novox/mesh-sdk/go/seatdef" "mesh-delivery-assigner/internal/assign" + plc "mesh-delivery-assigner/internal/assignment" "mesh-delivery-assigner/internal/judge" ) -// The seat mesh-placement's holder in the assigner's process (novox/hq ADR 0567 §2-§8): each pass follows every open -// proposal one step, from what the world holds. A proposal the verbs wrote is said (placement-proposed); the nodes it +// The seat mesh-module-assigner's holder in the assigner's process (novox/hq ADR 0567 §2-§8): each pass follows every open +// proposal one step, from what the world holds. A proposal the verbs wrote is said (assignments-proposed); the nodes it // must hear from are found by composing every node with and without it, and those its key does not change get a reached -// candidate; their answers are judged (placement-judged) and, on a passing verdict, the operator is asked to approve it; -// on the warrant each changed key is written by compare-and-set at the revision it was laid over (placement-changed), +// candidate; their answers are judged (assignments-judged) and, on a passing verdict, the operator is asked to approve it; +// on the warrant each changed key is written by compare-and-set at the revision it was laid over (assignments-changed), // and the controller's assignments are kept in step through its verbs until the lease write. A key moved meanwhile -// lays the proposal again; a decline, an expiry or a withdrawal ends it (placement-withdrawn). Every event is said on the +// lays the proposal again; a decline, an expiry or a withdrawal ends it (assignments-withdrawn). Every event is said on the // assigner's own stream, so only this process says them. Nothing polls: the wake at an answer's bound is the one wake. // publisher says one of the seat's events on the assigner's stream, under a message id the bus keeps one of. type publisher func(event string, body any, id string) error -// streamPublisher publishes on mesh.stream.mesh-delivery-assigner.event., with the headers an emit carries. -func streamPublisher(ctx context.Context, js jetstream.JetStream, node string) publisher { +// seatPublisher publishes the seat's events on mesh.seat.mesh-module-assigner.event., the seat's own subject, which +// the shared events stream keeps and every consumer of `mesh-module-assigner.` hears (the seat's family, 7 days), +// with the headers an emit carries. +func seatPublisher(ctx context.Context, js jetstream.JetStream, node string) publisher { return func(event string, body any, id string) error { data, err := json.Marshal(body) if err != nil { return err } - msg := nats.NewMsg(process.ModuleStreamSubject(delivery.AssignerModule, event)) + msg := nats.NewMsg(seatdef.EventSubject(mma.Seat, event)) msg.Header.Set("x-event-id", id) - msg.Header.Set("x-source", delivery.AssignerModule) + msg.Header.Set("x-source", assignment.Module) msg.Header.Set("x-node", node) msg.Header.Set("x-time", time.Now().UTC().Format(time.RFC3339Nano)) msg.Header.Set("content-type", "application/json") @@ -56,32 +69,32 @@ func streamPublisher(ctx context.Context, js jetstream.JetStream, node string) p } // seatActs keeps the seat's acts while this process runs: the warrant recorded in the hand-act log. The seat calls no verb of -// the controller: placement is the one source (ADR 0567 §8, issue 774). +// the controller: assignments is the one source (ADR 0567 §8, issue 774). type seatActs struct { bus caller wake func() logf func(string, ...any) // rec records the seat's acts in the hand-act log (ADR 0565 §11). - rec handacts.Recorder + rec meshhandactsclient.Recorder } func newSeatActs(bus caller, wake func(), logf func(string, ...any)) *seatActs { - return &seatActs{bus: bus, wake: wake, logf: logf, rec: handacts.Recorder{Ask: bus.Call}} + return &seatActs{bus: bus, wake: wake, logf: logf, rec: meshhandactsclient.Recorder{Ask: bus.Call}} } -// record records once, in the hand-act log, that placement was written on the operator's warrant (§5): the router's +// record records once, in the hand-act log, that assignments was written on the operator's warrant (§5): the router's // record of the teller's ask says who chose and how (ADR 0274). The log is the seat mesh-hand-acts' from its delivery, and -// the controller's while that seat has no holder (ADR 0565 §11, handacts.Recorder). +// the controller's while that seat has no holder (ADR 0565 §11, meshhandactsclient.Recorder). func (a *seatActs) record(warrant string) { if a == nil || warrant == "" { return } ask, _, _ := strings.Cut(warrant, "@") go func() { - if taken, err := a.rec.RecordWarrant(delivery.TellerModule, ask); err != nil { - a.logf("the placement's warrant %s is not recorded in the hand-act log: %v", ask, err) + if taken, err := a.rec.RecordWarrant(buckets.TellerModule, ask); err != nil { + a.logf("the assignments's warrant %s is not recorded in the hand-act log: %v", ask, err) } else { - a.logf("the placement's warrant %s is recorded in the log of %s", ask, taken.Log) + a.logf("the assignments's warrant %s is recorded in the log of %s", ask, taken.Log) } }() } @@ -124,7 +137,7 @@ func (p *passer) sayWaits(unbound []string) { if p.say == nil || p.said[c.Key] && p.saidWaits == state { return } - if c.State == asks.StateCleared && p.saidWaits == "" { + if c.State == occ.StateCleared && p.saidWaits == "" { return // nothing was raised by this process } if err := p.say(c); err != nil { @@ -134,26 +147,25 @@ func (p *passer) sayWaits(unbound []string) { p.said[c.Key], p.saidWaits = true, state } -// seatPlacements is every node's placement and the revision of its node key (its legacy key's for a node not yet -// converted), as the watch holds them: the revision a proposal is laid over (§2, research 072 row 37). -func (p *passer) seatPlacements() (map[string]delivery.Placement, map[string]uint64) { - return p.w.placement.AllRevisions() +// seatAssignments is every node's assignments and the revision of its node key, as the watch holds them: the revision a proposal is laid over (§2, research 072 row 37). +func (p *passer) seatAssignments() (map[string]mma.Assignments, map[string]uint64) { + return p.w.assignmentKeys.AllRevisions() } // candidatesOf is a proposal's candidates by node, with their keys' revisions and the newest write. -func (p *passer) candidatesOf(id string) (map[string]delivery.PlacementCandidate, map[string]uint64, time.Time) { - out, revs := map[string]delivery.PlacementCandidate{}, map[string]uint64{} +func (p *passer) candidatesOf(id string) (map[string]mma.CandidatesByProposalNodeValue, map[string]uint64, time.Time) { + out, revs := map[string]mma.CandidatesByProposalNodeValue{}, map[string]uint64{} var newest time.Time - for key, value := range p.w.read(delivery.PlacementCandidatesBucket) { - pid, node, err := delivery.ParsePlacementCandidateKey(key) + for key, value := range p.w.read(plc.CandidatesBucket) { + pid, node, err := plc.ParseCandidateKey(key) if err != nil || pid != id { continue } - c, err := delivery.DecodePlacementCandidate(value) + c, err := plc.DecodeCandidate(value) if err != nil { continue } - meta := p.w.metaOf(delivery.PlacementCandidatesBucket, key) + meta := p.w.metaOf(plc.CandidatesBucket, key) out[node], revs[node] = c, meta.revision if meta.at.After(newest) { newest = meta.at @@ -163,14 +175,14 @@ func (p *passer) candidatesOf(id string) (map[string]delivery.PlacementCandidate } // answersTo is, by node, each node's answer to the proposal in its `.node` have. -func (p *passer) answersTo(id string) map[string]*delivery.CandidateAnswer { - out := map[string]*delivery.CandidateAnswer{} - for key, value := range p.w.read(delivery.HaveBucket) { - node, _, isNode, err := delivery.ParseHaveKey(key) +func (p *passer) answersTo(id string) map[string]*nodeengine.CandidateAnswer { + out := map[string]*nodeengine.CandidateAnswer{} + for key, value := range p.w.read(buckets.Have) { + node, _, isNode, err := nodeengineclient.ParseHaveKey(key) if err != nil || !isNode { continue } - h, err := delivery.DecodeHaveNode(value) + h, err := nodeengineclient.DecodeHaveNode(value) if err != nil { continue } @@ -181,8 +193,8 @@ func (p *passer) answersTo(id string) map[string]*delivery.CandidateAnswer { return out } -// placementSeat takes every open proposal one step further. -func (p *passer) placementSeat(ctx context.Context) { +// assignmentsSeat takes every open proposal one step further. +func (p *passer) assignmentsSeat(ctx context.Context) { for _, b := range seatBuckets { if p.kvs[b] == nil { return @@ -191,70 +203,77 @@ func (p *passer) placementSeat(ctx context.Context) { if p.saidProposed == nil { p.saidProposed, p.intended = map[string]bool{}, map[string]map[string]map[string]bool{} } - p.convertPlacement(ctx) - current, revs := p.seatPlacements() + if !p.copyRetired(ctx) { + return // the old state is copied whole before the seat takes a step: a half-copied state would place nothing on a node + } + current, revs := p.seatAssignments() ids := make([]string, 0) - raws := p.w.read(delivery.PlacementProposalsBucket) + raws := p.w.read(plc.ProposalsBucket) for id := range raws { ids = append(ids, id) } sort.Strings(ids) for _, id := range ids { - prop, err := delivery.DecodePlacementProposal(raws[id]) + prop, err := plc.DecodeProposal(raws[id]) if err != nil || prop.ID != id { p.logf("the proposal %s cannot be read, so it is left: %v", id, err) continue } - p.stepProposal(ctx, prop, p.w.metaOf(delivery.PlacementProposalsBucket, id).revision, current, revs) + p.stepProposal(ctx, prop, p.w.metaOf(plc.ProposalsBucket, id).revision, current, revs) } } // pulse writes a bound's key with its time to live (§4): its expiry, heard on the watch as the server's delete marker, // is the event that ends the wait. Written again over one that stands, so its time starts anew. func (p *passer) pulse(ctx context.Context, key string, ttl time.Duration) { - kv := p.kvs[delivery.PlacementPulsesBucket] + kv := p.kvs[plc.PulsesBucket] _ = kv.Delete(ctx, key) rev, err := kv.Create(ctx, key, []byte(`{}`), jetstream.KeyTTL(ttl)) if err != nil { p.logf("the bound %s is not written, so its wait ends at once: %v", key, err) return } - p.w.putEntry(delivery.PlacementPulsesBucket, key, []byte(`{}`), rev, p.clock()) + p.w.putEntry(plc.PulsesBucket, key, []byte(`{}`), rev, p.clock()) } // pulsing says whether a bound's key stands. -func (p *passer) pulsing(key string) bool { return p.w.has(delivery.PlacementPulsesBucket, key) } +func (p *passer) pulsing(key string) bool { return p.w.has(plc.PulsesBucket, key) } // deletePulses deletes every bound of a proposal. func (p *passer) deletePulses(ctx context.Context, id string) { - for key := range p.w.read(delivery.PlacementPulsesBucket) { + for key := range p.w.read(plc.PulsesBucket) { if strings.HasSuffix(key, "."+id) || strings.Contains(key, "."+id+".") { - if err := p.kvs[delivery.PlacementPulsesBucket].Delete(ctx, key); err == nil { - p.w.put(delivery.PlacementPulsesBucket, key, nil) + if err := p.kvs[plc.PulsesBucket].Delete(ctx, key); err == nil { + p.w.put(plc.PulsesBucket, key, nil) } } } } // ask raises (or raises again) a proposal's authorising ask and its bound, and answers the proposal asked. -func (p *passer) ask(ctx context.Context, prop delivery.PlacementProposal, akey, brief string) (delivery.PlacementProposal, bool) { - a, err := assign.PlacementAsk(akey, prop, brief, assign.PlacementAsking(prop, p.clock()), p.clock()) +func (p *passer) ask(ctx context.Context, prop mma.Proposal, akey, brief string) (mma.Proposal, bool) { + a, err := assign.AssignmentsAsk(akey, prop, brief, assign.AssignmentsAsking(prop, p.clock()), p.clock()) if err != nil { p.logf("the ask about the proposal %s is not written: %v", prop.ID, err) return prop, false } - p.put(ctx, delivery.AssignerAsksBucket, akey, func() ([]byte, error) { return delivery.EncodeAssignerAsk(akey, a) }) - if pk, err := delivery.PulseAskKey(prop.ID); err == nil { - p.pulse(ctx, pk, a.Ask.Expires.Sub(p.clock())) + placed, err := assign.AskOf(a) + if err != nil { + p.logf("the ask about the proposal %s is not written: %v", prop.ID, err) + return prop, false } - prop.State, prop.Ask = delivery.ProposalAsked, a.Ask.ID + p.put(ctx, buckets.Asks, akey, func() ([]byte, error) { return assign.EncodeAsk(akey, a) }) + if pk, err := plc.PulseAskKey(prop.ID); err == nil { + p.pulse(ctx, pk, assign.ExpiresAt(placed).Sub(p.clock())) + } + prop.State, prop.Ask = mmac.ProposalAsked, placed.ID return prop, true } // stepProposal takes one proposal one step further. -func (p *passer) stepProposal(ctx context.Context, prop delivery.PlacementProposal, rev uint64, current map[string]delivery.Placement, revs map[string]uint64) { - if !p.saidProposed[prop.ID] && prop.State == delivery.ProposalAnswering && prop.Verdict == nil { - p.sayEvent(delivery.PlacementProposedEvent, delivery.PlacementEvent{Proposal: prop}, prop.ID) +func (p *passer) stepProposal(ctx context.Context, prop mma.Proposal, rev uint64, current map[string]mma.Assignments, revs map[string]uint64) { + if !p.saidProposed[prop.ID] && prop.State == mmac.ProposalAnswering && prop.Verdict == nil { + p.sayEvent(mma.EventAssignmentsProposed, mma.AssignmentsEvent{Proposal: prop}, prop.ID) p.saidProposed[prop.ID] = true } if prop.Withdrawn != "" { @@ -263,17 +282,17 @@ func (p *passer) stepProposal(ctx context.Context, prop delivery.PlacementPropos } if prop.Seed || prop.HandBack { // Adopt and hand-back are gone since the lease write (ADR 0547 §11, ADR 0567 §11): a proposal one left open ends. - p.endProposal(ctx, prop, "adopt and hand-back are gone since the lease write: placement is the mesh's alone") + p.endProposal(ctx, prop, "adopt and hand-back are gone since the lease write: assignments is the mesh's alone") return } if prop.Warrant != nil { p.addNode(ctx, prop, revs) return } - akey, _ := delivery.PlacementAskKey(prop.ID) - again := !prop.Again.IsZero() && (prop.Verdict == nil || prop.Again.After(prop.Verdict.At)) - if prop.State == delivery.ProposalAsked { - askPulse, _ := delivery.PulseAskKey(prop.ID) + akey, _ := asks.AssignmentsAskKey(prop.ID) + again := prop.Again != nil && !prop.Again.IsZero() && (prop.Verdict == nil || prop.Again.After(prop.Verdict.At)) + if prop.State == mmac.ProposalAsked { + askPulse, _ := plc.PulseAskKey(prop.ID) switch { case p.answeredWith(akey, assign.OptionYes): p.warranted(ctx, prop, rev, akey, current, revs) @@ -300,12 +319,12 @@ func (p *passer) stepProposal(ctx context.Context, prop delivery.PlacementPropos return // a candidate just written is not heard yet: its watch wakes the next pass } } - if written.After(prop.Written) { - prop.Written = written + if !written.IsZero() && (prop.Written == nil || written.After(*prop.Written)) { + prop.Written = &written } pulsing := map[string]bool{} for _, node := range prop.Must { - if k, err := delivery.PulseAnswerKey(prop.ID, node); err == nil { + if k, err := plc.PulseAnswerKey(prop.ID, node); err == nil { pulsing[node] = p.pulsing(k) } } @@ -315,12 +334,12 @@ func (p *passer) stepProposal(ctx context.Context, prop delivery.PlacementPropos } prop.Verdict = &v switch v.State { - case delivery.VerdictFail: - prop.State, prop.Ask = delivery.ProposalFailed, "" - case delivery.VerdictWaits: - prop.State, prop.Ask = delivery.ProposalAnswering, "" - case delivery.VerdictPass: - if prop.State != delivery.ProposalAsked || again { + case mdcc.VerdictFail: + prop.State, prop.Ask = mmac.ProposalFailed, "" + case plc.VerdictWaits: + prop.State, prop.Ask = mmac.ProposalAnswering, "" + case mdcc.VerdictPass: + if prop.State != mmac.ProposalAsked || again { var ok bool if prop, ok = p.ask(ctx, prop, akey, judge.Brief(prop, v)); !ok { return @@ -331,21 +350,21 @@ func (p *passer) stepProposal(ctx context.Context, prop delivery.PlacementPropos } } if p.writeProposal(ctx, prop, rev) { - p.sayEvent(delivery.PlacementJudgedEvent, delivery.PlacementEvent{Proposal: prop}, + p.sayEvent(mma.EventAssignmentsJudged, mma.AssignmentsEvent{Proposal: prop}, fmt.Sprintf("%s.judged.%d", prop.ID, v.At.UnixNano())) } } // sameVerdict says two verdicts say the same, their time aside. -func sameVerdict(a, b delivery.PlacementVerdict) bool { +func sameVerdict(a, b mma.Verdict) bool { a.At, b.At = time.Time{}, time.Time{} return reflect.DeepEqual(a, b) } // moved says whether a key the proposal changes is no longer at the revision it was laid over. -func moved(prop delivery.PlacementProposal, revs map[string]uint64) bool { +func moved(prop mma.Proposal, revs map[string]uint64) bool { for node, base := range prop.Base { - if revs[node] != base { + if revs[node] != uint64(base) { return true } } @@ -353,7 +372,7 @@ func moved(prop delivery.PlacementProposal, revs map[string]uint64) bool { } // intendedOf is, by proposal and the revisions it was laid over, what Intended computed, kept while this process runs. -func (p *passer) intendedOf(prop delivery.PlacementProposal, current map[string]delivery.Placement, cands map[string]delivery.PlacementCandidate) map[string]map[string]bool { +func (p *passer) intendedOf(prop mma.Proposal, current map[string]mma.Assignments, cands map[string]mma.CandidatesByProposalNodeValue) map[string]map[string]bool { key := fmt.Sprint(prop.ID, prop.Base) if got, ok := p.intended[key]; ok { return got @@ -363,14 +382,14 @@ func (p *passer) intendedOf(prop delivery.PlacementProposal, current map[string] return got } -// compute is what the proposal changes on each node by its placement, and the nodes that answer it: every node with a -// placement or a record (judge.Intended, ADR 0593 §8). The holder composes nothing: each node says what its compositions +// compute is what the proposal changes on each node by its assignments, and the nodes that answer it: every node with a +// assignments or a record (judge.Intended, ADR 0593 §8). The holder composes nothing: each node says what its compositions // change in its answer. -func (p *passer) compute(prop delivery.PlacementProposal, current map[string]delivery.Placement, cands map[string]delivery.PlacementCandidate) (map[string]map[string]bool, []string) { - laid := map[string]*delivery.Placement{} - for _, node := range prop.Nodes() { +func (p *passer) compute(prop mma.Proposal, current map[string]mma.Assignments, cands map[string]mma.CandidatesByProposalNodeValue) (map[string]map[string]bool, []string) { + laid := map[string]*mma.Assignments{} + for _, node := range plc.Nodes(prop) { if c, ok := cands[node]; ok && !c.Reached { - laid[node] = c.Placement + laid[node] = c.Assignments } } nodes := map[string]bool{} @@ -385,8 +404,8 @@ func (p *passer) compute(prop delivery.PlacementProposal, current map[string]del // mustAnswer finds the nodes the proposal must hear from (§3), writes a reached candidate for each whose key it does not // change, and writes them into the proposal. -func (p *passer) mustAnswer(ctx context.Context, prop delivery.PlacementProposal, rev uint64, current map[string]delivery.Placement, cands map[string]delivery.PlacementCandidate) { - for _, node := range prop.Nodes() { +func (p *passer) mustAnswer(ctx context.Context, prop mma.Proposal, rev uint64, current map[string]mma.Assignments, cands map[string]mma.CandidatesByProposalNodeValue) { + for _, node := range plc.Nodes(prop) { if _, ok := cands[node]; !ok { return // the verbs write the candidates before the proposal: wait to hear them } @@ -397,18 +416,18 @@ func (p *passer) mustAnswer(ctx context.Context, prop delivery.PlacementProposal if _, ok := cands[node]; ok { continue } - key, err := delivery.PlacementCandidateKey(prop.ID, node) + key, err := plc.CandidateKey(prop.ID, node) if err != nil { continue } - c := delivery.PlacementCandidate{Proposal: prop.ID, Reached: true} + c := mma.CandidatesByProposalNodeValue{Proposal: prop.ID, Reached: true} if pl, ok := current[node]; ok { - c.Placement = &pl + c.Assignments = &pl } - p.put(ctx, delivery.PlacementCandidatesBucket, key, func() ([]byte, error) { return delivery.EncodePlacementCandidate(c) }) + p.put(ctx, plc.CandidatesBucket, key, func() ([]byte, error) { return plc.EncodeCandidate(c) }) } for _, node := range must { - if k, err := delivery.PulseAnswerKey(prop.ID, node); err == nil { + if k, err := plc.PulseAnswerKey(prop.ID, node); err == nil { p.pulse(ctx, k, judge.AnswerBound) } } @@ -418,7 +437,7 @@ func (p *passer) mustAnswer(ctx context.Context, prop delivery.PlacementProposal // layAgain lays a proposal over the keys as they stand now (§5: a key moved): its candidates are written again, the // nodes that must answer found again, and it is judged and asked again; a proposal that no longer lays ends. -func (p *passer) layAgain(ctx context.Context, prop delivery.PlacementProposal, rev uint64, current map[string]delivery.Placement, revs map[string]uint64, why string) { +func (p *passer) layAgain(ctx context.Context, prop mma.Proposal, rev uint64, current map[string]mma.Assignments, revs map[string]uint64, why string) { held, recorded := p.lays(ctx) again, laid, findings := assign.Propose(assign.ProposeIn{ID: prop.ID, By: prop.By, Why: prop.Why, At: prop.At, Changes: prop.Changes, Current: current, Revisions: revs, Held: held, Recorded: recorded}) @@ -428,12 +447,12 @@ func (p *passer) layAgain(ctx context.Context, prop delivery.PlacementProposal, } p.deleteCandidates(ctx, prop.ID) p.deletePulses(ctx, prop.ID) - for _, node := range again.Nodes() { - key, _ := delivery.PlacementCandidateKey(prop.ID, node) - c := delivery.PlacementCandidate{Proposal: prop.ID, Placement: laid[node]} - p.put(ctx, delivery.PlacementCandidatesBucket, key, func() ([]byte, error) { return delivery.EncodePlacementCandidate(c) }) + for _, node := range plc.Nodes(again) { + key, _ := plc.CandidateKey(prop.ID, node) + c := mma.CandidatesByProposalNodeValue{Proposal: prop.ID, Assignments: laid[node]} + p.put(ctx, plc.CandidatesBucket, key, func() ([]byte, error) { return plc.EncodeCandidate(c) }) } - again.Written, again.Words = p.clock().UTC(), why+": laid again over the keys as they stand, to be judged and asked again" + again.Written, again.Words = ptr(p.clock().UTC()), why+": laid again over the keys as they stand, to be judged and asked again" p.writeProposal(ctx, again, rev) } @@ -441,8 +460,8 @@ func (p *passer) layAgain(ctx context.Context, prop delivery.PlacementProposal, // nodes with a record. func (p *passer) lays(ctx context.Context) (func(string) bool, func(string) bool) { held := map[string]bool{} - for key := range p.w.read(delivery.ModulesBucket) { - if name, err := delivery.ParseModulesKey(key); err == nil { + for key := range p.w.read(buckets.Modules) { + if name, err := meshcatalogclient.ParseModulesKey(key); err == nil { held[name] = true } } @@ -451,9 +470,9 @@ func (p *passer) lays(ctx context.Context) (func(string) bool, func(string) bool } // warranted writes an approved proposal (§5): each changed key by compare-and-set at the revision it was laid over; then the warrant recorded, the candidates and the -// proposal deleted, and placement-changed said. -func (p *passer) warranted(ctx context.Context, prop delivery.PlacementProposal, rev uint64, akey string, current map[string]delivery.Placement, revs map[string]uint64) { - if err := p.performs(akey, assign.PlacementAct(prop)); err != nil { +// proposal deleted, and assignments-changed said. +func (p *passer) warranted(ctx context.Context, prop mma.Proposal, rev uint64, akey string, current map[string]mma.Assignments, revs map[string]uint64) { + if err := p.performs(akey, assign.AssignmentsAct(prop)); err != nil { p.endProposal(ctx, prop, "its warrant does not bind these changes, so nothing is written: "+err.Error()) return } @@ -463,17 +482,17 @@ func (p *passer) warranted(ctx context.Context, prop delivery.PlacementProposal, return } cands, _, _ := p.candidatesOf(prop.ID) - laid := map[string]*delivery.Placement{} - for _, node := range prop.Nodes() { - laid[node] = cands[node].Placement + laid := map[string]*mma.Assignments{} + for _, node := range plc.Nodes(prop) { + laid[node] = cands[node].Assignments } writes, err := assign.Writes(prop, laid) if err != nil { p.logf("the proposal %s cannot be written: %v", prop.ID, err) return } - var changes []delivery.PlacementChange - writtenLaid := map[string]*delivery.Placement{} + var changes []plc.Change + writtenLaid := map[string]*mma.Assignments{} after := maps.Clone(current) afterRevs := maps.Clone(revs) for _, w := range writes { @@ -481,7 +500,7 @@ func (p *passer) warranted(ctx context.Context, prop delivery.PlacementProposal, if err != nil { // Another writer came first (a hand-back): what is written stands and is followed through; the rest is laid // again over the keys as this write left them. - p.logf("placement %s is not written at revision %d (%v): the rest of the proposal %s is laid again", w.Key, w.Base, err, prop.ID) + p.logf("assignments %s is not written at revision %d (%v): the rest of the proposal %s is laid again", w.Key, w.Base, err, prop.ID) p.followWrite(prop, answer, changes) if rest, ok := assign.Rest(prop, writtenLaid); ok { p.layAgain(ctx, rest, rev, after, afterRevs, "a key moved while it was written, after "+strings.Join(sortedNodes(writtenLaid), ", ")+" were") @@ -491,7 +510,7 @@ func (p *passer) warranted(ctx context.Context, prop delivery.PlacementProposal, return } before := current[w.Node] - now := delivery.Placement{Node: w.Node} + now := mma.Assignments{Node: w.Node} if w.Value != nil { now = *w.Value after[w.Node] = *w.Value @@ -499,32 +518,32 @@ func (p *passer) warranted(ctx context.Context, prop delivery.PlacementProposal, delete(after, w.Node) } afterRevs[w.Node] = rev2 - c := delivery.ChangeOf(before, now) + c := plc.ChangeOf(before, now) c.Before, c.After = w.Base, rev2 changes = append(changes, c) writtenLaid[w.Node] = w.Value } p.deleteCandidates(ctx, prop.ID) p.deletePulses(ctx, prop.ID) - _ = p.kvs[delivery.PlacementProposalsBucket].Delete(ctx, prop.ID) - p.w.put(delivery.PlacementProposalsBucket, prop.ID, nil) + _ = p.kvs[plc.ProposalsBucket].Delete(ctx, prop.ID) + p.w.put(plc.ProposalsBucket, prop.ID, nil) p.followWrite(prop, answer, changes) } // addNode writes a joining node on the warrant of the enrolment that issued its token (ADR 0587 §7): the operator's Approve // was the issue's, so no node is waited on and no ask is raised. The key is created at the revision it was laid over (none: a // node with a key is no joining node); the enrolment recorded the warrant in the hand-act log when it was given, so this does -// not record it again; placement-changed carries the warrant. A key written meanwhile ends the proposal, saying so. -func (p *passer) addNode(ctx context.Context, prop delivery.PlacementProposal, revs map[string]uint64) { - current, _ := p.seatPlacements() +// not record it again; assignments-changed carries the warrant. A key written meanwhile ends the proposal, saying so. +func (p *passer) addNode(ctx context.Context, prop mma.Proposal, revs map[string]uint64) { + current, _ := p.seatAssignments() if moved(prop, revs) { p.endProposal(ctx, prop, "a key it was laid over was written since: the enrolment proposes the node again") return } - laid, findings := delivery.LayChanges(current, prop.Changes) + laid, findings := plc.LayChanges(current, prop.Changes) findings = append(findings, assign.ControllerRefusals(prop.Changes, laid)...) node := prop.Changes[0].Node - if prop.Changes[0].Op == delivery.OpRemoveNode { + if prop.Changes[0].Op == mmac.OpRemoveNode { p.removeNode(ctx, prop, current, findings) return } @@ -538,22 +557,22 @@ func (p *passer) addNode(ctx context.Context, prop delivery.PlacementProposal, r p.endProposal(ctx, prop, "the node's key was not written: "+err.Error()) return } - c := delivery.ChangeOf(delivery.Placement{Node: node}, value) - c.Before, c.After = prop.Base[node], rev + c := plc.ChangeOf(mma.Assignments{Node: node}, value) + c.Before, c.After = uint64(prop.Base[node]), rev p.deleteCandidates(ctx, prop.ID) p.deletePulses(ctx, prop.ID) - _ = p.kvs[delivery.PlacementProposalsBucket].Delete(ctx, prop.ID) - p.w.put(delivery.PlacementProposalsBucket, prop.ID, nil) + _ = p.kvs[plc.ProposalsBucket].Delete(ctx, prop.ID) + p.w.put(plc.ProposalsBucket, prop.ID, nil) w := prop.Warrant - p.sayChanged(prop, delivery.Answer{Option: assign.OptionYes, Warrant: w.Warrant, + p.sayChanged(prop, mdc.Answer{Option: assign.OptionYes, Warrant: w.Warrant, Words: fmt.Sprintf("the operator approved the machine %s at %s through the enrolment's ask %s", w.Node, w.Address, w.Ask)}, - []delivery.PlacementChange{c}) + []plc.Change{c}) } // removeNode deletes the key of a joining node whose enrolment ended unconfirmed, on that enrolment's warrant (the operator's -// decision of 2026-11-02 01:05): no node answers and no ask is raised, as for its add-node; placement-changed carries the warrant. +// decision of 2026-11-02 01:05): no node answers and no ask is raised, as for its add-node; assignments-changed carries the warrant. // A node that runs modules or holds seats is no joining node and stays (LayChanges' finding ends the proposal). -func (p *passer) removeNode(ctx context.Context, prop delivery.PlacementProposal, current map[string]delivery.Placement, findings []string) { +func (p *passer) removeNode(ctx context.Context, prop mma.Proposal, current map[string]mma.Assignments, findings []string) { node := prop.Changes[0].Node before, ok := current[node] if len(findings) > 0 || !ok { @@ -565,21 +584,21 @@ func (p *passer) removeNode(ctx context.Context, prop delivery.PlacementProposal p.endProposal(ctx, prop, "the node's key was not deleted: "+err.Error()) return } - c := delivery.ChangeOf(before, delivery.Placement{Node: node}) - c.Before, c.After = prop.Base[node], rev + c := plc.ChangeOf(before, mma.Assignments{Node: node}) + c.Before, c.After = uint64(prop.Base[node]), rev p.deleteCandidates(ctx, prop.ID) p.deletePulses(ctx, prop.ID) - _ = p.kvs[delivery.PlacementProposalsBucket].Delete(ctx, prop.ID) - p.w.put(delivery.PlacementProposalsBucket, prop.ID, nil) + _ = p.kvs[plc.ProposalsBucket].Delete(ctx, prop.ID) + p.w.put(plc.ProposalsBucket, prop.ID, nil) w := prop.Warrant - p.sayChanged(prop, delivery.Answer{Option: assign.OptionYes, Warrant: w.Warrant, - Words: fmt.Sprintf("the machine %s left placement: its enrolment (ask %s) ended before it confirmed", w.Node, w.Ask)}, - []delivery.PlacementChange{c}) + p.sayChanged(prop, mdc.Answer{Option: assign.OptionYes, Warrant: w.Warrant, + Words: fmt.Sprintf("the machine %s left assignments: its enrolment (ask %s) ended before it confirmed", w.Node, w.Ask)}, + []plc.Change{c}) } -// followWrite follows the keys a warrant wrote: the warrant recorded and placement-changed said. No verb of the -// controller is called: the placement is the one source of where modules run (ADR 0567 §8, issue 774). -func (p *passer) followWrite(prop delivery.PlacementProposal, answer delivery.Answer, changes []delivery.PlacementChange) { +// followWrite follows the keys a warrant wrote: the warrant recorded and assignments-changed said. No verb of the +// controller is called: the assignments is the one source of where modules run (ADR 0567 §8, issue 774). +func (p *passer) followWrite(prop mma.Proposal, answer mdc.Answer, changes []plc.Change) { if len(changes) == 0 { return } @@ -587,7 +606,7 @@ func (p *passer) followWrite(prop delivery.PlacementProposal, answer delivery.An p.sayChanged(prop, answer, changes) } -func sortedNodes(m map[string]*delivery.Placement) []string { +func sortedNodes(m map[string]*mma.Assignments) []string { out := make([]string, 0, len(m)) for n := range m { out = append(out, n) @@ -596,8 +615,8 @@ func sortedNodes(m map[string]*delivery.Placement) []string { return out } -// sayChanged says placement-changed for the keys written. -func (p *passer) sayChanged(prop delivery.PlacementProposal, answer delivery.Answer, changes []delivery.PlacementChange) { +// sayChanged says assignments-changed for the keys written. +func (p *passer) sayChanged(prop mma.Proposal, answer mdc.Answer, changes []plc.Change) { if len(changes) == 0 { return } @@ -607,36 +626,36 @@ func (p *passer) sayChanged(prop delivery.PlacementProposal, answer delivery.Ans at = t } } - delivery.SortChanges(changes) - p.sayEvent(delivery.PlacementChangedEvent, delivery.PlacementEvent{Proposal: prop, Nodes: changes, - Warrant: &delivery.PlacementWarrant{Warrant: answer.Warrant, Words: answer.Words, At: at}}, prop.ID+".changed") + plc.SortChanges(changes) + p.sayEvent(mma.EventAssignmentsChanged, mma.AssignmentsEvent{Proposal: prop, Nodes: changeBodies(changes), + Warrant: &mma.AssignmentsEventWarrant{Warrant: answer.Warrant, Words: answer.Words, At: at}}, prop.ID+".changed") } -// endProposal ends a proposal: its ask, its candidates and its key deleted, and placement-withdrawn said carrying it +// endProposal ends a proposal: its ask, its candidates and its key deleted, and assignments-withdrawn said carrying it // whole with why it ended. -func (p *passer) endProposal(ctx context.Context, prop delivery.PlacementProposal, why string) { - if akey, err := delivery.PlacementAskKey(prop.ID); err == nil && p.w.has(delivery.AssignerAsksBucket, akey) { - if err := p.kvs[delivery.AssignerAsksBucket].Delete(ctx, akey); err == nil { - p.w.put(delivery.AssignerAsksBucket, akey, nil) +func (p *passer) endProposal(ctx context.Context, prop mma.Proposal, why string) { + if akey, err := asks.AssignmentsAskKey(prop.ID); err == nil && p.w.has(buckets.Asks, akey) { + if err := p.kvs[buckets.Asks].Delete(ctx, akey); err == nil { + p.w.put(buckets.Asks, akey, nil) } } p.deleteCandidates(ctx, prop.ID) p.deletePulses(ctx, prop.ID) - if err := p.kvs[delivery.PlacementProposalsBucket].Delete(ctx, prop.ID); err != nil && !errors.Is(err, context.Canceled) { + if err := p.kvs[plc.ProposalsBucket].Delete(ctx, prop.ID); err != nil && !errors.Is(err, context.Canceled) { p.logf("the proposal %s is not deleted, and is ended at the next pass: %v", prop.ID, err) return } - p.w.put(delivery.PlacementProposalsBucket, prop.ID, nil) + p.w.put(plc.ProposalsBucket, prop.ID, nil) p.logf("the proposal %s ends: %s", prop.ID, why) - p.sayEvent(delivery.PlacementWithdrawnEvent, delivery.PlacementEvent{Proposal: prop, Ended: why}, prop.ID+".withdrawn") + p.sayEvent(mma.EventAssignmentsWithdrawn, mma.AssignmentsEvent{Proposal: prop, Ended: why}, prop.ID+".withdrawn") } // deleteCandidates deletes every candidate of a proposal. func (p *passer) deleteCandidates(ctx context.Context, id string) { - for key := range p.w.read(delivery.PlacementCandidatesBucket) { - if pid, _, err := delivery.ParsePlacementCandidateKey(key); err == nil && pid == id { - if err := p.kvs[delivery.PlacementCandidatesBucket].Delete(ctx, key); err == nil { - p.w.put(delivery.PlacementCandidatesBucket, key, nil) + for key := range p.w.read(plc.CandidatesBucket) { + if pid, _, err := plc.ParseCandidateKey(key); err == nil && pid == id { + if err := p.kvs[plc.CandidatesBucket].Delete(ctx, key); err == nil { + p.w.put(plc.CandidatesBucket, key, nil) } } } @@ -644,13 +663,13 @@ func (p *passer) deleteCandidates(ctx context.Context, id string) { // writeProposal writes a proposal over the revision the pass read, by compare-and-set: a verb that marked it meanwhile // (withdraw, again) wins, and the next pass reads that. It says whether it wrote. -func (p *passer) writeProposal(ctx context.Context, prop delivery.PlacementProposal, rev uint64) bool { - raw, err := delivery.EncodePlacementProposal(prop) +func (p *passer) writeProposal(ctx context.Context, prop mma.Proposal, rev uint64) bool { + raw, err := plc.EncodeProposal(prop) if err != nil { p.logf("the proposal %s is not written: %v", prop.ID, err) return false } - kv := p.kvs[delivery.PlacementProposalsBucket] + kv := p.kvs[plc.ProposalsBucket] var at uint64 if rev == 0 { at, err = kv.Create(ctx, prop.ID, raw) @@ -663,12 +682,12 @@ func (p *passer) writeProposal(ctx context.Context, prop delivery.PlacementPropo } return false } - p.w.putEntry(delivery.PlacementProposalsBucket, prop.ID, raw, at, p.clock()) + p.w.putEntry(plc.ProposalsBucket, prop.ID, raw, at, p.clock()) return true } // say says one of the seat's events, said in the journal when the bus refuses it. -func (p *passer) sayEvent(event string, body delivery.PlacementEvent, id string) { +func (p *passer) sayEvent(event string, body mma.AssignmentsEvent, id string) { if p.publish == nil { return } @@ -677,30 +696,30 @@ func (p *passer) sayEvent(event string, body delivery.PlacementEvent, id string) } } -// writeNode writes one node's placement after (nil: none) as its keys (mesh-sdk delivery.PlacementKeys.Writes; research +// writeNode writes one node's assignments after (nil: none) as its keys (mesh-sdk mmac.Keys.Writes; research // 072 row 37): the module keys that change, each carrying the value it replaces, then the node key, the commit, then the // module keys no longer placed. It answers the node key's revision after (0: deleted). A write that fails up to the // commit ends it with nothing changed for any reader, the module keys written before it read at their previous values; // a delete after the commit that fails leaves a module key the node key does not name, which places nothing and is // deleted at the node's next write. Each key written is taken into the world at once, so the next write of the pass // reads it. -func (p *passer) writeNode(ctx context.Context, node string, after *delivery.Placement) (uint64, error) { - writes, err := p.w.placement.Writes(node, after) +func (p *passer) writeNode(ctx context.Context, node string, after *mma.Assignments) (uint64, error) { + writes, err := p.w.assignmentKeys.Writes(node, after) if err != nil { return 0, err } - kv := p.kvs[delivery.PlacementBucket] + kv := p.kvs[plc.AssignmentsBucket] committed, rev := false, uint64(0) for _, w := range writes { got, err := writeKey(ctx, kv, w) if err != nil { if committed { - p.logf("placement %s is not deleted after %s's write (%v): it places nothing, and goes at the node's next write", w.Key, node, err) + p.logf("assignments %s is not deleted after %s's write (%v): it places nothing, and goes at the node's next write", w.Key, node, err) continue } - return 0, fmt.Errorf("placement %s at revision %d: %w", w.Key, w.Base, err) + return 0, fmt.Errorf("assignments %s at revision %d: %w", w.Key, w.Base, err) } - p.w.putEntry(delivery.PlacementBucket, w.Key, w.Value, got, p.clock()) + p.w.putEntry(plc.AssignmentsBucket, w.Key, w.Value, got, p.clock()) if w.Commit { committed, rev = true, got } @@ -708,8 +727,8 @@ func (p *passer) writeNode(ctx context.Context, node string, after *delivery.Pla return rev, nil } -// writeKey makes one write of a placement key by compare-and-set and answers the key's revision after (0: deleted). -func writeKey(ctx context.Context, kv jetstream.KeyValue, w delivery.PlacementKeyWrite) (uint64, error) { +// writeKey makes one write of an assignments key by compare-and-set and answers the key's revision after (0: deleted). +func writeKey(ctx context.Context, kv jetstream.KeyValue, w mmac.KeyWrite) (uint64, error) { switch { case w.Value == nil: return 0, kv.Delete(ctx, w.Key, jetstream.LastRevision(w.Base)) @@ -720,29 +739,13 @@ func writeKey(ctx context.Context, kv jetstream.KeyValue, w delivery.PlacementKe } } -// convertPlacement converts every node the state holds only in the form before, `placement.`, into its node key -// and module keys (research 072 row 37), once: each node converted is said, and a node whose conversion fails is said -// and tried again at the next pass. The legacy key is left for the readers not yet delivered; a node key wins over it -// for every reader that reads both, and it is deleted with the node key when the node's placement is. -func (p *passer) convertPlacement(ctx context.Context) { - if p.convertedAll { - return +func ptr[T any](v T) *T { return &v } + +// changeBodies are the changes as the seat's event carries them. +func changeBodies(changes []plc.Change) []map[string]any { + out := make([]map[string]any, 0, len(changes)) + for _, c := range changes { + out = append(out, c.Body()) } - migrations, err := p.w.placement.Migrations() - if err != nil { - p.logf("placement: some nodes in the form before cannot be converted, and stay read from it: %v", err) - } - failed := false - for _, node := range slices.Sorted(maps.Keys(migrations)) { - before, rev, _ := p.w.placement.Of(node) - got, err := p.writeNode(ctx, node, &before) - if err != nil { - failed = true - p.logf("placement: %s is not converted from placement.%s (revision %d) to its node and module keys, and is tried again at the next pass: %v", node, node, rev, err) - continue - } - p.logf("placement: %s converted from placement.%s (revision %d) to %s.node (revision %d) and %d module keys (research 072 row 37); placement.%s stays for readers not yet delivered", - node, node, rev, node, got, len(before.Modules), node) - } - p.convertedAll = !failed && err == nil + return out } diff --git a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/seat_tools.go b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/seat_tools.go index 030cea1d..22342577 100644 --- a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/seat_tools.go +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/seat_tools.go @@ -4,19 +4,25 @@ import ( "encoding/json" "errors" "fmt" + "mesh-delivery-assigner/internal/buckets" "slices" "sort" "strings" "time" + "git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient" + "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" + "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" + + mmac "git.novox.be/novox/mesh-contracts/go/clients/meshmoduleassignerclient" + mma "git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner" stdio "git.novox.be/novox/mesh-sdk/go" - "git.novox.be/novox/mesh-sdk/go/catalogue" - "git.novox.be/novox/mesh-sdk/go/delivery" "mesh-delivery-assigner/internal/assign" + plc "mesh-delivery-assigner/internal/assignment" ) -// The seat mesh-placement's seven verbs (novox/hq ADR 0567 §10), served through the tool runner beside the put tools: a +// The seat mesh-module-assigner's seven verbs (novox/hq ADR 0567 §10), served through the tool runner beside the put tools: a // process never serves (ADR 0436). They read every state the seat keeps and write what a person asks of it: a proposal // and its candidates (propose, adopt), a proposal marked to end (withdraw) or to be judged again (again), and the keys // deleted (hand-back). Everything that follows a proposal — composing it, judging the answers, the ask, the write at the @@ -38,7 +44,7 @@ func (runtimeStore) history(state, key string) ([]stdio.StateRevision, error) { // The states the verbs read, as the runtime names them: the seat's own by their local names, the others' // `.`. const ( - modulesState = delivery.IndexerModule + ".modules" + modulesState = buckets.IndexerModule + ".modules" haveState = "node-engine.have" ) @@ -53,29 +59,29 @@ func newSeatTools(st seatStore, ask func(string, any) (json.RawMessage, error)) return &seatTools{st: st, ask: ask, now: time.Now} } -// keyed is one placement key as a verb answers it. +// keyed is one assignments key as a verb answers it. type keyed struct { - Placement delivery.Placement `json:"placement"` - Revision uint64 `json:"revision"` + Assignments mma.Assignments `json:"assignments"` + Revision uint64 `json:"revision"` } -// placements reads every node's placement and the revision of its node key (its legacy key's before it is converted), -// assembled from its keys in either form (delivery.PlacementKeys; research 072 row 37). -func (s *seatTools) placements() (map[string]delivery.Placement, map[string]uint64, error) { - keys, err := s.st.keys(delivery.PlacementState) +// assignments reads every node's assignments and the revision of its node key (its legacy key's before it is converted), +// assembled from its keys in either form (mmac.Keys; research 072 row 37). +func (s *seatTools) assignments() (map[string]mma.Assignments, map[string]uint64, error) { + keys, err := s.st.keys(mma.StateAssignments) if err != nil { - return nil, nil, fmt.Errorf("placement cannot be listed: %w", err) + return nil, nil, fmt.Errorf("assignments cannot be listed: %w", err) } sort.Strings(keys) - held := delivery.NewPlacementKeys() + held := mmac.NewKeys() var bad []string for _, key := range keys { - if _, _, _, err := delivery.ParsePlacementKey(key); err != nil { + if _, _, _, err := mmac.ParseKey(key); err != nil { continue } - raw, rev, found, err := s.st.get(delivery.PlacementState, key) + raw, rev, found, err := s.st.get(mma.StateAssignments, key) if err != nil { - return nil, nil, fmt.Errorf("placement key %s cannot be read: %w", key, err) + return nil, nil, fmt.Errorf("assignments key %s cannot be read: %w", key, err) } if !found { continue @@ -86,7 +92,7 @@ func (s *seatTools) placements() (map[string]delivery.Placement, map[string]uint } out, revs := held.AllRevisions() if len(bad) > 0 && len(out) == 0 { - return nil, nil, fmt.Errorf("placement cannot be read: %s", strings.Join(bad, "; ")) + return nil, nil, fmt.Errorf("assignments cannot be read: %s", strings.Join(bad, "; ")) } return out, revs, nil } @@ -99,7 +105,7 @@ func (s *seatTools) lays() (func(string) bool, func(string) bool, error) { } held := map[string]bool{} for _, k := range mods { - if name, err := delivery.ParseModulesKey(k); err == nil { + if name, err := meshcatalogclient.ParseModulesKey(k); err == nil { held[name] = true } } @@ -109,12 +115,12 @@ func (s *seatTools) lays() (func(string) bool, func(string) bool, error) { } recorded := map[string]bool{} for _, k := range haves { - node, _, isNode, err := delivery.ParseHaveKey(k) + node, _, isNode, err := nodeengineclient.ParseHaveKey(k) if err != nil || !isNode { continue } if raw, _, found, err := s.st.get(haveState, k); err == nil && found { - if h, err := delivery.DecodeHaveNode(raw); err == nil && h.Record != nil { + if h, err := nodeengineclient.DecodeHaveNode(raw); err == nil && h.Record != nil { recorded[node] = true } } @@ -123,42 +129,42 @@ func (s *seatTools) lays() (func(string) bool, func(string) bool, error) { } // proposal reads one open proposal. -func (s *seatTools) proposal(id string) (delivery.PlacementProposal, error) { - if err := delivery.CheckProposalID(id); err != nil { - return delivery.PlacementProposal{}, err +func (s *seatTools) proposal(id string) (mma.Proposal, error) { + if err := mmac.CheckProposalID(id); err != nil { + return mma.Proposal{}, err } - raw, _, found, err := s.st.get(delivery.PlacementProposalsState, id) + raw, _, found, err := s.st.get(mma.StateProposals, id) if err != nil { - return delivery.PlacementProposal{}, fmt.Errorf("the proposal %s cannot be read: %w", id, err) + return mma.Proposal{}, fmt.Errorf("the proposal %s cannot be read: %w", id, err) } if !found { - return delivery.PlacementProposal{}, fmt.Errorf("no proposal %s is open: mesh-placement.proposals lists those that are", id) + return mma.Proposal{}, fmt.Errorf("no proposal %s is open: mesh-module-assigner.proposals lists those that are", id) } - return delivery.DecodePlacementProposal(raw) + return plc.DecodeProposal(raw) } // write writes a proposal laid by assign.Propose: each candidate, then the proposal, whose write the assigner's process // takes as the event to compose and judge it. -func (s *seatTools) write(p delivery.PlacementProposal, laid map[string]*delivery.Placement) error { - p.Written = s.now().UTC() - for _, node := range p.Nodes() { - key, err := delivery.PlacementCandidateKey(p.ID, node) +func (s *seatTools) write(p mma.Proposal, laid map[string]*mma.Assignments) error { + p.Written = ptr(s.now().UTC()) + for _, node := range plc.Nodes(p) { + key, err := plc.CandidateKey(p.ID, node) if err != nil { return err } - raw, err := delivery.EncodePlacementCandidate(delivery.PlacementCandidate{Proposal: p.ID, Placement: laid[node]}) + raw, err := plc.EncodeCandidate(mma.CandidatesByProposalNodeValue{Proposal: p.ID, Assignments: laid[node]}) if err != nil { return err } - if err := s.st.put(delivery.PlacementCandidatesState, key, raw); err != nil { + if err := s.st.put(mma.StateCandidates, key, raw); err != nil { return fmt.Errorf("the candidate %s is not written: %w", key, err) } } - raw, err := delivery.EncodePlacementProposal(p) + raw, err := plc.EncodeProposal(p) if err != nil { return err } - if err := s.st.put(delivery.PlacementProposalsState, p.ID, raw); err != nil { + if err := s.st.put(mma.StateProposals, p.ID, raw); err != nil { return fmt.Errorf("the proposal %s is not written: %w", p.ID, err) } return nil @@ -166,17 +172,17 @@ func (s *seatTools) write(p delivery.PlacementProposal, laid map[string]*deliver // proposed is what propose and adopt answer. type proposed struct { - Proposal delivery.PlacementProposal `json:"proposal"` - Follow string `json:"follow"` + Proposal mma.Proposal `json:"proposal"` + Follow string `json:"follow"` } func followProposal(id string) string { - return fmt.Sprintf("the nodes it touches answer it; mesh-placement.proposals with id %q says where it stands, and the "+ + return fmt.Sprintf("the nodes it touches answer it; mesh-module-assigner.proposals with id %q says where it stands, and the "+ "operator is asked to approve it once its verdict passes", id) } // changesOf reads propose's changes: a JSON array, given as one or as its text. -func changesOf(v any) ([]delivery.PlacementProposalChange, error) { +func changesOf(v any) ([]mma.ProposalChange, error) { var raw []byte switch c := v.(type) { case string: @@ -189,7 +195,7 @@ func changesOf(v any) ([]delivery.PlacementProposalChange, error) { return nil, err } } - var out []delivery.PlacementProposalChange + var out []mma.ProposalChange dec := json.NewDecoder(strings.NewReader(string(raw))) dec.DisallowUnknownFields() if err := dec.Decode(&out); err != nil { @@ -205,11 +211,11 @@ func (s *seatTools) doPropose(args map[string]any, by string) (any, error) { return nil, err } for _, c := range changes { - if c.Op == delivery.OpSeed { + if c.Op == mmac.OpSeed { return nil, errors.New("the seed is gone with adopt: propose takes place, take-off, pin, unpin, hold, unhold, decide and config") } } - current, revs, err := s.placements() + current, revs, err := s.assignments() if err != nil { return nil, err } @@ -221,10 +227,11 @@ func (s *seatTools) doPropose(args map[string]any, by string) (any, error) { if err != nil { return nil, err } + enrollers := plc.EnrollersOf(current) now := s.now() - p, laid, findings := assign.Propose(assign.ProposeIn{ID: delivery.NewProposalID(now), By: by, Why: arg(args, "why"), At: now, + p, laid, findings := assign.Propose(assign.ProposeIn{ID: plc.NewProposalID(now), By: by, Why: arg(args, "why"), At: now, Changes: changes, Current: current, Revisions: revs, Held: held, Recorded: recorded, Warrant: warrant}) - if refusal := delivery.AddNodeRefusal(p, moduleOfCaller(by)); refusal != "" { + if refusal := plc.AddNodeRefusal(p, moduleOfCaller(by, enrollers), enrollers); refusal != "" { findings = append(findings, refusal) } if len(findings) > 0 { @@ -237,7 +244,7 @@ func (s *seatTools) doPropose(args map[string]any, by string) (any, error) { } // warrantOf reads propose's warrant, an enrolment's for an add-node: an object, given as one or as its text; none is none. -func warrantOf(v any) (*delivery.AddNodeWarrant, error) { +func warrantOf(v any) (*mma.Warrant, error) { var raw []byte switch w := v.(type) { case nil: @@ -253,7 +260,7 @@ func warrantOf(v any) (*delivery.AddNodeWarrant, error) { return nil, err } } - var out delivery.AddNodeWarrant + var out mma.Warrant dec := json.NewDecoder(strings.NewReader(string(raw))) dec.DisallowUnknownFields() if err := dec.Decode(&out); err != nil { @@ -262,18 +269,20 @@ func warrantOf(v any) (*delivery.AddNodeWarrant, error) { return &out, nil } -// moduleOfCaller is the module a caller is, as the runtime proves it, for AddNodeRefusal: mesh-enrolment when the proven -// caller is that module's (its bus user `.mesh-enrolment`, never a person's `person.mesh-enrolment`), else the caller -// as proven, which the refusal names. -func moduleOfCaller(by string) string { - if (stdio.Callers{delivery.EnrolmentModule}).Allows(by) { - return delivery.EnrolmentModule +// moduleOfCaller is the module a caller is, as the runtime proves it, for AddNodeRefusal: the holder of the seat +// mesh-node-enroller when the proven caller is that module's (its bus user `.`, never a person's +// `person.`), else the caller as proven, which the refusal names. +func moduleOfCaller(by string, enrollers []string) string { + for _, module := range enrollers { + if (stdio.Callers{module}).Allows(by) { + return module + } } return by } // doWithdraw is the verb withdraw (§2): the proposal is marked, and the assigner deletes it and its candidates and says -// placement-withdrawn carrying it whole. +// assignments-withdrawn carrying it whole. func (s *seatTools) doWithdraw(args map[string]any, by string) (any, error) { why := arg(args, "why") if why == "" { @@ -284,11 +293,11 @@ func (s *seatTools) doWithdraw(args map[string]any, by string) (any, error) { return nil, err } p.Withdrawn = fmt.Sprintf("withdrawn by %s: %s", by, why) - raw, err := delivery.EncodePlacementProposal(p) + raw, err := plc.EncodeProposal(p) if err != nil { return nil, err } - if err := s.st.put(delivery.PlacementProposalsState, p.ID, raw); err != nil { + if err := s.st.put(mma.StateProposals, p.ID, raw); err != nil { return nil, fmt.Errorf("the proposal %s is not withdrawn: %w", p.ID, err) } return map[string]any{"proposal": p.ID, "withdrawn": p.Withdrawn}, nil @@ -301,19 +310,19 @@ func (s *seatTools) doAgain(args map[string]any) (any, error) { if err != nil { return nil, err } - p.Again = s.now().UTC() - raw, err := delivery.EncodePlacementProposal(p) + p.Again = ptr(s.now().UTC()) + raw, err := plc.EncodeProposal(p) if err != nil { return nil, err } - if err := s.st.put(delivery.PlacementProposalsState, p.ID, raw); err != nil { + if err := s.st.put(mma.StateProposals, p.ID, raw); err != nil { return nil, fmt.Errorf("the proposal %s is not asked again: %w", p.ID, err) } return map[string]any{"proposal": p.ID, "again": p.Again, "follow": followProposal(p.ID)}, nil } -// placementAnswer is what the verb placement answers. -type placementAnswer struct { +// assignmentsAnswer is what the verb assignments answers. +type assignmentsAnswer struct { Keys map[string]keyed `json:"keys,omitempty"` Nodes []string `json:"nodes,omitempty"` Count int `json:"count"` @@ -324,38 +333,38 @@ type placementAnswer struct { // historyEntry is one change of a key in the state's revisions. type historyEntry struct { - Node string `json:"node"` - Revision uint64 `json:"revision"` - At time.Time `json:"at"` - Change delivery.PlacementChange `json:"change"` - Deleted bool `json:"deleted,omitempty"` + Node string `json:"node"` + Revision uint64 `json:"revision"` + At time.Time `json:"at"` + Change plc.Change `json:"change"` + Deleted bool `json:"deleted,omitempty"` } -// doPlacement is the verb placement (§10, §6): one node's key, one module's nodes, or every key; with history, the +// doAssignments is the verb assignments (§10, §6): one node's key, one module's nodes, or every key; with history, the // changes in order from the revisions the state keeps. -func (s *seatTools) doPlacement(args map[string]any) (any, error) { +func (s *seatTools) doAssignments(args map[string]any) (any, error) { if arg(args, "seats") == "true" { return s.seats(arg(args, "node")) } - all, revs, err := s.placements() + all, revs, err := s.assignments() if err != nil { return nil, err } node, module := arg(args, "node"), arg(args, "module") - out := placementAnswer{Keys: map[string]keyed{}, Count: len(all), History: delivery.PlacementHistory} + out := assignmentsAnswer{Keys: map[string]keyed{}, Count: len(all), History: plc.AssignmentsHistory} for n, p := range all { if node != "" && n != node { continue } - if module != "" && !strings.Contains(" "+strings.Join(p.Assigned(), " ")+" ", " "+module+" ") { + if module != "" && !strings.Contains(" "+strings.Join(mmac.Assigned(p), " ")+" ", " "+module+" ") { continue } - out.Keys[n] = keyed{Placement: p, Revision: revs[n]} + out.Keys[n] = keyed{Assignments: p, Revision: revs[n]} out.Nodes = append(out.Nodes, n) } sort.Strings(out.Nodes) if node != "" && len(out.Keys) == 0 && arg(args, "history") != "true" { - return nil, fmt.Errorf("%s has no placement key", node) + return nil, fmt.Errorf("%s has no assignments key", node) } if arg(args, "history") != "true" { return out, nil @@ -393,8 +402,8 @@ func (s *seatTools) doPlacement(args map[string]any) (any, error) { out.Changes = append(out.Changes, e) } } - out.Older = fmt.Sprintf("the state keeps %d revisions of each key; an older change is in its placement-changed event on "+ - "the assigner's stream %s, kept %d days", delivery.PlacementHistory, delivery.PlacementStream, delivery.PlacementStreamDays) + out.Older = fmt.Sprintf("the state keeps %d revisions of each key; an older change is in its assignments-changed event on "+ + "the seat's subject on the shared events stream, kept %d days", plc.AssignmentsHistory, plc.EventDays) return out, nil } @@ -402,18 +411,18 @@ func (s *seatTools) doPlacement(args map[string]any) (any, error) { // verdict with the differences and the answers it judged, its ask), its candidates by node with their revisions, and // every node's answer now. type openProposal struct { - Proposal delivery.PlacementProposal `json:"proposal"` - Candidates map[string]uint64 `json:"candidates"` - Answers map[string]delivery.CandidateAnswer `json:"answers,omitempty"` + Proposal mma.Proposal `json:"proposal"` + Candidates map[string]uint64 `json:"candidates"` + Answers map[string]nodeengine.CandidateAnswer `json:"answers,omitempty"` } // doProposals is the verb proposals (§10). func (s *seatTools) doProposals(args map[string]any) (any, error) { - ids, err := s.st.keys(delivery.PlacementProposalsState) + ids, err := s.st.keys(mma.StateProposals) if err != nil { return nil, fmt.Errorf("the proposals cannot be listed: %w", err) } - cands, err := s.st.keys(delivery.PlacementCandidatesState) + cands, err := s.st.keys(mma.StateCandidates) if err != nil { return nil, fmt.Errorf("the candidates cannot be listed: %w", err) } @@ -421,9 +430,9 @@ func (s *seatTools) doProposals(args map[string]any) (any, error) { if err != nil { return nil, fmt.Errorf("the nodes' haves cannot be listed: %w", err) } - answers := map[string]map[string]delivery.CandidateAnswer{} // by proposal, then node + answers := map[string]map[string]nodeengine.CandidateAnswer{} // by proposal, then node for _, k := range haves { - node, _, isNode, err := delivery.ParseHaveKey(k) + node, _, isNode, err := nodeengineclient.ParseHaveKey(k) if err != nil || !isNode { continue } @@ -431,13 +440,13 @@ func (s *seatTools) doProposals(args map[string]any) (any, error) { if err != nil || !found { continue } - h, err := delivery.DecodeHaveNode(raw) + h, err := nodeengineclient.DecodeHaveNode(raw) if err != nil { continue } for id, a := range h.Candidates { if answers[id] == nil { - answers[id] = map[string]delivery.CandidateAnswer{} + answers[id] = map[string]nodeengine.CandidateAnswer{} } answers[id][node] = a } @@ -454,11 +463,11 @@ func (s *seatTools) doProposals(args map[string]any) (any, error) { } o := openProposal{Proposal: p, Candidates: map[string]uint64{}, Answers: answers[id]} for _, k := range cands { - pid, node, err := delivery.ParsePlacementCandidateKey(k) + pid, node, err := plc.ParseCandidateKey(k) if err != nil || pid != id { continue } - if _, rev, found, err := s.st.get(delivery.PlacementCandidatesState, k); err == nil && found { + if _, rev, found, err := s.st.get(mma.StateCandidates, k); err == nil && found { o.Candidates[node] = rev } } @@ -473,48 +482,42 @@ func (s *seatTools) doProposals(args map[string]any) (any, error) { // tools are the seat's verbs as the runtime serves them, each named . (the tool runner serves a seat's verb // only from a tool of that name), with the seat's own schemas. func (s *seatTools) tools() []stdio.Tool { - seat, _ := catalogue.SeatNamed(catalogue.PlacementSeat) - input := map[string]map[string]any{} - description := map[string]string{} - for _, v := range seat.Serves { - input[v.Name], description[v.Name] = v.Input, v.Description - } run := map[string]func(map[string]any) (any, error){ - catalogue.PlacementVerbPlacement: s.doPlacement, - catalogue.PlacementVerbProposals: s.doProposals, - catalogue.PlacementVerbPropose: func(a map[string]any) (any, error) { return s.doPropose(a, stdio.CallerOf(a)) }, - catalogue.PlacementVerbWithdraw: func(a map[string]any) (any, error) { return s.doWithdraw(a, stdio.CallerOf(a)) }, - catalogue.PlacementVerbAgain: s.doAgain, + mma.VerbAssignments: s.doAssignments, + mma.VerbProposals: s.doProposals, + mma.VerbPropose: func(a map[string]any) (any, error) { return s.doPropose(a, stdio.CallerOf(a)) }, + mma.VerbWithdraw: func(a map[string]any) (any, error) { return s.doWithdraw(a, stdio.CallerOf(a)) }, + mma.VerbAgain: s.doAgain, } var out []stdio.Tool - for _, v := range seat.Serves { - out = append(out, stdio.Tool{Name: catalogue.PlacementSeat + "." + v.Name, Description: description[v.Name], Input: input[v.Name], Run: run[v.Name]}) + for _, v := range mma.Verbs() { + out = append(out, stdio.Tool{Name: mma.Seat + "." + v, Run: run[v]}) } return out } -// seats is the verb placement with seats (ADR 0571 §4): who holds each seat and on which node, as the state seat-holders +// seats is the verb assignments with seats (ADR 0571 §4): who holds each seat and on which node, as the state seat-holders // holds it; with node, the keys of seats held on that node. func (s *seatTools) seats(node string) (any, error) { - keys, err := s.st.keys(delivery.SeatHoldersState) + keys, err := s.st.keys(mma.StateSeatHolders) if err != nil { return nil, fmt.Errorf("seat-holders cannot be listed: %w", err) } sort.Strings(keys) - out := []delivery.SeatHolders{} + out := []mma.SeatHolders{} for _, key := range keys { - raw, _, found, err := s.st.get(delivery.SeatHoldersState, key) + raw, _, found, err := s.st.get(mma.StateSeatHolders, key) if err != nil { return nil, fmt.Errorf("seat-holders %s cannot be read: %w", key, err) } if !found { continue } - var sh delivery.SeatHolders + var sh mma.SeatHolders if json.Unmarshal(raw, &sh) != nil { continue } - if node != "" && sh.Node != node && !slices.ContainsFunc(sh.Holders, func(h delivery.SeatHolder) bool { return h.Node == node }) { + if node != "" && sh.Node != node && !slices.ContainsFunc(sh.Holders, func(h mma.SeatHoldersHoldersItem) bool { return h.Node == node }) { continue } out = append(out, sh) @@ -522,76 +525,56 @@ func (s *seatTools) seats(node string) (any, error) { return map[string]any{"seats": out, "holds": len(keys)}, nil } -// keyedRevision is one revision of a node's placement key, the key it is of beside it. +// keyedRevision is one revision of a node's assignments key, the key it is of beside it. type keyedRevision struct { stdio.StateRevision Key string } -// nodeHistory is the revisions of a node's placement, in the order the bus wrote them: its legacy key's -// `placement.` before it was converted, then its node key's `.node`, whose every revision is one write of -// the node (research 072 row 37: the node key is a write's commit, so its history is the node's placement history). +// nodeHistory is the revisions of a node's assignments, in the order the bus wrote them: its node key's `.node`, +// whose every revision is one write of the node (research 072 row 37: the node key is a write's commit, so its history is +// the node's assignments history). func (s *seatTools) nodeHistory(node string) ([]keyedRevision, error) { - legacy, err := delivery.LegacyPlacementKey(node) + nodeKey, err := mmac.NodeKey(node) if err != nil { return nil, err } - nodeKey, err := delivery.PlacementNodeKey(node) + revisions, err := s.st.history(mma.StateAssignments, nodeKey) if err != nil { - return nil, err + return nil, fmt.Errorf("the history of %s cannot be read: %w", nodeKey, err) } var out []keyedRevision - for _, key := range []string{legacy, nodeKey} { - revisions, err := s.st.history(delivery.PlacementState, key) - if err != nil { - return nil, fmt.Errorf("the history of %s cannot be read: %w", key, err) - } - for _, r := range revisions { - out = append(out, keyedRevision{StateRevision: r, Key: key}) - } + for _, r := range revisions { + out = append(out, keyedRevision{StateRevision: r, Key: nodeKey}) } sort.SliceStable(out, func(i, j int) bool { return out[i].Revision < out[j].Revision }) return out, nil } -// sketch is a revision of a node's placement as far as its history says a change: each module's digest, and its pins -// and holdings, which change every module (delivery.ChangeOf). +// sketch is a revision of a node's assignments as far as its history says a change: each module's digest, and its pins +// and holdings, which change every module (plc.ChangeOf). type sketch struct { - modules []delivery.PlacementRef + modules []mma.AssignmentsByNodeNodeValueModulesItem nodeWide string } -// sketchOf reads one revision of a node key or a legacy key; a value that does not read places nothing. +// sketchOf reads one revision of a node key; a value that does not read places nothing. func sketchOf(key string, value []byte) sketch { - _, _, kind, err := delivery.ParsePlacementKey(key) + _, _, kind, err := mmac.ParseKey(key) + if err != nil || kind != mmac.NodeKind { + return sketch{} + } + n, err := mmac.DecodeNode(value) if err != nil { return sketch{} } - wide := func(pins map[string]delivery.Provider, holds []delivery.Holding) string { - raw, _ := json.Marshal([]any{pins, holds}) - return string(raw) - } - if kind == delivery.PlacementNodeKind { - n, err := delivery.DecodePlacementNode(value) - if err != nil { - return sketch{} - } - return sketch{modules: n.Modules, nodeWide: wide(n.Pins, n.Holds)} - } - p, err := delivery.DecodePlacement(value) - if err != nil { - return sketch{} - } - out := sketch{nodeWide: wide(p.Pins, p.Holds)} - for _, m := range p.Modules { - out.modules = append(out.modules, delivery.PlacementRef{Module: m.Module, Digest: m.Digest()}) - } - return out + raw, _ := json.Marshal([]any{n.Pins, n.Holds}) + return sketch{modules: n.Modules, nodeWide: string(raw)} } -// changeFrom is what changed from before to s on node, as delivery.ChangeOf says it. -func (s sketch) changeFrom(node string, before sketch) delivery.PlacementChange { - c := delivery.PlacementChange{Node: node} +// changeFrom is what changed from before to s on node, as plc.ChangeOf says it. +func (s sketch) changeFrom(node string, before sketch) plc.Change { + c := plc.Change{Node: node} was := map[string]string{} for _, r := range before.modules { was[r.Module] = r.Digest diff --git a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/seat_tools_test.go b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/seat_tools_test.go index fd93f700..45596114 100644 --- a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/seat_tools_test.go +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/seat_tools_test.go @@ -5,6 +5,8 @@ import ( "encoding/json" "errors" "fmt" + "maps" + "mesh-delivery-assigner/internal/buckets" "os" "reflect" "slices" @@ -12,14 +14,22 @@ import ( "testing" "time" + "git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient" + "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" + "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" + "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" + "git.novox.be/novox/mesh-sdk/go/keys" + + mmac "git.novox.be/novox/mesh-contracts/go/clients/meshmoduleassignerclient" + mma "git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner" + mcc "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient" + "git.novox.be/novox/mesh-contracts/provisioning/go/clients/meshprovisionerclient" stdio "git.novox.be/novox/mesh-sdk/go" "git.novox.be/novox/mesh-sdk/go/catalogue" - "git.novox.be/novox/mesh-sdk/go/delivery" - "git.novox.be/novox/mesh-sdk/go/health" - "git.novox.be/novox/mesh-sdk/go/provisions" "github.com/nats-io/nats.go/jetstream" "mesh-delivery-assigner/internal/assign" + plc "mesh-delivery-assigner/internal/assignment" ) func (s memStore) del(state, key string) error { @@ -36,19 +46,20 @@ func (s memStore) history(state, key string) ([]stdio.StateRevision, error) { } // ADR 0567 §2, §7, §10: the seat's five verbs over the states. propose lays a proposal and writes it with its candidates, -// or refuses naming every finding and writes nothing; withdraw and again mark it for the holder; placement and +// or refuses naming every finding and writes nothing; withdraw and again mark it for the holder; assignments and // proposals read every state the seat keeps, and history names the stream for what the state no longer keeps. func TestTheSeatsVerbs(t *testing.T) { - anchor := delivery.Placement{V: delivery.Version, Node: "anchor", Modules: []delivery.PlacedModule{{Module: "web"}}} + anchor := mma.Assignments{V: keys.Version, Node: "anchor", Modules: []mma.PlacedModule{{Module: "web"}, {Module: "mesh-enrolment"}}, + Holds: []mma.Holding{{Claim: "mesh-node-enroller", Scope: "mesh", Module: "mesh-enrolment"}}} st := memStore{} - for _, state := range []string{delivery.PlacementState, delivery.PlacementProposalsState, delivery.PlacementCandidatesState, modulesState, haveState} { + for _, state := range []string{mma.StateAssignments, mma.StateProposals, mma.StateCandidates, modulesState, haveState} { st[state] = &memKV{values: map[string][]byte{}} } - for _, m := range []string{"web", "shell"} { + for _, m := range []string{"web", "shell", "mesh-enrolment"} { st[modulesState].values[m] = []byte("{}") } for _, n := range []string{"anchor", "laptop"} { - raw, _ := delivery.EncodeHaveNode(delivery.HaveNode{Record: &delivery.NodeRecord{Node: n}}) + raw, _ := nodeengineclient.EncodeHaveNode(nodeengine.HaveByNodeNodeValue{Record: &nodeengine.NodeRecord{Node: n}}) st[haveState].values[n+".node"] = raw } var asked []string @@ -58,8 +69,7 @@ func TestTheSeatsVerbs(t *testing.T) { }) s.now = func() time.Time { return time.Date(2026, 10, 28, 20, 0, 0, 0, time.UTC) } - raw, _ := delivery.EncodePlacement(anchor) - st[delivery.PlacementState].values["placement.anchor"] = raw + setAssignments(st[mma.StateAssignments], anchor) // withdraw and again mark the proposal; the holder ends or judges it. got, err := s.doPropose(map[string]any{"why": "hold", "changes": `[{"op":"hold","node":"anchor","module":"web","claim":"node-shell","scope":"node"}]`}, "op") @@ -73,18 +83,18 @@ func TestTheSeatsVerbs(t *testing.T) { if _, err := s.doAgain(map[string]any{"id": seed.ID}); err != nil { t.Fatal(err) } - if p, err := s.proposal(seed.ID); err != nil || !strings.Contains(p.Withdrawn, "not yet") || p.Again.IsZero() { + if p, err := s.proposal(seed.ID); err != nil || !strings.Contains(p.Withdrawn, "not yet") || p.Again == nil || p.Again.IsZero() { t.Errorf("marked = %+v, %v", p, err) } if _, err := s.doWithdraw(map[string]any{"id": "p-none", "why": "x"}, "op"); err == nil { t.Error("an unknown proposal was withdrawn") } - delete(st[delivery.PlacementProposalsState].values, seed.ID) // the holder ended it - st[delivery.PlacementCandidatesState].values = map[string][]byte{} + delete(st[mma.StateProposals].values, seed.ID) // the holder ended it + st[mma.StateCandidates].values = map[string][]byte{} // propose: refused whole, naming every finding; a valid move laid over the keys at their revisions. if _, err := s.doPropose(map[string]any{"why": "x", "changes": `[{"op":"place","node":"anchor","module":"ghost"},{"op":"hold","node":"anchor","module":"web","claim":"x"}]`}, "op"); err == nil || - !strings.Contains(err.Error(), "ghost") || len(st[delivery.PlacementProposalsState].values) != 0 { + !strings.Contains(err.Error(), "ghost") || len(st[mma.StateProposals].values) != 0 { t.Errorf("a refused proposal: %v", err) } if _, err := s.doPropose(map[string]any{"why": "x", "changes": `[{"op":"swap","node":"anchor"}]`}, "op"); err == nil { @@ -96,33 +106,33 @@ func TestTheSeatsVerbs(t *testing.T) { t.Fatal(err) } move := got.(proposed).Proposal - if move.By != "operator@laptop" || move.Base["anchor"] != uint64(len(raw)) || move.Base["laptop"] != 0 || len(st[delivery.PlacementCandidatesState].values) != 2 { + if move.By != "operator@laptop" || move.Base["anchor"] != int64(len(st[mma.StateAssignments].values["anchor.node"])) || move.Base["laptop"] != 0 || len(st[mma.StateCandidates].values) != 2 { t.Fatalf("propose = %+v", move) } - // placement and proposals read every state the seat keeps (§10); history names the stream for what is older. + // assignments and proposals read every state the seat keeps (§10); history names the stream for what is older. reads := map[string]bool{} - pa, err := s.doPlacement(map[string]any{"module": "web", "history": "true"}) - if a := pa.(placementAnswer); err != nil || !slices.Equal(a.Nodes, []string{"anchor"}) || len(a.Changes) != 1 || !strings.Contains(a.Older, delivery.PlacementStream) { - t.Errorf("placement of web = %+v, %v", pa, err) + pa, err := s.doAssignments(map[string]any{"module": "web", "history": "true"}) + if a := pa.(assignmentsAnswer); err != nil || !slices.Equal(a.Nodes, []string{"anchor"}) || len(a.Changes) != 1 || !strings.Contains(a.Older, "shared events stream") { + t.Errorf("assignments of web = %+v, %v", pa, err) } else { - reads[delivery.PlacementState], reads["stream "+delivery.PlacementStream] = true, true + reads[mma.StateAssignments], reads["events"] = true, true } - if _, err := s.doPlacement(map[string]any{"node": "laptop"}); err == nil { + if _, err := s.doAssignments(map[string]any{"node": "laptop"}); err == nil { t.Error("a node with no key was answered") } ps, err := s.doProposals(map[string]any{"id": move.ID}) if list := ps.(map[string]any)["proposals"].([]openProposal); err != nil || len(list) != 1 || len(list[0].Candidates) != 2 { t.Errorf("proposals = %+v, %v", ps, err) } else { - reads[delivery.PlacementProposalsState], reads[delivery.PlacementCandidatesState] = true, true + reads[mma.StateProposals], reads[mma.StateCandidates] = true, true } raw0, err := readManifest() if err != nil { t.Fatal(err) } for _, state := range raw0.State { - if slices.Contains([]string{delivery.PlacementState, delivery.PlacementProposalsState, delivery.PlacementCandidatesState}, state.Name) && !reads[state.Name] { + if slices.Contains([]string{mma.StateAssignments, mma.StateProposals, mma.StateCandidates}, state.Name) && !reads[state.Name] { t.Errorf("no verb reads the seat's state %s", state.Name) } } @@ -132,7 +142,7 @@ func TestTheSeatsVerbs(t *testing.T) { } } - // Issue 774 (ADR 0567 §8): every kind of change is proposed, and there is no controller to hand placement back to. + // Issue 774 (ADR 0567 §8): every kind of change is proposed, and there is no controller to hand assignments back to. if _, err := s.doPropose(map[string]any{"why": "hold", "changes": `[{"op":"hold","node":"anchor","module":"web","claim":"node-shell","scope":"node"}]`}, "op"); err != nil { t.Errorf("a hold: %v", err) } @@ -158,11 +168,10 @@ func TestTheSeatsVerbs(t *testing.T) { if err := addNode("novox.mesh-enrolment", good); err != nil { t.Errorf("an add-node from mesh-enrolment: %v", err) } - // A remove-node (the operator's decision of 2026-11-02 01:05) takes a joining node that never confirmed off placement: from + // A remove-node (the operator's decision of 2026-11-02 01:05) takes a joining node that never confirmed off assignments: from // mesh-enrolment, on the warrant naming that node, of a node running nothing, it is proposed with the node's key to delete; // of a node running modules, or with another node's warrant, it is refused. - joiner, _ := delivery.EncodePlacement(delivery.Placement{V: delivery.Version, Node: "joiner", Decided: delivery.NodeDecided{Address: "10.9.0.7"}, Modules: []delivery.PlacedModule{}}) - st[delivery.PlacementState].values["placement.joiner"] = joiner + setAssignments(st[mma.StateAssignments], mma.Assignments{V: keys.Version, Node: "joiner", Decided: mma.NodeDecided{Address: "10.9.0.7"}, Modules: []mma.PlacedModule{}}) removeNode := func(node, warrant string) (any, error) { return s.doPropose(map[string]any{"why": "enrolment ended", "warrant": warrant, "changes": []any{map[string]any{"op": "remove-node", "node": node}}}, "novox.mesh-enrolment") @@ -177,24 +186,22 @@ func TestTheSeatsVerbs(t *testing.T) { } if got, err := removeNode("joiner", `{"node":"joiner","address":"10.9.0.7","ask":"a","warrant":"a@t"}`); err != nil { t.Errorf("a remove-node from mesh-enrolment: %v", err) - } else if rm := got.(proposed).Proposal; !slices.Equal(rm.Nodes(), []string{"joiner"}) || !slices.Equal(rm.RemovedNodes(), []string{"joiner"}) { + } else if rm := got.(proposed).Proposal; !slices.Equal(plc.Nodes(rm), []string{"joiner"}) || !slices.Equal(plc.RemovedNodes(rm), []string{"joiner"}) { t.Errorf("the remove-node proposed %+v", rm) } // 1126/B8: the seat serves its five verbs; adopt and hand-back went with the controller (ADR 0590). - if tools := s.tools(); len(tools) != 5 || tools[2].Name != "mesh-placement.propose" || tools[2].Run == nil || tools[2].Input == nil { + if tools := s.tools(); len(tools) != 5 || tools[2].Name != "mesh-module-assigner.propose" || tools[2].Run == nil { t.Errorf("the seat's verbs as served: %+v", tools) } t.Run("1953/D4-D6: the seat mesh-wants' upgrade and bus", wantsVerbs) t.Run("1955/L7 (ADR 0571 §6): rule 7 reads the node's own composition; an unplaced node is said", func(t *testing.T) { w := newWorld() - fp := delivery.SourcePrefix2 + strings.Repeat("2", 64) - pl, _ := delivery.EncodePlacement(delivery.Placement{V: delivery.Version, Node: "ace", Modules: []delivery.PlacedModule{{Module: "web"}}}) - pkey, _ := delivery.LegacyPlacementKey("ace") - w.putEntry(delivery.PlacementBucket, pkey, pl, 1, time.Now()) + fp := meshcatalogclient.SourcePrefix2 + strings.Repeat("2", 64) + putAssignments(w, mma.Assignments{V: keys.Version, Node: "ace", Modules: []mma.PlacedModule{{Module: "web"}}}) for node, digest := range map[string]string{"ace": "sha256:" + strings.Repeat("a", 64), "g14": ""} { - hn, _ := delivery.EncodeHaveNode(delivery.HaveNode{Composed: digest}) - hk, _ := delivery.HaveNodeKey(node) - w.putEntry(delivery.HaveBucket, hk, hn, 1, time.Now()) + hn, _ := nodeengineclient.EncodeHaveNode(nodeengine.HaveByNodeNodeValue{Composed: digest}) + hk, _ := nodeengineclient.HaveNodeKey(node) + w.putEntry(buckets.Have, hk, hn, 1, time.Now()) } if d, ok := w.composed("ace", "web", fp); !ok || d.Digest != "sha256:"+strings.Repeat("a", 64) { t.Errorf("ace: %+v %v", d, ok) @@ -210,28 +217,21 @@ func TestTheSeatsVerbs(t *testing.T) { if _, ok := findings[assign.UnplacedKey("g14")]; !ok || len(findings) != 1 { t.Fatalf("findings = %v", findings) } - pl2, _ := delivery.EncodePlacement(delivery.Placement{V: delivery.Version, Node: "g14", Modules: []delivery.PlacedModule{{Module: "web"}}}) - pkey2, _ := delivery.LegacyPlacementKey("g14") - w.putEntry(delivery.PlacementBucket, pkey2, pl2, 1, time.Now()) + putAssignments(w, mma.Assignments{V: keys.Version, Node: "g14", Modules: []mma.PlacedModule{{Module: "web"}}}) p.unplacedFindings() if len(findings) != 0 { t.Errorf("placed again: findings %v", findings) } }) - t.Run("1955/L7 (ADR 0571 §4): seat-holders is written from placement, only the keys a change moves, and answered by placement seats", func(t *testing.T) { + t.Run("1955/L7 (ADR 0571 §4): seat-holders is written from assignments, only the keys a change moves, and answered by assignments seats", func(t *testing.T) { writes := 0 kv := &countingKV{memKV: memKV{values: map[string][]byte{}}, writes: &writes} - p := &passer{w: newWorld(), logf: t.Logf, kvs: buckets{delivery.SeatHoldersBucket: kv}} - place := func(node string, holds ...delivery.Holding) { - pl, err := delivery.EncodePlacement(delivery.Placement{V: delivery.Version, Node: node, Modules: []delivery.PlacedModule{{Module: "web"}, {Module: "nats"}}, Holds: holds}) - if err != nil { - t.Fatal(err) - } - k, _ := delivery.LegacyPlacementKey(node) - p.w.putEntry(delivery.PlacementBucket, k, pl, 1, time.Now()) + p := &passer{w: newWorld(), logf: t.Logf, kvs: kvSet{plc.SeatHoldersBucket: kv}} + place := func(node string, holds ...mma.Holding) { + putAssignments(p.w, mma.Assignments{V: keys.Version, Node: node, Modules: []mma.PlacedModule{{Module: "web"}, {Module: "nats"}}, Holds: holds}) } - place("ace", delivery.Holding{Claim: "mesh-broker", Scope: "mesh", Module: "nats"}, delivery.Holding{Claim: "node-shell", Scope: "node", Module: "web"}) - place("g14", delivery.Holding{Claim: "node-shell", Scope: "node", Module: "web"}) + place("ace", mma.Holding{Claim: "mesh-broker", Scope: "mesh", Module: "nats"}, mma.Holding{Claim: "node-shell", Scope: "node", Module: "web"}) + place("g14", mma.Holding{Claim: "node-shell", Scope: "node", Module: "web"}) ctx := context.Background() p.seatHolders(ctx) if writes != 3 || kv.values["mesh-broker"] == nil || kv.values["node-shell.ace"] == nil || kv.values["node-shell.g14"] == nil { @@ -239,107 +239,179 @@ func TestTheSeatsVerbs(t *testing.T) { } p.seatHolders(ctx) if writes != 3 { - t.Errorf("an unchanged placement wrote %d keys", writes-3) + t.Errorf("an unchanged assignments wrote %d keys", writes-3) } place("g14") // g14 holds nothing now p.seatHolders(ctx) if writes != 3 || kv.values["node-shell.g14"] != nil || kv.values["node-shell.ace"] == nil { t.Errorf("after g14's change: %d writes, %v", writes, kv.values) } - st := memStore{delivery.SeatHoldersState: &memKV{values: kv.values}} + st := memStore{mma.StateSeatHolders: &memKV{values: kv.values}} out, err := newSeatTools(st, nil).seats("ace") - if err != nil || len(out.(map[string]any)["seats"].([]delivery.SeatHolders)) != 2 { + if err != nil || len(out.(map[string]any)["seats"].([]mma.SeatHolders)) != 2 { t.Errorf("seats on ace = %+v, %v", out, err) } }) - t.Run("research 072 row 37: the form before is converted once, a node is written as its keys, and history reads both", func(t *testing.T) { + t.Run("research 072 row 37: a node is written as its keys, and history reads the node key", func(t *testing.T) { ctx := context.Background() kv := &revKV{values: map[string][]byte{}, revs: map[string]uint64{}} - var logs []string - p := &passer{w: newWorld(), kvs: buckets{delivery.PlacementBucket: kv}, now: time.Now, - logf: func(f string, a ...any) { logs = append(logs, fmt.Sprintf(f, a...)) }} - put := func(key string, value []byte, rev uint64) { - p.w.putEntry(delivery.PlacementBucket, key, value, rev, time.Now()) + p := &passer{w: newWorld(), kvs: kvSet{plc.AssignmentsBucket: kv}, now: time.Now, logf: t.Logf} + first := mma.Assignments{Node: "anchor", Modules: []mma.PlacedModule{{Module: "web"}}} + if _, err := p.writeNode(ctx, "anchor", &first); err != nil || strings.Join(kv.log, " ") != "create anchor.web create anchor.node" { + t.Fatalf("the first write = %v, %v", kv.log, err) } - was := delivery.Placement{Node: "anchor", Modules: []delivery.PlacedModule{{Module: "web"}}} - raw, _ := delivery.EncodePlacement(was) - put("placement.anchor", raw, kv.write("placement.anchor", raw)) - p.convertPlacement(ctx) - if strings.Join(kv.log, " ") != "create anchor.web create anchor.node" || !p.convertedAll || - !strings.Contains(strings.Join(logs, "\n"), "anchor converted from placement.anchor") { - t.Fatalf("the conversion wrote %v, said %v", kv.log, logs) - } - p.convertPlacement(ctx) - if len(kv.log) != 2 { - t.Errorf("a converted node is converted again: %v", kv.log) - } - // A write: the module keys, the node key (its commit), the mirror, then the module key no longer placed. - after := delivery.Placement{Node: "anchor", Modules: []delivery.PlacedModule{{Module: "shell", Ports: map[string]int{"22": 2222}}}} + raw := kv.values["anchor.node"] + // A write: the module keys, the node key (its commit), then the module key no longer placed. + after := mma.Assignments{Node: "anchor", Modules: []mma.PlacedModule{{Module: "shell", Ports: map[string]int64{"22": 2222}}}} kv.log = nil rev, err := p.writeNode(ctx, "anchor", &after) - if err != nil || strings.Join(kv.log, " ") != "create anchor.shell update anchor.node update placement.anchor delete anchor.web" || rev != kv.revs["anchor.node"] { + if err != nil || strings.Join(kv.log, " ") != "create anchor.shell update anchor.node delete anchor.web" || rev != kv.revs["anchor.node"] { t.Fatalf("the write = %v, %d, %v", kv.log, rev, err) } - if got, _ := p.seatPlacements(); !delivery.SamePlacement(got["anchor"], after) { + if got, _ := p.seatAssignments(); !mmac.SameAssignments(got["anchor"], after) { t.Errorf("the write is not read back at once: %+v", got) } // A commit refused leaves every reader on what stood: the module key written before it carries the value it replaced. kv.refuse, kv.log = "anchor.node", nil - moved := delivery.Placement{Node: "anchor", Modules: []delivery.PlacedModule{{Module: "shell", Ports: map[string]int{"22": 2223}}}} + moved := mma.Assignments{Node: "anchor", Modules: []mma.PlacedModule{{Module: "shell", Ports: map[string]int64{"22": 2223}}}} if _, err := p.writeNode(ctx, "anchor", &moved); err == nil || strings.Join(kv.log, " ") != "update anchor.shell" { t.Fatalf("a refused commit = %v, %v", kv.log, err) } - fresh, err := delivery.PlacementsOf(kv.values) - if err != nil || !delivery.SamePlacement(fresh["anchor"], after) { + fresh, err := mmac.AssignmentsOf(kv.values) + if err != nil || !mmac.SameAssignments(fresh["anchor"], after) { t.Errorf("a reader of the keys after a refused commit reads %+v, %v", fresh["anchor"], err) } kv.refuse = "" - // The verb's history reads the form before, then the node key. - st := memStore{delivery.PlacementState: &memKV{values: kv.values}} + // The verb's history reads the node key. + st := memStore{mma.StateAssignments: &memKV{values: kv.values}} hs := newSeatTools(st, nil) hs.st = historyStore{memStore: st, revisions: map[string][]stdio.StateRevision{ - "placement.anchor": {{Key: "placement.anchor", Value: raw, Revision: 1, Op: "put"}}, - "anchor.node": {{Key: "anchor.node", Value: kv.values["anchor.node"], Revision: 5, Op: "put"}, {Key: "anchor.node", Revision: 9, Op: "del"}}, + "anchor.node": {{Key: "anchor.node", Value: raw, Revision: 1, Op: "put"}, {Key: "anchor.node", Value: kv.values["anchor.node"], Revision: 5, Op: "put"}, {Key: "anchor.node", Revision: 9, Op: "del"}}, }} - pa, err := hs.doPlacement(map[string]any{"node": "anchor", "history": "true"}) - a, _ := pa.(placementAnswer) + pa, err := hs.doAssignments(map[string]any{"node": "anchor", "history": "true"}) + a, _ := pa.(assignmentsAnswer) if err != nil || len(a.Changes) != 3 || !slices.Equal(a.Changes[0].Change.Added, []string{"web"}) || !slices.Equal(a.Changes[1].Change.Added, []string{"shell"}) || !slices.Equal(a.Changes[1].Change.Removed, []string{"web"}) || !a.Changes[2].Deleted || !slices.Equal(a.Changes[2].Change.Removed, []string{"shell"}) { t.Errorf("history = %+v, %v", pa, err) } - // No placement: the mirror first, then the node key, then the module keys. + // No assignments: the node key, then the module keys. kv.log = nil - if _, err := p.writeNode(ctx, "anchor", nil); err != nil || strings.Join(kv.log, " ") != "delete placement.anchor delete anchor.node delete anchor.shell" { + if _, err := p.writeNode(ctx, "anchor", nil); err != nil || strings.Join(kv.log, " ") != "delete anchor.node delete anchor.shell" { t.Errorf("the delete = %v, %v", kv.log, err) } }) + t.Run("ADR 0641: the retired state is copied into assignments, key by key, with no gap and nothing deleted", func(t *testing.T) { + ctx := context.Background() + web, _ := mmac.AssignmentsValues(mma.Assignments{Node: "anchor", Modules: []mma.PlacedModule{{Module: "web"}}}) + shell, _ := mmac.AssignmentsValues(mma.Assignments{Node: "anchor", Modules: []mma.PlacedModule{{Module: "shell"}}}) + oldWeb := func() map[string][]byte { + return map[string][]byte{"anchor.node": web["anchor.node"], "anchor.web": web["anchor.web"]} + } + withLegacy := oldWeb() + withLegacy["placement.anchor"] = []byte(`{"whole":1}`) + withOdd := oldWeb() + withOdd["a.b.c"] = []byte("x") + for _, c := range []struct { + name string + old map[string][]byte // the retired state + marked bool // the copy ended at an earlier start + present map[string][]byte // what assignments holds at the start + wantLog string // the writes into assignments, in order + wantValues map[string][]byte // assignments afterwards + journal int // lines said + said copiedSaid // the event; none when zero + }{ + {"an empty new state takes every key, the node key last, and the old form is not copied", withLegacy, false, nil, + "create anchor.web create anchor.node", web, 1, copiedSaid{Copied: 2, Dropped: 1}}, + {"a copy cut short by a crash is completed at the next start, and the marker is written", oldWeb(), false, + map[string][]byte{"anchor.web": web["anchor.web"]}, + "create anchor.node", web, 1, copiedSaid{Copied: 1, Present: 1}}, + {"a start after the marker copies nothing, even with a key missing", oldWeb(), true, + map[string][]byte{"anchor.node": shell["anchor.node"], "anchor.shell": shell["anchor.shell"]}, + "", shell, 0, copiedSaid{}}, + {"a key that fits no form is left and counted", withOdd, false, nil, + "create anchor.web create anchor.node", web, 1, copiedSaid{Copied: 2, Skipped: 1}}, + {"an empty old state copies nothing and says nothing", nil, false, nil, "", nil, 0, copiedSaid{}}, + } { + t.Run(c.name, func(t *testing.T) { + next := &revKV{values: map[string][]byte{}, revs: map[string]uint64{}} + for k, v := range c.present { + next.write(k, v) + } + old := &retiredKV{values: map[string][]byte{}} + maps.Copy(old.values, c.old) + copies := &copiesKV{values: map[string][]byte{}} + if c.marked { + copies.values[copiedMarker] = []byte(`{}`) + } + var logs []string + bus := &emitted{} + p := &passer{w: newWorld(), kvs: kvSet{retiredBucket: old, plc.AssignmentsBucket: next, copiesBucket: copies}, now: time.Now, seat: &seatActs{bus: bus}, + logf: func(f string, a ...any) { logs = append(logs, fmt.Sprintf(f, a...)) }} + if !p.copyRetired(ctx) || !p.copied { + t.Fatalf("the copy did not end: %v", logs) + } + if got := strings.Join(next.log, " "); got != c.wantLog { + t.Errorf("writes = %q, want %q", got, c.wantLog) + } + if c.wantValues != nil && !maps.EqualFunc(next.values, c.wantValues, slices.Equal[[]byte]) { + t.Errorf("assignments = %v, want %v", next.values, c.wantValues) + } + if _, ok := copies.values[copiedMarker]; !ok { + t.Errorf("the end of the copy is not marked in %s: %v", copiesBucket, copies.values) + } + if !maps.EqualFunc(old.values, c.old, slices.Equal[[]byte]) || old.deletes != 0 { + t.Errorf("the retired state changed: %v, %d deletes; the readers delivered before still read it", old.values, old.deletes) + } + if len(logs) != c.journal { + t.Errorf("said %d lines %v, want %d", len(logs), logs, c.journal) + } + if c.said == (copiedSaid{}) { + if len(bus.events) != 0 { + t.Errorf("no copy was made, and said %v", bus.events) + } + } else if len(bus.events) != 1 || bus.events[0] != c.said { + t.Errorf("said %v, want the event %+v once", bus.events, c.said) + } + // A restart finds the marker: nothing is written, and no second event is said. + before := len(next.log) + p.copied = false + if !p.copyRetired(ctx) || len(next.log) != before || len(bus.events) > 1 { + t.Errorf("a second copy wrote %v, said %v", next.log[before:], bus.events) + } + if got := len(p.w.read(plc.AssignmentsBucket)); got != c.said.Copied { + t.Errorf("%d copied keys are in the world, want %d", got, c.said.Copied) + } + }) + } + }) t.Run("1954/D1: a failed provision is the assigner's finding until it is provided", func(t *testing.T) { findings := map[string][]byte{} - p := &passer{w: newWorld(), logf: func(string, ...any) {}, kvs: buckets{provisions.ProvisionsBucket: nil}, findings: assign.Findings{ + p := &passer{w: newWorld(), logf: func(string, ...any) {}, kvs: kvSet{buckets.Provisions: nil}, findings: assign.Findings{ Put: func(k string, v []byte) error { findings[k] = v; return nil }, Delete: func(k string) error { delete(findings, k); return nil }}} - failed, _ := provisions.Encode(provisions.Provision{Provision: "db", Consumer: provisions.End{Module: "photos", Node: "ace"}, - Provider: provisions.End{Module: "postgres", Node: "novox"}, State: provisions.Failed, Why: "the role exists already"}) - p.w.putEntry(provisions.ProvisionsBucket, "ace.photos.db", failed, 1, time.Now()) - p.kvs[provisions.ProvisionsBucket] = &memKV{} + failed, _ := meshprovisionerclient.Encode(meshprovisionerclient.Provision{Provision: "db", Consumer: meshprovisionerclient.End{Module: "photos", Node: "ace"}, + Provider: meshprovisionerclient.End{Module: "postgres", Node: "novox"}, State: meshprovisionerclient.Failed, Why: "the role exists already"}) + p.w.putEntry(buckets.Provisions, "ace.photos.db", failed, 1, time.Now()) + p.kvs[buckets.Provisions] = &memKV{} of := p.provisionsOf() - if got := of("photos")("ace"); got["ace.photos.db"].State != provisions.Failed { + if got := of("photos")("ace"); got["ace.photos.db"].State != meshprovisionerclient.Failed { t.Fatalf("provisions = %+v", got) } if _, ok := findings[assign.ProvisionFailedKey("ace.photos.db")]; !ok || len(findings) != 1 { t.Fatalf("findings = %v", findings) } - provided, _ := provisions.Encode(provisions.Provision{Provision: "db", Consumer: provisions.End{Module: "photos", Node: "ace"}, - Provider: provisions.End{Module: "postgres", Node: "novox"}, State: provisions.Provided}) - p.w.putEntry(provisions.ProvisionsBucket, "ace.photos.db", provided, 2, time.Now()) + provided, _ := meshprovisionerclient.Encode(meshprovisionerclient.Provision{Provision: "db", Consumer: meshprovisionerclient.End{Module: "photos", Node: "ace"}, + Provider: meshprovisionerclient.End{Module: "postgres", Node: "novox"}, State: meshprovisionerclient.Provided}) + p.w.putEntry(buckets.Provisions, "ace.photos.db", provided, 2, time.Now()) p.provisionsOf() if len(findings) != 0 { t.Errorf("findings after provided = %v", findings) } // A required provision with no key: pulsed with the bound first, said once the pulse expired, ended once keyed. ctx := context.Background() - p.kvs[delivery.PlacementPulsesBucket] = &memKV{values: map[string][]byte{}} + p.kvs[plc.PulsesBucket] = &memKV{values: map[string][]byte{}} gone := map[string]bool{"ace.photos.s3": true} p.provisionsMissing(ctx, gone) if !p.pulsing("provision-missing.ace.photos.s3") || len(findings) != 0 { @@ -349,7 +421,7 @@ func TestTheSeatsVerbs(t *testing.T) { if len(findings) != 0 { t.Fatal("said before its bound") } - p.w.put(delivery.PlacementPulsesBucket, "provision-missing.ace.photos.s3", nil) // the server expired it + p.w.put(plc.PulsesBucket, "provision-missing.ace.photos.s3", nil) // the server expired it p.provisionsMissing(ctx, gone) if _, ok := findings[assign.ProvisionMissingKey("ace.photos.s3")]; !ok { t.Fatalf("past its bound: findings %v", findings) @@ -365,31 +437,32 @@ func TestTheSeatsVerbs(t *testing.T) { // written with who and why, recorded as a hand act in the seat's log and said as policy-changed, and default takes it back; // the bus is refused; compare lists each node's composition and the findings; bus names the node behind the indexer's newest. func wantsVerbs(t *testing.T) { - fpOld, fpNew := delivery.SourcePrefix2+strings.Repeat("1", 64), delivery.SourcePrefix2+strings.Repeat("2", 64) + fpOld, fpNew := meshcatalogclient.SourcePrefix2+strings.Repeat("1", 64), meshcatalogclient.SourcePrefix2+strings.Repeat("2", 64) st := memStore{} - for _, state := range []string{modulesState, manifestsState, policiesState, haveState, putsState, "findings", delivery.ComposedRead} { + for _, state := range []string{modulesState, manifestsState, policiesState, haveState, putsState, "findings", buckets.ComposedRead} { st[state] = &memKV{values: map[string][]byte{}} } // ADR 0593 §6, §8: what each node published, every node's bucket as one state: anchor's own composition and its web at // a fingerprint, and laptop's web refused there. - webKey, _ := delivery.ComposedKey("anchor", "web", fpNew) - refusedKey, _ := delivery.ComposedKey("laptop", "web", fpNew) - st[delivery.ComposedRead].values["anchor.node"], _ = delivery.EncodeComposed(delivery.Composed{Node: "anchor"}) - st[delivery.ComposedRead].values[webKey], _ = delivery.EncodeComposed(delivery.Composed{Node: "anchor", Module: "web", FP: fpNew}) - st[delivery.ComposedRead].values[refusedKey], _ = delivery.EncodeComposed(delivery.Composed{Node: "laptop", Module: "web", FP: fpNew, Refused: "a required value is unset"}) + partNew, _ := meshcatalogclient.KeyPart(fpNew) + webKey, _ := nodeengineclient.ComposedKey("anchor", "web", partNew) + refusedKey, _ := nodeengineclient.ComposedKey("laptop", "web", partNew) + st[buckets.ComposedRead].values["anchor.node"], _ = nodeengineclient.EncodeComposition(nodeengineclient.Composition{Node: "anchor"}) + st[buckets.ComposedRead].values[webKey], _ = nodeengineclient.EncodeComposition(nodeengineclient.Composition{Node: "anchor", Module: "web", FP: fpNew}) + st[buckets.ComposedRead].values[refusedKey], _ = nodeengineclient.EncodeComposition(nodeengineclient.Composition{Node: "laptop", Module: "web", FP: fpNew, Refused: "a required value is unset"}) manifest := func(name, extra string) { raw := `{"module":"` + name + `","version":"1"` + extra + `}` - digest := delivery.ManifestDigest([]byte(raw)) - st[manifestsState].values[digest], _ = delivery.EncodeManifest(delivery.Manifest{V: delivery.Version, Module: name, JSON: raw}) - st[modulesState].values[name], _ = delivery.EncodeModule(delivery.Module{V: delivery.Version, FP2: fpNew, Manifest: digest, Repository: "novox/x", Commit: "c"}) + digest := meshcatalogclient.ManifestDigest([]byte(raw)) + st[manifestsState].values[digest], _ = meshcatalogclient.EncodeManifest(meshcatalog.ManifestsByDigestValue{V: keys.Version, Module: name, Manifest: raw}) + st[modulesState].values[name], _ = meshcatalogclient.EncodeModule(meshcatalog.ModulesByModuleValue{V: keys.Version, Fp2: fpNew, Manifest: digest, Repository: "novox/x", Commit: "c"}) } manifest("web", "") manifest("photos", `,"upgrade":{"policy":"record","why":"a person checks the albums"}`) manifest("nats", `,"provides":[{"name":"mesh-bus","scope":"mesh"}],"upgrade":{"policy":"record","why":"its streams"}`) for node, fp := range map[string]string{"anchor": fpOld, "laptop": fpNew} { - st[haveState].values[node+".nats"], _ = delivery.EncodeHave(delivery.Have{FP: fp, Phase: delivery.PhaseRunning, Health: delivery.HealthOK}) + st[haveState].values[node+".nats"], _ = nodeengineclient.EncodeHave(nodeengine.HaveByNodeModuleValue{Fp: fp, Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthOK}) } - st[haveState].values["anchor.node"], _ = delivery.EncodeHaveNode(delivery.HaveNode{Composed: "sha256:" + strings.Repeat("c", 64)}) + st[haveState].values["anchor.node"], _ = nodeengineclient.EncodeHaveNode(nodeengine.HaveByNodeNodeValue{Composed: "sha256:" + strings.Repeat("c", 64)}) var asked []string var said []string w := newWantsTools(st, func(key string, body any) (json.RawMessage, error) { @@ -448,7 +521,7 @@ func wantsVerbs(t *testing.T) { if n := watches[manifestsState+" by key"]; n == 0 || n > len(st[manifestsState].values) { t.Errorf("the manifests were read by key %d times, want each once", n) } - for _, state := range []string{modulesState, haveState, health.FindingsState} { + for _, state := range []string{modulesState, haveState, mcc.FindingsState} { if watches[state] != 1 { t.Errorf("%s was watched %d times, want once", state, watches[state]) } @@ -536,8 +609,11 @@ func (k *revKV) write(key string, value []byte) uint64 { } func (k *revKV) Create(_ context.Context, key string, value []byte, _ ...jetstream.KVCreateOpt) (uint64, error) { - if _, ok := k.values[key]; ok || key == k.refuse { - return 0, errors.New("key exists") + if _, ok := k.values[key]; ok { + return 0, jetstream.ErrKeyExists + } + if key == k.refuse { + return 0, errors.New("refused") } k.log = append(k.log, "create "+key) return k.write(key, value), nil @@ -570,3 +646,99 @@ type historyStore struct { func (s historyStore) history(_, key string) ([]stdio.StateRevision, error) { return s.revisions[key], nil } + +// putAssignments takes a node's assignments into the world as the keys of the state: a key per module, then the node key. +func putAssignments(w *world, a mma.Assignments) { + values, err := mmac.AssignmentsValues(a) + if err != nil { + panic(err) + } + for _, k := range slices.Sorted(maps.Keys(values)) { + w.putEntry(plc.AssignmentsBucket, k, values[k], 1, time.Now()) + } +} + +// setAssignments holds a node's assignments in a state as its keys: a key per module, and the node key. +func setAssignments(kv *memKV, a mma.Assignments) { + values, err := mmac.AssignmentsValues(a) + if err != nil { + panic(err) + } + maps.Copy(kv.values, values) +} + +// retiredKV is the retired state: its keys, its values, and deletes of them. +type retiredKV struct { + jetstream.KeyValue + values map[string][]byte + deletes int +} + +func (k *retiredKV) Keys(context.Context, ...jetstream.WatchOpt) ([]string, error) { + if len(k.values) == 0 { + return nil, jetstream.ErrNoKeysFound + } + return slices.Sorted(maps.Keys(k.values)), nil +} + +func (k *retiredKV) Get(_ context.Context, key string) (jetstream.KeyValueEntry, error) { + v, ok := k.values[key] + if !ok { + return nil, jetstream.ErrKeyNotFound + } + return retiredEntry{key: key, value: v}, nil +} + +func (k *retiredKV) Delete(_ context.Context, key string, _ ...jetstream.KVDeleteOpt) error { + k.deletes++ + delete(k.values, key) + return nil +} + +type retiredEntry struct { + jetstream.KeyValueEntry + key string + value []byte +} + +func (e retiredEntry) Value() []byte { return e.value } +func (e retiredEntry) Operation() jetstream.KeyValueOp { return jetstream.KeyValuePut } + +// emitted is a bus that keeps the module events it was asked to say. +type emitted struct { + caller + events []copiedSaid +} + +func (e *emitted) Emit(eventType string, body any) error { + if eventType == CopiedEvent { + e.events = append(e.events, body.(copiedSaid)) + } + return nil +} + +func (k *revKV) Keys(context.Context, ...jetstream.WatchOpt) ([]string, error) { + if len(k.values) == 0 { + return nil, jetstream.ErrNoKeysFound + } + return slices.Sorted(maps.Keys(k.values)), nil +} + +// copiesKV is the state copies: the marker of the end of a copy. +type copiesKV struct { + jetstream.KeyValue + values map[string][]byte +} + +func (k *copiesKV) Get(_ context.Context, key string) (jetstream.KeyValueEntry, error) { + v, ok := k.values[key] + if !ok { + return nil, jetstream.ErrKeyNotFound + } + return retiredEntry{key: key, value: v}, nil +} + +func (k *copiesKV) Put(_ context.Context, key string, value []byte) (uint64, error) { + k.values[key] = value + return uint64(len(k.values)), nil +} diff --git a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/tools.go b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/tools.go index 2ed4d101..62f94dbd 100644 --- a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/tools.go +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/tools.go @@ -4,6 +4,8 @@ import ( "encoding/json" "errors" "fmt" + "mesh-delivery-assigner/internal/buckets" + "mesh-delivery-assigner/internal/puts" "os" "regexp" "slices" @@ -12,15 +14,22 @@ import ( "sync" "time" + "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds" + stdio "git.novox.be/novox/mesh-sdk/go" - "git.novox.be/novox/mesh-sdk/go/delivery" + + "git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient/enginereads" ) // The assigner's tools (novox/hq ADR 0546), served through the tool runner from its bundle: a process never serves (ADR // 0436, mesh-sdk process). They write and read the puts; the assigner's process takes each put further (puts.go) and says -// every step as the event put. They are the module's own tools; the seat mesh-placement's verbs are served beside them +// every step as the event put. They are the module's own tools; the seat mesh-module-assigner's verbs are served beside them // (seat_tools.go, ADR 0567 §10). +// engineReadsTool lists what each node's engine reads, as the assigner uses it, and asks one node's engine again. +const engineReadsTool = "assigner_engine_reads" + // putsState is the assigner's state of puts, as the runtime names a module's own state. const putsState = "puts" @@ -69,33 +78,33 @@ func (s *putTools) version(module, version string) (string, error) { v := strings.ToLower(strings.TrimSpace(version)) switch { case v == "newest": - raw, _, found, err := s.st.get(delivery.IndexerModule+".modules", module) + raw, _, found, err := s.st.get(buckets.ModulesRead, module) if err != nil { return "", fmt.Errorf("the indexer's modules cannot be read: %w", err) } if !found { return "", fmt.Errorf("the indexer holds no module %s at the trunk", module) } - m, err := delivery.DecodeModule(raw) + m, err := meshcatalogclient.DecodeModule(raw) if err != nil { return "", fmt.Errorf("the indexer's %s cannot be read: %w", module, err) } - if m.FP2 == "" { + if m.Fp2 == "" { return "", fmt.Errorf("the indexer holds no fingerprint of the new form for %s: %s", module, m.None2) } - return m.FP2, nil + return m.Fp2, nil case fullFP.MatchString(v): return v, nil case prefix.MatchString(v): - start := strings.TrimPrefix(v, delivery.SourcePrefix2) - keys, err := s.st.keys(delivery.BuilderModule + ".builds") + start := strings.TrimPrefix(v, meshcatalogclient.SourcePrefix2) + keys, err := s.st.keys(builds.BuilderModule + ".builds") if err != nil { return "", fmt.Errorf("the builder's builds cannot be listed: %w", err) } var found []string for _, k := range keys { - m, fp, err := delivery.ParseBuilderBuildKey(k) - if err == nil && m == module && strings.HasPrefix(strings.TrimPrefix(fp, delivery.SourcePrefix2), start) { + m, fp, err := builds.ParseBuilderBuildKey(k) + if err == nil && m == module && strings.HasPrefix(strings.TrimPrefix(fp, meshcatalogclient.SourcePrefix2), start) { found = append(found, fp) } } @@ -115,14 +124,14 @@ func (s *putTools) version(module, version string) (string, error) { type putAnswer struct { Node string `json:"node"` Module string `json:"module"` - delivery.Put + puts.Put Follow string `json:"follow,omitempty"` } // doPut is the tool assigner_put. func (s *putTools) doPut(args map[string]any, by string) (any, error) { node, module, why := arg(args, "node"), arg(args, "module"), arg(args, "why") - key, err := delivery.PutKey(node, module) + key, err := puts.Key(node, module) if err != nil { return nil, err } @@ -146,15 +155,15 @@ func (s *putTools) doPut(args map[string]any, by string) (any, error) { if raw, _, found, err := s.st.get(putsState, key); err != nil { return nil, fmt.Errorf("the put of %s on %s cannot be read: %w", module, node, err) } else if found { - if before, err := delivery.DecodePut(raw); err == nil && !delivery.PutEnded(before.Step) { + if before, err := puts.Decode(raw); err == nil && !puts.Ended(before.Step) { if before.FP == fp && before.Hold == hold { return putAnswer{Node: node, Module: module, Put: before, Follow: followPut(node, module)}, nil } words += fmt.Sprintf("; it replaces the put of %s at %s, which stood at %s", module, shortFP(before.FP), before.Step) } } - p := delivery.Put{FP: fp, Hold: hold, Why: why, By: by, At: now}.Said(now, delivery.PutAsked, words) - data, err := delivery.EncodePut(p) + p := puts.Put{FP: fp, Hold: hold, Why: why, By: by, At: now}.Said(now, puts.Asked, words) + data, err := puts.Encode(p) if err != nil { return nil, err } @@ -166,15 +175,15 @@ func (s *putTools) doPut(args map[string]any, by string) (any, error) { } // await is the put at key once the assigner's process took it up past its asking, or as it stands after answerWithin. -func (s *putTools) await(key string, p delivery.Put) delivery.Put { +func (s *putTools) await(key string, p puts.Put) puts.Put { deadline := time.After(answerWithin) for { s.mu.Lock() raw, changed := s.heard[key], s.changed s.mu.Unlock() - if now, err := delivery.DecodePut(raw); err == nil && now.At.Equal(p.At) { + if now, err := puts.Decode(raw); err == nil && now.At.Equal(p.At) { p = now - if now.Step != delivery.PutAsked && now.Step != delivery.PutBuild && now.Step != delivery.PutCompose { + if now.Step != puts.Asked && now.Step != puts.Build && now.Step != puts.Compose { return p // it waits on a person or the node, or ended } } @@ -195,7 +204,7 @@ func followPut(node, module string) string { // doRelease is the tool assigner_release. func (s *putTools) doRelease(args map[string]any, by string) (any, error) { node, module, why := arg(args, "node"), arg(args, "module"), arg(args, "why") - key, err := delivery.PutKey(node, module) + key, err := puts.Key(node, module) if err != nil { return nil, err } @@ -209,15 +218,15 @@ func (s *putTools) doRelease(args map[string]any, by string) (any, error) { if !found { return nil, fmt.Errorf("%s holds no put of %s: nothing to release", node, module) } - p, err := delivery.DecodePut(raw) + p, err := puts.Decode(raw) if err != nil { return nil, fmt.Errorf("the put of %s on %s cannot be read: %w", module, node, err) } - if delivery.PutEnded(p.Step) { + if puts.Ended(p.Step) { return nil, fmt.Errorf("the put of %s on %s ended already (%s: %s): nothing to release", module, node, p.Step, p.Words) } - p = p.Said(s.now(), delivery.PutReleased, fmt.Sprintf("released by %s: %s; the assigner's rules write the want again", by, why)) - data, err := delivery.EncodePut(p) + p = p.Said(s.now(), puts.Released, fmt.Sprintf("released by %s: %s; the assigner's rules write the want again", by, why)) + data, err := puts.Encode(p) if err != nil { return nil, err } @@ -237,7 +246,7 @@ func (s *putTools) doPuts(args map[string]any) (any, error) { sort.Strings(keys) out := []putAnswer{} for _, key := range keys { - node, module, err := delivery.ParsePutKey(key) + node, module, err := puts.ParseKey(key) if err != nil || (arg(args, "node") != "" && node != arg(args, "node")) || (arg(args, "module") != "" && module != arg(args, "module")) { continue } @@ -245,7 +254,7 @@ func (s *putTools) doPuts(args map[string]any) (any, error) { if err != nil || !found { continue } - p, err := delivery.DecodePut(raw) + p, err := puts.Decode(raw) if err != nil { continue } @@ -358,12 +367,15 @@ func serveTools() error { }) wants.watched = stdio.NewWatchedStates() // the large states read whole from one watched copy (ADR 0580 clause 3) tools = append(tools, wants.tools()...) + tools = append(tools, viewTools(os.Getenv, keptSource, time.Now)...) + // What each node's engine reads, as the assigner uses it (ADR 0618 §3): its own state engine-reads, asked again through it. + tools = append(tools, enginereads.Tool(engineReadsTool, "the assigner", enginereads.KeptState(stdio.State(engineReadsState)), false, time.Now)) return stdio.Serve("", tools) } // shortFP is a fingerprint's first twelve hex digits. func shortFP(fp string) string { - part, err := delivery.FingerprintKeyPart(fp) + part, err := meshcatalogclient.KeyPart(fp) if err != nil || len(part) < 12 { return fp } diff --git a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/verbs.go b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/verbs.go index 28fc87b9..bc3f55e6 100644 --- a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/verbs.go +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/verbs.go @@ -2,10 +2,13 @@ package main import ( "context" + "mesh-delivery-assigner/internal/buckets" "slices" + "git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient" + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds" + "git.novox.be/novox/mesh-sdk/go/catalogue" - "git.novox.be/novox/mesh-sdk/go/delivery" "git.novox.be/novox/mesh-delivery/sdk/graph" @@ -17,11 +20,11 @@ import ( // each kept edge until a build serving its verb runs healthy, read from the haves, the builder's builds and the indexer's // manifests, as a provider is counted (ADR 0603 §5). -// moduleGraph is the module graph as the indexer last wrote it; a key that does not decode is left out, so its module -// waits on no verb. +// moduleGraph is the module graph as the indexer's first view and views family last said it (ADR 0631); a key that +// does not decode is left out, so its module waits on no verb. func (p *passer) moduleGraph() graph.Graph { g := graph.Graph{} - for key, raw := range p.w.read(graph.Bucket) { + for key, raw := range p.w.read(buckets.Graph) { module, err := graph.ParseKey(key) if err != nil { continue @@ -81,10 +84,10 @@ func (p *passer) offering(ctx context.Context, get getter, trunk map[string]cata continue } for module, h := range haves { - if h.Phase != delivery.PhaseRunning || h.Health != delivery.HealthOK { + if h.Phase != nodeengineclient.PhaseRunning || h.Health != nodeengineclient.HealthOK { continue } - m, ok := p.manifestOf(ctx, get, module, h.FP) + m, ok := p.manifestOf(ctx, get, module, h.Fp) if !ok { m = trunk[module] } @@ -108,15 +111,15 @@ func (p *passer) servesOf(ctx context.Context, get getter, module, fp string) ([ // manifestOf is the manifest of module's build at fp (the builder's build names its digest in the indexer's manifests), // and whether it could be read. func (p *passer) manifestOf(ctx context.Context, get getter, module, fp string) (catalogue.Manifest, bool) { - key, err := delivery.BuilderBuildKey(module, fp) + key, err := builds.BuilderBuildKey(module, fp) if err != nil { return catalogue.Manifest{}, false } - raw := p.w.value(delivery.BuilderBuildsBucket, key) + raw := p.w.value(buckets.Builds, key) if raw == nil { return catalogue.Manifest{}, false } - b, err := delivery.DecodeBuilderBuild(raw) + b, err := builds.DecodeBuilderBuild(raw) if err != nil || b.Manifest == "" { return catalogue.Manifest{}, false } diff --git a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/views.go b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/views.go new file mode 100644 index 00000000..34179c8b --- /dev/null +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/views.go @@ -0,0 +1,224 @@ +package main + +import ( + "context" + "encoding/json" + "mesh-delivery-assigner/internal/assignment" + "mesh-delivery-assigner/internal/buckets" + "slices" + "time" + + "github.com/nats-io/nats.go/jetstream" + + mma "git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner" + mcc "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient" + "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshhandactsclient" + stdio "git.novox.be/novox/mesh-sdk/go" + "git.novox.be/novox/mesh-sdk/go/process" + + "git.novox.be/novox/mesh-sdk/go/views" + + sdkfindings "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient/findings" + "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient/heard" + "git.novox.be/novox/mesh-delivery/sdk/graph" + + "mesh-delivery-assigner/internal/assign" + plc "mesh-delivery-assigner/internal/assignment" +) + +// marksBucket is the assigner's state of what its views family said each state up to. +var marksBucket = assignment.Module + "_" + views.MarksState + +// viewedTool is one tool over one state of the assigner's. +type viewedTool struct{ tool, state, words string } + +// firstViewTools are the assigner's first-view tools (ADR 0621 §1): each answers only its callers list +// (views.CallersEnv), which the manifest writes. +var firstViewTools = []viewedTool{ + {"assignments", mma.StateAssignments, "where each module is placed: the seat mesh-module-assigner's assignments, by module."}, + {"seat-holders", mma.StateSeatHolders, "who holds which seat on which node."}, + {"wants", "wants", "what each node should run: want.., its fingerprint and the rule that gave it."}, +} + +// readTools are the assigner's ordinary read tools, one per state its readers read (design 25 § 12.2). +var readTools = []viewedTool{ + {"assigner_read_assignments", mma.StateAssignments, "where each module is placed, by module."}, + {"assigner_read_seat_holders", mma.StateSeatHolders, "who holds which seat on which node."}, + {"assigner_read_wants", "wants", "what each node should run: want..."}, + {"assigner_read_findings", mcc.FindingsState, "the assigner's findings, which the condition keeper raises and clears."}, + {"assigner_read_asks", "asks", "the asks the assigner put to a person, and their answers."}, + {"assigner_read_differences", "differences", "every difference between a want and what a node runs."}, + {"assigner_read_copies", copiesState, "the copies the assigner made of a retired state: when the copy into assignments ended and how many keys it copied."}, +} + +// viewedStates are the states the views family says, each by its bucket: the six its readers read. +var viewedStates = map[string]string{ + mma.StateAssignments: plc.AssignmentsBucket, + mma.StateSeatHolders: plc.SeatHoldersBucket, + "wants": buckets.Wants, + mcc.FindingsState: assign.FindingsBucket, + "asks": buckets.Asks, + "differences": buckets.Differences, + copiesState: copiesBucket, +} + +// viewTools are the assigner's first-view and read tools, reading only its own states, so they answer as soon as its +// runtime of its own is connected (ADR 0621 §3). +func viewTools(getenv func(string) string, state func(string) views.Source, now func() time.Time) []stdio.Tool { + marks := state(views.MarksState) + var out []stdio.Tool + for _, t := range firstViewTools { + out = append(out, views.FirstViewTool(assignment.Module, t.tool, t.state, t.words, views.ParseCallers(getenv(views.CallersEnv(t.tool))), state(t.state), marks, now)) + } + for _, t := range readTools { + out = append(out, views.ReadTool(assignment.Module, t.tool, t.state, t.words, state(t.state), marks, now)) + } + // Its own first views, of the indexer's manifests, modules and graph (DLV-F3r, ADR 0631 §2): read, and asked again by hand. + return append(out, views.StatusTools(assignment.Module, "assigner_first_views", "assigner_first_view_again", stdio.State(views.StatusState), meshhandactsclient.Recorder{Ask: stdio.Ask}, now)...) +} + +func keptSource(name string) views.Source { return views.KeptSource{State: stdio.State(name)} } + +// startViews says each change of the assigner's viewed states on its views family, once its marks state is bound, +// each state as soon as its bucket is (kvs, or bound once it appears). A watch that ends is sent on errs. +func startViews(ctx context.Context, js jetstream.JetStream, nc process.Subscriber, account process.Account, kvs kvSet, findings assign.Findings, errs chan<- error, logf func(string, ...any)) { + tooLarge := func(state, key string, size int) { + if findings.Put == nil { + return + } + if err := viewFindings(findings).Put(views.TooLargeFinding(assignment.Module, state, key, size, time.Now())); err != nil { + logf("views: the finding of %s.%s too large is not written: %v", state, key, err) + } + } + process.AwaitBucket(ctx, js, nc, marksBucket, "no change is said on the views family, so every reader waits on its first view", logf).Ready(func(mk jetstream.KeyValue) { + em := viewsEmitter(js, account, mk, findings, tooLarge, logf) + for _, state := range []string{"asks", "differences", mcc.FindingsState, mma.StateAssignments, mma.StateSeatHolders, "wants", copiesState} { + state := state + start := func(kv jetstream.KeyValue) { + if err := em.Start(ctx, state, kv, errs); err != nil { + logf("%v: its readers ask its first view at each change until the assigner starts again", err) + } + } + if kv := kvs[viewedStates[state]]; kv != nil { + start(kv) + continue + } + process.AwaitBucket(ctx, js, nc, viewedStates[state], "its changes are not said on the views family", logf).Ready(start) + } + }) +} + +// viewsEmitter is the assigner's emitter of its views family: a change not said past views.Bound is its finding +// view-unsaid, in its findings (review round 2); with no findings state it is said in the log only. +func viewsEmitter(js jetstream.JetStream, account process.Account, mk jetstream.KeyValue, findings assign.Findings, tooLarge func(string, string, int), logf func(string, ...any)) views.Emitter { + em := views.Emitter{Owner: assignment.Module, Subject: account.EventSubject, Publish: views.JetStreamPublish(js), Marks: mk, TooLarge: tooLarge, Logf: logf} + if findings.Put != nil && findings.Delete != nil { + em.Findings = viewFindings(findings) + } + return em +} + +// viewFindings is the assigner's findings as the views' FindingWriter, in the seat mesh-conditions' layout. +func viewFindings(f assign.Findings) views.FindingWriter { + return sdkfindings.WriterOver(f.Put, f.Delete) +} + +// firstViewsBucket is the assigner's own state of its first views (views.StatusState). +var firstViewsBucket = assignment.Module + "_" + views.StatusState + +// indexerViews are the assigner's first views of the indexer's manifests, modules and module graph (ADR 0615 §1, +// ADR 0621 §2, ADR 0631; DLV-F2r): asked at the start through the indexer's tools, then followed on indexer-views, asking once more on a gap; +// past the bound its finding names the indexer and the tool, cleared by the answer. +type indexerViews struct { + js jetstream.JetStream + account process.Account + call func(string, any) (json.RawMessage, error) + findings assign.Findings + status *process.AwaitedBucket + logf func(string, ...any) + hub *process.EventHub +} + +// indexerFirstViews are the indexer's states the assigner takes by first view and the views family, each by the name of +// the indexer's bucket the world keeps it under: manifests and modules (ADR 0621 §2), and the module graph, whose verb +// edges a want waits on (ADR 0614 §2, §3; ADR 0631 §1). +var indexerFirstViews = map[string]string{"manifests": buckets.Manifests, "modules": buckets.Modules, graph.State: buckets.Graph} + +// firstViewBound and firstViewWait are each first view's bound before its condition is raised, and its first wait +// between two failed asks; zero is the views package's (views.Bound, two minutes, ADR 0615 §6; and a first wait of five +// seconds). A test shortens them. +var firstViewBound, firstViewWait time.Duration + +// start asks the three first views, each answer taken into w under the indexer's bucket names and settled once (a pass waits +// for it), and hears the views family; it answers how many settles to wait for. +func (iv *indexerViews) start(ctx context.Context, w *world, wake func(), settled chan<- struct{}) int { + finding := func(s views.Status, raised bool) { + if iv.findings.Put == nil { + return + } + f := views.UnansweredFinding(assignment.Module, s, time.Now()) + var err error + if raised { + err = viewFindings(iv.findings).Put(f) + } else { + err = viewFindings(iv.findings).Delete(f) + } + if err != nil { + iv.logf("views: the finding of the first view %s is not written: %v", s.Tool, err) + } + } + followers := map[string]*views.Follower{} + var all []*views.Follower + for state, bucket := range indexerFirstViews { + bucket := bucket + tool := buckets.IndexerModule + "." + state + f := &views.Follower{Owner: buckets.IndexerModule, Tool: tool, State: state, Ask: views.AskVia(iv.call, tool), Logf: iv.logf, + Bound: firstViewBound, Wait: firstViewWait, + Raise: func(s views.Status) { finding(s, true) }, Clear: func(s views.Status) { finding(s, false) }, + Changed: func(key string, value json.RawMessage, deleted bool, at time.Time) { + if deleted { + value = nil + } + w.putEntry(bucket, key, value, 0, at) + wake() + }} + followers[state] = f + all = append(all, f) + go func() { + if f.Start(ctx) == nil { + settled <- struct{}{} + } + }() + } + if iv.status != nil { + iv.status.Ready(func(kv jetstream.KeyValue) { + for _, f := range all { + f.Said = views.KeepStatus(ctx, kv, iv.logf) + } + if err := views.HearAgain(ctx, kv, all, iv.logf); err != nil { + iv.logf("views: assigner_first_view_again is not heard (%v) until the assigner starts again", err) + } + }) + } + // Not heard, the indexer's views family is the finding family-not-heard until it binds (ADR 0615 §5): the handler is + // registered on the module's hub, which the assigner starts once every handler is. + _ = notHeardWriter(iv.findings, iv.logf).Bind(process.EventsStream, "the indexer's manifests, modules and graph are the first views' alone until the assigner starts again", func() error { + return iv.hub.Handle("mesh.mod."+buckets.IndexerModule+".event."+views.EventPrefix+"*", views.Hear(ctx, followers, iv.logf)) + }) + return len(all) +} + +// notHeardWriter writes the assigner's finding of a family it does not hear into its findings (heard.Writer); with no +// findings state it is said in the log alone. +func notHeardWriter(findings assign.Findings, logf func(string, ...any)) heard.Writer { + w := heard.Writer{Observer: assignment.Module, Logf: logf, Put: findings.Put} + if findings.Delete != nil { + w.Delete = func(key string) error { + if keys, err := findings.Keys(); err == nil && !slices.Contains(keys, key) { + return nil // none stands + } + return findings.Delete(key) + } + } + return w +} diff --git a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/wants_tools.go b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/wants_tools.go index d0c4092c..964b05fe 100644 --- a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/wants_tools.go +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/wants_tools.go @@ -4,29 +4,36 @@ import ( "encoding/json" "errors" "fmt" + "mesh-delivery-assigner/internal/buckets" + "mesh-delivery-assigner/internal/puts" "sort" "strconv" "time" + "git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient" + meshhandacts "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshhandacts" + "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" + "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" + "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshwants" + + mcc "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient" + "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshhandactsclient" stdio "git.novox.be/novox/mesh-sdk/go" "git.novox.be/novox/mesh-sdk/go/catalogue" - "git.novox.be/novox/mesh-sdk/go/delivery" - "git.novox.be/novox/mesh-sdk/go/handacts" - "git.novox.be/novox/mesh-sdk/go/health" "mesh-delivery-assigner/internal/assign" ) // The seat mesh-wants' verbs (novox/hq ADR 0565 §7, §8, §9, §15; issue 1953 D4-D6), served through the tool runner beside the -// put tools and mesh-placement's verbs. `upgrade` reads and sets a person's upgrade policy, kept in the state `policies` +// put tools and mesh-module-assigner's verbs. `upgrade` reads and sets a person's upgrade policy, kept in the state `policies` // (each set recorded as a hand act and said as policy-changed), and with compare lists the digest of the composition each node's // own have names beside every composition each node published (ADR 0593 §8, composed.go); `bus` says what a bus upgrade would do. The assigner's process reads // `policies` when it judges a move and writes the finding policy-split while a split stands. // The states the verbs read, as the runtime names them. const ( - policiesState = delivery.AssignerPoliciesState - manifestsState = delivery.IndexerModule + ".manifests" + policiesState = buckets.PoliciesState + manifestsState = buckets.IndexerModule + ".manifests" // PolicyChangedEvent is the seat's event of a person's policy set or taken back. PolicyChangedEvent = "policy-changed" // busStepCause is the cause the bus's step is recorded under in the hand-act log. @@ -47,7 +54,7 @@ type wantsTools struct { // wantsEventSubject is the subject of one of the seat's events. func wantsEventSubject(event string) string { - return "mesh.seat." + catalogue.WantsSeat + ".event." + event + return "mesh.seat." + meshwants.Seat + ".event." + event } func newWantsTools(st seatStore, ask func(string, any) (json.RawMessage, error), emit func(event, id string, body any) error) *wantsTools { @@ -92,14 +99,14 @@ func (s *wantsTools) manifest(digest string) ([]byte, bool, error) { } // manifests is every module the indexer holds, with its trunk manifest and fingerprint. -func (s *wantsTools) manifests() (map[string]catalogue.Manifest, map[string]delivery.Module, error) { +func (s *wantsTools) manifests() (map[string]catalogue.Manifest, map[string]meshcatalog.ModulesByModuleValue, error) { mods, err := s.values(modulesState) if err != nil { return nil, nil, err } - out, held := map[string]catalogue.Manifest{}, map[string]delivery.Module{} + out, held := map[string]catalogue.Manifest{}, map[string]meshcatalog.ModulesByModuleValue{} for name, raw := range mods { - m, err := delivery.DecodeModule(raw) + m, err := meshcatalogclient.DecodeModule(raw) if err != nil { continue } @@ -111,12 +118,12 @@ func (s *wantsTools) manifests() (map[string]catalogue.Manifest, map[string]deli if err != nil || !found { continue } - kept, err := delivery.DecodeManifest(mraw) + kept, err := meshcatalogclient.DecodeManifest(mraw) if err != nil { continue } // Decoded, never judged (ADR 0578 §3); one that does not decode is left out of the answer, as before. - if man, err := catalogue.Decode([]byte(kept.JSON)); err == nil { + if man, err := catalogue.Decode([]byte(kept.Manifest)); err == nil { out[name] = man } } @@ -151,21 +158,21 @@ func (s *wantsTools) doUpgrade(args map[string]any, by string) (any, error) { } composed := map[string]string{} for key, raw := range haves { - if node, _, isNode, err := delivery.ParseHaveKey(key); err == nil && isNode { - if h, err := delivery.DecodeHaveNode(raw); err == nil && h.Composed != "" { + if node, _, isNode, err := nodeengineclient.ParseHaveKey(key); err == nil && isNode { + if h, err := nodeengineclient.DecodeHaveNode(raw); err == nil && h.Composed != "" { composed[node] = h.Composed } } } out := map[string]any{"composed": composed} // And what every node published (ADR 0593 §8), read only to compare. - if values, err := s.values(delivery.ComposedRead); err != nil { + if values, err := s.values(buckets.ComposedRead); err != nil { out["published-unread"] = err.Error() } else { out["published"] = published(values) } // The assigner's findings, its said conditions as the condition keeper reads them (§15). - if found, err := s.values(health.FindingsState); err != nil { + if found, err := s.values(mcc.FindingsState); err != nil { out["findings-unread"] = err.Error() } else { keys := []string{} @@ -226,8 +233,8 @@ func (s *wantsTools) setPolicy(module, policy, why, by string, manifests map[str return nil, fmt.Errorf("the choice for %s cannot be written: %w", module, err) } } - rec := handacts.Recorder{Ask: s.ask} - taken, rerr := rec.Record(handacts.Request{What: what, Why: why, Cause: "upgrade-policy", Verb: "mesh-wants.upgrade"}) + rec := meshhandactsclient.Recorder{Ask: s.ask} + taken, rerr := rec.Record(meshhandactsclient.Request{What: what, Why: why, Cause: "upgrade-policy", Verb: "mesh-wants.upgrade"}) recorded := taken.Log if rerr != nil { recorded = "not recorded: " + rerr.Error() @@ -266,37 +273,37 @@ func (s *wantsTools) doBus(map[string]any) (any, error) { if bus == "" { return nil, errors.New("the indexer holds no module that provides the bus") } - newest := held[bus].FP2 + newest := held[bus].Fp2 haves, err := s.values(haveState) if err != nil { return nil, err } var nodes []busNode for key, raw := range haves { - node, module, isNode, err := delivery.ParseHaveKey(key) + node, module, isNode, err := nodeengineclient.ParseHaveKey(key) if err != nil || isNode || module != bus { continue } - h, err := delivery.DecodeHave(raw) - if err != nil || h.Phase == delivery.PhaseRemoved { + h, err := nodeengineclient.DecodeHave(raw) + if err != nil || h.Phase == nodeengineclient.PhaseRemoved { continue } - nodes = append(nodes, busNode{Node: node, FP: h.FP, Behind: newest != "" && h.FP != newest}) + nodes = append(nodes, busNode{Node: node, FP: h.Fp, Behind: newest != "" && h.Fp != newest}) } sort.Slice(nodes, func(i, j int) bool { return nodes[i].Node < nodes[j].Node }) out := map[string]any{"module": bus, "newest": newest, "nodes": nodes} - if steps, err := s.ask("seat:"+handacts.Seat+".hand-acts", map[string]any{"cause": busStepCause, "days": 90}); err != nil { + if steps, err := s.ask("seat:"+meshhandacts.Seat+".hand-acts", map[string]any{"cause": busStepCause, "days": 90}); err != nil { out["steps-unread"] = err.Error() } else { out["steps"] = steps } - puts, err := s.values(putsState) + kept, err := s.values(putsState) if err != nil { out["puts-unread"] = err.Error() } else { mine := map[string]json.RawMessage{} - for k, v := range puts { - if _, module, err := delivery.ParsePutKey(k); err == nil && module == bus { + for k, v := range kept { + if _, module, err := puts.ParseKey(k); err == nil && module == bus { mine[k] = v } } @@ -305,16 +312,16 @@ func (s *wantsTools) doBus(map[string]any) (any, error) { return out, nil } -// tools are the seat's verbs as the runtime serves them, each named ., with the seat's own schemas. +// tools are the seat's verbs as the runtime serves them, each named .; the tool runner describes them with the +// seat's definition from the seat list (ADR 0635 §1), so the tools carry none. func (s *wantsTools) tools() []stdio.Tool { - seat, _ := catalogue.SeatNamed(catalogue.WantsSeat) run := map[string]func(map[string]any) (any, error){ - "upgrade": func(a map[string]any) (any, error) { return s.doUpgrade(a, stdio.CallerOf(a)) }, - "bus": s.doBus, + meshwants.VerbUpgrade: func(a map[string]any) (any, error) { return s.doUpgrade(a, stdio.CallerOf(a)) }, + meshwants.VerbBus: s.doBus, } var out []stdio.Tool - for _, v := range seat.Serves { - out = append(out, stdio.Tool{Name: catalogue.WantsSeat + "." + v.Name, Description: v.Description, Input: v.Input, Run: run[v.Name]}) + for _, v := range meshwants.Verbs() { + out = append(out, stdio.Tool{Name: meshwants.Seat + "." + v, Run: run[v]}) } return out } diff --git a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/world.go b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/world.go index f37724e7..7c60c464 100644 --- a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/world.go +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/world.go @@ -3,23 +3,34 @@ package main import ( "context" "errors" + "maps" + "mesh-delivery-assigner/internal/buckets" "slices" "sort" "sync" "time" + "git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient" + "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" + wdc "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshwatchdogsclient" + "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" + mdc "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliveryclient" + mwc "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshwantsclient" + "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds" + "git.novox.be/novox/mesh-delivery/sdk/trunk" + "git.novox.be/novox/mesh-sdk/go/keys" + "github.com/nats-io/nats.go/jetstream" + mmac "git.novox.be/novox/mesh-contracts/go/clients/meshmoduleassignerclient" + mma "git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner" "git.novox.be/novox/mesh-sdk/go/catalogue" - "git.novox.be/novox/mesh-sdk/go/delivery" "mesh-delivery-assigner/internal/assign" + plc "mesh-delivery-assigner/internal/assignment" ) -// noModuleWords is the indexer's reason for a module of a candidate whose directory the merge preview holds no manifest -// for (mesh-sdk delivery.CandidateNoneNoModule, which the indexer writes): the change removes the module. -const noModuleWords = delivery.CandidateNoneNoModule - // world is what the assigner watched, as it stands now: every value by bucket and key, and the manifests read by // digest. The watches write it; a pass reads it. type world struct { @@ -33,10 +44,10 @@ type world struct { // meta is, by bucket and key, the revision and the time of the value held now, where the watch said them (putEntry). meta map[string]entryMeta // trunkBuilt is, by module, its newest done trunk build in the builder's log (putBuilt). - trunkBuilt map[string]delivery.BuildLogEntry - // placement is the seat's state placement assembled node by node from its keys in either form (mesh-sdk - // delivery.PlacementKeys; research 072 row 37): the one reader of its keys in the assigner. - placement *delivery.PlacementKeys + trunkBuilt map[string]builds.BuildLogEntry + // assignments is the seat's state assignments assembled node by node from its keys in either form (mesh-sdk + // mmac.Keys; research 072 row 37): the one reader of its keys in the assigner. + assignmentKeys *mmac.Keys // sourceGroups is, by revision of the gate's source.main, the release (group) that moved it, from every revision its // history keeps (watched with history): a build's source revision names the release that carries it (ADR 0601 §1). sourceGroups map[uint64]string @@ -57,7 +68,7 @@ type parsed struct { func newWorld() *world { return &world{values: map[string]map[string][]byte{}, manifests: map[string]parsed{}, healthy: map[string][]string{}, changes: map[string]uint64{}, - meta: map[string]entryMeta{}, trunkBuilt: map[string]delivery.BuildLogEntry{}, placement: delivery.NewPlacementKeys(), + meta: map[string]entryMeta{}, trunkBuilt: map[string]builds.BuildLogEntry{}, assignmentKeys: mmac.NewKeys(), sourceGroups: map[uint64]string{}} } @@ -71,11 +82,11 @@ func (w *world) putEntry(bucket, key string, value []byte, revision uint64, at t return } w.meta[bucket+"|"+key] = entryMeta{revision: revision, at: at} - if bucket == delivery.PlacementBucket { - _, _ = w.placement.Put(key, value, revision, false) // put took it already; this is its revision + if bucket == plc.AssignmentsBucket { + _, _ = w.assignmentKeys.Put(key, value, revision, false) // put took it already; this is its revision } - if bucket == delivery.SourceBucket && key == delivery.SourceMainKey { - if s, err := delivery.DecodeSource(value); err == nil && s.Group != "" { + if bucket == buckets.Source && key == mdc.SourceMainKey { + if s, err := mdc.DecodeSource(value); err == nil && s.Group != "" { w.sourceGroups[revision] = s.Group } } @@ -88,26 +99,26 @@ func (w *world) putEntry(bucket, key string, value []byte, revision uint64, at t // unknown: none, and the move is asked of a person. func (w *world) release(module, fp string) (string, bool) { t, ok := w.trunk()[module] - if !ok || fp == "" || t.FP2 != fp || t.FPSrc == 0 || t.Commit == "" { + if !ok || fp == "" || t.Fp2 != fp || t.FpSrc == 0 || t.Commit == "" { return "", false } w.mu.Lock() - group := w.sourceGroups[t.FPSrc] + group := w.sourceGroups[uint64(t.FpSrc)] w.mu.Unlock() - key, err := delivery.MergesKey(group) + key, err := mdc.MergesKey(group) if group == "" || err != nil { return "", false } - raw := w.value(delivery.MergesBucket, key) + raw := w.value(buckets.Merges, key) if raw == nil { return "", false } - merge, err := delivery.DecodeMerge(raw) + merge, err := mdc.DecodeMerge(raw) if err != nil { return "", false } for _, pull := range merge.Pulls { - if pull.State == delivery.MergeMerged && pull.Commit == t.Commit && delivery.SameRepository(pull.Repository, t.Repository) { + if pull.State == string(mdc.MergeMerged) && pull.Commit == t.Commit && trunk.SameRepository(pull.Repository, t.Repository) { return group, true } } @@ -131,28 +142,28 @@ func (w *world) put(bucket, key string, value []byte) { w.changes[bucket]++ // the bucket's generation (bucketGen), a delete included if value == nil { delete(w.values[bucket], key) - if bucket == delivery.PlacementBucket { - _, _ = w.placement.Put(key, nil, 0, true) + if bucket == plc.AssignmentsBucket { + _, _ = w.assignmentKeys.Put(key, nil, 0, true) } return } w.values[bucket][key] = value w.changes[bucket+"|"+key]++ - if bucket == delivery.PlacementBucket { + if bucket == plc.AssignmentsBucket { // A key or value that does not read places nothing new on its node; the indexer's read tool says it. - _, _ = w.placement.Put(key, value, 0, false) + _, _ = w.assignmentKeys.Put(key, value, 0, false) } - if bucket == delivery.HaveBucket { - if h, err := delivery.DecodeHave(value); err == nil && h.FP != "" && h.Phase == delivery.PhaseRunning && h.Health == delivery.HealthOK { - list := slices.DeleteFunc(w.healthy[key], func(fp string) bool { return fp == h.FP }) - w.healthy[key] = append(list, h.FP) + if bucket == buckets.Have { + if h, err := nodeengineclient.DecodeHave(value); err == nil && h.Fp != "" && h.Phase == nodeengineclient.PhaseRunning && h.Health == nodeengineclient.HealthOK { + list := slices.DeleteFunc(w.healthy[key], func(fp string) bool { return fp == h.Fp }) + w.healthy[key] = append(list, h.Fp) } } } // lastHealthy is the newest fingerprint other than fp the node ran the module healthy at, or empty. func (w *world) lastHealthy(node, module, fp string) string { - key, err := delivery.HaveKey(node, module) + key, err := nodeengineclient.HaveKey(node, module) if err != nil { return "" } @@ -197,6 +208,19 @@ func kvGetter(kv jetstream.KeyValue) getter { } } +// getter reads a key of a bucket the world holds: errNotThere where it holds none. +func (w *world) getter(bucket string) getter { + return func(_ context.Context, key string) ([]byte, error) { + w.mu.Lock() + defer w.mu.Unlock() + v, ok := w.values[bucket][key] + if !ok { + return nil, errNotThere + } + return v, nil + } +} + // manifest reads the manifest of a digest from the indexer's bucket once and keeps it: a digest's manifest never changes. func (w *world) manifest(ctx context.Context, get getter, digest string) parsed { w.mu.Lock() @@ -205,7 +229,7 @@ func (w *world) manifest(ctx context.Context, get getter, digest string) parsed if ok { return p } - key, err := delivery.ManifestsKey(digest) + key, err := meshcatalogclient.ManifestsKey(digest) if err != nil { return parsed{err: err.Error()} } @@ -216,10 +240,10 @@ func (w *world) manifest(ctx context.Context, get getter, digest string) parsed if err != nil { return parsed{err: err.Error()} } - m, err := delivery.DecodeManifest(value) + m, err := meshcatalogclient.DecodeManifest(value) if err != nil { p = parsed{err: err.Error()} - } else if man, err := catalogue.Decode([]byte(m.JSON)); err != nil { + } else if man, err := catalogue.Decode([]byte(m.Manifest)); err != nil { // Decoded, never judged (novox/hq ADR 0578 §3): the indexer's judge judged it, and its verdict is taken from the // module's value where it is read (world.mesh). p = parsed{module: m.Module, err: "it does not decode: " + err.Error()} @@ -232,8 +256,8 @@ func (w *world) manifest(ctx context.Context, get getter, digest string) parsed return p } -// assigned is each node's modules the seat mesh-placement's placement names, in name order (ADR 0549 §5, ADR 0567 §8): the -// placement is the one source of where modules run. The controller's assignments are read no more (issue 774). +// assigned is each node's modules the seat mesh-module-assigner's assignments names, in name order (ADR 0549 §5, ADR 0567 §8): the +// assignments is the one source of where modules run. The controller's assignments are read no more (issue 774). func (w *world) assigned() map[string][]string { placed, _ := w.placed() out := make(map[string][]string, len(placed)) @@ -243,23 +267,23 @@ func (w *world) assigned() map[string][]string { return out } -// placed is each node's modules in the seat's placement (ADR 0567 §1), and whether any placement key was read: a key +// placed is each node's modules in the seat's assignments (ADR 0567 §1), and whether any assignments key was read: a key // that does not read places nothing on its node and is said by the indexer's own read tool. func (w *world) placed() (map[string][]string, bool) { out := map[string][]string{} - for node, p := range w.placements() { - out[node] = p.Assigned() + for node, p := range w.assignments() { + out[node] = mmac.Assigned(p) } - return out, len(w.read(delivery.PlacementBucket)) > 0 + return out, len(w.read(plc.AssignmentsBucket)) > 0 } -// placements is every node's placement, assembled from its keys in either form (delivery.PlacementKeys). -func (w *world) placements() map[string]delivery.Placement { - out, _ := w.placement.AllRevisions() +// assignments is every node's assignments, assembled from its keys in either form (mmac.Keys). +func (w *world) assignments() map[string]mma.Assignments { + out, _ := w.assignmentKeys.AllRevisions() return out } -// placedNodes is every node the seat's placement names (ADR 0567 §1): those compose their own modules (ADR 0547 §2), +// placedNodes is every node the seat's assignments names (ADR 0567 §1): those compose their own modules (ADR 0547 §2), // so their wants name the build alone and the seventh rule does not hold them. func (w *world) placedNodes() map[string]bool { placed, _ := w.placed() @@ -271,65 +295,75 @@ func (w *world) placedNodes() map[string]bool { } // trunk is every module the indexer holds at the trunk, with its value. -func (w *world) trunk() map[string]delivery.Module { - out := map[string]delivery.Module{} - for key, value := range w.read(delivery.ModulesBucket) { - name, err := delivery.ParseModulesKey(key) +func (w *world) trunk() map[string]meshcatalog.ModulesByModuleValue { + out := map[string]meshcatalog.ModulesByModuleValue{} + for key, value := range w.read(buckets.Modules) { + name, err := meshcatalogclient.ParseModulesKey(key) if err != nil { continue } - if m, err := delivery.DecodeModule(value); err == nil { + if m, err := meshcatalogclient.DecodeModule(value); err == nil { out[name] = m } } return out } -// records is each node's record in its own have key (`.node`): the nodes a placement proposal is answered by, -// with those placement names (ADR 0593 §8). -func (w *world) records() map[string]delivery.NodeRecord { - out := map[string]delivery.NodeRecord{} - for key, value := range w.read(delivery.HaveBucket) { - node, _, isNode, err := delivery.ParseHaveKey(key) +// records is each node's record in its own have key (`.node`): the nodes an assignments proposal is answered by, +// with those assignments names (ADR 0593 §8). +func (w *world) records() map[string]nodeengine.NodeRecord { + out := map[string]nodeengine.NodeRecord{} + for key, value := range w.read(buckets.Have) { + node, _, isNode, err := nodeengineclient.ParseHaveKey(key) if err != nil || !isNode { continue } - if h, err := delivery.DecodeHaveNode(value); err == nil && h.Record != nil { + if h, err := nodeengineclient.DecodeHaveNode(value); err == nil && h.Record != nil { out[node] = *h.Record } } return out } +// presenceOf reads one key of the watchman's presence: a node's key (``) with the node's online or not; the keys of +// the parts (`part..`) are not a node's and are none here. +func presenceOf(key string, value []byte) (node string, online, ok bool) { + node, part, _, err := wdc.ParsePresenceKey(key) + if err != nil || node == "" || part != "" { + return "", false, false + } + p, err := wdc.DecodeNodePresence(value) + if err != nil { + return "", false, false + } + return node, p.Online, true +} + // online is, by node, whether the watchman holds the node online. func (w *world) online() map[string]bool { out := map[string]bool{} - for key, value := range w.read(delivery.PresenceBucket) { - node, _, _, err := delivery.ParsePresenceKey(key) - if err != nil || node == "" { - continue - } - if p, err := delivery.DecodePresence(value); err == nil { - out[node] = p.Online + for key, value := range w.read(buckets.Presence) { + if node, online, ok := presenceOf(key, value); ok { + out[node] = online } } return out } // wantsOf is the wants the assigner wrote, by module then node. -func (w *world) wantsOf() map[string]map[string]delivery.Want { - out := map[string]map[string]delivery.Want{} - for key, value := range w.read(delivery.AssignerWantsBucket) { - node, module, err := delivery.ParseWantKey(key) +func (w *world) wantsOf() map[string]map[string]mwc.Want { + out := map[string]map[string]mwc.Want{} + for key, value := range w.read(buckets.Wants) { + node, module, err := mwc.ParseWantKey(key) if err != nil { continue } - want, err := delivery.DecodeWant(value) + want, err := mwc.DecodeWant(value) if err != nil { continue } if out[module] == nil { - out[module] = map[string]delivery.Want{} + out[module] = map[string]mwc.Want{} } out[module][node] = want } @@ -338,32 +372,28 @@ func (w *world) wantsOf() map[string]map[string]delivery.Want { // nodes is what the six rules read of the nodes: presence, haves, and every provision's providers with their nodes. func (w *world) nodes(manifests map[string]catalogue.Manifest, assigned map[string][]string) assign.Nodes { - n := assign.Nodes{Online: map[string]bool{}, Haves: map[string]map[string]delivery.Have{}, Providers: map[string]map[string][]string{}, + n := assign.Nodes{Online: map[string]bool{}, Haves: map[string]map[string]nodeengine.HaveByNodeModuleValue{}, Providers: map[string]map[string][]string{}, Reads: map[string][]string{}} - for key, value := range w.read(delivery.PresenceBucket) { - node, _, _, err := delivery.ParsePresenceKey(key) - if err != nil || node == "" { - continue - } - if p, err := delivery.DecodePresence(value); err == nil { - n.Online[node] = p.Online + for key, value := range w.read(buckets.Presence) { + if node, online, ok := presenceOf(key, value); ok { + n.Online[node] = online } } - for key, value := range w.read(delivery.HaveBucket) { - node, module, isNode, err := delivery.ParseHaveKey(key) + for key, value := range w.read(buckets.Have) { + node, module, isNode, err := nodeengineclient.ParseHaveKey(key) if err != nil { continue } if isNode { // What the node's engine reads (ADR 0614 §4); a record that states none is read as the baseline. - if h, err := delivery.DecodeHaveNode(value); err == nil && h.Record != nil { + if h, err := nodeengineclient.DecodeHaveNode(value); err == nil && h.Record != nil { n.Reads[node] = h.Record.ManifestReads } continue } - if h, err := delivery.DecodeHave(value); err == nil { + if h, err := nodeengineclient.DecodeHave(value); err == nil { if n.Haves[node] == nil { - n.Haves[node] = map[string]delivery.Have{} + n.Haves[node] = map[string]nodeengine.HaveByNodeModuleValue{} } n.Haves[node][module] = h } @@ -394,7 +424,7 @@ func (w *world) value(bucket, key string) []byte { // grantsBucket is the bucket the grants are read from: the grants part's state `grants`, always (ADR 0571 clause 9); no // manifest but the controller's reads the controller's grants. -func (w *world) grantsBucket() string { return delivery.PartGrantsBucket } +func (w *world) grantsBucket() string { return buckets.Grants } // has says whether a bucket holds a key now. func (w *world) has(bucket, key string) bool { @@ -406,39 +436,75 @@ func (w *world) has(bucket, key string) bool { // built says whether the builder's build of a module at a fingerprint is done. func (w *world) built(module, fp string) bool { - key, err := delivery.BuilderBuildKey(module, fp) + key, err := builds.BuilderBuildKey(module, fp) if err != nil { return false } w.mu.Lock() - value, ok := w.values[delivery.BuilderBuildsBucket][key] + value, ok := w.values[buckets.Builds][key] w.mu.Unlock() if !ok { return false } - b, err := delivery.DecodeBuilderBuild(value) - return err == nil && b.State == delivery.BuildDone + b, err := builds.DecodeBuilderBuild(value) + return err == nil && b.State == builds.BuildDone +} + +// buildNeeds are the reference forms the builder's done build of module at fp is written in (the build key's needs, ADR +// 0618 §1): nil when it is not done or names none. +func (w *world) buildNeeds(module, fp string) []string { + key, err := builds.BuilderBuildKey(module, fp) + if err != nil { + return nil + } + b, err := builds.DecodeBuilderBuild(w.value(buckets.Builds, key)) + if err != nil || b.State != builds.BuildDone { + return nil + } + return b.Needs +} + +// recordReads is what node's record `.node` states its engine reads (ADR 0614 §4), and whether the assigner holds +// a record of it: the fallback where the engine does not answer the verb manifest-reads (enginereads.FromRecord). +func (w *world) recordReads(node string) ([]string, bool) { + r, ok := w.records()[node] + return r.ManifestReads, ok +} + +// knownNodes is every node the assigner hears of: placed, with a record, or with a presence. +func (w *world) knownNodes() []string { + set := map[string]bool{} + for node := range w.placedNodes() { + set[node] = true + } + for node := range w.records() { + set[node] = true + } + for node := range w.online() { + set[node] = true + } + return slices.Sorted(maps.Keys(set)) } // composed is the composition of module for node: a placed node composes its own, and its composition is the digest its own // have names (ADR 0571 §6); an unplaced node has none, so it gets no want (and the pass says so, unplacedFindings). The // controller's copy (mesh-controller_composed) is read no more (issue 774). -func (w *world) composed(node, module, fp string) (delivery.ComposedDigest, bool) { +func (w *world) composed(node, module, fp string) (assign.ComposedDigest, bool) { if !w.placedNodes()[node] { - return delivery.ComposedDigest{}, false + return assign.ComposedDigest{}, false } return w.ownComposed(node) } // answer is the teller's answer to the ask at key (mesh-delivery-teller_answers, the same key), and whether there is one. -func (w *world) answer(key string) (delivery.Answer, bool) { +func (w *world) answer(key string) (mdc.Answer, bool) { w.mu.Lock() - value, ok := w.values[delivery.AnswersBucket][key] + value, ok := w.values[buckets.Answers][key] w.mu.Unlock() if !ok { - return delivery.Answer{}, false + return mdc.Answer{}, false } - a, err := delivery.DecodeAnswer(value) + a, err := mdc.DecodeAnswer(value) return a, err == nil } @@ -459,30 +525,30 @@ func (w *world) changed(bucket, key string) uint64 { // ownComposed is a placed node's own composition, as the digest its own have names (`.node` of the haves, Composed): // what rule 7 reads. A node whose have names none is not composed yet. -func (w *world) ownComposed(node string) (delivery.ComposedDigest, bool) { - key, err := delivery.HaveNodeKey(node) +func (w *world) ownComposed(node string) (assign.ComposedDigest, bool) { + key, err := nodeengineclient.HaveNodeKey(node) if err != nil { - return delivery.ComposedDigest{}, false + return assign.ComposedDigest{}, false } - raw, ok := w.read(delivery.HaveBucket)[key] + raw, ok := w.read(buckets.Have)[key] if !ok { - return delivery.ComposedDigest{}, false + return assign.ComposedDigest{}, false } - h, err := delivery.DecodeHaveNode(raw) + h, err := nodeengineclient.DecodeHaveNode(raw) if err != nil || h.Composed == "" { - return delivery.ComposedDigest{}, false + return assign.ComposedDigest{}, false } - return delivery.ComposedDigest{V: delivery.Version, Digest: h.Composed, Judgement: delivery.JudgedNeither, + return assign.ComposedDigest{V: keys.Version, Digest: h.Composed, Judgement: assign.JudgedNeither, Words: "the node composes its own"}, true } -// unplacedNodes are the nodes whose engine has a have and that no placement names: they get no +// unplacedNodes are the nodes whose engine has a have and that no assignments names: they get no // want, since nothing composes them. func (w *world) unplacedNodes() []string { placed := w.placedNodes() var out []string - for key := range w.read(delivery.HaveBucket) { - node, _, isNode, err := delivery.ParseHaveKey(key) + for key := range w.read(buckets.Have) { + node, _, isNode, err := nodeengineclient.ParseHaveKey(key) if err == nil && isNode && !placed[node] { out = append(out, node) } diff --git a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/world_test.go b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/world_test.go index 51adf59b..73a1d5cf 100644 --- a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/world_test.go +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/world_test.go @@ -2,12 +2,14 @@ package main import ( "context" - "slices" + "mesh-delivery-assigner/internal/buckets" "strings" "testing" "time" - "git.novox.be/novox/mesh-sdk/go/delivery" + "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" + "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" + "git.novox.be/novox/mesh-delivery/modules/mesh-grants/grantsstate" ) // fakeManifests is the indexer's manifests bucket in memory. @@ -15,8 +17,8 @@ type fakeManifests map[string][]byte func (f fakeManifests) add(t *testing.T, module, body string) string { t.Helper() - digest := delivery.ManifestDigest([]byte(body)) - value, err := delivery.EncodeManifest(delivery.Manifest{Module: module, JSON: body}) + digest := meshcatalogclient.ManifestDigest([]byte(body)) + value, err := meshcatalogclient.EncodeManifest(meshcatalog.ManifestsByDigestValue{Module: module, Manifest: body}) if err != nil { t.Fatal(err) } @@ -31,33 +33,24 @@ func (f fakeManifests) get(_ context.Context, key string) ([]byte, error) { return nil, errNotThere } -func fp(c byte) string { return delivery.SourcePrefix2 + strings.Repeat(string(c), 64) } - -func pull(repo string, number int, modules map[string]delivery.CandidateModule) delivery.CandidatePull { - p := delivery.CandidatePull{Head: delivery.Head{Repository: repo, Number: number, Head: strings.Repeat("3", 40)}, Fps: modules} - for m := range modules { - p.Modules = append(p.Modules, m) - } - slices.Sort(p.Modules) - return p -} +func fp(c byte) string { return meshcatalogclient.SourcePrefix2 + strings.Repeat(string(c), 64) } // Review round 1 (items 3 and 4): the world counts the values a key takes, so a have written again after an answer is seen. func theWorldCountsTheValuesAKeyTakes(t *testing.T) { w := newWorld() - w.put(delivery.HaveBucket, "ace.web", []byte(`{"v":1}`)) - w.put(delivery.HaveBucket, "ace.web", []byte(`{"v":1}`)) - w.put(delivery.HaveBucket, "ace.mail", []byte(`{"v":1}`)) - if got := w.changed(delivery.HaveBucket, "ace.web"); got != 2 { + w.put(buckets.Have, "ace.web", []byte(`{"v":1}`)) + w.put(buckets.Have, "ace.web", []byte(`{"v":1}`)) + w.put(buckets.Have, "ace.mail", []byte(`{"v":1}`)) + if got := w.changed(buckets.Have, "ace.web"); got != 2 { t.Errorf("changed = %d, want 2", got) } - if got := w.changed(delivery.HaveBucket, "gone"); got != 0 { + if got := w.changed(buckets.Have, "gone"); got != 0 { t.Errorf("changed = %d for a key never seen", got) } // ADR 0571 clause 9: grants are read from the grants part's bucket, the one bucket of grants (ADR 0590). - fp := delivery.SourcePrefix2 + strings.Repeat("d", 64) - gkey, _ := delivery.PartGrantKey("web", fp) - grant, _ := delivery.EncodeGrant(delivery.Grant{Accounts: []string{"ace.web"}}) + fp := meshcatalogclient.SourcePrefix2 + strings.Repeat("d", 64) + gkey, _ := grantsstate.PartGrantKey("web", fp) + grant, _ := grantsstate.EncodeGrant(grantsstate.Grant{Accounts: []string{"ace.web"}}) for _, row := range []struct { name string inPart bool @@ -66,11 +59,11 @@ func theWorldCountsTheValuesAKeyTakes(t *testing.T) { {"the part's grant", true, true}, {"no grant", false, false}, } { - p := newPasser(buckets{}, time.Now()) + p := newPasser(kvSet{}, time.Now()) if row.inPart { - p.w.put(delivery.PartGrantsBucket, gkey, grant) + p.w.put(buckets.Grants, gkey, grant) } - if got := p.w.grantsBucket(); got != delivery.PartGrantsBucket { + if got := p.w.grantsBucket(); got != buckets.Grants { t.Errorf("%s: grants are read from %s, want the part's", row.name, got) } if _, granted := p.composedFor("ace", "web", fp); granted != row.granted { diff --git a/modules/mesh-delivery-assigner/go.mod b/modules/mesh-delivery-assigner/go.mod index 777c0c97..bd6bd387 100644 --- a/modules/mesh-delivery-assigner/go.mod +++ b/modules/mesh-delivery-assigner/go.mod @@ -3,24 +3,95 @@ module mesh-delivery-assigner go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103221822-85ba0a9a70d8 + git.novox.be/novox/mesh-contracts/go/clients/meshmoduleassignerclient v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshhandactsclient v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshhandacts v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-contracts/provisioning/go/clients/meshprovisionerclient v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-delivery/contracts/go/seats/meshwants v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000 github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats.go v1.54.0 ) require ( - github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op // indirect + github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op // indirect github.com/google/go-tpm v0.9.8 // indirect - github.com/klauspost/compress v1.20.0 // indirect + github.com/klauspost/compress v1.20.1 // indirect github.com/minio/highwayhash v1.0.4 // indirect - github.com/nats-io/jwt/v2 v2.8.1 // indirect + github.com/nats-io/jwt/v2 v2.8.2 // indirect github.com/nats-io/nkeys v0.4.16 // indirect github.com/nats-io/nuid v1.0.1 // indirect golang.org/x/crypto v0.57.0 // indirect golang.org/x/sys v0.48.0 // indirect - golang.org/x/time v0.15.0 // indirect + golang.org/x/time v0.16.0 // indirect ) require git.novox.be/novox/mesh-delivery/sdk v0.0.0-00010101000000-000000000000 +require ( + git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-contracts/go/seats/nodeengine v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel v0.0.0-00010101000000-000000000000 +) + replace git.novox.be/novox/mesh-delivery/sdk => ../../sdk + +replace git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds => ../mesh-delivery-builder/builds + +replace git.novox.be/novox/mesh-delivery/modules/mesh-grants/grantsstate => ../mesh-grants/grantsstate + +require git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient v0.0.0-00010101000000-000000000000 + +require git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions v0.0.0-00010101000000-000000000000 + +require git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshwatchdogs v0.0.0-00010101000000-000000000000 + +require git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshwatchdogsclient v0.0.0-00010101000000-000000000000 + +require git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient v0.0.0-00010101000000-000000000000 + +replace git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient => ../../contracts/go/clients/meshcatalogclient + +require git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliveryclient v0.0.0-00010101000000-000000000000 + +replace git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliveryclient => ../../contracts/go/clients/meshdeliveryclient + +require git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog v0.0.0-00010101000000-000000000000 + +replace git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog => ../../contracts/go/seats/meshcatalog + +require git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdelivery v0.0.0-00010101000000-000000000000 + +replace git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdelivery => ../../contracts/go/seats/meshdelivery + +require git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds v0.0.0-00010101000000-000000000000 + +require git.novox.be/novox/mesh-delivery/modules/mesh-grants/grantsstate v0.0.0-00010101000000-000000000000 + +require git.novox.be/novox/mesh-contracts/identity-and-access/go/seats/meshnodeenroller v0.0.0-00010101000000-000000000000 + +require git.novox.be/novox/mesh-delivery/modules/mesh-delivery-assigner/asks v0.0.0-00010101000000-000000000000 + +replace git.novox.be/novox/mesh-delivery/modules/mesh-delivery-assigner/asks => ./asks + +require git.novox.be/novox/mesh-delivery/modules/mesh-delivery-view/viewstate v0.0.0-00010101000000-000000000000 + +replace git.novox.be/novox/mesh-delivery/modules/mesh-delivery-view/viewstate => ../mesh-delivery-view/viewstate + +require git.novox.be/novox/mesh-delivery/contracts/go/clients/meshwantsclient v0.0.0-00010101000000-000000000000 + +require git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliverycheckerclient v0.0.0-00010101000000-000000000000 + +replace git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliverycheckerclient => ../../contracts/go/clients/meshdeliverycheckerclient + +require git.novox.be/novox/mesh-networking/modules/mesh-wireguard/go v0.0.0-00010101000000-000000000000 + +require ( + git.novox.be/novox/mesh-delivery/contracts/go/clients/containerimageregistryclient v0.0.0-00010101000000-000000000000 // indirect + git.novox.be/novox/mesh-delivery/contracts/go/seats/containerimageregistry v0.0.0-00010101000000-000000000000 // indirect + git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdeliverychecker v0.0.0-00010101000000-000000000000 // indirect + git.novox.be/novox/mesh-delivery/contracts/go/seats/ociartifactregistry v0.0.0-00010101000000-000000000000 // indirect + git.novox.be/novox/mesh-networking/modules/mesh-connectivity-contracts/go/seats/theprivatenetwork v0.0.0-00010101000000-000000000000 // indirect +) diff --git a/modules/mesh-delivery-assigner/go.sum b/modules/mesh-delivery-assigner/go.sum index 7679a5dc..8ca93dfd 100644 --- a/modules/mesh-delivery-assigner/go.sum +++ b/modules/mesh-delivery-assigner/go.sum @@ -1,15 +1,13 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103221822-85ba0a9a70d8 h1:XEbIVImp6yGjA91rp0iZBoDdL5KfamjOuDLwWvqh0hc= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103221822-85ba0a9a70d8/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= -github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= -github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= +github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op h1:1BOWQJweNyvZMlpAHXGLiZQn9S+QXGcz3xh94lC0w6E= +github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= -github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA= -github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= +github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ= +github.com/klauspost/compress v1.20.1/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= github.com/minio/highwayhash v1.0.4 h1:asJizugGgchQod2ja9NJlGOWq4s7KsAWr5XUc9Clgl4= github.com/minio/highwayhash v1.0.4/go.mod h1:GGYsuwP/fPD6Y9hMiXuapVvlIUEhFhMTh0rxU3ik1LQ= -github.com/nats-io/jwt/v2 v2.8.1 h1:V0xpGuD/N8Mi+fQNDynXohVvp7ZztevW5io8CUWlPmU= -github.com/nats-io/jwt/v2 v2.8.1/go.mod h1:nWnOEEiVMiKHQpnAy4eXlizVEtSfzacZ1Q43LIRavZg= +github.com/nats-io/jwt/v2 v2.8.2 h1:XXRgB60MSTnqsRwejQurVDs/hcv2dkt+86GjI+I/bMc= +github.com/nats-io/jwt/v2 v2.8.2/go.mod h1:Ag/56sq9OblL4JgdYufDd16Egb17Kr/8WwwuO/forVc= github.com/nats-io/nats-server/v2 v2.11.17 h1:GKEghcFK6A+aFx11Yf1LjgLC3txAwvyhnYzhBIQZA8I= github.com/nats-io/nats-server/v2 v2.11.17/go.mod h1:B1sFVz4StNosQ903ak4N1G01Fl/9f8e06mXpFIE2K24= github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA= @@ -23,5 +21,5 @@ golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZ golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= -golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= -golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= +golang.org/x/time v0.16.0 h1:vMb6ptszcQMkcwiRTAuNNU50gom6++Q/6gY2hDM6VDE= +golang.org/x/time v0.16.0/go.mod h1:rVKOqvZeKvrDKTQiAHJ7wmwP0RzleSphoEA9RcdLA0s= diff --git a/modules/mesh-delivery-assigner/internal/assign/askjson.go b/modules/mesh-delivery-assigner/internal/assign/askjson.go new file mode 100644 index 00000000..cbe97275 --- /dev/null +++ b/modules/mesh-delivery-assigner/internal/assign/askjson.go @@ -0,0 +1,69 @@ +package assign + +import ( + "encoding/json" + "fmt" + "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliveryclient" + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-assigner/asks" + "time" + + 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" +) + +// assignerAsk is the asking-th ask a, as the asks state holds it: the router's ask as JSON (asks.Ask.Ask). +func assignerAsk(asking int, a oc.Ask) asks.Ask { + raw, err := json.Marshal(a) + if err != nil { // an oc.Ask holds only strings, bools and a slice of them: it cannot fail + panic(fmt.Sprintf("assign: the ask %q does not marshal: %v", a.ID, err)) + } + return asks.Ask{Asking: asking, Ask: raw} +} + +// AskOf is the router's ask an asks-state value carries. +func AskOf(a asks.Ask) (oc.Ask, error) { + var ask oc.Ask + if err := json.Unmarshal(a.Ask, &ask); err != nil { + return oc.Ask{}, fmt.Errorf("assign: the value carries no router's ask: %w", err) + } + return ask, nil +} + +// EncodeAsk is asks.EncodeAsk at key, after the checks every keeper of an ask owes (occ.Carried, ADR 0627 §4): +// the ask's id is meshdeliveryclient.AskingID(key, asking), and it offers at least one option. +func EncodeAsk(key string, a asks.Ask) ([]byte, error) { + if _, err := checkedAsk(key, a); err != nil { + return nil, err + } + return asks.EncodeAsk(key, a) +} + +func checkedAsk(key string, a asks.Ask) (oc.Ask, error) { + ask, err := occ.Carried(a.Ask, meshdeliveryclient.AskingID(key, a.Asking)) + if err != nil { + return oc.Ask{}, fmt.Errorf("assign: %w", err) + } + return ask, nil +} + +// DecodeAsk reads the ask at key (asks.DecodeAsk) with the router's ask it carries. +func DecodeAsk(key string, data []byte) (asks.Ask, oc.Ask, error) { + a, err := asks.DecodeAsk(key, data) + if err != nil { + return asks.Ask{}, oc.Ask{}, err + } + ask, err := checkedAsk(key, a) + if err != nil { + return asks.Ask{}, oc.Ask{}, err + } + return a, ask, nil +} + +// ExpiresAt is when the ask ends unanswered; the zero time for an ask that names none. +func ExpiresAt(a oc.Ask) time.Time { + t, err := occ.Time(a.Expires) + if err != nil { + return time.Time{} + } + return t +} diff --git a/modules/mesh-delivery-assigner/internal/assign/asks.go b/modules/mesh-delivery-assigner/internal/assign/asks.go index 70651e77..2587a5ae 100644 --- a/modules/mesh-delivery-assigner/internal/assign/asks.go +++ b/modules/mesh-delivery-assigner/internal/assign/asks.go @@ -2,34 +2,40 @@ package assign import ( "fmt" + "mesh-delivery-assigner/internal/assignment" "regexp" "strings" "time" - "git.novox.be/novox/mesh-sdk/go/asks" - "git.novox.be/novox/mesh-sdk/go/delivery" + "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" + "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliveryclient" + mwc "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshwantsclient" + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-assigner/asks" + + 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" ) -// HoldAsk is the ask about a module's fingerprint the assigner holds, at its key (delivery.HoldAskKey): keep it held or +// HoldAsk is the ask about a module's fingerprint the assigner holds, at its key (asks.HoldAskKey): keep it held or // release it. Releasing writes the held fingerprint's want again by the rules (ADR 0539 §19). node and words are the held // record's: the machine whose health check failed and the hold's words (issue 1906). asking numbers the writing: 0 the first, a // larger number an ask written again after a hold lifted by itself, so the router takes it as new. -func HoldAsk(key, module, fp, node, words string, asking int, now time.Time) delivery.AssignerAsk { - return delivery.AssignerAsk{Asking: asking, Ask: asks.Ask{ - ID: delivery.AskingID(key, asking), +func HoldAsk(key, module, fp, node, words string, asking int, now time.Time) asks.Ask { + return assignerAsk(asking, oc.Ask{ + ID: meshdeliveryclient.AskingID(key, asking), Headline: fmt.Sprintf("%s is held at %s", module, short(fp)), Explanation: fmt.Sprintf("The health check of %s failed on %s at %s (%s), so the assigner holds that version and wants "+ "no node to move to it. It lifts the hold by itself once every node that runs %s runs it healthy; releasing it "+ "lets the wants move to it now.", module, node, short(fp), words, module), - Options: []asks.Option{ - {ID: "keep", Label: "Keep it held", Does: "the version stays held", Level: asks.Acknowledge}, - {ID: "release", Label: "Release it", Does: "the version is released and the wants move to it by the rules again", Level: asks.Acknowledge}, + Options: []oc.Option{ + {ID: "keep", Label: "Keep it held", Does: "the version stays held", Level: occ.Acknowledge}, + {ID: "release", Label: "Release it", Does: "the version is released and the wants move to it by the rules again", Level: occ.Acknowledge}, }, - Who: asks.Operator, - Expires: now.Add(7 * 24 * time.Hour), + Who: occ.Operator, + Expires: occ.FormatTime(now.Add(7 * 24 * time.Hour)), OnExpiry: "the version stays held", About: key, - }} + }) } // The options of the restore ask (ADR 0537 §3, ADR 0539 §19), and what each does on the node. @@ -41,42 +47,42 @@ const ( // RestoreAsk is the ask about a module holding data whose fingerprint failed its health check on a node (ADR 0429 §6): it is // not set back by itself, since a set-back would lose the writes made since. words is the check's words. Restore sets the // node back to the last healthy fingerprint and loses the newer writes; leaving it stopped keeps the data as it is. -func RestoreAsk(key, node, module, fp, words string, now time.Time) delivery.AssignerAsk { - return delivery.AssignerAsk{Ask: asks.Ask{ - ID: delivery.AskingID(key, 0), +func RestoreAsk(key, node, module, fp, words string, now time.Time) asks.Ask { + return assignerAsk(0, oc.Ask{ + ID: meshdeliveryclient.AskingID(key, 0), Headline: fmt.Sprintf("%s failed its health check on %s", module, node), Explanation: fmt.Sprintf("%s holds data, and its health check failed on %s at %s: %s. Setting it back to the version that ran healthy "+ "would lose the writes made since; leaving it stopped keeps the data as it is.", module, node, short(fp), words), - Options: []asks.Option{ - {ID: OptionRestore, Label: "Restore it", Does: "the node is set back to the last healthy version and the newer writes are lost", Level: asks.Acknowledge}, - {ID: OptionLeaveStopped, Label: "Leave it stopped", Does: "the module stays as it is on the node and the data is kept", Level: asks.Acknowledge}, + Options: []oc.Option{ + {ID: OptionRestore, Label: "Restore it", Does: "the node is set back to the last healthy version and the newer writes are lost", Level: occ.Acknowledge}, + {ID: OptionLeaveStopped, Label: "Leave it stopped", Does: "the module stays as it is on the node and the data is kept", Level: occ.Acknowledge}, }, - Who: asks.Operator, - Expires: now.Add(7 * 24 * time.Hour), + Who: occ.Operator, + Expires: occ.FormatTime(now.Add(7 * 24 * time.Hour)), OnExpiry: "the module stays as it is on the node", About: key, - }} + }) } // HeldCondition is the condition the assigner raises when it holds a fingerprint of a module (ADR 0429 §6, ADR 0539 §19), or // clears (raised false) when the hold is released. It names the module, the node whose health check failed and the check's // words, under the assigner's own name (ADR 0437 clause 9), keyed by the held fingerprint. needsYou is true for a module holding // data, whose restore ask waits for the operator. -func HeldCondition(module, fp, node, words string, raised, needsYou bool) asks.Condition { - key := delivery.AssignerModule + ".held." + module + "." + strings.TrimPrefix(fp, delivery.SourcePrefix2) - subject := asks.Scope{Scope: "module", ID: module, Machine: node} +func HeldCondition(module, fp, node, words string, raised, needsYou bool) oc.Condition { + key := assignment.Module + ".held." + module + "." + strings.TrimPrefix(fp, meshcatalogclient.SourcePrefix2) + subject := oc.ConditionSubject{Scope: "module", ID: module, Machine: node} if !raised { - return asks.Condition{Key: key, Subject: subject, State: asks.StateCleared} + return oc.Condition{Key: key, Subject: subject, State: occ.StateCleared} } - c := asks.Condition{Key: key, Severity: asks.SeverityWarning, Subject: subject, State: asks.StateRaised, + c := oc.Condition{Key: key, Severity: occ.SeverityWarning, Subject: subject, State: occ.StateRaised, Summary: fmt.Sprintf("%s is held at %s", module, short(fp)), Headline: fmt.Sprintf("%s is held at %s", module, short(fp))} what := fmt.Sprintf("The health check of %s failed on %s at %s: %s. The assigner holds that version and wants no node to move to it.", module, node, short(fp), words) if needsYou { c.Needs = "answer whether to restore " + module + " on " + node - c.Explanation = asks.NeedsYou + c.Needs + ". " + what + c.Explanation = occ.NeedsYou + c.Needs + ". " + what } else { - c.Explanation = asks.NothingToDo + " " + what + " Nodes that wanted it are set back to the version that ran healthy." + c.Explanation = occ.NothingToDo + " " + what + " Nodes that wanted it are set back to the version that ran healthy." } return c } @@ -85,22 +91,22 @@ func HeldCondition(module, fp, node, words string, raised, needsYou bool) asks.C // node while other nodes run it healthy (ADR 0557 §1, issue 1496): the failure is the node's, so nothing is held or set back and // the node keeps its want. It names the node, the module, fp and the have's words, keyed by node and module, and is cleared // (raised false) once the node no longer fails alone. -func NodeFailedCondition(node, module, fp string, f NodeFailure, raised bool) asks.Condition { - key := delivery.AssignerModule + ".failed-alone." + node + "." + module - subject := asks.Scope{Scope: "module", ID: module, Machine: node} +func NodeFailedCondition(node, module, fp string, f NodeFailure, raised bool) oc.Condition { + key := assignment.Module + ".failed-alone." + node + "." + module + subject := oc.ConditionSubject{Scope: "module", ID: module, Machine: node} if !raised { - return asks.Condition{Key: key, Subject: subject, State: asks.StateCleared, + return oc.Condition{Key: key, Subject: subject, State: occ.StateCleared, Resolved: fmt.Sprintf("%s on %s no longer fails its health check alone.", module, node)} } headline := fmt.Sprintf("%s fails on %s alone", module, node) - if n := len([]rune(headline)); n > asks.HeadlineLength { - headline = string([]rune(headline)[:asks.HeadlineLength]) + if n := len([]rune(headline)); n > occ.HeadlineLength { + headline = string([]rune(headline)[:occ.HeadlineLength]) } - return asks.Condition{Key: key, Severity: asks.SeverityWarning, Subject: subject, State: asks.StateRaised, + return oc.Condition{Key: key, Severity: occ.SeverityWarning, Subject: subject, State: occ.StateRaised, Summary: headline, Headline: headline, Explanation: fmt.Sprintf("%s The health check of %s at %s failed on %s (%s), while it runs healthy on %s. The failure is %s's, "+ "so the assigner holds nothing and sets no node back, and %s keeps its want. This clears once %s runs it healthy or "+ - "another version is the newest.", asks.NothingToDo, module, short(fp), node, f.Words, strings.Join(f.Others, ", "), node, + "another version is the newest.", occ.NothingToDo, module, short(fp), node, f.Words, strings.Join(f.Others, ", "), node, node, node)} } @@ -108,28 +114,28 @@ func NodeFailedCondition(node, module, fp string, f NodeFailure, raised bool) as // gives up every module with a fingerprint of the new form, so nothing applies it there and its files are gone. It is raised (urgent) // naming the module, the node and the rule holding its first want back with the rule's words, under the assigner's own name keyed by // node and module, and cleared (raised false) when the node has a want for it again. -func NeitherCondition(node, module string, h Holding, raised bool) asks.Condition { - key := delivery.AssignerModule + ".neither." + node + "." + module - subject := asks.Scope{Scope: "module", ID: module, Machine: node} +func NeitherCondition(node, module string, h Holding, raised bool) oc.Condition { + key := assignment.Module + ".neither." + node + "." + module + subject := oc.ConditionSubject{Scope: "module", ID: module, Machine: node} if !raised { - return asks.Condition{Key: key, Subject: subject, State: asks.StateCleared, + return oc.Condition{Key: key, Subject: subject, State: occ.StateCleared, Resolved: fmt.Sprintf("%s on %s has a want again, so its own composition is applied there.", module, node)} } headline := fmt.Sprintf("%s on %s is on no list", module, node) - if n := len([]rune(headline)); n > asks.HeadlineLength { - headline = string([]rune(headline)[:asks.HeadlineLength]) + if n := len([]rune(headline)); n > occ.HeadlineLength { + headline = string([]rune(headline)[:occ.HeadlineLength]) } - return asks.Condition{Key: key, Severity: asks.SeverityUrgent, Subject: subject, State: asks.StateRaised, + return oc.Condition{Key: key, Severity: occ.SeverityUrgent, Subject: subject, State: occ.StateRaised, Summary: headline, Headline: headline, Explanation: fmt.Sprintf("%s %s is assigned to %s, but the node holds no want for it and the node's own composition does not "+ "hold it either, so nothing applies it there. The assigner's rule %d holds its first want back: %s. The assigner writes the want once the "+ - "rule holds, and clears this.", asks.NothingToDo, module, node, h.Rule, h.Words)} + "rule holds, and clears this.", occ.NothingToDo, module, node, h.Rule, h.Words)} } // NeitherLeftCondition clears the neither condition of module on node when it is no longer on neither list: why is "" when the // node has a live want for it again, else why it has none (it is held, its move waits for an ask, the node no longer runs it), so // the clear never says a want the node does not hold (issue 1945: "nats on novox has a want again" while it had none). -func NeitherLeftCondition(node, module, why string) asks.Condition { +func NeitherLeftCondition(node, module, why string) oc.Condition { c := NeitherCondition(node, module, Holding{}, false) if why != "" { c.Resolved = fmt.Sprintf("%s on %s is no longer on neither list and has no want: %s.", module, node, why) @@ -138,7 +144,7 @@ func NeitherLeftCondition(node, module, why string) asks.Condition { } // LiftedCondition clears the condition of a held fingerprint the assigner lifted by itself, saying why (issue 1906). -func LiftedCondition(module, fp string) asks.Condition { +func LiftedCondition(module, fp string) oc.Condition { c := HeldCondition(module, fp, "", "", false, false) c.Resolved = LiftedWords(module, fp) + "." return c @@ -148,37 +154,37 @@ func LiftedCondition(module, fp string) asks.Condition { const ( OptionYes = "yes" OptionNo = "no" - OptionBackupAgain = delivery.BackupAgain - OptionBackupSkip = delivery.BackupSkip + OptionBackupAgain = mwc.BackupAgain + OptionBackupSkip = mwc.BackupSkip ) // RecordAsk is the ask about a move of a module of policy record on node to the composition with the digest, judged by the // controller to recreate a container, to move data, or not to be judgeable (ADR 0539 §14). The want names the composition only // after the answer yes. The way the move was judged is in the ask. -func RecordAsk(key, node, module, fp string, need RecordNeed, now time.Time) delivery.AssignerAsk { - what := map[delivery.Judgement]string{ - delivery.JudgedRecreates: "recreates a container", - delivery.JudgedMovesData: "moves the module's data", - delivery.JudgedCannot: "cannot be judged: " + need.Words, +func RecordAsk(key, node, module, fp string, need RecordNeed, now time.Time) asks.Ask { + what := map[Judgement]string{ + JudgedRecreates: "recreates a container", + JudgedMovesData: "moves the module's data", + JudgedCannot: "cannot be judged: " + need.Words, }[need.Judgement] - return delivery.AssignerAsk{Ask: asks.Ask{ - ID: delivery.AskingID(key, 0), + return assignerAsk(0, oc.Ask{ + ID: meshdeliveryclient.AskingID(key, 0), Headline: fmt.Sprintf("%s waits for your yes on %s", module, node), Explanation: fmt.Sprintf("%s is recorded: a person takes each of its moves. The next one on %s (version %s) %s. "+ "The node is told to apply it only after you say yes.", module, node, short(fp), what), - Options: []asks.Option{ - {ID: OptionYes, Label: "Yes, move it", Does: "the node is told to apply the new composition of " + module, Level: asks.Acknowledge}, - {ID: OptionNo, Label: "No, not now", Does: "the node keeps what it runs", Level: asks.Acknowledge}, + Options: []oc.Option{ + {ID: OptionYes, Label: "Yes, move it", Does: "the node is told to apply the new composition of " + module, Level: occ.Acknowledge}, + {ID: OptionNo, Label: "No, not now", Does: "the node keeps what it runs", Level: occ.Acknowledge}, }, - Who: asks.Operator, - Expires: now.Add(7 * 24 * time.Hour), + Who: occ.Operator, + Expires: occ.FormatTime(now.Add(7 * 24 * time.Hour)), OnExpiry: "the node keeps what it runs", About: key, - }} + }) } // NextBackupAsking is the number of the asking that follows the one numbered after (a negative number: none yet). Numbers 0 and 1 -// give one id (delivery.AskingID), so the first asking is 0 and the next 2. +// give one id (meshdeliveryclient.AskingID), so the first asking is 0 and the next 2. func NextBackupAsking(after int) int { if after < 0 { return 0 @@ -191,8 +197,12 @@ var backupDigest = regexp.MustCompile(`\(composition (sha256:[0-9a-f]{64})\)`) // BackupAskDigest is the digest of the composition a backup ask was made about, from its explanation; empty for an ask that names // none. An answer is taken for the composition of the ask it answers, and a new revision at the same fingerprint is asked again // under a new number (ADR 0539 §16). -func BackupAskDigest(a delivery.AssignerAsk) string { - if m := backupDigest.FindStringSubmatch(a.Ask.Explanation); m != nil { +func BackupAskDigest(a asks.Ask) string { + ask, err := AskOf(a) + if err != nil { + return "" + } + if m := backupDigest.FindStringSubmatch(ask.Explanation); m != nil { return m[1] } return "" @@ -207,34 +217,34 @@ func BackupAnswerWords(option string, asking int) string { // BackupAsk is the ask about a backup of a module on node that failed before an apply (ADR 0539 §16): back up again, or go on // without a backup. digest is the composition about to be applied and asking the number of the asking, 0 the first; words are the // backup's own words, from the node's have. -func BackupAsk(key, node, module, fp, digest, words string, asking int, now time.Time) delivery.AssignerAsk { - return delivery.AssignerAsk{Asking: asking, Ask: asks.Ask{ - ID: delivery.AskingID(key, asking), +func BackupAsk(key, node, module, fp, digest, words string, asking int, now time.Time) asks.Ask { + return assignerAsk(asking, oc.Ask{ + ID: meshdeliveryclient.AskingID(key, asking), Headline: fmt.Sprintf("The backup of %s failed on %s", module, node), Explanation: fmt.Sprintf("The backup before applying %s at %s on %s failed: %s. %s keeps running what it has until you answer. (composition %s)", module, short(fp), node, words, module, digest), - Options: []asks.Option{ - {ID: OptionBackupAgain, Label: "Back up again", Does: "the node tries the backup again before it applies", Level: asks.Acknowledge}, - {ID: OptionBackupSkip, Label: "Go on without a backup", Does: "the node applies the new version without a backup", Level: asks.Acknowledge}, + Options: []oc.Option{ + {ID: OptionBackupAgain, Label: "Back up again", Does: "the node tries the backup again before it applies", Level: occ.Acknowledge}, + {ID: OptionBackupSkip, Label: "Go on without a backup", Does: "the node applies the new version without a backup", Level: occ.Acknowledge}, }, - Who: asks.Operator, - Expires: now.Add(7 * 24 * time.Hour), + Who: occ.Operator, + Expires: occ.FormatTime(now.Add(7 * 24 * time.Hour)), OnExpiry: "the module keeps running what it has", About: key, - }} + }) } // BackupCondition is the condition raised when a backup before an apply fails and the module is held on the node (ADR 0539 §16), // naming the node, the module and the backup's words; cleared under the same key. -func BackupCondition(node, module, fp, words string, raised bool) asks.Condition { - key := delivery.AssignerModule + ".backup." + node + "." + module + "." + strings.TrimPrefix(fp, delivery.SourcePrefix2) - subject := asks.Scope{Scope: "module", ID: module, Machine: node} +func BackupCondition(node, module, fp, words string, raised bool) oc.Condition { + key := assignment.Module + ".backup." + node + "." + module + "." + strings.TrimPrefix(fp, meshcatalogclient.SourcePrefix2) + subject := oc.ConditionSubject{Scope: "module", ID: module, Machine: node} if !raised { - return asks.Condition{Key: key, Subject: subject, State: asks.StateCleared} + return oc.Condition{Key: key, Subject: subject, State: occ.StateCleared} } headline := fmt.Sprintf("The backup of %s failed on %s", module, node) needs := "answer whether to back up again or go on without a backup" - return asks.Condition{Key: key, Severity: asks.SeverityWarning, Subject: subject, State: asks.StateRaised, + return oc.Condition{Key: key, Severity: occ.SeverityWarning, Subject: subject, State: occ.StateRaised, Summary: headline, Headline: headline, Needs: needs, - Explanation: asks.NeedsYou + needs + ". The backup before applying " + module + " at " + short(fp) + " on " + node + " failed: " + words + "."} + Explanation: occ.NeedsYou + needs + ". The backup before applying " + module + " at " + short(fp) + " on " + node + " failed: " + words + "."} } diff --git a/modules/mesh-delivery-assigner/internal/assign/asks_test.go b/modules/mesh-delivery-assigner/internal/assign/asks_test.go index 324002a0..9624858b 100644 --- a/modules/mesh-delivery-assigner/internal/assign/asks_test.go +++ b/modules/mesh-delivery-assigner/internal/assign/asks_test.go @@ -1,75 +1,105 @@ package assign import ( + "fmt" + "mesh-delivery-assigner/internal/assignment" + "mesh-delivery-assigner/internal/puts" "slices" "strings" "testing" "time" - "git.novox.be/novox/mesh-sdk/go/asks" - "git.novox.be/novox/mesh-sdk/go/delivery" - "git.novox.be/novox/mesh-sdk/go/health" + "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" + "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliveryclient" + mwc "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshwantsclient" + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-assigner/asks" + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-view/viewstate" + + mcc "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient" + mcseat "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions" + 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" ) // 1297/B9 (the assigner's half): the ask about a held fingerprint is a valid ask of the router and of mesh-sdk's layout at // its key, of a kind the view may answer. func TestTheHoldAskIsValid(t *testing.T) { - key, err := delivery.HoldAskKey("mail", fNew) + t.Run("hold", theHoldAskIsValid) + t.Run("restore", theRestoreAskIsValid) +} + +func theHoldAskIsValid(t *testing.T) { + key, err := asks.HoldAskKey("mail", fNew) if err != nil { t.Fatal(err) } now := time.Now() a := HoldAsk(key, "mail", fNew, "ace", "health failed, phase running: connection refused", 0, now) - if err := a.Ask.Check(now); err != nil { + if err := occ.Check(mustAsk(t, a), now); err != nil { t.Errorf("the router refuses the ask: %v", err) } - if _, err := delivery.EncodeAssignerAsk(key, a); err != nil { + if _, err := EncodeAsk(key, a); err != nil { t.Errorf("the ask cannot be written: %v", err) } // 1906/H3: the ask names the machine whose check failed and the hold's words. - if !strings.Contains(a.Ask.Explanation, "on ace") || !strings.Contains(a.Ask.Explanation, "connection refused") { - t.Errorf("the ask does not name the node and the hold's words: %q", a.Ask.Explanation) + if !strings.Contains(mustAsk(t, a).Explanation, "on ace") || !strings.Contains(mustAsk(t, a).Explanation, "connection refused") { + t.Errorf("the ask does not name the node and the hold's words: %q", mustAsk(t, a).Explanation) } // 1906/H2: an ask written again after a lift is another asking, under an id of its own. again := HoldAsk(key, "mail", fNew, "ace", "x", 7, now) - if again.Asking != 7 || again.Ask.ID != delivery.AskingID(key, 7) || again.Ask.ID == a.Ask.ID { - t.Errorf("the asking after a lift = %d %s", again.Asking, again.Ask.ID) + if again.Asking != 7 || mustAsk(t, again).ID != meshdeliveryclient.AskingID(key, 7) || mustAsk(t, again).ID == mustAsk(t, a).ID { + t.Errorf("the asking after a lift = %d %s", again.Asking, mustAsk(t, again).ID) } - if _, err := delivery.EncodeAssignerAsk(key, again); err != nil { + if _, err := EncodeAsk(key, again); err != nil { t.Errorf("the ask asked again cannot be written: %v", err) } - if _, err := delivery.ViewAnswerKey(key); err != nil { + if _, err := viewstate.ViewAnswerKey(key); err != nil { t.Errorf("the view may not answer it: %v", err) } + // The join (A1d ruling 1): mesh-sdk no longer refuses another id, no option or no ask in an assigner's ask; EncodeAsk does. + t.Run("EncodeAsk refuses what mesh-sdk no longer does", func(t *testing.T) { + good := HoldAsk(key, "mail", fNew, "ace", "x", 0, time.Unix(100, 0)) + other := good + other.Asking = 3 // its id is the asking 0's + empty := good + ask := mustAsk(t, good) + ask.Options = nil + empty.Ask = assignerAsk(0, ask).Ask + for name, a := range map[string]asks.Ask{"another id": other, "no option": empty, "no ask": {}} { + if _, err := EncodeAsk(key, a); err == nil { + t.Errorf("%s: written", name) + } + } + }) } // 1906/D3 (ADR 0539 §19): the ask about a data module held on a node is a valid ask of the router and of mesh-sdk's layout at // its restore key, of a kind the view may answer; it offers restoring (losing the newer writes) and leaving it stopped. -func TestTheRestoreAskIsValid(t *testing.T) { - key, err := delivery.RestoreAskKey("ace", "mail", fNew) +func theRestoreAskIsValid(t *testing.T) { + key, err := asks.RestoreAskKey("ace", "mail", fNew) if err != nil { t.Fatal(err) } now := time.Now() a := RestoreAsk(key, "ace", "mail", fNew, "connection refused", now) - if err := a.Ask.Check(now); err != nil { + if err := occ.Check(mustAsk(t, a), now); err != nil { t.Errorf("the router refuses the ask: %v", err) } - if _, err := delivery.EncodeAssignerAsk(key, a); err != nil { + if _, err := EncodeAsk(key, a); err != nil { t.Errorf("the ask cannot be written: %v", err) } - if _, err := delivery.ViewAnswerKey(key); err != nil { + if _, err := viewstate.ViewAnswerKey(key); err != nil { t.Errorf("the view may not answer it: %v", err) } var ids []string - for _, o := range a.Ask.Options { + for _, o := range mustAsk(t, a).Options { ids = append(ids, o.ID) } if len(ids) != 2 || ids[0] != OptionRestore || ids[1] != OptionLeaveStopped { t.Errorf("options = %v", ids) } - if !strings.Contains(a.Ask.Explanation, "connection refused") || !strings.Contains(a.Ask.Explanation, "ace") { - t.Errorf("the ask does not name the node and the check's words: %q", a.Ask.Explanation) + if !strings.Contains(mustAsk(t, a).Explanation, "connection refused") || !strings.Contains(mustAsk(t, a).Explanation, "ace") { + t.Errorf("the ask does not name the node and the check's words: %q", mustAsk(t, a).Explanation) } } @@ -83,13 +113,13 @@ func TestTheAssignersConditions(t *testing.T) { func heldConditionNamesModuleNodeAndWords(t *testing.T) { c := HeldCondition("mail", fNew, "ace", "connection refused", true, true) - if _, err := asks.EncodeCondition(c); err != nil { + if _, err := occ.EncodeCondition(c); err != nil { t.Fatalf("the condition is refused: %v", err) } - if c.State != asks.StateRaised || c.Subject.ID != "mail" || c.Subject.Machine != "ace" || !strings.Contains(c.Explanation, "connection refused") || !strings.Contains(c.Key, "mail") { + if c.State != occ.StateRaised || c.Subject.ID != "mail" || c.Subject.Machine != "ace" || !strings.Contains(c.Explanation, "connection refused") || !strings.Contains(c.Key, "mail") { t.Errorf("condition = %+v", c) } - if got := HeldCondition("mail", fNew, "", "", false, false); got.State != asks.StateCleared || got.Key != c.Key { + if got := HeldCondition("mail", fNew, "", "", false, false); got.State != occ.StateCleared || got.Key != c.Key { t.Errorf("cleared = %+v", got) } } @@ -97,10 +127,10 @@ func heldConditionNamesModuleNodeAndWords(t *testing.T) { // 1906/H2: a hold lifted by itself clears the held condition under its key, saying why. func liftedHoldClearsItsConditionSayingWhy(t *testing.T) { c := LiftedCondition("mail", fNew) - if _, err := asks.EncodeCondition(c); err != nil { + if _, err := occ.EncodeCondition(c); err != nil { t.Fatalf("the condition is refused: %v", err) } - if c.State != asks.StateCleared || c.Key != HeldCondition("mail", fNew, "ace", "x", true, false).Key || !strings.Contains(c.Resolved, "lifted its hold by itself") { + if c.State != occ.StateCleared || c.Key != HeldCondition("mail", fNew, "ace", "x", true, false).Key || !strings.Contains(c.Resolved, "lifted its hold by itself") { t.Errorf("condition = %+v", c) } } @@ -112,38 +142,38 @@ func liftedHoldClearsItsConditionSayingWhy(t *testing.T) { func saidConditionsAreFindings(t *testing.T) { at := time.Date(2026, 10, 29, 21, 0, 0, 0, time.UTC) for _, c := range []struct { - raised, cleared asks.Condition + raised, cleared oc.Condition key string }{ - {HeldCondition("mail", fNew, "ace", "refused", true, true), LiftedCondition("mail", fNew), "module.mail." + strings.TrimPrefix(fNew, delivery.SourcePrefix2) + ".held"}, + {HeldCondition("mail", fNew, "ace", "refused", true, true), LiftedCondition("mail", fNew), "module.mail." + strings.TrimPrefix(fNew, meshcatalogclient.SourcePrefix2) + ".held"}, {NodeFailedCondition("ace", "mail", fNew, NodeFailure{Words: "x", Others: []string{"g14"}}, true), NodeFailedCondition("ace", "mail", fNew, NodeFailure{}, false), "module.ace.mail.failed-alone"}, {NeitherCondition("ace", "mail", Holding{Rule: 3, Words: "w"}, true), NeitherCondition("ace", "mail", Holding{}, false), "module.ace.mail.neither"}, - {SeatWaitsCondition([]string{"placement"}), SeatWaitsCondition(nil), "module.mesh-delivery-assigner.seat-states"}, - {BackupCondition("ace", "mail", fNew, "disk full", true), BackupCondition("ace", "mail", fNew, "", false), "module.ace.mail." + strings.TrimPrefix(fNew, delivery.SourcePrefix2) + ".backup"}, + {SeatWaitsCondition([]string{"assignments"}), SeatWaitsCondition(nil), "module.mesh-delivery-assigner.seat-states"}, + {BackupCondition("ace", "mail", fNew, "disk full", true), BackupCondition("ace", "mail", fNew, "", false), "module.ace.mail." + strings.TrimPrefix(fNew, meshcatalogclient.SourcePrefix2) + ".backup"}, } { - puts, deletes, said := map[string]health.Finding{}, []string{}, 0 + puts, deletes, said := map[string]mcseat.FindingsByKeyValue{}, []string{}, 0 f := Findings{Put: func(k string, v []byte) error { - got, err := health.DecodeFinding(v) + got, err := mcc.DecodeFinding(v) puts[k] = got return err }, Delete: func(k string) error { deletes = append(deletes, k); return nil }, Now: func() time.Time { return at }} - say := f.Say(func(asks.Condition) error { said++; return nil }) + say := f.Say(func(oc.Condition) error { said++; return nil }) if err := say(c.raised); err != nil { t.Fatalf("%s: %v", c.key, err) } got, ok := puts[c.key] - if !ok || got.Severity != health.Severity(c.raised.Severity) || got.Headline != c.raised.Headline || got.Observer != delivery.AssignerModule || + if !ok || got.Severity != c.raised.Severity || got.Headline != c.raised.Headline || got.Observer != assignment.Module || got.Subject.Machine != c.raised.Subject.Machine || said != 0 { t.Fatalf("%s: finding %+v (written %v), said %d", c.key, got, puts, said) } if err := say(c.cleared); err != nil || !slices.Equal(deletes, []string{c.key}) || said != 0 { t.Fatalf("%s: deleted %v, said %d (%v)", c.key, deletes, said, err) } - if err := (Findings{}).Say(func(asks.Condition) error { said++; return nil })(c.raised); err != nil || said != 1 { + if err := (Findings{}).Say(func(oc.Condition) error { said++; return nil })(c.raised); err != nil || said != 1 { t.Fatalf("%s: with no findings bucket said %d (%v), want straight once", c.key, said, err) } } - if _, _, _, err := FindingOf(asks.Condition{Key: "another.x", State: asks.StateRaised}, at); err == nil { + if _, _, _, err := FindingOf(oc.Condition{Key: "another.x", State: occ.StateRaised}, at); err == nil { t.Error("a condition not the assigner's is a finding") } } @@ -152,36 +182,36 @@ func saidConditionsAreFindings(t *testing.T) { // the view, and name the move and the node. func TestTheRecordAndBackupAsksAreValid(t *testing.T) { now := time.Now() - rkey, err := delivery.RecordAskKey("ace", "mail", "sha256:"+strings.Repeat("b", 64)) + rkey, err := asks.RecordAskKey("ace", "mail", "sha256:"+strings.Repeat("b", 64)) if err != nil { t.Fatal(err) } - r := RecordAsk(rkey, "ace", "mail", fNew, RecordNeed{Node: "ace", Digest: "sha256:" + strings.Repeat("b", 64), Judgement: delivery.JudgedCannot, Words: "the composition it runs is gone"}, now) - bkey, err := delivery.BackupAskKey("ace", "mail", fNew) + r := RecordAsk(rkey, "ace", "mail", fNew, RecordNeed{Node: "ace", Digest: "sha256:" + strings.Repeat("b", 64), Judgement: JudgedCannot, Words: "the composition it runs is gone"}, now) + bkey, err := asks.BackupAskKey("ace", "mail", fNew) if err != nil { t.Fatal(err) } dgst := "sha256:" + strings.Repeat("b", 64) b := BackupAsk(bkey, "ace", "mail", fNew, dgst, "no space left", 0, now) - for key, a := range map[string]delivery.AssignerAsk{rkey: r, bkey: b} { - if err := a.Ask.Check(now); err != nil { + for key, a := range map[string]asks.Ask{rkey: r, bkey: b} { + if err := occ.Check(mustAsk(t, a), now); err != nil { t.Errorf("%s: the router refuses the ask: %v", key, err) } - if _, err := delivery.EncodeAssignerAsk(key, a); err != nil { + if _, err := EncodeAsk(key, a); err != nil { t.Errorf("%s: cannot be written: %v", key, err) } - if _, err := delivery.ViewAnswerKey(key); err != nil { + if _, err := viewstate.ViewAnswerKey(key); err != nil { t.Errorf("%s: the view may not answer it: %v", key, err) } } - if !strings.Contains(r.Ask.Explanation, "gone") || r.Ask.Options[0].ID != OptionYes { - t.Errorf("the record ask = %+v", r.Ask) + if !strings.Contains(mustAsk(t, r).Explanation, "gone") || mustAsk(t, r).Options[0].ID != OptionYes { + t.Errorf("the record ask = %+v", mustAsk(t, r)) } - if !strings.Contains(b.Ask.Explanation, "no space left") || b.Ask.Options[0].ID != delivery.BackupAgain || b.Ask.Options[1].ID != delivery.BackupSkip { - t.Errorf("the backup ask = %+v", b.Ask) + if !strings.Contains(mustAsk(t, b).Explanation, "no space left") || mustAsk(t, b).Options[0].ID != mwc.BackupAgain || mustAsk(t, b).Options[1].ID != mwc.BackupSkip { + t.Errorf("the backup ask = %+v", mustAsk(t, b)) } // the id binds the ask to the composition and to its number (review round 1, items 3 and 4) - if again := BackupAsk(bkey, "ace", "mail", fNew, dgst, "x", NextBackupAsking(0), now); again.Ask.ID == b.Ask.ID { + if again := BackupAsk(bkey, "ace", "mail", fNew, dgst, "x", NextBackupAsking(0), now); mustAsk(t, again).ID == mustAsk(t, b).ID { t.Errorf("asked again under the same id") } if NextBackupAsking(-1) != 0 || NextBackupAsking(0) != 2 || NextBackupAsking(2) != 3 { @@ -194,44 +224,51 @@ func TestTheRecordAndBackupAsksAreValid(t *testing.T) { t.Errorf("two answers of the same option are one want value") } c := BackupCondition("ace", "mail", fNew, "no space left", true) - if _, err := asks.EncodeCondition(c); err != nil { + if _, err := occ.EncodeCondition(c); err != nil { t.Errorf("the backup condition is refused: %v", err) } // ADR 0546 clauses 7 and 8: a put's ask is valid at the record ask's key; for the bus its yes approves and binds the act, // so the view cannot answer it, and it lasts no longer than an approving ask may. - put := delivery.Put{FP: fNew, Why: "the bus's user list", Hold: true} - d := delivery.ComposedDigest{Digest: dgst, Judgement: delivery.JudgedCannot, Words: "the have names no composition"} + put := puts.Put{FP: fNew, Why: "the bus's user list", Hold: true} + d := ComposedDigest{Digest: dgst, Judgement: JudgedCannot, Words: "the have names no composition"} for _, bus := range []bool{false, true} { a, err := PutAsk(rkey, "ace", "mail", put, d, bus, now) if err != nil { t.Fatalf("bus %v: %v", bus, err) } - if err := a.Ask.Check(now); err != nil { + if err := occ.Check(mustAsk(t, a), now); err != nil { t.Errorf("bus %v: the router refuses the put's ask: %v", bus, err) } - if _, err := delivery.EncodeAssignerAsk(rkey, a); err != nil { + if _, err := EncodeAsk(rkey, a); err != nil { t.Errorf("bus %v: the put's ask cannot be written: %v", bus, err) } - viewed := delivery.ViewAnswerFits(delivery.ViewAnswer{Asking: a.Ask.ID, Option: OptionYes}, a) - yes := a.Ask.Options[0] + viewed := viewstate.ViewAnswerFits(viewstate.ViewAnswer{Asking: mustAsk(t, a).ID, Option: OptionYes}, rkey, a) + yes := mustAsk(t, a).Options[0] + // mesh-sdk gives no refusal by level any more: the teller refuses an option a view cannot answer (its level is not + // acknowledge), as it did through the sdk before. + if viewed == nil { + if o, ok := occ.OptionOf(mustAsk(t, a), OptionYes); ok && o.Level != "" && o.Level != occ.Acknowledge { + viewed = fmt.Errorf("only a channel that proves who answered takes the option %s", o.ID) + } + } switch { - case !strings.Contains(a.Ask.Explanation, "the bus's user list"): - t.Errorf("bus %v: the put's ask does not say why: %s", bus, a.Ask.Explanation) - case bus && (yes.Level != asks.Approve || yes.Binds == "" || viewed == nil || a.Ask.Expires.After(now.Add(asks.ApproveLasts))): - t.Errorf("bus: the yes is %+v, the view's answer %v, expiring %s", yes, viewed, a.Ask.Expires) - case !bus && (yes.Level != asks.Acknowledge || viewed != nil): + case !strings.Contains(mustAsk(t, a).Explanation, "the bus's user list"): + t.Errorf("bus %v: the put's ask does not say why: %s", bus, mustAsk(t, a).Explanation) + case bus && (yes.Level != occ.Approve || yes.Binds == "" || viewed == nil || expiresAt(t, a).After(now.Add(occ.ApproveLasts))): + t.Errorf("bus: the yes is %+v, the view's answer %v, expiring %s", yes, viewed, expiresAt(t, a)) + case !bus && (yes.Level != occ.Acknowledge || viewed != nil): t.Errorf("a recorded module: the yes is %+v, the view's answer %v", yes, viewed) } later := put later.At = now.Add(time.Minute) b, _ := PutAsk(rkey, "ace", "mail", later, d, bus, now) - if b.Ask.ID == a.Ask.ID || b.Asking != PutAsking(later) { - t.Errorf("bus %v: a later put's ask is asking %d under id %q, the earlier's %q", bus, b.Asking, b.Ask.ID, a.Ask.ID) + if mustAsk(t, b).ID == mustAsk(t, a).ID || b.Asking != PutAsking(later) { + t.Errorf("bus %v: a later put's ask is asking %d under id %q, the earlier's %q", bus, b.Asking, mustAsk(t, b).ID, mustAsk(t, a).ID) } - if err := yes.Performs(PutAct("ace", "mail", fNew, dgst)); err != nil { + if err := occ.Performs(yes, PutAct("ace", "mail", fNew, dgst)); err != nil { t.Errorf("bus %v: the yes does not perform the act it bound: %v", bus, err) } - if err := yes.Performs(PutAct("ace", "mail", fNew, dig(fOld))); bus && err == nil { + if err := occ.Performs(yes, PutAct("ace", "mail", fNew, dig(fOld))); bus && err == nil { t.Errorf("bus: the yes performs another composition's act") } } @@ -240,15 +277,30 @@ func TestTheRecordAndBackupAsksAreValid(t *testing.T) { // 1945/AC21: the condition of a module on neither list is urgent, names the module, the node and the rule holding its want back, // under the assigner's own name keyed by node and module, and is cleared under the same key. func TestTheNeitherConditionNamesModuleNodeAndRule(t *testing.T) { - c := NeitherCondition("g14", "i3status-rust", Holding{Rule: delivery.RuleProviders, Words: "no provider of x11-display runs healthy on g14 or at its canary"}, true) - if _, err := asks.EncodeCondition(c); err != nil { + c := NeitherCondition("g14", "i3status-rust", Holding{Rule: mwc.RuleProviders, Words: "no provider of x11-display runs healthy on g14 or at its canary"}, true) + if _, err := occ.EncodeCondition(c); err != nil { t.Fatalf("the condition is refused: %v", err) } - if c.State != asks.StateRaised || c.Severity != asks.SeverityUrgent || c.Subject.ID != "i3status-rust" || c.Subject.Machine != "g14" || + if c.State != occ.StateRaised || c.Severity != occ.SeverityUrgent || c.Subject.ID != "i3status-rust" || c.Subject.Machine != "g14" || !strings.Contains(c.Explanation, "x11-display") || !strings.Contains(c.Explanation, "rule 5") || c.Key != "mesh-delivery-assigner.neither.g14.i3status-rust" { t.Errorf("condition = %+v", c) } - if got := NeitherCondition("g14", "i3status-rust", Holding{}, false); got.State != asks.StateCleared || got.Key != c.Key { + if got := NeitherCondition("g14", "i3status-rust", Holding{}, false); got.State != occ.StateCleared || got.Key != c.Key { t.Errorf("cleared = %+v", got) } } + +// mustAsk is the router's ask an asks-state value carries. +func mustAsk(t *testing.T, a asks.Ask) oc.Ask { + t.Helper() + ask, err := AskOf(a) + if err != nil { + t.Fatal(err) + } + return ask +} + +func expiresAt(t *testing.T, a asks.Ask) time.Time { + t.Helper() + return ExpiresAt(mustAsk(t, a)) +} diff --git a/modules/mesh-delivery-assigner/internal/assign/assignments.go b/modules/mesh-delivery-assigner/internal/assign/assignments.go new file mode 100644 index 00000000..6455b562 --- /dev/null +++ b/modules/mesh-delivery-assigner/internal/assign/assignments.go @@ -0,0 +1,23 @@ +package assign + +import ( + "mesh-delivery-assigner/internal/assignment" + "strings" + + 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" +) + +// SeatWaitsCondition is the assigner's word on the seat mesh-module-assigner while a state of the seat is not bound (ADR 0567 +// §1): raised naming each, cleared when none is left. +func SeatWaitsCondition(unbound []string) oc.Condition { + key := assignment.Module + ".seat-states" + subject := oc.ConditionSubject{Scope: "module", ID: assignment.Module} + if len(unbound) == 0 { + return oc.Condition{Key: key, Subject: subject, State: occ.StateCleared, Resolved: "the seat mesh-module-assigner holds all its states."} + } + why := "the seat waits for its states: " + strings.Join(unbound, ", ") + " are not on the bus or not granted yet" + return oc.Condition{Key: key, Severity: occ.SeverityWarning, Subject: subject, State: occ.StateRaised, + Summary: "the seat waits for its states", Headline: "the seat waits for its states", + Explanation: occ.NothingToDo + " " + why + "; each is bound at the next pass once it is, with no restart."} +} diff --git a/modules/mesh-delivery-assigner/internal/assign/composed.go b/modules/mesh-delivery-assigner/internal/assign/composed.go new file mode 100644 index 00000000..0379c12f --- /dev/null +++ b/modules/mesh-delivery-assigner/internal/assign/composed.go @@ -0,0 +1,45 @@ +package assign + +import nec "git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient" + +// Judgement is the assigner's judgement of a move from the composition a node runs, as its have names it, to a new one +// (ADR 0539 clauses 2 and 14, by ADR 0377 §3's tests). The three the node-engine names are its have's words (a have's +// judgement); neither and unchanged are the assigner's own. +type Judgement = string + +// The judgements of a move. +const ( + // JudgedRecreates is a move that recreates a container. + JudgedRecreates = nec.JudgedRecreates + // JudgedMovesData is a move that gives the module's data another place. + JudgedMovesData = nec.JudgedMovesData + // JudgedNeither is a move that does neither, and the judgement of a composition the node never ran. + JudgedNeither = "neither" + // JudgedCannot is a move that cannot be judged, because the composition the node runs is gone or cannot be read. + JudgedCannot = nec.JudgedCannot + // JudgedUnchanged is a composition equal to the module's slice of the declaration the node last applied, for a node + // whose have names no composition yet (the first want at the switch, novox/hq ADR 0540 clause 9): applying it + // changes nothing on the node, so it needs no person's yes. + JudgedUnchanged = "unchanged" +) + +// ComposedDigest is what the assigner knows of a composition beside the node's have: its Digest, and the Judgement of the +// move to it from the composition the node runs now, whose digest is Against (empty when the node runs no composition of +// the module). Words say why, and are required for a move that cannot be judged. +// +// Policy is the module's upgrade policy as the assigner holds it (ADR 0236, ADR 0539 clause 14), with PolicyFrom where it +// comes from and PolicyWhy why: the assigner holds a move of a module of policy record from it. +type ComposedDigest struct { + V int `json:"v"` + Digest string `json:"digest"` + Judgement Judgement `json:"judgement"` + Against string `json:"against,omitempty"` + Words string `json:"words,omitempty"` + // UserListOnly says the composition differs from the one the node's have names only in the bus's user list, whatever + // the judgement says of its mount: the assigner writes such a revision into the bus node's want at once, outside the + // record hold (ADR 0539 clause 7). + UserListOnly bool `json:"user-list-only,omitempty"` + Policy string `json:"policy,omitempty"` + PolicyFrom string `json:"policy-from,omitempty"` + PolicyWhy string `json:"policy-why,omitempty"` +} diff --git a/modules/mesh-delivery-assigner/internal/assign/findings.go b/modules/mesh-delivery-assigner/internal/assign/findings.go index 0840a0bd..ca58dfc9 100644 --- a/modules/mesh-delivery-assigner/internal/assign/findings.go +++ b/modules/mesh-delivery-assigner/internal/assign/findings.go @@ -3,12 +3,14 @@ package assign import ( "errors" "fmt" + "mesh-delivery-assigner/internal/assignment" "strings" "time" - "git.novox.be/novox/mesh-sdk/go/asks" - "git.novox.be/novox/mesh-sdk/go/delivery" - "git.novox.be/novox/mesh-sdk/go/health" + mcc "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient" + mcseat "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions" + 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" ) // The assigner's said conditions become findings (novox/hq ADR 0564 §2, issue 1952 WM6, note 16): every condition it says @@ -19,13 +21,13 @@ import ( // assigner no longer says it straight (never both). // FindingsBucket is the assigner's findings. -var FindingsBucket = health.FindingsBucket(delivery.AssignerModule) +var FindingsBucket = mcc.FindingsBucket(assignment.Module) // FindingOf is a said condition as a finding: its key `..`, from the condition's own key // `mesh-delivery-assigner..` (the subject's id when the key names none), its words unchanged. raised is false for // a clear, whose key is to be deleted; value is nil then. -func FindingOf(c asks.Condition, at time.Time) (key string, value []byte, raised bool, err error) { - rest, ok := strings.CutPrefix(c.Key, delivery.AssignerModule+".") +func FindingOf(c oc.Condition, at time.Time) (key string, value []byte, raised bool, err error) { + rest, ok := strings.CutPrefix(c.Key, assignment.Module+".") if !ok || rest == "" { return "", nil, false, fmt.Errorf("the condition %q is not the assigner's", c.Key) } @@ -35,27 +37,27 @@ func FindingOf(c asks.Condition, at time.Time) (key string, value []byte, raised } scope := c.Subject.Scope if scope == "" { - scope = health.ScopeModule + scope = mcc.ScopeModule } - key = health.FindingKey(scope, id, kind) - if c.State == asks.StateCleared { + key = mcc.Key(scope, id, kind) + if c.State == occ.StateCleared { return key, nil, false, nil } - if c.State != asks.StateRaised { + if c.State != occ.StateRaised { return "", nil, false, fmt.Errorf("the condition %s is neither raised nor cleared", c.Key) } summary := c.Summary if summary == "" { summary = c.Headline } - var actions []health.Action + var actions []mcseat.Action for _, a := range c.Actions { - actions = append(actions, health.Action{Label: a.Label, Verb: a.Verb, Machine: a.Machine, Arguments: a.Arguments, Level: string(a.Level)}) + actions = append(actions, mcseat.Action{Label: a.Label, Verb: a.Verb, Machine: a.Machine, Arguments: a.Arguments, Level: string(a.Level)}) } - value, err = health.EncodeFinding(health.Finding{Key: key, Kind: kind, Subject: health.Subject{Scope: scope, ID: id, Machine: c.Subject.Machine}, - Severity: health.Severity(c.Severity), Summary: summary, Headline: c.Headline, Explanation: c.Explanation, Resolved: c.Resolved, - Needs: c.Needs, Actions: actions, Evidence: []health.Evidence{{At: at.UTC(), Said: summary}}, Source: "assigner." + kind, - Observer: delivery.AssignerModule, At: at.UTC()}) + f := mcc.Observation{Scope: scope, ID: id, Kind: kind, Machine: c.Subject.Machine, Severity: string(c.Severity), Summary: summary, + Headline: c.Headline, Explanation: c.Explanation, Resolved: c.Resolved, Needs: c.Needs, Actions: actions, + Source: "assigner." + kind}.Finding(assignment.Module, "", at) + value, err = mcc.EncodeFinding(f) return key, value, true, err } @@ -70,8 +72,8 @@ type Findings struct { // Say is say with each condition written as a finding (or its finding deleted), which the condition keeper carries, the // one source since the controller's end (novox/hq ADR 0590 §3); said straight to the router only where there is no // findings bucket at all. A finding that cannot be written is the error. -func (f Findings) Say(say func(asks.Condition) error) func(asks.Condition) error { - return func(c asks.Condition) error { +func (f Findings) Say(say func(oc.Condition) error) func(oc.Condition) error { + return func(c oc.Condition) error { var errs []error if f.Put != nil { now := time.Now diff --git a/modules/mesh-delivery-assigner/internal/assign/placement.go b/modules/mesh-delivery-assigner/internal/assign/placement.go deleted file mode 100644 index 5019a0e9..00000000 --- a/modules/mesh-delivery-assigner/internal/assign/placement.go +++ /dev/null @@ -1,22 +0,0 @@ -package assign - -import ( - "strings" - - "git.novox.be/novox/mesh-sdk/go/asks" - "git.novox.be/novox/mesh-sdk/go/delivery" -) - -// SeatWaitsCondition is the assigner's word on the seat mesh-placement while a state of the seat is not bound (ADR 0567 -// §1): raised naming each, cleared when none is left. -func SeatWaitsCondition(unbound []string) asks.Condition { - key := delivery.AssignerModule + ".seat-states" - subject := asks.Scope{Scope: "module", ID: delivery.AssignerModule} - if len(unbound) == 0 { - return asks.Condition{Key: key, Subject: subject, State: asks.StateCleared, Resolved: "the seat mesh-placement holds all its states."} - } - why := "the seat waits for its states: " + strings.Join(unbound, ", ") + " are not on the bus or not granted yet" - return asks.Condition{Key: key, Severity: asks.SeverityWarning, Subject: subject, State: asks.StateRaised, - Summary: "the seat waits for its states", Headline: "the seat waits for its states", - Explanation: asks.NothingToDo + " " + why + "; each is bound at the next pass once it is, with no restart."} -} diff --git a/modules/mesh-delivery-assigner/internal/assign/policy.go b/modules/mesh-delivery-assigner/internal/assign/policy.go index fab4630b..5a20f309 100644 --- a/modules/mesh-delivery-assigner/internal/assign/policy.go +++ b/modules/mesh-delivery-assigner/internal/assign/policy.go @@ -3,15 +3,16 @@ package assign import ( "encoding/json" "fmt" + "mesh-delivery-assigner/internal/assignment" + "mesh-delivery-assigner/internal/buckets" "sort" "strings" "time" - "git.novox.be/novox/mesh-sdk/go/asks" + mcc "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient" + occ "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient" + "git.novox.be/novox/mesh-contracts/provisioning/go/clients/meshprovisionerclient" "git.novox.be/novox/mesh-sdk/go/catalogue" - "git.novox.be/novox/mesh-sdk/go/delivery" - "git.novox.be/novox/mesh-sdk/go/health" - "git.novox.be/novox/mesh-sdk/go/provisions" ) // A person's upgrade policy is the assigner's (novox/hq ADR 0565 §7, §8, issue 1953 D4, D5): kept in its state `policies`, one @@ -19,7 +20,7 @@ import ( // catalogue.Upgrade: a person's choice over the module's word, its irreplaceable data and the default, the bus as any module (ADR 0601 §1). // PoliciesBucket is the assigner's state `policies`. -var PoliciesBucket = delivery.AssignerModule + "_" + delivery.AssignerPoliciesState +var PoliciesBucket = assignment.Module + "_" + buckets.PoliciesState // DecodePolicies reads the policies state's values by key (the module); a value that cannot be read is left out, named. func DecodePolicies(values map[string][]byte) (map[string]catalogue.PersonPolicy, []string) { @@ -60,19 +61,22 @@ const KindProvisionFailed = "provision-failed" // ProvisionFailedKey is a failed provision's finding's key, `provider...[.].provision-failed`. func ProvisionFailedKey(key string) string { - return health.FindingKey(health.ScopeProvider, key, KindProvisionFailed) + return mcc.Key(mcc.ScopeProvider, key, KindProvisionFailed) } // ProvisionFailedFinding is the finding of a failed provision, naming its consumer, its provider and why. -func ProvisionFailedFinding(key string, p provisions.Provision, at time.Time) ([]byte, error) { +func ProvisionFailedFinding(key string, p meshprovisionerclient.Provision, at time.Time) ([]byte, error) { summary := fmt.Sprintf("%s for %s on %s failed at %s on %s: %s, so %s is not moved there until it is provided", p.Provision, p.Consumer.Module, p.Consumer.Node, p.Provider.Module, p.Provider.Node, p.Why, p.Consumer.Module) - return health.EncodeFinding(health.Finding{Key: ProvisionFailedKey(key), Kind: KindProvisionFailed, - Subject: health.Subject{Scope: health.ScopeProvider, ID: key, Machine: p.Consumer.Node, Also: also(p.Provider.Node, p.Consumer.Node)}, - Severity: health.Warning, Summary: summary, Headline: clipHeadline(p.Consumer.Module + " waits for a provision that failed"), - Explanation: asks.NothingToDo + " " + summary + ". It clears once the provider provides it.", - Resolved: p.Provision + " for " + p.Consumer.Module + " is provided", Evidence: []health.Evidence{{At: at.UTC(), Said: p.Why}}, - Source: "assigner." + KindProvisionFailed, Observer: delivery.AssignerModule, At: at.UTC()}) + return encode(mcc.Observation{Scope: mcc.ScopeProvider, ID: key, Kind: KindProvisionFailed, Machine: p.Consumer.Node, Also: also(p.Provider.Node, p.Consumer.Node), + Summary: summary, Said: p.Why, Headline: clipHeadline(p.Consumer.Module + " waits for a provision that failed"), + Explanation: occ.NothingToDo + " " + summary + ". It clears once the provider provides it.", + Resolved: p.Provision + " for " + p.Consumer.Module + " is provided", Source: "assigner." + KindProvisionFailed}, at) +} + +// encode is the observation as the assigner's finding at at, in the seat's layout. +func encode(o mcc.Observation, at time.Time) ([]byte, error) { + return mcc.EncodeFinding(o.Finding(assignment.Module, "", at)) } func also(provider, consumer string) []string { @@ -87,7 +91,7 @@ const KindProvisionMissing = "provision-missing" // ProvisionMissingKey is its key, `provider....provision-missing`. func ProvisionMissingKey(key string) string { - return health.FindingKey(health.ScopeProvider, key, KindProvisionMissing) + return mcc.Key(mcc.ScopeProvider, key, KindProvisionMissing) } // ProvisionMissingFinding is the finding of a required provision with no key past bound (key `..`). @@ -99,32 +103,28 @@ func ProvisionMissingFinding(key string, bound time.Duration, at time.Time) ([]b node, module, provision := parts[0], parts[1], parts[2] summary := fmt.Sprintf("%s on %s requires %s and mesh-provisioner has composed no key for it in %s, so %s is not moved there: "+ "no provider of it may be placed", module, node, provision, bound, module) - return health.EncodeFinding(health.Finding{Key: ProvisionMissingKey(key), Kind: KindProvisionMissing, - Subject: health.Subject{Scope: health.ScopeProvider, ID: key, Machine: node}, - Severity: health.Warning, Summary: summary, Headline: clipHeadline(module + " on " + node + " lacks " + provision), - Explanation: asks.NothingToDo + " " + summary + ". It clears once the provision has its key.", - Resolved: provision + " for " + module + " on " + node + " has its key", Evidence: []health.Evidence{{At: at.UTC(), Said: summary}}, - Source: "assigner." + KindProvisionMissing, Observer: delivery.AssignerModule, At: at.UTC()}) + return encode(mcc.Observation{Scope: mcc.ScopeProvider, ID: key, Kind: KindProvisionMissing, Machine: node, + Summary: summary, Headline: clipHeadline(module + " on " + node + " lacks " + provision), + Explanation: occ.NothingToDo + " " + summary + ". It clears once the provision has its key.", + Resolved: provision + " for " + module + " on " + node + " has its key", Source: "assigner." + KindProvisionMissing}, at) } -// KindUnplaced is, from the lease write, a node that runs but no placement names (ADR 0571 §6). +// KindUnplaced is, from the lease write, a node that runs but no assignments names (ADR 0571 §6). const KindUnplaced = "unplaced" // UnplacedKey is its finding's key, `machine..unplaced`. func UnplacedKey(node string) string { - return health.FindingKey(health.ScopeMachine, node, KindUnplaced) + return mcc.Key(mcc.ScopeMachine, node, KindUnplaced) } -// UnplacedFinding is the finding of a node no placement names since the lease write. +// UnplacedFinding is the finding of a node no assignments names since the lease write. func UnplacedFinding(node string, at time.Time) ([]byte, error) { - summary := fmt.Sprintf("%s runs, but no placement names it: since the controller handed its lease away nothing composes it, "+ + summary := fmt.Sprintf("%s runs, but no assignments names it: since the controller handed its lease away nothing composes it, "+ "so the assigner writes it no want", node) - return health.EncodeFinding(health.Finding{Key: UnplacedKey(node), Kind: KindUnplaced, - Subject: health.Subject{Scope: health.ScopeMachine, ID: node, Machine: node}, Severity: health.Warning, Summary: summary, - Headline: clipHeadline(node + " has no placement"), Needs: "place it with mesh-placement.propose.", - Explanation: asks.NeedsYou + "place it with mesh-placement.propose. " + summary + ".", - Resolved: node + " is placed", Evidence: []health.Evidence{{At: at.UTC(), Said: summary}}, - Source: "assigner." + KindUnplaced, Observer: delivery.AssignerModule, At: at.UTC()}) + return encode(mcc.Observation{Scope: mcc.ScopeMachine, ID: node, Kind: KindUnplaced, Machine: node, Summary: summary, + Headline: clipHeadline(node + " has no assignments"), Needs: "place it with mesh-module-assigner.propose.", + Explanation: occ.NeedsYou + "place it with mesh-module-assigner.propose. " + summary + ".", + Resolved: node + " is placed", Source: "assigner." + KindUnplaced}, at) } func clipHeadline(s string) string { diff --git a/modules/mesh-delivery-assigner/internal/assign/put.go b/modules/mesh-delivery-assigner/internal/assign/put.go index 46f993ce..da4c7aa5 100644 --- a/modules/mesh-delivery-assigner/internal/assign/put.go +++ b/modules/mesh-delivery-assigner/internal/assign/put.go @@ -2,10 +2,17 @@ package assign import ( "fmt" + "git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient" + "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" + "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliveryclient" + mwc "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshwantsclient" + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-assigner/asks" + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds" + "mesh-delivery-assigner/internal/puts" "time" - "git.novox.be/novox/mesh-sdk/go/asks" - "git.novox.be/novox/mesh-sdk/go/delivery" + 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" ) // A put (novox/hq ADR 0546): one version of a module put on one node by the assigner's tool assigner_put. The tool writes the put @@ -19,7 +26,7 @@ const ComposeWithin = 10 * time.Minute // PutWorld is what one step of a put reads. type PutWorld struct { Node, Module string - Put delivery.Put + Put puts.Put // Assigned says the module stands assigned to the node. Assigned bool // Bus says the module provides the mesh's bus (ADR 0539 §15); Recorded that it is of policy record (§14). @@ -30,7 +37,7 @@ type PutWorld struct { Newest string // Composed is the composition of the module for the node at the fingerprint, as its writer holds it; nil when there is // none. Granted says the grant at the fingerprint names the node's accounts. Both are read, never asked for (clause 4). - Composed *delivery.ComposedDigest + Composed *ComposedDigest Granted bool // Answer is the option the operator chose on this put's own ask (its asking, PutAsking), "" when none; AskExpired says // that ask expired unanswered. @@ -42,13 +49,13 @@ type PutWorld struct { Snapshotting bool SnapshotFailed string // Have is what the node says it runs of the module; nil when it says nothing. - Have *delivery.Have + Have *nodeengine.HaveByNodeModuleValue Now time.Time } // PutNext is what a step decides: the put as it stands after it, and the acts the pass does for it. type PutNext struct { - Put delivery.Put + Put puts.Put // Ask writes the put's ask about the move (PutAsk). Ask bool // Snapshot has the bus's node back the bus up now, its dump the streams' snapshot (ADR 0235). @@ -60,7 +67,7 @@ type PutNext struct { // StepPut takes a put one step further from what the world holds (ADR 0546 clauses 3 to 8). func StepPut(w PutWorld) PutNext { p := w.Put - if delivery.PutEnded(p.Step) { + if puts.Ended(p.Step) { return PutNext{Put: p} } say := func(step, format string, args ...any) PutNext { @@ -68,21 +75,21 @@ func StepPut(w PutWorld) PutNext { } fp := short(p.FP) if !w.Assigned { - return say(delivery.PutRefused, "%s is not assigned to %s: a put moves a module where it stands assigned (propose its placement first)", w.Module, w.Node) + return say(puts.Refused, "%s is not assigned to %s: a put moves a module where it stands assigned (propose its assignments first)", w.Module, w.Node) } switch w.Build { - case delivery.BuildDone: - case delivery.BuildFailed: - return say(delivery.PutRefused, "the builder's build of %s at %s failed: %s", w.Module, fp, w.BuildWords) + case builds.BuildDone: + case builds.BuildFailed: + return say(puts.Refused, "the builder's build of %s at %s failed: %s", w.Module, fp, w.BuildWords) case "": if p.FP != w.Newest { // The builder builds the trunk's newest fingerprint and the candidates', and takes no ask for another yet (ADR 0546 // clause 3): an older fingerprint it never built, or whose key it removed, cannot be put. - return say(delivery.PutRefused, "the builder holds no build of %s at %s, and builds by itself only the trunk's newest (%s): put that, or a version it built", w.Module, fp, short(w.Newest)) + return say(puts.Refused, "the builder holds no build of %s at %s, and builds by itself only the trunk's newest (%s): put that, or a version it built", w.Module, fp, short(w.Newest)) } - return say(delivery.PutBuild, "waits for the builder's build of %s at %s, the trunk's newest, which it builds by itself", w.Module, fp) + return say(puts.Build, "waits for the builder's build of %s at %s, the trunk's newest, which it builds by itself", w.Module, fp) default: - return say(delivery.PutBuild, "waits for the builder's build of %s at %s, which is %s", w.Module, fp, w.Build) + return say(puts.Build, "waits for the builder's build of %s at %s, which is %s", w.Module, fp, w.Build) } if w.Composed == nil || !w.Granted { missing := fmt.Sprintf("no composition of %s for %s at %s is written", w.Module, w.Node, fp) @@ -90,33 +97,33 @@ func StepPut(w PutWorld) PutNext { missing = fmt.Sprintf("the grant of %s at %s does not name %s's accounts", w.Module, fp, w.Node) } since := w.Now - if p.Step == delivery.PutCompose && len(p.Steps) > 0 { + if p.Step == puts.Compose && len(p.Steps) > 0 { since = p.Steps[len(p.Steps)-1].At } if w.Now.Sub(since) > ComposeWithin { - return say(delivery.PutRefused, "%s after %s: only a composition written by the take-in of its build (or one of the five kept) is put; "+ + return say(puts.Refused, "%s after %s: only a composition written by the take-in of its build (or one of the five kept) is put; "+ "for another the hand path is mesh-hand-compose (ADR 0539 §10)", missing, ComposeWithin) } - return say(delivery.PutCompose, "waits up to %s: %s yet", ComposeWithin, missing) + return say(puts.Compose, "waits up to %s: %s yet", ComposeWithin, missing) } d := *w.Composed - if p.Step == delivery.PutWanted || p.Step == delivery.PutHolding { + if p.Step == puts.Wanted || p.Step == puts.Holding { return PutNext{Put: applied(w, p)} } p.Composed = d.Digest if needsPerson(w, d) { switch w.Answer { case OptionNo: - return say(delivery.PutRefused, "the operator answered no to putting %s at %s on %s", w.Module, fp, w.Node) + return say(puts.Refused, "the operator answered no to putting %s at %s on %s", w.Module, fp, w.Node) case OptionYes: default: if w.AskExpired { - return say(delivery.PutRefused, "the operator's ask about putting %s at %s on %s expired unanswered: the node keeps what it runs", w.Module, fp, w.Node) + return say(puts.Refused, "the operator's ask about putting %s at %s on %s expired unanswered: the node keeps what it runs", w.Module, fp, w.Node) } - next := say(delivery.PutPerson, "waits for the operator's yes to putting %s at %s on %s (composition %s, judged %s)", w.Module, fp, w.Node, short(d.Digest), d.Judgement) + next := say(puts.Person, "waits for the operator's yes to putting %s at %s on %s (composition %s, judged %s)", w.Module, fp, w.Node, short(d.Digest), d.Judgement) if p.Ask == "" { next.Ask = true - next.Put.Ask, _ = delivery.RecordAskKey(w.Node, w.Module, d.Digest) + next.Put.Ask, _ = asks.RecordAskKey(w.Node, w.Module, d.Digest) } return next } @@ -125,11 +132,11 @@ func StepPut(w PutWorld) PutNext { case w.Snapshot != "": p.Snapshot = w.Snapshot case w.SnapshotFailed != "": - return say(delivery.PutRefused, "the bus's streams were not snapshotted, so the bus is not put: %s", w.SnapshotFailed) + return say(puts.Refused, "the bus's streams were not snapshotted, so the bus is not put: %s", w.SnapshotFailed) case w.Snapshotting: - return say(delivery.PutSnapshot, "the operator said yes; waits for %s's backup holder to snapshot the bus's streams", w.Node) + return say(puts.Snapshot, "the operator said yes; waits for %s's backup holder to snapshot the bus's streams", w.Node) default: - next := say(delivery.PutSnapshot, "the operator said yes; asked %s's backup holder to snapshot the bus's streams", w.Node) + next := say(puts.Snapshot, "the operator said yes; asked %s's backup holder to snapshot the bus's streams", w.Node) next.Snapshot = true return next } @@ -142,22 +149,22 @@ func StepPut(w PutWorld) PutNext { if p.Snapshot != "" { words += "; the streams were snapshotted: " + p.Snapshot } - next := PutNext{Put: applied(w, p.Said(w.Now, delivery.PutWanted, words))} + next := PutNext{Put: applied(w, p.Said(w.Now, puts.Wanted, words))} next.Warranted = w.Bus && p.Ask != "" return next } // applied is a wanted put once the node's have says how it went: holding or done when it runs the composition healthy. -func applied(w PutWorld, p delivery.Put) delivery.Put { +func applied(w PutWorld, p puts.Put) puts.Put { h := w.Have - if h == nil || h.FP != p.FP || h.Composed != p.Composed { + if h == nil || h.Fp != p.FP || h.Composed != p.Composed { return p } switch { - case h.Phase == delivery.PhaseRunning && h.Health == delivery.HealthOK && p.Hold: - return p.Said(w.Now, delivery.PutHolding, fmt.Sprintf("%s runs %s at %s healthy; held there until released", w.Node, w.Module, short(p.FP))) - case h.Phase == delivery.PhaseRunning && h.Health == delivery.HealthOK: - return p.Said(w.Now, delivery.PutDone, fmt.Sprintf("%s runs %s at %s healthy; the rules write its want again", w.Node, w.Module, short(p.FP))) + case h.Phase == nodeengineclient.PhaseRunning && h.Health == nodeengineclient.HealthOK && p.Hold: + return p.Said(w.Now, puts.Holding, fmt.Sprintf("%s runs %s at %s healthy; held there until released", w.Node, w.Module, short(p.FP))) + case h.Phase == nodeengineclient.PhaseRunning && h.Health == nodeengineclient.HealthOK: + return p.Said(w.Now, puts.Done, fmt.Sprintf("%s runs %s at %s healthy; the rules write its want again", w.Node, w.Module, short(p.FP))) case failed(*h): return p.Said(w.Now, p.Step, fmt.Sprintf("%s runs %s at %s and its health check failed: %s", w.Node, w.Module, short(p.FP), h.Detail)) } @@ -167,8 +174,8 @@ func applied(w PutWorld, p delivery.Put) delivery.Put { // needsPerson says whether the put's move waits for the operator: any move of the bus but a revision at the fingerprint the // node runs that changes only its user list (ADR 0539 §7, §15), and a move of a module of policy record judged to recreate a // container, move data, or that cannot be judged (§14). -func needsPerson(w PutWorld, d delivery.ComposedDigest) bool { - runs := w.Have != nil && w.Have.FP == w.Put.FP +func needsPerson(w PutWorld, d ComposedDigest) bool { + runs := w.Have != nil && w.Have.Fp == w.Put.FP if runs && w.Have.Composed == d.Digest { return false // the node runs this composition already: nothing moves } @@ -178,22 +185,22 @@ func needsPerson(w PutWorld, d delivery.ComposedDigest) bool { if !w.Recorded { return false } - return d.Judgement == delivery.JudgedRecreates || d.Judgement == delivery.JudgedMovesData || d.Judgement == delivery.JudgedCannot + return d.Judgement == JudgedRecreates || d.Judgement == JudgedMovesData || d.Judgement == JudgedCannot } // PutWant is the want a put writes once it is wanted: the fingerprint and composition, marked put, and held when it holds. -func PutWant(p delivery.Put) delivery.Want { - return delivery.Want{FP: p.FP, Composed: p.Composed, Put: true, Hold: p.Hold, Words: "put: " + p.Why} +func PutWant(p puts.Put) mwc.Want { + return mwc.Want{Fp: p.FP, Composed: p.Composed, Put: true, Hold: p.Hold, Words: "put: " + p.Why} } // PutOwns says what a put does to the node's want: owns says the rules pass over it, want the want it writes (nil: what // stands stays). A put owns the want from its asking until it ends; a held put ends only when released. -func PutOwns(p delivery.Put) (want *delivery.Want, owns bool) { +func PutOwns(p puts.Put) (want *mwc.Want, owns bool) { switch p.Step { - case delivery.PutWanted, delivery.PutHolding: + case puts.Wanted, puts.Holding: w := PutWant(p) return &w, true - case delivery.PutDone, delivery.PutReleased, delivery.PutRefused: + case puts.Done, puts.Released, puts.Refused: return nil, false } return nil, true @@ -202,7 +209,7 @@ func PutOwns(p delivery.Put) (want *delivery.Want, owns bool) { // PutAsking is a put's own asking number: when it was asked, in milliseconds. Each put asked under the same key is another // asking, so the ask is written again under an id of its own and an answer to an earlier put, whose warrant names that put's // asking, never counts for a later one (ADR 0546 clause 8: one approval binds one act). -func PutAsking(p delivery.Put) int { +func PutAsking(p puts.Put) int { if n := p.At.UnixMilli(); n > 0 { return int(n) } @@ -210,43 +217,43 @@ func PutAsking(p delivery.Put) int { } // PutAct is the act a bus put's yes binds and that is performed when its want is written. -func PutAct(node, module, fp, composed string) asks.Act { - return asks.Act{"put": "want", "node": node, "module": module, "fp": fp, "composed": composed} +func PutAct(node, module, fp, composed string) occ.Act { + return occ.Act{"put": "want", "node": node, "module": module, "fp": fp, "composed": composed} } // PutAsk is the ask about a put's move (ADR 0546 clauses 7 and 8), under the record ask's key of the composition so the // teller relays it and its answer as any record ask's. For the bus its yes is of level approve and binds the act, so only a // channel that proves who answered takes it (ADR 0259), never the view. -func PutAsk(key, node, module string, p delivery.Put, d delivery.ComposedDigest, bus bool, now time.Time) (delivery.AssignerAsk, error) { - yes := asks.Option{ID: OptionYes, Label: "Yes, put it", Does: "the node is told to apply " + module + " at " + short(p.FP), Level: asks.Acknowledge} - what := map[delivery.Judgement]string{ - delivery.JudgedRecreates: "recreates a container", - delivery.JudgedMovesData: "moves the module's data", - delivery.JudgedCannot: "cannot be judged: " + d.Words, - delivery.JudgedNeither: "recreates no container and moves no data", - delivery.JudgedUnchanged: "changes nothing the node runs", +func PutAsk(key, node, module string, p puts.Put, d ComposedDigest, bus bool, now time.Time) (asks.Ask, error) { + yes := oc.Option{ID: OptionYes, Label: "Yes, put it", Does: "the node is told to apply " + module + " at " + short(p.FP), Level: occ.Acknowledge} + what := map[Judgement]string{ + JudgedRecreates: "recreates a container", + JudgedMovesData: "moves the module's data", + JudgedCannot: "cannot be judged: " + d.Words, + JudgedNeither: "recreates no container and moves no data", + JudgedUnchanged: "changes nothing the node runs", }[d.Judgement] explanation := fmt.Sprintf("Asked to put %s at version %s on %s: %s. The move %s.", module, short(p.FP), node, p.Why, what) - expires := now.Add(7 * 24 * time.Hour) + expires := occ.FormatTime(now.Add(7 * 24 * time.Hour)) if bus { - binds, err := asks.ActDigest(PutAct(node, module, p.FP, d.Digest)) + binds, err := occ.ActDigest(PutAct(node, module, p.FP, d.Digest)) if err != nil { - return delivery.AssignerAsk{}, err + return asks.Ask{}, err } - yes.Level, yes.Binds = asks.Approve, binds + yes.Level, yes.Binds = occ.Approve, binds yes.Does = "the bus's streams are snapshotted, then " + node + " is told to apply the bus at " + short(p.FP) explanation += " It is the mesh's bus: every module reconnects while it restarts, and its streams are snapshotted first." - expires = now.Add(asks.ApproveLasts) + expires = occ.FormatTime(now.Add(occ.ApproveLasts)) } - return delivery.AssignerAsk{Ask: asks.Ask{ - ID: delivery.AskingID(key, PutAsking(p)), + return assignerAsk(PutAsking(p), oc.Ask{ + ID: meshdeliveryclient.AskingID(key, PutAsking(p)), Headline: fmt.Sprintf("Put %s on %s?", module, node), Explanation: explanation, - Options: []asks.Option{yes, - {ID: OptionNo, Label: "No", Does: "the node keeps what it runs, and the put is refused", Level: asks.Acknowledge}}, - Who: asks.Operator, + Options: []oc.Option{yes, + {ID: OptionNo, Label: "No", Does: "the node keeps what it runs, and the put is refused", Level: occ.Acknowledge}}, + Who: occ.Operator, Expires: expires, OnExpiry: "the node keeps what it runs", About: key, - }, Asking: PutAsking(p)}, nil + }), nil } diff --git a/modules/mesh-delivery-assigner/internal/assign/seat.go b/modules/mesh-delivery-assigner/internal/assign/seat.go index 1ee398c1..7e7fde86 100644 --- a/modules/mesh-delivery-assigner/internal/assign/seat.go +++ b/modules/mesh-delivery-assigner/internal/assign/seat.go @@ -7,37 +7,45 @@ import ( "strings" "time" - "git.novox.be/novox/mesh-sdk/go/asks" - "git.novox.be/novox/mesh-sdk/go/delivery" + "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliveryclient" + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-assigner/asks" + + mmac "git.novox.be/novox/mesh-contracts/go/clients/meshmoduleassignerclient" + 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" + + "mesh-delivery-assigner/internal/assignment" + plc "mesh-delivery-assigner/internal/assignment" ) -// The seat mesh-placement (novox/hq ADR 0567), held by the assigner beside its wants: placement is its state -// `placement`, changed only by a proposal (§2) the nodes it touches answer (§3), judged (§4) and written on the +// The seat mesh-module-assigner (novox/hq ADR 0567), held by the assigner beside its wants: assignments is its state +// `assignments`, changed only by a proposal (§2) the nodes it touches answer (§3), judged (§4) and written on the // operator's Approve at the revision it was laid over (§5). What here decides; the tools and the passes do. -// ProposeIn is what a proposal is laid over: its id, who and why, its changes; every node's current placement and the +// ProposeIn is what a proposal is laid over: its id, who and why, its changes; every node's current assignments and the // revision of its key; which modules the indexer holds and which nodes have a record; and, for the seed, the export's // digest. type ProposeIn struct { ID, By, Why string At time.Time - Changes []delivery.PlacementProposalChange - Current map[string]delivery.Placement + Changes []mma.ProposalChange + Current map[string]mma.Assignments Revisions map[string]uint64 Held func(module string) bool Recorded func(node string) bool // Warrant is the enrolment's warrant an add-node carries (ADR 0587 §7); only that change has one. - Warrant *delivery.AddNodeWarrant + Warrant *mma.Warrant } // Propose lays a proposal over the current keys (§2): the proposal (state answering, each changed node's base revision), -// the laid placement of every node whose key it changes, and every finding, which refuses it whole: a change that does not fit its node, and what -// CheckPlacements refuses of the result laid over every placement (two modules on one host port, a provider not placed, +// the laid assignments of every node whose key it changes, and every finding, which refuses it whole: a change that does not fit its node, and what +// CheckAssignments refuses of the result laid over every assignments (two modules on one host port, a provider not placed, // a seat held once held twice, two nodes with one address, two hubs, a module the indexer does not hold, a node with no // record). A key whose value the proposal leaves as it is changes nothing and is not laid. -func Propose(in ProposeIn) (delivery.PlacementProposal, map[string]*delivery.Placement, []string) { - p := delivery.PlacementProposal{ID: in.ID, By: strings.TrimSpace(in.By), Why: strings.TrimSpace(in.Why), At: in.At.UTC(), - Changes: in.Changes, Warrant: in.Warrant, Base: map[string]uint64{}, State: delivery.ProposalAnswering} +func Propose(in ProposeIn) (mma.Proposal, map[string]*mma.Assignments, []string) { + p := mma.Proposal{ID: in.ID, By: strings.TrimSpace(in.By), Why: strings.TrimSpace(in.Why), At: in.At.UTC(), + Changes: in.Changes, Warrant: in.Warrant, Base: map[string]int64{}, State: mmac.ProposalAnswering} var findings []string if p.By == "" { findings = append(findings, "the runtime names no caller: a proposal is made by someone") @@ -49,39 +57,39 @@ func Propose(in ProposeIn) (delivery.PlacementProposal, map[string]*delivery.Pla findings = append(findings, "a proposal changes something: it names no change") } for i, c := range in.Changes { - if c.Op == delivery.OpSeed { + if c.Op == mmac.OpSeed { findings = append(findings, fmt.Sprintf("change %d: the seed is gone with adopt, the controller's export being gone", i+1)) } } - laid, lay := delivery.LayChanges(in.Current, in.Changes) + laid, lay := plc.LayChanges(in.Current, in.Changes) findings = append(findings, lay...) findings = append(findings, ControllerRefusals(in.Changes, laid)...) - all := map[string]delivery.Placement{} + all := map[string]mma.Assignments{} for node, pl := range in.Current { all[node] = pl } - out := map[string]*delivery.Placement{} + out := map[string]*mma.Assignments{} for node, pl := range laid { - if before, ok := in.Current[node]; ok && delivery.SamePlacement(before, pl) { + if before, ok := in.Current[node]; ok && mmac.SameAssignments(before, pl) { continue } pl := pl all[node] = pl out[node] = &pl - p.Base[node] = in.Revisions[node] + p.Base[node] = int64(in.Revisions[node]) } // A remove-node deletes the key of a joining node that never confirmed (the operator's decision of 2026-11-02 01:05): its - // candidate is no placement, and the checks across nodes read the mesh without it. LayChanges said why one cannot go. - for _, node := range p.RemovedNodes() { + // candidate is no assignments, and the checks across nodes read the mesh without it. LayChanges said why one cannot go. + for _, node := range plc.RemovedNodes(p) { if _, ok := in.Current[node]; !ok { continue } delete(all, node) out[node] = nil - p.Base[node] = in.Revisions[node] + p.Base[node] = int64(in.Revisions[node]) } if len(findings) == 0 && len(out) == 0 { - findings = append(findings, "the changes leave every placement as it is") + findings = append(findings, "the changes leave every assignments as it is") } held, recorded := in.Held, in.Recorded if held == nil { @@ -94,7 +102,7 @@ func Propose(in ProposeIn) (delivery.PlacementProposal, map[string]*delivery.Pla // this one caller is the exception to a node with no record (ADR 0587 §7, changing ADR 0567 §2). joining := map[string]bool{} for _, c := range in.Changes { - if c.Op == delivery.OpAddNode { + if c.Op == mmac.OpAddNode { joining[c.Node] = true } } @@ -102,7 +110,7 @@ func Propose(in ProposeIn) (delivery.PlacementProposal, map[string]*delivery.Pla was := recorded recorded = func(node string) bool { return joining[node] || was(node) } } - for _, r := range delivery.CheckPlacements(all, held, recorded) { + for _, r := range plc.CheckAssignments(all, held, recorded) { findings = append(findings, r) } return p, out, findings @@ -111,19 +119,19 @@ func Propose(in ProposeIn) (delivery.PlacementProposal, map[string]*delivery.Pla // ControllerModule is the controller's module, which nothing places again (issue 1126). const ControllerModule = "mesh-controller" -// ControllerEnded is why a placement of the controller is refused: the parts that acted only once its lease was handed +// ControllerEnded is why assignments of the controller is refused: the parts that acted only once its lease was handed // (consumers, the user list, enrolment, dead letters) now act without reading the lease, so a controller placed again // would write beside them, silently. const ControllerEnded = "the controller ended by its unassignment on 2026-11-02; placing it again would make two writers (issue 1126)" // ControllerRefusals names every change, and every node as laid, that places the controller: by a place, by a seed's -// whole placement, or in what the changes lay. None when nothing does. -func ControllerRefusals(changes []delivery.PlacementProposalChange, laid map[string]delivery.Placement) []string { +// whole assignments, or in what the changes lay. None when nothing does. +func ControllerRefusals(changes []mma.ProposalChange, laid map[string]mma.Assignments) []string { var out []string nodes := map[string]bool{} for i, c := range changes { - places := c.Module == ControllerModule && c.Op != delivery.OpTakeOff - if c.Whole != nil && slices.Contains(c.Whole.Assigned(), ControllerModule) { + places := c.Module == ControllerModule && c.Op != mmac.OpTakeOff + if c.Whole != nil && slices.Contains(mmac.Assigned(*c.Whole), ControllerModule) { places = true } if places { @@ -132,7 +140,7 @@ func ControllerRefusals(changes []delivery.PlacementProposalChange, laid map[str } } for _, node := range slices.Sorted(maps.Keys(laid)) { - if !nodes[node] && slices.Contains(laid[node].Assigned(), ControllerModule) { + if !nodes[node] && slices.Contains(mmac.Assigned(laid[node]), ControllerModule) { out = append(out, fmt.Sprintf("%s would run %s: %s", node, ControllerModule, ControllerEnded)) } } @@ -140,23 +148,23 @@ func ControllerRefusals(changes []delivery.PlacementProposalChange, laid map[str } // Write is one node a warrant writes (§5): the node, its node key `.node` (the commit of the node's keys, -// mesh-sdk delivery.PlacementKeys.Writes), the revision of the node's placement it is written at (0: it has none, so +// mesh-sdk mmac.Keys.Writes), the revision of the node's assignments it is written at (0: it has none, so // it is created) and its value (nil: it is deleted). type Write struct { Node, Key string Base uint64 - Value *delivery.Placement + Value *mma.Assignments } // Writes is every key a proposal writes, by node order. -func Writes(p delivery.PlacementProposal, laid map[string]*delivery.Placement) ([]Write, error) { +func Writes(p mma.Proposal, laid map[string]*mma.Assignments) ([]Write, error) { var out []Write - for _, node := range p.Nodes() { - key, err := delivery.PlacementNodeKey(node) + for _, node := range plc.Nodes(p) { + key, err := mmac.NodeKey(node) if err != nil { return nil, err } - out = append(out, Write{Node: node, Key: key, Base: p.Base[node], Value: laid[node]}) + out = append(out, Write{Node: node, Key: key, Base: uint64(p.Base[node]), Value: laid[node]}) } return out, nil } @@ -170,56 +178,56 @@ const ( // CallClearedCondition clears the condition of a refused controller call a previous process raised, for its pair (node and // module, or node and provision for a pin). -func CallClearedCondition(kind, node, module string) asks.Condition { - return asks.Condition{Key: delivery.AssignerModule + "." + kind + "." + node + "." + module, - Subject: asks.Scope{Scope: "module", ID: module, Machine: node}, State: asks.StateCleared, - Resolved: "the assigner calls no verb of the controller any more: placement is the one source."} +func CallClearedCondition(kind, node, module string) oc.Condition { + return oc.Condition{Key: assignment.Module + "." + kind + "." + node + "." + module, + Subject: oc.ConditionSubject{Scope: "module", ID: module, Machine: node}, State: occ.StateCleared, + Resolved: "the assigner calls no verb of the controller any more: assignments is the one source."} } -// PlacementAct is the act an approved proposal's yes binds: writing exactly its changes at its base revisions. -func PlacementAct(p delivery.PlacementProposal) asks.Act { - return asks.Act{"placement": "write", "proposal": p.ID, "changes": delivery.ChangesDigest(p)} +// AssignmentsAct is the act an approved proposal's yes binds: writing exactly its changes at its base revisions. +func AssignmentsAct(p mma.Proposal) occ.Act { + return occ.Act{"assignments": "write", "proposal": p.ID, "changes": plc.ChangesDigest(p)} } -// PlacementAsking is a proposal's asking number: each judging that raises its ask again (again, a key moved) is +// AssignmentsAsking is a proposal's asking number: each judging that raises its ask again (again, a key moved) is // another asking, under an id of its own. -func PlacementAsking(p delivery.PlacementProposal, at time.Time) int { +func AssignmentsAsking(p mma.Proposal, at time.Time) int { return int(at.Unix()) } -// PlacementAsk is the authorising ask about a proposal (§5): an Approve that binds its act, so only a channel that +// AssignmentsAsk is the authorising ask about a proposal (§5): an Approve that binds its act, so only a channel that // proves who answered takes it, with the brief of the verdict by node (judge.Brief) as its explanation. -func PlacementAsk(key string, p delivery.PlacementProposal, brief string, asking int, now time.Time) (delivery.AssignerAsk, error) { - binds, err := asks.ActDigest(PlacementAct(p)) +func AssignmentsAsk(key string, p mma.Proposal, brief string, asking int, now time.Time) (asks.Ask, error) { + binds, err := occ.ActDigest(AssignmentsAct(p)) if err != nil { - return delivery.AssignerAsk{}, err + return asks.Ask{}, err } - nodes := p.Nodes() - headline := fmt.Sprintf("Change placement on %s?", strings.Join(nodes, ", ")) - if n := len([]rune(headline)); n > asks.HeadlineLength { - headline = fmt.Sprintf("Change placement on %d machines?", len(nodes)) + nodes := plc.Nodes(p) + headline := fmt.Sprintf("Change assignments on %s?", strings.Join(nodes, ", ")) + if n := len([]rune(headline)); n > occ.HeadlineLength { + headline = fmt.Sprintf("Change assignments on %d machines?", len(nodes)) } - return delivery.AssignerAsk{Ask: asks.Ask{ - ID: delivery.AskingID(key, asking), + return assignerAsk(asking, oc.Ask{ + ID: meshdeliveryclient.AskingID(key, asking), Headline: headline, Explanation: brief, - Options: []asks.Option{ - {ID: OptionYes, Label: "Approve", Does: "the placement of " + strings.Join(nodes, ", ") + " is written as proposed", - Level: asks.Approve, Binds: binds}, - {ID: OptionNo, Label: "Decline", Does: "nothing is written, and the proposal ends", Level: asks.Acknowledge}, + Options: []oc.Option{ + {ID: OptionYes, Label: "Approve", Does: "the assignments of " + strings.Join(nodes, ", ") + " is written as proposed", + Level: occ.Approve, Binds: binds}, + {ID: OptionNo, Label: "Decline", Does: "nothing is written, and the proposal ends", Level: occ.Acknowledge}, }, - Who: asks.Operator, - Expires: now.Add(asks.ApproveLasts), + Who: occ.Operator, + Expires: occ.FormatTime(now.Add(occ.ApproveLasts)), OnExpiry: "nothing is written, and the proposal ends", About: key, - }, Asking: asking}, nil + }), nil } // Rest is what a proposal leaves to write once the keys of written are: its changes of the other nodes, to be laid again // over the keys as the write left them (§5: a key moved while it was written). False when nothing is left. -func Rest(p delivery.PlacementProposal, written map[string]*delivery.Placement) (delivery.PlacementProposal, bool) { +func Rest(p mma.Proposal, written map[string]*mma.Assignments) (mma.Proposal, bool) { rest := p - rest.Changes, rest.Base = nil, map[string]uint64{} + rest.Changes, rest.Base = nil, map[string]int64{} for _, c := range p.Changes { if _, done := written[c.Node]; !done { rest.Changes = append(rest.Changes, c) @@ -230,6 +238,6 @@ func Rest(p delivery.PlacementProposal, written map[string]*delivery.Placement) rest.Base[node] = base } } - rest.Must, rest.Verdict, rest.Ask, rest.State = nil, nil, "", delivery.ProposalAnswering + rest.Must, rest.Verdict, rest.Ask, rest.State = nil, nil, "", mmac.ProposalAnswering return rest, len(rest.Changes) > 0 } diff --git a/modules/mesh-delivery-assigner/internal/assign/seat_test.go b/modules/mesh-delivery-assigner/internal/assign/seat_test.go index cd5bcfa6..a7724b9e 100644 --- a/modules/mesh-delivery-assigner/internal/assign/seat_test.go +++ b/modules/mesh-delivery-assigner/internal/assign/seat_test.go @@ -2,67 +2,71 @@ package assign import ( "encoding/json" + "git.novox.be/novox/mesh-sdk/go/keys" "slices" "strings" "testing" "time" - "git.novox.be/novox/mesh-sdk/go/asks" - "git.novox.be/novox/mesh-sdk/go/delivery" + mmac "git.novox.be/novox/mesh-contracts/go/clients/meshmoduleassignerclient" + mma "git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner" + occ "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient" + + plc "mesh-delivery-assigner/internal/assignment" ) // ADR 0567 §2, §5, §7, §8: what the seat's holder decides of a proposal: every refusal named at once, a valid one laid // with the base revision of each key it changes, the writes at those revisions, the controller's calls that keep its // assignments in step, the authorising ask bound to the changes, and the export read as the controller answers it. -func TestThePlacementSeatDecides(t *testing.T) { - anchor := delivery.Placement{V: delivery.Version, Node: "anchor", Decided: delivery.NodeDecided{Hub: true, Address: "10.0.0.1"}, - Modules: []delivery.PlacedModule{{Module: "store", Ports: map[string]int{"5432": 5432}}, {Module: "web"}}} - laptop := delivery.Placement{V: delivery.Version, Node: "laptop", Decided: delivery.NodeDecided{Address: "10.0.0.2"}, Modules: []delivery.PlacedModule{{Module: "shell"}}} - in := func(changes ...delivery.PlacementProposalChange) ProposeIn { +func TestTheAssignmentsSeatDecides(t *testing.T) { + anchor := mma.Assignments{V: keys.Version, Node: "anchor", Decided: mma.NodeDecided{Hub: true, Address: "10.0.0.1"}, + Modules: []mma.PlacedModule{{Module: "store", Ports: map[string]int64{"5432": 5432}}, {Module: "web"}}} + laptop := mma.Assignments{V: keys.Version, Node: "laptop", Decided: mma.NodeDecided{Address: "10.0.0.2"}, Modules: []mma.PlacedModule{{Module: "shell"}}} + in := func(changes ...mma.ProposalChange) ProposeIn { return ProposeIn{ID: "p-one", By: "operator@laptop", Why: "move web", At: time.Unix(1, 0), Changes: changes, - Current: map[string]delivery.Placement{"anchor": anchor, "laptop": laptop}, Revisions: map[string]uint64{"anchor": 4, "laptop": 9}, + Current: map[string]mma.Assignments{"anchor": anchor, "laptop": laptop}, Revisions: map[string]uint64{"anchor": 4, "laptop": 9}, Held: func(m string) bool { return m != "ghost" }, Recorded: func(n string) bool { return n == "anchor" || n == "laptop" }} } - move := []delivery.PlacementProposalChange{{Op: delivery.OpTakeOff, Node: "anchor", Module: "web"}, {Op: delivery.OpPlace, Node: "laptop", Module: "web"}} + move := []mma.ProposalChange{{Op: mmac.OpTakeOff, Node: "anchor", Module: "web"}, {Op: mmac.OpPlace, Node: "laptop", Module: "web"}} p, laid, findings := Propose(in(move...)) - if len(findings) != 0 || p.State != delivery.ProposalAnswering || p.Base["anchor"] != 4 || p.Base["laptop"] != 9 || - !slices.Equal(laid["laptop"].Assigned(), []string{"shell", "web"}) || !slices.Equal(laid["anchor"].Assigned(), []string{"store"}) { + if len(findings) != 0 || p.State != mmac.ProposalAnswering || p.Base["anchor"] != 4 || p.Base["laptop"] != 9 || + !slices.Equal(mmac.Assigned(*laid["laptop"]), []string{"shell", "web"}) || !slices.Equal(mmac.Assigned(*laid["anchor"]), []string{"store"}) { t.Fatalf("a move: %+v, %+v, %v", p, laid, findings) } - if _, err := delivery.EncodePlacementProposal(p); err != nil { + if _, err := plc.EncodeProposal(p); err != nil { t.Fatalf("the proposal cannot be written: %v", err) } for name, c := range map[string]struct { in ProposeIn want string }{ - "a module not held": {in(delivery.PlacementProposalChange{Op: delivery.OpPlace, Node: "laptop", Module: "ghost"}), "the indexer holds no module ghost"}, - "a node with no record": {in(delivery.PlacementProposalChange{Op: delivery.OpPlace, Node: "stranger", Module: "web"}), "stranger is a node with no record"}, - "nothing changes": {in(delivery.PlacementProposalChange{Op: delivery.OpPlace, Node: "anchor", Module: "web"}), "leave every placement as it is"}, + "a module not held": {in(mma.ProposalChange{Op: mmac.OpPlace, Node: "laptop", Module: "ghost"}), "the indexer holds no module ghost"}, + "a node with no record": {in(mma.ProposalChange{Op: mmac.OpPlace, Node: "stranger", Module: "web"}), "stranger is a node with no record"}, + "nothing changes": {in(mma.ProposalChange{Op: mmac.OpPlace, Node: "anchor", Module: "web"}), "leave every assignments as it is"}, "no why": {func() ProposeIn { i := in(move...); i.Why = " "; return i }(), "says why"}, "no caller": {func() ProposeIn { i := in(move...); i.By = ""; return i }(), "names no caller"}, - "a seed by hand": {in(delivery.PlacementProposalChange{Op: delivery.OpSeed, Node: "laptop", Whole: &laptop}), "gone with adopt"}, + "a seed by hand": {in(mma.ProposalChange{Op: mmac.OpSeed, Node: "laptop", Whole: &laptop}), "gone with adopt"}, // 1126: the controller ended by its unassignment; placed again it would be a second writer beside the parts that // act without its lease. - "the controller placed again": {in(delivery.PlacementProposalChange{Op: delivery.OpPlace, Node: "laptop", Module: "mesh-controller"}), "ended by its unassignment"}, - "the controller seeded whole": {in(delivery.PlacementProposalChange{Op: delivery.OpSeed, Node: "anchor", Whole: &delivery.Placement{V: delivery.Version, Node: "anchor", - Decided: anchor.Decided, Modules: []delivery.PlacedModule{{Module: "mesh-controller"}}}}), "two writers (issue 1126)"}, - "a pin of nobody placed": {in(delivery.PlacementProposalChange{Op: delivery.OpPin, Node: "laptop", Provision: "db", Provider: &delivery.Provider{Node: "laptop", Module: "store"}}), "not placed there"}, + "the controller placed again": {in(mma.ProposalChange{Op: mmac.OpPlace, Node: "laptop", Module: "mesh-controller"}), "ended by its unassignment"}, + "the controller seeded whole": {in(mma.ProposalChange{Op: mmac.OpSeed, Node: "anchor", Whole: &mma.Assignments{V: keys.Version, Node: "anchor", + Decided: anchor.Decided, Modules: []mma.PlacedModule{{Module: "mesh-controller"}}}}), "two writers (issue 1126)"}, + "a pin of nobody placed": {in(mma.ProposalChange{Op: mmac.OpPin, Node: "laptop", Provision: "db", Provider: &mma.Provider{Node: "laptop", Module: "store"}}), "not placed there"}, } { if _, _, f := Propose(c.in); !slices.ContainsFunc(f, func(s string) bool { return strings.Contains(s, c.want) }) { t.Errorf("%s: findings %v, want one naming %q", name, f, c.want) } } - // Every kind of change is taken: the placement is the one source, and the controller's verbs limit nothing (ADR 0567 §8). - for _, c := range []delivery.PlacementProposalChange{ - {Op: delivery.OpConfig, Node: "anchor", Module: "web", Values: map[string]any{"puid": 1}}, - {Op: delivery.OpPlace, Node: "laptop", Module: "web", Ports: map[string]int{"80": 8080}, Bindings: map[string]delivery.Provider{"db": {Node: "anchor", Module: "store"}}}, - {Op: delivery.OpConfig, Node: "anchor", Module: "web", Layer: map[string]any{"places": []any{}}}, - {Op: delivery.OpConfig, Node: "anchor", Module: "web", MeshWide: map[string]any{"accesses": []any{}}}, - {Op: delivery.OpDecide, Node: "laptop", Field: "site", Value: json.RawMessage(`"attic"`)}, - {Op: delivery.OpHold, Node: "anchor", Module: "store", Claim: "node-shell", Scope: "node"}, + // Every kind of change is taken: the assignments is the one source, and the controller's verbs limit nothing (ADR 0567 §8). + for _, c := range []mma.ProposalChange{ + {Op: mmac.OpConfig, Node: "anchor", Module: "web", Values: map[string]any{"puid": 1}}, + {Op: mmac.OpPlace, Node: "laptop", Module: "web", Ports: map[string]int64{"80": 8080}, Bindings: map[string]mma.Provider{"db": {Node: "anchor", Module: "store"}}}, + {Op: mmac.OpConfig, Node: "anchor", Module: "web", Layer: map[string]any{"places": []any{}}}, + {Op: mmac.OpConfig, Node: "anchor", Module: "web", MeshWide: map[string]any{"accesses": []any{}}}, + {Op: mmac.OpDecide, Node: "laptop", Field: "site", Value: json.RawMessage(`"attic"`)}, + {Op: mmac.OpHold, Node: "anchor", Module: "store", Claim: "node-shell", Scope: "node"}, } { if _, _, f := Propose(in(c)); len(f) != 0 { t.Errorf("%s is refused: %v", c.Op, f) @@ -70,8 +74,8 @@ func TestThePlacementSeatDecides(t *testing.T) { } // A key moved while the warrant wrote: what is left to write is the other nodes' changes, to be laid and asked again. - if rest, ok := Rest(p, map[string]*delivery.Placement{"anchor": laid["anchor"]}); !ok || len(rest.Changes) != 1 || rest.Changes[0].Node != "laptop" || - len(rest.Base) != 1 || rest.State != delivery.ProposalAnswering || rest.Must != nil { + if rest, ok := Rest(p, map[string]*mma.Assignments{"anchor": laid["anchor"]}); !ok || len(rest.Changes) != 1 || rest.Changes[0].Node != "laptop" || + len(rest.Base) != 1 || rest.State != mmac.ProposalAnswering || rest.Must != nil { t.Errorf("the rest = %+v, %v", rest, ok) } if _, ok := Rest(p, laid); ok { @@ -79,10 +83,10 @@ func TestThePlacementSeatDecides(t *testing.T) { } // The seat says it waits while one of its states is not bound, naming it, and clears once all are. - for _, unbound := range [][]string{{delivery.PlacementPulsesBucket}, nil} { + for _, unbound := range [][]string{{plc.PulsesBucket}, nil} { c := SeatWaitsCondition(unbound) - if _, err := asks.EncodeCondition(c); err != nil || (len(unbound) > 0) != (c.State == asks.StateRaised) || - (len(unbound) > 0 && !strings.Contains(c.Explanation, "the seat waits for its states: "+delivery.PlacementPulsesBucket)) { + if _, err := occ.EncodeCondition(c); err != nil || (len(unbound) > 0) != (c.State == occ.StateRaised) || + (len(unbound) > 0 && !strings.Contains(c.Explanation, "the seat waits for its states: "+plc.PulsesBucket)) { t.Errorf("waiting for %v: %+v, %v", unbound, c, err) } } @@ -91,13 +95,13 @@ func TestThePlacementSeatDecides(t *testing.T) { if err != nil || len(writes) != 2 || writes[0].Key != "anchor.node" || writes[0].Base != 4 || writes[1].Base != 9 { t.Fatalf("writes = %+v, %v", writes, err) } - a, err := PlacementAsk("placement.p-one", p, "move web (proposed by operator@laptop)", 7, time.Unix(100, 0)) - if err != nil || a.Ask.Check(time.Unix(100, 0)) != nil || a.Ask.Options[0].Level != asks.Approve || a.Ask.Options[0].Performs(PlacementAct(p)) != nil { + a, err := AssignmentsAsk("assignments.p-one", p, "move web (proposed by operator@laptop)", 7, time.Unix(100, 0)) + if err != nil || occ.Check(mustAsk(t, a), time.Unix(100, 0)) != nil || mustAsk(t, a).Options[0].Level != occ.Approve || occ.Performs(mustAsk(t, a).Options[0], AssignmentsAct(p)) != nil { t.Fatalf("the ask = %+v, %v", a, err) } q := p - q.Changes = append(slices.Clone(p.Changes), delivery.PlacementProposalChange{Op: delivery.OpUnpin, Node: "laptop", Provision: "x"}) - if a.Ask.Options[0].Performs(PlacementAct(q)) == nil { + q.Changes = append(slices.Clone(p.Changes), mma.ProposalChange{Op: mmac.OpUnpin, Node: "laptop", Provision: "x"}) + if occ.Performs(mustAsk(t, a).Options[0], AssignmentsAct(q)) == nil { t.Error("the ask's yes binds changes it was not asked about") } } diff --git a/modules/mesh-delivery-assigner/internal/assign/wants.go b/modules/mesh-delivery-assigner/internal/assign/wants.go index 5057439e..650b2157 100644 --- a/modules/mesh-delivery-assigner/internal/assign/wants.go +++ b/modules/mesh-delivery-assigner/internal/assign/wants.go @@ -2,16 +2,22 @@ package assign import ( "fmt" + "git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient" + "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" + "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" + mwc "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshwantsclient" + "git.novox.be/novox/mesh-delivery/modules/mesh-grants/grantsstate" "maps" + "mesh-delivery-assigner/internal/puts" "slices" "sort" "strings" "time" + "git.novox.be/novox/mesh-contracts/provisioning/go/clients/meshprovisionerclient" "git.novox.be/novox/mesh-sdk/go/catalogue" - "git.novox.be/novox/mesh-sdk/go/delivery" - "git.novox.be/novox/mesh-sdk/go/provisions" + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds/forms" "git.novox.be/novox/mesh-delivery/sdk/graph" ) @@ -34,7 +40,7 @@ type Module struct { Manifest catalogue.Manifest // Provisions is the seat mesh-provisioner's provisions this module consumes on a node, by key (ADR 0570 §4): the want // waits until each is provided. Nil: the seat's state is not read (not delivered yet), and nothing waits on it. - Provisions func(node string) map[string]provisions.Provision + Provisions func(node string) map[string]meshprovisionerclient.Provision // Answering is the manifests of the modules placed somewhere that provide a provision: what catalogue.Awaited // judges a want's provisions by (issue 774). Nil: none known, and no key is waited for that is not there. Answering func(provision string) []catalogue.Manifest @@ -58,7 +64,7 @@ type Module struct { Nodes []string // Composed answers the composition of the module for a node at a fingerprint: the digest the node's own have names (a // placed node composes its own, ADR 0571 §6), and whether it names one. Nil: none exists (ADR 0539 §1, §6). - Composed func(node, fp string) (delivery.ComposedDigest, bool) + Composed func(node, fp string) (ComposedDigest, bool) // Allowed says whether the operator answered yes to the record ask about the move of this module on node to the composition // with this digest (ADR 0539 §14). Nil: none did. Allowed func(node, digest string) bool @@ -76,21 +82,25 @@ type Module struct { // Now is when this pass reads the haves: the held record names it as when the health check was seen to fail. Zero: not said. Now time.Time // Puts are the puts of the module, by node (ADR 0546): while one owns a node's want (PutOwns), the rules pass over it. - Puts map[string]delivery.Put + Puts map[string]puts.Put // Needs are the reader capabilities the manifest at FP needs (the indexer's modules value, catalogue.ManifestNeeds, ADR // 0614 §4): a node's want moves only while that node's engine states each of them. Nil: none. Needs []string - // Self says whether node composes the module itself: the catalogue's placement names the node (ADR 0547 §2). It is the one + // FormNeeds are the reference forms the module's build at FP is written in (`form:`, the builder's build key's + // needs, ADR 0618 §1): a node's want is held only while that node's engine reads none of them (ADR 0624 §2). Nil: the + // build names none, and holds nothing. + FormNeeds []string + // Self says whether node composes the module itself: the catalogue's assignments names the node (ADR 0547 §2). It is the one // source of a want's Self: every want the rules write or keep is derived from it (selfDerived), so a want written Self while - // the node was placed does not outlive the placement (issue 1126). Nil: no node does. + // the node was placed does not outlive the assignments (issue 1126). Nil: no node does. Self func(node string) bool } func (m Module) selfOn(node string) bool { return m.Self != nil && m.Self(node) } -func (m Module) composition(node, fp string) (delivery.ComposedDigest, bool) { +func (m Module) composition(node, fp string) (ComposedDigest, bool) { if m.Composed == nil { - return delivery.ComposedDigest{}, false + return ComposedDigest{}, false } return m.Composed(node, fp) } @@ -98,17 +108,17 @@ func (m Module) composition(node, fp string) (delivery.ComposedDigest, bool) { // Node is what the rules read of the nodes: whether each is online (the watchman's presence) and each module's have. type Nodes struct { Online map[string]bool - Haves map[string]map[string]delivery.Have + Haves map[string]map[string]nodeengine.HaveByNodeModuleValue // Providers is, by provision, the modules that provide it, with the nodes each is assigned to. Providers map[string]map[string][]string - // Reads is, by node, the reader capabilities its node-engine states in its node record (delivery.NodeRecord.ManifestReads, + // Reads is, by node, the reader capabilities its node-engine states in its node record (nodeengine.NodeRecord.ManifestReads, // ADR 0614 §4). A node with none, or none read, is read as stating the baseline (catalogue.StatedManifestReads). Reads map[string][]string } // healthy says whether a have runs fp healthy. -func healthy(h delivery.Have, fp string) bool { - return h.FP == fp && h.Phase == delivery.PhaseRunning && h.Health == delivery.HealthOK +func healthy(h nodeengine.HaveByNodeModuleValue, fp string) bool { + return h.Fp == fp && h.Phase == nodeengineclient.PhaseRunning && h.Health == nodeengineclient.HealthOK } // Canary is the module's canary: the first of its nodes, by name, that is online (ADR 0435 §3; one ring, ordered by @@ -137,40 +147,40 @@ func Holds(m Module, node string, n Nodes) (bool, int, string) { func holds(m Module, node string, n Nodes, ordering bool) (bool, int, string) { switch { case !m.Built: - return false, delivery.RuleBuildDone, fmt.Sprintf("the build of %s at %s is not done", m.Name, short(m.FP)) + return false, mwc.RuleBuildDone, fmt.Sprintf("the build of %s at %s is not done", m.Name, short(m.FP)) case m.Held: - return false, delivery.RuleNotHeld, fmt.Sprintf("%s is held at %s", m.Name, short(m.FP)) + return false, mwc.RuleNotHeld, fmt.Sprintf("%s is held at %s", m.Name, short(m.FP)) } if ordering { canary := Canary(m.Nodes, n.Online) switch { case canary == "": - return false, delivery.RuleCanary, fmt.Sprintf("none of the nodes of %s is online, so it has no canary", m.Name) + return false, mwc.RuleCanary, fmt.Sprintf("none of the nodes of %s is online, so it has no canary", m.Name) case node != canary && !healthy(n.Haves[canary][m.Name], m.FP): - return false, delivery.RuleCanary, fmt.Sprintf("the canary %s does not run %s healthy yet", canary, short(m.FP)) + return false, mwc.RuleCanary, fmt.Sprintf("the canary %s does not run %s healthy yet", canary, short(m.FP)) } } if !m.Granted { - return false, delivery.RuleGrant, fmt.Sprintf("the controller has not issued the credential of %s for %s", m.Name, short(m.FP)) + return false, mwc.RuleGrant, fmt.Sprintf("the controller has not issued the credential of %s for %s", m.Name, short(m.FP)) } if m.GrantedOn != nil && !m.GrantedOn(node) { - return false, delivery.RuleGrant, fmt.Sprintf("the controller has not issued the credential of %s on %s for %s", m.Name, node, + return false, mwc.RuleGrant, fmt.Sprintf("the controller has not issued the credential of %s on %s for %s", m.Name, node, short(m.FP)) } if words, lacking := m.readerLacking(node, n); lacking { - return false, delivery.RuleComposed, words + return false, mwc.RuleComposed, words } if _, ok := m.composition(node, m.FP); !ok { - return false, delivery.RuleComposed, fmt.Sprintf("%s has named no composition of its own for %s at %s yet", node, m.Name, short(m.FP)) + return false, mwc.RuleComposed, fmt.Sprintf("%s has named no composition of its own for %s at %s yet", node, m.Name, short(m.FP)) } if words, waiting := m.provisionWaiting(node); waiting { - return false, delivery.RuleProvisions, words + return false, mwc.RuleProvisions, words } if words, waiting := m.verbWaiting(node); waiting { - return false, delivery.RuleProvisions, words + return false, mwc.RuleProvisions, words } if words, waiting := m.busStateWaiting(node); waiting { - return false, delivery.RuleProvisions, words + return false, mwc.RuleProvisions, words } return true, 0, "" } @@ -179,8 +189,14 @@ func holds(m Module, node string, n Nodes, ordering bool) (bool, int, string) { // manifest needs that the engine does not state, read as the baseline when it states none. It is held under rule 7 (the // composition), since no rule of its own is numbered in this release, with words naming the capability; it moves once the // engine states it. The node-engine's own new build is held only by a word of its own manifest no engine states. +// +// A build's reference forms hold the want only when the engine reads none of them (ADR 0624 §2): every engine reads a +// first form, so a build written in one is never held by its forms; the words name the forms it reads none of. func (m Module) readerLacking(node string, n Nodes) (string, bool) { missing := catalogue.MissingManifestReads(m.Needs, n.Reads[node]) + if len(missing) == 0 && forms.Current.Held(m.FormNeeds, n.Reads[node]) { + missing = slices.Clone(m.FormNeeds) + } if len(missing) == 0 { return "", false } @@ -226,7 +242,7 @@ func (m Module) provisionWaiting(node string) (string, bool) { } } for _, key := range slices.Sorted(maps.Keys(keyed)) { - if p := keyed[key]; p.State != provisions.Provided && slices.Contains(needed, p.Provision) { + if p := keyed[key]; p.State != meshprovisionerclient.Provided && slices.Contains(needed, p.Provision) { words := fmt.Sprintf("the provision %s of %s on %s is %s, not provided", key, m.Name, node, p.State) if p.Why != "" { words += ": " + p.Why @@ -278,7 +294,7 @@ func providedHealthy(provision, node string, n Nodes) (bool, string) { if !slices.Contains(nodes, node) { at = Canary(nodes, n.Online) } - if h, ok := n.Haves[at][p]; ok && at != "" && h.Phase == delivery.PhaseRunning && h.Health == delivery.HealthOK { + if h, ok := n.Haves[at][p]; ok && at != "" && h.Phase == nodeengineclient.PhaseRunning && h.Health == nodeengineclient.HealthOK { return true, "" } } @@ -309,9 +325,9 @@ func keepsData(m catalogue.Manifest) bool { // Outcome is what one module's pass decides: each node's want to write (only those that change), and a hold to write. type Outcome struct { - Wants map[string]delivery.Want + Wants map[string]mwc.Want // Held is the hold of m.FP to write, when a node's health check failed on it and it is not held yet. - Held *delivery.Held + Held *mwc.Held // Lifted is the hold of m.FP lifted by itself: every node that runs the module runs m.FP healthy (issue 1906). The held // record and its ask are taken away, and the wants move by the rules again in this pass. Lifted bool @@ -340,7 +356,7 @@ type Outcome struct { type RecordNeed struct { Node string Digest string - Judgement delivery.Judgement + Judgement Judgement Words string } @@ -361,8 +377,8 @@ func (h Holding) Reason() string { // health check of m.FP on a node holds it: every node wanting m.FP is set back to its last healthy fingerprint, but a // module holding data is not set back but asked about (Restore). A // node that wants m but is no longer among its nodes gets its want written removed, never deleted (ADR 0408 §4). -func Wants(m Module, n Nodes, wants map[string]delivery.Want, lastHealthy map[string]string) Outcome { - out := Outcome{Wants: map[string]delivery.Want{}, Rules: map[string]Holding{}, Neither: map[string]Holding{}} +func Wants(m Module, n Nodes, wants map[string]mwc.Want, lastHealthy map[string]string) Outcome { + out := Outcome{Wants: map[string]mwc.Want{}, Rules: map[string]Holding{}, Neither: map[string]Holding{}} held := m.Held if held && !m.Kept && m.FP != "" && allRunHealthy(m, n) { out.Lifted, held = true, false @@ -370,7 +386,7 @@ func Wants(m Module, n Nodes, wants map[string]delivery.Want, lastHealthy map[st if !held && !out.Lifted && !m.Released && m.FP != "" { for _, node := range slices.Sorted(slices.Values(m.Nodes)) { h, ok := n.Haves[node][m.Name] - if !ok || h.FP != m.FP || !failed(h) { + if !ok || h.Fp != m.FP || !failed(h) { continue } if others := healthyElsewhere(m, n, node); len(others) > 0 && !failedElsewhere(m, n, node) { @@ -385,7 +401,7 @@ func Wants(m Module, n Nodes, wants map[string]delivery.Want, lastHealthy map[st out.NodeFailed[node] = NodeFailure{Others: others, Words: HeldWords(h, time.Time{})} // said once: no clock in it continue } - out.Held = &delivery.Held{Node: node, Words: HeldWords(h, m.Now)} + out.Held = &mwc.Held{Node: node, Words: HeldWords(h, m.Now)} held = true break } @@ -397,24 +413,24 @@ func Wants(m Module, n Nodes, wants map[string]delivery.Want, lastHealthy map[st break } before, had := wants[node] - var next delivery.Want + var next mwc.Want p, put := m.Puts[node] if !put && had && before.Put && before.Hold { // The want is the truth (ADR 0546 clause 5): a want marked put and held stands while its put is not heard (the // puts not open, or not yet handed after a start); only a put heard released gives it back to the rules. A put // without hold is not kept this way: its put takes it further once heard. - p, put = delivery.Put{FP: before.FP, Composed: before.Composed, Hold: true, Why: strings.TrimPrefix(before.Words, "put: "), - Step: delivery.PutHolding}, true + p, put = puts.Put{FP: before.Fp, Composed: before.Composed, Hold: true, Why: strings.TrimPrefix(before.Words, "put: "), + Step: puts.Holding}, true } if put { if want, owns := PutOwns(p); owns { if want != nil { w := *want // A held bus still takes a revision of its composition that changes only its user list (ADR 0539 §7). - if ul, ok := m.userListRevision(node, before, had, n); ok && ul.FP == w.FP { + if ul, ok := m.userListRevision(node, before, had, n); ok && ul.Fp == w.Fp { w.Composed = ul.Composed } - if !had || !delivery.SameWant(normal(before), normal(w)) { + if !had || !mwc.SameWant(normal(before), normal(w)) { out.Wants[node] = w } } @@ -425,29 +441,29 @@ func Wants(m Module, n Nodes, wants map[string]delivery.Want, lastHealthy map[st // node runs, outside the rules, the record hold and the bar on a new fingerprint: a new module's credential must reach the // bus before the module can connect (ADR 0539 §7). The controller marks it; whatever it judges of the file's mount stands. if w, ok := m.userListRevision(node, before, had, n); ok { - if !had || !delivery.SameWant(normal(before), normal(w)) { + if !had || !mwc.SameWant(normal(before), normal(w)) { out.Wants[node] = w } continue } ok, rule, words := Holds(mm, node, n) target := m.FP - if have, ran := n.Haves[node][m.Name]; !ok && (!had || before.Removed) && rule == delivery.RuleCanary { + if have, ran := n.Haves[node][m.Name]; !ok && (!had || before.Removed) && rule == mwc.RuleCanary { // The canary orders a move, never the first want of a node that does not run the module (issue 1960: de-spiegel, // link2pay and photos on novox were left with no want). A node that runs an older fingerprint of the new form, // composed and granted, gets its first want at what it runs, and rule 3 then orders its move to F as from any want. // A node that runs an old-form build, which no want can name, stays under the canary (issue 1945, a design limit): // its first want names F once the canary runs F healthy. Any other node that runs it (F itself, or an older // fingerprint with no composition or grant) gets F by the rules without which there is nothing to want. - older := ran && runs(have) && have.FP != m.FP && strings.HasPrefix(have.FP, delivery.SourcePrefix2) + older := ran && runs(have) && have.Fp != m.FP && strings.HasPrefix(have.Fp, meshcatalogclient.SourcePrefix2) if older { - _, composed := m.composition(node, have.FP) - older = composed && m.GrantedAt != nil && m.GrantedAt(have.FP) + _, composed := m.composition(node, have.Fp) + older = composed && m.GrantedAt != nil && m.GrantedAt(have.Fp) } - oldForm := ran && runs(have) && have.FP != m.FP && !strings.HasPrefix(have.FP, delivery.SourcePrefix2) + oldForm := ran && runs(have) && have.Fp != m.FP && !strings.HasPrefix(have.Fp, meshcatalogclient.SourcePrefix2) switch { case older: - ok, target = true, have.FP + ok, target = true, have.Fp case oldForm: // held back by rule 3 as written: on neither list until the canary runs F healthy default: @@ -460,40 +476,40 @@ func Wants(m Module, n Nodes, wants map[string]delivery.Want, lastHealthy map[st } out.Unreached[node] = why } - if ok && target == m.FP && len(out.NodeFailed) > 0 && !(had && !before.Removed && before.FP == m.FP) && - n.Haves[node][m.Name].FP != m.FP { + if ok && target == m.FP && len(out.NodeFailed) > 0 && !(had && !before.Removed && before.Fp == m.FP) && + n.Haves[node][m.Name].Fp != m.FP { // While a node fails F alone, F reaches no further node: a node that neither wants nor runs F gets no new want // naming it, and wants already written stay (ADR 0557 §2). A node that runs F with no live want gets its want at F, or // it would be on neither list and its node-engine would give the module up (issue 1945). - ok, rule, words = false, delivery.RuleNotHeld, FailedAloneWords(m.Name, m.FP, slices.Sorted(maps.Keys(out.NodeFailed))) + ok, rule, words = false, mwc.RuleNotHeld, FailedAloneWords(m.Name, m.FP, slices.Sorted(maps.Keys(out.NodeFailed))) } switch { case ok: d, _ := m.composition(node, target) - next = delivery.Want{FP: target, Composed: d.Digest} + next = mwc.Want{Fp: target, Composed: d.Digest} at := m if target != m.FP { // the first want at the older fingerprint the node runs: held back from F by the rule, and judged as a want at it - next.Newest, next.Rule, next.Words = m.FP, rule, words + next.Newest, next.Rule, next.Words = m.FP, int64(rule), words at.FP = target } // A placed node's want names the build alone: it is the same target while the build is. - if had && !before.Removed && !before.Put && before.FP == target && (before.Composed == d.Digest || (before.Self && m.selfOn(node))) { + if had && !before.Removed && !before.Put && before.Fp == target && (before.Composed == d.Digest || (before.Self && m.selfOn(node))) { next.Backup, next.Words = before.Backup, before.Words // the answer to a failed backup stays while its target stays (ADR 0539 §16) } running, ran := n.Haves[node][m.Name] // A removed want is no want: a first want after it goes through the record ask as a first want does. asked, hadLive := before, had if before.Removed { - asked, hadLive = delivery.Want{}, false + asked, hadLive = mwc.Want{}, false } - if need, wait := at.mustAsk(node, asked, hadLive, ran, running.FP, running.Composed == "", d); wait { + if need, wait := at.mustAsk(node, asked, hadLive, ran, running.Fp, running.Composed == "", d); wait { if need != nil { out.Record = append(out.Record, *need) } continue } - case held && had && before.FP == m.FP: + case held && had && before.Fp == m.FP: out.Rules[node] = Holding{Rule: rule, Words: words} last := lastHealthy[node] switch { @@ -511,7 +527,7 @@ func Wants(m Module, n Nodes, wants map[string]delivery.Want, lastHealthy map[st next.Words = fmt.Sprintf("%s is held at %s, and the composition of %s at %s is gone, so %s is not set back", m.Name, short(m.FP), m.Name, short(last), node) break } - next = delivery.Want{FP: last, Composed: d.Digest, Newest: m.FP, Rule: rule, Words: words} + next = mwc.Want{Fp: last, Composed: d.Digest, Newest: m.FP, Rule: int64(rule), Words: words} } default: out.Rules[node] = Holding{Rule: rule, Words: words} @@ -522,31 +538,31 @@ func Wants(m Module, n Nodes, wants map[string]delivery.Want, lastHealthy map[st // list (issue 1945: systemd-resolved, held on shanks by the operator, was said as urgent every hour). h, there := n.Haves[node][m.Name] switch { - case there && h.Phase != delivery.PhaseRemoved && !held: + case there && h.Phase != nodeengineclient.PhaseRemoved && !held: out.Neither[node] = Holding{Rule: rule, Words: words} - case had && (!there || h.Phase == delivery.PhaseRemoved): + case had && (!there || h.Phase == nodeengineclient.PhaseRemoved): // A removed want of a node that does not run the module says why no want follows it (issue 1945), so the view's // assigner_want answers it; it stays removed. A node with no key gets none: a removed want written where none was // would have its node agent remove the module and stop holding its composition's resources back. - next = delivery.Want{Removed: true, Words: NoWantWords(rule, words)} - if !delivery.SameWant(normal(before), normal(next)) { + next = mwc.Want{Removed: true, Words: NoWantWords(rule, words)} + if !mwc.SameWant(normal(before), normal(next)) { out.Wants[node] = next } } continue } - next = delivery.Want{FP: before.FP, Composed: before.Composed, Newest: m.FP, Rule: rule, Words: words} - if before.FP == m.FP { + next = mwc.Want{Fp: before.Fp, Composed: before.Composed, Newest: m.FP, Rule: int64(rule), Words: words} + if before.Fp == m.FP { // Held at F by the rules: a new revision of F's composition waits for them, as a new fingerprint does (ADR 0539 §5). - next = delivery.Want{FP: m.FP, Composed: before.Composed} - } else if d, there := m.composition(node, before.FP); there && d.Digest != "" && d.Digest != before.Composed { + next = mwc.Want{Fp: m.FP, Composed: before.Composed} + } else if d, there := m.composition(node, before.Fp); there && d.Digest != "" && d.Digest != before.Composed { // Held back at an earlier fingerprint whose kept composition the controller composed again with today's config // (novox/hq ADR 0545): the want follows its new revision, so the node does not run that build with the config of // the moment it was made (on g14 a capacity of 0 after a rollback). Asked as any move of a record module is. at := m - at.FP = before.FP + at.FP = before.Fp running, ran := n.Haves[node][m.Name] - need, wait := at.mustAsk(node, before, true, ran, running.FP, running.Composed == "", d) + need, wait := at.mustAsk(node, before, true, ran, running.Fp, running.Composed == "", d) if need != nil { out.Record = append(out.Record, *need) } @@ -559,26 +575,26 @@ func Wants(m Module, n Nodes, wants map[string]delivery.Want, lastHealthy map[st if !write { continue } - if !had || !delivery.SameWant(normal(before), normal(next)) { + if !had || !mwc.SameWant(normal(before), normal(next)) { out.Wants[node] = next } } for node, before := range wants { if !slices.Contains(m.Nodes, node) && !before.Removed { - out.Wants[node] = delivery.Want{Removed: true, Words: fmt.Sprintf("%s is no longer assigned to %s", m.Name, node)} + out.Wants[node] = mwc.Want{Removed: true, Words: fmt.Sprintf("%s is no longer assigned to %s", m.Name, node)} } } return out } -// selfDerived is next with its Self derived from the placement now (Module.Self), whichever rule made it, and whether it is +// selfDerived is next with its Self derived from the assignments now (Module.Self), whichever rule made it, and whether it is // written. A placed node's want names the build alone (ADR 0547 §2). An unplaced node's want that a rule kept from the want // written while the node was placed (held at F, held back by rule 3 or 5, a data module asked about) names the controller's // composition of the same fingerprint, asked as any move of a record module is (ADR 0539 §14): on 2026-10-27, after the -// placement seed's revert, g14's systemd-resolved (held) and forticlient (rule 5), and shanks's forticlient (rule 3), kept +// assignments seed's revert, g14's systemd-resolved (held) and forticlient (rule 5), and shanks's forticlient (rule 3), kept // their Self wants, which their node agents, no longer placed, could not compose (issue 1126). With no such composition, or // while its ask waits, the want written stays and the node's holding says why. -func (m Module) selfDerived(node string, before delivery.Want, had bool, next delivery.Want, n Nodes, out *Outcome) (delivery.Want, bool) { +func (m Module) selfDerived(node string, before mwc.Want, had bool, next mwc.Want, n Nodes, out *Outcome) (mwc.Want, bool) { switch { case next.Removed: return next, true @@ -589,16 +605,16 @@ func (m Module) selfDerived(node string, before delivery.Want, had bool, next de return next, true } next.Self, next.Yes = false, "" - d, there := m.composition(node, next.FP) + d, there := m.composition(node, next.Fp) if !there || d.Digest == "" { - out.Rules[node] = Holding{Rule: delivery.RuleComposed, Words: fmt.Sprintf("%s on %s was composed by the node while it was placed, "+ - "and no composition of its own is named at %s now, so its want stays", m.Name, node, short(next.FP))} + out.Rules[node] = Holding{Rule: mwc.RuleComposed, Words: fmt.Sprintf("%s on %s was composed by the node while it was placed, "+ + "and no composition of its own is named at %s now, so its want stays", m.Name, node, short(next.Fp))} return next, false } at := m - at.FP = next.FP + at.FP = next.Fp running, ran := n.Haves[node][m.Name] - if need, wait := at.mustAsk(node, before, had, ran, running.FP, running.Composed == "", d); wait { + if need, wait := at.mustAsk(node, before, had, ran, running.Fp, running.Composed == "", d); wait { if need != nil && !slices.Contains(out.Record, *need) { // the branch held back at an older fingerprint may have asked it out.Record = append(out.Record, *need) } @@ -618,8 +634,8 @@ func NoWantWords(rule int, words string) string { // backs up, applies or checks is a step of the move, not its outcome (on novox at 15:27:36 on 2026-10-25, "health failed, phase // applying" held the controller's canary two seconds before the new build was healthy). Every healthy read (healthy, providedHealthy, // the world's lastHealthy) already asks for phase running. -func failed(h delivery.Have) bool { - return h.Health == delivery.HealthFailed && (h.Phase == delivery.PhaseRunning || h.Phase == delivery.PhaseFailed) +func failed(h nodeengine.HaveByNodeModuleValue) bool { + return h.Health == nodeengineclient.HealthFailed && (h.Phase == nodeengineclient.PhaseRunning || h.Phase == nodeengineclient.PhaseFailed) } // healthyElsewhere is every online node of m but node that runs m.FP healthy, by name. @@ -639,7 +655,7 @@ func healthyElsewhere(m Module, n Nodes, node string) []string { // the hold and the set-back stand at the second failure (ADR 0557 §2). func failedElsewhere(m Module, n Nodes, node string) bool { for _, other := range m.Nodes { - if h, ok := n.Haves[other][m.Name]; ok && other != node && h.FP == m.FP && failed(h) { + if h, ok := n.Haves[other][m.Name]; ok && other != node && h.Fp == m.FP && failed(h) { return true } } @@ -660,8 +676,8 @@ type NodeFailure struct { } // runs says whether a have is of a module the node runs: running, whatever its health. -func runs(h delivery.Have) bool { - return h.Phase == delivery.PhaseRunning +func runs(h nodeengine.HaveByNodeModuleValue) bool { + return h.Phase == nodeengineclient.PhaseRunning } // allRunHealthy says whether every node of m that runs the module (its have's phase is running, or failed) runs m.FP healthy, and @@ -673,7 +689,7 @@ func allRunHealthy(m Module, n Nodes) bool { ran := 0 for _, node := range m.Nodes { h, ok := n.Haves[node][m.Name] - if !ok || (h.Phase != delivery.PhaseRunning && h.Phase != delivery.PhaseFailed) { + if !ok || (h.Phase != nodeengineclient.PhaseRunning && h.Phase != nodeengineclient.PhaseFailed) { continue } if !healthy(h, m.FP) { @@ -686,7 +702,7 @@ func allRunHealthy(m Module, n Nodes) bool { // HeldWords are the words a hold keeps of its cause (issue 1906): the health and phase the node reported, when the assigner saw // it, and the node's own words. The node is the held record's own field. -func HeldWords(h delivery.Have, at time.Time) string { +func HeldWords(h nodeengine.HaveByNodeModuleValue, at time.Time) string { words := fmt.Sprintf("health %s, phase %s", h.Health, h.Phase) if !at.IsZero() { words += ", seen at " + at.UTC().Format(time.RFC3339) @@ -703,13 +719,13 @@ func LiftedWords(module, fp string) string { } // normal is a want without its version, as a decoded and a composed one are compared. -func normal(w delivery.Want) delivery.Want { +func normal(w mwc.Want) mwc.Want { w.V = 0 return w } func short(fp string) string { - part, err := delivery.FingerprintKeyPart(fp) + part, err := meshcatalogclient.KeyPart(fp) if err != nil || len(part) < 12 { return fp } @@ -720,18 +736,18 @@ func short(fp string) string { // module the node never ran (it has no want and no have), a move judged neither, a module that is not of policy record and a want // that stays are written as any want. The bus module is no exception: it moves by the rules (ADR 0601 §1), its node backing up its // streams before it applies (§2); the assigner asks no snapshot for it. -func (m Module) mustAsk(node string, before delivery.Want, had, ran bool, running string, haveNamesNoComposition bool, d delivery.ComposedDigest) (*RecordNeed, bool) { - if (!had && !ran) || before.Removed || (before.FP == m.FP && before.Composed == d.Digest) { +func (m Module) mustAsk(node string, before mwc.Want, had, ran bool, running string, haveNamesNoComposition bool, d ComposedDigest) (*RecordNeed, bool) { + if (!had && !ran) || before.Removed || (before.Fp == m.FP && before.Composed == d.Digest) { return nil, false } // Right after the switch no node has a readable want, and a have from the old node-engine names a fingerprint and no composition // (ADR 0539 §9, §11): a node running this fingerprint is given its want at it with no hold when the controller judges the composition // unchanged, equal to the declaration the node last applied (ADR 0540 §9), since the node agent then applies nothing. A composition that // differs from it (a changed setting or secret), or that the controller could not judge, waits for the ask. A new fingerprint still waits. - if !had && ran && running == m.FP && haveNamesNoComposition && d.Judgement == delivery.JudgedUnchanged { + if !had && ran && running == m.FP && haveNamesNoComposition && d.Judgement == JudgedUnchanged { return nil, false } - if !m.recorded(d) || d.Judgement == delivery.JudgedNeither || d.Judgement == delivery.JudgedUnchanged { + if !m.recorded(d) || d.Judgement == JudgedNeither || d.Judgement == JudgedUnchanged { return nil, false } if m.Allowed != nil && m.Allowed(node, d.Digest) { @@ -743,13 +759,13 @@ func (m Module) mustAsk(node string, before delivery.Want, had, ran bool, runnin // recorded says whether the module is of policy record: a person's choice at the assigner over the manifest's word (ADR // 0565 §7, catalogue.Upgrade); for a module with none, as the controller holds it beside the composition's digest until the // lease write (ADR 0539 §14); on a composition made by hand, which carries none, as the manifest derives it. -func (m Module) recorded(d delivery.ComposedDigest) bool { +func (m Module) recorded(d ComposedDigest) bool { if m.Chosen != nil { policy, _, _, _ := catalogue.Upgrade(m.Manifest, m.Chosen) return policy == catalogue.PolicyRecord } if d.Policy != "" { - return d.Policy == delivery.PolicyRecord + return d.Policy == catalogue.PolicyRecord } policy, _, _ := catalogue.DerivedUpgrade(m.Manifest) return policy == catalogue.PolicyRecord @@ -758,19 +774,19 @@ func (m Module) recorded(d delivery.ComposedDigest) bool { // userListRevision is the want that names the bus module's composition at the fingerprint the node runs when that composition // differs from the one the node runs only in the bus's user list (ComposedDigest.UserListOnly), and whether there is one. A node // whose want names another fingerprint than the one it runs, or a module that does not provide the bus, has none. -func (m Module) userListRevision(node string, before delivery.Want, had bool, n Nodes) (delivery.Want, bool) { +func (m Module) userListRevision(node string, before mwc.Want, had bool, n Nodes) (mwc.Want, bool) { if !catalogue.ProvidesBus(m.Manifest) { - return delivery.Want{}, false + return mwc.Want{}, false } running, ran := n.Haves[node][m.Name] - if !ran || running.FP == "" || (had && (before.Removed || before.FP != running.FP)) { - return delivery.Want{}, false + if !ran || running.Fp == "" || (had && (before.Removed || before.Fp != running.Fp)) { + return mwc.Want{}, false } - d, ok := m.composition(node, running.FP) + d, ok := m.composition(node, running.Fp) if !ok || !d.UserListOnly { - return delivery.Want{}, false + return mwc.Want{}, false } - w := delivery.Want{FP: running.FP, Composed: d.Digest} + w := mwc.Want{Fp: running.Fp, Composed: d.Digest} if had && before.Composed == d.Digest { w.Backup, w.Words = before.Backup, before.Words } @@ -779,6 +795,6 @@ func (m Module) userListRevision(node string, before delivery.Want, had bool, n // GrantNamesNode says whether a grant of module names its accounts on node (mesh-sdk's Grant.NamesNode, which a // put reads by too). -func GrantNamesNode(g delivery.Grant, node, module string) bool { +func GrantNamesNode(g grantsstate.Grant, node, module string) bool { return g.NamesNode(node, module) } diff --git a/modules/mesh-delivery-assigner/internal/assign/wants_test.go b/modules/mesh-delivery-assigner/internal/assign/wants_test.go index 5e61414d..4707f6d7 100644 --- a/modules/mesh-delivery-assigner/internal/assign/wants_test.go +++ b/modules/mesh-delivery-assigner/internal/assign/wants_test.go @@ -1,19 +1,26 @@ package assign import ( + "git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient" + "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" + "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" + mwc "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshwantsclient" + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-assigner/asks" + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds" + "git.novox.be/novox/mesh-delivery/modules/mesh-grants/grantsstate" + "mesh-delivery-assigner/internal/puts" "slices" "strings" "testing" "time" + "git.novox.be/novox/mesh-contracts/provisioning/go/clients/meshprovisionerclient" "git.novox.be/novox/mesh-sdk/go/catalogue" - "git.novox.be/novox/mesh-sdk/go/delivery" - "git.novox.be/novox/mesh-sdk/go/provisions" "git.novox.be/novox/mesh-delivery/sdk/graph" ) -func fpOf(c byte) string { return delivery.SourcePrefix2 + strings.Repeat(string(c), 64) } +func fpOf(c byte) string { return meshcatalogclient.SourcePrefix2 + strings.Repeat(string(c), 64) } var ( fNew = fpOf('b') @@ -27,9 +34,9 @@ func ready() (Module, Nodes) { Manifest: catalogue.Manifest{Module: "mail", Requires: []string{"database"}}} n := Nodes{ Online: map[string]bool{"ace": true, "g14": true}, - Haves: map[string]map[string]delivery.Have{ - "ace": {"mail": {FP: fNew, Phase: delivery.PhaseRunning, Health: delivery.HealthOK}, "store": {FP: fpOf('s'), Phase: delivery.PhaseRunning, Health: delivery.HealthOK}}, - "g14": {"mail": {FP: fOld, Phase: delivery.PhaseRunning, Health: delivery.HealthOK}}, + Haves: map[string]map[string]nodeengine.HaveByNodeModuleValue{ + "ace": {"mail": {Fp: fNew, Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthOK}, "store": {Fp: fpOf('s'), Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthOK}}, + "g14": {"mail": {Fp: fOld, Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthOK}}, }, Providers: map[string]map[string][]string{"database": {"store": {"ace"}}}, } @@ -40,12 +47,12 @@ func ready() (Module, Nodes) { func dig(fp string) string { return "sha256:" + strings.Repeat(fp[len(fp)-1:], 64) } // composedEverywhere is a Module.Composed with a composition for every node at every fingerprint, judged neither. -func composedEverywhere(_, fp string) (delivery.ComposedDigest, bool) { - return delivery.ComposedDigest{Digest: dig(fp), Judgement: delivery.JudgedNeither}, true +func composedEverywhere(_, fp string) (ComposedDigest, bool) { + return ComposedDigest{Digest: dig(fp), Judgement: JudgedNeither}, true } -func wantedOld() map[string]delivery.Want { - return map[string]delivery.Want{"ace": {FP: fOld, Composed: dig(fOld)}, "g14": {FP: fOld, Composed: dig(fOld)}} +func wantedOld() map[string]mwc.Want { + return map[string]mwc.Want{"ace": {Fp: fOld, Composed: dig(fOld)}, "g14": {Fp: fOld, Composed: dig(fOld)}} } // 1297/B4: with every rule holding, both nodes' wants move to the new fingerprint (the canary ace runs it healthy, so @@ -54,7 +61,7 @@ func TestEveryRuleHoldsSoTheWantsMove(t *testing.T) { m, n := ready() out := Wants(m, n, wantedOld(), nil) for _, node := range []string{"ace", "g14"} { - if out.Wants[node].FP != fNew || out.Wants[node].Rule != 0 || out.Wants[node].Composed != dig(fNew) { + if out.Wants[node].Fp != fNew || out.Wants[node].Rule != 0 || out.Wants[node].Composed != dig(fNew) { t.Errorf("%s's want = %+v, want %s", node, out.Wants[node], fNew) } } @@ -63,46 +70,46 @@ func TestEveryRuleHoldsSoTheWantsMove(t *testing.T) { // 1297/B4, one test per rule: given every rule but N holds, no want moves to F, and the rule N is named. func TestEachRuleHoldsTheWantBack(t *testing.T) { for rule, breakIt := range map[int]func(*Module, *Nodes){ - delivery.RuleBuildDone: func(m *Module, _ *Nodes) { m.Built = false }, - delivery.RuleNotHeld: func(m *Module, _ *Nodes) { m.Held = true }, - delivery.RuleCanary: func(_ *Module, n *Nodes) { - n.Haves["ace"]["mail"] = delivery.Have{FP: fOld, Phase: delivery.PhaseRunning, Health: delivery.HealthOK} + mwc.RuleBuildDone: func(m *Module, _ *Nodes) { m.Built = false }, + mwc.RuleNotHeld: func(m *Module, _ *Nodes) { m.Held = true }, + mwc.RuleCanary: func(_ *Module, n *Nodes) { + n.Haves["ace"]["mail"] = nodeengine.HaveByNodeModuleValue{Fp: fOld, Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthOK} }, - delivery.RuleGrant: func(m *Module, _ *Nodes) { m.Granted = false }, - delivery.RuleComposed: func(m *Module, _ *Nodes) { - m.Composed = func(string, string) (delivery.ComposedDigest, bool) { return delivery.ComposedDigest{}, false } + mwc.RuleGrant: func(m *Module, _ *Nodes) { m.Granted = false }, + mwc.RuleComposed: func(m *Module, _ *Nodes) { + m.Composed = func(string, string) (ComposedDigest, bool) { return ComposedDigest{}, false } }, // 1954/D1 (ADR 0570 §4): a provision of the module on that node still asked holds its want. - delivery.RuleProvisions: func(m *Module, _ *Nodes) { - m.Provisions = func(node string) map[string]provisions.Provision { - return map[string]provisions.Provision{node + ".mail.database": {Provision: "database", State: provisions.Asked}, node + ".mail.oidc": {Provision: "oidc", State: provisions.Provided}} + mwc.RuleProvisions: func(m *Module, _ *Nodes) { + m.Provisions = func(node string) map[string]meshprovisionerclient.Provision { + return map[string]meshprovisionerclient.Provision{node + ".mail.database": {Provision: "database", State: meshprovisionerclient.Asked}, node + ".mail.oidc": {Provision: "oidc", State: meshprovisionerclient.Provided}} } }, } { m, n := ready() breakIt(&m, &n) out := Wants(m, n, wantedOld(), nil) - if w, ok := out.Wants["g14"]; ok && w.FP == fNew { + if w, ok := out.Wants["g14"]; ok && w.Fp == fNew { t.Errorf("rule %d broken: g14's want moved to F", rule) } if got := out.Rules["g14"].Rule; got != rule { t.Errorf("rule %d broken: g14 is held back by rule %d (%s)", rule, got, out.Rules["g14"].Words) } - if w := out.Wants["g14"]; w.FP != "" && (w.FP != fOld || w.Newest != fNew || w.Rule != rule) { + if w := out.Wants["g14"]; w.Fp != "" && (w.Fp != fOld || w.Newest != fNew || w.Rule != int64(rule)) { t.Errorf("rule %d broken: g14's want is %+v", rule, w) } - if rule == delivery.RuleProvisions && (!strings.Contains(out.Rules["g14"].Words, "g14.mail.database") || !strings.Contains(out.Rules["g14"].Words, "asked")) { + if rule == mwc.RuleProvisions && (!strings.Contains(out.Rules["g14"].Words, "g14.mail.database") || !strings.Contains(out.Rules["g14"].Words, "asked")) { t.Errorf("the provision's hold does not name its key and state: %s", out.Rules["g14"].Words) } } // 1954/D1: every provision provided, the want moves; with the seat's state not read, nothing waits on it. - for _, all := range []func(string) map[string]provisions.Provision{ - func(node string) map[string]provisions.Provision { - return map[string]provisions.Provision{node + ".mail.database": {Provision: "database", State: provisions.Provided}} + for _, all := range []func(string) map[string]meshprovisionerclient.Provision{ + func(node string) map[string]meshprovisionerclient.Provision { + return map[string]meshprovisionerclient.Provision{node + ".mail.database": {Provision: "database", State: meshprovisionerclient.Provided}} }, nil} { m, n := ready() m.Provisions = all - if out := Wants(m, n, wantedOld(), nil); out.Wants["g14"].FP != fNew { + if out := Wants(m, n, wantedOld(), nil); out.Wants["g14"].Fp != fNew { t.Errorf("provided provisions held g14's want: %+v (%s)", out.Wants["g14"], out.Rules["g14"].Words) } } @@ -122,25 +129,37 @@ func TestEachRuleHoldsTheWantBack(t *testing.T) { needs []string reads map[string][]string held bool + // forms are the build's reference forms (ADR 0624 §2), and names what the hold's words name. + forms []string + names string }{ - {"a word the baseline lacks, on engines stating nothing", "mail", []string{"word:module", "word:tool-runner"}, nil, true}, + {"a word the baseline lacks, on engines stating nothing", "mail", []string{"word:module", "word:tool-runner"}, nil, true, nil, "word:tool-runner"}, {"the same, once both engines state it", "mail", []string{"word:module", "word:tool-runner"}, - map[string][]string{"ace": catalogue.ManifestReads(), "g14": catalogue.ManifestReads()}, false}, - {"the baseline's words on engines stating nothing", "mail", []string{"word:claims.serves", "seat:mesh-catalog", "form:config"}, nil, false}, - {"the node-engine's new build, whose contract has more words than its manifest uses", "mesh-host", hostNeeds, nil, false}, + map[string][]string{"ace": catalogue.ManifestReads(), "g14": catalogue.ManifestReads()}, false, nil, ""}, + {"the baseline's words on engines stating nothing", "mail", []string{"word:claims.serves", "seat:mesh-catalog", "form:config"}, nil, false, nil, ""}, + {"the node-engine's new build, whose contract has more words than its manifest uses", "mesh-host", hostNeeds, nil, false, nil, ""}, + // ADR 0624 §2, with form:new a form no engine reads unless it states it and artifact-store the first form. + {"g14 back stating {old}, a build carrying {old,new}: moves", "mail", nil, map[string][]string{"g14": {"word:module"}}, false, + []string{"form:artifact-store", "form:new"}, ""}, + {"an engine stating nothing, a build carrying the baseline's form: moves", "mail", nil, nil, false, []string{"form:artifact-store"}, ""}, + {"a build carrying {new} only: held, naming the form", "mail", nil, map[string][]string{"g14": {"word:module"}}, true, + []string{"form:new"}, "form:new"}, + {"the same, once g14's engine states it", "mail", nil, map[string][]string{"g14": {"form:new"}}, false, []string{"form:new"}, ""}, + {"a manifest needing an unstated word, its build in the first form: held, naming the word", "mail", []string{"word:tool-runner"}, nil, true, + []string{"form:artifact-store"}, "word:tool-runner"}, } { m, n := ready() - m.Name, m.Needs, n.Reads = c.name, c.needs, c.reads + m.Name, m.Needs, n.Reads, m.FormNeeds = c.name, c.needs, c.reads, c.forms n.Haves["ace"][c.name], n.Haves["g14"][c.name] = n.Haves["ace"]["mail"], n.Haves["g14"]["mail"] out := Wants(m, n, wantedOld(), nil) - if moved := out.Wants["g14"].FP == fNew; moved == c.held { + if moved := out.Wants["g14"].Fp == fNew; moved == c.held { t.Errorf("%s: g14's want %+v, held by %+v", c.why, out.Wants["g14"], out.Rules["g14"]) } - if r := out.Rules["g14"]; c.held && (r.Rule != delivery.RuleComposed || !strings.Contains(r.Words, "word:tool-runner") || + if r := out.Rules["g14"]; c.held && (r.Rule != mwc.RuleComposed || !strings.Contains(r.Words, c.names) || !strings.Contains(r.Words, "node-engine of g14")) { t.Errorf("%s: held by %+v, want rule 7 naming the capability", c.why, r) } - if w := out.Wants["g14"]; c.held && (w.Rule != delivery.RuleComposed || w.Newest != fNew) { + if w := out.Wants["g14"]; c.held && (w.Rule != mwc.RuleComposed || w.Newest != fNew) { t.Errorf("%s: g14's want %+v, want it kept at what it runs and naming rule 7", c.why, w) } } @@ -181,12 +200,12 @@ func TestEachRuleHoldsTheWantBack(t *testing.T) { } out := Wants(m, n, wantedOld(), nil) if c.holds == "" { - if out.Wants["g14"].FP != fNew { + if out.Wants["g14"].Fp != fNew { t.Errorf("%s: held %+v", c.name, out.Rules["g14"]) } continue } - if out.Wants["g14"].FP == fNew || out.Rules["g14"].Rule != delivery.RuleProvisions || out.Rules["g14"].Words != words { + if out.Wants["g14"].Fp == fNew || out.Rules["g14"].Rule != mwc.RuleProvisions || out.Rules["g14"].Words != words { t.Errorf("%s: want %+v, held by %+v", c.name, out.Wants["g14"], out.Rules["g14"]) } } @@ -197,17 +216,17 @@ func TestEachRuleHoldsTheWantBack(t *testing.T) { name string state, older string moves bool - }{{"the replaced edge provided, the older edge retiring", provisions.Provided, provisions.Retiring, true}, - {"the replaced edge provided, the older edge asked", provisions.Provided, provisions.Asked, true}, - {"the replaced edge asked, the older edge retiring", provisions.Asked, provisions.Retiring, false}} { + }{{"the replaced edge provided, the older edge retiring", meshprovisionerclient.Provided, meshprovisionerclient.Retiring, true}, + {"the replaced edge provided, the older edge asked", meshprovisionerclient.Provided, meshprovisionerclient.Asked, true}, + {"the replaced edge asked, the older edge retiring", meshprovisionerclient.Asked, meshprovisionerclient.Retiring, false}} { m, n := ready() m.Manifest.Requires = []string{"oidc"} - m.Provisions = func(node string) map[string]provisions.Provision { - return map[string]provisions.Provision{node + ".mail.oidc": {Provision: "oidc", State: c.state}, + m.Provisions = func(node string) map[string]meshprovisionerclient.Provision { + return map[string]meshprovisionerclient.Provision{node + ".mail.oidc": {Provision: "oidc", State: c.state}, node + ".mail.database": {Provision: "database", State: c.older, Why: "the operator's answer"}} } out := Wants(m, n, wantedOld(), nil) - if got := out.Wants["g14"].FP == fNew; got != c.moves || (!c.moves && (!strings.Contains(out.Rules["g14"].Words, "oidc") || strings.Contains(out.Rules["g14"].Words, "database"))) { + if got := out.Wants["g14"].Fp == fNew; got != c.moves || (!c.moves && (!strings.Contains(out.Rules["g14"].Words, "oidc") || strings.Contains(out.Rules["g14"].Words, "database"))) { t.Errorf("%s: want %+v, held by %q", c.name, out.Wants["g14"], out.Rules["g14"].Words) } } @@ -248,15 +267,17 @@ func TestEachRuleHoldsTheWantBack(t *testing.T) { } { m, n := ready() m.Manifest, m.Answering = c.manifest, answering - m.Provisions = func(string) map[string]provisions.Provision { return map[string]provisions.Provision{} } + m.Provisions = func(string) map[string]meshprovisionerclient.Provision { + return map[string]meshprovisionerclient.Provision{} + } out := Wants(m, n, wantedOld(), nil) if c.missing == "" { - if out.Wants["g14"].FP != fNew || len(m.MissingProvisions("g14")) != 0 { + if out.Wants["g14"].Fp != fNew || len(m.MissingProvisions("g14")) != 0 { t.Errorf("%s: held %+v", c.name, out.Rules["g14"]) } continue } - if out.Rules["g14"].Rule != delivery.RuleProvisions || !strings.Contains(out.Rules["g14"].Words, + if out.Rules["g14"].Rule != mwc.RuleProvisions || !strings.Contains(out.Rules["g14"].Words, "provision "+c.missing+" has no key yet") || len(m.MissingProvisions("g14")) != 1 { t.Errorf("%s: %+v", c.name, out.Rules["g14"]) } @@ -273,9 +294,11 @@ func TestEachRuleHoldsTheWantBack(t *testing.T) { m.Manifest.Requires = []string{c.requires} m.Answering = func(string) []catalogue.Manifest { return nil } m.Offered = func(p string) bool { return c.offered && p == c.requires } - m.Provisions = func(string) map[string]provisions.Provision { return map[string]provisions.Provision{} } + m.Provisions = func(string) map[string]meshprovisionerclient.Provision { + return map[string]meshprovisionerclient.Provision{} + } out := Wants(m, n, wantedOld(), nil) - if got := out.Wants["g14"].FP == fNew; got != c.offered || (!c.offered && (out.Rules["g14"].Rule != delivery.RuleProvisions || + if got := out.Wants["g14"].Fp == fNew; got != c.offered || (!c.offered && (out.Rules["g14"].Rule != mwc.RuleProvisions || !strings.Contains(out.Rules["g14"].Words, "no module offers database yet") || !strings.Contains(out.Rules["g14"].Words, "past its bound"))) { t.Errorf("%s: want %+v, held by %+v", c.name, out.Wants["g14"], out.Rules["g14"]) } @@ -285,26 +308,26 @@ func TestEachRuleHoldsTheWantBack(t *testing.T) { // healthy held photos, and novox offline left a module with no canary. for _, c := range []struct { name, module, provision, provider string - have *delivery.Have // what novox runs of the module; nil: a new assignment - before map[string]delivery.Want + have *nodeengine.HaveByNodeModuleValue // what novox runs of the module; nil: a new assignment + before map[string]mwc.Want online bool want string }{ {"de-spiegel, route-proxy down, old form", "de-spiegel", "route", "route-proxy", - &delivery.Have{FP: "build-1", Phase: delivery.PhaseRunning, Health: delivery.HealthUnknown}, nil, true, fNew}, + &nodeengine.HaveByNodeModuleValue{Fp: "build-1", Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthUnknown}, nil, true, fNew}, {"link2pay, route-proxy down, new assignment", "link2pay", "route", "route-proxy", nil, nil, true, fNew}, {"photos, minio not healthy, moving its want", "photos", "s3-bucket", "minio", - &delivery.Have{FP: fOld, Phase: delivery.PhaseRunning, Health: delivery.HealthOK}, - map[string]delivery.Want{"novox": {FP: fOld, Composed: dig(fOld)}}, true, fNew}, + &nodeengine.HaveByNodeModuleValue{Fp: fOld, Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthOK}, + map[string]mwc.Want{"novox": {Fp: fOld, Composed: dig(fOld)}}, true, fNew}, {"novox offline, no canary, new assignment", "link2pay", "route", "route-proxy", nil, nil, false, fNew}, {"novox offline, no canary, moving its want", "photos", "s3-bucket", "minio", - &delivery.Have{FP: fOld, Phase: delivery.PhaseRunning, Health: delivery.HealthOK}, - map[string]delivery.Want{"novox": {FP: fOld, Composed: dig(fOld)}}, false, fOld}, + &nodeengine.HaveByNodeModuleValue{Fp: fOld, Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthOK}, + map[string]mwc.Want{"novox": {Fp: fOld, Composed: dig(fOld)}}, false, fOld}, } { m := Module{Name: c.module, FP: fNew, Built: true, Granted: true, Composed: composedEverywhere, Nodes: []string{"novox"}, Manifest: catalogue.Manifest{Module: c.module, Requires: []string{c.provision}}} - n := Nodes{Online: map[string]bool{"novox": c.online}, Haves: map[string]map[string]delivery.Have{"novox": { - c.provider: {FP: fpOf('p'), Phase: delivery.PhaseFailed, Health: delivery.HealthFailed}}}, + n := Nodes{Online: map[string]bool{"novox": c.online}, Haves: map[string]map[string]nodeengine.HaveByNodeModuleValue{"novox": { + c.provider: {Fp: fpOf('p'), Phase: nodeengineclient.PhaseFailed, Health: nodeengineclient.HealthFailed}}}, Providers: map[string]map[string][]string{c.provision: {c.provider: {"novox"}}}} if c.have != nil { n.Haves["novox"][c.module] = *c.have @@ -314,7 +337,7 @@ func TestEachRuleHoldsTheWantBack(t *testing.T) { if !written { w = c.before["novox"] } - if w.FP != c.want || w.Removed { + if w.Fp != c.want || w.Removed { t.Errorf("%s: novox's want = %+v, want %s", c.name, w, short(c.want)) } if _, on := out.Neither["novox"]; on { @@ -325,78 +348,78 @@ func TestEachRuleHoldsTheWantBack(t *testing.T) { } } - // 1126/AC-SW1, AC-SW2, AC-SW3: a want written Self while g14 was placed, kept by a rule after the placement is gone, names + // 1126/AC-SW1, AC-SW2, AC-SW3: a want written Self while g14 was placed, kept by a rule after the assignments is gone, names // the controller's composition of the fingerprint the rule keeps (on 2026-10-27 g14's systemd-resolved, held, and forticlient, // rule 5, kept their Self wants, which g14 could no longer compose); with no such composition, or while a record ask waits, it // stays and is said; a placed node's want is Self with no composition. - selfAt := func(fp string) map[string]delivery.Want { - return map[string]delivery.Want{"ace": {FP: fNew, Composed: dig(fNew)}, "g14": {FP: fp, Self: true, Yes: dig(fp)}} + selfAt := func(fp string) map[string]mwc.Want { + return map[string]mwc.Want{"ace": {Fp: fNew, Composed: dig(fNew)}, "g14": {Fp: fp, Self: true, Yes: dig(fp)}} } atF := func(_ *Module, n *Nodes) { - n.Haves["g14"]["mail"] = delivery.Have{FP: fNew, Composed: dig(fNew), Phase: delivery.PhaseFailed, Health: delivery.HealthOK} + n.Haves["g14"]["mail"] = nodeengine.HaveByNodeModuleValue{Fp: fNew, Composed: dig(fNew), Phase: nodeengineclient.PhaseFailed, Health: nodeengineclient.HealthOK} } for _, c := range []struct { name string set func(*Module, *Nodes) before string - want delivery.Want // the want of g14 written; zero: none written - rule int // the rule g14's holding names + want mwc.Want // the want of g14 written; zero: none written + rule int // the rule g14's holding names asked bool }{ {name: "rule 5 at F", before: fNew, set: func(m *Module, n *Nodes) { atF(m, n) - n.Haves["ace"]["store"] = delivery.Have{FP: fpOf('s'), Phase: delivery.PhaseRunning, Health: delivery.HealthWaits} - }, want: delivery.Want{FP: fNew, Composed: dig(fNew)}}, + n.Haves["ace"]["store"] = nodeengine.HaveByNodeModuleValue{Fp: fpOf('s'), Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthWaits} + }, want: mwc.Want{Fp: fNew, Composed: dig(fNew)}}, {name: "rule 3 at F", before: fNew, set: func(m *Module, n *Nodes) { atF(m, n) - n.Haves["ace"]["mail"] = delivery.Have{FP: fOld, Phase: delivery.PhaseRunning, Health: delivery.HealthOK} - }, want: delivery.Want{FP: fNew, Composed: dig(fNew)}, rule: delivery.RuleCanary}, + n.Haves["ace"]["mail"] = nodeengine.HaveByNodeModuleValue{Fp: fOld, Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthOK} + }, want: mwc.Want{Fp: fNew, Composed: dig(fNew)}, rule: mwc.RuleCanary}, {name: "rule 3 held back at an older fingerprint", before: fOld, set: func(_ *Module, n *Nodes) { - n.Haves["ace"]["mail"] = delivery.Have{FP: fOld, Phase: delivery.PhaseRunning, Health: delivery.HealthOK} - }, want: delivery.Want{FP: fOld, Composed: dig(fOld), Newest: fNew, Rule: delivery.RuleCanary, - Words: "the canary ace does not run bbbbbbbbbbbb healthy yet"}, rule: delivery.RuleCanary}, + n.Haves["ace"]["mail"] = nodeengine.HaveByNodeModuleValue{Fp: fOld, Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthOK} + }, want: mwc.Want{Fp: fOld, Composed: dig(fOld), Newest: fNew, Rule: mwc.RuleCanary, + Words: "the canary ace does not run bbbbbbbbbbbb healthy yet"}, rule: mwc.RuleCanary}, {name: "held at F with nothing to set back to", before: fNew, set: func(m *Module, n *Nodes) { atF(m, n); m.Held = true }, - want: delivery.Want{FP: fNew, Composed: dig(fNew), Words: "mail is held at bbbbbbbbbbbb and g14 ran no other fingerprint healthy to set it back to"}, - rule: delivery.RuleNotHeld}, + want: mwc.Want{Fp: fNew, Composed: dig(fNew), Words: "mail is held at bbbbbbbbbbbb and g14 ran no other fingerprint healthy to set it back to"}, + rule: mwc.RuleNotHeld}, {name: "held data module", before: fNew, set: func(m *Module, n *Nodes) { atF(m, n) m.Held = true m.Manifest.Data = &catalogue.Data{Own: []catalogue.DataItem{{ID: "mail", Class: catalogue.ClassIrreplaceable}}} - }, want: delivery.Want{FP: fNew, Composed: dig(fNew), - Words: "mail holds data, so it is not set back from bbbbbbbbbbbb: the operator is asked whether to restore it"}, rule: delivery.RuleNotHeld}, + }, want: mwc.Want{Fp: fNew, Composed: dig(fNew), + Words: "mail holds data, so it is not set back from bbbbbbbbbbbb: the operator is asked whether to restore it"}, rule: mwc.RuleNotHeld}, {name: "a record ask waits", before: fNew, set: func(m *Module, n *Nodes) { atF(m, n) recording(m) m.Held = true - m.Composed = func(_, fp string) (delivery.ComposedDigest, bool) { - return delivery.ComposedDigest{Digest: dig(fp), Judgement: delivery.JudgedRecreates, Against: dig(fOld)}, true + m.Composed = func(_, fp string) (ComposedDigest, bool) { + return ComposedDigest{Digest: dig(fp), Judgement: JudgedRecreates, Against: dig(fOld)}, true } - }, rule: delivery.RuleNotHeld, asked: true}, + }, rule: mwc.RuleNotHeld, asked: true}, {name: "no composition of F", before: fNew, set: func(m *Module, n *Nodes) { atF(m, n) - m.Composed = func(node, fp string) (delivery.ComposedDigest, bool) { + m.Composed = func(node, fp string) (ComposedDigest, bool) { if node == "g14" { - return delivery.ComposedDigest{}, false + return ComposedDigest{}, false } return composedEverywhere(node, fp) } - }, rule: delivery.RuleComposed}, + }, rule: mwc.RuleComposed}, {name: "placed", before: fNew, set: func(m *Module, n *Nodes) { atF(m, n) m.Self = func(node string) bool { return node == "g14" } - m.Composed = func(node, fp string) (delivery.ComposedDigest, bool) { + m.Composed = func(node, fp string) (ComposedDigest, bool) { if node == "g14" { - return delivery.ComposedDigest{Judgement: delivery.JudgedNeither}, true + return ComposedDigest{Judgement: JudgedNeither}, true } return composedEverywhere(node, fp) } - }, want: delivery.Want{FP: fNew, Self: true}}, + }, want: mwc.Want{Fp: fNew, Self: true}}, } { m, n := ready() c.set(&m, &n) before := selfAt(c.before) if c.name == "placed" { - before["g14"] = delivery.Want{FP: fNew, Composed: dig(fNew)} + before["g14"] = mwc.Want{Fp: fNew, Composed: dig(fNew)} } out := Wants(m, n, before, nil) if got := out.Wants["g14"]; got != c.want { @@ -409,7 +432,7 @@ func TestEachRuleHoldsTheWantBack(t *testing.T) { t.Errorf("%s: record asks %+v", c.name, out.Record) } if w, ok := out.Wants["g14"]; ok { - if _, err := delivery.EncodeWant(w); err != nil { + if _, err := mwc.EncodeWant(w); err != nil { t.Errorf("%s: g14's want does not encode: %v", c.name, err) } } @@ -418,13 +441,13 @@ func TestEachRuleHoldsTheWantBack(t *testing.T) { // Rule 6 per node: a grant written for the module's other nodes does not grant a node whose account it does not name // (issue 1945, live on 2026-10-25 at 20:04: ace's mesh-build-go was wanted with no account on the bus). m, n := ready() - grant := delivery.Grant{Accounts: []string{"ace.mail", "ace.mail.send"}} + grant := grantsstate.Grant{Accounts: []string{"ace.mail", "ace.mail.send"}} m.GrantedOn = func(node string) bool { return GrantNamesNode(grant, node, "mail") } out := Wants(m, n, wantedOld(), nil) - if w := out.Wants["ace"]; w.FP != fNew { + if w := out.Wants["ace"]; w.Fp != fNew { t.Errorf("ace, whose account the grant names, wants %+v", w) } - if got := out.Rules["g14"]; got.Rule != delivery.RuleGrant || !strings.Contains(got.Words, "on g14") { + if got := out.Rules["g14"]; got.Rule != mwc.RuleGrant || !strings.Contains(got.Words, "on g14") { t.Errorf("g14, whose account the grant does not name, is held by %+v", got) } for _, c := range []struct { @@ -437,7 +460,7 @@ func TestEachRuleHoldsTheWantBack(t *testing.T) { {[]string{"g14.mailer"}, "g14", "mail", false}, {[]string{"ace.mail"}, "g14", "mail", false}, } { - if got := GrantNamesNode(delivery.Grant{Accounts: c.accounts}, c.node, c.module); got != c.named { + if got := GrantNamesNode(grantsstate.Grant{Accounts: c.accounts}, c.node, c.module); got != c.named { t.Errorf("%v names %s of %s: %v, want %v", c.accounts, c.module, c.node, got, c.named) } } @@ -447,17 +470,17 @@ func TestEachRuleHoldsTheWantBack(t *testing.T) { func TestAnOfflineCanaryIsPassedOver(t *testing.T) { m, n := ready() n.Online["ace"] = false - n.Haves["ace"]["mail"] = delivery.Have{FP: fOld, Phase: delivery.PhaseRunning, Health: delivery.HealthOK} + n.Haves["ace"]["mail"] = nodeengine.HaveByNodeModuleValue{Fp: fOld, Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthOK} n.Providers["database"]["store"] = []string{"ace", "g14"} - n.Haves["g14"]["store"] = delivery.Have{FP: fpOf('s'), Phase: delivery.PhaseRunning, Health: delivery.HealthOK} + n.Haves["g14"]["store"] = nodeengine.HaveByNodeModuleValue{Fp: fpOf('s'), Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthOK} if c := Canary(m.Nodes, n.Online); c != "g14" { t.Fatalf("canary = %q, want g14", c) } out := Wants(m, n, wantedOld(), nil) - if out.Wants["g14"].FP != fNew { + if out.Wants["g14"].Fp != fNew { t.Errorf("the canary g14's want = %+v", out.Wants["g14"]) } - if out.Rules["ace"].Rule != delivery.RuleCanary { + if out.Rules["ace"].Rule != mwc.RuleCanary { t.Errorf("ace, behind the canary g14, is held back by %+v", out.Rules["ace"]) } } @@ -465,24 +488,24 @@ func TestAnOfflineCanaryIsPassedOver(t *testing.T) { // 1297/B4: a failed health check of F holds F and sets the nodes that want it back to their last healthy fingerprint. func TestAFailedHealthHoldsAndSetsBack(t *testing.T) { m, n := ready() - n.Haves["ace"]["mail"] = delivery.Have{FP: fNew, Phase: delivery.PhaseRunning, Health: delivery.HealthFailed, Detail: "connection refused"} + n.Haves["ace"]["mail"] = nodeengine.HaveByNodeModuleValue{Fp: fNew, Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthFailed, Detail: "connection refused"} m.Now = time.Date(2026, 10, 25, 1, 0, 0, 0, time.UTC) - out := Wants(m, n, map[string]delivery.Want{"ace": {FP: fNew, Composed: dig(fNew)}, "g14": {FP: fOld, Composed: dig(fOld)}}, map[string]string{"ace": fOld}) + out := Wants(m, n, map[string]mwc.Want{"ace": {Fp: fNew, Composed: dig(fNew)}, "g14": {Fp: fOld, Composed: dig(fOld)}}, map[string]string{"ace": fOld}) // 1906/H3: the held record keeps its cause in words: which machine (its node), which health, when, and the node's words. if out.Held == nil || out.Held.Node != "ace" || out.Held.Words != "health failed, phase running, seen at 2026-10-25T01:00:00Z: connection refused" { t.Fatalf("held = %+v", out.Held) } - if w := out.Wants["ace"]; w.FP != fOld || w.Composed != dig(fOld) || w.Newest != fNew || w.Rule != delivery.RuleNotHeld { + if w := out.Wants["ace"]; w.Fp != fOld || w.Composed != dig(fOld) || w.Newest != fNew || w.Rule != mwc.RuleNotHeld { t.Errorf("ace's want = %+v, want set back to the last healthy fingerprint", w) } - if w, ok := out.Wants["g14"]; ok && w.FP == fNew { + if w, ok := out.Wants["g14"]; ok && w.Fp == fNew { t.Errorf("g14 moved to a held fingerprint: %+v", w) } // 1496, ADR 0557 §1, §2: the same failure while F runs healthy on another node is that node's: F is not held, nothing is set // back, the failing node keeps its want, and the outcome names it (on ace a newly assigned module's first check held a version healthy for an hour on three nodes). - n.Haves["g14"]["mail"] = delivery.Have{FP: fNew, Phase: delivery.PhaseRunning, Health: delivery.HealthOK} - before := map[string]delivery.Want{"ace": {FP: fNew, Composed: dig(fNew)}, "g14": {FP: fNew, Composed: dig(fNew)}} + n.Haves["g14"]["mail"] = nodeengine.HaveByNodeModuleValue{Fp: fNew, Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthOK} + before := map[string]mwc.Want{"ace": {Fp: fNew, Composed: dig(fNew)}, "g14": {Fp: fNew, Composed: dig(fNew)}} out = Wants(m, n, before, map[string]string{"ace": fOld, "g14": fOld}) if out.Held != nil || len(out.Wants) != 0 { t.Fatalf("held %+v, wants %+v: want no hold and no want changed", out.Held, out.Wants) @@ -499,23 +522,23 @@ func TestAFailedHealthHoldsAndSetsBack(t *testing.T) { failedHave := n.Haves["ace"]["mail"] n.Haves["ace"]["mail"], n.Haves["g14"]["mail"] = n.Haves["g14"]["mail"], failedHave n.Online["novox"] = true - n.Haves["novox"] = map[string]delivery.Have{"mail": {FP: fOld, Phase: delivery.PhaseRunning, Health: delivery.HealthOK}} - before["novox"] = delivery.Want{FP: fOld, Composed: dig(fOld)} + n.Haves["novox"] = map[string]nodeengine.HaveByNodeModuleValue{"mail": {Fp: fOld, Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthOK}} + before["novox"] = mwc.Want{Fp: fOld, Composed: dig(fOld)} out = Wants(three, n, before, map[string]string{"ace": fOld, "g14": fOld, "novox": fOld}) if _, alone := out.NodeFailed["g14"]; !alone { t.Errorf("g14 does not fail alone: %+v", out.NodeFailed) } - if w, written := out.Wants["novox"]; out.Held != nil || len(out.NodeFailed) != 1 || (written && w.FP == fNew) || out.Rules["novox"].Rule != delivery.RuleNotHeld { + if w, written := out.Wants["novox"]; out.Held != nil || len(out.NodeFailed) != 1 || (written && w.Fp == fNew) || out.Rules["novox"].Rule != mwc.RuleNotHeld { t.Errorf("held %+v, node failed %+v, novox's want %+v (%+v): want novox kept off F", out.Held, out.NodeFailed, out.Wants["novox"], out.Rules["novox"]) } if _, written := out.Wants["g14"]; written { t.Errorf("g14's want at F was rewritten: %+v", out.Wants["g14"]) } // A node that runs F healthy with no want, while g14 fails it alone, gets its want at F and is on no neither list (issue 1945). - n.Haves["novox"]["mail"] = delivery.Have{FP: fNew, Phase: delivery.PhaseRunning, Health: delivery.HealthOK} + n.Haves["novox"]["mail"] = nodeengine.HaveByNodeModuleValue{Fp: fNew, Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthOK} delete(before, "novox") out = Wants(three, n, before, map[string]string{"ace": fOld, "g14": fOld, "novox": fOld}) - if w := out.Wants["novox"]; w.FP != fNew || len(out.Neither) != 0 { + if w := out.Wants["novox"]; w.Fp != fNew || len(out.Neither) != 0 { t.Errorf("novox running F with no want: want %+v, neither %+v; want its want at F and no neither", w, out.Neither) } n.Haves["ace"]["mail"], n.Haves["g14"]["mail"] = failedHave, n.Haves["ace"]["mail"] @@ -525,22 +548,22 @@ func TestAFailedHealthHoldsAndSetsBack(t *testing.T) { for _, c := range []struct { name string online map[string]bool - novox delivery.Health + novox string }{ - {"the witness sleeps", map[string]bool{"ace": true, "g14": false, "novox": true}, delivery.HealthOK}, - {"F fails on a second node", map[string]bool{"ace": true, "g14": true, "novox": true}, delivery.HealthFailed}, + {"the witness sleeps", map[string]bool{"ace": true, "g14": false, "novox": true}, nodeengineclient.HealthOK}, + {"F fails on a second node", map[string]bool{"ace": true, "g14": true, "novox": true}, nodeengineclient.HealthFailed}, } { n.Online = c.online - n.Haves["novox"] = map[string]delivery.Have{"mail": {FP: fNew, Phase: delivery.PhaseRunning, Health: c.novox}} - if c.novox == delivery.HealthOK { - n.Haves["novox"]["mail"] = delivery.Have{FP: fOld, Phase: delivery.PhaseRunning, Health: delivery.HealthOK} + n.Haves["novox"] = map[string]nodeengine.HaveByNodeModuleValue{"mail": {Fp: fNew, Phase: nodeengineclient.PhaseRunning, Health: c.novox}} + if c.novox == nodeengineclient.HealthOK { + n.Haves["novox"]["mail"] = nodeengine.HaveByNodeModuleValue{Fp: fOld, Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthOK} } - before["novox"] = delivery.Want{FP: fNew, Composed: dig(fNew)} + before["novox"] = mwc.Want{Fp: fNew, Composed: dig(fNew)} out = Wants(three, n, before, map[string]string{"ace": fOld, "g14": fOld, "novox": fOld}) if out.Held == nil || out.Held.Node != "ace" || len(out.NodeFailed) != 0 { t.Errorf("%s: held %+v, node failed %+v: want F held at ace", c.name, out.Held, out.NodeFailed) } - if w := out.Wants["ace"]; w.FP != fOld { + if w := out.Wants["ace"]; w.Fp != fOld { t.Errorf("%s: ace's want = %+v, want set back", c.name, w) } } @@ -550,12 +573,12 @@ func TestAFailedHealthHoldsAndSetsBack(t *testing.T) { func TestADataModuleIsNotSetBack(t *testing.T) { m, n := ready() m.Manifest.Data = &catalogue.Data{Own: []catalogue.DataItem{{ID: "mail", Class: catalogue.ClassIrreplaceable}}} - n.Haves["ace"]["mail"] = delivery.Have{FP: fNew, Phase: delivery.PhaseRunning, Health: delivery.HealthFailed} - out := Wants(m, n, map[string]delivery.Want{"ace": {FP: fNew}}, map[string]string{"ace": fOld}) + n.Haves["ace"]["mail"] = nodeengine.HaveByNodeModuleValue{Fp: fNew, Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthFailed} + out := Wants(m, n, map[string]mwc.Want{"ace": {Fp: fNew}}, map[string]string{"ace": fOld}) if out.Held == nil { t.Fatal("F was not held") } - if w := out.Wants["ace"]; w.FP != fNew || !strings.Contains(w.Words, "holds data") { + if w := out.Wants["ace"]; w.Fp != fNew || !strings.Contains(w.Words, "holds data") { t.Errorf("ace's want = %+v, want it left at F, saying it holds data", w) } // 1906/D3: and the node is to be asked whether to restore it @@ -567,8 +590,8 @@ func TestADataModuleIsNotSetBack(t *testing.T) { // 1906/D3: a module without data is set back and asked about nothing. func TestAModuleWithoutDataIsNotAskedToRestore(t *testing.T) { m, n := ready() - n.Haves["ace"]["mail"] = delivery.Have{FP: fNew, Phase: delivery.PhaseRunning, Health: delivery.HealthFailed} - out := Wants(m, n, map[string]delivery.Want{"ace": {FP: fNew}}, map[string]string{"ace": fOld}) + n.Haves["ace"]["mail"] = nodeengine.HaveByNodeModuleValue{Fp: fNew, Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthFailed} + out := Wants(m, n, map[string]mwc.Want{"ace": {Fp: fNew}}, map[string]string{"ace": fOld}) if len(out.Restore) != 0 { t.Errorf("restore asks = %v", out.Restore) } @@ -579,7 +602,7 @@ func TestAModuleWithoutDataIsNotAskedToRestore(t *testing.T) { func TestALeavingNodeIsRemovedAndNothingIsWrittenTwice(t *testing.T) { m, n := ready() m.Nodes = []string{"ace"} - out := Wants(m, n, map[string]delivery.Want{"ace": {V: 1, FP: fNew, Composed: dig(fNew)}, "g14": {FP: fOld, Composed: dig(fOld)}}, nil) + out := Wants(m, n, map[string]mwc.Want{"ace": {V: 1, Fp: fNew, Composed: dig(fNew)}, "g14": {Fp: fOld, Composed: dig(fOld)}}, nil) if w := out.Wants["g14"]; !w.Removed { t.Errorf("g14's want = %+v, want removed", w) } @@ -593,19 +616,19 @@ func TestALeavingNodeIsRemovedAndNothingIsWrittenTwice(t *testing.T) { func TestAWantWaitsForItsCompositionAndNamesItsDigest(t *testing.T) { m, n := ready() have := map[string]bool{} - m.Composed = func(node, fp string) (delivery.ComposedDigest, bool) { + m.Composed = func(node, fp string) (ComposedDigest, bool) { if !have[node+"|"+fp] { - return delivery.ComposedDigest{}, false + return ComposedDigest{}, false } return composedEverywhere(node, fp) } out := Wants(m, n, wantedOld(), nil) for node, w := range out.Wants { - if w.FP == fNew { + if w.Fp == fNew { t.Errorf("%s's want moved to F before its composition exists: %+v", node, w) } } - if r := out.Rules["ace"]; r.Rule != delivery.RuleComposed || !strings.Contains(r.Words, "ace") { + if r := out.Rules["ace"]; r.Rule != mwc.RuleComposed || !strings.Contains(r.Words, "ace") { t.Errorf("ace is held back by %+v, want rule 7 naming the node", r) } // and a node that never had a want gets none @@ -614,10 +637,10 @@ func TestAWantWaitsForItsCompositionAndNamesItsDigest(t *testing.T) { } have["ace|"+fNew] = true out = Wants(m, n, wantedOld(), nil) - if w := out.Wants["ace"]; w.FP != fNew || w.Composed != dig(fNew) { + if w := out.Wants["ace"]; w.Fp != fNew || w.Composed != dig(fNew) { t.Errorf("ace's want = %+v, want F naming the composition's digest", w) } - if w, ok := out.Wants["g14"]; ok && w.FP == fNew { + if w, ok := out.Wants["g14"]; ok && w.Fp == fNew { t.Errorf("g14, whose composition does not exist, moved: %+v", w) } } @@ -627,13 +650,13 @@ func TestAWantWaitsForItsCompositionAndNamesItsDigest(t *testing.T) { func TestARevisionAtTheSameFingerprintIsAWantByTheSameRules(t *testing.T) { m, n := ready() revised := "sha256:" + strings.Repeat("9", 64) - m.Composed = func(node, fp string) (delivery.ComposedDigest, bool) { - return delivery.ComposedDigest{Digest: revised, Judgement: delivery.JudgedNeither}, true + m.Composed = func(node, fp string) (ComposedDigest, bool) { + return ComposedDigest{Digest: revised, Judgement: JudgedNeither}, true } - running := map[string]delivery.Want{"ace": {FP: fNew, Composed: dig(fNew)}, "g14": {FP: fNew, Composed: dig(fNew)}} + running := map[string]mwc.Want{"ace": {Fp: fNew, Composed: dig(fNew)}, "g14": {Fp: fNew, Composed: dig(fNew)}} out := Wants(m, n, running, nil) for _, node := range []string{"ace", "g14"} { - if w := out.Wants[node]; w.FP != fNew || w.Composed != revised { + if w := out.Wants[node]; w.Fp != fNew || w.Composed != revised { t.Errorf("%s's want = %+v, want F naming the revised digest", node, w) } } @@ -647,11 +670,11 @@ func TestARevisionAtTheSameFingerprintIsAWantByTheSameRules(t *testing.T) { // controller composed again with today's config (novox/hq ADR 0545: on g14 a capacity of 0 after a rollback), while // the rules still hold F; for a record module whose revision recreates a container, only after the ask. out = Wants(m, n, wantedOld(), nil) - if w := out.Wants["g14"]; w.FP != fOld || w.Composed != revised || w.Newest != fNew { + if w := out.Wants["g14"]; w.Fp != fOld || w.Composed != revised || w.Newest != fNew { t.Errorf("g14, held back at its earlier fingerprint, wants %+v; want it at that fingerprint naming the revised digest", w) } - m.Composed = func(node, fp string) (delivery.ComposedDigest, bool) { - return delivery.ComposedDigest{Digest: revised, Judgement: delivery.JudgedRecreates, Policy: delivery.PolicyRecord, + m.Composed = func(node, fp string) (ComposedDigest, bool) { + return ComposedDigest{Digest: revised, Judgement: JudgedRecreates, Policy: catalogue.PolicyRecord, Against: dig(fOld), Words: "recreates"}, true } out = Wants(m, n, wantedOld(), nil) @@ -666,31 +689,31 @@ func TestARevisionAtTheSameFingerprintIsAWantByTheSameRules(t *testing.T) { // 1906/D2: a set-back names the composition of the fingerprint it sets back to, and when that is gone the want stays and says so. func TestASetBackNamesTheOlderCompositionOrStays(t *testing.T) { m, n := ready() - n.Haves["ace"]["mail"] = delivery.Have{FP: fNew, Phase: delivery.PhaseRunning, Health: delivery.HealthFailed, Detail: "x"} - running := map[string]delivery.Want{"ace": {FP: fNew, Composed: dig(fNew)}} - m.Composed = func(node, fp string) (delivery.ComposedDigest, bool) { + n.Haves["ace"]["mail"] = nodeengine.HaveByNodeModuleValue{Fp: fNew, Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthFailed, Detail: "x"} + running := map[string]mwc.Want{"ace": {Fp: fNew, Composed: dig(fNew)}} + m.Composed = func(node, fp string) (ComposedDigest, bool) { if fp == fOld { - return delivery.ComposedDigest{}, false + return ComposedDigest{}, false } return composedEverywhere(node, fp) } out := Wants(m, n, running, map[string]string{"ace": fOld}) - if w := out.Wants["ace"]; w.FP == fOld { + if w := out.Wants["ace"]; w.Fp == fOld { t.Errorf("set back to a fingerprint whose composition is gone: %+v", w) } m.Composed = composedEverywhere out = Wants(m, n, running, map[string]string{"ace": fOld}) - if w := out.Wants["ace"]; w.FP != fOld || w.Composed != dig(fOld) { + if w := out.Wants["ace"]; w.Fp != fOld || w.Composed != dig(fOld) { t.Errorf("set-back want = %+v", w) } } // movingTo is a module ready to move, whose composition at F is judged j against the one it runs, and whose digest is dig(F). -func movingTo(j delivery.Judgement) (Module, Nodes, map[string]delivery.Want) { +func movingTo(j Judgement) (Module, Nodes, map[string]mwc.Want) { m, n := ready() - m.Composed = func(node, fp string) (delivery.ComposedDigest, bool) { - d := delivery.ComposedDigest{Digest: dig(fp), Judgement: j} - if j != delivery.JudgedNeither { + m.Composed = func(node, fp string) (ComposedDigest, bool) { + d := ComposedDigest{Digest: dig(fp), Judgement: j} + if j != JudgedNeither { d.Against, d.Words = dig(fOld), "judged" } return d, true @@ -705,7 +728,7 @@ func recording(m *Module) { // 1906/D4 (ADR 0539 §14): a module of policy record whose move is judged to recreate a container, to move data, or cannot be // judged, is not written until its ask is answered yes; the same at a new revision of the same fingerprint. func TestARecordModuleWaitsForItsAskWhenTheMoveIsNotJudgedNeither(t *testing.T) { - for _, j := range []delivery.Judgement{delivery.JudgedRecreates, delivery.JudgedMovesData, delivery.JudgedCannot} { + for _, j := range []Judgement{JudgedRecreates, JudgedMovesData, JudgedCannot} { m, n, wanted := movingTo(j) recording(&m) out := Wants(m, n, wanted, nil) @@ -722,13 +745,13 @@ func TestARecordModuleWaitsForItsAskWhenTheMoveIsNotJudgedNeither(t *testing.T) t.Errorf("%s: the asks = %+v, want one for ace naming the digest and the judgement", j, out.Record) } // a new revision at the same fingerprint is asked too - same := map[string]delivery.Want{"ace": {FP: fNew, Composed: dig(fOld)}} + same := map[string]mwc.Want{"ace": {Fp: fNew, Composed: dig(fOld)}} if out := Wants(m, n, same, nil); len(out.Record) == 0 || len(out.Wants) != 0 && out.Wants["ace"].Composed == dig(fNew) { t.Errorf("%s: a new revision at the same fingerprint: %+v", j, out) } m.Allowed = func(node, digest string) bool { return node == "ace" && digest == dig(fNew) } out = Wants(m, n, wanted, nil) - if w := out.Wants["ace"]; w.FP != fNew || w.Composed != dig(fNew) { + if w := out.Wants["ace"]; w.Fp != fNew || w.Composed != dig(fNew) { t.Errorf("%s: after the yes ace's want = %+v", j, w) } } @@ -737,27 +760,27 @@ func TestARecordModuleWaitsForItsAskWhenTheMoveIsNotJudgedNeither(t *testing.T) // 1906/D4: a move judged neither is written at once, whatever the policy; so is a module the node never ran; and a module // that rolls (not record) is never asked. func TestAMoveJudgedNeitherOrNeverRunOrNotRecordedIsWrittenAtOnce(t *testing.T) { - m, n, wanted := movingTo(delivery.JudgedNeither) + m, n, wanted := movingTo(JudgedNeither) recording(&m) - if out := Wants(m, n, wanted, nil); out.Wants["ace"].FP != fNew || len(out.Record) != 0 { + if out := Wants(m, n, wanted, nil); out.Wants["ace"].Fp != fNew || len(out.Record) != 0 { t.Errorf("neither: %+v", out) } - m, n, _ = movingTo(delivery.JudgedRecreates) + m, n, _ = movingTo(JudgedRecreates) recording(&m) m.Nodes = []string{"ace"} // g14 runs it with no want, so its first want is asked (1945/AC19): this case is ace's alone delete(n.Haves["ace"], "mail") - if out := Wants(m, n, nil, nil); out.Wants["ace"].FP != fNew || len(out.Record) != 0 { + if out := Wants(m, n, nil, nil); out.Wants["ace"].Fp != fNew || len(out.Record) != 0 { t.Errorf("a node that never ran it: %+v", out) } // but one that runs an older fingerprint with no want yet is asked like any other (one running F is not: N1 below) - m, n, _ = movingTo(delivery.JudgedCannot) + m, n, _ = movingTo(JudgedCannot) recording(&m) - n.Haves["ace"]["mail"] = delivery.Have{FP: fOld, Phase: delivery.PhaseRunning, Health: delivery.HealthOK} - if out := Wants(m, n, nil, nil); out.Wants["ace"].FP == fNew || len(out.Record) == 0 { + n.Haves["ace"]["mail"] = nodeengine.HaveByNodeModuleValue{Fp: fOld, Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthOK} + if out := Wants(m, n, nil, nil); out.Wants["ace"].Fp == fNew || len(out.Record) == 0 { t.Errorf("a node that runs it with no want: %+v", out) } - m, n, wanted = movingTo(delivery.JudgedRecreates) - if out := Wants(m, n, wanted, nil); out.Wants["ace"].FP != fNew || len(out.Record) != 0 { + m, n, wanted = movingTo(JudgedRecreates) + if out := Wants(m, n, wanted, nil); out.Wants["ace"].Fp != fNew || len(out.Record) != 0 { t.Errorf("a rolled module: %+v", out) } } @@ -765,16 +788,16 @@ func TestAMoveJudgedNeitherOrNeverRunOrNotRecordedIsWrittenAtOnce(t *testing.T) // 1906/D4 (ADR 0539 §7, §15): the bus module's new fingerprint is never written by the assigner, but a revision of its // composition at the same fingerprint that only changes the user list (judged neither) is written at once. func TestTheBusMovesByTheRulesAndItsUserListRevisionIsWrittenAtOnce(t *testing.T) { - m, n, _ := movingTo(delivery.JudgedNeither) + m, n, _ := movingTo(JudgedNeither) m.Manifest.Provides = []catalogue.Offer{{Name: "mesh-bus"}} // ADR 0601 §1, §2: the bus module's want is written by the rules, as any module's, and no snapshot is asked for it: its node // backs its streams up before it applies. - if out := Wants(m, n, wantedOld(), nil); out.Wants["ace"].FP != fNew || len(out.Record) != 0 || len(out.Neither) != 0 { + if out := Wants(m, n, wantedOld(), nil); out.Wants["ace"].Fp != fNew || len(out.Record) != 0 || len(out.Neither) != 0 { t.Errorf("the bus did not move by the rules: %+v", out) } - running := map[string]delivery.Want{"ace": {FP: fNew, Composed: dig(fOld)}, "g14": {FP: fNew, Composed: dig(fOld)}} + running := map[string]mwc.Want{"ace": {Fp: fNew, Composed: dig(fOld)}, "g14": {Fp: fNew, Composed: dig(fOld)}} out := Wants(m, n, running, nil) - if w := out.Wants["ace"]; w.FP != fNew || w.Composed != dig(fNew) { + if w := out.Wants["ace"]; w.Fp != fNew || w.Composed != dig(fNew) { t.Errorf("a user-list revision of the bus: ace's want = %+v", w) } if len(out.Record) != 0 { @@ -782,23 +805,23 @@ func TestTheBusMovesByTheRulesAndItsUserListRevisionIsWrittenAtOnce(t *testing.T } // ADR 0546: a put owns the node's want from its asking until it ends. While it waits the rules write nothing there; once // wanted its want stands, marked put and held, and a held bus still takes its user-list revision (ADR 0539 §7). - put := delivery.Put{FP: fNew, Hold: true, Why: "the bus's user list", Composed: dig(fNew)} + put := puts.Put{FP: fNew, Hold: true, Why: "the bus's user list", Composed: dig(fNew)} for _, c := range []struct { name string step string - wanted map[string]delivery.Want - want *delivery.Want // nil: nothing written on ace + wanted map[string]mwc.Want + want *mwc.Want // nil: nothing written on ace }{ - {"waiting for the operator", delivery.PutPerson, wantedOld(), nil}, - {"wanted", delivery.PutWanted, wantedOld(), &delivery.Want{FP: fNew, Composed: dig(fNew), Put: true, Hold: true, Words: "put: the bus's user list"}}, - {"holding, written already", delivery.PutHolding, map[string]delivery.Want{"ace": PutWant(put)}, nil}, - {"released: the rules again", delivery.PutReleased, map[string]delivery.Want{"ace": PutWant(put)}, &delivery.Want{FP: fNew, Composed: dig(fNew)}}, - {"not heard: a held want at an older fingerprint stands", "", map[string]delivery.Want{"ace": {FP: fOld, Composed: dig(fOld), Put: true, Hold: true, Words: "put: back"}}, nil}, - {"not heard: a held want the rules would rewrite stands", "", map[string]delivery.Want{"ace": PutWant(put)}, nil}, + {"waiting for the operator", puts.Person, wantedOld(), nil}, + {"wanted", puts.Wanted, wantedOld(), &mwc.Want{Fp: fNew, Composed: dig(fNew), Put: true, Hold: true, Words: "put: the bus's user list"}}, + {"holding, written already", puts.Holding, map[string]mwc.Want{"ace": PutWant(put)}, nil}, + {"released: the rules again", puts.Released, map[string]mwc.Want{"ace": PutWant(put)}, &mwc.Want{Fp: fNew, Composed: dig(fNew)}}, + {"not heard: a held want at an older fingerprint stands", "", map[string]mwc.Want{"ace": {Fp: fOld, Composed: dig(fOld), Put: true, Hold: true, Words: "put: back"}}, nil}, + {"not heard: a held want the rules would rewrite stands", "", map[string]mwc.Want{"ace": PutWant(put)}, nil}, } { p := put p.Step = c.step - m.Puts = map[string]delivery.Put{"ace": p} + m.Puts = map[string]puts.Put{"ace": p} if c.step == "" { m.Puts = nil } @@ -807,19 +830,19 @@ func TestTheBusMovesByTheRulesAndItsUserListRevisionIsWrittenAtOnce(t *testing.T switch { case c.want == nil && wrote: t.Errorf("put %s: the rules wrote ace's want %+v", c.name, got) - case c.want != nil && (!wrote || !delivery.SameWant(normal(got), *c.want)): + case c.want != nil && (!wrote || !mwc.SameWant(normal(got), *c.want)): t.Errorf("put %s: ace's want = %+v (written %v), want %+v", c.name, got, wrote, *c.want) } } userList := "sha256:" + strings.Repeat("7", 64) - m.Composed = func(node, fp string) (delivery.ComposedDigest, bool) { - return delivery.ComposedDigest{Digest: userList, Judgement: delivery.JudgedNeither, UserListOnly: true}, true + m.Composed = func(node, fp string) (ComposedDigest, bool) { + return ComposedDigest{Digest: userList, Judgement: JudgedNeither, UserListOnly: true}, true } - n.Haves["ace"]["mail"] = delivery.Have{FP: fNew, Composed: dig(fNew), Phase: delivery.PhaseRunning, Health: delivery.HealthOK} + n.Haves["ace"]["mail"] = nodeengine.HaveByNodeModuleValue{Fp: fNew, Composed: dig(fNew), Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthOK} held := put - held.Step = delivery.PutHolding - m.Puts = map[string]delivery.Put{"ace": held} - if w := Wants(m, n, map[string]delivery.Want{"ace": PutWant(held)}, nil).Wants["ace"]; w.Composed != userList || !w.Put || !w.Hold { + held.Step = puts.Holding + m.Puts = map[string]puts.Put{"ace": held} + if w := Wants(m, n, map[string]mwc.Want{"ace": PutWant(held)}, nil).Wants["ace"]; w.Composed != userList || !w.Put || !w.Hold { t.Errorf("a held bus did not take its user-list revision as a put: %+v", w) } m.Puts = nil @@ -831,11 +854,11 @@ func TestTheBusMovesByTheRulesAndItsUserListRevisionIsWrittenAtOnce(t *testing.T func stepsOfAPut(t *testing.T) { t.Helper() now := time.Date(2026, 10, 25, 22, 0, 0, 0, time.UTC) - base := delivery.Put{FP: fNew, Hold: true, Why: "the bus's user list", At: now}.Said(now, delivery.PutAsked, "asked") - d := delivery.ComposedDigest{Digest: dig(fNew), Judgement: delivery.JudgedCannot, Words: "the have names no composition"} - running := delivery.Have{FP: fOld, Phase: delivery.PhaseRunning, Health: delivery.HealthOK} + base := puts.Put{FP: fNew, Hold: true, Why: "the bus's user list", At: now}.Said(now, puts.Asked, "asked") + d := ComposedDigest{Digest: dig(fNew), Judgement: JudgedCannot, Words: "the have names no composition"} + running := nodeengine.HaveByNodeModuleValue{Fp: fOld, Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthOK} bus := func() PutWorld { - return PutWorld{Node: "novox", Module: "nats", Put: base, Assigned: true, Bus: true, Build: delivery.BuildDone, Newest: fNew, + return PutWorld{Node: "novox", Module: "nats", Put: base, Assigned: true, Bus: true, Build: builds.BuildDone, Newest: fNew, Composed: &d, Granted: true, Have: &running, Now: now} } cases := []struct { @@ -846,62 +869,62 @@ func stepsOfAPut(t *testing.T) { ask, snap bool warranted bool }{ - {"not assigned", func() PutWorld { w := bus(); w.Assigned = false; return w }, delivery.PutRefused, "not assigned", false, false, false}, - {"no build yet of the newest", func() PutWorld { w := bus(); w.Build = ""; return w }, delivery.PutBuild, "builds by itself", false, false, false}, - {"no build of an older one", func() PutWorld { w := bus(); w.Build, w.Newest = "", fpOf('c'); return w }, delivery.PutRefused, "holds no build", false, false, false}, - {"a failed build", func() PutWorld { w := bus(); w.Build, w.BuildWords = delivery.BuildFailed, "go vet"; return w }, delivery.PutRefused, "go vet", false, false, false}, - {"no composition yet: waits", func() PutWorld { w := bus(); w.Composed = nil; return w }, delivery.PutCompose, "no composition of nats for novox", false, false, false}, - {"composed, grant not naming the node", func() PutWorld { w := bus(); w.Granted = false; return w }, delivery.PutCompose, "does not name novox", false, false, false}, + {"not assigned", func() PutWorld { w := bus(); w.Assigned = false; return w }, puts.Refused, "not assigned", false, false, false}, + {"no build yet of the newest", func() PutWorld { w := bus(); w.Build = ""; return w }, puts.Build, "builds by itself", false, false, false}, + {"no build of an older one", func() PutWorld { w := bus(); w.Build, w.Newest = "", fpOf('c'); return w }, puts.Refused, "holds no build", false, false, false}, + {"a failed build", func() PutWorld { w := bus(); w.Build, w.BuildWords = builds.BuildFailed, "go vet"; return w }, puts.Refused, "go vet", false, false, false}, + {"no composition yet: waits", func() PutWorld { w := bus(); w.Composed = nil; return w }, puts.Compose, "no composition of nats for novox", false, false, false}, + {"composed, grant not naming the node", func() PutWorld { w := bus(); w.Granted = false; return w }, puts.Compose, "does not name novox", false, false, false}, {"none within the bound: refused", func() PutWorld { w := bus() w.Composed = nil - w.Put = w.Put.Said(now.Add(-ComposeWithin-time.Second), delivery.PutCompose, "waits") + w.Put = w.Put.Said(now.Add(-ComposeWithin-time.Second), puts.Compose, "waits") return w - }, delivery.PutRefused, "mesh-hand-compose", false, false, false}, - {"the bus asks the operator", bus, delivery.PutPerson, "waits for the operator's yes", true, false, false}, - {"the operator said no", func() PutWorld { w := bus(); w.Answer = OptionNo; return w }, delivery.PutRefused, "answered no", false, false, false}, - {"the ask expired unanswered", func() PutWorld { w := bus(); w.AskExpired = true; return w }, delivery.PutRefused, "expired unanswered", false, false, false}, - {"yes: the snapshot is asked", func() PutWorld { w := bus(); w.Answer = OptionYes; return w }, delivery.PutSnapshot, "asked novox's backup holder", false, true, false}, - {"the snapshot is taken", func() PutWorld { w := bus(); w.Answer, w.Snapshotting = OptionYes, true; return w }, delivery.PutSnapshot, "waits for novox's backup holder", false, false, false}, - {"the snapshot failed", func() PutWorld { w := bus(); w.Answer, w.SnapshotFailed = OptionYes, "no restic"; return w }, delivery.PutRefused, "no restic", false, false, false}, + }, puts.Refused, "mesh-hand-compose", false, false, false}, + {"the bus asks the operator", bus, puts.Person, "waits for the operator's yes", true, false, false}, + {"the operator said no", func() PutWorld { w := bus(); w.Answer = OptionNo; return w }, puts.Refused, "answered no", false, false, false}, + {"the ask expired unanswered", func() PutWorld { w := bus(); w.AskExpired = true; return w }, puts.Refused, "expired unanswered", false, false, false}, + {"yes: the snapshot is asked", func() PutWorld { w := bus(); w.Answer = OptionYes; return w }, puts.Snapshot, "asked novox's backup holder", false, true, false}, + {"the snapshot is taken", func() PutWorld { w := bus(); w.Answer, w.Snapshotting = OptionYes, true; return w }, puts.Snapshot, "waits for novox's backup holder", false, false, false}, + {"the snapshot failed", func() PutWorld { w := bus(); w.Answer, w.SnapshotFailed = OptionYes, "no restic"; return w }, puts.Refused, "no restic", false, false, false}, {"snapshotted: the want, warranted", func() PutWorld { w := bus() w.Answer, w.Snapshot = OptionYes, "novox's restore point" - w.Put.Ask, _ = delivery.RecordAskKey("novox", "nats", d.Digest) + w.Put.Ask, _ = asks.RecordAskKey("novox", "nats", d.Digest) return w - }, delivery.PutWanted, "held until released; the streams were snapshotted: novox's restore point", false, false, true}, + }, puts.Wanted, "held until released; the streams were snapshotted: novox's restore point", false, false, true}, {"a user-list revision of the bus needs nobody", func() PutWorld { w := bus() ul := d ul.UserListOnly = true - w.Composed, w.Have = &ul, &delivery.Have{FP: fNew, Composed: dig(fOld), Phase: delivery.PhaseRunning, Health: delivery.HealthOK} + w.Composed, w.Have = &ul, &nodeengine.HaveByNodeModuleValue{Fp: fNew, Composed: dig(fOld), Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthOK} return w - }, delivery.PutWanted, "held until released", false, false, false}, - {"a module not recorded needs nobody", func() PutWorld { w := bus(); w.Bus = false; return w }, delivery.PutWanted, "names", false, false, false}, + }, puts.Wanted, "held until released", false, false, false}, + {"a module not recorded needs nobody", func() PutWorld { w := bus(); w.Bus = false; return w }, puts.Wanted, "names", false, false, false}, {"a recorded module judged to recreate asks", func() PutWorld { w := bus() w.Bus, w.Recorded = false, true r := d - r.Judgement = delivery.JudgedRecreates + r.Judgement = JudgedRecreates w.Composed = &r return w - }, delivery.PutPerson, "waits for the operator's yes", true, false, false}, + }, puts.Person, "waits for the operator's yes", true, false, false}, {"wanted, the node runs it: holding", func() PutWorld { w := bus() - w.Put = w.Put.Said(now, delivery.PutWanted, "wanted") + w.Put = w.Put.Said(now, puts.Wanted, "wanted") w.Put.Composed = d.Digest - w.Have = &delivery.Have{FP: fNew, Composed: d.Digest, Phase: delivery.PhaseRunning, Health: delivery.HealthOK} + w.Have = &nodeengine.HaveByNodeModuleValue{Fp: fNew, Composed: d.Digest, Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthOK} return w - }, delivery.PutHolding, "held there until released", false, false, false}, + }, puts.Holding, "held there until released", false, false, false}, {"wanted without hold: done", func() PutWorld { w := bus() w.Put.Hold = false - w.Put = w.Put.Said(now, delivery.PutWanted, "wanted") + w.Put = w.Put.Said(now, puts.Wanted, "wanted") w.Put.Composed = d.Digest - w.Have = &delivery.Have{FP: fNew, Composed: d.Digest, Phase: delivery.PhaseRunning, Health: delivery.HealthOK} + w.Have = &nodeengine.HaveByNodeModuleValue{Fp: fNew, Composed: d.Digest, Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthOK} return w - }, delivery.PutDone, "the rules write its want again", false, false, false}, - {"released: nothing more", func() PutWorld { w := bus(); w.Put = w.Put.Said(now, delivery.PutReleased, "released"); return w }, delivery.PutReleased, "released", false, false, false}, + }, puts.Done, "the rules write its want again", false, false, false}, + {"released: nothing more", func() PutWorld { w := bus(); w.Put = w.Put.Said(now, puts.Released, "released"); return w }, puts.Released, "released", false, false, false}, } for _, c := range cases { next := StepPut(c.world()) @@ -921,14 +944,14 @@ func stepsOfAPut(t *testing.T) { // 1906/D4 (ADR 0539 §16): the answer to a failed backup stays in the want while its target stays. func TestTheBackupAnswerStaysInTheWantWhileItsTargetStays(t *testing.T) { m, n := ready() - running := map[string]delivery.Want{"ace": {FP: fNew, Composed: dig(fNew), Backup: delivery.BackupSkip, Words: BackupAnswerWords("skip", 0)}} + running := map[string]mwc.Want{"ace": {Fp: fNew, Composed: dig(fNew), Backup: mwc.BackupSkip, Words: BackupAnswerWords("skip", 0)}} if w, ok := Wants(m, n, running, nil).Wants["ace"]; ok { t.Errorf("the answer was erased: %+v", w) } // A placed node composes its own: its want names the build alone, and the answer stays in it all the same (the digest its // have names is not in the want, so a want kept for its digest would erase the answer at every pass). m.Self = func(string) bool { return true } - placed := map[string]delivery.Want{"ace": {FP: fNew, Self: true, Backup: delivery.BackupSkip, Words: BackupAnswerWords("skip", 0)}} + placed := map[string]mwc.Want{"ace": {Fp: fNew, Self: true, Backup: mwc.BackupSkip, Words: BackupAnswerWords("skip", 0)}} if w, ok := Wants(m, n, placed, nil).Wants["ace"]; ok { t.Errorf("the answer in a placed node's want was erased: %+v", w) } @@ -938,13 +961,13 @@ func TestTheBackupAnswerStaysInTheWantWhileItsTargetStays(t *testing.T) { // wants move by the rules again. func TestAReleasedHoldIsNotHeldAgain(t *testing.T) { m, n := ready() - n.Haves["ace"]["mail"] = delivery.Have{FP: fNew, Phase: delivery.PhaseRunning, Health: delivery.HealthFailed, Detail: "x"} + n.Haves["ace"]["mail"] = nodeengine.HaveByNodeModuleValue{Fp: fNew, Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthFailed, Detail: "x"} m.Released = true - out := Wants(m, n, map[string]delivery.Want{"ace": {FP: fOld, Composed: dig(fOld), Newest: fNew, Rule: delivery.RuleNotHeld}}, map[string]string{"ace": fOld}) + out := Wants(m, n, map[string]mwc.Want{"ace": {Fp: fOld, Composed: dig(fOld), Newest: fNew, Rule: mwc.RuleNotHeld}}, map[string]string{"ace": fOld}) if out.Held != nil { t.Errorf("a released fingerprint was held again: %+v", out.Held) } - if w := out.Wants["ace"]; w.FP != fNew || w.Composed != dig(fNew) || w.Rule != 0 { + if w := out.Wants["ace"]; w.Fp != fNew || w.Composed != dig(fNew) || w.Rule != 0 { t.Errorf("ace's want = %+v, want the held fingerprint written again", w) } } @@ -954,14 +977,14 @@ func TestAReleasedHoldIsNotHeldAgain(t *testing.T) { func TestARestoreAnswerSetsADataModuleBack(t *testing.T) { m, n := ready() m.Manifest.Data = &catalogue.Data{Own: []catalogue.DataItem{{ID: "mail", Class: catalogue.ClassIrreplaceable}}} - n.Haves["ace"]["mail"] = delivery.Have{FP: fNew, Phase: delivery.PhaseRunning, Health: delivery.HealthFailed} - running := map[string]delivery.Want{"ace": {FP: fNew, Composed: dig(fNew)}} + n.Haves["ace"]["mail"] = nodeengine.HaveByNodeModuleValue{Fp: fNew, Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthFailed} + running := map[string]mwc.Want{"ace": {Fp: fNew, Composed: dig(fNew)}} m.Restored = func(node string) bool { return false } - if w, ok := Wants(m, n, running, map[string]string{"ace": fOld}).Wants["ace"]; ok && w.FP == fOld { + if w, ok := Wants(m, n, running, map[string]string{"ace": fOld}).Wants["ace"]; ok && w.Fp == fOld { t.Errorf("set back without the operator's restore: %+v", w) } m.Restored = func(node string) bool { return node == "ace" } - if w := Wants(m, n, running, map[string]string{"ace": fOld}).Wants["ace"]; w.FP != fOld || w.Composed != dig(fOld) { + if w := Wants(m, n, running, map[string]string{"ace": fOld}).Wants["ace"]; w.Fp != fOld || w.Composed != dig(fOld) { t.Errorf("after restore ace's want = %+v", w) } } @@ -969,22 +992,22 @@ func TestARestoreAnswerSetsADataModuleBack(t *testing.T) { // 1906/D4 (ADR 0539 §14): the policy the controller holds beside the composition decides, the operator's own choice included; // a composition with none (made by hand) falls back to what the manifest derives. func TestTheRecordPolicyIsTheOneTheControllerHolds(t *testing.T) { - m, n, wanted := movingTo(delivery.JudgedMovesData) + m, n, wanted := movingTo(JudgedMovesData) with := func(policy, from string) { inner := m.Composed - m.Composed = func(node, fp string) (delivery.ComposedDigest, bool) { + m.Composed = func(node, fp string) (ComposedDigest, bool) { d, ok := inner(node, fp) d.Policy, d.PolicyFrom = policy, from return d, ok } } - with(delivery.PolicyRecord, "person") // a roll module the operator set to record - if out := Wants(m, n, wanted, nil); len(out.Record) == 0 || out.Wants["ace"].FP == fNew { + with(catalogue.PolicyRecord, "person") // a roll module the operator set to record + if out := Wants(m, n, wanted, nil); len(out.Record) == 0 || out.Wants["ace"].Fp == fNew { t.Errorf("the operator's record policy was not honoured: %+v", out) } recording(&m) // the manifest says record, the operator chose roll with("roll", "person") - if out := Wants(m, n, wanted, nil); len(out.Record) != 0 || out.Wants["ace"].FP != fNew { + if out := Wants(m, n, wanted, nil); len(out.Record) != 0 || out.Wants["ace"].Fp != fNew { t.Errorf("the operator's roll policy was not honoured: %+v", out) } with("", "") // made by hand: the manifest decides @@ -992,9 +1015,9 @@ func TestTheRecordPolicyIsTheOneTheControllerHolds(t *testing.T) { t.Errorf("a hand-made composition fell through the manifest's record policy: %+v", out) } // 1953/D4 (ADR 0565 §7): a person's choice at the assigner is over the controller's beside the composition. - with(delivery.PolicyRecord, "person") + with(catalogue.PolicyRecord, "person") m.Chosen = &catalogue.PersonPolicy{V: catalogue.PersonPolicyVersion, Module: m.Name, Policy: catalogue.PolicyRoll, Why: "w", By: "op"} - if out := Wants(m, n, wanted, nil); len(out.Record) != 0 || out.Wants["ace"].FP != fNew { + if out := Wants(m, n, wanted, nil); len(out.Record) != 0 || out.Wants["ace"].Fp != fNew { t.Errorf("a person's roll at the assigner was not honoured over the controller's record: %+v", out) } } @@ -1002,14 +1025,14 @@ func TestTheRecordPolicyIsTheOneTheControllerHolds(t *testing.T) { // A node that runs the bus with no readable want gets its want by the rules, at the fingerprint it runs or at a new one alike: the // bus has no bar of its own (ADR 0601 §1). func TestTheBusWithNoWantIsComparedWithWhatItRuns(t *testing.T) { - m, n, _ := movingTo(delivery.JudgedNeither) + m, n, _ := movingTo(JudgedNeither) m.Manifest.Provides = []catalogue.Offer{{Name: "mesh-bus"}} - n.Haves["ace"]["mail"] = delivery.Have{FP: fNew, Phase: delivery.PhaseRunning, Health: delivery.HealthOK} - if w, ok := Wants(m, n, nil, nil).Wants["ace"]; !ok || w.FP != fNew { + n.Haves["ace"]["mail"] = nodeengine.HaveByNodeModuleValue{Fp: fNew, Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthOK} + if w, ok := Wants(m, n, nil, nil).Wants["ace"]; !ok || w.Fp != fNew { t.Errorf("the bus runs F with no want and was not given one: %+v %v", w, ok) } - n.Haves["ace"]["mail"] = delivery.Have{FP: fOld, Phase: delivery.PhaseRunning, Health: delivery.HealthOK} - if w, ok := Wants(m, n, nil, nil).Wants["ace"]; !ok || w.FP != fNew { + n.Haves["ace"]["mail"] = nodeengine.HaveByNodeModuleValue{Fp: fOld, Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthOK} + if w, ok := Wants(m, n, nil, nil).Wants["ace"]; !ok || w.Fp != fNew { t.Errorf("the bus running an older fingerprint with no want was not given its want by the rules: %+v %v", w, ok) } } @@ -1019,24 +1042,24 @@ func TestTheBusWithNoWantIsComparedWithWhatItRuns(t *testing.T) { // says recreates) and whatever policy the bus has (record, from the bus). The shape is the controller's: judged recreates against // the composition the node runs, policy record, UserListOnly set. func TestAUserListOnlyRevisionOfTheBusIsWrittenAtOnceBeforeTheRecordHold(t *testing.T) { - m, n, _ := movingTo(delivery.JudgedRecreates) + m, n, _ := movingTo(JudgedRecreates) m.Manifest.Provides = []catalogue.Offer{{Name: "mesh-bus"}} userList := "sha256:" + strings.Repeat("7", 64) - m.Composed = func(node, fp string) (delivery.ComposedDigest, bool) { - d := delivery.ComposedDigest{Digest: dig(fp), Judgement: delivery.JudgedRecreates, Against: dig(fOld), Words: "the user list file is mounted", - Policy: delivery.PolicyRecord, PolicyFrom: "the bus"} + m.Composed = func(node, fp string) (ComposedDigest, bool) { + d := ComposedDigest{Digest: dig(fp), Judgement: JudgedRecreates, Against: dig(fOld), Words: "the user list file is mounted", + Policy: catalogue.PolicyRecord, PolicyFrom: "the bus"} if fp == fOld { d.Digest, d.UserListOnly = userList, true // the composition at the fingerprint the node runs, with the new user list } return d, true } - n.Haves["ace"]["mail"] = delivery.Have{FP: fOld, Phase: delivery.PhaseRunning, Health: delivery.HealthOK} - for name, wanted := range map[string]map[string]delivery.Want{ - "a want at the fingerprint it runs": {"ace": {FP: fOld, Composed: dig(fOld)}}, + n.Haves["ace"]["mail"] = nodeengine.HaveByNodeModuleValue{Fp: fOld, Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthOK} + for name, wanted := range map[string]map[string]mwc.Want{ + "a want at the fingerprint it runs": {"ace": {Fp: fOld, Composed: dig(fOld)}}, "no readable want": nil, } { out := Wants(m, n, wanted, nil) - if w := out.Wants["ace"]; w.FP != fOld || w.Composed != userList { + if w := out.Wants["ace"]; w.Fp != fOld || w.Composed != userList { t.Errorf("%s: ace's want = %+v, want the running fingerprint naming the user-list revision", name, w) } if len(out.Record) != 0 { @@ -1045,7 +1068,7 @@ func TestAUserListOnlyRevisionOfTheBusIsWrittenAtOnceBeforeTheRecordHold(t *test } // the same flag on a module that is not the bus changes nothing m.Manifest.Provides = nil - if out := Wants(m, n, map[string]delivery.Want{"ace": {FP: fOld, Composed: dig(fOld)}}, nil); out.Wants["ace"].Composed == userList { + if out := Wants(m, n, map[string]mwc.Want{"ace": {Fp: fOld, Composed: dig(fOld)}}, nil); out.Wants["ace"].Composed == userList { t.Errorf("a module that is not the bus was written at once: %+v", out.Wants["ace"]) } } @@ -1057,19 +1080,19 @@ func TestAUserListOnlyRevisionOfTheBusIsWrittenAtOnceBeforeTheRecordHold(t *test func TestAHaveFromBeforeTheSwitchGetsItsWantWithNoRecordHold(t *testing.T) { for name, bus := range map[string]bool{"a record module": false, "the bus": true} { // only a composition the controller judged unchanged (it equals the declaration the node last applied, ADR 0540 §9) skips the hold - unchanged := func(node, fp string) (delivery.ComposedDigest, bool) { - return delivery.ComposedDigest{Digest: dig(fp), Judgement: delivery.JudgedUnchanged}, true + unchanged := func(node, fp string) (ComposedDigest, bool) { + return ComposedDigest{Digest: dig(fp), Judgement: JudgedUnchanged}, true } - m, n, _ := movingTo(delivery.JudgedCannot) + m, n, _ := movingTo(JudgedCannot) recording(&m) m.Nodes = []string{"ace"} if bus { m.Manifest.Provides = []catalogue.Offer{{Name: "mesh-bus"}} } m.Composed = unchanged - n.Haves["ace"]["mail"] = delivery.Have{FP: fNew, Phase: delivery.PhaseRunning, Health: delivery.HealthOK} // no composition named + n.Haves["ace"]["mail"] = nodeengine.HaveByNodeModuleValue{Fp: fNew, Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthOK} // no composition named out := Wants(m, n, nil, nil) - if w := out.Wants["ace"]; w.FP != fNew || w.Composed != dig(fNew) { + if w := out.Wants["ace"]; w.Fp != fNew || w.Composed != dig(fNew) { t.Errorf("%s: ace's want = %+v, want F naming its composition", name, w) } if len(out.Record) != 0 { @@ -1077,17 +1100,17 @@ func TestAHaveFromBeforeTheSwitchGetsItsWantWithNoRecordHold(t *testing.T) { } // a composition that differs from the declaration the node kept (a changed setting or secret), and one the controller did not judge, // still wait for the ask - for what, j := range map[string]delivery.Judgement{"differing": delivery.JudgedRecreates, "unjudged": delivery.JudgedCannot, "judged neither": delivery.JudgedNeither} { + for what, j := range map[string]Judgement{"differing": JudgedRecreates, "unjudged": JudgedCannot, "judged neither": JudgedNeither} { m2, n2, _ := movingTo(j) recording(&m2) m2.Nodes = []string{"ace"} if bus { m2.Manifest.Provides = []catalogue.Offer{{Name: "mesh-bus"}} } - n2.Haves["ace"]["mail"] = delivery.Have{FP: fNew, Phase: delivery.PhaseRunning, Health: delivery.HealthOK} + n2.Haves["ace"]["mail"] = nodeengine.HaveByNodeModuleValue{Fp: fNew, Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthOK} out := Wants(m2, n2, nil, nil) - if j == delivery.JudgedNeither { - if out.Wants["ace"].FP != fNew || len(out.Record) != 0 { + if j == JudgedNeither { + if out.Wants["ace"].Fp != fNew || len(out.Record) != 0 { t.Errorf("%s, %s: %+v", name, what, out) } continue @@ -1097,15 +1120,15 @@ func TestAHaveFromBeforeTheSwitchGetsItsWantWithNoRecordHold(t *testing.T) { } } // a have that names a composition is judged as before - m.Composed = func(node, fp string) (delivery.ComposedDigest, bool) { - return delivery.ComposedDigest{Digest: dig(fp), Judgement: delivery.JudgedRecreates, Against: dig(fOld), Words: "judged"}, true + m.Composed = func(node, fp string) (ComposedDigest, bool) { + return ComposedDigest{Digest: dig(fp), Judgement: JudgedRecreates, Against: dig(fOld), Words: "judged"}, true } - n.Haves["ace"]["mail"] = delivery.Have{FP: fNew, Composed: dig(fOld), Phase: delivery.PhaseRunning, Health: delivery.HealthOK} - if out := Wants(m, n, nil, nil); out.Wants["ace"].FP == fNew && len(out.Record) == 0 { + n.Haves["ace"]["mail"] = nodeengine.HaveByNodeModuleValue{Fp: fNew, Composed: dig(fOld), Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthOK} + if out := Wants(m, n, nil, nil); out.Wants["ace"].Fp == fNew && len(out.Record) == 0 { t.Errorf("%s: a have naming a composition skipped the hold: %+v", name, out) } // a new fingerprint still asks (the bus is barred instead) - n.Haves["ace"]["mail"] = delivery.Have{FP: fOld, Phase: delivery.PhaseRunning, Health: delivery.HealthOK} + n.Haves["ace"]["mail"] = nodeengine.HaveByNodeModuleValue{Fp: fOld, Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthOK} out = Wants(m, n, nil, nil) if w, ok := out.Wants["ace"]; ok { t.Errorf("%s: a new fingerprint was written with no yes: %+v", name, w) @@ -1119,8 +1142,8 @@ func TestAHaveFromBeforeTheSwitchGetsItsWantWithNoRecordHold(t *testing.T) { // 1906/H3: a failed check whose node gave no words is held saying so, never with empty words. func TestAHoldOfAFailureWithoutWordsSaysSo(t *testing.T) { m, n := ready() - n.Haves["ace"]["mail"] = delivery.Have{FP: fNew, Phase: delivery.PhaseRunning, Health: delivery.HealthFailed} - out := Wants(m, n, map[string]delivery.Want{"ace": {FP: fNew, Composed: dig(fNew)}}, map[string]string{"ace": fOld}) + n.Haves["ace"]["mail"] = nodeengine.HaveByNodeModuleValue{Fp: fNew, Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthFailed} + out := Wants(m, n, map[string]mwc.Want{"ace": {Fp: fNew, Composed: dig(fNew)}}, map[string]string{"ace": fOld}) if out.Held == nil || out.Held.Words != "health failed, phase running: the node gave no words" { t.Fatalf("held = %+v", out.Held) } @@ -1133,7 +1156,7 @@ func TestAHoldIsLiftedOnceEveryNodeRunsItHealthy(t *testing.T) { everywhere := func() (Module, Nodes) { m, n := ready() m.Held = true - n.Haves["g14"]["mail"] = delivery.Have{FP: fNew, Phase: delivery.PhaseRunning, Health: delivery.HealthOK} + n.Haves["g14"]["mail"] = nodeengine.HaveByNodeModuleValue{Fp: fNew, Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthOK} return m, n } m, n := everywhere() @@ -1142,7 +1165,7 @@ func TestAHoldIsLiftedOnceEveryNodeRunsItHealthy(t *testing.T) { t.Fatalf("lifted = %v, held = %+v: want the hold lifted", out.Lifted, out.Held) } for _, node := range []string{"ace", "g14"} { - if w := out.Wants[node]; w.FP != fNew || w.Rule != 0 { + if w := out.Wants[node]; w.Fp != fNew || w.Rule != 0 { t.Errorf("%s's want = %+v, want it moved to the lifted fingerprint", node, w) } } @@ -1155,10 +1178,10 @@ func TestAHoldIsLiftedOnceEveryNodeRunsItHealthy(t *testing.T) { for name, change := range map[string]func(*Module, *Nodes){ "set back": func(_ *Module, n *Nodes) { - n.Haves["g14"]["mail"] = delivery.Have{FP: fOld, Phase: delivery.PhaseRunning, Health: delivery.HealthOK} + n.Haves["g14"]["mail"] = nodeengine.HaveByNodeModuleValue{Fp: fOld, Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthOK} }, "failing": func(_ *Module, n *Nodes) { - n.Haves["g14"]["mail"] = delivery.Have{FP: fNew, Phase: delivery.PhaseRunning, Health: delivery.HealthFailed} + n.Haves["g14"]["mail"] = nodeengine.HaveByNodeModuleValue{Fp: fNew, Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthFailed} }, "kept": func(m *Module, _ *Nodes) { m.Kept = true }, "no haves": func(_ *Module, n *Nodes) { delete(n.Haves["g14"], "mail"); delete(n.Haves["ace"], "mail") }, @@ -1181,11 +1204,11 @@ func x11() (Module, Nodes) { Manifest: catalogue.Manifest{Module: "screen-lock", Requires: []string{"x11-display"}}} n := Nodes{ Online: map[string]bool{"g14": true, "shanks": true}, - Haves: map[string]map[string]delivery.Have{ - "g14": {"screen-lock": {FP: fOld, Phase: delivery.PhaseRunning, Health: delivery.HealthUnknown}, - "xorg": {FP: fpOf('x'), Phase: delivery.PhaseRunning, Health: delivery.HealthUnknown}}, - "shanks": {"screen-lock": {FP: fOld, Phase: delivery.PhaseRunning, Health: delivery.HealthUnknown}, - "xorg": {FP: fpOf('x'), Phase: delivery.PhaseRunning, Health: delivery.HealthUnknown}}, + Haves: map[string]map[string]nodeengine.HaveByNodeModuleValue{ + "g14": {"screen-lock": {Fp: fOld, Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthUnknown}, + "xorg": {Fp: fpOf('x'), Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthUnknown}}, + "shanks": {"screen-lock": {Fp: fOld, Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthUnknown}, + "xorg": {Fp: fpOf('x'), Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthUnknown}}, }, Providers: map[string]map[string][]string{"x11-display": {"xorg": {"g14", "shanks"}}}, } @@ -1199,10 +1222,10 @@ func TestANodeRunningAModuleWithNoWantGetsItsFirstWantAtWhatItRuns(t *testing.T) m, n := x11() out := Wants(m, n, nil, nil) // 1960: the providers rule holds no want: the canary g14 gets F, its provider's health unknown said - if w := out.Wants["g14"]; w.FP != fNew || out.Unreached["g14"] == "" { + if w := out.Wants["g14"]; w.Fp != fNew || out.Unreached["g14"] == "" { t.Errorf("g14's want = %+v (unreached %q), want F, the provider said", w, out.Unreached["g14"]) } - if w := out.Wants["shanks"]; w.FP != fOld || w.Composed != dig(fOld) || w.Newest != fNew || w.Rule != delivery.RuleCanary { + if w := out.Wants["shanks"]; w.Fp != fOld || w.Composed != dig(fOld) || w.Newest != fNew || w.Rule != mwc.RuleCanary { t.Errorf("shanks's want = %+v, want its first want at the fingerprint it runs, held back from F by the canary", w) } if len(out.Neither) != 0 { @@ -1210,16 +1233,16 @@ func TestANodeRunningAModuleWithNoWantGetsItsFirstWantAtWhatItRuns(t *testing.T) } // with the provider healthy, only the canary holds shanks back: still at what it runs m, n = x11() - n.Haves["g14"]["xorg"] = delivery.Have{FP: fpOf('x'), Phase: delivery.PhaseRunning, Health: delivery.HealthOK} + n.Haves["g14"]["xorg"] = nodeengine.HaveByNodeModuleValue{Fp: fpOf('x'), Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthOK} n.Haves["shanks"]["xorg"] = n.Haves["g14"]["xorg"] - if w := Wants(m, n, nil, nil).Wants["shanks"]; w.FP != fOld || w.Rule != delivery.RuleCanary { + if w := Wants(m, n, nil, nil).Wants["shanks"]; w.Fp != fOld || w.Rule != mwc.RuleCanary { t.Errorf("shanks held back by the canary with no want: %+v, want at fOld by rule 3", w) } // a node already running F gets its want at F; a removed want is no want either m, n = x11() - n.Haves["g14"]["screen-lock"] = delivery.Have{FP: fNew, Phase: delivery.PhaseRunning, Health: delivery.HealthUnknown} - out = Wants(m, n, map[string]delivery.Want{"g14": {Removed: true}}, nil) - if w := out.Wants["g14"]; w.FP != fNew || w.Composed != dig(fNew) || w.Removed { + n.Haves["g14"]["screen-lock"] = nodeengine.HaveByNodeModuleValue{Fp: fNew, Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthUnknown} + out = Wants(m, n, map[string]mwc.Want{"g14": {Removed: true}}, nil) + if w := out.Wants["g14"]; w.Fp != fNew || w.Composed != dig(fNew) || w.Removed { t.Errorf("g14 running F with a removed want = %+v, want F", w) } } @@ -1229,12 +1252,12 @@ func TestANodeRunningAModuleWithNoWantGetsItsFirstWantAtWhatItRuns(t *testing.T) func TestNoFirstWantWithoutTheCompositionAndGrantOfWhatItRuns(t *testing.T) { for what, change := range map[string]func(*Module, *Nodes){ "an old-form build": func(_ *Module, n *Nodes) { - n.Haves["shanks"]["screen-lock"] = delivery.Have{FP: "build-2", Phase: delivery.PhaseRunning, Health: delivery.HealthUnknown} + n.Haves["shanks"]["screen-lock"] = nodeengine.HaveByNodeModuleValue{Fp: "build-2", Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthUnknown} }, "no grant": func(m *Module, _ *Nodes) { m.GrantedAt = func(fp string) bool { return fp == fNew } }, "no composition": func(m *Module, _ *Nodes) { - m.Composed = func(_, fp string) (delivery.ComposedDigest, bool) { - return delivery.ComposedDigest{Digest: dig(fp), Judgement: delivery.JudgedNeither}, fp == fNew + m.Composed = func(_, fp string) (ComposedDigest, bool) { + return ComposedDigest{Digest: dig(fp), Judgement: JudgedNeither}, fp == fNew } }, } { @@ -1246,13 +1269,13 @@ func TestNoFirstWantWithoutTheCompositionAndGrantOfWhatItRuns(t *testing.T) { if w, ok := out.Wants["shanks"]; ok { t.Errorf("%s: shanks got a want: %+v", what, w) } - if h, ok := out.Neither["shanks"]; !ok || h.Rule != delivery.RuleCanary { + if h, ok := out.Neither["shanks"]; !ok || h.Rule != mwc.RuleCanary { t.Errorf("%s: shanks on neither list = %+v (named %v), want rule 3", what, h, ok) } continue } // 1960: no want can name what it runs, so its first want names F rather than none: never on neither list - if w := out.Wants["shanks"]; w.FP != fNew { + if w := out.Wants["shanks"]; w.Fp != fNew { t.Errorf("%s: shanks's want = %+v, want F", what, w) } if h, ok := out.Neither["shanks"]; ok { @@ -1260,16 +1283,16 @@ func TestNoFirstWantWithoutTheCompositionAndGrantOfWhatItRuns(t *testing.T) { } } m, n := x11() - before := map[string]delivery.Want{"g14": {FP: fOld, Composed: dig(fOld)}, "shanks": {FP: fOld, Composed: dig(fOld)}} + before := map[string]mwc.Want{"g14": {Fp: fOld, Composed: dig(fOld)}, "shanks": {Fp: fOld, Composed: dig(fOld)}} out := Wants(m, n, before, nil) if len(out.Neither) != 0 { t.Errorf("nodes holding a want named on neither list: %+v", out.Neither) } // the canary g14 moves to F (its provider's health no rule); shanks waits for it - if w := out.Wants["shanks"]; w.FP != "" && w.FP != fOld { + if w := out.Wants["shanks"]; w.Fp != "" && w.Fp != fOld { t.Errorf("shanks's want moved from its want past the canary: %+v", w) } - if w := out.Wants["g14"]; w.FP != fNew { + if w := out.Wants["g14"]; w.Fp != fNew { t.Errorf("the canary g14's want = %+v, want F", w) } } @@ -1282,31 +1305,31 @@ func TestANewAssignmentWaitingIsNotOnNeitherList(t *testing.T) { delete(n.Haves["shanks"], "screen-lock") out := Wants(m, n, nil, nil) // 1960: the canary orders moves, never a first want: the new assignment is wanted at F - if w := out.Wants["shanks"]; w.FP != fNew { + if w := out.Wants["shanks"]; w.Fp != fNew { t.Errorf("a new assignment while the canary does not run F: %+v, want F", w) } if _, ok := out.Neither["shanks"]; ok { t.Errorf("a new assignment is named on neither list: %+v", out.Neither) } m.Built = false - if out := Wants(m, n, nil, nil); len(out.Neither) != 1 || out.Neither["g14"].Rule != delivery.RuleBuildDone { + if out := Wants(m, n, nil, nil); len(out.Neither) != 1 || out.Neither["g14"].Rule != mwc.RuleBuildDone { t.Errorf("with no build: neither = %+v, want g14 alone (it runs the module), by rule 1", out.Neither) } // 1945 (the assigner holds a first want back silently): with no build, a node whose want was removed and that does not run // the module has the reason written into its removed want, once; a node with no key gets no want written, and a node that // runs the module is on neither list, its removed want left as it is. - gone := delivery.Want{Removed: true, Words: "screen-lock is no longer assigned to shanks"} + gone := mwc.Want{Removed: true, Words: "screen-lock is no longer assigned to shanks"} for _, c := range []struct { name string - before map[string]delivery.Want - want map[string]delivery.Want + before map[string]mwc.Want + want map[string]mwc.Want }{ - {"no key: nothing written", nil, map[string]delivery.Want{}}, - {"a removed want: it says why", map[string]delivery.Want{"shanks": gone}, - map[string]delivery.Want{"shanks": {Removed: true, Words: "no want: rule 1: the build of screen-lock at " + short(m.FP) + " is not done"}}}, - {"a removed want that says why already: nothing written", map[string]delivery.Want{"shanks": {Removed: true, Words: "no want: rule 1: the build of screen-lock at " + short(m.FP) + " is not done"}}, - map[string]delivery.Want{}}, - {"a node that runs it: on neither list, its removed want left", map[string]delivery.Want{"g14": gone}, map[string]delivery.Want{}}, + {"no key: nothing written", nil, map[string]mwc.Want{}}, + {"a removed want: it says why", map[string]mwc.Want{"shanks": gone}, + map[string]mwc.Want{"shanks": {Removed: true, Words: "no want: rule 1: the build of screen-lock at " + short(m.FP) + " is not done"}}}, + {"a removed want that says why already: nothing written", map[string]mwc.Want{"shanks": {Removed: true, Words: "no want: rule 1: the build of screen-lock at " + short(m.FP) + " is not done"}}, + map[string]mwc.Want{}}, + {"a node that runs it: on neither list, its removed want left", map[string]mwc.Want{"g14": gone}, map[string]mwc.Want{}}, } { out := Wants(m, n, c.before, nil) if len(out.Wants) != len(c.want) { @@ -1314,7 +1337,7 @@ func TestANewAssignmentWaitingIsNotOnNeitherList(t *testing.T) { continue } for node, w := range c.want { - if !delivery.SameWant(normal(out.Wants[node]), w) { + if !mwc.SameWant(normal(out.Wants[node]), w) { t.Errorf("%s: want of %s = %+v, want %+v", c.name, node, out.Wants[node], w) } } @@ -1331,20 +1354,20 @@ func TestANewAssignmentWaitingIsNotOnNeitherList(t *testing.T) { // stays on neither list; the providers hold no want (issue 1960). The old-form node that is itself the canary gets F once its providers run healthy. func TestAnOldFormNodeGetsItsFirstWantAtFOnlyUnderTheCanary(t *testing.T) { oldForm := func(n Nodes, node string) { - n.Haves[node]["screen-lock"] = delivery.Have{FP: "build-1792260876956849831", Phase: delivery.PhaseRunning, Health: delivery.HealthUnknown} + n.Haves[node]["screen-lock"] = nodeengine.HaveByNodeModuleValue{Fp: "build-1792260876956849831", Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthUnknown} } providersHealthy := func(n Nodes) { for _, node := range []string{"g14", "shanks"} { - n.Haves[node]["xorg"] = delivery.Have{FP: fpOf('x'), Phase: delivery.PhaseRunning, Health: delivery.HealthOK} + n.Haves[node]["xorg"] = nodeengine.HaveByNodeModuleValue{Fp: fpOf('x'), Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthOK} } } // the canary g14 runs F healthy: shanks's first want names F m, n := x11() providersHealthy(n) oldForm(n, "shanks") - n.Haves["g14"]["screen-lock"] = delivery.Have{FP: fNew, Phase: delivery.PhaseRunning, Health: delivery.HealthOK} + n.Haves["g14"]["screen-lock"] = nodeengine.HaveByNodeModuleValue{Fp: fNew, Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthOK} out := Wants(m, n, nil, nil) - if w := out.Wants["shanks"]; w.FP != fNew || w.Composed != dig(fNew) || w.Newest != "" || w.Rule != 0 { + if w := out.Wants["shanks"]; w.Fp != fNew || w.Composed != dig(fNew) || w.Newest != "" || w.Rule != 0 { t.Errorf("canary healthy on F: shanks's want = %+v, want F", w) } if _, ok := out.Neither["shanks"]; ok { @@ -1354,20 +1377,20 @@ func TestAnOldFormNodeGetsItsFirstWantAtFOnlyUnderTheCanary(t *testing.T) { m, n = x11() providersHealthy(n) oldForm(n, "shanks") - n.Haves["g14"]["screen-lock"] = delivery.Have{FP: fNew, Phase: delivery.PhaseRunning, Health: delivery.HealthUnknown} + n.Haves["g14"]["screen-lock"] = nodeengine.HaveByNodeModuleValue{Fp: fNew, Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthUnknown} out = Wants(m, n, nil, nil) if w, ok := out.Wants["shanks"]; ok { t.Errorf("canary not healthy on F: shanks got a want: %+v", w) } - if h, ok := out.Neither["shanks"]; !ok || h.Rule != delivery.RuleCanary { + if h, ok := out.Neither["shanks"]; !ok || h.Rule != mwc.RuleCanary { t.Errorf("canary not healthy on F: shanks on neither list = %+v (named %v), want rule 3", h, ok) } // 1960: the canary healthy on F but the providers not healthy on shanks: F all the same, the provider down said m, n = x11() oldForm(n, "shanks") - n.Haves["g14"]["screen-lock"] = delivery.Have{FP: fNew, Phase: delivery.PhaseRunning, Health: delivery.HealthOK} + n.Haves["g14"]["screen-lock"] = nodeengine.HaveByNodeModuleValue{Fp: fNew, Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthOK} out = Wants(m, n, nil, nil) - if w := out.Wants["shanks"]; w.FP != fNew || out.Unreached["shanks"] == "" { + if w := out.Wants["shanks"]; w.Fp != fNew || out.Unreached["shanks"] == "" { t.Errorf("providers not healthy: shanks's want = %+v (unreached %q), want F, the provider said", w, out.Unreached["shanks"]) } if _, on := out.Neither["shanks"]; on { @@ -1377,7 +1400,7 @@ func TestAnOldFormNodeGetsItsFirstWantAtFOnlyUnderTheCanary(t *testing.T) { m, n = x11() providersHealthy(n) oldForm(n, "shanks") - n.Haves["g14"]["screen-lock"] = delivery.Have{FP: fNew, Phase: delivery.PhaseRunning, Health: delivery.HealthOK} + n.Haves["g14"]["screen-lock"] = nodeengine.HaveByNodeModuleValue{Fp: fNew, Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthOK} m.Granted = false if w, ok := Wants(m, n, nil, nil).Wants["shanks"]; ok { t.Errorf("F not granted: shanks got a want: %+v", w) @@ -1386,7 +1409,7 @@ func TestAnOldFormNodeGetsItsFirstWantAtFOnlyUnderTheCanary(t *testing.T) { m, n = x11() providersHealthy(n) oldForm(n, "g14") - if w := Wants(m, n, nil, nil).Wants["g14"]; w.FP != fNew || w.Composed != dig(fNew) { + if w := Wants(m, n, nil, nil).Wants["g14"]; w.Fp != fNew || w.Composed != dig(fNew) { t.Errorf("old-form canary: g14's want = %+v, want F", w) } } @@ -1395,22 +1418,22 @@ func TestAnOldFormNodeGetsItsFirstWantAtFOnlyUnderTheCanary(t *testing.T) { // The live row on novox at 15:27:36 on 2026-10-25 ("health failed, phase applying" at the controller's new fingerprint) held the // canary two seconds before the new build was healthy. func TestAFailedHealthWhileTheNodeMovesHoldsNothing(t *testing.T) { - for _, phase := range []delivery.Phase{delivery.PhaseBackingUp, delivery.PhaseApplying, delivery.PhaseChecking} { + for _, phase := range []string{nodeengineclient.PhaseBackingUp, nodeengineclient.PhaseApplying, nodeengineclient.PhaseChecking} { m := Module{Name: "mesh-controller", FP: fNew, Built: true, Granted: true, Composed: composedEverywhere, Nodes: []string{"novox"}, Manifest: catalogue.Manifest{Module: "mesh-controller"}} - n := Nodes{Online: map[string]bool{"novox": true}, Haves: map[string]map[string]delivery.Have{ - "novox": {"mesh-controller": {FP: fNew, Phase: phase, Health: delivery.HealthFailed}}}} - before := map[string]delivery.Want{"novox": {FP: fNew, Composed: dig(fNew)}} + n := Nodes{Online: map[string]bool{"novox": true}, Haves: map[string]map[string]nodeengine.HaveByNodeModuleValue{ + "novox": {"mesh-controller": {Fp: fNew, Phase: phase, Health: nodeengineclient.HealthFailed}}}} + before := map[string]mwc.Want{"novox": {Fp: fNew, Composed: dig(fNew)}} if out := Wants(m, n, before, map[string]string{"novox": fOld}); out.Held != nil { t.Errorf("phase %s, health failed: held %+v, want no hold while the node moves", phase, out.Held) } } - for _, phase := range []delivery.Phase{delivery.PhaseRunning, delivery.PhaseFailed} { + for _, phase := range []string{nodeengineclient.PhaseRunning, nodeengineclient.PhaseFailed} { m := Module{Name: "mesh-controller", FP: fNew, Built: true, Granted: true, Composed: composedEverywhere, Nodes: []string{"novox"}, Manifest: catalogue.Manifest{Module: "mesh-controller"}} - n := Nodes{Online: map[string]bool{"novox": true}, Haves: map[string]map[string]delivery.Have{ - "novox": {"mesh-controller": {FP: fNew, Phase: phase, Health: delivery.HealthFailed}}}} - before := map[string]delivery.Want{"novox": {FP: fNew, Composed: dig(fNew)}} + n := Nodes{Online: map[string]bool{"novox": true}, Haves: map[string]map[string]nodeengine.HaveByNodeModuleValue{ + "novox": {"mesh-controller": {Fp: fNew, Phase: phase, Health: nodeengineclient.HealthFailed}}}} + before := map[string]mwc.Want{"novox": {Fp: fNew, Composed: dig(fNew)}} if out := Wants(m, n, before, map[string]string{"novox": fOld}); out.Held == nil || out.Held.Node != "novox" { t.Errorf("phase %s, health failed: held %+v, want a hold on novox", phase, out.Held) } @@ -1421,30 +1444,30 @@ func TestAFailedHealthWhileTheNodeMovesHoldsNothing(t *testing.T) { // (removed, held for its backup, or still moving) is not counted, so it does not keep the hold up for ever; a node running another // fingerprint, or failed, still does. func TestAHoldIsLiftedOverHavesThatAreNotRunning(t *testing.T) { - setup := func(phase delivery.Phase, fp string, health delivery.Health) (Module, Nodes) { + setup := func(phase string, fp string, health string) (Module, Nodes) { m := Module{Name: "web", FP: fNew, Built: true, Granted: true, Held: true, Composed: composedEverywhere, Nodes: []string{"ace", "g14"}} - n := Nodes{Online: map[string]bool{"ace": true, "g14": true}, Haves: map[string]map[string]delivery.Have{ - "ace": {"web": {FP: fNew, Phase: delivery.PhaseRunning, Health: delivery.HealthOK}}, - "g14": {"web": {FP: fp, Phase: phase, Health: health}}}} + n := Nodes{Online: map[string]bool{"ace": true, "g14": true}, Haves: map[string]map[string]nodeengine.HaveByNodeModuleValue{ + "ace": {"web": {Fp: fNew, Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthOK}}, + "g14": {"web": {Fp: fp, Phase: phase, Health: health}}}} return m, n } - for _, phase := range []delivery.Phase{delivery.PhaseRemoved, delivery.PhaseHeld, delivery.PhaseApplying, delivery.PhaseBackingUp, delivery.PhaseChecking} { - m, n := setup(phase, fOld, delivery.HealthUnknown) + for _, phase := range []string{nodeengineclient.PhaseRemoved, nodeengineclient.PhaseHeld, nodeengineclient.PhaseApplying, nodeengineclient.PhaseBackingUp, nodeengineclient.PhaseChecking} { + m, n := setup(phase, fOld, nodeengineclient.HealthUnknown) if out := Wants(m, n, nil, nil); !out.Lifted { t.Errorf("g14's have in phase %s kept the hold up", phase) } } - for what, h := range map[string]delivery.Have{ - "running another fingerprint": {FP: fOld, Phase: delivery.PhaseRunning, Health: delivery.HealthOK}, - "failed": {FP: fNew, Phase: delivery.PhaseFailed, Health: delivery.HealthFailed}, + for what, h := range map[string]nodeengine.HaveByNodeModuleValue{ + "running another fingerprint": {Fp: fOld, Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthOK}, + "failed": {Fp: fNew, Phase: nodeengineclient.PhaseFailed, Health: nodeengineclient.HealthFailed}, } { - m, n := setup(h.Phase, h.FP, h.Health) + m, n := setup(h.Phase, h.Fp, h.Health) if out := Wants(m, n, nil, nil); out.Lifted { t.Errorf("g14 %s: the hold was lifted", what) } } // a failed health on a removed have holds nothing - m, n := setup(delivery.PhaseRemoved, fNew, delivery.HealthFailed) + m, n := setup(nodeengineclient.PhaseRemoved, fNew, nodeengineclient.HealthFailed) m.Held = false if out := Wants(m, n, nil, nil); out.Held != nil { t.Errorf("a removed have with a failed health held F: %+v", out.Held) @@ -1456,16 +1479,16 @@ func TestAHoldIsLiftedOverHavesThatAreNotRunning(t *testing.T) { func TestAFirstWantAfterARemovedWantGoesThroughTheRecordAsk(t *testing.T) { m, n := x11() recording(&m) - m.Composed = func(_, fp string) (delivery.ComposedDigest, bool) { - return delivery.ComposedDigest{Digest: dig(fp), Judgement: delivery.JudgedRecreates, Policy: delivery.PolicyRecord}, true + m.Composed = func(_, fp string) (ComposedDigest, bool) { + return ComposedDigest{Digest: dig(fp), Judgement: JudgedRecreates, Policy: catalogue.PolicyRecord}, true } - out := Wants(m, n, map[string]delivery.Want{"shanks": {Removed: true}}, nil) + out := Wants(m, n, map[string]mwc.Want{"shanks": {Removed: true}}, nil) if w, ok := out.Wants["shanks"]; ok { t.Errorf("shanks's first want after a removed want was written with no record ask: %+v", w) } asked := false for _, r := range out.Record { - asked = asked || (r.Node == "shanks" && r.Digest == dig(fOld) && r.Judgement == delivery.JudgedRecreates) + asked = asked || (r.Node == "shanks" && r.Digest == dig(fOld) && r.Judgement == JudgedRecreates) } if !asked { t.Errorf("the record asks = %+v, want one for shanks at the composition it runs", out.Record) @@ -1480,11 +1503,11 @@ func TestAHeldModuleWithNoWantIsNotOnNeitherList(t *testing.T) { "held at F": func(m *Module, _ *Nodes) { m.Held, m.Kept = true, true }, "held at F, running an old-form build": func(m *Module, n *Nodes) { m.Held, m.Kept = true, true - n.Haves["shanks"]["screen-lock"] = delivery.Have{FP: "build-2", Phase: delivery.PhaseRunning, Health: delivery.HealthUnknown} + n.Haves["shanks"]["screen-lock"] = nodeengine.HaveByNodeModuleValue{Fp: "build-2", Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthUnknown} }, "held by this pass": func(_ *Module, n *Nodes) { - n.Haves["g14"]["screen-lock"] = delivery.Have{FP: fNew, Phase: delivery.PhaseRunning, Health: delivery.HealthFailed} - n.Haves["shanks"]["screen-lock"] = delivery.Have{FP: "build-2", Phase: delivery.PhaseRunning, Health: delivery.HealthUnknown} + n.Haves["g14"]["screen-lock"] = nodeengine.HaveByNodeModuleValue{Fp: fNew, Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthFailed} + n.Haves["shanks"]["screen-lock"] = nodeengine.HaveByNodeModuleValue{Fp: "build-2", Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthUnknown} }, } { m, n := x11() @@ -1493,7 +1516,7 @@ func TestAHeldModuleWithNoWantIsNotOnNeitherList(t *testing.T) { if len(out.Neither) != 0 { t.Errorf("%s: a held module is on neither list: %+v", what, out.Neither) } - if out.Rules["shanks"].Rule != delivery.RuleNotHeld { + if out.Rules["shanks"].Rule != mwc.RuleNotHeld { t.Errorf("%s: shanks is held back by %+v, want rule 2", what, out.Rules["shanks"]) } } diff --git a/modules/mesh-delivery-assigner/internal/assignment/assignments_test.go b/modules/mesh-delivery-assigner/internal/assignment/assignments_test.go new file mode 100644 index 00000000..920c649e --- /dev/null +++ b/modules/mesh-delivery-assigner/internal/assignment/assignments_test.go @@ -0,0 +1,328 @@ +package assignment + +import ( + "encoding/json" + "errors" + "reflect" + "slices" + "strings" + "testing" + "time" + + client "git.novox.be/novox/mesh-contracts/go/clients/meshmoduleassignerclient" + seat "git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner" + "git.novox.be/novox/mesh-sdk/go/keys" +) + +// ADR 0567 §2, §4: what a change says, the holder's check of every assignments, a proposal's changes laid over the keys, +// its digests (held to the values the earlier holder's code made, in the field order it wrote), its refusals, and who holds +// which seat. +func TestAssignments(t *testing.T) { + g14 := seat.Assignments{Node: "g14", Revision: "abc", + Decided: seat.NodeDecided{Endpoint: "g14.example:51820", Site: "home", Address: "10.77.0.2", Account: "op", Taken: []string{"zsh"}, + Tunnel: &seat.FoundTunnel{Interface: "wg0", Config: "c", Port: 51820, Mtu: 1420, PublicKey: "K", Peers: []seat.FoundTunnelPeer{{PublicKey: "P", Address: "10.0.0.3"}}}, + PeerNames: map[string]string{"P": "pi"}}, + Modules: []seat.PlacedModule{ + {Module: "zsh"}, + {Module: "app", Ports: map[string]int64{"8080": 18080}, Bindings: map[string]seat.Provider{"postgres": {Node: "novox", Module: "postgres"}}}, + }, + Pins: map[string]seat.Provider{"shell": {Node: "g14", Module: "zsh"}}, + Holds: []seat.Holding{{Claim: "node-shell", Scope: "node", Module: "zsh"}}, + } + novox := seat.Assignments{Node: "novox", Decided: seat.NodeDecided{Hub: true, Address: "10.77.0.1"}, Modules: []seat.PlacedModule{{Module: "postgres", Ports: map[string]int64{"5432": 5432}}}, + Holds: []seat.Holding{{Claim: "mesh-store", Scope: "mesh", Module: "postgres"}}} + current := map[string]seat.Assignments{"g14": g14, "novox": novox} + const id = "p-20261028t183000-1a2b3c" + // the enroller is whichever module holds the seat mesh-node-enroller: no module is named for it + const enroller = "enroll-lab" + enrollers := EnrollersOf(map[string]seat.Assignments{"ace": {Node: "ace", + Holds: []seat.Holding{{Claim: "mesh-node-enroller", Scope: "mesh", Module: enroller}}}}) + if !slices.Equal(enrollers, []string{enroller}) { + t.Fatalf("the holders of the enroller seat = %v", enrollers) + } + + t.Run("what a change says", func(t *testing.T) { + after := g14 + after.Modules = []seat.PlacedModule{{Module: "app", Ports: map[string]int64{"8080": 18081}}, {Module: "new"}} + got := ChangeOf(g14, after) + want := Change{Node: "g14", Added: []string{"new"}, Removed: []string{"zsh"}, Changed: []string{"app"}} + if !reflect.DeepEqual(got, want) { + t.Errorf("ChangeOf = %+v, want %+v", got, want) + } + pinned := g14 + pinned.Pins = nil + if got := ChangeOf(g14, pinned); !reflect.DeepEqual(got.Changed, []string{"app", "zsh"}) { + t.Errorf("a pin change changes every module on the node: %+v", got) + } + if got := ChangeOf(seat.Assignments{}, novox); !reflect.DeepEqual(got.Added, []string{"postgres"}) || got.Node != "novox" { + t.Errorf("a new node: %+v", got) + } + c := []Change{{Node: "novox"}, {Node: "g14", Before: 1, After: 2}} + SortChanges(c) + if c[0].Node != "g14" || c[0].Body()["after"] != float64(2) { + t.Errorf("sorted %+v, body %v", c, c[0].Body()) + } + }) + + t.Run("the check of every assignments", func(t *testing.T) { + known := func(m string) bool { return m != "ghost" } + enrolled := func(n string) bool { return n != "stranger" } + if r := CheckAssignments(current, known, enrolled); len(r) != 0 { + t.Fatalf("a sound assignments is refused: %v", r) + } + // A replicated mesh seat (ADR 0223) is held on two nodes; a seat that is not replicated is refused there. + for name, refused := range map[string]bool{"mesh-dns-resolver": false, "mesh-broker": true} { + a := seat.Assignments{Node: "ace", Modules: []seat.PlacedModule{{Module: "dns"}}, Holds: []seat.Holding{{Claim: name, Scope: "mesh", Module: "dns"}}} + b := a + b.Node = "novox" + r := CheckAssignments(map[string]seat.Assignments{"ace": a, "novox": b}, known, enrolled) + if (len(r) != 0) != refused { + t.Errorf("%s on two nodes: refusals %v, want refused %v", name, r, refused) + } + } + clash := g14 + clash.Modules = append([]seat.PlacedModule{{Module: "ghost", Ports: map[string]int64{"80": 18080}}}, g14.Modules...) + clash.Modules[2] = seat.PlacedModule{Module: "app", Ports: map[string]int64{"8080": 18080}, Bindings: map[string]seat.Provider{"postgres": {Node: "ace", Module: "postgres"}}} + clash.Pins = map[string]seat.Provider{"shell": {Node: "g14", Module: "bash"}} + // ADR 0564 clause 6, Limits 5 (issue 1952 S8): mesh-watcher and mesh-self-check are never placed on one node. + clash.Modules = append(clash.Modules, seat.PlacedModule{Module: "mesh-watcher"}, seat.PlacedModule{Module: "mesh-self-check"}) + twice := novox + twice.Node = "stranger" + twice.Decided.Address = "10.77.0.2" + r := CheckAssignments(map[string]seat.Assignments{"g14": clash, "novox": novox, "stranger": twice}, known, enrolled) + for _, want := range []string{"holds no module ghost", "ghost and app are both published on host port 18080", + "bound to ace/postgres", "pin of shell names g14/bash", "stranger is a node with no record", + "mesh-store, held once for the mesh, is held on novox and stranger", + "g14 and stranger both have the address 10.77.0.2", "novox, stranger are each placed as it", + "g14: mesh-watcher and mesh-self-check are never placed on one node (ADR 0227 §6, ADR 0564 clause 6)"} { + if !slices.ContainsFunc(r, func(line string) bool { return strings.Contains(line, want) }) { + t.Errorf("no refusal says %q; got %v", want, r) + } + } + }) + + t.Run("changes laid over the keys", func(t *testing.T) { + laid, findings := LayChanges(current, []seat.ProposalChange{ + {Op: client.OpPlace, Node: "g14", Module: "new"}, + {Op: client.OpTakeOff, Node: "g14", Module: "zsh"}, + {Op: client.OpUnpin, Node: "g14", Provision: "shell"}, + {Op: client.OpDecide, Node: "novox", Field: "site", Value: "attic"}, + }) + if len(findings) != 0 || len(laid) != 2 || !reflect.DeepEqual(client.Assigned(laid["g14"]), []string{"app", "new"}) || + len(laid["g14"].Holds) != 0 || laid["g14"].Pins != nil || laid["novox"].Decided.Site != "attic" || !laid["novox"].Decided.Hub { + t.Fatalf("laid %+v, %v", laid, findings) + } + if !reflect.DeepEqual(client.Assigned(current["g14"]), []string{"zsh", "app"}) { + t.Error("laying changed the current assignments") + } + if fresh, f := LayChanges(current, []seat.ProposalChange{{Op: client.OpPlace, Node: "pi", Module: "zsh"}}); len(f) != 0 || fresh["pi"].Node != "pi" { + t.Errorf("a node with no key: %+v, %v", fresh, f) + } + for name, c := range map[string]struct { + change seat.ProposalChange + want string + }{ + "not placed": {seat.ProposalChange{Op: client.OpTakeOff, Node: "g14", Module: "nope"}, "not placed"}, + "no such field": {seat.ProposalChange{Op: client.OpDecide, Node: "g14", Field: "colour", Value: "x"}, "no decided field"}, + "wrong type": {seat.ProposalChange{Op: client.OpDecide, Node: "g14", Field: "hub", Value: "x"}, "does not take"}, + "unknown op": {seat.ProposalChange{Op: "move", Node: "g14"}, "no change of assignments"}, + "pin of nobody": {seat.ProposalChange{Op: client.OpPin, Node: "g14", Provision: "x"}, "provider"}, + "not held": {seat.ProposalChange{Op: client.OpUnhold, Node: "g14", Module: "app", Claim: "x"}, "holds no"}, + "bad port": {seat.ProposalChange{Op: client.OpPlace, Node: "g14", Module: "x", Ports: map[string]int64{"a": 1}}, "port"}, + "seed of another": {seat.ProposalChange{Op: client.OpSeed, Node: "g14", Whole: &novox}, "seed"}, + } { + if _, f := LayChanges(current, []seat.ProposalChange{c.change}); len(f) != 1 || !strings.Contains(f[0], c.want) { + t.Errorf("%s: %v, want a finding naming %q", name, f, c.want) + } + } + }) + + t.Run("a proposal, its digests and its refusals", func(t *testing.T) { + if got := NewProposalID(time.Date(2026, 10, 28, 18, 30, 0, 0, time.UTC)); !strings.HasPrefix(got, "p-20261028t183000-") || client.CheckProposalID(got) != nil { + t.Errorf("NewProposalID = %q", got) + } + p := seat.Proposal{ID: id, By: "operator@anchor", Why: "move the app", At: time.Unix(1, 0).UTC(), + Changes: []seat.ProposalChange{{Op: client.OpTakeOff, Node: "g14", Module: "zsh"}}, Base: map[string]int64{"g14": 4}, + State: client.ProposalAnswering} + raw, err := EncodeProposal(p) + if err != nil { + t.Fatal(err) + } + if back, err := DecodeProposal(raw); err != nil || back.ID != id || back.Base["g14"] != 4 || !reflect.DeepEqual(Nodes(back), []string{"g14"}) { + t.Fatalf("round trip %+v, %v", back, err) + } + if _, err := DecodeProposal([]byte(`{"v":9}`)); !errors.Is(err, keys.ErrUnknownVersion) { + t.Errorf("version 9: %v", err) + } + seed := p + seed.Seed, seed.ExportDigest = true, ExportDigest(current) + seed.Changes = []seat.ProposalChange{{Op: client.OpSeed, Node: "g14", Whole: &g14}} + if _, err := EncodeProposal(seed); err != nil { + t.Errorf("the seed: %v", err) + } + back := p + back.HandBack, back.Changes = true, nil + if _, err := EncodeProposal(back); err != nil { + t.Errorf("a hand-back: %v", err) + } + // The digests are what the earlier holder's code made of the same values (issue 1966: a proposal's ask binds its + // changes' digest, and a seed its export's), whatever order the generated types keep their fields in. + rich := seat.Proposal{ID: id, By: "operator@anchor", Why: "w", At: time.Unix(1, 0).UTC(), State: client.ProposalAnswering, + Base: map[string]int64{"g14": 4, "lab": 0, "novox": 2}, + Changes: []seat.ProposalChange{ + {Op: client.OpTakeOff, Node: "g14", Module: "zsh"}, + {Op: client.OpPlace, Node: "g14", Module: "new", Ports: map[string]int64{"80": 8080}, Bindings: map[string]seat.Provider{"pg": {Node: "novox", Module: "postgres"}}}, + {Op: client.OpPin, Node: "g14", Provision: "shell", Provider: &seat.Provider{Node: "g14", Module: "bash"}}, + {Op: client.OpHold, Node: "g14", Module: "app", Claim: "node-shell", Scope: "node", Kind: "k", Site: "s"}, + {Op: client.OpDecide, Node: "novox", Field: "site", Value: "attic"}, + {Op: client.OpDecide, Node: "novox", Field: "peer-names", Value: map[string]any{"k": "v"}}, + {Op: client.OpConfig, Node: "g14", Module: "app", MeshWide: map[string]any{"a": float64(1)}, Layer: map[string]any{"b": "c"}, MeshWideValues: map[string]any{"d": true}, Values: map[string]any{"e": []any{"f"}}}, + {Op: client.OpAddNode, Node: "lab", Decided: &seat.NodeDecided{Address: "10.0.0.7", Account: "x", AgentAccountHome: "/h"}, Keys: &seat.ProposalChangeKeys{Overlay: "wg", Identity: "id", Sealing: "se"}}, + }} + for name, c := range map[string]struct{ got, want string }{ + "export": {ExportDigest(current), "sha256:742f03d8790d7efa6abff76636f4a69ba2704d646233960f52ce08604b96e128"}, + "rich changes": {ChangesDigest(rich), "sha256:056338a2143d8c1af4e1275f41ffd06f18f3af3396a196d4444df83a0434475c"}, + "take-off": {ChangesDigest(p), "sha256:7ce48744dea7934e6f3a6a1eeffb28df4096ee6a6a6b4ae29e15fb019044d8de"}, + "seed": {ChangesDigest(seed), "sha256:731fe40d188acdf4a27ea73f3a5f1f9d584c3a8af7eaf15d4fb18e5da4853515"}, + "no changes": {ChangesDigest(seat.Proposal{ID: id, Base: map[string]int64{"g14": 4}}), "sha256:3c5f1509c2d1c867954c47303e8b8d84bd4cd9aa3458464c21933c6496999bee"}, + } { + if c.got != c.want { + t.Errorf("the digest of %s is %s, and the earlier holder made %s", name, c.got, c.want) + } + } + if ExportDigest(current) != ExportDigest(map[string]seat.Assignments{"novox": novox, "g14": g14}) || ExportDigest(current) == ExportDigest(nil) { + t.Error("the export digest depends on order or not on the assignments") + } + // 774/S42/AC1 (ADR 0587 §7): an add-node carries its enrolment's warrant naming its node and address, as its one + // change, from the enroller alone; a warrant naming another node or address is refused. + // At the operator's yes it carries the tunnel key alone (the operator, 2026-11-02 01:05). + joining := seat.ProposalChange{Op: client.OpAddNode, Node: "lab", Decided: &seat.NodeDecided{Address: "10.0.0.7"}, + Keys: &seat.ProposalChangeKeys{Overlay: "wg"}} + add := seat.Proposal{ID: id, By: "ace.mesh-enrolment", Why: "lab joins", At: time.Unix(1, 0).UTC(), + Changes: []seat.ProposalChange{joining}, Base: map[string]int64{"lab": 0}, State: client.ProposalAnswering, + Warrant: &seat.Warrant{Node: "lab", Address: "10.0.0.7", Ask: "a-1", Warrant: "w-1"}} + if raw, err := EncodeProposal(add); err != nil { + t.Errorf("an add-node: %v", err) + } else if back, err := DecodeProposal(raw); err != nil || back.Warrant == nil || back.Changes[0].Keys.Overlay != "wg" { + t.Errorf("an add-node round trip: %+v, %v", back, err) + } + if AddNodeRefusal(add, enroller, enrollers) != "" || !strings.Contains(AddNodeRefusal(add, "mesh-delivery-assigner", enrollers), "enroll-lab's alone") { + t.Errorf("add-node by caller: %q, %q", AddNodeRefusal(add, enroller, enrollers), AddNodeRefusal(add, "mesh-delivery-assigner", enrollers)) + } + if laid, f := LayChanges(current, add.Changes); len(f) != 0 || laid["lab"].Decided.Address != "10.0.0.7" || len(laid["lab"].Modules) != 0 { + t.Errorf("an add-node laid: %+v, %v", laid, f) + } + if _, f := LayChanges(current, []seat.ProposalChange{{Op: client.OpAddNode, Node: "g14", Decided: &seat.NodeDecided{Address: "10.0.0.9"}, + Keys: &seat.ProposalChangeKeys{Overlay: "wg"}}}); len(f) != 1 || !strings.Contains(f[0], "placed already") { + t.Errorf("an add-node of a placed node: %v", f) + } + for name, mut := range map[string]func(*seat.Proposal){ + "warrant of another node": func(p *seat.Proposal) { + p.Warrant = &seat.Warrant{Node: "pi", Address: "10.0.0.7", Ask: "a", Warrant: "w"} + }, + "warrant of another address": func(p *seat.Proposal) { + p.Warrant = &seat.Warrant{Node: "lab", Address: "10.0.0.8", Ask: "a", Warrant: "w"} + }, + "no warrant": func(p *seat.Proposal) { p.Warrant = nil }, + "no tunnel key": func(p *seat.Proposal) { + c := joining + c.Keys = &seat.ProposalChangeKeys{Identity: "id", Sealing: "seal"} + p.Changes = []seat.ProposalChange{c} + }, + "half its identity keys": func(p *seat.Proposal) { + c := joining + c.Keys = &seat.ProposalChangeKeys{Overlay: "wg", Identity: "id"} + p.Changes = []seat.ProposalChange{c} + }, + "beside another change": func(p *seat.Proposal) { + p.Changes = append(p.Changes, seat.ProposalChange{Op: client.OpPlace, Node: "lab", Module: "zsh"}) + }, + "the hub": func(p *seat.Proposal) { + c := joining + c.Decided = &seat.NodeDecided{Address: "10.0.0.7", Hub: true} + p.Changes = []seat.ProposalChange{c} + }, + "a warrant on a move": func(p *seat.Proposal) { + p.Changes, p.Base = []seat.ProposalChange{{Op: client.OpTakeOff, Node: "g14", Module: "zsh"}}, map[string]int64{"g14": 4} + }, + } { + q := add + q.Changes = append([]seat.ProposalChange(nil), add.Changes...) + mut(&q) + if _, err := EncodeProposal(q); err == nil { + t.Errorf("an add-node with %s: taken", name) + } + } + // With its identity keys whole, as bound at redeem, it is taken too. + whole := add + whole.Changes = []seat.ProposalChange{joining} + whole.Changes[0].Keys = &seat.ProposalChangeKeys{Overlay: "wg", Identity: "id", Sealing: "seal"} + if _, err := EncodeProposal(whole); err != nil { + t.Errorf("an add-node with its identity keys: %v", err) + } + // An enrolment's end takes its never-confirmed node off again: the enroller's alone, of a node running nothing. + remove := add + remove.Changes = []seat.ProposalChange{{Op: client.OpRemoveNode, Node: "lab"}} + if _, err := EncodeProposal(remove); err != nil || AddNodeRefusal(remove, "mesh-delivery-assigner", enrollers) == "" || + AddNodeRefusal(remove, enroller, enrollers) != "" || !slices.Equal(RemovedNodes(remove), []string{"lab"}) { + t.Errorf("a remove-node: %v, %q", err, AddNodeRefusal(remove, "mesh-delivery-assigner", enrollers)) + } + joined := map[string]seat.Assignments{"lab": {V: keys.Version, Node: "lab", Decided: seat.NodeDecided{Address: "10.0.0.7"}, Modules: []seat.PlacedModule{}}} + if laid, f := LayChanges(joined, remove.Changes); len(f) != 0 || len(laid) != 0 { + t.Errorf("a joining node removed: %v, %v", laid, f) + } + if _, f := LayChanges(current, []seat.ProposalChange{{Op: client.OpRemoveNode, Node: "g14"}}); len(f) != 1 || + !strings.Contains(f[0], "runs modules") { + t.Errorf("a running node removed: %v", f) + } + remove.Warrant = &seat.Warrant{Node: "pi", Address: "10.0.0.7", Ask: "a", Warrant: "w"} + if _, err := EncodeProposal(remove); err == nil { + t.Error("a remove-node on another node's warrant: taken") + } + for name, mut := range map[string]func(*seat.Proposal){ + "no id": func(p *seat.Proposal) { p.ID = "" }, + "no proposer": func(p *seat.Proposal) { p.By = "" }, + "no why": func(p *seat.Proposal) { p.Why = " " }, + "no change": func(p *seat.Proposal) { p.Changes = nil }, + "no base": func(p *seat.Proposal) { p.Base = map[string]int64{"novox": 1} }, + "no state": func(p *seat.Proposal) { p.State = "" }, + "asked, no ask": func(p *seat.Proposal) { p.State = client.ProposalAsked }, + "seed mixed": func(p *seat.Proposal) { p.Seed, p.ExportDigest = true, "d" }, + "bad change": func(p *seat.Proposal) { p.Changes[0].Op = "swap" }, + "seed, no digest": func(p *seat.Proposal) { *p = seed; p.ExportDigest = "" }, + "hand-back with changes": func(p *seat.Proposal) { p.HandBack = true }, + } { + q := p + q.Changes = append([]seat.ProposalChange(nil), p.Changes...) + mut(&q) + if _, err := EncodeProposal(q); err == nil { + t.Errorf("%s: taken", name) + } + } + }) + + // ADR 0571 clause 4: who holds which seat, from assignments; a node seat by its node. + t.Run("who holds which seat", func(t *testing.T) { + held := SeatHoldersOf(map[string]seat.Assignments{ + "ace": {Node: "ace", Holds: []seat.Holding{{Claim: "mesh-dns-resolver", Scope: "mesh", Module: "dnsmasq"}, {Claim: "node-files", Scope: "node", Module: "mesh-files"}}}, + "novox": {Node: "novox", Holds: []seat.Holding{{Claim: "mesh-dns-resolver", Scope: "mesh", Module: "dnsmasq"}, {Claim: "mesh-broker", Scope: "mesh", Module: "nats"}, + {Claim: "photo-gallery", Scope: "mesh", Module: "photos"}}}, + }) + if o := held["photo-gallery"]; !o.Outside || len(o.Holders) != 1 || held["mesh-broker"].Outside { + t.Errorf("a claim outside the set is %+v", o) + } + keyed := map[string][]byte{} + for k, v := range held { + keyed[k], _ = json.Marshal(v) + } + if h, err := client.HoldersOf(keyed, "mesh-dns-resolver", ""); err != nil || len(h) != 2 || h[0].Node != "ace" { + t.Errorf("a replicated seat's holders are %v, %v", h, err) + } + if h, _ := client.HoldersOf(keyed, "node-files", "ace"); len(h) != 1 || h[0].Module != "mesh-files" || len(held) != 4 { + t.Errorf("a node seat's holder is %v, keys %v", h, held) + } + if h, _ := client.HoldersOf(keyed, "node-files", "novox"); h != nil { + t.Errorf("a node seat nobody holds there is held by %v", h) + } + }) +} diff --git a/modules/mesh-delivery-assigner/internal/assignment/candidate.go b/modules/mesh-delivery-assigner/internal/assignment/candidate.go new file mode 100644 index 00000000..33e5f03c --- /dev/null +++ b/modules/mesh-delivery-assigner/internal/assignment/candidate.go @@ -0,0 +1,96 @@ +package assignment + +import ( + "encoding/json" + "fmt" + "strings" + + client "git.novox.be/novox/mesh-contracts/go/clients/meshmoduleassignerclient" + seat "git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner" + "git.novox.be/novox/mesh-sdk/go/keys" +) + +// Assignments candidates (novox/hq ADR 0567 §3): for each node a proposal must hear from, the holder writes one key +// `.` in its state `candidates`: the node's assignments with the proposal laid over it for a node whose key +// it changes, or the node's current key for one whose composition it changes only through another node (Reached). The +// layouts are the seat's contract's (CandidatesByProposalNodeValue); what refuses a candidate is here. + +// CandidateKey is the key of one node's candidate in one proposal: `.`, so a node watches `*.` and a +// proposal's keys are `.*`. +func CandidateKey(proposal, node string) (string, error) { + if err := client.CheckProposalID(proposal); err != nil { + return "", err + } + if err := keys.CheckName("node", node); err != nil { + return "", err + } + return seat.CandidatesByProposalNodeKey(proposal, node), nil +} + +// ParseCandidateKey is the inverse of CandidateKey; it refuses any other key, naming it. +func ParseCandidateKey(key string) (proposal, node string, err error) { + proposal, node, ok := strings.Cut(key, ".") + if !ok || keys.CheckName("node", node) != nil || client.CheckProposalID(proposal) != nil { + return "", "", fmt.Errorf("assignments: %q is not an assignments candidate key, .", key) + } + return proposal, node, nil +} + +func checkCandidate(c seat.CandidatesByProposalNodeValue) error { + if err := client.CheckProposalID(c.Proposal); err != nil { + return err + } + if c.Assignments != nil { + return client.CheckAssignments(*c.Assignments) + } + return nil +} + +// EncodeCandidate is c as JSON at the keys' Version; it refuses what DecodeCandidate refuses. +func EncodeCandidate(c seat.CandidatesByProposalNodeValue) ([]byte, error) { + if err := checkCandidate(c); err != nil { + return nil, err + } + c.V = keys.Version + if c.Assignments != nil { + p := *c.Assignments + p.V = keys.Version + c.Assignments = &p + } + return json.Marshal(c) +} + +// DecodeCandidate reads a candidate; it refuses an unknown version, a proposal id that is not one, and assignments the +// client refuses. +func DecodeCandidate(data []byte) (seat.CandidatesByProposalNodeValue, error) { + if err := keys.CheckVersion(data); err != nil { + return seat.CandidatesByProposalNodeValue{}, err + } + c, err := seat.DecodeCandidatesByProposalNodeValue(data) + if err != nil { + return seat.CandidatesByProposalNodeValue{}, fmt.Errorf("assignments: a candidate does not fit its shape: %w", err) + } + if err := checkCandidate(c); err != nil { + return seat.CandidatesByProposalNodeValue{}, err + } + return c, nil +} + +// PulseAnswerKey is the key of the bound on one node's answer to a proposal: `answer..`. +func PulseAnswerKey(proposal, node string) (string, error) { + if err := client.CheckProposalID(proposal); err != nil { + return "", err + } + if err := keys.CheckName("node", node); err != nil { + return "", err + } + return seat.PulsesByAnswerProposalNodeKey(proposal, node), nil +} + +// PulseAskKey is the key of the bound on a proposal's raised ask: `ask.`. +func PulseAskKey(proposal string) (string, error) { + if err := client.CheckProposalID(proposal); err != nil { + return "", err + } + return seat.PulsesByAskProposalKey(proposal), nil +} diff --git a/modules/mesh-delivery-assigner/internal/assignment/check.go b/modules/mesh-delivery-assigner/internal/assignment/check.go new file mode 100644 index 00000000..a3151d9f --- /dev/null +++ b/modules/mesh-delivery-assigner/internal/assignment/check.go @@ -0,0 +1,233 @@ +// Package assignments is the assigner's own check of assignments and of a proposal that changes it (novox/hq ADR 0567 §2, +// ADR 0634 §1 on where a seat's code goes): what the holder of the seat mesh-module-assigner decides before it writes +// anything. The layouts it works on are the seat's contract's (package meshmoduleassigner); what every reader of the +// assignments shares (the keys assembled into a node's assignments, the node key's digest, who holds a seat) is the seat's +// client's (package meshmoduleassignerclient). Nothing here is read by another module. +package assignment + +import ( + "encoding/json" + "fmt" + "sort" + "strings" + + client "git.novox.be/novox/mesh-contracts/go/clients/meshmoduleassignerclient" + seat "git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner" + "git.novox.be/novox/mesh-sdk/go/catalogue" + "git.novox.be/novox/mesh-sdk/go/keys" +) + +// KeptApart are the modules never placed on one node, each row naming the record that asks it (novox/hq ADR 0564 clause 6 +// and Limits 5; issue 1952 S8): no manifest word says it, so the check below holds the table. mesh-watcher hears the +// self-check's `checked` from a second machine (ADR 0227 §6), which on the self-check's own node would fall silent with +// it. +var KeptApart = []struct{ A, B, Record string }{ + {A: "mesh-watcher", B: "mesh-self-check", Record: "ADR 0227 §6, ADR 0564 clause 6"}, +} + +// CheckAssignments is the holder's check of a proposal laid over every assignments (ADR 0567 §2), over the assignments by +// node, the modules that are known and the nodes with a record. It returns every refusal, in node order, never only the +// first: assignments naming a node not enrolled or a module that is not known (held by the indexer, or provided by the +// mesh itself: the caller's `known` says both); two modules of one node on the same host port; a pin, binding or holding +// naming a provider that is not placed where it says; a seat of scope mesh held twice, unless the seat is replicated +// (catalogue.Seat's Replicated, ADR 0223), which is held once per node; two nodes with one address on the private +// network; more than one hub; two modules KeptApart on one node. A host port a manifest declares without a number and a +// requirement left unbound are the composition's to find (its ForeseenPorts and Unbound). +func CheckAssignments(assignments map[string]seat.Assignments, known func(module string) bool, enrolled func(node string) bool) []string { + var refusals []string + nodes := sortedKeys(assignments) + placed := func(p seat.Provider) bool { + other, ok := assignments[p.Node] + if !ok { + return false + } + for _, m := range other.Modules { + if m.Module == p.Module { + return true + } + } + return false + } + meshSeats := map[string]string{} + addresses := map[string]string{} + var hubs []string + for _, node := range nodes { + p := assignments[node] + if p.Node != node { + refusals = append(refusals, fmt.Sprintf("the assignments of %s names the node %q", node, p.Node)) + } + if a := strings.TrimSpace(p.Decided.Address); a != "" { + if other, twice := addresses[a]; twice { + refusals = append(refusals, fmt.Sprintf("%s and %s both have the address %s on the private network", other, node, a)) + } + addresses[a] = node + } + if p.Decided.Hub { + hubs = append(hubs, node) + } + if !enrolled(node) { + refusals = append(refusals, fmt.Sprintf("%s is a node with no record", node)) + } + placedHere := map[string]bool{} + for _, m := range p.Modules { + placedHere[m.Module] = true + } + for _, apart := range KeptApart { + if placedHere[apart.A] && placedHere[apart.B] { + refusals = append(refusals, fmt.Sprintf("%s: %s and %s are never placed on one node (%s)", node, apart.A, apart.B, apart.Record)) + } + } + hostPorts := map[int64]string{} + for _, m := range p.Modules { + if !known(m.Module) { + refusals = append(refusals, fmt.Sprintf("%s: the indexer holds no module %s", node, m.Module)) + } + for _, port := range sortedKeys(m.Ports) { + at := m.Ports[port] + if other, clash := hostPorts[at]; clash && other != m.Module { + refusals = append(refusals, fmt.Sprintf("%s: %s and %s are both published on host port %d", node, other, m.Module, at)) + } + hostPorts[at] = m.Module + } + for _, provision := range sortedKeys(m.Bindings) { + if to := m.Bindings[provision]; !placed(to) { + refusals = append(refusals, fmt.Sprintf("%s: %s's %s is bound to %s/%s, which is not placed there", node, m.Module, provision, to.Node, to.Module)) + } + } + } + for _, provision := range sortedKeys(p.Pins) { + if to := p.Pins[provision]; !placed(to) { + refusals = append(refusals, fmt.Sprintf("%s: the pin of %s names %s/%s, which is not placed there", node, provision, to.Node, to.Module)) + } + } + for _, h := range p.Holds { + if h.Scope != "mesh" { + continue + } + name := h.Claim + "/" + h.Kind + if s, known := catalogue.SeatNamed(h.Claim); known && s.Replicated { + // A replicated mesh seat (ADR 0223) is held on several nodes at once, each answering the same thing; + // it is still held once on each node. + name += "/" + node + } + if other, twice := meshSeats[name]; twice { + refusals = append(refusals, fmt.Sprintf("the seat %s, held once for the mesh, is held on %s and %s", h.Claim, other, node)) + } + meshSeats[name] = node + } + } + if len(hubs) > 1 { + refusals = append(refusals, fmt.Sprintf("the private network has one hub, and %s are each placed as it", strings.Join(hubs, ", "))) + } + return refusals +} + +// Change is what `assignments-changed` says of one node (ADR 0567 §6): the node, the revision of its key before (0: it had +// none) and after (0: the key was deleted), and its modules added, removed or changed against the assignments before. +type Change struct { + Node string `json:"node"` + Before uint64 `json:"before"` + After uint64 `json:"after"` + Added []string `json:"added,omitempty"` + Removed []string `json:"removed,omitempty"` + Changed []string `json:"changed,omitempty"` +} + +// Body is the change as the seat's event carries it, one entry of its `nodes`. +func (c Change) Body() map[string]any { + raw, _ := json.Marshal(c) + var out map[string]any + _ = json.Unmarshal(raw, &out) + return out +} + +// ChangeOf is what changed from before to after on one node; before is the zero Assignments for a node new to assignments. +// A change of the node's pins or holdings counts as a change of every module placed on it both times, since the +// composition of each may read them. +func ChangeOf(before, after seat.Assignments) Change { + c := Change{Node: after.Node} + if c.Node == "" { + c.Node = before.Node + } + was := map[string]seat.PlacedModule{} + for _, m := range before.Modules { + was[m.Module] = m + } + nodeWide := !jsonEqual(before.Pins, after.Pins) || !jsonEqual(before.Holds, after.Holds) + now := map[string]bool{} + for _, m := range after.Modules { + now[m.Module] = true + old, ok := was[m.Module] + switch { + case !ok: + c.Added = append(c.Added, m.Module) + case nodeWide || !jsonEqual(old, m): + c.Changed = append(c.Changed, m.Module) + } + } + for _, m := range before.Modules { + if !now[m.Module] { + c.Removed = append(c.Removed, m.Module) + } + } + sort.Strings(c.Added) + sort.Strings(c.Removed) + sort.Strings(c.Changed) + return c +} + +// SortChanges orders assignments-changed event's nodes by node. +func SortChanges(c []Change) { + sort.Slice(c, func(i, j int) bool { return c[i].Node < c[j].Node }) +} + +func jsonEqual(a, b any) bool { + ja, _ := json.Marshal(a) + jb, _ := json.Marshal(b) + return string(ja) == string(jb) +} + +// SeatHoldersOf is the state composed from every node's assignments (ADR 0567 §1): each holding by the seat it claims, +// keyed as the client's SeatHoldersKey says, with the module holding it and its node. A claim naming a seat's former name +// is keyed by the seat's name (ADR 0122); one naming no seat is kept, marked Outside. Deterministic, so a writer writes +// only a key that differs. +func SeatHoldersOf(assignments map[string]seat.Assignments) map[string]seat.SeatHolders { + out := map[string]seat.SeatHolders{} + for _, n := range sortedKeys(assignments) { + for _, h := range assignments[n].Holds { + node := "" + if h.Scope == "node" { + node = n + } + s, known := catalogue.SeatNamed(h.Claim) + name := h.Claim + if known { + name = s.Name + } + key, err := client.SeatHoldersKey(name, node) + if err != nil { + continue + } + sh := out[key] + sh.V, sh.Seat, sh.Scope, sh.Node, sh.Outside = keys.Version, name, h.Scope, node, !known + sh.Holders = append(sh.Holders, seat.SeatHoldersHoldersItem{Module: h.Module, Node: n}) + out[key] = sh + } + } + for k, sh := range out { + sort.Slice(sh.Holders, func(i, j int) bool { + return sh.Holders[i].Node+"/"+sh.Holders[i].Module < sh.Holders[j].Node+"/"+sh.Holders[j].Module + }) + out[k] = sh + } + return out +} + +func sortedKeys[V any](m map[string]V) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + sort.Strings(out) + return out +} diff --git a/modules/mesh-delivery-assigner/internal/assignment/digest.go b/modules/mesh-delivery-assigner/internal/assignment/digest.go new file mode 100644 index 00000000..b22e05c6 --- /dev/null +++ b/modules/mesh-delivery-assigner/internal/assignment/digest.go @@ -0,0 +1,215 @@ +package assignment + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + + seat "git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner" + "git.novox.be/novox/mesh-sdk/go/keys" +) + +// The digests here are of values a proposal's ask and a seed were bound to before this package: the fields are written in +// the order the first writer wrote them (the generated contract types order theirs by name), so a digest an earlier holder +// bound an ask to is the one this computes. Golden values in the tests hold it. + +type providerForm struct { + Node string `json:"node"` + Module string `json:"module"` +} + +type placedForm struct { + Module string `json:"module"` + Ports map[string]int64 `json:"ports,omitempty"` + Bindings map[string]providerForm `json:"bindings,omitempty"` + MeshWide map[string]any `json:"mesh-wide,omitempty"` + HasMesh bool `json:"has-mesh-wide,omitempty"` + Layer map[string]any `json:"layer,omitempty"` + HasLayer bool `json:"has-layer,omitempty"` + MeshValues map[string]any `json:"mesh-wide-values,omitempty"` + Values map[string]any `json:"values,omitempty"` +} + +type holdingForm struct { + Claim string `json:"claim"` + Scope string `json:"scope,omitempty"` + Module string `json:"module"` + Site string `json:"site,omitempty"` + Kind string `json:"kind,omitempty"` +} + +type decidedForm struct { + Endpoint string `json:"endpoint,omitempty"` + Site string `json:"site,omitempty"` + Hub bool `json:"hub,omitempty"` + Address string `json:"address,omitempty"` + PublicDomain string `json:"public-domain,omitempty"` + Account string `json:"account,omitempty"` + AccountHome string `json:"account-home,omitempty"` + AgentAccount string `json:"agent-account,omitempty"` + AgentHome string `json:"agent-account-home,omitempty"` + Adopted bool `json:"adopted,omitempty"` + Taken []string `json:"taken,omitempty"` + Tunnel *tunnelForm `json:"tunnel,omitempty"` + PeerNames map[string]string `json:"peer-names,omitempty"` +} + +type tunnelForm struct { + Interface string `json:"interface"` + Unit string `json:"unit,omitempty"` + Config string `json:"config,omitempty"` + Port int64 `json:"port,omitempty"` + MTU int64 `json:"mtu,omitempty"` + Address string `json:"address,omitempty"` + Range string `json:"range,omitempty"` + PublicKey string `json:"public-key,omitempty"` + Peers []tunnelPeerForm `json:"peers,omitempty"` +} + +type tunnelPeerForm struct { + PublicKey string `json:"public-key"` + Address string `json:"address,omitempty"` +} + +type assignmentsForm struct { + V int `json:"v"` + Node string `json:"node"` + Revision string `json:"revision,omitempty"` + Decided decidedForm `json:"decided"` + Modules []placedForm `json:"modules"` + Pins map[string]providerForm `json:"pins,omitempty"` + Holds []holdingForm `json:"holds,omitempty"` +} + +type joiningForm struct { + Identity string `json:"identity"` + Overlay string `json:"overlay,omitempty"` + Sealing string `json:"sealing"` + Serving string `json:"serving,omitempty"` +} + +type changeForm struct { + Op string `json:"op"` + Node string `json:"node"` + Module string `json:"module,omitempty"` + Ports map[string]int64 `json:"ports,omitempty"` + Bindings map[string]providerForm `json:"bindings,omitempty"` + Provision string `json:"provision,omitempty"` + Provider *providerForm `json:"provider,omitempty"` + Claim string `json:"claim,omitempty"` + Scope string `json:"scope,omitempty"` + Kind string `json:"kind,omitempty"` + Site string `json:"site,omitempty"` + Field string `json:"field,omitempty"` + Value json.RawMessage `json:"value,omitempty"` + MeshWide map[string]any `json:"mesh-wide,omitempty"` + Layer map[string]any `json:"layer,omitempty"` + MeshValues map[string]any `json:"mesh-wide-values,omitempty"` + Values map[string]any `json:"values,omitempty"` + Whole *assignmentsForm `json:"whole,omitempty"` + Decided *decidedForm `json:"decided,omitempty"` + Keys *joiningForm `json:"keys,omitempty"` +} + +func providerOf(p seat.Provider) providerForm { return providerForm{Node: p.Node, Module: p.Module} } + +func providersOf(m map[string]seat.Provider) map[string]providerForm { + if len(m) == 0 { + return nil + } + out := make(map[string]providerForm, len(m)) + for k, v := range m { + out[k] = providerOf(v) + } + return out +} + +func tunnelOf(t *seat.FoundTunnel) *tunnelForm { + if t == nil { + return nil + } + out := &tunnelForm{Interface: t.Interface, Unit: t.Unit, Config: t.Config, Port: t.Port, MTU: t.Mtu, Address: t.Address, Range: t.Range, PublicKey: t.PublicKey} + for _, p := range t.Peers { + out.Peers = append(out.Peers, tunnelPeerForm{PublicKey: p.PublicKey, Address: p.Address}) + } + return out +} + +func decidedOf(d seat.NodeDecided) decidedForm { + return decidedForm{Endpoint: d.Endpoint, Site: d.Site, Hub: d.Hub, Address: d.Address, PublicDomain: d.PublicDomain, + Account: d.Account, AccountHome: d.AccountHome, AgentAccount: d.AgentAccount, AgentHome: d.AgentAccountHome, + Adopted: d.Adopted, Taken: d.Taken, Tunnel: tunnelOf(d.Tunnel), PeerNames: d.PeerNames} +} + +func assignmentsOf(p seat.Assignments) assignmentsForm { + out := assignmentsForm{V: int(p.V), Node: p.Node, Revision: p.Revision, Decided: decidedOf(p.Decided), + Pins: providersOf(p.Pins)} + if p.Modules != nil { + out.Modules = []placedForm{} + } + for _, m := range p.Modules { + out.Modules = append(out.Modules, placedForm{Module: m.Module, Ports: m.Ports, Bindings: providersOf(m.Bindings), + MeshWide: m.MeshWide, HasMesh: m.HasMeshWide, Layer: m.Layer, HasLayer: m.HasLayer, MeshValues: m.MeshWideValues, Values: m.Values}) + } + for _, h := range p.Holds { + out.Holds = append(out.Holds, holdingForm{Claim: h.Claim, Scope: h.Scope, Module: h.Module, Site: h.Site, Kind: h.Kind}) + } + return out +} + +func changeOf(c seat.ProposalChange) changeForm { + out := changeForm{Op: c.Op, Node: c.Node, Module: c.Module, Ports: c.Ports, Bindings: providersOf(c.Bindings), Provision: c.Provision, + Claim: c.Claim, Scope: c.Scope, Kind: c.Kind, Site: c.Site, Field: c.Field, MeshWide: c.MeshWide, Layer: c.Layer, + MeshValues: c.MeshWideValues, Values: c.Values} + if c.Provider != nil { + p := providerOf(*c.Provider) + out.Provider = &p + } + if c.Value != nil { + out.Value, _ = json.Marshal(c.Value) + } + if c.Whole != nil { + w := assignmentsOf(*c.Whole) + out.Whole = &w + } + if c.Decided != nil { + d := decidedOf(*c.Decided) + out.Decided = &d + } + if c.Keys != nil { + out.Keys = &joiningForm{Identity: c.Keys.Identity, Overlay: c.Keys.Overlay, Sealing: c.Keys.Sealing, Serving: c.Keys.Serving} + } + return out +} + +// ExportDigest is the digest of an export's answer, every node's assignments, as the seed proposes it: the same +// assignments give the same digest, whatever order they came in. +func ExportDigest(assignments map[string]seat.Assignments) string { + h := sha256.New() + for _, node := range sortedKeys(assignments) { + p := assignments[node] + p.V = keys.Version // the export was made with the assignments encoded at the keys' version + raw, _ := json.Marshal(assignmentsOf(p)) + fmt.Fprintf(h, "%s\n%s\n", node, raw) + } + return "sha256:" + hex.EncodeToString(h.Sum(nil)) +} + +// ChangesDigest is the digest of a proposal's changes, which its authorising ask is bound to (§5). +func ChangesDigest(p seat.Proposal) string { + var changes []changeForm + if p.Changes != nil { + changes = make([]changeForm, 0, len(p.Changes)) + } + for _, c := range p.Changes { + changes = append(changes, changeOf(c)) + } + raw, _ := json.Marshal(struct { + ID string `json:"id"` + Changes []changeForm `json:"changes"` + Base map[string]int64 `json:"base"` + }{p.ID, changes, p.Base}) + sum := sha256.Sum256(raw) + return "sha256:" + hex.EncodeToString(sum[:]) +} diff --git a/modules/mesh-delivery-assigner/internal/assignment/main_test.go b/modules/mesh-delivery-assigner/internal/assignment/main_test.go new file mode 100644 index 00000000..da545265 --- /dev/null +++ b/modules/mesh-delivery-assigner/internal/assignment/main_test.go @@ -0,0 +1,15 @@ +package assignment + +import ( + "os" + "testing" + + "git.novox.be/novox/mesh-sdk/go/catalogue" + "git.novox.be/novox/mesh-sdk/go/seatlist/seatlisttest" +) + +// TestMain gives catalogue the seat list a host reads (ADR 0636 §1): none is compiled into mesh-sdk. +func TestMain(m *testing.M) { + catalogue.UseSeats(catalogue.SeatsFromDefinitions(seatlisttest.Definitions())) + os.Exit(m.Run()) +} diff --git a/modules/mesh-delivery-assigner/internal/assignment/proposal.go b/modules/mesh-delivery-assigner/internal/assignment/proposal.go new file mode 100644 index 00000000..b66cf164 --- /dev/null +++ b/modules/mesh-delivery-assigner/internal/assignment/proposal.go @@ -0,0 +1,431 @@ +package assignment + +import ( + "crypto/rand" + "encoding/hex" + "encoding/json" + "fmt" + "slices" + "strings" + "time" + + client "git.novox.be/novox/mesh-contracts/go/clients/meshmoduleassignerclient" + seat "git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner" + enroller "git.novox.be/novox/mesh-contracts/identity-and-access/go/seats/meshnodeenroller" + "git.novox.be/novox/mesh-sdk/go/keys" +) + +// Assignments changes only by a proposal (novox/hq ADR 0567 §2): `propose` takes why and a list of changes; the holder +// lays them over the current keys, names the revision of each key it laid them over, and either refuses at once with every +// finding or writes the proposal under its id in its state `proposals` and a candidate for each node it must hear from. + +// VerdictWaits is a proposal's verdict while a node it must hear from has not answered within the bound; it passes or +// fails as a check's verdict does (ADR 0567 §4). +const VerdictWaits = "waits" + +// VerdictPass and VerdictFail are the other two states of a proposal's verdict. +const ( + VerdictPass = "pass" + VerdictFail = "fail" +) + +// Nodes is every node whose key the proposal changes, sorted. +func Nodes(p seat.Proposal) []string { return sortedKeys(p.Base) } + +// RemovedNodes are the nodes whose key a proposal deletes (its remove-node), which LayChanges leaves out of what it lays. +func RemovedNodes(p seat.Proposal) []string { + var out []string + for _, c := range p.Changes { + if c.Op == client.OpRemoveNode { + out = append(out, c.Node) + } + } + return out +} + +// CheckChange refuses a change that names what its operation needs not, or an operation that is none. +func CheckChange(c seat.ProposalChange) error { + if err := keys.CheckName("node", c.Node); err != nil { + return err + } + needs := func(what string, ok bool) error { + if !ok { + return fmt.Errorf("assignments: a %s change of %s names no %s", c.Op, c.Node, what) + } + return nil + } + switch c.Op { + case client.OpPlace, client.OpTakeOff, client.OpConfig: + return needs("module", keys.CheckName("module", c.Module) == nil) + case client.OpPin: + return needs("provision or provider", c.Provision != "" && c.Provider != nil && c.Provider.Node != "" && c.Provider.Module != "") + case client.OpUnpin: + return needs("provision", c.Provision != "") + case client.OpHold, client.OpUnhold: + return needs("module or claim", keys.CheckName("module", c.Module) == nil && c.Claim != "") + case client.OpDecide: + return needs("field or value", c.Field != "" && c.Value != nil) + case client.OpSeed: + if c.Whole == nil || c.Whole.Node != c.Node { + return fmt.Errorf("assignments: the seed of %s names no assignments of that node", c.Node) + } + return client.CheckAssignments(*c.Whole) + case client.OpAddNode: + if c.Decided == nil || c.Decided.Address == "" || c.Keys == nil || c.Keys.Overlay == "" { + return fmt.Errorf("assignments: the add-node of %s names no address or no tunnel key", c.Node) + } + // The identity keys come at redeem, whole or not at all: an identity with no sealing key, or the reverse. + if (c.Keys.Identity == "") != (c.Keys.Sealing == "") || (c.Keys.Identity == "" && c.Keys.Serving != "") { + return fmt.Errorf("assignments: the add-node of %s names part of its identity keys; they come whole at redeem", c.Node) + } + if c.Decided.Hub { + return fmt.Errorf("assignments: the add-node of %s makes it the hub, which a joining node never is", c.Node) + } + if c.Module != "" || len(c.Ports)+len(c.Bindings) > 0 || c.Whole != nil { + return fmt.Errorf("assignments: the add-node of %s places a module; a module is placed by a proposal of its own", c.Node) + } + return nil + case client.OpRemoveNode: + if c.Module != "" || c.Decided != nil || c.Keys != nil || c.Whole != nil { + return fmt.Errorf("assignments: the remove-node of %s names more than its node", c.Node) + } + return nil + } + return fmt.Errorf("assignments: %q is no change of assignments: place, take-off, pin, unpin, hold, unhold, decide, config, add-node or remove-node", c.Op) +} + +// LayChanges is the assignments of the nodes the changes name with the changes laid over current, in the order given; +// current is every node's assignments now (absent: the node has no key). It returns, by node, the laid assignments, and +// every finding: a change that does not fit its node (a module taken off that is not placed, configured or holding +// there; a field NodeDecided does not have) or assignments the client refuses. What CheckAssignments refuses across nodes +// is the caller's to ask of the result laid over current. +func LayChanges(current map[string]seat.Assignments, changes []seat.ProposalChange) (map[string]seat.Assignments, []string) { + laid := map[string]seat.Assignments{} + var findings []string + of := func(node string) seat.Assignments { + if p, ok := laid[node]; ok { + return p + } + p, ok := current[node] + if !ok { + return seat.Assignments{V: keys.Version, Node: node, Modules: []seat.PlacedModule{}} + } + return cloneAssignments(p) + } + for i, c := range changes { + if err := CheckChange(c); err != nil { + findings = append(findings, fmt.Sprintf("change %d: %v", i+1, err)) + continue + } + p := of(c.Node) + at := -1 + for j, m := range p.Modules { + if m.Module == c.Module { + at = j + } + } + notPlaced := func() { + findings = append(findings, fmt.Sprintf("change %d: %s is not placed on %s", i+1, c.Module, c.Node)) + } + switch c.Op { + case client.OpSeed: + p = cloneAssignments(*c.Whole) + case client.OpAddNode: + // A joining node has no key yet (ADR 0587 §7): one that has is no joining node. + if _, has := current[c.Node]; has { + findings = append(findings, fmt.Sprintf("change %d: %s is placed already, so it does not join", i+1, c.Node)) + continue + } + p = seat.Assignments{V: keys.Version, Node: c.Node, Decided: *c.Decided, Modules: []seat.PlacedModule{}} + case client.OpRemoveNode: + // Only a joining node never confirmed goes this way: one with no module and no holding (a node that runs + // anything is taken off module by module, by a proposal the operator approves). + cur, has := current[c.Node] + if !has { + findings = append(findings, fmt.Sprintf("change %d: %s is not placed, so there is nothing to remove", i+1, c.Node)) + } else if len(cur.Modules)+len(cur.Holds) > 0 { + findings = append(findings, fmt.Sprintf("change %d: %s runs modules or holds seats, so it is no joining node to remove", i+1, c.Node)) + } + continue + case client.OpPlace: + m := seat.PlacedModule{Module: c.Module, Ports: c.Ports, Bindings: c.Bindings} + if at >= 0 { + o := p.Modules[at] + m.MeshWide, m.HasMeshWide, m.Layer, m.HasLayer = o.MeshWide, o.HasMeshWide, o.Layer, o.HasLayer + m.MeshWideValues, m.Values = o.MeshWideValues, o.Values + p.Modules[at] = m + } else { + p.Modules = append(p.Modules, m) + } + case client.OpTakeOff: + if at < 0 { + notPlaced() + continue + } + p.Modules = append(p.Modules[:at:at], p.Modules[at+1:]...) + var holds []seat.Holding + for _, h := range p.Holds { + if h.Module != c.Module { + holds = append(holds, h) + } + } + p.Holds = holds + case client.OpConfig: + if at < 0 { + notPlaced() + continue + } + m := &p.Modules[at] + if c.MeshWide != nil { + m.MeshWide, m.HasMeshWide = c.MeshWide, true + } + if c.Layer != nil { + m.Layer, m.HasLayer = c.Layer, true + } + if c.MeshWideValues != nil { + m.MeshWideValues = c.MeshWideValues + } + if c.Values != nil { + m.Values = c.Values + } + case client.OpPin: + if p.Pins == nil { + p.Pins = map[string]seat.Provider{} + } + p.Pins[c.Provision] = *c.Provider + case client.OpUnpin: + delete(p.Pins, c.Provision) + if len(p.Pins) == 0 { + p.Pins = nil + } + case client.OpHold: + if at < 0 { + notPlaced() + continue + } + p.Holds = append(p.Holds, seat.Holding{Claim: c.Claim, Scope: c.Scope, Module: c.Module, Site: c.Site, Kind: c.Kind}) + case client.OpUnhold: + kept := p.Holds[:0:0] + for _, h := range p.Holds { + if h.Module != c.Module || h.Claim != c.Claim { + kept = append(kept, h) + } + } + if len(kept) == len(p.Holds) { + findings = append(findings, fmt.Sprintf("change %d: %s holds no %s on %s", i+1, c.Module, c.Claim, c.Node)) + continue + } + p.Holds = kept + case client.OpDecide: + d, err := decide(p.Decided, c.Field, c.Value) + if err != nil { + findings = append(findings, fmt.Sprintf("change %d: %v", i+1, err)) + continue + } + p.Decided = d + } + laid[c.Node] = p + } + for _, node := range sortedKeys(laid) { + if err := client.CheckAssignments(laid[node]); err != nil { + findings = append(findings, err.Error()) + } + } + return laid, findings +} + +// decide is d with the field named by its JSON name set to value, refused when NodeDecided has no such field or the +// value does not fit it. +func decide(d seat.NodeDecided, field string, value any) (seat.NodeDecided, error) { + raw, _ := json.Marshal(d) + var fields map[string]json.RawMessage + _ = json.Unmarshal(raw, &fields) + if fields == nil { + fields = map[string]json.RawMessage{} + } + if !decidedFields[field] { + return d, fmt.Errorf("a node has no decided field %q", field) + } + set, err := json.Marshal(value) + if err != nil { + return d, fmt.Errorf("the decided field %q does not take %v: %v", field, value, err) + } + fields[field] = set + raw, _ = json.Marshal(fields) + var out seat.NodeDecided + dec := json.NewDecoder(strings.NewReader(string(raw))) + dec.DisallowUnknownFields() + if err := dec.Decode(&out); err != nil { + return d, fmt.Errorf("the decided field %q does not take %s: %v", field, set, err) + } + return out, nil +} + +// decidedFields are NodeDecided's fields by their JSON names. +var decidedFields = func() map[string]bool { + raw, _ := json.Marshal(seat.NodeDecided{Endpoint: "x", Site: "x", Hub: true, Address: "x", PublicDomain: "x", Account: "x", + AccountHome: "x", AgentAccount: "x", AgentAccountHome: "x", Adopted: true, Taken: []string{"x"}, Tunnel: &seat.FoundTunnel{Interface: "x"}, + PeerNames: map[string]string{"x": "x"}}) + var m map[string]any + _ = json.Unmarshal(raw, &m) + out := map[string]bool{} + for k := range m { + out[k] = true + } + return out +}() + +func cloneAssignments(p seat.Assignments) seat.Assignments { + raw, _ := json.Marshal(p) + var out seat.Assignments + _ = json.Unmarshal(raw, &out) + if out.Modules == nil { + out.Modules = []seat.PlacedModule{} + } + return out +} + +// NewProposalID is a fresh proposal id: its time in UTC and six random hex digits, `p-20261028t183000-1a2b3c`. The +// client's CheckProposalID says what an id is. +func NewProposalID(at time.Time) string { + var b [3]byte + _, _ = rand.Read(b[:]) + return "p-" + strings.ToLower(at.UTC().Format("20060102T150405")) + "-" + hex.EncodeToString(b[:]) +} + +// CheckProposal refuses a proposal that is none: an id that is none, no proposer or why, no change (but a hand-back), a +// change it does not take or of a node with no base revision, the seed mixed with other changes or without its export +// digest, a state it does not know, and an add-node or remove-node that is not as ADR 0587 §7 says. +func CheckProposal(p seat.Proposal) error { + if err := client.CheckProposalID(p.ID); err != nil { + return err + } + if strings.TrimSpace(p.By) == "" || strings.TrimSpace(p.Why) == "" { + return fmt.Errorf("assignments: the proposal %s names no proposer or no why", p.ID) + } + if len(p.Base) == 0 || (len(p.Changes) == 0) != p.HandBack || (p.HandBack && p.Seed) { + return fmt.Errorf("assignments: the proposal %s changes nothing, or is a hand-back with changes", p.ID) + } + for i, c := range p.Changes { + if err := CheckChange(c); err != nil { + return fmt.Errorf("assignments: the proposal %s, change %d: %w", p.ID, i+1, err) + } + if (c.Op == client.OpSeed) != p.Seed { + return fmt.Errorf("assignments: the proposal %s mixes the seed with other changes", p.ID) + } + if _, ok := p.Base[c.Node]; !ok { + return fmt.Errorf("assignments: the proposal %s changes %s and names no base revision of its key", p.ID, c.Node) + } + } + switch p.State { + case client.ProposalAnswering, client.ProposalFailed: + case client.ProposalAsked: + if p.Ask == "" || p.Verdict == nil || p.Verdict.State != VerdictPass { + return fmt.Errorf("assignments: the proposal %s is asked with no ask or no passing verdict", p.ID) + } + default: + return fmt.Errorf("assignments: the proposal %s is in no state %q", p.ID, p.State) + } + if p.Seed && p.ExportDigest == "" { + return fmt.Errorf("assignments: the seed %s names no export digest", p.ID) + } + return checkAddNode(p) +} + +// checkAddNode refuses an add-node or a remove-node that is not the proposal's one change, carries no warrant, or whose +// warrant names another node (or, for an add-node, another address) than the change; and a warrant on any other +// proposal (ADR 0587 §7). +func checkAddNode(p seat.Proposal) error { + adds := 0 + for _, c := range p.Changes { + if c.Op == client.OpAddNode || c.Op == client.OpRemoveNode { + adds++ + } + } + if adds == 0 { + if p.Warrant != nil { + return fmt.Errorf("assignments: the proposal %s carries an enrolment's warrant and adds no node", p.ID) + } + return nil + } + if adds != 1 || len(p.Changes) != 1 || p.HandBack || p.Seed { + return fmt.Errorf("assignments: the proposal %s adds a node beside other changes; an add-node is a proposal's one change", p.ID) + } + c, w := p.Changes[0], p.Warrant + if w == nil || w.Ask == "" || w.Warrant == "" { + return fmt.Errorf("assignments: the add-node of %s carries no warrant of its enrolment", c.Node) + } + if c.Op == client.OpRemoveNode { + if w.Node != c.Node { + return fmt.Errorf("assignments: the remove-node of %s carries the warrant of %s", c.Node, w.Node) + } + return nil + } + if w.Node != c.Node || w.Address != c.Decided.Address { + return fmt.Errorf("assignments: the add-node of %s at %s carries the warrant of %s at %s", c.Node, c.Decided.Address, w.Node, w.Address) + } + return nil +} + +// AddNodeRefusal is why the holder of the seat refuses an add-node or a remove-node from caller, the module the runtime +// proved called (ADR 0587 §7), or "": only a holder of the seat mesh-node-enroller (enrollers, EnrollersOf) adds a node; +// what the proposal itself must hold, CheckProposal refuses. +func AddNodeRefusal(p seat.Proposal, caller string, enrollers []string) string { + if err := checkAddNode(p); err != nil { + return err.Error() + } + if p.Warrant != nil && !slices.Contains(enrollers, caller) { + if len(enrollers) == 0 { + return fmt.Sprintf("an add-node or a remove-node is the holder's of the seat %s alone, nobody holds it, and %s proposed it", enroller.Seat, caller) + } + return fmt.Sprintf("an add-node or a remove-node is %s's alone, and %s proposed it", strings.Join(enrollers, ", "), caller) + } + return "" +} + +// EnrollersOf is the modules that hold the seat mesh-node-enroller on the assignments given, sorted: the state seat-holders +// composed from them (SeatHoldersOf), read by the client's HoldersOf. +func EnrollersOf(assignments map[string]seat.Assignments) []string { + holders, _ := client.HoldersOf(rawHolders(SeatHoldersOf(assignments)), enroller.Seat, "") + var out []string + for _, h := range holders { + if !slices.Contains(out, h.Module) { + out = append(out, h.Module) + } + } + slices.Sort(out) + return out +} + +func rawHolders(held map[string]seat.SeatHolders) map[string][]byte { + out := map[string][]byte{} + for k, v := range held { + out[k], _ = json.Marshal(v) + } + return out +} + +// EncodeProposal is p as JSON at the keys' Version; it refuses what DecodeProposal refuses. +func EncodeProposal(p seat.Proposal) ([]byte, error) { + if err := CheckProposal(p); err != nil { + return nil, err + } + p.V = keys.Version + return json.Marshal(p) +} + +// DecodeProposal reads a proposal; it refuses an unknown version, a field the contract does not have, and what +// CheckProposal refuses. +func DecodeProposal(data []byte) (seat.Proposal, error) { + if err := keys.CheckVersion(data); err != nil { + return seat.Proposal{}, err + } + p, err := seat.DecodeProposalsByProposalValue(data) + if err != nil { + return seat.Proposal{}, fmt.Errorf("assignments: a proposal does not fit its shape: %w", err) + } + if err := CheckProposal(p); err != nil { + return seat.Proposal{}, err + } + return p, nil +} diff --git a/modules/mesh-delivery-assigner/internal/assignment/state.go b/modules/mesh-delivery-assigner/internal/assignment/state.go new file mode 100644 index 00000000..36b44250 --- /dev/null +++ b/modules/mesh-delivery-assigner/internal/assignment/state.go @@ -0,0 +1,24 @@ +package assignment + +import seat "git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner" + +// Module is the module that holds the seat mesh-module-assigner: the states of the seat are named after it, as every +// module state is (a bucket is `_`), and its manifest declares them. +const Module = "mesh-delivery-assigner" + +// The buckets of the seat's states, which only the holder writes (ADR 0567 §1). +const ( + AssignmentsBucket = Module + "_" + seat.StateAssignments + ProposalsBucket = Module + "_" + seat.StateProposals + CandidatesBucket = Module + "_" + seat.StateCandidates + PulsesBucket = Module + "_" + seat.StatePulses + SeatHoldersBucket = Module + "_" + seat.StateSeatHolders +) + +const ( + // AssignmentsHistory is the revisions the assignments bucket keeps per key: the bus's most (§6). + AssignmentsHistory = 64 + // EventDays is how long the seat's events are kept: they ride the shared events stream on the seat's own subject + // (the seat's family, 7 days). + EventDays = 7 +) diff --git a/modules/mesh-delivery-assigner/internal/buckets/buckets.go b/modules/mesh-delivery-assigner/internal/buckets/buckets.go new file mode 100644 index 00000000..5e56fd5e --- /dev/null +++ b/modules/mesh-delivery-assigner/internal/buckets/buckets.go @@ -0,0 +1,67 @@ +// Package buckets holds the names of the bus buckets the assigner reads and the states it keeps beside the seats' own. A +// contract carries the local name of a state, not the module that holds it (ADR 0625 §4, §7), and the assigner opens +// buckets through grants its manifest declares, so the holder's name is kept here, in the assigner's own code, and held equal +// to the manifest by its test (ADR 0634 §1, finding F4). +package buckets + +import ( + "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" + watchdogs "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshwatchdogs" + catalog "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" + delivery "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdelivery" + wants "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshwants" + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds" + "git.novox.be/novox/mesh-delivery/modules/mesh-grants/grantsstate" + + plc "mesh-delivery-assigner/internal/assignment" +) + +// The states the assigner keeps: the wants seat's, and its own asks, puts and policies. +const ( + Wants = plc.Module + "_" + wants.StateWants + Differences = plc.Module + "_" + wants.StateDifferences + Asks = plc.Module + "_asks" + Puts = plc.Module + "_puts" + // PoliciesState is the state of the policies a person set for a module's upgrade; Policies its bucket. + PoliciesState = "policies" + Policies = plc.Module + "_" + PoliciesState +) + +// AsksHistory is the revisions the bucket of asks keeps per key. +const AsksHistory = 5 + +// The states of the other seats' holders that the assigner reads, named by the holder's module as every state is. +const ( + // Have holds a module's have on a node, `.`, and the node's own facts, `.node`. + Have = "node-engine_" + nodeengine.StateHave + // ComposedRead is the family of the node-engine's composed states, one bucket per node. + ComposedRead = "mesh-host." + nodeengine.StateComposed + // IndexerModule is the holder of the seat mesh-catalog, named from this module's manifest (`consumes` its views, `invokes` + // its tools; TestTheIndexerIsTheManifests), since no client names a holder (ADR 0625 §4). + IndexerModule = "mesh-delivery-indexer" + // Modules, Manifests and Graph are the indexer's buckets; ModulesRead and ManifestsRead the addresses of the states + // modules and manifests as a first view names them. + Modules = IndexerModule + "_" + catalog.StateModules + Manifests = IndexerModule + "_" + catalog.StateManifests + Graph = IndexerModule + "_graph" + ModulesRead = IndexerModule + "." + catalog.StateModules + ManifestsRead = IndexerModule + "." + catalog.StateManifests + // ProvisionerModule is the holder of the seat mesh-provisioner, named from this module's manifest (reads its provisions); Provisions + // is its state provisions. + ProvisionerModule = "mesh-provisioner" + Provisions = ProvisionerModule + "_provisions" + // Presence holds each part's presence and the node's. + Presence = "mesh-delivery-watchman_" + watchdogs.StatePresence + // Source and Merges are the gate's. + Source = "mesh-delivery-gate_" + delivery.StateSource + Merges = "mesh-delivery-gate_" + delivery.StateMerges + // TellerModule is the module whose hand the operator's answers pass: the one that holds each ask's answer, and the + // name the hand-act log records a warrant under. Held equal to the manifest's reads by its test. + TellerModule = "mesh-delivery-teller" + // Answers holds the teller's answer to each ask. + Answers = TellerModule + "_answers" + // Builds is the builder's. + Builds = builds.BuilderBuildsBucket + // Grants is the grants part's. + Grants = grantsstate.PartGrantsBucket +) diff --git a/modules/mesh-delivery-assigner/internal/buckets/buckets_test.go b/modules/mesh-delivery-assigner/internal/buckets/buckets_test.go new file mode 100644 index 00000000..0b6de994 --- /dev/null +++ b/modules/mesh-delivery-assigner/internal/buckets/buckets_test.go @@ -0,0 +1,22 @@ +package buckets + +import "testing" + +// The names are the ones the earlier shared form held (mesh-sdk's go/delivery), and the ones the manifest's reads and +// states say: a bucket is `_`. +func TestBucketsAreTheEarlierNames(t *testing.T) { + for name, c := range map[string][2]string{ + "Have": {Have, "node-engine_have"}, "ComposedRead": {ComposedRead, "mesh-host.composed"}, + "Modules": {Modules, "mesh-delivery-indexer_modules"}, "Manifests": {Manifests, "mesh-delivery-indexer_manifests"}, + "Presence": {Presence, "mesh-delivery-watchman_presence"}, "Source": {Source, "mesh-delivery-gate_source"}, + "Merges": {Merges, "mesh-delivery-gate_merges"}, "Answers": {Answers, "mesh-delivery-teller_answers"}, + "Builds": {Builds, "mesh-delivery-builder_builds"}, "Grants": {Grants, "mesh-grants_grants"}, + "Wants": {Wants, "mesh-delivery-assigner_wants"}, "Differences": {Differences, "mesh-delivery-assigner_differences"}, + "Asks": {Asks, "mesh-delivery-assigner_asks"}, "Puts": {Puts, "mesh-delivery-assigner_puts"}, + "Policies": {Policies, "mesh-delivery-assigner_policies"}, "Provisions": {Provisions, "mesh-provisioner_provisions"}, "TellerModule": {TellerModule, "mesh-delivery-teller"}, + } { + if c[0] != c[1] { + t.Errorf("%s = %q, want %q", name, c[0], c[1]) + } + } +} diff --git a/modules/mesh-delivery-assigner/internal/judge/judge.go b/modules/mesh-delivery-assigner/internal/judge/judge.go index c30b9392..2b1d7b55 100644 --- a/modules/mesh-delivery-assigner/internal/judge/judge.go +++ b/modules/mesh-delivery-assigner/internal/judge/judge.go @@ -1,5 +1,5 @@ -// Package judge is how the seat mesh-placement's holder judges a proposal (novox/hq ADR 0567 §3, §4, as ADR 0593 §8 -// changes them): what it changes on each node by its placement (Intended), every node answering it, and the verdict of +// Package judge is how the seat mesh-module-assigner's holder judges a proposal (novox/hq ADR 0567 §3, §4, as ADR 0593 §8 +// changes them): what it changes on each node by its assignments (Intended), every node answering it, and the verdict of // their answers (JudgeAnswers). The assigner's own (ADR 0593 §1), moved back from mesh-sdk's compose/judge: the holder // composes nothing, since every composition is its node's engine's, and a node answers through the candidates path // with what it composed. It holds no bus client and no timer: the holder gives it what it read. @@ -8,28 +8,33 @@ package judge import ( "encoding/json" "fmt" + "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" + mdcc "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliverycheckerclient" "reflect" "sort" "strconv" "strings" "time" - "git.novox.be/novox/mesh-sdk/go/delivery" + nec "git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient" + mma "git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner" + + plc "mesh-delivery-assigner/internal/assignment" ) // AnswerBound is how long the holder waits for a node's answer after its candidate was written: the time to live of the -// node's pulse key (delivery.PulseAnswerKey), whose expiry names it as giving no answer, never passed. The same answer -// bound a check gives a node (ADR 0593 §8, §7: 60 seconds, delivery.ComposeAnswerBound). A variable so a test on the bus +// node's pulse key (assignment.PulseAnswerKey), whose expiry names it as giving no answer, never passed. The same answer +// bound a check gives a node (ADR 0593 §8, §7: 60 seconds, nec.AnswerBound). A variable so a test on the bus // can shorten it. -var AnswerBound = delivery.ComposeAnswerBound +var AnswerBound = nec.AnswerBound // Intended is, by node, the modules whose placed entry the proposal changes there, and the nodes that answer it: every -// node (ADR 0593 §8, changing ADR 0567 §4's list of the nodes that must answer), sorted. current is the placement as it -// stands, laid the proposal's placements by node (nil removes the node's key) and nodes every node with a record. A +// node (ADR 0593 §8, changing ADR 0567 §4's list of the nodes that must answer), sorted. current is the assignments as it +// stands, laid the proposal's assignments by node (nil removes the node's key) and nodes every node with a record. A // node's set holds the modules the proposal adds, removes or whose placed entry differs on that node; a node with no // key now gets an empty set (§7: the seed is answered as the same). What a node's compositions change beyond its own -// placement it says itself in its answer, marked in the brief as not computed. -func Intended(current map[string]delivery.Placement, laid map[string]*delivery.Placement, nodes []string) (intended map[string]map[string]bool, must []string) { +// assignments it says itself in its answer, marked in the brief as not computed. +func Intended(current map[string]mma.Assignments, laid map[string]*mma.Assignments, nodes []string) (intended map[string]map[string]bool, must []string) { intended = map[string]map[string]bool{} all := map[string]bool{} for _, n := range nodes { @@ -45,13 +50,13 @@ func Intended(current map[string]delivery.Placement, laid map[string]*delivery.P before, placed := current[node] after, changesKey := laid[node] if !changesKey || !placed { - continue // untouched, or new to placement: every answer must be the same + continue // untouched, or new to assignments: every answer must be the same } - var afterModules []delivery.PlacedModule + var afterModules []mma.PlacedModule if after != nil { afterModules = after.Modules } - entries := map[string]delivery.PlacedModule{} + entries := map[string]mma.PlacedModule{} for _, m := range before.Modules { entries[m.Module] = m } @@ -79,10 +84,10 @@ func Intended(current map[string]delivery.Placement, laid map[string]*delivery.P // on the bus) is named as giving no answer and the proposal decided on the answers it has (ADR 0593 §8). // A difference fails nothing: one Intended computed is listed under Intended, one it did not under Unintended, both // shown in the ask's brief. The seed alone fails on any difference. -func JudgeAnswers(p delivery.PlacementProposal, intended map[string]map[string]bool, answers map[string]*delivery.CandidateAnswer, - revisions map[string]uint64, online map[string]bool, pulsing map[string]bool, now time.Time) delivery.PlacementVerdict { - v := delivery.PlacementVerdict{At: now.UTC(), Intended: map[string][]string{}, Unintended: map[string][]string{}, Carried: map[string][]string{}, - Answers: map[string]delivery.CandidateAnswer{}} +func JudgeAnswers(p mma.Proposal, intended map[string]map[string]bool, answers map[string]*nodeengine.CandidateAnswer, + revisions map[string]uint64, online map[string]bool, pulsing map[string]bool, now time.Time) mma.Verdict { + v := mma.Verdict{At: now.UTC(), Intended: map[string][]string{}, Unintended: map[string][]string{}, Carried: map[string][]string{}, + Answers: map[string]map[string]any{}} for node, set := range intended { for m := range set { v.Intended[node] = append(v.Intended[node], m) @@ -91,15 +96,15 @@ func JudgeAnswers(p delivery.PlacementProposal, intended map[string]map[string]b } must := p.Must if len(must) == 0 { - must = p.Nodes() + must = plc.Nodes(p) } for _, node := range must { a := answers[node] - if a == nil || a.Proposal != p.ID || a.Revision == 0 || a.Revision != revisions[node] { + if a == nil || a.Proposal != p.ID || a.Revision == 0 || uint64(a.Revision) != revisions[node] { v.Silent = append(v.Silent, node) continue } - v.Answers[node] = *a + v.Answers[node] = answerBody(*a) if a.Refused != "" { v.Fails = append(v.Fails, fmt.Sprintf("%s cannot compose the proposal: %s", node, a.Refused)) continue @@ -164,18 +169,18 @@ func JudgeAnswers(p delivery.PlacementProposal, intended map[string]map[string]b } switch { case len(v.Fails) > 0: - v.State = delivery.VerdictFail + v.State = mdcc.VerdictFail case len(waiting) > 0: - v.State = delivery.VerdictWaits + v.State = plc.VerdictWaits default: - v.State = delivery.VerdictPass + v.State = mdcc.VerdictPass } return v } // Brief is the verdict as the ask's brief shows it, by node (§5): what the proposal changes and what each node answered // beside it, marked, and what stays held as the node holds it today, with the proposal's why. -func Brief(p delivery.PlacementProposal, v delivery.PlacementVerdict) string { +func Brief(p mma.Proposal, v mma.Verdict) string { var b strings.Builder fmt.Fprintf(&b, "%s (proposed by %s)", p.Why, p.By) nodes := map[string]bool{} @@ -185,11 +190,11 @@ func Brief(p delivery.PlacementProposal, v delivery.PlacementVerdict) string { for n := range v.Unintended { nodes[n] = true } - for _, n := range p.Nodes() { + for _, n := range plc.Nodes(p) { nodes[n] = true } for n, a := range v.Answers { - if len(a.Held) > 0 { + if len(answerOf(a).Held) > 0 { nodes[n] = true } } @@ -214,7 +219,7 @@ func Brief(p delivery.PlacementProposal, v delivery.PlacementVerdict) string { if m := v.Carried[n]; len(m) > 0 { fmt.Fprintf(&b, "; the seed carries: %s", strings.Join(m, "; ")) } - if held := v.Answers[n].Held; len(held) > 0 { + if held := answerOf(v.Answers[n]).Held; len(held) > 0 { var names []string for _, h := range held { today := h.Today @@ -232,7 +237,7 @@ func Brief(p delivery.PlacementProposal, v delivery.PlacementVerdict) string { // differenceWords names one module's difference: the module, the first field that differs in it with its value as it // runs and as the seed composes it, each cut to about 60 characters ("(value cut)" where a body shown is cut before the // field), and how many fields more; or the resource and why when no body is shown. -func differenceWords(d delivery.ModuleDiffers) string { +func differenceWords(d nodeengine.CandidateDiffers) string { for _, b := range d.Bodies { if len(b.Paths) == 0 { continue @@ -254,7 +259,7 @@ func differenceWords(d delivery.ModuleDiffers) string { // shownValue bounds a value named in a brief. const shownValue = 60 -// walk follows a differing path (delivery.DifferingPaths: keys joined by dots, a key may hold dots, and a JSON value +// walk follows a differing path (the node-engine's DifferingPaths: keys joined by dots, a key may hold dots, and a JSON value // written in a string is read as JSON) through a body, answering the keys it passed and the value at its end. func walk(body, path string) (keys []string, value any, ok bool) { var v any @@ -329,3 +334,20 @@ func fieldName(running, composed, path string) string { } return path } + +// answerBody is a node's answer as the verdict carries it: the seat's contract keeps a verdict's answers as bodies, since +// the answer's layout is the node-engine's (ADR 0593 §7). +func answerBody(a nodeengine.CandidateAnswer) map[string]any { + raw, _ := json.Marshal(a) + var out map[string]any + _ = json.Unmarshal(raw, &out) + return out +} + +// answerOf reads a verdict's answer body back; a body that does not fit is an answer with nothing in it. +func answerOf(body map[string]any) nodeengine.CandidateAnswer { + raw, _ := json.Marshal(body) + var out nodeengine.CandidateAnswer + _ = json.Unmarshal(raw, &out) + return out +} diff --git a/modules/mesh-delivery-assigner/internal/judge/judge_test.go b/modules/mesh-delivery-assigner/internal/judge/judge_test.go index 3a3060c0..786b5d5d 100644 --- a/modules/mesh-delivery-assigner/internal/judge/judge_test.go +++ b/modules/mesh-delivery-assigner/internal/judge/judge_test.go @@ -1,23 +1,27 @@ package judge import ( + "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" + mdcc "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliverycheckerclient" "slices" "strings" "testing" "time" - "git.novox.be/novox/mesh-sdk/go/delivery" + mma "git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner" + + plc "mesh-delivery-assigner/internal/assignment" ) -// ADR 0567 §3, §4 (rows moved from the assigner's check of a pull request's placement, ADR 0559 §5): what a proposal +// ADR 0567 §3, §4 (rows moved from the assigner's check of a pull request's assignments, ADR 0559 §5): what a proposal // changes by node, and the verdict of the answers: differences shown and failing nothing, a refusal and a silent node // failing, a later answer judging again, the seed failing on any difference. func TestJudge(t *testing.T) { - current := map[string]delivery.Placement{"anchor": {Node: "anchor", Modules: []delivery.PlacedModule{{Module: "mail"}}}, "server": {Node: "server"}} - laid := map[string]*delivery.Placement{ - "anchor": {Node: "anchor", Modules: []delivery.PlacedModule{{Module: "mail", Ports: map[string]int{"25": 2525}}, {Module: "web"}}}, - "laptop": {Node: "laptop", Modules: []delivery.PlacedModule{{Module: "web"}}}} - // ADR 0593 §8: every node answers; what the placement changes is read from the placement alone. + current := map[string]mma.Assignments{"anchor": {Node: "anchor", Modules: []mma.PlacedModule{{Module: "mail"}}}, "server": {Node: "server"}} + laid := map[string]*mma.Assignments{ + "anchor": {Node: "anchor", Modules: []mma.PlacedModule{{Module: "mail", Ports: map[string]int64{"25": 2525}}, {Module: "web"}}}, + "laptop": {Node: "laptop", Modules: []mma.PlacedModule{{Module: "web"}}}} + // ADR 0593 §8: every node answers; what the assignments changes is read from the assignments alone. got, must := Intended(current, laid, []string{"anchor", "laptop", "server"}) if !got["anchor"]["mail"] || !got["anchor"]["web"] || len(got["laptop"]) != 0 || len(got["server"]) != 0 || !slices.Equal(must, []string{"anchor", "laptop", "server"}) { @@ -25,42 +29,42 @@ func TestJudge(t *testing.T) { } at := time.Date(2026, 10, 27, 2, 0, 0, 0, time.UTC) - p := delivery.PlacementProposal{ID: "p-one", By: "op", Why: "move web", Written: at, Base: map[string]uint64{"anchor": 3, "laptop": 0}, + p := mma.Proposal{ID: "p-one", By: "op", Why: "move web", Written: &at, Base: map[string]int64{"anchor": 3, "laptop": 0}, Must: []string{"anchor", "laptop"}} revisions := map[string]uint64{"anchor": 7, "laptop": 8} intended := map[string]map[string]bool{"anchor": {"web": true}} - same := func(rev uint64) *delivery.CandidateAnswer { - return &delivery.CandidateAnswer{Proposal: "p-one", Revision: rev, Verdict: delivery.CandidateSame, Same: []string{"mail"}} + same := func(rev uint64) *nodeengine.CandidateAnswer { + return &nodeengine.CandidateAnswer{Proposal: "p-one", Revision: int64(rev), Verdict: nodeengine.CandidateAnswerVerdictSame, Same: []string{"mail"}} } - differs := func(rev uint64, module string) *delivery.CandidateAnswer { - return &delivery.CandidateAnswer{Proposal: "p-one", Revision: rev, Verdict: delivery.CandidateDiffers, - Differs: []delivery.ModuleDiffers{{Module: module, Why: "the port differs"}}} + differs := func(rev uint64, module string) *nodeengine.CandidateAnswer { + return &nodeengine.CandidateAnswer{Proposal: "p-one", Revision: int64(rev), Verdict: nodeengine.CandidateAnswerVerdictDiffers, + Differs: []nodeengine.CandidateDiffers{{Module: module, Why: "the port differs"}}} } for _, row := range []struct { name string seed bool - answers map[string]*delivery.CandidateAnswer + answers map[string]*nodeengine.CandidateAnswer pulsing map[string]bool state string words string marked string }{ - {"all the same", false, map[string]*delivery.CandidateAnswer{"anchor": same(7), "laptop": same(8)}, nil, delivery.VerdictPass, "", ""}, - {"a module added where intended", false, map[string]*delivery.CandidateAnswer{"anchor": {Proposal: "p-one", Revision: 7, Verdict: delivery.CandidateDiffers, - Added: []delivery.ModuleAdded{{Module: "web", Unissued: []string{"its own secret"}}}}, "laptop": same(8)}, nil, delivery.VerdictPass, "", ""}, - {"a difference not computed passes, marked", false, map[string]*delivery.CandidateAnswer{"anchor": differs(7, "mail"), "laptop": same(8)}, nil, delivery.VerdictPass, "", "mail"}, - {"the seed carries a difference, named", true, map[string]*delivery.CandidateAnswer{"anchor": same(7), "laptop": {Proposal: "p-one", Revision: 8, - Verdict: delivery.CandidateDiffers, Differs: []delivery.ModuleDiffers{{Module: "node-tools", Resource: "node-tools.runtime", Why: "the resource differs", - Bodies: []delivery.ResourceBodies{{ID: "node-tools.runtime", Paths: []string{"env.MESH_OWNED_PATHS./etc/resolv.conf", "env.MESH_TOOL_MODULES"}, + {"all the same", false, map[string]*nodeengine.CandidateAnswer{"anchor": same(7), "laptop": same(8)}, nil, mdcc.VerdictPass, "", ""}, + {"a module added where intended", false, map[string]*nodeengine.CandidateAnswer{"anchor": {Proposal: "p-one", Revision: 7, Verdict: nodeengine.CandidateAnswerVerdictDiffers, + Added: []nodeengine.CandidateAdded{{Module: "web", Unissued: []string{"its own secret"}}}}, "laptop": same(8)}, nil, mdcc.VerdictPass, "", ""}, + {"a difference not computed passes, marked", false, map[string]*nodeengine.CandidateAnswer{"anchor": differs(7, "mail"), "laptop": same(8)}, nil, mdcc.VerdictPass, "", "mail"}, + {"the seed carries a difference, named", true, map[string]*nodeengine.CandidateAnswer{"anchor": same(7), "laptop": {Proposal: "p-one", Revision: 8, + Verdict: nodeengine.CandidateAnswerVerdictDiffers, Differs: []nodeengine.CandidateDiffers{{Module: "node-tools", Resource: "node-tools.runtime", Why: "the resource differs", + Bodies: []nodeengine.CandidateBodies{{ID: "node-tools.runtime", Paths: []string{"env.MESH_OWNED_PATHS./etc/resolv.conf", "env.MESH_TOOL_MODULES"}, Running: `{"env":{"MESH_OWNED_PATHS":"{\"/etc/hosts\":\"hostname\",\"/etc/resolv.conf\":\"networkmanager (node-uplink)\"}"}}`, - Composed: `{"env":{"MESH_OWNED_PATHS":"{\"/etc/hosts\":\"hostname\",\"/etc/resolv.conf\":\"systemd-resolved (node-resolver)\"}","MESH_TOOL_MODULES":"… (cut at 8192 bytes)`}}}}}}, nil, delivery.VerdictPass, "", ""}, - {"a refusal", false, map[string]*delivery.CandidateAnswer{"anchor": same(7), "laptop": {Proposal: "p-one", Revision: 8, Verdict: delivery.CandidateRefused, - Refusals: []delivery.ModuleRefused{{Module: "web", Why: "no own secret"}}}}, nil, delivery.VerdictFail, "laptop refuses", ""}, - {"a node not answering, within its bound", false, map[string]*delivery.CandidateAnswer{"anchor": same(7)}, map[string]bool{"laptop": true}, delivery.VerdictWaits, "laptop (offline)", ""}, - {"an answer of an earlier revision, past its bound, is no answer and decided without it", false, map[string]*delivery.CandidateAnswer{"anchor": same(6), "laptop": same(8)}, nil, delivery.VerdictPass, "no answer from anchor", ""}, - {"a module held as today passes, shown in the brief", false, map[string]*delivery.CandidateAnswer{"anchor": same(7), "laptop": {Proposal: "p-one", Revision: 8, - Verdict: delivery.CandidateSame, Held: []delivery.ModuleRefused{{Module: "spiegel", Why: "a required value is unset"}}}}, nil, delivery.VerdictPass, "", ""}, - {"its later answer", false, map[string]*delivery.CandidateAnswer{"anchor": same(7), "laptop": same(8)}, nil, delivery.VerdictPass, "", ""}, + Composed: `{"env":{"MESH_OWNED_PATHS":"{\"/etc/hosts\":\"hostname\",\"/etc/resolv.conf\":\"systemd-resolved (node-resolver)\"}","MESH_TOOL_MODULES":"… (cut at 8192 bytes)`}}}}}}, nil, mdcc.VerdictPass, "", ""}, + {"a refusal", false, map[string]*nodeengine.CandidateAnswer{"anchor": same(7), "laptop": {Proposal: "p-one", Revision: 8, Verdict: nodeengine.CandidateAnswerVerdictRefused, + Refusals: []nodeengine.CandidateRefusal{{Module: "web", Why: "no own secret"}}}}, nil, mdcc.VerdictFail, "laptop refuses", ""}, + {"a node not answering, within its bound", false, map[string]*nodeengine.CandidateAnswer{"anchor": same(7)}, map[string]bool{"laptop": true}, plc.VerdictWaits, "laptop (offline)", ""}, + {"an answer of an earlier revision, past its bound, is no answer and decided without it", false, map[string]*nodeengine.CandidateAnswer{"anchor": same(6), "laptop": same(8)}, nil, mdcc.VerdictPass, "no answer from anchor", ""}, + {"a module held as today passes, shown in the brief", false, map[string]*nodeengine.CandidateAnswer{"anchor": same(7), "laptop": {Proposal: "p-one", Revision: 8, + Verdict: nodeengine.CandidateAnswerVerdictSame, Held: []nodeengine.CandidateRefusal{{Module: "spiegel", Why: "a required value is unset"}}}}, nil, mdcc.VerdictPass, "", ""}, + {"its later answer", false, map[string]*nodeengine.CandidateAnswer{"anchor": same(7), "laptop": same(8)}, nil, mdcc.VerdictPass, "", ""}, } { q := p q.Seed = row.seed @@ -69,12 +73,12 @@ func TestJudge(t *testing.T) { if v.State != row.state || !strings.Contains(all, row.words) { t.Errorf("%s: %s, %q", row.name, v.State, all) } - if row.seed && row.state == delivery.VerdictPass && !strings.Contains(Brief(q, v), "the seed carries: node-tools: MESH_OWNED_PATHS[/etc/resolv.conf] networkmanager (node-uplink) → (value cut) (and 1 more fields)") { + if row.seed && row.state == mdcc.VerdictPass && !strings.Contains(Brief(q, v), "the seed carries: node-tools: MESH_OWNED_PATHS[/etc/resolv.conf] networkmanager (node-uplink) → (value cut) (and 1 more fields)") { t.Errorf("%s: the brief does not name it: %s", row.name, Brief(q, v)) } - if row.seed && row.state == delivery.VerdictPass { + if row.seed && row.state == mdcc.VerdictPass { whole := row.answers["laptop"].Differs[0] - whole.Bodies = []delivery.ResourceBodies{{ID: "r", Paths: []string{"env.MESH_OWNED_PATHS./etc/resolv.conf"}, + whole.Bodies = []nodeengine.CandidateBodies{{ID: "r", Paths: []string{"env.MESH_OWNED_PATHS./etc/resolv.conf"}, Running: `{"env":{"MESH_OWNED_PATHS":"{\"/etc/resolv.conf\":\"networkmanager (node-uplink)\"}"}}`, Composed: `{"env":{"MESH_OWNED_PATHS":"{\"/etc/resolv.conf\":\"systemd-resolved (node-resolver)\"}"}}`}} if got := differenceWords(whole); got != "node-tools: MESH_OWNED_PATHS[/etc/resolv.conf] networkmanager (node-uplink) → systemd-resolved (node-resolver)" { diff --git a/modules/mesh-delivery-assigner/internal/puts/puts.go b/modules/mesh-delivery-assigner/internal/puts/puts.go new file mode 100644 index 00000000..2ba7ee20 --- /dev/null +++ b/modules/mesh-delivery-assigner/internal/puts/puts.go @@ -0,0 +1,156 @@ +// Package puts is the assigner's put: one version of a module put on one node by its tool `assigner_put` (novox/hq +// ADR 0546), kept under `.` in its state puts. It is the assigner's own value, which no other module reads +// (the earlier form lived in mesh-sdk's go/delivery; ADR 0634 §5), so the check and the codec live here. +package puts + +import ( + "encoding/json" + "fmt" + "regexp" + "time" + + nec "git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient" + "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" + "git.novox.be/novox/mesh-sdk/go/keys" +) + +// Each put is the key `.` of the assigner's state puts, which holds the version asked, the hold, why, and each +// step the assigner took or waits on, so a reader watches the key to follow it. The want the put writes carries Put (and +// Hold), so the assigner's rules pass over that key while the put holds it. + +// History is the revisions the puts state keeps per key: each step of a put is one. +const History = 20 + +// A put's steps, in the order they are taken (ADR 0546 clauses 3 to 9). A put stands at the last step written. +const ( + // Asked is a put written by the tool, not taken up by a pass yet. + Asked = "asked" + // Build waits for the builder's build at the fingerprint. + Build = "build" + // Compose waits for the composition (and grant) at the fingerprint. + Compose = "compose" + // Person waits for the operator's answer to the ask about the move. + Person = "person" + // Snapshot waits for the bus's streams to be snapshotted after the operator's yes. + Snapshot = "snapshot" + // Wanted is the want written: the node's have has not reached it yet. + Wanted = "wanted" + // Holding is a held put whose have runs it healthy: the want stays until released. + Holding = "holding" + // Done is a put without hold whose have ran it healthy: the rules write the want again. + Done = "done" + // Released is a put released by the tool `assigner_release`. + Released = "released" + // Refused is a put that cannot go on: a build that failed, an answer no, a snapshot not taken. Its words say why. + Refused = "refused" +) + +// Ended says a put at this step is over: nothing more is done for it and the rules own the want. +func Ended(step string) bool { + return step == Done || step == Released || step == Refused +} + +// Step is one step a put took: when, which, and what was done or waits, in a sentence. +type Step struct { + At time.Time `json:"at"` + Step string `json:"step"` + Words string `json:"words"` +} + +// StepsKept is how many steps a put keeps, newest last. +const StepsKept = 16 + +// Put is the value of a key of the state puts. +type Put struct { + V int `json:"v"` + // FP is the fingerprint put, of the new form. + FP string `json:"fp"` + // Hold keeps the want at FP until released. + Hold bool `json:"hold,omitempty"` + Why string `json:"why"` + // By is who asked, as the bus named the caller; "unproven" when it named none. + By string `json:"by,omitempty"` + At time.Time `json:"at"` + // Step is where the put stands now, one of the steps above; Words say it in a sentence. + Step string `json:"step"` + Words string `json:"words,omitempty"` + // Composed is the digest of the composition the want names, once known. + Composed string `json:"composed,omitempty"` + // Ask is the key, in the asks state, of the ask about the move, once asked. + Ask string `json:"ask,omitempty"` + // Snapshot says where the bus's streams were snapshotted, once they were. + Snapshot string `json:"snapshot,omitempty"` + // Steps are the steps taken, oldest first, at most StepsKept. + Steps []Step `json:"steps,omitempty"` +} + +// Said is p at step with words, the step added to its steps when it differs from the last one. +func (p Put) Said(at time.Time, step, words string) Put { + if p.Step == step && p.Words == words { + return p + } + p.Step, p.Words = step, words + p.Steps = append(append([]Step(nil), p.Steps...), Step{At: at.UTC(), Step: step, Words: words}) + if len(p.Steps) > StepsKept { + p.Steps = p.Steps[len(p.Steps)-StepsKept:] + } + return p +} + +var digestForm = regexp.MustCompile(`^sha256:[0-9a-f]{64}$`) + +func (p Put) check() error { + if _, err := meshcatalogclient.KeyPart(p.FP); err != nil { + return fmt.Errorf("puts: a put's fingerprint: %w", err) + } + if p.Why == "" { + return fmt.Errorf("puts: a put says why") + } + switch p.Step { + case Asked, Build, Compose, Person, Snapshot, Wanted, Holding, Done, Released, Refused: + default: + return fmt.Errorf("puts: %q is not a step of a put", p.Step) + } + if p.Composed != "" && !digestForm.MatchString(p.Composed) { + return fmt.Errorf("puts: a put's composition digest %q is not sha256:<64 lowercase hex digits>", p.Composed) + } + return nil +} + +// Key is the key, in the state puts, of the put of module on node: `.`. +func Key(node, module string) (string, error) { + return nec.HaveKey(node, module) +} + +// ParseKey reads a Key. +func ParseKey(key string) (node, module string, err error) { + node, module, isNode, err := nec.ParseHaveKey(key) + if err == nil && isNode { + err = fmt.Errorf("puts: %q is a node's own key, and no put", key) + } + return node, module, err +} + +// Encode is p as JSON; it refuses a fingerprint not of the new form, a put that says no why and an unknown step. +func Encode(p Put) ([]byte, error) { + if err := p.check(); err != nil { + return nil, err + } + p.V = keys.Version + return json.Marshal(p) +} + +// Decode reads a put; it refuses an unknown version (keys.ErrUnknownVersion) and what Encode refuses. +func Decode(data []byte) (Put, error) { + if err := keys.CheckVersion(data); err != nil { + return Put{}, err + } + var p Put + if err := json.Unmarshal(data, &p); err != nil { + return Put{}, fmt.Errorf("puts: value does not fit its shape: %w", err) + } + if err := p.check(); err != nil { + return Put{}, err + } + return p, nil +} diff --git a/modules/mesh-delivery-assigner/internal/puts/puts_test.go b/modules/mesh-delivery-assigner/internal/puts/puts_test.go new file mode 100644 index 00000000..5844ba3d --- /dev/null +++ b/modules/mesh-delivery-assigner/internal/puts/puts_test.go @@ -0,0 +1,66 @@ +package puts + +import ( + "errors" + "strings" + "testing" + "time" + + "git.novox.be/novox/mesh-sdk/go/keys" +) + +// TestPut holds a put's bytes equal to what go/delivery's codec wrote for the same value (golden), its steps kept and its +// key, and the refusals it made. +func TestPut(t *testing.T) { + a := "src2:" + strings.Repeat("a", 64) + at := time.Date(2026, 11, 5, 10, 0, 0, 0, time.UTC) + p := Put{FP: a, Hold: true, Why: "why", By: "me", At: at, Composed: "sha256:" + strings.Repeat("c", 64), Ask: "k", Snapshot: "s"} + p = p.Said(at, Wanted, "w1") + p = p.Said(at, Wanted, "w1") + p = p.Said(at.Add(time.Minute), Holding, "w2") + golden := `{"v":1,"fp":"` + a + `","hold":true,"why":"why","by":"me","at":"2026-11-05T10:00:00Z","step":"holding","words":"w2","composed":"sha256:` + strings.Repeat("c", 64) + `","ask":"k","snapshot":"s","steps":[{"at":"2026-11-05T10:00:00Z","step":"wanted","words":"w1"},{"at":"2026-11-05T10:01:00Z","step":"holding","words":"w2"}]}` + raw, err := Encode(p) + if err != nil || string(raw) != golden { + t.Fatalf("wrote %s, %v", raw, err) + } + back, err := Decode(raw) + if err != nil || back.Step != Holding || len(back.Steps) != 2 { + t.Fatalf("read back %+v, %v", back, err) + } + for i := 0; i < 2*StepsKept; i++ { + p = p.Said(at, Build, strings.Repeat("x", i+1)) + } + if len(p.Steps) != StepsKept { + t.Fatalf("%d steps kept", len(p.Steps)) + } + if k, err := Key("ace", "novox.be"); err != nil || k != "ace.novox=2ebe" { + t.Fatalf("Key = %q %v", k, err) + } + if n, m, err := ParseKey("ace.novox=2ebe"); err != nil || n != "ace" || m != "novox.be" { + t.Fatalf("ParseKey = %q %q %v", n, m, err) + } + if _, _, err := ParseKey("ace.node"); err == nil { + t.Error("a node's own key is a put's") + } + for name, bad := range map[string]Put{ + "old fingerprint": {FP: "src1:x", Why: "w", Step: Asked}, + "no why": {FP: a, Step: Asked}, + "unknown step": {FP: a, Why: "w", Step: "later"}, + "bad digest": {FP: a, Why: "w", Step: Asked, Composed: "x"}, + } { + if _, err := Encode(bad); err == nil { + t.Errorf("%s: written", name) + } + } + if _, err := Decode([]byte(`{"fp":"` + a + `","why":"w","step":"asked"}`)); !errors.Is(err, keys.ErrUnknownVersion) { + t.Errorf("no version: %v", err) + } + for _, s := range []string{Done, Released, Refused} { + if !Ended(s) { + t.Errorf("%s is not an end", s) + } + } + if Ended(Holding) { + t.Error("holding is an end") + } +} diff --git a/modules/mesh-delivery-assigner/manifest_test.go b/modules/mesh-delivery-assigner/manifest_test.go index 087b34cd..a396d3a3 100644 --- a/modules/mesh-delivery-assigner/manifest_test.go +++ b/modules/mesh-delivery-assigner/manifest_test.go @@ -1,13 +1,19 @@ package manifest import ( + "git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner" + "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshwants" "os" "reflect" "slices" + "strings" "testing" "git.novox.be/novox/mesh-sdk/go/catalogue" - "git.novox.be/novox/mesh-sdk/go/delivery" + + "git.novox.be/novox/mesh-sdk/go/views" + + "mesh-delivery-assigner/internal/buckets" ) func load(t *testing.T) catalogue.Manifest { @@ -34,20 +40,31 @@ func TestManifest(t *testing.T) { reads := slices.Clone(m.Reads) slices.Sort(reads) // ADR 0593 §8: it composes nothing, so it reads no candidate, held or resolved value, compose step or build log; it reads - // every node's published compositions to compare them, and the gate's source and merges, for the release that carries a build (ADR 0601 §1); - // and the indexer's module graph, whose verb edges a want waits on (ADR 0614 §2, §3). - want := []string{"mesh-delivery-builder.builds", "mesh-delivery-gate.merges", "mesh-delivery-gate.source", "mesh-delivery-indexer.graph", "mesh-delivery-indexer.manifests", "mesh-delivery-indexer.modules", + // every node's published compositions to compare them, and the gate's source and merges, for the release that carries a build (ADR 0601 §1). + // DLV-F2r (ADR 0615 §1, ADR 0631 §1): the indexer's manifests, modules and module graph (whose verb edges a want waits + // on, ADR 0614 §2, §3) are read through its first views and views family, none of the indexer's states by reads. + want := []string{"mesh-delivery-builder.builds", "mesh-delivery-gate.merges", "mesh-delivery-gate.source", "mesh-delivery-teller.answers", "mesh-delivery-watchman.presence", "mesh-grants.grants", "mesh-host.composed", "mesh-provisioner.provisions", "node-engine.have"} if !reflect.DeepEqual(reads, want) { t.Errorf("reads = %v, want exactly %v", reads, want) } + // the buckets the program names are the manifest's reads, `.` as `_` + named := []string{buckets.Builds, buckets.Merges, buckets.Source, buckets.Answers, buckets.Presence, buckets.Grants, + buckets.ComposedRead, "mesh-provisioner_provisions", buckets.Have} + slices.Sort(named) + for i, r := range want { + if got := strings.Replace(named[i], "_", ".", 1); got != r { + t.Errorf("the buckets package names %q, the manifest reads %q", got, r) + } + } states := map[string]int{} for _, s := range m.State { states[s.Name] = s.History } if want := map[string]int{"asks": 5, "differences": 64, "puts": 20, "wants": 20, - "placement": 64, "proposals": 5, "candidates": 5, "pulses": 0, "findings": 5, "policies": 20, "seat-holders": 5}; !reflect.DeepEqual(states, want) { + "assignments": 64, "placement": 64, "copies": 1, "proposals": 5, "candidates": 5, "pulses": 0, "findings": 5, "policies": 20, "seat-holders": 5, views.MarksState: 1, views.StatusState: 1, + "engine-reads": 1}; !reflect.DeepEqual(states, want) { t.Errorf("state = %v, want %v", states, want) } if _, ok := m.OwnSecrets["broker"]; !ok || len(m.OwnSecrets) != 1 { @@ -63,34 +80,57 @@ func TestManifest(t *testing.T) { } // ADR 0546: it serves its own tools (put, release, puts) from its bundle through the tool runner (the artifact loads the // module), says each put's step as the event put, and calls the hand-act seat and the bus node's backup holder. - // ADR 0567 §1, §6-§10: it holds the seat mesh-placement, serving its five verbs (adopt and hand-back went with the - // controller, ADR 0590); keeps the seat's events on its stream placement for 30 days; calls the hand-act log; and reads + // ADR 0567 §1, §6-§10: it holds the seat mesh-module-assigner, serving its five verbs (adopt and hand-back went with the + // controller, ADR 0590); keeps the seat's events on its stream assignments for 30 days; calls the hand-act log; and reads // mesh-config's resolved values to compose every node a proposal touches. // ADR 0565 §7, §9 (issue 1953 D4): and the seat mesh-wants, serving upgrade and bus. - if len(m.Claims) != 2 || m.Claims[0].Name != catalogue.PlacementSeat || m.Claims[0].At() != catalogue.ScopeMesh || - !slices.Equal(m.Claims[0].Serves, []string{"placement", "proposals", "propose", "withdraw", "again"}) || - m.Claims[1].Name != catalogue.WantsSeat || m.Claims[1].At() != catalogue.ScopeMesh || !slices.Equal(m.Claims[1].Serves, []string{"upgrade", "bus"}) { - t.Errorf("the assigner claims %+v, want mesh-placement with its five verbs and mesh-wants with its two", m.Claims) + if len(m.Claims) != 2 || m.Claims[0].Name != meshmoduleassigner.Seat || m.Claims[0].At() != catalogue.ScopeMesh || + !slices.Equal(m.Claims[0].Serves, []string{"assignments", "proposals", "propose", "withdraw", "again"}) || + m.Claims[1].Name != meshwants.Seat || m.Claims[1].At() != catalogue.ScopeMesh || !slices.Equal(m.Claims[1].Serves, []string{"upgrade", "bus"}) { + t.Errorf("the assigner claims %+v, want mesh-module-assigner with its five verbs and mesh-wants with its two", m.Claims) } - if seat, _ := catalogue.SeatNamed(catalogue.WantsSeat); catalogue.CanHold(m, seat) != nil { - t.Errorf("the assigner cannot hold mesh-wants: %v", catalogue.CanHold(m, seat)) + // Whether the seats' definitions let it hold them is the manifest validator's (the judge, at the seat list). + // ADR 0621 §1: the first-view tools assignments, seat-holders and wants and design 25 § 12.2's six read tools, of the + // program's bundle, which runs in a runtime of its own on the assigner's account; ADR 0618 §3: and assigner_engine_reads, + // which lists and asks again what each node's engine reads. + if !slices.Equal(m.Tools, []string{"assigner_put", "assigner_release", "assigner_puts", "assignments", "seat-holders", "wants", + "assigner_read_assignments", "assigner_read_seat_holders", "assigner_read_wants", "assigner_read_findings", "assigner_read_asks", "assigner_read_differences", "assigner_read_copies", + "assigner_first_views", "assigner_first_view_again", "assigner_engine_reads"}) { + t.Errorf("the assigner serves tools %v, want its three put tools, three first views, six read tools and assigner_engine_reads", m.Tools) } - if seat, _ := catalogue.SeatNamed(catalogue.PlacementSeat); catalogue.CanHold(m, seat) != nil { - t.Errorf("the assigner cannot hold the seat: %v", catalogue.CanHold(m, seat)) + if m.RunsAs != m.Module || m.SecretsOwner != m.RunsAs { + t.Errorf("runs-as = %q: the bundle runs on the assigner's own account (ADR 0621 §1)", m.RunsAs) } - if !slices.Equal(m.Tools, []string{"assigner_put", "assigner_release", "assigner_puts"}) { - t.Errorf("the assigner serves tools %v, want its three put tools", m.Tools) + // ADR 0621 §2: each first-view tool answers exactly its readers, never the assigner. + callers := map[string]string{"assignments": "mesh-grants,node-tools,mesh-delivery-indexer", "seat-holders": "node-tools", "wants": "mesh-grants"} + env := m.Build.Artifacts[0].Env + for tool, list := range callers { + if env[views.CallersEnv(tool)] != list || views.ParseCallers(env[views.CallersEnv(tool)]).Allows("n1."+m.Module) { + t.Errorf("%s callers = %q, want %q", tool, env[views.CallersEnv(tool)], list) + } } - if len(m.Streams) != 1 || m.Streams[0].Name != delivery.PlacementStream || m.Streams[0].MaxAgeDays != delivery.PlacementStreamDays || - !slices.Equal(slices.Sorted(slices.Values(m.Streams[0].Events)), slices.Sorted(slices.Values(delivery.PlacementEvents))) { - t.Errorf("streams = %+v, want placement carrying the seat's four events for %d days", m.Streams, delivery.PlacementStreamDays) + // ADR 0635 §5: its views family is its ordinary events, `views.` among its emits, on no stream of its own. + // The seat's four events are said on the seat's own subject (the seat's family), so the module keeps no stream for them + // and lists none among its emits. + if len(m.Streams) != 0 { + t.Errorf("streams = %+v, want none: the seat's events ride the shared events stream", m.Streams) } - if want := []string{ + for _, e := range meshmoduleassigner.Events() { + if slices.Contains(m.Emits, e) { + t.Errorf("emits lists %s, an event the seat says", e) + } + } + // ADR 0618 §4: and it hears each node's engine say its reads changed. + if !slices.Equal(m.Consumes, []string{"mesh-delivery-indexer.views.graph", "mesh-delivery-indexer.views.manifests", "mesh-delivery-indexer.views.modules", "node-engine.manifest-reads-changed"}) { + t.Errorf("consumes = %v, want the indexer's views of graph, manifests and modules and the engines' reads changed", m.Consumes) + } + if want := []string{"mesh-delivery-indexer.graph", "mesh-delivery-indexer.manifests", "mesh-delivery-indexer.modules", "seat:mesh-hand-acts.hand-act", "seat:mesh-hand-acts.hand-acts", "seat:mesh-hand-acts.warranted", // ADR 0565 §9, §11: the seat alone - "seat:node-backup.backed-up", "seat:node-backup.now"}; !slices.Equal(slices.Sorted(slices.Values(m.Invokes)), want) { + "seat:node-backup.backed-up", "seat:node-backup.now", + "seat:node-engine.manifest-reads"}; !slices.Equal(slices.Sorted(slices.Values(m.Invokes)), want) { // ADR 0618 §3 t.Errorf("invokes = %v, want %v", m.Invokes, want) } - if !slices.Equal(m.Emits, []string{"put"}) { - t.Errorf("emits = %v, want put", m.Emits) + if want := []string{"assignments-copied", "put", "views.asks", "views.copies", "views.differences", "views.findings", "views.assignments", "views.seat-holders", "views.wants"}; !slices.Equal(m.Emits, want) { + t.Errorf("emits = %v, want the copy, put and its six views", m.Emits) } } diff --git a/modules/mesh-delivery-assigner/module.json b/modules/mesh-delivery-assigner/module.json index 7f94deaa..b1b56213 100644 --- a/modules/mesh-delivery-assigner/module.json +++ b/modules/mesh-delivery-assigner/module.json @@ -2,12 +2,13 @@ "module": "mesh-delivery-assigner", "version": "1", "slug": "assigner", + "runs-as": "mesh-delivery-assigner", "claims": [ { - "name": "mesh-placement", + "name": "mesh-module-assigner", "scope": "mesh", "serves": [ - "placement", + "assignments", "proposals", "propose", "withdraw", @@ -27,9 +28,6 @@ "mesh-delivery-builder.builds", "mesh-delivery-gate.merges", "mesh-delivery-gate.source", - "mesh-delivery-indexer.graph", - "mesh-delivery-indexer.manifests", - "mesh-delivery-indexer.modules", "mesh-delivery-teller.answers", "mesh-delivery-watchman.presence", "mesh-grants.grants", @@ -40,23 +38,56 @@ "uses": [ "operator-channel" ], + "consumes": [ + "mesh-delivery-indexer.views.graph", + "mesh-delivery-indexer.views.manifests", + "mesh-delivery-indexer.views.modules", + "node-engine.manifest-reads-changed" + ], "invokes": [ + "mesh-delivery-indexer.graph", + "mesh-delivery-indexer.manifests", + "mesh-delivery-indexer.modules", "seat:mesh-hand-acts.hand-act", "seat:mesh-hand-acts.hand-acts", "seat:mesh-hand-acts.warranted", "seat:node-backup.backed-up", - "seat:node-backup.now" + "seat:node-backup.now", + "seat:node-engine.manifest-reads" ], "emits": [ - "put" + "assignments-copied", + "put", + "views.asks", + "views.copies", + "views.differences", + "views.findings", + "views.assignments", + "views.seat-holders", + "views.wants" ], "tools": [ "assigner_put", "assigner_release", - "assigner_puts" + "assigner_puts", + "assignments", + "seat-holders", + "wants", + "assigner_read_assignments", + "assigner_read_seat_holders", + "assigner_read_wants", + "assigner_read_findings", + "assigner_read_asks", + "assigner_read_differences", + "assigner_read_copies", + "assigner_first_views", + "assigner_first_view_again", + "assigner_engine_reads" ], "state": [ + { "name": "assignments", "history": 64 }, { "name": "placement", "history": 64 }, + { "name": "copies", "history": 1 }, { "name": "proposals", "history": 5 }, { "name": "candidates", "history": 5 }, { "name": "pulses", "key-ttl": true }, @@ -66,19 +97,10 @@ { "name": "wants", "history": 20 }, { "name": "findings", "history": 5 }, { "name": "policies", "history": 20 }, - { "name": "seat-holders", "history": 5 } - ], - "streams": [ - { - "name": "placement", - "events": [ - "placement-proposed", - "placement-judged", - "placement-changed", - "placement-withdrawn" - ], - "max-age-days": 30 - } + { "name": "seat-holders", "history": 5 }, + { "name": "view-marks", "history": 1 }, + { "name": "first-views", "history": 1 }, + { "name": "engine-reads", "history": 1 } ], "own-secrets": { "broker": "${dir:state}/broker" @@ -128,7 +150,21 @@ "binary": "mesh-delivery-assigner", "loads": [ "mesh-delivery-assigner" - ] + ], + "env": { + "MESH_VIEW_CALLERS_ASSIGNMENTS": "mesh-grants,node-tools,mesh-delivery-indexer", + "MESH_VIEW_CALLERS_SEAT_HOLDERS": "node-tools", + "MESH_VIEW_CALLERS_WANTS": "mesh-grants" + } + }, + { + "name": "asks", + "kind": "package", + "language": "go", + "paths": [ + "asks" + ], + "series": "0.1" } ] } diff --git a/modules/mesh-delivery-assigner/oldseat_test.go b/modules/mesh-delivery-assigner/oldseat_test.go new file mode 100644 index 00000000..fb24bf03 --- /dev/null +++ b/modules/mesh-delivery-assigner/oldseat_test.go @@ -0,0 +1,28 @@ +package manifest + +import ( + "io/fs" + "os" + "path/filepath" + "strings" + "testing" +) + +// The seat the assigner holds is mesh-module-assigner (novox/hq ADR 0634 §9): no message, tool text or comment of the module +// says the name it had before. +func TestNoTextSaysTheOldSeatName(t *testing.T) { + old := "mesh-" + "assignments" + err := filepath.WalkDir(".", func(path string, d fs.DirEntry, err error) error { + if err != nil || d.IsDir() || !strings.HasSuffix(path, ".go") || path == "oldseat_test.go" { + return err + } + raw, err := os.ReadFile(path) + if err == nil && strings.Contains(string(raw), old) { + t.Errorf("%s still says %s", path, old) + } + return err + }) + if err != nil { + t.Fatal(err) + } +} diff --git a/modules/mesh-delivery-builder/buildrun/asks.go b/modules/mesh-delivery-builder/buildrun/asks.go index 26e4fd35..eb0b219f 100644 --- a/modules/mesh-delivery-builder/buildrun/asks.go +++ b/modules/mesh-delivery-builder/buildrun/asks.go @@ -10,7 +10,7 @@ import ( "github.com/nats-io/nats.go" "github.com/nats-io/nats.go/jetstream" - "git.novox.be/novox/mesh-sdk/go/delivery" + "git.novox.be/novox/mesh-delivery/sdk/buildstep" ) // **An asker puts an ask on its build seat's stream and waits for its step** (novox/hq ADR 0525 §1, §6; issue 1887 A4, @@ -25,7 +25,7 @@ type AskPublisher interface { // AskWaiter watches an ask's steps: every value a key `*.` of the build module's steps state takes, running or // ended, on the channel it answers, until ctx ends. A watch, not a poll. type AskWaiter interface { - WatchAsk(ctx context.Context, module, askID string) (<-chan delivery.Step, error) + WatchAsk(ctx context.Context, module, askID string) (<-chan buildstep.Step, error) } // Asker puts asks and waits for their steps. @@ -39,12 +39,12 @@ type Asker struct { } // Put puts a on its stream: the seat's accept subject of its language and kind. -func (k Asker) Put(ctx context.Context, a delivery.StepAsk) error { - raw, err := delivery.EncodeStepAsk(a) +func (k Asker) Put(ctx context.Context, a buildstep.StepAsk) error { + raw, err := buildstep.EncodeStepAsk(a) if err != nil { return err } - subject, err := delivery.AskSubject(a.Language, a.Kind) + subject, err := buildstep.AskSubject(a.Language, a.Kind) if err != nil { return err } @@ -57,16 +57,16 @@ func (k Asker) Put(ctx context.Context, a delivery.StepAsk) error { // Wait waits for the step of an ask already put (a builder that started again takes over its own asks with it, never // putting them twice) and answers it when it ended, whatever its state. The bound runs from the step's start on the node // that took the ask, read from its running key, so an ask that waits in the stream for a free slot waits as long as ctx. -func (k Asker) Wait(ctx context.Context, a delivery.StepAsk) (delivery.Step, error) { - module, err := delivery.BuildModuleOf(a.Language) +func (k Asker) Wait(ctx context.Context, a buildstep.StepAsk) (buildstep.Step, error) { + module, err := buildstep.BuildModuleOf(a.Language) if err != nil { - return delivery.Step{}, err + return buildstep.Step{}, err } watch, cancel := context.WithCancel(ctx) defer cancel() steps, err := k.Waiter.WatchAsk(watch, module, a.ID) if err != nil { - return delivery.Step{}, err + return buildstep.Step{}, err } var bound, outerCap <-chan time.Time var timer, capTimer *time.Timer @@ -89,20 +89,20 @@ func (k Asker) Wait(ctx context.Context, a delivery.StepAsk) (delivery.Step, err for { select { case <-ctx.Done(): - return delivery.Step{}, ctx.Err() + return buildstep.Step{}, ctx.Err() case <-bound: - return delivery.Step{}, standStill() + return buildstep.Step{}, standStill() case <-outerCap: - return delivery.Step{}, fmt.Errorf("the ask %s's step on %s did not end within %s of its start: it kept moving (last phase %q at %s) but never ended", + return buildstep.Step{}, fmt.Errorf("the ask %s's step on %s did not end within %s of its start: it kept moving (last phase %q at %s) but never ended", a.ID, running, 3*k.Bound, phase, phaseAt.UTC().Format(time.RFC3339)) case step, ok := <-steps: if !ok { - return delivery.Step{}, errors.New("the watch of the ask's step ended") + return buildstep.Step{}, errors.New("the watch of the ask's step ended") } if seen := stepSeenOf(ctx); seen != nil { seen(step) } - if step.State != delivery.StepRunning { + if step.State != buildstep.StepRunning { return step, nil } if k.Bound <= 0 { @@ -148,36 +148,36 @@ func (k Asker) Wait(ctx context.Context, a delivery.StepAsk) (delivery.Step, err type stepSeenKey struct{} // WithStepSeen is ctx carrying seen, which Asker.Wait hands every value the ask's step key takes while it waits: the node -// that took it, each phase it says while running (delivery.Step.Phase), and its end. A try names its asks' progress so +// that took it, each phase it says while running (buildstep.Step.Phase), and its end. A try names its asks' progress so // (issue 1890). seen runs in the waiting goroutine and must not block. A seen ctx already carries is kept: both are handed // every value, the earlier first (a check's begin bound watches the steps of a try that names them). -func WithStepSeen(ctx context.Context, seen func(delivery.Step)) context.Context { +func WithStepSeen(ctx context.Context, seen func(buildstep.Step)) context.Context { if before := stepSeenOf(ctx); before != nil { later := seen - seen = func(s delivery.Step) { before(s); later(s) } + seen = func(s buildstep.Step) { before(s); later(s) } } return context.WithValue(ctx, stepSeenKey{}, seen) } -func stepSeenOf(ctx context.Context) func(delivery.Step) { - seen, _ := ctx.Value(stepSeenKey{}).(func(delivery.Step)) +func stepSeenOf(ctx context.Context) func(buildstep.Step) { + seen, _ := ctx.Value(stepSeenKey{}).(func(buildstep.Step)) return seen } // Ask puts a and waits for its step; a step that did not pass is an error naming its node, its state and its reason. -func (k Asker) Ask(ctx context.Context, a delivery.StepAsk) (delivery.Step, error) { +func (k Asker) Ask(ctx context.Context, a buildstep.StepAsk) (buildstep.Step, error) { if err := k.Put(ctx, a); err != nil { - return delivery.Step{}, err + return buildstep.Step{}, err } return k.Passed(k.Wait(ctx, a)) } // Passed is step when it passed, else an error naming where it ended and why. -func (k Asker) Passed(step delivery.Step, err error) (delivery.Step, error) { +func (k Asker) Passed(step buildstep.Step, err error) (buildstep.Step, error) { if err != nil { return step, err } - if step.State != delivery.StepPassed { + if step.State != buildstep.StepPassed { return step, fmt.Errorf("the ask %s on %s %s: %s", step.ID, step.Node, step.State, step.Reason) } return step, nil @@ -199,20 +199,20 @@ func (s JetStreamAsks) PublishAsk(ctx context.Context, subject, id string, data // WatchAsk is AskWaiter's: the watch hands over what the keys hold now, so a step that ended before it began is read at // once, and every change after. -func (s JetStreamAsks) WatchAsk(ctx context.Context, module, askID string) (<-chan delivery.Step, error) { - pattern, err := delivery.AskStepWatch(askID) +func (s JetStreamAsks) WatchAsk(ctx context.Context, module, askID string) (<-chan buildstep.Step, error) { + pattern, err := buildstep.AskStepWatch(askID) if err != nil { return nil, err } - kv, err := s.JS.KeyValue(ctx, delivery.StepsBucket(module)) + kv, err := s.JS.KeyValue(ctx, StepsBucket(module)) if err != nil { - return nil, fmt.Errorf("the bucket %s: %w", delivery.StepsBucket(module), err) + return nil, fmt.Errorf("the bucket %s: %w", StepsBucket(module), err) } w, err := kv.Watch(ctx, pattern) if err != nil { return nil, err } - out := make(chan delivery.Step) + out := make(chan buildstep.Step) go func() { defer close(out) defer w.Stop() @@ -227,7 +227,7 @@ func (s JetStreamAsks) WatchAsk(ctx context.Context, module, askID string) (<-ch if e == nil || e.Operation() != jetstream.KeyValuePut { continue } - step, err := delivery.DecodeStep(e.Value()) + step, err := buildstep.DecodeStep(e.Value()) if err != nil { continue // not a step (a node's pause key never matches an ask id; anything else is not this ask's) } @@ -246,17 +246,17 @@ func (s JetStreamAsks) WatchAsk(ctx context.Context, module, askID string) (<-ch // written at once with what Answer says a build node wrote (running or ended). Write writes a step later. Safe for // concurrent use. type FakeAsks struct { - Answer func(a delivery.StepAsk) delivery.Step + Answer func(a buildstep.StepAsk) buildstep.Step mu sync.Mutex - asks []delivery.StepAsk - steps map[string][]delivery.Step - watchers map[string][]chan delivery.Step + asks []buildstep.StepAsk + steps map[string][]buildstep.Step + watchers map[string][]chan buildstep.Step } // PublishAsk is AskPublisher's. func (f *FakeAsks) PublishAsk(_ context.Context, _, id string, data []byte) error { - a, err := delivery.DecodeStepAsk(data) + a, err := buildstep.DecodeStepAsk(data) if err != nil { return err } @@ -270,13 +270,13 @@ func (f *FakeAsks) PublishAsk(_ context.Context, _, id string, data []byte) erro } // Write writes an ask's step, as a build node does. -func (f *FakeAsks) Write(step delivery.Step) { +func (f *FakeAsks) Write(step buildstep.Step) { f.mu.Lock() if f.steps == nil { - f.steps, f.watchers = map[string][]delivery.Step{}, map[string][]chan delivery.Step{} + f.steps, f.watchers = map[string][]buildstep.Step{}, map[string][]chan buildstep.Step{} } f.steps[step.ID] = append(f.steps[step.ID], step) - ws := append([]chan delivery.Step(nil), f.watchers[step.ID]...) + ws := append([]chan buildstep.Step(nil), f.watchers[step.ID]...) f.mu.Unlock() for _, w := range ws { w <- step @@ -284,14 +284,14 @@ func (f *FakeAsks) Write(step delivery.Step) { } // End is Write: kept for the tests that end a step. -func (f *FakeAsks) End(step delivery.Step) { f.Write(step) } +func (f *FakeAsks) End(step buildstep.Step) { f.Write(step) } // WatchAsk is AskWaiter's: what was written so far, then every write. -func (f *FakeAsks) WatchAsk(ctx context.Context, _ string, askID string) (<-chan delivery.Step, error) { - out := make(chan delivery.Step, 64) +func (f *FakeAsks) WatchAsk(ctx context.Context, _ string, askID string) (<-chan buildstep.Step, error) { + out := make(chan buildstep.Step, 64) f.mu.Lock() if f.steps == nil { - f.steps, f.watchers = map[string][]delivery.Step{}, map[string][]chan delivery.Step{} + f.steps, f.watchers = map[string][]buildstep.Step{}, map[string][]chan buildstep.Step{} } for _, s := range f.steps[askID] { out <- s @@ -314,8 +314,8 @@ func (f *FakeAsks) WatchAsk(ctx context.Context, _ string, askID string) (<-chan } // Asks are the asks put so far. -func (f *FakeAsks) Asks() []delivery.StepAsk { +func (f *FakeAsks) Asks() []buildstep.StepAsk { f.mu.Lock() defer f.mu.Unlock() - return append([]delivery.StepAsk(nil), f.asks...) + return append([]buildstep.StepAsk(nil), f.asks...) } diff --git a/modules/mesh-delivery-builder/buildrun/asks_integration_test.go b/modules/mesh-delivery-builder/buildrun/asks_integration_test.go index 6c6961c0..31363fad 100644 --- a/modules/mesh-delivery-builder/buildrun/asks_integration_test.go +++ b/modules/mesh-delivery-builder/buildrun/asks_integration_test.go @@ -11,7 +11,7 @@ import ( "github.com/nats-io/nats.go" "github.com/nats-io/nats.go/jetstream" - "git.novox.be/novox/mesh-sdk/go/delivery" + "git.novox.be/novox/mesh-delivery/sdk/buildstep" "git.novox.be/novox/mesh-sdk/go/testbus" ) @@ -29,7 +29,7 @@ func TestAnAskIsPutOnceAndItsStepIsRead(t *testing.T) { Retention: jetstream.WorkQueuePolicy}); err != nil { t.Fatal(err) } - kv, err := js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: delivery.StepsBucket("mesh-build-go")}) + kv, err := js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: StepsBucket("mesh-build-go")}) if err != nil { t.Fatal(err) } @@ -47,10 +47,10 @@ func TestAnAskIsPutOnceAndItsStepIsRead(t *testing.T) { } go func() { time.Sleep(100 * time.Millisecond) - key, _ := delivery.AskStepKey("ace", a.ID) - running, _ := delivery.EncodeStep(delivery.Step{ID: a.ID, Tool: "delivery ask", Node: "ace", State: delivery.StepRunning, Started: time.Now()}) + key, _ := buildstep.AskStepKey("ace", a.ID) + running, _ := buildstep.EncodeStep(buildstep.Step{ID: a.ID, Tool: "delivery ask", Node: "ace", State: buildstep.StepRunning, Started: time.Now()}) _, _ = kv.Put(ctx, key, running) - passed, _ := delivery.EncodeStep(delivery.Step{ID: a.ID, Tool: "delivery ask", Node: "ace", State: delivery.StepPassed, Started: time.Now(), + passed, _ := buildstep.EncodeStep(buildstep.Step{ID: a.ID, Tool: "delivery ask", Node: "ace", State: buildstep.StepPassed, Started: time.Now(), Result: json.RawMessage(`{"artifacts":[]}`)}) _, _ = kv.Put(ctx, key, passed) }() diff --git a/modules/mesh-delivery-builder/buildrun/asks_test.go b/modules/mesh-delivery-builder/buildrun/asks_test.go index 3011500d..50c3e4cf 100644 --- a/modules/mesh-delivery-builder/buildrun/asks_test.go +++ b/modules/mesh-delivery-builder/buildrun/asks_test.go @@ -9,33 +9,33 @@ import ( "testing" "time" - "git.novox.be/novox/mesh-sdk/go/delivery" + "git.novox.be/novox/mesh-delivery/sdk/buildstep" "git.novox.be/novox/mesh-sdk/go/health" ) -func goAsk(id string) delivery.StepAsk { - return delivery.StepAsk{ID: id, Kind: delivery.AskDelivery, Language: delivery.AskGo, Asker: "mesh-delivery-builder", At: time.Now(), - Build: &delivery.BuildJob{Module: "m", Path: ".", Fetch: map[string]any{"repository": "novox/x", "commit": strings.Repeat("a", 40)}, - Artifacts: []delivery.AskArtifact{{Artifact: "program"}}}} +func goAsk(id string) buildstep.StepAsk { + return buildstep.StepAsk{ID: id, Kind: buildstep.AskDelivery, Language: buildstep.AskGo, Asker: "mesh-delivery-builder", At: time.Now(), + Build: &buildstep.BuildJob{Module: "m", Path: ".", Fetch: &buildstep.Fetch{Repository: "novox/x", Commit: strings.Repeat("a", 40)}, + Artifacts: []buildstep.AskArtifact{{Artifact: "program"}}}} } // 1887/A4a: an asker puts the ask once and answers the step a build node wrote for it, naming the node; one that did not // pass is an error naming the node and why; a wait of an ask already put puts nothing. func TestAnAskerPutsOnceAndReadsTheStep(t *testing.T) { - f := &FakeAsks{Answer: func(a delivery.StepAsk) delivery.Step { - return delivery.Step{ID: a.ID, Node: "ace", State: delivery.StepPassed, Result: []byte(`{"artifacts":[]}`)} + f := &FakeAsks{Answer: func(a buildstep.StepAsk) buildstep.Step { + return buildstep.Step{ID: a.ID, Node: "ace", State: buildstep.StepPassed, Result: []byte(`{"artifacts":[]}`)} }} k := Asker{Publisher: f, Waiter: f} - var seen, also []delivery.Step // issue 1890: a waiter that asks to see each value of the step sees it + var seen, also []buildstep.Step // issue 1890: a waiter that asks to see each value of the step sees it // Issue 1921: a second seen on the same context (a check's begin bound inside a try) is added, never in place of the first. - both := WithStepSeen(WithStepSeen(context.Background(), func(s delivery.Step) { seen = append(seen, s) }), - func(s delivery.Step) { also = append(also, s) }) + both := WithStepSeen(WithStepSeen(context.Background(), func(s buildstep.Step) { seen = append(seen, s) }), + func(s buildstep.Step) { also = append(also, s) }) step, err := k.Ask(both, goAsk("mvl0frv18d1957")) if err != nil || step.Node != "ace" || len(f.Asks()) != 1 || len(seen) != 1 || seen[0].Node != "ace" || len(also) != 1 { t.Fatalf("step %+v, %v, asks %d, seen %+v and %+v", step, err, len(f.Asks()), seen, also) } - f.Answer = func(a delivery.StepAsk) delivery.Step { - return delivery.Step{ID: a.ID, Node: "shanks", State: delivery.StepRefused, Reason: "go 1.27 installed"} + f.Answer = func(a buildstep.StepAsk) buildstep.Step { + return buildstep.Step{ID: a.ID, Node: "shanks", State: buildstep.StepRefused, Reason: "go 1.27 installed"} } if _, err := k.Ask(context.Background(), goAsk("mvl0frv18d1958")); err == nil || !strings.Contains(err.Error(), "shanks refused: go 1.27 installed") { t.Fatalf("a refused step: %v", err) @@ -49,7 +49,7 @@ func TestAnAskerPutsOnceAndReadsTheStep(t *testing.T) { if _, err := k.Wait(waiting, goAsk("mvl0frv18d1959")); err != context.DeadlineExceeded { t.Fatalf("an ask no node took: %v, want it still waiting when ctx ended", err) } - g.Write(delivery.Step{ID: "mvl0frv18d1961", Node: "ace", State: delivery.StepRunning, Started: time.Now()}) + g.Write(buildstep.Step{ID: "mvl0frv18d1961", Node: "ace", State: buildstep.StepRunning, Started: time.Now()}) if _, err := k.Wait(context.Background(), goAsk("mvl0frv18d1961")); err == nil || !strings.Contains(err.Error(), "on ace did not end within") { t.Fatalf("a step that never ends: %v", err) } @@ -63,16 +63,16 @@ func TestAnAskerPutsOnceAndReadsTheStep(t *testing.T) { // of 60 ms). k = Asker{Publisher: g, Waiter: g, Bound: time.Hour} now := time.Now() - g.Write(delivery.Step{ID: "mvl0frv18d1980", Node: "ace", State: delivery.StepRunning, Started: now.Add(-90 * time.Minute), Phase: "pb", PhaseAt: now}) - g.Write(delivery.Step{ID: "mvl0frv18d1980", Node: "ace", State: delivery.StepPassed}) - if step, err := k.Wait(context.Background(), goAsk("mvl0frv18d1980")); err != nil || step.State != delivery.StepPassed { + g.Write(buildstep.Step{ID: "mvl0frv18d1980", Node: "ace", State: buildstep.StepRunning, Started: now.Add(-90 * time.Minute), Phase: "pb", PhaseAt: now}) + g.Write(buildstep.Step{ID: "mvl0frv18d1980", Node: "ace", State: buildstep.StepPassed}) + if step, err := k.Wait(context.Background(), goAsk("mvl0frv18d1980")); err != nil || step.State != buildstep.StepPassed { t.Fatalf("a step that kept moving past its bound: %+v, %v", step, err) } - g.Write(delivery.Step{ID: "mvl0frv18d1981", Node: "ace", State: delivery.StepRunning, Started: now.Add(-time.Hour), Phase: "integration: tests", PhaseAt: now.Add(-time.Hour)}) + g.Write(buildstep.Step{ID: "mvl0frv18d1981", Node: "ace", State: buildstep.StepRunning, Started: now.Add(-time.Hour), Phase: "integration: tests", PhaseAt: now.Add(-time.Hour)}) if _, err := k.Wait(context.Background(), goAsk("mvl0frv18d1981")); err == nil || !strings.Contains(err.Error(), `stood still in "integration: tests"`) { t.Fatalf("a step that stood still in a phase: %v", err) } - g.Write(delivery.Step{ID: "mvl0frv18d1982", Node: "ace", State: delivery.StepRunning, Started: now.Add(-3 * time.Hour), Phase: "qz", PhaseAt: now}) + g.Write(buildstep.Step{ID: "mvl0frv18d1982", Node: "ace", State: buildstep.StepRunning, Started: now.Add(-3 * time.Hour), Phase: "qz", PhaseAt: now}) if _, err := k.Wait(context.Background(), goAsk("mvl0frv18d1982")); err == nil || !strings.Contains(err.Error(), "kept moving") { t.Fatalf("a step that never stops moving: %v", err) } @@ -82,7 +82,7 @@ func TestAnAskerPutsOnceAndReadsTheStep(t *testing.T) { dead := &DeadLetters{} lw := LostAskWaiter{Inner: g, Dead: dead} k = Asker{Publisher: g, Waiter: lw, Bound: time.Minute} - g.Write(delivery.Step{ID: "mvl0frv18d1970", Node: "ace", State: delivery.StepRunning, Started: time.Now()}) + g.Write(buildstep.Step{ID: "mvl0frv18d1970", Node: "ace", State: buildstep.StepRunning, Started: time.Now()}) given, ok := GivenUpOf(map[string][]string{"Mesh-Dead-Msg-Id": {"mvl0frv18d1970"}, "Mesh-Dead-Deliveries": {"5"}, "Mesh-Dead-Stream": {"SEAT_NODE_BUILD_GO"}, "Mesh-Dead-Gave-Up": {"2026-10-26T12:26:40Z"}}) if !ok { @@ -90,7 +90,7 @@ func TestAnAskerPutsOnceAndReadsTheStep(t *testing.T) { } go dead.Heard(given) step, err = k.Wait(context.Background(), goAsk("mvl0frv18d1970")) - if err != nil || step.State != delivery.StepFailed || !IsLost(step) || + if err != nil || step.State != buildstep.StepFailed || !IsLost(step) || !strings.Contains(step.Reason, LostWords+" after 5 deliveries at 2026-10-26T12:26:40Z") { t.Fatalf("a given-up ask: %+v, %v", step, err) } @@ -101,7 +101,7 @@ func TestAnAskerPutsOnceAndReadsTheStep(t *testing.T) { if err != nil || !IsNotTaken(step) || IsLost(step) { t.Fatalf("an ask no node took: %+v, %v", step, err) } - g.Write(delivery.Step{ID: "mvl0frv18d1972", Node: "g14", State: delivery.StepPassed}) + g.Write(buildstep.Step{ID: "mvl0frv18d1972", Node: "g14", State: buildstep.StepPassed}) if step, err = k.Wait(NotTakenBound(context.Background(), time.Minute), goAsk("mvl0frv18d1972")); err != nil || step.Node != "g14" { t.Fatalf("a step written: %+v, %v", step, err) } @@ -110,14 +110,14 @@ func TestAnAskerPutsOnceAndReadsTheStep(t *testing.T) { fol := &refusingFollower{refuse: 1} var mu sync.Mutex var told []Hearing - hearing := NewDeadLetterHearing(context.Background(), fol, []string{delivery.AskGo}, dead, nil, + hearing := NewDeadLetterHearing(context.Background(), fol, []string{buildstep.AskGo}, dead, nil, func(h Hearing) { mu.Lock(); told = append(told, h); mu.Unlock() }) hearing.Wait() if why := hearing.Deaf(); !strings.Contains(why, "SEAT_NODE_BUILD_GO: permissions violation") { t.Fatalf("a refused hearing says %q", why) } k = Asker{Publisher: g, Waiter: LostAskWaiter{Inner: g, Dead: dead, Hearing: hearing}, Bound: time.Minute} - g.Write(delivery.Step{ID: "mvl0frv18d1973", Node: "g14", State: delivery.StepPassed}) + g.Write(buildstep.Step{ID: "mvl0frv18d1973", Node: "g14", State: buildstep.StepPassed}) if _, err := k.Wait(context.Background(), goAsk("mvl0frv18d1973")); err != nil { t.Fatal(err) } @@ -137,8 +137,8 @@ func TestAnAskerPutsOnceAndReadsTheStep(t *testing.T) { d := &FakeAsks{} heard := &HeardEnds{Inner: deafWatch{}, Read: d} k = Asker{Publisher: d, Waiter: heard, Bound: time.Minute} - d.Write(delivery.Step{ID: "mvl0frv18d1980", Node: "shanks", State: delivery.StepPassed}) - got := make(chan delivery.Step, 1) + d.Write(buildstep.Step{ID: "mvl0frv18d1980", Node: "shanks", State: buildstep.StepPassed}) + got := make(chan buildstep.Step, 1) go func() { s, _ := k.Wait(context.Background(), goAsk("mvl0frv18d1980")); got <- s }() for heard.Waiting() == 0 { time.Sleep(time.Millisecond) @@ -150,11 +150,11 @@ func TestAnAskerPutsOnceAndReadsTheStep(t *testing.T) { case <-time.After(20 * time.Millisecond): } heard.Nudge(GoBuildModule) - if s := <-got; s.State != delivery.StepPassed || s.Node != "shanks" { + if s := <-got; s.State != buildstep.StepPassed || s.Node != "shanks" { t.Fatalf("a step its watch never handed over: %+v", s) } closing := &HeardEnds{Inner: &closingWatch{inner: d}, Read: d} - if s, err := (Asker{Publisher: d, Waiter: closing}).Wait(context.Background(), goAsk("mvl0frv18d1980")); err != nil || s.State != delivery.StepPassed { + if s, err := (Asker{Publisher: d, Waiter: closing}).Wait(context.Background(), goAsk("mvl0frv18d1980")); err != nil || s.State != buildstep.StepPassed { t.Fatalf("a watch that closed once: %+v, %v", s, err) } for _, row := range []struct { @@ -181,8 +181,8 @@ func TestARedeliveryToTheSameNodeRestartsTheBound(t *testing.T) { k := Asker{Publisher: f, Waiter: f, Bound: time.Hour} const id = "mvl0frv18d1962" now := time.Now() - f.Write(delivery.Step{ID: id, Node: "ace", State: delivery.StepRunning, Started: now}) - f.Write(delivery.Step{ID: id, Node: "ace", State: delivery.StepRunning, Started: now.Add(-time.Hour)}) + f.Write(buildstep.Step{ID: id, Node: "ace", State: buildstep.StepRunning, Started: now}) + f.Write(buildstep.Step{ID: id, Node: "ace", State: buildstep.StepRunning, Started: now.Add(-time.Hour)}) ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() if _, err := k.Wait(ctx, goAsk(id)); err == nil || !strings.Contains(err.Error(), "on ace did not end within 1h0m0s of its start") { @@ -216,8 +216,8 @@ func (f *refusingFollower) heard(g GivenUp) { f.mu.Lock(); h := f.hand; f.mu.Unl // deafWatch is a watch of an ask's step that stays open and hears nothing. type deafWatch struct{} -func (deafWatch) WatchAsk(context.Context, string, string) (<-chan delivery.Step, error) { - return make(chan delivery.Step), nil +func (deafWatch) WatchAsk(context.Context, string, string) (<-chan buildstep.Step, error) { + return make(chan buildstep.Step), nil } // closingWatch closes its first watch at once, and hands the later ones to inner. @@ -226,10 +226,10 @@ type closingWatch struct { closed bool } -func (c *closingWatch) WatchAsk(ctx context.Context, module, askID string) (<-chan delivery.Step, error) { +func (c *closingWatch) WatchAsk(ctx context.Context, module, askID string) (<-chan buildstep.Step, error) { if !c.closed { c.closed = true - ch := make(chan delivery.Step) + ch := make(chan buildstep.Step) close(ch) return ch, nil } diff --git a/modules/mesh-delivery-builder/buildrun/choose.go b/modules/mesh-delivery-builder/buildrun/choose.go index 06aea831..b8997280 100644 --- a/modules/mesh-delivery-builder/buildrun/choose.go +++ b/modules/mesh-delivery-builder/buildrun/choose.go @@ -5,7 +5,7 @@ import ( "sort" "strings" - "git.novox.be/novox/mesh-sdk/go/delivery" + "git.novox.be/novox/mesh-delivery/sdk/buildstep" ) // ChooseNodes picks up to want build nodes for a language (ADR 0478 clause 3): the nodes whose toolchains key reports the @@ -18,10 +18,10 @@ import ( // agree on, qualifying or not, as the indexer computes it. When nothing is reported, or the nodes report different // versions (so the indexer agreed none), it fails naming the language and why; when no reporting node qualifies it fails // naming the nodes. A node whose build module refuses on its toolchain refuses the step, and the build fails naming it. -func ChooseNodes(language string, entries []delivery.NodeToolchain, places *Places, qualifies func(node string) bool, want int) ([]string, delivery.Toolchain2, error) { - tc, none := delivery.AgreedToolchain(entries) +func ChooseNodes(language string, entries []buildstep.NodeToolchain, places *Places, qualifies func(node string) bool, want int) ([]string, buildstep.Toolchain, error) { + tc, none := buildstep.AgreedToolchain(entries) if none != "" { - return nil, delivery.Toolchain2{}, fmt.Errorf("no %s build node qualifies: %s", language, none) + return nil, buildstep.Toolchain{}, fmt.Errorf("no %s build node qualifies: %s", language, none) } nodes, err := choose(language, entries, places, qualifies, want) return nodes, tc, err @@ -29,11 +29,11 @@ func ChooseNodes(language string, entries []delivery.NodeToolchain, places *Plac // ChooseAnyNodes is ChooseNodes for artifacts that need no toolchain, such as an archive (ADR 0478 clause 1): the // build module's nodes agree on nothing, so any node of the build module that qualifies is chosen, by capacity and load only. -func ChooseAnyNodes(language string, entries []delivery.NodeToolchain, places *Places, qualifies func(node string) bool, want int) ([]string, error) { +func ChooseAnyNodes(language string, entries []buildstep.NodeToolchain, places *Places, qualifies func(node string) bool, want int) ([]string, error) { return choose(language, entries, places, qualifies, want) } -func choose(language string, entries []delivery.NodeToolchain, places *Places, qualifies func(node string) bool, want int) ([]string, error) { +func choose(language string, entries []buildstep.NodeToolchain, places *Places, qualifies func(node string) bool, want int) ([]string, error) { if places == nil { places = &Places{} } diff --git a/modules/mesh-delivery-builder/buildrun/choose_test.go b/modules/mesh-delivery-builder/buildrun/choose_test.go index bb2e69ab..d39e70c6 100644 --- a/modules/mesh-delivery-builder/buildrun/choose_test.go +++ b/modules/mesh-delivery-builder/buildrun/choose_test.go @@ -4,17 +4,17 @@ import ( "strings" "testing" - "git.novox.be/novox/mesh-sdk/go/delivery" + "git.novox.be/novox/mesh-delivery/sdk/buildstep" ) const digest = "sha256:2222222222222222222222222222222222222222222222222222222222222222" func hex64(c byte) string { return strings.Repeat(string(c), 64) } -func tcs(language, version string, nodes ...string) []delivery.NodeToolchain { - var out []delivery.NodeToolchain +func tcs(language, version string, nodes ...string) []buildstep.NodeToolchain { + var out []buildstep.NodeToolchain for _, n := range nodes { - out = append(out, delivery.NodeToolchain{Node: n, Toolchain: delivery.Toolchain{Language: language, Version: version, Recipe: "1"}}) + out = append(out, buildstep.NodeToolchain{Node: n, Toolchain: buildstep.Toolchain{Language: language, Version: version, Recipe: "1"}}) } return out } @@ -42,8 +42,8 @@ func chooseWithOneNodeAnswersOneNode(t *testing.T) { // 1607/AC4 func chooseFailsNamingTheLanguageAndTheVersionsWhenNoNodeQualifies(t *testing.T) { // 1607/AC4 _, _, err := ChooseNodes("typescript", nil, nil, nil, 2) - if err == nil || !strings.Contains(err.Error(), "typescript") || !strings.Contains(err.Error(), delivery.ToolchainNoReport) { - t.Errorf("no report: error = %v, want it to name typescript and %q", err, delivery.ToolchainNoReport) + if err == nil || !strings.Contains(err.Error(), "typescript") || !strings.Contains(err.Error(), buildstep.ToolchainNoReport) { + t.Errorf("no report: error = %v, want it to name typescript and %q", err, buildstep.ToolchainNoReport) } entries := append(tcs("go", "go1.27.1", "novox"), tcs("go", "go1.26.0", "ace")...) _, _, err = ChooseNodes("go", entries, nil, nil, 2) diff --git a/modules/mesh-delivery-builder/buildrun/go.mod b/modules/mesh-delivery-builder/buildrun/go.mod index 1306670a..712cc087 100644 --- a/modules/mesh-delivery-builder/buildrun/go.mod +++ b/modules/mesh-delivery-builder/buildrun/go.mod @@ -3,20 +3,27 @@ module git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103221822-85ba0a9a70d8 + git.novox.be/novox/mesh-contracts/go/clients/meshmoduleassignerclient v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshwatchdogs v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-delivery/sdk v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000 github.com/nats-io/nats.go v1.54.0 ) require ( - github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op // indirect + github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op // indirect github.com/google/go-tpm v0.9.8 // indirect - github.com/klauspost/compress v1.20.0 // indirect + github.com/klauspost/compress v1.20.1 // indirect github.com/minio/highwayhash v1.0.4 // indirect - github.com/nats-io/jwt/v2 v2.8.1 // indirect + github.com/nats-io/jwt/v2 v2.8.2 // indirect github.com/nats-io/nats-server/v2 v2.11.17 // indirect github.com/nats-io/nkeys v0.4.16 // indirect github.com/nats-io/nuid v1.0.1 // indirect golang.org/x/crypto v0.57.0 // indirect golang.org/x/sys v0.48.0 // indirect - golang.org/x/time v0.15.0 // indirect + golang.org/x/time v0.16.0 // indirect ) + +require git.novox.be/novox/mesh-contracts/go/seats/nodeengine v0.0.0-00010101000000-000000000000 // indirect diff --git a/modules/mesh-delivery-builder/buildrun/go.sum b/modules/mesh-delivery-builder/buildrun/go.sum index 7679a5dc..8ca93dfd 100644 --- a/modules/mesh-delivery-builder/buildrun/go.sum +++ b/modules/mesh-delivery-builder/buildrun/go.sum @@ -1,15 +1,13 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103221822-85ba0a9a70d8 h1:XEbIVImp6yGjA91rp0iZBoDdL5KfamjOuDLwWvqh0hc= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103221822-85ba0a9a70d8/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= -github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= -github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= +github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op h1:1BOWQJweNyvZMlpAHXGLiZQn9S+QXGcz3xh94lC0w6E= +github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= -github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA= -github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= +github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ= +github.com/klauspost/compress v1.20.1/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= github.com/minio/highwayhash v1.0.4 h1:asJizugGgchQod2ja9NJlGOWq4s7KsAWr5XUc9Clgl4= github.com/minio/highwayhash v1.0.4/go.mod h1:GGYsuwP/fPD6Y9hMiXuapVvlIUEhFhMTh0rxU3ik1LQ= -github.com/nats-io/jwt/v2 v2.8.1 h1:V0xpGuD/N8Mi+fQNDynXohVvp7ZztevW5io8CUWlPmU= -github.com/nats-io/jwt/v2 v2.8.1/go.mod h1:nWnOEEiVMiKHQpnAy4eXlizVEtSfzacZ1Q43LIRavZg= +github.com/nats-io/jwt/v2 v2.8.2 h1:XXRgB60MSTnqsRwejQurVDs/hcv2dkt+86GjI+I/bMc= +github.com/nats-io/jwt/v2 v2.8.2/go.mod h1:Ag/56sq9OblL4JgdYufDd16Egb17Kr/8WwwuO/forVc= github.com/nats-io/nats-server/v2 v2.11.17 h1:GKEghcFK6A+aFx11Yf1LjgLC3txAwvyhnYzhBIQZA8I= github.com/nats-io/nats-server/v2 v2.11.17/go.mod h1:B1sFVz4StNosQ903ak4N1G01Fl/9f8e06mXpFIE2K24= github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA= @@ -23,5 +21,5 @@ golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZ golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= -golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= -golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= +golang.org/x/time v0.16.0 h1:vMb6ptszcQMkcwiRTAuNNU50gom6++Q/6gY2hDM6VDE= +golang.org/x/time v0.16.0/go.mod h1:rVKOqvZeKvrDKTQiAHJ7wmwP0RzleSphoEA9RcdLA0s= diff --git a/modules/mesh-delivery-builder/buildrun/have.go b/modules/mesh-delivery-builder/buildrun/have.go index f3c079cf..ae5be958 100644 --- a/modules/mesh-delivery-builder/buildrun/have.go +++ b/modules/mesh-delivery-builder/buildrun/have.go @@ -3,7 +3,7 @@ package buildrun import ( "sync" - "git.novox.be/novox/mesh-sdk/go/delivery" + "git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient" ) // HaveSet holds what each node's node-engine says of each module on it (node-engine_have, key `.`): the @@ -19,17 +19,17 @@ type HaveSet struct { // Put takes a change of the have bucket in and answers whether a module's healthy state changed. A node's own key // (`.node`) and a value that is not a have are left out. func (s *HaveSet) Put(key string, value []byte, deleted bool) bool { - node, module, isNode, err := delivery.ParseHaveKey(key) + node, module, isNode, err := nodeengineclient.ParseHaveKey(key) if err != nil || isNode { return false } ok := false if !deleted { - h, err := delivery.DecodeHave(value) + h, err := nodeengineclient.DecodeHave(value) if err != nil { return false } - ok = h.Phase == delivery.PhaseRunning && h.Health == delivery.HealthOK + ok = h.Phase == nodeengineclient.PhaseRunning && h.Health == nodeengineclient.HealthOK } k := node + "." + module s.mu.Lock() diff --git a/modules/mesh-delivery-builder/buildrun/heardends.go b/modules/mesh-delivery-builder/buildrun/heardends.go index 25b8cbbb..3cae74f1 100644 --- a/modules/mesh-delivery-builder/buildrun/heardends.go +++ b/modules/mesh-delivery-builder/buildrun/heardends.go @@ -7,7 +7,7 @@ import ( "github.com/nats-io/nats.go/jetstream" - "git.novox.be/novox/mesh-sdk/go/delivery" + "git.novox.be/novox/mesh-delivery/sdk/buildstep" ) // **A step's end is never only its own watch's to hear** (the lost check of 2026-10-28: mesh-catalog #553's verdict @@ -22,7 +22,7 @@ import ( // StepReader reads, now, every step a build module's steps state holds for an ask: its keys `*.`. type StepReader interface { - AskSteps(ctx context.Context, module, askID string) ([]delivery.Step, error) + AskSteps(ctx context.Context, module, askID string) ([]buildstep.Step, error) } // reopenTries is how often a wait opens its ask's watch again after it closed, before the wait ends failed. @@ -50,7 +50,7 @@ func (h *HeardEnds) logf(format string, args ...any) { } // WatchAsk is AskWaiter's: what Inner's watch hands, and, at each nudge, what the ask's keys hold now. -func (h *HeardEnds) WatchAsk(ctx context.Context, module, askID string) (<-chan delivery.Step, error) { +func (h *HeardEnds) WatchAsk(ctx context.Context, module, askID string) (<-chan buildstep.Step, error) { in, err := h.Inner.WatchAsk(ctx, module, askID) if err != nil { return nil, err @@ -62,7 +62,7 @@ func (h *HeardEnds) WatchAsk(ctx context.Context, module, askID string) (<-chan } h.waiting[askID] = w h.mu.Unlock() - out := make(chan delivery.Step) + out := make(chan buildstep.Step) go func() { defer close(out) defer func() { @@ -72,7 +72,7 @@ func (h *HeardEnds) WatchAsk(ctx context.Context, module, askID string) (<-chan } h.mu.Unlock() }() - send := func(s delivery.Step) bool { + send := func(s buildstep.Step) bool { select { case out <- s: return true @@ -112,7 +112,7 @@ func (h *HeardEnds) WatchAsk(ctx context.Context, module, askID string) (<-chan continue } for _, s := range steps { - if s.State != delivery.StepRunning { + if s.State != buildstep.StepRunning { h.logf("the ask %s's step on %s ended %s, read at a nudge: its watch had not handed it over", askID, s.Node, s.State) } if !send(s) { @@ -147,26 +147,26 @@ func (h *HeardEnds) Waiting() int { } // AskSteps is StepReader's: the keys `*.` of the build module's steps state, read now. -func (s JetStreamAsks) AskSteps(ctx context.Context, module, askID string) ([]delivery.Step, error) { - pattern, err := delivery.AskStepWatch(askID) +func (s JetStreamAsks) AskSteps(ctx context.Context, module, askID string) ([]buildstep.Step, error) { + pattern, err := buildstep.AskStepWatch(askID) if err != nil { return nil, err } - kv, err := s.JS.KeyValue(ctx, delivery.StepsBucket(module)) + kv, err := s.JS.KeyValue(ctx, StepsBucket(module)) if err != nil { - return nil, fmt.Errorf("the bucket %s: %w", delivery.StepsBucket(module), err) + return nil, fmt.Errorf("the bucket %s: %w", StepsBucket(module), err) } lister, err := kv.ListKeysFiltered(ctx, pattern) if err != nil { return nil, err } - var out []delivery.Step + var out []buildstep.Step for key := range lister.Keys() { e, err := kv.Get(ctx, key) if err != nil { continue // deleted between the list and the read } - if step, err := delivery.DecodeStep(e.Value()); err == nil { + if step, err := buildstep.DecodeStep(e.Value()); err == nil { out = append(out, step) } } @@ -182,7 +182,7 @@ func HearEnds(ctx context.Context, asks JetStreamAsks, inner AskWaiter, modules } h := &HeardEnds{Inner: inner, Read: asks, Logf: logf} for _, module := range modules { - kv, err := asks.JS.KeyValue(ctx, delivery.StepsBucket(module)) + kv, err := asks.JS.KeyValue(ctx, StepsBucket(module)) if err != nil { logf("the steps of %s cannot be read (%v): an ask whose own watch goes deaf is read only at a reconnect", module, err) continue @@ -206,7 +206,7 @@ func (h *HeardEnds) follow(ctx context.Context, kv jetstream.KeyValue, module st continue } heard = true - if step, err := delivery.DecodeStep(e.Value()); err == nil && step.State != delivery.StepRunning { + if step, err := buildstep.DecodeStep(e.Value()); err == nil && step.State != buildstep.StepRunning { h.Nudge(module) } } @@ -226,11 +226,11 @@ func (h *HeardEnds) follow(ctx context.Context, kv jetstream.KeyValue, module st } // AskSteps is StepReader's: the last step written for the ask, if any. -func (f *FakeAsks) AskSteps(_ context.Context, _ string, askID string) ([]delivery.Step, error) { +func (f *FakeAsks) AskSteps(_ context.Context, _ string, askID string) ([]buildstep.Step, error) { f.mu.Lock() defer f.mu.Unlock() if s := f.steps[askID]; len(s) > 0 { - return []delivery.Step{s[len(s)-1]}, nil + return []buildstep.Step{s[len(s)-1]}, nil } return nil, nil } diff --git a/modules/mesh-delivery-builder/buildrun/lostask.go b/modules/mesh-delivery-builder/buildrun/lostask.go index 750acaa4..a3392412 100644 --- a/modules/mesh-delivery-builder/buildrun/lostask.go +++ b/modules/mesh-delivery-builder/buildrun/lostask.go @@ -12,13 +12,13 @@ import ( "github.com/nats-io/nats.go/jetstream" - "git.novox.be/novox/mesh-sdk/go/delivery" + "git.novox.be/novox/mesh-delivery/sdk/buildstep" "git.novox.be/novox/mesh-sdk/go/health" "git.novox.be/novox/mesh-sdk/go/process" ) // **An ask the build seat gave up on ends its step** (novox/hq issue 734). A build seat's worker offers an ask -// delivery.AskMaxDeliver times; one handed out that often without an answer (a bus reload that drops every hand-out, a node +// buildstep.AskMaxDeliver times; one handed out that often without an answer (a bus reload that drops every hand-out, a node // that dies taking it each time) is given up by the server, and the controller keeps a copy of it in DEAD_LETTERS under // mesh.events.dead.., with the ask's message id (its ask id), how often it was handed out and when it // was given up. No build node writes a step for it, so an asker that waits for the step waits for ever: a try stays @@ -193,7 +193,7 @@ func (w LostAskWaiter) now() time.Time { } // WatchAsk is AskWaiter's. -func (w LostAskWaiter) WatchAsk(ctx context.Context, module, askID string) (<-chan delivery.Step, error) { +func (w LostAskWaiter) WatchAsk(ctx context.Context, module, askID string) (<-chan buildstep.Step, error) { if w.Hearing != nil && w.Hearing.Deaf() != "" { w.Hearing.Again() } @@ -215,12 +215,12 @@ func (w LostAskWaiter) WatchAsk(ctx context.Context, module, askID string) (<-ch nt, stopBound = context.WithTimeout(context.Background(), bound) notTaken = nt.Done() } - out := make(chan delivery.Step) + out := make(chan buildstep.Step) go func() { defer close(out) defer cancel() defer func() { stopBound() }() - send := func(s delivery.Step) bool { + send := func(s buildstep.Step) bool { select { case out <- s: return true @@ -228,9 +228,9 @@ func (w LostAskWaiter) WatchAsk(ctx context.Context, module, askID string) (<-ch return false } } - ended := func(reason string) delivery.Step { + ended := func(reason string) buildstep.Step { at := w.now().UTC() - return delivery.Step{ID: askID, Node: LostNode, State: delivery.StepFailed, Reason: reason, Ended: at} + return buildstep.Step{ID: askID, Node: LostNode, State: buildstep.StepFailed, Reason: reason, Ended: at} } for { select { @@ -261,10 +261,12 @@ func (w LostAskWaiter) WatchAsk(ctx context.Context, module, askID string) (<-ch } // IsLost says whether a step was ended by LostAskWaiter because the build seat gave its ask up (LostWords). -func IsLost(s delivery.Step) bool { return s.Node == LostNode && strings.Contains(s.Reason, LostWords) } +func IsLost(s buildstep.Step) bool { + return s.Node == LostNode && strings.Contains(s.Reason, LostWords) +} // IsNotTaken says whether a step was ended by LostAskWaiter because no build node took its ask within the bound. -func IsNotTaken(s delivery.Step) bool { +func IsNotTaken(s buildstep.Step) bool { return s.Node == LostNode && strings.Contains(s.Reason, NotTakenWords) } diff --git a/modules/mesh-delivery-builder/buildrun/names.go b/modules/mesh-delivery-builder/buildrun/names.go new file mode 100644 index 00000000..50ab553c --- /dev/null +++ b/modules/mesh-delivery-builder/buildrun/names.go @@ -0,0 +1,16 @@ +package buildrun + +// The holders' states the runner reads, named from the builder's own manifest (module.json `reads`; a unit row of the +// builder checks them against it) and from the build seats' states: a bucket is `_`, and no contract +// names a holder (novox/hq ADR 0625 §4, ADR 0634 §1). +const ( + // AssignmentsBucket is the assigner's state assignments, which the toolchain gate reads for the placing of a build module. + AssignerModule = "mesh-delivery-assigner" + AssignmentsBucket = AssignerModule + "_assignments" +) + +// StepsBucket is the bucket of a build module's state steps. +func StepsBucket(module string) string { return module + "_steps" } + +// ToolchainsBucket is the bucket of a build module's state toolchains. +func ToolchainsBucket(module string) string { return module + "_toolchains" } diff --git a/modules/mesh-delivery-builder/buildrun/presence.go b/modules/mesh-delivery-builder/buildrun/presence.go index 63f82435..fafc431b 100644 --- a/modules/mesh-delivery-builder/buildrun/presence.go +++ b/modules/mesh-delivery-builder/buildrun/presence.go @@ -3,7 +3,7 @@ package buildrun import ( "sync" - "git.novox.be/novox/mesh-sdk/go/delivery" + "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshwatchdogs" ) // PresenceSet holds which nodes are online, as the watchman writes it (mesh-delivery-watchman_presence, key ``, the @@ -17,13 +17,16 @@ type PresenceSet struct { // Put takes a change of the presence bucket in and answers whether a node's online state changed. A part's key and a // value that is not a presence are left out. func (s *PresenceSet) Put(key string, value []byte, deleted bool) bool { - node, part, _, err := delivery.ParsePresenceKey(key) - if err != nil || part != "" { + if _, _, isPart := meshwatchdogs.ParsePresenceByPartPartInstanceKey(key); isPart { + return false + } + node, ok := meshwatchdogs.ParsePresenceByNodeKey(key) + if !ok { return false } on := false if !deleted { - p, err := delivery.DecodePresence(value) + p, err := meshwatchdogs.DecodePresenceByNodeValue(value) if err != nil { return false } diff --git a/modules/mesh-delivery-builder/buildrun/presence_test.go b/modules/mesh-delivery-builder/buildrun/presence_test.go index 5157609a..38e45577 100644 --- a/modules/mesh-delivery-builder/buildrun/presence_test.go +++ b/modules/mesh-delivery-builder/buildrun/presence_test.go @@ -1,14 +1,15 @@ package buildrun import ( + "encoding/json" "testing" - "git.novox.be/novox/mesh-sdk/go/delivery" + "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshwatchdogs" ) func presenceValue(t *testing.T, online bool) []byte { t.Helper() - b, err := delivery.EncodePresence(delivery.Presence{Online: online}) + b, err := json.Marshal(meshwatchdogs.PresenceByNodeValue{Online: online}) if err != nil { t.Fatal(err) } diff --git a/modules/mesh-delivery-builder/buildrun/standing.go b/modules/mesh-delivery-builder/buildrun/standing.go index c0b92e96..e921c781 100644 --- a/modules/mesh-delivery-builder/buildrun/standing.go +++ b/modules/mesh-delivery-builder/buildrun/standing.go @@ -6,12 +6,14 @@ import ( "fmt" "maps" "slices" + "sort" "sync" "time" "github.com/nats-io/nats.go/jetstream" - "git.novox.be/novox/mesh-sdk/go/delivery" + "git.novox.be/novox/mesh-contracts/go/clients/meshmoduleassignerclient" + "git.novox.be/novox/mesh-delivery/sdk/buildstep" "git.novox.be/novox/mesh-sdk/go/process" ) @@ -21,12 +23,12 @@ import ( // ace.typescript at v26.12.0, and every Node fingerprint said "builders disagree"). // // ToolchainGate stands between the toolchains buckets and what takes their entries in (the indexer's PutToolchain, a -// ToolchainSet): an entry is passed on only while the placement (the seat mesh-placement's state `placement`, held by -// the assigner, delivery.PlacementBucket, read by delivery.Placed; ADR 0567, ADR 0549 §3) places its build module on -// its node, and only once it was written since that placement; it is passed on as deleted otherwise, said once, and -// passed on when its node writes it again. A report written before the placement is one an earlier run of the module +// ToolchainSet): an entry is passed on only while the assignments (the seat mesh-module-assigner's state `assignments`, held by +// the assigner, AssignmentsBucket, read by meshmoduleassignerclient.Placed; ADR 0567, ADR 0549 §3) places its build module on +// its node, and only once it was written since that assignments; it is passed on as deleted otherwise, said once, and +// passed on when its node writes it again. A report written before the assignments is one an earlier run of the module // left (issue 1940, 2026-10-25 20:56: mesh-build-node put on ace again brought ace.typescript v26.12.0 of 2026-10-23 -// back into the agreement before the module ran there). Until the placement is settled, and while no node's placement +// back into the agreement before the module ran there). Until the assignments is settled, and while no node's assignments // is held (before the assigner's adopt writes the first one, or where the state cannot be read), every entry is passed // on. Builders that run and report different versions still disagree: that refusal is the guard of novox/hq ADR 0465, // and it stays. @@ -38,16 +40,19 @@ type ToolchainGate struct { modules map[string]bool // the build modules whose toolchains pass the gate forward func(module, key string, value []byte, deleted bool) bool // answers whether it changed what it holds logf func(format string, a ...any) - placed *delivery.Placed - known bool // the placement is settled + placed *meshmoduleassignerclient.Placed + known bool // the assignments is settled raw map[string]map[string][]byte // module -> toolchains key -> value, as the bucket holds it left map[string]string // / -> why it was said left out, so each reason is said once + // OnPlaced is told, outside the gate's lock, once the assignments's history is read and after each revision of it + // FollowAssignments hears from then on (the counted nodes, Nodes, may have changed). Nil tells nobody. + OnPlaced func() } // NewToolchainGate is a gate over the toolchains of modules, passing what stands on to forward and saying with logf what // it leaves out. func NewToolchainGate(modules []string, forward func(module, key string, value []byte, deleted bool) bool, logf func(string, ...any)) *ToolchainGate { - g := &ToolchainGate{modules: map[string]bool{}, forward: forward, logf: logf, placed: delivery.NewPlaced(), + g := &ToolchainGate{modules: map[string]bool{}, forward: forward, logf: logf, placed: meshmoduleassignerclient.NewPlaced(), raw: map[string]map[string][]byte{}, left: map[string]string{}} for _, m := range modules { g.modules[m] = true @@ -70,28 +75,41 @@ func (g *ToolchainGate) Toolchain(module, key string, value []byte, deleted bool return g.pass(module, key) } -// Placement takes one revision of the seat mesh-placement's placement in (at is when the bus wrote it) and answers whether what it +// Assignments takes one revision of the seat mesh-module-assigner's assignments in (at is when the bus wrote it) and answers whether what it // passed on changed. A value that does not read is said, and places nothing on its node. -func (g *ToolchainGate) Placement(key string, value []byte, deleted bool, at time.Time) bool { +func (g *ToolchainGate) Assignments(key string, value []byte, deleted bool, at time.Time) bool { g.mu.Lock() defer g.mu.Unlock() was := g.placed.Seeded() node, changed, err := g.placed.Put(key, value, deleted, at) if err != nil { - g.logf("%s: %v (issue 1940)", delivery.PlacementBucket, err) + g.logf("%s: %v (issue 1940)", AssignmentsBucket, err) } if !g.known { return false } if was != g.placed.Seeded() { - // The first placement came, or the last went: every report's standing changed. + // The first assignments came, or the last went: every report's standing changed. return g.all() } return g.again(node, changed) } -// Settled says the placement's history is read: from now on only the reports of placed nodes pass, once a node's -// placement is held. It answers whether what it passed on changed. +// Nodes are the nodes the assignments places any module on, sorted: the nodes the mesh counts (a node leaves the count +// when the assignments no longer names it; ADR 0624 §1). None until the assignments is read. +func (g *ToolchainGate) Nodes() []string { + var out []string + for node, modules := range g.placed.ByNode() { + if len(modules) > 0 { + out = append(out, node) + } + } + sort.Strings(out) + return out +} + +// Settled says the assignments's history is read: from now on only the reports of placed nodes pass, once a node's +// assignments is held. It answers whether what it passed on changed. func (g *ToolchainGate) Settled() bool { g.mu.Lock() defer g.mu.Unlock() @@ -118,7 +136,7 @@ func (g *ToolchainGate) again(node string, modules []string) bool { continue } for _, k := range slices.Sorted(maps.Keys(g.raw[module])) { - if n, _, err := delivery.ParseToolchainKey(k); err == nil && n == node { + if n, _, err := buildstep.ParseToolchainKey(k); err == nil && n == node { changed = g.pass(module, k) || changed } } @@ -126,14 +144,14 @@ func (g *ToolchainGate) again(node string, modules []string) bool { return changed } -// pass hands one key on as it stands: its value when its node is placed and wrote it since (or the placement is not +// pass hands one key on as it stands: its value when its node is placed and wrote it since (or the assignments is not // known), deleted otherwise. The caller holds mu. func (g *ToolchainGate) pass(module, key string) bool { value, held := g.raw[module][key] if !held { return g.forward(module, key, nil, true) } - node, _, err := delivery.ParseToolchainKey(key) + node, _, err := buildstep.ParseToolchainKey(key) why := "" if err == nil && g.known && g.placed.Seeded() { why = g.leftOut(module, node, value) @@ -144,7 +162,7 @@ func (g *ToolchainGate) pass(module, key string) bool { } if g.left[module+"/"+key] != why { g.left[module+"/"+key] = why - g.logf("%s.%s is left out of the agreed toolchain: %s (issue 1940)", delivery.ToolchainsBucket(module), key, why) + g.logf("%s.%s is left out of the agreed toolchain: %s (issue 1940)", ToolchainsBucket(module), key, why) } return g.forward(module, key, nil, true) } @@ -157,7 +175,7 @@ func (g *ToolchainGate) leftOut(module, node string, value []byte) string { if !placed { return fmt.Sprintf("the catalogue does not place %s on %s, so it is no builder", module, node) } - tc, err := delivery.DecodeToolchain(value) + tc, err := buildstep.DecodeToolchain(value) if err == nil && tc.At.Before(since) { return fmt.Sprintf("it was written at %s, before %s was placed on %s at %s: it counts once %s writes it again", tc.At.UTC().Format(time.RFC3339), module, node, since.UTC().Format(time.RFC3339), node) @@ -165,24 +183,24 @@ func (g *ToolchainGate) leftOut(module, node string, value []byte) string { return "" } -// FollowPlacement reads the seat mesh-placement's placement with its history into gate, says it settled, and follows it; changed +// FollowAssignments reads the seat mesh-module-assigner's assignments with its history into gate, says it settled, and follows it; changed // is told whenever what the gate passes on changed. Where the state cannot be read or watched (not there, or this part // may not read it) it says so and the gate passes every report on: not fatal, since an indexer, builder or checker that // stops for it stops every delivery (issue 1945). An error of the watch after that ends on the channel. -func FollowPlacement(ctx context.Context, js process.BucketOpener, gate *ToolchainGate, changed func(context.Context), logf func(string, ...any)) (<-chan error, error) { +func FollowAssignments(ctx context.Context, js process.BucketOpener, gate *ToolchainGate, changed func(context.Context), logf func(string, ...any)) (<-chan error, error) { errs := make(chan error, 1) - kv, err := js.KeyValue(ctx, delivery.PlacementBucket) + kv, err := js.KeyValue(ctx, AssignmentsBucket) if err != nil { - logf("the state %s cannot be read, so every node's toolchain report counts, a build node's the catalogue does not place too (issue 1940): %v", delivery.PlacementBucket, err) + logf("the state %s cannot be read, so every node's toolchain report counts, a build node's the catalogue does not place too (issue 1940): %v", AssignmentsBucket, err) return errs, nil } w, err := kv.WatchAll(ctx, jetstream.IncludeHistory()) if err != nil { - logf("the state %s cannot be watched, so every node's toolchain report counts, a build node's the catalogue does not place too (issue 1940): %v", delivery.PlacementBucket, err) + logf("the state %s cannot be watched, so every node's toolchain report counts, a build node's the catalogue does not place too (issue 1940): %v", AssignmentsBucket, err) return errs, nil } put := func(e jetstream.KeyValueEntry) bool { - return gate.Placement(e.Key(), e.Value(), e.Operation() != jetstream.KeyValuePut, e.Created()) + return gate.Assignments(e.Key(), e.Value(), e.Operation() != jetstream.KeyValuePut, e.Created()) } for settled := false; !settled; { select { @@ -191,7 +209,7 @@ func FollowPlacement(ctx context.Context, js process.BucketOpener, gate *Toolcha return errs, ctx.Err() case e, ok := <-w.Updates(): if !ok { - return errs, errors.New("the watch of " + delivery.PlacementBucket + " ended before it settled") + return errs, errors.New("the watch of " + AssignmentsBucket + " ended before it settled") } if e == nil { settled = true @@ -203,6 +221,9 @@ func FollowPlacement(ctx context.Context, js process.BucketOpener, gate *Toolcha if gate.Settled() { changed(ctx) } + if gate.OnPlaced != nil { + gate.OnPlaced() + } go func() { defer func() { _ = w.Stop() }() for { @@ -212,12 +233,15 @@ func FollowPlacement(ctx context.Context, js process.BucketOpener, gate *Toolcha return case e, ok := <-w.Updates(): if !ok { - errs <- errors.New("the watch of " + delivery.PlacementBucket + " ended") + errs <- errors.New("the watch of " + AssignmentsBucket + " ended") return } if e != nil && put(e) { changed(ctx) } + if e != nil && gate.OnPlaced != nil { + gate.OnPlaced() + } } } }() diff --git a/modules/mesh-delivery-builder/buildrun/standing_test.go b/modules/mesh-delivery-builder/buildrun/standing_test.go index 6a55ef4b..506d0da2 100644 --- a/modules/mesh-delivery-builder/buildrun/standing_test.go +++ b/modules/mesh-delivery-builder/buildrun/standing_test.go @@ -12,60 +12,44 @@ import ( "github.com/nats-io/nats.go/jetstream" - "git.novox.be/novox/mesh-sdk/go/delivery" + "git.novox.be/novox/mesh-contracts/go/clients/meshmoduleassignerclient" + "git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner" + "git.novox.be/novox/mesh-delivery/sdk/buildstep" ) func gateReport(t *testing.T, node, version string, at time.Time) (string, []byte) { t.Helper() - key, err := delivery.ToolchainKey(node, "typescript") - if err != nil { - t.Fatal(err) - } - value, err := delivery.EncodeToolchain(delivery.Toolchain{Language: "typescript", Version: version, Recipe: "f59571cf4be75adf", At: at}) + key, _ := buildstep.ToolchainKey(node, "typescript") + value, err := buildstep.EncodeToolchain(buildstep.Toolchain{Language: "typescript", Version: version, Recipe: "f59571cf4be75adf", At: at}) if err != nil { t.Fatal(err) } return key, value } -// placing is a value of the state `placement` placing modules on node. +// placing is a value of the state `assignments` placing modules on node. func placing(t *testing.T, node string, modules ...string) (string, []byte) { t.Helper() - p := delivery.Placement{Node: node} + p := meshmoduleassigner.Assignments{Node: node} for _, m := range modules { - p.Modules = append(p.Modules, delivery.PlacedModule{Module: m}) + p.Modules = append(p.Modules, meshmoduleassigner.PlacedModule{Module: m}) } - values, err := delivery.PlacementValues(p) + values, err := meshmoduleassignerclient.AssignmentsValues(p) if err != nil { t.Fatal(err) } - key, _ := delivery.PlacementNodeKey(node) + key, _ := meshmoduleassignerclient.NodeKey(node) return key, values[key] } -// placingLegacy is placing in the form before, `placement.` (research 072 row 37). -func placingLegacy(t *testing.T, node string, modules ...string) (string, []byte) { - t.Helper() - p := delivery.Placement{Node: node} - for _, m := range modules { - p.Modules = append(p.Modules, delivery.PlacedModule{Module: m}) - } - key, _ := delivery.LegacyPlacementKey(node) - value, err := delivery.EncodePlacement(p) - if err != nil { - t.Fatal(err) - } - return key, value -} - func agreedOf(set *ToolchainSet) (string, string) { - tc, none := delivery.AgreedToolchain(set.Entries("typescript")) + tc, none := buildstep.AgreedToolchain(set.Entries("typescript")) return tc.Version, none } -// Issue 1940, the gate over the catalogue's placement (issue 774, ADR 0549 §3): a build node's report counts only while -// the placement places its build module on its node, and only once written since; before the placement is settled, and -// while no node's placement is held, every report counts. The live cases: mesh-build-node taken off ace left +// Issue 1940, the gate over the catalogue's assignments (issue 774, ADR 0549 §3): a build node's report counts only while +// the assignments places its build module on its node, and only once written since; before the assignments is settled, and +// while no node's assignments is held, every report counts. The live cases: mesh-build-node taken off ace left // ace.typescript at v26.12.0 against v26.12.1 on the three others; put on ace again, its report of 2026-10-23 06:29 came // back before the module ran there. func TestToolchainGate(t *testing.T) { @@ -79,7 +63,7 @@ func TestToolchainGate(t *testing.T) { for _, node := range three { if seeded { key, value := placing(t, node, NodeBuildModule) - gate.Placement(key, value, false, old.Add(-time.Hour)) + gate.Assignments(key, value, false, old.Add(-time.Hour)) } key, value := gateReport(t, node, "v26.12.1", placed.Add(time.Minute)) gate.Toolchain(NodeBuildModule, key, value, false) @@ -91,11 +75,11 @@ func TestToolchainGate(t *testing.T) { gate, set, said := start(t, true) key, value := gateReport(t, "ace", "v26.12.0", old) gate.Toolchain(NodeBuildModule, key, value, false) - if a := delivery.ToolchainAgreement(set.Entries("typescript")); len(a.SittingOut) != 1 || a.SittingOut[0].Node != "ace" { // ADR 0584: the majority is agreed, ace's report counts as the one that sits out - t.Fatalf("before the placement is settled every report counts; got %+v", a) + if a := buildstep.ToolchainAgreement(set.Entries("typescript")); len(a.SittingOut) != 1 || a.SittingOut[0].Node != "ace" { // ADR 0584: the majority is agreed, ace's report counts as the one that sits out + t.Fatalf("before the assignments is settled every report counts; got %+v", a) } k, v := placing(t, "ace", GoBuildModule) // another build module on ace: no matter - gate.Placement(k, v, false, old) + gate.Assignments(k, v, false, old) if !gate.Settled() { t.Fatal("settling left ace's report out, so what was passed on changed") } @@ -110,13 +94,13 @@ func TestToolchainGate(t *testing.T) { } // Placed on ace with a report written since: it counts, and the builders really disagree. k, v = placing(t, "ace", GoBuildModule, NodeBuildModule) - gate.Placement(k, v, false, old.Add(-time.Minute)) - if a := delivery.ToolchainAgreement(set.Entries("typescript")); a.Toolchain.Version != "v26.12.1" || len(a.SittingOut) != 1 || a.SittingOut[0].Version != "v26.12.0" { + gate.Assignments(k, v, false, old.Add(-time.Minute)) + if a := buildstep.ToolchainAgreement(set.Entries("typescript")); a.Toolchain.Version != "v26.12.1" || len(a.SittingOut) != 1 || a.SittingOut[0].Version != "v26.12.0" { t.Fatalf("builders that run different versions: the majority is agreed and ace sits out; got %+v", a) } // Taken off ace again (its key deleted): its report goes again. - if !gate.Placement(k, nil, true, placed) { - t.Fatal("taking ace's placement away passed nothing on") + if !gate.Assignments(k, nil, true, placed) { + t.Fatal("taking ace's assignments away passed nothing on") } if v, none := agreedOf(set); v != "v26.12.1" || none != "" { t.Fatalf("got %q, %q", v, none) @@ -133,26 +117,26 @@ func TestToolchainGate(t *testing.T) { at time.Time // when ace wrote its report counts bool }{ - {"a report left before the placement", old, false}, - {"a report written since the placement", placed.Add(30 * time.Second), true}, + {"a report left before the assignments", old, false}, + {"a report written since the assignments", placed.Add(30 * time.Second), true}, } { t.Run(c.name, func(t *testing.T) { gate, set, said := start(t, true) k, v := placing(t, "ace") // ace's key, first heard placing nothing - gate.Placement(k, v, false, old.Add(-time.Hour)) + gate.Assignments(k, v, false, old.Add(-time.Hour)) gate.Settled() k, v = placing(t, "ace", NodeBuildModule) - if gate.Placement(k, v, false, placed) { - t.Fatal("a placement with no report passed something on") + if gate.Assignments(k, v, false, placed) { + t.Fatal("assignments with no report passed something on") } key, value := gateReport(t, "ace", "v26.12.0", c.at) if got := gate.Toolchain(NodeBuildModule, key, value, false); got != c.counts { t.Fatalf("ace's report passed on: %v, want %v", got, c.counts) } - // ace's placement written again with another module beside: its standing does not move, nothing is said again. + // ace's assignments written again with another module beside: its standing does not move, nothing is said again. k, v = placing(t, "ace", GoBuildModule, NodeBuildModule) - if gate.Placement(k, v, false, placed.Add(time.Minute)) { - t.Fatal("the same placement of mesh-build-node changed what was passed on") + if gate.Assignments(k, v, false, placed.Add(time.Minute)) { + t.Fatal("the same assignments of mesh-build-node changed what was passed on") } if c.counts { return @@ -170,47 +154,38 @@ func TestToolchainGate(t *testing.T) { }) } - t.Run("no placement held: every report counts", func(t *testing.T) { + t.Run("no assignments held: every report counts", func(t *testing.T) { gate, set, _ := start(t, false) key, value := gateReport(t, "ace", "v26.12.0", old) gate.Toolchain(NodeBuildModule, key, value, false) gate.Settled() if n := len(set.Entries("typescript")); n != 4 { - t.Fatalf("before the first placement file every report counts; got %d", n) + t.Fatalf("before the first assignments file every report counts; got %d", n) } k, v := placing(t, "g14", NodeBuildModule) - if !gate.Placement(k, v, false, old.Add(-time.Hour)) || len(set.Entries("typescript")) != 1 { - t.Fatalf("the first placement leaves every node it does not place out; got %d", len(set.Entries("typescript"))) + if !gate.Assignments(k, v, false, old.Add(-time.Hour)) || len(set.Entries("typescript")) != 1 { + t.Fatalf("the first assignments leaves every node it does not place out; got %d", len(set.Entries("typescript"))) } - if !gate.Placement(k, nil, true, placed) || len(set.Entries("typescript")) != 4 { - t.Fatal("the last placement gone: every report counts again") + if !gate.Assignments(k, nil, true, placed) || len(set.Entries("typescript")) != 4 { + t.Fatal("the last assignments gone: every report counts again") } }) t.Run("the history: a module in the first revision heard of a key stands since before it", func(t *testing.T) { - p := delivery.NewPlaced() - // ace in the form before, then converted: its module stands since before, the legacy key read no more. - k, v := placingLegacy(t, "ace", NodeBuildModule) + p := meshmoduleassignerclient.NewPlaced() + // ace's node key is the first revision heard of it: its module stands since before. + k, v := placing(t, "ace", NodeBuildModule) p.Put(k, v, false, old) - k, v = placing(t, "ace", NodeBuildModule) - if _, changed, err := p.Put(k, v, false, placed); len(changed) != 0 || err != nil { - t.Errorf("the conversion of ace's key changed %v, %v", changed, err) - } if _, changed, _ := p.Put("ace."+NodeBuildModule, []byte("{}"), false, placed); len(changed) != 0 { t.Errorf("a module key says which modules stand: %v", changed) } - if k, v := placingLegacy(t, "ace"); true { - if _, changed, _ := p.Put(k, v, false, placed); len(changed) != 0 { - t.Errorf("the legacy key is read after the node key: %v", changed) - } - } k2, v2 := placing(t, "g14") p.Put(k2, v2, false, old) k2, v2 = placing(t, "g14", NodeBuildModule) if _, changed, _ := p.Put(k2, v2, false, placed); strings.Join(changed, ",") != NodeBuildModule { t.Errorf("a module placed says changed %v", changed) } - if _, _, err := p.Put("placement.novox", []byte("not json"), false, old); err == nil { + if _, _, err := p.Put("novox.node", []byte("not json"), false, old); err == nil { t.Error("a value that does not read was not said") } if s, ok := p.Since("ace", NodeBuildModule); !ok || !s.IsZero() { @@ -224,14 +199,14 @@ func TestToolchainGate(t *testing.T) { } }) - t.Run("a placement the bus refuses is said, and the part goes on", func(t *testing.T) { + t.Run("assignments the bus refuses is said, and the part goes on", func(t *testing.T) { var logs bytes.Buffer logger := log.New(&logs, "", 0) - refused := errors.New("nats: permissions violation for publish to $JS.API.CONSUMER.CREATE.KV_mesh-delivery-assigner_placement") + refused := errors.New("nats: permissions violation for publish to $JS.API.CONSUMER.CREATE.KV_mesh-delivery-assigner_assignments") gate, _, _ := start(t, false) for _, bus := range []refusingBus{{err: refused}, {refuseOpen: true, err: refused}} { - if _, err := FollowPlacement(context.Background(), bus, gate, func(context.Context) {}, logger.Printf); err != nil { - t.Errorf("a refused placement stopped the start: %v", err) + if _, err := FollowAssignments(context.Background(), bus, gate, func(context.Context) {}, logger.Printf); err != nil { + t.Errorf("a refused assignments stopped the start: %v", err) } } if !strings.Contains(logs.String(), "cannot be watched") || !strings.Contains(logs.String(), "cannot be read") { diff --git a/modules/mesh-delivery-builder/buildrun/steps.go b/modules/mesh-delivery-builder/buildrun/steps.go index f08e0026..37e9bed8 100644 --- a/modules/mesh-delivery-builder/buildrun/steps.go +++ b/modules/mesh-delivery-builder/buildrun/steps.go @@ -6,7 +6,7 @@ import ( "fmt" "time" - "git.novox.be/novox/mesh-sdk/go/delivery" + "git.novox.be/novox/mesh-delivery/sdk/buildstep" ) // Caller calls a tool of a module on one named machine (mesh-sdk process.Conn.CallOn; ADR 0469 clause 3). @@ -18,7 +18,7 @@ type Caller interface { // (ADR 0469 clause 2) once its state is not running. It is a watch of that key, not a poll; it returns when the step // ends or ctx does. type Steps interface { - Wait(ctx context.Context, module, key string) (delivery.Step, error) + Wait(ctx context.Context, module, key string) (buildstep.Step, error) } // Runner runs a build module's long steps: the call answers at once with the step's key, and the result is read from that @@ -48,7 +48,7 @@ func (r Runner) Run(ctx context.Context, module, node, tool string, args map[str if answer.Key == "" { return nil, fmt.Errorf("%s on %s answered no step key: %s", tool, node, raw) } - if n, _, err := delivery.ParseStepKey(answer.Key); err != nil || n != node { + if n, _, err := buildstep.ParseStepKey(answer.Key); err != nil || n != node { return nil, fmt.Errorf("%s on %s answered the step key %q, which is not a key of that node", tool, node, answer.Key) } wait, cancel := context.WithTimeoutCause(ctx, r.Bound, fmt.Errorf("the step %s did not end within %s", answer.Key, r.Bound)) @@ -60,7 +60,7 @@ func (r Runner) Run(ctx context.Context, module, node, tool string, args map[str } return nil, fmt.Errorf("%s on %s: %w", tool, node, err) } - if step.State != delivery.StepPassed { + if step.State != buildstep.StepPassed { return nil, fmt.Errorf("%s on %s: the step %s %s: %s", tool, node, answer.Key, step.State, step.Reason) } return step.Result, nil diff --git a/modules/mesh-delivery-builder/buildrun/steps_test.go b/modules/mesh-delivery-builder/buildrun/steps_test.go index fe1d32e0..c94a6314 100644 --- a/modules/mesh-delivery-builder/buildrun/steps_test.go +++ b/modules/mesh-delivery-builder/buildrun/steps_test.go @@ -9,7 +9,7 @@ import ( "testing" "time" - "git.novox.be/novox/mesh-sdk/go/delivery" + "git.novox.be/novox/mesh-delivery/sdk/buildstep" ) type call struct { @@ -35,12 +35,12 @@ func (f *fakeCaller) CallOn(node, key string, body any) (json.RawMessage, error) // fakeSteps holds the steps values by `/`; Wait on a key it does not hold blocks until the context ends. type fakeSteps struct { mu sync.Mutex - steps map[string]delivery.Step + steps map[string]buildstep.Step // during, when set, is called as a wait starts and the function it answers as the wait ends. during func(key string) func() } -func (f *fakeSteps) Wait(ctx context.Context, module, key string) (delivery.Step, error) { +func (f *fakeSteps) Wait(ctx context.Context, module, key string) (buildstep.Step, error) { f.mu.Lock() s, ok := f.steps[module+"/"+key] during := f.during @@ -52,11 +52,11 @@ func (f *fakeSteps) Wait(ctx context.Context, module, key string) (delivery.Step return s, nil } <-ctx.Done() - return delivery.Step{}, context.Cause(ctx) + return buildstep.Step{}, context.Cause(ctx) } -func stepAt(node, id, state, reason string, result string) delivery.Step { - s := delivery.Step{ID: id, Tool: "build_go_fetch", Node: node, State: state, Reason: reason} +func stepAt(node, id, state, reason string, result string) buildstep.Step { + s := buildstep.Step{ID: id, Tool: "build_go_fetch", Node: node, State: state, Reason: reason} if result != "" { s.Result = json.RawMessage(result) } @@ -65,7 +65,7 @@ func stepAt(node, id, state, reason string, result string) delivery.Step { func runCallsTheToolOnTheNodeAndReadsTheStepsKey(t *testing.T) { // 1607/AC5 caller := &fakeCaller{answer: `{"step":"abc1","key":"ace.abc1","state":"running"}`} - steps := &fakeSteps{steps: map[string]delivery.Step{"mesh-build-go/ace.abc1": stepAt("ace", "abc1", delivery.StepPassed, "", `{"workspace":"w1","commit":"c"}`)}} + steps := &fakeSteps{steps: map[string]buildstep.Step{"mesh-build-go/ace.abc1": stepAt("ace", "abc1", buildstep.StepPassed, "", `{"workspace":"w1","commit":"c"}`)}} res, err := Runner{Caller: caller, Steps: steps, Bound: time.Second}.Run(context.Background(), "mesh-build-go", "ace", "build_go_fetch", map[string]any{"repository": "novox/r", "commit": "c"}) if err != nil { t.Fatal(err) @@ -80,9 +80,9 @@ func runCallsTheToolOnTheNodeAndReadsTheStepsKey(t *testing.T) { // 1607/AC5 } func runFailsOnAFailedOrRefusedStepNamingItsReason(t *testing.T) { // 1607/AC5 - for _, state := range []string{delivery.StepFailed, delivery.StepRefused} { + for _, state := range []string{buildstep.StepFailed, buildstep.StepRefused} { caller := &fakeCaller{answer: `{"step":"abc1","key":"ace.abc1","state":"running"}`} - steps := &fakeSteps{steps: map[string]delivery.Step{"mesh-build-go/ace.abc1": stepAt("ace", "abc1", state, "go is go1.26.0, the recipe builds with go1.27.1", "")}} + steps := &fakeSteps{steps: map[string]buildstep.Step{"mesh-build-go/ace.abc1": stepAt("ace", "abc1", state, "go is go1.26.0, the recipe builds with go1.27.1", "")}} _, err := Runner{Caller: caller, Steps: steps, Bound: time.Second}.Run(context.Background(), "mesh-build-go", "ace", "build_go_build", nil) if err == nil || !strings.Contains(err.Error(), "go1.27.1") || !strings.Contains(err.Error(), state) || !strings.Contains(err.Error(), "ace") { t.Errorf("%s: error = %v, want the node, the state and the reason", state, err) diff --git a/modules/mesh-delivery-builder/buildrun/toolchains.go b/modules/mesh-delivery-builder/buildrun/toolchains.go index 61704252..a37200b9 100644 --- a/modules/mesh-delivery-builder/buildrun/toolchains.go +++ b/modules/mesh-delivery-builder/buildrun/toolchains.go @@ -3,7 +3,7 @@ package buildrun import ( "sync" - "git.novox.be/novox/mesh-sdk/go/delivery" + "git.novox.be/novox/mesh-delivery/sdk/buildstep" ) // LanguageOf is the language each build module's toolchains state holds (the indexer's ToolchainModules, reversed). @@ -13,7 +13,7 @@ var LanguageOf = map[string]string{GoBuildModule: "go", NodeBuildModule: "typesc // put them in. Safe for use from the watches and from a build at once. type ToolchainSet struct { mu sync.Mutex - by map[string]map[string]delivery.Toolchain // language -> node -> entry + by map[string]map[string]buildstep.Toolchain // language -> node -> entry } // Put takes a change of a build module's toolchains bucket in: a put of a node's entry (key .) or its @@ -24,7 +24,7 @@ func (s *ToolchainSet) Put(module, key string, value []byte, deleted bool) bool if !ok { return false } - node, keyLanguage, err := delivery.ParseToolchainKey(key) + node, keyLanguage, err := buildstep.ParseToolchainKey(key) if err != nil || keyLanguage != language { return false } @@ -37,15 +37,15 @@ func (s *ToolchainSet) Put(module, key string, value []byte, deleted bool) bool delete(s.by[language], node) return true } - tc, err := delivery.DecodeToolchain(value) + tc, err := buildstep.DecodeToolchain(value) if err != nil || tc.Language != language { return false } if s.by == nil { - s.by = map[string]map[string]delivery.Toolchain{} + s.by = map[string]map[string]buildstep.Toolchain{} } if s.by[language] == nil { - s.by[language] = map[string]delivery.Toolchain{} + s.by[language] = map[string]buildstep.Toolchain{} } cur, had := s.by[language][node] s.by[language][node] = tc @@ -53,12 +53,12 @@ func (s *ToolchainSet) Put(module, key string, value []byte, deleted bool) bool } // Entries are the nodes' entries of a language, in no particular order. -func (s *ToolchainSet) Entries(language string) []delivery.NodeToolchain { +func (s *ToolchainSet) Entries(language string) []buildstep.NodeToolchain { s.mu.Lock() defer s.mu.Unlock() - var out []delivery.NodeToolchain + var out []buildstep.NodeToolchain for node, tc := range s.by[language] { - out = append(out, delivery.NodeToolchain{Node: node, Toolchain: tc}) + out = append(out, buildstep.NodeToolchain{Node: node, Toolchain: tc}) } return out } diff --git a/modules/mesh-delivery-builder/buildrun/toolchains_test.go b/modules/mesh-delivery-builder/buildrun/toolchains_test.go index 4df0d38d..72aeab92 100644 --- a/modules/mesh-delivery-builder/buildrun/toolchains_test.go +++ b/modules/mesh-delivery-builder/buildrun/toolchains_test.go @@ -4,12 +4,13 @@ import ( "testing" "time" - "git.novox.be/novox/mesh-sdk/go/delivery" + "git.novox.be/novox/mesh-delivery/sdk/buildstep" ) func tcValue(t *testing.T, language, version string) []byte { t.Helper() - b, err := delivery.EncodeToolchain(delivery.Toolchain{Language: language, Version: version, Recipe: "1", At: time.Now()}) + now := time.Now() + b, err := buildstep.EncodeToolchain(buildstep.Toolchain{Language: language, Version: version, Recipe: "1", At: now}) if err != nil { t.Fatal(err) } diff --git a/modules/mesh-delivery-builder/builds/builds.go b/modules/mesh-delivery-builder/builds/builds.go new file mode 100644 index 00000000..231e9899 --- /dev/null +++ b/modules/mesh-delivery-builder/builds/builds.go @@ -0,0 +1,349 @@ +// Package builds holds what the builder module owns and no seat carries (novox/hq ADR 0634 §1): the layout of its state +// `builds` (a build per module and fingerprint) and the rules its writer keeps, the question a registry seat's holder asks +// it before it stores a publish, its event version.numbered and the entries of its build log. Other modules import it from +// the builder by its published version (ADR 0573 §1); the builder's manifest declares it as a package artifact. +package builds + +import ( + "encoding/json" + "fmt" + "reflect" + "slices" + "strings" + + "git.novox.be/novox/mesh-delivery/contracts/go/clients/containerimageregistryclient/reference" + "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" + "git.novox.be/novox/mesh-delivery/contracts/go/seats/containerimageregistry" + "git.novox.be/novox/mesh-delivery/contracts/go/seats/ociartifactregistry" + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds/forms" + "git.novox.be/novox/mesh-delivery/sdk/buildstep" + "git.novox.be/novox/mesh-delivery/sdk/kept" + "git.novox.be/novox/mesh-delivery/sdk/trunk" + "git.novox.be/novox/mesh-sdk/go/keys" +) + +// seats are the names of the registry seats a reference names first, from their contracts. +var seats = reference.Seats{Image: containerimageregistry.Seat, Object: ociartifactregistry.Seat} + +// askLanguages are the languages a build seat is named after (node-build-), which a building key's Asks are keyed by. +var askLanguages = []string{"go", "node", "image"} + +func checkAskLanguage(language string) error { + if !slices.Contains(askLanguages, language) { + return fmt.Errorf("builds: the ask language %q is not go, node or image", language) + } + return nil +} + +// The builder's keys (novox/hq ADR 0429 clauses 5 and 16, ADR 0437 clause 5, ADR 0405 clauses 2 and 7, ADR 0453 +// clause 4): a build is `.` in the builder's bucket, one key per fingerprint, since equal +// fingerprints are equal content and the same source is never built twice. They are written only by the builder part +// from R4c and read by the checker, the assigner and the view. They are not the ended controller's build records +// (buildrecord.go: BuildKey). +// +// **The rules its writer keeps.** A builder claims a fingerprint that has no build key by creating it queued or +// building, a create that fails when the key exists, and moves it by compare-and-set; a builder reclaims only a build +// whose `by` is offline in presence. A failed key is built again as a new revision of the same key (ADR 0429 clause +// 18). Only the builder's credential writes the bucket (clause 5); every other part reads it. +const ( + // BuilderModule is the module of the builder part. + BuilderModule = "mesh-delivery-builder" + // BuilderBuildsBucket is the builder's bucket of builds: `_`. + BuilderBuildsBucket = BuilderModule + "_builds" + // BuilderBuildsHistory is the revisions the bucket keeps per key. + BuilderBuildsHistory = 5 +) + +// The states of a build. +const ( + BuildQueued = "queued" + BuildBuilding = "building" + BuildDone = "done" + BuildFailed = "failed" +) + +// BuilderBuildKey is the key, in BuilderBuildsBucket, of a module's build at a fingerprint: `.`, the hex +// being the fingerprint's key part (meshcatalogclient.KeyPart), since a bus key may not hold ':'. A module holding a dot or a +// character a key refuses, and a fingerprint not of the new form, are refused, naming them. +func BuilderBuildKey(module, fp string) (string, error) { + if err := keys.CheckName("module", module); err != nil { + return "", err + } + part, err := meshcatalogclient.KeyPart(fp) + if err != nil { + return "", err + } + return module + "." + part, nil +} + +// ParseBuilderBuildKey is the inverse of BuilderBuildKey: the module and the fingerprint (`src2:` and the hex). +func ParseBuilderBuildKey(key string) (module, fp string, err error) { + module, part, ok := strings.Cut(key, ".") + if !ok { + return "", "", fmt.Errorf("builds: %q is not a builder build key, .", key) + } + if err := keys.CheckName("module", module); err != nil { + return "", "", fmt.Errorf("builds: %q is not a builder build key: %w", key, err) + } + if fp, err = meshcatalogclient.ParseKeyPart(part); err != nil { + return "", "", fmt.Errorf("builds: %q is not a builder build key: %w", key, err) + } + return module, fp, nil +} + +// BuiltArtifact is one artifact of a done build: its name, kind, digest and the reference that names where it is, with every +// form it is named in (novox/hq ADR 0618, ADR 0624). The Delivery SDK's forms package holds the type, since the forms read it +// and forms cannot import this package. +type BuiltArtifact = forms.BuiltArtifact + +const artifactStore = "artifact-store://" + +// referenceProblem says what is wrong with a's reference for its kind, or "". +func referenceProblem(a BuiltArtifact) string { + r := a.Reference + if r == "" || a.Kind == "" { + return "" + } + // An upstream copy may be named by the holder's own naming, upstream//@, in either form: the + // holder names it, not the module that asked for it (reference.InUpstream). + if path, kept := seats.InArtifactStore(r); kept && a.Kind == "upstream" { + if _, image, ok := reference.InUpstream(path); ok { + if _, digest, _ := strings.Cut(image, "@"); digest != a.Digest { + return "does not name its digest " + a.Digest + } + return "" + } + } + if seat, path, ok := seats.InRegistrySeat(r); ok { + want := "container-image-registry" + if a.Kind == "bundle" || a.Kind == "archive" { + want = "oci-artifact-registry" + } + module, rest, _ := strings.Cut(path, "/") + if a.Kind == "package" || seat != want || module == "" || rest != a.Name+"@"+a.Digest { + return "is not " + want + "//" + a.Name + "@" + a.Digest + } + return "" + } + switch a.Kind { + case "bundle", "archive": + path, ok := strings.CutPrefix(r, artifactStore) + module, rest, _ := strings.Cut(path, "/") + if !ok || module == "" || rest != a.Name+"/blobs/"+a.Digest { + return "is not artifact-store:///" + a.Name + "/blobs/" + a.Digest + } + case "image", "upstream": + path, digest, ok := strings.Cut(r, "@") + if !ok || digest != a.Digest { + return "does not name its digest " + a.Digest + } + var host, module, name string + if rest, kept := strings.CutPrefix(path, artifactStore); kept { + module, name, _ = strings.Cut(rest, "/") + host = "kept" + } else { + parts := strings.Split(path, "/") + if len(parts) == 3 { + host, module, name = parts[0], parts[1], parts[2] + } + } + if host == "" || !(host == "kept" || host == "localhost" || strings.ContainsAny(host, ".:")) || module == "" || name != a.Name { + return "is not //" + a.Name + "@" + a.Digest + } + case "package": + at := strings.LastIndex(r, "@") + if at <= 0 || at == len(r)-1 || r[:at] == "@" { + return "is not @" + } + } + return "" +} + +// Referenced says whether b is done and every artifact names where it is, which the controller delivers from (novox/hq +// ADR 0498 clause 8), and names the artifacts that name no reference. A done key with no artifact (a module that builds +// none, issue 1613) is referenced. +func (b BuilderBuild) Referenced() (ok bool, missing []string) { + for _, a := range b.Artifacts { + if a.Reference == "" { + missing = append(missing, a.Name) + } + } + return b.State == BuildDone && len(missing) == 0, missing +} + +// The prefixes of a failed key's error that a build module answers (novox/hq ADR 0498 clauses 2, 4 and 5; ADR 0478 +// clause 2), each followed by the artifact and the words of what refused it. +const ( + // NotStored: the push of a bundle, an archive or an upstream copy to the artifact store failed; built again. + NotStored = "not stored: " + // NotPinned: an upstream artifact names no digest. + NotPinned = "not pinned: " + // VersionTaken: the package registry holds the package's version with another packed file. + VersionTaken = "version taken: " + // RefusedKind: no build module builds this kind of artifact in its language. + RefusedKind = "refused kind: " + // WaitsFor: a fetch found the module's lock naming a package version the registry does not hold yet; the builder + // writes the key queued with Waits (novox/hq ADR 0500 clause 6), and it is built once the package is published. + WaitsFor = "waits for: " +) + +// FailedUntilChanged says whether a failed key's error is one that is not built again until the module's fingerprint +// changes (not pinned, version taken, refused kind: the same source fails the same way); every other failed key, a +// push that was not stored included, is built again as a new revision (ADR 0429 clause 18). +func FailedUntilChanged(err string) bool { + return strings.HasPrefix(err, NotPinned) || strings.HasPrefix(err, VersionTaken) || strings.HasPrefix(err, RefusedKind) +} + +// ToolchainUsed is what a build used for one language (ADR 0405 clauses 2 and 7): the installed toolchain's version and +// the build module's recipe version. +type ToolchainUsed struct { + Version string `json:"version"` + Recipe string `json:"recipe"` +} + +// BuilderBuild is the value of a builder build key: its State (queued, building, done or failed), By the builder +// instance that holds it, Step what it is doing or did last, Manifest the digest of the module's manifest (the key of +// the indexer's manifests bucket), Artifacts when done, Toolchains by language when it built or is building (a done +// build names them, so two builders that differ are seen: ADR 0405 clause 2), Error when failed, and Waits when queued for a +// package version. +type BuilderBuild struct { + V int `json:"v"` + State string `json:"state"` + By string `json:"by,omitempty"` + Step string `json:"step,omitempty"` + Manifest string `json:"manifest,omitempty"` + Artifacts []BuiltArtifact `json:"artifacts,omitempty"` + Toolchains map[string]ToolchainUsed `json:"toolchains,omitempty"` + Error string `json:"error,omitempty"` + // Waits is, on a queued key only, the package version `@` the module's lock names and the registry + // does not yet hold: the key is not claimed until that package's trunk key is done and published (novox/hq ADR 0500 + // clause 6). A reader that does not know the field reads the key as queued. + Waits string `json:"waits,omitempty"` + // Asks are, on a building key, the asks the builder put for it by build seat language (AskGo, AskNode, AskImage), each + // its id (novox/hq ADR 0525 §1; issue 1887 A4b): a builder that takes the key over at its start waits for those asks' + // steps and never puts them again. A reader that does not know the field reads the key as before. + Asks map[string]string `json:"asks,omitempty"` + // Imports are, on a done key, the mesh packages the built programs import, as the build module answered them + // (BuildAnswer.Imports; novox/hq ADR 0593 §2): the builder carries them onto the build's end (BuildLogEntry.Imports). + Imports []string `json:"imports,omitempty"` + // Needs are, on a done key only, the reader capabilities (`form:`) the build's reference forms need of a + // node-engine, beside the ones its manifest needs (novox/hq ADR 0618 §1). A reader that does not know the field + // reads the build as needing none. + Needs []string `json:"needs,omitempty"` +} + +func (b BuilderBuild) check() error { + if err := trunk.CheckImports(b.Imports); err != nil { + return err + } + switch b.State { + case BuildQueued, BuildBuilding, BuildDone, BuildFailed: + default: + return fmt.Errorf("builds: the build state %q is not queued, building, done or failed", b.State) + } + if b.Manifest != "" { + if !keys.IsHex64(b.Manifest) { + return fmt.Errorf("builds: %q is not a manifest digest, 64 lowercase hex digits", b.Manifest) + } + } + for language, t := range b.Toolchains { + if t.Version == "" { + return fmt.Errorf("builds: the toolchain of %q has no version", language) + } + if t.Recipe == "" { + return fmt.Errorf("builds: the toolchain of %q has no recipe version", language) + } + } + for i, a := range b.Artifacts { + if a.Name == "" { + return fmt.Errorf("builds: artifact %d of the build has no name", i) + } + if a.Digest == "" { + return fmt.Errorf("builds: the artifact %q of the build has no digest", a.Name) + } + if p := referenceProblem(a); p != "" { + return fmt.Errorf("builds: the reference %q of the %s %q %s", a.Reference, a.Kind, a.Name, p) + } + if len(a.References) > 0 && !slices.Contains(a.References, a.Reference) { + return fmt.Errorf("builds: the references of the %s %q do not name its reference %q", a.Kind, a.Name, a.Reference) + } + for _, r := range a.References { + if p := referenceProblem(BuiltArtifact{Name: a.Name, Kind: a.Kind, Digest: a.Digest, Reference: r}); p != "" { + return fmt.Errorf("builds: the reference %q of the %s %q %s", r, a.Kind, a.Name, p) + } + } + } + for _, n := range b.Needs { + if !strings.HasPrefix(n, "form:") || len(n) == len("form:") { + return fmt.Errorf("builds: the need %q of the build is not form:", n) + } + } + if len(b.Needs) > 0 && b.State != BuildDone { + return fmt.Errorf("builds: a %s build names needs; only a done key does", b.State) + } + for language, id := range b.Asks { + if b.State != BuildBuilding { + return fmt.Errorf("builds: a %s build names the ask %s; only a building key does", b.State, id) + } + if err := checkAskLanguage(language); err != nil { + return err + } + if err := buildstep.CheckAskID(id); err != nil { + return err + } + } + if b.Waits != "" { + if b.State != BuildQueued { + return fmt.Errorf("builds: a %s build waits for %q; only a queued key waits for a package", b.State, b.Waits) + } + if p := referenceProblem(BuiltArtifact{Kind: "package", Reference: b.Waits}); p != "" { + return fmt.Errorf("builds: the package a key waits for, %q, %s", b.Waits, p) + } + } + switch b.State { + case BuildQueued, BuildBuilding: + if len(b.Artifacts) > 0 || b.Error != "" { + return fmt.Errorf("builds: a %s build carries no artifact and no error", b.State) + } + case BuildDone: + // A module that builds no artifact gets a done key too, and is delivered from it (issue 1613). + if b.Manifest == "" { + return fmt.Errorf("builds: a done build names no manifest digest") + } + if b.Error != "" { + return fmt.Errorf("builds: a done build carries an error") + } + case BuildFailed: + if b.Error == "" { + return fmt.Errorf("builds: a failed build says no error") + } + } + return nil +} + +// EncodeBuilderBuild is b as JSON at Version; it refuses a build its state does not allow (done without a manifest +// digest, failed without an error, an artifact without a name or digest, a toolchain without a version or a +// recipe version). +func EncodeBuilderBuild(b BuilderBuild) ([]byte, error) { + if err := b.check(); err != nil { + return nil, err + } + b.V = keys.Version + return json.Marshal(b) +} + +// DecodeBuilderBuild reads a builder build value; it refuses an unknown version (keys.ErrUnknownVersion) and what +// EncodeBuilderBuild refuses. +func DecodeBuilderBuild(data []byte) (BuilderBuild, error) { + var b BuilderBuild + if err := kept.Decode(data, &b); err != nil { + return BuilderBuild{}, err + } + if err := b.check(); err != nil { + return BuilderBuild{}, err + } + return b, nil +} + +// SameBuilderBuild says whether two builds are the same value, so the builder writes only when they differ. +func SameBuilderBuild(a, b BuilderBuild) bool { return reflect.DeepEqual(a, b) } diff --git a/modules/mesh-delivery-builder/builds/builds_test.go b/modules/mesh-delivery-builder/builds/builds_test.go new file mode 100644 index 00000000..8e272b67 --- /dev/null +++ b/modules/mesh-delivery-builder/builds/builds_test.go @@ -0,0 +1,223 @@ +package builds + +import ( + "encoding/json" + "errors" + "fmt" + "reflect" + "strings" + "testing" + + "git.novox.be/novox/mesh-delivery/sdk/buildrecord" + "git.novox.be/novox/mesh-sdk/go/keys" +) + +func digestOf(c byte) string { return strings.Repeat(string(c), 64) } + +func fp2of(c byte) string { return "src2:" + digestOf(c) } + +func doneBuild() BuilderBuild { + return BuilderBuild{ + State: BuildDone, + By: "builder.novox.1", + Step: "pushed", + Manifest: digestOf('4'), + Artifacts: []BuiltArtifact{ + {Name: "code", Kind: "bundle", Digest: "sha256:" + digestOf('5')}, + {Name: "server", Kind: "image", Digest: "sha256:" + digestOf('6'), Reference: "registry.example/mail/server@sha256:" + digestOf('6')}, + }, + Toolchains: map[string]ToolchainUsed{ + "go": {Version: "1.26.0", Recipe: "3"}, + "typescript": {Version: "5.9.2", Recipe: "1"}, + }, + } +} + +func builderSamples() map[string]BuilderBuild { + failed := BuilderBuild{State: BuildFailed, By: "builder.novox.1", Step: "test", Error: "go test: 2 failed"} + both := doneBuild() + both.Needs = []string{"form:artifact-registry"} + both.Artifacts[1].References = []string{both.Artifacts[1].Reference, "artifact-store://mail/server@" + both.Artifacts[1].Digest} + return map[string]BuilderBuild{ + "done with needs and both forms": both, + "queued": {State: BuildQueued, Manifest: digestOf('4')}, + "building": {State: BuildBuilding, By: "builder.novox.1", Step: "fetch", Manifest: digestOf('4')}, + "done": doneBuild(), + "failed": failed, + } +} + +// 1358/AC1: the key is ., parsing gives both back; a module with a dot, an empty one, and a fingerprint not +// of the new form are refused, naming it. +func TestBuilderBuildKey(t *testing.T) { + fp := fp2of('a') + key, err := BuilderBuildKey("mesh-mail", fp) + if err != nil || key != "mesh-mail."+strings.Repeat("a", 64) { + t.Fatalf("key = %q, %v", key, err) + } + module, back, err := ParseBuilderBuildKey(key) + if err != nil || module != "mesh-mail" || back != fp { + t.Errorf("parse = %q, %q, %v", module, back, err) + } + src1 := "src1:" + strings.Repeat("1", 64) + for module, fp := range map[string]string{"a.b": fp, "": fp, "mesh-mail": src1, "mail ": fp} { + if _, err := BuilderBuildKey(module, fp); err == nil { + t.Errorf("BuilderBuildKey(%q, %q) accepted", module, fp) + } + } + if _, err := BuilderBuildKey("a.b", fp); err == nil || !strings.Contains(err.Error(), "a.b") { + t.Errorf("a dotted module is not named: %v", err) + } + if _, err := BuilderBuildKey("mesh-mail", src1); err == nil || !strings.Contains(err.Error(), src1) { + t.Errorf("a src1 fingerprint is not named: %v", err) + } + for _, bad := range []string{"", "mesh-mail", "mesh-mail.", "mesh-mail.abc", "mesh-mail." + strings.Repeat("A", 64), ".x", "mesh-mail.build.7", "a.b." + strings.Repeat("a", 64)} { + if _, _, err := ParseBuilderBuildKey(bad); err == nil || !strings.Contains(err.Error(), "\""+bad+"\"") { + t.Errorf("ParseBuilderBuildKey(%q) = %v, want a refusal naming it", bad, err) + } + } +} + +// 1358/AC2: a build value in each state encodes and decodes unchanged. +func TestBuilderBuildRoundTrip(t *testing.T) { + for name, b := range builderSamples() { + data, err := EncodeBuilderBuild(b) + if err != nil { + t.Fatalf("%s: %v", name, err) + } + if !strings.HasPrefix(string(data), `{"v":1,`) { + t.Errorf("%s at %s", name, data) + } + got, err := DecodeBuilderBuild(data) + b.V = keys.Version + if err != nil || !SameBuilderBuild(got, b) { + t.Errorf("%s: %+v %v", name, got, err) + } + } +} + +// 1358/AC3: done without a manifest digest, failed without an error, an artifact without a digest +// and an unknown state are refused. +func TestBuilderBuildChecked(t *testing.T) { + cases := map[string]struct { + edit func(*BuilderBuild) + words []string + }{ + "done without a manifest": {func(b *BuilderBuild) { b.Manifest = "" }, []string{"manifest"}}, + "a malformed manifest": {func(b *BuilderBuild) { b.Manifest = "abc" }, []string{"abc"}}, + "an artifact without a digest": {func(b *BuilderBuild) { b.Artifacts[1].Digest = "" }, []string{"digest", "server"}}, + "an artifact without a name": {func(b *BuilderBuild) { b.Artifacts[0].Name = "" }, []string{"name"}}, + "a need that is not a form": {func(b *BuilderBuild) { b.Needs = []string{"word:x"} }, []string{"word:x", "form:"}}, + "a reference not among the references": {func(b *BuilderBuild) { + b.Artifacts[1].References = []string{"artifact-store://mail/server@" + b.Artifacts[1].Digest} + }, []string{"server", "references"}}, + "a reference of another artifact": {func(b *BuilderBuild) { + b.Artifacts[1].References = []string{b.Artifacts[1].Reference, "registry.example/mail/other@" + b.Artifacts[1].Digest} + }, []string{"server", "other"}}, + "an unknown state": {func(b *BuilderBuild) { b.State = "paused" }, []string{"paused"}}, + "a toolchain without a version": {func(b *BuilderBuild) { b.Toolchains["go"] = ToolchainUsed{Recipe: "3"} }, []string{"go", "version"}}, + "a toolchain without a recipe": {func(b *BuilderBuild) { b.Toolchains["go"] = ToolchainUsed{Version: "1.26.0"} }, []string{"go", "recipe"}}, + } + for name, c := range cases { + b := doneBuild() + c.edit(&b) + _, err := EncodeBuilderBuild(b) + if err == nil { + t.Errorf("%s accepted", name) + continue + } + for _, w := range c.words { + if !strings.Contains(err.Error(), w) { + t.Errorf("%s: error %q does not name %q", name, err, w) + } + } + } + f := builderSamples()["failed"] + f.Error = "" + if _, err := EncodeBuilderBuild(f); err == nil || !strings.Contains(err.Error(), "error") { + t.Errorf("failed without an error: %v", err) + } + // a queued or building value may not already carry an outcome + q := builderSamples()["queued"] + q.Needs = []string{"form:x"} + if _, err := EncodeBuilderBuild(q); err == nil { + t.Error("a queued build with needs accepted") + } + q.Needs = nil + q.Artifacts = doneBuild().Artifacts + if _, err := EncodeBuilderBuild(q); err == nil { + t.Error("a queued build with artifacts accepted") + } + // decode refuses what encode refuses + if _, err := DecodeBuilderBuild([]byte(`{"v":1,"state":"done"}`)); err == nil { + t.Error("decoded a done build without a manifest digest") + } +} + +// 1358/AC4: a done value names the toolchain version per language and the recipe version; two values that differ only +// there are not the same. +func TestBuilderBuildNamesItsToolchains(t *testing.T) { + a, b, c := doneBuild(), doneBuild(), doneBuild() + b.Toolchains["go"] = ToolchainUsed{Version: "1.26.1", Recipe: "3"} + c.Toolchains["go"] = ToolchainUsed{Version: "1.26.0", Recipe: "4"} + if SameBuilderBuild(a, b) || SameBuilderBuild(a, c) || SameBuilderBuild(b, c) { + t.Error("values differing only in a toolchain's version or recipe are the same") + } + if !SameBuilderBuild(a, doneBuild()) { + t.Error("equal values differ") + } + data, err := EncodeBuilderBuild(a) + if err != nil { + t.Fatal(err) + } + got, err := DecodeBuilderBuild(data) + if err != nil || !reflect.DeepEqual(got.Toolchains, a.Toolchains) { + t.Errorf("toolchains = %+v, %v", got.Toolchains, err) + } +} + +// 1358/AC5: a value of another version, or none, is refused with keys.ErrUnknownVersion. +func TestBuilderBuildVersion(t *testing.T) { + for _, in := range []string{`{"v":2,"state":"queued"}`, `{"state":"queued"}`} { + if _, err := DecodeBuilderBuild([]byte(in)); !errors.Is(err, keys.ErrUnknownVersion) { + t.Errorf("DecodeBuilderBuild(%s) = %v", in, err) + } + } + // A holder's question before it stores (ADR 0605 §4): asked at the builder's address with name, version and digest; + // an answer without recorded is an error, never a yes. + for answer, want := range map[string]string{`{"recorded":true}`: "true ", `{"recorded":false,"why":"other digest"}`: "false other digest", + `{"name":"x"}`: "error", `not json`: "error"} { + b := BuilderVersions{Ask: func(addr string, body any) (json.RawMessage, error) { + if a := body.(map[string]any); addr != BuilderVersionsAddress || a["name"] != "n" || a["version"] != "0.1.2" || a["digest"] != "sha256:d" { + t.Errorf("asked %s %v", addr, body) + } + return json.RawMessage(answer), nil + }} + ok, why, err := b.Recorded("n", "0.1.2", "sha256:d") + if got := fmt.Sprintf("%v %s", ok, why); err != nil && want != "error" || err == nil && got != want { + t.Errorf("answer %s: %s, %v; want %s", answer, got, err, want) + } + } +} + +// 1358/AC6: the builder's bucket and module do not collide with the controller's build layout (buildrecord.go), which +// this change leaves as it was. +func TestBuilderLayoutDoesNotCollideWithTheControllers(t *testing.T) { + if BuilderBuildsBucket != "mesh-delivery-builder_builds" || BuilderBuildsHistory != 5 || BuilderModule != "mesh-delivery-builder" { + t.Errorf("bucket %s history %d module %s", BuilderBuildsBucket, BuilderBuildsHistory, BuilderModule) + } + if buildrecord.BuildKey("m", "7") != "m.build.7" || buildrecord.NewestKey("m") != "m.newest" { + t.Error("the controller's layout changed") + } + // the two key forms are never read as each other + key, _ := BuilderBuildKey("m", fp2of('a')) + if key == buildrecord.BuildKey("m", "a") || key == buildrecord.NewestKey("m") { + t.Errorf("builder key %q equals a controller key", key) + } + if _, _, err := ParseBuilderBuildKey(buildrecord.BuildKey("m", digestOf('a'))); err == nil { + t.Error("a controller build key parsed as the builder's") + } + if _, _, err := ParseBuilderBuildKey(buildrecord.NewestKey("m")); err == nil { + t.Error("a controller newest key parsed as the builder's") + } +} diff --git a/modules/mesh-delivery-builder/builds/busfree_test.go b/modules/mesh-delivery-builder/builds/busfree_test.go new file mode 100644 index 00000000..6d4af86b --- /dev/null +++ b/modules/mesh-delivery-builder/builds/busfree_test.go @@ -0,0 +1,21 @@ +package builds + +import ( + "os/exec" + "strings" + "testing" +) + +// The builder's published value is read by modules that never touch the bus (mesh-host's composer tool): nothing it +// imports reaches the bus library. +func TestThePackageReachesNoBus(t *testing.T) { + out, err := exec.Command("go", "list", "-deps", ".").Output() + if err != nil { + t.Fatalf("go list: %v", err) + } + for _, p := range strings.Fields(string(out)) { + if strings.HasPrefix(p, "github.com/nats-io/") { + t.Errorf("builds depends on %s", p) + } + } +} diff --git a/modules/mesh-delivery-builder/builds/forms/forms.go b/modules/mesh-delivery-builder/builds/forms/forms.go new file mode 100644 index 00000000..bd6d7ac8 --- /dev/null +++ b/modules/mesh-delivery-builder/builds/forms/forms.go @@ -0,0 +1,289 @@ +// Package forms holds the artifact reference forms the builder writes a done build key in and the assigner holds a want +// by (novox/hq ADR 0618 §1-§2, ADR 0624): logic only those two modules of mesh-delivery share, so it is mesh-delivery's +// and not mesh-sdk's (ADR 0593 §1, ADR 0608 §5). It lives beside the build key it reads, in the builder's published +// module `builds` (whose fields BuilderBuild.Needs and BuiltArtifact.References are), and reads the registry seat's form +// through the registry clients' reference package (ADR 0634 §4). +package forms + +import ( + "slices" + "sort" + "strings" + + "git.novox.be/novox/mesh-delivery/contracts/go/clients/containerimageregistryclient/reference" + containerimageregistry "git.novox.be/novox/mesh-delivery/contracts/go/seats/containerimageregistry" + ociartifactregistry "git.novox.be/novox/mesh-delivery/contracts/go/seats/ociartifactregistry" + "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +// BuiltArtifact is one artifact of a done build, as the forms read it: its name, kind, digest and the reference that names +// where it is, with every form it is named in (References, Reference among them; novox/hq ADR 0618, ADR 0624). A reader +// that does not know References reads Reference alone. The builder's build key holds the same fields (its package +// `builds`), whose type is this one, since both read the forms. +type BuiltArtifact struct { + Name string `json:"name"` + Kind string `json:"kind,omitempty"` + Digest string `json:"digest"` + Reference string `json:"reference,omitempty"` + References []string `json:"references,omitempty"` +} + +// artifactStore opens the artifact store's references (BuiltArtifact). +const artifactStore = "artifact-store://" + +// The artifact reference forms (novox/hq ADR 0605 §9, ADR 0618 §1-§2, ADR 0624): each form a done key's reference may be +// written in, named as the reader capability `form:` a node-engine states when it reads it. The forms written +// before any engine stated what it reads are first forms: every engine reads them, so none needs a statement, and a +// node whose engine states nothing (the baseline, ADR 0614 §4) reads exactly those. A new form is added to +// Current, newest last, in the change that teaches the node-engine to read it. + +// The first forms, by name. +const ( + // FormArtifactStore: artifact-store:////blobs/, or an image's kept artifact-store://…@. + FormArtifactStore = "artifact-store" + // FormRegistry: an image's or an upstream copy's //@. + FormRegistry = "registry" + // FormPackageVersion: a package's @. + FormPackageVersion = "package-version" +) + +// FormSeatReference is the form a registry seat answers for what it published, +// //@sha256: (novox/hq ADR 0605 §9; reference.SeatReference, read back by +// reference.Seats.InRegistrySeat): a bundle or an archive through oci-artifact-registry, an image through +// container-image-registry, and an upstream copy through container-image-registry under the holder's own naming, +// container-image-registry/upstream//@sha256: (reference.InUpstream). It is read by a node-engine that states +// form:seat-reference (catalogue.ReferenceFormSeat). Not a first form: an engine stating nothing reads the artifact in the +// older artifact-store form, which it derives (Older). +const FormSeatReference = catalogue.ReferenceFormSeat + +// ArtifactForm is one reference form: its name, whether it is a first form, the artifact kinds it names, how a +// reference is told to be in it, and, for a form that names the same artifact an older form does, that older reference +// (Older, nil when it derives none): the old-form window writes it beside the answered one (ADR 0624 §1, §3). +type ArtifactForm struct { + Name string + First bool + Kinds []string + Is func(reference string) bool + Older func(reference string) string +} + +// ArtifactForms are the forms a builder knows, oldest first. +type ArtifactForms []ArtifactForm + +// Current are the forms the builder writes and the assigner knows: the three first forms (BuiltArtifact's +// reference by kind), then the registry seat's form of a bundle or an archive. +var Current = ArtifactForms{ + // The registry form precedes the artifact-store form: what a build key kept of an image is recorded without the + // registry's address (reference.Seats.Recorded), so the artifact-store form is the newest first form an image has, the one + // Kept keeps for a node that states nothing. + {Name: FormRegistry, First: true, Is: func(r string) bool { return !strings.HasPrefix(r, artifactStore) && strings.Contains(r, "@sha256:") }, + Kinds: []string{catalogue.ArtifactImage, catalogue.ArtifactUpstream}}, + {Name: FormArtifactStore, First: true, Is: func(r string) bool { return strings.HasPrefix(r, artifactStore) }, + Kinds: []string{catalogue.ArtifactBundle, catalogue.ArtifactArchive, catalogue.ArtifactImage, catalogue.ArtifactUpstream}}, + {Name: FormPackageVersion, First: true, Is: func(r string) bool { return strings.LastIndex(r, "@") > 0 && !strings.Contains(r, "@sha256:") }, + Kinds: []string{catalogue.ArtifactPackage}}, + {Name: FormSeatReference, Kinds: []string{catalogue.ArtifactBundle, catalogue.ArtifactArchive, catalogue.ArtifactImage, catalogue.ArtifactUpstream}, + Is: func(r string) bool { _, _, ok := registryForms.InRegistrySeat(r); return ok }, + // The same artifact in the artifact store's form (reference.Seats.InArtifactStore): oci-artifact-registry//@ is + // artifact-store:////blobs/; container-image-registry//@ is artifact-store:///@, and an + // upstream copy's container-image-registry/upstream//

@ is artifact-store://upstream//

@. + Older: func(r string) string { + path, _ := registryForms.InArtifactStore(r) + return reference.ArtifactStoreScheme + path + }}, +} + +// FormNeed is the reader capability a form needs of a node-engine: `form:`. +func FormNeed(form string) string { return catalogue.ReadForm + form } + +// FormOf is the name of the form reference is written in for an artifact of kind, and whether one of fs names it. +func (fs ArtifactForms) FormOf(kind, reference string) (string, bool) { + for i := len(fs) - 1; i >= 0; i-- { // the newest first: a newer form may be a narrower shape of an older one + f := fs[i] + if (kind == "" || slices.Contains(f.Kinds, kind)) && f.Is(reference) { + return f.Name, true + } + } + return "", false +} + +// Reads says whether an engine whose statement is stated reads form: a first form always (the baseline included), any +// other only when stated names `form:`. A form fs does not know is read only when stated. +func (fs ArtifactForms) Reads(stated []string, form string) bool { + for _, f := range fs { + if f.Name == form && f.First { + return true + } + } + return slices.Contains(stated, FormNeed(form)) +} + +// Needs are the reader capabilities a done build's artifacts carry (ADR 0618 §1): `form:` of every form any of +// its references, single or listed, is written in, sorted. Nil when it names none (no artifact, or no reference). +func (fs ArtifactForms) Needs(artifacts []BuiltArtifact) []string { + set := map[string]bool{} + for _, a := range artifacts { + for _, r := range append([]string{a.Reference}, a.References...) { + if r == "" { + continue + } + if form, ok := fs.FormOf(a.Kind, r); ok { + set[FormNeed(form)] = true + } + } + } + if len(set) == 0 { + return nil + } + out := make([]string, 0, len(set)) + for n := range set { + out = append(out, n) + } + sort.Strings(out) + return out +} + +// Held says whether a build's reference forms hold a node's want (ADR 0624 §2): only when the build names forms and the +// node's engine, its statement stated, reads none of them. A build that names none holds no want. +func (fs ArtifactForms) Held(needs, stated []string) bool { + if len(needs) == 0 { + return false + } + for _, n := range needs { + if form, ok := strings.CutPrefix(n, catalogue.ReadForm); ok && fs.Reads(stated, form) { + return false + } + } + return true +} + +// Writable are the forms of kind every present node's engine reads (ADR 0618 §2: a new form is written only once every +// present engine states it), oldest first, by node its statement. With no node present, the first forms only. +func (fs ArtifactForms) Writable(kind string, present map[string][]string) []string { + var out []string + for _, f := range fs { + if !slices.Contains(f.Kinds, kind) { + continue + } + every := f.First || len(present) > 0 + for _, stated := range present { + every = every && fs.Reads(stated, f.Name) + } + if every { + out = append(out, f.Name) + } + } + return out +} + +// Kept are the forms a build key names an artifact of kind in (ADR 0624 §1, §3), oldest first, so the first is the one +// the single reference holds: the newest form every present engine reads (Writable), then, for each counted node, present +// or away, whose engine reads none of those kept so far, the newest form of kind it reads. A node stating nothing reads +// the first forms. A form leaves only once no counted node reads it alone; a node leaves the count when it is retired. +func (fs ArtifactForms) Kept(kind string, present, counted map[string][]string) []string { + writable := fs.Writable(kind, present) + kept := map[string]bool{} + if len(writable) > 0 { + kept[writable[len(writable)-1]] = true + } + nodes := make([]string, 0, len(counted)) + for node := range counted { + nodes = append(nodes, node) + } + sort.Strings(nodes) + for _, node := range nodes { + stated := counted[node] + var newest string + readsKept := false + for _, f := range fs { + if !slices.Contains(f.Kinds, kind) || !fs.Reads(stated, f.Name) { + continue + } + newest = f.Name + readsKept = readsKept || kept[f.Name] + } + if !readsKept && newest != "" { + kept[newest] = true + } + } + var out []string + for _, f := range fs { + if kept[f.Name] { + out = append(out, f.Name) + } + } + return out +} + +// Choose names a built artifact by the references the build answered in the forms Kept keeps (ADR 0624 §1, §3): the +// oldest kept form in the single Reference, every kept one in References when there are more than one. A reference in a +// form Kept does not keep is left out, unless none of the answered is kept: then the artifact keeps what was answered, +// its first answered in Reference, since a key names where its artifact is (ADR 0498 clause 1). An answered reference +// in a form that derives an older one (ArtifactForm.Older) answers that older one too, so the build answers one form and +// the key keeps each a counted node reads. +func (fs ArtifactForms) Choose(a BuiltArtifact, answered []string, present, counted map[string][]string) BuiltArtifact { + kept := fs.Kept(a.Kind, present, counted) + answered = fs.withOlder(a.Kind, answered) + type ref struct { + at int + reference string + } + var in []ref + for _, r := range answered { + if r == "" { + continue + } + form, ok := fs.FormOf(a.Kind, r) + if at := slices.Index(kept, form); ok && at >= 0 { + in = append(in, ref{at, r}) + } + } + sort.SliceStable(in, func(i, j int) bool { return in[i].at < in[j].at }) + a.References = nil + switch { + case len(in) > 0: + a.Reference = in[0].reference + if len(in) > 1 { + for _, r := range in { + a.References = append(a.References, r.reference) + } + } + default: + a.Reference = "" + for _, r := range answered { + if r == "" { + continue + } + if a.Reference == "" { + a.Reference = r + } + a.References = append(a.References, r) + } + if len(a.References) < 2 { + a.References = nil + } + } + return a +} + +// withOlder is answered with, after it, each older reference an answered one's form derives that was not answered itself. +func (fs ArtifactForms) withOlder(kind string, answered []string) []string { + out := slices.Clone(answered) + for _, r := range answered { + name, ok := fs.FormOf(kind, r) + if !ok { + continue + } + for _, f := range fs { + if f.Name == name && f.Older != nil { + if older := f.Older(r); !slices.Contains(out, older) { + out = append(out, older) + } + } + } + } + return out +} + +// registryForms reads a reference of the registry seat's form, with the two registry seats' names from their contracts. +var registryForms = reference.Seats{Image: containerimageregistry.Seat, Object: ociartifactregistry.Seat} diff --git a/modules/mesh-delivery-builder/builds/go.mod b/modules/mesh-delivery-builder/builds/go.mod new file mode 100644 index 00000000..84dbe8cb --- /dev/null +++ b/modules/mesh-delivery-builder/builds/go.mod @@ -0,0 +1,14 @@ +module git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds + +go 1.26.0 + +require ( + git.novox.be/novox/mesh-delivery/contracts/go/clients/containerimageregistryclient v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-delivery/contracts/go/seats/containerimageregistry v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-delivery/contracts/go/seats/ociartifactregistry v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-delivery/sdk v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000 +) + +require git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog v0.0.0-00010101000000-000000000000 // indirect diff --git a/modules/mesh-delivery-builder/builds/log.go b/modules/mesh-delivery-builder/builds/log.go new file mode 100644 index 00000000..6bf530c5 --- /dev/null +++ b/modules/mesh-delivery-builder/builds/log.go @@ -0,0 +1,182 @@ +package builds + +import ( + "encoding/json" + "fmt" + "sort" + "time" + + "git.novox.be/novox/mesh-delivery/sdk/trunk" + "git.novox.be/novox/mesh-sdk/go/keys" +) + +// The builder's log (clause 1; the lead and the operator, 2026-10-29): the builder's own module log (ADR 0297) `build-log`, +// kept 30 days (max-age-days), one entry under the key `` when a build of a fingerprint starts and one when it +// ends, so the newest entry of a key is the module's newest start or end. The builder's tools read it through the +// runtime's log verbs; another module reads it by naming BuildLogRead in its manifest's `reads`: a bundle through the +// runtime (stdio.Log(BuildLogRead)), a process on its own credential (process.Conn.ReadLog), each by newest per key, +// from a time, or following new entries. The catalogue module asks the builder's tool BuildsSinceTool instead. +const ( + BuilderLog = "build-log" + BuildLogRead = BuilderModule + "." + BuilderLog + BuildStartedEvent = "build.started" + BuildEndedEvent = "build.ended" + // BuildSaidEvent is a build's narration between its start and its end (design 51: a build narrates its publish, + // asked, waiting on a seat, stored, refused, at least every five minutes while it waits): its module, fingerprint, + // line and why. + BuildSaidEvent = "build.said" + // BuilderLogDays is how long the log keeps its entries. + BuilderLogDays = 30 + // BuildsSinceTool is the builder's tool answering every ended build since a time, in order (clause 9): its + // argument `since` (RFC 3339), its answer BuildsSince. + BuildsSinceTool = "builder_builds_since" +) + +// BuildLogKey is the key of a module's entries in the builder's log: the module's name, refused when a log key could not +// hold it. +func BuildLogKey(module string) (string, error) { + if err := keys.CheckName("module", module); err != nil { + return "", err + } + return module, nil +} + +// BuildLogEntry is one entry of the builder's log: the module, the fingerprint, the source revision, where the module is +// built from (its repository, its directory there, the commit and the ref), the build node and the time; at the end, +// the outcome, the manifest built (its digest, read from the indexer's state `manifests` under ManifestsKey), what it was +// built against (the bases' references, as the controller's built-before said them) and each artifact made +// (BuiltArtifact, the done key's: its name, kind, digest and reference). The repository, directory and commit are what +// the indexer's PathOf, the outside-built registrations and the assigner's running commits read; the manifest, against +// and made what the catalogue module registers a build from (the lead, 2026-10-29). +type BuildLogEntry struct { + V int `json:"v"` + Event string `json:"event"` + Module string `json:"module"` + FP string `json:"fp"` + Source string `json:"source"` + Repository string `json:"repository"` + Directory string `json:"directory"` + Commit string `json:"commit"` + Ref string `json:"ref,omitempty"` + Node string `json:"node"` + At time.Time `json:"at"` + Outcome string `json:"outcome,omitempty"` + Manifest string `json:"manifest,omitempty"` + Against []string `json:"against,omitempty"` + Made []BuiltArtifact `json:"made,omitempty"` + // Imports are, at a build's end, the mesh packages (import paths) the built programs import, their tests left out, + // sorted and each once, as the build module answered them (BuildAnswer.Imports; novox/hq ADR 0593 §2): the indexer + // keeps them on the module (KeptInputs2.Imports) when the build's fingerprint is the module's. + Imports []string `json:"imports,omitempty"` + // OutOfStep are, at a build's end, the generated files its test step found out of step with what their generator + // writes, as the build module answered them (its test step's `out-of-step`): the indexer raises a finding naming them + // for a trunk build, and only a clean build of the same module clears it. A start or a narration names none. + OutOfStep []string `json:"out-of-step,omitempty"` + // Error and Step are, at a build's end, why it failed and the step it was at or did last, as the build's key holds them + // (BuilderBuild.Error, Step): a reader of the log sees a failed build's reason, never only its outcome. A start or a + // narration names neither. + Error string `json:"error,omitempty"` + Step string `json:"step,omitempty"` + // Line is a narration's words, Why what it narrates (asked, waiting, stored, refused): a build.said entry's alone. + Line string `json:"line,omitempty"` + Why string `json:"why,omitempty"` +} + +// BuildsSince is the answer of BuildsSinceTool: every ended build since a time, oldest first, and whether the log held +// more than one answer carries (then ask again from the last one's time). +type BuildsSince struct { + Since time.Time `json:"since"` + Builds []BuildLogEntry `json:"builds"` + More bool `json:"more,omitempty"` +} + +// NewestPerModule is each module's newest entry of entries (one kind, read from the last per subject or whole), by +// module: the reader's helper for the five newest and the running commits. +func NewestPerModule(entries []BuildLogEntry) map[string]BuildLogEntry { + out := map[string]BuildLogEntry{} + for _, e := range entries { + if have, ok := out[e.Module]; !ok || e.At.After(have.At) { + out[e.Module] = e + } + } + return out +} + +// EndedSince is, of entries, the ended builds at or after since, oldest first: the reader's helper for history and for +// BuildsSinceTool's answer. +func EndedSince(entries []BuildLogEntry, since time.Time) []BuildLogEntry { + var out []BuildLogEntry + for _, e := range entries { + if e.Event == BuildEndedEvent && !e.At.Before(since) { + out = append(out, e) + } + } + sort.SliceStable(out, func(i, j int) bool { return out[i].At.Before(out[j].At) }) + return out +} + +// EncodeBuildLogEntry is e as JSON at Version; it refuses an entry a reader could not place. +func EncodeBuildLogEntry(e BuildLogEntry) ([]byte, error) { + e.V = keys.Version + switch { + case e.Event == BuildSaidEvent: + switch { + case e.Module == "" || e.FP == "" || e.At.IsZero() || e.Line == "": + return nil, fmt.Errorf("builds: a build's narration names its module, fingerprint, line and time") + case e.Outcome != "" || len(e.Made) > 0 || e.Manifest != "" || len(e.Against) > 0 || len(e.Imports) > 0 || len(e.OutOfStep) > 0 || + e.Error != "" || e.Step != "": + return nil, fmt.Errorf("builds: a build's narration names no outcome, manifest, base, artifact, import, file out of step, error or step") + } + return json.Marshal(e) + case e.Event != BuildStartedEvent && e.Event != BuildEndedEvent: + return nil, fmt.Errorf("builds: a build log entry is %s, %s or %s, not %q", BuildStartedEvent, BuildSaidEvent, BuildEndedEvent, e.Event) + case e.Line != "" || e.Why != "": + return nil, fmt.Errorf("builds: a build's start or end carries no narration's line") + case e.Module == "" || e.FP == "" || e.Node == "" || e.At.IsZero() || e.Repository == "" || e.Commit == "": + return nil, fmt.Errorf("builds: a build log entry names its module, fingerprint, repository, commit, node and time") + case e.Event == BuildEndedEvent && e.Outcome == "": + return nil, fmt.Errorf("builds: a build's end names its outcome") + case e.Event == BuildStartedEvent && (e.Outcome != "" || len(e.Made) > 0 || e.Manifest != "" || len(e.Against) > 0 || len(e.Imports) > 0 || + len(e.OutOfStep) > 0 || e.Error != "" || e.Step != ""): + return nil, fmt.Errorf("builds: a build's start names no outcome, manifest, base, artifact, import, file out of step, error or step") + } + if err := trunk.CheckImports(e.Imports); err != nil { + return nil, err + } + return json.Marshal(e) +} + +// DecodeBuildLogEntry reads one entry. +func DecodeBuildLogEntry(data []byte) (BuildLogEntry, error) { + var e BuildLogEntry + if err := json.Unmarshal(data, &e); err != nil { + return BuildLogEntry{}, fmt.Errorf("builds: a build log entry is not readable: %w", err) + } + if e.V != keys.Version { + return BuildLogEntry{}, fmt.Errorf("builds: a build log entry of version %d, and this reads %d", e.V, keys.Version) + } + return e, nil +} + +// BuildDuration is a build's duration, its end less its start (clause 1): false unless both are the same module's +// build of the same fingerprint, the start first. +func BuildDuration(start, end BuildLogEntry) (time.Duration, bool) { + if start.Event != BuildStartedEvent || end.Event != BuildEndedEvent || start.Module != end.Module || start.FP != end.FP || + end.At.Before(start.At) { + return 0, false + } + return end.At.Sub(start.At), true +} + +// IsBuildEntry says whether an entry of the builder's log is a build's start or end, read from its event alone, so an entry of +// another kind (a build's narration, build.said) is passed over before its form is read. One whose event cannot be read is +// taken as a build's, so its decoding says what is wrong with it. +func IsBuildEntry(value []byte) bool { + var e struct { + Event *string `json:"event"` + } + if json.Unmarshal(value, &e) != nil || e.Event == nil { + return true + } + return *e.Event == BuildStartedEvent || *e.Event == BuildEndedEvent +} diff --git a/modules/mesh-delivery-builder/builds/log_test.go b/modules/mesh-delivery-builder/builds/log_test.go new file mode 100644 index 00000000..93de78fc --- /dev/null +++ b/modules/mesh-delivery-builder/builds/log_test.go @@ -0,0 +1,68 @@ +package builds + +import ( + "testing" + "time" +) + +func TestTheBuildersLogKeepsAStartAndAnEndAndTheirDuration(t *testing.T) { + // ADR 0571 clause 1: the builder's log, a start and an end, and the duration between them. + at := time.Date(2026, 10, 29, 22, 0, 0, 0, time.UTC) + start := BuildLogEntry{Event: BuildStartedEvent, Module: "letta", FP: "src2:a", Source: "abc", Repository: "novox/mesh-catalog", + Directory: "modules/letta", Commit: "abc123", Node: "ace", At: at} + end := start + end.Event, end.At, end.Outcome, end.Manifest, end.Against = BuildEndedEvent, at.Add(3*time.Minute), "done", "sha256:m", []string{"artifact-store://base/image@sha256:b"} + end.Made = []BuiltArtifact{{Name: "image", Kind: "image", Digest: "sha256:1", Reference: "artifact-store://letta/image@sha256:1"}} + failed := end + failed.Outcome, failed.Made, failed.Error, failed.Step = BuildFailed, nil, "go test: TestX failed", "test" + for _, e := range []BuildLogEntry{start, end, failed} { + raw, err := EncodeBuildLogEntry(e) + back, derr := DecodeBuildLogEntry(raw) + if err != nil || derr != nil || back.Event != e.Event || back.Outcome != e.Outcome || back.Directory != "modules/letta" || + back.Repository != "novox/mesh-catalog" || back.Commit != "abc123" || back.Error != e.Error || back.Step != e.Step { + t.Errorf("%s read back as %+v, %v %v", e.Event, back, err, derr) + } + } + if d, ok := BuildDuration(start, end); !ok || d != 3*time.Minute { + t.Errorf("the build took %s, %v", d, ok) + } + older := end + older.At, older.FP = at.Add(-time.Hour), "src2:b" + other := end + other.Module = "mesh-catalog" + if n := NewestPerModule([]BuildLogEntry{older, end, other}); n["letta"].FP != "src2:a" || len(n) != 2 { + t.Errorf("each module's newest is %v", n) + } + if e := EndedSince([]BuildLogEntry{end, start, older}, at.Add(-2*time.Hour)); len(e) != 2 || e[0].FP != "src2:b" { + t.Errorf("the ended builds since are %v", e) + } + if k, err := BuildLogKey("letta"); k != "letta" || err != nil || BuildLogRead != "mesh-delivery-builder.build-log" { + t.Errorf("the key is %q, %v", k, err) + } + if _, err := BuildLogKey("a.b"); err == nil { + t.Error("a module with a dot was taken as a key") + } + for _, edit := range []func(*BuildLogEntry){func(e *BuildLogEntry) { e.Outcome = "" }, func(e *BuildLogEntry) { e.Commit = "" }, + func(e *BuildLogEntry) { e.Event, e.Outcome, e.Against = BuildStartedEvent, "", nil }, + func(e *BuildLogEntry) { + e.Event, e.Outcome, e.Against, e.Made, e.Manifest, e.Error = BuildStartedEvent, "", nil, nil, "", "x" + }, + func(e *BuildLogEntry) { + e.Event, e.Outcome, e.Against, e.Made, e.Manifest, e.Node, e.Line, e.Step = BuildSaidEvent, "", nil, nil, "", "", "asked", "test" + }} { + bad := end + edit(&bad) + if _, err := EncodeBuildLogEntry(bad); err == nil { + t.Errorf("%+v was encoded", bad) + } + } +} + +func TestOnlyABuildsStartOrEndIsABuildEntry(t *testing.T) { + for entry, want := range map[string]bool{`{"event":"build.started"}`: true, `{"event":"build.ended"}`: true, `{"event":"build.said"}`: false, + `not json`: true, `{}`: true} { + if got := IsBuildEntry([]byte(entry)); got != want { + t.Errorf("IsBuildEntry(%s) = %v", entry, got) + } + } +} diff --git a/modules/mesh-delivery-builder/builds/numbered.go b/modules/mesh-delivery-builder/builds/numbered.go new file mode 100644 index 00000000..0eca558a --- /dev/null +++ b/modules/mesh-delivery-builder/builds/numbered.go @@ -0,0 +1,57 @@ +package builds + +import ( + "encoding/json" + "fmt" +) + +// The builder's event version.numbered (design 51 *Events of the builder*, *The floor*): said once per version the builder +// numbers, on its own family `version` (seven days), apart from its builds' ends, so a waiter on a build's end does not +// wake on it. A build module consumes it to publish a build's version with the held digest, which stores nothing. +const ( + // VersionNumberedEvent is the event's local name. + VersionNumberedEvent = "version.numbered" + // VersionNumberedSubject is its subject, as a consumer's filter names it. + VersionNumberedSubject = "mesh.mod." + BuilderModule + ".event." + VersionNumberedEvent +) + +// VersionNumbered is the body of VersionNumberedEvent: a numbered version of a name, the build it was numbered for, and +// what the build module needs to publish it. +type VersionNumbered struct { + // Name is the numbered name, as builder_versions lists it; Kind its artifact's kind. + Name string `json:"name"` + Kind string `json:"kind"` + Version string `json:"version"` + Tag string `json:"tag,omitempty"` + // Module and Fingerprint are the build the version was numbered for; Commit its source commit. + Module string `json:"module"` + Fingerprint string `json:"fingerprint"` + Commit string `json:"commit,omitempty"` + // Digest is the built artifact's, when the build held one; Seat the registry seat it is published through. + Digest string `json:"digest,omitempty"` + Seat string `json:"seat,omitempty"` + // Head is the head the version answers, when it answers a tag ask. + Head string `json:"head,omitempty"` + // Again is when builder_publish_again said it once more, so the bus's dedup does not drop it. + Again string `json:"again,omitempty"` +} + +// EncodeVersionNumbered writes the body, refusing one that names no name, version, module or fingerprint. +func EncodeVersionNumbered(n VersionNumbered) ([]byte, error) { + if n.Name == "" || n.Version == "" || n.Module == "" || n.Fingerprint == "" { + return nil, fmt.Errorf("builds: %s names its name, version, module and fingerprint", VersionNumberedEvent) + } + return json.Marshal(n) +} + +// DecodeVersionNumbered reads the body, with the same refusal. +func DecodeVersionNumbered(data []byte) (VersionNumbered, error) { + var n VersionNumbered + if err := json.Unmarshal(data, &n); err != nil { + return VersionNumbered{}, fmt.Errorf("builds: %s is not readable: %w", VersionNumberedEvent, err) + } + if _, err := EncodeVersionNumbered(n); err != nil { + return VersionNumbered{}, err + } + return n, nil +} diff --git a/modules/mesh-delivery-builder/builds/reference_test.go b/modules/mesh-delivery-builder/builds/reference_test.go new file mode 100644 index 00000000..8032e036 --- /dev/null +++ b/modules/mesh-delivery-builder/builds/reference_test.go @@ -0,0 +1,157 @@ +package builds + +import ( + "strings" + "testing" +) + +func artifactOf(name, kind, digest, reference string) BuiltArtifact { + return BuiltArtifact{Name: name, Kind: kind, Digest: digest, Reference: reference} +} + +// A done key names where each artifact is (novox/hq ADR 0498 clauses 1, 5 and 6; the lead's decision L1 of R4e): a +// bundle or an archive by its artifact-store blob, an image or an upstream copy by the registry's reference to its +// digest (or the kept artifact-store form), a package by name and version. A reference of the wrong form for its kind, +// or naming another digest or artifact, is refused, naming the artifact. +func TestADoneKeyNamesEachArtifactsReferenceInTheFormOfItsKind(t *testing.T) { + d := "sha256:" + digestOf('6') + good := []BuiltArtifact{ + artifactOf("code", "bundle", d, "artifact-store://mail/code/blobs/"+d), + artifactOf("files", "archive", d, "artifact-store://mail/files/blobs/"+d), + artifactOf("server", "image", d, "10.0.0.1:5000/mail/server@"+d), + artifactOf("server", "image", d, "artifact-store://mail/server@"+d), + artifactOf("nats", "upstream", d, "registry.example/nats/nats@"+d), + artifactOf("lib", "package", d, "@novox/mesh-sdk@0.1.15"), + artifactOf("lib", "package", d, "left-pad@1.3.0"), + artifactOf("old", "", d, "anything at all"), + // The form a registry seat answers (ADR 0605 §9), beside the older one. + artifactOf("code", "bundle", d, "oci-artifact-registry/mail/code@"+d), + artifactOf("files", "archive", d, "oci-artifact-registry/mail/files@"+d), + artifactOf("server", "image", d, "container-image-registry/mail/server@"+d), + artifactOf("nats", "upstream", d, "container-image-registry/nats/nats@"+d), + // An upstream copy under the holder's own naming, upstream//, in either form (ADR 0624 §1). + artifactOf("app", "upstream", d, "container-image-registry/upstream/quay.io:443/org/app@"+d), + artifactOf("app", "upstream", d, "artifact-store://upstream/quay.io:443/org/app@"+d), + } + for _, a := range good { + b := doneBuild() + b.Artifacts = []BuiltArtifact{a} + if _, err := EncodeBuilderBuild(b); err != nil { + t.Errorf("%+v was refused: %v", a, err) + } + } + other := "sha256:" + digestOf('7') + bad := []BuiltArtifact{ + artifactOf("code", "bundle", d, "artifact-store://mail/code/blobs/"+other), + artifactOf("code", "bundle", d, "artifact-store://mail/other/blobs/"+d), + artifactOf("code", "bundle", d, "10.0.0.1:5000/mail/code@"+d), + artifactOf("server", "image", d, "10.0.0.1:5000/mail@"+d), + artifactOf("server", "image", d, "10.0.0.1:5000/mail/server@"+other), + artifactOf("server", "image", d, "mail/server@"+d), + artifactOf("server", "image", d, "artifact-store://mail/server/blobs/"+d), + artifactOf("lib", "package", d, "@novox/mesh-sdk"), + artifactOf("lib", "package", d, "0.1.15"), + artifactOf("code", "bundle", d, "container-image-registry/mail/code@"+d), + artifactOf("server", "image", d, "oci-artifact-registry/mail/server@"+d), + artifactOf("server", "image", d, "container-image-registry/mail/server@"+other), + artifactOf("server", "image", d, "container-image-registry/mail/other@"+d), + artifactOf("lib", "package", d, "container-image-registry/mail/lib@"+d), + artifactOf("app", "upstream", d, "container-image-registry/upstream/quay.io:443/org/app@"+other), + artifactOf("app", "upstream", d, "artifact-store://upstream/quay.io:443/org/app@"+other), + artifactOf("app", "upstream", d, "oci-artifact-registry/upstream/quay.io:443/org/app@"+d), + artifactOf("server", "image", d, "container-image-registry/upstream/quay.io:443/org/server@"+d), + } + for _, a := range bad { + b := doneBuild() + b.Artifacts = []BuiltArtifact{a} + if _, err := EncodeBuilderBuild(b); err == nil || !strings.Contains(err.Error(), `"`+a.Name+`"`) { + t.Errorf("%+v: %v, wanted a refusal naming the artifact", a, err) + } + } +} + +// The controller delivers only from a done key whose every artifact names a reference (ADR 0498 clause 8): Referenced +// says so and names the artifacts without one. +func TestReferencedNamesTheArtifactsWithoutAReference(t *testing.T) { + d := "sha256:" + digestOf('6') + b := doneBuild() + b.Artifacts = []BuiltArtifact{ + artifactOf("code", "bundle", d, "artifact-store://mail/code/blobs/"+d), + artifactOf("server", "image", d, ""), + } + if ok, missing := b.Referenced(); ok || len(missing) != 1 || missing[0] != "server" { + t.Fatalf("Referenced = %v, %v; want false, [server]", ok, missing) + } + b.Artifacts[1].Reference = "10.0.0.1:5000/mail/server@" + d + if ok, missing := b.Referenced(); !ok || len(missing) != 0 { + t.Fatalf("Referenced = %v, %v; want true", ok, missing) + } + if ok, _ := (BuilderBuild{State: BuildQueued}).Referenced(); ok { + t.Fatal("a queued key is referenced") + } +} + +// The errors a build module answers open with a fixed prefix (ADR 0498 clauses 2, 4 and 5; ADR 0478 clause 2), and three +// of them are not built again until the module's fingerprint changes; a failed push is. +func TestOnlyAPushFailureIsBuiltAgainBeforeTheFingerprintChanges(t *testing.T) { + for _, c := range []struct { + err string + again bool + }{ + {NotStored + "code: the store said 500", true}, + {NotPinned + "nats: upstream names no digest", false}, + {VersionTaken + "@novox/mesh-sdk@0.1.15 holds another file", false}, + {RefusedKind + "a package in go", false}, + {"go test: 2 failed", true}, + } { + if got := FailedUntilChanged(c.err); got == c.again { + t.Errorf("FailedUntilChanged(%q) = %v", c.err, got) + } + } + if NotStored != "not stored: " || NotPinned != "not pinned: " || VersionTaken != "version taken: " || RefusedKind != "refused kind: " { + t.Fatal("a prefix changed its words") + } + // A Node fetch that finds the lock naming a package version the registry does not hold fails with WaitsFor; the + // builder then writes the key queued with Waits, and the error is not one that waits for the fingerprint to change. + if WaitsFor != "waits for: " || FailedUntilChanged(WaitsFor+"@novox/mesh-sdk@0.1.15") { + t.Fatalf("WaitsFor = %q", WaitsFor) + } +} + +// A key whose module's lock names a version of the group's package not yet published waits, queued, saying which +// (ADR 0500 clause 6): an older reader reads it as queued, and only a queued key waits. +func TestAKeyWaitsQueuedForAPackageVersion(t *testing.T) { + b := BuilderBuild{State: BuildQueued, Manifest: digestOf('4'), Waits: "@novox/mesh-sdk@0.1.15"} + data, err := EncodeBuilderBuild(b) + if err != nil { + t.Fatal(err) + } + back, err := DecodeBuilderBuild(data) + if err != nil || back.Waits != b.Waits || back.State != BuildQueued { + t.Fatalf("decoded %+v, %v", back, err) + } + building := BuilderBuild{State: BuildBuilding, By: "x", Waits: "@novox/mesh-sdk@0.1.15"} + if _, err := EncodeBuilderBuild(building); err == nil { + t.Fatal("a building key that waits was accepted") + } + if _, err := EncodeBuilderBuild(BuilderBuild{State: BuildQueued, Waits: "mesh-sdk"}); err == nil { + t.Fatal("a wait naming no version was accepted") + } +} + +// A module that builds no artifact gets a done key too (issue 1613), and it is delivered: Referenced is ok for a done +// key with no artifact, since no artifact lacks a reference; a queued one with none still is not. +func TestADoneKeyWithNoArtifactIsReferenced(t *testing.T) { + b := BuilderBuild{State: BuildDone, Manifest: digestOf('4')} + data, err := EncodeBuilderBuild(b) + if err != nil { + t.Fatalf("a done key with no artifact was refused: %v", err) + } + back, err := DecodeBuilderBuild(data) + if err != nil { + t.Fatal(err) + } + if ok, missing := back.Referenced(); !ok || len(missing) != 0 { + t.Fatalf("Referenced = %v, %v; want true", ok, missing) + } +} diff --git a/modules/mesh-delivery-builder/builds/versions.go b/modules/mesh-delivery-builder/builds/versions.go new file mode 100644 index 00000000..a8abcf45 --- /dev/null +++ b/modules/mesh-delivery-builder/builds/versions.go @@ -0,0 +1,38 @@ +package builds + +import ( + "encoding/json" + "fmt" +) + +// The holder's question before it stores a publish (novox/hq ADR 0605 §4, design 51 *Publishing*): did the builder +// number this version with this digest? Every registry seat's holder asks it the same way, so the asking lives here +// once (ADR 0627 §2), beside the builder's layouts; no holder writes it again. +// +// The builder answers it today only as its module's tool builder_versions: no seat in the seat set names the builder's +// role yet. Once one does, BuilderVersionsAddress becomes that seat's verb and nothing else changes for a holder. +const BuilderVersionsAddress = BuilderModule + ".builder_versions" + +// AskFunc is the runtime's request and reply over the bus (mesh-sdk stdio.Ask), given by the holder so this package does +// not depend on the runtime. +type AskFunc func(address string, body any) (json.RawMessage, error) + +// BuilderVersions asks the builder whether it numbered a version with a digest. It answers the holder's Check: +// recorded, and why not when it is not. +type BuilderVersions struct{ Ask AskFunc } + +// Recorded asks the builder about name@version with digest. An answer without `recorded` is an error, never a yes. +func (b BuilderVersions) Recorded(name, version, digest string) (bool, string, error) { + raw, err := b.Ask(BuilderVersionsAddress, map[string]any{"name": name, "version": version, "digest": digest}) + if err != nil { + return false, "", err + } + var a struct { + Recorded *bool `json:"recorded"` + Why string `json:"why"` + } + if err := json.Unmarshal(raw, &a); err != nil || a.Recorded == nil { + return false, "", fmt.Errorf("builder_versions answered no recorded: %s", raw) + } + return *a.Recorded, a.Why, nil +} diff --git a/modules/mesh-delivery-builder/cmd/mesh-delivery-builder-tools/main.go b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder-tools/main.go index 56516c70..cc24d6ec 100644 --- a/modules/mesh-delivery-builder/cmd/mesh-delivery-builder-tools/main.go +++ b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder-tools/main.go @@ -14,21 +14,120 @@ import ( "sync" "time" + "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshhandactsclient" "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun" + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds" + "git.novox.be/novox/mesh-delivery/sdk/buildstep" stdio "git.novox.be/novox/mesh-sdk/go" - "git.novox.be/novox/mesh-sdk/go/delivery" + "git.novox.be/novox/mesh-sdk/go/views" + + "git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient/enginereads" "mesh-delivery-builder/internal/build" + "mesh-delivery-builder/internal/versions" ) +// EngineReadsTool lists, asks again and drops what each counted node's engine reads, as the builder keeps it. +const EngineReadsTool = "builder_engine_reads" + // queueAsker writes a queues value and waits for the builder's answer to it. type queueAsker func(key string, value []byte) (json.RawMessage, error) // Tools are the builder's tools: stuck reads, askAgain writes the reasks value, queue writes a queues value and waits for // the builder's read, buildAgain writes a reasks value of mode build and waits for the builder's answer. -func Tools(r build.StuckReader, askAgain func(key string, value []byte) error, queue, buildAgain queueAsker, since build.LogSince, now func() time.Time) []stdio.Tool { +// tags writes a tag-asks value and waits for the builder's answer; ta reads tag-asks. +func Tools(r build.StuckReader, askAgain func(key string, value []byte) error, queue, buildAgain queueAsker, since build.LogSince, now func() time.Time, vs versions.Reader, + tags queueAsker, ta, fw, of versions.Reader) []stdio.Tool { str := func(args map[string]any, k string) string { s, _ := args[k].(string); return strings.TrimSpace(s) } + // handAct writes a reasks value of a versions hand act and answers the builder's answer, or its refusal as an error. + handAct := func(key string, value []byte, err error) (any, error) { + if err != nil { + return nil, err + } + raw, err := buildAgain(key, value) + if err != nil { + return nil, err + } + a, err := build.DecodeReAsk(raw) + if err != nil { + return nil, err + } + if a.Refused { + return nil, errors.New(a.Answer) + } + return map[string]any{"key": key, "name": a.Name, "do": a.Do, "version": a.Version, "answer": a.Answer}, nil + } + tagAct := func(key string, value []byte, err error) (any, error) { + if err != nil { + return nil, err + } + raw, err := tags(key, value) + if err != nil { + return nil, err + } + return versions.DecodeTagAsk(raw) + } + nameFP := map[string]any{"type": "object", "required": []string{"name", "fp", "why"}, "properties": map[string]any{ + "name": map[string]any{"type": "string", "description": "the numbered name, /, as builder_versions lists it"}, + "fp": map[string]any{"type": "string", "description": "the fingerprint, src2:"}, + "why": map[string]any{"type": "string", "description": "why, in plain words"}}} + branch := func(head bool) map[string]any { + req := []string{"repository", "branch", "why"} + props := map[string]any{ + "repository": map[string]any{"type": "string", "description": "/"}, + "branch": map[string]any{"type": "string", "description": "the branch; its cleaned name is the tag"}, + "why": map[string]any{"type": "string", "description": "why, in plain words"}} + if head { + req = append(req, "head") + props["head"] = map[string]any{"type": "string", "description": "the commit the tagged version is built from"} + } + return map[string]any{"type": "object", "required": req, "properties": props} + } return []stdio.Tool{ + {Name: versions.PublishAgainTool, Description: versions.PublishAgainDescription, Input: nameFP, + Run: func(args map[string]any) (any, error) { + return handAct(build.NewPublishAgain(str(args, "name"), str(args, "fp"), str(args, "why"), stdio.CallerOf(args), now())) + }}, + {Name: versions.FloorAgainTool, Description: versions.FloorAgainDescription, + Input: map[string]any{"type": "object", "required": []string{"name", "why"}, "properties": map[string]any{ + "name": map[string]any{"type": "string", "description": "the numbered name, /"}, + "why": map[string]any{"type": "string", "description": "why, in plain words"}}}, + Run: func(args map[string]any) (any, error) { + return handAct(build.NewFloorAgain(str(args, "name"), str(args, "why"), stdio.CallerOf(args), now())) + }}, + {Name: versions.TagTool, Description: versions.TagDescription, Input: branch(true), + Run: func(args map[string]any) (any, error) { + return tagAct(versions.NewTagAsk(str(args, "repository"), str(args, "branch"), str(args, "head"), str(args, "why"), stdio.CallerOf(args), now())) + }}, + {Name: versions.UntagTool, Description: versions.UntagDescription, Input: branch(false), + Run: func(args map[string]any) (any, error) { + return tagAct(versions.NewUntag(str(args, "repository"), str(args, "branch"), str(args, "why"), stdio.CallerOf(args), now())) + }}, + {Name: versions.TagAsksTool, Description: versions.TagAsksDescription, + Input: map[string]any{"type": "object", "properties": map[string]any{ + "repository": map[string]any{"type": "string", "description": "only this repository's asks, /"}}}, + Run: func(args map[string]any) (any, error) { return versions.AnswerTagAsks(ta, str(args, "repository")) }}, + {Name: versions.OldFormTool, Description: versions.OldFormDescription, + Input: map[string]any{"type": "object", "properties": map[string]any{ + "drop": map[string]any{"type": "string", "description": "a retired node to drop from the count by hand"}, + "why": map[string]any{"type": "string", "description": "with drop: why, in plain words"}}}, + Run: func(args map[string]any) (any, error) { + if node := str(args, "drop"); node != "" { + return handAct(build.NewOldFormDrop(node, str(args, "why"), stdio.CallerOf(args), now())) + } + return versions.AnswerOldForm(of) + }}, + {Name: versions.FloorWaitsTool, Description: versions.FloorWaitsDescription, + Input: map[string]any{"type": "object", "properties": map[string]any{}}, + Run: func(map[string]any) (any, error) { return versions.AnswerFloorWaits(fw) }}, + {Name: versions.VersionsTool, Description: versions.VersionsDescription, + Input: map[string]any{"type": "object", "required": []string{"name"}, "properties": map[string]any{ + "name": map[string]any{"type": "string", "description": "the package, image, bundle or archive name"}, + "version": map[string]any{"type": "string", "description": "one version, to ask whether it is recorded"}, + "digest": map[string]any{"type": "string", "description": "with version: the digest the holder received"}}}, + Run: func(args map[string]any) (any, error) { + return versions.AnswerVersions(context.Background(), vs, str(args, "name"), str(args, "version"), str(args, "digest")) + }}, {Name: build.StuckTool, Description: "Every build key that waits (building or queued), oldest first: its module, fingerprint, step words and since when, and each ask it " + "waits on by language with its id, when it was put, its asking (1 for the first; the builder asks an ask again when it can no longer be " + @@ -94,10 +193,11 @@ func Tools(r build.StuckReader, askAgain func(key string, value []byte) error, q return map[string]any{"key": key, "revision": r.Revision, "asks": r.Asks, "answer": r.Answer, "next": build.StuckTool + " shows the key and its asks while they wait"}, nil }}, - {Name: delivery.BuildsSinceTool, + {Name: builds.BuildsSinceTool, Description: "Every build that ended since a time, oldest first, from the builder's log build-log (kept 30 days): per build its module, " + "fingerprint, source revision, repository, directory, commit and ref, the build node, when it ended, its outcome (done, failed or " + - "queued), the manifest built, the bases it was built against and each artifact made with its digest and reference. more says the " + + "queued), the manifest built, the bases it was built against and each artifact made with its digest and reference; a failed " + + "build carries its error and the step it was at. more says the " + "log held more than one answer carries: ask again from the last build's time. What the operator's command nox builds reads. Read-only.", Input: map[string]any{"type": "object", "required": []string{"since"}, "properties": map[string]any{ "since": map[string]any{"type": "string", "description": "RFC 3339 time; the builds that ended at or after it"}}}, @@ -152,7 +252,7 @@ func Tools(r build.StuckReader, askAgain func(key string, value []byte) error, q language = build.LanguageOf(str(args, "seat")) } if language == "" { - language = delivery.AskGo + language = buildstep.AskGo } limit, _ := args["limit"].(float64) key, value, err := build.NewQueueAsk(language, int(limit), stdio.CallerOf(args), now()) @@ -264,7 +364,65 @@ func main() { a, err := build.DecodeReAsk(v) return err == nil && !a.Answered.IsZero() }) - if err := stdio.Serve("", Tools(r, put, askQueue(stdio.State(build.QueuesState), 25*time.Second), buildAgain, stdio.Log(delivery.BuilderLog).Since, time.Now)); err != nil { + vs := stdio.State(versions.State) + vr := versions.Reader{Keys: vs.Keys, Get: func(key string) (json.RawMessage, bool, error) { + e, err := vs.Get(key) + if err != nil || e == nil { + return nil, false, err + } + return e.Value, true, nil + }} + ts := stdio.State(versions.TagAsksState) + tags := askKept(ts, versions.TagAsksState, versions.TagAsksBucket, "the ask", 25*time.Second, func(v []byte) bool { + a, err := versions.DecodeTagAsk(v) + return err == nil && !a.Answered.IsZero() + }) + tr := versions.Reader{Keys: ts.Keys, Get: func(key string) (json.RawMessage, bool, error) { + e, err := ts.Get(key) + if err != nil || e == nil { + return nil, false, err + } + return e.Value, true, nil + }} + fs := stdio.State(versions.FloorWaitsState) + fr := versions.Reader{Keys: fs.Keys, Get: func(key string) (json.RawMessage, bool, error) { + e, err := fs.Get(key) + if err != nil || e == nil { + return nil, false, err + } + return e.Value, true, nil + }} + ofs := stdio.State(versions.OldFormState) + ofr := versions.Reader{Keys: ofs.Keys, Get: func(key string) (json.RawMessage, bool, error) { + e, err := ofs.Get(key) + if err != nil || e == nil { + return nil, false, err + } + return e.Value, true, nil + }} + bs := stdio.State(versions.BoundsState) + br := versions.Reader{Keys: bs.Keys, Get: func(key string) (json.RawMessage, bool, error) { + e, err := bs.Get(key) + if err != nil || e == nil { + return nil, false, err + } + return e.Value, true, nil + }} + tools := Tools(r, put, askQueue(stdio.State(build.QueuesState), 25*time.Second), buildAgain, stdio.Log(builds.BuilderLog).Since, time.Now, vr, tags, tr, fr, ofr) + // The builder's expiring keys (state bounds): what each bounds and when it ends. + tools = append(tools, stdio.Tool{Name: versions.BoundsTool, Description: versions.BoundsDescription, + Input: map[string]any{"type": "object", "properties": map[string]any{}}, + Run: func(map[string]any) (any, error) { + rows, err := versions.AnswerBounds(br, time.Now()) + return map[string]any{"bounds": rows}, err + }}) + // What each counted node's engine reads, as the builder keeps it (ADR 0618 §2, ADR 0624 §1): listed, one node asked + // again, or one node's key dropped. + tools = append(tools, enginereads.Tool(EngineReadsTool, "the builder", enginereads.KeptState(stdio.State("engine-reads")), true, time.Now)) + // builder_first_views and builder_first_view_again: where the builder's first views of the indexer's and the assigner's + // states stand, and asking one again by hand (ADR 0615, DLV-M1). + tools = append(tools, views.StatusTools(builds.BuilderModule, "builder_first_views", "builder_first_view_again", stdio.State(views.StatusState), meshhandactsclient.Recorder{Ask: stdio.Ask}, time.Now)...) + if err := stdio.Serve("", tools); err != nil { fmt.Fprintf(os.Stderr, "[mesh-delivery-builder-tools] %v\n", err) os.Exit(1) } diff --git a/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/branchend.go b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/branchend.go new file mode 100644 index 00000000..4e2e1cd9 --- /dev/null +++ b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/branchend.go @@ -0,0 +1,174 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "strings" + "time" + + "github.com/nats-io/nats.go/jetstream" + + "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient" + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-forge-bridge/forgemsg" + + "mesh-delivery-builder/internal/build" + "mesh-delivery-builder/internal/versions" +) + +// forgeStream is the forge bridge's stream `forge` as the bus names it, where the builder's durable consumer hears a +// branch's end (design 51 *Tagged versions*). +const forgeStream = "FORGE" + +// forgeBranchDeleted is the forge bridge's kind for a branch's end (forgeBranchDeleted in the group's mesh-sdk). +const forgeBranchDeleted = "branch-deleted" + +// checkerAsksStream is the checker's family `asks` as the bus names it, and tagWantedSubject its event tag-wanted +// (design 51 *Tagged versions*, DLV-C3): on the checker's own stream, so its subject is mesh.stream..event.*. +const ( + checkerAsksStream = "ASKS" + tagWantedSubject = "mesh.stream." + checkerModule + ".event.tag-wanted" +) + +// forgeEnd is the part of a forge message the builder reads. +type forgeEnd struct { + Kind string `json:"kind"` + Repository string `json:"repository"` + Branch string `json:"branch"` + Action string `json:"action"` +} + +// ended says whether the message ends a branch: merged, a pull closed, or branch-deleted. +func (f forgeEnd) ended() bool { + switch f.Kind { + case forgemsg.ForgeMerged, forgeBranchDeleted: + return f.Branch != "" + case forgemsg.ForgePull: + return f.Action == forgemsg.PullClosed && f.Branch != "" + } + return false +} + +// branchEnds hears the forge bridge's stream: every message wakes a floor whose bridge_tags was silent, and a branch's +// end removes its tagged versions, acknowledged only once all are removed. +type branchEnds struct { + acts versions.Acts + findings *findings + tagAsks func() jetstream.KeyValue + wake func(ctx context.Context) + logf func(string, ...any) + now func() time.Time +} + +func (h *branchEnds) heard(ctx context.Context, m jetstream.Msg) { + if h.wake != nil { + h.wake(ctx) + } + var f forgeEnd + if err := json.Unmarshal(m.Data(), &f); err != nil || !f.ended() { + h.ack(m) + return + } + end, err := h.acts.BranchEnded(ctx, f.Repository, f.Branch) + if err != nil { + h.logf("the end of %s %s: the versions cannot be read, heard again: %v", f.Repository, f.Branch, err) + h.again(m) + return + } + id := f.Repository + "/" + f.Branch + if end.Done { + h.findings.clear(ctx, versions.RemovalNotDone, meshconditionsclient.ScopeModule, id) + if kv := h.tagAsks(); kv != nil { + if err := kv.Delete(ctx, versions.TagAskKey(f.Repository, f.Branch)); err != nil && !errors.Is(err, jetstream.ErrKeyNotFound) { + h.logf("the tag ask of %s %s stays in %s: %v", f.Repository, f.Branch, versions.TagAsksState, err) + } + } + h.ack(m) + return + } + since := h.now() + if md, err := m.Metadata(); err == nil { + since = md.Timestamp + } + if h.now().Sub(since) >= versions.RemovalFindingAfter { + h.findings.raise(ctx, finding{kind: versions.RemovalNotDone, scope: meshconditionsclient.ScopeModule, id: id, + summary: end.Finding(f.Repository, f.Branch, since), + headline: "A branch's tagged versions are not removed", + explanation: "The branch ended, and the registry seats did not say removed for every version tagged for it within 15 minutes.", + resolved: "Every seat said removed.", + needs: "Look at the seats named: " + strings.Join(end.Seats(), ", ")}) + } + h.logf("the end of %s %s: remove asked of %d tagged versions, heard again in a minute", f.Repository, f.Branch, len(end.Left)) + h.again(m) +} + +func (h *branchEnds) ack(m jetstream.Msg) { + if err := m.Ack(); err != nil { + h.logf("acknowledging %s: %v", m.Subject(), err) + } +} + +// again leaves the message for the bus to give again in a minute: the retry is the bus's redelivery, not a timer here. +func (h *branchEnds) again(m jetstream.Msg) { + if err := m.NakWithDelay(versions.RemoveAgainAfter); err != nil { + h.logf("leaving %s for the bus to give again: %v", m.Subject(), err) + } +} + +// tagWanted takes the checker's tag-wanted in: written to tag-asks as builder_tag writes it, answered by its watch. +func tagWanted(ctx context.Context, kv jetstream.KeyValue, m jetstream.Msg, by string, now time.Time, logf func(string, ...any)) { + if m.Subject() != tagWantedSubject { + _ = m.Ack() // the family's other events (the verdicts) are not the builder's + return + } + var t versions.TagWanted + err := json.Unmarshal(m.Data(), &t) + var key string + var value []byte + if err == nil { + key, value, err = versions.TagWantedAsk(t, by, now) + } + if err != nil { + logf("%s holds what is no tag-wanted, left: %v", m.Subject(), err) + _ = m.Ack() + return + } + if kv == nil { + logf("tag-wanted of %s %s is heard again: %s is not on the bus yet", t.Repository, t.Branch, versions.TagAsksState) + _ = m.NakWithDelay(versions.RemoveAgainAfter) + return + } + if _, err := kv.Put(ctx, key, value); err != nil { + logf("tag-wanted of %s %s is heard again: %v", t.Repository, t.Branch, err) + _ = m.Nak() + return + } + _ = m.Ack() +} + +// tagsVia is build.Builder.Tags over the state tag-asks: the asks of the repository, read when a check's build is +// looked at; none while the state is not bound. +func tagsVia(kv func() jetstream.KeyValue, logf func(string, ...any)) build.TagOf { + return func(ctx context.Context, repository, head string) (string, bool) { + store := kv() + if store == nil { + return "", false + } + lister, err := store.ListKeysFiltered(ctx, versions.EscapeToken(repository)+".>") + if err != nil { + logf("the tag asks of %s cannot be read, so its check builds no tagged version: %v", repository, err) + return "", false + } + var asks []versions.TagAsk + for key := range lister.Keys() { + e, err := store.Get(ctx, key) + if err != nil { + continue + } + if t, err := versions.DecodeTagAsk(e.Value()); err == nil { + asks = append(asks, t) + } + } + return versions.TagFor(asks, repository, head) + } +} diff --git a/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/bus.go b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/bus.go index 4023faf2..34654335 100644 --- a/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/bus.go +++ b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/bus.go @@ -7,8 +7,8 @@ import ( "github.com/nats-io/nats.go/jetstream" + "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" "git.novox.be/novox/mesh-sdk/go/catalogue" - "git.novox.be/novox/mesh-sdk/go/delivery" "mesh-delivery-builder/internal/build" ) @@ -111,7 +111,7 @@ func follow(ctx context.Context, w jetstream.KeyWatcher, each func(jetstream.Key type manifestReader struct{ kv jetstream.KeyValue } func (m manifestReader) Manifest(ctx context.Context, digest string) (catalogue.Manifest, error) { - key, err := delivery.ManifestsKey(digest) + key, err := meshcatalogclient.ManifestsKey(digest) if err != nil { return catalogue.Manifest{}, err } @@ -119,10 +119,10 @@ func (m manifestReader) Manifest(ctx context.Context, digest string) (catalogue. if err != nil { return catalogue.Manifest{}, fmt.Errorf("manifests.%s: %w", key, err) } - kept, err := delivery.DecodeManifest(e.Value()) + kept, err := meshcatalogclient.DecodeManifest(e.Value()) if err != nil { return catalogue.Manifest{}, err } // Decoded, never judged (novox/hq ADR 0578 §3): what a build needs of it is read, a field it does not know set aside. - return catalogue.Decode([]byte(kept.JSON)) + return catalogue.Decode([]byte(kept.Manifest)) } diff --git a/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/findings.go b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/findings.go new file mode 100644 index 00000000..10b6bac1 --- /dev/null +++ b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/findings.go @@ -0,0 +1,185 @@ +package main + +import ( + "context" + "errors" + "time" + + "github.com/nats-io/nats.go/jetstream" + + "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient" + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds" + "git.novox.be/novox/mesh-sdk/go/process" + + "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient/heard" + + "mesh-delivery-builder/internal/versions" +) + +// findingsBucket is the builder's findings (novox/hq ADR 0564 §2): what it sees wrong now, written at its raise and +// deleted at its clear, which the condition keeper raises and clears. +var findingsBucket = meshconditionsclient.FindingsBucket(builds.BuilderModule) + +// findings writes the builder's findings into its state, once bound; until then each is said in the log only. +type findings struct { + kv *process.AwaitedBucket + logf func(string, ...any) + now func() time.Time + // store stands in for kv in a test. + store findingStore +} + +// findingStore is the part of the findings bucket written. +type findingStore interface { + Get(ctx context.Context, key string) (jetstream.KeyValueEntry, error) + Put(ctx context.Context, key string, value []byte) (uint64, error) + Delete(ctx context.Context, key string, opts ...jetstream.KVDeleteOpt) error +} + +// finding is one of the builder's findings: its kind, what it is about, and its words. +type finding struct { + kind, scope, id string + summary, headline, explanation, resolved, needs string + severity string +} + +func (f *findings) bucket() findingStore { + switch { + case f == nil: + return nil + case f.store != nil: + return f.store + case f.kv == nil: + return nil + } + if kv := f.kv.KV(); kv != nil { + return kv + } + return nil +} + +// BuildFailed is the finding of a failed trunk build (DLV-B7, design 25), about its module. +const BuildFailed = "build-failed" + +// buildEnded raises the module's build-failed for a trunk build that failed and clears it at a later done one; a +// candidate's build (its ref named) is the check's to say. +func (f *findings) buildEnded(ctx context.Context, e builds.BuildLogEntry) { + if e.Event != builds.BuildEndedEvent || e.Ref != "" { + return + } + switch e.Outcome { + case builds.BuildFailed: + f.raise(ctx, finding{kind: BuildFailed, scope: meshconditionsclient.ScopeModule, id: e.Module, + summary: "the trunk build of " + e.Module + " at " + e.FP + " (commit " + e.Commit + ") failed", + headline: "A module's trunk build failed", + explanation: "The builder built " + e.Module + " from the trunk and the build failed, so its new version is not delivered.", + resolved: "A later trunk build of " + e.Module + " is done.", + needs: "builder_builds_since shows the build; builder_build_again builds it again."}) + case builds.BuildDone: + f.clear(ctx, BuildFailed, meshconditionsclient.ScopeModule, e.Module) + } +} + +// raise writes fd; it is written again only when its words changed, so the keeper's condition moves only then. +func (f *findings) raise(ctx context.Context, fd finding) { + o := meshconditionsclient.Observation{Scope: fd.scope, ID: fd.id, Kind: fd.kind, Severity: fd.severity, + Summary: fd.summary, Headline: fd.headline, Explanation: fd.explanation, Resolved: fd.resolved, Needs: fd.needs, + Source: "builder." + fd.kind} + kv := f.bucket() + if kv == nil { + f.logf("finding %s (not written, %s is not on the bus yet): %s", o.Key(), findingsBucket, fd.summary) + return + } + if _, err := meshconditionsclient.Raise(findingBucket{ctx, kv}, builds.BuilderModule, "", o, f.now()); err != nil { + f.logf("the finding %s cannot be written: %v", o.Key(), err) + } +} + +// clear deletes the finding of kind about scope and id; one not there is nothing to clear. +func (f *findings) clear(ctx context.Context, kind, scope, id string) { + kv := f.bucket() + if kv == nil { + return + } + if _, err := meshconditionsclient.Clear(findingBucket{ctx, kv}, scope, id, kind); err != nil { + f.logf("the finding %s cannot be cleared: %v", meshconditionsclient.Key(scope, id, kind), err) + } +} + +// findingBucket is the builder's findings store as the bucket the conditions client raises and clears in. +type findingBucket struct { + ctx context.Context + kv findingStore +} + +func (b findingBucket) Get(key string) ([]byte, bool, error) { + e, err := b.kv.Get(b.ctx, key) + switch { + case errors.Is(err, jetstream.ErrKeyNotFound): + return nil, false, nil + case err != nil: + return nil, false, err + } + return e.Value(), true, nil +} + +func (b findingBucket) Put(key string, value []byte) error { + _, err := b.kv.Put(b.ctx, key, value) + return err +} + +func (b findingBucket) Delete(key string) error { + if err := b.kv.Delete(b.ctx, key); err != nil && !errors.Is(err, jetstream.ErrKeyNotFound) { + return err + } + return nil +} + +// notHeard is the writer of the builder's finding of a family it does not hear (heard.Writer, ADR 0615 §5): written into +// its findings once bound, said in the log until then. +func (f *findings) notHeard(ctx context.Context) heard.Writer { + on := func(do func(findingStore)) { + switch { + case f.store != nil: + do(f.store) + case f.kv != nil: + f.kv.Ready(func(kv jetstream.KeyValue) { do(kv) }) + } + } + return heard.Writer{Observer: builds.BuilderModule, Logf: f.logf, Now: f.now, + Put: func(key string, value []byte) error { + on(func(kv findingStore) { + if _, err := kv.Put(ctx, key, value); err != nil { + f.logf("the finding %s cannot be written: %v", key, err) + } + }) + return nil + }, + Delete: func(key string) error { + on(func(kv findingStore) { + if _, err := kv.Get(ctx, key); err != nil { + return // none stands + } + if err := kv.Delete(ctx, key); err != nil { + f.logf("the finding %s cannot be cleared: %v", key, err) + } + }) + return nil + }} +} + +// floorSaid is the floor finding (design 51 *Health and conditions*: a floor source silent): raised for a name waiting on +// its floor, about that name, cleared once all three sources answered. +func (f *findings) floorSaid(ctx context.Context) func(versions.FloorWait, bool) { + return func(w versions.FloorWait, raised bool) { + if !raised { + f.clear(ctx, versions.FloorSilent, meshconditionsclient.ScopeModule, w.Name) + return + } + f.raise(ctx, finding{kind: versions.FloorSilent, scope: meshconditionsclient.ScopeModule, id: w.Name, summary: w.Finding(), + headline: "A version's floor is not known", + explanation: "The builder numbers a name only once its registry seat, the forge bridge's tags and the indexer's requirements all answered; one of them did not.", + resolved: "All three answered.", + needs: "builder_floor_waits names the silent source; builder_floor_again asks again by hand."}) + } +} diff --git a/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/gone_integration_test.go b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/gone_integration_test.go index 58395d16..ec0783ec 100644 --- a/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/gone_integration_test.go +++ b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/gone_integration_test.go @@ -12,8 +12,8 @@ import ( "github.com/nats-io/nats.go" "github.com/nats-io/nats.go/jetstream" - "git.novox.be/novox/mesh-sdk/go/catalogue" - "git.novox.be/novox/mesh-sdk/go/delivery" + sweep "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshartifactsweep" + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds" ) // A done key whose blob the artifact store no longer holds is built again, by the builder whole against the bus @@ -30,12 +30,12 @@ type heldVerb struct { func (h *heldVerb) serve(t *testing.T, b *bed) { t.Helper() - sub, err := b.js.Conn().Subscribe("mesh.seat."+catalogue.ArtifactSweepSeat+".call.held.>", func(m *nats.Msg) { + sub, err := b.js.Conn().Subscribe("mesh.seat."+sweep.Seat+".call.held.>", func(m *nats.Msg) { var args struct { References []string `json:"references"` } _ = json.Unmarshal(m.Data, &args) - a := delivery.HeldAnswer{Store: "store.test:5100", Held: []string{}, Gone: []string{}} + a := sweep.HeldAnswer{Store: "store.test:5100", Held: []string{}, Gone: []string{}} h.mu.Lock() for _, r := range args.References { h.asked = append(h.asked, r) @@ -65,11 +65,11 @@ func (h *heldVerb) asks() int { // from the manifest's consumes. func eventsConsumer(t *testing.T, b *bed, node string) { t.Helper() - if _, err := b.js.CreateStream(b.ctx, jetstream.StreamConfig{Name: eventsStream, Subjects: []string{delivery.ArtifactsGoneSubject}}); err != nil { + if _, err := b.js.CreateOrUpdateStream(b.ctx, jetstream.StreamConfig{Name: eventsStream, Subjects: []string{"mesh.mod.*.event.>", "mesh.seat.*.event.>"}}); err != nil { t.Fatal(err) } if _, err := b.js.CreateOrUpdateConsumer(b.ctx, eventsStream, jetstream.ConsumerConfig{Durable: node + "_mesh-delivery-builder", - AckPolicy: jetstream.AckExplicitPolicy, FilterSubjects: []string{delivery.ArtifactsGoneSubject}}); err != nil { + AckPolicy: jetstream.AckExplicitPolicy, FilterSubjects: []string{sweep.SubjectArtifactsGone}}); err != nil { t.Fatal(err) } } @@ -77,10 +77,10 @@ func eventsConsumer(t *testing.T, b *bed, node string) { // doneKey writes builds.. done, naming one archive at reference, and answers its revision. func doneKey(t *testing.T, b *bed, module, manifest, reference, digest string) uint64 { t.Helper() - key, _ := delivery.BuilderBuildKey(module, fp) - data, err := delivery.EncodeBuilderBuild(delivery.BuilderBuild{State: delivery.BuildDone, By: "n2.mesh-delivery-builder", + key, _ := builds.BuilderBuildKey(module, fp) + data, err := builds.EncodeBuilderBuild(builds.BuilderBuild{State: builds.BuildDone, By: "n2.mesh-delivery-builder", Manifest: manifest, Step: "built mesh-build-go on n2", - Artifacts: []delivery.BuiltArtifact{{Name: "files", Kind: "archive", Digest: digest, Reference: reference}}}) + Artifacts: []builds.BuiltArtifact{{Name: "files", Kind: "archive", Digest: digest, Reference: reference}}}) if err != nil { t.Fatal(err) } @@ -93,7 +93,7 @@ func doneKey(t *testing.T, b *bed, module, manifest, reference, digest string) u func revisionOf(t *testing.T, b *bed, module string) uint64 { t.Helper() - key, _ := delivery.BuilderBuildKey(module, fp) + key, _ := builds.BuilderBuildKey(module, fp) e, err := b.builds.Get(b.ctx, key) if err != nil { t.Fatal(err) @@ -114,8 +114,8 @@ func TestADoneKeyWhoseBlobIsGoneIsBuiltAgainAtStart(t *testing.T) { h.serve(t, b) b.start(t, "n1", nil) - v := b.await(t, "ca-trust", func(v delivery.BuilderBuild) bool { - return v.State == delivery.BuildDone && len(v.Artifacts) == 1 && v.Artifacts[0].Digest == digestOf("files") + v := b.await(t, "ca-trust", func(v builds.BuilderBuild) bool { + return v.State == builds.BuildDone && len(v.Artifacts) == 1 && v.Artifacts[0].Digest == digestOf("files") }) if !strings.Contains(v.Step, "built again: the artifact store no longer held "+oldRef) { t.Errorf("the rebuilt key's step %q does not say why it was built again", v.Step) @@ -167,17 +167,18 @@ func TestArtifactsGoneLeadsTheBuilderToBuildTheKeyAgain(t *testing.T) { mu.Lock() collected = true mu.Unlock() - key, _ := delivery.BuilderBuildKey("ca-trust", fp) - body, err := delivery.EncodeArtifactsGone(delivery.ArtifactsGone{Module: "ca-trust", Key: key, Fingerprint: fp, - Gone: []string{ref}, At: time.Now().UTC()}) + key, _ := builds.BuilderBuildKey("ca-trust", fp) + at := time.Now().UTC() + body, err := json.Marshal(sweep.ArtifactsGone{Event: sweep.EventArtifactsGone, Module: "ca-trust", Key: key, Fingerprint: fp, + Gone: []string{ref}, At: &at}) if err != nil { t.Fatal(err) } - if _, err := b.js.Publish(b.ctx, delivery.ArtifactsGoneSubject, body); err != nil { + if _, err := b.js.Publish(b.ctx, sweep.SubjectArtifactsGone, body); err != nil { t.Fatal(err) } - v := b.await(t, "ca-trust", func(v delivery.BuilderBuild) bool { - return v.State == delivery.BuildDone && strings.Contains(v.Step, "built again") + v := b.await(t, "ca-trust", func(v builds.BuilderBuild) bool { + return v.State == builds.BuildDone && strings.Contains(v.Step, "built again") }) if got := revisionOf(t, b, "ca-trust"); got <= rev || b.fake.count("mesh-build-go.build_go_build") != 1 { t.Errorf("build = %+v at revision %d (was %d), %d builds: want built again once on the event", v, got, rev, diff --git a/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/held.go b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/held.go index 369a245e..acdbd9fe 100644 --- a/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/held.go +++ b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/held.go @@ -10,7 +10,8 @@ import ( "github.com/nats-io/nats.go/jetstream" - "git.novox.be/novox/mesh-sdk/go/delivery" + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun" + "git.novox.be/novox/mesh-delivery/sdk/buildstep" "mesh-delivery-builder/internal/build" ) @@ -22,7 +23,7 @@ type heldOnTheBus struct{ js jetstream.JetStream } // Held is build.Builder's Held: a step key `.` in the build module's steps, or a message of the stream under // the ask's subject whose Nats-Msg-Id is the ask's id. -func (h heldOnTheBus) Held(ctx context.Context, a delivery.StepAsk) (bool, error) { +func (h heldOnTheBus) Held(ctx context.Context, a buildstep.StepAsk) (bool, error) { seen, err := h.seen(ctx, a, false) if err != nil { return false, err @@ -31,7 +32,7 @@ func (h heldOnTheBus) Held(ctx context.Context, a delivery.StepAsk) (bool, error } // Gone is build.Builder's Gone: why the ask can no longer be answered (build.AskSeen.Why), or "". -func (h heldOnTheBus) Gone(ctx context.Context, a delivery.StepAsk) (string, error) { +func (h heldOnTheBus) Gone(ctx context.Context, a buildstep.StepAsk) (string, error) { seen, err := h.seen(ctx, a, true) if err != nil { return "", err @@ -48,17 +49,17 @@ const ( // seen reads what the bus holds of the ask; with later, the ask asked last that a build node took too (AskSeen.Last), when // the ask is on its stream. -func (h heldOnTheBus) seen(ctx context.Context, a delivery.StepAsk, later bool) (build.AskSeen, error) { +func (h heldOnTheBus) seen(ctx context.Context, a buildstep.StepAsk, later bool) (build.AskSeen, error) { seen := build.AskSeen{Kind: a.Kind, At: build.AskedAt(a.ID)} - module, err := delivery.BuildModuleOf(a.Language) + module, err := buildstep.BuildModuleOf(a.Language) if err != nil { return seen, err } - steps, err := h.js.KeyValue(ctx, delivery.StepsBucket(module)) + steps, err := h.js.KeyValue(ctx, buildrun.StepsBucket(module)) if err != nil { - return seen, fmt.Errorf("the bucket %s: %w", delivery.StepsBucket(module), err) + return seen, fmt.Errorf("the bucket %s: %w", buildrun.StepsBucket(module), err) } - pattern, err := delivery.AskStepWatch(a.ID) + pattern, err := buildstep.AskStepWatch(a.ID) if err != nil { return seen, err } @@ -114,13 +115,13 @@ func (h heldOnTheBus) seen(ctx context.Context, a delivery.StepAsk, later bool) // stream is the build seat's stream of language, its name and the subject of kind on it. func (h heldOnTheBus) stream(ctx context.Context, language, kind string) (jetstream.Stream, string, string, error) { - subject, err := delivery.AskSubject(language, kind) + subject, err := buildstep.AskSubject(language, kind) if err != nil { return nil, "", "", err } - // The seat's stream by its name (delivery.AskStream), as the broker grants the builder its information and its + // The seat's stream by its name (buildstep.AskStream), as the broker grants the builder its information and its // messages by sequence and nothing wider (issue 1910). - name, err := delivery.AskStream(language) + name, err := buildstep.AskStream(language) if err != nil { return nil, "", "", err } @@ -145,7 +146,7 @@ func onSubject(ctx context.Context, stream jetstream.Stream, subject string, eac return err } q := build.Queued{Seq: m.Sequence, Ask: m.Header.Get(jetstream.MsgIDHeader), PutAt: m.Time} - if _, kind, err := delivery.ParseAskSubject(m.Subject); err == nil { + if _, kind, err := buildstep.ParseAskSubject(m.Subject); err == nil { q.Kind = kind } if q.At = build.AskedAt(q.Ask); q.At.IsZero() { @@ -185,7 +186,7 @@ func stepState(ctx context.Context, steps jetstream.KeyValue, keys []string) str if err != nil { continue } - if s, err := delivery.DecodeStep(e.Value()); err == nil { + if s, err := buildstep.DecodeStep(e.Value()); err == nil { return s.State } } @@ -214,7 +215,7 @@ func lastTaken(ctx context.Context, steps jetstream.KeyValue, taken map[string][ if err != nil { continue } - s, err := delivery.DecodeStep(e.Value()) + s, err := buildstep.DecodeStep(e.Value()) if err != nil { continue } @@ -230,7 +231,7 @@ func lastTaken(ctx context.Context, steps jetstream.KeyValue, taken map[string][ // Drop is build.Dropper's Drop: it removes the ask's message from its build seat's stream, found by its Nats-Msg-Id on its // subject, when the stream still holds it. The builder's account must be granted $JS.API.STREAM.MSG.DELETE.. -func (h heldOnTheBus) Drop(ctx context.Context, a delivery.StepAsk) error { +func (h heldOnTheBus) Drop(ctx context.Context, a buildstep.StepAsk) error { stream, name, subject, err := h.stream(ctx, a.Language, a.Kind) if errors.Is(err, jetstream.ErrStreamNotFound) { return nil @@ -258,11 +259,11 @@ func (h heldOnTheBus) Drop(ctx context.Context, a delivery.StepAsk) error { // every kind, oldest first and at most limit of each kind, each with its step's state and whether it still waits. func (h heldOnTheBus) Queue(ctx context.Context, language string, limit int) (build.SeatQueue, error) { q := build.SeatQueue{Language: language, Note: build.QueueNote} - module, err := delivery.BuildModuleOf(language) + module, err := buildstep.BuildModuleOf(language) if err != nil { return q, err } - stream, name, _, err := h.stream(ctx, language, delivery.AskDelivery) + stream, name, _, err := h.stream(ctx, language, buildstep.AskDelivery) q.Stream = name if errors.Is(err, jetstream.ErrStreamNotFound) { q.Note = name + " does not exist" @@ -276,16 +277,16 @@ func (h heldOnTheBus) Queue(ctx context.Context, language string, limit int) (bu return q, err } q.Messages, q.FirstSeq, q.LastSeq = info.State.Msgs, info.State.FirstSeq, info.State.LastSeq - steps, err := h.js.KeyValue(ctx, delivery.StepsBucket(module)) + steps, err := h.js.KeyValue(ctx, buildrun.StepsBucket(module)) if err != nil { - return q, fmt.Errorf("the bucket %s: %w", delivery.StepsBucket(module), err) + return q, fmt.Errorf("the bucket %s: %w", buildrun.StepsBucket(module), err) } taken, err := stepKeys(ctx, steps) if err != nil { return q, err } - for _, kind := range delivery.AskKinds { - subject, _ := delivery.AskSubject(language, kind) + for _, kind := range buildstep.AskKinds { + subject, _ := buildstep.AskSubject(language, kind) var ahead []build.Queued if err := onSubject(ctx, stream, subject, func(m build.Queued) bool { ahead = append(ahead, m) diff --git a/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/integration_test.go b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/integration_test.go index 96c9d54d..a1f9db6a 100644 --- a/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/integration_test.go +++ b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/integration_test.go @@ -9,9 +9,10 @@ import ( "encoding/json" "errors" "fmt" - "io" + "maps" "os" "path/filepath" + "slices" "strings" "sync" "sync/atomic" @@ -25,12 +26,24 @@ import ( // The build runner the build modules pull and run asks with: mesh-build-go's own package (novox/hq ADR 0593 §1), // another repository's, by its published version, so the builder is tested against the real pulling code. runner "git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep" - "git.novox.be/novox/mesh-sdk/go/buildstep" + "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient" + "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" + "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliveryclient" + "git.novox.be/novox/mesh-delivery/contracts/go/seats/gopackageregistry" + "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" + "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdelivery" + "git.novox.be/novox/mesh-delivery/contracts/go/seats/ociartifactregistry" + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun" + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds" + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-forge-bridge/forgemsg" + sdkstep "git.novox.be/novox/mesh-delivery/sdk/buildstep" "git.novox.be/novox/mesh-sdk/go/buscompose" - "git.novox.be/novox/mesh-sdk/go/delivery" + "git.novox.be/novox/mesh-sdk/go/keys" "git.novox.be/novox/mesh-sdk/go/process" + "git.novox.be/novox/mesh-sdk/go/views/viewstest" "mesh-delivery-builder/internal/build" + "mesh-delivery-builder/internal/versions" ) // busConf is the shape of the composed accounts file (mesh-controller internal/broker): one account with JetStream, the @@ -72,6 +85,9 @@ type fakeBuildModules struct { // waiting in their streams). closed atomic.Bool pullers []*runner.Puller + // hang, while it holds a channel, keeps every build step running until the channel is closed: a step that never ends, + // so the builder's step bound runs out (design 51's round-2 case). + hang atomic.Pointer[chan struct{}] } // open lets the nodes pull again and wakes their pullers. @@ -110,20 +126,26 @@ func (f *fakeBuildModules) step(module, name string, args map[string]any) (any, f.builds = append(f.builds, args) } f.mu.Unlock() + if h := f.hang.Load(); h != nil && name == "build" { + select { + case <-*h: + case <-f.ctx.Done(): + } + } switch { case name == "fetch": merge := "" if args["base"] != nil { // a fetch with a base answers the merge commit (mesh-build-go FetchResult) merge = `,"merge":"` + strings.Repeat("9", 40) + `"` } - return json.RawMessage(`{"workspace":"w` + buildstep.NewID() + `","commit":"` + commit + `"` + merge + `}`), nil + return json.RawMessage(`{"workspace":"w` + sdkstep.NewID() + `","commit":"` + commit + `"` + merge + `}`), nil case f.failBuild.Load() && tool != "build_image_copy": // the build fails wherever it runs return nil, fmt.Errorf("go build of %v: boom", args["artifact"]) case tool == "build_image_copy": // the upstream copy answers the digest and the registry's reference (ADR 0498 clause 4) d := digestOf(fmt.Sprint(args["artifact"])) return json.RawMessage(`{"artifact":"` + fmt.Sprint(args["artifact"]) + `","digest":"` + d + `","reference":"registry.example/` + fmt.Sprint(args["module"]) + `/` + fmt.Sprint(args["artifact"]) + `@` + d + `","pushed":true}`), nil case tool != "build_image_build" && f.refusePush.Load(): - return nil, fmt.Errorf("%s%v: the artifact store answered 507 Insufficient Storage", delivery.NotStored, args["artifact"]) + return nil, fmt.Errorf("%s%v: the artifact store answered 507 Insufficient Storage", builds.NotStored, args["artifact"]) case tool != "build_image_build": // a bundle or an archive is pushed and its reference answered (ADR 0498 clause 1) d := digestOf(fmt.Sprint(args["artifact"])) return json.RawMessage(`{"digest":"` + d + `","reference":"artifact-store://` + filepath.Base(fmt.Sprint(args["module"])) + `/` + fmt.Sprint(args["artifact"]) + `/blobs/` + d + `"}`), nil @@ -137,7 +159,7 @@ func (f *fakeBuildModules) step(module, name string, args map[string]any) (any, // serve makes module's steps bucket and its build seat's stream and workers, and pulls its asks on nodes. func (f *fakeBuildModules) serve(t *testing.T, module string, nodes ...string) { - kv, err := f.js.CreateKeyValue(f.ctx, jetstream.KeyValueConfig{Bucket: delivery.StepsBucket(module), History: 1}) + kv, err := f.js.CreateKeyValue(f.ctx, jetstream.KeyValueConfig{Bucket: buildrun.StepsBucket(module), History: 1}) if err != nil { t.Fatal(err) } @@ -161,7 +183,7 @@ func (f *fakeBuildModules) serve(t *testing.T, module string, nodes ...string) { } return 2 }, PullWait: 200 * time.Millisecond, - Run: func(ctx context.Context, a delivery.StepAsk) (any, error) { return runner.RunAsk(ctx, steps, a) }} + Run: func(ctx context.Context, a sdkstep.StepAsk) (any, error) { return runner.RunAsk(ctx, steps, a) }} f.mu.Lock() f.pullers = append(f.pullers, p) f.mu.Unlock() @@ -175,11 +197,11 @@ const trunkHead = "3333333333333333333333333333333333333333" // trunk writes the gate's source.main naming head as novox/mesh-catalog's trunk head, and answers its revision. func (b *bed) trunk(t *testing.T, head string) uint64 { t.Helper() - data, err := delivery.EncodeSource(delivery.Source{V: delivery.Version, Group: "g", Heads: map[string]string{"novox/mesh-catalog": head}}) + data, err := meshdeliveryclient.EncodeSource(meshdelivery.SourceByMainValue{V: keys.Version, Group: "g", Heads: map[string]string{"novox/mesh-catalog": head}}) if err != nil { t.Fatal(err) } - rev, err := b.source.Put(b.ctx, delivery.SourceMainKey, data) + rev, err := b.source.Put(b.ctx, meshdeliveryclient.SourceMainKey, data) if err != nil { t.Fatal(err) } @@ -195,7 +217,11 @@ type bed struct { builds jetstream.KeyValue tools map[string]jetstream.KeyValue instances jetstream.KeyValue - stop func() // stops the builder started last + tagAsks jetstream.KeyValue + tagBounds jetstream.KeyValue + packages jetstream.KeyValue // the indexer's packages, served by its read tool and views family + out *saidLog // the builder's process log, every instance's + stop func() // stops the builder started last fake *fakeBuildModules srvURL string dir string @@ -253,7 +279,7 @@ func startBedWith(t *testing.T, closed bool) *bed { t.Fatal(err) } } - kv, err := js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: delivery.ToolchainsBucket(module), History: 1}) + kv, err := js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: buildrun.ToolchainsBucket(module), History: 1}) if err != nil { t.Fatal(err) } @@ -266,15 +292,30 @@ func startBedWith(t *testing.T, closed bool) *bed { } return kv } - b.modules = mk(delivery.ModulesBucket, delivery.ModulesHistory) - b.candidates = mk(delivery.CandidatesBucket, delivery.CandidatesHistory) - b.source = mk(delivery.SourceBucket, delivery.SourceHistory) - mk(delivery.ManifestsBucket, delivery.ManifestsHistory) - b.builds = mk(delivery.BuilderBuildsBucket, delivery.BuilderBuildsHistory) + b.modules = mk(indexerBucket(modulesState), modulesHistory) + b.candidates = mk(indexerBucket(candidatesState), candidatesHistory) + b.source = mk(sourceBucket, sourceHistory) + mk(indexerBucket(manifestsState), manifestsHistory) + // The indexer and the assigner as the owners of the states the builder follows: their read tools and views families + // (ADR 0615, DLV-M1); n1 and n2 each have their durable consumer. + readers := []string{"n1_" + builds.BuilderModule, "n2_" + builds.BuilderModule} + for _, st := range []string{modulesState, manifestsState, candidatesState} { + viewstest.Serve(t, ctx, js, ctl, indexerModule, st, indexerModule+".indexer_read_"+st, readers...) + } + b.packages = viewstest.Serve(t, ctx, js, ctl, indexerModule, meshcatalog.StatePackages, indexerModule+".indexer_read_packages", readers...) + viewstest.Serve(t, ctx, js, ctl, assignerModule, "assignments", assignerModule+".assigner_read_assignments", readers...) + b.builds = mk(builds.BuilderBuildsBucket, builds.BuilderBuildsHistory) b.instances = mk(instancesBucket, 1) + // The tag asks and their bounds (design 51 *Tagged versions*): the bounds declared key-ttl, so an ended bound leaves the + // bus's marker a watch hears. + b.tagAsks = mk(versions.TagAsksBucket, 1) + if b.tagBounds, err = js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: versions.TagAskBoundsBucket, History: 1, LimitMarkerTTL: time.Minute}); err != nil { + t.Fatal(err) + } + b.out = &saidLog{} // The builder's module log, as the runtime raises it (ADR 0297): its stream on the log's subjects. - logStream := buscompose.LogStreamName(delivery.BuilderModule, delivery.BuilderLog) - if _, err := js.CreateStream(ctx, jetstream.StreamConfig{Name: logStream, Subjects: []string{buscompose.LogSubject(delivery.BuilderModule, delivery.BuilderLog) + ".>"}, + logStream := buscompose.LogStreamName(builds.BuilderModule, builds.BuilderLog) + if _, err := js.CreateStream(ctx, jetstream.StreamConfig{Name: logStream, Subjects: []string{buscompose.LogSubject(builds.BuilderModule, builds.BuilderLog) + ".>"}, AllowDirect: true}); err != nil { t.Fatal(err) } @@ -291,20 +332,20 @@ func startBedWith(t *testing.T, closed bool) *bed { } // logged is every entry of module's key in the builder's log, read through process.Conn.ReadLog on the controller's user. -func (b *bed) logged(t *testing.T, module string) []delivery.BuildLogEntry { +func (b *bed) logged(t *testing.T, module string) []builds.BuildLogEntry { t.Helper() conn, err := process.Connect(process.Account{URL: b.srvURL, Module: "mesh-delivery-view", User: "ctl", Password: "pw"}) if err != nil { t.Fatal(err) } defer conn.Close() - entries, err := conn.ReadLog(delivery.BuilderModule, delivery.BuilderLog).Since(time.Unix(0, 0), module) + entries, err := conn.ReadLog(builds.BuilderModule, builds.BuilderLog).Since(time.Unix(0, 0), module) if err != nil { t.Fatal(err) } - var out []delivery.BuildLogEntry + var out []builds.BuildLogEntry for _, le := range entries { - e, err := delivery.DecodeBuildLogEntry(le.Value) + e, err := builds.DecodeBuildLogEntry(le.Value) if err != nil { t.Fatal(err) } @@ -316,11 +357,11 @@ func (b *bed) logged(t *testing.T, module string) []delivery.BuildLogEntry { // report writes a node's toolchains key of a build module and says so on the module's stream, as the module does. func (b *bed) report(t *testing.T, module, node, language, version, recipe string) { t.Helper() - data, err := delivery.EncodeToolchain(delivery.Toolchain{Language: language, Version: version, Recipe: recipe, At: time.Now().UTC()}) + data, err := sdkstep.EncodeToolchain(sdkstep.Toolchain{Language: language, Version: version, Recipe: recipe, At: time.Now().UTC()}) if err != nil { t.Fatal(err) } - key, _ := delivery.ToolchainKey(node, language) + key, _ := sdkstep.ToolchainKey(node, language) if _, err := b.tools[module].Put(b.ctx, key, data); err != nil { t.Fatal(err) } @@ -331,34 +372,43 @@ func (b *bed) report(t *testing.T, module, node, language, version, recipe strin // module writes modules. as the indexer does for a module with a new-form fingerprint, and its manifest by digest. func (b *bed) module(t *testing.T, name, fixture, path string) string { - return b.moduleWith(t, name, fixture, path, map[string]delivery.ToolchainUsed{"go": {Version: "go1.27.1", Recipe: "1"}, "typescript": {Version: "tsc5", Recipe: "1"}}) + return b.moduleWith(t, name, fixture, path, bothToolchains) } +// bothToolchains are the toolchains the bed's nodes report, as a module's fingerprint names them. +var bothToolchains = map[string]builds.ToolchainUsed{"go": {Version: "go1.27.1", Recipe: "1"}, "typescript": {Version: "tsc5", Recipe: "1"}} + // moduleWith is module with the toolchains its fingerprint was computed with. -func (b *bed) moduleWith(t *testing.T, name, fixture, path string, toolchains map[string]delivery.ToolchainUsed) string { +func (b *bed) moduleWith(t *testing.T, name, fixture, path string, toolchains map[string]builds.ToolchainUsed) string { return b.moduleCommit(t, name, fixture, path, toolchains, commit) } -func (b *bed) moduleCommit(t *testing.T, name, fixture, path string, toolchains map[string]delivery.ToolchainUsed, at string) string { +func (b *bed) moduleCommit(t *testing.T, name, fixture, path string, toolchains map[string]builds.ToolchainUsed, at string) string { + t.Helper() + return b.moduleFP(t, name, fixture, path, toolchains, at, fp, 0) +} + +// moduleFP is moduleCommit with the new-form fingerprint fp2 and the source revision src of its merge (0: none). +func (b *bed) moduleFP(t *testing.T, name, fixture, path string, toolchains map[string]builds.ToolchainUsed, at, fp2 string, src uint64) string { t.Helper() raw, err := os.ReadFile(filepath.Join("testdata", fixture)) if err != nil { t.Fatal(err) } - digest := delivery.ManifestDigest(raw) - manifests, err := b.js.KeyValue(b.ctx, delivery.ManifestsBucket) + digest := meshcatalogclient.ManifestDigest(raw) + manifests, err := b.js.KeyValue(b.ctx, indexerBucket(manifestsState)) if err != nil { t.Fatal(err) } - mv, err := delivery.EncodeManifest(delivery.Manifest{Module: name, JSON: string(raw)}) + mv, err := meshcatalogclient.EncodeManifest(meshcatalog.ManifestsByDigestValue{Module: name, Manifest: string(raw)}) if err != nil { t.Fatal(err) } if _, err := manifests.Put(b.ctx, digest, mv); err != nil { t.Fatal(err) } - data, err := delivery.EncodeModule(delivery.Module{Repository: "novox/mesh-catalog", Commit: at, FP: "src1:" + strings.Repeat("f", 64), FP2: fp, - Manifest: digest, Path: path, Toolchains: toolchains}) + data, err := meshcatalogclient.EncodeModule(meshcatalog.ModulesByModuleValue{Repository: "novox/mesh-catalog", Commit: at, Fp: "src1:" + strings.Repeat("f", 64), Fp2: fp2, + Manifest: digest, Path: path, Toolchains: build.CatalogToolchains(toolchains), Src: int64(src)}) if err != nil { t.Fatal(err) } @@ -389,26 +439,68 @@ func (b *bed) start(t *testing.T, node string, env map[string]string) { } runCtx, stop := context.WithCancel(b.ctx) done := make(chan error, 1) - go func() { done <- run(runCtx, func(k string) string { return e[k] }, io.Discard) }() + go func() { done <- run(runCtx, func(k string) string { return e[k] }, b.out) }() var once sync.Once b.stop = func() { once.Do(func() { stop(); <-done }) } t.Cleanup(b.stop) } -// await reads the build key until pred holds of it, within 20 s. -func (b *bed) await(t *testing.T, module string, pred func(delivery.BuilderBuild) bool) delivery.BuilderBuild { +// saidLog is the builder's process log as its instances write it. +type saidLog struct { + mu sync.Mutex + buf strings.Builder +} + +func (l *saidLog) Write(p []byte) (int, error) { + l.mu.Lock() + defer l.mu.Unlock() + return l.buf.Write(p) +} + +// awaitSaid reads the process log until it holds want, within 20 s, and answers the line. +func (b *bed) awaitSaid(t *testing.T, want string) string { t.Helper() - key, err := delivery.BuilderBuildKey(module, fp) + deadline := time.Now().Add(20 * time.Second) + for { + b.out.mu.Lock() + all := b.out.buf.String() + b.out.mu.Unlock() + for _, line := range strings.Split(all, "\n") { + if strings.Contains(line, want) { + return line + } + } + if time.Now().After(deadline) { + t.Fatalf("the builder never said %q; it said:\n%s", want, all) + } + select { + case <-b.ctx.Done(): + t.Fatal(b.ctx.Err()) + case <-time.After(50 * time.Millisecond): + } + } +} + +// await reads the build key until pred holds of it, within 20 s. +func (b *bed) await(t *testing.T, module string, pred func(builds.BuilderBuild) bool) builds.BuilderBuild { + t.Helper() + return b.awaitFP(t, module, fp, pred) +} + +// awaitFP is await of the build key of fingerprint at. +func (b *bed) awaitFP(t *testing.T, module, at string, pred func(builds.BuilderBuild) bool) builds.BuilderBuild { + t.Helper() + key, err := builds.BuilderBuildKey(module, at) if err != nil { t.Fatal(err) } - var last delivery.BuilderBuild + var last builds.BuilderBuild for deadline := time.Now().Add(20 * time.Second); time.Now().Before(deadline); time.Sleep(50 * time.Millisecond) { e, err := b.builds.Get(b.ctx, key) if err != nil { continue } - if last, err = delivery.DecodeBuilderBuild(e.Value()); err == nil && pred(last) { + if last, err = builds.DecodeBuilderBuild(e.Value()); err == nil && pred(last) { return last } } @@ -416,8 +508,8 @@ func (b *bed) await(t *testing.T, module string, pred func(delivery.BuilderBuild return last } -func isState(state string) func(delivery.BuilderBuild) bool { - return func(v delivery.BuilderBuild) bool { return v.State == state } +func isState(state string) func(builds.BuilderBuild) bool { + return func(v builds.BuilderBuild) bool { return v.State == state } } // TestBuilderMakesADoneKeyWithTheDigests: modules.netcheck (a Go and a TypeScript bundle) and its manifest are in the @@ -425,9 +517,31 @@ func isState(state string) func(delivery.BuilderBuild) bool { // the digests of both bundles, both build modules' toolchains, after a fetch and a build on one node (ADR 0478 clause 4). func TestBuilderMakesADoneKeyWithTheDigests(t *testing.T) { // 1607/AC7 b := startBed(t) - digest := b.module(t, "netcheck", "catalogue-a67f0196-netcheck.module.json", "modules/netcheck") - b.start(t, "n1", nil) - v := b.await(t, "netcheck", isState(delivery.BuildDone)) + // DLV-B10 (design 51, round 2): the builder's versions, findings and the floor's three sources are on the bus; the seat + // answers 0.1.4, so netcheck's bundles are numbered from 0.1.5. Two merges close together, the first replaced before + // its claim: the second is numbered at once, and the first never. + reg := b.registry(t, "n1", "0.1.4") + const netcheck = "catalogue-a67f0196-netcheck.module.json" + first := "src2:" + strings.Repeat("1", 64) + b.moduleFP(t, "netcheck", netcheck, "modules/netcheck", bothToolchains, commit, first, 1) + digest := b.moduleFP(t, "netcheck", netcheck, "modules/netcheck", bothToolchains, commit, fp, 2) + // DLV-B6: a tag ask kept and never numbered (no check builds its head) is narrated once its bound ends on the bus, with + // what it waits on, and its bound is written again; no timer runs in the builder. + key, ask, err := versions.NewTagAsk("novox/mesh-sdk", "feat/x", "abc", "a group member's check", "me@shanks", time.Now()) + if err != nil { + t.Fatal(err) + } + if _, err := b.tagAsks.Put(b.ctx, key, ask); err != nil { + t.Fatal(err) + } + b.start(t, "n1", map[string]string{"MESH_BUILDER_TAG_ASK_BOUND": "1s", "MESH_BUILDER_STEP_BOUND": "1s"}) + v := b.await(t, "netcheck", isState(builds.BuildDone)) + for _, a := range []string{"tools-go", "tools-typescript"} { + es := reg.of(t, "netcheck/"+a) + if len(es) != 1 || es[0].Version != "0.1.5" || es[0].Fingerprint != fp || es[0].State != versions.Numbered || es[0].Digest != digestOf(a) { + t.Errorf("versions of netcheck/%s = %+v, want the second merge numbered 0.1.5 at once with its digest, the first never", a, es) + } + } if v.By != "n1.mesh-delivery-builder" || v.Manifest != digest || len(v.Artifacts) != 2 { t.Fatalf("build = %+v", v) } @@ -439,10 +553,22 @@ func TestBuilderMakesADoneKeyWithTheDigests(t *testing.T) { // 1607/AC7 if v.Toolchains["go"].Version != "go1.27.1" || v.Toolchains["typescript"].Version != "tsc5" { t.Errorf("toolchains = %+v", v.Toolchains) } + // ADR 0618 §1: the done key names the reference forms it is written in. + if len(v.Needs) != 1 || v.Needs[0] != "form:artifact-store" { + t.Errorf("needs = %v, want the first form", v.Needs) + } // 1955/L7 (ADR 0571 §1): the build's start and end are in the module log build-log under the module's key, read as a // reader with the log's read reads them, the end naming the manifest and what was made. - ended := b.logged(t, "netcheck") - if len(ended) != 2 || ended[0].Event != delivery.BuildStartedEvent || ended[1].Event != delivery.BuildEndedEvent || + // DLV-B10: between them the build log holds the build's narrations (build.said), each version numbered, and why. + var ended []builds.BuildLogEntry + for _, e := range b.logged(t, "netcheck") { + if e.Event != builds.BuildSaidEvent { + ended = append(ended, e) + } else if e.Why != versions.SaidNumbered || !strings.Contains(e.Line, "numbered 0.1.5") || e.FP != fp { + t.Errorf("said = %+v, want netcheck's versions numbered", e) + } + } + if len(ended) != 2 || ended[0].Event != builds.BuildStartedEvent || ended[1].Event != builds.BuildEndedEvent || ended[1].Manifest != digest || len(ended[1].Made) != 2 || ended[1].Node != "n1" { t.Errorf("logged = %+v, want the start and the end of netcheck's build", ended) } @@ -451,38 +577,168 @@ func TestBuilderMakesADoneKeyWithTheDigests(t *testing.T) { // 1607/AC7 t.Errorf("%s called %d times, want %d (on one node, ADR 0478 clause 4)", tool, got, want) } } -} - -// TestTwoBuilderInstancesStartingTogetherEndWithOneDoneKey: two builders on two nodes start together on one module key of an -// archive only (no toolchain named, as the indexer writes it); the second takes over the key the first holds building -// (ADR 0478 clause 6), and the key ends done, written once. -func TestTwoBuilderInstancesStartingTogetherEndWithOneDoneKey(t *testing.T) { // 1607/AC7 - b := startBed(t) - b.archive(t, "ca-trust", "modules/ca-trust", commit) - b.start(t, "n1", nil) - b.start(t, "n2", nil) - v := b.await(t, "ca-trust", isState(delivery.BuildDone)) - if len(v.Artifacts) != 1 || v.Artifacts[0].Kind != "archive" || v.Artifacts[0].Digest != digestOf("files") { - t.Fatalf("build = %+v, want the archive's digest", v) + said := b.awaitSaid(t, "the tag ask of novox/mesh-sdk feat/x at abc (feat-x) is not answered after") + if !strings.Contains(said, "the check's build of abc is not claimed yet") { + t.Errorf("the narration = %q, want what the ask waits on", said) } - if n := b.fake.count("mesh-build-go.build_go_build"); n < 1 { // each instance that built, on one node - t.Errorf("build_go_build was called %d times, want one for each instance that built", n) + // And in the build log, under the builder's own key (the ruling of 13:17: build.said). + if !slices.ContainsFunc(b.logged(t, builds.BuilderModule), func(e builds.BuildLogEntry) bool { + return e.Event == builds.BuildSaidEvent && e.Why == versions.SaidUnanswered && e.FP == key && e.Commit == "abc" && + strings.Contains(e.Line, "the check's build of abc is not claimed yet") + }) { + t.Errorf("the build log of %s = %+v, want the unanswered tag ask said", builds.BuilderModule, b.logged(t, builds.BuilderModule)) } + if e, err := b.tagAsks.Get(b.ctx, key); err != nil { + t.Fatal(err) + } else if a, _ := versions.DecodeTagAsk(e.Value()); a.Answered.IsZero() || !a.Unanswered() { + t.Errorf("the tag ask = %+v, want kept and answered, no version yet", a) + } + for deadline := time.Now().Add(10 * time.Second); ; time.Sleep(50 * time.Millisecond) { + if _, err := b.tagBounds.Get(b.ctx, key); err == nil { + break + } else if time.Now().After(deadline) { + t.Fatalf("the bound is not written again after its narration: %v", err) + } + } + // Design 32, ADR 0615 §1: a package's name the indexer writes later reaches the builder by its packages' views family + // (never their bucket), and looks at the module again; the done bundles are kept, not numbered twice. + if _, err := b.packages.Put(b.ctx, "netcheck.0.1", []byte(`{"v":1,"name":"netcheck","package":"netcheck/lib","series":"0.1"}`)); err != nil { + t.Fatal(err) + } + b.awaitSaid(t, "names a package of netcheck") + if got := reg.numberedOf("netcheck/tools-go"); len(got) != 1 || got[0] != "0.1.5" { + t.Errorf("version.numbered of netcheck/tools-go = %v, want 0.1.5 once", got) + } + // DLV-B10: builder_publish_again after a published takes a new number, and says it numbered. + for _, a := range []string{"tools-go", "tools-typescript"} { + reg.published(t, "netcheck/"+a, "0.1.5", digestOf(a)) + } + reg.awaitOf(t, "netcheck/tools-go", func(es []versions.Entry) bool { return len(es) == 1 && es[0].State == versions.Published }) + key, again, err := build.NewPublishAgain("netcheck/tools-go", fp, "the holder lost it", "me@shanks", time.Now()) + if err != nil { + t.Fatal(err) + } + if a := reg.answered(t, key, again); a.Refused || a.Do != versions.AgainRenumber || a.Version != "0.1.6" { + t.Errorf("builder_publish_again = %+v, want a new number, 0.1.6", a) + } + // Running-testing note (review round 1): two claims never get one number on the bus's own compare-and-set, as + // the builder's state versions keeps them: eight claims of one name at once, eight versions. + store := versions.Versions{Store: awaitedVersions{process.AwaitBucket(b.ctx, b.js, nil, versions.Bucket, "", t.Logf)}} + numbers := make(chan string, 8) + var claims sync.WaitGroup + for i := range 8 { + claims.Go(func() { + d, _, err := store.Number(b.ctx, versions.Claim{Name: "netcheck/race", Kind: versions.KindBundle, Fingerprint: fmt.Sprintf("f%d", i)}) + if err != nil { + t.Error(err) + } + numbers <- d.Version + }) + } + claims.Wait() + close(numbers) + got := map[string]bool{} + for v := range numbers { + got[v] = true + } + if len(got) != 8 { + t.Errorf("eight claims of one name at once were numbered %v, want eight versions", slices.Sorted(maps.Keys(got))) + } + if got := reg.numberedOf("netcheck/tools-go"); len(got) != 2 || got[1] != "0.1.6" { + t.Errorf("version.numbered of netcheck/tools-go = %v, want 0.1.6 said after 0.1.5", got) + } + // DLV-B10: a build module's bound running out fails the trunk build, which is the builder's finding build-failed; the + // fingerprint built again keeps its number, said with why, and its done build clears the finding. + release := make(chan struct{}) + b.fake.hang.Store(&release) + third := "src2:" + strings.Repeat("4", 64) + b.moduleFP(t, "netcheck", netcheck, "modules/netcheck", bothToolchains, commit, third, 3) + if f := b.awaitFP(t, "netcheck", third, isState(builds.BuildFailed)); !strings.Contains(f.Error, "did not end within") { + t.Errorf("the failed build = %+v, want its bound run out", f) + } + failed := meshconditionsclient.Key(meshconditionsclient.ScopeModule, "netcheck", BuildFailed) + reg.awaitFinding(t, failed, true) + b.fake.hang.Store(nil) + close(release) + b.moduleFP(t, "netcheck", netcheck, "modules/netcheck", bothToolchains, commit, third, 3) // the indexer writes it again + b.awaitFP(t, "netcheck", third, isState(builds.BuildDone)) + b.awaitSaid(t, "kept: fingerprint "+third+" is numbered under it") + if !slices.ContainsFunc(b.logged(t, "netcheck"), func(e builds.BuildLogEntry) bool { + return e.Event == builds.BuildSaidEvent && e.Why == versions.SaidKept && e.FP == third && strings.Contains(e.Line, "kept: fingerprint "+third) + }) { + t.Errorf("the build log of netcheck says no version kept at %s", third) + } + reg.awaitFinding(t, failed, false) + // DLV-B4: the seats' refused and removed, heard on the builder's durable consumer, move a numbered version: a retention + // removal turns the entry removed with its reason, a refusal refused with the holder's reason and the bytes it refused. + reg.outcome(t, ociartifactregistry.SubjectRemoved, versions.Outcome{Name: "netcheck/tools-typescript", Version: "0.1.5", Kind: "bundle", Holder: "distribution", Reason: "retention"}) + reg.outcome(t, ociartifactregistry.SubjectRefused, versions.Outcome{Name: "netcheck/tools-go", Version: "0.1.6", Digest: digestOf("other"), Kind: "bundle", Holder: "distribution", Reason: "the cap"}) + reg.awaitOf(t, "netcheck/tools-typescript", func(es []versions.Entry) bool { + return slices.ContainsFunc(es, func(e versions.Entry) bool { + return e.Version == "0.1.5" && e.State == versions.Removed && e.Reason == "retention" + }) + }) + reg.awaitOf(t, "netcheck/tools-go", func(es []versions.Entry) bool { + return slices.ContainsFunc(es, func(e versions.Entry) bool { + return e.Version == "0.1.6" && e.State == versions.Refused && e.Reason == "the cap" && e.RefusedDigest == digestOf("other") + }) + }) + // DLV-B6: a branch's end (the forge bridge's merged) asks the seat to remove each version tagged for it, the reason + // tag-gone, and the seat's removed turns it removed. + tagged := versions.Entry{Name: "mesh-sdk/go", Kind: versions.KindPackage, Series: "0.1", Version: "0.1.15-feat-x.1", Tag: "feat-x", Module: "mesh-sdk", + Fingerprint: "src2:" + strings.Repeat("5", 64), Repository: "novox/mesh-sdk", Seat: gopackageregistry.Seat, State: versions.Published, At: time.Now().UTC()} + raw, _ := json.Marshal(tagged) + if _, err := reg.versions.Put(b.ctx, versions.Key(tagged.Name, tagged.Version), raw); err != nil { + t.Fatal(err) + } + if _, err := b.js.Publish(b.ctx, process.ModuleStreamSubject(forgeBridgeModule, "forge"), + []byte(`{"kind":"`+forgemsg.ForgeMerged+`","repository":"novox/mesh-sdk","branch":"feat/x"}`)); err != nil { + t.Fatal(err) + } + for deadline := time.Now().Add(20 * time.Second); ; time.Sleep(50 * time.Millisecond) { + reg.mu.Lock() + asked := slices.ContainsFunc(reg.removes, func(a map[string]any) bool { + return a["name"] == tagged.Name && a["version"] == tagged.Version && a["reason"] == "tag-gone" + }) + reg.mu.Unlock() + if asked { + break + } + if time.Now().After(deadline) { + t.Fatalf("the seat's remove of %s@%s was not asked at the branch's end: %v", tagged.Name, tagged.Version, reg.removes) + } + } + reg.outcome(t, gopackageregistry.SubjectRemoved, versions.Outcome{Name: tagged.Name, Version: tagged.Version, Kind: versions.KindPackage, Holder: "gitea", Reason: "tag-gone"}) + reg.awaitOf(t, tagged.Name, func(es []versions.Entry) bool { return len(es) == 1 && es[0].State == versions.Removed }) } // TestAnArchiveOnlyModuleAndARootModuleAreBuilt: the indexer gives an archive no language, so modules.ca-trust has no // toolchains; the builder builds it on the build module's nodes all the same (review round 4). A module whose path is "." -// is given to the build tools as it is (1615/AC4). One table: the two tests were merged to pay for the enrolment's. +// is given to the build tools as it is (1615/AC4). Two builders on two nodes start together on the archive's key: the +// second takes over the key the first holds building (ADR 0478 clause 6), and the key ends done, written once (1607/AC7). +// One table: three tests were merged, to pay for the enrolment's and to bring mesh-delivery's count down (DLV-B8). func TestAnArchiveOnlyModuleAndARootModuleAreBuilt(t *testing.T) { - for _, c := range []struct{ name, path string }{{"no toolchain", "modules/ca-trust"}, {"root", delivery.RootPath}} { + for _, c := range []struct { + name, path string + two bool + }{{"no toolchain", "modules/ca-trust", false}, {"root", meshcatalogclient.RootPath, false}, {"two instances together", "modules/ca-trust", true}} { t.Run(c.name, func(t *testing.T) { b := startBed(t) b.archive(t, "ca-trust", c.path, commit) b.start(t, "n1", nil) - v := b.await(t, "ca-trust", isState(delivery.BuildDone)) + if c.two { + b.start(t, "n2", nil) + } + v := b.await(t, "ca-trust", isState(builds.BuildDone)) if c.name == "no toolchain" && (len(v.Toolchains) != 0 || len(v.Artifacts) != 1 || strings.HasPrefix(v.Step, "one node (")) { t.Errorf("build = %+v, want done naming no toolchain", v) } + if c.two && (len(v.Artifacts) != 1 || v.Artifacts[0].Kind != "archive" || v.Artifacts[0].Digest != digestOf("files")) { + t.Errorf("build = %+v, want the archive's digest", v) + } + if n := b.fake.count("mesh-build-go.build_go_build"); c.two && n < 1 { // each instance that built, on one node + t.Errorf("build_go_build was called %d times, want one for each instance that built", n) + } }) } } @@ -495,11 +751,11 @@ func TestAFailedKeyIsBuiltAgainAsANewRevisionFromANewModulesValueWithTheSameFing b.fake.failBuild.Store(true) b.archive(t, "ca-trust", "modules/ca-trust", commit) b.start(t, "n1", nil) - v := b.await(t, "ca-trust", isState(delivery.BuildFailed)) + v := b.await(t, "ca-trust", isState(builds.BuildFailed)) if !strings.Contains(v.Error, " on n") || !strings.Contains(v.Error, "boom") { t.Errorf("build = %+v, want failed naming the node and the step's reason", v) } - key, _ := delivery.BuilderBuildKey("ca-trust", fp) + key, _ := builds.BuilderBuildKey("ca-trust", fp) failed, err := b.builds.Get(b.ctx, key) if err != nil { t.Fatal(err) @@ -507,7 +763,7 @@ func TestAFailedKeyIsBuiltAgainAsANewRevisionFromANewModulesValueWithTheSameFing b.fake.failBuild.Store(false) // The indexer writes the module again (here at a later commit), with the same fingerprint. b.archive(t, "ca-trust", "modules/ca-trust", "89abcdef0123456789abcdef0123456789abcdef") - b.await(t, "ca-trust", isState(delivery.BuildDone)) + b.await(t, "ca-trust", isState(builds.BuildDone)) done, err := b.builds.Get(b.ctx, key) if err != nil { t.Fatal(err) @@ -528,8 +784,8 @@ func TestAToolchainChangeDoesNotBuildTheOldFingerprint(t *testing.T) { // 1615/A b.start(t, "n1", nil) // Another module, whose fingerprint names the new toolchain, is built; by then the old one has been looked at. b.moduleCommit(t, "netcheck2", "catalogue-a67f0196-netcheck.module.json", "modules/netcheck", - map[string]delivery.ToolchainUsed{"go": {Version: "go1.27.2", Recipe: "1"}, "typescript": {Version: "tsc5", Recipe: "1"}}, commit) - b.await(t, "netcheck2", isState(delivery.BuildDone)) + map[string]builds.ToolchainUsed{"go": {Version: "go1.27.2", Recipe: "1"}, "typescript": {Version: "tsc5", Recipe: "1"}}, commit) + b.await(t, "netcheck2", isState(builds.BuildDone)) if _, err := b.builds.Get(b.ctx, mustKey(t, "netcheck")); err == nil { t.Error("the old fingerprint, naming go1.27.1, was built with go1.27.2") } @@ -539,7 +795,7 @@ func TestAToolchainChangeDoesNotBuildTheOldFingerprint(t *testing.T) { // 1615/A } func mustKey(t *testing.T, module string) string { - k, err := delivery.BuilderBuildKey(module, fp) + k, err := builds.BuilderBuildKey(module, fp) if err != nil { t.Fatal(err) } @@ -549,14 +805,14 @@ func mustKey(t *testing.T, module string) string { // moduleAt is module with the go toolchain version the fingerprint names and the commit it was computed at. func (b *bed) moduleAt(t *testing.T, name, fixture, path, goVersion, at string) { t.Helper() - b.moduleCommit(t, name, fixture, path, map[string]delivery.ToolchainUsed{"go": {Version: goVersion, Recipe: "1"}}, at) + b.moduleCommit(t, name, fixture, path, map[string]builds.ToolchainUsed{"go": {Version: goVersion, Recipe: "1"}}, at) } // TestAKeyLeftBuildingForAModuleThatIsGoneIsClosedAtStart: the builds bucket holds a key left building by another node for // a module the indexer no longer has; the program, at start, fails it naming why (issue 1660, ADR 0478 clause 6). func TestAKeyLeftBuildingForAModuleThatIsGoneIsClosedAtStart(t *testing.T) { b := startBed(t) - data, err := delivery.EncodeBuilderBuild(delivery.BuilderBuild{State: delivery.BuildBuilding, By: "n2.mesh-delivery-builder", Manifest: strings.Repeat("a", 64)}) + data, err := builds.EncodeBuilderBuild(builds.BuilderBuild{State: builds.BuildBuilding, By: "n2.mesh-delivery-builder", Manifest: strings.Repeat("a", 64)}) if err != nil { t.Fatal(err) } @@ -564,7 +820,7 @@ func TestAKeyLeftBuildingForAModuleThatIsGoneIsClosedAtStart(t *testing.T) { t.Fatal(err) } b.start(t, "n1", nil) - v := b.await(t, "ghost", isState(delivery.BuildFailed)) + v := b.await(t, "ghost", isState(builds.BuildFailed)) if !strings.Contains(v.Error, "no longer has this fingerprint") || !strings.Contains(v.Error, "n2.mesh-delivery-builder") { t.Errorf("build = %+v, want it closed naming who left it", v) } @@ -579,8 +835,8 @@ func TestBuilderBuildsACandidatesFingerprintFromTheHeadMergedOntoTheTrunk(t *tes b := startBed(t) other := "src2:" + strings.Repeat("e", 64) digest := b.module(t, "netcheck", "catalogue-a67f0196-netcheck.module.json", "modules/netcheck") - data, err := delivery.EncodeModule(delivery.Module{Repository: "novox/mesh-catalog", Commit: commit, FP: "src1:" + strings.Repeat("f", 64), FP2: other, - Manifest: digest, Path: "modules/netcheck", Toolchains: map[string]delivery.ToolchainUsed{"go": {Version: "go1.27.1", Recipe: "1"}, "typescript": {Version: "tsc5", Recipe: "1"}}}) + data, err := meshcatalogclient.EncodeModule(meshcatalog.ModulesByModuleValue{Repository: "novox/mesh-catalog", Commit: commit, Fp: "src1:" + strings.Repeat("f", 64), Fp2: other, + Manifest: digest, Path: "modules/netcheck", Toolchains: build.CatalogToolchains(map[string]builds.ToolchainUsed{"go": {Version: "go1.27.1", Recipe: "1"}, "typescript": {Version: "tsc5", Recipe: "1"}})}) if err != nil { t.Fatal(err) } @@ -588,14 +844,14 @@ func TestBuilderBuildsACandidatesFingerprintFromTheHeadMergedOntoTheTrunk(t *tes t.Fatal(err) } const head = "1111111111111111111111111111111111111111" - pulls := []delivery.CandidatePull{{Head: delivery.Head{Repository: "novox/mesh-catalog", Number: 7, Head: head}, Modules: []string{"netcheck"}, - Fps: map[string]delivery.CandidateModule{"netcheck": {FP2: fp, Manifest: digest}}}} - hash, _ := delivery.HeadsHash([]delivery.Head{pulls[0].Head}) - cand, err := delivery.EncodeCandidate(delivery.Candidate{V: delivery.Version, Group: "feat/x", Base: b.trunk(t, trunkHead), HeadsHash: hash, Pulls: pulls, FPSet: delivery.FingerprintSet(pulls)}) + pulls := []meshcatalog.CandidatesByGroupValuePullsItem{{Repository: "novox/mesh-catalog", Number: 7, Head: head, Modules: []string{"netcheck"}, + Fps: map[string]meshcatalog.CandidatesByGroupValuePullsItemFpsValue{"netcheck": {Fp2: fp, Manifest: digest}}}} + hash, _ := meshdeliveryclient.HeadsHash([]meshdeliveryclient.Head{{Repository: pulls[0].Repository, Number: int(pulls[0].Number), Head: pulls[0].Head}}) + cand, err := json.Marshal(meshcatalog.CandidatesByGroupValue{V: keys.Version, Group: "feat/x", Base: int64(b.trunk(t, trunkHead)), Heads: hash, Pulls: pulls, Fpset: meshcatalogclient.FingerprintSet(pulls)}) if err != nil { t.Fatal(err) } - ckey, err := delivery.CandidatesKey("feat/x") + ckey, err := keys.EscapeName("feat/x"), error(nil) if err != nil { t.Fatal(err) } @@ -603,7 +859,7 @@ func TestBuilderBuildsACandidatesFingerprintFromTheHeadMergedOntoTheTrunk(t *tes t.Fatal(err) } b.start(t, "n1", nil) - v := b.await(t, "netcheck", isState(delivery.BuildDone)) + v := b.await(t, "netcheck", isState(builds.BuildDone)) if len(v.Artifacts) != 2 { t.Fatalf("build = %+v", v) } @@ -631,7 +887,7 @@ func TestAnImageIsBuiltOnOneNodeAndOnlyATrunkKeyPublishes(t *testing.T) { // 170 digest := b.moduleCommit(t, "imaged", "catalogue-a67f0196-imaged.module.json", "modules/imaged", nil, commit) // The trunk carries `other`; its key is built and published. trunk := func(fp2 string) { - data, err := delivery.EncodeModule(delivery.Module{Repository: "novox/mesh-catalog", Commit: commit, FP: "src1:" + strings.Repeat("f", 64), FP2: fp2, + data, err := meshcatalogclient.EncodeModule(meshcatalog.ModulesByModuleValue{Repository: "novox/mesh-catalog", Commit: commit, Fp: "src1:" + strings.Repeat("f", 64), Fp2: fp2, Manifest: digest, Path: "modules/imaged"}) if err != nil { t.Fatal(err) @@ -642,12 +898,12 @@ func TestAnImageIsBuiltOnOneNodeAndOnlyATrunkKeyPublishes(t *testing.T) { // 170 } trunk(other) b.start(t, "n1", nil) - okey, _ := delivery.BuilderBuildKey("imaged", other) - awaitKey := func(key string, pred func(delivery.BuilderBuild) bool) delivery.BuilderBuild { - var last delivery.BuilderBuild + okey, _ := builds.BuilderBuildKey("imaged", other) + awaitKey := func(key string, pred func(builds.BuilderBuild) bool) builds.BuilderBuild { + var last builds.BuilderBuild for deadline := time.Now().Add(20 * time.Second); time.Now().Before(deadline); time.Sleep(50 * time.Millisecond) { if e, err := b.builds.Get(b.ctx, key); err == nil { - if last, err = delivery.DecodeBuilderBuild(e.Value()); err == nil && pred(last) { + if last, err = builds.DecodeBuilderBuild(e.Value()); err == nil && pred(last) { return last } } @@ -655,24 +911,24 @@ func TestAnImageIsBuiltOnOneNodeAndOnlyATrunkKeyPublishes(t *testing.T) { // 170 t.Fatalf("builds.%s = %+v within 20 s", key, last) return last } - v := awaitKey(okey, isState(delivery.BuildDone)) + v := awaitKey(okey, isState(builds.BuildDone)) if len(v.Artifacts) != 1 || v.Artifacts[0].Digest != digestOf("server") || !strings.HasSuffix(v.Artifacts[0].Reference, "@"+digestOf("server")) { t.Fatalf("trunk build = %+v, want the digest and the reference", v) } // The candidate carries fp: built, not published. const head = "1111111111111111111111111111111111111111" - pulls := []delivery.CandidatePull{{Head: delivery.Head{Repository: "novox/mesh-catalog", Number: 7, Head: head}, Modules: []string{"imaged"}, - Fps: map[string]delivery.CandidateModule{"imaged": {FP2: fp, Manifest: digest}}}} - hash, _ := delivery.HeadsHash([]delivery.Head{pulls[0].Head}) - cand, err := delivery.EncodeCandidate(delivery.Candidate{V: delivery.Version, Group: "feat/x", Base: b.trunk(t, trunkHead), HeadsHash: hash, Pulls: pulls, FPSet: delivery.FingerprintSet(pulls)}) + pulls := []meshcatalog.CandidatesByGroupValuePullsItem{{Repository: "novox/mesh-catalog", Number: 7, Head: head, Modules: []string{"imaged"}, + Fps: map[string]meshcatalog.CandidatesByGroupValuePullsItemFpsValue{"imaged": {Fp2: fp, Manifest: digest}}}} + hash, _ := meshdeliveryclient.HeadsHash([]meshdeliveryclient.Head{{Repository: pulls[0].Repository, Number: int(pulls[0].Number), Head: pulls[0].Head}}) + cand, err := json.Marshal(meshcatalog.CandidatesByGroupValue{V: keys.Version, Group: "feat/x", Base: int64(b.trunk(t, trunkHead)), Heads: hash, Pulls: pulls, Fpset: meshcatalogclient.FingerprintSet(pulls)}) if err != nil { t.Fatal(err) } - ckey, _ := delivery.CandidatesKey("feat/x") + ckey := keys.EscapeName("feat/x") if _, err := b.candidates.Put(b.ctx, ckey, cand); err != nil { t.Fatal(err) } - v = b.await(t, "imaged", isState(delivery.BuildDone)) + v = b.await(t, "imaged", isState(builds.BuildDone)) if len(v.Artifacts) != 1 || v.Artifacts[0].Reference != "" { t.Fatalf("candidate build = %+v, want done with no reference", v) } @@ -683,8 +939,8 @@ func TestAnImageIsBuiltOnOneNodeAndOnlyATrunkKeyPublishes(t *testing.T) { // 170 b.fake.mu.Unlock() // The pull request is merged: the trunk's record names fp, and its key ends published, built once more with publish. trunk(fp) - v = b.await(t, "imaged", func(v delivery.BuilderBuild) bool { - return v.State == delivery.BuildDone && len(v.Artifacts) == 1 && v.Artifacts[0].Reference != "" + v = b.await(t, "imaged", func(v builds.BuilderBuild) bool { + return v.State == builds.BuildDone && len(v.Artifacts) == 1 && v.Artifacts[0].Reference != "" }) if v.Artifacts[0].Digest != digestOf("server") { t.Errorf("merged build = %+v", v) @@ -710,13 +966,13 @@ func TestEveryDoneKeyNamesItsReferencesAndARefusedPushIsBuiltAgain(t *testing.T) b.fake.refusePush.Store(true) b.archive(t, "ca-trust", "modules/ca-trust", commit) b.start(t, "n1", nil) - v := b.await(t, "ca-trust", isState(delivery.BuildFailed)) - if !strings.HasPrefix(v.Error, delivery.NotStored) || !strings.Contains(v.Error, "507") { - t.Fatalf("build = %+v, want failed opening %q with the store's words", v, delivery.NotStored) + v := b.await(t, "ca-trust", isState(builds.BuildFailed)) + if !strings.HasPrefix(v.Error, builds.NotStored) || !strings.Contains(v.Error, "507") { + t.Fatalf("build = %+v, want failed opening %q with the store's words", v, builds.NotStored) } b.fake.refusePush.Store(false) b.archive(t, "ca-trust", "modules/ca-trust", "89abcdef0123456789abcdef0123456789abcdef") - v = b.await(t, "ca-trust", isState(delivery.BuildDone)) + v = b.await(t, "ca-trust", isState(builds.BuildDone)) if ok, missing := v.Referenced(); !ok { t.Errorf("build = %+v, artifacts naming no reference: %v", v, missing) } @@ -729,7 +985,7 @@ func TestAPinnedUpstreamIsCopiedByTheImageBuildModule(t *testing.T) { b := startBed(t) b.moduleCommit(t, "hello-web", "catalogue-49caf17f-hello-web.module.json", "modules/hello-web", nil, commit) b.start(t, "n1", nil) - v := b.await(t, "hello-web", isState(delivery.BuildDone)) + v := b.await(t, "hello-web", isState(builds.BuildDone)) if ok, missing := v.Referenced(); !ok { t.Fatalf("build = %+v, artifacts naming no reference: %v", v, missing) } @@ -748,10 +1004,10 @@ func TestAManifestFallingBackToAPredefinedKeyIsBuilt(t *testing.T) { b := startBed(t) b.module(t, "claude-code", "catalogue-835bf22b-claude-code.module.json", "modules/claude-code") b.start(t, "n1", nil) - v := b.await(t, "claude-code", func(v delivery.BuilderBuild) bool { - return v.State == delivery.BuildDone || v.State == delivery.BuildFailed + v := b.await(t, "claude-code", func(v builds.BuilderBuild) bool { + return v.State == builds.BuildDone || v.State == builds.BuildFailed }) - if v.State != delivery.BuildDone { + if v.State != builds.BuildDone { t.Errorf("build = %+v, want done: the manifest is read", v) } } @@ -791,14 +1047,19 @@ func TestTheBuilderAsksTheBuildSeatsAndCallsNoNode(t *testing.T) { } defer sub.Close() var calls atomic.Int64 - s, err := sub.Subscribe("mesh.mod.*.call.>", func(*nats.Msg) { calls.Add(1) }) + // A build module's tool, never the owners' read tools the builder's first views ask (DLV-M1). + s, err := sub.Subscribe("mesh.mod.*.call.>", func(m *nats.Msg) { + if strings.HasPrefix(m.Subject, "mesh.mod.mesh-build-") { + calls.Add(1) + } + }) if err != nil { t.Fatal(err) } defer s.Unsubscribe() asked := b.asksPut(t, sub) b.start(t, "n1", nil) - v := b.await(t, "netcheck", isState(delivery.BuildDone)) + v := b.await(t, "netcheck", isState(builds.BuildDone)) for _, a := range v.Artifacts { if a.Reference == "" { t.Errorf("artifact %s names no reference", a.Name) @@ -814,7 +1075,7 @@ func TestTheBuilderAsksTheBuildSeatsAndCallsNoNode(t *testing.T) { t.Fatalf("asks put = %+v, want one Go and one Node delivery ask", got) } for _, a := range got { - if a.Kind != delivery.AskDelivery || a.Build == nil || a.Build.FP != fp { + if a.Kind != sdkstep.AskDelivery || a.Build == nil || a.Build.Fp != fp { t.Errorf("ask %+v, want a delivery ask of the fingerprint", a) } } @@ -822,12 +1083,12 @@ func TestTheBuilderAsksTheBuildSeatsAndCallsNoNode(t *testing.T) { // asksPut hears every ask put on a build seat's accept subjects (a core subscription beside the stream) and answers the // asks heard so far. -func (b *bed) asksPut(t *testing.T, nc *nats.Conn) func() []delivery.StepAsk { +func (b *bed) asksPut(t *testing.T, nc *nats.Conn) func() []sdkstep.StepAsk { t.Helper() var mu sync.Mutex - var asks []delivery.StepAsk + var asks []sdkstep.StepAsk s, err := nc.Subscribe("mesh.seat.*.accept.>", func(m *nats.Msg) { - if a, err := delivery.DecodeStepAsk(m.Data); err == nil { + if a, err := sdkstep.DecodeStepAsk(m.Data); err == nil { mu.Lock() asks = append(asks, a) mu.Unlock() @@ -837,10 +1098,10 @@ func (b *bed) asksPut(t *testing.T, nc *nats.Conn) func() []delivery.StepAsk { t.Fatal(err) } t.Cleanup(func() { _ = s.Unsubscribe() }) - return func() []delivery.StepAsk { + return func() []sdkstep.StepAsk { mu.Lock() defer mu.Unlock() - return append([]delivery.StepAsk(nil), asks...) + return append([]sdkstep.StepAsk(nil), asks...) } } @@ -856,7 +1117,7 @@ func TestARestartedBuilderPutsNoSecondAsk(t *testing.T) { defer sub.Close() asked := b.asksPut(t, sub) b.start(t, "n1", nil) - v := b.await(t, "netcheck", func(v delivery.BuilderBuild) bool { return v.State == delivery.BuildBuilding && len(v.Asks) == 2 }) + v := b.await(t, "netcheck", func(v builds.BuilderBuild) bool { return v.State == builds.BuildBuilding && len(v.Asks) == 2 }) for deadline := time.Now().Add(20 * time.Second); len(asked()) < 2; time.Sleep(20 * time.Millisecond) { if time.Now().After(deadline) { t.Fatalf("asks put = %+v within 20 s, want the two the key names", asked()) @@ -865,15 +1126,15 @@ func TestARestartedBuilderPutsNoSecondAsk(t *testing.T) { // 1945 note 62, on the real bus: the stream read as builder_seat_queue reads it shows the Go ask waiting; the ask can // still be answered (nothing was taken since); and a message dropped as an ask asked again is gone from the stream. held := heldOnTheBus{b.js} - goAsk := delivery.StepAsk{ID: v.Asks[delivery.AskGo], Language: delivery.AskGo, Kind: delivery.AskDelivery} - stale := delivery.StepAsk{ID: "mvo53jcm70b5f7", Language: delivery.AskGo, Kind: delivery.AskDelivery} - subject, _ := delivery.AskSubject(delivery.AskGo, delivery.AskDelivery) + goAsk := sdkstep.StepAsk{ID: v.Asks[sdkstep.AskGo], Language: sdkstep.AskGo, Kind: sdkstep.AskDelivery} + stale := sdkstep.StepAsk{ID: "mvo53jcm70b5f7", Language: sdkstep.AskGo, Kind: sdkstep.AskDelivery} + subject, _ := sdkstep.AskSubject(sdkstep.AskGo, sdkstep.AskDelivery) if _, err := b.js.Publish(b.ctx, subject, []byte("{}"), jetstream.WithMsgID(stale.ID)); err != nil { t.Fatal(err) } queued := func() []build.QueuedState { - q, err := held.Queue(b.ctx, delivery.AskGo, 10) - if err != nil || len(q.Kinds) != 1 || q.Kinds[0].Kind != delivery.AskDelivery { + q, err := held.Queue(b.ctx, sdkstep.AskGo, 10) + if err != nil || len(q.Kinds) != 1 || q.Kinds[0].Kind != sdkstep.AskDelivery { t.Fatalf("the Go seat's queue = %+v, %v; want its delivery asks", q, err) } return q.Kinds[0].Messages @@ -893,7 +1154,7 @@ func TestARestartedBuilderPutsNoSecondAsk(t *testing.T) { b.stop() b.start(t, "n1", nil) // the same builder, started again, while both asks wait b.fake.open() - done := b.await(t, "netcheck", isState(delivery.BuildDone)) + done := b.await(t, "netcheck", isState(builds.BuildDone)) if got := asked(); len(got) != 2 { t.Fatalf("asks put = %d (%+v), want the two the key named and none again", len(got), got) } @@ -910,3 +1171,179 @@ func TestARestartedBuilderPutsNoSecondAsk(t *testing.T) { t.Errorf("done = %+v", done) } } + +// registry is the part of the bed design 51's builder cases need: the builder's states versions, findings, floor-waits and +// reasks, EVENTS with the seat oci-artifact-registry's outcomes and the builder's durable consumer of it, the builder's +// own events kept, and the floor's three sources served on the bus as their holders answer through the tool runner +// ({result}): the seat's versions, the forge bridge's bridge_tags and the indexer's indexer_requirements. +type registry struct { + b *bed + versions, findings, reasks jetstream.KeyValue + mu sync.Mutex + numbered []builds.VersionNumbered + removes []map[string]any +} + +func (b *bed) registry(t *testing.T, node string, seatVersions ...string) *registry { + t.Helper() + r := ®istry{b: b} + mk := func(name string) jetstream.KeyValue { + kv, err := b.js.CreateKeyValue(b.ctx, jetstream.KeyValueConfig{Bucket: name, History: 1}) + if err != nil { + t.Fatal(err) + } + return kv + } + r.versions, r.findings, r.reasks = mk(versions.Bucket), mk(findingsBucket), mk(build.ReAsksBucket) + mk(versions.FloorWaitsBucket) + // EVENTS with two seats' outcomes, and the forge bridge's stream (a branch's end): each with the builder's durable consumer. + for stream, subjects := range map[string][]string{ + eventsStream: {"mesh.mod.*.event.>", "mesh.seat.*.event.>"}, + forgeStream: {process.ModuleStreamSubject(forgeBridgeModule, ">")}, + } { + if _, err := b.js.CreateOrUpdateStream(b.ctx, jetstream.StreamConfig{Name: stream, Subjects: subjects}); err != nil { + t.Fatal(err) + } + if _, err := b.js.CreateOrUpdateConsumer(b.ctx, stream, jetstream.ConsumerConfig{Durable: node + "_" + builds.BuilderModule, + AckPolicy: jetstream.AckExplicitPolicy}); err != nil { + t.Fatal(err) + } + } + // The Go seat's remove, as its holder answers it; each call is kept. + rm, err := b.js.Conn().Subscribe("mesh.seat."+gopackageregistry.Seat+".call.remove.>", func(m *nats.Msg) { + var args map[string]any + _ = json.Unmarshal(m.Data, &args) + r.mu.Lock() + r.removes = append(r.removes, args) + r.mu.Unlock() + _ = m.Respond([]byte(`{"result":{},"node":"ctl"}`)) + }) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = rm.Unsubscribe() }) + nc := b.js.Conn() + // As a build module hears it: on the contract's subject, read by the contract's decoder (BG2's late publish). + sub, err := nc.Subscribe(builds.VersionNumberedSubject, func(m *nats.Msg) { + if nb, err := builds.DecodeVersionNumbered(m.Data); err == nil { + r.mu.Lock() + r.numbered = append(r.numbered, nb) + r.mu.Unlock() + } + }) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = sub.Unsubscribe() }) + var listed []map[string]string + for _, v := range seatVersions { + listed = append(listed, map[string]string{"version": v}) + } + for subject, answer := range map[string]any{ + "mesh.seat." + ociartifactregistry.Seat + ".call.versions.>": map[string]any{"versions": listed}, + "mesh.mod.mesh-delivery-forge-bridge.call.bridge_tags.>": map[string]any{"tags": []string{}}, + "mesh.mod." + indexerModule + ".call.indexer_requirements.>": map[string]any{"requirements": []string{}}, + } { + reply, _ := json.Marshal(map[string]any{"result": answer, "node": "ctl"}) + s, err := nc.Subscribe(subject, func(m *nats.Msg) { _ = m.Respond(reply) }) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = s.Unsubscribe() }) + } + return r +} + +// of is every entry versions holds of name. +func (r *registry) of(t *testing.T, name string) []versions.Entry { + t.Helper() + l, err := r.versions.ListKeysFiltered(r.b.ctx, versions.NameFilter(name)) + if err != nil { + t.Fatal(err) + } + defer func() { _ = l.Stop() }() + var out []versions.Entry + for k := range l.Keys() { + e, err := r.versions.Get(r.b.ctx, k) + if err != nil { + t.Fatal(err) + } + var v versions.Entry + if err := json.Unmarshal(e.Value(), &v); err != nil { + t.Fatal(err) + } + out = append(out, v) + } + return out +} + +// awaitOf reads name's entries until pred holds of them, within 20 s. +func (r *registry) awaitOf(t *testing.T, name string, pred func([]versions.Entry) bool) []versions.Entry { + t.Helper() + var es []versions.Entry + for deadline := time.Now().Add(20 * time.Second); time.Now().Before(deadline); time.Sleep(50 * time.Millisecond) { + if es = r.of(t, name); pred(es) { + return es + } + } + t.Fatalf("versions of %s = %+v within 20 s", name, es) + return es +} + +// published says the artifact seat's published for name@version, as its holder does. +func (r *registry) published(t *testing.T, name, version, digest string) { + t.Helper() + r.outcome(t, ociartifactregistry.SubjectPublished, versions.Outcome{Name: name, Version: version, Digest: digest, Kind: "bundle", Holder: "distribution"}) +} + +// outcome says a seat's outcome on subject, as its holder does. +func (r *registry) outcome(t *testing.T, subject string, o versions.Outcome) { + t.Helper() + data, _ := json.Marshal(o) + msg := nats.NewMsg(subject) + msg.Header.Set("x-event-id", subject+"-"+o.Name+"-"+o.Version) + msg.Data = data + if _, err := r.b.js.PublishMsg(r.b.ctx, msg); err != nil { + t.Fatal(err) + } +} + +// answered writes a reasks value and reads it until the builder answered it, within 20 s. +func (r *registry) answered(t *testing.T, key string, value []byte) build.ReAsk { + t.Helper() + if _, err := r.reasks.Put(r.b.ctx, key, value); err != nil { + t.Fatal(err) + } + var a build.ReAsk + for deadline := time.Now().Add(20 * time.Second); time.Now().Before(deadline); time.Sleep(50 * time.Millisecond) { + if e, err := r.reasks.Get(r.b.ctx, key); err == nil && json.Unmarshal(e.Value(), &a) == nil && !a.Answered.IsZero() { + return a + } + } + t.Fatalf("reasks.%s = %+v, not answered within 20 s", key, a) + return a +} + +// awaitFinding reads the builder's finding key until it is there (raised) or gone, within 20 s. +func (r *registry) awaitFinding(t *testing.T, key string, raised bool) { + t.Helper() + for deadline := time.Now().Add(20 * time.Second); time.Now().Before(deadline); time.Sleep(50 * time.Millisecond) { + if _, err := r.findings.Get(r.b.ctx, key); (err == nil) == raised { + return + } + } + t.Fatalf("the finding %s raised = %v within 20 s, want %v", key, !raised, raised) +} + +// numberedOf are the version.numbered the builder said for name, in order. +func (r *registry) numberedOf(name string) []string { + r.mu.Lock() + defer r.mu.Unlock() + var out []string + for _, nb := range r.numbered { + if nb.Name == name { + out = append(out, nb.Version) + } + } + return out +} diff --git a/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/main.go b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/main.go index bdfdb4a3..ff15f021 100644 --- a/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/main.go +++ b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/main.go @@ -5,6 +5,7 @@ package main import ( + "cmp" "context" "fmt" "io" @@ -18,15 +19,25 @@ import ( "syscall" "time" + sdkfindings "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient/findings" + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun" + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds/forms" "github.com/nats-io/nats.go" "github.com/nats-io/nats.go/jetstream" - "git.novox.be/novox/mesh-sdk/go/delivery" + "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliveryclient" + sweep "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshartifactsweep" + "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds" + "git.novox.be/novox/mesh-delivery/sdk/buildstep" + "git.novox.be/novox/mesh-delivery/sdk/trunk" "git.novox.be/novox/mesh-sdk/go/process" + "git.novox.be/novox/mesh-sdk/go/views" "mesh-delivery-builder/internal/build" + "mesh-delivery-builder/internal/versions" ) const ( @@ -57,26 +68,60 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro return err } defer conn.Close() - builds, err := process.EssentialBucket(ctx, js, "builder", delivery.BuilderBuildsBucket, "claim or write a build key") + buildsKV, err := process.EssentialBucket(ctx, js, "builder", builds.BuilderBuildsBucket, "claim or write a build key") if err != nil { return err } - modules, err := process.EssentialBucket(ctx, js, "builder", delivery.ModulesBucket, "know any fingerprint to build") + // The indexer's modules, manifests, candidates and packages and the assigner's assignments are their owners' states, read by the + // first view of their read tools and then their views families, never their buckets (ADR 0615 §1, DLV-M1). An owner that + // is down never holds the start: each is read on a goroutine once its first view is answered, and until then the + // unanswered first view is the builder's finding. + calls, err := process.Connect(cred, nats.Name(cred.Node+"."+cred.Module+".calls"), nats.RetryOnFailedConnect(true)) if err != nil { return err } - manifests, err := process.EssentialBucket(ctx, js, "builder", delivery.ManifestsBucket, "read what a module builds") + defer calls.Close() + // The module has one durable consumer of EVENTS: one hub pulls it and hands each message to the part that hears it (the + // views reader and the builder's own events), started once every part is registered. + hub := process.NewEventHub(logger.Printf) + reader := &views.ProcessReader{Module: builds.BuilderModule, Call: calls.Call, Logf: logger.Printf, Hub: hub} + findingsKV := process.AwaitBucket(ctx, js, conn, findingsBucket, "the builder's findings, a first view unanswered past its bound among them, are said in its log only", logger.Printf) + reader.Findings = func() views.FindingWriter { return sdkfindings.Writer(ctx, findingsKV.KV) } + modules, err := reader.Follow(indexerModule, modulesState, indexerModule+".indexer_read_modules") + if err != nil { + return err + } + manifests, err := reader.Follow(indexerModule, manifestsState, indexerModule+".indexer_read_manifests") + if err != nil { + return err + } + // The candidates' fingerprints are built too (issue 1684). + candidates, err := reader.Follow(indexerModule, candidatesState, indexerModule+".indexer_read_candidates") + if err != nil { + return err + } + // The indexer's packages, whose names a package is numbered under (design 32). + packages, err := reader.Follow(indexerModule, meshcatalog.StatePackages, indexerModule+".indexer_read_packages") + if err != nil { + return err + } + assignments, err := reader.Follow(assignerModule, "assignments", assignerModule+".assigner_read_assignments") if err != nil { return err } - // The candidates' fingerprints are built too (issue 1684): a builder that cannot read them builds the trunk's. - candidates := process.OptionalBucket(ctx, js, delivery.CandidatesBucket, "no candidate's fingerprint is built until the builder starts again; the trunk's are", logger.Printf) bound := defaultStepBound if v := strings.TrimSpace(getenv("MESH_BUILDER_STEP_BOUND")); v != "" { if bound, err = time.ParseDuration(v); err != nil { return fmt.Errorf("MESH_BUILDER_STEP_BOUND: %w", err) } } + // An unanswered tag ask is narrated every TagAskBound (design 51: five minutes); a test gives a shorter one. + tagAskBound := versions.TagAskBound + if v := strings.TrimSpace(getenv("MESH_BUILDER_TAG_ASK_BOUND")); v != "" { + if tagAskBound, err = time.ParseDuration(v); err != nil { + return fmt.Errorf("MESH_BUILDER_TAG_ASK_BOUND: %w", err) + } + } tools := &buildrun.ToolchainSet{} // Each build module's part of a build is an ask on its build seat's stream, pulled by a build node with a free slot // (novox/hq ADR 0525): the builder chooses no node, so it reads neither presence nor have and holds no capacity. @@ -88,29 +133,48 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro dead := &buildrun.DeadLetters{} // The state may not be on the bus yet when a delivery started the builder (the grants part creates it, issue 1790): it // is bound when the bus says it was created, and the latest hearing is written then. - hearingKV := process.AwaitBucket(ctx, js, conn, delivery.BuilderModule+"_"+buildrun.HearingState, "builder_stuck cannot say whether DEAD_LETTERS is heard", logger.Printf) - hearing := buildrun.NewDeadLetterHearing(ctx, buildrun.JetStreamDeadLetters{JS: js}, delivery.AskLanguages, dead, logger.Printf, + hearingKV := process.AwaitBucket(ctx, js, conn, builds.BuilderModule+"_"+buildrun.HearingState, "builder_stuck cannot say whether DEAD_LETTERS is heard", logger.Printf) + hearing := buildrun.NewDeadLetterHearing(ctx, buildrun.JetStreamDeadLetters{JS: js}, buildstep.AskLanguages, dead, logger.Printf, buildrun.SayHearing(ctx, hearingKV, logger.Printf)) defer hearing.Stop() // Each ask's step is heard by its own watch and, at every step end of its build module and every reconnect, read // (buildrun.HearEnds): a watch that goes deaf loses no end (the lost check of 2026-10-28). var buildModules []string - for _, l := range delivery.AskLanguages { - if m, err := delivery.BuildModuleOf(l); err == nil { + for _, l := range buildstep.AskLanguages { + if m, err := buildstep.BuildModuleOf(l); err == nil { buildModules = append(buildModules, m) } } heard := buildrun.HearEnds(ctx, asks, asks, buildModules, logger.Printf) conn.SetReconnectHandler(func(*nats.Conn) { hearing.Again(); heard.Nudge("") }) // The builder asks the controller, as its own account, whether the artifact store still holds a done key's blobs - // (the seat verb held, novox/hq issue 1937) before it leaves the key done. - calls, err := process.Connect(cred, nats.Name(cred.Node+"."+cred.Module+".calls"), nats.RetryOnFailedConnect(true)) - if err != nil { - return err - } - defer calls.Close() + // (the seat verb held, novox/hq issue 1937) before it leaves the key done, on its calls connection. + // The builder numbers each trunk build at its claim (novox/hq design 51 *The versions*): its state versions, bound once + // the bus holds it; the floor's three sources asked as its own account; each new number said as version.numbered. + versionsKV := process.AwaitBucket(ctx, js, conn, versions.Bucket, "no trunk build is numbered: each is built unnumbered and published by its digest", logger.Printf) + found := &findings{kv: findingsKV, logf: logger.Printf, now: time.Now} + floorWaits := &versions.FloorWaits{Logf: logger.Printf, Said: found.floorSaid(context.WithoutCancel(ctx))} + // The names waiting on their floor are kept in the state floor-waits (DLV-B4): a restarted builder takes them in, so + // their silent sources' events still wake them; until the state is bound they are kept here only. + process.AwaitBucket(ctx, js, conn, versions.FloorWaitsBucket, "the names waiting on their floor are kept in memory only, and builder_floor_waits shows none", logger.Printf). + Ready(func(kv jetstream.KeyValue) { + floorWaits.Keep(kvKeeper{ctx: ctx, kv: kv}, heldFloorWaits(ctx, kv, logger.Printf)) + }) + // A package is numbered under its name in its ecosystem, read from the indexer's packages state (design 32). + numberer := &versions.Numberer{Versions: versions.Versions{Store: awaitedVersions{versionsKV}}, Floor: floorVia(calls.Call), Waits: floorWaits, + Names: versions.NamesIn(indexerPackages{packages}), + Said: func(nb builds.VersionNumbered) { + if err := emitNumbered(context.WithoutCancel(ctx), js, cred, nb); err != nil { + logger.Printf("%v", err) + } + }} + // A publish that ends badly is a finding (design 51 *Health and conditions*): a holder's refused, a version no holder + // answered within its bound (a key of the state bounds with its own time to live), a seat's outcome that does not read. + pubBoundsKV := process.AwaitBucket(ctx, js, conn, versions.BoundsBucket, "a publish no holder answers, and a node holding the old form seven days, are not raised", logger.Printf) + pubs := &publishes{bounds: pubBoundsKV.KV, versions: numberer.Versions, findings: found, bound: versions.PublishBound, logf: logger.Printf} + numberer.Publishing = pubs.arm b := &build.Builder{ - Claimer: build.Claimer{Store: kvStore{builds}, By: cred.Node + "." + cred.Module}, + Claimer: build.Claimer{Store: kvStore{buildsKV}, By: cred.Node + "." + cred.Module}, Asks: buildrun.Asker{Publisher: asks, Waiter: buildrun.LostAskWaiter{Inner: heard, Dead: dead, Hearing: hearing}, Bound: bound}, Held: heldOnTheBus{js}, // An ask that can no longer be answered is asked again (issue 1945), read at the step bound: a context's bound. @@ -122,20 +186,52 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro Stored: storedVia(calls.Call), Node: cred.Node, } + tagAsksKV := process.AwaitBucket(ctx, js, conn, versions.TagAsksBucket, "builder_tag and builder_untag are not answered", logger.Printf) + // A check's build of a head a kept tag ask names numbers its packages tagged (design 51 *Tagged versions*), and the ask + // is marked numbered with its versions. + b.Tags = tagsVia(tagAsksKV.KV, logger.Printf) + b.Numbers = build.VersionNumbers{N: numberer, TagNumbered: tagNumbered(tagAsksKV.KV, logger.Printf, time.Now)} + // An ask not answered within five minutes is narrated with what it waits on: its bound's end on the bus says when. + boundsKV := process.AwaitBucket(ctx, js, conn, versions.TagAskBoundsBucket, "an unanswered tag ask is not narrated", logger.Printf) + bounds := &tagBounds{asks: tagAsksKV.KV, bounds: boundsKV.KV, bound: tagAskBound, waits: floorWaits.Waiting, logf: logger.Printf, now: time.Now, + log: func(e builds.BuildLogEntry) { b.Log(e) }} + // A state bound later numbers the builds looked at without it. + versionsKV.Ready(func(jetstream.KeyValue) { b.Reconsider(ctx) }) + // The versions' hand acts (builder_publish_again, builder_floor_again) come through reasks, as builder_build_again. + acts := versions.Acts{Numberer: numberer, Done: doneVia(b), Reconsider: b.Reconsider, Remove: removeVia(calls.Call), + BuildAgain: func(ctx context.Context, module, fp, why, by string) (string, bool) { + r := b.BuildAgain(ctx, module, fp, why, by, buildAgainBound) + if r.Refused != "" { + return r.Refused, true + } + if r.Failed != "" { + return "built again, and it failed before its asks were put: " + r.Failed, false + } + return fmt.Sprintf("built again as revision %d", r.Revision), false + }} // A candidate's preview is merged onto the trunk head the indexer previewed it onto: source.main at the candidate's base // revision (the gate's source state). The source's revisions are watched with their history (a direct get of one revision // is not what a read grants): a revision heard after the candidate that names it builds that candidate then. Where it // cannot be read the trunk's keys are built and the candidates wait (issue 1945). - errs := make(chan error, 6) - trunks := &trunkSource{ctx: ctx, js: js, heads: &sourceHeads{}, moved: b.TrunkMoved, errs: errs, logf: logger.Printf} + errs := make(chan error, 10) + trunks := &trunkSource{ctx: ctx, js: js, heads: &sourceHeads{}, moved: func(ctx context.Context) { + // A source revision heard is the forge's merge: it wakes a floor whose bridge_tags was silent (design 51 *The floor*). + (&floorWake{b: b, waits: floorWaits, logf: logger.Printf}).wake(ctx, versions.WakeForge, "", "") + b.TrunkMoved(ctx) + }, errs: errs, logf: logger.Printf} trunks.start() b.Trunk = trunks.head // The builder's log (ADR 0571 §1): each build's start and end in its module log `build-log`, read by the view, the // indexer and the assigner through their reads, and by the operator's command nox builds through the tool builder_builds_since. - b.Log = func(e delivery.BuildLogEntry) { + b.Log = func(e builds.BuildLogEntry) { + if e.Node == "" { // a narration of no one build (a tag ask, the old form's window) is this instance's + e.Node = cred.Node + } if err := appendLog(context.WithoutCancel(ctx), js, cred, e); err != nil { logger.Printf("%v", err) } + // A failed trunk build is the builder's finding build-failed, cleared by a later done one (DLV-B7). + found.buildEnded(context.WithoutCancel(ctx), e) } b.Said = func(e build.AskedAgain) { if err := emitAskedAgain(context.WithoutCancel(ctx), js, cred, e); err != nil { @@ -162,45 +258,232 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro // The toolchains first, so a build that starts from the first modules value finds the nodes. watch := newToolchainWatch(ctx, js, tools, b.NodesChanged, logger) // Only the toolchain reports of nodes the catalogue places the build module on count (issue 1940, ADR 0549 §3): the - // placement is settled first, so the toolchains settle through the gate already knowing it. + // assignments is settled first, so the toolchains settle through the gate already knowing it. gate := buildrun.NewToolchainGate([]string{buildrun.GoBuildModule, buildrun.NodeBuildModule}, tools.Put, logger.Printf) watch.put = gate.Toolchain - placementErrs, err := buildrun.FollowPlacement(ctx, js, gate, b.NodesChanged, logger.Printf) - if err != nil { - return err + // What each counted node's engine reads (ADR 0618 §2, ADR 0624 §1): asked through the node-engine seat's verb as the + // builder's own account, for every node the assignments names, and kept in the state engine-reads once it is bound. + reads := newEngineReads(calls.CallOn, gate, logger.Printf) + // The old reference form's window over them (DLV-B9), kept in the state old-form once it is bound; builder_old_form + // reads it and drops a retired node by hand. + reads.ctx = context.WithoutCancel(ctx) + reads.window = &oldForm{forms: forms.Current, findings: found, logf: logger.Printf, now: time.Now, log: func(e builds.BuildLogEntry) { b.Log(e) }, + bounds: pubBoundsKV.KV, + said: func(body versions.OldFormClosedBody) { + if err := emitEvent(context.WithoutCancel(ctx), js, cred, versions.OldFormClosedEvent, "old-form-closed-"+body.At.Format(time.RFC3339Nano), body); err != nil { + logger.Printf("saying %s: %v", versions.OldFormClosedEvent, err) + } + }} + acts.DropOldForm = func(ctx context.Context, node, by, why string, at time.Time) (string, error) { + answer, err := reads.window.drop(ctx, node, by, why, at) + if err == nil { + reads.stepWindow() + } + return answer, err } + process.AwaitBucket(ctx, js, conn, versions.OldFormBucket, "the old form's window is kept in memory only, and builder_old_form shows none", logger.Printf). + Ready(func(kv jetstream.KeyValue) { + reads.window.bind(context.WithoutCancel(ctx), kv) + reads.stepWindow() + }) + gate.OnPlaced = reads.placed + b.Forms = reads.built + // Until the assignments's first view is answered the gate passes every report on, as before the assignments is settled. + assignmentsErrs := make(chan error, 1) + go func() { + select { + case <-ctx.Done(): + return + case <-assignments.Follower().Ready(): + } + pe, err := buildrun.FollowAssignments(ctx, reader, gate, b.NodesChanged, logger.Printf) + if err != nil { + assignmentsErrs <- err + return + } + assignmentsErrs <- <-pe + }() if err := watch.start(); err != nil { return err } // A build module says each time it wrote its toolchains key: its stream, and the builder's durable consumer on it, are // made by the controller. Where the stream is not there yet the builder says so and is started again with the build // module. + // Each consumer that is not there yet is the finding family-not-heard naming its stream until it binds at a later + // start (heard.Writer; ADR 0615 §5); one there that cannot be heard stops the builder, as before. + notHeard := found.notHeard(ctx) + var stopped error + hear := func(stream, consequence string, each func(jetstream.Msg)) { + _ = notHeard.Bind(stream, consequence, func() error { + cons, err := cred.EventConsumer(ctx, js, stream) + if err != nil { + return fmt.Errorf("the consumer %s_%s on %s does not exist yet: %w", cred.Node, cred.Module, stream, err) + } + cc, err := process.HearEvents(cons, each, logger.Printf) + if err != nil { + stopped = cmp.Or(stopped, err) + return err + } + go func() { <-ctx.Done(); cc.Stop() }() + return nil + }) + } for _, module := range []string{buildrun.GoBuildModule, buildrun.NodeBuildModule} { - stream := toolchainStreams[module] - cons, err := cred.EventConsumer(ctx, js, stream) - if err != nil { - logger.Printf("the consumer %s_%s on %s does not exist yet, so %s's reports are not heard until the builder starts again: %v", cred.Node, cred.Module, stream, module, err) - continue - } - cc, err := process.HearEvents(cons, func(m jetstream.Msg) { watch.Reported(m) }, logger.Printf) - if err != nil { - return err - } - defer cc.Stop() + hear(toolchainStreams[module], module+"'s toolchain reports are not heard until the builder starts again", func(m jetstream.Msg) { watch.Reported(m) }) } // The seat mesh-artifact-sweep says artifacts-gone when it found a key's blob gone (issue 1937, ADR 0571 §2): the // builder's durable consumer on EVENTS, made by the grants part from the manifest's consumes. Where it is not there yet the builder says // so; it still asks held at its start and at every module value. - switch cons, err := cred.EventConsumer(ctx, js, eventsStream); { - case err != nil: - logger.Printf("the consumer %s_%s on %s does not exist yet, so %s is not heard until the builder starts again: %v", - cred.Node, cred.Module, eventsStream, delivery.ArtifactsGoneEvent, err) - default: - cc, err := process.HearEvents(cons, func(m jetstream.Msg) { heardGone(ctx, b, m, logger.Printf) }, logger.Printf) - if err != nil { - return err + // The same consumer carries what wakes a floor (design 51 *The floor*): the indexer's manifest-accepted and the + // condition keeper's condition-cleared. + floor := floorWake{b: b, waits: floorWaits, logf: logger.Printf} + // And the four registry seats' published, refused and removed (DLV-B4), which move a numbered version. + seats := seatHearer{n: numberer, b: b, logf: logger.Printf, pub: pubs} + events := func(m jetstream.Msg) { + if reads.heard(m, logger.Printf) || floor.heard(ctx, m) || seats.heard(ctx, m) { // reads: the node-engine seat's manifest-reads-changed (ADR 0618 §4) + return + } + heardGone(ctx, b, m, logger.Printf) + } + // The seats' events (artifacts-gone, the registry seats' words, manifest-accepted and manifest-reads-changed and the + // seat mesh-conditions' condition-cleared, which this pattern covers: a consumer takes no two overlapping filters); the + // owners' views events are the views reader's. + if err := hub.Handle("mesh.seat.*.event.>", events); err != nil { + return err + } + if stopped != nil { + return stopped + } + reader.Start(ctx, js, cred, process.AwaitBucket(ctx, js, conn, firstViewsBucket, "builder_first_views reads nothing and builder_first_view_again is not heard", logger.Printf)) + // Every part has registered: the hub pulls the module's durable. Where the consumer is not there yet the builder says so + // and binds it at a later change (heard.Writer; ADR 0615 §5). + _ = notHeard.Bind(eventsStream, sweep.EventArtifactsGone+", what wakes a floor, the registry seats' words, the views and manifest-reads-changed are not heard until the builder starts again", func() error { + return hub.Start(ctx, js, cred) + }) + go func() { <-ctx.Done(); hub.Stop() }() + // The modules, once their first views and the manifests' are answered: what a module builds is read from the manifests. + go func() { errs <- buildFromModules(ctx, b, modules, manifests, candidates) }() + // A package's name heard in the indexer's packages looks at its module again, so a done build is numbered then. + go func() { errs <- followPackages(ctx, packages, b, logger.Printf) }() + // A person asks a key's build again with builder_ask_again, which writes the reasks state (issue 1945): watched, as + // every other input. The builder asks again by itself without it. + // A state not on the bus yet at this start is watched once it appears (issue 1790). + process.AwaitBucket(ctx, js, conn, build.ReAsksBucket, "builder_ask_again is not heard; an ask that can no longer be answered is still asked again by itself", logger.Printf).Ready(func(kv jetstream.KeyValue) { + eachReAsk := func(e jetstream.KeyValueEntry) { reAsked(ctx, b, acts, kv, e, logger.Printf) } + if rw, err := settle(ctx, kv, eachReAsk); err != nil { + process.NotWatched(logger.Printf, build.ReAsksBucket, err, "builder_ask_again is not heard until the builder starts again") + } else { + go func() { errs <- follow(ctx, rw, eachReAsk) }() + } + }) + // A tag ask or a branch's removal by hand (builder_tag, builder_untag) is written to tag-asks and answered there + // (design 51 *Tagged versions*); the checker's tag-wanted and the branch's end join it on their consumers (DLV-B6). + tagAsksKV.Ready(func(kv jetstream.KeyValue) { + each := func(e jetstream.KeyValueEntry) { + tagAsked(ctx, acts, kv, e, logger.Printf) + if !opOf(e) { + bounds.disarm(ctx, e.Key()) + } else if t, err := versions.DecodeTagAsk(e.Value()); err == nil { + bounds.asked(ctx, e.Key(), t) + } + } + if tw, err := settle(ctx, kv, each); err != nil { + process.NotWatched(logger.Printf, versions.TagAsksBucket, err, "builder_tag and builder_untag are not answered until the builder starts again") + } else { + go func() { errs <- follow(ctx, tw, each) }() + } + }) + pubBoundsKV.Ready(func(kv jetstream.KeyValue) { + each := func(e jetstream.KeyValueEntry) { + if versions.IsOldFormBound(e.Key()) { + if e.Operation() == jetstream.KeyValuePurge { + reads.stepWindow() // a node's seven days ended: the step raises old-form-held now + } + return + } + pubs.ended(ctx, e) + } + watchBounds(ctx, kv, each, found.notHeard(ctx), errs, logger.Printf) + }) + boundsKV.Ready(func(kv jetstream.KeyValue) { + each := func(e jetstream.KeyValueEntry) { bounds.ended(ctx, e) } + if bw, err := settle(ctx, kv, each); err != nil { + process.NotWatched(logger.Printf, versions.TagAskBoundsBucket, err, "an unanswered tag ask is not narrated until the builder starts again") + } else { + go func() { errs <- follow(ctx, bw, each) }() + } + }) + // A person reads a build seat's stream with builder_seat_queue, which writes the queues state (issue 1945 note 62): the + // builder alone may read the stream, so it answers there. + process.AwaitBucket(ctx, js, conn, build.QueuesBucket, "builder_seat_queue is not answered", logger.Printf).Ready(func(kv jetstream.KeyValue) { + eachRead := func(e jetstream.KeyValueEntry) { queueRead(ctx, heldOnTheBus{js}, kv, e, logger.Printf) } + if qw, err := settle(ctx, kv, eachRead); err != nil { + process.NotWatched(logger.Printf, build.QueuesBucket, err, "builder_seat_queue is not answered until the builder starts again") + } else { + go func() { errs <- follow(ctx, qw, eachRead) }() + } + }) + // A branch's end removes its tagged versions (design 51 *Tagged versions*, DLV-B6): the forge bridge's merged, a pull's + // closed or branch-deleted on the builder's durable consumer of its stream; each forge message also wakes a floor whose + // bridge_tags was silent. The checker's tag-wanted is kept in tag-asks. A consumer not there yet is said. + ends := &branchEnds{acts: acts, findings: found, tagAsks: tagAsksKV.KV, logf: logger.Printf, now: time.Now, + wake: func(ctx context.Context) { + (&floorWake{b: b, waits: floorWaits, logf: logger.Printf}).wake(ctx, versions.WakeForge, "", "") + }} + for _, c := range []struct { + stream, what string + hear func(jetstream.Msg) + }{ + {forgeStream, "a branch's end", func(m jetstream.Msg) { ends.heard(ctx, m) }}, + {checkerAsksStream, "the checker's tag-wanted", func(m jetstream.Msg) { + tagWanted(ctx, tagAsksKV.KV(), m, checkerModule, time.Now(), logger.Printf) + }}, + } { + hear(c.stream, c.what+" is not heard until the builder starts again", c.hear) + } + if stopped != nil { + return stopped + } + // The state engine-reads, new with ADR 0618 (issue 1790's kind): until it is on the bus each statement is kept in memory + // only, and builder_engine_reads answers nothing. + process.AwaitBucket(ctx, js, conn, engineReadsBucket, "what each node's engine reads is kept in memory only", logger.Printf).Ready(func(kv jetstream.KeyValue) { + reads.bind(ctx, kv, errs, logger.Printf) + }) + logger.Printf("started on %s", cred.Node) + select { + case <-ctx.Done(): + b.Wait() + return nil + case err := <-errs: + return err + case err := <-assignmentsErrs: + return err + case err := <-watch.errs: + return err + } +} + +// The builder's own state of its first views (views.StatusState); its findings (findingsBucket) are where the condition +// keeper raises a first view unanswered past its bound (ADR 0615 §6). +var firstViewsBucket = builds.BuilderModule + "_" + views.StatusState + +// The indexer's states the builder follows (indexerBucket(modulesState), ManifestsBucket, CandidatesBucket), and the owners' +// views families as their manifests' `streams` name them. +const ( + modulesState = "modules" + manifestsState = "manifests" + candidatesState = "candidates" +) + +// buildFromModules builds from the indexer's modules and candidates once the first views of the modules, the manifests and +// the candidates are answered (never at the start: an indexer that is down holds no other part of it), and follows them. +func buildFromModules(ctx context.Context, b *build.Builder, modules, manifests, candidates *views.Picture) error { + for _, p := range []*views.Picture{modules, manifests, candidates} { + select { + case <-ctx.Done(): + return nil + case <-p.Follower().Ready(): } - defer cc.Stop() } each := func(e jetstream.KeyValueEntry) { if opOf(e) { @@ -225,52 +508,16 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro b.ForgetCandidate(e.Key()) } } - if candidates != nil { - cw, err := settle(ctx, candidates, eachCandidate) - if err != nil { - process.NotWatched(logger.Printf, delivery.CandidatesBucket, err, "no candidate's fingerprint is built until the builder starts again") - } else { - go func() { errs <- follow(ctx, cw, eachCandidate) }() - } - } - // A person asks a key's build again with builder_ask_again, which writes the reasks state (issue 1945): watched, as - // every other input. The builder asks again by itself without it. - // A state not on the bus yet at this start is watched once it appears (issue 1790). - process.AwaitBucket(ctx, js, conn, build.ReAsksBucket, "builder_ask_again is not heard; an ask that can no longer be answered is still asked again by itself", logger.Printf).Ready(func(kv jetstream.KeyValue) { - eachReAsk := func(e jetstream.KeyValueEntry) { reAsked(ctx, b, kv, e, logger.Printf) } - if rw, err := settle(ctx, kv, eachReAsk); err != nil { - process.NotWatched(logger.Printf, build.ReAsksBucket, err, "builder_ask_again is not heard until the builder starts again") - } else { - go func() { errs <- follow(ctx, rw, eachReAsk) }() - } - }) - // A person reads a build seat's stream with builder_seat_queue, which writes the queues state (issue 1945 note 62): the - // builder alone may read the stream, so it answers there. - process.AwaitBucket(ctx, js, conn, build.QueuesBucket, "builder_seat_queue is not answered", logger.Printf).Ready(func(kv jetstream.KeyValue) { - eachRead := func(e jetstream.KeyValueEntry) { queueRead(ctx, heldOnTheBus{js}, kv, e, logger.Printf) } - if qw, err := settle(ctx, kv, eachRead); err != nil { - process.NotWatched(logger.Printf, build.QueuesBucket, err, "builder_seat_queue is not answered until the builder starts again") - } else { - go func() { errs <- follow(ctx, qw, eachRead) }() - } - }) - logger.Printf("started on %s", cred.Node) - go func() { errs <- follow(ctx, w, each) }() - select { - case <-ctx.Done(): - b.Wait() - return nil - case err := <-errs: - return err - case err := <-placementErrs: - return err - case err := <-watch.errs: + cw, err := settle(ctx, candidates, eachCandidate) + if err != nil { return err } + go func() { _ = follow(ctx, cw, eachCandidate) }() + return follow(ctx, w, each) } // sourceHeads are the trunk heads of each revision of the gate's source.main the watch delivered, the newest -// delivery.SourceHistory of them: what a candidate's base revision names. +// sourceHistory of them: what a candidate's base revision names. type sourceHeads struct { mu sync.Mutex byRev map[uint64]map[string]string @@ -281,7 +528,7 @@ func (s *sourceHeads) put(e jetstream.KeyValueEntry) bool { if !opOf(e) { return false } - src, err := delivery.DecodeSource(e.Value()) + src, err := meshdeliveryclient.DecodeSource(e.Value()) if err != nil { return false } @@ -294,7 +541,7 @@ func (s *sourceHeads) put(e jetstream.KeyValueEntry) bool { return false } s.byRev[e.Revision()] = src.Heads - for len(s.byRev) > delivery.SourceHistory { + for len(s.byRev) > sourceHistory { delete(s.byRev, slices.Min(slices.Collect(maps.Keys(s.byRev)))) } return true @@ -309,7 +556,7 @@ func (s *sourceHeads) head(_ context.Context, rev uint64, repository string) (st return "", fmt.Errorf("source revision %d is not heard (it is built once it is)", rev) } for r, head := range heads { - if delivery.SameRepository(r, repository) { + if trunk.SameRepository(r, repository) { return head, nil } } diff --git a/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/names.go b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/names.go new file mode 100644 index 00000000..b52510ea --- /dev/null +++ b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/names.go @@ -0,0 +1,37 @@ +package main + +import ( + "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun" +) + +// The holders and buckets the builder names, taken from its own manifest (module.json: its `reads`) and from the holders' +// seats' state names; a unit row checks them against the manifest (TestTheNamesAreTheManifests). No contract names a holder +// (novox/hq ADR 0625 §4, ADR 0634 §1), except the clients of the indexer's and the assigner's states, whose holder names +// are used from there. +const ( + gateModule = "mesh-delivery-gate" + checkerModule = "mesh-delivery-checker" + forgeBridgeModule = "mesh-delivery-forge-bridge" + + // indexerModule is the holder of the states modules, manifests, candidates and packages the builder follows. + indexerModule = "mesh-delivery-indexer" + // assignerModule is the holder of the state assignments. + assignerModule = buildrun.AssignerModule + + // sourceBucket is the gate's state source (reads: mesh-delivery-gate.source), the heads a candidate's base names. + sourceBucket = gateModule + "_source" + // packagesBucket is the indexer's state packages. + packagesBucket = indexerModule + "_" + meshcatalog.StatePackages + + // The revisions each keeps per key (the beds of the integration test create them alike). + sourceHistory = 64 + candidatesHistory = 10 + modulesHistory = 20 + manifestsHistory = 1 +) + +func indexerBucket(state string) string { return indexerModule + "_" + state } + +// namedTreePrefix opens the tree input of a module whose every artifact names its paths. +const namedTreePrefix = "named:" diff --git a/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/names_test.go b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/names_test.go new file mode 100644 index 00000000..6b3b86c3 --- /dev/null +++ b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/names_test.go @@ -0,0 +1,50 @@ +package main + +import ( + "encoding/json" + "os" + "slices" + "testing" + + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun" +) + +// TestTheNamesAreTheManifests: the holders and buckets the builder names are its manifest's `reads` (the gate's source, the +// build modules' steps and toolchains) and its `consumes` of the indexer's and assigner's views, since no contract names a +// holder (novox/hq ADR 0634 §1). +func TestTheNamesAreTheManifests(t *testing.T) { + raw, err := os.ReadFile("../../module.json") + if err != nil { + t.Fatal(err) + } + var m struct { + Reads []string `json:"reads"` + Consumes []string `json:"consumes"` + } + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatal(err) + } + for _, read := range []string{ + gateModule + ".source", + "mesh-build-go.steps", "mesh-build-go.toolchains", "mesh-build-image.steps", "mesh-build-node.steps", "mesh-build-node.toolchains", + } { + if !slices.Contains(m.Reads, read) { + t.Errorf("the builder reads %s, its manifest's reads are %v", read, m.Reads) + } + } + for _, view := range []string{indexerModule + ".views.modules", indexerModule + ".views.manifests", indexerModule + ".views.candidates", + indexerModule + ".views.packages", assignerModule + ".views.assignments"} { + if !slices.Contains(m.Consumes, view) { + t.Errorf("the builder follows %s, its manifest consumes %v", view, m.Consumes) + } + } + for want, got := range map[string]string{ + "mesh-delivery-gate_source": sourceBucket, "mesh-build-go_steps": buildrun.StepsBucket("mesh-build-go"), + "mesh-build-node_toolchains": buildrun.ToolchainsBucket("mesh-build-node"), "mesh-delivery-indexer_packages": packagesBucket, + "mesh-delivery-assigner_assignments": buildrun.AssignmentsBucket, + } { + if want != got { + t.Errorf("a bucket is %q, want %q", got, want) + } + } +} diff --git a/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/oldform.go b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/oldform.go new file mode 100644 index 00000000..c77e8dc9 --- /dev/null +++ b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/oldform.go @@ -0,0 +1,187 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "slices" + "strings" + "sync" + "time" + + "github.com/nats-io/nats.go/jetstream" + + "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient" + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds" + + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds/forms" + + "mesh-delivery-builder/internal/versions" +) + +// oldForm keeps the old reference form's window (design 51 *Every build keeps the old reference form*, DLV-B9) in the +// state old-form: stepped on every change of what the counted nodes state, of the assignments, on a hand drop and at each +// build key written, never on a timer. A node holding it seven days is the finding old-form-held; its close is said once +// as version.old-form-closed and in the builder's log. +type oldForm struct { + forms forms.ArtifactForms + findings *findings + // said emits version.old-form-closed. + said func(versions.OldFormClosedBody) + // log writes the close into the builder's build log (build.said); nil writes none. + log func(builds.BuildLogEntry) + logf func(string, ...any) + now func() time.Time + + // bounds is the builder's state bounds: each node holding the window has a key there whose time to live ends at its + // seven days, so old-form-held is raised then, not at the next change (review round 1); nil keeps none. + bounds func() jetstream.KeyValue + + mu sync.Mutex + w versions.OldFormWindow + kv jetstream.KeyValue + // overdue are the nodes the finding is raised for now. + overdue []string +} + +// bind takes the kept window in, so a restarted builder keeps each node's since and the nodes dropped by hand, and keeps +// every later step in kv. +func (o *oldForm) bind(ctx context.Context, kv jetstream.KeyValue) { + e, err := kv.Get(ctx, versions.OldFormKey) + o.mu.Lock() + defer o.mu.Unlock() + switch { + case errors.Is(err, jetstream.ErrKeyNotFound): + case err != nil: + o.logf("%s.%s cannot be read, so the window starts again: %v", versions.OldFormState, versions.OldFormKey, err) + default: + w, err := versions.DecodeOldFormWindow(e.Value()) + if err != nil { + o.logf("%v; the window starts again", err) + break + } + // Taken in under what was stepped before the state was bound: the kept since and drops are the older truth. + for n, s := range o.w.Since { + if _, ok := w.Since[n]; !ok && w.Open && slices.Contains(w.Holding, n) { + w.Since[n] = s + } + } + for _, d := range o.w.Dropped { + w, _ = w.Drop(d.Node, d.By, d.Why, d.At) + } + o.w = w + } + o.kv = kv + o.keep(ctx) +} + +// dropped is the nodes dropped by hand. +func (o *oldForm) dropped() map[string]bool { + o.mu.Lock() + defer o.mu.Unlock() + return o.w.DroppedSet() +} + +// step moves the window to what counted states. +// named is every node the read named (the assignments's nodes before any is dropped), counted the statements of those +// still counted: a read naming no node judges nothing (window.StepWindow), so it never closes the window. +func (o *oldForm) step(ctx context.Context, named []string, counted map[string][]string) { + o.mu.Lock() + defer o.mu.Unlock() + before := o.w + next, closed, overdue := o.w.Step(named, versions.Holding(o.forms, counted, o.w.DroppedSet()), o.now()) + o.w = next + if !slices.Equal(before.Holding, next.Holding) || before.Open != next.Open || closed { + o.keep(ctx) + } + for _, n := range o.overdue { + if !slices.Contains(overdue, n) { + o.findings.clear(ctx, versions.OldFormHeld, meshconditionsclient.ScopeMachine, n) + } + } + for _, n := range overdue { + o.findings.raise(ctx, finding{kind: versions.OldFormHeld, scope: meshconditionsclient.ScopeMachine, id: n, + summary: fmt.Sprintf("the node-engine of %s has read only an older reference form since %s, so every build key still carries it", + n, next.Since[n].Format(time.RFC3339)), + headline: "A node holds the old reference form", + explanation: "The builder writes an older reference form into every build key while a counted node's engine reads none of the newest (ADR 0624 §1); this node has held it seven days.", + resolved: "The node's engine states the newest form, the node leaves the assignments, or it is dropped as retired.", + needs: "builder_old_form shows the window; a retired node is dropped there by hand; builder_engine_reads asks the node again."}) + } + o.overdue = overdue + o.keepBounds(ctx, before, next, overdue) + if closed { + body := next.Closing() + line := fmt.Sprintf("the old reference form's window closed at %s: no counted node-engine reads only an older form (last held by %s; dropped by hand: %d); new build keys leave it out", + body.At.Format(time.RFC3339), strings.Join(body.LastHolding, ", "), len(body.Dropped)) + o.logf("%s", line) + // In the build log too (design 51 *The node-engine's own build*), under the builder's own key, the window's key + // standing for the fingerprint: the close is no one build's. + if o.log != nil { + o.log(builds.BuildLogEntry{Event: builds.BuildSaidEvent, Module: builds.BuilderModule, FP: versions.OldFormKey, + At: body.At.UTC(), Line: line, Why: versions.SaidOldFormClosed}) + } + if o.said != nil { + o.said(body) + } + } +} + +// keepBounds writes a bound for each node holding the window and not yet overdue, ending at its seven days, and deletes +// the bound of each node that no longer holds it. A standing bound keeps its time (a Create). Called with mu held. +func (o *oldForm) keepBounds(ctx context.Context, before, next versions.OldFormWindow, overdue []string) { + if o.bounds == nil { + return + } + kv := o.bounds() + if kv == nil { + return + } + for _, n := range before.Holding { + if !slices.Contains(next.Holding, n) { + if err := kv.Delete(ctx, versions.OldFormBoundKey(n)); err != nil && !errors.Is(err, jetstream.ErrKeyNotFound) { + o.logf("the old form's bound of %s is not deleted: %v", n, err) + } + } + } + for _, n := range next.Holding { + left := next.Since[n].Add(versions.FindingAfter).Sub(o.now()) + if slices.Contains(overdue, n) || left <= 0 { + continue + } + if left < time.Second { + left = time.Second // the bus's least time to live + } + if _, err := kv.Create(ctx, versions.OldFormBoundKey(n), versions.EncodeBound("the seven days "+n+" holds the old form's window", o.now().Add(left)), jetstream.KeyTTL(left)); err != nil && !errors.Is(err, jetstream.ErrKeyExists) { + o.logf("the old form's bound of %s is not written, so its seven days are seen at the next change only: %v", n, err) + } + } +} + +// drop is builder_old_form's drop: the node leaves the count, the window is stepped by the caller. +func (o *oldForm) drop(ctx context.Context, node, by, why string, at time.Time) (string, error) { + o.mu.Lock() + defer o.mu.Unlock() + w, err := o.w.Drop(node, by, why, at) + if err != nil { + return "", err + } + o.w = w + o.keep(ctx) + return node + " dropped from the old form's count by hand; the window is stepped now", nil +} + +// keep writes the window, once the state is bound; until then it is kept here only. Called with mu held. +func (o *oldForm) keep(ctx context.Context) { + if o.kv == nil { + return + } + raw, err := json.Marshal(o.w) + if err == nil { + _, err = o.kv.Put(ctx, versions.OldFormKey, raw) + } + if err != nil { + o.logf("%s.%s cannot be written: %v", versions.OldFormState, versions.OldFormKey, err) + } +} diff --git a/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/optional.go b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/optional.go index 02afe10c..09324c26 100644 --- a/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/optional.go +++ b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/optional.go @@ -7,7 +7,7 @@ import ( "github.com/nats-io/nats.go/jetstream" - "git.novox.be/novox/mesh-sdk/go/delivery" + "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliveryclient" ) // **A part does not stop for a bucket it can work without** (mesh-sdk process.EssentialBucket, OptionalBucket) (novox/hq issue 1945, met twice on 2026-10-25: the builder of @@ -39,17 +39,17 @@ func (t *trunkSource) ensure() error { if t.opened { return nil } - kv, err := t.js.KeyValue(t.ctx, delivery.SourceBucket) + kv, err := t.js.KeyValue(t.ctx, sourceBucket) if err != nil { - return fmt.Errorf("the bucket %s cannot be read (%v)", delivery.SourceBucket, err) + return fmt.Errorf("the bucket %s cannot be read (%v)", sourceBucket, err) } - w, err := kv.Watch(t.ctx, delivery.SourceMainKey, jetstream.IncludeHistory()) + w, err := kv.Watch(t.ctx, meshdeliveryclient.SourceMainKey, jetstream.IncludeHistory()) if err != nil { - return fmt.Errorf("the bucket %s cannot be watched (%v)", delivery.SourceBucket, err) + return fmt.Errorf("the bucket %s cannot be watched (%v)", sourceBucket, err) } sw, err := settled(t.ctx, w, func(e jetstream.KeyValueEntry) { t.heads.put(e) }) if err != nil { - return fmt.Errorf("the bucket %s: %w", delivery.SourceBucket, err) + return fmt.Errorf("the bucket %s: %w", sourceBucket, err) } t.opened = true go func() { diff --git a/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/publishbounds.go b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/publishbounds.go new file mode 100644 index 00000000..9f5cf00e --- /dev/null +++ b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/publishbounds.go @@ -0,0 +1,129 @@ +package main + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/nats-io/nats.go/jetstream" + + "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient" + "git.novox.be/novox/mesh-sdk/go/process" + + "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient/heard" + + "mesh-delivery-builder/internal/versions" +) + +// publishes raises and clears the builder's findings of a publish that ended badly (versions/publish.go): a holder's +// refused, a version unanswered past its bound, kept as a key of the state bounds with its own time to live, and a seat's +// outcome that does not read. +type publishes struct { + bounds func() jetstream.KeyValue + versions versions.Versions + findings *findings + bound time.Duration + logf func(string, ...any) +} + +// arm writes the version's bound when none stands, so a standing bound keeps its time. A bound that cannot be written +// would never end, so the version is publish-unanswered at once (review round 2): the finding says why. +func (p *publishes) arm(ctx context.Context, e versions.Entry) { + kv := p.bounds() + var err error + if kv == nil { + err = fmt.Errorf("%s is not on the bus yet", versions.BoundsBucket) + } else if _, err = kv.Create(ctx, versions.PublishBoundKey(e.Name, e.Version), versions.EncodeBound("the publish of "+e.Name+"@"+e.Version+" to "+e.Seat, time.Now().Add(p.bound)), jetstream.KeyTTL(p.bound)); errors.Is(err, jetstream.ErrKeyExists) { + err = nil + } + if err == nil { + return + } + p.logf("the publish bound of %s@%s is not written, so it is raised unanswered now: %v", e.Name, e.Version, err) + p.unanswered(ctx, e, fmt.Sprintf("its bound could not be written (%v), so the builder cannot wait on the holder's word", err)) +} + +// unanswered raises publish-unanswered about e's name, saying why. +func (p *publishes) unanswered(ctx context.Context, v versions.Entry, why string) { + p.findings.raise(ctx, finding{kind: versions.PublishUnanswered, scope: meshconditionsclient.ScopeModule, id: v.Name, + summary: fmt.Sprintf("%s said neither published nor refused of %s@%s: %s", v.Seat, v.Name, v.Version, why), + headline: "A registry seat did not answer a publish", + explanation: "The builder built and numbered " + v.Name + " " + v.Version + " and asked " + v.Seat + " to publish it; no word of its holder is known, so the build says done while the version may not be in the registry.", + resolved: "That version is published, or a later version of " + v.Name + " is.", + needs: "a look at the holder of " + v.Seat + " (its health and log); builder_publish_again asks the publish again."}) +} + +// heard is told each holder's word the numberer took in: published clears the name's findings, refused raises +// publish-refused with the holder's reason, and either ends the version's bound. +func (p *publishes) heard(ctx context.Context, seat, event string, o versions.Outcome) { + if o.Version != "" { + if kv := p.bounds(); kv != nil { + if err := kv.Delete(ctx, versions.PublishBoundKey(o.Name, o.Version)); err != nil && !errors.Is(err, jetstream.ErrKeyNotFound) { + p.logf("the publish bound of %s@%s is not ended: %v", o.Name, o.Version, err) + } + } + } + switch event { + case versions.HeardPublished: + p.findings.clear(ctx, versions.PublishRefused, meshconditionsclient.ScopeModule, o.Name) + p.findings.clear(ctx, versions.PublishUnanswered, meshconditionsclient.ScopeModule, o.Name) + case versions.HeardRefused: + reason := o.Reason + if reason == "" { + reason = "it gave no reason" + } + p.findings.raise(ctx, finding{kind: versions.PublishRefused, scope: meshconditionsclient.ScopeModule, id: o.Name, + summary: fmt.Sprintf("%s refused to publish %s@%s (digest %s): %s", seat, o.Name, o.Version, o.Digest, reason), + headline: "A registry seat refused a publish", + explanation: "The builder built and numbered " + o.Name + " " + o.Version + ", and the holder of " + seat + " refused to store it, so that version is not in the registry.", + resolved: "That version is published, or a later version of " + o.Name + " is.", + needs: "builder_versions shows the version and why; builder_publish_again publishes it again once the cause is mended."}) + } +} + +// unread raises seat-outcome-unread about seat: what it said on subject is no outcome. read clears it. +func (p *publishes) unread(ctx context.Context, seat, subject string, why error) { + p.findings.raise(ctx, finding{kind: versions.SeatOutcomeUnread, scope: meshconditionsclient.ScopeModule, id: seat, + summary: fmt.Sprintf("%s said on %s what is no outcome of a publish: %v", seat, subject, why), + headline: "A registry seat said what the builder cannot read", + explanation: "The builder hears each registry seat's published, refused and removed to move a numbered version; this one does not read, so the version it was about does not move.", + resolved: "The next outcome of " + seat + " reads.", + needs: "a look at the seat's holder: the outcome's body is its contract's (mesh-contracts)."}) +} + +func (p *publishes) read(ctx context.Context, seat string) { + p.findings.clear(ctx, versions.SeatOutcomeUnread, meshconditionsclient.ScopeModule, seat) +} + +// ended is told each change of the state bounds: a bound whose time ended (the bus's marker) on a version still numbered +// raises publish-unanswered. +func (p *publishes) ended(ctx context.Context, e jetstream.KeyValueEntry) { + key, ok := versions.PublishBoundOf(e.Key()) + if e.Operation() != jetstream.KeyValuePurge || !ok { + return + } + raw, _, found, err := p.versions.Store.Get(ctx, key) + if err != nil || !found { + return + } + v, err := versions.Decode(raw) + if err != nil || v.State != versions.Numbered { + return + } + p.unanswered(ctx, v, fmt.Sprintf("nothing within %s of its build", p.bound)) +} + +// watchBounds watches the state bounds, handing each change to each: a watch that cannot start loses every bound's end, +// so it is the finding family-not-heard naming the state until a later start watches it (review round 2). +func watchBounds(ctx context.Context, kv jetstream.KeyValue, each func(jetstream.KeyValueEntry), w heard.Writer, errs chan<- error, logf func(string, ...any)) { + const consequence = "a publish no holder answers, and a node holding the old form seven days, are not raised until the builder starts again" + pw, err := settle(ctx, kv, each) + if err != nil { + process.NotWatched(logf, versions.BoundsBucket, err, consequence) + w.NotHeard(versions.BoundsState, consequence, err) + return + } + w.Heard(versions.BoundsState) + go func() { errs <- follow(ctx, pw, each) }() +} diff --git a/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/reads.go b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/reads.go new file mode 100644 index 00000000..4475e766 --- /dev/null +++ b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/reads.go @@ -0,0 +1,151 @@ +package main + +import ( + "context" + "slices" + "sync" + + "github.com/nats-io/nats.go/jetstream" + + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds" + "git.novox.be/novox/mesh-sdk/go/process" + + "git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient/enginereads" + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun" +) + +// EngineReadsState is the builder's state of what each counted node's engine states it reads (novox/hq ADR 0618 §2, ADR +// 0624 §1): one key ``, written from the node-engine seat's verb manifest-reads at the builder's start, on the +// seat's event manifest-reads-changed for that node, and when a person asks again (builder_engine_reads); a node that +// is away keeps its last statement, and its key is removed when the assignments no longer names the node. A counted node +// with no key reads as stating the baseline: its first forms. No node's record is read in its place. +const EngineReadsState = "engine-reads" + +// engineReadsBucket is that state's bucket. +const engineReadsBucket = builds.BuilderModule + "_" + EngineReadsState + +// engineReads keeps the statements and the counted nodes for the builder's forms (build.Builder.Forms). +type engineReads struct { + keeper *enginereads.Keeper + gate *buildrun.ToolchainGate + mu sync.Mutex + counted []string + // window is the old reference form's window over these statements (DLV-B9); nil keeps none. + window *oldForm + ctx context.Context +} + +// newEngineReads asks through call (process.Conn.CallOn) and keeps each answer in the state once it is bound. +func newEngineReads(call enginereads.Caller, gate *buildrun.ToolchainGate, logf func(string, ...any)) *engineReads { + r := &engineReads{gate: gate, keeper: &enginereads.Keeper{Call: call, Logf: logf}} + r.keeper.Changed = r.stepWindow + return r +} + +// stepWindow moves the old form's window to what the counted nodes state now (design 51 *Every build keeps the old +// reference form*): on a statement's change, the assignments's and a hand drop, and at each build key written. +func (r *engineReads) stepWindow() { + if r.window == nil { + return + } + ctx := r.ctx + if ctx == nil { + ctx = context.Background() + } + _, counted := r.forms() + r.window.step(ctx, r.named(), counted) +} + +// named are the nodes the assignments names, none dropped: what the window's step judges a read by. +func (r *engineReads) named() []string { + r.mu.Lock() + defer r.mu.Unlock() + return slices.Clone(r.counted) +} + +// bind keeps the statements in kv and hears it: every node's last statement at the start, a person's ask again and drop +// later. Bound once the state is on the bus (process.AwaitBucket). +func (r *engineReads) bind(ctx context.Context, kv jetstream.KeyValue, errs chan<- error, logf func(string, ...any)) { + r.keeper.Put = func(node string, v []byte) error { _, err := kv.Put(ctx, node, v); return err } + r.keeper.Delete = func(node string) error { return kv.Delete(ctx, node) } + each := func(e jetstream.KeyValueEntry) { + r.keeper.Heard(e.Key(), e.Value(), e.Operation() != jetstream.KeyValuePut) + } + w, err := settle(ctx, kv, each) + if err != nil { + process.NotWatched(logf, engineReadsBucket, err, "builder_engine_reads is not answered until the builder starts again") + return + } + go func() { errs <- follow(ctx, w, each) }() + // Every counted node asked again, so what was asked before the state was bound is kept in it too. + r.mu.Lock() + nodes := slices.Clone(r.counted) + r.mu.Unlock() + for _, node := range nodes { + r.keeper.AskNode(node, "the state engine-reads is bound") + } +} + +// placed takes the assignments in: a node it names newly is asked, one it no longer names is forgotten, key and all. The +// first call asks every counted node (the builder's start). +func (r *engineReads) placed() { + nodes := r.gate.Nodes() + r.mu.Lock() + before, first := r.counted, r.counted == nil + r.counted = nodes + r.mu.Unlock() + for _, node := range nodes { + if first || !slices.Contains(before, node) { + r.keeper.AskNode(node, "counted: the assignments names it") + } + } + for _, node := range before { + if !slices.Contains(nodes, node) { + r.keeper.Forget(node) + } + } + r.stepWindow() +} + +// heard takes an event of the builder's consumer on EVENTS: manifest-reads-changed asks its node again, and answers true. +func (r *engineReads) heard(m jetstream.Msg, logf func(string, ...any)) bool { + node, ours, err := enginereads.NodeOf(m.Subject(), m.Data()) + if !ours { + return false + } + if ackErr := m.Ack(); ackErr != nil { + logf("acknowledging %s: %v", m.Subject(), ackErr) + } + if err != nil { + logf("%s holds what is not %s, and %s is asked again all the same: %v", m.Subject(), enginereads.Event, node, err) + } + r.keeper.AskNode(node, enginereads.Event) + return true +} + +// forms is the counted nodes' statements, and those of the nodes whose engine answered; a retired node dropped by hand +// (builder_old_form) counts no more. +func (r *engineReads) forms() (present, counted map[string][]string) { + r.mu.Lock() + nodes := slices.Clone(r.counted) + r.mu.Unlock() + if r.window != nil { + dropped := r.window.dropped() + nodes = slices.DeleteFunc(nodes, func(n string) bool { return dropped[n] }) + } + return r.keeper.Present(nodes), r.keeper.Reads(nodes) +} + +// built is build.Builder.Forms: forms, and the window stepped with them, so a node's seven days are seen at the next +// build key at the latest. +func (r *engineReads) built() (present, counted map[string][]string) { + present, counted = r.forms() + if r.window != nil { + ctx := r.ctx + if ctx == nil { + ctx = context.Background() + } + r.window.step(ctx, r.named(), counted) + } + return present, counted +} diff --git a/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/reasks.go b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/reasks.go index f2b4f371..8d7c8d3c 100644 --- a/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/reasks.go +++ b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/reasks.go @@ -8,13 +8,14 @@ import ( "strings" "time" + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds" "git.novox.be/novox/mesh-sdk/go/buscompose" - "git.novox.be/novox/mesh-sdk/go/delivery" "git.novox.be/novox/mesh-sdk/go/process" "github.com/nats-io/nats.go" "github.com/nats-io/nats.go/jetstream" "mesh-delivery-builder/internal/build" + "mesh-delivery-builder/internal/versions" ) // askAgainer is the part of the builder a reasks value asks through. @@ -31,7 +32,7 @@ const buildAgainBound = 15 * time.Second // reAsked answers one value of the reasks state (the tool builder_ask_again, issue 1945): a value not yet answered asks the // key's waiting lanes again and is written back, by compare-and-set at its revision, with what the builder did. A value // answered already (this builder's own write) or deleted is left. -func reAsked(ctx context.Context, b askAgainer, kv jetstream.KeyValue, e jetstream.KeyValueEntry, logf func(string, ...any)) { +func reAsked(ctx context.Context, b askAgainer, acts versions.Acts, kv jetstream.KeyValue, e jetstream.KeyValueEntry, logf func(string, ...any)) { if e.Operation() != jetstream.KeyValuePut { return } @@ -52,6 +53,11 @@ func reAsked(ctx context.Context, b askAgainer, kv jetstream.KeyValue, e jetstre announcedAgain(ctx, b, kv, e, r, logf) return } + if r.Mode == build.ModePublishAgain || r.Mode == build.ModeFloorAgain || r.Mode == build.ModeOldFormDrop { + // A version's hand act (design 51 *Tools of the builder*) may build again, so it is answered beside the watch. + go versionActed(ctx, acts, kv, e, r, logf) + return + } asked := b.AskAgain(e.Key(), r.Why) if len(asked) == 0 { r.Answer = "no ask of this key waits in this builder now (it is not building, or its build has not reached its ask yet): nothing was asked; builder_stuck shows where it stands" @@ -72,21 +78,7 @@ func reAsked(ctx context.Context, b askAgainer, kv jetstream.KeyValue, e jetstre // emitAskedAgain says the builder's event build.asked-again (issue 1945): mesh.mod..event.build.asked-again, // with the SDK's envelope headers and the new ask's id as the bus's dedup id. func emitAskedAgain(ctx context.Context, js jetstream.JetStream, cred process.Account, e build.AskedAgain) error { - data, err := json.Marshal(e) - if err != nil { - return err - } - msg := nats.NewMsg(cred.EventSubject(build.AskedAgainEvent)) - id := "asked-again-" + e.New - msg.Header.Set("x-event-id", id) - msg.Header.Set("x-source", cred.Module) - msg.Header.Set("x-node", cred.Node) - msg.Header.Set("x-time", time.Now().UTC().Format(time.RFC3339Nano)) - msg.Header.Set("content-type", "application/json") - msg.Data = data - pub, cancel := context.WithTimeout(ctx, 10*time.Second) - defer cancel() - if _, err := js.PublishMsg(pub, msg, jetstream.WithMsgID(id)); err != nil { + if err := emitEvent(ctx, js, cred, build.AskedAgainEvent, "asked-again-"+e.New, e); err != nil { return fmt.Errorf("saying %s for %s: %w", build.AskedAgainEvent, e.Key, err) } return nil @@ -142,18 +134,18 @@ func announcedAgain(ctx context.Context, b askAgainer, kv jetstream.KeyValue, e } // appendLog appends one entry to the builder's module log `build-log` (ADR 0297, ADR 0571 §1) under the module's key -// (delivery.BuildLogKey), on the log's own subject: a module's program appends to the logs it keeps on its own credential. +// (builds.BuildLogKey), on the log's own subject: a module's program appends to the logs it keeps on its own credential. // The id makes a repeated append of one entry one entry. -func appendLog(ctx context.Context, js jetstream.JetStream, cred process.Account, e delivery.BuildLogEntry) error { - data, err := delivery.EncodeBuildLogEntry(e) +func appendLog(ctx context.Context, js jetstream.JetStream, cred process.Account, e builds.BuildLogEntry) error { + data, err := builds.EncodeBuildLogEntry(e) if err != nil { return err } - key, err := delivery.BuildLogKey(e.Module) + key, err := builds.BuildLogKey(e.Module) if err != nil { return err } - msg := nats.NewMsg(buscompose.LogSubject(cred.Module, delivery.BuilderLog) + "." + key) + msg := nats.NewMsg(buscompose.LogSubject(cred.Module, builds.BuilderLog) + "." + key) msg.Data = data id := e.Event + "." + e.Module + "." + e.FP + "." + e.At.Format(time.RFC3339Nano) pub, cancel := context.WithTimeout(ctx, 10*time.Second) @@ -163,3 +155,33 @@ func appendLog(ctx context.Context, js jetstream.JetStream, cred process.Account } return nil } + +// versionActed answers a reasks value of builder_publish_again, builder_floor_again or builder_old_form's drop, a hand act said in the log. +func versionActed(ctx context.Context, acts versions.Acts, kv jetstream.KeyValue, e jetstream.KeyValueEntry, r build.ReAsk, logf func(string, ...any)) { + var err error + if r.Mode == build.ModePublishAgain { + var ag versions.Again + ag, r.Answer, err = acts.PublishAgain(ctx, r.Name, r.FP, r.Why, r.By, r.At) + r.Do, r.Version = ag.Do, ag.Entry.Version + } else if r.Mode == build.ModeOldFormDrop { + if acts.DropOldForm == nil { + err = errors.New("this builder keeps no old-form window") + } else { + r.Answer, err = acts.DropOldForm(ctx, r.Name, r.By, r.Why, r.At) + } + } else { + r.Answer, err = acts.FloorAgain(ctx, r.Name) + } + if err != nil { + r.Answer, r.Refused = err.Error(), true + } + r.Answered = time.Now().UTC() + logf("%s: %s of %s by %s (%s): %s", e.Key(), r.Mode, r.Name, r.By, r.Why, r.Answer) + data, err := json.Marshal(r) + if err != nil { + return + } + if _, err := kv.Update(ctx, e.Key(), data, e.Revision()); err != nil && !errors.Is(err, jetstream.ErrKeyExists) && !errors.Is(err, jetstream.ErrKeyRevisionMismatch) { + logf("%s.%s: the answer could not be written: %v", build.ReAsksState, e.Key(), err) + } +} diff --git a/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/refused_test.go b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/refused_test.go index 08d5fb7c..f4d30f13 100644 --- a/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/refused_test.go +++ b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/refused_test.go @@ -3,24 +3,45 @@ package main import ( "bytes" "context" + "encoding/json" "errors" "log" + "maps" + "slices" "strings" "testing" + "time" + "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient" + + "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient/heard" "git.novox.be/novox/mesh-sdk/go/process" + "github.com/nats-io/nats.go" "github.com/nats-io/nats.go/jetstream" + "git.novox.be/novox/mesh-contracts/go/clients/meshmoduleassignerclient" + "git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient/enginereads" + "git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner" + "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" + sweep "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshartifactsweep" "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun" - "git.novox.be/novox/mesh-sdk/go/delivery" + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds" + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds/forms" + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-forge-bridge/forgemsg" + + "mesh-delivery-builder/internal/build" + "mesh-delivery-builder/internal/versions" ) // 1945 (a part exits at start for a bucket it works without): a bus that refuses every toolchains bucket leaves the // watch unstarted and the part running, said; a refused bucket it cannot work without is an error naming the bucket and -// its grant. The gate over the seat's placement, and its refusal, are mesh-sdk's (buildrun.ToolchainGate, +// its grant. The gate over the seat's assignments, and its refusal, are mesh-sdk's (buildrun.ToolchainGate, // issue 774). func TestARefusedBucketIsSaidAndThePartGoesOn(t *testing.T) { + t.Run("a floor's wake is heard on the events consumer (design 51 The floor)", aFloorsWakeIsHeard) + t.Run("a failed trunk build is a finding until a later done one (DLV-B7)", aFailedTrunkBuildIsAFinding) + t.Run("a branch's end is acknowledged once its tagged versions are removed (design 51 Tagged versions)", aBranchsEndIsHeard) set := &buildrun.ToolchainSet{} var logs bytes.Buffer logger := log.New(&logs, "", 0) @@ -39,12 +60,106 @@ func TestARefusedBucketIsSaidAndThePartGoesOn(t *testing.T) { // head is told why and opens it again then. src := &trunkSource{ctx: context.Background(), js: refusingBus{refuseOpen: true, err: refused.err}, heads: &sourceHeads{}, errs: make(chan error, 1), logf: logger.Printf} src.start() - if _, err := src.head(context.Background(), 7, "novox/mesh-catalog"); err == nil || !strings.Contains(err.Error(), delivery.SourceBucket) { + if _, err := src.head(context.Background(), 7, "novox/mesh-catalog"); err == nil || !strings.Contains(err.Error(), sourceBucket) { t.Errorf("a trunk head with the source refused: %v, want why", err) } if src.opened { t.Error("a refused source counts as opened, so it is never opened again") } + t.Run("ADR 0624: the counted nodes' engine reads", theCountedNodesEngineReads) +} + +// theCountedNodesEngineReads: the builder asks the node-engine seat's verb of every node the assignments names; a node away +// and never asked has no statement and reads as the baseline's first forms, with no node record read in its place; an +// away node keeps its last statement; a node the assignments no longer names leaves the count, its key removed; and +// builder_engine_reads drops one node's key. +func theCountedNodesEngineReads(t *testing.T) { + gate := buildrun.NewToolchainGate(nil, func(string, string, []byte, bool) bool { return false }, t.Logf) + place := func(nodes ...string) { + for _, node := range []string{"n1", "n2"} { + pl := meshmoduleassigner.Assignments{Node: node} + if slices.Contains(nodes, node) { + pl.Modules = []meshmoduleassigner.PlacedModule{{Module: "web"}} + } + values, err := meshmoduleassignerclient.AssignmentsValues(pl) + if err != nil { + t.Fatal(err) + } + for key, raw := range values { + gate.Assignments(key, raw, false, time.Now()) + } + if !slices.Contains(nodes, node) { + key, _ := meshmoduleassignerclient.ModuleKey(node, "web") + gate.Assignments(key, nil, true, time.Now()) + } + } + } + away := map[string]bool{"n2": true} + asked := map[string]int{} + r := newEngineReads(func(node, key string, _ any) (json.RawMessage, error) { + asked[node]++ + if away[node] { + return nil, nats.ErrNoResponders + } + return json.Marshal(nodeengine.ManifestReadsAnswer{V: 1, Node: node, ManifestReads: []string{"form:new"}, StatedAt: time.Now().Format(time.RFC3339Nano)}) + }, gate, t.Logf) + r.keeper.Sync = true + kept := map[string][]byte{} + r.keeper.Put = func(node string, v []byte) error { kept[node] = v; return nil } + r.keeper.Delete = func(node string) error { delete(kept, node); return nil } + place("n1", "n2") + r.placed() + present, counted := r.forms() + if !slices.Equal(present["n1"], []string{"form:new"}) || len(present) != 1 || len(counted) != 2 || counted["n2"] != nil { + t.Errorf("away and never asked: present %v, counted %v", present, counted) + } + if s := r.keeper.Statements()["n2"]; s.From != enginereads.FromNone || s.Answered || asked["n2"] != 1 { + t.Errorf("n2, away and never asked, is %+v (asked %d)", s, asked["n2"]) + } + // n2 back, stating {word:module}, then away again: it keeps that statement. + away["n2"] = false + r.keeper.Heard("n2", mustStatement(t, enginereads.Statement{Node: "n2", From: enginereads.FromVerb, Answered: true, + AskAgain: &enginereads.Again{By: "n1.ada", Why: "back"}}), false) + away["n2"] = true + r.keeper.AskNode("n2", "its event") + if _, counted = r.forms(); !slices.Equal(counted["n2"], []string{"form:new"}) || r.keeper.Statements()["n2"].Answered { + t.Errorf("away again, n2 reads %v (%+v)", counted["n2"], r.keeper.Statements()["n2"]) + } + // n2 retired: it leaves the assignments, and its key goes. + place("n1") + r.placed() + if _, counted = r.forms(); len(counted) != 1 || kept["n2"] != nil { + t.Errorf("retired n2: counted %v, its key %s", counted, kept["n2"]) + } + // builder_engine_reads drops one key. + st := &memReads{v: map[string][]byte{"n1": kept["n1"]}} + tool := enginereads.Tool("builder_engine_reads", "the builder", st, true, time.Now) + if _, err := tool.Run(map[string]any{"node": "n1", "drop": "true", "why": "a test"}); err != nil || st.v["n1"] != nil { + t.Errorf("drop: %v, the key %s", err, st.v["n1"]) + } +} + +func mustStatement(t *testing.T, s enginereads.Statement) []byte { + t.Helper() + raw, err := enginereads.Encode(s) + if err != nil { + t.Fatal(err) + } + return raw +} + +// memReads is the builder's state of engine reads in memory, as the tool reads it. +type memReads struct{ v map[string][]byte } + +func (m *memReads) Keys() ([]string, error) { return slices.Collect(maps.Keys(m.v)), nil } +func (m *memReads) Get(key string) ([]byte, uint64, bool, error) { + v, ok := m.v[key] + return v, 1, ok, nil +} +func (m *memReads) Put(key string, value []byte) (uint64, error) { m.v[key] = value; return 2, nil } +func (m *memReads) Delete(key string) error { delete(m.v, key); return nil } +func (m *memReads) Await(string, uint64, func([]byte) bool, time.Duration) ([]byte, error) { + return nil, errors.New("no part answers here") } // refusingBus is a bus that refuses: every open (refuseOpen), or every watch of a bucket it opens, with err. @@ -71,3 +186,347 @@ func (r refusedWatch) WatchAll(context.Context, ...jetstream.WatchOpt) (jetstrea func (r refusedWatch) Watch(context.Context, string, ...jetstream.WatchOpt) (jetstream.KeyWatcher, error) { return nil, r.err } + +// wakeMsg is a message of the builder's events consumer: its subject and body, acknowledged. +type wakeMsg struct { + jetstream.Msg + subject string + data []byte + acked bool + naked bool + delayed time.Duration +} + +func (m *wakeMsg) Subject() string { return m.subject } +func (m *wakeMsg) Data() []byte { return m.data } +func (m *wakeMsg) Ack() error { m.acked = true; return nil } +func (m *wakeMsg) Nak() error { m.naked = true; return nil } +func (m *wakeMsg) NakWithDelay(d time.Duration) error { + m.delayed = d + return nil +} +func (m *wakeMsg) Metadata() (*jetstream.MsgMetadata, error) { + return nil, errors.New("no metadata") +} +func (m *wakeMsg) Headers() nats.Header { + return nil +} + +func aFloorsWakeIsHeard(t *testing.T) { + var logs bytes.Buffer + waits := &versions.FloorWaits{} + waits.Wait("m/lib", versions.SeatGoPackages, "", versions.Floor{Silent: []string{versions.SourceRequirements}}, nil, time.Unix(0, 0)) + waits.Wait("m/img", versions.SeatImages, "", versions.Floor{Silent: []string{versions.SourceTags}}, nil, time.Unix(0, 0)) + f := &floorWake{b: &build.Builder{}, waits: waits, logf: log.New(&logs, "", 0).Printf} + accepted := &wakeMsg{subject: manifestAcceptedSubject} + if !f.heard(context.Background(), accepted) || !accepted.acked || !strings.Contains(logs.String(), "the floor of m/lib is asked again: manifest-accepted") { + t.Fatalf("manifest-accepted: acked %v, log %q", accepted.acked, logs.String()) + } + logs.Reset() + cleared := &wakeMsg{subject: conditionClearedSubject, data: []byte(`{"subject":{"scope":"probe","id":"ace/mesh-delivery-forge-bridge"}}`)} + if !f.heard(context.Background(), cleared) || !strings.Contains(logs.String(), "the floor of m/img is asked again: condition-cleared") { + t.Fatalf("condition-cleared: log %q", logs.String()) + } + if f.heard(context.Background(), &wakeMsg{subject: sweep.SubjectArtifactsGone}) { + t.Fatal("artifacts-gone was taken as a floor's wake") + } + // DLV-B4: a registry seat's outcome on the same consumer moves a version; a published wakes a name whose seat was + // silent; one the state cannot take is left for the bus to give again. + logs.Reset() + waits.Wait("m/pkg", versions.SeatGoPackages, "", versions.Floor{Silent: []string{versions.SourceSeat}}, nil, time.Unix(0, 0)) + h := &seatHearer{n: &versions.Numberer{Versions: versions.Versions{Store: noVersions{}}, Waits: waits}, b: &build.Builder{}, logf: log.New(&logs, "", 0).Printf} + byDigest := &wakeMsg{subject: "mesh.seat.go-package-registry.event.published", data: []byte(`{"name":"m/other","digest":"sha256:aa","kind":"package","holder":"gitea"}`)} + if !h.heard(context.Background(), byDigest) || !byDigest.acked || !strings.Contains(logs.String(), "the floor of m/pkg is asked again: go-package-registry said published") { + t.Fatalf("published: acked %v, log %q", byDigest.acked, logs.String()) + } + refused := &wakeMsg{subject: "mesh.seat.oci-artifact-registry.event.refused", data: []byte(`{"name":"m/program","version":"0.1.3","digest":"sha256:bb","reason":"bytes differ"}`)} + if !h.heard(context.Background(), refused) || refused.acked || !refused.naked { + t.Fatalf("a refusal the state cannot take: acked %v, naked %v, log %q", refused.acked, refused.naked, logs.String()) + } + if h.heard(context.Background(), &wakeMsg{subject: manifestAcceptedSubject}) { + t.Fatal("manifest-accepted was taken as a seat's outcome") + } +} + +// noVersions is a state versions that holds nothing. +type noVersions struct{} + +func (noVersions) Get(context.Context, string) ([]byte, uint64, bool, error) { + return nil, 0, false, nil +} +func (noVersions) Create(context.Context, string, []byte) (uint64, error) { + return 0, errors.New("read only") +} +func (noVersions) Update(context.Context, string, []byte, uint64) (uint64, error) { + return 0, errors.New("read only") +} +func (noVersions) KeysOf(context.Context, string) ([]string, error) { return nil, nil } + +func aBranchsEndIsHeard(t *testing.T) { + for _, c := range []struct { + f forgeEnd + want bool + }{ + {forgeEnd{Kind: forgemsg.ForgeMerged, Repository: "o/r", Branch: "feat/x"}, true}, + {forgeEnd{Kind: forgeBranchDeleted, Repository: "o/r", Branch: "feat/x"}, true}, + {forgeEnd{Kind: forgemsg.ForgePull, Repository: "o/r", Branch: "feat/x", Action: forgemsg.PullClosed}, true}, + {forgeEnd{Kind: forgemsg.ForgePull, Repository: "o/r", Branch: "feat/x", Action: forgemsg.PullOpened}, false}, + {forgeEnd{Kind: forgemsg.ForgeMerged, Repository: "o/r"}, false}, + } { + if got := c.f.ended(); got != c.want { + t.Errorf("%+v ended = %v, want %v", c.f, got, c.want) + } + } + var logs bytes.Buffer + woken := 0 + h := &branchEnds{acts: versions.Acts{Numberer: &versions.Numberer{Versions: versions.Versions{Store: noVersions{}}}}, findings: &findings{logf: t.Logf}, + tagAsks: func() jetstream.KeyValue { return nil }, wake: func(context.Context) { woken++ }, logf: log.New(&logs, "", 0).Printf, now: time.Now} + open := &wakeMsg{subject: "mesh.mod.mesh-delivery-forge-bridge.event.forge.o.r.pull", data: []byte(`{"kind":"pull","repository":"o/r","branch":"feat/x","action":"opened"}`)} + h.heard(context.Background(), open) + merged := &wakeMsg{subject: "mesh.mod.mesh-delivery-forge-bridge.event.forge.o.r.merged", data: []byte(`{"kind":"merged","repository":"o/r","branch":"feat/x"}`)} + h.heard(context.Background(), merged) + if !open.acked || !merged.acked || woken != 2 { + t.Errorf("an open pull acked %v, a merge with nothing tagged acked %v, floors woken %d times", open.acked, merged.acked, woken) + } + // The checker's family holds its verdicts beside tag-wanted: a verdict is acknowledged and not kept; a tag-wanted with + // tag-asks not bound is heard again. + verdict := &wakeMsg{subject: "mesh.stream.mesh-delivery-checker.event.verdict", data: []byte(`{"repository":"o/r"}`)} + wanted := &wakeMsg{subject: tagWantedSubject, data: []byte(`{"repository":"o/r","branch":"feat/x","head":"abc","tag":"feat-x"}`)} + for _, m := range []*wakeMsg{verdict, wanted} { + tagWanted(context.Background(), nil, m, checkerModule, time.Now(), t.Logf) + } + if !verdict.acked || verdict.delayed != 0 || wanted.acked || wanted.delayed != versions.RemoveAgainAfter || !strings.HasPrefix(tagWantedSubject, "mesh.stream.") { + t.Errorf("a verdict acked %v (delayed %v), a tag-wanted acked %v (delayed %v)", verdict.acked, verdict.delayed, wanted.acked, wanted.delayed) + } + unreadable := &wakeMsg{subject: merged.subject, data: merged.data} + h.acts.Numberer.Versions.Store = failingVersions{} + h.heard(context.Background(), unreadable) + if unreadable.acked || unreadable.delayed != versions.RemoveAgainAfter { + t.Errorf("a branch's end the versions cannot answer: acked %v, given again after %v", unreadable.acked, unreadable.delayed) + } +} + +// failingVersions is a state versions that cannot be read. +type failingVersions struct{ noVersions } + +func (failingVersions) KeysOf(context.Context, string) ([]string, error) { + return nil, errors.New("down") +} + +func aFailedTrunkBuildIsAFinding(t *testing.T) { + st := &memFindings{vals: map[string][]byte{}} + f := &findings{store: st, logf: t.Logf, now: func() time.Time { return time.Unix(0, 0) }} + ended := func(outcome, ref string) builds.BuildLogEntry { + return builds.BuildLogEntry{Event: builds.BuildEndedEvent, Module: "m", FP: "f1", Commit: "c", Ref: ref, Outcome: outcome} + } + key := meshconditionsclient.Key(meshconditionsclient.ScopeModule, "m", BuildFailed) + f.buildEnded(context.Background(), ended(builds.BuildFailed, "refs/pull/3/head")) + if len(st.vals) != 0 { + t.Fatalf("a candidate's failed build is a finding: %v", st.vals) + } + f.buildEnded(context.Background(), ended(builds.BuildFailed, "")) + got, err := meshconditionsclient.DecodeFinding(st.vals[key]) + if err != nil || got.Observer != builds.BuilderModule || !strings.Contains(got.Summary, "trunk build of m at f1") { + t.Fatalf("the finding = %+v, %v", got, err) + } + f.buildEnded(context.Background(), ended(builds.BuildDone, "")) + if _, ok := st.vals[key]; ok { + t.Error("a later done trunk build leaves build-failed") + } + // The floor finding: raised while a name waits on its floor, cleared once it is known. + fw := &versions.FloorWaits{Said: f.floorSaid(context.Background())} + fw.Wait("m/lib", versions.SeatGoPackages, "", versions.Floor{Silent: []string{versions.SourceTags}}, nil, time.Unix(0, 0)) + floorKey := meshconditionsclient.Key(meshconditionsclient.ScopeModule, "m/lib", versions.FloorSilent) + if got, err := meshconditionsclient.DecodeFinding(st.vals[floorKey]); err != nil || !strings.Contains(got.Summary, "bridge_tags did not answer") { + t.Fatalf("the floor finding = %+v, %v", got, err) + } + fw.Answered("m/lib") + if _, ok := st.vals[floorKey]; ok { + t.Error("a known floor leaves floor-silent") + } + // Design 51 *Health and conditions* (review round 1): a holder's refused is publish-refused with its reason, a version + // no holder answered within its bound publish-unanswered, a seat's outcome that does not read seat-outcome-unread; + // a later published of the name clears the first two, the seat's next outcome that reads the third. + vs := versions.Versions{Store: memVersions{}} + for _, v := range []string{"0.1.3", "0.1.4"} { + raw, err := versions.Encode(versions.Entry{Name: "m/program", Kind: versions.KindBundle, Series: "0.1", Version: v, Seat: "oci-artifact-registry", State: versions.Numbered}) + if err != nil { + t.Fatal(err) + } + vs.Store.(memVersions)[versions.Key("m/program", v)] = raw + } + pubs := &publishes{bounds: func() jetstream.KeyValue { return nil }, versions: vs, findings: f, bound: versions.PublishBound, logf: t.Logf} + h := &seatHearer{n: &versions.Numberer{Versions: vs}, b: &build.Builder{}, logf: t.Logf, pub: pubs} + refusedKey := meshconditionsclient.Key(meshconditionsclient.ScopeModule, "m/program", versions.PublishRefused) + refused := &wakeMsg{subject: "mesh.seat.oci-artifact-registry.event.refused", data: []byte(`{"name":"m/program","version":"0.1.3","digest":"sha256:bb","reason":"bytes differ"}`)} + if !h.heard(context.Background(), refused) || !refused.acked { + t.Fatalf("a refusal: acked %v", refused.acked) + } + if got, err := meshconditionsclient.DecodeFinding(st.vals[refusedKey]); err != nil || !strings.Contains(got.Summary, "refused to publish m/program@0.1.3") || !strings.Contains(got.Summary, "bytes differ") { + t.Fatalf("a holder's refused: the finding = %+v, %v", got, err) + } + pubs.ended(context.Background(), purged{key: versions.PublishBoundKey("m/program", "0.1.4")}) + unansweredKey := meshconditionsclient.Key(meshconditionsclient.ScopeModule, "m/program", versions.PublishUnanswered) + if got, err := meshconditionsclient.DecodeFinding(st.vals[unansweredKey]); err != nil || !strings.Contains(got.Summary, "neither published nor refused of m/program@0.1.4") { + t.Fatalf("a bound ended unanswered: the finding = %+v, %v", got, err) + } + // Review round 2: a bound that cannot be written is publish-unanswered at once; a watch of bounds that cannot start + // is family-not-heard naming the state. + delete(st.vals, unansweredKey) + pubs.arm(context.Background(), versions.Entry{Name: "m/program", Version: "0.1.4", Seat: "oci-artifact-registry"}) + if got, err := meshconditionsclient.DecodeFinding(st.vals[unansweredKey]); err != nil || !strings.Contains(got.Summary, "its bound could not be written") { + t.Fatalf("a bound not written: the finding = %+v, %v", got, err) + } + watchBounds(context.Background(), refusedWatch{err: errors.New("permissions violation")}, func(jetstream.KeyValueEntry) {}, f.notHeard(context.Background()), nil, t.Logf) + if got, err := meshconditionsclient.DecodeFinding(st.vals[heard.Key(builds.BuilderModule, versions.BoundsState)]); err != nil || !strings.Contains(got.Summary, "permissions violation") { + t.Fatalf("bounds not watched: the finding = %+v, %v", got, err) + } + delete(st.vals, heard.Key(builds.BuilderModule, versions.BoundsState)) + malformed := &wakeMsg{subject: "mesh.seat.oci-artifact-registry.event.published", data: []byte(`{"version":"0.1.4"}`)} + unreadKey := meshconditionsclient.Key(meshconditionsclient.ScopeModule, "oci-artifact-registry", versions.SeatOutcomeUnread) + if !h.heard(context.Background(), malformed) || !malformed.acked || st.vals[unreadKey] == nil { + t.Fatalf("an outcome that does not read: acked %v, findings %v", malformed.acked, st.vals) + } + published := &wakeMsg{subject: "mesh.seat.oci-artifact-registry.event.published", data: []byte(`{"name":"m/program","version":"0.1.4","digest":"sha256:cc"}`)} + if !h.heard(context.Background(), published) || st.vals[refusedKey] != nil || st.vals[unansweredKey] != nil || st.vals[unreadKey] != nil { + t.Fatalf("a later published leaves the findings: %v", st.vals) + } + // ADR 0615 §5 (review round 1): a consumer not there yet is family-not-heard naming its stream, cleared when it binds. + notHeard := f.notHeard(context.Background()) + unheardKey := heard.Key(builds.BuilderModule, "FORGE") + _ = notHeard.Bind("FORGE", "a branch's end is not heard", func() error { return errors.New("consumer not found") }) + if got, err := meshconditionsclient.DecodeFinding(st.vals[unheardKey]); err != nil || got.Kind != heard.Kind || !strings.Contains(got.Summary, "does not hear FORGE") { + t.Fatalf("a consumer not there: the finding = %+v, %v", got, err) + } + _ = notHeard.Bind("FORGE", "a branch's end is not heard", func() error { return nil }) + if _, ok := st.vals[unheardKey]; ok { + t.Error("a consumer bound leaves family-not-heard") + } + // DLV-B9, the old form's window: one counted node reading only the first form holds it; seven days on it is + // old-form-held about that node; the node stating the newest form closes it, said once, the finding cleared; a + // retired node dropped by hand leaves the count. + now := time.Unix(0, 0) + fs := forms.ArtifactForms{{Name: "registry", First: true, Kinds: []string{"image"}}, {Name: "oci", Kinds: []string{"image"}}} + var closings []versions.OldFormClosedBody + var logged []builds.BuildLogEntry + marks := &memBounds{keys: map[string]bool{}} + window := &oldForm{forms: fs, findings: f, logf: t.Logf, now: func() time.Time { return now }, bounds: func() jetstream.KeyValue { return marks }, + said: func(b versions.OldFormClosedBody) { closings = append(closings, b) }, + log: func(e builds.BuildLogEntry) { logged = append(logged, e) }} + reads := &engineReads{keeper: &enginereads.Keeper{}, window: window, counted: []string{"a", "b"}} + newest := []string{forms.FormNeed("oci")} + counted := map[string][]string{"a": newest, "b": nil} + window.step(context.Background(), []string{"a", "b"}, counted) + heldKey := meshconditionsclient.Key(meshconditionsclient.ScopeMachine, "b", versions.OldFormHeld) + if !window.w.Open || !slices.Equal(window.w.Holding, []string{"b"}) || st.vals[heldKey] != nil { + t.Fatalf("one node lacking the newest form: %+v", window.w) + } + // Review round 1: the node holding it has a bound in the state bounds ending at its seven days, so old-form-held is + // raised then, not at the next change. + if !marks.keys[versions.OldFormBoundKey("b")] || marks.keys[versions.OldFormBoundKey("a")] { + t.Fatalf("the old form's bounds = %v, want b's alone", marks.keys) + } + // Security-upgrade F3 (review round 1): an empty node_haves read, naming no node, leaves the window open. + window.step(context.Background(), nil, map[string][]string{}) + if !window.w.Open || !slices.Equal(window.w.Holding, []string{"b"}) || len(closings) != 0 { + t.Fatalf("an empty read closed the window: %+v, closings %+v", window.w, closings) + } + now = now.Add(versions.FindingAfter) + window.step(context.Background(), []string{"a", "b"}, counted) + if got, err := meshconditionsclient.DecodeFinding(st.vals[heldKey]); err != nil || !strings.Contains(got.Summary, "the node-engine of b") { + t.Fatalf("seven days held: the finding = %+v, %v", got, err) + } + counted["b"] = newest + window.step(context.Background(), []string{"a", "b"}, counted) + window.step(context.Background(), []string{"a", "b"}, counted) + if marks.keys[versions.OldFormBoundKey("b")] { + t.Error("a node no longer holding the window keeps its bound") + } + if window.w.Open || len(closings) != 1 || !slices.Equal(closings[0].LastHolding, []string{"b"}) || st.vals[heldKey] != nil { + t.Errorf("the node back with the newest form: %+v, closings %+v, finding left %v", window.w, closings, st.vals[heldKey] != nil) + } + if len(logged) != 1 || logged[0].Event != builds.BuildSaidEvent || logged[0].Why != versions.SaidOldFormClosed || + !strings.Contains(logged[0].Line, "last held by b") { + t.Errorf("the build log = %+v, want the close said once (build.said)", logged) + } else if _, err := builds.EncodeBuildLogEntry(logged[0]); err != nil { + t.Errorf("the close is no entry the build log takes: %v", err) + } + if _, err := window.drop(context.Background(), "b", "op@shanks", "retired", now); err != nil { + t.Fatal(err) + } + if _, c := reads.forms(); len(c) != 1 || c["b"] != nil { + t.Errorf("a node dropped by hand is still counted: %v", c) + } +} + +// memVersions is a state versions in memory. +type memVersions map[string][]byte + +func (m memVersions) Get(_ context.Context, key string) ([]byte, uint64, bool, error) { + v, ok := m[key] + return v, 1, ok, nil +} +func (m memVersions) Create(_ context.Context, key string, v []byte) (uint64, error) { + m[key] = v + return 1, nil +} +func (m memVersions) Update(_ context.Context, key string, v []byte, _ uint64) (uint64, error) { + m[key] = v + return 2, nil +} +func (m memVersions) KeysOf(context.Context, string) ([]string, error) { + return slices.Collect(maps.Keys(m)), nil +} + +// memBounds is the state bounds in memory: which keys stand. +type memBounds struct { + jetstream.KeyValue + keys map[string]bool +} + +func (m *memBounds) Create(_ context.Context, key string, _ []byte, _ ...jetstream.KVCreateOpt) (uint64, error) { + if m.keys[key] { + return 0, jetstream.ErrKeyExists + } + m.keys[key] = true + return 1, nil +} +func (m *memBounds) Delete(_ context.Context, key string, _ ...jetstream.KVDeleteOpt) error { + delete(m.keys, key) + return nil +} + +// purged is the bus's marker of a key whose time to live ended. +type purged struct { + jetstream.KeyValueEntry + key string +} + +func (p purged) Key() string { return p.key } +func (p purged) Operation() jetstream.KeyValueOp { return jetstream.KeyValuePurge } + +// memFindings is a findings bucket in memory. +type memFindings struct{ vals map[string][]byte } + +type memEntry struct { + jetstream.KeyValueEntry + v []byte +} + +func (e memEntry) Value() []byte { return e.v } + +func (m *memFindings) Get(_ context.Context, key string) (jetstream.KeyValueEntry, error) { + v, ok := m.vals[key] + if !ok { + return nil, jetstream.ErrKeyNotFound + } + return memEntry{v: v}, nil +} +func (m *memFindings) Put(_ context.Context, key string, v []byte) (uint64, error) { + m.vals[key] = v + return 1, nil +} +func (m *memFindings) Delete(_ context.Context, key string, _ ...jetstream.KVDeleteOpt) error { + delete(m.vals, key) + return nil +} diff --git a/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/seats.go b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/seats.go new file mode 100644 index 00000000..d6029f81 --- /dev/null +++ b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/seats.go @@ -0,0 +1,148 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "strings" + + "github.com/nats-io/nats.go/jetstream" + + "git.novox.be/novox/mesh-delivery/contracts/go/seats/containerimageregistry" + "git.novox.be/novox/mesh-delivery/contracts/go/seats/gopackageregistry" + "git.novox.be/novox/mesh-delivery/contracts/go/seats/npmpackageregistry" + "git.novox.be/novox/mesh-delivery/contracts/go/seats/ociartifactregistry" + + "mesh-delivery-builder/internal/build" + "mesh-delivery-builder/internal/versions" +) + +// seatEvent is one registry seat's event the builder hears: the seat and the event's name. +type seatEvent struct{ seat, event string } + +// seatEvents are the four registry seats' published, refused, removed and kept, by subject (design 51 *How a publish ends*, +// DLV-B4): each a seat event `.` on the shared events stream, heard on the builder's durable consumer of +// it through the manifest's consumes. The subjects are the seats' contracts' (mesh-contracts). +var seatEvents = map[string]seatEvent{ + containerimageregistry.SubjectPublished: {containerimageregistry.Seat, containerimageregistry.EventPublished}, + containerimageregistry.SubjectRefused: {containerimageregistry.Seat, containerimageregistry.EventRefused}, + containerimageregistry.SubjectRemoved: {containerimageregistry.Seat, containerimageregistry.EventRemoved}, + containerimageregistry.SubjectKept: {containerimageregistry.Seat, containerimageregistry.EventKept}, + ociartifactregistry.SubjectPublished: {ociartifactregistry.Seat, ociartifactregistry.EventPublished}, + ociartifactregistry.SubjectRefused: {ociartifactregistry.Seat, ociartifactregistry.EventRefused}, + ociartifactregistry.SubjectRemoved: {ociartifactregistry.Seat, ociartifactregistry.EventRemoved}, + ociartifactregistry.SubjectKept: {ociartifactregistry.Seat, ociartifactregistry.EventKept}, + gopackageregistry.SubjectPublished: {gopackageregistry.Seat, gopackageregistry.EventPublished}, + gopackageregistry.SubjectRefused: {gopackageregistry.Seat, gopackageregistry.EventRefused}, + gopackageregistry.SubjectRemoved: {gopackageregistry.Seat, gopackageregistry.EventRemoved}, + gopackageregistry.SubjectKept: {gopackageregistry.Seat, gopackageregistry.EventKept}, + npmpackageregistry.SubjectPublished: {npmpackageregistry.Seat, npmpackageregistry.EventPublished}, + npmpackageregistry.SubjectRefused: {npmpackageregistry.Seat, npmpackageregistry.EventRefused}, + npmpackageregistry.SubjectRemoved: {npmpackageregistry.Seat, npmpackageregistry.EventRemoved}, + npmpackageregistry.SubjectKept: {npmpackageregistry.Seat, npmpackageregistry.EventKept}, +} + +// seatHearer takes the registry seats' outcomes in: each moves a numbered version (versions.Numberer.Heard), and a +// `published` that wakes a name waiting on its floor looks at every module again. +type seatHearer struct { + n *versions.Numberer + b *build.Builder + logf func(string, ...any) + // pub raises and clears the findings of a publish that ended badly; nil raises none. + pub *publishes +} + +// heard takes a message of the events consumer in when it is a registry seat's outcome, and answers whether it was. +// One the state could not take is left unacknowledged (Nak), so the bus gives it again; one that is no outcome is said +// and dropped. +func (h *seatHearer) heard(ctx context.Context, m jetstream.Msg) bool { + se, ok := seatEvents[m.Subject()] + if !ok { + return false + } + var o versions.Outcome + if err := json.Unmarshal(m.Data(), &o); err != nil || o.Name == "" { + if err == nil { + err = errors.New("it names no name") + } + h.logf("%s holds what is no outcome of %s, left: %v", m.Subject(), se.seat, err) + if h.pub != nil { + h.pub.unread(ctx, se.seat, m.Subject(), err) + } + h.ack(m) + return true + } + if h.pub != nil { + h.pub.read(ctx, se.seat) + } + id := "" + if m.Headers() != nil { + id = m.Headers().Get("x-event-id") + } + wake, err := h.n.Heard(ctx, se.seat, se.event, id, o) + if err != nil { + h.logf("%v; heard again", err) + if nakErr := m.Nak(); nakErr != nil { + h.logf("leaving %s for the bus to give again: %v", m.Subject(), nakErr) + } + return true + } + if h.pub != nil { + h.pub.heard(ctx, se.seat, se.event, o) + } + h.ack(m) + if len(wake) > 0 { + h.logf("the floor of %s is asked again: %s said %s", strings.Join(wake, ", "), se.seat, se.event) + h.b.Reconsider(ctx) + } + return true +} + +func (h *seatHearer) ack(m jetstream.Msg) { + if err := m.Ack(); err != nil { + h.logf("acknowledging %s: %v", m.Subject(), err) + } +} + +// kvKeeper is the state floor-waits as versions.FloorKeeper. +type kvKeeper struct { + ctx context.Context + kv jetstream.KeyValue +} + +func (k kvKeeper) Put(key string, value []byte) error { + _, err := k.kv.Put(k.ctx, key, value) + return err +} + +func (k kvKeeper) Delete(key string) error { + err := k.kv.Delete(k.ctx, key) + if errors.Is(err, jetstream.ErrKeyNotFound) { + return nil + } + return err +} + +// heldFloorWaits are the waits floor-waits holds at the builder's start; a value that is no wait is said and left. +func heldFloorWaits(ctx context.Context, kv jetstream.KeyValue, logf func(string, ...any)) []versions.FloorWait { + l, err := kv.ListKeys(ctx) + if err != nil { + logf("%s cannot be listed, so a restart's waits are asked at their next claim: %v", versions.FloorWaitsState, err) + return nil + } + defer func() { _ = l.Stop() }() + var out []versions.FloorWait + for k := range l.Keys() { + e, err := kv.Get(ctx, k) + if err != nil { + continue + } + w, err := versions.DecodeFloorWait(e.Value()) + if err != nil { + logf("%s.%s holds what is no floor wait, left: %v", versions.FloorWaitsState, k, err) + continue + } + out = append(out, w) + } + return out +} diff --git a/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/stored.go b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/stored.go index c5fc876a..0cf302c1 100644 --- a/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/stored.go +++ b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/stored.go @@ -9,8 +9,7 @@ import ( "github.com/nats-io/nats.go/jetstream" - "git.novox.be/novox/mesh-sdk/go/catalogue" - "git.novox.be/novox/mesh-sdk/go/delivery" + sweep "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshartifactsweep" "mesh-delivery-builder/internal/build" ) @@ -20,7 +19,7 @@ import ( const eventsStream = "EVENTS" // SeatHeld is the seat mesh-artifact-sweep's verb held (ADR 0571 §2). -const SeatHeld = "seat:" + catalogue.ArtifactSweepSeat + ".held" +const SeatHeld = "seat:" + sweep.Seat + ".held" // toolCaller calls a tool or a seat verb as the builder's own account (mesh-sdk process.Conn.Call). type toolCaller func(key string, body any) (json.RawMessage, error) @@ -35,7 +34,7 @@ func storedVia(call toolCaller) func(ctx context.Context, references []string) ( if err != nil { return nil, fmt.Errorf("asking held: %w", err) } - a, err := delivery.DecodeHeldAnswer(raw, references) + a, err := decodeHeld(raw, references) if err != nil { return nil, err } @@ -54,13 +53,62 @@ func storedVia(call toolCaller) func(ctx context.Context, references []string) ( // heardGone takes one artifacts-gone event in: acknowledged, then handed to the builder, which asks the store and builds // the key again. A body that is not one is said and acknowledged, so it is not handed over again. func heardGone(ctx context.Context, b *build.Builder, m jetstream.Msg, logf func(string, ...any)) { - e, err := delivery.DecodeArtifactsGone(m.Data()) + e, err := decodeGone(m.Data()) if ackErr := m.Ack(); ackErr != nil { logf("acknowledging %s: %v", m.Subject(), ackErr) } if err != nil { - logf("%s holds what is not %s, left: %v", m.Subject(), delivery.ArtifactsGoneEvent, err) + logf("%s holds what is not %s, left: %v", m.Subject(), sweep.EventArtifactsGone, err) return } b.ArtifactsGone(ctx, e) } + +// decodeHeld reads the seat's answer to held: the verb answers {output, ok, answer}, the answer being the seat's +// HeldAnswer. A reference asked that the answer names in none of held, gone and unknown is an error: an answer that +// leaves one out would let a key with a gone blob be reused. +func decodeHeld(raw []byte, asked []string) (sweep.HeldAnswer, error) { + var wrapped struct { + Output string `json:"output"` + OK bool `json:"ok"` + Answer json.RawMessage `json:"answer"` + } + if err := json.Unmarshal(raw, &wrapped); err != nil { + return sweep.HeldAnswer{}, fmt.Errorf("the answer of %s is not {output, ok, answer}: %w", SeatHeld, err) + } + if !wrapped.OK || len(wrapped.Answer) == 0 { + return sweep.HeldAnswer{}, fmt.Errorf("%s did not answer: %s", SeatHeld, wrapped.Output) + } + var a sweep.HeldAnswer + if err := json.Unmarshal(wrapped.Answer, &a); err != nil { + return sweep.HeldAnswer{}, fmt.Errorf("the answer of %s is not {held, gone}: %w", SeatHeld, err) + } + named := map[string]bool{} + for _, r := range a.Held { + named[r] = true + } + for _, r := range a.Gone { + named[r] = true + } + for r := range a.Unknown { + named[r] = true + } + for _, r := range asked { + if !named[r] { + return sweep.HeldAnswer{}, fmt.Errorf("the answer of %s names nothing of %s", SeatHeld, r) + } + } + return a, nil +} + +// decodeGone reads the event's body, refusing one that names no module, fingerprint or reference. +func decodeGone(raw []byte) (sweep.ArtifactsGone, error) { + var e sweep.ArtifactsGone + if err := json.Unmarshal(raw, &e); err != nil { + return sweep.ArtifactsGone{}, fmt.Errorf("%s is not one: %w", sweep.EventArtifactsGone, err) + } + if e.Event != sweep.EventArtifactsGone || e.Module == "" || e.Fingerprint == "" || len(e.Gone) == 0 { + return sweep.ArtifactsGone{}, fmt.Errorf("%s names no module, fingerprint or gone reference: %s", sweep.EventArtifactsGone, raw) + } + return e, nil +} diff --git a/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/tagbounds.go b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/tagbounds.go new file mode 100644 index 00000000..c9dc9d67 --- /dev/null +++ b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/tagbounds.go @@ -0,0 +1,132 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "time" + + "github.com/nats-io/nats.go/jetstream" + + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds" + + "mesh-delivery-builder/internal/versions" +) + +// tagBounds narrates a tag ask the builder has not answered within five minutes, with what it waits on (design 51 +// *Tagged versions*): each unanswered ask has a key in the state tag-ask-bounds written with its own time to live, and +// the bus's marker of its end, heard on the watch, is what narrates it and writes the bound again. No timer. +type tagBounds struct { + asks func() jetstream.KeyValue + bounds func() jetstream.KeyValue + waits func() []versions.FloorWait + logf func(string, ...any) + // log writes a narration into the builder's build log (build.said); nil writes none. + log func(builds.BuildLogEntry) + now func() time.Time + bound time.Duration +} + +// arm writes key's bound when none stands: a Create, so a standing bound keeps its time and a restart arms each +// unanswered ask whose bound ended while the builder was away. +func (tb *tagBounds) arm(ctx context.Context, key string) { + kv := tb.bounds() + if kv == nil { + return + } + if _, err := kv.Create(ctx, key, []byte(`{}`), jetstream.KeyTTL(tb.bound)); err != nil && !errors.Is(err, jetstream.ErrKeyExists) { + tb.logf("the bound of the tag ask %s is not written, so it is not narrated past five minutes: %v", key, err) + } +} + +// disarm removes key's bound: the ask was answered or ended. +func (tb *tagBounds) disarm(ctx context.Context, key string) { + if kv := tb.bounds(); kv != nil { + _ = kv.Delete(ctx, key) + } +} + +// asked is told each value of tag-asks: an unanswered ask is armed, any other disarmed. +func (tb *tagBounds) asked(ctx context.Context, key string, t versions.TagAsk) { + if t.Unanswered() { + tb.arm(ctx, key) + return + } + tb.disarm(ctx, key) +} + +// ended is told each change of tag-ask-bounds: a bound whose time ended (the bus's marker, a purge on the watch) on an +// ask still unanswered narrates it and arms it again. +func (tb *tagBounds) ended(ctx context.Context, e jetstream.KeyValueEntry) { + if e.Operation() != jetstream.KeyValuePurge { + return + } + kv := tb.asks() + if kv == nil { + return + } + got, err := kv.Get(ctx, e.Key()) + if err != nil { + return // the ask ended with its branch + } + t, err := versions.DecodeTagAsk(got.Value()) + if err != nil || !t.Unanswered() { + return + } + var waits []versions.FloorWait + if tb.waits != nil { + waits = tb.waits() + } + line := versions.NarrateUnanswered(t, waits, tb.now()) + tb.logf("%s", line) + // In the build log too (design 51 *Tagged versions*): under the builder's own key, no build being claimed yet, the + // ask's key standing for the fingerprint. + if tb.log != nil { + tb.log(builds.BuildLogEntry{Event: builds.BuildSaidEvent, Module: builds.BuilderModule, FP: e.Key(), Commit: t.Head, + At: tb.now().UTC(), Line: line, Why: versions.SaidUnanswered}) + } + tb.arm(ctx, e.Key()) +} + +// tagNumbered marks the tag ask of a check's build's head numbered with its versions in tag-asks, by compare-and-set. +func tagNumbered(kv func() jetstream.KeyValue, logf func(string, ...any), now func() time.Time) func(context.Context, versions.BuildClaim, versions.Numbering) { + return func(ctx context.Context, c versions.BuildClaim, got versions.Numbering) { + store := kv() + if store == nil { + return + } + lister, err := store.ListKeysFiltered(ctx, versions.EscapeToken(c.Repository)+".>") + if err != nil { + logf("the tag ask of %s at %s is not marked numbered: %v", c.Repository, c.Head, err) + return + } + for key := range lister.Keys() { + for range 3 { + e, err := store.Get(ctx, key) + if err != nil { + break + } + t, err := versions.DecodeTagAsk(e.Value()) + if err != nil { + break + } + t, changed := t.WithNumbered(c.Module, c.Head, got.Versions, got.Names, now()) + if !changed { + break + } + data, err := json.Marshal(t) + if err != nil { + break + } + if _, err = store.Update(ctx, key, data, e.Revision()); err == nil { + logf("%s: tag ask of %s %s: %s", key, t.Repository, t.Branch, t.Answer) + break + } + if !errors.Is(err, jetstream.ErrKeyExists) && !errors.Is(err, jetstream.ErrKeyRevisionMismatch) { + logf("the tag ask %s is not marked numbered: %v", key, err) + break + } + } + } + } +} diff --git a/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/toolchains.go b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/toolchains.go index 38a9259c..82734a16 100644 --- a/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/toolchains.go +++ b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/toolchains.go @@ -12,8 +12,6 @@ import ( "git.novox.be/novox/mesh-sdk/go/process" "github.com/nats-io/nats.go/jetstream" - - "git.novox.be/novox/mesh-sdk/go/delivery" ) // toolchainStreams are the stream each build module carries its toolchain.reported event on, as the bus names it @@ -57,7 +55,7 @@ func (w *toolchainWatch) ensure(module string) error { if w.watched[module] { return nil } - name := delivery.ToolchainsBucket(module) + name := buildrun.ToolchainsBucket(module) kv, err := w.js.KeyValue(w.ctx, name) switch { case errors.Is(err, jetstream.ErrBucketNotFound): diff --git a/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/versions.go b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/versions.go new file mode 100644 index 00000000..40d50d4e --- /dev/null +++ b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/versions.go @@ -0,0 +1,356 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + "github.com/nats-io/nats.go" + "github.com/nats-io/nats.go/jetstream" + + "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions" + "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" + "git.novox.be/novox/mesh-delivery/contracts/go/seats/ociartifactregistry" + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds" + "git.novox.be/novox/mesh-sdk/go/process" + + "mesh-delivery-builder/internal/build" + "mesh-delivery-builder/internal/versions" +) + +// The floor's two module tools (novox/hq design 51 *The floor*); the seat's is `seat:.versions`. +const ( + bridgeTagsTool = "mesh-delivery-forge-bridge.bridge_tags" + requirementsTool = "mesh-delivery-indexer.indexer_requirements" +) + +// awaitedVersions is the state versions as versions.Store: bound once the bus holds it (the grants part makes it, issue +// 1790); until then every use is an error, so a trunk build is built unnumbered and said (build.Builder.number). +type awaitedVersions struct{ b *process.AwaitedBucket } + +var errVersionsMissing = errors.New("the state " + versions.Bucket + " is not on the bus yet") + +func (s awaitedVersions) kv() (jetstream.KeyValue, error) { + if kv := s.b.KV(); kv != nil { + return kv, nil + } + return nil, errVersionsMissing +} + +func (s awaitedVersions) Get(ctx context.Context, key string) ([]byte, uint64, bool, error) { + kv, err := s.kv() + if err != nil { + return nil, 0, false, err + } + e, err := kv.Get(ctx, key) + if errors.Is(err, jetstream.ErrKeyNotFound) { + return nil, 0, false, nil + } + if err != nil { + return nil, 0, false, err + } + return e.Value(), e.Revision(), true, nil +} + +// indexerPackages is the indexer's packages state as the builder reads a package's name in its ecosystem from it (design +// 32): the picture of its read tool's first view and its views family (ADR 0615 §1), never its bucket. Until the first +// view is answered a package is built unnumbered and said (versions.Numberer.Names), and numbered once its name is heard +// (followPackages). +type indexerPackages struct{ kv jetstream.KeyValue } + +func (s indexerPackages) Keys(ctx context.Context) ([]string, error) { + keys, err := s.kv.Keys(ctx) + if errors.Is(err, jetstream.ErrNoKeysFound) { + return nil, nil + } + return keys, err +} + +func (s indexerPackages) Get(ctx context.Context, key string) ([]byte, bool, error) { + e, err := s.kv.Get(ctx, key) + if errors.Is(err, jetstream.ErrKeyNotFound) || errors.Is(err, jetstream.ErrKeyDeleted) { + return nil, false, nil + } + if err != nil { + return nil, false, err + } + return e.Value(), true, nil +} + +// followPackages watches the picture of the indexer's packages state: each series key heard that names a package's name +// (versions.NamedModule), its first view's included, looks at its module again (build.Builder.PackageNamed), so a done +// build that left the package unnumbered is numbered then with its digests. Woken by the views family, never a timer; a +// key heard again naming the name and module it named (the indexer writes a series at each publish) looks at nothing. +func followPackages(ctx context.Context, kv jetstream.KeyValue, b *build.Builder, logf func(string, ...any)) error { + w, err := kv.WatchAll(ctx) + if err != nil { + return err + } + heard := map[string]string{} + return follow(ctx, w, func(e jetstream.KeyValueEntry) { + if e.Operation() != jetstream.KeyValuePut { + delete(heard, e.Key()) + return + } + module, name, named := versions.NamedModule(e.Key(), e.Value()) + if named && heard[e.Key()] != module+" "+name { + heard[e.Key()] = module + " " + name + logf("%s.%s names a package of %s: its build is looked at again", packagesBucket, e.Key(), module) + b.PackageNamed(ctx, module) + } + }) +} + +func versionsConflict(err error) error { + if errors.Is(err, jetstream.ErrKeyExists) || errors.Is(err, jetstream.ErrKeyRevisionMismatch) { + return versions.ErrConflict + } + return err +} + +func (s awaitedVersions) Create(ctx context.Context, key string, value []byte) (uint64, error) { + kv, err := s.kv() + if err != nil { + return 0, err + } + rev, err := kv.Create(ctx, key, value) + return rev, versionsConflict(err) +} + +func (s awaitedVersions) Update(ctx context.Context, key string, value []byte, revision uint64) (uint64, error) { + kv, err := s.kv() + if err != nil { + return 0, err + } + rev, err := kv.Update(ctx, key, value, revision) + return rev, versionsConflict(err) +} + +func (s awaitedVersions) KeysOf(ctx context.Context, filter string) ([]string, error) { + kv, err := s.kv() + if err != nil { + return nil, err + } + l, err := kv.ListKeysFiltered(ctx, filter) + if err != nil { + return nil, err + } + defer func() { _ = l.Stop() }() + var out []string + for k := range l.Keys() { + out = append(out, k) + } + return out, nil +} + +// floorVia is the floor's three sources as calls of the builder's own account (design 51 *The floor*): the seat's verb +// versions, the forge bridge's bridge_tags and the indexer's indexer_requirements. A call that fails is that source +// silent. +func floorVia(call toolCaller) versions.FloorSources { + return versions.FloorSources{ + Seat: func(_ context.Context, seat, name string) ([]string, error) { + raw, err := call("seat:"+seat+".versions", map[string]any{"name": name}) + if err != nil { + return nil, err + } + var a struct { + Versions []struct { + Version string `json:"version"` + } `json:"versions"` + } + if err := json.Unmarshal(raw, &a); err != nil { + return nil, fmt.Errorf("%s's versions answered what is no list of versions: %w", seat, err) + } + var out []string + for _, v := range a.Versions { + if v.Version != "" { + out = append(out, v.Version) + } + } + return out, nil + }, + Tags: func(_ context.Context, repository, prefix string) ([]string, error) { + raw, err := call(bridgeTagsTool, map[string]any{"repository": repository, "prefix": prefix}) + if err != nil { + return nil, err + } + var a struct { + Tags []string `json:"tags"` + } + if err := json.Unmarshal(raw, &a); err != nil { + return nil, fmt.Errorf("bridge_tags answered what is no list of tags: %w", err) + } + return a.Tags, nil + }, + Requirements: func(_ context.Context, name string) ([]string, error) { + raw, err := call(requirementsTool, map[string]any{"package": name}) + if err != nil { + return nil, err + } + var a struct { + Requirements []string `json:"requirements"` + } + if err := json.Unmarshal(raw, &a); err != nil { + return nil, fmt.Errorf("indexer_requirements answered what is no list of versions: %w", err) + } + return a.Requirements, nil + }, + } +} + +// emitNumbered says the builder's event version.numbered (design 51 *Events of the builder*), on its own family apart +// from its builds' ends; the name and version are the bus's dedup id. +func emitNumbered(ctx context.Context, js jetstream.JetStream, cred process.Account, b builds.VersionNumbered) error { + id := "numbered-" + versions.Key(b.Name, b.Version) + if b.Again != "" { + id += "-again-" + b.Again + } + if err := emitEvent(ctx, js, cred, builds.VersionNumberedEvent, id, b); err != nil { + return fmt.Errorf("saying %s for %s@%s: %w", builds.VersionNumberedEvent, b.Name, b.Version, err) + } + return nil +} + +// emitEvent says one of the builder's events, mesh.mod..event., with the SDK's envelope headers and id as +// the bus's dedup id. +func emitEvent(ctx context.Context, js jetstream.JetStream, cred process.Account, event, id string, body any) error { + data, err := json.Marshal(body) + if err != nil { + return err + } + msg := nats.NewMsg(cred.EventSubject(event)) + msg.Header.Set("x-event-id", id) + msg.Header.Set("x-source", cred.Module) + msg.Header.Set("x-node", cred.Node) + msg.Header.Set("x-time", time.Now().UTC().Format(time.RFC3339Nano)) + msg.Header.Set("content-type", "application/json") + msg.Data = data + pub, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + _, err = js.PublishMsg(pub, msg, jetstream.WithMsgID(id)) + return err +} + +// The events on the builder's consumer of EVENTS that wake a floor (design 51 *The floor*, DLV-B3b). +var ( + manifestAcceptedSubject = meshcatalog.SubjectManifestAccepted + conditionClearedSubject = meshconditions.SubjectConditionCleared +) + +// floorWake asks the floor of the names an event wakes again, by looking at every module again: a trunk build is +// numbered before its claim (build.Builder.number). +type floorWake struct { + b *build.Builder + waits *versions.FloorWaits + logf func(string, ...any) +} + +// heard takes a message of the consumer in when it is one that wakes a floor, acknowledged, and answers whether it was. +func (f *floorWake) heard(ctx context.Context, m jetstream.Msg) bool { + switch m.Subject() { + case manifestAcceptedSubject: + f.ack(m) + f.wake(ctx, versions.WakeAccepted, "", "") + case conditionClearedSubject: + f.ack(m) + var e meshconditions.ConditionEvent + if err := json.Unmarshal(m.Data(), &e); err != nil { + f.logf("%s holds what is no condition, left: %v", m.Subject(), err) + return true + } + if e.Subject == nil { + return true + } + for _, module := range conditionModules(e.Subject.ID) { + f.wake(ctx, versions.WakeCleared, "", module) + } + default: + return false + } + return true +} + +func (f *floorWake) ack(m jetstream.Msg) { + if err := m.Ack(); err != nil { + f.logf("acknowledging %s: %v", m.Subject(), err) + } +} + +// wake looks at every module again when the event wakes a name, and says which. +func (f *floorWake) wake(ctx context.Context, kind, seat, module string) { + names := f.waits.Wake(kind, seat, module, "") + if len(names) == 0 { + return + } + f.logf("the floor of %s is asked again: %s", strings.Join(names, ", "), kind) + f.b.Reconsider(ctx) +} + +// conditionModules are the tokens of a condition's subject id (`/`, `.`): a module named +// among them is the one whose condition cleared. +func conditionModules(id string) []string { + return strings.FieldsFunc(id, func(r rune) bool { return r == '/' || r == '.' || r == ':' }) +} + +// doneVia says whether a build key is done, read from the builder's own builds state: the build's file is held. +func doneVia(b *build.Builder) func(ctx context.Context, module, fp string) (bool, error) { + return func(ctx context.Context, module, fp string) (bool, error) { + key, err := builds.BuilderBuildKey(module, fp) + if err != nil { + return false, err + } + raw, _, found, err := b.Claimer.Store.Get(ctx, key) + if err != nil || !found { + return false, err + } + cur, err := builds.DecodeBuilderBuild(raw) + if err != nil { + return false, err + } + return cur.State == builds.BuildDone, nil + } +} + +// removeVia calls a registry seat's remove for one tagged version, as the builder's own account (design 51 *Tagged +// versions*): the reason tag-gone; the seat says removed on its events, or answers kept when another tag names the +// digest (the registry part's RemoveAnswer: kept, reason, tags), which ends the remove. +func removeVia(call toolCaller) func(ctx context.Context, seat string, e versions.Entry) (versions.RemoveOutcome, error) { + return func(_ context.Context, seat string, e versions.Entry) (versions.RemoveOutcome, error) { + raw, err := call("seat:"+seat+".remove", map[string]any{"name": e.Name, "version": e.Version, "tag": e.Tag, "reason": "tag-gone"}) + if err != nil { + return versions.RemoveOutcome{}, err + } + var a ociartifactregistry.RemoveAnswer // the four registry seats share the part's answer + if len(raw) > 0 && json.Unmarshal(raw, &a) != nil { + return versions.RemoveOutcome{}, fmt.Errorf("%s's remove answered what is no answer of it: %s", seat, raw) + } + return versions.RemoveOutcome{Kept: a.Kept, Reason: a.Reason, Tags: a.Tags}, nil + } +} + +// tagAsked answers an unanswered value of tag-asks by compare-and-set at its revision, said in the log. +func tagAsked(ctx context.Context, acts versions.Acts, kv jetstream.KeyValue, e jetstream.KeyValueEntry, logf func(string, ...any)) { + if e.Operation() != jetstream.KeyValuePut { + return + } + t, err := versions.DecodeTagAsk(e.Value()) + if err != nil { + logf("%s.%s holds what is not a tag ask, left: %v", versions.TagAsksState, e.Key(), err) + return + } + if !t.Answered.IsZero() { + return + } + t = acts.TagAsked(ctx, t, time.Now()) + logf("%s: %s of %s %s by %s (%s): %s", e.Key(), map[bool]string{true: "untag", false: "tag ask"}[t.Untag], t.Repository, t.Branch, t.By, t.Why, t.Answer) + data, err := json.Marshal(t) + if err != nil { + return + } + if _, err := kv.Update(ctx, e.Key(), data, e.Revision()); err != nil && !errors.Is(err, jetstream.ErrKeyExists) && !errors.Is(err, jetstream.ErrKeyRevisionMismatch) { + logf("%s.%s: the answer could not be written: %v", versions.TagAsksState, e.Key(), err) + } +} diff --git a/modules/mesh-delivery-builder/go.mod b/modules/mesh-delivery-builder/go.mod index 3131c6f3..916455c4 100644 --- a/modules/mesh-delivery-builder/go.mod +++ b/modules/mesh-delivery-builder/go.mod @@ -3,25 +3,62 @@ module mesh-delivery-builder go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103221822-85ba0a9a70d8 + git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-contracts/go/clients/meshmoduleassignerclient v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshhandactsclient v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshwatchdogs v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliveryclient v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-delivery/contracts/go/seats/containerimageregistry v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-delivery/contracts/go/seats/gopackageregistry v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdelivery v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-delivery/contracts/go/seats/npmpackageregistry v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-delivery/contracts/go/seats/ociartifactregistry v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-delivery/modules/mesh-delivery-forge-bridge/forgemsg v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000 github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats.go v1.54.0 ) -require git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep v0.0.0-20261102193506-d221c633799a - require ( git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun v0.0.0-00010101000000-000000000000 - github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op // indirect + github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op // indirect github.com/google/go-tpm v0.9.8 // indirect - github.com/klauspost/compress v1.20.0 // indirect + github.com/klauspost/compress v1.20.1 // indirect github.com/minio/highwayhash v1.0.4 // indirect - github.com/nats-io/jwt/v2 v2.8.1 // indirect + github.com/nats-io/jwt/v2 v2.8.2 // indirect github.com/nats-io/nkeys v0.4.16 // indirect github.com/nats-io/nuid v1.0.1 // indirect golang.org/x/crypto v0.57.0 // indirect golang.org/x/sys v0.48.0 // indirect - golang.org/x/time v0.15.0 // indirect + golang.org/x/time v0.16.0 // indirect ) replace git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun => ./buildrun + +require ( + git.novox.be/novox/mesh-contracts/go/seats/nodeengine v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-delivery/sdk v0.0.0-00010101000000-000000000000 +) + +replace git.novox.be/novox/mesh-delivery/sdk => ../../sdk + +require git.novox.be/novox/mesh-delivery/contracts/go/seats/meshartifactsweep v0.0.0-00010101000000-000000000000 + +require git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient v0.0.0-00010101000000-000000000000 + +require git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions v0.0.0-00010101000000-000000000000 + +replace git.novox.be/novox/mesh-delivery/contracts/go/seats/meshartifactsweep => ../../contracts/go/seats/meshartifactsweep + +require ( + git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshhandacts v0.0.0-00010101000000-000000000000 // indirect + git.novox.be/novox/mesh-delivery/contracts/go/clients/containerimageregistryclient v0.0.0-00010101000000-000000000000 // indirect + git.novox.be/novox/mesh-delivery/contracts/go/clients/gopackageregistryclient v0.0.0-00010101000000-000000000000 // indirect + git.novox.be/novox/mesh-delivery/contracts/go/clients/npmpackageregistryclient v0.0.0-00010101000000-000000000000 // indirect + git.novox.be/novox/mesh-delivery/contracts/go/clients/ociartifactregistryclient v0.0.0-00010101000000-000000000000 // indirect +) diff --git a/modules/mesh-delivery-builder/go.sum b/modules/mesh-delivery-builder/go.sum index 7ff42872..8ca93dfd 100644 --- a/modules/mesh-delivery-builder/go.sum +++ b/modules/mesh-delivery-builder/go.sum @@ -1,17 +1,13 @@ -git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep v0.0.0-20261102193506-d221c633799a h1:oxgksCiq272/afXbUION/dxtAS5MPGnmyq6+AZQxmXg= -git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep v0.0.0-20261102193506-d221c633799a/go.mod h1:kpsX4Pgss5EeuBvel1zWa2ogj7MkR3jZso6oa/J0YUQ= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103221822-85ba0a9a70d8 h1:XEbIVImp6yGjA91rp0iZBoDdL5KfamjOuDLwWvqh0hc= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103221822-85ba0a9a70d8/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= -github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= -github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= +github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op h1:1BOWQJweNyvZMlpAHXGLiZQn9S+QXGcz3xh94lC0w6E= +github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= -github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA= -github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= +github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ= +github.com/klauspost/compress v1.20.1/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= github.com/minio/highwayhash v1.0.4 h1:asJizugGgchQod2ja9NJlGOWq4s7KsAWr5XUc9Clgl4= github.com/minio/highwayhash v1.0.4/go.mod h1:GGYsuwP/fPD6Y9hMiXuapVvlIUEhFhMTh0rxU3ik1LQ= -github.com/nats-io/jwt/v2 v2.8.1 h1:V0xpGuD/N8Mi+fQNDynXohVvp7ZztevW5io8CUWlPmU= -github.com/nats-io/jwt/v2 v2.8.1/go.mod h1:nWnOEEiVMiKHQpnAy4eXlizVEtSfzacZ1Q43LIRavZg= +github.com/nats-io/jwt/v2 v2.8.2 h1:XXRgB60MSTnqsRwejQurVDs/hcv2dkt+86GjI+I/bMc= +github.com/nats-io/jwt/v2 v2.8.2/go.mod h1:Ag/56sq9OblL4JgdYufDd16Egb17Kr/8WwwuO/forVc= github.com/nats-io/nats-server/v2 v2.11.17 h1:GKEghcFK6A+aFx11Yf1LjgLC3txAwvyhnYzhBIQZA8I= github.com/nats-io/nats-server/v2 v2.11.17/go.mod h1:B1sFVz4StNosQ903ak4N1G01Fl/9f8e06mXpFIE2K24= github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA= @@ -25,5 +21,5 @@ golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZ golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= -golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= -golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= +golang.org/x/time v0.16.0 h1:vMb6ptszcQMkcwiRTAuNNU50gom6++Q/6gY2hDM6VDE= +golang.org/x/time v0.16.0/go.mod h1:rVKOqvZeKvrDKTQiAHJ7wmwP0RzleSphoEA9RcdLA0s= diff --git a/modules/mesh-delivery-builder/internal/build/announce.go b/modules/mesh-delivery-builder/internal/build/announce.go index 7ccdd9b2..c810d215 100644 --- a/modules/mesh-delivery-builder/internal/build/announce.go +++ b/modules/mesh-delivery-builder/internal/build/announce.go @@ -5,7 +5,7 @@ import ( "errors" "fmt" - "git.novox.be/novox/mesh-sdk/go/delivery" + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds" ) // **A done build is announced again by hand** (recovery of photos and link2pay on novox, 2026-10-29). The controller takes a @@ -18,7 +18,7 @@ import ( // not done is refused with why: failed (builder_build_again builds it) or waiting (builder_ask_again). func (b *Builder) AnnounceAgain(ctx context.Context, module, fp, why, by string) BuildAgainResult { b.init() - key, err := delivery.BuilderBuildKey(module, fp) + key, err := builds.BuilderBuildKey(module, fp) if err != nil { return BuildAgainResult{Refused: err.Error()} } @@ -29,13 +29,13 @@ func (b *Builder) AnnounceAgain(ctx context.Context, module, fp, why, by string) if !found { return BuildAgainResult{Refused: fmt.Sprintf("%s has no build key: nothing to announce", key)} } - cur, err := delivery.DecodeBuilderBuild(value) + cur, err := builds.DecodeBuilderBuild(value) if err != nil { return BuildAgainResult{Refused: fmt.Sprintf("%s holds what is not a build: %v", key, err)} } switch cur.State { - case delivery.BuildDone: - case delivery.BuildFailed: + case builds.BuildDone: + case builds.BuildFailed: return BuildAgainResult{Refused: fmt.Sprintf("%s is failed, not done: %s builds it again", key, BuildAgainTool)} default: return BuildAgainResult{Refused: fmt.Sprintf("%s is %q, not done: %s asks its waiting asks again", key, cur.State, AskAgainTool)} diff --git a/modules/mesh-delivery-builder/internal/build/announce_test.go b/modules/mesh-delivery-builder/internal/build/announce_test.go index 021c900a..64101416 100644 --- a/modules/mesh-delivery-builder/internal/build/announce_test.go +++ b/modules/mesh-delivery-builder/internal/build/announce_test.go @@ -6,19 +6,19 @@ import ( "strings" "testing" - "git.novox.be/novox/mesh-sdk/go/delivery" + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds" ) func TestAnnounceAgainRewritesOnlyADoneKeyUnchanged(t *testing.T) { // recovery of photos and link2pay, 2026-10-29 for _, c := range []struct { name string - key *delivery.BuilderBuild + key *builds.BuilderBuild fp string refuse string }{ - {name: "a done key is written again, unchanged, as a new revision", key: &delivery.BuilderBuild{State: delivery.BuildDone, Step: "built"}}, - {name: "a failed key is refused, builder_build_again is for it", refuse: "builder_build_again", key: &delivery.BuilderBuild{State: delivery.BuildFailed, Error: "x"}}, - {name: "a building key is refused, builder_ask_again is for it", refuse: "builder_ask_again", key: &delivery.BuilderBuild{State: delivery.BuildBuilding, By: "novox.builder", Step: "asked"}}, + {name: "a done key is written again, unchanged, as a new revision", key: &builds.BuilderBuild{State: builds.BuildDone, Step: "built"}}, + {name: "a failed key is refused, builder_build_again is for it", refuse: "builder_build_again", key: &builds.BuilderBuild{State: builds.BuildFailed, Error: "x"}}, + {name: "a building key is refused, builder_ask_again is for it", refuse: "builder_ask_again", key: &builds.BuilderBuild{State: builds.BuildBuilding, By: "novox.builder", Step: "asked"}}, {name: "a key with no build is refused", refuse: "has no build key"}, {name: "a fingerprint that is not one is refused", refuse: "fingerprint", fp: "nope"}, } { diff --git a/modules/mesh-delivery-builder/internal/build/askagain.go b/modules/mesh-delivery-builder/internal/build/askagain.go index e91e6ba2..072b6877 100644 --- a/modules/mesh-delivery-builder/internal/build/askagain.go +++ b/modules/mesh-delivery-builder/internal/build/askagain.go @@ -12,12 +12,12 @@ import ( "time" "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun" - "git.novox.be/novox/mesh-sdk/go/buildstep" - "git.novox.be/novox/mesh-sdk/go/delivery" + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds" + sdkstep "git.novox.be/novox/mesh-delivery/sdk/buildstep" ) // **An ask that can no longer be answered is asked again** (novox/hq issue 1945). A build seat's stream gives an ask up -// once it was delivered as often as its worker may (delivery.AskMaxDeliver), and a message given up on, dropped or +// once it was delivered as often as its worker may (sdkstep.AskMaxDeliver), and a message given up on, dropped or // terminated with no step written is answered by no build node: the lane waiting for its step would wait for ever, and on // restart take over the same id and wait again. The builder notices it at the check bound (CheckAfter, the step bound), // a context's bound and no timer of its own: it asks Gone, which reads the bus, and asks such an ask again under a new @@ -90,10 +90,10 @@ type Taken struct { } // Proves is whether a taken ask of kind taken shows that a message of kind waiting asked before it is no longer -// delivered: a holder pulls the kinds in delivery.AskKinds' order, so taking one of the same kind or of a later one means +// delivered: a holder pulls the kinds in sdkstep.AskKinds' order, so taking one of the same kind or of a later one means // its worker had none of waiting's kind left to give. func Proves(taken, waiting string) bool { - t, w := slices.Index(delivery.AskKinds, taken), slices.Index(delivery.AskKinds, waiting) + t, w := slices.Index(sdkstep.AskKinds, taken), slices.Index(sdkstep.AskKinds, waiting) return t >= 0 && w >= 0 && t >= w } @@ -147,7 +147,7 @@ func (s AskSeen) at() time.Time { return s.At } -// AskedAt is when an ask id was made (buildstep.NewID: its milliseconds in base 36 before six hex digits), or zero. +// AskedAt is when an ask id was made (sdkstep.NewID: its milliseconds in base 36 before six hex digits), or zero. func AskedAt(id string) time.Time { if len(id) <= 6 { return time.Time{} @@ -162,11 +162,11 @@ func AskedAt(id string) time.Time { // Dropper removes an ask's message from its build seat's stream (Builder.Gone may be one): the old message of an ask asked // again, so a message given up on does not sit ahead of every later ask of its kind until its age (issue 1945 note 62). type Dropper interface { - Drop(ctx context.Context, a delivery.StepAsk) error + Drop(ctx context.Context, a sdkstep.StepAsk) error } // drop removes the old ask's message when Gone can; a refusal is said and changes nothing else: the new ask is put. -func (b *Builder) drop(ctx context.Context, ask delivery.StepAsk, old string) { +func (b *Builder) drop(ctx context.Context, ask sdkstep.StepAsk, old string) { d, ok := b.Gone.(Dropper) if !ok { return @@ -302,8 +302,8 @@ func (h byHand) Error() string { return "asked again by hand: " + h.why } // answer waits for the step of the lane's ask, and asks it again while it can no longer be answered (Gone) or a person // asked it again (AskAgain); it answers the step and the ask that has it. -func (b *Builder) answer(ctx context.Context, l *lane, ask delivery.StepAsk, asked *askNotes, module, fp string) (delivery.Step, delivery.StepAsk, error) { - key, _ := delivery.BuilderBuildKey(module, fp) +func (b *Builder) answer(ctx context.Context, l *lane, ask sdkstep.StepAsk, asked *askNotes, module, fp string) (sdkstep.Step, sdkstep.StepAsk, error) { + key, _ := builds.BuilderBuildKey(module, fp) again := b.listen(key, ask.Language) defer b.unlisten(key, ask.Language, again) asking := askingOf(asked.step, l.module) @@ -334,7 +334,7 @@ func (b *Builder) answer(ctx context.Context, l *lane, ask delivery.StepAsk, ask return step, ask, ctx.Err() } old := ask.ID - ask.ID, ask.At = buildstep.NewID(), time.Now() + ask.ID, ask.At = sdkstep.NewID(), time.Now() if err := asked.note(ctx, ask.Language, ask.ID, fmt.Sprintf("put %s again after %s declined it", l.module, step.Node)); err != nil { return step, ask, fmt.Errorf("the ask of %s put again after a decline could not be written to the key: %w", l.module, err) } @@ -349,7 +349,7 @@ func (b *Builder) answer(ctx context.Context, l *lane, ask delivery.StepAsk, ask } asking++ old := ask.ID - ask.ID, ask.At = buildstep.NewID(), time.Now() + ask.ID, ask.At = sdkstep.NewID(), time.Now() if err := asked.note(ctx, ask.Language, ask.ID, fmt.Sprintf("asked %s again (asking %d): %s", l.module, asking, why)); err != nil { return step, ask, fmt.Errorf("the ask of %s asked again could not be written to the key: %w", l.module, err) } @@ -370,7 +370,7 @@ func (b *Builder) answer(ctx context.Context, l *lane, ask delivery.StepAsk, ask // waitOnce waits for the ask's step until it ends, a person asks it again, or the check bound passes: then it asks Gone // and answers errAgain with why when the ask can no longer be answered, errCheck when it waits on. -func (b *Builder) waitOnce(ctx context.Context, ask delivery.StepAsk, again <-chan string) (delivery.Step, string, error) { +func (b *Builder) waitOnce(ctx context.Context, ask sdkstep.StepAsk, again <-chan string) (sdkstep.Step, string, error) { hand, cancel := context.WithCancelCause(ctx) defer cancel(nil) go func() { @@ -387,7 +387,7 @@ func (b *Builder) waitOnce(ctx context.Context, ask delivery.StepAsk, again <-ch defer stop() } step, err := b.Asks.Wait(wait, ask) - if _, declined := delivery.DeclinedOf(step); err == nil && declined { + if _, declined := sdkstep.DeclinedOf(step); err == nil && declined { // A build node off the agreed toolchain sat out (ADR 0584 clause 2). It is no asking: answer() puts the ask again // after a pause, within DeclineBound. return step, step.Reason, errDeclined diff --git a/modules/mesh-delivery-builder/internal/build/askfake_test.go b/modules/mesh-delivery-builder/internal/build/askfake_test.go index 6894884e..c5bcffb2 100644 --- a/modules/mesh-delivery-builder/internal/build/askfake_test.go +++ b/modules/mesh-delivery-builder/internal/build/askfake_test.go @@ -7,7 +7,7 @@ import ( "maps" "strings" - "git.novox.be/novox/mesh-sdk/go/delivery" + "git.novox.be/novox/mesh-delivery/sdk/buildstep" ) // The build runner a build node runs an ask with is mesh-build-go's (novox/hq ADR 0593 §1), another repository's, @@ -21,18 +21,19 @@ type stepFunc func(ctx context.Context, args map[string]any) (any, error) // askSteps are a build module's steps an ask is run with. type askSteps struct{ Fetch, Build, Copy stepFunc } -// runAsk runs a build ask with steps and answers its delivery.BuildAnswer, or the error that fails the step. -func runAsk(ctx context.Context, steps askSteps, ask delivery.StepAsk) (any, error) { +// runAsk runs a build ask with steps and answers its buildstep.BuildAnswer, or the error that fails the step. +func runAsk(ctx context.Context, steps askSteps, ask buildstep.StepAsk) (any, error) { if ask.Build == nil { return nil, fmt.Errorf("the ask %s holds no build job", ask.ID) } job := *ask.Build fetch := job.Fetch if len(ask.Packages) > 0 && fetch != nil { - fetch = maps.Clone(fetch) - fetch["packages"] = maps.Clone(ask.Packages) + withPackages := *fetch + withPackages.Packages = maps.Clone(ask.Packages) + fetch = &withPackages } - answer := delivery.BuildAnswer{} + answer := buildstep.BuildAnswer{} workspace := "" for _, a := range job.Artifacts { var out any @@ -44,7 +45,7 @@ func runAsk(ctx context.Context, steps askSteps, ask delivery.StepAsk) (any, err out, err = steps.Copy(ctx, map[string]any{"module": job.Module, "artifact": a.Artifact, "from": a.From}) } else { if workspace == "" { - raw, err := asRaw(steps.Fetch(ctx, fetch)) + raw, err := asRaw(steps.Fetch(ctx, fetchArgs(fetch))) if err != nil { return nil, err } @@ -56,7 +57,7 @@ func runAsk(ctx context.Context, steps askSteps, ask delivery.StepAsk) (any, err if err := json.Unmarshal(raw, &f); err != nil || f.Workspace == "" { return nil, fmt.Errorf("the fetch answered no workspace: %s", raw) } - if _, candidate := fetch["base"]; candidate && (len(f.Conflicts) > 0 || f.Merge == "") { + if candidate := fetch.Base != ""; candidate && (len(f.Conflicts) > 0 || f.Merge == "") { return nil, fmt.Errorf("the head does not merge into the trunk: %s", strings.Join(f.Conflicts, ", ")) } workspace = f.Workspace @@ -78,7 +79,7 @@ func runAsk(ctx context.Context, steps askSteps, ask delivery.StepAsk) (any, err if err := json.Unmarshal(raw, &built); err != nil || built.Digest == "" { return nil, fmt.Errorf("the build of %s answered no digest: %s", a.Artifact, raw) } - answer.Artifacts = append(answer.Artifacts, delivery.AskBuilt{Artifact: a.Artifact, Digest: built.Digest, Reference: built.Reference}) + answer.Artifacts = append(answer.Artifacts, buildstep.AskBuilt{Artifact: a.Artifact, Digest: built.Digest, Reference: built.Reference}) } return answer, nil } @@ -92,3 +93,11 @@ func asRaw(v any, err error) (json.RawMessage, error) { } return json.Marshal(v) } + +// fetchArgs is the fetch of an ask as the fetch tool is called with it: its JSON object. +func fetchArgs(f *buildstep.Fetch) map[string]any { + raw, _ := json.Marshal(f) + var args map[string]any + _ = json.Unmarshal(raw, &args) + return args +} diff --git a/modules/mesh-delivery-builder/internal/build/buildagain.go b/modules/mesh-delivery-builder/internal/build/buildagain.go index 62d3deee..6f39187e 100644 --- a/modules/mesh-delivery-builder/internal/build/buildagain.go +++ b/modules/mesh-delivery-builder/internal/build/buildagain.go @@ -8,7 +8,7 @@ import ( "time" "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun" - "git.novox.be/novox/mesh-sdk/go/delivery" + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds" ) // **A failed key is built again by hand** (novox/hq issue 1126 note 72; ADR 0429 clause 18: "a fingerprint is built again @@ -88,7 +88,7 @@ func (b *Builder) handEnded(key string) { // ctx is the builder's own context: the build it starts lives on it. func (b *Builder) BuildAgain(ctx context.Context, module, fp, why, by string, bound time.Duration) BuildAgainResult { b.init() - key, err := delivery.BuilderBuildKey(module, fp) + key, err := builds.BuilderBuildKey(module, fp) if err != nil { return BuildAgainResult{Refused: err.Error()} } @@ -99,18 +99,18 @@ func (b *Builder) BuildAgain(ctx context.Context, module, fp, why, by string, bo if !found { return BuildAgainResult{Refused: fmt.Sprintf("%s has no build key: nothing failed. The builder builds a fingerprint by itself once the indexer writes it to its module", key)} } - cur, err := delivery.DecodeBuilderBuild(value) + cur, err := builds.DecodeBuilderBuild(value) if err != nil { return BuildAgainResult{Refused: fmt.Sprintf("%s holds what is not a build: %v", key, err)} } switch { - case cur.State == delivery.BuildDone: + case cur.State == builds.BuildDone: return BuildAgainResult{Refused: fmt.Sprintf("%s is done: nothing to do", key)} - case cur.State == delivery.BuildBuilding || cur.State == delivery.BuildQueued: + case cur.State == builds.BuildBuilding || cur.State == builds.BuildQueued: return BuildAgainResult{Refused: fmt.Sprintf("%s is waiting (%s: %s): %s asks its waiting asks again", key, cur.State, cur.Step, AskAgainTool)} - case cur.State != delivery.BuildFailed: + case cur.State != builds.BuildFailed: return BuildAgainResult{Refused: fmt.Sprintf("%s is %q, not failed", key, cur.State)} - case delivery.FailedUntilChanged(cur.Error) || strings.HasPrefix(cur.Error, buildrun.DigestsDifferPrefix): + case builds.FailedUntilChanged(cur.Error) || strings.HasPrefix(cur.Error, buildrun.DigestsDifferPrefix): return BuildAgainResult{Refused: fmt.Sprintf("%s failed for a reason the builder does not retry on the same fingerprint, it waits for another fingerprint: %s", key, cur.Error)} } // One critical section: the key is refused when it is looked at or asked already, the module still has the fingerprint, @@ -119,7 +119,7 @@ func (b *Builder) BuildAgain(ctx context.Context, module, fp, why, by string, bo b.mu.Lock() m, known := b.known[module] switch { - case !known || m.FP2 != fp: + case !known || m.Fp2 != fp: b.mu.Unlock() return BuildAgainResult{Refused: fmt.Sprintf("module %s does not have the fingerprint %s now (a newer one superseded it, or its modules value is not read yet): the builder builds the module's current fingerprint", module, fp)} case m.Manifest == "": @@ -159,11 +159,11 @@ func (b *Builder) BuildAgain(ctx context.Context, module, fp, why, by string, bo case ended && len(res.Asks) < max(lanes, 0)+boolInt(lanes < 0): // The look ended before every ask was put: say how the key stands, not that it was asked. if v, _, found, err := b.Claimer.Store.Get(ctx, key); err == nil && found { - if cur, err := delivery.DecodeBuilderBuild(v); err == nil { + if cur, err := builds.DecodeBuilderBuild(v); err == nil { switch cur.State { - case delivery.BuildFailed: + case builds.BuildFailed: res.Failed = cur.Error - case delivery.BuildDone: + case builds.BuildDone: default: res.Pending = fmt.Sprintf("the look ended with the key %s (%s) before every ask was put: its log says why", cur.State, cur.Step) } diff --git a/modules/mesh-delivery-builder/internal/build/candidates.go b/modules/mesh-delivery-builder/internal/build/candidates.go index fd9cc4de..fc65815c 100644 --- a/modules/mesh-delivery-builder/internal/build/candidates.go +++ b/modules/mesh-delivery-builder/internal/build/candidates.go @@ -5,8 +5,10 @@ import ( "fmt" "sort" + "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun" - "git.novox.be/novox/mesh-sdk/go/delivery" + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds" + "git.novox.be/novox/mesh-delivery/sdk/buildstep" ) // How a candidate's fingerprint is built (novox/hq ADR 0484 clause 3, issue 1684): the indexer writes, for each module a @@ -24,7 +26,7 @@ type via struct{ Ref, Base string } // carries. A value that is not a candidate is said and left. func (b *Builder) Candidate(ctx context.Context, key string, value []byte) { b.init() - c, err := delivery.DecodeCandidate(value) + c, err := meshcatalog.DecodeCandidatesByGroupValue(value) if err != nil { b.logf("candidates.%s holds what is not a candidate, left: %v", key, err) return @@ -43,7 +45,7 @@ func (b *Builder) ForgetCandidate(key string) { b.mu.Unlock() } -func (b *Builder) considerCandidate(ctx context.Context, c delivery.Candidate) { +func (b *Builder) considerCandidate(ctx context.Context, c meshcatalog.CandidatesByGroupValue) { for _, p := range c.Pulls { names := make([]string, 0, len(p.Fps)) for name := range p.Fps { @@ -52,33 +54,33 @@ func (b *Builder) considerCandidate(ctx context.Context, c delivery.Candidate) { sort.Strings(names) for _, name := range names { cm := p.Fps[name] - if cm.FP2 == "" || cm.Manifest == "" { + if cm.Fp2 == "" || cm.Manifest == "" { continue // no fingerprint of the new form for it (a reason in none2) or no manifest to build by } b.mu.Lock() trunk, known := b.known[name] b.mu.Unlock() if !known || trunk.Path == "" { - b.logf("candidates.%s: the module %s has no record of the trunk yet, so its fingerprint %s is not built", c.Group, name, cm.FP2) + b.logf("candidates.%s: the module %s has no record of the trunk yet, so its fingerprint %s is not built", c.Group, name, cm.Fp2) continue } - key, err := delivery.BuilderBuildKey(name, cm.FP2) + key, err := builds.BuilderBuildKey(name, cm.Fp2) if err != nil { b.logf("candidates.%s: %v", c.Group, err) continue } - m := delivery.Module{Repository: p.Repository, Commit: p.Head.Head, FP2: cm.FP2, Manifest: cm.Manifest, Path: trunk.Path, - Toolchains: b.agreedFor(ctx, cm.Manifest), Packages: cm.Packages} // the group's package heads (ADR 0573 §4) + m := meshcatalog.ModulesByModuleValue{Repository: p.Repository, Commit: p.Head, Fp2: cm.Fp2, Manifest: cm.Manifest, Path: trunk.Path, + Toolchains: CatalogToolchains(b.agreedFor(ctx, cm.Manifest)), Packages: cm.Packages} // the group's package heads (ADR 0573 §4) if len(cm.Paths) > 0 { // The named paths' objects the preview gave it: the fetch names the paths (runLane, ADR 0499 clause 4). - m.Inputs2 = &delivery.KeptInputs2{Paths: cm.Paths} + m.Inputs2 = &meshcatalog.ModulesByModuleValueInputs2{Paths: cm.Paths} } // The preview is merged onto the trunk head the candidate was previewed onto: the head of the pull request's // repository in the gate's source revision c.Base (ADR 0484 §3). Never the module's value's commit: that is the // commit its fingerprint first appeared at, and may be many merges behind the trunk. - base, err := b.trunkHead(ctx, c.Base, p.Repository) + base, err := b.trunkHead(ctx, uint64(c.Base), p.Repository) if err != nil { - b.logf("candidates.%s: the module %s's fingerprint %s is not built: %v", c.Group, name, cm.FP2, err) + b.logf("candidates.%s: the module %s's fingerprint %s is not built: %v", c.Group, name, cm.Fp2, err) continue } b.mu.Lock() @@ -94,19 +96,19 @@ func (b *Builder) considerCandidate(ctx context.Context, c delivery.Candidate) { // agreedFor are the toolchains the build nodes agree on now, for the languages of the manifest's plan: the toolchains the // candidate's fingerprint was computed with, as the indexer recomputes it whenever they change. A language with none agreed // is left out, and the build then waits for it as the trunk's does. -func (b *Builder) agreedFor(ctx context.Context, manifest string) map[string]delivery.ToolchainUsed { +func (b *Builder) agreedFor(ctx context.Context, manifest string) map[string]builds.ToolchainUsed { plan, err, _ := b.planOf(ctx, manifest) if err != nil { return nil } - out := map[string]delivery.ToolchainUsed{} + out := map[string]builds.ToolchainUsed{} for _, bm := range plan.Modules() { if !plan.NeedsToolchain(bm) { continue } language := buildrun.LanguageOf[bm] - if tc, none := delivery.AgreedToolchain(b.Toolchains.Entries(language)); none == "" { - out[language] = delivery.ToolchainUsed{Version: tc.Version, Recipe: tc.Recipe} + if tc, none := buildstep.AgreedToolchain(b.Toolchains.Entries(language)); none == "" { + out[language] = builds.ToolchainUsed{Version: tc.Version, Recipe: tc.Recipe} } } if len(out) == 0 { diff --git a/modules/mesh-delivery-builder/internal/build/candidates_test.go b/modules/mesh-delivery-builder/internal/build/candidates_test.go index 41ca4f10..a20e2c40 100644 --- a/modules/mesh-delivery-builder/internal/build/candidates_test.go +++ b/modules/mesh-delivery-builder/internal/build/candidates_test.go @@ -8,16 +8,20 @@ import ( "strings" "testing" + "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" + "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliveryclient" + "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds" "git.novox.be/novox/mesh-sdk/go/catalogue" - "git.novox.be/novox/mesh-sdk/go/delivery" + "git.novox.be/novox/mesh-sdk/go/keys" ) // trunkModule writes the trunk's record of the module m at a fingerprint of its own, so that the candidate's fingerprint (fp1) // is one the trunk does not carry. func trunkModule(t *testing.T, r *rig) { t.Helper() - data, err := delivery.EncodeModule(delivery.Module{Repository: "novox/mesh-catalog", Commit: "7a74cee83f88e6585a5511c3cc53bdf0f5303d20", FP: "src1:" + hex64('f'), - FP2: "src2:" + hex64('e'), Manifest: mdigest('a'), Path: "modules/m", Toolchains: agreedToolchains}) + data, err := meshcatalogclient.EncodeModule(meshcatalog.ModulesByModuleValue{Repository: "novox/mesh-catalog", Commit: "7a74cee83f88e6585a5511c3cc53bdf0f5303d20", Fp: "src1:" + hex64('f'), + Fp2: "src2:" + hex64('e'), Manifest: mdigest('a'), Path: "modules/m", Toolchains: CatalogToolchains(agreedToolchains)}) if err != nil { t.Fatal(err) } @@ -30,13 +34,13 @@ func trunkModule(t *testing.T, r *rig) { func candidateValue(t *testing.T, group string, number int, head string) []byte { t.Helper() - pulls := []delivery.CandidatePull{{Head: delivery.Head{Repository: "novox/mesh-catalog", Number: number, Head: head}, Modules: []string{"m"}, - Fps: map[string]delivery.CandidateModule{"m": {FP2: fp1, Manifest: mdigest('a')}}}} - hash, err := delivery.HeadsHash([]delivery.Head{pulls[0].Head}) + pulls := []meshcatalog.CandidatesByGroupValuePullsItem{{Repository: "novox/mesh-catalog", Number: int64(number), Head: head, Modules: []string{"m"}, + Fps: map[string]meshcatalog.CandidatesByGroupValuePullsItemFpsValue{"m": {Fp2: fp1, Manifest: mdigest('a')}}}} + hash, err := meshdeliveryclient.HeadsHash([]meshdeliveryclient.Head{{Repository: pulls[0].Repository, Number: int(pulls[0].Number), Head: pulls[0].Head}}) if err != nil { t.Fatal(err) } - data, err := delivery.EncodeCandidate(delivery.Candidate{V: delivery.Version, Group: group, HeadsHash: hash, Pulls: pulls, FPSet: delivery.FingerprintSet(pulls)}) + data, err := json.Marshal(meshcatalog.CandidatesByGroupValue{V: keys.Version, Group: group, Heads: hash, Pulls: pulls, Fpset: meshcatalogclient.FingerprintSet(pulls)}) if err != nil { t.Fatal(err) } @@ -53,7 +57,7 @@ const ( // indexerCandidate is a candidate as the indexer writes it, with the gate's source revision it names (mesh-delivery-indexer // internal/index testdata/candidate-written.json, kept equal to what the indexer writes by its own test). -func indexerCandidate(t *testing.T) (rev uint64, heads map[string]string, c delivery.Candidate, raw []byte) { +func indexerCandidate(t *testing.T) (rev uint64, heads map[string]string, c meshcatalog.CandidatesByGroupValue, raw []byte) { t.Helper() data, err := os.ReadFile("../../../mesh-delivery-indexer/internal/index/testdata/candidate-written.json") if err != nil { @@ -69,7 +73,7 @@ func indexerCandidate(t *testing.T) (rev uint64, heads map[string]string, c deli if err := json.Unmarshal(data, &w); err != nil { t.Fatal(err) } - if c, err = delivery.DecodeCandidate(w.Candidate); err != nil { + if c, err = meshcatalog.DecodeCandidatesByGroupValue(w.Candidate); err != nil { t.Fatal(err) } return w.Source.Rev, w.Source.Heads, c, w.Candidate @@ -91,8 +95,8 @@ func TestACandidatesFingerprintIsBuiltOnceAsAKeyOfItsOwnFromTheHeadMergedOntoThe return heads[repository], nil } const valueCommit = "7a74cee83f88e6585a5511c3cc53bdf0f5303d20" - value, err := delivery.EncodeModule(delivery.Module{Repository: p.Repository, Commit: valueCommit, FP2: "src2:" + hex64('e'), - Manifest: cm.Manifest, Path: "modules/avahi", Toolchains: agreedToolchains}) + value, err := meshcatalogclient.EncodeModule(meshcatalog.ModulesByModuleValue{Repository: p.Repository, Commit: valueCommit, Fp2: "src2:" + hex64('e'), + Manifest: cm.Manifest, Path: "modules/avahi", Toolchains: CatalogToolchains(agreedToolchains)}) if err != nil { t.Fatal(err) } @@ -108,7 +112,7 @@ func TestACandidatesFingerprintIsBuiltOnceAsAKeyOfItsOwnFromTheHeadMergedOntoThe t.Fatalf("%d fetches of the indexer's candidate, want one", len(fetches)) } f := fetches[0].body.(map[string]any) - if want := heads[p.Repository]; want == "" || f["base"] != want || f["base"] == valueCommit || f["commit"] != p.Head.Head { + if want := heads[p.Repository]; want == "" || f["base"] != want || f["base"] == valueCommit || f["commit"] != p.Head { t.Errorf("fetch args = %+v, want the head merged onto the trunk head %.8s of source revision %d", f, want, rev) } } @@ -117,7 +121,7 @@ func TestACandidatesFingerprintIsBuiltOnceAsAKeyOfItsOwnFromTheHeadMergedOntoThe r.b.Candidate(context.Background(), "feat%2Fx", candidateValue(t, "feat/x", 7, headX)) r.b.Wait() v := valueOf(t, r.st, r.key) - if v.State != delivery.BuildDone || len(v.Artifacts) != 1 { + if v.State != builds.BuildDone || len(v.Artifacts) != 1 { t.Fatalf("build = %+v, want the candidate's fingerprint built", v) } fetches := r.bm.callsOf("build_go_fetch") @@ -158,7 +162,7 @@ func TestAFingerprintTheTrunkCarriesIsBuiltFromTheTrunksCommit(t *testing.T) { / t.Errorf("fetch args = %+v: a trunk fingerprint is built from the trunk's commit alone", f) } } - if v := valueOf(t, r.st, r.key); v.State != delivery.BuildDone { + if v := valueOf(t, r.st, r.key); v.State != builds.BuildDone { t.Errorf("build = %+v", v) } } @@ -174,7 +178,7 @@ func TestACandidateWhoseHeadDoesNotMergeIsFailedAndNoTrunkBuildIsStoredUnderIt(t r.b.Candidate(context.Background(), "feat%2Fx", candidateValue(t, "feat/x", 7, headX)) r.b.Wait() v := valueOf(t, r.st, r.key) - if v.State != delivery.BuildFailed || (name == "conflict" && !strings.Contains(v.Error, "go.mod")) || len(r.bm.callsOf("build_go_build")) != 0 { + if v.State != builds.BuildFailed || (name == "conflict" && !strings.Contains(v.Error, "go.mod")) || len(r.bm.callsOf("build_go_build")) != 0 { t.Errorf("%s: build = %+v, builds %d, want failed and nothing built", name, v, len(r.bm.callsOf("build_go_build"))) } } diff --git a/modules/mesh-delivery-builder/internal/build/claim.go b/modules/mesh-delivery-builder/internal/build/claim.go index 50b933a9..a45baac7 100644 --- a/modules/mesh-delivery-builder/internal/build/claim.go +++ b/modules/mesh-delivery-builder/internal/build/claim.go @@ -9,8 +9,9 @@ import ( "strings" "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun" + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds" "git.novox.be/novox/mesh-sdk/go/catalogue" - "git.novox.be/novox/mesh-sdk/go/delivery" + "git.novox.be/novox/mesh-sdk/go/keys" ) // ErrConflict is what a Store answers when a Create finds the key or an Update finds another revision than the one given. @@ -37,7 +38,7 @@ type Claimer struct { type Claim struct { Key string Rev uint64 - Build delivery.BuilderBuild + Build builds.BuilderBuild // Gone are the references of a done key the artifact store no longer holds, for which it was claimed again // (ClaimOptions.Stored; issue 1937): said in the log and the key's step. Gone []string @@ -71,7 +72,7 @@ type ClaimOptions struct { // StoredReferences are the references of a done build's bundles and archives that name a blob in the artifact store // (`artifact-store:////blobs/`): what the store's collector can delete. An image or an upstream // copy is a manifest, which the collector keeps, and a package lives in the package registry. -func StoredReferences(b delivery.BuilderBuild) []string { +func StoredReferences(b builds.BuilderBuild) []string { var out []string for _, a := range b.Artifacts { if strings.HasPrefix(a.Reference, "artifact-store://") && strings.Contains(a.Reference, "/blobs/") && @@ -89,12 +90,12 @@ func GoneStep(gone []string) string { // Unpublished is whether a done build holds an image built without a push or a package built without a publish: it has // no reference (a candidate's, ADR 0487 clause 12, ADR 0498 clauses 5 and 6). -func Unpublished(b delivery.BuilderBuild) bool { return len(UnpublishedModules(b)) > 0 } +func Unpublished(b builds.BuilderBuild) bool { return len(UnpublishedModules(b)) > 0 } // UnpublishedModules are the build modules that publish what a done build holds unpublished: the image build module for // an image, the Node build module for a package; sorted, none when it holds nothing unpublished. -func UnpublishedModules(b delivery.BuilderBuild) []string { - if b.State != delivery.BuildDone { +func UnpublishedModules(b builds.BuilderBuild) []string { + if b.State != builds.BuildDone { return nil } var image, pkg bool @@ -115,7 +116,7 @@ func UnpublishedModules(b delivery.BuilderBuild) []string { // publishable are the build modules of UnpublishedModules that can publish now: the image build module only while a node // runs it (imageBuilder). -func publishable(b delivery.BuilderBuild, imageBuilder func() bool) []string { +func publishable(b builds.BuilderBuild, imageBuilder func() bool) []string { var out []string for _, bm := range UnpublishedModules(b) { if bm != ImageBuildModule || (imageBuilder != nil && imageBuilder()) { @@ -128,8 +129,8 @@ func publishable(b delivery.BuilderBuild, imageBuilder func() bool) []string { // Unreferenced is whether a done build holds a bundle, an archive or an upstream copy that names no reference: a key done // before R4e, whose artifacts stayed on their build nodes (ADR 0498 clause 8). An artifact of no kind (a key written before // kinds) is one too. Claim takes such a key again, once: its new revision names them. -func Unreferenced(b delivery.BuilderBuild) bool { - if b.State != delivery.BuildDone { +func Unreferenced(b builds.BuilderBuild) bool { + if b.State != builds.BuildDone { return false } for _, a := range b.Artifacts { @@ -151,7 +152,7 @@ func Unreferenced(b delivery.BuilderBuild) bool { // - a done key of the trunk's fingerprint whose bundle or archive blob the artifact store no longer holds // (ClaimOptions.Stored, issue 1937) is claimed building as a new revision and built whole, its step saying which; // - a failed key is taken again as a new revision of the same key (ADR 0429 clause 18), except one failed for a kind no -// builder builds, for an upstream pinned by no digest or a package version taken (delivery.FailedUntilChanged: ADR 0478 +// builder builds, for an upstream pinned by no digest or a package version taken (builds.FailedUntilChanged: ADR 0478 // clause 2, ADR 0498 clauses 4 and 5) or for digests that differ (buildrun.DigestsDifferPrefix, clause 4), // which wait for another fingerprint, and one failed for want of an image builder (NoImageBuilderPrefix), which waits for // another fingerprint or a node that runs the image build module (ClaimOptions.ImageBuilder); @@ -160,11 +161,11 @@ func Unreferenced(b delivery.BuilderBuild) bool { // - a key left queued or building by this builder is taken as it stands (ADR 0478 clause 5: a stopped builder takes its // own again when it starts), and so is one left by another builder when ClaimOptions.TakeOver (clause 6). func (c Claimer) Claim(ctx context.Context, module, fingerprint, manifest string, opt ClaimOptions) (*Claim, error) { - key, err := delivery.BuilderBuildKey(module, fingerprint) + key, err := builds.BuilderBuildKey(module, fingerprint) if err != nil { return nil, err } - building := delivery.BuilderBuild{State: delivery.BuildBuilding, By: c.By, Step: "claimed", Manifest: manifest} + building := builds.BuilderBuild{State: builds.BuildBuilding, By: c.By, Step: "claimed", Manifest: manifest} value, rev, found, err := c.Store.Get(ctx, key) if err != nil { return nil, fmt.Errorf("%s: %w", key, err) @@ -172,12 +173,12 @@ func (c Claimer) Claim(ctx context.Context, module, fingerprint, manifest string if !found { return c.write(ctx, key, building, func(data []byte) (uint64, error) { return c.Store.Create(ctx, key, data) }) } - cur, err := delivery.DecodeBuilderBuild(value) + cur, err := builds.DecodeBuilderBuild(value) if err != nil { return nil, fmt.Errorf("%s holds what is not a build: %w", key, err) } switch { - case cur.State == delivery.BuildDone: + case cur.State == builds.BuildDone: if Unreferenced(cur) { return c.write(ctx, key, building, func(data []byte) (uint64, error) { return c.Store.Update(ctx, key, data, rev) }) } @@ -207,13 +208,13 @@ func (c Claimer) Claim(ctx context.Context, module, fingerprint, manifest string return &Claim{Key: key, Rev: rev, Build: cur}, nil } return nil, nil - case cur.State == delivery.BuildFailed && (delivery.FailedUntilChanged(cur.Error) || strings.HasPrefix(cur.Error, buildrun.DigestsDifferPrefix)): + case cur.State == builds.BuildFailed && (builds.FailedUntilChanged(cur.Error) || strings.HasPrefix(cur.Error, buildrun.DigestsDifferPrefix)): return nil, nil - case cur.State == delivery.BuildFailed && strings.HasPrefix(cur.Error, NoImageBuilderPrefix) && (opt.ImageBuilder == nil || !opt.ImageBuilder()): + case cur.State == builds.BuildFailed && strings.HasPrefix(cur.Error, NoImageBuilderPrefix) && (opt.ImageBuilder == nil || !opt.ImageBuilder()): return nil, nil - case cur.State == delivery.BuildFailed: + case cur.State == builds.BuildFailed: return c.write(ctx, key, building, func(data []byte) (uint64, error) { return c.Store.Update(ctx, key, data, rev) }) - case cur.State == delivery.BuildQueued && cur.Waits != "": + case cur.State == builds.BuildQueued && cur.Waits != "": if opt.Published == nil || !opt.Published(cur.Waits) { return nil, nil // the package it waits for is not published yet } @@ -224,8 +225,8 @@ func (c Claimer) Claim(ctx context.Context, module, fingerprint, manifest string return nil, nil } -func (c Claimer) write(_ context.Context, key string, b delivery.BuilderBuild, put func([]byte) (uint64, error)) (*Claim, error) { - data, err := delivery.EncodeBuilderBuild(b) +func (c Claimer) write(_ context.Context, key string, b builds.BuilderBuild, put func([]byte) (uint64, error)) (*Claim, error) { + data, err := builds.EncodeBuilderBuild(b) if err != nil { return nil, err } @@ -236,18 +237,18 @@ func (c Claimer) write(_ context.Context, key string, b delivery.BuilderBuild, p if err != nil { return nil, fmt.Errorf("%s: %w", key, err) } - b.V = delivery.Version + b.V = keys.Version return &Claim{Key: key, Rev: rev, Build: b}, nil } // Finish moves the claimed key to b by compare-and-set at the claim's revision, and writes nothing when b is the value // the key holds. A key that changed meanwhile is ErrConflict: the claim is no longer this builder's. -func (c Claimer) Finish(ctx context.Context, cl *Claim, b delivery.BuilderBuild) error { - b.V = delivery.Version - if delivery.SameBuilderBuild(cl.Build, b) { +func (c Claimer) Finish(ctx context.Context, cl *Claim, b builds.BuilderBuild) error { + b.V = keys.Version + if builds.SameBuilderBuild(cl.Build, b) { return nil } - data, err := delivery.EncodeBuilderBuild(b) + data, err := builds.EncodeBuilderBuild(b) if err != nil { return err } diff --git a/modules/mesh-delivery-builder/internal/build/claim_test.go b/modules/mesh-delivery-builder/internal/build/claim_test.go index ed359a31..51e93a86 100644 --- a/modules/mesh-delivery-builder/internal/build/claim_test.go +++ b/modules/mesh-delivery-builder/internal/build/claim_test.go @@ -6,7 +6,7 @@ import ( "sync" "testing" - "git.novox.be/novox/mesh-sdk/go/delivery" + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds" ) // memStore is a bucket's create and compare-and-set in memory, with a revision per write. @@ -78,13 +78,13 @@ const ( digest = "sha256:2222222222222222222222222222222222222222222222222222222222222222" ) -func seed(t *testing.T, st *memStore, b delivery.BuilderBuild) string { +func seed(t *testing.T, st *memStore, b builds.BuilderBuild) string { t.Helper() - key, err := delivery.BuilderBuildKey("m", fp1) + key, err := builds.BuilderBuildKey("m", fp1) if err != nil { t.Fatal(err) } - data, err := delivery.EncodeBuilderBuild(b) + data, err := builds.EncodeBuilderBuild(b) if err != nil { t.Fatal(err) } @@ -92,9 +92,9 @@ func seed(t *testing.T, st *memStore, b delivery.BuilderBuild) string { return key } -func valueOf(t *testing.T, st *memStore, key string) delivery.BuilderBuild { +func valueOf(t *testing.T, st *memStore, key string) builds.BuilderBuild { t.Helper() - b, err := delivery.DecodeBuilderBuild(st.vals[key]) + b, err := builds.DecodeBuilderBuild(st.vals[key]) if err != nil { t.Fatal(err) } @@ -108,18 +108,17 @@ func TestClaimCreatesTheKeyBuildingAndASecondBuilderGetsNothing(t *testing.T) { if err != nil || got == nil { t.Fatalf("first claim = %v, %v, want a claim", got, err) } - if v := valueOf(t, st, got.Key); v.State != delivery.BuildBuilding || v.By != "novox.a" || v.Manifest != hex64('a') { + if v := valueOf(t, st, got.Key); v.State != builds.BuildBuilding || v.By != "novox.a" || v.Manifest != hex64('a') { t.Errorf("the key holds %+v, want building by novox.a with the manifest digest", v) } if second, err := b.Claim(context.Background(), "m", fp1, hex64('a'), ClaimOptions{}); err != nil || second != nil { t.Errorf("second builder's claim = %v, %v, want none: the same source is never built twice", second, err) } -} -func TestClaimLosingTheCreateRaceGetsNothing(t *testing.T) { // 1607/AC3 - st := newMem() - st.before = func() { seed(t, st, delivery.BuilderBuild{State: delivery.BuildBuilding, By: "novox.b"}) } - got, err := Claimer{Store: st, By: "novox.a"}.Claim(context.Background(), "m", fp1, "", ClaimOptions{}) + // A claim losing the create race gets nothing (merged into this test for the test budget, design 51). + st = newMem() + st.before = func() { seed(t, st, builds.BuilderBuild{State: builds.BuildBuilding, By: "novox.b"}) } + got, err = Claimer{Store: st, By: "novox.a"}.Claim(context.Background(), "m", fp1, "", ClaimOptions{}) if err != nil || got != nil { t.Errorf("claim = %v, %v, want none: the create failed because the key exists", got, err) } @@ -127,7 +126,7 @@ func TestClaimLosingTheCreateRaceGetsNothing(t *testing.T) { // 1607/AC3 func TestADoneKeyIsNeverTakenAgain(t *testing.T) { // 1607/AC3; a key whose artifacts name references (ADR 0498 clause 8) st := newMem() - seed(t, st, delivery.BuilderBuild{State: delivery.BuildDone, By: "novox.a", Manifest: hex64('a'), Artifacts: []delivery.BuiltArtifact{{Name: "x", Kind: "bundle", Digest: digest, Reference: "artifact-store://m/x/blobs/" + digest}}}) + seed(t, st, builds.BuilderBuild{State: builds.BuildDone, By: "novox.a", Manifest: hex64('a'), Artifacts: []builds.BuiltArtifact{{Name: "x", Kind: "bundle", Digest: digest, Reference: "artifact-store://m/x/blobs/" + digest}}}) writes := st.writes got, err := Claimer{Store: st, By: "novox.a"}.Claim(context.Background(), "m", fp1, hex64('a'), ClaimOptions{}) if err != nil || got != nil || st.writes != writes { @@ -137,25 +136,25 @@ func TestADoneKeyIsNeverTakenAgain(t *testing.T) { // 1607/AC3; a key whose arti func TestAFailedKeyIsTakenAgainAsANewRevisionExceptARefusedKind(t *testing.T) { // 1607/AC3, ADR 0478 clause 2 st := newMem() - key := seed(t, st, delivery.BuilderBuild{State: delivery.BuildFailed, By: "novox.a", Error: "the step failed"}) + key := seed(t, st, builds.BuilderBuild{State: builds.BuildFailed, By: "novox.a", Error: "the step failed"}) rev := st.revs[key] got, err := Claimer{Store: st, By: "novox.a"}.Claim(context.Background(), "m", fp1, hex64('a'), ClaimOptions{}) if err != nil || got == nil || st.revs[key] <= rev { t.Fatalf("claim = %v, %v, revision %d (was %d), want a claim as a new revision of the same key", got, err, st.revs[key], rev) } - if v := valueOf(t, st, key); v.State != delivery.BuildBuilding || v.Error != "" { + if v := valueOf(t, st, key); v.State != builds.BuildBuilding || v.Error != "" { t.Errorf("the key holds %+v, want building with no error", v) } st = newMem() - key = seed(t, st, delivery.BuilderBuild{State: delivery.BuildFailed, By: "novox.a", Error: delivery.RefusedKind + "package of language \"python\" (artifact x)"}) + key = seed(t, st, builds.BuilderBuild{State: builds.BuildFailed, By: "novox.a", Error: builds.RefusedKind + "package of language \"python\" (artifact x)"}) writes := st.writes if got, err := (Claimer{Store: st, By: "novox.a"}).Claim(context.Background(), "m", fp1, "", ClaimOptions{}); err != nil || got != nil || st.writes != writes { t.Errorf("a refused kind: claim = %v, %v, writes %d (was %d), want none and no write", got, err, st.writes, writes) } - for _, e := range []string{delivery.NotPinned + "up: nats:2 names no digest", delivery.VersionTaken + "@novox/mesh-sdk@0.1.15"} { // B3, B5: ADR 0498 clauses 4, 5 + for _, e := range []string{builds.NotPinned + "up: nats:2 names no digest", builds.VersionTaken + "@novox/mesh-sdk@0.1.15"} { // B3, B5: ADR 0498 clauses 4, 5 st = newMem() - seed(t, st, delivery.BuilderBuild{State: delivery.BuildFailed, By: "novox.a", Error: e}) + seed(t, st, builds.BuilderBuild{State: builds.BuildFailed, By: "novox.a", Error: e}) writes = st.writes if got, err := (Claimer{Store: st, By: "novox.a"}).Claim(context.Background(), "m", fp1, "", ClaimOptions{}); err != nil || got != nil || st.writes != writes { t.Errorf("%s: claim = %v, %v, writes %d (was %d), want none until the fingerprint changes", e, got, err, st.writes, writes) @@ -165,7 +164,7 @@ func TestAFailedKeyIsTakenAgainAsANewRevisionExceptARefusedKind(t *testing.T) { func TestAKeyFailedForNoImageBuilderIsTakenAgainOnlyWhenANodeHasTheModule(t *testing.T) { // 1705b st := newMem() - key := seed(t, st, delivery.BuilderBuild{State: delivery.BuildFailed, By: "novox.a", Error: NoImageBuilderPrefix + " no node has mesh-build-image"}) + key := seed(t, st, builds.BuilderBuild{State: builds.BuildFailed, By: "novox.a", Error: NoImageBuilderPrefix + " no node has mesh-build-image"}) writes := st.writes c := Claimer{Store: st, By: "novox.a"} for _, opt := range []ClaimOptions{{}, {ImageBuilder: func() bool { return false }}} { @@ -174,7 +173,7 @@ func TestAKeyFailedForNoImageBuilderIsTakenAgainOnlyWhenANodeHasTheModule(t *tes } } got, err := c.Claim(context.Background(), "m", fp1, hex64('a'), ClaimOptions{ImageBuilder: func() bool { return true }}) - if err != nil || got == nil || valueOf(t, st, key).State != delivery.BuildBuilding { + if err != nil || got == nil || valueOf(t, st, key).State != builds.BuildBuilding { t.Errorf("claim = %v, %v, want a claim with the key building", got, err) } } @@ -183,11 +182,11 @@ func TestFinishMovesTheKeyByCompareAndSetAndWritesOnlyADifferentValue(t *testing st := newMem() c := Claimer{Store: st, By: "novox.a"} got, _ := c.Claim(context.Background(), "m", fp1, hex64('a'), ClaimOptions{}) - done := delivery.BuilderBuild{State: delivery.BuildDone, By: "novox.a", Manifest: hex64('a'), Artifacts: []delivery.BuiltArtifact{{Name: "x", Kind: "archive", Digest: digest}}} + done := builds.BuilderBuild{State: builds.BuildDone, By: "novox.a", Manifest: hex64('a'), Artifacts: []builds.BuiltArtifact{{Name: "x", Kind: "archive", Digest: digest}}} if err := c.Finish(context.Background(), got, done); err != nil { t.Fatal(err) } - if v := valueOf(t, st, got.Key); v.State != delivery.BuildDone || v.Artifacts[0].Digest != digest { + if v := valueOf(t, st, got.Key); v.State != builds.BuildDone || v.Artifacts[0].Digest != digest { t.Errorf("the key holds %+v", v) } writes := st.writes @@ -197,14 +196,13 @@ func TestFinishMovesTheKeyByCompareAndSetAndWritesOnlyADifferentValue(t *testing if st.writes != writes { t.Errorf("the same value was written again (%d writes, was %d)", st.writes, writes) } -} -func TestFinishAfterTheKeyChangedIsAConflictNotAnOverwrite(t *testing.T) { // 1607/AC3 - st := newMem() - c := Claimer{Store: st, By: "novox.a"} - got, _ := c.Claim(context.Background(), "m", fp1, "", ClaimOptions{}) + // Finish after the key changed is a conflict, not an overwrite (merged into this test for the test budget). + st = newMem() + c = Claimer{Store: st, By: "novox.a"} + got, _ = c.Claim(context.Background(), "m", fp1, "", ClaimOptions{}) st.put(got.Key, []byte(`{"v":1,"state":"failed","error":"someone else"}`)) - err := c.Finish(context.Background(), got, delivery.BuilderBuild{State: delivery.BuildFailed, Error: "mine"}) + err := c.Finish(context.Background(), got, builds.BuilderBuild{State: builds.BuildFailed, Error: "mine"}) if !errors.Is(err, ErrConflict) { t.Errorf("Finish = %v, want ErrConflict", err) } diff --git a/modules/mesh-delivery-builder/internal/build/fakes_test.go b/modules/mesh-delivery-builder/internal/build/fakes_test.go index 2b993dbf..bd18bba5 100644 --- a/modules/mesh-delivery-builder/internal/build/fakes_test.go +++ b/modules/mesh-delivery-builder/internal/build/fakes_test.go @@ -10,7 +10,8 @@ import ( "testing" "time" - "git.novox.be/novox/mesh-sdk/go/delivery" + "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshwatchdogs" + "git.novox.be/novox/mesh-delivery/sdk/buildstep" ) type call struct { @@ -36,12 +37,12 @@ func (f *fakeCaller) CallOn(node, key string, body any) (json.RawMessage, error) // fakeSteps holds the steps values by `/`; Wait on a key it does not hold blocks until the context ends. type fakeSteps struct { mu sync.Mutex - steps map[string]delivery.Step + steps map[string]buildstep.Step // during, when set, is called as a wait starts and the function it answers as the wait ends. during func(key string) func() } -func (f *fakeSteps) Wait(ctx context.Context, module, key string) (delivery.Step, error) { +func (f *fakeSteps) Wait(ctx context.Context, module, key string) (buildstep.Step, error) { f.mu.Lock() s, ok := f.steps[module+"/"+key] during := f.during @@ -53,11 +54,11 @@ func (f *fakeSteps) Wait(ctx context.Context, module, key string) (delivery.Step return s, nil } <-ctx.Done() - return delivery.Step{}, context.Cause(ctx) + return buildstep.Step{}, context.Cause(ctx) } -func stepAt(node, id, state, reason string, result string) delivery.Step { - s := delivery.Step{ID: id, Tool: "build_go_fetch", Node: node, State: state, Reason: reason} +func stepAt(node, id, state, reason string, result string) buildstep.Step { + s := buildstep.Step{ID: id, Tool: "build_go_fetch", Node: node, State: state, Reason: reason} if result != "" { s.Result = json.RawMessage(result) } @@ -67,7 +68,7 @@ func stepAt(node, id, state, reason string, result string) delivery.Step { // presenceValue is a watchman presence value, as its writer encodes it (mesh-sdk go/delivery EncodePresence). func presenceValue(t *testing.T, online bool) []byte { t.Helper() - b, err := delivery.EncodePresence(delivery.Presence{Online: online}) + b, err := json.Marshal(meshwatchdogs.PresenceByNodeValue{Online: online}) if err != nil { t.Fatal(err) } @@ -77,7 +78,7 @@ func presenceValue(t *testing.T, online bool) []byte { // tcValue is a toolchains value, as the build module's writer encodes it (mesh-sdk go/delivery EncodeToolchain). func tcValue(t *testing.T, language, version string) []byte { t.Helper() - b, err := delivery.EncodeToolchain(delivery.Toolchain{Language: language, Version: version, Recipe: "1", At: time.Now()}) + b, err := buildstep.EncodeToolchain(buildstep.Toolchain{Language: language, Version: version, Recipe: "1", At: time.Now().UTC()}) if err != nil { t.Fatal(err) } @@ -85,10 +86,10 @@ func tcValue(t *testing.T, language, version string) []byte { } // tcs are the toolchain entries of a language on nodes, as the toolchains set gives them. -func tcs(language, version string, nodes ...string) []delivery.NodeToolchain { - var out []delivery.NodeToolchain +func tcs(language, version string, nodes ...string) []buildstep.NodeToolchain { + var out []buildstep.NodeToolchain for _, n := range nodes { - out = append(out, delivery.NodeToolchain{Node: n, Toolchain: delivery.Toolchain{Language: language, Version: version, Recipe: "1"}}) + out = append(out, buildstep.NodeToolchain{Node: n, Toolchain: buildstep.Toolchain{Language: language, Version: version, Recipe: "1"}}) } return out } diff --git a/modules/mesh-delivery-builder/internal/build/fixtures_test.go b/modules/mesh-delivery-builder/internal/build/fixtures_test.go index 467f91d8..956a831c 100644 --- a/modules/mesh-delivery-builder/internal/build/fixtures_test.go +++ b/modules/mesh-delivery-builder/internal/build/fixtures_test.go @@ -8,8 +8,8 @@ import ( "testing" "time" - "git.novox.be/novox/mesh-sdk/go/buildstep" - "git.novox.be/novox/mesh-sdk/go/delivery" + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds" + sdkstep "git.novox.be/novox/mesh-delivery/sdk/buildstep" ) // result reads a captured step value of mesh-build-image (testdata/captured/README) and returns its result. @@ -19,11 +19,11 @@ func captured(t *testing.T, name string) json.RawMessage { if err != nil { t.Fatal(err) } - var step delivery.Step + var step sdkstep.Step if err := json.Unmarshal(raw, &step); err != nil { t.Fatalf("%s: %v", name, err) } - if step.State != delivery.StepPassed || len(step.Result) == 0 { + if step.State != sdkstep.StepPassed || len(step.Result) == 0 { t.Fatalf("%s: state %q, result %s, want a passed step with a result", name, step.State, step.Result) } return step.Result @@ -43,15 +43,15 @@ func TestCapturedImageStepsDecodeWithTheBuildersOwnTypes(t *testing.T) { // revi }, Build: func(context.Context, map[string]any) (any, error) { return json.RawMessage(captured(t, tc.build)), nil }, } - ask := delivery.StepAsk{ID: buildstep.NewID(), Kind: delivery.AskDelivery, Language: delivery.AskImage, Asker: delivery.BuilderModule, - At: time.Now(), Build: &delivery.BuildJob{Module: "m", Path: "modules/m", Publish: tc.publish, - Fetch: map[string]any{"repository": "novox/mesh-catalog", "commit": strings.Repeat("a", 40)}, - Artifacts: []delivery.AskArtifact{{Artifact: "server"}}}} + ask := sdkstep.StepAsk{ID: sdkstep.NewID(), Kind: sdkstep.AskDelivery, Language: sdkstep.AskImage, Asker: builds.BuilderModule, + At: time.Now(), Build: &sdkstep.BuildJob{Module: "m", Path: "modules/m", Publish: tc.publish, + Fetch: &sdkstep.Fetch{Repository: "novox/mesh-catalog", Commit: strings.Repeat("a", 40)}, + Artifacts: []sdkstep.AskArtifact{{Artifact: "server"}}}} out, err := runAsk(context.Background(), steps, ask) if err != nil { t.Fatalf("%s: %v", tc.build, err) } - a := out.(delivery.BuildAnswer) + a := out.(sdkstep.BuildAnswer) if len(a.Artifacts) != 1 || a.Artifacts[0].Digest == "" || (a.Artifacts[0].Reference != "") != tc.ref { t.Errorf("%s answered %+v, want a digest and a reference only for the trunk", tc.build, a) } diff --git a/modules/mesh-delivery-builder/internal/build/followups_test.go b/modules/mesh-delivery-builder/internal/build/followups_test.go index ce0efd65..ac03518e 100644 --- a/modules/mesh-delivery-builder/internal/build/followups_test.go +++ b/modules/mesh-delivery-builder/internal/build/followups_test.go @@ -5,20 +5,22 @@ import ( "strings" "testing" - "git.novox.be/novox/mesh-sdk/go/delivery" + "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" + "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds" ) // 1660: at start the builder closes a key left building for a fingerprint the module no longer has. func TestAKeyLeftBuildingForAFingerprintTheModuleNoLongerHasIsClosedAtStart(t *testing.T) { // issue 1660 r := newRig(t, goBundle) - stale := seed(t, r.st, delivery.BuilderBuild{State: delivery.BuildBuilding, By: "ace.builder", Manifest: manifestD}) // m at fp1 - other, _ := delivery.BuilderBuildKey("gone", fp1) - data, _ := delivery.EncodeBuilderBuild(delivery.BuilderBuild{State: delivery.BuildBuilding, By: "ace.builder", Manifest: manifestD}) + stale := seed(t, r.st, builds.BuilderBuild{State: builds.BuildBuilding, By: "ace.builder", Manifest: manifestD}) // m at fp1 + other, _ := builds.BuilderBuildKey("gone", fp1) + data, _ := builds.EncodeBuilderBuild(builds.BuilderBuild{State: builds.BuildBuilding, By: "ace.builder", Manifest: manifestD}) r.st.put(other, data) // The module m has moved on to another fingerprint. - moved := delivery.Module{Repository: "novox/mesh-catalog", Commit: "7a74cee83f88e6585a5511c3cc53bdf0f5303d20", FP: "src1:" + hex64('f'), - FP2: "src2:" + hex64('7'), Manifest: manifestD, Path: "modules/m", Toolchains: agreedToolchains} - enc, _ := delivery.EncodeModule(moved) + moved := meshcatalog.ModulesByModuleValue{Repository: "novox/mesh-catalog", Commit: "7a74cee83f88e6585a5511c3cc53bdf0f5303d20", Fp: "src1:" + hex64('f'), + Fp2: "src2:" + hex64('7'), Manifest: manifestD, Path: "modules/m", Toolchains: CatalogToolchains(agreedToolchains)} + enc, _ := meshcatalogclient.EncodeModule(moved) r.b.Module(context.Background(), "m", enc) r.b.Wait() if err := r.b.Sweep(context.Background()); err != nil { @@ -26,7 +28,7 @@ func TestAKeyLeftBuildingForAFingerprintTheModuleNoLongerHasIsClosedAtStart(t *t } for _, k := range []string{stale, other} { v := valueOf(t, r.st, k) - if v.State != delivery.BuildFailed || !strings.Contains(v.Error, "no longer") || !strings.Contains(v.Error, "ace.builder") { + if v.State != builds.BuildFailed || !strings.Contains(v.Error, "no longer") || !strings.Contains(v.Error, "ace.builder") { t.Errorf("%s = %+v, want it closed as failed, naming why and who left it", k, v) } } @@ -34,13 +36,13 @@ func TestAKeyLeftBuildingForAFingerprintTheModuleNoLongerHasIsClosedAtStart(t *t func TestSweepLeavesAKeyOfTheModulesCurrentFingerprintForTheBuildToTakeOver(t *testing.T) { // issue 1660 r := newRig(t, goBundle) - seed(t, r.st, delivery.BuilderBuild{State: delivery.BuildBuilding, By: "ace.builder", Manifest: manifestD}) + seed(t, r.st, builds.BuilderBuild{State: builds.BuildBuilding, By: "ace.builder", Manifest: manifestD}) r.b.Module(context.Background(), "m", moduleValue(t, "modules/m")) // takes it over and builds it r.b.Wait() if err := r.b.Sweep(context.Background()); err != nil { t.Fatal(err) } - if v := valueOf(t, r.st, r.key); v.State != delivery.BuildDone || v.By != "novox.builder" { + if v := valueOf(t, r.st, r.key); v.State != builds.BuildDone || v.By != "novox.builder" { t.Errorf("build = %+v, want done by novox.builder", v) } } diff --git a/modules/mesh-delivery-builder/internal/build/gone_test.go b/modules/mesh-delivery-builder/internal/build/gone_test.go index 499cdbc9..fea331ca 100644 --- a/modules/mesh-delivery-builder/internal/build/gone_test.go +++ b/modules/mesh-delivery-builder/internal/build/gone_test.go @@ -9,7 +9,8 @@ import ( "sync" "testing" - "git.novox.be/novox/mesh-sdk/go/delivery" + sweep "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshartifactsweep" + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds" ) // A done key whose blob the artifact store no longer holds is built again (novox/hq issue 1937): on 2026-10-24 the @@ -75,9 +76,9 @@ func (l *logged) has(words ...string) bool { const goneRef = "artifact-store://m/tool/blobs/" + digest -func doneKeyNaming(ref string) delivery.BuilderBuild { - return delivery.BuilderBuild{State: delivery.BuildDone, By: "shanks.builder", Manifest: manifestD, Step: "built mesh-build-go on shanks", - Artifacts: []delivery.BuiltArtifact{{Name: "tool", Kind: "bundle", Digest: digest, Reference: ref}}} +func doneKeyNaming(ref string) builds.BuilderBuild { + return builds.BuilderBuild{State: builds.BuildDone, By: "shanks.builder", Manifest: manifestD, Step: "built mesh-build-go on shanks", + Artifacts: []builds.BuiltArtifact{{Name: "tool", Kind: "bundle", Digest: digest, Reference: ref}}} } // 1937/AC3 (unit, the claim): a done key of the trunk's fingerprint whose blob is gone is claimed building as a new @@ -85,7 +86,7 @@ func doneKeyNaming(ref string) delivery.BuilderBuild { func TestADoneTrunkKeyWhoseBlobIsGoneIsClaimedAgain(t *testing.T) { st := newMem() c := Claimer{Store: st, By: "novox.builder"} - key, _ := delivery.BuilderBuildKey("m", fp1) + key, _ := builds.BuilderBuildKey("m", fp1) rev := putBuild(t, st, key, doneKeyNaming(goneRef)) held := &fakeHeld{gone: map[string]bool{goneRef: true}} @@ -97,11 +98,11 @@ func TestADoneTrunkKeyWhoseBlobIsGoneIsClaimedAgain(t *testing.T) { if err != nil || cl == nil { t.Fatalf("claim = %+v, %v; want the key claimed again", cl, err) } - if cl.Rev <= rev || cl.Build.State != delivery.BuildBuilding || !slices.Equal(cl.Gone, []string{goneRef}) { + if cl.Rev <= rev || cl.Build.State != builds.BuildBuilding || !slices.Equal(cl.Gone, []string{goneRef}) { t.Errorf("claim = %+v (revision was %d); want building as a new revision naming the gone reference", cl, rev) } v := valueOf(t, st, key) - if v.State != delivery.BuildBuilding || !strings.Contains(v.Step, "no longer holds") || !strings.Contains(v.Step, goneRef) { + if v.State != builds.BuildBuilding || !strings.Contains(v.Step, "no longer holds") || !strings.Contains(v.Step, goneRef) { t.Errorf("the key holds %+v; want building, its step naming the gone reference", v) } } @@ -111,9 +112,9 @@ func TestADoneTrunkKeyWhoseBlobIsGoneIsClaimedAgain(t *testing.T) { func TestADoneTrunkKeyStaysDoneWhileTheStoreHoldsItsBlobs(t *testing.T) { st := newMem() c := Claimer{Store: st, By: "novox.builder"} - key, _ := delivery.BuilderBuildKey("m", fp1) + key, _ := builds.BuilderBuildKey("m", fp1) b := doneKeyNaming(goneRef) - b.Artifacts = append(b.Artifacts, delivery.BuiltArtifact{Name: "img", Kind: "image", Digest: digest, Reference: "registry.example/m/img@" + digest}) + b.Artifacts = append(b.Artifacts, builds.BuiltArtifact{Name: "img", Kind: "image", Digest: digest, Reference: "registry.example/m/img@" + digest}) rev := putBuild(t, st, key, b) held := &fakeHeld{gone: map[string]bool{}} if cl, err := c.Claim(t.Context(), "m", fp1, manifestD, ClaimOptions{Trunk: true, Stored: held.stored}); err != nil || cl != nil { @@ -142,7 +143,7 @@ func TestTheBuilderBuildsAgainADoneKeyWhoseBlobIsGone(t *testing.T) { r.b.Stored = held.stored rev := putBuild(t, r.st, r.key, doneKeyNaming(goneRef)) v := r.consider(t, "modules/m") - if v.State != delivery.BuildDone || r.st.revs[r.key] <= rev || len(r.bm.callsOf("build_go_build")) == 0 { + if v.State != builds.BuildDone || r.st.revs[r.key] <= rev || len(r.bm.callsOf("build_go_build")) == 0 { t.Fatalf("build = %+v at revision %d (was %d); want built again", v, r.st.revs[r.key], rev) } if !strings.Contains(v.Step, "built again: the artifact store no longer held "+goneRef) { @@ -176,7 +177,7 @@ func TestArtifactsGoneMakesTheBuilderAskAndBuildAgain(t *testing.T) { held := &fakeHeld{gone: map[string]bool{}} r.b.Stored = held.stored putBuild(t, r.st, r.key, doneKeyNaming(goneRef)) - if v := r.consider(t, "modules/m"); v.State != delivery.BuildDone || len(r.bm.callsOf("build_go_build")) != 0 { + if v := r.consider(t, "modules/m"); v.State != builds.BuildDone || len(r.bm.callsOf("build_go_build")) != 0 { t.Fatalf("a key whose blob is held was built: %+v", v) } rev := r.st.revs[r.key] @@ -184,18 +185,18 @@ func TestArtifactsGoneMakesTheBuilderAskAndBuildAgain(t *testing.T) { held.mu.Lock() held.gone[goneRef] = true // collected after the builder's look; the controller's take-in found it held.mu.Unlock() - r.b.ArtifactsGone(context.Background(), delivery.ArtifactsGone{Event: delivery.ArtifactsGoneEvent, Module: "m", + r.b.ArtifactsGone(context.Background(), sweep.ArtifactsGone{Event: sweep.EventArtifactsGone, Module: "m", Fingerprint: "src2:" + strings.Repeat("9", 64), Gone: []string{goneRef}}) r.b.Wait() if r.st.revs[r.key] != rev || !log.has("a fingerprint the module does not have now") { t.Fatalf("an event for another fingerprint moved the key (revision %d, was %d): %v", r.st.revs[r.key], rev, log.lines) } - r.b.ArtifactsGone(context.Background(), delivery.ArtifactsGone{Event: delivery.ArtifactsGoneEvent, Module: "m", + r.b.ArtifactsGone(context.Background(), sweep.ArtifactsGone{Event: sweep.EventArtifactsGone, Module: "m", Key: r.key, Fingerprint: fp1, Gone: []string{goneRef}}) r.b.Wait() v := valueOf(t, r.st, r.key) - if v.State != delivery.BuildDone || r.st.revs[r.key] <= rev || len(r.bm.callsOf("build_go_build")) == 0 { + if v.State != builds.BuildDone || r.st.revs[r.key] <= rev || len(r.bm.callsOf("build_go_build")) == 0 { t.Fatalf("build = %+v at revision %d (was %d); want built again on the event", v, r.st.revs[r.key], rev) } if !log.has("heard artifacts-gone") || !log.has(r.key, "built again", goneRef) { diff --git a/modules/mesh-delivery-builder/internal/build/imports_test.go b/modules/mesh-delivery-builder/internal/build/imports_test.go index 4b7d27e4..20ee96e5 100644 --- a/modules/mesh-delivery-builder/internal/build/imports_test.go +++ b/modules/mesh-delivery-builder/internal/build/imports_test.go @@ -6,7 +6,8 @@ import ( "sync" "testing" - "git.novox.be/novox/mesh-sdk/go/delivery" + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds" + "git.novox.be/novox/mesh-delivery/sdk/buildstep" ) // ADR 0593 §2 (F3): the imports a Go build module answers at a build's end reach the done key and the log's end entry, @@ -14,7 +15,7 @@ import ( func importsABuildAnswersReachTheDoneKeyAndTheLog(t *testing.T) { r := newRig(t, goBundle) imports := []string{"git.novox.be/novox/mesh-sdk/go", "git.novox.be/novox/mesh-sdk/go/announce"} - r.asks.Answer = func(a delivery.StepAsk) delivery.Step { + r.asks.Answer = func(a buildstep.StepAsk) buildstep.Step { step := r.answer(a) var answer map[string]any if err := json.Unmarshal(step.Result, &answer); err == nil && a.Language == "go" { @@ -23,14 +24,61 @@ func importsABuildAnswersReachTheDoneKeyAndTheLog(t *testing.T) { } return step } - var logged []delivery.BuildLogEntry + var logged []builds.BuildLogEntry var mu sync.Mutex - r.b.Node, r.b.Log = "novox", func(e delivery.BuildLogEntry) { mu.Lock(); logged = append(logged, e); mu.Unlock() } + r.b.Node, r.b.Log = "novox", func(e builds.BuildLogEntry) { mu.Lock(); logged = append(logged, e); mu.Unlock() } v := r.consider(t, "modules/m") - if v.State != delivery.BuildDone || strings.Join(v.Imports, ",") != strings.Join(imports, ",") { + if v.State != builds.BuildDone || strings.Join(v.Imports, ",") != strings.Join(imports, ",") { t.Fatalf("done key %+v, want the imports", v) } if len(logged) != 2 || strings.Join(logged[1].Imports, ",") != strings.Join(imports, ",") || len(logged[0].Imports) != 0 { t.Fatalf("log %+v, want the imports on the end alone", logged) } } + +// Design 26 (the lead's ruling on A2i's finding 2): the out-of-step files a contracts module's build module answers, in a +// failed step's result too, reach the log's end entry, sorted and each once, where the indexer raises its finding; a +// build that answers none names none. +func outOfStepFilesABuildAnswersReachTheLog(t *testing.T) { + for _, row := range []struct { + name string + fail bool + files []string + want string + }{ + {"a passed build naming files", false, []string{"go/b.go", "go/a.go", "go/b.go"}, "go/a.go,go/b.go"}, + {"a failed build naming files", true, []string{"ts/x.ts"}, "ts/x.ts"}, + {"a build naming none", false, nil, ""}, + } { + t.Run(row.name, func(t *testing.T) { + r := newRig(t, goBundle) + r.asks.Answer = func(a buildstep.StepAsk) buildstep.Step { + step := r.answer(a) + answer := map[string]any{} + _ = json.Unmarshal(step.Result, &answer) + if row.files != nil { + answer["out-of-step"] = row.files + } + step.Result, _ = json.Marshal(answer) + if row.fail { + step.State, step.Reason = buildstep.StepFailed, "the generated files are out of step" + } + return step + } + var logged []builds.BuildLogEntry + var mu sync.Mutex + r.b.Node, r.b.Log = "novox", func(e builds.BuildLogEntry) { mu.Lock(); logged = append(logged, e); mu.Unlock() } + r.consider(t, "modules/m") + mu.Lock() + defer mu.Unlock() + if len(logged) != 2 || strings.Join(logged[1].OutOfStep, ",") != row.want || len(logged[0].OutOfStep) != 0 { + t.Fatalf("log %+v, want the end naming %q", logged, row.want) + } + // A failed build's end carries its reason, so the log never hides why it failed; a passed one no error. + if end := logged[1]; row.fail != strings.Contains(end.Error, "out of step") || !row.fail && end.Error != "" || + logged[0].Error != "" || logged[0].Step != "" { + t.Errorf("log %+v, want a failed build's end alone to carry its error", logged) + } + }) + } +} diff --git a/modules/mesh-delivery-builder/internal/build/numbers.go b/modules/mesh-delivery-builder/internal/build/numbers.go new file mode 100644 index 00000000..6cf6250f --- /dev/null +++ b/modules/mesh-delivery-builder/internal/build/numbers.go @@ -0,0 +1,142 @@ +package build + +import ( + "context" + "fmt" + "strconv" + "time" + + "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds" + "git.novox.be/novox/mesh-delivery/sdk/trunk" + + "mesh-delivery-builder/internal/versions" +) + +// Numbers numbers a trunk build at its claim (novox/hq design 51 *The versions*, ADR 0606, ADR 0617 §1) and records the +// digests it was built with. Nil numbers nothing. +type Numbers interface { + // tag is a check's build's tag (TagOf), empty for a trunk build; done the digests of its key when it is done already + // (versions.BuildClaim.Done), nil otherwise. + Number(ctx context.Context, module string, m meshcatalog.ModulesByModuleValue, plan Plan, tag string, done map[string]string) (versions.Numbering, error) + Built(ctx context.Context, module string, m meshcatalog.ModulesByModuleValue, plan Plan, got versions.Numbering, made []builds.BuiltArtifact) error +} + +// VersionNumbers is Numbers over the builder's state versions. +type VersionNumbers struct { + N *versions.Numberer + // TagNumbered is told the versions a check's build of a tag ask's head was numbered (the ask is then marked numbered + // in tag-asks, design 51 *Tagged versions*); nil tells nobody. + TagNumbered func(ctx context.Context, c versions.BuildClaim, got versions.Numbering) +} + +// ClaimOf is the claim a module value's trunk build is numbered by: a package per source revision (Src), the rest per +// fingerprint, in merge order (Src). +func ClaimOf(module string, m meshcatalog.ModulesByModuleValue, plan Plan) versions.BuildClaim { + c := versions.BuildClaim{Module: module, Path: m.Path, Repository: trunk.RepositoryName(m.Repository), Fingerprint: m.Fp2, + Commit: m.Commit, Merge: uint64(m.Src)} + if uint64(m.Src) != 0 { + c.Revision = strconv.FormatUint(uint64(m.Src), 10) + } + for _, t := range plan.Tasks { + c.Artifacts = append(c.Artifacts, versions.ClaimArtifact{Artifact: t.Artifact, Kind: t.Kind, Language: t.Language}) + } + return c +} + +func (v VersionNumbers) Number(ctx context.Context, module string, m meshcatalog.ModulesByModuleValue, plan Plan, tag string, done map[string]string) (versions.Numbering, error) { + c := ClaimOf(module, m, plan) + c.Done = done + if tag != "" { + // A check's build of a head a tag ask names: its packages tagged, below the next version (design 51 *Tagged versions*). + c.Tag, c.Head, c.Revision, c.Merge = tag, m.Commit, "", 0 + } + got, err := v.N.Number(ctx, c) + if err == nil && tag != "" && len(got.Versions) > 0 && v.TagNumbered != nil { + v.TagNumbered(ctx, c, got) + } + return got, err +} + +func (v VersionNumbers) Built(ctx context.Context, module string, m meshcatalog.ModulesByModuleValue, plan Plan, got versions.Numbering, made []builds.BuiltArtifact) error { + digests := map[string]string{} + for _, a := range made { + digests[a.Name] = a.Digest + } + return v.N.Built(ctx, ClaimOf(module, m, plan), got, digests) +} + +// TagOf answers the cleaned tag a kept tag ask names for a repository's head (the builder's state tag-asks, design 51 +// *Tagged versions*): a check's build of that head numbers its packages tagged at its claim. +type TagOf func(ctx context.Context, repository, head string) (tag string, ok bool) + +// number numbers a build before its claim, a trunk build's artifacts or a check's build's packages under its tag: false +// when it waits for its floor (a package, design 51 *The floor*), and then nothing is claimed; the floor's wake looks at +// the module again. +func (b *Builder) number(ctx context.Context, module string, m meshcatalog.ModulesByModuleValue, plan Plan, tag string) (versions.Numbering, bool) { + if b.Numbers == nil { + return versions.Numbering{}, true + } + var done map[string]string + if tag == "" { + done = b.doneDigests(ctx, module, m.Fp2) + } + got, err := b.Numbers.Number(ctx, module, m, plan, tag, done) + for _, s := range got.Said { + b.logf("%s at %s: %s", module, m.Fp2, s.Line) + // And in the build log (design 51 *The versions*: it says a version was kept, and why), between the build's start and end. + // A look at a key already done builds nothing and has no start or end: a version kept there says nothing new, and is + // not logged (each later look, a first view answered or a name heard, would add one). + if b.Log != nil && !(done != nil && s.Why == versions.SaidKept) { + b.Log(builds.BuildLogEntry{Event: builds.BuildSaidEvent, Module: module, FP: m.Fp2, Commit: m.Commit, Node: b.Node, + At: time.Now().UTC(), Line: s.Line, Why: s.Why}) + } + } + if err != nil { + // Said, and built as before: an unnumbered build is published by its digest (ADR 0617 §1), numbered at its next look. + b.logf("%s at %s: %v", module, m.Fp2, fmt.Errorf("not numbered, the versions cannot be used: %w", err)) + return versions.Numbering{}, true + } + if got.Wait != "" { + b.logf("%s at %s is not built yet: %s", module, m.Fp2, got.Wait) + return got, false + } + return got, true +} + +// doneDigests are the digests of a trunk build's key by artifact when the key is done, nil otherwise or when it cannot be +// read: a look at a done build numbers what it left unnumbered with them (versions.BuildClaim.Done). +func (b *Builder) doneDigests(ctx context.Context, module, fp string) map[string]string { + key, err := builds.BuilderBuildKey(module, fp) + if err != nil || b.Claimer.Store == nil { + return nil + } + raw, _, found, err := b.Claimer.Store.Get(ctx, key) + if err != nil || !found { + return nil + } + cur, err := builds.DecodeBuilderBuild(raw) + if err != nil || cur.State != builds.BuildDone { + return nil + } + out := map[string]string{} + for _, a := range cur.Artifacts { + if a.Digest != "" { + out[a.Name] = a.Digest + } + } + return out +} + +// PackageNamed looks at a module again when the indexer's packages state names a name for one of its packages +// (versions.NamedModule): a done trunk build that left the package unnumbered, its name not known at its claim, is +// numbered then with the done build's digests, and version.numbered says them for its build module's late publish. +func (b *Builder) PackageNamed(ctx context.Context, module string) { + b.init() + b.mu.Lock() + m, ok := b.known[module] + b.mu.Unlock() + if ok { + b.consider(ctx, module, m) + } +} diff --git a/modules/mesh-delivery-builder/internal/build/plan.go b/modules/mesh-delivery-builder/internal/build/plan.go index 4a5969a1..11c678d5 100644 --- a/modules/mesh-delivery-builder/internal/build/plan.go +++ b/modules/mesh-delivery-builder/internal/build/plan.go @@ -9,8 +9,8 @@ import ( "strings" "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun" + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds" "git.novox.be/novox/mesh-sdk/go/catalogue" - "git.novox.be/novox/mesh-sdk/go/delivery" ) // ImageBuildModule is the build module that builds an image artifact (novox/hq ADR 0487): buildah, rootless, on the nodes that @@ -63,19 +63,19 @@ func (p Plan) NeedsToolchain(module string) bool { } // Refused is the error of a plan the builder does not build: an artifact of a kind (or of a language) no build module -// builds. It opens delivery.RefusedKind, so its key is not built again until the fingerprint changes. +// builds. It opens builds.RefusedKind, so its key is not built again until the fingerprint changes. type Refused struct{ Artifact, What string } func (r *Refused) Error() string { - return fmt.Sprintf("%s%s (artifact %s): no build module builds it; the builder builds Go and TypeScript bundles, archives, images, upstream copies and TypeScript packages", delivery.RefusedKind, r.What, r.Artifact) + return fmt.Sprintf("%s%s (artifact %s): no build module builds it; the builder builds Go and TypeScript bundles, archives, images, upstream copies and TypeScript packages", builds.RefusedKind, r.What, r.Artifact) } // NotPinned is the error of a plan with an upstream artifact whose reference names no digest (ADR 0498 clause 4): it opens -// delivery.NotPinned, so its key is not built again until the fingerprint changes. +// builds.NotPinned, so its key is not built again until the fingerprint changes. type NotPinned struct{ Artifact, From string } func (n *NotPinned) Error() string { - return fmt.Sprintf("%s%s: %q names no digest (@sha256:), so what would be copied is whatever its tag means today", delivery.NotPinned, n.Artifact, n.From) + return fmt.Sprintf("%s%s: %q names no digest (@sha256:), so what would be copied is whatever its tag means today", builds.NotPinned, n.Artifact, n.From) } // PlanOf is the plan of a manifest (ADR 0478 clauses 1 and 2, ADR 0498 clauses 4 and 5): a Go bundle and an archive are diff --git a/modules/mesh-delivery-builder/internal/build/plan_test.go b/modules/mesh-delivery-builder/internal/build/plan_test.go index 05608e98..897f3e1c 100644 --- a/modules/mesh-delivery-builder/internal/build/plan_test.go +++ b/modules/mesh-delivery-builder/internal/build/plan_test.go @@ -3,11 +3,12 @@ package build import ( "errors" - "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun" - "git.novox.be/novox/mesh-sdk/go/delivery" "strings" "testing" + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun" + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds" + "git.novox.be/novox/mesh-sdk/go/catalogue" ) @@ -57,21 +58,21 @@ func TestPlanSendsAPinnedUpstreamToTheImageBuildModulesCopyAndRefusesAnUnpinnedO t.Error("a copy needs no toolchain") } _, err = PlanOf(manifestOf(catalogue.Artifact{Name: "up", Kind: "upstream", From: "nats:2.11.17-alpine"})) - if err == nil || !strings.HasPrefix(err.Error(), delivery.NotPinned+"up") || !delivery.FailedUntilChanged(err.Error()) { - t.Errorf("an upstream with no digest: error = %v, want it to open %q naming the artifact", err, delivery.NotPinned) + if err == nil || !strings.HasPrefix(err.Error(), builds.NotPinned+"up") || !builds.FailedUntilChanged(err.Error()) { + t.Errorf("an upstream with no digest: error = %v, want it to open %q naming the artifact", err, builds.NotPinned) } } func TestPlanRefusesAPackageOfAnotherLanguageByName(t *testing.T) { // ADR 0498 clause 5 _, err := PlanOf(manifestOf(catalogue.Artifact{Name: "ok", Kind: "archive"}, catalogue.Artifact{Name: "pkg", Kind: "package", Language: "python"})) var r *Refused - if err == nil || !asRefused(err, &r) || !strings.HasPrefix(err.Error(), delivery.RefusedKind) || !strings.Contains(err.Error(), "python") || !strings.Contains(err.Error(), "pkg") { - t.Errorf("error = %v, want it to open %q naming the artifact and its language", err, delivery.RefusedKind) + if err == nil || !asRefused(err, &r) || !strings.HasPrefix(err.Error(), builds.RefusedKind) || !strings.Contains(err.Error(), "python") || !strings.Contains(err.Error(), "pkg") { + t.Errorf("error = %v, want it to open %q naming the artifact and its language", err, builds.RefusedKind) } } func TestPlanRefusesABundleOfAnotherLanguageAndAModuleWithNoArtifact(t *testing.T) { - if _, err := PlanOf(manifestOf(catalogue.Artifact{Name: "x", Kind: "bundle", Language: "python"})); err == nil || !strings.HasPrefix(err.Error(), delivery.RefusedKind) { + if _, err := PlanOf(manifestOf(catalogue.Artifact{Name: "x", Kind: "bundle", Language: "python"})); err == nil || !strings.HasPrefix(err.Error(), builds.RefusedKind) { t.Errorf("a python bundle: error = %v, want a refusal", err) } p, err := PlanOf(manifestOf()) diff --git a/modules/mesh-delivery-builder/internal/build/queue.go b/modules/mesh-delivery-builder/internal/build/queue.go index 7542a503..68d94081 100644 --- a/modules/mesh-delivery-builder/internal/build/queue.go +++ b/modules/mesh-delivery-builder/internal/build/queue.go @@ -6,7 +6,8 @@ import ( "strings" "time" - "git.novox.be/novox/mesh-sdk/go/delivery" + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds" + "git.novox.be/novox/mesh-delivery/sdk/buildstep" ) // **The tool builder_seat_queue reads a build seat's stream as the builder reads it** (novox/hq issue 1945 note 62; the @@ -19,7 +20,7 @@ const ( SeatQueueTool = "builder_seat_queue" // QueuesState is the builder's state of queue reads: the bucket mesh-delivery-builder_queues, keyed by the language. QueuesState = "queues" - QueuesBucket = delivery.BuilderModule + "_" + QueuesState + QueuesBucket = builds.BuilderModule + "_" + QueuesState // DefaultQueueLimit and MaxQueueLimit bound the messages read of each kind. DefaultQueueLimit = 50 MaxQueueLimit = 500 @@ -87,7 +88,7 @@ func NewKindQueue(kind string, seen AskSeen, now time.Time) KindQueue { // NewQueueAsk is the value builder_seat_queue writes for a language: its key and the value. func NewQueueAsk(language string, limit int, by string, at time.Time) (string, []byte, error) { - if _, err := delivery.AskStream(language); err != nil { + if _, err := buildstep.AskStream(language); err != nil { return "", nil, fmt.Errorf("language: %w", err) } if limit <= 0 { diff --git a/modules/mesh-delivery-builder/internal/build/r4e_test.go b/modules/mesh-delivery-builder/internal/build/r4e_test.go index a9ab4f29..73b1145e 100644 --- a/modules/mesh-delivery-builder/internal/build/r4e_test.go +++ b/modules/mesh-delivery-builder/internal/build/r4e_test.go @@ -2,12 +2,17 @@ package build import ( "context" + "encoding/json" "strings" "testing" - "git.novox.be/novox/mesh-sdk/go/buildstep" + "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" + "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliveryclient" + "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds" + "git.novox.be/novox/mesh-delivery/sdk/buildstep" "git.novox.be/novox/mesh-sdk/go/catalogue" - "git.novox.be/novox/mesh-sdk/go/delivery" + "git.novox.be/novox/mesh-sdk/go/keys" ) // R4e: references in the key (novox/hq ADR 0498), upstream copies, packages published from the trunk, the package wait @@ -19,7 +24,7 @@ func TestACandidatesBundleKeyNamesItsReferenceAndTheTrunkBuildsItNoMore(t *testi r.b.Candidate(context.Background(), "feat%2Fx", candidateValue(t, "feat/x", 7, headX)) r.b.Wait() v := valueOf(t, r.st, r.key) - if v.State != delivery.BuildDone { + if v.State != builds.BuildDone { t.Fatalf("build = %+v, want done", v) } if ok, missing := v.Referenced(); !ok { @@ -40,21 +45,21 @@ func TestABundleWhosePushAnsweredNoReferenceFailsNotStoredAndIsBuiltAgain(t *tes r := newRig(t, goBundle) r.bm.noRef["ace/tool"] = true v := r.consider(t, "modules/m") - if v.State != delivery.BuildFailed || !strings.HasPrefix(v.Error, delivery.NotStored) || !strings.Contains(v.Error, "tool") { - t.Fatalf("build = %+v, want failed opening %q and naming the artifact", v, delivery.NotStored) + if v.State != builds.BuildFailed || !strings.HasPrefix(v.Error, builds.NotStored) || !strings.Contains(v.Error, "tool") { + t.Fatalf("build = %+v, want failed opening %q and naming the artifact", v, builds.NotStored) } delete(r.bm.noRef, "ace/tool") - if v = r.consider(t, "modules/m"); v.State != delivery.BuildDone { + if v = r.consider(t, "modules/m"); v.State != builds.BuildDone { t.Errorf("build = %+v, want a not-stored key built again as a new revision", v) } } func TestAPushRefusedByTheStoreFailsTheKeyOpeningNotStored(t *testing.T) { // B1, ADR 0498 clause 2 r := newRig(t, goBundle) - r.bm.fails["ace/build_go_build"] = delivery.NotStored + "tool: the artifact store answered 507 insufficient storage" + r.bm.fails["ace/build_go_build"] = builds.NotStored + "tool: the artifact store answered 507 insufficient storage" v := r.consider(t, "modules/m") - if v.State != delivery.BuildFailed || !strings.HasPrefix(v.Error, delivery.NotStored) || !strings.Contains(v.Error, "507") || !strings.Contains(v.Error, "ace") { - t.Fatalf("build = %+v, want failed opening %q with the store's words and the node", v, delivery.NotStored) + if v.State != builds.BuildFailed || !strings.HasPrefix(v.Error, builds.NotStored) || !strings.Contains(v.Error, "507") || !strings.Contains(v.Error, "ace") { + t.Fatalf("build = %+v, want failed opening %q with the store's words and the node", v, builds.NotStored) } } @@ -78,9 +83,9 @@ func TestOnlyATrunkKeysBuildsAreAskedToPublish(t *testing.T) { // B1, the lead's } // putBuild writes a builder key's value as the store would hold it. -func putBuild(t *testing.T, st *memStore, key string, b delivery.BuilderBuild) uint64 { +func putBuild(t *testing.T, st *memStore, key string, b builds.BuilderBuild) uint64 { t.Helper() - data, err := delivery.EncodeBuilderBuild(b) + data, err := builds.EncodeBuilderBuild(b) if err != nil { t.Fatal(err) } @@ -91,10 +96,10 @@ func putBuild(t *testing.T, st *memStore, key string, b delivery.BuilderBuild) u func TestADoneKeyWrittenBeforeR4eIsBuiltAgainOnceAsANewRevision(t *testing.T) { // B2, ADR 0498 clause 8 r := newRig(t, goBundle, archiveA) - rev := putBuild(t, r.st, r.key, delivery.BuilderBuild{State: delivery.BuildDone, By: "novox.builder", Manifest: manifestD, Step: "built mesh-build-go on ace and novox", - Artifacts: []delivery.BuiltArtifact{{Name: "files", Kind: "archive", Digest: digest}, {Name: "tool", Kind: "bundle", Digest: digest}}}) + rev := putBuild(t, r.st, r.key, builds.BuilderBuild{State: builds.BuildDone, By: "novox.builder", Manifest: manifestD, Step: "built mesh-build-go on ace and novox", + Artifacts: []builds.BuiltArtifact{{Name: "files", Kind: "archive", Digest: digest}, {Name: "tool", Kind: "bundle", Digest: digest}}}) v := r.consider(t, "modules/m") - if v.State != delivery.BuildDone || r.st.revs[r.key] <= rev { + if v.State != builds.BuildDone || r.st.revs[r.key] <= rev { t.Fatalf("build = %+v at revision %d (was %d), want built again as a new revision", v, r.st.revs[r.key], rev) } if ok, missing := v.Referenced(); !ok { @@ -113,7 +118,7 @@ func TestAPinnedUpstreamIsCopiedOnceOnOneImageNodeWithNoFetchAndItsKeyNamesTheRe r := newRig(t, up) r.puller = "ace" v := r.consider(t, "modules/m") - if v.State != delivery.BuildDone || len(v.Artifacts) != 1 { + if v.State != builds.BuildDone || len(v.Artifacts) != 1 { t.Fatalf("build = %+v, want done with the copy", v) } if a := v.Artifacts[0]; a.Kind != "upstream" || a.Digest != digest || a.Reference != "registry.example/m/up@"+digest { @@ -146,13 +151,13 @@ func TestAnUpstreamBesideAnImageIsCopiedAndTheImageFetchedAndBuiltOnTheSameNode( func TestACopyThatFailsNotStoredFailsTheKeyAndIsBuiltAgain(t *testing.T) { // B4, ADR 0498 clause 2 r := newRig(t, catalogue.Artifact{Name: "up", Kind: "upstream", From: "nats:2.11.17-alpine@" + digest}) r.puller = "ace" - r.bm.fails["ace/build_image_copy"] = delivery.NotStored + "up: the registry answered 500" + r.bm.fails["ace/build_image_copy"] = builds.NotStored + "up: the registry answered 500" v := r.consider(t, "modules/m") - if v.State != delivery.BuildFailed || !strings.HasPrefix(v.Error, delivery.NotStored) || delivery.FailedUntilChanged(v.Error) { + if v.State != builds.BuildFailed || !strings.HasPrefix(v.Error, builds.NotStored) || builds.FailedUntilChanged(v.Error) { t.Fatalf("build = %+v, want failed 'not stored:' and built again", v) } delete(r.bm.fails, "ace/build_image_copy") - if v = r.consider(t, "modules/m"); v.State != delivery.BuildDone { + if v = r.consider(t, "modules/m"); v.State != builds.BuildDone { t.Errorf("build = %+v, want done on the new revision", v) } } @@ -166,7 +171,7 @@ func TestATypeScriptPackageIsBuiltByTheNodeModuleAndPublishedOnlyForTheTrunk(t * c.b.Candidate(context.Background(), "feat%2Fx", candidateValue(t, "feat/x", 7, headX)) c.b.Wait() v := valueOf(t, c.st, c.key) - if v.State != delivery.BuildDone || len(v.Artifacts) != 1 || v.Artifacts[0].Kind != "package" || v.Artifacts[0].Reference != "" { + if v.State != builds.BuildDone || len(v.Artifacts) != 1 || v.Artifacts[0].Kind != "package" || v.Artifacts[0].Reference != "" { t.Fatalf("candidate build = %+v, want done with the packed digest and no reference", v) } for _, call := range c.bm.callsOf("build_node_build") { @@ -178,7 +183,7 @@ func TestATypeScriptPackageIsBuiltByTheNodeModuleAndPublishedOnlyForTheTrunk(t * r := newRig(t, tsPackage) r.bm.packages["lib"] = "@novox/mesh-sdk@0.1.15" v = r.consider(t, "modules/m") - if v.State != delivery.BuildDone || v.Artifacts[0].Reference != "@novox/mesh-sdk@0.1.15" { + if v.State != builds.BuildDone || v.Artifacts[0].Reference != "@novox/mesh-sdk@0.1.15" { t.Fatalf("trunk build = %+v, want done naming the published version", v) } if n := len(r.bm.callsOf("build_node_build")); n == 0 { @@ -189,11 +194,11 @@ func TestATypeScriptPackageIsBuiltByTheNodeModuleAndPublishedOnlyForTheTrunk(t * func TestAVersionTakenFailsTheKeyUntilTheFingerprintChanges(t *testing.T) { // B5, ADR 0498 clause 5 r := newRig(t, tsPackage) r.bm.packages["lib"] = "@novox/mesh-sdk@0.1.15" - r.bm.fails["ace/build_node_build"] = delivery.VersionTaken + "@novox/mesh-sdk@0.1.15 is published with another packed file" + r.bm.fails["ace/build_node_build"] = builds.VersionTaken + "@novox/mesh-sdk@0.1.15 is published with another packed file" r.bm.fails["novox/build_node_build"] = r.bm.fails["ace/build_node_build"] v := r.consider(t, "modules/m") - if v.State != delivery.BuildFailed || !strings.HasPrefix(v.Error, delivery.VersionTaken) || !strings.Contains(v.Error, "0.1.15") { - t.Fatalf("build = %+v, want failed opening %q naming the version", v, delivery.VersionTaken) + if v.State != builds.BuildFailed || !strings.HasPrefix(v.Error, builds.VersionTaken) || !strings.Contains(v.Error, "0.1.15") { + t.Fatalf("build = %+v, want failed opening %q naming the version", v, builds.VersionTaken) } writes := r.st.writes r.consider(t, "modules/m") @@ -208,7 +213,7 @@ func TestACandidatesPackageIsPublishedOnceWhenTheTrunkNamesItsFingerprint(t *tes trunkModule(t, r) r.b.Candidate(context.Background(), "feat%2Fx", candidateValue(t, "feat/x", 7, headX)) r.b.Wait() - if v := valueOf(t, r.st, r.key); v.State != delivery.BuildDone { + if v := valueOf(t, r.st, r.key); v.State != builds.BuildDone { t.Fatalf("candidate build = %+v", v) } rev := r.st.revs[r.key] @@ -237,12 +242,12 @@ func TestAPublishThatFailsVersionTakenOnTheTrunkKeepsThePrefixFirst(t *testing.T r.b.Candidate(context.Background(), "feat%2Fx", candidateValue(t, "feat/x", 7, headX)) r.b.Wait() for _, n := range []string{"ace", "novox"} { - r.bm.fails[n+"/build_node_build"] = delivery.VersionTaken + "@novox/mesh-sdk@0.1.15 is published with another packed file" + r.bm.fails[n+"/build_node_build"] = builds.VersionTaken + "@novox/mesh-sdk@0.1.15 is published with another packed file" } r.b.Module(context.Background(), "m", moduleValue(t, "modules/m")) r.b.Wait() - if v := valueOf(t, r.st, r.key); v.State != delivery.BuildFailed || !strings.HasPrefix(v.Error, delivery.VersionTaken) { - t.Errorf("build = %+v, want failed opening %q", v, delivery.VersionTaken) + if v := valueOf(t, r.st, r.key); v.State != builds.BuildFailed || !strings.HasPrefix(v.Error, builds.VersionTaken) { + t.Errorf("build = %+v, want failed opening %q", v, builds.VersionTaken) } } @@ -251,8 +256,8 @@ func sdkModule(t *testing.T, r *rig, ref string) []byte { t.Helper() r.mf.byDigest[mdigest('b')] = catalogue.Manifest{Module: "sdk", Build: &catalogue.Build{Artifacts: []catalogue.Artifact{tsPackage}}} r.bm.packages["lib"] = ref - data, err := delivery.EncodeModule(delivery.Module{Repository: "novox/mesh-sdk", Commit: "8a74cee83f88e6585a5511c3cc53bdf0f5303d20", FP2: "src2:" + hex64('5'), - Manifest: mdigest('b'), Path: ".", Toolchains: agreedToolchains}) + data, err := meshcatalogclient.EncodeModule(meshcatalog.ModulesByModuleValue{Repository: "novox/mesh-sdk", Commit: "8a74cee83f88e6585a5511c3cc53bdf0f5303d20", Fp2: "src2:" + hex64('5'), + Manifest: mdigest('b'), Path: ".", Toolchains: CatalogToolchains(agreedToolchains)}) if err != nil { t.Fatal(err) } @@ -266,7 +271,7 @@ func TestAModuleWhoseLockNamesAnUnpublishedPackageWaitsQueuedAndIsBuiltOncePubli r.bm.fails[n+"/build_node_fetch"] = waitsFor + ref + ": the lock names a version the registry does not hold" } v := r.consider(t, "modules/m") - if v.State != delivery.BuildQueued || v.Waits != ref || v.Error != "" { + if v.State != builds.BuildQueued || v.Waits != ref || v.Error != "" { t.Fatalf("build = %+v, want queued, waiting for %s, not failed", v, ref) } delete(r.bm.fails, "ace/build_node_fetch") @@ -279,7 +284,7 @@ func TestAModuleWhoseLockNamesAnUnpublishedPackageWaitsQueuedAndIsBuiltOncePubli } r.b.Module(context.Background(), "sdk", sdkModule(t, r, ref)) // the package's trunk key is built and published r.b.Wait() - if v = valueOf(t, r.st, r.key); v.State != delivery.BuildDone || v.Waits != "" { + if v = valueOf(t, r.st, r.key); v.State != builds.BuildDone || v.Waits != "" { t.Errorf("build = %+v, want built once the package it waits for is published", v) } } @@ -287,16 +292,16 @@ func TestAModuleWhoseLockNamesAnUnpublishedPackageWaitsQueuedAndIsBuiltOncePubli func TestAKeyWaitingForAPackageAlreadyPublishedIsBuiltWhenTheBuilderStarts(t *testing.T) { // B7: the scan at start const ref = "@novox/mesh-sdk@0.1.15" r := newRig(t, tsBundle) - putBuild(t, r.st, r.key, delivery.BuilderBuild{State: delivery.BuildQueued, By: "novox.builder", Manifest: manifestD, Waits: ref, Step: "waits"}) - sdkKey, _ := delivery.BuilderBuildKey("sdk", "src2:"+hex64('5')) - putBuild(t, r.st, sdkKey, delivery.BuilderBuild{State: delivery.BuildDone, By: "novox.builder", Manifest: mdigest('b'), Step: "built", - Artifacts: []delivery.BuiltArtifact{{Name: "lib", Kind: "package", Digest: digest, Reference: ref}}}) + putBuild(t, r.st, r.key, builds.BuilderBuild{State: builds.BuildQueued, By: "novox.builder", Manifest: manifestD, Waits: ref, Step: "waits"}) + sdkKey, _ := builds.BuilderBuildKey("sdk", "src2:"+hex64('5')) + putBuild(t, r.st, sdkKey, builds.BuilderBuild{State: builds.BuildDone, By: "novox.builder", Manifest: mdigest('b'), Step: "built", + Artifacts: []builds.BuiltArtifact{{Name: "lib", Kind: "package", Digest: digest, Reference: ref}}}) r.b.Module(context.Background(), "m", moduleValue(t, "modules/m")) if err := r.b.Sweep(context.Background()); err != nil { t.Fatal(err) } r.b.Wait() - if v := valueOf(t, r.st, r.key); v.State != delivery.BuildDone { + if v := valueOf(t, r.st, r.key); v.State != builds.BuildDone { t.Errorf("build = %+v, want built: the package it waits for was published before the builder started", v) } } @@ -307,9 +312,9 @@ func TestTheFetchOfAModuleNamingItsPathsCarriesItsDirectoryAndThosePaths(t *test paths := map[string]string{"src": hex40('1'), "package.json": hex40('2')} // The named tree the indexer computes from those paths (its own, ADR 0593 §2), given as data: the builder reads it, // never computes it. - const tree = delivery.NamedTreePrefix + "5b5e0f3b6f3c1f2a9d8e7c6b5a4938271605f4e3d2c1b0a9f8e7d6c5b4a39281" - data, err := delivery.EncodeModule(delivery.Module{Repository: "novox/mesh-sdk", Commit: "7a74cee83f88e6585a5511c3cc53bdf0f5303d20", FP2: fp1, Manifest: manifestD, Path: ".", - Toolchains: agreedToolchains, Inputs2: &delivery.KeptInputs2{Tree: tree, Paths: paths}}) + const tree = "named:" + "5b5e0f3b6f3c1f2a9d8e7c6b5a4938271605f4e3d2c1b0a9f8e7d6c5b4a39281" + data, err := meshcatalogclient.EncodeModule(meshcatalog.ModulesByModuleValue{Repository: "novox/mesh-sdk", Commit: "7a74cee83f88e6585a5511c3cc53bdf0f5303d20", Fp2: fp1, Manifest: manifestD, Path: ".", + Toolchains: CatalogToolchains(agreedToolchains), Inputs2: &meshcatalog.ModulesByModuleValueInputs2{Tree: tree, Paths: paths}}) if err != nil { t.Fatal(err) } @@ -335,7 +340,7 @@ func hex40(c byte) string { return strings.Repeat(string(c), 40) } func TestOneNodeBuildsEachFingerprint(t *testing.T) { // B9, ADR 0478 clause 4: "From R4e one node builds each fingerprint" r := newRig(t, goBundle, archiveA, tsBundle) // ace and novox both qualify for both build modules v := r.consider(t, "modules/m") - if v.State != delivery.BuildDone || strings.Contains(v.Step, " and ") || strings.HasPrefix(v.Step, "one node (") { + if v.State != builds.BuildDone || strings.Contains(v.Step, " and ") || strings.HasPrefix(v.Step, "one node (") { t.Fatalf("build = %+v, want done on one node of each build module, unmarked", v) } if f, b := len(r.bm.callsOf("build_go_fetch")), len(r.bm.callsOf("build_go_build")); f != 1 || b != 2 { @@ -348,9 +353,9 @@ func TestOneNodeBuildsEachFingerprint(t *testing.T) { // B9, ADR 0478 clause 4: func TestAKeyDoneOnOneNodeIsNeverBuiltAgainForASecondNode(t *testing.T) { // B9: the second-node retake is gone r := newRig(t, goBundle) - putBuild(t, r.st, r.key, delivery.BuilderBuild{State: delivery.BuildDone, By: "novox.builder", Manifest: manifestD, + putBuild(t, r.st, r.key, builds.BuilderBuild{State: builds.BuildDone, By: "novox.builder", Manifest: manifestD, Step: "one node (mesh-build-go): built mesh-build-go on one node, novox", - Artifacts: []delivery.BuiltArtifact{{Name: "tool", Kind: "bundle", Digest: digest, Reference: "artifact-store://m/tool/blobs/" + digest}}}) + Artifacts: []builds.BuiltArtifact{{Name: "tool", Kind: "bundle", Digest: digest, Reference: "artifact-store://m/tool/blobs/" + digest}}}) writes := r.st.writes r.b.Module(context.Background(), "m", moduleValue(t, "modules/m")) r.b.NodesChanged(context.Background()) @@ -364,10 +369,10 @@ func TestACandidatesFetchNamesTheNamedPathsTheIndexerGaveIt(t *testing.T) { // A r := newRig(t, tsPackage) r.bm.packages["lib"] = "@novox/mesh-sdk@0.1.15" trunkModule(t, r) - pulls := []delivery.CandidatePull{{Head: delivery.Head{Repository: "novox/mesh-catalog", Number: 7, Head: headX}, Modules: []string{"m"}, - Fps: map[string]delivery.CandidateModule{"m": {FP2: fp1, Manifest: mdigest('a'), Paths: map[string]string{"src": hex40('1'), "package.json": hex40('2')}}}}} - hash, _ := delivery.HeadsHash([]delivery.Head{pulls[0].Head}) - data, err := delivery.EncodeCandidate(delivery.Candidate{V: delivery.Version, Group: "feat/x", HeadsHash: hash, Pulls: pulls, FPSet: delivery.FingerprintSet(pulls)}) + pulls := []meshcatalog.CandidatesByGroupValuePullsItem{{Repository: "novox/mesh-catalog", Number: 7, Head: headX, Modules: []string{"m"}, + Fps: map[string]meshcatalog.CandidatesByGroupValuePullsItemFpsValue{"m": {Fp2: fp1, Manifest: mdigest('a'), Paths: map[string]string{"src": hex40('1'), "package.json": hex40('2')}}}}} + hash, _ := meshdeliveryclient.HeadsHash([]meshdeliveryclient.Head{{Repository: pulls[0].Repository, Number: int(pulls[0].Number), Head: pulls[0].Head}}) + data, err := json.Marshal(meshcatalog.CandidatesByGroupValue{V: keys.Version, Group: "feat/x", Heads: hash, Pulls: pulls, Fpset: meshcatalogclient.FingerprintSet(pulls)}) if err != nil { t.Fatal(err) } diff --git a/modules/mesh-delivery-builder/internal/build/round4_test.go b/modules/mesh-delivery-builder/internal/build/round4_test.go index 1f1673ec..8d7d8a1d 100644 --- a/modules/mesh-delivery-builder/internal/build/round4_test.go +++ b/modules/mesh-delivery-builder/internal/build/round4_test.go @@ -2,16 +2,19 @@ package build import ( "context" - "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun" "testing" + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun" + + "git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient" + "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds" "git.novox.be/novox/mesh-sdk/go/catalogue" - "git.novox.be/novox/mesh-sdk/go/delivery" ) -func haveValue(t *testing.T, phase delivery.Phase, health delivery.Health) []byte { +func haveValue(t *testing.T, phase, health string) []byte { t.Helper() - b, err := delivery.EncodeHave(delivery.Have{Phase: phase, Health: health}) + b, err := nodeengineclient.EncodeHave(nodeengine.HaveByNodeModuleValue{Phase: phase, Health: health}) if err != nil { t.Fatal(err) } @@ -22,7 +25,7 @@ func haveValue(t *testing.T, phase delivery.Phase, health delivery.Health) []byt // fingerprint names no toolchain, and the builder builds it on the build module's nodes with no toolchain agreed. func TestAnArchiveOnlyModuleIsBuiltAlthoughItsFingerprintNamesNoToolchain(t *testing.T) { // review round 4, 1 r := newRig(t, archiveA) - v := func() delivery.BuilderBuild { + v := func() builds.BuilderBuild { r.b.Module(context.Background(), "m", moduleValueWith(t, "modules/m", nil)) r.b.Wait() if _, ok := r.st.vals[r.key]; !ok { @@ -30,7 +33,7 @@ func TestAnArchiveOnlyModuleIsBuiltAlthoughItsFingerprintNamesNoToolchain(t *tes } return valueOf(t, r.st, r.key) }() - if v.State != delivery.BuildDone || len(v.Artifacts) != 1 || v.Artifacts[0].Kind != "archive" || len(v.Toolchains) != 0 { + if v.State != builds.BuildDone || len(v.Artifacts) != 1 || v.Artifacts[0].Kind != "archive" || len(v.Toolchains) != 0 { t.Fatalf("build = %+v, want done with the archive and no toolchain named", v) } if n := len(r.bm.callsOf("build_go_build")); n != 1 { @@ -51,7 +54,7 @@ func TestABundleAndAnArchiveOfOneBuildModuleStillNeedTheAgreedToolchain(t *testi func TestClaimLeavesAKeyFailedForDifferingDigestsAlone(t *testing.T) { // review round 4, 2 st := newMem() - seed(t, st, delivery.BuilderBuild{State: delivery.BuildFailed, Error: buildrun.DigestsDifferPrefix + " the nodes built tool to different digests: ace x; novox y"}) + seed(t, st, builds.BuilderBuild{State: builds.BuildFailed, Error: buildrun.DigestsDifferPrefix + " the nodes built tool to different digests: ace x; novox y"}) writes := st.writes got, err := (Claimer{Store: st, By: "novox.a"}).Claim(context.Background(), "m", fp1, "", ClaimOptions{TakeOver: true}) if err != nil || got != nil || st.writes != writes { @@ -62,7 +65,7 @@ func TestClaimLeavesAKeyFailedForDifferingDigestsAlone(t *testing.T) { // review // 6. A key found building is taken over, whoever holds it. func TestABuildLeftBuildingByAnyBuilderIsTakenOverAtStart(t *testing.T) { // review round 4, 6; 1607/AC3, ADR 0478 clauses 5, 6 st := newMem() - seed(t, st, delivery.BuilderBuild{State: delivery.BuildBuilding, By: "novox.a"}) + seed(t, st, builds.BuilderBuild{State: builds.BuildBuilding, By: "novox.a"}) if got, err := (Claimer{Store: st, By: "novox.a"}).Claim(context.Background(), "m", fp1, "", ClaimOptions{}); err != nil || got == nil { t.Errorf("own building key: claim = %v, %v, want it taken again", got, err) } @@ -76,9 +79,9 @@ func TestABuildLeftBuildingByAnyBuilderIsTakenOverAtStart(t *testing.T) { // rev func TestABuilderFindingAKeyBuildingByAnotherNodeBuildsItOnStart(t *testing.T) { // review round 4, 6 r := newRig(t, goBundle) - seed(t, r.st, delivery.BuilderBuild{State: delivery.BuildBuilding, By: "ace.builder", Manifest: manifestD}) + seed(t, r.st, builds.BuilderBuild{State: builds.BuildBuilding, By: "ace.builder", Manifest: manifestD}) v := r.consider(t, "modules/m") - if v.State != delivery.BuildDone || v.By != "novox.builder" { + if v.State != builds.BuildDone || v.By != "novox.builder" { t.Errorf("build = %+v, want it taken over and done by novox.builder", v) } } diff --git a/modules/mesh-delivery-builder/internal/build/run.go b/modules/mesh-delivery-builder/internal/build/run.go index 106916cf..4d38f634 100644 --- a/modules/mesh-delivery-builder/internal/build/run.go +++ b/modules/mesh-delivery-builder/internal/build/run.go @@ -13,15 +13,22 @@ import ( "sync" "time" + "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" + sweep "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshartifactsweep" + "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun" - "git.novox.be/novox/mesh-sdk/go/buildstep" + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds" + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds/forms" + sdkstep "git.novox.be/novox/mesh-delivery/sdk/buildstep" + repotrunk "git.novox.be/novox/mesh-delivery/sdk/trunk" "git.novox.be/novox/mesh-sdk/go/catalogue" - "git.novox.be/novox/mesh-sdk/go/delivery" + + "mesh-delivery-builder/internal/versions" ) // ToolchainEntries are the nodes' toolchain entries of a language (buildrun.ToolchainSet). type ToolchainEntries interface { - Entries(language string) []delivery.NodeToolchain + Entries(language string) []sdkstep.NodeToolchain } // Builder builds, dark, every new-form fingerprint the indexer writes (novox/hq ADR 0453 clause 4, ADR 0478): it is @@ -34,14 +41,14 @@ type Builder struct { // Asks puts each build module's part of a build as one ask on its build seat's stream and waits for its step (novox/hq // ADR 0525 §1, §3, §6; issue 1887 A4): the builder chooses no node, whichever build node pulls the ask builds it. Asks interface { - Put(ctx context.Context, a delivery.StepAsk) error - Wait(ctx context.Context, a delivery.StepAsk) (delivery.Step, error) + Put(ctx context.Context, a sdkstep.StepAsk) error + Wait(ctx context.Context, a sdkstep.StepAsk) (sdkstep.Step, error) } // Held says whether an ask a key names is on its stream or a build node wrote a step for it (issue 1910): a key taken // over at start whose ask is neither was left by a builder that stopped between naming the ask and putting it, and // the ask is put then, under the same id. Nil takes every named ask as held. Held interface { - Held(ctx context.Context, a delivery.StepAsk) (bool, error) + Held(ctx context.Context, a sdkstep.StepAsk) (bool, error) } Toolchains ToolchainEntries Manifests Manifests @@ -62,7 +69,7 @@ type Builder struct { // ask has waited CheckAfter, and again each CheckAfter it goes on waiting. An ask that cannot be answered is asked // again under a new id, at most Askings times. Nil, or CheckAfter 0, never asks it. Gone interface { - Gone(ctx context.Context, a delivery.StepAsk) (string, error) + Gone(ctx context.Context, a sdkstep.StepAsk) (string, error) } CheckAfter time.Duration // Askings is how many times one build module's part of a build is asked, the first included, before its key is failed @@ -74,24 +81,36 @@ type Builder struct { // Said is told each ask asked again (the builder's event build.asked-again). Nil tells nobody. Said func(AskedAgain) // Log is told each build's start and end, the entries of the builder's log (novox/hq ADR 0571 §1). Nil tells nobody. - Log func(delivery.BuildLogEntry) + Log func(builds.BuildLogEntry) // Now is the builder's clock: of the log, and of the decline bound (DeclineBound runs from the first decline); nil is // time.Now. Node is the node the builder runs on, which asked the build. Now func() time.Time Node string + // Numbers numbers each trunk build at its claim and records what it was built with (design 51 *The versions*); nil + // numbers nothing. + Numbers Numbers + // Tags answers a check's build's tag from the kept tag asks; nil numbers no check's build. + Tags TagOf + // Forms answers what each node's engine states it reads (ADR 0618 §2, ADR 0624 §1): present, the nodes whose engine + // answered when last asked, and counted, every node the assignments names, present or away, a node with no statement + // stating nothing (the baseline's first forms). A done key names each artifact in the forms these keep, the oldest in + // its single reference, and its needs. Nil: no node states anything, and the first forms are kept. + Forms func() (present, counted map[string][]string) once sync.Once sem chan struct{} wg sync.WaitGroup mu sync.Mutex - known map[string]delivery.Module // by modules key - inflight map[string]bool // by build key - again map[string]delivery.Module // by build key: the newest value that arrived while the key was being built - plans map[string]planned // by manifest digest - cands map[string]delivery.Candidate // by candidates key (candidates.go) - via map[string]via // by build key: a candidate's fetch (candidates.go) - trunkFP map[string]bool // by build key: a trunk module value carries this fingerprint - tried map[string]bool // by build key: looked at since this process started + known map[string]meshcatalog.ModulesByModuleValue // by modules key + inflight map[string]bool // by build key + again map[string]meshcatalog.ModulesByModuleValue // by build key: the newest value that arrived while the key was being built + plans map[string]planned // by manifest digest + cands map[string]meshcatalog.CandidatesByGroupValue // by candidates key (candidates.go) + via map[string]via // by build key: a candidate's fetch (candidates.go) + // outOfStep are, by build key, the out-of-step files a build's lanes answered, until its end entry takes them. + outOfStep map[string][]string + trunkFP map[string]bool // by build key: a trunk module value carries this fingerprint + tried map[string]bool // by build key: looked at since this process started // published are the package versions `@` a done key of this bucket names as a package's reference: a key // waiting for one is built once it is here (ADR 0500 clause 6). published map[string]bool @@ -116,8 +135,8 @@ func (b *Builder) init() { limit = 256 } b.sem = make(chan struct{}, limit) - b.cands, b.via, b.trunkFP = map[string]delivery.Candidate{}, map[string]via{}, map[string]bool{} - b.known, b.inflight, b.again, b.plans, b.tried = map[string]delivery.Module{}, map[string]bool{}, map[string]delivery.Module{}, map[string]planned{}, map[string]bool{} + b.cands, b.via, b.trunkFP = map[string]meshcatalog.CandidatesByGroupValue{}, map[string]via{}, map[string]bool{} + b.known, b.inflight, b.again, b.plans, b.tried = map[string]meshcatalog.ModulesByModuleValue{}, map[string]bool{}, map[string]meshcatalog.ModulesByModuleValue{}, map[string]planned{}, map[string]bool{} b.published, b.held, b.waiting = map[string]bool{}, map[string]bool{}, map[string]map[string]chan string{} b.hands = map[string]*handWatch{} }) @@ -136,14 +155,14 @@ func (b *Builder) Wait() { b.wg.Wait() } // to be built. A value that is not a module is said and left. func (b *Builder) Module(ctx context.Context, key string, value []byte) { b.init() - m, err := delivery.DecodeModule(value) + m, err := meshcatalogclient.DecodeModule(value) if err != nil { b.logf("modules.%s holds what is not a module, left: %v", key, err) return } b.mu.Lock() b.known[key] = m - if bk, err := delivery.BuilderBuildKey(key, m.FP2); err == nil && m.FP2 != "" { + if bk, err := builds.BuilderBuildKey(key, m.Fp2); err == nil && m.Fp2 != "" { // A fingerprint the trunk carries is built from the trunk's commit, whoever asked for it first. b.trunkFP[bk] = true delete(b.via, bk) @@ -169,8 +188,8 @@ func (b *Builder) Sweep(ctx context.Context) error { b.mu.Lock() current := map[string]bool{} for name, m := range b.known { - if m.FP2 != "" { - if k, err := delivery.BuilderBuildKey(name, m.FP2); err == nil { + if m.Fp2 != "" { + if k, err := builds.BuilderBuildKey(name, m.Fp2); err == nil { current[k] = true } } @@ -182,17 +201,17 @@ func (b *Builder) Sweep(ctx context.Context) error { if err != nil || !found { continue } - cur, err := delivery.DecodeBuilderBuild(value) + cur, err := builds.DecodeBuilderBuild(value) if err != nil { continue } newly = b.notePublished(cur) || newly - if current[key] || cur.State != delivery.BuildBuilding { + if current[key] || cur.State != builds.BuildBuilding { continue } - closed := delivery.BuilderBuild{State: delivery.BuildFailed, By: b.Claimer.By, Manifest: cur.Manifest, Step: cur.Step, + closed := builds.BuilderBuild{State: builds.BuildFailed, By: b.Claimer.By, Manifest: cur.Manifest, Step: cur.Step, Error: fmt.Sprintf("closed at start: left building by %s, and the module no longer has this fingerprint, so nothing will build it", cur.By)} - data, err := delivery.EncodeBuilderBuild(closed) + data, err := builds.EncodeBuilderBuild(closed) if err != nil { return err } @@ -209,8 +228,8 @@ func (b *Builder) Sweep(ctx context.Context) error { } // notePublished keeps the package versions a done build names as references, and says whether one was new. -func (b *Builder) notePublished(v delivery.BuilderBuild) bool { - if v.State != delivery.BuildDone { +func (b *Builder) notePublished(v builds.BuilderBuild) bool { + if v.State != builds.BuildDone { return false } b.mu.Lock() @@ -266,11 +285,11 @@ func (b *Builder) storedOnce(key string) func(ctx context.Context, references [] // that the store does not hold what the module's key names. The module is looked at again, which asks the store (ClaimOptions.Stored) and // builds the key again; an event for a fingerprint the module no longer has is said and left. The event only wakes the // builder: what is built rests on the store's answer, never on the event alone. -func (b *Builder) ArtifactsGone(ctx context.Context, e delivery.ArtifactsGone) { +func (b *Builder) ArtifactsGone(ctx context.Context, e sweep.ArtifactsGone) { b.init() b.mu.Lock() m, ok := b.known[e.Module] - if key, err := delivery.BuilderBuildKey(e.Module, e.Fingerprint); err == nil { + if key, err := builds.BuilderBuildKey(e.Module, e.Fingerprint); err == nil { for id := range b.held { if strings.HasPrefix(id, key+" ") { delete(b.held, id) @@ -278,13 +297,13 @@ func (b *Builder) ArtifactsGone(ctx context.Context, e delivery.ArtifactsGone) { } } b.mu.Unlock() - if !ok || m.FP2 != e.Fingerprint { + if !ok || m.Fp2 != e.Fingerprint { b.logf("%s: heard %s for %s, a fingerprint the module does not have now: nothing to build", e.Module, - delivery.ArtifactsGoneEvent, e.Fingerprint) + sweep.EventArtifactsGone, e.Fingerprint) return } b.logf("%s: heard %s (%s): asking the artifact store and building the key again", e.Module, - delivery.ArtifactsGoneEvent, strings.Join(e.Gone, ", ")) + sweep.EventArtifactsGone, strings.Join(e.Gone, ", ")) b.consider(ctx, e.Module, m) } @@ -292,7 +311,7 @@ func (b *Builder) ArtifactsGone(ctx context.Context, e delivery.ArtifactsGone) { func (b *Builder) Reconsider(ctx context.Context) { b.init() b.mu.Lock() - known := make(map[string]delivery.Module, len(b.known)) + known := make(map[string]meshcatalog.ModulesByModuleValue, len(b.known)) for k, m := range b.known { known[k] = m } @@ -308,7 +327,7 @@ func (b *Builder) Reconsider(ctx context.Context) { func (b *Builder) TrunkMoved(ctx context.Context) { b.init() b.mu.Lock() - cands := make([]delivery.Candidate, 0, len(b.cands)) + cands := make([]meshcatalog.CandidatesByGroupValue, 0, len(b.cands)) for _, c := range b.cands { cands = append(cands, c) } @@ -319,7 +338,7 @@ func (b *Builder) TrunkMoved(ctx context.Context) { } } -func sortedKeys(m map[string]delivery.Module) []string { +func sortedKeys(m map[string]meshcatalog.ModulesByModuleValue) []string { keys := make([]string, 0, len(m)) for k := range m { keys = append(keys, k) @@ -348,11 +367,11 @@ func (b *Builder) planOf(ctx context.Context, digest string) (Plan, error, bool) return plan, err, true } -func (b *Builder) consider(ctx context.Context, module string, m delivery.Module) { - if m.FP2 == "" || m.Manifest == "" { +func (b *Builder) consider(ctx context.Context, module string, m meshcatalog.ModulesByModuleValue) { + if m.Fp2 == "" || m.Manifest == "" { return } - key, err := delivery.BuilderBuildKey(module, m.FP2) + key, err := builds.BuilderBuildKey(module, m.Fp2) if err != nil { b.logf("modules.%s: %v", module, err) return @@ -388,7 +407,7 @@ func (b *Builder) consider(ctx context.Context, module string, m delivery.Module // startOnce looks at the module for a key the caller has set inflight already (BuildAgain): the by-hand look runs once, and // a look the builder queued for the key meanwhile (again) still runs after it, as consider's loop does. -func (b *Builder) startOnce(ctx context.Context, key, module string, m delivery.Module) { +func (b *Builder) startOnce(ctx context.Context, key, module string, m meshcatalog.ModulesByModuleValue) { b.wg.Add(1) go func() { defer b.wg.Done() @@ -412,8 +431,8 @@ func (b *Builder) startOnce(ctx context.Context, key, module string, m delivery. // try claims the fingerprint and builds it, writing the key done or failed. A build that ends because ctx did is left // building by this builder: it takes it again when it starts (ADR 0478 clause 5). -func (b *Builder) try(ctx context.Context, module string, m delivery.Module) { - key, _ := delivery.BuilderBuildKey(module, m.FP2) +func (b *Builder) try(ctx context.Context, module string, m meshcatalog.ModulesByModuleValue) { + key, _ := builds.BuilderBuildKey(module, m.Fp2) defer b.handEnded(key) plan, planErr, read := b.planOf(ctx, m.Manifest) nothing := read && planErr == nil && len(plan.Tasks) == 0 @@ -421,7 +440,7 @@ func (b *Builder) try(ctx context.Context, module string, m delivery.Module) { if why := b.stale(m, plan); why != "" { // The fingerprint names a toolchain the build nodes no longer agree on: a new fingerprint is coming, and a build // now would be of this source with another toolchain (ADR 0584 clause 1, which changes ADR 0477 clause 2). Nothing is written. - b.logf("%s at %s is not built: %s", module, m.FP2, why) + b.logf("%s at %s is not built: %s", module, m.Fp2, why) return } } @@ -430,7 +449,18 @@ func (b *Builder) try(ctx context.Context, module string, m delivery.Module) { first := !b.tried[key] b.tried[key] = true b.mu.Unlock() - cl, err := b.Claimer.Claim(ctx, module, m.FP2, m.Manifest, ClaimOptions{TakeOver: first, ImageBuilder: imageSeat, Trunk: trunk, Published: b.isPublished, + var numbering versions.Numbering + tag := "" + if !trunk && b.Tags != nil && b.refOf(module, m.Fp2) != "" { + tag, _ = b.Tags(ctx, repotrunk.RepositoryName(m.Repository), m.Commit) + } + if (trunk || tag != "") && planErr == nil && !nothing { + var goOn bool + if numbering, goOn = b.number(ctx, module, m, plan, tag); !goOn { + return + } + } + cl, err := b.Claimer.Claim(ctx, module, m.Fp2, m.Manifest, ClaimOptions{TakeOver: first, ImageBuilder: imageSeat, Trunk: trunk, Published: b.isPublished, Stored: b.storedOnce(key)}) if err != nil { b.logf("%s: %v", module, err) @@ -440,7 +470,7 @@ func (b *Builder) try(ctx context.Context, module string, m delivery.Module) { return } b.handClaimed(key, cl.Rev) - b.logBuild(LogStarted(module, m, b.refOf(module, m.FP2), b.Node, b.now())) + b.logBuild(LogStarted(module, m, b.refOf(module, m.Fp2), b.Node, b.now())) if cl.Unasked != nil { b.logf("%v", cl.Unasked) } @@ -450,10 +480,10 @@ func (b *Builder) try(ctx context.Context, module string, m delivery.Module) { } // A done key taken again builds only what is missing: the image or the package built unpublished as a candidate's; the // other artifacts stay as the done key has them (ADR 0487 clauses 5 and 8, ADR 0498 clause 6). - var prior *delivery.BuilderBuild + var prior *builds.BuilderBuild var only map[string]bool publishing := false - if cl.Build.State == delivery.BuildDone { + if cl.Build.State == builds.BuildDone { prior, only = &cl.Build, map[string]bool{} if trunk { for _, bm := range publishable(cl.Build, imageSeat) { @@ -462,11 +492,11 @@ func (b *Builder) try(ctx context.Context, module string, m delivery.Module) { } } } - var result delivery.BuilderBuild + var result builds.BuilderBuild if nothing { // A manifest with no artifact is built by building nothing: its key is done with no artifact, claimed and written as // any other, so the controller has a done key to register it from (issue 1613, ADR 0478 clause 1, ADR 0501 clause 1). - result = delivery.BuilderBuild{State: delivery.BuildDone, Step: "nothing to build: the manifest names no artifact"} + result = builds.BuilderBuild{State: builds.BuildDone, Step: "nothing to build: the manifest names no artifact"} } else { result = b.build(ctx, module, m, plan, planErr, prior, only, b.notes(cl)) } @@ -478,10 +508,10 @@ func (b *Builder) try(ctx context.Context, module string, m delivery.Module) { return } result.By, result.Manifest = b.Claimer.By, m.Manifest - if len(cl.Gone) > 0 && result.State == delivery.BuildDone { + if len(cl.Gone) > 0 && result.State == builds.BuildDone { result.Step += " (built again: the artifact store no longer held " + strings.Join(cl.Gone, ", ") + ")" } - if publishing && result.State == delivery.BuildFailed { + if publishing && result.State == builds.BuildFailed { // The trunk's image or package was built only as a candidate's and its push or publish failed: the key says so, so the builds view, the // checker and the failed-key retake see it, and a look does not build the image again for nothing. result.Error = keyError([]string{"publishing what the merged commit built failed: " + result.Error}) @@ -490,11 +520,18 @@ func (b *Builder) try(ctx context.Context, module string, m delivery.Module) { b.logf("%s: the build could not be written: %v", cl.Key, err) return } - b.logBuild(LogEnded(module, m, b.refOf(module, m.FP2), b.Node, b.now(), result)) - if result.State == delivery.BuildFailed { + ended := LogEnded(module, m, b.refOf(module, m.Fp2), b.Node, b.now(), result) + ended.OutOfStep = b.takeOutOfStep(key) // the generated files the build module's compare found out of step (design 26) + b.logBuild(ended) + if result.State == builds.BuildDone && b.Numbers != nil && len(numbering.Versions) > 0 { + if err := b.Numbers.Built(ctx, module, m, plan, numbering, result.Artifacts); err != nil { + b.logf("%s: %v", cl.Key, err) + } + } + if result.State == builds.BuildFailed { b.logf("%s: failed: %s", cl.Key, result.Error) } - if result.State == delivery.BuildQueued { + if result.State == builds.BuildQueued { b.logf("%s: waits for the package %s to be published", cl.Key, result.Waits) } if b.notePublished(result) { @@ -505,13 +542,13 @@ func (b *Builder) try(ctx context.Context, module string, m delivery.Module) { // stale is why the module's fingerprint is not built now, or empty when it is: the toolchain each language of the plan // has agreed (novox/hq ADR 0465) must be the one the fingerprint was computed with (Module.Toolchains). -func (b *Builder) stale(m delivery.Module, plan Plan) string { +func (b *Builder) stale(m meshcatalog.ModulesByModuleValue, plan Plan) string { for _, bm := range plan.Modules() { if !plan.NeedsToolchain(bm) { continue // an archive is packed with no toolchain } language := buildrun.LanguageOf[bm] - ag := delivery.ToolchainAgreement(b.Toolchains.Entries(language)) + ag := sdkstep.ToolchainAgreement(b.Toolchains.Entries(language)) if ag.Wait != "" { return fmt.Sprintf("the %s toolchain is not agreed: %s", language, ag.Wait) } @@ -524,22 +561,23 @@ func (b *Builder) stale(m delivery.Module, plan Plan) string { return "" } -func failed(format string, args ...any) delivery.BuilderBuild { - return delivery.BuilderBuild{State: delivery.BuildFailed, Error: fmt.Sprintf(format, args...)} +func failed(format string, args ...any) builds.BuilderBuild { + return builds.BuilderBuild{State: builds.BuildFailed, Error: fmt.Sprintf(format, args...)} } // lane is the build of a plan's tasks of one build module on one node. type lane struct { module, node string // node is the one that pulled the lane's ask tasks []Task - toolchain *delivery.ToolchainUsed + toolchain *builds.ToolchainUsed digests map[string]string // artifact -> digest references map[string]string // artifact -> the reference an image was pushed to (a trunk build only) imports []string // the mesh packages the lane's programs import, as its build module answered them + outOfStep []string // the generated files its build module's compare found out of step, passed or failed err error } -func (b *Builder) build(ctx context.Context, module string, m delivery.Module, plan Plan, planErr error, prior *delivery.BuilderBuild, only map[string]bool, asked *askNotes) delivery.BuilderBuild { +func (b *Builder) build(ctx context.Context, module string, m meshcatalog.ModulesByModuleValue, plan Plan, planErr error, prior *builds.BuilderBuild, only map[string]bool, asked *askNotes) builds.BuilderBuild { switch { case planErr != nil: return failed("%s", planErr) @@ -547,9 +585,9 @@ func (b *Builder) build(ctx context.Context, module string, m delivery.Module, p // Empty is not known (a root module's path is "."): the indexer writes it again (issue 1615). return failed("the module's key names no directory (path), so the build tools cannot be told where %s lives", module) } - repository := delivery.RepositoryName(m.Repository) + repository := repotrunk.RepositoryName(m.Repository) var lanes []*lane - used := map[string]delivery.ToolchainUsed{} + used := map[string]builds.ToolchainUsed{} var sitting []string // the nodes that sit out of a language's build, said in the key's step (ADR 0584 clause 2) var kept []string // build modules whose artifacts stay as the prior done key has them for _, bm := range plan.Modules() { @@ -557,20 +595,20 @@ func (b *Builder) build(ctx context.Context, module string, m delivery.Module, p kept = append(kept, bm) continue } - var toolchain *delivery.ToolchainUsed + var toolchain *builds.ToolchainUsed if bm != ImageBuildModule && plan.NeedsToolchain(bm) { // The ask names the toolchain the fingerprint was computed with; a node building with another refuses it. language := buildrun.LanguageOf[bm] - ag := delivery.ToolchainAgreement(b.Toolchains.Entries(language)) + ag := sdkstep.ToolchainAgreement(b.Toolchains.Entries(language)) if ag.Wait != "" { return failed("the %s toolchain is not agreed: %s", language, ag.Wait) } tc := ag.Toolchain sitting = append(sitting, SittingOutWords(language, ag)...) if want := m.Toolchains[language]; want.Version != tc.Version || want.Recipe != tc.Recipe { - return delivery.BuilderBuild{} // the agreed toolchain moved since try looked: nothing is written + return builds.BuilderBuild{} // the agreed toolchain moved since try looked: nothing is written } - u := delivery.ToolchainUsed{Version: tc.Version, Recipe: tc.Recipe} + u := builds.ToolchainUsed{Version: tc.Version, Recipe: tc.Recipe} used[language], toolchain = u, &u } var tasks []Task @@ -582,7 +620,7 @@ func (b *Builder) build(ctx context.Context, module string, m delivery.Module, p // One ask builds each fingerprint's part of a build module, on whichever node pulls it (ADR 0525 §3). lanes = append(lanes, &lane{module: bm, tasks: tasks, toolchain: toolchain, digests: map[string]string{}, references: map[string]string{}}) } - if key, err := delivery.BuilderBuildKey(module, m.FP2); err == nil { + if key, err := builds.BuilderBuildKey(module, m.Fp2); err == nil { b.handLanes(key, len(lanes)) } var wg sync.WaitGroup @@ -591,6 +629,9 @@ func (b *Builder) build(ctx context.Context, module string, m delivery.Module, p go func() { defer wg.Done(); b.runLane(ctx, l, repository, m, module, asked) }() } wg.Wait() + if key, err := builds.BuilderBuildKey(module, m.Fp2); err == nil { + b.keepOutOfStep(key, lanes) + } var errs []string for _, l := range lanes { if l.err != nil { @@ -601,11 +642,15 @@ func (b *Builder) build(ctx context.Context, module string, m delivery.Module, p if ref := waitsOf(errs); ref != "" { // The module's lock names a version of a package the registry does not hold yet: the key waits for it, queued, and // is not failed (ADR 0500 clause 6). - return delivery.BuilderBuild{State: delivery.BuildQueued, Waits: ref, Step: "waits for the package " + ref + " to be published"} + return builds.BuilderBuild{State: builds.BuildQueued, Waits: ref, Step: "waits for the package " + ref + " to be published"} } return failed("%s", keyError(errs)) } - var artifacts []delivery.BuiltArtifact + var artifacts []builds.BuiltArtifact + var present, counted map[string][]string + if b.Forms != nil { + present, counted = b.Forms() + } keptModule := map[string]bool{} for _, bm := range kept { keptModule[bm] = true @@ -633,7 +678,8 @@ func (b *Builder) build(ctx context.Context, module string, m delivery.Module, p if err != nil { return failed("%s", err) } - built := delivery.BuiltArtifact{Name: t.Artifact, Kind: t.Kind, Digest: d} + built := builds.BuiltArtifact{Name: t.Artifact, Kind: t.Kind, Digest: d} + var answered []string for _, l := range lanes { if l.module != t.Module { continue @@ -642,13 +688,14 @@ func (b *Builder) build(ctx context.Context, module string, m delivery.Module, p if r == "" && stored(t.Kind) { // A bundle, an archive or an upstream copy is done only when its push was answered with where it is (ADR 0498 // clause 1); a candidate's included (clause 3). - return failed("%s%s on %s: the build answered no reference, so its push to the artifact store was not answered", delivery.NotStored, t.Artifact, l.node) + return failed("%s%s on %s: the build answered no reference, so its push to the artifact store was not answered", builds.NotStored, t.Artifact, l.node) } if r != "" { - built.Reference = r + answered = append(answered, r) } } - artifacts = append(artifacts, built) + // The forms a counted node's engine reads are kept, the oldest in the single reference (ADR 0624 §1, §3). + artifacts = append(artifacts, forms.Current.Choose(built, answered, present, counted)) } var steps []string for _, l := range lanes { @@ -669,11 +716,11 @@ func (b *Builder) build(ctx context.Context, module string, m delivery.Module, p if len(used) == 0 { used = nil } - done := delivery.BuilderBuild{State: delivery.BuildDone, By: b.Claimer.By, Manifest: m.Manifest, Step: step, Artifacts: artifacts, Toolchains: used, - Imports: importsOf(lanes)} - if _, err := delivery.EncodeBuilderBuild(done); err != nil { + done := builds.BuilderBuild{State: builds.BuildDone, By: b.Claimer.By, Manifest: m.Manifest, Step: step, Artifacts: artifacts, Toolchains: used, + Imports: importsOf(lanes), Needs: forms.Current.Needs(artifacts)} // the forms it is written in (ADR 0618 §1) + if _, err := builds.EncodeBuilderBuild(done); err != nil { // A reference the build module answered that does not name the artifact and its digest is no reference. - return failed("%s%v", delivery.NotStored, err) + return failed("%s%v", builds.NotStored, err) } return done } @@ -686,7 +733,7 @@ func stored(kind string) bool { // waitsFor opens the reason of a fetch step whose module's lock names a package version the registry does not hold, then // `@` (the Node build module's, ADR 0500 clause 6). -const waitsFor = delivery.WaitsFor +const waitsFor = builds.WaitsFor // waitsOf is the package version a lane's error says the module waits for, or "" when none does or what it names is not // `@`. @@ -698,7 +745,7 @@ func waitsOf(errs []string) string { } ref, _, _ := strings.Cut(e[at+len(waitsFor):], " ") ref = strings.TrimRight(ref, ":;,") - if _, err := delivery.EncodeBuilderBuild(delivery.BuilderBuild{State: delivery.BuildQueued, Waits: ref}); err == nil { + if _, err := builds.EncodeBuilderBuild(builds.BuilderBuild{State: builds.BuildQueued, Waits: ref}); err == nil { return ref } } @@ -706,8 +753,8 @@ func waitsOf(errs []string) string { } // keyPrefixes are the errors a build module's step may open its reason with that the key's error must open with too, so -// the claim reads them (delivery.FailedUntilChanged), first the ones not built again. -var keyPrefixes = []string{delivery.NotPinned, delivery.VersionTaken, delivery.RefusedKind, delivery.NotStored} +// the claim reads them (builds.FailedUntilChanged), first the ones not built again. +var keyPrefixes = []string{builds.NotPinned, builds.VersionTaken, builds.RefusedKind, builds.NotStored} // keyError is the error of a key from its lanes' errors: when one names a prefix of keyPrefixes inside the step's words // (` on : the step failed: not stored: ...`), the key's error opens with that prefix, so a reader of the @@ -731,8 +778,8 @@ func keyError(errs []string) string { // want of an image builder before R4g is taken again (ADR 0525 §1). func imageSeat() bool { return true } -// askLanguage is the build seat language of each build module (delivery.AskSubject). -var askLanguage = map[string]string{buildrun.GoBuildModule: delivery.AskGo, buildrun.NodeBuildModule: delivery.AskNode, ImageBuildModule: delivery.AskImage} +// askLanguage is the build seat language of each build module (sdkstep.AskSubject). +var askLanguage = map[string]string{buildrun.GoBuildModule: sdkstep.AskGo, buildrun.NodeBuildModule: sdkstep.AskNode, ImageBuildModule: sdkstep.AskImage} // askNotes writes a claimed key's asks into it, building, before each is put (ADR 0525 §1; issue 1887 A4b), and holds // the asks a key taken over at start already names, so they are waited for and never put again. @@ -747,7 +794,7 @@ type askNotes struct { func (b *Builder) notes(cl *Claim) *askNotes { n := &askNotes{b: b, cl: cl} - if cl.Build.State == delivery.BuildBuilding && len(cl.Build.Asks) > 0 { + if cl.Build.State == builds.BuildBuilding && len(cl.Build.Asks) > 0 { n.resumed, n.step = maps.Clone(cl.Build.Asks), cl.Build.Step } return n @@ -765,7 +812,7 @@ func (n *askNotes) note(ctx context.Context, language, id, step string) error { n.mu.Lock() defer n.mu.Unlock() v := n.cl.Build - v.State, v.By, v.Step, v.Error, v.Artifacts = delivery.BuildBuilding, n.b.Claimer.By, step, "", nil + v.State, v.By, v.Step, v.Error, v.Artifacts, v.Needs = builds.BuildBuilding, n.b.Claimer.By, step, "", nil, nil v.Asks = maps.Clone(v.Asks) if v.Asks == nil { v.Asks = map[string]string{} @@ -782,9 +829,9 @@ func (n *askNotes) note(ctx context.Context, language, id, step string) error { // check stream for a candidate's) and reads what the node that pulled it built (ADR 0525 §1, §3, §6). An upstream artifact // is copied by its pinned reference with no fetch (ADR 0498 clause 4); a lane of copies alone fetches nothing. A lane of a // key taken over at start waits for the ask the key names and puts none. -func (b *Builder) runLane(ctx context.Context, l *lane, repository string, m delivery.Module, module string, asked *askNotes) { - req := buildstep.FetchRequest{Repository: repository, Commit: m.Commit} - if key, err := delivery.BuilderBuildKey(module, m.FP2); err == nil { +func (b *Builder) runLane(ctx context.Context, l *lane, repository string, m meshcatalog.ModulesByModuleValue, module string, asked *askNotes) { + req := sdkstep.Fetch{Repository: repository, Commit: m.Commit} + if key, err := builds.BuilderBuildKey(module, m.Fp2); err == nil { b.mu.Lock() v, candidate := b.via[key] b.mu.Unlock() @@ -803,26 +850,26 @@ func (b *Builder) runLane(ctx context.Context, l *lane, repository string, m del } candidate := req.Base != "" language := askLanguage[l.module] - kind := delivery.AskDelivery + kind := sdkstep.AskDelivery if candidate { - kind = delivery.AskCheck + kind = sdkstep.AskCheck } - job := &delivery.BuildJob{Module: module, Path: m.Path, FP: m.FP2, Toolchain: l.toolchain, + job := &sdkstep.BuildJob{Module: module, Path: m.Path, Fp: m.Fp2, Toolchain: buildToolchain(language, l.toolchain), // Only a trunk key's build pushes an image or publishes a package; a candidate's never does (ADR 0487 clause 12, // ADR 0498 clauses 5 and 6). A bundle or an archive is pushed either way (clause 3). Publish: !candidate} for _, t := range l.tasks { - a := delivery.AskArtifact{Artifact: t.Artifact} + a := sdkstep.AskArtifact{Artifact: t.Artifact} if t.Kind == catalogue.ArtifactUpstream { a.From = t.From // the copy runs no code of a change: the module's name, the artifact and its pin } job.Artifacts = append(job.Artifacts, a) } if needsFetch(l.tasks) { - job.Fetch = req.Args() + job.Fetch = &req } - ask := delivery.StepAsk{ID: buildstep.NewID(), Kind: kind, Language: language, Asker: delivery.BuilderModule, At: time.Now(), Build: job} - if language == delivery.AskGo && job.Fetch != nil && len(m.Packages) > 0 { + ask := sdkstep.StepAsk{ID: sdkstep.NewID(), Kind: kind, Language: language, Asker: builds.BuilderModule, At: time.Now(), Build: job} + if language == sdkstep.AskGo && job.Fetch != nil && len(m.Packages) > 0 { // The versions of the mesh packages the fingerprint was computed with (novox/hq ADR 0573 §2): the build module writes // them into the workspace, whatever the repository's go.mod names. ask.Packages = maps.Clone(m.Packages) @@ -855,23 +902,29 @@ func (b *Builder) runLane(ctx context.Context, l *lane, repository string, m del return } } - step, ask, err := b.answer(ctx, l, ask, asked, module, m.FP2) + step, ask, err := b.answer(ctx, l, ask, asked, module, m.Fp2) if err != nil { l.err = fmt.Errorf("%s: %w", l.module, err) return } l.node = step.Node - if step.State != delivery.StepPassed { + // The build module's compare of a contracts module's generated files (design 26, CAT-BG5/BN2) answers the files out + // of step as sdkstep.BuildAnswer.OutOfStep, in a failed step's result too: read before the step's state is. + var compared sdkstep.BuildAnswer + if len(step.Result) > 0 && json.Unmarshal(step.Result, &compared) == nil { + l.outOfStep = compared.OutOfStep + } + if step.State != sdkstep.StepPassed { l.err = fmt.Errorf("%s on %s: the ask %s %s: %s", l.module, step.Node, ask.ID, step.State, step.Reason) return } - var answer delivery.BuildAnswer + var answer sdkstep.BuildAnswer if err := json.Unmarshal(step.Result, &answer); err != nil { l.err = fmt.Errorf("the ask of %s on %s answered what is not a build: %s", l.module, step.Node, step.Result) return } l.imports = answer.Imports - built := map[string]delivery.AskBuilt{} + built := map[string]sdkstep.AskBuilt{} for _, a := range answer.Artifacts { built[a.Artifact] = a } @@ -909,11 +962,11 @@ func (b *Builder) now() time.Time { } // logBuild tells the log an entry, said in the journal when it cannot be one. -func (b *Builder) logBuild(e delivery.BuildLogEntry) { +func (b *Builder) logBuild(e builds.BuildLogEntry) { if b.Log == nil { return } - if _, err := delivery.EncodeBuildLogEntry(e); err != nil { + if _, err := builds.EncodeBuildLogEntry(e); err != nil { b.logf("%s at %s: no log entry: %v", e.Module, e.FP, err) return } @@ -922,7 +975,7 @@ func (b *Builder) logBuild(e delivery.BuildLogEntry) { // refOf is the ref a candidate's build of module's fingerprint is built for, "" for the trunk's. func (b *Builder) refOf(module, fp string) string { - key, err := delivery.BuilderBuildKey(module, fp) + key, err := builds.BuilderBuildKey(module, fp) if err != nil { return "" } @@ -934,31 +987,62 @@ func (b *Builder) refOf(module, fp string) string { // LogStarted is the log's entry of a build of module's fingerprint starting (ADR 0571 §1): the module, the fingerprint, the // source revision, where it is built from (repository, directory, commit, the candidate's ref), the node of the builder // that asked it and the time. -func LogStarted(module string, m delivery.Module, ref, by string, at time.Time) delivery.BuildLogEntry { +func LogStarted(module string, m meshcatalog.ModulesByModuleValue, ref, by string, at time.Time) builds.BuildLogEntry { dir := m.Path if dir == "" { - dir = delivery.RootPath + dir = meshcatalogclient.RootPath } - return delivery.BuildLogEntry{Event: delivery.BuildStartedEvent, Module: module, FP: m.FP2, Source: strconv.FormatUint(m.Src, 10), + return builds.BuildLogEntry{Event: builds.BuildStartedEvent, Module: module, FP: m.Fp2, Source: strconv.FormatUint(uint64(m.Src), 10), Repository: m.Repository, Directory: dir, Commit: m.Commit, Ref: ref, Node: by, At: at.UTC()} } // LogEnded is the log's entry of that build ending: the outcome (done, failed or queued), the manifest built, the bases it // was built against and each artifact made, as the done key has them: what the operator's command nox builds shows of a build. -func LogEnded(module string, m delivery.Module, ref, by string, at time.Time, result delivery.BuilderBuild) delivery.BuildLogEntry { +func LogEnded(module string, m meshcatalog.ModulesByModuleValue, ref, by string, at time.Time, result builds.BuilderBuild) builds.BuildLogEntry { e := LogStarted(module, m, ref, by, at) - e.Event, e.Outcome, e.Manifest = delivery.BuildEndedEvent, result.State, m.Manifest + e.Event, e.Outcome, e.Manifest = builds.BuildEndedEvent, result.State, m.Manifest if result.Manifest != "" { e.Manifest = result.Manifest } if m.Inputs2 != nil { e.Against = append([]string(nil), m.Inputs2.Bases...) } - e.Made = append([]delivery.BuiltArtifact(nil), result.Artifacts...) + e.Made = append([]builds.BuiltArtifact(nil), result.Artifacts...) e.Imports = append([]string(nil), result.Imports...) // the indexer keeps them on the module (novox/hq ADR 0593 §2) + e.Error, e.Step = result.Error, result.Step // a failed build's reason, not only its outcome return e } +// keepOutOfStep holds, by build key, the out-of-step files the lanes' build modules answered, sorted and each once, until +// the build's end entry takes them (takeOutOfStep); a build with none holds nothing. +func (b *Builder) keepOutOfStep(key string, lanes []*lane) { + seen := map[string]bool{} + for _, l := range lanes { + for _, f := range l.outOfStep { + seen[f] = true + } + } + b.mu.Lock() + defer b.mu.Unlock() + if len(seen) == 0 { + delete(b.outOfStep, key) + return + } + if b.outOfStep == nil { + b.outOfStep = map[string][]string{} + } + b.outOfStep[key] = slices.Sorted(maps.Keys(seen)) +} + +// takeOutOfStep answers the out-of-step files held for a build key and forgets them. +func (b *Builder) takeOutOfStep(key string) []string { + b.mu.Lock() + defer b.mu.Unlock() + files := b.outOfStep[key] + delete(b.outOfStep, key) + return files +} + // importsOf are the mesh packages the lanes' programs import, as their build modules answered them (novox/hq ADR 0593 // §2): sorted and each once; nil when none answered any. A lane kept from a prior build answered none, so a build that // kept one reports only what it built. @@ -977,7 +1061,7 @@ func importsOf(lanes []*lane) []string { // SittingOutWords says, per node that is not on the agreed toolchain of a language, that it sits out and which version // it has against the agreed one: "ace sits out: typescript 26.12.1, agreed 26.13.0" (novox/hq ADR 0584 clause 2). -func SittingOutWords(language string, a delivery.Agreement) []string { +func SittingOutWords(language string, a sdkstep.Agreement) []string { var out []string for _, s := range a.SittingOut { out = append(out, fmt.Sprintf("%s sits out: %s %s, agreed %s", s.Node, language, s.Version, a.Toolchain.Version)) diff --git a/modules/mesh-delivery-builder/internal/build/run_test.go b/modules/mesh-delivery-builder/internal/build/run_test.go index 3a042d25..0da72ac9 100644 --- a/modules/mesh-delivery-builder/internal/build/run_test.go +++ b/modules/mesh-delivery-builder/internal/build/run_test.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "reflect" + "slices" "strconv" "strings" "sync" @@ -13,10 +14,17 @@ import ( "testing" "time" + "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" + "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun" + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds" + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds/forms" + "git.novox.be/novox/mesh-delivery/sdk/buildstep" stdio "git.novox.be/novox/mesh-sdk/go" "git.novox.be/novox/mesh-sdk/go/catalogue" - "git.novox.be/novox/mesh-sdk/go/delivery" + "git.novox.be/novox/mesh-sdk/go/keys" + + "mesh-delivery-builder/internal/versions" ) // buildModules simulates the two build modules on the nodes: a long tool answers a step and key at once and writes the @@ -41,7 +49,7 @@ type buildModules struct { } func newBuildModules() *buildModules { - return &buildModules{steps: &fakeSteps{steps: map[string]delivery.Step{}}, digests: map[string]string{}, fails: map[string]string{}, noRef: map[string]bool{}, packages: map[string]string{}} + return &buildModules{steps: &fakeSteps{steps: map[string]buildstep.Step{}}, digests: map[string]string{}, fails: map[string]string{}, noRef: map[string]bool{}, packages: map[string]string{}} } func (b *buildModules) CallOn(node, key string, body any) (json.RawMessage, error) { @@ -59,9 +67,9 @@ func (b *buildModules) CallOn(node, key string, body any) (json.RawMessage, erro id := fmt.Sprintf("s%d", b.n) module, tool, _ := strings.Cut(key, ".") args, _ := body.(map[string]any) - step := delivery.Step{ID: id, Tool: tool, Node: node, State: delivery.StepPassed} + step := buildstep.Step{ID: id, Tool: tool, Node: node, State: buildstep.StepPassed} if reason, ok := b.fails[node+"/"+tool]; ok { - step.State, step.Reason = delivery.StepFailed, reason + step.State, step.Reason = buildstep.StepFailed, reason } else if strings.HasSuffix(tool, "_fetch") { step.Result = json.RawMessage(`{"workspace":"w-` + node + `"}`) if args["base"] != nil { // a fetch with a base answers the merge, or the files in conflict (mesh-build-go FetchResult) @@ -92,7 +100,7 @@ func (b *buildModules) CallOn(node, key string, body any) (json.RawMessage, erro step.Result = json.RawMessage(`{"digest":"` + d + `"}`) } } - sk, _ := delivery.StepKey(node, id) + sk, _ := buildstep.StepKey(node, id) b.steps.mu.Lock() b.steps.steps[module+"/"+sk] = step b.steps.mu.Unlock() @@ -141,12 +149,12 @@ var toolPrefix = map[string]string{buildrun.GoBuildModule: "build_go_", buildrun // answer runs an ask as the build module that pulls it does (runAsk), with the fake build modules' tools on the // rig's pulling node, and answers the step that node writes. -func (r *rig) answer(a delivery.StepAsk) delivery.Step { +func (r *rig) answer(a buildstep.StepAsk) buildstep.Step { node := r.puller if node == "" { node = "ace" } - module, _ := delivery.BuildModuleOf(a.Language) + module, _ := buildstep.BuildModuleOf(a.Language) runner := buildrun.Runner{Caller: r.bm, Steps: r.bm.steps, Bound: time.Second} tool := func(name string) stepFunc { return func(ctx context.Context, args map[string]any) (any, error) { @@ -159,9 +167,9 @@ func (r *rig) answer(a delivery.StepAsk) delivery.Step { } steps := askSteps{Fetch: tool("fetch"), Build: tool("build"), Copy: tool("copy")} out, err := runAsk(context.Background(), steps, a) - step := delivery.Step{ID: a.ID, Tool: a.Kind + " ask", Node: node, State: delivery.StepPassed} + step := buildstep.Step{ID: a.ID, Tool: a.Kind + " ask", Node: node, State: buildstep.StepPassed} if err != nil { - step.State, step.Reason = delivery.StepFailed, err.Error() + step.State, step.Reason = buildstep.StepFailed, err.Error() return step } step.Result, _ = json.Marshal(out) @@ -182,27 +190,27 @@ func newRig(t *testing.T, artifacts ...catalogue.Artifact) *rig { Trunk: func(context.Context, uint64, string) (string, error) { return trunkHead, nil }, } var err error - if r.key, err = delivery.BuilderBuildKey("m", fp1); err != nil { + if r.key, err = builds.BuilderBuildKey("m", fp1); err != nil { t.Fatal(err) } return r } // agreedToolchains are what the rig's nodes report, and so what a fingerprint computed now names. -var agreedToolchains = map[string]delivery.ToolchainUsed{"go": {Version: "go1.27.1", Recipe: "1"}, "typescript": {Version: "tsc5", Recipe: "1"}} +var agreedToolchains = map[string]builds.ToolchainUsed{"go": {Version: "go1.27.1", Recipe: "1"}, "typescript": {Version: "tsc5", Recipe: "1"}} func moduleValue(t *testing.T, path string) []byte { return moduleValueWith(t, path, agreedToolchains) } -func moduleValueWith(t *testing.T, path string, toolchains map[string]delivery.ToolchainUsed) []byte { +func moduleValueWith(t *testing.T, path string, toolchains map[string]builds.ToolchainUsed) []byte { t.Helper() - data, err := delivery.EncodeModule(delivery.Module{Repository: "novox/mesh-catalog", Commit: "7a74cee83f88e6585a5511c3cc53bdf0f5303d20", FP: "src1:" + hex64('f'), FP2: fp1, Manifest: mdigest('a'), Path: path, Toolchains: toolchains}) + data, err := meshcatalogclient.EncodeModule(meshcatalog.ModulesByModuleValue{Repository: "novox/mesh-catalog", Commit: "7a74cee83f88e6585a5511c3cc53bdf0f5303d20", Fp: "src1:" + hex64('f'), Fp2: fp1, Manifest: mdigest('a'), Path: path, Toolchains: CatalogToolchains(toolchains)}) if err != nil { t.Fatal(err) } return data } -func (r *rig) consider(t *testing.T, path string) delivery.BuilderBuild { +func (r *rig) consider(t *testing.T, path string) builds.BuilderBuild { t.Helper() r.b.Module(context.Background(), "m", moduleValue(t, path)) r.b.Wait() @@ -222,6 +230,8 @@ var ( func TestABundleAndAnArchiveAreBuiltOnOneNodeAndDone(t *testing.T) { // 1607/AC7, ADR 0478 clauses 1, 4 t.Run("the imports a build answers reach the done key and the log", importsABuildAnswersReachTheDoneKeyAndTheLog) + t.Run("the out-of-step files a build answers reach the log", outOfStepFilesABuildAnswersReachTheLog) + t.Run("ADR 0624: a done key keeps each form a counted node reads", aDoneKeyKeepsEachFormACountedNodeReads) r := newRig(t, goBundle, archiveA, tsBundle) // ADR 0584: a third node agrees with novox and ace's typescript is the odd one; the build is done and its step says ace sits out. r.tools.Put(buildrun.NodeBuildModule, "g14.typescript", tcValue(t, "typescript", "tsc5"), false) @@ -231,43 +241,43 @@ func TestABundleAndAnArchiveAreBuiltOnOneNodeAndDone(t *testing.T) { // 1607/AC7 t.Errorf("the key does not say ace sits out: %s", st) } }() - var logged []delivery.BuildLogEntry + var logged []builds.BuildLogEntry var lmu sync.Mutex - r.b.Node, r.b.Log = "novox", func(e delivery.BuildLogEntry) { lmu.Lock(); logged = append(logged, e); lmu.Unlock() } + r.b.Node, r.b.Log = "novox", func(e builds.BuildLogEntry) { lmu.Lock(); logged = append(logged, e); lmu.Unlock() } v := r.consider(t, "modules/m") // 1955/L7 (ADR 0571 §1): the build is one start and one end in the builder's log, every field named, its duration read // from the two. - if len(logged) != 2 || logged[0].Event != delivery.BuildStartedEvent || logged[1].Event != delivery.BuildEndedEvent || - logged[1].Outcome != delivery.BuildDone || len(logged[1].Made) != 3 || logged[1].Made[0].Reference == "" || + if len(logged) != 2 || logged[0].Event != builds.BuildStartedEvent || logged[1].Event != builds.BuildEndedEvent || + logged[1].Outcome != builds.BuildDone || len(logged[1].Made) != 3 || logged[1].Made[0].Reference == "" || logged[1].Manifest != manifestD || logged[0].Manifest != "" || logged[0].Repository != "novox/mesh-catalog" || logged[0].Directory != "modules/m" || logged[0].Commit != "7a74cee83f88e6585a5511c3cc53bdf0f5303d20" || logged[0].Node != "novox" || logged[0].FP != fp1 || logged[0].Module != "m" { t.Fatalf("logged = %+v", logged) } for _, e := range logged { - if _, err := delivery.EncodeBuildLogEntry(e); err != nil { + if _, err := builds.EncodeBuildLogEntry(e); err != nil { t.Errorf("an entry the log refuses: %v", err) } } - if _, ok := delivery.BuildDuration(logged[0], logged[1]); !ok { + if _, ok := builds.BuildDuration(logged[0], logged[1]); !ok { t.Error("no duration from the two entries") } // builder_builds_since (ADR 0571 clause 9) answers the end alone from the log's page, and says when the log holds more. var page stdio.LogPage for i, e := range logged { - raw, _ := delivery.EncodeBuildLogEntry(e) + raw, _ := builds.EncodeBuildLogEntry(e) page.Entries = append(page.Entries, stdio.LogEntry{Sequence: uint64(i + 1), Key: e.Module, Value: raw, At: e.At}) } page.Next = 3 since, err := BuildsSince(func(time.Time, string, int) (*stdio.LogPage, error) { return &page, nil }, logged[0].At) - if err != nil || len(since.Builds) != 1 || since.Builds[0].Event != delivery.BuildEndedEvent || len(since.Builds[0].Made) != 3 || !since.More { + if err != nil || len(since.Builds) != 1 || since.Builds[0].Event != builds.BuildEndedEvent || len(since.Builds[0].Made) != 3 || !since.More { t.Errorf("builds since = %+v, %v, want the one end and more", since, err) } page.Entries[0].Value = []byte(`{"v":0}`) if _, err := BuildsSince(func(time.Time, string, int) (*stdio.LogPage, error) { return &page, nil }, logged[0].At); err == nil { t.Error("an entry that does not read was skipped, want it refused") } - if v.State != delivery.BuildDone || v.By != "novox.builder" || v.Manifest != manifestD || len(v.Artifacts) != 3 { + if v.State != builds.BuildDone || v.By != "novox.builder" || v.Manifest != manifestD || len(v.Artifacts) != 3 { t.Fatalf("build = %+v, want done by novox.builder with three artifacts", v) } for i, want := range []string{"files", "tool", "web"} { @@ -278,6 +288,11 @@ func TestABundleAndAnArchiveAreBuiltOnOneNodeAndDone(t *testing.T) { // 1607/AC7 if v.Artifacts[0].Kind != "archive" || v.Artifacts[1].Kind != "bundle" { t.Errorf("kinds = %+v", v.Artifacts) } + // ADR 0618 §1: the done key names the forms its references are written in, here the first form alone, and keeps no list + // beside the single reference while no counted node needs another form. + if !slices.Equal(v.Needs, []string{"form:artifact-store"}) || v.Artifacts[0].References != nil { + t.Errorf("needs = %v, references = %v", v.Needs, v.Artifacts[0].References) + } if got := v.Toolchains["go"]; got.Version != "go1.27.1" || got.Recipe != "1" { t.Errorf("toolchains = %+v, want go and typescript named", v.Toolchains) } @@ -310,14 +325,14 @@ func TestATrunkFingerprintIsOneAskOnTheDeliveryStream(t *testing.T) { r := newRig(t, goBundle) r.asks.Answer = nil // no node has pulled it yet // Issue 1585 (ADR 0573 §2): the ask carries the package versions the fingerprint was computed with. - sdk := delivery.PackageVersions{"git.novox.be/novox/mesh-sdk/go": hex40('5')} - data, err := delivery.EncodeModule(delivery.Module{Repository: "novox/mesh-catalog", Commit: "7a74cee83f88e6585a5511c3cc53bdf0f5303d20", - FP2: fp1, Manifest: mdigest('a'), Path: "modules/m", Toolchains: agreedToolchains, Packages: sdk}) + sdk := buildstep.PackageVersions{"git.novox.be/novox/mesh-sdk/go": hex40('5')} + data, err := meshcatalogclient.EncodeModule(meshcatalog.ModulesByModuleValue{Repository: "novox/mesh-catalog", Commit: "7a74cee83f88e6585a5511c3cc53bdf0f5303d20", + Fp2: fp1, Manifest: mdigest('a'), Path: "modules/m", Toolchains: CatalogToolchains(agreedToolchains), Packages: sdk}) if err != nil { t.Fatal(err) } r.b.Module(context.Background(), "m", data) - var asks []delivery.StepAsk + var asks []buildstep.StepAsk for deadline := time.Now().Add(10 * time.Second); len(asks) == 0; time.Sleep(time.Millisecond) { if time.Now().After(deadline) { t.Fatal("no ask was put") @@ -325,13 +340,13 @@ func TestATrunkFingerprintIsOneAskOnTheDeliveryStream(t *testing.T) { asks = r.asks.Asks() } a := asks[0] - if len(asks) != 1 || a.Kind != delivery.AskDelivery || a.Language != delivery.AskGo || a.Build == nil || a.Build.FP != fp1 { + if len(asks) != 1 || a.Kind != buildstep.AskDelivery || a.Language != buildstep.AskGo || a.Build == nil || a.Build.Fp != fp1 { t.Fatalf("asks = %+v, want one Go delivery ask of the fingerprint", asks) } if !reflect.DeepEqual(a.Packages, sdk) { t.Errorf("the ask's packages = %v, want %v", a.Packages, sdk) } - if v := valueOf(t, r.st, r.key); v.State != delivery.BuildBuilding || v.Asks[delivery.AskGo] != a.ID { + if v := valueOf(t, r.st, r.key); v.State != builds.BuildBuilding || v.Asks[buildstep.AskGo] != a.ID { t.Fatalf("the key = %+v, want building naming the ask %s", v, a.ID) } if n := len(r.bm.callsOf("build_go_build")) + len(r.bm.callsOf("build_go_fetch")); n != 0 { @@ -339,7 +354,7 @@ func TestATrunkFingerprintIsOneAskOnTheDeliveryStream(t *testing.T) { } r.asks.End(r.answer(a)) // a node pulled it and ran it r.b.Wait() - if v := valueOf(t, r.st, r.key); v.State != delivery.BuildDone || !strings.Contains(v.Step, "on ace") || v.Artifacts[0].Reference == "" || len(v.Asks) != 0 { + if v := valueOf(t, r.st, r.key); v.State != builds.BuildDone || !strings.Contains(v.Step, "on ace") || v.Artifacts[0].Reference == "" || len(v.Asks) != 0 { t.Fatalf("the key = %+v, want done on ace naming the reference", v) } } @@ -349,17 +364,17 @@ func TestATrunkFingerprintIsOneAskOnTheDeliveryStream(t *testing.T) { func TestARestartedBuilderWaitsForTheAskTheKeyNames(t *testing.T) { r := newRig(t, goBundle) const id = "mvl0frv18d1957" - data, _ := delivery.EncodeBuilderBuild(delivery.BuilderBuild{State: delivery.BuildBuilding, By: "novox.builder", Manifest: manifestD, - Step: "asked mesh-build-go", Asks: map[string]string{delivery.AskGo: id}}) + data, _ := builds.EncodeBuilderBuild(builds.BuilderBuild{State: builds.BuildBuilding, By: "novox.builder", Manifest: manifestD, + Step: "asked mesh-build-go", Asks: map[string]string{buildstep.AskGo: id}}) r.st.vals[r.key] = data r.asks.Answer = nil - r.asks.End(delivery.Step{ID: id, Node: "shanks", State: delivery.StepPassed, + r.asks.End(buildstep.Step{ID: id, Node: "shanks", State: buildstep.StepPassed, Result: []byte(`{"artifacts":[{"artifact":"tool","digest":"` + digest + `","reference":"artifact-store://m/tool/blobs/` + digest + `"}]}`)}) v := r.consider(t, "modules/m") if n := len(r.asks.Asks()); n != 0 { t.Fatalf("%d asks put again", n) } - if v.State != delivery.BuildDone || !strings.Contains(v.Step, "on shanks") { + if v.State != builds.BuildDone || !strings.Contains(v.Step, "on shanks") { t.Fatalf("the key = %+v, want done from the ask's step on shanks", v) } } @@ -368,13 +383,13 @@ func TestAFailingStepFailsTheBuildWithItsReasonAndNodeAndIsTriedAgainAsANewRevis r := newRig(t, goBundle) r.bm.fails["ace/build_go_build"] = "go build of tool: undefined: x" v := r.consider(t, "modules/m") - if v.State != delivery.BuildFailed || !strings.Contains(v.Error, "ace") || !strings.Contains(v.Error, "undefined: x") { + if v.State != builds.BuildFailed || !strings.Contains(v.Error, "ace") || !strings.Contains(v.Error, "undefined: x") { t.Fatalf("build = %+v, want failed with the node and the reason", v) } rev := r.st.revs[r.key] delete(r.bm.fails, "ace/build_go_build") v = r.consider(t, "modules/m") - if v.State != delivery.BuildDone || r.st.revs[r.key] <= rev { + if v.State != builds.BuildDone || r.st.revs[r.key] <= rev { t.Errorf("build = %+v at revision %d (was %d), want done as a new revision of the same key", v, r.st.revs[r.key], rev) } } @@ -383,7 +398,7 @@ func TestAnImageGoesToOneNodeAndGetsOneDoneKeyWithItsReference(t *testing.T) { / r := newRig(t, imageArt) r.puller = "ace" // two nodes qualify; an image still builds on one v := r.consider(t, "modules/m") - if v.State != delivery.BuildDone || len(v.Artifacts) != 1 || v.Artifacts[0].Kind != "image" || v.Artifacts[0].Digest != digest { + if v.State != builds.BuildDone || len(v.Artifacts) != 1 || v.Artifacts[0].Kind != "image" || v.Artifacts[0].Digest != digest { t.Fatalf("build = %+v, want done with one image artifact and its digest", v) } if v.Artifacts[0].Reference == "" { @@ -403,7 +418,7 @@ func TestABundleAndAnImageOfOneModuleBuildOnOneNodeEach(t *testing.T) { // 1705a r := newRig(t, goBundle, imageArt) r.puller = "ace" v := r.consider(t, "modules/m") - if v.State != delivery.BuildDone || len(v.Artifacts) != 2 { + if v.State != builds.BuildDone || len(v.Artifacts) != 2 { t.Fatalf("build = %+v", v) } if n := len(r.bm.callsOf("build_go_build")); n != 1 { @@ -418,10 +433,21 @@ func TestACandidatesImageCallCarriesNoPublishAndItsKeyNoReference(t *testing.T) r := newRig(t, imageArt) r.puller = "ace" trunkModule(t, r) + // DLV-B6: a check's build of a head a kept tag ask names is numbered under that tag at its claim. + nums := &fakeNumbers{} + var tagAsked []string + r.b.Numbers = nums + r.b.Tags = func(_ context.Context, repository, head string) (string, bool) { + tagAsked = append(tagAsked, repository+"@"+head) + return "feat-x", head == headX + } r.b.Candidate(context.Background(), "feat%2Fx", candidateValue(t, "feat/x", 7, headX)) r.b.Wait() + if !slices.Equal(nums.tags, []string{"feat-x"}) || len(tagAsked) != 1 || !strings.HasSuffix(tagAsked[0], "@"+headX) { + t.Errorf("the check's build numbered with tags %v (asked %v), want feat-x for its head", nums.tags, tagAsked) + } v := valueOf(t, r.st, r.key) - if v.State != delivery.BuildDone || len(v.Artifacts) != 1 || v.Artifacts[0].Reference != "" { + if v.State != builds.BuildDone || len(v.Artifacts) != 1 || v.Artifacts[0].Reference != "" { t.Fatalf("build = %+v, want done with no reference", v) } calls := r.bm.callsOf("build_image_build") @@ -433,20 +459,75 @@ func TestACandidatesImageCallCarriesNoPublishAndItsKeyNoReference(t *testing.T) } } -func TestATrunkImageCallCarriesPublish(t *testing.T) { // 1705b +func TestATrunkImageCallCarriesPublish(t *testing.T) { // 1705b; design 51 *The versions*: numbered at the claim r := newRig(t, imageArt) r.puller = "ace" + nums := &fakeNumbers{wait: "m/img: the package waits for its floor"} + r.b.Numbers = nums + r.b.Module(context.Background(), "m", moduleValue(t, "modules/m")) + r.b.Wait() + if _, ok := r.st.vals[r.key]; ok || nums.numbered != 1 { + t.Fatalf("a build waiting for its floor was claimed (numbered %d times)", nums.numbered) + } + nums.wait, nums.versions = "", map[string]string{"img": "0.1.4"} r.consider(t, "modules/m") calls := r.bm.callsOf("build_image_build") if len(calls) != 1 || calls[0].body.(map[string]any)["publish"] != true { t.Errorf("calls = %+v, want publish true", calls) } + if nums.built["img"] != digest { + t.Errorf("the built digest recorded = %v, want %s for img", nums.built, digest) + } + if nums.done != nil { + t.Errorf("a build still to make was numbered with done digests %v", nums.done) + } + // The indexer's packages state names a package of the module (PackageNamed): the done build is looked at again and + // numbered with its key's digests, so version.numbered carries them for the late publish; nothing is built again. + looks := nums.numbered + r.b.PackageNamed(context.Background(), "m") + r.b.Wait() + if nums.numbered != looks+1 || nums.done["img"] != digest || len(r.bm.callsOf("build_image_build")) != 1 { + t.Errorf("a named package's done build: numbered %d more times with %v, %d image builds; want once with img %s and no build", + nums.numbered-looks, nums.done, len(r.bm.callsOf("build_image_build")), digest) + } + r.b.PackageNamed(context.Background(), "other") + r.b.Wait() + if nums.numbered != looks+1 { + t.Errorf("a module the builder does not know was looked at") + } +} + +// fakeNumbers is Numbers that answers what it is set to and keeps the digests a done build recorded. +type fakeNumbers struct { + wait string + versions map[string]string + numbered int + built map[string]string + tags []string + done map[string]string // the done key's digests the last look numbered with +} + +func (f *fakeNumbers) Number(_ context.Context, _ string, _ meshcatalog.ModulesByModuleValue, _ Plan, tag string, done map[string]string) (versions.Numbering, error) { + f.tags = append(f.tags, tag) + f.done = done + f.numbered++ + return versions.Numbering{Wait: f.wait, Versions: f.versions}, nil +} + +func (f *fakeNumbers) Built(_ context.Context, _ string, _ meshcatalog.ModulesByModuleValue, _ Plan, got versions.Numbering, made []builds.BuiltArtifact) error { + f.built = map[string]string{} + for _, a := range made { + if got.Versions[a.Name] != "" { + f.built[a.Name] = a.Digest + } + } + return nil } func TestAnUnpinnedUpstreamFailsNotPinnedAndIsNeverBuiltAgain(t *testing.T) { // B3, ADR 0498 clause 4 r := newRig(t, archiveA, catalogue.Artifact{Name: "up", Kind: "upstream", From: "nats:2.11.17-alpine"}) v := r.consider(t, "modules/m") - if v.State != delivery.BuildFailed || !strings.HasPrefix(v.Error, delivery.NotPinned) || !strings.Contains(v.Error, "up") { + if v.State != builds.BuildFailed || !strings.HasPrefix(v.Error, builds.NotPinned) || !strings.Contains(v.Error, "up") { t.Fatalf("build = %+v, want failed 'not pinned:' naming the artifact", v) } if len(r.bm.calls) != 0 { @@ -462,7 +543,7 @@ func TestAnUnpinnedUpstreamFailsNotPinnedAndIsNeverBuiltAgain(t *testing.T) { // func TestAModuleKeyWithNoPathFailsNamingThatReason(t *testing.T) { // 1607/AC8, ADR 0478 clause 8 r := newRig(t, goBundle) v := r.consider(t, "") - if v.State != delivery.BuildFailed || !strings.Contains(v.Error, "path") || len(r.bm.calls) != 0 { + if v.State != builds.BuildFailed || !strings.Contains(v.Error, "path") || len(r.bm.calls) != 0 { t.Errorf("build = %+v with %d calls, want failed naming the missing path and nothing called", v, len(r.bm.calls)) } } @@ -471,14 +552,14 @@ func TestAnUnreadableManifestFailsTheBuild(t *testing.T) { r := newRig(t, goBundle) delete(r.mf.byDigest, mdigest('a')) v := r.consider(t, "modules/m") - if v.State != delivery.BuildFailed || !strings.Contains(v.Error, "manifest") { + if v.State != builds.BuildFailed || !strings.Contains(v.Error, "manifest") { t.Errorf("build = %+v, want failed naming the manifest", v) } } func TestAModuleWithNoFingerprintIsNotBuilt(t *testing.T) { // 1607/AC2 r := newRig(t) - data, _ := delivery.EncodeModule(delivery.Module{Repository: "novox/r", Commit: "c", FP: "src1:" + hex64('f'), None2: "toolchain not yet reported"}) + data, _ := meshcatalogclient.EncodeModule(meshcatalog.ModulesByModuleValue{Repository: "novox/r", Commit: "c", Fp: "src1:" + hex64('f'), None2: "toolchain not yet reported"}) r.b.Module(context.Background(), "n", data) r.b.Wait() if len(r.st.vals) != 0 { @@ -491,7 +572,7 @@ func TestAModuleWithNoArtifactGetsADoneKeyWithNoArtifactAndNoBuildCall(t *testin r := newRig(t) r.mf.byDigest[mdigest('a')] = manifest v := r.consider(t, "") // a module with nothing to build needs no directory - if v.State != delivery.BuildDone || len(v.Artifacts) != 0 || v.Manifest != manifestD || v.By != "novox.builder" { + if v.State != builds.BuildDone || len(v.Artifacts) != 0 || v.Manifest != manifestD || v.By != "novox.builder" { t.Errorf("%s: build = %+v, want done with no artifact", name, v) } if len(r.bm.calls) != 0 { @@ -508,8 +589,8 @@ func TestAModuleWithNoArtifactGetsADoneKeyWithNoArtifactAndNoBuildCall(t *testin func TestAKeyTheBuilderLeftBuildingIsBuiltAtStartAndDoneKeysAreNotBuiltTwice(t *testing.T) { // 1607/AC3, AC7 r := newRig(t, goBundle) - seed(t, r.st, delivery.BuilderBuild{State: delivery.BuildBuilding, By: "novox.builder", Manifest: manifestD}) - if v := r.consider(t, "modules/m"); v.State != delivery.BuildDone { + seed(t, r.st, builds.BuilderBuild{State: builds.BuildBuilding, By: "novox.builder", Manifest: manifestD}) + if v := r.consider(t, "modules/m"); v.State != builds.BuildDone { t.Fatalf("build = %+v, want the builder's own building key built", v) } calls := len(r.bm.calls) @@ -534,7 +615,7 @@ func TestTwoInstancesStartingTogetherEndWithOneDoneKey(t *testing.T) { // 1607/A go func() { defer wg.Done(); b.Module(context.Background(), "m", value); b.Wait() }() } wg.Wait() - if v := valueOf(t, r.st, r.key); v.State != delivery.BuildDone || len(v.Artifacts) != 1 { + if v := valueOf(t, r.st, r.key); v.State != builds.BuildDone || len(v.Artifacts) != 1 { t.Errorf("build = %+v, want one done key", v) } if n := len(r.bm.callsOf("build_go_build")); n < 1 { @@ -554,14 +635,14 @@ func TestAModulesValueThatArrivesWhileItsKeyIsBeingBuiltIsLookedAtAgainWhenItEnd delete(r.bm.fails, "ace/build_go_build") // what the event is about: the build can succeed now close(r.bm.hold) r.b.Wait() - if v := valueOf(t, r.st, r.key); v.State != delivery.BuildDone { + if v := valueOf(t, r.st, r.key); v.State != builds.BuildDone { t.Errorf("build = %+v, want the failed key built again as a new revision after the first build ended", v) } } func TestAKeyIsBuiltOnlyWithTheToolchainItsFingerprintNamesAndNothingIsWrittenOtherwise(t *testing.T) { // 1615/AC4 r := newRig(t, goBundle) - old := map[string]delivery.ToolchainUsed{"go": {Version: "go1.26.0", Recipe: "1"}} + old := map[string]builds.ToolchainUsed{"go": {Version: "go1.26.0", Recipe: "1"}} r.b.Module(context.Background(), "m", moduleValueWith(t, "modules/m", old)) r.b.Wait() if len(r.st.vals) != 0 || len(r.bm.calls) != 0 { @@ -569,7 +650,7 @@ func TestAKeyIsBuiltOnlyWithTheToolchainItsFingerprintNamesAndNothingIsWrittenOt t.Errorf("keys %v and %d calls for a fingerprint of go1.26.0 while the nodes agree on go1.27.1, want neither", r.st.vals, len(r.bm.calls)) } // A recipe that differs is a different toolchain too, and a value that names none (written before) is not built. - r.b.Module(context.Background(), "m", moduleValueWith(t, "modules/m", map[string]delivery.ToolchainUsed{"go": {Version: "go1.27.1", Recipe: "0"}})) + r.b.Module(context.Background(), "m", moduleValueWith(t, "modules/m", map[string]builds.ToolchainUsed{"go": {Version: "go1.27.1", Recipe: "0"}})) r.b.Module(context.Background(), "m", moduleValueWith(t, "modules/m", nil)) r.b.Wait() if len(r.st.vals) != 0 || len(r.bm.calls) != 0 { @@ -583,9 +664,9 @@ func TestAKeyIsBuiltOnlyWithTheToolchainItsFingerprintNamesAndNothingIsWrittenOt if len(r.st.vals) != 0 { t.Errorf("keys %v: the old fingerprint was built with the new toolchain", r.st.vals) } - r.b.Module(context.Background(), "m", moduleValueWith(t, "modules/m", map[string]delivery.ToolchainUsed{"go": {Version: "go1.27.2", Recipe: "1"}})) + r.b.Module(context.Background(), "m", moduleValueWith(t, "modules/m", map[string]builds.ToolchainUsed{"go": {Version: "go1.27.2", Recipe: "1"}})) r.b.Wait() - if v := valueOf(t, r.st, r.key); v.State != delivery.BuildDone || v.Toolchains["go"].Version != "go1.27.2" { + if v := valueOf(t, r.st, r.key); v.State != builds.BuildDone || v.Toolchains["go"].Version != "go1.27.2" { t.Errorf("build = %+v, want done with go1.27.2", v) } } @@ -599,7 +680,7 @@ func aRecipeThatMovesBuildsTheModuleAgainUnderItsNewFingerprint(t *testing.T) { calls := len(r.bm.calls) fp2 := "src2:" + hex64('2') for _, n := range []string{"ace", "novox"} { - b, err := delivery.EncodeToolchain(delivery.Toolchain{Language: "go", Version: "go1.27.1", Recipe: "2", At: time.Now()}) + b, err := buildstep.EncodeToolchain(buildstep.Toolchain{Language: "go", Version: "go1.27.1", Recipe: "2", At: time.Now().UTC()}) if err != nil { t.Fatal(err) } @@ -610,21 +691,21 @@ func aRecipeThatMovesBuildsTheModuleAgainUnderItsNewFingerprint(t *testing.T) { if len(r.bm.calls) != calls { t.Errorf("%d calls, was %d: the first fingerprint was built again for the new recipe", len(r.bm.calls), calls) } - data, err := delivery.EncodeModule(delivery.Module{Repository: "novox/mesh-catalog", Commit: "7a74cee83f88e6585a5511c3cc53bdf0f5303d20", FP: "src1:" + hex64('f'), FP2: fp2, - Manifest: mdigest('a'), Path: "modules/m", Toolchains: map[string]delivery.ToolchainUsed{"go": {Version: "go1.27.1", Recipe: "2"}}}) + data, err := meshcatalogclient.EncodeModule(meshcatalog.ModulesByModuleValue{Repository: "novox/mesh-catalog", Commit: "7a74cee83f88e6585a5511c3cc53bdf0f5303d20", Fp: "src1:" + hex64('f'), Fp2: fp2, + Manifest: mdigest('a'), Path: "modules/m", Toolchains: CatalogToolchains(map[string]builds.ToolchainUsed{"go": {Version: "go1.27.1", Recipe: "2"}})}) if err != nil { t.Fatal(err) } r.b.Module(context.Background(), "m", data) r.b.Wait() - second, err := delivery.BuilderBuildKey("m", fp2) + second, err := builds.BuilderBuildKey("m", fp2) if err != nil { t.Fatal(err) } - if v := valueOf(t, r.st, second); v.State != delivery.BuildDone || v.Toolchains["go"].Recipe != "2" { + if v := valueOf(t, r.st, second); v.State != builds.BuildDone || v.Toolchains["go"].Recipe != "2" { t.Errorf("second build = %+v, want done with recipe 2", v) } - if v := valueOf(t, r.st, r.key); v.State != delivery.BuildDone { + if v := valueOf(t, r.st, r.key); v.State != builds.BuildDone { t.Errorf("first build = %+v, want it left done", v) } } @@ -641,7 +722,7 @@ func TestNodesTheyDisagreeOnLeaveTheKeyUnwritten(t *testing.T) { // 1615/AC4 func TestARootModulesPathIsDotAndIsGivenToTheBuildTools(t *testing.T) { // 1615/AC4 r := newRig(t, goBundle) - if v := r.consider(t, delivery.RootPath); v.State != delivery.BuildDone { + if v := r.consider(t, meshcatalogclient.RootPath); v.State != builds.BuildDone { t.Fatalf("build = %+v, want done for a root module", v) } if args := r.bm.callsOf("build_go_build")[0].body.(map[string]any); args["module"] != "." { @@ -656,7 +737,7 @@ func TestACandidatesImageIsPublishedOnceWhenTheTrunkNamesItsFingerprint(t *testi r.b.Candidate(context.Background(), "feat%2Fx", candidateValue(t, "feat/x", 7, headX)) r.b.Wait() v := valueOf(t, r.st, r.key) - if v.State != delivery.BuildDone || v.Artifacts[0].Reference != "" { + if v.State != builds.BuildDone || v.Artifacts[0].Reference != "" { t.Fatalf("candidate build = %+v, want done with no reference", v) } if n := len(r.bm.callsOf("build_image_build")); n != 1 { @@ -666,7 +747,7 @@ func TestACandidatesImageIsPublishedOnceWhenTheTrunkNamesItsFingerprint(t *testi r.b.Module(context.Background(), "m", moduleValue(t, "modules/m")) r.b.Wait() v = valueOf(t, r.st, r.key) - if v.State != delivery.BuildDone || len(v.Artifacts) != 1 || v.Artifacts[0].Reference == "" { + if v.State != builds.BuildDone || len(v.Artifacts) != 1 || v.Artifacts[0].Reference == "" { t.Fatalf("trunk build = %+v, want done with a reference", v) } calls := r.bm.callsOf("build_image_build") @@ -694,21 +775,21 @@ func TestAFailedPublishOfACandidatesImageIsWrittenToTheKey(t *testing.T) { // re trunkModule(t, r) r.b.Candidate(context.Background(), "feat%2Fx", candidateValue(t, "feat/x", 7, headX)) r.b.Wait() - if v := valueOf(t, r.st, r.key); v.State != delivery.BuildDone { + if v := valueOf(t, r.st, r.key); v.State != builds.BuildDone { t.Fatalf("candidate build = %+v", v) } r.bm.fails["ace/build_image_build"] = "push to the registry: connection refused" r.b.Module(context.Background(), "m", moduleValue(t, "modules/m")) // the merge: the trunk names the fingerprint r.b.Wait() v := valueOf(t, r.st, r.key) - if v.State != delivery.BuildFailed || !strings.Contains(v.Error, "connection refused") || !strings.Contains(v.Error, "publish") { + if v.State != builds.BuildFailed || !strings.Contains(v.Error, "connection refused") || !strings.Contains(v.Error, "publish") { t.Fatalf("build = %+v, want failed naming the publish and its reason", v) } // The failed key is retaken as any failed key is, and ends published once the registry answers. delete(r.bm.fails, "ace/build_image_build") r.b.Module(context.Background(), "m", moduleValue(t, "modules/m")) r.b.Wait() - if v := valueOf(t, r.st, r.key); v.State != delivery.BuildDone || v.Artifacts[0].Reference == "" { + if v := valueOf(t, r.st, r.key); v.State != builds.BuildDone || v.Artifacts[0].Reference == "" { t.Errorf("build = %+v, want done with a reference", v) } } @@ -718,18 +799,18 @@ func TestAFailedPublishOfACandidatesImageIsWrittenToTheKey(t *testing.T) { // re type goneAsks struct { mu sync.Mutex n int - why func(a delivery.StepAsk) string + why func(a buildstep.StepAsk) string dropped []string } -func (g *goneAsks) Drop(_ context.Context, a delivery.StepAsk) error { +func (g *goneAsks) Drop(_ context.Context, a buildstep.StepAsk) error { g.mu.Lock() defer g.mu.Unlock() g.dropped = append(g.dropped, a.ID) return nil } -func (g *goneAsks) Gone(_ context.Context, a delivery.StepAsk) (string, error) { +func (g *goneAsks) Gone(_ context.Context, a buildstep.StepAsk) (string, error) { g.mu.Lock() g.n++ g.mu.Unlock() @@ -743,7 +824,7 @@ func (g *goneAsks) asked() int { } // waitForAsks waits until n asks were put. -func waitForAsks(t *testing.T, r *rig, n int) []delivery.StepAsk { +func waitForAsks(t *testing.T, r *rig, n int) []buildstep.StepAsk { t.Helper() for deadline := time.Now().Add(10 * time.Second); len(r.asks.Asks()) < n; time.Sleep(time.Millisecond) { if time.Now().After(deadline) { @@ -761,7 +842,7 @@ func TestABuildAskWaitingInItsStreamIsNotBounded(t *testing.T) { r := newRig(t, goBundle) r.asks.Answer = nil r.b.Asks = buildrun.Asker{Publisher: r.asks, Waiter: r.asks, Bound: 20 * time.Millisecond} - gone := &goneAsks{why: func(delivery.StepAsk) string { return "" }} + gone := &goneAsks{why: func(buildstep.StepAsk) string { return "" }} r.b.Gone, r.b.CheckAfter = gone, 5*time.Millisecond r.b.Module(context.Background(), "m", moduleValue(t, "modules/m")) waitForAsks(t, r, 1) @@ -772,7 +853,7 @@ func TestABuildAskWaitingInItsStreamIsNotBounded(t *testing.T) { t.Fatalf("whether the ask can still be answered was read %d times within 10 s, want 5", gone.asked()) } } - if v := valueOf(t, r.st, r.key); v.State != delivery.BuildBuilding { + if v := valueOf(t, r.st, r.key); v.State != builds.BuildBuilding { t.Fatalf("the key = %+v while its ask waits, want building", v) } if gone.asked() == 0 { @@ -780,7 +861,7 @@ func TestABuildAskWaitingInItsStreamIsNotBounded(t *testing.T) { } r.asks.End(r.answer(r.asks.Asks()[0])) r.b.Wait() - if v := valueOf(t, r.st, r.key); v.State != delivery.BuildDone || len(r.asks.Asks()) != 1 { + if v := valueOf(t, r.st, r.key); v.State != builds.BuildDone || len(r.asks.Asks()) != 1 { t.Fatalf("the key = %+v after %d asks, want done from the one ask", v, len(r.asks.Asks())) } }) @@ -794,17 +875,17 @@ func TestABuildAskWaitingInItsStreamIsNotBounded(t *testing.T) { r.b.Now = func() time.Time { return start.Add(time.Duration(clock.Add(1)) * time.Second) } r.b.DeclinePause, r.b.DeclineBound = -1, time.Minute r.b.Module(context.Background(), "m", moduleValue(t, "modules/m")) - declined, _ := json.Marshal(delivery.StepDeclined{Declined: true, Lacks: []string{"the agreed toolchain go1.27.1 (recipe 1)"}}) + declined, _ := json.Marshal(buildstep.StepDeclined{Declined: true, Lacks: []string{"the agreed toolchain go1.27.1 (recipe 1)"}}) waitForAsks(t, r, 1) - for ended, deadline := 0, time.Now().Add(20*time.Second); valueOf(t, r.st, r.key).State == delivery.BuildBuilding && time.Now().Before(deadline); time.Sleep(5 * time.Millisecond) { + for ended, deadline := 0, time.Now().Add(20*time.Second); valueOf(t, r.st, r.key).State == builds.BuildBuilding && time.Now().Before(deadline); time.Sleep(5 * time.Millisecond) { if asks := r.asks.Asks(); len(asks) > ended { // each ask put again is declined in its turn - r.asks.End(delivery.Step{ID: asks[ended].ID, Node: "ace", State: delivery.StepRefused, Reason: "this node builds with go1.26.0", Result: declined}) + r.asks.End(buildstep.Step{ID: asks[ended].ID, Node: "ace", State: buildstep.StepRefused, Reason: "this node builds with go1.26.0", Result: declined}) ended++ } } r.b.Wait() v := valueOf(t, r.st, r.key) - if n := len(r.asks.Asks()); v.State != delivery.BuildFailed || n <= DefaultAskings || !strings.Contains(v.Error, "declined for") || !strings.Contains(v.Error, "ace: this node builds with go1.26.0") { + if n := len(r.asks.Asks()); v.State != builds.BuildFailed || n <= DefaultAskings || !strings.Contains(v.Error, "declined for") || !strings.Contains(v.Error, "ace: this node builds with go1.26.0") { t.Fatalf("the key = %+v after %d asks, want it failed after more than %d asks, naming ace and its reason", v, n, DefaultAskings) } }) @@ -824,44 +905,44 @@ func TestABuildAskWaitingInItsStreamIsNotBounded(t *testing.T) { // it stands (key, absent when nil), the module known at known's fingerprint ("" for the rig's): refuse is a part of why it // is refused, "" when it is built again as a new revision and its ask id answered. again bool - key *delivery.BuilderBuild + key *builds.BuilderBuild known string refuse string // edit changes the module the builder has read, busy marks the key looked at (1) or asked by hand (2), failed is a part of // the error the key then holds when its build ends before its asks are put. - edit func(m *delivery.Module) + edit func(m *meshcatalog.ModulesByModuleValue) busy int failed string // during is a look of the builder's own at the module while the by-hand build runs: it still runs afterwards. during bool }{ {name: "build again: a look the builder queued during the by-hand build still runs afterwards", again: true, during: true, - key: &delivery.BuilderBuild{State: delivery.BuildFailed, Error: "ace: no tun"}}, + key: &builds.BuilderBuild{State: builds.BuildFailed, Error: "ace: no tun"}}, {name: "build again: a key looked at by the builder now is refused, not built twice", again: true, refuse: "is being looked at", busy: 1, - key: &delivery.BuilderBuild{State: delivery.BuildFailed, Error: "ace: no tun"}}, + key: &builds.BuilderBuild{State: builds.BuildFailed, Error: "ace: no tun"}}, {name: "build again: a key already asked by hand is refused, its watch kept", again: true, refuse: "is being looked at", busy: 2, - key: &delivery.BuilderBuild{State: delivery.BuildFailed, Error: "ace: no tun"}}, - {name: "build again: a module with no manifest is refused", again: true, refuse: "names no manifest", edit: func(m *delivery.Module) { m.Manifest = "" }, - key: &delivery.BuilderBuild{State: delivery.BuildFailed, Error: "ace: no tun"}}, + key: &builds.BuilderBuild{State: builds.BuildFailed, Error: "ace: no tun"}}, + {name: "build again: a module with no manifest is refused", again: true, refuse: "names no manifest", edit: func(m *meshcatalog.ModulesByModuleValue) { m.Manifest = "" }, + key: &builds.BuilderBuild{State: builds.BuildFailed, Error: "ace: no tun"}}, {name: "build again: a toolchain that moved writes nothing and is said at once", again: true, refuse: "wrote nothing", - edit: func(m *delivery.Module) { - m.Toolchains = map[string]delivery.ToolchainUsed{"go": {Version: "go1.0", Recipe: "1"}} + edit: func(m *meshcatalog.ModulesByModuleValue) { + m.Toolchains = CatalogToolchains(map[string]builds.ToolchainUsed{"go": {Version: "go1.0", Recipe: "1"}}) }, - key: &delivery.BuilderBuild{State: delivery.BuildFailed, Error: "ace: no tun"}}, + key: &builds.BuilderBuild{State: builds.BuildFailed, Error: "ace: no tun"}}, {name: "build again: a build that fails before its asks says the key failed, not that it was asked", again: true, failed: "cannot be read", - edit: func(m *delivery.Module) { m.Manifest = mdigest('b') }, - key: &delivery.BuilderBuild{State: delivery.BuildFailed, Error: "ace: no tun"}}, + edit: func(m *meshcatalog.ModulesByModuleValue) { m.Manifest = mdigest('b') }, + key: &builds.BuilderBuild{State: builds.BuildFailed, Error: "ace: no tun"}}, {name: "build again: a failed key is written building as a new revision and asks the seat", again: true, - key: &delivery.BuilderBuild{State: delivery.BuildFailed, Error: "ace: the step failed: no tun"}}, + key: &builds.BuilderBuild{State: builds.BuildFailed, Error: "ace: the step failed: no tun"}}, {name: "build again: a done key is refused, nothing to do", again: true, refuse: "is done: nothing to do", - key: &delivery.BuilderBuild{State: delivery.BuildDone, Step: "built"}}, + key: &builds.BuilderBuild{State: builds.BuildDone, Step: "built"}}, {name: "build again: a building key is refused, builder_ask_again is for it", again: true, refuse: "is waiting", - key: &delivery.BuilderBuild{State: delivery.BuildBuilding, By: "novox.builder", Step: "asked mesh-build-go", Asks: map[string]string{delivery.AskGo: "mvl0frv18d1960"}}}, + key: &builds.BuilderBuild{State: builds.BuildBuilding, By: "novox.builder", Step: "asked mesh-build-go", Asks: map[string]string{buildstep.AskGo: "mvl0frv18d1960"}}}, {name: "build again: a key failed for a pin missing is refused, it waits for another fingerprint", again: true, refuse: "does not retry", - key: &delivery.BuilderBuild{State: delivery.BuildFailed, Error: delivery.NotPinned + "web"}}, + key: &builds.BuilderBuild{State: builds.BuildFailed, Error: builds.NotPinned + "web"}}, {name: "build again: a key with no build is refused", again: true, refuse: "has no build key"}, {name: "build again: a fingerprint its module no longer has is refused", again: true, refuse: "does not have the fingerprint", known: "src2:" + strings.Repeat("9", 64), - key: &delivery.BuilderBuild{State: delivery.BuildFailed, Error: "ace: no tun"}}, + key: &builds.BuilderBuild{State: builds.BuildFailed, Error: "ace: no tun"}}, {name: "given up by its stream: asked again under a new id and done from it", puts: 2, done: true, asking: 2}, {name: "asked again by hand: the same, with no check bound", byHand: true, puts: 2, done: true, asking: 2}, {name: "given up into DEAD_LETTERS: its wait ends failed with the words and it is asked again at once", dead: true, puts: 2, done: true, asking: 2}, @@ -877,12 +958,12 @@ func TestABuildAskWaitingInItsStreamIsNotBounded(t *testing.T) { c.key.Manifest = manifestD rev = putBuild(t, r.st, r.key, *c.key) } - m, err := delivery.DecodeModule(moduleValue(t, "modules/m")) + m, err := meshcatalogclient.DecodeModule(moduleValue(t, "modules/m")) if err != nil { t.Fatal(err) } if c.known != "" { - m.FP2 = c.known + m.Fp2 = c.known } if c.edit != nil { c.edit(&m) @@ -908,7 +989,7 @@ func TestABuildAskWaitingInItsStreamIsNotBounded(t *testing.T) { r.b.Module(context.Background(), "m", moduleValue(t, "modules/m")) r.asks.End(r.answer(waitForAsks(t, r, 1)[0])) r.b.Wait() - if v := valueOf(t, r.st, r.key); v.State != delivery.BuildDone || res.Revision <= rev || stored.Load() != 1 { + if v := valueOf(t, r.st, r.key); v.State != builds.BuildDone || res.Revision <= rev || stored.Load() != 1 { t.Fatalf("key %+v, answer %+v, %d asks of the store; want built from the new revision and the queued look run once after it", v, res, stored.Load()) } return @@ -928,10 +1009,10 @@ func TestABuildAskWaitingInItsStreamIsNotBounded(t *testing.T) { return } asks := r.asks.Asks() - if res.Refused != "" || res.Pending != "" || res.Revision <= rev || len(asks) != 1 || res.Asks[delivery.AskGo] != asks[0].ID { + if res.Refused != "" || res.Pending != "" || res.Revision <= rev || len(asks) != 1 || res.Asks[buildstep.AskGo] != asks[0].ID { t.Fatalf("answer %+v after %d asks; want the new revision (was %d) and the ask id of the one ask", res, len(asks), rev) } - if v := valueOf(t, r.st, r.key); v.State != delivery.BuildDone || after <= res.Revision { + if v := valueOf(t, r.st, r.key); v.State != builds.BuildDone || after <= res.Revision { t.Errorf("the key = %+v at revision %d; want built from the new revision %d", v, after, res.Revision) } if !log.has(r.key, "built again by hand", "no tun on ace", "shanks.lead") { @@ -947,8 +1028,8 @@ func TestABuildAskWaitingInItsStreamIsNotBounded(t *testing.T) { first := "" if c.step != "" { first = "mvl0frv18d1960" - data, _ := delivery.EncodeBuilderBuild(delivery.BuilderBuild{State: delivery.BuildBuilding, By: "novox.builder", Manifest: manifestD, - Step: c.step, Asks: map[string]string{delivery.AskGo: first}}) + data, _ := builds.EncodeBuilderBuild(builds.BuilderBuild{State: builds.BuildBuilding, By: "novox.builder", Manifest: manifestD, + Step: c.step, Asks: map[string]string{buildstep.AskGo: first}}) r.st.vals[r.key] = data } gone := &goneAsks{} @@ -962,7 +1043,7 @@ func TestABuildAskWaitingInItsStreamIsNotBounded(t *testing.T) { r.b.Asks = buildrun.Asker{Publisher: r.asks, Waiter: heard, Bound: 2 * time.Second} } if !c.byHand && !c.dead && !c.deaf { - gone.why = func(a delivery.StepAsk) string { + gone.why = func(a buildstep.StepAsk) string { if c.always || a.ID == first || first == "" { return "it waits first in line while a node took a later ask" } @@ -998,7 +1079,7 @@ func TestABuildAskWaitingInItsStreamIsNotBounded(t *testing.T) { first = asks[0].ID } last := asks[len(asks)-1] - if v := valueOf(t, r.st, r.key); v.Asks[delivery.AskGo] != last.ID || !strings.Contains(v.Step, "again (asking 2)") { + if v := valueOf(t, r.st, r.key); v.Asks[buildstep.AskGo] != last.ID || !strings.Contains(v.Step, "again (asking 2)") { t.Fatalf("the key = %+v, want building naming the ask asked again %s", v, last.ID) } r.asks.End(r.answer(last)) @@ -1008,10 +1089,10 @@ func TestABuildAskWaitingInItsStreamIsNotBounded(t *testing.T) { if n := len(r.asks.Asks()); n != c.puts { t.Errorf("%d asks put, want %d", n, c.puts) } - if c.done && v.State != delivery.BuildDone { + if c.done && v.State != builds.BuildDone { t.Errorf("the key = %+v, want done from the ask asked again", v) } - if !c.done && (v.State != delivery.BuildFailed || !strings.Contains(v.Error, "could not be answered and was asked")) { + if !c.done && (v.State != builds.BuildFailed || !strings.Contains(v.Error, "could not be answered and was asked")) { t.Errorf("the key = %+v, want failed saying the ask could not be answered", v) } mu.Lock() @@ -1034,18 +1115,18 @@ func TestABuildAskWaitingInItsStreamIsNotBounded(t *testing.T) { taken := func(kind string, at time.Time) *Taken { return &Taken{Ask: "later", Kind: kind, At: at, Node: "g14", Started: at} } - later := taken(delivery.AskDelivery, put.Add(time.Minute)) + later := taken(buildstep.AskDelivery, put.Add(time.Minute)) // ahead is n messages put before the ask, a minute apart, each with step (its state, "" for none). ahead := func(n int, step string) []Queued { var qs []Queued for i := range n { at := put.Add(-time.Duration(n-i) * time.Minute) - qs = append(qs, Queued{Seq: uint64(i + 1), Ask: fmt.Sprint("a", i), Kind: delivery.AskDelivery, At: at, PutAt: at, Step: step}) + qs = append(qs, Queued{Seq: uint64(i + 1), Ask: fmt.Sprint("a", i), Kind: buildstep.AskDelivery, At: at, PutAt: at, Step: step}) } return qs } seen := func(qs []Queued, last *Taken) AskSeen { - return AskSeen{Stream: "S", Kind: delivery.AskDelivery, At: put, OnStream: true, PutAt: put, Ahead: qs, Last: last} + return AskSeen{Stream: "S", Kind: buildstep.AskDelivery, At: put, OnStream: true, PutAt: put, Ahead: qs, Last: last} } for _, c := range []struct { name string @@ -1057,14 +1138,14 @@ func TestABuildAskWaitingInItsStreamIsNotBounded(t *testing.T) { {"no longer on its stream", AskSeen{Stream: "S"}, "no longer holds it", 0}, {"first in line, a later ask taken", seen(nil, later), "no longer delivers it", 0}, {"first in line, nothing taken since", seen(nil, nil), "", 0}, - {"first in line, the ask taken last was asked before it", seen(nil, taken(delivery.AskDelivery, put.Add(-time.Second))), "", 0}, + {"first in line, the ask taken last was asked before it", seen(nil, taken(buildstep.AskDelivery, put.Add(-time.Second))), "", 0}, {"two wait ahead, nothing taken since", seen(ahead(2, ""), nil), "", 2}, - {"two ahead passed over, the ask not yet", seen(ahead(2, ""), taken(delivery.AskDelivery, put.Add(-time.Second))), "", 0}, + {"two ahead passed over, the ask not yet", seen(ahead(2, ""), taken(buildstep.AskDelivery, put.Add(-time.Second))), "", 0}, // Note 62: seven messages SEAT_NODE_BUILD_GO gave up on sat ahead of a Go ask for five hours. {"seven given up on ahead, a later ask taken", seen(ahead(7, ""), later), "behind 7 given up on or taken", 0}, - {"seven taken ahead (failed as poison), a later ask taken", seen(ahead(7, delivery.StepFailed), later), "no longer delivers it", 0}, - {"a check ask taken later proves a delivery ask passed", seen(ahead(7, ""), taken(delivery.AskCheck, put.Add(time.Minute))), "no longer delivers it", 0}, - {"a delivery ask taken later does not prove a check ask passed", func() AskSeen { s := seen(ahead(3, ""), later); s.Kind = delivery.AskCheck; return s }(), "", 3}, + {"seven taken ahead (failed as poison), a later ask taken", seen(ahead(7, buildstep.StepFailed), later), "no longer delivers it", 0}, + {"a check ask taken later proves a delivery ask passed", seen(ahead(7, ""), taken(buildstep.AskCheck, put.Add(time.Minute))), "no longer delivers it", 0}, + {"a delivery ask taken later does not prove a check ask passed", func() AskSeen { s := seen(ahead(3, ""), later); s.Kind = buildstep.AskCheck; return s }(), "", 3}, } { got := c.seen.Why() if (c.want == "") != (got == "") || !strings.Contains(got, c.want) || c.seen.Waiting() != c.waiting { @@ -1091,12 +1172,12 @@ func TestABuildAskWaitingInItsStreamIsNotBounded(t *testing.T) { return nil, time.Time{}, nil }} } - key, _ := delivery.BuilderBuildKey("m", fp1) - done, _ := delivery.BuilderBuildKey("d", fp1) - building, _ := delivery.EncodeBuilderBuild(delivery.BuilderBuild{State: delivery.BuildBuilding, By: "novox.builder", Manifest: manifestD, - Step: "asked mesh-build-go again (asking 2): x", Asks: map[string]string{delivery.AskGo: "mvo53jcm70b5f7", delivery.AskImage: "mvo53jcm70b5f8"}}) - finished, _ := delivery.EncodeBuilderBuild(delivery.BuilderBuild{State: delivery.BuildDone, Manifest: manifestD, Step: "built"}) - running, _ := json.Marshal(delivery.Step{V: delivery.Version, ID: "mvo53jcm70b5f8", Tool: "delivery ask", Node: "g14", State: delivery.StepRunning, Started: at}) + key, _ := builds.BuilderBuildKey("m", fp1) + done, _ := builds.BuilderBuildKey("d", fp1) + building, _ := builds.EncodeBuilderBuild(builds.BuilderBuild{State: builds.BuildBuilding, By: "novox.builder", Manifest: manifestD, + Step: "asked mesh-build-go again (asking 2): x", Asks: map[string]string{buildstep.AskGo: "mvo53jcm70b5f7", buildstep.AskImage: "mvo53jcm70b5f8"}}) + finished, _ := builds.EncodeBuilderBuild(builds.BuilderBuild{State: builds.BuildDone, Manifest: manifestD, Step: "built"}) + running, _ := json.Marshal(buildstep.Step{V: keys.Version, ID: "mvo53jcm70b5f8", Tool: "delivery ask", Node: "g14", State: buildstep.StepRunning, Started: at}) _, reask, _ := NewReAsk("m", fp1, "stuck", "shanks.lead", at) r := StuckReader{Builds: state(map[string]string{key: string(building), done: string(finished)}), ReAsks: state(map[string]string{key: string(reask)}), Steps: map[string]StateRead{buildrun.GoBuildModule: state(map[string]string{}), ImageBuildModule: state(map[string]string{"g14.mvo53jcm70b5f8": string(running)})}, @@ -1115,7 +1196,7 @@ func TestABuildAskWaitingInItsStreamIsNotBounded(t *testing.T) { t.Fatalf("stuck = %+v, want the building key alone, an hour old, one ask with no step, with its ask again", stuck) } a := stuck[0].Asks - if len(a) != 2 || a[0].Language != delivery.AskGo || a[0].Asking != 2 || a[0].Step != "none" || a[0].Asked.IsZero() || + if len(a) != 2 || a[0].Language != buildstep.AskGo || a[0].Asking != 2 || a[0].Step != "none" || a[0].Asked.IsZero() || a[1].Asking != 1 || !strings.HasPrefix(a[1].Step, "running on g14") { t.Fatalf("asks = %+v, want go at asking 2 with no step and image at asking 1 running on g14", a) } @@ -1126,7 +1207,7 @@ func TestABuildAskWaitingInItsStreamIsNotBounded(t *testing.T) { var gets, written, inFlight, maxFlight atomic.Int64 vals := map[string]string{} for i := 0; i < 3000; i++ { - k, _ := delivery.BuilderBuildKey(fmt.Sprintf("mod%02d", i%100), fmt.Sprintf("src2:%064x", i)) + k, _ := builds.BuilderBuildKey(fmt.Sprintf("mod%02d", i%100), fmt.Sprintf("src2:%064x", i)) vals[k] = string(finished) if (i/100)%10 == 0 { vals[k] = string(building) @@ -1171,7 +1252,7 @@ func TestABuildAskWaitingInItsStreamIsNotBounded(t *testing.T) { // heldAsks says an ask is held when its id is among held. type heldAsks map[string]bool -func (h heldAsks) Held(_ context.Context, a delivery.StepAsk) (bool, error) { return h[a.ID], nil } +func (h heldAsks) Held(_ context.Context, a buildstep.StepAsk) (bool, error) { return h[a.ID], nil } // 1910: a builder that stopped between naming the ask in its key and putting it left a key naming an ask neither the // stream holds nor any node ran: started again, the builder puts that ask, under the same id, once, and the key is @@ -1180,13 +1261,13 @@ func TestAnAskTheKeyNamesAndNobodyPutIsPutAtStart(t *testing.T) { for _, held := range []bool{false, true} { r := newRig(t, goBundle) const id = "mvl0frv18d1958" - data, _ := delivery.EncodeBuilderBuild(delivery.BuilderBuild{State: delivery.BuildBuilding, By: "novox.builder", Manifest: manifestD, - Step: "asked mesh-build-go", Asks: map[string]string{delivery.AskGo: id}}) + data, _ := builds.EncodeBuilderBuild(builds.BuilderBuild{State: builds.BuildBuilding, By: "novox.builder", Manifest: manifestD, + Step: "asked mesh-build-go", Asks: map[string]string{buildstep.AskGo: id}}) r.st.vals[r.key] = data r.b.Held = heldAsks{id: held} if held { r.asks.Answer = nil - r.asks.End(delivery.Step{ID: id, Node: "shanks", State: delivery.StepPassed, + r.asks.End(buildstep.Step{ID: id, Node: "shanks", State: buildstep.StepPassed, Result: []byte(`{"artifacts":[{"artifact":"tool","digest":"` + digest + `","reference":"artifact-store://m/tool/blobs/` + digest + `"}]}`)}) } v := r.consider(t, "modules/m") @@ -1197,7 +1278,7 @@ func TestAnAskTheKeyNamesAndNobodyPutIsPutAtStart(t *testing.T) { case !held && (len(asks) != 1 || asks[0].ID != id): t.Errorf("the ask nobody put: asks %+v, want one under %s", asks, id) } - if v.State != delivery.BuildDone { + if v.State != builds.BuildDone { t.Errorf("held %v: the key = %+v, want done", held, v) } } @@ -1206,7 +1287,7 @@ func TestAnAskTheKeyNamesAndNobodyPutIsPutAtStart(t *testing.T) { // unreadableHeld cannot tell whether an ask is held. type unreadableHeld struct{} -func (unreadableHeld) Held(context.Context, delivery.StepAsk) (bool, error) { +func (unreadableHeld) Held(context.Context, buildstep.StepAsk) (bool, error) { return false, errors.New("permissions violation for publish to $JS.API.STREAM.INFO.SEAT_NODE_BUILD_GO") } @@ -1215,15 +1296,15 @@ func (unreadableHeld) Held(context.Context, delivery.StepAsk) (bool, error) { func TestATakeOverThatCannotReadTheStreamWaitsForTheAsk(t *testing.T) { r := newRig(t, goBundle) const id = "mvl0frv18d1959" - data, _ := delivery.EncodeBuilderBuild(delivery.BuilderBuild{State: delivery.BuildBuilding, By: "novox.builder", Manifest: manifestD, - Step: "asked mesh-build-go", Asks: map[string]string{delivery.AskGo: id}}) + data, _ := builds.EncodeBuilderBuild(builds.BuilderBuild{State: builds.BuildBuilding, By: "novox.builder", Manifest: manifestD, + Step: "asked mesh-build-go", Asks: map[string]string{buildstep.AskGo: id}}) r.st.vals[r.key] = data r.b.Held = unreadableHeld{} r.asks.Answer = nil - r.asks.End(delivery.Step{ID: id, Node: "shanks", State: delivery.StepPassed, + r.asks.End(buildstep.Step{ID: id, Node: "shanks", State: buildstep.StepPassed, Result: []byte(`{"artifacts":[{"artifact":"tool","digest":"` + digest + `","reference":"artifact-store://m/tool/blobs/` + digest + `"}]}`)}) v := r.consider(t, "modules/m") - if v.State != delivery.BuildDone || len(r.asks.Asks()) != 0 { + if v.State != builds.BuildDone || len(r.asks.Asks()) != 0 { t.Fatalf("the key = %+v after %d asks, want done from the ask's step, none put", v, len(r.asks.Asks())) } } @@ -1231,6 +1312,120 @@ func TestATakeOverThatCannotReadTheStreamWaitsForTheAsk(t *testing.T) { // silentWatch is a watch of an ask's step that stays open and hears nothing. type silentWatch struct{} -func (silentWatch) WatchAsk(context.Context, string, string) (<-chan delivery.Step, error) { - return make(chan delivery.Step), nil +func (silentWatch) WatchAsk(context.Context, string, string) (<-chan buildstep.Step, error) { + return make(chan buildstep.Step), nil +} + +// aDoneKeyKeepsEachFormACountedNodeReads holds the builder's rows of ADR 0624's check: every build keeps each form a +// counted node's engine reads, present or away, a node stating nothing reading the baseline's first form; the oldest kept +// form is the single reference; a retired node leaves the count. old is the first form, new a fixture second form read +// only by an engine stating form:new. +func aDoneKeyKeepsEachFormACountedNodeReads(t *testing.T) { + fs := forms.ArtifactForms{ + {Name: "old", First: true, Kinds: []string{"bundle"}, Is: func(r string) bool { return strings.HasPrefix(r, "artifact-store://") }}, + {Name: "new", Kinds: []string{"bundle"}, Is: func(r string) bool { return strings.HasPrefix(r, "10.0.0.1:5000/") }}, + } + both, nothing, onlyNew := []string{"form:new", "word:module"}, []string(nil), []string{"form:new"} + oldRef, newRef := "artifact-store://m/code/blobs/"+digest, "10.0.0.1:5000/m/code@"+digest + for _, row := range []struct { + name string + present, counted map[string][]string + kept []string + reference string + references []string + }{ + {"A present {old,new}, B away {old}: both forms, the old in the single reference", + map[string][]string{"a": both}, map[string][]string{"a": both, "b": {"word:module"}}, []string{"old", "new"}, oldRef, []string{oldRef, newRef}}, + {"A present {new}, B away stating nothing: the baseline's form stays", + map[string][]string{"a": onlyNew}, map[string][]string{"a": onlyNew, "b": nothing}, []string{"old", "new"}, oldRef, []string{oldRef, newRef}}, + {"A present {old,new}, B away and never asked (no key): the baseline's form stays", + map[string][]string{"a": both}, map[string][]string{"a": both, "b": nil}, []string{"old", "new"}, oldRef, []string{oldRef, newRef}}, + {"B retired: the new form only", map[string][]string{"a": both}, map[string][]string{"a": both}, []string{"new"}, newRef, nil}, + {"B present stating nothing: the new form is not written", + map[string][]string{"a": both, "b": nothing}, map[string][]string{"a": both, "b": nothing}, []string{"old"}, oldRef, nil}, + {"no node present: the first form", nil, map[string][]string{"a": both}, []string{"old"}, oldRef, nil}, + } { + if got := fs.Kept("bundle", row.present, row.counted); !slices.Equal(got, row.kept) { + t.Errorf("%s: kept %v, want %v", row.name, got, row.kept) + } + a := fs.Choose(forms.BuiltArtifact{Name: "code", Kind: "bundle", Digest: digest}, []string{newRef, oldRef}, row.present, row.counted) + if a.Reference != row.reference || !slices.Equal(a.References, row.references) { + t.Errorf("%s: %q %v, want %q %v", row.name, a.Reference, a.References, row.reference, row.references) + } + // An engine that predates the list reads the single reference, in a form every counted node reads (ADR 0624 §3). + if form, _ := fs.FormOf("bundle", a.Reference); form != row.kept[0] { + t.Errorf("%s: the single reference is in %q, want the oldest kept %q", row.name, form, row.kept[0]) + } + } + // An answered reference no kept form names still names where the artifact is (ADR 0498 clause 1). + if a := fs.Choose(forms.BuiltArtifact{Name: "code", Kind: "bundle", Digest: digest}, []string{oldRef}, map[string][]string{"a": both}, + map[string][]string{"a": both}); a.Reference != oldRef { + t.Errorf("a lone answered reference was dropped: %+v", a) + } + withBoth := []forms.BuiltArtifact{{Name: "code", Kind: "bundle", Digest: digest, Reference: oldRef, References: []string{oldRef, newRef}}} + if got := fs.Needs(withBoth); !slices.Equal(got, []string{"form:new", "form:old"}) { + t.Errorf("needs %v", got) + } + // The builder's own forms (forms.Current): a bundle a build step answers in the registry seat's form (mesh-build-go's + // oci-artifact-registry//@) keeps the artifact-store form it derives for each counted node + // whose engine does not state form:seat-reference, that one in the single reference (ADR 0624 §1, §3). + seat := []string{forms.FormNeed(forms.FormSeatReference), "word:module"} + // Each kind the seat form names (a bundle through oci-artifact-registry, an image and an upstream copy through + // container-image-registry, the copy under the holder's own upstream//), with the artifact-store form it derives. + for _, k := range []struct{ kind, seatRef, storeRef string }{ + {"bundle", "oci-artifact-registry/m/code@" + digest, "artifact-store://m/code/blobs/" + digest}, + {"image", "container-image-registry/m/code@" + digest, "artifact-store://m/code@" + digest}, + {"upstream", "container-image-registry/upstream/quay.io:443/org/app@" + digest, "artifact-store://upstream/quay.io:443/org/app@" + digest}, + } { + seatRef, storeRef := k.seatRef, k.storeRef + for _, row := range []struct { + name string + present, counted map[string][]string + reference string + references []string + needs []string + }{ + {"seat form answered, B away stating nothing: the derived store form stays, in the single reference", + map[string][]string{"a": seat}, map[string][]string{"a": seat, "b": nil}, storeRef, []string{storeRef, seatRef}, + []string{"form:artifact-store", "form:seat-reference"}}, + {"seat form answered, every counted engine reads it: the seat form only", + map[string][]string{"a": seat}, map[string][]string{"a": seat, "b": seat}, seatRef, nil, []string{"form:seat-reference"}}, + {"seat form answered, a present engine stating nothing: the store form only", + map[string][]string{"a": seat, "b": nil}, map[string][]string{"a": seat, "b": nil}, storeRef, nil, []string{"form:artifact-store"}}, + {"seat form answered, no node present: the store form", nil, map[string][]string{"a": seat}, storeRef, nil, []string{"form:artifact-store"}}, + } { + a := forms.Current.Choose(forms.BuiltArtifact{Name: "code", Kind: k.kind, Digest: digest}, []string{seatRef}, row.present, row.counted) + if a.Reference != row.reference || !slices.Equal(a.References, row.references) { + t.Errorf("%s %s: %q %v, want %q %v", k.kind, row.name, a.Reference, a.References, row.reference, row.references) + } + needs := forms.Current.Needs([]forms.BuiltArtifact{a}) + if !slices.Equal(needs, row.needs) { + t.Errorf("%s %s: needs %v, want %v", k.kind, row.name, needs, row.needs) + } + for node, stated := range row.counted { + if forms.Current.Held(needs, stated) { + t.Errorf("%s %s: the want of %s is held", k.kind, row.name, node) + } + } + if _, err := builds.EncodeBuilderBuild(builds.BuilderBuild{State: builds.BuildDone, Manifest: strings.TrimPrefix(digest, "sha256:"), Artifacts: []builds.BuiltArtifact{a}, Needs: needs}); err != nil { + t.Errorf("%s %s: %v", k.kind, row.name, err) + } + } + } + // What the assigner holds a want by (ADR 0624 §2), from the same forms. + for _, row := range []struct { + name string + needs []string + stated []string + held bool + }{ + {"a node stating {old}, a build carrying {old,new}: moves", []string{"form:new", "form:old"}, []string{"word:module"}, false}, + {"a node stating nothing, a build carrying the baseline's form: moves", []string{"form:old"}, nil, false}, + {"a build carrying {new} only, the node stating {old}: held", []string{"form:new"}, []string{"word:module"}, true}, + {"a build naming no form: moves", nil, nil, false}, + } { + if got := fs.Held(row.needs, row.stated); got != row.held { + t.Errorf("%s: held %v", row.name, got) + } + } } diff --git a/modules/mesh-delivery-builder/internal/build/since.go b/modules/mesh-delivery-builder/internal/build/since.go index 968e5979..8875b37d 100644 --- a/modules/mesh-delivery-builder/internal/build/since.go +++ b/modules/mesh-delivery-builder/internal/build/since.go @@ -4,8 +4,8 @@ import ( "fmt" "time" + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds" stdio "git.novox.be/novox/mesh-sdk/go" - "git.novox.be/novox/mesh-sdk/go/delivery" ) // BuildsSinceLimit is how many entries one answer of BuildsSinceTool reads of the log; More says the log held more. @@ -14,28 +14,28 @@ const BuildsSinceLimit = 500 // LogSince is one page of the builder's log from a time on: stdio.Log(BuilderLog).Since. type LogSince func(at time.Time, keys string, limit int) (*stdio.LogPage, error) -// BuildsSince answers delivery.BuildsSinceTool (ADR 0571 clause 9): every ended build in the builder's log from since on, +// BuildsSince answers builds.BuildsSinceTool (ADR 0571 clause 9): every ended build in the builder's log from since on, // oldest first, read through the runtime's log verbs; More when the page was not the log's last, so the caller asks again // from the last build's time. An entry the log holds that does not read is refused, naming its sequence: a reader that // skipped it would register too little. -func BuildsSince(read LogSince, since time.Time) (delivery.BuildsSince, error) { - out := delivery.BuildsSince{Since: since.UTC(), Builds: []delivery.BuildLogEntry{}} +func BuildsSince(read LogSince, since time.Time) (builds.BuildsSince, error) { + out := builds.BuildsSince{Since: since.UTC(), Builds: []builds.BuildLogEntry{}} page, err := read(since, "", BuildsSinceLimit) if err != nil { - return out, fmt.Errorf("the builder's log %s from %s: %w", delivery.BuilderLog, since.Format(time.RFC3339), err) + return out, fmt.Errorf("the builder's log %s from %s: %w", builds.BuilderLog, since.Format(time.RFC3339), err) } if page == nil { return out, nil } - var entries []delivery.BuildLogEntry + var entries []builds.BuildLogEntry for _, le := range page.Entries { - e, err := delivery.DecodeBuildLogEntry(le.Value) + e, err := builds.DecodeBuildLogEntry(le.Value) if err != nil { - return out, fmt.Errorf("the builder's log %s, entry %d: %w", delivery.BuilderLog, le.Sequence, err) + return out, fmt.Errorf("the builder's log %s, entry %d: %w", builds.BuilderLog, le.Sequence, err) } entries = append(entries, e) } - if ended := delivery.EndedSince(entries, since); ended != nil { + if ended := builds.EndedSince(entries, since); ended != nil { out.Builds = ended } out.More = page.Next != 0 diff --git a/modules/mesh-delivery-builder/internal/build/stuck.go b/modules/mesh-delivery-builder/internal/build/stuck.go index d6a713c0..e5ef2e86 100644 --- a/modules/mesh-delivery-builder/internal/build/stuck.go +++ b/modules/mesh-delivery-builder/internal/build/stuck.go @@ -1,6 +1,8 @@ package build import ( + "mesh-delivery-builder/internal/versions" + "encoding/json" "errors" "fmt" @@ -12,7 +14,8 @@ import ( "time" "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun" - "git.novox.be/novox/mesh-sdk/go/delivery" + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds" + "git.novox.be/novox/mesh-delivery/sdk/buildstep" ) // The builder's tools (novox/hq issue 1945; the instruction "tools are how a module is spoken to"): builder_stuck reads @@ -29,7 +32,7 @@ const ( AnnounceAgainTool = "builder_announce_again" // ReAsksState is the builder's state of asks to ask again: the bucket mesh-delivery-builder_reasks, keyed as the build key. ReAsksState = "reasks" - ReAsksBucket = delivery.BuilderModule + "_" + ReAsksState + ReAsksBucket = builds.BuilderModule + "_" + ReAsksState ) // ReAsk is a value of the reasks state: why a person asked the key's build again, who and when, and, once the builder @@ -54,6 +57,67 @@ type ReAsk struct { Failed string `json:"failed,omitempty"` Revision uint64 `json:"revision,omitempty"` Asks map[string]string `json:"asks,omitempty"` + // Name is the numbered name (`/`) of a ModePublishAgain or ModeFloorAgain value; Do and Version the + // builder's answer: what it did (versions.Again*) and the version published. + Name string `json:"name,omitempty"` + Do string `json:"do,omitempty"` + Version string `json:"version,omitempty"` +} + +// ModePublishAgain is the Mode of a reasks value of builder_publish_again (design 51 *Tools of the builder*). +const ModePublishAgain = "publish-again" + +// ModeFloorAgain is the Mode of a reasks value of builder_floor_again (design 51 *The floor*). +const ModeFloorAgain = "floor-again" + +// NewPublishAgain is the value builder_publish_again writes for a name's fingerprint: the key of the name's module and +// fingerprint, and the value. +func NewPublishAgain(name, fp, why, by string, at time.Time) (string, []byte, error) { + module, artifact, ok := strings.Cut(strings.TrimSpace(name), "/") + if !ok || module == "" || artifact == "" { + return "", nil, fmt.Errorf("name %q is not a numbered name, /, as builder_versions lists it", name) + } + key, err := builds.BuilderBuildKey(module, fp) + if err != nil { + return "", nil, err + } + if strings.TrimSpace(why) == "" { + return "", nil, errors.New("say why it is published again (why)") + } + data, err := json.Marshal(ReAsk{V: 1, Why: strings.TrimSpace(why), By: by, At: at.UTC(), Mode: ModePublishAgain, Module: module, FP: fp, Name: strings.TrimSpace(name)}) + return key, data, err +} + +// NewFloorAgain is the value builder_floor_again writes for a name: the key `floor.`, never a build key, +// and the value. +func NewFloorAgain(name, why, by string, at time.Time) (string, []byte, error) { + name = strings.TrimSpace(name) + if module, artifact, ok := strings.Cut(name, "/"); !ok || module == "" || artifact == "" { + return "", nil, fmt.Errorf("name %q is not a numbered name, /, as builder_versions lists it", name) + } + if strings.TrimSpace(why) == "" { + return "", nil, errors.New("say why the floor is asked again (why)") + } + data, err := json.Marshal(ReAsk{V: 1, Why: strings.TrimSpace(why), By: by, At: at.UTC(), Mode: ModeFloorAgain, Name: name}) + return "floor." + versions.EscapeToken(name), data, err +} + +// ModeOldFormDrop is the Mode of a reasks value of builder_old_form's drop (design 51 *Every build keeps the old +// reference form*): a retired node dropped from the count by hand; Name is the node. +const ModeOldFormDrop = "old-form-drop" + +// NewOldFormDrop is the value builder_old_form writes to drop a retired node: the key `old-form.`, never a build +// key, and the value. +func NewOldFormDrop(node, why, by string, at time.Time) (string, []byte, error) { + node = strings.TrimSpace(node) + if node == "" || strings.ContainsAny(node, ". *>") { + return "", nil, fmt.Errorf("node %q is not a node's name", node) + } + if strings.TrimSpace(why) == "" { + return "", nil, errors.New("say why the node leaves the count (why)") + } + data, err := json.Marshal(ReAsk{V: 1, Why: strings.TrimSpace(why), By: by, At: at.UTC(), Mode: ModeOldFormDrop, Name: node}) + return "old-form." + node, data, err } // ModeBuild is the Mode of a reasks value that asks the key to be built again, not its waiting asks to be asked again. @@ -76,7 +140,7 @@ func DecodeReAsk(data []byte) (ReAsk, error) { // NewReAsk is the value builder_ask_again writes for a module's fingerprint: its key and the value. func NewReAsk(module, fp, why, by string, at time.Time) (string, []byte, error) { - key, err := delivery.BuilderBuildKey(module, fp) + key, err := builds.BuilderBuildKey(module, fp) if err != nil { return "", nil, err } @@ -89,7 +153,7 @@ func NewReAsk(module, fp, why, by string, at time.Time) (string, []byte, error) // NewBuildAgain is the value builder_build_again writes for a module's fingerprint: its key and the value. func NewBuildAgain(module, fp, why, by string, at time.Time) (string, []byte, error) { - key, err := delivery.BuilderBuildKey(module, fp) + key, err := builds.BuilderBuildKey(module, fp) if err != nil { return "", nil, err } @@ -102,7 +166,7 @@ func NewBuildAgain(module, fp, why, by string, at time.Time) (string, []byte, er // NewAnnounceAgain is the value builder_announce_again writes for a module's fingerprint: its key and the value. func NewAnnounceAgain(module, fp, why, by string, at time.Time) (string, []byte, error) { - key, err := delivery.BuilderBuildKey(module, fp) + key, err := builds.BuilderBuildKey(module, fp) if err != nil { return "", nil, err } @@ -240,7 +304,7 @@ func (r StuckReader) Stuck(module string) (map[string]any, error) { } byID := make(map[string]string, len(ks)) for _, k := range ks { - if _, id, err := delivery.ParseStepKey(k); err == nil { + if _, id, err := buildstep.ParseStepKey(k); err == nil { byID[id] = k } } @@ -251,7 +315,7 @@ func (r StuckReader) Stuck(module string) (map[string]any, error) { type parsed struct{ key, mod, fp string } var matching []parsed for _, key := range keys { - mod, fp, err := delivery.ParseBuilderBuildKey(key) + mod, fp, err := builds.ParseBuilderBuildKey(key) if err != nil || (module != "" && mod != module) { continue } @@ -282,8 +346,8 @@ func (r StuckReader) Stuck(module string) (map[string]any, error) { if raw == nil { continue } - v, err := delivery.DecodeBuilderBuild(raw) - if err != nil || (v.State != delivery.BuildBuilding && v.State != delivery.BuildQueued) { + v, err := builds.DecodeBuilderBuild(raw) + if err != nil || (v.State != builds.BuildBuilding && v.State != builds.BuildQueued) { continue } if at.IsZero() && r.Builds.Written != nil { @@ -294,7 +358,7 @@ func (r StuckReader) Stuck(module string) (map[string]any, error) { s.Age = now().Sub(at).Round(time.Second).String() } for language, id := range v.Asks { - bm, _ := delivery.BuildModuleOf(language) + bm, _ := buildstep.BuildModuleOf(language) a := StuckAsk{Language: language, BuildModule: bm, ID: id, Asking: askingOf(v.Step, bm), Step: "none"} if t, ok := idTime(id); ok { a.Asked = t @@ -303,7 +367,7 @@ func (r StuckReader) Stuck(module string) (map[string]any, error) { if k, ok := stepKeys[bm][id]; ok { reads++ if raw, _, err := st.Get(k); err == nil && raw != nil { - if step, err := delivery.DecodeStep(raw); err == nil { + if step, err := buildstep.DecodeStep(raw); err == nil { a.Step = fmt.Sprintf("%s on %s since %s", step.State, step.Node, step.Started.UTC().Format(time.RFC3339)) } } @@ -315,7 +379,7 @@ func (r StuckReader) Stuck(module string) (map[string]any, error) { s.Asks = append(s.Asks, a) } sort.Slice(s.Asks, func(i, j int) bool { return s.Asks[i].Language < s.Asks[j].Language }) - s.Unasked = v.State == delivery.BuildBuilding && len(v.Asks) == 0 + s.Unasked = v.State == builds.BuildBuilding && len(v.Asks) == 0 if r.ReAsks.Get != nil { reads++ if raw, _, err := r.ReAsks.Get(key); err == nil && raw != nil { diff --git a/modules/mesh-delivery-builder/internal/build/toolchains.go b/modules/mesh-delivery-builder/internal/build/toolchains.go new file mode 100644 index 00000000..f2477c0a --- /dev/null +++ b/modules/mesh-delivery-builder/internal/build/toolchains.go @@ -0,0 +1,28 @@ +package build + +import ( + "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds" + sdkstep "git.novox.be/novox/mesh-delivery/sdk/buildstep" +) + +// CatalogToolchains are the toolchains of a build, by language, as a module's value holds them (the catalog's form names +// the language in the entry). +func CatalogToolchains(used map[string]builds.ToolchainUsed) map[string]meshcatalog.ToolchainUsed { + if used == nil { + return nil + } + out := make(map[string]meshcatalog.ToolchainUsed, len(used)) + for language, t := range used { + out[language] = meshcatalog.ToolchainUsed{Language: language, Recipe: t.Recipe, Version: t.Version} + } + return out +} + +// buildToolchain is the toolchain a build ask names for a language (the ask's form names the language in the entry). +func buildToolchain(language string, t *builds.ToolchainUsed) *sdkstep.ToolchainUsed { + if t == nil { + return nil + } + return &sdkstep.ToolchainUsed{Language: language, Recipe: t.Recipe, Version: t.Version} +} diff --git a/modules/mesh-delivery-builder/internal/versions/again.go b/modules/mesh-delivery-builder/internal/versions/again.go new file mode 100644 index 00000000..9e77235b --- /dev/null +++ b/modules/mesh-delivery-builder/internal/versions/again.go @@ -0,0 +1,351 @@ +package versions + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "sort" + "strings" + "time" + + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds" +) + +// What builder_publish_again does with one fingerprint of a name (design 51 *Tools of the builder*), after a holder failed. +const ( + // AgainPublish says version.numbered again for the recorded version: the build module publishes the held digest. + AgainPublish = "publish" + // AgainBuild builds the fingerprint again under the same number: the build's file is gone and no published was said + // for the number, so the new digest replaces the recorded one (Numberer.Built). + AgainBuild = "build" + // AgainRenumber takes a new number: a published was said for the number, so other bytes never go under it. + AgainRenumber = "renumber" +) + +// Again is what builder_publish_again does, with the entry it acts on and why. +type Again struct { + Do string `json:"do"` + Entry Entry `json:"entry"` + Why string `json:"why"` +} + +// PublishAgain decides builder_publish_again for fingerprint among a name's entries; built says whether the builder still +// holds the build's file (its done key). It refuses a fingerprint not numbered, and a version refused for other bytes +// than the ones recorded under it. +func PublishAgain(entries []Entry, fingerprint string, built bool) (Again, error) { + fingerprint = strings.TrimSpace(fingerprint) + if fingerprint == "" { + return Again{}, errors.New("fp is required: the fingerprint whose version is published again") + } + for _, e := range latestFirst(entries) { + if e.Tag != "" || e.Fingerprint != fingerprint { + continue + } + switch { + case e.State == Refused && e.RefusedDigest != "" && e.Digest != "" && e.RefusedDigest != e.Digest: + return Again{}, fmt.Errorf("%s@%s was refused for other bytes (%s, recorded %s): it is not published again; a new build takes a new number", + e.Name, e.Version, e.RefusedDigest, e.Digest) + case e.State == Published || e.State == Removed: + return Again{Do: AgainRenumber, Entry: e, Why: fmt.Sprintf("%s@%s was %s, so the fingerprint takes a new number", e.Name, e.Version, e.State)}, nil + case !built: + return Again{Do: AgainBuild, Entry: e, Why: fmt.Sprintf("the build's file is gone and no published was said for %s@%s: built again under the same number", e.Name, e.Version)}, nil + } + return Again{Do: AgainPublish, Entry: e, Why: fmt.Sprintf("%s@%s (%s) is said numbered again, so its build module publishes the held digest", e.Name, e.Version, e.State)}, nil + } + return Again{}, fmt.Errorf("fingerprint %s is not numbered for this name: builder_versions lists what is", fingerprint) +} + +// The builder's state of tag asks (design 51 *Tagged versions*): one key per repository and branch, removed when the +// branch is merged or deleted or its pull request closed. +const ( + TagAsksState = "tag-asks" + TagAsksBucket = "mesh-delivery-builder_tag-asks" +) + +// The states of a tag ask. +const ( + TagAsked = "asked" + TagNumbered = "numbered" + TagRemoving = "removing" + TagRemoved = "removed" +) + +// TagAsk is one value of tag-asks: an ask of a tagged version for a branch's head, from the checker's tag-wanted or by +// hand (builder_tag), or by hand the removal of the branch's tagged versions (builder_untag, Untag). +type TagAsk struct { + V int `json:"v"` + Repository string `json:"repository"` + Branch string `json:"branch"` + Head string `json:"head,omitempty"` + Tag string `json:"tag"` + By string `json:"by,omitempty"` + Why string `json:"why,omitempty"` + At time.Time `json:"at"` + Untag bool `json:"untag,omitempty"` + // State, Versions (name to version), Left (what a removal still waits on, name@version to its seat) and Answer are + // the builder's. + State string `json:"state,omitempty"` + Versions map[string]string `json:"versions,omitempty"` + Left map[string]string `json:"left,omitempty"` + Answer string `json:"answer,omitempty"` + Answered time.Time `json:"answered,omitzero"` +} + +// TagAskKey is the key of a repository's branch in tag-asks. +func TagAskKey(repository, branch string) string { + return EscapeToken(repository) + "." + EscapeToken(branch) +} + +// NewTagAsk is the value builder_tag writes: its key and the value. The tag is the branch's, cleaned (CleanTag). +func NewTagAsk(repository, branch, head, why, by string, at time.Time) (string, []byte, error) { + return newTagAsk(repository, branch, head, why, by, at, false) +} + +// NewUntag is the value builder_untag writes: its key and the value. +func NewUntag(repository, branch, why, by string, at time.Time) (string, []byte, error) { + return newTagAsk(repository, branch, "", why, by, at, true) +} + +func newTagAsk(repository, branch, head, why, by string, at time.Time, untag bool) (string, []byte, error) { + repository, branch, head, why = strings.TrimSpace(repository), strings.TrimSpace(branch), strings.TrimSpace(head), strings.TrimSpace(why) + switch { + case repository == "" || !strings.Contains(repository, "/"): + return "", nil, errors.New("repository is required: /") + case branch == "": + return "", nil, errors.New("branch is required") + case !untag && head == "": + return "", nil, errors.New("head is required: the commit the tagged version is built from") + case why == "": + return "", nil, errors.New("say why (why): a hand act is recorded") + } + data, err := json.Marshal(TagAsk{V: 1, Repository: repository, Branch: branch, Head: head, Tag: CleanTag(branch), By: by, Why: why, + At: at.UTC(), Untag: untag, State: TagAsked}) + return TagAskKey(repository, branch), data, err +} + +// DecodeTagAsk reads a tag-asks value. +func DecodeTagAsk(data []byte) (TagAsk, error) { + var a TagAsk + if err := json.Unmarshal(data, &a); err != nil { + return a, err + } + if a.V != 1 { + return a, fmt.Errorf("a tag-asks value of version %d, not 1", a.V) + } + return a, nil +} + +// TagAsksAnswer is what builder_tag_asks answers: the open asks, oldest first. +type TagAsksAnswer struct { + Asks []TagAsk `json:"asks"` +} + +// AnswerTagAsks is builder_tag_asks over the state read, only repository's when it is given. +func AnswerTagAsks(r Reader, repository string) (TagAsksAnswer, error) { + keys, err := r.Keys() + if err != nil { + return TagAsksAnswer{}, fmt.Errorf("%s cannot be listed: %w", TagAsksState, err) + } + out := TagAsksAnswer{Asks: []TagAsk{}} + for _, k := range keys { + raw, ok, err := r.Get(k) + if err != nil { + return TagAsksAnswer{}, fmt.Errorf("%s.%s: %w", TagAsksState, k, err) + } + if !ok { + continue + } + a, err := DecodeTagAsk(raw) + if err != nil { + return TagAsksAnswer{}, fmt.Errorf("%s.%s holds what is not a tag ask: %w", TagAsksState, k, err) + } + if repository == "" || a.Repository == repository { + out.Asks = append(out.Asks, a) + } + } + sort.Slice(out.Asks, func(i, j int) bool { return out.Asks[i].At.Before(out.Asks[j].At) }) + return out, nil +} + +// Untagged are repository's tagged versions of tag that are not removed (a group's branch spans repositories, each +// ending apart): what a branch's end, or builder_untag, removes through +// each one's seat (design 51 *Tagged versions*). A version with no tag is never among them. +func Untagged(entries []Entry, repository, tag string) []Entry { + tag = CleanTag(tag) + var out []Entry + for _, e := range entries { + if e.Tag != "" && e.Repository == repository && CleanTag(e.Tag) == tag && e.State != Removed && e.State != Kept { + out = append(out, e) + } + } + return InOrder(out) +} + +// The tools' descriptions. +const ( + PublishAgainDescription = "Publish one fingerprint's version again after its holder failed (design 51): with the build's file held, the " + + "builder says version.numbered again and the build module publishes the held digest; with the file gone, it builds the fingerprint " + + "again under the same number, the new digest replacing the recorded one, since no published was said for it; after a published (or " + + "a removal) it takes a new number. It refuses a fingerprint not numbered and a version the holder refused for other bytes. A hand " + + "act, recorded with its caller and why in the builder's log." + FloorAgainDescription = "Ask the floor of one name again now, by hand (design 51 *The floor*): the seat's versions, the forge bridge's " + + "bridge_tags and the indexer's indexer_requirements, as the builder asks them by itself when a silent source says something. " + + "Answers which sources were silent and whether the floor is now known. A name the builder's versions already hold needs no floor: " + + "refused, saying so. A hand act." + TagDescription = "Ask a tagged version for a branch's head by hand, as the checker's tag-wanted asks it (design 51 *Tagged versions*): " + + "the builder keeps the ask in its state tag-asks until the branch is merged or deleted, or its pull request closed, and numbers " + + "each package the head builds below the next trunk version (0.1.15-.2). A hand act." + TagAsksDescription = "The open tag asks of the builder's state tag-asks, oldest first: repository, branch, head, tag, who asked and why, " + + "its state (asked, numbered, removing, removed), the versions numbered for it and, while its tagged versions are removed, the " + + "seats that have not yet said removed. With repository, that repository's only. Read-only." + UntagDescription = "Remove a branch's tagged versions by hand, as its merge, its pull request's close or its deletion does (design 51 " + + "*Tagged versions*): the builder calls each seat's remove with the reason tag-gone and marks each version removed once its seat says " + + "removed. Answers the versions it removes and the seats still waited on. A version with no tag is never removed. A hand act." +) + +// Acts are the builder's hand acts on its versions, answered by its program (the tools bundle writes them to reasks). +type Acts struct { + Numberer *Numberer + // Done says whether the build key of module and fingerprint is done: the build's file is held. + Done func(ctx context.Context, module, fp string) (bool, error) + // BuildAgain builds a failed key again (builder_build_again's path): its answer, or why it refused. + BuildAgain func(ctx context.Context, module, fp, why, by string) (answer string, refused bool) + // Reconsider looks at every module again, so a claim asks a name's floor. + Reconsider func(ctx context.Context) + // Remove calls a seat's verb remove for one tagged version, reason tag-gone: what it answered, kept or asked. + Remove func(ctx context.Context, seat string, e Entry) (RemoveOutcome, error) + // DropOldForm drops a retired node from the old form's count by hand (builder_old_form): its answer, or why not. + DropOldForm func(ctx context.Context, node, by, why string, at time.Time) (string, error) +} + +// TagAsked answers one unanswered value of tag-asks. An untag calls remove on each of the branch's tagged versions' +// seats and leaves each in Left until its seat says removed (Heard, on the seat's events); a call that fails stays in +// Left with its error, asked again by the branch's end. A tag ask is kept: its versions are numbered when the head's +// packages are claimed for the group's check. +func (a Acts) TagAsked(ctx context.Context, t TagAsk, now time.Time) TagAsk { + t.Answered = now.UTC() + if !t.Untag { + t.State = TagAsked + t.Answer = "kept: each package the head builds is numbered " + "-" + t.Tag + ". when its build is claimed for the check" + return t + } + all, err := a.Numberer.Versions.All(ctx) + if err != nil { + t.Answer = "nothing removed: " + err.Error() + return t + } + t.Left, t.Versions = map[string]string{}, map[string]string{} + for _, e := range Untagged(all, t.Repository, t.Tag) { + t.Versions[e.Name] = e.Version + at := e.Name + "@" + e.Version + t.Left[at] = e.Seat + if left, ok := a.removeOne(ctx, e); ok { + delete(t.Left, at) + } else if left != "" { + t.Left[at] = left + } + } + if len(t.Left) == 0 { + t.State, t.Left, t.Answer = TagRemoved, nil, "the branch holds no tagged version: nothing to remove" + return t + } + t.State = TagRemoving + t.Answer = fmt.Sprintf("remove asked of %d tagged versions; each is marked removed when its seat says removed", len(t.Left)) + return t +} + +// removeOne asks e's seat to remove it: done when the seat kept it whole (recorded Kept, no retry); otherwise left, with +// the error of a call that failed. +func (a Acts) removeOne(ctx context.Context, e Entry) (left string, done bool) { + got, err := a.Remove(ctx, e.Seat, e) + if err != nil { + return e.Seat + ": " + err.Error(), false + } + if !got.Kept { + return "", false + } + if err := a.Numberer.keep(ctx, e, got.Reason, got.Tags); err != nil { + return e.Seat + ": kept, and not recorded so: " + err.Error(), false + } + return "", true +} + +// PublishAgain is builder_publish_again of name's fingerprint: what it did, the version, and its answer; an error refuses. +func (a Acts) PublishAgain(ctx context.Context, name, fp, why, by string, at time.Time) (Again, string, error) { + module, _, _ := strings.Cut(name, "/") + entries, err := a.Numberer.Versions.Of(ctx, name) + if err != nil { + return Again{}, "", err + } + done, err := a.Done(ctx, module, fp) + if err != nil { + return Again{}, "", fmt.Errorf("the build key of %s at %s cannot be read: %w", module, fp, err) + } + ag, err := PublishAgain(entries, fp, done) + if err != nil { + return Again{}, "", err + } + e := ag.Entry + if ag.Do == AgainRenumber { + _, ne, err := a.Numberer.Versions.Number(ctx, Claim{Name: e.Name, Kind: e.Kind, Series: e.Series, Module: e.Module, Fingerprint: e.Fingerprint, + Commit: e.Commit, Revision: e.Revision, Seat: e.Seat, Merge: e.Merge, Fresh: true}) + if err != nil { + return Again{}, "", err + } + ag.Entry = ne + } + answer := ag.Why + if ag.Do == AgainBuild || (ag.Do == AgainRenumber && !done) { + said, refused := a.BuildAgain(ctx, module, fp, why, by) + if refused { + return Again{}, "", fmt.Errorf("%s; and the build again was refused: %s", ag.Why, said) + } + answer += "; " + said + } + if ag.Do == AgainPublish || (ag.Do == AgainRenumber && done) { + if a.Numberer.Said != nil { + b := NumberedOf(ag.Entry, "") + b.Again = at.UTC().Format(time.RFC3339Nano) + a.Numberer.Said(b) + } + answer += "; " + builds.VersionNumberedEvent + " said for " + ag.Entry.Version + } + return ag, answer, nil +} + +// FloorAgain is builder_floor_again of name: the floor counts only for a name the versions hold nothing of. +func (a Acts) FloorAgain(ctx context.Context, name string) (string, error) { + entries, err := a.Numberer.Versions.Of(ctx, name) + if err != nil { + return "", err + } + if len(entries) > 0 { + return "", fmt.Errorf("%s holds %d versions: its floor counts only for its first number, so nothing is asked", name, len(entries)) + } + answer := name + " waits on no floor in this builder: the floor is asked at its next claim" + if a.Numberer.Waits != nil { + for _, w := range a.Numberer.Waits.Waiting() { + if w.Name == name { + answer = fmt.Sprintf("the floor of %s is asked again (%s were silent)", name, strings.Join(w.Silent, ", ")) + } + } + a.Numberer.Waits.Wake(WakeByHand, "", "", name) + } + a.Reconsider(ctx) + return answer + "; builder_versions shows its version once numbered", nil +} + +// TagFor is the cleaned tag a kept tag ask names for repository's head: the ask a check's build of that head is +// numbered under (design 51 *Tagged versions*). An untag, or an ask whose branch ended, names none. +func TagFor(asks []TagAsk, repository, head string) (string, bool) { + for _, t := range asks { + if t.Untag || t.State == TagRemoving || t.State == TagRemoved || t.Head == "" { + continue + } + if t.Repository == repository && t.Head == head { + return t.Tag, t.Tag != "" + } + } + return "", false +} diff --git a/modules/mesh-delivery-builder/internal/versions/branchend.go b/modules/mesh-delivery-builder/internal/versions/branchend.go new file mode 100644 index 00000000..1ced0765 --- /dev/null +++ b/modules/mesh-delivery-builder/internal/versions/branchend.go @@ -0,0 +1,92 @@ +package versions + +import ( + "context" + "fmt" + "sort" + "strings" + "time" +) + +// A branch's end removes its tagged versions (design 51 *Tagged versions*, DLV-B6): on the forge bridge's `merged`, a +// pull's `closed` or `branch-deleted`, heard on the builder's durable consumer of the stream `forge`, the builder calls +// each seat's `remove` and acknowledges the message only once every seat said `removed` (Heard marks the entry); until +// then the bus gives it again each minute (RemoveAgainAfter) and, past RemovalFindingAfter, the builder's finding names +// the seats still owing. +const ( + RemoveAgainAfter = time.Minute + RemovalFindingAfter = 15 * time.Minute + // RemovalNotDone is the finding's kind. + RemovalNotDone = "tag-removal-not-done" +) + +// BranchEnd is what one hearing of a branch's end did: the versions still not removed (name@version to its seat, with +// the error of a remove that failed), and whether all are gone so the message is acknowledged. +type BranchEnd struct { + Left map[string]string + Done bool +} + +// Seats are the seats still owing a removal, sorted, each once. +func (b BranchEnd) Seats() []string { + seen := map[string]bool{} + var out []string + for _, s := range b.Left { + seat, _, _ := strings.Cut(s, ":") + if !seen[seat] { + seen[seat] = true + out = append(out, seat) + } + } + sort.Strings(out) + return out +} + +// Finding is the removal finding's words: what is left, since when. +func (b BranchEnd) Finding(repository, branch string, since time.Time) string { + var at []string + for v, s := range b.Left { + at = append(at, v+" on "+s) + } + sort.Strings(at) + return fmt.Sprintf("the tagged versions of %s %s are not all removed since its end at %s: %s; the builder asks remove again each minute", + repository, branch, since.UTC().Format(time.RFC3339), strings.Join(at, ", ")) +} + +// BranchEnded removes repository's tagged versions of branch: every one not yet removed is asked of its seat again. +// A store that cannot be read is the error: nothing is acknowledged then. +func (a Acts) BranchEnded(ctx context.Context, repository, branch string) (BranchEnd, error) { + all, err := a.Numberer.Versions.All(ctx) + if err != nil { + return BranchEnd{}, err + } + left := map[string]string{} + for _, e := range Untagged(all, repository, branch) { + at := e.Name + "@" + e.Version + left[at] = e.Seat + if a.Remove == nil { + continue + } + // A seat that kept it whole (another tag names its digest) ends it here: recorded kept, never asked again. + if l, done := a.removeOne(ctx, e); done { + delete(left, at) + } else if l != "" { + left[at] = l + } + } + return BranchEnd{Left: left, Done: len(left) == 0}, nil +} + +// TagWanted is the body of the checker's `tag-wanted` (design 51 *Tagged versions*): a group member's head to number +// tagged versions for. +type TagWanted struct { + Repository string `json:"repository"` + Branch string `json:"branch"` + Head string `json:"head"` + Tag string `json:"tag,omitempty"` +} + +// TagWantedAsk is the tag-asks value a tag-wanted becomes, as builder_tag writes it by hand: by the checker. +func TagWantedAsk(t TagWanted, by string, at time.Time) (string, []byte, error) { + return NewTagAsk(t.Repository, t.Branch, t.Head, "the checker's tag-wanted for a group member's check", by, at) +} diff --git a/modules/mesh-delivery-builder/internal/versions/claim.go b/modules/mesh-delivery-builder/internal/versions/claim.go new file mode 100644 index 00000000..16d3e2a9 --- /dev/null +++ b/modules/mesh-delivery-builder/internal/versions/claim.go @@ -0,0 +1,263 @@ +package versions + +import ( + "context" + "fmt" + "sort" + "strings" + "time" + + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds" +) + +// The registry seats a version is published to (design 51 *The seats*), by what is published. +const ( + SeatImages = "container-image-registry" + SeatArtifacts = "oci-artifact-registry" + SeatGoPackages = "go-package-registry" + SeatNPM = "npm-package-registry" +) + +// SeatOf is the registry seat an artifact of kind in language is published to, and the kind the builder numbers it as; +// ok is false for what is not numbered (an upstream copy is mirrored by its pinned digest, design 51 *Publishing*). +func SeatOf(kind, language string) (seat, numberedAs string, ok bool) { + switch kind { + case KindImage: + return SeatImages, KindImage, true + case KindBundle, KindArchive: + return SeatArtifacts, kind, true + case KindPackage: + switch language { + case "go": + return SeatGoPackages, KindPackage, true + case "typescript", "javascript", "node": + return SeatNPM, KindPackage, true + } + } + return "", "", false +} + +// NameOf is the name a module's image, bundle or archive is numbered under: `/`, the name the seat +// answers it by (ADR 0605 §9's `//@…`). A package is numbered under its name in its own +// ecosystem instead (Numberer.Names). +func NameOf(module, artifact string) string { return module + "/" + artifact } + +// PackageNames answers a package's name in its own ecosystem (design 32: a Go package's module path, an npm package's +// package.json name) for the module and artifact it is built from, as the indexer's packages state holds it; known is +// false when the state names none for it. An error is a state that cannot be read. +type PackageNames func(ctx context.Context, module, artifact string) (name string, known bool, err error) + +// TagPrefix is the git tag prefix of a package in the module's directory path: `go/v` for mesh-sdk's `go`, `v` at the +// root (design 51: the builder tags each Go version `go/v0.1.`). +func TagPrefix(path string) string { + path = strings.Trim(path, "/") + if path == "" || path == "." { + return "v" + } + return path + "/v" +} + +// ClaimArtifact is one artifact of a trunk build to number. +type ClaimArtifact struct { + Artifact, Kind, Language string +} + +// BuildClaim is a trunk build at its claim: what Numberer.Number numbers. +type BuildClaim struct { + Module, Path, Repository, Fingerprint, Commit, Series string + // Revision is the source revision of the merge (a package is numbered per revision); Merge its sequence, for order. + Revision string + Merge uint64 + Artifacts []ClaimArtifact + // Tag numbers a check's build of a head a tag ask names (design 51 *Tagged versions*): its packages only, each a + // tagged version kept with Repository for its branch's end; Head is the head, said in numbered. Empty for a trunk build. + Tag, Head string + // Done are the digests of the build's done key, by artifact, when the claim is a look at a build already done: what it + // left unnumbered (a package whose name was not known yet, a floor that was silent) is numbered with them, so + // version.numbered carries the digest its build module publishes late. Nil for a build still to make. + Done map[string]string +} + +// Numbering is what the claim got. +type Numbering struct { + // Versions are each artifact's version, by artifact; an artifact built unnumbered (its floor not known) has none. + Versions map[string]string + // Unnumbered are the artifacts built and published by digest, numbered once their floor answers (ADR 0617 §1). + Unnumbered []string + // Wait is why the build waits: a package whose floor is not known (design 51 *The floor*). Nothing is claimed. + Wait string + // Said are the builder's narrations of each decision, for its process log and its build log (build.said). + Said []Narration + // Names are the names each numbered artifact was numbered under, by artifact (NameOf, or a package's name in its + // ecosystem). + Names map[string]string +} + +// NameOf is the name artifact of module was numbered under in this numbering. +func (g Numbering) NameOf(module, artifact string) string { + if name := g.Names[artifact]; name != "" { + return name + } + return NameOf(module, artifact) +} + +// Narration is one line the builder says in its build log (design 51: the build log says a version was kept, and +// why), and what it narrates. +type Narration struct{ Line, Why string } + +// What a narration of the build log narrates (the build.said entry's why). +const ( + SaidNumbered = "numbered" + SaidKept = "kept" + SaidWaiting = "waiting" + SaidUnnumbered = "unnumbered" + SaidUnanswered = "tag ask unanswered" + SaidOldFormClosed = "old-form-closed" +) + +// narrationOf is a decision's narration. +func narrationOf(name string, d Decision) Narration { + why := SaidNumbered + switch { + case d.Keep: + why = SaidKept + case d.Wait: + why = SaidWaiting + case d.Unnumbered: + why = SaidUnnumbered + } + return Narration{Line: name + ": " + d.Why, Why: why} +} + +// Numberer numbers trunk builds at their claim (design 51 *The versions*, DLV-B2, B3): it asks a name's floor only +// when versions holds nothing for it, records a silent floor as a wait (FloorWaits, woken by its source's event), and +// says each new number (Said, the event version.numbered). +type Numberer struct { + Versions Versions + Floor FloorSources + Waits *FloorWaits + // Holder is the module holding a seat, whose health condition's clear wakes a silent seat source; nil names none. + Holder func(seat string) string + // Said is told each version newly numbered; nil tells nobody. + Said func(builds.VersionNumbered) + // Names answers a package's name in its ecosystem; nil numbers a package as NameOf names it. + Names PackageNames + // Publishing is told each version Built recorded that is still numbered, its holder's word not heard yet: the + // builder arms its publish bound (publish.go); nil arms none. + Publishing func(ctx context.Context, e Entry) + Now func() time.Time +} + +// Number numbers every numbered artifact of c. An error is a store that cannot be read or written: the claim is then +// built as before, unnumbered, and said. +func (n *Numberer) Number(ctx context.Context, c BuildClaim) (Numbering, error) { + out := Numbering{Versions: map[string]string{}, Names: map[string]string{}} + arts := append([]ClaimArtifact(nil), c.Artifacts...) + sort.Slice(arts, func(i, j int) bool { return arts[i].Artifact < arts[j].Artifact }) + for _, a := range arts { + seat, kind, ok := SeatOf(a.Kind, a.Language) + if !ok || (c.Tag != "" && kind != KindPackage) { + continue + } + name := NameOf(c.Module, a.Artifact) + if kind == KindPackage && n.Names != nil { + // A package is numbered, asked for its floor and published under its name in its ecosystem (design 32), the + // name the holder's builder_versions question and the seat's versions name it by. + named, known, err := n.Names(ctx, c.Module, a.Artifact) + if err != nil || !known { + why := "the indexer's packages state names no name of it in its ecosystem yet (it writes one at the module's next trunk merge)" + if err != nil { + why = "the indexer's packages state cannot be read: " + err.Error() + } + out.Unnumbered = append(out.Unnumbered, a.Artifact) + out.Said = append(out.Said, Narration{Line: name + ": built unnumbered: " + why, Why: SaidUnnumbered}) + continue + } + name = named + } + out.Names[a.Artifact] = name + entries, err := n.Versions.Of(ctx, name) + if err != nil { + return out, err + } + var floor Floor + var why map[string]string + if len(entries) == 0 { + floor, why = n.askFloor(ctx, seat, name, c) + } + d, e, err := n.Versions.Number(ctx, Claim{Name: name, Kind: kind, Series: c.Series, Module: c.Module, Fingerprint: c.Fingerprint, + Commit: c.Commit, Revision: c.Revision, Digest: c.Done[a.Artifact], Seat: seat, Merge: c.Merge, Floor: floor, Tag: c.Tag, Repository: c.Repository}) + if err != nil { + return out, err + } + out.Said = append(out.Said, narrationOf(name, d)) + switch { + case d.Wait: + n.wait(name, seat, floor, why) + out.Wait = name + ": " + d.Why + return out, nil + case d.Unnumbered: + n.wait(name, seat, floor, why) + out.Unnumbered = append(out.Unnumbered, a.Artifact) + continue + } + if n.Waits != nil { + n.Waits.Answered(name) + } + out.Versions[a.Artifact] = d.Version + if !d.Keep && n.Said != nil { + n.Said(NumberedOf(e, c.Head)) + } + } + return out, nil +} + +func (n *Numberer) askFloor(ctx context.Context, seat, name string, c BuildClaim) (Floor, map[string]string) { + return n.Floor.Ask(ctx, FloorOf{Seat: seat, Name: name, Repository: c.Repository, Prefix: TagPrefix(c.Path)}) +} + +func (n *Numberer) wait(name, seat string, f Floor, why map[string]string) { + if n.Waits == nil { + return + } + holder := "" + if n.Holder != nil { + holder = n.Holder(seat) + } + now := time.Now() + if n.Now != nil { + now = n.Now() + } + n.Waits.Wait(name, seat, holder, f, why, now) +} + +// Built records the digest each numbered artifact was built with, on an entry that holds none yet or was never published: what the holder's +// builder_versions question compares (ADR 0605 §4). +func (n *Numberer) Built(ctx context.Context, c BuildClaim, got Numbering, digests map[string]string) error { + var errs []string + for artifact, version := range got.Versions { + d := digests[artifact] + if d == "" { + continue + } + e, err := n.Versions.Mark(ctx, got.NameOf(c.Module, artifact), version, func(e *Entry) { + // The new digest replaces the recorded one only while no published was said for the number (design 51 + // *Tools of the builder*): a rebuild of a gone file under the same number. + if e.Digest == "" || e.State == Numbered || e.State == Refused { + e.Digest = d + } + }) + if err != nil { + errs = append(errs, err.Error()) + continue + } + if e.State == Numbered && n.Publishing != nil { + n.Publishing(ctx, e) + } + } + if len(errs) > 0 { + sort.Strings(errs) + return fmt.Errorf("the built digests could not be recorded: %s", strings.Join(errs, "; ")) + } + return nil +} diff --git a/modules/mesh-delivery-builder/internal/versions/floor.go b/modules/mesh-delivery-builder/internal/versions/floor.go new file mode 100644 index 00000000..8beea79b --- /dev/null +++ b/modules/mesh-delivery-builder/internal/versions/floor.go @@ -0,0 +1,319 @@ +package versions + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "slices" + "sort" + "strings" + "sync" + "time" +) + +// FloorSources are the three sources of a name's floor (design 51 *The floor*): each a tool call that answers the +// versions it knows of a name, or an error when it does not answer. +type FloorSources struct { + // Seat is `seat:.versions` of the registry seat the name is published to. + Seat func(ctx context.Context, seat, name string) ([]string, error) + // Tags is the forge bridge's bridge_tags: the git tags of the repository the name is built from, under prefix. + Tags func(ctx context.Context, repository, prefix string) ([]string, error) + // Requirements is the indexer's indexer_requirements: the highest pseudo-versions consumers require. + Requirements func(ctx context.Context, name string) ([]string, error) +} + +// FloorOf names what a floor is asked for: the name on its seat, and the repository and tag prefix of its git tags. +type FloorOf struct{ Seat, Name, Repository, Prefix string } + +// Ask asks the three sources of a name; a source that errs, or is not wired, is named in Silent with its error in why. +func (s FloorSources) Ask(ctx context.Context, of FloorOf) (Floor, map[string]string) { + seat, name := of.Seat, of.Name + var f Floor + why := map[string]string{} + ask := func(source string, call func() ([]string, error), into *[]string) { + if call == nil { + f.Silent, why[source] = append(f.Silent, source), "not asked: the builder has no path to it" + return + } + got, err := call() + if err != nil { + f.Silent, why[source] = append(f.Silent, source), err.Error() + return + } + *into = got + } + var seatCall, tagsCall, reqCall func() ([]string, error) + if s.Seat != nil { + seatCall = func() ([]string, error) { return s.Seat(ctx, seat, name) } + } + if s.Tags != nil { + tagsCall = func() ([]string, error) { return s.Tags(ctx, of.Repository, of.Prefix) } + } + if s.Requirements != nil { + reqCall = func() ([]string, error) { return s.Requirements(ctx, name) } + } + ask(SourceSeat, seatCall, &f.Seat) + ask(SourceTags, tagsCall, &f.Tags) + ask(SourceRequirements, reqCall, &f.Requirements) + return f, why +} + +// What wakes a name waiting on its floor, never a timer (design 51, DLV-B3b): the source that was silent saying +// something, or its module's health condition clearing. +const ( + // WakePublished is the holder's `published` on the builder's durable consumer of `.events`. + WakePublished = "published" + // WakeForge is the forge bridge's next message on its stream `forge`. + WakeForge = "forge" + // WakeAccepted is the indexer's next `manifest-accepted`. + WakeAccepted = "manifest-accepted" + // WakeCleared is the condition keeper's clear of a silent module's health condition on `mesh-conditions`. + WakeCleared = "condition-cleared" + // WakeByHand is builder_floor_again, a hand act. + WakeByHand = "by hand" +) + +// sourceOfWake is the floor source each event says something of; a clear names the source by its module. +var sourceOfWake = map[string]string{WakePublished: SourceSeat, WakeForge: SourceTags, WakeAccepted: SourceRequirements} + +// ModuleOfSource is the module whose health condition's clear wakes a silent source. +var ModuleOfSource = map[string]string{ + SourceTags: "mesh-delivery-forge-bridge", + SourceRequirements: "mesh-delivery-indexer", +} + +// FloorWait is one name waiting on its floor: the sources still silent and why, since when; the finding the builder +// writes names them, cleared once all three answer. +type FloorWait struct { + Name string `json:"name"` + Seat string `json:"seat"` + Silent []string `json:"silent"` + Why map[string]string `json:"why,omitempty"` + Since time.Time `json:"since"` + // Holder is the module holding the name's seat: its condition's clear wakes the seat source. + Holder string `json:"holder,omitempty"` +} + +// Finding is the floor finding's words. +func (w FloorWait) Finding() string { + var parts []string + for _, s := range w.Silent { + p := s + if why := w.Why[s]; why != "" { + p += " (" + why + ")" + } + parts = append(parts, p) + } + return fmt.Sprintf("the floor of %s is not known since %s: %s did not answer; nothing of it is numbered until all three answer", + w.Name, w.Since.UTC().Format(time.RFC3339), strings.Join(parts, ", ")) +} + +// The builder's state floor-waits (DLV-B4): each name waiting on its floor, keyed by its escaped name, so a restarted +// builder still knows which names its silent sources' events wake; builder_floor_waits reads it. +const ( + FloorWaitsState = "floor-waits" + FloorWaitsBucket = "mesh-delivery-builder_floor-waits" + FloorWaitsTool = "builder_floor_waits" +) + +// FloorWaitKey is a wait's key in floor-waits. +func FloorWaitKey(name string) string { return EscapeToken(name) } + +// FloorKeeper keeps the waits on the bus: Put a wait's value, Delete its key. +type FloorKeeper interface { + Put(key string, value []byte) error + Delete(key string) error +} + +// FloorWaits are the names waiting on their floor. Wake answers which names to ask again for an event, Answered drops +// a name once all three answered; the builder's finding for each is the wait's Finding. Once Kept, every change is +// written to the state floor-waits as well. +type FloorWaits struct { + mu sync.Mutex + waits map[string]FloorWait + keeper FloorKeeper + // Logf says a wait that could not be kept; nil says nothing. + Logf func(string, ...any) + // Said is told each wait recorded (raised) and each answered (not raised): the builder's finding floor-silent. + Said func(w FloorWait, raised bool) +} + +// FloorSilent is the kind of the floor finding. +const FloorSilent = "floor-silent" + +// Keep binds the state: the waits it holds that are not known here are taken in (a restarted builder's), every wait +// known here is written to it, and every change from now on is. +func (fw *FloorWaits) Keep(k FloorKeeper, held []FloorWait) { + fw.mu.Lock() + if fw.waits == nil { + fw.waits = map[string]FloorWait{} + } + for _, w := range held { + if _, had := fw.waits[w.Name]; !had && w.Name != "" { + fw.waits[w.Name] = w + } + } + fw.keeper = k + all := make([]FloorWait, 0, len(fw.waits)) + for _, w := range fw.waits { + all = append(all, w) + } + fw.mu.Unlock() + for _, w := range all { + fw.put(k, w) + } +} + +func (fw *FloorWaits) put(k FloorKeeper, w FloorWait) { + if k == nil { + return + } + data, err := json.Marshal(w) + if err == nil { + err = k.Put(FloorWaitKey(w.Name), data) + } + if err != nil && fw.Logf != nil { + fw.Logf("the floor wait of %s is not kept in %s (a restart forgets it until its next claim): %v", w.Name, FloorWaitsState, err) + } +} + +// DecodeFloorWait reads one value of floor-waits. +func DecodeFloorWait(raw []byte) (FloorWait, error) { + var w FloorWait + if err := json.Unmarshal(raw, &w); err != nil { + return FloorWait{}, err + } + if w.Name == "" { + return FloorWait{}, errors.New("a floor wait names no name") + } + return w, nil +} + +// Wait records that name's floor is not known. +func (fw *FloorWaits) Wait(name, seat, holder string, f Floor, why map[string]string, now time.Time) FloorWait { + fw.mu.Lock() + if fw.waits == nil { + fw.waits = map[string]FloorWait{} + } + w, had := fw.waits[name] + if !had { + w.Since = now + } + w.Name, w.Seat, w.Holder, w.Silent, w.Why = name, seat, holder, slices.Clone(f.Silent), why + fw.waits[name] = w + k := fw.keeper + fw.mu.Unlock() + fw.put(k, w) + if fw.Said != nil { + fw.Said(w, true) + } + return w +} + +// Answered drops name: its floor is known. It says whether the name waited, so its finding is cleared. +func (fw *FloorWaits) Answered(name string) bool { + fw.mu.Lock() + w, had := fw.waits[name] + delete(fw.waits, name) + k := fw.keeper + fw.mu.Unlock() + if had && fw.Said != nil { + fw.Said(w, false) + } + if had && k != nil { + if err := k.Delete(FloorWaitKey(name)); err != nil && fw.Logf != nil { + fw.Logf("the floor wait of %s is not cleared from %s: %v", name, FloorWaitsState, err) + } + } + return had +} + +// FloorWaitsAnswer is what builder_floor_waits answers: every name waiting on its floor, oldest first, with its finding. +type FloorWaitsAnswer struct { + Waits []FloorWaitRead `json:"waits"` +} + +// FloorWaitRead is one wait as builder_floor_waits shows it. +type FloorWaitRead struct { + FloorWait + Finding string `json:"finding"` +} + +// AnswerFloorWaits is builder_floor_waits, read from the state floor-waits. +func AnswerFloorWaits(r Reader) (FloorWaitsAnswer, error) { + keys, err := r.Keys() + if err != nil { + return FloorWaitsAnswer{}, err + } + out := FloorWaitsAnswer{Waits: []FloorWaitRead{}} + for _, k := range keys { + raw, ok, err := r.Get(k) + if err != nil { + return FloorWaitsAnswer{}, err + } + if !ok { + continue + } + w, err := DecodeFloorWait(raw) + if err != nil { + return FloorWaitsAnswer{}, fmt.Errorf("%s.%s: %w", FloorWaitsState, k, err) + } + out.Waits = append(out.Waits, FloorWaitRead{FloorWait: w, Finding: w.Finding()}) + } + sort.Slice(out.Waits, func(i, j int) bool { + if !out.Waits[i].Since.Equal(out.Waits[j].Since) { + return out.Waits[i].Since.Before(out.Waits[j].Since) + } + return out.Waits[i].Name < out.Waits[j].Name + }) + return out, nil +} + +// FloorWaitsDescription is builder_floor_waits' description. +const FloorWaitsDescription = "The names waiting on their floor (the builder's state floor-waits, design 51 *The floor*), oldest first: " + + "each name, its registry seat, the sources still silent and why, since when, and the finding the builder says for it. " + + "Nothing of a waiting name is numbered until all three sources answer; builder_floor_again asks one again by hand. Read-only." + +// Wake answers the names to ask their floor again on an event: kind one of the Wake constants; seat the seat a +// `published` came on; module the module whose condition cleared. A name is woken only when the event says something +// of a source it waits on; WakeByHand with a name wakes that name alone. +func (fw *FloorWaits) Wake(kind, seat, module, name string) []string { + fw.mu.Lock() + defer fw.mu.Unlock() + var out []string + for n, w := range fw.waits { + switch kind { + case WakeByHand: + if n == name { + out = append(out, n) + } + case WakeCleared: + for _, s := range w.Silent { + if ModuleOfSource[s] == module || (s == SourceSeat && w.Holder != "" && w.Holder == module) { + out = append(out, n) + break + } + } + default: + src := sourceOfWake[kind] + if slices.Contains(w.Silent, src) && (kind != WakePublished || seat == "" || seat == w.Seat) { + out = append(out, n) + } + } + } + sort.Strings(out) + return out +} + +// Waiting is every wait, by name. +func (fw *FloorWaits) Waiting() []FloorWait { + fw.mu.Lock() + defer fw.mu.Unlock() + out := make([]FloorWait, 0, len(fw.waits)) + for _, w := range fw.waits { + out = append(out, w) + } + sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) + return out +} diff --git a/modules/mesh-delivery-builder/internal/versions/heard.go b/modules/mesh-delivery-builder/internal/versions/heard.go new file mode 100644 index 00000000..573f4047 --- /dev/null +++ b/modules/mesh-delivery-builder/internal/versions/heard.go @@ -0,0 +1,95 @@ +package versions + +import ( + "context" + "fmt" +) + +// The registry seats' events the builder hears on its durable consumer of each `.events` (design 51 *How a +// publish ends*, DLV-B4). +const ( + HeardPublished = "published" + HeardRefused = "refused" + HeardRemoved = "removed" + HeardKept = "kept" +) + +// Outcome is the body of a registry seat's published, refused and removed (mesh-contracts parts/registry Outcome), the +// fields the builder reads. +type Outcome struct { + Name string `json:"name"` + Version string `json:"version,omitempty"` + Digest string `json:"digest"` + Kind string `json:"kind"` + Holder string `json:"holder"` + Reason string `json:"reason,omitempty"` + // Tags are, on kept, the other tags still naming its digest. + Tags []string `json:"tags,omitempty"` +} + +// RemoveOutcome is what a seat's remove answered: kept whole (another tag names its digest), with why and those tags; or +// asked, the seat saying removed on its events. +type RemoveOutcome struct { + Kept bool + Reason string + Tags []string +} + +// keep marks a tagged version kept by its seat, so it is asked no more. +func (n *Numberer) keep(ctx context.Context, e Entry, reason string, tags []string) error { + if reason == "" { + reason = "shared-digest" + } + _, err := n.Versions.Mark(ctx, e.Name, e.Version, func(x *Entry) { + x.State, x.Reason, x.KeptWith = Kept, reason, append([]string(nil), tags...) + }) + return err +} + +// Heard takes one seat event in: a numbered version's entry turns published (with its digest when it held none), +// refused with the holder's reason, removed with its reason and the event's id, or kept (another tag names its digest) +// with the other tags; a by-digest publish (no version) +// marks nothing. It answers the names waiting on their floor that the event wakes: a `published` says the seat +// answers (design 51 *The floor*). +func (n *Numberer) Heard(ctx context.Context, seat, event, id string, o Outcome) ([]string, error) { + var wake []string + if event == HeardPublished && n.Waits != nil { + wake = n.Waits.Wake(WakePublished, seat, "", "") + } + if o.Version == "" { + return wake, nil + } + var change func(*Entry) + switch event { + case HeardPublished: + change = func(e *Entry) { + e.State, e.Reason = Published, "" + if e.Digest == "" { + e.Digest = o.Digest + } + } + case HeardRefused: + change = func(e *Entry) { + if e.State != Published { + e.State, e.Reason, e.RefusedDigest = Refused, o.Reason, o.Digest + } + } + case HeardRemoved: + change = func(e *Entry) { e.State, e.Reason, e.RemovedBy = Removed, o.Reason, id } + case HeardKept: + change = func(e *Entry) { + if e.State != Removed { + e.State, e.Reason, e.KeptWith = Kept, o.Reason, append([]string(nil), o.Tags...) + if e.Reason == "" { + e.Reason = "shared-digest" + } + } + } + default: + return wake, fmt.Errorf("%s says %q, which the builder does not hear", seat, event) + } + if _, err := n.Versions.Mark(ctx, o.Name, o.Version, change); err != nil { + return wake, fmt.Errorf("%s's %s of %s@%s: %w", seat, event, o.Name, o.Version, err) + } + return wake, nil +} diff --git a/modules/mesh-delivery-builder/internal/versions/names.go b/modules/mesh-delivery-builder/internal/versions/names.go new file mode 100644 index 00000000..66dd54d0 --- /dev/null +++ b/modules/mesh-delivery-builder/internal/versions/names.go @@ -0,0 +1,72 @@ +package versions + +import ( + "context" + "sort" + "strings" + + "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" +) + +// PackagesState is the indexer's packages state as the builder reads it (its manifest's reads grant): its keys, and one +// key's value (found false when it holds none). +type PackagesState interface { + Keys(ctx context.Context) ([]string, error) + Get(ctx context.Context, key string) (value []byte, found bool, err error) +} + +// NamesIn are a package's names in its ecosystem as the indexer's packages state holds them (design 32): the name of the +// series key that names module and artifact as what it is built from. A series key is named `.`; a key +// with no dot is a mesh Go module's published packages, not a series. A value that does not decode is passed over. +func NamesIn(s PackagesState) PackageNames { + return func(ctx context.Context, module, artifact string) (string, bool, error) { + keys, err := s.Keys(ctx) + if err != nil { + return "", false, err + } + sort.Strings(keys) + built := NameOf(module, artifact) + for _, key := range keys { + if !isSeriesKey(key) { + continue + } + value, found, err := s.Get(ctx, key) + if err != nil { + return "", false, err + } + if !found { + continue + } + v, err := meshcatalog.DecodePackagesByPackageSeriesValue(value) // the indexer's value of a series key (mesh-sdk's contract, J4) + if err != nil || v.Package != built { + continue + } + return v.Name, true, nil + } + return "", false, nil + } +} + +// NamedModule is the module a value of the indexer's packages state names a package's name for (design 32), and that +// name: a series key whose value names a name and the / it is built from. The builder looks at that module again when +// the value is heard, so a done build that left the package unnumbered (its name not known at its claim) is numbered then. +func NamedModule(key string, value []byte) (module, name string, ok bool) { + if !isSeriesKey(key) { + return "", "", false + } + v, err := meshcatalog.DecodePackagesByPackageSeriesValue(value) + if err != nil || v.Name == "" { + return "", "", false + } + module, artifact, cut := strings.Cut(v.Package, "/") + if !cut || module == "" || artifact == "" { + return "", "", false + } + return module, v.Name, true +} + +// isSeriesKey says whether a key of the indexer's packages state is a series key (`.`), not a per-module one. +func isSeriesKey(key string) bool { + _, _, ok := meshcatalog.ParsePackagesByPackageSeriesKey(key) + return ok +} diff --git a/modules/mesh-delivery-builder/internal/versions/oldform.go b/modules/mesh-delivery-builder/internal/versions/oldform.go new file mode 100644 index 00000000..6f435865 --- /dev/null +++ b/modules/mesh-delivery-builder/internal/versions/oldform.go @@ -0,0 +1,158 @@ +package versions + +import ( + "encoding/json" + "errors" + "fmt" + "slices" + "sort" + "strings" + "time" + + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds" + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds/forms" + "git.novox.be/novox/mesh-delivery/sdk/window" +) + +// The old reference form's window (design 51 *Every build keeps the old reference form*, ADR 0624 §1, ADR 0618 §2-§4, +// DLV-B9). Which forms a build key keeps is mesh-delivery's sdk/forms (Kept, Choose), the counted nodes' statements the +// builder's state engine-reads; this is only the window over them: open while a counted node-engine, present or away, +// reads of some artifact kind none of the newest form the builder knows for it, so an older form stays in new build keys +// for it; closed, said once with `old-form-closed` and a log line, when none does. It moves on what moves a statement +// (the node-engine seat's verb `manifest-reads` and its event `manifest-reads-changed`), the assignments, a hand drop and +// each build key written; never on a timer. + +// OldFormState is the builder's state of the window: one key, OldFormKey, an OldFormWindow. builder_old_form reads it. +const ( + OldFormState = "old-form" + OldFormKey = "window" +) + +// OldFormBucket is that state's bucket. +const OldFormBucket = builds.BuilderModule + "_" + OldFormState + +// OldFormHeld is the finding of a node holding the window open for FindingAfter, about that node, cleared when the +// window closes or the node no longer holds it. +const OldFormHeld = "old-form-held" + +// Holding are the counted nodes, those dropped by hand left out, whose engine reads, of some artifact kind fs knows, not +// the newest form of that kind: the nodes for which an older form stays in new build keys (forms.ArtifactForms.Kept). A +// node stating nothing reads the first forms (fs.Reads), sorted. +func Holding(fs forms.ArtifactForms, counted map[string][]string, dropped map[string]bool) []string { + newest := map[string]string{} // kind -> newest form + var kinds []string + for _, f := range fs { + for _, k := range f.Kinds { + if _, ok := newest[k]; !ok { + kinds = append(kinds, k) + } + newest[k] = f.Name + } + } + var out []string + for node, stated := range counted { + if dropped[node] { + continue + } + for _, k := range kinds { + if !fs.Reads(stated, newest[k]) { + out = append(out, node) + break + } + } + } + sort.Strings(out) + return out +} + +// OldFormWindow is the window's record: whether it is open, who holds it and since when (window.WindowHeld, the part +// every window keeper shares), and the nodes dropped by hand; what builder_old_form answers and what `old-form-closed` +// carries when it closes. +type OldFormWindow struct { + window.WindowHeld + Dropped []Dropped `json:"dropped,omitempty"` +} + +// Dropped is one retired node dropped from the count by hand (builder_old_form): a hand act with who and why. +type Dropped struct { + Node string `json:"node"` + By string `json:"by"` + Why string `json:"why"` + At time.Time `json:"at"` +} + +// OldFormClosedBody is the body of OldFormClosedEvent (design 51): when, the nodes that held it last and the nodes dropped +// by hand. +type OldFormClosedBody struct { + At time.Time `json:"at"` + LastHolding []string `json:"last_holding,omitempty"` + Dropped []Dropped `json:"dropped,omitempty"` +} + +// FindingAfter is how long a node holds the window open before it is the builder's finding naming the node. +const FindingAfter = 7 * 24 * time.Hour + +// Step moves the window by mesh-sdk's one window rule (window.StepWindow): named is every counted node the read named, +// those dropped by hand included, and holding those of them holding the window (Holding). A read naming no node (no +// assignments read yet, an empty node_haves) judges nothing, so it never closes the window. It answers the new window, +// whether it closed in this step (so the builder says `old-form-closed` and its log line once), and the nodes holding it +// past FindingAfter (the finding, cleared for every other node). +func (w OldFormWindow) Step(named, holding []string, now time.Time) (next OldFormWindow, closed bool, overdue []string) { + held, closed, overdue := window.StepWindow(w.WindowHeld, named, holding, now, FindingAfter) + return OldFormWindow{WindowHeld: held, Dropped: w.Dropped}, closed, overdue +} + +// Drop drops a retired node from the count by hand; dropping it again changes nothing. +func (w OldFormWindow) Drop(node, by, why string, at time.Time) (OldFormWindow, error) { + node, why = strings.TrimSpace(node), strings.TrimSpace(why) + if node == "" || why == "" { + return w, errors.New("name the retired node (node) and say why it leaves the count (why)") + } + if w.DroppedSet()[node] { + return w, nil + } + w.Dropped = append(slices.Clone(w.Dropped), Dropped{Node: node, By: by, Why: why, At: at.UTC()}) + return w, nil +} + +// DroppedSet is the dropped nodes, as Holding takes them. +func (w OldFormWindow) DroppedSet() map[string]bool { + out := map[string]bool{} + for _, d := range w.Dropped { + out[d.Node] = true + } + return out +} + +// Closing is the event's body of a window that closed in a Step. +func (w OldFormWindow) Closing() OldFormClosedBody { + return OldFormClosedBody{At: w.Closed, LastHolding: w.LastHolding, Dropped: w.Dropped} +} + +// DecodeOldFormWindow reads the state's value; none is a closed window no node holds. +func DecodeOldFormWindow(raw []byte) (OldFormWindow, error) { + var w OldFormWindow + if len(raw) == 0 { + return w, nil + } + if err := json.Unmarshal(raw, &w); err != nil { + return OldFormWindow{}, fmt.Errorf("the old-form window is not readable: %w", err) + } + return w, nil +} + +// OldFormDescription is builder_old_form's description. +const OldFormDescription = "The old reference form's window (design 51 *Every build keeps the old reference form*, ADR 0624): whether it is open, " + + "the counted nodes, present or away, whose node-engine reads an older reference form alone and since when (a node holding it seven days " + + "is the builder's finding " + OldFormHeld + "), when it last closed and who held it then, and the retired nodes dropped from the count by " + + "hand. With drop (a node) and why, drops that retired node from the count by hand, a hand act the builder answers; a node still placed " + + "leaves the count by itself only when the assignments no longer names it. builder_engine_reads shows what each node's engine states." + +// AnswerOldForm is builder_old_form's read: the window as the state holds it. +func AnswerOldForm(r Reader) (OldFormWindow, error) { + raw, ok, err := r.Get(OldFormKey) + if err != nil || !ok { + return OldFormWindow{}, err + } + return DecodeOldFormWindow(raw) +} diff --git a/modules/mesh-delivery-builder/internal/versions/publish.go b/modules/mesh-delivery-builder/internal/versions/publish.go new file mode 100644 index 00000000..d185f6bf --- /dev/null +++ b/modules/mesh-delivery-builder/internal/versions/publish.go @@ -0,0 +1,107 @@ +package versions + +import ( + "encoding/json" + "fmt" + "sort" + "strings" + "time" +) + +// **A publish that ends badly is loud** (design 51 *Health and conditions*; review round 1): the builder raises the +// finding publish-refused when a holder says refused, and publish-unanswered when a version it built and numbered is +// neither published nor refused within PublishBound. Each is about the name, so a later published of any version of it +// clears both. A seat's outcome that does not read is the finding seat-outcome-unread about the seat, cleared by its +// next outcome that reads. +// +// The bound is a key of the builder's state bounds per version (PublishBoundKey), written with its own time to live +// when the build is done and the version is still numbered, deleted at the holder's word; the bus's marker of its end, +// heard on the watch, is what raises publish-unanswered while the version is still numbered. No timer. +const ( + // BoundsState is the builder's state of its expiring keys, each with its own time to live: a publish's bound + // (PublishBoundKey) and a node's seven days holding the old form's window (OldFormBoundKey). Its end, the bus's + // marker heard on the watch, is what acts. + BoundsState = "bounds" + BoundsBucket = "mesh-delivery-builder_bounds" + // PublishBound is the build module's own wait for the holder's word (design 51 *How a publish ends*: at most + // 10 minutes), so a version unanswered past it is one the holder never answered. + PublishBound = 10 * time.Minute + + PublishRefused = "publish-refused" + PublishUnanswered = "publish-unanswered" + SeatOutcomeUnread = "seat-outcome-unread" +) + +// The prefixes of the keys of BoundsState. +const ( + publishBoundPrefix = "publish." + oldFormBoundPrefix = "old-form." +) + +// PublishBoundKey is the bound of name's version: `publish.`. +func PublishBoundKey(name, version string) string { return publishBoundPrefix + Key(name, version) } + +// OldFormBoundKey is the bound of node's seven days holding the old form's window: `old-form.`. +func OldFormBoundKey(node string) string { return oldFormBoundPrefix + EscapeToken(node) } + +// PublishBoundOf is the versions key a publish's bound names, and whether key is one; a key that is no publish's +// bound is an old form's (IsOldFormBound) or nothing the builder wrote. +func PublishBoundOf(key string) (string, bool) { return strings.CutPrefix(key, publishBoundPrefix) } + +// IsOldFormBound says key is a node's bound of the old form's window: its end steps the window, which raises the +// finding for every node past FindingAfter. +func IsOldFormBound(key string) bool { return strings.HasPrefix(key, oldFormBoundPrefix) } + +// BoundsTool is builder_bounds: every key of BoundsState, what it bounds and when it ends. +const BoundsTool = "builder_bounds" + +// BoundsDescription is builder_bounds' description. +const BoundsDescription = "The builder's expiring keys, its state " + BoundsState + " (design 51 *Health and conditions*): each key with what it " + + "bounds (a version's publish, whose end unanswered is the finding " + PublishUnanswered + "; or a node's seven days holding the old " + + "form's window, whose end is the finding " + OldFormHeld + "), when it ends, and how long is left. The bus ends each key by its own time to live." + +// Bound is a key's value in BoundsState: what it bounds, in plain words, and when it ends. +type Bound struct { + Bounds string `json:"bounds"` + Ends time.Time `json:"ends"` +} + +// EncodeBound is a Bound's bytes. +func EncodeBound(what string, ends time.Time) []byte { + raw, _ := json.Marshal(Bound{Bounds: what, Ends: ends.UTC()}) + return raw +} + +// BoundRow is one row of builder_bounds. +type BoundRow struct { + Key string `json:"key"` + Bounds string `json:"bounds"` + Ends time.Time `json:"ends,omitzero"` + Left string `json:"left,omitempty"` +} + +// AnswerBounds is builder_bounds: every key, sorted, with what it bounds and when it ends; a value that does not read +// says so in bounds. +func AnswerBounds(r Reader, now time.Time) ([]BoundRow, error) { + keys, err := r.Keys() + if err != nil { + return nil, fmt.Errorf("the state %s cannot be listed: %w", BoundsState, err) + } + sort.Strings(keys) + out := []BoundRow{} + for _, k := range keys { + raw, ok, err := r.Get(k) + if err != nil || !ok { + continue + } + var b Bound + row := BoundRow{Key: k} + if json.Unmarshal(raw, &b) != nil || b.Bounds == "" { + row.Bounds = "a value that does not read: " + string(raw) + } else { + row.Bounds, row.Ends, row.Left = b.Bounds, b.Ends, b.Ends.Sub(now).Round(time.Second).String() + } + out = append(out, row) + } + return out, nil +} diff --git a/modules/mesh-delivery-builder/internal/versions/store.go b/modules/mesh-delivery-builder/internal/versions/store.go new file mode 100644 index 00000000..4f064420 --- /dev/null +++ b/modules/mesh-delivery-builder/internal/versions/store.go @@ -0,0 +1,230 @@ +package versions + +import ( + "context" + "errors" + "fmt" + "time" +) + +// AllFilter is the key filter of every version of every name: two tokens. +const AllFilter = "*.*" + +// ErrConflict is what a Store answers when a Create finds the key or an Update finds another revision. +var ErrConflict = errors.New("the key exists or changed") + +// Store is the bucket: create and compare-and-set, as the builder's builds bucket (internal/build.Store). +type Store interface { + Get(ctx context.Context, key string) (value []byte, revision uint64, found bool, err error) + Create(ctx context.Context, key string, value []byte) (uint64, error) + Update(ctx context.Context, key string, value []byte, revision uint64) (uint64, error) + // KeysOf are the keys under a filter (NameFilter, or AllFilter for every key). + KeysOf(ctx context.Context, filter string) ([]string, error) +} + +// Versions is the builder's state. +type Versions struct { + Store Store + Now func() time.Time +} + +// Of is every entry of name. +func (v Versions) Of(ctx context.Context, name string) ([]Entry, error) { + keys, err := v.Store.KeysOf(ctx, NameFilter(name)) + if err != nil { + return nil, fmt.Errorf("the versions of %s cannot be listed: %w", name, err) + } + var out []Entry + for _, k := range keys { + raw, _, found, err := v.Store.Get(ctx, k) + if err != nil { + return nil, fmt.Errorf("%s: %w", k, err) + } + if !found { + continue + } + e, err := Decode(raw) + if err != nil { + return nil, fmt.Errorf("%s holds what is not a version: %w", k, err) + } + out = append(out, e) + } + return InOrder(out), nil +} + +// Claim is one numbering asked of the builder. +type Claim struct { + Name, Kind, Series, Module, Fingerprint, Commit, Revision, Digest, Seat string + // Repository is the module's repository, kept on a tagged version for its branch's end. + Repository string + // Tag asks a tagged version for this cleaned tag; empty for a trunk version. + Tag string + Merge uint64 + Floor Floor + // Fresh numbers anew a fingerprint or revision already numbered: builder_publish_again after a published (design 51 + // *Tools of the builder*). + Fresh bool +} + +// Number numbers a claim under compare-and-set: two claims of one name never get one number, the second reading the +// first's key and taking the next (ADR 0606 §2). It answers the decision and, when it numbered or kept a version, its +// entry. Attempts are bounded by the claims the name can meet at once; a store that keeps conflicting is an error. +func (v Versions) Number(ctx context.Context, c Claim) (Decision, Entry, error) { + if c.Series == "" { + c.Series = DefaultSeries + } + for attempt := 0; attempt < 16; attempt++ { + entries, err := v.Of(ctx, c.Name) + if err != nil { + return Decision{}, Entry{}, err + } + var d Decision + if c.Tag != "" { + if !c.Floor.Known() && len(entries) == 0 { + return Decision{Wait: true, Why: "the tagged version waits for its floor"}, Entry{}, nil + } + ver := Tagged(entries, c.Series, c.Tag, c.Floor) + d = Decision{Version: ver, Publish: true, Why: "tagged " + ver} + for _, e := range entries { + // The same fingerprint claimed again for its tag (a check's build looked at again) keeps its number. + if e.Tag == CleanTag(c.Tag) && e.Fingerprint == c.Fingerprint && e.State != Removed { + d = Decision{Version: e.Version, Keep: true, Publish: true, Why: "tagged " + e.Version + ", kept for this fingerprint"} + break + } + } + } else { + d = Decide(fresh(entries, c), c.Kind, c.Series, c.Fingerprint, c.Revision, c.Floor) + } + if d.Version == "" { + return d, Entry{}, nil + } + if d.Keep { + for _, e := range entries { + if e.Version == d.Version { + return d, e, nil + } + } + } + e := Entry{Name: c.Name, Kind: c.Kind, Series: c.Series, Version: d.Version, Tag: c.Tag, Module: c.Module, + Fingerprint: c.Fingerprint, Commit: c.Commit, Revision: c.Revision, Digest: c.Digest, Seat: c.Seat, + State: Numbered, At: v.now(), Merge: c.Merge} + if c.Tag != "" { + e.Repository = c.Repository + } + raw, err := Encode(e) + if err != nil { + return Decision{}, Entry{}, err + } + _, err = v.Store.Create(ctx, Key(c.Name, d.Version), raw) + if errors.Is(err, ErrConflict) { + continue // another claim took this number: read again and take the next + } + if err != nil { + return Decision{}, Entry{}, fmt.Errorf("%s@%s: %w", c.Name, d.Version, err) + } + return d, e, nil + } + return Decision{}, Entry{}, fmt.Errorf("%s: the versions kept changing under 16 claims", c.Name) +} + +// Mark moves one version to a new state by compare-and-set, as the holders' events say (published, refused, removed): +// change edits the entry read; a version the state does not hold is an error naming it. +func (v Versions) Mark(ctx context.Context, name, version string, change func(*Entry)) (Entry, error) { + key := Key(name, version) + for attempt := 0; attempt < 16; attempt++ { + raw, rev, found, err := v.Store.Get(ctx, key) + if err != nil { + return Entry{}, fmt.Errorf("%s: %w", key, err) + } + if !found { + return Entry{}, fmt.Errorf("%s@%s is not numbered by the builder", name, version) + } + e, err := Decode(raw) + if err != nil { + return Entry{}, fmt.Errorf("%s holds what is not a version: %w", key, err) + } + change(&e) + e.At = v.now() + out, err := Encode(e) + if err != nil { + return Entry{}, err + } + if _, err := v.Store.Update(ctx, key, out, rev); errors.Is(err, ErrConflict) { + continue + } else if err != nil { + return Entry{}, fmt.Errorf("%s: %w", key, err) + } + return e, nil + } + return Entry{}, fmt.Errorf("%s@%s kept changing under 16 marks", name, version) +} + +// Recorded is the holder's question through builder_versions (ADR 0605 §4): whether name, version and digest are a +// version the builder numbered. It answers the entry when they are, and why not otherwise. +func (v Versions) Recorded(ctx context.Context, name, version, digest string) (Entry, bool, string, error) { + raw, _, found, err := v.Store.Get(ctx, Key(name, version)) + if err != nil { + return Entry{}, false, "", err + } + if !found { + return Entry{}, false, fmt.Sprintf("%s@%s is not a version the builder numbered", name, version), nil + } + e, err := Decode(raw) + if err != nil { + return Entry{}, false, "", err + } + switch { + case e.State == Removed: + return e, false, fmt.Sprintf("%s@%s was removed (%s)", name, version, e.Reason), nil + case e.Digest != "" && digest != "" && e.Digest != digest: + return e, false, fmt.Sprintf("%s@%s is recorded with digest %s, not %s", name, version, e.Digest, digest), nil + } + return e, true, "", nil +} + +func (v Versions) now() time.Time { + if v.Now != nil { + return v.Now().UTC() + } + return time.Now().UTC() +} + +// fresh is entries as Decide reads them for c: with Fresh, the entries of c's fingerprint and revision still count for the +// next number but keep none. +func fresh(entries []Entry, c Claim) []Entry { + if !c.Fresh { + return entries + } + out := make([]Entry, len(entries)) + for i, e := range entries { + if e.Tag == "" && (e.Fingerprint == c.Fingerprint || (c.Revision != "" && e.Revision == c.Revision)) { + e.Fingerprint, e.Revision = "", "" + } + out[i] = e + } + return out +} + +// All is every entry of every name, by name in order: what builder_untag and the tag asks read. +func (v Versions) All(ctx context.Context) ([]Entry, error) { + keys, err := v.Store.KeysOf(ctx, AllFilter) + if err != nil { + return nil, fmt.Errorf("the versions cannot be listed: %w", err) + } + var out []Entry + for _, k := range keys { + raw, _, found, err := v.Store.Get(ctx, k) + if err != nil { + return nil, fmt.Errorf("%s: %w", k, err) + } + if !found { + continue + } + e, err := Decode(raw) + if err != nil { + return nil, fmt.Errorf("%s holds what is not a version: %w", k, err) + } + out = append(out, e) + } + return out, nil +} diff --git a/modules/mesh-delivery-builder/internal/versions/tagbound.go b/modules/mesh-delivery-builder/internal/versions/tagbound.go new file mode 100644 index 00000000..51cfdc83 --- /dev/null +++ b/modules/mesh-delivery-builder/internal/versions/tagbound.go @@ -0,0 +1,75 @@ +package versions + +import ( + "fmt" + "maps" + "slices" + "strings" + "time" +) + +// The bound of an unanswered tag ask (design 51 *Tagged versions*: "an ask the builder has not answered within five +// minutes is narrated in the build log with what it waits on"): a key of the state tag-ask-bounds, written with its own +// time to live, whose expiry the builder hears on its watch of the state. No timer: the bus ends the bound. +const ( + TagAskBoundsState = "tag-ask-bounds" + TagAskBoundsBucket = "mesh-delivery-builder_tag-ask-bounds" + TagAskBound = 5 * time.Minute +) + +// Unanswered says whether a tag ask still waits for its versions: kept, not an untag, none numbered yet. +func (t TagAsk) Unanswered() bool { + return !t.Untag && t.State == TagAsked && len(t.Versions) == 0 +} + +// WithNumbered is the tag ask once a check's build of its head numbered module's packages under its tag: numbered, each +// version by the name it was numbered under (names, by artifact; NameOf where it names none), the answer naming them. An +// untag, or an ask of another head, is left as it is. +func (t TagAsk) WithNumbered(module, head string, got, names map[string]string, at time.Time) (TagAsk, bool) { + if t.Untag || t.Head != head || len(got) == 0 || t.State == TagRemoving || t.State == TagRemoved { + return t, false + } + if t.Versions == nil { + t.Versions = map[string]string{} + } + changed := false + for artifact, v := range got { + name := Numbering{Names: names}.NameOf(module, artifact) + if t.Versions[name] != v { + t.Versions[name], changed = v, true + } + } + if !changed && t.State == TagNumbered { + return t, false + } + var said []string + for _, name := range slices.Sorted(maps.Keys(t.Versions)) { + said = append(said, name+" "+t.Versions[name]) + } + t.State, t.Answered = TagNumbered, at.UTC() + t.Answer = "numbered: " + strings.Join(said, ", ") + return t, true +} + +// NarrateUnanswered is the line said for a tag ask its bound found unanswered: since when, and what it waits on, the +// names waiting on their floor named with their silent sources. +func NarrateUnanswered(t TagAsk, waits []FloorWait, now time.Time) string { + on := "the check's build of " + short(t.Head) + " is not claimed yet (the checker builds a group member's head once it asks)" + var floors []string + for _, w := range waits { + floors = append(floors, w.Name+" waits on its floor ("+strings.Join(w.Silent, ", ")+" silent)") + } + if len(floors) > 0 { + slices.Sort(floors) + on += "; " + strings.Join(floors, "; ") + } + return fmt.Sprintf("the tag ask of %s %s at %s (%s) is not answered after %s: it waits on %s", + t.Repository, t.Branch, short(t.Head), t.Tag, now.Sub(t.At).Round(time.Second), on) +} + +func short(head string) string { + if len(head) > 12 { + return head[:12] + } + return head +} diff --git a/modules/mesh-delivery-builder/internal/versions/tools.go b/modules/mesh-delivery-builder/internal/versions/tools.go new file mode 100644 index 00000000..bb8d94f0 --- /dev/null +++ b/modules/mesh-delivery-builder/internal/versions/tools.go @@ -0,0 +1,111 @@ +package versions + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds" +) + +// The builder's tools of its versions (design 51 *Events of the builder*, *Tools of the builder*). +const ( + VersionsTool = "builder_versions" + PublishAgainTool = "builder_publish_again" + FloorAgainTool = "builder_floor_again" + TagTool = "builder_tag" + TagAsksTool = "builder_tag_asks" + UntagTool = "builder_untag" + OldFormTool = "builder_old_form" + // OldFormClosedEvent is, with builds.VersionNumberedEvent, the builder's own family `version`, kept seven days, + // apart from its builds' ends, so a waiter on a build's end does not wake on them. + OldFormClosedEvent = "version.old-form-closed" +) + +// NumberedOf is the version.numbered body of an entry (builds.VersionNumbered, the contract a build module reads). +func NumberedOf(e Entry, head string) builds.VersionNumbered { + return builds.VersionNumbered{Name: e.Name, Kind: e.Kind, Version: e.Version, Tag: e.Tag, Module: e.Module, Fingerprint: e.Fingerprint, + Commit: e.Commit, Digest: e.Digest, Seat: e.Seat, Head: head} +} + +// Reader reads the state as the tools bundle may: every key, and one. +type Reader struct { + Keys func() ([]string, error) + Get func(key string) (json.RawMessage, bool, error) +} + +// readStore is a Store over a Reader that refuses every write: the tools bundle only reads versions. +type readStore struct{ r Reader } + +var errReadOnly = errors.New("the versions are written by the builder alone") + +func (s readStore) Get(_ context.Context, key string) ([]byte, uint64, bool, error) { + v, ok, err := s.r.Get(key) + return v, 0, ok, err +} +func (readStore) Create(context.Context, string, []byte) (uint64, error) { return 0, errReadOnly } +func (readStore) Update(context.Context, string, []byte, uint64) (uint64, error) { + return 0, errReadOnly +} +func (s readStore) KeysOf(_ context.Context, filter string) ([]string, error) { + all, err := s.r.Keys() + if err != nil { + return nil, err + } + if filter == AllFilter { + return all, nil + } + prefix := strings.TrimSuffix(filter, "*") + var out []string + for _, k := range all { + if strings.HasPrefix(k, prefix) && !strings.Contains(k[len(prefix):], ".") { + out = append(out, k) + } + } + return out, nil +} + +// VersionsAnswer is what builder_versions answers. +type VersionsAnswer struct { + Name string `json:"name"` + Versions []Entry `json:"versions"` + // Asked, Recorded and Why answer a holder's question for one version and digest (ADR 0605 §4). + Asked string `json:"asked,omitempty"` + Recorded *bool `json:"recorded,omitempty"` + Why string `json:"why,omitempty"` +} + +// AnswerVersions is builder_versions: a name's versions in order, in one call; with a version (and a digest), whether +// the builder numbered it with that digest, which the holder asks before it stores (ADR 0605 §4). +func AnswerVersions(ctx context.Context, r Reader, name, version, digest string) (VersionsAnswer, error) { + name = strings.TrimSpace(name) + if name == "" { + return VersionsAnswer{}, errors.New("name is required: the package, image, bundle or archive name") + } + v := Versions{Store: readStore{r}} + all, err := v.Of(ctx, name) + if err != nil { + return VersionsAnswer{}, err + } + out := VersionsAnswer{Name: name, Versions: all} + if out.Versions == nil { + out.Versions = []Entry{} + } + if version = strings.TrimSpace(version); version != "" { + _, ok, why, err := v.Recorded(ctx, name, version, strings.TrimSpace(digest)) + if err != nil { + return VersionsAnswer{}, fmt.Errorf("%s@%s: %w", name, version, err) + } + out.Asked, out.Recorded, out.Why = version, &ok, why + } + return out, nil +} + +// VersionsDescription is builder_versions' description. +const VersionsDescription = "A name's versions as the builder numbered them (its state versions, design 51): every trunk and tagged " + + "version of a package, image, bundle or archive in order, each with its kind, series, fingerprint, commit, source revision, digest, " + + "seat, state (numbered, published, refused with its reason, removed with its time, reason and the event that removed it, or kept: its seat kept it whole on remove, its digest shared, with the reason and the other tags naming it, kept_with) and when. " + + "With version, and digest, it also answers recorded: whether the builder numbered that version with that digest, and why not: the " + + "question a registry seat's holder asks before it stores a publish (ADR 0605 §4). Read-only." diff --git a/modules/mesh-delivery-builder/internal/versions/versions.go b/modules/mesh-delivery-builder/internal/versions/versions.go new file mode 100644 index 00000000..4e129e31 --- /dev/null +++ b/modules/mesh-delivery-builder/internal/versions/versions.go @@ -0,0 +1,396 @@ +// Package versions is the builder's state `versions` (novox/hq design 51 *The versions*, ADR 0606): one key per name, +// tag and version, written only by the builder under compare-and-set, read by others only through the builder's tool +// builder_versions or its events (ADR 0596 §2). It holds what each version is (its fingerprint, commit, source revision +// and digest) and where it stands: numbered, published, refused with its reason, or removed with its time, reason and the +// event that removed it. +// +// The state is declared in the builder's old `state` section (ADR 0619: the builder is one of the foundation's nine +// parts), history one; its cap is 64 MiB, the most the template state allows (ADR 0595 §3), and past 80 % of it the +// builder writes a finding, cleared below it (Fill). +package versions + +import ( + "encoding/json" + "errors" + "fmt" + "sort" + "strconv" + "strings" + "time" +) + +// State is the builder state's name; Bucket the bucket the bus keeps it in. +const ( + State = "versions" + Bucket = "mesh-delivery-builder_versions" + // Cap is the bucket's cap in bytes (64 MiB); FullPercent the share of it past which the builder writes its finding. + Cap = 64 << 20 + FullPercent = 80 + // DefaultSeries is the series of a name whose manifest declares none (design 32). + DefaultSeries = "0.1" +) + +// The states a version is in. +const ( + Numbered = "numbered" + Published = "published" + Refused = "refused" + Removed = "removed" + // Kept is a tagged version its seat kept whole on remove: another tag names its digest, so the store cannot remove the + // tag alone (review round 2; the registry seats' `kept`). Its Reason is the seat's (shared-digest), KeptWith the + // other tags; it is asked no more. + Kept = "kept" +) + +// The kinds the builder numbers: a package per source revision, the rest per fingerprint (design 51). +const ( + KindPackage = "package" + KindImage = "image" + KindBundle = "bundle" + KindArchive = "archive" +) + +// Entry is one key of the state: a version of a name, tagged or not. +type Entry struct { + Name string `json:"name"` + Kind string `json:"kind"` + Series string `json:"series"` + Version string `json:"version"` + // Tag is the cleaned tag of a tagged version (`feat-x`); empty for a trunk version. + Tag string `json:"tag,omitempty"` + Module string `json:"module"` + Fingerprint string `json:"fingerprint"` + Commit string `json:"commit,omitempty"` + // Revision is the source revision a package's version is numbered for (one version per revision, design 51). + Revision string `json:"revision,omitempty"` + // Digest is the digest the build recorded (an image's manifest, a blob's, Go's h1: hash). + Digest string `json:"digest,omitempty"` + // Seat is the registry seat it is published to. + Seat string `json:"seat,omitempty"` + State string `json:"state"` + At time.Time `json:"at"` + // Reason is why it was refused or removed: the holder's reason, a branch's end, retention, the sweep. + Reason string `json:"reason,omitempty"` + // Repository is a tagged version's repository: its branch's end removes it (Untagged). + Repository string `json:"repository,omitempty"` + // KeptWith are the other tags naming its digest, when its seat kept it (Kept). + KeptWith []string `json:"kept_with,omitempty"` + // RemovedBy is the id of the event that removed it. + RemovedBy string `json:"removed_by,omitempty"` + // RefusedDigest is the digest the holder refused: one other than Digest means the seat was offered other bytes under + // this number, which builder_publish_again refuses to publish again (design 51 *Tools of the builder*). + RefusedDigest string `json:"refused_digest,omitempty"` + // Merge is the forge bridge's sequence of the merge that numbered it, for merge order. + Merge uint64 `json:"merge,omitempty"` +} + +// Valid says what is wrong with an entry, or nil. +func (e Entry) Valid() error { + switch { + case strings.TrimSpace(e.Name) == "": + return errors.New("a version names no name") + case e.Version == "": + return fmt.Errorf("%s: a version with no version", e.Name) + } + switch e.State { + case Numbered, Published, Refused, Removed, Kept: + default: + return fmt.Errorf("%s@%s: unknown state %q", e.Name, e.Version, e.State) + } + switch e.Kind { + case KindPackage, KindImage, KindBundle, KindArchive: + default: + return fmt.Errorf("%s@%s: unknown kind %q", e.Name, e.Version, e.Kind) + } + if _, _, err := SplitSeries(e.Series); err != nil { + return fmt.Errorf("%s@%s: %w", e.Name, e.Version, err) + } + return nil +} + +// Encode and Decode are the value's bytes. +func Encode(e Entry) ([]byte, error) { + if err := e.Valid(); err != nil { + return nil, err + } + return json.Marshal(e) +} + +func Decode(raw []byte) (Entry, error) { + var e Entry + if err := json.Unmarshal(raw, &e); err != nil { + return Entry{}, err + } + return e, e.Valid() +} + +// Key is the state's key of a name's version: two tokens, the name and the version each escaped (EscapeToken), so a +// name's versions are the keys under NameFilter and no name's keys fall under another's. +func Key(name, version string) string { return EscapeToken(name) + "." + EscapeToken(version) } + +// NameFilter is the key filter of every version of name. +func NameFilter(name string) string { return EscapeToken(name) + ".*" } + +// EscapeToken makes s one token of a bus key: letters, digits, '-', '_' and '/' stay; '=' and every other byte become +// '=' and two hex digits ('.' is "=2E", '@' "=40"). Two different strings never escape alike. +func EscapeToken(s string) string { + var b strings.Builder + for i := 0; i < len(s); i++ { + c := s[i] + switch { + case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9', c == '-', c == '_', c == '/': + b.WriteByte(c) + default: + fmt.Fprintf(&b, "=%02X", c) + } + } + return b.String() +} + +// SplitSeries reads a series `.`. +func SplitSeries(series string) (major, minor int, err error) { + a, b, ok := strings.Cut(series, ".") + if !ok { + return 0, 0, fmt.Errorf("series %q is not .", series) + } + if major, err = strconv.Atoi(a); err != nil || major < 0 { + return 0, 0, fmt.Errorf("series %q is not .", series) + } + if minor, err = strconv.Atoi(b); err != nil || minor < 0 { + return 0, 0, fmt.Errorf("series %q is not .", series) + } + return major, minor, nil +} + +// Patch is the count of a trunk version of series (`0.1.14` → 14), read leniently: a leading 'v' and a path prefix +// (`go/v0.1.13`) are dropped; ok is false for a version of another series or a tagged one. +func Patch(series, version string) (int, bool) { + if i := strings.LastIndex(version, "/"); i >= 0 { + version = version[i+1:] + } + version = strings.TrimPrefix(version, "v") + if strings.ContainsAny(version, "-+") { + return 0, false + } + rest, ok := strings.CutPrefix(version, series+".") + if !ok { + return 0, false + } + n, err := strconv.Atoi(rest) + if err != nil || n < 0 { + return 0, false + } + return n, true +} + +// Floor is what the three sources answered for a name (design 51 *The floor*, ADR 0606 §3): the seat's versions, the +// package's git tags (the forge bridge's bridge_tags) and the pseudo-versions consumers require (the indexer's +// indexer_requirements). A source not answered is listed in Silent. +type Floor struct { + Seat []string `json:"seat,omitempty"` + Tags []string `json:"tags,omitempty"` + Requirements []string `json:"requirements,omitempty"` + // Silent names the sources that did not answer: "seat", "bridge_tags", "indexer_requirements". + Silent []string `json:"silent,omitempty"` +} + +// The floor's sources, as Floor.Silent names them. +const ( + SourceSeat = "seat" + SourceTags = "bridge_tags" + SourceRequirements = "indexer_requirements" +) + +// Known is whether every source answered. +func (f Floor) Known() bool { return len(f.Silent) == 0 } + +// Highest is the highest count of series the floor's three sources name, -1 when none names one. A pseudo-version +// (`v0.1.14-0.2026…-abc`) counts as the version below it was cut from, so its base (`0.1.13`) counts: Go orders it +// before `0.1.14`. +func (f Floor) Highest(series string) int { + high := -1 + for _, list := range [][]string{f.Seat, f.Tags, f.Requirements} { + for _, v := range list { + n, ok := Patch(series, v) + if !ok { + n, ok = pseudoBase(series, v) + } + if ok && n > high { + high = n + } + } + } + return high +} + +// pseudoBase is the count a Go pseudo-version stands on: `v0.1.14-0.