Files
mesh-controller/cmd/mesh-controller/delivery_changes.go
T
mesh-tools 013dad4688 Take the catalogue, overlay and validator from mesh-sdk in cmd/mesh-builder and cmd/mesh-controller, a to g (issue 1381, 1 of 6 import swaps)
Swaps three import paths for mesh-sdk's go/catalogue, go/overlay and go/validate (the same packages, copied from this repository at 6c2a8d62) and lets gofmt order the groups. The six swap commits compile together once the pin commit (go.mod, modules.txt) is on the branch; each alone does not, because a file on the SDK's types cannot call one still on the old package.
2026-10-20 08:22:07 +00:00

272 lines
8.6 KiB
Go

package main
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"net/url"
"path"
"slices"
"strings"
"sync"
"time"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"github.com/novox/mesh-controller/internal/inventory"
"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
// Since is the first commit of the repository in the change, when the change is several merges: what it replaced
// is the first parent of that one, so a verb an earlier merge added is added by the change too.
Since 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 == "" {
first := s.Since
if first == "" {
first = s.New
}
if old, err = parentOf(ctx, s.Owner, s.Repo, first); 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
}
// truncatedPaths are the manifests a change that names only a beginning of its files is read as changing: those of the
// modules it moves that are built from its repository. Said, never skipped: no edge may be lost to a long list.
func truncatedPaths(entries []inventory.Entry, owner, repo string, moved []string) []string {
var out []string
for _, e := range entries {
if slices.Contains(moved, e.Manifest.Module) && sameRepository(e.Source.Repository, link.SourceMoved{Owner: owner, Repo: repo}) {
out = append(out, path.Join(strings.Trim(e.Source.Path, "/"), moduleManifestFile))
}
}
fmt.Printf(" %s/%s changed more files than were listed: the manifests of its modules (%s) are read as changed\n",
owner, repo, strings.Join(out, ", "))
return out
}
// changeSourcesOf are a group's members as changes to read: each head over what its pull request merges into.
func changeSourcesOf(members []orderMember, entries []inventory.Entry, reach map[string]orderReach) []changeSource {
var out []changeSource
for _, m := range members {
p := m.pull()
if p.PathsTruncated {
p.Paths, p.PathsTruncated = truncatedPaths(entries, p.Owner, p.Repo, reach[m.ID].Moved), false
}
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
}