From 70a2f729e4591c2e310b2d9810d733be2b56ea06 Mon Sep 17 00:00:00 2001 From: Erik van Oosten Date: Sun, 15 Mar 2026 13:17:14 +0100 Subject: [PATCH] Increase compatibility for terminal/no-terminal options Increase compatibility with docker compose for exec and run commands: - Add support for long option `--no-tty` besides `-T`. - Add support for hidden options `-t` and `--tty`. - Make default for tty auto-detected. - For the `exec` command: run `podman exec` without `--interactive` when no-tty is specified (the `run` command already does this). - For `run`, fix adding --rm option when detached. The default for terminal/no-terminal is based on terminal detection, mimicking docker compose code for [run](https://github.com/docker/compose/blob/3371227794f5f3645f4f19829c60a741635ed329/cmd/compose/run.go#L178) and [exec](https://github.com/docker/compose/blob/3371227794f5f3645f4f19829c60a741635ed329/cmd/compose/exec.go#L72). Hidden options `-t` and `--tty` are mimicking docker compose code for [run](https://github.com/docker/compose/blob/3371227794f5f3645f4f19829c60a741635ed329/cmd/compose/run.go#L195-L196) and [exec](https://github.com/docker/compose/blob/3371227794f5f3645f4f19829c60a741635ed329/cmd/compose/exec.go#L77-L78). NOTE: some of the added integration tests require the `script` command to emulate a TTY. This command is available in most unix environments, but might not be present in a minimal docker images. When `script` is not available, these tests pass without doing anything. Fixes #900. Signed-off-by: Erik van Oosten --- completion/bash/podman-compose | 12 +- newsfragments/improve-no-tty-compat.feature | 1 + podman_compose.py | 37 +++--- tests/integration/exec_tty/__init__.py | 0 tests/integration/exec_tty/context/Dockerfile | 3 + tests/integration/exec_tty/docker-compose.yml | 6 + .../exec_tty/test_podman_compose_exec.py | 108 ++++++++++++++++++ tests/integration/run_tty/__init__.py | 0 tests/integration/run_tty/context/Dockerfile | 1 + tests/integration/run_tty/docker-compose.yml | 6 + .../run_tty/test_podman_compose_run.py | 89 +++++++++++++++ tests/unit/test_compose_exec_args.py | 10 +- ..._compose_run_update_container_from_args.py | 2 +- 13 files changed, 254 insertions(+), 21 deletions(-) create mode 100644 newsfragments/improve-no-tty-compat.feature create mode 100644 tests/integration/exec_tty/__init__.py create mode 100644 tests/integration/exec_tty/context/Dockerfile create mode 100644 tests/integration/exec_tty/docker-compose.yml create mode 100644 tests/integration/exec_tty/test_podman_compose_exec.py create mode 100644 tests/integration/run_tty/__init__.py create mode 100644 tests/integration/run_tty/context/Dockerfile create mode 100644 tests/integration/run_tty/docker-compose.yml create mode 100644 tests/integration/run_tty/test_podman_compose_run.py diff --git a/completion/bash/podman-compose b/completion/bash/podman-compose index 4f62e912..f12bcfa1 100644 --- a/completion/bash/podman-compose +++ b/completion/bash/podman-compose @@ -73,7 +73,7 @@ _completeUpArgs() { # complete the arguments for `podman-compose exec` and return 0 _completeExecArgs() { - exec_opts="${help_opts} -d --detach --privileged -u --user -T --index -e --env -w --workdir" + exec_opts="${help_opts} -d --detach --privileged -u --user -T --no-tty --index -e --env -w --workdir" if [[ ${prev} == "-u" || ${prev} == "--user" || ${prev} == "--index" || ${prev} == "-e" || ${prev} == "--env" || ${prev} == "-w" || ${prev} == "--workdir" ]]; then return 0 elif [[ ${cur} == -* ]]; then @@ -237,7 +237,7 @@ _completeStartArgs() { # complete the arguments for `podman-compose run` and return 0 _completeRunArgs() { - run_opts="${help_opts} -d --detach --privileged -u --user -T --index -e --env -w --workdir" + run_opts="${help_opts} -d --detach --privileged -u --user -T --no-tty --index -e --env -w --workdir" if [[ ${prev} == "-u" || ${prev} == "--user" || ${prev} == "--index" || ${prev} == "-e" || ${prev} == "--env" || ${prev} == "-w" || ${prev} == "--workdir" ]]; then return 0 elif [[ ${cur} == -* ]]; then @@ -267,7 +267,7 @@ _podmanCompose() { help_opts="-h --help" # global options that don't take additional arguments - basic_global_opts="${help_opts} -v --no-ansi --no-cleanup --dry-run" + basic_global_opts="${help_opts} -v --no-ansi --no-cleanup --dry-run" # global options that take paths as arguments path_arg_global_opts="-f --file --podman-path" @@ -291,7 +291,7 @@ _podmanCompose() { if [[ $? -eq 0 ]]; then return 0 fi - + # computing comp_cword_adj, which thruthfully tells us how deep in the subcommands tree we are # additionally, set the chosen_root_command if possible comp_cword_adj=${COMP_CWORD} @@ -309,7 +309,7 @@ _podmanCompose() { fi if [[ ${el} == -* && ${el} != ${cur} ]]; then let "comp_cword_adj--" - + for opt in ${arg_global_opts_array[@]}; do if [[ ${el} == ${opt} ]]; then skip_next="yes" @@ -320,7 +320,7 @@ _podmanCompose() { fi done fi - + if [[ ${comp_cword_adj} -eq 1 ]]; then _completeRoot diff --git a/newsfragments/improve-no-tty-compat.feature b/newsfragments/improve-no-tty-compat.feature new file mode 100644 index 00000000..3866d318 --- /dev/null +++ b/newsfragments/improve-no-tty-compat.feature @@ -0,0 +1 @@ +Increased compatibility with exec/run parameters `-T`, `--no-tty`, `-t`, and `-tty`, default is now detected diff --git a/podman_compose.py b/podman_compose.py index 03f298a3..8a1f49f3 100755 --- a/podman_compose.py +++ b/podman_compose.py @@ -1419,7 +1419,7 @@ async def container_to_args( podman_args.extend(["--hostname", cnt["hostname"]]) if cnt.get("shm_size"): podman_args.extend(["--shm-size", str(cnt["shm_size"])]) - if cnt.get("stdin_open"): + if cnt.get("tty") and cnt.get("stdin_open"): podman_args.append("-i") if cnt.get("stop_signal"): podman_args.extend(["--stop-signal", cnt["stop_signal"]]) @@ -4071,10 +4071,10 @@ async def compose_run(compose: PodmanCompose, args: argparse.Namespace) -> None: compose_run_update_container_from_args(compose, cnt, args) # run podman podman_args = await container_to_args(compose, cnt, args.detach, args.no_deps) - if not args.detach: + if args.tty and not args.detach: podman_args.insert(1, "-i") - if args.rm: - podman_args.insert(1, "--rm") + if args.rm: + podman_args.insert(1, "--rm") p = await compose.podman.run([], "run", podman_args) sys.exit(p) @@ -4111,7 +4111,7 @@ def compose_run_update_container_from_args( volumes = clone(cnt.get("volumes", [])) volumes.extend(args.volume) cnt["volumes"] = volumes - cnt["tty"] = not args.T + cnt["tty"] = args.tty if args.cnt_command is not None and len(args.cnt_command) > 0: cnt["command"] = args.cnt_command # can't restart and --rm @@ -4131,14 +4131,15 @@ async def compose_exec(compose: PodmanCompose, args: argparse.Namespace) -> None def compose_exec_args(cnt: dict, container_name: str, args: argparse.Namespace) -> list[str]: - podman_args = ["--interactive"] + podman_args = [] if args.privileged: podman_args += ["--privileged"] if args.user: podman_args += ["--user", args.user] if args.workdir: podman_args += ["--workdir", args.workdir] - if not args.T: + if args.tty: + podman_args += ["--interactive"] podman_args += ["--tty"] env = dict(cnt.get("environment", {})) if args.env: @@ -4600,11 +4601,16 @@ def compose_run_parse(parser: argparse.ArgumentParser) -> None: action="append", help="Bind mount a volume (can be used multiple times)", ) - parser.add_argument( + tty_parser = parser.add_mutually_exclusive_group(required=False) + tty_parser.add_argument( "-T", - action="store_true", - help="Disable pseudo-tty allocation. By default `podman-compose run` allocates a TTY.", + "--no-tty", + dest="tty", + action="store_false", + help="Disable pseudo-TTY allocation (default: auto-detected)", ) + tty_parser.add_argument("-t", "--tty", dest="tty", action="store_true", help=argparse.SUPPRESS) + parser.set_defaults(tty=sys.stdout.isatty()) parser.add_argument( "-w", "--workdir", @@ -4638,11 +4644,16 @@ def compose_exec_parse(parser: argparse.ArgumentParser) -> None: parser.add_argument( "-u", "--user", type=str, default=None, help="Run as specified username or uid" ) - parser.add_argument( + tty_parser = parser.add_mutually_exclusive_group(required=False) + tty_parser.add_argument( "-T", - action="store_true", - help="Disable pseudo-tty allocation. By default `podman-compose run` allocates a TTY.", + "--no-tty", + dest="tty", + action="store_false", + help="Disable pseudo-TTY allocation (default: auto-detected)", ) + tty_parser.add_argument("-t", "--tty", dest="tty", action="store_true", help=argparse.SUPPRESS) + parser.set_defaults(tty=sys.stdout.isatty()) parser.add_argument( "--index", type=int, diff --git a/tests/integration/exec_tty/__init__.py b/tests/integration/exec_tty/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/integration/exec_tty/context/Dockerfile b/tests/integration/exec_tty/context/Dockerfile new file mode 100644 index 00000000..6ff7aa13 --- /dev/null +++ b/tests/integration/exec_tty/context/Dockerfile @@ -0,0 +1,3 @@ +FROM nopush/podman-compose-test + +CMD ["sleep", "120"] diff --git a/tests/integration/exec_tty/docker-compose.yml b/tests/integration/exec_tty/docker-compose.yml new file mode 100644 index 00000000..0a64b291 --- /dev/null +++ b/tests/integration/exec_tty/docker-compose.yml @@ -0,0 +1,6 @@ +version: "3" +services: + web1: + build: + context: ./context + dockerfile: Dockerfile diff --git a/tests/integration/exec_tty/test_podman_compose_exec.py b/tests/integration/exec_tty/test_podman_compose_exec.py new file mode 100644 index 00000000..37e7700f --- /dev/null +++ b/tests/integration/exec_tty/test_podman_compose_exec.py @@ -0,0 +1,108 @@ +# SPDX-License-Identifier: GPL-2.0 + +import os +import subprocess +import unittest + +from tests.integration.test_utils import RunSubprocessMixin +from tests.integration.test_utils import podman_compose_path +from tests.integration.test_utils import test_path + + +def compose_yaml_path() -> str: + """Returns the path to the compose file used for this test module""" + base_path = os.path.join(test_path(), "exec_tty") + return os.path.join(base_path, "docker-compose.yml") + + +class TestComposeExtraHosts(unittest.TestCase, RunSubprocessMixin): + def test_exec(self) -> None: + script_installed = ( + subprocess.run(["script", "--version"], stdout=subprocess.DEVNULL).returncode == 0 + ) + + try: + self.run_subprocess_assert_returncode([ + podman_compose_path(), + "-f", + compose_yaml_path(), + "build", + "--no-cache", + ]) + + self.run_subprocess_assert_returncode([ + podman_compose_path(), + "-f", + compose_yaml_path(), + "up", + "-d", + ]) + + # TTY auto detected (no tty) + self.run_subprocess_assert_returncode( + [ + podman_compose_path(), + "-f", + compose_yaml_path(), + "exec", + "web1", + "tty", + "-s", + ], + expected_returncode=1, # exit code 1 == no tty + ) + + # TTY auto detected (tty emulated by 'script' command) + if script_installed: + self.run_subprocess_assert_returncode( + [ + "script", + "--return", + "--quiet", + "--log-out", + "/dev/null", + "--command", + f"{podman_compose_path()} -f {compose_yaml_path()} exec web1 tty -s", + ], + expected_returncode=0, # exit code 0 == tty + ) + + # TTY disabled (even though an emulated tty is available through the 'script' command) + if script_installed: + self.run_subprocess_assert_returncode( + [ + "script", + "--return", + "--log-out", + "/dev/null", + "--quiet", + "--command", + f"{podman_compose_path()} " + f"-f {compose_yaml_path()} " + "exec --no-tty web1 tty -s", + ], + expected_returncode=1, # exit code 1 == no tty + ) + + # TTY enabled (tty emulated by 'script' command) + if script_installed: + self.run_subprocess_assert_returncode( + [ + "script", + "--return", + "--log-out", + "/dev/null", + "--quiet", + "--command", + f"{podman_compose_path()} -f {compose_yaml_path()} exec --tty web1 tty -s", + ], + expected_returncode=0, # exit code 0 == tty + ) + + finally: + self.run_subprocess_assert_returncode([ + podman_compose_path(), + "-f", + compose_yaml_path(), + "down", + ]) diff --git a/tests/integration/run_tty/__init__.py b/tests/integration/run_tty/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/integration/run_tty/context/Dockerfile b/tests/integration/run_tty/context/Dockerfile new file mode 100644 index 00000000..e6b6caba --- /dev/null +++ b/tests/integration/run_tty/context/Dockerfile @@ -0,0 +1 @@ +FROM nopush/podman-compose-test diff --git a/tests/integration/run_tty/docker-compose.yml b/tests/integration/run_tty/docker-compose.yml new file mode 100644 index 00000000..0a64b291 --- /dev/null +++ b/tests/integration/run_tty/docker-compose.yml @@ -0,0 +1,6 @@ +version: "3" +services: + web1: + build: + context: ./context + dockerfile: Dockerfile diff --git a/tests/integration/run_tty/test_podman_compose_run.py b/tests/integration/run_tty/test_podman_compose_run.py new file mode 100644 index 00000000..93e5705e --- /dev/null +++ b/tests/integration/run_tty/test_podman_compose_run.py @@ -0,0 +1,89 @@ +# SPDX-License-Identifier: GPL-2.0 + +import os +import subprocess +import unittest + +from tests.integration.test_utils import RunSubprocessMixin +from tests.integration.test_utils import podman_compose_path +from tests.integration.test_utils import test_path + + +def compose_yaml_path() -> str: + """Returns the path to the compose file used for this test module""" + base_path = os.path.join(test_path(), "run_tty") + return os.path.join(base_path, "docker-compose.yml") + + +class TestComposeExtraHosts(unittest.TestCase, RunSubprocessMixin): + def test_exec(self) -> None: + script_installed = ( + subprocess.run(["script", "--version"], stdout=subprocess.DEVNULL).returncode == 0 + ) + + self.run_subprocess_assert_returncode([ + podman_compose_path(), + "-f", + compose_yaml_path(), + "build", + "--no-cache", + ]) + + # TTY auto detected (no tty) + self.run_subprocess_assert_returncode( + [ + podman_compose_path(), + "-f", + compose_yaml_path(), + "run", + "web1", + "tty", + "-s", + ], + expected_returncode=1, # exit code 1 == no tty + ) + + # TTY auto detected (tty emulated by 'script' command) + if script_installed: + self.run_subprocess_assert_returncode( + [ + "script", + "--return", + "--quiet", + "--log-out", + "/dev/null", + "--command", + f"{podman_compose_path()} -f {compose_yaml_path()} run web1 tty -s", + ], + expected_returncode=0, # exit code 0 == tty + ) + + # TTY disabled (even though an emulated tty is available through the 'script' command) + if script_installed: + self.run_subprocess_assert_returncode( + [ + "script", + "--return", + "--quiet", + "--log-out", + "/dev/null", + "--command", + f"{podman_compose_path()} -f {compose_yaml_path()} run --no-tty web1 tty -s", + ], + expected_returncode=1, # exit code 1 == no tty + ) + + # TTY enabled (tty emulated by 'script' command) + if script_installed: + self.run_subprocess_assert_returncode( + [ + "script", + "--return", + "--log-out", + "/dev/null", + "--quiet", + "--command", + f"{podman_compose_path()} -f {compose_yaml_path()} run --tty web1 tty -s", + ], + expected_returncode=0, # exit code 0 == tty + ) diff --git a/tests/unit/test_compose_exec_args.py b/tests/unit/test_compose_exec_args.py index 5f9529ad..6ec6206a 100644 --- a/tests/unit/test_compose_exec_args.py +++ b/tests/unit/test_compose_exec_args.py @@ -30,6 +30,14 @@ def test_additional_env_value_equals(self) -> None: ] self.assertEqual(result, expected) + def test_noninteractive(self) -> None: + cnt = get_minimal_container() + args = get_minimal_args() + args.tty = False + + result = compose_exec_args(cnt, "container_name", args) + self.assertEqual(result, ["container_name"]) + def get_minimal_container() -> dict: return {} @@ -37,7 +45,7 @@ def get_minimal_container() -> dict: def get_minimal_args() -> argparse.Namespace: return argparse.Namespace( - T=None, + tty=True, cnt_command=None, env=None, privileged=None, diff --git a/tests/unit/test_compose_run_update_container_from_args.py b/tests/unit/test_compose_run_update_container_from_args.py index 1a5953f5..031b6061 100644 --- a/tests/unit/test_compose_run_update_container_from_args.py +++ b/tests/unit/test_compose_run_update_container_from_args.py @@ -63,7 +63,7 @@ def get_minimal_compose() -> PodmanCompose: def get_minimal_args() -> argparse.Namespace: return argparse.Namespace( - T=None, + tty=True, cnt_command=None, entrypoint=None, env=None,