Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions newsfragments/do_not_pass_stdin_to_containers_on_up.bugfix
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Only forward stdin to containers during `run` and `exec` commands, matching docker-compose behavior.
23 changes: 20 additions & 3 deletions podman_compose.py
Original file line number Diff line number Diff line change
Expand Up @@ -1945,9 +1945,18 @@ async def run( # pylint: disable=dangerous-default-value
log_formatter: str | None = None,
*,
suppress_output: bool = False,
pass_stdin: bool = False,
# Intentionally mutable default argument to hold references to tasks
task_reference: set[asyncio.Task] = set(),
) -> int | None:
"""Run a podman command.

By default, podman-compose does not pass its own stdin to podman
subprocesses. This prevents piped input intended for podman-compose
(e.g. ``cat compose.yml | podman-compose -f - up``) from leaking
into containers. Only ``run`` and ``exec`` commands should set
``pass_stdin=True`` to allow interactive container input.
"""
async with self.semaphore:
cmd_args = list(map(str, cmd_args or []))
xargs = self.compose.get_podman_args(cmd) if cmd else []
Expand All @@ -1956,9 +1965,16 @@ async def run( # pylint: disable=dangerous-default-value
if self.dry_run:
return None

# When pass_stdin is False, use PIPE so the subprocess does not
# inherit podman-compose's stdin. Using DEVNULL would send EOF
# to containers with stdin_open, causing them to exit
# immediately instead of waiting for input as docker-compose does.
stdin_arg = None if pass_stdin else asyncio.subprocess.PIPE

if log_formatter is not None:
p = await asyncio.create_subprocess_exec(
*cmd_ls,
stdin=stdin_arg,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
close_fds=False,
Expand All @@ -1984,13 +2000,14 @@ async def run( # pylint: disable=dangerous-default-value
elif suppress_output:
p = await asyncio.create_subprocess_exec(
*cmd_ls,
stdin=stdin_arg,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
close_fds=False,
) # pylint: disable=consider-using-with

else:
p = await asyncio.create_subprocess_exec(*cmd_ls, close_fds=False) # pylint: disable=consider-using-with
p = await asyncio.create_subprocess_exec(*cmd_ls, stdin=stdin_arg, close_fds=False) # pylint: disable=consider-using-with

try:
exit_code = await p.wait()
Expand Down Expand Up @@ -4587,7 +4604,7 @@ async def compose_run(compose: PodmanCompose, args: argparse.Namespace) -> None:
podman_args.insert(1, "-i")
if args.rm:
podman_args.insert(1, "--rm")
p = await compose.podman.run([], "run", podman_args)
p = await compose.podman.run([], "run", podman_args, pass_stdin=True)
sys.exit(p)


Expand Down Expand Up @@ -4677,7 +4694,7 @@ async def compose_exec(compose: PodmanCompose, args: argparse.Namespace) -> None
container_name = container_names[args.index - 1]
cnt = compose.container_by_name[container_name]
podman_args = compose_exec_args(cnt, container_name, args)
p = await compose.podman.run([], "exec", podman_args)
p = await compose.podman.run([], "exec", podman_args, pass_stdin=True)
sys.exit(p)


Expand Down
Empty file.
5 changes: 5 additions & 0 deletions tests/integration/stdin_behavior/docker-compose.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
services:
test:
image: nopush/podman-compose-test
command: ["sh", "-c", "echo 'Waiting...'; read line; echo 'Received:' $$line"]
stdin_open: true
150 changes: 150 additions & 0 deletions tests/integration/stdin_behavior/test_stdin_behavior.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
# SPDX-License-Identifier: GPL-2.0

import os
import textwrap
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:
return os.path.join(os.path.join(test_path(), "stdin_behavior"), "docker-compose.yaml")


class TestStdinBehavior(unittest.TestCase, RunSubprocessMixin):
def setUp(self) -> None:
# Clean up any leftover containers before each test
self.run_subprocess(
[
podman_compose_path(),
"-f",
compose_yaml_path(),
"down",
"-t",
"0",
],
)

def tearDown(self) -> None:
# Clean up any leftover containers after each test
self.run_subprocess(
[
podman_compose_path(),
"-f",
compose_yaml_path(),
"down",
"-t",
"0",
],
)

def test_up_does_not_pass_stdin_to_container(self) -> None:
"""
When stdin is piped to podman-compose up, the container should not
receive it. A container with stdin_open=true should wait for input
instead of reading the piped data.
"""
self.run_subprocess(
[
podman_compose_path(),
"-f",
compose_yaml_path(),
"up",
"--abort-on-container-exit",
],
input=b"hello\n",
timeout=5,
)

output, _ = self.run_subprocess_assert_returncode(
[
podman_compose_path(),
"-f",
compose_yaml_path(),
"logs",
"--no-log-prefix",
"--no-color",
],
0,
)
self.assertEqual(output, b'Waiting...\nReceived:\n')

def test_run_passes_stdin_to_container(self) -> None:
"""
When stdin is piped to podman-compose run, the container should
receive it, matching docker-compose behavior.
"""
out, _ = self.run_subprocess_assert_returncode(
[
podman_compose_path(),
"-f",
compose_yaml_path(),
"run",
"test",
],
input=b"hello\n",
)
self.assertIn(b"Received: hello", out)

def test_exec_passes_stdin_to_container(self) -> None:
"""
When stdin is piped to podman-compose exec, the container should
receive it, matching docker-compose behavior.
"""
# Start the container in detached mode first
self.run_subprocess_assert_returncode(
[
podman_compose_path(),
"-f",
compose_yaml_path(),
"up",
"-d",
],
)

out, _ = self.run_subprocess_assert_returncode(
[
podman_compose_path(),
"-f",
compose_yaml_path(),
"exec",
"test",
"sh",
"-c",
"read line; echo 'ReceivedExec:' $line",
],
input=b"exec-hello\n",
)
self.assertEqual(out, b'exec-hello\r\nReceivedExec: exec-hello\r\n')

def test_compose_file_from_stdin(self) -> None:
"""
Reading a compose file from stdin via ``-f -`` must still work
even though stdin is not forwarded to containers.
"""
compose_content = b"""services:
test:
image: nopush/podman-compose-test
command: ["echo", "from-stdin-compose"]
"""
out, _ = self.run_subprocess_assert_returncode(
[
podman_compose_path(),
"-f",
"-",
"config",
],
input=compose_content,
)
expected = textwrap.dedent("""\
services:
test:
command:
- echo
- from-stdin-compose
image: nopush/podman-compose-test

""")
self.assertEqual(out.decode("utf-8"), expected)
13 changes: 11 additions & 2 deletions tests/integration/test_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ def run_subprocess(
env: dict[str, str] = {},
timeout: Optional[float] = None,
cwd: Optional[Path] = None,
input: Optional[bytes] = None,
) -> tuple[bytes, bytes, int]:
begin = time.time()
if self.is_debug_enabled():
Expand All @@ -82,10 +83,15 @@ def run_subprocess(
args,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
stdin=subprocess.PIPE if input is not None else None,
env=os.environ | env,
cwd=cwd,
)
out, err = proc.communicate(timeout=timeout)
try:
out, err = proc.communicate(input=input, timeout=timeout)
except subprocess.TimeoutExpired:
proc.kill()
out, err = proc.communicate()
if self.is_debug_enabled():
print("TEST_CALL completed", time.time() - begin)
print("STDOUT:", out.decode('utf-8'))
Expand All @@ -99,8 +105,11 @@ def run_subprocess_assert_returncode(
env: dict[str, str] = {},
timeout: Optional[float] = None,
cwd: Optional[Path] = None,
input: Optional[bytes] = None,
) -> tuple[bytes, bytes]:
out, err, returncode = self.run_subprocess(args, env=env, timeout=timeout, cwd=cwd)
out, err, returncode = self.run_subprocess(
args, env=env, timeout=timeout, cwd=cwd, input=input
)
decoded_out = out.decode('utf-8')
decoded_err = err.decode('utf-8')
self.assertEqual( # type: ignore[attr-defined]
Expand Down