A walk left the machine holding the bus out of a gated send whenever a build waited there for that same gate, so a module's new health tool was refused by the bus on its first machine and the gate could not pass. The grants step sends that machine its declaration with every build kept, before the first machine's send; plans and the change plan say it.
599 lines
27 KiB
Go
599 lines
27 KiB
Go
package inventory
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
)
|
|
|
|
// A Plan is what a merge produces (novox/hq ADR 0162): the modules it changed and everything
|
|
// standing on them, sorted into tiers, each module's state, and the tier the plan is at. Kept in
|
|
// the store so a controller replaced mid-plan resumes it, and so `status` can say what a merge
|
|
// still waits for.
|
|
type Plan struct {
|
|
ID string `json:"id"`
|
|
Repository string `json:"repository"`
|
|
// Branch is the branch the merge went into (novox/hq issue 254): a newer plan supersedes the open
|
|
// ones of the same repository and branch. Empty for a plan from before it was kept.
|
|
Branch string `json:"branch,omitempty"`
|
|
Commit string `json:"commit"`
|
|
// Merged is when the forge made the merge this plan answers (novox/hq issue 349): the order of a
|
|
// branch's merges, which is not the order their plans were made in when one was acted on late. Zero
|
|
// for a release, and for a plan kept before it was.
|
|
Merged time.Time `json:"merged,omitzero"`
|
|
Created time.Time `json:"created"`
|
|
Updated time.Time `json:"updated"`
|
|
State string `json:"state"`
|
|
Tier int `json:"tier"`
|
|
Tiers [][]string `json:"tiers"`
|
|
Modules map[string]*PlanModule `json:"modules"`
|
|
Note string `json:"note,omitempty"`
|
|
// TierEntered is when the plan entered the tier it is at (novox/hq to-be 45 Phase 0), read and
|
|
// never written from here: a save measures the tier it leaves and stamps the next. What the
|
|
// watchdog of a plan's progress (S3) reads.
|
|
TierEntered time.Time `json:"tier_entered,omitempty"`
|
|
// Revision is the plan's as it was read, and the one a save must find (novox/hq to-be 45 §6): a
|
|
// plan is written by compare-and-set, so a write against a plan another writer moved since is
|
|
// refused rather than laid over it. Zero is a plan never saved. SavePlan moves it.
|
|
Revision int64 `json:"revision"`
|
|
// Epoch is the controller lease epoch that wrote it last; zero for a write that claimed none.
|
|
Epoch uint64 `json:"epoch,omitempty"`
|
|
// Release is set on a release plan (novox/hq ADR 0236): not a merge's, but the builds waiting for a
|
|
// gate, walked through the machines one at a time.
|
|
Release *PlanRelease `json:"release,omitempty"`
|
|
// Delivery is set on a walk that waits for its delivery's word (novox/hq ADR 0239), and on every walk and
|
|
// batch that names the merges it answers (ADR 0276): Awaits empty for a walk on the controller's own path,
|
|
// which starts when it is cut.
|
|
Delivery *PlanDelivery `json:"delivery,omitempty"`
|
|
// Commits is every repository's commit the walk carries, one per repository (novox/hq ADR 0276): a batch's
|
|
// walk can carry several. Repository and Commit above keep one of them, for a reader that knows one. Empty
|
|
// for a walk kept before it was: Repository and Commit are then the whole of it.
|
|
Commits []PlanCommit `json:"commits,omitempty"`
|
|
// Times are the moments of a walk no other field keeps (novox/hq ADR 0282 decision 6): when its batch's
|
|
// window closed, when it was cut, and its class. Nil for a walk kept before they were, whose phases before
|
|
// its build are said unknown.
|
|
Times *PlanTimes `json:"times,omitempty"`
|
|
// Phases is the walk's time from its merge, phase by phase (ADR 0282 decision 6): never kept, worked out
|
|
// from the walk's own moments by whoever says the walk (`delivery walks`, `plan-moved`).
|
|
Phases *WalkPhases `json:"phases,omitempty"`
|
|
}
|
|
|
|
// PlanTimes are a walk's own moments beside its modules' (novox/hq ADR 0282 decision 6).
|
|
type PlanTimes struct {
|
|
// WindowClosed is when its batch's merge window closed: no merge for the window's length, or its maximum.
|
|
// Nil for a walk no window assembled (one merge walked alone).
|
|
WindowClosed *time.Time `json:"window_closed,omitempty"`
|
|
// Cut is when the batch became the walk.
|
|
Cut *time.Time `json:"cut,omitempty"`
|
|
// Class is the walk's module class, read at its cut: core when it moves a module on the controller's own
|
|
// path or the mesh's resolver, leaf otherwise (ADR 0282 decision 1).
|
|
Class string `json:"class,omitempty"`
|
|
}
|
|
|
|
// PlanCommit is one repository's commit a walk carries: the latest merge of its branch in the batch.
|
|
type PlanCommit struct {
|
|
Repository string `json:"repository"`
|
|
Branch string `json:"branch,omitempty"`
|
|
Commit string `json:"commit"`
|
|
Merged time.Time `json:"merged,omitzero"`
|
|
}
|
|
|
|
// PlanMerge is one merge a walk or batch answers (novox/hq ADR 0276), as mesh-delivery reads it: Carried is
|
|
// the commit of its repository the walk builds and sends when that is a later merge containing it, and
|
|
// empty when it is the merge itself.
|
|
type PlanMerge struct {
|
|
Repository string `json:"repository"`
|
|
Commit string `json:"commit"`
|
|
Carried string `json:"carried,omitempty"`
|
|
// Number and Title are the merge's pull request as the forge announced it, and Moves the modules the merge
|
|
// moved when it was heard: what `plans` names a walk by. Empty where the announcement said none.
|
|
Number int `json:"number,omitempty"`
|
|
Title string `json:"title,omitempty"`
|
|
Moves []string `json:"moves,omitempty"`
|
|
// Merged is when the forge made the merge and Heard when the controller heard it (novox/hq ADR 0282): where
|
|
// its delivery time starts. Zero for a merge named before they were kept.
|
|
Merged time.Time `json:"merged,omitzero"`
|
|
Heard time.Time `json:"heard,omitzero"`
|
|
}
|
|
|
|
// PlanBatch is a batch's window while it is one (novox/hq ADR 0276): when it closes unless another merge
|
|
// comes, when it closes at the latest, and the walk it waits behind once closed.
|
|
type PlanBatch struct {
|
|
ClosesAt time.Time `json:"closes_at"`
|
|
AtMost time.Time `json:"at_most"`
|
|
Behind string `json:"behind,omitempty"`
|
|
// Own says the batch holds merges on the controller's own path, whose walk waits for nobody's word: such
|
|
// a merge never shares a batch with one that waits for mesh-delivery's (ADR 0276, decided during the
|
|
// build, 2026-10-10).
|
|
Own bool `json:"own,omitempty"`
|
|
}
|
|
|
|
// OwnPath says the record is a batch of merges on the controller's own path.
|
|
func (p Plan) OwnPath() bool {
|
|
return p.Delivery != nil && p.Delivery.Batch != nil && p.Delivery.Batch.Own
|
|
}
|
|
|
|
// CommitOf is the commit of a repository the walk carries; empty when it carries none of it.
|
|
func (p Plan) CommitOf(repository string) string {
|
|
for _, c := range p.Commits {
|
|
if strings.EqualFold(c.Repository, repository) {
|
|
return c.Commit
|
|
}
|
|
}
|
|
if strings.EqualFold(p.Repository, repository) {
|
|
return p.Commit
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// CommitOn is the commit of a repository's branch the walk carries; empty when it carries none of it. A
|
|
// commit kept without its branch matches any branch.
|
|
func (p Plan) CommitOn(repository, branch string) string {
|
|
for _, c := range p.Commits {
|
|
if strings.EqualFold(c.Repository, repository) && (branch == "" || c.Branch == "" || c.Branch == branch) {
|
|
return c.Commit
|
|
}
|
|
}
|
|
if len(p.Commits) == 0 && strings.EqualFold(p.Repository, repository) &&
|
|
(branch == "" || p.Branch == "" || p.Branch == branch) {
|
|
return p.Commit
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// Carried is every repository's commit the walk carries: Commits, or its one repository and commit.
|
|
func (p Plan) Carried() []PlanCommit {
|
|
if len(p.Commits) > 0 {
|
|
return p.Commits
|
|
}
|
|
if p.Repository == "" && p.Commit == "" {
|
|
return nil
|
|
}
|
|
return []PlanCommit{{Repository: p.Repository, Branch: p.Branch, Commit: p.Commit, Merged: p.Merged}}
|
|
}
|
|
|
|
// Named is the walk as a person reads it: each repository at its commit.
|
|
func (p Plan) Named() string {
|
|
var out []string
|
|
for _, c := range p.Carried() {
|
|
out = append(out, c.Repository+" "+short(c.Commit))
|
|
}
|
|
return strings.Join(out, ", ")
|
|
}
|
|
|
|
// Batch says the record is a batch still being assembled or waiting to be cut, not a walk (ADR 0276).
|
|
func (p Plan) Batch() bool { return p.State == PlanAssembling || p.State == PlanQueued }
|
|
|
|
// PlanDelivery is what a walk waits for and what came of the wait (novox/hq ADR 0239).
|
|
type PlanDelivery struct {
|
|
// Awaits is who must say the walk may start: the seat whose holder owns the delivery.
|
|
Awaits string `json:"awaits"`
|
|
// Go is when it was let go, By by whom (the seat's holder, or a person's name) and Why.
|
|
Go *time.Time `json:"go,omitempty"`
|
|
By string `json:"by,omitempty"`
|
|
Why string `json:"why,omitempty"`
|
|
// Stopped is who ended the walk through the delivery's owner, and StoppedWhy why: the walk is failed,
|
|
// and the delivery reads it as stopped, not as a build that failed.
|
|
Stopped string `json:"stopped,omitempty"`
|
|
StoppedWhy string `json:"stopped_why,omitempty"`
|
|
// Merges is every merge the walk or batch answers (novox/hq ADR 0276): its own commits, and the earlier
|
|
// merges of a repository its later commit contains.
|
|
Merges []PlanMerge `json:"merges,omitempty"`
|
|
// TakenOverBy names the walk a walk folded before it started was taken over by: its merges are that
|
|
// walk's now.
|
|
TakenOverBy string `json:"taken_over_by,omitempty"`
|
|
// Batch is the window of a batch; nil once it is cut into a walk.
|
|
Batch *PlanBatch `json:"batch,omitempty"`
|
|
// Alone says the walk walks one merge on its own commit, after a failed walk carried it in a later one:
|
|
// built at that commit, not at the branch.
|
|
Alone bool `json:"alone,omitempty"`
|
|
}
|
|
|
|
// Waiting is whether the walk waits for its delivery's word.
|
|
func (p Plan) Waiting() bool {
|
|
return p.Delivery != nil && p.Delivery.Awaits != "" && p.Delivery.Go == nil
|
|
}
|
|
|
|
// PlanSaved is told every plan this process kept, after it is kept (novox/hq ADR 0239): the serving
|
|
// controller says it on the bus as `plan-moved`. Nil in a command, which says nothing; whoever follows a
|
|
// walk also asks for it, so a save made by a command is found by comparison.
|
|
var PlanSaved func(Plan)
|
|
|
|
// ErrPlanMoved is a save against a plan written by somebody else since it was read.
|
|
var ErrPlanMoved = errors.New("the plan was written by somebody else since it was read")
|
|
|
|
// PlanModule is one module's state within a plan.
|
|
type PlanModule struct {
|
|
// State: asked, built, failed; empty for a module whose tier has not been asked yet.
|
|
State string `json:"state,omitempty"`
|
|
AskedAt *time.Time `json:"asked_at,omitempty"`
|
|
BuiltAt *time.Time `json:"built_at,omitempty"`
|
|
// SentAt is when the plan sent the machines running this module its new build, because a
|
|
// later tier is built by it (ADR 0163's gate): the reports that open the gate are the ones
|
|
// after this.
|
|
SentAt *time.Time `json:"sent_at,omitempty"`
|
|
// First is the machines the plan sent the new build to first, and FirstAt when (novox/hq issue
|
|
// 249, ADR 0218): unless the module's policy rolls it out together, one machine takes it before
|
|
// the rest, and the rest are sent once that one reports it applied. Kept so a controller
|
|
// replaced while the plan waits on that report resumes the wait rather than sending again. The
|
|
// machine holding the bus is among them when its user list had to go first.
|
|
First []string `json:"first,omitempty"`
|
|
FirstAt *time.Time `json:"first_at,omitempty"`
|
|
Commit string `json:"commit,omitempty"`
|
|
Why string `json:"why,omitempty"`
|
|
// Build is the id of the build the plan asked for this module (novox/hq ADR 0219), so the plan
|
|
// matches its outcome by id — the one thing every outcome echoes, a failed one that never learnt
|
|
// its module's name included. Empty in a plan from before it was kept, which is matched by
|
|
// module, or by repository and path, as before.
|
|
Build string `json:"build,omitempty"`
|
|
// Previous is the build the first machine ran of this module before the plan sent it the new one —
|
|
// the commit its last send carried (ADR 0221) — kept at the first send: what a rollback puts back
|
|
// (novox/hq ADR 0236). Empty when the machine had never been sent the module, or what it was sent
|
|
// is not known.
|
|
Previous string `json:"previous,omitempty"`
|
|
// Gate is the new build's judging on its first machine (novox/hq ADR 0236, to-be 45 §8), kept so a
|
|
// controller replaced mid-judging resumes it, and read back through `plans` as the rollout's record.
|
|
Gate *PlanGate `json:"gate,omitempty"`
|
|
// GatedBy names the module of the same tier whose gate judges this one on its first machine: they
|
|
// went there in one send (novox/hq issue 281), and one gate judges what one send moved. Empty for
|
|
// the module the gate is kept on, and for a plan from before tiers were sent whole.
|
|
GatedBy string `json:"gated_by,omitempty"`
|
|
// Rest is, per machine of the rest, the declaration the send after the first-node gate carried there (novox/hq ADR
|
|
// 0282 decision 6): the machine's first report of it, applied, is when the build runs there.
|
|
Rest map[string]SentDeclaration `json:"rest,omitempty"`
|
|
}
|
|
|
|
// PlanGate is one module's rollout record at its gate (to-be 45 §8): the component, the first machine,
|
|
// from and to which build, the verdict, how long it took to reach it, and whether it was rolled back.
|
|
type PlanGate struct {
|
|
// Component is the core component the module is — mesh-controller, mesh-host, node-tools — or empty
|
|
// for any other module, judged by its own health.
|
|
Component string `json:"component,omitempty"`
|
|
Machines []string `json:"machines"`
|
|
From string `json:"from,omitempty"`
|
|
To string `json:"to,omitempty"`
|
|
// Since is when the judging began: the first machine reported the new build applied.
|
|
Since *time.Time `json:"since,omitempty"`
|
|
// Sent is, per machine, the declaration the gate's send carried there (novox/hq issue 352): what a
|
|
// machine's report is held against. Absent on a gate kept before it was, which reads the report
|
|
// against the send made last, as before.
|
|
Sent map[string]SentDeclaration `json:"sent,omitempty"`
|
|
// Passes counts the consecutive judgings that found it healthy, LastPass the newest; a judging that
|
|
// does not resets them.
|
|
Passes int `json:"passes,omitempty"`
|
|
LastPass *time.Time `json:"last_pass,omitempty"`
|
|
// Last is what the newest judging found wanting, while it still may pass.
|
|
Last string `json:"last,omitempty"`
|
|
// Verdict is empty while judging, then passed or failed, with Why, at JudgedAt, Took after Since.
|
|
Verdict string `json:"verdict,omitempty"`
|
|
Why string `json:"why,omitempty"`
|
|
JudgedAt *time.Time `json:"judged_at,omitempty"`
|
|
Took string `json:"took,omitempty"`
|
|
// Rollback is how a failed build was put back: rolled-back, or not-rolled-back with why.
|
|
Rollback string `json:"rollback,omitempty"`
|
|
// Kept says a passing verdict was written to the gate's records.
|
|
Kept bool `json:"kept,omitempty"`
|
|
// Carried is every module whose build moved on the judged machines with the send — the plan's own
|
|
// module and whatever else was waiting there for a gate (novox/hq ADR 0236): each is judged here, a
|
|
// pass is its verdict too, and one that fails is put back.
|
|
Carried []CarriedMove `json:"carried,omitempty"`
|
|
// Failing names the modules the last judging found wanting.
|
|
Failing []string `json:"failing,omitempty"`
|
|
// Healthy counts, per module, the consecutive judgings that found it healthy on every machine judged,
|
|
// or waiting for a person (novox/hq ADR 0254): each module's passes, apart from the send's.
|
|
Healthy map[string]int `json:"healthy,omitempty"`
|
|
// Waits is, per module, the wait for a person the newest judging read (ADR 0254): carried along, never
|
|
// a reason to fail.
|
|
Waits map[string]string `json:"waits,omitempty"`
|
|
// Passing names the modules that passed on their own when the send failed (ADR 0254): each keeps its
|
|
// pass, and is not put back.
|
|
Passing []string `json:"passing,omitempty"`
|
|
// HealthyAt is, per module, when its newest healthy judging was counted: a pass is counted only gateEvery
|
|
// after the one before (issue 318 review).
|
|
HealthyAt map[string]time.Time `json:"healthy_at,omitempty"`
|
|
// Broken names the modules a judging found broken, kept for the rest of the judging, and BrokenWhy the
|
|
// first reason: the send fails, and the modules beside them are judged to their own verdict first.
|
|
Broken []string `json:"broken,omitempty"`
|
|
BrokenWhy string `json:"broken_why,omitempty"`
|
|
// Returned names the broken modules already put back, at once, while the rest of the send is judged.
|
|
Returned []string `json:"returned,omitempty"`
|
|
// Readings are the judging's readings with their times (novox/hq ADR 0282 decision 6): every reading that
|
|
// counted a pass, and the first that did not after one that did. At most maxReadings, the newest kept.
|
|
Readings []GateReading `json:"readings,omitempty"`
|
|
// Grants is the grants step taken before this gate's send (novox/hq issue 490): the bus's user list sent
|
|
// alone to the machine holding the bus, every build there kept, so what the send newly grants is on the
|
|
// bus before the gate judges it. Nil when the list did not change, or the gate's machine holds the bus.
|
|
Grants *GrantsStep `json:"grants,omitempty"`
|
|
}
|
|
|
|
// GrantsStep is the bus's user list sent to the machine holding the bus ahead of a gated send (novox/hq
|
|
// issue 490): to which machine, when, and, when it could not be sent, why — the send went on without it.
|
|
type GrantsStep struct {
|
|
Node string `json:"node"`
|
|
At *time.Time `json:"at,omitempty"`
|
|
Failed string `json:"failed,omitempty"`
|
|
}
|
|
|
|
// GateReading is one reading of a first-node gate.
|
|
type GateReading struct {
|
|
At time.Time `json:"at"`
|
|
Healthy bool `json:"healthy"`
|
|
// Said is what a reading that did not pass found wanting.
|
|
Said string `json:"said,omitempty"`
|
|
}
|
|
|
|
// maxReadings bounds a first-node gate's readings: a judging that never passes reads every few seconds for ten minutes.
|
|
const maxReadings = 24
|
|
|
|
// Read keeps one reading: a pass always, and a reading that did not pass only when the one before passed or
|
|
// there is none, so a judging waiting for a module to start keeps one line of it, not hundreds.
|
|
func (g *PlanGate) Read(at time.Time, healthy bool, said string) {
|
|
if !healthy && len(g.Readings) > 0 && !g.Readings[len(g.Readings)-1].Healthy {
|
|
return
|
|
}
|
|
if r := []rune(said); len(r) > 200 {
|
|
said = string(r[:200])
|
|
}
|
|
g.Readings = append(g.Readings, GateReading{At: at, Healthy: healthy, Said: said})
|
|
if len(g.Readings) > maxReadings {
|
|
g.Readings = g.Readings[len(g.Readings)-maxReadings:]
|
|
}
|
|
}
|
|
|
|
// CarriedMove is one module's build moving on a machine with a gated send.
|
|
type CarriedMove struct {
|
|
Module string `json:"module"`
|
|
Node string `json:"node"`
|
|
From string `json:"from,omitempty"`
|
|
To string `json:"to"`
|
|
Build string `json:"build,omitempty"`
|
|
// Recreates is what the send does to the module's containers, in the mesh's words — how many it
|
|
// recreates, and whether with a new image or only their declaration (novox/hq ADR 0245); empty when
|
|
// it recreates none, or when the build the machine ran is not known.
|
|
Recreates string `json:"recreates,omitempty"`
|
|
}
|
|
|
|
// PlanRelease is a release plan's walk through the machines (novox/hq ADR 0236): every module build
|
|
// that waits for a gate, sent one machine at a time, each judged before the next.
|
|
type PlanRelease struct {
|
|
Order []string `json:"order"`
|
|
Next int `json:"next"`
|
|
// Gate is the machine being judged; nil between machines.
|
|
Gate *PlanGate `json:"gate,omitempty"`
|
|
Done []string `json:"done,omitempty"`
|
|
// Skipped are the machines not heard from when their turn came, left as they were.
|
|
Skipped []string `json:"skipped,omitempty"`
|
|
// By is the person who released it, empty when the mesh did.
|
|
By string `json:"by,omitempty"`
|
|
}
|
|
|
|
// The states a plan passes through.
|
|
const (
|
|
PlanBuilding = "building"
|
|
PlanRolling = "rolling"
|
|
PlanDone = "done"
|
|
PlanFailed = "failed"
|
|
// PlanSuperseded is a plan a newer merge of the same repository and branch took over (novox/hq
|
|
// issue 254, ADR 0218): what it had not built is in the newer plan, and its note names it.
|
|
PlanSuperseded = "superseded"
|
|
// PlanAssembling is a batch whose merge window is open (novox/hq ADR 0276), and PlanQueued one whose
|
|
// window closed while a walk is open: neither is a walk yet. Cut, a batch keeps its id and is building.
|
|
PlanAssembling = "assembling"
|
|
PlanQueued = "queued"
|
|
)
|
|
|
|
// Open says whether the plan is still being worked.
|
|
func (p Plan) Open() bool { return p.State == PlanBuilding || p.State == PlanRolling }
|
|
|
|
// SavePlan writes a plan, new or changed, whole: the plan is small and read as one thing.
|
|
//
|
|
// **By compare-and-set on its revision, carrying the epoch** (novox/hq to-be 45 §6): written only if
|
|
// the plan is still at the revision it was read at — a new one only if it does not exist — and refused
|
|
// with ErrPlanMoved otherwise; and only by a process that may act (ActsUnder), whose epoch it records.
|
|
// On success p's revision and epoch are the ones written, so the caller may save it again.
|
|
func (i *Inventory) SavePlan(ctx context.Context, p *Plan) error {
|
|
epoch, err := i.actingEpoch(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("the plan for %s %s is not written: %w", p.Repository, p.Commit, err)
|
|
}
|
|
tiers, err := json.Marshal(p.Tiers)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
modules, err := json.Marshal(p.Modules)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var release, delivery, commits, times []byte
|
|
if p.Times != nil {
|
|
if times, err = json.Marshal(p.Times); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if len(p.Commits) > 0 {
|
|
if commits, err = json.Marshal(p.Commits); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if p.Release != nil {
|
|
if release, err = json.Marshal(p.Release); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if p.Delivery != nil {
|
|
if delivery, err = json.Marshal(p.Delivery); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
// **And how long the tier it left took** (novox/hq to-be 45 Phase 0): measured here, where the
|
|
// plan moves, in the same transaction as the move, so no save can move a tier unmeasured or
|
|
// measure one twice.
|
|
tx, err := i.store.Pool().Begin(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer func() { _ = tx.Rollback(ctx) }()
|
|
entered, err := planTierLeft(ctx, tx, *p, time.Now())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var revision int64
|
|
err = tx.QueryRow(ctx,
|
|
`insert into release_plan (id, repository, commit_hash, created, updated, state, tier, tiers, modules, note,
|
|
branch, tier_entered, revision, epoch, release, delivery, merged_at, commits, times)
|
|
values ($1, $2, $3, $4, now(), $5, $6, $7, $8, $9, $10, $11, 1, $13, $14, $15, $16, $17, $18)
|
|
on conflict (id) do update set updated = now(), state = excluded.state, tier = excluded.tier,
|
|
tiers = excluded.tiers, modules = excluded.modules, note = excluded.note, branch = excluded.branch,
|
|
tier_entered = excluded.tier_entered, revision = release_plan.revision + 1, epoch = excluded.epoch,
|
|
release = excluded.release, delivery = excluded.delivery, repository = excluded.repository,
|
|
commit_hash = excluded.commit_hash, merged_at = excluded.merged_at, commits = excluded.commits,
|
|
created = excluded.created, times = excluded.times
|
|
where release_plan.revision = $12
|
|
returning revision`,
|
|
p.ID, p.Repository, p.Commit, p.Created, p.State, p.Tier, tiers, modules, p.Note, p.Branch, entered,
|
|
p.Revision, epoch, release, delivery, mergedAt(p.Merged), commits, times).Scan(&revision)
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
// The row is there and at another revision — moved since this was read, or there already
|
|
// when this one is new: either way not this writer's to overwrite. (A plan saved before plans
|
|
// had revisions is at zero, and its first save here is from a read at zero.)
|
|
return fmt.Errorf("the plan for %s %s (%s) is not written: %w", p.Repository, short(p.Commit), p.ID, ErrPlanMoved)
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := tx.Commit(ctx); err != nil {
|
|
return err
|
|
}
|
|
p.Revision, p.TierEntered = revision, entered
|
|
if epoch != nil {
|
|
p.Epoch = uint64(*epoch)
|
|
} else {
|
|
p.Epoch = 0
|
|
}
|
|
if PlanSaved != nil {
|
|
PlanSaved(*p)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// short is a commit as a person reads it.
|
|
func short(commit string) string {
|
|
if len(commit) > 8 {
|
|
return commit[:8]
|
|
}
|
|
return commit
|
|
}
|
|
|
|
// mergedAt is a plan's merge time as the store keeps it: null when not known.
|
|
func mergedAt(t time.Time) *time.Time {
|
|
if t.IsZero() {
|
|
return nil
|
|
}
|
|
return &t
|
|
}
|
|
|
|
// OpenPlans is every plan still being worked, oldest first.
|
|
func (i *Inventory) OpenPlans(ctx context.Context) ([]Plan, error) {
|
|
return i.plans(ctx, `where state in ('building', 'rolling') order by created`)
|
|
}
|
|
|
|
// PlansSince is every plan made after a moment, and every plan still being worked, oldest first.
|
|
func (i *Inventory) PlansSince(ctx context.Context, since time.Time) ([]Plan, error) {
|
|
return i.plans(ctx, `where created > $1 or state in ('building', 'rolling') order by created`, since)
|
|
}
|
|
|
|
// RecentPlans is the last few plans, newest first, open or not — what the overview shows.
|
|
func (i *Inventory) RecentPlans(ctx context.Context, limit int) ([]Plan, error) {
|
|
return i.plans(ctx, fmt.Sprintf(`order by created desc limit %d`, limit))
|
|
}
|
|
|
|
// NewestMergeOf is the plan, in any state, of the newest merge into a repository's branch that the mesh
|
|
// planned: the branch's newest commit the mesh knows of (novox/hq issue 349). False when no plan of it
|
|
// recorded when its merge was made.
|
|
func (i *Inventory) NewestMergeOf(ctx context.Context, repository, branch string) (Plan, bool, error) {
|
|
plans, err := i.plans(ctx, `where lower(repository) = lower($1) and branch = $2 and merged_at is not null
|
|
and release is null order by merged_at desc, created desc limit 1`, repository, branch)
|
|
if err != nil || len(plans) == 0 {
|
|
return Plan{}, false, err
|
|
}
|
|
return plans[0], true, nil
|
|
}
|
|
|
|
// PlanByID is one plan.
|
|
func (i *Inventory) PlanByID(ctx context.Context, id string) (Plan, error) {
|
|
plans, err := i.plans(ctx, `where id = '`+id+`'`)
|
|
if err != nil {
|
|
return Plan{}, err
|
|
}
|
|
if len(plans) == 0 {
|
|
return Plan{}, fmt.Errorf("no plan %s", id)
|
|
}
|
|
return plans[0], nil
|
|
}
|
|
|
|
func (i *Inventory) plans(ctx context.Context, tail string, args ...any) ([]Plan, error) {
|
|
rows, err := i.store.Pool().Query(ctx,
|
|
`select id, repository, commit_hash, created, updated, state, tier, tiers, modules, note, branch,
|
|
coalesce(tier_entered, created), revision, coalesce(epoch, 0), release, delivery, merged_at, commits, times
|
|
from release_plan `+tail, args...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var out []Plan
|
|
for rows.Next() {
|
|
var p Plan
|
|
var tiers, modules, release, delivery, commits, times []byte
|
|
var epoch int64
|
|
var merged *time.Time
|
|
if err := rows.Scan(&p.ID, &p.Repository, &p.Commit, &p.Created, &p.Updated, &p.State,
|
|
&p.Tier, &tiers, &modules, &p.Note, &p.Branch, &p.TierEntered, &p.Revision, &epoch, &release,
|
|
&delivery, &merged, &commits, ×); err != nil {
|
|
return nil, err
|
|
}
|
|
if len(times) > 0 {
|
|
if err := json.Unmarshal(times, &p.Times); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
if len(commits) > 0 {
|
|
if err := json.Unmarshal(commits, &p.Commits); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
if merged != nil {
|
|
p.Merged = merged.UTC()
|
|
}
|
|
if len(release) > 0 {
|
|
if err := json.Unmarshal(release, &p.Release); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
if len(delivery) > 0 {
|
|
if err := json.Unmarshal(delivery, &p.Delivery); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
p.Epoch = uint64(epoch)
|
|
if err := json.Unmarshal(tiers, &p.Tiers); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := json.Unmarshal(modules, &p.Modules); err != nil {
|
|
return nil, err
|
|
}
|
|
if p.Modules == nil {
|
|
p.Modules = map[string]*PlanModule{}
|
|
}
|
|
out = append(out, p)
|
|
}
|
|
if errors.Is(rows.Err(), pgx.ErrNoRows) {
|
|
return nil, nil
|
|
}
|
|
return out, rows.Err()
|
|
}
|