From 89d41855e24e7aad82a8af92ae106ffe913a0e8f Mon Sep 17 00:00:00 2001 From: Oleg Tkachuk Date: Thu, 18 Dec 2025 14:01:42 +0200 Subject: [PATCH] feat: add Server Inventory API, CLI, tests, and Docker Compose stack - Implement FastAPI + PostgreSQL API using raw SQL with full CRUD endpoints - Add ETag-based optimistic concurrency (If-Match) and conditional GET (If-None-Match) - Introduce Typer-based CLI with retries/backoff and table/JSON output - Add pytest integration test suite with health-aware startup + deterministic DB cleanup - Provide Dockerfiles and a working docker-compose stack for local/CI execution --- .gitignore | 22 +++ README.md | 164 ++++++++++++++++++--- README.md.original | 31 ++++ api/.dockerignore | 10 ++ api/Dockerfile | 46 ++++++ api/README.md | 199 ++++++++++++++++++++++++++ api/app/__init__.py | 0 api/app/config.py | 33 +++++ api/app/db.py | 137 ++++++++++++++++++ api/app/errors.py | 23 +++ api/app/etag.py | 20 +++ api/app/lifespan.py | 16 +++ api/app/main.py | 17 +++ api/app/mapping.py | 17 +++ api/app/models.py | 37 +++++ api/app/routes/__init__.py | 1 + api/app/routes/health.py | 33 +++++ api/app/routes/servers.py | 238 +++++++++++++++++++++++++++++++ api/requirements.txt | 7 + cli/.dockerignore | 10 ++ cli/Dockerfile | 29 ++++ cli/README.md | 219 ++++++++++++++++++++++++++++ cli/inventory_cli/__init__.py | 1 + cli/inventory_cli/__main__.py | 8 ++ cli/inventory_cli/app.py | 193 +++++++++++++++++++++++++ cli/inventory_cli/config.py | 27 ++++ cli/inventory_cli/errors.py | 96 +++++++++++++ cli/inventory_cli/formatting.py | 52 +++++++ cli/inventory_cli/http_client.py | 88 ++++++++++++ cli/inventory_cli/output.py | 32 +++++ cli/requirements.txt | 4 + docker-compose.yml | 36 +++++ pyproject.toml | 25 ++++ tests/.dockerignore | 10 ++ tests/Dockerfile | 6 + tests/README.md | 190 ++++++++++++++++++++++++ tests/requirements.txt | 3 + tests/test_api.py | 213 +++++++++++++++++++++++++++ 38 files changed, 2270 insertions(+), 23 deletions(-) create mode 100644 .gitignore create mode 100644 README.md.original create mode 100644 api/.dockerignore create mode 100644 api/Dockerfile create mode 100644 api/README.md create mode 100644 api/app/__init__.py create mode 100644 api/app/config.py create mode 100644 api/app/db.py create mode 100644 api/app/errors.py create mode 100644 api/app/etag.py create mode 100644 api/app/lifespan.py create mode 100644 api/app/main.py create mode 100644 api/app/mapping.py create mode 100644 api/app/models.py create mode 100644 api/app/routes/__init__.py create mode 100644 api/app/routes/health.py create mode 100644 api/app/routes/servers.py create mode 100644 api/requirements.txt create mode 100644 cli/.dockerignore create mode 100644 cli/Dockerfile create mode 100644 cli/README.md create mode 100644 cli/inventory_cli/__init__.py create mode 100644 cli/inventory_cli/__main__.py create mode 100644 cli/inventory_cli/app.py create mode 100644 cli/inventory_cli/config.py create mode 100644 cli/inventory_cli/errors.py create mode 100644 cli/inventory_cli/formatting.py create mode 100644 cli/inventory_cli/http_client.py create mode 100644 cli/inventory_cli/output.py create mode 100644 cli/requirements.txt create mode 100644 docker-compose.yml create mode 100644 pyproject.toml create mode 100644 tests/.dockerignore create mode 100644 tests/Dockerfile create mode 100644 tests/README.md create mode 100644 tests/requirements.txt create mode 100644 tests/test_api.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b74bd95 --- /dev/null +++ b/.gitignore @@ -0,0 +1,22 @@ +# Python build artifacts +build/ +dist/ +*.egg-info/ + +# Virtual environments +.venv/ +venv/ + +# Python cache +__pycache__/ +*.py[cod] + +# Test / coverage +.pytest_cache/ +.coverage +htmlcov/ + +# OS / IDE +.DS_Store +.idea/ +.vscode/ diff --git a/README.md b/README.md index 3145d38..1992aae 100644 --- a/README.md +++ b/README.md @@ -1,31 +1,149 @@ -# Instructions +# Server Inventory Platform -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 **Server Inventory platform** consisting of: -Deliverables: -- PR to https://github.com/Mathpix/hiring-challenge-devops-python that includes: -- API code -- CLI code -- pytest test suite -- Working Docker Compose stack +- **REST API** — FastAPI + PostgreSQL, raw SQL, optimistic concurrency (ETag / If-Match) +- **CLI** — Typer-based client with retry/backoff, table/JSON output, and script-friendly behavior +- **Integration tests** — pytest + httpx test suite designed for Docker Compose and CI pipelines -Short API.md on how to run everything, also a short API and CLI spec +Documentation is intentionally **component-scoped** to reduce duplication and keep ownership clear. -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 +--- -Requirements: -- Use FastAPI or Flask -- Store data in PostgreSQL -- Use raw SQL +## Repository Layout -Validate that: -- hostname is unique -- IP address looks like an IP +```text +. +├── api/ # FastAPI backend service +├── cli/ # inventory-cli (Typer-based CLI) +├── tests/ # Integration test suite +├── pyproject.toml # Build & packaging metadata (inventory-cli) +├── docker-compose.yml # Local stack (DB + API + tests) +└── README.md # Root overview (this file) +``` -State is one of: active, offline, retired +--- +### Build & Packaging Metadata + +- **`pyproject.toml`** + Defines project metadata, dependencies, build backend, and CLI entry points + for the `inventory-cli` package. Enables standardized builds (PEP 517/621), + installation via `pip`/`pipx`, and reproducible CI packaging. + +--- + +## Component Documentation + +- **API** — architecture, configuration, Docker build, health probes, ETag semantics + → [`api/README.md`](api/README.md) + +- **CLI** — installation, usage, commands, retries, output formats, Docker/pipx workflows + → [`cli/README.md`](cli/README.md) + +- **Tests** — integration coverage, readiness-aware startup, DB cleanup strategy, CI execution + → [`tests/README.md`](tests/README.md) + +--- + +## Quick Start (Local Development) + +### Start database and API + +```bash +docker compose up --build -d db api +``` + +or + +```bash +docker-compose up -d --build db api +``` + +API endpoints: + +- Base URL: `http://localhost:8000` +- OpenAPI / Swagger UI: `http://localhost:8000/docs` + +### Run integration tests (optional) + +```bash +docker compose up --build --exit-code-from tests tests +``` + +or + +```bash +docker-compose up --build --exit-code-from tests tests +``` + +### Use the CLI + +If installed locally: + +```bash +inventory-cli list +``` + +Or run via Docker: + +```bash +docker run --rm -e INVENTORY_API_URL=http://host.docker.internal:8000 inventory-cli list +``` + +--- + +## Engineering Principles + +- **Operational clarity** — explicit configuration and predictable runtime behavior +- **Safety under concurrency** — optimistic locking for updates; conditional GET support +- **Cloud-native readiness** — liveness/readiness endpoints designed for orchestration +- **Deterministic automation** — CI-friendly containers; stable exit codes and JSON output +- **Data-layer control** — raw SQL for transparency and performance (no ORM) + +--- + +## Roadmap + +The list below captures common “enterprise hardening” and scale-readiness enhancements. + +### API + +- [ ] Cursor-based pagination for large datasets +- [ ] OpenAPI schema versioning and backward-compatibility policy +- [ ] Bulk operations (batch create/update/delete) +- [ ] Soft delete and retention policy +- [ ] Audit log / change history (who/when/what) +- [ ] Metrics endpoint (Prometheus) and standardized health reporting +- [ ] Structured logging with request IDs / trace IDs +- [ ] Authentication & authorization (JWT/OIDC) and/or mTLS +- [ ] Rate limiting and abuse protection + +### CLI + +- [ ] Config file support (e.g., `~/.inventory-cli.yaml`) with environment override rules +- [ ] Shell autocompletion (bash/zsh/fish) +- [ ] Bulk import/export (CSV/JSON) and idempotent upsert semantics +- [ ] `--watch` mode for polling / monitoring workflows +- [ ] Improved UX for errors (hinting, remediation guidance, exit code taxonomy) + +### Tests / CI + +- [ ] Parallel test execution and faster feedback loops +- [ ] Contract testing against OpenAPI schema +- [ ] Fault-injection scenarios (DB unavailable, timeouts, 5xx, latency) +- [ ] Load/performance tests (k6/Locust) with baseline SLOs +- [ ] CI pipeline templates (GitHub Actions) with caching and artifact publishing + +### Platform + +- [ ] Helm chart and Kubernetes manifests (with configurable probes/resources) +- [ ] Kustomize overlays for environment promotion +- [ ] Deployment examples (RDS/ECS/EKS) and reference Terraform modules +- [ ] Observability stack example (logs/metrics/traces) + +--- + +## License + +MIT diff --git a/README.md.original b/README.md.original new file mode 100644 index 0000000..3145d38 --- /dev/null +++ b/README.md.original @@ -0,0 +1,31 @@ +# Instructions + +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. + +Deliverables: +- PR to https://github.com/Mathpix/hiring-challenge-devops-python that includes: +- API code +- CLI code +- pytest test suite +- Working Docker Compose stack + +Short API.md on how to run everything, also a short API and CLI spec + +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 + +Requirements: +- Use FastAPI or Flask +- Store data in PostgreSQL +- Use raw SQL + +Validate that: +- hostname is unique +- IP address looks like an IP + +State is one of: active, offline, retired + diff --git a/api/.dockerignore b/api/.dockerignore new file mode 100644 index 0000000..714d793 --- /dev/null +++ b/api/.dockerignore @@ -0,0 +1,10 @@ +__pycache__/ +*.pyc +.pytest_cache/ +.mypy_cache/ +.venv/ +.env +build/ +dist/ +*.egg-info/ +.git/ diff --git a/api/Dockerfile b/api/Dockerfile new file mode 100644 index 0000000..fff99a4 --- /dev/null +++ b/api/Dockerfile @@ -0,0 +1,46 @@ +# ---- builder ---- +FROM python:3.12-slim AS builder + +WORKDIR /w + +ENV PIP_NO_CACHE_DIR=1 \ + PIP_DISABLE_PIP_VERSION_CHECK=1 \ + PYTHONDONTWRITEBYTECODE=1 + +COPY requirements.txt /w/requirements.txt + +# Build wheels once +RUN pip wheel --wheel-dir /wheels -r /w/requirements.txt + +# ---- runtime ---- +FROM python:3.12-slim + +WORKDIR /app + +ENV PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 \ + PIP_NO_CACHE_DIR=1 \ + PIP_DISABLE_PIP_VERSION_CHECK=1 + +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates curl \ + && rm -rf /var/lib/apt/lists/* + +RUN useradd -m -u 10001 appuser + +COPY --from=builder /wheels /wheels +RUN pip install --no-index --find-links=/wheels /wheels/* + +COPY app /app/app + +USER appuser +EXPOSE 8000 + +HEALTHCHECK --interval=10s --timeout=3s --start-period=10s --retries=3 \ + CMD curl -fsS http://127.0.0.1:8000/servers?limit=1 >/dev/null || exit 1 + +ENV UVICORN_HOST=0.0.0.0 \ + UVICORN_PORT=8000 \ + UVICORN_WORKERS=1 + +CMD ["sh", "-lc", "uvicorn app.main:app --host ${UVICORN_HOST} --port ${UVICORN_PORT} --workers ${UVICORN_WORKERS}"] diff --git a/api/README.md b/api/README.md new file mode 100644 index 0000000..c0d807d --- /dev/null +++ b/api/README.md @@ -0,0 +1,199 @@ +# Server Inventory API + +A REST API for server inventory management built on **FastAPI**, **PostgreSQL**, and **psycopg3**. +Designed for concurrent access, safe updates, and Kubernetes-native deployments. + +--- + +## ✨ Features + +- CRUD API for servers (create, list, get, update, delete) +- Optimistic concurrency control using version-based **ETags** +- RFC-friendly ETag support: + - `If-Match` for safe updates (lost update protection) + - `If-None-Match` for conditional GET (`304 Not Modified`) +- PostgreSQL connection pooling via `psycopg_pool` +- Automatic schema initialization & safe migrations +- Indexes optimized for real query patterns +- Kubernetes-ready: + - Liveness probe + - Readiness probe (DB + pool check) +- Multi-stage Docker build +- Strict request validation with **Pydantic v2** +- Clear and explicit DB → HTTP error mapping + +--- + +## 🧱 Architecture Overview + +```bash +app/ +├── main.py # FastAPI app wiring +├── config.py # Environment-based configuration +├── db.py # PostgreSQL pool, schema, health checks +├── lifespan.py # Startup / shutdown lifecycle +├── models.py # Pydantic models +├── etag.py # ETag helpers +├── errors.py # DB -> HTTP error mapping +├── mapping.py # DB row -> API model mapping +└── routes/ + ├── servers.py # /servers API + └── health.py # /health endpoints +``` + +--- + +## 📦 Requirements + +- Python **3.12** +- PostgreSQL **13+** + +Key dependencies: + +- `fastapi` +- `uvicorn` +- `psycopg[binary]` +- `psycopg-pool` +- `pydantic` v2 +- `pydantic-settings` + +--- + +## ⚙️ Configuration + +All configuration is provided via environment variables. + +### Required + +```env +DATABASE_URL=postgresql://user:password@host:5432/dbname +``` + +### Optional (defaults shown) + +```env +APP_TITLE="Server Inventory API" +APP_VERSION="1.0.0" + +DB_POOL_MIN_SIZE=1 +DB_POOL_MAX_SIZE=10 +DB_POOL_TIMEOUT=5.0 +DB_APPLICATION_NAME=server-inventory-api + +LIST_DEFAULT_LIMIT=100 +LIST_MAX_LIMIT=500 +``` + +`.env` file is supported automatically. + +--- + +## 🚀 Running Locally + +### Install dependencies + +```bash +pip install -r requirements.txt +``` + +### Run the API + +```bash +uvicorn app.main:app --host 0.0.0.0 --port 8000 +``` + +The API will be available at: + +```bash +http://localhost:8000 +``` + +Swagger UI: + +```bash +http://localhost:8000/docs +``` + +--- + +## 🐳 Running with Docker + +### Build image + +```bash +docker build -t server-inventory-api . +``` + +### Run container + +```bash +docker run -p 8000:8000 \ + -e DATABASE_URL=postgresql://user:pass@db:5432/db \ + server-inventory-api +``` + +--- + +## 🩺 Health Endpoints + +### Liveness + +```http +GET /health/liveness +``` + +### Readiness + +```http +GET /health/readiness +``` + +--- + +## 📚 API Endpoints + +### Create Server + +```http +POST /servers +``` + +### List Servers + +```http +GET /servers +``` + +### Get Server + +```http +GET /servers/{id} +``` + +### Update Server + +```http +PUT /servers/{id} +``` + +### Delete Server + +```http +DELETE /servers/{id} +``` + +--- + +## 🧪 Testing + +Run tests: + +```bash +pytest +``` + +--- + +## 📄 License + +MIT diff --git a/api/app/__init__.py b/api/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/app/config.py b/api/app/config.py new file mode 100644 index 0000000..1090942 --- /dev/null +++ b/api/app/config.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +from pydantic import Field, PostgresDsn +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + # Pydantic v2 settings config + model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8") + + # --- App --- + app_title: str = "Server Inventory API" + app_version: str = "1.0.0" + + # --- Database --- + database_url: PostgresDsn = Field( + ..., + alias="DATABASE_URL", + description="PostgreSQL DSN, e.g. postgresql://user:pass@host:5432/db", + ) + + # --- DB pool --- + db_pool_min_size: int = Field(1, ge=1) + db_pool_max_size: int = Field(10, ge=1) + db_pool_timeout: float = Field(5.0, ge=0.1, description="Seconds to wait for a free connection") + db_application_name: str = Field("server-inventory-api", description="Postgres application_name") + + # --- List API defaults --- + list_default_limit: int = Field(100, ge=1, le=1000) + list_max_limit: int = Field(500, ge=1, le=5000) + + +settings = Settings() diff --git a/api/app/db.py b/api/app/db.py new file mode 100644 index 0000000..71d649f --- /dev/null +++ b/api/app/db.py @@ -0,0 +1,137 @@ +# db.py +from __future__ import annotations + +from contextlib import contextmanager +from typing import Iterator + +import psycopg +from psycopg_pool import ConnectionPool + +from .config import settings + +_pool: ConnectionPool | None = None + + +def is_pool_initialized() -> bool: + """ + Readiness helper: tells whether the global pool is initialized. + """ + return _pool is not None + + +def _require_pool() -> ConnectionPool: + if _pool is None: + raise RuntimeError("DB pool is not initialized") + return _pool + + +def init_pool() -> None: + """ + Initialize a global connection pool. + Call once on application startup. + """ + global _pool + if _pool is not None: + return + + _pool = ConnectionPool( + conninfo=str(settings.database_url), + min_size=settings.db_pool_min_size, + max_size=settings.db_pool_max_size, + timeout=settings.db_pool_timeout, + kwargs={"application_name": settings.db_application_name}, + open=True, + ) + + +def close_pool() -> None: + """ + Close the global connection pool. + Call on application shutdown. + """ + global _pool + if _pool is not None: + _pool.close() + _pool = None + + +@contextmanager +def get_conn() -> Iterator[psycopg.Connection]: + """ + Acquire a connection from the pool. + """ + pool = _require_pool() + with pool.connection() as conn: + yield conn + + +def db_ping() -> bool: + """ + Lightweight DB health check. + Used by readiness probe. + + Returns: + True -> DB reachable and usable + False -> pool not initialized or DB not reachable + """ + if _pool is None: + return False + + try: + with _pool.connection() as conn: + with conn.cursor() as cur: + cur.execute("SELECT 1;") + cur.fetchone() + return True + except Exception: + return False + + +def init_db() -> None: + """ + Create schema, columns, triggers, indexes. + Includes: + - version column for optimistic concurrency + - trigger for updated_at auto-update + - indexes for real query patterns + """ + ddl = """ + CREATE TABLE IF NOT EXISTS servers ( + id SERIAL PRIMARY KEY, + hostname TEXT NOT NULL UNIQUE, + ip_address TEXT NOT NULL, + state TEXT NOT NULL CHECK (state IN ('active','offline','retired')), + datacenter TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + version BIGINT NOT NULL DEFAULT 1 + ); + + ALTER TABLE servers + ADD COLUMN IF NOT EXISTS version BIGINT NOT NULL DEFAULT 1; + + CREATE OR REPLACE FUNCTION set_updated_at() + RETURNS TRIGGER AS $$ + BEGIN + NEW.updated_at = NOW(); + RETURN NEW; + END; + $$ LANGUAGE plpgsql; + + DROP TRIGGER IF EXISTS trg_servers_set_updated_at ON servers; + CREATE TRIGGER trg_servers_set_updated_at + BEFORE UPDATE ON servers + FOR EACH ROW + EXECUTE FUNCTION set_updated_at(); + + CREATE INDEX IF NOT EXISTS idx_servers_state ON servers(state); + CREATE INDEX IF NOT EXISTS idx_servers_datacenter ON servers(datacenter); + CREATE INDEX IF NOT EXISTS idx_servers_datacenter_state ON servers(datacenter, state); + CREATE INDEX IF NOT EXISTS idx_servers_active_by_dc + ON servers(datacenter) + WHERE state = 'active'; + """ + with get_conn() as conn: + with conn.transaction(): + with conn.cursor() as cur: + cur.execute(ddl) diff --git a/api/app/errors.py b/api/app/errors.py new file mode 100644 index 0000000..53dcf70 --- /dev/null +++ b/api/app/errors.py @@ -0,0 +1,23 @@ +from __future__ import annotations + +import logging + +import psycopg +from fastapi import HTTPException, status +from psycopg.errors import CheckViolation, UniqueViolation + +log = logging.getLogger(__name__) + + +def raise_db_error(action: str, exc: Exception) -> None: + # More accurate codes for concurrent / DB scenarios. + if isinstance(exc, UniqueViolation): + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="hostname already exists") + if isinstance(exc, CheckViolation): + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid server state") + if isinstance(exc, psycopg.OperationalError): + log.exception("DB operational error during %s", action) + raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="database unavailable") + + log.exception("DB error during %s", action) + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="database error") diff --git a/api/app/etag.py b/api/app/etag.py new file mode 100644 index 0000000..13cebf8 --- /dev/null +++ b/api/app/etag.py @@ -0,0 +1,20 @@ +from __future__ import annotations + + +def etag_from_version(version: int) -> str: + # Weak ETag, opaque token. Example: W/"v12" + return f'W/"v{version}"' + + +def parse_if_match_version(if_match: str) -> int | None: + """ + Accepts only our ETag format: W/"v" + Returns version or None if invalid. + """ + s = if_match.strip() + if not s.startswith('W/"v') or not s.endswith('"'): + return None + inner = s[len('W/"v') : -1] + if not inner.isdigit(): + return None + return int(inner) diff --git a/api/app/lifespan.py b/api/app/lifespan.py new file mode 100644 index 0000000..6f00bd9 --- /dev/null +++ b/api/app/lifespan.py @@ -0,0 +1,16 @@ +from __future__ import annotations + +from contextlib import asynccontextmanager +from fastapi import FastAPI + +from .db import close_pool, init_db, init_pool + + +@asynccontextmanager +async def lifespan(app: FastAPI): + init_pool() + init_db() + try: + yield + finally: + close_pool() diff --git a/api/app/main.py b/api/app/main.py new file mode 100644 index 0000000..ca59af1 --- /dev/null +++ b/api/app/main.py @@ -0,0 +1,17 @@ +from __future__ import annotations + +from fastapi import FastAPI + +from .config import settings +from .lifespan import lifespan +from .routes.servers import router as servers_router +from .routes.health import router as health_router + +app = FastAPI( + title=settings.app_title, + version=settings.app_version, + lifespan=lifespan, +) + +app.include_router(health_router) +app.include_router(servers_router) diff --git a/api/app/mapping.py b/api/app/mapping.py new file mode 100644 index 0000000..5ed3032 --- /dev/null +++ b/api/app/mapping.py @@ -0,0 +1,17 @@ +from __future__ import annotations + +from typing import Any + +from .models import ServerOut + + +def row_to_out(row: tuple[Any, ...]) -> ServerOut: + return ServerOut( + id=row[0], + hostname=row[1], + ip_address=row[2], + state=row[3], + datacenter=row[4], + updated_at=row[5], + version=row[6], + ) diff --git a/api/app/models.py b/api/app/models.py new file mode 100644 index 0000000..5f2859e --- /dev/null +++ b/api/app/models.py @@ -0,0 +1,37 @@ +# models.py +from __future__ import annotations + +from datetime import datetime +from enum import Enum + +from pydantic import BaseModel, Field, IPvAnyAddress + + +class ServerState(str, Enum): + active = "active" + offline = "offline" + retired = "retired" + + +class ServerCreate(BaseModel): + hostname: str = Field(min_length=1, max_length=255) + ip_address: IPvAnyAddress + state: ServerState + datacenter: str | None = Field(default=None, max_length=255) + + +class ServerUpdate(BaseModel): + hostname: str | None = Field(default=None, min_length=1, max_length=255) + ip_address: IPvAnyAddress | None = None + state: ServerState | None = None + datacenter: str | None = Field(default=None, max_length=255) + + +class ServerOut(BaseModel): + id: int + hostname: str + ip_address: str + state: ServerState + datacenter: str | None = None + updated_at: datetime + version: int diff --git a/api/app/routes/__init__.py b/api/app/routes/__init__.py new file mode 100644 index 0000000..7f96e8c --- /dev/null +++ b/api/app/routes/__init__.py @@ -0,0 +1 @@ +# empty on purpose diff --git a/api/app/routes/health.py b/api/app/routes/health.py new file mode 100644 index 0000000..42104e2 --- /dev/null +++ b/api/app/routes/health.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +from fastapi import APIRouter, HTTPException, status + +from ..db import db_ping, is_pool_initialized + +router = APIRouter(tags=["health"]) + + +@router.get("/health/liveness") +def liveness(): + # Liveness should NOT depend on external deps (DB). + # If the process is running and can answer HTTP, it's alive. + return {"status": "alive"} + + +@router.get("/health/readiness") +def readiness(): + # Readiness checks dependencies needed to serve traffic. + # Requirement: pool must be initialized + SELECT 1 must work. + if not is_pool_initialized(): + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="db pool not initialized", + ) + + if not db_ping(): + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="database not ready", + ) + + return {"status": "ready"} diff --git a/api/app/routes/servers.py b/api/app/routes/servers.py new file mode 100644 index 0000000..52bcb49 --- /dev/null +++ b/api/app/routes/servers.py @@ -0,0 +1,238 @@ +from __future__ import annotations + +from fastapi import APIRouter, Header, HTTPException, Query, Response, status + +from ..config import settings +from ..db import get_conn +from ..etag import etag_from_version, parse_if_match_version +from ..errors import raise_db_error +from ..mapping import row_to_out +from ..models import ServerCreate, ServerOut, ServerUpdate, ServerState + +router = APIRouter() + + +@router.post("/servers", response_model=ServerOut, status_code=status.HTTP_201_CREATED) +def create_server(payload: ServerCreate, response: Response): + sql = """ + INSERT INTO servers (hostname, ip_address, state, datacenter) + VALUES (%s, %s, %s, %s) + RETURNING id, hostname, ip_address, state, datacenter, updated_at, version; + """ + try: + with get_conn() as conn: + with conn.transaction(): + with conn.cursor() as cur: + cur.execute( + sql, + ( + payload.hostname, + str(payload.ip_address), + payload.state.value, + payload.datacenter, + ), + ) + row = cur.fetchone() + except Exception as e: + raise_db_error("create_server", e) + + if not row: + # Should not happen, but keep safe. + raise HTTPException(status_code=500, detail="database error") + + out = row_to_out(row) + + # (4) ETag + (4) Location on create + response.headers["ETag"] = etag_from_version(out.version) + response.headers["Location"] = f"/servers/{out.id}" + return out + + +@router.get("/servers", response_model=list[ServerOut]) +def list_servers( + limit: int = Query(default=settings.list_default_limit, ge=1), + offset: int = Query(default=0, ge=0), + state: ServerState | None = Query(default=None), + datacenter: str | None = Query(default=None, min_length=1, max_length=255), +): + # (10) Pagination + filters, so indexes matter. + limit = min(limit, settings.list_max_limit) + + where: list[str] = [] + values: list[object] = [] + + if state is not None: + where.append("state = %s") + values.append(state.value) + if datacenter is not None: + where.append("datacenter = %s") + values.append(datacenter) + + where_sql = f"WHERE {' AND '.join(where)}" if where else "" + + sql = f""" + SELECT id, hostname, ip_address, state, datacenter, updated_at, version + FROM servers + {where_sql} + ORDER BY id ASC + LIMIT %s OFFSET %s; + """ + values.extend([limit, offset]) + + try: + with get_conn() as conn: + with conn.cursor() as cur: + cur.execute(sql, tuple(values)) + rows = cur.fetchall() + except Exception as e: + raise_db_error("list_servers", e) + + return [row_to_out(r) for r in rows] + + +@router.get("/servers/{id}", response_model=ServerOut) +def get_server( + id: int, + response: Response, + if_none_match: str | None = Header(default=None, alias="If-None-Match"), +): + sql = """ + SELECT id, hostname, ip_address, state, datacenter, updated_at, version + FROM servers + WHERE id = %s; + """ + try: + with get_conn() as conn: + with conn.cursor() as cur: + cur.execute(sql, (id,)) + row = cur.fetchone() + except Exception as e: + raise_db_error("get_server", e) + + if not row: + raise HTTPException(status_code=404, detail="server not found") + + out = row_to_out(row) + etag = etag_from_version(out.version) + + # (3) If-None-Match support + if if_none_match is not None and if_none_match.strip() == etag: + response.headers["ETag"] = etag + return Response(status_code=status.HTTP_304_NOT_MODIFIED, headers={"ETag": etag}) + + response.headers["ETag"] = etag + return out + + +@router.put("/servers/{id}", response_model=ServerOut) +def update_server( + id: int, + payload: ServerUpdate, + response: Response, + if_match: str | None = Header(default=None, alias="If-Match"), +): + # (1) Lost update protection: atomic optimistic concurrency via version + fields: list[str] = [] + values: list[object] = [] + + if payload.hostname is not None: + fields.append("hostname = %s") + values.append(payload.hostname) + if payload.ip_address is not None: + fields.append("ip_address = %s") + values.append(str(payload.ip_address)) + if payload.state is not None: + fields.append("state = %s") + values.append(payload.state.value) + if payload.datacenter is not None: + fields.append("datacenter = %s") + values.append(payload.datacenter) + + if not fields: + raise HTTPException(status_code=400, detail="no fields to update") + + # version increment is the key here; updated_at handled by DB trigger. + fields.append("version = version + 1") + + # If-Match optional (backward compatible) + expected_version: int | None = None + if if_match is not None: + expected_version = parse_if_match_version(if_match) + if expected_version is None: + raise HTTPException(status_code=400, detail="invalid If-Match format") + + where_extra = "" + if expected_version is not None: + where_extra = " AND version = %s" + + sql = f""" + UPDATE servers + SET {", ".join(fields)} + WHERE id = %s{where_extra} + RETURNING id, hostname, ip_address, state, datacenter, updated_at, version; + """ + + # bind values in correct order + bind: list[object] = [] + bind.extend(values) + bind.append(id) + if expected_version is not None: + bind.append(expected_version) + + try: + with get_conn() as conn: + with conn.transaction(): + with conn.cursor() as cur: + cur.execute(sql, tuple(bind)) + row = cur.fetchone() + except Exception as e: + raise_db_error("update_server", e) + + if not row: + # Distinguish not found vs precondition failed + try: + with get_conn() as conn: + with conn.cursor() as cur: + cur.execute("SELECT 1 FROM servers WHERE id = %s;", (id,)) + exists = cur.fetchone() is not None + except Exception as e: + raise_db_error("update_server_exists_check", e) + + if not exists: + raise HTTPException(status_code=404, detail="server not found") + + # (3) concurrent scenario + if expected_version is not None: + raise HTTPException( + status_code=status.HTTP_412_PRECONDITION_FAILED, + detail="resource was updated; re-fetch and retry with a fresh ETag", + ) + + # No If-Match: we allowed overwrite, but row missing means it didn't exist + raise HTTPException(status_code=404, detail="server not found") + + out = row_to_out(row) + response.headers["ETag"] = etag_from_version(out.version) + return out + + +@router.delete("/servers/{id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_server(id: int): + sql = """ + DELETE FROM servers + WHERE id = %s + RETURNING id; + """ + try: + with get_conn() as conn: + with conn.transaction(): + with conn.cursor() as cur: + cur.execute(sql, (id,)) + row = cur.fetchone() + except Exception as e: + raise_db_error("delete_server", e) + + if not row: + raise HTTPException(status_code=404, detail="server not found") + + return None diff --git a/api/requirements.txt b/api/requirements.txt new file mode 100644 index 0000000..e975ae8 --- /dev/null +++ b/api/requirements.txt @@ -0,0 +1,7 @@ +fastapi==0.125.0 +uvicorn[standard]==0.38.0 +psycopg[binary]==3.3.2 +psycopg-pool==3.3.0 +pydantic==2.12.5 +pydantic-settings==2.10.1 +python-dotenv==1.2.1 diff --git a/cli/.dockerignore b/cli/.dockerignore new file mode 100644 index 0000000..714d793 --- /dev/null +++ b/cli/.dockerignore @@ -0,0 +1,10 @@ +__pycache__/ +*.pyc +.pytest_cache/ +.mypy_cache/ +.venv/ +.env +build/ +dist/ +*.egg-info/ +.git/ diff --git a/cli/Dockerfile b/cli/Dockerfile new file mode 100644 index 0000000..60a3008 --- /dev/null +++ b/cli/Dockerfile @@ -0,0 +1,29 @@ +# syntax=docker/dockerfile:1.6 + +FROM python:3.12-slim AS builder +WORKDIR /src +RUN pip install --no-cache-dir -U pip build +COPY pyproject.toml README.md /src/ +COPY cli/inventory_cli /src/cli/inventory_cli +RUN python -m build --wheel --outdir /dist + + +FROM python:3.12-slim AS runtime +WORKDIR /app + +# pipx needs PATH +ENV PIPX_HOME=/opt/pipx +ENV PIPX_BIN_DIR=/usr/local/bin +ENV PATH="${PIPX_BIN_DIR}:${PATH}" +ENV PYTHONUNBUFFERED=1 + +RUN pip install --no-cache-dir -U pip pipx \ + && pipx ensurepath + +# Install the wheel via pipx +COPY --from=builder /dist/*.whl /tmp/ +RUN pipx install /tmp/*.whl \ + && rm -rf /tmp/*.whl + +ENTRYPOINT ["inventory-cli"] +CMD ["--help"] diff --git a/cli/README.md b/cli/README.md new file mode 100644 index 0000000..897f5ec --- /dev/null +++ b/cli/README.md @@ -0,0 +1,219 @@ +# inventory-cli + +A **CLI client** for the **Server Inventory API**. + +Built with **Typer**, **Click**, **Rich**, and **Requests**, this CLI provides a clean UX, retries with exponential backoff, structured errors, and both human-friendly and machine-friendly output formats. + +--- + +## Features + +- CRUD operations for servers (`list`, `get`, `create`, `update`, `delete`) +- Automatic retries with exponential backoff (network errors, 5xx, 429) +- Configurable timeouts and retry policy +- Pretty table output (Rich) or JSON output for scripting +- Works locally, via `pipx`, or as a Docker container +- Stable behavior across Click / Typer versions + +--- + +## Requirements + +- Python **>= 3.11** +- An accessible **Server Inventory API** + +Python dependencies: + +- `typer==0.20.0` +- `click>=8.2,<9` +- `rich>=13.7` +- `requests>=2.32,<3` + +--- + +## Installation + +### Option 1: Install with pipx (recommended) + +```bash +pip install --user pipx +pipx ensurepath + +pipx install inventory-cli +``` + +Verify: + +```bash +inventory-cli --help +``` + +--- + +### Option 2: Install from source (virtualenv) + +```bash +python -m venv .venv +source .venv/bin/activate + +pip install -r cli/requirements.txt +pip install -e . +``` + +--- + +### Option 3: Run via Docker + +```bash +docker build -t inventory-cli ./cli +docker run --rm inventory-cli --help +``` + +To connect to a local API: + +```bash +docker run --rm -e INVENTORY_API_URL=http://host.docker.internal:8000 inventory-cli list +``` + +--- + +## Configuration + +### Environment variables + +| Variable | Description | Default | +|--------|-------------|---------| +| `INVENTORY_API_URL` | Base URL of the API | `http://localhost:8000` | + +--- + +### Global CLI options + +```bash +inventory-cli [OPTIONS] COMMAND [ARGS] +``` + +Common options: + +- `--api-url TEXT` – API base URL +- `--connect-timeout FLOAT` – Connection timeout (seconds) +- `--read-timeout FLOAT` – Read timeout (seconds) +- `--retries INTEGER` – Retry attempts for transient failures +- `--output [table|json]` – Output format +- `--verbose` / `-v` – Debug output +- `--no-color` – Disable colored output + +--- + +## Commands + +### List servers + +```bash +inventory-cli list +``` + +```bash +inventory-cli list --output json +``` + +--- + +### Get server by ID + +```bash +inventory-cli get 1 +``` + +--- + +### Create a server + +```bash +inventory-cli create --hostname srv-1 --ip 10.0.0.1 --state active --datacenter dc1 +``` + +--- + +### Update a server + +```bash +inventory-cli update 1 --state retired +``` + +```bash +inventory-cli update 1 --hostname new-name --ip 10.0.0.9 +``` + +--- + +### Delete a server + +```bash +inventory-cli delete 1 +``` + +--- + +## Output formats + +### Table (default) + +Human-friendly Rich tables, suitable for terminals. + +### JSON + +Machine-friendly output for scripting: + +```bash +inventory-cli list --output json | jq . +``` + +Errors are also returned as structured JSON when `--output json` is used. + +--- + +## Error handling + +- HTTP errors are translated into clear CLI messages +- Validation errors are unpacked and displayed per-field +- Network failures trigger retries with exponential backoff +- JSON output remains clean (errors go to STDERR) + +--- + +## Exit codes + +- `0` – Success +- `1` – Any error (HTTP, validation, network) + +This makes the CLI safe for use in scripts and CI pipelines. + +--- + +## Development + +Build a wheel: + +```bash +python -m build +``` + +Run locally: + +```bash +inventory-cli --help +``` + +--- + +## License + +MIT + +--- + +## Related + +- **Server Inventory API** – FastAPI + PostgreSQL backend +- Designed for Docker, Kubernetes, and CI-friendly workflows diff --git a/cli/inventory_cli/__init__.py b/cli/inventory_cli/__init__.py new file mode 100644 index 0000000..a32e63c --- /dev/null +++ b/cli/inventory_cli/__init__.py @@ -0,0 +1 @@ +__all__ = ["app"] diff --git a/cli/inventory_cli/__main__.py b/cli/inventory_cli/__main__.py new file mode 100644 index 0000000..53f9426 --- /dev/null +++ b/cli/inventory_cli/__main__.py @@ -0,0 +1,8 @@ +from .app import app + +def main() -> None: + # Makes help/usage stable across click/typer versions + app(prog_name="inventory-cli") + +if __name__ == "__main__": + main() diff --git a/cli/inventory_cli/app.py b/cli/inventory_cli/app.py new file mode 100644 index 0000000..7c79e0d --- /dev/null +++ b/cli/inventory_cli/app.py @@ -0,0 +1,193 @@ +from __future__ import annotations + +import typer +from rich.console import Console + +from .config import ( + Settings, + resolve_api_url, + DEFAULT_CONNECT_TIMEOUT, + DEFAULT_READ_TIMEOUT, + DEFAULT_RETRIES, + DEFAULT_BACKOFF_BASE, + DEFAULT_BACKOFF_MAX, + DEFAULT_INVENTORY_API_URL, +) +from .output import out_format, echo_json, echo_info, console +from .errors import ensure_ok, emit_error +from .http_client import request +from .formatting import print_server_table, print_servers_table + +app = typer.Typer(add_completion=False, help="CLI for Server Inventory API") + +@app.callback(invoke_without_command=True) +def global_options( + ctx: typer.Context, + api_url: str = typer.Option( + None, + "--api-url", + help=f"Inventory API base URL [default: {DEFAULT_INVENTORY_API_URL}]", + show_default=False, + ), + connect_timeout: float = typer.Option( + DEFAULT_CONNECT_TIMEOUT, + "--connect-timeout", + help="API connect timeout in seconds", + show_default=True, + min=0.01, + ), + read_timeout: float = typer.Option( + DEFAULT_READ_TIMEOUT, + "--read-timeout", + help="API read timeout in seconds", + show_default=True, + min=0.01, + ), + retries: int = typer.Option( + DEFAULT_RETRIES, + "--retries", + help="Number of retries for transient failures (network/5xx/429)", + show_default=True, + min=0, + max=10, + ), + output: str = typer.Option( + "table", + "--output", + help="Output format: table | json", + show_default=True, + case_sensitive=False, + ), + verbose: bool = typer.Option( + False, + "--verbose", + "-v", + help="Enable verbose (debug) output", + show_default=True, + ), + no_color: bool = typer.Option( + False, + "--no-color", + help="Disable colored output", + show_default=True, + ), +): + ctx.ensure_object(dict) + + settings = Settings( + api_url=resolve_api_url(api_url), + timeouts=(connect_timeout, read_timeout), + retries=retries, + backoff_base=DEFAULT_BACKOFF_BASE, + backoff_max=DEFAULT_BACKOFF_MAX, + output=output.lower(), + verbose=verbose, + console=Console(color_system=None if no_color else "auto"), + err_console=Console(stderr=True, color_system=None if no_color else "auto"), + ) + + ctx.obj = settings + + echo_info( + ctx, + "Config: " + f"api_url={settings.api_url}, " + f"timeouts(connect/read)={settings.timeouts[0]}/{settings.timeouts[1]}s, " + f"retries={settings.retries}, " + f"output={settings.output}", + ) + +@app.command("list", help="List all servers") +def list_servers(ctx: typer.Context): + r = request(ctx, "GET", "/servers") + ensure_ok(ctx, r, "List servers") + data = r.json() + + if out_format(ctx) == "json": + echo_json(data) + else: + print_servers_table(ctx, data) + +@app.command("get", help="Get server by ID") +def get_server(ctx: typer.Context, id: int): + r = request(ctx, "GET", f"/servers/{id}") + if r.status_code == 404: + emit_error(ctx, action=f"Get server id={id}", http_status=404, message=f"Server with id={id} not found") + raise typer.Exit(1) + + ensure_ok(ctx, r, f"Get server id={id}") + data = r.json() + + if out_format(ctx) == "json": + echo_json(data) + else: + print_server_table(ctx, data, title=f"Server #{data.get('id', id)}") + +@app.command("create", help="Create a new server") +def create_server( + ctx: typer.Context, + hostname: str = typer.Option(..., help="Unique server hostname"), + ip: str = typer.Option(..., "--ip", help="IP address"), + state: str = typer.Option(..., help="Server state: active | offline | retired"), + datacenter: str = typer.Option(None, help="Datacenter name"), +): + payload = {"hostname": hostname, "ip_address": ip, "state": state, "datacenter": datacenter} + r = request(ctx, "POST", "/servers", json=payload) + + if r.status_code == 409: + emit_error(ctx, action="Create server", http_status=409, message="hostname must be unique") + raise typer.Exit(1) + + ensure_ok(ctx, r, "Create server") + data = r.json() + + if out_format(ctx) == "json": + echo_json(data) + else: + print_server_table(ctx, data, title="Created Server") + +@app.command("update", help="Update an existing server") +def update_server( + ctx: typer.Context, + id: int, + hostname: str = typer.Option(None, help="New hostname"), + ip: str = typer.Option(None, "--ip", help="New IP-address"), + state: str = typer.Option(None, help="New state"), + datacenter: str = typer.Option(None, help="New datacenter"), +): + payload: dict = {} + if hostname is not None: + payload["hostname"] = hostname + if ip is not None: + payload["ip_address"] = ip + if state is not None: + payload["state"] = state + if datacenter is not None: + payload["datacenter"] = datacenter + + r = request(ctx, "PUT", f"/servers/{id}", json=payload) + if r.status_code == 404: + emit_error(ctx, action=f"Update server id={id}", http_status=404, message=f"Server with id={id} not found (cannot update)") + raise typer.Exit(1) + + ensure_ok(ctx, r, f"Update server id={id}") + data = r.json() + + if out_format(ctx) == "json": + echo_json(data) + else: + print_server_table(ctx, data, title="Updated Server") + +@app.command("delete", help="Delete a server by ID") +def delete_server(ctx: typer.Context, id: int): + r = request(ctx, "DELETE", f"/servers/{id}") + if r.status_code == 404: + emit_error(ctx, action=f"Delete server id={id}", http_status=404, message=f"Server with id={id} not found (cannot delete)") + raise typer.Exit(1) + + ensure_ok(ctx, r, f"Delete server id={id}") + + if out_format(ctx) == "json": + echo_json({"ok": True, "status": "deleted", "id": id}) + else: + console(ctx).print(f"[green]Server with id={id} has been deleted successfully[/green]") diff --git a/cli/inventory_cli/config.py b/cli/inventory_cli/config.py new file mode 100644 index 0000000..3553bb2 --- /dev/null +++ b/cli/inventory_cli/config.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +import os +from dataclasses import dataclass +from rich.console import Console + +DEFAULT_CONNECT_TIMEOUT = 2.0 +DEFAULT_READ_TIMEOUT = 10.0 +DEFAULT_RETRIES = 3 +DEFAULT_BACKOFF_BASE = 0.5 +DEFAULT_BACKOFF_MAX = 8.0 +DEFAULT_INVENTORY_API_URL = "http://localhost:8000" + +@dataclass(frozen=True) +class Settings: + api_url: str + timeouts: tuple[float, float] + retries: int + backoff_base: float + backoff_max: float + output: str # "table" | "json" + verbose: bool + console: Console + err_console: Console + +def resolve_api_url(cli_value: str | None) -> str: + return (cli_value or os.getenv("INVENTORY_API_URL") or DEFAULT_INVENTORY_API_URL).rstrip("/") diff --git a/cli/inventory_cli/errors.py b/cli/inventory_cli/errors.py new file mode 100644 index 0000000..7ccb40b --- /dev/null +++ b/cli/inventory_cli/errors.py @@ -0,0 +1,96 @@ +from __future__ import annotations + +import typer +import requests +from .output import echo_error, echo_json, out_format + +def emit_error( + ctx: typer.Context, + *, + action: str, + http_status: int | None, + message: str, + details: object | None = None, +) -> None: + human = f"{action} failed" + if http_status is not None: + human += f" (HTTP {http_status})" + human += f": {message}" + + echo_error(ctx, human) + + if out_format(ctx) == "json": + payload: dict = { + "ok": False, + "action": action, + "error": {"message": message}, + } + if http_status is not None: + payload["error"]["http_status"] = http_status + if details is not None: + payload["error"]["details"] = details + echo_json(payload) + +def _format_validation_detail(detail_list: list) -> tuple[str, list[dict]]: + parts: list[str] = [] + structured: list[dict] = [] + + for item in detail_list: + if not isinstance(item, dict): + continue + + loc = item.get("loc") + msg = item.get("msg") + typ = item.get("type") + + where = "" + if isinstance(loc, (list, tuple)): + where = ".".join(str(x) for x in loc if x != "body") + + if where and msg: + parts.append(f"{where}: {msg}") + elif msg: + parts.append(str(msg)) + + entry: dict = {} + if where: + entry["field"] = where + if msg: + entry["message"] = msg + if typ: + entry["type"] = typ + if entry: + structured.append(entry) + + human = "; ".join(parts) if parts else "Validation error" + return human, structured + +def extract_error_from_response(r: requests.Response) -> tuple[str, object | None]: + try: + payload = r.json() + except Exception: + payload = None + + if isinstance(payload, dict) and isinstance(payload.get("detail"), list): + human, structured = _format_validation_detail(payload["detail"]) + return human, {"validation": structured} + + if isinstance(payload, dict) and isinstance(payload.get("detail"), str): + return payload["detail"], None + + if payload is not None: + return "Request failed", payload + + text = (r.text or "").strip() + if text: + return text, None + + return r.reason or "Request failed", None + +def ensure_ok(ctx: typer.Context, r: requests.Response, action: str) -> None: + if 200 <= r.status_code <= 299: + return + + msg, details = extract_error_from_response(r) + emit_error(ctx, action=action, http_status=r.status_code, message=msg, details=details) + raise typer.Exit(1) diff --git a/cli/inventory_cli/formatting.py b/cli/inventory_cli/formatting.py new file mode 100644 index 0000000..b2bc994 --- /dev/null +++ b/cli/inventory_cli/formatting.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +import json +import typer +from rich.table import Table +from .output import console +from .errors import emit_error + +def print_server_table(ctx: typer.Context, server: dict, title: str = "Server") -> None: + if not isinstance(server, dict): + emit_error( + ctx, + action=title, + http_status=None, + message="Unexpected response format (expected JSON object)", + details={"received_type": type(server).__name__}, + ) + raise typer.Exit(1) + + preferred = ["id", "hostname", "ip_address", "state", "datacenter"] + cols = [k for k in preferred if k in server] + cols += sorted(k for k in server.keys() if k not in cols) + + table = Table(title=title) + for col in cols: + table.add_column(str(col)) + + def fmt(v) -> str: + if v is None: + return "" + if isinstance(v, (dict, list)): + return json.dumps(v, ensure_ascii=False) + return str(v) + + table.add_row(*(fmt(server.get(col)) for col in cols)) + console(ctx).print(table) + +def print_servers_table(ctx: typer.Context, servers: list[dict]) -> None: + table = Table(title="Servers") + for col in ["id", "hostname", "ip_address", "state", "datacenter"]: + table.add_column(col) + + for s in servers: + table.add_row( + str(s.get("id", "")), + str(s.get("hostname", "") or ""), + str(s.get("ip_address", "") or ""), + str(s.get("state", "") or ""), + str(s.get("datacenter") or ""), + ) + + console(ctx).print(table) diff --git a/cli/inventory_cli/http_client.py b/cli/inventory_cli/http_client.py new file mode 100644 index 0000000..8f7d48c --- /dev/null +++ b/cli/inventory_cli/http_client.py @@ -0,0 +1,88 @@ +from __future__ import annotations + +import time +import random +import typer +import requests +from .output import echo_info +from .errors import emit_error + +def _sleep_with_backoff(attempt: int, base: float, cap: float) -> None: + # Exponential backoff with jitter + expo = base * (2 ** max(0, attempt - 1)) + delay = min(cap, expo) + jitter = random.uniform(0.7, 1.3) + time.sleep(delay * jitter) + +def request(ctx: typer.Context, method: str, path: str, **kwargs) -> requests.Response: + cfg = ctx.obj + url = f"{cfg.api_url}{path}" + timeouts = cfg.timeouts + retries = cfg.retries + backoff_base = cfg.backoff_base + backoff_max = cfg.backoff_max + + start = time.perf_counter() + echo_info(ctx, f"Request: {method} {url} timeouts(connect/read)={timeouts[0]}/{timeouts[1]}s retries={retries}") + + last_exc: Exception | None = None + last_status: int | None = None + + for attempt in range(0, retries + 1): + try: + resp = requests.request(method, url, timeout=timeouts, **kwargs) + + if resp.status_code == 429 or 500 <= resp.status_code <= 599: + last_status = resp.status_code + if attempt < retries: + echo_info(ctx, f"Retrying {method} {path}: status={resp.status_code} (attempt {attempt+1}/{retries})") + _sleep_with_backoff(attempt + 1, backoff_base, backoff_max) + continue + + elapsed_ms = int((time.perf_counter() - start) * 1000) + echo_info(ctx, f"Response: {method} {path} -> {resp.status_code} ({elapsed_ms}ms)") + return resp + + except (requests.Timeout, requests.ConnectionError) as e: + last_exc = e + if attempt < retries: + echo_info(ctx, f"Retrying {method} {path}: {type(e).__name__} (attempt {attempt+1}/{retries})") + _sleep_with_backoff(attempt + 1, backoff_base, backoff_max) + continue + break + + # Out of retries + if isinstance(last_exc, requests.Timeout): + emit_error( + ctx, + action=f"{method} {path}", + http_status=None, + message=f"Request timed out (connect/read={timeouts[0]}/{timeouts[1]}s) after {retries} retries", + details={"connect_timeout": timeouts[0], "read_timeout": timeouts[1], "retries": retries}, + ) + elif isinstance(last_exc, requests.ConnectionError): + emit_error( + ctx, + action=f"{method} {path}", + http_status=None, + message=f"Cannot connect to API ({url}) after {retries} retries", + details={"url": url, "retries": retries}, + ) + elif last_status is not None: + emit_error( + ctx, + action=f"{method} {path}", + http_status=last_status, + message=f"Request failed with status {last_status} after {retries} retries", + details={"status": last_status, "retries": retries}, + ) + else: + emit_error( + ctx, + action=f"{method} {path}", + http_status=None, + message=f"Request failed after {retries} retries", + details={"retries": retries}, + ) + + raise typer.Exit(1) diff --git a/cli/inventory_cli/output.py b/cli/inventory_cli/output.py new file mode 100644 index 0000000..d55a92f --- /dev/null +++ b/cli/inventory_cli/output.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +import json +import sys +import typer +from rich.console import Console + +def out_format(ctx: typer.Context) -> str: + return (ctx.obj.output if ctx.obj else "table") # type: ignore[attr-defined] + +def console(ctx: typer.Context) -> Console: + return ctx.obj.console # type: ignore[attr-defined] + +def err_console(ctx: typer.Context) -> Console: + return ctx.obj.err_console # type: ignore[attr-defined] + +def verbose(ctx: typer.Context) -> bool: + return bool(ctx.obj.verbose) # type: ignore[attr-defined] + +def echo_info(ctx: typer.Context, msg: str) -> None: + # Debug output goes to STDERR to keep JSON stdout clean + if verbose(ctx): + err_console(ctx).print(f"[dim]{msg}[/dim]") + +def echo_error(ctx: typer.Context, msg: str) -> None: + # Human-friendly errors always go to STDERR + err_console(ctx).print(f"[red]{msg}[/red]") + +def echo_json(data: object) -> None: + # Machine-friendly output always goes to STDOUT + sys.stdout.write(json.dumps(data, ensure_ascii=False) + "\n") + sys.stdout.flush() diff --git a/cli/requirements.txt b/cli/requirements.txt new file mode 100644 index 0000000..14fdb78 --- /dev/null +++ b/cli/requirements.txt @@ -0,0 +1,4 @@ +typer==0.20.0 +click>=8.2,<9 +rich>=13.7 +requests>=2.32.0,<3 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..e4928a1 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,36 @@ +services: + db: + image: postgres:16 + environment: + POSTGRES_USER: inventory + POSTGRES_PASSWORD: inventory + POSTGRES_DB: inventory + ports: + - "5432:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U inventory -d inventory"] + interval: 3s + timeout: 3s + retries: 20 + + api: + build: ./api + environment: + DATABASE_URL: postgresql://inventory:inventory@db:5432/inventory + ports: + - "8000:8000" + depends_on: + db: + condition: service_healthy + + # Run tests against the live API + DB + tests: + build: ./tests + environment: + API_URL: http://api:8000 + DATABASE_URL: postgresql://inventory:inventory@db:5432/inventory + depends_on: + db: + condition: service_healthy + api: + condition: service_started diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..5d22617 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,25 @@ +[build-system] +requires = ["setuptools>=69", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "inventory-cli" +version = "0.1.0" +description = "CLI for Server Inventory API" +readme = "README.md" +requires-python = ">=3.11" +dependencies = [ + "typer==0.20.0", + "click>=8.2,<9", + "rich>=13.7", + "requests>=2.32.0,<3", +] + +[project.scripts] +inventory-cli = "inventory_cli.__main__:main" + +[tool.setuptools] +package-dir = {"" = "cli"} + +[tool.setuptools.packages.find] +where = ["cli"] diff --git a/tests/.dockerignore b/tests/.dockerignore new file mode 100644 index 0000000..714d793 --- /dev/null +++ b/tests/.dockerignore @@ -0,0 +1,10 @@ +__pycache__/ +*.pyc +.pytest_cache/ +.mypy_cache/ +.venv/ +.env +build/ +dist/ +*.egg-info/ +.git/ diff --git a/tests/Dockerfile b/tests/Dockerfile new file mode 100644 index 0000000..065dc48 --- /dev/null +++ b/tests/Dockerfile @@ -0,0 +1,6 @@ +FROM python:3.12-slim +WORKDIR /tests +COPY requirements.txt /tests/requirements.txt +RUN pip install --no-cache-dir -r /tests/requirements.txt +COPY test_api.py /tests/test_api.py +CMD ["pytest", "-q"] diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..86aa40a --- /dev/null +++ b/tests/README.md @@ -0,0 +1,190 @@ +# Server Inventory API — Test Suite + +This directory contains the **integration test suite** for the **Server Inventory API**. + +The tests are written with **pytest** and validate the API end‑to‑end against a running +FastAPI + PostgreSQL stack, including: + +- Health checks (liveness & readiness) +- CRUD operations for servers +- ETag / optimistic concurrency control +- Pagination and filtering +- Validation and error handling + +These tests are designed to be run **inside Docker** or as part of **CI pipelines**. + +--- + +## Tech Stack + +- **pytest** — test runner +- **httpx** — HTTP client for API calls +- **psycopg** — direct DB access for test cleanup +- **Docker** — isolated and reproducible execution + +### Dependencies + +```text +pytest==9.0.2 +httpx==0.28.1 +psycopg[binary]==3.3.2 +``` + +--- + +## Environment Variables + +The test runner relies on the following environment variables: + +| Variable | Description | Default | +|--------|-------------|---------| +| `API_URL` | Base URL of the API service | `http://api:8000` | +| `DATABASE_URL` | PostgreSQL DSN used to clean DB between tests | *(required for DB cleanup)* | + +Example: + +```bash +export API_URL=http://localhost:8000 +export DATABASE_URL=postgresql://user:pass@localhost:5432/inventory +``` + +--- + +## Health‑Aware Startup Logic + +Before executing any tests, the suite waits for the API to become fully operational: + +1. **Liveness check** + `GET /health/liveness` + Confirms the process is running. + +2. **Readiness check** + `GET /health/readiness` + Confirms: + - DB connection pool is initialized + - `SELECT 1` succeeds against PostgreSQL + +If readiness is not reached within the timeout, the test run fails early. + +This makes the suite safe to use in **Docker Compose** and **Kubernetes CI jobs**. + +--- + +## Database Cleanup Strategy + +Before each test: + +- The `servers` table is truncated +- IDs are reset using `RESTART IDENTITY` + +This guarantees **deterministic test behavior** and avoids cross‑test coupling. + +```sql +TRUNCATE TABLE servers RESTART IDENTITY; +``` + +--- + +## What Is Covered + +### Health Checks + +- `/health/liveness` → `200` +- `/health/readiness` → `200` + +### CRUD + +- Create server +- Get server by ID +- List servers +- Update server +- Delete server + +### Concurrency & Caching + +- `ETag` header on create and update +- `If-None-Match` → `304 Not Modified` +- `If-Match` optimistic locking → `412 Precondition Failed` + +### Validation & Errors + +- Invalid IP address → `422` +- Duplicate hostname → `409` +- Missing resources → `404` + +### Pagination & Filters + +- `limit` / `offset` +- Filter by `state` +- Filter by `datacenter` + +--- + +## Running Tests with Docker + +### Dockerfile + +The provided Dockerfile builds a minimal test runner image: + +```dockerfile +FROM python:3.12-slim + +WORKDIR /tests + +COPY requirements.txt /tests/requirements.txt +RUN pip install --no-cache-dir -r /tests/requirements.txt + +COPY test_api.py /tests/test_api.py + +CMD ["pytest", "-q"] +``` + +### Example (Docker Compose) + +```bash +docker compose up --build --exit-code-from tests tests +``` + +The container will: + +- Wait for API readiness +- Execute all tests +- Exit with a non‑zero code on failure (CI‑friendly) + +--- + +## Local Execution (Optional) + +If you want to run tests locally (API + DB must already be running): + +```bash +python -m venv .venv +source .venv/bin/activate + +pip install -r requirements.txt +pytest -q +``` + +--- + +## Design Goals + +- ✅ Deterministic +- ✅ CI‑friendly +- ✅ Kubernetes‑ready +- ✅ No mocks — real DB, real HTTP +- ✅ Fast feedback on startup failures + +--- + +## Notes + +- Tests assume the API exposes **health endpoints** +- Schema migrations are handled by the API on startup +- This suite is intentionally **black‑box**: only public API + DB state are used + +--- + +## License + +MIT diff --git a/tests/requirements.txt b/tests/requirements.txt new file mode 100644 index 0000000..f43a744 --- /dev/null +++ b/tests/requirements.txt @@ -0,0 +1,3 @@ +pytest==9.0.2 +httpx==0.28.1 +psycopg[binary]==3.3.2 diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..5c89e68 --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,213 @@ +import os +import time +import psycopg +import pytest +from httpx import Client + +API_URL = os.getenv("API_URL", "http://api:8000").rstrip("/") +DATABASE_URL = os.getenv("DATABASE_URL") + + +def wait_for_api(timeout_s: int = 45) -> None: + """ + Wait until the process is alive, then until it is ready (DB reachable). + """ + deadline = time.time() + timeout_s + + # 1) Wait liveness + while time.time() < deadline: + try: + with Client(timeout=2.0) as c: + r = c.get(f"{API_URL}/health/liveness") + if r.status_code == 200: + break + except Exception: + pass + time.sleep(0.5) + else: + raise RuntimeError("API did not become alive (/health/liveness)") + + # 2) Wait readiness (DB + pool) + while time.time() < deadline: + try: + with Client(timeout=2.0) as c: + r = c.get(f"{API_URL}/health/readiness") + if r.status_code == 200: + return + except Exception: + pass + time.sleep(0.5) + + raise RuntimeError("API did not become ready (/health/readiness)") + + +@pytest.fixture(autouse=True, scope="session") +def _ready(): + wait_for_api() + + +@pytest.fixture(autouse=True) +def clean_db(): + # Truncate between tests to keep deterministic + if not DATABASE_URL: + yield + return + + with psycopg.connect(DATABASE_URL) as conn: + with conn.cursor() as cur: + cur.execute("TRUNCATE TABLE servers RESTART IDENTITY;") + conn.commit() + yield + + +def test_health_endpoints(): + with Client(timeout=2.0) as c: + r1 = c.get(f"{API_URL}/health/liveness") + assert r1.status_code == 200 + assert r1.json().get("status") in ("alive", "ok", "UP") + + r2 = c.get(f"{API_URL}/health/readiness") + assert r2.status_code == 200 + assert r2.json().get("status") in ("ready", "ok", "UP") + + +def test_create_returns_etag_and_location(): + with Client() as c: + payload = {"hostname": "srv-1", "ip_address": "10.0.0.1", "state": "active", "datacenter": "dc1"} + r = c.post(f"{API_URL}/servers", json=payload) + assert r.status_code == 201 + + assert "ETag" in r.headers + assert r.headers["ETag"].startswith('W/"v') and r.headers["ETag"].endswith('"') + + assert "Location" in r.headers + assert r.headers["Location"].endswith("/servers/1") + + +def test_create_and_get_sets_etag(): + with Client() as c: + payload = {"hostname": "srv-1", "ip_address": "10.0.0.1", "state": "active", "datacenter": "dc1"} + r = c.post(f"{API_URL}/servers", json=payload) + assert r.status_code == 201 + s = r.json() + assert s["id"] == 1 + assert s["hostname"] == "srv-1" + + r2 = c.get(f"{API_URL}/servers/1") + assert r2.status_code == 200 + assert r2.json()["ip_address"] == "10.0.0.1" + assert "ETag" in r2.headers + + +def test_if_none_match_returns_304(): + with Client() as c: + r = c.post( + f"{API_URL}/servers", + json={"hostname": "srv", "ip_address": "10.0.0.9", "state": "offline"}, + ) + assert r.status_code == 201 + + r2 = c.get(f"{API_URL}/servers/1") + assert r2.status_code == 200 + etag = r2.headers.get("ETag") + assert etag + + r3 = c.get(f"{API_URL}/servers/1", headers={"If-None-Match": etag}) + assert r3.status_code == 304 + assert r3.headers.get("ETag") == etag + + +def test_list_pagination_and_filters(): + with Client() as c: + # 4 servers, mixed states and datacenters + payloads = [ + {"hostname": "a1", "ip_address": "10.0.0.1", "state": "active", "datacenter": "dc1"}, + {"hostname": "a2", "ip_address": "10.0.0.2", "state": "offline", "datacenter": "dc1"}, + {"hostname": "b1", "ip_address": "10.0.0.3", "state": "active", "datacenter": "dc2"}, + {"hostname": "b2", "ip_address": "10.0.0.4", "state": "retired", "datacenter": "dc2"}, + ] + for p in payloads: + rr = c.post(f"{API_URL}/servers", json=p) + assert rr.status_code == 201 + + # pagination + r = c.get(f"{API_URL}/servers", params={"limit": 2, "offset": 0}) + assert r.status_code == 200 + assert len(r.json()) == 2 + + r2 = c.get(f"{API_URL}/servers", params={"limit": 2, "offset": 2}) + assert r2.status_code == 200 + assert len(r2.json()) == 2 + + # filter by state + r3 = c.get(f"{API_URL}/servers", params={"state": "active"}) + assert r3.status_code == 200 + assert len(r3.json()) == 2 + assert all(x["state"] == "active" for x in r3.json()) + + # filter by datacenter + r4 = c.get(f"{API_URL}/servers", params={"datacenter": "dc1"}) + assert r4.status_code == 200 + assert len(r4.json()) == 2 + assert all(x.get("datacenter") == "dc1" for x in r4.json()) + + +def test_unique_hostname(): + with Client() as c: + r1 = c.post(f"{API_URL}/servers", json={"hostname": "dup", "ip_address": "10.0.0.2", "state": "active"}) + assert r1.status_code == 201 + r2 = c.post(f"{API_URL}/servers", json={"hostname": "dup", "ip_address": "10.0.0.3", "state": "offline"}) + assert r2.status_code == 409 + + +def test_ip_validation(): + with Client() as c: + r = c.post(f"{API_URL}/servers", json={"hostname": "badip", "ip_address": "not-an-ip", "state": "active"}) + assert r.status_code == 422 # FastAPI validation error + + +def test_update_with_if_match_success_and_conflict(): + with Client() as c: + r = c.post(f"{API_URL}/servers", json={"hostname": "srv", "ip_address": "10.0.0.9", "state": "offline"}) + assert r.status_code == 201 + + # get current ETag + r2 = c.get(f"{API_URL}/servers/1") + assert r2.status_code == 200 + etag = r2.headers.get("ETag") + assert etag + + # update with correct If-Match -> OK + r3 = c.put(f"{API_URL}/servers/1", json={"state": "retired"}, headers={"If-Match": etag}) + assert r3.status_code == 200 + assert r3.json()["state"] == "retired" + assert "ETag" in r3.headers + new_etag = r3.headers["ETag"] + assert new_etag != etag + + # update again with OLD etag -> 412 + r4 = c.put(f"{API_URL}/servers/1", json={"state": "active"}, headers={"If-Match": etag}) + assert r4.status_code == 412 + + +def test_update_without_if_match_still_works(): + with Client() as c: + r = c.post(f"{API_URL}/servers", json={"hostname": "srv", "ip_address": "10.0.0.9", "state": "offline"}) + assert r.status_code == 201 + + r2 = c.put(f"{API_URL}/servers/1", json={"state": "retired"}) + assert r2.status_code == 200 + assert r2.json()["state"] == "retired" + + +def test_delete(): + with Client() as c: + r = c.post(f"{API_URL}/servers", json={"hostname": "srv", "ip_address": "10.0.0.9", "state": "offline"}) + assert r.status_code == 201 + + r3 = c.delete(f"{API_URL}/servers/1") + assert r3.status_code == 204 + + r4 = c.get(f"{API_URL}/servers/1") + assert r4.status_code == 404 +