Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .env
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
POSTGRES_USER=admin
POSTGRES_PASSWORD=changeMe
POSTGRES_DB=inventory
POSTGRES_PORT=5432
POSTGRES_HOST=localhost
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
.venv
.pyenv
.idea
.pytest_cache
13 changes: 13 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
FROM python:3.9-slim

WORKDIR /inventory

COPY requirements.txt .

RUN pip install --no-cache-dir -r requirements.txt

COPY app ./app

EXPOSE 8000

CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
179 changes: 156 additions & 23 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,31 +1,164 @@
# Instructions
# Inventory Management System

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.
Inventory Management CRUD application built with **FastAPI**, **Asyncpg (Raw SQL)**, and **PostgreSQL**. It includes a REST API, a CLI tool, and a Dockerized database environment.

Deliverables:
- PR to https://github.com/Mathpix/hiring-challenge-devops-python that includes:
- API code
- CLI code
- pytest test suite
- Working Docker Compose stack
## Quick Start

Short API.md on how to run everything, also a short API and CLI spec
### 1. Prerequisites
* Docker & Docker Compose
* Python 3.9+

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
### 2. Start the Database
The project uses a Dockerized PostgreSQL instance.
```bash
# Start the database container
docker compose up --build -d

Requirements:
- Use FastAPI or Flask
- Store data in PostgreSQL
- Use raw SQL
# Verify it is healthy
docker compose ps
```
#### API URL: http://127.0.0.1:8000

Validate that:
- hostname is unique
- IP address looks like an IP
#### Swagger UI: http://127.0.0.1:8000/docs

State is one of: active, offline, retired
Database Port: 5432 (Exposed locally for debugging)

Default credentials: Defined in .env

Init: Schema is automatically applied from init.sql.

## Usage Examples

Below are full examples of how to interact with the API using standard `curl` commands or the included Python CLI tool.

### 1. Create a Server
```bash
python3 app/cli.py create web-01 192.168.1.10 --state active
```
```bash
curl -X POST http://127.0.0.1:8000/servers \
-H "Content-Type: application/json" \
-d '{"hostname": "web-01", "ip_address": "192.168.1.10", "state": "active"}'
```
### 2. List All Servers
```bash
python3 app/cli.py list
```
```bash
curl -s http://127.0.0.1:8000/servers
```
### 3. Get Single Server Details
```bash
python3 app/cli.py get 1
```
```bash
curl -s http://127.0.0.1:8000/servers/1
```
### 4. Update a Server
```bash
python3 app/cli.py update 1 --ip 10.0.0.50 --state offline
```
```bash
curl -X PUT http://127.0.0.1:8000/servers/1 \
-H "Content-Type: application/json" \
-d '{"ip_address": "10.0.0.50", "state": "offline"}'
```
### 5. Delete a Server
```bash
python3 app/cli.py delete 1
```
```bash
curl -X DELETE http://127.0.0.1:8000/servers/1
```

## CLI Specification

The command-line interface (`app/cli.py`) interacts directly with the running API.

**Usage:** `python3 app/cli.py [COMMAND] [ARGS]`

| Command | Arguments | Description | Example |
| :--- | :--- | :--- | :--- |
| `list` | None | Lists all servers in a formatted table. | `python3 app/cli.py list` |
| `get` | `ID` | Retrieves details of a single server by ID. | `python3 app/cli.py get 1` |
| `create` | `HOSTNAME` `IP` `[--state]` | Creates a new server. Default state is `active`. | `python3 app/cli.py create web-01 10.0.0.5 --state offline` |
| `update` | `ID` `[--hostname]` `[--ip]` `[--state]` | Updates specific fields of an existing server. | `python3 app/cli.py update 1 --state retired` |
| `delete` | `ID` | Deletes a server permanently. | `python3 app/cli.py delete 1` |

---

## API Specification

The API follows RESTful standards. All request and response bodies are JSON.

**Base URL:** `http://127.0.0.1:8000`

### Endpoints

#### 1. List Servers
* **Method:** `GET`
* **Endpoint:** `/servers`
* **Description:** Retrieve a list of all servers.
* **Response:** `200 OK` (Array of Server Objects)

#### 2. Create Server
* **Method:** `POST`
* **Endpoint:** `/servers`
* **Body:**
```json
{
"hostname": "string (unique)",
"ip_address": "string (IPv4/IPv6)",
"state": "active | offline | retired (optional, default: active)"
}
```
* **Response:** `201 Created`

#### 3. Get Server
* **Method:** `GET`
* **Endpoint:** `/servers/{id}`
* **Description:** Retrieve a single server by its numeric ID.
* **Response:** `200 OK` or `404 Not Found`

#### 4. Update Server
* **Method:** `PUT`
* **Endpoint:** `/servers/{id}`
* **Body:** (Partial updates allowed)
```json
{
"hostname": "new-name",
"state": "retired"
}
```
* **Response:** `200 OK`

#### 5. Delete Server
* **Method:** `DELETE`
* **Endpoint:** `/servers/{id}`
* **Description:** Delete a server by ID.
* **Response:** `204 No Content`

### Validation Rules
* **Hostname:** Must be non-empty and unique across the database.
* **IP Address:** Must be a valid standard IPv4 or IPv6 address.
* **State:** Strictly limited to `active`, `offline`, or `retired`.

