From 36fad25f25c34935a8c8381c413c53d39159c27d Mon Sep 17 00:00:00 2001 From: Monika Kairaityte Date: Sun, 20 Jul 2025 20:59:43 +0300 Subject: [PATCH 1/2] Implement volumes `bind.create_host_path` option The `type:bind` volume option `create_host_path` is currently unsupported in `podman-compose`. This prevents users from disabling the automatic creation of host source directories, creating an incompatibility with `docker-compose` functionality. Refer to the relevant `docker-compose` documentation: https://docs.docker.com/reference/compose-file/services/#volumes This commit implements the `create_host_path` option to: - Achieve better alignment with `docker-compose` behavior - Provide control over host directory creation Signed-off-by: Monika Kairaityte --- .../add-volumes-bind-create-host-path-option.bugfix | 1 + podman_compose.py | 6 ++++++ 2 files changed, 7 insertions(+) create mode 100644 newsfragments/add-volumes-bind-create-host-path-option.bugfix diff --git a/newsfragments/add-volumes-bind-create-host-path-option.bugfix b/newsfragments/add-volumes-bind-create-host-path-option.bugfix new file mode 100644 index 0000000..1c0d9f6 --- /dev/null +++ b/newsfragments/add-volumes-bind-create-host-path-option.bugfix @@ -0,0 +1 @@ +Implemented volumes bind `create_host_path` option. diff --git a/podman_compose.py b/podman_compose.py index e627923..b4b24d5 100755 --- a/podman_compose.py +++ b/podman_compose.py @@ -405,6 +405,12 @@ async def assert_volume(compose: PodmanCompose, mount_dict: dict[str, Any]) -> N mount_src = mount_dict["source"] mount_src = os.path.realpath(os.path.join(basedir, os.path.expanduser(mount_src))) if not os.path.exists(mount_src): + bind_opts = mount_dict.get("bind", {}) + if "create_host_path" in bind_opts and not bind_opts["create_host_path"]: + raise ValueError( + "invalid mount config for type 'bind': bind source path does not exist: " + f"{mount_src}" + ) try: os.makedirs(mount_src, exist_ok=True) except OSError: From 4f9b4198653833bb898dff8742a1c1c2435d8c87 Mon Sep 17 00:00:00 2001 From: Monika Kairaityte Date: Wed, 23 Jul 2025 10:54:33 +0300 Subject: [PATCH 2/2] tests: Add unit and integration tests for `create_host_path` Signed-off-by: Monika Kairaityte --- tests/integration/vol/long_syntax/__init__.py | 0 .../vol/long_syntax/docker-compose.yml | 27 ++++++ .../test_podman_compose_vol_long_syntax.py | 88 +++++++++++++++++++ .../integration/vol/short_syntax/__init__.py | 0 .../vol/short_syntax/docker-compose.yml | 13 +++ .../test_podman_compose_vol_short_syntax.py | 55 ++++++++++++ tests/unit/test_container_to_args.py | 66 ++++++++++++++ 7 files changed, 249 insertions(+) create mode 100644 tests/integration/vol/long_syntax/__init__.py create mode 100644 tests/integration/vol/long_syntax/docker-compose.yml create mode 100644 tests/integration/vol/long_syntax/test_podman_compose_vol_long_syntax.py create mode 100644 tests/integration/vol/short_syntax/__init__.py create mode 100644 tests/integration/vol/short_syntax/docker-compose.yml create mode 100644 tests/integration/vol/short_syntax/test_podman_compose_vol_short_syntax.py diff --git a/tests/integration/vol/long_syntax/__init__.py b/tests/integration/vol/long_syntax/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration/vol/long_syntax/docker-compose.yml b/tests/integration/vol/long_syntax/docker-compose.yml new file mode 100644 index 0000000..69a3ce1 --- /dev/null +++ b/tests/integration/vol/long_syntax/docker-compose.yml @@ -0,0 +1,27 @@ +version: "3" +services: + create_host_path_default_true: # default is to always create + image: nopush/podman-compose-test + command: ["/bin/busybox", "mkdir", "cont/test_dir/new_dir"] + volumes: + - type: bind + source: ./test_dir + target: /cont/test_dir + create_host_path_true: + image: nopush/podman-compose-test + command: ["/bin/busybox", "mkdir", "cont/test_dir/new_dir"] + volumes: + - type: bind + source: ./test_dir + target: /cont/test_dir + bind: + create_host_path: true + create_host_path_false: + image: nopush/podman-compose-test + command: ["/bin/busybox", "mkdir", "cont/test_dir/new_dir"] + volumes: + - type: bind + source: ./test_dir + target: /cont/test_dir + bind: + create_host_path: false diff --git a/tests/integration/vol/long_syntax/test_podman_compose_vol_long_syntax.py b/tests/integration/vol/long_syntax/test_podman_compose_vol_long_syntax.py new file mode 100644 index 0000000..cdc2351 --- /dev/null +++ b/tests/integration/vol/long_syntax/test_podman_compose_vol_long_syntax.py @@ -0,0 +1,88 @@ +# SPDX-License-Identifier: GPL-2.0 + +import os +import shutil +import unittest + +from parameterized import parameterized + +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(test_ref_folder: str) -> str: + return os.path.join(test_path(), "vol", test_ref_folder, "docker-compose.yml") + + +class TestComposeVolLongSyntax(unittest.TestCase, RunSubprocessMixin): + @parameterized.expand([ + (True, "create_host_path_default_true"), + (True, "create_host_path_true"), + (True, "create_host_path_false"), + (False, "create_host_path_default_true"), + (False, "create_host_path_true"), + ]) + def test_source_host_dir(self, source_dir_exists: bool, service_name: str) -> None: + project_dir = os.path.join(test_path(), "vol", "long_syntax") + if source_dir_exists: + # create host source directory for volume + os.mkdir(os.path.join(project_dir, "test_dir")) + else: + # make sure there is no such directory + self.assertFalse(os.path.isdir(os.path.join(project_dir, "test_dir"))) + + try: + self.run_subprocess_assert_returncode( + [ + podman_compose_path(), + "-f", + compose_yaml_path("long_syntax"), + "up", + "-d", + f"{service_name}", + ], + 0, + ) + # command of the service creates a new directory on mounted directory 'test_dir' in + # the container. Check if host directory now has the same directory. It represents a + # successful mount. + # If source host directory does not exist, it is created + self.assertTrue(os.path.isdir(os.path.join(project_dir, "test_dir/new_dir"))) + + finally: + self.run_subprocess_assert_returncode([ + podman_compose_path(), + "-f", + compose_yaml_path("long_syntax"), + "down", + "-t", + "0", + ]) + shutil.rmtree(os.path.join(project_dir, "test_dir")) + + def test_no_host_source_dir_create_host_path_false(self) -> None: + try: + _, error = self.run_subprocess_assert_returncode( + [ + podman_compose_path(), + "-f", + compose_yaml_path("long_syntax"), + "up", + "-d", + "create_host_path_false", + ], + 1, + ) + self.assertIn( + b"invalid mount config for type 'bind': bind source path does not exist:", error + ) + finally: + self.run_subprocess([ + podman_compose_path(), + "-f", + compose_yaml_path("long_syntax"), + "down", + "-t", + "0", + ]) diff --git a/tests/integration/vol/short_syntax/__init__.py b/tests/integration/vol/short_syntax/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration/vol/short_syntax/docker-compose.yml b/tests/integration/vol/short_syntax/docker-compose.yml new file mode 100644 index 0000000..5442354 --- /dev/null +++ b/tests/integration/vol/short_syntax/docker-compose.yml @@ -0,0 +1,13 @@ +version: "3" +services: + test1: + image: nopush/podman-compose-test + #["/bin/busybox", "sh", "-c", "env | grep ZZVAR3"] + command: ["/bin/busybox", "mkdir", "cont/test_dir/new_dir"] + volumes: + - ./test_dir:/cont/test_dir + test2: + image: nopush/podman-compose-test + command: ["/bin/busybox", "mkdir", "cont/test_dir/new_dir"] + volumes: + - ./test_dir:/cont/test_dir diff --git a/tests/integration/vol/short_syntax/test_podman_compose_vol_short_syntax.py b/tests/integration/vol/short_syntax/test_podman_compose_vol_short_syntax.py new file mode 100644 index 0000000..a956f6e --- /dev/null +++ b/tests/integration/vol/short_syntax/test_podman_compose_vol_short_syntax.py @@ -0,0 +1,55 @@ +# SPDX-License-Identifier: GPL-2.0 + +import os +import shutil +import unittest + +from parameterized import parameterized + +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(test_ref_folder: str) -> str: + return os.path.join(test_path(), "vol", test_ref_folder, "docker-compose.yml") + + +class TestComposeVolShortSyntax(unittest.TestCase, RunSubprocessMixin): + @parameterized.expand([ + (True, "test1"), + (False, "test2"), + ]) + def test_source_host_dir(self, source_host_dir_exists: bool, service_name: str) -> None: + project_dir = os.path.join(test_path(), "vol", "short_syntax") + # create host source directory for volume + if source_host_dir_exists: + os.mkdir(os.path.join(project_dir, "test_dir")) + try: + self.run_subprocess_assert_returncode( + [ + podman_compose_path(), + "-f", + compose_yaml_path("short_syntax"), + "up", + "-d", + f"{service_name}", + ], + 0, + ) + # command of the service creates a new directory on mounted directory 'test_dir' in + # container. Check if host directory now has the same directory. It represents a + # successful mount. + # On service test2 source host directory is created as it did not exist + self.assertTrue(os.path.isdir(os.path.join(project_dir, "test_dir/new_dir"))) + + finally: + self.run_subprocess([ + podman_compose_path(), + "-f", + compose_yaml_path("short_syntax"), + "down", + "-t", + "0", + ]) + shutil.rmtree(os.path.join(project_dir, "test_dir")) diff --git a/tests/unit/test_container_to_args.py b/tests/unit/test_container_to_args.py index aba4a51..3d035ae 100644 --- a/tests/unit/test_container_to_args.py +++ b/tests/unit/test_container_to_args.py @@ -1,6 +1,7 @@ # SPDX-License-Identifier: GPL-2.0 import os +import shutil import unittest from typing import Any from unittest import mock @@ -679,6 +680,71 @@ class TestContainerToArgs(unittest.IsolatedAsyncioTestCase): ], ) + @parameterized.expand([ + ( + "create_host_path_set_to_true", + {"bind": {"create_host_path": True}}, + ), + ( + "create_host_path_default_true", + {}, + ), + ]) + async def test_volumes_bind_mount_create_source_dir(self, test_name: str, bind: dict) -> None: + # creates a missing source dir + c = create_compose_mock() + c.prefer_volume_over_mount = True + cnt = get_minimal_container() + + cnt["_service"] = cnt["service_name"] + + volume_info = { + "type": "bind", + "source": "./not_exists/foo", + "target": "/mnt", + } + volume_info.update(bind) + cnt["volumes"] = [ + volume_info, + ] + + args = await container_to_args(c, cnt) + + self.assertEqual( + args, + [ + "--name=project_name_service_name1", + "-d", + "-v", + f"{get_test_file_path('./test_dirname/not_exists/foo')}:/mnt", + "--network=bridge:alias=service_name", + "busybox", + ], + ) + dir_path = get_test_file_path('./test_dirname/not_exists/foo') + shutil.rmtree(dir_path) + + # throws an error as the source path does not exist and its creation was suppressed with the + # create_host_path = False option + async def test_volumes_bind_mount_source_does_not_exist(self) -> None: + c = create_compose_mock() + c.prefer_volume_over_mount = True + cnt = get_minimal_container() + + cnt["_service"] = cnt["service_name"] + + cnt["volumes"] = [ + { + "type": "bind", + "source": "./not_exists/foo", + "target": "/mnt", + "bind": {"create_host_path": False}, + } + ] + + with self.assertRaises(ValueError): + await container_to_args(c, cnt) + @parameterized.expand([ ("not_compat", False, "test_project_name", "test_project_name_network1"), ("compat_no_dash", True, "test_project_name", "test_project_name_network1"),