Files
mesh-catalog/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/ports.go
T

462 lines
16 KiB
Go

package main
import (
"encoding/json"
"errors"
"fmt"
"slices"
"sort"
"strconv"
"strings"
stdio "git.novox.be/novox/mesh-sdk/go"
)
// What mesh-delivery asks of others, and where it keeps what it owns. **It never sends to a machine, writes
// a declaration, a grant or a bus object, and never registers a build** (novox/hq ADR 0239 decision 6): it
// asks the controller, through the controller's seat, and the forge's holder, through its tools.
// ControllerSeat is the seat whose verbs the controller serves.
const ControllerSeat = "mesh-controller"
// Member is one delivery as the controller's planner takes it (mesh-controller orderMember).
type Member struct {
ID string `json:"id"`
Repository string `json:"repository"`
Base string `json:"base,omitempty"`
Head string `json:"head,omitempty"`
Number int `json:"number,omitempty"`
Paths []string `json:"paths,omitempty"`
PathsTruncated bool `json:"paths_truncated,omitempty"`
Removed []string `json:"removed,omitempty"`
ModuleDirs []string `json:"module_dirs,omitempty"`
ModuleDirsSaid bool `json:"module_dirs_said,omitempty"`
CloneURL string `json:"clone_url,omitempty"`
// Ready is whether the operator's Approve of its merge is asked or given: its check is published on the ready stream (ADR 0406 §3, 0421 §10).
Ready bool `json:"ready,omitempty"`
// BaseSHA is the base branch's head the forge last announced the head with (novox/hq ADR 0323): the trunk commit
// the controller's verdict names when no build judges the head.
BaseSHA string `json:"base_sha,omitempty"`
}
// MemberOf is a delivery as a member.
func MemberOf(d *Delivery) Member {
return Member{Ready: d.Ready != nil && d.Ready.Merged == nil && (d.Ready.AskID != "" || d.Ready.Approved != nil), ID: d.ID, Repository: d.Repository, Base: d.Base, Head: d.Commit, Number: d.Number, Paths: d.Paths,
PathsTruncated: d.PathsTruncated, Removed: d.Removed, ModuleDirs: d.ModuleDirs, ModuleDirsSaid: d.ModuleDirsSaid,
CloneURL: d.CloneURL, BaseSHA: d.BaseSHA}
}
// Controller is the controller's verbs this owner asks with.
type Controller interface {
// Plan is the delivery plan of a diffset, kept nowhere.
Plan(repository, base, head string, paths, moduleDirs, removed []string) (*DeliveryPlan, error)
// Check asks a group's composed check, or — with no group and one member — that head's own check again.
Check(group string, members []Member) (string, error)
// Deliver lets a waiting walk start.
Deliver(walk, why string) error
// BackupsAgain asks the controller to take a held walk's failed backups again, and to go on when they pass
// (novox/hq ADR 0404 clause 4).
BackupsAgain(walk, why string) error
// Stop ends a walk, said as stopped by whom.
Stop(walk, why, by string) error
// StopWithout ends a walk and walks its other merges again without the merges named, each
// `<repository>@<commit>`, and without each later merge of their repository, which contains one (novox/hq
// ADR 0299).
StopWithout(walk, why, by string, without []string) (StopAnswer, error)
// Walks are the walks the controller keeps — one, given its id — and whether this seat has a holder on record.
Walks(walk string) (bool, []Walk, error)
// ForgetChecks lets go of the builds a pull request's merge checks made for its merge (novox/hq ADR 0350 §5).
ForgetChecks(owner, repo string, number int) error
}
// Forge is what the forge's holder does for a delivery.
type Forge interface {
// Statuses reads a commit's statuses — a pull request's head's, given its number — and whether the branch
// it merges into would let it merge. Reads only.
Statuses(owner, repo string, number int, commit, base string) (*ForgeChecks, error)
// Contains is whether a commit is contained in another — an ancestor of it, or it. Reads only.
Contains(owner, repo, commit, in string) (bool, error)
// OpenPulls is every open pull request, with what its base requires and what its head has (novox/hq issue
// 347). Reads only.
OpenPulls() ([]OpenPull, error)
}
// ForgeChecks is what the forge says of a commit's checks (the gitea module's gitea_commit_statuses).
type ForgeChecks struct {
Commit string `json:"commit"`
Pull *ForgePull `json:"pull,omitempty"`
Combined string `json:"combined,omitempty"`
Statuses []ForgeStatus `json:"statuses"`
Merge ForgeMerge `json:"merge"`
}
// ForgePull is a pull request as the forge says it.
type ForgePull struct {
Number int `json:"number"`
Title string `json:"title,omitempty"`
State string `json:"state,omitempty"`
Merged bool `json:"merged,omitempty"`
Base string `json:"base,omitempty"`
Head string `json:"head,omitempty"`
HeadSHA string `json:"head_sha,omitempty"`
MergeCommit string `json:"merge_commit_sha,omitempty"`
HTMLURL string `json:"html_url,omitempty"`
}
// ForgeStatus is one status of a commit, the newest of its context.
type ForgeStatus struct {
Context string `json:"context"`
State string `json:"state"`
Description string `json:"description"`
TargetURL string `json:"target_url,omitempty"`
CreatedAt string `json:"created_at,omitempty"`
UpdatedAt string `json:"updated_at,omitempty"`
Creator string `json:"creator,omitempty"`
}
// ForgeMerge is whether the branch's protection lets the commit merge: every required status a success.
// Mergeable is nil when the protection could not be read.
type ForgeMerge struct {
Branch string `json:"branch"`
Protected bool `json:"protected"`
Required []string `json:"required"`
Blocking []struct {
Context string `json:"context"`
State string `json:"state"`
} `json:"blocking"`
Mergeable *bool `json:"mergeable"`
Says string `json:"says"`
}
// Store keeps deliveries and groups: one key each, in the module's own state (ADR 0201).
type Store interface {
PutDelivery(d *Delivery) error
DeleteDelivery(id string) error
Deliveries() ([]*Delivery, error)
PutGroup(g *Group) error
DeleteGroup(id string) error
Groups() ([]*Group, error)
}
// --- over the runtime -----------------------------------------------------------------------------------
// asking is how a tool is asked: stdio.Ask, or a test's.
type asking func(key string, body any) (json.RawMessage, error)
// seatController asks the controller's seat through the runtime.
type seatController struct{ ask asking }
// answerOf reads a verb's answer whatever wraps it: the controller's {output, ok, answer}, a text the
// runtime handed over, or the protocol's content list.
func answerOf(raw json.RawMessage) (json.RawMessage, string, bool) {
var s string
if json.Unmarshal(raw, &s) == nil {
return answerOf(json.RawMessage(s))
}
var m map[string]json.RawMessage
if json.Unmarshal(raw, &m) != nil {
return raw, string(raw), true
}
if content, has := m["content"]; has {
var items []struct {
Text string `json:"text"`
}
var e struct {
IsError bool `json:"isError"`
}
_ = json.Unmarshal(raw, &e)
if json.Unmarshal(content, &items) == nil && len(items) > 0 {
inner, out, ok := answerOf(json.RawMessage(items[0].Text))
return inner, out, ok && !e.IsError
}
}
var v struct {
Output string `json:"output"`
OK *bool `json:"ok"`
Answer json.RawMessage `json:"answer"`
}
if json.Unmarshal(raw, &v) == nil && v.OK != nil {
return v.Answer, v.Output, *v.OK
}
return raw, string(raw), true
}
func lastLine(s string) string {
lines := strings.Split(strings.TrimSpace(s), "\n")
return strings.TrimSpace(lines[len(lines)-1])
}
func (c seatController) verb(name string, args map[string]any) (json.RawMessage, error) {
raw, err := c.ask("seat:"+ControllerSeat+"."+name, args)
if err != nil {
return nil, err
}
answer, output, ok := answerOf(raw)
if !ok {
return nil, fmt.Errorf("the controller refused %s: %s", name, lastLine(output))
}
return answer, nil
}
// Plan asks the controller's `plans` for the plan of a change as data (novox/hq ADR 0450 §2): the document the old
// per-change verb answered, now the what-if of the plans every person reads as text.
func (c seatController) Plan(repository, base, head string, paths, moduleDirs, removed []string) (*DeliveryPlan, error) {
args := map[string]any{"repository": repository, "paths": strings.Join(paths, ","), "data": "true"}
if base != "" {
args["base"] = base
}
if head != "" {
args["head"] = head
}
if len(moduleDirs) > 0 {
args["module-dirs"] = strings.Join(moduleDirs, ",")
}
if len(removed) > 0 {
args["removed"] = strings.Join(removed, ",")
}
raw, err := c.verb("plans", args)
if err != nil {
return nil, err
}
var a struct {
Plan *DeliveryPlan `json:"plan"`
}
if err := json.Unmarshal(raw, &a); err != nil || a.Plan == nil {
return nil, errors.New("the controller's plans answered no plan as data")
}
return a.Plan, nil
}
func (c seatController) Check(group string, members []Member) (string, error) {
args := map[string]any{"members": string(mustJSON(members))}
if group != "" {
args["group"] = group
}
raw, err := c.verb("delivery-check", args)
if err != nil {
return "", err
}
var a struct {
Asked string `json:"asked"`
Rechecked string `json:"rechecked"`
}
_ = json.Unmarshal(raw, &a)
if a.Asked != "" {
return a.Asked, nil
}
if a.Rechecked != "" {
return a.Rechecked, nil
}
return "", fmt.Errorf("the controller's delivery-check answered no check id (%s): it is not taken as asked", clip(string(raw), 200))
}
func (c seatController) Deliver(walk, why string) error {
_, err := c.verb("deliver", map[string]any{"plan": walk, "why": why})
return err
}
func (c seatController) BackupsAgain(walk, why string) error {
_, err := c.verb("delivery-backups-again", map[string]any{"plan": walk, "why": why})
return err
}
func (c seatController) ForgetChecks(owner, repo string, number int) error {
_, err := c.verb("forget-checks", map[string]any{"owner": owner, "repo": repo, "number": strconv.Itoa(number)})
return err
}
func (c seatController) Stop(walk, why, by string) error {
_, err := c.verb("delivery-stop", map[string]any{"plan": walk, "why": why, "by": by})
return err
}
// StopAnswer is what the controller's `delivery-stop` with merges to leave out answers (novox/hq ADR 0299): the walk
// stopped, the batch that walks its other merges again with those merges, and every merge left out.
type StopAnswer struct {
Stopped string `json:"stopped"`
WalkedAgainBy string `json:"walked_again_by,omitempty"`
WalkedAgain []string `json:"walked_again,omitempty"`
LeftOut []WalkLeftOut `json:"left_out,omitempty"`
// Kept are the modules the stopped walk sent that nothing walks again, on the machines named; Withheld the
// modules the walk that walks the others withholds; StillCarriedBy the merges elsewhere that will deliver a
// stopped change when walked — a later merge of its repository, or one moving its modules (ADR 0299).
Kept []WalkKept `json:"kept,omitempty"`
Withheld []string `json:"withheld,omitempty"`
StillCarriedBy []string `json:"still_carried_by,omitempty"`
}
// WalkKept is a module a stopped walk left running its build, and where.
type WalkKept struct {
Module string `json:"module"`
Machines []string `json:"machines"`
}
func (c seatController) StopWithout(walk, why, by string, without []string) (StopAnswer, error) {
raw, err := c.verb("delivery-stop", map[string]any{"plan": walk, "why": why, "by": by,
"without": strings.Join(without, ",")})
if err != nil {
return StopAnswer{}, err
}
var a StopAnswer
if json.Unmarshal(raw, &a) != nil || a.Stopped == "" {
return StopAnswer{}, fmt.Errorf("the controller's delivery-stop did not say what it left out: %s",
lastLine(string(raw)))
}
return a, nil
}
func (c seatController) Walks(walk string) (bool, []Walk, error) {
args := map[string]any{}
if walk != "" {
args["plan"] = walk
}
raw, err := c.verb("delivery-walks", args)
if err != nil {
return false, nil, err
}
var a struct {
Held bool `json:"held"`
Walks []Walk `json:"walks"`
}
if err := json.Unmarshal(raw, &a); err != nil {
return false, nil, fmt.Errorf("the controller's delivery-walks is not readable: %w", err)
}
return a.Held, a.Walks, nil
}
// toolForge asks the forge's holder through its tools.
type toolForge struct{ ask asking }
func (f toolForge) tool(name string, args map[string]any) error {
raw, err := f.ask("gitea."+name, args)
if err != nil {
return err
}
if _, output, ok := answerOf(raw); !ok {
return fmt.Errorf("the forge refused %s: %s", name, lastLine(output))
}
return nil
}
func (f toolForge) Statuses(owner, repo string, number int, commit, base string) (*ForgeChecks, error) {
args := map[string]any{"owner": owner, "repo": repo}
if number > 0 {
args["number"] = number
}
if commit != "" {
args["sha"] = commit
}
if base != "" {
args["base"] = base
}
raw, err := f.ask("gitea.gitea_commit_statuses", args)
if err != nil {
return nil, err
}
answer, output, ok := answerOf(raw)
if !ok {
return nil, fmt.Errorf("the forge refused gitea_commit_statuses: %s", lastLine(output))
}
var c ForgeChecks
if err := json.Unmarshal(answer, &c); err != nil || c.Commit == "" {
return nil, fmt.Errorf("the forge's gitea_commit_statuses is not readable: %s", clip(string(answer), 200))
}
return &c, nil
}
func (f toolForge) OpenPulls() ([]OpenPull, error) {
raw, err := f.ask("gitea.gitea_open_pulls", map[string]any{})
if err != nil {
return nil, err
}
answer, output, ok := answerOf(raw)
if !ok {
return nil, fmt.Errorf("the forge refused gitea_open_pulls: %s", lastLine(output))
}
var open struct {
Pulls []OpenPull `json:"pulls"`
}
if err := json.Unmarshal(answer, &open); err != nil || open.Pulls == nil {
return nil, fmt.Errorf("the forge's gitea_open_pulls is not readable: %s", clip(string(answer), 200))
}
return open.Pulls, nil
}
func (f toolForge) Contains(owner, repo, commit, in string) (bool, error) {
raw, err := f.ask("gitea.gitea_contains", map[string]any{"owner": owner, "repo": repo, "commit": commit, "in": in})
if err != nil {
return false, err
}
answer, output, ok := answerOf(raw)
if !ok {
return false, fmt.Errorf("the forge refused gitea_contains: %s", lastLine(output))
}
var a struct {
Contains *bool `json:"contains"`
}
if err := json.Unmarshal(answer, &a); err != nil || a.Contains == nil {
return false, fmt.Errorf("the forge's gitea_contains is not readable: %s", clip(string(answer), 200))
}
return *a.Contains, nil
}
// kvStore is the module's own state: `deliveries` and `groups`, one key each.
type kvStore struct{}
// kvKey is an id as a bucket key: only the characters a key may carry.
func kvKey(id string) string {
var b strings.Builder
for _, r := range id {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-', r == '_', r == '=':
b.WriteRune(r)
default:
b.WriteString("_" + fmt.Sprintf("%02x", r) + "_")
}
}
return b.String()
}
func (kvStore) PutDelivery(d *Delivery) error {
_, err := stdio.State("deliveries").Put(kvKey(d.ID), d)
return err
}
func (kvStore) DeleteDelivery(id string) error { return stdio.State("deliveries").Delete(kvKey(id)) }
func (kvStore) PutGroup(g *Group) error {
_, err := stdio.State("groups").Put(kvKey(g.ID), g)
return err
}
func (kvStore) DeleteGroup(id string) error { return stdio.State("groups").Delete(kvKey(id)) }
// readAll reads every key of a state but the ones it is told to leave (the gate's mark `carried` is no delivery).
func readAll[T any](state string, leave ...string) ([]*T, error) {
keys, err := stdio.State(state).Keys()
if err != nil {
return nil, err
}
sort.Strings(keys)
var out []*T
for _, k := range keys {
if slices.Contains(leave, k) {
continue
}
e, err := stdio.State(state).Get(k)
if err != nil {
return nil, err
}
if e == nil {
continue
}
var v T
if err := json.Unmarshal(e.Value, &v); err != nil {
logf("[mesh-delivery] %s %s cannot be read (%v); left as it is", state, k, err)
continue
}
out = append(out, &v)
}
return out, nil
}
func (kvStore) Deliveries() ([]*Delivery, error) { return readAll[Delivery]("deliveries", carriedKey) }
func (kvStore) Groups() ([]*Group, error) { return readAll[Group]("groups") }