Say why assign finds no module, and keep an assignment pending on its build
A merge asks for a new module's build, and assign answered "no module of that name" until the build registered it, which read as a module nobody registered (hq issue 325). Keep every build request, tell a build in flight, a module known and not built, and an unknown name apart, and make an assignment made while the build runs when the build registers the module.
This commit is contained in:
@@ -7,8 +7,10 @@ import (
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
)
|
||||
|
||||
// The things the mesh can be asked to do, separated from how it was asked.
|
||||
@@ -41,6 +43,11 @@ import (
|
||||
// It costs a resolution per machine. Assignment is a person typing a command, and being told which
|
||||
// machines this just blocked is worth more than the milliseconds.
|
||||
func assign(ctx context.Context, open *stores, node string, modules ...string) (string, error) {
|
||||
return assignWith(ctx, open, node, assignOptions{}, modules...)
|
||||
}
|
||||
|
||||
// assignWith is assign asked with its options: build, for a module known and not built (novox/hq issue 325).
|
||||
func assignWith(ctx context.Context, open *stores, node string, opts assignOptions, modules ...string) (string, error) {
|
||||
if len(modules) == 0 {
|
||||
return "", fmt.Errorf("assign %s names no module", node)
|
||||
}
|
||||
@@ -51,6 +58,29 @@ func assign(ctx context.Context, open *stores, node string, modules ...string) (
|
||||
return "", err
|
||||
}
|
||||
defer release()
|
||||
// **A module the catalogue does not hold is looked for further before it is refused** (novox/hq issue
|
||||
// 325): a build in flight keeps the assignment pending, a module known and not built is said with how to
|
||||
// build it, and only a name the mesh never heard of is refused as unknown. Several modules in one act
|
||||
// are judged together (ADR 0207), so an act naming one not registered is not made in part.
|
||||
if missing, err := notInCatalogue(ctx, open, modules); err != nil {
|
||||
return "", err
|
||||
} else if len(missing) > 0 {
|
||||
if len(modules) == 1 {
|
||||
return notRegistered(ctx, open, node, modules[0], opts)
|
||||
}
|
||||
var lines []string
|
||||
for _, m := range missing {
|
||||
where, err := whereIs(ctx, open.inventory, m, time.Now())
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
lines = append(lines, where.said)
|
||||
}
|
||||
return "", fmt.Errorf("%w: nothing was assigned, since modules assigned together are judged together "+
|
||||
"(ADR 0207) and %s not registered:\n %s\n assign them together once each is registered, or each "+
|
||||
"alone to keep it pending on its build", inventory.ErrNoSuchModule, strings.Join(missing, ", "),
|
||||
strings.Join(lines, "\n "))
|
||||
}
|
||||
// **The one assignment refused for what the node lacks** (novox/hq ADR 0207). Everything else
|
||||
// an assignment leaves unresolved is kept, because assignment is not an ordering; a module whose
|
||||
// resources are applied through a seat nothing on the node holds is refused, because that order
|
||||
@@ -200,6 +230,17 @@ func unassign(ctx context.Context, open *stores, node string, modules ...string)
|
||||
for _, a := range assigned {
|
||||
runs[a] = true
|
||||
}
|
||||
// A module only pending on the node (novox/hq issue 325) is withdrawn, when it is the act's one module:
|
||||
// that is the undo of an assignment kept while its build runs.
|
||||
if len(modules) == 1 && !runs[modules[0]] {
|
||||
said, withdrawn, err := withdrawPending(ctx, open.inventory, node, modules[0])
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if withdrawn {
|
||||
return said, nil
|
||||
}
|
||||
}
|
||||
for _, module := range modules {
|
||||
if !runs[module] {
|
||||
return "", fmt.Errorf("%s is not assigned to %s", module, node)
|
||||
|
||||
@@ -462,6 +462,17 @@ func buildOneAsked(ctx context.Context, source buildSource, path, ref string, wa
|
||||
}
|
||||
defer ask.Close()
|
||||
fmt.Printf(" of %s\n", seat)
|
||||
// Kept before it is asked, so `assign` knows a module is coming while its build runs (novox/hq issue 325).
|
||||
// A dry run registers nothing, and is not kept.
|
||||
if !dryRun {
|
||||
if open, err := openStores(ctx); err == nil {
|
||||
recordBuildRequest(ctx, open.inventory, inventory.BuildRequest{ID: request.ID,
|
||||
Repository: source.Repository, Seat: source.Seat, Path: path, Ref: ref, For: "build"})
|
||||
open.Close()
|
||||
} else {
|
||||
fmt.Fprintf(os.Stderr, "build %s is asked and not kept as a build request: %v\n", request.ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
if wait == 0 {
|
||||
// Asked and not waited for (novox/hq issue 176): the outcome is the role's event, and the
|
||||
@@ -492,6 +503,10 @@ func buildOneAsked(ctx context.Context, source buildSource, path, ref string, wa
|
||||
}
|
||||
defer open.Close()
|
||||
manifest, kept, err := takeIn(ctx, open.inventory, result)
|
||||
// The assignments pending on this build, made or ended (novox/hq issue 325).
|
||||
for _, line := range settlePendingOnBuild(ctx, open, result, manifest.Module, err) {
|
||||
fmt.Printf(" %s\n", line)
|
||||
}
|
||||
if err != nil {
|
||||
return request.ID, err
|
||||
}
|
||||
@@ -771,6 +786,11 @@ type answers struct {
|
||||
// handed to the operator; healsUnread why it could not be read.
|
||||
heals *healsCount
|
||||
healsUnread string
|
||||
// pending is every assignment waiting for its module's build, and every one ended in the last day
|
||||
// (novox/hq issue 325); pendingUnread why they could not be read. One waiting, or one ended other than
|
||||
// applied, is not well: an assignment somebody made is not made yet, or will not be.
|
||||
pending []inventory.PendingAssignment
|
||||
pendingUnread string
|
||||
}
|
||||
|
||||
// heldBy is every artifact this mesh has built, for a build that may need one as its base.
|
||||
|
||||
@@ -371,6 +371,15 @@ func (b builds) Built(ctx context.Context, result link.BuildResult) error {
|
||||
return nil
|
||||
}
|
||||
manifest, _, err := takeIn(ctx, b.inv, result)
|
||||
// The assignments pending on this build, made or ended (novox/hq issue 325), said in the daemon's log
|
||||
// after what became of the build.
|
||||
if b.open != nil {
|
||||
defer func() {
|
||||
for _, line := range settlePendingOnBuild(ctx, b.open, result, manifest.Module, err) {
|
||||
fmt.Printf("%s: %s\n", result.ID, line)
|
||||
}
|
||||
}()
|
||||
}
|
||||
// When it was asked, so a plan takes as its outcome only a build asked for it or after it
|
||||
// (novox/hq 04-ISSUES/219). Zero when the id does not say.
|
||||
asked, _ := link.BuildAskedAt(result.ID)
|
||||
|
||||
@@ -354,9 +354,20 @@ func moduleCommand(ctx context.Context, args []string) error {
|
||||
func assignCommand(ctx context.Context, verb string, args []string) error {
|
||||
// Several modules in one act (novox/hq ADR 0207): holders that depend on each other — the
|
||||
// service manager and the package manager — can only go on, or come off, together.
|
||||
set := flag.NewFlagSet(verb, flag.ContinueOnError)
|
||||
// A module known and not built: ask for its build, and keep the assignment pending on it (novox/hq
|
||||
// issue 325). Only assign reads it.
|
||||
build := set.Bool("build", false, "for a module known and not built: ask for its build, and assign it when it registers")
|
||||
args, err := parseAround(set, args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(args) < 2 {
|
||||
return fmt.Errorf("%s <node> <module> [<module>…]", verb)
|
||||
}
|
||||
if *build && verb == "unassign" {
|
||||
return errors.New("unassign takes no --build")
|
||||
}
|
||||
open, err := openStores(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -365,7 +376,9 @@ func assignCommand(ctx context.Context, verb string, args []string) error {
|
||||
|
||||
// The act itself is in acts.go, so the command API refuses exactly what this refuses
|
||||
// (novox/hq ADR 0035). What differs between the surfaces is how the answer is printed.
|
||||
act := assign
|
||||
act := func(ctx context.Context, open *stores, node string, modules ...string) (string, error) {
|
||||
return assignWith(ctx, open, node, assignOptions{Build: *build}, modules...)
|
||||
}
|
||||
if verb == "unassign" {
|
||||
act = unassign
|
||||
}
|
||||
|
||||
@@ -0,0 +1,442 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
)
|
||||
|
||||
// A module not registered yet, and the assignment that waits for it (novox/hq issue 325).
|
||||
//
|
||||
// On 2026-10-08 the catalogue's pull request adding `sensors` merged; a minute later `assign g14 sensors`
|
||||
// answered "no module of that name: sensors", and a few minutes later the same call worked. The merge had
|
||||
// asked for the build (issue 300) and the build had not finished. The answer read as "you forgot to register
|
||||
// it", and nothing in it said a build was on its way.
|
||||
//
|
||||
// So an assignment of a module the catalogue does not hold looks further before it refuses, and says which
|
||||
// of three cases it is in:
|
||||
//
|
||||
// - **a build is in flight**: a build request for the module's directory has no outcome yet. The
|
||||
// assignment is kept as a **pending assignment** and made when the build registers the module.
|
||||
// - **known, not built**: the mesh asked for the directory before, and the build failed, was not
|
||||
// registered, said nothing within its bound, or could not be asked. Said, with `build` and assign's
|
||||
// own build argument, which asks for the build and keeps the assignment pending on it.
|
||||
// - **unknown**: no module, build request or merge by that name. Refused as before, with the closest names.
|
||||
//
|
||||
// **Pending by default while a build is in flight**, without an argument to ask for it: an assignment is
|
||||
// what a person meant and is kept even when its machine does not resolve (acts.go), nothing is sent until a
|
||||
// push, and `unassign` withdraws it. Asking for a second act once the build lands is exactly the two acts by
|
||||
// hand issue 300 removed. A build is never asked for by default: it runs on another machine, and a directory
|
||||
// whose last build failed is a fault to look at before it is a thing to retry.
|
||||
|
||||
// buildRequestBound is how long a build request may go without an outcome before it is no longer read as in
|
||||
// flight, and a pending assignment waiting for it expires. Generous: a build waits behind others on the
|
||||
// build seat, and a pending assignment that waits a little longer costs nothing.
|
||||
const buildRequestBound = 2 * time.Hour
|
||||
|
||||
// pendingShownFor is how long an ended pending assignment is said in `status`.
|
||||
const pendingShownFor = 24 * time.Hour
|
||||
|
||||
// assignOptions are what an assignment was asked with beside its machine and modules.
|
||||
type assignOptions struct {
|
||||
// Build asks for the build of a module known and not built, and keeps the assignment pending on it.
|
||||
Build bool
|
||||
}
|
||||
|
||||
// recordBuildRequest keeps a build request so `assign` can find it. Said, never fatal: the build was asked.
|
||||
func recordBuildRequest(ctx context.Context, inv *inventory.Inventory, r inventory.BuildRequest) {
|
||||
if err := inv.RecordBuildRequest(ctx, r); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "build %s was asked, and could not be kept as a build request, so `assign` "+
|
||||
"will not know it is coming: %v\n", r.ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// The cases of a module the catalogue does not hold (novox/hq issue 325).
|
||||
const (
|
||||
unknownModule = iota
|
||||
buildInFlight
|
||||
knownNotBuilt
|
||||
)
|
||||
|
||||
// notHeld is where a module the catalogue does not hold stands: its case, the build request it was read
|
||||
// from, and the sentence that says it.
|
||||
type notHeld struct {
|
||||
kind int
|
||||
request inventory.RequestOutcome
|
||||
said string
|
||||
}
|
||||
|
||||
// whereIs looks for a module the catalogue does not hold among the build requests the mesh keeps.
|
||||
func whereIs(ctx context.Context, inv *inventory.Inventory, module string, now time.Time) (notHeld, error) {
|
||||
requests, err := inv.RequestsNamed(ctx, module)
|
||||
if err != nil {
|
||||
return notHeld{}, err
|
||||
}
|
||||
if r, ok := inFlight(requests, now); ok {
|
||||
return notHeld{kind: buildInFlight, request: r, said: fmt.Sprintf("%s is not registered yet: it is being "+
|
||||
"built from %s since %s, as build %s; it can be assigned once that build registers it", module,
|
||||
requestSource(r), clock(r.At), r.ID)}, nil
|
||||
}
|
||||
if len(requests) > 0 {
|
||||
return notHeld{kind: knownNotBuilt, request: requests[0], said: fmt.Sprintf("%s is known and not "+
|
||||
"registered: %s", module, whyNotBuilt(requests[0], now))}, nil
|
||||
}
|
||||
said := fmt.Sprintf("%v: %s — the mesh holds no module, build request or merge by that name",
|
||||
inventory.ErrNoSuchModule, module)
|
||||
if near := closestNames(ctx, inv, module); len(near) > 0 {
|
||||
said += "; the closest it holds: " + strings.Join(near, ", ")
|
||||
}
|
||||
return notHeld{kind: unknownModule, said: said}, nil
|
||||
}
|
||||
|
||||
// notInCatalogue is the modules named that the catalogue does not hold.
|
||||
func notInCatalogue(ctx context.Context, open *stores, modules []string) ([]string, error) {
|
||||
shelf, err := open.inventory.Catalogue(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var missing []string
|
||||
for _, m := range modules {
|
||||
if _, ok := shelf[m]; !ok {
|
||||
missing = append(missing, m)
|
||||
}
|
||||
}
|
||||
return missing, nil
|
||||
}
|
||||
|
||||
// notRegistered answers an assignment of a module the catalogue does not hold: pending on a build in flight,
|
||||
// known and not built, or unknown (novox/hq issue 325). An answer with no error is a pending assignment kept.
|
||||
func notRegistered(ctx context.Context, open *stores, node, module string, opts assignOptions) (string, error) {
|
||||
inv := open.inventory
|
||||
if _, err := inv.NodeByName(ctx, node); err != nil {
|
||||
return "", err
|
||||
}
|
||||
now := time.Now()
|
||||
where, err := whereIs(ctx, inv, module, now)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
switch where.kind {
|
||||
case buildInFlight:
|
||||
lead := where.said
|
||||
if opts.Build {
|
||||
lead += "\n no second build was asked: this one is running"
|
||||
}
|
||||
return keepPending(ctx, open, node, module, where.request, lead)
|
||||
case knownNotBuilt:
|
||||
last := where.request
|
||||
if !opts.Build {
|
||||
return "", fmt.Errorf("%w: %s\n assign it again with build \"true\" to ask for its build and keep this "+
|
||||
"assignment until the build registers it; or `build` it (repository %s, path %s) and assign it "+
|
||||
"once it is registered", inventory.ErrNoSuchModule, where.said, last.Repository, orRoot(last.Path))
|
||||
}
|
||||
source := buildSource{Repository: last.Repository, Seat: last.Seat}
|
||||
id, err := askABuild(ctx, source, last.Path, last.Ref)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%s\n its build could not be asked for: %w; nothing was assigned", where.said, err)
|
||||
}
|
||||
asked := inventory.RequestOutcome{BuildRequest: inventory.BuildRequest{ID: id, Repository: last.Repository,
|
||||
Seat: last.Seat, Path: last.Path, Ref: last.Ref, For: "assign", At: now.UTC()}}
|
||||
recordBuildRequest(ctx, inv, asked.BuildRequest)
|
||||
lead := fmt.Sprintf("%s\n build %s of %s is asked for now; %s can be assigned once it registers it",
|
||||
where.said, id, requestSource(asked), module)
|
||||
return keepPending(ctx, open, node, module, asked, lead)
|
||||
}
|
||||
answer := strings.TrimPrefix(where.said, inventory.ErrNoSuchModule.Error()) +
|
||||
"\n a module in a repository is built with `build` (its repository and path), then assigned"
|
||||
if opts.Build {
|
||||
answer += "\n build \"true\" asks for nothing here: the mesh does not know where a module of that name is"
|
||||
}
|
||||
return "", fmt.Errorf("%w%s", inventory.ErrNoSuchModule, answer)
|
||||
}
|
||||
|
||||
// keepPending records the assignment as pending on a build request, and says so.
|
||||
func keepPending(ctx context.Context, open *stores, node, module string, r inventory.RequestOutcome, lead string) (string, error) {
|
||||
inv := open.inventory
|
||||
p, err := inv.RecordPending(ctx, inventory.PendingAssignment{Node: node, Module: module, Build: r.ID,
|
||||
Repository: r.Repository, Path: r.Path})
|
||||
if errors.Is(err, inventory.ErrAlreadyPending) {
|
||||
return lead + fmt.Sprintf("\n %s already waits for %s: nothing changed", node, module), nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
answer := lead + fmt.Sprintf("\n the assignment is kept as pending: %s is assigned %s when the build "+
|
||||
"registers it, and `status` says it until then. If the build fails it expires and says why; "+
|
||||
"`unassign %s %s` withdraws it", node, module, node, module)
|
||||
// The build may have registered the module between the look and the record: made at once, then.
|
||||
if shelf, err := inv.Catalogue(ctx); err == nil {
|
||||
if _, now := shelf[module]; now {
|
||||
if line := applyPending(ctx, open, p, r.ID); line != "" {
|
||||
answer += "\n " + line
|
||||
}
|
||||
}
|
||||
}
|
||||
return answer, nil
|
||||
}
|
||||
|
||||
// inFlight is the newest build request for the module with no outcome yet, asked within the bound.
|
||||
func inFlight(requests []inventory.RequestOutcome, now time.Time) (inventory.RequestOutcome, bool) {
|
||||
for _, r := range requests {
|
||||
if r.NotAsked == "" && !r.Heard && now.Sub(r.At) < buildRequestBound {
|
||||
return r, true
|
||||
}
|
||||
}
|
||||
return inventory.RequestOutcome{}, false
|
||||
}
|
||||
|
||||
// whyNotBuilt says what came of a module's last build request.
|
||||
func whyNotBuilt(r inventory.RequestOutcome, now time.Time) string {
|
||||
where := fmt.Sprintf("%s in %s", orRoot(r.Path), r.Repository)
|
||||
switch {
|
||||
case r.NotAsked != "":
|
||||
return fmt.Sprintf("%s; the merge that added it (%s) could not ask for its build: %s", where,
|
||||
short(r.Commit), firstLine(r.NotAsked))
|
||||
case !r.Heard:
|
||||
return fmt.Sprintf("%s; build %s was asked at %s, and no outcome has been heard in %s", where, r.ID,
|
||||
clock(r.At), now.Sub(r.At).Round(time.Minute))
|
||||
case r.Failed != "":
|
||||
return fmt.Sprintf("%s; its last build, %s at %s, failed: %s", where, r.ID, clock(r.HeardAt),
|
||||
firstLine(r.Failed))
|
||||
case r.Module != "" && r.Module != r.Name():
|
||||
return fmt.Sprintf("%s; its last build, %s, registered it as %s", where, r.ID, r.Module)
|
||||
default:
|
||||
return fmt.Sprintf("%s; its last build, %s at %s, was recorded and not registered — `builds` says why",
|
||||
where, r.ID, clock(r.HeardAt))
|
||||
}
|
||||
}
|
||||
|
||||
// requestSource is a build request's source as a person reads it: repository@commit (directory).
|
||||
func requestSource(r inventory.RequestOutcome) string {
|
||||
at := short(r.Commit)
|
||||
if at == "" {
|
||||
at = r.Ref
|
||||
}
|
||||
if at == "" {
|
||||
at = "its default branch"
|
||||
}
|
||||
return fmt.Sprintf("%s@%s (%s)", r.Repository, at, orRoot(r.Path))
|
||||
}
|
||||
|
||||
// clock is a moment as a person reads it, in the controller's local time.
|
||||
func clock(t time.Time) string { return t.Local().Format("2006-01-02 15:04:05 MST") }
|
||||
|
||||
// closestNames is up to three names the mesh holds that are near the one asked: registered modules and
|
||||
// directories it asked to build.
|
||||
func closestNames(ctx context.Context, inv *inventory.Inventory, name string) []string {
|
||||
var candidates []string
|
||||
if shelf, err := inv.Catalogue(ctx); err == nil {
|
||||
for m := range shelf {
|
||||
candidates = append(candidates, m)
|
||||
}
|
||||
}
|
||||
if asked, err := inv.RequestedNames(ctx); err == nil {
|
||||
candidates = append(candidates, asked...)
|
||||
}
|
||||
type scored struct {
|
||||
name string
|
||||
distance int
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
var near []scored
|
||||
limit := max(2, len(name)/3)
|
||||
for _, c := range candidates {
|
||||
if seen[c] || c == name {
|
||||
continue
|
||||
}
|
||||
seen[c] = true
|
||||
d := editDistance(name, c)
|
||||
if d <= limit || strings.Contains(c, name) || strings.Contains(name, c) {
|
||||
near = append(near, scored{c, d})
|
||||
}
|
||||
}
|
||||
sort.Slice(near, func(i, j int) bool {
|
||||
if near[i].distance != near[j].distance {
|
||||
return near[i].distance < near[j].distance
|
||||
}
|
||||
return near[i].name < near[j].name
|
||||
})
|
||||
var out []string
|
||||
for i := 0; i < len(near) && i < 3; i++ {
|
||||
out = append(out, near[i].name)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// editDistance is the Levenshtein distance between two names.
|
||||
func editDistance(a, b string) int {
|
||||
ra, rb := []rune(a), []rune(b)
|
||||
prev := make([]int, len(rb)+1)
|
||||
for j := range prev {
|
||||
prev[j] = j
|
||||
}
|
||||
for i := 1; i <= len(ra); i++ {
|
||||
cur := make([]int, len(rb)+1)
|
||||
cur[0] = i
|
||||
for j := 1; j <= len(rb); j++ {
|
||||
cost := 1
|
||||
if ra[i-1] == rb[j-1] {
|
||||
cost = 0
|
||||
}
|
||||
cur[j] = min(prev[j]+1, cur[j-1]+1, prev[j-1]+cost)
|
||||
}
|
||||
prev = cur
|
||||
}
|
||||
return prev[len(rb)]
|
||||
}
|
||||
|
||||
// applyPending makes a pending assignment now that its module is registered, and settles it: applied, or
|
||||
// refused with the refusal. Returns what it said, or nothing when another process settled it first.
|
||||
func applyPending(ctx context.Context, open *stores, p inventory.PendingAssignment, by string) string {
|
||||
inv := open.inventory
|
||||
answer, err := assign(ctx, open, p.Node, p.Module)
|
||||
state, note := inventory.PendingApplied, ""
|
||||
if err != nil {
|
||||
state = inventory.PendingRefused
|
||||
note = fmt.Sprintf("the pending assignment of %s to %s was refused when build %s registered it: %s",
|
||||
p.Module, p.Node, by, firstLine(err.Error()))
|
||||
} else {
|
||||
// Not promised as sent: a walk sends it if the module's upgrade announcement finds it
|
||||
// assigned, and nothing does if the announcement came first. `status` says which, as for any machine.
|
||||
note = fmt.Sprintf("%s is assigned %s: build %s registered it (pending since %s); `status` says whether "+
|
||||
"%s has been sent it, and `push %s` sends it if not", p.Node, p.Module, by, clock(p.Since), p.Node, p.Node)
|
||||
if strings.Contains(answer, "already runs") {
|
||||
note = fmt.Sprintf("%s already runs %s: the pending assignment is met", p.Node, p.Module)
|
||||
}
|
||||
}
|
||||
settled, serr := inv.SettlePending(ctx, p.ID, state, note)
|
||||
if serr != nil {
|
||||
return fmt.Sprintf("%s — and it could not be recorded as settled: %v", note, serr)
|
||||
}
|
||||
if !settled {
|
||||
return ""
|
||||
}
|
||||
return note
|
||||
}
|
||||
|
||||
// expirePending ends a pending assignment whose build will not register its module, with why.
|
||||
func expirePending(ctx context.Context, inv *inventory.Inventory, p inventory.PendingAssignment, why string) string {
|
||||
note := fmt.Sprintf("the pending assignment of %s to %s expired: %s; nothing was assigned. Assign it again with "+
|
||||
"build \"true\" to ask for the build again", p.Module, p.Node, why)
|
||||
settled, err := inv.SettlePending(ctx, p.ID, inventory.PendingExpired, note)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("%s — and it could not be recorded: %v", note, err)
|
||||
}
|
||||
if !settled {
|
||||
return ""
|
||||
}
|
||||
return note
|
||||
}
|
||||
|
||||
// settlePendingOnBuild is a build's outcome reaching the assignments waiting for it, wherever the outcome is
|
||||
// taken in (novox/hq issue 325): a module registered makes every assignment pending on it; a build of a
|
||||
// pending assignment that failed, or was not registered, ends it with why. Returns what was said.
|
||||
func settlePendingOnBuild(ctx context.Context, open *stores, result link.BuildResult, module string, takeErr error) []string {
|
||||
inv := open.inventory
|
||||
waiting, err := inv.Pending(ctx, time.Time{})
|
||||
if err != nil {
|
||||
return []string{fmt.Sprintf("the pending assignments could not be read after build %s: %v", result.ID, err)}
|
||||
}
|
||||
registered := module != "" && (takeErr == nil || errors.Is(takeErr, inventory.ErrSuperseded))
|
||||
var said []string
|
||||
for _, p := range waiting {
|
||||
switch {
|
||||
case registered && p.Module == module:
|
||||
if line := applyPending(ctx, open, p, result.ID); line != "" {
|
||||
said = append(said, line)
|
||||
}
|
||||
case !registered && p.Build == result.ID:
|
||||
why := firstLine(result.Failed)
|
||||
if why == "" && takeErr != nil {
|
||||
why = firstLine(takeErr.Error())
|
||||
}
|
||||
what := fmt.Sprintf("build %s of %s failed: %s", result.ID, orRoot(p.Path), why)
|
||||
if result.Failed == "" {
|
||||
what = fmt.Sprintf("build %s of %s was recorded and not registered: %s", result.ID, orRoot(p.Path), why)
|
||||
}
|
||||
if line := expirePending(ctx, inv, p, what); line != "" {
|
||||
said = append(said, line)
|
||||
}
|
||||
}
|
||||
}
|
||||
return said
|
||||
}
|
||||
|
||||
// settleStalePending settles what a missed outcome left waiting: a module registered meanwhile is assigned,
|
||||
// a build heard and not registered ends its assignment, and one with no outcome past the bound expires.
|
||||
// Asked where the mesh is read for status, so a pending assignment never waits silently for ever.
|
||||
func settleStalePending(ctx context.Context, open *stores, now time.Time) []string {
|
||||
inv := open.inventory
|
||||
waiting, err := inv.Pending(ctx, time.Time{})
|
||||
if err != nil {
|
||||
// Said, not swallowed; status reads the pending assignments again and says the same failure there.
|
||||
return []string{fmt.Sprintf("the pending assignments could not be read to settle them: %v", err)}
|
||||
}
|
||||
shelf, err := inv.Catalogue(ctx)
|
||||
if err != nil {
|
||||
return []string{fmt.Sprintf("the catalogue could not be read to settle the pending assignments: %v", err)}
|
||||
}
|
||||
var said []string
|
||||
for _, p := range waiting {
|
||||
if _, ok := shelf[p.Module]; ok {
|
||||
if line := applyPending(ctx, open, p, p.Build); line != "" {
|
||||
said = append(said, line)
|
||||
}
|
||||
continue
|
||||
}
|
||||
requests, err := inv.RequestsNamed(ctx, p.Module)
|
||||
if err != nil {
|
||||
said = append(said, fmt.Sprintf("the build requests for %s could not be read: %v", p.Module, err))
|
||||
continue
|
||||
}
|
||||
var r *inventory.RequestOutcome
|
||||
for i := range requests {
|
||||
if requests[i].ID == p.Build {
|
||||
r = &requests[i]
|
||||
}
|
||||
}
|
||||
why := ""
|
||||
switch {
|
||||
case r == nil && now.Sub(p.Since) > buildRequestBound:
|
||||
why = fmt.Sprintf("build %s is no longer on record", p.Build)
|
||||
case r != nil && r.Heard:
|
||||
why = whyNotBuilt(*r, now)
|
||||
case r != nil && now.Sub(r.At) > buildRequestBound:
|
||||
why = fmt.Sprintf("no outcome of build %s was heard within %s of asking", p.Build, buildRequestBound)
|
||||
}
|
||||
if why == "" {
|
||||
continue
|
||||
}
|
||||
if line := expirePending(ctx, inv, p, why); line != "" {
|
||||
said = append(said, line)
|
||||
}
|
||||
}
|
||||
return said
|
||||
}
|
||||
|
||||
// withdrawPending is `unassign` of a module only pending on a machine: the pending assignment withdrawn.
|
||||
func withdrawPending(ctx context.Context, inv *inventory.Inventory, node, module string) (string, bool, error) {
|
||||
waiting, err := inv.Pending(ctx, time.Time{})
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
for _, p := range waiting {
|
||||
if p.Node != node || p.Module != module {
|
||||
continue
|
||||
}
|
||||
note := fmt.Sprintf("the pending assignment of %s to %s is withdrawn; build %s goes on, and registers %s "+
|
||||
"assigned nowhere", module, node, p.Build, module)
|
||||
if _, err := inv.SettlePending(ctx, p.ID, inventory.PendingWithdrawn, note); err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
return note, true, nil
|
||||
}
|
||||
return "", false, nil
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
)
|
||||
|
||||
// novox/hq issue 325: an assignment of a module the catalogue does not hold says which case it is in — a
|
||||
// build in flight (kept pending), known and not built (said, with build), or unknown (refused, with the
|
||||
// closest names) — and a pending assignment is made when its build registers the module, or ends with why.
|
||||
|
||||
// aCatalogueMesh is aMesh with one module held from the catalogue, so a merge of it is acted on.
|
||||
func aCatalogueMesh(t *testing.T) *stores {
|
||||
t.Helper()
|
||||
open := aMesh(t)
|
||||
if err := open.inventory.RegisterModule(t.Context(), catalogue.Manifest{Module: "networkmanager", Version: "1"},
|
||||
inventory.Source{Repository: "novox/mesh-catalog", Seat: "git", Path: "modules/networkmanager", Ref: "main",
|
||||
BuiltFrom: "c0", Head: "c0"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return open
|
||||
}
|
||||
|
||||
// mergeAdding is the catalogue's merge adding one module's directory, acted on as the bus hands it over.
|
||||
func mergeAdding(t *testing.T, open *stores, dir, commit string) {
|
||||
t.Helper()
|
||||
m := link.SourceMoved{Owner: "novox", Repo: "mesh-catalog", Base: "main", Commit: commit,
|
||||
Paths: []string{dir + "/module.json"}, ModuleDirs: []string{dir}, ModuleDirsSaid: true}
|
||||
if err := (following{open: open}).SourceMoved(t.Context(), m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// outcome is a build's outcome as the build seat announces it: registered as module, or failed.
|
||||
func outcome(id, dir, commit, module, failed string) link.BuildResult {
|
||||
r := link.BuildResult{ID: id, Repository: "novox/mesh-catalog", Path: dir, Ref: "main", Commit: commit,
|
||||
On: "anchor", Failed: failed, Source: &link.SourceOnSeat{Repository: "novox/mesh-catalog", Seat: "git"}}
|
||||
if failed == "" {
|
||||
r.Module = module
|
||||
r.Manifest, _ = json.Marshal(catalogue.Manifest{Module: module, Version: "1"})
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
func assignedTo(t *testing.T, open *stores, node string) []string {
|
||||
t.Helper()
|
||||
got, err := open.inventory.Assigned(t.Context(), node)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return got
|
||||
}
|
||||
|
||||
func pendingOf(t *testing.T, open *stores) []inventory.PendingAssignment {
|
||||
t.Helper()
|
||||
got, err := open.inventory.Pending(t.Context(), time.Now().Add(-time.Hour))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return got
|
||||
}
|
||||
|
||||
// A build in flight: the merge asked for it, the assignment is kept pending and said so with the build, and
|
||||
// the build's outcome makes it. Status says it while it waits.
|
||||
func TestAnAssignmentWaitsForTheBuildInFlight(t *testing.T) {
|
||||
open := aCatalogueMesh(t)
|
||||
ctx := t.Context()
|
||||
asksWithPaths(t)
|
||||
mergeAdding(t, open, "modules/sensors", "3da80a4b00aa")
|
||||
|
||||
said, err := assign(ctx, open, "laptop", "sensors")
|
||||
if err != nil {
|
||||
t.Fatalf("an assignment while its build runs was refused: %v", err)
|
||||
}
|
||||
t.Logf("assign laptop sensors, while its build runs:\n%s", said)
|
||||
for _, want := range []string{"being built from novox/mesh-catalog@3da80a4b (modules/sensors)",
|
||||
"build b-modules/sensors", "kept as pending", "`unassign laptop sensors`"} {
|
||||
if !strings.Contains(said, want) {
|
||||
t.Fatalf("the answer does not say %q:\n%s", want, said)
|
||||
}
|
||||
}
|
||||
if slices.Contains(assignedTo(t, open, "laptop"), "sensors") {
|
||||
t.Fatal("a module not registered was assigned")
|
||||
}
|
||||
pending := pendingOf(t, open)
|
||||
if len(pending) != 1 || pending[0].State != inventory.PendingWaiting || pending[0].Build != "b-modules/sensors" {
|
||||
t.Fatalf("pending: %+v", pending)
|
||||
}
|
||||
// Asked twice, it is one pending assignment.
|
||||
if again, err := assign(ctx, open, "laptop", "sensors"); err != nil || !strings.Contains(again, "already waits") {
|
||||
t.Fatalf("a second assignment: %q %v", again, err)
|
||||
}
|
||||
|
||||
asked, err := theThreeQuestions(ctx, open)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if asked.well() {
|
||||
t.Fatal("status called the mesh well while an assignment waits")
|
||||
}
|
||||
body, err := statusAsJSON(asked)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var doc meshStatus
|
||||
if err := json.Unmarshal(body, &doc); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(doc.Pending) != 1 || doc.Pending[0].State != "waiting" || doc.Pending[0].Module != "sensors" {
|
||||
t.Fatalf("status says pending %+v", doc.Pending)
|
||||
}
|
||||
|
||||
if err := (builds{open.inventory, open}).Built(ctx, outcome("b-modules/sensors", "modules/sensors",
|
||||
"3da80a4b00aa", "sensors", "")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !slices.Contains(assignedTo(t, open, "laptop"), "sensors") {
|
||||
t.Fatal("the build registered sensors and the pending assignment was not made")
|
||||
}
|
||||
pending = pendingOf(t, open)
|
||||
if len(pending) != 1 || pending[0].State != inventory.PendingApplied ||
|
||||
!strings.Contains(pending[0].Note, "`push laptop` sends it if not") {
|
||||
t.Fatalf("pending after the build: %+v", pending)
|
||||
}
|
||||
}
|
||||
|
||||
// The build of a pending assignment fails: it expires, saying the build's words, and nothing is assigned.
|
||||
func TestAPendingAssignmentExpiresWhenItsBuildFails(t *testing.T) {
|
||||
open := aCatalogueMesh(t)
|
||||
ctx := t.Context()
|
||||
asksWithPaths(t)
|
||||
mergeAdding(t, open, "modules/sensors", "3da80a4b00aa")
|
||||
if _, err := assign(ctx, open, "laptop", "sensors"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := (builds{open.inventory, open}).Built(ctx, outcome("b-modules/sensors", "modules/sensors",
|
||||
"3da80a4b00aa", "", "go build: undefined: sensorsRead")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pending := pendingOf(t, open)
|
||||
if len(pending) != 1 || pending[0].State != inventory.PendingExpired {
|
||||
t.Fatalf("pending after a failed build: %+v", pending)
|
||||
}
|
||||
for _, want := range []string{"expired", "build b-modules/sensors of modules/sensors failed",
|
||||
"undefined: sensorsRead", "nothing was assigned", "build \"true\""} {
|
||||
if !strings.Contains(pending[0].Note, want) {
|
||||
t.Fatalf("the expiry does not say %q: %s", want, pending[0].Note)
|
||||
}
|
||||
}
|
||||
if slices.Contains(assignedTo(t, open, "laptop"), "sensors") {
|
||||
t.Fatal("a failed build's module was assigned")
|
||||
}
|
||||
asked, err := theThreeQuestions(ctx, open)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if asked.well() {
|
||||
t.Fatal("status called the mesh well on the day a pending assignment expired")
|
||||
}
|
||||
|
||||
// Known and not built now: said, and refused without build.
|
||||
_, err = assign(ctx, open, "laptop", "sensors")
|
||||
if !errors.Is(err, inventory.ErrNoSuchModule) {
|
||||
t.Fatalf("a module whose build failed: %v", err)
|
||||
}
|
||||
for _, want := range []string{"sensors is known and not registered", "its last build, b-modules/sensors",
|
||||
"failed: go build: undefined: sensorsRead", "build \"true\"", "repository novox/mesh-catalog, path modules/sensors"} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Fatalf("the refusal does not say %q:\n%v", want, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Known and not built, asked with build: the build is asked from where the last one was, and the assignment
|
||||
// kept pending on the new build.
|
||||
func TestAssignWithBuildAsksForAKnownModule(t *testing.T) {
|
||||
open := aCatalogueMesh(t)
|
||||
ctx := t.Context()
|
||||
inv := open.inventory
|
||||
if err := inv.RecordBuildRequest(ctx, inventory.BuildRequest{ID: "build-old", Repository: "novox/mesh-catalog",
|
||||
Seat: "git", Path: "modules/sensors", Ref: "main", For: "build", At: time.Now().Add(-time.Hour)}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := inv.RecordBuild(ctx, inventory.Build{ID: "build-old", Repository: "novox/mesh-catalog", Ref: "main",
|
||||
Path: "modules/sensors", On: "anchor", Failed: "no space left on device"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
asked := asksWithPaths(t)
|
||||
said, err := assignWith(ctx, open, "laptop", assignOptions{Build: true}, "sensors")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(*asked) != 1 || (*asked)[0] != [3]string{"novox/mesh-catalog", "modules/sensors", "main"} {
|
||||
t.Fatalf("asked %v", *asked)
|
||||
}
|
||||
for _, want := range []string{"known and not registered", "no space left on device",
|
||||
"build b-modules/sensors of novox/mesh-catalog@main (modules/sensors) is asked for now", "kept as pending"} {
|
||||
if !strings.Contains(said, want) {
|
||||
t.Fatalf("the answer does not say %q:\n%s", want, said)
|
||||
}
|
||||
}
|
||||
pending := pendingOf(t, open)
|
||||
if len(pending) != 1 || pending[0].Build != "b-modules/sensors" {
|
||||
t.Fatalf("pending: %+v", pending)
|
||||
}
|
||||
}
|
||||
|
||||
// Unknown: refused as before, as no module of that name, with the closest names the mesh holds; build asks
|
||||
// for nothing, since the mesh does not know where it is.
|
||||
func TestAnUnknownModuleIsRefusedWithTheClosestNames(t *testing.T) {
|
||||
open := aCatalogueMesh(t)
|
||||
ctx := t.Context()
|
||||
register(t, open, catalogue.Manifest{Module: "sensord", Version: "1"})
|
||||
asked := asksWithPaths(t)
|
||||
_, err := assignWith(ctx, open, "laptop", assignOptions{Build: true}, "sensors")
|
||||
if !errors.Is(err, inventory.ErrNoSuchModule) {
|
||||
t.Fatalf("an unknown module: %v", err)
|
||||
}
|
||||
for _, want := range []string{"no module of that name: sensors", "no module, build request or merge by that name",
|
||||
"the closest it holds: sensord", "asks for nothing here"} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Fatalf("the refusal does not say %q:\n%v", want, err)
|
||||
}
|
||||
}
|
||||
if len(*asked) != 0 || len(pendingOf(t, open)) != 0 {
|
||||
t.Fatalf("an unknown module asked %v, pending %+v", *asked, pendingOf(t, open))
|
||||
}
|
||||
}
|
||||
|
||||
// Several modules in one act, one of them not registered: nothing is assigned and nothing kept pending, and
|
||||
// each missing one is said.
|
||||
func TestAnActWithAModuleNotRegisteredIsRefusedWhole(t *testing.T) {
|
||||
open := aCatalogueMesh(t)
|
||||
ctx := t.Context()
|
||||
asksWithPaths(t)
|
||||
register(t, open, catalogue.Manifest{Module: "known", Version: "1"})
|
||||
mergeAdding(t, open, "modules/sensors", "3da80a4b00aa")
|
||||
_, err := assign(ctx, open, "laptop", "known", "sensors")
|
||||
if err == nil || !strings.Contains(err.Error(), "nothing was assigned") || !strings.Contains(err.Error(), "being built") {
|
||||
t.Fatalf("an act naming a module in flight: %v", err)
|
||||
}
|
||||
if slices.Contains(assignedTo(t, open, "laptop"), "known") || len(pendingOf(t, open)) != 0 {
|
||||
t.Fatal("an act refused whole was made in part")
|
||||
}
|
||||
}
|
||||
|
||||
// unassign withdraws a pending assignment; the build goes on and registers the module assigned nowhere.
|
||||
func TestUnassignWithdrawsAPendingAssignment(t *testing.T) {
|
||||
open := aCatalogueMesh(t)
|
||||
ctx := t.Context()
|
||||
asksWithPaths(t)
|
||||
mergeAdding(t, open, "modules/sensors", "3da80a4b00aa")
|
||||
if _, err := assign(ctx, open, "laptop", "sensors"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
said, err := unassign(ctx, open, "laptop", "sensors")
|
||||
if err != nil || !strings.Contains(said, "withdrawn") {
|
||||
t.Fatalf("unassign of a pending assignment: %q %v", said, err)
|
||||
}
|
||||
if err := (builds{open.inventory, open}).Built(ctx, outcome("b-modules/sensors", "modules/sensors",
|
||||
"3da80a4b00aa", "sensors", "")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if slices.Contains(assignedTo(t, open, "laptop"), "sensors") {
|
||||
t.Fatal("a withdrawn assignment was made")
|
||||
}
|
||||
}
|
||||
|
||||
// A build that says nothing within the bound: the pending assignment expires at the next look, saying so,
|
||||
// rather than waiting for ever; and a build heard by another process meanwhile is made at the next look.
|
||||
func TestAPendingAssignmentNeverWaitsSilently(t *testing.T) {
|
||||
open := aCatalogueMesh(t)
|
||||
ctx := t.Context()
|
||||
inv := open.inventory
|
||||
for _, r := range []inventory.BuildRequest{
|
||||
{ID: "build-lost", Repository: "novox/mesh-catalog", Seat: "git", Path: "modules/lost", Ref: "main",
|
||||
For: "merge", At: time.Now().Add(-buildRequestBound / 2)},
|
||||
{ID: "build-heard", Repository: "novox/mesh-catalog", Seat: "git", Path: "modules/heard", Ref: "main",
|
||||
For: "merge", At: time.Now()},
|
||||
} {
|
||||
if err := inv.RecordBuildRequest(ctx, r); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for _, m := range []string{"lost", "heard"} {
|
||||
if _, err := assign(ctx, open, "laptop", m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
// Heard by a process that kept no pending assignment: registered, and nothing made it.
|
||||
register(t, open, catalogue.Manifest{Module: "heard", Version: "1"})
|
||||
|
||||
said := settleStalePending(ctx, open, time.Now().Add(buildRequestBound))
|
||||
if len(said) != 2 {
|
||||
t.Fatalf("settled %v", said)
|
||||
}
|
||||
if !slices.Contains(assignedTo(t, open, "laptop"), "heard") {
|
||||
t.Fatal("a module registered meanwhile was not assigned at the next look")
|
||||
}
|
||||
byModule := map[string]inventory.PendingAssignment{}
|
||||
for _, p := range pendingOf(t, open) {
|
||||
byModule[p.Module] = p
|
||||
}
|
||||
if p := byModule["lost"]; p.State != inventory.PendingExpired || !strings.Contains(p.Note, "no outcome of build build-lost") {
|
||||
t.Fatalf("a build that said nothing: %+v", p)
|
||||
}
|
||||
if p := byModule["heard"]; p.State != inventory.PendingApplied {
|
||||
t.Fatalf("a module registered meanwhile: %+v", p)
|
||||
}
|
||||
}
|
||||
|
||||
// The seat's assign passes build on; unassign takes none.
|
||||
func TestTheSeatsAssignPassesBuild(t *testing.T) {
|
||||
argv, err := argvFor("assign", map[string]any{"node": "g14", "module": "sensors", "build": "true"})
|
||||
if err != nil || !slices.Equal(argv, []string{"assign", "g14", "sensors", "--build"}) {
|
||||
t.Fatalf("assign with build: %v %v", argv, err)
|
||||
}
|
||||
if _, err := argvFor("unassign", map[string]any{"node": "g14", "module": "sensors", "build": "true"}); err == nil {
|
||||
t.Fatal("unassign took build")
|
||||
}
|
||||
}
|
||||
@@ -101,6 +101,24 @@ type meshStatus struct {
|
||||
// Overflowing is every module whose identity overflows the bound of a provision it requires, and
|
||||
// so is left out of its provider's grants (novox/hq ADR 0225). Absent when every identity fits.
|
||||
Overflowing []catalogue.Overflow `json:"overflowing,omitempty"`
|
||||
// Pending is every assignment waiting for its module's build to register it, and every one ended in
|
||||
// the last day with what ended it (novox/hq issue 325). Absent when there is none; PendingUnread says
|
||||
// why they could not be read.
|
||||
Pending []pendingStatus `json:"pending,omitempty"`
|
||||
PendingUnread string `json:"pendingUnread,omitempty"`
|
||||
}
|
||||
|
||||
// pendingStatus is one pending assignment as status says it.
|
||||
type pendingStatus struct {
|
||||
Node string `json:"node"`
|
||||
Module string `json:"module"`
|
||||
State string `json:"state"`
|
||||
Build string `json:"build"`
|
||||
Repository string `json:"repository,omitempty"`
|
||||
Path string `json:"path,omitempty"`
|
||||
Since time.Time `json:"since"`
|
||||
Settled *time.Time `json:"settled,omitempty"`
|
||||
Note string `json:"note,omitempty"`
|
||||
}
|
||||
|
||||
// machineFiltered is one rule set on a converged machine that the mesh did not write and that
|
||||
@@ -240,6 +258,11 @@ func statusAsJSON(asked answers) ([]byte, error) {
|
||||
out.Conditions, out.ConditionsUnread = inBrief(asked.conditions), asked.conditionsUnread
|
||||
out.Failing = providerStandings(asked.conditions)
|
||||
out.Overflowing = asked.overflowing
|
||||
out.PendingUnread = asked.pendingUnread
|
||||
for _, p := range asked.pending {
|
||||
out.Pending = append(out.Pending, pendingStatus{Node: p.Node, Module: p.Module, State: p.State,
|
||||
Build: p.Build, Repository: p.Repository, Path: p.Path, Since: p.Since, Settled: p.Settled, Note: p.Note})
|
||||
}
|
||||
for name := range asked.refused {
|
||||
out.Unresolved = append(out.Unresolved, machineUnresolved{
|
||||
Node: name, Problem: asked.refused[name]})
|
||||
|
||||
@@ -337,8 +337,15 @@ func askTier(ctx context.Context, inv *inventory.Inventory, p *inventory.Plan) e
|
||||
|
||||
// askABuild is how a plan asks for one build, not waited for, and learns the id it asked under. A
|
||||
// variable so a test of what a plan does around an ask needs no build machine.
|
||||
var askABuild = func(ctx context.Context, source buildSource, path, ref string) (string, error) {
|
||||
return buildOneAsked(ctx, source, path, ref, 0, false)
|
||||
//
|
||||
// Set in init, not where it is declared: an assignment pending on a build asks for one too (novox/hq issue
|
||||
// 325), and a build's outcome makes pending assignments, so the two refer to each other.
|
||||
var askABuild func(ctx context.Context, source buildSource, path, ref string) (string, error)
|
||||
|
||||
func init() {
|
||||
askABuild = func(ctx context.Context, source buildSource, path, ref string) (string, error) {
|
||||
return buildOneAsked(ctx, source, path, ref, 0, false)
|
||||
}
|
||||
}
|
||||
|
||||
// askModule asks the build machine for one module of a plan and marks it asked, with the id it was
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
)
|
||||
|
||||
// novox/hq issue 325, replayed with only what the controller had before its fix, so it can be laid over the
|
||||
// older commit. On 2026-10-08 the catalogue's pull request adding `sensors` merged, and the merge asked for
|
||||
// its build (issue 300). About a minute later `assign g14 sensors` answered "no module of that name:
|
||||
// sensors"; a few minutes later the same call worked, because the build had registered the module. The
|
||||
// answer read as "nobody registered it". An assignment made while the build runs says the build — where it
|
||||
// is from, since when, its id — is kept, and is made when the build registers the module.
|
||||
func TestReplay325(t *testing.T) {
|
||||
open := aMesh(t)
|
||||
ctx := t.Context()
|
||||
if err := open.inventory.RegisterModule(ctx, catalogue.Manifest{Module: "networkmanager", Version: "1"},
|
||||
inventory.Source{Repository: "novox/mesh-catalog", Seat: "git", Path: "modules/networkmanager", Ref: "main",
|
||||
BuiltFrom: "c0", Head: "c0"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
asksWithPaths(t)
|
||||
m := link.SourceMoved{Owner: "novox", Repo: "mesh-catalog", Base: "main", Commit: "5e450125aa",
|
||||
Paths: []string{"modules/sensors/module.json", "modules/sensors/cmd/sensors/main.go"},
|
||||
ModuleDirs: []string{"modules/sensors"}, ModuleDirsSaid: true}
|
||||
if err := (following{open: open}).SourceMoved(ctx, m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
said, err := assign(ctx, open, "laptop", "sensors")
|
||||
if err != nil {
|
||||
t.Fatalf("assign while the merge's build runs was refused: %v", err)
|
||||
}
|
||||
for _, want := range []string{"being built", "novox/mesh-catalog", "modules/sensors", "b-modules/sensors"} {
|
||||
if !strings.Contains(said, want) {
|
||||
t.Fatalf("the answer does not say %q:\n%s", want, said)
|
||||
}
|
||||
}
|
||||
|
||||
manifest, _ := json.Marshal(catalogue.Manifest{Module: "sensors", Version: "1"})
|
||||
if err := (builds{open.inventory, open}).Built(ctx, link.BuildResult{ID: "b-modules/sensors",
|
||||
Repository: "novox/mesh-catalog", Path: "modules/sensors", Ref: "main", Commit: "5e450125aa", On: "anchor",
|
||||
Module: "sensors", Manifest: manifest,
|
||||
Source: &link.SourceOnSeat{Repository: "novox/mesh-catalog", Seat: "git"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assigned, err := open.inventory.Assigned(ctx, "laptop")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !slices.Contains(assigned, "sensors") {
|
||||
t.Fatalf("the build registered sensors and laptop runs %v", assigned)
|
||||
}
|
||||
}
|
||||
@@ -418,7 +418,13 @@ func (a *verbArguments) commandLine() ([]string, error) {
|
||||
}
|
||||
// Several modules comma-separated, judged as one act (novox/hq ADR 0207): the holders of
|
||||
// the seats that apply resources depend on each other and go on together.
|
||||
return append([]string{verb, str("node")}, splitModules(str("module"))...), nil
|
||||
argv := append([]string{verb, str("node")}, splitModules(str("module"))...)
|
||||
// A module known and not built: its build asked for, the assignment pending on it (novox/hq
|
||||
// issue 325). unassign declares no build, so a call giving it is refused before this.
|
||||
if verb == "assign" && on("build") {
|
||||
argv = append(argv, "--build")
|
||||
}
|
||||
return argv, nil
|
||||
case "pin":
|
||||
if err := need("node", "provision", "from", "module"); err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -143,6 +144,8 @@ func printStatus(asked answers) error {
|
||||
len(quiet), strings.Join(said, "\n "))
|
||||
}
|
||||
|
||||
printPending(asked.pending, asked.pendingUnread)
|
||||
|
||||
if open, late := openPlans(asked.plans, time.Now(), asked.paused, asked.tierBounds); len(open) > 0 {
|
||||
fmt.Printf("%d plan(s) open", len(open))
|
||||
if late > 0 {
|
||||
@@ -466,6 +469,15 @@ func theThreeQuestions(ctx context.Context, open *stores) (answers, error) {
|
||||
if out.conditions, err = openConditions(ctx); err != nil {
|
||||
out.conditionsUnread = err.Error()
|
||||
}
|
||||
// And the assignments waiting for their module's build (novox/hq issue 325): what a missed outcome left
|
||||
// waiting is settled first, so one never waits silently past its build.
|
||||
for _, line := range settleStalePending(ctx, open, time.Now()) {
|
||||
fmt.Fprintln(os.Stderr, line)
|
||||
}
|
||||
if out.pending, err = inv.Pending(ctx, time.Now().Add(-pendingShownFor)); err != nil {
|
||||
out.pendingUnread = err.Error()
|
||||
err = nil
|
||||
}
|
||||
out.plans, err = inv.RecentPlans(ctx, 5)
|
||||
if err != nil {
|
||||
return answers{}, err
|
||||
@@ -598,7 +610,53 @@ func (a answers) well() bool {
|
||||
return len(a.wrong) == 0 && len(a.quiet) == 0 && len(a.behind) == 0 &&
|
||||
len(a.waiting) == 0 && len(a.refused) == 0 && a.network == "" && len(a.untaken) == 0 &&
|
||||
len(a.filtered) == 0 && len(a.unheld) == 0 && len(a.overflowing) == 0 &&
|
||||
len(a.conditions) == 0 && a.conditionsUnread == ""
|
||||
len(a.conditions) == 0 && a.conditionsUnread == "" && a.pendingUnread == "" && !pendingNotWell(a.pending)
|
||||
}
|
||||
|
||||
// pendingNotWell is whether any pending assignment is waiting, or ended without being made.
|
||||
func pendingNotWell(pending []inventory.PendingAssignment) bool {
|
||||
for _, p := range pending {
|
||||
if p.State != inventory.PendingApplied && p.State != inventory.PendingWithdrawn {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// printPending says the assignments waiting for their module's build, and those ended in the last day
|
||||
// (novox/hq issue 325).
|
||||
func printPending(pending []inventory.PendingAssignment, unread string) {
|
||||
if unread != "" {
|
||||
fmt.Printf("the assignments waiting for a build could not be read: %s\n\n", unread)
|
||||
return
|
||||
}
|
||||
var waiting, ended []inventory.PendingAssignment
|
||||
for _, p := range pending {
|
||||
if p.State == inventory.PendingWaiting {
|
||||
waiting = append(waiting, p)
|
||||
} else {
|
||||
ended = append(ended, p)
|
||||
}
|
||||
}
|
||||
if len(waiting) > 0 {
|
||||
fmt.Printf("%d assignment(s) wait for their module's build to register it:\n", len(waiting))
|
||||
for _, p := range waiting {
|
||||
fmt.Printf(" %-12s %-20s build %s of %s %s, since %s\n", p.Node, p.Module, p.Build, p.Repository,
|
||||
orRoot(p.Path), clock(p.Since))
|
||||
}
|
||||
fmt.Printf("\n each is made when its build registers the module; `unassign <node> <module>` withdraws it\n\n")
|
||||
}
|
||||
if len(ended) > 0 {
|
||||
fmt.Printf("%d pending assignment(s) ended in the last day:\n", len(ended))
|
||||
for _, p := range ended {
|
||||
at := ""
|
||||
if p.Settled != nil {
|
||||
at = clock(*p.Settled)
|
||||
}
|
||||
fmt.Printf(" %-12s %-20s %-9s %s\n %-12s %s\n", p.Node, p.Module, p.State, at, "", p.Note)
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
}
|
||||
|
||||
// hostSplit is which machines report which host version, for every version more than one machine
|
||||
|
||||
@@ -360,7 +360,7 @@ func (f following) SourceMoved(ctx context.Context, m link.SourceMoved) error {
|
||||
}
|
||||
// **A new module is built and registered, and sent nowhere** (novox/hq issue 300): the delivery plan
|
||||
// says so, and assigning it is a person's act, which needs it registered first.
|
||||
built := askNewModules(ctx, m, from, added)
|
||||
built := askNewModules(ctx, inv, m, from, added)
|
||||
moved := append(append([]inventory.Entry{}, touched...), packaging...)
|
||||
if len(moved) == 0 {
|
||||
if len(built) > 0 {
|
||||
@@ -491,7 +491,8 @@ func (f following) SourceMoved(ctx context.Context, m link.SourceMoved) error {
|
||||
// new module either. Outside the plan: the plan walks modules the catalogue holds, and a new one has no
|
||||
// machine to send to and nothing standing on it. What could not be asked is said, and left to `build`.
|
||||
// Returns the directories asked for.
|
||||
func askNewModules(ctx context.Context, m link.SourceMoved, from []inventory.Entry, added []string) []string {
|
||||
func askNewModules(ctx context.Context, inv *inventory.Inventory, m link.SourceMoved, from []inventory.Entry,
|
||||
added []string) []string {
|
||||
if len(added) == 0 || len(from) == 0 {
|
||||
return nil
|
||||
}
|
||||
@@ -503,11 +504,19 @@ func askNewModules(ctx context.Context, m link.SourceMoved, from []inventory.Ent
|
||||
path = ""
|
||||
}
|
||||
id, err := askABuild(ctx, source, path, m.Base)
|
||||
// Kept, asked or not, so `assign` can tell a build in flight, or a merge that could not ask for one,
|
||||
// from a module nobody ever heard of (novox/hq issue 325).
|
||||
request := inventory.BuildRequest{ID: id, Repository: source.Repository, Seat: source.Seat, Path: path,
|
||||
Ref: m.Base, Commit: m.Commit, For: "merge"}
|
||||
if err != nil {
|
||||
request.ID = fmt.Sprintf("not-asked-%s-%s", short(m.Commit), strings.ReplaceAll(dir, "/", "-"))
|
||||
request.NotAsked = err.Error()
|
||||
recordBuildRequest(ctx, inv, request)
|
||||
fmt.Printf(" %s is a new module in %s/%s and could not be built: %v — a hand `build` of it "+
|
||||
"asks again\n", dir, m.Owner, m.Repo, err)
|
||||
continue
|
||||
}
|
||||
recordBuildRequest(ctx, inv, request)
|
||||
fmt.Printf(" %s is a new module in %s/%s: build %s asked at %s; registered when it lands, assigned "+
|
||||
"nowhere\n", dir, m.Owner, m.Repo, id, m.Base)
|
||||
asked = append(asked, dir)
|
||||
|
||||
Reference in New Issue
Block a user