Draw a plan's order per module from the graph and the manifests' change, and read no after: line (issue 977, part B, ADR 0419)
This commit is contained in:
@@ -0,0 +1,246 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"path"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
)
|
||||
|
||||
// What a change says of the manifests it touches, read from the forge (novox/hq ADR 0419 clause 3, the lead's settlement
|
||||
// of issue 977 question 1): the controller asks the forge for each changed module.json at the commit and at what it
|
||||
// replaced, once per commit. The composed check's result is not used: the plan is cut after the merge.
|
||||
|
||||
// changeSource is one change to a repository whose manifests are read: the commit after it and, when known, what it
|
||||
// replaced (a pull request's base commit or branch). An empty Old is the first parent of New: the merge's base.
|
||||
type changeSource struct {
|
||||
Owner, Repo string
|
||||
New, Old string
|
||||
Paths []string
|
||||
Truncated bool
|
||||
Removed []string
|
||||
}
|
||||
|
||||
// manifestAt reads a repository's module.json at a ref from the forge; the bool says it exists there. A variable so a
|
||||
// test needs no forge.
|
||||
var manifestAt = func(ctx context.Context, owner, repo, file, ref string) (catalogue.Manifest, bool, error) {
|
||||
var found struct {
|
||||
Result struct {
|
||||
Content string `json:"content"`
|
||||
Encoding string `json:"encoding"`
|
||||
} `json:"result"`
|
||||
}
|
||||
missing, err := askForgeApi(ctx, "/repos/"+owner+"/"+repo+"/contents/"+escapedPath(file)+"?ref="+url.QueryEscape(ref), &found)
|
||||
if err != nil {
|
||||
return catalogue.Manifest{}, false, err
|
||||
}
|
||||
if missing {
|
||||
return catalogue.Manifest{}, false, nil
|
||||
}
|
||||
body := []byte(found.Result.Content)
|
||||
if found.Result.Encoding == "base64" {
|
||||
if body, err = base64.StdEncoding.DecodeString(strings.ReplaceAll(found.Result.Content, "\n", "")); err != nil {
|
||||
return catalogue.Manifest{}, false, fmt.Errorf("%s/%s %s at %s cannot be read: %w", owner, repo, file, ref, err)
|
||||
}
|
||||
}
|
||||
var m catalogue.Manifest
|
||||
if err := json.Unmarshal(body, &m); err != nil {
|
||||
return catalogue.Manifest{}, false, fmt.Errorf("%s/%s %s at %s is not a manifest: %w", owner, repo, file, ref, err)
|
||||
}
|
||||
return m, true, nil
|
||||
}
|
||||
|
||||
// parentOf is the first parent of a commit, from the forge. A variable so a test needs no forge.
|
||||
var parentOf = func(ctx context.Context, owner, repo, commit string) (string, error) {
|
||||
var found struct {
|
||||
Result struct {
|
||||
Parents []struct {
|
||||
SHA string `json:"sha"`
|
||||
} `json:"parents"`
|
||||
} `json:"result"`
|
||||
}
|
||||
if _, err := askForgeApi(ctx, "/repos/"+owner+"/"+repo+"/git/commits/"+url.PathEscape(commit), &found); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(found.Result.Parents) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
return found.Result.Parents[0].SHA, nil
|
||||
}
|
||||
|
||||
func escapedPath(p string) string {
|
||||
parts := strings.Split(p, "/")
|
||||
for i := range parts {
|
||||
parts[i] = url.PathEscape(parts[i])
|
||||
}
|
||||
return strings.Join(parts, "/")
|
||||
}
|
||||
|
||||
// askForgeApi makes one read of the forge's API through its module's tool on the bus. It answers true when the forge
|
||||
// says there is no such thing (404), and an error for anything else it cannot answer.
|
||||
func askForgeApi(ctx context.Context, apiPath string, into any) (bool, error) {
|
||||
js, err := aBus()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer js.Close()
|
||||
raw, _ := json.Marshal(map[string]any{"path": apiPath})
|
||||
answer, err := link.Ask(ctx, link.OverNATS{Conn: js.Conn()}, "gitea", "gitea_api", raw, 30*time.Second)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if answer.Error != "" {
|
||||
if strings.Contains(answer.Error, " 404 ") {
|
||||
return true, nil
|
||||
}
|
||||
return false, fmt.Errorf("the forge did not answer %s: %s", apiPath, answer.Error)
|
||||
}
|
||||
if err := json.Unmarshal(answer.Result, into); err != nil {
|
||||
return false, fmt.Errorf("the forge's answer to %s cannot be read: %w", apiPath, err)
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// manifestsRead keeps what the forge said of a manifest at a commit, which never changes: one ask per commit.
|
||||
var (
|
||||
manifestsRead = map[string]manifestRead{}
|
||||
manifestsReadMu sync.Mutex
|
||||
)
|
||||
|
||||
type manifestRead struct {
|
||||
manifest catalogue.Manifest
|
||||
exists bool
|
||||
}
|
||||
|
||||
func manifestCached(ctx context.Context, owner, repo, file, ref string) (catalogue.Manifest, bool, error) {
|
||||
key := strings.Join([]string{owner, repo, file, ref}, "\x00")
|
||||
if len(ref) == 40 {
|
||||
manifestsReadMu.Lock()
|
||||
got, ok := manifestsRead[key]
|
||||
manifestsReadMu.Unlock()
|
||||
if ok {
|
||||
return got.manifest, got.exists, nil
|
||||
}
|
||||
}
|
||||
m, exists, err := manifestAt(ctx, owner, repo, file, ref)
|
||||
if err == nil && len(ref) == 40 {
|
||||
manifestsReadMu.Lock()
|
||||
if len(manifestsRead) > 512 {
|
||||
clear(manifestsRead)
|
||||
}
|
||||
manifestsRead[key] = manifestRead{m, exists}
|
||||
manifestsReadMu.Unlock()
|
||||
}
|
||||
return m, exists, err
|
||||
}
|
||||
|
||||
// verbsServed are the seat verbs a manifest serves, as "<seat>.<verb>": those of the seats it defines, those of the
|
||||
// seats it claims, and its own tools as "<module>.<tool>".
|
||||
func verbsServed(m catalogue.Manifest) []string {
|
||||
var out []string
|
||||
for _, s := range m.DefinesSeats {
|
||||
for _, v := range s.Serves {
|
||||
out = appendNew(out, s.Name+"."+v.Name)
|
||||
}
|
||||
}
|
||||
for _, c := range m.Claims {
|
||||
for _, v := range c.ServesFor(m) {
|
||||
out = appendNew(out, c.Name+"."+v)
|
||||
}
|
||||
}
|
||||
for _, t := range m.Tools {
|
||||
out = appendNew(out, m.Module+"."+t)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// verbsInvoked are the verbs a manifest calls, as "<seat>.<verb>" or "<module>.<tool>".
|
||||
func verbsInvoked(m catalogue.Manifest) []string {
|
||||
var out []string
|
||||
for _, i := range m.Invokes {
|
||||
if i = strings.TrimPrefix(i, "seat:"); i != "*" && i != "" {
|
||||
out = appendNew(out, i)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func added(now, was []string) []string {
|
||||
var out []string
|
||||
for _, v := range now {
|
||||
if !slices.Contains(was, v) {
|
||||
out = append(out, v)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// changesOf reads, for each manifest the changes touch, what they add to the verbs its module serves and invokes, by
|
||||
// module. A change whose paths are unsaid or truncated names no manifest: nothing is read for it. An error is the
|
||||
// forge's, never read as "no change": the plan is not cut on a guess.
|
||||
func changesOf(ctx context.Context, sources []changeSource) (map[string]moduleChange, error) {
|
||||
out := map[string]moduleChange{}
|
||||
for _, s := range sources {
|
||||
if s.Truncated {
|
||||
continue
|
||||
}
|
||||
for _, p := range s.Paths {
|
||||
if path.Base(p) != "module.json" || slices.Contains(s.Removed, p) {
|
||||
continue
|
||||
}
|
||||
now, exists, err := manifestCached(ctx, s.Owner, s.Repo, p, s.New)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !exists || now.Module == "" {
|
||||
continue
|
||||
}
|
||||
old := s.Old
|
||||
if old == "" {
|
||||
if old, err = parentOf(ctx, s.Owner, s.Repo, s.New); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
var was catalogue.Manifest
|
||||
if old != "" {
|
||||
if was, _, err = manifestCached(ctx, s.Owner, s.Repo, p, old); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
c := out[now.Module]
|
||||
c.Manifest = true
|
||||
for _, v := range added(verbsServed(now), verbsServed(was)) {
|
||||
c.Serves = appendNew(c.Serves, v)
|
||||
}
|
||||
for _, v := range added(verbsInvoked(now), verbsInvoked(was)) {
|
||||
c.Invokes = appendNew(c.Invokes, v)
|
||||
}
|
||||
out[now.Module] = c
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// changeSourcesOf are a group's members as changes to read: each head over what its pull request merges into.
|
||||
func changeSourcesOf(members []orderMember) []changeSource {
|
||||
var out []changeSource
|
||||
for _, m := range members {
|
||||
p := m.pull()
|
||||
old := p.BaseSHA
|
||||
if old == "" {
|
||||
old = p.Base
|
||||
}
|
||||
out = append(out, changeSource{Owner: p.Owner, Repo: p.Repo, New: p.Commit, Old: old, Paths: p.Paths,
|
||||
Truncated: p.PathsTruncated, Removed: p.Removed})
|
||||
}
|
||||
return out
|
||||
}
|
||||
Reference in New Issue
Block a user