mirror of
https://gitea.com/Lydanne/buildx.git
synced 2025-05-18 00:47:48 +08:00
404 lines
9.1 KiB
Go
404 lines
9.1 KiB
Go
package history
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/csv"
|
|
"fmt"
|
|
"io"
|
|
"path/filepath"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/docker/buildx/build"
|
|
"github.com/docker/buildx/builder"
|
|
"github.com/docker/buildx/localstate"
|
|
controlapi "github.com/moby/buildkit/api/services/control"
|
|
"github.com/moby/buildkit/util/gitutil"
|
|
"github.com/pkg/errors"
|
|
"golang.org/x/sync/errgroup"
|
|
)
|
|
|
|
const recordsLimit = 50
|
|
|
|
func buildName(fattrs map[string]string, ls *localstate.State) string {
|
|
var res string
|
|
|
|
var target, contextPath, dockerfilePath, vcsSource string
|
|
if v, ok := fattrs["target"]; ok {
|
|
target = v
|
|
}
|
|
if v, ok := fattrs["context"]; ok {
|
|
contextPath = filepath.ToSlash(v)
|
|
} else if v, ok := fattrs["vcs:localdir:context"]; ok && v != "." {
|
|
contextPath = filepath.ToSlash(v)
|
|
}
|
|
if v, ok := fattrs["vcs:source"]; ok {
|
|
vcsSource = v
|
|
}
|
|
if v, ok := fattrs["filename"]; ok && v != "Dockerfile" {
|
|
dockerfilePath = filepath.ToSlash(v)
|
|
}
|
|
if v, ok := fattrs["vcs:localdir:dockerfile"]; ok && v != "." {
|
|
dockerfilePath = filepath.ToSlash(filepath.Join(v, dockerfilePath))
|
|
}
|
|
|
|
var localPath string
|
|
if ls != nil && !build.IsRemoteURL(ls.LocalPath) {
|
|
if ls.LocalPath != "" && ls.LocalPath != "-" {
|
|
localPath = filepath.ToSlash(ls.LocalPath)
|
|
}
|
|
if ls.DockerfilePath != "" && ls.DockerfilePath != "-" && ls.DockerfilePath != "Dockerfile" {
|
|
dockerfilePath = filepath.ToSlash(ls.DockerfilePath)
|
|
}
|
|
}
|
|
|
|
// remove default dockerfile name
|
|
const defaultFilename = "/Dockerfile"
|
|
hasDefaultFileName := strings.HasSuffix(dockerfilePath, defaultFilename) || dockerfilePath == ""
|
|
dockerfilePath = strings.TrimSuffix(dockerfilePath, defaultFilename)
|
|
|
|
// dockerfile is a subpath of context
|
|
if strings.HasPrefix(dockerfilePath, localPath) && len(dockerfilePath) > len(localPath) {
|
|
res = dockerfilePath[strings.LastIndex(localPath, "/")+1:]
|
|
} else {
|
|
// Otherwise, use basename
|
|
bpath := localPath
|
|
if len(dockerfilePath) > 0 {
|
|
bpath = dockerfilePath
|
|
}
|
|
if len(bpath) > 0 {
|
|
lidx := strings.LastIndex(bpath, "/")
|
|
res = bpath[lidx+1:]
|
|
if !hasDefaultFileName {
|
|
if lidx != -1 {
|
|
res = filepath.ToSlash(filepath.Join(filepath.Base(bpath[:lidx]), res))
|
|
} else {
|
|
res = filepath.ToSlash(filepath.Join(filepath.Base(bpath), res))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if len(contextPath) > 0 {
|
|
res = contextPath
|
|
}
|
|
if len(target) > 0 {
|
|
if len(res) > 0 {
|
|
res = res + " (" + target + ")"
|
|
} else {
|
|
res = target
|
|
}
|
|
}
|
|
if res == "" && vcsSource != "" {
|
|
return vcsSource
|
|
}
|
|
return res
|
|
}
|
|
|
|
func trimBeginning(s string, n int) string {
|
|
if len(s) <= n {
|
|
return s
|
|
}
|
|
return ".." + s[len(s)-n+2:]
|
|
}
|
|
|
|
type historyRecord struct {
|
|
*controlapi.BuildHistoryRecord
|
|
currentTimestamp *time.Time
|
|
node *builder.Node
|
|
name string
|
|
}
|
|
|
|
type queryOptions struct {
|
|
CompletedOnly bool
|
|
Filters []string
|
|
}
|
|
|
|
func queryRecords(ctx context.Context, ref string, nodes []builder.Node, opts *queryOptions) ([]historyRecord, error) {
|
|
var mu sync.Mutex
|
|
var out []historyRecord
|
|
|
|
var offset *int
|
|
if strings.HasPrefix(ref, "^") {
|
|
off, err := strconv.Atoi(ref[1:])
|
|
if err != nil {
|
|
return nil, errors.Wrapf(err, "invalid offset %q", ref)
|
|
}
|
|
offset = &off
|
|
ref = ""
|
|
}
|
|
|
|
var filters []string
|
|
if opts != nil {
|
|
filters = opts.Filters
|
|
}
|
|
|
|
eg, ctx := errgroup.WithContext(ctx)
|
|
for _, node := range nodes {
|
|
node := node
|
|
eg.Go(func() error {
|
|
if node.Driver == nil {
|
|
return nil
|
|
}
|
|
var records []historyRecord
|
|
c, err := node.Driver.Client(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
var matchers []matchFunc
|
|
if len(filters) > 0 {
|
|
filters, matchers, err = dockerFiltersToBuildkit(filters)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
sb := bytes.NewBuffer(nil)
|
|
w := csv.NewWriter(sb)
|
|
w.Write(filters)
|
|
w.Flush()
|
|
filters = []string{strings.TrimSuffix(sb.String(), "\n")}
|
|
}
|
|
|
|
serv, err := c.ControlClient().ListenBuildHistory(ctx, &controlapi.BuildHistoryRequest{
|
|
EarlyExit: true,
|
|
Ref: ref,
|
|
Limit: recordsLimit,
|
|
Filter: filters,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
md, err := serv.Header()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var ts *time.Time
|
|
if v, ok := md[headerKeyTimestamp]; ok {
|
|
t, err := time.Parse(time.RFC3339Nano, v[0])
|
|
if err != nil {
|
|
return err
|
|
}
|
|
ts = &t
|
|
}
|
|
defer serv.CloseSend()
|
|
loop0:
|
|
for {
|
|
he, err := serv.Recv()
|
|
if err != nil {
|
|
if errors.Is(err, io.EOF) {
|
|
break
|
|
}
|
|
return err
|
|
}
|
|
if he.Type == controlapi.BuildHistoryEventType_DELETED || he.Record == nil {
|
|
continue
|
|
}
|
|
if opts != nil && opts.CompletedOnly && he.Type != controlapi.BuildHistoryEventType_COMPLETE {
|
|
continue
|
|
}
|
|
|
|
// for older buildkit that don't support filters apply local filters
|
|
for _, matcher := range matchers {
|
|
if !matcher(he.Record) {
|
|
continue loop0
|
|
}
|
|
}
|
|
|
|
records = append(records, historyRecord{
|
|
BuildHistoryRecord: he.Record,
|
|
currentTimestamp: ts,
|
|
node: &node,
|
|
})
|
|
}
|
|
mu.Lock()
|
|
out = append(out, records...)
|
|
mu.Unlock()
|
|
return nil
|
|
})
|
|
}
|
|
|
|
if err := eg.Wait(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
slices.SortFunc(out, func(a, b historyRecord) int {
|
|
return b.CreatedAt.AsTime().Compare(a.CreatedAt.AsTime())
|
|
})
|
|
|
|
if offset != nil {
|
|
var filtered []historyRecord
|
|
for _, r := range out {
|
|
if *offset > 0 {
|
|
*offset--
|
|
continue
|
|
}
|
|
filtered = append(filtered, r)
|
|
break
|
|
}
|
|
if *offset > 0 {
|
|
return nil, errors.Errorf("no completed build found with offset %d", *offset)
|
|
}
|
|
out = filtered
|
|
}
|
|
|
|
return out, nil
|
|
}
|
|
|
|
func formatDuration(d time.Duration) string {
|
|
if d < time.Minute {
|
|
return fmt.Sprintf("%.1fs", d.Seconds())
|
|
}
|
|
return fmt.Sprintf("%dm %2ds", int(d.Minutes()), int(d.Seconds())%60)
|
|
}
|
|
|
|
type matchFunc func(*controlapi.BuildHistoryRecord) bool
|
|
|
|
func dockerFiltersToBuildkit(in []string) ([]string, []matchFunc, error) {
|
|
out := []string{}
|
|
matchers := []matchFunc{}
|
|
for _, f := range in {
|
|
key, value, sep, found := cutAny(f, "!=", "=", "<=", "<", ">=", ">")
|
|
if !found {
|
|
return nil, nil, errors.Errorf("invalid filter %q", f)
|
|
}
|
|
switch key {
|
|
case "ref", "repository", "status":
|
|
if sep != "=" && sep != "!=" {
|
|
return nil, nil, errors.Errorf("invalid separator for %q, expected = or !=", f)
|
|
}
|
|
matchers = append(matchers, valueFiler(key, value, sep))
|
|
if sep == "=" {
|
|
if key == "status" {
|
|
sep = "=="
|
|
} else {
|
|
sep = "~="
|
|
}
|
|
}
|
|
case "startedAt", "completedAt", "duration":
|
|
if sep == "=" || sep == "!=" {
|
|
return nil, nil, errors.Errorf("invalid separator for %q, expected <=, <, >= or >", f)
|
|
}
|
|
matcher, err := timeBasedFilter(key, value, sep)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
matchers = append(matchers, matcher)
|
|
default:
|
|
return nil, nil, errors.Errorf("unsupported filter %q", f)
|
|
}
|
|
out = append(out, key+sep+value)
|
|
}
|
|
return out, matchers, nil
|
|
}
|
|
|
|
func valueFiler(key, value, sep string) matchFunc {
|
|
return func(rec *controlapi.BuildHistoryRecord) bool {
|
|
var recValue string
|
|
switch key {
|
|
case "ref":
|
|
recValue = rec.Ref
|
|
case "repository":
|
|
v, ok := rec.FrontendAttrs["vcs:source"]
|
|
if ok {
|
|
recValue = v
|
|
} else {
|
|
if context, ok := rec.FrontendAttrs["context"]; ok {
|
|
if ref, err := gitutil.ParseGitRef(context); err == nil {
|
|
recValue = ref.Remote
|
|
}
|
|
}
|
|
}
|
|
case "status":
|
|
if rec.CompletedAt != nil {
|
|
if rec.Error != nil {
|
|
if strings.Contains(rec.Error.Message, "context canceled") {
|
|
recValue = "canceled"
|
|
} else {
|
|
recValue = "error"
|
|
}
|
|
} else {
|
|
recValue = "completed"
|
|
}
|
|
} else {
|
|
recValue = "running"
|
|
}
|
|
}
|
|
switch sep {
|
|
case "=":
|
|
if key == "status" {
|
|
return recValue == value
|
|
}
|
|
return strings.Contains(recValue, value)
|
|
case "!=":
|
|
return recValue != value
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
}
|
|
|
|
func timeBasedFilter(key, value, sep string) (matchFunc, error) {
|
|
var cmp int64
|
|
switch key {
|
|
case "startedAt", "completedAt":
|
|
v, err := time.ParseDuration(value)
|
|
if err == nil {
|
|
tm := time.Now().Add(-v)
|
|
cmp = tm.Unix()
|
|
} else {
|
|
tm, err := time.Parse(time.RFC3339, value)
|
|
if err != nil {
|
|
return nil, errors.Errorf("invalid time %s", value)
|
|
}
|
|
cmp = tm.Unix()
|
|
}
|
|
case "duration":
|
|
v, err := time.ParseDuration(value)
|
|
if err != nil {
|
|
return nil, errors.Errorf("invalid duration %s", value)
|
|
}
|
|
cmp = int64(v)
|
|
default:
|
|
return nil, nil
|
|
}
|
|
|
|
return func(rec *controlapi.BuildHistoryRecord) bool {
|
|
var val int64
|
|
switch key {
|
|
case "startedAt":
|
|
val = rec.CreatedAt.AsTime().Unix()
|
|
case "completedAt":
|
|
if rec.CompletedAt != nil {
|
|
val = rec.CompletedAt.AsTime().Unix()
|
|
}
|
|
case "duration":
|
|
if rec.CompletedAt != nil {
|
|
val = int64(rec.CompletedAt.AsTime().Sub(rec.CreatedAt.AsTime()))
|
|
}
|
|
}
|
|
switch sep {
|
|
case ">=":
|
|
return val >= cmp
|
|
case "<=":
|
|
return val <= cmp
|
|
case ">":
|
|
return val > cmp
|
|
default:
|
|
return val < cmp
|
|
}
|
|
}, nil
|
|
}
|
|
|
|
func cutAny(s string, seps ...string) (before, after, sep string, found bool) {
|
|
for _, sep := range seps {
|
|
if idx := strings.Index(s, sep); idx != -1 {
|
|
return s[:idx], s[idx+len(sep):], sep, true
|
|
}
|
|
}
|
|
return s, "", "", false
|
|
}
|