Merge branch 'b2/registry' into b7/registry

This commit is contained in:
2026-11-05 14:32:56 +01:00
23 changed files with 1895 additions and 0 deletions
@@ -0,0 +1,372 @@
// Package builds holds what the builder module owns and no seat carries (novox/hq ADR 0634 §1): the layout of its state
// `builds` (a build per module and fingerprint) and the rules its writer keeps, the question a registry seat's holder asks
// it before it stores a publish, its event version.numbered and the entries of its build log. Other modules import it from
// the builder by its published version (ADR 0573 §1); the builder's manifest declares it as a package artifact.
package builds
import (
"encoding/json"
"fmt"
"reflect"
"slices"
"strings"
"git.novox.be/novox/mesh-delivery/contracts/go/seats/containerimageregistry"
"git.novox.be/novox/mesh-delivery/contracts/go/seats/ociartifactregistry"
"git.novox.be/novox/mesh-delivery/sdk/buildstep"
"git.novox.be/novox/mesh-delivery/sdk/fingerprint"
"git.novox.be/novox/mesh-delivery/sdk/kept"
"git.novox.be/novox/mesh-delivery/sdk/reference"
"git.novox.be/novox/mesh-delivery/sdk/trunk"
"git.novox.be/novox/mesh-sdk/go/keys"
)
// seats are the names of the registry seats a reference names first, from their contracts.
var seats = reference.Seats{Image: containerimageregistry.Seat, Object: ociartifactregistry.Seat}
// askLanguages are the languages a build seat is named after (node-build-<language>), which a building key's Asks are keyed by.
var askLanguages = []string{"go", "node", "image"}
func checkAskLanguage(language string) error {
if !slices.Contains(askLanguages, language) {
return fmt.Errorf("builds: the ask language %q is not go, node or image", language)
}
return nil
}
// The builder's keys (novox/hq ADR 0429 clauses 5 and 16, ADR 0437 clause 5, ADR 0405 clauses 2 and 7, ADR 0453
// clause 4): a build is `<module>.<fingerprint key part>` in the builder's bucket, one key per fingerprint, since equal
// fingerprints are equal content and the same source is never built twice. They are written only by the builder part
// from R4c and read by the checker, the assigner and the view. They are not the ended controller's build records
// (buildrecord.go: BuildKey).
//
// **The rules its writer keeps.** A builder claims a fingerprint that has no build key by creating it queued or
// building, a create that fails when the key exists, and moves it by compare-and-set; a builder reclaims only a build
// whose `by` is offline in presence. A failed key is built again as a new revision of the same key (ADR 0429 clause
// 18). Only the builder's credential writes the bucket (clause 5); every other part reads it.
const (
// BuilderModule is the module of the builder part.
BuilderModule = "mesh-delivery-builder"
// BuilderBuildsBucket is the builder's bucket of builds: `<owner module>_<name>`.
BuilderBuildsBucket = BuilderModule + "_builds"
// BuilderBuildsHistory is the revisions the bucket keeps per key.
BuilderBuildsHistory = 5
)
// The states of a build.
const (
BuildQueued = "queued"
BuildBuilding = "building"
BuildDone = "done"
BuildFailed = "failed"
)
// BuilderBuildKey is the key, in BuilderBuildsBucket, of a module's build at a fingerprint: `<module>.<hex>`, the hex
// being the fingerprint's key part (fingerprint.KeyPart), since a bus key may not hold ':'. A module holding a dot or a
// character a key refuses, and a fingerprint not of the new form, are refused, naming them.
func BuilderBuildKey(module, fp string) (string, error) {
if err := kept.CheckName("module", module); err != nil {
return "", err
}
part, err := fingerprint.KeyPart(fp)
if err != nil {
return "", err
}
return module + "." + part, nil
}
// ParseBuilderBuildKey is the inverse of BuilderBuildKey: the module and the fingerprint (`src2:` and the hex).
func ParseBuilderBuildKey(key string) (module, fp string, err error) {
module, part, ok := strings.Cut(key, ".")
if !ok {
return "", "", fmt.Errorf("builds: %q is not a builder build key, <module>.<fingerprint hex>", key)
}
if err := kept.CheckName("module", module); err != nil {
return "", "", fmt.Errorf("builds: %q is not a builder build key: %w", key, err)
}
if fp, err = fingerprint.ParseKeyPart(part); err != nil {
return "", "", fmt.Errorf("builds: %q is not a builder build key: %w", key, err)
}
return module, fp, nil
}
// BuiltArtifact is one artifact of a done build: its name in the manifest, its kind, its content digest (`sha256:` and
// 64 hex digits) and the reference that names where it is (novox/hq ADR 0498 clauses 1, 5 and 6; the lead's decision
// L1 of R4e), by kind:
//
// bundle, archive artifact-store://<module>/<artifact>/blobs/<digest> the blob pushed to the artifact store
// image, upstream <registry>/<module>/<artifact>@<digest> the image registry's reference, which
// artifact-store://<module>/<artifact>@<digest> catalogue.Recorded keeps without its address
// package <name>@<version> the version published from the trunk
//
// From the registry milestone a done key names the reference its seat answered (novox/hq ADR 0605 §9, design 51 *The
// done key names the reference the seat answered*): `<seat>/<module>/<artifact>@<digest>`, <seat> the registry seat it
// came through (oci-artifact-registry for a bundle or an archive, container-image-registry for an image or an upstream
// copy), and `<name>@<version>` for a package as before. A key written in the older form keeps it; this reads both, and
// no build is built again for its form (seats.InArtifactStore reads both the same way).
//
// <artifact> is Name and <digest> is Digest. A reference is empty for a candidate's image or package, which is never
// pushed or published (ADR 0498 clause 6), and in a key done before R4e; the controller delivers only from a key whose
// every artifact names one (Referenced, clause 8). A kind left empty (a key written before kinds) is not checked.
type BuiltArtifact struct {
Name string `json:"name"`
Kind string `json:"kind,omitempty"`
Digest string `json:"digest"`
Reference string `json:"reference,omitempty"`
// References are every form this artifact is named in, Reference among them (novox/hq ADR 0618, ADR 0624): a build
// keeps each form that a counted node-engine states, away nodes included, and Reference stays the form every one
// of them reads. A reader that does not know the field reads Reference alone.
References []string `json:"references,omitempty"`
}
const artifactStore = "artifact-store://"
// referenceProblem says what is wrong with a's reference for its kind, or "".
func (a BuiltArtifact) referenceProblem() string {
r := a.Reference
if r == "" || a.Kind == "" {
return ""
}
// An upstream copy may be named by the holder's own naming, upstream/<host>/<path>@<digest>, in either form: the
// holder names it, not the module that asked for it (reference.InUpstream).
if path, kept := seats.InArtifactStore(r); kept && a.Kind == "upstream" {
if _, image, ok := reference.InUpstream(path); ok {
if _, digest, _ := strings.Cut(image, "@"); digest != a.Digest {
return "does not name its digest " + a.Digest
}
return ""
}
}
if seat, path, ok := seats.InRegistrySeat(r); ok {
want := "container-image-registry"
if a.Kind == "bundle" || a.Kind == "archive" {
want = "oci-artifact-registry"
}
module, rest, _ := strings.Cut(path, "/")
if a.Kind == "package" || seat != want || module == "" || rest != a.Name+"@"+a.Digest {
return "is not " + want + "/<module>/" + a.Name + "@" + a.Digest
}
return ""
}
switch a.Kind {
case "bundle", "archive":
path, ok := strings.CutPrefix(r, artifactStore)
module, rest, _ := strings.Cut(path, "/")
if !ok || module == "" || rest != a.Name+"/blobs/"+a.Digest {
return "is not artifact-store://<module>/" + a.Name + "/blobs/" + a.Digest
}
case "image", "upstream":
path, digest, ok := strings.Cut(r, "@")
if !ok || digest != a.Digest {
return "does not name its digest " + a.Digest
}
var host, module, name string
if rest, kept := strings.CutPrefix(path, artifactStore); kept {
module, name, _ = strings.Cut(rest, "/")
host = "kept"
} else {
parts := strings.Split(path, "/")
if len(parts) == 3 {
host, module, name = parts[0], parts[1], parts[2]
}
}
if host == "" || !(host == "kept" || host == "localhost" || strings.ContainsAny(host, ".:")) || module == "" || name != a.Name {
return "is not <registry>/<module>/" + a.Name + "@" + a.Digest
}
case "package":
at := strings.LastIndex(r, "@")
if at <= 0 || at == len(r)-1 || r[:at] == "@" {
return "is not <name>@<version>"
}
}
return ""
}
// Referenced says whether b is done and every artifact names where it is, which the controller delivers from (novox/hq
// ADR 0498 clause 8), and names the artifacts that name no reference. A done key with no artifact (a module that builds
// none, issue 1613) is referenced.
func (b BuilderBuild) Referenced() (ok bool, missing []string) {
for _, a := range b.Artifacts {
if a.Reference == "" {
missing = append(missing, a.Name)
}
}
return b.State == BuildDone && len(missing) == 0, missing
}
// The prefixes of a failed key's error that a build module answers (novox/hq ADR 0498 clauses 2, 4 and 5; ADR 0478
// clause 2), each followed by the artifact and the words of what refused it.
const (
// NotStored: the push of a bundle, an archive or an upstream copy to the artifact store failed; built again.
NotStored = "not stored: "
// NotPinned: an upstream artifact names no digest.
NotPinned = "not pinned: "
// VersionTaken: the package registry holds the package's version with another packed file.
VersionTaken = "version taken: "
// RefusedKind: no build module builds this kind of artifact in its language.
RefusedKind = "refused kind: "
// WaitsFor: a fetch found the module's lock naming a package version the registry does not hold yet; the builder
// writes the key queued with Waits (novox/hq ADR 0500 clause 6), and it is built once the package is published.
WaitsFor = "waits for: "
)
// FailedUntilChanged says whether a failed key's error is one that is not built again until the module's fingerprint
// changes (not pinned, version taken, refused kind: the same source fails the same way); every other failed key, a
// push that was not stored included, is built again as a new revision (ADR 0429 clause 18).
func FailedUntilChanged(err string) bool {
return strings.HasPrefix(err, NotPinned) || strings.HasPrefix(err, VersionTaken) || strings.HasPrefix(err, RefusedKind)
}
// ToolchainUsed is what a build used for one language (ADR 0405 clauses 2 and 7): the installed toolchain's version and
// the build module's recipe version.
type ToolchainUsed struct {
Version string `json:"version"`
Recipe string `json:"recipe"`
}
// BuilderBuild is the value of a builder build key: its State (queued, building, done or failed), By the builder
// instance that holds it, Step what it is doing or did last, Manifest the digest of the module's manifest (the key of
// the indexer's manifests bucket), Artifacts when done, Toolchains by language when it built or is building (a done
// build names them, so two builders that differ are seen: ADR 0405 clause 2), Error when failed, and Waits when queued for a
// package version.
type BuilderBuild struct {
V int `json:"v"`
State string `json:"state"`
By string `json:"by,omitempty"`
Step string `json:"step,omitempty"`
Manifest string `json:"manifest,omitempty"`
Artifacts []BuiltArtifact `json:"artifacts,omitempty"`
Toolchains map[string]ToolchainUsed `json:"toolchains,omitempty"`
Error string `json:"error,omitempty"`
// Waits is, on a queued key only, the package version `<name>@<version>` the module's lock names and the registry
// does not yet hold: the key is not claimed until that package's trunk key is done and published (novox/hq ADR 0500
// clause 6). A reader that does not know the field reads the key as queued.
Waits string `json:"waits,omitempty"`
// Asks are, on a building key, the asks the builder put for it by build seat language (AskGo, AskNode, AskImage), each
// its id (novox/hq ADR 0525 §1; issue 1887 A4b): a builder that takes the key over at its start waits for those asks'
// steps and never puts them again. A reader that does not know the field reads the key as before.
Asks map[string]string `json:"asks,omitempty"`
// Imports are, on a done key, the mesh packages the built programs import, as the build module answered them
// (BuildAnswer.Imports; novox/hq ADR 0593 §2): the builder carries them onto the build's end (BuildLogEntry.Imports).
Imports []string `json:"imports,omitempty"`
// Needs are, on a done key only, the reader capabilities (`form:<name>`) the build's reference forms need of a
// node-engine, beside the ones its manifest needs (novox/hq ADR 0618 §1). A reader that does not know the field
// reads the build as needing none.
Needs []string `json:"needs,omitempty"`
}
func (b BuilderBuild) check() error {
if err := trunk.CheckImports(b.Imports); err != nil {
return err
}
switch b.State {
case BuildQueued, BuildBuilding, BuildDone, BuildFailed:
default:
return fmt.Errorf("builds: the build state %q is not queued, building, done or failed", b.State)
}
if b.Manifest != "" {
if !kept.IsHex64(b.Manifest) {
return fmt.Errorf("builds: %q is not a manifest digest, 64 lowercase hex digits", b.Manifest)
}
}
for language, t := range b.Toolchains {
if t.Version == "" {
return fmt.Errorf("builds: the toolchain of %q has no version", language)
}
if t.Recipe == "" {
return fmt.Errorf("builds: the toolchain of %q has no recipe version", language)
}
}
for i, a := range b.Artifacts {
if a.Name == "" {
return fmt.Errorf("builds: artifact %d of the build has no name", i)
}
if a.Digest == "" {
return fmt.Errorf("builds: the artifact %q of the build has no digest", a.Name)
}
if p := a.referenceProblem(); p != "" {
return fmt.Errorf("builds: the reference %q of the %s %q %s", a.Reference, a.Kind, a.Name, p)
}
if len(a.References) > 0 && !slices.Contains(a.References, a.Reference) {
return fmt.Errorf("builds: the references of the %s %q do not name its reference %q", a.Kind, a.Name, a.Reference)
}
for _, r := range a.References {
if p := (BuiltArtifact{Name: a.Name, Kind: a.Kind, Digest: a.Digest, Reference: r}).referenceProblem(); p != "" {
return fmt.Errorf("builds: the reference %q of the %s %q %s", r, a.Kind, a.Name, p)
}
}
}
for _, n := range b.Needs {
if !strings.HasPrefix(n, "form:") || len(n) == len("form:") {
return fmt.Errorf("builds: the need %q of the build is not form:<name>", n)
}
}
if len(b.Needs) > 0 && b.State != BuildDone {
return fmt.Errorf("builds: a %s build names needs; only a done key does", b.State)
}
for language, id := range b.Asks {
if b.State != BuildBuilding {
return fmt.Errorf("builds: a %s build names the ask %s; only a building key does", b.State, id)
}
if err := checkAskLanguage(language); err != nil {
return err
}
if err := buildstep.CheckAskID(id); err != nil {
return err
}
}
if b.Waits != "" {
if b.State != BuildQueued {
return fmt.Errorf("builds: a %s build waits for %q; only a queued key waits for a package", b.State, b.Waits)
}
if p := (BuiltArtifact{Kind: "package", Reference: b.Waits}).referenceProblem(); p != "" {
return fmt.Errorf("builds: the package a key waits for, %q, %s", b.Waits, p)
}
}
switch b.State {
case BuildQueued, BuildBuilding:
if len(b.Artifacts) > 0 || b.Error != "" {
return fmt.Errorf("builds: a %s build carries no artifact and no error", b.State)
}
case BuildDone:
// A module that builds no artifact gets a done key too, and is delivered from it (issue 1613).
if b.Manifest == "" {
return fmt.Errorf("builds: a done build names no manifest digest")
}
if b.Error != "" {
return fmt.Errorf("builds: a done build carries an error")
}
case BuildFailed:
if b.Error == "" {
return fmt.Errorf("builds: a failed build says no error")
}
}
return nil
}
// EncodeBuilderBuild is b as JSON at Version; it refuses a build its state does not allow (done without a manifest
// digest, failed without an error, an artifact without a name or digest, a toolchain without a version or a
// recipe version).
func EncodeBuilderBuild(b BuilderBuild) ([]byte, error) {
if err := b.check(); err != nil {
return nil, err
}
b.V = keys.Version
return json.Marshal(b)
}
// DecodeBuilderBuild reads a builder build value; it refuses an unknown version (kept.ErrUnknownVersion) and what
// EncodeBuilderBuild refuses.
func DecodeBuilderBuild(data []byte) (BuilderBuild, error) {
var b BuilderBuild
if err := kept.Decode(data, &b); err != nil {
return BuilderBuild{}, err
}
if err := b.check(); err != nil {
return BuilderBuild{}, err
}
return b, nil
}
// SameBuilderBuild says whether two builds are the same value, so the builder writes only when they differ.
func SameBuilderBuild(a, b BuilderBuild) bool { return reflect.DeepEqual(a, b) }
@@ -0,0 +1,224 @@
package builds
import (
"encoding/json"
"errors"
"fmt"
"reflect"
"strings"
"testing"
"git.novox.be/novox/mesh-delivery/sdk/buildrecord"
"git.novox.be/novox/mesh-delivery/sdk/kept"
"git.novox.be/novox/mesh-sdk/go/keys"
)
func digestOf(c byte) string { return strings.Repeat(string(c), 64) }
func fp2of(c byte) string { return "src2:" + digestOf(c) }
func doneBuild() BuilderBuild {
return BuilderBuild{
State: BuildDone,
By: "builder.novox.1",
Step: "pushed",
Manifest: digestOf('4'),
Artifacts: []BuiltArtifact{
{Name: "code", Kind: "bundle", Digest: "sha256:" + digestOf('5')},
{Name: "server", Kind: "image", Digest: "sha256:" + digestOf('6'), Reference: "registry.example/mail/server@sha256:" + digestOf('6')},
},
Toolchains: map[string]ToolchainUsed{
"go": {Version: "1.26.0", Recipe: "3"},
"typescript": {Version: "5.9.2", Recipe: "1"},
},
}
}
func builderSamples() map[string]BuilderBuild {
failed := BuilderBuild{State: BuildFailed, By: "builder.novox.1", Step: "test", Error: "go test: 2 failed"}
both := doneBuild()
both.Needs = []string{"form:artifact-registry"}
both.Artifacts[1].References = []string{both.Artifacts[1].Reference, "artifact-store://mail/server@" + both.Artifacts[1].Digest}
return map[string]BuilderBuild{
"done with needs and both forms": both,
"queued": {State: BuildQueued, Manifest: digestOf('4')},
"building": {State: BuildBuilding, By: "builder.novox.1", Step: "fetch", Manifest: digestOf('4')},
"done": doneBuild(),
"failed": failed,
}
}
// 1358/AC1: the key is <module>.<hex>, parsing gives both back; a module with a dot, an empty one, and a fingerprint not
// of the new form are refused, naming it.
func TestBuilderBuildKey(t *testing.T) {
fp := fp2of('a')
key, err := BuilderBuildKey("mesh-mail", fp)
if err != nil || key != "mesh-mail."+strings.Repeat("a", 64) {
t.Fatalf("key = %q, %v", key, err)
}
module, back, err := ParseBuilderBuildKey(key)
if err != nil || module != "mesh-mail" || back != fp {
t.Errorf("parse = %q, %q, %v", module, back, err)
}
src1 := "src1:" + strings.Repeat("1", 64)
for module, fp := range map[string]string{"a.b": fp, "": fp, "mesh-mail": src1, "mail ": fp} {
if _, err := BuilderBuildKey(module, fp); err == nil {
t.Errorf("BuilderBuildKey(%q, %q) accepted", module, fp)
}
}
if _, err := BuilderBuildKey("a.b", fp); err == nil || !strings.Contains(err.Error(), "a.b") {
t.Errorf("a dotted module is not named: %v", err)
}
if _, err := BuilderBuildKey("mesh-mail", src1); err == nil || !strings.Contains(err.Error(), src1) {
t.Errorf("a src1 fingerprint is not named: %v", err)
}
for _, bad := range []string{"", "mesh-mail", "mesh-mail.", "mesh-mail.abc", "mesh-mail." + strings.Repeat("A", 64), ".x", "mesh-mail.build.7", "a.b." + strings.Repeat("a", 64)} {
if _, _, err := ParseBuilderBuildKey(bad); err == nil || !strings.Contains(err.Error(), "\""+bad+"\"") {
t.Errorf("ParseBuilderBuildKey(%q) = %v, want a refusal naming it", bad, err)
}
}
}
// 1358/AC2: a build value in each state encodes and decodes unchanged.
func TestBuilderBuildRoundTrip(t *testing.T) {
for name, b := range builderSamples() {
data, err := EncodeBuilderBuild(b)
if err != nil {
t.Fatalf("%s: %v", name, err)
}
if !strings.HasPrefix(string(data), `{"v":1,`) {
t.Errorf("%s at %s", name, data)
}
got, err := DecodeBuilderBuild(data)
b.V = keys.Version
if err != nil || !SameBuilderBuild(got, b) {
t.Errorf("%s: %+v %v", name, got, err)
}
}
}
// 1358/AC3: done without a manifest digest, failed without an error, an artifact without a digest
// and an unknown state are refused.
func TestBuilderBuildChecked(t *testing.T) {
cases := map[string]struct {
edit func(*BuilderBuild)
words []string
}{
"done without a manifest": {func(b *BuilderBuild) { b.Manifest = "" }, []string{"manifest"}},
"a malformed manifest": {func(b *BuilderBuild) { b.Manifest = "abc" }, []string{"abc"}},
"an artifact without a digest": {func(b *BuilderBuild) { b.Artifacts[1].Digest = "" }, []string{"digest", "server"}},
"an artifact without a name": {func(b *BuilderBuild) { b.Artifacts[0].Name = "" }, []string{"name"}},
"a need that is not a form": {func(b *BuilderBuild) { b.Needs = []string{"word:x"} }, []string{"word:x", "form:"}},
"a reference not among the references": {func(b *BuilderBuild) {
b.Artifacts[1].References = []string{"artifact-store://mail/server@" + b.Artifacts[1].Digest}
}, []string{"server", "references"}},
"a reference of another artifact": {func(b *BuilderBuild) {
b.Artifacts[1].References = []string{b.Artifacts[1].Reference, "registry.example/mail/other@" + b.Artifacts[1].Digest}
}, []string{"server", "other"}},
"an unknown state": {func(b *BuilderBuild) { b.State = "paused" }, []string{"paused"}},
"a toolchain without a version": {func(b *BuilderBuild) { b.Toolchains["go"] = ToolchainUsed{Recipe: "3"} }, []string{"go", "version"}},
"a toolchain without a recipe": {func(b *BuilderBuild) { b.Toolchains["go"] = ToolchainUsed{Version: "1.26.0"} }, []string{"go", "recipe"}},
}
for name, c := range cases {
b := doneBuild()
c.edit(&b)
_, err := EncodeBuilderBuild(b)
if err == nil {
t.Errorf("%s accepted", name)
continue
}
for _, w := range c.words {
if !strings.Contains(err.Error(), w) {
t.Errorf("%s: error %q does not name %q", name, err, w)
}
}
}
f := builderSamples()["failed"]
f.Error = ""
if _, err := EncodeBuilderBuild(f); err == nil || !strings.Contains(err.Error(), "error") {
t.Errorf("failed without an error: %v", err)
}
// a queued or building value may not already carry an outcome
q := builderSamples()["queued"]
q.Needs = []string{"form:x"}
if _, err := EncodeBuilderBuild(q); err == nil {
t.Error("a queued build with needs accepted")
}
q.Needs = nil
q.Artifacts = doneBuild().Artifacts
if _, err := EncodeBuilderBuild(q); err == nil {
t.Error("a queued build with artifacts accepted")
}
// decode refuses what encode refuses
if _, err := DecodeBuilderBuild([]byte(`{"v":1,"state":"done"}`)); err == nil {
t.Error("decoded a done build without a manifest digest")
}
}
// 1358/AC4: a done value names the toolchain version per language and the recipe version; two values that differ only
// there are not the same.
func TestBuilderBuildNamesItsToolchains(t *testing.T) {
a, b, c := doneBuild(), doneBuild(), doneBuild()
b.Toolchains["go"] = ToolchainUsed{Version: "1.26.1", Recipe: "3"}
c.Toolchains["go"] = ToolchainUsed{Version: "1.26.0", Recipe: "4"}
if SameBuilderBuild(a, b) || SameBuilderBuild(a, c) || SameBuilderBuild(b, c) {
t.Error("values differing only in a toolchain's version or recipe are the same")
}
if !SameBuilderBuild(a, doneBuild()) {
t.Error("equal values differ")
}
data, err := EncodeBuilderBuild(a)
if err != nil {
t.Fatal(err)
}
got, err := DecodeBuilderBuild(data)
if err != nil || !reflect.DeepEqual(got.Toolchains, a.Toolchains) {
t.Errorf("toolchains = %+v, %v", got.Toolchains, err)
}
}
// 1358/AC5: a value of another version, or none, is refused with kept.ErrUnknownVersion.
func TestBuilderBuildVersion(t *testing.T) {
for _, in := range []string{`{"v":2,"state":"queued"}`, `{"state":"queued"}`} {
if _, err := DecodeBuilderBuild([]byte(in)); !errors.Is(err, kept.ErrUnknownVersion) {
t.Errorf("DecodeBuilderBuild(%s) = %v", in, err)
}
}
// A holder's question before it stores (ADR 0605 §4): asked at the builder's address with name, version and digest;
// an answer without recorded is an error, never a yes.
for answer, want := range map[string]string{`{"recorded":true}`: "true ", `{"recorded":false,"why":"other digest"}`: "false other digest",
`{"name":"x"}`: "error", `not json`: "error"} {
b := BuilderVersions{Ask: func(addr string, body any) (json.RawMessage, error) {
if a := body.(map[string]any); addr != BuilderVersionsAddress || a["name"] != "n" || a["version"] != "0.1.2" || a["digest"] != "sha256:d" {
t.Errorf("asked %s %v", addr, body)
}
return json.RawMessage(answer), nil
}}
ok, why, err := b.Recorded("n", "0.1.2", "sha256:d")
if got := fmt.Sprintf("%v %s", ok, why); err != nil && want != "error" || err == nil && got != want {
t.Errorf("answer %s: %s, %v; want %s", answer, got, err, want)
}
}
}
// 1358/AC6: the builder's bucket and module do not collide with the controller's build layout (buildrecord.go), which
// this change leaves as it was.
func TestBuilderLayoutDoesNotCollideWithTheControllers(t *testing.T) {
if BuilderBuildsBucket != "mesh-delivery-builder_builds" || BuilderBuildsHistory != 5 || BuilderModule != "mesh-delivery-builder" {
t.Errorf("bucket %s history %d module %s", BuilderBuildsBucket, BuilderBuildsHistory, BuilderModule)
}
if buildrecord.BuildKey("m", "7") != "m.build.7" || buildrecord.NewestKey("m") != "m.newest" {
t.Error("the controller's layout changed")
}
// the two key forms are never read as each other
key, _ := BuilderBuildKey("m", fp2of('a'))
if key == buildrecord.BuildKey("m", "a") || key == buildrecord.NewestKey("m") {
t.Errorf("builder key %q equals a controller key", key)
}
if _, _, err := ParseBuilderBuildKey(buildrecord.BuildKey("m", digestOf('a'))); err == nil {
t.Error("a controller build key parsed as the builder's")
}
if _, _, err := ParseBuilderBuildKey(buildrecord.NewestKey("m")); err == nil {
t.Error("a controller newest key parsed as the builder's")
}
}
@@ -0,0 +1,10 @@
module git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds
go 1.26.0
require (
git.novox.be/novox/mesh-delivery/contracts/go/seats/containerimageregistry v0.0.0
git.novox.be/novox/mesh-delivery/contracts/go/seats/ociartifactregistry v0.0.0
git.novox.be/novox/mesh-delivery/sdk v0.0.0
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f
)
+177
View File
@@ -0,0 +1,177 @@
package builds
import (
"encoding/json"
"fmt"
"sort"
"time"
"git.novox.be/novox/mesh-delivery/sdk/kept"
"git.novox.be/novox/mesh-delivery/sdk/trunk"
"git.novox.be/novox/mesh-sdk/go/keys"
)
// The builder's log (clause 1; the lead and the operator, 2026-10-29): the builder's own module log (ADR 0297) `build-log`,
// kept 30 days (max-age-days), one entry under the key `<module>` when a build of a fingerprint starts and one when it
// ends, so the newest entry of a key is the module's newest start or end. The builder's tools read it through the
// runtime's log verbs; another module reads it by naming BuildLogRead in its manifest's `reads`: a bundle through the
// runtime (stdio.Log(BuildLogRead)), a process on its own credential (process.Conn.ReadLog), each by newest per key,
// from a time, or following new entries. The catalogue module asks the builder's tool BuildsSinceTool instead.
const (
BuilderLog = "build-log"
BuildLogRead = BuilderModule + "." + BuilderLog
BuildStartedEvent = "build.started"
BuildEndedEvent = "build.ended"
// BuildSaidEvent is a build's narration between its start and its end (design 51: a build narrates its publish,
// asked, waiting on a seat, stored, refused, at least every five minutes while it waits): its module, fingerprint,
// line and why.
BuildSaidEvent = "build.said"
// BuilderLogDays is how long the log keeps its entries.
BuilderLogDays = 30
// BuildsSinceTool is the builder's tool answering every ended build since a time, in order (clause 9): its
// argument `since` (RFC 3339), its answer BuildsSince.
BuildsSinceTool = "builder_builds_since"
)
// BuildLogKey is the key of a module's entries in the builder's log: the module's name, refused when a log key could not
// hold it.
func BuildLogKey(module string) (string, error) {
if err := kept.CheckName("module", module); err != nil {
return "", err
}
return module, nil
}
// BuildLogEntry is one entry of the builder's log: the module, the fingerprint, the source revision, where the module is
// built from (its repository, its directory there, the commit and the ref), the build node and the time; at the end,
// the outcome, the manifest built (its digest, read from the indexer's state `manifests` under ManifestsKey), what it was
// built against (the bases' references, as the controller's built-before said them) and each artifact made
// (BuiltArtifact, the done key's: its name, kind, digest and reference). The repository, directory and commit are what
// the indexer's PathOf, the outside-built registrations and the assigner's running commits read; the manifest, against
// and made what the catalogue module registers a build from (the lead, 2026-10-29).
type BuildLogEntry struct {
V int `json:"v"`
Event string `json:"event"`
Module string `json:"module"`
FP string `json:"fp"`
Source string `json:"source"`
Repository string `json:"repository"`
Directory string `json:"directory"`
Commit string `json:"commit"`
Ref string `json:"ref,omitempty"`
Node string `json:"node"`
At time.Time `json:"at"`
Outcome string `json:"outcome,omitempty"`
Manifest string `json:"manifest,omitempty"`
Against []string `json:"against,omitempty"`
Made []BuiltArtifact `json:"made,omitempty"`
// Imports are, at a build's end, the mesh packages (import paths) the built programs import, their tests left out,
// sorted and each once, as the build module answered them (BuildAnswer.Imports; novox/hq ADR 0593 §2): the indexer
// keeps them on the module (KeptInputs2.Imports) when the build's fingerprint is the module's.
Imports []string `json:"imports,omitempty"`
// OutOfStep are, at a build's end, the generated files its test step found out of step with what their generator
// writes, as the build module answered them (its test step's `out-of-step`): the indexer raises a finding naming them
// for a trunk build, and only a clean build of the same module clears it. A start or a narration names none.
OutOfStep []string `json:"out-of-step,omitempty"`
// Line is a narration's words, Why what it narrates (asked, waiting, stored, refused): a build.said entry's alone.
Line string `json:"line,omitempty"`
Why string `json:"why,omitempty"`
}
// BuildsSince is the answer of BuildsSinceTool: every ended build since a time, oldest first, and whether the log held
// more than one answer carries (then ask again from the last one's time).
type BuildsSince struct {
Since time.Time `json:"since"`
Builds []BuildLogEntry `json:"builds"`
More bool `json:"more,omitempty"`
}
// NewestPerModule is each module's newest entry of entries (one kind, read from the last per subject or whole), by
// module: the reader's helper for the five newest and the running commits.
func NewestPerModule(entries []BuildLogEntry) map[string]BuildLogEntry {
out := map[string]BuildLogEntry{}
for _, e := range entries {
if have, ok := out[e.Module]; !ok || e.At.After(have.At) {
out[e.Module] = e
}
}
return out
}
// EndedSince is, of entries, the ended builds at or after since, oldest first: the reader's helper for history and for
// BuildsSinceTool's answer.
func EndedSince(entries []BuildLogEntry, since time.Time) []BuildLogEntry {
var out []BuildLogEntry
for _, e := range entries {
if e.Event == BuildEndedEvent && !e.At.Before(since) {
out = append(out, e)
}
}
sort.SliceStable(out, func(i, j int) bool { return out[i].At.Before(out[j].At) })
return out
}
// EncodeBuildLogEntry is e as JSON at Version; it refuses an entry a reader could not place.
func EncodeBuildLogEntry(e BuildLogEntry) ([]byte, error) {
e.V = keys.Version
switch {
case e.Event == BuildSaidEvent:
switch {
case e.Module == "" || e.FP == "" || e.At.IsZero() || e.Line == "":
return nil, fmt.Errorf("builds: a build's narration names its module, fingerprint, line and time")
case e.Outcome != "" || len(e.Made) > 0 || e.Manifest != "" || len(e.Against) > 0 || len(e.Imports) > 0 || len(e.OutOfStep) > 0:
return nil, fmt.Errorf("builds: a build's narration names no outcome, manifest, base, artifact, import or file out of step")
}
return json.Marshal(e)
case e.Event != BuildStartedEvent && e.Event != BuildEndedEvent:
return nil, fmt.Errorf("builds: a build log entry is %s, %s or %s, not %q", BuildStartedEvent, BuildSaidEvent, BuildEndedEvent, e.Event)
case e.Line != "" || e.Why != "":
return nil, fmt.Errorf("builds: a build's start or end carries no narration's line")
case e.Module == "" || e.FP == "" || e.Node == "" || e.At.IsZero() || e.Repository == "" || e.Commit == "":
return nil, fmt.Errorf("builds: a build log entry names its module, fingerprint, repository, commit, node and time")
case e.Event == BuildEndedEvent && e.Outcome == "":
return nil, fmt.Errorf("builds: a build's end names its outcome")
case e.Event == BuildStartedEvent && (e.Outcome != "" || len(e.Made) > 0 || e.Manifest != "" || len(e.Against) > 0 || len(e.Imports) > 0 ||
len(e.OutOfStep) > 0):
return nil, fmt.Errorf("builds: a build's start names no outcome, manifest, base, artifact, import or file out of step")
}
if err := trunk.CheckImports(e.Imports); err != nil {
return nil, err
}
return json.Marshal(e)
}
// DecodeBuildLogEntry reads one entry.
func DecodeBuildLogEntry(data []byte) (BuildLogEntry, error) {
var e BuildLogEntry
if err := json.Unmarshal(data, &e); err != nil {
return BuildLogEntry{}, fmt.Errorf("builds: a build log entry is not readable: %w", err)
}
if e.V != keys.Version {
return BuildLogEntry{}, fmt.Errorf("builds: a build log entry of version %d, and this reads %d", e.V, keys.Version)
}
return e, nil
}
// BuildDuration is a build's duration, its end less its start (clause 1): false unless both are the same module's
// build of the same fingerprint, the start first.
func BuildDuration(start, end BuildLogEntry) (time.Duration, bool) {
if start.Event != BuildStartedEvent || end.Event != BuildEndedEvent || start.Module != end.Module || start.FP != end.FP ||
end.At.Before(start.At) {
return 0, false
}
return end.At.Sub(start.At), true
}
// IsBuildEntry says whether an entry of the builder's log is a build's start or end, read from its event alone, so an entry of
// another kind (a build's narration, build.said) is passed over before its form is read. One whose event cannot be read is
// taken as a build's, so its decoding says what is wrong with it.
func IsBuildEntry(value []byte) bool {
var e struct {
Event *string `json:"event"`
}
if json.Unmarshal(value, &e) != nil || e.Event == nil {
return true
}
return *e.Event == BuildStartedEvent || *e.Event == BuildEndedEvent
}
@@ -0,0 +1,60 @@
package builds
import (
"testing"
"time"
)
func TestTheBuildersLogKeepsAStartAndAnEndAndTheirDuration(t *testing.T) {
// ADR 0571 clause 1: the builder's log, a start and an end, and the duration between them.
at := time.Date(2026, 10, 29, 22, 0, 0, 0, time.UTC)
start := BuildLogEntry{Event: BuildStartedEvent, Module: "letta", FP: "src2:a", Source: "abc", Repository: "novox/mesh-catalog",
Directory: "modules/letta", Commit: "abc123", Node: "ace", At: at}
end := start
end.Event, end.At, end.Outcome, end.Manifest, end.Against = BuildEndedEvent, at.Add(3*time.Minute), "done", "sha256:m", []string{"artifact-store://base/image@sha256:b"}
end.Made = []BuiltArtifact{{Name: "image", Kind: "image", Digest: "sha256:1", Reference: "artifact-store://letta/image@sha256:1"}}
for _, e := range []BuildLogEntry{start, end} {
raw, err := EncodeBuildLogEntry(e)
back, derr := DecodeBuildLogEntry(raw)
if err != nil || derr != nil || back.Event != e.Event || back.Outcome != e.Outcome || back.Directory != "modules/letta" ||
back.Repository != "novox/mesh-catalog" || back.Commit != "abc123" {
t.Errorf("%s read back as %+v, %v %v", e.Event, back, err, derr)
}
}
if d, ok := BuildDuration(start, end); !ok || d != 3*time.Minute {
t.Errorf("the build took %s, %v", d, ok)
}
older := end
older.At, older.FP = at.Add(-time.Hour), "src2:b"
other := end
other.Module = "mesh-catalog"
if n := NewestPerModule([]BuildLogEntry{older, end, other}); n["letta"].FP != "src2:a" || len(n) != 2 {
t.Errorf("each module's newest is %v", n)
}
if e := EndedSince([]BuildLogEntry{end, start, older}, at.Add(-2*time.Hour)); len(e) != 2 || e[0].FP != "src2:b" {
t.Errorf("the ended builds since are %v", e)
}
if k, err := BuildLogKey("letta"); k != "letta" || err != nil || BuildLogRead != "mesh-delivery-builder.build-log" {
t.Errorf("the key is %q, %v", k, err)
}
if _, err := BuildLogKey("a.b"); err == nil {
t.Error("a module with a dot was taken as a key")
}
for _, edit := range []func(*BuildLogEntry){func(e *BuildLogEntry) { e.Outcome = "" }, func(e *BuildLogEntry) { e.Commit = "" },
func(e *BuildLogEntry) { e.Event, e.Outcome, e.Against = BuildStartedEvent, "", nil }} {
bad := end
edit(&bad)
if _, err := EncodeBuildLogEntry(bad); err == nil {
t.Errorf("%+v was encoded", bad)
}
}
}
func TestOnlyABuildsStartOrEndIsABuildEntry(t *testing.T) {
for entry, want := range map[string]bool{`{"event":"build.started"}`: true, `{"event":"build.ended"}`: true, `{"event":"build.said"}`: false,
`not json`: true, `{}`: true} {
if got := IsBuildEntry([]byte(entry)); got != want {
t.Errorf("IsBuildEntry(%s) = %v", entry, got)
}
}
}
@@ -0,0 +1,57 @@
package builds
import (
"encoding/json"
"fmt"
)
// The builder's event version.numbered (design 51 *Events of the builder*, *The floor*): said once per version the builder
// numbers, on its own family `version` (seven days), apart from its builds' ends, so a waiter on a build's end does not
// wake on it. A build module consumes it to publish a build's version with the held digest, which stores nothing.
const (
// VersionNumberedEvent is the event's local name.
VersionNumberedEvent = "version.numbered"
// VersionNumberedSubject is its subject, as a consumer's filter names it.
VersionNumberedSubject = "mesh.mod." + BuilderModule + ".event." + VersionNumberedEvent
)
// VersionNumbered is the body of VersionNumberedEvent: a numbered version of a name, the build it was numbered for, and
// what the build module needs to publish it.
type VersionNumbered struct {
// Name is the numbered name, as builder_versions lists it; Kind its artifact's kind.
Name string `json:"name"`
Kind string `json:"kind"`
Version string `json:"version"`
Tag string `json:"tag,omitempty"`
// Module and Fingerprint are the build the version was numbered for; Commit its source commit.
Module string `json:"module"`
Fingerprint string `json:"fingerprint"`
Commit string `json:"commit,omitempty"`
// Digest is the built artifact's, when the build held one; Seat the registry seat it is published through.
Digest string `json:"digest,omitempty"`
Seat string `json:"seat,omitempty"`
// Head is the head the version answers, when it answers a tag ask.
Head string `json:"head,omitempty"`
// Again is when builder_publish_again said it once more, so the bus's dedup does not drop it.
Again string `json:"again,omitempty"`
}
// EncodeVersionNumbered writes the body, refusing one that names no name, version, module or fingerprint.
func EncodeVersionNumbered(n VersionNumbered) ([]byte, error) {
if n.Name == "" || n.Version == "" || n.Module == "" || n.Fingerprint == "" {
return nil, fmt.Errorf("builds: %s names its name, version, module and fingerprint", VersionNumberedEvent)
}
return json.Marshal(n)
}
// DecodeVersionNumbered reads the body, with the same refusal.
func DecodeVersionNumbered(data []byte) (VersionNumbered, error) {
var n VersionNumbered
if err := json.Unmarshal(data, &n); err != nil {
return VersionNumbered{}, fmt.Errorf("builds: %s is not readable: %w", VersionNumberedEvent, err)
}
if _, err := EncodeVersionNumbered(n); err != nil {
return VersionNumbered{}, err
}
return n, nil
}
@@ -0,0 +1,157 @@
package builds
import (
"strings"
"testing"
)
func artifactOf(name, kind, digest, reference string) BuiltArtifact {
return BuiltArtifact{Name: name, Kind: kind, Digest: digest, Reference: reference}
}
// A done key names where each artifact is (novox/hq ADR 0498 clauses 1, 5 and 6; the lead's decision L1 of R4e): a
// bundle or an archive by its artifact-store blob, an image or an upstream copy by the registry's reference to its
// digest (or the kept artifact-store form), a package by name and version. A reference of the wrong form for its kind,
// or naming another digest or artifact, is refused, naming the artifact.
func TestADoneKeyNamesEachArtifactsReferenceInTheFormOfItsKind(t *testing.T) {
d := "sha256:" + digestOf('6')
good := []BuiltArtifact{
artifactOf("code", "bundle", d, "artifact-store://mail/code/blobs/"+d),
artifactOf("files", "archive", d, "artifact-store://mail/files/blobs/"+d),
artifactOf("server", "image", d, "10.0.0.1:5000/mail/server@"+d),
artifactOf("server", "image", d, "artifact-store://mail/server@"+d),
artifactOf("nats", "upstream", d, "registry.example/nats/nats@"+d),
artifactOf("lib", "package", d, "@novox/mesh-sdk@0.1.15"),
artifactOf("lib", "package", d, "left-pad@1.3.0"),
artifactOf("old", "", d, "anything at all"),
// The form a registry seat answers (ADR 0605 §9), beside the older one.
artifactOf("code", "bundle", d, "oci-artifact-registry/mail/code@"+d),
artifactOf("files", "archive", d, "oci-artifact-registry/mail/files@"+d),
artifactOf("server", "image", d, "container-image-registry/mail/server@"+d),
artifactOf("nats", "upstream", d, "container-image-registry/nats/nats@"+d),
// An upstream copy under the holder's own naming, upstream/<host>/<path>, in either form (ADR 0624 §1).
artifactOf("app", "upstream", d, "container-image-registry/upstream/quay.io:443/org/app@"+d),
artifactOf("app", "upstream", d, "artifact-store://upstream/quay.io:443/org/app@"+d),
}
for _, a := range good {
b := doneBuild()
b.Artifacts = []BuiltArtifact{a}
if _, err := EncodeBuilderBuild(b); err != nil {
t.Errorf("%+v was refused: %v", a, err)
}
}
other := "sha256:" + digestOf('7')
bad := []BuiltArtifact{
artifactOf("code", "bundle", d, "artifact-store://mail/code/blobs/"+other),
artifactOf("code", "bundle", d, "artifact-store://mail/other/blobs/"+d),
artifactOf("code", "bundle", d, "10.0.0.1:5000/mail/code@"+d),
artifactOf("server", "image", d, "10.0.0.1:5000/mail@"+d),
artifactOf("server", "image", d, "10.0.0.1:5000/mail/server@"+other),
artifactOf("server", "image", d, "mail/server@"+d),
artifactOf("server", "image", d, "artifact-store://mail/server/blobs/"+d),
artifactOf("lib", "package", d, "@novox/mesh-sdk"),
artifactOf("lib", "package", d, "0.1.15"),
artifactOf("code", "bundle", d, "container-image-registry/mail/code@"+d),
artifactOf("server", "image", d, "oci-artifact-registry/mail/server@"+d),
artifactOf("server", "image", d, "container-image-registry/mail/server@"+other),
artifactOf("server", "image", d, "container-image-registry/mail/other@"+d),
artifactOf("lib", "package", d, "container-image-registry/mail/lib@"+d),
artifactOf("app", "upstream", d, "container-image-registry/upstream/quay.io:443/org/app@"+other),
artifactOf("app", "upstream", d, "artifact-store://upstream/quay.io:443/org/app@"+other),
artifactOf("app", "upstream", d, "oci-artifact-registry/upstream/quay.io:443/org/app@"+d),
artifactOf("server", "image", d, "container-image-registry/upstream/quay.io:443/org/server@"+d),
}
for _, a := range bad {
b := doneBuild()
b.Artifacts = []BuiltArtifact{a}
if _, err := EncodeBuilderBuild(b); err == nil || !strings.Contains(err.Error(), `"`+a.Name+`"`) {
t.Errorf("%+v: %v, wanted a refusal naming the artifact", a, err)
}
}
}
// The controller delivers only from a done key whose every artifact names a reference (ADR 0498 clause 8): Referenced
// says so and names the artifacts without one.
func TestReferencedNamesTheArtifactsWithoutAReference(t *testing.T) {
d := "sha256:" + digestOf('6')
b := doneBuild()
b.Artifacts = []BuiltArtifact{
artifactOf("code", "bundle", d, "artifact-store://mail/code/blobs/"+d),
artifactOf("server", "image", d, ""),
}
if ok, missing := b.Referenced(); ok || len(missing) != 1 || missing[0] != "server" {
t.Fatalf("Referenced = %v, %v; want false, [server]", ok, missing)
}
b.Artifacts[1].Reference = "10.0.0.1:5000/mail/server@" + d
if ok, missing := b.Referenced(); !ok || len(missing) != 0 {
t.Fatalf("Referenced = %v, %v; want true", ok, missing)
}
if ok, _ := (BuilderBuild{State: BuildQueued}).Referenced(); ok {
t.Fatal("a queued key is referenced")
}
}
// The errors a build module answers open with a fixed prefix (ADR 0498 clauses 2, 4 and 5; ADR 0478 clause 2), and three
// of them are not built again until the module's fingerprint changes; a failed push is.
func TestOnlyAPushFailureIsBuiltAgainBeforeTheFingerprintChanges(t *testing.T) {
for _, c := range []struct {
err string
again bool
}{
{NotStored + "code: the store said 500", true},
{NotPinned + "nats: upstream names no digest", false},
{VersionTaken + "@novox/mesh-sdk@0.1.15 holds another file", false},
{RefusedKind + "a package in go", false},
{"go test: 2 failed", true},
} {
if got := FailedUntilChanged(c.err); got == c.again {
t.Errorf("FailedUntilChanged(%q) = %v", c.err, got)
}
}
if NotStored != "not stored: " || NotPinned != "not pinned: " || VersionTaken != "version taken: " || RefusedKind != "refused kind: " {
t.Fatal("a prefix changed its words")
}
// A Node fetch that finds the lock naming a package version the registry does not hold fails with WaitsFor; the
// builder then writes the key queued with Waits, and the error is not one that waits for the fingerprint to change.
if WaitsFor != "waits for: " || FailedUntilChanged(WaitsFor+"@novox/mesh-sdk@0.1.15") {
t.Fatalf("WaitsFor = %q", WaitsFor)
}
}
// A key whose module's lock names a version of the group's package not yet published waits, queued, saying which
// (ADR 0500 clause 6): an older reader reads it as queued, and only a queued key waits.
func TestAKeyWaitsQueuedForAPackageVersion(t *testing.T) {
b := BuilderBuild{State: BuildQueued, Manifest: digestOf('4'), Waits: "@novox/mesh-sdk@0.1.15"}
data, err := EncodeBuilderBuild(b)
if err != nil {
t.Fatal(err)
}
back, err := DecodeBuilderBuild(data)
if err != nil || back.Waits != b.Waits || back.State != BuildQueued {
t.Fatalf("decoded %+v, %v", back, err)
}
building := BuilderBuild{State: BuildBuilding, By: "x", Waits: "@novox/mesh-sdk@0.1.15"}
if _, err := EncodeBuilderBuild(building); err == nil {
t.Fatal("a building key that waits was accepted")
}
if _, err := EncodeBuilderBuild(BuilderBuild{State: BuildQueued, Waits: "mesh-sdk"}); err == nil {
t.Fatal("a wait naming no version was accepted")
}
}
// A module that builds no artifact gets a done key too (issue 1613), and it is delivered: Referenced is ok for a done
// key with no artifact, since no artifact lacks a reference; a queued one with none still is not.
func TestADoneKeyWithNoArtifactIsReferenced(t *testing.T) {
b := BuilderBuild{State: BuildDone, Manifest: digestOf('4')}
data, err := EncodeBuilderBuild(b)
if err != nil {
t.Fatalf("a done key with no artifact was refused: %v", err)
}
back, err := DecodeBuilderBuild(data)
if err != nil {
t.Fatal(err)
}
if ok, missing := back.Referenced(); !ok || len(missing) != 0 {
t.Fatalf("Referenced = %v, %v; want true", ok, missing)
}
}
@@ -0,0 +1,38 @@
package builds
import (
"encoding/json"
"fmt"
)
// The holder's question before it stores a publish (novox/hq ADR 0605 §4, design 51 *Publishing*): did the builder
// number this version with this digest? Every registry seat's holder asks it the same way, so the asking lives here
// once (ADR 0627 §2), beside the builder's layouts; no holder writes it again.
//
// The builder answers it today only as its module's tool builder_versions: no seat in the seat set names the builder's
// role yet. Once one does, BuilderVersionsAddress becomes that seat's verb and nothing else changes for a holder.
const BuilderVersionsAddress = BuilderModule + ".builder_versions"
// AskFunc is the runtime's request and reply over the bus (mesh-sdk stdio.Ask), given by the holder so this package does
// not depend on the runtime.
type AskFunc func(address string, body any) (json.RawMessage, error)
// BuilderVersions asks the builder whether it numbered a version with a digest. It answers the holder's Check:
// recorded, and why not when it is not.
type BuilderVersions struct{ Ask AskFunc }
// Recorded asks the builder about name@version with digest. An answer without `recorded` is an error, never a yes.
func (b BuilderVersions) Recorded(name, version, digest string) (bool, string, error) {
raw, err := b.Ask(BuilderVersionsAddress, map[string]any{"name": name, "version": version, "digest": digest})
if err != nil {
return false, "", err
}
var a struct {
Recorded *bool `json:"recorded"`
Why string `json:"why"`
}
if err := json.Unmarshal(raw, &a); err != nil || a.Recorded == nil {
return false, "", fmt.Errorf("builder_versions answered no recorded: %s", raw)
}
return *a.Recorded, a.Why, nil
}
@@ -167,6 +167,15 @@
"loads": [
"mesh-delivery-builder-tools"
]
},
{
"name": "builds",
"kind": "package",
"language": "go",
"paths": [
"builds"
],
"series": "0.1"
}
]
}
+13
View File
@@ -0,0 +1,13 @@
module git.novox.be/novox/mesh-delivery/modules/mesh-grants/grantsstate
go 1.26.0
require (
git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds v0.0.0
git.novox.be/novox/mesh-delivery/sdk v0.0.0
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f
)
replace git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds => ../../mesh-delivery-builder/builds
replace git.novox.be/novox/mesh-delivery/sdk => ../../../sdk
+102
View File
@@ -0,0 +1,102 @@
// Package grantsstate holds what the grants part owns and no seat carries (novox/hq ADR 0634 §1): the layout of its states
// `grants` (what a build's module may use) and `user-list` (the sealed list of users a bus node follows), and the rules over
// them. Other modules import it from the grants part by its published version (ADR 0573 §1).
package grantsstate
import (
"encoding/json"
"fmt"
"reflect"
"strings"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds"
"git.novox.be/novox/mesh-delivery/sdk/kept"
"git.novox.be/novox/mesh-sdk/go/keys"
)
// **The grants issued** (novox/hq ADR 0528 clause 2, ADR 0429 §6 rule 6, ADR 0547 §4, §5, ADR 0590): when the grants
// part has issued a module's bus credentials for a fingerprint, it writes `<module>.<fingerprint hex>` in its state
// `grants` (PartGrantsBucket). The assigner reads it: a node's want moves to a fingerprint only once its grant exists.
// Only the grants part writes the bucket.
const (
// PartGrantsState is the grants part's state of issued credentials by fingerprint, as its manifest names it.
PartGrantsState = "grants"
// PartGrantsBucket is that state's bucket, `<owner module>_<name>`.
PartGrantsBucket = "mesh-grants_" + PartGrantsState
// PartGrantsRead is that state as a reader names it in `reads`.
PartGrantsRead = "mesh-grants." + PartGrantsState
// PartGrantsHistory is the revisions the bucket keeps per key.
PartGrantsHistory = 5
)
// PartGrantKey is the key, in PartGrantsBucket, of a module's credential issued for a fingerprint: `<module>.<hex>`,
// the hex being the fingerprint's key part (FingerprintKeyPart).
func PartGrantKey(module, fingerprint string) (string, error) {
return builds.BuilderBuildKey(module, fingerprint)
}
// ParsePartGrantKey is the inverse of PartGrantKey; it refuses a want or held key, naming it.
func ParsePartGrantKey(key string) (module, fingerprint string, err error) {
if module, fingerprint, err = builds.ParseBuilderBuildKey(key); err != nil {
return "", "", fmt.Errorf("grantsstate: %q is not a grant key, <module>.<fingerprint hex>: %w", key, err)
}
return module, fingerprint, nil
}
// Grant is the value of a grant key: the bus accounts (their public keys) the module's processes were issued
// for that fingerprint, none for a module whose grants the tool runner's account carries, so a reissue for another
// account is a new revision. The entry's own time says when it was issued.
type Grant struct {
V int `json:"v"`
Accounts []string `json:"accounts,omitempty"`
}
func (g Grant) check() error {
for _, a := range g.Accounts {
if strings.TrimSpace(a) == "" || strings.ContainsAny(a, " \t\n") {
return fmt.Errorf("grantsstate: a grant names an empty or spaced account %q", a)
}
}
return nil
}
// EncodeGrant is g as JSON at Version; it refuses an empty or spaced account.
func EncodeGrant(g Grant) ([]byte, error) {
if err := g.check(); err != nil {
return nil, err
}
g.V = keys.Version
return json.Marshal(g)
}
// DecodeGrant reads a grant; it refuses an unknown version (ErrUnknownVersion) and an empty or spaced account.
func DecodeGrant(data []byte) (Grant, error) {
var g Grant
if err := kept.Decode(data, &g); err != nil {
return Grant{}, err
}
if err := g.check(); err != nil {
return Grant{}, err
}
return g, nil
}
// SameGrant says whether two grants are the same value.
func SameGrant(a, b Grant) bool { return reflect.DeepEqual(a, b) }
// NamesNode says whether the grant names module's accounts on node: its own `<node>.<module>` or a process's
// `<node>.<module>.<process>`. A grant that names no account (a module that does not speak on the bus) names every
// node. The assigner wants a node only once the grant names its account (issue 1945), and a put waits for it (ADR
// 0546 clause 4).
func (g Grant) NamesNode(node, module string) bool {
if len(g.Accounts) == 0 {
return true
}
own := node + "." + module
for _, a := range g.Accounts {
if a == own || strings.HasPrefix(a, own+".") {
return true
}
}
return false
}
@@ -0,0 +1,118 @@
package grantsstate
import (
"bytes"
"strings"
"testing"
"git.novox.be/novox/mesh-sdk/go/keys"
"git.novox.be/novox/mesh-sdk/go/seal"
)
// 1394/B1b: a grant key round-trips; a want or held key is refused, naming it; a grant round-trips and one naming an
// empty account is refused.
func TestTheGrantsIssued(t *testing.T) {
fp := "src2:" + strings.Repeat("c", 64)
key, err := PartGrantKey("mesh-delivery-assigner", fp)
if err != nil || key != "mesh-delivery-assigner."+strings.Repeat("c", 64) {
t.Fatalf("PartGrantKey = %q, %v", key, err)
}
module, back, err := ParsePartGrantKey(key)
if err != nil || module != "mesh-delivery-assigner" || back != fp {
t.Errorf("ParsePartGrantKey = %q, %q, %v", module, back, err)
}
held := "mesh-mail." + strings.Repeat("c", 64) + ".held"
for _, bad := range []string{"want.ace.mesh-mail", held, "ace.mesh-mail", ""} {
if _, _, err := ParsePartGrantKey(bad); err == nil || !strings.Contains(err.Error(), "\""+bad+"\"") {
t.Errorf("ParsePartGrantKey(%q) = %v, want a refusal naming it", bad, err)
}
}
for _, g := range []Grant{{}, {Accounts: []string{"UABC", "UDEF"}}} {
data, err := EncodeGrant(g)
if err != nil {
t.Fatal(err)
}
got, err := DecodeGrant(data)
g.V = keys.Version
if err != nil || !SameGrant(got, g) {
t.Errorf("grant %+v came back %+v, %v", g, got, err)
}
}
if _, err := EncodeGrant(Grant{Accounts: []string{""}}); err == nil {
t.Errorf("a grant naming an empty account was accepted")
}
g := Grant{Accounts: []string{"ace.mesh-build-go", "shanks.mesh-mail.reader"}}
for _, c := range []struct {
g Grant
node, module string
names bool
}{
{g, "ace", "mesh-build-go", true},
{g, "shanks", "mesh-mail", true},
{g, "g14", "mesh-build-go", false},
{g, "ace", "mesh-build", false},
{Grant{}, "novox", "nats", true},
} {
if got := c.g.NamesNode(c.node, c.module); got != c.names {
t.Errorf("%v names %s.%s: %v, want %v", c.g.Accounts, c.node, c.module, got, c.names)
}
}
}
// The grants part's list sealed to the bus node (novox/hq ADR 0547 clause 7): the key's value round-trips and refuses what
// the bus node could not follow, and the object opens with the node's key alone, to the digest the key names.
func TestTheGrantsPartsSealedUserList(t *testing.T) {
list := []byte("accounts { MESH { users = [ { user: \"controller\" } ] } }\n")
digest := UserListDigest(list)
node, err := seal.GenerateKeyPair()
if err != nil {
t.Fatal(err)
}
other, _ := seal.GenerateKeyPair()
g := GrantsUserList{Follow: FollowGrants, Digest: digest, Object: UserListObjectName(digest),
SealedTo: SealingKeyID(node.PublicKey), Node: "novox", Revision: 1, Users: 1}
raw, err := EncodeGrantsUserList(g)
if err != nil {
t.Fatal(err)
}
back, err := DecodeGrantsUserList(raw)
if err != nil || back.Digest != digest || back.LeftOut == nil || back.Follow != FollowGrants {
t.Fatalf("decoded %+v, %v", back, err)
}
// Issue 1126 B4a (ADR 0590): a key written with the lease's flag still reads; the controller's list is no source.
if old, err := DecodeGrantsUserList([]byte(`{"v":1,"follow":"grants","digest":"","object":"","sealed_to":"",` +
`"node":"novox","revision":3,"users":40,"left_out":[],"at":"2026-10-30T10:00:00Z","lease-handed":true}`)); err != nil || old.Revision != 3 {
t.Errorf("a key with the lease's flag decodes %+v, %v", old, err)
}
for _, bad := range []GrantsUserList{{Follow: "both", Digest: digest, Object: g.Object}, {Follow: "controller"},
{Follow: FollowGrants, Digest: digest, Object: "other"}, {Follow: FollowGrants, Digest: "md5:x", Object: "md5:x"}} {
if _, err := EncodeGrantsUserList(bad); err == nil {
t.Errorf("%+v was written", bad)
}
}
if _, err := EncodeGrantsUserList(GrantsUserList{Follow: FollowGrants}); err != nil {
t.Errorf("a switch with no list yet refused: %v", err)
}
object, err := SealUserList(list, node.PublicKey)
if err != nil {
t.Fatal(err)
}
if bytes.Contains(object, []byte("controller")) {
t.Fatal("the object carries the list in the clear")
}
opener := func(priv string) func(seal.Box) ([]byte, error) {
return func(b seal.Box) ([]byte, error) { s, err := seal.Open(b, priv); return []byte(s), err }
}
if got, err := OpenUserList(object, digest, opener(node.PrivateKey)); err != nil || !bytes.Equal(got, list) {
t.Fatalf("opened %q, %v", got, err)
}
if _, err := OpenUserList(object, digest, opener(other.PrivateKey)); err == nil {
t.Error("another key opened the list")
}
if _, err := OpenUserList(object, "sha256:00", opener(node.PrivateKey)); err == nil {
t.Error("a list of another digest was taken")
}
if _, err := SealUserList(list, "not a key"); err == nil {
t.Error("a list was sealed to no key")
}
}
+148
View File
@@ -0,0 +1,148 @@
package grantsstate
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"strings"
"time"
"git.novox.be/novox/mesh-delivery/sdk/kept"
"git.novox.be/novox/mesh-sdk/go/keys"
"git.novox.be/novox/mesh-sdk/go/seal"
)
// **The grants part's user list, sealed to the bus node** (novox/hq ADR 0547 clauses 7 and 8, issue 1945): the contract
// between the grants part (mesh-delivery modules/mesh-grants), which writes it, and the node-engine of the node holding
// mesh-broker (mesh-host), which follows it once the operator's take-over says so.
//
// - The grants part composes the bus's user list (the accounts block, buscompose.ComposeAccounts, with the vault's
// hashes), seals it to the bus node's sealing key (seal.Seal, the key the node says in its have of the bus module,
// UserListWritten.Key), and puts the box as one object of its object bucket GrantsUserListObjects, named by the
// list's digest (UserListObjectName).
// - It writes the key GrantsUserListKey of its state `user-list` (bucket GrantsUserListBucket): the GrantsUserList
// below, which names the object, the list's digest, its revision, its user count and the users left out, and which
// source the bus node follows (Follow: FollowGrants, the one source since the controller ended, ADR 0590).
// - The node-engine of the bus node watches that key alone. It opens the object, checks the digest, writes the bus's
// user-list file (mode 0600) only when the file's digest differs, and says what it loaded in its have of the bus
// module (UserListWritten, Source GrantsUserListSource).
// The names of the grants part's user list on the bus.
const (
// GrantsUserListBucket is the grants part's state `user-list`.
GrantsUserListBucket = "mesh-grants_user-list"
// GrantsUserListKey is the one key of it the bus node reads.
GrantsUserListKey = "sealed"
// GrantsUserListObjects is the grants part's object bucket, where each sealed list is one object.
GrantsUserListObjects = "mesh-grants-user-list"
// GrantsUserListObjectStream is the bucket's stream.
GrantsUserListObjectStream = "OBJ_" + GrantsUserListObjects
// GrantsUserListSource is the source a have names for a list taken from the grants part.
GrantsUserListSource = GrantsUserListBucket + "." + GrantsUserListKey
)
// FollowGrants is the list the bus node follows: the grants part's, the one source (ADR 0590).
const FollowGrants = "grants"
// GrantsUserList is the value of the grants part's key `user-list.sealed`.
type GrantsUserList struct {
V int `json:"v"`
// Follow is which list the bus node follows: FollowGrants, the one value it takes.
Follow string `json:"follow"`
// Digest is the list's digest, `sha256:<hex>` of the file's bytes as the bus loads them.
Digest string `json:"digest"`
// Object is the object's name in GrantsUserListObjects (UserListObjectName of Digest).
Object string `json:"object"`
// SealedTo is the fingerprint of the key the object is sealed to (SealingKeyID), and Node the node it holds.
SealedTo string `json:"sealed_to"`
Node string `json:"node"`
// Revision counts the lists the grants part wrote: one more for each new digest.
Revision uint64 `json:"revision"`
Users int `json:"users"`
// LeftOut are the users the list leaves out for want of a hash in the vault.
LeftOut []string `json:"left_out"`
At time.Time `json:"at"`
}
// EncodeGrantsUserList is g as JSON at Version; it refuses a value the bus node could not follow.
func EncodeGrantsUserList(g GrantsUserList) ([]byte, error) {
g.V = keys.Version
if g.LeftOut == nil {
g.LeftOut = []string{}
}
if err := g.check(); err != nil {
return nil, err
}
return json.Marshal(g)
}
// DecodeGrantsUserList reads the key's value; it refuses another version and what Encode refuses.
func DecodeGrantsUserList(data []byte) (GrantsUserList, error) {
var g GrantsUserList
if err := kept.Decode(data, &g); err != nil {
return GrantsUserList{}, err
}
if err := g.check(); err != nil {
return GrantsUserList{}, err
}
return g, nil
}
func (g GrantsUserList) check() error {
if g.Follow != FollowGrants {
return fmt.Errorf("grantsstate: the user list's follow %q is not %s, the one list since the controller ended (ADR 0590)",
g.Follow, FollowGrants)
}
if g.Digest == "" {
return nil // a switch with no list written yet: the bus node keeps what it follows
}
if !strings.HasPrefix(g.Digest, "sha256:") || g.Object != UserListObjectName(g.Digest) {
return fmt.Errorf("grantsstate: the user list names object %q for digest %q; the object is the digest's hex", g.Object, g.Digest)
}
return nil
}
// UserListDigest is a list's digest as the have and the key say it, `sha256:<hex>` of its bytes.
func UserListDigest(content []byte) string {
sum := sha256.Sum256(content)
return "sha256:" + hex.EncodeToString(sum[:])
}
// UserListObjectName is the object a list of that digest is put under: the digest's hex.
func UserListObjectName(digest string) string { return strings.TrimPrefix(digest, "sha256:") }
// SealingKeyID is a short fingerprint of a public key in PEM, `sha256:<16 hex>`, to say which key a box is sealed to
// without carrying the key.
func SealingKeyID(publicPEM string) string {
sum := sha256.Sum256([]byte(strings.TrimSpace(publicPEM)))
return "sha256:" + hex.EncodeToString(sum[:8])
}
// SealUserList is the object a list is put as: the sealed box (seal.Seal) of its bytes to the bus node's key, as JSON.
func SealUserList(list []byte, nodePEM string) ([]byte, error) {
if !strings.Contains(nodePEM, "PUBLIC KEY") {
return nil, fmt.Errorf("grantsstate: the bus node's key is not a public key in PEM")
}
box, err := seal.Seal(string(list), nodePEM)
if err != nil {
return nil, fmt.Errorf("grantsstate: the user list could not be sealed: %w", err)
}
return json.Marshal(box)
}
// OpenUserList opens an object with open (the node's own key) and checks it is the list of digest.
func OpenUserList(object []byte, digest string, open func(seal.Box) ([]byte, error)) ([]byte, error) {
var box seal.Box
if err := json.Unmarshal(object, &box); err != nil {
return nil, fmt.Errorf("grantsstate: the object is no sealed box: %w", err)
}
list, err := open(box)
if err != nil {
return nil, err
}
if got := UserListDigest(list); got != digest {
return nil, fmt.Errorf("grantsstate: the opened list's digest is %s, and the key names %s", got, digest)
}
return list, nil
}
+9
View File
@@ -133,6 +133,15 @@
"loads": [
"mesh-grants"
]
},
{
"name": "grantsstate",
"kind": "package",
"language": "go",
"paths": [
"grantsstate"
],
"series": "0.1"
}
]
}
+9
View File
@@ -187,3 +187,12 @@ type Ask struct {
Fetch map[string]any `json:"fetch"`
} `json:"compose"`
}
// CheckAskID refuses an id that is no ask's: base-36 milliseconds and six hex digits, as NewID gives. Its form keeps it apart from
// a module's or a node's name in a key.
func CheckAskID(id string) error {
if !askIDOK.MatchString(id) {
return fmt.Errorf("buildstep: %q is not an ask id (base-36 milliseconds and six hex digits)", id)
}
return nil
}
+42
View File
@@ -0,0 +1,42 @@
// Package fingerprint holds the form of a module's source fingerprint, `src2:` and 64 lowercase hex digits (novox/hq ADR 0593
// §2): the indexer computes it alone, in its own package; every part that keys a state by it reads this form. It is the
// Delivery domain's rule, over the `fp2` field of mesh-catalog's `modules` value (ADR 0634 §1).
package fingerprint
import (
"fmt"
"strings"
)
// SourcePrefix2 opens every source fingerprint of the form.
const SourcePrefix2 = "src2:"
// Toolchain2 is what an artifact's toolchain enters the fingerprint as (novox/hq ADR 0405 clause 7): the artifact's
// language, the installed toolchain's version and the build module's recipe version.
type Toolchain2 struct {
Language string
Version string
Recipe string
}
// KeyPart is a fingerprint as a key part: its 64 hex digits without the prefix, since a bus key may not hold ':'. It
// refuses a value that is not `src2:` and 64 lowercase hex digits, a src1 fingerprint included, naming it.
func KeyPart(fp string) (string, error) {
part, ok := strings.CutPrefix(fp, SourcePrefix2)
if !ok || !isHex64(part) {
return "", fmt.Errorf("fingerprint: %q is not a fingerprint of the form, %s and 64 lowercase hex digits", fp, SourcePrefix2)
}
return part, nil
}
// ParseKeyPart is the inverse of KeyPart. It refuses a part that is not 64 lowercase hex digits, naming it.
func ParseKeyPart(part string) (string, error) {
if !isHex64(part) {
return "", fmt.Errorf("fingerprint: %q is not the key part of a fingerprint, 64 lowercase hex digits", part)
}
return SourcePrefix2 + part, nil
}
func isHex64(s string) bool {
return len(s) == 64 && strings.Trim(s, "0123456789abcdef") == ""
}
+34
View File
@@ -0,0 +1,34 @@
package fingerprint
import (
"strings"
"testing"
)
// The key part holds only bus key characters and parsing gives the fingerprint back; a src1 or malformed value is refused,
// naming it.
func TestFingerprintKeyPart(t *testing.T) {
fp := SourcePrefix2 + strings.Repeat("ab", 32)
part, err := KeyPart(fp)
if err != nil {
t.Fatal(err)
}
if len(part) != 64 || strings.Trim(part, "0123456789abcdef") != "" {
t.Errorf("key part %q is not 64 lowercase hex characters", part)
}
back, err := ParseKeyPart(part)
if err != nil || back != fp {
t.Errorf("parse(%q) = %q, %v; want %q", part, back, err, fp)
}
src1 := "src1:" + strings.Repeat("c", 64)
for _, bad := range []string{src1, "src2:", "src2:xyz", "", "src2:" + strings.Repeat("A", 64), "src2:" + strings.Repeat("a", 63)} {
if _, err := KeyPart(bad); err == nil || !strings.Contains(err.Error(), "\""+bad+"\"") {
t.Errorf("KeyPart(%q) = %v, want a refusal naming it", bad, err)
}
}
for _, bad := range []string{src1, "src2:" + strings.Repeat("a", 64), strings.Repeat("a", 63), strings.Repeat("A", 64), "", "zz"} {
if _, err := ParseKeyPart(bad); err == nil || !strings.Contains(err.Error(), "\""+bad+"\"") {
t.Errorf("ParseKeyPart(%q) = %v, want a refusal naming it", bad, err)
}
}
}
+73
View File
@@ -0,0 +1,73 @@
// Package kept holds what the layouts of the Delivery domain's kept values share: the version check on reading one, the
// rule a name must keep to be a key part, and the form of a digest. It names no seat and no module (ADR 0634 §2). The
// version itself (keys.Version) and the escaping of a name (keys.EscapeName) are mesh-sdk's, since more than one domain uses
// them.
package kept
import (
"encoding/json"
"errors"
"fmt"
"regexp"
"git.novox.be/novox/mesh-sdk/go/keys"
)
// ErrUnknownVersion is what Decode returns for a value whose version is missing or not keys.Version. Test it with errors.Is.
var ErrUnknownVersion = errors.New("kept: unknown value version")
var nameOK = regexp.MustCompile(`^[-/_=a-zA-Z0-9]+$`)
// CheckName refuses a name that is empty or holds a dot, a wildcard, whitespace or a character a bus key refuses.
func CheckName(kind, name string) error {
if name == "" {
return fmt.Errorf("kept: the %s name is empty", kind)
}
if !nameOK.MatchString(name) {
return fmt.Errorf("kept: the %s name %q holds a dot, a wildcard, whitespace or a character a key refuses", kind, name)
}
return nil
}
var hex64 = regexp.MustCompile(`^[0-9a-f]{64}$`)
// IsHex64 says whether s is 64 lowercase hex digits.
func IsHex64(s string) bool { return hex64.MatchString(s) }
// CheckVersion refuses a value with no version or one this reader does not read.
func CheckVersion(data []byte) error {
var probe struct {
V *int `json:"v"`
}
if err := json.Unmarshal(data, &probe); err != nil {
return fmt.Errorf("kept: value is not JSON: %w", err)
}
if probe.V == nil {
return fmt.Errorf("%w: no version in the value, this reader reads %d", ErrUnknownVersion, keys.Version)
}
if *probe.V != keys.Version {
return fmt.Errorf("%w: the value is version %d, this reader reads %d", ErrUnknownVersion, *probe.V, keys.Version)
}
return nil
}
// Decode reads data into into after CheckVersion.
func Decode(data []byte, into any) error {
if err := CheckVersion(data); err != nil {
return err
}
if err := json.Unmarshal(data, into); err != nil {
return fmt.Errorf("kept: value does not fit its shape: %w", err)
}
return nil
}
var digestForm = regexp.MustCompile(`^sha256:[0-9a-f]{64}$`)
// CheckDigest refuses a digest that is not sha256:<64 lowercase hex digits>, saying what it is of.
func CheckDigest(digest, of string) error {
if !digestForm.MatchString(digest) {
return fmt.Errorf("kept: %s %q is not sha256:<64 lowercase hex digits>", of, digest)
}
return nil
}
+31
View File
@@ -0,0 +1,31 @@
package kept
import (
"errors"
"strings"
"testing"
)
func TestAKeptValueIsReadOnlyAtItsVersionAndAKeyPartOnlyOfKeyCharacters(t *testing.T) {
var v struct{ V int }
for data, ok := range map[string]bool{`{"v":1}`: true, `{"v":2}`: false, `{}`: false} {
err := Decode([]byte(data), &v)
if (err == nil) != ok || (!ok && !errors.Is(err, ErrUnknownVersion)) {
t.Errorf("Decode(%s) = %v", data, err)
}
}
if Decode([]byte(`nope`), &v) == nil {
t.Error("a value that is no JSON is read")
}
for name, ok := range map[string]bool{"a-b_c/d=": true, "": false, "a.b": false, "a b": false, "a*": false} {
if (CheckName("module", name) == nil) != ok {
t.Errorf("CheckName(%q) = %v", name, CheckName("module", name))
}
}
if !IsHex64(strings.Repeat("a", 64)) || IsHex64(strings.Repeat("A", 64)) || IsHex64("a") {
t.Error("IsHex64 reads a wrong form")
}
if CheckDigest("sha256:"+strings.Repeat("0", 64), "x") != nil || CheckDigest("sha256:0", "x") == nil {
t.Error("CheckDigest reads a wrong form")
}
}
+116
View File
@@ -0,0 +1,116 @@
// Package reference holds the form of an artifact's reference once a registry seat published it: `<seat>/<module>/<artifact>@<digest>`
// (novox/hq ADR 0605 §9), and the older `artifact-store://<path>` form read beside it. It is the Delivery domain's rule over
// the names of the two registry seats that front the artifact store; it names no seat itself (ADR 0634 §4, ADR 0635 §4): the
// caller gives the seats' names from their contracts (Seats), so a package here cannot drift from a contract.
package reference
import "strings"
// ArtifactStoreScheme marks a reference to something in the mesh's artifact store, kept without the store's address.
const ArtifactStoreScheme = "artifact-store://"
// UpstreamPrefix opens the holder's own name for an upstream copy it keeps: `upstream/<host>/<path>@<digest>`, after
// `<image seat>/` in the seat's form and after `artifact-store://` in the older one.
const UpstreamPrefix = "upstream/"
// Seats are the names of the two registry seats a reference of the registry form names first: Image the seat that fronts
// images and upstream copies (container-image-registry), Object the seat that fronts bundles and archives
// (oci-artifact-registry). The caller reads them from the seats' contracts.
type Seats struct{ Image, Object string }
// All is the two names.
func (s Seats) All() []string { return []string{s.Image, s.Object} }
// Recorded is a reference as the mesh records it: the artifact store's address, if the builder wrote one, taken off.
func (s Seats) Recorded(reference string) string {
if _, _, seat := s.InRegistrySeat(reference); seat || strings.HasPrefix(reference, ArtifactStoreScheme) {
return reference
}
if rest, isURL := strings.CutPrefix(reference, "http://"); isURL {
if _, path, ok := strings.Cut(rest, "/v2/"); ok && strings.Contains(path, "/blobs/") {
return ArtifactStoreScheme + path
}
return reference
}
host, path, ok := strings.Cut(reference, "/")
if !ok || !isRegistryHost(host) || !strings.Contains(path, "@sha256:") {
return reference
}
return ArtifactStoreScheme + path
}
// isRegistryHost is the runtime's own rule for reading the first component of a reference as a registry rather than a
// namespace: it has a dot or a port in it, or it is localhost.
func isRegistryHost(component string) bool {
return component == "localhost" || strings.ContainsAny(component, ".:")
}
// InArtifactStore reports whether a reference is a kept one, and what it names there: `<module>/<artifact>@<digest>` for an
// image, `<module>/<artifact>/blobs/<digest>` for a bundle or an archive. It reads both forms (ADR 0605 §9).
func (s Seats) InArtifactStore(reference string) (path string, kept bool) {
if seat, rest, ok := s.InRegistrySeat(reference); ok {
if seat == s.Object {
name, digest, _ := strings.Cut(rest, "@")
return name + "/blobs/" + digest, true
}
return rest, true
}
return strings.CutPrefix(reference, ArtifactStoreScheme)
}
// SeatReference is the reference a build names an image, bundle, archive or upstream copy by once it published it through
// a registry seat: `<seat>/<module>/<artifact>@sha256:<digest>`, as InRegistrySeat reads it back.
func SeatReference(seat, module, artifact, digest string) string {
return seat + "/" + module + "/" + artifact + "@" + digest
}
// InRegistrySeat reads a reference in the form a registry seat answers: the seat it came through and the rest. A seat's
// name holds no dot or port, so no image of an outside registry reads as one, and a kept `artifact-store://` reference is
// not one. It also reads the holder's own naming of an upstream copy through the image seat, whose rest InUpstream reads.
func (s Seats) InRegistrySeat(reference string) (seat, path string, ok bool) {
first, rest, found := strings.Cut(reference, "/")
if !found || !strings.Contains(rest, "@sha256:") {
return "", "", false
}
if _, _, upstream := InUpstream(rest); upstream && first == s.Image {
return first, rest, true
}
if strings.Count(rest, "/") != 1 {
return "", "", false
}
for _, name := range s.All() {
if first == name {
return name, rest, true
}
}
return "", "", false
}
// InUpstream reads a path in the holder's naming of an upstream copy, `upstream/<host>/<path>@sha256:<digest>`: the
// upstream registry host and the image's path there, with its digest.
func InUpstream(path string) (host, image string, ok bool) {
rest, found := strings.CutPrefix(path, UpstreamPrefix)
if !found {
return "", "", false
}
host, image, found = strings.Cut(rest, "/")
name, digest, pinned := strings.Cut(image, "@")
if !found || !isRegistryHost(host) || name == "" || strings.HasPrefix(name, "/") || strings.HasSuffix(name, "/") ||
strings.Contains(name, "//") || !pinned || !strings.HasPrefix(digest, "sha256:") || strings.Contains(digest, "/") {
return "", "", false
}
return host, image, true
}
// Routed is a kept reference as a machine fetches it, through the artifact store at `address` (host:port). A reference
// that is not a kept one is what it was.
func (s Seats) Routed(reference, address string) string {
path, kept := s.InArtifactStore(reference)
if !kept {
return reference
}
if strings.Contains(path, "/blobs/") {
return "http://" + address + "/v2/" + path
}
return address + "/" + path
}
+69
View File
@@ -0,0 +1,69 @@
package reference
import (
"strings"
"testing"
)
var digest = "sha256:" + strings.Repeat("d", 64)
var seats = Seats{Image: "container-image-registry", Object: "oci-artifact-registry"}
func TestAReferenceIsRecordedWithoutTheStoresAddress(t *testing.T) {
cases := map[string]string{
"anchor.internal:5100/gitea/server@" + digest: ArtifactStoreScheme + "gitea/server@" + digest,
"localhost:5000/gitea/server@" + digest: ArtifactStoreScheme + "gitea/server@" + digest,
"http://anchor.internal:5100/v2/gitea/config/blobs/" + digest: ArtifactStoreScheme + "gitea/config/blobs/" + digest,
ArtifactStoreScheme + "gitea/server@" + digest: ArtifactStoreScheme + "gitea/server@" + digest,
digest: digest,
"@novox/sdk@1.2.3": "@novox/sdk@1.2.3",
"gitea/gitea@" + digest: "gitea/gitea@" + digest,
"https://registry.example/v2/gitea/config/blobs/" + digest: "https://registry.example/v2/gitea/config/blobs/" + digest,
// The form a registry seat answers is recorded as it is (ADR 0605 §9).
"container-image-registry/gitea/server@" + digest: "container-image-registry/gitea/server@" + digest,
"oci-artifact-registry/gitea/config@" + digest: "oci-artifact-registry/gitea/config@" + digest,
}
for announced, want := range cases {
if got := seats.Recorded(announced); got != want {
t.Errorf("Recorded(%q) = %q, want %q", announced, got, want)
}
}
// And every reader reads both forms: a kept reference and the seat's are fetched from the same place.
for reference, want := range map[string]string{
ArtifactStoreScheme + "gitea/server@" + digest: "10.0.0.1:5000/gitea/server@" + digest,
"container-image-registry/gitea/server@" + digest: "10.0.0.1:5000/gitea/server@" + digest,
ArtifactStoreScheme + "gitea/config/blobs/" + digest: "http://10.0.0.1:5000/v2/gitea/config/blobs/" + digest,
"oci-artifact-registry/gitea/config@" + digest: "http://10.0.0.1:5000/v2/gitea/config/blobs/" + digest,
"registry.example/gitea/server@" + digest: "registry.example/gitea/server@" + digest,
"mesh-artifact-store/gitea/server@" + digest: "mesh-artifact-store/gitea/server@" + digest,
// An upstream copy under the holder's own naming, in both forms.
"container-image-registry/upstream/quay.io:443/org/app@" + digest: "10.0.0.1:5000/upstream/quay.io:443/org/app@" + digest,
ArtifactStoreScheme + "upstream/quay.io:443/org/app@" + digest: "10.0.0.1:5000/upstream/quay.io:443/org/app@" + digest,
} {
if got := seats.Routed(reference, "10.0.0.1:5000"); got != want {
t.Errorf("Routed(%q) = %q, want %q", reference, got, want)
}
}
// A build module names what it published in the seat's form, and reads it back as the seat it came through.
for _, seat := range seats.All() {
ref := SeatReference(seat, "gitea", "config", digest)
if got, path, ok := seats.InRegistrySeat(ref); !ok || got != seat || path != "gitea/config@"+digest {
t.Errorf("SeatReference(%s) = %q, read back as %q %q %v", seat, ref, got, path, ok)
}
}
// The holder's naming of an upstream copy reads through the image seat only, and InUpstream reads its host and path.
for reference, want := range map[string]bool{
"container-image-registry/upstream/quay.io:443/org/app@" + digest: true,
"oci-artifact-registry/upstream/quay.io:443/org/app@" + digest: false,
"container-image-registry/upstream/org/app@" + digest: false,
"container-image-registry/upstream/quay.io/app": false,
} {
_, path, ok := seats.InRegistrySeat(reference)
if ok != want {
t.Errorf("InRegistrySeat(%q) read %v, want %v", reference, ok, want)
}
if host, image, up := InUpstream(path); ok && (!up || host != "quay.io:443" || image != "org/app@"+digest) {
t.Errorf("InUpstream(%q) = %q %q %v", path, host, image, up)
}
}
}
+15
View File
@@ -1,6 +1,7 @@
package trunk
import (
"fmt"
"strings"
)
@@ -26,3 +27,17 @@ func PackageRepository(pkg string) (repository, dir string, ok bool) {
}
return "novox/" + name, dir, true
}
// CheckImports refuses an import set (the imports of a module's kept inputs, of a build's answer and of a build log entry)
// that holds a path that is no mesh package's, or is not sorted and each once.
func CheckImports(imports []string) error {
for i, pkg := range imports {
if _, _, ok := PackageRepository(pkg); !ok || strings.ContainsAny(pkg, " \t\n@") {
return fmt.Errorf("trunk: the import %q is no mesh package's path (it starts with %s)", pkg, ForgeModulePrefix)
}
if i > 0 && imports[i-1] >= pkg {
return fmt.Errorf("trunk: the imports are not sorted and each once (%s after %s)", pkg, imports[i-1])
}
}
return nil
}
+12
View File
@@ -33,3 +33,15 @@ func TestSameRepository(t *testing.T) { // 1281/AC1
t.Errorf("RepositoryName = %q", got)
}
}
func TestAnImportSetHoldsMeshPackagesSortedAndEachOnce(t *testing.T) {
a, b := ForgeModulePrefix+"mesh-sdk/go", ForgeModulePrefix+"mesh-delivery/sdk"
if err := CheckImports([]string{b, a}); err != nil {
t.Errorf("a sorted set is refused: %v", err)
}
for name, bad := range map[string][]string{"unsorted": {a, b}, "twice": {a, a}, "not a mesh package": {"github.com/x/y"}, "a version": {a + "@v1"}} {
if CheckImports(bad) == nil {
t.Errorf("%s: %v is taken", name, bad)
}
}
}