diff --git a/.env b/.env new file mode 100644 index 0000000..c1e8d13 --- /dev/null +++ b/.env @@ -0,0 +1,5 @@ +DB_USER="user" +DB_HOST="localhost" +DB_PORT=5432 +DB_NAME="server-db" +DB_PASS="password123" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1572ab3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +venv/* +__pycache__/* diff --git a/API.md b/API.md new file mode 100644 index 0000000..8c966ea --- /dev/null +++ b/API.md @@ -0,0 +1,121 @@ +# Mathpix hiring challenge API +## Overview +This is a submission for the mathpix devops hiring challenge. It uses the following libraries to achieve the requirements. +- [FastAPI](https://fastapi.tiangolo.com/) +- [pytest](https://docs.pytest.org/en/stable/) +- [pydantic](https://docs.pydantic.dev/latest/) +- [asyncpg](https://magicstack.github.io/asyncpg/current/) +- [aiosql](https://aiosql.github.io/aiosql/) +- [typer](https://typer.tiangolo.com/) + +The API is exposed through a docker container running FastAPI. The database connections are handled by asyncpg for simplicity and performance. For executing SQL queries, I use _almost_ raw sql through the form of aiosql. +### About aiosql +The choice to use aiosql comes down to it being effectively raw sql with a couple of extra features. aiosql allows me to have the queries written separate from the Python application logic (separation of concerns) but produces python bindings to these queries. This makes it simple to use the queries by themselves or load them in Python without much hassle. +## How to run +You'll need docker and python3 installed on your system to get started. + +### Environment Setup +There is an already existing .env file included as it does not contain actual secrets. Optionally, Create a `.env` file in the project root with some environment information: +``` +DB_USER="user" +DB_HOST="localhost" +DB_PORT=5432 +DB_NAME="server-db" +DB_PASS="password123" +``` + +```bash +# Installing the python libraries +python3 -m venv venv +source venv/bin/activate +pip install -r requirements.txt +``` + +### Starting the Application +For simplicity, use the [Justfile](https://just.systems/) to startup the docker compose. +```bash +just # shows all commands with what they do +just up # starts up the application and database +just fresh # reset database and rebuild +``` + +## Testing +Run the test suite with pytest. Tests require the database to be running: +```bash +just fresh # reset database first +just test # run pytest +``` +Note: For local testing, ensure your `.env` file has `DB_HOST=localhost` (not `postgres`). + +Tests use fixtures for cleanup, so they can run multiple times without conflicts. + +## API Endpoints + +Base URL: `http://localhost:8000` + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/` | Redirects to `/docs` (Swagger UI) | +| POST | `/servers` | Create a new server | +| GET | `/servers` | List all servers | +| GET | `/servers/{id}` | Get a server by ID | +| PUT | `/servers/{id}` | Update a server | +| DELETE | `/servers/{id}` | Delete a server | + +### Server Model +```json +{ + "id": 1, + "hostname": "web-01", + "ip": "192.168.1.100", + "state": "active", + "created_at": "2025-01-01T00:00:00" +} +``` + +### Create Server (POST /servers) +Request: +```json +{ + "hostname": "web-01", + "ip": "192.168.1.100" +} +``` +Response: `200` with server object, `409` if hostname/IP exists, `422` if invalid IP + +### Update Server (PUT /servers/{id}) +Request: Full server object with updated fields +Response: `200` with updated server, `404` if not found, `409` if hostname/IP conflict + +### Validation +- **hostname**: Must be unique +- **ip**: Must be valid IPv4 or IPv6 address +- **state**: One of `active`, `offline`, `retired` + +## CLI +The CLI tool in `cli.py` provides commands for interacting with the server API from your terminal. Available commands include: + +- `create `: Create a new server with the given hostname and IP address. +- `list`: Display all servers in a table. +- `get `: Show details for a specific server by its ID. +- `update [--hostname ] [--ip ] [--state ]`: Update server fields (provide only fields you wish to change). Valid states: `active`, `offline`, `retired`. +- `delete `: Remove a server by its ID. + +All commands provide user-friendly output and API error messages. For further help, run: +``` +python cli.py --help +``` +or for a command: +``` +python cli.py --help +``` +See code in `cli.py` for further details. + +Using the cli can be done both through regular python commands and the Justfile. The CLI is managed by the [Typer](https://typer.tiangolo.com/) library. The reason for this over argparse is mostly due to it being considered the 'FastAPI of CLIs' and I wanted to give it a shot. The CLI also has some nice QOL libraries that make the terminal output nice and modern such as [rich](https://rich.readthedocs.io/en/latest/). +```bash +just cli --help #see all the different cli commands + +#Both these commands are the same! +python3 cli.py list #show all servers +just cli list +``` diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..a8b5a34 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,27 @@ +FROM python:3.12-slim AS builder + +WORKDIR /app + +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . +RUN pip install --no-cache-dir --user -r requirements.txt + +FROM python:3.12-slim + +WORKDIR /app + +COPY --from=builder /root/.local /root/.local + +ENV PATH=/root/.local/bin:$PATH + +COPY main.py models.py queries.sql ./ + +EXPOSE 8000 + +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/')" || exit 1 + +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/Justfile b/Justfile new file mode 100644 index 0000000..49c3aed --- /dev/null +++ b/Justfile @@ -0,0 +1,50 @@ +alias r := run +alias t := test +alias d := down +alias f := fresh +alias c := cli + +VENV_BIN := "./venv/bin" + +@_default: + just --list + +run: + {{VENV_BIN}}/fastapi dev main.py + +test: + {{VENV_BIN}}/pytest tests.py -v + +@up: + docker compose up -d + +@down: + docker compose down + +@logs: + docker compose logs -f + +@build: + docker compose build + +@ps: + docker compose ps + +@restart: + docker compose restart + +@stop: + docker compose stop + +@start: + docker compose start + +@rebuild: + docker compose up -d --build + +@fresh: + docker compose down -v + docker compose up -d --build + +@cli *args: + {{VENV_BIN}}/python cli.py {{args}} diff --git a/cli.py b/cli.py new file mode 100644 index 0000000..20a2920 --- /dev/null +++ b/cli.py @@ -0,0 +1,148 @@ +import typer +import requests +import ipaddress +from typing import Optional +from rich.console import Console +from rich.table import Table +from rich import print as rprint +from models import ServerState + +app = typer.Typer() +console = Console() +BASE_URL = "http://localhost:8000" + + +def validate_ip(ip: str) -> str: + try: + ipaddress.ip_address(ip) + except ValueError: + raise typer.BadParameter(f"Invalid IP address: {ip}") + return ip + + +def handle_response(response: requests.Response) -> dict: + if response.status_code >= 400: + try: + error_detail = response.json().get("detail", "Unknown error") + except: + error_detail = response.text or f"HTTP {response.status_code}" + raise typer.BadParameter(f"API error: {error_detail}") + return response.json() + + +def make_request(method: str, url: str, **kwargs) -> requests.Response: + try: + return requests.request(method, url, **kwargs) + except requests.exceptions.ConnectionError: + raise typer.BadParameter( + f"Could not connect to API at {BASE_URL}. " + "Make sure the API server is running." + ) + + +@app.command() +def create(hostname: str, ip: str, owner:str): + """Create a new server.""" + validate_ip(ip) + response = make_request("POST", f"{BASE_URL}/servers", json={ + "hostname": hostname, "ip": ip, "owner": owner + }) + server = handle_response(response) + rprint(f"[green]✓[/green] Created server: {server['hostname']} (ID: {server['id']})") + _print_server(server) + + +@app.command() +def list(): + """List all servers.""" + response = make_request("GET", f"{BASE_URL}/servers") + servers = handle_response(response) + + if not servers: + rprint("[yellow]No servers found[/yellow]") + return + + table = Table(title="Servers") + table.add_column("ID", style="cyan") + table.add_column("Hostname", style="magenta") + table.add_column("Owner", style="yellow") + table.add_column("IP", style="blue") + table.add_column("State", style="green") + table.add_column("Created At", style="dim") + + for server in servers: + table.add_row( + str(server["id"]), + server["hostname"], + server["owner"], + server["ip"], + server["state"], + server["created_at"] + ) + + console.print(table) + + +@app.command() +def get(id: int): + """Get a server by ID.""" + response = make_request("GET", f"{BASE_URL}/servers/{id}") + server = handle_response(response) + _print_server(server) + + +@app.command() +def update( + id: int, + hostname: Optional[str] = None, + owner: Optional[str] = None, + ip: Optional[str] = None, + state: Optional[ServerState] = None +): + """Update a server. Provide only the fields you want to update.""" + if ip: + validate_ip(ip) + get_response = make_request("GET", f"{BASE_URL}/servers/{id}") + current_server = handle_response(get_response) + + update_data = { + "id": current_server["id"], + "hostname": hostname or current_server["hostname"], + "owner": owner or current_server["owner"], + "ip": ip or current_server["ip"], + "state": state.value if state else current_server["state"], + "created_at": current_server["created_at"] + } + + response = make_request("PUT", f"{BASE_URL}/servers/{id}", json=update_data) + server = handle_response(response) + rprint(f"[green]✓[/green] Updated server {id}") + _print_server(server) + + +@app.command() +def delete(id: int): + """Delete a server by ID.""" + response = make_request("DELETE", f"{BASE_URL}/servers/{id}") + server = handle_response(response) + rprint(f"[green]✓[/green] Deleted server: {server['hostname']} (ID: {server['id']})") + + +def _print_server(server: dict): + """Print a single server in a formatted way.""" + table = Table(show_header=False, box=None) + table.add_column("Field", style="cyan", width=12) + table.add_column("Value", style="white") + + table.add_row("ID", str(server["id"])) + table.add_row("Hostname", server["hostname"]) + table.add_row("Owner", server["owner"]) + table.add_row("IP", server["ip"]) + table.add_row("State", server["state"]) + table.add_row("Created At", server["created_at"]) + + console.print(table) + + +if __name__ == "__main__": + app() diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..a84ca04 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,49 @@ +services: + api: + build: + context: . + dockerfile: Dockerfile + container_name: server_api + ports: + - "8000:8000" + environment: + - APP_URL=http://localhost:8000 + - DB_USER=${DB_USER} + - DB_PASS=${DB_PASS} + - DB_HOST=postgres + - DB_PORT=5432 + - DB_NAME=${DB_NAME} + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/')"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 10s + restart: unless-stopped + depends_on: + postgres: + condition: service_healthy + networks: + - server-network + + postgres: + image: postgres:latest + container_name: server_db + environment: + POSTGRES_USER: ${DB_USER} + POSTGRES_PASSWORD: ${DB_PASS} + POSTGRES_DB: ${DB_NAME} + ports: + - "5432:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${DB_USER} -d ${DB_NAME}"] + interval: 5s + timeout: 5s + retries: 5 + restart: unless-stopped + networks: + - server-network + +networks: + server-network: + driver: bridge diff --git a/main.py b/main.py new file mode 100644 index 0000000..1f0cddd --- /dev/null +++ b/main.py @@ -0,0 +1,125 @@ +from fastapi import FastAPI, HTTPException, Request +from fastapi.responses import RedirectResponse +from typing import List +from contextlib import asynccontextmanager +from models import ServerModel, ServerCreate, ServerState +from dotenv import load_dotenv +import logging +import aiosql +import asyncpg +import os + +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', + datefmt='%Y-%m-%d %H:%M:%S' +) +load_dotenv() +DB_USER = os.getenv("DB_USER") +DB_PASS = os.getenv("DB_PASS") +DB_HOST = os.getenv("DB_HOST") +DB_PORT = os.getenv("DB_PORT") +DB_NAME = os.getenv("DB_NAME") +queries = None + +logger = logging.getLogger("fastapi") + +@asynccontextmanager +async def lifespan(app: FastAPI): + """Initialize database pool and schema on startup, cleanup on shutdown.""" + global queries + logger.info("Inventory server api starting up...") + queries = aiosql.from_path("queries.sql", "asyncpg") + pool = await asyncpg.create_pool( + dsn=f"postgres://{DB_USER}:{DB_PASS}@{DB_HOST}:{DB_PORT}/{DB_NAME}", + min_size=5, + max_size=20 + ) + app.state.pool = pool + # Use the create_schemas query from queries.sql to initialize the db + async with pool.acquire() as conn: + await queries.create_schemas(conn) + yield + logger.info("Inventory server api shutting down...") + await pool.close() + + +app = FastAPI(lifespan=lifespan) + + +@app.get("/") +async def root(): + """Redirect to API documentation.""" + return RedirectResponse(url="/docs") + + +@app.post("/servers", response_model=ServerModel) +async def create_server(request: Request, server: ServerCreate) -> ServerModel: + """Create a new server with hostname and IP address.""" + global queries + pool = request.app.state.pool + try: + async with pool.acquire() as conn: + record = await queries.create_server(conn, **server.model_dump()) + return ServerModel( + id=record['id'], + created_at=record['created_at'], + state=ServerState.ACTIVE, + **server.model_dump() + ) + except asyncpg.UniqueViolationError: + raise HTTPException(status_code=409, detail="Hostname or IP address already exists") + except Exception as e: + logger.exception("Error creating server") + raise HTTPException(status_code=400, detail=f"Failed to create server: {str(e)}") + +@app.get("/servers", response_model=List[ServerModel]) +async def list_servers(request: Request) -> List[ServerModel]: + """List all servers ordered by creation date.""" + global queries + pool = request.app.state.pool + async with pool.acquire() as conn: + results = queries.list_servers(conn) + return [ServerModel(**dict(row)) async for row in results] + +@app.get("/servers/{id}", response_model=ServerModel) +async def get_server(request: Request, id: int) -> ServerModel: + """Get a server by ID.""" + global queries + pool = request.app.state.pool + async with pool.acquire() as conn: + record = await queries.get_server_by_id(conn, id=id) + if not record: + raise HTTPException(status_code=404, detail=f"Server with id {id} not found") + return ServerModel(**dict(record)) + +@app.put("/servers/{id}", response_model=ServerModel) +async def update_server(request: Request, id: int, server: ServerModel) -> ServerModel: + """Update a server's hostname, IP, or state.""" + global queries + pool = request.app.state.pool + try: + data = server.model_dump() + data["state"] = server.state.value + async with pool.acquire() as conn: + resp = await queries.update_server(conn, **data) + except asyncpg.UniqueViolationError: + raise HTTPException(status_code=409, detail="Hostname or IP address or Owner already exists") + except Exception as e: + logger.exception("Error updating server") + raise HTTPException(status_code=400, detail=f"Failed to update server: {str(e)}") + if resp is None: + raise HTTPException(status_code=404, detail=f"Server with id {id} not found") + return ServerModel(**dict(resp)) + + +@app.delete("/servers/{id}", response_model=ServerModel) +async def delete_server(request: Request, id: int) -> ServerModel: + """Delete a server by ID.""" + global queries + pool = request.app.state.pool + async with pool.acquire() as conn: + resp = await queries.delete_server_by_id(conn, id=id) + if resp is None: + raise HTTPException(status_code=404, detail=f"Server with id {id} not found") + return ServerModel(**dict(resp)) diff --git a/models.py b/models.py new file mode 100644 index 0000000..82ad60d --- /dev/null +++ b/models.py @@ -0,0 +1,44 @@ +from datetime import datetime +from pydantic import BaseModel, field_validator +import enum +import ipaddress + + +def validate_ip_address(v: str) -> str: + try: + ipaddress.ip_address(v) + except ValueError: + raise ValueError('Invalid IP address format') + return v + + +class ServerState(enum.Enum): + ACTIVE = 'active' + OFFLINE = 'offline' + RETIRED = 'retired' + +class ServerModel(BaseModel): + model_config = {"from_attributes": True} + id: int + hostname: str + owner: str + ip: str + state: ServerState + created_at: datetime + + @field_validator('ip') + @classmethod + def validate_ip(cls, v: str) -> str: + return validate_ip_address(v) + + +class ServerCreate(BaseModel): + hostname: str + owner: str + ip: str + + @field_validator('ip') + @classmethod + def validate_ip(cls, v: str) -> str: + return validate_ip_address(v) + diff --git a/queries.sql b/queries.sql new file mode 100644 index 0000000..834d717 --- /dev/null +++ b/queries.sql @@ -0,0 +1,41 @@ +-- name: create_server