Skip to content

feat(down): Add supervisor programs to down #278

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 25 commits into from
Jun 2, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
5a82809
add validation for non remote dependencies
hubertdeng123 May 12, 2025
6ecedd5
fix some tests
hubertdeng123 May 13, 2025
eccadb2
add supervisor config validation
hubertdeng123 May 13, 2025
b1e2dc7
add two more tests
hubertdeng123 May 13, 2025
d9c5fc8
change structure of dependeny to also pass in type
hubertdeng123 May 13, 2025
7e4beff
fix more stuff
hubertdeng123 May 13, 2025
eb39a55
remove print statement
hubertdeng123 May 13, 2025
6d22675
fix tests
hubertdeng123 May 13, 2025
03b77d4
actually fix tests
hubertdeng123 May 13, 2025
c92b0c1
fix dependency_type
hubertdeng123 May 14, 2025
0a8c34f
add supervisor program to up
hubertdeng123 May 15, 2025
f64c964
merge main
hubertdeng123 May 21, 2025
879295e
add supervisor to down
hubertdeng123 May 22, 2025
4394c3c
add case to not allow bringing up supervisor programs from outside th…
hubertdeng123 May 27, 2025
907e81c
Merge branch 'hubertdeng123/add-supervisor-to-up' into hubertdeng123/…
hubertdeng123 May 27, 2025
8fe28d8
rename and move around function parameters
hubertdeng123 May 30, 2025
834c46a
merge main
hubertdeng123 May 30, 2025
6e1eb2c
address some comments
hubertdeng123 May 30, 2025
b4d992d
get rid of duplicate check for programs conf file
hubertdeng123 May 30, 2025
9ca7924
fix tests
hubertdeng123 May 30, 2025
2951195
indent span for starting supervisor programs correctly
hubertdeng123 May 30, 2025
44e40db
Merge branch 'hubertdeng123/add-supervisor-to-up' into hubertdeng123/…
hubertdeng123 May 30, 2025
4cf89e9
remove redundant code
hubertdeng123 May 30, 2025
fa37b8f
Merge branch 'main' into hubertdeng123/add-supervisor-to-down
hubertdeng123 May 31, 2025
e60c7cc
capture exception for supervisor failures
hubertdeng123 Jun 2, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions devservices/commands/down.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,13 @@
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 SupervisorError
from devservices.utils.console import Console
from devservices.utils.console import Status
from devservices.utils.dependencies import construct_dependency_graph
Expand All @@ -35,6 +37,7 @@
from devservices.utils.state import ServiceRuntime
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:
Expand Down Expand Up @@ -105,6 +108,14 @@ def down(args: Namespace) -> None:
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
]

with Status(
lambda: console.warning(f"Stopping {service.name}"),
) as status:
Expand All @@ -129,6 +140,13 @@ def down(args: Namespace) -> None:
)
exit(1)

try:
bring_down_supervisor_programs(supervisor_programs, service, status)
except SupervisorError as se:
capture_exception(se)
status.failure(str(se))
exit(1)

# Check if any service depends on the service we are trying to bring down
# TODO: We should also take into account the active modes of the other services (this is not trivial to do)
other_started_services = active_services.difference({service.name})
Expand Down Expand Up @@ -285,3 +303,24 @@ def _bring_down_dependency(
for dependency in cmd.services:
status.info(f"Stopping {dependency}")
return run_cmd(cmd.full_command, current_env)


def bring_down_supervisor_programs(
supervisor_programs: list[str], service: Service, status: Status
) -> 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,
)

for program in supervisor_programs:
status.info(f"Stopping {program}")
manager.stop_process(program)

status.info("Stopping supervisor daemon")
manager.stop_supervisor_daemon()
263 changes: 263 additions & 0 deletions tests/commands/test_down.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,19 @@

import pytest

from devservices.commands.down import bring_down_supervisor_programs
from devservices.commands.down import down
from devservices.configs.service_config import Dependency
from devservices.configs.service_config import RemoteConfig
from devservices.configs.service_config import ServiceConfig
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
from devservices.utils.services import Service
Expand All @@ -25,6 +29,7 @@
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


