Files
mesh-delivery/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/versions.go
T
jschoubben 436347d712 The builder takes the Delivery SDK's buildstep for ids and fetch requests and the conditions client for its findings and the cleared condition (ADR 0635 §4, issue 1966)
mesh-sdk go/buildstep and go/health's condition and finding names are no longer imported; the seat's condition-cleared is heard under the seat events pattern already held.

Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-05 17:15:36 +01:00

357 lines
12 KiB
Go

package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream"
"git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions"
"git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog"
"git.novox.be/novox/mesh-delivery/contracts/go/seats/ociartifactregistry"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds"
"git.novox.be/novox/mesh-sdk/go/process"
"mesh-delivery-builder/internal/build"
"mesh-delivery-builder/internal/versions"
)
// The floor's two module tools (novox/hq design 51 *The floor*); the seat's is `seat:<registry>.versions`.
const (
bridgeTagsTool = "mesh-delivery-forge-bridge.bridge_tags"
requirementsTool = "mesh-delivery-indexer.indexer_requirements"
)
// awaitedVersions is the state versions as versions.Store: bound once the bus holds it (the grants part makes it, issue
// 1790); until then every use is an error, so a trunk build is built unnumbered and said (build.Builder.number).
type awaitedVersions struct{ b *process.AwaitedBucket }
var errVersionsMissing = errors.New("the state " + versions.Bucket + " is not on the bus yet")
func (s awaitedVersions) kv() (jetstream.KeyValue, error) {
if kv := s.b.KV(); kv != nil {
return kv, nil
}
return nil, errVersionsMissing
}
func (s awaitedVersions) Get(ctx context.Context, key string) ([]byte, uint64, bool, error) {
kv, err := s.kv()
if err != nil {
return nil, 0, false, err
}
e, err := kv.Get(ctx, key)
if errors.Is(err, jetstream.ErrKeyNotFound) {
return nil, 0, false, nil
}
if err != nil {
return nil, 0, false, err
}
return e.Value(), e.Revision(), true, nil
}
// indexerPackages is the indexer's packages state as the builder reads a package's name in its ecosystem from it (design
// 32): the picture of its read tool's first view and its views family (ADR 0615 §1), never its bucket. Until the first
// view is answered a package is built unnumbered and said (versions.Numberer.Names), and numbered once its name is heard
// (followPackages).
type indexerPackages struct{ kv jetstream.KeyValue }
func (s indexerPackages) Keys(ctx context.Context) ([]string, error) {
keys, err := s.kv.Keys(ctx)
if errors.Is(err, jetstream.ErrNoKeysFound) {
return nil, nil
}
return keys, err
}
func (s indexerPackages) Get(ctx context.Context, key string) ([]byte, bool, error) {
e, err := s.kv.Get(ctx, key)
if errors.Is(err, jetstream.ErrKeyNotFound) || errors.Is(err, jetstream.ErrKeyDeleted) {
return nil, false, nil
}
if err != nil {
return nil, false, err
}
return e.Value(), true, nil
}
// followPackages watches the picture of the indexer's packages state: each series key heard that names a package's name
// (versions.NamedModule), its first view's included, looks at its module again (build.Builder.PackageNamed), so a done
// build that left the package unnumbered is numbered then with its digests. Woken by the views family, never a timer; a
// key heard again naming the name and module it named (the indexer writes a series at each publish) looks at nothing.
func followPackages(ctx context.Context, kv jetstream.KeyValue, b *build.Builder, logf func(string, ...any)) error {
w, err := kv.WatchAll(ctx)
if err != nil {
return err
}
heard := map[string]string{}
return follow(ctx, w, func(e jetstream.KeyValueEntry) {
if e.Operation() != jetstream.KeyValuePut {
delete(heard, e.Key())
return
}
module, name, named := versions.NamedModule(e.Key(), e.Value())
if named && heard[e.Key()] != module+" "+name {
heard[e.Key()] = module + " " + name
logf("%s.%s names a package of %s: its build is looked at again", packagesBucket, e.Key(), module)
b.PackageNamed(ctx, module)
}
})
}
func versionsConflict(err error) error {
if errors.Is(err, jetstream.ErrKeyExists) || errors.Is(err, jetstream.ErrKeyRevisionMismatch) {
return versions.ErrConflict
}
return err
}
func (s awaitedVersions) Create(ctx context.Context, key string, value []byte) (uint64, error) {
kv, err := s.kv()
if err != nil {
return 0, err
}
rev, err := kv.Create(ctx, key, value)
return rev, versionsConflict(err)
}
func (s awaitedVersions) Update(ctx context.Context, key string, value []byte, revision uint64) (uint64, error) {
kv, err := s.kv()
if err != nil {
return 0, err
}
rev, err := kv.Update(ctx, key, value, revision)
return rev, versionsConflict(err)
}
func (s awaitedVersions) KeysOf(ctx context.Context, filter string) ([]string, error) {
kv, err := s.kv()
if err != nil {
return nil, err
}
l, err := kv.ListKeysFiltered(ctx, filter)
if err != nil {
return nil, err
}
defer func() { _ = l.Stop() }()
var out []string
for k := range l.Keys() {
out = append(out, k)
}
return out, nil
}
// floorVia is the floor's three sources as calls of the builder's own account (design 51 *The floor*): the seat's verb
// versions, the forge bridge's bridge_tags and the indexer's indexer_requirements. A call that fails is that source
// silent.
func floorVia(call toolCaller) versions.FloorSources {
return versions.FloorSources{
Seat: func(_ context.Context, seat, name string) ([]string, error) {
raw, err := call("seat:"+seat+".versions", map[string]any{"name": name})
if err != nil {
return nil, err
}
var a struct {
Versions []struct {
Version string `json:"version"`
} `json:"versions"`
}
if err := json.Unmarshal(raw, &a); err != nil {
return nil, fmt.Errorf("%s's versions answered what is no list of versions: %w", seat, err)
}
var out []string
for _, v := range a.Versions {
if v.Version != "" {
out = append(out, v.Version)
}
}
return out, nil
},
Tags: func(_ context.Context, repository, prefix string) ([]string, error) {
raw, err := call(bridgeTagsTool, map[string]any{"repository": repository, "prefix": prefix})
if err != nil {
return nil, err
}
var a struct {
Tags []string `json:"tags"`
}
if err := json.Unmarshal(raw, &a); err != nil {
return nil, fmt.Errorf("bridge_tags answered what is no list of tags: %w", err)
}
return a.Tags, nil
},
Requirements: func(_ context.Context, name string) ([]string, error) {
raw, err := call(requirementsTool, map[string]any{"package": name})
if err != nil {
return nil, err
}
var a struct {
Requirements []string `json:"requirements"`
}
if err := json.Unmarshal(raw, &a); err != nil {
return nil, fmt.Errorf("indexer_requirements answered what is no list of versions: %w", err)
}
return a.Requirements, nil
},
}
}
// emitNumbered says the builder's event version.numbered (design 51 *Events of the builder*), on its own family apart
// from its builds' ends; the name and version are the bus's dedup id.
func emitNumbered(ctx context.Context, js jetstream.JetStream, cred process.Account, b builds.VersionNumbered) error {
id := "numbered-" + versions.Key(b.Name, b.Version)
if b.Again != "" {
id += "-again-" + b.Again
}
if err := emitEvent(ctx, js, cred, builds.VersionNumberedEvent, id, b); err != nil {
return fmt.Errorf("saying %s for %s@%s: %w", builds.VersionNumberedEvent, b.Name, b.Version, err)
}
return nil
}
// emitEvent says one of the builder's events, mesh.mod.<module>.event.<event>, with the SDK's envelope headers and id as
// the bus's dedup id.
func emitEvent(ctx context.Context, js jetstream.JetStream, cred process.Account, event, id string, body any) error {
data, err := json.Marshal(body)
if err != nil {
return err
}
msg := nats.NewMsg(cred.EventSubject(event))
msg.Header.Set("x-event-id", id)
msg.Header.Set("x-source", cred.Module)
msg.Header.Set("x-node", cred.Node)
msg.Header.Set("x-time", time.Now().UTC().Format(time.RFC3339Nano))
msg.Header.Set("content-type", "application/json")
msg.Data = data
pub, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
_, err = js.PublishMsg(pub, msg, jetstream.WithMsgID(id))
return err
}
// The events on the builder's consumer of EVENTS that wake a floor (design 51 *The floor*, DLV-B3b).
var (
manifestAcceptedSubject = meshcatalog.SubjectManifestAccepted
conditionClearedSubject = meshconditions.SubjectConditionCleared
)
// floorWake asks the floor of the names an event wakes again, by looking at every module again: a trunk build is
// numbered before its claim (build.Builder.number).
type floorWake struct {
b *build.Builder
waits *versions.FloorWaits
logf func(string, ...any)
}
// heard takes a message of the consumer in when it is one that wakes a floor, acknowledged, and answers whether it was.
func (f *floorWake) heard(ctx context.Context, m jetstream.Msg) bool {
switch m.Subject() {
case manifestAcceptedSubject:
f.ack(m)
f.wake(ctx, versions.WakeAccepted, "", "")
case conditionClearedSubject:
f.ack(m)
var e meshconditions.ConditionEvent
if err := json.Unmarshal(m.Data(), &e); err != nil {
f.logf("%s holds what is no condition, left: %v", m.Subject(), err)
return true
}
if e.Subject == nil {
return true
}
for _, module := range conditionModules(e.Subject.ID) {
f.wake(ctx, versions.WakeCleared, "", module)
}
default:
return false
}
return true
}
func (f *floorWake) ack(m jetstream.Msg) {
if err := m.Ack(); err != nil {
f.logf("acknowledging %s: %v", m.Subject(), err)
}
}
// wake looks at every module again when the event wakes a name, and says which.
func (f *floorWake) wake(ctx context.Context, kind, seat, module string) {
names := f.waits.Wake(kind, seat, module, "")
if len(names) == 0 {
return
}
f.logf("the floor of %s is asked again: %s", strings.Join(names, ", "), kind)
f.b.Reconsider(ctx)
}
// conditionModules are the tokens of a condition's subject id (`<node>/<module>`, `<module>.<check>`): a module named
// among them is the one whose condition cleared.
func conditionModules(id string) []string {
return strings.FieldsFunc(id, func(r rune) bool { return r == '/' || r == '.' || r == ':' })
}
// doneVia says whether a build key is done, read from the builder's own builds state: the build's file is held.
func doneVia(b *build.Builder) func(ctx context.Context, module, fp string) (bool, error) {
return func(ctx context.Context, module, fp string) (bool, error) {
key, err := builds.BuilderBuildKey(module, fp)
if err != nil {
return false, err
}
raw, _, found, err := b.Claimer.Store.Get(ctx, key)
if err != nil || !found {
return false, err
}
cur, err := builds.DecodeBuilderBuild(raw)
if err != nil {
return false, err
}
return cur.State == builds.BuildDone, nil
}
}
// removeVia calls a registry seat's remove for one tagged version, as the builder's own account (design 51 *Tagged
// versions*): the reason tag-gone; the seat says removed on its events, or answers kept when another tag names the
// digest (the registry part's RemoveAnswer: kept, reason, tags), which ends the remove.
func removeVia(call toolCaller) func(ctx context.Context, seat string, e versions.Entry) (versions.RemoveOutcome, error) {
return func(_ context.Context, seat string, e versions.Entry) (versions.RemoveOutcome, error) {
raw, err := call("seat:"+seat+".remove", map[string]any{"name": e.Name, "version": e.Version, "tag": e.Tag, "reason": "tag-gone"})
if err != nil {
return versions.RemoveOutcome{}, err
}
var a ociartifactregistry.RemoveAnswer // the four registry seats share the part's answer
if len(raw) > 0 && json.Unmarshal(raw, &a) != nil {
return versions.RemoveOutcome{}, fmt.Errorf("%s's remove answered what is no answer of it: %s", seat, raw)
}
return versions.RemoveOutcome{Kept: a.Kept, Reason: a.Reason, Tags: a.Tags}, nil
}
}
// tagAsked answers an unanswered value of tag-asks by compare-and-set at its revision, said in the log.
func tagAsked(ctx context.Context, acts versions.Acts, kv jetstream.KeyValue, e jetstream.KeyValueEntry, logf func(string, ...any)) {
if e.Operation() != jetstream.KeyValuePut {
return
}
t, err := versions.DecodeTagAsk(e.Value())
if err != nil {
logf("%s.%s holds what is not a tag ask, left: %v", versions.TagAsksState, e.Key(), err)
return
}
if !t.Answered.IsZero() {
return
}
t = acts.TagAsked(ctx, t, time.Now())
logf("%s: %s of %s %s by %s (%s): %s", e.Key(), map[bool]string{true: "untag", false: "tag ask"}[t.Untag], t.Repository, t.Branch, t.By, t.Why, t.Answer)
data, err := json.Marshal(t)
if err != nil {
return
}
if _, err := kv.Update(ctx, e.Key(), data, e.Revision()); err != nil && !errors.Is(err, jetstream.ErrKeyExists) && !errors.Is(err, jetstream.ErrKeyRevisionMismatch) {
logf("%s.%s: the answer could not be written: %v", versions.TagAsksState, e.Key(), err)
}
}