The reservation keeps the repository and commit of the build and whether the module has a Go package; version-released writes the tag through the seat git's verb tag before the key goes, and a tag the forge did not write leaves the release to be heard again. Builds: design 47 G §370 Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
467 lines
16 KiB
Go
467 lines
16 KiB
Go
package versions
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"git.novox.be/novox/mesh-delivery/contracts/go/seats/meshbuilds"
|
|
)
|
|
|
|
// **The reservation is one key per module and MAJOR.MINOR** (design 47 G §369, §52): the builder keeps in its old state
|
|
// section `reserved` the version its build reserved and each artifact's digest once built, written by compare-and-set at
|
|
// the build's start. A newer build of the module takes the next after the higher of the highest released and the one
|
|
// reserved, so two builds never share a version, and a superseded build's version is never released (a gap in the
|
|
// patches). The key is deleted when the catalog says `version-released` for it, or when a version of another MAJOR.MINOR
|
|
// of the module is released (the manifest moved on). This is no list of released versions: numbering reads those only from
|
|
// the catalog (§348), handed to Reserve by the caller.
|
|
|
|
const (
|
|
// ReservedState is the builder's old state section of the reservations.
|
|
ReservedState = "reserved"
|
|
// ReservedBucket is its bucket.
|
|
ReservedBucket = "mesh-delivery-builder_" + ReservedState
|
|
// ReservedTool is builder_reserved, ReservedDropTool builder_reserved_drop.
|
|
ReservedTool = "builder_reserved"
|
|
ReservedDropTool = "builder_reserved_drop"
|
|
// UnreleasedBound is the publish's bound: past it a built reservation with an artifact unpublished is the finding
|
|
// version-unreleased (§369).
|
|
UnreleasedBound = 10 * time.Minute
|
|
// VersionUnreleased is that finding.
|
|
VersionUnreleased = "version-unreleased"
|
|
)
|
|
|
|
// ReservedVersion is the value of a reservation key.
|
|
type ReservedVersion struct {
|
|
V int `json:"v"`
|
|
Module string `json:"module"`
|
|
Series string `json:"series"` // MAJOR.MINOR
|
|
Version string `json:"version"` // MAJOR.MINOR.PATCH
|
|
// Artifacts are the module's artifacts at the version: the digest once built, empty before.
|
|
Artifacts map[string]string `json:"artifacts"`
|
|
// BuiltAt is when the build ended with every digest known; the publish's bound counts from it.
|
|
BuiltAt time.Time `json:"built-at,omitzero"`
|
|
// Published are the artifacts a registry seat said published at this version with the built digest.
|
|
Published map[string]bool `json:"published,omitempty"`
|
|
At time.Time `json:"at"`
|
|
// Repository and Commit are what the build was made from, and Tag the git tag its release is written as (a Go
|
|
// package's, GoTagOf): empty for a module with no Go package (design 47 G §370).
|
|
Repository string `json:"repository,omitempty"`
|
|
Commit string `json:"commit,omitempty"`
|
|
Tag string `json:"tag,omitempty"`
|
|
}
|
|
|
|
// Source is what a build reserving a version is made from (design 47 G §370): the repository and commit the git tag is
|
|
// written at, and whether the module has a Go package, whose version is tagged.
|
|
type Source struct {
|
|
Repository, Commit string
|
|
GoPackage bool
|
|
}
|
|
|
|
// ReservedKey is the key of module's reservation of series (MAJOR.MINOR).
|
|
func ReservedKey(module, series string) string { return Key(module, series) }
|
|
|
|
// ErrNotFilled is what Reserve answers before the catalog says versions-filled: nothing is reserved until then (§369).
|
|
var ErrNotFilled = errors.New("versions-filled is not heard: nothing is reserved before the catalog's versions are filled")
|
|
|
|
// ReserveStore is the reservations' bucket: compare-and-set, and deletion.
|
|
type ReserveStore interface {
|
|
Store
|
|
Delete(ctx context.Context, key string) error
|
|
}
|
|
|
|
// Reserver reserves versions.
|
|
type Reserver struct {
|
|
Store ReserveStore
|
|
// ReleasedOf are the patch versions (MAJOR.MINOR.PATCH) the catalog holds released for module, from its verb versions.
|
|
ReleasedOf func(ctx context.Context, module string) ([]string, error)
|
|
// Filled says the catalog said versions-filled.
|
|
Filled func() bool
|
|
Now func() time.Time
|
|
}
|
|
|
|
func (r *Reserver) now() time.Time {
|
|
if r.Now != nil {
|
|
return r.Now().UTC()
|
|
}
|
|
return time.Now().UTC()
|
|
}
|
|
|
|
// SplitVersion is a MAJOR.MINOR.PATCH version's series and patch.
|
|
func SplitVersion(version string) (series string, patch int, err error) {
|
|
i := strings.LastIndex(version, ".")
|
|
if i < 0 || strings.Count(version, ".") != 2 {
|
|
return "", 0, fmt.Errorf("%q is not MAJOR.MINOR.PATCH", version)
|
|
}
|
|
patch, err = strconv.Atoi(version[i+1:])
|
|
if err != nil || patch < 0 {
|
|
return "", 0, fmt.Errorf("%q is not MAJOR.MINOR.PATCH", version)
|
|
}
|
|
return version[:i], patch, nil
|
|
}
|
|
|
|
// Reserve reserves the next version of module's series for a build that starts, for artifacts (their names), and answers
|
|
// it. The next patch is the one after the higher of the highest released and the one reserved, 0 when there is neither. A
|
|
// conflicting write (another build reserved first) is read again and the next taken.
|
|
func (r *Reserver) Reserve(ctx context.Context, module, series string, artifacts []string, src Source) (ReservedVersion, error) {
|
|
if r.Filled != nil && !r.Filled() {
|
|
return ReservedVersion{}, ErrNotFilled
|
|
}
|
|
released, err := r.ReleasedOf(ctx, module)
|
|
if err != nil {
|
|
return ReservedVersion{}, fmt.Errorf("the released versions of %s cannot be read: %w", module, err)
|
|
}
|
|
high := -1
|
|
for _, v := range released {
|
|
if s, p, err := SplitVersion(v); err == nil && s == series && p > high {
|
|
high = p
|
|
}
|
|
}
|
|
key := ReservedKey(module, series)
|
|
for {
|
|
cur, rev, found, err := r.get(ctx, key)
|
|
if err != nil {
|
|
return ReservedVersion{}, err
|
|
}
|
|
next := high
|
|
if found {
|
|
if _, p, err := SplitVersion(cur.Version); err == nil && p > next {
|
|
next = p
|
|
}
|
|
}
|
|
out := ReservedVersion{V: 1, Module: module, Series: series, Version: fmt.Sprintf("%s.%d", series, next+1), Artifacts: map[string]string{}, At: r.now(),
|
|
Repository: src.Repository, Commit: src.Commit}
|
|
if src.GoPackage {
|
|
out.Tag = GoTagOf(out.Version)
|
|
}
|
|
for _, a := range artifacts {
|
|
out.Artifacts[a] = ""
|
|
}
|
|
raw, _ := json.Marshal(out)
|
|
if found {
|
|
_, err = r.Store.Update(ctx, key, raw, rev)
|
|
} else {
|
|
_, err = r.Store.Create(ctx, key, raw)
|
|
}
|
|
if errors.Is(err, ErrConflict) {
|
|
continue
|
|
}
|
|
if err != nil {
|
|
return ReservedVersion{}, fmt.Errorf("%s cannot be reserved: %w", key, err)
|
|
}
|
|
return out, nil
|
|
}
|
|
}
|
|
|
|
func (r *Reserver) get(ctx context.Context, key string) (ReservedVersion, uint64, bool, error) {
|
|
raw, rev, found, err := r.Store.Get(ctx, key)
|
|
if err != nil || !found {
|
|
return ReservedVersion{}, 0, false, err
|
|
}
|
|
var v ReservedVersion
|
|
if err := json.Unmarshal(raw, &v); err != nil {
|
|
return ReservedVersion{}, 0, false, fmt.Errorf("%s holds what is not a reservation: %w", key, err)
|
|
}
|
|
return v, rev, true, nil
|
|
}
|
|
|
|
// Built records artifact's digest under the reserved version of module's series; once every artifact has one the build is
|
|
// ended (BuiltAt). A reservation moved on to another version (a newer build took the next) is left alone and answers false.
|
|
func (r *Reserver) Built(ctx context.Context, module, version, artifact, digest string) (bool, error) {
|
|
series, _, err := SplitVersion(version)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
key := ReservedKey(module, series)
|
|
for {
|
|
cur, rev, found, err := r.get(ctx, key)
|
|
if err != nil || !found || cur.Version != version {
|
|
return false, err
|
|
}
|
|
cur.Artifacts[artifact] = digest
|
|
if cur.BuiltAt.IsZero() && allSet(cur.Artifacts) {
|
|
cur.BuiltAt = r.now()
|
|
}
|
|
raw, _ := json.Marshal(cur)
|
|
if _, err := r.Store.Update(ctx, key, raw, rev); errors.Is(err, ErrConflict) {
|
|
continue
|
|
} else if err != nil {
|
|
return false, fmt.Errorf("%s cannot be written: %w", key, err)
|
|
}
|
|
return true, nil
|
|
}
|
|
}
|
|
|
|
func allSet(m map[string]string) bool {
|
|
for _, d := range m {
|
|
if d == "" {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// Released takes the catalog's version-released in: the key of that version is deleted, and every key of module's other
|
|
// series (the manifest moved on). A key holding a newer reserved version of the same series stays. It answers the keys
|
|
// deleted.
|
|
func (r *Reserver) Released(ctx context.Context, module, version string) ([]string, error) {
|
|
series, _, err := SplitVersion(version)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
keys, err := r.Store.KeysOf(ctx, NameFilter(module))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("the reservations of %s cannot be listed: %w", module, err)
|
|
}
|
|
var gone []string
|
|
for _, k := range keys {
|
|
cur, _, found, err := r.get(ctx, k)
|
|
if err != nil {
|
|
return gone, err
|
|
}
|
|
if !found || (cur.Series == series && cur.Version != version) {
|
|
continue
|
|
}
|
|
if err := r.Store.Delete(ctx, k); err != nil {
|
|
return gone, fmt.Errorf("%s cannot be deleted: %w", k, err)
|
|
}
|
|
gone = append(gone, k)
|
|
}
|
|
sort.Strings(gone)
|
|
return gone, nil
|
|
}
|
|
|
|
// Unreleased are the reservations whose build ended more than UnreleasedBound before now with an artifact unpublished:
|
|
// the finding version-unreleased, naming the module, the version and the artifacts missing (published says whether an
|
|
// artifact is). A reservation whose build has not ended is not one.
|
|
type Unreleased struct {
|
|
Module, Version string
|
|
Missing []string
|
|
}
|
|
|
|
// Overdue answers the reservations past the bound with an artifact not published.
|
|
func (r *Reserver) Overdue(ctx context.Context, published func(module, version, artifact string) bool) ([]Unreleased, error) {
|
|
keys, err := r.Store.KeysOf(ctx, AllFilter)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("the reservations cannot be listed: %w", err)
|
|
}
|
|
sort.Strings(keys)
|
|
var out []Unreleased
|
|
for _, k := range keys {
|
|
cur, _, found, err := r.get(ctx, k)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !found || cur.BuiltAt.IsZero() || r.now().Sub(cur.BuiltAt) < UnreleasedBound {
|
|
continue
|
|
}
|
|
var missing []string
|
|
for _, a := range cur.Missing() {
|
|
if published == nil || !published(cur.Module, cur.Version, a) {
|
|
missing = append(missing, a)
|
|
}
|
|
}
|
|
if len(missing) > 0 {
|
|
out = append(out, Unreleased{Module: cur.Module, Version: cur.Version, Missing: missing})
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// All are the reservations, sorted by key: builder_reserved.
|
|
func (r *Reserver) All(ctx context.Context) ([]ReservedVersion, error) {
|
|
keys, err := r.Store.KeysOf(ctx, AllFilter)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("the reservations cannot be listed: %w", err)
|
|
}
|
|
sort.Strings(keys)
|
|
var out []ReservedVersion
|
|
for _, k := range keys {
|
|
cur, _, found, err := r.get(ctx, k)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if found {
|
|
out = append(out, cur)
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// Drop deletes module's reservation of series on a person's word (builder_reserved_drop); the caller writes the hand-act
|
|
// log. It answers whether there was one.
|
|
func (r *Reserver) Drop(ctx context.Context, module, series string) (bool, error) {
|
|
key := ReservedKey(module, series)
|
|
_, _, found, err := r.get(ctx, key)
|
|
if err != nil || !found {
|
|
return false, err
|
|
}
|
|
return true, r.Store.Delete(ctx, key)
|
|
}
|
|
|
|
// Published takes a registry seat's published in: the artifacts of a reservation at version (a leading v is the Go form)
|
|
// whose built digest it names are marked published. It answers the keys it marked.
|
|
func (r *Reserver) Published(ctx context.Context, version, digest string) ([]string, error) {
|
|
version = strings.TrimPrefix(version, "v")
|
|
if version == "" || digest == "" {
|
|
return nil, nil
|
|
}
|
|
keys, err := r.Store.KeysOf(ctx, AllFilter)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("the reservations cannot be listed: %w", err)
|
|
}
|
|
sort.Strings(keys)
|
|
var marked []string
|
|
for _, k := range keys {
|
|
again:
|
|
cur, rev, found, err := r.get(ctx, k)
|
|
if err != nil {
|
|
return marked, err
|
|
}
|
|
if !found || cur.Version != version {
|
|
continue
|
|
}
|
|
changed := false
|
|
for a, d := range cur.Artifacts {
|
|
if d == digest && !cur.Published[a] {
|
|
if cur.Published == nil {
|
|
cur.Published = map[string]bool{}
|
|
}
|
|
cur.Published[a], changed = true, true
|
|
}
|
|
}
|
|
if !changed {
|
|
continue
|
|
}
|
|
raw, _ := json.Marshal(cur)
|
|
if _, err := r.Store.Update(ctx, k, raw, rev); errors.Is(err, ErrConflict) {
|
|
goto again
|
|
} else if err != nil {
|
|
return marked, fmt.Errorf("%s cannot be written: %w", k, err)
|
|
}
|
|
marked = append(marked, k)
|
|
}
|
|
return marked, nil
|
|
}
|
|
|
|
// Of is module's reservation of series, and whether there is one; with an empty series the module's highest.
|
|
func (r *Reserver) Of(ctx context.Context, module, version string) (ReservedVersion, bool, error) {
|
|
all, err := r.All(ctx)
|
|
if err != nil {
|
|
return ReservedVersion{}, false, err
|
|
}
|
|
var out ReservedVersion
|
|
found := false
|
|
for _, v := range all {
|
|
if v.Module != module || (version != "" && v.Version != version) {
|
|
continue
|
|
}
|
|
if !found {
|
|
out, found = v, true
|
|
continue
|
|
}
|
|
_, p, _ := SplitVersion(v.Version)
|
|
_, q, _ := SplitVersion(out.Version)
|
|
if v.Series > out.Series || (v.Series == out.Series && p > q) {
|
|
out = v
|
|
}
|
|
}
|
|
return out, found, nil
|
|
}
|
|
|
|
const reservedBoundPrefix = "reserved."
|
|
|
|
// ReservedBoundKey is the bound of a reservation's publish in the state bounds (key-ttl): `reserved.<reserved key>`, armed
|
|
// when its build ended with every digest known. Its end while the reservation stands is the finding version-unreleased.
|
|
func ReservedBoundKey(module, series string) string {
|
|
return ReservedBoundFor(ReservedKey(module, series))
|
|
}
|
|
|
|
// ReservedBoundFor is the bound of the reservation under key.
|
|
func ReservedBoundFor(key string) string { return reservedBoundPrefix + key }
|
|
|
|
// ReservedBoundOf is the reserved key a reservation's bound names, and whether key is one.
|
|
func ReservedBoundOf(key string) (string, bool) { return strings.CutPrefix(key, reservedBoundPrefix) }
|
|
|
|
// readOnlyReserve is the reservations read by the tools bundle, which writes nothing.
|
|
type readOnlyReserve struct{ readStore }
|
|
|
|
func (readOnlyReserve) Delete(context.Context, string) error { return errReadOnly }
|
|
|
|
// AnswerReserved is builder_reserved: every reservation, or one module's.
|
|
func AnswerReserved(r Reader, module string) (map[string]any, error) {
|
|
rs := &Reserver{Store: readOnlyReserve{readStore{r}}}
|
|
all, err := rs.All(context.Background())
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out := []ReservedVersion{}
|
|
for _, v := range all {
|
|
if module == "" || v.Module == module {
|
|
out = append(out, v)
|
|
}
|
|
}
|
|
return map[string]any{"reserved": out}, nil
|
|
}
|
|
|
|
// AnswerReservedVerb is the seat mesh-builds' verb reserved: module's reserved version (the given one, else the highest)
|
|
// with each artifact's digest, an artifact not built yet with none.
|
|
func AnswerReservedVerb(r Reader, module, version string) (meshbuilds.ReservedAnswer, error) {
|
|
module = strings.TrimSpace(module)
|
|
if module == "" {
|
|
return meshbuilds.ReservedAnswer{}, errors.New("module is required")
|
|
}
|
|
rs := &Reserver{Store: readOnlyReserve{readStore{r}}}
|
|
v, found, err := rs.Of(context.Background(), module, strings.TrimSpace(version))
|
|
if err != nil {
|
|
return meshbuilds.ReservedAnswer{}, err
|
|
}
|
|
if !found {
|
|
return meshbuilds.ReservedAnswer{}, fmt.Errorf("%s has no reserved version%s", module, map[bool]string{true: " " + version, false: ""}[version != ""])
|
|
}
|
|
out := meshbuilds.ReservedAnswer{Module: v.Module, Version: v.Version, Artifacts: []meshbuilds.ReservedAnswerArtifactsItem{}}
|
|
names := make([]string, 0, len(v.Artifacts))
|
|
for a := range v.Artifacts {
|
|
names = append(names, a)
|
|
}
|
|
sort.Strings(names)
|
|
for _, a := range names {
|
|
out.Artifacts = append(out.Artifacts, meshbuilds.ReservedAnswerArtifactsItem{Artifact: a, Digest: v.Artifacts[a]})
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// The descriptions of the reservation's tools.
|
|
const (
|
|
ReservedDescription = "The versions the builder reserved (its state reserved, design 47 G section 369): one per module and MAJOR.MINOR, " +
|
|
"the version its build reserved at its start, each artifact's digest once built, when the build ended and which artifacts a registry " +
|
|
"seat said published. With module, that module's only. A key goes when the catalog says version-released for it. Read-only."
|
|
ReservedDropDescription = "Drop a module's reservation of one MAJOR.MINOR by hand (design 47 G section 369), when its version will never be " +
|
|
"released; the next build takes the next after the highest released. A hand act: it is recorded in the hand-act log."
|
|
// ReservedVerb is the seat mesh-builds' verb served as a tool of the same name.
|
|
ReservedVerb = meshbuilds.VerbReserved
|
|
)
|
|
|
|
// Missing are the artifacts of v no registry seat said published, sorted.
|
|
func (v ReservedVersion) Missing() []string {
|
|
var out []string
|
|
for a := range v.Artifacts {
|
|
if !v.Published[a] {
|
|
out = append(out, a)
|
|
}
|
|
}
|
|
sort.Strings(out)
|
|
return out
|
|
}
|
|
|
|
// At is the reservation under key, and whether there is one.
|
|
func (r *Reserver) At(ctx context.Context, key string) (ReservedVersion, bool, error) {
|
|
v, _, found, err := r.get(ctx, key)
|
|
return v, found, err
|
|
}
|