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
357 lines
12 KiB
Go
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)
|
|
}
|
|
}
|