Compute the source fingerprint with mesh-sdk's one function (issue 1214)
This commit is contained in:
@@ -3,7 +3,7 @@ module github.com/novox/mesh-controller
|
|||||||
go 1.26.0
|
go 1.26.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261018184557-238440d6f8c4
|
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261019091011-aa68b67389c9
|
||||||
github.com/jackc/pgx/v5 v5.10.0
|
github.com/jackc/pgx/v5 v5.10.0
|
||||||
github.com/nats-io/nats-server/v2 v2.11.17
|
github.com/nats-io/nats-server/v2 v2.11.17
|
||||||
github.com/nats-io/nats.go v1.54.0
|
github.com/nats-io/nats.go v1.54.0
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ git.novox.be/novox/mesh-host v0.0.0-20261018171808-9d9e3a46c4f5 h1:E5stfIeCN6uYg
|
|||||||
git.novox.be/novox/mesh-host v0.0.0-20261018171808-9d9e3a46c4f5/go.mod h1:4wyoCr6IIIIarEUuROThWDj7DAvJvIidqMjWcpSnOwE=
|
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 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.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=
|
||||||
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 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.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
|||||||
+21
-27
@@ -10,6 +10,7 @@ import (
|
|||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||||
"github.com/novox/mesh-controller/internal/catalogue"
|
"github.com/novox/mesh-controller/internal/catalogue"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -36,7 +37,7 @@ import (
|
|||||||
// (novox/hq ADR 0097 refuses the unpinned bases; what a RUN step downloads is not seen here).
|
// (novox/hq ADR 0097 refuses the unpinned bases; what a RUN step downloads is not seen here).
|
||||||
|
|
||||||
// sourcePrefix names the fingerprint's form, so a later form is never compared equal to this one.
|
// sourcePrefix names the fingerprint's form, so a later form is never compared equal to this one.
|
||||||
const sourcePrefix = "src1:"
|
const sourcePrefix = delivery.SourcePrefix
|
||||||
|
|
||||||
// sourceInputs collects what one build was made from.
|
// sourceInputs collects what one build was made from.
|
||||||
type sourceInputs struct {
|
type sourceInputs struct {
|
||||||
@@ -262,40 +263,33 @@ func (s *sourceInputs) notPinned(why string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// sourceFingerprint is the one function the controller's builder and the indexer both call (novox/hq ADR 0437 §5);
|
||||||
|
// a variable only so a test can see it called.
|
||||||
|
var sourceFingerprint = delivery.SourceFingerprint
|
||||||
|
|
||||||
|
// inputs are what the build's fingerprint is of, as the SDK names them.
|
||||||
|
func (s *sourceInputs) inputs() delivery.SourceInputs {
|
||||||
|
if s == nil {
|
||||||
|
return delivery.SourceInputs{}
|
||||||
|
}
|
||||||
|
return delivery.SourceInputs{
|
||||||
|
Module: s.module, Tree: s.tree, Bases: s.bases,
|
||||||
|
Contexts: s.contexts, Toolchains: s.toolchains, Unpinned: s.unpinned,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// fingerprint is the build's source fingerprint, or empty when the source does not pin the build.
|
// fingerprint is the build's source fingerprint, or empty when the source does not pin the build.
|
||||||
func (s *sourceInputs) fingerprint() string {
|
func (s *sourceInputs) fingerprint() string {
|
||||||
if s == nil || s.unpinned != "" || s.tree == "" {
|
if s == nil {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
var lines []string
|
fp, _ := sourceFingerprint(s.inputs())
|
||||||
lines = append(lines, "module "+s.module, "tree "+s.tree)
|
return fp
|
||||||
bases := map[string]bool{}
|
|
||||||
for _, b := range s.bases {
|
|
||||||
bases[referenceDigest(b)] = true
|
|
||||||
}
|
|
||||||
for b := range bases {
|
|
||||||
lines = append(lines, "base "+b)
|
|
||||||
}
|
|
||||||
for a, t := range s.contexts {
|
|
||||||
lines = append(lines, "context "+a+" "+t)
|
|
||||||
}
|
|
||||||
for a, t := range s.toolchains {
|
|
||||||
lines = append(lines, "toolchain "+a+" "+t)
|
|
||||||
}
|
|
||||||
// The module and its tree first, the rest in a fixed order.
|
|
||||||
sort.Strings(lines[2:])
|
|
||||||
sum := sha256.Sum256([]byte(strings.Join(lines, "\n")))
|
|
||||||
return sourcePrefix + hex.EncodeToString(sum[:])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// referenceDigest is a reference's digest — `sha256:…` — so the registry address it was copied into does not
|
// referenceDigest is a reference's digest — `sha256:…` — so the registry address it was copied into does not
|
||||||
// enter the fingerprint; the reference itself when it carries none.
|
// enter the fingerprint; the reference itself when it carries none.
|
||||||
func referenceDigest(reference string) string {
|
func referenceDigest(reference string) string { return delivery.ReferenceDigest(reference) }
|
||||||
if _, digest, pinned := strings.Cut(reference, "@"); pinned && digest != "" {
|
|
||||||
return digest
|
|
||||||
}
|
|
||||||
return reference
|
|
||||||
}
|
|
||||||
|
|
||||||
// gitTree is the git tree of a directory of a clone at its checked-out commit: the whole tree for an
|
// gitTree is the git tree of a directory of a clone at its checked-out commit: the whole tree for an
|
||||||
// empty path.
|
// empty path.
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
package builder
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 1214/AC1: the inputs of three builds the live builder recorded a source fingerprint for, copied from mesh-sdk
|
||||||
|
// go/delivery/fingerprint_test.go (TestFingerprintLiveBuilds), where each value is captured, never written by hand.
|
||||||
|
func TestTheBuildersFingerprintEqualsWhatTheLiveBuilderRecorded(t *testing.T) {
|
||||||
|
const ts = "typescript sha256:4b81682df5665c1f461d3f720a3a0fd7593f4aa71e4fc272dbfdc4bbd235b489 20125bd590041690"
|
||||||
|
for _, c := range []struct {
|
||||||
|
name string
|
||||||
|
in sourceInputs
|
||||||
|
recorded string
|
||||||
|
}{
|
||||||
|
{"mailu", sourceInputs{
|
||||||
|
module: "mailu",
|
||||||
|
tree: "cfc6fa8ff5fc1e220132e51ade653b9d3f387b3e",
|
||||||
|
bases: []string{"sha256:25f3cfeaceca14921366af4d1240b56457ef46273bdb508c7b0e8f469f6fd228"},
|
||||||
|
toolchains: map[string]string{"code": ts},
|
||||||
|
}, "src1:539f2e64000d436c572ef53b40e524012455945f8c5a1c6bb173ee0707e88ef4"},
|
||||||
|
{"radarr", sourceInputs{
|
||||||
|
module: "radarr",
|
||||||
|
tree: "42b9c918b5af75cba2bbc75845012e7f64e16df9",
|
||||||
|
toolchains: map[string]string{
|
||||||
|
"code": ts,
|
||||||
|
"tools-go": "go sha256:2054591ca034b68323d87ec887ba34f56819cb3364034360c0777da4b314dd54 0995d85edc9a6a23",
|
||||||
|
},
|
||||||
|
}, "src1:b3c150d39c395b99dd68974a48490e9a42467aa5d486d0c604d6ef1e8e9c910a"},
|
||||||
|
{"build-agent", sourceInputs{
|
||||||
|
module: "build-agent",
|
||||||
|
tree: "11c0b4f90cae0d6e4ef9cc48cbfdf9f6dbe52bca",
|
||||||
|
bases: []string{
|
||||||
|
"sha256:d9e853e87e55526f6b2917df91a2115c36dd7c696a35be12163d44e6e2a4b6bc",
|
||||||
|
"sha256:8ac98ca534ac3f51e1f420a1dd2c15e74c75cfa0f23f3ad27eb5d7236c349a0c",
|
||||||
|
},
|
||||||
|
contexts: map[string]string{"server": "narrow:d51ca744fd11af59d616c858f76c27b3e58e16e11fbdc0c720eb9d7d19d55fac"},
|
||||||
|
}, "src1:e846efe73716b78e80860baaac96153de2cb76551325753838ca245d2160a3d7"},
|
||||||
|
} {
|
||||||
|
if got := c.in.fingerprint(); got != c.recorded {
|
||||||
|
t.Errorf("%s: fingerprint = %q, the live builder recorded %q", c.name, got, c.recorded)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1214/AC2: a build with no tree, or one that resolves packages when built, gets no fingerprint.
|
||||||
|
func TestABuildWithoutATreeOrUnpinnedGetsNoFingerprint(t *testing.T) {
|
||||||
|
if (&sourceInputs{module: "app"}).fingerprint() != "" {
|
||||||
|
t.Error("a build with no tree has a fingerprint")
|
||||||
|
}
|
||||||
|
s := &sourceInputs{module: "app", tree: "aaaa"}
|
||||||
|
s.notPinned("it resolves packages from the mesh's registry at build time")
|
||||||
|
if got := s.fingerprint(); got != "" {
|
||||||
|
t.Errorf("an unpinned build has a fingerprint: %s", got)
|
||||||
|
}
|
||||||
|
if got := orNoTree(""); got != "its tree was not named" {
|
||||||
|
t.Errorf("the reason for no tree = %q", got)
|
||||||
|
}
|
||||||
|
if got := orNoTree(s.unpinned); got != s.unpinned {
|
||||||
|
t.Errorf("the reason for unpinned = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1214/AC3: the builder has no hash of its own: its fingerprint is what delivery.SourceFingerprint says, whatever
|
||||||
|
// that is (a stand-in proves it is called), and the reuse key does not move with it.
|
||||||
|
func TestTheBuilderFingerprintsWithTheSDKFunction(t *testing.T) {
|
||||||
|
was := sourceFingerprint
|
||||||
|
t.Cleanup(func() { sourceFingerprint = was })
|
||||||
|
var got delivery.SourceInputs
|
||||||
|
sourceFingerprint = func(in delivery.SourceInputs) (string, string) {
|
||||||
|
got = in
|
||||||
|
return "src1:stand-in", ""
|
||||||
|
}
|
||||||
|
s := &sourceInputs{module: "m", tree: "t", bases: []string{"x@sha256:a"}, contexts: map[string]string{"c": "ct"}, toolchains: map[string]string{"c": "go"}}
|
||||||
|
if fp := s.fingerprint(); fp != "src1:stand-in" {
|
||||||
|
t.Fatalf("fingerprint = %q, not the SDK function's", fp)
|
||||||
|
}
|
||||||
|
if got.Module != "m" || got.Tree != "t" || len(got.Bases) != 1 || got.Contexts["c"] != "ct" || got.Toolchains["c"] != "go" {
|
||||||
|
t.Errorf("the SDK function was given %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+1
-1
@@ -9,7 +9,7 @@ a second place to clean besides the catalogue itself.
|
|||||||
|
|
||||||
mesh-catalog e0bc919d4945bb5f13f971f74643a0b714cf45da modules/*/module.json, modules/nats/Dockerfile
|
mesh-catalog e0bc919d4945bb5f13f971f74643a0b714cf45da modules/*/module.json, modules/nats/Dockerfile
|
||||||
mesh-host 9d9e3a46c4f5943cfffb413d962ce48b75e2fd39 examples/foundation-first-node.lock
|
mesh-host 9d9e3a46c4f5943cfffb413d962ce48b75e2fd39 examples/foundation-first-node.lock
|
||||||
mesh-sdk 238440d6f8c46a8dd1d829f4313d7b035a6ca847 conformance/events/module-event.json
|
mesh-sdk aa68b67389c964ea3e573bc50630c77d6518cdab conformance/events/module-event.json
|
||||||
|
|
||||||
To move them, from this repository's root, with the two repositories checked out beside it:
|
To move them, from this repository's root, with the two repositories checked out beside it:
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
package asks
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ConditionSubject is where a module says a condition to the operator: a by-caller accept of the operator-channel
|
||||||
|
// seat, `mesh.seat.operator-channel.accept.condition.<publisher>` (novox/hq ADR 0437 §9). The router reads the
|
||||||
|
// verb at the fifth part and the caller at the sixth, so the publisher is one token, the module's own name; it is
|
||||||
|
// refused when empty or when it holds a dot, a wildcard or whitespace.
|
||||||
|
func ConditionSubject(publisher string) (string, error) {
|
||||||
|
if publisher == "" {
|
||||||
|
return "", errors.New("asks: the condition's publisher is empty")
|
||||||
|
}
|
||||||
|
if strings.ContainsAny(publisher, ".*> \t\r\n") || strings.ContainsFunc(publisher, func(r rune) bool { return r <= ' ' }) {
|
||||||
|
return "", fmt.Errorf("asks: the condition's publisher %q is not one token: it holds a dot, a wildcard or whitespace", publisher)
|
||||||
|
}
|
||||||
|
return "mesh.seat." + Seat + ".accept.condition." + publisher, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConditionVersion is the version of the condition value this package writes and the only one it reads.
|
||||||
|
const ConditionVersion = 1
|
||||||
|
|
||||||
|
// ErrUnknownConditionVersion is what DecodeCondition returns for a value whose version is missing or not
|
||||||
|
// ConditionVersion. Test it with errors.Is.
|
||||||
|
var ErrUnknownConditionVersion = errors.New("asks: unknown condition version")
|
||||||
|
|
||||||
|
// The words a condition's explanation opens with (novox/hq ADR 0253 §1).
|
||||||
|
const (
|
||||||
|
NothingToDo = "Nothing for you to do."
|
||||||
|
NeedsYou = "Needs you: "
|
||||||
|
)
|
||||||
|
|
||||||
|
// The severities and states of a condition.
|
||||||
|
const (
|
||||||
|
SeverityUrgent = "urgent"
|
||||||
|
SeverityWarning = "warning"
|
||||||
|
StateRaised = "raised"
|
||||||
|
StateCleared = "cleared"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Scope is what a condition is about, under the controller's names.
|
||||||
|
type Scope struct {
|
||||||
|
Scope string `json:"scope"`
|
||||||
|
ID string `json:"id"`
|
||||||
|
Machine string `json:"machine,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Action is one answer a notification offers, under the controller's names.
|
||||||
|
type Action struct {
|
||||||
|
Label string `json:"label"`
|
||||||
|
Verb string `json:"verb"`
|
||||||
|
Machine string `json:"machine,omitempty"`
|
||||||
|
Arguments map[string]string `json:"arguments,omitempty"`
|
||||||
|
Level Level `json:"level,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Condition is a condition a module says to the operator, under the names of the controller's conditions: its key,
|
||||||
|
// severity, subject, state (raised or cleared), summary and the plain words of ADR 0253. A cleared condition needs
|
||||||
|
// only its key, subject and state. The router alone chooses the channels.
|
||||||
|
type Condition struct {
|
||||||
|
V int `json:"v"`
|
||||||
|
Key string `json:"key"`
|
||||||
|
Severity string `json:"severity,omitempty"`
|
||||||
|
Subject Scope `json:"subject"`
|
||||||
|
State string `json:"state"`
|
||||||
|
Summary string `json:"summary,omitempty"`
|
||||||
|
Headline string `json:"headline,omitempty"`
|
||||||
|
Needs string `json:"needs,omitempty"`
|
||||||
|
Explanation string `json:"explanation,omitempty"`
|
||||||
|
Resolved string `json:"resolved,omitempty"`
|
||||||
|
Actions []Action `json:"actions,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c Condition) check() error {
|
||||||
|
if c.Key == "" {
|
||||||
|
return errors.New("asks: a condition has no key")
|
||||||
|
}
|
||||||
|
if c.Subject.Scope == "" || c.Subject.ID == "" {
|
||||||
|
return errors.New("asks: a condition's subject needs its scope and id")
|
||||||
|
}
|
||||||
|
switch c.State {
|
||||||
|
case StateCleared:
|
||||||
|
return nil
|
||||||
|
case StateRaised:
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("asks: the condition's state %q is not raised or cleared", c.State)
|
||||||
|
}
|
||||||
|
switch c.Severity {
|
||||||
|
case SeverityUrgent, SeverityWarning:
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("asks: the condition's severity %q is not urgent or warning", c.Severity)
|
||||||
|
}
|
||||||
|
if c.Headline == "" {
|
||||||
|
return errors.New("asks: a raised condition has no headline")
|
||||||
|
}
|
||||||
|
if n := len([]rune(c.Headline)); n > HeadlineLength {
|
||||||
|
return fmt.Errorf("asks: the condition's headline is %d characters, more than %d", n, HeadlineLength)
|
||||||
|
}
|
||||||
|
verdict := NothingToDo
|
||||||
|
if c.Needs != "" {
|
||||||
|
verdict = NeedsYou + c.Needs
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(c.Explanation, verdict) {
|
||||||
|
return fmt.Errorf("asks: the condition's explanation must open with %q", verdict)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// EncodeCondition is c as JSON at ConditionVersion; it refuses what ADR 0253 and ADR 0437 §9 refuse.
|
||||||
|
func EncodeCondition(c Condition) ([]byte, error) {
|
||||||
|
if err := c.check(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
c.V = ConditionVersion
|
||||||
|
return json.Marshal(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DecodeCondition reads a condition; it refuses an unknown version (ErrUnknownConditionVersion) and what
|
||||||
|
// EncodeCondition refuses.
|
||||||
|
func DecodeCondition(data []byte) (Condition, error) {
|
||||||
|
var probe struct {
|
||||||
|
V *int `json:"v"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(data, &probe); err != nil {
|
||||||
|
return Condition{}, fmt.Errorf("asks: the condition is not JSON: %w", err)
|
||||||
|
}
|
||||||
|
if probe.V == nil {
|
||||||
|
return Condition{}, fmt.Errorf("%w: no version in the value, this reader reads %d", ErrUnknownConditionVersion, ConditionVersion)
|
||||||
|
}
|
||||||
|
if *probe.V != ConditionVersion {
|
||||||
|
return Condition{}, fmt.Errorf("%w: the value is version %d, this reader reads %d", ErrUnknownConditionVersion, *probe.V, ConditionVersion)
|
||||||
|
}
|
||||||
|
var c Condition
|
||||||
|
if err := json.Unmarshal(data, &c); err != nil {
|
||||||
|
return Condition{}, fmt.Errorf("asks: the condition does not fit its shape: %w", err)
|
||||||
|
}
|
||||||
|
if err := c.check(); err != nil {
|
||||||
|
return Condition{}, err
|
||||||
|
}
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SameCondition says whether two conditions are the same value.
|
||||||
|
func SameCondition(a, b Condition) bool { return reflect.DeepEqual(a, b) }
|
||||||
+13
-1
@@ -18,7 +18,19 @@
|
|||||||
// question 9 item 7): a writer writes a new version only once every reader's have shows a version that reads it. A
|
// question 9 item 7): a writer writes a new version only once every reader's have shows a version that reads it. A
|
||||||
// writer writes only when the new value differs from the one before (the Same functions).
|
// writer writes only when the new value differs from the one before (the Same functions).
|
||||||
//
|
//
|
||||||
// Standard library only.
|
// **Source, candidates, modules and the forge** (issue 1200; ADR 0437 §5, release 3): the gate's bucket `source` with
|
||||||
|
// its one key `main` (every repository's merged head and the group that moved it), the indexer's `candidates.<group>`
|
||||||
|
// and `modules.<module>` (a module's source fingerprint, or none and why), and the forge bridge's messages named
|
||||||
|
// `forge.<owner>.<name>.<kind>` (source.go).
|
||||||
|
//
|
||||||
|
// **Merges, asks, owed and answers** (issue 1201; ADR 0437 §6, §8): the gate's `merges.<group>`, its asks
|
||||||
|
// `merge.<group>.<heads hash>` (the router's ask whole), its owed keys `<repository>.<number>.<head>.<act>` that the
|
||||||
|
// forge bridge performs, and the teller's answer under the ask's own key (gate.go).
|
||||||
|
//
|
||||||
|
// **The source fingerprint** (issue 1203; ADR 0437 §5): one function of named inputs that the controller's builder and
|
||||||
|
// the indexer both call, so they agree byte for byte (fingerprint.go).
|
||||||
|
//
|
||||||
|
// Standard library only, and this module's own asks package.
|
||||||
package delivery
|
package delivery
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package delivery
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SourceInputs are what a module's source fingerprint is of (novox/hq ADR 0437 §5): the module, the git tree of
|
||||||
|
// its directory, its bases (references, of which only the digest counts), the root tree of each repository its
|
||||||
|
// artifacts read (by artifact), each artifact's toolchain line, and the reason the source does not pin the build
|
||||||
|
// when it does not (a build that resolves packages when built). A context is the value the builder records for it:
|
||||||
|
// a repository's root tree, or for an image that compiles a Go program the `narrow:` hash of the build source it was
|
||||||
|
// handed (mesh-controller internal/builder/builder.go:1146-1156).
|
||||||
|
type SourceInputs struct {
|
||||||
|
Module string
|
||||||
|
Tree string
|
||||||
|
Bases []string
|
||||||
|
Contexts map[string]string
|
||||||
|
Toolchains map[string]string
|
||||||
|
Unpinned string
|
||||||
|
}
|
||||||
|
|
||||||
|
// SourceFingerprint is the module's source fingerprint, `src1:` and the hex sha256 of its inputs' lines, or none
|
||||||
|
// and the reason when the source does not pin the build: Unpinned when set, "no tree" when Tree is empty. It is the
|
||||||
|
// one function the controller's builder and the indexer both call, so they agree byte for byte. The lines are
|
||||||
|
// "module <m>" and "tree <t>", then "base <digest>" (once each), "context <artifact> <tree>" and "toolchain
|
||||||
|
// <artifact> <line>" sorted, all joined by a newline.
|
||||||
|
func SourceFingerprint(in SourceInputs) (fp, none string) {
|
||||||
|
if in.Unpinned != "" {
|
||||||
|
return "", in.Unpinned
|
||||||
|
}
|
||||||
|
if in.Tree == "" {
|
||||||
|
return "", "no tree"
|
||||||
|
}
|
||||||
|
lines := []string{"module " + in.Module, "tree " + in.Tree}
|
||||||
|
bases := map[string]bool{}
|
||||||
|
for _, b := range in.Bases {
|
||||||
|
bases[ReferenceDigest(b)] = true
|
||||||
|
}
|
||||||
|
for b := range bases {
|
||||||
|
lines = append(lines, "base "+b)
|
||||||
|
}
|
||||||
|
for a, t := range in.Contexts {
|
||||||
|
lines = append(lines, "context "+a+" "+t)
|
||||||
|
}
|
||||||
|
for a, t := range in.Toolchains {
|
||||||
|
lines = append(lines, "toolchain "+a+" "+t)
|
||||||
|
}
|
||||||
|
sort.Strings(lines[2:])
|
||||||
|
sum := sha256.Sum256([]byte(strings.Join(lines, "\n")))
|
||||||
|
return SourcePrefix + hex.EncodeToString(sum[:]), ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReferenceDigest is a reference's digest, `sha256:...`, so the registry address a base was copied into does not
|
||||||
|
// enter the fingerprint; the reference itself when it carries none.
|
||||||
|
func ReferenceDigest(reference string) string {
|
||||||
|
if _, digest, pinned := strings.Cut(reference, "@"); pinned && digest != "" {
|
||||||
|
return digest
|
||||||
|
}
|
||||||
|
return reference
|
||||||
|
}
|
||||||
@@ -0,0 +1,404 @@
|
|||||||
|
package delivery
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"reflect"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The gate's buckets and the teller's (ADR 0437 §6, §8). The gate writes merges (each pull request's expected
|
||||||
|
// head, its state and each merge commit), its asks (`merge.<group>.<heads hash>`, the router's ask whole) and owed
|
||||||
|
// (one key per act it owes the forge, which the forge bridge performs once per revision). The teller writes its
|
||||||
|
// answer under the ask's own key in its bucket answers.
|
||||||
|
const (
|
||||||
|
MergesBucket = GateModule + "_merges"
|
||||||
|
AsksBucket = GateModule + "_asks"
|
||||||
|
OwedBucket = GateModule + "_owed"
|
||||||
|
AnswersBucket = TellerModule + "_answers"
|
||||||
|
MergesHistory = 10
|
||||||
|
AsksHistory = 5
|
||||||
|
OwedHistory = 5
|
||||||
|
AnswersHistory = 5
|
||||||
|
)
|
||||||
|
|
||||||
|
// AskKindMerge is the kind of the gate's ask for a group's merge, the first part of its key.
|
||||||
|
const AskKindMerge = "merge"
|
||||||
|
|
||||||
|
// The acts the gate owes the forge, the last part of an owed key.
|
||||||
|
const (
|
||||||
|
OwedStatus = "status"
|
||||||
|
OwedView = "view"
|
||||||
|
OwedNote = "note"
|
||||||
|
OwedComment = "comment"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The states 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"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The states a status the gate owes the forge may carry.
|
||||||
|
var statusStates = []string{"pending", "success", "failure", "error", "warning"}
|
||||||
|
|
||||||
|
var hashOK = regexp.MustCompile(`^[0-9a-f]{12}$`)
|
||||||
|
|
||||||
|
// MergesKey is the key, in MergesBucket, of a group: `<group>`.
|
||||||
|
func MergesKey(group string) (string, error) {
|
||||||
|
if err := checkName("group", group); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return group, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MergeAskKey is the key, in AsksBucket, of the ask for merging a group at these heads: `merge.<group>.<heads
|
||||||
|
// hash>`. The teller's answer has the same key in AnswersBucket (AnswerKey).
|
||||||
|
func MergeAskKey(group string, heads []Head) (string, error) {
|
||||||
|
if err := checkName("group", group); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
hash, err := HeadsHash(heads)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return AskKindMerge + "." + group + "." + 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("delivery: %q is not an ask key, merge.<group>.<heads hash>", key)
|
||||||
|
}
|
||||||
|
if err := checkName("group", parts[1]); err != nil {
|
||||||
|
return "", "", "", fmt.Errorf("delivery: %q is not an ask key: %w", key, err)
|
||||||
|
}
|
||||||
|
if !hashOK.MatchString(parts[2]) {
|
||||||
|
return "", "", "", fmt.Errorf("delivery: %q is not an ask key: %q is not 12 hex digits", key, parts[2])
|
||||||
|
}
|
||||||
|
return parts[0], parts[1], parts[2], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AnswerKey is the key, in AnswersBucket, of the teller's answer to the ask at askKey: the same string.
|
||||||
|
func AnswerKey(askKey string) string { return askKey }
|
||||||
|
|
||||||
|
// AskID is the id the teller gives the router for the ask at askKey. The router's ids refuse dots (asks.UsableID),
|
||||||
|
// 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]
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkOwedAct(act string) (kind, context string, err error) {
|
||||||
|
kind, context, hasContext := strings.Cut(act, "=")
|
||||||
|
switch kind {
|
||||||
|
case OwedStatus:
|
||||||
|
if !hasContext {
|
||||||
|
return "", "", fmt.Errorf("delivery: the owed act %q is a status without its context, status=<context>", act)
|
||||||
|
}
|
||||||
|
if err := checkName("status context", context); err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
return kind, context, nil
|
||||||
|
case OwedView, OwedNote, OwedComment:
|
||||||
|
if hasContext {
|
||||||
|
return "", "", fmt.Errorf("delivery: the owed act %q takes no context", act)
|
||||||
|
}
|
||||||
|
return kind, "", nil
|
||||||
|
}
|
||||||
|
return "", "", fmt.Errorf("delivery: the owed act %q is not status=<context>, view, note or comment", act)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseOwedAct splits an owed act into its kind and, for a status, its context.
|
||||||
|
func ParseOwedAct(act string) (kind, context string, err error) { return checkOwedAct(act) }
|
||||||
|
|
||||||
|
// OwedKey is the key, in OwedBucket, of an act the gate owes the forge on a pull request at a head:
|
||||||
|
// `<repository>.<number>.<head>.<act>`, the act being `status=<context>` (mesh/merge-gate), `view`, `note` or
|
||||||
|
// `comment`.
|
||||||
|
func OwedKey(repository string, number int, head, act string) (string, error) {
|
||||||
|
if err := checkRepository(repository); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if number <= 0 {
|
||||||
|
return "", fmt.Errorf("delivery: an owed key needs the pull request number, not %d", number)
|
||||||
|
}
|
||||||
|
if err := checkName("head", head); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if _, _, err := checkOwedAct(act); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return repository + "." + strconv.Itoa(number) + "." + head + "." + act, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseOwedKey is the inverse of OwedKey; act is whole, as `status=<context>` for a status.
|
||||||
|
func ParseOwedKey(key string) (repository string, number int, head, act string, err error) {
|
||||||
|
parts := strings.Split(key, ".")
|
||||||
|
if len(parts) != 4 {
|
||||||
|
return "", 0, "", "", fmt.Errorf("delivery: %q is not an owed key, <repository>.<number>.<head>.<act>", key)
|
||||||
|
}
|
||||||
|
number, convErr := strconv.Atoi(parts[1])
|
||||||
|
if convErr != nil {
|
||||||
|
return "", 0, "", "", fmt.Errorf("delivery: %q is not an owed key: %q is not a pull request number", key, parts[1])
|
||||||
|
}
|
||||||
|
if _, err := OwedKey(parts[0], number, parts[2], parts[3]); err != nil {
|
||||||
|
return "", 0, "", "", fmt.Errorf("delivery: %q is not an owed key: %w", key, err)
|
||||||
|
}
|
||||||
|
return parts[0], number, parts[2], parts[3], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge is the value of a merges key: the ask approved and, for each pull request, the head the approval expects
|
||||||
|
// and how far its merge has come.
|
||||||
|
type Merge struct {
|
||||||
|
V int `json:"v"`
|
||||||
|
Group string `json:"group"`
|
||||||
|
Ask string `json:"ask"`
|
||||||
|
Pulls []MergePull `json:"pulls"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MergePull is one pull request of a merge: its expected head, its state, the merge commit once merged, the
|
||||||
|
// forge's words when refused.
|
||||||
|
type MergePull struct {
|
||||||
|
Head
|
||||||
|
State MergeState `json:"state"`
|
||||||
|
Commit string `json:"commit,omitempty"`
|
||||||
|
Words string `json:"words,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Merge) check() error {
|
||||||
|
if err := checkName("group", m.Group); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if m.Ask == "" {
|
||||||
|
return fmt.Errorf("delivery: a merge names no ask")
|
||||||
|
}
|
||||||
|
if len(m.Pulls) == 0 {
|
||||||
|
return fmt.Errorf("delivery: a merge names no pull request")
|
||||||
|
}
|
||||||
|
for _, p := range m.Pulls {
|
||||||
|
who := fmt.Sprintf("%s#%d", p.Repository, p.Number)
|
||||||
|
switch p.State {
|
||||||
|
case MergeMerging:
|
||||||
|
case MergeMerged:
|
||||||
|
if p.Commit == "" {
|
||||||
|
return fmt.Errorf("delivery: the merged pull request %s has no merge commit", who)
|
||||||
|
}
|
||||||
|
case MergeRefused:
|
||||||
|
if p.Words == "" {
|
||||||
|
return fmt.Errorf("delivery: the refused pull request %s has no words of the forge", who)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("delivery: the state %q of %s is not merging, merged or refused", p.State, who)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ask is the value of an asks key: the router's ask whole as the gate composes it, so the teller publishes it
|
||||||
|
// unchanged, and the group and heads it was composed for.
|
||||||
|
type Ask struct {
|
||||||
|
V int `json:"v"`
|
||||||
|
Group string `json:"group"`
|
||||||
|
Heads []Head `json:"heads"`
|
||||||
|
Ask asks.Ask `json:"ask"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a Ask) check() error {
|
||||||
|
key, err := MergeAskKey(a.Group, a.Heads)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("delivery: an ask needs its group and at least one pull request's head: %w", err)
|
||||||
|
}
|
||||||
|
if want := AskID(key); a.Ask.ID != want {
|
||||||
|
return fmt.Errorf("delivery: the ask's id %q is not %q, the id its group and heads give", a.Ask.ID, want)
|
||||||
|
}
|
||||||
|
if len(a.Ask.Options) == 0 {
|
||||||
|
return fmt.Errorf("delivery: the ask has no option")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Answer is the teller's answer to an ask: the option chosen, the warrant's id and its words.
|
||||||
|
type Answer struct {
|
||||||
|
V int `json:"v"`
|
||||||
|
Option string `json:"option"`
|
||||||
|
Warrant string `json:"warrant"`
|
||||||
|
Words string `json:"words"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a Answer) check() error {
|
||||||
|
for _, f := range []struct{ name, value string }{{"option", a.Option}, {"warrant", a.Warrant}, {"words", a.Words}} {
|
||||||
|
if f.value == "" {
|
||||||
|
return fmt.Errorf("delivery: an answer has no %s", f.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Owed is the value of an owed key: what the forge bridge performs. A status needs Context and State, a view and a
|
||||||
|
// comment need Text, a note needs Text and Ref (the notes ref, mesh-plan).
|
||||||
|
type Owed struct {
|
||||||
|
V int `json:"v"`
|
||||||
|
Act string `json:"act"`
|
||||||
|
Context string `json:"context,omitempty"`
|
||||||
|
State string `json:"state,omitempty"`
|
||||||
|
Text string `json:"text,omitempty"`
|
||||||
|
Ref string `json:"ref,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o Owed) check() error {
|
||||||
|
switch o.Act {
|
||||||
|
case OwedStatus:
|
||||||
|
if o.Context == "" {
|
||||||
|
return fmt.Errorf("delivery: an owed status has no context")
|
||||||
|
}
|
||||||
|
for _, s := range statusStates {
|
||||||
|
if o.State == s {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fmt.Errorf("delivery: the owed status state %q is not %s", o.State, strings.Join(statusStates, ", "))
|
||||||
|
case OwedView, OwedComment:
|
||||||
|
if o.Text == "" {
|
||||||
|
return fmt.Errorf("delivery: an owed %s has no text", o.Act)
|
||||||
|
}
|
||||||
|
case OwedNote:
|
||||||
|
if o.Text == "" {
|
||||||
|
return fmt.Errorf("delivery: an owed note has no text")
|
||||||
|
}
|
||||||
|
if o.Ref == "" {
|
||||||
|
return fmt.Errorf("delivery: an owed note has no ref")
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("delivery: the owed act %q is not status, view, note or comment", o.Act)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// EncodeMerge is m as JSON at Version; it refuses a merged pull request without its merge commit, a refused one
|
||||||
|
// without the forge's words and a state outside the list.
|
||||||
|
func EncodeMerge(m Merge) ([]byte, error) {
|
||||||
|
if err := m.check(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
m.V = Version
|
||||||
|
return json.Marshal(m)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DecodeMerge reads a merge; it refuses an unknown version (ErrUnknownVersion) and what EncodeMerge refuses.
|
||||||
|
func DecodeMerge(data []byte) (Merge, error) {
|
||||||
|
var m Merge
|
||||||
|
if err := decode(data, &m); err != nil {
|
||||||
|
return Merge{}, err
|
||||||
|
}
|
||||||
|
if err := m.check(); err != nil {
|
||||||
|
return Merge{}, err
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// EncodeAsk is a as JSON at Version; it refuses an ask whose id is not the one its group and heads give, or with
|
||||||
|
// no option.
|
||||||
|
func EncodeAsk(a Ask) ([]byte, error) {
|
||||||
|
if err := a.check(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
a.V = Version
|
||||||
|
return json.Marshal(a)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DecodeAsk reads an ask; it refuses an unknown version (ErrUnknownVersion) and what EncodeAsk refuses.
|
||||||
|
func DecodeAsk(data []byte) (Ask, error) {
|
||||||
|
var a Ask
|
||||||
|
if err := decode(data, &a); err != nil {
|
||||||
|
return Ask{}, err
|
||||||
|
}
|
||||||
|
if err := a.check(); err != nil {
|
||||||
|
return Ask{}, err
|
||||||
|
}
|
||||||
|
return a, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// EncodeAnswer is a as JSON at Version; it refuses an answer missing its option, warrant or words.
|
||||||
|
func EncodeAnswer(a Answer) ([]byte, error) {
|
||||||
|
if err := a.check(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
a.V = Version
|
||||||
|
return json.Marshal(a)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DecodeAnswer reads an answer; it refuses an unknown version (ErrUnknownVersion) and what EncodeAnswer refuses.
|
||||||
|
func DecodeAnswer(data []byte) (Answer, error) {
|
||||||
|
var a Answer
|
||||||
|
if err := decode(data, &a); err != nil {
|
||||||
|
return Answer{}, err
|
||||||
|
}
|
||||||
|
if err := a.check(); err != nil {
|
||||||
|
return Answer{}, err
|
||||||
|
}
|
||||||
|
return a, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// EncodeOwed is o as JSON at Version; it refuses an act missing what it needs.
|
||||||
|
func EncodeOwed(o Owed) ([]byte, error) {
|
||||||
|
if err := o.check(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
o.V = Version
|
||||||
|
return json.Marshal(o)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DecodeOwed reads an owed act; it refuses an unknown version (ErrUnknownVersion) and what EncodeOwed refuses.
|
||||||
|
func DecodeOwed(data []byte) (Owed, error) {
|
||||||
|
var o Owed
|
||||||
|
if err := decode(data, &o); err != nil {
|
||||||
|
return Owed{}, err
|
||||||
|
}
|
||||||
|
if err := o.check(); err != nil {
|
||||||
|
return Owed{}, err
|
||||||
|
}
|
||||||
|
return o, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SameMerge says whether two merges are the same value, so a writer writes only when they differ.
|
||||||
|
func SameMerge(a, b Merge) bool { return reflect.DeepEqual(a, b) }
|
||||||
|
|
||||||
|
// SameAsk says whether two asks are the same value.
|
||||||
|
func SameAsk(a, b Ask) bool { return reflect.DeepEqual(a, b) }
|
||||||
|
|
||||||
|
// SameAnswer says whether two answers are the same value.
|
||||||
|
func SameAnswer(a, b Answer) bool { return a == b }
|
||||||
|
|
||||||
|
// SameOwed says whether two owed acts are the same value.
|
||||||
|
func SameOwed(a, b Owed) bool { return a == b }
|
||||||
@@ -0,0 +1,407 @@
|
|||||||
|
package delivery
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"reflect"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The modules of the parts release 3 adds (novox/hq ADR 0437): their buckets are their own state, `<module>_<name>`.
|
||||||
|
const (
|
||||||
|
GateModule = "mesh-delivery-gate"
|
||||||
|
IndexerModule = "mesh-delivery-indexer"
|
||||||
|
ForgeBridgeModule = "mesh-delivery-forge-bridge"
|
||||||
|
TellerModule = "mesh-delivery-teller"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The gate's source bucket and the indexer's candidates and modules buckets (ADR 0437 §5). The source bucket has
|
||||||
|
// one key, SourceMainKey: every repository's merged head and the group that moved it, written by the gate alone by
|
||||||
|
// compare-and-set; its revision is the mesh's source revision. The indexer writes the other two.
|
||||||
|
const (
|
||||||
|
SourceBucket = GateModule + "_source"
|
||||||
|
SourceMainKey = "main"
|
||||||
|
SourceHistory = 64
|
||||||
|
CandidatesBucket = IndexerModule + "_candidates"
|
||||||
|
// CandidatesHistory is the revisions the candidates bucket keeps per key.
|
||||||
|
CandidatesHistory = 10
|
||||||
|
ModulesBucket = IndexerModule + "_modules"
|
||||||
|
// ModulesHistory is the revisions the modules bucket keeps per key.
|
||||||
|
ModulesHistory = 20
|
||||||
|
)
|
||||||
|
|
||||||
|
// ForgeStream is the forge bridge's stream as its manifest declares it; the broker derives the real names.
|
||||||
|
const ForgeStream = "forge"
|
||||||
|
|
||||||
|
// The kinds of forge message, the last part of a forge event's name.
|
||||||
|
const (
|
||||||
|
ForgePull = "pull"
|
||||||
|
ForgeMerged = "merged"
|
||||||
|
ForgeRefused = "refused"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The actions of a pull message.
|
||||||
|
const (
|
||||||
|
PullOpened = "opened"
|
||||||
|
PullUpdated = "updated"
|
||||||
|
PullClosed = "closed"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SourcePrefix opens every source fingerprint.
|
||||||
|
const SourcePrefix = "src1:"
|
||||||
|
|
||||||
|
// CandidatesKey is the key, in CandidatesBucket, of a group: `<group>`, 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 err := checkName("group", group); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return group, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseCandidatesKey is the inverse of CandidatesKey.
|
||||||
|
func ParseCandidatesKey(key string) (group string, err error) {
|
||||||
|
if group, err = CandidatesKey(key); err != nil {
|
||||||
|
return "", fmt.Errorf("delivery: %q is not a candidates key: %w", key, err)
|
||||||
|
}
|
||||||
|
return group, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ModulesKey is the key, in ModulesBucket, of a module: `<module>`.
|
||||||
|
func ModulesKey(module string) (string, error) {
|
||||||
|
if err := checkName("module", module); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return module, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseModulesKey is the inverse of ModulesKey.
|
||||||
|
func ParseModulesKey(key string) (module string, err error) {
|
||||||
|
if module, err = ModulesKey(key); err != nil {
|
||||||
|
return "", fmt.Errorf("delivery: %q is not a modules key: %w", key, err)
|
||||||
|
}
|
||||||
|
return module, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkRepository refuses a repository that is not `<owner>/<name>`: exactly one slash, each part a valid name.
|
||||||
|
func checkRepository(repository string) error {
|
||||||
|
owner, name, ok := strings.Cut(repository, "/")
|
||||||
|
if !ok || strings.Contains(name, "/") {
|
||||||
|
return fmt.Errorf("delivery: the repository %q is not <owner>/<name>, with exactly one slash", repository)
|
||||||
|
}
|
||||||
|
if err := checkName("repository owner", owner); err != nil {
|
||||||
|
return fmt.Errorf("delivery: the repository %q: %w", repository, err)
|
||||||
|
}
|
||||||
|
if err := checkName("repository name", name); err != nil {
|
||||||
|
return fmt.Errorf("delivery: the repository %q: %w", repository, err)
|
||||||
|
}
|
||||||
|
if strings.Contains(owner, "/") {
|
||||||
|
return fmt.Errorf("delivery: the repository %q is not <owner>/<name>, with exactly one slash", repository)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkForgeKind(kind string) error {
|
||||||
|
switch kind {
|
||||||
|
case ForgePull, ForgeMerged, ForgeRefused:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("delivery: the forge kind %q is not pull, merged or refused", kind)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ForgeEvent is the name of a forge message of the given kind: `forge.<owner>.<name>.<kind>`.
|
||||||
|
func ForgeEvent(repository, kind string) (string, error) {
|
||||||
|
if err := checkRepository(repository); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if err := checkForgeKind(kind); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
owner, name, _ := strings.Cut(repository, "/")
|
||||||
|
return "forge." + owner + "." + name + "." + kind, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseForgeEvent is the inverse of ForgeEvent.
|
||||||
|
func ParseForgeEvent(event string) (repository, kind string, err error) {
|
||||||
|
parts := strings.Split(event, ".")
|
||||||
|
if len(parts) != 4 || parts[0] != "forge" {
|
||||||
|
return "", "", fmt.Errorf("delivery: %q is not a forge event, forge.<owner>.<name>.<kind>", event)
|
||||||
|
}
|
||||||
|
repository = parts[1] + "/" + parts[2]
|
||||||
|
if _, err := ForgeEvent(repository, parts[3]); err != nil {
|
||||||
|
return "", "", fmt.Errorf("delivery: %q is not a forge event: %w", event, err)
|
||||||
|
}
|
||||||
|
return repository, parts[3], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Head is one pull request at one commit: the repository as `<owner>/<name>` 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 `<repository>#<number>=<head>`, 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("delivery: 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// Source is the value of SourceMainKey: every repository's merged head (repository to full sha, not only the
|
||||||
|
// group's) and the group that moved it.
|
||||||
|
type Source struct {
|
||||||
|
V int `json:"v"`
|
||||||
|
Group string `json:"group"`
|
||||||
|
Heads map[string]string `json:"heads"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s Source) check() error {
|
||||||
|
if len(s.Heads) == 0 {
|
||||||
|
return fmt.Errorf("delivery: a source names no repository")
|
||||||
|
}
|
||||||
|
for repository, head := range s.Heads {
|
||||||
|
if repository == "" {
|
||||||
|
return fmt.Errorf("delivery: a source names a repository with no name")
|
||||||
|
}
|
||||||
|
if head == "" {
|
||||||
|
return fmt.Errorf("delivery: the source's head of %s is empty", repository)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Candidate is the value of a candidates key: the group's pull requests and heads, the heads hash, the base (the
|
||||||
|
// source revision it was previewed onto) and the modules each pull request touches. It carries no fingerprint
|
||||||
|
// until release 4 (ADR 0437 §5).
|
||||||
|
type Candidate struct {
|
||||||
|
V int `json:"v"`
|
||||||
|
Group string `json:"group"`
|
||||||
|
Base uint64 `json:"base"`
|
||||||
|
HeadsHash string `json:"heads"`
|
||||||
|
Pulls []CandidatePull `json:"pulls"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CandidatePull is one pull request of a candidate and the modules it touches.
|
||||||
|
type CandidatePull struct {
|
||||||
|
Head
|
||||||
|
Modules []string `json:"modules"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c Candidate) check() error {
|
||||||
|
if len(c.Pulls) == 0 {
|
||||||
|
return fmt.Errorf("delivery: a candidate names no pull request")
|
||||||
|
}
|
||||||
|
heads := make([]Head, len(c.Pulls))
|
||||||
|
for i, p := range c.Pulls {
|
||||||
|
heads[i] = p.Head
|
||||||
|
}
|
||||||
|
want, _ := HeadsHash(heads)
|
||||||
|
if c.HeadsHash != want {
|
||||||
|
return fmt.Errorf("delivery: the candidate's heads hash %q is not the hash %q of its pull requests' heads", c.HeadsHash, want)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Module is the value of a modules key: the module's source fingerprint, or none and why (a build that resolves
|
||||||
|
// packages when built gets none, said here), and where it was computed: Repository and Commit are the trunk merge,
|
||||||
|
// Src the source revision then (0 before the gate writes source.main). Exactly one of FP and None is set.
|
||||||
|
type Module struct {
|
||||||
|
V int `json:"v"`
|
||||||
|
FP string `json:"fp,omitempty"`
|
||||||
|
None string `json:"none,omitempty"`
|
||||||
|
Src uint64 `json:"src,omitempty"`
|
||||||
|
Repository string `json:"repository"`
|
||||||
|
Commit string `json:"commit"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Module) check() error {
|
||||||
|
switch {
|
||||||
|
case m.FP != "" && m.None != "":
|
||||||
|
return fmt.Errorf("delivery: a module has both a fingerprint and a reason for none")
|
||||||
|
case m.FP == "" && m.None == "":
|
||||||
|
return fmt.Errorf("delivery: a module has neither a fingerprint nor a reason for none")
|
||||||
|
case m.FP != "" && !strings.HasPrefix(m.FP, SourcePrefix):
|
||||||
|
return fmt.Errorf("delivery: the module's fingerprint %q does not start with %s", m.FP, SourcePrefix)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Forge is a message of the forge bridge's stream, trimmed to repository, branch, pull request and head. A pull
|
||||||
|
// message says Action; a merged message carries the merge commit, the tree of every directory holding a
|
||||||
|
// module.json and the repository's root tree; a refused message names the owed act it answers and the forge's own
|
||||||
|
// words.
|
||||||
|
type Forge struct {
|
||||||
|
V int `json:"v"`
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
Repository string `json:"repository"`
|
||||||
|
Branch string `json:"branch,omitempty"`
|
||||||
|
Number int `json:"number,omitempty"`
|
||||||
|
Head string `json:"head,omitempty"`
|
||||||
|
Action string `json:"action,omitempty"`
|
||||||
|
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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f Forge) check() error {
|
||||||
|
if err := checkRepository(f.Repository); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := checkForgeKind(f.Kind); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
switch f.Kind {
|
||||||
|
case ForgePull:
|
||||||
|
if f.Number <= 0 {
|
||||||
|
return fmt.Errorf("delivery: a pull message needs its pull request number")
|
||||||
|
}
|
||||||
|
if f.Head == "" {
|
||||||
|
return fmt.Errorf("delivery: a pull message needs its head")
|
||||||
|
}
|
||||||
|
switch f.Action {
|
||||||
|
case PullOpened, PullUpdated, PullClosed:
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("delivery: the pull action %q is not opened, updated or closed", f.Action)
|
||||||
|
}
|
||||||
|
case ForgeMerged:
|
||||||
|
if f.Number <= 0 {
|
||||||
|
return fmt.Errorf("delivery: a merged message needs its pull request number")
|
||||||
|
}
|
||||||
|
if f.Head == "" {
|
||||||
|
return fmt.Errorf("delivery: a merged message needs its head")
|
||||||
|
}
|
||||||
|
if f.Merge == "" {
|
||||||
|
return fmt.Errorf("delivery: a merged message needs its merge commit")
|
||||||
|
}
|
||||||
|
if f.Root == "" {
|
||||||
|
return fmt.Errorf("delivery: a merged message needs the repository's root tree")
|
||||||
|
}
|
||||||
|
if len(f.Trees) == 0 {
|
||||||
|
return fmt.Errorf("delivery: a merged message needs the tree of at least one directory holding a module.json")
|
||||||
|
}
|
||||||
|
for dir, tree := range f.Trees {
|
||||||
|
if tree == "" {
|
||||||
|
return fmt.Errorf("delivery: a merged message's tree of the directory %q is empty", dir)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case ForgeRefused:
|
||||||
|
if f.Act == "" {
|
||||||
|
return fmt.Errorf("delivery: a refused message needs the act it answers")
|
||||||
|
}
|
||||||
|
if f.Words == "" {
|
||||||
|
return fmt.Errorf("delivery: a refused message needs the forge's words")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// EncodeSource is s as JSON at Version; it refuses a source with no repository or an empty head.
|
||||||
|
func EncodeSource(s Source) ([]byte, error) {
|
||||||
|
if err := s.check(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
s.V = Version
|
||||||
|
return json.Marshal(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DecodeSource reads a source; it refuses an unknown version (ErrUnknownVersion) and what EncodeSource refuses.
|
||||||
|
func DecodeSource(data []byte) (Source, error) {
|
||||||
|
var s Source
|
||||||
|
if err := decode(data, &s); err != nil {
|
||||||
|
return Source{}, err
|
||||||
|
}
|
||||||
|
if err := s.check(); err != nil {
|
||||||
|
return Source{}, err
|
||||||
|
}
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// EncodeCandidate is c as JSON at Version; it refuses a heads hash that is not the hash of its pull requests' heads.
|
||||||
|
func EncodeCandidate(c Candidate) ([]byte, error) {
|
||||||
|
if err := c.check(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
c.V = Version
|
||||||
|
return json.Marshal(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DecodeCandidate reads a candidate; it refuses an unknown version (ErrUnknownVersion) and what EncodeCandidate refuses.
|
||||||
|
func DecodeCandidate(data []byte) (Candidate, error) {
|
||||||
|
var c Candidate
|
||||||
|
if err := decode(data, &c); err != nil {
|
||||||
|
return Candidate{}, err
|
||||||
|
}
|
||||||
|
if err := c.check(); err != nil {
|
||||||
|
return Candidate{}, err
|
||||||
|
}
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// EncodeModule is m as JSON at Version; it refuses both or neither of a fingerprint and a reason, and a
|
||||||
|
// fingerprint not of the form src1:.
|
||||||
|
func EncodeModule(m Module) ([]byte, error) {
|
||||||
|
if err := m.check(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
m.V = Version
|
||||||
|
return json.Marshal(m)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DecodeModule reads a module; it refuses an unknown version (ErrUnknownVersion) and what EncodeModule refuses.
|
||||||
|
func DecodeModule(data []byte) (Module, error) {
|
||||||
|
var m Module
|
||||||
|
if err := decode(data, &m); err != nil {
|
||||||
|
return Module{}, err
|
||||||
|
}
|
||||||
|
if err := m.check(); err != nil {
|
||||||
|
return Module{}, err
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// EncodeForge is f as JSON at Version; it refuses a message missing what its kind needs.
|
||||||
|
func EncodeForge(f Forge) ([]byte, error) {
|
||||||
|
if err := f.check(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
f.V = Version
|
||||||
|
return json.Marshal(f)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DecodeForge reads a forge message; it refuses an unknown version (ErrUnknownVersion) and what EncodeForge refuses.
|
||||||
|
func DecodeForge(data []byte) (Forge, error) {
|
||||||
|
var f Forge
|
||||||
|
if err := decode(data, &f); err != nil {
|
||||||
|
return Forge{}, err
|
||||||
|
}
|
||||||
|
if err := f.check(); err != nil {
|
||||||
|
return Forge{}, err
|
||||||
|
}
|
||||||
|
return f, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SameSource says whether two sources are the same value, so a writer writes only when they differ.
|
||||||
|
func SameSource(a, b Source) bool { return reflect.DeepEqual(a, b) }
|
||||||
|
|
||||||
|
// SameCandidate says whether two candidates are the same value.
|
||||||
|
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 }
|
||||||
|
|
||||||
|
// SameForge says whether two forge messages are the same value.
|
||||||
|
func SameForge(a, b Forge) bool { return reflect.DeepEqual(a, b) }
|
||||||
Vendored
+2
-2
@@ -1,5 +1,5 @@
|
|||||||
# git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261018184557-238440d6f8c4
|
# git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261019091011-aa68b67389c9
|
||||||
## explicit; go 1.22
|
## explicit; go 1.26.0
|
||||||
git.novox.be/novox/mesh-sdk/go/asks
|
git.novox.be/novox/mesh-sdk/go/asks
|
||||||
git.novox.be/novox/mesh-sdk/go/delivery
|
git.novox.be/novox/mesh-sdk/go/delivery
|
||||||
git.novox.be/novox/mesh-sdk/go/seal
|
git.novox.be/novox/mesh-sdk/go/seal
|
||||||
|
|||||||
Reference in New Issue
Block a user