From 17781b0eb925f53d1f6d306d518c6cd5f612523e Mon Sep 17 00:00:00 2001 From: Marc Cataford Date: Sun, 14 Apr 2024 00:27:55 -0400 Subject: [PATCH] feat: add status command to query the basic status meta of services --- pyproject.toml | 5 ++- requirements_dev.txt | 5 +++ requirements_test.txt | 36 ++++++------------ spud/cli.py | 18 +++++++++ spud/config.py | 2 + spud/container_managers.py | 71 ++++++++++++++++++++++++++++++++++++ spud/daemon.py | 10 +++++ tests/conftest.py | 5 +++ tests/test_cli.py | 25 +++++++++++-- tests/test_config.py | 13 ++++--- tests/test_daemon.py | 13 +++++++ tests/test_podman_manager.py | 50 +++++++++++++++++++++++++ 12 files changed, 218 insertions(+), 35 deletions(-) create mode 100644 spud/container_managers.py create mode 100644 tests/test_podman_manager.py diff --git a/pyproject.toml b/pyproject.toml index de4ec78..1ccfe7b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,7 +17,8 @@ sast = [ "isort" ] test = [ - "pytest" + "pytest", + "pytest-httpx" ] [project.scripts] @@ -33,6 +34,8 @@ jobs = 0 disable = [ "missing-function-docstring", "missing-module-docstring", + "missing-class-docstring", + "fixme", ] source-roots = ["spud", "tests"] diff --git a/requirements_dev.txt b/requirements_dev.txt index 07c5a50..aebdb3d 100644 --- a/requirements_dev.txt +++ b/requirements_dev.txt @@ -45,6 +45,7 @@ httptools==0.6.1 httpx==0.27.0 # via # -c requirements.txt + # pytest-httpx # spud (pyproject.toml) idna==3.7 # via @@ -85,6 +86,10 @@ pydantic-core==2.16.3 pylint==3.1.0 # via spud (pyproject.toml) pytest==8.1.1 + # via + # pytest-httpx + # spud (pyproject.toml) +pytest-httpx==0.30.0 # via spud (pyproject.toml) python-dotenv==1.0.1 # via diff --git a/requirements_test.txt b/requirements_test.txt index c6c0ace..5deb9ce 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -8,10 +8,6 @@ anyio==4.3.0 # httpx # starlette # watchfiles -astroid==3.1.0 - # via pylint -black==24.3.0 - # via spud (pyproject.toml) certifi==2024.2.2 # via # -c requirements.txt @@ -20,11 +16,8 @@ certifi==2024.2.2 click==8.1.7 # via # -c requirements.txt - # black # spud (pyproject.toml) # uvicorn -dill==0.3.8 - # via pylint fastapi==0.110.1 # via # -c requirements.txt @@ -45,28 +38,19 @@ httptools==0.6.1 httpx==0.27.0 # via # -c requirements.txt + # pytest-httpx # spud (pyproject.toml) idna==3.7 # via # -c requirements.txt # anyio # httpx -isort==5.13.2 - # via - # pylint - # spud (pyproject.toml) -mccabe==0.7.0 - # via pylint -mypy-extensions==1.0.0 - # via black +iniconfig==2.0.0 + # via pytest packaging==24.0 - # via black -pathspec==0.12.1 - # via black -platformdirs==4.2.0 - # via - # black - # pylint + # via pytest +pluggy==1.4.0 + # via pytest pydantic==2.6.4 # via # -c requirements.txt @@ -76,7 +60,11 @@ pydantic-core==2.16.3 # via # -c requirements.txt # pydantic -pylint==3.1.0 +pytest==8.1.1 + # via + # pytest-httpx + # spud (pyproject.toml) +pytest-httpx==0.30.0 # via spud (pyproject.toml) python-dotenv==1.0.1 # via @@ -95,8 +83,6 @@ starlette==0.37.2 # via # -c requirements.txt # fastapi -tomlkit==0.12.4 - # via pylint typing-extensions==4.11.0 # via # -c requirements.txt diff --git a/spud/cli.py b/spud/cli.py index 0c943b6..829a8c4 100644 --- a/spud/cli.py +++ b/spud/cli.py @@ -8,6 +8,7 @@ import json import pathlib import click +import httpx import uvicorn from spud.config import Configuration @@ -33,6 +34,9 @@ def cli(context, config): if config_path.exists(): context.obj["config"] = Configuration.from_file(config_path) + context.obj["api_client"] = httpx.Client( + base_url=context.obj["config"].api_baseurl + ) else: context.obj["config"] = None @@ -73,6 +77,19 @@ def print_config(context): click.echo(config.model_dump_json(indent=2)) +@click.command() +@click.pass_context +def status(context): + response = context.obj["api_client"].get("/status") + data = response.json() + + for service in data: + click.echo(f"{service['name']}: {service['status']}") + + for container in service["containers"]: + click.echo(f" + {container['name']} - {container['status']}") + + @click.command() @click.option( "--reload", @@ -88,6 +105,7 @@ def daemon(reload): cli.add_command(init) cli.add_command(print_config) +cli.add_command(status) cli.add_command(daemon) if __name__ == "__main__": diff --git a/spud/config.py b/spud/config.py index 0d8e255..13daa10 100644 --- a/spud/config.py +++ b/spud/config.py @@ -7,6 +7,8 @@ import pydantic class Configuration(pydantic.BaseModel): """Command line application configuration options""" + api_baseurl: str + @classmethod def from_file(cls, path: pathlib.Path) -> "Configuration": """ diff --git a/spud/container_managers.py b/spud/container_managers.py new file mode 100644 index 0000000..9aced1e --- /dev/null +++ b/spud/container_managers.py @@ -0,0 +1,71 @@ +import json +import os +import subprocess +import typing + +import pydantic + + +class ContainerMetadata(pydantic.BaseModel): + name: str + status: str + id: str + + +class ServiceMetadata(pydantic.BaseModel): + name: str + id: str + type: str + containers: list[ContainerMetadata] + status: str + + +class PodmanManager(pydantic.BaseModel): + def _get_pods(self) -> dict[str, typing.Any]: + pod_result = subprocess.run( + [ + "podman", + "pod", + "ps", + "--format=json", + "--filter", + "status=running", + "--filter", + "status=degraded", + ], + check=True, + capture_output=True, + env=os.environ, + ) + + return json.loads(pod_result.stdout) + + def get_services(self) -> dict[str, ServiceMetadata]: + """ + Fetches metadata about all services currently running. + + Services are either pods or pod-less containers. + """ + + services = [] + for pod_meta in self._get_pods(): + containers = [ + ContainerMetadata( + name=container_meta["Names"], + status=container_meta["Status"], + id=container_meta["Id"], + ) + for container_meta in pod_meta["Containers"] + ] + + services.append( + ServiceMetadata( + name=pod_meta["Name"], + id=pod_meta["Id"], + type="pod", + status=pod_meta["Status"], + containers=containers, + ) + ) + + return services diff --git a/spud/daemon.py b/spud/daemon.py index 00f0b36..2f122b6 100644 --- a/spud/daemon.py +++ b/spud/daemon.py @@ -1,9 +1,19 @@ import fastapi +from spud.container_managers import PodmanManager, ServiceMetadata + app = fastapi.FastAPI() +manager = PodmanManager() + @app.get("/") def alive(): """Live check.""" return 200 + + +@app.get("/status") +def check_service_statuses() -> list[ServiceMetadata]: + """Reports on the status of all services.""" + return manager.get_services() diff --git a/tests/conftest.py b/tests/conftest.py index d32c621..480f3bd 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4,6 +4,11 @@ import pytest import spud.cli +@pytest.fixture(name="sample_config") +def sample_config_fixture(): + return {"api_baseurl": "http://test.url"} + + @pytest.fixture(autouse=True) def mock_default_config_path(monkeypatch, tmpdir): monkeypatch.setattr( diff --git a/tests/test_cli.py b/tests/test_cli.py index 5fe368a..a344861 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,3 +1,4 @@ +import json import pathlib from unittest.mock import Mock @@ -5,11 +6,13 @@ import pytest import uvicorn -def test_init_raises_if_config_file_exists_default_path(invoke_cli, tmpdir): +def test_init_raises_if_config_file_exists_default_path( + invoke_cli, tmpdir, sample_config +): expected_default_path = tmpdir / ".config" / "spud" / "config.json" pathlib.Path(expected_default_path).parent.mkdir(parents=True) - expected_default_path.write("{}") + expected_default_path.write(json.dumps(sample_config)) result = invoke_cli(["init"]) @@ -20,10 +23,12 @@ def test_init_raises_if_config_file_exists_default_path(invoke_cli, tmpdir): ) -def test_init_raises_if_config_file_exists_custom_path(invoke_cli, tmpdir): +def test_init_raises_if_config_file_exists_custom_path( + invoke_cli, tmpdir, sample_config +): expected_default_path = tmpdir / "config.json" - expected_default_path.write("{}") + expected_default_path.write(json.dumps(sample_config)) result = invoke_cli(["--config", str(expected_default_path), "init"]) @@ -70,3 +75,15 @@ def test_daemon_starts_server_with_reload_option(invoke_cli, monkeypatch, reload assert result.exit_code == 0 run_mock.assert_called_once_with("spud.daemon:app", reload=reload_flag) + + +# FIXME: Assert based on stdout. +def test_status_prints_service_statuses(tmpdir, invoke_cli, httpx_mock, sample_config): + httpx_mock.add_response("http://test.url/status", json=[]) + config_file = tmpdir / "config.json" + + config_file.write(json.dumps(sample_config)) + + result = invoke_cli(["--config", str(config_file), "status"]) + + assert result.exit_code == 0 diff --git a/tests/test_config.py b/tests/test_config.py index 0dbadce..c980264 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -5,11 +5,6 @@ import pytest from spud.config import Configuration -@pytest.fixture(name="sample_config") -def sample_config_fixture(): - return {} - - def test_from_file_creates_configuration_from_file(tmpdir, sample_config): config_file = tmpdir / "config.json" config_file.write(json.dumps(sample_config)) @@ -30,3 +25,11 @@ def test_from_file_raises_if_file_not_json(tmpdir): with pytest.raises(RuntimeError): Configuration.from_file(config_file) + + +def test_from_file_raises_if_wrong_schema(tmpdir): + config_file = tmpdir / "config.json" + config_file.write(json.dumps({"random-key": 1})) + + with pytest.raises(RuntimeError): + Configuration.from_file(config_file) diff --git a/tests/test_daemon.py b/tests/test_daemon.py index 5dd8f60..9dfb93c 100644 --- a/tests/test_daemon.py +++ b/tests/test_daemon.py @@ -1,3 +1,5 @@ +from unittest.mock import Mock + import pytest from fastapi.testclient import TestClient @@ -13,3 +15,14 @@ def test_alive_returns_200(client): response = client.get("/") assert response.status_code == 200 + + +# FIXME: Test premise could be stronger and cover the response schema / API contract. +def test_check_service_statuses_calls_get_services(client, monkeypatch): + mock = Mock() + mock.return_value = [] + monkeypatch.setattr(spud.daemon.PodmanManager, "get_services", mock) + + client.get("/status") + + mock.assert_called_once() diff --git a/tests/test_podman_manager.py b/tests/test_podman_manager.py new file mode 100644 index 0000000..4d38efc --- /dev/null +++ b/tests/test_podman_manager.py @@ -0,0 +1,50 @@ +import json +import os +import typing + +import pytest + +from spud.container_managers import PodmanManager, ServiceMetadata + + +@pytest.fixture(name="mock_podman") +def mock_podman_fixture(tmpdir, monkeypatch): + def _mock_podman(output: dict[str, typing.Any]): + serialized_output = json.dumps(output) + mock_out_path = tmpdir / "out" + mock_out_path.write(serialized_output) + mock_bin_path = tmpdir / "podman" + mock_bin_path.write(f"#!/bin/bash\ncat {str(tmpdir)}/out") + mock_bin_path.chmod(0o777) + monkeypatch.setenv("PATH", str(tmpdir), prepend=os.pathsep) + + return _mock_podman + + +def test_get_services_returns_metadata_on_services(mock_podman): + manager = PodmanManager() + + mock_podman_output = [ + { + "Name": "pod1", + "Id": "1", + "Status": "Running", + "Containers": [ + { + "Names": "pod1-1", + "Status": "Running", + "Id": "2", + } + ], + } + ] + + mock_podman(mock_podman_output) + + services = manager.get_services() + + assert len(services) == 1 + service = services[0] + + assert isinstance(service, ServiceMetadata) + assert service.name == "pod1"