diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml new file mode 100644 index 0000000..f42d1da --- /dev/null +++ b/.github/workflows/ci-cd.yml @@ -0,0 +1,97 @@ +name: CI/CD + +on: + workflow_dispatch: + pull_request: + push: + branches: + - main + +permissions: + contents: read + +jobs: + ci: + name: Run Tests + runs-on: ubuntu-latest + + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_DB: texet + POSTGRES_USER: texet + POSTGRES_PASSWORD: texet_ci_password + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U texet -d texet" + --health-interval 5s + --health-timeout 3s + --health-retries 20 + + env: + DATABASE_URL: postgresql+asyncpg://texet:texet_ci_password@127.0.0.1:5432/texet + DATABASE_URL_TEST: postgresql+asyncpg://texet:texet_ci_password@127.0.0.1:5432/texet_test + OPENAI_API_KEY: ci-test-key + OPENAI_MODEL: gpt-4o-mini + SMS_OUTBOUND_URL: "" + ADMIN_USERNAME: admin + ADMIN_PASSWORD: admin + ADMIN_SECRET_KEY: ci-admin-secret + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Set up uv + uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + + - name: Install dependencies + run: uv sync --frozen + + - name: Run tests with coverage + run: uv run pytest --cov + + deploy_backend: + name: Deploy Backend to Elastic Beanstalk + runs-on: ubuntu-latest + needs: ci + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + environment: production + concurrency: + group: eb-production-deploy + cancel-in-progress: false + permissions: + id-token: write + contents: read + + env: + AWS_REGION: us-east-1 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/${{ secrets.AWS_ROLE_NAME }} + aws-region: ${{ env.AWS_REGION }} + + - name: Deploy to Elastic Beanstalk + uses: aws-actions/aws-elasticbeanstalk-deploy@v1.0.0 + with: + aws-region: ${{ env.AWS_REGION }} + application-name: ${{ secrets.AWS_BACKEND_APPLICATION_NAME }} + environment-name: ${{ secrets.AWS_PROD_BACKEND_ENVIRONMENT_NAME }} + version-label: ${{ github.sha }} + create-application-if-not-exists: false + create-environment-if-not-exists: false diff --git a/README.md b/README.md index 882da15..7173a5c 100644 --- a/README.md +++ b/README.md @@ -283,6 +283,12 @@ uses Docker Compose; use `uv` when you need to add/upgrade packages or run local - `make migration name=...` creates a new Alembic revision (requires the DB running). - `make migrate` applies Alembic migrations (requires the DB running). +### CI/CD + +- Workflow: `.github/workflows/ci-cd.yml` +- Quick onboarding and maintenance guide: `docs/ci-cd.md` +- Production runbook (developers, operators, researchers, students): `docs/operations-playbook.md` + ## Notes - Do not commit `.env` files or real API keys. Rotate any keys that have been shared. diff --git a/docs/ci-cd.md b/docs/ci-cd.md new file mode 100644 index 0000000..20c1500 --- /dev/null +++ b/docs/ci-cd.md @@ -0,0 +1,49 @@ +# CI/CD Quick Guide + +## What runs on GitHub + +- Workflow file: `.github/workflows/ci-cd.yml` +Triggers: +- `pull_request`: run CI only. +- `push` to `main`: run CI, then deploy if CI passes. +- `workflow_dispatch`: optional manual run. + +## CI (tests) + +- Job: `ci` +- Runner: `ubuntu-latest` +- Database: Postgres service container (`postgres:16-alpine`) +- Test command: `uv run pytest --cov` +DB URLs are provided via: +- `DATABASE_URL` +- `DATABASE_URL_TEST` + +Why Postgres is required: + +- Test fixtures create/reset `texet_test` and apply Alembic migrations before tests. +- Many tests assert database behavior directly. + +No-internet test guard: + +- `tests/conftest.py` blocks external hostname resolution during tests. +- Only loopback and configured DB hosts are allowed. +- If a test accidentally calls a real external API, it fails fast. + +## CD (Elastic Beanstalk) + +- Job: `deploy_backend` +- Runs only on `push` to `main` and only after CI succeeds. +- Uses GitHub Environment: `production` (for protection rules/approvals). +- Auth: GitHub OIDC with `aws-actions/configure-aws-credentials@v4`. +- Deploy: `aws-actions/aws-elasticbeanstalk-deploy@v1.0.0`. + +Required GitHub secrets: + +- `AWS_ACCOUNT_ID` +- `AWS_ROLE_NAME` +- `AWS_BACKEND_APPLICATION_NAME` +- `AWS_PROD_BACKEND_ENVIRONMENT_NAME` + +## Notes + +- If test setup changes, update CI env vars and test fixtures together. diff --git a/tests/conftest.py b/tests/conftest.py index bcb75bb..32bcfa6 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,7 +1,10 @@ from __future__ import annotations import asyncio +import ipaddress import os +import socket +from collections.abc import Generator from pathlib import Path import asyncpg @@ -38,6 +41,58 @@ def _load_env_file(path: Path) -> None: _load_env_file(PROJECT_ROOT / ".env.db") +def _allowed_test_hosts() -> set[str]: + hosts = {"localhost", "127.0.0.1", "::1", "db"} + for env_name in ("DATABASE_URL_TEST", "DATABASE_URL"): + raw_url = os.getenv(env_name) + if not raw_url: + continue + parsed = make_url(raw_url) + if parsed.host: + hosts.add(parsed.host) + return {host.lower() for host in hosts} + + +def _is_allowed_host(host: object, allowed_hosts: set[str]) -> bool: + if host is None: + return True + if isinstance(host, bytes): + host = host.decode("utf-8", errors="ignore") + if not isinstance(host, str): + return True + + normalized = host.strip().lower() + if not normalized: + return True + if normalized in allowed_hosts: + return True + + try: + ip = ipaddress.ip_address(normalized) + except ValueError: + return False + return ip.is_loopback + + +@pytest.fixture(scope="session", autouse=True) +def block_external_network() -> Generator[None, None, None]: + """Block accidental internet calls while allowing local/test DB traffic.""" + + allowed_hosts = _allowed_test_hosts() + real_getaddrinfo = socket.getaddrinfo + + def guarded_getaddrinfo(host: object, *args: object, **kwargs: object) -> object: + if _is_allowed_host(host, allowed_hosts): + return real_getaddrinfo(host, *args, **kwargs) + raise RuntimeError(f"External network access is blocked during tests: host={host!r}") + + socket.getaddrinfo = guarded_getaddrinfo + try: + yield + finally: + socket.getaddrinfo = real_getaddrinfo + + async def _ensure_test_db(database_url: str, reset: bool = False) -> None: url = make_url(database_url) if not url.database: