From a8c51731a1798f81debff491e8c0d9b933fe6bd5 Mon Sep 17 00:00:00 2001 From: DmitriKo Date: Sat, 10 Jan 2026 12:57:08 +0200 Subject: [PATCH 01/31] initial package structure --- .gitignore | 35 +++++++++ README.md | 38 ++++++++++ pyproject.toml | 135 +++++++++++++++++++++++++++++++++++ src/happy_servers/__init__py | 0 4 files changed, 208 insertions(+) create mode 100644 .gitignore create mode 100644 pyproject.toml create mode 100644 src/happy_servers/__init__py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a7e7ffd --- /dev/null +++ b/.gitignore @@ -0,0 +1,35 @@ +# 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 + +# OS +.DS_Store +Thumbs.db \ No newline at end of file diff --git a/README.md b/README.md index 3145d38..c1ccf31 100644 --- a/README.md +++ b/README.md @@ -29,3 +29,41 @@ 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 . +``` \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..d7a2da4 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,135 @@ +[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.ctl:main" + +[project.urls] +Homepage = "https://github.com/yourusername/happy-servers" +Documentation = "https://github.com/yourusername/happy-servers#readme" +Repository = "https://github.com/yourusername/happy-servers.git" +Issues = "https://github.com/yourusername/happy-servers/issues" + +[tool.hatch.build.targets.wheel] +packages = ["src/happy_servers"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +asyncio_mode = "auto" +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/src/happy_servers/__init__py b/src/happy_servers/__init__py new file mode 100644 index 0000000..e69de29 From 2c804744a325242a14d37c495667373f1c5a941c Mon Sep 17 00:00:00 2001 From: DmitriKo Date: Sat, 10 Jan 2026 13:16:29 +0200 Subject: [PATCH 02/31] initial api server --- README.md | 9 ++++++++- src/happy_servers/api/__init__.py | 31 +++++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) create mode 100644 src/happy_servers/api/__init__.py diff --git a/README.md b/README.md index c1ccf31..a7a575e 100644 --- a/README.md +++ b/README.md @@ -66,4 +66,11 @@ Start with create Python virtual environment and setup our package for local dev python -m venv .venv source .venv/bin/activate pip install -e . -``` \ No newline at end of file +``` + +Run API locally with auto reload +```bash +export HAPPY_SERVERS_DEBUG=true +hsapi +``` +You could set HAPPY_SERVERS_SERVER_PORT if you with no default (9000) port diff --git a/src/happy_servers/api/__init__.py b/src/happy_servers/api/__init__.py new file mode 100644 index 0000000..81b4fcf --- /dev/null +++ b/src/happy_servers/api/__init__.py @@ -0,0 +1,31 @@ +"""Happy Servers API entry point.""" + +import logging +import os + +import uvicorn + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + + +def main() -> None: + """Start the API server.""" + host = os.getenv("HAPPY_SERVERS_SERVER_HOST", "0.0.0.0") + port = int(os.getenv("HAPPY_SERVERS_SERVER_PORT", "9000")) + debug = os.getenv("HAPPY_SERVERS_DEBUG", "").lower() in ("true", "1", "yes") + + logger.info(f"Starting Happy Servers API on {host}:{port}") + if debug: + logger.info("Debug mode enabled, auto-reload active") + + uvicorn.run( + "happy_servers.api.app:app", + host=host, + port=port, + reload=debug, + ) + + +if __name__ == "__main__": + main() \ No newline at end of file From 22809a8541f0569a272e8947948cfc70559d6825 Mon Sep 17 00:00:00 2001 From: DmitriKo Date: Sat, 10 Jan 2026 13:34:14 +0200 Subject: [PATCH 03/31] initial docker compose and dockerfie --- Dockerfile | 36 +++++++++++++++++++++++++ compose.yaml | 46 ++++++++++++++++++++++++++++++++ migrations/README.md | 63 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 145 insertions(+) create mode 100644 Dockerfile create mode 100644 compose.yaml create mode 100644 migrations/README.md diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..5004d31 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,36 @@ +# 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 +COPY --from=builder /app/dist/*.whl /tmp/ +RUN pip install --no-cache-dir /tmp/*.whl && rm /tmp/*.whl + +# Switch to non-root user +USER appuser + +# Expose API port +EXPOSE 8000 + +# Default command runs the API +CMD ["hsapi"] \ No newline at end of file diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..52ec702 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,46 @@ +services: + db: + image: postgres:17-alpine + environment: + POSTGRES_USER: ${POSTGRES_USER:-postgres} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres} + POSTGRES_DB: ${POSTGRES_DB:-happy_servers} + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres -d happy_servers"] + interval: 5s + timeout: 5s + retries: 5 + + migrations: + image: migrate/migrate + volumes: + - ./migrations:/migrations:ro + command: + - "-path=/migrations" + - "-database=postgres://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@db:5432/${POSTGRES_DB:-happy_servers}?sslmode=disable" + - "up" + depends_on: + db: + condition: service_healthy + + api: + build: . + ports: + - "${HAPPY_SERVERS_SERVER_PORT:-9000}:${HAPPY_SERVERS_SERVER_PORT:-9000}" + environment: + HAPPY_SERVERS_DB_HOST: db + HAPPY_SERVERS_DB_PORT: 5432 + HAPPY_SERVERS_DB_USER: ${POSTGRES_USER:-postgres} + HAPPY_SERVERS_DB_PASSWORD: ${POSTGRES_PASSWORD:-postgres} + HAPPY_SERVERS_DB_NAME: ${POSTGRES_DB:-happy_servers} + HAPPY_SERVERS_SERVER_PORT: ${HAPPY_SERVERS_SERVER_PORT:-9000} + depends_on: + migrations: + condition: service_completed_successfully + +volumes: + postgres_data: 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 From d55b50192a08cc38e6b8d1f1ccfccaba3405c635 Mon Sep 17 00:00:00 2001 From: DmitriKo Date: Sat, 10 Jan 2026 13:43:08 +0200 Subject: [PATCH 04/31] db migrations --- .../000001_create_servers_table.down.sql | 7 ++++ migrations/000001_create_servers_table.up.sql | 19 ++++++++++ .../000002_create_servers_history.down.sql | 12 ++++++ .../000002_create_servers_history.up.sql | 38 +++++++++++++++++++ 4 files changed, 76 insertions(+) create mode 100644 migrations/000001_create_servers_table.down.sql create mode 100644 migrations/000001_create_servers_table.up.sql create mode 100644 migrations/000002_create_servers_history.down.sql create mode 100644 migrations/000002_create_servers_history.up.sql 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(); From 5da470df74ab2100573561d63d62ecb55f38df9b Mon Sep 17 00:00:00 2001 From: DmitriKo Date: Sat, 10 Jan 2026 13:54:56 +0200 Subject: [PATCH 05/31] git guardian to ignore compose --- .gitguardian.yaml | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 .gitguardian.yaml 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 From 4477dbeeaf880287461f4d152a6aa8c1dd23c07d Mon Sep 17 00:00:00 2001 From: DmitriKo Date: Sat, 10 Jan 2026 14:18:33 +0200 Subject: [PATCH 06/31] scafolding unit testing --- README.md | 2 +- pyproject.toml | 1 - tests/unit/conftest.py | 47 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 48 insertions(+), 2 deletions(-) create mode 100644 tests/unit/conftest.py diff --git a/README.md b/README.md index a7a575e..10b2472 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,7 @@ Start with create Python virtual environment and setup our package for local dev ```bash python -m venv .venv source .venv/bin/activate -pip install -e . +pip install -e ".[dev]" ``` Run API locally with auto reload diff --git a/pyproject.toml b/pyproject.toml index d7a2da4..eda0e8f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -64,7 +64,6 @@ packages = ["src/happy_servers"] [tool.pytest.ini_options] testpaths = ["tests"] -asyncio_mode = "auto" asyncio_default_fixture_loop_scope = "function" addopts = [ "-v", diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py new file mode 100644 index 0000000..5bf0bcb --- /dev/null +++ b/tests/unit/conftest.py @@ -0,0 +1,47 @@ +"""Pytest fixtures for unit tests. No real database, all mocked.""" + +from typing import Any +from unittest.mock import MagicMock, patch +from uuid import uuid4 + +import pytest + + +@pytest.fixture +def mock_db_connection(): + """Mock database connection.""" + with patch("happy_servers.database.get_connection") as mock: + conn = MagicMock() + cursor = MagicMock() + conn.__enter__ = MagicMock(return_value=conn) + conn.__exit__ = MagicMock(return_value=False) + conn.cursor.return_value.__enter__ = MagicMock(return_value=cursor) + conn.cursor.return_value.__exit__ = MagicMock(return_value=False) + mock.return_value.__enter__ = MagicMock(return_value=conn) + mock.return_value.__exit__ = MagicMock(return_value=False) + yield {"connection": conn, "cursor": cursor} + + +@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 From 64f21e0ce9506c99a3ac513ad915d3847e1941bf Mon Sep 17 00:00:00 2001 From: DmitriKo Date: Sat, 10 Jan 2026 14:24:53 +0200 Subject: [PATCH 07/31] health endpoint --- src/happy_servers/__init__.py | 3 +++ src/happy_servers/__init__py | 0 src/happy_servers/api/app.py | 17 +++++++++++++++++ tests/unit/test_health.py | 14 ++++++++++++++ 4 files changed, 34 insertions(+) create mode 100644 src/happy_servers/__init__.py delete mode 100644 src/happy_servers/__init__py create mode 100644 src/happy_servers/api/app.py create mode 100644 tests/unit/test_health.py 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/__init__py b/src/happy_servers/__init__py deleted file mode 100644 index e69de29..0000000 diff --git a/src/happy_servers/api/app.py b/src/happy_servers/api/app.py new file mode 100644 index 0000000..884b2a8 --- /dev/null +++ b/src/happy_servers/api/app.py @@ -0,0 +1,17 @@ +"""FastAPI application.""" + +from fastapi import FastAPI + +from happy_servers import __version__ + +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__} \ 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 From 650087cdd76ba32cab6391b7295556927725051b Mon Sep 17 00:00:00 2001 From: DmitriKo Date: Sat, 10 Jan 2026 14:38:33 +0200 Subject: [PATCH 08/31] initial POST /servers endpoint --- src/happy_servers/api/app.py | 20 +++++++++++++++++++- src/happy_servers/models.py | 23 +++++++++++++++++++++++ tests/unit/test_servers.py | 27 +++++++++++++++++++++++++++ 3 files changed, 69 insertions(+), 1 deletion(-) create mode 100644 src/happy_servers/models.py create mode 100644 tests/unit/test_servers.py diff --git a/src/happy_servers/api/app.py b/src/happy_servers/api/app.py index 884b2a8..4f3d016 100644 --- a/src/happy_servers/api/app.py +++ b/src/happy_servers/api/app.py @@ -1,8 +1,12 @@ """FastAPI application.""" from fastapi import FastAPI +from fastapi import FastAPI, status +from datetime import datetime, timezone +from uuid import uuid4 from happy_servers import __version__ +from happy_servers.models import Server, ServerCreate app = FastAPI( title="Happy Servers API", @@ -14,4 +18,18 @@ @app.get("/health") async def health(): """Health check endpoint.""" - return {"status": "healthy", "version": __version__} \ No newline at end of file + 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.""" + now = datetime.now(timezone.utc) + return Server( + id=uuid4(), + hostname=data.hostname, + ip_address=data.ip_address, + datacenter=data.datacenter, + state=data.state, + created_at=now, + updated_at=now, + ) \ 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..af66d66 --- /dev/null +++ b/src/happy_servers/models.py @@ -0,0 +1,23 @@ +"""Pydantic models for request/response validation.""" + +from datetime import datetime +from uuid import UUID + +from pydantic import BaseModel + + +class ServerCreate(BaseModel): + hostname: str + ip_address: str + datacenter: str + state: str = "active" + + +class Server(BaseModel): + id: UUID + hostname: str + ip_address: str + datacenter: str + state: str + created_at: datetime + updated_at: datetime diff --git a/tests/unit/test_servers.py b/tests/unit/test_servers.py new file mode 100644 index 0000000..3e7472c --- /dev/null +++ b/tests/unit/test_servers.py @@ -0,0 +1,27 @@ +"""Tests for servers endpoints.""" + +from fastapi.testclient import TestClient + +from happy_servers.api.app import app + +client = TestClient(app) + + +def test_create_server(): + """Create a server with valid data.""" + response = client.post( + "/servers", + json={ + "hostname": "web-01", + "ip_address": "192.168.1.100", + "datacenter": "us-east-1", + }, + ) + + assert response.status_code == 201 + 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" + assert "id" in data \ No newline at end of file From 61b289b0e1d18358f6d95bb6778deb02094788a4 Mon Sep 17 00:00:00 2001 From: DmitriKo Date: Sat, 10 Jan 2026 14:49:08 +0200 Subject: [PATCH 09/31] better server create test --- src/happy_servers/api/app.py | 15 ++++------ src/happy_servers/repository.py | 8 +++++ tests/unit/test_servers.py | 53 +++++++++++++++++++++++---------- 3 files changed, 50 insertions(+), 26 deletions(-) create mode 100644 src/happy_servers/repository.py diff --git a/src/happy_servers/api/app.py b/src/happy_servers/api/app.py index 4f3d016..5dfb738 100644 --- a/src/happy_servers/api/app.py +++ b/src/happy_servers/api/app.py @@ -7,6 +7,8 @@ from happy_servers import __version__ from happy_servers.models import Server, ServerCreate +from happy_servers import repository + app = FastAPI( title="Happy Servers API", @@ -20,16 +22,9 @@ 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.""" - now = datetime.now(timezone.utc) - return Server( - id=uuid4(), - hostname=data.hostname, - ip_address=data.ip_address, - datacenter=data.datacenter, - state=data.state, - created_at=now, - updated_at=now, - ) \ No newline at end of file + result = await repository.create_server(data) + return Server.model_validate(result) diff --git a/src/happy_servers/repository.py b/src/happy_servers/repository.py new file mode 100644 index 0000000..4bab8cd --- /dev/null +++ b/src/happy_servers/repository.py @@ -0,0 +1,8 @@ +"""Repository for server data access.""" + +from happy_servers.models import ServerCreate + + +async def create_server(data: ServerCreate) -> dict: + """Create a new server in the database.""" + raise NotImplementedError("Database not configured") diff --git a/tests/unit/test_servers.py b/tests/unit/test_servers.py index 3e7472c..7007de9 100644 --- a/tests/unit/test_servers.py +++ b/tests/unit/test_servers.py @@ -1,5 +1,9 @@ """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 @@ -8,20 +12,37 @@ def test_create_server(): - """Create a server with valid data.""" - response = client.post( - "/servers", - json={ - "hostname": "web-01", - "ip_address": "192.168.1.100", - "datacenter": "us-east-1", - }, - ) + """Create a server calls repository with correct data.""" + server_id = uuid4() + now = datetime.now(timezone.utc) - assert response.status_code == 201 - 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" - assert "id" in data \ No newline at end of file + 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" \ No newline at end of file From 4084963e4494e31c5847f246cf1c674c5ef270dd Mon Sep 17 00:00:00 2001 From: DmitriKo Date: Sat, 10 Jan 2026 14:56:40 +0200 Subject: [PATCH 10/31] persist created server --- src/happy_servers/repository.py | 34 ++++++++++++++++++++++++++++++++- tests/unit/conftest.py | 26 +++++++++++++++---------- tests/unit/test_repository.py | 24 +++++++++++++++++++++++ 3 files changed, 73 insertions(+), 11 deletions(-) create mode 100644 tests/unit/test_repository.py diff --git a/src/happy_servers/repository.py b/src/happy_servers/repository.py index 4bab8cd..4549bb3 100644 --- a/src/happy_servers/repository.py +++ b/src/happy_servers/repository.py @@ -1,8 +1,40 @@ """Repository for server data access.""" +from contextlib import asynccontextmanager + +from psycopg_pool import AsyncConnectionPool + from happy_servers.models import ServerCreate +_pool: AsyncConnectionPool | None = None + + +def init_pool(conninfo: str) -> None: + """Initialize the connection pool.""" + global _pool + _pool = AsyncConnectionPool(conninfo) + + +@asynccontextmanager +async def get_connection(): + """Get a connection from the pool.""" + if _pool is None: + raise RuntimeError("Database pool not initialized") + async with _pool.connection() as conn: + yield conn + async def create_server(data: ServerCreate) -> dict: """Create a new server in the database.""" - raise NotImplementedError("Database not configured") + async with get_connection() as conn: + async with conn.cursor() as cursor: + 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), + ) + row = cursor.fetchone() + return row \ No newline at end of file diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py index 5bf0bcb..3b83b85 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -1,7 +1,7 @@ """Pytest fixtures for unit tests. No real database, all mocked.""" from typing import Any -from unittest.mock import MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, patch from uuid import uuid4 import pytest @@ -9,17 +9,23 @@ @pytest.fixture def mock_db_connection(): - """Mock database connection.""" - with patch("happy_servers.database.get_connection") as mock: + """Mock database connection for repository.""" + with patch("happy_servers.repository.get_connection") as mock: conn = MagicMock() cursor = MagicMock() - conn.__enter__ = MagicMock(return_value=conn) - conn.__exit__ = MagicMock(return_value=False) - conn.cursor.return_value.__enter__ = MagicMock(return_value=cursor) - conn.cursor.return_value.__exit__ = MagicMock(return_value=False) - mock.return_value.__enter__ = MagicMock(return_value=conn) - mock.return_value.__exit__ = MagicMock(return_value=False) - yield {"connection": conn, "cursor": cursor} + + # Make execute async + cursor.execute = 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 diff --git a/tests/unit/test_repository.py b/tests/unit/test_repository.py new file mode 100644 index 0000000..0e85d39 --- /dev/null +++ b/tests/unit/test_repository.py @@ -0,0 +1,24 @@ +"""Tests for repository layer.""" + +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() \ No newline at end of file From 4e3278694eeb5fe5ffbb570ab1294e0a345021f1 Mon Sep 17 00:00:00 2001 From: DmitriKo Date: Sat, 10 Jan 2026 14:58:52 +0200 Subject: [PATCH 11/31] ip validation --- src/happy_servers/models.py | 18 ++++++++++++++++-- tests/unit/test_servers.py | 16 +++++++++++++++- 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/src/happy_servers/models.py b/src/happy_servers/models.py index af66d66..9e20249 100644 --- a/src/happy_servers/models.py +++ b/src/happy_servers/models.py @@ -1,9 +1,16 @@ """Pydantic models for request/response validation.""" +import re from datetime import datetime from uuid import UUID -from pydantic import BaseModel +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 ServerCreate(BaseModel): @@ -12,6 +19,13 @@ class ServerCreate(BaseModel): datacenter: str state: str = "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 Server(BaseModel): id: UUID @@ -20,4 +34,4 @@ class Server(BaseModel): datacenter: str state: str created_at: datetime - updated_at: datetime + updated_at: datetime \ No newline at end of file diff --git a/tests/unit/test_servers.py b/tests/unit/test_servers.py index 7007de9..6ce8763 100644 --- a/tests/unit/test_servers.py +++ b/tests/unit/test_servers.py @@ -45,4 +45,18 @@ def test_create_server(): assert data["hostname"] == "web-01" assert data["ip_address"] == "192.168.1.100" assert data["datacenter"] == "us-east-1" - assert data["state"] == "active" \ No newline at end of file + 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 \ No newline at end of file From 2a48f75418cf726d5481938934d4ceb1310b3f0d Mon Sep 17 00:00:00 2001 From: DmitriKo Date: Sat, 10 Jan 2026 16:08:17 +0200 Subject: [PATCH 12/31] implement get server endpoint --- src/happy_servers/api/app.py | 17 ++++++++++++----- src/happy_servers/repository.py | 17 +++++++++++++++++ tests/unit/test_repository.py | 15 +++++++++++++++ tests/unit/test_servers.py | 30 +++++++++++++++++++++++++++++- 4 files changed, 73 insertions(+), 6 deletions(-) diff --git a/src/happy_servers/api/app.py b/src/happy_servers/api/app.py index 5dfb738..a706c0f 100644 --- a/src/happy_servers/api/app.py +++ b/src/happy_servers/api/app.py @@ -1,15 +1,13 @@ """FastAPI application.""" -from fastapi import FastAPI -from fastapi import FastAPI, status -from datetime import datetime, timezone -from uuid import uuid4 +from uuid import UUID + +from fastapi import FastAPI, HTTPException, status from happy_servers import __version__ from happy_servers.models import Server, ServerCreate from happy_servers import repository - app = FastAPI( title="Happy Servers API", description="Inventory management for cloud server provisioning", @@ -28,3 +26,12 @@ async def create_server(data: ServerCreate) -> Server: """Create a new server.""" result = await repository.create_server(data) return Server.model_validate(result) + + +@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) \ No newline at end of file diff --git a/src/happy_servers/repository.py b/src/happy_servers/repository.py index 4549bb3..61230cd 100644 --- a/src/happy_servers/repository.py +++ b/src/happy_servers/repository.py @@ -1,6 +1,7 @@ """Repository for server data access.""" from contextlib import asynccontextmanager +from uuid import UUID from psycopg_pool import AsyncConnectionPool @@ -37,4 +38,20 @@ async def create_server(data: ServerCreate) -> dict: (data.hostname, data.ip_address, data.datacenter, data.state), ) row = 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() as cursor: + await cursor.execute( + """ + SELECT id, hostname, ip_address, datacenter, state, created_at, updated_at + FROM servers + WHERE id = %s + """, + (server_id,), + ) + row = cursor.fetchone() return row \ No newline at end of file diff --git a/tests/unit/test_repository.py b/tests/unit/test_repository.py index 0e85d39..e927e0e 100644 --- a/tests/unit/test_repository.py +++ b/tests/unit/test_repository.py @@ -1,5 +1,7 @@ """Tests for repository layer.""" +from uuid import uuid4 + import pytest from happy_servers.models import ServerCreate @@ -21,4 +23,17 @@ async def test_create_server_calls_database(mock_db_connection, sample_server): 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() \ No newline at end of file diff --git a/tests/unit/test_servers.py b/tests/unit/test_servers.py index 6ce8763..d0ba235 100644 --- a/tests/unit/test_servers.py +++ b/tests/unit/test_servers.py @@ -59,4 +59,32 @@ def test_create_server_invalid_ip_returns_422(): }, ) - assert response.status_code == 422 \ No newline at end of file + 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" + assert data["ip_address"] == "192.168.1.100" \ No newline at end of file From 1a190801f81055d03c3e73ab44d4f92a9680921f Mon Sep 17 00:00:00 2001 From: DmitriKo Date: Sat, 10 Jan 2026 16:15:25 +0200 Subject: [PATCH 13/31] delete server endpoint --- src/happy_servers/api/app.py | 10 +++++++++- src/happy_servers/repository.py | 16 +++++++++++++++- tests/unit/test_repository.py | 13 +++++++++++++ tests/unit/test_servers.py | 14 +++++++++++++- 4 files changed, 50 insertions(+), 3 deletions(-) diff --git a/src/happy_servers/api/app.py b/src/happy_servers/api/app.py index a706c0f..eb5373c 100644 --- a/src/happy_servers/api/app.py +++ b/src/happy_servers/api/app.py @@ -34,4 +34,12 @@ async def get_server(server_id: UUID) -> Server: 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) \ No newline at end of file + 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") \ No newline at end of file diff --git a/src/happy_servers/repository.py b/src/happy_servers/repository.py index 61230cd..c953780 100644 --- a/src/happy_servers/repository.py +++ b/src/happy_servers/repository.py @@ -54,4 +54,18 @@ async def get_server(server_id: UUID) -> dict | None: (server_id,), ) row = cursor.fetchone() - return row \ No newline at end of file + 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 \ No newline at end of file diff --git a/tests/unit/test_repository.py b/tests/unit/test_repository.py index e927e0e..b375ae0 100644 --- a/tests/unit/test_repository.py +++ b/tests/unit/test_repository.py @@ -36,4 +36,17 @@ async def test_get_server_calls_database(mock_db_connection, sample_server): 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() \ No newline at end of file diff --git a/tests/unit/test_servers.py b/tests/unit/test_servers.py index d0ba235..170d905 100644 --- a/tests/unit/test_servers.py +++ b/tests/unit/test_servers.py @@ -87,4 +87,16 @@ def test_get_server(): data = response.json() assert data["hostname"] == "web-01" - assert data["ip_address"] == "192.168.1.100" \ No newline at end of file + + +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) From 28473f4385cdd1ad09dff8d34563d2a125d526f3 Mon Sep 17 00:00:00 2001 From: DmitriKo Date: Sat, 10 Jan 2026 16:25:43 +0200 Subject: [PATCH 14/31] PUT endpoint --- src/happy_servers/api/app.py | 13 ++++++++-- src/happy_servers/models.py | 14 +++++++++++ src/happy_servers/repository.py | 44 +++++++++++++++++++++++++++++++-- tests/unit/test_repository.py | 18 +++++++++++++- tests/unit/test_servers.py | 32 ++++++++++++++++++++++++ 5 files changed, 116 insertions(+), 5 deletions(-) diff --git a/src/happy_servers/api/app.py b/src/happy_servers/api/app.py index eb5373c..4974b46 100644 --- a/src/happy_servers/api/app.py +++ b/src/happy_servers/api/app.py @@ -5,7 +5,7 @@ from fastapi import FastAPI, HTTPException, status from happy_servers import __version__ -from happy_servers.models import Server, ServerCreate +from happy_servers.models import Server, ServerCreate, ServerUpdate from happy_servers import repository app = FastAPI( @@ -42,4 +42,13 @@ 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") \ No newline at end of file + 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.""" + result = await repository.update_server(server_id, data) + 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/models.py b/src/happy_servers/models.py index 9e20249..730879a 100644 --- a/src/happy_servers/models.py +++ b/src/happy_servers/models.py @@ -27,6 +27,20 @@ def validate_ip_address(cls, v: str) -> str: return v +class ServerUpdate(BaseModel): + hostname: str | None = None + ip_address: str | None = None + datacenter: str | None = None + state: str | 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 diff --git a/src/happy_servers/repository.py b/src/happy_servers/repository.py index c953780..75feaeb 100644 --- a/src/happy_servers/repository.py +++ b/src/happy_servers/repository.py @@ -5,7 +5,7 @@ from psycopg_pool import AsyncConnectionPool -from happy_servers.models import ServerCreate +from happy_servers.models import ServerCreate, ServerUpdate _pool: AsyncConnectionPool | None = None @@ -68,4 +68,44 @@ async def delete_server(server_id: UUID) -> bool: """, (server_id,), ) - return cursor.rowcount > 0 \ No newline at end of file + 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) + + 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() 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 = cursor.fetchone() + return row \ No newline at end of file diff --git a/tests/unit/test_repository.py b/tests/unit/test_repository.py index b375ae0..6c67692 100644 --- a/tests/unit/test_repository.py +++ b/tests/unit/test_repository.py @@ -49,4 +49,20 @@ async def test_delete_server_calls_database(mock_db_connection): result = await repository.delete_server(server_id) assert result is True - cursor.execute.assert_called_once() \ No newline at end of file + 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() diff --git a/tests/unit/test_servers.py b/tests/unit/test_servers.py index 170d905..86ad57f 100644 --- a/tests/unit/test_servers.py +++ b/tests/unit/test_servers.py @@ -100,3 +100,35 @@ def test_delete_server(): assert response.status_code == 204 mock_repo.delete_server.assert_called_once_with(server_id) + + +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" + From 54d25d4f54de11e76f3df3b4a17a0f5feab0d90f Mon Sep 17 00:00:00 2001 From: DmitriKo Date: Sat, 10 Jan 2026 16:39:48 +0200 Subject: [PATCH 15/31] GET /servers --- src/happy_servers/api/app.py | 26 ++++++++++++++++- src/happy_servers/models.py | 9 +++++- src/happy_servers/repository.py | 52 ++++++++++++++++++++++++++++++++- tests/unit/test_repository.py | 27 +++++++++++++++++ tests/unit/test_servers.py | 30 +++++++++++++++++++ 5 files changed, 141 insertions(+), 3 deletions(-) diff --git a/src/happy_servers/api/app.py b/src/happy_servers/api/app.py index 4974b46..fecf0da 100644 --- a/src/happy_servers/api/app.py +++ b/src/happy_servers/api/app.py @@ -5,7 +5,7 @@ from fastapi import FastAPI, HTTPException, status from happy_servers import __version__ -from happy_servers.models import Server, ServerCreate, ServerUpdate +from happy_servers.models import Server, ServerCreate, ServerUpdate, ServerList from happy_servers import repository app = FastAPI( @@ -28,6 +28,30 @@ async def create_server(data: ServerCreate) -> Server: 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.""" diff --git a/src/happy_servers/models.py b/src/happy_servers/models.py index 730879a..317da92 100644 --- a/src/happy_servers/models.py +++ b/src/happy_servers/models.py @@ -48,4 +48,11 @@ class Server(BaseModel): datacenter: str state: str created_at: datetime - updated_at: datetime \ No newline at end of file + 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 index 75feaeb..3941eda 100644 --- a/src/happy_servers/repository.py +++ b/src/happy_servers/repository.py @@ -108,4 +108,54 @@ async def update_server(server_id: UUID, data: ServerUpdate) -> dict | None: values, ) row = cursor.fetchone() - return row \ No newline at end of file + 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() as cursor: + # Get total count + await cursor.execute( + f"SELECT COUNT(*) as count FROM servers {where_clause}", + values, + ) + total = cursor.fetchone()["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 = cursor.fetchall() + + return servers, total \ No newline at end of file diff --git a/tests/unit/test_repository.py b/tests/unit/test_repository.py index 6c67692..e0a8d9b 100644 --- a/tests/unit/test_repository.py +++ b/tests/unit/test_repository.py @@ -66,3 +66,30 @@ async def test_update_server_calls_database(mock_db_connection, sample_server): 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 index 86ad57f..e55ad20 100644 --- a/tests/unit/test_servers.py +++ b/tests/unit/test_servers.py @@ -132,3 +132,33 @@ def test_update_server(): assert data["hostname"] == "web-01-updated" assert data["state"] == "offline" + +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 From 0b9889d384cf00aa93b58bb2370569431af2897f Mon Sep 17 00:00:00 2001 From: DmitriKo Date: Sat, 10 Jan 2026 16:43:45 +0200 Subject: [PATCH 16/31] hostname is unique --- src/happy_servers/api/app.py | 6 +++++- src/happy_servers/repository.py | 6 ++++++ tests/unit/test_servers.py | 19 +++++++++++++++++++ 3 files changed, 30 insertions(+), 1 deletion(-) diff --git a/src/happy_servers/api/app.py b/src/happy_servers/api/app.py index fecf0da..2b9b280 100644 --- a/src/happy_servers/api/app.py +++ b/src/happy_servers/api/app.py @@ -7,6 +7,7 @@ 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", @@ -24,7 +25,10 @@ async def health(): @app.post("/servers", status_code=status.HTTP_201_CREATED) async def create_server(data: ServerCreate) -> Server: """Create a new server.""" - result = await repository.create_server(data) + try: + result = await repository.create_server(data) + except DuplicateHostnameError: + raise HTTPException(status_code=409, detail="Hostname already exists") return Server.model_validate(result) diff --git a/src/happy_servers/repository.py b/src/happy_servers/repository.py index 3941eda..7c14b20 100644 --- a/src/happy_servers/repository.py +++ b/src/happy_servers/repository.py @@ -7,6 +7,12 @@ from happy_servers.models import ServerCreate, ServerUpdate + +class DuplicateHostnameError(Exception): + """Raised when hostname already exists.""" + pass + + _pool: AsyncConnectionPool | None = None diff --git a/tests/unit/test_servers.py b/tests/unit/test_servers.py index e55ad20..2de5513 100644 --- a/tests/unit/test_servers.py +++ b/tests/unit/test_servers.py @@ -62,6 +62,25 @@ def test_create_server_invalid_ip_returns_422(): 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_get_server(): """Get server by ID calls repository and returns server.""" server_id = uuid4() From e4adc6b01a3df554647e4a097b3d62c0f013aff8 Mon Sep 17 00:00:00 2001 From: DmitriKo Date: Sat, 10 Jan 2026 16:48:24 +0200 Subject: [PATCH 17/31] state validation --- src/happy_servers/models.py | 11 +++++++++-- tests/unit/test_servers.py | 27 +++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/src/happy_servers/models.py b/src/happy_servers/models.py index 317da92..1e309e9 100644 --- a/src/happy_servers/models.py +++ b/src/happy_servers/models.py @@ -2,6 +2,7 @@ import re from datetime import datetime +from enum import Enum from uuid import UUID from pydantic import BaseModel, field_validator @@ -13,11 +14,17 @@ ) +class ServerState(str, Enum): + ACTIVE = "active" + OFFLINE = "offline" + RETIRED = "retired" + + class ServerCreate(BaseModel): hostname: str ip_address: str datacenter: str - state: str = "active" + state: ServerState = ServerState.ACTIVE @field_validator("ip_address") @classmethod @@ -31,7 +38,7 @@ class ServerUpdate(BaseModel): hostname: str | None = None ip_address: str | None = None datacenter: str | None = None - state: str | None = None + state: ServerState | None = None @field_validator("ip_address") @classmethod diff --git a/tests/unit/test_servers.py b/tests/unit/test_servers.py index 2de5513..cb57372 100644 --- a/tests/unit/test_servers.py +++ b/tests/unit/test_servers.py @@ -81,6 +81,21 @@ def test_create_server_duplicate_hostname_returns_409(): 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() @@ -108,6 +123,18 @@ def test_get_server(): 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() From 2c87922e3244a2d85e2e19f4477246c8f22b53aa Mon Sep 17 00:00:00 2001 From: DmitriKo Date: Sat, 10 Jan 2026 16:48:56 +0200 Subject: [PATCH 18/31] test 404 on missing delete --- tests/unit/test_servers.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/unit/test_servers.py b/tests/unit/test_servers.py index cb57372..4297544 100644 --- a/tests/unit/test_servers.py +++ b/tests/unit/test_servers.py @@ -148,6 +148,18 @@ def test_delete_server(): 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() From 12f4ca7e3a61894cbf603687b2f857da5c3093bc Mon Sep 17 00:00:00 2001 From: DmitriKo Date: Sat, 10 Jan 2026 16:50:28 +0200 Subject: [PATCH 19/31] minor improvements --- src/happy_servers/api/app.py | 5 ++++- tests/unit/test_servers.py | 17 +++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/src/happy_servers/api/app.py b/src/happy_servers/api/app.py index 2b9b280..8bf71ff 100644 --- a/src/happy_servers/api/app.py +++ b/src/happy_servers/api/app.py @@ -76,7 +76,10 @@ async def delete_server(server_id: UUID) -> None: @app.put("/servers/{server_id}") async def update_server(server_id: UUID, data: ServerUpdate) -> Server: """Update a server by ID.""" - result = await repository.update_server(server_id, data) + 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/tests/unit/test_servers.py b/tests/unit/test_servers.py index 4297544..82dd1ae 100644 --- a/tests/unit/test_servers.py +++ b/tests/unit/test_servers.py @@ -191,6 +191,23 @@ def test_update_server(): 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() From c3e64c685502c21140d28df5f8a50b781fee5348 Mon Sep 17 00:00:00 2001 From: DmitriKo Date: Sat, 10 Jan 2026 17:05:17 +0200 Subject: [PATCH 20/31] close pool method --- src/happy_servers/repository.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/happy_servers/repository.py b/src/happy_servers/repository.py index 7c14b20..16f2553 100644 --- a/src/happy_servers/repository.py +++ b/src/happy_servers/repository.py @@ -22,6 +22,14 @@ def init_pool(conninfo: str) -> None: _pool = AsyncConnectionPool(conninfo) +def close_pool() -> None: + """Close the connection pool.""" + global _pool + if _pool is not None: + _pool.close() + _pool = None + + @asynccontextmanager async def get_connection(): """Get a connection from the pool.""" From f4dd7c896234b960751da3b89f4b5da58eb6f265 Mon Sep 17 00:00:00 2001 From: DmitriKo Date: Sat, 10 Jan 2026 18:21:06 +0200 Subject: [PATCH 21/31] better test servce in compose --- compose.yaml | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/compose.yaml b/compose.yaml index 52ec702..489684d 100644 --- a/compose.yaml +++ b/compose.yaml @@ -42,5 +42,22 @@ services: migrations: condition: service_completed_successfully + test: + profiles: ["test"] + build: . + command: pytest tests/integration/ -v + environment: + HAPPY_SERVERS_DB_HOST: db + HAPPY_SERVERS_DB_PORT: 5432 + HAPPY_SERVERS_DB_USER: ${POSTGRES_USER:-postgres} + HAPPY_SERVERS_DB_PASSWORD: ${POSTGRES_PASSWORD:-postgres} + volumes: + - ./src:/app/src:ro + - ./tests:/app/tests:ro + - ./migrations:/app/migrations:ro + depends_on: + db: + condition: service_healthy + volumes: postgres_data: From 1d8a50b6a77b89fd9ddeee7a9bb7e61f62997c5c Mon Sep 17 00:00:00 2001 From: DmitriKo Date: Sat, 10 Jan 2026 18:21:36 +0200 Subject: [PATCH 22/31] put pytest into dockerfile so intergration tests ran inside compose --- Dockerfile | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index 5004d31..8cfc3a8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -22,9 +22,15 @@ WORKDIR /app # Create non-root user RUN useradd --create-home --shell /bin/bash appuser -# Install the wheel from builder +# Install the wheel from builder with dev dependencies COPY --from=builder /app/dist/*.whl /tmp/ -RUN pip install --no-cache-dir /tmp/*.whl && rm /tmp/*.whl +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 From 49b3ea1b67eabe041dff98fb57c132209f5811a4 Mon Sep 17 00:00:00 2001 From: DmitriKo Date: Sat, 10 Jan 2026 19:13:07 +0200 Subject: [PATCH 23/31] initial version of integration tests --- README.md | 14 +++- compose.yaml | 8 +- src/happy_servers/repository.py | 65 +++++++++------ tests/integration/conftest.py | 85 ++++++++++++++++++++ tests/integration/test_servers.py | 126 ++++++++++++++++++++++++++++++ 5 files changed, 270 insertions(+), 28 deletions(-) create mode 100644 tests/integration/conftest.py create mode 100644 tests/integration/test_servers.py diff --git a/README.md b/README.md index 10b2472..4c41ab0 100644 --- a/README.md +++ b/README.md @@ -73,4 +73,16 @@ Run API locally with auto reload export HAPPY_SERVERS_DEBUG=true hsapi ``` -You could set HAPPY_SERVERS_SERVER_PORT if you with no default (9000) port +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 is safe to run: +```bash +pytest ./tests/unit/ +``` +## Integration +Integration tests are using a real database server. The test database gets created before every test. Run it inside docker compose environment. +```bash +docker compose --profile test run --rm test +``` \ No newline at end of file diff --git a/compose.yaml b/compose.yaml index 489684d..c4bcda9 100644 --- a/compose.yaml +++ b/compose.yaml @@ -45,16 +45,20 @@ services: test: profiles: ["test"] build: . - command: pytest tests/integration/ -v + command: pytest tests/integration/ -v -p no:cacheprovider environment: HAPPY_SERVERS_DB_HOST: db HAPPY_SERVERS_DB_PORT: 5432 HAPPY_SERVERS_DB_USER: ${POSTGRES_USER:-postgres} HAPPY_SERVERS_DB_PASSWORD: ${POSTGRES_PASSWORD:-postgres} volumes: - - ./src:/app/src:ro - ./tests:/app/tests:ro - ./migrations:/app/migrations:ro + - ./pyproject.toml:/app/pyproject.toml:ro + develop: + watch: + - action: rebuild + path: ./src depends_on: db: condition: service_healthy diff --git a/src/happy_servers/repository.py b/src/happy_servers/repository.py index 16f2553..6e9d2e5 100644 --- a/src/happy_servers/repository.py +++ b/src/happy_servers/repository.py @@ -1,8 +1,10 @@ """Repository for server data access.""" +import os from contextlib import asynccontextmanager from uuid import UUID +from psycopg.rows import dict_row from psycopg_pool import AsyncConnectionPool from happy_servers.models import ServerCreate, ServerUpdate @@ -16,49 +18,61 @@ class DuplicateHostnameError(Exception): _pool: AsyncConnectionPool | None = None -def init_pool(conninfo: str) -> None: - """Initialize the connection pool.""" - global _pool - _pool = AsyncConnectionPool(conninfo) +def _get_conninfo() -> str: + """Build connection string from environment.""" + 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") + name = os.getenv("HAPPY_SERVERS_DB_NAME", "happy_servers") + return f"postgresql://{user}:{password}@{host}:{port}/{name}" -def close_pool() -> None: +async def close_pool() -> None: """Close the connection pool.""" global _pool if _pool is not None: - _pool.close() + await _pool.close() _pool = None @asynccontextmanager async def get_connection(): """Get a connection from the pool.""" + global _pool if _pool is None: - raise RuntimeError("Database pool not initialized") + _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() as cursor: - 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), - ) - row = cursor.fetchone() + 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() as cursor: + async with conn.cursor(row_factory=dict_row) as cursor: await cursor.execute( """ SELECT id, hostname, ip_address, datacenter, state, created_at, updated_at @@ -67,7 +81,7 @@ async def get_server(server_id: UUID) -> dict | None: """, (server_id,), ) - row = cursor.fetchone() + row = await cursor.fetchone() return row @@ -101,7 +115,7 @@ async def update_server(server_id: UUID, data: ServerUpdate) -> dict | None: values.append(data.datacenter) if data.state is not None: fields.append("state = %s") - values.append(data.state) + values.append(data.state.value) if not fields: # Nothing to update, just return current @@ -111,7 +125,7 @@ async def update_server(server_id: UUID, data: ServerUpdate) -> dict | None: values.append(server_id) async with get_connection() as conn: - async with conn.cursor() as cursor: + async with conn.cursor(row_factory=dict_row) as cursor: await cursor.execute( f""" UPDATE servers @@ -121,7 +135,7 @@ async def update_server(server_id: UUID, data: ServerUpdate) -> dict | None: """, values, ) - row = cursor.fetchone() + row = await cursor.fetchone() return row @@ -151,13 +165,14 @@ async def list_servers( where_clause = "WHERE " + " AND ".join(conditions) async with get_connection() as conn: - async with conn.cursor() as cursor: + 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, ) - total = cursor.fetchone()["count"] + count_row = await cursor.fetchone() + total = count_row["count"] # Get paginated results await cursor.execute( @@ -170,6 +185,6 @@ async def list_servers( """, values + [limit, offset], ) - servers = cursor.fetchall() + servers = await cursor.fetchall() return servers, total \ No newline at end of file diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py new file mode 100644 index 0000000..32c9f71 --- /dev/null +++ b/tests/integration/conftest.py @@ -0,0 +1,85 @@ +"""Pytest fixtures for integration tests with real PostgreSQL.""" + +import os + +import psycopg +import pytest + +# Test database name - MUST have test_ prefix +TEST_DB_NAME = "test_happy_servers" + + +def get_db_config(): + """Get database configuration from environment.""" + return { + "host": os.getenv("HAPPY_SERVERS_DB_HOST", "localhost"), + "port": int(os.getenv("HAPPY_SERVERS_DB_PORT", "5432")), + "user": os.getenv("HAPPY_SERVERS_DB_USER", "postgres"), + "password": os.getenv("HAPPY_SERVERS_DB_PASSWORD", "postgres"), + } + + +def get_admin_conninfo(): + """Connection string for admin operations (create/drop DB).""" + cfg = get_db_config() + return f"postgresql://{cfg['user']}:{cfg['password']}@{cfg['host']}:{cfg['port']}/postgres" + + +def get_test_conninfo(): + """Connection string for test database.""" + cfg = get_db_config() + return f"postgresql://{cfg['user']}:{cfg['password']}@{cfg['host']}:{cfg['port']}/{TEST_DB_NAME}" + + +@pytest.fixture(scope="session", autouse=True) +def setup_test_database(): + """Create test database before tests, drop after.""" + # Safety check + if "test_" not in TEST_DB_NAME: + raise RuntimeError("Test database name MUST contain 'test_'") + + # Create database + with psycopg.connect(get_admin_conninfo(), autocommit=True) as conn: + with conn.cursor() as cur: + cur.execute(f"DROP DATABASE IF EXISTS {TEST_DB_NAME}") + cur.execute(f"CREATE DATABASE {TEST_DB_NAME}") + + # Run migrations + with psycopg.connect(get_test_conninfo()) as conn: + with conn.cursor() as cur: + migrations_dir = os.path.join(os.path.dirname(__file__), "..", "..", "migrations") + + # Read and execute migration files in order + migration_files = sorted( + f for f in os.listdir(migrations_dir) if f.endswith(".up.sql") + ) + for migration_file in migration_files: + with open(os.path.join(migrations_dir, migration_file)) as f: + cur.execute(f.read()) + conn.commit() + + # Set environment variable for repository to use test database + os.environ["HAPPY_SERVERS_DB_NAME"] = TEST_DB_NAME + + yield + + # Teardown - terminate connections and drop database + with psycopg.connect(get_admin_conninfo(), autocommit=True) as conn: + with conn.cursor() as cur: + # Terminate all connections to test database + cur.execute(f""" + SELECT pg_terminate_backend(pid) + FROM pg_stat_activity + WHERE datname = '{TEST_DB_NAME}' AND pid <> pg_backend_pid() + """) + cur.execute(f"DROP DATABASE IF EXISTS {TEST_DB_NAME}") + + +@pytest.fixture +def clean_db(): + """Truncate all tables before each test.""" + with psycopg.connect(get_test_conninfo()) as conn: + with conn.cursor() as cur: + cur.execute("TRUNCATE TABLE servers CASCADE") + 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..0cc132f --- /dev/null +++ b/tests/integration/test_servers.py @@ -0,0 +1,126 @@ +"""Integration tests for servers API with real PostgreSQL.""" + +import os + +import psycopg +import pytest +from fastapi.testclient import TestClient + +from happy_servers.api.app import app + +client = TestClient(app) + + +def get_test_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") + return f"postgresql://{user}:{password}@{host}:{port}/test_happy_servers" + + +@pytest.mark.integration +def test_server_lifecycle(clean_db): + """Test complete server lifecycle: create, validate, update, delete.""" + conninfo = get_test_conninfo() + + # 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 psycopg.connect(conninfo) as conn: + with 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 psycopg.connect(conninfo) as conn: + with 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 psycopg.connect(conninfo) as conn: + with 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 From d13637bddfaa1a0a9264622ec7e7e4e4d754de3a Mon Sep 17 00:00:00 2001 From: DmitriKo Date: Sat, 10 Jan 2026 19:16:09 +0200 Subject: [PATCH 24/31] fix mocking for unit tests --- tests/unit/conftest.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py index 3b83b85..1f3d565 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -14,8 +14,10 @@ def mock_db_connection(): conn = MagicMock() cursor = MagicMock() - # Make execute async + # 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) From 23c86f79c45898d24428fba14a925650b533a573 Mon Sep 17 00:00:00 2001 From: DmitriKo Date: Sat, 10 Jan 2026 20:03:13 +0200 Subject: [PATCH 25/31] make a dedicated container for integration testing --- Dockerfile | 2 +- README.md | 4 +- compose.test.yaml | 73 +++++++++++ pyproject.toml | 6 +- tests/integration/conftest.py | 90 +++----------- tests/integration/test_servers.py | 197 ++++++++++++++---------------- 6 files changed, 189 insertions(+), 183 deletions(-) create mode 100644 compose.test.yaml diff --git a/Dockerfile b/Dockerfile index 8cfc3a8..f301694 100644 --- a/Dockerfile +++ b/Dockerfile @@ -36,7 +36,7 @@ COPY migrations/ ./migrations/ USER appuser # Expose API port -EXPOSE 8000 +EXPOSE 9000 # Default command runs the API CMD ["hsapi"] \ No newline at end of file diff --git a/README.md b/README.md index 4c41ab0..05334fe 100644 --- a/README.md +++ b/README.md @@ -82,7 +82,7 @@ For unit tests we don't use real database. It is mocked. Thus it is is safe to r pytest ./tests/unit/ ``` ## Integration -Integration tests are using a real database server. The test database gets created before every test. Run it inside docker compose environment. +Integration tests are using a real database server. Run it inside docker compose environment. ```bash -docker compose --profile test run --rm test +docker compose -f compose.test.yaml run --rm test ``` \ 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/pyproject.toml b/pyproject.toml index eda0e8f..6f0dbf8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -51,7 +51,7 @@ dev = [ [project.scripts] hsapi = "happy_servers.api:main" -hsctl = "happy_servers.ctl:main" +hsctl = "happy_servers.cli:main" [project.urls] Homepage = "https://github.com/yourusername/happy-servers" @@ -59,8 +59,8 @@ Documentation = "https://github.com/yourusername/happy-servers#readme" Repository = "https://github.com/yourusername/happy-servers.git" Issues = "https://github.com/yourusername/happy-servers/issues" -[tool.hatch.build.targets.wheel] -packages = ["src/happy_servers"] +[tool.hatch.build] +sources = ["src"] [tool.pytest.ini_options] testpaths = ["tests"] diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 32c9f71..1364454 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -1,85 +1,33 @@ -"""Pytest fixtures for integration tests with real PostgreSQL.""" +"""Pytest fixtures for integration tests.""" import os import psycopg import pytest -# Test database name - MUST have test_ prefix -TEST_DB_NAME = "test_happy_servers" +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}" -def get_db_config(): - """Get database configuration from environment.""" - return { - "host": os.getenv("HAPPY_SERVERS_DB_HOST", "localhost"), - "port": int(os.getenv("HAPPY_SERVERS_DB_PORT", "5432")), - "user": os.getenv("HAPPY_SERVERS_DB_USER", "postgres"), - "password": os.getenv("HAPPY_SERVERS_DB_PASSWORD", "postgres"), - } - -def get_admin_conninfo(): - """Connection string for admin operations (create/drop DB).""" - cfg = get_db_config() - return f"postgresql://{cfg['user']}:{cfg['password']}@{cfg['host']}:{cfg['port']}/postgres" - - -def get_test_conninfo(): - """Connection string for test database.""" - cfg = get_db_config() - return f"postgresql://{cfg['user']}:{cfg['password']}@{cfg['host']}:{cfg['port']}/{TEST_DB_NAME}" - - -@pytest.fixture(scope="session", autouse=True) -def setup_test_database(): - """Create test database before tests, drop after.""" - # Safety check - if "test_" not in TEST_DB_NAME: - raise RuntimeError("Test database name MUST contain 'test_'") - - # Create database - with psycopg.connect(get_admin_conninfo(), autocommit=True) as conn: - with conn.cursor() as cur: - cur.execute(f"DROP DATABASE IF EXISTS {TEST_DB_NAME}") - cur.execute(f"CREATE DATABASE {TEST_DB_NAME}") - - # Run migrations - with psycopg.connect(get_test_conninfo()) as conn: - with conn.cursor() as cur: - migrations_dir = os.path.join(os.path.dirname(__file__), "..", "..", "migrations") - - # Read and execute migration files in order - migration_files = sorted( - f for f in os.listdir(migrations_dir) if f.endswith(".up.sql") - ) - for migration_file in migration_files: - with open(os.path.join(migrations_dir, migration_file)) as f: - cur.execute(f.read()) - conn.commit() - - # Set environment variable for repository to use test database - os.environ["HAPPY_SERVERS_DB_NAME"] = TEST_DB_NAME - - yield - - # Teardown - terminate connections and drop database - with psycopg.connect(get_admin_conninfo(), autocommit=True) as conn: - with conn.cursor() as cur: - # Terminate all connections to test database - cur.execute(f""" - SELECT pg_terminate_backend(pid) - FROM pg_stat_activity - WHERE datname = '{TEST_DB_NAME}' AND pid <> pg_backend_pid() - """) - cur.execute(f"DROP DATABASE IF EXISTS {TEST_DB_NAME}") +@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(): +def clean_db(db_conn): """Truncate all tables before each test.""" - with psycopg.connect(get_test_conninfo()) as conn: - with conn.cursor() as cur: - cur.execute("TRUNCATE TABLE servers CASCADE") - conn.commit() + 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 index 0cc132f..aa5ddf2 100644 --- a/tests/integration/test_servers.py +++ b/tests/integration/test_servers.py @@ -2,125 +2,110 @@ import os -import psycopg +import httpx import pytest -from fastapi.testclient import TestClient -from happy_servers.api.app import app - -client = TestClient(app) - - -def get_test_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") - return f"postgresql://{user}:{password}@{host}:{port}/test_happy_servers" +API_URL = os.environ['API_URL'] @pytest.mark.integration -def test_server_lifecycle(clean_db): +def test_server_lifecycle(clean_db, db_conn): """Test complete server lifecycle: create, validate, update, delete.""" - conninfo = get_test_conninfo() - # 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 psycopg.connect(conninfo) as conn: - with conn.cursor() as cur: + 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 psycopg.connect(conninfo) as conn: - with conn.cursor() as cur: + + # 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 psycopg.connect(conninfo) as conn: - with conn.cursor() as cur: + + # 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 From 8f7ec50736dd7437daf1615295732ba1f0506ee2 Mon Sep 17 00:00:00 2001 From: DmitriKo Date: Sat, 10 Jan 2026 20:30:32 +0200 Subject: [PATCH 26/31] CLI tool --- README.md | 28 ++++- pyproject.toml | 5 +- src/happy_servers/cli/__init__.py | 5 + src/happy_servers/cli/cli.py | 197 ++++++++++++++++++++++++++++++ 4 files changed, 230 insertions(+), 5 deletions(-) create mode 100644 src/happy_servers/cli/__init__.py create mode 100644 src/happy_servers/cli/cli.py diff --git a/README.md b/README.md index 05334fe..d752ed4 100644 --- a/README.md +++ b/README.md @@ -85,4 +85,30 @@ pytest ./tests/unit/ Integration tests are using a real database server. Run it inside docker compose environment. ```bash docker compose -f compose.test.yaml run --rm test -``` \ No newline at end of file +``` + +# 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/pyproject.toml b/pyproject.toml index 6f0dbf8..db176de 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -54,10 +54,7 @@ hsapi = "happy_servers.api:main" hsctl = "happy_servers.cli:main" [project.urls] -Homepage = "https://github.com/yourusername/happy-servers" -Documentation = "https://github.com/yourusername/happy-servers#readme" -Repository = "https://github.com/yourusername/happy-servers.git" -Issues = "https://github.com/yourusername/happy-servers/issues" +Homepage = "https://github.com/dmitriko/hiring-challenge-devops-python" [tool.hatch.build] sources = ["src"] 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 From 956a2c46075de41f0d7478f65f007ef18142376c Mon Sep 17 00:00:00 2001 From: DmitriKo Date: Sat, 10 Jan 2026 20:45:02 +0200 Subject: [PATCH 27/31] initial version of API.md --- API.md | 330 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 330 insertions(+) create mode 100644 API.md diff --git a/API.md b/API.md new file mode 100644 index 0000000..222e7b4 --- /dev/null +++ b/API.md @@ -0,0 +1,330 @@ +# Happy Servers - API & CLI Documentation + +Inventory management system for tracking cloud servers across data centers. + +## Quick Start + +### Using Docker Compose (Recommended) + +```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]" + +# Start PostgreSQL (required) +docker compose up -d postgres migrate + +# Run API with auto-reload +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 +``` + +--- + +## API Specification + +Base URL: `http://localhost:9000` + +### Health Check + +``` +GET /health +``` + +**Response** `200 OK` +```json +{"status": "healthy"} +``` + +--- + +### 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 +{ + "items": [ + { + "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 is available as the `hsctl` command. + +### 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 +``` + +Prompts for confirmation before deletion. + +**Example** +```bash +hsctl delete 550e8400-e29b-41d4-a716-446655440000 +``` + +--- + +## Environment Variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `HAPPY_SERVERS_SERVER_PORT` | 9000 | API server port | +| `HAPPY_SERVERS_DEBUG` | false | Enable debug/reload mode | +| `DB_HOST` | localhost | PostgreSQL host | +| `DB_PORT` | 5432 | PostgreSQL port | +| `DB_NAME` | happy_servers | Database name | +| `DB_USER` | postgres | Database user | +| `DB_PASSWORD` | postgres | Database password | +| `HAPPY_SERVERS_API_URL` | http://localhost:9000 | CLI target API URL | + +--- + +## 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 [go-migrate](https://github.com/golang-migrate/migrate) and run automatically in Docker Compose. From 96141881e908c4660f33a5b8f218f1b5d1baaf23 Mon Sep 17 00:00:00 2001 From: DmitriKo Date: Sun, 11 Jan 2026 11:57:23 +0200 Subject: [PATCH 28/31] ensure dot env is properly supported. fixes for docs. better local development setup. --- .env.example | 13 +++++++++ .gitignore | 1 + API.md | 18 ++++++++++--- README.md | 5 +++- compose.yaml | 44 +++++++++---------------------- src/happy_servers/api/__init__.py | 21 ++++++++------- src/happy_servers/config.py | 43 ++++++++++++++++++++++++++++++ src/happy_servers/repository.py | 12 +++------ start-local-dev.sh | 12 +++++++++ 9 files changed, 114 insertions(+), 55 deletions(-) create mode 100644 .env.example create mode 100644 src/happy_servers/config.py create mode 100755 start-local-dev.sh 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/.gitignore b/.gitignore index a7e7ffd..5a1c38e 100644 --- a/.gitignore +++ b/.gitignore @@ -29,6 +29,7 @@ htmlcov/ # Environment .env +.env.local # OS .DS_Store diff --git a/API.md b/API.md index 222e7b4..05bd282 100644 --- a/API.md +++ b/API.md @@ -2,6 +2,15 @@ Inventory management system for tracking cloud servers across data centers. +## 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 + +--- + ## Quick Start ### Using Docker Compose (Recommended) @@ -24,11 +33,12 @@ source .venv/bin/activate # Install package in dev mode pip install -e ".[dev]" -# Start PostgreSQL (required) -docker compose up -d postgres migrate - -# Run API with auto-reload +cp .env.example .env +# Run postgres with migrations +docker compose up -d db migrations +export HAPPY_SERVERS_DB_HOST=127.0.0.1 export HAPPY_SERVERS_DEBUG=true +# Run API hsapi ``` diff --git a/README.md b/README.md index d752ed4..db6366c 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,9 @@ pip install -e ".[dev]" Run API locally with auto reload ```bash +cp .env.example .env +docker compose up -d db migrations +export HAPPY_SERVERS_DB_HOST=127.0.0.1 export HAPPY_SERVERS_DEBUG=true hsapi ``` @@ -77,7 +80,7 @@ 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 is safe to run: +For unit tests we don't use real database. It is mocked. Thus it is safe to run: ```bash pytest ./tests/unit/ ``` diff --git a/compose.yaml b/compose.yaml index c4bcda9..f267ad1 100644 --- a/compose.yaml +++ b/compose.yaml @@ -2,15 +2,15 @@ services: db: image: postgres:17-alpine environment: - POSTGRES_USER: ${POSTGRES_USER:-postgres} - POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres} - POSTGRES_DB: ${POSTGRES_DB:-happy_servers} + 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 postgres -d happy_servers"] + test: ["CMD-SHELL", "pg_isready -U ${HAPPY_SERVERS_DB_USER:-postgres} -d ${HAPPY_SERVERS_DB_NAME:-happy_servers}"] interval: 5s timeout: 5s retries: 5 @@ -21,7 +21,7 @@ services: - ./migrations:/migrations:ro command: - "-path=/migrations" - - "-database=postgres://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@db:5432/${POSTGRES_DB:-happy_servers}?sslmode=disable" + - "-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: @@ -30,38 +30,18 @@ services: api: build: . ports: - - "${HAPPY_SERVERS_SERVER_PORT:-9000}:${HAPPY_SERVERS_SERVER_PORT:-9000}" + - "${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: ${POSTGRES_USER:-postgres} - HAPPY_SERVERS_DB_PASSWORD: ${POSTGRES_PASSWORD:-postgres} - HAPPY_SERVERS_DB_NAME: ${POSTGRES_DB:-happy_servers} - HAPPY_SERVERS_SERVER_PORT: ${HAPPY_SERVERS_SERVER_PORT:-9000} + 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 - test: - profiles: ["test"] - build: . - command: pytest tests/integration/ -v -p no:cacheprovider - environment: - HAPPY_SERVERS_DB_HOST: db - HAPPY_SERVERS_DB_PORT: 5432 - HAPPY_SERVERS_DB_USER: ${POSTGRES_USER:-postgres} - HAPPY_SERVERS_DB_PASSWORD: ${POSTGRES_PASSWORD:-postgres} - volumes: - - ./tests:/app/tests:ro - - ./migrations:/app/migrations:ro - - ./pyproject.toml:/app/pyproject.toml:ro - develop: - watch: - - action: rebuild - path: ./src - depends_on: - db: - condition: service_healthy - volumes: - postgres_data: + postgres_data: \ No newline at end of file diff --git a/src/happy_servers/api/__init__.py b/src/happy_servers/api/__init__.py index 81b4fcf..f3a7588 100644 --- a/src/happy_servers/api/__init__.py +++ b/src/happy_servers/api/__init__.py @@ -1,29 +1,30 @@ """Happy Servers API entry point.""" import logging -import os import uvicorn -logging.basicConfig(level=logging.INFO) +from happy_servers.config import get_settings + logger = logging.getLogger(__name__) def main() -> None: """Start the API server.""" - host = os.getenv("HAPPY_SERVERS_SERVER_HOST", "0.0.0.0") - port = int(os.getenv("HAPPY_SERVERS_SERVER_PORT", "9000")) - debug = os.getenv("HAPPY_SERVERS_DEBUG", "").lower() in ("true", "1", "yes") + 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 {host}:{port}") - if debug: + 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=host, - port=port, - reload=debug, + host=settings.server_host, + port=settings.server_port, + reload=settings.debug, ) diff --git a/src/happy_servers/config.py b/src/happy_servers/config.py new file mode 100644 index 0000000..5707069 --- /dev/null +++ b/src/happy_servers/config.py @@ -0,0 +1,43 @@ +"""Configuration management via environment variables.""" + +from functools import lru_cache + +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: str = "postgres" + db_name: str = "happy_servers" + + # API Server + server_host: str = "0.0.0.0" + server_port: int = 8000 + debug: bool = False + + @property + def db_url(self) -> str: + """PostgreSQL connection URL for psycopg.""" + return ( + f"postgresql://{self.db_user}:{self.db_password}" + 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/repository.py b/src/happy_servers/repository.py index 6e9d2e5..5a79ac2 100644 --- a/src/happy_servers/repository.py +++ b/src/happy_servers/repository.py @@ -1,12 +1,12 @@ """Repository for server data access.""" -import os 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 @@ -19,13 +19,9 @@ class DuplicateHostnameError(Exception): def _get_conninfo() -> str: - """Build connection string from environment.""" - 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") - name = os.getenv("HAPPY_SERVERS_DB_NAME", "happy_servers") - return f"postgresql://{user}:{password}@{host}:{port}/{name}" + """Build connection string from config.""" + settings = get_settings() + return settings.db_url async def close_pool() -> None: diff --git a/start-local-dev.sh b/start-local-dev.sh new file mode 100755 index 0000000..293f998 --- /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 From b647a860d4439146423be9eb4b82d54d288f74b4 Mon Sep 17 00:00:00 2001 From: DmitriKo Date: Sun, 11 Jan 2026 15:57:37 +0200 Subject: [PATCH 29/31] minor fixes --- API.md | 19 ++++++++++--------- start-local-dev.sh | 2 +- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/API.md b/API.md index 05bd282..6daf46b 100644 --- a/API.md +++ b/API.md @@ -2,18 +2,10 @@ Inventory management system for tracking cloud servers across data centers. -## 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 - ---- ## Quick Start -### Using Docker Compose (Recommended) +### Using Docker Compose ```bash # Start the full stack (PostgreSQL + Migrations + API) @@ -58,6 +50,15 @@ 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 + +after your started API server with default settings + ## API Specification Base URL: `http://localhost:9000` diff --git a/start-local-dev.sh b/start-local-dev.sh index 293f998..7d07186 100755 --- a/start-local-dev.sh +++ b/start-local-dev.sh @@ -3,7 +3,7 @@ set -e # make sure .env is here -[ -f .env ] || echo "Create .env file based on .env.example" && exit 1 +[ -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 From cd941eb5735c22be33d3c6c3264ba4f1b7673a01 Mon Sep 17 00:00:00 2001 From: DmitriKo Date: Sun, 11 Jan 2026 16:36:38 +0200 Subject: [PATCH 30/31] minor fixes for docs and default port consistency --- API.md | 42 +++++++++++++++++++++---------------- README.md | 1 - src/happy_servers/config.py | 2 +- 3 files changed, 25 insertions(+), 20 deletions(-) diff --git a/API.md b/API.md index 6daf46b..34be102 100644 --- a/API.md +++ b/API.md @@ -2,7 +2,6 @@ Inventory management system for tracking cloud servers across data centers. - ## Quick Start ### Using Docker Compose @@ -25,12 +24,14 @@ 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 -export HAPPY_SERVERS_DB_HOST=127.0.0.1 + +# Run API with debug mode export HAPPY_SERVERS_DEBUG=true -# Run API hsapi ``` @@ -57,7 +58,7 @@ 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 -after your started API server with default settings +--- ## API Specification @@ -71,7 +72,10 @@ GET /health **Response** `200 OK` ```json -{"status": "healthy"} +{ + "status": "healthy", + "version": "0.1.0" +} ``` --- @@ -137,7 +141,7 @@ GET /servers?state=active&datacenter=us-east-1&limit=10&offset=0 **Response** `200 OK` ```json { - "items": [ + "data": [ { "id": "550e8400-e29b-41d4-a716-446655440000", "hostname": "web-01.us-east", @@ -232,7 +236,7 @@ DELETE /servers/{id} ## CLI Specification -The CLI is available as the `hsctl` command. +The CLI (`hsctl`) connects directly to the database. ### Create Server @@ -296,14 +300,16 @@ hsctl update 550e8400-e29b-41d4-a716-446655440000 -s retired ### Delete Server ```bash -hsctl delete +hsctl delete [-y] ``` -Prompts for confirmation before deletion. +| Option | Description | +|--------|-------------| +| `-y, --yes` | Skip confirmation prompt | **Example** ```bash -hsctl delete 550e8400-e29b-41d4-a716-446655440000 +hsctl delete 550e8400-e29b-41d4-a716-446655440000 -y ``` --- @@ -312,14 +318,14 @@ hsctl delete 550e8400-e29b-41d4-a716-446655440000 | 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/reload mode | -| `DB_HOST` | localhost | PostgreSQL host | -| `DB_PORT` | 5432 | PostgreSQL port | -| `DB_NAME` | happy_servers | Database name | -| `DB_USER` | postgres | Database user | -| `DB_PASSWORD` | postgres | Database password | -| `HAPPY_SERVERS_API_URL` | http://localhost:9000 | CLI target API URL | +| `HAPPY_SERVERS_DEBUG` | false | Enable debug mode and auto-reload | --- @@ -338,4 +344,4 @@ CREATE TABLE servers ( ); ``` -Migrations are managed with [go-migrate](https://github.com/golang-migrate/migrate) and run automatically in Docker Compose. +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/README.md b/README.md index db6366c..b3fca04 100644 --- a/README.md +++ b/README.md @@ -72,7 +72,6 @@ Run API locally with auto reload ```bash cp .env.example .env docker compose up -d db migrations -export HAPPY_SERVERS_DB_HOST=127.0.0.1 export HAPPY_SERVERS_DEBUG=true hsapi ``` diff --git a/src/happy_servers/config.py b/src/happy_servers/config.py index 5707069..aa035ea 100644 --- a/src/happy_servers/config.py +++ b/src/happy_servers/config.py @@ -25,7 +25,7 @@ class Settings(BaseSettings): # API Server server_host: str = "0.0.0.0" - server_port: int = 8000 + server_port: int = 9000 debug: bool = False @property From cf0667e045c3bd5d052a9bcf55157b13b5290f9d Mon Sep 17 00:00:00 2001 From: DmitriKo Date: Sun, 11 Jan 2026 20:07:11 +0200 Subject: [PATCH 31/31] minor fixes --- run-integrartion-tests.sh | 3 +++ src/happy_servers/config.py | 5 +++-- 2 files changed, 6 insertions(+), 2 deletions(-) create mode 100755 run-integrartion-tests.sh 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/config.py b/src/happy_servers/config.py index aa035ea..4ad4171 100644 --- a/src/happy_servers/config.py +++ b/src/happy_servers/config.py @@ -2,6 +2,7 @@ from functools import lru_cache +from pydantic import SecretStr from pydantic_settings import BaseSettings, SettingsConfigDict @@ -20,7 +21,7 @@ class Settings(BaseSettings): db_host: str = "localhost" db_port: int = 5432 db_user: str = "postgres" - db_password: str = "postgres" + db_password: SecretStr db_name: str = "happy_servers" # API Server @@ -32,7 +33,7 @@ class Settings(BaseSettings): def db_url(self) -> str: """PostgreSQL connection URL for psycopg.""" return ( - f"postgresql://{self.db_user}:{self.db_password}" + f"postgresql://{self.db_user}:{self.db_password.get_secret_value()}" f"@{self.db_host}:{self.db_port}/{self.db_name}" )