Milestone 47 G: mesh-catalog's part of the one release #677
@@ -115,9 +115,6 @@ dist/
|
||||
/modules/mailu/cmd/automx/automx
|
||||
/modules/memory-pressure/cmd/memory-pressure/memory-pressure
|
||||
/modules/memory-pressure/memory-pressure
|
||||
/modules/mesh-build-go/buildstep/cmd/mesh-pack/mesh-pack
|
||||
/modules/mesh-build-go/buildstep/cmd/seatgen/seatgen
|
||||
/modules/mesh-build-go/buildstep/mesh-pack
|
||||
/modules/mesh-build-go/cmd/mesh-build-go/mesh-build-go
|
||||
/modules/mesh-build-go/mesh-build-go
|
||||
/modules/mesh-build-image/cmd/mesh-build-image/mesh-build-image
|
||||
|
||||
@@ -145,7 +145,7 @@ func runsTheKit(dir, call string) (bool, error) {
|
||||
return err
|
||||
}
|
||||
found = strings.Contains(string(raw), call) &&
|
||||
strings.Contains(string(raw), `"git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep/conformance"`)
|
||||
strings.Contains(string(raw), `"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-sdk/steprun/conformance"`)
|
||||
return nil
|
||||
})
|
||||
return found, err
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
// claims no node-build-* seat is not judged.
|
||||
func TestABuildModuleWithATestStepMustRunTheKit(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
const imp = "package main\n\nimport \"git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep/conformance\"\n\n"
|
||||
const imp = "package main\n\nimport \"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-sdk/steprun/conformance\"\n\n"
|
||||
const sweep = "func TestSweep(t *testing.T) { conformance.LeftRunning(t, nil) }\nfunc TestPassing(t *testing.T) { conformance.Passing(t, nil, nil) }\n"
|
||||
kit := imp + "func TestKit(t *testing.T) { conformance.Run(t, conformance.GoTest, nil) }\n" + sweep
|
||||
for path, body := range map[string]string{
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
// go run . <catalogue root>
|
||||
//
|
||||
// Exits 1 on a finding. The packages that count are those the build module counts (mesh-build-node fetch.go
|
||||
// ownDependencies): the `dependencies` beyond the SDK, typescript and the @types packages.
|
||||
// ownDependencies): the `dependencies` beyond the mesh's own packages (@novox/mesh-*, the SDK among them), typescript and the
|
||||
// @types packages.
|
||||
package main
|
||||
|
||||
import (
|
||||
@@ -17,6 +18,10 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// meshPackages is the prefix of every package the mesh itself publishes: the build module's toolchain and the mesh's registry
|
||||
// give them, as they give the SDK, so a module naming only them needs no lock (design 47 G A-56).
|
||||
const meshPackages = "@novox/mesh-"
|
||||
|
||||
// Finding is one module refused: it names Packages beyond the SDK and has no lock file.
|
||||
type Finding struct {
|
||||
Module string
|
||||
@@ -70,7 +75,7 @@ func Check(root string) ([]Finding, error) {
|
||||
}
|
||||
var names []string
|
||||
for n := range pkg.Dependencies {
|
||||
if n != "@novox/mesh-sdk" && n != "typescript" && !strings.HasPrefix(n, "@types/") {
|
||||
if !strings.HasPrefix(n, meshPackages) && n != "typescript" && !strings.HasPrefix(n, "@types/") {
|
||||
names = append(names, n)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,6 +35,8 @@ func TestAModuleNamingOnlyTheSDKNeedsNoLock(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
module(t, root, "plain", "typescript", sdkOnly, false)
|
||||
module(t, root, "none", "typescript", "", false)
|
||||
// The mesh's own packages count as the SDK does (design 47 G A-56): a contracts package needs no lock.
|
||||
module(t, root, "contracts", "typescript", `{"dependencies":{"@novox/mesh-sdk":"^0.1.1","@novox/mesh-delivery-contracts-git":"^0.1.0"}}`, false)
|
||||
got, err := Check(root)
|
||||
if err != nil || len(got) != 0 {
|
||||
t.Fatalf("%v %v", got, err)
|
||||
|
||||
@@ -11,4 +11,4 @@ replace git.novox.be/novox/mesh-catalog/modules/disk-load/diskreadings => ../../
|
||||
|
||||
replace git.novox.be/novox/mesh-catalog/modules/restic/backupgood => ../../modules/restic/backupgood
|
||||
|
||||
require git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000 // indirect
|
||||
require git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000
|
||||
|
||||
+44
-23
@@ -31,6 +31,7 @@ import (
|
||||
|
||||
"git.novox.be/novox/mesh-catalog/modules/disk-load/diskreadings"
|
||||
"git.novox.be/novox/mesh-catalog/modules/restic/backupgood"
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
|
||||
// Known are the constants of the owners' published packages an announcement may name a state by, by the selector the
|
||||
@@ -53,6 +54,7 @@ func Findings(root string) ([]string, error) {
|
||||
}
|
||||
dirs := map[string]string{}
|
||||
reads := map[string][]string{}
|
||||
holders := map[string][]string{} // seat -> the modules of the catalogue that claim it
|
||||
for _, path := range manifests {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
@@ -61,11 +63,17 @@ func Findings(root string) ([]string, error) {
|
||||
var m struct {
|
||||
Module string `json:"module"`
|
||||
Reads []json.RawMessage `json:"reads"`
|
||||
Claims []struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"claims"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", path, err)
|
||||
}
|
||||
dirs[m.Module] = filepath.Dir(path)
|
||||
for _, c := range m.Claims {
|
||||
holders[c.Name] = append(holders[c.Name], m.Module)
|
||||
}
|
||||
for _, r := range m.Reads {
|
||||
var s string
|
||||
if json.Unmarshal(r, &s) == nil {
|
||||
@@ -77,32 +85,45 @@ func Findings(root string) ([]string, error) {
|
||||
owners := map[string]Announced{}
|
||||
for reader, rs := range reads {
|
||||
for _, r := range rs {
|
||||
owner, state, ok := strings.Cut(r, ".")
|
||||
dir, inCatalogue := dirs[owner]
|
||||
if !ok || !inCatalogue || owner == reader {
|
||||
continue
|
||||
}
|
||||
a, seen := owners[owner]
|
||||
if !seen {
|
||||
var problems []string
|
||||
a, problems, err = AnnouncedBy(dir)
|
||||
// A read in the seat form `seat:<seat>.<state>` (design 47 G A-116, A-128) is of the holder of the seat, found where it
|
||||
// is read: each module of the catalogue claiming the seat is judged for the state, as a read of it by name is.
|
||||
var targets []string
|
||||
state := ""
|
||||
if seat, local, isSeat, err := catalogue.SeatRead(r); isSeat {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
continue // the manifest validator names a malformed seat read
|
||||
}
|
||||
owners[owner] = a
|
||||
for _, p := range problems {
|
||||
out = append(out, owner+": "+p)
|
||||
targets, state = holders[seat], local
|
||||
} else if owner, local, ok := strings.Cut(r, "."); ok {
|
||||
targets, state = []string{owner}, local
|
||||
}
|
||||
for _, owner := range targets {
|
||||
dir, inCatalogue := dirs[owner]
|
||||
if !inCatalogue || owner == reader {
|
||||
continue
|
||||
}
|
||||
a, seen := owners[owner]
|
||||
if !seen {
|
||||
var problems []string
|
||||
a, problems, err = AnnouncedBy(dir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
owners[owner] = a
|
||||
for _, p := range problems {
|
||||
out = append(out, owner+": "+p)
|
||||
}
|
||||
}
|
||||
if a == nil {
|
||||
continue // announces nothing from code: read as before
|
||||
}
|
||||
readable, keeps := a[state]
|
||||
switch {
|
||||
case !keeps:
|
||||
out = append(out, fmt.Sprintf("%s reads %s.%s, which %s's code does not announce: the read is refused on the mesh", reader, owner, state, owner))
|
||||
case !readable:
|
||||
out = append(out, fmt.Sprintf("%s reads %s.%s, which %s's code announces not readable: the read is refused on the mesh; announce it readable", reader, owner, state, owner))
|
||||
}
|
||||
}
|
||||
if a == nil {
|
||||
continue // announces nothing from code: read as before
|
||||
}
|
||||
readable, keeps := a[state]
|
||||
switch {
|
||||
case !keeps:
|
||||
out = append(out, fmt.Sprintf("%s reads %s.%s, which %s's code does not announce: the read is refused on the mesh", reader, owner, state, owner))
|
||||
case !readable:
|
||||
out = append(out, fmt.Sprintf("%s reads %s.%s, which %s's code announces not readable: the read is refused on the mesh; announce it readable", reader, owner, state, owner))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,6 +50,15 @@ func TestFindings(t *testing.T) {
|
||||
"modules/r/module.json": `{"module":"r","reads":["o.bound"]}`,
|
||||
"modules/o/module.json": `{"module":"o"}`,
|
||||
"modules/o/index.ts": `await keeps([{ name: "bound", readable: true }]);`}, ""},
|
||||
{"seat form, the holder's state not readable", map[string]string{
|
||||
"modules/r/module.json": `{"module":"r","reads":["seat:the-seat.s"]}`,
|
||||
"modules/o/module.json": `{"module":"o","claims":[{"name":"the-seat","scope":"mesh"}]}`,
|
||||
"modules/o/cmd/o/main.go": goOwner + "var _ = announce.State{Name: \"s\"}\n"},
|
||||
"r reads o.s, which o's code announces not readable"},
|
||||
{"seat form, the holder's state readable, and a seat no module of the catalogue holds", map[string]string{
|
||||
"modules/r/module.json": `{"module":"r","reads":["seat:the-seat.s","seat:elsewhere.s"]}`,
|
||||
"modules/o/module.json": `{"module":"o","claims":[{"name":"the-seat","scope":"mesh"}]}`,
|
||||
"modules/o/cmd/o/main.go": goOwner + "var _ = announce.State{Name: \"s\", Readable: true}\n"}, ""},
|
||||
{"an owner that announces nothing, and one outside the catalogue", map[string]string{
|
||||
"modules/r/module.json": `{"module":"r","reads":["o.s","mesh-controller.lease"]}`,
|
||||
"modules/o/module.json": `{"module":"o"}`,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Command removedpackages refuses a Go source of the catalogue that imports what mesh-sdk gave up in the release that
|
||||
// narrowed it (novox/hq ADR 0593 §1): a package that left for its owner, and the build runner's names that left
|
||||
// mesh-sdk's buildstep for mesh-build-go's package.
|
||||
// mesh-sdk's buildstep for the build runner's package (now the delivery SDK's steprun).
|
||||
//
|
||||
// A module compiles against the mesh-sdk version it pins, so a source that still names a removed package builds until the
|
||||
// pin moves, and then breaks in a delivery of its own; and a later change could bring the import back with the old pin.
|
||||
@@ -8,8 +8,8 @@
|
||||
//
|
||||
// - an import of a package in Removed (or one under it);
|
||||
// - in a file importing mesh-sdk's buildstep (under any name), a selector naming anything that is not in
|
||||
// BuildstepContract: what is left of that package is what the delivery parts read, and the runner is
|
||||
// mesh-build-go's package (git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep).
|
||||
// BuildstepContract: what is left of that package is what the delivery parts read, and the runner is the
|
||||
// delivery SDK's package steprun (git.novox.be/novox/mesh-delivery/modules/mesh-delivery-sdk/steprun).
|
||||
//
|
||||
// It reads the non-test and test Go files under modules/ and checks/ (testdata, vendor and node_modules left out).
|
||||
//
|
||||
@@ -35,8 +35,8 @@ const sdk = "git.novox.be/novox/mesh-sdk/go"
|
||||
|
||||
// Removed are the packages of mesh-sdk that left it, each with where its logic is now.
|
||||
var Removed = map[string]string{
|
||||
"buildcpu": "the build runner's package, git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep/buildcpu",
|
||||
"buildstep/conformance": "the build runner's package, git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep/conformance",
|
||||
"buildcpu": "the build runner's package, git.novox.be/novox/mesh-delivery/modules/mesh-delivery-sdk/steprun/buildcpu",
|
||||
"buildstep/conformance": "the build runner's package, git.novox.be/novox/mesh-delivery/modules/mesh-delivery-sdk/steprun/conformance",
|
||||
"delivery/buildrun": "the builder's package (mesh-delivery-builder), imported by its repository's modules",
|
||||
"compose": "the node-engine, which alone composes (ADR 0593 §6)",
|
||||
"compose/frombus": "the node-engine, which alone composes (ADR 0593 §6)",
|
||||
@@ -140,7 +140,7 @@ func fileFindings(path, rel string) ([]string, error) {
|
||||
return true
|
||||
}
|
||||
seen[sel.Sel.Name] = true
|
||||
out = append(out, fmt.Sprintf("%s: uses %s.%s, which is the build runner's, no longer mesh-sdk's buildstep; import git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep (novox/hq ADR 0593 §1)", rel, buildstep, sel.Sel.Name))
|
||||
out = append(out, fmt.Sprintf("%s: uses %s.%s, which is the build runner's, no longer mesh-sdk's buildstep; import git.novox.be/novox/mesh-delivery/modules/mesh-delivery-sdk/steprun (novox/hq ADR 0593 §1)", rel, buildstep, sel.Sel.Name))
|
||||
return true
|
||||
})
|
||||
return out, nil
|
||||
|
||||
@@ -18,11 +18,11 @@ func TestFindingsRefuseWhatMeshSDKGaveUp(t *testing.T) {
|
||||
{"clean", "modules/a/x.go", head + "\t\"git.novox.be/novox/mesh-sdk/go/delivery\"\n)\n\nvar _ = delivery.StepsState\n", ""},
|
||||
{"a removed package", "modules/a/x.go", head + "\t\"git.novox.be/novox/mesh-sdk/go/compose\"\n)\n\nvar _ = compose.Resolve\n", "imports git.novox.be/novox/mesh-sdk/go/compose, which left mesh-sdk; it is the node-engine"},
|
||||
{"under a removed package", "modules/a/x_test.go", head + "\t\"git.novox.be/novox/mesh-sdk/go/compose/frombus\"\n)\n", "the node-engine"},
|
||||
{"buildcpu", "checks/c/x.go", head + "\t\"git.novox.be/novox/mesh-sdk/go/buildcpu\"\n)\n", "buildstep/buildcpu"},
|
||||
{"buildcpu", "checks/c/x.go", head + "\t\"git.novox.be/novox/mesh-sdk/go/buildcpu\"\n)\n", "steprun/buildcpu"},
|
||||
{"the runner through mesh-sdk's buildstep", "modules/a/x.go", head + "\t\"git.novox.be/novox/mesh-sdk/go/buildstep\"\n)\n\nvar _ = buildstep.RunAsk\n", "uses buildstep.RunAsk, which is the build runner's"},
|
||||
{"the runner through an alias", "modules/a/x.go", head + "\tsdkstep \"git.novox.be/novox/mesh-sdk/go/buildstep\"\n)\n\nvar _ = sdkstep.Puller{}\n", "uses sdkstep.Puller"},
|
||||
{"the contract of buildstep", "modules/a/x.go", head + "\t\"git.novox.be/novox/mesh-sdk/go/buildstep\"\n)\n\nvar _ = buildstep.NewID\nvar _ buildstep.Failure\n", ""},
|
||||
{"the runner's own package", "modules/a/x.go", head + "\t\"git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep\"\n)\n\nvar _ = buildstep.RunAsk\n", ""},
|
||||
{"the runner's own package", "modules/a/x.go", head + "\t\"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-sdk/steprun\"\n)\n\nvar _ = steprun.RunAsk\n", ""},
|
||||
{"a copy in testdata", "modules/a/testdata/x.go", head + "\t\"git.novox.be/novox/mesh-sdk/go/compose\"\n)\n", ""},
|
||||
} {
|
||||
t.Run(row.name, func(t *testing.T) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"module": "adwaita",
|
||||
"version": "1",
|
||||
"version": "0.1",
|
||||
"moved": true,
|
||||
"capabilities": [
|
||||
"package-manager"
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"fmt"
|
||||
"sort"
|
||||
|
||||
sweep "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshartifactsweep"
|
||||
sweep "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshartifactsweep"
|
||||
)
|
||||
|
||||
// The holders whose states this module reads, one constant each (a reader names its holder once; ADR 0634 §4).
|
||||
|
||||
@@ -20,21 +20,21 @@ import (
|
||||
"fmt"
|
||||
engineclient "git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient"
|
||||
nodeengine "git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
|
||||
catalogclient "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient"
|
||||
catalog "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog"
|
||||
meshwants "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshwants"
|
||||
builds "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds"
|
||||
catalogclient "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/meshcatalogclient"
|
||||
catalog "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog"
|
||||
meshwants "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshwants"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient"
|
||||
"git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshhandactsclient"
|
||||
meshconditions "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions"
|
||||
artifactref "git.novox.be/novox/mesh-delivery/contracts/go/clients/containerimageregistryclient/reference"
|
||||
artifactref "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/containerimageregistryclient/reference"
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/keys"
|
||||
"git.novox.be/novox/mesh-sdk/go/views"
|
||||
)
|
||||
|
||||
@@ -45,8 +45,6 @@ const (
|
||||
HavesRead = "node-engine.have"
|
||||
// ManifestsRead is the indexer's manifests: the images a manifest pins by itself.
|
||||
ManifestsRead = indexerModule + "." + catalog.StateManifests
|
||||
// FindingsState is the holder's own findings (meshconditions.StateFindings): image-missing.
|
||||
FindingsState = meshconditions.StateFindings
|
||||
// Module is this module, the observer its findings name.
|
||||
Module = "artifact-store-tools"
|
||||
)
|
||||
@@ -83,8 +81,10 @@ type Door interface {
|
||||
|
||||
// Holder is the seat's holder.
|
||||
type Holder struct {
|
||||
Bus Bus
|
||||
Door func() (Door, error)
|
||||
Bus Bus
|
||||
// Findings is the module's finding writer: S25's and the sweep's findings go through it.
|
||||
Findings Findings
|
||||
Door func() (Door, error)
|
||||
// Listed is the store as its seats list it now (Store.View); nil reads none, and then nothing is said gone or collected.
|
||||
Listed func(ctx context.Context) (*View, error)
|
||||
Acts meshhandactsclient.Recorder
|
||||
@@ -95,6 +95,7 @@ type Holder struct {
|
||||
|
||||
sweep sync.Mutex // one sweep at a time
|
||||
mu sync.Mutex
|
||||
fmu sync.Mutex // one pass of Observe at a time
|
||||
ended map[string]time.Time // a done key's end, read once from its history
|
||||
}
|
||||
|
||||
@@ -852,16 +853,60 @@ func inStoreAt(image, address string) string {
|
||||
return r
|
||||
}
|
||||
|
||||
// Observe writes S25's findings (image-missing): each want whose build names an image the store does not hold, on the
|
||||
// want's machine, and deletes each finding that no longer holds. Nothing is said without a listing.
|
||||
// Observe says S25's findings (image-missing): each want whose build names an image the store does not hold, on the
|
||||
// want's machine, written once and cleared once it no longer holds. Nothing is said without a listing. What stands is what
|
||||
// the held writer holds (design 47 G A-32, A-38), so a restart neither drops nor rewrites it.
|
||||
func (h *Holder) Observe(ctx context.Context) error {
|
||||
j, err := h.judge(ctx)
|
||||
now, err := h.observed(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if j.view == nil {
|
||||
if now == nil {
|
||||
return nil
|
||||
}
|
||||
h.fmu.Lock()
|
||||
defer h.fmu.Unlock()
|
||||
said := map[string]meshconditions.Finding{}
|
||||
for _, f := range h.Findings.Held() {
|
||||
if f.Kind == ImageMissing {
|
||||
said[views.FindingKey(f.Scope, f.ID, f.Kind)] = f
|
||||
}
|
||||
}
|
||||
for k, was := range said {
|
||||
if _, still := now[k]; !still {
|
||||
if err := h.Findings.Clear(ctx, was.Scope, was.ID, was.Kind, "the store holds the image again"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
for k, f := range now {
|
||||
if was, ok := said[k]; ok && was.Summary == f.Summary {
|
||||
continue // written only when what it says changes
|
||||
}
|
||||
if err := h.Findings.Write(ctx, f); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Findings is the module's held finding writer (views.HeldWriter): the holder writes and clears through it and reads what it
|
||||
// holds (design 47 G §689, A-29, A-38).
|
||||
type Findings interface {
|
||||
Write(ctx context.Context, finding meshconditions.Finding) error
|
||||
Clear(ctx context.Context, scope, id, kind, why string) error
|
||||
Held() []meshconditions.Finding
|
||||
}
|
||||
|
||||
// observed is S25's findings now by key, nil without a listing.
|
||||
func (h *Holder) observed(ctx context.Context) (map[string]meshconditions.Finding, error) {
|
||||
j, err := h.judge(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if j.view == nil {
|
||||
return nil, nil
|
||||
}
|
||||
address := ""
|
||||
if d, err := h.Door(); err == nil {
|
||||
address = d.Address()
|
||||
@@ -870,7 +915,7 @@ func (h *Holder) Observe(ctx context.Context) error {
|
||||
for _, b := range j.built {
|
||||
builds[b.Module+" "+b.FP] = b
|
||||
}
|
||||
now := map[string]meshconditions.FindingsByKeyValue{}
|
||||
now := map[string]meshconditions.Finding{}
|
||||
for _, w := range j.wants {
|
||||
b, ok := builds[w.Module+" "+w.FP]
|
||||
if !ok {
|
||||
@@ -895,63 +940,26 @@ func (h *Holder) Observe(ctx context.Context) error {
|
||||
continue
|
||||
}
|
||||
f := imageMissing(w.Node, w.Module, reference, h.now())
|
||||
now[f.Key] = f
|
||||
now[views.FindingKey(f.Scope, f.ID, f.Kind)] = f
|
||||
break
|
||||
}
|
||||
}
|
||||
keys, err := h.Bus.Keys(FindingsState)
|
||||
if err != nil {
|
||||
return fmt.Errorf("the findings: %w", err)
|
||||
}
|
||||
for _, k := range keys {
|
||||
if k == sweepFindingKey {
|
||||
continue // the sweep's own finding, which the sweep clears
|
||||
}
|
||||
if _, still := now[k]; !still {
|
||||
// A finding of a first view unanswered is the reader's (views.Reader), cleared by its answer, not by a sweep.
|
||||
if raw, found, err := h.Bus.Get(FindingsState, k); err == nil && found {
|
||||
if f, err := meshconditions.DecodeFindingsByKeyValue(raw); err == nil && f.Kind == views.UnansweredKind {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if err := h.Bus.Delete(FindingsState, k); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
for k, f := range now {
|
||||
raw, found, err := h.Bus.Get(FindingsState, k)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if found {
|
||||
if was, err := meshconditions.DecodeFindingsByKeyValue(raw); err == nil && was.Summary == f.Summary {
|
||||
continue // written only when what it says changes
|
||||
}
|
||||
}
|
||||
if err := meshconditionsclient.CheckFinding(f); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := h.Bus.Put(FindingsState, k, f); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
return now, nil
|
||||
}
|
||||
|
||||
// imageMissing is S25's finding, in the controller's words (cmd/mesh-controller/image_missing.go watchMissingImages).
|
||||
func imageMissing(node, module, image string, at time.Time) meshconditions.FindingsByKeyValue {
|
||||
id := module + "." + node
|
||||
return meshconditions.FindingsByKeyValue{V: meshconditionsclient.FindingVersion, Key: meshconditionsclient.Key(meshconditionsclient.ScopeModule, id, ImageMissing), Kind: ImageMissing,
|
||||
Subject: meshconditions.Subject{Scope: meshconditionsclient.ScopeModule, ID: id, Machine: node},
|
||||
Severity: meshconditionsclient.Warning, Resolver: meshconditionsclient.ResolverOperator,
|
||||
// imageMissing is S25's finding, in the controller's words (cmd/mesh-controller/image_missing.go watchMissingImages); its id
|
||||
// is the module's and the machine's, each escaped (design 47 G A-14).
|
||||
func imageMissing(node, module, image string, at time.Time) meshconditions.Finding {
|
||||
id := keys.EscapeName(module) + "." + keys.EscapeName(node)
|
||||
return meshconditions.Finding{Scope: "module", ID: id, Kind: ImageMissing,
|
||||
Subject: meshconditions.Subject{Scope: "module", ID: id, Machine: node},
|
||||
Severity: meshconditions.FindingSeverityWarning, Resolver: "operator",
|
||||
Summary: fmt.Sprintf("%s is left out of every send to %s: its declaration names an image that the artifact store does "+
|
||||
"not hold (the evidence names it); the running container is untouched", module, node),
|
||||
Headline: capital(module) + " is not updated on " + node,
|
||||
Explanation: fmt.Sprintf("The new version of %s names a container image that the mesh's store does not hold, so it "+
|
||||
"was not sent to %s. What runs there is untouched, and the rest of %s is sent as usual.", module, node, node),
|
||||
Needs: fmt.Sprintf("build %s again; the details name the image.", module),
|
||||
Resolved: "Resolved: the store holds the image of " + module + " again",
|
||||
Evidence: []meshconditions.Evidence{{At: at.UTC(), Said: "image " + image + " is not in the store"}},
|
||||
Source: "S25", Observer: Module, At: at.UTC()}
|
||||
}
|
||||
|
||||
@@ -198,7 +198,7 @@ func TestTheSeatSweepsAtABuildsEndOnARealBusWithNoLease(t *testing.T) {
|
||||
}
|
||||
return m.Data, nil
|
||||
}
|
||||
h := &Holder{Bus: findingsAt{realBus: bus}, Door: mem.Door, Listed: mem.Listed, Seats: ask, Now: time.Now, Logf: t.Logf}
|
||||
h := &Holder{Bus: findingsAt{realBus: bus}, Findings: findingsThrough{findingsAt{realBus: bus}}, Door: mem.Door, Listed: mem.Listed, Seats: ask, Now: time.Now, Logf: t.Logf}
|
||||
h.Acts = meshhandactsclient.Recorder{Ask: ask}
|
||||
|
||||
// A build's end heard from the builds bucket, as follow hands it over.
|
||||
|
||||
@@ -6,10 +6,10 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
nodeengine "git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
|
||||
catalogclient "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient"
|
||||
catalog "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog"
|
||||
meshwants "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshwants"
|
||||
builds "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds"
|
||||
catalogclient "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/meshcatalogclient"
|
||||
catalog "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog"
|
||||
meshwants "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshwants"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
@@ -17,14 +17,14 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient"
|
||||
"git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshhandactsclient"
|
||||
meshconditions "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions"
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"git.novox.be/novox/mesh-sdk/go/views"
|
||||
|
||||
artifactref "git.novox.be/novox/mesh-delivery/contracts/go/clients/containerimageregistryclient/reference"
|
||||
reg "git.novox.be/novox/mesh-delivery/contracts/go/seats/containerimageregistry"
|
||||
ociseat "git.novox.be/novox/mesh-delivery/contracts/go/seats/ociartifactregistry"
|
||||
artifactref "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/containerimageregistryclient/reference"
|
||||
reg "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/containerimageregistry"
|
||||
ociseat "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/ociartifactregistry"
|
||||
)
|
||||
|
||||
// memBus is the holder's bus in memory: states by name, each key's history, and what was published.
|
||||
@@ -229,7 +229,7 @@ func aMesh(t *testing.T) (*Holder, *memBus, *memDoor, *[]string) {
|
||||
want, _ := json.Marshal(meshwants.WantsByWantNodeModuleValue{V: 1, Fp: fp(1), Self: true})
|
||||
_ = b.Put(WantsRead, "want.ace.letta", json.RawMessage(want))
|
||||
var acts []string
|
||||
h := &Holder{Bus: b, Door: func() (Door, error) { return door, nil }, Seats: door.seat,
|
||||
h := &Holder{Bus: b, Findings: findingsThrough{b}, Door: func() (Door, error) { return door, nil }, Seats: door.seat,
|
||||
Listed: func(context.Context) (*View, error) { return &View{L: listing}, nil },
|
||||
Now: func() time.Time { return day.Add(48 * time.Hour) },
|
||||
Acts: meshhandactsclient.Recorder{Ask: func(key string, body any) (json.RawMessage, error) {
|
||||
@@ -370,7 +370,7 @@ func TestTheSeatKeepsTheFiveNewestAndTheWantedAndSweepsWithNoLease(t *testing.T)
|
||||
if c.letGo[0] == archiveRef("letta", 2) && !slices.Contains(door.calls, "seat:oci-artifact-registry.remove letta/files@"+digestOf(archiveRef("letta", 2))+" "+Retention) {
|
||||
t.Errorf("the seats were called %v", door.calls)
|
||||
}
|
||||
_, finding, _ := b.Get(FindingsState, sweepFindingKey)
|
||||
_, finding, _ := b.Get(FindingsState, views.FindingKey("module", Module, SweepRefused))
|
||||
if finding != (c.refuse != "") {
|
||||
t.Errorf("the sweep's finding is written: %v, want %v", finding, c.refuse != "")
|
||||
}
|
||||
@@ -379,7 +379,7 @@ func TestTheSeatKeepsTheFiveNewestAndTheWantedAndSweepsWithNoLease(t *testing.T)
|
||||
if _, err := h.Sweep(context.Background(), Sweeping{Most: MostPerSweep, By: "again"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, still, _ := b.Get(FindingsState, sweepFindingKey); still {
|
||||
if _, still, _ := b.Get(FindingsState, views.FindingKey("module", Module, SweepRefused)); still {
|
||||
t.Error("a clean sweep left the sweep's finding")
|
||||
}
|
||||
return
|
||||
@@ -410,13 +410,16 @@ func TestAWantedImageTheStoreLacksIsAFindingAndImagesNamesWantsAndHaves(t *testi
|
||||
if err := h.Observe(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
key := meshconditionsclient.Key(meshconditionsclient.ScopeModule, "letta.ace", ImageMissing)
|
||||
key := views.FindingKey("module", "letta.ace", ImageMissing)
|
||||
raw, found, _ := b.Get(FindingsState, key)
|
||||
f, err := meshconditions.DecodeFindingsByKeyValue(raw)
|
||||
var f meshconditions.Finding
|
||||
err := json.Unmarshal(raw, &f)
|
||||
if !found || err != nil || f.Subject.Machine != "ace" || f.Source != "S25" || !strings.Contains(f.Evidence[0].Said, imageRef("letta", 1)) {
|
||||
t.Fatalf("the finding is %+v (%v, %v)", f, found, err)
|
||||
}
|
||||
view.L.Repos[repo].Revisions[digest] = true
|
||||
// A restart: a holder made afresh holds no finding of its own, and clears what the writer holds (design 47 G A-32, A-38).
|
||||
h = &Holder{Bus: h.Bus, Findings: h.Findings, Door: h.Door, Listed: h.Listed, Seats: h.Seats, Now: h.Now, Acts: h.Acts, Logf: h.Logf}
|
||||
if err := h.Observe(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -441,3 +444,41 @@ func TestAWantedImageTheStoreLacksIsAFindingAndImagesNamesWantsAndHaves(t *testi
|
||||
t.Fatalf("the images of ace are %+v: %v", images, err)
|
||||
}
|
||||
}
|
||||
|
||||
// FindingsState is the module's state `findings`, where the tests' writer keeps what the holder writes through it.
|
||||
const FindingsState = "findings"
|
||||
|
||||
// findingsThrough is the holder's finding writer in a test: it keeps a finding in its key and deletes it as the module's
|
||||
// writer does (views.Writer), without the bus.
|
||||
type findingsThrough struct {
|
||||
bus interface {
|
||||
Put(state, key string, value any) error
|
||||
Delete(state, key string) error
|
||||
}
|
||||
}
|
||||
|
||||
func (f findingsThrough) Write(_ context.Context, finding meshconditions.Finding) error {
|
||||
return f.bus.Put(FindingsState, views.FindingKey(finding.Scope, finding.ID, finding.Kind), finding)
|
||||
}
|
||||
|
||||
// Held is what the writer holds: the findings in the module's state.
|
||||
func (f findingsThrough) Held() []meshconditions.Finding {
|
||||
var out []meshconditions.Finding
|
||||
keys, _ := f.bus.(interface {
|
||||
Keys(string) ([]string, error)
|
||||
}).Keys(FindingsState)
|
||||
for _, k := range keys {
|
||||
raw, ok, _ := f.bus.(interface {
|
||||
Get(string, string) ([]byte, bool, error)
|
||||
}).Get(FindingsState, k)
|
||||
var m meshconditions.Finding
|
||||
if ok && json.Unmarshal(raw, &m) == nil {
|
||||
out = append(out, m)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (f findingsThrough) Clear(_ context.Context, scope, id, kind, _ string) error {
|
||||
return f.bus.Delete(FindingsState, views.FindingKey(scope, id, kind))
|
||||
}
|
||||
|
||||
@@ -11,9 +11,9 @@ import (
|
||||
"fmt"
|
||||
"sort"
|
||||
|
||||
ociseat "git.novox.be/novox/mesh-delivery/contracts/go/seats/ociartifactregistry"
|
||||
ociseat "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/ociartifactregistry"
|
||||
|
||||
reg "git.novox.be/novox/mesh-delivery/contracts/go/seats/containerimageregistry"
|
||||
reg "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/containerimageregistry"
|
||||
)
|
||||
|
||||
// Repo is one repository as the seats' list answers it.
|
||||
|
||||
@@ -13,8 +13,8 @@ import (
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient"
|
||||
"git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshhandactsclient"
|
||||
meshconditions "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions"
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/views"
|
||||
@@ -24,12 +24,29 @@ func logf(format string, a ...any) { fmt.Fprintf(os.Stderr, format+"\n", a...) }
|
||||
|
||||
func main() {
|
||||
s := &Store{Ask: stdio.Ask}
|
||||
v := &views.Reader{Module: "artifact-store-tools", Logf: logf, Findings: meshconditionsclient.FindingWriter{State: stdio.State("findings")}, Acts: meshhandactsclient.Recorder{Ask: stdio.Ask}}
|
||||
v := &views.Reader{Module: "artifact-store-tools", Logf: logf, Acts: meshhandactsclient.Recorder{Ask: stdio.Ask}}
|
||||
v.Follow(assignerModule, "wants", WantsTool)
|
||||
v.Follow(indexerModule, "manifests", ManifestsTool)
|
||||
h := &Holder{Bus: runtimeBus{views: v}, Door: DoorAt(os.Getenv("MESH_ARTIFACT_STORE_ADDRESS_FILE"), s), Listed: s.View,
|
||||
Seats: stdio.Ask, Now: time.Now, Logf: logf}
|
||||
h.Acts = meshhandactsclient.Recorder{Ask: stdio.Ask}
|
||||
// The module's one held writer (design 47 G §689, A-16, A-29, A-32): the holder's findings and the reader's go through it.
|
||||
w, err := views.NewStdioHeldWriter(views.WriterConfig[meshconditions.Finding, meshconditions.FindingCleared, meshconditions.FindingsStated, meshconditions.FindingsAskedEvent]{
|
||||
Asked: meshconditions.SubjectFindingsAsked, Module: Module, State: stdio.State("findings"), Kinds: []string{ImageMissing, SweepRefused}})
|
||||
if err != nil {
|
||||
logf("%v", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
h.Findings = w
|
||||
v.Findings = w
|
||||
// the runtime answers the writer's subscription only once Serve has its channel
|
||||
go func() {
|
||||
<-stdio.Served()
|
||||
if err := w.Start(context.Background()); err != nil {
|
||||
logf("%v", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}()
|
||||
v.Start(context.Background())
|
||||
go follow(h, v)
|
||||
// An empty name serves as the module the runtime names (MESH_SERVED_MODULE): artifact-store-tools.
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
sweep "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshartifactsweep"
|
||||
sweep "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshartifactsweep"
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
)
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
reg "git.novox.be/novox/mesh-delivery/contracts/go/seats/containerimageregistry"
|
||||
sweep "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshartifactsweep"
|
||||
reg "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/containerimageregistry"
|
||||
sweep "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshartifactsweep"
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/seatlist/seatlisttest"
|
||||
"git.novox.be/novox/mesh-sdk/go/views"
|
||||
@@ -474,14 +474,19 @@ func TestTheToolsServedAreTheToolsTheManifestNames(t *testing.T) {
|
||||
t.Errorf("invokes %v", m.Invokes)
|
||||
}
|
||||
// ADR 0615: the assigner's wants and the indexer's manifests are asked once and followed by their events, not read.
|
||||
if !reflect.DeepEqual(m.Consumes, []string{"mesh-delivery-assigner.views.wants", "mesh-delivery-indexer.views.manifests"}) ||
|
||||
if !reflect.DeepEqual(m.Consumes, []string{"mesh-delivery-assigner.views.wants", "mesh-delivery-indexer.views.manifests", "mesh-conditions.findings-asked"}) ||
|
||||
slices.Contains(m.Reads, WantsRead) || slices.Contains(m.Reads, ManifestsRead) {
|
||||
t.Errorf("consumes %v, reads %v", m.Consumes, m.Reads)
|
||||
}
|
||||
// The operator, 2026-11-04 (issue 1966): it reaches the store through the registry seats' verbs, and requires no
|
||||
// provision artifact-store.
|
||||
if len(m.Requires) != 0 {
|
||||
t.Errorf("requires %v, want nothing: the store is reached through the registry seats", m.Requires)
|
||||
// What it requires is its finding state and stream alone, in their template form (design 47 G §529).
|
||||
var required []string
|
||||
for _, r := range m.Requires {
|
||||
required = append(required, fmt.Sprint(r.(map[string]any)["name"]))
|
||||
}
|
||||
if !reflect.DeepEqual(required, []string{"findings", "finding-events"}) {
|
||||
t.Errorf("requires %v, want its findings and finding-events alone: the store is reached through the registry seats", m.Requires)
|
||||
}
|
||||
// Its address file is filled from the image seat holder's listen (ADR 0632 §1), never by the retired provision.
|
||||
address, err := os.ReadFile("../../files/store-address")
|
||||
|
||||
@@ -10,25 +10,23 @@ package main
|
||||
// sweep's finding, sweep-refused; the next sweep with no refusal clears it.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient"
|
||||
meshconditions "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions"
|
||||
artifactref "git.novox.be/novox/mesh-delivery/contracts/go/clients/containerimageregistryclient/reference"
|
||||
ociseat "git.novox.be/novox/mesh-delivery/contracts/go/seats/ociartifactregistry"
|
||||
artifactref "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/containerimageregistryclient/reference"
|
||||
ociseat "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/ociartifactregistry"
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/keys"
|
||||
|
||||
reg "git.novox.be/novox/mesh-delivery/contracts/go/seats/containerimageregistry"
|
||||
reg "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/containerimageregistry"
|
||||
)
|
||||
|
||||
// SweepRefused is the sweep's finding: a seat refused a hold or a remove the sweep asked.
|
||||
const SweepRefused = "sweep-refused"
|
||||
|
||||
// sweepFindingKey is the one key of the sweep's finding in the module's findings.
|
||||
var sweepFindingKey = meshconditionsclient.Key(meshconditionsclient.ScopeModule, Module, SweepRefused)
|
||||
|
||||
// canonical is a kept reference in the older form, `artifact-store://<path>`, whichever form it came in; another is as
|
||||
// it was.
|
||||
func canonical(reference string) string {
|
||||
@@ -115,26 +113,28 @@ func (h *Holder) removeOnSeat(reference, why string) error {
|
||||
// sweepFinding writes the sweep's finding: what the seat refused, and why.
|
||||
func (h *Holder) sweepFinding(what string, err error) {
|
||||
at := h.now().UTC()
|
||||
f := meshconditions.FindingsByKeyValue{V: meshconditionsclient.FindingVersion, Key: sweepFindingKey, Kind: SweepRefused,
|
||||
Subject: meshconditions.Subject{Scope: meshconditionsclient.ScopeModule, ID: Module},
|
||||
Severity: meshconditionsclient.Warning, Resolver: meshconditionsclient.ResolverOperator,
|
||||
f := meshconditions.Finding{Scope: "module", ID: keys.EscapeName(Module), Kind: SweepRefused,
|
||||
Subject: meshconditions.Subject{Scope: "module", ID: keys.EscapeName(Module)},
|
||||
Severity: meshconditions.FindingSeverityWarning, Resolver: "operator",
|
||||
Summary: fmt.Sprintf("the artifact sweep stopped: a registry seat refused %s (the evidence names why)", what),
|
||||
Headline: "The artifact sweep stopped",
|
||||
Explanation: "The sweep keeps what the mesh still needs and removes the rest through the registry seats. A seat " +
|
||||
"refused one of its asks, so it stopped before anything more was removed; what it kept stays kept.",
|
||||
Needs: "read the seat holder's journal for the refusal; the next sweep asks again.",
|
||||
Resolved: "Resolved: the artifact sweep ran through without a refusal",
|
||||
Evidence: []meshconditions.Evidence{{At: at.UTC(), Said: fmt.Sprintf("%s: %v", what, err)}},
|
||||
Source: "CAT-AS1", Observer: Module, At: at}
|
||||
if werr := h.Bus.Put(FindingsState, sweepFindingKey, f); werr != nil {
|
||||
if werr := h.Findings.Write(context.Background(), f); werr != nil {
|
||||
h.logf("the sweep's finding (%s: %v) could not be written: %v", what, err, werr)
|
||||
}
|
||||
}
|
||||
|
||||
// sweepClean clears the sweep's finding after a sweep no seat refused.
|
||||
// sweepClean clears the sweep's finding, when the held writer holds it, after a sweep no seat refused.
|
||||
func (h *Holder) sweepClean() {
|
||||
if _, found, err := h.Bus.Get(FindingsState, sweepFindingKey); err == nil && found {
|
||||
if err := h.Bus.Delete(FindingsState, sweepFindingKey); err != nil {
|
||||
for _, was := range h.Findings.Held() {
|
||||
if was.Kind != SweepRefused {
|
||||
continue
|
||||
}
|
||||
if err := h.Findings.Clear(context.Background(), was.Scope, was.ID, was.Kind, "a sweep ran through without a refusal"); err != nil {
|
||||
h.logf("the sweep's finding could not be cleared: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,18 +5,17 @@ go 1.26.0
|
||||
require (
|
||||
git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-contracts/go/seats/nodeengine v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshhandactsclient v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshhandacts v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-delivery/contracts/go/clients/containerimageregistryclient v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-delivery/contracts/go/seats/containerimageregistry v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-delivery/contracts/go/seats/meshartifactsweep v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-delivery/contracts/go/seats/meshwants v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-delivery/contracts/go/seats/ociartifactregistry v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/containerimageregistryclient v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/meshcatalogclient v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/containerimageregistry v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshartifactsweep v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshwants v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/ociartifactregistry v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000
|
||||
github.com/nats-io/nats.go v1.54.0
|
||||
)
|
||||
@@ -35,4 +34,6 @@ require (
|
||||
golang.org/x/time v0.16.0 // indirect
|
||||
)
|
||||
|
||||
require git.novox.be/novox/mesh-delivery/sdk v0.0.0-00010101000000-000000000000 // indirect
|
||||
require git.novox.be/novox/mesh-delivery/modules/mesh-delivery-sdk v0.0.0-00010101000000-000000000000 // indirect
|
||||
|
||||
require git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshdelivery v0.0.0-00010101000000-000000000000 // indirect
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"module": "artifact-store-tools",
|
||||
"version": "1",
|
||||
"version": "0.1",
|
||||
"packages": {
|
||||
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds": "0.1"
|
||||
},
|
||||
@@ -39,10 +39,8 @@
|
||||
],
|
||||
"consumes": [
|
||||
"mesh-delivery-assigner.views.wants",
|
||||
"mesh-delivery-indexer.views.manifests"
|
||||
],
|
||||
"state": [
|
||||
"findings"
|
||||
"mesh-delivery-indexer.views.manifests",
|
||||
"mesh-conditions.findings-asked"
|
||||
],
|
||||
"tools": [
|
||||
"artifact_store_repositories",
|
||||
@@ -108,5 +106,26 @@
|
||||
"from": "files"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"requires": [
|
||||
{
|
||||
"name": "findings",
|
||||
"storage": "key-value",
|
||||
"template": "state"
|
||||
},
|
||||
{
|
||||
"name": "finding-events",
|
||||
"storage": "stream",
|
||||
"template": "events",
|
||||
"options": {
|
||||
"events": [
|
||||
"finding-written.*",
|
||||
"finding-cleared.*",
|
||||
"findings-stated.*"
|
||||
],
|
||||
"max-age-days": 7,
|
||||
"max-mib": 16
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"module": "asus-zephyrus-g14",
|
||||
"version": "1",
|
||||
"version": "0.1",
|
||||
"moved": true,
|
||||
"templates": "templates",
|
||||
"capabilities": [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"module": "audit-logger",
|
||||
"version": "1",
|
||||
"version": "0.1",
|
||||
"slug": "audit",
|
||||
"consumes": [
|
||||
"**"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"module": "avahi",
|
||||
"version": "1",
|
||||
"version": "0.1",
|
||||
"capabilities": [
|
||||
"package-manager",
|
||||
"service-manager"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"module": "baserow",
|
||||
"version": "1",
|
||||
"version": "0.1",
|
||||
"capabilities": [
|
||||
"container-runtime"
|
||||
],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"module": "blueman",
|
||||
"version": "1",
|
||||
"version": "0.1",
|
||||
"capabilities": [
|
||||
"package-manager"
|
||||
],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"module": "bluetooth",
|
||||
"version": "1",
|
||||
"version": "0.1",
|
||||
"capabilities": [
|
||||
"package-manager",
|
||||
"service-manager"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"module": "brother",
|
||||
"version": "1",
|
||||
"version": "0.1",
|
||||
"requires": [
|
||||
{
|
||||
"name": "s3-bucket",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"module": "buildah",
|
||||
"version": "1",
|
||||
"version": "0.1",
|
||||
"capabilities": [
|
||||
"package-manager",
|
||||
"user-namespaces"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"module": "ca-trust",
|
||||
"version": "1",
|
||||
"version": "0.1",
|
||||
"slug": "catrust",
|
||||
"capabilities": [
|
||||
"service-manager"
|
||||
|
||||
@@ -83,7 +83,7 @@ must see, a node that joins later included — kept, so it carries no secret eit
|
||||
| what this node should hold | the licence manager's `binding.changed` naming this node, and at start (or a reconnect) its first view, `anthropic-licence-manager.bindings` with `consumer` this node; an event for another node is ignored. On a newer generation, at start, and on the vault's `secret-changed` for the licence it holds, this module asks `anthropic-licence-manager.current` which licence, and the vault's `get` for that licence's access token (`licence.<key>.access`), sealed to its own key, and writes it access-token-only — so the agent here never refreshes, and never receives a refresh token. A home holding no token is fetched again at the same generation (design 39 bus-and-switch §19, §22) |
|
||||
| an MCP server registered through this module before registering was refused (hq issue 340) | a key in the module's `servers` state — `all.<server>` for every node, `<node>.<server>` for one; every node watches it and renders what applies to it, a node's own entry over the one for every node. A node that joins later, or was off, reads the whole current set at start; unregistering is a delete. Nothing adds a key any more: a new tool server is the operator's, in the `mcp_servers` setting at the controller's terminal |
|
||||
| a proposed instruction section (hq ADR 0274) | a key in the module's `proposals` state per proposal, the ask published on `operator-channel` under this module's name, and the router's record of it read under that name alone; what a node approved is its own file, never the bus |
|
||||
| a session's question to the operator (hq ADR 0332) | a key `q-…` in the module's `questions` state per question, the ask published on `operator-channel` under this module's name, and the router's record of it read under that name alone; the router's word that it ended without a choice is kept on the question; two slot keys `slot-1`, `slot-2` name the questions open |
|
||||
| a session's question to the operator (hq ADR 0332) | a key `q-…` in the module's `questions` state per question, the ask published on `operator-channel` under this module's name, and the router's record of it read under that name alone; the router's word that it ended without a choice is kept on the question |
|
||||
| a project's memory (hq ADR 0343) | a key in the module's `memory` state per file — `<kind>.<project and path, base64url>`, `operator` for the operator's own homes and `agent` for an agent account's — holding the text, its digest, the digest of the version it replaced, the node and when it changed; a removal is a value marked removed, never a deleted key. Written at the revision read, so of two nodes writing one file only one wins. Every node of the same kind watches it. No event and no tool answer carries memory text |
|
||||
| the agent's configuration (hq ADR 0216) | a key in the module's `config` state per registration — `mesh.<kind>.<name>` for every node, `node.<node>.<kind>.<name>` for one, `home.<node>.<kind>.<name>` for one account's own directory — the item and its files in one value, at most 256 KiB. Every node watches it and renders what applies to it, a node item over a mesh item of the same kind and name. A hook, settings, or an item that would run something by itself is never taken, whenever it was put there (hq issue 340) |
|
||||
|
||||
@@ -233,11 +233,8 @@ key says waiting, chosen (the option, who answered, on which channel, when), end
|
||||
refused, cancelled, replaced), or not proven: the answer is read from the router's own record and held to the
|
||||
question as `questions` keeps it, so a question rewritten after it was asked, a warrant for another ask or a choice
|
||||
after expiry is never read as a choice. One call waits at most 60 s; the tool asks a session to read a question at
|
||||
most once a minute, and nothing enforces it. At most two questions wait at once, mesh-wide: each holds one of two
|
||||
slot keys in `questions`, taken by create or compare-and-set, so two nodes asking at once cannot both take the last,
|
||||
and a slot that cannot be read refuses the question. Questions alone never fill the router's three open asks, so one
|
||||
is left for a proposed instruction section while two are open. A question the router holds no record of, its refusal
|
||||
not heard here, keeps its slot and reads as waiting until it expires: the router may still take it from its queue,
|
||||
most once a minute, and nothing enforces it. No question is refused for how many wait: the router offers an asker at most three asks at once, holds the oldest back behind newer ones and offers it again when one ends (hq ADR 0666 §12). A question the router holds no record of, its refusal
|
||||
not heard here, reads as waiting until it expires: the router may still take it from its queue,
|
||||
and no cancel can overtake it there (the queue offers work again after an error, and a cancel of an ask not yet
|
||||
recorded is dropped); once expired, the router refuses it if taken late. A refused question keeps none of
|
||||
its words. An answer is the operator's word to the session and authorises nothing; a question never defaults.
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
package main
|
||||
|
||||
// claude-code's reading of which modules exist and of each placed module's manifest (design 47 G §196, §594, §614): the
|
||||
// catalog half, taken through meshcatalogclient's follower of the watched branches on the part's one views reader. A
|
||||
// module's manifest is the one its catalog graph entry carries whole (§443), never the indexer's state `modules` or
|
||||
// `manifests`.
|
||||
|
||||
import (
|
||||
catalog "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog"
|
||||
)
|
||||
|
||||
// CatalogHalf is what this module reads of the catalog half.
|
||||
type CatalogHalf interface {
|
||||
// Entry is a module's entry at the trunk; an error while a watched branch has no first view yet, never "no module".
|
||||
Entry(module string) (catalog.CatalogEntry, bool, error)
|
||||
// Ready is nil once every watched branch has its first view (none-yet counting as one), else says what it waits for.
|
||||
Ready() error
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/meshcatalogclient"
|
||||
catalog "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog"
|
||||
delivery "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshdelivery"
|
||||
"git.novox.be/novox/mesh-sdk/go/views"
|
||||
)
|
||||
|
||||
// wiringOf is a part's reader in miniature: it keeps the followers adopted, so a test starts them as the reader's Start does.
|
||||
type wiringOf struct {
|
||||
mu sync.Mutex
|
||||
fs []*views.Follower
|
||||
}
|
||||
|
||||
func (w *wiringOf) Adopt(f *views.Follower, _, _ string, _ func(context.Context, []byte) error) error {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
w.fs = append(w.fs, f)
|
||||
return nil
|
||||
}
|
||||
func (w *wiringOf) Drop(string, string) error { return nil }
|
||||
|
||||
// Design 47 G §196, §594: which modules exist, and the manifest of each, are the catalog half's; a branch with no first
|
||||
// view yet is an error naming it, never no module, and an entry's manifest is the one it carries whole.
|
||||
func TestCatalogHalfIsTheWatchedBranchesNewestRevision(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
answered := false
|
||||
var mu sync.Mutex
|
||||
call := func(key string, _ any) (json.RawMessage, error) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
switch key {
|
||||
case meshcatalogclient.RegistrationsVerb:
|
||||
return json.Marshal(catalog.RegistrationsAnswer{Registrations: []catalog.Registration{{Module: "m", Version: 1, Branches: []string{"main"}}}})
|
||||
case meshcatalogclient.SourceVerb:
|
||||
return json.Marshal(delivery.SourceAnswer{})
|
||||
case meshcatalogclient.RevisionVerb:
|
||||
if !answered {
|
||||
return nil, fmt.Errorf("the holder is away")
|
||||
}
|
||||
return json.Marshal(catalog.RevisionAnswer{Revision: &catalog.CatalogRevision{Revision: 1, Kind: meshcatalogclient.KindTrunk},
|
||||
Entries: []catalog.CatalogEntry{{Module: "pg", Manifest: map[string]any{"module": "pg"}}}})
|
||||
}
|
||||
return nil, fmt.Errorf("asked %s", key)
|
||||
}
|
||||
w := &wiringOf{}
|
||||
b, err := meshcatalogclient.NewBranches(w, call)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h := b
|
||||
if _, _, err := h.Entry("pg"); err == nil || !strings.Contains(err.Error(), "no watched branch") {
|
||||
t.Fatalf("before the registrations: %v, want an error naming the missing branch", err)
|
||||
}
|
||||
go func() { _ = w.fs[0].Start(ctx) }() // the registrations
|
||||
for len(b.Followed()) == 0 {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
t.Fatal("the registrations' branch is never followed")
|
||||
case <-time.After(10 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
if _, _, err := h.Entry("pg"); err == nil || !strings.Contains(err.Error(), "no first view of main") {
|
||||
t.Fatalf("with no first view: %v, want an error naming the branch, never no module", err)
|
||||
}
|
||||
mu.Lock()
|
||||
answered = true
|
||||
mu.Unlock()
|
||||
c, _ := b.Catalog("main")
|
||||
if err := c.Start(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, ok, err := h.Entry("pg")
|
||||
if err != nil || !ok || got.Manifest["module"] != "pg" || h.Ready() != nil {
|
||||
t.Fatalf("with the first view: %v, %v, %v", got, ok, err)
|
||||
}
|
||||
if _, ok, err := h.Entry("absent"); ok || err != nil {
|
||||
t.Fatalf("a module the half does not list: %v, %v", ok, err)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -9,6 +10,8 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/views"
|
||||
)
|
||||
|
||||
var now = time.Now().UnixMilli()
|
||||
@@ -595,6 +598,20 @@ func TestTheFirstViewOfTheBindingIsApplied(t *testing.T) {
|
||||
if _, err := FirstBinding(p, ask, writer(w)); err != nil || len(asked) != 6 || asked[5] != VaultGet {
|
||||
t.Fatalf("a start asked %v", asked)
|
||||
}
|
||||
// A-134: the first view is the follower the module's views reader adopts on binding.changed.<consumer>; its answer
|
||||
// applies the binding as FirstBinding does.
|
||||
r := &views.Reader{Module: Module}
|
||||
f := bindingsFollower(p, ask, writer(w), func(done string, err error) {
|
||||
if err != nil || !strings.Contains(done, `"generation":9`) {
|
||||
t.Errorf("the adopted first view applied %q, %v", done, err)
|
||||
}
|
||||
})
|
||||
if err := r.Adopt(f, Manager, BindingChangedEvent, func(context.Context, []byte) error { return nil }); err != nil || !r.Follows(Manager+".bindings") {
|
||||
t.Fatalf("the bindings follower is not adopted: %v", err)
|
||||
}
|
||||
if err := f.Start(context.Background()); err != nil || len(asked) != 9 {
|
||||
t.Fatalf("the adopted first view: %v, asked %v", err, asked)
|
||||
}
|
||||
raw, _ := os.ReadFile("../../module.json")
|
||||
var m struct {
|
||||
Consumes, Invokes []string
|
||||
|
||||
@@ -277,10 +277,11 @@ func TestTheManifestListsEveryToolServed(t *testing.T) {
|
||||
_ = json.Unmarshal(raw, &m)
|
||||
// The hand-act seat's warranted and hand-act, the seat mesh-catalog's seat list (ADR 0635 §1), and the owners' read tools of the three followed states; no verb of
|
||||
// the controller, and no read of the indexer's or the assigner's state (ADR 0615 §1): their views are consumed. The
|
||||
// licence binding is the seat's bindings and the manager's binding.changed, its token the vault's get and secret-changed (design 39 bus-and-switch §19, §22, B9).
|
||||
if !slices.Equal(m.Invokes, []string{"seat:mesh-hand-acts.warranted", "seat:mesh-hand-acts.hand-act", "seat:mesh-catalog.seat-definitions", ModulesTool, ManifestsTool, AssignmentsTool, SeatVerb("bindings"), SeatVerb("current"), VaultGet}) ||
|
||||
// licence binding is the seat's bindings and the manager's binding.changed, its token the vault's get and secret-changed (design 39 bus-and-switch §19, §22, B9); a
|
||||
// notice goes out on the operator channel's notify (design 47 G GR8).
|
||||
if !slices.Equal(m.Invokes, []string{"seat:mesh-hand-acts.warranted", "seat:mesh-hand-acts.hand-act", "seat:mesh-catalog.seat-definitions", RevisionVerb, RegistrationsVerb, SourceVerb, AssignmentsTool, SeatVerb("bindings"), SeatVerb("current"), VaultGet, "seat:operator-channel.notify"}) ||
|
||||
len(m.Reads) != 0 ||
|
||||
!slices.Equal(m.Consumes, []string{"mesh-delivery-indexer.views.modules", "mesh-delivery-indexer.views.manifests", "mesh-delivery-assigner.views.assignments", "mesh-catalog.seat-definition-changed", "mesh-catalog.seat-definition-removed", HoldingsAsked, BindingChanged, SecretChanged}) {
|
||||
!slices.Equal(m.Consumes, []string{"mesh-catalog.catalog-revision", "mesh-catalog.registration-changed", "mesh-delivery.source-moved", "mesh-delivery-assigner.views.assignments", "mesh-catalog.seat-definition-changed", "mesh-catalog.seat-definition-removed", "mesh-conditions.findings-asked", HoldingsAsked, BindingChanged, SecretChanged}) {
|
||||
t.Errorf("invokes = %v, reads = %v, consumes = %v", m.Invokes, m.Reads, m.Consumes)
|
||||
}
|
||||
if src, _ := os.ReadFile("proposals.go"); !strings.Contains(string(src), `"seat:mesh-hand-acts.warranted"`) ||
|
||||
|
||||
@@ -2,14 +2,8 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
assignerclient "git.novox.be/novox/mesh-contracts/go/clients/meshmoduleassignerclient"
|
||||
mesh "git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner"
|
||||
catalogclient "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient"
|
||||
catalog "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog"
|
||||
"os"
|
||||
"reflect"
|
||||
"slices"
|
||||
@@ -17,6 +11,10 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
assignerclient "git.novox.be/novox/mesh-contracts/go/clients/meshmoduleassignerclient"
|
||||
mesh "git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner"
|
||||
catalog "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/seatdef"
|
||||
"git.novox.be/novox/mesh-sdk/go/seatlist"
|
||||
"git.novox.be/novox/mesh-sdk/go/views"
|
||||
@@ -57,7 +55,7 @@ func someSeats() []seatdef.Definition {
|
||||
// saying what its tool replaces, as the seat mesh-module-assigner and the indexer keep them on the bus.
|
||||
func aMesh(t *testing.T) aBus {
|
||||
t.Helper()
|
||||
b := aBus{AssignmentsRead: {}, ModulesRead: {}, ManifestsRead: {}}
|
||||
b := aBus{AssignmentsRead: {}}
|
||||
for _, p := range []mesh.Assignments{
|
||||
{V: 1, Node: "anchor", Decided: mesh.NodeDecided{PublicDomain: "Anchor.Example"},
|
||||
Modules: []mesh.PlacedModule{{Module: "docker"}, {Module: "zsh"}}},
|
||||
@@ -71,21 +69,33 @@ func aMesh(t *testing.T) aBus {
|
||||
b[AssignmentsRead][k] = v
|
||||
}
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// fakeCatalog is the catalog half as the tests give it: docker and zsh, docker's manifest saying what its tool replaces.
|
||||
type fakeCatalog struct {
|
||||
entries map[string]catalog.CatalogEntry
|
||||
notYet error
|
||||
}
|
||||
|
||||
func (f fakeCatalog) Entry(module string) (catalog.CatalogEntry, bool, error) {
|
||||
e, ok := f.entries[module]
|
||||
return e, ok, f.notYet
|
||||
}
|
||||
|
||||
func (f fakeCatalog) Ready() error { return f.notYet }
|
||||
|
||||
func aCatalog() fakeCatalog {
|
||||
f := fakeCatalog{entries: map[string]catalog.CatalogEntry{}}
|
||||
for module, manifest := range map[string]string{
|
||||
"docker": `{"module":"docker","replaces":{"docker_logs":["docker logs"]}}`,
|
||||
"zsh": `{"module":"zsh"}`,
|
||||
} {
|
||||
digest := fmt.Sprintf("%x", sha256.Sum256([]byte(manifest)))
|
||||
mkey, _ := catalogclient.ManifestsKey(digest)
|
||||
raw, err := json.Marshal(catalog.ManifestsByDigestValue{V: 1, Module: module, Manifest: manifest})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b[ManifestsRead][mkey] = raw
|
||||
key, _ := catalogclient.ModulesKey(module)
|
||||
b[ModulesRead][key], _ = json.Marshal(catalog.ModulesByModuleValue{V: 1, None: "nothing is built of it", Manifest: digest, Repository: "novox/mesh-catalog", Commit: "c"})
|
||||
var whole map[string]any
|
||||
_ = json.Unmarshal([]byte(manifest), &whole)
|
||||
f.entries[module] = catalog.CatalogEntry{Module: module, Manifest: whole}
|
||||
}
|
||||
return b
|
||||
return f
|
||||
}
|
||||
|
||||
func resolving(name string) []string {
|
||||
@@ -97,7 +107,7 @@ func resolving(name string) []string {
|
||||
// controller: the seats' verbs, the placed modules' own tools, every machine with its domains and addresses — a
|
||||
// loopback address never one of them.
|
||||
func TestTheMeshsToolsAreReadFromTheBus(t *testing.T) {
|
||||
m, err := ReadMeshToolsFrom(aMesh(t), someSeats(), resolving)
|
||||
m, err := ReadMeshToolsFrom(aMesh(t), aCatalog(), someSeats(), resolving)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -134,7 +144,7 @@ func TestTheMeshsToolsAreReadFromTheBus(t *testing.T) {
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatalf("the seat list was not read: %+v", seatList.Status())
|
||||
}
|
||||
got, err := ReadMeshToolsFrom(aMesh(t), definedSeats(), resolving)
|
||||
got, err := ReadMeshToolsFrom(aMesh(t), aCatalog(), definedSeats(), resolving)
|
||||
var addrs []string
|
||||
for _, r := range got.Replacements {
|
||||
addrs = append(addrs, r.Address)
|
||||
@@ -156,7 +166,7 @@ func TestTheMeshsToolsAreReadFromTheBus(t *testing.T) {
|
||||
// **The table is generated, not written** (novox/hq ADR 0245): what the records say a verb replaces is a row,
|
||||
// the machines' seats first, and nothing is rendered where the records say nothing.
|
||||
func TestTheInsteadOfTableIsGeneratedFromTheRecords(t *testing.T) {
|
||||
m, _ := ReadMeshToolsFrom(aMesh(t), someSeats(), resolving)
|
||||
m, _ := ReadMeshToolsFrom(aMesh(t), aCatalog(), someSeats(), resolving)
|
||||
table := InsteadOf(&m)
|
||||
for _, want := range []string{
|
||||
"| `<node>/node-service-manager.` | `status` — `systemctl status`, `systemctl is-active` · `journal` — `journalctl` |",
|
||||
@@ -206,7 +216,7 @@ func TestTheInsteadOfTableIsGeneratedFromTheRecords(t *testing.T) {
|
||||
// Asked again with the same answer, nothing is written and nothing renders.
|
||||
func TestTheMeshsToolsAreKeptOnlyWhenTheyChange(t *testing.T) {
|
||||
p := Paths{State: t.TempDir()}
|
||||
m, _ := ReadMeshToolsFrom(aMesh(t), someSeats(), resolving)
|
||||
m, _ := ReadMeshToolsFrom(aMesh(t), aCatalog(), someSeats(), resolving)
|
||||
if !KeepMeshTools(p, m) {
|
||||
t.Fatal("the first answer was not kept")
|
||||
}
|
||||
@@ -243,7 +253,7 @@ func TestTheMachinesRunningAModuleAreReadFromAssignments(t *testing.T) {
|
||||
func TestTheMeshIsReadFromTheOwnersFirstViews(t *testing.T) {
|
||||
bus := aMesh(t)
|
||||
r := newMeshViews()
|
||||
for _, name := range []string{ModulesRead, ManifestsRead, AssignmentsRead} {
|
||||
for _, name := range []string{AssignmentsRead} {
|
||||
state := strings.TrimPrefix(name, strings.Split(name, ".")[0]+".")
|
||||
values := map[string]json.RawMessage{}
|
||||
for k, v := range bus[name] {
|
||||
@@ -257,16 +267,20 @@ func TestTheMeshIsReadFromTheOwnersFirstViews(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
want, err := ReadMeshToolsFrom(bus, someSeats(), resolving)
|
||||
want, err := ReadMeshToolsFrom(bus, aCatalog(), someSeats(), resolving)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := ReadMeshToolsFrom(viewStates{r}, someSeats(), resolving)
|
||||
got, err := ReadMeshToolsFrom(viewStates{r}, aCatalog(), someSeats(), resolving)
|
||||
if err != nil || !reflect.DeepEqual(got, want) || len(got.Replacements) == 0 {
|
||||
t.Fatalf("from the views: %+v, %v\nfrom the state: %+v", got, err, want)
|
||||
}
|
||||
// A reader not yet answered says so, and reads nothing.
|
||||
if _, err := ReadMeshToolsFrom(viewStates{newMeshViews()}, someSeats(), resolving); err == nil || !strings.Contains(err.Error(), "no first view") {
|
||||
// A catalog half with no first view says so, and reads no module as absent.
|
||||
if _, err := ReadMeshToolsFrom(bus, fakeCatalog{notYet: errors.New("the catalog half has no first view of main yet")}, someSeats(), resolving); err == nil || !strings.Contains(err.Error(), "no first view") {
|
||||
t.Fatalf("an unanswered catalog half: %v", err)
|
||||
}
|
||||
if _, err := ReadMeshToolsFrom(viewStates{newMeshViews()}, aCatalog(), someSeats(), resolving); err == nil || !strings.Contains(err.Error(), "no first view") {
|
||||
t.Fatalf("an unanswered first view: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -274,7 +288,7 @@ func TestTheMeshIsReadFromTheOwnersFirstViews(t *testing.T) {
|
||||
// Issue 1126 B13 AC1 (ADR 0590): over the seats the mesh defines, the rendered instructions, their table among them,
|
||||
// name no address of the controller, which is not placed: each job is named by the seat that owns it now.
|
||||
func TestTheRenderedInstructionsNameNoControllerAddress(t *testing.T) {
|
||||
m, err := ReadMeshToolsFrom(aMesh(t), someSeats(), resolving)
|
||||
m, err := ReadMeshToolsFrom(aMesh(t), aCatalog(), someSeats(), resolving)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The operator channel's notify, which notifyOperator calls, is in the manifest's invokes, which the grants part reads per
|
||||
// verb (design 47 G GR8): without it the call is refused live.
|
||||
func TestTheManifestInvokesTheNotifyTheModuleCalls(t *testing.T) {
|
||||
raw, err := os.ReadFile("../../module.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(raw), `"seat:operator-channel.notify"`) {
|
||||
t.Error("the manifest does not invoke seat:operator-channel.notify")
|
||||
}
|
||||
}
|
||||
@@ -23,9 +23,10 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient"
|
||||
condseat "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions"
|
||||
occ "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient"
|
||||
oc "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel"
|
||||
catalogclient "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/meshcatalogclient"
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"git.novox.be/novox/mesh-sdk/go/seatlist"
|
||||
"git.novox.be/novox/mesh-sdk/go/views"
|
||||
@@ -218,12 +219,19 @@ func nodesRunningMe() ([]string, error) { return NodesRunning(viewStates{meshVie
|
||||
// holds them equal).
|
||||
const (
|
||||
assignerModule = "mesh-delivery-assigner"
|
||||
indexerModule = "mesh-delivery-indexer"
|
||||
)
|
||||
|
||||
// The seat verbs the follower of the watched branches asks (design 47 G §614), as the manifest's `invokes` names them.
|
||||
const (
|
||||
RevisionVerb = "seat:mesh-catalog.revision"
|
||||
RegistrationsVerb = "seat:mesh-catalog.registrations"
|
||||
SourceVerb = "seat:mesh-delivery.source"
|
||||
)
|
||||
|
||||
// meshCatalog is this module's reading of which modules exist: the catalog half, set where the reader is built.
|
||||
var meshCatalog CatalogHalf
|
||||
|
||||
const (
|
||||
ModulesTool = indexerModule + ".indexer_read_modules"
|
||||
ManifestsTool = indexerModule + ".indexer_read_manifests"
|
||||
AssignmentsTool = assignerModule + ".assigner_read_assignments"
|
||||
)
|
||||
|
||||
@@ -231,12 +239,22 @@ const (
|
||||
// once of the owner's read tool, then followed on the owner's views family. It reads none of their state.
|
||||
var meshViews *views.Reader
|
||||
|
||||
// newFindingsWriter is this module's finding writer (design 47 G §689, A-16): it states the findings of its views reader and
|
||||
// observes none of its own.
|
||||
func newFindingsWriter() (*views.Writer[condseat.Finding, condseat.FindingCleared, condseat.FindingsStated, condseat.FindingsAskedEvent], error) {
|
||||
return views.NewStdioWriter(views.WriterConfig[condseat.Finding, condseat.FindingCleared, condseat.FindingsStated, condseat.FindingsAskedEvent]{
|
||||
Asked: condseat.SubjectFindingsAsked, Module: "claude-code", State: stdio.State("findings"),
|
||||
Observe: func(context.Context) (views.Observed[condseat.Finding], error) {
|
||||
return views.Observed[condseat.Finding]{}, nil
|
||||
}})
|
||||
}
|
||||
|
||||
// meshChanged is told, without waiting, that a followed state changed: the instructions' table is read again.
|
||||
var meshChanged = make(chan struct{}, 1)
|
||||
|
||||
// newMeshViews builds the reader of those three states; Start asks them.
|
||||
func newMeshViews() *views.Reader {
|
||||
v := &views.Reader{Module: Module, Logf: func(f string, a ...any) { say(f, a...) }, Findings: meshconditionsclient.FindingWriter{State: stdio.State("findings")},
|
||||
v := &views.Reader{Module: Module, Logf: func(f string, a ...any) { say(f, a...) },
|
||||
Acts: handActs{},
|
||||
Changed: func(string, string, json.RawMessage, bool) {
|
||||
select {
|
||||
@@ -244,8 +262,6 @@ func newMeshViews() *views.Reader {
|
||||
default:
|
||||
}
|
||||
}}
|
||||
v.Follow(indexerModule, "modules", ModulesTool)
|
||||
v.Follow(indexerModule, "manifests", ManifestsTool)
|
||||
v.Follow(assignerModule, "assignments", AssignmentsTool)
|
||||
return v
|
||||
}
|
||||
@@ -326,7 +342,7 @@ func tools(p Paths, servers ServerState, view *ServerView, config ConfigState, c
|
||||
Description: "Write Claude Code's managed directory now, from the mesh's facts, this module's settings and the servers registered here — after reading the bus again for what each tool replaces and which machines the mesh has (the instructions' table and the shell's guard).",
|
||||
Run: func(map[string]any) (any, error) {
|
||||
answer := map[string]any{}
|
||||
if m, err := ReadMeshToolsFrom(viewStates{meshViews}, definedSeats(), lookup); err != nil {
|
||||
if m, err := ReadMeshToolsFrom(viewStates{meshViews}, meshCatalog, definedSeats(), lookup); err != nil {
|
||||
answer["asked"] = "the bus could not be read in full (" + err.Error() + "); what was kept is rendered"
|
||||
} else {
|
||||
KeepMeshTools(p, m)
|
||||
@@ -460,6 +476,20 @@ func main() {
|
||||
}
|
||||
p, launched := PathsFrom(os.Getenv)
|
||||
meshViews = newMeshViews()
|
||||
branches, err := catalogclient.FollowBranches(meshViews, func(key string, body any) (json.RawMessage, error) { return stdio.Ask(key, body) })
|
||||
if err != nil {
|
||||
say("%v", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
branches.Logf = say
|
||||
// A change in a branch's half is told as any followed state's: the instructions' table is read again.
|
||||
branches.Changed = func(string, string, json.RawMessage, bool, time.Time) {
|
||||
select {
|
||||
case meshChanged <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
meshCatalog = branches
|
||||
if launched {
|
||||
fsys = FilesFor(p)
|
||||
}
|
||||
@@ -489,6 +519,20 @@ func main() {
|
||||
}
|
||||
}
|
||||
}
|
||||
findings, err := newFindingsWriter()
|
||||
if err != nil {
|
||||
say("%v", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
meshViews.Findings = findings
|
||||
// the runtime answers the writer's subscription only once Serve has its channel
|
||||
go func() {
|
||||
<-stdio.Served()
|
||||
if err := findings.Start(context.Background()); err != nil {
|
||||
say("%v", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}()
|
||||
meshViews.Start(context.Background())
|
||||
go refreshMeshTools(p)
|
||||
servers := stateOf{stdio.State("servers")}
|
||||
@@ -624,22 +668,15 @@ func run(p Paths, view *ServerView, configView *ConfigView) {
|
||||
}
|
||||
go report()
|
||||
}
|
||||
go persist("hearing this node's licence binding", func() error {
|
||||
err := stdio.Subscribe(BindingChanged, func(e stdio.Envelope) error {
|
||||
applied(OnBindingChanged(p, e.Body, ask, writeManaged))
|
||||
// Adopted by this module's views reader (design 47 G A-134): the reader hears binding.changed.<consumer> and asks the
|
||||
// first view once its subscription is taken, and raises and clears that view's findings through the module's writer.
|
||||
if err := meshViews.Adopt(bindingsFollower(p, ask, writeManaged, applied), Manager, BindingChangedEvent,
|
||||
func(_ context.Context, body []byte) error {
|
||||
applied(OnBindingChanged(p, body, ask, writeManaged))
|
||||
return nil
|
||||
})
|
||||
if err == nil {
|
||||
go persist("asking this node's licence binding", func() error {
|
||||
done, err := FirstBinding(p, ask, writeManaged)
|
||||
if err == nil {
|
||||
applied(done, nil)
|
||||
}
|
||||
return err
|
||||
}, func(int) {})
|
||||
}
|
||||
return err
|
||||
}, func(n int) { say("hearing this node's licence binding%s", refusals(n)) })
|
||||
}); err != nil {
|
||||
say("this node's licence binding is not followed: %v", err)
|
||||
}
|
||||
// The token of the licence this node holds, asked again when the vault says its access secret changed (§19).
|
||||
go persist("hearing the vault's changes of this node's licence token", func() error {
|
||||
return stdio.Subscribe(SecretChanged, func(e stdio.Envelope) error {
|
||||
|
||||
@@ -16,10 +16,6 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
assignerclient "git.novox.be/novox/mesh-contracts/go/clients/meshmoduleassignerclient"
|
||||
mesh "git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner"
|
||||
catalogclient "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient"
|
||||
catalog "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -28,6 +24,9 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
assignerclient "git.novox.be/novox/mesh-contracts/go/clients/meshmoduleassignerclient"
|
||||
mesh "git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/seatdef"
|
||||
"git.novox.be/novox/mesh-sdk/go/seatlist"
|
||||
@@ -93,8 +92,6 @@ func ReadMeshTools(p Paths) *MeshTools {
|
||||
// The states read, each declared in the manifest's reads.
|
||||
const (
|
||||
AssignmentsRead = (assignerModule + "." + mesh.StateAssignments)
|
||||
ModulesRead = indexerModule + "." + catalog.StateModules
|
||||
ManifestsRead = indexerModule + "." + catalog.StateManifests
|
||||
)
|
||||
|
||||
// States reads the module states the manifest declares in its reads: a state's keys, and one key's value.
|
||||
@@ -152,44 +149,28 @@ func NodesRunning(st States, module string) ([]string, error) {
|
||||
return runsOn(placed)[module], nil
|
||||
}
|
||||
|
||||
// modulesTools is every placed module's own tools that replace a command, by module, from the manifest the indexer
|
||||
// keeps of it. A module whose manifest cannot be read is left out and said in the error.
|
||||
func modulesTools(st States, on map[string][]string) ([]ModuleTools, error) {
|
||||
// modulesTools is every placed module's own tools that replace a command, by module, from the manifest its catalog
|
||||
// graph entry carries. A module whose manifest cannot be read is left out and said in the error.
|
||||
func modulesTools(cat CatalogHalf, on map[string][]string) ([]ModuleTools, error) {
|
||||
var out []ModuleTools
|
||||
var problems []error
|
||||
if err := cat.Ready(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
modules := make([]string, 0, len(on))
|
||||
for m := range on {
|
||||
modules = append(modules, m)
|
||||
}
|
||||
sort.Strings(modules)
|
||||
for _, module := range modules {
|
||||
key, err := catalogclient.ModulesKey(module)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
raw, found, err := st.Get(ModulesRead, key)
|
||||
entry, found, err := cat.Entry(module)
|
||||
if err != nil || !found {
|
||||
if err != nil {
|
||||
problems = append(problems, fmt.Errorf("%s %s: %w", ModulesRead, module, err))
|
||||
problems = append(problems, fmt.Errorf("the catalog half's entry of %s: %w", module, err))
|
||||
}
|
||||
continue
|
||||
}
|
||||
m, err := catalogclient.DecodeModule(raw)
|
||||
if err != nil || m.Manifest == "" {
|
||||
continue
|
||||
}
|
||||
mkey, err := catalogclient.ManifestsKey(m.Manifest)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
raw, found, err = st.Get(ManifestsRead, mkey)
|
||||
if err != nil || !found {
|
||||
if err != nil {
|
||||
problems = append(problems, fmt.Errorf("%s of %s: %w", ManifestsRead, module, err))
|
||||
}
|
||||
continue
|
||||
}
|
||||
kept, err := catalogclient.DecodeManifest(raw)
|
||||
kept, err := json.Marshal(entry.Manifest)
|
||||
if err != nil {
|
||||
problems = append(problems, fmt.Errorf("the manifest of %s: %w", module, err))
|
||||
continue
|
||||
@@ -197,7 +178,7 @@ func modulesTools(st States, on map[string][]string) ([]ModuleTools, error) {
|
||||
var man struct {
|
||||
Replaces map[string][]string `json:"replaces"`
|
||||
}
|
||||
if json.Unmarshal([]byte(kept.Manifest), &man) != nil || len(man.Replaces) == 0 {
|
||||
if json.Unmarshal(kept, &man) != nil || len(man.Replaces) == 0 {
|
||||
continue
|
||||
}
|
||||
out = append(out, ModuleTools{Module: module, On: on[module], Replaces: man.Replaces})
|
||||
@@ -263,14 +244,14 @@ func lookup(name string) []string {
|
||||
|
||||
// ReadMeshToolsFrom reads the bus now: the seats' verbs, the placed modules' own tools, and every node assignments names
|
||||
// with its domains and addresses. A part that cannot be read is left out and said in the error; what was read is kept.
|
||||
func ReadMeshToolsFrom(st States, defined []seatdef.Definition, resolve func(string) []string) (MeshTools, error) {
|
||||
func ReadMeshToolsFrom(st States, cat CatalogHalf, defined []seatdef.Definition, resolve func(string) []string) (MeshTools, error) {
|
||||
var m MeshTools
|
||||
placed, err := assignments(st)
|
||||
if err != nil {
|
||||
m.Replacements = ReplacementsOf(defined, nil)
|
||||
return m, err
|
||||
}
|
||||
modules, merr := modulesTools(st, runsOn(placed))
|
||||
modules, merr := modulesTools(cat, runsOn(placed))
|
||||
m.Replacements = ReplacementsOf(defined, modules)
|
||||
for node, p := range placed {
|
||||
machine := Machine{Name: node, Domains: []string{node + InternalSuffix}}
|
||||
@@ -357,7 +338,7 @@ func refreshMeshTools(p Paths) {
|
||||
}
|
||||
startSeatList(seatlist.StdioBus())
|
||||
for {
|
||||
m, err := ReadMeshToolsFrom(viewStates{meshViews}, definedSeats(), lookup)
|
||||
m, err := ReadMeshToolsFrom(viewStates{meshViews}, meshCatalog, definedSeats(), lookup)
|
||||
if err != nil {
|
||||
say("reading what each tool replaces and which machines the mesh has: %v", err)
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ package main
|
||||
// every node, `<node>.<server>` for one — which every node watches.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
@@ -30,6 +31,8 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/views"
|
||||
)
|
||||
|
||||
// Seat is the licence manager's role, and Manager the module whose events this one consumes.
|
||||
@@ -481,7 +484,25 @@ func fetched(p Paths, b *BindingState, ask Ask, write WriteManaged) (string, err
|
||||
|
||||
// BindingChanged is the manager's event of a binding written or deleted (design 39 bus-and-switch §21), which this
|
||||
// module consumes (§22).
|
||||
const BindingChanged = Manager + ".binding.changed"
|
||||
const BindingChanged = Manager + "." + BindingChangedEvent
|
||||
|
||||
// BindingChangedEvent is that event's name as the manager says it, with the consumer as its further part
|
||||
// (`binding.changed.<consumer>`, design 47 G A-134).
|
||||
const BindingChangedEvent = "binding.changed"
|
||||
|
||||
// bindingsFollower is this node's first view of its licence binding, adopted by the module's views reader (A-134): its
|
||||
// ask is FirstBinding, whose outcome goes to applied; the picture keeps no binding, which the node's own files hold.
|
||||
func bindingsFollower(p Paths, ask Ask, write WriteManaged, applied func(string, error)) *views.Follower {
|
||||
return &views.Follower{Owner: Manager, Tool: SeatVerb("bindings"), State: "bindings",
|
||||
Ask: func(context.Context, string) (views.Answer, error) {
|
||||
done, err := FirstBinding(p, ask, write)
|
||||
if err != nil {
|
||||
return views.Answer{}, err
|
||||
}
|
||||
applied(done, nil)
|
||||
return views.Answer{Owner: Manager, State: "bindings", Values: map[string]json.RawMessage{}, At: time.Now()}, nil
|
||||
}}
|
||||
}
|
||||
|
||||
// bindingOf is a consumer's binding as the manager says it, in binding.changed and in the verb bindings' answer: an
|
||||
// empty licence is a release (B22: decoded from a copy of the writer's form, testdata/binding-changed.json).
|
||||
|
||||
@@ -906,10 +906,6 @@ func Propose(p Paths, in ProposeInput, store ProposalStore, bus AskBus, now time
|
||||
if why := approverReady(bus); why != "" {
|
||||
return refuse(why + "; " + AtTheTerminalInstead)
|
||||
}
|
||||
if waiting := waitingProposals(store, approved, now); waiting >= occ.MostOpen {
|
||||
return refuse(fmt.Sprintf("%d proposals already wait for the operator's answer, and the operator is asked at most %d "+
|
||||
"questions at once; claude_code_proposals lists them", waiting, occ.MostOpen))
|
||||
}
|
||||
if problem := ProposalProblem(prop); problem != "" {
|
||||
return refuse(problem)
|
||||
}
|
||||
@@ -956,26 +952,6 @@ func Propose(p Paths, in ProposeInput, store ProposalStore, bus AskBus, now time
|
||||
}, nil
|
||||
}
|
||||
|
||||
// waitingProposals counts the proposals still open: asked, not decided here, not expired.
|
||||
func waitingProposals(store ProposalStore, approved Approved, now time.Time) int {
|
||||
keys, err := store.Keys()
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
n := 0
|
||||
for _, k := range keys {
|
||||
prop, ok := readProposal(store, k)
|
||||
if !ok || prop.Asked != "" {
|
||||
continue
|
||||
}
|
||||
if _, decided := approved.Decided[prop.ID]; decided || now.After(prop.At.Add(ProposalLasts)) {
|
||||
continue
|
||||
}
|
||||
n++
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func readProposal(store ProposalStore, key string) (Proposal, bool) {
|
||||
raw, found, err := store.Get(key)
|
||||
if err != nil || !found {
|
||||
|
||||
@@ -21,7 +21,6 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -38,26 +37,8 @@ const QuestionsState = "questions"
|
||||
// QuestionPrefix starts every question's key, so the router's word on one is never taken for a proposal's.
|
||||
const QuestionPrefix = "q-"
|
||||
|
||||
// MostQuestions is how many questions may wait at once, mesh-wide: the router allows an asker three open asks
|
||||
// (occ.MostOpen), and questions alone never fill them, so one is left for a proposed instruction section (ADR 0274)
|
||||
// while two questions are open. Each open question holds one slot key in the `questions` state (slotKey), taken by
|
||||
// create or by compare-and-set, so two nodes asking at once cannot both take the last one.
|
||||
const MostQuestions = occ.MostOpen - 1
|
||||
|
||||
// A question the router holds no record of, and whose refusal was not heard here, keeps its slot until it expires:
|
||||
// the router may still have it in its queue (down, or backlogged), and neither the queue's order nor a cancel can
|
||||
// take it back — the queue offers a piece of work again after an error, so a cancel may be taken before the ask it
|
||||
// cancels, and a cancel of an ask not yet recorded is dropped. Once the question has expired the router refuses it
|
||||
// when it is taken (oc.Ask.Check), so the slot is free and the question has ended.
|
||||
|
||||
// slotKey is the key of one of the MostQuestions slots: not a question's key, so no question is ever read from it.
|
||||
func slotKey(n int) string { return fmt.Sprintf("slot-%d", n) }
|
||||
|
||||
// slot is what a slot key holds: the question holding it.
|
||||
type slot struct {
|
||||
Question string `json:"question"`
|
||||
At time.Time `json:"at"`
|
||||
}
|
||||
// A question is never refused for how many wait: the router offers an asker at most three asks at once and holds the
|
||||
// oldest back behind newer ones, offered again when one ends (novox/hq ADR 0666 §12), so no question is hidden here.
|
||||
|
||||
// The bounds of a question's life (ADR 0332 §2): at least five minutes, at most a day less ten minutes (the
|
||||
// router's bound for an approval, with the margin a proposal keeps), an hour when none is given.
|
||||
@@ -372,19 +353,9 @@ func AskQuestion(p Paths, in QuestionInput, store QuestionStore, bus AskBus, now
|
||||
if err := occ.Check(ask, now); err != nil {
|
||||
return refuse(err.Error())
|
||||
}
|
||||
// The question is kept before its slot is taken, so a slot always names a question another node can judge.
|
||||
if err := store.Create(q.ID, q); err != nil {
|
||||
return nil, fmt.Errorf("the question could not be kept, so the operator was not asked: %w", err)
|
||||
}
|
||||
if open, err := takeSlot(store, bus, q, now); err != nil {
|
||||
_ = store.Put(q.ID, tombstone(q, "not asked: "+err.Error()))
|
||||
return refuse(err.Error())
|
||||
} else if open != nil {
|
||||
_ = store.Put(q.ID, tombstone(q, "not asked: two questions were open"))
|
||||
return refuse(fmt.Sprintf("%d questions already wait for the operator's answer (%s), and sessions hold at most %d "+
|
||||
"at once, so one of the router's three open asks is left for a proposed instruction section; read them with "+
|
||||
"claude_code_answer, or wait for one to end", len(open), strings.Join(open, ", "), MostQuestions))
|
||||
}
|
||||
if err := bus.Publish(oc.AcceptSubjectAsk(Module), ask, ask.ID); err != nil {
|
||||
_ = store.Put(q.ID, tombstone(q, "not asked: "+err.Error()))
|
||||
return nil, fmt.Errorf("the operator could not be asked: %v", err)
|
||||
@@ -420,75 +391,6 @@ func labelsOf(q Question) string {
|
||||
return strings.Join(out, ", ")
|
||||
}
|
||||
|
||||
// questionOpen says whether a question still waits: kept, asked, not ended, not expired, and open in the router's
|
||||
// record or not recorded yet. An unreadable record is open: a slot is never freed on a guess.
|
||||
func questionOpen(store QuestionStore, bus AskBus, id string, now time.Time) (bool, error) {
|
||||
q, ok, err := readQuestionErr(store, id)
|
||||
if err != nil {
|
||||
return true, err
|
||||
}
|
||||
if !ok || q.Asked != "" || q.Ended != "" || !now.Before(q.Expires) {
|
||||
return false, nil
|
||||
}
|
||||
entry, err := bus.Record(q.ID)
|
||||
if err != nil {
|
||||
return true, err
|
||||
}
|
||||
if entry == nil {
|
||||
return true, nil
|
||||
}
|
||||
var rec routerRecord
|
||||
if json.Unmarshal(entry.Value, &rec) != nil {
|
||||
return true, nil
|
||||
}
|
||||
return rec.State == "" || rec.State == "open", nil
|
||||
}
|
||||
|
||||
// takeSlot takes one of the MostQuestions slots for q: a slot no key holds, by create, or one whose question no longer
|
||||
// waits, by compare-and-set at the revision read. Of two nodes taking the same slot only one wins; the other tries
|
||||
// the next. It answers the open questions when every slot is held, and an error — refusing, never guessing — when a
|
||||
// slot or its question cannot be read.
|
||||
func takeSlot(store QuestionStore, bus AskBus, q Question, now time.Time) ([]string, error) {
|
||||
mine := slot{Question: q.ID, At: q.At}
|
||||
var open []string
|
||||
for n := 1; n <= MostQuestions; n++ {
|
||||
key := slotKey(n)
|
||||
raw, rev, found, err := store.Get(key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the open questions could not be counted (%v), so none more is asked", err)
|
||||
}
|
||||
if !found {
|
||||
if err := store.Create(key, mine); err == nil {
|
||||
return nil, nil
|
||||
} else if !errors.Is(err, stdio.ErrChanged) {
|
||||
return nil, fmt.Errorf("the open questions could not be counted (%v), so none more is asked", err)
|
||||
}
|
||||
if raw, rev, found, err = store.Get(key); err != nil || !found {
|
||||
return nil, fmt.Errorf("the open questions could not be counted, so none more is asked")
|
||||
}
|
||||
}
|
||||
var held slot
|
||||
if json.Unmarshal(raw, &held) == nil && held.Question != "" {
|
||||
waits, err := questionOpen(store, bus, held.Question, now)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the open questions could not be counted (%v), so none more is asked", err)
|
||||
}
|
||||
if waits {
|
||||
open = append(open, held.Question)
|
||||
continue
|
||||
}
|
||||
}
|
||||
if err := store.Update(key, mine, rev); err == nil {
|
||||
return nil, nil
|
||||
} else if !errors.Is(err, stdio.ErrChanged) {
|
||||
return nil, fmt.Errorf("the open questions could not be counted (%v), so none more is asked", err)
|
||||
}
|
||||
open = append(open, "one taken just now")
|
||||
}
|
||||
sort.Strings(open)
|
||||
return open, nil
|
||||
}
|
||||
|
||||
// tombstone is what is kept of a question that was not asked or that the router refused: its key, who asked, when, and
|
||||
// why it ended — none of its words, which the router judged unfit to leave the mesh or never saw.
|
||||
func tombstone(q Question, ended string) Question {
|
||||
@@ -608,7 +510,7 @@ func ReadAnswer(store QuestionStore, bus AskBus, key string, now time.Time) (map
|
||||
case entry == nil:
|
||||
out["state"] = AnswerWaiting
|
||||
out["words"] = "not yet recorded by the router: it may still be in the router's queue, or was refused without the " +
|
||||
"refusal being heard here. It holds its slot, and the operator may still be asked, until it expires at " +
|
||||
"refusal being heard here. The operator may still be asked until it expires at " +
|
||||
q.Expires.Format(time.RFC3339)
|
||||
case entry == nil, rec.State == "open", rec.Warrant == nil:
|
||||
out["state"] = AnswerWaiting
|
||||
|
||||
@@ -6,7 +6,6 @@ package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
@@ -182,22 +181,21 @@ func TestAQuestionIsRefusedOutsideItsBounds(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAtMostTwoQuestionsWaitSoAProposalFindsRoom(t *testing.T) {
|
||||
// ADR 0666 §12: a question is never refused for how many wait; the router holds the oldest back, offered again when one ends.
|
||||
func TestAThirdQuestionIsAskedNotRefusedAndAProposalIsAskedBesideThem(t *testing.T) {
|
||||
p, _ := node(t, "g14")
|
||||
store, r := newMemQuestions(), newRouter()
|
||||
first := askMergeTonight(t, p, store, r, t0)
|
||||
askMergeTonight(t, p, store, r, t0)
|
||||
if _, err := AskQuestion(p, mergeTonight(), store, r, t0); err == nil || !strings.Contains(err.Error(), "2 questions already wait") {
|
||||
t.Fatalf("a third question: %v", err)
|
||||
for i := 0; i < 4; i++ {
|
||||
if _, err := AskQuestion(p, mergeTonight(), store, r, t0); err != nil {
|
||||
t.Fatalf("question %d: %v", i+1, err)
|
||||
}
|
||||
}
|
||||
r.close(r.chose(first, "o1", t0.Add(time.Minute)))
|
||||
if _, err := AskQuestion(p, mergeTonight(), store, r, t0.Add(2*time.Minute)); err != nil {
|
||||
t.Fatalf("a question once one was answered: %v", err)
|
||||
if len(r.subjects) != 4 {
|
||||
t.Errorf("the router was asked %d times, want 4", len(r.subjects))
|
||||
}
|
||||
// A proposal is still asked beside two waiting questions.
|
||||
pstore := &memProposals{m: map[string]json.RawMessage{}}
|
||||
if _, err := Propose(p, ProposeInput{Scope: ScopeMesh, Name: "x", Text: "Text.\n"}, pstore, r, t0); err != nil {
|
||||
t.Errorf("a proposal beside two questions: %v", err)
|
||||
t.Errorf("a proposal beside the questions: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -234,9 +232,6 @@ func TestTheRoutersRefusalIsSaidAndKept(t *testing.T) {
|
||||
if err != nil || out["state"] != AnswerEnded || !strings.Contains(out["words"].(string), "refused") {
|
||||
t.Errorf("read after the refusal: %v %v", out, err)
|
||||
}
|
||||
if waits, err := questionOpen(store, refusing, refusing.id, t0.Add(time.Minute)); waits || err != nil {
|
||||
t.Errorf("a refused question still counts as waiting: %v %v", waits, err)
|
||||
}
|
||||
var kept Question
|
||||
_ = json.Unmarshal(store.m[refusing.id], &kept)
|
||||
if kept.Headline != "" || kept.Context != "" || len(kept.Options) != 0 || kept.Why != "" || kept.OnExpiry != "" {
|
||||
@@ -316,10 +311,6 @@ type memQuestions struct {
|
||||
m map[string]json.RawMessage
|
||||
rev map[string]uint64
|
||||
n uint64
|
||||
// failGet fails every read of a key with this prefix.
|
||||
failGet string
|
||||
// afterGet runs after a read, outside the lock: another node writing between a read and a write.
|
||||
afterGet func(key string)
|
||||
}
|
||||
|
||||
func newMemQuestions() *memQuestions {
|
||||
@@ -328,17 +319,9 @@ func newMemQuestions() *memQuestions {
|
||||
|
||||
func (s *memQuestions) Get(key string) (json.RawMessage, uint64, bool, error) {
|
||||
s.mu.Lock()
|
||||
if s.failGet != "" && strings.HasPrefix(key, s.failGet) {
|
||||
s.mu.Unlock()
|
||||
return nil, 0, false, errors.New("the bus did not answer")
|
||||
}
|
||||
defer s.mu.Unlock()
|
||||
raw, ok := s.m[key]
|
||||
rev, after := s.rev[key], s.afterGet
|
||||
s.mu.Unlock()
|
||||
if after != nil {
|
||||
after(key)
|
||||
}
|
||||
return raw, rev, ok, nil
|
||||
return raw, s.rev[key], ok, nil
|
||||
}
|
||||
func (s *memQuestions) put(key string, value any) error {
|
||||
raw, err := json.Marshal(value)
|
||||
@@ -397,121 +380,3 @@ func TestAWarrantForAnotherAskOrOptionIsNotProven(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Two nodes asking at the same moment take two slots at most: of three sessions racing, one is refused.
|
||||
func TestTheCapHoldsAcrossNodesAskingAtOnce(t *testing.T) {
|
||||
store, r := newMemQuestions(), &lockedRouter{router: newRouter()}
|
||||
nodes := []string{"g14", "shanks", "ace"}
|
||||
var wg sync.WaitGroup
|
||||
errs := make([]error, len(nodes))
|
||||
for i, name := range nodes {
|
||||
p, _ := node(t, name)
|
||||
wg.Add(1)
|
||||
go func(i int, p Paths) {
|
||||
defer wg.Done()
|
||||
in := mergeTonight()
|
||||
in.By = "fix-589"
|
||||
_, errs[i] = AskQuestion(p, in, store, r, t0)
|
||||
}(i, p)
|
||||
}
|
||||
wg.Wait()
|
||||
refused := 0
|
||||
for _, err := range errs {
|
||||
if err != nil {
|
||||
if !strings.Contains(err.Error(), "questions already wait") {
|
||||
t.Errorf("refused for another reason: %v", err)
|
||||
}
|
||||
refused++
|
||||
}
|
||||
}
|
||||
if refused != 1 {
|
||||
t.Errorf("%d of three questions asked at once were refused; one is", refused)
|
||||
}
|
||||
}
|
||||
|
||||
// A slot that cannot be read refuses the question: the cap fails closed.
|
||||
func TestTheCapFailsClosed(t *testing.T) {
|
||||
p, _ := node(t, "g14")
|
||||
store, r := newMemQuestions(), newRouter()
|
||||
store.failGet = "slot-"
|
||||
if _, err := AskQuestion(p, mergeTonight(), store, r, t0); err == nil || !strings.Contains(err.Error(), "could not be counted") {
|
||||
t.Fatalf("asked with the slots unreadable: %v", err)
|
||||
}
|
||||
if len(r.subjects) != 0 {
|
||||
t.Error("the operator was asked anyway")
|
||||
}
|
||||
}
|
||||
|
||||
// A question whose refusal was not heard here, and of which the router holds no record, keeps its slot and reads as
|
||||
// waiting until it expires — the router may still take it from its queue — and only then frees its slot and ends.
|
||||
func TestAQuestionTheRouterHasNotRecordedHoldsItsSlotUntilItExpires(t *testing.T) {
|
||||
p, _ := node(t, "g14")
|
||||
store, r := newMemQuestions(), newRouter()
|
||||
first := askMergeTonight(t, p, store, r, t0)
|
||||
askMergeTonight(t, p, store, r, t0)
|
||||
delete(r.records, first) // the router down or backlogged, or its refusal never heard here
|
||||
later := t0.Add(QuestionDefault - time.Minute)
|
||||
if _, err := AskQuestion(p, mergeTonight(), store, r, later); err == nil {
|
||||
t.Fatal("the slot of a question the router may still show was freed before it expired")
|
||||
}
|
||||
out, _ := ReadAnswer(store, r, first, later)
|
||||
if out["state"] != AnswerWaiting || !strings.Contains(out["words"].(string), "not yet recorded") {
|
||||
t.Errorf("before expiry read as %v", out)
|
||||
}
|
||||
expired := t0.Add(QuestionDefault)
|
||||
if out, _ := ReadAnswer(store, r, first, expired); out["state"] != AnswerEnded ||
|
||||
out["now"] != "do what you said: I leave the pull request open" {
|
||||
t.Errorf("at expiry read as %v", out)
|
||||
}
|
||||
if _, err := AskQuestion(p, mergeTonight(), store, r, expired); err != nil {
|
||||
t.Errorf("the slot of an expired question was not freed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A slot is taken only at the revision read: when another node takes both slots between this node's read and its
|
||||
// write, this node's writes are refused and the question is refused — a plain write would take a slot it lost.
|
||||
func TestASlotIsTakenOnlyAtTheRevisionRead(t *testing.T) {
|
||||
p, _ := node(t, "g14")
|
||||
store, r := newMemQuestions(), newRouter()
|
||||
// Two questions that ended: their slots are free to take again.
|
||||
for _, id := range []string{askMergeTonight(t, p, store, r, t0), askMergeTonight(t, p, store, r, t0)} {
|
||||
r.close(oc.Warrant{Ask: id, Outcome: occ.OutcomeExpired, At: occ.FormatTime(t0.Add(time.Minute))})
|
||||
}
|
||||
shanks, _ := node(t, "shanks")
|
||||
other := newRouter()
|
||||
store.afterGet = func(key string) {
|
||||
if !strings.HasPrefix(key, "slot-") {
|
||||
return
|
||||
}
|
||||
// Another node asks between this node's read of the slot and its write: its question waits.
|
||||
q := Question{ID: newQuestionID(), Headline: "Another", Context: "c", Why: "w", OnExpiry: "e", By: "other",
|
||||
From: shanks.Node, At: t0, Expires: t0.Add(time.Hour),
|
||||
Options: []QuestionOption{{ID: "o1", Label: "A", Means: "a", Recommended: true}, {ID: "o2", Label: "B", Means: "b"}}}
|
||||
_ = store.Put(q.ID, q)
|
||||
_ = other.Publish(oc.AcceptSubjectAsk(Module), q.Ask(), q.ID)
|
||||
r.records[q.ID] = other.records[q.ID]
|
||||
_ = store.Put(key, slot{Question: q.ID, At: t0})
|
||||
}
|
||||
_, err := AskQuestion(p, mergeTonight(), store, r, t0.Add(2*time.Minute))
|
||||
store.afterGet = nil
|
||||
if err == nil || !strings.Contains(err.Error(), "questions already wait") {
|
||||
t.Fatalf("a slot lost to another node was taken: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// lockedRouter is the router fake, safe for sessions asking at once.
|
||||
type lockedRouter struct {
|
||||
mu sync.Mutex
|
||||
*router
|
||||
}
|
||||
|
||||
func (l *lockedRouter) Publish(subject string, body any, id string) error {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
return l.router.Publish(subject, body, id)
|
||||
}
|
||||
func (l *lockedRouter) Record(id string) (*stdio.StateEntry, error) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
return l.router.Record(id)
|
||||
}
|
||||
|
||||
@@ -2,11 +2,12 @@ package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
occ "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient"
|
||||
oc "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
occ "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient"
|
||||
oc "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel"
|
||||
)
|
||||
|
||||
// A section registered for the mesh before the controller's terminal took that over, or set at the terminal, is
|
||||
|
||||
@@ -60,13 +60,12 @@ func waitBlocksStop(session string, w Wait, now time.Time) (bool, string) {
|
||||
"{\"op\":\"said\",\"session\":%q,\"by\":%q,\"id\":%q}.", w.ID, w.What, session, w.By, w.ID)
|
||||
}
|
||||
|
||||
// watcherAdvice is what a session is told to do to follow a wait (ADR 0489 §3, review round 1 item 3): a
|
||||
// family is a candidate for mesh_wait only when the tool runner is granted it (ADR 0367 §2), which this hook
|
||||
// cannot know, so it is offered conditionally; a watcher that rereads the read address always works.
|
||||
// watcherAdvice is what a session is told to do to follow a wait (ADR 0489 §3, design 47 E §20): a wait with an event is
|
||||
// followed by mesh_wait on it, rereading what it reads after every answer, or by a watcher that does; with no event, by a
|
||||
// watcher that rereads the read address. The advice puts no condition on a grant.
|
||||
func watcherAdvice(w Wait) string {
|
||||
if w.Family != "" {
|
||||
return fmt.Sprintf("mesh_wait on %s if this session's tool runner is granted it, otherwise start a watcher "+
|
||||
"that rereads %s", w.Family, w.Read.Address)
|
||||
return fmt.Sprintf("mesh_wait on %s and reread %s after every answer, or start a watcher that does", w.Family, w.Read.Address)
|
||||
}
|
||||
return fmt.Sprintf("start a watcher that rereads %s", w.Read.Address)
|
||||
}
|
||||
|
||||
@@ -300,15 +300,15 @@ func TestStopAllowsWhenTheRefusalCounterCannotBeWritten(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Review round 1, item 3 (1743/AC1, §3 with ADR 0367 §2): a family is offered only conditionally (the tool
|
||||
// runner may not be granted it); without one, the advice is only to start a watcher.
|
||||
func TestWaitBlocksStopAdvisesMeshWaitOnlyConditionallyOtherwiseAWatcher(t *testing.T) {
|
||||
// Design 47 E §20: a wait with an event is followed by mesh_wait on it and a reread after every answer, or a watcher
|
||||
// that does, with no condition on a grant; without an event, the advice is only to start a watcher.
|
||||
func TestWaitBlocksStopAdvisesMeshWaitOnItsEventOtherwiseAWatcher(t *testing.T) {
|
||||
withFamily := openWait("w-f", 10)
|
||||
withFamily.Read = WaitRead{Address: "mesh-delivery.recheck"}
|
||||
withFamily.Family = "mesh-delivery.decided"
|
||||
_, reason := waitBlocksStop("sess-f", withFamily, stopT0.Add(time.Minute))
|
||||
if !strings.Contains(reason, "mesh_wait on mesh-delivery.decided if this session's tool runner is granted it") ||
|
||||
!strings.Contains(reason, "otherwise start a watcher that rereads mesh-delivery.recheck") {
|
||||
if !strings.Contains(reason, "mesh_wait on mesh-delivery.decided and reread mesh-delivery.recheck after every answer, or start a watcher that does") ||
|
||||
strings.Contains(reason, "granted") {
|
||||
t.Fatalf("%s", reason)
|
||||
}
|
||||
|
||||
|
||||
@@ -7,12 +7,11 @@ require git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000
|
||||
require (
|
||||
git.novox.be/novox/mesh-contracts/go/clients/meshmoduleassignerclient v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshhandactsclient v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/meshcatalogclient v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog v0.0.0-00010101000000-000000000000
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
@@ -26,6 +25,10 @@ require (
|
||||
)
|
||||
|
||||
require (
|
||||
git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions v0.0.0-00010101000000-000000000000 // indirect
|
||||
git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshhandacts v0.0.0-00010101000000-000000000000 // indirect
|
||||
)
|
||||
|
||||
require git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshdelivery v0.0.0-00010101000000-000000000000
|
||||
|
||||
require git.novox.be/novox/mesh-delivery/modules/mesh-delivery-sdk v0.0.0-00010101000000-000000000000 // indirect
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"module": "claude-code",
|
||||
"version": "1",
|
||||
"version": "0.1",
|
||||
"moved": true,
|
||||
"slug": "agent",
|
||||
"capabilities": [
|
||||
@@ -11,6 +11,25 @@
|
||||
{
|
||||
"name": "gitea-read",
|
||||
"optional": true
|
||||
},
|
||||
{
|
||||
"name": "finding-events",
|
||||
"storage": "stream",
|
||||
"template": "events",
|
||||
"options": {
|
||||
"events": [
|
||||
"finding-written.*",
|
||||
"finding-cleared.*",
|
||||
"findings-stated.*"
|
||||
],
|
||||
"max-age-days": 7,
|
||||
"max-mib": 16
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "findings",
|
||||
"storage": "key-value",
|
||||
"template": "state"
|
||||
}
|
||||
],
|
||||
"binds": {
|
||||
@@ -26,19 +45,23 @@
|
||||
"seat:mesh-hand-acts.warranted",
|
||||
"seat:mesh-hand-acts.hand-act",
|
||||
"seat:mesh-catalog.seat-definitions",
|
||||
"mesh-delivery-indexer.indexer_read_modules",
|
||||
"mesh-delivery-indexer.indexer_read_manifests",
|
||||
"seat:mesh-catalog.revision",
|
||||
"seat:mesh-catalog.registrations",
|
||||
"seat:mesh-delivery.source",
|
||||
"mesh-delivery-assigner.assigner_read_assignments",
|
||||
"seat:anthropic-licence-manager.bindings",
|
||||
"seat:anthropic-licence-manager.current",
|
||||
"seat:mesh-vault.get"
|
||||
"seat:mesh-vault.get",
|
||||
"seat:operator-channel.notify"
|
||||
],
|
||||
"consumes": [
|
||||
"mesh-delivery-indexer.views.modules",
|
||||
"mesh-delivery-indexer.views.manifests",
|
||||
"mesh-catalog.catalog-revision",
|
||||
"mesh-catalog.registration-changed",
|
||||
"mesh-delivery.source-moved",
|
||||
"mesh-delivery-assigner.views.assignments",
|
||||
"mesh-catalog.seat-definition-changed",
|
||||
"mesh-catalog.seat-definition-removed",
|
||||
"mesh-conditions.findings-asked",
|
||||
"claude-licence-manager.holdings.asked",
|
||||
"claude-licence-manager.binding.changed",
|
||||
"mesh-vault.secret-changed"
|
||||
@@ -50,8 +73,7 @@
|
||||
"proposals",
|
||||
"questions",
|
||||
"memory",
|
||||
"waits",
|
||||
"findings"
|
||||
"waits"
|
||||
],
|
||||
"emits": [
|
||||
"judged",
|
||||
@@ -237,7 +259,16 @@
|
||||
"path": "${dir:state}/settings.json",
|
||||
"mode": "0600",
|
||||
"owner": "${machine:account}",
|
||||
"takes-config": ["role", "mcp_servers", "managed_settings", "instructions", "output_styles", "skills", "commands", "agents"],
|
||||
"takes-config": [
|
||||
"role",
|
||||
"mcp_servers",
|
||||
"managed_settings",
|
||||
"instructions",
|
||||
"output_styles",
|
||||
"skills",
|
||||
"commands",
|
||||
"agents"
|
||||
],
|
||||
"merge": "json",
|
||||
"template": "files/settings.json"
|
||||
}
|
||||
|
||||
@@ -96,6 +96,10 @@ func (m *Manager) record(ctx context.Context, what, line string, detail map[stri
|
||||
return nil
|
||||
}
|
||||
|
||||
// BindingChanged is the event of a consumer's binding written or deleted, said as `binding.changed.<consumer>`: the consumer is
|
||||
// its further part, the one form of G's seat events (novox/hq design 47 G A-134), so a reader adopts it.
|
||||
const BindingChanged = "binding.changed"
|
||||
|
||||
// changed says that one consumer's key of `bindings` was written or deleted (§21, B7): its licence (empty on a
|
||||
// release) and that licence's key, kind, generation, the licence before, why, by and when. Never a token.
|
||||
func (m *Manager) changed(b Binding, from string) {
|
||||
@@ -103,7 +107,7 @@ func (m *Manager) changed(b Binding, from string) {
|
||||
if b.Licence != "" {
|
||||
key = KeyFor(b.Licence)
|
||||
}
|
||||
_ = m.Emit("binding.changed", map[string]any{"consumer": b.Consumer, "licence": b.Licence, "key": key,
|
||||
_ = m.Emit(BindingChanged+"."+b.Consumer, map[string]any{"consumer": b.Consumer, "licence": b.Licence, "key": key,
|
||||
"kind": b.Kind, "generation": b.Generation, "from": from, "why": b.Why, "by": b.By,
|
||||
"at": m.Now().UTC().Format(time.RFC3339Nano)})
|
||||
}
|
||||
|
||||
@@ -573,9 +573,14 @@ func TestWhatIsNotAnAPIKeyIsRefused(t *testing.T) {
|
||||
func (mm *miniMesh) changedSince(mark int) []map[string]any {
|
||||
var out []map[string]any
|
||||
for _, e := range mm.events[mark:] {
|
||||
if body, ok := strings.CutPrefix(e, "binding.changed "); ok {
|
||||
if rest, ok := strings.CutPrefix(e, BindingChanged+"."); ok {
|
||||
// The consumer is the event's further part (A-134); one said under another consumer's name reads as none.
|
||||
consumer, body, _ := strings.Cut(rest, " ")
|
||||
var v map[string]any
|
||||
_ = json.Unmarshal([]byte(body), &v)
|
||||
if v["consumer"] != consumer {
|
||||
v["consumer"] = "said as binding.changed." + consumer
|
||||
}
|
||||
out = append(out, v)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,6 +147,22 @@ func logsSection(logs []json.RawMessage) map[string]map[string]any {
|
||||
|
||||
// mayAsk is whether the manager's own account may ask key: a module tool asked on one node (`<module>.<tool>@<node>`)
|
||||
// is invoked as `<module>.<tool>`.
|
||||
// emits is whether the manifest names the event key's family: the key itself, or a family it extends by further parts,
|
||||
// as binding.changed.<consumer> extends binding.changed (design 47 G A-134).
|
||||
func (i issued) emits(key string) bool {
|
||||
for k := key; k != ""; {
|
||||
if i.Emits[k] {
|
||||
return true
|
||||
}
|
||||
dot := strings.LastIndex(k, ".")
|
||||
if dot < 0 {
|
||||
break
|
||||
}
|
||||
k = k[:dot]
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (i issued) mayAsk(key string) bool {
|
||||
k, _, _ := strings.Cut(key, "@")
|
||||
return i.Invokes[k]
|
||||
|
||||
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -105,7 +106,7 @@ func TestOneSwitchedPerAct(t *testing.T) {
|
||||
switch name {
|
||||
case "licence.switched":
|
||||
switched = append(switched, f)
|
||||
case "binding.changed":
|
||||
case "binding.changed." + fmt.Sprint(f["consumer"]):
|
||||
changed++
|
||||
}
|
||||
}
|
||||
|
||||
@@ -154,6 +154,9 @@ func TestResyncSaysEachOnce(t *testing.T) {
|
||||
count := map[string]int{}
|
||||
for _, e := range mm.events {
|
||||
name, _, _ := strings.Cut(e, " ")
|
||||
if strings.HasPrefix(name, BindingChanged+".") {
|
||||
name = BindingChanged
|
||||
}
|
||||
count[name]++
|
||||
}
|
||||
puts, _ := got["licence-put"].([]any)
|
||||
|
||||
@@ -291,7 +291,7 @@ func (m *mesh) until(what string, ok func() bool) {
|
||||
func (m *mesh) count(key string) int {
|
||||
n := 0
|
||||
for _, s := range m.said {
|
||||
if s.key == key {
|
||||
if s.key == key || strings.HasPrefix(s.key, key+".") {
|
||||
n++
|
||||
}
|
||||
}
|
||||
@@ -302,7 +302,7 @@ func (m *mesh) count(key string) int {
|
||||
func (m *mesh) of(key string) []map[string]any {
|
||||
var out []map[string]any
|
||||
for _, s := range m.said {
|
||||
if s.key == key {
|
||||
if s.key == key || strings.HasPrefix(s.key, key+".") {
|
||||
var b map[string]any
|
||||
_ = json.Unmarshal(s.body, &b)
|
||||
out = append(out, b)
|
||||
@@ -543,7 +543,7 @@ func (p *started) refused(method string, params json.RawMessage) error {
|
||||
return fmt.Errorf("%s keeps no log called %q", Publisher, a.Log)
|
||||
case method == "mesh/ask" && !p.own.mayAsk(a.Key):
|
||||
return fmt.Errorf("this account may not call %s", a.Key)
|
||||
case method == "mesh/publish" && !p.own.Emits[a.Key]:
|
||||
case method == "mesh/publish" && !p.own.emits(a.Key):
|
||||
return fmt.Errorf("%s does not emit %s", Publisher, a.Key)
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"module": "claude-licence-manager",
|
||||
"version": "1",
|
||||
"version": "0.1",
|
||||
"slug": "licmgr",
|
||||
"runs-as": "claude-licence-manager",
|
||||
"requires": [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"module": "clipmenu",
|
||||
"version": "1",
|
||||
"version": "0.1",
|
||||
"moved": true,
|
||||
"capabilities": [
|
||||
"package-manager"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"module": "confluence",
|
||||
"version": "1",
|
||||
"version": "0.1",
|
||||
"slug": "confl",
|
||||
"own-secrets": {
|
||||
"token": "${dir:state}/token"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"module": "cups",
|
||||
"version": "1",
|
||||
"version": "0.1",
|
||||
"capabilities": [
|
||||
"package-manager",
|
||||
"service-manager"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"module": "dbus",
|
||||
"version": "1",
|
||||
"version": "0.1",
|
||||
"capabilities": [
|
||||
"package-manager",
|
||||
"service-manager"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"module": "desk-channel",
|
||||
"version": "1",
|
||||
"version": "0.1",
|
||||
"slug": "desk",
|
||||
"runs-as": "desk-channel",
|
||||
"claims": [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"module": "dev-tools",
|
||||
"version": "1",
|
||||
"version": "0.1",
|
||||
"capabilities": [
|
||||
"package-manager"
|
||||
],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"module": "disk-load",
|
||||
"version": "1",
|
||||
"version": "0.1",
|
||||
"tools": [
|
||||
"disk_load",
|
||||
"disk_top",
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
reg "git.novox.be/novox/mesh-delivery/contracts/go/seats/containerimageregistry"
|
||||
reg "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/containerimageregistry"
|
||||
|
||||
"distribution/internal/holder"
|
||||
)
|
||||
|
||||
@@ -3,12 +3,11 @@ package main
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
reg "git.novox.be/novox/mesh-delivery/contracts/go/seats/containerimageregistry"
|
||||
reg "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/containerimageregistry"
|
||||
|
||||
"distribution/internal/holder"
|
||||
)
|
||||
@@ -31,7 +30,7 @@ func TestTheVerbsSpeakTheSeatsContract(t *testing.T) {
|
||||
reg.VerbVersions: reg.VersionsInput{}, reg.VerbRemove: reg.RemoveInput{}, reg.VerbHold: reg.HoldInput{},
|
||||
reg.VerbRelease: reg.ReleaseInput{}, reg.VerbMirror: reg.MirrorInput{}, reg.VerbTags: reg.TagsInput{}}
|
||||
seen := 0
|
||||
for _, tool := range tools(&holder.Verbs{}, kept[holder.Window]{}, func() (holder.Callers, error) { return holder.DefaultCallers(), nil }) {
|
||||
for _, tool := range tools(&holder.Verbs{}, kept[holder.Window]{}) {
|
||||
seat, verb, ok := strings.Cut(tool.Name, ".")
|
||||
in, isVerb := inputs[verb]
|
||||
if !ok || !isVerb || (seat != holder.ImageSeat && seat != holder.ArtifactSeat) {
|
||||
@@ -57,28 +56,6 @@ func TestTheVerbsSpeakTheSeatsContract(t *testing.T) {
|
||||
t.Errorf("%s's body %s is not the contract's: %v", event, b, err)
|
||||
}
|
||||
}
|
||||
// The caller list is read held: one never read answers no verb, and registry_callers says it as read.
|
||||
unread := func() (holder.Callers, error) { return nil, errors.New("config refused") }
|
||||
for _, tool := range tools(&holder.Verbs{}, kept[holder.Window]{}, unread) {
|
||||
if tool.Name == holder.ImageSeat+"."+holder.VerbVersions {
|
||||
if _, err := tool.Run(map[string]any{"name": "m/a"}); err == nil || !strings.Contains(err.Error(), "caller list") {
|
||||
t.Errorf("versions with an unread caller list = %v, want refused naming the caller list", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
answered := false
|
||||
for _, tool := range tools(&holder.Verbs{}, kept[holder.Window]{}, func() (holder.Callers, error) { return holder.Callers{holder.VerbPublish: {"mesh-build-go"}}, nil }) {
|
||||
if tool.Name == holder.CallersTool {
|
||||
answered = true
|
||||
got, err := tool.Run(map[string]any{})
|
||||
if b, _ := json.Marshal(got); err != nil || string(b) != `{"callers":{"publish":["mesh-build-go"]}}` {
|
||||
t.Errorf("%s = %s, %v", holder.CallersTool, b, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !answered {
|
||||
t.Errorf("no tool %s: the program could not read the caller list", holder.CallersTool)
|
||||
}
|
||||
if a := publishAnswer(holder.PublishAnswer{Transfer: "t", Missing: []string{"sha256:bb"}}); !reflect.DeepEqual(a.Lacks, []string{"sha256:bb"}) || a.Held {
|
||||
t.Errorf("publish answers %+v", a)
|
||||
}
|
||||
|
||||
@@ -12,19 +12,19 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
builds "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient"
|
||||
"git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshhandactsclient"
|
||||
meshconditions "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions"
|
||||
reg "git.novox.be/novox/mesh-delivery/contracts/go/seats/containerimageregistry"
|
||||
"git.novox.be/novox/mesh-delivery/sdk/parts"
|
||||
reg "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/containerimageregistry"
|
||||
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-sdk/parts"
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"git.novox.be/novox/mesh-sdk/go/seatlist"
|
||||
"git.novox.be/novox/mesh-sdk/go/views"
|
||||
|
||||
"distribution/internal/holder"
|
||||
)
|
||||
@@ -68,21 +68,6 @@ func say(seat, event string, body holder.Event) error {
|
||||
return stdio.SeatPublish("mesh.seat."+seat+".event."+event, eventBody(event, body), body.ID)
|
||||
}
|
||||
|
||||
func finding(kind, subject, words string, raised bool) {
|
||||
key := meshconditionsclient.Key(meshconditionsclient.ScopeSeat, "distribution", kind+"."+strings.NewReplacer(".", "_", "@", "_", "/", "_").Replace(subject))
|
||||
if !raised {
|
||||
_ = stdio.State(meshconditions.StateFindings).Delete(key)
|
||||
return
|
||||
}
|
||||
f := meshconditions.FindingsByKeyValue{V: meshconditionsclient.FindingVersion, Key: key, Kind: kind, Ref: key[strings.LastIndex(key, ".")+1:],
|
||||
Subject: meshconditions.Subject{Scope: meshconditionsclient.ScopeSeat, ID: "distribution"}, Severity: meshconditionsclient.Warning,
|
||||
Summary: words, Headline: words, Explanation: words, Resolved: "cleared once it no longer holds",
|
||||
Source: kind, Observer: "distribution", At: time.Now().UTC()}
|
||||
if _, err := stdio.State(meshconditions.StateFindings).Put(key, f); err != nil {
|
||||
logf("the finding %s could not be written: %v", key, err)
|
||||
}
|
||||
}
|
||||
|
||||
// learned are the proved callers whose build this process recorded as publishing through the seats.
|
||||
var learned sync.Map
|
||||
|
||||
@@ -248,16 +233,8 @@ func arg[T any](args map[string]any) (T, error) {
|
||||
|
||||
func str(args map[string]any, k string) string { s, _ := args[k].(string); return strings.TrimSpace(s) }
|
||||
|
||||
// tools are distribution's tools over v; each call that checks a caller reads the caller list held (callers:
|
||||
// the config value as mesh-config resolves it now), so an operator's change of it holds from the next call on.
|
||||
func tools(v *holder.Verbs, windows kept[holder.Window], callers func() (holder.Callers, error)) []stdio.Tool {
|
||||
now := func() (*holder.Verbs, error) {
|
||||
c, err := callers()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the caller list cannot be read, so no verb is answered: %w", err)
|
||||
}
|
||||
return v.WithCallers(c), nil
|
||||
}
|
||||
// tools are distribution's tools over v.
|
||||
func tools(v *holder.Verbs, windows kept[holder.Window]) []stdio.Tool {
|
||||
obj := func(props map[string]any, req ...string) map[string]any {
|
||||
return map[string]any{"type": "object", "required": req, "properties": props}
|
||||
}
|
||||
@@ -268,10 +245,6 @@ func tools(v *holder.Verbs, windows kept[holder.Window], callers func() (holder.
|
||||
stdio.Tool{Name: seat + "." + holder.VerbPublish, Description: "Open a transfer of a publish (ADR 0611): its name, version, kind, digest and parts; answers the parts the holder lacks.",
|
||||
Input: obj(map[string]any{"transfer": s("the transfer's id"), "name": s("<module>/<artifact>"), "version": s("the builder's version"), "kind": s("image, bundle, archive or upstream"), "digest": s("sha256:…"), "parts": map[string]any{"type": "array"}}, "transfer", "name", "kind", "digest", "parts"),
|
||||
Run: func(args map[string]any) (any, error) {
|
||||
v, err := now()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
in, err := arg[reg.PublishInput](args)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -285,10 +258,6 @@ func tools(v *holder.Verbs, windows kept[holder.Window], callers func() (holder.
|
||||
stdio.Tool{Name: seat + "." + holder.VerbPublishPart, Description: "Send one chunk (at most 1 MiB) of one part of an open transfer, from the caller that opened it.",
|
||||
Input: obj(map[string]any{"transfer": s("its id"), "part": s("the part's digest"), "sequence": map[string]any{"type": "integer"}, "last": map[string]any{"type": "boolean"}, "data": s("base64")}, "transfer", "part", "sequence", "last", "data"),
|
||||
Run: func(args map[string]any) (any, error) {
|
||||
v, err := now()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
in, err := arg[reg.PublishPartInput](args)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -302,10 +271,6 @@ func tools(v *holder.Verbs, windows kept[holder.Window], callers func() (holder.
|
||||
}},
|
||||
stdio.Tool{Name: seat + "." + holder.VerbVersions, Description: "The versions the seat holds of a name.", Input: obj(map[string]any{"name": s("<module>/<artifact>")}, "name"),
|
||||
Run: func(args map[string]any) (any, error) {
|
||||
v, err := now()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a, err := v.Versions(seat, stdio.CallerOf(args), str(args, "name"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -315,10 +280,6 @@ func tools(v *holder.Verbs, windows kept[holder.Window], callers func() (holder.
|
||||
stdio.Tool{Name: seat + "." + holder.VerbRemove, Description: "Remove a tagged version (the builder), or for retention an untagged one by its digest (the artifact sweep; an index with the platform manifests no tagged or held index names, and never a manifest such an index names); says removed.",
|
||||
Input: obj(map[string]any{"name": s("name"), "version": s("a tagged version"), "tag": s("its tag"), "digest": s("for retention"), "reason": s("why")}, "name", "reason"),
|
||||
Run: func(args map[string]any) (any, error) {
|
||||
v, err := now()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
in, err := arg[reg.RemoveInput](args)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -333,10 +294,6 @@ func tools(v *holder.Verbs, windows kept[holder.Window], callers func() (holder.
|
||||
stdio.Tool{Name: seat + "." + holder.VerbList, Description: "What the store holds of one repository, or of every repository the seat holds: each manifest's digest, whether it is an index and the manifests it names, the tags naming it, its size and whether it is held (ADR 0632 §3). Read from the store's own files; changes nothing.",
|
||||
Input: obj(map[string]any{"name": s("<module>/<artifact>; every repository when not given")}),
|
||||
Run: func(args map[string]any) (any, error) {
|
||||
v, err := now()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
repos, err := v.List(context.Background(), seat, stdio.CallerOf(args), str(args, "name"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -346,10 +303,6 @@ func tools(v *holder.Verbs, windows kept[holder.Window], callers func() (holder.
|
||||
stdio.Tool{Name: seat + "." + holder.VerbHold, Description: "Keep one untagged bundle, archive or copied base from retention (the artifact sweep alone); says held.",
|
||||
Input: obj(map[string]any{"name": s("name"), "digest": s("sha256:…"), "reason": s("which of the sweep's rules keeps it")}, "name", "digest", "reason"),
|
||||
Run: func(args map[string]any) (any, error) {
|
||||
v, err := now()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
in, err := arg[reg.HoldInput](args)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -362,10 +315,6 @@ func tools(v *holder.Verbs, windows kept[holder.Window], callers func() (holder.
|
||||
stdio.Tool{Name: seat + "." + holder.VerbRelease, Description: "Let a hold go (the artifact sweep, or the operator's session through registry_holds); says released.",
|
||||
Input: obj(map[string]any{"name": s("name"), "digest": s("sha256:…"), "reason": s("why it is let go")}, "name", "digest", "reason"),
|
||||
Run: func(args map[string]any) (any, error) {
|
||||
v, err := now()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
in, err := arg[reg.ReleaseInput](args)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -381,10 +330,6 @@ func tools(v *holder.Verbs, windows kept[holder.Window], callers func() (holder.
|
||||
out = append(out,
|
||||
stdio.Tool{Name: holder.ImageSeat + "." + holder.VerbTags, Description: "The tags of an image.", Input: obj(map[string]any{"name": s("name")}, "name"),
|
||||
Run: func(args map[string]any) (any, error) {
|
||||
v, err := now()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a, err := v.Tags(holder.ImageSeat, stdio.CallerOf(args), str(args, "name"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -394,10 +339,6 @@ func tools(v *holder.Verbs, windows kept[holder.Window], callers func() (holder.
|
||||
stdio.Tool{Name: holder.ImageSeat + "." + holder.VerbMirror, Description: "Copy an outside image, pinned by its digest, into the store as upstream/<host>/<path> (every platform, only the parts it lacks): answers at once with the reference the copy will have, and says published (or refused, with why) on the seat's events when the copy ends; wait on that event (mesh-build-image alone).",
|
||||
Input: obj(map[string]any{"source": s("<host>/<path>"), "digest": s("sha256:…")}, "source", "digest"),
|
||||
Run: func(args map[string]any) (any, error) {
|
||||
v, err := now()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
in, err := arg[reg.MirrorInput](args)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -408,28 +349,15 @@ func tools(v *holder.Verbs, windows kept[holder.Window], callers func() (holder.
|
||||
}
|
||||
return reg.MirrorAnswer{Digest: in.Digest, Reference: ref}, nil
|
||||
}},
|
||||
stdio.Tool{Name: holder.CallersTool, Description: "Who may call which verb of distribution's seats: the trusted config value callers as mesh-config resolves it on this machine now (design 51's list where unset). The program asks it at each wake of the windows, as it has no config of its own.",
|
||||
Input: obj(map[string]any{}),
|
||||
Run: func(map[string]any) (any, error) {
|
||||
c, err := callers()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{"callers": c}, nil
|
||||
}},
|
||||
stdio.Tool{Name: "registry_health", Description: "distribution's health check, run by the runtime each minute: versions of each seat asked through the store's read side (the program's port 5100); fails naming the program when it does not answer.",
|
||||
Input: obj(map[string]any{}),
|
||||
Run: func(map[string]any) (any, error) {
|
||||
front, _ := url.Parse("http://127.0.0.1:5100")
|
||||
return holder.Health(&holder.OCI{Base: front}, finding)
|
||||
return holder.Health(&holder.OCI{Base: front}, findings.Say)
|
||||
}},
|
||||
stdio.Tool{Name: "registry_incoming", Description: "Open transfers of incoming and its fill; with drop, drops one by hand (a hand act, why required).",
|
||||
Input: obj(map[string]any{"drop": s("a transfer id to drop"), "why": s("why it is dropped")}),
|
||||
Run: func(args map[string]any) (any, error) {
|
||||
v, err := now()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
idle, _ := v.DropIdle()
|
||||
if id := str(args, "drop"); id != "" {
|
||||
t, err := v.DropByHand(id, stdio.CallerOf(args), str(args, "why"))
|
||||
@@ -472,10 +400,6 @@ func tools(v *holder.Verbs, windows kept[holder.Window], callers func() (holder.
|
||||
stdio.Tool{Name: "registry_holds", Description: "What is held, since when and for whom; with release, lets one go through the seat's release as the operator's session (a hand act).",
|
||||
Input: obj(map[string]any{"seat": s("the seat"), "release": s("the name to release"), "digest": s("its digest"), "why": s("why")}),
|
||||
Run: func(args map[string]any) (any, error) {
|
||||
v, err := now()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if name := str(args, "release"); name != "" {
|
||||
if str(args, "why") == "" {
|
||||
return nil, errors.New("a release by hand is a hand act: say why")
|
||||
@@ -517,14 +441,38 @@ func tools(v *holder.Verbs, windows kept[holder.Window], callers func() (holder.
|
||||
return out
|
||||
}
|
||||
|
||||
// findings keeps the open findings of this bundle for its writer (design 47 G §689).
|
||||
var findings = &holder.Findings{Logf: logf}
|
||||
|
||||
func main() {
|
||||
// The tools bundle's findings go through the module's held writer (design 47 G §689, A-16, A-29, A-32); it owns the transfers'
|
||||
// kinds, the program's writer the driver's.
|
||||
w, err := views.NewStdioHeldWriter(views.WriterConfig[meshconditions.Finding, meshconditions.FindingCleared, meshconditions.FindingsStated, meshconditions.FindingsAskedEvent]{
|
||||
Asked: meshconditions.SubjectFindingsAsked, Module: holder.Self, State: stdio.State("findings"), Kinds: holder.TransferFindingKinds, Logf: logf})
|
||||
if err != nil {
|
||||
logf("%v", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
findings.Writer = w
|
||||
// the runtime answers the writer's state and subscription only once Serve has its channel
|
||||
go func() {
|
||||
<-stdio.Served()
|
||||
// the keys of the older form end in no kind the writer owns, so it does not clear them (A-31)
|
||||
if err := holder.DeleteOldFindings(stdio.State("findings"), holder.Self, holder.TransferFindingKinds); err != nil {
|
||||
logf("the old findings are not deleted: %v", err)
|
||||
}
|
||||
if err := w.Start(context.Background()); err != nil {
|
||||
logf("%v", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}()
|
||||
base, _ := url.Parse("http://127.0.0.1:5001")
|
||||
store := &holder.OCI{Base: base, Upstream: &holder.Upstream{}}
|
||||
h := &holder.Holder{Objects: parts.BusObjects(holder.IncomingObjects), Builder: builds.BuilderVersions{Ask: stdio.Ask}, Store: store, Say: say, Finding: finding,
|
||||
h := &holder.Holder{Objects: parts.BusObjects(holder.IncomingObjects), Builder: holder.Reserved{Ask: stdio.Ask}, Store: store, Say: say, Finding: findings.Say,
|
||||
Transfers: parts.BusTransfers[holder.Opening](holder.TransfersState), Cap: parts.BusCap(holder.IncomingObjects), Record: recordAct, Idle: parts.BusArm(holder.IdleState)}
|
||||
files := &holder.Files{Container: or(os.Getenv("MESH_REGISTRY_CONTAINER"), "mesh-registry"), Run: holder.ExecRunner, UID: os.Getuid()}
|
||||
v := &holder.Verbs{Holder: h, Catalog: store, Holds: kept[holder.Hold]{state: "holds", key: holder.Hold.Key}, Mirrors: mirrors{},
|
||||
Files: files}
|
||||
Files: files, SweepHolder: func() (string, error) { return seatlist.HolderOf(stdio.Ask, holder.SweepSeat) }}
|
||||
go followMirrors(v)
|
||||
go parts.FollowIdle(holder.IdleState, parts.IDs(h.Transfers), h.Expired, func(id string, dropped bool, err error) {
|
||||
if err != nil {
|
||||
@@ -534,7 +482,7 @@ func main() {
|
||||
logf("transfer %s had no part for %s: dropped with its parts", id, holder.TransferIdle)
|
||||
})
|
||||
windows := kept[holder.Window]{state: "windows", key: func(w holder.Window) string { return w.Name }}
|
||||
if err := stdio.Serve("", tools(v, windows, holder.ConfiguredCallers)); err != nil {
|
||||
if err := stdio.Serve("", tools(v, windows)); err != nil {
|
||||
logf("%v", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
package main
|
||||
|
||||
// The build modules the windows' driver counts (design 47 G §610, A-156, A-82): the modules that claim a build seat
|
||||
// (`node-build-*`) in the catalog half, followed per watched branch by the seat mesh-catalog's client on the program's
|
||||
// one views reader. No list of names is kept here, and none is configured.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/meshcatalogclient"
|
||||
catalog "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog"
|
||||
)
|
||||
|
||||
// buildSeatPrefix starts the name of every seat a build module claims.
|
||||
const buildSeatPrefix = "node-build-"
|
||||
|
||||
// buildModulesOf are the modules among entries whose manifest claims a build seat, sorted.
|
||||
func buildModulesOf(entries map[string]catalog.CatalogEntry) []string {
|
||||
var out []string
|
||||
for module, e := range entries {
|
||||
claims, _ := e.Manifest["claims"].([]any)
|
||||
for _, c := range claims {
|
||||
if name, _ := c.(map[string]any)["name"].(string); strings.HasPrefix(name, buildSeatPrefix) {
|
||||
out = append(out, module)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
slices.Sort(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// claimsBuildSeat says whether a module's entry, as the branch follower tells it, claims a build seat.
|
||||
func claimsBuildSeat(module string, raw json.RawMessage) bool {
|
||||
var e catalog.CatalogEntry
|
||||
if json.Unmarshal(raw, &e) != nil {
|
||||
return true // an entry not read may be one: the driver judges again
|
||||
}
|
||||
return len(buildModulesOf(map[string]catalog.CatalogEntry{module: e})) == 1
|
||||
}
|
||||
|
||||
// branchesBuilds answers the driver's build modules from the catalog half of every followed branch; with a branch that has
|
||||
// no first view yet it is an error naming it, never no build module.
|
||||
type branchesBuilds struct{ b *meshcatalogclient.Branches }
|
||||
|
||||
func (h branchesBuilds) Modules() ([]string, error) {
|
||||
if err := h.b.Ready(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
all := map[string]catalog.CatalogEntry{}
|
||||
for _, branch := range h.b.Followed() {
|
||||
c, ok := h.b.Catalog(branch)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
half, _ := c.View() // a branch at none-yet is ready and empty (§614)
|
||||
for m, e := range half.Entries {
|
||||
if _, ok := all[m]; !ok {
|
||||
all[m] = e
|
||||
}
|
||||
}
|
||||
}
|
||||
return buildModulesOf(all), nil
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
catalog "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog"
|
||||
)
|
||||
|
||||
func entry(claims ...string) catalog.CatalogEntry {
|
||||
var cs []any
|
||||
for _, c := range claims {
|
||||
cs = append(cs, map[string]any{"name": c, "scope": "node"})
|
||||
}
|
||||
return catalog.CatalogEntry{Manifest: map[string]any{"claims": cs}}
|
||||
}
|
||||
|
||||
// Design 47 G §610, A-156: the build modules the driver counts are the modules claiming a `node-build-*` seat in the
|
||||
// catalog half, whatever their names; no list of names is kept.
|
||||
func TestBuildModulesAreTheClaimantsOfBuildSeats(t *testing.T) {
|
||||
entries := map[string]catalog.CatalogEntry{
|
||||
"a-compiler": entry("node-build-go"), "an-imager": entry("node-build-image", "something"),
|
||||
"gitea": entry("git"), "mesh-build-go": entry("not-a-build-seat"), "no-claims": {Manifest: map[string]any{}},
|
||||
}
|
||||
if got, want := buildModulesOf(entries), []string{"a-compiler", "an-imager"}; !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("build modules = %v, want %v", got, want)
|
||||
}
|
||||
raw, _ := json.Marshal(entry("node-build-node"))
|
||||
if !claimsBuildSeat("m", raw) {
|
||||
t.Error("an entry claiming a build seat is not told as one")
|
||||
}
|
||||
raw, _ = json.Marshal(entry("git"))
|
||||
if claimsBuildSeat("m", raw) {
|
||||
t.Error("an entry claiming no build seat wakes the driver")
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,10 @@ import (
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
meshconditions "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions"
|
||||
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/meshcatalogclient"
|
||||
"git.novox.be/novox/mesh-sdk/go/process"
|
||||
"git.novox.be/novox/mesh-sdk/go/views"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
|
||||
"distribution/internal/holder"
|
||||
@@ -62,9 +65,47 @@ func run(ctx context.Context, getenv func(string) string) error {
|
||||
return err
|
||||
}
|
||||
defer calls.Close()
|
||||
findings := process.OptionalBucket(ctx, js, "distribution_findings", "a window held for seven days is said in the log only", logger.Printf)
|
||||
go drive(ctx, js, cred, &holder.Driver{Delivery: busDelivery{calls}, State: kvWindows{windows}, Callers: busCallers{calls}.Read,
|
||||
Closed: sayClosed(js, cred), Finding: findingsKV(findings, logger.Printf)}, logger)
|
||||
// The program's findings go through the module's writer (design 47 G §689); it owns the driver's kinds, the tools
|
||||
// bundle's writer the transfers'.
|
||||
findings := &holder.Findings{Logf: logger.Printf}
|
||||
w, err := views.NewHeldWriter(views.WriterConfig[meshconditions.Finding, meshconditions.FindingCleared, meshconditions.FindingsStated, meshconditions.FindingsAskedEvent]{
|
||||
Asked: meshconditions.SubjectFindingsAsked, Module: holder.Self, Node: cred.Node, State: calls.State("findings"), Kinds: holder.DriverFindingKinds,
|
||||
JS: js, Conn: conn, Logf: logger.Printf})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
findings.Writer = w
|
||||
// the keys of the older form end in no kind the writer owns, so it does not clear them (A-31)
|
||||
if err := holder.DeleteOldFindings(calls.State("findings"), holder.Self, holder.DriverFindingKinds); err != nil {
|
||||
logger.Printf("the old findings are not deleted: %v", err)
|
||||
}
|
||||
if err := w.Start(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
defer w.Close()
|
||||
// Which modules are the build modules is the catalog half's (design 47 G §610, A-156): the branch follower of the seat
|
||||
// mesh-catalog's client on a views reader that shares the program's one events hub. The driver wakes again when a
|
||||
// build module's entry comes, changes or goes, so the start's wake, made before the first view, is not the last.
|
||||
hub := process.NewEventHub(logger.Printf)
|
||||
reader := &views.ProcessReader{Module: holder.Self, Call: calls.Call, Logf: logger.Printf, Hub: hub}
|
||||
branches, err := meshcatalogclient.FollowBranchesProcess(reader, calls.Call)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
branches.Logf = logger.Printf
|
||||
catalogMoved := make(chan struct{}, 1)
|
||||
branches.Changed = func(_, module string, entry json.RawMessage, deleted bool, _ time.Time) {
|
||||
if !deleted && !claimsBuildSeat(module, entry) {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case catalogMoved <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
reader.Start(ctx, js, cred, nil)
|
||||
go drive(ctx, js, cred, hub, catalogMoved, &holder.Driver{Delivery: busDelivery{calls}, State: kvWindows{windows},
|
||||
BuildModules: branchesBuilds{branches}.Modules, Closed: sayClosed(js, cred), Finding: findings.Say}, logger)
|
||||
}
|
||||
front := &holder.Front{Store: store, Window: &open, Logf: logger.Printf,
|
||||
Published: func(name, tag string) (bool, error) {
|
||||
|
||||
@@ -7,8 +7,8 @@ package main
|
||||
// OCI store on the loopback, the status view's node_haves and the node-engine seat's manifest-reads answered on their
|
||||
// call subjects. An away node holds the old name open; its return (manifest-reads-changed) closes it, said once on
|
||||
// distribution.events, after which a direct write is refused naming publish; reads stay open; a waiter on a seat's
|
||||
// `published` hears none of the windows' events; and the build modules counted are publish's callers as the tools
|
||||
// bundle answers the config value, not design 51's defaults.
|
||||
// `published` hears none of the windows' events; and the build modules counted are the modules claiming a `node-build-*` seat
|
||||
// in the catalog half the seat mesh-catalog answers (design 47 G §610, A-156), not a list of names.
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -29,6 +29,8 @@ import (
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
|
||||
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/meshcatalogclient"
|
||||
catalog "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog"
|
||||
"git.novox.be/novox/mesh-sdk/go/testbus"
|
||||
|
||||
"distribution/internal/holder"
|
||||
@@ -56,7 +58,8 @@ func TestDistributionWhole(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := js.CreateOrUpdateConsumer(ctx, "EVENTS", jetstream.ConsumerConfig{Durable: "novox_distribution", AckPolicy: jetstream.AckExplicitPolicy,
|
||||
FilterSubjects: []string{"mesh.mod.mesh-delivery-view.event.delivered", "mesh.seat.node-engine.event.manifest-reads-changed.*"}}); err != nil {
|
||||
FilterSubjects: []string{"mesh.mod.mesh-delivery-view.event.delivered", "mesh.seat.node-engine.event.manifest-reads-changed.*",
|
||||
"mesh.seat.mesh-catalog.event.catalog-revision.>", "mesh.seat.mesh-catalog.event.registration-changed", "mesh.seat.mesh-delivery.event.source-moved"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -77,21 +80,36 @@ func TestDistributionWhole(t *testing.T) {
|
||||
answer(m, map[string]any{"keys": map[string]any{
|
||||
"ace.mesh-build-go": map[string]any{"fp": "fp-new"}, "g14.mesh-build-go": map[string]any{"fp": fp},
|
||||
"ace.gitea": map[string]any{"fp": "x"}, "g14.gitea": map[string]any{"fp": "x"},
|
||||
// A build module the operator took off publish's list: it holds no window (the caller list as configured).
|
||||
// A module claiming no build seat in the catalog half: it holds no window.
|
||||
"g14.mesh-build-node": map[string]any{"fp": "fp-node-old"},
|
||||
}})
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// distribution's own tools bundle answers the config value callers as mesh-config resolved it: publish by
|
||||
// mesh-build-go alone. The program reads it there, never from an environment variable nothing renders.
|
||||
if _, err := nc.Subscribe("mesh.mod.distribution.>", func(m *nats.Msg) {
|
||||
if !strings.Contains(m.Subject, holder.CallersTool) {
|
||||
return
|
||||
// The catalog half, as the seats mesh-catalog and mesh-delivery answer it: one watched branch, main, whose trunk revision
|
||||
// has mesh-build-go claiming the build seat node-build-go; mesh-build-node claims no build seat, so it holds no window.
|
||||
entryOf := func(module string, claims ...string) catalog.CatalogEntry {
|
||||
var cs []any
|
||||
for _, c := range claims {
|
||||
cs = append(cs, map[string]any{"name": c, "scope": "node"})
|
||||
}
|
||||
return catalog.CatalogEntry{Module: module, Manifest: map[string]any{"claims": cs}}
|
||||
}
|
||||
catalogHalf := func(m *nats.Msg) {
|
||||
switch {
|
||||
case strings.Contains(m.Subject, ".call.registrations."):
|
||||
answer(m, catalog.RegistrationsAnswer{Registrations: []catalog.Registration{{Module: "mesh-build-go", Branches: []string{"main"}}}})
|
||||
case strings.Contains(m.Subject, ".call.revision."):
|
||||
answer(m, catalog.RevisionAnswer{Revision: &catalog.CatalogRevision{Revision: 1, Kind: meshcatalogclient.KindTrunk, Branch: "main"},
|
||||
Entries: []catalog.CatalogEntry{entryOf("mesh-build-go", "node-build-go"), entryOf("mesh-build-node"), entryOf("gitea", "git")}})
|
||||
case strings.Contains(m.Subject, ".call.source."):
|
||||
answer(m, map[string]any{"branches": []string{"main"}})
|
||||
}
|
||||
}
|
||||
for _, subject := range []string{"mesh.seat.mesh-catalog.>", "mesh.seat.mesh-delivery.call.>"} {
|
||||
if _, err := nc.Subscribe(subject, catalogHalf); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
answer(m, map[string]any{"callers": map[string]any{"publish": []string{"mesh-build-go"}}})
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
engine := func(node string) {
|
||||
for _, subj := range []string{"mesh.seat.node-engine.call.manifest-reads." + node + ".>", "mesh.seat.node-engine.tool.manifest-reads." + node} {
|
||||
@@ -210,9 +228,12 @@ func TestDistributionWhole(t *testing.T) {
|
||||
i, err := c.Info(ctx)
|
||||
return err == nil && i.NumPending == 0 && i.NumAckPending == 0 && i.Delivered.Consumer >= 2
|
||||
})
|
||||
info, _ := s.Info(ctx)
|
||||
if info.State.Msgs != 2 {
|
||||
t.Errorf("distribution.events holds %d events, want old-name-closed and direct-writes-closed once each", info.State.Msgs)
|
||||
// The stream holds the module's own events once each; the finding writer's (design 47 G A-30) are said beside them.
|
||||
info, _ := s.Info(ctx, jetstream.WithSubjectFilter("mesh.stream.distribution.event.>"))
|
||||
for _, ev := range []string{holder.OldNameClosedEvent, holder.DirectWritesClosedEvent} {
|
||||
if n := info.State.Subjects["mesh.stream.distribution.event."+ev]; n != 1 {
|
||||
t.Errorf("distribution.events holds %s %d times, want once", ev, n)
|
||||
}
|
||||
}
|
||||
for _, ev := range []string{holder.OldNameClosedEvent, holder.DirectWritesClosedEvent} {
|
||||
if _, err := s.GetLastMsgForSubject(ctx, "mesh.stream.distribution.event."+ev); err != nil {
|
||||
|
||||
@@ -18,8 +18,6 @@ import (
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
|
||||
"git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient"
|
||||
meshconditions "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions"
|
||||
"git.novox.be/novox/mesh-sdk/go/process"
|
||||
|
||||
"distribution/internal/holder"
|
||||
@@ -29,8 +27,6 @@ import (
|
||||
const (
|
||||
havesAsk = "mesh-delivery-view.node_haves"
|
||||
readsAsk = "seat:node-engine.manifest-reads"
|
||||
// callersAsk is distribution's own tool answering its config value callers as resolved.
|
||||
callersAsk = holder.Self + "." + holder.CallersTool
|
||||
)
|
||||
|
||||
// caller is a process's call of a tool or a seat verb.
|
||||
@@ -39,25 +35,6 @@ type caller interface {
|
||||
CallOn(node, key string, body any) (json.RawMessage, error)
|
||||
}
|
||||
|
||||
// busCallers reads the caller list from distribution's own tools bundle (holder.CallersTool, manifest invokes): a
|
||||
// program has no runtime that resolves its module's config values, the tools bundle has (stdio.Config), so the program
|
||||
// asks it on each wake rather than read an environment variable nothing renders.
|
||||
type busCallers struct{ c caller }
|
||||
|
||||
func (b busCallers) Read() (holder.Callers, error) {
|
||||
raw, err := b.c.Call(callersAsk, map[string]any{})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", callersAsk, err)
|
||||
}
|
||||
var a struct {
|
||||
Callers json.RawMessage `json:"callers"`
|
||||
}
|
||||
if err := json.Unmarshal(unwrap(raw), &a); err != nil || len(a.Callers) == 0 {
|
||||
return nil, fmt.Errorf("%s answered what is not a caller list: %s", callersAsk, unwrap(raw))
|
||||
}
|
||||
return holder.ParseCallers(string(a.Callers))
|
||||
}
|
||||
|
||||
// busDelivery answers holder.Delivery through the bus.
|
||||
type busDelivery struct{ c caller }
|
||||
|
||||
@@ -173,30 +150,6 @@ func (k kvWindows) Publishing() (holder.Publishing, error) {
|
||||
return p, k.get(holder.PublishingKey, &p)
|
||||
}
|
||||
|
||||
// findingsKV writes the driver's findings into distribution's state findings, as the tools bundle writes its own.
|
||||
func findingsKV(kv jetstream.KeyValue, logf func(string, ...any)) func(kind, subject, words string, raised bool) {
|
||||
return func(kind, subject, words string, raised bool) {
|
||||
if kv == nil {
|
||||
return
|
||||
}
|
||||
key := meshconditionsclient.Key(meshconditionsclient.ScopeSeat, holder.Self, kind+"."+strings.NewReplacer(".", "_", "@", "_", "/", "_").Replace(subject))
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
if !raised {
|
||||
_ = kv.Delete(ctx, key)
|
||||
return
|
||||
}
|
||||
f := meshconditions.FindingsByKeyValue{V: meshconditionsclient.FindingVersion, Key: key, Kind: kind, Ref: key[strings.LastIndex(key, ".")+1:],
|
||||
Subject: meshconditions.Subject{Scope: meshconditionsclient.ScopeSeat, ID: holder.Self}, Severity: meshconditionsclient.Warning,
|
||||
Summary: words, Headline: words, Explanation: words + "; registry_old_name or registry_direct_writes drops a retired node by hand",
|
||||
Resolved: "cleared when the window closes", Source: kind, Observer: holder.Self, At: time.Now().UTC()}
|
||||
b, _ := json.Marshal(f)
|
||||
if _, err := kv.Put(ctx, key, b); err != nil {
|
||||
logf("the finding %s could not be written: %v", key, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// sayClosed says a window's closing on distribution's own family distribution.events, once (its id is the window's).
|
||||
func sayClosed(js jetstream.JetStream, cred process.Account) func(holder.Closed) error {
|
||||
return func(c holder.Closed) error {
|
||||
@@ -229,7 +182,7 @@ func wakes(subject string) (string, bool) {
|
||||
|
||||
// drive wakes the driver at start and on each waking event of the program's durable consumer of the events stream.
|
||||
// A wake that could not judge is said and its event asked again a minute later.
|
||||
func drive(ctx context.Context, js jetstream.JetStream, cred process.Account, d *holder.Driver, logger *log.Logger) {
|
||||
func drive(ctx context.Context, js jetstream.JetStream, cred process.Account, hub *process.EventHub, catalogMoved <-chan struct{}, d *holder.Driver, logger *log.Logger) {
|
||||
wake := func(why string) error {
|
||||
said, err := d.Wake(why)
|
||||
for _, s := range said {
|
||||
@@ -241,9 +194,9 @@ func drive(ctx context.Context, js jetstream.JetStream, cred process.Account, d
|
||||
return err
|
||||
}
|
||||
_ = wake("distribution started")
|
||||
// The module's one durable on the events stream is pulled by the events hub (mesh-sdk process), each waking event
|
||||
// registered by its subject pattern: the seat of the view says delivered, the node-engine says a manifest read changed.
|
||||
hub := process.NewEventHub(logger.Printf)
|
||||
// The module's one durable on the events stream is pulled by the events hub (mesh-sdk process), which the views reader
|
||||
// of the catalog half shares: each waking event registered by its subject pattern, the seat of the view saying
|
||||
// delivered, the node-engine saying a manifest read changed.
|
||||
handle := func(m jetstream.Msg) {
|
||||
why, ok := wakes(m.Subject())
|
||||
if !ok {
|
||||
@@ -266,6 +219,13 @@ func drive(ctx context.Context, js jetstream.JetStream, cred process.Account, d
|
||||
logger.Printf("the events stream cannot be heard (%v): the windows move only at the next start", err)
|
||||
return
|
||||
}
|
||||
<-ctx.Done()
|
||||
hub.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-catalogMoved:
|
||||
_ = wake("a build module moved in the catalog half")
|
||||
case <-ctx.Done():
|
||||
hub.Stop()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,13 +3,14 @@ module distribution
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshhandactsclient v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-delivery/contracts/go/clients/containerimageregistryclient v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-delivery/contracts/go/seats/containerimageregistry v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-delivery/sdk v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/containerimageregistryclient v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/meshcatalogclient v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/containerimageregistry v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshbuilds v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-delivery/modules/mesh-delivery-sdk v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000
|
||||
github.com/nats-io/nats.go v1.54.0
|
||||
)
|
||||
@@ -30,7 +31,7 @@ require (
|
||||
|
||||
require (
|
||||
git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshhandacts v0.0.0-00010101000000-000000000000 // indirect
|
||||
git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient v0.0.0-00010101000000-000000000000 // indirect
|
||||
git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog v0.0.0-00010101000000-000000000000 // indirect
|
||||
git.novox.be/novox/mesh-delivery/contracts/go/seats/ociartifactregistry v0.0.0-00010101000000-000000000000 // indirect
|
||||
git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/ociartifactregistry v0.0.0-00010101000000-000000000000 // indirect
|
||||
)
|
||||
|
||||
require git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshdelivery v0.0.0-00010101000000-000000000000 // indirect
|
||||
|
||||
@@ -1,135 +0,0 @@
|
||||
// Package holder is distribution's holding of the two image seats, `container-image-registry` and
|
||||
// `oci-artifact-registry`, and of the old seat `mesh-artifact-store` until its window closes (novox/hq design 51 *The
|
||||
// four registry seats*, ADR 0605, ADR 0611, ADR 0617 §2). A build module publishes into a seat through `publish` and
|
||||
// `publish-part`: the parts land in distribution's own object store `incoming`, are hashed, checked against the
|
||||
// builder's `builder_versions` and stored in the artifact store, which listens on its node's loopback; the store's port
|
||||
// is the program's, reads open, writes from its own process alone (front.go).
|
||||
package holder
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"sort"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
)
|
||||
|
||||
// The seats distribution holds, and the old one it holds until its window closes.
|
||||
const (
|
||||
ImageSeat = "container-image-registry"
|
||||
ArtifactSeat = "oci-artifact-registry"
|
||||
OldSeat = "mesh-artifact-store"
|
||||
)
|
||||
|
||||
// The verbs of the seats (design 26 *A seat's definition*; design 51 *One contract*).
|
||||
const (
|
||||
VerbPublish = "publish"
|
||||
VerbPublishPart = "publish-part"
|
||||
VerbVersions = "versions"
|
||||
VerbRemove = "remove"
|
||||
VerbMirror = "mirror"
|
||||
VerbTags = "tags"
|
||||
VerbHold = "hold"
|
||||
VerbRelease = "release"
|
||||
)
|
||||
|
||||
// Seats are the seats distribution answers verbs on.
|
||||
var Seats = []string{ImageSeat, ArtifactSeat}
|
||||
|
||||
// VerbsOf are a seat's verbs: mirror and tags on the image seat alone; hold, release and list on both of distribution's.
|
||||
func VerbsOf(seat string) []string {
|
||||
v := []string{VerbPublish, VerbPublishPart, VerbVersions, VerbRemove, VerbHold, VerbRelease, VerbList}
|
||||
if seat == ImageSeat {
|
||||
v = append(v, VerbMirror, VerbTags)
|
||||
}
|
||||
sort.Strings(v)
|
||||
return v
|
||||
}
|
||||
|
||||
// Callers is the holder's trusted config value `callers`: per verb, the callers that may call it, read as mesh-sdk's
|
||||
// seat-neutral list per verb reads it (ADR 0627 §2). The holder checks the caller the tool runner proved and stamped on
|
||||
// the request (design 25 § 12.4), never a name in the request's body.
|
||||
type Callers = stdio.VerbCallers
|
||||
|
||||
// DefaultCallers is the list design 51 *Who may call which verb* names: publish and publish-part the build modules;
|
||||
// mirror mesh-build-image; remove the builder (a tagged version) and the artifact sweep's holder (retention); hold the
|
||||
// artifact sweep's holder alone; release that holder, and the operator's session only through registry_holds (Operator
|
||||
// below). versions, tags and list are read by anyone on the mesh, as the store's reads are.
|
||||
func DefaultCallers() Callers {
|
||||
builds := []string{"mesh-build-go", "mesh-build-image", "mesh-build-node"}
|
||||
return Callers{
|
||||
VerbPublish: builds,
|
||||
VerbPublishPart: builds,
|
||||
VerbMirror: {"mesh-build-image"},
|
||||
VerbRemove: {"mesh-delivery-builder", SweepHolder},
|
||||
VerbHold: {SweepHolder},
|
||||
VerbRelease: {SweepHolder, Operator},
|
||||
VerbVersions: {Anyone},
|
||||
VerbTags: {Anyone},
|
||||
VerbList: {Anyone},
|
||||
}
|
||||
}
|
||||
|
||||
// SweepHolder is the module holding the seat `mesh-artifact-sweep`, the artifact sweep.
|
||||
const SweepHolder = "artifact-store-tools"
|
||||
|
||||
// The entries of a list that stand for a class, and the caller the runtime names for a call it could not prove.
|
||||
const (
|
||||
Anyone = stdio.AnyCaller
|
||||
Operator = stdio.AnyPerson
|
||||
Unproven = stdio.Unproven
|
||||
)
|
||||
|
||||
// Self is distribution's own account: never on release's list, so registry_holds cannot release in its own name.
|
||||
const Self = "distribution"
|
||||
|
||||
// ParseCallers reads the config value; an empty value is DefaultCallers. A verb no seat has is refused, and Self on
|
||||
// release's list is refused (design 51).
|
||||
func ParseCallers(raw string) (Callers, error) {
|
||||
c, err := stdio.ParseVerbCallers(raw, VerbsOf(ImageSeat), DefaultCallers())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return selfNotReleasing(c)
|
||||
}
|
||||
|
||||
// configured holds the config value `callers`: asked of the runtime once, by a watch, and replaced on each change of
|
||||
// the value, so a call never asks (issue 1966: an ask that timed out refused every verb).
|
||||
var configured = stdio.HoldVerbCallers(CallersConfig, VerbsOf(ImageSeat), DefaultCallers())
|
||||
|
||||
// ConfiguredCallers is the config value `callers` as mesh-config resolved it for this machine, held from the tools
|
||||
// runtime's watch of it (never an environment variable: nothing renders a config value into a program's environment);
|
||||
// unset is DefaultCallers, and Self on release's list is refused as ParseCallers refuses it.
|
||||
func ConfiguredCallers() (Callers, error) {
|
||||
c, err := configured()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return selfNotReleasing(c)
|
||||
}
|
||||
|
||||
// CallersConfig is the config value's key, and CallersTool the tools bundle's tool answering it as resolved: the
|
||||
// program, which has no tools runtime, asks that tool over the bus (manifest invokes).
|
||||
const (
|
||||
CallersConfig = "callers"
|
||||
CallersTool = "registry_callers"
|
||||
)
|
||||
|
||||
// WithCallers is v with the caller list c: a copy for one call, so a list read on that call never changes another's.
|
||||
func (v *Verbs) WithCallers(c Callers) *Verbs {
|
||||
h := *v.Holder
|
||||
h.Callers = c
|
||||
out := *v
|
||||
out.Holder = &h
|
||||
return &out
|
||||
}
|
||||
|
||||
func selfNotReleasing(c Callers) (Callers, error) {
|
||||
if slices.Contains(c[VerbRelease], Self) {
|
||||
return nil, fmt.Errorf("callers puts %s on release's list: registry_holds would release in its own name", Self)
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// CallerModule is the module of a proved module caller, which the runtime stamps `<node>.<module>`.
|
||||
func CallerModule(proved string) string { return stdio.ModuleOf(proved) }
|
||||
@@ -65,8 +65,9 @@ type WindowState interface {
|
||||
type Driver struct {
|
||||
Delivery Delivery
|
||||
State WindowState
|
||||
// Callers is the caller list as it holds on this wake: publish's callers are the build modules counted.
|
||||
Callers func() (Callers, error)
|
||||
// BuildModules are the modules claiming a build seat (`node-build-*`) in the catalog half as it holds on this wake:
|
||||
// the build modules counted (design 47 G §610, A-156).
|
||||
BuildModules func() ([]string, error)
|
||||
// Closed says a window's closing on distribution.events.
|
||||
Closed func(c Closed) error
|
||||
Finding func(kind, subject, words string, raised bool)
|
||||
@@ -87,11 +88,10 @@ func (d *Driver) Read() ([]NodeRead, error) {
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the publishing builds cannot be read, so no window moves: %w", err)
|
||||
}
|
||||
callers, err := d.Callers()
|
||||
builds, err := d.BuildModules()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the caller list cannot be read, so no window moves: %w", err)
|
||||
return nil, fmt.Errorf("the build modules cannot be read from the catalog half, so no window moves: %w", err)
|
||||
}
|
||||
builds := callers[VerbPublish]
|
||||
byNode := map[string]*NodeRead{}
|
||||
var order []string
|
||||
for _, h := range haves {
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
package holder
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
meshconditions "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions"
|
||||
keyparts "git.novox.be/novox/mesh-sdk/go/keys"
|
||||
"git.novox.be/novox/mesh-sdk/go/views"
|
||||
)
|
||||
|
||||
// FindingKinds are the kinds the driver says (the program) and the transfers say (the tools bundle): each bundle's writer
|
||||
// owns its own, since the two share one state `findings` (design 47 G §580, §689).
|
||||
var (
|
||||
DriverFindingKinds = []string{FindingWindowHeld}
|
||||
TransferFindingKinds = []string{"publish-refused", "incoming-full"}
|
||||
)
|
||||
|
||||
// FindingWriter is the module's writer (views.Writer) as the keeper writes and clears through it.
|
||||
type FindingWriter interface {
|
||||
Write(ctx context.Context, finding meshconditions.Finding) error
|
||||
Clear(ctx context.Context, scope, id, kind, why string) error
|
||||
}
|
||||
|
||||
// Findings keeps a bundle's open findings and says them through the module's writer (design 47 G §689, A-14): its `Say` is
|
||||
// the Finding callback of the Driver and the Holder; the writer is the held writer (views.HeldWriter), which holds what is open (A-29, A-32).
|
||||
type Findings struct {
|
||||
Writer FindingWriter
|
||||
Logf func(string, ...any)
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
// Say raises (raised) or clears the finding of a kind about a subject. Its id is the seat's and the subject's, each escaped
|
||||
// as a key's name is (design 47 G A-14); the kind stays the bundle's own word.
|
||||
func (k *Findings) Say(kind, subject, words string, raised bool) {
|
||||
id := keyparts.EscapeName(Self) + "." + keyparts.EscapeName(subject)
|
||||
key := views.FindingKey("seat", id, kind)
|
||||
if !raised {
|
||||
if err := k.Writer.Clear(context.Background(), "seat", id, kind, "it no longer holds"); err != nil {
|
||||
k.Logf("the finding %s could not be cleared: %v", key, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
at := time.Now()
|
||||
if k.Now != nil {
|
||||
at = k.Now()
|
||||
}
|
||||
f := meshconditions.Finding{Scope: "seat", ID: id, Kind: kind, Subject: meshconditions.Subject{Scope: "seat", ID: id},
|
||||
Severity: meshconditions.FindingSeverityWarning, Summary: words, Headline: words, Explanation: words,
|
||||
Source: kind, Observer: Self, At: at.UTC()}
|
||||
if err := k.Writer.Write(context.Background(), f); err != nil {
|
||||
k.Logf("the finding %s could not be written: %v", key, err)
|
||||
}
|
||||
}
|
||||
|
||||
// DeleteOldFindings deletes, once at a module's start, the findings the keys of an older form hold (design 47 G A-31): the form
|
||||
// `seat.<seat>.<kind>_<subject>`, whose kind is no kind the writer owns in its key, so the writer's own first statement does not
|
||||
// clear it. Only a key of one of kinds is deleted; the keys of the writer's form (`seat.<seat>.<subject>.<kind>`) are left.
|
||||
func DeleteOldFindings(state views.FindingState, seat string, kinds []string) error {
|
||||
keys, err := state.Keys()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, k := range keys {
|
||||
rest, ok := strings.CutPrefix(k, "seat."+seat+".")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for _, kind := range kinds {
|
||||
if strings.HasPrefix(rest, kind+"_") {
|
||||
if err := state.Delete(k); err != nil {
|
||||
return err
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package holder
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
)
|
||||
|
||||
// A-31: the keys of the older form (`seat.<seat>.<kind>_<subject>`) are deleted once; a key of the writer's form, another seat's
|
||||
// and another kind's stay.
|
||||
func TestTheOldFindingKeysAreDeletedOnce(t *testing.T) {
|
||||
state := &oldKeysState{keys: map[string]bool{}}
|
||||
for _, k := range []string{
|
||||
"seat.distribution.publish-refused_write", "seat.distribution.publish-refused_a_b_c",
|
||||
"seat.distribution.sub.publish-refused", "seat.other.publish-refused_write", "seat.distribution.unrelated_write"} {
|
||||
state.keys[k] = true
|
||||
}
|
||||
if err := DeleteOldFindings(state, "distribution", []string{"publish-refused", "incoming-full"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var left []string
|
||||
for k := range state.keys {
|
||||
left = append(left, k)
|
||||
}
|
||||
sort.Strings(left)
|
||||
if got, want := strings.Join(left, " "), "seat.distribution.sub.publish-refused seat.distribution.unrelated_write seat.other.publish-refused_write"; got != want {
|
||||
t.Errorf("left %s, want %s", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
type oldKeysState struct{ keys map[string]bool }
|
||||
|
||||
func (s *oldKeysState) Get(string) (*stdio.StateEntry, error) { return nil, nil }
|
||||
func (s *oldKeysState) Put(string, any) (uint64, error) { return 0, nil }
|
||||
func (s *oldKeysState) Delete(k string) error { delete(s.keys, k); return nil }
|
||||
func (s *oldKeysState) Keys() ([]string, error) {
|
||||
var out []string
|
||||
for k := range s.keys {
|
||||
out = append(out, k)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -17,7 +17,7 @@ import (
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshhandactsclient"
|
||||
artifactref "git.novox.be/novox/mesh-delivery/contracts/go/clients/containerimageregistryclient/reference"
|
||||
artifactref "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/containerimageregistryclient/reference"
|
||||
)
|
||||
|
||||
type memObjects map[string][]byte
|
||||
@@ -194,14 +194,15 @@ func (m memHolds) List() ([]Hold, error) {
|
||||
|
||||
func newBed(builderOK bool) *bed {
|
||||
b := &bed{store: &fakeStore{has: map[string]bool{}}, findings: map[string]bool{}, now: time.Unix(1000, 0), cat: &fakeCatalog{tags: map[string]string{}}}
|
||||
b.h = &Holder{Callers: DefaultCallers(), Objects: memObjects{}, Transfers: memTransfers{}, Builder: fakeBuilder{ok: builderOK},
|
||||
b.h = &Holder{Objects: memObjects{}, Transfers: memTransfers{}, Builder: fakeBuilder{ok: builderOK},
|
||||
Store: b.store, Now: func() time.Time { return b.now },
|
||||
Say: func(seat, event string, body Event) error {
|
||||
b.said = append(b.said, said{seat, event, body})
|
||||
return nil
|
||||
},
|
||||
Finding: func(kind, subject, _ string, raised bool) { b.findings[kind+" "+subject] = raised }}
|
||||
b.v = &Verbs{Holder: b.h, Catalog: b.cat, Holds: memHolds{}, Mirrors: memMirrors{}}
|
||||
b.v = &Verbs{Holder: b.h, Catalog: b.cat, Holds: memHolds{}, Mirrors: memMirrors{},
|
||||
SweepHolder: func() (string, error) { return "artifact-store-tools", nil }}
|
||||
return b
|
||||
}
|
||||
|
||||
@@ -221,8 +222,9 @@ func digestOf(b []byte) string { s := sha256.Sum256(b); return "sha256:" + hex.E
|
||||
const builder = "ace.mesh-build-go"
|
||||
|
||||
// TestTheHolderOfTheTwoImageSeats holds design 51's holder cases at the unit level (CAT-D1, D2, D3, D3b, D4, D4b, D5):
|
||||
// one table or case per rule, the caller list and the store front's.
|
||||
// one table or case per rule, the store front's.
|
||||
func TestTheHolderOfTheTwoImageSeats(t *testing.T) {
|
||||
t.Run("a publish is checked against the builder's reservation", reservedChecksAPublishAgainstTheReservation)
|
||||
blob := []byte(strings.Repeat("a", 3000))
|
||||
part := Part{Digest: digestOf(blob), Size: int64(len(blob))}
|
||||
req := func(id string) PublishRequest {
|
||||
@@ -242,14 +244,14 @@ func TestTheHolderOfTheTwoImageSeats(t *testing.T) {
|
||||
if a, _ := b.h.Publish(ArtifactSeat, builder, req("t1")); !reflect.DeepEqual(a.Missing, []string{part.Digest}) {
|
||||
t.Errorf("resumed publish = %+v", a)
|
||||
}
|
||||
// A chunk that is neither the cursor nor 0 is refused; a chunk 0 again is a part lacked travelling whole again
|
||||
// (mesh-delivery's parts.Receiver.Take, 1966/AC203-1, AC203-2): its earlier chunks are dropped and it is taken.
|
||||
// A chunk 0 again starts the part afresh (it travels whole again, design 51 Publishing); a chunk that is neither the
|
||||
// cursor nor 0 is refused, naming the cursor.
|
||||
if _, err := b.h.PublishPart(ArtifactSeat, builder, PartRequest{Transfer: "t1", Digest: part.Digest, Sequence: 0, Data: blob[:1000]}); err != nil {
|
||||
t.Errorf("a chunk 0 sent again was refused: %v", err)
|
||||
}
|
||||
if _, err := b.h.PublishPart(ArtifactSeat, builder, PartRequest{Transfer: "t1", Digest: part.Digest, Sequence: 2, Data: blob[:1000]}); err == nil {
|
||||
t.Error("a chunk out of sequence was taken: the transfer goes on from the chunks held")
|
||||
}
|
||||
if _, err := b.h.PublishPart(ArtifactSeat, builder, PartRequest{Transfer: "t1", Digest: part.Digest, Sequence: 0, Data: blob[:1000]}); err != nil {
|
||||
t.Errorf("a chunk 0 again was refused: %v", err)
|
||||
}
|
||||
a, err = b.h.PublishPart(ArtifactSeat, builder, PartRequest{Transfer: "t1", Digest: part.Digest, Sequence: 1, Last: true, Data: blob[1000:]})
|
||||
if err != nil || !a.Ended || len(b.said) != 1 || b.said[0].event != EventPublished || b.said[0].body.Reference == "" {
|
||||
t.Fatalf("last chunk = %+v, %v; said %+v", a, err, b.said)
|
||||
@@ -269,8 +271,6 @@ func TestTheHolderOfTheTwoImageSeats(t *testing.T) {
|
||||
do func(b *bed) error
|
||||
want string
|
||||
}{
|
||||
{"a caller not on the list", func(b *bed) error { _, err := b.h.Publish(ArtifactSeat, "ace.mesh-provisioner", req("t")); return err }, "not on the holder's caller list"},
|
||||
{"an unproven caller", func(b *bed) error { _, err := b.h.Publish(ArtifactSeat, Unproven, req("t")); return err }, "no caller the tool runner proved"},
|
||||
{"a part from another caller than the opener", func(b *bed) error {
|
||||
_, _ = b.h.Publish(ArtifactSeat, builder, req("t"))
|
||||
_, err := b.h.PublishPart(ArtifactSeat, "ace.mesh-build-node", PartRequest{Transfer: "t", Digest: part.Digest, Last: true, Data: blob})
|
||||
@@ -291,12 +291,12 @@ func TestTheHolderOfTheTwoImageSeats(t *testing.T) {
|
||||
_, err := b.h.Publish(ArtifactSeat, builder, req("t"))
|
||||
return err
|
||||
}, "at their cap"},
|
||||
{"a version the builder did not number", func(b *bed) error {
|
||||
{"a version the builder did not reserve", func(b *bed) error {
|
||||
b.h.Builder = fakeBuilder{ok: false}
|
||||
b.store.has[part.Digest] = true // held already: the publish ends at once
|
||||
_, err := b.h.Publish(ArtifactSeat, builder, req("t"))
|
||||
return err
|
||||
}, "the builder did not number it"},
|
||||
}, "the builder did not reserve it"},
|
||||
{"an image on the artifact seat", func(b *bed) error {
|
||||
r := req("t")
|
||||
r.Kind = KindImage
|
||||
@@ -319,13 +319,16 @@ func TestTheHolderOfTheTwoImageSeats(t *testing.T) {
|
||||
{"a hold of an image", func(b *bed) error {
|
||||
return b.v.HoldOne(ImageSeat, "novox.artifact-store-tools", HoldRequest{Name: "m/i", Kind: KindImage, Digest: part.Digest})
|
||||
}, "never held"},
|
||||
{"a hold from another caller", func(b *bed) error {
|
||||
return b.v.HoldOne(ArtifactSeat, "novox.mesh-build-go", HoldRequest{Name: "m/a", Kind: KindArchive, Digest: part.Digest})
|
||||
}, "not on the holder's caller list"},
|
||||
{"a release from a caller not on the list", func(b *bed) error {
|
||||
_, err := b.v.Release(ArtifactSeat, "novox.mesh-build-go", HoldRequest{Name: "m/a", Digest: part.Digest})
|
||||
{"a retention removal by the module that held the sweep seat before", func(b *bed) error {
|
||||
b.v.SweepHolder = func() (string, error) { return "artifact-sweeper", nil } // the seat's holder as the seat says it now
|
||||
_, err := b.v.Remove(ArtifactSeat, "novox.artifact-store-tools", RemoveRequest{Name: "m/program", Digest: part.Digest, Reason: ReasonRetention})
|
||||
return err
|
||||
}, "not on the holder's caller list"},
|
||||
}, "artifact sweep's alone"},
|
||||
{"a retention removal while the sweep's holder cannot be read", func(b *bed) error {
|
||||
b.v.SweepHolder = func() (string, error) { return "", errors.New("the seat's holders are not answered") }
|
||||
_, err := b.v.Remove(ArtifactSeat, "novox.artifact-store-tools", RemoveRequest{Name: "m/program", Digest: part.Digest, Reason: ReasonRetention})
|
||||
return err
|
||||
}, "cannot be read"},
|
||||
{"a release in distribution's own name", func(b *bed) error {
|
||||
_, err := b.v.Release(ArtifactSeat, "novox.distribution", HoldRequest{Name: "m/a", Digest: part.Digest})
|
||||
return err
|
||||
@@ -340,9 +343,6 @@ func TestTheHolderOfTheTwoImageSeats(t *testing.T) {
|
||||
t.Errorf("%s: err = %v, want one saying %q", c.name, err, c.want)
|
||||
}
|
||||
}
|
||||
if _, err := ParseCallers(`{"release":["distribution"]}`); err == nil {
|
||||
t.Error("a caller list putting distribution on release's list is taken")
|
||||
}
|
||||
})
|
||||
t.Run("only the missing layers are sent, and an idle transfer is dropped", func(t *testing.T) {
|
||||
b := newBed(true)
|
||||
@@ -776,7 +776,7 @@ func TestTheHolderOfTheTwoImageSeats(t *testing.T) {
|
||||
var closed []string
|
||||
found := map[string]bool{}
|
||||
now := time.Unix(0, 0)
|
||||
d := &Driver{Delivery: del, State: st, Callers: func() (Callers, error) { return DefaultCallers(), nil }, Now: func() time.Time { return now },
|
||||
d := &Driver{Delivery: del, State: st, BuildModules: func() ([]string, error) { return []string{"mesh-build-go", "mesh-build-image", "mesh-build-node"}, nil }, Now: func() time.Time { return now },
|
||||
Closed: func(c Closed) error { closed = append(closed, c.Window+" "+c.Seat); return nil },
|
||||
Finding: func(kind, subject, _ string, raised bool) { found[kind+" "+subject] = raised }}
|
||||
if _, err := d.Wake("start"); err != nil {
|
||||
@@ -848,9 +848,9 @@ func TestTheHolderOfTheTwoImageSeats(t *testing.T) {
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The builder's question is its published package, taken by its series (ADR 0634 §1).
|
||||
if m.Packages["git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds"] != "0.1" {
|
||||
t.Errorf("packages %v: want builds of series 0.1", m.Packages)
|
||||
// The reservation is asked through the seat mesh-builds, so the manifest takes no package of the builder (design 47 §381).
|
||||
if len(m.Packages) != 0 {
|
||||
t.Errorf("packages %v: want none", m.Packages)
|
||||
}
|
||||
// E15: the provision artifact-store retires (ADR 0632 §3); the bootstrap keeps it as an alias on port 5100 while
|
||||
// the running node-engines still find the store by it (issue 1966), and nothing else is provided or served.
|
||||
@@ -869,8 +869,9 @@ func TestTheHolderOfTheTwoImageSeats(t *testing.T) {
|
||||
if _, ok := claims[OldSeat]; !ok {
|
||||
t.Error("the old seat mesh-artifact-store is no longer claimed while its window is open")
|
||||
}
|
||||
if !reflect.DeepEqual(m.Consumes, []string{"mesh-delivery-view.delivered", "node-engine.manifest-reads-changed"}) {
|
||||
t.Errorf("consumes = %v, want delivered and manifest-reads-changed (the windows' wakes)", m.Consumes)
|
||||
if !reflect.DeepEqual(m.Consumes, []string{"mesh-delivery-view.delivered", "node-engine.manifest-reads-changed", "mesh-conditions.findings-asked",
|
||||
"mesh-catalog.catalog-revision", "mesh-catalog.registration-changed", "mesh-delivery.source-moved"}) {
|
||||
t.Errorf("consumes = %v, want delivered and manifest-reads-changed (the windows' wakes) and the catalog half's events", m.Consumes)
|
||||
}
|
||||
// E14: the registry container, on the loopback, is listed before the program, so the port is free when it starts.
|
||||
store, program := -1, -1
|
||||
@@ -915,19 +916,34 @@ func TestTheHolderOfTheTwoImageSeats(t *testing.T) {
|
||||
// Running review F2: incoming asks the bus 8 GiB, the most the objects template allows, equal to IncomingCap
|
||||
// (said only where no binding is read); transfers keeps for good, the holder dropping an idle one by its key
|
||||
// in idle, a state of keys with their own time to live.
|
||||
// The image registry is a foundation part (design 47 G §584): its bus state is in its old sections, incoming in
|
||||
// `objects`, the others in `state`.
|
||||
var req struct {
|
||||
Requires []struct {
|
||||
Name string `json:"name"`
|
||||
Options map[string]any `json:"options"`
|
||||
} `json:"requires"`
|
||||
Objects []struct {
|
||||
Name string `json:"name"`
|
||||
MaxMiB uint64 `json:"max-mib"`
|
||||
} `json:"objects"`
|
||||
State []json.RawMessage `json:"state"`
|
||||
}
|
||||
_ = json.Unmarshal(raw, &req)
|
||||
opts := map[string]map[string]any{}
|
||||
for _, r := range req.Requires {
|
||||
opts[r.Name] = r.Options
|
||||
for _, e := range req.State {
|
||||
var o map[string]any
|
||||
if json.Unmarshal(e, &o) != nil {
|
||||
var name string
|
||||
_ = json.Unmarshal(e, &name)
|
||||
o = map[string]any{"name": name}
|
||||
}
|
||||
opts[fmt.Sprint(o["name"])] = o
|
||||
}
|
||||
if mib, _ := opts[IncomingObjects]["max-mib"].(float64); uint64(mib)<<20 != IncomingCap {
|
||||
t.Errorf("incoming asks %v MiB, IncomingCap is %d bytes: want them equal", opts[IncomingObjects]["max-mib"], uint64(IncomingCap))
|
||||
var incoming uint64
|
||||
for _, o := range req.Objects {
|
||||
if o.Name == IncomingObjects {
|
||||
incoming = o.MaxMiB
|
||||
}
|
||||
}
|
||||
if incoming<<20 != IncomingCap {
|
||||
t.Errorf("incoming asks %v MiB, IncomingCap is %d bytes: want them equal", incoming, uint64(IncomingCap))
|
||||
}
|
||||
if _, ok := opts[TransfersState]["ttl-seconds"]; ok {
|
||||
t.Errorf("transfers has a lifetime %v: the bus would drop a transfer's key and leave its parts", opts[TransfersState])
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
"fmt"
|
||||
"sort"
|
||||
|
||||
reg "git.novox.be/novox/mesh-delivery/contracts/go/seats/containerimageregistry"
|
||||
reg "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/containerimageregistry"
|
||||
)
|
||||
|
||||
// VerbList is the seats' verb list.
|
||||
@@ -29,9 +29,6 @@ type StoreFiles interface {
|
||||
// and name not held is an empty answer. A manifest whose content cannot be read fails the answer, by name: a listing
|
||||
// missing one would read as the store not holding it.
|
||||
func (v *Verbs) List(ctx context.Context, seat, caller, name string) ([]reg.Repository, error) {
|
||||
if err := v.Callers.Allowed(VerbList, caller); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if seat != ImageSeat && seat != ArtifactSeat {
|
||||
return nil, fmt.Errorf("list: %s is no seat of distribution's", seat)
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
reg "git.novox.be/novox/mesh-delivery/contracts/go/seats/containerimageregistry"
|
||||
reg "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/containerimageregistry"
|
||||
)
|
||||
|
||||
// dg is a digest made from a short name, so a test reads as names.
|
||||
@@ -128,7 +128,7 @@ func TestListAnswersTheStoreThroughTheSeat(t *testing.T) {
|
||||
f := filesWorld()
|
||||
b := newBed(true)
|
||||
b.v.Files = &Files{Container: "mesh-registry", Run: f.run(t)}
|
||||
if err := b.v.HoldOne(ArtifactSeat, SweepHolder, HoldRequest{Name: "app/tools", Digest: dg("a-kept"), Why: "kept"}); err != nil {
|
||||
if err := b.v.HoldOne(ArtifactSeat, "novox.artifact-store-tools", HoldRequest{Name: "app/tools", Digest: dg("a-kept"), Why: "kept"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
package holder
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshbuilds"
|
||||
)
|
||||
|
||||
// Reserved is the holder's check of a publish against the builder's reservation (novox/hq design 47 §381, §478, §504): it asks the
|
||||
// seat mesh-builds' verb reserved with the publish's name and version, a package's name in its ecosystem, and accepts the publish
|
||||
// only when that version is the one reserved and an artifact of it has the publish's digest. It reads no catalog and a publish gains
|
||||
// no field. It is the Builder the holder's Check asks.
|
||||
type Reserved struct {
|
||||
// Ask is the runtime's request and reply over the bus (mesh-sdk stdio.Ask).
|
||||
Ask func(address string, body any) (json.RawMessage, error)
|
||||
}
|
||||
|
||||
// Address is the seat verb asked, as the manifest's `invokes` names it.
|
||||
const Address = "seat:" + meshbuilds.Seat + "." + meshbuilds.VerbReserved
|
||||
|
||||
// Recorded answers whether name@version with digest is reserved, and why not when it is not. An answer that cannot be read is an
|
||||
// error, never a yes; a refusal by the builder (a name no entry carries, a version it did not reserve) is a no that names it.
|
||||
func (r Reserved) Recorded(name, version, digest string) (bool, string, error) {
|
||||
raw, err := r.Ask(Address, meshbuilds.ReservedInput{Name: name, Version: version})
|
||||
if err != nil {
|
||||
return false, "", fmt.Errorf("%s did not answer for %s@%s: %w", Address, name, version, err)
|
||||
}
|
||||
var a meshbuilds.ReservedAnswer
|
||||
if err := json.Unmarshal(raw, &a); err != nil || a.Version == "" {
|
||||
return false, "", fmt.Errorf("%s answered no reservation for %s@%s: %s", Address, name, version, raw)
|
||||
}
|
||||
if a.Version != version {
|
||||
return false, fmt.Sprintf("%s reserved %s for %s, not %s", a.Module, a.Version, name, version), nil
|
||||
}
|
||||
for _, art := range a.Artifacts {
|
||||
if art.Digest == digest {
|
||||
return true, "", nil
|
||||
}
|
||||
}
|
||||
return false, fmt.Sprintf("%s reserved %s@%s for another digest than %s", a.Module, name, version, digest), nil
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package holder
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A publish is checked against the builder's reservation by the seat mesh-builds' verb reserved, asked with the package's name
|
||||
// (design 47 §381, §478).
|
||||
func reservedChecksAPublishAgainstTheReservation(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
answer string
|
||||
err error
|
||||
ok bool
|
||||
why string // part of the reason, or of the error
|
||||
}{
|
||||
{name: "the reserved version and digest", answer: `{"module":"mesh-image","version":"0.1.7","artifacts":[{"artifact":"mesh-image:image","digest":"sha256:aa"}]}`, ok: true},
|
||||
{name: "another artifact of it carries the digest", answer: `{"module":"mesh-image","version":"0.1.7","artifacts":[{"artifact":"mesh-image:aux"},{"artifact":"mesh-image:image","digest":"sha256:aa"}]}`, ok: true},
|
||||
{name: "another digest", answer: `{"module":"mesh-image","version":"0.1.7","artifacts":[{"artifact":"mesh-image:image","digest":"sha256:bb"}]}`, why: "another digest than sha256:aa"},
|
||||
{name: "another version reserved", answer: `{"module":"mesh-image","version":"0.1.8","artifacts":[]}`, why: "reserved 0.1.8 for registry/mesh-image, not 0.1.7"},
|
||||
{name: "the builder refused the name", err: errors.New("no entry carries registry/mesh-image"), why: "no entry carries registry/mesh-image"},
|
||||
{name: "an answer that is none", answer: `{"error":"x"}`, why: "answered no reservation"},
|
||||
} {
|
||||
asked := ""
|
||||
r := Reserved{Ask: func(address string, body any) (json.RawMessage, error) {
|
||||
b, _ := json.Marshal(body)
|
||||
asked = address + " " + string(b)
|
||||
return json.RawMessage(c.answer), c.err
|
||||
}}
|
||||
ok, why, err := r.Recorded("registry/mesh-image", "0.1.7", "sha256:aa")
|
||||
if asked != `seat:mesh-builds.reserved {"name":"registry/mesh-image","version":"0.1.7"}` {
|
||||
t.Fatalf("%s: asked %s", c.name, asked)
|
||||
}
|
||||
if c.ok {
|
||||
if !ok || err != nil {
|
||||
t.Errorf("%s: not accepted: %v %v", c.name, why, err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if ok || !strings.Contains(why+fmtErr(err), c.why) {
|
||||
t.Errorf("%s: ok=%v why=%q err=%v, want %q", c.name, ok, why, err, c.why)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func fmtErr(err error) string {
|
||||
if err == nil {
|
||||
return ""
|
||||
}
|
||||
return err.Error()
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
// Package holder is distribution's holding of the two image seats, `container-image-registry` and
|
||||
// `oci-artifact-registry`, and of the old seat `mesh-artifact-store` until its window closes (novox/hq design 51 *The
|
||||
// four registry seats*, ADR 0605, ADR 0611, ADR 0617 §2). A build module publishes into a seat through `publish` and
|
||||
// `publish-part`: the parts land in distribution's own object store `incoming`, are hashed, checked against the
|
||||
// builder's reservation (`reserved`) and stored in the artifact store, which listens on its node's loopback; the store's port
|
||||
// is the program's, reads open, writes from its own process alone (front.go).
|
||||
package holder
|
||||
|
||||
import (
|
||||
"sort"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
)
|
||||
|
||||
// The seats distribution holds, and the old one it holds until its window closes.
|
||||
const (
|
||||
ImageSeat = "container-image-registry"
|
||||
ArtifactSeat = "oci-artifact-registry"
|
||||
OldSeat = "mesh-artifact-store"
|
||||
)
|
||||
|
||||
// SweepSeat is the seat of the artifact sweep, whose holder alone removes an untagged version for retention.
|
||||
const SweepSeat = "mesh-artifact-sweep"
|
||||
|
||||
// The verbs of the seats (design 26 *A seat's definition*; design 51 *One contract*).
|
||||
const (
|
||||
VerbPublish = "publish"
|
||||
VerbPublishPart = "publish-part"
|
||||
VerbVersions = "versions"
|
||||
VerbRemove = "remove"
|
||||
VerbMirror = "mirror"
|
||||
VerbTags = "tags"
|
||||
VerbHold = "hold"
|
||||
VerbRelease = "release"
|
||||
)
|
||||
|
||||
// Seats are the seats distribution answers verbs on.
|
||||
var Seats = []string{ImageSeat, ArtifactSeat}
|
||||
|
||||
// VerbsOf are a seat's verbs: mirror and tags on the image seat alone; hold, release and list on both of distribution's.
|
||||
func VerbsOf(seat string) []string {
|
||||
v := []string{VerbPublish, VerbPublishPart, VerbVersions, VerbRemove, VerbHold, VerbRelease, VerbList}
|
||||
if seat == ImageSeat {
|
||||
v = append(v, VerbMirror, VerbTags)
|
||||
}
|
||||
sort.Strings(v)
|
||||
return v
|
||||
}
|
||||
|
||||
// Self is distribution's own account: never the caller of a release, so registry_holds cannot release in its own name.
|
||||
const Self = "distribution"
|
||||
|
||||
// CallerModule is the module of a proved module caller, which the runtime stamps `<node>.<module>`.
|
||||
func CallerModule(proved string) string { return stdio.ModuleOf(proved) }
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshhandactsclient"
|
||||
"git.novox.be/novox/mesh-delivery/sdk/parts"
|
||||
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-sdk/parts"
|
||||
)
|
||||
|
||||
// The bounds of incoming (design 51 *Publishing*, ADR 0611): a part is at most 1 MiB, a transfer with no part for 24
|
||||
@@ -129,7 +129,7 @@ type Objects = parts.Objects
|
||||
// TransferState is the state transfers.
|
||||
type TransferState = parts.Transfers[Opening]
|
||||
|
||||
// Builder is the builder's tool builder_versions, asked before anything is stored (ADR 0605 §4).
|
||||
// Builder is the builder's reservation (Reserved), asked before anything is stored (ADR 0605 §4, design 47 §381).
|
||||
type Builder interface {
|
||||
Recorded(name, version, digest string) (ok bool, why string, err error)
|
||||
}
|
||||
@@ -158,7 +158,6 @@ type Finding func(kind, subject, words string, raised bool)
|
||||
|
||||
// Holder answers the verbs.
|
||||
type Holder struct {
|
||||
Callers Callers
|
||||
Objects Objects
|
||||
Transfers TransferState
|
||||
Builder Builder
|
||||
@@ -196,17 +195,17 @@ func (h *Holder) receiver() *parts.Receiver[Opening] {
|
||||
ok, err := h.Store.Has(t.Shape.Name, digest)
|
||||
return err == nil && ok
|
||||
},
|
||||
// builder_versions is asked before anything is stored (ADR 0605 §4).
|
||||
// The reservation is asked before anything is stored (ADR 0605 §4, design 47 §381).
|
||||
Check: func(t Transfer) error {
|
||||
if t.Shape.Version == "" {
|
||||
return nil
|
||||
}
|
||||
ok, why, err := h.Builder.Recorded(t.Shape.Name, t.Shape.Version, t.Shape.Digest)
|
||||
if err != nil {
|
||||
return errors.New("builder_versions did not answer, so nothing is stored: " + err.Error())
|
||||
return errors.New("the reservation could not be read, so nothing is stored: " + err.Error())
|
||||
}
|
||||
if !ok {
|
||||
return errors.New("the builder did not number it: " + why)
|
||||
return errors.New("the builder did not reserve it: " + why)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
@@ -245,9 +244,6 @@ func answerOf(r parts.Received) PublishAnswer {
|
||||
|
||||
// Publish is `publish` on seat for the proved caller.
|
||||
func (h *Holder) Publish(seat, caller string, r PublishRequest) (PublishAnswer, error) {
|
||||
if err := h.Callers.Allowed(VerbPublish, caller); err != nil {
|
||||
return PublishAnswer{}, err
|
||||
}
|
||||
if err := checkPublish(seat, r); err != nil {
|
||||
return PublishAnswer{}, err
|
||||
}
|
||||
@@ -272,9 +268,6 @@ func (h *Holder) Publish(seat, caller string, r PublishRequest) (PublishAnswer,
|
||||
|
||||
// PublishPart is `publish-part`: one chunk, from the caller that opened the transfer alone.
|
||||
func (h *Holder) PublishPart(seat, caller string, r PartRequest) (PublishAnswer, error) {
|
||||
if err := h.Callers.Allowed(VerbPublishPart, caller); err != nil {
|
||||
return PublishAnswer{}, err
|
||||
}
|
||||
got, err := h.receiver().Take(r.Transfer, caller, r.Digest, r.Sequence, r.Last, r.Data, func(t Transfer) error {
|
||||
if t.Shape.Seat != seat {
|
||||
return fmt.Errorf("transfer %s is %s's, not %s's", r.Transfer, t.Shape.Seat, seat)
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
reg "git.novox.be/novox/mesh-delivery/contracts/go/seats/containerimageregistry"
|
||||
reg "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/containerimageregistry"
|
||||
)
|
||||
|
||||
// Catalog is what the store answers of what it holds.
|
||||
@@ -85,6 +85,9 @@ type Verbs struct {
|
||||
Mirrors Mirrors
|
||||
// Files are the store's own files, which list reads (ADR 0632 §3).
|
||||
Files StoreFiles
|
||||
// SweepHolder is the module holding the seat `mesh-artifact-sweep`, as the seat's holders say it now (design 47 G §7, §428);
|
||||
// a removal for retention is that module's alone.
|
||||
SweepHolder func() (string, error)
|
||||
}
|
||||
|
||||
// VersionsAnswer is `versions`: the versions the seat holds of name.
|
||||
@@ -96,9 +99,6 @@ type VersionsAnswer struct {
|
||||
|
||||
// Versions is `versions`.
|
||||
func (v *Verbs) Versions(seat, caller, name string) (VersionsAnswer, error) {
|
||||
if err := v.Callers.Allowed(VerbVersions, caller); err != nil {
|
||||
return VersionsAnswer{}, err
|
||||
}
|
||||
if strings.TrimSpace(name) == "" {
|
||||
return VersionsAnswer{}, errors.New("versions: no name")
|
||||
}
|
||||
@@ -139,16 +139,17 @@ const ReasonSharedDigest = reg.RemoveAnswerReasonSharedDigest
|
||||
// Remove is `remove`: the builder removes a tagged version; the artifact sweep's holder an untagged bundle, archive or
|
||||
// copied base with the reason retention, which a hold keeps. Every removal says `removed` with its reason.
|
||||
func (v *Verbs) Remove(seat, caller string, r RemoveRequest) (Removal, error) {
|
||||
if err := v.Callers.Allowed(VerbRemove, caller); err != nil {
|
||||
return Removal{}, err
|
||||
}
|
||||
if strings.TrimSpace(r.Name) == "" || strings.TrimSpace(r.Reason) == "" {
|
||||
return Removal{}, errors.New("remove: a name and a reason are required")
|
||||
}
|
||||
ev := Event{Name: r.Name, Version: r.Version, Kind: r.Kind, Digest: r.Digest, Holder: Self, Reason: r.Reason, By: caller}
|
||||
switch {
|
||||
case r.Reason == ReasonRetention:
|
||||
if CallerModule(caller) != SweepHolder {
|
||||
sweep, err := v.SweepHolder()
|
||||
if err != nil {
|
||||
return Removal{}, fmt.Errorf("remove: the holder of %s cannot be read, so nothing is removed for retention: %w", SweepSeat, err)
|
||||
}
|
||||
if CallerModule(caller) != sweep {
|
||||
return Removal{}, fmt.Errorf("remove: refused for %s: a removal for retention is the artifact sweep's alone", caller)
|
||||
}
|
||||
if r.Digest == "" {
|
||||
@@ -231,9 +232,6 @@ type HoldRequest struct {
|
||||
|
||||
// HoldOne is `hold`: the artifact sweep's holder alone; an image is never held (its own rules keep images).
|
||||
func (v *Verbs) HoldOne(seat, caller string, r HoldRequest) error {
|
||||
if err := v.Callers.Allowed(VerbHold, caller); err != nil {
|
||||
return err
|
||||
}
|
||||
// The seat's contract names no kind: on the image seat a hold is of a copied base (upstream/…), never an image of
|
||||
// its own; on the artifact seat of a bundle or an archive.
|
||||
if r.Kind == "" && seat == ImageSeat && strings.HasPrefix(r.Name, "upstream/") {
|
||||
@@ -261,9 +259,6 @@ func (v *Verbs) Release(seat, caller string, r HoldRequest) (bool, error) {
|
||||
if CallerModule(caller) == Self && !strings.HasPrefix(caller, "person.") {
|
||||
return false, fmt.Errorf("release: refused for %s: distribution cannot release in its own name", caller)
|
||||
}
|
||||
if err := v.Callers.Allowed(VerbRelease, caller); err != nil {
|
||||
return false, err
|
||||
}
|
||||
key := Hold{Seat: seat, Name: r.Name, Digest: r.Digest}.Key()
|
||||
h, held, err := v.Holds.Get(key)
|
||||
if err != nil {
|
||||
@@ -283,9 +278,6 @@ func (v *Verbs) Tags(seat, caller, name string) (VersionsAnswer, error) {
|
||||
if seat != ImageSeat {
|
||||
return VersionsAnswer{}, fmt.Errorf("tags: %s serves no tags", seat)
|
||||
}
|
||||
if err := v.Callers.Allowed(VerbTags, caller); err != nil {
|
||||
return VersionsAnswer{}, err
|
||||
}
|
||||
got, err := v.Catalog.Versions(name)
|
||||
if err != nil {
|
||||
return VersionsAnswer{}, err
|
||||
@@ -317,9 +309,6 @@ func (v *Verbs) Mirror(seat, caller string, r MirrorRequest) (string, error) {
|
||||
if seat != ImageSeat {
|
||||
return "", fmt.Errorf("mirror: %s serves no mirror", seat)
|
||||
}
|
||||
if err := v.Callers.Allowed(VerbMirror, caller); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if r.Source == "" || !strings.HasPrefix(r.Digest, "sha256:") {
|
||||
return "", errors.New("mirror: refused: an upstream image is copied only by its pinned digest")
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ package holder
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"git.novox.be/novox/mesh-delivery/sdk/window"
|
||||
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-sdk/window"
|
||||
"slices"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
{
|
||||
"module": "distribution",
|
||||
"version": "2",
|
||||
"packages": {
|
||||
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds": "0.1"
|
||||
},
|
||||
"version": "0.1",
|
||||
"provides": [
|
||||
{
|
||||
"name": "artifact-store",
|
||||
@@ -53,7 +50,6 @@
|
||||
}
|
||||
],
|
||||
"tools": [
|
||||
"registry_callers",
|
||||
"registry_direct_writes",
|
||||
"registry_health",
|
||||
"registry_holds",
|
||||
@@ -62,80 +58,23 @@
|
||||
"registry_old_name"
|
||||
],
|
||||
"invokes": [
|
||||
"distribution.registry_callers",
|
||||
"mesh-delivery-builder.builder_versions",
|
||||
"seat:mesh-builds.reserved",
|
||||
"mesh-delivery-view.node_haves",
|
||||
"seat:node-engine.manifest-reads",
|
||||
"seat:mesh-hand-acts.hand-act"
|
||||
"seat:mesh-hand-acts.hand-act",
|
||||
"seat:mesh-catalog.entry",
|
||||
"seat:mesh-catalog.registrations",
|
||||
"seat:mesh-catalog.revision",
|
||||
"seat:mesh-delivery.source",
|
||||
"seat:mesh-module-assigner.assignments"
|
||||
],
|
||||
"consumes": [
|
||||
"mesh-delivery-view.delivered",
|
||||
"node-engine.manifest-reads-changed"
|
||||
],
|
||||
"config": {
|
||||
"callers": {
|
||||
"type": "string",
|
||||
"trusted": true,
|
||||
"runtime": true,
|
||||
"default": "",
|
||||
"description": "per verb, the modules that may call it (JSON object of verb to modules; operator for the operator's session); empty is design 51's list"
|
||||
}
|
||||
},
|
||||
"requires": [
|
||||
{
|
||||
"name": "incoming",
|
||||
"storage": "object-store",
|
||||
"template": "objects",
|
||||
"options": {
|
||||
"max-mib": 8192
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "transfers",
|
||||
"storage": "key-value",
|
||||
"template": "state"
|
||||
},
|
||||
{
|
||||
"name": "idle",
|
||||
"storage": "key-value",
|
||||
"template": "state",
|
||||
"options": {
|
||||
"key-ttl": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "holds",
|
||||
"storage": "key-value",
|
||||
"template": "state"
|
||||
},
|
||||
{
|
||||
"name": "windows",
|
||||
"storage": "key-value",
|
||||
"template": "state"
|
||||
},
|
||||
{
|
||||
"name": "mirrors",
|
||||
"storage": "key-value",
|
||||
"template": "state"
|
||||
},
|
||||
{
|
||||
"name": "findings",
|
||||
"storage": "key-value",
|
||||
"template": "state"
|
||||
},
|
||||
{
|
||||
"name": "events",
|
||||
"storage": "stream",
|
||||
"template": "events",
|
||||
"options": {
|
||||
"events": [
|
||||
"old-name-closed",
|
||||
"direct-writes-closed"
|
||||
],
|
||||
"max-age-days": 7,
|
||||
"max-mib": 16
|
||||
}
|
||||
}
|
||||
"node-engine.manifest-reads-changed",
|
||||
"mesh-conditions.findings-asked",
|
||||
"mesh-catalog.catalog-revision",
|
||||
"mesh-catalog.registration-changed",
|
||||
"mesh-delivery.source-moved"
|
||||
],
|
||||
"capabilities": [
|
||||
"container-runtime"
|
||||
@@ -281,5 +220,43 @@
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"streams": [
|
||||
{
|
||||
"name": "distribution-findings",
|
||||
"events": [
|
||||
"finding-written.*",
|
||||
"finding-cleared.*",
|
||||
"findings-stated.*"
|
||||
],
|
||||
"max-age-days": 7,
|
||||
"max-mib": 16
|
||||
},
|
||||
{
|
||||
"name": "distribution-events",
|
||||
"events": [
|
||||
"old-name-closed",
|
||||
"direct-writes-closed"
|
||||
],
|
||||
"max-age-days": 7,
|
||||
"max-mib": 16
|
||||
}
|
||||
],
|
||||
"objects": [
|
||||
{
|
||||
"name": "incoming",
|
||||
"max-mib": 8192
|
||||
}
|
||||
],
|
||||
"state": [
|
||||
"transfers",
|
||||
{
|
||||
"name": "idle",
|
||||
"key-ttl": true
|
||||
},
|
||||
"holds",
|
||||
"windows",
|
||||
"mirrors",
|
||||
"findings"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"module": "dmenu",
|
||||
"version": "1",
|
||||
"version": "0.1",
|
||||
"capabilities": [
|
||||
"package-manager"
|
||||
],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"module": "dnsmasq",
|
||||
"version": "1",
|
||||
"version": "0.1",
|
||||
"upgrade": {
|
||||
"policy": "record",
|
||||
"why": "the mesh's resolver: a build that breaks it can stop a machine resolving the bus, and then neither the gate's rollback nor a push reaches it (hq ADR 0236, issue 260)"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"module": "docker-compose",
|
||||
"version": "1",
|
||||
"version": "0.1",
|
||||
"capabilities": [
|
||||
"package-manager"
|
||||
],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"module": "docker",
|
||||
"version": "1",
|
||||
"version": "0.1",
|
||||
"capabilities": [
|
||||
"package-manager",
|
||||
"service-manager",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"module": "dunst",
|
||||
"version": "1",
|
||||
"version": "0.1",
|
||||
"moved": true,
|
||||
"capabilities": [
|
||||
"package-manager"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"module": "fail2ban",
|
||||
"version": "1",
|
||||
"version": "0.1",
|
||||
"capabilities": [
|
||||
"firewall"
|
||||
],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"module": "feh",
|
||||
"version": "1",
|
||||
"version": "0.1",
|
||||
"moved": true,
|
||||
"capabilities": [
|
||||
"package-manager"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"module": "flatpak",
|
||||
"version": "1",
|
||||
"version": "0.1",
|
||||
"capabilities": [
|
||||
"package-manager"
|
||||
],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"module": "fonts",
|
||||
"version": "1",
|
||||
"version": "0.1",
|
||||
"capabilities": [
|
||||
"package-manager"
|
||||
],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"module": "forticlient",
|
||||
"version": "1",
|
||||
"version": "0.1",
|
||||
"upgrade": {
|
||||
"policy": "record",
|
||||
"why": "its adapter routes the company's domains on the machine a person works on: a build that breaks it cuts the person off from work names until a person pushes the next (hq ADR 0236, ADR 0247)"
|
||||
|
||||
@@ -67,8 +67,8 @@ refuses the commit that would make a branch a rule guards. A rule covers a branc
|
||||
forge itself decides it (Gitea 1.27, novox/hq issue 539): a plain name covers that branch in any case; a glob
|
||||
(`*`, `**`, `?`, `[abc]`, `[!abc]`, `{a,b}`, `\` escaping) covers case-sensitively with `/` as separator; a glob
|
||||
the forge cannot compile covers its own text. `cmd/forge/rules.go` and `guard.ts` hold this one rule. The rules are read before anything is sent; rules that cannot be read refuse the
|
||||
commit. Nothing about merging changes: a merge still needs an independent review at the head and both
|
||||
required checks green, through `gitea_merge_pull_request`.
|
||||
commit. Nothing about merging changes: a merge is the seat git's `merge-pull`, at the head the operator's
|
||||
Approve names.
|
||||
|
||||
**A patch too large to type** (novox/hq issue 852: a feature's diff is megabytes) goes as `patch_file`, with
|
||||
`patch_sha256`. The caller writes the patch into the module's spool directory `forge-patches`
|
||||
@@ -197,7 +197,7 @@ the module's own object store `incoming`, its transfers in the state `transfers`
|
||||
side), `versions` and `remove` (a tagged version, by the builder). Before anything is uploaded the holder asks the
|
||||
builder whether it numbered the version with that digest; a package goes up as npm's publish sends it, a Go module's
|
||||
zip to the Go upload. Every outcome is said on the seat's events (`published`, `refused`, `removed`), a Go package's
|
||||
with its module path. Who may call each verb is the trusted config value `callers` (empty: design 51's list).
|
||||
with its module path. Who may call each verb is the seat definition's `callers`, which the grants part grants by; the holder keeps no list.
|
||||
|
||||
## The package registry's retention (novox/hq ADR 0251 §4, to-be 51)
|
||||
|
||||
|
||||
@@ -5,13 +5,12 @@
|
||||
// the bus's message id (mesh-tools node-tools/internal/bus/bus.go, `nats.MsgId`), and the bus stores a message it has
|
||||
// seen the id of, within its de-duplication window, once.
|
||||
//
|
||||
// The SDK's `emit` takes no id, so this publishes through the broker with the headers `emit` stamps and the id given.
|
||||
// It returns to `emit` once the SDK takes an id.
|
||||
// They are the seat git's events (design 47 *The seat git*), said through the runtime's seat publish with that id.
|
||||
//
|
||||
// The ids: a merge by its merge commit, a head by the head and the trunk commit it was announced against (the check asked
|
||||
// again against a new trunk commit is another announcement), a close by its head.
|
||||
|
||||
import { broker } from "@novox/mesh-sdk/messaging";
|
||||
import { sayOnSeat } from "./seatevents.js";
|
||||
import { state as keptState } from "@novox/mesh-sdk/state";
|
||||
|
||||
type Said = { owner?: string; repo?: string; number?: number; head_sha?: string; base_sha?: string; merge_commit_sha?: string };
|
||||
@@ -32,23 +31,11 @@ export function announcementId(type: string, body: unknown): string | undefined
|
||||
}
|
||||
}
|
||||
|
||||
/** Announce a merge, a head or a close: published with its message id. */
|
||||
/** Announce a merge, a head or a close: said on the seat git (seatevents.ts) with its message id. */
|
||||
export async function announce(type: string, body: unknown): Promise<void> {
|
||||
const id = announcementId(type, body);
|
||||
if (!id) throw new Error(`${type} is not an announcement with a message id`);
|
||||
const node = process.env.MESH_NODE ?? "unknown";
|
||||
await broker().publish({
|
||||
key: type,
|
||||
node,
|
||||
body,
|
||||
headers: {
|
||||
"x-event-id": id,
|
||||
"x-source": process.env.MESH_MODULE ?? "unknown",
|
||||
"x-node": node,
|
||||
"x-time": new Date().toISOString(),
|
||||
"content-type": "application/json",
|
||||
},
|
||||
});
|
||||
if (!(await sayOnSeat(type, (body ?? {}) as Record<string, unknown>, id))) throw new Error(`the seat git says no ${type}`);
|
||||
}
|
||||
|
||||
// --- the watermark: the one value of the old files that is not the bus's to remember --------------------------------
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
// **The forge module's view of the catalog half** (novox/hq design 47 *Every part takes a first view, then follows the
|
||||
// events*, ADR 0656 §6): which repositories build a registered module is the catalog half's word, asked of the seat
|
||||
// mesh-catalog's verb `revision` through the seat's generated TypeScript contract and then kept from its event
|
||||
// `catalog-revision`, never the holder's state nor a module named. The picture is mesh-sdk's views Follower, so the
|
||||
// module's first-view tools (statusTools) read and steer it as every part's.
|
||||
//
|
||||
// The readers' rule: a candidate's revision moves nothing; a trunk revision heard that the view does not hold yet is
|
||||
// waited for (said in the status) and asked, and past mesh-sdk's WAITS_BOUND_MS the module writes view-waits; until the
|
||||
// first view is held the module acts on nothing (writes.ts grants no write).
|
||||
|
||||
import { Follower, type Answer, type Finding, type FollowerOptions, type Status } from "@novox/mesh-sdk/views";
|
||||
import {
|
||||
seat as CATALOG, verbs as CATALOG_VERBS, events as CATALOG_EVENTS, states as CATALOG_STATES,
|
||||
type CatalogEntry, type CatalogRevisionEvent, type RevisionAnswer, type RevisionInput,
|
||||
} from "@novox/mesh-delivery-contracts-mesh-catalog";
|
||||
|
||||
/** The seat verb the view asks, as the manifest's `invokes` names it. */
|
||||
export const REVISION_VERB = `seat:${CATALOG}.${CATALOG_VERBS.revision}`;
|
||||
/** The seat event the view hears, as the manifest's `consumes` names it. */
|
||||
export const CATALOG_REVISION_EVENT = `${CATALOG}.${CATALOG_EVENTS.catalogRevision}`;
|
||||
/** A trunk revision; a candidate's moves no running part (ADR 0656 §6). */
|
||||
export const TRUNK = "trunk";
|
||||
|
||||
/** A revision view is never made again: one fixed creation time lets the follower tell a revision heard from one held. */
|
||||
const CREATED = new Date(0).toISOString();
|
||||
|
||||
/** Asks one seat verb over the bus and answers what the holder said. */
|
||||
export type Ask = (verb: string, input: RevisionInput) => Promise<unknown>;
|
||||
|
||||
/** The hooks a part hands the view: everything of mesh-sdk's FollowerOptions but what the view sets itself. */
|
||||
export type CatalogHooks = Omit<FollowerOptions, "owner" | "tool" | "state" | "ask">;
|
||||
|
||||
/** A revision's answer as the view takes it: the contract leaves its revision and entries optional, the view needs both. */
|
||||
export type HeldAnswer = RevisionAnswer & Required<Pick<RevisionAnswer, "revision" | "entries">>;
|
||||
|
||||
/**
|
||||
* The revision's answer, or the holder's refusal as an error: never an empty view taken for the trunk. An answer without
|
||||
* its revision or its entries is an error the caller sees (principle 3), never a default.
|
||||
*/
|
||||
export function revisionAnswer(raw: unknown): HeldAnswer {
|
||||
let a: unknown = raw;
|
||||
if (typeof raw === "string") {
|
||||
try {
|
||||
a = JSON.parse(raw);
|
||||
} catch {
|
||||
throw new Error(`${REVISION_VERB} answered: ${raw.slice(0, 200)}`);
|
||||
}
|
||||
}
|
||||
const r = a as Partial<RevisionAnswer> & { error?: unknown };
|
||||
if (!r || typeof r !== "object" || typeof r.revision?.revision !== "number" || r.revision.revision <= 0 || !Array.isArray(r.entries)) {
|
||||
const said = r && typeof r === "object" && typeof r.error === "string" ? r.error : JSON.stringify(a ?? null).slice(0, 200);
|
||||
throw new Error(`${REVISION_VERB} answered no revision: ${said}`);
|
||||
}
|
||||
return r as HeldAnswer;
|
||||
}
|
||||
|
||||
export class CatalogView {
|
||||
readonly follower: Follower;
|
||||
/** The trunk revision whose pages are being read; 0 between asks. */
|
||||
private paging = 0;
|
||||
/** The trunk revision the view holds whole; 0 before the first view. */
|
||||
private heldRevision = 0;
|
||||
|
||||
private readonly ask: Ask;
|
||||
|
||||
constructor(ask: Ask, hooks: CatalogHooks) {
|
||||
this.ask = ask;
|
||||
this.follower = new Follower({ ...hooks, owner: CATALOG, tool: REVISION_VERB, state: CATALOG_STATES.catalogRevisions,
|
||||
ask: (after) => this.page(after) });
|
||||
}
|
||||
|
||||
/** The ask the last event began, not waited on by hear: a test awaits it. */
|
||||
asked: Promise<void> = Promise.resolve();
|
||||
|
||||
/** The trunk revision the view holds, 0 before the first view. */
|
||||
get held(): number {
|
||||
return this.heldRevision;
|
||||
}
|
||||
|
||||
/** One module's entry in the view, as the catalog half says it. */
|
||||
entry(module: string): CatalogEntry | undefined {
|
||||
return this.follower.get(module) as CatalogEntry | undefined;
|
||||
}
|
||||
|
||||
/** The Follower's ask: the trunk's newest revision, then the next pages of the same one. */
|
||||
private async page(after: string): Promise<Answer> {
|
||||
const input: RevisionInput = after && this.paging ? { after, revision: this.paging } : {};
|
||||
const a = revisionAnswer(await this.ask(REVISION_VERB, input));
|
||||
const rev = a.revision.revision;
|
||||
if (a.revision.kind !== TRUNK) throw new Error(`${REVISION_VERB} answered a ${a.revision.kind} revision for the trunk`);
|
||||
if (after && this.paging && rev !== this.paging) {
|
||||
const was = this.paging;
|
||||
this.paging = 0;
|
||||
throw new Error(`${REVISION_VERB} answered revision ${rev} for a page of revision ${was}`);
|
||||
}
|
||||
this.paging = rev;
|
||||
const values: Record<string, unknown> = {};
|
||||
for (const e of a.entries) values[e.module] = e;
|
||||
if (!a.next) {
|
||||
this.paging = 0;
|
||||
this.heldRevision = rev;
|
||||
const waits = Number(this.follower.status()["waits-for"] ?? 0);
|
||||
if (waits <= rev) await this.follower.holds(String(rev));
|
||||
}
|
||||
return { v: 1, owner: CATALOG, state: CATALOG_STATES.catalogRevisions, revision: rev, created: CREATED, values,
|
||||
...(a.next ? { next: a.next } : {}), at: new Date().toISOString() };
|
||||
}
|
||||
|
||||
/**
|
||||
* One `catalog-revision` event. A candidate's moves nothing; a trunk revision the view holds, or an older one, is passed
|
||||
* over; a newer one is waited for and asked, not waited on here. Data that is no such event is an error.
|
||||
*/
|
||||
async hear(ev: CatalogRevisionEvent | undefined): Promise<void> {
|
||||
if (!ev || typeof ev.revision !== "number" || ev.revision <= 0) {
|
||||
throw new Error(`not a ${CATALOG_REVISION_EVENT} event: ${JSON.stringify(ev ?? null).slice(0, 200)}`);
|
||||
}
|
||||
if (ev.kind !== TRUNK || ev.revision <= this.heldRevision) return;
|
||||
const held = this.heldRevision;
|
||||
// The follower keeps the newest revision heard, so an ask running when it was heard is asked once more.
|
||||
await this.follower.apply({ v: 1, owner: CATALOG, state: CATALOG_STATES.catalogRevisions, key: CATALOG_REVISION_EVENT,
|
||||
revision: ev.revision, created: CREATED, gap: true });
|
||||
await this.follower.waitFor(String(ev.revision));
|
||||
this.asked = this.follower.gap(`catalog revision ${ev.revision} said, the view holds ${held}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** The kind of a finding of a first view unanswered past its bound (ADR 0615 §6), as mesh-sdk's Go views writes it. */
|
||||
export const UNANSWERED_KIND = "first-view-unanswered";
|
||||
|
||||
/** The key of a finding about a module: scope, the id's dotted parts, and the kind, each part made safe (mesh-sdk health.Key). */
|
||||
export function findingKey(scope: string, id: string, kind: string): string {
|
||||
return [scope, ...id.split("."), kind].map((p) => p.trim().replace(/[^A-Za-z0-9_=/-]/g, "_") || "_").join(".");
|
||||
}
|
||||
|
||||
/** A finding as the condition keeper reads it from the module's state `findings`: its key and its value. */
|
||||
export function findingValue(f: Finding): { key: string; value: Record<string, unknown> } {
|
||||
const key = findingKey("module", f.id, f.kind);
|
||||
return { key, value: {
|
||||
v: 1, key, kind: f.kind, subject: { scope: "module", id: f.id }, severity: "warning", summary: f.summary, headline: f.headline,
|
||||
explanation: f.explanation, resolved: "", evidence: [{ at: f.at, said: f.summary }], source: `${f.observer}.${f.kind}`,
|
||||
observer: f.observer, at: f.at,
|
||||
} };
|
||||
}
|
||||
|
||||
/** A part's finding of a first view unanswered past its bound, from the follower's status. */
|
||||
export function unansweredFinding(part: string, s: Status, at: Date): Finding {
|
||||
const summary = `${part} has no first view of ${s.owner}'s ${s.state}: ${s.tool} has not answered since ${s.asked ?? "its start"} (${(s.error ?? "").trim()})`;
|
||||
return { observer: part, kind: UNANSWERED_KIND, id: `${part}.${s.owner}.${s.state}`, summary, headline: `${part} waits for ${s.owner}`,
|
||||
explanation: `${part} reads ${s.owner}'s ${s.state} only through ${s.tool}, and keeps asking it; it never reads the state itself. The answer ` +
|
||||
`clears this. Look at ${s.owner}'s holder: is it running and connected?`, at: at.toISOString() };
|
||||
}
|
||||
@@ -1,225 +0,0 @@
|
||||
// **mesh/check is written from the checker's owed state, read by a watch** (ADR 0521; R4e round 2 finding R2-1; finding K1
|
||||
// before it; novox/hq issue 1301; ADR 0447 §1, ADR 0503 §3: main requires mesh/check alone).
|
||||
//
|
||||
// No tool writes mesh/check: a tool is told no caller, and the console every agent session speaks through (node-tools)
|
||||
// invokes every tool, so a tool that wrote it could be called by any session to merge past the checker (the fault issue
|
||||
// 293 closed for the two statuses before it). Nor does an event: nothing in the delivery plan orders this module before the
|
||||
// forge bridge, and a durable consumer whose filters change keeps its deliver policy, so an event said before this module
|
||||
// consumed it was lost. The checker keeps what it owes the forge in its state `owed` (ADR 0484 §2), which it announces
|
||||
// readable (ADR 0495 §2-§3), and only the checker writes it. This module watches it: the watch hands over the latest value of
|
||||
// every key when it starts, whenever that is, and then every change. Each key whose act is the status mesh/check is
|
||||
// written on its head, once per revision; a revision older than one already written on the key is dropped.
|
||||
//
|
||||
// A read refused (the grant not yet composed) is said once and asked again on a bound until it holds. A forge that refuses
|
||||
// a status is asked again on a bound, until it writes or a newer revision of the key comes.
|
||||
|
||||
import type { CommitStatus } from "./client.js";
|
||||
import { bindUntilHeld, bindWait } from "./bind.js";
|
||||
import { checkName } from "./guard.js";
|
||||
import { CHECK_STATUS } from "./pulls.js";
|
||||
|
||||
/** The checker's state this module reads, and the act of its keys this module writes. */
|
||||
export const CHECKER_OWED = "mesh-delivery-checker.owed";
|
||||
export const CHECK_ACT = `status=${CHECK_STATUS}`;
|
||||
|
||||
const states = new Set(["pending", "success", "error", "failure", "warning"]);
|
||||
|
||||
/** What writing the check needs of the forge. */
|
||||
export interface CheckForge {
|
||||
setCommitStatus(owner: string, repo: string, sha: string, status: CommitStatus): Promise<void>;
|
||||
}
|
||||
|
||||
/** One change of the checker's owed, as the SDK's state watch hands it. */
|
||||
export interface OwedChange {
|
||||
key: string;
|
||||
op: "put" | "delete";
|
||||
value?: unknown;
|
||||
revision: number;
|
||||
current?: boolean;
|
||||
}
|
||||
|
||||
/** A key part as mesh-sdk go/delivery EscapeName writes it, read back: `=` and two lowercase hex digits per byte. */
|
||||
function unescapeName(part: string): string {
|
||||
if (!/^([-/_a-zA-Z0-9]|=[0-9a-f]{2})+$/.test(part)) throw new Error(`${JSON.stringify(part)} is not an escaped name`);
|
||||
return Buffer.from(part.replace(/=([0-9a-f]{2})/g, (_, h) => String.fromCharCode(parseInt(h, 16))), "latin1").toString("utf8");
|
||||
}
|
||||
|
||||
/** The pull request and head of the checker's owed key (mesh-sdk go/delivery CheckerOwedKey):
|
||||
* `<owner>/<name>.<number>.<head>.status=mesh/check`, owner and name escaped. */
|
||||
export function owedKeyOf(key: string): { owner: string; repo: string; number: number; sha: string } {
|
||||
const parts = String(key ?? "").split(".");
|
||||
if (parts.length !== 4) throw new Error(`${JSON.stringify(key)} is not an owed key, <repository>.<number>.<head>.<act>`);
|
||||
const [repository, number, sha, act] = parts;
|
||||
if (act !== CHECK_ACT) throw new Error(`${JSON.stringify(key)} owes ${JSON.stringify(act)}, not ${CHECK_ACT}`);
|
||||
const m = /^([^/]+)\/([^/]+)$/.exec(repository);
|
||||
if (!m) throw new Error(`${JSON.stringify(key)} names no <owner>/<name>`);
|
||||
if (!/^(0|[1-9][0-9]*)$/.test(number)) throw new Error(`${JSON.stringify(key)} names no pull request number`);
|
||||
if (!/^([0-9a-f]{40}|[0-9a-f]{64})$/i.test(sha)) throw new Error(`${JSON.stringify(key)} names no head's whole sha`);
|
||||
return { owner: checkName("owner", unescapeName(m[1])), repo: checkName("name", unescapeName(m[2])), number: Number(number), sha };
|
||||
}
|
||||
|
||||
/** The status an owed change asks for, checked as mesh-sdk go/delivery DecodeCheckerOwed checks it; or why it is not one. */
|
||||
export function checkStatusOf(change: OwedChange): { owner: string; repo: string; sha: string; revision: number; status: CommitStatus } {
|
||||
if (change.op !== "put") throw new Error(`a ${change.op} owes nothing`);
|
||||
const { owner, repo, sha } = owedKeyOf(change.key);
|
||||
const revision = change.revision;
|
||||
if (typeof revision !== "number" || !Number.isSafeInteger(revision) || revision < 0) {
|
||||
throw new Error(`${JSON.stringify(revision)} is not a revision`);
|
||||
}
|
||||
const v = change.value;
|
||||
if (!v || typeof v !== "object") throw new Error("the value is no owed act");
|
||||
const o = v as Record<string, unknown>;
|
||||
if (o.v !== 1) throw new Error(`the owed act's version ${JSON.stringify(o.v)} is not one this module reads`);
|
||||
if (o.act !== "status" || o.context !== CHECK_STATUS) {
|
||||
throw new Error(`the owed act is ${JSON.stringify(o.act)} ${JSON.stringify(o.context)}, not the status ${CHECK_STATUS}`);
|
||||
}
|
||||
const state = String(o.state ?? "");
|
||||
if (!states.has(state)) throw new Error(`${JSON.stringify(o.state)} is not a status the forge keeps`);
|
||||
let description = String(o.text ?? "").replace(/\s+/g, " ").trim();
|
||||
if (description.length > 140) description = description.slice(0, 139) + "…";
|
||||
const target = typeof o.target === "string" && /^https?:\/\//.test(o.target) ? o.target : undefined;
|
||||
const status: CommitStatus = { state: state as CommitStatus["state"], context: CHECK_STATUS, description,
|
||||
...(target ? { target_url: target } : {}) };
|
||||
return { owner, repo, sha, revision, status };
|
||||
}
|
||||
|
||||
/** Whether the forge's refusal will not change by asking again: it forbids the write (403) or knows no such repository
|
||||
* or commit (404), as the client words it ("Gitea API <path>: <status> ..."). */
|
||||
export function refusedForGood(err: unknown): boolean {
|
||||
return /Gitea API \S+: (403|404) /.test(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
|
||||
/** How many mesh/check writes this module has at the forge at once. */
|
||||
export const WRITES_AT_ONCE = 4;
|
||||
|
||||
/** The one writer of mesh/check: each owed revision of the checker's, in revision order per key. */
|
||||
export class CheckStatuses {
|
||||
/** The newest revision written of each key, and the newest seen; the oldest keys go past what any forge holds open. */
|
||||
private written = new Map<string, number>();
|
||||
private seen = new Map<string, number>();
|
||||
/** The turn each key's writes wait on, and the asks again still running. */
|
||||
private turns = new Map<string, Promise<unknown>>();
|
||||
private again = new Set<Promise<void>>();
|
||||
/** Writes at the forge at once, at most WRITES_AT_ONCE: a watch's start hands over every key together (issue 1726). */
|
||||
private writing = 0;
|
||||
private queued: (() => void)[] = [];
|
||||
private forge: CheckForge;
|
||||
private say: (line: string) => void;
|
||||
private sleep: (ms: number) => Promise<void>;
|
||||
|
||||
constructor(forge: CheckForge, say: (line: string) => void = (l) => console.error(l),
|
||||
sleep: (ms: number) => Promise<void> = (ms) => new Promise((r) => setTimeout(r, ms))) {
|
||||
this.forge = forge;
|
||||
this.say = say;
|
||||
this.sleep = sleep;
|
||||
}
|
||||
|
||||
/** Write the change's status: "written", or "dropped" for one that owes no mesh/check or is older than one written. A
|
||||
* forge that refuses throws. */
|
||||
async apply(change: OwedChange): Promise<"written" | "dropped"> {
|
||||
let asked;
|
||||
try {
|
||||
asked = checkStatusOf(change);
|
||||
} catch (err) {
|
||||
// A deleted key is owed no more: its revision is seen, so a write of it still asked again ends (askAgain).
|
||||
if (change?.op === "delete" && typeof change.key === "string" && Number.isInteger(change.revision)) {
|
||||
const seen = this.seen.get(change.key);
|
||||
if (seen === undefined || change.revision > seen) this.remember(this.seen, change.key, change.revision);
|
||||
return "dropped";
|
||||
}
|
||||
this.say(`[gitea] ${CHECKER_OWED} ${change?.key} at revision ${change?.revision} writes nothing: ${err instanceof Error ? err.message : err}`);
|
||||
return "dropped";
|
||||
}
|
||||
const key = change.key;
|
||||
const seen = this.seen.get(key);
|
||||
if (seen === undefined || asked.revision > seen) this.remember(this.seen, key, asked.revision);
|
||||
// One key's writes take turns, so two at once are weighed one after the other.
|
||||
const before = this.turns.get(key) ?? Promise.resolve();
|
||||
const turn = before.then(async (): Promise<"written" | "dropped"> => {
|
||||
const newest = this.written.get(key);
|
||||
if (newest !== undefined && asked.revision < newest) {
|
||||
this.say(`[gitea] ${asked.owner}/${asked.repo}@${asked.sha.slice(0, 12)}: mesh/check at revision ${asked.revision} is older than ${newest}, already written, and is dropped`);
|
||||
return "dropped";
|
||||
}
|
||||
await this.atTheForge(() => this.forge.setCommitStatus(asked.owner, asked.repo, asked.sha, asked.status));
|
||||
this.remember(this.written, key, asked.revision);
|
||||
return "written";
|
||||
});
|
||||
const settled = turn.catch(() => {});
|
||||
this.turns.set(key, settled);
|
||||
void settled.then(() => { if (this.turns.get(key) === settled) this.turns.delete(key); });
|
||||
return turn;
|
||||
}
|
||||
|
||||
/** What the watch hands over: taken at once and written in the background, or when the forge refuses, asked again on a
|
||||
* bound until it is written or a newer revision of the key was seen. Never throws: a watch's handler that throws loses
|
||||
* the change. It never waits on the forge (novox/hq issue 1726): the runtime hands a watch's current values one at a
|
||||
* time, waits for each to be taken and gives the whole set 10 s; a forge write per key made a large owed outlast that
|
||||
* bound at every attempt, so the watch never held and no verdict reached mesh/check. Each key's writes still take
|
||||
* turns in revision order (apply). */
|
||||
async take(change: OwedChange): Promise<void> {
|
||||
const run = this.apply(change).then(() => {}, (err) => this.askAgain(change, err));
|
||||
this.again.add(run);
|
||||
void run.finally(() => this.again.delete(run));
|
||||
}
|
||||
|
||||
/** Runs one forge write when fewer than WRITES_AT_ONCE are running, else after one ends. */
|
||||
private async atTheForge<T>(write: () => Promise<T>): Promise<T> {
|
||||
if (this.writing >= WRITES_AT_ONCE) await new Promise<void>((r) => this.queued.push(r));
|
||||
else this.writing++;
|
||||
try {
|
||||
return await write();
|
||||
} finally {
|
||||
const next = this.queued.shift();
|
||||
if (next) next();
|
||||
else this.writing--;
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolves when no write or ask again is running. */
|
||||
async settled(): Promise<void> {
|
||||
while (this.again.size) await Promise.all([...this.again]);
|
||||
}
|
||||
|
||||
private async askAgain(change: OwedChange, first: unknown): Promise<void> {
|
||||
let err = first;
|
||||
for (let attempt = 1; ; attempt++) {
|
||||
if (refusedForGood(err)) {
|
||||
// A repository the forge forbids or does not know answers the same at every ask (the sweep of 2026-10-30, issue
|
||||
// 774: hal and novadock, 119 asks each): said once, never asked again; a newer revision of the key asks anew.
|
||||
this.say(`[gitea] ${change.key}: mesh/check at revision ${change.revision} is refused for good (${err instanceof Error ? err.message : err}); not asked again`);
|
||||
return;
|
||||
}
|
||||
const wait = bindWait(attempt);
|
||||
this.say(`[gitea] ${change.key}: mesh/check at revision ${change.revision} could not be written (${err instanceof Error ? err.message : err}); asked again in ${wait / 1000}s`);
|
||||
await this.sleep(wait);
|
||||
const newest = this.seen.get(change.key);
|
||||
if (newest !== undefined && newest > change.revision) return;
|
||||
try {
|
||||
await this.apply(change);
|
||||
return;
|
||||
} catch (e) {
|
||||
err = e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private remember(map: Map<string, number>, key: string, revision: number): void {
|
||||
map.delete(key);
|
||||
map.set(key, revision);
|
||||
if (map.size > 4000) map.delete(map.keys().next().value as string);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Watch the checker's owed with `watch` (the SDK's `state(CHECKER_OWED).watch`), handing each change to `checks`. A watch
|
||||
* refused — the read not granted yet — is said once and asked again on a bound until it holds (bind.ts). Never rejects.
|
||||
*/
|
||||
export async function watchOwed(watch: (handler: (change: OwedChange) => Promise<void>) => Promise<void>, checks: CheckStatuses,
|
||||
sleep?: (ms: number) => Promise<void>, say: (line: string) => void = (l) => console.error(l)): Promise<void> {
|
||||
let refusals = 0;
|
||||
await bindUntilHeld(CHECKER_OWED, () => watch((change) => checks.take(change)), sleep, (line) => {
|
||||
if (/could not be bound/.test(line) && refusals++ > 0) return;
|
||||
say(line);
|
||||
});
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user