mesh-delivery-sdk: module.json with its one package artifact; trunk, buildrecord and forgekey (the builder kind, the clean runner, Said) move in from mesh-sdk (D-6, D-9, ADR 0634 §3, ADR 0635 §4; issue 1966)

This commit is contained in:
2026-11-05 14:16:52 +01:00
parent 69edf1e99e
commit 5651c611f7
9 changed files with 512 additions and 0 deletions
+70
View File
@@ -0,0 +1,70 @@
package buildrecord
import "time"
// BuildRecord is the layout the ended controller's chain wrote (mesh-controller internal/chain/records.go Build, line 91),
// the fields a reader outside it read (novox/hq ADR 0437 §4 §5, issue 1223). The controller's record carries more; a decode ignores
// what is not here. Path is the module's directory in its repository, as the controller writes it (Build.Path, records.go:100,
// set from the build request trimmed of slashes at internal/inventory/chain_requests.go:19); empty for a module whose
// manifest is the repository's root. SourceInputs has no json keys of its own: it is written under its Go field names, as the controller
// writes it.
type BuildRecord struct {
ID string `json:"id"`
State string `json:"state,omitempty"`
Failed string `json:"failed,omitempty"`
Repository string `json:"repository"`
Ref string `json:"ref,omitempty"`
Module string `json:"module,omitempty"`
Path string `json:"path,omitempty"`
Commit string `json:"commit,omitempty"`
At time.Time `json:"at,omitzero"`
SourceFingerprint string `json:"source-fingerprint,omitempty"`
SourceInputs *SourceInputs `json:"source-inputs,omitempty"`
// Made is what the build made (Build.Made, records.go:110); the digest is the tail of an artifact's Reference.
Made []Artifact `json:"made,omitempty"`
}
// Artifact is one thing a build made: mesh-controller internal/chain/records.go Artifact, json keys name, kind and
// reference, nothing else (novox/hq ADR 0478 clause 8).
type Artifact struct {
Name string `json:"name"`
Kind string `json:"kind"`
Reference string `json:"reference"`
}
// BuildKey is a build's record under its module, a build that knew none under the request's key: copied from
// mesh-controller internal/chain/keys.go:35-40 (BuildKey, with RequestKey at line 42).
func BuildKey(module, id string) string {
if module == "" {
return "req." + id
}
return module + ".build." + id
}
// NewestKey is the key of a module's newest build, of any state: copied from mesh-controller internal/chain/keys.go:44.
// The key holds no record: its value is the newest build's id as raw bytes (internal/inventory/chain_builds.go:210 and
// :546), and the record lives at BuildKey(module, id). The pointer is a hint, checked against that record.
func NewestKey(module string) string { return module + ".newest" }
// SourceInputs are what the ended controller's source fingerprint (src1) was of, as its build records hold them
// (BuildRecord), read and never computed again (novox/hq ADR 0437 §5): the module, the git tree of
// its directory, its bases (references, of which only the digest counts), the root tree of each repository its
// artifacts read (by artifact), each artifact's toolchain line, and the reason the source does not pin the build
// when it does not (a build that resolves packages when built). A context is the value the builder records for it:
// a repository's root tree, or for an image that compiles a Go program the `narrow:` hash of the build source it was
// handed (mesh-controller internal/builder/builder.go:1146-1156).
type SourceInputs struct {
Module string
Tree string
Bases []string
Contexts map[string]string
Toolchains map[string]string
Unpinned string
}
// IsTrunkBuild is whether the record is a successful build of the trunk that recorded its source inputs: an outcome
// (the controller writes no state on one; state asked is a request, records.go:57-59), not failed, and ref "main" or none. A build a delivery plan asked after a merge, or rebuild or build --behind, follows the branch
// without naming it, so it carries no ref; a build of any other branch or of a commit names it (issue 1281).
func (r BuildRecord) IsTrunkBuild() bool {
return r.State == "" && r.Failed == "" && r.SourceInputs != nil && (r.Ref == "" || r.Ref == "main")
}
+179
View File
@@ -0,0 +1,179 @@
package buildrecord
import (
"encoding/json"
"os"
"testing"
"time"
)
// The fixture's path is what the controller writes for a build request: strings.Trim(path, "/") at mesh-controller
// internal/inventory/chain_requests.go:19, the module's directory in its repository (Build.Path, internal/chain/records.go:100).
// The fixture started as a record the controller's own encoder wrote (json.Marshal of a chain.Build, mesh-controller
// internal/chain, a throwaway test run once and deleted) with build-agent's build-1792395447088936520 values, plus a
// manifest-digest and one made artifact (shape: mesh-controller internal/chain/records.go:91-117 Build, json tags, at commit
// 1f708e2d8d72e2c27dbeaba02f14c128d700eada; an outcome has no state key and a build asked after a merge no ref, records.go:57-59), which a BuildRecord does not carry. It was then edited by hand
// to the controller's layout for an outcome: state and ref removed, the repository a clone URL (issue 1281).
func loadBuildFixture(t *testing.T) []byte {
t.Helper()
raw, err := os.ReadFile("testdata/build-record-build-agent.json")
if err != nil {
t.Fatal(err)
}
return raw
}
func TestBuildRecordDecodes(t *testing.T) { // 1223/AC1
var r BuildRecord
if err := json.Unmarshal(loadBuildFixture(t), &r); err != nil {
t.Fatal(err)
}
for _, c := range []struct{ name, got, want string }{
{"id", r.ID, "build-1792395447088936520"},
{"module", r.Module, "build-agent"},
{"repository", r.Repository, "http://novox.internal:20000/novox/mesh-controller.git"},
{"ref", r.Ref, ""},
{"commit", r.Commit, "7a74cee83f88e6585a5511c3cc53bdf0f5303d20"},
{"state", r.State, ""},
{"failed", r.Failed, ""},
{"source-fingerprint", r.SourceFingerprint, "src1:e846efe73716b78e80860baaac96153de2cb76551325753838ca245d2160a3d7"},
} {
if c.got != c.want {
t.Errorf("%s = %q, want %q", c.name, c.got, c.want)
}
}
if want := time.Date(2026, 10, 19, 9, 38, 0, 0, time.UTC); !r.At.Equal(want) {
t.Errorf("at = %v, want %v", r.At, want)
}
if !r.IsTrunkBuild() { // 1281/AC7
t.Error("the fixture, a record as the controller writes it, is not a trunk build")
}
if r.SourceInputs == nil {
t.Fatal("source-inputs missing")
}
}
func TestBuildRecordPath(t *testing.T) { // 1233/AC1
var r BuildRecord
if err := json.Unmarshal(loadBuildFixture(t), &r); err != nil {
t.Fatal(err)
}
if r.Path != "modules/build-agent" {
t.Errorf("path = %q, want modules/build-agent", r.Path)
}
// mesh-controller's manifest is its repository's root: the controller writes no path (omitempty), so the path is "".
var m map[string]json.RawMessage
if err := json.Unmarshal(loadBuildFixture(t), &m); err != nil {
t.Fatal(err)
}
delete(m, "path")
raw, _ := json.Marshal(m)
var root BuildRecord
if err := json.Unmarshal(raw, &root); err != nil {
t.Fatal(err)
}
if root.Path != "" {
t.Errorf("path of a root module = %q, want empty", root.Path)
}
// Every field 1223/AC1 checks is still equal in both.
if root.ID != r.ID || root.Module != r.Module || root.Repository != r.Repository || root.Ref != r.Ref ||
root.Commit != r.Commit || root.State != r.State || root.SourceFingerprint != r.SourceFingerprint || !root.At.Equal(r.At) {
t.Errorf("fields differ: %+v vs %+v", root, r)
}
// And the path survives a round trip through the SDK's own type.
back, _ := json.Marshal(r)
var again BuildRecord
if err := json.Unmarshal(back, &again); err != nil || again.Path != r.Path {
t.Errorf("round trip path = %q (%v)", again.Path, err)
}
}
func TestBuildRecordWithoutInputs(t *testing.T) { // 1223/AC2
var m map[string]json.RawMessage
if err := json.Unmarshal(loadBuildFixture(t), &m); err != nil {
t.Fatal(err)
}
delete(m, "source-inputs")
raw, _ := json.Marshal(m)
var r BuildRecord
if err := json.Unmarshal(raw, &r); err != nil {
t.Fatal(err)
}
if r.SourceInputs != nil {
t.Errorf("source-inputs = %+v, want nil", r.SourceInputs)
}
if r.Module != "build-agent" {
t.Errorf("module = %q, want build-agent", r.Module)
}
}
func TestBuildKeys(t *testing.T) { // 1223/AC3
// Literal values of mesh-controller internal/broker/controller_buckets.go:101 (BucketName(ControllerSeat, ChainBuilds)
// is "mesh-controller_builds") and internal/chain/keys.go:35-44.
if got := BuildKey("build-agent", "build-1"); got != "build-agent.build.build-1" {
t.Errorf("BuildKey = %q", got)
}
if got := BuildKey("", "build-1"); got != "req.build-1" {
t.Errorf("BuildKey without a module = %q", got)
}
if got := NewestKey("build-agent"); got != "build-agent.newest" {
t.Errorf("NewestKey = %q", got)
}
}
// The fixture's made artifacts are the controller's Artifact (mesh-controller internal/chain/records.go, json tags name,
// kind, reference; the controller writes no digest key, the digest is the tail of the reference). The kinds and references
// are those of the live record mesh-controller.builds printed for build-agent build-1792540091397811913 (2026-10-21 01:49).
func TestBuildRecordMade(t *testing.T) { // 1588/AC2
var r BuildRecord
if err := json.Unmarshal(loadBuildFixture(t), &r); err != nil {
t.Fatal(err)
}
want := []Artifact{
{Name: "server", Kind: "image", Reference: "artifact-store://build-agent/server@sha256:a79c13f1c727ff94959e80824cc704ea58b80d80ecdd00fd447698acebb35090"},
{Name: "files", Kind: "archive", Reference: "artifact-store://build-agent/files/blobs/sha256:bccd3f47851436d1b45a3063cc2d7e34c5c109b2d030739bfbb4b9781703f9a9"},
}
if len(r.Made) != len(want) {
t.Fatalf("made = %+v, want %+v", r.Made, want)
}
for i := range want {
if r.Made[i] != want[i] {
t.Errorf("made[%d] = %+v, want %+v", i, r.Made[i], want[i])
}
}
var none BuildRecord
if err := json.Unmarshal([]byte(`{"id":"b","repository":"a/b"}`), &none); err != nil || none.Made != nil {
t.Errorf("a record without made: %+v, %v", none, err)
}
}
func TestIsTrunkBuild(t *testing.T) { // 1281/AC2
inputs := &SourceInputs{Module: "zsh", Tree: "t"}
for _, c := range []struct {
name string
r BuildRecord
want bool
}{
{"built on main", BuildRecord{Ref: "main", SourceInputs: inputs}, true},
// a build a delivery plan asked after a merge, or rebuild / build --behind, carries no ref (live builds,
// 2026-10-19: zsh build-1792417485811374131, mesh-delivery build-1792435539606203626)
{"built with no ref", BuildRecord{SourceInputs: inputs}, true},
{"another branch", BuildRecord{Ref: "feat/x", SourceInputs: inputs}, false},
{"a commit", BuildRecord{Ref: "9efdd87a", SourceInputs: inputs}, false},
{"asked", BuildRecord{State: "asked", SourceInputs: inputs}, false},
{"answered, state answered, no source inputs", BuildRecord{State: "answered"}, false},
{"failed", BuildRecord{Failed: "boom", Ref: "main", SourceInputs: inputs}, false},
{"no source inputs", BuildRecord{Ref: "main"}, false},
{"no ref and no source inputs", BuildRecord{}, false},
} {
if got := c.r.IsTrunkBuild(); got != c.want {
t.Errorf("%s: IsTrunkBuild = %v, want %v", c.name, got, c.want)
}
}
// decoded as the controller writes it: the key "ref" is absent
var r BuildRecord
raw := `{"id":"build-1792417485811374131","repository":"http://novox.internal:20000/novox/mesh-catalog.git","module":"zsh","source-inputs":{"Module":"zsh","Tree":"t"}}`
if err := json.Unmarshal([]byte(raw), &r); err != nil || !r.IsTrunkBuild() {
t.Errorf("a decoded record with no ref: trunk = %v, err %v", r.IsTrunkBuild(), err)
}
}
@@ -0,0 +1 @@
{"id":"build-1792395447088936520","repository":"http://novox.internal:20000/novox/mesh-controller.git","module":"build-agent","commit":"7a74cee83f88e6585a5511c3cc53bdf0f5303d20","path":"modules/build-agent","manifest-digest":"sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef","source-fingerprint":"src1:e846efe73716b78e80860baaac96153de2cb76551325753838ca245d2160a3d7","source-inputs":{"Module":"build-agent","Tree":"11c0b4f90cae0d6e4ef9cc48cbfdf9f6dbe52bca","Bases":["sha256:d9e853e87e55526f6b2917df91a2115c36dd7c696a35be12163d44e6e2a4b6bc","sha256:8ac98ca534ac3f51e1f420a1dd2c15e74c75cfa0f23f3ad27eb5d7236c349a0c"],"Contexts":{"server":"narrow:d51ca744fd11af59d616c858f76c27b3e58e16e11fbdc0c720eb9d7d19d55fac"},"Toolchains":null,"Unpinned":""},"made":[{"name":"server","kind":"image","reference":"artifact-store://build-agent/server@sha256:a79c13f1c727ff94959e80824cc704ea58b80d80ecdd00fd447698acebb35090"},{"name":"files","kind":"archive","reference":"artifact-store://build-agent/files/blobs/sha256:bccd3f47851436d1b45a3063cc2d7e34c5c109b2d030739bfbb4b9781703f9a9"}],"at":"2026-10-19T09:38:00Z"}
+107
View File
@@ -0,0 +1,107 @@
// Package forgekey holds the Delivery domain's names beside mesh-sdk's forgekey (novox/hq ADR 0635 §4): the kind of
// key a build module keeps, the runner whose commands get exactly the environment they are given (a fetch holds no
// registry password), and the words a failed command is reported in. mesh-sdk's forgekey keeps what every module's
// key keeper shares: KeyKeeper, KeyPutter, Runner, ExecRunner and Ran.
package forgekey
import (
"bytes"
"context"
"errors"
"fmt"
"os"
"os/exec"
"strings"
"time"
"unicode/utf8"
sdk "git.novox.be/novox/mesh-sdk/go/forgekey"
)
// KindBuilder is the kind of key a build module's account keeps (ADR 0466 §2); sdk.KindMachine is a machine's own.
const KindBuilder = "builder"
// ExecRunnerClean is a runner whose commands get exactly the env argument as their environment, and nothing of this
// process's (issue 1497; novox/hq ADR 0466 §4: no registry password in a fetch's environment). The caller gives PATH
// and HOME itself. A directory is the command's own business (`git -C`, `go -C`): it is started in the HOME it is
// given, or the machine's temporary directory, never in this process's. bound 0 leaves only the step's own context.
func ExecRunnerClean(bound time.Duration) sdk.Runner {
return func(ctx context.Context, env []string, name string, args ...string) sdk.Ran {
if bound > 0 {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, bound)
defer cancel()
}
cmd := exec.CommandContext(ctx, name, args...)
cmd.Env = append([]string{}, env...) // non-nil, so an empty list is an empty environment
cmd.Dir = homeOrTemp(env)
var out, errb bytes.Buffer
cmd.Stdout, cmd.Stderr = &out, &errb
err := cmd.Run()
r := sdk.Ran{Stdout: out.String(), Stderr: errb.String()}
var exitErr *exec.ExitError
switch {
case errors.Is(ctx.Err(), context.DeadlineExceeded):
r.Status, r.Err = 124, "ended by its step's deadline"
if bound > 0 {
r.Err = fmt.Sprintf("no answer within %d s", int(bound/time.Second))
}
case errors.Is(err, exec.ErrNotFound):
r.Status, r.Err = 127, "ENOENT"
case errors.As(err, &exitErr):
r.Status = exitErr.ExitCode()
case err != nil:
r.Status, r.Err = 1, err.Error()
}
return r
}
}
// homeOrTemp is the directory a clean command starts in: the HOME it is given, or the temporary directory (novox/hq
// issue 734: never one that may be deleted under it, as a module's build directory is when a new build replaces it).
func homeOrTemp(env []string) string {
for i := len(env) - 1; i >= 0; i-- {
if home, ok := strings.CutPrefix(env[i], "HOME="); ok {
if info, err := os.Stat(home); err == nil && info.IsDir() {
return home
}
break
}
}
return os.TempDir()
}
// SaidBound bounds what Said keeps of a command's words, so a reason stays a line of a step's record and of a log.
const SaidBound = 600
// Said is what a command said of its failure in its own words, for a reason: every line that says something, trimmed
// and joined with "; ", bounded at SaidBound bytes. A line that is a bare prefix and nothing else (git writes "error:"
// alone before and after the forge's own message, "remote:" or "fatal:" likewise) is left out, so the cause is never
// cut off behind it.
func Said(s string) string {
var keep []string
for _, l := range strings.Split(s, "\n") {
l = strings.TrimSpace(l)
if l == "" || barePrefix(l) {
continue
}
keep = append(keep, l)
}
out := strings.Join(keep, "; ")
if len(out) > SaidBound {
cut := SaidBound
for cut > 0 && !utf8.RuneStart(out[cut]) {
cut--
}
out = out[:cut] + " …"
}
return out
}
// barePrefix says whether a line is one word and a colon, such as "error:" or "remote:", with nothing after it.
func barePrefix(l string) bool {
if !strings.HasSuffix(l, ":") {
return false
}
return !strings.ContainsAny(strings.TrimSuffix(l, ":"), " \t:")
}
+49
View File
@@ -0,0 +1,49 @@
package forgekey
import (
"context"
"os"
"strings"
"testing"
sdk "git.novox.be/novox/mesh-sdk/go/forgekey"
)
func TestSaidKeepsTheCauseBehindABarePrefix(t *testing.T) {
forge := "error: \nerror: User: 22:nox-builder is not authorized to read novox/x.\nerror: \nfatal: Could not read from remote repository.\n\nPlease make sure you have the correct access rights\n"
for _, c := range []struct{ name, in, want string }{
{"forge refusal", forge, "error: User: 22:nox-builder is not authorized to read novox/x.; fatal: Could not read from remote repository.; Please make sure you have the correct access rights"},
{"one line", "fatal: couldn't find remote ref x\n", "fatal: couldn't find remote ref x"},
{"bare prefixes only", "error:\nremote:\n", ""},
{"a colon inside stays", " ENOENT \nfatal: bad: thing\n", "ENOENT; fatal: bad: thing"},
{"empty", "", ""},
} {
if got := Said(c.in); got != c.want {
t.Errorf("%s: Said = %q, want %q", c.name, got, c.want)
}
}
long := Said(strings.Repeat("é", SaidBound))
if !strings.HasSuffix(long, " …") || len(long) > SaidBound+len(" …") || !strings.HasPrefix(long, "é") {
t.Errorf("long: %d bytes, ends %q", len(long), long[len(long)-6:])
}
}
// ExecRunnerClean gives the command only the env argument, and starts it in the HOME it is given, even when this
// process's directory was deleted under it (issue 1497, issue 734).
func TestExecRunnerCleanGivesTheCommandOnlyTheEnvArgument(t *testing.T) {
t.Setenv("MESH_TEST_SECRET", "x")
var run sdk.Runner = ExecRunnerClean(0)
r := run(context.Background(), []string{"PATH=" + os.Getenv("PATH"), "HOME=/h"}, "env")
if r.Status != 0 || !strings.Contains(r.Stdout, "HOME=/h") || strings.Contains(r.Stdout, "MESH_TEST_SECRET") {
t.Errorf("ran = %+v", r)
}
home, gone := t.TempDir(), t.TempDir()
t.Chdir(gone)
if err := os.Remove(gone); err != nil {
t.Fatal(err)
}
r = run(context.Background(), []string{"PATH=" + os.Getenv("PATH"), "HOME=" + home}, "pwd")
if r.Status != 0 || strings.TrimSpace(r.Stdout) != home {
t.Errorf("a clean command run from a deleted directory = %+v, want it started in its HOME %s", r, home)
}
}
+18
View File
@@ -0,0 +1,18 @@
{
"module": "mesh-delivery-sdk",
"version": "1",
"build": {
"artifacts": [
{
"name": "go-sdk",
"kind": "package",
"language": "go",
"paths": [
"."
],
"series": "0.1"
}
]
},
"resources": []
}
+28
View File
@@ -0,0 +1,28 @@
package trunk
import (
"strings"
)
// A mesh package is a Go module of a repository of the mesh's forge (ForgeModulePrefix): mesh-sdk's Go module, and an
// owner module's package (novox/hq ADR 0593 §1). The indexer alone reads which packages a module imports, their content
// digests and their latest published versions, and writes them in its state (PublishedPackages, ADR 0593 §2); a build
// writes the version it is asked into its own go.mod and go.sum (StepAsk.Packages).
// ForgeModulePrefix is the module path prefix of the Go modules the mesh's forge serves: a Go module whose path starts
// with it is a mesh package, git.novox.be/novox/<repository>[/<directory of the module in it>].
const ForgeModulePrefix = "git.novox.be/novox/"
// PackageRepository is the repository (<owner>/<name>) a mesh package is published from and the package's directory in
// it ("" for its root); false for a path that is not a forge module's.
func PackageRepository(pkg string) (repository, dir string, ok bool) {
rest, found := strings.CutPrefix(pkg, ForgeModulePrefix)
if !found || rest == "" {
return "", "", false
}
name, dir, _ := strings.Cut(rest, "/")
if name == "" {
return "", "", false
}
return "novox/" + name, dir, true
}
+25
View File
@@ -0,0 +1,25 @@
package trunk
import "strings"
// RepositoryName is the repository as the forge names it, "owner/name", from either spelling the mesh holds: the name
// itself, or the clone URL a build record carries ("http://host:port/owner/name.git", with or without ".git" or a
// trailing slash). A string of fewer than two path segments is returned as it is, trimmed (issue 1281).
func RepositoryName(s string) string {
s = strings.TrimSpace(s)
s = strings.TrimRight(s, "/")
s = strings.TrimSuffix(s, ".git")
s = strings.TrimRight(s, "/")
parts := strings.FieldsFunc(s, func(r rune) bool { return r == '/' || r == ':' })
if len(parts) < 2 {
return s
}
return parts[len(parts)-2] + "/" + parts[len(parts)-1]
}
// SameRepository is whether two spellings name one repository: the same owner and name, whichever way each is written
// (RepositoryName), compared without regard to case as the forge does. An empty spelling names none (issue 1281).
func SameRepository(a, b string) bool {
a, b = RepositoryName(a), RepositoryName(b)
return a != "" && strings.EqualFold(a, b)
}
+35
View File
@@ -0,0 +1,35 @@
package trunk
import (
"testing"
)
func TestSameRepository(t *testing.T) { // 1281/AC1
const forge = "novox/mesh-catalog"
for _, c := range []struct {
a, b string
same bool
}{
// the clone URL as a build record carries it (mesh-controller.command builds, 2026-10-19 ~21:20 local)
{forge, "http://novox.internal:20000/novox/mesh-catalog.git", true},
{forge, "http://novox.internal:20000/novox/mesh-catalog", true},
{forge, "http://novox.internal:20000/novox/mesh-catalog.git/", true},
{"http://novox.internal:20000/novox/mesh-catalog.git/", forge, true},
{forge, "novox/mesh-catalog", true},
{forge, "Novox/Mesh-Catalog", true},
{forge, "http://novox.internal:20000/novox/mesh-catalog-x.git", false},
{forge, "novox/mesh-catalog-x", false},
{forge, "http://novox.internal:20000/other/mesh-catalog.git", false},
{forge, "other/mesh-catalog", false},
{forge, "http://novox.internal:20000/novox/mesh-media-catalog.git", false},
{forge, "", false},
{"", "", false},
} {
if got := SameRepository(c.a, c.b); got != c.same {
t.Errorf("SameRepository(%q, %q) = %v, want %v", c.a, c.b, got, c.same)
}
}
if got := RepositoryName("http://novox.internal:20000/novox/mesh-catalog.git"); got != "novox/mesh-catalog" {
t.Errorf("RepositoryName = %q", got)
}
}