diff --git a/commands/history/ls.go b/commands/history/ls.go index 5a6e9d4e9095..c902a3434629 100644 --- a/commands/history/ls.go +++ b/commands/history/ls.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "os" + "path" "slices" "time" @@ -14,10 +15,12 @@ import ( "github.com/docker/buildx/util/cobrautil/completion" "github.com/docker/buildx/util/confutil" "github.com/docker/buildx/util/desktop" + "github.com/docker/buildx/util/gitutil" "github.com/docker/cli/cli" "github.com/docker/cli/cli/command" "github.com/docker/cli/cli/command/formatter" "github.com/docker/go-units" + "github.com/pkg/errors" "github.com/spf13/cobra" ) @@ -38,6 +41,9 @@ type lsOptions struct { builder string format string noTrunc bool + + filters []string + local bool } func runLs(ctx context.Context, dockerCli command.Cli, opts lsOptions) error { @@ -56,7 +62,29 @@ func runLs(ctx context.Context, dockerCli command.Cli, opts lsOptions) error { } } - out, err := queryRecords(ctx, "", nodes, nil) + queryOptions := &queryOptions{} + + if opts.local { + wd, err := os.Getwd() + if err != nil { + return err + } + gitc, err := gitutil.New(gitutil.WithContext(ctx), gitutil.WithWorkingDir(wd)) + if err != nil { + if st, err1 := os.Stat(path.Join(wd, ".git")); err1 == nil && st.IsDir() { + return errors.Wrap(err, "git was not found in the system") + } + return errors.Wrapf(err, "could not find git repository for local filter") + } + remote, err := gitc.RemoteURL() + if err != nil { + return errors.Wrapf(err, "could not get remote URL for local filter") + } + queryOptions.Filters = append(queryOptions.Filters, fmt.Sprintf("repository=%s", remote)) + } + queryOptions.Filters = append(queryOptions.Filters, opts.filters...) + + out, err := queryRecords(ctx, "", nodes, queryOptions) if err != nil { return err } @@ -92,6 +120,8 @@ func lsCmd(dockerCli command.Cli, rootOpts RootOptions) *cobra.Command { flags := cmd.Flags() flags.StringVar(&options.format, "format", formatter.TableFormatKey, "Format the output") flags.BoolVar(&options.noTrunc, "no-trunc", false, "Don't truncate output") + flags.StringArrayVar(&options.filters, "filter", nil, `Provide filter values (e.g., "status=error")`) + flags.BoolVar(&options.local, "local", false, "List records for current repository only") return cmd } diff --git a/commands/history/utils.go b/commands/history/utils.go index c74b7c7c63b1..95c8702e15f4 100644 --- a/commands/history/utils.go +++ b/commands/history/utils.go @@ -1,7 +1,9 @@ package history import ( + "bytes" "context" + "encoding/csv" "fmt" "io" "path/filepath" @@ -15,10 +17,13 @@ import ( "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 @@ -110,6 +115,7 @@ type historyRecord struct { type queryOptions struct { CompletedOnly bool + Filters []string } func queryRecords(ctx context.Context, ref string, nodes []builder.Node, opts *queryOptions) ([]historyRecord, error) { @@ -126,6 +132,11 @@ func queryRecords(ctx context.Context, ref string, nodes []builder.Node, opts *q ref = "" } + var filters []string + if opts != nil { + filters = opts.Filters + } + eg, ctx := errgroup.WithContext(ctx) for _, node := range nodes { node := node @@ -138,9 +149,25 @@ func queryRecords(ctx context.Context, ref string, nodes []builder.Node, opts *q 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 @@ -158,6 +185,7 @@ func queryRecords(ctx context.Context, ref string, nodes []builder.Node, opts *q ts = &t } defer serv.CloseSend() + loop0: for { he, err := serv.Recv() if err != nil { @@ -173,6 +201,13 @@ func queryRecords(ctx context.Context, ref string, nodes []builder.Node, opts *q 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, @@ -219,3 +254,150 @@ func formatDuration(d time.Duration) string { } 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 +} diff --git a/docs/reference/buildx_history_ls.md b/docs/reference/buildx_history_ls.md index e03b64e4bcf2..e63224b13c0d 100644 --- a/docs/reference/buildx_history_ls.md +++ b/docs/reference/buildx_history_ls.md @@ -5,12 +5,14 @@ List build records ### Options -| Name | Type | Default | Description | -|:----------------|:---------|:--------|:-----------------------------------------| -| `--builder` | `string` | | Override the configured builder instance | -| `-D`, `--debug` | `bool` | | Enable debug logging | -| `--format` | `string` | `table` | Format the output | -| `--no-trunc` | `bool` | | Don't truncate output | +| Name | Type | Default | Description | +|:----------------|:--------------|:--------|:---------------------------------------------| +| `--builder` | `string` | | Override the configured builder instance | +| `-D`, `--debug` | `bool` | | Enable debug logging | +| `--filter` | `stringArray` | | Provide filter values (e.g., `status=error`) | +| `--format` | `string` | `table` | Format the output | +| `--local` | `bool` | | List records for current repository only | +| `--no-trunc` | `bool` | | Don't truncate output |