Two controllers could both act (issue 204), a reconcile's report could overtake the apply after it and the digest decided (issue 267), and a grant could make a second writer of a machine's report. - The lease (internal/lease, ADR 0229): mesh-controller_lease key `holder`, 15 s age, renewed every 5 s by compare-and-set; the epoch is the revision it was taken at. The gate is the clock (stops 3 s before expiry); a refused renewal is a loss and the process exits; a holder that stops gives it back. serve takes it before asserting the bus. Epochs kept in the store (migration 0068 controller_epoch) as a floor: a bucket raised from nothing is compacted past it. Unleased (no epoch, S12 urgent) only when nobody holds it and the bus will not let it be written. A shell command acts under the holder's epoch, or its own lease when none. - Declarations carry `epoch` inside the signed envelope, only to a machine whose latest account carried a report_sequence (mesh-host #35); would-send is composed with the epoch last sent. Allot and the send both pass the gate. - Reports: contract in internal/link/order.go (epoch, sequence, report_sequence, older_than, refused_older). Accounts kept by epoch, then sequence, then report sequence; older refused, counted; unordered reports keep the digest rule. Plans by compare-and-set on a revision, with epoch. Conditions and calls carry the epoch and are not written off the lease. - S12 and S13 (naming the writer by epoch) watched, D5 run; reset of the bucket said. Writers table compiled in and enforced in PermissionsFor; the controller no longer publishes mesh.control.>. A contract per consumed kind, and the empty-on-error lint over the repository. - mesh-host pinned to its main with the epoch in the validator (D1 validates the envelope as sent). Needs mesh-host's genesis lock with the lease grant (mesh-host PR) for TestTheInstallersFirstUserListIsWhatTheControllerWouldCompose.
215 lines
8.6 KiB
Go
215 lines
8.6 KiB
Go
package inventory
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"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"`
|
|
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"`
|
|
}
|
|
|
|
// 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"`
|
|
}
|
|
|
|
// 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"
|
|
)
|
|
|
|
// 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
|
|
}
|
|
// **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)
|
|
values ($1, $2, $3, $4, now(), $5, $6, $7, $8, $9, $10, $11, 1, $13)
|
|
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
|
|
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).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
|
|
}
|
|
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
|
|
}
|
|
|
|
// 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`)
|
|
}
|
|
|
|
// 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))
|
|
}
|
|
|
|
// 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) ([]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)
|
|
from release_plan `+tail)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var out []Plan
|
|
for rows.Next() {
|
|
var p Plan
|
|
var tiers, modules []byte
|
|
var epoch int64
|
|
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); 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()
|
|
}
|