From 247c422fbfb6a3c9b555d23cf4a73133b1648e27 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 04:56:25 +0100 Subject: [PATCH 1/4] BG9, BN9, BI3: the build seats' steps with drop writes the hand-act log first MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A drop of a kept detected-steps key records the act with the caller the bus proved, then deletes the key; a log that refuses deletes nothing and is answered error; a drop without why is refused; fetch is asked unless drop. Builds: design 47 G §463, §480 Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h --- .../mesh-build-go/buildstep/dropdetected.go | 35 ++++++++++++++++++ .../cmd/mesh-build-go/handact.go | 13 +++++++ .../mesh-build-go/cmd/mesh-build-go/main.go | 2 + .../mesh-build-go/cmd/mesh-build-go/module.go | 4 ++ .../mesh-build-go/cmd/mesh-build-go/steps.go | 33 +++++++++++++++-- .../cmd/mesh-build-go/steps_test.go | 32 ++++++++++++++++ modules/mesh-build-go/go.mod | 2 + modules/mesh-build-go/module.json | 1 + .../cmd/mesh-build-image/asks.go | 2 +- .../cmd/mesh-build-image/compose_test.go | 2 +- .../cmd/mesh-build-image/handact.go | 13 +++++++ .../cmd/mesh-build-image/main.go | 1 + .../cmd/mesh-build-image/module.go | 2 + .../cmd/mesh-build-image/steps.go | 30 +++++++++++++-- .../cmd/mesh-build-image/steps_test.go | 26 ++++++++++++- modules/mesh-build-image/go.mod | 2 + modules/mesh-build-image/module.json | 1 + .../cmd/mesh-build-node/handact.go | 13 +++++++ .../cmd/mesh-build-node/main.go | 2 + .../cmd/mesh-build-node/module.go | 4 ++ .../cmd/mesh-build-node/steps.go | 37 +++++++++++++++++-- .../cmd/mesh-build-node/steps_test.go | 26 +++++++++++++ modules/mesh-build-node/go.mod | 2 + modules/mesh-build-node/module.json | 1 + 24 files changed, 272 insertions(+), 14 deletions(-) create mode 100644 modules/mesh-build-go/buildstep/dropdetected.go create mode 100644 modules/mesh-build-go/cmd/mesh-build-go/handact.go create mode 100644 modules/mesh-build-image/cmd/mesh-build-image/handact.go create mode 100644 modules/mesh-build-node/cmd/mesh-build-node/handact.go diff --git a/modules/mesh-build-go/buildstep/dropdetected.go b/modules/mesh-build-go/buildstep/dropdetected.go new file mode 100644 index 000000000..986fe78a2 --- /dev/null +++ b/modules/mesh-build-go/buildstep/dropdetected.go @@ -0,0 +1,35 @@ +package buildstep + +import ( + "errors" + "fmt" + "strings" +) + +// DropDetected is the drop of the build seats' `steps` (design 47 G §463, §480): a person's word deletes the kept key of +// an artifact's detected steps. The act is written in the hand-act log first, with the caller the bus proved; a drop +// whose log write fails deletes nothing and is answered by the text it returns, which names the log. A drop with no +// `why`, or whose call the runtime proved no caller for, is refused (an error, nothing recorded). +// +// record writes the act in the hand-act log of the seat mesh-hand-acts; forget deletes the key (nil where the seat keeps +// none, as an image's). +func DropDetected(artifact, why, caller, key string, record func(what, why string) error, forget func(key string) error) (string, error) { + switch { + case strings.TrimSpace(why) == "": + return "", errors.New("steps: a drop says why") + case caller == "" || caller == "unproven": + return "", errors.New("steps: a drop names who drops it, and the runtime proved no caller") + } + if record == nil { + return "the hand-act log is not served here: nothing was deleted", nil + } + if err := record(fmt.Sprintf("dropped the detected steps of %s (by %s)", artifact, caller), why); err != nil { + return fmt.Sprintf("the hand-act log refused the drop of %s, so nothing was deleted: %v", artifact, err), nil + } + if forget != nil { + if err := forget(key); err != nil { + return fmt.Sprintf("the drop of %s was recorded in the hand-act log and the key could not be deleted: %v", artifact, err), nil + } + } + return "", nil +} diff --git a/modules/mesh-build-go/cmd/mesh-build-go/handact.go b/modules/mesh-build-go/cmd/mesh-build-go/handact.go new file mode 100644 index 000000000..2903d3b6a --- /dev/null +++ b/modules/mesh-build-go/cmd/mesh-build-go/handact.go @@ -0,0 +1,13 @@ +package main + +import ( + "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshhandactsclient" + stdio "git.novox.be/novox/mesh-sdk/go" +) + +// handActRecorder writes an act by hand in the hand-act log of the seat mesh-hand-acts (design 47 G §480); a log that +// does not take it is an error, never a quiet success. +func handActRecorder(what, why string) error { + _, err := meshhandactsclient.Recorder{Ask: stdio.Ask}.Record(meshhandactsclient.Request{What: what, Why: why, Cause: "build-steps-drop", Verb: "steps drop"}) + return err +} diff --git a/modules/mesh-build-go/cmd/mesh-build-go/main.go b/modules/mesh-build-go/cmd/mesh-build-go/main.go index 8817fc799..edbf844b3 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/main.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/main.go @@ -44,6 +44,8 @@ func main() { Steps: stdio.State(seat.StateSteps).Put, Detected: stdio.State(seat.StateDetectedSteps).Put, ReadDetected: readOf(stdio.State(seat.StateDetectedSteps)).Get, + ForgetDetected: stdio.State(seat.StateDetectedSteps).Delete, + RecordAct: handActRecorder, ReadSteps: readOf(stdio.State(seat.StateSteps)), Pauses: stdio.State(sdkstep.PausesState).Put, ReadPauses: readOf(stdio.State(sdkstep.PausesState)), diff --git a/modules/mesh-build-go/cmd/mesh-build-go/module.go b/modules/mesh-build-go/cmd/mesh-build-go/module.go index 26632aed3..5f0f36909 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/module.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/module.go @@ -37,6 +37,10 @@ type Module struct { // answer of the verb steps, one key per artifact. Detected buildstep.Put ReadDetected func(key string) (json.RawMessage, error) + // ForgetDetected deletes one key of detected-steps; RecordAct writes an act in the hand-act log of the seat + // mesh-hand-acts (§463, §480): a drop writes it first. + ForgetDetected func(key string) error + RecordAct func(what, why string) error // Pauses is the Put of the pauses state, which keeps the node's pause key without an age; ReadPauses reads it back. Pauses buildstep.Put ReadPauses StateRead diff --git a/modules/mesh-build-go/cmd/mesh-build-go/steps.go b/modules/mesh-build-go/cmd/mesh-build-go/steps.go index 7745cc695..7d9ddde4a 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/steps.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/steps.go @@ -130,6 +130,9 @@ type StepsAnswer struct { Fingerprint string `json:"fingerprint,omitempty"` Steps []DetectedStep `json:"steps"` Kept bool `json:"kept,omitempty"` + // Dropped is the artifact whose key a drop deleted; Error why a drop deleted nothing, naming the hand-act log. + Dropped string `json:"dropped,omitempty"` + Error string `json:"error,omitempty"` } // stepsTool is the seat's verb `steps`: it fetches the artifact's commit into a workspace of its own, only the tree (no @@ -138,26 +141,31 @@ func (m *Module) stepsTool() stdio.Tool { return stdio.Tool{Name: Seat + "." + seat.VerbSteps, Description: "Which pipeline steps a Go artifact can run, found by Go's convention: lint always, unit tests where a package holds a test file, " + "integration tests where one holds a test file built with the tag integration. A level with no test is not answered.", - Input: map[string]any{"type": "object", "required": []string{"module", "artifact", "fetch"}, "properties": map[string]any{ + Input: map[string]any{"type": "object", "required": []string{"module", "artifact"}, "properties": map[string]any{ "module": map[string]any{"type": "string"}, "artifact": map[string]any{"type": "string", "description": "the artifact reference, :"}, "fingerprint": map[string]any{"type": "string"}, "fetch": map[string]any{"type": "object", "description": "what the fetch tool is asked: the repository, the full commit and the module's directory"}, "again": map[string]any{"type": "boolean", "description": "find them anew, not from the kept answer"}, + "drop": map[string]any{"type": "boolean", "description": "delete the artifact's kept key instead of detecting; needs why (written in the hand-act log first)"}, + "why": map[string]any{"type": "string", "description": "with drop, why: a non-empty text"}, }}, Run: m.Short(m.steps)} } func (m *Module) steps(ctx context.Context, args map[string]any) (any, error) { str := func(k string) string { s, _ := args[k].(string); return s } + if str("artifact") == "" || str("module") == "" { + return nil, fmt.Errorf("steps: the module and the artifact are asked") + } + if drop, _ := args["drop"].(bool); drop { + return m.dropDetected(args, stdio.CallerOf(args)) + } fetch, _ := args["fetch"].(map[string]any) req, err := sdkstep.ParseFetchRequest(fetch) if err != nil { return nil, err } - if str("artifact") == "" || str("module") == "" { - return nil, fmt.Errorf("steps: the module and the artifact are asked") - } artifact, fp := str("artifact"), str("fingerprint") key := seat.DetectedStepsByModuleArtifactKey(str("module"), artifactName(artifact)) // The kept answer is the artifact's newest fingerprint's: used for the same fingerprint, replaced by a newer one @@ -181,6 +189,23 @@ func (m *Module) steps(ctx context.Context, args map[string]any) (any, error) { return StepsAnswer{Artifact: artifact, Fingerprint: fp, Steps: steps}, nil } +// dropDetected is `steps` with drop (§463, §480): the act is written in the hand-act log with the caller the bus proved, +// then the kept key is deleted; a log that refuses leaves the key. +func (m *Module) dropDetected(args map[string]any, caller string) (any, error) { + artifact, _ := args["artifact"].(string) + why, _ := args["why"].(string) + module, _ := args["module"].(string) + key := seat.DetectedStepsByModuleArtifactKey(module, artifactName(artifact)) + failed, err := buildstep.DropDetected(artifact, why, caller, key, m.RecordAct, m.ForgetDetected) + if err != nil { + return nil, err + } + if failed != "" { + return StepsAnswer{Artifact: artifact, Error: failed}, nil + } + return StepsAnswer{Artifact: artifact, Dropped: artifact}, nil +} + // artifactName is the artifact of an artifact reference :. func artifactName(ref string) string { if _, name, ok := strings.Cut(ref, ":"); ok { diff --git a/modules/mesh-build-go/cmd/mesh-build-go/steps_test.go b/modules/mesh-build-go/cmd/mesh-build-go/steps_test.go index b60021fff..3113c4efb 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/steps_test.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/steps_test.go @@ -3,8 +3,10 @@ package main import ( "context" "encoding/json" + "errors" "git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep" "reflect" + "strings" "testing" ) @@ -87,3 +89,33 @@ func TestTheStepsVerbKeepsItsAnswerByFingerprint(t *testing.T) { t.Fatalf("newer fingerprint: %+v", a) } } + +// Design 47 G §463, §480 (BG9): steps with drop writes the hand-act log first, then deletes the key; a log that +// refuses deletes nothing; a drop without why, or with no caller the bus proved, is refused; fetch is not asked. +func TestTheStepsDropWritesTheHandActLogFirst(t *testing.T) { + var order []string + kept := map[string]bool{"mod.prog": true} + m := &Module{ + ForgetDetected: func(key string) error { order = append(order, "delete "+key); delete(kept, key); return nil }, + RecordAct: func(what, why string) error { order = append(order, "log "+why); return nil }, + } + drop := func(why, caller string) (any, error) { + return m.dropDetected(map[string]any{"module": "mod", "artifact": "mod:prog", "drop": true, "why": why}, caller) + } + got, err := drop("a stale answer", "shanks.person") + if a, _ := got.(StepsAnswer); err != nil || a.Dropped != "mod:prog" || kept["mod.prog"] || !reflect.DeepEqual(order, []string{"log a stale answer", "delete mod.prog"}) { + t.Fatalf("drop = %+v, %v, order %v, kept %v", got, err, order, kept) + } + if _, err := drop("", "shanks.person"); err == nil { + t.Error("a drop without why was taken") + } + if _, err := drop("why", "unproven"); err == nil { + t.Error("a drop with no proven caller was taken") + } + kept["mod.prog"], order = true, nil + m.RecordAct = func(string, string) error { return errors.New("log down") } + got, err = drop("again", "shanks.person") + if a, _ := got.(StepsAnswer); err != nil || a.Dropped != "" || !strings.Contains(a.Error, "hand-act log") || !kept["mod.prog"] || len(order) != 0 { + t.Fatalf("a refusing log: %+v, %v, kept %v, order %v", got, err, kept, order) + } +} diff --git a/modules/mesh-build-go/go.mod b/modules/mesh-build-go/go.mod index a3591514d..13a7cc1a1 100644 --- a/modules/mesh-build-go/go.mod +++ b/modules/mesh-build-go/go.mod @@ -3,6 +3,8 @@ module meshbuildgo go 1.26.0 require ( + 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/meshhandacts v0.0.0-00010101000000-000000000000 git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep 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-delivery/contracts/go/clients/containerimageregistryclient v0.0.0-00010101000000-000000000000 diff --git a/modules/mesh-build-go/module.json b/modules/mesh-build-go/module.json index 19c0890ae..fa30c8470 100644 --- a/modules/mesh-build-go/module.json +++ b/modules/mesh-build-go/module.json @@ -39,6 +39,7 @@ "mesh-conditions.findings-asked" ], "invokes": [ + "seat:mesh-hand-acts.hand-act", "mesh-delivery-indexer.indexer_read_manifests", "seat:oci-artifact-registry.publish", "seat:oci-artifact-registry.publish-part", diff --git a/modules/mesh-build-image/cmd/mesh-build-image/asks.go b/modules/mesh-build-image/cmd/mesh-build-image/asks.go index 38a1bda81..141c550b2 100644 --- a/modules/mesh-build-image/cmd/mesh-build-image/asks.go +++ b/modules/mesh-build-image/cmd/mesh-build-image/asks.go @@ -77,7 +77,7 @@ func (m *Module) Puller(steps []StepTool, source buildstep.AskSource) *buildstep // SeatTools are the seat's verbs on this node (ADR 0525 §10). func (m *Module) SeatTools(p *buildstep.Puller) []stdio.Tool { - return append(buildstep.SeatTools(Seat, "image", p), stepsTool(), m.keptArtifactVerb()) + return append(buildstep.SeatTools(Seat, "image", p), m.stepsTool(), m.keptArtifactVerb()) } // StepEndTool ends one step left running (issue 1890); the module's steps state is the one it reads and writes. diff --git a/modules/mesh-build-image/cmd/mesh-build-image/compose_test.go b/modules/mesh-build-image/cmd/mesh-build-image/compose_test.go index e952b09b5..72a70ec14 100644 --- a/modules/mesh-build-image/cmd/mesh-build-image/compose_test.go +++ b/modules/mesh-build-image/cmd/mesh-build-image/compose_test.go @@ -23,7 +23,7 @@ func TestTheModuleInvokesTheImageSeatAndHoldsNoRegistry(t *testing.T) { if err != nil || json.Unmarshal(raw, &m) != nil { t.Fatal(err) } - if want := []string{"seat:container-image-registry.publish", "seat:container-image-registry.publish-part", + if want := []string{"seat:mesh-hand-acts.hand-act", "seat:container-image-registry.publish", "seat:container-image-registry.publish-part", "seat:container-image-registry.mirror", "seat:mesh-module-assigner.assignments", "seat:mesh-catalog.module"}; !slices.Equal(m.Invokes, want) { t.Errorf("invokes = %v, want %v", m.Invokes, want) } diff --git a/modules/mesh-build-image/cmd/mesh-build-image/handact.go b/modules/mesh-build-image/cmd/mesh-build-image/handact.go new file mode 100644 index 000000000..2903d3b6a --- /dev/null +++ b/modules/mesh-build-image/cmd/mesh-build-image/handact.go @@ -0,0 +1,13 @@ +package main + +import ( + "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshhandactsclient" + stdio "git.novox.be/novox/mesh-sdk/go" +) + +// handActRecorder writes an act by hand in the hand-act log of the seat mesh-hand-acts (design 47 G §480); a log that +// does not take it is an error, never a quiet success. +func handActRecorder(what, why string) error { + _, err := meshhandactsclient.Recorder{Ask: stdio.Ask}.Record(meshhandactsclient.Request{What: what, Why: why, Cause: "build-steps-drop", Verb: "steps drop"}) + return err +} diff --git a/modules/mesh-build-image/cmd/mesh-build-image/main.go b/modules/mesh-build-image/cmd/mesh-build-image/main.go index 13224f1b2..f8b4c2e9c 100644 --- a/modules/mesh-build-image/cmd/mesh-build-image/main.go +++ b/modules/mesh-build-image/cmd/mesh-build-image/main.go @@ -33,6 +33,7 @@ func main() { ctx := context.Background() m := &Module{ Node: strings.TrimSpace(os.Getenv("MESH_NODE")), + RecordAct: handActRecorder, Steps: stdio.State(seat.StateSteps).Put, ReadSteps: readOf(stdio.State(seat.StateSteps)), Pauses: stdio.State(sdkstep.PausesState).Put, diff --git a/modules/mesh-build-image/cmd/mesh-build-image/module.go b/modules/mesh-build-image/cmd/mesh-build-image/module.go index 73558e4e1..3392d7ff6 100644 --- a/modules/mesh-build-image/cmd/mesh-build-image/module.go +++ b/modules/mesh-build-image/cmd/mesh-build-image/module.go @@ -21,6 +21,8 @@ const CheckRound = 10 * time.Minute // Module is the image build module's one running state. type Module struct { Node string + // RecordAct writes an act in the hand-act log of the seat mesh-hand-acts (§463, §480): a drop writes it first. + RecordAct func(what, why string) error // Account and UID are the account the module runs as; Home is its home. Account, UID, Home string // Steps is the Put of the steps state; ReadSteps reads it back for the read tools. diff --git a/modules/mesh-build-image/cmd/mesh-build-image/steps.go b/modules/mesh-build-image/cmd/mesh-build-image/steps.go index e62ff9364..555b5ea6f 100644 --- a/modules/mesh-build-image/cmd/mesh-build-image/steps.go +++ b/modules/mesh-build-image/cmd/mesh-build-image/steps.go @@ -7,6 +7,7 @@ package main import ( "fmt" + "git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep" seat "git.novox.be/novox/mesh-delivery/contracts/go/seats/nodebuildimage" stdio "git.novox.be/novox/mesh-sdk/go" ) @@ -16,10 +17,13 @@ type StepsAnswer struct { Artifact string `json:"artifact"` Fingerprint string `json:"fingerprint,omitempty"` Steps []seat.DetectedStep `json:"steps"` + // Dropped is the artifact a drop was recorded for; Error why a drop deleted nothing, naming the hand-act log. + Dropped string `json:"dropped,omitempty"` + Error string `json:"error,omitempty"` } // stepsTool is the seat's verb `steps`. -func stepsTool() stdio.Tool { +func (m *Module) stepsTool() stdio.Tool { return stdio.Tool{Name: Seat + "." + seat.VerbSteps, Description: "Which pipeline steps an image artifact can run: none (an image has no lint, unit or integration test step).", Input: map[string]any{"type": "object", "required": []string{"module", "artifact"}, "properties": map[string]any{ @@ -28,14 +32,34 @@ func stepsTool() stdio.Tool { "fingerprint": map[string]any{"type": "string"}, "fetch": map[string]any{"type": "object", "description": "not read: an image's steps need no tree"}, "again": map[string]any{"type": "boolean", "description": "not read: the answer is the same anew"}, + "drop": map[string]any{"type": "boolean", "description": "delete the artifact's kept key instead (an image keeps none); needs why, written in the hand-act log first"}, + "why": map[string]any{"type": "string", "description": "with drop, why: a non-empty text"}, }}, - Run: steps} + Run: m.steps} } -func steps(args map[string]any) (any, error) { +func (m *Module) steps(args map[string]any) (any, error) { str := func(k string) string { s, _ := args[k].(string); return s } if str("artifact") == "" || str("module") == "" { return nil, fmt.Errorf("steps: the module and the artifact are asked") } + if drop, _ := args["drop"].(bool); drop { + return m.dropDetected(args, stdio.CallerOf(args)) + } return StepsAnswer{Artifact: str("artifact"), Fingerprint: str("fingerprint"), Steps: []seat.DetectedStep{}}, nil } + +// dropDetected is `steps` with drop (§463, §480): the act is written in the hand-act log with the caller the bus proved; +// an image keeps no detected steps, so there is no key to delete. +func (m *Module) dropDetected(args map[string]any, caller string) (any, error) { + artifact, _ := args["artifact"].(string) + why, _ := args["why"].(string) + failed, err := buildstep.DropDetected(artifact, why, caller, "", m.RecordAct, nil) + if err != nil { + return nil, err + } + if failed != "" { + return StepsAnswer{Artifact: artifact, Error: failed}, nil + } + return StepsAnswer{Artifact: artifact, Dropped: artifact}, nil +} diff --git a/modules/mesh-build-image/cmd/mesh-build-image/steps_test.go b/modules/mesh-build-image/cmd/mesh-build-image/steps_test.go index b24fea3e4..3c1f6dc65 100644 --- a/modules/mesh-build-image/cmd/mesh-build-image/steps_test.go +++ b/modules/mesh-build-image/cmd/mesh-build-image/steps_test.go @@ -1,6 +1,10 @@ package main -import "testing" +import ( + "errors" + "strings" + "testing" +) // Design 47 G §22, §328 (BLD8): the verb steps is served and answers none for an image. func TestTheStepsVerbAnswersNoStepForAnImage(t *testing.T) { @@ -22,3 +26,23 @@ func TestTheStepsVerbAnswersNoStepForAnImage(t *testing.T) { t.Fatal("the module serves no verb steps") } } + +// Design 47 G §463, §480 (BI3): steps with drop writes the hand-act log; a log that refuses answers error; a drop +// without why is refused. +func TestTheStepsDropWritesTheHandActLog(t *testing.T) { + var logged []string + m := &Module{RecordAct: func(what, why string) error { logged = append(logged, why); return nil }} + args := map[string]any{"module": "m", "artifact": "m:server", "drop": true, "why": "stale"} + got, err := m.dropDetected(args, "shanks.person") + if a, _ := got.(StepsAnswer); err != nil || a.Dropped != "m:server" || len(logged) != 1 { + t.Fatalf("drop = %+v, %v, logged %v", got, err, logged) + } + if _, err := m.dropDetected(map[string]any{"module": "m", "artifact": "m:server", "drop": true}, "shanks.person"); err == nil { + t.Error("a drop without why was taken") + } + m.RecordAct = func(string, string) error { return errors.New("log down") } + got, _ = m.dropDetected(args, "shanks.person") + if a, _ := got.(StepsAnswer); !strings.Contains(a.Error, "hand-act log") || a.Dropped != "" { + t.Fatalf("a refusing log: %+v", got) + } +} diff --git a/modules/mesh-build-image/go.mod b/modules/mesh-build-image/go.mod index bdd7b9dea..2779db400 100644 --- a/modules/mesh-build-image/go.mod +++ b/modules/mesh-build-image/go.mod @@ -3,6 +3,8 @@ module meshbuildimage go 1.26.0 require ( + 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/meshhandacts v0.0.0-00010101000000-000000000000 git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep 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 diff --git a/modules/mesh-build-image/module.json b/modules/mesh-build-image/module.json index 4cfac839a..7fefa26e9 100644 --- a/modules/mesh-build-image/module.json +++ b/modules/mesh-build-image/module.json @@ -62,6 +62,7 @@ "node-tools" ], "invokes": [ + "seat:mesh-hand-acts.hand-act", "seat:container-image-registry.publish", "seat:container-image-registry.publish-part", "seat:container-image-registry.mirror", diff --git a/modules/mesh-build-node/cmd/mesh-build-node/handact.go b/modules/mesh-build-node/cmd/mesh-build-node/handact.go new file mode 100644 index 000000000..2903d3b6a --- /dev/null +++ b/modules/mesh-build-node/cmd/mesh-build-node/handact.go @@ -0,0 +1,13 @@ +package main + +import ( + "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshhandactsclient" + stdio "git.novox.be/novox/mesh-sdk/go" +) + +// handActRecorder writes an act by hand in the hand-act log of the seat mesh-hand-acts (design 47 G §480); a log that +// does not take it is an error, never a quiet success. +func handActRecorder(what, why string) error { + _, err := meshhandactsclient.Recorder{Ask: stdio.Ask}.Record(meshhandactsclient.Request{What: what, Why: why, Cause: "build-steps-drop", Verb: "steps drop"}) + return err +} diff --git a/modules/mesh-build-node/cmd/mesh-build-node/main.go b/modules/mesh-build-node/cmd/mesh-build-node/main.go index ad83cc12f..c1abbf75f 100644 --- a/modules/mesh-build-node/cmd/mesh-build-node/main.go +++ b/modules/mesh-build-node/cmd/mesh-build-node/main.go @@ -50,6 +50,8 @@ func main() { ReadSteps: readOf(stdio.State(seat.StateSteps)), Detected: stdio.State(seat.StateDetectedSteps).Put, ReadDetected: readOf(stdio.State(seat.StateDetectedSteps)).Get, + ForgetDetected: stdio.State(seat.StateDetectedSteps).Delete, + RecordAct: handActRecorder, Pauses: stdio.State(sdkstep.PausesState).Put, ReadPauses: readOf(stdio.State(sdkstep.PausesState)), ForgetPause: stdio.State(sdkstep.PausesState).Delete, diff --git a/modules/mesh-build-node/cmd/mesh-build-node/module.go b/modules/mesh-build-node/cmd/mesh-build-node/module.go index f87c17b98..8f1aea066 100644 --- a/modules/mesh-build-node/cmd/mesh-build-node/module.go +++ b/modules/mesh-build-node/cmd/mesh-build-node/module.go @@ -51,6 +51,10 @@ type Module struct { // Detected and ReadDetected are the Put and the read of the state detected-steps (design 47 G §326, §414). Detected buildstep.Put ReadDetected func(key string) (json.RawMessage, error) + // ForgetDetected deletes one key of detected-steps; RecordAct writes an act in the hand-act log of the seat + // mesh-hand-acts (§463, §480): a drop writes it first. + ForgetDetected func(key string) error + RecordAct func(what, why string) error // Pauses is the Put of the pauses state, which keeps the node's pause key without an age; ReadPauses reads it back. Pauses buildstep.Put ReadPauses StateRead diff --git a/modules/mesh-build-node/cmd/mesh-build-node/steps.go b/modules/mesh-build-node/cmd/mesh-build-node/steps.go index 0f109953f..ca51adb3c 100644 --- a/modules/mesh-build-node/cmd/mesh-build-node/steps.go +++ b/modules/mesh-build-node/cmd/mesh-build-node/steps.go @@ -38,6 +38,9 @@ type StepsAnswer struct { Fingerprint string `json:"fingerprint,omitempty"` Steps []DetectedStep `json:"steps"` Kept bool `json:"kept,omitempty"` + // Dropped is the artifact whose key a drop deleted; Error why a drop deleted nothing, naming the hand-act log. + Dropped string `json:"dropped,omitempty"` + Error string `json:"error,omitempty"` } // detectSteps finds the steps of the package directories under dir. @@ -77,26 +80,31 @@ func (m *Module) stepsTool() stdio.Tool { return stdio.Tool{Name: Seat + "." + seat.VerbSteps, Description: "Which pipeline steps a TypeScript artifact can run: the type-check as lint, always; unit tests where a package has a test script; " + "integration tests where one has a test:integration script. A level with no test is not answered.", - Input: map[string]any{"type": "object", "required": []string{"module", "artifact", "fetch"}, "properties": map[string]any{ + Input: map[string]any{"type": "object", "required": []string{"module", "artifact"}, "properties": map[string]any{ "module": map[string]any{"type": "string"}, "artifact": map[string]any{"type": "string", "description": "the artifact reference, :"}, "fingerprint": map[string]any{"type": "string"}, "fetch": map[string]any{"type": "object", "description": "what the fetch tool is asked: the repository, the full commit and the module's directory"}, "again": map[string]any{"type": "boolean", "description": "find them anew, not from the kept answer"}, + "drop": map[string]any{"type": "boolean", "description": "delete the artifact's kept key instead of detecting; needs why (written in the hand-act log first)"}, + "why": map[string]any{"type": "string", "description": "with drop, why: a non-empty text"}, }}, Run: m.Short(m.steps)} } func (m *Module) steps(ctx context.Context, args map[string]any) (any, error) { str := func(k string) string { s, _ := args[k].(string); return s } + if str("artifact") == "" || str("module") == "" { + return nil, fmt.Errorf("steps: the module and the artifact are asked") + } + if drop, _ := args["drop"].(bool); drop { + return m.dropDetected(args, stdio.CallerOf(args)) + } fetch, _ := args["fetch"].(map[string]any) req, err := sdkstep.ParseFetchRequest(fetch) if err != nil { return nil, err } - if str("artifact") == "" || str("module") == "" { - return nil, fmt.Errorf("steps: the module and the artifact are asked") - } artifact, fp := str("artifact"), str("fingerprint") name := artifact if _, n, ok := strings.Cut(artifact, ":"); ok { @@ -126,6 +134,27 @@ func (m *Module) steps(ctx context.Context, args map[string]any) (any, error) { return StepsAnswer{Artifact: artifact, Fingerprint: fp, Steps: steps}, nil } +// dropDetected is `steps` with drop (§463, §480): the act is written in the hand-act log with the caller the bus proved, +// then the kept key is deleted; a log that refuses leaves the key. +func (m *Module) dropDetected(args map[string]any, caller string) (any, error) { + artifact, _ := args["artifact"].(string) + why, _ := args["why"].(string) + module, _ := args["module"].(string) + name := artifact + if _, n, ok := strings.Cut(artifact, ":"); ok { + name = n + } + key := seat.DetectedStepsByModuleArtifactKey(module, name) + failed, err := buildstep.DropDetected(artifact, why, caller, key, m.RecordAct, m.ForgetDetected) + if err != nil { + return nil, err + } + if failed != "" { + return StepsAnswer{Artifact: artifact, Error: failed}, nil + } + return StepsAnswer{Artifact: artifact, Dropped: artifact}, nil +} + // stepsOfTree fetches the commit alone (nothing installed) into a workspace removed at the end. func (m *Module) stepsOfTree(ctx context.Context, req sdkstep.FetchRequest) ([]DetectedStep, error) { if err := os.MkdirAll(m.Work, 0o700); err != nil { diff --git a/modules/mesh-build-node/cmd/mesh-build-node/steps_test.go b/modules/mesh-build-node/cmd/mesh-build-node/steps_test.go index 0e7accaaa..3593c2d65 100644 --- a/modules/mesh-build-node/cmd/mesh-build-node/steps_test.go +++ b/modules/mesh-build-node/cmd/mesh-build-node/steps_test.go @@ -2,9 +2,11 @@ package main import ( "context" + "errors" "os" "path/filepath" "reflect" + "strings" "testing" ) @@ -51,3 +53,27 @@ func TestTheStepsVerbIsServedAndRefusesAnAskWithoutAnArtifact(t *testing.T) { t.Error("an ask with no artifact was answered") } } + +// Design 47 G §463, §480 (BN9): steps with drop writes the hand-act log first, then deletes the key; a log that +// refuses deletes nothing; a drop without why is refused. +func TestTheStepsDropWritesTheHandActLogFirst(t *testing.T) { + var order []string + m := &Module{ + ForgetDetected: func(key string) error { order = append(order, "delete "+key); return nil }, + RecordAct: func(what, why string) error { order = append(order, "log "+why); return nil }, + } + args := map[string]any{"module": "mod", "artifact": "mod:web", "drop": true, "why": "stale"} + got, err := m.dropDetected(args, "shanks.person") + if a, _ := got.(StepsAnswer); err != nil || a.Dropped != "mod:web" || !reflect.DeepEqual(order, []string{"log stale", "delete mod.web"}) { + t.Fatalf("drop = %+v, %v, order %v", got, err, order) + } + if _, err := m.dropDetected(map[string]any{"module": "mod", "artifact": "mod:web", "drop": true}, "shanks.person"); err == nil { + t.Error("a drop without why was taken") + } + order = nil + m.RecordAct = func(string, string) error { return errors.New("log down") } + got, err = m.dropDetected(args, "shanks.person") + if a, _ := got.(StepsAnswer); err != nil || !strings.Contains(a.Error, "hand-act log") || len(order) != 0 { + t.Fatalf("a refusing log: %+v, %v, order %v", got, err, order) + } +} diff --git a/modules/mesh-build-node/go.mod b/modules/mesh-build-node/go.mod index af385b3dd..cb074bd3b 100644 --- a/modules/mesh-build-node/go.mod +++ b/modules/mesh-build-node/go.mod @@ -3,6 +3,8 @@ module meshbuildnode go 1.26.0 require ( + 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/meshhandacts v0.0.0-00010101000000-000000000000 git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep 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 diff --git a/modules/mesh-build-node/module.json b/modules/mesh-build-node/module.json index 1afa7b1a6..a056ac734 100644 --- a/modules/mesh-build-node/module.json +++ b/modules/mesh-build-node/module.json @@ -75,6 +75,7 @@ "npm-package-registry": "${dir:state}/package-registry.json" }, "invokes": [ + "seat:mesh-hand-acts.hand-act", "seat:oci-artifact-registry.publish", "seat:oci-artifact-registry.publish-part", "seat:npm-package-registry.publish", From 904b8aedd43b27f9ec13c05adfbd7f4ec8f0e94d Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 05:00:48 +0100 Subject: [PATCH 2/4] BG7, BN7: a fetch naming builds reads each package member from the artifact store MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each member's build is read at /v2///blobs/ through the required provision artifact-store (bound to a file), checked against its digest and laid as the package it names: a Go module zip replaced in the temporary modfile, an npm tarball in node_modules. A mismatch or a store that does not answer fails the step naming the member. Builds: design 47 G §465, §373, §466 (lead answer A-151) Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h --- .../mesh-build-go/buildstep/storedbuilds.go | 116 ++++++++++++++++++ .../mesh-build-go/cmd/mesh-build-go/builds.go | 110 +++++++++++++++++ .../cmd/mesh-build-go/builds_test.go | 75 +++++++++++ .../mesh-build-go/cmd/mesh-build-go/fetch.go | 17 ++- .../cmd/mesh-build-go/forge_test.go | 3 +- .../mesh-build-go/cmd/mesh-build-go/main.go | 1 + .../cmd/mesh-build-go/manifest_test.go | 9 +- .../mesh-build-go/cmd/mesh-build-go/module.go | 2 + modules/mesh-build-go/module.json | 2 + .../cmd/mesh-build-node/builds.go | 70 +++++++++++ .../cmd/mesh-build-node/builds_test.go | 57 +++++++++ .../cmd/mesh-build-node/fetch.go | 30 ++++- .../cmd/mesh-build-node/main.go | 1 + .../cmd/mesh-build-node/manifest_test.go | 5 +- .../cmd/mesh-build-node/module.go | 2 + modules/mesh-build-node/module.json | 2 + 16 files changed, 489 insertions(+), 13 deletions(-) create mode 100644 modules/mesh-build-go/buildstep/storedbuilds.go create mode 100644 modules/mesh-build-go/cmd/mesh-build-go/builds.go create mode 100644 modules/mesh-build-go/cmd/mesh-build-go/builds_test.go create mode 100644 modules/mesh-build-node/cmd/mesh-build-node/builds.go create mode 100644 modules/mesh-build-node/cmd/mesh-build-node/builds_test.go diff --git a/modules/mesh-build-go/buildstep/storedbuilds.go b/modules/mesh-build-go/buildstep/storedbuilds.go new file mode 100644 index 000000000..f10101295 --- /dev/null +++ b/modules/mesh-build-go/buildstep/storedbuilds.go @@ -0,0 +1,116 @@ +package buildstep + +// **A group run's package members, read from the artifact store** (design 47 G §465, §373): the ask's fetch names each +// member's build by its digest; the build module reads it from the artifact store, through the provision it requires +// (`artifact-store`, bound to a file holding `at` and `serves.port`), at +// `artifact-store:////blobs/`, that is GET `/v2///blobs/`, and checks +// the bytes against the digest. It only reads: nothing writes into the store but its holder (ADR 0614 §2). + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "regexp" + "strings" + + sdkstep "git.novox.be/novox/mesh-delivery/sdk/buildstep" +) + +// MaxStoredBuild is the most bytes of one member's build the module reads. +const MaxStoredBuild = 512 << 20 + +var blobDigest = regexp.MustCompile(`^sha256:[0-9a-f]{64}$`) + +// BuildsArg reads a fetch's `builds` argument (the part build-step's Fetch.builds): a list of {package, module, +// artifact, digest}, as JSON gives it or as the ask's own form. nil for none. +func BuildsArg(v any) ([]sdkstep.FetchBuild, error) { + switch v := v.(type) { + case nil: + return nil, nil + case []sdkstep.FetchBuild: + return append([]sdkstep.FetchBuild(nil), v...), check(v) + case []any: + raw, err := json.Marshal(v) + if err != nil { + return nil, err + } + var out []sdkstep.FetchBuild + if err := json.Unmarshal(raw, &out); err != nil { + return nil, fmt.Errorf("builds is a list of {package, module, artifact, digest}: %w", err) + } + return out, check(out) + } + return nil, fmt.Errorf("builds is a list of {package, module, artifact, digest}") +} + +func check(bs []sdkstep.FetchBuild) error { + for _, b := range bs { + if b.Package == "" || b.Module == "" || b.Artifact == "" || !blobDigest.MatchString(b.Digest) { + return fmt.Errorf("builds: %+v names no package, module, artifact or sha256: digest", b) + } + } + return nil +} + +// StoreURL is the artifact store's base URL from its binding file (`at`, and `serves` scheme and port), as the Go +// seat's binding reads; an error naming the file when it is not bound. +func StoreURL(bindFile string) (string, error) { + raw, err := os.ReadFile(bindFile) + if err != nil { + return "", fmt.Errorf("the artifact store is not bound (%v)", err) + } + var b struct { + At string `json:"at"` + Serves struct { + Scheme string `json:"scheme"` + Port int `json:"port"` + } `json:"serves"` + } + if json.Unmarshal(raw, &b) != nil || b.At == "" { + return "", fmt.Errorf("the artifact store's binding %s names no address", bindFile) + } + scheme := b.Serves.Scheme + if scheme == "" { + scheme = "http" + } + if b.Serves.Port == 0 { + return fmt.Sprintf("%s://%s", scheme, b.At), nil + } + return fmt.Sprintf("%s://%s:%d", scheme, b.At, b.Serves.Port), nil +} + +// ReadStoredBuild reads one member's build from the store at base and checks its bytes against its digest; a failure +// names the member. +func ReadStoredBuild(ctx context.Context, client *http.Client, base string, b sdkstep.FetchBuild) ([]byte, error) { + member := fmt.Sprintf("the build of %s (%s %s at %s)", b.Package, b.Module, b.Artifact, b.Digest) + url := strings.TrimRight(base, "/") + "/v2/" + b.Module + "/" + b.Artifact + "/blobs/" + b.Digest + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("%s: %w", member, err) + } + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("%s: the artifact store did not answer: %w", member, err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("%s: the artifact store answered %s", member, resp.Status) + } + data, err := io.ReadAll(io.LimitReader(resp.Body, MaxStoredBuild+1)) + if err != nil { + return nil, fmt.Errorf("%s: reading it: %w", member, err) + } + if len(data) > MaxStoredBuild { + return nil, fmt.Errorf("%s: larger than %d bytes", member, MaxStoredBuild) + } + sum := sha256.Sum256(data) + if got := "sha256:" + hex.EncodeToString(sum[:]); got != b.Digest { + return nil, fmt.Errorf("%s: the bytes read are %s, not the digest asked", member, got) + } + return data, nil +} diff --git a/modules/mesh-build-go/cmd/mesh-build-go/builds.go b/modules/mesh-build-go/cmd/mesh-build-go/builds.go new file mode 100644 index 000000000..23ff24ac5 --- /dev/null +++ b/modules/mesh-build-go/cmd/mesh-build-go/builds.go @@ -0,0 +1,110 @@ +package main + +// **A group run's package members, laid from their builds** (design 47 G §465, §373): a fetch naming `builds` reads each +// member's build (a Go module zip, every file under @/, gopackage.go) from the artifact store by +// its digest, checks it, unpacks it beside the workspace and replaces the package by that directory in each module +// directory's temporary modfile: nothing is fetched by a version, and no version is reserved. A member that does not +// match its digest, or a store that does not answer, fails the step naming the member. + +import ( + "archive/zip" + "bytes" + "context" + "fmt" + "io" + "os" + "path" + "path/filepath" + "strings" + + "git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep" + sdkstep "git.novox.be/novox/mesh-delivery/sdk/buildstep" +) + +// layBuilds lays each of builds as the package it names in every module directory dirs (relative to tree) of the +// workspace root; a directory that vendors resolves nothing and is left. +func (m *Module) layBuilds(ctx context.Context, root string, dirs []string, builds []sdkstep.FetchBuild) error { + if len(builds) == 0 { + return nil + } + base, err := buildstep.StoreURL(m.ArtifactStoreFile) + if err != nil { + return fmt.Errorf("a group run's members are read from the artifact store: %w", err) + } + tree := filepath.Join(root, "tree") + for i, b := range builds { + data, err := buildstep.ReadStoredBuild(ctx, m.httpClient(), base, b) + if err != nil { + return err + } + where := filepath.Join(root, "builds", fmt.Sprintf("%d", i)) + if err := unzipBuild(data, where); err != nil { + return fmt.Errorf("the build of %s (%s %s at %s): %w", b.Package, b.Module, b.Artifact, b.Digest, err) + } + for _, d := range dirs { + dir := filepath.Join(tree, d) + if _, err := os.Stat(filepath.Join(dir, "vendor", "modules.txt")); err == nil { + continue + } + if err := m.replaceInModfile(dir, b.Package, where); err != nil { + return err + } + } + if m.Say != nil { + m.Say("laid %s from its build %s (%s %s)", b.Package, b.Digest, b.Module, b.Artifact) + } + } + return nil +} + +// unzipBuild unpacks a Go module zip into dir, the first path element (@) taken off; a name that +// leaves dir is refused. +func unzipBuild(data []byte, dir string) error { + zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data))) + if err != nil { + return fmt.Errorf("it is not a module zip: %w", err) + } + if err := os.MkdirAll(dir, 0o700); err != nil { + return err + } + var total int64 + for _, f := range zr.File { + if f.FileInfo().IsDir() { + continue + } + _, rest, ok := strings.Cut(f.Name, "@") + if !ok { + return fmt.Errorf("the entry %q is outside @/", f.Name) + } + _, rest, ok = strings.Cut(rest, "/") + clean := path.Clean("/" + rest) + if !ok || clean == "/" || strings.Contains(rest, "..") { + return fmt.Errorf("the entry %q is not a file of the module", f.Name) + } + target := filepath.Join(dir, filepath.FromSlash(clean)) + if err := os.MkdirAll(filepath.Dir(target), 0o700); err != nil { + return err + } + rc, err := f.Open() + if err != nil { + return err + } + out, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600) + if err != nil { + rc.Close() + return err + } + n, err := io.Copy(out, io.LimitReader(rc, buildstep.MaxStoredBuild)) + rc.Close() + if cerr := out.Close(); err == nil { + err = cerr + } + if err != nil { + return err + } + if total += n; total > buildstep.MaxStoredBuild { + return fmt.Errorf("it unpacks to more than %d bytes", buildstep.MaxStoredBuild) + } + } + return nil +} diff --git a/modules/mesh-build-go/cmd/mesh-build-go/builds_test.go b/modules/mesh-build-go/cmd/mesh-build-go/builds_test.go new file mode 100644 index 000000000..21656f5bd --- /dev/null +++ b/modules/mesh-build-go/cmd/mesh-build-go/builds_test.go @@ -0,0 +1,75 @@ +package main + +import ( + "archive/zip" + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + sdkstep "git.novox.be/novox/mesh-delivery/sdk/buildstep" +) + +// Design 47 G §465, §373 (BG7): a fetch naming builds reads each member from the artifact store by its digest, checks the +// bytes and lays them as the package named; a mismatch, or a store that does not answer, fails naming the member. +func TestAFetchLaysAGroupMembersBuildFromTheArtifactStore(t *testing.T) { + var zbuf bytes.Buffer + zw := zip.NewWriter(&zbuf) + w, _ := zw.Create("example.test/lib@v0.0.0/go.mod") + w.Write([]byte("module example.test/lib\n")) + w, _ = zw.Create("example.test/lib@v0.0.0/sub/a.go") + w.Write([]byte("package sub\n")) + zw.Close() + sum := sha256.Sum256(zbuf.Bytes()) + digest := "sha256:" + hex.EncodeToString(sum[:]) + var asked []string + store := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + asked = append(asked, r.URL.Path) + if r.URL.Path != "/v2/lib/pkg/blobs/"+digest { + http.NotFound(rw, r) + return + } + rw.Write(zbuf.Bytes()) + })) + defer store.Close() + + m := &Module{Work: t.TempDir(), Home: t.TempDir()} + root := filepath.Join(m.Work, "ws1") + if err := os.MkdirAll(filepath.Join(root, "tree"), 0o700); err != nil { + t.Fatal(err) + } + os.WriteFile(filepath.Join(root, "tree", "go.mod"), []byte("module example.test/app\n\nrequire example.test/lib v0.0.0-00010101000000-000000000000\n"), 0o600) + m.ArtifactStoreFile = filepath.Join(m.Home, "artifact-store.json") + os.WriteFile(m.ArtifactStoreFile, []byte(`{"at":"`+strings.TrimPrefix(store.URL, "http://")+`"}`), 0o600) + member := sdkstep.FetchBuild{Package: "example.test/lib", Module: "lib", Artifact: "pkg", Digest: digest} + + if err := m.layBuilds(context.Background(), root, []string{"."}, []sdkstep.FetchBuild{member}); err != nil { + t.Fatal(err) + } + if b, err := os.ReadFile(filepath.Join(root, "builds", "0", "sub", "a.go")); err != nil || string(b) != "package sub\n" { + t.Fatalf("the member was not unpacked: %q, %v", b, err) + } + mod, _ := os.ReadFile(filepath.Join(root, "pins", "root", "go.mod")) + if want := "replace example.test/lib => " + filepath.Join(root, "builds", "0"); !strings.Contains(string(mod), want) { + t.Errorf("the temporary modfile lacks %q:\n%s", want, mod) + } + // A member whose bytes are not its digest, and a store that answers nothing, fail naming the member. + bad := member + bad.Digest = "sha256:" + strings.Repeat("0", 64) + for _, c := range []sdkstep.FetchBuild{bad, {Package: "example.test/lib", Module: "lib", Artifact: "missing", Digest: digest}} { + err := m.layBuilds(context.Background(), root, []string{"."}, []sdkstep.FetchBuild{c}) + if err == nil || !strings.Contains(err.Error(), "example.test/lib") { + t.Errorf("member %+v: error %v", c, err) + } + } + store.Close() + if err := m.layBuilds(context.Background(), root, []string{"."}, []sdkstep.FetchBuild{member}); err == nil || !strings.Contains(err.Error(), "example.test/lib") { + t.Errorf("a store that does not answer: %v", err) + } +} diff --git a/modules/mesh-build-go/cmd/mesh-build-go/fetch.go b/modules/mesh-build-go/cmd/mesh-build-go/fetch.go index 8e76eb0f6..9802ff224 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/fetch.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/fetch.go @@ -77,6 +77,11 @@ func (m *Module) fetchTool() StepTool { "dir": sdkstep.FetchRequestProperties()["dir"], "paths": sdkstep.FetchRequestProperties()["paths"], "packages": sdkstep.FetchRequestProperties()["packages"], + "builds": map[string]any{"type": "array", "description": "optional: a group run's package members, each by its build's digest, read from the artifact store and laid as the package it names (design 47 G §465)", + "items": map[string]any{"type": "object", "properties": map[string]any{ + "package": map[string]any{"type": "string"}, "module": map[string]any{"type": "string"}, + "artifact": map[string]any{"type": "string"}, "digest": map[string]any{"type": "string"}, + }, "required": []string{"package", "module", "artifact", "digest"}}}, "beside": map[string]any{"type": "array", "description": "optional sibling repositories the tests read, each placed as a plain directory (no symlink) under the workspace's beside/, which build_go_test gives the tests as MESH_CHECK_BESIDE", "items": map[string]any{"type": "object", "properties": map[string]any{ "repository": map[string]any{"type": "string", "description": "/ on the forge"}, @@ -104,6 +109,10 @@ func (m *Module) fetch(ctx context.Context, args map[string]any) (res any, err e if err != nil { return nil, err } + builds, err := buildstep.BuildsArg(args["builds"]) + if err != nil { + return nil, err + } m.CleanWorkspaces(m.now()) id := sdkstep.NewID() done := m.spaces().Hold(id) @@ -115,10 +124,10 @@ func (m *Module) fetch(ctx context.Context, args map[string]any) (res any, err e } } }() - return m.fetchInto(ctx, req, id, siblings) + return m.fetchInto(ctx, req, id, siblings, builds) } -func (m *Module) fetchInto(ctx context.Context, req sdkstep.FetchRequest, id string, siblings []siblingArg) (any, error) { +func (m *Module) fetchInto(ctx context.Context, req sdkstep.FetchRequest, id string, siblings []siblingArg, builds []sdkstep.FetchBuild) (any, error) { repository, commit, ref, base := req.Repository, req.Commit, req.Ref, req.Base var err error root := filepath.Join(m.Work, id) @@ -159,6 +168,10 @@ func (m *Module) fetchInto(ctx context.Context, req sdkstep.FetchRequest, id str if base != "" && res.Merge == "" { return res, nil // a conflict: the tree is at the trunk, nothing of the head is built or tested } + // A group run's package members are laid from their builds, never fetched by a version (builds.go, §465). + if err := m.layBuilds(ctx, root, dirs, builds); err != nil { + return nil, err + } // The directories download side by side, up to the bound (ADR 0536 §2); the first failure in directory order is the // step's. Resolving a pin places a repository beside the workspace, which two directories may share: one at a time. var pins sync.Mutex diff --git a/modules/mesh-build-go/cmd/mesh-build-go/forge_test.go b/modules/mesh-build-go/cmd/mesh-build-go/forge_test.go index a3fdf7dc9..2acdce793 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/forge_test.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/forge_test.go @@ -146,6 +146,7 @@ func TestForgeFilesRenderFromTheBoundValuesInTheModulesOwnHome(t *testing.T) { func TestManifestRequiresForgeAccountKeepsKeysAndTakesFilesFromTemplates(t *testing.T) { m := readForgeManifest(t) // node-tools: the module pulls its asks through the tool runner's seat pull (1911). + // artifact-store: a group run's members are read from it (design 47 G §465). // go-package-registry: the Go seat's read side, the fetch's first source (design 51, CAT-BG3, E2). // detected-steps: the state of the verb steps (design 47 G §326, §414), asked by the template state. // kept-builds: the builds kept for the artifact store's holder (keptbuilds.go), by the template objects (W12.5). @@ -155,7 +156,7 @@ func TestManifestRequiresForgeAccountKeepsKeysAndTakesFilesFromTemplates(t *test findings := map[string]any{"name": "findings", "storage": "key-value", "template": "state"} findingEvents := map[string]any{"name": "finding-events", "storage": "stream", "template": "events", "options": map[string]any{ "events": []any{"finding-written.*", "finding-cleared.*", "findings-stated.*"}, "max-age-days": float64(7), "max-mib": float64(16)}} - if want := []any{detected, kept, "forge-account", "go-toolchain", "go-package-registry", "node-tools", findings, findingEvents}; !reflect.DeepEqual(m.Requires, want) { + if want := []any{detected, kept, "artifact-store", "forge-account", "go-toolchain", "go-package-registry", "node-tools", findings, findingEvents}; !reflect.DeepEqual(m.Requires, want) { t.Errorf("requires = %v, want %v", m.Requires, want) } keeps := false diff --git a/modules/mesh-build-go/cmd/mesh-build-go/main.go b/modules/mesh-build-go/cmd/mesh-build-go/main.go index edbf844b3..fa75df4c1 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/main.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/main.go @@ -63,6 +63,7 @@ func main() { } m.Run = StepRunner(m.Home) m.GoSeatFile = m.Home + "/go-package-registry.json" + m.ArtifactStoreFile = m.Home + "/artifact-store.json" m.Settings = m.Home + "/settings.json" m.ReadManifest = askManifest m.AskInput = stdio.Ask diff --git a/modules/mesh-build-go/cmd/mesh-build-go/manifest_test.go b/modules/mesh-build-go/cmd/mesh-build-go/manifest_test.go index 99e7eb34e..c59075693 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/manifest_test.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/manifest_test.go @@ -172,12 +172,13 @@ func TestTheModuleDeclaresBubblewrapAndTheCapabilityItNeeds(t *testing.T) { } // 1587-S1, CAT-BG1 (design 51 *Publishing*, ADR 0614 §2): the module publishes a done archive through the seat -// oci-artifact-registry's verbs, which it invokes (the served-verb edge orders it after the holder), and no longer -// requires the artifact store or is told its address: nothing writes into the store but its holder. +// oci-artifact-registry's verbs, which it invokes (the served-verb edge orders it after the holder), and only reads +// the artifact store (§465): nothing writes into the store but its holder. func TestTheModuleInvokesTheArtifactSeatsVerbsAndWritesNoStore(t *testing.T) { m := readManifest(t) - if slices.Contains(m.Requires, "artifact-store") { - t.Errorf("requires %v still name artifact-store", m.Requires) + // It requires the provision to read a group run's package members' builds (design 47 G §465), never to write. + if !slices.Contains(m.Requires, "artifact-store") { + t.Errorf("requires %v lack artifact-store, which a group run's members are read from", m.Requires) } for _, seat := range []string{ArtifactSeat, GoPackageSeat} { // a Go package's too (CAT-BG2) for _, verb := range []string{"publish", "publish-part"} { diff --git a/modules/mesh-build-go/cmd/mesh-build-go/module.go b/modules/mesh-build-go/cmd/mesh-build-go/module.go index 5f0f36909..e6e00fd9a 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/module.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/module.go @@ -84,6 +84,8 @@ type Module struct { // packages*): bound, the mesh's Go modules are fetched through it first, Go's public proxy second, and the forge's // git over the account's ssh last (Go's direct, kept for this milestone only); unbound, as before, directly. GoSeatFile string + // ArtifactStoreFile is the binding of the provision artifact-store, read for a group run's package members (§465). + ArtifactStoreFile string // Call asks a seat's verb over the bus: the publish of a done archive (publish.go). stdio.Ask when nil; a test's // fake holder. Call callFunc diff --git a/modules/mesh-build-go/module.json b/modules/mesh-build-go/module.json index fa30c8470..b4a1eb975 100644 --- a/modules/mesh-build-go/module.json +++ b/modules/mesh-build-go/module.json @@ -112,6 +112,7 @@ "max-mib": 1024 } }, + "artifact-store", "forge-account", "go-toolchain", "go-package-registry", @@ -137,6 +138,7 @@ } ], "binds": { + "artifact-store": "${dir:state}/artifact-store.json", "go-package-registry": "${dir:state}/go-package-registry.json" }, "own-secrets": { diff --git a/modules/mesh-build-node/cmd/mesh-build-node/builds.go b/modules/mesh-build-node/cmd/mesh-build-node/builds.go new file mode 100644 index 000000000..0945353f8 --- /dev/null +++ b/modules/mesh-build-node/cmd/mesh-build-node/builds.go @@ -0,0 +1,70 @@ +package main + +// **A group run's package members, laid from their builds** (design 47 G §465, §373): a fetch naming `builds` reads each +// member's build (the npm package's packed tarball, package.go's packed) from the artifact store by its digest, checks +// it and places it as the local package it names in each TypeScript module's node_modules: nothing is fetched by a +// version, and none is reserved. A member that does not match its digest, or a store that does not answer, fails the +// step naming the member. + +import ( + "context" + "fmt" + "os" + "path/filepath" + + "git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep" + sdkstep "git.novox.be/novox/mesh-delivery/sdk/buildstep" +) + +// storedBuild is one member's build, read and checked. +type storedBuild struct { + member sdkstep.FetchBuild + tgz []byte +} + +// readBuilds reads every member of builds from the artifact store. +func (m *Module) readBuilds(ctx context.Context, builds []sdkstep.FetchBuild) ([]storedBuild, error) { + if len(builds) == 0 { + return nil, nil + } + base, err := buildstep.StoreURL(m.ArtifactStoreFile) + if err != nil { + return nil, fmt.Errorf("a group run's members are read from the artifact store: %w", err) + } + var out []storedBuild + for _, b := range builds { + data, err := buildstep.ReadStoredBuild(ctx, m.httpClient(), base, b) + if err != nil { + return nil, err + } + out = append(out, storedBuild{b, data}) + } + return out, nil +} + +// names says whether a member of the builds is the package pkg. +func namesPackage(builds []storedBuild, pkg string) bool { + for _, b := range builds { + if b.member.Package == pkg { + return true + } + } + return false +} + +// layBuilds places each member in dir's node_modules as the package it names, in place of any other. +func (m *Module) layBuilds(dir string, builds []storedBuild) error { + for _, b := range builds { + into := filepath.Join(dir, "node_modules", filepath.FromSlash(b.member.Package)) + if err := os.RemoveAll(into); err != nil { + return err + } + if err := unpackPackage(b.tgz, into); err != nil { + return fmt.Errorf("the build of %s (%s %s at %s): %w", b.member.Package, b.member.Module, b.member.Artifact, b.member.Digest, err) + } + if m.Say != nil { + m.Say("laid %s from its build %s (%s %s)", b.member.Package, b.member.Digest, b.member.Module, b.member.Artifact) + } + } + return nil +} diff --git a/modules/mesh-build-node/cmd/mesh-build-node/builds_test.go b/modules/mesh-build-node/cmd/mesh-build-node/builds_test.go new file mode 100644 index 000000000..0d63bbcc7 --- /dev/null +++ b/modules/mesh-build-node/cmd/mesh-build-node/builds_test.go @@ -0,0 +1,57 @@ +package main + +import ( + "context" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + sdkstep "git.novox.be/novox/mesh-delivery/sdk/buildstep" +) + +// Design 47 G §465, §373 (BN7): a fetch naming builds reads each member from the artifact store by its digest, checks the +// bytes and lays it in node_modules as the package named; a mismatch, or a store that does not answer, fails naming it. +func TestAFetchLaysAGroupMembersBuildFromTheArtifactStore(t *testing.T) { + tgz := tgzOf(t, "@novox/lib", "0.0.0") + digest := digestOf(tgz) + store := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v2/lib/pkg/blobs/"+digest { + http.NotFound(w, r) + return + } + w.Write(tgz) + })) + defer store.Close() + m := &Module{ArtifactStoreFile: filepath.Join(t.TempDir(), "artifact-store.json")} + os.WriteFile(m.ArtifactStoreFile, []byte(`{"at":"`+strings.TrimPrefix(store.URL, "http://")+`"}`), 0o600) + member := sdkstep.FetchBuild{Package: "@novox/lib", Module: "lib", Artifact: "pkg", Digest: digest} + + got, err := m.readBuilds(context.Background(), []sdkstep.FetchBuild{member}) + if err != nil { + t.Fatal(err) + } + dir := t.TempDir() + if err := m.layBuilds(dir, got); err != nil { + t.Fatal(err) + } + if b, err := os.ReadFile(filepath.Join(dir, "node_modules", "@novox", "lib", "dist", "index.js")); err != nil || !strings.Contains(string(b), "0.0.0") { + t.Fatalf("the member was not laid: %q, %v", b, err) + } + if !namesPackage(got, "@novox/lib") || namesPackage(got, "@novox/other") { + t.Error("namesPackage is wrong") + } + bad := member + bad.Digest = "sha256:" + strings.Repeat("0", 64) + for _, c := range []sdkstep.FetchBuild{bad, {Package: "@novox/lib", Module: "lib", Artifact: "missing", Digest: digest}} { + if _, err := m.readBuilds(context.Background(), []sdkstep.FetchBuild{c}); err == nil || !strings.Contains(err.Error(), "@novox/lib") { + t.Errorf("member %+v: error %v", c, err) + } + } + store.Close() + if _, err := m.readBuilds(context.Background(), []sdkstep.FetchBuild{member}); err == nil || !strings.Contains(err.Error(), "@novox/lib") { + t.Errorf("a store that does not answer: %v", err) + } +} diff --git a/modules/mesh-build-node/cmd/mesh-build-node/fetch.go b/modules/mesh-build-node/cmd/mesh-build-node/fetch.go index 962076296..1b5d1049f 100644 --- a/modules/mesh-build-node/cmd/mesh-build-node/fetch.go +++ b/modules/mesh-build-node/cmd/mesh-build-node/fetch.go @@ -102,6 +102,11 @@ func (m *Module) fetchTool() StepTool { "package": map[string]any{"type": "object", "description": "optional: the group's tagged version of the SDK, {name, version}, placed from the registry in each TypeScript member whatever its lock names (ADR 0607 §3); or the group's package member, {repository, commit, ref} (ADR 0500 §5), packed here, publishing nothing, and served to each TypeScript member whose lock names its version, in place of the registry's", "properties": map[string]any{"name": map[string]any{"type": "string"}, "version": map[string]any{"type": "string"}, "repository": map[string]any{"type": "string"}, "commit": map[string]any{"type": "string"}, "ref": map[string]any{"type": "string"}}}, + "builds": map[string]any{"type": "array", "description": "optional: a group run's package members, each by its build's digest, read from the artifact store and laid as the package it names in each module's node_modules (design 47 G §465)", + "items": map[string]any{"type": "object", "properties": map[string]any{ + "package": map[string]any{"type": "string"}, "module": map[string]any{"type": "string"}, + "artifact": map[string]any{"type": "string"}, "digest": map[string]any{"type": "string"}, + }, "required": []string{"package", "module", "artifact", "digest"}}}, "base": map[string]any{"type": "string", "description": "optional trunk commit, 40 hex digits: the head is merged onto it as the forge's merge would, and the answer carries the merge commit, the files in conflict (an answer, the workspace is then left at the trunk commit), the changed paths and the tree of each module directory"}, }, "required": []string{"repository", "commit"}, @@ -124,6 +129,10 @@ func (m *Module) fetch(ctx context.Context, args map[string]any) (res any, err e if err != nil { return nil, err } + buildsAsked, err := buildstep.BuildsArg(args["builds"]) + if err != nil { + return nil, err + } m.CleanWorkspaces(m.now()) id := sdkstep.NewID() done := m.spaces().Hold(id) @@ -135,10 +144,10 @@ func (m *Module) fetch(ctx context.Context, args map[string]any) (res any, err e } } }() - return m.fetchInto(ctx, req, group, id) + return m.fetchInto(ctx, req, group, id, buildsAsked) } -func (m *Module) fetchInto(ctx context.Context, req sdkstep.FetchRequest, group *groupPackage, id string) (any, error) { +func (m *Module) fetchInto(ctx context.Context, req sdkstep.FetchRequest, group *groupPackage, id string, buildsAsked []sdkstep.FetchBuild) (any, error) { repository, commit, ref, base := req.Repository, req.Commit, req.Ref, req.Base var err error root := filepath.Join(m.Work, id) @@ -181,6 +190,11 @@ func (m *Module) fetchInto(ctx context.Context, req sdkstep.FetchRequest, group } served, res.Group = &p, p.Name+"@"+p.Version+" "+p.Integrity } + // A group run's package members, each read from the artifact store by its build's digest (builds.go, §465). + members, err := m.readBuilds(ctx, buildsAsked) + if err != nil { + return nil, err + } dirs, packages, err := m.typeScriptModules(ctx, tree) if err != nil { return nil, err @@ -193,6 +207,9 @@ func (m *Module) fetchInto(ctx context.Context, req sdkstep.FetchRequest, group if err := m.installDev(ctx, root, dir, d); err != nil { return nil, err } + if err := m.layBuilds(dir, members); err != nil { + return nil, err + } res.Installed = append(res.Installed, d) continue } @@ -201,8 +218,13 @@ func (m *Module) fetchInto(ctx context.Context, req sdkstep.FetchRequest, group return nil, err } if refused == "" { - // The latest published SDK, or the group's (ADR 0573 §2, ADR 0607 §3). - if err := m.placeSDK(ctx, dir, d, served, group); err != nil { + // The latest published SDK, or the group's (ADR 0573 §2, ADR 0607 §3); the SDK as a member's build is laid from it. + if !namesPackage(members, sdkPackage) { + if err := m.placeSDK(ctx, dir, d, served, group); err != nil { + return nil, err + } + } + if err := m.layBuilds(dir, members); err != nil { return nil, err } if res.SDK == nil { diff --git a/modules/mesh-build-node/cmd/mesh-build-node/main.go b/modules/mesh-build-node/cmd/mesh-build-node/main.go index c1abbf75f..eb037cd9e 100644 --- a/modules/mesh-build-node/cmd/mesh-build-node/main.go +++ b/modules/mesh-build-node/cmd/mesh-build-node/main.go @@ -65,6 +65,7 @@ func main() { } m.Floor = floorOf(m.Node) m.Settings = filepath.Join(home, "settings.json") + m.ArtifactStoreFile = filepath.Join(home, "artifact-store.json") // Outside a launch by the runtime — a build, a check — there is no node and no state to write. if m.Node != "" { go m.Start(ctx) diff --git a/modules/mesh-build-node/cmd/mesh-build-node/manifest_test.go b/modules/mesh-build-node/cmd/mesh-build-node/manifest_test.go index 3d28b5550..32b9b6681 100644 --- a/modules/mesh-build-node/cmd/mesh-build-node/manifest_test.go +++ b/modules/mesh-build-node/cmd/mesh-build-node/manifest_test.go @@ -238,8 +238,9 @@ func TestTheModuleInvokesTheRegistrySeatsAndHoldsNoWriteCredential(t *testing.T) } } } - if slices.Contains(m.Requires, "artifact-store") { - t.Errorf("requires %v still name artifact-store", m.Requires) + // It requires the provision to read a group run's package members' builds (design 47 G §465), never to write. + if !slices.Contains(m.Requires, "artifact-store") { + t.Errorf("requires %v lack artifact-store, which a group run's members are read from", m.Requires) } for _, r := range m.Resources { if r["id"] == "registry" { diff --git a/modules/mesh-build-node/cmd/mesh-build-node/module.go b/modules/mesh-build-node/cmd/mesh-build-node/module.go index 8f1aea066..f51485aff 100644 --- a/modules/mesh-build-node/cmd/mesh-build-node/module.go +++ b/modules/mesh-build-node/cmd/mesh-build-node/module.go @@ -51,6 +51,8 @@ type Module struct { // Detected and ReadDetected are the Put and the read of the state detected-steps (design 47 G §326, §414). Detected buildstep.Put ReadDetected func(key string) (json.RawMessage, error) + // ArtifactStoreFile is the binding of the provision artifact-store, read for a group run's package members (§465). + ArtifactStoreFile string // ForgetDetected deletes one key of detected-steps; RecordAct writes an act in the hand-act log of the seat // mesh-hand-acts (§463, §480): a drop writes it first. ForgetDetected func(key string) error diff --git a/modules/mesh-build-node/module.json b/modules/mesh-build-node/module.json index a056ac734..4678e794e 100644 --- a/modules/mesh-build-node/module.json +++ b/modules/mesh-build-node/module.json @@ -66,12 +66,14 @@ ], "requires": [ {"name": "detected-steps", "storage": "key-value", "template": "state", "options": {"history": 1}}, + "artifact-store", "forge-account", "npm-package-registry", "node-toolchain", "node-tools" ], "binds": { + "artifact-store": "${dir:state}/artifact-store.json", "npm-package-registry": "${dir:state}/package-registry.json" }, "invokes": [ From f339ea5936d667b1b7d37e4947fe040d09ff0d7d Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 05:14:21 +0100 Subject: [PATCH 3/4] BG5, BN5, BG6, BN6 (toolchain part): toolchain-reported on the seat's subject, with the toolchain program's digest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The guard's toolchains value carries the sha256 of the go or node program found on the path (read again when the version changes); toolchain-reported is said on the build seat's subject of the node at start and on each change of version, recipe or digest; a build's ToolchainUsed names the digest. The legacy toolchain.reported emit and its stream go: nobody consumes them. Builds: design 47 G §492, §472, §466 (lead answer A-152) Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h --- .../mesh-build-go/cmd/mesh-build-go/asks.go | 2 +- .../cmd/mesh-build-go/emit_test.go | 54 +++++++---- .../mesh-build-go/cmd/mesh-build-go/main.go | 2 +- .../mesh-build-go/cmd/mesh-build-go/module.go | 61 ++++++++---- .../cmd/mesh-build-go/toolchainreport.go | 94 +++++++++++++++++++ modules/mesh-build-go/module.json | 8 -- .../cmd/mesh-build-node/asks.go | 2 +- .../cmd/mesh-build-node/emit_test.go | 54 +++++++---- .../cmd/mesh-build-node/main.go | 2 +- .../cmd/mesh-build-node/module.go | 57 +++++++---- .../cmd/mesh-build-node/toolchainreport.go | 94 +++++++++++++++++++ modules/mesh-build-node/module.json | 8 -- 12 files changed, 348 insertions(+), 90 deletions(-) create mode 100644 modules/mesh-build-go/cmd/mesh-build-go/toolchainreport.go create mode 100644 modules/mesh-build-node/cmd/mesh-build-node/toolchainreport.go diff --git a/modules/mesh-build-go/cmd/mesh-build-go/asks.go b/modules/mesh-build-go/cmd/mesh-build-go/asks.go index b49bd7d1b..30d9e74a2 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/asks.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/asks.go @@ -40,7 +40,7 @@ func (m *Module) askSteps(steps []StepTool) buildstep.AskSteps { if err != nil { return sdkstep.ToolchainUsed{}, err } - return sdkstep.ToolchainUsed{Version: installed, Recipe: m.Recipe.Version()}, nil + return sdkstep.ToolchainUsed{Version: installed, Recipe: m.Recipe.Version(), Digest: m.programDigest(installed)}, nil }, // The kept results reach a test step in its ask (design 47 *Carried, not run*, line 593 at b229f5c4; issue 1966 // K1): this module reads no other part's kept state. diff --git a/modules/mesh-build-go/cmd/mesh-build-go/emit_test.go b/modules/mesh-build-go/cmd/mesh-build-go/emit_test.go index 46112afd6..fe43817da 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/emit_test.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/emit_test.go @@ -5,51 +5,69 @@ import ( "encoding/json" "os" "path/filepath" - "slices" "testing" ) -// Issues 1483 and 1488 (for the indexer, issue 1444): the guard emits toolchain.reported after each write of the -// toolchains key, with the node and the language. -func TestTheGuardEmitsToolchainReportedAfterEachWrite(t *testing.T) { - m := moduleWith(newStates(), "go1.27.1") +// Design 47 G §492, §472, §466 (BG5, BG6): the guard says toolchain-reported on the Go build seat's subject of its node, +// with the version, the recipe and the digest of the go program, at the first write and again only when one of them +// changed; the toolchains key and a build's toolchain carry the digest. +func TestTheGuardSaysToolchainReportedOnAChangeWithTheProgramDigest(t *testing.T) { + states := newStates() + m := moduleWith(states, "go1.27.1") + prog := filepath.Join(t.TempDir(), "go") + os.WriteFile(prog, []byte("go program"), 0o755) + m.ToolchainProgram = func() (string, error) { return prog, nil } type event struct { - typ string - body map[string]string + subject string + body map[string]string + id string } var got []event - m.Emit = func(typ string, body any) error { + m.SayToolchain = func(subject string, body any, id string) error { raw, _ := json.Marshal(body) var b map[string]string json.Unmarshal(raw, &b) - got = append(got, event{typ, b}) + got = append(got, event{subject, b, id}) return nil } if err := m.Check(context.Background()); err != nil { t.Fatal(err) } - if len(got) != 1 || got[0].typ != "toolchain.reported" || got[0].body["node"] != "ace" || got[0].body["language"] != "go" || len(got[0].body) != 2 { - t.Errorf("events = %+v, want one toolchain.reported for ace, go", got) + if len(got) != 1 || got[0].subject != "mesh.seat.node-build-go.event.toolchain-reported.ace" || got[0].body["node"] != "ace" || got[0].body["language"] != "go" || + got[0].body["version"] != "go1.27.1" || got[0].body["recipe"] != m.Recipe.Version() || len(got[0].body["digest"]) != len("sha256:")+64 { + t.Fatalf("events = %+v, want one toolchain-reported for ace, go with the program's digest", got) + } + if tc, _ := states.toolchain("ace.go"); tc.Digest != got[0].body["digest"] { + t.Errorf("the toolchains key's digest is %q, the event's %q", tc.Digest, got[0].body["digest"]) } m.Check(context.Background()) - if len(got) != 2 { - t.Errorf("a second guard pass emitted %d events in all, want 2", len(got)) + if len(got) != 1 { + t.Errorf("a second pass that changed nothing said %d events in all, want 1", len(got)) + } + // A build's toolchain names the same digest (§466). + if used, err := m.askSteps(nil).Toolchain(context.Background()); err != nil || used.Digest != got[0].body["digest"] || used.Version != "go1.27.1" { + t.Errorf("a build's toolchain = %+v (%v), want the digest %s", used, err, got[0].body["digest"]) + } + os.WriteFile(prog, []byte("another go program"), 0o755) + m.Probe = func(context.Context) (string, error) { return "go1.27.2", nil } + m.Check(context.Background()) + if len(got) != 2 || got[1].body["version"] != "go1.27.2" || got[1].body["digest"] == got[0].body["digest"] { + t.Errorf("a new version said %+v, want a second event with another digest", got) } } -// The event is carried by the module's stream, not named in emits (ADR 0363: one place); the validator checks that. -func TestTheManifestDeclaresTheToolchainsStream(t *testing.T) { +// The seat's events are its definition's: the module declares no stream of its own for the toolchain (§492). +func TestTheManifestDeclaresNoToolchainStream(t *testing.T) { raw, err := os.ReadFile(filepath.Join("..", "..", "module.json")) if err != nil { t.Fatal(err) } var m struct { Streams []struct { - Name string `json:"name"` - Events []string `json:"events"` + Name string `json:"name"` } `json:"streams"` } - if err := json.Unmarshal(raw, &m); err != nil || len(m.Streams) < 1 || m.Streams[0].Name != "toolchains-go" || !slices.Equal(m.Streams[0].Events, []string{"toolchain.**"}) { + if err := json.Unmarshal(raw, &m); err != nil || len(m.Streams) != 0 { t.Errorf("streams = %+v (%v)", m.Streams, err) } } diff --git a/modules/mesh-build-go/cmd/mesh-build-go/main.go b/modules/mesh-build-go/cmd/mesh-build-go/main.go index fa75df4c1..f5727ac0e 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/main.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/main.go @@ -40,7 +40,7 @@ func main() { Node: strings.TrimSpace(os.Getenv("MESH_NODE")), Recipe: Current(), Toolchains: stdio.State(seat.StateToolchains).Put, - Emit: func(t string, b any) error { return stdio.Emit(t, b) }, + SayToolchain: stdio.SeatPublish, Steps: stdio.State(seat.StateSteps).Put, Detected: stdio.State(seat.StateDetectedSteps).Put, ReadDetected: readOf(stdio.State(seat.StateDetectedSteps)).Get, diff --git a/modules/mesh-build-go/cmd/mesh-build-go/module.go b/modules/mesh-build-go/cmd/mesh-build-go/module.go index e6e00fd9a..a300f93be 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/module.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/module.go @@ -6,6 +6,7 @@ import ( "fmt" stdio "git.novox.be/novox/mesh-sdk/go" "net/http" + "os/exec" "sync" "time" @@ -48,9 +49,6 @@ type Module struct { ForgetPause func(key string) error // WatchPauses follows the drops other holders write in the shared pauses state (stdio.State(...).Watch). WatchPauses func(key string, handle func(stdio.StateChange) error) error - // Emit tells the bus an event (stdio.Emit); nil emits nothing. The guard emits toolchain.reported after each write of - // the toolchains key, so the indexer, which holds no timer, learns that the bucket exists (issue 1444). - Emit func(eventType string, body any) error // Ctx is the process's context, which a step runs under (the tool call's is cut at 30 s, the step's is not). Ctx context.Context Say func(format string, a ...any) @@ -123,8 +121,17 @@ type Module struct { KeptBuilds KeptObjects Self string - spacesOnce sync.Once - workspaces *buildstep.Workspaces + // SayToolchain says an event on a seat's subject (stdio.SeatPublish; nil says none, a test's); ToolchainProgram finds the go + // program whose digest the toolchain's is (exec.LookPath when nil). + SayToolchain func(subject string, body any, id string) error + ToolchainProgram func() (string, error) + + reporterOnce sync.Once + reporter *toolchainReporter + digestMu sync.Mutex + digestFor, progDigest string + spacesOnce sync.Once + workspaces *buildstep.Workspaces } // guard is the SDK guard for the version the probe just read: the module names no Go version (ADR 0477 §2), so the @@ -243,20 +250,38 @@ func (m *Module) Status(ctx context.Context) map[string]any { return out } -// ToolchainReported is the event the guard emits after it writes the toolchains key. -const ToolchainReported = "toolchain.reported" - -// reportingPut is the toolchains Put that then emits toolchain.reported with the node and the language. A write that -// failed emits nothing; an emit that failed is said and does not refuse the step. +// reportingPut is the toolchains Put that then says toolchain-reported on the Go build seat's subject of this node when +// the toolchain changed (design 47 G §492): at the module's start and on each change of the key. A write that failed +// says nothing; an event that failed is said and does not refuse the step. func (m *Module) reportingPut() buildstep.Put { - return func(key string, value any) (uint64, error) { - rev, err := m.Toolchains(key, value) - if err != nil || m.Emit == nil { - return rev, err + m.reporterOnce.Do(func() { + m.reporter = &toolchainReporter{AskLanguage: sdkstep.AskGo, Node: m.Node, Publish: m.SayToolchain} + }) + return putReporting(m.Toolchains, m.reporter, m.programDigest, m.Say) +} + +// programDigest is the digest of the Go program this node's go-toolchain provides, the `go` found on the path: read again +// only when the installed version changed (design 47 G §472; the lead's answer A-152). +func (m *Module) programDigest(version string) string { + m.digestMu.Lock() + defer m.digestMu.Unlock() + if m.digestFor == version && m.progDigest != "" { + return m.progDigest + } + find := m.ToolchainProgram + if find == nil { + find = func() (string, error) { return exec.LookPath("go") } + } + path, err := find() + if err == nil { + var d string + if d, err = programFileDigest(path); err == nil { + m.digestFor, m.progDigest = version, d + return d } - if eerr := m.Emit(ToolchainReported, map[string]string{"node": m.Node, "language": "go"}); eerr != nil && m.Say != nil { - m.Say("the event %s could not be emitted: %v", ToolchainReported, eerr) - } - return rev, nil + } + if m.Say != nil { + m.Say("the digest of the go toolchain program could not be read: %v", err) } + return "" } diff --git a/modules/mesh-build-go/cmd/mesh-build-go/toolchainreport.go b/modules/mesh-build-go/cmd/mesh-build-go/toolchainreport.go new file mode 100644 index 000000000..d0c609a74 --- /dev/null +++ b/modules/mesh-build-go/cmd/mesh-build-go/toolchainreport.go @@ -0,0 +1,94 @@ +package main + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep" + "io" + "os" + "sync" + + sdkstep "git.novox.be/novox/mesh-delivery/sdk/buildstep" +) + +// toolchainReporter says `toolchain-reported` on the build seat's subject of its node (design 47 G §492): once at the +// module's start and each time its toolchains key of the language changes (a write that changes nothing says nothing). +// Language is the seat's ask language (go, node, image). +type toolchainReporter struct { + AskLanguage, Node string + // Publish is stdio.SeatPublish: the event is de-duplicated by its id. + Publish func(subject string, body any, id string) error + + mu sync.Mutex + last map[string]sdkstep.Toolchain +} + +// Report says the toolchain written under key at revision rev when it differs from the last one said for the key. +func (r *toolchainReporter) Report(key string, rev uint64, t sdkstep.Toolchain) error { + r.mu.Lock() + defer r.mu.Unlock() + if prev, ok := r.last[key]; ok && prev.Version == t.Version && prev.Recipe == t.Recipe && prev.Digest == t.Digest && prev.Language == t.Language { + return nil + } + seat, err := sdkstep.BuildSeat(r.AskLanguage) + if err != nil { + return err + } + if _, err := sdkstep.ToolchainKey(r.Node, t.Language); err != nil { + return err + } + body := map[string]string{"node": r.Node, "language": t.Language, "version": t.Version, "recipe": t.Recipe, "digest": t.Digest} + subject := "mesh.seat." + seat + ".event.toolchain-reported." + r.Node + if err := r.Publish(subject, body, fmt.Sprintf("toolchain-reported.%s.%d", key, rev)); err != nil { + return err + } + if r.last == nil { + r.last = map[string]sdkstep.Toolchain{} + } + r.last[key] = t + return nil +} + +// putReporting wraps a toolchains Put so each write that changes the toolchain is then said by r. A write that failed +// says nothing; an event that failed is passed to say and does not refuse the write. +func putReporting(put buildstep.Put, r *toolchainReporter, digest func(version string) string, say func(format string, a ...any)) buildstep.Put { + return func(key string, value any) (uint64, error) { + raw, merr := json.Marshal(value) + var t sdkstep.Toolchain + if merr != nil || json.Unmarshal(raw, &t) != nil { + return put(key, value) // not a toolchain value: written as it is + } + // The value carries the digest of the toolchain program beside the version and the recipe (design 47 G §472). + if t.Digest == "" && digest != nil { + t.Digest = digest(t.Version) + } + enc, eerr := sdkstep.EncodeToolchain(t) + if eerr != nil { + return put(key, value) + } + rev, err := put(key, json.RawMessage(enc)) + if err != nil || r == nil || r.Publish == nil { + return rev, err + } + if eerr := r.Report(key, rev, t); eerr != nil && say != nil { + say("toolchain-reported could not be said: %v", eerr) + } + return rev, nil + } +} + +// ProgramDigest is the sha256: digest of the file at path, a toolchain program (design 47 G §472). +func programFileDigest(path string) (string, error) { + f, err := os.Open(path) + if err != nil { + return "", err + } + defer f.Close() + h := sha256.New() + if _, err := io.Copy(h, f); err != nil { + return "", err + } + return "sha256:" + hex.EncodeToString(h.Sum(nil)), nil +} diff --git a/modules/mesh-build-go/module.json b/modules/mesh-build-go/module.json index b4a1eb975..f70fc1b91 100644 --- a/modules/mesh-build-go/module.json +++ b/modules/mesh-build-go/module.json @@ -87,14 +87,6 @@ "build_go_status", "build_go_health" ], - "streams": [ - { - "name": "toolchains-go", - "events": [ - "toolchain.**" - ] - } - ], "requires": [ { "name": "detected-steps", diff --git a/modules/mesh-build-node/cmd/mesh-build-node/asks.go b/modules/mesh-build-node/cmd/mesh-build-node/asks.go index da739e18a..d0381878d 100644 --- a/modules/mesh-build-node/cmd/mesh-build-node/asks.go +++ b/modules/mesh-build-node/cmd/mesh-build-node/asks.go @@ -37,7 +37,7 @@ func (m *Module) askSteps(steps []StepTool) buildstep.AskSteps { if err != nil { return sdkstep.ToolchainUsed{}, err } - return sdkstep.ToolchainUsed{Version: installed, Recipe: m.recipe().Version()}, nil + return sdkstep.ToolchainUsed{Version: installed, Recipe: m.recipe().Version(), Digest: m.programDigest(installed)}, nil }, Release: func(ws string) error { return m.spaces().Release(ws) }, Logf: m.Say, diff --git a/modules/mesh-build-node/cmd/mesh-build-node/emit_test.go b/modules/mesh-build-node/cmd/mesh-build-node/emit_test.go index 5d98c2395..4be2ed41e 100644 --- a/modules/mesh-build-node/cmd/mesh-build-node/emit_test.go +++ b/modules/mesh-build-node/cmd/mesh-build-node/emit_test.go @@ -5,51 +5,69 @@ import ( "encoding/json" "os" "path/filepath" - "slices" "testing" ) -// Issues 1483 and 1488 (for the indexer, issue 1444): the guard emits toolchain.reported after each write of the -// toolchains key, with the node and the language. -func TestTheGuardEmitsToolchainReportedAfterEachWrite(t *testing.T) { - m := moduleWith(newStates(), "v26.10.0") +// Design 47 G §492, §472, §466 (BG5, BG6): the guard says toolchain-reported on the Node build seat's subject of its node, +// with the version, the recipe and the digest of the node program, at the first write and again only when one of them +// changed; the toolchains key and a build's toolchain carry the digest. +func TestTheGuardSaysToolchainReportedOnAChangeWithTheProgramDigest(t *testing.T) { + states := newStates() + m := moduleWith(states, "v26.10.0") + prog := filepath.Join(t.TempDir(), "node") + os.WriteFile(prog, []byte("node program"), 0o755) + m.ToolchainProgram = func() (string, error) { return prog, nil } type event struct { - typ string - body map[string]string + subject string + body map[string]string + id string } var got []event - m.Emit = func(typ string, body any) error { + m.SayToolchain = func(subject string, body any, id string) error { raw, _ := json.Marshal(body) var b map[string]string json.Unmarshal(raw, &b) - got = append(got, event{typ, b}) + got = append(got, event{subject, b, id}) return nil } if err := m.Check(context.Background()); err != nil { t.Fatal(err) } - if len(got) != 1 || got[0].typ != "toolchain.reported" || got[0].body["node"] != "ace" || got[0].body["language"] != "typescript" || len(got[0].body) != 2 { - t.Errorf("events = %+v, want one toolchain.reported for ace, typescript", got) + if len(got) != 1 || got[0].subject != "mesh.seat.node-build-node.event.toolchain-reported.ace" || got[0].body["node"] != "ace" || got[0].body["language"] != "typescript" || + got[0].body["version"] != "v26.10.0" || got[0].body["recipe"] != m.Recipe.Version() || len(got[0].body["digest"]) != len("sha256:")+64 { + t.Fatalf("events = %+v, want one toolchain-reported for ace, go with the program's digest", got) + } + if tc, _ := states.toolchain("ace.typescript"); tc.Digest != got[0].body["digest"] { + t.Errorf("the toolchains key's digest is %q, the event's %q", tc.Digest, got[0].body["digest"]) } m.Check(context.Background()) - if len(got) != 2 { - t.Errorf("a second guard pass emitted %d events in all, want 2", len(got)) + if len(got) != 1 { + t.Errorf("a second pass that changed nothing said %d events in all, want 1", len(got)) + } + // A build's toolchain names the same digest (§466). + if used, err := m.askSteps(nil).Toolchain(context.Background()); err != nil || used.Digest != got[0].body["digest"] || used.Version != "v26.10.0" { + t.Errorf("a build's toolchain = %+v (%v), want the digest %s", used, err, got[0].body["digest"]) + } + os.WriteFile(prog, []byte("another node program"), 0o755) + m.Probe = func(context.Context) (string, error) { return "v26.11.0", nil } + m.Check(context.Background()) + if len(got) != 2 || got[1].body["version"] != "v26.11.0" || got[1].body["digest"] == got[0].body["digest"] { + t.Errorf("a new version said %+v, want a second event with another digest", got) } } -// The event is carried by the module's stream, not named in emits (ADR 0363: one place); the validator checks that. -func TestTheManifestDeclaresTheToolchainsStream(t *testing.T) { +// The seat's events are its definition's: the module declares no stream of its own for the toolchain (§492). +func TestTheManifestDeclaresNoToolchainStream(t *testing.T) { raw, err := os.ReadFile(filepath.Join("..", "..", "module.json")) if err != nil { t.Fatal(err) } var m struct { Streams []struct { - Name string `json:"name"` - Events []string `json:"events"` + Name string `json:"name"` } `json:"streams"` } - if err := json.Unmarshal(raw, &m); err != nil || len(m.Streams) != 1 || m.Streams[0].Name != "toolchains-node" || !slices.Equal(m.Streams[0].Events, []string{"toolchain.**"}) { + if err := json.Unmarshal(raw, &m); err != nil || len(m.Streams) != 0 { t.Errorf("streams = %+v (%v)", m.Streams, err) } } diff --git a/modules/mesh-build-node/cmd/mesh-build-node/main.go b/modules/mesh-build-node/cmd/mesh-build-node/main.go index eb037cd9e..e8ff97ff1 100644 --- a/modules/mesh-build-node/cmd/mesh-build-node/main.go +++ b/modules/mesh-build-node/cmd/mesh-build-node/main.go @@ -45,7 +45,7 @@ func main() { Probe: ProbeNode, Toolchain: &Toolchain{Paths: paths, Run: dforgekey.ExecRunnerClean(0), Registry: registry}, Toolchains: stdio.State(seat.StateToolchains).Put, - Emit: func(t string, b any) error { return stdio.Emit(t, b) }, + SayToolchain: stdio.SeatPublish, Steps: stdio.State(seat.StateSteps).Put, ReadSteps: readOf(stdio.State(seat.StateSteps)), Detected: stdio.State(seat.StateDetectedSteps).Put, diff --git a/modules/mesh-build-node/cmd/mesh-build-node/module.go b/modules/mesh-build-node/cmd/mesh-build-node/module.go index f51485aff..1d93108f7 100644 --- a/modules/mesh-build-node/cmd/mesh-build-node/module.go +++ b/modules/mesh-build-node/cmd/mesh-build-node/module.go @@ -7,6 +7,7 @@ import ( stdio "git.novox.be/novox/mesh-sdk/go" "net/http" "os" + "os/exec" "sync" "time" @@ -64,9 +65,15 @@ type Module struct { ForgetPause func(key string) error // WatchPauses follows the drops other holders write in the shared pauses state (stdio.State(...).Watch). WatchPauses func(key string, handle func(stdio.StateChange) error) error - // Emit tells the bus an event (stdio.Emit); nil emits nothing. The guard emits toolchain.reported after each write of - // the toolchains key, so the indexer, which holds no timer, learns that the bucket exists (issue 1444). - Emit func(eventType string, body any) error + // SayToolchain says an event on a seat's subject (stdio.SeatPublish; nil says none, a test's); ToolchainProgram finds the + // node program whose digest the toolchain's is (exec.LookPath when nil). + SayToolchain func(subject string, body any, id string) error + ToolchainProgram func() (string, error) + reporterOnce sync.Once + reporter *toolchainReporter + digestMu sync.Mutex + digestFor string + progDigest string // Ctx is the process's context, which a step runs under (the tool call's is cut at 30 s, the step's is not). Ctx context.Context Say func(format string, a ...any) @@ -266,20 +273,38 @@ func pathOrDefault() string { return defaultPath } -// ToolchainReported is the event the guard emits after it writes the toolchains key. -const ToolchainReported = "toolchain.reported" - -// reportingPut is the toolchains Put that then emits toolchain.reported with the node and the language. A write that -// failed emits nothing; an emit that failed is said and does not refuse the step. +// reportingPut is the toolchains Put that then says toolchain-reported on the Node build seat's subject of this node when +// the toolchain changed (design 47 G §492): at the module's start and on each change of the key. A write that failed +// says nothing; an event that failed is said and does not refuse the step. func (m *Module) reportingPut() buildstep.Put { - return func(key string, value any) (uint64, error) { - rev, err := m.Toolchains(key, value) - if err != nil || m.Emit == nil { - return rev, err + m.reporterOnce.Do(func() { + m.reporter = &toolchainReporter{AskLanguage: sdkstep.AskNode, Node: m.Node, Publish: m.SayToolchain} + }) + return putReporting(m.Toolchains, m.reporter, m.programDigest, m.Say) +} + +// programDigest is the digest of the node program this node's node-toolchain provides, the `node` found on the path: read +// again only when the installed version changed (design 47 G §472; the lead's answer A-152). +func (m *Module) programDigest(version string) string { + m.digestMu.Lock() + defer m.digestMu.Unlock() + if m.digestFor == version && m.progDigest != "" { + return m.progDigest + } + find := m.ToolchainProgram + if find == nil { + find = func() (string, error) { return exec.LookPath("node") } + } + path, err := find() + if err == nil { + var d string + if d, err = programFileDigest(path); err == nil { + m.digestFor, m.progDigest = version, d + return d } - if eerr := m.Emit(ToolchainReported, map[string]string{"node": m.Node, "language": "typescript"}); eerr != nil && m.Say != nil { - m.Say("the event %s could not be emitted: %v", ToolchainReported, eerr) - } - return rev, nil + } + if m.Say != nil { + m.Say("the digest of the node toolchain program could not be read: %v", err) } + return "" } diff --git a/modules/mesh-build-node/cmd/mesh-build-node/toolchainreport.go b/modules/mesh-build-node/cmd/mesh-build-node/toolchainreport.go new file mode 100644 index 000000000..d0c609a74 --- /dev/null +++ b/modules/mesh-build-node/cmd/mesh-build-node/toolchainreport.go @@ -0,0 +1,94 @@ +package main + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep" + "io" + "os" + "sync" + + sdkstep "git.novox.be/novox/mesh-delivery/sdk/buildstep" +) + +// toolchainReporter says `toolchain-reported` on the build seat's subject of its node (design 47 G §492): once at the +// module's start and each time its toolchains key of the language changes (a write that changes nothing says nothing). +// Language is the seat's ask language (go, node, image). +type toolchainReporter struct { + AskLanguage, Node string + // Publish is stdio.SeatPublish: the event is de-duplicated by its id. + Publish func(subject string, body any, id string) error + + mu sync.Mutex + last map[string]sdkstep.Toolchain +} + +// Report says the toolchain written under key at revision rev when it differs from the last one said for the key. +func (r *toolchainReporter) Report(key string, rev uint64, t sdkstep.Toolchain) error { + r.mu.Lock() + defer r.mu.Unlock() + if prev, ok := r.last[key]; ok && prev.Version == t.Version && prev.Recipe == t.Recipe && prev.Digest == t.Digest && prev.Language == t.Language { + return nil + } + seat, err := sdkstep.BuildSeat(r.AskLanguage) + if err != nil { + return err + } + if _, err := sdkstep.ToolchainKey(r.Node, t.Language); err != nil { + return err + } + body := map[string]string{"node": r.Node, "language": t.Language, "version": t.Version, "recipe": t.Recipe, "digest": t.Digest} + subject := "mesh.seat." + seat + ".event.toolchain-reported." + r.Node + if err := r.Publish(subject, body, fmt.Sprintf("toolchain-reported.%s.%d", key, rev)); err != nil { + return err + } + if r.last == nil { + r.last = map[string]sdkstep.Toolchain{} + } + r.last[key] = t + return nil +} + +// putReporting wraps a toolchains Put so each write that changes the toolchain is then said by r. A write that failed +// says nothing; an event that failed is passed to say and does not refuse the write. +func putReporting(put buildstep.Put, r *toolchainReporter, digest func(version string) string, say func(format string, a ...any)) buildstep.Put { + return func(key string, value any) (uint64, error) { + raw, merr := json.Marshal(value) + var t sdkstep.Toolchain + if merr != nil || json.Unmarshal(raw, &t) != nil { + return put(key, value) // not a toolchain value: written as it is + } + // The value carries the digest of the toolchain program beside the version and the recipe (design 47 G §472). + if t.Digest == "" && digest != nil { + t.Digest = digest(t.Version) + } + enc, eerr := sdkstep.EncodeToolchain(t) + if eerr != nil { + return put(key, value) + } + rev, err := put(key, json.RawMessage(enc)) + if err != nil || r == nil || r.Publish == nil { + return rev, err + } + if eerr := r.Report(key, rev, t); eerr != nil && say != nil { + say("toolchain-reported could not be said: %v", eerr) + } + return rev, nil + } +} + +// ProgramDigest is the sha256: digest of the file at path, a toolchain program (design 47 G §472). +func programFileDigest(path string) (string, error) { + f, err := os.Open(path) + if err != nil { + return "", err + } + defer f.Close() + h := sha256.New() + if _, err := io.Copy(h, f); err != nil { + return "", err + } + return "sha256:" + hex.EncodeToString(h.Sum(nil)), nil +} diff --git a/modules/mesh-build-node/module.json b/modules/mesh-build-node/module.json index 4678e794e..99da70e35 100644 --- a/modules/mesh-build-node/module.json +++ b/modules/mesh-build-node/module.json @@ -56,14 +56,6 @@ "build_node_status", "build_node_health" ], - "streams": [ - { - "name": "toolchains-node", - "events": [ - "toolchain.**" - ] - } - ], "requires": [ {"name": "detected-steps", "storage": "key-value", "template": "state", "options": {"history": 1}}, "artifact-store", From 48ad5ce947f5c0de60f5e0e2ae3ac05c47f21cc2 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 05:15:43 +0100 Subject: [PATCH 4/4] BG8, BN8, BI2: each artifact carries the version the ask names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Go program is linked with -X main.meshVersion beside main.builtFor, a Node program's package.json version is set, an image is labelled org.opencontainers.image.version; a build with no version stamps none. Builds: design 47 G §458, §573 Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h --- .../mesh-build-go/cmd/mesh-build-go/build.go | 6 ++++++ .../cmd/mesh-build-go/build_test.go | 7 +++++++ .../cmd/mesh-build-image/build.go | 2 +- .../cmd/mesh-build-image/build_test.go | 4 ++-- .../cmd/mesh-build-node/build.go | 8 +++++++ .../cmd/mesh-build-node/build_test.go | 21 +++++++++++++++++++ 6 files changed, 45 insertions(+), 3 deletions(-) diff --git a/modules/mesh-build-go/cmd/mesh-build-go/build.go b/modules/mesh-build-go/cmd/mesh-build-go/build.go index d42fd7dbf..26f007085 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/build.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/build.go @@ -26,6 +26,8 @@ const ( recipeFlags = "-trimpath -buildvcs=false" recipeLinkerFlags = "-s -w" recipeSystemStamp = "main.builtFor" + // versionStamp is the Go variable a program carries its version in (design 47 G §458). + versionStamp = "main.meshVersion" // outDir is where a build leaves an artifact in the module's directory (builder.Out). outDir = ".mesh-build" ) @@ -153,6 +155,10 @@ func (m *Module) build(ctx context.Context, args map[string]any) (any, error) { if system := strings.TrimSpace(art.System); system != "" { linker += " -X " + recipeSystemStamp + "=" + system } + // A build asked with a version carries it in main.meshVersion beside main.builtFor; one with none stamps none (§458). + if version != "" { + linker += " -X " + versionStamp + "=" + version + } cmd := []string{"-C", dir, "build", "-ldflags", linker, "-o", filepath.Join(out, binary), "./" + strings.Trim(art.From, "./")} // Shut off (ADR 0448): the shared caches are written by a trunk build alone (publish), a candidate's go build writes // beneath a throwaway layer. diff --git a/modules/mesh-build-go/cmd/mesh-build-go/build_test.go b/modules/mesh-build-go/cmd/mesh-build-go/build_test.go index 29dda3fbc..0c3f6a989 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/build_test.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/build_test.go @@ -273,6 +273,13 @@ func TestTheBuildGetsTheVersionTheAskCarriesAsMeshVersion(t *testing.T) { } got := "" for _, r := range *calls { + // §458: the program is linked with main.meshVersion beside main.builtFor, and with no version none is stamped. + if strings.Contains(r.line, " build ") && strings.Contains(r.line, "-X main.meshVersion=") != (version != "") { + t.Errorf("ask version %q: the go build line is %q", version, r.line) + } + if version != "" && strings.Contains(r.line, " build ") && !strings.Contains(r.line, "-X main.meshVersion="+version) { + t.Errorf("ask version %q: the go build line lacks main.meshVersion: %q", version, r.line) + } if strings.HasPrefix(r.line, "go ") { for _, e := range r.env { if strings.HasPrefix(e, "MESH_VERSION=") { diff --git a/modules/mesh-build-image/cmd/mesh-build-image/build.go b/modules/mesh-build-image/cmd/mesh-build-image/build.go index 79b582095..14ebe5482 100644 --- a/modules/mesh-build-image/cmd/mesh-build-image/build.go +++ b/modules/mesh-build-image/cmd/mesh-build-image/build.go @@ -172,7 +172,7 @@ func (m *Module) build(ctx context.Context, args map[string]any) (any, error) { argv = append(argv, imageArgs(manifest)...) // The version the build ask carries is the build argument MESH_VERSION (design 47 G §370). if version != "" { - argv = append(argv, "--build-arg", "MESH_VERSION="+version) + argv = append(argv, "--build-arg", "MESH_VERSION="+version, "--label", "org.opencontainers.image.version="+version) // the label of §458 } argv = append(argv, contextDir) if r := m.Run(ctx, m.env(), "buildah", argv...); r.Status != 0 || r.Err != "" { diff --git a/modules/mesh-build-image/cmd/mesh-build-image/build_test.go b/modules/mesh-build-image/cmd/mesh-build-image/build_test.go index 7bbb850c4..1a533a77a 100644 --- a/modules/mesh-build-image/cmd/mesh-build-image/build_test.go +++ b/modules/mesh-build-image/cmd/mesh-build-image/build_test.go @@ -338,12 +338,12 @@ func TestTheImageBuildGetsTheVersionTheAskCarriesAsMeshVersion(t *testing.T) { t.Fatalf("step = %+v", st) } bud := f.called("bud") - if len(bud) != 1 || !strings.Contains(bud[0].line, "--build-arg MESH_VERSION=1.4.3") { + if len(bud) != 1 || !strings.Contains(bud[0].line, "--build-arg MESH_VERSION=1.4.3") || !strings.Contains(bud[0].line, "--label org.opencontainers.image.version=1.4.3") { t.Fatalf("bud = %+v", bud) } m, f, states = setup(t, same) runBuild(t, m, states, nil) - if bud := f.called("bud"); len(bud) != 1 || strings.Contains(bud[0].line, "MESH_VERSION") { + if bud := f.called("bud"); len(bud) != 1 || (strings.Contains(bud[0].line, "MESH_VERSION") || strings.Contains(bud[0].line, "image.version")) { t.Fatalf("a build with no version: %+v", bud) } } diff --git a/modules/mesh-build-node/cmd/mesh-build-node/build.go b/modules/mesh-build-node/cmd/mesh-build-node/build.go index 4147c76c5..b14f72172 100644 --- a/modules/mesh-build-node/cmd/mesh-build-node/build.go +++ b/modules/mesh-build-node/cmd/mesh-build-node/build.go @@ -137,6 +137,14 @@ func (m *Module) build(ctx context.Context, args map[string]any) (any, error) { if err := copyPackages(filepath.Join(m.toolchainDir(), "node_modules"), filepath.Join(stage, "node_modules")); err != nil { return nil, fmt.Errorf("providing the toolchain's packages to the build: %w", err) } + // A program reads its own version from its package.json `version`, which is the version the ask carries (§458); the + // staged copy is the build's own, so nothing is restored. A build with no version, or a module with no package.json, + // keeps what it carries. + if _, err := os.Stat(filepath.Join(dir, "package.json")); err == nil && version != "" { + if _, err := setPackageVersion(dir, version); err != nil { + return nil, err + } + } out, final := Out(a.Name), Out(a.Name)+".bundled" if err := os.MkdirAll(filepath.Join(dir, out), 0o755); err != nil { return nil, err diff --git a/modules/mesh-build-node/cmd/mesh-build-node/build_test.go b/modules/mesh-build-node/cmd/mesh-build-node/build_test.go index 469482168..7c899578c 100644 --- a/modules/mesh-build-node/cmd/mesh-build-node/build_test.go +++ b/modules/mesh-build-node/cmd/mesh-build-node/build_test.go @@ -328,3 +328,24 @@ func TestTheBundleBuildGetsTheVersionTheAskCarriesAsMeshVersion(t *testing.T) { } } } + +// Design 47 G §458 (BN8): a Node program reads its version from its package.json `version`, which is the version the +// ask carries; a build with no version keeps what the package.json carries. +func TestTheBundleCarriesTheAsksVersionInItsPackageJSON(t *testing.T) { + for _, c := range []struct{ version, want string }{{"1.4.3", "1.4.3"}, {"", "0.0.0"}} { + m, f, work := builtMachine(t, "") + workspaceWith(t, work, map[string]string{"modules/m/package.json": `{"name":"m","version":"0.0.0"}`}) + args := buildArgs() + if c.version != "" { + args["version"] = c.version + } + if _, err := m.build(context.Background(), args); err != nil { + t.Fatal(err) + } + _ = f + raw, err := os.ReadFile(filepath.Join(work, "w1", "stage-tools", "modules", "module", "package.json")) + if err != nil || !strings.Contains(string(raw), `"version": "`+c.want+`"`) && !strings.Contains(string(raw), `"version":"`+c.want+`"`) { + t.Errorf("ask version %q: the staged package.json is %q (%v), want version %s", c.version, raw, err, c.want) + } + } +}