diff --git a/cmd/podman/compose.go b/cmd/podman/compose.go new file mode 100644 index 0000000000..8f507e63c7 --- /dev/null +++ b/cmd/podman/compose.go @@ -0,0 +1,302 @@ +//go:build amd64 || arm64 +// +build amd64 arm64 + +package main + +import ( + "errors" + "fmt" + "io" + "net/url" + "os" + "os/exec" + "runtime" + "strconv" + "strings" + "text/template" + + "github.com/containers/common/pkg/config" + cmdMachine "github.com/containers/podman/v4/cmd/podman/machine" + "github.com/containers/podman/v4/cmd/podman/registry" + "github.com/containers/podman/v4/pkg/machine" + "github.com/sirupsen/logrus" + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +var composeCommand = &cobra.Command{ + Use: "compose [options]", + Short: "Run compose workloads via an external provider such as docker-compose or podman-compose", + Long: `This command is a thin wrapper around an external compose provider such as docker-compose or podman-compose. This means that podman compose is executing another tool that implements the compose functionality but sets up the environment in a way to let the compose provider communicate transparently with the local Podman socket. The specified options as well the command and argument are passed directly to the compose provider. + +The default compose providers are docker-compose and podman-compose. If installed, docker-compose takes precedence since it is the original implementation of the Compose specification and is widely used on the supported platforms (i.e., Linux, Mac OS, Windows). + +If you want to change the default behavior or have a custom installation path for your provider of choice, please change the compose_provider field in containers.conf(5). You may also set PODMAN_COMPOSE_PROVIDER environment variable.`, + RunE: composeMain, + ValidArgsFunction: composeCompletion, + Example: `podman compose -f nginx.yaml up --detach + podman --log-level=debug compose -f many-images.yaml pull`, + DisableFlagParsing: true, + Annotations: map[string]string{registry.ParentNSRequired: ""}, // don't join user NS for SSH to work correctly +} + +func init() { + // NOTE: we need to fully disable flag parsing and manually parse the + // flags in composeMain. cobra's FParseErrWhitelist will strip off + // unknown flags _before_ the first argument. So `--unknown argument` + // will show as `argument`. + + registry.Commands = append(registry.Commands, registry.CliCommand{Command: composeCommand}) +} + +func composeCompletion(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + var stdout strings.Builder + + args = append(args, toComplete) + args = append([]string{"__complete"}, args...) + if err := composeProviderExec(args, &stdout, io.Discard, false); err != nil { + // Ignore errors since some providers may not expose a __complete command. + return nil, cobra.ShellCompDirectiveError + } + + var num int + output := strings.Split(strings.TrimRight(stdout.String(), "\n"), "\n") + if len(output) >= 1 { + if lastLine := output[len(output)-1]; strings.HasPrefix(lastLine, ":") { + var err error + if num, err = strconv.Atoi(lastLine[1:]); err != nil { + return nil, cobra.ShellCompDirectiveError + } + output = output[:len(output)-1] + } + } + return output, cobra.ShellCompDirective(num) +} + +// composeProvider provides the name of or absolute path to the compose +// provider (i.e., the external binary such as docker-compose). +func composeProvider() (string, error) { + if value, ok := os.LookupEnv("PODMAN_COMPOSE_PROVIDER"); ok { + return value, nil + } + + candidates := registry.PodmanConfig().ContainersConfDefaultsRO.Engine.ComposeProviders + if len(candidates) == 0 { + return "", errors.New("no compose provider specified, please refer to `man podman-compose` for details") + } + + for _, candidate := range candidates { + path, err := exec.LookPath(os.ExpandEnv(candidate)) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + continue + } + return "", err + } + // First specified provider "candidate" wins. + logrus.Debugf("Found compose provider %q", path) + return path, nil + } + + return "", errors.New("no configured compose provider found on system, please refer to the documentation for details") +} + +// composeDockerHost returns the value to be set in the DOCKER_HOST environment +// variable. +func composeDockerHost() (string, error) { + if value, ok := os.LookupEnv("DOCKER_HOST"); ok { + return value, nil + } + + // For local clients (Linux/FreeBSD), use the default API + // address. + if !registry.IsRemote() { + return registry.DefaultAPIAddress(), nil + } + + cfg, err := config.ReadCustomConfig() + if err != nil { + return "", err + } + + // NOTE: podman --connection=foo and --url=... are injected + // into the default connection below in `root.go`. + defaultConnection := cfg.Engine.ActiveService + if defaultConnection == "" { + switch runtime.GOOS { + // If no default connection is set on Linux or FreeBSD, + // we just use the local socket by default - just as + // the remote client does. + case "linux", "freebsd": + return registry.DefaultAPIAddress(), nil + // If there is no default connection on Windows or Mac + // OS, we can safely assume that something went wrong. + // A `podman machine init` will set the connection. + default: + return "", fmt.Errorf("cannot connect to a socket or via SSH: no default connection found: consider running `podman machine init`") + } + } + + connection, ok := cfg.Engine.ServiceDestinations[defaultConnection] + if !ok { + return "", fmt.Errorf("internal error: default connection %q not found in database", defaultConnection) + } + parsedConnection, err := url.Parse(connection.URI) + if err != nil { + return "", fmt.Errorf("preparing connection to remote machine: %w", err) + } + + // If the default connection does not point to a `podman + // machine`, we cannot use a local path and need to use SSH. + if !connection.IsMachine { + // Compose doesn't like paths, so we optimistically + // assume the presence of a Docker socket on the remote + // machine which is the case for podman machines. + return strings.TrimSuffix(connection.URI, parsedConnection.Path), nil + } + + machineProvider, err := cmdMachine.GetSystemProvider() + if err != nil { + return "", fmt.Errorf("getting machine provider: %w", err) + } + machineList, err := machineProvider.List(machine.ListOptions{}) + if err != nil { + return "", fmt.Errorf("listing machines: %w", err) + } + + // Now we know that the connection points to a machine and we + // can find the machine by looking for the one with the + // matching port. + connectionPort, err := strconv.Atoi(parsedConnection.Port()) + if err != nil { + return "", fmt.Errorf("parsing connection port: %w", err) + } + for _, item := range machineList { + if connectionPort != item.Port { + continue + } + + vm, err := machineProvider.LoadVMByName(item.Name) + if err != nil { + return "", fmt.Errorf("loading machine: %w", err) + } + info, err := vm.Inspect() + if err != nil { + return "", fmt.Errorf("inspecting machine: %w", err) + } + if info.ConnectionInfo.PodmanSocket == nil { + return "", errors.New("socket of machine is not set") + } + if info.State != machine.Running { + return "", fmt.Errorf("machine %s is not running but in state %s", item.Name, info.State) + } + return "unix://" + info.ConnectionInfo.PodmanSocket.Path, nil + } + + return "", fmt.Errorf("could not find a matching machine for connection %q", connection.URI) +} + +// composeEnv returns the compose-specific environment variables. +func composeEnv() ([]string, error) { + hostValue, err := composeDockerHost() + if err != nil { + return nil, err + } + + return []string{ + "DOCKER_HOST=" + hostValue, + // Podman doesn't support all buildkit features and since it's + // a continuous catch-up game, disable buildkit on the client + // side. + // + // See https://github.com/containers/podman/issues/18617#issuecomment-1600495841 + "DOCKER_BUILDKIT=0", + // FIXME: DOCKER_CONFIG is limited by containers/podman/issues/18617 + // and it remains unclear which default path should be set + // w.r.t. Docker compatibility and a smooth experience of podman-login + // working with podman-compose _by default_. + "DOCKER_CONFIG=" + os.Getenv("DOCKER_CONFIG"), + }, nil +} + +// underline uses ANSI codes to underline the specified string. +func underline(str string) string { + return "\033[4m" + str + "\033[0m" +} + +// composeProviderExec executes the compose provider with the specified arguments. +func composeProviderExec(args []string, stdout io.Writer, stderr io.Writer, warn bool) error { + provider, err := composeProvider() + if err != nil { + return err + } + + env, err := composeEnv() + if err != nil { + return err + } + + if stdout == nil { + stdout = os.Stdout + } + if stderr == nil { + stderr = os.Stderr + } + + cmd := exec.Command(provider, args...) + cmd.Stdin = os.Stdin + cmd.Stdout = stdout + cmd.Stderr = stderr + cmd.Env = append(os.Environ(), env...) + logrus.Debugf("Executing compose provider (%s %s) with additional env %s", provider, strings.Join(args, " "), strings.Join(env, " ")) + + if warn { + fmt.Fprint(os.Stderr, underline(fmt.Sprintf(">>>> Executing external compose provider %q. Please refer to the documentation for details. <<<<\n\n", provider))) + } + + if err := cmd.Run(); err != nil { + // Make sure podman returns with the same exit code as the compose provider. + if exitErr, isExit := err.(*exec.ExitError); isExit { + registry.SetExitCode(exitErr.ExitCode()) + } + // Format the error to make it explicit that error did not come + // from podman but from the executed compose provider. + return fmt.Errorf("executing %s %s: %w", provider, strings.Join(args, " "), err) + } + + return nil +} + +// composeHelp is a custom help function to display the help message of the +// configured compose-provider. +func composeHelp(cmd *cobra.Command) error { + tmpl, err := template.New("help_template").Parse(helpTemplate) + if err != nil { + return err + } + if err := tmpl.Execute(os.Stdout, cmd); err != nil { + return err + } + + return composeProviderExec([]string{"--help"}, nil, nil, registry.PodmanConfig().ContainersConfDefaultsRO.Engine.ComposeWarningLogs) +} + +// composeMain is the main function of the compose command. +func composeMain(cmd *cobra.Command, args []string) error { + // We have to manually parse the flags here to make sure all arguments + // after `podman compose [ARGS]` are passed to the compose provider. + // For now, we only look for the --help flag. + fs := pflag.NewFlagSet("args", pflag.ContinueOnError) + fs.ParseErrorsWhitelist.UnknownFlags = true + fs.SetInterspersed(false) + fs.BoolP("help", "h", false, "") + if err := fs.Parse(args); err != nil { + return fmt.Errorf("parsing arguments: %w", err) + } + + if len(args) == 0 || fs.Lookup("help").Changed { + return composeHelp(cmd) + } + + return composeProviderExec(args, nil, nil, registry.PodmanConfig().ContainersConfDefaultsRO.Engine.ComposeWarningLogs) +} diff --git a/docs/source/markdown/.gitignore b/docs/source/markdown/.gitignore index 8807c337f6..3aae56e5c6 100644 --- a/docs/source/markdown/.gitignore +++ b/docs/source/markdown/.gitignore @@ -1,6 +1,7 @@ podman-attach.1.md podman-auto-update.1.md podman-build.1.md +podman-compose.1.md podman-container-clone.1.md podman-container-diff.1.md podman-container-inspect.1.md diff --git a/docs/source/markdown/podman-compose.1.md.in b/docs/source/markdown/podman-compose.1.md.in new file mode 100644 index 0000000000..05c99135a1 --- /dev/null +++ b/docs/source/markdown/podman-compose.1.md.in @@ -0,0 +1,21 @@ +% podman-compose 1 + +## NAME +podman\-compose - Run Compose workloads via an external compose provider + +## SYNOPSIS +**podman compose** [*options*] [*command* [*arg* ...]] + +## DESCRIPTION +**podman compose** is a thin wrapper around an external compose provider such as docker-compose or podman-compose. This means that `podman compose` is executing another tool that implements the compose functionality but sets up the environment in a way to let the compose provider communicate transparently with the local Podman socket. The specified options as well the command and argument are passed directly to the compose provider. + +The default compose providers are `docker-compose` and `podman-compose`. If installed, `docker-compose` takes precedence since it is the original implementation of the Compose specification and is widely used on the supported platforms (i.e., Linux, Mac OS, Windows). + +If you want to change the default behavior or have a custom installation path for your provider of choice, please change the `compose_provider` field in `containers.conf(5)`. You may also set the `PODMAN_COMPOSE_PROVIDER` environment variable. + +## OPTIONS + +To see supported options of the installed compose provider, please run `podman compose --help`. + +## SEE ALSO +**[podman(1)](podman.1.md)**, **[containers.conf(5)](https://github.com/containers/common/blob/main/docs/containers.conf.5.md)** diff --git a/docs/source/markdown/podman.1.md b/docs/source/markdown/podman.1.md index 0facec194c..8126ea37cc 100644 --- a/docs/source/markdown/podman.1.md +++ b/docs/source/markdown/podman.1.md @@ -315,6 +315,7 @@ the exit codes follow the `chroot` standard, see below: | [podman-build(1)](podman-build.1.md) | Build a container image using a Containerfile. | | [podman-commit(1)](podman-commit.1.md) | Create new image based on the changed container. | | [podman-completion(1)](podman-completion.1.md) | Generate shell completion scripts | +| [podman-compose(1)](podman-compose.1.md) | Run Compose workloads via an external compose provider. | | [podman-container(1)](podman-container.1.md) | Manage containers. | | [podman-cp(1)](podman-cp.1.md) | Copy files/folders between a container and the local filesystem. | | [podman-create(1)](podman-create.1.md) | Create a new container. | diff --git a/go.mod b/go.mod index 7707c2a5d3..6c23c0ccbb 100644 --- a/go.mod +++ b/go.mod @@ -13,7 +13,7 @@ require ( github.com/containernetworking/cni v1.1.2 github.com/containernetworking/plugins v1.3.0 github.com/containers/buildah v1.31.1-0.20230722114901-5ece066f82c6 - github.com/containers/common v0.55.1-0.20230721175448-664d013a6ae2 + github.com/containers/common v0.55.1-0.20230724161016-2966c705a7a3 github.com/containers/conmon v2.0.20+incompatible github.com/containers/image/v5 v5.26.1-0.20230721194716-30c87d4a5b8d github.com/containers/libhvee v0.4.0 @@ -48,7 +48,7 @@ require ( github.com/opencontainers/go-digest v1.0.0 github.com/opencontainers/image-spec v1.1.0-rc4 github.com/opencontainers/runc v1.1.8 - github.com/opencontainers/runtime-spec v1.1.0-rc.3 + github.com/opencontainers/runtime-spec v1.1.0 github.com/opencontainers/runtime-tools v0.9.1-0.20230317050512-e931285f4b69 github.com/opencontainers/selinux v1.11.0 github.com/openshift/imagebuilder v1.2.5 diff --git a/go.sum b/go.sum index f959d6b7e5..0698e2e950 100644 --- a/go.sum +++ b/go.sum @@ -247,8 +247,8 @@ github.com/containernetworking/plugins v1.3.0 h1:QVNXMT6XloyMUoO2wUOqWTC1hWFV62Q github.com/containernetworking/plugins v1.3.0/go.mod h1:Pc2wcedTQQCVuROOOaLBPPxrEXqqXBFt3cZ+/yVg6l0= github.com/containers/buildah v1.31.1-0.20230722114901-5ece066f82c6 h1:K/S8SFQsnnNTF0Ws58SrBD9L0EuClzAG8Zp08d7+6AA= github.com/containers/buildah v1.31.1-0.20230722114901-5ece066f82c6/go.mod h1:0sptTFBBtSznLqoTh80DfvMOCNbdRsNRgVOKhBhrupA= -github.com/containers/common v0.55.1-0.20230721175448-664d013a6ae2 h1:4B42HUIAghFGSqej5RADTNf0WlOBFiGGzmGjNa3Do78= -github.com/containers/common v0.55.1-0.20230721175448-664d013a6ae2/go.mod h1:O/JSRY1dLfwgBxVvn3yJfKvF63KEjbNJcJAtjpNvO90= +github.com/containers/common v0.55.1-0.20230724161016-2966c705a7a3 h1:0fHDAdLNfOs5AuBizE7TOECDA4gCLoCgE6geR3k/H78= +github.com/containers/common v0.55.1-0.20230724161016-2966c705a7a3/go.mod h1:SUX+gHoElocPp664K79AEt+GGHwngBJLrKdwNIRW0tQ= github.com/containers/conmon v2.0.20+incompatible h1:YbCVSFSCqFjjVwHTPINGdMX1F6JXHGTUje2ZYobNrkg= github.com/containers/conmon v2.0.20+incompatible/go.mod h1:hgwZ2mtuDrppv78a/cOBNiCm6O0UMWGx1mu7P00nu5I= github.com/containers/image/v5 v5.26.1-0.20230721194716-30c87d4a5b8d h1:g6DFcXXEMd1OwSVtbrUolGzmkMNyQDyc4OKHOFxbNeE= @@ -820,8 +820,8 @@ github.com/opencontainers/runtime-spec v1.0.2-0.20190207185410-29686dbc5559/go.m github.com/opencontainers/runtime-spec v1.0.2/go.mod h1:jwyrGlmzljRJv/Fgzds9SsS/C5hL+LL3ko9hs6T5lQ0= github.com/opencontainers/runtime-spec v1.0.3-0.20200929063507-e6143ca7d51d/go.mod h1:jwyrGlmzljRJv/Fgzds9SsS/C5hL+LL3ko9hs6T5lQ0= github.com/opencontainers/runtime-spec v1.0.3-0.20210326190908-1c3f411f0417/go.mod h1:jwyrGlmzljRJv/Fgzds9SsS/C5hL+LL3ko9hs6T5lQ0= -github.com/opencontainers/runtime-spec v1.1.0-rc.3 h1:l04uafi6kxByhbxev7OWiuUv0LZxEsYUfDWZ6bztAuU= -github.com/opencontainers/runtime-spec v1.1.0-rc.3/go.mod h1:jwyrGlmzljRJv/Fgzds9SsS/C5hL+LL3ko9hs6T5lQ0= +github.com/opencontainers/runtime-spec v1.1.0 h1:HHUyrt9mwHUjtasSbXSMvs4cyFxh+Bll4AjJ9odEGpg= +github.com/opencontainers/runtime-spec v1.1.0/go.mod h1:jwyrGlmzljRJv/Fgzds9SsS/C5hL+LL3ko9hs6T5lQ0= github.com/opencontainers/runtime-tools v0.0.0-20181011054405-1d69bd0f9c39/go.mod h1:r3f7wjNzSs2extwzU3Y+6pKfobzPh+kKFJ3ofN+3nfs= github.com/opencontainers/runtime-tools v0.9.1-0.20230317050512-e931285f4b69 h1:NL4xDvl68WWqQ+8WPMM3l5PsZTxaT7Z4K3VSKDRuAGs= github.com/opencontainers/runtime-tools v0.9.1-0.20230317050512-e931285f4b69/go.mod h1:bNpfuSHA3DZRtD0TPWO8LzgtLpFPTVA/3jDkzD/OPyk= diff --git a/hack/man-page-checker b/hack/man-page-checker index b2b5049c65..748da6b7a0 100755 --- a/hack/man-page-checker +++ b/hack/man-page-checker @@ -78,6 +78,11 @@ function compare_usage() { local cmd="$1" local from_man="$2" + # Special case: this is an external call to docker + if [[ $cmd = "podman compose" ]] || [[ $cmd = "podmansh" ]]; then + return + fi + # Sometimes in CI we run before podman gets built. test -x ../../../bin/podman || return diff --git a/hack/xref-helpmsgs-manpages b/hack/xref-helpmsgs-manpages index 2c75ce620c..2335f5dc0a 100755 --- a/hack/xref-helpmsgs-manpages +++ b/hack/xref-helpmsgs-manpages @@ -95,6 +95,13 @@ my %Format_Option_Is_Special = map { $_ => 1 } ( 'inspect', # ambiguous (container/image) ); +# Do not cross-reference these. +my %Skip_Subcommand = map { $_ => 1 } ( + "help", # has no man page + "completion", # internal (hidden) subcommand + "compose", # external tool, outside of our control +); + # END user-customizable section ############################################################################### @@ -279,8 +286,8 @@ sub xref_by_man { next if "@subcommand" eq 'system' && $k eq 'service'; - # Special case: podman completion is a hidden command - next if $k eq 'completion'; + # Special case for hidden or external commands + next if $Skip_Subcommand{$k}; warn "$ME: 'podman @subcommand': $k in $man, but not --help\n"; ++$Errs; @@ -346,7 +353,7 @@ sub podman_help { } $help{$subcommand} = podman_help(@_, $subcommand) - unless $subcommand eq 'help'; # 'help' not in man + unless $Skip_Subcommand{$subcommand}; } } elsif ($section eq 'options') { diff --git a/pkg/rootless/rootless_linux.c b/pkg/rootless/rootless_linux.c index bf400c0594..66963660a7 100644 --- a/pkg/rootless/rootless_linux.c +++ b/pkg/rootless/rootless_linux.c @@ -387,6 +387,7 @@ can_use_shortcut (char **argv) || strcmp (argv[argc], "version") == 0 || strcmp (argv[argc], "context") == 0 || strcmp (argv[argc], "search") == 0 + || strcmp (argv[argc], "compose") == 0 || (strcmp (argv[argc], "system") == 0 && argv[argc+1] && strcmp (argv[argc+1], "service") != 0)) { ret = false; diff --git a/test/compose/test-compose b/test/compose/test-compose index fe2da9532f..201cead240 100755 --- a/test/compose/test-compose +++ b/test/compose/test-compose @@ -276,11 +276,12 @@ done # When rootless use a socket path accessible by the rootless user if is_rootless; then DOCKER_SOCK="$WORKDIR/docker.sock" - DOCKER_HOST="unix://$DOCKER_SOCK" - # export DOCKER_HOST docker-compose will use it - export DOCKER_HOST fi +# export DOCKER_HOST docker-compose will use it +DOCKER_HOST="unix://$DOCKER_SOCK" +export DOCKER_HOST + # Identify the tests to run. If called with args, use those as globs. tests_to_run=() if [ -n "$*" ]; then @@ -331,12 +332,12 @@ for t in "${tests_to_run[@]}"; do trap '. teardown.sh' 0 fi - docker-compose up -d &> $logfile + podman compose up -d &> $logfile docker_compose_rc=$? if [[ $docker_compose_rc -ne 0 ]]; then _show_ok 0 "$testname - up" "[ok]" "status=$docker_compose_rc" sed -e 's/^/# /' <$logfile - docker-compose down >>$logfile 2>&1 # No status check here + podman compose down >>$logfile 2>&1 # No status check here exit 1 fi _show_ok 1 "$testname - up" @@ -354,7 +355,7 @@ for t in "${tests_to_run[@]}"; do fi # Done. Clean up. - docker-compose down &>> $logfile + podman compose down &>> $logfile rc=$? if [[ $rc -eq 0 ]]; then _show_ok 1 "$testname - down" diff --git a/test/system/015-help.bats b/test/system/015-help.bats index 927645f296..36bda467cf 100644 --- a/test/system/015-help.bats +++ b/test/system/015-help.bats @@ -17,6 +17,11 @@ function check_help() { local -A found for cmd in $(_podman_commands "$@"); do + # Skip the compose command which is calling `docker-compose --help` + # and hence won't match the assumptions made below. + if [[ "$cmd" == "compose" ]]; then + continue + fi # Human-readable podman command string, with multiple spaces collapsed command_string="podman $* $cmd" command_string=${command_string// / } # 'podman x' -> 'podman x' diff --git a/test/system/600-completion.bats b/test/system/600-completion.bats index 1060cde46f..6a577ffc12 100644 --- a/test/system/600-completion.bats +++ b/test/system/600-completion.bats @@ -37,6 +37,11 @@ function check_shell_completion() { " for cmd in $(_podman_commands "$@"); do + # Skip the compose command which is calling `docker-compose --help` + # and hence won't match the assumptions made below. + if [[ "$cmd" == "compose" ]]; then + continue + fi # Human-readable podman command string, with multiple spaces collapsed name="podman" if is_remote; then diff --git a/test/system/610-format.bats b/test/system/610-format.bats index 1862301552..448d1e6ed6 100644 --- a/test/system/610-format.bats +++ b/test/system/610-format.bats @@ -55,6 +55,12 @@ can_run_stats= # > run the command with --format '{{"\n"}}' and make sure it passes function check_subcommand() { for cmd in $(_podman_commands "$@"); do + # Skip the compose command which is calling `docker-compose --help` + # and hence won't match the assumptions made below. + if [[ "$cmd" == "compose" ]]; then + continue + fi + # Human-readable podman command string, with multiple spaces collapsed # Special case: 'podman machine' can only be run under ideal conditions if [[ "$cmd" = "machine" ]] && [[ -z "$can_run_podman_machine" ]]; then continue diff --git a/test/system/850-compose.bats b/test/system/850-compose.bats new file mode 100644 index 0000000000..91e6afc267 --- /dev/null +++ b/test/system/850-compose.bats @@ -0,0 +1,67 @@ +#!/usr/bin/env bats -*- bats -*- +# +# Smoke tests for the podman-compose command. test/compose takes care of functional tests. +# + +load helpers + +@test "podman compose - smoke tests" { + fake_compose_bin="$PODMAN_TMPDIR/fake_compose" + cat >$fake_compose_bin <$compose_conf <