Skip to content
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

ref(modes): Refactoring modes to support multiple concurrent modes #173

Merged
merged 10 commits into from
Dec 6, 2024
13 changes: 9 additions & 4 deletions devservices/commands/down.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,15 +72,20 @@ def down(args: Namespace) -> None:
console.warning(f"{service.name} is not running")
exit(0)

mode = state.get_mode_for_service(service.name) or "default"
mode_dependencies = modes[mode]
active_modes = state.get_active_modes_for_service(service.name)
mode_dependencies = set()
for active_mode in active_modes:
active_mode_dependencies = modes.get(active_mode, [])
mode_dependencies.update(active_mode_dependencies)

with Status(
lambda: console.warning(f"Stopping {service.name}"),
lambda: console.success(f"{service.name} stopped"),
) as status:
try:
remote_dependencies = install_and_verify_dependencies(service, mode=mode)
remote_dependencies = install_and_verify_dependencies(
service, modes=active_modes
)
except DependencyError as de:
capture_exception(de)
status.failure(str(de))
Expand All @@ -89,7 +94,7 @@ def down(args: Namespace) -> None:
service, remote_dependencies
)
try:
_down(service, remote_dependencies, mode_dependencies, status)
_down(service, remote_dependencies, list(mode_dependencies), status)
except DockerComposeError as dce:
capture_exception(dce)
status.failure(f"Failed to stop {service.name}: {dce.stderr}")
Expand Down
4 changes: 2 additions & 2 deletions devservices/commands/list_services.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,9 @@ def list_services(args: Namespace) -> None:

for service in services_to_show:
status = "running" if service.name in running_services else "stopped"
mode = state.get_mode_for_service(service.name)
active_modes = state.get_active_modes_for_service(service.name)
console.info(f"- {service.name}")
console.info(f" mode: {mode}")
console.info(f" modes: {active_modes}")
console.info(f" status: {status}")
console.info(f" location: {service.repo_path}")

Expand Down
61 changes: 5 additions & 56 deletions devservices/commands/up.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
from __future__ import annotations

import concurrent.futures
import os
import subprocess
from argparse import _SubParsersAction
Expand All @@ -23,7 +22,6 @@
from devservices.utils.console import Console
from devservices.utils.console import Status
from devservices.utils.dependencies import construct_dependency_graph
from devservices.utils.dependencies import get_non_shared_remote_dependencies
from devservices.utils.dependencies import install_and_verify_dependencies
from devservices.utils.dependencies import InstalledRemoteDependency
from devservices.utils.docker_compose import get_docker_compose_commands_to_run
Expand Down Expand Up @@ -69,64 +67,14 @@ def up(args: Namespace) -> None:
modes = service.config.modes
mode = args.mode

state = State()
started_services = state.get_started_services()
running_mode = state.get_mode_for_service(service.name) or "default"

# TODO: Remove this once we properly handle mode switching
if service.name in started_services and running_mode != mode:
console.warning(
f"Service '{service.name}' is already running in mode: '{running_mode}', restarting in mode: '{mode}'"
)
with Status() as status:
try:
remote_dependencies = install_and_verify_dependencies(
service, mode=running_mode
)
except DependencyError as de:
capture_exception(de)
status.failure(str(de))
exit(1)
except ModeDoesNotExistError as mde:
capture_exception(mde)
status.failure(str(mde))
exit(1)
service_config_file_path = os.path.join(
service.repo_path, DEVSERVICES_DIR_NAME, CONFIG_FILE_NAME
)
current_env = os.environ.copy()
running_mode_dependencies = modes[running_mode]
remote_dependencies_to_bring_down = get_non_shared_remote_dependencies(
service, remote_dependencies
)
down_docker_compose_commands = get_docker_compose_commands_to_run(
service=service,
remote_dependencies=list(remote_dependencies_to_bring_down),
current_env=current_env,
command="down",
options=[],
service_config_file_path=service_config_file_path,
mode_dependencies=running_mode_dependencies,
)

with concurrent.futures.ThreadPoolExecutor() as executor:
futures = [
executor.submit(run_cmd, cmd, current_env)
for cmd in down_docker_compose_commands
]
for future in concurrent.futures.as_completed(futures):
future.result()

state.remove_started_service(service.name)

