diff --git a/cmd/podman/common/completion.go b/cmd/podman/common/completion.go index c90d403966..1f1412e7a3 100644 --- a/cmd/podman/common/completion.go +++ b/cmd/podman/common/completion.go @@ -88,6 +88,7 @@ func setupImageEngine(cmd *cobra.Command) (entities.ImageEngine, error) { } func getContainers(cmd *cobra.Command, toComplete string, cType completeType, statuses ...string) ([]string, cobra.ShellCompDirective) { + var listContainers []entities.ListContainer suggestions := []string{} listOpts := entities.ContainerListOptions{ Filters: make(map[string][]string), @@ -109,7 +110,20 @@ func getContainers(cmd *cobra.Command, toComplete string, cType completeType, st return nil, cobra.ShellCompDirectiveNoFileComp } - for _, c := range containers { + listContainers = append(listContainers, containers...) + + // Add containers from the external storage into complete list + if ok, _ := cmd.Flags().GetBool("external"); ok { + externalContainers, err := engine.ContainerListExternal(registry.Context()) + if err != nil { + cobra.CompErrorln(err.Error()) + return nil, cobra.ShellCompDirectiveNoFileComp + } + + listContainers = append(listContainers, externalContainers...) + } + + for _, c := range listContainers { // include ids in suggestions if cType == completeIDs or // more then 2 chars are typed and cType == completeDefault if ((len(toComplete) > 1 && cType == completeDefault) || @@ -341,6 +355,43 @@ func getArtifacts(cmd *cobra.Command, toComplete string) ([]string, cobra.ShellC return suggestions, cobra.ShellCompDirectiveNoFileComp } +func getCommands(cmd *cobra.Command, toComplete string) ([]string, cobra.ShellCompDirective) { + suggestions := []string{} + lsOpts := entities.ContainerListOptions{} + + engine, err := setupContainerEngine(cmd) + if err != nil { + cobra.CompErrorln(err.Error()) + return nil, cobra.ShellCompDirectiveNoFileComp + } + + containers, err := engine.ContainerList(registry.Context(), lsOpts) + if err != nil { + cobra.CompErrorln(err.Error()) + return nil, cobra.ShellCompDirectiveNoFileComp + } + + externalContainers, err := engine.ContainerListExternal(registry.Context()) + if err != nil { + cobra.CompErrorln(err.Error()) + return nil, cobra.ShellCompDirectiveNoFileComp + } + containers = append(containers, externalContainers...) + + for _, container := range containers { + // taking of the first element of commands list is done intentionally + // to exclude command arguments from suggestions (e.g. exclude arguments "-g daemon" + // from "nginx -g daemon" output) + if len(container.Command) > 0 { + if strings.HasPrefix(container.Command[0], toComplete) { + suggestions = append(suggestions, container.Command[0]) + } + } + } + + return suggestions, cobra.ShellCompDirectiveNoFileComp +} + func fdIsNotDir(f *os.File) bool { stat, err := f.Stat() if err != nil { @@ -1703,6 +1754,7 @@ func AutocompletePsFilters(cmd *cobra.Command, args []string, toComplete string) kv := keyValueCompletion{ "ancestor=": func(s string) ([]string, cobra.ShellCompDirective) { return getImages(cmd, s) }, "before=": func(s string) ([]string, cobra.ShellCompDirective) { return getContainers(cmd, s, completeDefault) }, + "command=": func(s string) ([]string, cobra.ShellCompDirective) { return getCommands(cmd, s) }, "exited=": nil, "health=": func(_ string) ([]string, cobra.ShellCompDirective) { return []string{define.HealthCheckHealthy, diff --git a/docs/source/markdown/podman-pause.1.md.in b/docs/source/markdown/podman-pause.1.md.in index 772092f20f..b12945e68e 100644 --- a/docs/source/markdown/podman-pause.1.md.in +++ b/docs/source/markdown/podman-pause.1.md.in @@ -43,6 +43,7 @@ Valid filters are listed below: | pod | [Pod] name or full or partial ID of pod | | network | [Network] name or full ID of network | | until | [DateTime] Containers created before the given duration or time. | +| command | [Command] the command the container is executing, only argv[0] is taken | @@option latest diff --git a/docs/source/markdown/podman-ps.1.md b/docs/source/markdown/podman-ps.1.md index 30ccf98021..4689c9860d 100644 --- a/docs/source/markdown/podman-ps.1.md +++ b/docs/source/markdown/podman-ps.1.md @@ -61,7 +61,7 @@ Valid filters are listed below: | pod | [Pod] name or full or partial ID of pod | | network | [Network] name or full ID of network | | until | [DateTime] container created before the given duration or time. | - +| command | [Command] the command the container is executing, only argv[0] is taken | #### **--format**=*format* diff --git a/docs/source/markdown/podman-restart.1.md.in b/docs/source/markdown/podman-restart.1.md.in index b17c6d2e7e..a659828433 100644 --- a/docs/source/markdown/podman-restart.1.md.in +++ b/docs/source/markdown/podman-restart.1.md.in @@ -46,6 +46,7 @@ Valid filters are listed below: | pod | [Pod] name or full or partial ID of pod | | network | [Network] name or full ID of network | | until | [DateTime] Containers created before the given duration or time. | +| command | [Command] the command the container is executing, only argv[0] is taken | @@option latest diff --git a/docs/source/markdown/podman-rm.1.md.in b/docs/source/markdown/podman-rm.1.md.in index 7717c8ea18..3c3e798fce 100644 --- a/docs/source/markdown/podman-rm.1.md.in +++ b/docs/source/markdown/podman-rm.1.md.in @@ -50,6 +50,7 @@ Valid filters are listed below: | pod | [Pod] name or full or partial ID of pod | | network | [Network] name or full ID of network | | until | [DateTime] Containers created before the given duration or time. | +| command | [Command] the command the container is executing, only argv[0] is taken | #### **--force**, **-f** diff --git a/docs/source/markdown/podman-start.1.md.in b/docs/source/markdown/podman-start.1.md.in index 6b92be13ea..5e823baa0a 100644 --- a/docs/source/markdown/podman-start.1.md.in +++ b/docs/source/markdown/podman-start.1.md.in @@ -51,6 +51,7 @@ Valid filters are listed below: | pod | [Pod] name or full or partial ID of pod | | network | [Network] name or full ID of network | | until | [DateTime] Containers created before the given duration or time. | +| command | [Command] the command the container is executing, only argv[0] is taken | @@option interactive diff --git a/docs/source/markdown/podman-stop.1.md.in b/docs/source/markdown/podman-stop.1.md.in index b58f37ae8d..46759f1996 100644 --- a/docs/source/markdown/podman-stop.1.md.in +++ b/docs/source/markdown/podman-stop.1.md.in @@ -49,6 +49,7 @@ Valid filters are listed below: | pod | [Pod] name or full or partial ID of pod | | network | [Network] name or full ID of network | | until | [DateTime] Containers created before the given duration or time. | +| command | [Command] the command the container is executing, only argv[0] is taken | @@option ignore diff --git a/docs/source/markdown/podman-unpause.1.md.in b/docs/source/markdown/podman-unpause.1.md.in index 7aa57b3012..fd3e82cb71 100644 --- a/docs/source/markdown/podman-unpause.1.md.in +++ b/docs/source/markdown/podman-unpause.1.md.in @@ -43,6 +43,7 @@ Valid filters are listed below: | pod | [Pod] name or full or partial ID of pod | | network | [Network] name or full ID of network | | until | [DateTime] Containers created before the given duration or time. | +| command | [Command] the command the container is executing, only argv[0] is taken | @@option latest diff --git a/libpod/runtime_ctr.go b/libpod/runtime_ctr.go index 87d8484783..3636a7d6d4 100644 --- a/libpod/runtime_ctr.go +++ b/libpod/runtime_ctr.go @@ -1246,11 +1246,21 @@ func (r *Runtime) GetContainers(loadState bool, filters ...ContainerFilter) ([]* return nil, err } - ctrsFiltered := make([]*Container, 0, len(ctrs)) + ctrsFiltered := applyContainersFilters(ctrs, filters...) - for _, ctr := range ctrs { + return ctrsFiltered, nil +} + +// Applies container filters on bunch of containers +func applyContainersFilters(containers []*Container, filters ...ContainerFilter) []*Container { + ctrsFiltered := make([]*Container, 0, len(containers)) + + for _, ctr := range containers { include := true for _, filter := range filters { + if filter == nil { + continue + } include = include && filter(ctr) } @@ -1259,7 +1269,7 @@ func (r *Runtime) GetContainers(loadState bool, filters ...ContainerFilter) ([]* } } - return ctrsFiltered, nil + return ctrsFiltered } // GetAllContainers is a helper function for GetContainers diff --git a/pkg/domain/entities/container_ps.go b/pkg/domain/entities/container_ps.go index 0cd8e740f9..b408eec06d 100644 --- a/pkg/domain/entities/container_ps.go +++ b/pkg/domain/entities/container_ps.go @@ -8,6 +8,11 @@ import ( "github.com/containers/podman/v5/pkg/domain/entities/types" ) +// ExternalContainerFilter is a function to determine whether a container list is included +// in command output. Container lists to be outputted are tested using the function. +// A true return will include the container list, a false return will exclude it. +type ExternalContainerFilter func(*ListContainer) bool + // ListContainer describes a container suitable for listing type ListContainer = types.ListContainer diff --git a/pkg/domain/filters/containers.go b/pkg/domain/filters/containers.go index b4a683631c..9a903b8978 100644 --- a/pkg/domain/filters/containers.go +++ b/pkg/domain/filters/containers.go @@ -14,6 +14,8 @@ import ( "github.com/containers/common/pkg/util" "github.com/containers/podman/v5/libpod" "github.com/containers/podman/v5/libpod/define" + "github.com/containers/podman/v5/pkg/domain/entities/types" + "github.com/containers/storage" ) // GenerateContainerFilterFuncs return ContainerFilter functions based of filter. @@ -282,6 +284,10 @@ func GenerateContainerFilterFuncs(filter string, filterValues []string, r *libpo } return false }, filterValueError + case "command": + return func(c *libpod.Container) bool { + return util.StringMatchRegexSlice(c.Command()[0], filterValues) + }, nil } return nil, fmt.Errorf("%s is an invalid filter", filter) } @@ -315,3 +321,209 @@ func prepareUntilFilterFunc(filterValues []string) (func(container *libpod.Conta return false }, nil } + +// GenerateContainerFilterFuncs return ContainerFilter functions based of filter. +func GenerateExternalContainerFilterFuncs(filter string, filterValues []string, r *libpod.Runtime) (func(listContainer *types.ListContainer) bool, error) { + switch filter { + case "id": + return func(listContainer *types.ListContainer) bool { + return filters.FilterID(listContainer.ID, filterValues) + }, nil + case "name": + // we only have to match one name + return func(listContainer *types.ListContainer) bool { + namesList := listContainer.Names + + for _, f := range filterValues { + f = strings.ReplaceAll(f, "/", "") + if util.StringMatchRegexSlice(f, namesList) { + return true + } + } + + return false + }, nil + case "command": + return func(listContainer *types.ListContainer) bool { + return util.StringMatchRegexSlice(listContainer.Command[0], filterValues) + }, nil + case "ancestor": + // This needs to refine to match docker + // - ancestor=([:tag]|| ⟨image@digest⟩) - containers created from an image or a descendant. + return func(listContainer *types.ListContainer) bool { + for _, filterValue := range filterValues { + var imageTag string + var imageNameWithoutTag string + // Compare with ImageID, ImageName + // Will match ImageName if running image has tag latest for other tags exact complete filter must be given + name, tag, hasColon := strings.Cut(listContainer.Image, ":") + if hasColon { + imageNameWithoutTag = name + imageTag = tag + } + + if (listContainer.ImageID == filterValue) || + util.StringMatchRegexSlice(listContainer.Image, filterValues) || + (util.StringMatchRegexSlice(imageNameWithoutTag, filterValues) && imageTag == "latest") { + return true + } + } + return false + }, nil + case "before": + var createTime time.Time + var externCons []storage.Container + externCons, err := r.StorageContainers() + if err != nil { + return nil, err + } + + for _, filterValue := range filterValues { + for _, ctr := range externCons { + if slices.Contains(ctr.Names, filterValue) { + if createTime.IsZero() || createTime.After(ctr.Created) { + createTime = ctr.Created + } + } + } + } + + return func(listContainer *types.ListContainer) bool { + return createTime.After(listContainer.Created) + }, nil + case "since": + var createTime time.Time + var externCons []storage.Container + externCons, err := r.StorageContainers() + if err != nil { + return nil, err + } + + for _, filterValue := range filterValues { + for _, ctr := range externCons { + if slices.Contains(ctr.Names, filterValue) { + if createTime.IsZero() || createTime.After(ctr.Created) { + createTime = ctr.Created + } + } + } + } + + return func(listContainer *types.ListContainer) bool { + return createTime.Before(listContainer.Created) + }, nil + case "until": + until, err := filters.ComputeUntilTimestamp(filterValues) + if err != nil { + return nil, err + } + return func(listContainer *types.ListContainer) bool { + if !until.IsZero() && listContainer.Created.Before(until) { + return true + } + return false + }, nil + case "status": + for _, filterValue := range filterValues { + if _, err := define.StringToContainerStatus(filterValue); err != nil { + return nil, err + } + } + return func(listContainer *types.ListContainer) bool { + status := listContainer.State + if status == define.ContainerStateConfigured.String() { + status = "created" + } else if status == define.ContainerStateStopped.String() { + status = "exited" + } + for _, filterValue := range filterValues { + if filterValue == "stopped" { + filterValue = "exited" + } + if status == filterValue { + return true + } + } + return false + }, nil + case "exited": + var exitCodes []int32 + for _, exitCode := range filterValues { + ec, err := strconv.ParseInt(exitCode, 10, 32) + if err != nil { + return nil, fmt.Errorf("exited code out of range %q: %w", ec, err) + } + exitCodes = append(exitCodes, int32(ec)) + } + return func(listContainer *types.ListContainer) bool { + ec := listContainer.ExitCode + exited := listContainer.Exited + if exited { + for _, exitCode := range exitCodes { + if ec == exitCode { + return true + } + } + } + return false + }, nil + case "label": + return func(listContainer *types.ListContainer) bool { + return !filters.MatchLabelFilters(filterValues, listContainer.Labels) + }, nil + case "pod": + var pods []*libpod.Pod + for _, podNameOrID := range filterValues { + p, err := r.LookupPod(podNameOrID) + if err != nil { + if errors.Is(err, define.ErrNoSuchPod) { + continue + } + return nil, err + } + pods = append(pods, p) + } + return func(listContainer *types.ListContainer) bool { + // if no pods match, quick out + if len(pods) < 1 { + return false + } + // if the container has no pod id, quick out + if len(listContainer.ID) < 1 { + return false + } + for _, p := range pods { + // we already looked up by name or id, so id match + // here is ok + if p.ID() == listContainer.ID { + return true + } + } + return false + }, nil + case "network": + var inputNetNames []string + for _, val := range filterValues { + net, err := r.Network().NetworkInspect(val) + if err != nil { + if errors.Is(err, define.ErrNoSuchNetwork) { + continue + } + return nil, err + } + inputNetNames = append(inputNetNames, net.Name) + } + return func(listContainer *types.ListContainer) bool { + for _, net := range listContainer.Networks { + if slices.Contains(inputNetNames, net) { + return true + } + } + return false + }, nil + case "restart-policy", "volume", "health": + return nil, fmt.Errorf("filter %s is not applicable for external containers", filter) + } + + return nil, fmt.Errorf("%s is an invalid filter", filter) +} diff --git a/pkg/ps/ps.go b/pkg/ps/ps.go index 7a77621e75..619aafed60 100644 --- a/pkg/ps/ps.go +++ b/pkg/ps/ps.go @@ -24,19 +24,33 @@ import ( "github.com/sirupsen/logrus" ) +// ExternalContainerFilter is a function to determine whether a container list is included +// in command output. Container lists to be outputted are tested using the function. +// A true return will include the container list, a false return will exclude it. +type ExternalContainerFilter func(*entities.ListContainer) bool + func GetContainerLists(runtime *libpod.Runtime, options entities.ContainerListOptions) ([]entities.ListContainer, error) { var ( pss = []entities.ListContainer{} ) filterFuncs := make([]libpod.ContainerFilter, 0, len(options.Filters)) + filterExtFuncs := make([]entities.ExternalContainerFilter, 0, len(options.Filters)) all := options.All || options.Last > 0 if len(options.Filters) > 0 { for k, v := range options.Filters { generatedFunc, err := filters.GenerateContainerFilterFuncs(k, v, runtime) - if err != nil { + if err != nil && !options.External { return nil, err } filterFuncs = append(filterFuncs, generatedFunc) + + if options.External { + generatedExtFunc, err := filters.GenerateExternalContainerFilterFuncs(k, v, runtime) + if err != nil { + return nil, err + } + filterExtFuncs = append(filterExtFuncs, generatedExtFunc) + } } } @@ -87,7 +101,7 @@ func GetContainerLists(runtime *libpod.Runtime, options entities.ContainerListOp } if options.External { - listCon, err := GetExternalContainerLists(runtime) + listCon, err := GetExternalContainerLists(runtime, filterExtFuncs...) if err != nil { return nil, err } @@ -107,9 +121,9 @@ func GetContainerLists(runtime *libpod.Runtime, options entities.ContainerListOp } // GetExternalContainerLists returns list of external containers for e.g. created by buildah -func GetExternalContainerLists(runtime *libpod.Runtime) ([]entities.ListContainer, error) { +func GetExternalContainerLists(runtime *libpod.Runtime, filterExtFuncs ...entities.ExternalContainerFilter) ([]entities.ListContainer, error) { var ( - pss = []entities.ListContainer{} + pss = []*entities.ListContainer{} ) externCons, err := runtime.StorageContainers() @@ -128,10 +142,31 @@ func GetExternalContainerLists(runtime *libpod.Runtime) ([]entities.ListContaine case err != nil: return nil, err default: - pss = append(pss, listCon) + pss = append(pss, &listCon) } } - return pss, nil + + filteredPss := applyExternalContainersFilters(pss, filterExtFuncs...) + + return filteredPss, nil +} + +// Apply container filters on bunch of external container lists +func applyExternalContainersFilters(containersList []*entities.ListContainer, filters ...entities.ExternalContainerFilter) []entities.ListContainer { + ctrsFiltered := make([]entities.ListContainer, 0, len(containersList)) + + for _, ctr := range containersList { + include := true + for _, filter := range filters { + include = include && filter(ctr) + } + + if include { + ctrsFiltered = append(ctrsFiltered, *ctr) + } + } + + return ctrsFiltered } // ListContainerBatch is used in ps to reduce performance hits by "batching" diff --git a/test/e2e/ps_test.go b/test/e2e/ps_test.go index c79e1e55e1..d347c5b31e 100644 --- a/test/e2e/ps_test.go +++ b/test/e2e/ps_test.go @@ -405,6 +405,37 @@ var _ = Describe("Podman ps", func() { Expect(actual).ToNot(ContainSubstring("NAMES")) }) + // This test checks a ps filtering by container command/entrypoint + // To improve the test reliability a container ID is also checked + It("podman ps filter by container command", func() { + matchedSession := podmanTest.Podman([]string{"run", "-d", "--name", "matched", ALPINE, "top"}) + matchedSession.WaitWithDefaultTimeout() + containedID := matchedSession.OutputToString() // save container ID returned by the run command + Expect(containedID).ShouldNot(BeEmpty()) + Expect(matchedSession).Should(ExitCleanly()) + + matchedSession = podmanTest.Podman([]string{"ps", "-a", "--no-trunc", "--noheading", "--filter", "command=top"}) + matchedSession.WaitWithDefaultTimeout() + Expect(matchedSession).Should(ExitCleanly()) + + output := matchedSession.OutputToStringArray() + Expect(output).To(HaveLen(1)) + Expect(output).Should(ContainElement(ContainSubstring(containedID))) + + unmatchedSession := podmanTest.Podman([]string{"run", "-d", "--name", "unmatched", ALPINE, "sh"}) + unmatchedSession.WaitWithDefaultTimeout() + containedID = unmatchedSession.OutputToString() // save container ID returned by the run command + Expect(containedID).ShouldNot(BeEmpty()) + Expect(unmatchedSession).Should(ExitCleanly()) + + unmatchedSession = podmanTest.Podman([]string{"ps", "-a", "--no-trunc", "--noheading", "--filter", "command=fakecommand"}) + unmatchedSession.WaitWithDefaultTimeout() + Expect(unmatchedSession).Should(ExitCleanly()) + + output = unmatchedSession.OutputToStringArray() + Expect(output).To(BeEmpty()) + }) + It("podman ps mutually exclusive flags", func() { session := podmanTest.Podman([]string{"ps", "-aqs"}) session.WaitWithDefaultTimeout() @@ -896,4 +927,110 @@ var _ = Describe("Podman ps", func() { Expect(session.OutputToString()).To(Or(Equal(net1+","+net2), Equal(net2+","+net1))) }) + // This test checks ps filtering of external container by container command/entrypoint + It("podman ps filter external by container command", func() { + create := podmanTest.Podman([]string{"create", "--name", "test", BB}) + create.WaitWithDefaultTimeout() + Expect(create).Should(ExitCleanly()) + + // Container should exist + Expect(podmanTest.NumberOfContainers()).To(Equal(1)) + + session := podmanTest.Podman([]string{"ps", "-a", "--external", "--no-trunc", "--noheading", "--filter", "command=sh"}) + session.WaitWithDefaultTimeout() + Expect(session).Should(ExitCleanly()) + + output := session.OutputToStringArray() + Expect(output).To(HaveLen(1)) + }) + + // This test checks ps filtering of external container by container name + It("podman ps filter external by container name", func() { + create := podmanTest.Podman([]string{"create", "--name", "test", BB}) + create.WaitWithDefaultTimeout() + Expect(create).Should(ExitCleanly()) + + // Container should exist + Expect(podmanTest.NumberOfContainers()).To(Equal(1)) + + session := podmanTest.Podman([]string{"ps", "-a", "--external", "--no-trunc", "--noheading", "--filter", "name=test"}) + session.WaitWithDefaultTimeout() + Expect(session).Should(ExitCleanly()) + + output := session.OutputToStringArray() + Expect(output).To(HaveLen(1)) + + }) + + // This test checks ps filtering of external container by container id + It("podman ps filter external by container id", func() { + create := podmanTest.Podman([]string{"create", "--name", "test", BB}) + create.WaitWithDefaultTimeout() + Expect(create).Should(ExitCleanly()) + + // Container should exist + Expect(podmanTest.NumberOfContainers()).To(Equal(1)) + + session := podmanTest.Podman([]string{"ps", "-a", "--external", "--no-trunc", "--noheading", "--filter", "id=" + create.OutputToString()}) + session.WaitWithDefaultTimeout() + Expect(session).Should(ExitCleanly()) + + output := session.OutputToStringArray() + Expect(output).To(HaveLen(1)) + }) + + // This test checks ps filtering of external container by container label + It("podman ps filter external by container ancestor", func() { + create := podmanTest.Podman([]string{"create", "--name", "test", BB}) + create.WaitWithDefaultTimeout() + Expect(create).Should(ExitCleanly()) + + // Container should exist + Expect(podmanTest.NumberOfContainers()).To(Equal(1)) + + session := podmanTest.Podman([]string{"ps", "-a", "--external", "--no-trunc", "--noheading", "--filter", "ancestor=" + BB}) + session.WaitWithDefaultTimeout() + Expect(session).Should(ExitCleanly()) + + output := session.OutputToStringArray() + Expect(output).To(HaveLen(1)) + }) + + // This test checks ps filtering of external container created earlier than a given + It("podman ps filter external by container created earlier than a given", func() { + early := podmanTest.Podman([]string{"create", "--name", "early", BB}) + early.WaitWithDefaultTimeout() + Expect(early).Should(ExitCleanly()) + + late := podmanTest.Podman([]string{"create", "--name", "late", BB}) + late.WaitWithDefaultTimeout() + Expect(late).Should(ExitCleanly()) + + session := podmanTest.Podman([]string{"ps", "-a", "--external", "--no-trunc", "--noheading", "--filter", "before=late"}) + session.WaitWithDefaultTimeout() + Expect(session).Should(ExitCleanly()) + + output := session.OutputToStringArray() + Expect(output).To(HaveLen(1)) + Expect(output).Should(ContainElement(ContainSubstring("early"))) + }) + + // This test checks ps filtering of external container created since a given + It("podman ps filter external by container created since a given", func() { + early := podmanTest.Podman([]string{"create", "--name", "early", BB}) + early.WaitWithDefaultTimeout() + Expect(early).Should(ExitCleanly()) + + late := podmanTest.Podman([]string{"create", "--name", "late", BB}) + late.WaitWithDefaultTimeout() + Expect(late).Should(ExitCleanly()) + + session := podmanTest.Podman([]string{"ps", "-a", "--external", "--no-trunc", "--noheading", "--filter", "since=early"}) + session.WaitWithDefaultTimeout() + Expect(session).Should(ExitCleanly()) + + output := session.OutputToStringArray() + Expect(output).To(HaveLen(1)) + Expect(output).Should(ContainElement(ContainSubstring("late"))) + }) })