Skip to content

history: add filters to ls #3091

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Apr 8, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 31 additions & 1 deletion commands/history/ls.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"encoding/json"
"fmt"
"os"
"path"
"slices"
"time"

Expand All @@ -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"
)

Expand All @@ -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 {
Expand All @@ -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
}
Expand Down Expand Up @@ -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
}
Expand Down
182 changes: 182 additions & 0 deletions commands/history/utils.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
package history

import (
"bytes"
"context"
"encoding/csv"
"fmt"
"io"
"path/filepath"
Expand All @@ -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

Expand Down Expand Up @@ -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) {
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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 {
Expand All @@ -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,
Expand Down Expand Up @@ -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 = "~="
Comment on lines +275 to +278
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah ok I see we set it here after valueFiler matching moby/buildkit#5705 (comment)

}
}
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)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm surprised we don't have an operator for "contains" with docker filters like ~ and does not contain with !~ (cc @thaJeztah).

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
}
14 changes: 8 additions & 6 deletions docs/reference/buildx_history_ls.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`) |
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As follow-up we should document the available filters with some examples similar to https://docs.docker.com/reference/cli/docker/container/ls/#filter

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah found pr description we could grab in the docs: moby/buildkit#5705 (comment)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note that separators are different. BuildKit uses containerd style while buildx uses docker style.

| `--format` | `string` | `table` | Format the output |
| `--local` | `bool` | | List records for current repository only |
| `--no-trunc` | `bool` | | Don't truncate output |


<!---MARKER_GEN_END-->
Expand Down