feat: add status command to query the basic status meta of services
This commit is contained in:
parent
409ce88ede
commit
17781b0eb9
12 changed files with 218 additions and 35 deletions
|
@ -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"]
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
18
spud/cli.py
18
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__":
|
||||
|
|
|
@ -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":
|
||||
"""
|
||||
|
|
71
spud/container_managers.py
Normal file
71
spud/container_managers.py
Normal file
|
@ -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
|
|
@ -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()
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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()
|
||||
|
|
50
tests/test_podman_manager.py
Normal file
50
tests/test_podman_manager.py
Normal file
|
@ -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"
|
Reference in a new issue