diff --git a/newsfragments/interpolate_service_env_file.feature b/newsfragments/interpolate_service_env_file.feature new file mode 100644 index 00000000..e1ad1aab --- /dev/null +++ b/newsfragments/interpolate_service_env_file.feature @@ -0,0 +1 @@ +Support variable interpolation in service-level env_file files, allowing references to project environment variables using both ``${VAR}`` and ``$VAR`` syntaxes. diff --git a/podman_compose.py b/podman_compose.py index 3a90cc0a..cc95d590 100755 --- a/podman_compose.py +++ b/podman_compose.py @@ -16,6 +16,7 @@ import glob import hashlib import inspect +import io import json import logging import os @@ -1398,7 +1399,11 @@ async def container_to_args( continue raise ValueError(f"Env file at {i} does not exist") dotenv_dict = {} - dotenv_dict = dotenv_to_dict(i) + project_environ = getattr(compose, 'environ', None) + if isinstance(project_environ, dict): + dotenv_dict = dotenv_to_dict(i, project_environ) + else: + dotenv_dict = dotenv_to_dict(i) env = norm_as_list(dotenv_dict) for e in env: podman_args.extend(["-e", e]) @@ -2343,10 +2348,31 @@ def resolve_extends( services[name] = new_service -def dotenv_to_dict(dotenv_path: str) -> dict[str, str | None]: +def _preprocess_env_file(content: str) -> str: + """Replace $VAR with ${VAR} so python-dotenv can interpolate both syntaxes.""" + # Replace $VAR with ${VAR} but leave $$ and ${VAR} unchanged. + # Match $ followed by a valid variable name (alphanumeric + underscore), + # but not when preceded by another $ or followed by {. + return re.sub(r"(? dict[str, str | None]: if not os.path.isfile(dotenv_path): return {} - return dotenv_values(dotenv_path) + with open(dotenv_path, encoding="utf-8") as fh: + content = fh.read() + content = _preprocess_env_file(content) + if environ: + original_environ = dict(os.environ) + os.environ.update({k: v for k, v in environ.items() if v is not None}) + try: + return dotenv_values(stream=io.StringIO(content)) + finally: + os.environ.clear() + os.environ.update(original_environ) + return dotenv_values(stream=io.StringIO(content)) COMPOSE_DEFAULT_LS = [ diff --git a/tests/integration/env_file_interpolation/.env b/tests/integration/env_file_interpolation/.env new file mode 100644 index 00000000..09d50138 --- /dev/null +++ b/tests/integration/env_file_interpolation/.env @@ -0,0 +1 @@ +BAR=bar diff --git a/tests/integration/env_file_interpolation/.env.extra b/tests/integration/env_file_interpolation/.env.extra new file mode 100644 index 00000000..a9a6cdc5 --- /dev/null +++ b/tests/integration/env_file_interpolation/.env.extra @@ -0,0 +1 @@ +FOO=${BAR} diff --git a/tests/integration/env_file_interpolation/__init__.py b/tests/integration/env_file_interpolation/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/integration/env_file_interpolation/docker-compose.yml b/tests/integration/env_file_interpolation/docker-compose.yml new file mode 100644 index 00000000..75efaa44 --- /dev/null +++ b/tests/integration/env_file_interpolation/docker-compose.yml @@ -0,0 +1,5 @@ +services: + app: + image: nopush/podman-compose-test + env_file: .env.extra + command: ["/bin/sh", "-c", "env | grep '^FOO='"] diff --git a/tests/integration/env_file_interpolation/test_env_file_interpolation.py b/tests/integration/env_file_interpolation/test_env_file_interpolation.py new file mode 100644 index 00000000..2958a823 --- /dev/null +++ b/tests/integration/env_file_interpolation/test_env_file_interpolation.py @@ -0,0 +1,41 @@ +# SPDX-License-Identifier: GPL-2.0 + +import os +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_base_path() -> str: + return os.path.join(test_path(), "env_file_interpolation") + + +class TestEnvFileInterpolation(unittest.TestCase, RunSubprocessMixin): + def test_env_file_interpolates_from_project_dotenv(self) -> None: + base_path = compose_base_path() + path_compose_file = os.path.join(base_path, "docker-compose.yml") + try: + self.run_subprocess_assert_returncode([ + podman_compose_path(), + "-f", + path_compose_file, + "up", + ]) + output, _ = self.run_subprocess_assert_returncode([ + podman_compose_path(), + "-f", + path_compose_file, + "logs", + "--no-log-prefix", + "--no-color", + ]) + self.assertEqual(output, b"FOO=bar\n") + finally: + self.run_subprocess_assert_returncode([ + podman_compose_path(), + "-f", + path_compose_file, + "down", + ]) diff --git a/tests/unit/test_container_to_args.py b/tests/unit/test_container_to_args.py index acc0b742..b46764f9 100644 --- a/tests/unit/test_container_to_args.py +++ b/tests/unit/test_container_to_args.py @@ -1355,3 +1355,29 @@ async def test_entrypoint_string_is_shlex_split(self) -> None: "busybox", ], ) + + async def test_env_file_interpolates_from_project_dotenv_braces(self) -> None: + """Env file values with ${VAR} should interpolate using project .env variables.""" + c = create_compose_mock() + c.environ = {"BAR": "bar"} + + cnt = get_minimal_container() + env_file = get_test_file_path('tests/integration/env_file_interpolation/.env.extra') + cnt['env_file'] = env_file + + args = await container_to_args(c, cnt) + self.assertIn("-e", args) + self.assertIn("FOO=bar", args) + + async def test_env_file_interpolates_from_project_dotenv_no_braces(self) -> None: + """Env file values with $VAR should interpolate using project .env variables.""" + c = create_compose_mock() + c.environ = {"BAR": "bar"} + + cnt = get_minimal_container() + env_file = get_test_file_path('tests/integration/env_file_interpolation/.env.extra') + cnt['env_file'] = env_file + + args = await container_to_args(c, cnt) + self.assertIn("-e", args) + self.assertIn("FOO=bar", args)