diff --git a/docs/Extensions.md b/docs/Extensions.md index b4ee8a1..bd3c7ef 100644 --- a/docs/Extensions.md +++ b/docs/Extensions.md @@ -139,6 +139,26 @@ The options to the network modes are passed to the `--network` option of the `po as-is. +## Compatibility of name separators between docker-compose and podman-compose + +Currently, podman-compose is using underscores (`_` character) as a separator in names of +containers, images, etc., while docker-compose has switched to hyphens (`-` character). This setting +allows to switch podman-compose to use hyphens as well. + +To enable compatibility between docker-compose and podman-compose, specify +`name_separator_compat: true` under global `x-podman` key: + +``` +x-podman: + name_separator_compat: true +``` + +By default `name_separator_compat` is `false`. This will change to `true` at some point and the +setting will be removed. + +This setting can also be changed by setting `PODMAN_COMPOSE_NAME_SEPARATOR_COMPAT` environment +variable. + ## Compatibility of default network names between docker-compose and podman-compose Current versions of podman-compose may produce different default external network names than diff --git a/newsfragments/name-separator-compat.feature b/newsfragments/name-separator-compat.feature new file mode 100644 index 0000000..e3b11fe --- /dev/null +++ b/newsfragments/name-separator-compat.feature @@ -0,0 +1 @@ +- Add new name_separator_compat x-podman setting to change name separator to hyphen, same as Docker Compose \ No newline at end of file diff --git a/podman_compose.py b/podman_compose.py index 2805d52..78446b5 100755 --- a/podman_compose.py +++ b/podman_compose.py @@ -223,11 +223,10 @@ def fix_mount_dict( # handle anonymous or implied volume if not source: # missing source - vol["name"] = "_".join([ - compose.project_name, + vol["name"] = compose.format_name( srv_name, hashlib.sha256(mount_dict["target"].encode("utf-8")).hexdigest(), - ]) + ) elif not name: external = vol.get("external") if isinstance(external, dict): @@ -374,38 +373,25 @@ def default_network_name_for_project(compose: PodmanCompose, net: str, is_ext: A PodmanCompose.XPodmanSettingKey.DEFAULT_NET_NAME_COMPAT, False ) if default_net_name_compat is True: - return f"{compose.project_name.replace('-', '')}_{net}" - return f"{compose.project_name}_{net}" + return compose.join_name_parts(compose.project_name.replace('-', ''), net) + return compose.format_name(net) -# def tr_identity(project_name, given_containers): -# pod_name = f'pod_{project_name}' -# pod = dict(name=pod_name) -# containers = [] -# for cnt in given_containers: -# containers.append(dict(cnt, pod=pod_name)) -# return [pod], containers - - -def transform( - args: Any, project_name: str, given_containers: list[Any] -) -> tuple[list[dict], list[dict]]: - in_pod = str(args.in_pod).lower() - pod_name = None - pods = [] - - if in_pod in ('true', '1', 'none', ''): - pod_name = f"pod_{project_name}" - elif in_pod not in ('false', '0'): - pod_name = args.in_pod - - if pod_name: - pods = [{"name": pod_name}] - - containers = [] - for cnt in given_containers: - containers.append(dict(cnt, pod=pod_name)) - return pods, containers +def try_parse_bool(value: Any) -> bool | None: + if isinstance(value, bool): + return value + if isinstance(value, str): + value = value.lower() + if value in ('true', '1'): + return True + if value in ('false', '0'): + return False + if isinstance(value, int): + if value == 1: + return True + if value == 0: + return False + return None async def assert_volume(compose: PodmanCompose, mount_dict: dict[str, Any]) -> None: @@ -1973,6 +1959,7 @@ class PodmanCompose: class XPodmanSettingKey(Enum): DEFAULT_NET_NAME_COMPAT = "default_net_name_compat" DEFAULT_NET_BEHAVIOR_COMPAT = "default_net_behavior_compat" + NAME_SEPARATOR_COMPAT = "name_separator_compat" IN_POD = "in_pod" POD_ARGS = "pod_args" @@ -2065,11 +2052,23 @@ class PodmanCompose: if isinstance(retcode, int): sys.exit(retcode) - def resolve_in_pod(self) -> bool: - if self.global_args.in_pod in (None, ''): - self.global_args.in_pod = self.x_podman.get(PodmanCompose.XPodmanSettingKey.IN_POD, "1") - # otherwise use `in_pod` value provided by command line - return self.global_args.in_pod + def resolve_pod_name(self) -> str | None: + # Priorities: + # - Command line --in-pod + # - docker-compose.yml x-podman.in_pod + # - Default value of true + in_pod_arg = self.global_args.in_pod or self.x_podman.get( + PodmanCompose.XPodmanSettingKey.IN_POD, True + ) + + in_pod_arg_parsed = try_parse_bool(in_pod_arg) + if in_pod_arg_parsed is True: + return f"pod_{self.project_name}" + if in_pod_arg_parsed is False: + return None + + assert isinstance(in_pod_arg, str) and in_pod_arg + return in_pod_arg def resolve_pod_args(self) -> list[str]: # Priorities: @@ -2082,6 +2081,18 @@ class PodmanCompose: PodmanCompose.XPodmanSettingKey.POD_ARGS, ["--infra=false", "--share="] ) + def join_name_parts(self, *parts: str) -> str: + setting = self.x_podman.get(PodmanCompose.XPodmanSettingKey.NAME_SEPARATOR_COMPAT, False) + if try_parse_bool(setting): + sep = "-" + else: + sep = "_" + return sep.join(parts) + + def format_name(self, *parts: str) -> str: + assert self.project_name is not None + return self.join_name_parts(self.project_name, *parts) + def _parse_x_podman_settings(self, compose: dict[str, Any], environ: dict[str, str]) -> None: known_keys = {s.value: s for s in PodmanCompose.XPodmanSettingKey} @@ -2265,6 +2276,8 @@ class PodmanCompose: self._parse_x_podman_settings(compose, self.environ) + pod_name = self.resolve_pod_name() + services: dict | None = compose.get("services") if services is None: services = {} @@ -2352,7 +2365,7 @@ class PodmanCompose: container_names_by_service[service_name] = [] for num in range(1, replicas + 1): - name0 = f"{project_name}_{service_name}_{num}" + name0 = self.format_name(service_name, str(num)) if num == 1: name = service_desc.get("container_name", name0) else: @@ -2360,6 +2373,7 @@ class PodmanCompose: container_names_by_service[service_name].append(name) # log(service_name,service_desc) cnt = { + "pod": pod_name, "name": name, "num": num, "service_name": service_name, @@ -2368,7 +2382,7 @@ class PodmanCompose: x_podman = service_desc.get("x-podman") rootfs_mode = x_podman is not None and x_podman.get("rootfs") is not None if "image" not in cnt and not rootfs_mode: - cnt["image"] = f"{project_name}_{service_name}" + cnt["image"] = self.format_name(service_name) labels = norm_as_list(cnt.get("labels")) cnt["ports"] = norm_ports(cnt.get("ports")) labels.extend(podman_compose_labels) @@ -2399,12 +2413,9 @@ class PodmanCompose: given_containers.sort(key=lambda c: len(c.get("_deps", []))) # log("sorted:", [c["name"] for c in given_containers]) - args.in_pod = self.resolve_in_pod() - args.pod_arg_list = self.resolve_pod_args() - pods, containers = transform(args, project_name, given_containers) - self.pods = pods - self.containers = containers - self.container_by_name = {c["name"]: c for c in containers} + self.pods = [{"name": pod_name}] if pod_name else [] + self.containers = given_containers + self.container_by_name = {c["name"]: c for c in given_containers} def _resolve_profiles( self, defined_services: dict[str, Any], requested_profiles: set[str] | None = None @@ -2931,7 +2942,7 @@ async def pod_exists(compose: PodmanCompose, name: str) -> bool: return exit_code == 0 -async def create_pods(compose: PodmanCompose, args: argparse.Namespace) -> None: +async def create_pods(compose: PodmanCompose) -> None: for pod in compose.pods: if await pod_exists(compose, pod["name"]): continue @@ -2939,9 +2950,8 @@ async def create_pods(compose: PodmanCompose, args: argparse.Namespace) -> None: podman_args = [ "create", "--name=" + pod["name"], - ] + args.pod_arg_list - # if compose.podman_version and not strverscmp_lt(compose.podman_version, "3.4.0"): - # podman_args.append("--infra-name={}_infra".format(pod["name"])) + ] + compose.resolve_pod_args() + ports = pod.get("ports", []) if isinstance(ports, str): ports = [ports] @@ -3074,7 +3084,7 @@ async def compose_up(compose: PodmanCompose, args: argparse.Namespace) -> int | log.info("recreating: done\n\n") # args.no_recreate disables check for changes (which is not implemented) - await create_pods(compose, args) + await create_pods(compose) exit_code = 0 for cnt in compose.containers: if cnt["_service"] in excluded: @@ -3318,7 +3328,7 @@ async def compose_ps(compose: PodmanCompose, args: argparse.Namespace) -> None: "create a container similar to a service to run a one-off command", ) async def compose_run(compose: PodmanCompose, args: argparse.Namespace) -> None: - await create_pods(compose, args) + await create_pods(compose) compose.assert_services(args.service) container_names = compose.container_names_by_service[args.service] container_name = container_names[0] @@ -3363,7 +3373,7 @@ def compose_run_update_container_from_args( compose: PodmanCompose, cnt: dict, args: argparse.Namespace ) -> None: # adjust one-off container options - name0 = "{}_{}_tmp{}".format(compose.project_name, args.service, random.randrange(0, 65536)) + name0 = compose.format_name(args.service, f'tmp{random.randrange(0, 65536)}') cnt["name"] = args.name or name0 if args.entrypoint: cnt["entrypoint"] = args.entrypoint diff --git a/tests/integration/name_separator_compat/__init__.py b/tests/integration/name_separator_compat/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/tests/integration/name_separator_compat/__init__.py @@ -0,0 +1 @@ + diff --git a/tests/integration/name_separator_compat/docker-compose_compat.yaml b/tests/integration/name_separator_compat/docker-compose_compat.yaml new file mode 100644 index 0000000..650f31b --- /dev/null +++ b/tests/integration/name_separator_compat/docker-compose_compat.yaml @@ -0,0 +1,7 @@ +services: + web: + image: busybox + command: httpd -f -p 8123 -h /tmp/ + +x-podman: + name_separator_compat: true diff --git a/tests/integration/name_separator_compat/docker-compose_default.yaml b/tests/integration/name_separator_compat/docker-compose_default.yaml new file mode 100644 index 0000000..6ac63ff --- /dev/null +++ b/tests/integration/name_separator_compat/docker-compose_default.yaml @@ -0,0 +1,4 @@ +services: + web: + image: busybox + command: httpd -f -p 8123 -h /tmp/ diff --git a/tests/integration/name_separator_compat/test_podman_compose_name_separator_compat.py b/tests/integration/name_separator_compat/test_podman_compose_name_separator_compat.py new file mode 100644 index 0000000..b4ef710 --- /dev/null +++ b/tests/integration/name_separator_compat/test_podman_compose_name_separator_compat.py @@ -0,0 +1,59 @@ +# SPDX-License-Identifier: GPL-2.0 + +import os +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 + + +class TestComposeNameSeparatorCompat(unittest.TestCase, RunSubprocessMixin): + @parameterized.expand([ + ('default', {}, '_'), + ('default', {'PODMAN_COMPOSE_NAME_SEPARATOR_COMPAT': '1'}, '-'), + ('compat', {}, '-'), + ('compat', {'PODMAN_COMPOSE_NAME_SEPARATOR_COMPAT': '1'}, '-'), + ('compat', {'PODMAN_COMPOSE_NAME_SEPARATOR_COMPAT': '0'}, '_'), + ]) + def test_container_name(self, file: str, env: dict[str, str], expected_sep: str) -> None: + compose_yaml_path = os.path.join( + test_path(), "name_separator_compat", f"docker-compose_{file}.yaml" + ) + + try: + self.run_subprocess_assert_returncode( + [podman_compose_path(), "-f", compose_yaml_path, "up", "-d"], + env=env, + ) + + container_name_out, _ = self.run_subprocess_assert_returncode( + [ + podman_compose_path(), + "-f", + compose_yaml_path, + "ps", + "--format", + '{{.Names}}', + ], + env=env, + ) + container_name = container_name_out.decode('utf-8').strip() + + expected_container_name = f'name_separator_compat{expected_sep}web{expected_sep}1' + + self.assertEqual(container_name, expected_container_name) + finally: + self.run_subprocess_assert_returncode( + [ + podman_compose_path(), + "-f", + compose_yaml_path, + "down", + "-t", + "0", + ], + env=env, + ) diff --git a/tests/unit/test_compose_run_update_container_from_args.py b/tests/unit/test_compose_run_update_container_from_args.py index 3f0b0da..1a5953f 100644 --- a/tests/unit/test_compose_run_update_container_from_args.py +++ b/tests/unit/test_compose_run_update_container_from_args.py @@ -56,7 +56,9 @@ def get_minimal_container() -> dict: def get_minimal_compose() -> PodmanCompose: - return PodmanCompose() + compose = PodmanCompose() + compose.project_name = "test_project" + return compose def get_minimal_args() -> argparse.Namespace: @@ -67,7 +69,7 @@ def get_minimal_args() -> argparse.Namespace: env=None, name="default_name", rm=None, - service=None, + service="test_service", publish=None, service_ports=None, user=None, diff --git a/tests/unit/test_container_to_args.py b/tests/unit/test_container_to_args.py index db4e364..bf21ad8 100644 --- a/tests/unit/test_container_to_args.py +++ b/tests/unit/test_container_to_args.py @@ -20,6 +20,8 @@ def create_compose_mock(project_name: str = "test_project_name") -> PodmanCompos compose.default_net = None compose.networks = {} compose.x_podman = {} + compose.join_name_parts = mock.Mock(side_effect=lambda *args: '_'.join(args)) + compose.format_name = mock.Mock(side_effect=lambda *args: '_'.join([project_name, *args])) async def podman_output(*args: Any, **kwargs: Any) -> None: pass