Merge branch 'b2/registry' into b7/registry
This commit is contained in:
@@ -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
|
||||
)
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -133,6 +133,15 @@
|
||||
"loads": [
|
||||
"mesh-grants"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "grantsstate",
|
||||
"kind": "package",
|
||||
"language": "go",
|
||||
"paths": [
|
||||
"grantsstate"
|
||||
],
|
||||
"series": "0.1"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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") == ""
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user