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

feat(state): Add state with sqlite db #103

Merged
merged 8 commits into from
Nov 1, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
10 changes: 9 additions & 1 deletion devservices/commands/start.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from devservices.utils.console import Status
from devservices.utils.docker_compose import run_docker_compose_command
from devservices.utils.services import find_matching_service
from devservices.utils.state import State


def add_parser(subparsers: _SubParsersAction[ArgumentParser]) -> None:
Expand Down Expand Up @@ -35,8 +36,15 @@
with Status(f"Starting {service.name}", f"{service.name} started") as status:
try:
run_docker_compose_command(
service, "up", mode_dependencies, ["-d"], force_update_dependencies=True
service,
"up",
mode_dependencies,
["-d"],
force_update_dependencies=True,
)
except DockerComposeError as dce:
status.print(f"Failed to start {service.name}: {dce.stderr}")
exit(1)
# TODO: We should factor in healthchecks here before marking service as running

Check warning on line 48 in devservices/commands/start.py

View check run for this annotation

Codecov / codecov/patch

devservices/commands/start.py#L48

Added line #L48 was not covered by tests
state = State()
state.add_started_service(service.name, mode_to_start)
5 changes: 5 additions & 0 deletions devservices/commands/stop.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from devservices.utils.console import Status
from devservices.utils.docker_compose import run_docker_compose_command
from devservices.utils.services import find_matching_service
from devservices.utils.state import State


def add_parser(subparsers: _SubParsersAction[ArgumentParser]) -> None:
Expand Down Expand Up @@ -38,3 +39,7 @@
except DockerComposeError as dce:
status.print(f"Failed to stop {service.name}: {dce.stderr}")
exit(1)

Check warning on line 42 in devservices/commands/stop.py

View check run for this annotation

Codecov / codecov/patch

devservices/commands/stop.py#L42

Added line #L42 was not covered by tests
# TODO: We should factor in healthchecks here before marking service as stopped
state = State()
state.remove_started_service(service.name)
1 change: 1 addition & 0 deletions devservices/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
DEVSERVICES_LOCAL_DIR = os.path.expanduser("~/.local/share/sentry-devservices")
DEVSERVICES_DEPENDENCIES_CACHE_DIR = os.path.join(DEVSERVICES_CACHE_DIR, "dependencies")
DEVSERVICES_DEPENDENCIES_CACHE_DIR_KEY = "DEVSERVICES_DEPENDENCIES_CACHE_DIR"
STATE_DB_FILE = os.path.join(DEVSERVICES_LOCAL_DIR, "state")

DEPENDENCY_CONFIG_VERSION = "v1"
DEPENDENCY_GIT_PARTIAL_CLONE_CONFIG_OPTIONS = {
Expand Down
81 changes: 81 additions & 0 deletions devservices/utils/state.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
from __future__ import annotations

import os
import sqlite3

from devservices.constants import DEVSERVICES_LOCAL_DIR
from devservices.constants import STATE_DB_FILE


class State:
_instance: State | None = None
state_db_file: str
conn: sqlite3.Connection

def __new__(cls) -> State:
if cls._instance is None:
cls._instance = super(State, cls).__new__(cls)
if not os.path.exists(DEVSERVICES_LOCAL_DIR):
os.makedirs(DEVSERVICES_LOCAL_DIR)
cls._instance.state_db_file = STATE_DB_FILE
cls._instance.conn = sqlite3.connect(cls._instance.state_db_file)
cls._instance.initialize_database()
return cls._instance

def initialize_database(self) -> None:
cursor = self.conn.cursor()
cursor.execute(
"""
CREATE TABLE IF NOT EXISTS started_services (
service_name TEXT PRIMARY KEY,
mode TEXT,
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
"""
)
self.conn.commit()

def add_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:
return
cursor.execute(
"""
INSERT INTO started_services (service_name, mode) VALUES (?, ?)
""",
(service_name, mode),
)
self.conn.commit()

def remove_started_service(self, service_name: str) -> None:
cursor = self.conn.cursor()
cursor.execute(
"""
DELETE FROM started_services WHERE service_name = ?
""",
(service_name,),
)
self.conn.commit()

def get_started_services(self) -> list[str]:
cursor = self.conn.cursor()
cursor.execute(
"""
SELECT service_name FROM started_services
"""
)
return [row[0] for row in cursor.fetchall()]

def get_mode_for_service(self, service_name: str) -> str | None:
cursor = self.conn.cursor()
cursor.execute(
"""
SELECT mode FROM started_services WHERE service_name = ?
""",
(service_name,),
)
result = cursor.fetchone()
if result is None:
return None
return str(result[0])
15 changes: 13 additions & 2 deletions tests/commands/test_start.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,10 @@
stdout="clickhouse\nredis\n",
),
)
def test_start_simple(mock_run: mock.Mock, tmp_path: Path) -> None:
@mock.patch("devservices.utils.state.State.add_started_service")
def test_start_simple(
mock_add_started_service: mock.Mock, mock_run: mock.Mock, tmp_path: Path
) -> None:
with mock.patch(
"devservices.utils.docker_compose.DEVSERVICES_DEPENDENCIES_CACHE_DIR",
str(tmp_path / "dependency-dir"),
Expand Down Expand Up @@ -81,10 +84,16 @@ def test_start_simple(mock_run: mock.Mock, tmp_path: Path) -> None:
env=mock.ANY,
)

