Take the mesh-sdk that knows shares on an s3-bucket requirement, and prove the controller composes and checks it (issue 1593) #416

Merged
mesh-tools merged 2 commits from feat/files-through-the-store into main 2026-10-21 10:42:05 +00:00
13 changed files with 645 additions and 32 deletions
+104
View File
@@ -0,0 +1,104 @@
package main
import (
"bytes"
"crypto/ecdh"
"crypto/rand"
"encoding/base64"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/compose"
"git.novox.be/novox/mesh-sdk/go/overlay"
)
// Issue 1593 (novox/hq ADR 0468 §8): `shares` on a requirement of s3-bucket, taken in with mesh-sdk 465beb4. The fixture's
// shape is mesh-sdk's go/catalogue/bucket_shares_test.go (issue 1591) and go/compose/resolve_test.go theObjectsMesh.
const filesRequiringShares = `{"module":"files","version":"1","requires":[
{"name":"s3-bucket","shares":[{"to":"seat:node-files","methods":["put"]}]}]}`
func aKey(t *testing.T) string {
t.Helper()
k, err := ecdh.X25519().GenerateKey(rand.Reader)
if err != nil {
t.Fatal(err)
}
return base64.StdEncoding.EncodeToString(k.PublicKey().Bytes())
}
// 1593/AC1 unit: composing the provider's machine, the file it receives for the consumer carries the shares.
func TestTheProvidersMachineReceivesTheSharesOfAnS3BucketRequirement(t *testing.T) {
var overlayModule catalogue.Manifest
raw, err := json.Marshal(overlay.Manifest())
if err != nil {
t.Fatal(err)
}
if err := json.Unmarshal(raw, &overlayModule); err != nil {
t.Fatal(err)
}
consumer, err := catalogue.ParseManifest([]byte(filesRequiringShares))
if err != nil {
t.Fatal(err)
}
provider := catalogue.Manifest{Module: "objects", Version: "1",
Provides: []catalogue.Offer{{Name: "s3-bucket", Scope: catalogue.ScopeMesh,
Identity: &catalogue.OfferIdentity{Max: 20, In: "an S3 access key"}}},
Receives: map[string]string{"s3-bucket": "/var/lib/mesh/objects/mesh.json"}}
in := compose.Input{
Catalogue: map[string]catalogue.Manifest{overlay.Name: overlayModule, "objects": provider, "files": consumer},
Issued: []compose.Issued{{Provision: "s3-bucket", Consumer: "laptop", ConsumerModule: "files",
Provider: "anchor", ForProvider: "sealed-to-anchor"}},
}
for i, name := range []string{"anchor", "laptop"} {
assigned := []string{overlay.Name, "objects"}
if name == "laptop" {
assigned = []string{overlay.Name, "files"}
}
in.Nodes = append(in.Nodes, compose.Node{Name: name, SealingKey: name + "-key", Assigned: assigned,
Place: compose.Place{Key: aKey(t), Endpoint: name + ".example:51820", Site: "here", Hub: name == "anchor",
Address: []string{"10.77.0.1", "10.77.0.2"}[i]},
Capabilities: map[string]bool{"container-runtime": true, "wireguard": true, "systemd": true}})
}
rendered, err := compose.Render(in, "anchor", compose.Foreseeing)
if err != nil {
t.Fatal(err)
}
if len(rendered.Rendering.Grants) != 1 {
t.Fatalf("grants are %+v", rendered.Rendering.Grants)
}
got, err := json.Marshal(rendered.Rendering.Grants[0].Values["shares"])
if err != nil {
t.Fatal(err)
}
if want := `[{"methods":["put"],"to":"seat:node-files"}]`; string(got) != want {
t.Fatalf("the provider's grant for the consumer carries shares %s, want %s", got, want)
}
}
// 1593/AC2 unit: the module check accepts a manifest with shares and refuses an unknown method, citing ADR 0468 §8.
func TestModuleCheckAcceptsSharesAndRefusesAnUnknownMethod(t *testing.T) {
dir := t.TempDir()
write := func(name, body string) string {
p := filepath.Join(dir, name+".json")
if err := os.WriteFile(p, []byte(body), 0o600); err != nil {
t.Fatal(err)
}
return p
}
var out bytes.Buffer
if err := moduleCheckFor([]string{write("files", filesRequiringShares)}, 6, &out); err != nil {
t.Fatalf("a manifest with shares was refused: %v\n%s", err, out.String())
}
out.Reset()
bad := write("bad", `{"module":"bad","version":"1","requires":[
{"name":"s3-bucket","shares":[{"to":"seat:node-files","methods":["delete"]}]}]}`)
err := moduleCheckFor([]string{bad}, 6, &out)
if err == nil || !strings.Contains(out.String(), "ADR 0468 §8") {
t.Fatalf("an unknown method passed or the refusal does not cite the ADR: %v\n%s", err, out.String())
}
}
+1 -1
View File
@@ -3,7 +3,7 @@ module github.com/novox/mesh-controller
go 1.26.0
require (
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261020085642-ab0c16ddad26
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021093645-465beb4ea5e3
github.com/jackc/pgx/v5 v5.10.0
github.com/nats-io/nats-server/v2 v2.11.17
github.com/nats-io/nats.go v1.54.0
+2 -6
View File
@@ -1,11 +1,7 @@
git.novox.be/novox/mesh-host v0.0.0-20261018171808-9d9e3a46c4f5 h1:E5stfIeCN6uYgA9TCv+4K8JgaURg3zQppvUgJLaDMP4=
git.novox.be/novox/mesh-host v0.0.0-20261018171808-9d9e3a46c4f5/go.mod h1:4wyoCr6IIIIarEUuROThWDj7DAvJvIidqMjWcpSnOwE=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261018184557-238440d6f8c4 h1:yYDxFRJoH59kiCoEulD+FBwctyf9CrtKIxlEAsVL39Q=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261018184557-238440d6f8c4/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261019091011-aa68b67389c9 h1:qHpN/fpTDqijfMVoepxUT6mPkfcydb4LiPKsIokv6iM=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261019091011-aa68b67389c9/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261020085642-ab0c16ddad26 h1:oT1l3ZdjyF82MK14QMLKzoH2+iOFH/+UEYdlK3T2f7k=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261020085642-ab0c16ddad26/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021093645-465beb4ea5e3 h1:OKxU64kHN1yQ/GOa6LwoUwVmiQ74d83WMSZJRFPVm9c=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021093645-465beb4ea5e3/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/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+1 -1
View File
@@ -9,7 +9,7 @@ a second place to clean besides the catalogue itself.
mesh-catalog e0bc919d4945bb5f13f971f74643a0b714cf45da modules/*/module.json, modules/nats/Dockerfile
mesh-host 9d9e3a46c4f5943cfffb413d962ce48b75e2fd39 examples/foundation-first-node.lock
mesh-sdk ab0c16ddad260289006a40c5dc6fd4973e0b5e0b conformance/events/module-event.json
mesh-sdk 465beb4ea5e37eb36f6ba7fc60274de2d5a0d060 conformance/events/module-event.json
To move them, from this repository's root, with the two repositories checked out beside it:
@@ -0,0 +1,124 @@
package catalogue
import (
"encoding/json"
"fmt"
"regexp"
"sort"
)
// BucketShare is one entry of `shares` on a module's requirement of `s3-bucket` (novox/hq ADR 0468 §8): who else may
// put or get in the consumer's bucket, and with which methods. Not the NFS shares of shares.go (ADR 0263).
//
// "requires": [{"name": "s3-bucket", "shares": [{"to": "seat:node-files", "methods": ["put"]},
// {"to": "print-and-scan", "methods": ["get"]}]}]
//
// `to` is a module name, or `seat:<seat name>`. `methods` is a non-empty subset of get and put, without a repeat.
// Shares are allowed on `s3-bucket` only, and no two entries name the same `to`.
type BucketShare struct {
To string `json:"to"`
Methods []string `json:"methods"`
}
// BucketRequirement is the requirement `shares` may be written on.
const BucketRequirement = "s3-bucket"
// RequireSharesKey is where a stored manifest keeps the shares of its requirements: beside `requires`, which stays a
// plain list of names, for the reason OptionalRequiresKey is beside it. An older controller sets the unknown key
// aside instead of failing the catalogue.
const RequireSharesKey = "requires-shares"
// SharesValue is the key of the value a consumer gives the provider of s3-bucket in its grant: the list of
// {to, methods} sorted by `to`. A consumer's own `contributes` may not name it.
const SharesValue = "shares"
var shareTarget = regexp.MustCompile(`^(seat:)?[a-z0-9][a-z0-9-]*$`)
// checkBucketShares refuses a malformed `shares` of one requirement, naming the entry and ADR 0468 §8.
func checkBucketShares(requirement string, shares []BucketShare) error {
if len(shares) == 0 {
return nil
}
if requirement != BucketRequirement {
return fmt.Errorf("requires %s: `shares` is allowed on %s only (novox/hq ADR 0468 §8)", requirement, BucketRequirement)
}
seen := map[string]bool{}
for i, s := range shares {
at := fmt.Sprintf("requires %s: shares[%d] (to %q)", requirement, i, s.To)
if !shareTarget.MatchString(s.To) {
return fmt.Errorf("%s: `to` is a module name or seat:<seat name> (novox/hq ADR 0468 §8)", at)
}
if seen[s.To] {
return fmt.Errorf("%s: a second entry for the same `to` (novox/hq ADR 0468 §8)", at)
}
seen[s.To] = true
if len(s.Methods) == 0 {
return fmt.Errorf("%s: `methods` is empty; name get, put or both (novox/hq ADR 0468 §8)", at)
}
used := map[string]bool{}
for _, method := range s.Methods {
if method != "get" && method != "put" {
return fmt.Errorf("%s: method %q is not get or put (novox/hq ADR 0468 §8)", at, method)
}
if used[method] {
return fmt.Errorf("%s: method %q twice (novox/hq ADR 0468 §8)", at, method)
}
used[method] = true
}
}
return nil
}
// readRequireShares reads the stored form's side key.
func readRequireShares(raw json.RawMessage) (map[string][]BucketShare, error) {
var out map[string][]BucketShare
if err := json.Unmarshal(raw, &out); err != nil {
return nil, fmt.Errorf("%s: an object of requirement to a list of {to, methods}: %w", RequireSharesKey, err)
}
for requirement, shares := range out {
if err := checkBucketShares(requirement, shares); err != nil {
return nil, err
}
}
return out, nil
}
// sortedShares is a requirement's shares as they are handed to the provider: sorted by `to` (a file whose lines move
// looks changed), each entry's methods sorted, and a copy so the manifest is not reordered.
func sortedShares(shares []BucketShare) []BucketShare {
out := make([]BucketShare, 0, len(shares))
for _, s := range shares {
methods := append([]string(nil), s.Methods...)
sort.Strings(methods)
out = append(out, BucketShare{To: s.To, Methods: methods})
}
sort.Slice(out, func(i, j int) bool { return out[i].To < out[j].To })
return out
}
// withShares adds this module's shares of the requirement to the values it gives the provider. A module whose own
// `contributes` already names `shares` is refused: the field is the only way to say it (ADR 0468 §8).
func withShares(m Manifest, requirement string, values map[string]any) (map[string]any, error) {
shares := m.RequireShares[requirement]
if _, named := values[SharesValue]; named && requirement == BucketRequirement {
return nil, fmt.Errorf("%s contributing to %s names `shares`; write it on its requirement instead (novox/hq ADR 0468 §8)",
m.Module, requirement)
}
if len(shares) == 0 {
return values, nil
}
out := make(map[string]any, len(values)+1)
for k, v := range values {
out[k] = v
}
listed := []any{}
for _, s := range sortedShares(shares) {
methods := []any{}
for _, method := range s.Methods {
methods = append(methods, method)
}
listed = append(listed, map[string]any{"to": s.To, "methods": methods})
}
out[SharesValue] = listed
return out, nil
}
@@ -1783,6 +1783,10 @@ func (r Resolution) contributions(settings SettingsBy, grants []Grant,
if err != nil {
return nil, err
}
// What the module shares of its bucket reaches the provider with the rest (novox/hq ADR 0468 §8).
if values, err = withShares(m, to, values); err != nil {
return nil, err
}
out[to] = append(out[to], Contribution{From: m.Module, Values: values})
}
// Several contributions to one requirement (ADR 0094's sibling for `contributes`): an
@@ -2169,11 +2173,19 @@ func (r Resolution) ContributionsFrom(requirement, module string, settings Setti
mine = append(mine, g.Values)
}
}
var asker Manifest
for _, m := range r.Modules {
if m.Module == module {
asker = m
}
}
if len(mine) == 1 {
// Its shares are already in, put there where its contribution was composed (contributions).
return mine[0], true, nil
}
if len(mine) > 1 {
return map[string]any{}, true, nil
values, err := withShares(asker, requirement, map[string]any{})
return values, err == nil, err
}
// It contributes no payload — but a require-only consumer of a parameterless provision (one whose
// `serves` names no consumer-supplied key: `redis-cache`, `amqp`) still ASKS for it and must be
@@ -2186,7 +2198,8 @@ func (r Resolution) ContributionsFrom(requirement, module string, settings Setti
}
for _, req := range m.Requires {
if req == requirement {
return map[string]any{}, true, nil
values, err := withShares(m, requirement, map[string]any{})
return values, err == nil, err
}
}
}
+46 -2
View File
@@ -494,6 +494,9 @@ type Manifest struct {
// by UnmarshalJSON from the long form of a `requires` entry, {name, optional}, or from the stored
// form's `optional-requires` list; never written by hand.
OptionalRequires map[string]bool `json:"-"`
// RequireShares is who else may use the bucket a module requires, by requirement (BucketShare, novox/hq ADR 0468
// §8): filled from the long form's `shares`, or from the stored form's `requires-shares`; never written by hand.
RequireShares map[string][]BucketShare `json:"-"`
// Emits are the events this module publishes, named **locally**: `order.placed`, not a subject
// and not a routing key. The mesh derives where it lands (design 29 §1), so reorganising the
@@ -1538,8 +1541,9 @@ func (m *Manifest) UnmarshalJSON(raw []byte) error {
delete(keys, "contributes")
}
var optionalRequires map[string]bool
var requireShares map[string][]BucketShare
if requires, ok := keys["requires"]; ok && string(requires) != "null" {
names, optional, err := readRequires(requires)
names, optional, shares, err := readRequires(requires)
if err != nil {
return err
}
@@ -1549,6 +1553,23 @@ func (m *Manifest) UnmarshalJSON(raw []byte) error {
}
keys["requires"] = plainNames
optionalRequires = optional
requireShares = shares
}
// The stored form's shares (RequireSharesKey).
if stored, ok := keys[RequireSharesKey]; ok {
if string(stored) != "null" {
stood, err := readRequireShares(stored)
if err != nil {
return err
}
for requirement, shares := range stood {
if requireShares == nil {
requireShares = map[string][]BucketShare{}
}
requireShares[requirement] = shares
}
}
delete(keys, RequireSharesKey)
}
// The stored form: `requires` plain, and which are optional beside it (OptionalRequiresKey).
if stored, ok := keys[OptionalRequiresKey]; ok {
@@ -1599,6 +1620,7 @@ func (m *Manifest) UnmarshalJSON(raw []byte) error {
m.unknown = unknown
m.forms = forms
m.OptionalRequires = optionalRequires
m.RequireShares = requireShares
if len(plain) > 0 {
m.Secrets = plain
}
@@ -1611,6 +1633,21 @@ func (m *Manifest) UnmarshalJSON(raw []byte) error {
if len(contributesMany) > 0 {
m.ContributesMany = contributesMany
}
if err := m.checkSharesNotContributed(); err != nil {
return err
}
return nil
}
// checkSharesNotContributed refuses a module whose own `contributes` to s3-bucket names `shares` (ADR 0468 §8).
func (m Manifest) checkSharesNotContributed() error {
if len(m.RequireShares[BucketRequirement]) == 0 {
return nil
}
if _, named := m.Contributes[BucketRequirement][SharesValue]; named {
return fmt.Errorf("contributes.%s names `shares` and so does its requirement; keep one, the requirement's (novox/hq ADR 0468 §8)",
BucketRequirement)
}
return nil
}
@@ -1655,7 +1692,7 @@ func (m Manifest) marshalFields() ([]byte, error) {
if err != nil {
return nil, err
}
if len(m.SecretsMany) == 0 && len(m.ContributesMany) == 0 && len(m.OptionalRequires) == 0 {
if len(m.SecretsMany) == 0 && len(m.ContributesMany) == 0 && len(m.OptionalRequires) == 0 && len(m.RequireShares) == 0 {
return raw, nil
}
var keys map[string]json.RawMessage
@@ -1671,6 +1708,13 @@ func (m Manifest) marshalFields() ([]byte, error) {
}
keys[OptionalRequiresKey] = listed
}
if len(m.RequireShares) > 0 {
listed, err := json.Marshal(m.RequireShares)
if err != nil {
return nil, err
}
keys[RequireSharesKey] = listed
}
if len(m.ContributesMany) > 0 {
mergedContributes := map[string]any{}
for to, values := range m.Contributes {
@@ -32,17 +32,20 @@ const OnlyWhenBound = "only-when-bound"
type requirementForm struct {
Name string `json:"name"`
Optional bool `json:"optional,omitempty"`
// Shares is who else may use the consumer's bucket (BucketShare, novox/hq ADR 0468 §8), on s3-bucket only.
Shares []BucketShare `json:"shares,omitempty"`
}
// readRequires reads `requires` in both of its forms — a bare name, or {name, optional} — into the
// names and which of them are optional.
func readRequires(raw json.RawMessage) ([]string, map[string]bool, error) {
func readRequires(raw json.RawMessage) ([]string, map[string]bool, map[string][]BucketShare, error) {
var entries []json.RawMessage
if err := json.Unmarshal(raw, &entries); err != nil {
return nil, nil, fmt.Errorf("requires: a list of names, or of {name, optional}: %w", err)
return nil, nil, nil, fmt.Errorf("requires: a list of names, or of {name, optional}: %w", err)
}
var names []string
optional := map[string]bool{}
var shares map[string][]BucketShare
for i, e := range entries {
var plain string
if err := json.Unmarshal(e, &plain); err == nil {
@@ -53,12 +56,21 @@ func readRequires(raw json.RawMessage) ([]string, map[string]bool, error) {
dec := json.NewDecoder(bytes.NewReader(e))
dec.DisallowUnknownFields()
if err := dec.Decode(&full); err != nil {
return nil, nil, fmt.Errorf("requires[%d]: a name, or {name, optional} (novox/hq ADR 0330): %w", i, err)
return nil, nil, nil, fmt.Errorf("requires[%d]: a name, or {name, optional} (novox/hq ADR 0330): %w", i, err)
}
if strings.TrimSpace(full.Name) == "" {
return nil, nil, fmt.Errorf("requires[%d] names no requirement: {name, optional}", i)
return nil, nil, nil, fmt.Errorf("requires[%d] names no requirement: {name, optional}", i)
}
names = append(names, full.Name)
if err := checkBucketShares(full.Name, full.Shares); err != nil {
return nil, nil, nil, fmt.Errorf("requires[%d]: %w", i, err)
}
if len(full.Shares) > 0 {
if shares == nil {
shares = map[string][]BucketShare{}
}
shares[full.Name] = full.Shares
}
if full.Optional {
optional[full.Name] = true
}
@@ -66,7 +78,7 @@ func readRequires(raw json.RawMessage) ([]string, map[string]bool, error) {
if len(optional) == 0 {
optional = nil
}
return names, optional, nil
return names, optional, shares, nil
}
// OptionalRequiresKey is where a stored manifest keeps which of its requirements are optional: beside
@@ -20,6 +20,16 @@ type BuildRecord struct {
At time.Time `json:"at,omitzero"`
SourceFingerprint string `json:"source-fingerprint,omitempty"`
SourceInputs *SourceInputs `json:"source-inputs,omitempty"`
// Made is what the build made (Build.Made, records.go:110); the digest is the tail of an artifact's Reference.
Made []Artifact `json:"made,omitempty"`
}
// Artifact is one thing a build made: mesh-controller internal/chain/records.go Artifact, json keys name, kind and
// reference, nothing else (novox/hq ADR 0478 clause 7).
type Artifact struct {
Name string `json:"name"`
Kind string `json:"kind"`
Reference string `json:"reference"`
}
// BuildsBucket is the bucket the builds are kept in: mesh-controller internal/broker/controller_buckets.go:101,
+81 -14
View File
@@ -377,15 +377,37 @@ func (c Candidate) check() error {
// Manifest, the digest of the module's manifest, the key of the manifests bucket. A value written before has none of
// the three, and a reader of today's shape ignores them.
type Module struct {
V int `json:"v"`
FP string `json:"fp,omitempty"`
None string `json:"none,omitempty"`
FP2 string `json:"fp2,omitempty"`
None2 string `json:"none2,omitempty"`
Manifest string `json:"manifest,omitempty"`
Src uint64 `json:"src,omitempty"`
Repository string `json:"repository"`
Commit string `json:"commit"`
V int `json:"v"`
FP string `json:"fp,omitempty"`
None string `json:"none,omitempty"`
FP2 string `json:"fp2,omitempty"`
None2 string `json:"none2,omitempty"`
Manifest string `json:"manifest,omitempty"`
// Inputs2 are the new-form inputs the merge gave the module, kept so the indexer computes FP2 again when a build
// module's toolchain versions appear, with no merge (novox/hq ADR 0465 clause 2). It needs Manifest.
Inputs2 *KeptInputs2 `json:"inputs2,omitempty"`
Src uint64 `json:"src,omitempty"`
Repository string `json:"repository"`
Commit string `json:"commit"`
// Path is the module's directory in its repository, trimmed of slashes, empty when its manifest is the repository's
// root (novox/hq ADR 0478 clause 8): the build tools need it. A value written before has none.
Path string `json:"path,omitempty"`
}
// KeptInputs2 are SourceInputs2 without the module's name (the key has it) and without toolchains (those come from
// the build modules' toolchain keys when the fingerprint is computed): what a Module keeps of a merge.
type KeptInputs2 struct {
Tree string `json:"tree"`
Bases []string `json:"bases,omitempty"`
Builder string `json:"builder,omitempty"`
Contexts map[string]string `json:"contexts,omitempty"`
Locks map[string]string `json:"locks,omitempty"`
}
// With is the full inputs of module m: the kept ones and the given toolchains.
func (k KeptInputs2) With(module string, toolchains map[string]Toolchain2) SourceInputs2 {
return SourceInputs2{Module: module, Tree: k.Tree, Bases: k.Bases, Builder: k.Builder, Contexts: k.Contexts,
Locks: k.Locks, Toolchains: toolchains}
}
func (m Module) check() error {
@@ -399,6 +421,9 @@ func (m Module) check() error {
case m.FP2 != "" && m.None2 != "":
return fmt.Errorf("delivery: a module has both fp2 and none2")
}
if m.Inputs2 != nil && m.Manifest == "" {
return fmt.Errorf("delivery: a module keeps new-form inputs but has no manifest digest")
}
return checkNewForm(m.FP2, m.Manifest)
}
@@ -421,9 +446,14 @@ type Forge struct {
Merge string `json:"merge,omitempty"`
Trees map[string]string `json:"trees,omitempty"`
Root string `json:"root,omitempty"`
Act string `json:"act,omitempty"`
Words string `json:"words,omitempty"`
Open []OpenPull `json:"open,omitempty"`
// Manifests are the module.json bytes, exact, of each module directory: of the merge's trees on a merged message,
// of Dirs at the head on a pull message. Locks are, of each such directory, its lock files' hex sha256 by path
// relative to it (novox/hq ADR 0465 clause 1). Older writers send neither.
Manifests map[string]string `json:"manifests,omitempty"`
Locks map[string]map[string]string `json:"locks,omitempty"`
Act string `json:"act,omitempty"`
Words string `json:"words,omitempty"`
Open []OpenPull `json:"open,omitempty"`
}
func (f Forge) check() error {
@@ -455,6 +485,13 @@ func (f Forge) check() error {
if len(f.Dirs) > 0 && !f.DirsKnown {
return fmt.Errorf("delivery: a pull message with module directories must say it knows them (dirs-known)")
}
dirs := map[string]bool{}
for _, d := range f.Dirs {
dirs[d] = true
}
if err := f.checkInputs(func(d string) bool { return dirs[d] }, "its module directories"); err != nil {
return err
}
case ForgeMerged:
if len(f.Dirs) > 0 || f.DirsKnown {
return fmt.Errorf("delivery: a merged message carries no module directories")
@@ -479,10 +516,16 @@ func (f Forge) check() error {
return fmt.Errorf("delivery: a merged message's tree of the directory %q is empty", dir)
}
}
if err := f.checkInputs(func(d string) bool { _, ok := f.Trees[d]; return ok }, "its trees"); err != nil {
return err
}
case ForgeRefused:
if len(f.Dirs) > 0 || f.DirsKnown {
return fmt.Errorf("delivery: a refused message carries no module directories")
}
if len(f.Manifests) > 0 || len(f.Locks) > 0 {
return fmt.Errorf("delivery: a refused message carries no manifests or lock hashes")
}
if f.Act == "" {
return fmt.Errorf("delivery: a refused message needs the act it answers")
}
@@ -493,11 +536,35 @@ func (f Forge) check() error {
return nil
}
// checkInputs refuses a manifest or a lock hash of a directory that known does not know (of is what it knows them
// by), an empty manifest and an empty lock path or hash, naming the directory.
func (f Forge) checkInputs(known func(dir string) bool, of string) error {
for dir, m := range f.Manifests {
if !known(dir) {
return fmt.Errorf("delivery: the manifest of the directory %q is not of %s", dir, of)
}
if m == "" {
return fmt.Errorf("delivery: the manifest of the directory %q is empty", dir)
}
}
for dir, locks := range f.Locks {
if !known(dir) {
return fmt.Errorf("delivery: the lock hashes of the directory %q are not of %s", dir, of)
}
for path, hash := range locks {
if path == "" || hash == "" {
return fmt.Errorf("delivery: the lock %q of the directory %q has an empty path or hash", path, dir)
}
}
}
return nil
}
// checkOpen refuses an open message that names a repository, a branch, a head or anything of the other kinds, or
// an open pull request that is not `<owner>/<name>` and a number.
func (f Forge) checkOpen() error {
if f.Repository != "" || f.Branch != "" || f.Number != 0 || f.Head != "" || f.Action != "" || len(f.Dirs) > 0 || f.DirsKnown ||
f.Merge != "" || len(f.Trees) > 0 || f.Root != "" || f.Act != "" || f.Words != "" {
f.Merge != "" || len(f.Trees) > 0 || len(f.Manifests) > 0 || len(f.Locks) > 0 || f.Root != "" || f.Act != "" || f.Words != "" {
return fmt.Errorf("delivery: an open message carries only the open pull requests it names")
}
for _, p := range f.Open {
@@ -603,7 +670,7 @@ func SameSource(a, b Source) bool { return reflect.DeepEqual(a, b) }
func SameCandidate(a, b Candidate) bool { return reflect.DeepEqual(a, b) }
// SameModule says whether two modules are the same value.
func SameModule(a, b Module) bool { return a == b }
func SameModule(a, b Module) bool { return reflect.DeepEqual(a, b) }
// SameForge says whether two forge messages are the same value.
func SameForge(a, b Forge) bool { return reflect.DeepEqual(a, b) }
+116
View File
@@ -0,0 +1,116 @@
package delivery
import (
"encoding/json"
"fmt"
"regexp"
"strings"
"time"
)
// StepsState is the name of the state a build module keeps its long steps in; its bucket is `<module>_steps`, built
// by StepsBucket (novox/hq ADR 0469 clause 2; issue 1471). A tool call is cut at 30 s, so a fetch, a test level or a
// build answers at once with a step id and writes its progress and its end to the key `<node>.<step id>`.
const StepsState = "steps"
// StepsBucket is the bucket of a build module's steps.
func StepsBucket(module string) string { return module + "_" + StepsState }
// The states of a step. A step ends in passed, failed or refused; its key is written for each of them, and that
// write is the event the step's reader waits on.
const (
StepRunning = "running"
StepPassed = "passed"
StepFailed = "failed"
StepRefused = "refused"
)
var stepIDOK = regexp.MustCompile(`^[a-z0-9]+$`)
func checkStepID(id string) error {
if !stepIDOK.MatchString(id) {
return fmt.Errorf("delivery: the step id %q is not lower-case letters and digits", id)
}
return nil
}
// StepKey is the key of a node's step: `<node>.<id>`. It refuses a node that is not a name and an id that is not
// lower-case letters and digits (one key token, never a dot).
func StepKey(node, id string) (string, error) {
if err := checkName("node", node); err != nil {
return "", err
}
if err := checkStepID(id); err != nil {
return "", err
}
return node + "." + id, nil
}
// ParseStepKey is the inverse of StepKey.
func ParseStepKey(key string) (node, id string, err error) {
node, id, ok := strings.Cut(key, ".")
if !ok {
return "", "", fmt.Errorf("delivery: %q is not a step key, <node>.<id>", key)
}
if _, err := StepKey(node, id); err != nil {
return "", "", fmt.Errorf("delivery: %q is not a step key: %w", key, err)
}
return node, id, nil
}
// Step is the value of a steps key: which tool runs on which node, how far it is, when it started and ended (UTC, as
// the toolchains' At), why it was refused or failed, and what it returned.
type Step struct {
V int `json:"v"`
ID string `json:"id"`
Tool string `json:"tool"`
Node string `json:"node"`
State string `json:"state"`
Started time.Time `json:"started"`
Ended time.Time `json:"ended,omitzero"`
Reason string `json:"reason,omitempty"`
Result json.RawMessage `json:"result,omitempty"`
}
func (s Step) check() error {
if err := checkStepID(s.ID); err != nil {
return err
}
if err := checkName("node", s.Node); err != nil {
return err
}
if s.Tool == "" {
return fmt.Errorf("delivery: a step needs its tool")
}
switch s.State {
case StepRunning, StepPassed, StepFailed, StepRefused:
default:
return fmt.Errorf("delivery: the step state %q is not running, passed, failed or refused", s.State)
}
if len(s.Result) > 0 && !json.Valid(s.Result) {
return fmt.Errorf("delivery: the step's result is not JSON")
}
return nil
}
// EncodeStep is s as JSON at Version; it refuses an id that is not one key token, a node that is not a name, an empty
// tool, a state outside the four and a result that is not JSON.
func EncodeStep(s Step) ([]byte, error) {
if err := s.check(); err != nil {
return nil, err
}
s.V = Version
return json.Marshal(s)
}
// DecodeStep reads a steps value; it refuses an unknown version (ErrUnknownVersion) and what EncodeStep refuses.
func DecodeStep(data []byte) (Step, error) {
var s Step
if err := decode(data, &s); err != nil {
return Step{}, err
}
if err := s.check(); err != nil {
return Step{}, err
}
return s, nil
}
@@ -0,0 +1,127 @@
package delivery
import (
"encoding/json"
"fmt"
"sort"
"strings"
"time"
)
// ToolchainsState is the name of the state a build module keeps its toolchains in; its bucket is
// `<module>_toolchains`, built as the SDK names a module's state, by ToolchainsBucket (novox/hq ADR 0465 clause 2).
const ToolchainsState = "toolchains"
// ToolchainsBucket is the bucket of a build module's toolchains.
func ToolchainsBucket(module string) string { return module + "_" + ToolchainsState }
// ToolchainNoReport is the reason AgreedToolchain gives when no node has reported a language's toolchain.
const ToolchainNoReport = "toolchain not yet reported"
func checkToolchainLanguage(language string) error {
switch language {
case "go", "typescript":
return nil
}
return fmt.Errorf("delivery: the toolchain language %q is not go or typescript", language)
}
// ToolchainKey is the key of a node's toolchain of one language: `<node>.<language>`. It refuses a node that is not a
// name and a language other than go or typescript.
func ToolchainKey(node, language string) (string, error) {
if err := checkName("node", node); err != nil {
return "", err
}
if err := checkToolchainLanguage(language); err != nil {
return "", err
}
return node + "." + language, nil
}
// ParseToolchainKey is the inverse of ToolchainKey.
func ParseToolchainKey(key string) (node, language string, err error) {
node, language, ok := strings.Cut(key, ".")
if !ok {
return "", "", fmt.Errorf("delivery: %q is not a toolchain key, <node>.<language>", key)
}
if _, err := ToolchainKey(node, language); err != nil {
return "", "", fmt.Errorf("delivery: %q is not a toolchain key: %w", key, err)
}
return node, language, nil
}
// Toolchain is the value of a toolchains key: the version of the language's toolchain installed on the node and the
// build module's recipe version, and when the module saw them.
type Toolchain struct {
V int `json:"v"`
Language string `json:"language"`
Version string `json:"version"`
Recipe string `json:"recipe"`
At time.Time `json:"at"`
}
func (t Toolchain) check() error {
if err := checkToolchainLanguage(t.Language); err != nil {
return err
}
if t.Version == "" || t.Recipe == "" {
return fmt.Errorf("delivery: a %s toolchain needs its version and its recipe", t.Language)
}
return nil
}
// EncodeToolchain is t as JSON at Version; it refuses a language other than go or typescript and an empty version or
// recipe.
func EncodeToolchain(t Toolchain) ([]byte, error) {
if err := t.check(); err != nil {
return nil, err
}
t.V = Version
return json.Marshal(t)
}
// DecodeToolchain reads a toolchains value; it refuses an unknown version (ErrUnknownVersion) and what EncodeToolchain
// refuses.
func DecodeToolchain(data []byte) (Toolchain, error) {
var t Toolchain
if err := decode(data, &t); err != nil {
return Toolchain{}, err
}
if err := t.check(); err != nil {
return Toolchain{}, err
}
return t, nil
}
// NodeToolchain is one node's entry of a language, as read from the toolchains bucket.
type NodeToolchain struct {
Node string
Toolchain
}
// AgreedToolchain is the one Toolchain2 every node's entry of a language agrees on, or none and why: "toolchain not
// yet reported" with no entries, and "builders disagree" naming each differing version and recipe with the nodes
// reporting it when two differ (novox/hq ADR 0405 clause 2). The answer does not depend on the entries' order. The
// entries must all be of one language.
func AgreedToolchain(entries []NodeToolchain) (tc Toolchain2, none string) {
if len(entries) == 0 {
return Toolchain2{}, ToolchainNoReport
}
groups := map[Toolchain2][]string{}
for _, e := range entries {
k := Toolchain2{Language: e.Language, Version: e.Version, Recipe: e.Recipe}
groups[k] = append(groups[k], e.Node)
}
if len(groups) == 1 {
for k := range groups {
return k, ""
}
}
parts := make([]string, 0, len(groups))
for k, nodes := range groups {
sort.Strings(nodes)
parts = append(parts, fmt.Sprintf("%s recipe %s on %s", k.Version, k.Recipe, strings.Join(nodes, ", ")))
}
sort.Strings(parts)
return Toolchain2{}, "builders disagree: " + strings.Join(parts, "; ")
}
+1 -1
View File
@@ -1,4 +1,4 @@
# git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261020085642-ab0c16ddad26
# git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021093645-465beb4ea5e3
## explicit; go 1.26.0
git.novox.be/novox/mesh-sdk/go/asks
git.novox.be/novox/mesh-sdk/go/catalogue