diff --git a/.pylintrc b/.pylintrc index 75169ce..05cf9e1 100644 --- a/.pylintrc +++ b/.pylintrc @@ -1,7 +1,7 @@ [MESSAGES CONTROL] # C0111 missing-docstring: missing-class-docstring, missing-function-docstring, missing-method-docstring, missing-module-docstrin # consider-using-with: we need it for color formatter pipe -disable=too-many-lines,too-many-branches,too-many-locals,too-many-statements,too-many-arguments,too-many-instance-attributes,fixme,multiple-statements,missing-docstring,line-too-long,consider-using-f-string,consider-using-with,unnecessary-lambda-assignment +disable=too-many-lines,too-many-branches,too-many-locals,too-many-statements,too-many-arguments,too-many-instance-attributes,fixme,multiple-statements,missing-docstring,line-too-long,consider-using-f-string,consider-using-with,unnecessary-lambda-assignment,broad-exception-caught # allow _ for ignored variables # allow generic names like a,b,c and i,j,k,l,m,n and x,y,z # allow k,v for key/value diff --git a/newsfragments/1165-PROJECT_NAME-interpolation.bugfix b/newsfragments/1165-PROJECT_NAME-interpolation.bugfix new file mode 100644 index 0000000..e4200e0 --- /dev/null +++ b/newsfragments/1165-PROJECT_NAME-interpolation.bugfix @@ -0,0 +1 @@ +- Fixed interpolation of the environment variable **COMPOSE_PROJECT_NAME** when it is set from the top-level **name** value within the Compose file. diff --git a/newsfragments/1165-project-name-evaluation-order.bugfix b/newsfragments/1165-project-name-evaluation-order.bugfix new file mode 100644 index 0000000..d8834e1 --- /dev/null +++ b/newsfragments/1165-project-name-evaluation-order.bugfix @@ -0,0 +1 @@ +- Fixed project name evaluation order to match the order defined in the [compose spec](https://docs.docker.com/compose/how-tos/project-name/#set-a-project-name). diff --git a/newsfragments/sigint-up.bugfix b/newsfragments/sigint-up.bugfix new file mode 100644 index 0000000..8edd22c --- /dev/null +++ b/newsfragments/sigint-up.bugfix @@ -0,0 +1 @@ +- Fixed handling SIGINT when running "up" command to shutdown gracefully \ No newline at end of file diff --git a/podman_compose.py b/podman_compose.py index 50057b2..65cb74e 100755 --- a/podman_compose.py +++ b/podman_compose.py @@ -1980,6 +1980,25 @@ class PodmanCompose: sys.exit(1) content = normalize(content) # log(filename, json.dumps(content, indent = 2)) + + # See also https://docs.docker.com/compose/how-tos/project-name/#set-a-project-name + # **project_name** is initialized to the argument of the `-p` command line flag. + if not project_name: + project_name = self.environ.get("COMPOSE_PROJECT_NAME") + if not project_name: + project_name = content.get("name") + if not project_name: + project_name = dir_basename.lower() + # More strict then actually needed for simplicity: + # podman requires [a-zA-Z0-9][a-zA-Z0-9_.-]* + project_name_normalized = norm_re.sub("", project_name) + if not project_name_normalized: + raise RuntimeError(f"Project name [{project_name}] normalized to empty") + project_name = project_name_normalized + + self.project_name = project_name + self.environ.update({"COMPOSE_PROJECT_NAME": self.project_name}) + content = rec_subs(content, self.environ) if isinstance(services := content.get('services'), dict): for service in services.values(): @@ -2012,19 +2031,6 @@ class PodmanCompose: log.debug(" ** merged:\n%s", json.dumps(compose, indent=2)) # ver = compose.get('version') - if not project_name: - project_name = compose.get("name") - if project_name is None: - # More strict then actually needed for simplicity: - # podman requires [a-zA-Z0-9][a-zA-Z0-9_.-]* - project_name = self.environ.get("COMPOSE_PROJECT_NAME", dir_basename.lower()) - project_name = norm_re.sub("", project_name) - if not project_name: - raise RuntimeError(f"Project name [{dir_basename}] normalized to empty") - - self.project_name = project_name - self.environ.update({"COMPOSE_PROJECT_NAME": self.project_name}) - services = compose.get("services") if services is None: services = {} @@ -2629,7 +2635,7 @@ async def compose_build(compose, args): status = 0 for t in asyncio.as_completed(tasks): s = await t - if s is not None: + if s is not None and s != 0: status = s return status @@ -2794,9 +2800,22 @@ async def compose_up(compose: PodmanCompose, args): max_service_length = curr_length if curr_length > max_service_length else max_service_length tasks = set() + + async def handle_sigint(): + log.info("Caught SIGINT or Ctrl+C, shutting down...") + try: + log.info("Shutting down gracefully, please wait...") + down_args = argparse.Namespace(**dict(args.__dict__, volumes=False)) + await compose.commands["down"](compose, down_args) + except Exception as e: + log.error("Error during shutdown: %s", e) + finally: + for task in tasks: + task.cancel() + if sys.platform != 'win32': loop = asyncio.get_event_loop() - loop.add_signal_handler(signal.SIGINT, lambda: [t.cancel("User exit") for t in tasks]) + loop.add_signal_handler(signal.SIGINT, lambda: asyncio.create_task(handle_sigint())) for i, cnt in enumerate(compose.containers): # Add colored service prefix to output by piping output through sed diff --git a/tests/integration/build_fail_multi/__init__.py b/tests/integration/build_fail_multi/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration/build_fail_multi/bad/Dockerfile b/tests/integration/build_fail_multi/bad/Dockerfile new file mode 100644 index 0000000..8ee7eb8 --- /dev/null +++ b/tests/integration/build_fail_multi/bad/Dockerfile @@ -0,0 +1,3 @@ +FROM busybox + +RUN false diff --git a/tests/integration/build_fail_multi/docker-compose.yml b/tests/integration/build_fail_multi/docker-compose.yml new file mode 100644 index 0000000..d5de8e9 --- /dev/null +++ b/tests/integration/build_fail_multi/docker-compose.yml @@ -0,0 +1,8 @@ +version: "3" +services: + bad: + build: + context: bad + good: + build: + context: good diff --git a/tests/integration/build_fail_multi/good/Dockerfile b/tests/integration/build_fail_multi/good/Dockerfile new file mode 100644 index 0000000..d1c8850 --- /dev/null +++ b/tests/integration/build_fail_multi/good/Dockerfile @@ -0,0 +1,3 @@ +FROM busybox +#ensure that this build finishes second so that it has a chance to overwrite the return code +RUN sleep 0.5 diff --git a/tests/integration/build_fail_multi/test_podman_compose_build_fail_multi.py b/tests/integration/build_fail_multi/test_podman_compose_build_fail_multi.py new file mode 100644 index 0000000..167becb --- /dev/null +++ b/tests/integration/build_fail_multi/test_podman_compose_build_fail_multi.py @@ -0,0 +1,31 @@ +# 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_yaml_path(): + """ "Returns the path to the compose file used for this test module""" + base_path = os.path.join(test_path(), "build_fail_multi") + return os.path.join(base_path, "docker-compose.yml") + + +class TestComposeBuildFailMulti(unittest.TestCase, RunSubprocessMixin): + def test_build_fail_multi(self): + output, error = self.run_subprocess_assert_returncode( + [ + podman_compose_path(), + "-f", + compose_yaml_path(), + "build", + # prevent the successful build from being cached to ensure it runs long enough + "--no-cache", + ], + expected_returncode=1, + ) + self.assertIn("RUN false", str(output)) + self.assertIn("while running runtime: exit status 1", str(error)) diff --git a/tests/integration/env-tests/container-compose.yml b/tests/integration/env-tests/container-compose.yml index 3a8b28e..4498f09 100644 --- a/tests/integration/env-tests/container-compose.yml +++ b/tests/integration/env-tests/container-compose.yml @@ -1,5 +1,7 @@ version: "3" +name: my-project-name + services: env-test: image: busybox @@ -8,3 +10,9 @@ services: ZZVAR1: myval1 ZZVAR2: 2-$ZZVAR1 ZZVAR3: 3-$ZZVAR2 + + project-name-test: + image: busybox + command: sh -c "echo $$PNAME" + environment: + PNAME: ${COMPOSE_PROJECT_NAME} diff --git a/tests/integration/env-tests/test_podman_compose_env.py b/tests/integration/env-tests/test_podman_compose_env.py index 351644f..f1c35cf 100644 --- a/tests/integration/env-tests/test_podman_compose_env.py +++ b/tests/integration/env-tests/test_podman_compose_env.py @@ -36,3 +36,54 @@ class TestComposeEnv(unittest.TestCase, RunSubprocessMixin): compose_yaml_path(), "down", ]) + + """ + Tests interpolation of COMPOSE_PROJECT_NAME in the podman-compose config, + which is different from external environment variables because COMPOSE_PROJECT_NAME + is a predefined environment variable generated from the `name` value in the top-level + of the compose.yaml. + + See also + - https://docs.docker.com/reference/compose-file/interpolation/ + - https://docs.docker.com/reference/compose-file/version-and-name/#name-top-level-element + - https://docs.docker.com/compose/how-tos/environment-variables/envvars/ + - https://github.com/compose-spec/compose-spec/blob/main/04-version-and-name.md + """ + + def test_project_name(self): + try: + output, _ = self.run_subprocess_assert_returncode([ + podman_compose_path(), + "-f", + compose_yaml_path(), + "run", + "project-name-test", + ]) + self.assertIn("my-project-name", str(output)) + finally: + self.run_subprocess_assert_returncode([ + podman_compose_path(), + "-f", + compose_yaml_path(), + "down", + ]) + + def test_project_name_override(self): + try: + output, _ = self.run_subprocess_assert_returncode([ + podman_compose_path(), + "-f", + compose_yaml_path(), + "run", + "-e", + "COMPOSE_PROJECT_NAME=project-name-override", + "project-name-test", + ]) + self.assertIn("project-name-override", str(output)) + finally: + self.run_subprocess_assert_returncode([ + podman_compose_path(), + "-f", + compose_yaml_path(), + "down", + ]) diff --git a/tests/integration/selinux/docker-compose.yml b/tests/integration/selinux/docker-compose.yml index 24caab6..d166bc8 100644 --- a/tests/integration/selinux/docker-compose.yml +++ b/tests/integration/selinux/docker-compose.yml @@ -1,14 +1,18 @@ version: "3" services: - web1: + container1: image: busybox - command: httpd -f -p 80 -h /var/www/html + command: ["busybox", "sleep", "infinity"] volumes: - type: bind - source: ./docker-compose.yml - target: /var/www/html/index.html + source: ./host_test_text.txt + target: /test_text.txt bind: selinux: z - ports: - - "8080:80" - + container2: + image: busybox + command: ["busybox", "sleep", "infinity"] + volumes: + - type: bind + source: ./host_test_text.txt + target: /test_text.txt diff --git a/tests/integration/selinux/host_test_text.txt b/tests/integration/selinux/host_test_text.txt new file mode 100644 index 0000000..13462fe --- /dev/null +++ b/tests/integration/selinux/host_test_text.txt @@ -0,0 +1 @@ +# There must be a source file in the host for volumes type: bind diff --git a/tests/integration/selinux/test_podman_compose_selinux.py b/tests/integration/selinux/test_podman_compose_selinux.py new file mode 100644 index 0000000..2c04d18 --- /dev/null +++ b/tests/integration/selinux/test_podman_compose_selinux.py @@ -0,0 +1,58 @@ +# SPDX-License-Identifier: GPL-2.0 + +import json +import os +import subprocess +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 + + +class TestPodmanCompose(unittest.TestCase, RunSubprocessMixin): + def test_selinux(self): + # test if when using volumes type:bind with selinux:z option, container ackquires a + # respective host:source:z mapping in CreateCommand list + compose_path = os.path.join(test_path(), "selinux", "docker-compose.yml") + try: + # change working directory to where docker-compose.yml file is so that containers can + # directly access host source file for mounting from that working directory + subprocess.run( + [ + podman_compose_path(), + "-f", + compose_path, + "up", + "-d", + "container1", + "container2", + ], + cwd=os.path.join(test_path(), 'selinux'), + ) + out, _ = self.run_subprocess_assert_returncode([ + "podman", + "inspect", + "selinux_container1_1", + ]) + inspect_out = json.loads(out) + create_command_list = inspect_out[0].get("Config", []).get("CreateCommand", {}) + self.assertIn('./host_test_text.txt:/test_text.txt:z', create_command_list) + + out, _ = self.run_subprocess_assert_returncode([ + "podman", + "inspect", + "selinux_container2_1", + ]) + inspect_out = json.loads(out) + create_command_list = inspect_out[0].get("Config", []).get("CreateCommand", {}) + self.assertIn('./host_test_text.txt:/test_text.txt', create_command_list) + finally: + out, _ = self.run_subprocess_assert_returncode([ + podman_compose_path(), + "-f", + compose_path, + "down", + "-t", + "0", + ])