diff --git a/devservices/commands/foreground.py b/devservices/commands/foreground.py new file mode 100644 index 00000000..737a8076 --- /dev/null +++ b/devservices/commands/foreground.py @@ -0,0 +1,133 @@ +from __future__ import annotations + +import os +import pty +import shlex +from argparse import _SubParsersAction +from argparse import ArgumentParser +from argparse import Namespace + +from sentry_sdk import capture_exception + +from devservices.constants import DependencyType +from devservices.constants import DEVSERVICES_DIR_NAME +from devservices.constants import PROGRAMS_CONF_FILE_NAME +from devservices.exceptions import ConfigError +from devservices.exceptions import ConfigNotFoundError +from devservices.exceptions import ServiceNotFoundError +from devservices.exceptions import SupervisorConfigError +from devservices.exceptions import SupervisorProcessError +from devservices.utils.console import Console +from devservices.utils.services import find_matching_service +from devservices.utils.state import State +from devservices.utils.state import StateTables +from devservices.utils.supervisor import SupervisorManager + + +def add_parser(subparsers: _SubParsersAction[ArgumentParser]) -> None: + parser = subparsers.add_parser( + "foreground", help="Run a service's program in the foreground" + ) + parser.add_argument( + "program_name", help="Name of the program to run in the foreground" + ) + parser.set_defaults(func=foreground) + + +def foreground(args: Namespace) -> None: + """Run a service's program in the foreground.""" + console = Console() + program_name = args.program_name + try: + service = find_matching_service() + except ConfigNotFoundError as e: + capture_exception(e, level="info") + console.failure( + f"{str(e)}. Please specify a service (i.e. `devservices down sentry`) or run the command from a directory with a devservices configuration." + ) + exit(1) + except ConfigError as e: + capture_exception(e) + console.failure(str(e)) + exit(1) + except ServiceNotFoundError as e: + console.failure(str(e)) + exit(1) + modes = service.config.modes + if program_name not in service.config.dependencies: + console.failure( + f"Program {program_name} does not exist in the service's config" + ) + return + state = State() + starting_services = set(state.get_service_entries(StateTables.STARTING_SERVICES)) + started_services = set(state.get_service_entries(StateTables.STARTED_SERVICES)) + active_services = starting_services.union(started_services) + if service.name not in active_services: + console.warning(f"{service.name} is not running") + return + active_starting_modes = state.get_active_modes_for_service( + service.name, StateTables.STARTING_SERVICES + ) + active_started_modes = state.get_active_modes_for_service( + service.name, StateTables.STARTED_SERVICES + ) + active_modes = active_starting_modes or active_started_modes + mode_dependencies = set() + for active_mode in active_modes: + active_mode_dependencies = modes.get(active_mode, []) + mode_dependencies.update(active_mode_dependencies) + + supervisor_programs = [ + dep + for dep in mode_dependencies + if dep in service.config.dependencies + and service.config.dependencies[dep].dependency_type + == DependencyType.SUPERVISOR + ] + + if program_name not in supervisor_programs: + console.failure( + f"Program {program_name} is not running in any active modes of {service.name}" + ) + return + + programs_config_path = os.path.join( + service.repo_path, f"{DEVSERVICES_DIR_NAME}/{PROGRAMS_CONF_FILE_NAME}" + ) + + manager = SupervisorManager( + programs_config_path, + service_name=service.name, + ) + + try: + program_command = manager.get_program_command(program_name) + except SupervisorConfigError as e: + capture_exception(e, level="info") + console.failure(f"Error when getting program command: {str(e)}") + return + + try: + # Stop the supervisor process before running in foreground + console.info(f"Stopping {program_name} in supervisor") + manager.stop_process(program_name) + console.info(f"Starting {program_name} in foreground") + argv = shlex.split(program_command) + + # Run the process in foreground + pty.spawn(argv) + + except SupervisorProcessError as e: + capture_exception(e) + console.failure(f"Error stopping {program_name} in supervisor: {str(e)}") + except (OSError, FileNotFoundError, PermissionError) as e: + capture_exception(e) + console.failure(f"Error running {program_name} in foreground: {str(e)}") + + try: + console.info(f"Restarting {program_name} in background") + manager.start_process(program_name) + except SupervisorProcessError as e: + capture_exception(e) + console.failure(f"Error restarting {program_name} in background: {str(e)}") diff --git a/devservices/main.py b/devservices/main.py index ad4d5026..d59c96f3 100644 --- a/devservices/main.py +++ b/devservices/main.py @@ -20,6 +20,7 @@ from sentry_sdk.types import Hint from devservices.commands import down +from devservices.commands import foreground from devservices.commands import list_dependencies from devservices.commands import list_services from devservices.commands import logs @@ -150,6 +151,7 @@ def main() -> None: purge.add_parser(subparsers) serve.add_parser(subparsers) toggle.add_parser(subparsers) + foreground.add_parser(subparsers) reset.add_parser(subparsers) args = parser.parse_args() diff --git a/tests/commands/test_foreground.py b/tests/commands/test_foreground.py new file mode 100644 index 00000000..e0c43007 --- /dev/null +++ b/tests/commands/test_foreground.py @@ -0,0 +1,701 @@ +from __future__ import annotations + +import os +from argparse import Namespace +from pathlib import Path +from unittest import mock + +import pytest + +from devservices.commands.foreground import foreground +from devservices.constants import Color +from devservices.constants import CONFIG_FILE_NAME +from devservices.constants import DEVSERVICES_DIR_NAME +from devservices.exceptions import ConfigError +from devservices.exceptions import ServiceNotFoundError +from devservices.exceptions import SupervisorConfigError +from devservices.exceptions import SupervisorProcessError +from devservices.utils.state import State +from devservices.utils.state import StateTables +from testing.utils import create_config_file +from testing.utils import create_programs_conf_file + + +@mock.patch("devservices.commands.foreground.pty.spawn") +@mock.patch("devservices.utils.supervisor.SupervisorManager.start_process") +@mock.patch("devservices.utils.supervisor.SupervisorManager.stop_process") +def test_foreground_success( + mock_stop_process: mock.Mock, + mock_start_process: mock.Mock, + mock_pty_spawn: mock.Mock, + tmp_path: Path, +) -> None: + config = { + "x-sentry-service-config": { + "version": 0.1, + "service_name": "example-service", + "dependencies": { + "redis": {"description": "Redis"}, + "worker": {"description": "Worker"}, + }, + "modes": {"default": ["redis", "worker"]}, + }, + "services": { + "redis": {"image": "redis:6.2.14-alpine"}, + }, + } + + service_path = tmp_path / "example-service" + create_config_file(service_path, config) + os.chdir(service_path) + + programs_config = """ +[program:worker] +command=python worker.py +autostart=true +autorestart=true +""" + create_programs_conf_file(service_path, programs_config) + + args = Namespace(program_name="worker") + + with mock.patch("devservices.utils.state.STATE_DB_FILE", str(tmp_path / "state")): + state = State() + state.update_service_entry( + "example-service", "default", StateTables.STARTED_SERVICES + ) + foreground(args) + + mock_stop_process.assert_called_once_with("worker") + mock_pty_spawn.assert_called_once_with(["python", "worker.py"]) + mock_start_process.assert_called_once_with("worker") + + +@mock.patch("devservices.commands.foreground.pty.spawn") +def test_foreground_service_not_running( + mock_pty_spawn: mock.Mock, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + config = { + "x-sentry-service-config": { + "version": 0.1, + "service_name": "example-service", + "dependencies": { + "worker": {"description": "Worker"}, + }, + "modes": {"default": ["worker"]}, + }, + } + + service_path = tmp_path / "example-service" + create_config_file(service_path, config) + os.chdir(service_path) + + programs_config = """ +[program:worker] +command=python worker.py +autostart=true +autorestart=true +""" + create_programs_conf_file(service_path, programs_config) + + args = Namespace(program_name="worker") + + with mock.patch("devservices.utils.state.STATE_DB_FILE", str(tmp_path / "state")): + foreground(args) + + captured = capsys.readouterr() + assert ( + f"{Color.YELLOW}example-service is not running{Color.RESET}\n" == captured.out + ) + + mock_pty_spawn.assert_not_called() + + +@mock.patch("devservices.commands.foreground.pty.spawn") +def test_foreground_program_not_in_supervisor_programs( + mock_pty_spawn: mock.Mock, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + config = { + "x-sentry-service-config": { + "version": 0.1, + "service_name": "example-service", + "dependencies": { + "redis": {"description": "Redis"}, + "worker": {"description": "Worker"}, + }, + "modes": {"default": ["redis", "worker"]}, + }, + "services": { + "redis": {"image": "redis:6.2.14-alpine"}, + }, + } + + service_path = tmp_path / "example-service" + create_config_file(service_path, config) + os.chdir(service_path) + + programs_config = """ +[program:worker] +command=python worker.py +autostart=true +autorestart=true +""" + create_programs_conf_file(service_path, programs_config) + + args = Namespace(program_name="nonexistent") + + with mock.patch("devservices.utils.state.STATE_DB_FILE", str(tmp_path / "state")): + state = State() + state.update_service_entry( + "example-service", "default", StateTables.STARTED_SERVICES + ) + foreground(args) + + captured = capsys.readouterr() + assert ( + f"{Color.RED}Program nonexistent does not exist in the service's config{Color.RESET}\n" + == captured.out + ) + + mock_pty_spawn.assert_not_called() + + +@mock.patch("devservices.commands.foreground.pty.spawn") +def test_foreground_program_not_in_active_modes( + mock_pty_spawn: mock.Mock, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + config = { + "x-sentry-service-config": { + "version": 0.1, + "service_name": "example-service", + "dependencies": { + "redis": {"description": "Redis"}, + "worker": {"description": "Worker"}, + }, + "modes": {"default": ["redis"], "other": ["worker"]}, + }, + "services": { + "redis": {"image": "redis:6.2.14-alpine"}, + }, + } + + service_path = tmp_path / "example-service" + create_config_file(service_path, config) + os.chdir(service_path) + + programs_config = """ +[program:worker] +command=python worker.py +autostart=true +autorestart=true +""" + create_programs_conf_file(service_path, programs_config) + + args = Namespace(program_name="worker") + + with mock.patch("devservices.utils.state.STATE_DB_FILE", str(tmp_path / "state")): + state = State() + state.update_service_entry( + "example-service", "default", StateTables.STARTED_SERVICES + ) + foreground(args) + + captured = capsys.readouterr() + assert ( + f"{Color.RED}Program worker is not running in any active modes of example-service{Color.RESET}\n" + == captured.out + ) + + mock_pty_spawn.assert_not_called() + + +@mock.patch("devservices.commands.foreground.pty.spawn") +def test_foreground_programs_conf_not_found( + mock_pty_spawn: mock.Mock, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + config = { + "x-sentry-service-config": { + "version": 0.1, + "service_name": "example-service", + "dependencies": { + "redis": {"description": "Redis"}, + "worker": {"description": "Worker"}, + }, + "modes": {"default": ["redis", "worker"]}, + }, + "services": { + "redis": {"image": "redis:6.2.14-alpine"}, + }, + } + + service_path = tmp_path / "example-service" + create_config_file(service_path, config) + os.chdir(service_path) + + args = Namespace(program_name="worker") + + with ( + mock.patch("devservices.utils.state.STATE_DB_FILE", str(tmp_path / "state")), + pytest.raises(SystemExit), + ): + state = State() + state.update_service_entry( + "example-service", "default", StateTables.STARTED_SERVICES + ) + foreground(args) + + captured = capsys.readouterr() + assert ( + f"{Color.RED}Dependency 'worker' is not remote but is not defined in docker-compose services or programs file{Color.RESET}\n" + == captured.out + ) + + mock_pty_spawn.assert_not_called() + + +def test_foreground_config_not_found_error( + capsys: pytest.CaptureFixture[str], + tmp_path: Path, +) -> None: + os.chdir(tmp_path) + + args = Namespace(program_name="worker") + + with pytest.raises(SystemExit): + foreground(args) + + captured = capsys.readouterr() + assert ( + f"{Color.RED}No devservices configuration found in {tmp_path / DEVSERVICES_DIR_NAME / CONFIG_FILE_NAME}. Please specify a service (i.e. `devservices down sentry`) or run the command from a directory with a devservices configuration.{Color.RESET}\n" + == captured.out + ) + + +@mock.patch("devservices.commands.foreground.find_matching_service") +def test_foreground_config_error( + mock_find_matching_service: mock.Mock, + capsys: pytest.CaptureFixture[str], +) -> None: + mock_find_matching_service.side_effect = ConfigError("Invalid config") + + args = Namespace(program_name="worker") + + with pytest.raises(SystemExit): + foreground(args) + + captured = capsys.readouterr() + assert f"{Color.RED}Invalid config{Color.RESET}\n" == captured.out + + +@mock.patch("devservices.commands.foreground.find_matching_service") +def test_foreground_service_not_found_error( + mock_find_matching_service: mock.Mock, + capsys: pytest.CaptureFixture[str], +) -> None: + mock_find_matching_service.side_effect = ServiceNotFoundError("Service not found") + + args = Namespace(program_name="worker") + + with pytest.raises(SystemExit): + foreground(args) + + captured = capsys.readouterr() + assert f"{Color.RED}Service not found{Color.RESET}\n" == captured.out + + +@mock.patch("devservices.commands.foreground.pty.spawn") +@mock.patch("devservices.utils.supervisor.SupervisorManager.get_program_command") +def test_foreground_supervisor_config_error( + mock_get_program_command: mock.Mock, + mock_pty_spawn: mock.Mock, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + config = { + "x-sentry-service-config": { + "version": 0.1, + "service_name": "example-service", + "dependencies": { + "worker": {"description": "Worker"}, + }, + "modes": {"default": ["worker"]}, + }, + } + + service_path = tmp_path / "example-service" + create_config_file(service_path, config) + os.chdir(service_path) + + programs_config = """ +[program:worker] +command=python worker.py +autostart=true +autorestart=true +""" + create_programs_conf_file(service_path, programs_config) + + mock_get_program_command.side_effect = SupervisorConfigError("Program config error") + + args = Namespace(program_name="worker") + + with mock.patch("devservices.utils.state.STATE_DB_FILE", str(tmp_path / "state")): + state = State() + state.update_service_entry( + "example-service", "default", StateTables.STARTED_SERVICES + ) + foreground(args) + + # Verify output + captured = capsys.readouterr() + assert ( + f"{Color.RED}Error when getting program command: Program config error{Color.RESET}\n" + == captured.out + ) + + # Verify pty.spawn was not called + mock_pty_spawn.assert_not_called() + + +@mock.patch("devservices.commands.foreground.pty.spawn") +@mock.patch("devservices.utils.supervisor.SupervisorManager.start_process") +@mock.patch("devservices.utils.supervisor.SupervisorManager.stop_process") +def test_foreground_pty_spawn_exception( + mock_stop_process: mock.Mock, + mock_start_process: mock.Mock, + mock_pty_spawn: mock.Mock, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + config = { + "x-sentry-service-config": { + "version": 0.1, + "service_name": "example-service", + "dependencies": { + "worker": {"description": "Worker"}, + }, + "modes": {"default": ["worker"]}, + }, + } + + service_path = tmp_path / "example-service" + create_config_file(service_path, config) + os.chdir(service_path) + + programs_config = """ +[program:worker] +command=python worker.py +autostart=true +autorestart=true +""" + create_programs_conf_file(service_path, programs_config) + + mock_pty_spawn.side_effect = OSError("Spawn failed") + + args = Namespace(program_name="worker") + + with mock.patch("devservices.utils.state.STATE_DB_FILE", str(tmp_path / "state")): + state = State() + state.update_service_entry( + "example-service", "default", StateTables.STARTED_SERVICES + ) + foreground(args) + + captured = capsys.readouterr() + assert ( + f"Stopping worker in supervisor\nStarting worker in foreground\n{Color.RED}Error running worker in foreground: Spawn failed{Color.RESET}\nRestarting worker in background\n" + == captured.out + ) + + mock_start_process.assert_called_once_with("worker") + mock_stop_process.assert_called_once_with("worker") + + +@mock.patch("devservices.commands.foreground.pty.spawn") +@mock.patch("devservices.utils.supervisor.SupervisorManager.start_process") +@mock.patch("devservices.utils.supervisor.SupervisorManager.stop_process") +def test_foreground_stop_process_exception( + mock_stop_process: mock.Mock, + mock_start_process: mock.Mock, + mock_pty_spawn: mock.Mock, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + config = { + "x-sentry-service-config": { + "version": 0.1, + "service_name": "example-service", + "dependencies": { + "worker": {"description": "Worker"}, + }, + "modes": {"default": ["worker"]}, + }, + } + + service_path = tmp_path / "example-service" + create_config_file(service_path, config) + os.chdir(service_path) + + programs_config = """ +[program:worker] +command=python worker.py +autostart=true +autorestart=true +""" + create_programs_conf_file(service_path, programs_config) + + mock_stop_process.side_effect = SupervisorProcessError("Stop process failed") + + args = Namespace(program_name="worker") + + with mock.patch("devservices.utils.state.STATE_DB_FILE", str(tmp_path / "state")): + state = State() + state.update_service_entry( + "example-service", "default", StateTables.STARTED_SERVICES + ) + foreground(args) + + mock_pty_spawn.assert_not_called() + mock_start_process.assert_called_once_with("worker") + mock_stop_process.assert_called_once_with("worker") + + captured = capsys.readouterr() + assert ( + f"Stopping worker in supervisor\n{Color.RED}Error stopping worker in supervisor: Stop process failed{Color.RESET}\nRestarting worker in background\n" + == captured.out + ) + + +@mock.patch("devservices.commands.foreground.pty.spawn") +@mock.patch("devservices.utils.supervisor.SupervisorManager.start_process") +@mock.patch("devservices.utils.supervisor.SupervisorManager.stop_process") +def test_foreground_start_process_exception( + mock_stop_process: mock.Mock, + mock_start_process: mock.Mock, + mock_pty_spawn: mock.Mock, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + config = { + "x-sentry-service-config": { + "version": 0.1, + "service_name": "example-service", + "dependencies": { + "worker": {"description": "Worker"}, + }, + "modes": {"default": ["worker"]}, + }, + } + + service_path = tmp_path / "example-service" + create_config_file(service_path, config) + os.chdir(service_path) + + programs_config = """ +[program:worker] +command=python worker.py +autostart=true +autorestart=true +""" + create_programs_conf_file(service_path, programs_config) + + mock_start_process.side_effect = SupervisorProcessError("Start process failed") + + args = Namespace(program_name="worker") + + with mock.patch("devservices.utils.state.STATE_DB_FILE", str(tmp_path / "state")): + state = State() + state.update_service_entry( + "example-service", "default", StateTables.STARTED_SERVICES + ) + foreground(args) + + mock_pty_spawn.assert_called_once_with(["python", "worker.py"]) + mock_start_process.assert_called_once_with("worker") + mock_stop_process.assert_called_once_with("worker") + + captured = capsys.readouterr() + assert ( + f"Stopping worker in supervisor\nStarting worker in foreground\nRestarting worker in background\n{Color.RED}Error restarting worker in background: Start process failed{Color.RESET}\n" + == captured.out + ) + + +@mock.patch("devservices.commands.foreground.pty.spawn") +@mock.patch("devservices.utils.supervisor.SupervisorManager.start_process") +@mock.patch("devservices.utils.supervisor.SupervisorManager.stop_process") +def test_foreground_with_starting_services( + mock_stop_process: mock.Mock, + mock_start_process: mock.Mock, + mock_pty_spawn: mock.Mock, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + config = { + "x-sentry-service-config": { + "version": 0.1, + "service_name": "example-service", + "dependencies": { + "worker": {"description": "Worker"}, + }, + "modes": {"default": ["worker"]}, + }, + } + + service_path = tmp_path / "example-service" + create_config_file(service_path, config) + os.chdir(service_path) + + programs_config = """ +[program:worker] +command=python worker.py +autostart=true +autorestart=true +""" + create_programs_conf_file(service_path, programs_config) + + args = Namespace(program_name="worker") + + with mock.patch("devservices.utils.state.STATE_DB_FILE", str(tmp_path / "state")): + state = State() + state.update_service_entry( + "example-service", "default", StateTables.STARTING_SERVICES + ) + foreground(args) + + # Verify calls + mock_pty_spawn.assert_called_once_with(["python", "worker.py"]) + + mock_start_process.assert_called_once_with("worker") + mock_stop_process.assert_called_once_with("worker") + + captured = capsys.readouterr() + assert ( + "Stopping worker in supervisor\nStarting worker in foreground\nRestarting worker in background\n" + == captured.out + ) + + +@mock.patch("devservices.commands.foreground.pty.spawn") +@mock.patch("devservices.utils.supervisor.SupervisorManager.start_process") +@mock.patch("devservices.utils.supervisor.SupervisorManager.stop_process") +def test_foreground_multiple_modes_and_dependencies( + mock_stop_process: mock.Mock, + mock_start_process: mock.Mock, + mock_pty_spawn: mock.Mock, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + config = { + "x-sentry-service-config": { + "version": 0.1, + "service_name": "example-service", + "dependencies": { + "redis": {"description": "Redis"}, + "postgres": {"description": "Postgres"}, + "worker": {"description": "Worker"}, + "consumer": {"description": "Consumer"}, + }, + "modes": { + "default": ["redis", "worker"], + "full": ["redis", "postgres", "worker", "consumer"], + }, + }, + "services": { + "redis": {"image": "redis:6.2.14-alpine"}, + "postgres": {"image": "postgres:13"}, + }, + } + + service_path = tmp_path / "example-service" + create_config_file(service_path, config) + os.chdir(service_path) + + programs_config = """ +[program:worker] +command=python worker.py +autostart=true +autorestart=true + +[program:consumer] +command=python consumer.py +autostart=true +autorestart=true +""" + create_programs_conf_file(service_path, programs_config) + + args = Namespace(program_name="consumer") + + with mock.patch("devservices.utils.state.STATE_DB_FILE", str(tmp_path / "state")): + state = State() + state.update_service_entry( + "example-service", "full", StateTables.STARTED_SERVICES + ) + foreground(args) + + # Verify calls + mock_pty_spawn.assert_called_once_with(["python", "consumer.py"]) + + mock_start_process.assert_called_once_with("consumer") + mock_stop_process.assert_called_once_with("consumer") + + captured = capsys.readouterr() + assert ( + "Stopping consumer in supervisor\nStarting consumer in foreground\nRestarting consumer in background\n" + == captured.out + ) + + +@mock.patch("devservices.commands.foreground.pty.spawn") +def test_foreground_no_active_modes( + mock_pty_spawn: mock.Mock, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + config = { + "x-sentry-service-config": { + "version": 0.1, + "service_name": "example-service", + "dependencies": { + "worker": {"description": "Worker"}, + }, + "modes": {"default": ["worker"], "other": []}, + }, + } + + service_path = tmp_path / "example-service" + create_config_file(service_path, config) + os.chdir(service_path) + + programs_config = """ +[program:worker] +command=python worker.py +autostart=true +autorestart=true +""" + create_programs_conf_file(service_path, programs_config) + + args = Namespace(program_name="worker") + + with mock.patch("devservices.utils.state.STATE_DB_FILE", str(tmp_path / "state")): + state = State() + state.update_service_entry( + "example-service", "other", StateTables.STARTED_SERVICES + ) + foreground(args) + + # Should not call pty.spawn since no supervisor programs are active + mock_pty_spawn.assert_not_called() + + captured = capsys.readouterr() + assert ( + captured.out + == f"{Color.RED}Program worker is not running in any active modes of example-service{Color.RESET}\n" + )