diff --git a/cmd/nerdctl/system_events.go b/cmd/nerdctl/system_events.go index db41ab17dba..889d3a1df9f 100644 --- a/cmd/nerdctl/system_events.go +++ b/cmd/nerdctl/system_events.go @@ -39,6 +39,7 @@ func newEventsCommand() *cobra.Command { eventsCommand.RegisterFlagCompletionFunc("format", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { return []string{"json"}, cobra.ShellCompDirectiveNoFileComp }) + eventsCommand.Flags().StringSliceP("filter", "f", []string{}, "Filter matches containers based on given conditions") return eventsCommand } @@ -51,10 +52,15 @@ func processSystemEventsOptions(cmd *cobra.Command) (types.SystemEventsOptions, if err != nil { return types.SystemEventsOptions{}, err } + filters, err := cmd.Flags().GetStringSlice("filter") + if err != nil { + return types.SystemEventsOptions{}, err + } return types.SystemEventsOptions{ Stdout: cmd.OutOrStdout(), GOptions: globalOptions, Format: format, + Filters: filters, }, nil } diff --git a/cmd/nerdctl/system_events_linux_test.go b/cmd/nerdctl/system_events_linux_test.go new file mode 100644 index 00000000000..16a0a954ac0 --- /dev/null +++ b/cmd/nerdctl/system_events_linux_test.go @@ -0,0 +1,111 @@ +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package main + +import ( + "fmt" + "strings" + "testing" + "time" + + "github.com/containerd/nerdctl/v2/pkg/testutil" + "gotest.tools/v3/assert" +) + +func testEventFilter(t *testing.T, args ...string) string { + t.Parallel() + base := testutil.NewBase(t) + testContainerName := testutil.Identifier(t) + defer base.Cmd("rm", "-f", testContainerName).Run() + + fullArgs := []string{"events", "--filter"} + fullArgs = append(fullArgs, args...) + fullArgs = append(fullArgs, + "--format", + "json", + ) + + eventsCmd := base.Cmd(fullArgs...).Start() + base.Cmd("run", "--rm", testutil.CommonImage).Start() + time.Sleep(3 * time.Second) + return eventsCmd.Stdout() +} + +func TestEventFilters(t *testing.T) { + + type testCase struct { + name string + args []string + nerdctlOut string + dockerOut string + dockerSkip bool + } + testCases := []testCase{ + { + name: "CapitializedFilter", + args: []string{"event=START"}, + nerdctlOut: "\"Status\":\"start\"", + dockerOut: "\"status\":\"start\"", + dockerSkip: true, + }, + { + name: "StartEventFilter", + args: []string{"event=start"}, + nerdctlOut: "\"Status\":\"start\"", + dockerOut: "\"status\":\"start\"", + dockerSkip: false, + }, + { + name: "UnsupportedEventFilter", + args: []string{"event=unknown"}, + nerdctlOut: "\"Status\":\"unknown\"", + dockerSkip: true, + }, + { + name: "StatusFilter", + args: []string{"status=start"}, + nerdctlOut: "\"Status\":\"start\"", + dockerOut: "\"status\":\"start\"", + dockerSkip: false, + }, + { + name: "UnsupportedStatusFilter", + args: []string{"status=unknown"}, + nerdctlOut: "\"Status\":\"unknown\"", + dockerSkip: true, + }, + } + + for _, tc := range testCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + actualOut := testEventFilter(t, tc.args...) + errorMsg := fmt.Sprintf("%s failed;\nActual Filter Result: '%s'", tc.name, actualOut) + + isDocker := testutil.GetTarget() == testutil.Docker + if isDocker && tc.dockerSkip { + t.Skip("test is incompatible with Docker") + } + + if isDocker { + assert.Equal(t, true, strings.Contains(actualOut, tc.dockerOut), errorMsg) + } else { + assert.Equal(t, true, strings.Contains(actualOut, tc.nerdctlOut), errorMsg) + } + }) + } +} diff --git a/docs/command-reference.md b/docs/command-reference.md index 29c17be1ed7..459d4c1bba5 100644 --- a/docs/command-reference.md +++ b/docs/command-reference.md @@ -1275,8 +1275,10 @@ Usage: `nerdctl events [OPTIONS]` Flags: - :whale: `--format`: Format the output using the given Go template, e.g, `{{json .}}` +- :whale: `-f, --filter`: Filter containers based on given conditions + - :whale: `--filter event=`: Event's status. Start is the only supported status. -Unimplemented `docker events` flags: `--filter`, `--since`, `--until` +Unimplemented `docker events` flags: `--since`, `--until` ### :whale: nerdctl info diff --git a/pkg/api/types/system_types.go b/pkg/api/types/system_types.go index 65df3218674..bfadba7a057 100644 --- a/pkg/api/types/system_types.go +++ b/pkg/api/types/system_types.go @@ -37,6 +37,8 @@ type SystemEventsOptions struct { GOptions GlobalCommandOptions // Format the output using the given Go template, e.g, '{{json .}} Format string + // Filter events based on given conditions + Filters []string } // SystemPruneOptions specifies options for `nerdctl system prune`. diff --git a/pkg/cmd/system/events.go b/pkg/cmd/system/events.go index 08a890e3942..7f902920066 100644 --- a/pkg/cmd/system/events.go +++ b/pkg/cmd/system/events.go @@ -48,18 +48,101 @@ type EventOut struct { type Status string const ( - START Status = "START" - UNKNOWN Status = "UNKNOWN" + START Status = "start" + UNKNOWN Status = "unknown" ) +var statuses = [...]Status{START, UNKNOWN} + +func isStatus(status string) bool { + status = strings.ToLower(status) + + for _, supportedStatus := range statuses { + if string(supportedStatus) == status { + return true + } + } + + return false +} + func TopicToStatus(topic string) Status { - if strings.Contains(strings.ToUpper(topic), string(START)) { + if strings.Contains(strings.ToLower(topic), string(START)) { return START } return UNKNOWN } +// EventFilter for filtering events +type EventFilter func(*EventOut) bool + +// generateEventFilter is similar to Podman implementation: +// https://github.com/containers/podman/blob/189d862d54b3824c74bf7474ddfed6de69ec5a09/libpod/events/filters.go#L11 +func generateEventFilter(filter, filterValue string) (func(e *EventOut) bool, error) { + switch strings.ToUpper(filter) { + case "EVENT", "STATUS": + return func(e *EventOut) bool { + if !isStatus(string(e.Status)) { + return false + } + + return strings.EqualFold(string(e.Status), filterValue) + }, nil + } + + return nil, fmt.Errorf("%s is an invalid or unsupported filter", filter) +} + +// parseFilter is similar to Podman implementation: +// https://github.com/containers/podman/blob/189d862d54b3824c74bf7474ddfed6de69ec5a09/libpod/events/filters.go#L96 +func parseFilter(filter string) (string, string, error) { + filterSplit := strings.SplitN(filter, "=", 2) + if len(filterSplit) != 2 { + return "", "", fmt.Errorf("%s is an invalid filter", filter) + } + return filterSplit[0], filterSplit[1], nil +} + +// applyFilters is similar to Podman implementation: +// https://github.com/containers/podman/blob/189d862d54b3824c74bf7474ddfed6de69ec5a09/libpod/events/filters.go#L106 +func applyFilters(event *EventOut, filterMap map[string][]EventFilter) bool { + for _, filters := range filterMap { + match := false + for _, filter := range filters { + if filter(event) { + match = true + break + } + } + if !match { + return false + } + } + return true +} + +// generateEventFilters is similar to Podman implementation: +// https://github.com/containers/podman/blob/189d862d54b3824c74bf7474ddfed6de69ec5a09/libpod/events/filters.go#L11 +func generateEventFilters(filters []string) (map[string][]EventFilter, error) { + filterMap := make(map[string][]EventFilter) + for _, filter := range filters { + key, val, err := parseFilter(filter) + if err != nil { + return nil, err + } + filterFunc, err := generateEventFilter(key, val) + if err != nil { + return nil, err + } + filterSlice := filterMap[key] + filterSlice = append(filterSlice, filterFunc) + filterMap[key] = filterSlice + } + + return filterMap, nil +} + // Events is from https://github.com/containerd/containerd/blob/v1.4.3/cmd/ctr/commands/events/events.go func Events(ctx context.Context, client *containerd.Client, options types.SystemEventsOptions) error { eventsClient := client.EventService() @@ -77,6 +160,10 @@ func Events(ctx context.Context, client *containerd.Client, options types.System return err } } + filterMap, err := generateEventFilters(options.Filters) + if err != nil { + return err + } for { var e *events.Envelope select { @@ -99,37 +186,41 @@ func Events(ctx context.Context, client *containerd.Client, options types.System continue } } - if tmpl != nil { - var data map[string]interface{} - err := json.Unmarshal(out, &data) - if err != nil { - log.G(ctx).WithError(err).Warn("cannot marshal Any into JSON") - } else { - _, ok := data["container_id"] - if ok { - id = data["container_id"].(string) - } + var data map[string]interface{} + err := json.Unmarshal(out, &data) + if err != nil { + log.G(ctx).WithError(err).Warn("cannot marshal Any into JSON") + } else { + _, ok := data["container_id"] + if ok { + id = data["container_id"].(string) } + } - out := EventOut{e.Timestamp, id, e.Namespace, e.Topic, TopicToStatus(e.Topic), string(out)} - var b bytes.Buffer - if err := tmpl.Execute(&b, out); err != nil { - return err - } - if _, err := fmt.Fprintln(options.Stdout, b.String()+"\n"); err != nil { - return err - } - } else { - if _, err := fmt.Fprintln( - options.Stdout, - e.Timestamp, - e.Namespace, - e.Topic, - string(out), - ); err != nil { - return err + eOut := EventOut{e.Timestamp, id, e.Namespace, e.Topic, TopicToStatus(e.Topic), string(out)} + match := applyFilters(&eOut, filterMap) + if match { + if tmpl != nil { + var b bytes.Buffer + if err := tmpl.Execute(&b, eOut); err != nil { + return err + } + if _, err := fmt.Fprintln(options.Stdout, b.String()+"\n"); err != nil { + return err + } + } else { + if _, err := fmt.Fprintln( + options.Stdout, + e.Timestamp, + e.Namespace, + e.Topic, + string(out), + ); err != nil { + return err + } } } + } } }