C2b: a seat verb's no responders is a transport fault when the assigner names a holder of the seat; a seat nothing holds fails at once (ADR 0664 §1, issue 1748)

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
This commit is contained in:
2026-11-09 23:12:35 +01:00
parent b9cfa4fe16
commit 89ec9429db
16 changed files with 88 additions and 9 deletions
+1
View File
@@ -3,6 +3,7 @@ module git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep
go 1.26.0
require (
git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner 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/gopackageregistryclient v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-delivery/contracts/go/clients/npmpackageregistryclient v0.0.0-00010101000000-000000000000
@@ -15,6 +15,7 @@ import (
"os"
"strings"
assigner "git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner"
imageclient "git.novox.be/novox/mesh-delivery/contracts/go/clients/containerimageregistryclient"
goclient "git.novox.be/novox/mesh-delivery/contracts/go/clients/gopackageregistryclient"
npmclient "git.novox.be/novox/mesh-delivery/contracts/go/clients/npmpackageregistryclient"
@@ -23,6 +24,7 @@ import (
goseat "git.novox.be/novox/mesh-delivery/contracts/go/seats/gopackageregistry"
npmseat "git.novox.be/novox/mesh-delivery/contracts/go/seats/npmpackageregistry"
ociseat "git.novox.be/novox/mesh-delivery/contracts/go/seats/ociartifactregistry"
sdkstep "git.novox.be/novox/mesh-delivery/sdk/buildstep"
"git.novox.be/novox/mesh-delivery/sdk/parts"
)
@@ -76,6 +78,41 @@ func SentHow(n int) func(parts.Sent) string {
// PublishParts publishes in with ps as its parts on seat, through that seat's client: in's fields are the seat's own
// (the four registry seats share the publish verb's input, design 51), the parts are written by the client.
func PublishParts(ctx context.Context, call SeatCall, seat string, in reg.PublishInput, ps []parts.Part) (parts.Sent, error) {
sent, err := publishParts(ctx, call, seat, in, ps)
return sent, HolderSilentOr(call, seat, err)
}
// AssignerHolders is the verb that tells who holds a seat: `mesh-module-assigner.assignments` with seats
// (the state seat-holders). The build modules' manifests `invokes` it.
const AssignerHolders = "seat:" + assigner.Seat + "." + assigner.VerbAssignments
// HolderSilentOr is err, or when it is a seat verb's "no responders" and the assigner names a holder of seat, a
// sdkstep.HolderSilent (a Passing: the holder's tool runner is being replaced and answers a moment later, ADR 0664 §1). The
// bus says "no responders" for a seat nothing holds too, so the words alone are no transport fault: with no holder named, or
// when the holders cannot be asked, the error stays as it is, said with why, and fails at once.
func HolderSilentOr(call SeatCall, seat string, err error) error {
if err == nil || !strings.Contains(strings.ToLower(err.Error()), "no responders") {
return err
}
raw, aerr := call(AssignerHolders, map[string]any{"seats": assigner.AssignmentsInputSeatsTrue})
if aerr != nil {
return fmt.Errorf("%w (who holds %s could not be asked: %v)", err, seat, aerr)
}
var answer struct {
Seats []assigner.SeatHolders `json:"seats"`
}
if jerr := json.Unmarshal(raw, &answer); jerr != nil {
return fmt.Errorf("%w (who holds %s was answered in what is not read: %v)", err, seat, jerr)
}
for _, sh := range answer.Seats {
if sh.Seat == seat && len(sh.Holders) > 0 {
return fmt.Errorf("%w: %w", err, &sdkstep.HolderSilent{Seat: seat, Holder: sh.Holders[0].Module, Node: sh.Holders[0].Node})
}
}
return fmt.Errorf("%w (nothing holds %s)", err, seat)
}
func publishParts(ctx context.Context, call SeatCall, seat string, in reg.PublishInput, ps []parts.Part) (parts.Sent, error) {
switch seat {
case ImageSeat:
s, err := imageclient.Publish(ctx, imageclient.Call(call), in, ps)
@@ -178,7 +178,7 @@ func (m *Module) packInto(ctx context.Context, workspace, module, name, artifact
return BuildResult{}, fmt.Errorf("packing %s: %w", artifact, err)
}
if err := m.publishArchive(ctx, name, artifact, kind, digest, archive); err != nil {
return BuildResult{}, fmt.Errorf("%s%s: %v", builds.NotStored, artifact, err)
return BuildResult{}, fmt.Errorf("%s%s: %w", builds.NotStored, artifact, err)
}
return BuildResult{Artifact: artifact, Digest: digest, Size: len(archive), Reference: artifactref.SeatReference(ArtifactSeat, name, artifact, digest)}, nil
}
@@ -82,7 +82,7 @@ func (m *Module) buildGoPackage(ctx context.Context, workspace, dir string, art
return res, nil
}
if err := m.publishOn(ctx, seat, path, "package", version, digest, transferName(path, version, digest), body); err != nil {
return BuildResult{}, fmt.Errorf("%s%s: %v", builds.NotStored, art.Name, err)
return BuildResult{}, fmt.Errorf("%s%s: %w", builds.NotStored, art.Name, err)
}
res.Reference = path + "@" + version
return res, nil
@@ -14,6 +14,7 @@ import (
"sync"
"testing"
"git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep"
registry "git.novox.be/novox/mesh-delivery/contracts/go/seats/containerimageregistry"
builds "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds"
sdkstep "git.novox.be/novox/mesh-delivery/sdk/buildstep"
@@ -139,6 +140,27 @@ func TestARefusedPushFailsTheStepNotStored(t *testing.T) {
!strings.Contains(st.Reason, "oci-artifact-registry") {
t.Errorf("no holder: the step ended %s: %q", st.State, st.Reason)
}
// ADR 0664 §1 (issue 1748): "no responders" is the same words for a seat nothing holds and for a holder whose tool runner is
// being replaced; the assigner says which. A named holder makes it a transport fault (the bus gives the ask again), none
// fails at once naming it.
for _, c := range []struct {
name, holders string
passing bool
words string
}{
{"a holder named", `{"seats":[{"seat":"oci-artifact-registry","holders":[{"module":"mesh-registry","node":"novox"}]}],"holds":1}`, true, "the holder is silent: seat oci-artifact-registry, held by mesh-registry on novox"},
{"nothing holds it", `{"seats":[{"seat":"go-package-registry","holders":[{"module":"mesh-registry","node":"novox"}]}],"holds":1}`, false, "nothing holds oci-artifact-registry"},
} {
m.Call = func(key string, _ any) (json.RawMessage, error) {
if key == buildstep.AssignerHolders {
return json.RawMessage(c.holders), nil
}
return nil, errors.New("nats: no responders available for request")
}
if _, st := runBuild(t, m, states, "tools"); st.State != sdkstep.StepFailed || st.Passing != c.passing || !strings.Contains(st.Reason, c.words) {
t.Errorf("%s: the step ended %s: %q (passing %v)", c.name, st.State, st.Reason, st.Passing)
}
}
}
// 1587-S4 (the lead's decision L9): the packed file is not left on the node once its push answered, a candidate's
+1
View File
@@ -102,6 +102,7 @@ replace git.novox.be/novox/mesh-catalog/modules/ssh-client/sshkeys => ../ssh-cli
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/go/seats/meshmoduleassigner v0.0.0-00010101000000-000000000000 // indirect
git.novox.be/novox/mesh-delivery/contracts/go/clients/gopackageregistryclient v0.0.0-00010101000000-000000000000 // indirect
git.novox.be/novox/mesh-delivery/contracts/go/clients/npmpackageregistryclient v0.0.0-00010101000000-000000000000 // indirect
git.novox.be/novox/mesh-delivery/contracts/go/clients/ociartifactregistryclient v0.0.0-00010101000000-000000000000 // indirect
+2 -1
View File
@@ -35,7 +35,8 @@
"seat:oci-artifact-registry.publish-part",
"seat:go-package-registry.publish",
"seat:go-package-registry.publish-part",
"seat:mesh-delivery-checker.input"
"seat:mesh-delivery-checker.input",
"seat:mesh-module-assigner.assignments"
],
"config": {
"capacity": {
@@ -24,7 +24,7 @@ func TestTheModuleInvokesTheImageSeatAndHoldsNoRegistry(t *testing.T) {
t.Fatal(err)
}
if want := []string{"seat:container-image-registry.publish", "seat:container-image-registry.publish-part",
"seat:container-image-registry.mirror"}; !slices.Equal(m.Invokes, want) {
"seat:container-image-registry.mirror", "seat:mesh-module-assigner.assignments"}; !slices.Equal(m.Invokes, want) {
t.Errorf("invokes = %v, want %v", m.Invokes, want)
}
// The builder's version.numbered for the late publish (ADR 0617 §1), and the seat's outcomes a copy waits on.
@@ -136,7 +136,7 @@ func (m *Module) publish(ctx context.Context, workspace, module, local string, r
Transfer: buildstep.TransferName(name, q, img.Digest)}
sent, err := buildstep.PublishFiles(ctx, m.call(), buildstep.ImageSeat, in, img.Files)
if err != nil {
return nil, fmt.Errorf("%s%s: %v", builds.NotStored, res.Artifact, err)
return nil, fmt.Errorf("%s%s: %w", builds.NotStored, res.Artifact, err)
}
m.say("published %s on %s: %d of its %d parts sent, the rest held", name, buildstep.ImageSeat, len(sent.Parts), len(img.Files))
res.Digest, res.Pushed = img.Digest, true
+1
View File
@@ -34,6 +34,7 @@ require git.novox.be/novox/mesh-catalog/modules/ssh-client/sshkeys v0.0.0-000101
replace git.novox.be/novox/mesh-catalog/modules/ssh-client/sshkeys => ../ssh-client/sshkeys
require (
git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner v0.0.0-00010101000000-000000000000 // indirect
git.novox.be/novox/mesh-delivery/contracts/go/clients/gopackageregistryclient 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/clients/npmpackageregistryclient v0.0.0-00010101000000-000000000000 // indirect
+2 -1
View File
@@ -63,7 +63,8 @@
"invokes": [
"seat:container-image-registry.publish",
"seat:container-image-registry.publish-part",
"seat:container-image-registry.mirror"
"seat:container-image-registry.mirror",
"seat:mesh-module-assigner.assignments"
],
"consumes": [
"mesh-delivery-builder.version.numbered",
@@ -168,7 +168,7 @@ func (m *Module) build(ctx context.Context, args map[string]any) (any, error) {
// Published from this process once the build ended, a candidate's as a trunk build's (ADR 0498 §1 §2; CAT-BN3): the
// step answers the seat's reference, or fails not stored. The packed bytes are never written to the node (L9).
if err := m.publishOn(ctx, buildstep.ArtifactSeat, a.module+"/"+a.Name, "bundle", "", digest, archive); err != nil {
return nil, fmt.Errorf("%s%s: %v", builds.NotStored, a.Name, err)
return nil, fmt.Errorf("%s%s: %w", builds.NotStored, a.Name, err)
}
files := []string{}
_ = filepath.WalkDir(bundleDir, func(p string, d os.DirEntry, err error) error {
@@ -551,7 +551,7 @@ func (m *Module) buildPackage(ctx context.Context, workspace, module, root, dir,
return res, nil
}
if err := m.publishOn(ctx, seat, p.Name, "package", p.Version, p.Digest, p.tgz); err != nil {
return nil, fmt.Errorf("%s%s: %v", builds.NotStored, a.Name, err)
return nil, fmt.Errorf("%s%s: %w", builds.NotStored, a.Name, err)
}
res.Reference = p.Name + "@" + p.Version
return res, nil
@@ -17,6 +17,7 @@ import (
"git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep"
builds "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds"
sdkstep "git.novox.be/novox/mesh-delivery/sdk/buildstep"
)
// pushed are the blobs the fake holder took, by digest, for the tests that read an archive back.
@@ -130,6 +131,18 @@ func TestARefusedPushFailsNotStoredAndNoPackedFileStays(t *testing.T) {
if c.words != "" && (err == nil || !strings.HasPrefix(err.Error(), builds.NotStored+"tools: ") || !strings.Contains(err.Error(), c.words)) {
t.Errorf("a refused publish answered %v", err)
}
if c.noSeat {
// ADR 0664 §1: with a holder named by the assigner the same words are a transport fault, the bus gives the ask again.
m.Call = func(key string, _ any) (json.RawMessage, error) {
if key == buildstep.AssignerHolders {
return json.RawMessage(`{"seats":[{"seat":"oci-artifact-registry","holders":[{"module":"mesh-registry","node":"novox"}]}],"holds":1}`), nil
}
return nil, errors.New("nats: no responders available for request")
}
if _, err := m.build(context.Background(), buildArgs()); err == nil || !sdkstep.IsPassing(err) || !strings.Contains(err.Error(), "the holder is silent") {
t.Errorf("no responders with a holder named answered %v, want a transport fault", err)
}
}
var archives []string
filepath.WalkDir(work, func(p string, d os.DirEntry, err error) error {
if err == nil && strings.HasSuffix(p, ".tar.gz") {
+1
View File
@@ -94,6 +94,7 @@ require git.novox.be/novox/mesh-catalog/modules/ssh-client/sshkeys v0.0.0-000101
replace git.novox.be/novox/mesh-catalog/modules/ssh-client/sshkeys => ../ssh-client/sshkeys
require (
git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner v0.0.0-00010101000000-000000000000 // indirect
git.novox.be/novox/mesh-delivery/contracts/go/clients/gopackageregistryclient 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/clients/npmpackageregistryclient v0.0.0-00010101000000-000000000000 // indirect
+2 -1
View File
@@ -76,7 +76,8 @@
"seat:oci-artifact-registry.publish",
"seat:oci-artifact-registry.publish-part",
"seat:npm-package-registry.publish",
"seat:npm-package-registry.publish-part"
"seat:npm-package-registry.publish-part",
"seat:mesh-module-assigner.assignments"
],
"own-secrets": {
"broker": "${dir:state}/broker"