diff --git a/API.md b/API.md new file mode 100644 index 0000000..74e35c0 --- /dev/null +++ b/API.md @@ -0,0 +1,122 @@ +CRUD service for tracking the state of servers across multiple datacenters. + +The project is implemented as a small production-style backend service with: +- FastAPI +- PostgreSQL +- Raw SQL (psycopg) +- Docker Compose +- CLI tool +- pytest test suite (including Docker-based execution) + +--- + +## How to run the application + +### Requirements +- Docker +- Docker Compose + +From the project root directory: + +```bash +docker compose up --build + + +After startup the API will be available at: +API base URL: http://localhost:8000 +Swagger / OpenAPI UI: http://localhost:8000/docs + +A server has the following fields: + +{ + "id": 1, + "hostname": "srv-1", + "ip_address": "10.0.0.1", + "state": "active", + "datacenter": "berlin", + "created_at": "2026-01-18T12:17:01Z", + "updated_at": "2026-01-18T12:17:01Z" +} + +Validation rules: +hostname must be unique +ip_address must be a valid IPv4 or IPv6 address + +state must be one of: +active +offline +retired + +API endpoints +Create a server +curl -X POST http://localhost:8000/servers \ + -H 'Content-Type: application/json' \ + -d '{ + "hostname": "srv-1", + "ip_address": "10.0.0.1", + "state": "active", + "datacenter": "berlin" + }' + +List all servers +curl http://localhost:8000/servers + +Get server by id +curl http://localhost:8000/servers/1 + +Update a server +curl -X PUT http://localhost:8000/servers/1 \ + -H 'Content-Type: application/json' \ + -d '{ + "state": "offline" + }' + +Delete a server +curl -X DELETE http://localhost:8000/servers/1 + +The CLI communicates with the HTTP API. + +Optionally set the API URL: + +export API_URL=http://localhost:8000 + + +Examples: + +python cli.py create srv-2 10.0.0.2 active --datacenter ber +python cli.py list +python cli.py get 1 +python cli.py update 1 --state retired +python cli.py delete 1 + +Running tests (Docker) + +Tests can be executed entirely inside Docker, using the same image and dependencies +as the API service. + +Start PostgreSQL +docker compose up -d db + +Run tests in a container +docker compose run --rm tests + +Expected output: + +6 passed + +Notes about test execution: +Tests use the same PostgreSQL service defined in docker-compose.yml +The test suite truncates the servers table between tests +During negative tests (e.g. duplicate hostname), PostgreSQL logs +unique constraint violation errors — this is expected and handled by the API +Running tests locally (optional) + +If you prefer to run tests outside Docker: +export DATABASE_URL=postgresql://inventory:inventory@localhost:5432/inventory +pip install -r requirements.txt +pytest -q + +Notes: +Database schema is initialized automatically on application startup +All database operations are implemented using raw SQL (no ORM) +The CLI is a thin client that communicates with the API over HTTP diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..f107ce8 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,15 @@ +FROM python:3.12-slim + +WORKDIR /app + +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +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..e69de29 diff --git a/app/__pycache__/__init__.cpython-314.pyc b/app/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000..82ac119 Binary files /dev/null and b/app/__pycache__/__init__.cpython-314.pyc differ diff --git a/app/__pycache__/db.cpython-314.pyc b/app/__pycache__/db.cpython-314.pyc new file mode 100644 index 0000000..32761fa Binary files /dev/null and b/app/__pycache__/db.cpython-314.pyc differ diff --git a/app/__pycache__/main.cpython-314.pyc b/app/__pycache__/main.cpython-314.pyc new file mode 100644 index 0000000..35ec7eb Binary files /dev/null and b/app/__pycache__/main.cpython-314.pyc differ diff --git a/app/__pycache__/schemas.cpython-314.pyc b/app/__pycache__/schemas.cpython-314.pyc new file mode 100644 index 0000000..b9fbe13 Binary files /dev/null and b/app/__pycache__/schemas.cpython-314.pyc differ diff --git a/app/db.py b/app/db.py new file mode 100644 index 0000000..778f2c2 --- /dev/null +++ b/app/db.py @@ -0,0 +1,42 @@ +import os +from contextlib import contextmanager +from typing import Iterator + +import psycopg +from psycopg.rows import dict_row + +DATABASE_URL = os.getenv("DATABASE_URL", "postgresql://inventory:inventory@localhost:5432/inventory") + + +@contextmanager +def get_conn() -> Iterator[psycopg.Connection]: + conn = psycopg.connect(DATABASE_URL, row_factory=dict_row) + try: + yield conn + conn.commit() + except Exception: + conn.rollback() + raise + finally: + conn.close() + + +def init_db() -> None: + ddl = """ + CREATE TABLE IF NOT EXISTS servers ( + id BIGSERIAL PRIMARY KEY, + hostname TEXT NOT NULL UNIQUE, + ip_address INET NOT NULL, + state TEXT NOT NULL, + datacenter TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT servers_state_check CHECK (state IN ('active', 'offline', 'retired')) + ); + + CREATE INDEX IF NOT EXISTS idx_servers_state ON servers(state); + """ + with get_conn() as conn: + with conn.cursor() as cur: + cur.execute(ddl) + diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..f8aeeca --- /dev/null +++ b/app/main.py @@ -0,0 +1,135 @@ +from contextlib import asynccontextmanager + +from fastapi import FastAPI, HTTPException, Response, status +from psycopg.errors import CheckViolation, UniqueViolation + +from app.db import get_conn, init_db +from app.schemas import ServerCreate, ServerOut, ServerUpdate + + +@asynccontextmanager +async def lifespan(app: FastAPI): + # Initialize DB schema on startup + init_db() + yield + # Nothing to cleanup here (connections are per-request) + + +app = FastAPI(title="Server Inventory API", version="1.0.0", lifespan=lifespan) + + +def _row_to_out(row: dict) -> ServerOut: + return ServerOut( + id=row["id"], + hostname=row["hostname"], + ip_address=str(row["ip_address"]), + state=row["state"], + datacenter=row.get("datacenter"), + created_at=row["created_at"].isoformat(), + updated_at=row["updated_at"].isoformat(), + ) + +@app.post("/servers", response_model=ServerOut, status_code=status.HTTP_201_CREATED) +def create_server(payload: ServerCreate) -> ServerOut: + sql = """ + INSERT INTO servers (hostname, ip_address, state, datacenter) + VALUES (%s, %s, %s, %s) + RETURNING id, hostname, ip_address, state, datacenter, created_at, updated_at; + """ + try: + with get_conn() as conn: + with conn.cursor() as cur: + cur.execute(sql, (payload.hostname, str(payload.ip_address), payload.state.value, payload.datacenter)) + row = cur.fetchone() + return _row_to_out(row) + except UniqueViolation: + raise HTTPException(status_code=409, detail="hostname must be unique") + except CheckViolation: + # should be prevented by pydantic Enum, but keep DB safety + raise HTTPException(status_code=422, detail="invalid state") + + +@app.get("/servers", response_model=list[ServerOut]) +def list_servers() -> list[ServerOut]: + sql = """ + SELECT id, hostname, ip_address, state, datacenter, created_at, updated_at + FROM servers + ORDER BY id; + """ + with get_conn() as conn: + with conn.cursor() as cur: + cur.execute(sql) + rows = cur.fetchall() + return [_row_to_out(r) for r in rows] + + +@app.get("/servers/{server_id}", response_model=ServerOut) +def get_server(server_id: int) -> ServerOut: + sql = """ + SELECT id, hostname, ip_address, state, datacenter, created_at, updated_at + FROM servers + WHERE id = %s; + """ + with get_conn() as conn: + with conn.cursor() as cur: + cur.execute(sql, (server_id,)) + row = cur.fetchone() + if not row: + raise HTTPException(status_code=404, detail="server not found") + return _row_to_out(row) + + +@app.put("/servers/{server_id}", response_model=ServerOut) +def update_server(server_id: int, payload: ServerUpdate) -> ServerOut: + # Build dynamic raw SQL safely with whitelist + parameter list + fields = [] + params: list[object] = [] + + if payload.hostname is not None: + fields.append("hostname = %s") + params.append(payload.hostname) + if payload.ip_address is not None: + fields.append("ip_address = %s") + params.append(str(payload.ip_address)) + if payload.state is not None: + fields.append("state = %s") + params.append(payload.state.value) + if payload.datacenter is not None: + fields.append("datacenter = %s") + params.append(payload.datacenter) + + if not fields: + raise HTTPException(status_code=400, detail="no fields to update") + + sql = f""" + UPDATE servers + SET {", ".join(fields)}, updated_at = now() + WHERE id = %s + RETURNING id, hostname, ip_address, state, datacenter, created_at, updated_at; + """ + params.append(server_id) + + try: + with get_conn() as conn: + with conn.cursor() as cur: + cur.execute(sql, tuple(params)) + row = cur.fetchone() + if not row: + raise HTTPException(status_code=404, detail="server not found") + return _row_to_out(row) + except UniqueViolation: + raise HTTPException(status_code=409, detail="hostname must be unique") + except CheckViolation: + raise HTTPException(status_code=422, detail="invalid state") + + +@app.delete("/servers/{server_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_server(server_id: int) -> Response: + sql = "DELETE FROM servers WHERE id = %s;" + with get_conn() as conn: + with conn.cursor() as cur: + cur.execute(sql, (server_id,)) + if cur.rowcount == 0: + raise HTTPException(status_code=404, detail="server not found") + return Response(status_code=status.HTTP_204_NO_CONTENT) + diff --git a/app/schemas.py b/app/schemas.py new file mode 100644 index 0000000..080aec1 --- /dev/null +++ b/app/schemas.py @@ -0,0 +1,35 @@ +from enum import Enum +from typing import Optional + +from pydantic import BaseModel, Field, IPvAnyAddress + + +class ServerState(str, Enum): + active = "active" + offline = "offline" + retired = "retired" + + +class ServerCreate(BaseModel): + hostname: str = Field(min_length=1, max_length=255) + ip_address: IPvAnyAddress + state: ServerState + datacenter: Optional[str] = Field(default=None, max_length=255) + + +class ServerUpdate(BaseModel): + hostname: Optional[str] = Field(default=None, min_length=1, max_length=255) + ip_address: Optional[IPvAnyAddress] = None + state: Optional[ServerState] = None + datacenter: Optional[str] = Field(default=None, max_length=255) + + +class ServerOut(BaseModel): + id: int + hostname: str + ip_address: str + state: ServerState + datacenter: Optional[str] = None + created_at: str + updated_at: str + diff --git a/cli.py b/cli.py new file mode 100644 index 0000000..f235c80 --- /dev/null +++ b/cli.py @@ -0,0 +1,79 @@ +import os +from typing import Optional + +import requests +import typer + +app = typer.Typer(help="Server Inventory CLI (talks to the REST API)") + +API_URL = os.getenv("API_URL", "http://localhost:8000") + + +def _handle(resp: requests.Response): + if resp.status_code >= 400: + try: + detail = resp.json() + except Exception: + detail = resp.text + raise typer.Exit(code=1) from Exception(f"HTTP {resp.status_code}: {detail}") + if resp.status_code == 204: + return None + return resp.json() + + +@app.command() +def create(hostname: str, ip: str, state: str, datacenter: Optional[str] = None): + """Create a server.""" + payload = {"hostname": hostname, "ip_address": ip, "state": state, "datacenter": datacenter} + r = requests.post(f"{API_URL}/servers", json=payload, timeout=10) + typer.echo(_handle(r)) + + +@app.command("list") +def list_cmd(): + """List all servers.""" + r = requests.get(f"{API_URL}/servers", timeout=10) + typer.echo(_handle(r)) + + +@app.command() +def get(id: int): + """Get one server by id.""" + r = requests.get(f"{API_URL}/servers/{id}", timeout=10) + typer.echo(_handle(r)) + + +@app.command() +def update( + id: int, + hostname: Optional[str] = None, + ip: Optional[str] = None, + state: Optional[str] = None, + datacenter: Optional[str] = None, +): + """Update a server (only provided fields are changed).""" + payload = {} + if hostname is not None: + payload["hostname"] = hostname + if ip is not None: + payload["ip_address"] = ip + if state is not None: + payload["state"] = state + if datacenter is not None: + payload["datacenter"] = datacenter + + r = requests.put(f"{API_URL}/servers/{id}", json=payload, timeout=10) + typer.echo(_handle(r)) + + +@app.command() +def delete(id: int): + """Delete a server by id.""" + r = requests.delete(f"{API_URL}/servers/{id}", timeout=10) + _handle(r) + typer.echo("deleted") + + +if __name__ == "__main__": + app() + diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..812e3ae --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,35 @@ +services: + db: + image: postgres:16 + environment: + POSTGRES_DB: inventory + POSTGRES_USER: inventory + POSTGRES_PASSWORD: inventory + ports: + - "5432:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U inventory -d inventory"] + interval: 2s + timeout: 3s + retries: 30 + + api: + build: . + environment: + DATABASE_URL: postgresql://inventory:inventory@db:5432/inventory + APP_ENV: dev + ports: + - "8000:8000" + depends_on: + db: + condition: service_healthy + + tests: + build: . + environment: + DATABASE_URL: postgresql://inventory:inventory@db:5432/inventory + depends_on: + db: + condition: service_healthy + command: ["pytest", "-q"] + diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..3e3a32b --- /dev/null +++ b/pytest.ini @@ -0,0 +1,5 @@ +[pytest] +testpaths = tests +filterwarnings = + ignore:'asyncio\.iscoroutinefunction' is deprecated:DeprecationWarning + diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..79a1e26 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,9 @@ +fastapi==0.115.6 +uvicorn[standard]==0.32.1 +psycopg[binary]==3.2.13 +typer==0.15.1 +requests==2.32.3 + +pytest==8.3.4 +httpx==0.28.1 + diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/__pycache__/__init__.cpython-314.pyc b/tests/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000..6373af2 Binary files /dev/null and b/tests/__pycache__/__init__.cpython-314.pyc differ diff --git a/tests/__pycache__/conftest.cpython-314-pytest-8.3.4.pyc b/tests/__pycache__/conftest.cpython-314-pytest-8.3.4.pyc new file mode 100644 index 0000000..bff7be7 Binary files /dev/null and b/tests/__pycache__/conftest.cpython-314-pytest-8.3.4.pyc differ diff --git a/tests/__pycache__/test_servers_api.cpython-314-pytest-8.3.4.pyc b/tests/__pycache__/test_servers_api.cpython-314-pytest-8.3.4.pyc new file mode 100644 index 0000000..3249be1 Binary files /dev/null and b/tests/__pycache__/test_servers_api.cpython-314-pytest-8.3.4.pyc differ diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..2928b46 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,30 @@ +import os + +import pytest +from fastapi.testclient import TestClient + +from app.db import init_db, get_conn +from app.main import app + +TEST_DATABASE_URL = os.getenv("DATABASE_URL", "postgresql://inventory:inventory@localhost:5432/inventory") + + +@pytest.fixture(autouse=True, scope="session") +def _init(): + # ensure schema exists + init_db() + + +@pytest.fixture(autouse=True) +def _clean_db(): + # truncate before each test for isolation + with get_conn() as conn: + with conn.cursor() as cur: + cur.execute("TRUNCATE TABLE servers RESTART IDENTITY;") + yield + + +@pytest.fixture() +def client(): + return TestClient(app) + diff --git a/tests/test_servers_api.py b/tests/test_servers_api.py new file mode 100644 index 0000000..4b56b5f --- /dev/null +++ b/tests/test_servers_api.py @@ -0,0 +1,67 @@ +def test_crud_happy_path(client): + # create + r = client.post( + "/servers", + json={"hostname": "srv-1", "ip_address": "10.0.0.1", "state": "active", "datacenter": "dc-ber"}, + ) + assert r.status_code == 201 + data = r.json() + assert data["id"] == 1 + assert data["hostname"] == "srv-1" + assert data["ip_address"] == "10.0.0.1" + assert data["state"] == "active" + + # list + r = client.get("/servers") + assert r.status_code == 200 + lst = r.json() + assert len(lst) == 1 + + # get one + r = client.get("/servers/1") + assert r.status_code == 200 + assert r.json()["hostname"] == "srv-1" + + # update + r = client.put("/servers/1", json={"state": "offline", "ip_address": "10.0.0.2"}) + assert r.status_code == 200 + assert r.json()["state"] == "offline" + assert r.json()["ip_address"] == "10.0.0.2" + + # delete + r = client.delete("/servers/1") + assert r.status_code == 204 + + # verify gone + r = client.get("/servers/1") + assert r.status_code == 404 + + +def test_hostname_unique(client): + r1 = client.post("/servers", json={"hostname": "dup", "ip_address": "10.0.0.1", "state": "active"}) + assert r1.status_code == 201 + + r2 = client.post("/servers", json={"hostname": "dup", "ip_address": "10.0.0.2", "state": "offline"}) + assert r2.status_code == 409 + assert "unique" in r2.json()["detail"] + + +def test_ip_validation(client): + r = client.post("/servers", json={"hostname": "badip", "ip_address": "not-an-ip", "state": "active"}) + assert r.status_code == 422 # pydantic validation + + +def test_state_validation(client): + r = client.post("/servers", json={"hostname": "badstate", "ip_address": "10.0.0.3", "state": "broken"}) + assert r.status_code == 422 # enum validation + + +def test_update_not_found(client): + r = client.put("/servers/999", json={"state": "offline"}) + assert r.status_code == 404 + + +def test_delete_not_found(client): + r = client.delete("/servers/999") + assert r.status_code == 404 +