package check import ( "context" "encoding/json" "fmt" "strings" "sync" "time" "git.novox.be/novox/mesh-sdk/go/buildstep" "git.novox.be/novox/mesh-sdk/go/catalogue" "git.novox.be/novox/mesh-sdk/go/delivery" "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun" "mesh-delivery-checker/rules" ) // The checking program (novox/hq ADR 0644 §1, ADR 0656 §5; design 47's bootstrap delivery; issue 1921): every check has // the program built from the group's mesh-delivery member head, else from mesh-delivery's trunk, and run in the build // seat's sandbox through the seat node-build-go's ask `run`, with every member's head laid for it. It answers the heads' // seat list first (`-seats`), which the manifest validator's and the composer's requests carry, and then the check's // verdict of the facts the asking gathered (`-verdict`). The running checker asks, keeps and records; it judges nothing. const ( // InputsStateName is the checker's own state of run inputs and InputsBucket its bucket: every input by the sha256 of // its exact bytes, read by the checker alone. InputVerb is the seat verb a run ask's input names // (delivery.RunInput.Verb), which the checker's tools serve from that state (Reader.Input): the Go build module asks it // as its manifest's invokes grants it and checks the digest (delivery.RunInput.Read), never reading the state (ADR 0596 // §2; the operator's answer on milestone 47/B0). InputsStateName = "inputs" InputsBucket = delivery.CheckerModule + "_" + InputsStateName InputVerb = "seat:" + catalogue.CheckerSeat + "." + delivery.CheckerInputVerb // CheckingProgramDir and CheckingProgramPackage are where in mesh-delivery the checking program is built. CheckingProgramDir = "modules/mesh-delivery-checker" CheckingProgramPackage = "mesh-delivery-checker/cmd/mesh-checking-program" // DefaultBeginBound is how long a run ask may wait for a build node to begin its step before it is put once more, and // after that once more before the check fails naming the job: a build module that does not know the job `run` drops // it unanswered (the lead's ruling on milestone 47/B0, 2026-11-06). DefaultBeginBound = 5 * time.Minute ) // Inputs keeps a run's input under its digest, exactly its bytes. type Inputs interface { Keep(ctx context.Context, digest string, body []byte) error } func (j *Examiner) beginBound() time.Duration { if j.BeginBound > 0 { return j.BeginBound } return DefaultBeginBound } // checkingProgram is where this check's checking program is built: the group's mesh-delivery member head, else // mesh-delivery's trunk; false when neither is known. func (st *state) checkingProgram() (delivery.RunProgram, string, bool) { p := delivery.RunProgram{Repository: DeliveryRepository, Dir: CheckingProgramDir, Package: CheckingProgramPackage} for _, pull := range st.c.Pulls { if pull.Repository == DeliveryRepository { p.Commit = pull.Head return p, "the group's mesh-delivery head " + pull.Head, true } } if st.j.Trunks != nil { p.Commit = st.j.Trunks.Heads()[DeliveryRepository] } return p, "mesh-delivery's trunk " + p.Commit, p.Commit != "" } // runHeads are the members' heads, as the program reads them. func (st *state) runHeads() []delivery.RunHead { out := make([]delivery.RunHead, 0, len(st.c.Pulls)) for _, p := range st.c.Pulls { out = append(out, delivery.RunHead{Repository: p.Repository, Commit: p.Head}) } return out } // runProgram has the checking program built and run with args over body, and answers its output and what judged; an // error says why it did not answer: its input over its bound, no build node beginning the job, a step that did not // pass, or a program that exited otherwise than 0. func (st *state) runProgram(ctx context.Context, args []string, body []byte) (json.RawMessage, *delivery.JudgedBy, error) { prog, at, ok := st.checkingProgram() if !ok { return nil, nil, fmt.Errorf("it cannot be built: the trunk head of %s is not known", DeliveryRepository) } if st.j.Inputs == nil { return nil, nil, fmt.Errorf("its input cannot be kept: the checker keeps no state %s", InputsStateName) } in, err := delivery.RunInputOf(InputVerb, body) if err != nil { return nil, nil, err } if err := st.j.Inputs.Keep(ctx, in.Digest, body); err != nil { return nil, nil, fmt.Errorf("its input could not be kept in %s: %w", InputsStateName, err) } job := delivery.RunJob{Program: prog, Heads: st.runHeads(), Input: in, Args: args, Reader: "the checking program at " + at} var ids []string for range 2 { a := delivery.StepAsk{ID: buildstep.NewID(), Kind: delivery.AskCheck, Language: delivery.AskGo, Asker: delivery.CheckerModule, At: st.j.now().UTC(), Packages: st.packagesOf(DeliveryRepository), Run: &job} ids = append(ids, a.ID) st.c.Progress.Set(fmt.Sprintf("the run ask %s of the checking program %s", a.ID, strings.Join(args, " ")), "") st.c.Asked(a.ID) if err := st.j.Asks.Put(ctx, a); err != nil { return nil, nil, fmt.Errorf("its run ask could not be put: %w", err) } waiting := ctx if watch := st.c.Watch; watch != nil { id := a.ID watch(id, nil) // a try names it at once (issue 1890) waiting = buildrun.WithStepSeen(ctx, func(s delivery.Step) { watch(id, &s) }) } step, begun, err := st.waitBegun(waiting, a) if !begun { continue } if err != nil { return nil, nil, fmt.Errorf("its run ask %s: %w", a.ID, err) } if step.State != delivery.StepPassed { return nil, nil, fmt.Errorf("its run ask %s ended %s on %s: %s", a.ID, step.State, step.Node, step.Reason) } var answer delivery.RunAnswer if err := json.Unmarshal(step.Result, &answer); err != nil { return nil, nil, fmt.Errorf("its run ask %s answered no run answer: %w", a.ID, err) } if answer.Exit != 0 { return nil, nil, fmt.Errorf("it exited %d on %s: %s", answer.Exit, step.Node, answer.Said) } return answer.Output, &delivery.JudgedBy{Program: prog, Key: answer.Key, Built: answer.Built, Heads: job.Heads}, nil } return nil, nil, fmt.Errorf("no build node began the job run of the asks %s within %s each: a build module that does not know the job run drops it", strings.Join(ids, " and "), st.j.beginBound()) } // waitBegun waits for a's step, and stops waiting, begun false, when no build node began it within the begin bound: a // bound on the wait's context, no timer of its own. func (st *state) waitBegun(ctx context.Context, a delivery.StepAsk) (delivery.Step, bool, error) { wait, cancel := context.WithCancel(ctx) defer cancel() begin, stop := context.WithTimeout(ctx, st.j.beginBound()) defer stop() seen := make(chan struct{}) var once sync.Once type ended struct { step delivery.Step err error } done := make(chan ended, 1) go func() { step, err := st.j.Asks.Wait(buildrun.WithStepSeen(wait, func(delivery.Step) { once.Do(func() { close(seen) }) }), a) done <- ended{step, err} }() select { case e := <-done: return e.step, true, e.err case <-seen: case <-begin.Done(): if ctx.Err() != nil { return delivery.Step{}, true, ctx.Err() } return delivery.Step{}, false, nil } e := <-done return e.step, true, e.err } // seatList asks the checking program for the heads' seat list, which the validator's and the composer's requests carry // (ADR 0636 §4); the list and what it left out are the check's notes. A list it cannot make fails the check, naming why, // and answers false: the check ends there, with no verdict asked of a program that could not answer its list (review // round 1 of milestone 47/B0: a build node with no job run would hold the check two begin bounds more). func (st *state) seatList(ctx context.Context) bool { if st.narrowTry() { return true } body, err := json.Marshal(st.factsHead()) if err == nil { var out json.RawMessage if out, _, err = st.runProgram(ctx, []string{"-seats"}, body); err == nil { var a rules.SeatsAnswer if err = json.Unmarshal(out, &a); err == nil && a.V != rules.SeatsVersion { err = fmt.Errorf("it answered a seat list of version %d, not %d", a.V, rules.SeatsVersion) } if err == nil { st.c.Seats = a.Seats st.c.note(fmt.Sprintf("the heads' seat list holds %d seat definitions", len(a.Seats))) for _, l := range a.Left { st.c.note("the heads' seat list leaves out " + l.Path + ": " + l.Why) } return true } } } st.fail(goModule, "the heads' seat list could not be made by the checking program: "+err.Error()) return false } // factsHead are the facts that name the check, without what the asking gathered. func (st *state) factsHead() rules.Facts { f := rules.Facts{V: rules.FactsVersion, Group: st.c.Group, Heads: st.c.Heads, Trunk: st.c.Trunk} for _, p := range st.c.Pulls { f.Members = append(f.Members, rules.Member{Repository: p.Repository, Number: p.Number, Head: p.Head, Trunk: p.Trunk}) } return f } // judged is the checking program's answer to the check's facts (`-verdict`), with what judged; a program that cannot be // asked, built or run, or does not answer a verdict, fails the check naming the checking program, never the running // checker's rules in its place: the facts gain the failure and the verdict is worded from them as a failure. func (st *state) judged(ctx context.Context) (rules.Answer, *delivery.JudgedBy) { body, err := json.Marshal(st.factsOf()) var by *delivery.JudgedBy if err == nil { var out json.RawMessage if out, by, err = st.runProgram(ctx, []string{"-verdict"}, body); err == nil { var a rules.Answer if err = json.Unmarshal(out, &a); err == nil && a.V != rules.AnswerVersion { err = fmt.Errorf("it answered a verdict of version %d, not %d", a.V, rules.AnswerVersion) } if err == nil { return a, by } } } st.fail(goModule, "the check could not be judged by the checking program: "+err.Error()) return rules.Answer{V: rules.AnswerVersion, Verdict: rules.Judge(st.factsOf())}, by }