From 0e544b41a7ae69483e67747953ed1a3a31afcc05 Mon Sep 17 00:00:00 2001 From: XV1R Date: Wed, 17 Dec 2025 00:30:51 -0500 Subject: [PATCH 1/3] Init --- .gitignore | 2 + Dockerfile | 27 +++++ Justfile | 13 +++ docker-compose.yml | 40 +++++++ main.py | 85 +++++++++++++++ models.py | 21 ++++ myWork.txt | 261 +++++++++++++++++++++++++++++++++++++++++++++ queries.sql | 31 ++++++ requirements.txt | 42 ++++++++ 9 files changed, 522 insertions(+) create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 Justfile create mode 100644 docker-compose.yml create mode 100644 main.py create mode 100644 models.py create mode 100644 myWork.txt create mode 100644 queries.sql create mode 100644 requirements.txt 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/Dockerfile b/Dockerfile new file mode 100644 index 0000000..393d57c --- /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 ./ + +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", "app:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/Justfile b/Justfile new file mode 100644 index 0000000..bbf44d1 --- /dev/null +++ b/Justfile @@ -0,0 +1,13 @@ +alias r := run +alias t := test + +VENV_BIN := "./venv/bin" + +@_default: + just --list + +run: + {{VENV_BIN}}/fastapi dev main.py + +test: + {{VENV_BIN}}/pytest diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..653e540 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,40 @@ +services: + api: + build: + context: . + dockerfile: Dockerfile + container_name: server_api + ports: + - "8000:8000" + environment: + - APP_URl=http//api:8000 + 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 + networks: + - server-network + + postgres: + image: postgres:latest + container_name: server_db + environmnet: + 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..76fb568 --- /dev/null +++ b/main.py @@ -0,0 +1,85 @@ +from fastapi import FastAPI, HTTPException, Request +from typing import List,Optional +from contextlib import asynccontextmanager +from models import ServerModel, ServerCreate, ServerState +import logging +import aiosql +import asyncpg + +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', + datefmt='%Y-%m-%d %H:%M:%S' +) +queries = None +conn = None + +@asynccontextmanager +async def lifespan(app: FastAPI): + global queries, conn + logger.info("Inventory server api starting up...") + queries = aiosql.from_path("queries.sql", "asyncpg") + pool = await asyncpg.create_pool( + dsn="postgres://user:pass@localhost/db", + min_size=5, + max_size=20 + ) + app.state.pool = pool + yield + logger.info("Inventory server api shutting down...") + await pool.close() + + +app = FastAPI(lifespan=lifespan) +logger = logging.getLogger("fastapi") + + +@app.post("/servers", response_model=ServerModel) +async def create_server(request: Request, server: ServerCreate) -> Optional[ServerModel]: + global queries + pool = request.app.state.pool + try: + record = await queries.create_server(pool, **server.model_dump()) + return ServerModel( + id=record['id'], + created_at=record['created_at'], + state=ServerState.ACTIVE, + **server.model_dump() + ) + except: + raise HTTPException(status_code=400, detail="IDK man") + +@app.get("/servers", response_model=List[ServerModel]) +async def list_servers(request: Request) -> List[ServerModel]: + global queries + pool = request.app.state.pool + return await queries.list_servers(pool) + +@app.get("/servers/{id}", response_model=ServerModel) +async def get_server(request: Request, id: int) -> Optional[ServerModel]: + global queries + pool = request.app.state.pool + record = await queries.get_server_by_id(pool,id=id) + if not record: + logging.error(f"Unable to find server with id {id}") + raise HTTPException() + return record + +@app.put("/servers/{id}", response_model=ServerModel) +async def update_server(request: Request, id: int, server: ServerModel) -> Optional[ServerModel]: + global queries + pool = request.app.state.pool + resp = await queries.update_server(pool, id=id, **server.model_dump()) + if resp is None: + logging.error(f"Unable to find server with id {id}") + return resp + + +@app.delete("/servers/{id}", response_model=ServerModel) +async def delete_server(id: int) -> Optional[ServerModel]: + global queries + try: + resp = await queries.delete_server_by_id(pool, id=id) + return resp + except: + raise HTTPException() diff --git a/models.py b/models.py new file mode 100644 index 0000000..bab3edb --- /dev/null +++ b/models.py @@ -0,0 +1,21 @@ +from datetime import datetime +from pydantic import BaseModel +import enum + +class ServerState(enum.Enum): + ACTIVE = 'active' + OFFLINE = 'offline' + RETIRED = 'retired' + +class ServerModel(BaseModel): + model_config = {"from_attributes": True} + id: int + hostname: str + ip: str + state: ServerState + created_at: datetime + +class ServerCreate(BaseModel): + hostname: str + ip: str + diff --git a/myWork.txt b/myWork.txt new file mode 100644 index 0000000..33a44eb --- /dev/null +++ b/myWork.txt @@ -0,0 +1,261 @@ +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/Dockerfile b/Dockerfile +new file mode 100644 +index 0000000..393d57c +--- /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 ./ ++ ++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", "app:app", "--host", "0.0.0.0", "--port", "8000"] +diff --git a/Justfile b/Justfile +new file mode 100644 +index 0000000..bbf44d1 +--- /dev/null ++++ b/Justfile +@@ -0,0 +1,13 @@ ++alias r := run ++alias t := test ++ ++VENV_BIN := "./venv/bin" ++ ++@_default: ++ just --list ++ ++run: ++ {{VENV_BIN}}/fastapi dev main.py ++ ++test: ++ {{VENV_BIN}}/pytest +diff --git a/docker-compose.yml b/docker-compose.yml +new file mode 100644 +index 0000000..653e540 +--- /dev/null ++++ b/docker-compose.yml +@@ -0,0 +1,40 @@ ++services: ++ api: ++ build: ++ context: . ++ dockerfile: Dockerfile ++ container_name: server_api ++ ports: ++ - "8000:8000" ++ environment: ++ - APP_URl=http//api:8000 ++ 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 ++ networks: ++ - server-network ++ ++ postgres: ++ image: postgres:latest ++ container_name: server_db ++ environmnet: ++ 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..795ed61 +--- /dev/null ++++ b/main.py +@@ -0,0 +1,85 @@ ++from fastapi import FastAPI, HTTPException, Request ++from typing import List,Optional ++from contextlib import asynccontextmanager ++from models import ServerModel, ServerCreate ++import logging ++import aiosql ++import asyncpg ++ ++logging.basicConfig( ++ level=logging.INFO, ++ format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', ++ datefmt='%Y-%m-%d %H:%M:%S' ++) ++queries = None ++conn = None ++ ++@asynccontextmanager ++async def lifespan(app: FastAPI): ++ global queries, conn ++ logger.info("Inventory server api starting up...") ++ queries = aiosql.from_path("queries.sql", "asyncpg") ++ pool = await asyncpg.create_pool( ++ dsn="postgres://user:pass@localhost/db", ++ min_size=5, ++ max_size=20 ++ ) ++ app.state.pool = pool ++ yield ++ logger.info("Inventory server api shutting down...") ++ await pool.close() ++ ++ ++app = FastAPI(lifespan=lifespan) ++logger = logging.getLogger("fastapi") ++ ++ ++@app.post("/servers", response_model=ServerModel) ++async def create_server(request: Request, server: ServerCreate) -> Optional[ServerModel]: ++ global queries ++ pool = request.app.state.pool ++ try: ++ record = await queries.create_server(pool, **server.model_dump()) ++ return ServerModel( ++ id=record['id'], ++ created_at=record['created_at'], ++ state=ServerState.ACTIVE, ++ **server.model_dump() ++ ) ++ except: ++ raise HTTPException(status_code=400, detail="IDK man") ++ ++@app.get("/servers", response_model=List[ServerModel]) ++async def list_servers(request: Request) -> List[ServerModel]: ++ global queries ++ pool = request.app.state.pool ++ return await queries.list_servers(pool) ++ ++@app.get("/servers/{id}", response_model=ServerModel) ++async def get_server(request: Request, id: int) -> Optional[ServerModel]: ++ global queries ++ pool = request.app.state.pool ++ record = await queries.get_server_by_id(pool,id=id) ++ if not record: ++ logging.error(f"Unable to find server with id {id}") ++ raise HTTPException() ++ return record ++ ++@app.put("/servers/{id}", response_model=ServerModel) ++async def update_server(request: Request, id: int, server: ServerModel) -> Optional[ServerModel]: ++ global queries ++ pool = request.app.state.pool ++ resp = await queries.update_server(pool, id=id, **server.model_dump()) ++ if resp is None: ++ logging.error(f"Unable to find server with id {id}") ++ return resp ++ ++ ++@app.delete("/servers/{id}", response_model=ServerModel) ++async def delete_server(id: int) -> Optional[ServerModel]: ++ global queries ++ try: ++ resp = await queries.delete_server_by_id(pool, id=id) ++ return resp ++ except: ++ raise HTTPException() +diff --git a/models.py b/models.py +new file mode 100644 +index 0000000..bab3edb +--- /dev/null ++++ b/models.py +@@ -0,0 +1,21 @@ ++from datetime import datetime ++from pydantic import BaseModel ++import enum ++ ++class ServerState(enum.Enum): ++ ACTIVE = 'active' ++ OFFLINE = 'offline' ++ RETIRED = 'retired' ++ ++class ServerModel(BaseModel): ++ model_config = {"from_attributes": True} ++ id: int ++ hostname: str ++ ip: str ++ state: ServerState ++ created_at: datetime ++ ++class ServerCreate(BaseModel): ++ hostname: str ++ ip: str ++ +diff --git a/queries.sql b/queries.sql +new file mode 100644 +index 0000000..fcb3c7b +--- /dev/null ++++ b/queries.sql +@@ -0,0 +1,31 @@ ++-- name: create_server Date: Wed, 17 Dec 2025 03:16:56 -0500 Subject: [PATCH 2/3] Submission --- .env | 5 + API.md | 121 +++++++++++++++++++++ Dockerfile | 6 +- Justfile | 39 ++++++- cli.py | 141 ++++++++++++++++++++++++ docker-compose.yml | 19 +++- main.py | 90 +++++++++++----- models.py | 23 +++- myWork.txt | 261 --------------------------------------------- queries.sql | 27 +++-- requirements.txt | 7 ++ tests.py | 156 +++++++++++++++++++++++++++ 12 files changed, 590 insertions(+), 305 deletions(-) create mode 100644 .env create mode 100644 API.md create mode 100644 cli.py delete mode 100644 myWork.txt create mode 100644 tests.py 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/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 index 393d57c..a8b5a34 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,7 +6,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ build-essential \ && rm -rf /var/lib/apt/lists/* -COPY requirements.txt +COPY requirements.txt . RUN pip install --no-cache-dir --user -r requirements.txt FROM python:3.12-slim @@ -17,11 +17,11 @@ COPY --from=builder /root/.local /root/.local ENV PATH=/root/.local/bin:$PATH -COPY main.py models.py ./ +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", "app:app", "--host", "0.0.0.0", "--port", "8000"] +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/Justfile b/Justfile index bbf44d1..49c3aed 100644 --- a/Justfile +++ b/Justfile @@ -1,5 +1,8 @@ alias r := run alias t := test +alias d := down +alias f := fresh +alias c := cli VENV_BIN := "./venv/bin" @@ -10,4 +13,38 @@ run: {{VENV_BIN}}/fastapi dev main.py test: - {{VENV_BIN}}/pytest + {{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..cb05547 --- /dev/null +++ b/cli.py @@ -0,0 +1,141 @@ +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): + """Create a new server.""" + validate_ip(ip) + response = make_request("POST", f"{BASE_URL}/servers", json={"hostname": hostname, "ip": ip}) + 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("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["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, + 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"], + "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("IP", server["ip"]) + table.add_row("State", server["state"]) + table.add_row("Created At", server["created_at"]) + + console.print(table) + + +if __name__ == "__main__": + app() \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 653e540..a84ca04 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,7 +7,12 @@ services: ports: - "8000:8000" environment: - - APP_URl=http//api:8000 + - 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 @@ -15,13 +20,16 @@ services: 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 - environmnet: + environment: POSTGRES_USER: ${DB_USER} POSTGRES_PASSWORD: ${DB_PASS} POSTGRES_DB: ${DB_NAME} @@ -35,6 +43,7 @@ services: restart: unless-stopped networks: - server-network - networks: - server-network: - driver: bridge + +networks: + server-network: + driver: bridge diff --git a/main.py b/main.py index 76fb568..738b07e 100644 --- a/main.py +++ b/main.py @@ -1,85 +1,125 @@ from fastapi import FastAPI, HTTPException, Request -from typing import List,Optional +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 -conn = None + +logger = logging.getLogger("fastapi") @asynccontextmanager async def lifespan(app: FastAPI): - global queries, conn + """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="postgres://user:pass@localhost/db", + 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) -logger = logging.getLogger("fastapi") + + +@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) -> Optional[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: - record = await queries.create_server(pool, **server.model_dump()) + 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: - raise HTTPException(status_code=400, detail="IDK man") + 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 - return await queries.list_servers(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) -> Optional[ServerModel]: +async def get_server(request: Request, id: int) -> ServerModel: + """Get a server by ID.""" global queries pool = request.app.state.pool - record = await queries.get_server_by_id(pool,id=id) + async with pool.acquire() as conn: + record = await queries.get_server_by_id(conn, id=id) if not record: - logging.error(f"Unable to find server with id {id}") - raise HTTPException() - return 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) -> Optional[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 - resp = await queries.update_server(pool, id=id, **server.model_dump()) + 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 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: - logging.error(f"Unable to find server with id {id}") - return resp + 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(id: int) -> Optional[ServerModel]: +async def delete_server(request: Request, id: int) -> ServerModel: + """Delete a server by ID.""" global queries - try: - resp = await queries.delete_server_by_id(pool, id=id) - return resp - except: - raise HTTPException() + 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 index bab3edb..bacdb10 100644 --- a/models.py +++ b/models.py @@ -1,6 +1,16 @@ from datetime import datetime -from pydantic import BaseModel +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' @@ -15,7 +25,18 @@ class ServerModel(BaseModel): 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 ip: str + @field_validator('ip') + @classmethod + def validate_ip(cls, v: str) -> str: + return validate_ip_address(v) + diff --git a/myWork.txt b/myWork.txt deleted file mode 100644 index 33a44eb..0000000 --- a/myWork.txt +++ /dev/null @@ -1,261 +0,0 @@ -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/Dockerfile b/Dockerfile -new file mode 100644 -index 0000000..393d57c ---- /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 ./ -+ -+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", "app:app", "--host", "0.0.0.0", "--port", "8000"] -diff --git a/Justfile b/Justfile -new file mode 100644 -index 0000000..bbf44d1 ---- /dev/null -+++ b/Justfile -@@ -0,0 +1,13 @@ -+alias r := run -+alias t := test -+ -+VENV_BIN := "./venv/bin" -+ -+@_default: -+ just --list -+ -+run: -+ {{VENV_BIN}}/fastapi dev main.py -+ -+test: -+ {{VENV_BIN}}/pytest -diff --git a/docker-compose.yml b/docker-compose.yml -new file mode 100644 -index 0000000..653e540 ---- /dev/null -+++ b/docker-compose.yml -@@ -0,0 +1,40 @@ -+services: -+ api: -+ build: -+ context: . -+ dockerfile: Dockerfile -+ container_name: server_api -+ ports: -+ - "8000:8000" -+ environment: -+ - APP_URl=http//api:8000 -+ 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 -+ networks: -+ - server-network -+ -+ postgres: -+ image: postgres:latest -+ container_name: server_db -+ environmnet: -+ 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..795ed61 ---- /dev/null -+++ b/main.py -@@ -0,0 +1,85 @@ -+from fastapi import FastAPI, HTTPException, Request -+from typing import List,Optional -+from contextlib import asynccontextmanager -+from models import ServerModel, ServerCreate -+import logging -+import aiosql -+import asyncpg -+ -+logging.basicConfig( -+ level=logging.INFO, -+ format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', -+ datefmt='%Y-%m-%d %H:%M:%S' -+) -+queries = None -+conn = None -+ -+@asynccontextmanager -+async def lifespan(app: FastAPI): -+ global queries, conn -+ logger.info("Inventory server api starting up...") -+ queries = aiosql.from_path("queries.sql", "asyncpg") -+ pool = await asyncpg.create_pool( -+ dsn="postgres://user:pass@localhost/db", -+ min_size=5, -+ max_size=20 -+ ) -+ app.state.pool = pool -+ yield -+ logger.info("Inventory server api shutting down...") -+ await pool.close() -+ -+ -+app = FastAPI(lifespan=lifespan) -+logger = logging.getLogger("fastapi") -+ -+ -+@app.post("/servers", response_model=ServerModel) -+async def create_server(request: Request, server: ServerCreate) -> Optional[ServerModel]: -+ global queries -+ pool = request.app.state.pool -+ try: -+ record = await queries.create_server(pool, **server.model_dump()) -+ return ServerModel( -+ id=record['id'], -+ created_at=record['created_at'], -+ state=ServerState.ACTIVE, -+ **server.model_dump() -+ ) -+ except: -+ raise HTTPException(status_code=400, detail="IDK man") -+ -+@app.get("/servers", response_model=List[ServerModel]) -+async def list_servers(request: Request) -> List[ServerModel]: -+ global queries -+ pool = request.app.state.pool -+ return await queries.list_servers(pool) -+ -+@app.get("/servers/{id}", response_model=ServerModel) -+async def get_server(request: Request, id: int) -> Optional[ServerModel]: -+ global queries -+ pool = request.app.state.pool -+ record = await queries.get_server_by_id(pool,id=id) -+ if not record: -+ logging.error(f"Unable to find server with id {id}") -+ raise HTTPException() -+ return record -+ -+@app.put("/servers/{id}", response_model=ServerModel) -+async def update_server(request: Request, id: int, server: ServerModel) -> Optional[ServerModel]: -+ global queries -+ pool = request.app.state.pool -+ resp = await queries.update_server(pool, id=id, **server.model_dump()) -+ if resp is None: -+ logging.error(f"Unable to find server with id {id}") -+ return resp -+ -+ -+@app.delete("/servers/{id}", response_model=ServerModel) -+async def delete_server(id: int) -> Optional[ServerModel]: -+ global queries -+ try: -+ resp = await queries.delete_server_by_id(pool, id=id) -+ return resp -+ except: -+ raise HTTPException() -diff --git a/models.py b/models.py -new file mode 100644 -index 0000000..bab3edb ---- /dev/null -+++ b/models.py -@@ -0,0 +1,21 @@ -+from datetime import datetime -+from pydantic import BaseModel -+import enum -+ -+class ServerState(enum.Enum): -+ ACTIVE = 'active' -+ OFFLINE = 'offline' -+ RETIRED = 'retired' -+ -+class ServerModel(BaseModel): -+ model_config = {"from_attributes": True} -+ id: int -+ hostname: str -+ ip: str -+ state: ServerState -+ created_at: datetime -+ -+class ServerCreate(BaseModel): -+ hostname: str -+ ip: str -+ -diff --git a/queries.sql b/queries.sql -new file mode 100644 -index 0000000..fcb3c7b ---- /dev/null -+++ b/queries.sql -@@ -0,0 +1,31 @@ -+-- name: create_server Date: Mon, 22 Dec 2025 17:45:11 -0500 Subject: [PATCH 3/3] Add owner --- cli.py | 13 ++++++++++--- main.py | 2 +- models.py | 2 ++ queries.sql | 17 +++++++++-------- 4 files changed, 22 insertions(+), 12 deletions(-) diff --git a/cli.py b/cli.py index cb05547..20a2920 100644 --- a/cli.py +++ b/cli.py @@ -41,10 +41,12 @@ def make_request(method: str, url: str, **kwargs) -> requests.Response: @app.command() -def create(hostname: str, ip: str): +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}) + 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) @@ -63,6 +65,7 @@ def list(): 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") @@ -71,6 +74,7 @@ def list(): table.add_row( str(server["id"]), server["hostname"], + server["owner"], server["ip"], server["state"], server["created_at"] @@ -91,6 +95,7 @@ def get(id: int): def update( id: int, hostname: Optional[str] = None, + owner: Optional[str] = None, ip: Optional[str] = None, state: Optional[ServerState] = None ): @@ -103,6 +108,7 @@ def update( 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"] @@ -130,6 +136,7 @@ def _print_server(server: dict): 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"]) @@ -138,4 +145,4 @@ def _print_server(server: dict): if __name__ == "__main__": - app() \ No newline at end of file + app() diff --git a/main.py b/main.py index 738b07e..1f0cddd 100644 --- a/main.py +++ b/main.py @@ -104,7 +104,7 @@ async def update_server(request: Request, id: int, server: ServerModel) -> Serve 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 already exists") + 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)}") diff --git a/models.py b/models.py index bacdb10..82ad60d 100644 --- a/models.py +++ b/models.py @@ -21,6 +21,7 @@ class ServerModel(BaseModel): model_config = {"from_attributes": True} id: int hostname: str + owner: str ip: str state: ServerState created_at: datetime @@ -33,6 +34,7 @@ def validate_ip(cls, v: str) -> str: class ServerCreate(BaseModel): hostname: str + owner: str ip: str @field_validator('ip') diff --git a/queries.sql b/queries.sql index 17f3264..834d717 100644 --- a/queries.sql +++ b/queries.sql @@ -1,28 +1,28 @@ -- name: create_server