Merge branch 'b2/registry' into b7/registry
This commit is contained in:
@@ -0,0 +1,222 @@
|
||||
// Package meshdeliverycheckerclient holds the logic the holders and callers of the seat mesh-delivery-checker share over its
|
||||
// contract (novox/hq ADR 0625 §4, ADR 0634 §2): the rules a verdict and a kept verdict must keep to be written or read. The
|
||||
// checker writes them, and the view, the gate and the indexer read them; the contract's decoding is strict by schema only, so
|
||||
// these rules are written once here and no caller copies them.
|
||||
package meshdeliverycheckerclient
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
checker "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdeliverychecker"
|
||||
"git.novox.be/novox/mesh-delivery/sdk/kept"
|
||||
)
|
||||
|
||||
// The states of a verdict, the levels of a check and the compositions' states.
|
||||
const (
|
||||
VerdictRunning = "running"
|
||||
VerdictPass = "pass"
|
||||
VerdictFail = "fail"
|
||||
|
||||
LevelUnit = "unit"
|
||||
LevelIntegration = "integration"
|
||||
|
||||
CompositionNotRun = "not-run"
|
||||
CompositionPass = "pass"
|
||||
CompositionFail = "fail"
|
||||
)
|
||||
|
||||
// MaxFailures is the most failures a verdict names; it names the count of the rest beside them.
|
||||
const MaxFailures = 50
|
||||
|
||||
func checkLevel(level string) error {
|
||||
if level != LevelUnit && level != LevelIntegration {
|
||||
return fmt.Errorf("meshdeliverycheckerclient: the level %q is not %s or %s", level, LevelUnit, LevelIntegration)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isHex12(s string) bool { return len(s) == 12 && strings.Trim(s, "0123456789abcdef") == "" }
|
||||
|
||||
// CheckRepository refuses a repository that is not <owner>/<name> with exactly one slash, each part a key part.
|
||||
func CheckRepository(repository string) error {
|
||||
owner, name, ok := strings.Cut(repository, "/")
|
||||
if !ok || strings.Contains(name, "/") {
|
||||
return fmt.Errorf("meshdeliverycheckerclient: the repository %q is not <owner>/<name>, with exactly one slash", repository)
|
||||
}
|
||||
if err := kept.CheckName("repository owner", owner); err != nil {
|
||||
return fmt.Errorf("meshdeliverycheckerclient: the repository %q: %w", repository, err)
|
||||
}
|
||||
if err := kept.CheckName("repository name", name); err != nil {
|
||||
return fmt.Errorf("meshdeliverycheckerclient: the repository %q: %w", repository, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func checkComposition(c *checker.VerdictsByGroupHeadsValueComposition) error {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
switch c.State {
|
||||
case CompositionNotRun:
|
||||
if c.Sdk != "" || len(c.Machines) > 0 {
|
||||
return fmt.Errorf("meshdeliverycheckerclient: the verdict's composition is not run and names no mesh-sdk commit or machines")
|
||||
}
|
||||
case CompositionPass:
|
||||
if c.Sdk == "" {
|
||||
return fmt.Errorf("meshdeliverycheckerclient: the verdict's composition passed and names no mesh-sdk commit")
|
||||
}
|
||||
case CompositionFail:
|
||||
if c.Words == "" {
|
||||
return fmt.Errorf("meshdeliverycheckerclient: the verdict's composition failed and gives no words")
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("meshdeliverycheckerclient: the verdict's composition state %q is not %s, %s or %s", c.State, CompositionNotRun, CompositionPass, CompositionFail)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CheckVerdict refuses a verdict a reader could not place: no group, heads that are not 12 lowercase hex digits, a repository
|
||||
// both merged and read beside, a carried-from that carries nothing, a series change from a series to itself or named twice, a
|
||||
// level that is neither unit nor integration or counts a negative number, a time below zero, a pass that says nothing of a
|
||||
// level, a fail that names neither a test nor a machine, and failures on a verdict that did not fail.
|
||||
func CheckVerdict(v checker.VerdictsByGroupHeadsValue) error {
|
||||
const who = "meshdeliverycheckerclient: "
|
||||
if v.Group == "" {
|
||||
return fmt.Errorf(who + "a verdict names no group")
|
||||
}
|
||||
if !isHex12(v.Heads) {
|
||||
return fmt.Errorf(who+"the verdict's heads hash %q is not 12 lowercase hex digits", v.Heads)
|
||||
}
|
||||
for repository, head := range v.Trunk {
|
||||
if head == "" {
|
||||
return fmt.Errorf(who+"the verdict's trunk head of %s is empty", repository)
|
||||
}
|
||||
}
|
||||
for repository, commit := range v.Beside {
|
||||
if commit == "" {
|
||||
return fmt.Errorf(who+"the verdict's sibling commit of %s is empty", repository)
|
||||
}
|
||||
if _, ok := v.Trunk[repository]; ok {
|
||||
return fmt.Errorf(who+"the verdict names %s as a trunk it merged and as a sibling it read", repository)
|
||||
}
|
||||
}
|
||||
if len(v.CarriedFrom) > 0 {
|
||||
if v.State == VerdictRunning {
|
||||
return fmt.Errorf(who + "a running verdict is carried from nothing: carried-from is written with its end")
|
||||
}
|
||||
moved := false
|
||||
for repository, commit := range v.CarriedFrom {
|
||||
now, inTrunk := v.Trunk[repository]
|
||||
if !inTrunk {
|
||||
now = v.Beside[repository]
|
||||
}
|
||||
if commit == "" || now == "" {
|
||||
return fmt.Errorf(who+"the verdict's carried-from names %s, which it judged on no commit of", repository)
|
||||
}
|
||||
moved = moved || commit != now
|
||||
}
|
||||
if !moved {
|
||||
return fmt.Errorf(who + "the verdict's carried-from names the commits it is judged on: nothing moved, so nothing was carried")
|
||||
}
|
||||
}
|
||||
changed := map[string]bool{}
|
||||
for _, c := range v.Series {
|
||||
if c.Module == "" || c.Artifact == "" || c.From == "" || c.To == "" {
|
||||
return fmt.Errorf(who+"the verdict's series change %+v names no module, artifact, from or to", c)
|
||||
}
|
||||
if c.From == c.To {
|
||||
return fmt.Errorf(who+"the verdict's series change of %s/%s is from %s to the same series", c.Module, c.Artifact, c.From)
|
||||
}
|
||||
if changed[c.Module+"/"+c.Artifact] {
|
||||
return fmt.Errorf(who+"the verdict names the series change of %s/%s twice", c.Module, c.Artifact)
|
||||
}
|
||||
changed[c.Module+"/"+c.Artifact] = true
|
||||
}
|
||||
for level, r := range v.Levels {
|
||||
if err := checkLevel(level); err != nil {
|
||||
return err
|
||||
}
|
||||
if r.Run < 0 || r.Kept < 0 || r.Skipped < 0 {
|
||||
return fmt.Errorf(who+"the verdict's %s level counts a negative number of tests", level)
|
||||
}
|
||||
}
|
||||
if err := checkComposition(v.Composition); err != nil {
|
||||
return err
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, level := range v.OverBound {
|
||||
if err := checkLevel(level); err != nil {
|
||||
return fmt.Errorf(who+"the verdict's over-bound names: %w", err)
|
||||
}
|
||||
if seen[level] {
|
||||
return fmt.Errorf(who+"the verdict's over-bound names the level %s twice", level)
|
||||
}
|
||||
seen[level] = true
|
||||
}
|
||||
if t := v.Took; t != nil {
|
||||
for part, ms := range map[string]int64{"queue": t.Queue, "clone": t.Clone, "unit": t.Unit, "lint": t.Lint,
|
||||
"integration-setup": t.IntegrationSetup, "integration-run": t.IntegrationRun, "composition": t.Composition} {
|
||||
if ms < 0 {
|
||||
return fmt.Errorf(who+"the verdict's time of the part %s is negative", part)
|
||||
}
|
||||
}
|
||||
}
|
||||
named := v.Failed != nil && (v.Failed.Test != "" || v.Failed.Machine != "")
|
||||
if len(v.Failures) > 0 && v.State != VerdictFail {
|
||||
return fmt.Errorf(who+"a %s verdict names no failures", v.State)
|
||||
}
|
||||
if len(v.Failures) > MaxFailures+1 {
|
||||
return fmt.Errorf(who+"the verdict names %d failures, more than %d and the count of the rest", len(v.Failures), MaxFailures)
|
||||
}
|
||||
switch v.State {
|
||||
case VerdictRunning:
|
||||
if v.Failed != nil && *v.Failed != (checker.VerdictsByGroupHeadsValueFailed{}) {
|
||||
return fmt.Errorf(who + "a running verdict names no failure")
|
||||
}
|
||||
case VerdictPass:
|
||||
if v.Failed != nil && *v.Failed != (checker.VerdictsByGroupHeadsValueFailed{}) {
|
||||
return fmt.Errorf(who + "a pass names no failure")
|
||||
}
|
||||
for _, level := range []string{LevelUnit, LevelIntegration} {
|
||||
if _, ok := v.Levels[level]; !ok {
|
||||
return fmt.Errorf(who+"a pass says nothing of the %s level", level)
|
||||
}
|
||||
}
|
||||
case VerdictFail:
|
||||
if !named {
|
||||
return fmt.Errorf(who + "a fail names neither a test nor a machine")
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf(who+"the verdict state %q is not running, pass or fail", v.State)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CheckKept refuses a kept verdict that is not a pass of a repository's package at a level, with its toolchain and its time:
|
||||
// a failure is never kept.
|
||||
func CheckKept(k checker.KeptByLevelDigestValue) error {
|
||||
const who = "meshdeliverycheckerclient: "
|
||||
if k.State != VerdictPass {
|
||||
return fmt.Errorf(who+"a kept verdict is a pass, never %q: a failure is never kept", k.State)
|
||||
}
|
||||
if err := CheckRepository(k.Repository); err != nil {
|
||||
return err
|
||||
}
|
||||
if k.Package == "" {
|
||||
return fmt.Errorf(who + "a kept verdict names no package")
|
||||
}
|
||||
if err := checkLevel(k.Level); err != nil {
|
||||
return err
|
||||
}
|
||||
if k.Tests < 0 || k.Took < 0 {
|
||||
return fmt.Errorf(who + "a kept verdict counts a negative number of tests or a negative time")
|
||||
}
|
||||
if k.Toolchain == "" {
|
||||
return fmt.Errorf(who + "a kept verdict names no toolchain version")
|
||||
}
|
||||
if k.At == "" {
|
||||
return fmt.Errorf(who + "a kept verdict says no time")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package meshdeliverycheckerclient
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
checker "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdeliverychecker"
|
||||
)
|
||||
|
||||
type verdict = checker.VerdictsByGroupHeadsValue
|
||||
|
||||
func passing() verdict {
|
||||
return verdict{Group: "g", Heads: "0123456789ab", State: VerdictPass, Trunk: map[string]string{"novox/a": "c1"},
|
||||
Levels: map[string]checker.VerdictsByGroupHeadsValueLevelsValue{LevelUnit: {Run: 3}, LevelIntegration: {Run: 1}}}
|
||||
}
|
||||
|
||||
// The rules a verdict keeps to be written or read: each case edits a passing verdict and names what the refusal says.
|
||||
func TestAVerdictIsRefusedForWhatAReaderCouldNotPlace(t *testing.T) {
|
||||
if err := CheckVerdict(passing()); err != nil {
|
||||
t.Fatalf("a passing verdict is refused: %v", err)
|
||||
}
|
||||
for name, c := range map[string]struct {
|
||||
edit func(*verdict)
|
||||
says string
|
||||
}{
|
||||
"no group": {func(v *verdict) { v.Group = "" }, "no group"},
|
||||
"heads of 11 digits": {func(v *verdict) { v.Heads = "0123456789a" }, "12 lowercase hex"},
|
||||
"an empty trunk head": {func(v *verdict) { v.Trunk["novox/a"] = "" }, "trunk head"},
|
||||
"merged and read": {func(v *verdict) { v.Beside = map[string]string{"novox/a": "c2"} }, "trunk it merged and as a sibling"},
|
||||
"nothing carried": {func(v *verdict) { v.CarriedFrom = map[string]string{"novox/a": "c1"} }, "nothing moved"},
|
||||
"a running carry": {func(v *verdict) { v.State, v.CarriedFrom = VerdictRunning, map[string]string{"novox/a": "c0"} }, "carried from nothing"},
|
||||
"a series to itself": {func(v *verdict) {
|
||||
v.Series = []checker.VerdictsByGroupHeadsValueSeriesItem{{Module: "m", Artifact: "a", From: "0.1", To: "0.1"}}
|
||||
}, "same series"},
|
||||
"an unknown level": {func(v *verdict) { v.Levels["smoke"] = checker.VerdictsByGroupHeadsValueLevelsValue{} }, "smoke"},
|
||||
"a negative count": {func(v *verdict) { v.Levels[LevelUnit] = checker.VerdictsByGroupHeadsValueLevelsValue{Run: -1} }, "negative"},
|
||||
"a negative time": {func(v *verdict) { v.Took = &checker.VerdictsByGroupHeadsValueTook{Unit: -1} }, "negative"},
|
||||
"a pass without a level": {func(v *verdict) { delete(v.Levels, LevelIntegration) }, "integration level"},
|
||||
"a pass that failed": {func(v *verdict) { v.Failed = &checker.VerdictsByGroupHeadsValueFailed{Test: "T"} }, "names no failure"},
|
||||
"failures of a pass": {func(v *verdict) { v.Failures = []checker.VerdictsByGroupHeadsValueFailuresItem{{Test: "T"}} }, "names no failures"},
|
||||
"a fail naming nothing": {func(v *verdict) { v.State = VerdictFail }, "neither a test nor a machine"},
|
||||
"an unknown state": {func(v *verdict) { v.State = "maybe" }, "maybe"},
|
||||
"a composition passed without a commit": {func(v *verdict) {
|
||||
v.Composition = &checker.VerdictsByGroupHeadsValueComposition{State: CompositionPass}
|
||||
}, "mesh-sdk commit"},
|
||||
"the same level over bound twice": {func(v *verdict) { v.OverBound = []string{LevelUnit, LevelUnit} }, "twice"},
|
||||
} {
|
||||
v := passing()
|
||||
c.edit(&v)
|
||||
if err := CheckVerdict(v); err == nil || !strings.Contains(err.Error(), c.says) {
|
||||
t.Errorf("%s: %v, want a refusal saying %q", name, err, c.says)
|
||||
}
|
||||
}
|
||||
failed := passing()
|
||||
failed.State, failed.Failed = VerdictFail, &checker.VerdictsByGroupHeadsValueFailed{Machine: "ace"}
|
||||
if err := CheckVerdict(failed); err != nil {
|
||||
t.Errorf("a fail naming a machine is refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAKeptVerdictIsAPassOfAPackageAtALevel(t *testing.T) {
|
||||
ok := checker.KeptByLevelDigestValue{State: VerdictPass, Repository: "novox/mesh-sdk", Package: "./go/keys", Level: LevelUnit, Toolchain: "1.26.0", At: "2026-11-05T10:00:00Z"}
|
||||
if err := CheckKept(ok); err != nil {
|
||||
t.Fatalf("a kept pass is refused: %v", err)
|
||||
}
|
||||
for name, edit := range map[string]func(*checker.KeptByLevelDigestValue){
|
||||
"a fail": func(k *checker.KeptByLevelDigestValue) { k.State = VerdictFail },
|
||||
"no owner": func(k *checker.KeptByLevelDigestValue) { k.Repository = "mesh-sdk" },
|
||||
"no package": func(k *checker.KeptByLevelDigestValue) { k.Package = "" },
|
||||
"an unknown level": func(k *checker.KeptByLevelDigestValue) { k.Level = "smoke" },
|
||||
"negative tests": func(k *checker.KeptByLevelDigestValue) { k.Tests = -1 },
|
||||
"no toolchain": func(k *checker.KeptByLevelDigestValue) { k.Toolchain = "" },
|
||||
"no time": func(k *checker.KeptByLevelDigestValue) { k.At = "" },
|
||||
} {
|
||||
k := ok
|
||||
edit(&k)
|
||||
if CheckKept(k) == nil {
|
||||
t.Errorf("%s was taken", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
module git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliverycheckerclient
|
||||
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174118-f94abb95aaef
|
||||
git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdeliverychecker v0.0.0
|
||||
git.novox.be/novox/mesh-delivery/sdk v0.0.0
|
||||
)
|
||||
@@ -14,10 +14,11 @@ import (
|
||||
const Seat = "mesh-catalog"
|
||||
|
||||
const (
|
||||
VerbModules = "modules"
|
||||
VerbModule = "module"
|
||||
VerbProvides = "provides"
|
||||
VerbDependents = "dependents"
|
||||
VerbModules = "modules"
|
||||
VerbModule = "module"
|
||||
VerbProvides = "provides"
|
||||
VerbDependents = "dependents"
|
||||
VerbSeatDefinitions = "seat-definitions"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -27,7 +28,7 @@ const (
|
||||
|
||||
// Verbs is every verb the seat serves, in its definition's order: a new copy each call.
|
||||
func Verbs() []string {
|
||||
return []string{VerbModules, VerbModule, VerbProvides, VerbDependents}
|
||||
return []string{VerbModules, VerbModule, VerbProvides, VerbDependents, VerbSeatDefinitions}
|
||||
}
|
||||
|
||||
// Events is every event published on the seat's subject: a new copy each call.
|
||||
@@ -343,6 +344,41 @@ type DependentsInput struct {
|
||||
// DependentsAnswer is what dependents answers.
|
||||
type DependentsAnswer = map[string]any
|
||||
|
||||
// SeatDefinitionsInput is what seat-definitions takes.
|
||||
type SeatDefinitionsInput struct {
|
||||
// the seat's name; none lists every trunk seat
|
||||
Seat string `json:"seat,omitempty"`
|
||||
}
|
||||
|
||||
// SeatDefinitionsAnswerEntry is a type of the contract.
|
||||
type SeatDefinitionsAnswerEntry struct {
|
||||
// the definition, resolved with the parts it includes
|
||||
Definition map[string]any `json:"definition,omitempty"`
|
||||
Digest string `json:"digest"`
|
||||
Seat string `json:"seat"`
|
||||
// trunk
|
||||
Source string `json:"source"`
|
||||
// why the definition could not be read, when it could not
|
||||
Unparsed string `json:"unparsed,omitempty"`
|
||||
}
|
||||
|
||||
// SeatDefinitionsAnswerSeatsItem is a type of the contract.
|
||||
type SeatDefinitionsAnswerSeatsItem struct {
|
||||
Digest string `json:"digest"`
|
||||
Seat string `json:"seat"`
|
||||
// trunk
|
||||
Source string `json:"source"`
|
||||
}
|
||||
|
||||
// SeatDefinitionsAnswer is what seat-definitions answers.
|
||||
type SeatDefinitionsAnswer struct {
|
||||
Entry *SeatDefinitionsAnswerEntry `json:"entry,omitempty"`
|
||||
Found bool `json:"found,omitempty"`
|
||||
Revision int64 `json:"revision,omitempty"`
|
||||
Seat string `json:"seat,omitempty"`
|
||||
Seats []SeatDefinitionsAnswerSeatsItem `json:"seats,omitempty"`
|
||||
}
|
||||
|
||||
// SeatDefinitionChangedEvent is the body of the event seat-definition-changed.
|
||||
type SeatDefinitionChangedEvent struct {
|
||||
Digest string `json:"digest"`
|
||||
|
||||
@@ -112,6 +112,15 @@
|
||||
],
|
||||
"series": "0.1"
|
||||
},
|
||||
{
|
||||
"name": "go-meshdeliverycheckerclient",
|
||||
"kind": "package",
|
||||
"language": "go",
|
||||
"paths": [
|
||||
"go/clients/meshdeliverycheckerclient"
|
||||
],
|
||||
"series": "0.1"
|
||||
},
|
||||
{
|
||||
"name": "go-gopackageregistryclient",
|
||||
"kind": "package",
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"seat": "container-image-registry",
|
||||
"scope": "mesh",
|
||||
"grain": "whole",
|
||||
"domain": "Delivery",
|
||||
"decision": "novox/hq ADR 0605",
|
||||
"description": "The mesh's container images, in the one artifact store: the OCI distribution protocol's read side, an image's tags and mirroring an outside image.",
|
||||
"includes": [
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"seat": "git",
|
||||
"scope": "mesh",
|
||||
"grain": "seat-list",
|
||||
"domain": "Delivery",
|
||||
"decision": "novox/hq ADR 0111",
|
||||
"delivers": "git"
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"seat": "go-package-registry",
|
||||
"scope": "mesh",
|
||||
"grain": "whole",
|
||||
"domain": "Delivery",
|
||||
"decision": "novox/hq ADR 0605",
|
||||
"description": "The mesh's Go modules: Go's module proxy protocol's read side, and publishing over the bus.",
|
||||
"delivers": "go-package-registry",
|
||||
|
||||
@@ -3,5 +3,6 @@
|
||||
"seat": "mesh-artifact-store",
|
||||
"scope": "mesh",
|
||||
"grain": "seat-list",
|
||||
"domain": "Delivery",
|
||||
"decision": "novox/hq ADR 0075"
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"seat": "mesh-artifact-sweep",
|
||||
"scope": "mesh",
|
||||
"grain": "seat-list",
|
||||
"domain": "Delivery",
|
||||
"decision": "novox/hq ADR 0571",
|
||||
"verbs": [
|
||||
{
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"seat": "mesh-catalog",
|
||||
"scope": "mesh",
|
||||
"grain": "whole",
|
||||
"domain": "Delivery",
|
||||
"decision": "novox/hq ADR 0604",
|
||||
"verbs": [
|
||||
{
|
||||
@@ -80,6 +81,86 @@
|
||||
"type": "object",
|
||||
"description": "what dependents answers, as its description says"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "seat-definitions",
|
||||
"description": "The seat list (ADR 0636 §4): with no seat, every trunk seat with its digest and the revision of the list; with a seat, that trunk definition resolved with the parts it includes. Reads only.",
|
||||
"optional": true,
|
||||
"input": {
|
||||
"properties": {
|
||||
"seat": {
|
||||
"description": "the seat's name; none lists every trunk seat",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"answer": {
|
||||
"type": "object",
|
||||
"description": "with no seat: the revision of the list and every trunk seat with its digest; with a seat: whether it was found and its entry, the definition resolved",
|
||||
"properties": {
|
||||
"revision": {
|
||||
"type": "integer"
|
||||
},
|
||||
"seats": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"seat": {
|
||||
"type": "string"
|
||||
},
|
||||
"source": {
|
||||
"type": "string",
|
||||
"description": "trunk"
|
||||
},
|
||||
"digest": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"seat",
|
||||
"source",
|
||||
"digest"
|
||||
]
|
||||
}
|
||||
},
|
||||
"seat": {
|
||||
"type": "string"
|
||||
},
|
||||
"found": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"entry": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"seat": {
|
||||
"type": "string"
|
||||
},
|
||||
"source": {
|
||||
"type": "string",
|
||||
"description": "trunk"
|
||||
},
|
||||
"digest": {
|
||||
"type": "string"
|
||||
},
|
||||
"definition": {
|
||||
"type": "object",
|
||||
"description": "the definition, resolved with the parts it includes"
|
||||
},
|
||||
"unparsed": {
|
||||
"type": "string",
|
||||
"description": "why the definition could not be read, when it could not"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"seat",
|
||||
"source",
|
||||
"digest"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"description": "The catalog of the mesh: every module the trunk holds with its manifest, fingerprint and verdict, the packages published, and the seat list (ADR 0636 §1, §4).",
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"seat": "mesh-delivery-checker",
|
||||
"scope": "mesh",
|
||||
"grain": "whole",
|
||||
"domain": "Delivery",
|
||||
"decision": "novox/hq ADR 0547",
|
||||
"verbs": [
|
||||
{
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"seat": "mesh-delivery",
|
||||
"scope": "mesh",
|
||||
"grain": "whole",
|
||||
"domain": "Delivery",
|
||||
"decision": "novox/hq ADR 0239",
|
||||
"verbs": [
|
||||
{
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"seat": "node-build-go",
|
||||
"scope": "node",
|
||||
"grain": "whole",
|
||||
"domain": "Delivery",
|
||||
"decision": "novox/hq ADR 0525, ADR 0536, ADR 0553",
|
||||
"pulled-by-kind": true,
|
||||
"yields": true,
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"seat": "node-build-image",
|
||||
"scope": "node",
|
||||
"grain": "whole",
|
||||
"domain": "Delivery",
|
||||
"decision": "novox/hq ADR 0525, ADR 0536, ADR 0553",
|
||||
"pulled-by-kind": true,
|
||||
"yields": true,
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"seat": "node-build-node",
|
||||
"scope": "node",
|
||||
"grain": "whole",
|
||||
"domain": "Delivery",
|
||||
"decision": "novox/hq ADR 0525, ADR 0536, ADR 0553",
|
||||
"pulled-by-kind": true,
|
||||
"yields": true,
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"seat": "npm-package-registry",
|
||||
"scope": "mesh",
|
||||
"grain": "whole",
|
||||
"domain": "Delivery",
|
||||
"decision": "novox/hq ADR 0109",
|
||||
"description": "The mesh's npm packages: the npm registry protocol's read side, and publishing over the bus.",
|
||||
"delivers": "npm-package-registry",
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"seat": "oci-artifact-registry",
|
||||
"scope": "mesh",
|
||||
"grain": "whole",
|
||||
"domain": "Delivery",
|
||||
"decision": "novox/hq ADR 0605",
|
||||
"description": "The mesh's bundles and archives, by digest, in the one artifact store: the OCI distribution protocol's read side.",
|
||||
"includes": [
|
||||
|
||||
@@ -9,6 +9,7 @@ export const verbs = {
|
||||
module: "module",
|
||||
provides: "provides",
|
||||
dependents: "dependents",
|
||||
seatDefinitions: "seat-definitions",
|
||||
} as const;
|
||||
|
||||
export const events = {
|
||||
@@ -189,6 +190,39 @@ export interface DependentsInput {
|
||||
/** is what dependents answers. */
|
||||
export type DependentsAnswer = Record<string, unknown>;
|
||||
|
||||
/** is what seat-definitions takes. */
|
||||
export interface SeatDefinitionsInput {
|
||||
/** the seat's name; none lists every trunk seat */
|
||||
"seat"?: string;
|
||||
}
|
||||
|
||||
export interface SeatDefinitionsAnswerEntry {
|
||||
/** the definition, resolved with the parts it includes */
|
||||
"definition"?: Record<string, unknown>;
|
||||
"digest": string;
|
||||
"seat": string;
|
||||
/** trunk */
|
||||
"source": string;
|
||||
/** why the definition could not be read, when it could not */
|
||||
"unparsed"?: string;
|
||||
}
|
||||
|
||||
export interface SeatDefinitionsAnswerSeatsItem {
|
||||
"digest": string;
|
||||
"seat": string;
|
||||
/** trunk */
|
||||
"source": string;
|
||||
}
|
||||
|
||||
/** is what seat-definitions answers. */
|
||||
export interface SeatDefinitionsAnswer {
|
||||
"entry"?: SeatDefinitionsAnswerEntry;
|
||||
"found"?: boolean;
|
||||
"revision"?: number;
|
||||
"seat"?: string;
|
||||
"seats"?: SeatDefinitionsAnswerSeatsItem[];
|
||||
}
|
||||
|
||||
/** is the body of the event seat-definition-changed. */
|
||||
export interface SeatDefinitionChangedEvent {
|
||||
"digest": string;
|
||||
|
||||
@@ -33,6 +33,8 @@ type artifact struct {
|
||||
From string `json:"from"`
|
||||
Binary string `json:"binary"`
|
||||
Loads []string `json:"loads"`
|
||||
Paths []string `json:"paths"`
|
||||
Series string `json:"series"`
|
||||
}
|
||||
|
||||
type manifest struct {
|
||||
@@ -196,8 +198,12 @@ func TestManifestProcess(t *testing.T) { // 1607/AC1
|
||||
if _, bus := p["bus"]; bus {
|
||||
t.Errorf("the process declares a bus: it runs on its own broker credential")
|
||||
}
|
||||
if len(m.Build.Artifacts) != 2 {
|
||||
t.Fatalf("artifacts = %+v, want the program and the tools", m.Build.Artifacts)
|
||||
if len(m.Build.Artifacts) != 3 {
|
||||
t.Fatalf("artifacts = %+v, want the program, the tools and the package builds", m.Build.Artifacts)
|
||||
}
|
||||
// ADR 0634 §1, ADR 0606 §4: the module's own package is published, so a module of another repository imports it by version.
|
||||
if a := m.Build.Artifacts[2]; a.Name != "builds" || a.Kind != "package" || a.Language != "go" || !reflect.DeepEqual(a.Paths, []string{"builds"}) || a.Series != "0.1" {
|
||||
t.Errorf("package artifact = %+v", a)
|
||||
}
|
||||
if a := m.Build.Artifacts[1]; a.Name != "tools" || a.Kind != "bundle" || a.Binary != "mesh-delivery-builder-tools" || !reflect.DeepEqual(a.Loads, []string{"mesh-delivery-builder-tools"}) {
|
||||
t.Errorf("tools artifact = %+v", a) // 1945
|
||||
|
||||
@@ -35,6 +35,8 @@ type artifact struct {
|
||||
From string `json:"from"`
|
||||
Binary string `json:"binary"`
|
||||
Loads []string `json:"loads"`
|
||||
Paths []string `json:"paths"`
|
||||
Series string `json:"series"`
|
||||
}
|
||||
|
||||
type manifest struct {
|
||||
@@ -150,8 +152,12 @@ func TestManifestProcess(t *testing.T) { // 1235/AC1
|
||||
if _, bus := p["bus"]; bus {
|
||||
t.Errorf("the process declares a bus: it runs on its own broker credential")
|
||||
}
|
||||
if len(m.Build.Artifacts) != 2 || m.Build.Artifacts[1].Name != "tools" || m.Build.Artifacts[1].From != "cmd/mesh-delivery-forge-bridge-tools" {
|
||||
t.Fatalf("artifacts = %+v, want the program and the tools bundle", m.Build.Artifacts)
|
||||
if len(m.Build.Artifacts) != 3 || m.Build.Artifacts[1].Name != "tools" || m.Build.Artifacts[1].From != "cmd/mesh-delivery-forge-bridge-tools" {
|
||||
t.Fatalf("artifacts = %+v, want the program, the tools bundle and the package forgemsg", m.Build.Artifacts)
|
||||
}
|
||||
// ADR 0573 §1: the gate requires forgemsg by a published version, so the module declares it as a package.
|
||||
if a := m.Build.Artifacts[2]; a.Name != "forgemsg" || a.Kind != "package" || a.Language != "go" || len(a.Paths) != 1 || a.Paths[0] != "forgemsg" || a.Series != "0.1" {
|
||||
t.Errorf("package artifact = %+v", a)
|
||||
}
|
||||
a := m.Build.Artifacts[0]
|
||||
if st, err := os.Stat(a.From); err != nil || !st.IsDir() || a.Binary != "mesh-delivery-forge-bridge" || !strings.HasSuffix(a.From, a.Binary) {
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
{ "name": "end-asks", "history": 1 }
|
||||
],
|
||||
"tools": [
|
||||
"bridge_tags",
|
||||
"bridge_groups",
|
||||
"bridge_group_end_again",
|
||||
"bridge_merged_message",
|
||||
|
||||
@@ -2,4 +2,9 @@ module git.novox.be/novox/mesh-delivery/modules/mesh-delivery-indexer/takein
|
||||
|
||||
go 1.26.0
|
||||
|
||||
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104002338-5a38a1221659
|
||||
require (
|
||||
git.novox.be/novox/mesh-delivery/sdk v0.0.0
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104002338-5a38a1221659
|
||||
)
|
||||
|
||||
replace git.novox.be/novox/mesh-delivery/sdk => ../../../sdk
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
package takein
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-delivery/sdk/kept"
|
||||
"git.novox.be/novox/mesh-sdk/go/keys"
|
||||
)
|
||||
|
||||
// TakenState is the indexer's state `taken`: one key per repository (trunk.RepositoryKey), the last merge it took in.
|
||||
const TakenState = "taken"
|
||||
|
||||
// Taken is the value of a taken key: the repository, the merge commit the indexer last took in (applied its message
|
||||
// whole), and when.
|
||||
type Taken struct {
|
||||
V int `json:"v"`
|
||||
Repository string `json:"repository"`
|
||||
Merge string `json:"merge"`
|
||||
At time.Time `json:"at"`
|
||||
}
|
||||
|
||||
// EncodeTaken is t as JSON at Version; a value with no repository or merge is refused.
|
||||
func EncodeTaken(t Taken) ([]byte, error) {
|
||||
if t.Repository == "" || t.Merge == "" {
|
||||
return nil, fmt.Errorf("takein: a taken value names its repository and merge")
|
||||
}
|
||||
t.V = keys.Version
|
||||
return json.Marshal(t)
|
||||
}
|
||||
|
||||
// DecodeTaken reads a taken value; it refuses an unknown version (ErrUnknownVersion) and one with no merge.
|
||||
func DecodeTaken(data []byte) (Taken, error) {
|
||||
var t Taken
|
||||
if err := kept.Decode(data, &t); err != nil {
|
||||
return t, err
|
||||
}
|
||||
if t.Repository == "" || t.Merge == "" {
|
||||
return t, fmt.Errorf("takein: a taken value names no repository or merge")
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package takein
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-delivery/sdk/kept"
|
||||
"git.novox.be/novox/mesh-sdk/go/keys"
|
||||
)
|
||||
|
||||
// 1280: the taken value (ADR 0556) round-trips at its version, and one with no repository or merge, or of another version,
|
||||
// is refused.
|
||||
func TestATakenValueRoundTripsAndRefusesWhatLacksARepositoryOrAMerge(t *testing.T) {
|
||||
tk := Taken{Repository: "novox/mesh-sdk", Merge: "cccc", At: time.Date(2026, 10, 26, 20, 0, 0, 0, time.UTC)}
|
||||
data, err := EncodeTaken(tk)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tk.V = keys.Version
|
||||
if got, err := DecodeTaken(data); err != nil || got != tk {
|
||||
t.Errorf("taken round trip: %+v %v", got, err)
|
||||
}
|
||||
if _, err := EncodeTaken(Taken{Repository: "a/b"}); err == nil || !strings.Contains(err.Error(), "merge") {
|
||||
t.Errorf("taken without merge: %v", err)
|
||||
}
|
||||
for _, in := range []string{`{"v":2,"repository":"a/b","merge":"c"}`, `{"repository":"a/b","merge":"c"}`} {
|
||||
if _, err := DecodeTaken([]byte(in)); !errors.Is(err, kept.ErrUnknownVersion) {
|
||||
t.Errorf("decode of %s = %v, want ErrUnknownVersion", in, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,8 @@ type artifact struct {
|
||||
From string `json:"from"`
|
||||
Binary string `json:"binary"`
|
||||
Loads []string `json:"loads"`
|
||||
Paths []string `json:"paths"`
|
||||
Series string `json:"series"`
|
||||
Context *struct {
|
||||
Repository string `json:"repository"`
|
||||
} `json:"context"`
|
||||
@@ -150,13 +152,19 @@ func TestManifestIsTakenAndReadsOnly(t *testing.T) { // 1134/AC7
|
||||
|
||||
func TestManifestIsWholeInItsRepository(t *testing.T) { // 1134/AC7
|
||||
m, _ := load(t)
|
||||
if len(m.Build.Artifacts) != 4 {
|
||||
if len(m.Build.Artifacts) != 5 {
|
||||
t.Fatalf("artifacts = %+v", m.Build.Artifacts)
|
||||
}
|
||||
for _, a := range m.Build.Artifacts {
|
||||
if a.Name == "files" {
|
||||
continue
|
||||
}
|
||||
if a.Name == "viewstate" { // ADR 0634 §1: the module's own package, published for the gate
|
||||
if a.Kind != "package" || a.Language != "go" || len(a.Paths) != 1 || a.Paths[0] != "viewstate" || a.Series != "0.1" {
|
||||
t.Errorf("package artifact = %+v", a)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if a.Context != nil {
|
||||
t.Errorf("artifact %s names a context of another repository", a.Name)
|
||||
}
|
||||
|
||||
@@ -186,6 +186,15 @@
|
||||
"name": "files",
|
||||
"kind": "archive",
|
||||
"from": "files"
|
||||
},
|
||||
{
|
||||
"name": "viewstate",
|
||||
"kind": "package",
|
||||
"language": "go",
|
||||
"paths": [
|
||||
"viewstate"
|
||||
],
|
||||
"series": "0.1"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
module git.novox.be/novox/mesh-delivery/modules/mesh-delivery-view/viewstate
|
||||
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
git.novox.be/novox/mesh-delivery/sdk v0.0.0
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f
|
||||
)
|
||||
|
||||
replace git.novox.be/novox/mesh-delivery/sdk => ../../../sdk
|
||||
@@ -0,0 +1,184 @@
|
||||
// Package viewstate holds what the delivery view owns and no seat carries (novox/hq ADR 0634 §1): the layout of its state
|
||||
// `reach` (how far a repository's modules reached on the nodes that want them) and of its answers to asks, and the rules over
|
||||
// them. Other modules import it from the view by its published version (ADR 0573 §1).
|
||||
package viewstate
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"maps"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"git.novox.be/novox/mesh-delivery/sdk/kept"
|
||||
"git.novox.be/novox/mesh-sdk/go/keys"
|
||||
)
|
||||
|
||||
// ViewModule is the module of the delivery view.
|
||||
const ViewModule = "mesh-delivery-view"
|
||||
|
||||
// modulesHistory is the revisions the indexer's state `modules` keeps per key, the most changes a module's reach keeps.
|
||||
const modulesHistory = 20
|
||||
|
||||
// How far a repository reached (novox/hq ADR 0556, issue 1280). The indexer says, per repository, the last merge it took in
|
||||
// (the indexer's state `taken`); the delivery view keeps, per repository, how far each of its modules reached on the nodes that want it, and
|
||||
// derives from it the repository's reached head (ReachBucket): the newest head of the repository at which every wanted
|
||||
// module runs, on every node, a build at or after its last change. A merge at or before the reached head is on the
|
||||
// machines. Nothing records a merge as delivered (ADR 0429 §1): the gate and the tracker derive it from this state.
|
||||
|
||||
// ReachBucket is the delivery view's state `reach`: one key per repository (RepositoryKey), how far its modules reached.
|
||||
const ReachBucket = ViewModule + "_reach"
|
||||
|
||||
// Reach is the value of a reach key: how far the modules of one repository reached on the nodes. Indexed is the last
|
||||
// merge the indexer took in for it (Taken.Merge), empty while it took in none. A repository the indexer holds no module
|
||||
// of (a library) has no Modules. Reached is the reached head: the newest head of the repository in the source's history,
|
||||
// not past Indexed, at which every module with a node that wants it has its floor at or after its last change at or before
|
||||
// that head; ReachedSrc is the source revision that named it. Holds says, in words, what keeps the next head of the
|
||||
// repository from being reached (a module on a node, the indexer's lag); empty when the repository's newest head is reached.
|
||||
type Reach struct {
|
||||
V int `json:"v"`
|
||||
Repository string `json:"repository"`
|
||||
Indexed string `json:"indexed,omitempty"`
|
||||
Modules map[string]ModuleReach `json:"modules,omitempty"`
|
||||
Reached string `json:"reached,omitempty"`
|
||||
ReachedSrc uint64 `json:"reached_src,omitempty"`
|
||||
Holds []string `json:"holds,omitempty"`
|
||||
}
|
||||
|
||||
// ModuleReach is how far one module reached. FP is its fingerprint at the indexer now; Nodes how many nodes want it
|
||||
// (a want not removed); Changes its fingerprints the view knows of, each with the merge commit it first appeared at,
|
||||
// newest first. Floor is the oldest first-appearance commit among the fingerprints its nodes' haves run, a running have
|
||||
// counting whether its health check passes or not; it is empty when a node that wants the module has no running have, or
|
||||
// runs a fingerprint not among Changes. Waiting names, in words, every node where the module is not settled (its have
|
||||
// different from its want, not running, or not healthy), with or without a floor: health is said here and never moves the
|
||||
// floor (ADR 0556 clause 2). A module whose floor is empty while a node wants it holds no head of the repository back: a
|
||||
// reader takes a merge among its Changes as not yet on the machines, with these words. A module no node wants has neither.
|
||||
type ModuleReach struct {
|
||||
FP string `json:"fp,omitempty"`
|
||||
Nodes int `json:"nodes"`
|
||||
Changes []Change `json:"changes,omitempty"`
|
||||
Floor string `json:"floor,omitempty"`
|
||||
Waiting string `json:"waiting,omitempty"`
|
||||
}
|
||||
|
||||
// Change is one fingerprint of a module, the merge commit it first appeared at (Module.Commit), and the source revision
|
||||
// that named that commit as the repository's head (Module.Src, or the source's history), 0 when not known.
|
||||
type Change struct {
|
||||
Commit string `json:"commit"`
|
||||
FP string `json:"fp"`
|
||||
Src uint64 `json:"src,omitempty"`
|
||||
}
|
||||
|
||||
// MostChanges is how many changes of a module a reach value keeps: as many revisions as the indexer's modules bucket keeps.
|
||||
const MostChanges = modulesHistory
|
||||
|
||||
func (r Reach) check() error {
|
||||
if r.Repository == "" {
|
||||
return fmt.Errorf("viewstate: a reach value names no repository")
|
||||
}
|
||||
if (r.Reached == "") != (r.ReachedSrc == 0) {
|
||||
return fmt.Errorf("viewstate: the reach of %s names its reached head and its source revision together", r.Repository)
|
||||
}
|
||||
for name, m := range r.Modules {
|
||||
if name == "" {
|
||||
return fmt.Errorf("viewstate: a reach value names a module with no name")
|
||||
}
|
||||
if m.Nodes < 0 {
|
||||
return fmt.Errorf("viewstate: the reach of %s counts %d nodes", name, m.Nodes)
|
||||
}
|
||||
if len(m.Changes) > MostChanges {
|
||||
return fmt.Errorf("viewstate: the reach of %s keeps %d changes, more than %d", name, len(m.Changes), MostChanges)
|
||||
}
|
||||
for _, c := range m.Changes {
|
||||
if c.Commit == "" || c.FP == "" {
|
||||
return fmt.Errorf("viewstate: a change of %s names no commit or fingerprint", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// EncodeReach is r as JSON at Version; a value that does not check is refused.
|
||||
func EncodeReach(r Reach) ([]byte, error) {
|
||||
if err := r.check(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r.V = keys.Version
|
||||
return json.Marshal(r)
|
||||
}
|
||||
|
||||
// DecodeReach reads a reach value; it refuses an unknown version (ErrUnknownVersion) and one that does not check.
|
||||
func DecodeReach(data []byte) (Reach, error) {
|
||||
var r Reach
|
||||
if err := kept.Decode(data, &r); err != nil {
|
||||
return r, err
|
||||
}
|
||||
return r, r.check()
|
||||
}
|
||||
|
||||
// SameReach says whether two reach values are the same, so the view writes only when they differ.
|
||||
func SameReach(a, b Reach) bool {
|
||||
if a.Repository != b.Repository || a.Indexed != b.Indexed || len(a.Modules) != len(b.Modules) ||
|
||||
a.Reached != b.Reached || a.ReachedSrc != b.ReachedSrc || !slices.Equal(a.Holds, b.Holds) {
|
||||
return false
|
||||
}
|
||||
for _, name := range slices.Sorted(maps.Keys(a.Modules)) {
|
||||
x, ok := b.Modules[name]
|
||||
y := a.Modules[name]
|
||||
if !ok || x.FP != y.FP || x.Nodes != y.Nodes || x.Floor != y.Floor || x.Waiting != y.Waiting ||
|
||||
!slices.Equal(x.Changes, y.Changes) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// ViewAnswersBucket is the delivery view's state `answers`: one key per ask it answered, and ViewAnswersHistory the revisions it
|
||||
// keeps per key.
|
||||
const (
|
||||
ViewAnswersBucket = ViewModule + "_answers"
|
||||
ViewAnswersHistory = 5
|
||||
)
|
||||
|
||||
// ViewAnswer is the value of the view's answer to an ask of the assigner: the asking it answers, the option chosen and the words
|
||||
// said with it. The key of an answer is the ask's key and ".view" (the assigner's package of asks holds the ask keys).
|
||||
type ViewAnswer struct {
|
||||
V int `json:"v"`
|
||||
Asking string `json:"asking"`
|
||||
Option string `json:"option"`
|
||||
Words string `json:"words,omitempty"`
|
||||
}
|
||||
|
||||
func (a ViewAnswer) check() error {
|
||||
if strings.TrimSpace(a.Asking) == "" {
|
||||
return fmt.Errorf("viewstate: a view answer names no asking")
|
||||
}
|
||||
if strings.TrimSpace(a.Option) == "" {
|
||||
return fmt.Errorf("viewstate: a view answer names no option")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// EncodeViewAnswer is a as JSON at the version; an answer naming no asking or no option is refused.
|
||||
func EncodeViewAnswer(a ViewAnswer) ([]byte, error) {
|
||||
if err := a.check(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a.V = keys.Version
|
||||
return json.Marshal(a)
|
||||
}
|
||||
|
||||
// DecodeViewAnswer reads an answer; it refuses an unknown version (kept.ErrUnknownVersion) and what EncodeViewAnswer refuses.
|
||||
func DecodeViewAnswer(data []byte) (ViewAnswer, error) {
|
||||
var a ViewAnswer
|
||||
if err := kept.Decode(data, &a); err != nil {
|
||||
return ViewAnswer{}, err
|
||||
}
|
||||
if err := a.check(); err != nil {
|
||||
return ViewAnswer{}, err
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// SameViewAnswer says whether two answers are the same value, so the view writes only when they differ.
|
||||
func SameViewAnswer(a, b ViewAnswer) bool { return a == b }
|
||||
@@ -0,0 +1,91 @@
|
||||
package viewstate
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/keys"
|
||||
)
|
||||
|
||||
func sampleReach() Reach {
|
||||
return Reach{V: keys.Version, Repository: "novox/mesh-catalog", Indexed: "cccc", Reached: "cccc", ReachedSrc: 9, Modules: map[string]ModuleReach{
|
||||
"gitea": {FP: "src2:a", Nodes: 1, Floor: "cccc", Waiting: "gitea on ace runs src2:a and its health check failed", Changes: []Change{{Commit: "cccc", FP: "src2:a", Src: 9}, {Commit: "bbbb", FP: "src2:b", Src: 7}}},
|
||||
"zsh": {FP: "src2:z", Changes: []Change{{Commit: "aaaa", FP: "src2:z"}}},
|
||||
}}
|
||||
}
|
||||
|
||||
// 1280: the reach value (ADR 0556) round-trips, and values that do not hold are refused, naming what is wrong.
|
||||
func TestAReachValueRoundTripsAndRefusesWhatDoesNotHold(t *testing.T) {
|
||||
wantErr := func(what string, err error, in string) {
|
||||
t.Helper()
|
||||
if err == nil || !strings.Contains(err.Error(), in) {
|
||||
t.Errorf("%s: %v, want an error naming %q", what, err, in)
|
||||
}
|
||||
}
|
||||
r := sampleReach()
|
||||
data, err := EncodeReach(r)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, err := DecodeReach(data); err != nil || !SameReach(got, r) {
|
||||
t.Errorf("reach round trip: %+v %v", got, err)
|
||||
}
|
||||
r.Repository = ""
|
||||
_, err = EncodeReach(r)
|
||||
wantErr("reach without repository", err, "repository")
|
||||
r = sampleReach()
|
||||
m := r.Modules["gitea"]
|
||||
m.Changes = append(m.Changes, Change{Commit: "dddd"})
|
||||
r.Modules["gitea"] = m
|
||||
_, err = EncodeReach(r)
|
||||
wantErr("reach with a change naming no fingerprint", err, "gitea")
|
||||
r = sampleReach()
|
||||
m = r.Modules["gitea"]
|
||||
m.Changes = make([]Change, MostChanges+1)
|
||||
r.Modules["gitea"] = m
|
||||
_, err = EncodeReach(r)
|
||||
wantErr("reach keeping too many changes", err, "more than")
|
||||
r = sampleReach()
|
||||
r.ReachedSrc = 0
|
||||
_, err = EncodeReach(r)
|
||||
wantErr("reach with a head and no revision", err, "reached head")
|
||||
}
|
||||
|
||||
func TestReachValuesDifferingInAnyPartAreNotTheSame(t *testing.T) {
|
||||
if !SameReach(sampleReach(), sampleReach()) {
|
||||
t.Error("equal reach values differ")
|
||||
}
|
||||
for name, change := range map[string]func(*Reach){
|
||||
"indexed": func(r *Reach) { r.Indexed = "dddd" },
|
||||
"floor": func(r *Reach) { m := r.Modules["gitea"]; m.Floor = "bbbb"; r.Modules["gitea"] = m },
|
||||
"changes": func(r *Reach) { m := r.Modules["zsh"]; m.Changes = nil; r.Modules["zsh"] = m },
|
||||
"modules": func(r *Reach) { delete(r.Modules, "zsh") },
|
||||
"reached": func(r *Reach) { r.Reached, r.ReachedSrc = "bbbb", 7 },
|
||||
"holds": func(r *Reach) { r.Holds = []string{"gitea on ace does not yet run src2:a"} },
|
||||
} {
|
||||
r := sampleReach()
|
||||
change(&r)
|
||||
if SameReach(r, sampleReach()) {
|
||||
t.Errorf("reach values differing in %s are the same", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestViewAnswerRoundTrip(t *testing.T) {
|
||||
a := ViewAnswer{Asking: "hold-mesh-mail-aaaa-0123456789ab", Option: "release", Words: "fixed by #12"}
|
||||
data, err := EncodeViewAnswer(a)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := DecodeViewAnswer(data)
|
||||
a.V = keys.Version
|
||||
if err != nil || !SameViewAnswer(got, a) {
|
||||
t.Errorf("view answer came back %+v, %v", got, err)
|
||||
}
|
||||
if _, err := EncodeViewAnswer(ViewAnswer{Asking: "x", Option: " "}); err == nil {
|
||||
t.Errorf("a view answer naming no option was accepted")
|
||||
}
|
||||
if _, err := EncodeViewAnswer(ViewAnswer{Option: "release"}); err == nil {
|
||||
t.Errorf("a view answer naming no asking was accepted")
|
||||
}
|
||||
}
|
||||
+17
-1
@@ -1,6 +1,11 @@
|
||||
package trunk
|
||||
|
||||
import "strings"
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/keys"
|
||||
)
|
||||
|
||||
// RepositoryName is the repository as the forge names it, "owner/name", from either spelling the mesh holds: the name
|
||||
// itself, or the clone URL a build record carries ("http://host:port/owner/name.git", with or without ".git" or a
|
||||
@@ -23,3 +28,14 @@ func SameRepository(a, b string) bool {
|
||||
a, b = RepositoryName(a), RepositoryName(b)
|
||||
return a != "" && strings.EqualFold(a, b)
|
||||
}
|
||||
|
||||
// RepositoryKey is a repository's key in the indexer's state `taken` and the view's state `reach`: its forge name `owner/name` (RepositoryName), in
|
||||
// lower case as the forge compares it, escaped as a key part (keys.EscapeName). An empty spelling, or one with no owner, is
|
||||
// refused.
|
||||
func RepositoryKey(repository string) (string, error) {
|
||||
name := strings.ToLower(RepositoryName(repository))
|
||||
if owner, repo, ok := strings.Cut(name, "/"); !ok || owner == "" || repo == "" {
|
||||
return "", fmt.Errorf("trunk: %q names no repository as owner/name", repository)
|
||||
}
|
||||
return keys.EscapeName(name), nil
|
||||
}
|
||||
|
||||
@@ -45,3 +45,14 @@ func TestAnImportSetHoldsMeshPackagesSortedAndEachOnce(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A repository keys the indexer's taken and the view's reach by its forge name, in lower case, from either spelling (ADR 0556).
|
||||
func TestARepositoryIsKeyedByItsLowerCaseForgeName(t *testing.T) {
|
||||
for in, want := range map[string]string{"novox/Mesh-SDK": "novox/mesh-sdk", "http://h:3000/novox/mesh-sdk.git": "novox/mesh-sdk",
|
||||
"a/b.c": "a/b=2ec", "": "", "nope": "", "/x": ""} {
|
||||
got, err := RepositoryKey(in)
|
||||
if (want == "") != (err != nil) || got != want {
|
||||
t.Errorf("RepositoryKey(%q) = %q, %v; want %q", in, got, err, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user