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"
|
"isort"
|
||||||
]
|
]
|
||||||
test = [
|
test = [
|
||||||
"pytest"
|
"pytest",
|
||||||
|
"pytest-httpx"
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
|
@ -33,6 +34,8 @@ jobs = 0
|
||||||
disable = [
|
disable = [
|
||||||
"missing-function-docstring",
|
"missing-function-docstring",
|
||||||
"missing-module-docstring",
|
"missing-module-docstring",
|
||||||
|
"missing-class-docstring",
|
||||||
|
"fixme",
|
||||||
]
|
]
|
||||||
source-roots = ["spud", "tests"]
|
source-roots = ["spud", "tests"]
|
||||||
|
|
||||||
|
|
|
@ -45,6 +45,7 @@ httptools==0.6.1
|
||||||
httpx==0.27.0
|
httpx==0.27.0
|
||||||
# via
|
# via
|
||||||
# -c requirements.txt
|
# -c requirements.txt
|
||||||
|
# pytest-httpx
|
||||||
# spud (pyproject.toml)
|
# spud (pyproject.toml)
|
||||||
idna==3.7
|
idna==3.7
|
||||||
# via
|
# via
|
||||||
|
@ -85,6 +86,10 @@ pydantic-core==2.16.3
|
||||||
pylint==3.1.0
|
pylint==3.1.0
|
||||||
# via spud (pyproject.toml)
|
# via spud (pyproject.toml)
|
||||||
pytest==8.1.1
|
pytest==8.1.1
|
||||||
|
# via
|
||||||
|
# pytest-httpx
|
||||||
|
# spud (pyproject.toml)
|
||||||
|
pytest-httpx==0.30.0
|
||||||
# via spud (pyproject.toml)
|
# via spud (pyproject.toml)
|
||||||
python-dotenv==1.0.1
|
python-dotenv==1.0.1
|
||||||
# via
|
# via
|
||||||
|
|
|
@ -8,10 +8,6 @@ anyio==4.3.0
|
||||||
# httpx
|
# httpx
|
||||||
# starlette
|
# starlette
|
||||||
# watchfiles
|
# watchfiles
|
||||||
astroid==3.1.0
|
|
||||||
# via pylint
|
|
||||||
black==24.3.0
|
|
||||||
# via spud (pyproject.toml)
|
|
||||||
certifi==2024.2.2
|
certifi==2024.2.2
|
||||||
# via
|
# via
|
||||||
# -c requirements.txt
|
# -c requirements.txt
|
||||||
|
@ -20,11 +16,8 @@ certifi==2024.2.2
|
||||||
click==8.1.7
|
click==8.1.7
|
||||||
# via
|
# via
|
||||||
# -c requirements.txt
|
# -c requirements.txt
|
||||||
# black
|
|
||||||
# spud (pyproject.toml)
|
# spud (pyproject.toml)
|
||||||
# uvicorn
|
# uvicorn
|
||||||
dill==0.3.8
|
|
||||||
# via pylint
|
|
||||||
fastapi==0.110.1
|
fastapi==0.110.1
|
||||||
# via
|
# via
|
||||||
# -c requirements.txt
|
# -c requirements.txt
|
||||||
|
@ -45,28 +38,19 @@ httptools==0.6.1
|
||||||
httpx==0.27.0
|
httpx==0.27.0
|
||||||
# via
|
# via
|
||||||
# -c requirements.txt
|
# -c requirements.txt
|
||||||
|
# pytest-httpx
|
||||||
# spud (pyproject.toml)
|
# spud (pyproject.toml)
|
||||||
idna==3.7
|
idna==3.7
|
||||||
# via
|
# via
|
||||||
# -c requirements.txt
|
# -c requirements.txt
|
||||||
# anyio
|
# anyio
|
||||||
# httpx
|
# httpx
|
||||||
isort==5.13.2
|
iniconfig==2.0.0
|
||||||
# via
|
# via pytest
|
||||||
# pylint
|
|
||||||
# spud (pyproject.toml)
|
|
||||||
mccabe==0.7.0
|
|
||||||
# via pylint
|
|
||||||
mypy-extensions==1.0.0
|
|
||||||
# via black
|
|
||||||
packaging==24.0
|
packaging==24.0
|
||||||
# via black
|
# via pytest
|
||||||
pathspec==0.12.1
|
pluggy==1.4.0
|
||||||
# via black
|
# via pytest
|
||||||
platformdirs==4.2.0
|
|
||||||
# via
|
|
||||||
# black
|
|
||||||
# pylint
|
|
||||||
pydantic==2.6.4
|
pydantic==2.6.4
|
||||||
# via
|
# via
|
||||||
# -c requirements.txt
|
# -c requirements.txt
|
||||||
|
@ -76,7 +60,11 @@ pydantic-core==2.16.3
|
||||||
# via
|
# via
|
||||||
# -c requirements.txt
|
# -c requirements.txt
|
||||||
# pydantic
|
# pydantic
|
||||||
pylint==3.1.0
|
pytest==8.1.1
|
||||||
|
# via
|
||||||
|
# pytest-httpx
|
||||||
|
# spud (pyproject.toml)
|
||||||
|
pytest-httpx==0.30.0
|
||||||
# via spud (pyproject.toml)
|
# via spud (pyproject.toml)
|
||||||
python-dotenv==1.0.1
|
python-dotenv==1.0.1
|
||||||
# via
|
# via
|
||||||
|
@ -95,8 +83,6 @@ starlette==0.37.2
|
||||||
# via
|
# via
|
||||||
# -c requirements.txt
|
# -c requirements.txt
|
||||||
# fastapi
|
# fastapi
|
||||||
tomlkit==0.12.4
|
|
||||||
# via pylint
|
|
||||||
typing-extensions==4.11.0
|
typing-extensions==4.11.0
|
||||||
# via
|
# via
|
||||||
# -c requirements.txt
|
# -c requirements.txt
|
||||||
|
|
18
spud/cli.py
18
spud/cli.py
|
@ -8,6 +8,7 @@ import json
|
||||||
import pathlib
|
import pathlib
|
||||||
|
|
||||||
import click
|
import click
|
||||||
|
import httpx
|
||||||
import uvicorn
|
import uvicorn
|
||||||
|
|
||||||
from spud.config import Configuration
|
from spud.config import Configuration
|
||||||
|
@ -33,6 +34,9 @@ def cli(context, config):
|
||||||
|
|
||||||
if config_path.exists():
|
if config_path.exists():
|
||||||
context.obj["config"] = Configuration.from_file(config_path)
|
context.obj["config"] = Configuration.from_file(config_path)
|
||||||
|
context.obj["api_client"] = httpx.Client(
|
||||||
|
base_url=context.obj["config"].api_baseurl
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
context.obj["config"] = None
|
context.obj["config"] = None
|
||||||
|
|
||||||
|
@ -73,6 +77,19 @@ def print_config(context):
|
||||||
click.echo(config.model_dump_json(indent=2))
|
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.command()
|
||||||
@click.option(
|
@click.option(
|
||||||
"--reload",
|
"--reload",
|
||||||
|
@ -88,6 +105,7 @@ def daemon(reload):
|
||||||
|
|
||||||
cli.add_command(init)
|
cli.add_command(init)
|
||||||
cli.add_command(print_config)
|
cli.add_command(print_config)
|
||||||
|
cli.add_command(status)
|
||||||
cli.add_command(daemon)
|
cli.add_command(daemon)
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|
|
@ -7,6 +7,8 @@ import pydantic
|
||||||
class Configuration(pydantic.BaseModel):
|
class Configuration(pydantic.BaseModel):
|
||||||
"""Command line application configuration options"""
|
"""Command line application configuration options"""
|
||||||
|
|
||||||
|
api_baseurl: str
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_file(cls, path: pathlib.Path) -> "Configuration":
|
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
|
import fastapi
|
||||||
|
|
||||||
|
from spud.container_managers import PodmanManager, ServiceMetadata
|
||||||
|
|
||||||
app = fastapi.FastAPI()
|
app = fastapi.FastAPI()
|
||||||
|
|
||||||
|
manager = PodmanManager()
|
||||||
|
|
||||||
|
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
def alive():
|
def alive():
|
||||||
"""Live check."""
|
"""Live check."""
|
||||||
return 200
|
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
|
import spud.cli
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(name="sample_config")
|
||||||
|
def sample_config_fixture():
|
||||||
|
return {"api_baseurl": "http://test.url"}
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
def mock_default_config_path(monkeypatch, tmpdir):
|
def mock_default_config_path(monkeypatch, tmpdir):
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
import json
|
||||||
import pathlib
|
import pathlib
|
||||||
from unittest.mock import Mock
|
from unittest.mock import Mock
|
||||||
|
|
||||||
|
@ -5,11 +6,13 @@ import pytest
|
||||||
import uvicorn
|
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"
|
expected_default_path = tmpdir / ".config" / "spud" / "config.json"
|
||||||
|
|
||||||
pathlib.Path(expected_default_path).parent.mkdir(parents=True)
|
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"])
|
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 = tmpdir / "config.json"
|
||||||
|
|
||||||
expected_default_path.write("{}")
|
expected_default_path.write(json.dumps(sample_config))
|
||||||
|
|
||||||
result = invoke_cli(["--config", str(expected_default_path), "init"])
|
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
|
assert result.exit_code == 0
|
||||||
|
|
||||||
run_mock.assert_called_once_with("spud.daemon:app", reload=reload_flag)
|
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
|
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):
|
def test_from_file_creates_configuration_from_file(tmpdir, sample_config):
|
||||||
config_file = tmpdir / "config.json"
|
config_file = tmpdir / "config.json"
|
||||||
config_file.write(json.dumps(sample_config))
|
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):
|
with pytest.raises(RuntimeError):
|
||||||
Configuration.from_file(config_file)
|
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
|
import pytest
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
@ -13,3 +15,14 @@ def test_alive_returns_200(client):
|
||||||
response = client.get("/")
|
response = client.get("/")
|
||||||
|
|
||||||
assert response.status_code == 200
|
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