Skip to content

Commit d80ece5

Browse files
authored
Merge pull request #3091 from tonistiigi/history-filters
history: add filters to ls
2 parents 1f44971 + f1b8951 commit d80ece5

File tree

3 files changed

+221
-7
lines changed

3 files changed

+221
-7
lines changed

commands/history/ls.go

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"encoding/json"
66
"fmt"
77
"os"
8+
"path"
89
"slices"
910
"time"
1011

@@ -14,10 +15,12 @@ import (
1415
"github.com/docker/buildx/util/cobrautil/completion"
1516
"github.com/docker/buildx/util/confutil"
1617
"github.com/docker/buildx/util/desktop"
18+
"github.com/docker/buildx/util/gitutil"
1719
"github.com/docker/cli/cli"
1820
"github.com/docker/cli/cli/command"
1921
"github.com/docker/cli/cli/command/formatter"
2022
"github.com/docker/go-units"
23+
"github.com/pkg/errors"
2124
"github.com/spf13/cobra"
2225
)
2326

@@ -38,6 +41,9 @@ type lsOptions struct {
3841
builder string
3942
format string
4043
noTrunc bool
44+
45+
filters []string
46+
local bool
4147
}
4248

4349
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 {
5662
}
5763
}
5864

59-
out, err := queryRecords(ctx, "", nodes, nil)
65+
queryOptions := &queryOptions{}
66+
67+
if opts.local {
68+
wd, err := os.Getwd()
69+
if err != nil {
70+
return err
71+
}
72+
gitc, err := gitutil.New(gitutil.WithContext(ctx), gitutil.WithWorkingDir(wd))
73+
if err != nil {
74+
if st, err1 := os.Stat(path.Join(wd, ".git")); err1 == nil && st.IsDir() {
75+
return errors.Wrap(err, "git was not found in the system")
76+
}
77+
return errors.Wrapf(err, "could not find git repository for local filter")
78+
}
79+
remote, err := gitc.RemoteURL()
80+
if err != nil {
81+
return errors.Wrapf(err, "could not get remote URL for local filter")
82+
}
83+
queryOptions.Filters = append(queryOptions.Filters, fmt.Sprintf("repository=%s", remote))
84+
}
85+
queryOptions.Filters = append(queryOptions.Filters, opts.filters...)
86+
87+
out, err := queryRecords(ctx, "", nodes, queryOptions)
6088
if err != nil {
6189
return err
6290
}
@@ -92,6 +120,8 @@ func lsCmd(dockerCli command.Cli, rootOpts RootOptions) *cobra.Command {
92120
flags := cmd.Flags()
93121
flags.StringVar(&options.format, "format", formatter.TableFormatKey, "Format the output")
94122
flags.BoolVar(&options.noTrunc, "no-trunc", false, "Don't truncate output")
123+
flags.StringArrayVar(&options.filters, "filter", nil, `Provide filter values (e.g., "status=error")`)
124+
flags.BoolVar(&options.local, "local", false, "List records for current repository only")
95125

96126
return cmd
97127
}

commands/history/utils.go

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
package history
22

