A merge produces a tiered plan the mesh keeps (hq ADR 0162)
A module's dependencies are one relation in the catalogue — stands-on, packages, built-by, declared — answered by one call. A merge takes what moved and everything reachable from it, sorts the set into tiers (a code dependency in the same tier, a build dependency after its base is built, a runtime dependency after the build machine is built and running; the build machine's own base comes first, built by the one that runs), writes the plan to the store, asks the first tier and returns. Every outcome advances the plan; a ticker advances what outcomes cannot; a controller replaced mid-plan resumes it. status lists open plans and names one that has waited too long.
This commit is contained in:
@@ -0,0 +1,124 @@
|
||||
package inventory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
)
|
||||
|
||||
// The kinds of edge in the catalogue's one dependency relation (novox/hq ADR 0162).
|
||||
const (
|
||||
// EdgeStandsOn: the module's artifact is built on the other's.
|
||||
EdgeStandsOn = "stands-on"
|
||||
// EdgePackages: the module's build reads the other's repository.
|
||||
EdgePackages = "packages"
|
||||
// EdgeBuiltBy: the module is built by the holder of the build-machine seat.
|
||||
EdgeBuiltBy = "built-by"
|
||||
// EdgeDeclared: the manifest's own `build.on`.
|
||||
EdgeDeclared = "declared"
|
||||
)
|
||||
|
||||
// Edge is one dependency: From depends on To, in the way Kind says.
|
||||
type Edge struct {
|
||||
From string `json:"from"`
|
||||
To string `json:"to"`
|
||||
Kind string `json:"kind"`
|
||||
}
|
||||
|
||||
// Dependencies is the catalogue's dependency relation, whole: every module the mesh holds, with
|
||||
// an edge to each module it depends on and the kind of dependency on the edge. One answer, so
|
||||
// nothing else computes an edge (novox/hq ADR 0162) — the merge handler, `build --on` and the
|
||||
// overview all read this.
|
||||
//
|
||||
// Four sources, one relation: a manifest's `build.on`; the artifacts the latest build was made
|
||||
// against (an `artifact-store://<module>/…` reference is an edge to that module); the repositories
|
||||
// the latest build read (an edge to the module whose source that is); and the build machine, which
|
||||
// every source-built module is built by.
|
||||
func (i *Inventory) Dependencies(ctx context.Context) ([]Edge, error) {
|
||||
entries, err := i.Catalogued(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
against, err := i.BuiltAgainst(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
read, err := i.ReadRepositories(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return dependenciesOf(entries, against, read), nil
|
||||
}
|
||||
|
||||
// dependenciesOf is Dependencies over what was read, so a test can hand it a catalogue.
|
||||
func dependenciesOf(entries []Entry, against map[string][]string, read map[string][]ReadRepository) []Edge {
|
||||
known := map[string]bool{}
|
||||
byRepository := map[string][]string{}
|
||||
var builders []string
|
||||
for _, e := range entries {
|
||||
name := e.Manifest.Module
|
||||
known[name] = true
|
||||
if r := repositoryKey(e.Source.Repository); r != "" {
|
||||
byRepository[r] = append(byRepository[r], name)
|
||||
}
|
||||
if e.Manifest.ClaimsSeat("mesh-build-machine") {
|
||||
builders = append(builders, name)
|
||||
}
|
||||
}
|
||||
seen := map[Edge]bool{}
|
||||
var out []Edge
|
||||
add := func(from, to, kind string) {
|
||||
if from == to || !known[to] {
|
||||
return
|
||||
}
|
||||
e := Edge{From: from, To: to, Kind: kind}
|
||||
if !seen[e] {
|
||||
seen[e] = true
|
||||
out = append(out, e)
|
||||
}
|
||||
}
|
||||
for _, e := range entries {
|
||||
name := e.Manifest.Module
|
||||
if e.Manifest.Build != nil {
|
||||
for _, on := range e.Manifest.Build.On {
|
||||
if on.Module != "" {
|
||||
add(name, on.Module, EdgeDeclared)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, ref := range against[name] {
|
||||
if rest, ok := strings.CutPrefix(ref, catalogue.ArtifactStoreScheme); ok {
|
||||
if base, _, found := strings.Cut(rest, "/"); found {
|
||||
add(name, base, EdgeStandsOn)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, r := range read[name] {
|
||||
for _, other := range byRepository[repositoryKey(r.Repository)] {
|
||||
add(name, other, EdgePackages)
|
||||
}
|
||||
}
|
||||
if e.Source.Repository != "" {
|
||||
for _, b := range builders {
|
||||
add(name, b, EdgeBuiltBy)
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(a, b int) bool {
|
||||
if out[a].From != out[b].From {
|
||||
return out[a].From < out[b].From
|
||||
}
|
||||
if out[a].To != out[b].To {
|
||||
return out[a].To < out[b].To
|
||||
}
|
||||
return out[a].Kind < out[b].Kind
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// repositoryKey is a repository as compared: lower-cased, without a trailing `.git`.
|
||||
func repositoryKey(repository string) string {
|
||||
return strings.ToLower(strings.TrimSuffix(strings.TrimSpace(repository), ".git"))
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package inventory
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
)
|
||||
|
||||
// The catalogue's one dependency relation (novox/hq ADR 0162): four kinds of edge from one call.
|
||||
func TestDependenciesAreOneRelationWithTheirKinds(t *testing.T) {
|
||||
entry := func(name, repository string) Entry {
|
||||
return Entry{Manifest: catalogue.Manifest{Module: name}, Source: Source{Repository: repository}}
|
||||
}
|
||||
builder := entry("builder", "http://forge/novox/mesh-catalog.git")
|
||||
builder.Manifest.Claims = []catalogue.Claim{{Name: "mesh-build-machine", Scope: catalogue.ScopeMesh}}
|
||||
plugin := entry("shop-plugin", "http://forge/novox/mesh-catalog.git")
|
||||
plugin.Manifest.Build = &catalogue.Build{On: []catalogue.BuildsOn{{Arg: "BASE", Module: "shop"}}}
|
||||
entries := []Entry{
|
||||
entry("mesh-tools", "http://forge/novox/mesh-tools.git"),
|
||||
entry("shop", "http://forge/novox/mesh-catalog.git"),
|
||||
plugin,
|
||||
builder,
|
||||
entry("mesh-controller", "http://forge/novox/mesh-controller.git"),
|
||||
entry("route-proxy", "http://forge/novox/mesh-catalog.git"),
|
||||
{Manifest: catalogue.Manifest{Module: "hand-made"}},
|
||||
}
|
||||
against := map[string][]string{
|
||||
"shop": {catalogue.ArtifactStoreScheme + "mesh-tools/runtime@sha256:a"},
|
||||
"builder": {catalogue.ArtifactStoreScheme + "mesh-tools/runtime@sha256:a"},
|
||||
}
|
||||
read := map[string][]ReadRepository{
|
||||
"route-proxy": {{Repository: "http://forge/novox/mesh-controller.git", Ref: "main"}},
|
||||
}
|
||||
got := dependenciesOf(entries, against, read)
|
||||
has := func(from, to, kind string) bool {
|
||||
for _, e := range got {
|
||||
if e == (Edge{From: from, To: to, Kind: kind}) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
for _, want := range []Edge{
|
||||
{"shop", "mesh-tools", EdgeStandsOn},
|
||||
{"builder", "mesh-tools", EdgeStandsOn},
|
||||
{"shop-plugin", "shop", EdgeDeclared},
|
||||
{"route-proxy", "mesh-controller", EdgePackages},
|
||||
{"shop", "builder", EdgeBuiltBy},
|
||||
{"mesh-controller", "builder", EdgeBuiltBy},
|
||||
{"mesh-tools", "builder", EdgeBuiltBy},
|
||||
} {
|
||||
if !has(want.From, want.To, want.Kind) {
|
||||
t.Errorf("missing %+v in %+v", want, got)
|
||||
}
|
||||
}
|
||||
if has("builder", "builder", EdgeBuiltBy) {
|
||||
t.Error("the builder is not built by itself")
|
||||
}
|
||||
for _, e := range got {
|
||||
if e.From == "hand-made" {
|
||||
t.Errorf("a module with no source depends on nothing: %+v", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
-- A merge produces a tiered plan the mesh keeps (novox/hq ADR 0162): what the merge changed and
|
||||
-- everything standing on it, sorted into tiers, each module's state, and the tier the plan is at.
|
||||
-- Kept so a controller replaced mid-plan resumes it, and so `status` can say what a merge still
|
||||
-- waits for.
|
||||
create table release_plan (
|
||||
id text primary key,
|
||||
repository text not null,
|
||||
commit_hash text not null,
|
||||
created timestamptz not null default now(),
|
||||
updated timestamptz not null default now(),
|
||||
-- building: a tier's builds are asked; rolling: the tier is built and the machines are applying
|
||||
-- what a later tier needs running; done; failed.
|
||||
state text not null,
|
||||
tier int not null default 0,
|
||||
tiers jsonb not null,
|
||||
modules jsonb not null,
|
||||
note text not null default ''
|
||||
);
|
||||
create index release_plan_open on release_plan (created) where state in ('building', 'rolling');
|
||||
@@ -0,0 +1,123 @@
|
||||
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"`
|
||||
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"`
|
||||
}
|
||||
|
||||
// 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"`
|
||||
Commit string `json:"commit,omitempty"`
|
||||
Why string `json:"why,omitempty"`
|
||||
}
|
||||
|
||||
// The states a plan passes through.
|
||||
const (
|
||||
PlanBuilding = "building"
|
||||
PlanRolling = "rolling"
|
||||
PlanDone = "done"
|
||||
PlanFailed = "failed"
|
||||
)
|
||||
|
||||
// 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.
|
||||
func (i *Inventory) SavePlan(ctx context.Context, p Plan) error {
|
||||
tiers, err := json.Marshal(p.Tiers)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
modules, err := json.Marshal(p.Modules)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = i.store.Pool().Exec(ctx,
|
||||
`insert into release_plan (id, repository, commit_hash, created, updated, state, tier, tiers, modules, note)
|
||||
values ($1, $2, $3, $4, now(), $5, $6, $7, $8, $9)
|
||||
on conflict (id) do update set updated = now(), state = excluded.state, tier = excluded.tier,
|
||||
tiers = excluded.tiers, modules = excluded.modules, note = excluded.note`,
|
||||
p.ID, p.Repository, p.Commit, p.Created, p.State, p.Tier, tiers, modules, p.Note)
|
||||
return err
|
||||
}
|
||||
|
||||
// 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
|
||||
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
|
||||
if err := rows.Scan(&p.ID, &p.Repository, &p.Commit, &p.Created, &p.Updated, &p.State,
|
||||
&p.Tier, &tiers, &modules, &p.Note); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
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()
|
||||
}
|
||||
Reference in New Issue
Block a user