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/interpolate_service_env_file.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Support variable interpolation in service-level env_file files, allowing references to project environment variables using both ``${VAR}`` and ``$VAR`` syntaxes.
32 changes: 29 additions & 3 deletions podman_compose.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
import glob
import hashlib
import inspect
import io
import json
import logging
import os
Expand Down Expand Up @@ -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])
Expand Down Expand Up @@ -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"(?<!\$)\$(?!\$)(?!\{)([A-Za-z_][A-Za-z0-9_]*)", r"${\1}", content)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is unrelated to adding support for interpolation in env_file.



def dotenv_to_dict(
dotenv_path: str, environ: dict[str, str | None] | None = None
) -> 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()
Comment thread
mokibit marked this conversation as resolved.
os.environ.update(original_environ)
return dotenv_values(stream=io.StringIO(content))


COMPOSE_DEFAULT_LS = [
Expand Down
1 change: 1 addition & 0 deletions tests/integration/env_file_interpolation/.env
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
BAR=bar
1 change: 1 addition & 0 deletions tests/integration/env_file_interpolation/.env.extra
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
FOO=${BAR}
Empty file.
5 changes: 5 additions & 0 deletions tests/integration/env_file_interpolation/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
services:
app:
image: nopush/podman-compose-test
env_file: .env.extra
command: ["/bin/sh", "-c", "env | grep '^FOO='"]
Original file line number Diff line number Diff line change
@@ -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",
])
26 changes: 26 additions & 0 deletions tests/unit/test_container_to_args.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
mokibit marked this conversation as resolved.

args = await container_to_args(c, cnt)
self.assertIn("-e", args)
Comment thread
mokibit marked this conversation as resolved.
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)