feat: add status command to query the basic status meta of services
All checks were successful
/ Static Analysis (push) Successful in 1m5s
/ Tests (push) Successful in 52s

This commit is contained in:
Marc 2024-04-14 00:27:55 -04:00
parent 409ce88ede
commit 17781b0eb9
Signed by: marc
GPG key ID: 048E042F22B5DC79
12 changed files with 218 additions and 35 deletions

View file

@ -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"]

View file

@ -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

View file

@ -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

View file

@ -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__":

View file

@ -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":
"""

View 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

View file

@ -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()

View file

@ -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(

View file

@ -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

View file

@ -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)

View 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()

View 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"