## Testing
The project includes a pytest suite that mocks the database connection, ensuring logic is tested without requiring a running DB.
```bash
# Run all tests
python3 -m pytest tests/test_main.py -v
```

## Start the API Server locally
(http://127.0.0.1:8080)
```bash

python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt

# Run the API
uvicorn app.main:app --reload --port 8080
```

Empty file added app/__init__.py
Empty file.
129 changes: 129 additions & 0 deletions app/cli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import typer
import requests
import json
from typing import Optional
from rich.console import Console
from rich.table import Table

app = typer.Typer()
console = Console()

API_URL = "http://127.0.0.1:8000"

@app.command()
def list():
"""List all servers"""
try:
response = requests.get(f"{API_URL}/servers")
response.raise_for_status()
servers = response.json()

table = Table(title="Inventory Servers")
table.add_column("ID", style="cyan")
table.add_column("Hostname", style="green")
table.add_column("IP Address", style="magenta")
table.add_column("State", style="yellow")

for s in servers:
# Handle cases where state might be missing
state_val = s.get("state", "unknown")
table.add_row(str(s["id"]), s["hostname"], s["ip_address"], state_val)

console.print(table)
except Exception as e:
console.print(f"[bold red]Error:[/bold red] {e}")

@app.command()
def get(server_id: int):
"""Get details of a single server by ID"""
try:
response = requests.get(f"{API_URL}/servers/{server_id}")

if response.status_code == 200:
server = response.json()

table = Table(title=f"Server Details (ID: {server_id})")
table.add_column("Field", style="cyan", justify="right")
table.add_column("Value", style="green")

table.add_row("ID", str(server["id"]))
table.add_row("Hostname", server["hostname"])
table.add_row("IP Address", server["ip_address"])
table.add_row("State", server.get("state", "active"))

console.print(table)

elif response.status_code == 404:
console.print(f"[bold red]Error:[/bold red] Server with ID {server_id} not found.")
else:
console.print(f"[bold red]Failed:[/bold red] {response.text}")

except Exception as e:
console.print(f"[bold red]Error:[/bold red] {e}")

@app.command()
def create(
hostname: str,
ip: str,
state: str = typer.Option("active", help="active, offline, or retired")
):
"""Create a new server"""
payload = {"hostname": hostname, "ip_address": ip, "state": state}
try:
response = requests.post(f"{API_URL}/servers", json=payload)
if response.status_code == 201:
console.print(f"[bold green]Success![/bold green] Created server: {response.json()}")
else:
console.print(f"[bold red]Failed:[/bold red] {response.text}")
except Exception as e:
console.print(f"[bold red]Error:[/bold red] {e}")

@app.command()
def update(
server_id: int,
hostname: Optional[str] = typer.Option(None, help="New hostname"),
ip: Optional[str] = typer.Option(None, help="New IP address"),
state: Optional[str] = typer.Option(None, help="active, offline, or retired")
):
"""Update an existing server. Use --hostname or --ip or --state to specify changes."""

# Build payload with only the fields that were provided
payload = {}
if hostname:
payload["hostname"] = hostname
if ip:
payload["ip_address"] = ip
if state:
payload["state"] = state

if not payload:
console.print("[bold yellow]Warning:[/bold yellow] No fields provided to update.")
return

try:
response = requests.put(f"{API_URL}/servers/{server_id}", json=payload)

if response.status_code == 200:
console.print(f"[bold green]Success![/bold green] Updated server: {response.json()}")
elif response.status_code == 404:
console.print(f"[bold red]Error:[/bold red] Server with ID {server_id} not found.")
else:
console.print(f"[bold red]Failed:[/bold red] {response.text}")

except Exception as e:
console.print(f"[bold red]Error:[/bold red] {e}")

@app.command()
def delete(server_id: int):
"""Delete a server by ID"""
try:
response = requests.delete(f"{API_URL}/servers/{server_id}")
if response.status_code == 204:
console.print(f"[bold green]Success![/bold green] Server {server_id} deleted.")
else:
console.print(f"[bold red]Failed:[/bold red] {response.text}")
except Exception as e:
console.print(f"[bold red]Error:[/bold red] {e}")

if __name__ == "__main__":
app()
37 changes: 37 additions & 0 deletions app/database.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import os
import asyncpg
from dotenv import load_dotenv

# 1. Load .env file explicitly so uvicorn can find variables locally
load_dotenv()

# 2. Get DB config with defaults for local development
DB_USER = os.getenv("POSTGRES_USER")
DB_PASS = os.getenv("POSTGRES_PASSWORD")
DB_NAME = os.getenv("POSTGRES_DB")
DB_PORT = os.getenv("POSTGRES_PORT")
DB_HOST = os.getenv("POSTGRES_HOST")

DATABASE_URL = f"postgresql://{DB_USER}:{DB_PASS}@{DB_HOST}:{DB_PORT}/{DB_NAME}"

class Database:
def __init__(self):
self.pool = None

async def connect(self):
print(f"Connecting to {DATABASE_URL}...") # Debug print
self.pool = await asyncpg.create_pool(dsn=DATABASE_URL)
print("Database connection pool created.")

async def disconnect(self):
if self.pool:
await self.pool.close()
print("Database connection pool closed.")

db = Database()

async def get_db_conn():
if not db.pool:
raise RuntimeError("Database not initialized")
async with db.pool.acquire() as conn:
yield conn
Loading