Skip to content

Commit

Permalink
Add pool controller
Browse files Browse the repository at this point in the history
This lets API users list pools and query individual ones.

In the course, sort the API online doc sections a little.

Related: CentOS#417

Signed-off-by: Nils Philippsen <nils@redhat.com>
  • Loading branch information
nphilipp committed Jun 8, 2022
1 parent 4e710de commit d031569
Show file tree
Hide file tree
Showing 6 changed files with 223 additions and 6 deletions.
8 changes: 8 additions & 0 deletions duffy/api_models/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
from .common import APIResult, APIResultAction # noqa: F401
from .node import NodeCreateModel, NodeModel, NodeResult, NodeResultCollection # noqa: F401
from .pool import ( # noqa: F401
PoolConciseModel,
PoolLevelsModel,
PoolModel,
PoolResult,
PoolResultCollection,
PoolVerboseModel,
)
from .session import ( # noqa: F401
SessionCreateModel,
SessionModel,
Expand Down
46 changes: 46 additions & 0 deletions duffy/api_models/pool.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
from abc import ABC
from typing import List, Union

from pydantic import BaseModel, Field, conint

from .common import APIResult

# pool model


class PoolLevelsModel(BaseModel):
provisioning: conint(ge=0)
ready: conint(ge=0)
contextualizing: conint(ge=0)
deployed: conint(ge=0)
deprovisioning: conint(ge=0)


class PoolBase(BaseModel, ABC):
name: str
fill_level: conint(ge=0) = Field(alias="fill-level")

class Config:
extra = "forbid"


class PoolConciseModel(PoolBase):
pass


class PoolVerboseModel(PoolConciseModel):
levels: PoolLevelsModel


PoolModel = Union[PoolConciseModel, PoolVerboseModel]


# API results


class PoolResult(APIResult):
pool: PoolModel


class PoolResultCollection(APIResult):
pools: List[PoolModel]
60 changes: 60 additions & 0 deletions duffy/app/controllers/pool.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
"""This is the pool controller."""

from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from starlette.status import HTTP_404_NOT_FOUND, HTTP_422_UNPROCESSABLE_ENTITY

from ...api_models import PoolResult, PoolResultCollection
from ...database.model import Node
from ...nodes.pools import ConcreteNodePool
from ..database import req_db_async_session

router = APIRouter(prefix="/pools")


# http get http://localhost:8080/api/v1/pools
@router.get("", response_model=PoolResultCollection, tags=["pools"])
async def get_all_pools(db_async_session: AsyncSession = Depends(req_db_async_session)):
"""Return all pools."""
pools = [
{"name": pool.name, "fill-level": pool["fill-level"]}
for pool in ConcreteNodePool.iter_pools()
if "fill-level" in pool
]

return {"action": "get", "pools": pools}


# http get http://localhost:8080/api/v1/pool/name-of-the-pool
@router.get("/{name}", response_model=PoolResult, tags=["pools"])
async def get_pool(name: str, db_async_session: AsyncSession = Depends(req_db_async_session)):
"""Return the pool with the specified **NAME**."""
pool = ConcreteNodePool.known_pools.get(name)

if pool is None:
raise HTTPException(HTTP_404_NOT_FOUND)

if "fill-level" not in pool:
raise HTTPException(HTTP_422_UNPROCESSABLE_ENTITY)

pool_result = {
"name": name,
"fill-level": pool["fill-level"],
"levels": {
"provisioning": 0,
"ready": 0,
"contextualizing": 0,
"deployed": 0,
"deprovisioning": 0,
},
}

for state, quantity in await db_async_session.execute(
select(Node.state, func.count(Node.state))
.filter(Node.active == True, Node.pool == name) # noqa: E712
.group_by(Node.state)
):
pool_result["levels"][state.name] = quantity

return {"action": "get", "pool": pool_result}
21 changes: 16 additions & 5 deletions duffy/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@

from .. import database, tasks
from ..exceptions import DuffyConfigurationError
from ..nodes.pools import NodePool
from ..version import __version__
from .controllers import node, session, tenant
from .controllers import node, pool, session, tenant

log = logging.getLogger(__name__)

Expand All @@ -17,9 +18,10 @@
"""

tags_metadata = [
{"name": "nodes", "description": "Operations with physical and virtual nodes"},
{"name": "sessions", "description": "Operations with sessions"},
{"name": "tenants", "description": "Operations with tenants"},
{"name": "sessions", "description": "Operations on sessions"},
{"name": "pools", "description": "Operations on node pools"},
{"name": "nodes", "description": "Operations on physical and virtual nodes"},
{"name": "tenants", "description": "Operations on tenants"},
]

app = FastAPI(
Expand All @@ -35,9 +37,18 @@

PREFIX = "/api/v1"

app.include_router(session.router, prefix=PREFIX)
app.include_router(pool.router, prefix=PREFIX)
app.include_router(node.router, prefix=PREFIX)
app.include_router(tenant.router, prefix=PREFIX)
app.include_router(session.router, prefix=PREFIX)


# Post-process configuration


@app.on_event("startup")
async def post_process_config():
NodePool.process_configuration()


# DB model initialization
Expand Down
86 changes: 86 additions & 0 deletions tests/app/controllers/test_pool.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
from unittest import mock

import pytest
from starlette.status import HTTP_200_OK, HTTP_404_NOT_FOUND, HTTP_422_UNPROCESSABLE_ENTITY

from duffy.database.model import Node


class MockPool(dict):
def __init__(self, name, **kwargs):
self.name = name
super().__init__(**kwargs)


@pytest.mark.duffy_config(example_config=True, clear=True)
class TestPool:
@mock.patch("duffy.app.controllers.pool.ConcreteNodePool")
async def test_get_all_pools(self, ConcreteNodePool, client):
ConcreteNodePool.iter_pools.return_value = [
MockPool(name="foo"), # missing fill-level, shouldn't be listed
MockPool(name="bar", **{"fill-level": 64}),
]

response = await client.get("/api/v1/pools")
result = response.json()

ConcreteNodePool.iter_pools.assert_called_once_with()

assert result["pools"] == [{"name": "bar", "fill-level": 64}]

@pytest.mark.parametrize("pool", ("foo", "bar", "baz"))
@mock.patch("duffy.app.controllers.pool.ConcreteNodePool")
async def test_get_pools(
self, ConcreteNodePool, pool, client, db_async_session, db_async_model_initialized
):
ConcreteNodePool.known_pools = {
"foo": MockPool(name="foo"), # missing fill-level, shouldn't be listed
"bar": MockPool(name="bar", **{"fill-level": 64}),
}

ipaddr_octet = 1
for state, quantity in (("ready", 3), ("deployed", 2)):
for idx in range(quantity):
ipaddr_octet += 1
db_async_session.add(
Node(
hostname=f"node-{ipaddr_octet}",
ipaddr=f"192.168.1.{ipaddr_octet}",
pool="bar",
state=state,
)
)
await db_async_session.flush()

if pool == "bar":
expected_status = HTTP_200_OK
expected_result = {
"action": "get",
"pool": {
"name": "bar",
"fill-level": 64,
"levels": {
"provisioning": 0,
"ready": 3,
"contextualizing": 0,
"deployed": 2,
"deprovisioning": 0,
},
},
}
else:
expected_result = None
if pool == "foo":
expected_status = HTTP_422_UNPROCESSABLE_ENTITY
else:
expected_status = HTTP_404_NOT_FOUND

response = await client.get(f"/api/v1/pools/{pool}")
result = response.json()

assert response.status_code == expected_status

if expected_result:
assert result == expected_result
else:
assert "detail" in result
8 changes: 7 additions & 1 deletion tests/app/test_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

import pytest

from duffy.app.main import app, init_model, init_tasks
from duffy.app.main import app, init_model, init_tasks, post_process_config
from duffy.exceptions import DuffyConfigurationError

from ..util import noop_context
Expand Down Expand Up @@ -39,6 +39,12 @@ async def test_redoc_docs(self, client):
parser = HTMLParser()
parser.feed(response.text)

@mock.patch("duffy.app.main.NodePool")
async def test_post_process_config(self, NodePool):
await post_process_config()

NodePool.process_configuration.assert_called_once_with()

@pytest.mark.parametrize("config_error", (False, True))
@mock.patch("duffy.database.init_async_model")
@mock.patch("duffy.database.init_sync_model")
Expand Down

0 comments on commit d031569

Please sign in to comment.