diff --git a/devservices/commands/down.py b/devservices/commands/down.py index 0d670e90..64068f63 100644 --- a/devservices/commands/down.py +++ b/devservices/commands/down.py @@ -15,12 +15,12 @@ from devservices.constants import DEVSERVICES_DEPENDENCIES_CACHE_DIR from devservices.constants import DEVSERVICES_DEPENDENCIES_CACHE_DIR_KEY 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 DependencyError from devservices.exceptions import DockerComposeError from devservices.exceptions import ServiceNotFoundError +from devservices.exceptions import SupervisorConfigError from devservices.exceptions import SupervisorError from devservices.utils.console import Console from devservices.utils.console import Status @@ -318,14 +318,16 @@ def bring_down_supervisor_programs( ) -> None: if len(supervisor_programs) == 0: 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, + + config_file_path = os.path.join( + service.repo_path, DEVSERVICES_DIR_NAME, CONFIG_FILE_NAME ) + try: + manager = SupervisorManager(service.name, config_file_path) + except SupervisorConfigError: + raise + with concurrent.futures.ThreadPoolExecutor() as executor: futures = [ executor.submit(_stop_supervisor_program, manager, program, status) diff --git a/devservices/commands/foreground.py b/devservices/commands/foreground.py index be2c0cae..d81b4ccd 100644 --- a/devservices/commands/foreground.py +++ b/devservices/commands/foreground.py @@ -9,9 +9,9 @@ from sentry_sdk import capture_exception +from devservices.constants import CONFIG_FILE_NAME 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 @@ -92,14 +92,15 @@ def foreground(args: Namespace) -> None: ) return - programs_config_path = os.path.join( - service.repo_path, f"{DEVSERVICES_DIR_NAME}/{PROGRAMS_CONF_FILE_NAME}" + config_file_path = os.path.join( + service.repo_path, DEVSERVICES_DIR_NAME, CONFIG_FILE_NAME ) - manager = SupervisorManager( - programs_config_path, - service_name=service.name, - ) + try: + manager = SupervisorManager(service.name, config_file_path) + except SupervisorConfigError as e: + capture_exception(e, level="info") + return try: program_command = manager.get_program_command(program_name) diff --git a/devservices/commands/logs.py b/devservices/commands/logs.py index 490478c4..3c202f6b 100644 --- a/devservices/commands/logs.py +++ b/devservices/commands/logs.py @@ -16,7 +16,6 @@ from devservices.constants import DEVSERVICES_DEPENDENCIES_CACHE_DIR_KEY from devservices.constants import DEVSERVICES_DIR_NAME from devservices.constants import MAX_LOG_LINES -from devservices.constants import PROGRAMS_CONF_FILE_NAME from devservices.exceptions import ConfigError from devservices.exceptions import ConfigNotFoundError from devservices.exceptions import DependencyError @@ -178,12 +177,11 @@ def _supervisor_logs( supervisor_logs: dict[str, str] = {} - programs_config_path = os.path.join( - service.repo_path, DEVSERVICES_DIR_NAME, PROGRAMS_CONF_FILE_NAME + config_file_path = os.path.join( + service.repo_path, DEVSERVICES_DIR_NAME, CONFIG_FILE_NAME ) - try: - manager = SupervisorManager(programs_config_path, service_name=service.name) + manager = SupervisorManager(service.name, config_file_path) except SupervisorConfigError as e: capture_exception(e) return supervisor_logs diff --git a/devservices/commands/serve.py b/devservices/commands/serve.py index 924a7e22..516b55b1 100644 --- a/devservices/commands/serve.py +++ b/devservices/commands/serve.py @@ -9,8 +9,8 @@ from sentry_sdk import capture_exception +from devservices.constants import CONFIG_FILE_NAME 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 SupervisorConfigError @@ -46,16 +46,24 @@ def serve(args: Namespace) -> None: console.failure(str(e)) exit(1) - programs_config_path = os.path.join( - service.repo_path, f"{DEVSERVICES_DIR_NAME}/{PROGRAMS_CONF_FILE_NAME}" + config_file_path = os.path.join( + service.repo_path, DEVSERVICES_DIR_NAME, CONFIG_FILE_NAME ) - if not os.path.exists(programs_config_path): - console.failure(f"No programs.conf file found in {programs_config_path}.") + + try: + manager = SupervisorManager(service.name, config_file_path) + except SupervisorConfigError as e: + capture_exception(e, level="info") + console.failure( + f"Unable to bring up devserver due to supervisor config error: {str(e)}" + ) + return + + if not manager.has_programs: + console.failure( + "No programs found in config. Please add the devserver in the `x-programs` block to your config.yml" + ) return - manager = SupervisorManager( - programs_config_path, - service_name=service.name, - ) try: devserver_command = manager.get_program_command("devserver") diff --git a/devservices/commands/status.py b/devservices/commands/status.py index 7f907f8b..830742fa 100644 --- a/devservices/commands/status.py +++ b/devservices/commands/status.py @@ -20,12 +20,12 @@ from devservices.constants import DEVSERVICES_DEPENDENCIES_CACHE_DIR from devservices.constants import DEVSERVICES_DEPENDENCIES_CACHE_DIR_KEY 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 DependencyError from devservices.exceptions import DockerComposeError from devservices.exceptions import ServiceNotFoundError +from devservices.exceptions import SupervisorConfigError from devservices.utils.console import Console from devservices.utils.dependencies import construct_dependency_graph from devservices.utils.dependencies import DependencyGraph @@ -103,16 +103,18 @@ def status(args: Namespace) -> None: console.warning(f"Status unavailable. {service.name} is not running standalone") return # Since exit(0) is captured as an internal_error by sentry - programs_config_path = os.path.join( - service.repo_path, f"{DEVSERVICES_DIR_NAME}/{PROGRAMS_CONF_FILE_NAME}" + config_file_path = os.path.join( + service.repo_path, DEVSERVICES_DIR_NAME, CONFIG_FILE_NAME ) process_statuses = {} - if os.path.exists(programs_config_path): - supervisor_manager = SupervisorManager( - programs_config_path, - service.name, - ) + + try: + supervisor_manager = SupervisorManager(service.name, config_file_path) process_statuses = supervisor_manager.get_all_process_info() + except SupervisorConfigError as e: + capture_exception(e) + console.failure(str(e)) + exit(1) try: status_tree = get_status_for_service(service, process_statuses) diff --git a/devservices/commands/up.py b/devservices/commands/up.py index 259e7996..ca180db6 100644 --- a/devservices/commands/up.py +++ b/devservices/commands/up.py @@ -17,7 +17,6 @@ from devservices.constants import DEVSERVICES_DEPENDENCIES_CACHE_DIR from devservices.constants import DEVSERVICES_DEPENDENCIES_CACHE_DIR_KEY 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 ContainerHealthcheckFailedError @@ -25,6 +24,7 @@ from devservices.exceptions import DockerComposeError from devservices.exceptions import ModeDoesNotExistError from devservices.exceptions import ServiceNotFoundError +from devservices.exceptions import SupervisorConfigError from devservices.exceptions import SupervisorError from devservices.utils.console import Console from devservices.utils.console import Status @@ -399,15 +399,16 @@ def bring_up_supervisor_programs( f"Cannot bring up supervisor programs from outside the service repository. Please run the command from the service repository ({service.repo_path})" ) 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, + config_file_path = os.path.join( + service.repo_path, DEVSERVICES_DIR_NAME, CONFIG_FILE_NAME ) + try: + manager = SupervisorManager(service.name, config_file_path) + except SupervisorConfigError: + raise + status.info("Starting supervisor daemon") manager.start_supervisor_daemon() diff --git a/devservices/configs/service_config.py b/devservices/configs/service_config.py index bb91a7bc..44dc8b2e 100644 --- a/devservices/configs/service_config.py +++ b/devservices/configs/service_config.py @@ -10,10 +10,10 @@ from devservices.constants import CONFIG_FILE_NAME from devservices.constants import DependencyType from devservices.constants import DEVSERVICES_DIR_NAME -from devservices.constants import PROGRAMS_CONF_FILE_NAME from devservices.exceptions import ConfigNotFoundError from devservices.exceptions import ConfigParseError from devservices.exceptions import ConfigValidationError +from devservices.utils.supervisor import ProgramData from devservices.utils.supervisor import SupervisorManager VALID_VERSIONS = [0.1] @@ -91,8 +91,10 @@ def load_service_config_from_file(repo_path: str) -> ServiceConfig: docker_compose_services = config.get("services", {}).keys() - supervisor_programs = load_supervisor_programs_from_file( - repo_path, service_config_data.get("service_name") + supervisor_programs = load_supervisor_programs_from_programs_data( + config_path, + service_config_data.get("service_name"), + config.get("x-programs", {}), ) valid_dependency_keys = {field.name for field in fields(Dependency)} @@ -113,7 +115,7 @@ def load_service_config_from_file(repo_path: str) -> ServiceConfig: dependency_type = DependencyType.COMPOSE else: raise ConfigValidationError( - f"Dependency '{key}' is not remote but is not defined in docker-compose services or programs file" + f"Dependency '{key}' is not remote but is not defined in docker-compose services or x-programs" ) else: dependency_type = DependencyType.SERVICE @@ -142,13 +144,15 @@ def load_service_config_from_file(repo_path: str) -> ServiceConfig: return service_config -def load_supervisor_programs_from_file(repo_path: str, service_name: str) -> set[str]: - programs_config_path = os.path.join( - repo_path, DEVSERVICES_DIR_NAME, PROGRAMS_CONF_FILE_NAME - ) - if not os.path.exists(programs_config_path): +def load_supervisor_programs_from_programs_data( + service_config_path: str, service_name: str, programs_data: ProgramData +) -> set[str]: + if not programs_data: return set() - manager = SupervisorManager(programs_config_path, service_name=service_name) + + manager = SupervisorManager( + service_name=service_name, service_config_path=service_config_path + ) opts = ServerOptions() opts.configfile = manager.config_file_path opts.process_config() diff --git a/devservices/constants.py b/devservices/constants.py index 91292aba..5b893c46 100644 --- a/devservices/constants.py +++ b/devservices/constants.py @@ -25,7 +25,6 @@ class DependencyType(StrEnum): MINIMUM_DOCKER_COMPOSE_VERSION = "2.29.7" DEVSERVICES_DIR_NAME = "devservices" CONFIG_FILE_NAME = "config.yml" -PROGRAMS_CONF_FILE_NAME = "programs.conf" DOCKER_CONFIG_DIR = os.environ.get("DOCKER_CONFIG", os.path.expanduser("~/.docker")) DOCKER_USER_PLUGIN_DIR = os.path.join(DOCKER_CONFIG_DIR, "cli-plugins/") diff --git a/devservices/utils/supervisor.py b/devservices/utils/supervisor.py index 26adb16b..6403ed82 100644 --- a/devservices/utils/supervisor.py +++ b/devservices/utils/supervisor.py @@ -10,6 +10,7 @@ from enum import IntEnum from typing import TypedDict +import yaml from sentry_sdk import capture_exception from supervisor.options import ServerOptions @@ -91,24 +92,81 @@ class ProcessInfo(TypedDict): group: str +class SupervisorProgramConfig(TypedDict, total=False): + """Supervisor program configuration.""" + + command: str + autostart: str | bool + autorestart: str | bool + directory: str + environment: str + user: str + priority: str | int + startsecs: str | int + startretries: str | int + stdout_logfile: str + stderr_logfile: str + redirect_stderr: str | bool + + +ProgramData = dict[str, SupervisorProgramConfig] + + +# Default values for supervisor program configuration +SUPERVISOR_PROGRAM_DEFAULTS = { + "autostart": "false", + "autorestart": "true", +} + + class SupervisorManager: - def __init__(self, config_file_path: str, service_name: str) -> None: + def __init__( + self, + service_name: str, + service_config_path: str, + ) -> None: self.service_name = service_name - if not os.path.exists(config_file_path): - raise SupervisorConfigError( - f"Config file {config_file_path} does not exist" - ) self.socket_path = os.path.join( DEVSERVICES_SUPERVISOR_CONFIG_DIR, f"{service_name}.sock" ) - self.config_file_path = self._extend_config_file(config_file_path) - def _extend_config_file(self, config_file_path: str) -> str: - """Extend the supervisor config file passed into devservices with configuration settings that should be abstracted from users.""" + # Load service config and extract x-programs data + if os.path.exists(service_config_path): + with open(service_config_path, "r", encoding="utf-8") as stream: + config = yaml.safe_load(stream) + else: + raise SupervisorConfigError(f"Config file {service_config_path} not found") + + if config is None: + raise SupervisorConfigError(f"Config file {service_config_path} is empty") + programs_data: ProgramData = config.get("x-programs", {}) + + self.has_programs = len(programs_data.keys()) > 0 + + # Generate supervisor config file from x-programs data + self.config_file_path = self._generate_config_from_programs_data(programs_data) + + def _generate_config_from_programs_data(self, programs_data: ProgramData) -> str: config = configparser.ConfigParser() - config.read(config_file_path) + # Add program sections + for program_name, program_config in programs_data.items(): + section_name = f"program:{program_name}" + config[section_name] = {} + + # Apply defaults for any missing configuration values + program_config_with_defaults = { + **SUPERVISOR_PROGRAM_DEFAULTS, + **program_config, + } + + for key, value in program_config_with_defaults.items(): + if isinstance(value, bool): + config[section_name][key] = str(value).lower() + else: + config[section_name][key] = str(value) + os.makedirs(DEVSERVICES_SUPERVISOR_CONFIG_DIR, exist_ok=True) # Set unix http server to use the socket path @@ -129,13 +187,13 @@ def _extend_config_file(self, config_file_path: str) -> str: "supervisor.rpcinterface_factory": "supervisor.rpcinterface:make_main_rpcinterface" } - extended_config_file_path = os.path.join( + config_file_path = os.path.join( DEVSERVICES_SUPERVISOR_CONFIG_DIR, f"{self.service_name}.processes.conf" ) - with open(extended_config_file_path, "w") as f: + with open(config_file_path, "w") as f: config.write(f) - return extended_config_file_path + return config_file_path def _get_rpc_client(self) -> xmlrpc.client.ServerProxy: """Get or create an XML-RPC client that connects to the supervisor daemon.""" @@ -317,6 +375,9 @@ def tail_program_logs(self, program_name: str) -> None: def get_all_process_info(self) -> dict[str, ProcessInfo]: """Get status information for all supervisor programs.""" # Check if supervisor client is up first, return empty list if down + if not self.has_programs: + return {} + try: client = self._get_rpc_client() client.supervisor.getState() diff --git a/testing/utils.py b/testing/utils.py index d63b7f35..9ea28fc7 100644 --- a/testing/utils.py +++ b/testing/utils.py @@ -8,7 +8,6 @@ import yaml from devservices.constants import DEVSERVICES_DIR_NAME -from devservices.constants import PROGRAMS_CONF_FILE_NAME TESTING_DIR = os.path.abspath(os.path.dirname(__file__)) @@ -27,14 +26,6 @@ def create_config_file( yaml.dump(config, f, sort_keys=False, default_flow_style=False) -def create_programs_conf_file(tmp_path: Path, config: str) -> None: - devservices_dir = Path(tmp_path, DEVSERVICES_DIR_NAME) - devservices_dir.mkdir(parents=True, exist_ok=True) - tmp_file = Path(devservices_dir, PROGRAMS_CONF_FILE_NAME) - with tmp_file.open("w") as f: - f.write(config) - - def run_git_command(command: list[str], cwd: Path) -> None: subprocess.run( ["git", *command], cwd=cwd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL diff --git a/tests/commands/test_down.py b/tests/commands/test_down.py index 61c6f5eb..f0e4731a 100644 --- a/tests/commands/test_down.py +++ b/tests/commands/test_down.py @@ -16,10 +16,8 @@ from devservices.constants import CONFIG_FILE_NAME 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 ServiceNotFoundError -from devservices.exceptions import SupervisorConfigError from devservices.exceptions import SupervisorError from devservices.utils.dependencies import install_and_verify_dependencies from devservices.utils.docker_compose import DockerComposeCommand @@ -29,7 +27,6 @@ from devservices.utils.state import StateTables from testing.utils import create_config_file from testing.utils import create_mock_git_repo -from testing.utils import create_programs_conf_file from testing.utils import run_git_command @@ -1367,6 +1364,11 @@ def test_down_supervisor_program_error( }, "modes": {"default": ["supervisor-program"]}, }, + "x-programs": { + "supervisor-program": { + "command": "echo 'Hello, world!'", + } + }, "services": { "redis": {"image": "redis:6.2.14-alpine"}, "clickhouse": { @@ -1379,12 +1381,6 @@ def test_down_supervisor_program_error( create_config_file(service_path, config) os.chdir(service_path) - supervisor_program_config = """ -[program:supervisor-program] -command=echo "Hello, world!" -""" - create_programs_conf_file(service_path, supervisor_program_config) - args = Namespace(service_name=None, debug=False, exclude_local=False) with ( @@ -1432,6 +1428,11 @@ def test_down_supervisor_program_success( }, "modes": {"default": ["supervisor-program"]}, }, + "x-programs": { + "supervisor-program": { + "command": "echo 'Hello, world!'", + } + }, "services": { "redis": {"image": "redis:6.2.14-alpine"}, "clickhouse": { @@ -1444,12 +1445,6 @@ def test_down_supervisor_program_success( create_config_file(service_path, config) os.chdir(service_path) - supervisor_program_config = """ -[program:supervisor-program] -command=echo "Hello, world!" -""" - create_programs_conf_file(service_path, supervisor_program_config) - args = Namespace(service_name=None, debug=False, exclude_local=False) with ( @@ -1478,42 +1473,6 @@ def test_down_supervisor_program_success( assert "example-service stopped" in captured.out.strip() -@mock.patch("devservices.utils.supervisor.SupervisorManager.stop_supervisor_daemon") -@mock.patch("devservices.utils.supervisor.SupervisorManager.stop_process") -def test_bring_down_supervisor_programs_no_programs_config( - mock_stop_process: mock.Mock, - mock_stop_supervisor_daemon: mock.Mock, - tmp_path: Path, -) -> None: - service_config = ServiceConfig( - version=0.1, - service_name="test-service", - dependencies={ - "supervisor-program": Dependency( - description="Supervisor program", - dependency_type=DependencyType.SUPERVISOR, - ), - }, - modes={"default": ["supervisor-program"]}, - ) - service = Service( - name="test-service", - repo_path=str(tmp_path), - config=service_config, - ) - - status = mock.MagicMock() - - with pytest.raises( - SupervisorConfigError, - match=f"Config file {tmp_path / DEVSERVICES_DIR_NAME / PROGRAMS_CONF_FILE_NAME} does not exist", - ): - bring_down_supervisor_programs(["supervisor-program"], service, status) - - mock_stop_supervisor_daemon.assert_not_called() - mock_stop_process.assert_not_called() - - @mock.patch("devservices.utils.supervisor.SupervisorManager.stop_supervisor_daemon") @mock.patch("devservices.utils.supervisor.SupervisorManager.stop_process") def test_bring_down_supervisor_programs_empty_list( @@ -1573,14 +1532,20 @@ def test_bring_down_supervisor_programs_success( config=service_config, ) - programs_conf_path = tmp_path / DEVSERVICES_DIR_NAME / PROGRAMS_CONF_FILE_NAME - - create_programs_conf_file( - programs_conf_path, - """ -[program:supervisor-program] -command=echo "Hello, world!" -""", + create_config_file( + tmp_path, + { + "x-sentry-service-config": { + "version": 0.1, + "service_name": "test-service", + }, + "x-programs": { + "supervisor-program": { + "command": "echo 'Hello, world!'", + } + }, + "services": {}, + }, ) status = mock.MagicMock() diff --git a/tests/commands/test_foreground.py b/tests/commands/test_foreground.py index e0c43007..fc6b4f47 100644 --- a/tests/commands/test_foreground.py +++ b/tests/commands/test_foreground.py @@ -18,7 +18,6 @@ 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") @@ -40,6 +39,11 @@ def test_foreground_success( }, "modes": {"default": ["redis", "worker"]}, }, + "x-programs": { + "worker": { + "command": "python worker.py", + }, + }, "services": { "redis": {"image": "redis:6.2.14-alpine"}, }, @@ -49,14 +53,6 @@ def test_foreground_success( 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")): @@ -86,20 +82,17 @@ def test_foreground_service_not_running( }, "modes": {"default": ["worker"]}, }, + "x-programs": { + "worker": { + "command": "python worker.py", + }, + }, } 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")): @@ -129,6 +122,11 @@ def test_foreground_program_not_in_supervisor_programs( }, "modes": {"default": ["redis", "worker"]}, }, + "x-programs": { + "worker": { + "command": "python worker.py", + }, + }, "services": { "redis": {"image": "redis:6.2.14-alpine"}, }, @@ -138,14 +136,6 @@ def test_foreground_program_not_in_supervisor_programs( 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")): @@ -180,6 +170,11 @@ def test_foreground_program_not_in_active_modes( }, "modes": {"default": ["redis"], "other": ["worker"]}, }, + "x-programs": { + "worker": { + "command": "python worker.py", + }, + }, "services": { "redis": {"image": "redis:6.2.14-alpine"}, }, @@ -189,14 +184,6 @@ def test_foreground_program_not_in_active_modes( 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")): @@ -254,7 +241,7 @@ def test_foreground_programs_conf_not_found( 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" + f"{Color.RED}Dependency 'worker' is not remote but is not defined in docker-compose services or x-programs{Color.RESET}\n" == captured.out ) @@ -328,20 +315,17 @@ def test_foreground_supervisor_config_error( }, "modes": {"default": ["worker"]}, }, + "x-programs": { + "worker": { + "command": "python worker.py", + }, + }, } 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") @@ -383,20 +367,17 @@ def test_foreground_pty_spawn_exception( }, "modes": {"default": ["worker"]}, }, + "x-programs": { + "worker": { + "command": "python worker.py", + }, + }, } 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") @@ -437,20 +418,17 @@ def test_foreground_stop_process_exception( }, "modes": {"default": ["worker"]}, }, + "x-programs": { + "worker": { + "command": "python worker.py", + }, + }, } 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") @@ -492,20 +470,17 @@ def test_foreground_start_process_exception( }, "modes": {"default": ["worker"]}, }, + "x-programs": { + "worker": { + "command": "python worker.py", + }, + }, } 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") @@ -547,20 +522,17 @@ def test_foreground_with_starting_services( }, "modes": {"default": ["worker"]}, }, + "x-programs": { + "worker": { + "command": "python worker.py", + }, + }, } 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")): @@ -608,6 +580,14 @@ def test_foreground_multiple_modes_and_dependencies( "full": ["redis", "postgres", "worker", "consumer"], }, }, + "x-programs": { + "worker": { + "command": "python worker.py", + }, + "consumer": { + "command": "python consumer.py", + }, + }, "services": { "redis": {"image": "redis:6.2.14-alpine"}, "postgres": {"image": "postgres:13"}, @@ -618,19 +598,6 @@ def test_foreground_multiple_modes_and_dependencies( 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")): @@ -668,20 +635,17 @@ def test_foreground_no_active_modes( }, "modes": {"default": ["worker"], "other": []}, }, + "x-programs": { + "worker": { + "command": "python worker.py", + }, + }, } 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")): diff --git a/tests/commands/test_list_services.py b/tests/commands/test_list_services.py index 8baa76c0..3ced4b2a 100644 --- a/tests/commands/test_list_services.py +++ b/tests/commands/test_list_services.py @@ -161,7 +161,7 @@ def test_list_running_services_config_error( assert ( captured.out - == "\x1b[0;33mexample-service was found with an invalid config\x1b[0m\n\x1b[0;31mDependency 'clickhouse' is not remote but is not defined in docker-compose services or programs file\x1b[0m\n" + == "\x1b[0;33mexample-service was found with an invalid config\x1b[0m\n\x1b[0;31mDependency 'clickhouse' is not remote but is not defined in docker-compose services or x-programs\x1b[0m\n" ) diff --git a/tests/commands/test_logs.py b/tests/commands/test_logs.py index 388bd5f7..5e80e7a0 100644 --- a/tests/commands/test_logs.py +++ b/tests/commands/test_logs.py @@ -14,7 +14,6 @@ from devservices.constants import Color from devservices.constants import CONFIG_FILE_NAME from devservices.constants import DEVSERVICES_DIR_NAME -from devservices.constants import PROGRAMS_CONF_FILE_NAME from devservices.exceptions import DependencyError from devservices.exceptions import DockerComposeError from devservices.exceptions import SupervisorError @@ -22,7 +21,6 @@ from devservices.utils.state import StateTables from testing.utils import create_config_file from testing.utils import create_mock_git_repo -from testing.utils import create_programs_conf_file @mock.patch("devservices.commands.logs.get_docker_compose_commands_to_run") @@ -410,19 +408,17 @@ def test_logs_with_supervisor_dependencies( }, "modes": {"default": ["redis", "worker"]}, }, + "x-programs": { + "worker": { + "command": "python run worker", + }, + }, "services": { "redis": {"image": "redis:6.2.14-alpine"}, }, } create_config_file(test_service_repo_path, config) - supervisor_config = """ -[program:worker] -command=python run worker -""" - - create_programs_conf_file(test_service_repo_path, supervisor_config) - args = Namespace(service_name="test-service") mock_get_service_entries.return_value = ["test-service"] mock_install_and_verify_dependencies.return_value = set() @@ -466,17 +462,14 @@ def test_supervisor_logs_no_config_file( }, "modes": {"default": ["worker"]}, }, + "x-programs": { + "worker": { + "command": "python run worker", + }, + }, } create_config_file(test_service_repo_path, config) - supervisor_config = """ -[program:worker] -command=python run worker -""" - - # Create programs.conf file for supervisor dependency - create_programs_conf_file(test_service_repo_path, supervisor_config) - service_config = load_service_config_from_file(str(test_service_repo_path)) service = Service( name=service_config.service_name, @@ -510,17 +503,14 @@ def test_supervisor_logs_manager_creation_error( }, "modes": {"default": ["worker"]}, }, + "x-programs": { + "worker": { + "command": "python run worker", + }, + }, } create_config_file(test_service_repo_path, config) - supervisor_config = """ -[program:worker] -command=python run worker -""" - - # Create programs.conf file for supervisor dependency - create_programs_conf_file(test_service_repo_path, supervisor_config) - # Load the service from config from devservices.configs.service_config import load_service_config_from_file @@ -646,19 +636,16 @@ def test_supervisor_logs_success( }, "modes": {"default": ["worker"]}, }, + "x-programs": { + "worker": { + "command": "python run worker", + }, + }, } create_config_file(test_service_repo_path, config) # Create the programs.conf file - programs_config_path = ( - test_service_repo_path / DEVSERVICES_DIR_NAME / PROGRAMS_CONF_FILE_NAME - ) - supervisor_config = """ -[program:worker] -command=python run worker -""" - - create_programs_conf_file(test_service_repo_path, supervisor_config) + config_file_path = test_service_repo_path / DEVSERVICES_DIR_NAME / CONFIG_FILE_NAME # Load the service from config from devservices.configs.service_config import load_service_config_from_file @@ -678,7 +665,7 @@ def test_supervisor_logs_success( assert result == {"worker": "worker program logs"} mock_supervisor_manager_class.assert_called_once_with( - str(programs_config_path), service_name="test-service" + "test-service", str(config_file_path) ) mock_manager.get_program_logs.assert_called_once_with("worker") @@ -701,19 +688,16 @@ def test_supervisor_logs_supervisor_error( }, "modes": {"default": ["worker"]}, }, + "x-programs": { + "worker": { + "command": "python run worker", + }, + }, } create_config_file(test_service_repo_path, config) # Create the programs.conf file - programs_config_path = ( - test_service_repo_path / DEVSERVICES_DIR_NAME / PROGRAMS_CONF_FILE_NAME - ) - supervisor_config = """ -[program:worker] -command=python run worker -""" - - create_programs_conf_file(test_service_repo_path, supervisor_config) + config_file_path = test_service_repo_path / DEVSERVICES_DIR_NAME / CONFIG_FILE_NAME # Load the service from config from devservices.configs.service_config import load_service_config_from_file @@ -733,6 +717,6 @@ def test_supervisor_logs_supervisor_error( assert result == {"worker": "Error getting logs for worker: Failed to get logs"} mock_supervisor_manager_class.assert_called_once_with( - str(programs_config_path), service_name="test-service" + "test-service", str(config_file_path) ) mock_manager.get_program_logs.assert_called_once_with("worker") diff --git a/tests/commands/test_serve.py b/tests/commands/test_serve.py index 0f4c9cf4..a93b9768 100644 --- a/tests/commands/test_serve.py +++ b/tests/commands/test_serve.py @@ -9,11 +9,10 @@ import pytest from devservices.commands.serve import serve +from devservices.constants import Color from devservices.constants import CONFIG_FILE_NAME from devservices.constants import DEVSERVICES_DIR_NAME -from devservices.constants import PROGRAMS_CONF_FILE_NAME from testing.utils import create_config_file -from testing.utils import create_programs_conf_file @patch("devservices.commands.serve.pty.spawn") @@ -31,6 +30,11 @@ def test_serve_success( }, "modes": {"default": ["redis", "clickhouse"]}, }, + "x-programs": { + "devserver": { + "command": "run devserver", + } + }, "services": { "redis": {"image": "redis:6.2.14-alpine"}, "clickhouse": { @@ -41,13 +45,6 @@ def test_serve_success( service_path = tmp_path / "example-service" create_config_file(service_path, config) os.chdir(service_path) - programs_config = """ -[program:devserver] -command=run devserver -autostart=true -autorestart=true -""" - create_programs_conf_file(service_path, programs_config) args = Namespace(extra=[]) @@ -90,7 +87,7 @@ def test_serve_devservices_config_not_found( out, err = capsys.readouterr() assert ( out - == f"\x1b[0;31mNo programs.conf file found in {service_path}/{DEVSERVICES_DIR_NAME}/{PROGRAMS_CONF_FILE_NAME}.\x1b[0m\n" + == f"{Color.RED}No programs found in config. Please add the devserver in the `x-programs` block to your config.yml{Color.RESET}\n" ) mock_pty_spawn.assert_not_called() @@ -112,7 +109,7 @@ def test_serve_programs_conf_not_found( out, err = capsys.readouterr() assert ( out - == f"\x1b[0;31mNo devservices configuration found in {service_path}/{DEVSERVICES_DIR_NAME}/{CONFIG_FILE_NAME}. Please run the command from a directory with a valid devservices configuration.\x1b[0m\n" + == f"{Color.RED}No devservices configuration found in {service_path}/{DEVSERVICES_DIR_NAME}/{CONFIG_FILE_NAME}. Please run the command from a directory with a valid devservices configuration.{Color.RESET}\n" ) mock_pty_spawn.assert_not_called() @@ -133,6 +130,11 @@ def test_serve_devserver_command_not_found( }, "modes": {"default": ["redis", "clickhouse"]}, }, + "x-programs": { + "consumer": { + "command": "run consumer", + } + }, "services": { "redis": {"image": "redis:6.2.14-alpine"}, "clickhouse": { @@ -143,13 +145,6 @@ def test_serve_devserver_command_not_found( service_path = tmp_path / "example-service" create_config_file(service_path, config) os.chdir(service_path) - programs_config = """ -[program:consumer] -command=run consumer -autostart=true -autorestart=true -""" - create_programs_conf_file(service_path, programs_config) args = Namespace(extra=[]) @@ -158,6 +153,6 @@ def test_serve_devserver_command_not_found( out, err = capsys.readouterr() assert ( out - == "\x1b[0;31mError when getting devserver command: Program devserver not found in config\x1b[0m\n" + == f"{Color.RED}Error when getting devserver command: Program devserver not found in config{Color.RESET}\n" ) mock_pty_spawn.assert_not_called() diff --git a/tests/commands/test_status.py b/tests/commands/test_status.py index 34782043..5e5a3f95 100644 --- a/tests/commands/test_status.py +++ b/tests/commands/test_status.py @@ -36,7 +36,6 @@ from devservices.utils.supervisor import SupervisorProcessState from testing.utils import create_config_file from testing.utils import create_mock_git_repo -from testing.utils import create_programs_conf_file from testing.utils import run_git_command @@ -740,32 +739,38 @@ def test_status_service_not_found( assert "Service not found" in captured.out -@mock.patch("devservices.commands.status.find_matching_service") @mock.patch("devservices.commands.status.install_and_verify_dependencies") +@mock.patch( + "devservices.commands.status.SupervisorManager.get_all_process_info", + return_value={}, +) def test_status_dependency_error( + mock_supervisor_get_all_process_info: mock.Mock, mock_install_and_verify_dependencies: mock.Mock, - mock_find_matching_service: mock.Mock, capsys: pytest.CaptureFixture[str], tmp_path: Path, ) -> None: with ( mock.patch("devservices.utils.state.STATE_DB_FILE", str(tmp_path / "state")), + mock.patch( + "devservices.utils.services.get_coderoot", + return_value=str(tmp_path), + ), ): state = State() state.update_service_entry( "test-service", "default", StateTables.STARTED_SERVICES ) - service = Service( - name="test-service", - repo_path=str(tmp_path), - config=ServiceConfig( - version=0.1, - service_name="test-service", - dependencies={}, - modes={"default": []}, - ), - ) - mock_find_matching_service.return_value = service + config_file = { + "x-sentry-service-config": { + "version": 0.1, + "service_name": "test-service", + "dependencies": {}, + "modes": {"default": []}, + }, + } + create_config_file(tmp_path / "test-service", config_file) + os.chdir(tmp_path / "test-service") mock_install_and_verify_dependencies.side_effect = DependencyError( repo_name="test-service", repo_link=str(tmp_path), branch="main" ) @@ -776,8 +781,7 @@ def test_status_dependency_error( assert exc_info.value.code == 1 - mock_find_matching_service.assert_called_once_with("test-service") - mock_install_and_verify_dependencies.assert_called_once_with(service) + mock_install_and_verify_dependencies.assert_called_once() captured = capsys.readouterr() assert ( @@ -785,7 +789,6 @@ def test_status_dependency_error( ) -@mock.patch("devservices.commands.status.find_matching_service") @mock.patch( "devservices.commands.status.install_and_verify_dependencies", return_value=set() ) @@ -795,28 +798,30 @@ def test_status_docker_compose_error( mock_generate_service_status_tree: mock.Mock, mock_get_status_json_results: mock.Mock, mock_install_and_verify_dependencies: mock.Mock, - mock_find_matching_service: mock.Mock, capsys: pytest.CaptureFixture[str], tmp_path: Path, ) -> None: with ( mock.patch("devservices.utils.state.STATE_DB_FILE", str(tmp_path / "state")), + mock.patch( + "devservices.utils.services.get_coderoot", + return_value=str(tmp_path), + ), ): state = State() state.update_service_entry( "test-service", "default", StateTables.STARTED_SERVICES ) - service = Service( - name="test-service", - repo_path=str(tmp_path), - config=ServiceConfig( - version=0.1, - service_name="test-service", - dependencies={}, - modes={"default": []}, - ), - ) - mock_find_matching_service.return_value = service + config_file = { + "x-sentry-service-config": { + "version": 0.1, + "service_name": "test-service", + "dependencies": {}, + "modes": {"default": []}, + }, + } + create_config_file(tmp_path / "test-service", config_file) + os.chdir(tmp_path / "test-service") mock_get_status_json_results.side_effect = DockerComposeError( command="docker compose ps", returncode=1, @@ -1087,6 +1092,11 @@ def test_status_with_supervisor_programs( }, "modes": {"default": ["clickhouse", "worker"]}, }, + "x-programs": { + "worker": { + "command": "python worker.py", + }, + }, "services": { "clickhouse": { "image": "altinity/clickhouse-server:23.8.11.29.altinitystable" @@ -1095,15 +1105,6 @@ def test_status_with_supervisor_programs( } create_config_file(test_service_repo_path, config) - supervisor_program_config = """ -[program:worker] -command=python worker.py -autostart=false -autorestart=true -""" - - create_programs_conf_file(test_service_repo_path, supervisor_program_config) - # Commit the config files so find_matching_service can discover them run_git_command(["add", "."], cwd=test_service_repo_path) run_git_command(["commit", "-m", "Add config"], cwd=test_service_repo_path) diff --git a/tests/commands/test_up.py b/tests/commands/test_up.py index 126f82a1..bb92dd74 100644 --- a/tests/commands/test_up.py +++ b/tests/commands/test_up.py @@ -17,7 +17,6 @@ from devservices.constants import DependencyType from devservices.constants import DEVSERVICES_DIR_NAME from devservices.constants import HEALTHCHECK_TIMEOUT -from devservices.constants import PROGRAMS_CONF_FILE_NAME from devservices.exceptions import ConfigError from devservices.exceptions import ContainerHealthcheckFailedError from devservices.exceptions import DependencyError @@ -32,7 +31,6 @@ from devservices.utils.state import StateTables from testing.utils import create_config_file from testing.utils import create_mock_git_repo -from testing.utils import create_programs_conf_file from testing.utils import run_git_command @@ -2474,6 +2472,11 @@ def test_up_supervisor_program( }, "modes": {"default": ["supervisor-program"]}, }, + "x-programs": { + "supervisor-program": { + "command": "python run program", + } + }, "services": {}, } @@ -2481,13 +2484,6 @@ def test_up_supervisor_program( create_config_file(service_path, config) os.chdir(service_path) - supervisor_program_config = """ -[program:supervisor-program] -command=echo "Hello, world!" -""" - - create_programs_conf_file(service_path, supervisor_program_config) - args = Namespace( service_name=None, debug=False, mode="default", exclude_local=False ) @@ -2571,6 +2567,11 @@ def test_up_supervisor_program_error( }, "modes": {"default": ["supervisor-program"]}, }, + "x-programs": { + "supervisor-program": { + "command": "python run program", + } + }, "services": {}, } @@ -2578,13 +2579,6 @@ def test_up_supervisor_program_error( create_config_file(service_path, config) os.chdir(service_path) - supervisor_program_config = """ -[program:supervisor-program] -command=echo "Hello, world!" -""" - - create_programs_conf_file(service_path, supervisor_program_config) - args = Namespace( service_name=None, debug=False, mode="default", exclude_local=False ) @@ -2654,7 +2648,7 @@ def test_bring_up_supervisor_programs_no_programs_config( with pytest.raises( SupervisorConfigError, - match=f"Config file {tmp_path / DEVSERVICES_DIR_NAME / PROGRAMS_CONF_FILE_NAME} does not exist", + match=f"Config file {tmp_path / DEVSERVICES_DIR_NAME / CONFIG_FILE_NAME} not found", ): os.chdir(tmp_path) bring_up_supervisor_programs(service, ["supervisor-program"], status) @@ -2722,15 +2716,25 @@ def test_bring_up_supervisor_programs_success( config=service_config, ) - programs_conf_path = tmp_path / DEVSERVICES_DIR_NAME / PROGRAMS_CONF_FILE_NAME + # Create config file with x-programs + config = { + "x-sentry-service-config": { + "version": 0.1, + "service_name": "test-service", + "dependencies": { + "supervisor-program": {"description": "Supervisor program"}, + }, + "modes": {"default": ["supervisor-program"]}, + }, + "x-programs": { + "supervisor-program": { + "command": "echo 'Hello, world!'", + } + }, + "services": {}, + } - create_programs_conf_file( - programs_conf_path, - """ -[program:supervisor-program] -command=echo "Hello, world!" -""", - ) + create_config_file(tmp_path, config) status = mock.MagicMock() @@ -2777,16 +2781,25 @@ def test_bring_up_supervisor_programs_wrong_directory( config=service_config, ) - programs_conf_path = ( - service_repo_path / DEVSERVICES_DIR_NAME / PROGRAMS_CONF_FILE_NAME - ) - create_programs_conf_file( - programs_conf_path, - """ -[program:supervisor-program] -command=echo "Hello, world!" -""", - ) + # Create config file with x-programs + config = { + "x-sentry-service-config": { + "version": 0.1, + "service_name": "test-service", + "dependencies": { + "supervisor-program": {"description": "Supervisor program"}, + }, + "modes": {"default": ["supervisor-program"]}, + }, + "x-programs": { + "supervisor-program": { + "command": "echo 'Hello, world!'", + } + }, + "services": {}, + } + + create_config_file(service_repo_path, config) status = mock.MagicMock() diff --git a/tests/configs/test_service_config.py b/tests/configs/test_service_config.py index 695a3ab9..57d5dae7 100644 --- a/tests/configs/test_service_config.py +++ b/tests/configs/test_service_config.py @@ -2,17 +2,20 @@ from dataclasses import asdict from pathlib import Path +from typing import cast import pytest from devservices.configs.service_config import load_service_config_from_file -from devservices.configs.service_config import load_supervisor_programs_from_file +from devservices.configs.service_config import ( + load_supervisor_programs_from_programs_data, +) from devservices.constants import DependencyType from devservices.exceptions import ConfigNotFoundError from devservices.exceptions import ConfigParseError from devservices.exceptions import ConfigValidationError +from devservices.utils.supervisor import ProgramData from testing.utils import create_config_file -from testing.utils import create_programs_conf_file @pytest.mark.parametrize( @@ -325,7 +328,7 @@ def test_load_service_config_from_file_no_matching_docker_compose_service( load_service_config_from_file(str(tmp_path)) assert ( str(e.value) - == "Dependency 'example-dependency' is not remote but is not defined in docker-compose services or programs file" + == "Dependency 'example-dependency' is not remote but is not defined in docker-compose services or x-programs" ) @@ -483,7 +486,7 @@ def test_load_service_config_from_file_no_programs_file(tmp_path: Path) -> None: load_service_config_from_file(str(tmp_path)) assert ( str(e.value) - == "Dependency 'example-program' is not remote but is not defined in docker-compose services or programs file" + == "Dependency 'example-program' is not remote but is not defined in docker-compose services or x-programs" ) @@ -502,6 +505,12 @@ def test_load_service_config_from_file_valid_programs_file(tmp_path: Path) -> No }, "modes": {"default": ["example-dependency", "example-program"]}, }, + "x-programs": { + "example-program": { + "command": "python run program", + "autostart": True, + } + }, "services": { "example-dependency": { "image": "example-dependency", @@ -510,12 +519,6 @@ def test_load_service_config_from_file_valid_programs_file(tmp_path: Path) -> No } create_config_file(tmp_path, devservices_config) - programs_config = """[program:example-program] -command=echo "Hello, World!" -autostart=true -""" - create_programs_conf_file(tmp_path, programs_config) - service_config = load_service_config_from_file(str(tmp_path)) assert ( service_config.dependencies["example-program"].dependency_type @@ -527,17 +530,49 @@ def test_load_service_config_from_file_valid_programs_file(tmp_path: Path) -> No ) -def test_load_supervisor_programs_from_file_no_programs_file(tmp_path: Path) -> None: - programs = load_supervisor_programs_from_file(str(tmp_path), "example-service") +def test_load_supervisor_programs_from_programs_data_no_x_programs( + tmp_path: Path, +) -> None: + config = { + "x-sentry-service-config": { + "version": 0.1, + "service_name": "example-service", + "dependencies": {}, + "modes": {"default": []}, + }, + "services": {}, + } + create_config_file(tmp_path, config) + config_path = tmp_path / "devservices" / "config.yml" + + programs = load_supervisor_programs_from_programs_data( + str(config_path), "example-service", {} + ) assert programs == set() -def test_load_supervisor_programs_from_file_valid_programs_file(tmp_path: Path) -> None: - programs_config = """[program:example-program] -command=echo "Hello, World!" -autostart=true -""" - create_programs_conf_file(tmp_path, programs_config) +def test_load_supervisor_programs_from_programs_data_with_x_programs( + tmp_path: Path, +) -> None: + config = { + "x-sentry-service-config": { + "version": 0.1, + "service_name": "example-service", + "dependencies": {}, + "modes": {"default": []}, + }, + "x-programs": { + "example-program": { + "command": "python run program", + "autostart": True, + } + }, + "services": {}, + } + create_config_file(tmp_path, config) + config_path = tmp_path / "devservices" / "config.yml" - programs = load_supervisor_programs_from_file(str(tmp_path), "example-service") + programs = load_supervisor_programs_from_programs_data( + str(config_path), "example-service", cast(ProgramData, config["x-programs"]) + ) assert programs == {"example-program"} diff --git a/tests/utils/test_supervisor.py b/tests/utils/test_supervisor.py index d8cf25ac..08fbc17a 100644 --- a/tests/utils/test_supervisor.py +++ b/tests/utils/test_supervisor.py @@ -19,6 +19,7 @@ from devservices.utils.supervisor import SupervisorProcessState from devservices.utils.supervisor import UnixSocketHTTPConnection from devservices.utils.supervisor import UnixSocketTransport +from testing.utils import create_config_file @mock.patch("socket.socket") @@ -64,16 +65,21 @@ def supervisor_manager(tmp_path: Path) -> SupervisorManager: with mock.patch( "devservices.utils.supervisor.DEVSERVICES_SUPERVISOR_CONFIG_DIR", tmp_path ): - config_file_path = tmp_path / DEVSERVICES_DIR_NAME / "processes.conf" - config_file_path.parent.mkdir(parents=True, exist_ok=True) - config_file_path.write_text( - """ - [program:test_program] - command = python test_program.py - """ - ) + config = { + "x-sentry-service-config": { + "version": 0.1, + "service_name": "test-service", + "dependencies": { + "test-program": {"description": "Test program"}, + }, + "modes": {}, + }, + "x-programs": {"test_program": {"command": "python test_program.py"}}, + } + create_config_file(tmp_path, config) + service_config_path = tmp_path / DEVSERVICES_DIR_NAME / "config.yml" return SupervisorManager( - config_file_path=str(config_file_path), service_name="test-service" + service_name="test-service", service_config_path=str(service_config_path) ) @@ -85,9 +91,83 @@ def test_init_with_config_file(supervisor_manager: SupervisorManager) -> None: def test_init_with_nonexistent_config() -> None: with pytest.raises(SupervisorConfigError): SupervisorManager( - config_file_path="/nonexistent/path.conf", service_name="test-service" + service_name="test-service", service_config_path="/nonexistent/path.yml" + ) + + +def test_init_with_empty_config_file(tmp_path: Path) -> None: + with mock.patch( + "devservices.utils.supervisor.DEVSERVICES_SUPERVISOR_CONFIG_DIR", tmp_path + ): + # Create an empty service config YAML file + service_config_path = tmp_path / DEVSERVICES_DIR_NAME / "config.yml" + service_config_path.parent.mkdir(parents=True, exist_ok=True) + service_config_path.write_text("") + + with pytest.raises( + SupervisorConfigError, match=f"Config file {service_config_path} is empty" + ): + SupervisorManager( + service_name="test-service", + service_config_path=str(service_config_path), + ) + + +def test_supervisor_program_defaults(tmp_path: Path) -> None: + with mock.patch( + "devservices.utils.supervisor.DEVSERVICES_SUPERVISOR_CONFIG_DIR", tmp_path + ): + # Create a service config YAML file with minimal x-programs config + service_config_path = tmp_path / DEVSERVICES_DIR_NAME / "config.yml" + service_config_path.parent.mkdir(parents=True, exist_ok=True) + service_config_path.write_text( + """ +x-programs: + test_program: + command: python test_program.py + """ + ) + + manager = SupervisorManager( + service_name="test-service", service_config_path=str(service_config_path) + ) + + # Read the generated supervisor config to check defaults were applied + with open(manager.config_file_path, "r") as f: + config_content = f.read() + + assert "autostart = false" in config_content + assert "autorestart = true" in config_content + + +def test_supervisor_program_custom_values_override_defaults(tmp_path: Path) -> None: + with mock.patch( + "devservices.utils.supervisor.DEVSERVICES_SUPERVISOR_CONFIG_DIR", tmp_path + ): + # Create a service config YAML file with custom autostart/autorestart values + service_config_path = tmp_path / DEVSERVICES_DIR_NAME / "config.yml" + service_config_path.parent.mkdir(parents=True, exist_ok=True) + service_config_path.write_text( + """ +x-programs: + test_program: + command: python test_program.py + autostart: false + autorestart: true + """ ) + manager = SupervisorManager( + service_name="test-service", service_config_path=str(service_config_path) + ) + + # Read the generated supervisor config to check custom values were used + with open(manager.config_file_path, "r") as f: + config_content = f.read() + + assert "autostart = false" in config_content + assert "autorestart = true" in config_content + @mock.patch("devservices.utils.supervisor.xmlrpc.client.ServerProxy") def test_get_rpc_client_success( @@ -327,6 +407,8 @@ def test_extend_config_file( assert ( f.read() == f"""[program:test_program] +autostart = false +autorestart = true command = python test_program.py [unix_http_server] @@ -580,6 +662,17 @@ def test_get_all_process_info_success( assert actual == expected +@mock.patch("devservices.utils.supervisor.xmlrpc.client.ServerProxy") +def test_get_all_process_info_no_programs( + mock_rpc_client: mock.MagicMock, supervisor_manager: SupervisorManager +) -> None: + supervisor_manager.has_programs = False + + result = supervisor_manager.get_all_process_info() + + assert result == {} + + @mock.patch("devservices.utils.supervisor.xmlrpc.client.ServerProxy") def test_get_all_process_info_empty_list( mock_rpc_client: mock.MagicMock, supervisor_manager: SupervisorManager