33
import (
4+
"bytes"
45
"context"
6+
"encoding/csv"
57
"fmt"
68
"io"
79
"path/filepath"
@@ -15,10 +17,13 @@ import (
1517
"github.com/docker/buildx/builder"
1618
"github.com/docker/buildx/localstate"
1719
controlapi "github.com/moby/buildkit/api/services/control"
20+
"github.com/moby/buildkit/util/gitutil"
1821
"github.com/pkg/errors"
1922
"golang.org/x/sync/errgroup"
2023
)
2124

25+
const recordsLimit = 50
26+
2227
func buildName(fattrs map[string]string, ls *localstate.State) string {
2328
var res string
2429

@@ -110,6 +115,7 @@ type historyRecord struct {
110115

111116
type queryOptions struct {
112117
CompletedOnly bool
118+
Filters []string
113119
}
114120

115121
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
126132
ref = ""
127133
}
128134

135+
var filters []string
136+
if opts != nil {
137+
filters = opts.Filters
138+
}
139+
129140
eg, ctx := errgroup.WithContext(ctx)
130141
for _, node := range nodes {
131142
node := node
@@ -138,9 +149,25 @@ func queryRecords(ctx context.Context, ref string, nodes []builder.Node, opts *q
138149
if err != nil {
139150
return err
140151
}
152+
153+
var matchers []matchFunc
154+
if len(filters) > 0 {
155+
filters, matchers, err = dockerFiltersToBuildkit(filters)
156+
if err != nil {
157+
return err
158+
}
159+
sb := bytes.NewBuffer(nil)
160+
w := csv.NewWriter(sb)
161+
w.Write(filters)
162+
w.Flush()
163+
filters = []string{strings.TrimSuffix(sb.String(), "\n")}
164+
}
165+
141166
serv, err := c.ControlClient().ListenBuildHistory(ctx, &controlapi.BuildHistoryRequest{
142167
EarlyExit: true,
143168
Ref: ref,
169+
Limit: recordsLimit,
170+
Filter: filters,
144171
})
145172
if err != nil {
146173
return err
@@ -158,6 +185,7 @@ func queryRecords(ctx context.Context, ref string, nodes []builder.Node, opts *q
158185
ts = &t
159186
}
160187
defer serv.CloseSend()
188+
loop0:
161189
for {
162190
he, err := serv.Recv()
163191
if err != nil {
@@ -173,6 +201,13 @@ func queryRecords(ctx context.Context, ref string, nodes []builder.Node, opts *q
173201
continue
174202
}
175203

204+
// for older buildkit that don't support filters apply local filters
205+
for _, matcher := range matchers {
206+
if !matcher(he.Record) {
207+
continue loop0
208+
}
209+
}
210+
176211
records = append(records, historyRecord{
177212
BuildHistoryRecord: he.Record,
178213
currentTimestamp: ts,
@@ -219,3 +254,150 @@ func formatDuration(d time.Duration) string {
219254
}
220255
return fmt.Sprintf("%dm %2ds", int(d.Minutes()), int(d.Seconds())%60)
221256
}
257+
258+
type matchFunc func(*controlapi.BuildHistoryRecord) bool
259+
260+
func dockerFiltersToBuildkit(in []string) ([]string, []matchFunc, error) {
261+
out := []string{}
262+
matchers := []matchFunc{}
263+
for _, f := range in {
264+
key, value, sep, found := cutAny(f, "!=", "=", "<=", "<", ">=", ">")
265+
if !found {
266+
return nil, nil, errors.Errorf("invalid filter %q", f)
267+
}
268+
switch key {
269+
case "ref", "repository", "status":
270+
if sep != "=" && sep != "!=" {
271+
return nil, nil, errors.Errorf("invalid separator for %q, expected = or !=", f)
272+
}
273+
matchers = append(matchers, valueFiler(key, value, sep))
274+
if sep == "=" {
275+
if key == "status" {
276+
sep = "=="
277+
} else {
278+
sep = "~="
279+
}
280+
}
281+
case "startedAt", "completedAt", "duration":
282+
if sep == "=" || sep == "!=" {
283+
return nil, nil, errors.Errorf("invalid separator for %q, expected <=, <, >= or >", f)
284+
}
285+
matcher, err := timeBasedFilter(key, value, sep)
286+
if err != nil {
287+
return nil, nil, err
288+
}
289+
matchers = append(matchers, matcher)
290+
default:
291+
return nil, nil, errors.Errorf("unsupported filter %q", f)
292+
}
293+
out = append(out, key+sep+value)
294+
}
295+
return out, matchers, nil
296+
}
297+
298+
func valueFiler(key, value, sep string) matchFunc {
299+
return func(rec *controlapi.BuildHistoryRecord) bool {
300+
var recValue string
301+
switch key {
302+
case "ref":
303+
recValue = rec.Ref
304+
case "repository":
305+
v, ok := rec.FrontendAttrs["vcs:source"]
306+
if ok {
307+
recValue = v
308+
} else {
309+
if context, ok := rec.FrontendAttrs["context"]; ok {
310+
if ref, err := gitutil.ParseGitRef(context); err == nil {
311+
recValue = ref.Remote
312+
}
313+
}
314+
}
315+
case "status":
316+
if rec.CompletedAt != nil {
317+
if rec.Error != nil {
318+
if strings.Contains(rec.Error.Message, "context canceled") {
319+
recValue = "canceled"
320+
} else {
321+
recValue = "error"
322+
}
323+
} else {
324+
recValue = "completed"
325+
}
326+
} else {
327+
recValue = "running"
328+
}
329+
}
330+
switch sep {
331+
case "=":
332+
if key == "status" {
333+
return recValue == value
334+
}
335+
return strings.Contains(recValue, value)
336+
case "!=":
337+
return recValue != value
338+
default:
339+
return false
340+
}
341+
}
342+
}
343+
344+
func timeBasedFilter(key, value, sep string) (matchFunc, error) {
345+
var cmp int64
346+
switch key {
347+
case "startedAt", "completedAt":
348+
v, err := time.ParseDuration(value)
349+
if err == nil {
350+
tm := time.Now().Add(-v)
351+
cmp = tm.Unix()
352+
} else {
353+
tm, err := time.Parse(time.RFC3339, value)
354+
if err != nil {
355+
return nil, errors.Errorf("invalid time %s", value)
356+
}
357+
cmp = tm.Unix()
358+
}
359+
case "duration":
360+
v, err := time.ParseDuration(value)
361+
if err != nil {
362+
return nil, errors.Errorf("invalid duration %s", value)
363+
}
364+
cmp = int64(v)
365+
default:
366+
return nil, nil
367+
}
368+
369+
return func(rec *controlapi.BuildHistoryRecord) bool {
370+
var val int64
371+
switch key {
372+
case "startedAt":
373+
val = rec.CreatedAt.AsTime().Unix()
374+
case "completedAt":
375+
if rec.CompletedAt != nil {
376+
val = rec.CompletedAt.AsTime().Unix()
377+
}
378+
case "duration":
379+
if rec.CompletedAt != nil {
380+
val = int64(rec.CompletedAt.AsTime().Sub(rec.CreatedAt.AsTime()))
381+
}
382+
}
383+
switch sep {
384+
case ">=":
385+
return val >= cmp
386+
case "<=":
387+
return val <= cmp
388+
case ">":
389+
return val > cmp
390+
default:
391+
return val < cmp
392+
}
393+
}, nil
394+
}
395+
396+
func cutAny(s string, seps ...string) (before, after, sep string, found bool) {
397+
for _, sep := range seps {
398+
if idx := strings.Index(s, sep); idx != -1 {
399+
return s[:idx], s[idx+len(sep):], sep, true
400+
}
401+
}
402+
return s, "", "", false
403+
}

docs/reference/buildx_history_ls.md

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,14 @@ List build records
55

66
### Options
77

8-
| Name | Type | Default | Description |
9-
|:----------------|:---------|:--------|:-----------------------------------------|
10-
| `--builder` | `string` | | Override the configured builder instance |
11-
| `-D`, `--debug` | `bool` | | Enable debug logging |
12-
| `--format` | `string` | `table` | Format the output |
13-
| `--no-trunc` | `bool` | | Don't truncate output |
8+
| Name | Type | Default | Description |
9+
|:----------------|:--------------|:--------|:---------------------------------------------|
10+
| `--builder` | `string` | | Override the configured builder instance |
11+
| `-D`, `--debug` | `bool` | | Enable debug logging |
12+
| `--filter` | `stringArray` | | Provide filter values (e.g., `status=error`) |
13+
| `--format` | `string` | `table` | Format the output |
14+
| `--local` | `bool` | | List records for current repository only |
15+
| `--no-trunc` | `bool` | | Don't truncate output |
1416

1517

1618
<!---MARKER_GEN_END-->

0 commit comments

Comments
 (0)