mock_add_started_service.assert_called_with("example-service", "default")


@mock.patch("devservices.utils.docker_compose.subprocess.run")
@mock.patch("devservices.utils.state.State.add_started_service")
def test_start_error(
mock_run: mock.Mock, capsys: pytest.CaptureFixture[str], tmp_path: Path
mock_add_started_service: mock.Mock,
mock_run: mock.Mock,
capsys: pytest.CaptureFixture[str],
tmp_path: Path,
) -> None:
mock_run.side_effect = subprocess.CalledProcessError(
returncode=1, stderr="Docker Compose error", cmd=""
Expand Down Expand Up @@ -121,3 +130,5 @@ def test_start_error(
assert (
"Failed to start example-service: Docker Compose error" in captured.out.strip()
)

mock_add_started_service.assert_not_called()
15 changes: 13 additions & 2 deletions tests/commands/test_stop.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,10 @@
stdout="clickhouse\nredis\n",
),
)
def test_stop_simple(mock_run: mock.Mock, tmp_path: Path) -> None:
@mock.patch("devservices.utils.state.State.remove_started_service")
def test_stop_simple(
mock_remove_started_service: mock.Mock, mock_run: mock.Mock, tmp_path: Path
) -> None:
with mock.patch(
"devservices.utils.docker_compose.DEVSERVICES_DEPENDENCIES_CACHE_DIR",
str(tmp_path / "dependency-dir"),
Expand Down Expand Up @@ -80,10 +83,16 @@ def test_stop_simple(mock_run: mock.Mock, tmp_path: Path) -> None:
env=mock.ANY,
)

mock_remove_started_service.assert_called_with("example-service")


@mock.patch("devservices.utils.docker_compose.subprocess.run")
@mock.patch("devservices.utils.state.State.remove_started_service")
def test_stop_error(
mock_run: mock.Mock, capsys: pytest.CaptureFixture[str], tmp_path: Path
mock_remove_started_service: mock.Mock,
mock_run: mock.Mock,
capsys: pytest.CaptureFixture[str],
tmp_path: Path,
) -> None:
mock_run.side_effect = subprocess.CalledProcessError(
returncode=1, stderr="Docker Compose error", cmd=""
Expand Down Expand Up @@ -120,3 +129,5 @@ def test_stop_error(
assert (
"Failed to stop example-service: Docker Compose error" in captured.out.strip()
)

mock_remove_started_service.assert_not_called()
10 changes: 10 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
from __future__ import annotations

import pytest

from devservices.utils.state import State


@pytest.fixture(autouse=True)
def clear_singleton_instance() -> None:
State._instance = None
54 changes: 54 additions & 0 deletions tests/utils/test_state.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
from __future__ import annotations

from pathlib import Path
from unittest import mock

from devservices.utils.state import State


def test_state_simple(tmp_path: Path) -> None:
with mock.patch("devservices.utils.state.STATE_DB_FILE", str(tmp_path / "state")):
state = State()
assert state.get_started_services() == []


def test_state_add_started_service(tmp_path: Path) -> None:
with mock.patch("devservices.utils.state.STATE_DB_FILE", str(tmp_path / "state")):
state = State()
state.add_started_service("example-service", "default")
assert state.get_started_services() == ["example-service"]
assert state.get_mode_for_service("example-service") == "default"


def test_state_remove_started_service(tmp_path: Path) -> None:
with mock.patch("devservices.utils.state.STATE_DB_FILE", str(tmp_path / "state")):
state = State()
state.add_started_service("example-service", "default")
assert state.get_started_services() == ["example-service"]
assert state.get_mode_for_service("example-service") == "default"
state.remove_started_service("example-service")
assert state.get_started_services() == []


def test_state_remove_unknown_service(tmp_path: Path) -> None:
with mock.patch("devservices.utils.state.STATE_DB_FILE", str(tmp_path / "state")):
state = State()
state.remove_started_service("unknown-service")
assert state.get_started_services() == []


def test_start_service_twice(tmp_path: Path) -> None:
with mock.patch("devservices.utils.state.STATE_DB_FILE", str(tmp_path / "state")):
state = State()
state.add_started_service("example-service", "default")
assert state.get_started_services() == ["example-service"]
assert state.get_mode_for_service("example-service") == "default"
state.add_started_service("example-service", "default")
assert state.get_started_services() == ["example-service"]
assert state.get_mode_for_service("example-service") == "default"


def test_get_mode_for_nonexistent_service(tmp_path: Path) -> None:
with mock.patch("devservices.utils.state.STATE_DB_FILE", str(tmp_path / "state")):
state = State()
assert state.get_mode_for_service("unknown-service") is None