Expand Down Expand Up @@ -1334,3 +1339,261 @@ def test_down_shared_and_local_dependencies(
mock.call("other-service", StateTables.STARTED_SERVICES),
]
)


@mock.patch("devservices.utils.state.State.remove_service_entry")
@mock.patch(
"devservices.utils.supervisor.SupervisorManager.stop_supervisor_daemon",
side_effect=SupervisorError("Error stopping supervisor daemon"),
)
@mock.patch("devservices.utils.supervisor.SupervisorManager.stop_process")
def test_down_supervisor_program_error(
mock_stop_process: mock.Mock,
mock_stop_supervisor_daemon: mock.Mock,
mock_remove_service_entry: mock.Mock,
tmp_path: Path,
capsys: pytest.CaptureFixture[str],
) -> None:
with mock.patch(
"devservices.commands.down.DEVSERVICES_DEPENDENCIES_CACHE_DIR",
str(tmp_path / "dependency-dir"),
):
config = {
"x-sentry-service-config": {
"version": 0.1,
"service_name": "example-service",
"dependencies": {
"supervisor-program": {"description": "Supervisor program"},
},
"modes": {"default": ["supervisor-program"]},
},
"services": {
"redis": {"image": "redis:6.2.14-alpine"},
"clickhouse": {
"image": "altinity/clickhouse-server:23.8.11.29.altinitystable"
},
},
}

service_path = tmp_path / "example-service"
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 (
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.STARTING_SERVICES
)
down(args)

mock_remove_service_entry.assert_not_called()

captured = capsys.readouterr()
mock_stop_process.assert_called_once_with("supervisor-program")
mock_stop_supervisor_daemon.assert_called_once()
assert "Stopping supervisor-program" in captured.out.strip()
assert "Stopping supervisor daemon" in captured.out.strip()
assert "Error stopping supervisor daemon" in captured.out.strip()


@mock.patch("devservices.utils.state.State.remove_service_entry")
@mock.patch("devservices.utils.supervisor.SupervisorManager.stop_supervisor_daemon")
@mock.patch("devservices.utils.supervisor.SupervisorManager.stop_process")
def test_down_supervisor_program_success(
mock_stop_process: mock.Mock,
mock_stop_supervisor_daemon: mock.Mock,
mock_remove_service_entry: mock.Mock,
tmp_path: Path,
capsys: pytest.CaptureFixture[str],
) -> None:
with mock.patch(
"devservices.commands.down.DEVSERVICES_DEPENDENCIES_CACHE_DIR",
str(tmp_path / "dependency-dir"),
):
config = {
"x-sentry-service-config": {
"version": 0.1,
"service_name": "example-service",
"dependencies": {
"supervisor-program": {"description": "Supervisor program"},
},
"modes": {"default": ["supervisor-program"]},
},
"services": {
"redis": {"image": "redis:6.2.14-alpine"},
"clickhouse": {
"image": "altinity/clickhouse-server:23.8.11.29.altinitystable"
},
},
}

service_path = tmp_path / "example-service"
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 (
mock.patch(
"devservices.utils.state.STATE_DB_FILE", str(tmp_path / "state")
),
):
state = State()
state.update_service_entry(
"example-service", "default", StateTables.STARTING_SERVICES
)
down(args)

mock_remove_service_entry.assert_has_calls(
[
mock.call("example-service", StateTables.STARTING_SERVICES),
mock.call("example-service", StateTables.STARTED_SERVICES),
]
)

captured = capsys.readouterr()
mock_stop_process.assert_called_once_with("supervisor-program")
mock_stop_supervisor_daemon.assert_called_once()
assert "Stopping supervisor-program" in captured.out.strip()
assert "Stopping supervisor daemon" in captured.out.strip()
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(
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()

bring_down_supervisor_programs([], service, status)

status.info.assert_not_called()
status.failure.assert_not_called()

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_success(
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,
)

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!"
""",
)

status = mock.MagicMock()

bring_down_supervisor_programs(["supervisor-program"], service, status)

status.info.assert_has_calls(
[
mock.call("Stopping supervisor-program"),
mock.call("Stopping supervisor daemon"),
]
)
status.failure.assert_not_called()

mock_stop_supervisor_daemon.assert_called_once()
mock_stop_process.assert_called_once_with("supervisor-program")
Loading