From 54a856cb208e688ba0a92da6c14e4842cef0950f Mon Sep 17 00:00:00 2001 From: Kyryl Skobylko Date: Wed, 14 Jan 2026 10:00:00 +0200 Subject: [PATCH] fix: Add all the stuff --- .env.example | 7 + .gitignore | 20 +++ Dockerfile | 13 ++ Makefile | 61 +++++++ README.md | 391 ++++++++++++++++++++++++++++++++++++++++--- api/__init__.py | 1 + api/config.py | 30 ++++ api/database.py | 50 ++++++ api/main.py | 122 ++++++++++++++ api/models.py | 88 ++++++++++ cli/__init__.py | 1 + cli/main.py | 143 ++++++++++++++++ docker-compose.yml | 56 +++++++ pyproject.toml | 32 ++++ pytest.ini | 11 ++ requirements-dev.txt | 3 + requirements.txt | 7 + tests/__init__.py | 1 + tests/conftest.py | 92 ++++++++++ tests/test_api.py | 339 +++++++++++++++++++++++++++++++++++++ tests/test_models.py | 126 ++++++++++++++ 21 files changed, 1571 insertions(+), 23 deletions(-) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 Makefile create mode 100644 api/__init__.py create mode 100644 api/config.py create mode 100644 api/database.py create mode 100644 api/main.py create mode 100644 api/models.py create mode 100644 cli/__init__.py create mode 100755 cli/main.py create mode 100644 docker-compose.yml create mode 100644 pyproject.toml create mode 100644 pytest.ini create mode 100644 requirements-dev.txt create mode 100644 requirements.txt create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/test_api.py create mode 100644 tests/test_models.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..81114be --- /dev/null +++ b/.env.example @@ -0,0 +1,7 @@ +DB_HOST=localhost +DB_PORT=5432 +DB_NAME=inventory +DB_USER=postgres +DB_PASSWORD=postgres + +DEBUG=false diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b6a5527 --- /dev/null +++ b/.gitignore @@ -0,0 +1,20 @@ +venv/ +env/ +.venv/ +.env/ +__pycache__/ + +.pytest_cache/ + + +postgres_data/ + +# Local dev stuff +.env +.env.local + +# MacOS BS +.DS_Store +Thumbs.db + +server_inventory.egg-info \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..264d6ef --- /dev/null +++ b/Dockerfile @@ -0,0 +1,13 @@ +FROM python:3.11-slim + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY api/ ./api/ +COPY cli/ ./cli/ + +EXPOSE 8000 + +CMD ["uvicorn", "api.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..02394a7 --- /dev/null +++ b/Makefile @@ -0,0 +1,61 @@ +.PHONY: install dev test clean docker-up docker-down + +PYTHON := $(shell command -v .venv/bin/python 2>/dev/null || echo python3) +PIP := $(shell command -v .venv/bin/pip 2>/dev/null || echo pip3) +PYTEST := $(shell command -v .venv/bin/pytest 2>/dev/null || echo pytest) + +help: + @echo "make install - install deps" + @echo "make dev - run dev server" + @echo "make test - run tests" + @echo "make run - start stack" + @echo "make stop - stop stack" + @echo "make clean - cleanup" + @echo "make install-cli- install CLI tool" + +install: + $(PIP) install -r requirements.txt + +install-dev: install + $(PIP) install -r requirements-dev.txt + +dev: + $(PYTHON) -m uvicorn api.main:app --reload + +install-cli: install + $(PIP) install -e . + +test: + DB_HOST=localhost DB_PORT=5433 DB_NAME=inventory_test DB_USER=postgres DB_PASSWORD=postgres \ + $(PYTEST) -v + +test-cov: + DB_HOST=localhost DB_PORT=5433 DB_NAME=inventory_test DB_USER=postgres DB_PASSWORD=postgres \ + $(PYTEST) --cov=api --cov-report=html + +db-up: + docker-compose up -d db + +db-test-up: + docker-compose --profile test up -d db-test + +run: + docker-compose up -d + +stop: + docker-compose down + +build: + docker-compose build --no-cache + +logs: + docker-compose logs -f + +init-db: + $(PYTHON) -c "from api.database import init_db; init_db(); print('Done')" + +clean: + find . -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true + find . -type d -name .pytest_cache -exec rm -rf {} + 2>/dev/null || true + find . -type d -name htmlcov -exec rm -rf {} + 2>/dev/null || true + rm -f .coverage diff --git a/README.md b/README.md index 3145d38..77ca1dd 100644 --- a/README.md +++ b/README.md @@ -1,31 +1,376 @@ -# Instructions +# Server 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. +A CRUD application for tracking servers across multiple 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 -Short API.md on how to run everything, also a short API and CLI spec +- REST API for server management (Create, Read, Update, Delete) +- Command-line interface (CLI) for server operations +- PostgreSQL database with raw SQL queries +- Docker Compose for easy deployment +- Comprehensive test suite -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 +## Quick Start -Requirements: -- Use FastAPI or Flask -- Store data in PostgreSQL -- Use raw SQL +### Using Docker Compose (Recommended) -Validate that: -- hostname is unique -- IP address looks like an IP +1. **Start the stack:** + ```bash + make run + ``` -State is one of: active, offline, retired +2. **Verify the API is running:** + ```bash + curl http://localhost:8000/health + ``` + +3. **Stop the stack:** + ```bash + make stop + ``` + +### Local Development + +1. **Install dependencies:** + ```bash + make install + ``` + +2. **Start PostgreSQL** (or use Docker): + ```bash + make db-up + ``` + +3. **Set environment variables:** + ```bash + export DB_HOST=localhost + export DB_PORT=5432 + export DB_NAME=inventory + export DB_USER=postgres + export DB_PASSWORD=postgres + ``` + +4. **Run the API:** + ```bash + uvicorn api.main:app --reload + ``` + +## Running Tests + +1. **Start the test database:** + ```bash + make test + ``` + +--- + +## API Specification + +Base URL: `http://localhost:8000` +Check docs at `http://localhost:8000/redoc` or `http://localhost:8000/swagger` + +### Endpoints + +#### Create Server +``` +POST /servers +``` + +**Request Body:** +```json +{ + "hostname": "web-server-01", + "ip_address": "192.168.1.100", + "state": "active", + "datacenter": "us-east-1" +} +``` + +**Response:** `201 Created` +```json +{ + "id": 1, + "hostname": "web-server-01", + "ip_address": "192.168.1.100", + "state": "active", + "datacenter": "us-east-1", + "created_at": "2025-01-13T10:00:00", + "updated_at": "2025-01-13T10:00:00" +} +``` + +**Errors:** +- `409 Conflict` - Hostname already exists +- `422 Unprocessable Entity` - Validation error + +--- + +#### List Servers +``` +GET /servers +``` + +**Query Parameters:** +- `state` (optional): Filter by state (`active`, `offline`, `retired`) +- `datacenter` (optional): Filter by datacenter + +**Response:** `200 OK` +```json +[ + { + "id": 1, + "hostname": "web-server-01", + "ip_address": "192.168.1.100", + "state": "active", + "datacenter": "us-east-1", + "created_at": "2025-01-13T10:00:00", + "updated_at": "2025-01-13T10:00:00" + } +] +``` + +--- + +#### Get Server +``` +GET /servers/{id} +``` + +**Response:** `200 OK` +```json +{ + "id": 1, + "hostname": "web-server-01", + "ip_address": "192.168.1.100", + "state": "active", + "datacenter": "us-east-1", + "created_at": "2025-01-13T10:00:00", + "updated_at": "2025-01-13T10:00:00" +} +``` + +**Errors:** +- `404 Not Found` - Server not found + +--- + +#### Update Server +``` +PUT /servers/{id} +``` + +**Request Body:** (all fields optional) +```json +{ + "hostname": "web-server-02", + "ip_address": "192.168.1.101", + "state": "offline", + "datacenter": "us-west-2" +} +``` + +**Response:** `200 OK` +```json +{ + "id": 1, + "hostname": "web-server-02", + "ip_address": "192.168.1.101", + "state": "offline", + "datacenter": "us-west-2", + "created_at": "2025-01-13T10:00:00", + "updated_at": "2025-01-13T10:30:00" +} +``` + +**Errors:** +- `404 Not Found` - Server not found +- `409 Conflict` - Hostname already exists +- `422 Unprocessable Entity` - Validation error + +--- + +#### Delete Server +``` +DELETE /servers/{id} +``` + +**Response:** `204 No Content` + +**Errors:** +- `404 Not Found` - Server not found + +--- + +#### Health Check +``` +GET /health +``` + +**Response:** `200 OK` +```json +{ + "status": "healthy" +} +``` + +--- + +### Validation Rules + +| Field | Rules | +|-------|-------| +| `hostname` | Required, unique, max 255 characters | +| `ip_address` | Required, valid IPv4 or IPv6 format | +| `state` | Must be one of: `active`, `offline`, `retired` (default: `active`) | +| `datacenter` | Optional string | + +--- + +## CLI Specification + +The CLI tool provides a command-line interface to interact with the API. + +### Installation + +```bash +make install-cli +``` +I will be available under active venv, to activate it run: + +```bash +source .venv/bin/activate +``` + +### Usage + +```bash +server-cli [OPTIONS] COMMAND [ARGS] +``` + +### Global Options + +| Option | Description | +|--------|-------------| +| `--api-url URL` | API base URL (default: `http://localhost:8000`) | +| `--json` | Output results as JSON | + +### Commands + +#### Create Server +```bash +server-cli create --hostname NAME --ip IP [--state STATE] [--datacenter DC] +``` + +**Options:** +- `--hostname` (required): Server hostname +- `--ip` (required): Server IP address +- `--state`: Server state (`active`, `offline`, `retired`) - default: `active` +- `--datacenter`: Data center location + +**Example:** +```bash +server-cli create --hostname web-01 --ip 192.168.1.100 --datacenter us-east-1 +``` + +--- + +#### List Servers +```bash +server-cli list [--state STATE] [--datacenter DC] +``` + +**Options:** +- `--state`: Filter by state +- `--datacenter`: Filter by datacenter + +**Example:** +```bash +server-cli list --state active +``` + +--- + +#### Get Server +```bash +server-cli get ID +``` + +**Example:** +```bash +server-cli get 1 +``` + +--- + +#### Update Server +```bash +server-cli update ID [--hostname NAME] [--ip IP] [--state STATE] [--datacenter DC] +``` + +**Example:** +```bash +server-cli update 1 --state retired +``` + +--- + +#### Delete Server +```bash +server-cli delete ID +``` + +**Example:** +```bash +server-cli delete 1 +``` + +--- + +### CLI Examples + +```bash +# Create a new server +server-cli create --hostname web-server-01 --ip 192.168.1.100 --datacenter us-east-1 + +# List all active servers +server-cli list --state active + +# Get server details +server-cli get 1 + +# Update server state +server-cli update 1 --state offline + +# Delete a server +server-cli delete 1 + +# Output as JSON +server-cli --json list + +# Use custom API URL +server-cli --api-url http://api.example.com:8000 list +``` + + + +## Database Schema + +```sql +CREATE TABLE servers ( + id SERIAL PRIMARY KEY, + hostname VARCHAR(255) NOT NULL UNIQUE, + ip_address VARCHAR(45) NOT NULL, + state VARCHAR(20) NOT NULL DEFAULT 'active', + datacenter VARCHAR(255), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT valid_state CHECK (state IN ('active', 'offline', 'retired')) +); +``` + +## API Documentation + +When the API is running, interactive documentation is available at: +- **Swagger UI:** http://localhost:8000/docs +- **ReDoc:** http://localhost:8000/redoc diff --git a/api/__init__.py b/api/__init__.py new file mode 100644 index 0000000..30d966c --- /dev/null +++ b/api/__init__.py @@ -0,0 +1 @@ +# Server Inventory Management API diff --git a/api/config.py b/api/config.py new file mode 100644 index 0000000..a8159b8 --- /dev/null +++ b/api/config.py @@ -0,0 +1,30 @@ +from functools import lru_cache +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + model_config = SettingsConfigDict( + env_file=".env", + env_file_encoding="utf-8", + case_sensitive=False, + extra="ignore", + ) + + db_host: str = "localhost" + db_port: int = 5432 + db_name: str = "inventory" + db_user: str = "postgres" + db_password: str = "postgres" + debug: bool = False + + @property + def database_url(self) -> str: + return f"postgresql://{self.db_user}:{self.db_password}@{self.db_host}:{self.db_port}/{self.db_name}" + + +@lru_cache +def get_settings() -> Settings: + return Settings() + + +settings = get_settings() diff --git a/api/database.py b/api/database.py new file mode 100644 index 0000000..ff28341 --- /dev/null +++ b/api/database.py @@ -0,0 +1,50 @@ +"""Database stuff.""" + +import psycopg +from psycopg.rows import dict_row +from contextlib import contextmanager + +from api.config import settings + + +def get_connection(): + return psycopg.connect( + host=settings.db_host, + port=settings.db_port, + dbname=settings.db_name, + user=settings.db_user, + password=settings.db_password, + row_factory=dict_row, + ) + + +@contextmanager +def get_db_cursor(): + conn = get_connection() + try: + with conn.cursor() as cursor: + yield cursor + conn.commit() + except Exception: + conn.rollback() + raise + finally: + conn.close() + + +def init_db(): + with get_db_cursor() as cursor: + cursor.execute(""" + CREATE TABLE IF NOT EXISTS servers ( + id SERIAL PRIMARY KEY, + hostname VARCHAR(255) NOT NULL UNIQUE, + ip_address VARCHAR(45) NOT NULL, + state VARCHAR(20) NOT NULL DEFAULT 'active', + datacenter VARCHAR(255), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT valid_state CHECK (state IN ('active', 'offline', 'retired')) + ) + """) + cursor.execute("CREATE INDEX IF NOT EXISTS idx_servers_hostname ON servers(hostname)") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_servers_state ON servers(state)") diff --git a/api/main.py b/api/main.py new file mode 100644 index 0000000..0c458bd --- /dev/null +++ b/api/main.py @@ -0,0 +1,122 @@ +from fastapi import FastAPI, HTTPException, status + +from api.database import get_db_cursor, init_db +from api.models import ServerCreate, ServerUpdate, ServerResponse, ErrorResponse + +app = FastAPI( + title="Server Inventory API", + description="Track servers across data centers", + version="1.0.0", +) + + +@app.on_event("startup") +async def startup(): + init_db() + + +@app.get("/health") +def health(): + return {"status": "healthy"} + + +@app.post("/servers", response_model=ServerResponse, status_code=status.HTTP_201_CREATED, + responses={409: {"model": ErrorResponse}}) +def create_server(server: ServerCreate): + with get_db_cursor() as cursor: + cursor.execute("SELECT id FROM servers WHERE hostname = %s", (server.hostname,)) + if cursor.fetchone(): + raise HTTPException(status_code=409, detail=f"Server '{server.hostname}' already exists") + + cursor.execute(""" + INSERT INTO servers (hostname, ip_address, state, datacenter) + VALUES (%s, %s, %s, %s) + RETURNING id, hostname, ip_address, state, datacenter, created_at, updated_at + """, (server.hostname, server.ip_address, server.state.value, server.datacenter)) + + return ServerResponse(**cursor.fetchone()) + + +@app.get("/servers", response_model=list[ServerResponse]) +def list_servers(state: str = None, datacenter: str = None): + with get_db_cursor() as cursor: + query = "SELECT * FROM servers WHERE 1=1" + params = [] + + if state: + query += " AND state = %s" + params.append(state) + if datacenter: + query += " AND datacenter = %s" + params.append(datacenter) + + query += " ORDER BY id" + cursor.execute(query, params) + + return [ServerResponse(**row) for row in cursor.fetchall()] + + +@app.get("/servers/{server_id}", response_model=ServerResponse, + responses={404: {"model": ErrorResponse}}) +def get_server(server_id: int): + with get_db_cursor() as cursor: + cursor.execute("SELECT * FROM servers WHERE id = %s", (server_id,)) + row = cursor.fetchone() + if not row: + raise HTTPException(status_code=404, detail=f"Server {server_id} not found") + return ServerResponse(**row) + + +@app.put("/servers/{server_id}", response_model=ServerResponse, + responses={404: {"model": ErrorResponse}, 409: {"model": ErrorResponse}}) +def update_server(server_id: int, server: ServerUpdate): + with get_db_cursor() as cursor: + # check exists + cursor.execute("SELECT * FROM servers WHERE id = %s", (server_id,)) + existing = cursor.fetchone() + if not existing: + raise HTTPException(status_code=404, detail=f"Server {server_id} not found") + + # check hostname conflict + if server.hostname and server.hostname != existing["hostname"]: + cursor.execute("SELECT id FROM servers WHERE hostname = %s AND id != %s", + (server.hostname, server_id)) + if cursor.fetchone(): + raise HTTPException(status_code=409, detail=f"Hostname '{server.hostname}' already taken") + + # build update + updates = [] + params = [] + if server.hostname is not None: + updates.append("hostname = %s") + params.append(server.hostname) + if server.ip_address is not None: + updates.append("ip_address = %s") + params.append(server.ip_address) + if server.state is not None: + updates.append("state = %s") + params.append(server.state.value) + if server.datacenter is not None: + updates.append("datacenter = %s") + params.append(server.datacenter) + + if updates: + updates.append("updated_at = CURRENT_TIMESTAMP") + params.append(server_id) + cursor.execute(f""" + UPDATE servers SET {', '.join(updates)} + WHERE id = %s + RETURNING * + """, params) + return ServerResponse(**cursor.fetchone()) + + return ServerResponse(**existing) + + +@app.delete("/servers/{server_id}", status_code=status.HTTP_204_NO_CONTENT, + responses={404: {"model": ErrorResponse}}) +def delete_server(server_id: int): + with get_db_cursor() as cursor: + cursor.execute("DELETE FROM servers WHERE id = %s RETURNING id", (server_id,)) + if not cursor.fetchone(): + raise HTTPException(status_code=404, detail=f"Server {server_id} not found") diff --git a/api/models.py b/api/models.py new file mode 100644 index 0000000..c1306e5 --- /dev/null +++ b/api/models.py @@ -0,0 +1,88 @@ +import ipaddress +from typing import Optional +from datetime import datetime +from enum import Enum + +from pydantic import BaseModel, field_validator + + +class ServerState(str, Enum): + ACTIVE = "active" + OFFLINE = "offline" + RETIRED = "retired" + + +class ServerBase(BaseModel): + hostname: str + ip_address: str + state: ServerState = ServerState.ACTIVE + datacenter: Optional[str] = None + + @field_validator("hostname") + @classmethod + def hostname_not_empty(cls, v: str) -> str: + v = v.strip() + if not v: + raise ValueError("Hostname cannot be empty") + if len(v) > 255: + raise ValueError("Hostname too long") + return v + + @field_validator("ip_address") + @classmethod + def valid_ip(cls, v: str) -> str: + v = v.strip() + try: + ipaddress.ip_address(v) + except ValueError: + raise ValueError("Invalid IP address format. Must be a valid IPv4 or IPv6 address") + return v + + +class ServerCreate(ServerBase): + pass + + +class ServerUpdate(BaseModel): + hostname: Optional[str] = None + ip_address: Optional[str] = None + state: Optional[ServerState] = None + datacenter: Optional[str] = None + + @field_validator("hostname") + @classmethod + def hostname_not_empty(cls, v: Optional[str]) -> Optional[str]: + if v is None: + return v + v = v.strip() + if not v: + raise ValueError("Hostname cannot be empty") + if len(v) > 255: + raise ValueError("Hostname too long") + return v + + @field_validator("ip_address") + @classmethod + def valid_ip(cls, v: Optional[str]) -> Optional[str]: + if v is None: + return v + v = v.strip() + try: + ipaddress.ip_address(v) + except ValueError: + raise ValueError("Invalid IP address format. Must be a valid IPv4 or IPv6 address") + return v + + +class ServerResponse(BaseModel): + id: int + hostname: str + ip_address: str + state: str + datacenter: Optional[str] + created_at: datetime + updated_at: datetime + + +class ErrorResponse(BaseModel): + detail: str diff --git a/cli/__init__.py b/cli/__init__.py new file mode 100644 index 0000000..4a151b6 --- /dev/null +++ b/cli/__init__.py @@ -0,0 +1 @@ +# Server Inventory Management CLI diff --git a/cli/main.py b/cli/main.py new file mode 100755 index 0000000..9f6a332 --- /dev/null +++ b/cli/main.py @@ -0,0 +1,143 @@ +#!/usr/bin/env python3 +"""CLI for server inventory management.""" + +import argparse +import sys +import json +import requests + +API_URL = "http://localhost:8000" + + +def handle_response(resp, expected=200): + if resp.status_code == expected: + return resp.json() if expected != 204 else {} + try: + err = resp.json().get("detail", "Unknown error") + except: + err = f"HTTP {resp.status_code}" + print(f"Error: {err}", file=sys.stderr) + sys.exit(1) + + +def format_table(servers): + if not servers: + return "No servers found." + + headers = ["ID", "Hostname", "IP Address", "State", "Datacenter"] + rows = [[str(s["id"]), s["hostname"], s["ip_address"], s["state"], s.get("datacenter") or "-"] + for s in servers] + + widths = [max(len(h), max((len(r[i]) for r in rows), default=0)) + for i, h in enumerate(headers)] + + lines = [" | ".join(h.ljust(widths[i]) for i, h in enumerate(headers))] + lines.append("-+-".join("-" * w for w in widths)) + lines.extend(" | ".join(c.ljust(widths[i]) for i, c in enumerate(row)) for row in rows) + + return "\n".join(lines) + + +def cmd_create(args): + data = {"hostname": args.hostname, "ip_address": args.ip, "state": args.state} + if args.datacenter: + data["datacenter"] = args.datacenter + result = handle_response(requests.post(f"{API_URL}/servers", json=data), 201) + if args.json: + print(json.dumps(result, indent=2, default=str)) + else: + print(f"Created server {result['id']}: {result['hostname']}") + + +def cmd_list(args): + params = {} + if args.state: + params["state"] = args.state + if args.datacenter: + params["datacenter"] = args.datacenter + result = handle_response(requests.get(f"{API_URL}/servers", params=params)) + if args.json: + print(json.dumps(result, indent=2, default=str)) + else: + print(format_table(result)) + + +def cmd_get(args): + result = handle_response(requests.get(f"{API_URL}/servers/{args.id}")) + if args.json: + print(json.dumps(result, indent=2, default=str)) + else: + print(f"ID: {result['id']}") + print(f"Hostname: {result['hostname']}") + print(f"IP: {result['ip_address']}") + print(f"State: {result['state']}") + print(f"Datacenter: {result.get('datacenter') or '-'}") + + +def cmd_update(args): + data = {} + if args.hostname: + data["hostname"] = args.hostname + if args.ip: + data["ip_address"] = args.ip + if args.state: + data["state"] = args.state + if args.datacenter: + data["datacenter"] = args.datacenter + + result = handle_response(requests.put(f"{API_URL}/servers/{args.id}", json=data)) + if args.json: + print(json.dumps(result, indent=2, default=str)) + else: + print(f"Updated server {result['id']}") + + +def cmd_delete(args): + handle_response(requests.delete(f"{API_URL}/servers/{args.id}"), 204) + print(f"Deleted server {args.id}") + + +def main(): + parser = argparse.ArgumentParser(description="Server Inventory CLI") + parser.add_argument("--json", action="store_true", help="Output as JSON") + subs = parser.add_subparsers(dest="cmd", required=True) + + # create + p = subs.add_parser("create", help="Create server") + p.add_argument("--hostname", required=True) + p.add_argument("--ip", required=True) + p.add_argument("--state", default="active", choices=["active", "offline", "retired"]) + p.add_argument("--datacenter") + p.set_defaults(func=cmd_create) + + # list + p = subs.add_parser("list", help="List servers") + p.add_argument("--state", choices=["active", "offline", "retired"]) + p.add_argument("--datacenter") + p.set_defaults(func=cmd_list) + + # get + p = subs.add_parser("get", help="Get server") + p.add_argument("id", type=int) + p.set_defaults(func=cmd_get) + + # update + p = subs.add_parser("update", help="Update server") + p.add_argument("id", type=int) + p.add_argument("--hostname") + p.add_argument("--ip") + p.add_argument("--state", choices=["active", "offline", "retired"]) + p.add_argument("--datacenter") + p.set_defaults(func=cmd_update) + + # delete + p = subs.add_parser("delete", help="Delete server") + p.add_argument("id", type=int) + p.set_defaults(func=cmd_delete) + + args = parser.parse_args() + args.func(args) + + +if __name__ == "__main__": + main() diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..0c3a08d --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,56 @@ +services: + db: + image: postgres:15-alpine + container_name: inventory-db + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: inventory + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 5s + timeout: 5s + retries: 5 + + api: + build: + context: . + dockerfile: Dockerfile + container_name: inventory-api + ports: + - "8000:8000" + environment: + DB_HOST: db + DB_PORT: 5432 + DB_NAME: inventory + DB_USER: postgres + DB_PASSWORD: postgres + depends_on: + db: + condition: service_healthy + restart: unless-stopped + + # Test database for running tests + db-test: + image: postgres:15-alpine + container_name: inventory-db-test + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: inventory_test + ports: + - "5433:5432" + profiles: + - test + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 5s + timeout: 5s + retries: 5 + +volumes: + postgres_data: diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..d8d83ae --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,32 @@ +[build-system] +requires = ["setuptools>=61.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "server-inventory" +version = "1.0.0" +description = "Server inventory management API and CLI" +readme = "README.md" +requires-python = ">=3.10" +dependencies = [ + "fastapi>=0.104.0", + "uvicorn>=0.24.0", + "psycopg[binary]>=3.1.0", + "pydantic>=2.5.0", + "pydantic-settings>=2.1.0", + "python-dotenv>=1.0.0", + "requests>=2.31.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=7.0.0", + "pytest-cov>=4.0.0", + "httpx>=0.25.0", +] + +[project.scripts] +server-cli = "cli.main:main" + +[tool.setuptools.packages.find] +include = ["api*", "cli*"] diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..1b716f8 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,11 @@ +[pytest] +testpaths = tests +python_files = test_*.py +python_classes = Test* +python_functions = test_* +addopts = -v --tb=short +filterwarnings = + ignore::DeprecationWarning + +[tool:pytest] +asyncio_mode = auto diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..f72fa61 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,3 @@ +pytest>=7.4.0 +httpx>=0.25.0 +pytest-cov>=4.1.0 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..ff4c9ae --- /dev/null +++ b/requirements.txt @@ -0,0 +1,7 @@ +fastapi>=0.104.0 +uvicorn>=0.24.0 +psycopg[binary]>=3.1.0 +pydantic>=2.5.0 +pydantic-settings>=2.1.0 +python-dotenv>=1.0.0 +requests>=2.31.0 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..7bce965 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +# Tests for Server Inventory Management diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..53e12de --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,92 @@ +import os +import pytest +import psycopg +from psycopg.rows import dict_row +from fastapi.testclient import TestClient + +# test db config +os.environ.setdefault("DB_HOST", "localhost") +os.environ.setdefault("DB_PORT", "5433") +os.environ.setdefault("DB_NAME", "inventory_test") +os.environ.setdefault("DB_USER", "postgres") +os.environ.setdefault("DB_PASSWORD", "postgres") + +from api.main import app +from api.database import init_db, get_connection + + +@pytest.fixture(scope="session") +def db_connection(): + conn = get_connection() + yield conn + conn.close() + + +@pytest.fixture(scope="function") +def clean_db(): + """Reset db before each test.""" + conn = psycopg.connect( + host=os.environ["DB_HOST"], + port=int(os.environ["DB_PORT"]), + dbname=os.environ["DB_NAME"], + user=os.environ["DB_USER"], + password=os.environ["DB_PASSWORD"], + row_factory=dict_row, + ) + + with conn.cursor() as cur: + cur.execute(""" + CREATE TABLE IF NOT EXISTS servers ( + id SERIAL PRIMARY KEY, + hostname VARCHAR(255) NOT NULL UNIQUE, + ip_address VARCHAR(45) NOT NULL, + state VARCHAR(20) NOT NULL DEFAULT 'active', + datacenter VARCHAR(255), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT valid_state CHECK (state IN ('active', 'offline', 'retired')) + ) + """) + conn.commit() + cur.execute("TRUNCATE TABLE servers RESTART IDENTITY CASCADE") + conn.commit() + conn.close() + yield + + # cleanup + conn = psycopg.connect( + host=os.environ["DB_HOST"], + port=int(os.environ["DB_PORT"]), + dbname=os.environ["DB_NAME"], + user=os.environ["DB_USER"], + password=os.environ["DB_PASSWORD"], + ) + with conn.cursor() as cur: + cur.execute("TRUNCATE TABLE servers RESTART IDENTITY CASCADE") + conn.commit() + conn.close() + + +@pytest.fixture +def client(clean_db): + with TestClient(app) as c: + yield c + + +@pytest.fixture +def sample_server(): + return { + "hostname": "web-server-01", + "ip_address": "192.168.1.100", + "state": "active", + "datacenter": "us-east-1", + } + + +@pytest.fixture +def sample_servers(): + return [ + {"hostname": "web-server-01", "ip_address": "192.168.1.100", "state": "active", "datacenter": "us-east-1"}, + {"hostname": "db-server-01", "ip_address": "192.168.1.101", "state": "active", "datacenter": "us-east-1"}, + {"hostname": "cache-server-01", "ip_address": "192.168.1.102", "state": "offline", "datacenter": "us-west-2"}, + ] diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..a4238ce --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,339 @@ +"""Tests for API endpoints.""" + +import pytest + + +class TestCreateServer: + """Tests for POST /servers endpoint.""" + + def test_create_server_success(self, client, sample_server): + """Test creating a server with valid data.""" + response = client.post("/servers", json=sample_server) + + assert response.status_code == 201 + data = response.json() + assert data["hostname"] == sample_server["hostname"] + assert data["ip_address"] == sample_server["ip_address"] + assert data["state"] == sample_server["state"] + assert data["datacenter"] == sample_server["datacenter"] + assert "id" in data + assert "created_at" in data + assert "updated_at" in data + + def test_create_server_minimal(self, client): + """Test creating a server with minimal required fields.""" + server = { + "hostname": "minimal-server", + "ip_address": "10.0.0.1", + } + response = client.post("/servers", json=server) + + assert response.status_code == 201 + data = response.json() + assert data["hostname"] == server["hostname"] + assert data["state"] == "active" # Default state + assert data["datacenter"] is None + + def test_create_server_duplicate_hostname(self, client, sample_server): + """Test that duplicate hostnames are rejected.""" + response = client.post("/servers", json=sample_server) + assert response.status_code == 201 + + # Try to create another server with the same hostname + response = client.post("/servers", json=sample_server) + assert response.status_code == 409 + assert "already exists" in response.json()["detail"] + + def test_create_server_invalid_ip(self, client): + """Test that invalid IP addresses are rejected.""" + server = { + "hostname": "invalid-ip-server", + "ip_address": "not-an-ip", + } + response = client.post("/servers", json=server) + assert response.status_code == 422 + + def test_create_server_invalid_ip_format(self, client): + """Test various invalid IP formats.""" + invalid_ips = [ + "256.1.1.1", + "1.1.1.256", + "1.1.1", + "1.1.1.1.1", + "abc.def.ghi.jkl", + "", + ] + + for ip in invalid_ips: + server = { + "hostname": f"server-{ip}", + "ip_address": ip, + } + response = client.post("/servers", json=server) + assert response.status_code == 422, f"Expected 422 for IP: {ip}" + + def test_create_server_valid_ipv4(self, client): + """Test various valid IPv4 addresses.""" + valid_ips = [ + "0.0.0.0", + "255.255.255.255", + "192.168.1.1", + "10.0.0.1", + "172.16.0.1", + ] + + for i, ip in enumerate(valid_ips): + server = { + "hostname": f"server-v4-{i}", + "ip_address": ip, + } + response = client.post("/servers", json=server) + assert response.status_code == 201, f"Expected 201 for IP: {ip}" + + def test_create_server_invalid_state(self, client): + """Test that invalid states are rejected.""" + server = { + "hostname": "invalid-state-server", + "ip_address": "192.168.1.1", + "state": "unknown", + } + response = client.post("/servers", json=server) + assert response.status_code == 422 + + def test_create_server_empty_hostname(self, client): + """Test that empty hostname is rejected.""" + server = { + "hostname": "", + "ip_address": "192.168.1.1", + } + response = client.post("/servers", json=server) + assert response.status_code == 422 + + +class TestListServers: + """Tests for GET /servers endpoint.""" + + def test_list_servers_empty(self, client): + """Test listing servers when database is empty.""" + response = client.get("/servers") + + assert response.status_code == 200 + assert response.json() == [] + + def test_list_servers(self, client, sample_servers): + """Test listing all servers.""" + # Create servers + for server in sample_servers: + client.post("/servers", json=server) + + response = client.get("/servers") + + assert response.status_code == 200 + data = response.json() + assert len(data) == len(sample_servers) + + def test_list_servers_filter_by_state(self, client, sample_servers): + """Test filtering servers by state.""" + # Create servers + for server in sample_servers: + client.post("/servers", json=server) + + response = client.get("/servers", params={"state": "active"}) + + assert response.status_code == 200 + data = response.json() + assert len(data) == 2 # Two active servers in sample data + assert all(s["state"] == "active" for s in data) + + def test_list_servers_filter_by_datacenter(self, client, sample_servers): + """Test filtering servers by datacenter.""" + # Create servers + for server in sample_servers: + client.post("/servers", json=server) + + response = client.get("/servers", params={"datacenter": "us-east-1"}) + + assert response.status_code == 200 + data = response.json() + assert len(data) == 2 # Two servers in us-east-1 + assert all(s["datacenter"] == "us-east-1" for s in data) + + +class TestGetServer: + """Tests for GET /servers/{id} endpoint.""" + + def test_get_server_success(self, client, sample_server): + """Test getting a server by ID.""" + # Create server + create_response = client.post("/servers", json=sample_server) + server_id = create_response.json()["id"] + + response = client.get(f"/servers/{server_id}") + + assert response.status_code == 200 + data = response.json() + assert data["id"] == server_id + assert data["hostname"] == sample_server["hostname"] + + def test_get_server_not_found(self, client): + """Test getting a non-existent server.""" + response = client.get("/servers/99999") + + assert response.status_code == 404 + assert "not found" in response.json()["detail"] + + +class TestUpdateServer: + """Tests for PUT /servers/{id} endpoint.""" + + def test_update_server_hostname(self, client, sample_server): + """Test updating server hostname.""" + # Create server + create_response = client.post("/servers", json=sample_server) + server_id = create_response.json()["id"] + + response = client.put( + f"/servers/{server_id}", + json={"hostname": "new-hostname"} + ) + + assert response.status_code == 200 + data = response.json() + assert data["hostname"] == "new-hostname" + assert data["ip_address"] == sample_server["ip_address"] + + def test_update_server_state(self, client, sample_server): + """Test updating server state.""" + # Create server + create_response = client.post("/servers", json=sample_server) + server_id = create_response.json()["id"] + + response = client.put( + f"/servers/{server_id}", + json={"state": "retired"} + ) + + assert response.status_code == 200 + data = response.json() + assert data["state"] == "retired" + + def test_update_server_ip_address(self, client, sample_server): + """Test updating server IP address.""" + # Create server + create_response = client.post("/servers", json=sample_server) + server_id = create_response.json()["id"] + + response = client.put( + f"/servers/{server_id}", + json={"ip_address": "10.0.0.99"} + ) + + assert response.status_code == 200 + data = response.json() + assert data["ip_address"] == "10.0.0.99" + + def test_update_server_multiple_fields(self, client, sample_server): + """Test updating multiple fields at once.""" + # Create server + create_response = client.post("/servers", json=sample_server) + server_id = create_response.json()["id"] + + update_data = { + "hostname": "updated-server", + "ip_address": "10.0.0.50", + "state": "offline", + "datacenter": "eu-west-1", + } + + response = client.put(f"/servers/{server_id}", json=update_data) + + assert response.status_code == 200 + data = response.json() + assert data["hostname"] == update_data["hostname"] + assert data["ip_address"] == update_data["ip_address"] + assert data["state"] == update_data["state"] + assert data["datacenter"] == update_data["datacenter"] + + def test_update_server_not_found(self, client): + """Test updating a non-existent server.""" + response = client.put( + "/servers/99999", + json={"hostname": "new-hostname"} + ) + + assert response.status_code == 404 + + def test_update_server_duplicate_hostname(self, client, sample_servers): + """Test that updating to a duplicate hostname is rejected.""" + # Create two servers + client.post("/servers", json=sample_servers[0]) + create_response = client.post("/servers", json=sample_servers[1]) + server_id = create_response.json()["id"] + + # Try to update second server with first server's hostname + response = client.put( + f"/servers/{server_id}", + json={"hostname": sample_servers[0]["hostname"]} + ) + + assert response.status_code == 409 + + def test_update_server_invalid_ip(self, client, sample_server): + """Test that updating with invalid IP is rejected.""" + create_response = client.post("/servers", json=sample_server) + server_id = create_response.json()["id"] + + response = client.put( + f"/servers/{server_id}", + json={"ip_address": "invalid-ip"} + ) + + assert response.status_code == 422 + + def test_update_server_empty_body(self, client, sample_server): + """Test updating with empty body returns unchanged server.""" + create_response = client.post("/servers", json=sample_server) + server_id = create_response.json()["id"] + original = create_response.json() + + response = client.put(f"/servers/{server_id}", json={}) + + assert response.status_code == 200 + data = response.json() + assert data["hostname"] == original["hostname"] + assert data["ip_address"] == original["ip_address"] + + +class TestDeleteServer: + """Tests for DELETE /servers/{id} endpoint.""" + + def test_delete_server_success(self, client, sample_server): + """Test deleting a server.""" + # Create server + create_response = client.post("/servers", json=sample_server) + server_id = create_response.json()["id"] + + # Delete server + response = client.delete(f"/servers/{server_id}") + assert response.status_code == 204 + + # Verify it's deleted + get_response = client.get(f"/servers/{server_id}") + assert get_response.status_code == 404 + + def test_delete_server_not_found(self, client): + """Test deleting a non-existent server.""" + response = client.delete("/servers/99999") + + assert response.status_code == 404 + + +class TestHealthCheck: + """Tests for health check endpoint.""" + + def test_health_check(self, client): + """Test health check endpoint.""" + response = client.get("/health") + + assert response.status_code == 200 + assert response.json()["status"] == "healthy" diff --git a/tests/test_models.py b/tests/test_models.py new file mode 100644 index 0000000..8c43164 --- /dev/null +++ b/tests/test_models.py @@ -0,0 +1,126 @@ +"""Tests for Pydantic models validation.""" + +import pytest +from pydantic import ValidationError + +from api.models import ServerCreate, ServerUpdate, ServerState + + +class TestServerCreateValidation: + """Tests for ServerCreate model validation.""" + + def test_valid_server_create(self): + """Test creating a valid server model.""" + server = ServerCreate( + hostname="test-server", + ip_address="192.168.1.1", + state=ServerState.ACTIVE, + datacenter="us-east-1", + ) + assert server.hostname == "test-server" + assert server.ip_address == "192.168.1.1" + assert server.state == ServerState.ACTIVE + + def test_valid_ipv4_addresses(self): + """Test various valid IPv4 addresses.""" + valid_ips = [ + "0.0.0.0", + "255.255.255.255", + "192.168.1.1", + "10.0.0.1", + "172.16.0.1", + "1.2.3.4", + ] + + for ip in valid_ips: + server = ServerCreate(hostname="test", ip_address=ip) + assert server.ip_address == ip + + def test_invalid_ipv4_addresses(self): + """Test various invalid IPv4 addresses.""" + invalid_ips = [ + "256.1.1.1", + "1.256.1.1", + "1.1.256.1", + "1.1.1.256", + "1.1.1", + "1.1.1.1.1", + "a.b.c.d", + "192.168.1", + "", + "not-an-ip", + ] + + for ip in invalid_ips: + with pytest.raises(ValidationError): + ServerCreate(hostname="test", ip_address=ip) + + def test_valid_ipv6_addresses(self): + """Test various valid IPv6 addresses.""" + valid_ips = [ + "2001:0db8:85a3:0000:0000:8a2e:0370:7334", + "::1", + "fe80::1", + ] + + for ip in valid_ips: + server = ServerCreate(hostname="test", ip_address=ip) + assert server.ip_address == ip + + def test_empty_hostname_rejected(self): + """Test that empty hostname is rejected.""" + with pytest.raises(ValidationError): + ServerCreate(hostname="", ip_address="192.168.1.1") + + def test_whitespace_hostname_rejected(self): + """Test that whitespace-only hostname is rejected.""" + with pytest.raises(ValidationError): + ServerCreate(hostname=" ", ip_address="192.168.1.1") + + def test_hostname_trimmed(self): + """Test that hostname whitespace is trimmed.""" + server = ServerCreate(hostname=" test-server ", ip_address="192.168.1.1") + assert server.hostname == "test-server" + + def test_all_valid_states(self): + """Test all valid server states.""" + for state in ServerState: + server = ServerCreate( + hostname="test", + ip_address="192.168.1.1", + state=state, + ) + assert server.state == state + + def test_default_state(self): + """Test that default state is active.""" + server = ServerCreate(hostname="test", ip_address="192.168.1.1") + assert server.state == ServerState.ACTIVE + + +class TestServerUpdateValidation: + """Tests for ServerUpdate model validation.""" + + def test_all_fields_optional(self): + """Test that all fields are optional in update.""" + server = ServerUpdate() + assert server.hostname is None + assert server.ip_address is None + assert server.state is None + assert server.datacenter is None + + def test_partial_update(self): + """Test partial update with only some fields.""" + server = ServerUpdate(hostname="new-hostname") + assert server.hostname == "new-hostname" + assert server.ip_address is None + + def test_invalid_ip_in_update(self): + """Test that invalid IP is rejected in update.""" + with pytest.raises(ValidationError): + ServerUpdate(ip_address="invalid-ip") + + def test_empty_hostname_in_update(self): + """Test that empty hostname is rejected in update.""" + with pytest.raises(ValidationError): + ServerUpdate(hostname="")