Files
mesh-controller/internal/inventory/gate.go
T
jochen d9289ef6d4 Gate a release plan's first machine and roll a failed build back there (hq ADR 0236)
A build that reported applied was sent everywhere; one that then did nothing, served
no tools or broke its machine's word reached every machine. Now the first machine is
judged by the component's health (the core's definitions, as doctor probes H-*, or a
module's own) three times over two minutes within ten; a failing gate puts the previous
build back there once, marks the build, and says it as a condition and an event.
Upgrades roll out by default; the bus is a planned step; a module deleted at its
source is not built (the public-acme plan failure).
2026-10-06 18:56:54 +02:00

219 lines
7.5 KiB
Go

package inventory
import (
"context"
"encoding/json"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
"github.com/novox/mesh-controller/internal/catalogue"
)
// The gate's verdicts (novox/hq ADR 0235, to-be 45 §8): what a build did on its first machine, and,
// for one that failed there, how it was put back. One row per build, written by the plan that rolled it
// out, under the lease.
// The gate's verdicts and a failed build's rollback.
const (
GatePassed = "passed"
GateFailed = "failed"
RollingBack = "rolling-back"
RolledBack = "rolled-back"
NotRolledBack = "not-rolled-back"
)
// GateVerdict is one build's verdict at its gate.
type GateVerdict struct {
Build string
Module string
Commit string
Previous string
Plan string
Machines []string
Verdict string
Rollback string
Why string
Component string
// JudgingFrom is when the first machine reported the build applied and the judging began.
JudgingFrom *time.Time
JudgedAt time.Time
Epoch uint64
}
// RecordGate writes a build's verdict. **A failed build's row is written once**: a second failure for
// the same build is refused with ErrGateKept, which is what keeps a rollback to one per build — the row
// is written before the rollback's send, and a controller replaced in between finds it.
func (i *Inventory) RecordGate(ctx context.Context, v GateVerdict) error {
epoch, err := i.actingEpoch(ctx)
if err != nil {
return fmt.Errorf("the gate's verdict on %s is not written: %w", v.Build, err)
}
if v.Machines == nil {
v.Machines = []string{}
}
tag, err := i.store.Pool().Exec(ctx,
`insert into build_gate (build, module, commit_hash, previous, plan, machines, verdict, rollback, why,
component, judging_from, judged_at, epoch)
values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, now(), $12)
on conflict (build) do update set verdict = excluded.verdict, rollback = excluded.rollback,
why = excluded.why, previous = excluded.previous, machines = excluded.machines,
judged_at = now(), epoch = excluded.epoch
where build_gate.verdict = 'passed' and excluded.verdict = 'passed'`,
v.Build, v.Module, v.Commit, v.Previous, v.Plan, v.Machines, v.Verdict, v.Rollback, v.Why, v.Component,
v.JudgingFrom, epoch)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return fmt.Errorf("%w: %s", ErrGateKept, v.Build)
}
return nil
}
// ErrGateKept is a verdict already kept for the build, which is not written over.
var ErrGateKept = errors.New("this build's verdict at its gate is already kept")
// SetRollback records how a failed build's rollback went.
func (i *Inventory) SetRollback(ctx context.Context, build, rollback, why string) error {
if _, err := i.actingEpoch(ctx); err != nil {
return err
}
_, err := i.store.Pool().Exec(ctx,
`update build_gate set rollback = $2, why = $3, judged_at = now() where build = $1 and verdict = 'failed'`,
build, rollback, why)
return err
}
// GateOf is a build's verdict, and whether it has one.
func (i *Inventory) GateOf(ctx context.Context, build string) (GateVerdict, bool, error) {
rows, err := i.store.Pool().Query(ctx, gateSelect+` where build = $1`, build)
if err != nil {
return GateVerdict{}, false, err
}
list, err := scanGates(rows)
if err != nil || len(list) == 0 {
return GateVerdict{}, false, err
}
return list[0], true, nil
}
// GateFailed is whether a build failed its gate: one the mesh never registers or sends again on its own.
func (i *Inventory) GateFailed(ctx context.Context, build string) (bool, error) {
v, found, err := i.GateOf(ctx, build)
return found && v.Verdict == GateFailed, err
}
// LatestGates is the newest verdict of every module that has one: what the gate probe (DG) reads.
func (i *Inventory) LatestGates(ctx context.Context) ([]GateVerdict, error) {
rows, err := i.store.Pool().Query(ctx, `select distinct on (module) build, module, commit_hash, previous, plan,
machines, verdict, rollback, why, component, judging_from, judged_at, coalesce(epoch, 0)
from build_gate order by module, judged_at desc`)
if err != nil {
return nil, err
}
return scanGates(rows)
}
// Gates is the newest verdicts, newest first: what `plans gates` lists.
func (i *Inventory) Gates(ctx context.Context, limit int) ([]GateVerdict, error) {
rows, err := i.store.Pool().Query(ctx, gateSelect+` order by judged_at desc limit $1`, limit)
if err != nil {
return nil, err
}
return scanGates(rows)
}
const gateSelect = `select build, module, commit_hash, previous, plan, machines, verdict, rollback, why, component,
judging_from, judged_at, coalesce(epoch, 0) from build_gate`
func scanGates(rows pgx.Rows) ([]GateVerdict, error) {
defer rows.Close()
var out []GateVerdict
for rows.Next() {
var v GateVerdict
var epoch int64
if err := rows.Scan(&v.Build, &v.Module, &v.Commit, &v.Previous, &v.Plan, &v.Machines, &v.Verdict,
&v.Rollback, &v.Why, &v.Component, &v.JudgingFrom, &v.JudgedAt, &epoch); err != nil {
return nil, err
}
v.Epoch = uint64(epoch)
out = append(out, v)
}
return out, rows.Err()
}
// PreviousBuild is the build a module goes back to when a build of it fails its gate: the newest build
// that worked, made from the commit the first machine ran before, asked before the failed one, and not
// itself failed at a gate. Among the builds whose artifacts the mesh keeps (KeptBuilds): an older one
// may already be gone from the artifact store. False when there is none to go back to.
func (i *Inventory) PreviousBuild(ctx context.Context, module, commit string, failed Build) (Build, bool, error) {
builds, err := i.Builds(ctx, module, 50)
if err != nil {
return Build{}, false, err
}
kept := 0
for _, b := range builds {
if !b.Worked() {
continue
}
kept++
if kept > KeptBuilds {
break
}
if b.ID == failed.ID || (commit != "" && b.Commit != commit) {
continue
}
if !failed.AskedOrAt().IsZero() && !b.AskedOrAt().Before(failed.AskedOrAt()) {
continue
}
if bad, err := i.GateFailed(ctx, b.ID); err != nil {
return Build{}, false, err
} else if bad {
continue
}
var manifest []byte
if err := i.store.Pool().QueryRow(ctx, `select manifest from build where id = $1`, b.ID).Scan(&manifest); err != nil {
return Build{}, false, err
}
if len(manifest) == 0 || string(manifest) == "null" {
continue
}
b.Manifest = manifest
return b, true, nil
}
return Build{}, false, nil
}
// RestoreModule puts a module's registered build back to an earlier one: its manifest, the commit it
// was built from, and when it was asked — as now, so the build that failed its gate, asked before, can
// never register over it again (issue 219's order). The source's head is left where the merge moved it:
// the module IS behind its source, and `status` says so.
func (i *Inventory) RestoreModule(ctx context.Context, b Build) error {
if _, err := i.actingEpoch(ctx); err != nil {
return fmt.Errorf("%s is not put back: %w", b.Module, err)
}
m, err := catalogue.ParseManifest(b.Manifest)
if err != nil {
return fmt.Errorf("%s's build %s is not a manifest the mesh can register again: %w", b.Module, b.ID, err)
}
raw, err := json.Marshal(m)
if err != nil {
return err
}
tag, err := i.store.Pool().Exec(ctx,
`update module set manifest = $2, version = nullif($3, ''), built_from = nullif($4, ''),
built_asked = now(), registered = now()
where name = $1`, b.Module, raw, m.Version, b.Commit)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return fmt.Errorf("%w: %s", ErrNoSuchModule, b.Module)
}
return nil
}