diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..9c80106 --- /dev/null +++ b/.env.example @@ -0,0 +1,13 @@ +# Database +HAPPY_SERVERS_DB_HOST=localhost +HAPPY_SERVERS_DB_PORT=5432 +HAPPY_SERVERS_DB_USER=postgres +HAPPY_SERVERS_DB_PASSWORD=postgres +HAPPY_SERVERS_DB_NAME=happy_servers + +# API Server +HAPPY_SERVERS_SERVER_HOST=0.0.0.0 +HAPPY_SERVERS_SERVER_PORT=9000 + +# Debug (enables auto-reload) +HAPPY_SERVERS_DEBUG=false diff --git a/.gitguardian.yaml b/.gitguardian.yaml new file mode 100644 index 0000000..85e7363 --- /dev/null +++ b/.gitguardian.yaml @@ -0,0 +1,2 @@ + paths-ignore: + - compose.yaml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5a1c38e --- /dev/null +++ b/.gitignore @@ -0,0 +1,36 @@ +# Virtual environments +.venv/ +venv/ +ENV/ + +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +dist/ +*.egg-info/ +*.egg +.eggs/ + +# Testing +.pytest_cache/ +.coverage +htmlcov/ +.tox/ + +# IDEs +.idea/ +.vscode/ +*.swp +*.swo + +# Environment +.env +.env.local + +# OS +.DS_Store +Thumbs.db \ No newline at end of file diff --git a/API.md b/API.md new file mode 100644 index 0000000..34be102 --- /dev/null +++ b/API.md @@ -0,0 +1,347 @@ +# Happy Servers - API & CLI Documentation + +Inventory management system for tracking cloud servers across data centers. + +## Quick Start + +### Using Docker Compose + +```bash +# Start the full stack (PostgreSQL + Migrations + API) +docker compose up -d + +# API is available at http://localhost:9000 +curl http://localhost:9000/health +``` + +### Local Development + +```bash +# Create virtual environment +python -m venv .venv +source .venv/bin/activate + +# Install package in dev mode +pip install -e ".[dev]" + +# Copy and configure environment +cp .env.example .env + +# Run postgres with migrations +docker compose up -d db migrations + +# Run API with debug mode +export HAPPY_SERVERS_DEBUG=true +hsapi +``` + +## Running Tests + +### Unit Tests (no database required) + +```bash +pytest tests/unit/ -v +``` + +### Integration Tests (requires Docker) + +```bash +docker compose -f compose.test.yaml run --rm test +``` + +--- + +## Interactive API Documentation + +FastAPI auto-generates interactive documentation: + +- **Swagger UI**: http://localhost:9000/docs - Test endpoints directly in the browser +- **ReDoc**: http://localhost:9000/redoc - Clean API reference documentation + +--- + +## API Specification + +Base URL: `http://localhost:9000` + +### Health Check + +``` +GET /health +``` + +**Response** `200 OK` +```json +{ + "status": "healthy", + "version": "0.1.0" +} +``` + +--- + +### Create Server + +``` +POST /servers +Content-Type: application/json +``` + +**Request Body** +```json +{ + "hostname": "web-01.us-east", + "ip_address": "192.168.1.100", + "datacenter": "us-east-1", + "state": "active" +} +``` + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| hostname | string | Yes | Unique server hostname | +| ip_address | string | Yes | Valid IPv4 address | +| datacenter | string | Yes | Datacenter location | +| state | string | No | One of: `active`, `offline`, `retired` (default: `active`) | + +**Response** `201 Created` +```json +{ + "id": "550e8400-e29b-41d4-a716-446655440000", + "hostname": "web-01.us-east", + "ip_address": "192.168.1.100", + "datacenter": "us-east-1", + "state": "active", + "created_at": "2024-01-15T10:30:00Z", + "updated_at": "2024-01-15T10:30:00Z" +} +``` + +**Errors** +- `409 Conflict` - Hostname already exists +- `422 Unprocessable Entity` - Invalid IP address or state + +--- + +### List Servers + +``` +GET /servers +GET /servers?state=active&datacenter=us-east-1&limit=10&offset=0 +``` + +| Parameter | Type | Description | +|-----------|------|-------------| +| state | string | Filter by state | +| datacenter | string | Filter by datacenter | +| ip_address | string | Filter by IP address | +| limit | int | Max results (default: 100) | +| offset | int | Skip results (default: 0) | + +**Response** `200 OK` +```json +{ + "data": [ + { + "id": "550e8400-e29b-41d4-a716-446655440000", + "hostname": "web-01.us-east", + "ip_address": "192.168.1.100", + "datacenter": "us-east-1", + "state": "active", + "created_at": "2024-01-15T10:30:00Z", + "updated_at": "2024-01-15T10:30:00Z" + } + ], + "total": 1, + "limit": 100, + "offset": 0 +} +``` + +--- + +### Get Server + +``` +GET /servers/{id} +``` + +**Response** `200 OK` +```json +{ + "id": "550e8400-e29b-41d4-a716-446655440000", + "hostname": "web-01.us-east", + "ip_address": "192.168.1.100", + "datacenter": "us-east-1", + "state": "active", + "created_at": "2024-01-15T10:30:00Z", + "updated_at": "2024-01-15T10:30:00Z" +} +``` + +**Errors** +- `404 Not Found` - Server does not exist + +--- + +### Update Server + +``` +PUT /servers/{id} +Content-Type: application/json +``` + +**Request Body** (all fields optional) +```json +{ + "hostname": "web-01-updated", + "ip_address": "192.168.1.101", + "datacenter": "us-west-2", + "state": "offline" +} +``` + +**Response** `200 OK` +```json +{ + "id": "550e8400-e29b-41d4-a716-446655440000", + "hostname": "web-01-updated", + "ip_address": "192.168.1.101", + "datacenter": "us-west-2", + "state": "offline", + "created_at": "2024-01-15T10:30:00Z", + "updated_at": "2024-01-15T11:00:00Z" +} +``` + +**Errors** +- `404 Not Found` - Server does not exist +- `409 Conflict` - Hostname already taken +- `422 Unprocessable Entity` - Invalid IP or state + +--- + +### Delete Server + +``` +DELETE /servers/{id} +``` + +**Response** `204 No Content` + +**Errors** +- `404 Not Found` - Server does not exist + +--- + +## CLI Specification + +The CLI (`hsctl`) connects directly to the database. + +### Create Server + +```bash +hsctl create -h -i -d [-s ] +``` + +| Option | Description | +|--------|-------------| +| `-h, --hostname` | Server hostname (required) | +| `-i, --ip` | IP address (required) | +| `-d, --datacenter` | Datacenter location (required) | +| `-s, --state` | State: active, offline, retired (default: active) | + +**Example** +```bash +hsctl create -h web-01 -i 192.168.1.100 -d us-east-1 -s active +``` + +--- + +### List Servers + +```bash +hsctl list [--state ] [--datacenter ] +``` + +**Example** +```bash +hsctl list --state active --datacenter us-east-1 +``` + +--- + +### Get Server + +```bash +hsctl get +``` + +**Example** +```bash +hsctl get 550e8400-e29b-41d4-a716-446655440000 +``` + +--- + +### Update Server + +```bash +hsctl update [-h ] [-i ] [-d ] [-s ] +``` + +**Example** +```bash +hsctl update 550e8400-e29b-41d4-a716-446655440000 -s retired +``` + +--- + +### Delete Server + +```bash +hsctl delete [-y] +``` + +| Option | Description | +|--------|-------------| +| `-y, --yes` | Skip confirmation prompt | + +**Example** +```bash +hsctl delete 550e8400-e29b-41d4-a716-446655440000 -y +``` + +--- + +## Environment Variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `HAPPY_SERVERS_DB_HOST` | localhost | PostgreSQL host | +| `HAPPY_SERVERS_DB_PORT` | 5432 | PostgreSQL port | +| `HAPPY_SERVERS_DB_NAME` | happy_servers | Database name | +| `HAPPY_SERVERS_DB_USER` | postgres | Database user | +| `HAPPY_SERVERS_DB_PASSWORD` | postgres | Database password | +| `HAPPY_SERVERS_SERVER_HOST` | 0.0.0.0 | API server host | +| `HAPPY_SERVERS_SERVER_PORT` | 9000 | API server port | +| `HAPPY_SERVERS_DEBUG` | false | Enable debug mode and auto-reload | + +--- + +## Database Schema + +```sql +CREATE TABLE servers ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + hostname VARCHAR(255) NOT NULL UNIQUE, + ip_address VARCHAR(45) NOT NULL, + datacenter VARCHAR(255) NOT NULL, + state VARCHAR(20) NOT NULL DEFAULT 'active', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT valid_state CHECK (state IN ('active', 'offline', 'retired')) +); +``` + +Migrations are managed with [golang-migrate](https://github.com/golang-migrate/migrate) and run automatically in Docker Compose. \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..f301694 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,42 @@ +# Build stage +FROM python:3.12-slim AS builder + +WORKDIR /app + +# Install build dependencies +RUN pip install --no-cache-dir build + +# Copy project files +COPY pyproject.toml README.md ./ +COPY src/ ./src/ + +# Build wheel +RUN python -m build --wheel + + +# Runtime stage +FROM python:3.12-slim + +WORKDIR /app + +# Create non-root user +RUN useradd --create-home --shell /bin/bash appuser + +# Install the wheel from builder with dev dependencies +COPY --from=builder /app/dist/*.whl /tmp/ +RUN pip install --no-cache-dir /tmp/*.whl && \ + pip install --no-cache-dir pytest pytest-asyncio && \ + rm /tmp/*.whl + +# Copy tests and migrations for integration tests +COPY tests/ ./tests/ +COPY migrations/ ./migrations/ + +# Switch to non-root user +USER appuser + +# Expose API port +EXPOSE 9000 + +# Default command runs the API +CMD ["hsapi"] \ No newline at end of file diff --git a/README.md b/README.md index 3145d38..b3fca04 100644 --- a/README.md +++ b/README.md @@ -29,3 +29,88 @@ Validate that: State is one of: active, offline, retired +# Tools +- python package with entry points for cli and api +- go-migrate for database schema evolution +- docker-compose for local development +- Helm chart for deployment to K8s + +# Folder structure +``` +├── src/ +│ └── happy_servers/ +│ ├── __init__.py +│ ├── config.py +│ ├── database.py +│ ├── models.py +│ ├── api/ +│ │ ├── __init__.py +│ │ ├── app.py +│ │ └── routes.py +│ └── ctl/ +│ ├── __init__.py +│ └── cli.py +├── migrations/ +│ └── ... +├── tests/ +│ └── ... +├── pyproject.toml +├── docker-compose.yml +├── Dockerfile +└── API.md +``` +# Local development +Start with create Python virtual environment and setup our package for local development + +```bash +python -m venv .venv +source .venv/bin/activate +pip install -e ".[dev]" +``` + +Run API locally with auto reload +```bash +cp .env.example .env +docker compose up -d db migrations +export HAPPY_SERVERS_DEBUG=true +hsapi +``` +You could set HAPPY_SERVERS_SERVER_PORT if you want no default (9000) port + +# Testing +## Unit +For unit tests we don't use real database. It is mocked. Thus it is safe to run: +```bash +pytest ./tests/unit/ +``` +## Integration +Integration tests are using a real database server. Run it inside docker compose environment. +```bash +docker compose -f compose.test.yaml run --rm test +``` + +# Command Line Tool +CLI is available as `hsctl` command. You'll get it automatically installed once pip install is ran. +```bash +hsctl -h + + delete Delete a server. + get Get a server by ID. + list List all servers. + update Update a server. +``` + +```bash +hsctl create --help +Usage: hsctl create [OPTIONS] + + Create a new server. + +Options: + -h, --hostname TEXT Server hostname (must be unique) [required] + -i, --ip TEXT Server IP address [required] + -d, --datacenter TEXT Datacenter location [required] + -s, --state [active|offline|retired] + Server state (default: active) + --help Show this message and exit. + ``` \ No newline at end of file diff --git a/compose.test.yaml b/compose.test.yaml new file mode 100644 index 0000000..fb34ef0 --- /dev/null +++ b/compose.test.yaml @@ -0,0 +1,73 @@ +services: + db: + image: postgres:17-alpine + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: test_happy_servers + volumes: + - test_postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres -d test_happy_servers"] + interval: 2s + timeout: 2s + retries: 5 + + migrations: + image: migrate/migrate + volumes: + - ./migrations:/migrations:ro + command: ["-path=/migrations", "-database=postgres://postgres:postgres@db:5432/test_happy_servers?sslmode=disable", "up"] + depends_on: + db: + condition: service_healthy + + api: + build: . + environment: + HAPPY_SERVERS_DB_HOST: db + HAPPY_SERVERS_DB_PORT: 5432 + HAPPY_SERVERS_DB_NAME: test_happy_servers + HAPPY_SERVERS_DB_USER: postgres + HAPPY_SERVERS_DB_PASSWORD: postgres + HAPPY_SERVERS_SERVER_PORT: 8000 + depends_on: + migrations: + condition: service_completed_successfully + healthcheck: + test: ["CMD", "python", "-c", "import httpx; httpx.get('http://localhost:8000/health')"] + interval: 2s + timeout: 2s + retries: 10 + develop: + watch: + - action: rebuild + path: ./src + + test: + build: . + command: pytest tests/integration/ -v -p no:cacheprovider + environment: + API_URL: http://api:8000 + HAPPY_SERVERS_DB_HOST: db + HAPPY_SERVERS_DB_PORT: 5432 + HAPPY_SERVERS_DB_NAME: test_happy_servers + HAPPY_SERVERS_DB_USER: postgres + HAPPY_SERVERS_DB_PASSWORD: postgres + volumes: + - ./tests:/app/tests:ro + - ./pyproject.toml:/app/pyproject.toml:ro + depends_on: + db: + condition: service_healthy + migrations: + condition: service_completed_successfully + api: + condition: service_healthy + develop: + watch: + - action: rebuild + path: ./src + +volumes: + test_postgres_data: \ No newline at end of file diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..f267ad1 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,47 @@ +services: + db: + image: postgres:17-alpine + environment: + POSTGRES_USER: ${HAPPY_SERVERS_DB_USER:-postgres} + POSTGRES_PASSWORD: ${HAPPY_SERVERS_DB_PASSWORD:-postgres} + POSTGRES_DB: ${HAPPY_SERVERS_DB_NAME:-happy_servers} + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${HAPPY_SERVERS_DB_USER:-postgres} -d ${HAPPY_SERVERS_DB_NAME:-happy_servers}"] + interval: 5s + timeout: 5s + retries: 5 + + migrations: + image: migrate/migrate + volumes: + - ./migrations:/migrations:ro + command: + - "-path=/migrations" + - "-database=postgres://${HAPPY_SERVERS_DB_USER:-postgres}:${HAPPY_SERVERS_DB_PASSWORD:-postgres}@db:5432/${HAPPY_SERVERS_DB_NAME:-happy_servers}?sslmode=disable" + - "up" + depends_on: + db: + condition: service_healthy + + api: + build: . + ports: + - "${HAPPY_SERVERS_SERVER_PORT:-8000}:${HAPPY_SERVERS_SERVER_PORT:-8000}" + environment: + HAPPY_SERVERS_DB_HOST: db + HAPPY_SERVERS_DB_PORT: 5432 + HAPPY_SERVERS_DB_USER: ${HAPPY_SERVERS_DB_USER:-postgres} + HAPPY_SERVERS_DB_PASSWORD: ${HAPPY_SERVERS_DB_PASSWORD:-postgres} + HAPPY_SERVERS_DB_NAME: ${HAPPY_SERVERS_DB_NAME:-happy_servers} + HAPPY_SERVERS_SERVER_PORT: ${HAPPY_SERVERS_SERVER_PORT:-8000} + HAPPY_SERVERS_DEBUG: ${HAPPY_SERVERS_DEBUG:-false} + depends_on: + migrations: + condition: service_completed_successfully + +volumes: + postgres_data: \ No newline at end of file diff --git a/migrations/000001_create_servers_table.down.sql b/migrations/000001_create_servers_table.down.sql new file mode 100644 index 0000000..40a9a67 --- /dev/null +++ b/migrations/000001_create_servers_table.down.sql @@ -0,0 +1,7 @@ +-- Drop indexes +DROP INDEX IF EXISTS idx_servers_created_at; +DROP INDEX IF EXISTS idx_servers_datacenter; +DROP INDEX IF EXISTS idx_servers_state; + +-- Drop table +DROP TABLE IF EXISTS servers; diff --git a/migrations/000001_create_servers_table.up.sql b/migrations/000001_create_servers_table.up.sql new file mode 100644 index 0000000..4c3ecd7 --- /dev/null +++ b/migrations/000001_create_servers_table.up.sql @@ -0,0 +1,19 @@ +-- Create servers table +CREATE TABLE IF NOT EXISTS servers ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + hostname VARCHAR(255) NOT NULL, + ip_address VARCHAR(15) NOT NULL, + datacenter VARCHAR(100) NOT NULL, + state VARCHAR(20) NOT NULL DEFAULT 'active', + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + + -- Constraints + CONSTRAINT servers_hostname_unique UNIQUE (hostname), + CONSTRAINT servers_state_check CHECK (state IN ('active', 'offline', 'retired')) +); + +-- Index for common queries +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_created_at ON servers(created_at DESC); diff --git a/migrations/000002_create_servers_history.down.sql b/migrations/000002_create_servers_history.down.sql new file mode 100644 index 0000000..253b3a6 --- /dev/null +++ b/migrations/000002_create_servers_history.down.sql @@ -0,0 +1,12 @@ +-- Drop trigger +DROP TRIGGER IF EXISTS servers_history_trigger ON servers; + +-- Drop function +DROP FUNCTION IF EXISTS servers_history_trigger; + +-- Drop indexes +DROP INDEX IF EXISTS idx_servers_history_changed_at; +DROP INDEX IF EXISTS idx_servers_history_server_id; + +-- Drop table +DROP TABLE IF EXISTS servers_history; diff --git a/migrations/000002_create_servers_history.up.sql b/migrations/000002_create_servers_history.up.sql new file mode 100644 index 0000000..6b36a92 --- /dev/null +++ b/migrations/000002_create_servers_history.up.sql @@ -0,0 +1,38 @@ +-- Create servers history table +CREATE TABLE servers_history ( + id SERIAL PRIMARY KEY, + server_id UUID NOT NULL, + operation VARCHAR(10) NOT NULL, + old_data JSONB, + new_data JSONB, + changed_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_servers_history_server_id ON servers_history(server_id); +CREATE INDEX idx_servers_history_changed_at ON servers_history(changed_at DESC); + +-- Create trigger function +CREATE OR REPLACE FUNCTION servers_history_trigger() +RETURNS TRIGGER AS $$ +BEGIN + IF TG_OP = 'INSERT' THEN + INSERT INTO servers_history (server_id, operation, new_data) + VALUES (NEW.id, 'INSERT', to_jsonb(NEW)); + RETURN NEW; + ELSIF TG_OP = 'UPDATE' THEN + INSERT INTO servers_history (server_id, operation, old_data, new_data) + VALUES (NEW.id, 'UPDATE', to_jsonb(OLD), to_jsonb(NEW)); + RETURN NEW; + ELSIF TG_OP = 'DELETE' THEN + INSERT INTO servers_history (server_id, operation, old_data) + VALUES (OLD.id, 'DELETE', to_jsonb(OLD)); + RETURN OLD; + END IF; + RETURN NULL; +END; +$$ LANGUAGE plpgsql; + +-- Attach trigger to servers table +CREATE TRIGGER servers_history_trigger +AFTER INSERT OR UPDATE OR DELETE ON servers +FOR EACH ROW EXECUTE FUNCTION servers_history_trigger(); diff --git a/migrations/README.md b/migrations/README.md new file mode 100644 index 0000000..f1cdcfc --- /dev/null +++ b/migrations/README.md @@ -0,0 +1,63 @@ +# Database Migrations + +This folder contains raw SQL migrations managed by [golang-migrate](https://github.com/golang-migrate/migrate). + +## Naming Convention + +``` +{version}_{description}.up.sql # Applied when migrating up +{version}_{description}.down.sql # Applied when rolling back +``` + +Version is a sequential number padded to 6 digits: `000001`, `000002`, etc. + +## Usage + +### Via Docker Compose + +```bash +# Run all pending migrations +docker compose up migrations + +# Or run manually +docker compose run --rm migrations up + +# Rollback last migration +docker compose run --rm migrations down 1 + +# Check current version +docker compose run --rm migrations version +``` + +### Via migrate CLI + +```bash +# Install +brew install golang-migrate # macOS +# or: https://github.com/golang-migrate/migrate/releases + +# Run migrations +migrate -path ./migrations -database "postgres://postgres:postgres@localhost:5432/happy_servers?sslmode=disable" up + +# Rollback +migrate -path ./migrations -database "..." down 1 + +# Force version (fix dirty state) +migrate -path ./migrations -database "..." force VERSION +``` + +## Creating New Migrations + +```bash +migrate create -ext sql -dir migrations -seq add_some_feature +``` + +This creates: +- `migrations/000002_add_some_feature.up.sql` +- `migrations/000002_add_some_feature.down.sql` + +## Links + +- [golang-migrate GitHub](https://github.com/golang-migrate/migrate) +- [CLI Documentation](https://github.com/golang-migrate/migrate/tree/master/cmd/migrate) +- [PostgreSQL Driver](https://github.com/golang-migrate/migrate/tree/master/database/postgres) \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..db176de --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,131 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "happy-servers" +version = "0.1.0" +description = "Inventory management for cloud server provisioning" +readme = "README.md" +license = { text = "MIT" } +requires-python = ">=3.11" +authors = [ + { name = "DmitriKo", email = "dmitrkozhevin@gmail.com" }, +] +classifiers = [ + "Development Status :: 4 - Beta", + "Environment :: Console", + "Environment :: Web Environment", + "Framework :: FastAPI", + "Intended Audience :: System Administrators", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Topic :: System :: Systems Administration", + "Typing :: Typed", +] +keywords = ["server", "inventory", "devops", "infrastructure"] + +dependencies = [ + "fastapi>=0.115.0", + "uvicorn[standard]>=0.32.0", + "psycopg[binary,pool]>=3.2.0", + "pydantic>=2.9.0", + "pydantic-settings>=2.6.0", + "click>=8.1.0", + "rich>=13.9.0", + "httpx>=0.27.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.3.0", + "pytest-cov>=6.0.0", + "pytest-asyncio>=0.24.0", + "ruff>=0.7.0", + "mypy>=1.13.0", +] + +[project.scripts] +hsapi = "happy_servers.api:main" +hsctl = "happy_servers.cli:main" + +[project.urls] +Homepage = "https://github.com/dmitriko/hiring-challenge-devops-python" + +[tool.hatch.build] +sources = ["src"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +asyncio_default_fixture_loop_scope = "function" +addopts = [ + "-v", + "--tb=short", + "--strict-markers", +] +markers = [ + "integration: marks tests as integration tests (may require database)", +] + +[tool.ruff] +target-version = "py312" +line-length = 100 +src = ["src", "tests"] + +[tool.ruff.lint] +select = [ + "E", # pycodestyle errors + "W", # pycodestyle warnings + "F", # pyflakes + "I", # isort + "B", # flake8-bugbear + "C4", # flake8-comprehensions + "UP", # pyupgrade + "ARG", # flake8-unused-arguments + "SIM", # flake8-simplify +] +ignore = [ + "E501", # line too long (handled by formatter) + "B008", # do not perform function calls in argument defaults (needed for FastAPI Depends) +] + +[tool.ruff.lint.isort] +known-first-party = ["happy_servers"] + +[tool.mypy] +python_version = "3.12" +strict = true +warn_return_any = true +warn_unused_ignores = true +disallow_untyped_defs = true +disallow_incomplete_defs = true +check_untyped_defs = true +disallow_untyped_decorators = true +no_implicit_optional = true +warn_redundant_casts = true +warn_unused_configs = true +show_error_codes = true +files = ["src/happy_servers"] + +[[tool.mypy.overrides]] +module = ["tests.*"] +disallow_untyped_defs = false + +[tool.coverage.run] +source = ["src/happy_servers"] +branch = true +omit = ["*/tests/*"] + +[tool.coverage.report] +exclude_lines = [ + "pragma: no cover", + "def __repr__", + "raise AssertionError", + "raise NotImplementedError", + "if __name__ == .__main__.:", + "if TYPE_CHECKING:", +] \ No newline at end of file diff --git a/run-integrartion-tests.sh b/run-integrartion-tests.sh new file mode 100755 index 0000000..97427c2 --- /dev/null +++ b/run-integrartion-tests.sh @@ -0,0 +1,3 @@ +#!/bin/sh +# run integrations tests inside docker compose +docker compose -f compose.test.yaml run --rm test \ No newline at end of file diff --git a/src/happy_servers/__init__.py b/src/happy_servers/__init__.py new file mode 100644 index 0000000..b64fd65 --- /dev/null +++ b/src/happy_servers/__init__.py @@ -0,0 +1,3 @@ +"""Happy Servers - Inventory management for cloud server provisioning.""" + +__version__ = "0.1.0" diff --git a/src/happy_servers/api/__init__.py b/src/happy_servers/api/__init__.py new file mode 100644 index 0000000..f3a7588 --- /dev/null +++ b/src/happy_servers/api/__init__.py @@ -0,0 +1,32 @@ +"""Happy Servers API entry point.""" + +import logging + +import uvicorn + +from happy_servers.config import get_settings + +logger = logging.getLogger(__name__) + + +def main() -> None: + """Start the API server.""" + settings = get_settings() + + log_level = logging.DEBUG if settings.debug else logging.INFO + logging.basicConfig(level=log_level) + + logger.info(f"Starting Happy Servers API on {settings.server_host}:{settings.server_port}") + if settings.debug: + logger.info("Debug mode enabled, auto-reload active") + + uvicorn.run( + "happy_servers.api.app:app", + host=settings.server_host, + port=settings.server_port, + reload=settings.debug, + ) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/src/happy_servers/api/app.py b/src/happy_servers/api/app.py new file mode 100644 index 0000000..8bf71ff --- /dev/null +++ b/src/happy_servers/api/app.py @@ -0,0 +1,85 @@ +"""FastAPI application.""" + +from uuid import UUID + +from fastapi import FastAPI, HTTPException, status + +from happy_servers import __version__ +from happy_servers.models import Server, ServerCreate, ServerUpdate, ServerList +from happy_servers import repository +from happy_servers.repository import DuplicateHostnameError + +app = FastAPI( + title="Happy Servers API", + description="Inventory management for cloud server provisioning", + version=__version__, +) + + +@app.get("/health") +async def health(): + """Health check endpoint.""" + return {"status": "healthy", "version": __version__} + + +@app.post("/servers", status_code=status.HTTP_201_CREATED) +async def create_server(data: ServerCreate) -> Server: + """Create a new server.""" + try: + result = await repository.create_server(data) + except DuplicateHostnameError: + raise HTTPException(status_code=409, detail="Hostname already exists") + return Server.model_validate(result) + + +@app.get("/servers") +async def list_servers( + datacenter: str | None = None, + state: str | None = None, + ip_address: str | None = None, + limit: int = 100, + offset: int = 0, +) -> ServerList: + """List servers with optional filters and pagination.""" + servers, total = await repository.list_servers( + datacenter=datacenter, + state=state, + ip_address=ip_address, + limit=limit, + offset=offset, + ) + return ServerList( + data=[Server.model_validate(s) for s in servers], + total=total, + limit=limit, + offset=offset, + ) + + +@app.get("/servers/{server_id}") +async def get_server(server_id: UUID) -> Server: + """Get a server by ID.""" + result = await repository.get_server(server_id) + if result is None: + raise HTTPException(status_code=404, detail="Server not found") + return Server.model_validate(result) + + +@app.delete("/servers/{server_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_server(server_id: UUID) -> None: + """Delete a server by ID.""" + deleted = await repository.delete_server(server_id) + if not deleted: + raise HTTPException(status_code=404, detail="Server not found") + + +@app.put("/servers/{server_id}") +async def update_server(server_id: UUID, data: ServerUpdate) -> Server: + """Update a server by ID.""" + try: + result = await repository.update_server(server_id, data) + except DuplicateHostnameError: + raise HTTPException(status_code=409, detail="Hostname already exists") + if result is None: + raise HTTPException(status_code=404, detail="Server not found") + return Server.model_validate(result) \ No newline at end of file diff --git a/src/happy_servers/cli/__init__.py b/src/happy_servers/cli/__init__.py new file mode 100644 index 0000000..42a160e --- /dev/null +++ b/src/happy_servers/cli/__init__.py @@ -0,0 +1,5 @@ +"""CLI entry point.""" + +from happy_servers.cli.cli import cli as main + +__all__ = ["main"] diff --git a/src/happy_servers/cli/cli.py b/src/happy_servers/cli/cli.py new file mode 100644 index 0000000..12b375f --- /dev/null +++ b/src/happy_servers/cli/cli.py @@ -0,0 +1,197 @@ +"""CLI for Happy Servers inventory management.""" + +import asyncio +from typing import Any +from uuid import UUID + +import click +from rich.console import Console +from rich.table import Table + +from happy_servers import __version__ +from happy_servers.models import ServerCreate, ServerState, ServerUpdate +from happy_servers import repository +from happy_servers.repository import DuplicateHostnameError + +console = Console() + + +def print_server(server: dict[str, Any]) -> None: + """Print a single server as a table.""" + table = Table(show_header=False, box=None) + table.add_column("Field", style="bold cyan") + table.add_column("Value") + + table.add_row("ID", str(server["id"])) + table.add_row("Hostname", server["hostname"]) + table.add_row("IP Address", server["ip_address"]) + table.add_row("Datacenter", server["datacenter"]) + table.add_row("State", server["state"]) + table.add_row("Created", str(server["created_at"])) + table.add_row("Updated", str(server["updated_at"])) + + console.print(table) + + +def print_servers_table(servers: list[dict[str, Any]], total: int) -> None: + """Print servers as a table.""" + if not servers: + console.print("[yellow]No servers found.[/yellow]") + return + + table = Table(title=f"Servers ({total} total)") + table.add_column("ID", style="dim") + table.add_column("Hostname", style="cyan") + table.add_column("IP Address") + table.add_column("Datacenter") + table.add_column("State") + + for server in servers: + state = server["state"] + state_style = { + "active": "green", + "offline": "yellow", + "retired": "red", + }.get(state, "") + + short_id = str(server["id"])[:8] + + table.add_row( + short_id, + server["hostname"], + server["ip_address"], + server["datacenter"], + f"[{state_style}]{state}[/{state_style}]", + ) + + console.print(table) + + +@click.group() +@click.version_option(version=__version__, prog_name="hsctl") +def cli() -> None: + """Happy Servers inventory management CLI.""" + pass + + +@cli.command("create") +@click.option("--hostname", "-h", required=True, help="Server hostname (must be unique)") +@click.option("--ip", "-i", "ip_address", required=True, help="Server IP address") +@click.option("--datacenter", "-d", required=True, help="Datacenter location") +@click.option( + "--state", + "-s", + type=click.Choice(["active", "offline", "retired"]), + default="active", + help="Server state (default: active)", +) +def create_cmd( + hostname: str, + ip_address: str, + datacenter: str, + state: str, +) -> None: + """Create a new server.""" + try: + data = ServerCreate( + hostname=hostname, + ip_address=ip_address, + datacenter=datacenter, + state=ServerState(state), + ) + except ValueError as e: + raise click.ClickException(str(e)) + + try: + server = asyncio.run(repository.create_server(data)) + console.print("[green]✓ Server created successfully[/green]") + print_server(server) + except DuplicateHostnameError as e: + raise click.ClickException(f"Server with hostname '{e}' already exists") + + +@cli.command("list") +@click.option( + "--state", + "-s", + type=click.Choice(["active", "offline", "retired"]), + help="Filter by state", +) +@click.option("--datacenter", "-d", help="Filter by datacenter") +def list_cmd( + state: str | None, + datacenter: str | None, +) -> None: + """List all servers.""" + servers, total = asyncio.run(repository.list_servers(state=state, datacenter=datacenter)) + print_servers_table(servers, total) + + +@cli.command("get") +@click.argument("server_id", type=click.UUID) +def get_cmd(server_id: UUID) -> None: + """Get a server by ID.""" + server = asyncio.run(repository.get_server(server_id)) + if server is None: + raise click.ClickException(f"Server with id '{server_id}' not found") + print_server(server) + + +@cli.command("update") +@click.argument("server_id", type=click.UUID) +@click.option("--hostname", "-h", help="New hostname") +@click.option("--ip", "-i", "ip_address", help="New IP address") +@click.option("--datacenter", "-d", help="New datacenter") +@click.option( + "--state", + "-s", + type=click.Choice(["active", "offline", "retired"]), + help="New state", +) +def update_cmd( + server_id: UUID, + hostname: str | None, + ip_address: str | None, + datacenter: str | None, + state: str | None, +) -> None: + """Update a server.""" + if not any([hostname, ip_address, datacenter, state]): + raise click.ClickException("At least one field must be provided to update") + + try: + data = ServerUpdate( + hostname=hostname, + ip_address=ip_address, + datacenter=datacenter, + state=ServerState(state) if state else None, + ) + except ValueError as e: + raise click.ClickException(str(e)) + + try: + server = asyncio.run(repository.update_server(server_id, data)) + if server is None: + raise click.ClickException(f"Server with id '{server_id}' not found") + console.print("[green]✓ Server updated successfully[/green]") + print_server(server) + except DuplicateHostnameError as e: + raise click.ClickException(f"Server with hostname '{e}' already exists") + + +@cli.command("delete") +@click.argument("server_id", type=click.UUID) +@click.option("--yes", "-y", is_flag=True, help="Skip confirmation") +def delete_cmd(server_id: UUID, yes: bool) -> None: + """Delete a server.""" + if not yes: + click.confirm(f"Are you sure you want to delete server {server_id}?", abort=True) + + deleted = asyncio.run(repository.delete_server(server_id)) + if not deleted: + raise click.ClickException(f"Server with id '{server_id}' not found") + console.print(f"[green]✓ Server {server_id} deleted[/green]") + + +if __name__ == "__main__": + cli() \ No newline at end of file diff --git a/src/happy_servers/config.py b/src/happy_servers/config.py new file mode 100644 index 0000000..4ad4171 --- /dev/null +++ b/src/happy_servers/config.py @@ -0,0 +1,44 @@ +"""Configuration management via environment variables.""" + +from functools import lru_cache + +from pydantic import SecretStr +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + """Application settings loaded from environment variables.""" + + model_config = SettingsConfigDict( + env_prefix="HAPPY_SERVERS_", + env_file=".env", + env_file_encoding="utf-8", + case_sensitive=False, + extra="ignore", + ) + + # Database + db_host: str = "localhost" + db_port: int = 5432 + db_user: str = "postgres" + db_password: SecretStr + db_name: str = "happy_servers" + + # API Server + server_host: str = "0.0.0.0" + server_port: int = 9000 + debug: bool = False + + @property + def db_url(self) -> str: + """PostgreSQL connection URL for psycopg.""" + return ( + f"postgresql://{self.db_user}:{self.db_password.get_secret_value()}" + f"@{self.db_host}:{self.db_port}/{self.db_name}" + ) + + +@lru_cache +def get_settings() -> Settings: + """Get cached settings instance.""" + return Settings() \ No newline at end of file diff --git a/src/happy_servers/models.py b/src/happy_servers/models.py new file mode 100644 index 0000000..1e309e9 --- /dev/null +++ b/src/happy_servers/models.py @@ -0,0 +1,65 @@ +"""Pydantic models for request/response validation.""" + +import re +from datetime import datetime +from enum import Enum +from uuid import UUID + +from pydantic import BaseModel, field_validator + +# Simple IPv4 regex +IPV4_PATTERN = re.compile( + r"^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}" + r"(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$" +) + + +class ServerState(str, Enum): + ACTIVE = "active" + OFFLINE = "offline" + RETIRED = "retired" + + +class ServerCreate(BaseModel): + hostname: str + ip_address: str + datacenter: str + state: ServerState = ServerState.ACTIVE + + @field_validator("ip_address") + @classmethod + def validate_ip_address(cls, v: str) -> str: + if not IPV4_PATTERN.match(v): + raise ValueError("Invalid IPv4 address") + return v + + +class ServerUpdate(BaseModel): + hostname: str | None = None + ip_address: str | None = None + datacenter: str | None = None + state: ServerState | None = None + + @field_validator("ip_address") + @classmethod + def validate_ip_address(cls, v: str | None) -> str | None: + if v is not None and not IPV4_PATTERN.match(v): + raise ValueError("Invalid IPv4 address") + return v + + +class Server(BaseModel): + id: UUID + hostname: str + ip_address: str + datacenter: str + state: str + created_at: datetime + updated_at: datetime + + +class ServerList(BaseModel): + data: list[Server] + total: int + limit: int + offset: int \ No newline at end of file diff --git a/src/happy_servers/repository.py b/src/happy_servers/repository.py new file mode 100644 index 0000000..5a79ac2 --- /dev/null +++ b/src/happy_servers/repository.py @@ -0,0 +1,186 @@ +"""Repository for server data access.""" + +from contextlib import asynccontextmanager +from uuid import UUID + +from psycopg.rows import dict_row +from psycopg_pool import AsyncConnectionPool + +from happy_servers.config import get_settings +from happy_servers.models import ServerCreate, ServerUpdate + + +class DuplicateHostnameError(Exception): + """Raised when hostname already exists.""" + pass + + +_pool: AsyncConnectionPool | None = None + + +def _get_conninfo() -> str: + """Build connection string from config.""" + settings = get_settings() + return settings.db_url + + +async def close_pool() -> None: + """Close the connection pool.""" + global _pool + if _pool is not None: + await _pool.close() + _pool = None + + +@asynccontextmanager +async def get_connection(): + """Get a connection from the pool.""" + global _pool + if _pool is None: + _pool = AsyncConnectionPool(_get_conninfo(), open=False) + if not _pool._opened: + await _pool.open() + async with _pool.connection() as conn: + yield conn + + +async def create_server(data: ServerCreate) -> dict: + """Create a new server in the database.""" + from psycopg import errors + + async with get_connection() as conn: + async with conn.cursor(row_factory=dict_row) as cursor: + try: + await cursor.execute( + """ + INSERT INTO servers (hostname, ip_address, datacenter, state) + VALUES (%s, %s, %s, %s) + RETURNING id, hostname, ip_address, datacenter, state, created_at, updated_at + """, + (data.hostname, data.ip_address, data.datacenter, data.state.value), + ) + except errors.UniqueViolation: + raise DuplicateHostnameError(data.hostname) + row = await cursor.fetchone() + return row + + +async def get_server(server_id: UUID) -> dict | None: + """Get a server by ID.""" + async with get_connection() as conn: + async with conn.cursor(row_factory=dict_row) as cursor: + await cursor.execute( + """ + SELECT id, hostname, ip_address, datacenter, state, created_at, updated_at + FROM servers + WHERE id = %s + """, + (server_id,), + ) + row = await cursor.fetchone() + return row + + +async def delete_server(server_id: UUID) -> bool: + """Delete a server by ID. Returns True if deleted, False if not found.""" + async with get_connection() as conn: + async with conn.cursor() as cursor: + await cursor.execute( + """ + DELETE FROM servers + WHERE id = %s + """, + (server_id,), + ) + return cursor.rowcount > 0 + + +async def update_server(server_id: UUID, data: ServerUpdate) -> dict | None: + """Update a server by ID. Returns updated server or None if not found.""" + # Build SET clause from non-None fields + fields = [] + values = [] + if data.hostname is not None: + fields.append("hostname = %s") + values.append(data.hostname) + if data.ip_address is not None: + fields.append("ip_address = %s") + values.append(data.ip_address) + if data.datacenter is not None: + fields.append("datacenter = %s") + values.append(data.datacenter) + if data.state is not None: + fields.append("state = %s") + values.append(data.state.value) + + if not fields: + # Nothing to update, just return current + return await get_server(server_id) + + fields.append("updated_at = NOW()") + values.append(server_id) + + async with get_connection() as conn: + async with conn.cursor(row_factory=dict_row) as cursor: + await cursor.execute( + f""" + UPDATE servers + SET {", ".join(fields)} + WHERE id = %s + RETURNING id, hostname, ip_address, datacenter, state, created_at, updated_at + """, + values, + ) + row = await cursor.fetchone() + return row + + +async def list_servers( + datacenter: str | None = None, + state: str | None = None, + ip_address: str | None = None, + limit: int = 100, + offset: int = 0, +) -> tuple[list[dict], int]: + """List servers with optional filters. Returns (servers, total_count).""" + conditions = [] + values = [] + + if datacenter is not None: + conditions.append("datacenter = %s") + values.append(datacenter) + if state is not None: + conditions.append("state = %s") + values.append(state) + if ip_address is not None: + conditions.append("ip_address = %s") + values.append(ip_address) + + where_clause = "" + if conditions: + where_clause = "WHERE " + " AND ".join(conditions) + + async with get_connection() as conn: + async with conn.cursor(row_factory=dict_row) as cursor: + # Get total count + await cursor.execute( + f"SELECT COUNT(*) as count FROM servers {where_clause}", + values, + ) + count_row = await cursor.fetchone() + total = count_row["count"] + + # Get paginated results + await cursor.execute( + f""" + SELECT id, hostname, ip_address, datacenter, state, created_at, updated_at + FROM servers + {where_clause} + ORDER BY created_at DESC + LIMIT %s OFFSET %s + """, + values + [limit, offset], + ) + servers = await cursor.fetchall() + + return servers, total \ No newline at end of file diff --git a/start-local-dev.sh b/start-local-dev.sh new file mode 100755 index 0000000..7d07186 --- /dev/null +++ b/start-local-dev.sh @@ -0,0 +1,12 @@ +#!/bin/sh +# start local api server for developmeent alongside with db in docker compose +set -e + +# make sure .env is here +[ -f .env ] || { echo "Create .env file based on .env.example"; exit 1; } + +docker compose up -d db migrations +export HAPPY_SERVERS_DB_HOST=127.0.0.1 +export HAPPY_SERVERS_DB_PORT=5432 +export HAPPY_SERVERS_DEBUG=true +.venv/bin/hsapi \ No newline at end of file diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py new file mode 100644 index 0000000..1364454 --- /dev/null +++ b/tests/integration/conftest.py @@ -0,0 +1,33 @@ +"""Pytest fixtures for integration tests.""" + +import os + +import psycopg +import pytest + + +def get_db_conninfo(): + """Build connection string for test database.""" + host = os.getenv("HAPPY_SERVERS_DB_HOST", "localhost") + port = os.getenv("HAPPY_SERVERS_DB_PORT", "5432") + user = os.getenv("HAPPY_SERVERS_DB_USER", "postgres") + password = os.getenv("HAPPY_SERVERS_DB_PASSWORD", "postgres") + db = os.getenv("HAPPY_SERVERS_DB_NAME", "test_happy_servers") + return f"postgresql://{user}:{password}@{host}:{port}/{db}" + + +@pytest.fixture(scope="session") +def db_conn(): + """Database connection for direct verification.""" + conninfo = get_db_conninfo() + with psycopg.connect(conninfo) as conn: + yield conn + + +@pytest.fixture +def clean_db(db_conn): + """Truncate all tables before each test.""" + with db_conn.cursor() as cur: + cur.execute("TRUNCATE TABLE servers CASCADE") + db_conn.commit() + yield \ No newline at end of file diff --git a/tests/integration/test_servers.py b/tests/integration/test_servers.py new file mode 100644 index 0000000..aa5ddf2 --- /dev/null +++ b/tests/integration/test_servers.py @@ -0,0 +1,111 @@ +"""Integration tests for servers API with real PostgreSQL.""" + +import os + +import httpx +import pytest + +API_URL = os.environ['API_URL'] + + +@pytest.mark.integration +def test_server_lifecycle(clean_db, db_conn): + """Test complete server lifecycle: create, validate, update, delete.""" + + with httpx.Client(base_url=API_URL, timeout=10) as client: + # 1. Create server + response = client.post( + "/servers", + json={ + "hostname": "web-01", + "ip_address": "192.168.1.100", + "datacenter": "us-east-1", + }, + ) + assert response.status_code == 201 + server = response.json() + server_id = server["id"] + assert server["hostname"] == "web-01" + assert server["state"] == "active" + + # 2. Check database directly + with db_conn.cursor() as cur: + cur.execute("SELECT hostname, state FROM servers WHERE id = %s", (server_id,)) + row = cur.fetchone() + assert row[0] == "web-01" + assert row[1] == "active" + + # 3. Can't create with duplicate hostname + response = client.post( + "/servers", + json={ + "hostname": "web-01", + "ip_address": "192.168.1.101", + "datacenter": "us-east-1", + }, + ) + assert response.status_code == 409 + + # 4. Can't create with wrong IP + response = client.post( + "/servers", + json={ + "hostname": "web-02", + "ip_address": "not-an-ip", + "datacenter": "us-east-1", + }, + ) + assert response.status_code == 422 + + # 5. Can't create with wrong state + response = client.post( + "/servers", + json={ + "hostname": "web-02", + "ip_address": "192.168.1.102", + "datacenter": "us-east-1", + "state": "invalid-state", + }, + ) + assert response.status_code == 422 + + # 6. Update state to valid value + response = client.put( + f"/servers/{server_id}", + json={"state": "offline"}, + ) + assert response.status_code == 200 + assert response.json()["state"] == "offline" + + # 7. Check database for update + with db_conn.cursor() as cur: + cur.execute("SELECT state FROM servers WHERE id = %s", (server_id,)) + row = cur.fetchone() + assert row[0] == "offline" + + # 8. Can't PUT wrong state + response = client.put( + f"/servers/{server_id}", + json={"state": "invalid-state"}, + ) + assert response.status_code == 422 + + # 9. Can't PUT wrong IP + response = client.put( + f"/servers/{server_id}", + json={"ip_address": "not-an-ip"}, + ) + assert response.status_code == 422 + + # 10. Delete server + response = client.delete(f"/servers/{server_id}") + assert response.status_code == 204 + + # 11. Verify deleted + response = client.get(f"/servers/{server_id}") + assert response.status_code == 404 + + # Also verify in database + with db_conn.cursor() as cur: + cur.execute("SELECT COUNT(*) FROM servers WHERE id = %s", (server_id,)) + assert cur.fetchone()[0] == 0 \ No newline at end of file diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py new file mode 100644 index 0000000..1f3d565 --- /dev/null +++ b/tests/unit/conftest.py @@ -0,0 +1,55 @@ +"""Pytest fixtures for unit tests. No real database, all mocked.""" + +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch +from uuid import uuid4 + +import pytest + + +@pytest.fixture +def mock_db_connection(): + """Mock database connection for repository.""" + with patch("happy_servers.repository.get_connection") as mock: + conn = MagicMock() + cursor = MagicMock() + + # Make async methods + cursor.execute = AsyncMock() + cursor.fetchone = AsyncMock() + cursor.fetchall = AsyncMock() + + # Async context manager for connection + mock.return_value.__aenter__ = AsyncMock(return_value=conn) + mock.return_value.__aexit__ = AsyncMock(return_value=None) + + # Async context manager for cursor + conn.cursor.return_value.__aenter__ = AsyncMock(return_value=cursor) + conn.cursor.return_value.__aexit__ = AsyncMock(return_value=None) + + yield {"connection": conn, "cursor": cursor, "mock": mock} + + +@pytest.fixture +def sample_server() -> dict[str, Any]: + """Sample server data as returned from DB.""" + return { + "id": uuid4(), + "hostname": "web-01", + "ip_address": "192.168.1.100", + "datacenter": "us-east-1", + "state": "active", + "created_at": "2024-01-15T10:30:00+00:00", + "updated_at": "2024-01-15T10:30:00+00:00", + } + + +@pytest.fixture +def sample_server_input() -> dict[str, str]: + """Sample valid server input data.""" + return { + "hostname": "web-01", + "ip_address": "192.168.1.100", + "datacenter": "us-east-1", + "state": "active", + } \ No newline at end of file diff --git a/tests/unit/test_health.py b/tests/unit/test_health.py new file mode 100644 index 0000000..202dbd1 --- /dev/null +++ b/tests/unit/test_health.py @@ -0,0 +1,14 @@ +"""Tests for health endpoint.""" + +from fastapi.testclient import TestClient + +from happy_servers.api.app import app + + +def test_health_returns_ok(): + """Health endpoint returns healthy status.""" + client = TestClient(app) + response = client.get("/health") + + assert response.status_code == 200 + assert response.json()["status"] == "healthy" \ No newline at end of file diff --git a/tests/unit/test_repository.py b/tests/unit/test_repository.py new file mode 100644 index 0000000..e0a8d9b --- /dev/null +++ b/tests/unit/test_repository.py @@ -0,0 +1,95 @@ +"""Tests for repository layer.""" + +from uuid import uuid4 + +import pytest + +from happy_servers.models import ServerCreate +from happy_servers import repository + + +@pytest.mark.asyncio +async def test_create_server_calls_database(mock_db_connection, sample_server): + """create_server executes correct SQL and returns result.""" + cursor = mock_db_connection["cursor"] + cursor.fetchone.return_value = sample_server + + data = ServerCreate( + hostname="web-01", + ip_address="192.168.1.100", + datacenter="us-east-1", + ) + + result = await repository.create_server(data) + + assert result["hostname"] == "web-01" + cursor.execute.assert_called_once() + + +@pytest.mark.asyncio +async def test_get_server_calls_database(mock_db_connection, sample_server): + """get_server executes correct SQL and returns result.""" + cursor = mock_db_connection["cursor"] + cursor.fetchone.return_value = sample_server + server_id = sample_server["id"] + + result = await repository.get_server(server_id) + + assert result["id"] == server_id + cursor.execute.assert_called_once() + + +@pytest.mark.asyncio +async def test_delete_server_calls_database(mock_db_connection): + """delete_server executes correct SQL and returns True.""" + cursor = mock_db_connection["cursor"] + cursor.rowcount = 1 + server_id = uuid4() + + result = await repository.delete_server(server_id) + + assert result is True + cursor.execute.assert_called_once() + + +@pytest.mark.asyncio +async def test_update_server_calls_database(mock_db_connection, sample_server): + """update_server executes correct SQL and returns result.""" + cursor = mock_db_connection["cursor"] + cursor.fetchone.return_value = sample_server + server_id = sample_server["id"] + + from happy_servers.models import ServerUpdate + data = ServerUpdate(hostname="web-01-updated", state="offline") + + result = await repository.update_server(server_id, data) + + assert result["id"] == server_id + cursor.execute.assert_called_once() + + +@pytest.mark.asyncio +async def test_list_servers_calls_database(mock_db_connection, sample_server): + """list_servers executes correct SQL and returns results with count.""" + cursor = mock_db_connection["cursor"] + cursor.fetchall.return_value = [sample_server] + cursor.fetchone.return_value = {"count": 1} + + servers, total = await repository.list_servers( + datacenter="us-east-1", + state="active", + limit=10, + offset=20, + ) + + assert len(servers) == 1 + assert total == 1 + + # Verify execute was called twice (count + select) + assert cursor.execute.call_count == 2 + + # Verify pagination params in second call + select_call = cursor.execute.call_args_list[1] + args = select_call[0] + assert 10 in args[1] # limit + assert 20 in args[1] # offset \ No newline at end of file diff --git a/tests/unit/test_servers.py b/tests/unit/test_servers.py new file mode 100644 index 0000000..82dd1ae --- /dev/null +++ b/tests/unit/test_servers.py @@ -0,0 +1,239 @@ +"""Tests for servers endpoints.""" + +from datetime import datetime, timezone +from unittest.mock import AsyncMock, patch +from uuid import uuid4 + +from fastapi.testclient import TestClient + +from happy_servers.api.app import app + +client = TestClient(app) + + +def test_create_server(): + """Create a server calls repository with correct data.""" + server_id = uuid4() + now = datetime.now(timezone.utc) + + mock_server = { + "id": server_id, + "hostname": "web-01", + "ip_address": "192.168.1.100", + "datacenter": "us-east-1", + "state": "active", + "created_at": now, + "updated_at": now, + } + + with patch("happy_servers.api.app.repository") as mock_repo: + mock_repo.create_server = AsyncMock(return_value=mock_server) + + response = client.post( + "/servers", + json={ + "hostname": "web-01", + "ip_address": "192.168.1.100", + "datacenter": "us-east-1", + }, + ) + + assert response.status_code == 201 + mock_repo.create_server.assert_called_once() + + data = response.json() + assert data["hostname"] == "web-01" + assert data["ip_address"] == "192.168.1.100" + assert data["datacenter"] == "us-east-1" + assert data["state"] == "active" + + +def test_create_server_invalid_ip_returns_422(): + """Invalid IP address returns 422.""" + response = client.post( + "/servers", + json={ + "hostname": "web-01", + "ip_address": "not-an-ip", + "datacenter": "us-east-1", + }, + ) + + assert response.status_code == 422 + + +def test_create_server_duplicate_hostname_returns_409(): + """Duplicate hostname returns 409.""" + from happy_servers.repository import DuplicateHostnameError + + with patch("happy_servers.api.app.repository") as mock_repo: + mock_repo.create_server = AsyncMock(side_effect=DuplicateHostnameError("web-01")) + + response = client.post( + "/servers", + json={ + "hostname": "web-01", + "ip_address": "192.168.1.100", + "datacenter": "us-east-1", + }, + ) + + assert response.status_code == 409 + + +def test_create_server_invalid_state_returns_422(): + """Invalid state returns 422.""" + response = client.post( + "/servers", + json={ + "hostname": "web-01", + "ip_address": "192.168.1.100", + "datacenter": "us-east-1", + "state": "invalid-state", + }, + ) + + assert response.status_code == 422 + + +def test_get_server(): + """Get server by ID calls repository and returns server.""" + server_id = uuid4() + now = datetime.now(timezone.utc) + + mock_server = { + "id": server_id, + "hostname": "web-01", + "ip_address": "192.168.1.100", + "datacenter": "us-east-1", + "state": "active", + "created_at": now, + "updated_at": now, + } + + with patch("happy_servers.api.app.repository") as mock_repo: + mock_repo.get_server = AsyncMock(return_value=mock_server) + + response = client.get(f"/servers/{server_id}") + + assert response.status_code == 200 + mock_repo.get_server.assert_called_once_with(server_id) + + data = response.json() + assert data["hostname"] == "web-01" + + +def test_get_server_not_found_returns_404(): + """Get server returns 404 when not found.""" + server_id = uuid4() + + with patch("happy_servers.api.app.repository") as mock_repo: + mock_repo.get_server = AsyncMock(return_value=None) + + response = client.get(f"/servers/{server_id}") + + assert response.status_code == 404 + + +def test_delete_server(): + """Delete server by ID calls repository and returns 204.""" + server_id = uuid4() + + with patch("happy_servers.api.app.repository") as mock_repo: + mock_repo.delete_server = AsyncMock(return_value=True) + + response = client.delete(f"/servers/{server_id}") + + assert response.status_code == 204 + mock_repo.delete_server.assert_called_once_with(server_id) + + +def test_delete_server_not_found_returns_404(): + """Delete server returns 404 when not found.""" + server_id = uuid4() + + with patch("happy_servers.api.app.repository") as mock_repo: + mock_repo.delete_server = AsyncMock(return_value=False) + + response = client.delete(f"/servers/{server_id}") + + assert response.status_code == 404 + + +def test_update_server(): + """Update server by ID calls repository and returns updated server.""" + server_id = uuid4() + now = datetime.now(timezone.utc) + + mock_server = { + "id": server_id, + "hostname": "web-01-updated", + "ip_address": "192.168.1.100", + "datacenter": "us-east-1", + "state": "offline", + "created_at": now, + "updated_at": now, + } + + with patch("happy_servers.api.app.repository") as mock_repo: + mock_repo.update_server = AsyncMock(return_value=mock_server) + + response = client.put( + f"/servers/{server_id}", + json={"hostname": "web-01-updated", "state": "offline"}, + ) + + assert response.status_code == 200 + mock_repo.update_server.assert_called_once() + + data = response.json() + assert data["hostname"] == "web-01-updated" + assert data["state"] == "offline" + + +def test_update_server_duplicate_hostname_returns_409(): + """Update with duplicate hostname returns 409.""" + from happy_servers.repository import DuplicateHostnameError + + server_id = uuid4() + + with patch("happy_servers.api.app.repository") as mock_repo: + mock_repo.update_server = AsyncMock(side_effect=DuplicateHostnameError("web-01")) + + response = client.put( + f"/servers/{server_id}", + json={"hostname": "web-01"}, + ) + + assert response.status_code == 409 + + +def test_list_servers(): + """List servers calls repository with filters and returns paginated result.""" + server_id = uuid4() + now = datetime.now(timezone.utc) + + mock_servers = [ + { + "id": server_id, + "hostname": "web-01", + "ip_address": "192.168.1.100", + "datacenter": "us-east-1", + "state": "active", + "created_at": now, + "updated_at": now, + } + ] + + with patch("happy_servers.api.app.repository") as mock_repo: + mock_repo.list_servers = AsyncMock(return_value=(mock_servers, 1)) + + response = client.get("/servers?datacenter=us-east-1&state=active&limit=10&offset=0") + + assert response.status_code == 200 + mock_repo.list_servers.assert_called_once() + + data = response.json() + assert data["total"] == 1 + assert len(data["data"]) == 1 + assert data["data"][0]["hostname"] == "web-01" \ No newline at end of file