From 68088ec782b88ba9a34290f99de28e94462834cd Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 19 Oct 2026 11:54:21 +0200 Subject: [PATCH] Record the inputs of a build's source fingerprint in its build record (issue 1215) --- cmd/mesh-builder/main.go | 1 + cmd/mesh-controller/build.go | 1 + cmd/mesh-controller/build_test.go | 11 +++++ internal/builder/build_source_test.go | 19 +++++++++ internal/builder/builder.go | 7 +++- internal/builder/source.go | 9 +++++ internal/builder/source_sdk_test.go | 42 +++++++++++++++++++ internal/chain/records.go | 29 +++++++------ internal/inventory/builds.go | 3 ++ internal/inventory/chain_builds.go | 4 +- internal/inventory/chain_builds_test.go | 54 +++++++++++++++++++++++++ internal/link/build.go | 5 +++ internal/link/source_inputs_test.go | 35 ++++++++++++++++ 13 files changed, 204 insertions(+), 16 deletions(-) create mode 100644 internal/link/source_inputs_test.go diff --git a/cmd/mesh-builder/main.go b/cmd/mesh-builder/main.go index 468f2a4b..db7cd778 100644 --- a/cmd/mesh-builder/main.go +++ b/cmd/mesh-builder/main.go @@ -432,6 +432,7 @@ func answer(ctx context.Context, publisher builder.Publisher, on, workspace stri } result.Against = built.Against result.SourceFingerprint = built.Source + result.SourceInputs = built.SourceInputs // What the walk did instead of building, or why it built (novox/hq ADR 0350 §3, §4). result.ReuseKey = built.ReuseKey if built.Reused != nil { diff --git a/cmd/mesh-controller/build.go b/cmd/mesh-controller/build.go index 10b20eba..5028bdf4 100644 --- a/cmd/mesh-controller/build.go +++ b/cmd/mesh-controller/build.go @@ -153,6 +153,7 @@ func buildFrom(result link.BuildResult) inventory.Build { Path: result.Path, // What it was made from (novox/hq issue 280): two builds with one are one build. SourceFingerprint: result.SourceFingerprint, + SourceInputs: result.SourceInputs, // And its reuse key (novox/hq ADR 0350 §1), which the chain names (ADR 0408 §1). ReuseKey: result.ReuseKey, } diff --git a/cmd/mesh-controller/build_test.go b/cmd/mesh-controller/build_test.go index ab1bf2e7..434f961b 100644 --- a/cmd/mesh-controller/build_test.go +++ b/cmd/mesh-controller/build_test.go @@ -2,10 +2,12 @@ package main import ( "bytes" + "reflect" "strings" "testing" "time" + sdkdelivery "git.novox.be/novox/mesh-sdk/go/delivery" "github.com/novox/mesh-controller/internal/inventory" "github.com/novox/mesh-controller/internal/link" ) @@ -66,3 +68,12 @@ func TestBuildsSaysTheSourceFingerprint(t *testing.T) { t.Errorf("the other lines changed:\n%s\nwant\n%s", plain.String(), want) } } + +// 1215/AC4: what the builder said of the inputs is what the controller keeps of the build. +func TestTheControllerKeepsTheInputsABuilderSaid(t *testing.T) { + in := &sdkdelivery.SourceInputs{Module: "mailu", Tree: "t", Toolchains: map[string]string{"code": "ts sha256:aa 01"}} + kept := buildFrom(link.BuildResult{ID: "build-1", Repository: "r", Commit: "c", SourceFingerprint: "src1:x", SourceInputs: in}) + if !reflect.DeepEqual(kept.SourceInputs, in) { + t.Errorf("the controller keeps %+v, the builder said %+v", kept.SourceInputs, in) + } +} diff --git a/internal/builder/build_source_test.go b/internal/builder/build_source_test.go index e356ccd7..87ae28fe 100644 --- a/internal/builder/build_source_test.go +++ b/internal/builder/build_source_test.go @@ -9,6 +9,8 @@ import ( "slices" "strings" "testing" + + "git.novox.be/novox/mesh-sdk/go/delivery" ) // What a build says it was made from, as files (novox/hq ADR 0267), and what a build compiling a Go @@ -202,3 +204,20 @@ func TestAGoBundleSaysItsClosureAndAnUntoldImageNothing(t *testing.T) { t.Fatalf("an image compiling nothing it was told of said a build source: %+v", got.Sources) } } + +// 1215/AC1 (compiled here, run by the merge check; the unit level has 1215/AC1 over the same inputs): a build's +// result carries every input of its fingerprint, and the SDK function over them gives it again. +func TestABuildSaysTheInputsItsFingerprintWasComputedFrom(t *testing.T) { + got, _, _ := buildTheProxy(t, aSharedRepository("one", "1"), false) + if got.SourceInputs == nil { + t.Fatal("the result carries no source inputs") + } + in := *got.SourceInputs + if in.Module != "route-proxy" || in.Tree == "" || in.Contexts["server"] == "" { + t.Errorf("the inputs miss the module, its tree or the server's context: %+v", in) + } + fp, none := delivery.SourceFingerprint(in) + if fp == "" || none != "" || fp != got.Source { + t.Errorf("the inputs give %q (%q); the build recorded %q", fp, none, got.Source) + } +} diff --git a/internal/builder/builder.go b/internal/builder/builder.go index 634a6346..ea264e80 100644 --- a/internal/builder/builder.go +++ b/internal/builder/builder.go @@ -21,6 +21,7 @@ import ( "strings" "time" + "git.novox.be/novox/mesh-sdk/go/delivery" "github.com/novox/mesh-controller/internal/catalogue" ) @@ -91,6 +92,10 @@ type Result struct { // (novox/hq issue 280). Source string + // SourceInputs are what Source was computed from (novox/hq ADR 0437 §5, issue 1215), set also where the source + // does not pin the build, so the reason is visible: with them delivery.SourceFingerprint gives Source again. + SourceInputs *delivery.SourceInputs + // ReuseKey is the build's reuse key (novox/hq ADR 0350 §1): its source fingerprint's parts with the builder's // version, and ReuseParts those parts; empty, with NoKey saying why, where the source does not pin the build. ReuseKey string @@ -491,7 +496,7 @@ func build(ctx context.Context, run Runner, publish Publisher, // The reuse key (novox/hq ADR 0350 §1): what a check keeps its build under, and what a walk takes one by. key := keyOf(src, manifest, Version) return Result{Manifest: resolved, Commit: commit, Built: built, - Against: against(within, manifest, stoodOn), Read: readBy(manifest), Source: fingerprint, + Against: against(within, manifest, stoodOn), Read: readBy(manifest), Source: fingerprint, SourceInputs: resultInputs(src), Trunk: trunk, OnTrunk: onTrunk, Branches: branches, Sources: sources, ReuseKey: key.Key, ReuseParts: key.Parts, NoKey: key.Why, Reused: reused, Why: builtWhy, Predefined: listed}, nil diff --git a/internal/builder/source.go b/internal/builder/source.go index fecee717..7ea82049 100644 --- a/internal/builder/source.go +++ b/internal/builder/source.go @@ -278,6 +278,15 @@ func (s *sourceInputs) inputs() delivery.SourceInputs { } } +// resultInputs are the inputs a build's result records; nil for a build with no inputs to speak of. +func resultInputs(s *sourceInputs) *delivery.SourceInputs { + if s == nil { + return nil + } + in := s.inputs() + return &in +} + // fingerprint is the build's source fingerprint, or empty when the source does not pin the build. func (s *sourceInputs) fingerprint() string { if s == nil { diff --git a/internal/builder/source_sdk_test.go b/internal/builder/source_sdk_test.go index 05c6259b..6da2ff76 100644 --- a/internal/builder/source_sdk_test.go +++ b/internal/builder/source_sdk_test.go @@ -1,6 +1,7 @@ package builder import ( + "encoding/json" "testing" "git.novox.be/novox/mesh-sdk/go/delivery" @@ -81,3 +82,44 @@ func TestTheBuilderFingerprintsWithTheSDKFunction(t *testing.T) { t.Errorf("the SDK function was given %+v", got) } } + +// 1215/AC1: the inputs a result records, read back, give the fingerprint the build recorded (the live builds of +// 1214/AC1), through encoding/json as the build record keeps them. +func TestTheInputsAResultRecordsGiveItsFingerprintAgain(t *testing.T) { + s := &sourceInputs{module: "build-agent", tree: "11c0b4f90cae0d6e4ef9cc48cbfdf9f6dbe52bca", + bases: []string{ + "sha256:d9e853e87e55526f6b2917df91a2115c36dd7c696a35be12163d44e6e2a4b6bc", + "sha256:8ac98ca534ac3f51e1f420a1dd2c15e74c75cfa0f23f3ad27eb5d7236c349a0c", + }, + contexts: map[string]string{"server": "narrow:d51ca744fd11af59d616c858f76c27b3e58e16e11fbdc0c720eb9d7d19d55fac"}} + raw, err := json.Marshal(resultInputs(s)) + if err != nil { + t.Fatal(err) + } + var back delivery.SourceInputs + if err := json.Unmarshal(raw, &back); err != nil { + t.Fatal(err) + } + want := "src1:e846efe73716b78e80860baaac96153de2cb76551325753838ca245d2160a3d7" + if fp, none := delivery.SourceFingerprint(back); fp != want || none != "" { + t.Errorf("the inputs read back give %q (%q), want %q", fp, none, want) + } +} + +// 1215/AC3: a build that gets no fingerprint still has its inputs recorded, so the reason is visible. +func TestABuildWithNoFingerprintStillSaysItsInputs(t *testing.T) { + s := newSourceInputs("app") + s.tree = "aaaa" + s.notPinned("it resolves packages from the mesh's registry at build time") + in := resultInputs(s) + if in == nil || in.Module != "app" || in.Tree != "aaaa" || in.Unpinned == "" { + t.Fatalf("the inputs of an unpinned build: %+v", in) + } + if fp, none := delivery.SourceFingerprint(*in); fp != "" || none != in.Unpinned { + t.Errorf("the inputs give %q (%q)", fp, none) + } + noTree := resultInputs(newSourceInputs("app")) + if noTree == nil || noTree.Module != "app" || noTree.Tree != "" { + t.Errorf("the inputs of a build with no tree: %+v", noTree) + } +} diff --git a/internal/chain/records.go b/internal/chain/records.go index 78bdeaab..2738abd2 100644 --- a/internal/chain/records.go +++ b/internal/chain/records.go @@ -10,6 +10,8 @@ package chain import ( "encoding/json" "time" + + "git.novox.be/novox/mesh-sdk/go/delivery" ) // Ref names a build and the commit it was made from: one end of a change. @@ -97,19 +99,20 @@ type Build struct { Path string `json:"path,omitempty"` Manifest json.RawMessage `json:"manifest,omitempty"` // ManifestDigest names the manifest in the manifests bucket (ManifestKey); a record that has it keeps no Manifest. - ManifestDigest string `json:"manifest-digest,omitempty"` - Against []string `json:"against,omitempty"` - Mirrored []string `json:"mirrored,omitempty"` - Read []ReadRepository `json:"read,omitempty"` - Sources []Source `json:"sources,omitempty"` - SourceFingerprint string `json:"source-fingerprint,omitempty"` - Failed string `json:"failed,omitempty"` - Made []Artifact `json:"made,omitempty"` - Asked time.Time `json:"asked,omitzero"` - At time.Time `json:"at,omitzero"` - ReuseKey string `json:"reuse-key,omitempty"` - Published *Published `json:"published,omitempty"` - Request *Request `json:"request,omitempty"` + ManifestDigest string `json:"manifest-digest,omitempty"` + Against []string `json:"against,omitempty"` + Mirrored []string `json:"mirrored,omitempty"` + Read []ReadRepository `json:"read,omitempty"` + Sources []Source `json:"sources,omitempty"` + SourceFingerprint string `json:"source-fingerprint,omitempty"` + SourceInputs *delivery.SourceInputs `json:"source-inputs,omitempty"` + Failed string `json:"failed,omitempty"` + Made []Artifact `json:"made,omitempty"` + Asked time.Time `json:"asked,omitzero"` + At time.Time `json:"at,omitzero"` + ReuseKey string `json:"reuse-key,omitempty"` + Published *Published `json:"published,omitempty"` + Request *Request `json:"request,omitempty"` } // Commit is one repository's commit a rollout carries. diff --git a/internal/inventory/builds.go b/internal/inventory/builds.go index 99a3bed4..fea4a256 100644 --- a/internal/inventory/builds.go +++ b/internal/inventory/builds.go @@ -5,6 +5,7 @@ import ( "strings" "time" + "git.novox.be/novox/mesh-sdk/go/delivery" "github.com/novox/mesh-controller/internal/chain" ) @@ -51,6 +52,8 @@ type Build struct { // SourceFingerprint is what the build was made from, hashed, as its builder said it (novox/hq // issue 280); empty from a builder that predates it, or where the source does not pin the build. SourceFingerprint string + // SourceInputs are what it was computed from (novox/hq ADR 0437 §5); nil from a build recorded before they were. + SourceInputs *delivery.SourceInputs // Failed is the builder's own words, empty when it worked. Failed string Made []Artifact diff --git a/internal/inventory/chain_builds.go b/internal/inventory/chain_builds.go index 25e4aa79..0ae1b7a3 100644 --- a/internal/inventory/chain_builds.go +++ b/internal/inventory/chain_builds.go @@ -23,7 +23,7 @@ import ( // toChainBuild is a build as the bucket keeps it. func toChainBuild(b Build) chain.Build { c := chain.Build{ID: b.ID, Repository: b.Repository, Ref: b.Ref, Module: b.Module, Commit: b.Commit, On: b.On, - Path: b.Path, Against: b.Against, Mirrored: b.Mirrored, SourceFingerprint: b.SourceFingerprint, + Path: b.Path, Against: b.Against, Mirrored: b.Mirrored, SourceFingerprint: b.SourceFingerprint, SourceInputs: b.SourceInputs, ManifestDigest: b.ManifestDigest, Failed: b.Failed, Asked: b.Asked.UTC(), At: b.At.UTC(), ReuseKey: b.ReuseKey, Published: b.Published} if len(b.Manifest) > 0 { c.Manifest = json.RawMessage(b.Manifest) @@ -46,7 +46,7 @@ func toChainBuild(b Build) chain.Build { // fromChainBuild is a build as the mesh reads it. func fromChainBuild(c chain.Build) Build { b := Build{ID: c.ID, Repository: c.Repository, Ref: c.Ref, Module: c.Module, Commit: c.Commit, On: c.On, Path: c.Path, - Manifest: []byte(c.Manifest), ManifestDigest: c.ManifestDigest, Against: c.Against, Mirrored: c.Mirrored, SourceFingerprint: c.SourceFingerprint, + Manifest: []byte(c.Manifest), ManifestDigest: c.ManifestDigest, Against: c.Against, Mirrored: c.Mirrored, SourceFingerprint: c.SourceFingerprint, SourceInputs: c.SourceInputs, Failed: c.Failed, Asked: c.Asked, At: c.At, ReuseKey: c.ReuseKey, Published: c.Published} if len(b.Manifest) == 0 { b.Manifest = nil diff --git a/internal/inventory/chain_builds_test.go b/internal/inventory/chain_builds_test.go index 348b20d1..b1ef3314 100644 --- a/internal/inventory/chain_builds_test.go +++ b/internal/inventory/chain_builds_test.go @@ -5,9 +5,11 @@ import ( "encoding/json" "fmt" "reflect" + "strings" "testing" "time" + "git.novox.be/novox/mesh-sdk/go/delivery" "github.com/nats-io/nats.go" "github.com/novox/mesh-controller/internal/chain" @@ -129,3 +131,55 @@ func TestTheBusBeingAwayIsNotNowLikeTheStoreBeingAway(t *testing.T) { } } } + +// 1215/AC1 and AC4: the inputs of a build's fingerprint are kept in the build's record, through the bucket's bytes, unchanged. +func TestABuildKeepsTheInputsOfItsSourceFingerprint(t *testing.T) { + in := &delivery.SourceInputs{Module: "notes", Tree: "t1", Bases: []string{"r/b@sha256:aa"}, + Contexts: map[string]string{"server": "narrow:ff"}, Toolchains: map[string]string{"code": "go sha256:cc 0123"}} + fp, _ := delivery.SourceFingerprint(*in) + b := Build{ID: "build-1", Module: "notes", Commit: "c1", SourceFingerprint: fp, SourceInputs: in} + raw, err := json.Marshal(toChainBuild(b)) + if err != nil { + t.Fatal(err) + } + var back chain.Build + if err := json.Unmarshal(raw, &back); err != nil { + t.Fatal(err) + } + got := fromChainBuild(back) + if !reflect.DeepEqual(got.SourceInputs, in) { + t.Fatalf("the inputs came back as %+v, not %+v", got.SourceInputs, in) + } + if again, _ := delivery.SourceFingerprint(*got.SourceInputs); again != got.SourceFingerprint { + t.Errorf("the inputs read back give %q, the record says %q", again, got.SourceFingerprint) + } +} + +// 1215/AC3: a build with no fingerprint keeps its inputs, so the reason is visible. +func TestABuildWithNoFingerprintKeepsItsInputs(t *testing.T) { + in := &delivery.SourceInputs{Module: "notes", Tree: "t1", Unpinned: "it resolves packages when built"} + raw, _ := json.Marshal(toChainBuild(Build{ID: "build-1", Module: "notes", SourceInputs: in})) + var back chain.Build + if err := json.Unmarshal(raw, &back); err != nil { + t.Fatal(err) + } + if got := fromChainBuild(back); got.SourceFingerprint != "" || !reflect.DeepEqual(got.SourceInputs, in) { + t.Errorf("read back as %q with %+v", got.SourceFingerprint, got.SourceInputs) + } +} + +// 1215/AC2: a record written before the inputs were kept reads as before, with none and no error. +func TestABuildRecordFromBeforeTheInputsReadsAsBefore(t *testing.T) { + var back chain.Build + old := `{"id":"build-1","module":"notes","commit":"c1","source-fingerprint":"src1:aa","reuse-key":"rk"}` + if err := json.Unmarshal([]byte(old), &back); err != nil { + t.Fatal(err) + } + got := fromChainBuild(back) + if got.SourceInputs != nil || got.SourceFingerprint != "src1:aa" || got.ReuseKey != "rk" { + t.Errorf("an old record reads as %+v", got) + } + if raw, _ := json.Marshal(toChainBuild(got)); strings.Contains(string(raw), "source-inputs") { + t.Errorf("an old record is written with inputs: %s", raw) + } +} diff --git a/internal/link/build.go b/internal/link/build.go index d40ca40a..d16601eb 100644 --- a/internal/link/build.go +++ b/internal/link/build.go @@ -5,6 +5,8 @@ import ( "strconv" "strings" "time" + + "git.novox.be/novox/mesh-sdk/go/delivery" ) // Asking a machine to build a module, and hearing what came out. @@ -410,6 +412,9 @@ type BuildResult struct { // byte-reproducible. Empty from a builder that predates it, or where the source does not pin the // build; then only identical artifacts make a rebuild no move. SourceFingerprint string `json:"source-fingerprint,omitempty"` + // SourceInputs are what SourceFingerprint was computed from (novox/hq ADR 0437 §5, issue 1215), as encoding/json + // writes the SDK's type; also set where the source does not pin the build. + SourceInputs *delivery.SourceInputs `json:"source-inputs,omitempty"` // Delivery is the how-manyth time the queue handed this ask out (BuildStart.Delivery): more than 1 // says the outcome is from a run started again after an earlier taker left it (novox/hq issue 594). diff --git a/internal/link/source_inputs_test.go b/internal/link/source_inputs_test.go new file mode 100644 index 00000000..f37ff09f --- /dev/null +++ b/internal/link/source_inputs_test.go @@ -0,0 +1,35 @@ +package link + +import ( + "encoding/json" + "reflect" + "testing" + + "git.novox.be/novox/mesh-sdk/go/delivery" +) + +// 1215/AC4: the builder's result on the wire carries the inputs, under source-inputs, and reads back unchanged. +func TestABuildResultCarriesTheSourceInputs(t *testing.T) { + in := &delivery.SourceInputs{Module: "m", Tree: "t", Bases: []string{"x@sha256:a"}, + Contexts: map[string]string{"c": "narrow:1"}, Toolchains: map[string]string{"c": "go sha256:b 01"}, Unpinned: "why"} + raw, err := json.Marshal(BuildResult{ID: "build-1", SourceInputs: in}) + if err != nil { + t.Fatal(err) + } + var wire map[string]json.RawMessage + if err := json.Unmarshal(raw, &wire); err != nil || wire["source-inputs"] == nil { + t.Fatalf("the result on the wire has no source-inputs: %s", raw) + } + var back BuildResult + if err := json.Unmarshal(raw, &back); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(back.SourceInputs, in) { + t.Errorf("read back as %+v, not %+v", back.SourceInputs, in) + } + raw, _ = json.Marshal(BuildResult{ID: "build-2"}) + wire = nil + if err := json.Unmarshal(raw, &wire); err != nil || wire["source-inputs"] != nil { + t.Errorf("a result with none writes source-inputs: %s", raw) + } +}