diff --git a/cmd/mesh-controller/shares_test.go b/cmd/mesh-controller/shares_test.go new file mode 100644 index 00000000..294a38e8 --- /dev/null +++ b/cmd/mesh-controller/shares_test.go @@ -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()) + } +} diff --git a/go.mod b/go.mod index 069eec06..1bb6c910 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index abcbc3b8..f7884acb 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/testdata/beside/CAPTURED b/testdata/beside/CAPTURED index 16d7e706..711e1cdd 100644 --- a/testdata/beside/CAPTURED +++ b/testdata/beside/CAPTURED @@ -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: diff --git a/vendor/git.novox.be/novox/mesh-sdk/go/catalogue/bucket_shares.go b/vendor/git.novox.be/novox/mesh-sdk/go/catalogue/bucket_shares.go new file mode 100644 index 00000000..9443f7c3 --- /dev/null +++ b/vendor/git.novox.be/novox/mesh-sdk/go/catalogue/bucket_shares.go @@ -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:`. `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: (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 +} diff --git a/vendor/git.novox.be/novox/mesh-sdk/go/catalogue/declaration.go b/vendor/git.novox.be/novox/mesh-sdk/go/catalogue/declaration.go index a2d75976..35895c88 100644 --- a/vendor/git.novox.be/novox/mesh-sdk/go/catalogue/declaration.go +++ b/vendor/git.novox.be/novox/mesh-sdk/go/catalogue/declaration.go @@ -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 } } } diff --git a/vendor/git.novox.be/novox/mesh-sdk/go/catalogue/manifest.go b/vendor/git.novox.be/novox/mesh-sdk/go/catalogue/manifest.go index cd48fdf3..04646544 100644 --- a/vendor/git.novox.be/novox/mesh-sdk/go/catalogue/manifest.go +++ b/vendor/git.novox.be/novox/mesh-sdk/go/catalogue/manifest.go @@ -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 { diff --git a/vendor/git.novox.be/novox/mesh-sdk/go/catalogue/optional_requirements.go b/vendor/git.novox.be/novox/mesh-sdk/go/catalogue/optional_requirements.go index f2db8adf..921b1d76 100644 --- a/vendor/git.novox.be/novox/mesh-sdk/go/catalogue/optional_requirements.go +++ b/vendor/git.novox.be/novox/mesh-sdk/go/catalogue/optional_requirements.go @@ -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 diff --git a/vendor/git.novox.be/novox/mesh-sdk/go/delivery/buildrecord.go b/vendor/git.novox.be/novox/mesh-sdk/go/delivery/buildrecord.go index 86bd87b4..d3f9fb9c 100644 --- a/vendor/git.novox.be/novox/mesh-sdk/go/delivery/buildrecord.go +++ b/vendor/git.novox.be/novox/mesh-sdk/go/delivery/buildrecord.go @@ -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, diff --git a/vendor/git.novox.be/novox/mesh-sdk/go/delivery/source.go b/vendor/git.novox.be/novox/mesh-sdk/go/delivery/source.go index 31c1a9a0..a21bf9d1 100644 --- a/vendor/git.novox.be/novox/mesh-sdk/go/delivery/source.go +++ b/vendor/git.novox.be/novox/mesh-sdk/go/delivery/source.go @@ -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 `/` 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) } diff --git a/vendor/git.novox.be/novox/mesh-sdk/go/delivery/steps.go b/vendor/git.novox.be/novox/mesh-sdk/go/delivery/steps.go new file mode 100644 index 00000000..61f8114c --- /dev/null +++ b/vendor/git.novox.be/novox/mesh-sdk/go/delivery/steps.go @@ -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 `_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 `.`. +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: `.`. 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, .", 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 +} diff --git a/vendor/git.novox.be/novox/mesh-sdk/go/delivery/toolchains.go b/vendor/git.novox.be/novox/mesh-sdk/go/delivery/toolchains.go new file mode 100644 index 00000000..4fa2361e --- /dev/null +++ b/vendor/git.novox.be/novox/mesh-sdk/go/delivery/toolchains.go @@ -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 +// `_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: `.`. 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, .", 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, "; ") +} diff --git a/vendor/modules.txt b/vendor/modules.txt index 8e1f1467..c26aec0c 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -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