Skip to content

Commit 7ce6233

Browse files
authored
Merge pull request #1462 from lytedev/fix/include-relative-path-resolution
Fix/include relative path resolution
2 parents fa5c9db + f91201f commit 7ce6233

7 files changed

Lines changed: 137 additions & 6 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Resolve relative paths in included compose files against the included file's directory rather than the project root, matching the Compose Spec. Affects volumes, env_file, and build.context inside files referenced by include:. Fixes #1301.

podman_compose.py

Lines changed: 48 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2150,16 +2150,33 @@ def normalize_service(service: dict[str, Any], sub_dir: str = "") -> dict[str, A
21502150

21512151
new_volumes.append(v)
21522152
service["volumes"] = new_volumes
2153+
if "env_file" in service and sub_dir:
2154+
new_env_file = []
2155+
for ef in service["env_file"]:
2156+
if isinstance(ef, str):
2157+
if is_relative_ref(ef):
2158+
ef = os.path.join(sub_dir, ef)
2159+
elif isinstance(ef, dict):
2160+
path = ef.get("path")
2161+
if isinstance(path, str) and is_relative_ref(path):
2162+
ef["path"] = os.path.join(sub_dir, path)
2163+
new_env_file.append(ef)
2164+
service["env_file"] = new_env_file
21532165
return service
21542166

21552167

2156-
def normalize(compose: dict[str, Any]) -> dict[str, Any]:
2168+
def normalize(compose: dict[str, Any], sub_dir: str = "") -> dict[str, Any]:
21572169
"""
21582170
convert compose dict of some keys from string or dicts into arrays
2171+
2172+
If ``sub_dir`` is provided, relative paths in ``volumes``, ``env_file`` and
2173+
``build.context`` are rewritten to be relative to ``sub_dir`` (used when an
2174+
included file lives in a different directory than the project root, per
2175+
Compose Spec resolution of paths in ``include:``d files).
21592176
"""
21602177
services = compose.get("services", {}) or {}
21612178
for service in services.values():
2162-
normalize_service(service)
2179+
normalize_service(service, sub_dir)
21632180
return compose
21642181

21652182

@@ -2721,6 +2738,11 @@ def _parse_compose_file(self) -> None:
27212738
compose: dict[str, Any] = {}
27222739
# Iterate over files primitively to allow appending to files in-loop
27232740
files_iter = iter(files)
2741+
# Track files appended by ``include:`` so we can resolve their
2742+
# relative paths against the included file's directory per the
2743+
# Compose Spec, without changing the legacy merge behavior of
2744+
# files passed directly via ``-f``.
2745+
include_origin_files: set[str] = set()
27242746

27252747
while True:
27262748
try:
@@ -2737,7 +2759,22 @@ def _parse_compose_file(self) -> None:
27372759
if not isinstance(content, dict):
27382760
log.fatal("Compose file does not contain a top level object: %s", filename)
27392761
sys.exit(1)
2740-
content = normalize(content)
2762+
# For files arriving via ``include:``, paths inside the file must
2763+
# resolve against the included file's directory rather than the
2764+
# project root (Compose Spec, ``include`` section). Pass that as
2765+
# sub_dir so volumes / env_file / build.context get rewritten.
2766+
file_sub_dir = ""
2767+
if filename in include_origin_files:
2768+
file_dir = os.path.dirname(os.path.abspath(filename))
2769+
file_sub_dir = os.path.relpath(file_dir, self.dirname)
2770+
if file_sub_dir == ".":
2771+
file_sub_dir = ""
2772+
elif not file_sub_dir.startswith((".", "/")):
2773+
# Prefix with "./" so rewritten paths remain recognizable
2774+
# as relative refs (is_relative_ref checks for "./"/".."
2775+
# prefixes).
2776+
file_sub_dir = "./" + file_sub_dir
2777+
content = normalize(content, file_sub_dir)
27412778
# log(filename, json.dumps(content, indent = 2))
27422779

27432780
# See also https://docs.docker.com/compose/how-tos/project-name/#set-a-project-name
@@ -2780,23 +2817,28 @@ def _parse_compose_file(self) -> None:
27802817
if not isinstance(include, list):
27812818
raise RuntimeError("`include` must be a list")
27822819

2820+
new_includes: list[str] = []
27832821
for item in include:
27842822
if isinstance(item, str):
2785-
files.append(os.path.join(os.path.dirname(filename), item))
2823+
new_includes.append(os.path.join(os.path.dirname(filename), item))
27862824
elif isinstance(item, dict):
27872825
if "path" not in item:
27882826
raise RuntimeError("Missing required 'path' key in `include` block")
27892827
path = item["path"]
27902828
if isinstance(path, str):
2791-
files.append(os.path.join(os.path.dirname(filename), path))
2829+
new_includes.append(os.path.join(os.path.dirname(filename), path))
27922830
elif isinstance(path, list):
2793-
files.extend([os.path.join(os.path.dirname(filename), p) for p in path])
2831+
new_includes.extend(
2832+
os.path.join(os.path.dirname(filename), p) for p in path
2833+
)
27942834
else:
27952835
raise RuntimeError("'path' must be a string or a list of strings")
27962836
else:
27972837
raise RuntimeError(
27982838
"Items in `include` must be strings or dictionaries with a 'path' key"
27992839
)
2840+
files.extend(new_includes)
2841+
include_origin_files.update(new_includes)
28002842
# As compose obj is updated and tested with every loop, not deleting `include`
28012843
# from it, results in it being tested again and again, original values for
28022844
# `include` be appended to `files`, and, included files be processed for ever.

tests/integration/include_relative_paths/__init__.py

Whitespace-only changes.
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
version: "3.6"
2+
3+
name: include-relative-paths
4+
5+
include:
6+
- sub/included.yaml
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
services:
2+
# All relative paths below should resolve against this file's directory
3+
# (sub/), not the project root. So:
4+
# ./local.env -> sub/local.env
5+
# ../shared.env -> shared.env (in project root)
6+
# ./data:/data:ro -> sub/data
7+
# ../assets:/assets:ro -> assets (in project root)
8+
web:
9+
image: nopush/podman-compose-test
10+
env_file:
11+
- ./local.env
12+
- ../shared.env
13+
volumes:
14+
- ./data:/data:ro
15+
- ../assets:/assets:ro
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
# SPDX-License-Identifier: GPL-2.0
2+
3+
"""
4+
Tests that relative paths (volumes, env_file) inside an included compose
5+
file are resolved against the included file's directory, not the project
6+
root. See https://github.com/containers/podman-compose/issues/1301 and the
7+
Compose Spec include resolution rules.
8+
"""
9+
10+
import os
11+
import textwrap
12+
import unittest
13+
14+
from tests.integration.test_utils import RunSubprocessMixin
15+
from tests.integration.test_utils import podman_compose_path
16+
from tests.integration.test_utils import test_path
17+
18+
19+
def compose_file() -> str:
20+
return os.path.join(test_path(), "include_relative_paths", "docker-compose.yaml")
21+
22+
23+
class TestIncludeRelativePaths(unittest.TestCase, RunSubprocessMixin):
24+
def test_relative_paths_in_included_file(self) -> None:
25+
"""
26+
The included file at ``sub/included.yaml`` references
27+
``./local.env``, ``../shared.env``, ``./data:/data:ro`` and
28+
``../assets:/assets:ro``. After include resolution these must point
29+
at paths under ``sub/`` for the ``./`` forms and at the project
30+
root for the ``../`` forms.
31+
"""
32+
out, _ = self.run_subprocess_assert_returncode([
33+
"coverage",
34+
"run",
35+
podman_compose_path(),
36+
"-f",
37+
compose_file(),
38+
"config",
39+
])
40+
41+
expected = textwrap.dedent("""\
42+
name: include-relative-paths
43+
services:
44+
web:
45+
env_file:
46+
- ./sub/./local.env
47+
- ./sub/../shared.env
48+
image: nopush/podman-compose-test
49+
volumes:
50+
- ./sub/./data:/data:ro
51+
- ./sub/../assets:/assets:ro
52+
version: '3.6'
53+
54+
""")
55+
self.assertEqual(out.decode("utf-8"), expected)

tests/unit/test_normalize_service.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,18 @@ def test_simple(self, input: dict[str, Any], expected: dict[str, Any]) -> None:
7878
]
7979
},
8080
),
81+
(
82+
{"env_file": "./.env"},
83+
{"env_file": ["./sub_dir/./.env"]},
84+
),
85+
(
86+
{"env_file": ["./.env", "../shared.env"]},
87+
{"env_file": ["./sub_dir/./.env", "./sub_dir/../shared.env"]},
88+
),
89+
(
90+
{"env_file": [{"path": "./.env", "required": False}]},
91+
{"env_file": [{"path": "./sub_dir/./.env", "required": False}]},
92+
),
8193
])
8294
def test_normalize_service_with_sub_dir(
8395
self, input: dict[str, Any], expected: dict[str, Any]

0 commit comments

Comments
 (0)