diff --git a/podman_compose.py b/podman_compose.py index ab2e605..9e3efe0 100755 --- a/podman_compose.py +++ b/podman_compose.py @@ -1523,6 +1523,8 @@ class PodmanCompose: # log(filename, json.dumps(content, indent = 2)) content = rec_subs(content, self.environ) rec_merge(compose, content) + resolved_services = self._resolve_profiles(compose.get("services", {}), set(args.profile)) + compose["services"] = resolved_services self.merged_yaml = yaml.safe_dump(compose) merged_json_b = json.dumps(compose, separators=(",", ":")).encode("utf-8") self.yaml_hash = hashlib.sha256(merged_json_b).hexdigest() @@ -1552,6 +1554,8 @@ class PodmanCompose: if services is None: services = {} log("WARNING: No services defined") + # include services with no profile defined or the selected profiles + services = self._resolve_profiles(services, set(args.profile)) # NOTE: maybe add "extends.service" to _deps at this stage flat_deps(services, with_extends=True) @@ -1666,6 +1670,28 @@ class PodmanCompose: self.containers = containers self.container_by_name = {c["name"]: c for c in containers} + def _resolve_profiles(self, defined_services, requested_profiles=None): + """ + Returns a service dictionary (key = service name, value = service config) compatible with the requested_profiles + list. + + The returned service dictionary contains all services which do not include/reference a profile in addition to + services that match the requested_profiles. + + :param defined_services: The service dictionary + :param requested_profiles: The profiles requested using the --profile arg. + """ + if requested_profiles is None: + requested_profiles = set() + + services = {} + + for name, config in defined_services.items(): + service_profiles = set(config.get("profiles", [])) + if not service_profiles or requested_profiles.intersection(service_profiles): + services[name] = config + return services + def _parse_args(self): parser = argparse.ArgumentParser(formatter_class=argparse.RawTextHelpFormatter) self._init_global_parser(parser) @@ -1717,6 +1743,13 @@ class PodmanCompose: action="append", default=[], ) + parser.add_argument( + "--profile", + help="Specify a profile to enable", + metavar="profile", + action="append", + default=[] + ) parser.add_argument( "-p", "--project-name", diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..2733b73 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,25 @@ +"""conftest.py + +Defines global pytest fixtures available to all tests. +""" +import pytest +from pathlib import Path +import os + + +@pytest.fixture +def base_path(): + """Returns the base path for the project""" + return Path(__file__).parent.parent + + +@pytest.fixture +def test_path(base_path): + """Returns the path to the tests directory""" + return os.path.join(base_path, "tests") + + +@pytest.fixture +def podman_compose_path(base_path): + """Returns the path to the podman compose script""" + return os.path.join(base_path, "podman_compose.py") diff --git a/tests/profile/docker-compose.yml b/tests/profile/docker-compose.yml new file mode 100644 index 0000000..0a2a7cd --- /dev/null +++ b/tests/profile/docker-compose.yml @@ -0,0 +1,24 @@ +version: "3" +services: + default-service: + image: busybox + command: ["/bin/busybox", "httpd", "-f", "-h", "/etc/", "-p", "8000"] + tmpfs: + - /run + - /tmp + service-1: + image: busybox + command: ["/bin/busybox", "httpd", "-f", "-h", "/etc/", "-p", "8000"] + tmpfs: + - /run + - /tmp + profiles: + - profile-1 + service-2: + image: busybox + command: ["/bin/busybox", "httpd", "-f", "-h", "/etc/", "-p", "8000"] + tmpfs: + - /run + - /tmp + profiles: + - profile-2 \ No newline at end of file diff --git a/tests/test_podman_compose_config.py b/tests/test_podman_compose_config.py new file mode 100644 index 0000000..4ece5ab --- /dev/null +++ b/tests/test_podman_compose_config.py @@ -0,0 +1,78 @@ +""" +test_podman_compose_config.py + +Tests the podman-compose config command which is used to return defined compose services. +""" +import pytest +import os +from test_podman_compose import capture + + +@pytest.fixture +def profile_compose_file(test_path): + """"Returns the path to the `profile` compose file used for this test module""" + return os.path.join(test_path, "profile", "docker-compose.yml") + + +def test_config_no_profiles(podman_compose_path, profile_compose_file): + """ + Tests podman-compose config command without profile enablement. + + :param podman_compose_path: The fixture used to specify the path to the podman compose file. + :param profile_compose_file: The fixtued used to specify the path to the "profile" compose used in the test. + """ + config_cmd = [ + "python3", + podman_compose_path, + "-f", + profile_compose_file, + "config" + ] + + out, err, return_code = capture(config_cmd) + assert return_code == 0 + + string_output = out.decode("utf-8") + assert "default-service" in string_output + assert "service-1" not in string_output + assert "service-2" not in string_output + + +@pytest.mark.parametrize("profiles, expected_services", + [ + (["--profile", "profile-1", "config"], + {"default-service": True, "service-1": True, "service-2": False}), + (["--profile", "profile-2", "config"], + {"default-service": True, "service-1": False, "service-2": True}), + (["--profile", "profile-1", "--profile", "profile-2", "config"], + {"default-service": True, "service-1": True, "service-2": True}) + ]) +def test_config_profiles(podman_compose_path, profile_compose_file, profiles, expected_services): + """ + Tests podman-compose + :param podman_compose_path: The fixture used to specify the path to the podman compose file. + :param profile_compose_file: The fixtued used to specify the path to the "profile" compose used in the test. + :param profiles: The enabled profiles for the parameterized test. + :param expected_services: Dictionary used to model the expected "enabled" services in the profile. + Key = service name, Value = True if the service is enabled, otherwise False. + """ + config_cmd = [ + "python3", + podman_compose_path, + "-f", + profile_compose_file + ] + config_cmd.extend(profiles) + + out, err, return_code = capture(config_cmd) + assert return_code == 0 + + actual_output = out.decode("utf-8") + + assert len(expected_services) == 3 + + actual_services = {} + for service, expected_check in expected_services.items(): + actual_services[service] = service in actual_output + + assert expected_services == actual_services diff --git a/tests/test_podman_compose_up_down.py b/tests/test_podman_compose_up_down.py new file mode 100644 index 0000000..33c1c34 --- /dev/null +++ b/tests/test_podman_compose_up_down.py @@ -0,0 +1,80 @@ +""" +test_podman_compose_up_down.py + +Tests the podman compose up and down commands used to create and remove services. +""" +import pytest +import os +from test_podman_compose import capture + + +@pytest.fixture +def profile_compose_file(test_path): + """"Returns the path to the `profile` compose file used for this test module""" + return os.path.join(test_path, "profile", "docker-compose.yml") + + +@pytest.fixture(autouse=True) +def teardown(podman_compose_path, profile_compose_file): + """ + Ensures that the services within the "profile compose file" are removed between each test case. + + :param podman_compose_path: The path to the podman compose script. + :param profile_compose_file: The path to the compose file used for this test module. + """ + # run the test case + yield + + down_cmd = [ + "python3", + podman_compose_path, + "--profile", + "profile-1", + "--profile", + "profile-2", + "-f", + profile_compose_file, + "down" + ] + capture(down_cmd) + + +@pytest.mark.parametrize("profiles, expected_services", + [ + (["--profile", "profile-1", "up", "-d"], + {"default-service": True, "service-1": True, "service-2": False}), + (["--profile", "profile-2", "up", "-d"], + {"default-service": True, "service-1": False, "service-2": True}), + (["--profile", "profile-1", "--profile", "profile-2", "up", "-d"], + {"default-service": True, "service-1": True, "service-2": True}) + ]) +def test_up(podman_compose_path, profile_compose_file, profiles, expected_services): + up_cmd = [ + "python3", + podman_compose_path, + "-f", + profile_compose_file, + ] + up_cmd.extend(profiles) + + out, err, return_code = capture(up_cmd) + assert return_code == 0 + + check_cmd = [ + "podman", + "container", + "ps", + "--format", + '"{{.Names}}"', + ] + out, err, return_code = capture(check_cmd) + assert return_code == 0 + + assert len(expected_services) == 3 + actual_output = out.decode("utf-8") + + actual_services = {} + for service, expected_check in expected_services.items(): + actual_services[service] = service in actual_output + + assert expected_services == actual_services