From 27bcca697f87c1cf06fcfa3067a5e46a2882f671 Mon Sep 17 00:00:00 2001 From: Simon Walker Date: Mon, 31 Jul 2023 13:35:21 +0100 Subject: [PATCH 01/39] Start to flesh out fixture --- tests/bootstrap/test_localstack_container.py | 116 ++++++++++++++++++- 1 file changed, 113 insertions(+), 3 deletions(-) diff --git a/tests/bootstrap/test_localstack_container.py b/tests/bootstrap/test_localstack_container.py index ad413a771a900..011f2e91f1a1a 100644 --- a/tests/bootstrap/test_localstack_container.py +++ b/tests/bootstrap/test_localstack_container.py @@ -1,15 +1,125 @@ +from __future__ import annotations + +import logging +import time + import pytest import requests -from localstack import config +from localstack import config, constants from localstack.config import in_docker from localstack.utils.bootstrap import LocalstackContainerServer +from localstack.utils.container_utils.container_client import ( + ContainerClient, + ContainerConfiguration, + ContainerException, + PortMappings, +) +from localstack.utils.docker_utils import DOCKER_CLIENT +from localstack.utils.strings import to_str + +LOG = logging.getLogger(__name__) + + +class LocalStackContainer: + def __init__(self, client: ContainerClient, config: ContainerConfiguration): + self.client = client + self.config = config + self.container_id: str | None = None + + def start(self) -> "LocalStackContainer": + self.container_id = self.client.create_container_from_config(self.config) + try: + self.client.start_container(self.container_id) + except ContainerException as e: + if LOG.isEnabledFor(logging.DEBUG): + LOG.exception("Error while starting LocalStack container") + else: + LOG.error( + "Error while starting LocalStack container: %s\n%s", e.message, to_str(e.stderr) + ) + raise + return self + + def run(self) -> "LocalStackContainer": + self.start() + return self.wait_until_ready() + + def is_up(self): + if self.container_id is None: + return False + + logs = self.client.get_container_logs(self.container_id) + return constants.READY_MARKER_OUTPUT in logs.splitlines() + + def wait_until_ready( + self, max_retries: int = 30, sleep_time: float = 0.2 + ) -> "LocalStackContainer": + for _ in range(max_retries): + if self.is_up(): + return self + + time.sleep(sleep_time) + + # TODO: bad error message + raise RuntimeError("Container did not start") + + def remove(self): + self.client.stop_container(self.container_id, timeout=10) + self.client.remove_container(self.container_id, force=True, check_existence=False) + + +class ContainerFactory: + def __init__(self): + self.client = DOCKER_CLIENT + self._containers: list[LocalStackContainer] = [] + + def __call__( + self, pro: bool = False, publish: list[int] | None = None, /, **kwargs + ) -> LocalStackContainer: + config = ContainerConfiguration(**kwargs) + if pro: + config.env_vars["GATEWAY_LISTEN"] = "0.0.0.0:4566,0.0.0.0:443" + config.env_vars["LOCALSTACK_API_KEY"] = "test" + + port_mappings = PortMappings() + if publish: + for port in publish: + port_mappings.add(port) + else: + port_mappings.add(4566) + + config.ports = port_mappings + container = LocalStackContainer(self.client, config) + self._containers.append(container) + return container + + def remove_all_containers(self): + failures = [] + for container in self._containers: + try: + container.remove() + except Exception as e: + failures.append((container, e)) + + if failures: + for (container, ex) in failures: + if LOG.isEnabledFor(logging.DEBUG): + LOG.error(f"Failed to remove container {container.container_id}", exc_info=ex) + else: + LOG.error(f"Failed to remove container {container.container_id}") + + +@pytest.fixture(scope="session") +def container_factory() -> ContainerFactory: + factory = ContainerFactory() + yield factory + factory.remove_all_containers() @pytest.mark.skipif(condition=in_docker(), reason="cannot run bootstrap tests in docker") class TestLocalstackContainerServer: - def test_lifecycle(self): - + def test_lifecycle(self, container_factory: ContainerFactory): server = LocalstackContainerServer() server.container.ports.add(config.EDGE_PORT) From ad5b50b858b8e82334dcce0445c8c3848730734d Mon Sep 17 00:00:00 2001 From: Simon Walker Date: Mon, 31 Jul 2023 13:39:25 +0100 Subject: [PATCH 02/39] Move container fixture to pytest conftest file --- tests/bootstrap/conftest.py | 113 ++++++++++++++++++ tests/bootstrap/test_localstack_container.py | 115 +------------------ 2 files changed, 115 insertions(+), 113 deletions(-) diff --git a/tests/bootstrap/conftest.py b/tests/bootstrap/conftest.py index 0579c8a9122cb..55c6c316a19cb 100644 --- a/tests/bootstrap/conftest.py +++ b/tests/bootstrap/conftest.py @@ -1,6 +1,23 @@ +from __future__ import annotations + +import logging +import time +from typing import Generator + import pytest from localstack import config +from localstack import constants +from localstack.utils.container_utils.container_client import ( + ContainerClient, + ContainerConfiguration, + ContainerException, + PortMappings, +) +from localstack.utils.docker_utils import DOCKER_CLIENT +from localstack.utils.strings import to_str + +LOG = logging.getLogger(__name__) @pytest.fixture(autouse=True) @@ -8,3 +25,99 @@ def _setup_cli_environment(monkeypatch): # normally we are setting LOCALSTACK_CLI in localstack/cli/main.py, which is not actually run in the tests monkeypatch.setenv("LOCALSTACK_CLI", "1") monkeypatch.setattr(config, "dirs", config.Directories.for_cli()) + + +class LocalStackContainer: + def __init__(self, client: ContainerClient, config: ContainerConfiguration): + self.client = client + self.config = config + self.container_id: str | None = None + + def start(self) -> "LocalStackContainer": + self.container_id = self.client.create_container_from_config(self.config) + try: + self.client.start_container(self.container_id) + except ContainerException as e: + if LOG.isEnabledFor(logging.DEBUG): + LOG.exception("Error while starting LocalStack container") + else: + LOG.error( + "Error while starting LocalStack container: %s\n%s", e.message, to_str(e.stderr) + ) + raise + return self + + def run(self) -> "LocalStackContainer": + self.start() + return self.wait_until_ready() + + def is_up(self): + if self.container_id is None: + return False + + logs = self.client.get_container_logs(self.container_id) + return constants.READY_MARKER_OUTPUT in logs.splitlines() + + def wait_until_ready( + self, max_retries: int = 30, sleep_time: float = 0.2 + ) -> "LocalStackContainer": + for _ in range(max_retries): + if self.is_up(): + return self + + time.sleep(sleep_time) + + # TODO: bad error message + raise RuntimeError("Container did not start") + + def remove(self): + self.client.stop_container(self.container_id, timeout=10) + self.client.remove_container(self.container_id, force=True, check_existence=False) + + +class ContainerFactory: + def __init__(self): + self.client = DOCKER_CLIENT + self._containers: list[LocalStackContainer] = [] + + def __call__( + self, pro: bool = False, publish: list[int] | None = None, /, **kwargs + ) -> LocalStackContainer: + config = ContainerConfiguration(**kwargs) + if pro: + config.env_vars["GATEWAY_LISTEN"] = "0.0.0.0:4566,0.0.0.0:443" + config.env_vars["LOCALSTACK_API_KEY"] = "test" + + port_mappings = PortMappings() + if publish: + for port in publish: + port_mappings.add(port) + else: + port_mappings.add(4566) + + config.ports = port_mappings + container = LocalStackContainer(self.client, config) + self._containers.append(container) + return container + + def remove_all_containers(self): + failures = [] + for container in self._containers: + try: + container.remove() + except Exception as e: + failures.append((container, e)) + + if failures: + for container, ex in failures: + if LOG.isEnabledFor(logging.DEBUG): + LOG.error(f"Failed to remove container {container.container_id}", exc_info=ex) + else: + LOG.error(f"Failed to remove container {container.container_id}") + + +@pytest.fixture(scope="session") +def container_factory() -> Generator[ContainerFactory, None, None]: + factory = ContainerFactory() + yield factory + factory.remove_all_containers() diff --git a/tests/bootstrap/test_localstack_container.py b/tests/bootstrap/test_localstack_container.py index 011f2e91f1a1a..729090b045521 100644 --- a/tests/bootstrap/test_localstack_container.py +++ b/tests/bootstrap/test_localstack_container.py @@ -1,125 +1,14 @@ -from __future__ import annotations - -import logging -import time - import pytest import requests -from localstack import config, constants +from localstack import config from localstack.config import in_docker from localstack.utils.bootstrap import LocalstackContainerServer -from localstack.utils.container_utils.container_client import ( - ContainerClient, - ContainerConfiguration, - ContainerException, - PortMappings, -) -from localstack.utils.docker_utils import DOCKER_CLIENT -from localstack.utils.strings import to_str - -LOG = logging.getLogger(__name__) - - -class LocalStackContainer: - def __init__(self, client: ContainerClient, config: ContainerConfiguration): - self.client = client - self.config = config - self.container_id: str | None = None - - def start(self) -> "LocalStackContainer": - self.container_id = self.client.create_container_from_config(self.config) - try: - self.client.start_container(self.container_id) - except ContainerException as e: - if LOG.isEnabledFor(logging.DEBUG): - LOG.exception("Error while starting LocalStack container") - else: - LOG.error( - "Error while starting LocalStack container: %s\n%s", e.message, to_str(e.stderr) - ) - raise - return self - - def run(self) -> "LocalStackContainer": - self.start() - return self.wait_until_ready() - - def is_up(self): - if self.container_id is None: - return False - - logs = self.client.get_container_logs(self.container_id) - return constants.READY_MARKER_OUTPUT in logs.splitlines() - - def wait_until_ready( - self, max_retries: int = 30, sleep_time: float = 0.2 - ) -> "LocalStackContainer": - for _ in range(max_retries): - if self.is_up(): - return self - - time.sleep(sleep_time) - - # TODO: bad error message - raise RuntimeError("Container did not start") - - def remove(self): - self.client.stop_container(self.container_id, timeout=10) - self.client.remove_container(self.container_id, force=True, check_existence=False) - - -class ContainerFactory: - def __init__(self): - self.client = DOCKER_CLIENT - self._containers: list[LocalStackContainer] = [] - - def __call__( - self, pro: bool = False, publish: list[int] | None = None, /, **kwargs - ) -> LocalStackContainer: - config = ContainerConfiguration(**kwargs) - if pro: - config.env_vars["GATEWAY_LISTEN"] = "0.0.0.0:4566,0.0.0.0:443" - config.env_vars["LOCALSTACK_API_KEY"] = "test" - - port_mappings = PortMappings() - if publish: - for port in publish: - port_mappings.add(port) - else: - port_mappings.add(4566) - - config.ports = port_mappings - container = LocalStackContainer(self.client, config) - self._containers.append(container) - return container - - def remove_all_containers(self): - failures = [] - for container in self._containers: - try: - container.remove() - except Exception as e: - failures.append((container, e)) - - if failures: - for (container, ex) in failures: - if LOG.isEnabledFor(logging.DEBUG): - LOG.error(f"Failed to remove container {container.container_id}", exc_info=ex) - else: - LOG.error(f"Failed to remove container {container.container_id}") - - -@pytest.fixture(scope="session") -def container_factory() -> ContainerFactory: - factory = ContainerFactory() - yield factory - factory.remove_all_containers() @pytest.mark.skipif(condition=in_docker(), reason="cannot run bootstrap tests in docker") class TestLocalstackContainerServer: - def test_lifecycle(self, container_factory: ContainerFactory): + def test_lifecycle(self): server = LocalstackContainerServer() server.container.ports.add(config.EDGE_PORT) From 5e27284081fc8a1917b6a790c98351558ec2bb30 Mon Sep 17 00:00:00 2001 From: Simon Walker Date: Mon, 31 Jul 2023 15:21:23 +0100 Subject: [PATCH 03/39] Use updated LocalstackContainer --- localstack/utils/bootstrap.py | 32 +++++- tests/bootstrap/conftest.py | 97 ++++++------------- .../test_container_listen_configuration.py | 12 +++ tests/integration/docker_utils/test_docker.py | 8 +- 4 files changed, 76 insertions(+), 73 deletions(-) create mode 100644 tests/bootstrap/test_container_listen_configuration.py diff --git a/localstack/utils/bootstrap.py b/localstack/utils/bootstrap.py index f84b3d733bf2d..ed26cd43b0702 100644 --- a/localstack/utils/bootstrap.py +++ b/localstack/utils/bootstrap.py @@ -370,14 +370,17 @@ def extract_port_flags(user_flags, port_mappings: PortMappings): class LocalstackContainer: config: ContainerConfiguration + id: str | None def __init__(self, name: str = None): self.config = self._get_default_configuration(name) self.logfile = os.path.join(config.dirs.tmp, f"{self.config.name}_container.log") self.additional_flags = [] # TODO: see comment in run() + self.id = None - def _get_default_configuration(self, name: str = None) -> ContainerConfiguration: + @staticmethod + def _get_default_configuration(name: str = None) -> ContainerConfiguration: """Returns a ContainerConfiguration populated with default values or values gathered from the environment for starting the LocalStack container.""" return ContainerConfiguration( @@ -392,7 +395,7 @@ def _get_default_configuration(self, name: str = None) -> ContainerConfiguration env_vars={}, ) - def run(self): + def run(self, attach: bool = True): if isinstance(DOCKER_CLIENT, CmdDockerClient): DOCKER_CLIENT.default_run_outfile = self.logfile @@ -413,7 +416,21 @@ def run(self): self._ensure_container_network(cfg.network) try: - return DOCKER_CLIENT.run_container_from_config(cfg) + self.id = DOCKER_CLIENT.create_container_from_config(cfg) + except ContainerException as e: + if LOG.isEnabledFor(logging.DEBUG): + LOG.exception("Error while creating LocalStack container") + else: + LOG.error( + "Error while creating LocalStack container: %s\n%s", e.message, to_str(e.stderr) + ) + raise + + # populate the name so that we can check if the container is running. + self.config.name = DOCKER_CLIENT.get_container_name(self.id) + + try: + return DOCKER_CLIENT.start_container(self.id, attach=attach) except ContainerException as e: if LOG.isEnabledFor(logging.DEBUG): LOG.exception("Error while starting LocalStack container") @@ -465,6 +482,15 @@ def volumes(self) -> VolumeMappings: def ports(self) -> PortMappings: return self.config.ports + def wait_until_ready(self, timeout: float | None = None): + if self.id is None: + raise ValueError("no container id found, cannot wait until ready") + return poll_condition(self.is_container_running, timeout) + + def is_container_running(self) -> bool: + logs = DOCKER_CLIENT.get_container_logs(self.id) + return constants.READY_MARKER_OUTPUT in logs.splitlines() + class LocalstackContainerServer(Server): container: LocalstackContainer diff --git a/tests/bootstrap/conftest.py b/tests/bootstrap/conftest.py index 55c6c316a19cb..ac91267a2cda4 100644 --- a/tests/bootstrap/conftest.py +++ b/tests/bootstrap/conftest.py @@ -1,21 +1,16 @@ from __future__ import annotations import logging -import time from typing import Generator import pytest from localstack import config -from localstack import constants from localstack.utils.container_utils.container_client import ( - ContainerClient, - ContainerConfiguration, - ContainerException, PortMappings, ) +from localstack.utils.bootstrap import LocalstackContainer from localstack.utils.docker_utils import DOCKER_CLIENT -from localstack.utils.strings import to_str LOG = logging.getLogger(__name__) @@ -27,66 +22,29 @@ def _setup_cli_environment(monkeypatch): monkeypatch.setattr(config, "dirs", config.Directories.for_cli()) -class LocalStackContainer: - def __init__(self, client: ContainerClient, config: ContainerConfiguration): - self.client = client - self.config = config - self.container_id: str | None = None - - def start(self) -> "LocalStackContainer": - self.container_id = self.client.create_container_from_config(self.config) - try: - self.client.start_container(self.container_id) - except ContainerException as e: - if LOG.isEnabledFor(logging.DEBUG): - LOG.exception("Error while starting LocalStack container") - else: - LOG.error( - "Error while starting LocalStack container: %s\n%s", e.message, to_str(e.stderr) - ) - raise - return self - - def run(self) -> "LocalStackContainer": - self.start() - return self.wait_until_ready() - - def is_up(self): - if self.container_id is None: - return False - - logs = self.client.get_container_logs(self.container_id) - return constants.READY_MARKER_OUTPUT in logs.splitlines() - - def wait_until_ready( - self, max_retries: int = 30, sleep_time: float = 0.2 - ) -> "LocalStackContainer": - for _ in range(max_retries): - if self.is_up(): - return self - - time.sleep(sleep_time) - - # TODO: bad error message - raise RuntimeError("Container did not start") - - def remove(self): - self.client.stop_container(self.container_id, timeout=10) - self.client.remove_container(self.container_id, force=True, check_existence=False) - - class ContainerFactory: def __init__(self): - self.client = DOCKER_CLIENT - self._containers: list[LocalStackContainer] = [] + self._containers: list[LocalstackContainer] = [] def __call__( - self, pro: bool = False, publish: list[int] | None = None, /, **kwargs - ) -> LocalStackContainer: - config = ContainerConfiguration(**kwargs) + self, + # convenience properties + pro: bool = False, + publish: list[int] | None = None, + # ContainerConfig properties + **kwargs, + ) -> LocalstackContainer: + container = LocalstackContainer() + + # allow for randomised container names + container.config.name = None + + for key, value in kwargs.items(): + setattr(container.config, key, value) + if pro: - config.env_vars["GATEWAY_LISTEN"] = "0.0.0.0:4566,0.0.0.0:443" - config.env_vars["LOCALSTACK_API_KEY"] = "test" + container.config.env_vars["GATEWAY_LISTEN"] = "0.0.0.0:4566,0.0.0.0:443" + container.config.env_vars["LOCALSTACK_API_KEY"] = "test" port_mappings = PortMappings() if publish: @@ -95,25 +53,32 @@ def __call__( else: port_mappings.add(4566) - config.ports = port_mappings - container = LocalStackContainer(self.client, config) + container.config.ports = port_mappings self._containers.append(container) return container def remove_all_containers(self): failures = [] for container in self._containers: + if not container.id: + LOG.error(f"Container {container} missing container_id") + continue + + # allow tests to stop the container manually + if not DOCKER_CLIENT.is_container_running(container.config.name): + continue + try: - container.remove() + DOCKER_CLIENT.stop_container(container_name=container.id, timeout=30) except Exception as e: failures.append((container, e)) if failures: for container, ex in failures: if LOG.isEnabledFor(logging.DEBUG): - LOG.error(f"Failed to remove container {container.container_id}", exc_info=ex) + LOG.error(f"Failed to remove container {container.id}", exc_info=ex) else: - LOG.error(f"Failed to remove container {container.container_id}") + LOG.error(f"Failed to remove container {container.id}") @pytest.fixture(scope="session") diff --git a/tests/bootstrap/test_container_listen_configuration.py b/tests/bootstrap/test_container_listen_configuration.py new file mode 100644 index 0000000000000..0197d8a2ae3cc --- /dev/null +++ b/tests/bootstrap/test_container_listen_configuration.py @@ -0,0 +1,12 @@ +from tests.bootstrap.conftest import ContainerFactory + + +def test_defaults(container_factory: ContainerFactory): + """ + The default configuration is to listen on 0.0.0.0:4566 + """ + container = container_factory() + container.run(attach=False) + container.wait_until_ready() + + print(10) diff --git a/tests/integration/docker_utils/test_docker.py b/tests/integration/docker_utils/test_docker.py index 392360a5f662b..2f7c39362b4a8 100644 --- a/tests/integration/docker_utils/test_docker.py +++ b/tests/integration/docker_utils/test_docker.py @@ -626,9 +626,9 @@ def _test_copy_into_container( with file_path.open(mode="w") as fd: fd.write("foobared\n") - docker_client.copy_into_container(c.container_name, str(local_path), container_path) + docker_client.copy_into_container(c.name, str(local_path), container_path) - output, _ = docker_client.start_container(c.container_id, attach=True) + output, _ = docker_client.start_container(c.id, attach=True) output = output.decode(config.DEFAULT_ENCODING) assert "foobared" in output @@ -1318,11 +1318,11 @@ def _test_copy_from_container( dummy_container, ): docker_client.exec_in_container( - dummy_container.container_id, + dummy_container.id, command=["sh", "-c", f"echo TEST_CONTENT > {container_file_name}"], ) docker_client.copy_from_container( - dummy_container.container_id, + dummy_container.id, local_path=str(local_path), container_path=container_file_name, ) From 7c9e3d48fc11f3f339e1fe8a764d5f787ca029e5 Mon Sep 17 00:00:00 2001 From: Simon Walker Date: Mon, 31 Jul 2023 15:26:04 +0100 Subject: [PATCH 04/39] Don't expose ports by default --- tests/bootstrap/conftest.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/bootstrap/conftest.py b/tests/bootstrap/conftest.py index ac91267a2cda4..20d54bd22a085 100644 --- a/tests/bootstrap/conftest.py +++ b/tests/bootstrap/conftest.py @@ -36,6 +36,9 @@ def __call__( ) -> LocalstackContainer: container = LocalstackContainer() + # override some default configuration + container.config.ports = None + # allow for randomised container names container.config.name = None @@ -50,8 +53,6 @@ def __call__( if publish: for port in publish: port_mappings.add(port) - else: - port_mappings.add(4566) container.config.ports = port_mappings self._containers.append(container) From 1b274373c599448c07c63ae803ac8f7cbb0da8a7 Mon Sep 17 00:00:00 2001 From: Simon Walker Date: Mon, 31 Jul 2023 17:00:01 +0100 Subject: [PATCH 05/39] Add test for GATEWAY_LISTEN --- localstack/testing/pytest/fixtures.py | 2 +- localstack/utils/bootstrap.py | 3 + tests/bootstrap/conftest.py | 6 +- .../test_container_listen_configuration.py | 79 ++++++++++++++++++- 4 files changed, 86 insertions(+), 4 deletions(-) diff --git a/localstack/testing/pytest/fixtures.py b/localstack/testing/pytest/fixtures.py index 82637d663d765..2cbe12818f11e 100644 --- a/localstack/testing/pytest/fixtures.py +++ b/localstack/testing/pytest/fixtures.py @@ -1715,7 +1715,7 @@ def factory(ports=None, **kwargs): @pytest.fixture -def cleanups(aws_client): +def cleanups(): cleanup_fns = [] yield cleanup_fns diff --git a/localstack/utils/bootstrap.py b/localstack/utils/bootstrap.py index ed26cd43b0702..2e37f6d690e2b 100644 --- a/localstack/utils/bootstrap.py +++ b/localstack/utils/bootstrap.py @@ -491,6 +491,9 @@ def is_container_running(self) -> bool: logs = DOCKER_CLIENT.get_container_logs(self.id) return constants.READY_MARKER_OUTPUT in logs.splitlines() + def exec_in_container(self, *args, **kwargs): + return DOCKER_CLIENT.exec_in_container(container_name_or_id=self.id, *args, **kwargs) + class LocalstackContainerServer(Server): container: LocalstackContainer diff --git a/tests/bootstrap/conftest.py b/tests/bootstrap/conftest.py index 20d54bd22a085..79512b1b3865b 100644 --- a/tests/bootstrap/conftest.py +++ b/tests/bootstrap/conftest.py @@ -37,7 +37,7 @@ def __call__( container = LocalstackContainer() # override some default configuration - container.config.ports = None + container.config.ports = PortMappings() # allow for randomised container names container.config.name = None @@ -45,6 +45,7 @@ def __call__( for key, value in kwargs.items(): setattr(container.config, key, value) + # handle the convenience options if pro: container.config.env_vars["GATEWAY_LISTEN"] = "0.0.0.0:4566,0.0.0.0:443" container.config.env_vars["LOCALSTACK_API_KEY"] = "test" @@ -53,8 +54,9 @@ def __call__( if publish: for port in publish: port_mappings.add(port) - container.config.ports = port_mappings + + # track the container so we can remove it later self._containers.append(container) return container diff --git a/tests/bootstrap/test_container_listen_configuration.py b/tests/bootstrap/test_container_listen_configuration.py index 0197d8a2ae3cc..a45019210b9c1 100644 --- a/tests/bootstrap/test_container_listen_configuration.py +++ b/tests/bootstrap/test_container_listen_configuration.py @@ -1,3 +1,11 @@ +import pytest +import requests +from requests.exceptions import ConnectionError + +from localstack.utils.container_utils.container_client import NoSuchNetwork +from localstack.utils.docker_utils import DOCKER_CLIENT +from localstack.utils.net import get_free_tcp_port +from localstack.utils.strings import short_uid from tests.bootstrap.conftest import ContainerFactory @@ -5,8 +13,77 @@ def test_defaults(container_factory: ContainerFactory): """ The default configuration is to listen on 0.0.0.0:4566 """ + port = get_free_tcp_port() container = container_factory() + container.config.ports.add(port, 4566) + container.run(attach=False) + container.wait_until_ready() + + r = requests.get(f"http://127.0.0.1:{port}/_localstack/health") + assert r.status_code == 200 + + +def test_gateway_listen_single_value(container_factory: ContainerFactory, cleanups): + """ + Test using GATEWAY_LISTEN to change the hypercorn port + """ + port1 = get_free_tcp_port() + + container = container_factory( + env_vars={ + "GATEWAY_LISTEN": "0.0.0.0:5000", + }, + ) + container.config.ports.add(port1, 5000) + container.run(attach=False) + container.wait_until_ready() + + # check the ports listening on 0.0.0.0 + r = requests.get(f"http://127.0.0.1:{port1}/_localstack/health") + assert r.status_code == 200 + + +@pytest.mark.skip(reason="TODO") +def test_gateway_listen_multiple_values(container_factory: ContainerFactory, cleanups): + """ + Test multiple container ports + """ + port1 = get_free_tcp_port() + port2 = get_free_tcp_port() + + network_name = f"net-{short_uid()}" + try: + DOCKER_CLIENT.inspect_network(network_name) + except NoSuchNetwork: + DOCKER_CLIENT.create_network(network_name) + cleanups.append(lambda: DOCKER_CLIENT.delete_network(network_name)) + + container = container_factory( + env_vars={ + "GATEWAY_LISTEN": ",".join( + [ + "0.0.0.0:5000", + "127.0.0.1:2000", + ] + ) + }, + network=network_name, + ) + container.config.ports.add(port1, 5000) + container.config.ports.add(port2, 2000) container.run(attach=False) container.wait_until_ready() - print(10) + # check the ports listening on 0.0.0.0 + r = requests.get(f"http://127.0.0.1:{port1}/_localstack/health") + assert r.status_code == 200 + + # port2 should not be accessible from the host + with pytest.raises(ConnectionError): + requests.get(f"http://127.0.0.1:{port2}/_localstack/health") + + # but should be available on localhost + stdout, stderr = container.exec_in_container( + command=["curl", "http://127.0.0.1:2000/_localstack/health"] + ) + assert stdout == b"Foobar" From 61de7771865cff6f3f138ce4e3d03ede3f6d0927 Mon Sep 17 00:00:00 2001 From: Simon Walker Date: Tue, 1 Aug 2023 09:30:09 +0100 Subject: [PATCH 06/39] WIP: add runningcontainer type --- localstack/utils/bootstrap.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/localstack/utils/bootstrap.py b/localstack/utils/bootstrap.py index 2e37f6d690e2b..c061614db46c2 100644 --- a/localstack/utils/bootstrap.py +++ b/localstack/utils/bootstrap.py @@ -495,6 +495,12 @@ def exec_in_container(self, *args, **kwargs): return DOCKER_CLIENT.exec_in_container(container_name_or_id=self.id, *args, **kwargs) +class RunningLocalstackContainer: + """ + Represents a LocalStack container that is running. + """ + + class LocalstackContainerServer(Server): container: LocalstackContainer From c723693130764877543ba683140ad5546cf2211f Mon Sep 17 00:00:00 2001 From: Simon Walker Date: Tue, 1 Aug 2023 17:07:20 +0100 Subject: [PATCH 07/39] Add running container abstraction --- localstack/utils/bootstrap.py | 118 +++++++++++++----- tests/bootstrap/conftest.py | 20 ++- .../test_container_listen_configuration.py | 12 +- 3 files changed, 99 insertions(+), 51 deletions(-) diff --git a/localstack/utils/bootstrap.py b/localstack/utils/bootstrap.py index c061614db46c2..6061c767c59ed 100644 --- a/localstack/utils/bootstrap.py +++ b/localstack/utils/bootstrap.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import copy import functools import logging @@ -370,14 +372,20 @@ def extract_port_flags(user_flags, port_mappings: PortMappings): class LocalstackContainer: config: ContainerConfiguration - id: str | None + running_container: RunningLocalstackContainer | None def __init__(self, name: str = None): self.config = self._get_default_configuration(name) self.logfile = os.path.join(config.dirs.tmp, f"{self.config.name}_container.log") self.additional_flags = [] # TODO: see comment in run() - self.id = None + + # marker to access the running container + self.running_container = None + + def truncate_log(self): + with open(self.logfile, "wb") as fd: + fd.write(b"") @staticmethod def _get_default_configuration(name: str = None) -> ContainerConfiguration: @@ -395,7 +403,7 @@ def _get_default_configuration(name: str = None) -> ContainerConfiguration: env_vars={}, ) - def run(self, attach: bool = True): + def run(self, attach: bool = True) -> RunningLocalstackContainer: if isinstance(DOCKER_CLIENT, CmdDockerClient): DOCKER_CLIENT.default_run_outfile = self.logfile @@ -416,7 +424,7 @@ def run(self, attach: bool = True): self._ensure_container_network(cfg.network) try: - self.id = DOCKER_CLIENT.create_container_from_config(cfg) + id = DOCKER_CLIENT.create_container_from_config(cfg) except ContainerException as e: if LOG.isEnabledFor(logging.DEBUG): LOG.exception("Error while creating LocalStack container") @@ -426,21 +434,14 @@ def run(self, attach: bool = True): ) raise - # populate the name so that we can check if the container is running. - self.config.name = DOCKER_CLIENT.get_container_name(self.id) - - try: - return DOCKER_CLIENT.start_container(self.id, attach=attach) - except ContainerException as e: - if LOG.isEnabledFor(logging.DEBUG): - LOG.exception("Error while starting LocalStack container") - else: - LOG.error( - "Error while starting LocalStack container: %s\n%s", e.message, to_str(e.stderr) - ) - raise + running_container = RunningLocalstackContainer( + id, container_config=self.config, logfile=self.logfile + ) + running_container.start(attach=attach) + return running_container - def _ensure_container_network(self, network: str): + @staticmethod + def _ensure_container_network(network: str): """Makes sure the configured container network exists""" if network: if network in ["host", "bridge"]: @@ -451,13 +452,6 @@ def _ensure_container_network(self, network: str): LOG.debug("Container network %s not found, creating it", network) DOCKER_CLIENT.create_network(network) - def truncate_log(self): - with open(self.logfile, "wb") as fd: - fd.write(b"") - - # these properties are there to not break code that configures the container by using these values - # that code should ideally be refactored soon-ish to use the config instead. - @property def env_vars(self) -> Dict[str, str]: return self.config.env_vars @@ -482,6 +476,68 @@ def volumes(self) -> VolumeMappings: def ports(self) -> PortMappings: return self.config.ports + +class RunningLocalstackContainer: + """ + Represents a LocalStack container that is running. + """ + + id: str + name: str + container_config: ContainerConfiguration + + def __init__(self, id: str, container_config: ContainerConfiguration, logfile: str): + self.id = id + self.container_config = container_config + self.logfile = logfile + + self.name = DOCKER_CLIENT.get_container_name(self.id) + + def start(self, attach: bool = False): + try: + return DOCKER_CLIENT.start_container(self.id, attach=attach) + except ContainerException as e: + LOG.error( + "Error while starting LocalStack container: %s\n%s", + e.message, + to_str(e.stderr), + exc_info=LOG.isEnabledFor(logging.DEBUG), + ) + raise + + def shutdown(self): + if not DOCKER_CLIENT.is_container_running(self.name): + return + + DOCKER_CLIENT.stop_container(container_name=self.id, timeout=30) + + def truncate_log(self): + with open(self.logfile, "wb") as fd: + fd.write(b"") + + # these properties are there to not break code that configures the container by using these values + # that code should ideally be refactored soon-ish to use the config instead. + + @property + def env_vars(self) -> Dict[str, str]: + return self.container_config.env_vars + + @property + def entrypoint(self) -> Optional[str]: + return self.container_config.entrypoint + + @entrypoint.setter + def entrypoint(self, value: str): + self.container_config.entrypoint = value + + @property + def volumes(self) -> VolumeMappings: + return self.container_config.volumes + + @property + def ports(self) -> PortMappings: + return self.container_config.ports + def wait_until_ready(self, timeout: float | None = None): if self.id is None: raise ValueError("no container id found, cannot wait until ready") @@ -495,14 +551,8 @@ def exec_in_container(self, *args, **kwargs): return DOCKER_CLIENT.exec_in_container(container_name_or_id=self.id, *args, **kwargs) -class RunningLocalstackContainer: - """ - Represents a LocalStack container that is running. - """ - - class LocalstackContainerServer(Server): - container: LocalstackContainer + container: LocalstackContainer | RunningLocalstackContainer def __init__(self, container=None) -> None: super().__init__(config.EDGE_PORT, config.EDGE_BIND_HOST) @@ -534,7 +584,9 @@ def do_run(self): 'LocalStack container named "%s" is already running' % self.container.name ) - return self.container.run() + config.dirs.mkdirs() + self.container = self.container.run() + return self.container def do_shutdown(self): try: diff --git a/tests/bootstrap/conftest.py b/tests/bootstrap/conftest.py index 79512b1b3865b..5132cf511f07f 100644 --- a/tests/bootstrap/conftest.py +++ b/tests/bootstrap/conftest.py @@ -10,7 +10,7 @@ PortMappings, ) from localstack.utils.bootstrap import LocalstackContainer -from localstack.utils.docker_utils import DOCKER_CLIENT +from localstack.utils.container_utils.container_client import PortMappings LOG = logging.getLogger(__name__) @@ -63,25 +63,21 @@ def __call__( def remove_all_containers(self): failures = [] for container in self._containers: - if not container.id: - LOG.error(f"Container {container} missing container_id") - continue - - # allow tests to stop the container manually - if not DOCKER_CLIENT.is_container_running(container.config.name): + if not container.running_container: + # container is not running continue try: - DOCKER_CLIENT.stop_container(container_name=container.id, timeout=30) + container.running_container.shutdown() except Exception as e: failures.append((container, e)) if failures: for container, ex in failures: - if LOG.isEnabledFor(logging.DEBUG): - LOG.error(f"Failed to remove container {container.id}", exc_info=ex) - else: - LOG.error(f"Failed to remove container {container.id}") + LOG.error( + f"Failed to remove container {container.running_container.id}", + exc_info=LOG.isEnabledFor(logging.DEBUG), + ) @pytest.fixture(scope="session") diff --git a/tests/bootstrap/test_container_listen_configuration.py b/tests/bootstrap/test_container_listen_configuration.py index a45019210b9c1..b54b5ffa3b168 100644 --- a/tests/bootstrap/test_container_listen_configuration.py +++ b/tests/bootstrap/test_container_listen_configuration.py @@ -16,8 +16,8 @@ def test_defaults(container_factory: ContainerFactory): port = get_free_tcp_port() container = container_factory() container.config.ports.add(port, 4566) - container.run(attach=False) - container.wait_until_ready() + running_container = container.run(attach=False) + running_container.wait_until_ready() r = requests.get(f"http://127.0.0.1:{port}/_localstack/health") assert r.status_code == 200 @@ -35,8 +35,8 @@ def test_gateway_listen_single_value(container_factory: ContainerFactory, cleanu }, ) container.config.ports.add(port1, 5000) - container.run(attach=False) - container.wait_until_ready() + running_container = container.run(attach=False) + running_container.wait_until_ready() # check the ports listening on 0.0.0.0 r = requests.get(f"http://127.0.0.1:{port1}/_localstack/health") @@ -71,8 +71,8 @@ def test_gateway_listen_multiple_values(container_factory: ContainerFactory, cle ) container.config.ports.add(port1, 5000) container.config.ports.add(port2, 2000) - container.run(attach=False) - container.wait_until_ready() + running_container = container.run(attach=False) + running_container.wait_until_ready() # check the ports listening on 0.0.0.0 r = requests.get(f"http://127.0.0.1:{port1}/_localstack/health") From 8577a420b7264d2a1857f87d0816044bd3e9d599 Mon Sep 17 00:00:00 2001 From: Simon Walker Date: Tue, 1 Aug 2023 19:24:26 +0100 Subject: [PATCH 08/39] Revert invalid automated refactor --- tests/integration/docker_utils/test_docker.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/integration/docker_utils/test_docker.py b/tests/integration/docker_utils/test_docker.py index 2f7c39362b4a8..392360a5f662b 100644 --- a/tests/integration/docker_utils/test_docker.py +++ b/tests/integration/docker_utils/test_docker.py @@ -626,9 +626,9 @@ def _test_copy_into_container( with file_path.open(mode="w") as fd: fd.write("foobared\n") - docker_client.copy_into_container(c.name, str(local_path), container_path) + docker_client.copy_into_container(c.container_name, str(local_path), container_path) - output, _ = docker_client.start_container(c.id, attach=True) + output, _ = docker_client.start_container(c.container_id, attach=True) output = output.decode(config.DEFAULT_ENCODING) assert "foobared" in output @@ -1318,11 +1318,11 @@ def _test_copy_from_container( dummy_container, ): docker_client.exec_in_container( - dummy_container.id, + dummy_container.container_id, command=["sh", "-c", f"echo TEST_CONTENT > {container_file_name}"], ) docker_client.copy_from_container( - dummy_container.id, + dummy_container.container_id, local_path=str(local_path), container_path=container_file_name, ) From 5ca588cd0fb3a1e3b41f944d690f219daeccbd94 Mon Sep 17 00:00:00 2001 From: Simon Walker Date: Wed, 2 Aug 2023 22:52:22 +0100 Subject: [PATCH 09/39] Do not pass detach - this is not part of the create API --- localstack/utils/container_utils/container_client.py | 1 - 1 file changed, 1 deletion(-) diff --git a/localstack/utils/container_utils/container_client.py b/localstack/utils/container_utils/container_client.py index febd5eb643474..35c8a3a19045f 100644 --- a/localstack/utils/container_utils/container_client.py +++ b/localstack/utils/container_utils/container_client.py @@ -772,7 +772,6 @@ def create_container_from_config(self, container_config: ContainerConfiguration) remove=container_config.remove, interactive=container_config.interactive, tty=container_config.tty, - detach=container_config.detach, command=container_config.command, mount_volumes=container_config.volumes, ports=container_config.ports, From c7a0496f281d67136f3a8046942876505b07706b Mon Sep 17 00:00:00 2001 From: Simon Walker Date: Thu, 10 Aug 2023 09:57:18 +0100 Subject: [PATCH 10/39] More robust container logs test Not completely sure why we need to increase the number of lines checked, since the behaviour is the same on master --- tests/bootstrap/test_cli.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tests/bootstrap/test_cli.py b/tests/bootstrap/test_cli.py index 485343b83a577..359f2b1b91eb0 100644 --- a/tests/bootstrap/test_cli.py +++ b/tests/bootstrap/test_cli.py @@ -91,9 +91,8 @@ def test_logs(self, runner, container_client): runner.invoke(cli, ["start", "-d"]) runner.invoke(cli, ["wait", "-t", "60"]) - result = runner.invoke(cli, ["logs", "--tail", "3"]) - assert result.output.count("\n") == 3 - assert constants.READY_MARKER_OUTPUT in result.output + result = runner.invoke(cli, ["logs", "--tail", "20"]) + assert constants.READY_MARKER_OUTPUT in result.output.splitlines() def test_status_services(self, runner): result = runner.invoke(cli, ["status", "services"]) From 413ac3b1c957e5b9cb7b6acb289ec41cec4108f9 Mon Sep 17 00:00:00 2001 From: Simon Walker Date: Fri, 18 Aug 2023 13:58:47 +0100 Subject: [PATCH 11/39] Add attach_to_container method in docker client --- localstack/utils/container_utils/container_client.py | 6 ++++++ localstack/utils/container_utils/docker_cmd_client.py | 5 +++++ localstack/utils/container_utils/docker_sdk_client.py | 7 ++++++- 3 files changed, 17 insertions(+), 1 deletion(-) diff --git a/localstack/utils/container_utils/container_client.py b/localstack/utils/container_utils/container_client.py index 35c8a3a19045f..4cd04d71e2b85 100644 --- a/localstack/utils/container_utils/container_client.py +++ b/localstack/utils/container_utils/container_client.py @@ -919,6 +919,12 @@ def start_container( :return: A tuple (stdout, stderr) if attach or interactive is set, otherwise a tuple (b"container_name_or_id", b"") """ + @abstractmethod + def attach_to_container(self, container_name_or_id: str): + """ + Attach local standard input, output, and error streams to a running container + """ + @abstractmethod def login(self, username: str, password: str, registry: Optional[str] = None) -> None: """ diff --git a/localstack/utils/container_utils/docker_cmd_client.py b/localstack/utils/container_utils/docker_cmd_client.py index 584a495c1c7f5..3415e49e366a1 100644 --- a/localstack/utils/container_utils/docker_cmd_client.py +++ b/localstack/utils/container_utils/docker_cmd_client.py @@ -660,6 +660,11 @@ def start_container( LOG.debug("Start container with cmd: %s", cmd) return self._run_async_cmd(cmd, stdin, container_name_or_id) + def attach_to_container(self, container_name_or_id: str): + cmd = self._docker_cmd() + ["attach", container_name_or_id] + LOG.debug(f"Attaching to container {container_name_or_id}") + run(cmd) + def _run_async_cmd( self, cmd: List[str], stdin: bytes, container_name: str, image_name=None ) -> Tuple[bytes, bytes]: diff --git a/localstack/utils/container_utils/docker_sdk_client.py b/localstack/utils/container_utils/docker_sdk_client.py index 8c950abf6c7a3..d8873a89a5037 100644 --- a/localstack/utils/container_utils/docker_sdk_client.py +++ b/localstack/utils/container_utils/docker_sdk_client.py @@ -7,7 +7,7 @@ import socket import threading from time import sleep -from typing import Dict, List, Optional, Tuple, Union +from typing import Dict, List, Optional, Tuple, Union, cast from urllib.parse import quote import docker @@ -574,6 +574,11 @@ def wait_for_result(*_): except APIError as e: raise ContainerException() from e + def attach_to_container(self, container_name_or_id: str): + client: DockerClient = self.client() + container = cast(Container, client.containers.get(container_name_or_id)) + container.attach() + def create_container( self, image_name: str, From 998cf8b3dbe84903e5d5d4d29a87a89e0d65c8f1 Mon Sep 17 00:00:00 2001 From: Simon Walker Date: Mon, 21 Aug 2023 13:38:35 +0100 Subject: [PATCH 12/39] Configure ContainerConfiguration rather than container --- localstack/plugins.py | 5 +- localstack/utils/analytics/metadata.py | 5 +- localstack/utils/bootstrap.py | 341 ++++++++---------- .../utils/container_utils/container_client.py | 1 - localstack/utils/tail.py | 4 + tests/bootstrap/conftest.py | 51 ++- .../test_container_listen_configuration.py | 164 +++++---- tests/bootstrap/test_localstack_container.py | 2 +- 8 files changed, 284 insertions(+), 289 deletions(-) diff --git a/localstack/plugins.py b/localstack/plugins.py index 2cd97e1ad6701..0a5804457f1fe 100644 --- a/localstack/plugins.py +++ b/localstack/plugins.py @@ -2,17 +2,18 @@ from localstack import config from localstack.runtime import hooks +from localstack.utils.container_utils.container_client import ContainerConfiguration LOG = logging.getLogger(__name__) @hooks.configure_localstack_container() -def configure_edge_port(container): +def configure_edge_port(container_config: ContainerConfiguration): ports = [config.EDGE_PORT, config.EDGE_PORT_HTTP] LOG.debug("configuring container with edge ports: %s", ports) for port in ports: if port: - container.ports.add(port) + container_config.ports.add(port) # Register the ArnPartitionRewriteListener only if the feature flag is enabled diff --git a/localstack/utils/analytics/metadata.py b/localstack/utils/analytics/metadata.py index d25fa5788128e..1f30c4025f442 100644 --- a/localstack/utils/analytics/metadata.py +++ b/localstack/utils/analytics/metadata.py @@ -6,6 +6,7 @@ from localstack import config, constants from localstack.runtime import hooks +from localstack.utils.container_utils.container_client import ContainerConfiguration from localstack.utils.functions import call_safe from localstack.utils.json import FileMappedDocument from localstack.utils.objects import singleton_factory @@ -177,7 +178,7 @@ def prepare_host_machine_id(): @hooks.configure_localstack_container() -def _mount_machine_file(container): +def _mount_machine_file(container_config: ContainerConfiguration): from localstack.utils.container_utils.container_client import VolumeBind # mount tha machine file from the host's CLI cache directory into the appropriate location in the @@ -185,4 +186,4 @@ def _mount_machine_file(container): machine_file = os.path.join(config.dirs.cache, "machine.json") if os.path.isfile(machine_file): target = os.path.join(config.dirs.for_container().cache, "machine.json") - container.volumes.add(VolumeBind(machine_file, target, read_only=True)) + container_config.volumes.add(VolumeBind(machine_file, target, read_only=True)) diff --git a/localstack/utils/bootstrap.py b/localstack/utils/bootstrap.py index 6061c767c59ed..d51aa29f6c954 100644 --- a/localstack/utils/bootstrap.py +++ b/localstack/utils/bootstrap.py @@ -370,78 +370,55 @@ def extract_port_flags(user_flags, port_mappings: PortMappings): return user_flags -class LocalstackContainer: - config: ContainerConfiguration - running_container: RunningLocalstackContainer | None - - def __init__(self, name: str = None): - self.config = self._get_default_configuration(name) - self.logfile = os.path.join(config.dirs.tmp, f"{self.config.name}_container.log") - - self.additional_flags = [] # TODO: see comment in run() - +class Container: + def __init__(self, container_config: ContainerConfiguration): + self.config = container_config # marker to access the running container - self.running_container = None - - def truncate_log(self): - with open(self.logfile, "wb") as fd: - fd.write(b"") + self.running_container: RunningContainer | None = None - @staticmethod - def _get_default_configuration(name: str = None) -> ContainerConfiguration: - """Returns a ContainerConfiguration populated with default values or values gathered from the - environment for starting the LocalStack container.""" - return ContainerConfiguration( - image_name=get_docker_image_to_start(), - name=name or config.MAIN_CONTAINER_NAME, - volumes=VolumeMappings(), - remove=True, - # FIXME: update with https://github.com/localstack/localstack/pull/7991 - ports=PortMappings(bind_host=config.EDGE_BIND_HOST), - entrypoint=os.environ.get("ENTRYPOINT"), - command=shlex.split(os.environ.get("CMD", "")) or None, - env_vars={}, - ) - - def run(self, attach: bool = True) -> RunningLocalstackContainer: - if isinstance(DOCKER_CLIENT, CmdDockerClient): - DOCKER_CLIENT.default_run_outfile = self.logfile + def start(self, attach: bool = False) -> RunningContainer: + cfg = copy.deepcopy(self.config) - # FIXME: this is pretty awkward, but additional_flags in the LocalstackContainer API was always a - # list of ["-e FOO=BAR", ...], whereas in the DockerClient it is expected to be a string. so we - # need to re-assemble it here. the better way would be to not use additional_flags here all - # together. it is still used in ext in `configure_pro_container` which could be refactored to use - # the additional port bindings. + # FIXME: this is pretty awkward, but additional_flags in the LocalstackContainer API was + # always a list of ["-e FOO=BAR", ...], whereas in the DockerClient it is expected to be + # a string. so we need to re-assemble it here. the better way would be to not use + # additional_flags here all together. it is still used in ext in + # `configure_pro_container` which could be refactored to use the additional port bindings. cfg = copy.deepcopy(self.config) if not cfg.additional_flags: cfg.additional_flags = "" - if self.additional_flags: - cfg.additional_flags += " " + " ".join(self.additional_flags) - # TODO: there could be a --network flag in `additional_flags`. we solve a similar problem for the - # ports using `extract_port_flags`. maybe it would be better to consolidate all this into the - # ContainerConfig object, like ContainerConfig.update_from_flags(str). + # TODO: there could be a --network flag in `additional_flags`. we solve a similar problem + # for the ports using `extract_port_flags`. maybe it would be better to consolidate all + # this into the ContainerConfig object, like ContainerConfig.update_from_flags(str). self._ensure_container_network(cfg.network) try: id = DOCKER_CLIENT.create_container_from_config(cfg) except ContainerException as e: if LOG.isEnabledFor(logging.DEBUG): - LOG.exception("Error while creating LocalStack container") + LOG.exception("Error while creating container") else: LOG.error( - "Error while creating LocalStack container: %s\n%s", e.message, to_str(e.stderr) + "Error while creating container: %s\n%s", e.message, to_str(e.stderr or "?") ) raise - running_container = RunningLocalstackContainer( - id, container_config=self.config, logfile=self.logfile - ) - running_container.start(attach=attach) - return running_container + try: + DOCKER_CLIENT.start_container(id, attach=attach) + except ContainerException as e: + LOG.error( + "Error while starting LocalStack container: %s\n%s", + e.message, + to_str(e.stderr), + exc_info=LOG.isEnabledFor(logging.DEBUG), + ) + raise + + return RunningContainer(id, container_config=self.config) @staticmethod - def _ensure_container_network(network: str): + def _ensure_container_network(network: str | None = None): """Makes sure the configured container network exists""" if network: if network in ["host", "bridge"]: @@ -452,149 +429,126 @@ def _ensure_container_network(network: str): LOG.debug("Container network %s not found, creating it", network) DOCKER_CLIENT.create_network(network) - @property - def env_vars(self) -> Dict[str, str]: - return self.config.env_vars - - @property - def entrypoint(self) -> Optional[str]: - return self.config.entrypoint - - @entrypoint.setter - def entrypoint(self, value: str): - self.config.entrypoint = value - - @property - def name(self) -> str: - return self.config.name - - @property - def volumes(self) -> VolumeMappings: - return self.config.volumes - - @property - def ports(self) -> PortMappings: - return self.config.ports - -class RunningLocalstackContainer: +class RunningContainer: """ Represents a LocalStack container that is running. """ - id: str - name: str - container_config: ContainerConfiguration - - def __init__(self, id: str, container_config: ContainerConfiguration, logfile: str): + def __init__(self, id: str, container_config: ContainerConfiguration): self.id = id - self.container_config = container_config - self.logfile = logfile - + self.config = container_config self.name = DOCKER_CLIENT.get_container_name(self.id) + self.running = True - def start(self, attach: bool = False): - try: - return DOCKER_CLIENT.start_container(self.id, attach=attach) - except ContainerException as e: - LOG.error( - "Error while starting LocalStack container: %s\n%s", - e.message, - to_str(e.stderr), - exc_info=LOG.isEnabledFor(logging.DEBUG), - ) - raise - - def shutdown(self): - if not DOCKER_CLIENT.is_container_running(self.name): - return - - DOCKER_CLIENT.stop_container(container_name=self.id, timeout=30) + def __enter__(self): + return self - def truncate_log(self): - with open(self.logfile, "wb") as fd: - fd.write(b"") + def __exit__(self, exc_type, exc_value, traceback): + self.shutdown() - # these properties are there to not break code that configures the container by using these values - # that code should ideally be refactored soon-ish to use the config instead. - - @property - def env_vars(self) -> Dict[str, str]: - return self.container_config.env_vars - - @property - def entrypoint(self) -> Optional[str]: - return self.container_config.entrypoint + def wait_until_ready(self, timeout: float = None): + def is_container_running() -> bool: + logs = DOCKER_CLIENT.get_container_logs(self.id) + if constants.READY_MARKER_OUTPUT in logs.splitlines(): + return True - @entrypoint.setter - def entrypoint(self, value: str): - self.container_config.entrypoint = value + return False - @property - def volumes(self) -> VolumeMappings: - return self.container_config.volumes + poll_condition(is_container_running, timeout) - @property - def ports(self) -> PortMappings: - return self.container_config.ports + def shutdown(self, timeout: int = 10): + if not DOCKER_CLIENT.is_container_running(self.name): + return - def wait_until_ready(self, timeout: float | None = None): - if self.id is None: - raise ValueError("no container id found, cannot wait until ready") - return poll_condition(self.is_container_running, timeout) + DOCKER_CLIENT.stop_container(container_name=self.id, timeout=timeout) + DOCKER_CLIENT.remove_container(container_name=self.id, force=True, check_existence=False) + self.running = False - def is_container_running(self) -> bool: - logs = DOCKER_CLIENT.get_container_logs(self.id) - return constants.READY_MARKER_OUTPUT in logs.splitlines() + def attach(self): + DOCKER_CLIENT.attach_to_container(container_name_or_id=self.id) def exec_in_container(self, *args, **kwargs): return DOCKER_CLIENT.exec_in_container(container_name_or_id=self.id, *args, **kwargs) class LocalstackContainerServer(Server): - container: LocalstackContainer | RunningLocalstackContainer - - def __init__(self, container=None) -> None: + def __init__(self, container_configuration: ContainerConfiguration | None = None) -> None: super().__init__(config.EDGE_PORT, config.EDGE_BIND_HOST) - self.container = container or LocalstackContainer() + + if container_configuration is None: + port_configuration = PortMappings(bind_host=config.GATEWAY_LISTEN[0].host) + for addr in config.GATEWAY_LISTEN: + port_configuration.add(addr.port) + + container_configuration = ContainerConfiguration( + image_name=get_docker_image_to_start(), + name=config.MAIN_CONTAINER_NAME, + volumes=VolumeMappings(), + remove=True, + ports=port_configuration, + entrypoint=os.environ.get("ENTRYPOINT"), + command=shlex.split(os.environ.get("CMD", "")) or None, + env_vars={}, + ) + + self.container: Container | RunningContainer = Container(container_configuration) def is_up(self) -> bool: """ Checks whether the container is running, and the Ready marker has been printed to the logs. """ - if not self.is_container_running(): return False + logs = DOCKER_CLIENT.get_container_logs(self.container.name) if constants.READY_MARKER_OUTPUT not in logs.splitlines(): return False + # also checks the edge port health status return super().is_up() def is_container_running(self) -> bool: + # if we have not started the container then we are not up + if not isinstance(self.container, RunningContainer): + return False + return DOCKER_CLIENT.is_container_running(self.container.name) def wait_is_container_running(self, timeout=None) -> bool: return poll_condition(self.is_container_running, timeout) def do_run(self): - if DOCKER_CLIENT.is_container_running(self.container.name): + if self.is_container_running(): raise ContainerExists( 'LocalStack container named "%s" is already running' % self.container.name ) config.dirs.mkdirs() - self.container = self.container.run() + match self.container: + case Container(): + LOG.debug("starting LocalStack container") + self.container = self.container.start(attach=False) + self.container.attach() + case _: + raise ValueError(f"Invalid container type: {type(self.container)}") + return self.container def do_shutdown(self): - try: - DOCKER_CLIENT.stop_container( - self.container.name, timeout=10 - ) # giving the container some time to stop - except Exception as e: - LOG.info("error cleaning up localstack container %s: %s", self.container.name, e) + match self.container: + case RunningContainer(): + try: + DOCKER_CLIENT.stop_container( + self.container.name, timeout=10 + ) # giving the container some time to stop + except Exception as e: + LOG.info( + "error cleaning up localstack container %s: %s", self.container.name, e + ) + case Container(): + raise ValueError(f"Container {self.container} not started") class ContainerExists(Exception): @@ -611,66 +565,82 @@ def prepare_docker_start(): config.dirs.mkdirs() -def configure_container(container: LocalstackContainer): +def configure_container(container_config: ContainerConfiguration): """ Configuration routine for the LocalstackContainer. """ + port_configuration = PortMappings(bind_host=config.GATEWAY_LISTEN[0].host) + for addr in config.GATEWAY_LISTEN: + port_configuration.add(addr.port) + + container_config.image_name = get_docker_image_to_start() + container_config.name = config.MAIN_CONTAINER_NAME + container_config.volumes = VolumeMappings() + container_config.remove = True + container_config.ports = port_configuration + container_config.entrypoint = os.environ.get("ENTRYPOINT") + container_config.command = shlex.split(os.environ.get("CMD", "")) or None + container_config.env_vars = {} + # get additional configured flags user_flags = config.DOCKER_FLAGS - user_flags = extract_port_flags(user_flags, container.ports) - container.additional_flags.extend(shlex.split(user_flags)) + user_flags = extract_port_flags(user_flags, container_config.ports) + # TODO: handle additional flags + # container_config.additional_flags.extend(shlex.split(user_flags)) # get additional parameters from plugins - hooks.configure_localstack_container.run(container) + hooks.configure_localstack_container.run(container_config) # construct default port mappings - container.ports.add(get_edge_port_http()) + container_config.ports.add(get_edge_port_http()) for port in range(config.EXTERNAL_SERVICE_PORTS_START, config.EXTERNAL_SERVICE_PORTS_END): - container.ports.add(port) + container_config.ports.add(port) if config.DEVELOP: - container.ports.add(config.DEVELOP_PORT) + container_config.ports.add(config.DEVELOP_PORT) # environment variables # pass through environment variables defined in config for env_var in config.CONFIG_ENV_VARS: value = os.environ.get(env_var, None) if value is not None: - container.env_vars[env_var] = value - container.env_vars["DOCKER_HOST"] = f"unix://{config.DOCKER_SOCK}" + container_config.env_vars[env_var] = value + container_config.env_vars["DOCKER_HOST"] = f"unix://{config.DOCKER_SOCK}" # TODO this is default now, remove once a considerate time is passed # to activate proper signal handling - container.env_vars["SET_TERM_HANDLER"] = "1" + container_config.env_vars["SET_TERM_HANDLER"] = "1" - configure_volume_mounts(container) + configure_volume_mounts(container_config) # mount docker socket - container.volumes.append((config.DOCKER_SOCK, config.DOCKER_SOCK)) + container_config.volumes.append((config.DOCKER_SOCK, config.DOCKER_SOCK)) - container.privileged = True + container_config.privileged = True -def configure_container_from_cli_params(container: LocalstackContainer, params: Dict[str, Any]): +def configure_container_from_cli_params( + container_config: ContainerConfiguration, params: Dict[str, Any] +): # TODO: consolidate with container_client.Util.parse_additional_flags # network flag if params.get("network"): - container.config.network = params.get("network") + container_config.network = params.get("network") # parse environment variable flags if params.get("env"): for e in params.get("env"): if "=" in e: k, v = e.split("=", maxsplit=1) - container.config.env_vars[k] = v + container_config.env_vars[k] = v else: # there's currently no way in our abstraction to only pass the variable name (as you can do # in docker) so we resolve the value here. - container.config.env_vars[e] = os.getenv(e) + container_config.env_vars[e] = os.getenv(e) -def configure_volume_mounts(container: LocalstackContainer): - container.volumes.add(VolumeBind(config.VOLUME_DIR, DEFAULT_VOLUME_DIR)) +def configure_volume_mounts(container_config: ContainerConfiguration): + container_config.volumes.add(VolumeBind(config.VOLUME_DIR, DEFAULT_VOLUME_DIR)) @log_duration() @@ -696,25 +666,31 @@ def start_infra_in_docker(console, cli_params: Dict[str, Any] = None): prepare_docker_start() # create and prepare container - container = LocalstackContainer() - configure_container(container) + container_config = ContainerConfiguration(get_docker_image_to_start()) + configure_container(container_config) if cli_params: - configure_container_from_cli_params(container, cli_params or {}) - ensure_container_image(console, container) + configure_container_from_cli_params(container_config, cli_params or {}) + ensure_container_image(console, container_config) status = console.status("Starting LocalStack container") status.start() # printing the container log is the current way we're occupying the terminal def _init_log_printer(line): - """Prints the console rule separator on the first line, then re-configures the callback to print.""" + """Prints the console rule separator on the first line, then re-configures the callback + to print.""" status.stop() console.rule("LocalStack Runtime Log (press [bold][yellow]CTRL-C[/yellow][/bold] to quit)") print(line) log_printer.callback = print - container.truncate_log() - log_printer = FileListener(container.logfile, callback=_init_log_printer) + logfile = os.path.join(config.dirs.tmp, f"{container_config.name}_container.log") + + if isinstance(DOCKER_CLIENT, CmdDockerClient): + DOCKER_CLIENT.default_run_outfile = logfile + + log_printer = FileListener(logfile, callback=_init_log_printer) + log_printer.truncate_log() log_printer.start() # Set up signal handler, to enable clean shutdown across different operating systems. @@ -730,14 +706,14 @@ def shutdown_handler(*args): shutdown_event.set() print("Shutting down...") server.shutdown() - log_printer.close() + # log_printer.close() shutdown_event = threading.Event() shutdown_event_lock = threading.RLock() signal.signal(signal.SIGINT, shutdown_handler) # start the Localstack container as a Server - server = LocalstackContainerServer(container) + server = LocalstackContainerServer(container_config) try: server.start() server.join() @@ -750,15 +726,15 @@ def shutdown_handler(*args): shutdown_handler() -def ensure_container_image(console, container: LocalstackContainer): +def ensure_container_image(console, container_config: ContainerConfiguration): try: - DOCKER_CLIENT.inspect_image(container.config.image_name, pull=False) + DOCKER_CLIENT.inspect_image(container_config.image_name, pull=False) return except NoSuchImage: console.log("container image not found on host") - with console.status(f"Pulling container image {container.config.image_name}"): - DOCKER_CLIENT.pull_image(container.config.image_name) + with console.status(f"Pulling container image {container_config.image_name}"): + DOCKER_CLIENT.pull_image(container_config.image_name) console.log("download complete") @@ -775,18 +751,17 @@ def start_infra_in_docker_detached(console, cli_params: Dict[str, Any] = None): # create and prepare container console.log("configuring container") - container = LocalstackContainer() - configure_container(container) + container_config = ContainerConfiguration(get_docker_image_to_start()) + configure_container(container_config) if cli_params: - configure_container_from_cli_params(container, cli_params) - ensure_container_image(console, container) + configure_container_from_cli_params(container_config, cli_params) + ensure_container_image(console, container_config) - container.config.detach = True - container.truncate_log() + container_config.detach = True # start the Localstack container as a Server console.log("starting container") - server = LocalstackContainerServer(container) + server = LocalstackContainerServer(container_config) server.start() server.wait_is_container_running() console.log("detaching") diff --git a/localstack/utils/container_utils/container_client.py b/localstack/utils/container_utils/container_client.py index 4cd04d71e2b85..5e544922beebf 100644 --- a/localstack/utils/container_utils/container_client.py +++ b/localstack/utils/container_utils/container_client.py @@ -783,7 +783,6 @@ def create_container_from_config(self, container_config: ContainerConfiguration) security_opt=container_config.security_opt, network=container_config.network, dns=container_config.dns, - additional_flags=container_config.additional_flags, workdir=container_config.workdir, privileged=container_config.privileged, platform=container_config.platform, diff --git a/localstack/utils/tail.py b/localstack/utils/tail.py index 3f2ea8bba74b4..81c5bcba44dad 100644 --- a/localstack/utils/tail.py +++ b/localstack/utils/tail.py @@ -43,6 +43,10 @@ def close(self): self.started.clear() self.thread = None + def truncate_log(self): + with open(self.file_path, "wb") as outfile: + outfile.write(b"") + def _do_start_thread(self) -> FuncThread: if self.use_tail_command: thread = self._create_tail_command_thread() diff --git a/tests/bootstrap/conftest.py b/tests/bootstrap/conftest.py index 5132cf511f07f..79931da3e8a14 100644 --- a/tests/bootstrap/conftest.py +++ b/tests/bootstrap/conftest.py @@ -1,16 +1,19 @@ from __future__ import annotations -import logging from typing import Generator +import logging +import os +import shlex import pytest from localstack import config +from localstack.utils.bootstrap import Container, get_docker_image_to_start from localstack.utils.container_utils.container_client import ( + ContainerConfiguration, PortMappings, + VolumeMappings, ) -from localstack.utils.bootstrap import LocalstackContainer -from localstack.utils.container_utils.container_client import PortMappings LOG = logging.getLogger(__name__) @@ -24,7 +27,7 @@ def _setup_cli_environment(monkeypatch): class ContainerFactory: def __init__(self): - self._containers: list[LocalstackContainer] = [] + self._containers: list[Container] = [] def __call__( self, @@ -33,28 +36,36 @@ def __call__( publish: list[int] | None = None, # ContainerConfig properties **kwargs, - ) -> LocalstackContainer: - container = LocalstackContainer() - - # override some default configuration - container.config.ports = PortMappings() + ) -> Container: + port_configuration = PortMappings() + if publish: + for port in publish: + port_configuration.add(port) + + container_configuration = ContainerConfiguration( + image_name=get_docker_image_to_start(), + name=config.MAIN_CONTAINER_NAME, + volumes=VolumeMappings(), + remove=True, + ports=port_configuration, + entrypoint=os.environ.get("ENTRYPOINT"), + command=shlex.split(os.environ.get("CMD", "")) or None, + env_vars={}, + ) # allow for randomised container names - container.config.name = None - - for key, value in kwargs.items(): - setattr(container.config, key, value) + container_configuration.name = None # handle the convenience options if pro: - container.config.env_vars["GATEWAY_LISTEN"] = "0.0.0.0:4566,0.0.0.0:443" - container.config.env_vars["LOCALSTACK_API_KEY"] = "test" + container_configuration.env_vars["GATEWAY_LISTEN"] = "0.0.0.0:4566,0.0.0.0:443" + container_configuration.env_vars["LOCALSTACK_API_KEY"] = "test" - port_mappings = PortMappings() - if publish: - for port in publish: - port_mappings.add(port) - container.config.ports = port_mappings + # override values from kwargs + for key, value in kwargs.items(): + setattr(container_configuration, key, value) + + container = Container(container_configuration) # track the container so we can remove it later self._containers.append(container) diff --git a/tests/bootstrap/test_container_listen_configuration.py b/tests/bootstrap/test_container_listen_configuration.py index b54b5ffa3b168..b5a263b96bfa6 100644 --- a/tests/bootstrap/test_container_listen_configuration.py +++ b/tests/bootstrap/test_container_listen_configuration.py @@ -1,89 +1,93 @@ import pytest import requests -from requests.exceptions import ConnectionError +from localstack.config import in_docker from localstack.utils.container_utils.container_client import NoSuchNetwork from localstack.utils.docker_utils import DOCKER_CLIENT from localstack.utils.net import get_free_tcp_port from localstack.utils.strings import short_uid -from tests.bootstrap.conftest import ContainerFactory - - -def test_defaults(container_factory: ContainerFactory): - """ - The default configuration is to listen on 0.0.0.0:4566 - """ - port = get_free_tcp_port() - container = container_factory() - container.config.ports.add(port, 4566) - running_container = container.run(attach=False) - running_container.wait_until_ready() - - r = requests.get(f"http://127.0.0.1:{port}/_localstack/health") - assert r.status_code == 200 - - -def test_gateway_listen_single_value(container_factory: ContainerFactory, cleanups): - """ - Test using GATEWAY_LISTEN to change the hypercorn port - """ - port1 = get_free_tcp_port() - - container = container_factory( - env_vars={ - "GATEWAY_LISTEN": "0.0.0.0:5000", - }, - ) - container.config.ports.add(port1, 5000) - running_container = container.run(attach=False) - running_container.wait_until_ready() - - # check the ports listening on 0.0.0.0 - r = requests.get(f"http://127.0.0.1:{port1}/_localstack/health") - assert r.status_code == 200 - - -@pytest.mark.skip(reason="TODO") -def test_gateway_listen_multiple_values(container_factory: ContainerFactory, cleanups): - """ - Test multiple container ports - """ - port1 = get_free_tcp_port() - port2 = get_free_tcp_port() + +@pytest.fixture +def ensure_network(cleanups): + def _ensure_network(name: str): + try: + DOCKER_CLIENT.inspect_network(name) + except NoSuchNetwork: + DOCKER_CLIENT.create_network(name) + cleanups.append(lambda: DOCKER_CLIENT.delete_network(name)) + + return _ensure_network + + +@pytest.fixture +def docker_network(ensure_network): network_name = f"net-{short_uid()}" - try: - DOCKER_CLIENT.inspect_network(network_name) - except NoSuchNetwork: - DOCKER_CLIENT.create_network(network_name) - cleanups.append(lambda: DOCKER_CLIENT.delete_network(network_name)) - - container = container_factory( - env_vars={ - "GATEWAY_LISTEN": ",".join( - [ - "0.0.0.0:5000", - "127.0.0.1:2000", - ] - ) - }, - network=network_name, - ) - container.config.ports.add(port1, 5000) - container.config.ports.add(port2, 2000) - running_container = container.run(attach=False) - running_container.wait_until_ready() - - # check the ports listening on 0.0.0.0 - r = requests.get(f"http://127.0.0.1:{port1}/_localstack/health") - assert r.status_code == 200 - - # port2 should not be accessible from the host - with pytest.raises(ConnectionError): - requests.get(f"http://127.0.0.1:{port2}/_localstack/health") - - # but should be available on localhost - stdout, stderr = container.exec_in_container( - command=["curl", "http://127.0.0.1:2000/_localstack/health"] - ) - assert stdout == b"Foobar" + ensure_network(network_name) + return network_name + + +@pytest.mark.skipif(condition=in_docker(), reason="cannot run bootstrap tests in docker") +class TestContainerConfiguration: + def test_defaults(self, container_factory): + """ + The default configuration is to listen on 0.0.0.0:4566 + """ + port = get_free_tcp_port() + container = container_factory() + container.config.ports.add(port, 4566) + with container.start(attach=False) as running_container: + running_container.wait_until_ready() + + r = requests.get(f"http://127.0.0.1:{port}/_localstack/health") + assert r.status_code == 200 + + def test_gateway_listen_single_value(self, container_factory): + """ + Test using GATEWAY_LISTEN to change the hypercorn port + """ + port1 = get_free_tcp_port() + + container = container_factory( + env_vars={ + "GATEWAY_LISTEN": "0.0.0.0:5000", + }, + ) + container.config.ports.add(port1, 5000) + with container.start(attach=False) as running_container: + running_container.wait_until_ready() + + # check the ports listening on 0.0.0.0 + r = requests.get(f"http://127.0.0.1:{port1}/_localstack/health") + assert r.status_code == 200 + + def test_gateway_listen_multiple_values(self, container_factory, docker_network): + """ + Test multiple container ports + """ + port1 = get_free_tcp_port() + port2 = get_free_tcp_port() + + container = container_factory( + env_vars={ + "GATEWAY_LISTEN": ",".join( + [ + "0.0.0.0:5000", + "0.0.0.0:2000", + ] + ) + }, + network=docker_network, + ) + container.config.ports.add(port1, 5000) + container.config.ports.add(port2, 2000) + with container.start(attach=False) as running_container: + running_container.wait_until_ready() + + # check the ports listening on 0.0.0.0 + r = requests.get(f"http://127.0.0.1:{port1}/_localstack/health") + assert r.ok + + # port2 should not be accessible from the host + r = requests.get(f"http://127.0.0.1:{port2}/_localstack/health") + assert r.ok diff --git a/tests/bootstrap/test_localstack_container.py b/tests/bootstrap/test_localstack_container.py index 729090b045521..19b47fa46aaf0 100644 --- a/tests/bootstrap/test_localstack_container.py +++ b/tests/bootstrap/test_localstack_container.py @@ -10,7 +10,7 @@ class TestLocalstackContainerServer: def test_lifecycle(self): server = LocalstackContainerServer() - server.container.ports.add(config.EDGE_PORT) + server.container.config.ports.add(config.EDGE_PORT) assert not server.is_up() try: From e02633c63ef392b2644046e631fc3cf1ec79382b Mon Sep 17 00:00:00 2001 From: Simon Walker Date: Mon, 21 Aug 2023 14:44:11 +0100 Subject: [PATCH 13/39] Handle logfile outside of LocalstackContainer --- localstack/cli/localstack.py | 5 +++-- localstack/utils/bootstrap.py | 10 +++++----- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/localstack/cli/localstack.py b/localstack/cli/localstack.py index 681dfd58ecb02..58866f7e1086f 100644 --- a/localstack/cli/localstack.py +++ b/localstack/cli/localstack.py @@ -9,6 +9,8 @@ from localstack.utils.analytics.cli import publish_invocation from localstack.utils.json import CustomEncoder +from ..utils.bootstrap import get_container_default_logfile_location + if sys.version_info >= (3, 8): from typing import TypedDict else: @@ -532,11 +534,10 @@ def cmd_logs(follow: bool, tail: int) -> None: If your LocalStack container has a different name, set the config variable `MAIN_CONTAINER_NAME`. """ - from localstack.utils.bootstrap import LocalstackContainer from localstack.utils.docker_utils import DOCKER_CLIENT container_name = config.MAIN_CONTAINER_NAME - logfile = LocalstackContainer(container_name).logfile + logfile = get_container_default_logfile_location(container_name) if not DOCKER_CLIENT.is_container_running(container_name): console.print("localstack container not running") diff --git a/localstack/utils/bootstrap.py b/localstack/utils/bootstrap.py index d51aa29f6c954..5616ae425971c 100644 --- a/localstack/utils/bootstrap.py +++ b/localstack/utils/bootstrap.py @@ -26,7 +26,6 @@ VolumeBind, VolumeMappings, ) -from localstack.utils.container_utils.docker_cmd_client import CmdDockerClient from localstack.utils.docker_utils import DOCKER_CLIENT from localstack.utils.files import cache_dir, mkdir from localstack.utils.functions import call_safe @@ -111,6 +110,10 @@ def get_image_environment_variable(env_name: str) -> Optional[str]: return found_env.split("=")[1] +def get_container_default_logfile_location(container_name: str) -> str: + return os.path.join(config.dirs.tmp, f"{container_name}_container.log") + + def get_server_version_from_running_container() -> str: try: # try to extract from existing running container @@ -684,10 +687,7 @@ def _init_log_printer(line): print(line) log_printer.callback = print - logfile = os.path.join(config.dirs.tmp, f"{container_config.name}_container.log") - - if isinstance(DOCKER_CLIENT, CmdDockerClient): - DOCKER_CLIENT.default_run_outfile = logfile + logfile = get_container_default_logfile_location(container_config.name) log_printer = FileListener(logfile, callback=_init_log_printer) log_printer.truncate_log() From c6cad7b958b27eadad8f7bb6f12a46ea01cb6f8b Mon Sep 17 00:00:00 2001 From: Simon Walker Date: Wed, 23 Aug 2023 12:19:38 +0100 Subject: [PATCH 14/39] Don't use relative imports --- localstack/cli/localstack.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/localstack/cli/localstack.py b/localstack/cli/localstack.py index 58866f7e1086f..61b5dfaa82af8 100644 --- a/localstack/cli/localstack.py +++ b/localstack/cli/localstack.py @@ -7,10 +7,9 @@ from localstack import config from localstack.utils.analytics.cli import publish_invocation +from localstack.utils.bootstrap import get_container_default_logfile_location from localstack.utils.json import CustomEncoder -from ..utils.bootstrap import get_container_default_logfile_location - if sys.version_info >= (3, 8): from typing import TypedDict else: From 7c2efe64fce00fa01471f23647b30e51e53d1abf Mon Sep 17 00:00:00 2001 From: Simon Walker Date: Wed, 23 Aug 2023 15:49:16 +0100 Subject: [PATCH 15/39] Fix not printing to the terminal correctly --- localstack/utils/bootstrap.py | 10 ++++++++-- localstack/utils/container_utils/docker_cmd_client.py | 2 +- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/localstack/utils/bootstrap.py b/localstack/utils/bootstrap.py index 5616ae425971c..e4d33fef264c0 100644 --- a/localstack/utils/bootstrap.py +++ b/localstack/utils/bootstrap.py @@ -26,6 +26,7 @@ VolumeBind, VolumeMappings, ) +from localstack.utils.container_utils.docker_cmd_client import CmdDockerClient from localstack.utils.docker_utils import DOCKER_CLIENT from localstack.utils.files import cache_dir, mkdir from localstack.utils.functions import call_safe @@ -533,6 +534,12 @@ def do_run(self): case Container(): LOG.debug("starting LocalStack container") self.container = self.container.start(attach=False) + if isinstance(DOCKER_CLIENT, CmdDockerClient): + DOCKER_CLIENT.default_run_outfile = get_container_default_logfile_location( + self.container.config.name + ) + + # block the current thread self.container.attach() case _: raise ValueError(f"Invalid container type: {type(self.container)}") @@ -688,7 +695,6 @@ def _init_log_printer(line): log_printer.callback = print logfile = get_container_default_logfile_location(container_config.name) - log_printer = FileListener(logfile, callback=_init_log_printer) log_printer.truncate_log() log_printer.start() @@ -706,7 +712,7 @@ def shutdown_handler(*args): shutdown_event.set() print("Shutting down...") server.shutdown() - # log_printer.close() + log_printer.close() shutdown_event = threading.Event() shutdown_event_lock = threading.RLock() diff --git a/localstack/utils/container_utils/docker_cmd_client.py b/localstack/utils/container_utils/docker_cmd_client.py index 3415e49e366a1..4ee5865c4302f 100644 --- a/localstack/utils/container_utils/docker_cmd_client.py +++ b/localstack/utils/container_utils/docker_cmd_client.py @@ -663,7 +663,7 @@ def start_container( def attach_to_container(self, container_name_or_id: str): cmd = self._docker_cmd() + ["attach", container_name_or_id] LOG.debug(f"Attaching to container {container_name_or_id}") - run(cmd) + return self._run_async_cmd(cmd, stdin=None, container_name=container_name_or_id) def _run_async_cmd( self, cmd: List[str], stdin: bytes, container_name: str, image_name=None From c9d5f6a1164515af8c3801b6ac7cbcc0457fcf17 Mon Sep 17 00:00:00 2001 From: Simon Walker Date: Wed, 23 Aug 2023 16:27:55 +0100 Subject: [PATCH 16/39] Remove outdated comment --- tests/bootstrap/test_container_listen_configuration.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/bootstrap/test_container_listen_configuration.py b/tests/bootstrap/test_container_listen_configuration.py index b5a263b96bfa6..7236646feb03f 100644 --- a/tests/bootstrap/test_container_listen_configuration.py +++ b/tests/bootstrap/test_container_listen_configuration.py @@ -88,6 +88,5 @@ def test_gateway_listen_multiple_values(self, container_factory, docker_network) r = requests.get(f"http://127.0.0.1:{port1}/_localstack/health") assert r.ok - # port2 should not be accessible from the host r = requests.get(f"http://127.0.0.1:{port2}/_localstack/health") assert r.ok From 0a41ffc015391d4d64afcd9ac1a90b49b65aa4eb Mon Sep 17 00:00:00 2001 From: Simon Walker Date: Wed, 23 Aug 2023 16:31:05 +0100 Subject: [PATCH 17/39] Put back wrongly removed additional_flags --- localstack/utils/container_utils/container_client.py | 1 + 1 file changed, 1 insertion(+) diff --git a/localstack/utils/container_utils/container_client.py b/localstack/utils/container_utils/container_client.py index 5e544922beebf..4cd04d71e2b85 100644 --- a/localstack/utils/container_utils/container_client.py +++ b/localstack/utils/container_utils/container_client.py @@ -783,6 +783,7 @@ def create_container_from_config(self, container_config: ContainerConfiguration) security_opt=container_config.security_opt, network=container_config.network, dns=container_config.dns, + additional_flags=container_config.additional_flags, workdir=container_config.workdir, privileged=container_config.privileged, platform=container_config.platform, From de2744af9aba47d3b1c11362d80333b75e8aef61 Mon Sep 17 00:00:00 2001 From: Simon Walker Date: Wed, 23 Aug 2023 22:38:36 +0100 Subject: [PATCH 18/39] Move cleanups fixture to common location The bootstrap tests don't use the old location --- localstack/testing/pytest/fixtures.py | 13 ------------- tests/conftest.py | 16 ++++++++++++++++ 2 files changed, 16 insertions(+), 13 deletions(-) diff --git a/localstack/testing/pytest/fixtures.py b/localstack/testing/pytest/fixtures.py index 2cbe12818f11e..7dd1b4ff7df04 100644 --- a/localstack/testing/pytest/fixtures.py +++ b/localstack/testing/pytest/fixtures.py @@ -1714,19 +1714,6 @@ def factory(ports=None, **kwargs): LOG.debug("Error cleaning up EC2 security group: %s, %s", sg_group_id, e) -@pytest.fixture -def cleanups(): - cleanup_fns = [] - - yield cleanup_fns - - for cleanup_callback in cleanup_fns[::-1]: - try: - cleanup_callback() - except Exception as e: - LOG.warning("Failed to execute cleanup", exc_info=e) - - @pytest.fixture(scope="session") def account_id(aws_client): return aws_client.sts.get_caller_identity()["Account"] diff --git a/tests/conftest.py b/tests/conftest.py index ccf47b8bc7bdd..2e1d61cbe6dbf 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,3 +1,4 @@ +import logging import os from typing import TYPE_CHECKING @@ -8,6 +9,8 @@ if TYPE_CHECKING: from localstack.testing.snapshots import SnapshotSession +LOG = logging.getLogger(__name__) + os.environ["LOCALSTACK_INTERNAL_TEST_RUN"] = "1" pytest_plugins = [ @@ -136,3 +139,16 @@ def secondary_aws_client(aws_client_factory): from localstack.testing.aws.util import secondary_testing_aws_client return secondary_testing_aws_client(aws_client_factory) + + +@pytest.fixture +def cleanups(): + cleanup_fns = [] + + yield cleanup_fns + + for cleanup_callback in cleanup_fns[::-1]: + try: + cleanup_callback() + except Exception as e: + LOG.warning("Failed to execute cleanup", exc_info=e) From db3da41ab798314e96a4fe76c2dec0335fa8d56a Mon Sep 17 00:00:00 2001 From: Simon Walker Date: Wed, 23 Aug 2023 22:39:00 +0100 Subject: [PATCH 19/39] Fix additional flag usage --- localstack/utils/bootstrap.py | 6 ++++-- tests/bootstrap/conftest.py | 5 +++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/localstack/utils/bootstrap.py b/localstack/utils/bootstrap.py index e4d33fef264c0..eb0b93a0c6a85 100644 --- a/localstack/utils/bootstrap.py +++ b/localstack/utils/bootstrap.py @@ -595,8 +595,10 @@ def configure_container(container_config: ContainerConfiguration): # get additional configured flags user_flags = config.DOCKER_FLAGS user_flags = extract_port_flags(user_flags, container_config.ports) - # TODO: handle additional flags - # container_config.additional_flags.extend(shlex.split(user_flags)) + if container_config.additional_flags is None: + container_config.additional_flags = user_flags + else: + container_config.additional_flags = f"{container_config.additional_flags} {user_flags}" # get additional parameters from plugins hooks.configure_localstack_container.run(container_config) diff --git a/tests/bootstrap/conftest.py b/tests/bootstrap/conftest.py index 79931da3e8a14..f4d562a0c3c52 100644 --- a/tests/bootstrap/conftest.py +++ b/tests/bootstrap/conftest.py @@ -1,9 +1,9 @@ from __future__ import annotations -from typing import Generator import logging import os import shlex +from typing import Generator import pytest @@ -20,7 +20,8 @@ @pytest.fixture(autouse=True) def _setup_cli_environment(monkeypatch): - # normally we are setting LOCALSTACK_CLI in localstack/cli/main.py, which is not actually run in the tests + # normally we are setting LOCALSTACK_CLI in localstack/cli/main.py, which is not actually run + # in the tests monkeypatch.setenv("LOCALSTACK_CLI", "1") monkeypatch.setattr(config, "dirs", config.Directories.for_cli()) From f05af93a2645cb14ce25d75a779381f654d342a0 Mon Sep 17 00:00:00 2001 From: Simon Walker Date: Thu, 24 Aug 2023 09:40:55 +0100 Subject: [PATCH 20/39] Swap match blocks for isinstance checks We need this to run on older versions of python since this is run from within the CLI --- localstack/utils/bootstrap.py | 44 +++++++++++++++-------------------- 1 file changed, 19 insertions(+), 25 deletions(-) diff --git a/localstack/utils/bootstrap.py b/localstack/utils/bootstrap.py index eb0b93a0c6a85..96db94cfdfe9d 100644 --- a/localstack/utils/bootstrap.py +++ b/localstack/utils/bootstrap.py @@ -530,35 +530,29 @@ def do_run(self): ) config.dirs.mkdirs() - match self.container: - case Container(): - LOG.debug("starting LocalStack container") - self.container = self.container.start(attach=False) - if isinstance(DOCKER_CLIENT, CmdDockerClient): - DOCKER_CLIENT.default_run_outfile = get_container_default_logfile_location( - self.container.config.name - ) - - # block the current thread - self.container.attach() - case _: - raise ValueError(f"Invalid container type: {type(self.container)}") + if not isinstance(self.container, Container): + raise ValueError(f"Invalid container type: {type(self.container)}") + + LOG.debug("starting LocalStack container") + self.container = self.container.start(attach=False) + if isinstance(DOCKER_CLIENT, CmdDockerClient): + DOCKER_CLIENT.default_run_outfile = get_container_default_logfile_location( + self.container.config.name + ) + # block the current thread + self.container.attach() return self.container def do_shutdown(self): - match self.container: - case RunningContainer(): - try: - DOCKER_CLIENT.stop_container( - self.container.name, timeout=10 - ) # giving the container some time to stop - except Exception as e: - LOG.info( - "error cleaning up localstack container %s: %s", self.container.name, e - ) - case Container(): - raise ValueError(f"Container {self.container} not started") + if not isinstance(self.container, RunningContainer): + raise ValueError(f"Container {self.container} not started") + try: + DOCKER_CLIENT.stop_container( + self.container.name, timeout=10 + ) # giving the container some time to stop + except Exception as e: + LOG.info("error cleaning up localstack container %s: %s", self.container.name, e) class ContainerExists(Exception): From 763e98781100c296ee88f452340ed5a7345b92a7 Mon Sep 17 00:00:00 2001 From: Simon Walker Date: Thu, 24 Aug 2023 17:14:33 +0100 Subject: [PATCH 21/39] Revert "Move cleanups fixture to common location" This reverts commit 41bf501b354dab778f077a08c75894e685dfae68. --- localstack/testing/pytest/fixtures.py | 13 +++++++++++++ tests/conftest.py | 16 ---------------- 2 files changed, 13 insertions(+), 16 deletions(-) diff --git a/localstack/testing/pytest/fixtures.py b/localstack/testing/pytest/fixtures.py index 7dd1b4ff7df04..2cbe12818f11e 100644 --- a/localstack/testing/pytest/fixtures.py +++ b/localstack/testing/pytest/fixtures.py @@ -1714,6 +1714,19 @@ def factory(ports=None, **kwargs): LOG.debug("Error cleaning up EC2 security group: %s, %s", sg_group_id, e) +@pytest.fixture +def cleanups(): + cleanup_fns = [] + + yield cleanup_fns + + for cleanup_callback in cleanup_fns[::-1]: + try: + cleanup_callback() + except Exception as e: + LOG.warning("Failed to execute cleanup", exc_info=e) + + @pytest.fixture(scope="session") def account_id(aws_client): return aws_client.sts.get_caller_identity()["Account"] diff --git a/tests/conftest.py b/tests/conftest.py index 2e1d61cbe6dbf..ccf47b8bc7bdd 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,4 +1,3 @@ -import logging import os from typing import TYPE_CHECKING @@ -9,8 +8,6 @@ if TYPE_CHECKING: from localstack.testing.snapshots import SnapshotSession -LOG = logging.getLogger(__name__) - os.environ["LOCALSTACK_INTERNAL_TEST_RUN"] = "1" pytest_plugins = [ @@ -139,16 +136,3 @@ def secondary_aws_client(aws_client_factory): from localstack.testing.aws.util import secondary_testing_aws_client return secondary_testing_aws_client(aws_client_factory) - - -@pytest.fixture -def cleanups(): - cleanup_fns = [] - - yield cleanup_fns - - for cleanup_callback in cleanup_fns[::-1]: - try: - cleanup_callback() - except Exception as e: - LOG.warning("Failed to execute cleanup", exc_info=e) From 39bf30ddaf8bd39dd73cd3392ae5f58c450d998f Mon Sep 17 00:00:00 2001 From: Simon Walker Date: Thu, 24 Aug 2023 17:22:13 +0100 Subject: [PATCH 22/39] Allow localstack.testing.pytest.fixtures in bootstrap tests we need this for the cleanups fixture --- .github/workflows/tests-cli.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/tests-cli.yml b/.github/workflows/tests-cli.yml index e021a897cf264..9ee6f03785005 100644 --- a/.github/workflows/tests-cli.yml +++ b/.github/workflows/tests-cli.yml @@ -74,6 +74,6 @@ jobs: pip install pytest pytest-tinybird - name: Run CLI tests env: - PYTEST_ADDOPTS: "${{ env.TINYBIRD_PYTEST_ARGS }}-p no:localstack.testing.pytest.fixtures -p no:localstack.testing.pytest.snapshot -p no:localstack.testing.pytest.filters -p no:localstack.testing.pytest.fixture_conflicts -p no:tests.fixtures -s" + PYTEST_ADDOPTS: "${{ env.TINYBIRD_PYTEST_ARGS }}-p no:localstack.testing.pytest.snapshot -p no:localstack.testing.pytest.filters -p no:localstack.testing.pytest.fixture_conflicts -p no:tests.fixtures -s" run: | python -m pytest tests/bootstrap/ From 64b8319f5f3e498ad456c0c52d3c46fe0b19bbec Mon Sep 17 00:00:00 2001 From: Simon Walker Date: Thu, 24 Aug 2023 17:31:33 +0100 Subject: [PATCH 23/39] Revert "Allow localstack.testing.pytest.fixtures in bootstrap tests" This reverts commit 79bf1df7b2b69eaa1101ac2fe18e96378bae6049. --- .github/workflows/tests-cli.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/tests-cli.yml b/.github/workflows/tests-cli.yml index 9ee6f03785005..e021a897cf264 100644 --- a/.github/workflows/tests-cli.yml +++ b/.github/workflows/tests-cli.yml @@ -74,6 +74,6 @@ jobs: pip install pytest pytest-tinybird - name: Run CLI tests env: - PYTEST_ADDOPTS: "${{ env.TINYBIRD_PYTEST_ARGS }}-p no:localstack.testing.pytest.snapshot -p no:localstack.testing.pytest.filters -p no:localstack.testing.pytest.fixture_conflicts -p no:tests.fixtures -s" + PYTEST_ADDOPTS: "${{ env.TINYBIRD_PYTEST_ARGS }}-p no:localstack.testing.pytest.fixtures -p no:localstack.testing.pytest.snapshot -p no:localstack.testing.pytest.filters -p no:localstack.testing.pytest.fixture_conflicts -p no:tests.fixtures -s" run: | python -m pytest tests/bootstrap/ From c9617c995d34bdc10578e257b0d4169386bb7cc0 Mon Sep 17 00:00:00 2001 From: Simon Walker Date: Thu, 24 Aug 2023 17:33:21 +0100 Subject: [PATCH 24/39] Duplicate cleanups fixture for now --- tests/bootstrap/conftest.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/bootstrap/conftest.py b/tests/bootstrap/conftest.py index f4d562a0c3c52..bb2c7f5914b1d 100644 --- a/tests/bootstrap/conftest.py +++ b/tests/bootstrap/conftest.py @@ -26,6 +26,21 @@ def _setup_cli_environment(monkeypatch): monkeypatch.setattr(config, "dirs", config.Directories.for_cli()) +# TODO: for now we duplicate this fixture since we can't enable the fixture plugin, and can't +# move the fixture to tests/conftest.py because some unit tests are dependent on its current path +@pytest.fixture +def cleanups(): + cleanup_fns = [] + + yield cleanup_fns + + for cleanup_callback in cleanup_fns[::-1]: + try: + cleanup_callback() + except Exception as e: + LOG.warning("Failed to execute cleanup", exc_info=e) + + class ContainerFactory: def __init__(self): self._containers: list[Container] = [] From cd70b64e6fc98a7a576194a209d0e77176192935 Mon Sep 17 00:00:00 2001 From: Simon Walker Date: Tue, 29 Aug 2023 15:46:37 +0100 Subject: [PATCH 25/39] Disable most test fixtures for bootstrap tests --- .circleci/config.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index e2b15555e2ec6..def0d5117f845 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -290,8 +290,9 @@ jobs: environment: TEST_PATH: "tests/bootstrap" COVERAGE_ARGS: "-p" + PYTEST_ADDOPTS: "${{ env.TINYBIRD_PYTEST_ARGS }}-p no:localstack.testing.pytest.fixtures -p no:localstack.testing.pytest.snapshot -p no:localstack.testing.pytest.filters -p no:localstack.testing.pytest.fixture_conflicts -p no:tests.fixtures -s" command: | - PYTEST_ARGS="-s ${TINYBIRD_PYTEST_ARGS}--junitxml=target/reports/bootstrap-tests.xml -o junit_suite_name=bootstrap-tests" make test-coverage + make test-coverage - store_test_results: path: target/reports/ - run: From 64f0efa748b7cbb75ed503c5da9fb2bee21546c7 Mon Sep 17 00:00:00 2001 From: Simon Walker Date: Tue, 29 Aug 2023 15:53:10 +0100 Subject: [PATCH 26/39] Configuration utilities take container --- localstack/plugins.py | 6 +-- localstack/utils/analytics/metadata.py | 6 +-- localstack/utils/bootstrap.py | 69 +++++++++++++------------- 3 files changed, 40 insertions(+), 41 deletions(-) diff --git a/localstack/plugins.py b/localstack/plugins.py index 0a5804457f1fe..5a1133d049887 100644 --- a/localstack/plugins.py +++ b/localstack/plugins.py @@ -2,18 +2,18 @@ from localstack import config from localstack.runtime import hooks -from localstack.utils.container_utils.container_client import ContainerConfiguration +from localstack.utils.bootstrap import Container LOG = logging.getLogger(__name__) @hooks.configure_localstack_container() -def configure_edge_port(container_config: ContainerConfiguration): +def configure_edge_port(container: Container): ports = [config.EDGE_PORT, config.EDGE_PORT_HTTP] LOG.debug("configuring container with edge ports: %s", ports) for port in ports: if port: - container_config.ports.add(port) + container.config.ports.add(port) # Register the ArnPartitionRewriteListener only if the feature flag is enabled diff --git a/localstack/utils/analytics/metadata.py b/localstack/utils/analytics/metadata.py index 1f30c4025f442..e93d46642da45 100644 --- a/localstack/utils/analytics/metadata.py +++ b/localstack/utils/analytics/metadata.py @@ -6,7 +6,7 @@ from localstack import config, constants from localstack.runtime import hooks -from localstack.utils.container_utils.container_client import ContainerConfiguration +from localstack.utils.bootstrap import Container from localstack.utils.functions import call_safe from localstack.utils.json import FileMappedDocument from localstack.utils.objects import singleton_factory @@ -178,7 +178,7 @@ def prepare_host_machine_id(): @hooks.configure_localstack_container() -def _mount_machine_file(container_config: ContainerConfiguration): +def _mount_machine_file(container: Container): from localstack.utils.container_utils.container_client import VolumeBind # mount tha machine file from the host's CLI cache directory into the appropriate location in the @@ -186,4 +186,4 @@ def _mount_machine_file(container_config: ContainerConfiguration): machine_file = os.path.join(config.dirs.cache, "machine.json") if os.path.isfile(machine_file): target = os.path.join(config.dirs.for_container().cache, "machine.json") - container_config.volumes.add(VolumeBind(machine_file, target, read_only=True)) + container.config.volumes.add(VolumeBind(machine_file, target, read_only=True)) diff --git a/localstack/utils/bootstrap.py b/localstack/utils/bootstrap.py index 96db94cfdfe9d..edcc40ec69a89 100644 --- a/localstack/utils/bootstrap.py +++ b/localstack/utils/bootstrap.py @@ -569,7 +569,7 @@ def prepare_docker_start(): config.dirs.mkdirs() -def configure_container(container_config: ContainerConfiguration): +def configure_container(container: Container): """ Configuration routine for the LocalstackContainer. """ @@ -577,76 +577,74 @@ def configure_container(container_config: ContainerConfiguration): for addr in config.GATEWAY_LISTEN: port_configuration.add(addr.port) - container_config.image_name = get_docker_image_to_start() - container_config.name = config.MAIN_CONTAINER_NAME - container_config.volumes = VolumeMappings() - container_config.remove = True - container_config.ports = port_configuration - container_config.entrypoint = os.environ.get("ENTRYPOINT") - container_config.command = shlex.split(os.environ.get("CMD", "")) or None - container_config.env_vars = {} + container.config.image_name = get_docker_image_to_start() + container.config.name = config.MAIN_CONTAINER_NAME + container.config.volumes = VolumeMappings() + container.config.remove = True + container.config.ports = port_configuration + container.config.entrypoint = os.environ.get("ENTRYPOINT") + container.config.command = shlex.split(os.environ.get("CMD", "")) or None + container.config.env_vars = {} # get additional configured flags user_flags = config.DOCKER_FLAGS - user_flags = extract_port_flags(user_flags, container_config.ports) - if container_config.additional_flags is None: - container_config.additional_flags = user_flags + user_flags = extract_port_flags(user_flags, container.config.ports) + if container.config.additional_flags is None: + container.config.additional_flags = user_flags else: - container_config.additional_flags = f"{container_config.additional_flags} {user_flags}" + container.config.additional_flags = f"{container.config.additional_flags} {user_flags}" # get additional parameters from plugins - hooks.configure_localstack_container.run(container_config) + hooks.configure_localstack_container.run(container.config) # construct default port mappings - container_config.ports.add(get_edge_port_http()) + container.config.ports.add(get_edge_port_http()) for port in range(config.EXTERNAL_SERVICE_PORTS_START, config.EXTERNAL_SERVICE_PORTS_END): - container_config.ports.add(port) + container.config.ports.add(port) if config.DEVELOP: - container_config.ports.add(config.DEVELOP_PORT) + container.config.ports.add(config.DEVELOP_PORT) # environment variables # pass through environment variables defined in config for env_var in config.CONFIG_ENV_VARS: value = os.environ.get(env_var, None) if value is not None: - container_config.env_vars[env_var] = value - container_config.env_vars["DOCKER_HOST"] = f"unix://{config.DOCKER_SOCK}" + container.config.env_vars[env_var] = value + container.config.env_vars["DOCKER_HOST"] = f"unix://{config.DOCKER_SOCK}" # TODO this is default now, remove once a considerate time is passed # to activate proper signal handling - container_config.env_vars["SET_TERM_HANDLER"] = "1" + container.config.env_vars["SET_TERM_HANDLER"] = "1" - configure_volume_mounts(container_config) + configure_volume_mounts(container) # mount docker socket - container_config.volumes.append((config.DOCKER_SOCK, config.DOCKER_SOCK)) + container.config.volumes.append((config.DOCKER_SOCK, config.DOCKER_SOCK)) - container_config.privileged = True + container.config.privileged = True -def configure_container_from_cli_params( - container_config: ContainerConfiguration, params: Dict[str, Any] -): +def configure_container_from_cli_params(container: Container, params: Dict[str, Any]): # TODO: consolidate with container_client.Util.parse_additional_flags # network flag if params.get("network"): - container_config.network = params.get("network") + container.config.network = params.get("network") # parse environment variable flags if params.get("env"): for e in params.get("env"): if "=" in e: k, v = e.split("=", maxsplit=1) - container_config.env_vars[k] = v + container.config.env_vars[k] = v else: # there's currently no way in our abstraction to only pass the variable name (as you can do # in docker) so we resolve the value here. - container_config.env_vars[e] = os.getenv(e) + container.config.env_vars[e] = os.getenv(e) -def configure_volume_mounts(container_config: ContainerConfiguration): - container_config.volumes.add(VolumeBind(config.VOLUME_DIR, DEFAULT_VOLUME_DIR)) +def configure_volume_mounts(container: Container): + container.config.volumes.add(VolumeBind(config.VOLUME_DIR, DEFAULT_VOLUME_DIR)) @log_duration() @@ -673,6 +671,7 @@ def start_infra_in_docker(console, cli_params: Dict[str, Any] = None): # create and prepare container container_config = ContainerConfiguration(get_docker_image_to_start()) + configure_container(container_config) if cli_params: configure_container_from_cli_params(container_config, cli_params or {}) @@ -728,15 +727,15 @@ def shutdown_handler(*args): shutdown_handler() -def ensure_container_image(console, container_config: ContainerConfiguration): +def ensure_container_image(console, container: Container): try: - DOCKER_CLIENT.inspect_image(container_config.image_name, pull=False) + DOCKER_CLIENT.inspect_image(container.config.image_name, pull=False) return except NoSuchImage: console.log("container image not found on host") - with console.status(f"Pulling container image {container_config.image_name}"): - DOCKER_CLIENT.pull_image(container_config.image_name) + with console.status(f"Pulling container image {container.config.image_name}"): + DOCKER_CLIENT.pull_image(container.config.image_name) console.log("download complete") From 7042c49ea70c653b87fa39658e7481499b434485 Mon Sep 17 00:00:00 2001 From: Simon Walker Date: Thu, 31 Aug 2023 09:41:35 +0100 Subject: [PATCH 27/39] Rename truncate_log -> truncate_file --- localstack/utils/bootstrap.py | 2 +- localstack/utils/tail.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/localstack/utils/bootstrap.py b/localstack/utils/bootstrap.py index edcc40ec69a89..9c54efefe920e 100644 --- a/localstack/utils/bootstrap.py +++ b/localstack/utils/bootstrap.py @@ -691,7 +691,7 @@ def _init_log_printer(line): logfile = get_container_default_logfile_location(container_config.name) log_printer = FileListener(logfile, callback=_init_log_printer) - log_printer.truncate_log() + log_printer.truncate_file() log_printer.start() # Set up signal handler, to enable clean shutdown across different operating systems. diff --git a/localstack/utils/tail.py b/localstack/utils/tail.py index 81c5bcba44dad..1e8cd4d37e7b8 100644 --- a/localstack/utils/tail.py +++ b/localstack/utils/tail.py @@ -43,7 +43,7 @@ def close(self): self.started.clear() self.thread = None - def truncate_log(self): + def truncate_file(self): with open(self.file_path, "wb") as outfile: outfile.write(b"") From 99b567c061ee963dd0f856f71376fc8ac6f8e92b Mon Sep 17 00:00:00 2001 From: Simon Walker Date: Thu, 31 Aug 2023 12:43:32 +0100 Subject: [PATCH 28/39] Allow for custom docker client --- localstack/utils/bootstrap.py | 72 ++++++++++++++++++++--------------- 1 file changed, 42 insertions(+), 30 deletions(-) diff --git a/localstack/utils/bootstrap.py b/localstack/utils/bootstrap.py index 9c54efefe920e..fb0b2e2c6e0bc 100644 --- a/localstack/utils/bootstrap.py +++ b/localstack/utils/bootstrap.py @@ -18,6 +18,7 @@ from localstack.runtime import hooks from localstack.utils.container_networking import get_main_container_name from localstack.utils.container_utils.container_client import ( + ContainerClient, ContainerConfiguration, ContainerException, NoSuchImage, @@ -375,14 +376,15 @@ def extract_port_flags(user_flags, port_mappings: PortMappings): class Container: - def __init__(self, container_config: ContainerConfiguration): + def __init__( + self, container_config: ContainerConfiguration, docker_client: ContainerClient | None = None + ): self.config = container_config # marker to access the running container self.running_container: RunningContainer | None = None + self.container_client = docker_client or DOCKER_CLIENT def start(self, attach: bool = False) -> RunningContainer: - cfg = copy.deepcopy(self.config) - # FIXME: this is pretty awkward, but additional_flags in the LocalstackContainer API was # always a list of ["-e FOO=BAR", ...], whereas in the DockerClient it is expected to be # a string. so we need to re-assemble it here. the better way would be to not use @@ -398,7 +400,7 @@ def start(self, attach: bool = False) -> RunningContainer: self._ensure_container_network(cfg.network) try: - id = DOCKER_CLIENT.create_container_from_config(cfg) + id = self.container_client.create_container_from_config(cfg) except ContainerException as e: if LOG.isEnabledFor(logging.DEBUG): LOG.exception("Error while creating container") @@ -409,7 +411,7 @@ def start(self, attach: bool = False) -> RunningContainer: raise try: - DOCKER_CLIENT.start_container(id, attach=attach) + self.container_client.start_container(id, attach=attach) except ContainerException as e: LOG.error( "Error while starting LocalStack container: %s\n%s", @@ -421,17 +423,16 @@ def start(self, attach: bool = False) -> RunningContainer: return RunningContainer(id, container_config=self.config) - @staticmethod - def _ensure_container_network(network: str | None = None): + def _ensure_container_network(self, network: str | None = None): """Makes sure the configured container network exists""" if network: if network in ["host", "bridge"]: return try: - DOCKER_CLIENT.inspect_network(network) + self.container_client.inspect_network(network) except NoSuchNetwork: LOG.debug("Container network %s not found, creating it", network) - DOCKER_CLIENT.create_network(network) + self.container_client.create_network(network) class RunningContainer: @@ -439,11 +440,16 @@ class RunningContainer: Represents a LocalStack container that is running. """ - def __init__(self, id: str, container_config: ContainerConfiguration): + def __init__( + self, + id: str, + container_config: ContainerConfiguration, + docker_client: ContainerClient | None = None, + ): self.id = id self.config = container_config - self.name = DOCKER_CLIENT.get_container_name(self.id) - self.running = True + self.container_client = docker_client or DOCKER_CLIENT + self.name = self.container_client.get_container_name(self.id) def __enter__(self): return self @@ -451,29 +457,35 @@ def __enter__(self): def __exit__(self, exc_type, exc_value, traceback): self.shutdown() - def wait_until_ready(self, timeout: float = None): - def is_container_running() -> bool: - logs = DOCKER_CLIENT.get_container_logs(self.id) - if constants.READY_MARKER_OUTPUT in logs.splitlines(): - return True + def is_running(self) -> bool: + logs = self.get_logs() + if constants.READY_MARKER_OUTPUT in logs.splitlines(): + return True - return False + return False - poll_condition(is_container_running, timeout) + def get_logs(self) -> str: + return self.container_client.get_container_logs(self.id) + + def wait_until_ready(self, timeout: float = None): + poll_condition(self.is_running, timeout) def shutdown(self, timeout: int = 10): - if not DOCKER_CLIENT.is_container_running(self.name): + if not self.container_client.is_container_running(self.name): return - DOCKER_CLIENT.stop_container(container_name=self.id, timeout=timeout) - DOCKER_CLIENT.remove_container(container_name=self.id, force=True, check_existence=False) - self.running = False + self.container_client.stop_container(container_name=self.id, timeout=timeout) + self.container_client.remove_container( + container_name=self.id, force=True, check_existence=False + ) def attach(self): - DOCKER_CLIENT.attach_to_container(container_name_or_id=self.id) + self.container_client.attach_to_container(container_name_or_id=self.id) def exec_in_container(self, *args, **kwargs): - return DOCKER_CLIENT.exec_in_container(container_name_or_id=self.id, *args, **kwargs) + return self.container_client.exec_in_container( + container_name_or_id=self.id, *args, **kwargs + ) class LocalstackContainerServer(Server): @@ -505,7 +517,7 @@ def is_up(self) -> bool: if not self.is_container_running(): return False - logs = DOCKER_CLIENT.get_container_logs(self.container.name) + logs = self.container.get_logs() if constants.READY_MARKER_OUTPUT not in logs.splitlines(): return False @@ -518,7 +530,7 @@ def is_container_running(self) -> bool: if not isinstance(self.container, RunningContainer): return False - return DOCKER_CLIENT.is_container_running(self.container.name) + return self.container.is_running() def wait_is_container_running(self, timeout=None) -> bool: return poll_condition(self.is_container_running, timeout) @@ -545,12 +557,12 @@ def do_run(self): return self.container def do_shutdown(self): + if not isinstance(self.container, RunningContainer): raise ValueError(f"Container {self.container} not started") + try: - DOCKER_CLIENT.stop_container( - self.container.name, timeout=10 - ) # giving the container some time to stop + self.container.shutdown(timeout=10) except Exception as e: LOG.info("error cleaning up localstack container %s: %s", self.container.name, e) From 6cf702ab1c76b01bb5de79ee80951ec671094f5a Mon Sep 17 00:00:00 2001 From: Simon Walker Date: Thu, 31 Aug 2023 12:43:36 +0100 Subject: [PATCH 29/39] Configuration runs on container not config --- localstack/utils/bootstrap.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/localstack/utils/bootstrap.py b/localstack/utils/bootstrap.py index fb0b2e2c6e0bc..3c5774870ad97 100644 --- a/localstack/utils/bootstrap.py +++ b/localstack/utils/bootstrap.py @@ -607,7 +607,7 @@ def configure_container(container: Container): container.config.additional_flags = f"{container.config.additional_flags} {user_flags}" # get additional parameters from plugins - hooks.configure_localstack_container.run(container.config) + hooks.configure_localstack_container.run(container) # construct default port mappings container.config.ports.add(get_edge_port_http()) @@ -683,11 +683,12 @@ def start_infra_in_docker(console, cli_params: Dict[str, Any] = None): # create and prepare container container_config = ContainerConfiguration(get_docker_image_to_start()) + container = Container(container_config) - configure_container(container_config) + configure_container(container) if cli_params: - configure_container_from_cli_params(container_config, cli_params or {}) - ensure_container_image(console, container_config) + configure_container_from_cli_params(container, cli_params or {}) + ensure_container_image(console, container) status = console.status("Starting LocalStack container") status.start() @@ -765,10 +766,11 @@ def start_infra_in_docker_detached(console, cli_params: Dict[str, Any] = None): # create and prepare container console.log("configuring container") container_config = ContainerConfiguration(get_docker_image_to_start()) - configure_container(container_config) + container = Container(container_config) + configure_container(container) if cli_params: - configure_container_from_cli_params(container_config, cli_params) - ensure_container_image(console, container_config) + configure_container_from_cli_params(container, cli_params) + ensure_container_image(console, container) container_config.detach = True From f53f0e457a5f5ddcc59e919aae72547146090ad9 Mon Sep 17 00:00:00 2001 From: Simon Walker Date: Thu, 31 Aug 2023 13:57:55 +0100 Subject: [PATCH 30/39] Fix circleci bootstrap test invocation --- .circleci/config.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index def0d5117f845..cf241ab1a4c86 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -290,7 +290,7 @@ jobs: environment: TEST_PATH: "tests/bootstrap" COVERAGE_ARGS: "-p" - PYTEST_ADDOPTS: "${{ env.TINYBIRD_PYTEST_ARGS }}-p no:localstack.testing.pytest.fixtures -p no:localstack.testing.pytest.snapshot -p no:localstack.testing.pytest.filters -p no:localstack.testing.pytest.fixture_conflicts -p no:tests.fixtures -s" + PYTEST_ADDOPTS: "${TINYBIRD_PYTEST_ARGS}-p no:localstack.testing.pytest.fixtures -p no:localstack.testing.pytest.snapshot -p no:localstack.testing.pytest.filters -p no:localstack.testing.pytest.fixture_conflicts -p no:tests.fixtures -s" command: | make test-coverage - store_test_results: From 505674dc1a71eed160ebd1a29c1b7b410c051955 Mon Sep 17 00:00:00 2001 From: Simon Walker Date: Thu, 31 Aug 2023 14:53:58 +0100 Subject: [PATCH 31/39] Pass tinybird args flag on command line I think the environment section is set up separately to the main invocation, which in turn sources the tinybird parameters --- .circleci/config.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index cf241ab1a4c86..5a0ffe11a338f 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -290,9 +290,8 @@ jobs: environment: TEST_PATH: "tests/bootstrap" COVERAGE_ARGS: "-p" - PYTEST_ADDOPTS: "${TINYBIRD_PYTEST_ARGS}-p no:localstack.testing.pytest.fixtures -p no:localstack.testing.pytest.snapshot -p no:localstack.testing.pytest.filters -p no:localstack.testing.pytest.fixture_conflicts -p no:tests.fixtures -s" command: | - make test-coverage + PYTEST_ARGS="${TINYBIRD_PYTEST_ARGS}-p no:localstack.testing.pytest.fixtures -p no:localstack.testing.pytest.snapshot -p no:localstack.testing.pytest.filters -p no:localstack.testing.pytest.fixture_conflicts -p no:tests.fixtures -s" make test-coverage - store_test_results: path: target/reports/ - run: From effa04a7f1810136b98d24802cc40fc0db090582 Mon Sep 17 00:00:00 2001 From: Simon Walker Date: Thu, 31 Aug 2023 14:57:27 +0100 Subject: [PATCH 32/39] Reset circleci config --- .circleci/config.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 5a0ffe11a338f..e2b15555e2ec6 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -291,7 +291,7 @@ jobs: TEST_PATH: "tests/bootstrap" COVERAGE_ARGS: "-p" command: | - PYTEST_ARGS="${TINYBIRD_PYTEST_ARGS}-p no:localstack.testing.pytest.fixtures -p no:localstack.testing.pytest.snapshot -p no:localstack.testing.pytest.filters -p no:localstack.testing.pytest.fixture_conflicts -p no:tests.fixtures -s" make test-coverage + PYTEST_ARGS="-s ${TINYBIRD_PYTEST_ARGS}--junitxml=target/reports/bootstrap-tests.xml -o junit_suite_name=bootstrap-tests" make test-coverage - store_test_results: path: target/reports/ - run: From dbbf45bef0d1195c813c664434899fc25eb65019 Mon Sep 17 00:00:00 2001 From: Simon Walker Date: Thu, 31 Aug 2023 15:27:25 +0100 Subject: [PATCH 33/39] Disable localstack.pytest.fixtures This should prevent the duplicate fixtures problem --- .circleci/config.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index e2b15555e2ec6..641455d7315cd 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -291,7 +291,7 @@ jobs: TEST_PATH: "tests/bootstrap" COVERAGE_ARGS: "-p" command: | - PYTEST_ARGS="-s ${TINYBIRD_PYTEST_ARGS}--junitxml=target/reports/bootstrap-tests.xml -o junit_suite_name=bootstrap-tests" make test-coverage + PYTEST_ARGS="${TINYBIRD_PYTEST_ARGS} -p no:localstack.testing.pytest.fixtures --junitxml=target/reports/bootstrap-tests.xml -o junit_suite_name=bootstrap-tests" make test-coverage - store_test_results: path: target/reports/ - run: From 65ea529cc5f9392fb351b19621af15c58a46ae5b Mon Sep 17 00:00:00 2001 From: Simon Walker Date: Thu, 31 Aug 2023 15:58:08 +0100 Subject: [PATCH 34/39] Handle health check better --- localstack/utils/bootstrap.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/localstack/utils/bootstrap.py b/localstack/utils/bootstrap.py index 3c5774870ad97..d8357052b302d 100644 --- a/localstack/utils/bootstrap.py +++ b/localstack/utils/bootstrap.py @@ -465,19 +465,20 @@ def is_running(self) -> bool: return False def get_logs(self) -> str: - return self.container_client.get_container_logs(self.id) + return self.container_client.get_container_logs(self.id, safe=True) def wait_until_ready(self, timeout: float = None): poll_condition(self.is_running, timeout) - def shutdown(self, timeout: int = 10): + def shutdown(self, timeout: int = 10, remove: bool = True): if not self.container_client.is_container_running(self.name): return self.container_client.stop_container(container_name=self.id, timeout=timeout) - self.container_client.remove_container( - container_name=self.id, force=True, check_existence=False - ) + if remove: + self.container_client.remove_container( + container_name=self.id, force=True, check_existence=False + ) def attach(self): self.container_client.attach_to_container(container_name_or_id=self.id) From 06a03cb70f4269d5eca1f74bfb51dd5d75f9ae1e Mon Sep 17 00:00:00 2001 From: Simon Walker Date: Thu, 31 Aug 2023 16:50:16 +0100 Subject: [PATCH 35/39] Update localstack/utils/container_utils/docker_cmd_client.py Co-authored-by: Thomas Rausch --- localstack/utils/container_utils/docker_cmd_client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/localstack/utils/container_utils/docker_cmd_client.py b/localstack/utils/container_utils/docker_cmd_client.py index 4ee5865c4302f..01089e63c8639 100644 --- a/localstack/utils/container_utils/docker_cmd_client.py +++ b/localstack/utils/container_utils/docker_cmd_client.py @@ -662,7 +662,7 @@ def start_container( def attach_to_container(self, container_name_or_id: str): cmd = self._docker_cmd() + ["attach", container_name_or_id] - LOG.debug(f"Attaching to container {container_name_or_id}") + LOG.debug("Attaching to container %s", container_name_or_id) return self._run_async_cmd(cmd, stdin=None, container_name=container_name_or_id) def _run_async_cmd( From f341355878f3630ee03ebf1e4b2f078c284a978d Mon Sep 17 00:00:00 2001 From: Simon Walker Date: Thu, 31 Aug 2023 17:07:36 +0100 Subject: [PATCH 36/39] Handle shutdown type failures This is annoying as we don't have a nice type system *cough* rust *cough* --- localstack/utils/bootstrap.py | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/localstack/utils/bootstrap.py b/localstack/utils/bootstrap.py index d8357052b302d..c1ee0c14504b9 100644 --- a/localstack/utils/bootstrap.py +++ b/localstack/utils/bootstrap.py @@ -488,6 +488,12 @@ def exec_in_container(self, *args, **kwargs): container_name_or_id=self.id, *args, **kwargs ) + def stopped(self) -> Container: + """ + Convert this running instance to a stopped instance ready to be restarted + """ + return Container(container_config=self.config, docker_client=self.container_client) + class LocalstackContainerServer(Server): def __init__(self, container_configuration: ContainerConfiguration | None = None) -> None: @@ -536,6 +542,12 @@ def is_container_running(self) -> bool: def wait_is_container_running(self, timeout=None) -> bool: return poll_condition(self.is_container_running, timeout) + def start(self) -> bool: + if isinstance(self.container, RunningContainer): + raise RuntimeError("cannot start container as container reference has been started") + + return super().start() + def do_run(self): if self.is_container_running(): raise ContainerExists( @@ -557,13 +569,16 @@ def do_run(self): self.container.attach() return self.container - def do_shutdown(self): - + def shutdown(self): if not isinstance(self.container, RunningContainer): raise ValueError(f"Container {self.container} not started") + return super().shutdown() + + def do_shutdown(self): try: self.container.shutdown(timeout=10) + self.container = self.container.stopped() except Exception as e: LOG.info("error cleaning up localstack container %s: %s", self.container.name, e) From 9bc8ce1d3b0d51895edf0690bc22dd71115ab243 Mon Sep 17 00:00:00 2001 From: Simon Walker Date: Thu, 31 Aug 2023 17:10:53 +0100 Subject: [PATCH 37/39] Revert custom cleanups implementation --- .circleci/config.yml | 2 +- tests/bootstrap/conftest.py | 15 --------------- 2 files changed, 1 insertion(+), 16 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 641455d7315cd..ab4fd232ed30c 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -291,7 +291,7 @@ jobs: TEST_PATH: "tests/bootstrap" COVERAGE_ARGS: "-p" command: | - PYTEST_ARGS="${TINYBIRD_PYTEST_ARGS} -p no:localstack.testing.pytest.fixtures --junitxml=target/reports/bootstrap-tests.xml -o junit_suite_name=bootstrap-tests" make test-coverage + PYTEST_ARGS="${TINYBIRD_PYTEST_ARGS}--junitxml=target/reports/bootstrap-tests.xml -o junit_suite_name=bootstrap-tests" make test-coverage - store_test_results: path: target/reports/ - run: diff --git a/tests/bootstrap/conftest.py b/tests/bootstrap/conftest.py index fdb6eb1dc74ea..9b8ea877d8fb1 100644 --- a/tests/bootstrap/conftest.py +++ b/tests/bootstrap/conftest.py @@ -18,21 +18,6 @@ LOG = logging.getLogger(__name__) -# TODO: for now we duplicate this fixture since we can't enable the fixture plugin, and can't -# move the fixture to tests/conftest.py because some unit tests are dependent on its current path -@pytest.fixture -def cleanups(): - cleanup_fns = [] - - yield cleanup_fns - - for cleanup_callback in cleanup_fns[::-1]: - try: - cleanup_callback() - except Exception as e: - LOG.warning("Failed to execute cleanup", exc_info=e) - - class ContainerFactory: def __init__(self): self._containers: list[Container] = [] From 63adfbbd55786d322a05302233737628068309ba Mon Sep 17 00:00:00 2001 From: Simon Walker Date: Fri, 1 Sep 2023 09:26:25 +0100 Subject: [PATCH 38/39] Remove localstack specifics from Container --- localstack/utils/bootstrap.py | 9 +++++---- tests/bootstrap/conftest.py | 15 +++++++++++++-- .../test_container_listen_configuration.py | 14 ++++++++------ 3 files changed, 26 insertions(+), 12 deletions(-) diff --git a/localstack/utils/bootstrap.py b/localstack/utils/bootstrap.py index c1ee0c14504b9..5ed0d42a5c97a 100644 --- a/localstack/utils/bootstrap.py +++ b/localstack/utils/bootstrap.py @@ -21,6 +21,7 @@ ContainerClient, ContainerConfiguration, ContainerException, + NoSuchContainer, NoSuchImage, NoSuchNetwork, PortMappings, @@ -458,11 +459,11 @@ def __exit__(self, exc_type, exc_value, traceback): self.shutdown() def is_running(self) -> bool: - logs = self.get_logs() - if constants.READY_MARKER_OUTPUT in logs.splitlines(): + try: + self.container_client.inspect_container(self.id) return True - - return False + except NoSuchContainer: + return False def get_logs(self) -> str: return self.container_client.get_container_logs(self.id, safe=True) diff --git a/tests/bootstrap/conftest.py b/tests/bootstrap/conftest.py index 9b8ea877d8fb1..f9370a0d99154 100644 --- a/tests/bootstrap/conftest.py +++ b/tests/bootstrap/conftest.py @@ -7,13 +7,14 @@ import pytest -from localstack import config -from localstack.utils.bootstrap import Container, get_docker_image_to_start +from localstack import config, constants +from localstack.utils.bootstrap import Container, RunningContainer, get_docker_image_to_start from localstack.utils.container_utils.container_client import ( ContainerConfiguration, PortMappings, VolumeMappings, ) +from localstack.utils.sync import poll_condition LOG = logging.getLogger(__name__) @@ -94,3 +95,13 @@ def container_factory() -> Generator[ContainerFactory, None, None]: @pytest.fixture(scope="session", autouse=True) def setup_host_config_dirs(): config.dirs.mkdirs() + + +@pytest.fixture +def wait_for_localstack_ready(): + def _wait_for(container: RunningContainer, timeout: float | None = None): + container.wait_until_ready(timeout) + + poll_condition(lambda: constants.READY_MARKER_OUTPUT in container.get_logs().splitlines()) + + return _wait_for diff --git a/tests/bootstrap/test_container_listen_configuration.py b/tests/bootstrap/test_container_listen_configuration.py index 7236646feb03f..661c8fd3710f3 100644 --- a/tests/bootstrap/test_container_listen_configuration.py +++ b/tests/bootstrap/test_container_listen_configuration.py @@ -29,7 +29,7 @@ def docker_network(ensure_network): @pytest.mark.skipif(condition=in_docker(), reason="cannot run bootstrap tests in docker") class TestContainerConfiguration: - def test_defaults(self, container_factory): + def test_defaults(self, container_factory, wait_for_localstack_ready): """ The default configuration is to listen on 0.0.0.0:4566 """ @@ -37,12 +37,12 @@ def test_defaults(self, container_factory): container = container_factory() container.config.ports.add(port, 4566) with container.start(attach=False) as running_container: - running_container.wait_until_ready() + wait_for_localstack_ready(running_container) r = requests.get(f"http://127.0.0.1:{port}/_localstack/health") assert r.status_code == 200 - def test_gateway_listen_single_value(self, container_factory): + def test_gateway_listen_single_value(self, container_factory, wait_for_localstack_ready): """ Test using GATEWAY_LISTEN to change the hypercorn port """ @@ -55,13 +55,15 @@ def test_gateway_listen_single_value(self, container_factory): ) container.config.ports.add(port1, 5000) with container.start(attach=False) as running_container: - running_container.wait_until_ready() + wait_for_localstack_ready(running_container) # check the ports listening on 0.0.0.0 r = requests.get(f"http://127.0.0.1:{port1}/_localstack/health") assert r.status_code == 200 - def test_gateway_listen_multiple_values(self, container_factory, docker_network): + def test_gateway_listen_multiple_values( + self, container_factory, docker_network, wait_for_localstack_ready + ): """ Test multiple container ports """ @@ -82,7 +84,7 @@ def test_gateway_listen_multiple_values(self, container_factory, docker_network) container.config.ports.add(port1, 5000) container.config.ports.add(port2, 2000) with container.start(attach=False) as running_container: - running_container.wait_until_ready() + wait_for_localstack_ready(running_container) # check the ports listening on 0.0.0.0 r = requests.get(f"http://127.0.0.1:{port1}/_localstack/health") From 65f22816cc0fa060bda42ab67295b2266068d5ef Mon Sep 17 00:00:00 2001 From: Simon Walker Date: Fri, 1 Sep 2023 15:22:58 +0100 Subject: [PATCH 39/39] Add timeout to poll_condition for wait_for_localstack_ready --- tests/bootstrap/conftest.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/bootstrap/conftest.py b/tests/bootstrap/conftest.py index f9370a0d99154..2f5eca0b5e65e 100644 --- a/tests/bootstrap/conftest.py +++ b/tests/bootstrap/conftest.py @@ -102,6 +102,9 @@ def wait_for_localstack_ready(): def _wait_for(container: RunningContainer, timeout: float | None = None): container.wait_until_ready(timeout) - poll_condition(lambda: constants.READY_MARKER_OUTPUT in container.get_logs().splitlines()) + poll_condition( + lambda: constants.READY_MARKER_OUTPUT in container.get_logs().splitlines(), + timeout=timeout, + ) return _wait_for