diff --git a/.circleci/config.yml b/.circleci/config.yml index e2b15555e2ec6..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="-s ${TINYBIRD_PYTEST_ARGS}--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/localstack/cli/localstack.py b/localstack/cli/localstack.py index 681dfd58ecb02..61b5dfaa82af8 100644 --- a/localstack/cli/localstack.py +++ b/localstack/cli/localstack.py @@ -7,6 +7,7 @@ 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 if sys.version_info >= (3, 8): @@ -532,11 +533,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/plugins.py b/localstack/plugins.py index 2cd97e1ad6701..5a1133d049887 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.bootstrap import Container LOG = logging.getLogger(__name__) @hooks.configure_localstack_container() -def configure_edge_port(container): +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.ports.add(port) + container.config.ports.add(port) # Register the ArnPartitionRewriteListener only if the feature flag is enabled diff --git a/localstack/testing/pytest/fixtures.py b/localstack/testing/pytest/fixtures.py index ab518bd9d0c6f..492c6370bd865 100644 --- a/localstack/testing/pytest/fixtures.py +++ b/localstack/testing/pytest/fixtures.py @@ -1740,7 +1740,7 @@ def factory(ports=None, **kwargs): @pytest.fixture -def cleanups(aws_client): +def cleanups(): cleanup_fns = [] yield cleanup_fns diff --git a/localstack/utils/analytics/metadata.py b/localstack/utils/analytics/metadata.py index d25fa5788128e..e93d46642da45 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.bootstrap import Container 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: 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 @@ -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 bcf4a8414143b..31161a2a1c14d 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 @@ -16,8 +18,10 @@ 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, + NoSuchContainer, NoSuchImage, NoSuchNetwork, PortMappings, @@ -109,6 +113,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 @@ -368,144 +376,210 @@ def extract_port_flags(user_flags, port_mappings: PortMappings): return user_flags -class LocalstackContainer: - config: ContainerConfiguration - - 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() - - def _get_default_configuration(self, 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): - if isinstance(DOCKER_CLIENT, CmdDockerClient): - DOCKER_CLIENT.default_run_outfile = self.logfile - - # 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. +class Container: + 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: + # 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: - return DOCKER_CLIENT.run_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 starting LocalStack container") + LOG.exception("Error while creating container") else: LOG.error( - "Error while starting 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 - def _ensure_container_network(self, network: str): + try: + self.container_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) + + 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) - 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. +class RunningContainer: + """ + Represents a LocalStack container that is running. + """ - @property - def env_vars(self) -> Dict[str, str]: - return self.config.env_vars + def __init__( + self, + id: str, + container_config: ContainerConfiguration, + docker_client: ContainerClient | None = None, + ): + self.id = id + self.config = container_config + self.container_client = docker_client or DOCKER_CLIENT + self.name = self.container_client.get_container_name(self.id) - @property - def entrypoint(self) -> Optional[str]: - return self.config.entrypoint + def __enter__(self): + return self - @entrypoint.setter - def entrypoint(self, value: str): - self.config.entrypoint = value + def __exit__(self, exc_type, exc_value, traceback): + self.shutdown() - @property - def name(self) -> str: - return self.config.name + def is_running(self) -> bool: + try: + self.container_client.inspect_container(self.id) + return True + except NoSuchContainer: + return False - @property - def volumes(self) -> VolumeMappings: - return self.config.volumes + def get_logs(self) -> str: + return self.container_client.get_container_logs(self.id, safe=True) - @property - def ports(self) -> PortMappings: - return self.config.ports + def wait_until_ready(self, timeout: float = None): + poll_condition(self.is_running, timeout) + def shutdown(self, timeout: int = 10, remove: bool = True): + if not self.container_client.is_container_running(self.name): + return -class LocalstackContainerServer(Server): - container: LocalstackContainer + self.container_client.stop_container(container_name=self.id, timeout=timeout) + 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) + + def exec_in_container(self, *args, **kwargs): + return self.container_client.exec_in_container( + container_name_or_id=self.id, *args, **kwargs + ) - def __init__(self, container=None) -> None: + 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: 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) + + logs = self.container.get_logs() 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: - return DOCKER_CLIENT.is_container_running(self.container.name) + # if we have not started the container then we are not up + if not isinstance(self.container, RunningContainer): + return False + + return self.container.is_running() 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 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 ) - return self.container.run() + config.dirs.mkdirs() + 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 shutdown(self): + if not isinstance(self.container, RunningContainer): + raise ValueError(f"Container {self.container} not started") + + return super().shutdown() def do_shutdown(self): try: - DOCKER_CLIENT.stop_container( - self.container.name, timeout=10 - ) # giving the container some time to stop + 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) @@ -524,45 +598,61 @@ def prepare_docker_start(): config.dirs.mkdirs() -def configure_container(container: LocalstackContainer): +def configure_container(container: Container): """ 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) + 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) # 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) # mount docker socket - container.volumes.append((config.DOCKER_SOCK, config.DOCKER_SOCK)) + container.config.volumes.append((config.DOCKER_SOCK, config.DOCKER_SOCK)) -def configure_container_from_cli_params(container: LocalstackContainer, 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"): @@ -580,8 +670,8 @@ def configure_container_from_cli_params(container: LocalstackContainer, params: 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: Container): + container.config.volumes.add(VolumeBind(config.VOLUME_DIR, DEFAULT_VOLUME_DIR)) @log_duration() @@ -607,7 +697,9 @@ def start_infra_in_docker(console, cli_params: Dict[str, Any] = None): prepare_docker_start() # create and prepare container - container = LocalstackContainer() + container_config = ContainerConfiguration(get_docker_image_to_start()) + container = Container(container_config) + configure_container(container) if cli_params: configure_container_from_cli_params(container, cli_params or {}) @@ -618,14 +710,16 @@ def start_infra_in_docker(console, cli_params: Dict[str, Any] = None): # 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 = get_container_default_logfile_location(container_config.name) + log_printer = FileListener(logfile, callback=_init_log_printer) + log_printer.truncate_file() log_printer.start() # Set up signal handler, to enable clean shutdown across different operating systems. @@ -648,7 +742,7 @@ def shutdown_handler(*args): 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() @@ -661,7 +755,7 @@ def shutdown_handler(*args): shutdown_handler() -def ensure_container_image(console, container: LocalstackContainer): +def ensure_container_image(console, container: Container): try: DOCKER_CLIENT.inspect_image(container.config.image_name, pull=False) return @@ -686,18 +780,18 @@ def start_infra_in_docker_detached(console, cli_params: Dict[str, Any] = None): # create and prepare container console.log("configuring container") - container = LocalstackContainer() + container_config = ContainerConfiguration(get_docker_image_to_start()) + container = Container(container_config) configure_container(container) if cli_params: configure_container_from_cli_params(container, cli_params) ensure_container_image(console, container) - 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 febd5eb643474..4cd04d71e2b85 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, @@ -920,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..01089e63c8639 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("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( 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, diff --git a/localstack/utils/tail.py b/localstack/utils/tail.py index 3f2ea8bba74b4..1e8cd4d37e7b8 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_file(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 f9ead8e2d2fc0..2f5eca0b5e65e 100644 --- a/tests/bootstrap/conftest.py +++ b/tests/bootstrap/conftest.py @@ -1,8 +1,110 @@ +from __future__ import annotations + +import logging +import os +import shlex +from typing import Generator + import pytest -from localstack import config +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__) + + +class ContainerFactory: + def __init__(self): + self._containers: list[Container] = [] + + def __call__( + self, + # convenience properties + pro: bool = False, + publish: list[int] | None = None, + # ContainerConfig properties + **kwargs, + ) -> 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_configuration.name = None + + # handle the convenience options + if pro: + container_configuration.env_vars["GATEWAY_LISTEN"] = "0.0.0.0:4566,0.0.0.0:443" + container_configuration.env_vars["LOCALSTACK_API_KEY"] = "test" + + # 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) + return container + + def remove_all_containers(self): + failures = [] + for container in self._containers: + if not container.running_container: + # container is not running + continue + + try: + container.running_container.shutdown() + except Exception as e: + failures.append((container, e)) + + if failures: + for container, ex in failures: + LOG.error( + f"Failed to remove container {container.running_container.id}", + exc_info=LOG.isEnabledFor(logging.DEBUG), + ) + + +@pytest.fixture(scope="session") +def container_factory() -> Generator[ContainerFactory, None, None]: + factory = ContainerFactory() + yield factory + factory.remove_all_containers() @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(), + timeout=timeout, + ) + + return _wait_for diff --git a/tests/bootstrap/test_container_listen_configuration.py b/tests/bootstrap/test_container_listen_configuration.py new file mode 100644 index 0000000000000..661c8fd3710f3 --- /dev/null +++ b/tests/bootstrap/test_container_listen_configuration.py @@ -0,0 +1,94 @@ +import pytest +import requests + +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 + + +@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()}" + 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, wait_for_localstack_ready): + """ + 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: + 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, wait_for_localstack_ready): + """ + 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: + 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, wait_for_localstack_ready + ): + """ + 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: + 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.ok + + 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 ad413a771a900..19b47fa46aaf0 100644 --- a/tests/bootstrap/test_localstack_container.py +++ b/tests/bootstrap/test_localstack_container.py @@ -9,9 +9,8 @@ @pytest.mark.skipif(condition=in_docker(), reason="cannot run bootstrap tests in docker") 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: diff --git a/tests/cli/test_cli.py b/tests/cli/test_cli.py index 485343b83a577..359f2b1b91eb0 100644 --- a/tests/cli/test_cli.py +++ b/tests/cli/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"])