diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9d77b9c --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +__pycache__/ +*.py[cod] +.Python +env/ +venv/ +.venv/ +.mypy_cache/ +.pytest_cache/ +.DS_Store +*.log +/.idea/ +/.vscode/ diff --git a/API.md b/API.md new file mode 100644 index 0000000..431a1b6 --- /dev/null +++ b/API.md @@ -0,0 +1,82 @@ +# Server Inventory + +CRUD API and CLI for managing servers across data centers. Built with FastAPI and PostgreSQL using raw SQL. + +## Quickstart (Docker Compose) + +``` +docker compose up --build +``` + +Services: +- API at `http://localhost:8000` +- PostgreSQL at `postgresql://postgres:postgres@localhost:5432/server_inventory` + +Environment variables: +- `DATABASE_URL` (API) – defaults to `postgresql://postgres:postgres@localhost:5432/server_inventory` +- `API_BASE_URL` (CLI) – defaults to `http://localhost:8000` + +## Running Locally (without Docker) + +``` +python -m venv .venv +source .venv/bin/activate +pip install -r requirements.txt +export DATABASE_URL="postgresql://postgres:postgres@localhost:5432/server_inventory" +uvicorn app.main:app --reload +``` + +PostgreSQL must be running and reachable via `DATABASE_URL`. The app will create the `servers` table if it does not exist. + +## API Endpoints + +- `POST /servers` – create a server (unique `hostname`, valid IP, `state` in `active|offline|retired`) +- `GET /servers` – list all servers +- `GET /servers/{id}` – fetch one server +- `PUT /servers/{id}` – update an existing server +- `DELETE /servers/{id}` – delete a server + +Example request: + +```bash +curl -X POST http://localhost:8000/servers \ + -H "Content-Type: application/json" \ + -d '{"hostname": "web-1", "ip_address": "10.0.0.1", "state": "active"}' +``` + +## CLI + +Commands run against the API (set `API_BASE_URL` if needed): + +``` +python -m cli.main list +python -m cli.main get 1 +python -m cli.main create web-1 10.0.0.1 --state active +python -m cli.main update 1 --hostname web-2 --state offline +python -m cli.main delete 1 +``` + +`update` merges provided fields with the current record so you do not need to pass all fields. + +## Testing + +Pytest requires access to PostgreSQL. By default it connects to `postgresql://postgres:postgres@localhost:5432/postgres`, creates a temporary database named `server_inventory_test`, and drops it afterwards. Override with: + +- `TEST_DATABASE_ADMIN_URL` – admin DSN to create/drop the test database +- `TEST_DATABASE_NAME` – name of the temporary test database + +Run tests: + +``` +pytest +``` + +To test inside Docker, bring up the stack and run: + +``` +docker compose exec api env \ + TEST_DATABASE_ADMIN_URL=postgresql://postgres:postgres@db:5432/postgres \ + TEST_DATABASE_NAME=server_inventory_test \ + pytest -q +``` + diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..054f790 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,20 @@ +FROM python:3.11-slim + +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 + +WORKDIR /app + +ENV PYTHONPATH=/app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY app ./app +COPY cli ./cli +COPY tests ./tests +COPY README.md . + +EXPOSE 8000 + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..734782c --- /dev/null +++ b/app/__init__.py @@ -0,0 +1 @@ +"""Application package for the server inventory API.""" diff --git a/app/db.py b/app/db.py new file mode 100644 index 0000000..5ba3355 --- /dev/null +++ b/app/db.py @@ -0,0 +1,69 @@ +import os +from typing import Iterable, Tuple + +from psycopg import Connection +from psycopg.errors import UniqueViolation +from psycopg_pool import ConnectionPool + +DEFAULT_DATABASE_URL = "postgresql://postgres:postgres@localhost:5432/server_inventory" +ALLOWED_STATES: Tuple[str, ...] = ("active", "offline", "retired") + + +def get_database_url() -> str: + return os.getenv("DATABASE_URL", DEFAULT_DATABASE_URL) + + +def create_pool(database_url: str | None = None) -> ConnectionPool: + pool = ConnectionPool( + conninfo=database_url or get_database_url(), + min_size=1, + max_size=10, + timeout=10, + open=True, # explicit to avoid future default change warnings + ) + pool.wait() + return pool + + +def ensure_schema(pool: ConnectionPool) -> None: + with pool.connection() as conn: + with conn.cursor() as cur: + cur.execute( + """ + CREATE TABLE IF NOT EXISTS servers ( + id SERIAL PRIMARY KEY, + hostname TEXT NOT NULL UNIQUE, + ip_address TEXT NOT NULL, + state TEXT NOT NULL CHECK (state IN ('active', 'offline', 'retired')), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + """ + ) + cur.execute("CREATE INDEX IF NOT EXISTS idx_servers_hostname ON servers(hostname);") + conn.commit() + + +def row_to_dict(row: Iterable) -> dict: + id_, hostname, ip_address, state, created_at, updated_at = row + return { + "id": id_, + "hostname": hostname, + "ip_address": ip_address, + "state": state, + "created_at": created_at, + "updated_at": updated_at, + } + + +__all__ = [ + "ALLOWED_STATES", + "DEFAULT_DATABASE_URL", + "UniqueViolation", + "Connection", + "ConnectionPool", + "create_pool", + "ensure_schema", + "get_database_url", + "row_to_dict", +] diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..f1d96b7 --- /dev/null +++ b/app/main.py @@ -0,0 +1,132 @@ +import logging +from contextlib import asynccontextmanager +from typing import Generator, List, Optional + +from fastapi import Depends, FastAPI, HTTPException, Response, status + +from app import schemas +from app.db import ( + Connection, + ConnectionPool, + UniqueViolation, + create_pool, + ensure_schema, + row_to_dict, +) + +LOGGER = logging.getLogger(__name__) + + +def create_app(database_url: Optional[str] = None) -> FastAPI: + pool: ConnectionPool = create_pool(database_url) + + @asynccontextmanager + async def lifespan(app: FastAPI): + ensure_schema(pool) + yield + pool.close() + + app = FastAPI(title="Server Inventory API", version="1.0.0", lifespan=lifespan) + app.state.pool = pool + + def get_connection() -> Generator[Connection, None, None]: + with pool.connection() as conn: + yield conn + + @app.get("/health", status_code=status.HTTP_200_OK) + def health() -> dict: + return {"status": "ok"} + + @app.post("/servers", response_model=schemas.Server, status_code=status.HTTP_201_CREATED) + def create_server(server: schemas.ServerCreate, conn: Connection = Depends(get_connection)) -> dict: + try: + with conn.cursor() as cur: + cur.execute( + """ + INSERT INTO servers (hostname, ip_address, state) + VALUES (%s, %s, %s) + RETURNING id, hostname, ip_address, state, created_at, updated_at + """, + (server.hostname, str(server.ip_address), server.state), + ) + row = cur.fetchone() + conn.commit() + return row_to_dict(row) + except UniqueViolation: + conn.rollback() + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, detail="A server with this hostname already exists." + ) + + @app.get("/servers", response_model=List[schemas.Server]) + def list_servers(conn: Connection = Depends(get_connection)) -> list[dict]: + with conn.cursor() as cur: + cur.execute( + """ + SELECT id, hostname, ip_address, state, created_at, updated_at + FROM servers + ORDER BY id + """ + ) + rows = cur.fetchall() + return [row_to_dict(row) for row in rows] + + @app.get("/servers/{server_id}", response_model=schemas.Server) + def get_server(server_id: int, conn: Connection = Depends(get_connection)) -> dict: + with conn.cursor() as cur: + cur.execute( + """ + SELECT id, hostname, ip_address, state, created_at, updated_at + FROM servers + WHERE id = %s + """, + (server_id,), + ) + row = cur.fetchone() + if not row: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Server not found.") + return row_to_dict(row) + + @app.put("/servers/{server_id}", response_model=schemas.Server) + def update_server(server_id: int, server: schemas.ServerUpdate, conn: Connection = Depends(get_connection)) -> dict: + try: + with conn.cursor() as cur: + cur.execute( + """ + UPDATE servers + SET hostname = %s, + ip_address = %s, + state = %s, + updated_at = NOW() + WHERE id = %s + RETURNING id, hostname, ip_address, state, created_at, updated_at + """, + (server.hostname, str(server.ip_address), server.state, server_id), + ) + row = cur.fetchone() + if not row: + conn.rollback() + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Server not found.") + conn.commit() + return row_to_dict(row) + except UniqueViolation: + conn.rollback() + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, detail="A server with this hostname already exists." + ) + + @app.delete("/servers/{server_id}", status_code=status.HTTP_204_NO_CONTENT) + def delete_server(server_id: int, conn: Connection = Depends(get_connection)) -> Response: + with conn.cursor() as cur: + cur.execute("DELETE FROM servers WHERE id = %s RETURNING id;", (server_id,)) + row = cur.fetchone() + if not row: + conn.rollback() + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Server not found.") + conn.commit() + return Response(status_code=status.HTTP_204_NO_CONTENT) + + return app + + +app = create_app() diff --git a/app/schemas.py b/app/schemas.py new file mode 100644 index 0000000..6cd4355 --- /dev/null +++ b/app/schemas.py @@ -0,0 +1,28 @@ +from datetime import datetime +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field, IPvAnyAddress, constr + +StateLiteral = Literal["active", "offline", "retired"] + + +class ServerBase(BaseModel): + hostname: constr(strip_whitespace=True, min_length=1) = Field(..., description="Unique hostname for the server") + ip_address: IPvAnyAddress = Field(..., description="IPv4 or IPv6 address") + state: StateLiteral = Field(..., description="Operational state of the server") + + +class ServerCreate(ServerBase): + pass + + +class ServerUpdate(ServerBase): + pass + + +class Server(ServerBase): + id: int + created_at: datetime + updated_at: datetime + + model_config = ConfigDict(from_attributes=True) diff --git a/cli/__init__.py b/cli/__init__.py new file mode 100644 index 0000000..534141a --- /dev/null +++ b/cli/__init__.py @@ -0,0 +1 @@ +"""Command-line utilities for managing the server inventory through the API.""" diff --git a/cli/main.py b/cli/main.py new file mode 100644 index 0000000..157e99d --- /dev/null +++ b/cli/main.py @@ -0,0 +1,121 @@ +import json +import os +from typing import Optional + +import requests +import typer + +VALID_STATES = ("active", "offline", "retired") + +cli = typer.Typer(help="Manage servers through the Server Inventory API.") + + +def api_base_url() -> str: + return os.getenv("API_BASE_URL", "http://localhost:8000") + + +def handle_response(response: requests.Response) -> Optional[dict]: + if response.status_code >= 400: + try: + detail = response.json() + except ValueError: + detail = response.text + typer.echo(f"Error {response.status_code}: {detail}", err=True) + raise typer.Exit(code=1) + if response.status_code == 204: + return None + return response.json() + + +def print_json(data: object) -> None: + typer.echo(json.dumps(data, indent=2, sort_keys=True)) + + +@cli.command("list") +def list_servers() -> None: + """List all servers.""" + response = requests.get(f"{api_base_url()}/servers") + data = handle_response(response) + print_json(data) + + +@cli.command("get") +def get_server(server_id: int) -> None: + """Get a single server by ID.""" + response = requests.get(f"{api_base_url()}/servers/{server_id}") + data = handle_response(response) + print_json(data) + + +@cli.command("create") +def create_server( + hostname: str = typer.Argument(..., help="Unique hostname"), + ip_address: str = typer.Argument(..., help="IPv4 or IPv6 address"), + state: str = typer.Option("active", help=f"Server state ({', '.join(VALID_STATES)})"), +) -> None: + """Create a new server.""" + state = state.lower() + if state not in VALID_STATES: + typer.echo(f"State must be one of: {', '.join(VALID_STATES)}", err=True) + raise typer.Exit(code=1) + + response = requests.post( + f"{api_base_url()}/servers", + json={"hostname": hostname, "ip_address": ip_address, "state": state}, + ) + data = handle_response(response) + print_json(data) + + +@cli.command("update") +def update_server( + server_id: int = typer.Argument(..., help="Server ID"), + hostname: str = typer.Option(None, help="New hostname"), + ip_address: str = typer.Option(None, help="New IP address"), + state: str = typer.Option(None, help=f"New state ({', '.join(VALID_STATES)})"), +) -> None: + """Update an existing server.""" + payload = {} + if hostname is not None: + payload["hostname"] = hostname + if ip_address is not None: + payload["ip_address"] = ip_address + if state is not None: + state = state.lower() + if state not in VALID_STATES: + typer.echo(f"State must be one of: {', '.join(VALID_STATES)}", err=True) + raise typer.Exit(code=1) + payload["state"] = state + + if not payload: + typer.echo("Nothing to update. Provide at least one field.", err=True) + raise typer.Exit(code=1) + + # The API expects all fields; fetch current and merge. + current = handle_response(requests.get(f"{api_base_url()}/servers/{server_id}")) + current_subset = { + "hostname": current["hostname"], + "ip_address": current["ip_address"], + "state": current["state"], + } + payload = {**current_subset, **payload} + + response = requests.put(f"{api_base_url()}/servers/{server_id}", json=payload) + data = handle_response(response) + print_json(data) + + +@cli.command("delete") +def delete_server(server_id: int) -> None: + """Delete a server.""" + response = requests.delete(f"{api_base_url()}/servers/{server_id}") + handle_response(response) + typer.echo("Deleted.") + + +def main() -> None: + cli() + + +if __name__ == "__main__": + main() diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..6fe937a --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,30 @@ +services: + db: + image: postgres:15 + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: server_inventory + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 5s + timeout: 5s + retries: 5 + + api: + build: . + environment: + DATABASE_URL: postgresql://postgres:postgres@db:5432/server_inventory + depends_on: + db: + condition: service_healthy + ports: + - "8000:8000" + command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload + +volumes: + postgres_data: diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..b39f297 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,8 @@ +fastapi==0.110.0 +uvicorn[standard]==0.27.1 +psycopg[binary,pool]==3.1.18 +typer==0.12.3 +click==8.1.7 +requests==2.31.0 +pytest==8.1.1 +httpx==0.27.0 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..db2facf --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,57 @@ +import os +from urllib.parse import urlsplit, urlunsplit + +import pytest +import psycopg +from psycopg import sql +import httpx +from httpx import ASGITransport + +from app.db import ensure_schema +from app.main import create_app + +TEST_DB_NAME = os.getenv("TEST_DATABASE_NAME", "server_inventory_test") + + +def build_test_dsn(admin_dsn: str) -> str: + parts = urlsplit(admin_dsn) + path = f"/{TEST_DB_NAME}" + return urlunsplit((parts.scheme, parts.netloc, path, parts.query, parts.fragment)) + + +@pytest.fixture(scope="session") +def test_database_url(): + admin_dsn = os.getenv("TEST_DATABASE_ADMIN_URL", "postgresql://postgres:postgres@localhost:5432/postgres") + test_dsn = build_test_dsn(admin_dsn) + + with psycopg.connect(admin_dsn, autocommit=True) as conn: + with conn.cursor() as cur: + cur.execute("SELECT 1 FROM pg_database WHERE datname = %s;", (TEST_DB_NAME,)) + exists = cur.fetchone() is not None + if not exists: + cur.execute(sql.SQL("CREATE DATABASE {}").format(sql.Identifier(TEST_DB_NAME))) + + yield test_dsn + + with psycopg.connect(admin_dsn, autocommit=True) as conn: + with conn.cursor() as cur: + cur.execute(sql.SQL("DROP DATABASE IF EXISTS {} WITH (FORCE)").format(sql.Identifier(TEST_DB_NAME))) + + +@pytest.fixture +def app(test_database_url): + app = create_app(database_url=test_database_url) + ensure_schema(app.state.pool) + yield app + + +@pytest.fixture +async def client(app): + with app.state.pool.connection() as conn: + with conn.cursor() as cur: + cur.execute("TRUNCATE TABLE servers RESTART IDENTITY;") + conn.commit() + + transport = ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client: + yield client diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..98bb11e --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,85 @@ +from http import HTTPStatus + +import pytest + +pytestmark = pytest.mark.anyio + + +async def test_create_and_get_server(client): + create_response = await client.post( + "/servers", + json={"hostname": "web-1", "ip_address": "10.0.0.1", "state": "active"}, + ) + assert create_response.status_code == HTTPStatus.CREATED + created = create_response.json() + assert created["hostname"] == "web-1" + assert created["state"] == "active" + + get_response = await client.get(f"/servers/{created['id']}") + assert get_response.status_code == HTTPStatus.OK + fetched = get_response.json() + assert fetched["hostname"] == created["hostname"] + assert fetched["ip_address"] == created["ip_address"] + assert fetched["state"] == created["state"] + + +async def test_list_servers(client): + await client.post("/servers", json={"hostname": "db-1", "ip_address": "10.0.0.2", "state": "offline"}) + await client.post("/servers", json={"hostname": "cache-1", "ip_address": "10.0.0.3", "state": "retired"}) + + response = await client.get("/servers") + assert response.status_code == HTTPStatus.OK + servers = response.json() + hostnames = [srv["hostname"] for srv in servers] + assert hostnames == ["db-1", "cache-1"] + + +async def test_hostname_must_be_unique(client): + first = await client.post("/servers", json={"hostname": "dup-1", "ip_address": "10.0.0.4", "state": "active"}) + assert first.status_code == HTTPStatus.CREATED + + duplicate = await client.post( + "/servers", json={"hostname": "dup-1", "ip_address": "10.0.0.5", "state": "offline"} + ) + assert duplicate.status_code == HTTPStatus.CONFLICT + + +@pytest.mark.parametrize( + "payload", + [ + {"hostname": "bad-ip", "ip_address": "not-an-ip", "state": "active"}, + {"hostname": "bad-state", "ip_address": "10.0.0.6", "state": "paused"}, + ], +) +async def test_validation_errors(client, payload): + response = await client.post("/servers", json=payload) + assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY + + +async def test_update_server(client): + create_response = await client.post( + "/servers", json={"hostname": "old-host", "ip_address": "10.0.0.7", "state": "active"} + ) + server_id = create_response.json()["id"] + + update_response = await client.put( + f"/servers/{server_id}", + json={"hostname": "new-host", "ip_address": "10.0.0.8", "state": "offline"}, + ) + assert update_response.status_code == HTTPStatus.OK + updated = update_response.json() + assert updated["hostname"] == "new-host" + assert updated["state"] == "offline" + + +async def test_delete_server(client): + create_response = await client.post( + "/servers", json={"hostname": "to-delete", "ip_address": "10.0.0.9", "state": "active"} + ) + server_id = create_response.json()["id"] + + delete_response = await client.delete(f"/servers/{server_id}") + assert delete_response.status_code == HTTPStatus.NO_CONTENT + + get_response = await client.get(f"/servers/{server_id}") + assert get_response.status_code == HTTPStatus.NOT_FOUND