diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e41de5d --- /dev/null +++ b/.gitignore @@ -0,0 +1,165 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/latest/usage/project/#working-with-version-control +.pdm.toml +.pdm-python +.pdm-build/ + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + + +.DS_Store \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e6de526 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,5 @@ +FROM python:3.13-slim +WORKDIR /app +COPY . . +RUN pip install --no-cache-dir -r requirements.txt +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] \ No newline at end of file diff --git a/README.md b/README.md index 3145d38..e9758fa 100644 --- a/README.md +++ b/README.md @@ -1,31 +1,83 @@ -# Instructions +# Server Inventory Manager -You are developing an inventory management software solution for a cloud services company that provisions servers in multiple data centers. You must build a CRUD app for tracking the state of all the servers. +A CRUD application to manage server inventory across data centers. Built with FastAPI and PostgreSQL using raw SQL. -Deliverables: -- PR to https://github.com/Mathpix/hiring-challenge-devops-python that includes: -- API code -- CLI code -- pytest test suite -- Working Docker Compose stack +## Features +- REST API for Server management. +- CLI tool. +- Raw SQL implementation. +- Hostname uniqueness enforcement. +- IP address and State validation. -Short API.md on how to run everything, also a short API and CLI spec +## Prerequisites +- Docker & Docker Compose +- Python 3.11+ (if running CLI locally) -Required endpoints: -- POST /servers → create a server -- GET /servers → list all servers -- GET /servers/{id} → get one server -- PUT /servers/{id} → update server -- DELETE /servers/{id} → delete server +## Project Structure -Requirements: -- Use FastAPI or Flask -- Store data in PostgreSQL -- Use raw SQL +```text +├── app +│ ├── database.py # Raw SQL wrapper +│ ├── main.py # API Endpoints +│ └── models.py # Pydantic schemas +├── cli +│ └── main.py # CLI Tool +├── tests +│ └── test_api.py # Pytest suite +├── docker-compose.yml +└── Dockerfile +``` -Validate that: -- hostname is unique -- IP address looks like an IP +## How to Run -State is one of: active, offline, retired +### 1. Start the Application +Build and start the API and Database containers: +```bash +docker-compose up --build +``` + +The API will be accessible at: http://localhost:8000 +Automatic API Docs (Swagger UI): http://localhost:8000/docs + +### 2. Run the Test Suite +Execute the pytest suite inside the running container to verify functionality: + +```bash +docker-compose run --rm api pytest -v +``` + +### CLI Usage +You can run the CLI tool from your local machine (requires Python installed) or enter the container to run it. + +Option A: Running Locally (Recommended) +#### 1. Install Dependencies: +```bash +pip install -r requirements.txt +``` + +#### 2. Commands: +- Create a server +```bash +python cli/main.py create "srv-node-01" "192.168.1.50" --state active +``` +- List all servers +```bash +python cli/main.py list +``` +- Get a single server +```bash +# Replace 1 with the actual ID +python cli/main.py get 1 +``` +- Update a server +```bash +# Change state to offline +python cli/main.py update 1 --state offline +# Change IP address +python cli/main.py update 1 --ip "192.168.1.11" +``` +- Delete a server +```bash +python cli/main.py delete 1 +``` \ No newline at end of file diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/database.py b/app/database.py new file mode 100644 index 0000000..7dabb25 --- /dev/null +++ b/app/database.py @@ -0,0 +1,44 @@ +import os +import asyncpg +from typing import List, Optional, Any + +DATABASE_URL = os.getenv("DATABASE_URL", "postgres://postgres:postgres@localhost:5432/postgres") + +class Database: + def __init__(self): + self.pool = None + + async def connect(self): + self.pool = await asyncpg.create_pool(DATABASE_URL) + await self.init_db() + + async def close(self): + await self.pool.close() + + async def init_db(self): + query = """ + CREATE TABLE IF NOT EXISTS servers ( + id SERIAL PRIMARY KEY, + hostname VARCHAR(255) UNIQUE NOT NULL, + ip_address VARCHAR(45) NOT NULL, + state VARCHAR(20) NOT NULL CHECK (state IN ('active', 'offline', 'retired')) + ); + """ + async with self.pool.acquire() as connection: + await connection.execute(query) + + async def fetch_all(self, query: str, *args) -> List[dict]: + async with self.pool.acquire() as connection: + rows = await connection.fetch(query, *args) + return [dict(row) for row in rows] + + async def fetch_one(self, query: str, *args) -> Optional[dict]: + async with self.pool.acquire() as connection: + row = await connection.fetchrow(query, *args) + return dict(row) if row else None + + async def execute(self, query: str, *args) -> Any: + async with self.pool.acquire() as connection: + return await connection.fetchval(query, *args) + +db = Database() diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..a242584 --- /dev/null +++ b/app/main.py @@ -0,0 +1,99 @@ +from contextlib import asynccontextmanager +from fastapi import FastAPI, HTTPException, status, Query +from asyncpg.exceptions import UniqueViolationError +from app.database import db +from app.models import ServerCreate, ServerUpdate, ServerResponse + +@asynccontextmanager +async def lifespan(_: FastAPI): + await db.connect() + yield + await db.close() + +app = FastAPI(title="Server Inventory API", lifespan=lifespan) + +@app.post("/server", response_model=ServerResponse, status_code=status.HTTP_201_CREATED) +async def create_server(server: ServerCreate): + query = """ + INSERT INTO servers (hostname, ip_address, state) + VALUES ($1, $2, $3) + RETURNING id, hostname, ip_address, state; + """ + try: + row = await db.fetch_one( + query, + server.hostname, + str(server.ip_address), + server.state + ) + return row + except UniqueViolationError: + raise HTTPException( + status_code=400, + detail=f"Server with hostname '{server.hostname}' already exists." + ) + +@app.get("/server/{server_id}", response_model=ServerResponse) +async def get_server(server_id: int): + query = "SELECT id, hostname, ip_address, state FROM servers WHERE id = $1;" + row = await db.fetch_one(query, server_id) + if not row: + raise HTTPException(status_code=404, detail="Server not found") + return row + +@app.put("/server/{server_id}", response_model=ServerResponse) +async def update_server(server_id: int, server: ServerUpdate): + check_query = "SELECT id FROM servers WHERE id = $1;" + if not await db.fetch_one(check_query, server_id): + raise HTTPException(status_code=404, detail="Server not found") + + fields = [] + values = [] + idx = 1 + + if server.hostname is not None: + fields.append(f"hostname = ${idx}") + values.append(server.hostname) + idx += 1 + if server.ip_address is not None: + fields.append(f"ip_address = ${idx}") + values.append(str(server.ip_address)) + idx += 1 + if server.state is not None: + fields.append(f"state = ${idx}") + values.append(server.state) + idx += 1 + + if not fields: + raise HTTPException(status_code=400, detail="No fields provided for update") + + values.append(server_id) + query = f""" + UPDATE servers + SET {', '.join(fields)} + WHERE id = ${idx} + RETURNING id, hostname, ip_address, state; + """ + + try: + row = await db.fetch_one(query, *values) + return row + except UniqueViolationError: + raise HTTPException(status_code=400, detail="Hostname already taken") + +@app.delete("/server/{server_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_server(server_id: int): + query = "DELETE FROM servers WHERE id = $1 RETURNING id;" + row = await db.fetch_one(query, server_id) + if not row: + raise HTTPException(status_code=404, detail="Server not found") + +@app.get("/servers", response_model=list[ServerResponse]) +async def get_servers(limit: int = Query(100, ge=1), offset: int = Query(0, ge=0)): + query = """ + SELECT id, hostname, ip_address, state + FROM servers + ORDER BY id + LIMIT $1 OFFSET $2; + """ + return await db.fetch_all(query, limit, offset) diff --git a/app/models.py b/app/models.py new file mode 100644 index 0000000..644cd38 --- /dev/null +++ b/app/models.py @@ -0,0 +1,28 @@ +from datetime import datetime +from typing import Literal, Optional +from pydantic import BaseModel, Field, IPvAnyAddress, ConfigDict + +ServerState = Literal["active", "offline", "retired"] + +class ServerBase(BaseModel): + hostname: str = Field(..., min_length=1, max_length=255) + ip_address: IPvAnyAddress + state: ServerState + +class ServerCreate(ServerBase): + pass + +class ServerUpdate(BaseModel): + hostname: Optional[str] = Field(None, min_length=1, max_length=255) + ip_address: Optional[IPvAnyAddress] = None + state: Optional[ServerState] = None + +class ServerOut(ServerBase): + id: int + insert_timesatamp: datetime + last_update_timestamp: datetime + +class ServerResponse(ServerBase): + id: int + + model_config = ConfigDict(from_attributes=True) \ No newline at end of file diff --git a/cli/__init__.py b/cli/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/cli/main.py b/cli/main.py new file mode 100644 index 0000000..5f4c9bc --- /dev/null +++ b/cli/main.py @@ -0,0 +1,86 @@ +import typer +import httpx +import json +from typing import Optional +from enum import Enum + +app = typer.Typer() +API_URL = "http://localhost:8000" + +class ServerState(str, Enum): + ACTIVE = "active" + OFFLINE = "offline" + RETIRED = "retired" + +@app.command() +def create(hostname: str, ip: str, state: ServerState = ServerState.ACTIVE): + """Create a new server.""" + payload = {"hostname": hostname, "ip_address": ip, "state": state.value} + try: + r = httpx.post(f"{API_URL}/server", json=payload) + r.raise_for_status() + typer.echo(json.dumps(r.json(), indent=2)) + except httpx.HTTPStatusError as e: + typer.secho(f"Error: {e.response.text}", fg=typer.colors.RED) + +@app.command() +def list(): + """List all servers.""" + try: + r = httpx.get(f"{API_URL}/servers") + r.raise_for_status() + data = r.json() + typer.echo(json.dumps(data, indent=2)) + except httpx.HTTPError as e: + typer.secho(f"Error connecting to API: {e}", fg=typer.colors.RED) + +@app.command() +def get(server_id: int): + """Get details of a specific server.""" + try: + r = httpx.get(f"{API_URL}/server/{server_id}") + if r.status_code == 404: + typer.secho("Server not found", fg=typer.colors.RED) + return + r.raise_for_status() + typer.echo(json.dumps(r.json(), indent=2)) + except httpx.HTTPError as e: + typer.secho(f"Error: {e}", fg=typer.colors.RED) + +@app.command() +def update(server_id: int, hostname: Optional[str] = None, ip: Optional[str] = None, state: Optional[ServerState] = None): + """Update a server.""" + payload = {} + if hostname: payload["hostname"] = hostname + if ip: payload["ip_address"] = ip + if state: payload["state"] = state.value + + if not payload: + typer.secho("No fields to update.", fg=typer.colors.YELLOW) + return + + try: + r = httpx.put(f"{API_URL}/server/{server_id}", json=payload) + if r.status_code == 404: + typer.secho("Server not found", fg=typer.colors.RED) + return + r.raise_for_status() + typer.echo(json.dumps(r.json(), indent=2)) + except httpx.HTTPStatusError as e: + typer.secho(f"Error: {e.response.text}", fg=typer.colors.RED) + +@app.command() +def delete(server_id: int): + """Delete a server.""" + try: + r = httpx.delete(f"{API_URL}/server/{server_id}") + if r.status_code == 404: + typer.secho("Server not found", fg=typer.colors.RED) + return + r.raise_for_status() + typer.secho(f"Server {server_id} deleted successfully.", fg=typer.colors.GREEN) + except httpx.HTTPError as e: + typer.secho(f"Error: {e}", fg=typer.colors.RED) + +if __name__ == "__main__": + app() \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..c15081a --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,26 @@ +services: + postgres: + image: postgres:17 + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: postgres + ports: + - "5432:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres -d postgres"] + interval: 5s + timeout: 5s + retries: 5 + api: + build: . + ports: + - "8000:8000" + environment: + DATABASE_URL: postgres://postgres:postgres@postgres:5432/postgres + depends_on: + postgres: + condition: service_healthy + volumes: + - .:/app + command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..8147098 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,30 @@ +annotated-doc==0.0.4 +annotated-types==0.7.0 +anyio==4.12.0 +asyncpg==0.31.0 +certifi==2025.11.12 +click==8.3.1 +fastapi==0.124.4 +h11==0.16.0 +httpcore==1.0.9 +httpx==0.28.1 +idna==3.11 +iniconfig==2.3.0 +markdown-it-py==4.0.0 +mdurl==0.1.2 +packaging==25.0 +pluggy==1.6.0 +psycopg==3.3.2 +psycopg-binary==3.3.2 +pydantic==2.12.5 +pydantic_core==2.41.5 +Pygments==2.19.2 +pytest==9.0.2 +pytest-asyncio==1.3.0 +rich==14.2.0 +shellingham==1.5.4 +starlette==0.50.0 +typer==0.20.0 +typing-inspection==0.4.2 +typing_extensions==4.15.0 +uvicorn==0.38.0 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..2396e05 --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,104 @@ +import pytest +import pytest_asyncio +from httpx import ASGITransport, AsyncClient +from app.main import app +from app.database import db + +@pytest.fixture(scope="module") +def anyio_backend(): + return "asyncio" + +@pytest_asyncio.fixture(autouse=True) +async def setup_db(): + await db.connect() + await db.execute("TRUNCATE servers RESTART IDENTITY;") + yield + await db.close() + +@pytest.mark.asyncio +async def test_create_server(): + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ac: + response = await ac.post("/server", json={ + "hostname": "srv-1", + "ip_address": "192.168.1.2", + "state": "active" + }) + assert response.status_code == 201 + data = response.json() + assert data["hostname"] == "srv-1" + assert data["id"] is not None + +@pytest.mark.asyncio +async def test_create_duplicate_hostname_fails(): + async with AsyncClient(transport=ASGITransport(app=app),base_url="http://test") as ac: + first = await ac.post("/server", json={ + "hostname": "srv-1", # Duplicate + "ip_address": "192.168.1.11", + "state": "offline" + }) + assert first.status_code == 201 + + duplicate = await ac.post("/server", json={ + "hostname": "srv-1", # Duplicate + "ip_address": "192.168.1.12", + "state": "offline" + }) + assert duplicate.status_code == 400 + assert "already exists" in duplicate.json()["detail"] + +@pytest.mark.asyncio +async def test_invalid_ip_fails(): + async with AsyncClient(transport=ASGITransport(app=app),base_url="http://test") as ac: + response = await ac.post("/server", json={ + "hostname": "srv-2", + "ip_address": "invalid-ip", + "state": "active" + }) + assert response.status_code == 422 # Validation Error + +@pytest.mark.asyncio +async def test_update_server(): + # First create + async with AsyncClient(transport=ASGITransport(app=app),base_url="http://test") as ac: + create = await ac.post("/server", json={ + "hostname": "srv-update", + "ip_address": "192.168.1.11", + "state": "active" + }) + server_id = create.json()["id"] + + update = await ac.put(f"/server/{server_id}", json={ + "state": "retired" + }) + assert update.status_code == 200 + assert update.json()["state"] == "retired" + +@pytest.mark.asyncio +async def test_delete_server(): + # Create to delete + async with AsyncClient(transport=ASGITransport(app=app),base_url="http://test") as ac: + create = await ac.post("/server", json={ + "hostname": "srv-delete", + "ip_address": "192.168.1.11", + "state": "offline" + }) + server_id = create.json()["id"] + + delete = await ac.delete(f"/server/{server_id}") + assert delete.status_code == 204 + + # Verify gone + get = await ac.get(f"/server/{server_id}") + assert get.status_code == 404 + +@pytest.mark.asyncio +async def test_get_servers(): + async with AsyncClient(transport=ASGITransport(app=app),base_url="http://test") as ac: + await ac.post("/server", json={ + "hostname": "srv-1", + "ip_address": "192.168.1.11", + "state": "active" + }) + response = await ac.get("/servers") + assert response.status_code == 200 + assert len(response.json()) > 0