160 lines
6.1 KiB
Go
160 lines
6.1 KiB
Go
package main
|
|
|
|
// The fetch step (novox/hq ADR 0487 §1, ADR 0466 §4 §5, ADR 0469 §2; issue 1703b): a commit of a repository of the
|
|
// mesh's forge, fetched over SSH as the module's own account into a workspace of its own. Nothing is downloaded
|
|
// beyond it: an image's Dockerfile fetches what it needs itself, inside the build.
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
|
|
"git.novox.be/novox/mesh-sdk/go/buildstep"
|
|
)
|
|
|
|
var (
|
|
commitOK = regexp.MustCompile(`^[0-9a-f]{40}$`)
|
|
repositoryOK = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*/[A-Za-z0-9][A-Za-z0-9._-]*$`)
|
|
workspaceOK = regexp.MustCompile(`^[a-z0-9]+$`)
|
|
)
|
|
|
|
// ForgeAlias is the ssh name the forge-account provision sets up in the account's ssh files (ADR 0409 §1); the
|
|
// remote of a fetch is ssh://<it>/<owner>/<repository>.git.
|
|
const ForgeAlias = "mesh-forge"
|
|
|
|
// WorkspaceAge is how long a workspace is kept: a module removes the workspaces older than a day, each time it
|
|
// fetches and every round it checks itself. A workspace lies in the module's work directory, never in its home
|
|
// (ADR 0466 §1).
|
|
const WorkspaceAge = 24 * time.Hour
|
|
|
|
// FetchResult is what a passed fetch step says: the workspace it made and the commit in it, and, only when the call
|
|
// gave a base, the merge preview of ADR 0484 clause 3 (the trunk commit, the merge commit of the head onto it, the
|
|
// files in conflict, the paths the merge changes and the tree of each module directory). A head that does not
|
|
// merge cleanly is an answer: Merge is empty, Conflicts names the files, and the workspace is left at the trunk.
|
|
type FetchResult struct {
|
|
Workspace string `json:"workspace"`
|
|
Commit string `json:"commit"`
|
|
Base string `json:"base,omitempty"`
|
|
Merge string `json:"merge,omitempty"`
|
|
Conflicts []string `json:"conflicts,omitempty"`
|
|
Changed []string `json:"changed,omitempty"`
|
|
Trees map[string]buildstep.ModuleTree `json:"trees,omitempty"`
|
|
}
|
|
|
|
func (m *Module) fetchTool() StepTool {
|
|
return StepTool{
|
|
Name: FetchTool,
|
|
Description: "Fetch a commit of a repository of the mesh's forge over SSH, as this module's own account, into a " +
|
|
"workspace of its own. Answers a step id at once; the step ends on its key in the steps state with the " +
|
|
"workspace's id.",
|
|
Input: map[string]any{
|
|
"type": "object",
|
|
"properties": map[string]any{
|
|
"repository": map[string]any{"type": "string", "description": "<owner>/<repository> on the forge"},
|
|
"commit": map[string]any{"type": "string", "description": "the full commit, 40 hex digits"},
|
|
"ref": map[string]any{"type": "string", "description": "refs/pull/<n>/head when only a pull request's head carries the commit"},
|
|
"base": map[string]any{"type": "string", "description": "optional trunk commit, 40 hex digits: the head is merged onto it as the forge's merge would, and the answer carries the merge commit, the files in conflict (an answer, the workspace is then left at the trunk commit), the changed paths and the tree of each module directory"},
|
|
},
|
|
"required": []string{"repository", "commit"},
|
|
},
|
|
Long: true,
|
|
Run: m.fetch,
|
|
}
|
|
}
|
|
|
|
func (m *Module) fetch(ctx context.Context, args map[string]any) (any, error) {
|
|
repository, _ := args["repository"].(string)
|
|
commit, _ := args["commit"].(string)
|
|
ref, _ := args["ref"].(string)
|
|
base, _ := args["base"].(string)
|
|
if !repositoryOK.MatchString(repository) || strings.Contains(repository, "..") {
|
|
return nil, fmt.Errorf("%q is not a repository, <owner>/<name>", repository)
|
|
}
|
|
if !commitOK.MatchString(commit) {
|
|
return nil, fmt.Errorf("%q is not a full commit (40 hex digits)", commit)
|
|
}
|
|
if base != "" && !commitOK.MatchString(base) {
|
|
return nil, fmt.Errorf("%q is not a trunk commit (40 hex digits)", base)
|
|
}
|
|
m.CleanWorkspaces(m.clock())
|
|
id := buildstep.NewID()
|
|
tree := filepath.Join(m.Work, id, "tree")
|
|
if err := os.MkdirAll(tree, 0o700); err != nil {
|
|
return nil, err
|
|
}
|
|
remote := m.remote(repository)
|
|
if err := buildstep.FetchCommit(ctx, m.Run, tree, remote, commit, ref, m.FetchOptions...); err != nil {
|
|
return nil, err
|
|
}
|
|
res := FetchResult{Workspace: id, Commit: commit}
|
|
if base != "" {
|
|
if err := m.merge(ctx, &res, tree, remote, base, commit); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
return res, nil
|
|
}
|
|
|
|
// remote is the address a repository is fetched from: the forge over ssh as the account, or RemoteBase when a test set it.
|
|
func (m *Module) remote(repository string) string {
|
|
base := m.RemoteBase
|
|
if base == "" {
|
|
base = "ssh://" + ForgeAlias + "/"
|
|
}
|
|
return base + repository + ".git"
|
|
}
|
|
|
|
// merge makes the merge preview of the head onto base (the SDK's buildstep.MergeInto, shared by every build module)
|
|
// and writes the answer into res.
|
|
func (m *Module) merge(ctx context.Context, res *FetchResult, tree, remote, base, commit string) error {
|
|
mr, err := buildstep.MergeInto(ctx, m.Run, tree, remote, base, commit, m.FetchOptions...)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
res.Base = mr.Base
|
|
res.Conflicts, res.Merge, res.Changed = mr.Conflicts, mr.Merge, mr.Changed
|
|
if res.Merge == "" {
|
|
return nil
|
|
}
|
|
res.Trees, err = buildstep.ModuleTrees(ctx, m.Run, nil, tree, res.Merge)
|
|
return err
|
|
}
|
|
|
|
// CleanWorkspaces removes the workspaces older than WorkspaceAge from the work directory.
|
|
func (m *Module) CleanWorkspaces(now time.Time) {
|
|
entries, err := os.ReadDir(m.Work)
|
|
if err != nil {
|
|
return
|
|
}
|
|
for _, e := range entries {
|
|
info, err := e.Info()
|
|
if err != nil || !e.IsDir() || !workspaceOK.MatchString(e.Name()) || now.Sub(info.ModTime()) < WorkspaceAge {
|
|
continue
|
|
}
|
|
if err := os.RemoveAll(filepath.Join(m.Work, e.Name())); err != nil && m.Say != nil {
|
|
m.Say("the workspace %s could not be removed: %v", e.Name(), err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// clock is the time now: Now when a test set it, else the wall clock.
|
|
func (m *Module) clock() time.Time {
|
|
if m.Now != nil {
|
|
return m.Now()
|
|
}
|
|
return time.Now()
|
|
}
|
|
|
|
func firstLine(s string) string {
|
|
for _, l := range strings.Split(s, "\n") {
|
|
if l = strings.TrimSpace(l); l != "" {
|
|
return l
|
|
}
|
|
}
|
|
return ""
|
|
}
|