with Status(
lambda: console.warning(f"Starting '{service.name}' in mode: '{mode}'"),
lambda: console.success(f"{service.name} started"),
) as status:
try:
status.info("Retrieving dependencies")
remote_dependencies = install_and_verify_dependencies(
service, force_update_dependencies=True, mode=mode
service, force_update_dependencies=True, modes=[mode]
)
except DependencyError as de:
capture_exception(de)
Expand All @@ -142,14 +90,14 @@ def up(args: Namespace) -> None:
pass
try:
mode_dependencies = modes[mode]
_up(service, remote_dependencies, mode_dependencies, status)
_up(service, [mode], remote_dependencies, mode_dependencies, status)
except DockerComposeError as dce:
capture_exception(dce)
status.failure(f"Failed to start {service.name}: {dce.stderr}")
exit(1)
# TODO: We should factor in healthchecks here before marking service as running
state = State()
state.add_started_service(service.name, mode)
state.update_started_service(service.name, mode)


def _bring_up_dependency(
Expand All @@ -163,6 +111,7 @@ def _bring_up_dependency(

def _up(
service: Service,
modes: list[str],
remote_dependencies: set[InstalledRemoteDependency],
mode_dependencies: list[str],
status: Status,
Expand All @@ -180,7 +129,7 @@ def _up(
DEVSERVICES_DEPENDENCIES_CACHE_DIR_KEY
] = relative_local_dependency_directory
options = ["-d"]
dependency_graph = construct_dependency_graph(service)
dependency_graph = construct_dependency_graph(service, modes=modes)
starting_order = dependency_graph.get_starting_order()
sorted_remote_dependencies = sorted(
remote_dependencies, key=lambda dep: starting_order.index(dep.service_name)
Expand Down
33 changes: 25 additions & 8 deletions devservices/utils/dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -160,11 +160,20 @@ def _set_config(self, key: str, value: str) -> None:


def install_and_verify_dependencies(
service: Service, force_update_dependencies: bool = False, mode: str = "default"
service: Service,
force_update_dependencies: bool = False,
modes: list[str] | None = None,
) -> set[InstalledRemoteDependency]:
if mode not in service.config.modes:
raise ModeDoesNotExistError(service_name=service.name, mode=mode)
mode_dependencies = set(service.config.modes[mode])
"""
Install and verify dependencies for a service
"""
if modes is None:
modes = ["default"]
mode_dependencies = set()
for mode in modes:
if mode not in service.config.modes:
raise ModeDoesNotExistError(service_name=service.name, mode=mode)
mode_dependencies.update(service.config.modes[mode])
matching_dependencies = [
dependency
for dependency_key, dependency in list(service.config.dependencies.items())
Expand Down Expand Up @@ -526,15 +535,23 @@ def get_remote_dependency_config(remote_config: RemoteConfig) -> ServiceConfig:
return load_service_config_from_file(dependency_repo_dir)


def construct_dependency_graph(service: Service) -> DependencyGraph:
def construct_dependency_graph(service: Service, modes: list[str]) -> DependencyGraph:
dependency_graph = DependencyGraph()

def _construct_dependency_graph(service_config: ServiceConfig) -> None:
def _construct_dependency_graph(
service_config: ServiceConfig, modes: list[str]
) -> None:
service_mode_dependencies = set()
for mode in modes:
service_mode_dependencies.update(service_config.modes.get(mode, []))
for dependency_name, dependency in service_config.dependencies.items():
# Skip the dependency if it's not in the modes (since it may not be installed and we don't care about it)
if dependency_name not in service_mode_dependencies:
continue
dependency_graph.add_edge(service_config.service_name, dependency_name)
if _has_remote_config(dependency.remote):
dependency_config = get_remote_dependency_config(dependency.remote)
_construct_dependency_graph(dependency_config)
_construct_dependency_graph(dependency_config, [dependency.remote.mode])

_construct_dependency_graph(service.config)
_construct_dependency_graph(service.config, modes)
return dependency_graph
31 changes: 20 additions & 11 deletions devservices/utils/state.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,17 +35,26 @@ def initialize_database(self) -> None:
)
self.conn.commit()

def add_started_service(self, service_name: str, mode: str) -> None:
def update_started_service(self, service_name: str, mode: str) -> None:
cursor = self.conn.cursor()
started_services = self.get_started_services()
if service_name in started_services:
active_modes = self.get_active_modes_for_service(service_name)
if service_name in started_services and mode in active_modes:
return
cursor.execute(
"""
INSERT INTO started_services (service_name, mode) VALUES (?, ?)
IanWoodard marked this conversation as resolved.
Show resolved Hide resolved
""",
(service_name, mode),
)
if service_name in started_services:
cursor.execute(
"""
UPDATE started_services SET mode = ? WHERE service_name = ?
""",
(",".join(active_modes + [mode]), service_name),
)
else:
cursor.execute(
"""
INSERT INTO started_services (service_name, mode) VALUES (?, ?)
""",
(service_name, ",".join(active_modes + [mode])),
)
self.conn.commit()

def remove_started_service(self, service_name: str) -> None:
Expand All @@ -67,7 +76,7 @@ def get_started_services(self) -> list[str]:
)
return [row[0] for row in cursor.fetchall()]

def get_mode_for_service(self, service_name: str) -> str | None:
def get_active_modes_for_service(self, service_name: str) -> list[str]:
cursor = self.conn.cursor()
cursor.execute(
"""
Expand All @@ -77,8 +86,8 @@ def get_mode_for_service(self, service_name: str) -> str | None:
)
result = cursor.fetchone()
if result is None:
return None
return str(result[0])
return []
return str(result[0]).split(",")

def clear_state(self) -> None:
cursor = self.conn.cursor()
Expand Down
6 changes: 3 additions & 3 deletions tests/commands/test_down.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ def test_down_simple(
"devservices.utils.state.STATE_DB_FILE", str(tmp_path / "state")
):
state = State()
state.add_started_service("example-service", "default")
state.update_started_service("example-service", "default")
down(args)

# Ensure the DEVSERVICES_DEPENDENCIES_CACHE_DIR_KEY is set and is relative
Expand Down Expand Up @@ -137,7 +137,7 @@ def test_down_error(

with mock.patch("devservices.utils.state.STATE_DB_FILE", str(tmp_path / "state")):
state = State()
state.add_started_service("example-service", "default")
state.update_started_service("example-service", "default")
with pytest.raises(SystemExit):
down(args)

Expand Down Expand Up @@ -202,7 +202,7 @@ def test_down_mode_simple(
"devservices.utils.state.STATE_DB_FILE", str(tmp_path / "state")
):
state = State()
state.add_started_service("example-service", "test")
state.update_started_service("example-service", "test")
down(args)

# Ensure the DEVSERVICES_DEPENDENCIES_CACHE_DIR_KEY is set and is relative
Expand Down
8 changes: 4 additions & 4 deletions tests/commands/test_list_services.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ def test_list_running_services(
return_value=str(tmp_path / "code"),
), mock.patch("devservices.utils.state.STATE_DB_FILE", str(tmp_path / "state")):
state = State()
state.add_started_service("example-service", "default")
state.update_started_service("example-service", "default")
config = {
"x-sentry-service-config": {
"version": 0.1,
Expand Down Expand Up @@ -47,7 +47,7 @@ def test_list_running_services(

assert (
captured.out
== f"Running services:\n- example-service\n mode: default\n status: running\n location: {tmp_path / 'code' / 'example-service'}\n"
== f"Running services:\n- example-service\n modes: ['default']\n status: running\n location: {tmp_path / 'code' / 'example-service'}\n"
)


Expand All @@ -57,7 +57,7 @@ def test_list_all_services(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -
return_value=str(tmp_path / "code"),
), mock.patch("devservices.utils.state.STATE_DB_FILE", str(tmp_path / "state")):
state = State()
state.add_started_service("example-service", "default")
state.update_started_service("example-service", "default")
config = {
"x-sentry-service-config": {
"version": 0.1,
Expand Down Expand Up @@ -85,5 +85,5 @@ def test_list_all_services(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -

assert (
captured.out
== f"Services installed locally:\n- example-service\n mode: default\n status: running\n location: {tmp_path / 'code' / 'example-service'}\n"
== f"Services installed locally:\n- example-service\n modes: ['default']\n status: running\n location: {tmp_path / 'code' / 'example-service'}\n"
)
6 changes: 3 additions & 3 deletions tests/commands/test_purge.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ def test_purge_with_cache_and_state_and_no_running_containers_confirmed(
cache_file.write_text("This is a test cache file.")

state = State()
state.add_started_service("test-service", "test-mode")
state.update_started_service("test-service", "test-mode")

assert cache_file.exists()
assert state.get_started_services() == ["test-service"]
Expand Down Expand Up @@ -96,7 +96,7 @@ def test_purge_with_cache_and_state_and_running_containers_with_networks_confirm
cache_file.write_text("This is a test cache file.")

state = State()
state.add_started_service("test-service", "test-mode")
state.update_started_service("test-service", "test-mode")

assert cache_file.exists()
assert state.get_started_services() == ["test-service"]
Expand Down Expand Up @@ -155,7 +155,7 @@ def test_purge_with_cache_and_state_and_running_containers_not_confirmed(
cache_file.write_text("This is a test cache file.")

state = State()
state.add_started_service("test-service", "test-mode")
state.update_started_service("test-service", "test-mode")

args = Namespace()
purge(args)
Expand Down
Loading
Loading