From dd34a90068a28d8cc570d748b044c868a42d8f52 Mon Sep 17 00:00:00 2001 From: Bas Zoetekouw Date: Thu, 21 Mar 2024 15:10:24 +0100 Subject: [PATCH 01/10] Add testcase for failing network config Signed-off-by: Bas Zoetekouw --- tests/nets_test_ip/docker-compose.yml | 59 ++++++++++++++ tests/nets_test_ip/test1.txt | 1 + tests/nets_test_ip/test2.txt | 1 + tests/nets_test_ip/test3.txt | 1 + tests/nets_test_ip/test4.txt | 1 + tests/test_podman_compose_networks.py | 112 ++++++++++++++++++++++++++ 6 files changed, 175 insertions(+) create mode 100644 tests/nets_test_ip/docker-compose.yml create mode 100644 tests/nets_test_ip/test1.txt create mode 100644 tests/nets_test_ip/test2.txt create mode 100644 tests/nets_test_ip/test3.txt create mode 100644 tests/nets_test_ip/test4.txt create mode 100644 tests/test_podman_compose_networks.py diff --git a/tests/nets_test_ip/docker-compose.yml b/tests/nets_test_ip/docker-compose.yml new file mode 100644 index 0000000..c2ed04c --- /dev/null +++ b/tests/nets_test_ip/docker-compose.yml @@ -0,0 +1,59 @@ +version: "3" +networks: + shared-network: + driver: bridge + ipam: + config: + - subnet: "172.19.1.0/24" + internal-network: + driver: bridge + ipam: + config: + - subnet: "172.19.2.0/24" + +services: + web1: + image: busybox + hostname: web1 + command: ["/bin/busybox", "httpd", "-f", "-h", "/var/www/html", "-p", "8001"] + working_dir: /var/www/html + networks: + shared-network: + ipv4_address: "172.19.1.10" + internal-network: + ipv4_address: "172.19.2.10" + volumes: + - ./test1.txt:/var/www/html/index.txt:ro,z + web2: + image: busybox + hostname: web2 + command: ["/bin/busybox", "httpd", "-f", "-h", "/var/www/html", "-p", "8001"] + working_dir: /var/www/html + mac_address: "02:01:01:00:02:02" + networks: + internal-network: + ipv4_address: "172.19.2.11" + volumes: + - ./test2.txt:/var/www/html/index.txt:ro,z + + web3: + image: busybox + hostname: web2 + command: ["/bin/busybox", "httpd", "-f", "-h", "/var/www/html", "-p", "8001"] + working_dir: /var/www/html + networks: + internal-network: + volumes: + - ./test3.txt:/var/www/html/index.txt:ro,z + + web4: + image: busybox + hostname: web2 + command: ["/bin/busybox", "httpd", "-f", "-h", "/var/www/html", "-p", "8001"] + working_dir: /var/www/html + networks: + internal-network: + shared-network: + ipv4_address: "172.19.1.13" + volumes: + - ./test4.txt:/var/www/html/index.txt:ro,z diff --git a/tests/nets_test_ip/test1.txt b/tests/nets_test_ip/test1.txt new file mode 100644 index 0000000..a5bce3f --- /dev/null +++ b/tests/nets_test_ip/test1.txt @@ -0,0 +1 @@ +test1 diff --git a/tests/nets_test_ip/test2.txt b/tests/nets_test_ip/test2.txt new file mode 100644 index 0000000..180cf83 --- /dev/null +++ b/tests/nets_test_ip/test2.txt @@ -0,0 +1 @@ +test2 diff --git a/tests/nets_test_ip/test3.txt b/tests/nets_test_ip/test3.txt new file mode 100644 index 0000000..df6b0d2 --- /dev/null +++ b/tests/nets_test_ip/test3.txt @@ -0,0 +1 @@ +test3 diff --git a/tests/nets_test_ip/test4.txt b/tests/nets_test_ip/test4.txt new file mode 100644 index 0000000..d234c5e --- /dev/null +++ b/tests/nets_test_ip/test4.txt @@ -0,0 +1 @@ +test4 diff --git a/tests/test_podman_compose_networks.py b/tests/test_podman_compose_networks.py new file mode 100644 index 0000000..c86cf25 --- /dev/null +++ b/tests/test_podman_compose_networks.py @@ -0,0 +1,112 @@ +# SPDX-License-Identifier: GPL-2.0 + +""" +test_podman_compose_networks.py + +Tests the podman networking parameters +""" + +# pylint: disable=redefined-outer-name +import os +import unittest + +from .test_podman_compose import podman_compose_path +from .test_podman_compose import test_path +from .test_utils import RunSubprocessMixin + + +class TestPodmanComposeNetwork(RunSubprocessMixin, unittest.TestCase): + @staticmethod + def compose_file(): + """Returns the path to the compose file used for this test module""" + return os.path.join(test_path(), "nets_test_ip", "docker-compose.yml") + + def teardown(self): + """ + Ensures that the services within the "profile compose file" are removed between + each test case. + """ + # run the test case + yield + + down_cmd = [ + "coverage", + "run", + podman_compose_path(), + "-f", + self.compose_file(), + "kill", + "-a", + ] + self.run_subprocess(down_cmd) + + def test_networks(self): + up_cmd = [ + "coverage", + "run", + podman_compose_path(), + "-f", + self.compose_file(), + "up", + "-d", + "--force-recreate", + ] + + self.run_subprocess_assert_returncode(up_cmd) + + check_cmd = [ + podman_compose_path(), + "-f", + self.compose_file(), + "ps", + "--format", + '"{{.Names}}"', + ] + out, _ = self.run_subprocess_assert_returncode(check_cmd) + self.assertIn(b"nets_test_ip_web1_1", out) + self.assertIn(b"nets_test_ip_web2_1", out) + + expected_wget = { + "172.19.1.10": "test1", + "172.19.2.10": "test1", + "172.19.2.11": "test2", + "web3": "test3", + "172.19.1.13": "test4", + } + + for service in ("web1", "web2"): + for ip, expect in expected_wget.items(): + wget_cmd = [ + podman_compose_path(), + "-f", + self.compose_file(), + "exec", + service, + "wget", + "-q", + "-O-", + f"http://{ip}:8001/index.txt", + ] + out, _ = self.run_subprocess_assert_returncode(wget_cmd) + self.assertEqual(f"{expect}\r\n", out.decode('utf-8')) + + expected_macip = { + "web1": {"eth0": ["172.19.1.10"], "eth1": ["172.19.2.10"]}, + "web2": {"eth0": ["172.19.2.11"]}, + } + + for service, interfaces in expected_macip.items(): + ip_cmd = [ + podman_compose_path(), + "-f", + self.compose_file(), + "exec", + service, + "ip", + "addr", + "show", + ] + out, _ = self.run_subprocess_assert_returncode(ip_cmd) + for interface, values in interfaces.items(): + ip = values[0] + self.assertIn(f"inet {ip}/", out.decode('utf-8')) From 2743d690d2043cd64e21a7b6cc0357a382b2b7da Mon Sep 17 00:00:00 2001 From: Bas Zoetekouw Date: Thu, 21 Mar 2024 15:13:04 +0100 Subject: [PATCH 02/10] Fix support for multiple networks with explicitly specified ipv4/ipv6 addresses Signed-off-by: Bas Zoetekouw --- podman_compose.py | 42 +++++++++++++++++++++---------- pytests/test_container_to_args.py | 12 ++++----- 2 files changed, 35 insertions(+), 19 deletions(-) diff --git a/podman_compose.py b/podman_compose.py index ed61c35..4148ed3 100755 --- a/podman_compose.py +++ b/podman_compose.py @@ -855,32 +855,48 @@ def get_net_args(compose, cnt): net_names.append(net_name) net_names_str = ",".join(net_names) - if ip_assignments > 1: - multiple_nets = cnt.get("networks", None) - multiple_net_names = multiple_nets.keys() + # TODO add support for per-interface aliases + # see https://docs.docker.com/compose/compose-file/compose-file-v3/#aliases + multiple_nets = cnt.get("networks", None) + if multiple_nets and len(multiple_nets) > 1: + # networks can be specified as a dict with config per network or as a plain list without + # config. Support both cases by converting the plain list to a dict with empty config. + if is_list(multiple_nets): + multiple_nets = {net: {} for net in multiple_nets} + else: + multiple_nets = {net: net_config or {} for net, net_config in multiple_nets.items()} - for net_ in multiple_net_names: + for net_, net_config_ in multiple_nets.items(): net_desc = nets[net_] or {} is_ext = net_desc.get("external", None) ext_desc = is_ext if is_dict(is_ext) else {} default_net_name = net_ if is_ext else f"{proj_name}_{net_}" net_name = ext_desc.get("name", None) or net_desc.get("name", None) or default_net_name - ipv4 = multiple_nets[net_].get("ipv4_address", None) - ipv6 = multiple_nets[net_].get("ipv6_address", None) - if ipv4 is not None and ipv6 is not None: - net_args.extend(["--network", f"{net_name}:ip={ipv4},ip={ipv6}"]) - elif ipv4 is None and ipv6 is not None: - net_args.extend(["--network", f"{net_name}:ip={ipv6}"]) - elif ipv6 is None and ipv4 is not None: - net_args.extend(["--network", f"{net_name}:ip={ipv4}"]) + ipv4 = net_config_.get("ipv4_address", None) + ipv6 = net_config_.get("ipv6_address", None) + + net_options = [] + if ipv4: + net_options.append(f"ip={ipv4}") + if ipv6: + net_options.append(f"ip={ipv6}") + + if net_options: + net_args.extend(["--network", f"{net_name}:" + ",".join(net_options)]) + else: + net_args.extend(["--network", f"{net_name}"]) else: if is_bridge: - net_args.extend(["--net", net_names_str, "--network-alias", ",".join(aliases)]) + net_args.extend(["--network", net_names_str]) if ip: net_args.append(f"--ip={ip}") if ip6: net_args.append(f"--ip6={ip6}") + + if is_bridge: + net_args.extend(["--network-alias", ",".join(aliases)]) + return net_args diff --git a/pytests/test_container_to_args.py b/pytests/test_container_to_args.py index 840ae70..66f6812 100644 --- a/pytests/test_container_to_args.py +++ b/pytests/test_container_to_args.py @@ -37,7 +37,7 @@ class TestContainerToArgs(unittest.IsolatedAsyncioTestCase): [ "--name=project_name_service_name1", "-d", - "--net", + "--network", "", "--network-alias", "service_name", @@ -57,7 +57,7 @@ class TestContainerToArgs(unittest.IsolatedAsyncioTestCase): [ "--name=project_name_service_name1", "-d", - "--net", + "--network", "", "--network-alias", "service_name", @@ -82,7 +82,7 @@ class TestContainerToArgs(unittest.IsolatedAsyncioTestCase): [ "--name=project_name_service_name1", "-d", - "--net", + "--network", "", "--network-alias", "service_name", @@ -109,7 +109,7 @@ class TestContainerToArgs(unittest.IsolatedAsyncioTestCase): [ "--name=project_name_service_name1", "-d", - "--net", + "--network", "", "--network-alias", "service_name", @@ -143,7 +143,7 @@ class TestContainerToArgs(unittest.IsolatedAsyncioTestCase): [ "--name=project_name_service_name1", "-d", - "--net", + "--network", "", "--network-alias", "service_name", @@ -166,7 +166,7 @@ class TestContainerToArgs(unittest.IsolatedAsyncioTestCase): "--name=project_name_service_name1", "-d", "--http-proxy=false", - "--net", + "--network", "", "--network-alias", "service_name", From 91fbea3d89d7e27b8c6c7f44a853052cc347add0 Mon Sep 17 00:00:00 2001 From: Bas Zoetekouw Date: Thu, 21 Mar 2024 15:19:23 +0100 Subject: [PATCH 03/10] Add unit tests for get_net_args() Signed-off-by: Bas Zoetekouw --- pytests/test_container_to_args.py | 4 +- pytests/test_get_net_args.py | 216 ++++++++++++++++++++++++++++++ 2 files changed, 218 insertions(+), 2 deletions(-) create mode 100644 pytests/test_get_net_args.py diff --git a/pytests/test_container_to_args.py b/pytests/test_container_to_args.py index 66f6812..5bfb8c5 100644 --- a/pytests/test_container_to_args.py +++ b/pytests/test_container_to_args.py @@ -6,9 +6,9 @@ from unittest import mock from podman_compose import container_to_args -def create_compose_mock(): +def create_compose_mock(project_name="test_project_name"): compose = mock.Mock() - compose.project_name = "test_project_name" + compose.project_name = project_name compose.dirname = "test_dirname" compose.container_names_by_service.get = mock.Mock(return_value=None) compose.prefer_volume_over_mount = False diff --git a/pytests/test_get_net_args.py b/pytests/test_get_net_args.py new file mode 100644 index 0000000..433025a --- /dev/null +++ b/pytests/test_get_net_args.py @@ -0,0 +1,216 @@ +import unittest + +from podman_compose import get_net_args + +from .test_container_to_args import create_compose_mock + +PROJECT_NAME = "test_project_name" +SERVICE_NAME = "service_name" +CONTAINER_NAME = f"{PROJECT_NAME}_{SERVICE_NAME}_1" + + +def get_networked_compose(num_networks=1): + compose = create_compose_mock(PROJECT_NAME) + for network in range(num_networks): + compose.networks[f"net{network}"] = { + "driver": "bridge", + "ipam": { + "config": [ + {"subnet": f"192.168.{network}.0/24"}, + {"subnet": f"fd00:{network}::/64"}, + ] + }, + "enable_ipv6": True, + } + + return compose + + +def get_minimal_container(): + return { + "name": CONTAINER_NAME, + "service_name": SERVICE_NAME, + "image": "busybox", + } + + +class TestGetNetArgs(unittest.TestCase): + def test_minimal(self): + compose = get_networked_compose() + container = get_minimal_container() + container["networks"] = {"net0": {}} + + expected_args = [ + "--network", + f"{PROJECT_NAME}_net0", + "--network-alias", + SERVICE_NAME, + ] + args = get_net_args(compose, container) + self.assertListEqual(expected_args, args) + + def test_alias(self): + compose = get_networked_compose() + container = get_minimal_container() + container["networks"] = {"net0": {}} + container["_aliases"] = ["alias1", "alias2"] + + expected_args = [ + "--network", + f"{PROJECT_NAME}_net0", + "--network-alias", + f"{SERVICE_NAME},alias1,alias2", + ] + args = get_net_args(compose, container) + self.assertListEqual(expected_args, args) + + def test_one_ipv4(self): + ip = "192.168.0.42" + compose = get_networked_compose() + container = get_minimal_container() + container["networks"] = {"net0": {"ipv4_address": ip}} + + expected_args = [ + "--network", + f"{PROJECT_NAME}_net0", + "--ip=" + ip, + "--network-alias", + SERVICE_NAME, + ] + args = get_net_args(compose, container) + self.assertEqual(expected_args, args) + + def test_one_ipv6(self): + ipv6_address = "fd00:0::42" + compose = get_networked_compose() + container = get_minimal_container() + container["networks"] = {"net0": {"ipv6_address": ipv6_address}} + + expected_args = [ + "--network", + f"{PROJECT_NAME}_net0", + "--ip6=" + ipv6_address, + "--network-alias", + SERVICE_NAME, + ] + args = get_net_args(compose, container) + self.assertListEqual(expected_args, args) + + def test_one_mac(self): + mac = "00:11:22:33:44:55" + compose = get_networked_compose() + container = get_minimal_container() + container["networks"] = {"net0": {}} + container["mac_address"] = mac + + expected_args = [ + "--mac-address", + mac, + "--network", + f"{PROJECT_NAME}_net0", + "--network-alias", + SERVICE_NAME, + ] + args = get_net_args(compose, container) + self.assertListEqual(expected_args, args) + + def test_two_nets_as_dict(self): + compose = get_networked_compose(num_networks=2) + container = get_minimal_container() + container["networks"] = {"net0": {}, "net1": {}} + + expected_args = [ + "--network", + f"{PROJECT_NAME}_net0", + "--network", + f"{PROJECT_NAME}_net1", + "--network-alias", + SERVICE_NAME, + ] + args = get_net_args(compose, container) + self.assertListEqual(expected_args, args) + + def test_two_nets_as_list(self): + compose = get_networked_compose(num_networks=2) + container = get_minimal_container() + container["networks"] = ["net0", "net1"] + + expected_args = [ + "--network", + f"{PROJECT_NAME}_net0", + "--network", + f"{PROJECT_NAME}_net1", + "--network-alias", + SERVICE_NAME, + ] + args = get_net_args(compose, container) + self.assertListEqual(expected_args, args) + + def test_two_ipv4(self): + ip0 = "192.168.0.42" + ip1 = "192.168.1.42" + compose = get_networked_compose(num_networks=2) + container = get_minimal_container() + container["networks"] = {"net0": {"ipv4_address": ip0}, "net1": {"ipv4_address": ip1}} + + expected_args = [ + "--network", + f"{PROJECT_NAME}_net0:ip={ip0}", + "--network", + f"{PROJECT_NAME}_net1:ip={ip1}", + "--network-alias", + SERVICE_NAME, + ] + args = get_net_args(compose, container) + self.assertListEqual(expected_args, args) + + def test_two_ipv6(self): + ip0 = "fd00:0::42" + ip1 = "fd00:1::42" + compose = get_networked_compose(num_networks=2) + container = get_minimal_container() + container["networks"] = {"net0": {"ipv6_address": ip0}, "net1": {"ipv6_address": ip1}} + + expected_args = [ + "--network", + f"{PROJECT_NAME}_net0:ip={ip0}", + "--network", + f"{PROJECT_NAME}_net1:ip={ip1}", + "--network-alias", + SERVICE_NAME, + ] + args = get_net_args(compose, container) + self.assertListEqual(expected_args, args) + + def test_mixed_config(self): + ip4_0 = "192.168.0.42" + ip4_1 = "192.168.1.42" + ip6_0 = "fd00:0::42" + ip6_2 = "fd00:2::42" + mac = "00:11:22:33:44:55" + compose = get_networked_compose(num_networks=4) + container = get_minimal_container() + container["networks"] = { + "net0": {"ipv4_address": ip4_0, "ipv6_address": ip6_0}, + "net1": {"ipv4_address": ip4_1}, + "net2": {"ipv6_address": ip6_2}, + "net3": {}, + } + container["mac_address"] = mac + + expected_args = [ + "--mac-address", + mac, + "--network", + f"{PROJECT_NAME}_net0:ip={ip4_0},ip={ip6_0}", + "--network", + f"{PROJECT_NAME}_net1:ip={ip4_1}", + "--network", + f"{PROJECT_NAME}_net2:ip={ip6_2}", + "--network", + f"{PROJECT_NAME}_net3", + "--network-alias", + SERVICE_NAME, + ] + args = get_net_args(compose, container) + self.assertListEqual(expected_args, args) From 45ca1f994f8870e13e2cc677e858ce6d32acf02d Mon Sep 17 00:00:00 2001 From: Bas Zoetekouw Date: Thu, 21 Mar 2024 15:36:49 +0100 Subject: [PATCH 04/10] Support podman-specific per-network mac_address specifiation Signed-off-by: Bas Zoetekouw --- docs/Extensions.md | 47 ++++++++++++++++++ podman_compose.py | 28 +++++++++-- pytests/test_get_net_args.py | 68 +++++++++++++++++++++++++-- tests/nets_test_ip/docker-compose.yml | 2 + tests/test_podman_compose_networks.py | 10 ++-- 5 files changed, 144 insertions(+), 11 deletions(-) create mode 100644 docs/Extensions.md diff --git a/docs/Extensions.md b/docs/Extensions.md new file mode 100644 index 0000000..a538822 --- /dev/null +++ b/docs/Extensions.md @@ -0,0 +1,47 @@ +# Podman specific extensions to the docker-compose format + +Podman-compose supports the following extension to the docker-compose format. + +## Per-network MAC-addresses + +Generic docker-compose files support specification of the MAC address on the container level. If the +container has multiple network interfaces, the specified MAC address is applied to the first +specified network. + +Podman-compose in addition supports the specification of MAC addresses on a per-network basis. This +is done by adding a `podman.mac_address` key to the network configuration in the container. The +value of the `podman.mac_address` key is the MAC address to be used for the network interface. + +Specifying a MAC address for the container and for individual networks at the same time is not +supported. + +Example: + +```yaml +--- +version: "3" + +networks: + net0: + driver: "bridge" + ipam: + config: + - subnet: "192.168.0.0/24" + net1: + driver: "bridge" + ipam: + config: + - subnet: "192.168.1.0/24" + +services: + webserver + image: "busybox" + command: ["/bin/busybox", "httpd", "-f", "-h", "/etc", "-p", "8001"] + networks: + net0: + ipv4_address: "192.168.0.10" + podman.mac_address: "02:aa:aa:aa:aa:aa" + net1: + ipv4_address: "192.168.1.10" + podman.mac_address: "02:bb:bb:bb:bb:bb" +``` diff --git a/podman_compose.py b/podman_compose.py index 4148ed3..73ec795 100755 --- a/podman_compose.py +++ b/podman_compose.py @@ -779,10 +779,8 @@ async def assert_cnt_nets(compose, cnt): def get_net_args(compose, cnt): service_name = cnt["service_name"] net_args = [] - mac_address = cnt.get("mac_address", None) - if mac_address: - net_args.extend(["--mac-address", mac_address]) is_bridge = False + mac_address = cnt.get("mac_address", None) net = cnt.get("network_mode", None) if net: if net == "none": @@ -866,6 +864,18 @@ def get_net_args(compose, cnt): else: multiple_nets = {net: net_config or {} for net, net_config in multiple_nets.items()} + # if a mac_address was specified on the container level, we need to check that it is not + # specified on the network level as well + if mac_address is not None: + for net_config_ in multiple_nets.values(): + network_mac = net_config_.get("podman.mac_address", None) + if network_mac is not None: + raise RuntimeError( + f"conflicting mac addresses {mac_address} and {network_mac}:" + "specifying mac_address on both container and network level " + "is not supported" + ) + for net_, net_config_ in multiple_nets.items(): net_desc = nets[net_] or {} is_ext = net_desc.get("external", None) @@ -875,6 +885,16 @@ def get_net_args(compose, cnt): ipv4 = net_config_.get("ipv4_address", None) ipv6 = net_config_.get("ipv6_address", None) + # custom extension; not supported by docker-compose v3 + mac = net_config_.get("podman.mac_address", None) + + # if a mac_address was specified on the container level, apply it to the first network + # This works for Python > 3.6, because dict insert ordering is preserved, so we are + # sure that the first network we encounter here is also the first one specified by + # the user + if mac is None and mac_address is not None: + mac = mac_address + mac_address = None net_options = [] if ipv4: @@ -893,6 +913,8 @@ def get_net_args(compose, cnt): net_args.append(f"--ip={ip}") if ip6: net_args.append(f"--ip6={ip6}") + if mac_address: + net_args.append(f"--mac-address={mac_address}") if is_bridge: net_args.extend(["--network-alias", ",".join(aliases)]) diff --git a/pytests/test_get_net_args.py b/pytests/test_get_net_args.py index 433025a..94cfff3 100644 --- a/pytests/test_get_net_args.py +++ b/pytests/test_get_net_args.py @@ -104,10 +104,27 @@ class TestGetNetArgs(unittest.TestCase): container["mac_address"] = mac expected_args = [ - "--mac-address", - mac, "--network", f"{PROJECT_NAME}_net0", + "--mac-address=" + mac, + "--network-alias", + SERVICE_NAME, + ] + args = get_net_args(compose, container) + self.assertListEqual(expected_args, args) + + def test_one_mac_two_nets(self): + mac = "00:11:22:33:44:55" + compose = get_networked_compose(num_networks=6) + container = get_minimal_container() + container["networks"] = {"net0": {}, "net1": {}} + container["mac_address"] = mac + + expected_args = [ + "--network", + f"{PROJECT_NAME}_net0:mac={mac}", + "--network", + f"{PROJECT_NAME}_net1", "--network-alias", SERVICE_NAME, ] @@ -182,6 +199,49 @@ class TestGetNetArgs(unittest.TestCase): args = get_net_args(compose, container) self.assertListEqual(expected_args, args) + # custom extension; not supported by docker-compose + def test_two_mac(self): + mac0 = "00:00:00:00:00:01" + mac1 = "00:00:00:00:00:02" + compose = get_networked_compose(num_networks=2) + container = get_minimal_container() + container["networks"] = { + "net0": {"podman.mac_address": mac0}, + "net1": {"podman.mac_address": mac1}, + } + + expected_args = [ + "--network", + f"{PROJECT_NAME}_net0:mac={mac0}", + "--network", + f"{PROJECT_NAME}_net1:mac={mac1}", + "--network-alias", + SERVICE_NAME, + ] + args = get_net_args(compose, container) + self.assertListEqual(expected_args, args) + + def test_mixed_mac(self): + ip4_0 = "192.168.0.42" + ip4_1 = "192.168.1.42" + ip4_2 = "192.168.2.42" + mac_0 = "00:00:00:00:00:01" + mac_1 = "00:00:00:00:00:02" + + compose = get_networked_compose(num_networks=3) + container = get_minimal_container() + container["networks"] = { + "net0": {"ipv4_address": ip4_0}, + "net1": {"ipv4_address": ip4_1, "podman.mac_address": mac_0}, + "net2": {"ipv4_address": ip4_2}, + } + container["mac_address"] = mac_1 + + expected_exception = ( + r"specifying mac_address on both container and network level " r"is not supported" + ) + self.assertRaisesRegex(RuntimeError, expected_exception, get_net_args, compose, container) + def test_mixed_config(self): ip4_0 = "192.168.0.42" ip4_1 = "192.168.1.42" @@ -199,10 +259,8 @@ class TestGetNetArgs(unittest.TestCase): container["mac_address"] = mac expected_args = [ - "--mac-address", - mac, "--network", - f"{PROJECT_NAME}_net0:ip={ip4_0},ip={ip6_0}", + f"{PROJECT_NAME}_net0:ip={ip4_0},ip={ip6_0},mac={mac}", "--network", f"{PROJECT_NAME}_net1:ip={ip4_1}", "--network", diff --git a/tests/nets_test_ip/docker-compose.yml b/tests/nets_test_ip/docker-compose.yml index c2ed04c..8b0ae70 100644 --- a/tests/nets_test_ip/docker-compose.yml +++ b/tests/nets_test_ip/docker-compose.yml @@ -20,8 +20,10 @@ services: networks: shared-network: ipv4_address: "172.19.1.10" + podman.mac_address: "02:01:01:00:01:01" internal-network: ipv4_address: "172.19.2.10" + podman.mac_address: "02:01:01:00:02:01" volumes: - ./test1.txt:/var/www/html/index.txt:ro,z web2: diff --git a/tests/test_podman_compose_networks.py b/tests/test_podman_compose_networks.py index c86cf25..faee1fb 100644 --- a/tests/test_podman_compose_networks.py +++ b/tests/test_podman_compose_networks.py @@ -91,8 +91,11 @@ class TestPodmanComposeNetwork(RunSubprocessMixin, unittest.TestCase): self.assertEqual(f"{expect}\r\n", out.decode('utf-8')) expected_macip = { - "web1": {"eth0": ["172.19.1.10"], "eth1": ["172.19.2.10"]}, - "web2": {"eth0": ["172.19.2.11"]}, + "web1": { + "eth0": ["172.19.1.10", "02:01:01:00:01:01"], + "eth1": ["172.19.2.10", "02:01:01:00:02:01"], + }, + "web2": {"eth0": ["172.19.2.11", "02:01:01:00:02:02"]}, } for service, interfaces in expected_macip.items(): @@ -108,5 +111,6 @@ class TestPodmanComposeNetwork(RunSubprocessMixin, unittest.TestCase): ] out, _ = self.run_subprocess_assert_returncode(ip_cmd) for interface, values in interfaces.items(): - ip = values[0] + ip, mac = values + self.assertIn(f"ether {mac}", out.decode('utf-8')) self.assertIn(f"inet {ip}/", out.decode('utf-8')) From bdff78dcba77fd1a7ebd09a0d6005d647974e08f Mon Sep 17 00:00:00 2001 From: Bas Zoetekouw Date: Thu, 21 Mar 2024 15:37:10 +0100 Subject: [PATCH 05/10] Ignore files generated by tests Signed-off-by: Bas Zoetekouw --- .gitignore | 2 ++ podman_compose.py | 2 ++ 2 files changed, 4 insertions(+) diff --git a/.gitignore b/.gitignore index 6621793..6d96ad9 100644 --- a/.gitignore +++ b/.gitignore @@ -47,6 +47,8 @@ coverage.xml *.cover .hypothesis/ .pytest_cache/ +test-compose.yaml +test-compose-?.yaml # Translations *.mo diff --git a/podman_compose.py b/podman_compose.py index 73ec795..f005501 100755 --- a/podman_compose.py +++ b/podman_compose.py @@ -901,6 +901,8 @@ def get_net_args(compose, cnt): net_options.append(f"ip={ipv4}") if ipv6: net_options.append(f"ip={ipv6}") + if mac: + net_options.append(f"mac={mac}") if net_options: net_args.extend(["--network", f"{net_name}:" + ",".join(net_options)]) From 9baea704d769e457113d2bd2eccaf0ba9ce95201 Mon Sep 17 00:00:00 2001 From: Bas Zoetekouw Date: Thu, 21 Mar 2024 16:28:15 +0100 Subject: [PATCH 06/10] use preferred format of podman command line options Specifically: - use "--network=foo" instead of "--network foo" - specify "--network-alias" multiple times instead of concatenating values Signed-off-by: Bas Zoetekouw --- podman_compose.py | 22 +++--- pytests/test_container_to_args.py | 36 +++------ pytests/test_get_net_args.py | 118 +++++++++++++----------------- 3 files changed, 74 insertions(+), 102 deletions(-) diff --git a/podman_compose.py b/podman_compose.py index f005501..29cc86f 100755 --- a/podman_compose.py +++ b/podman_compose.py @@ -786,18 +786,18 @@ def get_net_args(compose, cnt): if net == "none": is_bridge = False elif net == "host": - net_args.extend(["--network", net]) + net_args.append(f"--network={net}") elif net.startswith("slirp4netns:"): - net_args.extend(["--network", net]) + net_args.append(f"--network={net}") elif net.startswith("ns:"): - net_args.extend(["--network", net]) + net_args.append(f"--network={net}") elif net.startswith("service:"): other_srv = net.split(":", 1)[1].strip() other_cnt = compose.container_names_by_service[other_srv][0] - net_args.extend(["--network", f"container:{other_cnt}"]) + net_args.append(f"--network=container:{other_cnt}") elif net.startswith("container:"): other_cnt = net.split(":", 1)[1].strip() - net_args.extend(["--network", f"container:{other_cnt}"]) + net_args.append(f"--network=container:{other_cnt}") elif net.startswith("bridge"): is_bridge = True else: @@ -905,12 +905,15 @@ def get_net_args(compose, cnt): net_options.append(f"mac={mac}") if net_options: - net_args.extend(["--network", f"{net_name}:" + ",".join(net_options)]) + net_args.append(f"--network={net_name}:" + ",".join(net_options)) else: - net_args.extend(["--network", f"{net_name}"]) + net_args.append(f"--network={net_name}") else: if is_bridge: - net_args.extend(["--network", net_names_str]) + if net_names_str: + net_args.append(f"--network={net_names_str}") + else: + net_args.append("--network=bridge") if ip: net_args.append(f"--ip={ip}") if ip6: @@ -919,7 +922,8 @@ def get_net_args(compose, cnt): net_args.append(f"--mac-address={mac_address}") if is_bridge: - net_args.extend(["--network-alias", ",".join(aliases)]) + for alias in aliases: + net_args.extend([f"--network-alias={alias}"]) return net_args diff --git a/pytests/test_container_to_args.py b/pytests/test_container_to_args.py index 5bfb8c5..4fd8077 100644 --- a/pytests/test_container_to_args.py +++ b/pytests/test_container_to_args.py @@ -37,10 +37,8 @@ class TestContainerToArgs(unittest.IsolatedAsyncioTestCase): [ "--name=project_name_service_name1", "-d", - "--network", - "", - "--network-alias", - "service_name", + "--network=bridge", + "--network-alias=service_name", "busybox", ], ) @@ -57,10 +55,8 @@ class TestContainerToArgs(unittest.IsolatedAsyncioTestCase): [ "--name=project_name_service_name1", "-d", - "--network", - "", - "--network-alias", - "service_name", + "--network=bridge", + "--network-alias=service_name", "--runtime", "runsc", "busybox", @@ -82,10 +78,8 @@ class TestContainerToArgs(unittest.IsolatedAsyncioTestCase): [ "--name=project_name_service_name1", "-d", - "--network", - "", - "--network-alias", - "service_name", + "--network=bridge", + "--network-alias=service_name", "--sysctl", "net.core.somaxconn=1024", "--sysctl", @@ -109,10 +103,8 @@ class TestContainerToArgs(unittest.IsolatedAsyncioTestCase): [ "--name=project_name_service_name1", "-d", - "--network", - "", - "--network-alias", - "service_name", + "--network=bridge", + "--network-alias=service_name", "--sysctl", "net.core.somaxconn=1024", "--sysctl", @@ -143,10 +135,8 @@ class TestContainerToArgs(unittest.IsolatedAsyncioTestCase): [ "--name=project_name_service_name1", "-d", - "--network", - "", - "--network-alias", - "service_name", + "--network=bridge", + "--network-alias=service_name", "--pid", "host", "busybox", @@ -166,10 +156,8 @@ class TestContainerToArgs(unittest.IsolatedAsyncioTestCase): "--name=project_name_service_name1", "-d", "--http-proxy=false", - "--network", - "", - "--network-alias", - "service_name", + "--network=bridge", + "--network-alias=service_name", "busybox", ], ) diff --git a/pytests/test_get_net_args.py b/pytests/test_get_net_args.py index 94cfff3..51970c9 100644 --- a/pytests/test_get_net_args.py +++ b/pytests/test_get_net_args.py @@ -38,13 +38,22 @@ class TestGetNetArgs(unittest.TestCase): def test_minimal(self): compose = get_networked_compose() container = get_minimal_container() + + expected_args = [ + "--network=bridge", + f"--network-alias={SERVICE_NAME}", + ] + args = get_net_args(compose, container) + self.assertListEqual(expected_args, args) + + def test_one_net(self): + compose = get_networked_compose() + container = get_minimal_container() container["networks"] = {"net0": {}} expected_args = [ - "--network", - f"{PROJECT_NAME}_net0", - "--network-alias", - SERVICE_NAME, + f"--network={PROJECT_NAME}_net0", + f"--network-alias={SERVICE_NAME}", ] args = get_net_args(compose, container) self.assertListEqual(expected_args, args) @@ -56,10 +65,10 @@ class TestGetNetArgs(unittest.TestCase): container["_aliases"] = ["alias1", "alias2"] expected_args = [ - "--network", - f"{PROJECT_NAME}_net0", - "--network-alias", - f"{SERVICE_NAME},alias1,alias2", + f"--network={PROJECT_NAME}_net0", + f"--network-alias={SERVICE_NAME}", + "--network-alias=alias1", + "--network-alias=alias2", ] args = get_net_args(compose, container) self.assertListEqual(expected_args, args) @@ -71,11 +80,9 @@ class TestGetNetArgs(unittest.TestCase): container["networks"] = {"net0": {"ipv4_address": ip}} expected_args = [ - "--network", - f"{PROJECT_NAME}_net0", - "--ip=" + ip, - "--network-alias", - SERVICE_NAME, + f"--network={PROJECT_NAME}_net0", + f"--ip={ip}", + f"--network-alias={SERVICE_NAME}", ] args = get_net_args(compose, container) self.assertEqual(expected_args, args) @@ -87,11 +94,9 @@ class TestGetNetArgs(unittest.TestCase): container["networks"] = {"net0": {"ipv6_address": ipv6_address}} expected_args = [ - "--network", - f"{PROJECT_NAME}_net0", - "--ip6=" + ipv6_address, - "--network-alias", - SERVICE_NAME, + f"--network={PROJECT_NAME}_net0", + f"--ip6={ipv6_address}", + f"--network-alias={SERVICE_NAME}", ] args = get_net_args(compose, container) self.assertListEqual(expected_args, args) @@ -104,11 +109,9 @@ class TestGetNetArgs(unittest.TestCase): container["mac_address"] = mac expected_args = [ - "--network", - f"{PROJECT_NAME}_net0", - "--mac-address=" + mac, - "--network-alias", - SERVICE_NAME, + f"--network={PROJECT_NAME}_net0", + f"--mac-address={mac}", + f"--network-alias={SERVICE_NAME}", ] args = get_net_args(compose, container) self.assertListEqual(expected_args, args) @@ -121,12 +124,9 @@ class TestGetNetArgs(unittest.TestCase): container["mac_address"] = mac expected_args = [ - "--network", - f"{PROJECT_NAME}_net0:mac={mac}", - "--network", - f"{PROJECT_NAME}_net1", - "--network-alias", - SERVICE_NAME, + f"--network={PROJECT_NAME}_net0:mac={mac}", + f"--network={PROJECT_NAME}_net1", + f"--network-alias={SERVICE_NAME}", ] args = get_net_args(compose, container) self.assertListEqual(expected_args, args) @@ -137,12 +137,9 @@ class TestGetNetArgs(unittest.TestCase): container["networks"] = {"net0": {}, "net1": {}} expected_args = [ - "--network", - f"{PROJECT_NAME}_net0", - "--network", - f"{PROJECT_NAME}_net1", - "--network-alias", - SERVICE_NAME, + f"--network={PROJECT_NAME}_net0", + f"--network={PROJECT_NAME}_net1", + f"--network-alias={SERVICE_NAME}", ] args = get_net_args(compose, container) self.assertListEqual(expected_args, args) @@ -153,12 +150,9 @@ class TestGetNetArgs(unittest.TestCase): container["networks"] = ["net0", "net1"] expected_args = [ - "--network", - f"{PROJECT_NAME}_net0", - "--network", - f"{PROJECT_NAME}_net1", - "--network-alias", - SERVICE_NAME, + f"--network={PROJECT_NAME}_net0", + f"--network={PROJECT_NAME}_net1", + f"--network-alias={SERVICE_NAME}", ] args = get_net_args(compose, container) self.assertListEqual(expected_args, args) @@ -171,12 +165,9 @@ class TestGetNetArgs(unittest.TestCase): container["networks"] = {"net0": {"ipv4_address": ip0}, "net1": {"ipv4_address": ip1}} expected_args = [ - "--network", - f"{PROJECT_NAME}_net0:ip={ip0}", - "--network", - f"{PROJECT_NAME}_net1:ip={ip1}", - "--network-alias", - SERVICE_NAME, + f"--network={PROJECT_NAME}_net0:ip={ip0}", + f"--network={PROJECT_NAME}_net1:ip={ip1}", + f"--network-alias={SERVICE_NAME}", ] args = get_net_args(compose, container) self.assertListEqual(expected_args, args) @@ -189,12 +180,9 @@ class TestGetNetArgs(unittest.TestCase): container["networks"] = {"net0": {"ipv6_address": ip0}, "net1": {"ipv6_address": ip1}} expected_args = [ - "--network", - f"{PROJECT_NAME}_net0:ip={ip0}", - "--network", - f"{PROJECT_NAME}_net1:ip={ip1}", - "--network-alias", - SERVICE_NAME, + f"--network={PROJECT_NAME}_net0:ip={ip0}", + f"--network={PROJECT_NAME}_net1:ip={ip1}", + f"--network-alias={SERVICE_NAME}", ] args = get_net_args(compose, container) self.assertListEqual(expected_args, args) @@ -211,12 +199,9 @@ class TestGetNetArgs(unittest.TestCase): } expected_args = [ - "--network", - f"{PROJECT_NAME}_net0:mac={mac0}", - "--network", - f"{PROJECT_NAME}_net1:mac={mac1}", - "--network-alias", - SERVICE_NAME, + f"--network={PROJECT_NAME}_net0:mac={mac0}", + f"--network={PROJECT_NAME}_net1:mac={mac1}", + f"--network-alias={SERVICE_NAME}", ] args = get_net_args(compose, container) self.assertListEqual(expected_args, args) @@ -259,16 +244,11 @@ class TestGetNetArgs(unittest.TestCase): container["mac_address"] = mac expected_args = [ - "--network", - f"{PROJECT_NAME}_net0:ip={ip4_0},ip={ip6_0},mac={mac}", - "--network", - f"{PROJECT_NAME}_net1:ip={ip4_1}", - "--network", - f"{PROJECT_NAME}_net2:ip={ip6_2}", - "--network", - f"{PROJECT_NAME}_net3", - "--network-alias", - SERVICE_NAME, + f"--network={PROJECT_NAME}_net0:ip={ip4_0},ip={ip6_0},mac={mac}", + f"--network={PROJECT_NAME}_net1:ip={ip4_1}", + f"--network={PROJECT_NAME}_net2:ip={ip6_2}", + f"--network={PROJECT_NAME}_net3", + f"--network-alias={SERVICE_NAME}", ] args = get_net_args(compose, container) self.assertListEqual(expected_args, args) From 65849c95e28299eb7165e746e0504c42f2e3cb8c Mon Sep 17 00:00:00 2001 From: Bas Zoetekouw Date: Thu, 21 Mar 2024 16:43:37 +0100 Subject: [PATCH 07/10] add comment about per-network aliases Signed-off-by: Bas Zoetekouw --- podman_compose.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/podman_compose.py b/podman_compose.py index 29cc86f..9abaaa1 100755 --- a/podman_compose.py +++ b/podman_compose.py @@ -853,8 +853,12 @@ def get_net_args(compose, cnt): net_names.append(net_name) net_names_str = ",".join(net_names) - # TODO add support for per-interface aliases - # see https://docs.docker.com/compose/compose-file/compose-file-v3/#aliases + # TODO: add support for per-interface aliases + # See https://docs.docker.com/compose/compose-file/compose-file-v3/#aliases + # Even though podman accepts network-specific aliases (e.g., --network=bridge:alias=foo, + # podman currently ignores this if a per-container network-alias is set; as pdoman-compose + # always sets a network-alias to the container name, is currently doesn't make sense to + # implement this. multiple_nets = cnt.get("networks", None) if multiple_nets and len(multiple_nets) > 1: # networks can be specified as a dict with config per network or as a plain list without From 9fd4cf43c12baebe4b8870de7e9179b31736d483 Mon Sep 17 00:00:00 2001 From: Bas Zoetekouw Date: Thu, 21 Mar 2024 19:37:40 +0100 Subject: [PATCH 08/10] Add unit tests for network_mode Signed-off-by: Bas Zoetekouw --- pytests/test_get_net_args.py | 40 ++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/pytests/test_get_net_args.py b/pytests/test_get_net_args.py index 51970c9..f70e1b7 100644 --- a/pytests/test_get_net_args.py +++ b/pytests/test_get_net_args.py @@ -1,5 +1,7 @@ import unittest +from parameterized import parameterized + from podman_compose import get_net_args from .test_container_to_args import create_compose_mock @@ -252,3 +254,41 @@ class TestGetNetArgs(unittest.TestCase): ] args = get_net_args(compose, container) self.assertListEqual(expected_args, args) + + @parameterized.expand([ + ("bridge", ["--network=bridge", f"--network-alias={SERVICE_NAME}"]), + ("host", ["--network=host"]), + ("none", []), + ("slirp4netns:cidr=10.42.0.0/24", ["--network=slirp4netns:cidr=10.42.0.0/24"]), + ("pasta:--ipv4-only,-a,10.0.2.0", ["--network=pasta:--ipv4-only,-a,10.0.2.0"]), + ("container:my_container", ["--network=container:my_container"]), + ]) + def test_network_modes(self, network_mode, expected_args): + compose = get_networked_compose() + container = get_minimal_container() + container["network_mode"] = network_mode + + args = get_net_args(compose, container) + self.assertListEqual(expected_args, args) + + def test_network_mode_invalid(self): + compose = get_networked_compose() + container = get_minimal_container() + container["network_mode"] = "invalid_mode" + + with self.assertRaises(SystemExit): + get_net_args(compose, container) + + def test_network__mode_service(self): + compose = get_networked_compose() + compose.container_names_by_service = { + "service_1": ["container_1"], + "service_2": ["container_2"], + } + + container = get_minimal_container() + container["network_mode"] = "service:service_2" + + expected_args = ["--network=container:container_2"] + args = get_net_args(compose, container) + self.assertListEqual(expected_args, args) From 6feff244db2258bc0ad34800d88d99521e5cc335 Mon Sep 17 00:00:00 2001 From: Bas Zoetekouw Date: Thu, 21 Mar 2024 19:41:25 +0100 Subject: [PATCH 09/10] slirp4netns can be used without options Signed-off-by: Bas Zoetekouw --- podman_compose.py | 2 +- pytests/test_get_net_args.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/podman_compose.py b/podman_compose.py index 9abaaa1..ed39b6c 100755 --- a/podman_compose.py +++ b/podman_compose.py @@ -787,7 +787,7 @@ def get_net_args(compose, cnt): is_bridge = False elif net == "host": net_args.append(f"--network={net}") - elif net.startswith("slirp4netns:"): + elif net.startswith("slirp4netns"): # Note: podman-specific network mode net_args.append(f"--network={net}") elif net.startswith("ns:"): net_args.append(f"--network={net}") diff --git a/pytests/test_get_net_args.py b/pytests/test_get_net_args.py index f70e1b7..ca149bd 100644 --- a/pytests/test_get_net_args.py +++ b/pytests/test_get_net_args.py @@ -259,6 +259,7 @@ class TestGetNetArgs(unittest.TestCase): ("bridge", ["--network=bridge", f"--network-alias={SERVICE_NAME}"]), ("host", ["--network=host"]), ("none", []), + ("slirp4netns", ["--network=slirp4netns"]), ("slirp4netns:cidr=10.42.0.0/24", ["--network=slirp4netns:cidr=10.42.0.0/24"]), ("pasta:--ipv4-only,-a,10.0.2.0", ["--network=pasta:--ipv4-only,-a,10.0.2.0"]), ("container:my_container", ["--network=container:my_container"]), From 953534a71a436f45959a1c3e794f2f015ea52428 Mon Sep 17 00:00:00 2001 From: Bas Zoetekouw Date: Thu, 21 Mar 2024 19:42:05 +0100 Subject: [PATCH 10/10] Support and document all podman-specific network_modes Signed-off-by: Bas Zoetekouw --- docs/Extensions.md | 20 ++++++++++++++++++++ podman_compose.py | 7 ++++++- pytests/test_get_net_args.py | 3 +++ 3 files changed, 29 insertions(+), 1 deletion(-) diff --git a/docs/Extensions.md b/docs/Extensions.md index a538822..6d9af73 100644 --- a/docs/Extensions.md +++ b/docs/Extensions.md @@ -45,3 +45,23 @@ services: ipv4_address: "192.168.1.10" podman.mac_address: "02:bb:bb:bb:bb:bb" ``` + +## Podman-specific network modes + +Generic docker-compose supports the following values for `network-mode` for a container: + +- `bridge` +- `host` +- `none` +- `service` +- `container` + +In addition, podman-compose supports the following podman-specific values for `network-mode`: + +- `slirp4netns[:,...]` +- `ns:` +- `pasta[:,...]` +- `private` + +The options to the network modes are passed to the `--network` option of the `podman create` command +as-is. diff --git a/podman_compose.py b/podman_compose.py index ed39b6c..b9942ec 100755 --- a/podman_compose.py +++ b/podman_compose.py @@ -789,7 +789,11 @@ def get_net_args(compose, cnt): net_args.append(f"--network={net}") elif net.startswith("slirp4netns"): # Note: podman-specific network mode net_args.append(f"--network={net}") - elif net.startswith("ns:"): + elif net == "private": # Note: podman-specific network mode + net_args.append("--network=private") + elif net.startswith("pasta"): # Note: podman-specific network mode + net_args.append(f"--network={net}") + elif net.startswith("ns:"): # Note: podman-specific network mode net_args.append(f"--network={net}") elif net.startswith("service:"): other_srv = net.split(":", 1)[1].strip() @@ -809,6 +813,7 @@ def get_net_args(compose, cnt): default_net = compose.default_net nets = compose.networks cnt_nets = cnt.get("networks", None) + aliases = [service_name] # NOTE: from podman manpage: # NOTE: A container will only have access to aliases on the first network diff --git a/pytests/test_get_net_args.py b/pytests/test_get_net_args.py index ca149bd..6a9fc5c 100644 --- a/pytests/test_get_net_args.py +++ b/pytests/test_get_net_args.py @@ -261,7 +261,10 @@ class TestGetNetArgs(unittest.TestCase): ("none", []), ("slirp4netns", ["--network=slirp4netns"]), ("slirp4netns:cidr=10.42.0.0/24", ["--network=slirp4netns:cidr=10.42.0.0/24"]), + ("private", ["--network=private"]), + ("pasta", ["--network=pasta"]), ("pasta:--ipv4-only,-a,10.0.2.0", ["--network=pasta:--ipv4-only,-a,10.0.2.0"]), + ("ns:my_namespace", ["--network=ns:my_namespace"]), ("container:my_container", ["--network=container:my_container"]), ]) def test_network_modes(self, network_mode, expected_args):