diff --git a/.bandit b/.bandit index ad180480..94416dbd 100644 --- a/.bandit +++ b/.bandit @@ -1,2 +1,2 @@ [bandit] -exclude: ./src/tests/* +exclude=src/tests/*,.venv,__pycache__,*.pyc,migrations/* diff --git a/.flake8 b/.flake8 deleted file mode 100644 index add395f6..00000000 --- a/.flake8 +++ /dev/null @@ -1,14 +0,0 @@ -[flake8] -max-line-length = 88 - -select = C,E,F,W,B,B950 - -max-complexity = 10 - -ignore = - E501 - F401 - -exclude = - __pycache__ - testing.py diff --git a/.github/SETUP.md b/.github/SETUP.md new file mode 100644 index 00000000..24b25262 --- /dev/null +++ b/.github/SETUP.md @@ -0,0 +1,79 @@ +# GitHub Actions Environment Variables Setup + +This is a quick reference guide. For complete setup instructions, see [`OPS.md`](../OPS.md#github-actions-aws-oidc-setup). + +## Required Secret + +The workflow requires one GitHub repository secret: + +- **`AWS_ROLE_ARN`**: The ARN of the IAM role that GitHub Actions will assume to push Docker images to ECR + +## Setup Steps + +### 1. Create AWS IAM Role + +**Follow the complete instructions in [`OPS.md`](../OPS.md#github-actions-aws-oidc-setup)** which includes: +- Creating the OIDC identity provider +- Creating the IAM role with trust policy +- Attaching ECR permissions policy + +After completing the AWS setup, you'll have an IAM role ARN (typically: `arn:aws:iam::633607774026:role/GitHubActions-ECR-Push`) + +### 2. Add GitHub Secret + +Add the IAM role ARN as a GitHub repository secret using the GitHub CLI: + +```bash +# Ensure you're authenticated with GitHub CLI +# If not already authenticated, run: gh auth login + +# Set the secret (replace with your actual role ARN if different) +gh secret set AWS_ROLE_ARN --body "arn:aws:iam::633607774026:role/GitHubActions-ECR-Push" +``` + +**Note**: Make sure you're in the repository directory or specify the repo with `--repo operationcode/back-end`. + +### 3. Verify Setup + +After adding the secret, the workflow will automatically: +- Authenticate to AWS using OIDC (no credentials stored) +- Build Docker images for ARM64 platform +- Push to ECR with appropriate tags: + - `:staging` for non-master branches + - `:prod` for master branch (after CI passes) + +## Testing + +To test the setup: + +1. **Test staging build**: Push to any branch except `master` + - Should trigger Docker build and push to `:staging` tag + - Check ECR repository to verify image was pushed + +2. **Test production build**: Merge to `master` branch + - Should run lint, test, security checks first + - If all pass, should build and push to `:prod` tag + - Check ECR repository to verify image was pushed + +## Troubleshooting + +### Build fails with "Error assuming role" +- Verify the `AWS_ROLE_ARN` secret is set correctly +- Check that the IAM role exists and has the correct trust policy +- Ensure the OIDC identity provider is configured + +### Build fails with "AccessDenied" to ECR +- Verify the IAM role has the ECR push policy attached +- Check that the policy allows access to the correct ECR repository +- Ensure the repository path matches: `633607774026.dkr.ecr.us-east-2.amazonaws.com/back-end` + +### Production build runs even when tests fail +- This shouldn't happen - production builds depend on `ci-success` job +- Check that `ci-success` job is properly failing when tests fail +- Verify branch protection rules if using them + +## Additional Resources + +- Full AWS OIDC setup: See [`OPS.md`](../OPS.md#github-actions-aws-oidc-setup) +- GitHub Actions secrets: https://docs.github.com/en/actions/security-guides/encrypted-secrets +- AWS OIDC with GitHub: https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/configuring-openid-connect-in-amazon-web-services diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..9c13f970 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,314 @@ +name: CI + +on: + push: + branches: [master] + pull_request: + branches: [master] + +env: + POETRY_VERSION: "2.3.0" + POETRY_VIRTUALENVS_IN_PROJECT: true + +jobs: + lint: + name: Lint + runs-on: ubuntu-latest + # Only run on master branch pushes and PRs to master + if: github.event_name == 'pull_request' || github.ref == 'refs/heads/master' + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Load cached Poetry installation + id: cached-poetry + uses: actions/cache@v4 + with: + path: ~/.local + key: poetry-${{ env.POETRY_VERSION }}-${{ runner.os }} + + - name: Install Poetry + if: steps.cached-poetry.outputs.cache-hit != 'true' + uses: snok/install-poetry@v1 + with: + version: ${{ env.POETRY_VERSION }} + virtualenvs-create: true + virtualenvs-in-project: true + + - name: Load cached venv + id: cached-venv + uses: actions/cache@v4 + with: + path: .venv + key: venv-lint-${{ runner.os }}-py3.12-${{ hashFiles('poetry.lock') }} + restore-keys: | + venv-lint-${{ runner.os }}-py3.12- + + - name: Install dependencies + if: steps.cached-venv.outputs.cache-hit != 'true' + run: poetry install --only dev --no-interaction + + - name: Run Ruff linter + run: poetry run ruff check . + + - name: Run Ruff formatter check + run: poetry run ruff format --check . + + test: + name: Test + runs-on: ubuntu-latest + # Only run on master branch pushes and PRs to master + if: github.event_name == 'pull_request' || github.ref == 'refs/heads/master' + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python 3.12 + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Load cached Poetry installation + id: cached-poetry + uses: actions/cache@v4 + with: + path: ~/.local + key: poetry-${{ env.POETRY_VERSION }}-${{ runner.os }} + + - name: Install Poetry + if: steps.cached-poetry.outputs.cache-hit != 'true' + uses: snok/install-poetry@v1 + with: + version: ${{ env.POETRY_VERSION }} + virtualenvs-create: true + virtualenvs-in-project: true + + - name: Load cached venv + id: cached-venv + uses: actions/cache@v4 + with: + path: .venv + key: venv-test-${{ runner.os }}-py3.12-${{ hashFiles('poetry.lock') }} + restore-keys: | + venv-test-${{ runner.os }}-py3.12- + + - name: Install dependencies + if: steps.cached-venv.outputs.cache-hit != 'true' + run: poetry install --no-interaction + + - name: Run tests with coverage + working-directory: src + run: | + poetry run pytest \ + --cov=. \ + --cov-report=xml \ + --cov-report=term-missing \ + -v \ + --tb=short + env: + DJANGO_ENV: testing + ENVIRONMENT: TEST + SECRET_KEY: test-secret-key-for-ci + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v5 + with: + files: ./src/coverage.xml + fail_ci_if_error: false + verbose: true + + security: + name: Security Scan + runs-on: ubuntu-latest + # Only run on master branch pushes and PRs to master + if: github.event_name == 'pull_request' || github.ref == 'refs/heads/master' + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python 3.12 + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Load cached Poetry installation + id: cached-poetry + uses: actions/cache@v4 + with: + path: ~/.local + key: poetry-${{ env.POETRY_VERSION }}-${{ runner.os }} + + - name: Install Poetry + if: steps.cached-poetry.outputs.cache-hit != 'true' + uses: snok/install-poetry@v1 + with: + version: ${{ env.POETRY_VERSION }} + virtualenvs-create: true + virtualenvs-in-project: true + + - name: Load cached venv + id: cached-venv + uses: actions/cache@v4 + with: + path: .venv + key: venv-security-${{ runner.os }}-py3.12-${{ hashFiles('poetry.lock') }} + restore-keys: | + venv-security-${{ runner.os }}-py3.12- + + - name: Install dependencies + if: steps.cached-venv.outputs.cache-hit != 'true' + run: poetry install --no-interaction + + - name: Run Bandit security linter + run: poetry run bandit -r src --skip B101 --severity-level high -f json -o bandit-report.json || true + + - name: Display Bandit results + run: poetry run bandit -r src --skip B101 --severity-level high -f txt || true + + docker-build-push: + name: Build and Push Docker Image + runs-on: ubuntu-latest + # Run on push to master (build+push) and on PRs (build only) + if: github.event_name == 'push' || github.event_name == 'pull_request' + # For master/PR, wait for CI checks to pass + needs: [ci-success] + permissions: + id-token: write # Required for OIDC authentication + contents: read # Required to checkout code + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Determine push eligibility + id: can-push + run: | + if [ "${{ github.event_name }}" == "push" ]; then + echo "push=true" >> $GITHUB_OUTPUT + elif [ "${{ github.event.pull_request.head.repo.full_name }}" == "${{ github.repository }}" ]; then + echo "push=true" >> $GITHUB_OUTPUT + else + echo "push=false" >> $GITHUB_OUTPUT + fi + + - name: Debug OIDC claims + if: steps.can-push.outputs.push == 'true' + run: | + echo "repo=${{ github.repository }}" + echo "ref=${{ github.ref }}" + echo "event=${{ github.event_name }}" + echo "head=${{ github.event.pull_request.head.repo.full_name }}" + if [ -z "$ACTIONS_ID_TOKEN_REQUEST_URL" ] || [ -z "$ACTIONS_ID_TOKEN_REQUEST_TOKEN" ]; then + echo "OIDC env missing" + exit 0 + fi + token_json=$(curl -sS -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \ + "${ACTIONS_ID_TOKEN_REQUEST_URL}&audience=sts.amazonaws.com" || true) + if [ -z "$token_json" ] || [ "$token_json" = "null" ]; then + echo "OIDC token missing" + exit 0 + fi + OIDC_TOKEN_JSON="$token_json" python - <<'PY' + import base64,json,os,sys + token_json = os.environ.get("OIDC_TOKEN_JSON","") + if not token_json: + print("OIDC token missing") + sys.exit(0) + token = json.loads(token_json).get("value","") + if not token: + print("OIDC token missing") + sys.exit(0) + payload = token.split(".")[1] + payload += "=" * ((4 - len(payload) % 4) % 4) + data = json.loads(base64.urlsafe_b64decode(payload)) + print(f"oidc.aud={data.get('aud')}") + print(f"oidc.sub={data.get('sub')}") + PY + + - name: Determine Docker tag + id: docker-tag + run: | + if [ "${{ github.ref }}" == "refs/heads/master" ]; then + echo "image=633607774026.dkr.ecr.us-east-2.amazonaws.com/back-end:prod" >> $GITHUB_OUTPUT + echo "environment=Production" >> $GITHUB_OUTPUT + else + echo "image=633607774026.dkr.ecr.us-east-2.amazonaws.com/back-end:staging" >> $GITHUB_OUTPUT + echo "environment=Staging" >> $GITHUB_OUTPUT + fi + echo "Building for ${{ steps.docker-tag.outputs.environment }} with image: ${{ steps.docker-tag.outputs.image }}" + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + with: + platforms: linux/arm64 + + - name: Configure AWS credentials + if: steps.can-push.outputs.push == 'true' + uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: ${{ secrets.AWS_ROLE_ARN }} + role-session-name: GitHubActions-DockerBuild-${{ steps.docker-tag.outputs.environment }} + aws-region: us-east-2 + + - name: Login to Amazon ECR + id: login-ecr + if: steps.can-push.outputs.push == 'true' + uses: aws-actions/amazon-ecr-login@v2 + + - name: Build and push Docker image + uses: docker/build-push-action@v5 + with: + context: . + target: runtime + platforms: linux/arm64 + push: ${{ steps.can-push.outputs.push == 'true' }} + tags: | + ${{ steps.docker-tag.outputs.image }} + provenance: false + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Output image URI + if: steps.can-push.outputs.push == 'true' + run: | + echo "Successfully pushed ${{ steps.docker-tag.outputs.environment }} image:" + echo "${{ steps.docker-tag.outputs.image }}" + + # Final status check for branch protection + ci-success: + name: CI Success + needs: [lint, test, security] + runs-on: ubuntu-latest + # Always run to satisfy docker-build-push dependency + if: always() + steps: + - name: Check all jobs passed (master/PR only) + if: github.event_name == 'pull_request' || github.ref == 'refs/heads/master' + run: | + # Check if jobs were skipped (non-master) or failed + if [[ "${{ needs.lint.result }}" == "skipped" ]]; then + echo "Lint job was skipped - this should not happen on master/PR" + exit 1 + fi + if [[ "${{ needs.lint.result }}" != "success" ]]; then + echo "Lint job failed" + exit 1 + fi + if [[ "${{ needs.test.result }}" == "skipped" ]]; then + echo "Test job was skipped - this should not happen on master/PR" + exit 1 + fi + if [[ "${{ needs.test.result }}" != "success" ]]; then + echo "Test job failed" + exit 1 + fi + # Security is informational, doesn't fail CI + echo "All required jobs passed!" + - name: Pass through for non-master branches + if: github.event_name != 'pull_request' && github.ref != 'refs/heads/master' + run: | + echo "Skipping CI checks for non-master branch (staging build will proceed)" diff --git a/Dockerfile b/Dockerfile index f35f486a..dded4d67 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,93 +1,146 @@ # syntax=docker/dockerfile:1 -# ============================================================================= -# Builder stage: compile dependencies -# ============================================================================= -FROM python:3.12-alpine AS builder - -# Install build dependencies for compiling Python packages -RUN apk add --no-cache \ - gcc \ - musl-dev \ - libffi-dev \ - postgresql-dev \ - python3-dev \ - zlib-dev \ - jpeg-dev - -# Install poetry system-wide (not in venv, so it won't be copied to production) -RUN pip install --no-cache-dir poetry - -# Create clean venv with upgraded pip (--upgrade-deps handles CVE-2025-8869) -RUN python -m venv /venv --upgrade-deps - -WORKDIR /build +# ============================================================================ +# Build stage: Install dependencies using Poetry +# ============================================================================ +FROM python:3.12-slim AS builder + +# Install build dependencies required for compiling Python packages +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential \ + libpq-dev \ + curl \ + && rm -rf /var/lib/apt/lists/* + +# Install Poetry +ENV POETRY_VERSION=2.3.0 \ + POETRY_HOME="/opt/poetry" \ + POETRY_NO_INTERACTION=1 \ + POETRY_VIRTUALENVS_IN_PROJECT=1 \ + POETRY_VIRTUALENVS_CREATE=1 \ + POETRY_CACHE_DIR=/tmp/poetry_cache + +RUN curl -sSL https://install.python-poetry.org | python3 - && \ + ln -s /opt/poetry/bin/poetry /usr/local/bin/poetry + +WORKDIR /app + +# Copy dependency files first for better layer caching COPY pyproject.toml poetry.lock ./ -# Tell poetry to use our venv instead of creating its own -ENV VIRTUAL_ENV=/venv \ - PATH="/venv/bin:$PATH" +# Install dependencies into a virtual environment +# Using --mount=type=cache speeds up rebuilds significantly +RUN --mount=type=cache,target=$POETRY_CACHE_DIR \ + poetry install --only=main --no-interaction --no-ansi --no-root + +# ============================================================================ +# Development builder: Install all dependencies including dev tools +# ============================================================================ +FROM builder AS builder-dev + +# Install all dependencies including dev (debug_toolbar, pytest, etc.) +RUN --mount=type=cache,target=$POETRY_CACHE_DIR \ + poetry install --no-interaction --no-ansi --no-root + +# ============================================================================ +# Development stage: Full development environment with dev tools +# ============================================================================ +FROM python:3.12-slim AS development + +LABEL org.opencontainers.image.source="https://github.com/operationcode/back-end" +LABEL org.opencontainers.image.description="Operation Code Backend - Development" +LABEL org.opencontainers.image.licenses="MIT" + +# Install runtime dependencies +RUN apt-get update && apt-get install -y --no-install-recommends \ + libpq5 \ + curl \ + wget \ + && apt-get upgrade -y \ + && rm -rf /var/lib/apt/lists/* \ + && apt-get clean + +# Create non-root user for security +RUN groupadd -r appuser && \ + useradd -r -g appuser -u 1000 -m -d /app appuser + +# Set environment variables for Python optimization +ENV PYTHONUNBUFFERED=1 \ + PYTHONPATH=/app/src \ + PATH="/app/.venv/bin:$PATH" \ + VIRTUAL_ENV=/app/.venv -# Install production dependencies only -RUN poetry install --only=main --no-interaction --no-cache +WORKDIR /app -# ============================================================================= -# Test builder: add dev dependencies -# ============================================================================= -FROM builder AS test-builder +# Copy virtual environment with dev dependencies from builder-dev stage +COPY --from=builder-dev --chown=appuser:appuser /app/.venv /app/.venv -RUN poetry install --no-interaction --no-cache +# Copy application code +COPY --chown=appuser:appuser ./src ./src -# ============================================================================= -# Runtime base: minimal image shared by test and production -# ============================================================================= -FROM python:3.12-alpine AS runtime-base +# Set working directory to src for running the application +WORKDIR /app/src -# Install only runtime dependencies (no build tools, no poetry) -# Upgrade system pip to fix CVE-2025-8869 (even though app uses venv pip) -RUN apk upgrade --no-cache && \ - apk add --no-cache libpq libjpeg-turbo && \ - pip install --no-cache-dir --upgrade pip +# Switch to non-root user +USER appuser -ENV PYTHONUNBUFFERED=1 \ - PATH="/venv/bin:$PATH" +# Expose port for Django dev server +EXPOSE 8000 -WORKDIR /app +# Run Django development server (will be overridden by docker-compose) +CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"] -# ============================================================================= -# Test stage -# ============================================================================= -FROM runtime-base AS test +# ============================================================================ +# Production/Runtime stage: Minimal production image (DEFAULT) +# ============================================================================ +FROM python:3.12-slim AS runtime -COPY --from=test-builder /venv /venv -COPY src ./src -COPY .dev ./src/.dev -COPY pytest.ini ./ +LABEL org.opencontainers.image.source="https://github.com/operationcode/back-end" +LABEL org.opencontainers.image.description="Operation Code Backend - Django API" +LABEL org.opencontainers.image.licenses="MIT" -WORKDIR /app/src +# Install only runtime dependencies (no build tools) +RUN apt-get update && apt-get install -y --no-install-recommends \ + libpq5 \ + curl \ + wget \ + && apt-get upgrade -y \ + && rm -rf /var/lib/apt/lists/* \ + && apt-get clean -ENV DJANGO_ENV=testing \ - ENVIRONMENT=TEST +# Create non-root user for security +RUN groupadd -r appuser && \ + useradd -r -g appuser -u 1000 -m -d /app appuser -CMD ["pytest", "-v"] +# Set environment variables for Python optimization +ENV PYTHONUNBUFFERED=1 \ + PYTHONPATH=/app/src \ + PATH="/app/.venv/bin:$PATH" \ + VIRTUAL_ENV=/app/.venv -# ============================================================================= -# Production stage -# ============================================================================= -FROM runtime-base AS production +WORKDIR /app + +# Copy virtual environment from builder stage (production deps only) +COPY --from=builder --chown=appuser:appuser /app/.venv /app/.venv -COPY --from=builder /venv /venv -COPY src ./src +# Copy application code +COPY --chown=appuser:appuser ./src ./src -# Pre-compile Python bytecode for faster cold starts +# Pre-compile Python bytecode for faster startup RUN python -m compileall -q ./src/ +# Set working directory to src for running the application WORKDIR /app/src -ENV DJANGO_ENV=production \ - DB_ENGINE=django.db.backends.postgresql +# Switch to non-root user +USER appuser +# Expose port for Gunicorn EXPOSE 8000 +# Health check endpoint +HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \ + CMD curl -f http://localhost:8000/healthz || exit 1 + # Run background task processor and gunicorn -CMD ["sh", "-c", "python manage.py qcluster & gunicorn operationcode_backend.wsgi -c /app/src/gunicorn_config.py"] +CMD ["sh", "-c", "python manage.py qcluster & gunicorn operationcode_backend.wsgi -c gunicorn_config.py"] diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..990711c1 --- /dev/null +++ b/Makefile @@ -0,0 +1,82 @@ +.PHONY: help install lint lint-fix format test test-unit test-integration test-cov security ci migrate createsuperuser runserver shell clean + +# Default target +help: + @echo "Available commands:" + @echo " make install - Install dependencies with poetry" + @echo " make lint - Run ruff linter and formatter check" + @echo " make lint-fix - Auto-fix linting and formatting issues" + @echo " make format - Format code with ruff" + @echo " make test - Run all tests" + @echo " make test-unit - Run unit tests only" + @echo " make test-integration - Run integration tests only" + @echo " make test-cov - Run tests with coverage report" + @echo " make security - Run bandit security scanner" + @echo " make ci - Run all CI checks (lint, test-cov, security)" + @echo " make migrate - Run Django migrations" + @echo " make createsuperuser - Create Django superuser" + @echo " make runserver - Run Django development server" + @echo " make shell - Open Django shell" + @echo " make clean - Remove Python cache files and test artifacts" + +# Install dependencies +install: + poetry install + +# Linting and formatting +lint: + poetry run ruff check . + poetry run ruff format --check . + +lint-fix: + poetry run ruff check --fix . + poetry run ruff format . + +format: + poetry run ruff format . + +# Testing +test: + cd src && poetry run pytest + +test-unit: + cd src && poetry run pytest tests/unit/ + +test-integration: + cd src && poetry run pytest tests/integration/ + +test-cov: + cd src && DJANGO_ENV=testing ENVIRONMENT=TEST SECRET_KEY=test-secret-key poetry run pytest --cov=. --cov-report=xml --cov-report=term-missing -v + +# Security +security: + poetry run bandit -r src --skip B101 --severity-level high -f txt + +# CI - runs all checks that CI will run +ci: lint test-cov security + @echo "" + @echo "✓ All CI checks passed!" + +# Django commands +migrate: + poetry run python src/manage.py migrate + +createsuperuser: + poetry run python src/manage.py createsuperuser + +runserver: + poetry run python src/manage.py runserver + +shell: + poetry run python src/manage.py shell + +# Cleanup +clean: + find . -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true + find . -type f -name "*.pyc" -delete + find . -type f -name "*.pyo" -delete + find . -type d -name ".pytest_cache" -exec rm -rf {} + 2>/dev/null || true + find . -type f -name ".coverage" -delete + find . -type f -name "coverage.xml" -delete + rm -rf .ruff_cache htmlcov + @echo "✓ Cleaned up Python cache files and test artifacts" diff --git a/OPS.md b/OPS.md index 4cd606ef..68e03890 100644 --- a/OPS.md +++ b/OPS.md @@ -4,7 +4,18 @@ The backend is deployed to AWS ECS (Elastic Container Service) with separate sta ## Building and Pushing Docker Images -Use the `docker-build.sh` script to build multi-architecture images and push to AWS ECR: +### Automated Builds (Recommended) + +Docker images are automatically built and pushed to AWS ECR via GitHub Actions: + +- **PR branches** (any branch except `master`): Automatically builds and pushes to `:staging` tag +- **Master branch**: Automatically builds and pushes to `:prod` tag after CI checks pass + +The automated builds use AWS OIDC for secure authentication (no long-lived credentials). + +### Manual Builds (Legacy) + +For manual builds, use the `docker-build.sh` script: ```bash # Build and push staging images @@ -14,9 +25,7 @@ Use the `docker-build.sh` script to build multi-architecture images and push to ./docker-build.sh prod ``` -This creates: -- `back-end:staging-amd64` and `back-end:staging-arm64` images -- A multi-arch manifest at `back-end:staging` +This creates ARM64 images tagged as `back-end:staging` or `back-end:prod`. ## Deploying to ECS @@ -74,3 +83,140 @@ aws logs tail /ecs/back-end-staging --follow aws logs tail /ecs/back-end-production --follow ``` +# GitHub Actions AWS OIDC Setup + +The CI/CD pipeline uses AWS OIDC (OpenID Connect) for secure authentication to AWS without storing long-lived credentials. This follows AWS security best practices. + +## Prerequisites + +- AWS account with ECR repository: `633607774026.dkr.ecr.us-east-2.amazonaws.com/back-end` +- GitHub repository: `operationcode/back-end` +- AWS IAM permissions to create IAM roles and policies + +## Setup Instructions + +### 1. Create IAM OIDC Identity Provider + +If not already configured, create an OIDC identity provider for GitHub: + +```bash +aws iam create-open-id-connect-provider \ + --url https://token.actions.githubusercontent.com \ + --client-id-list sts.amazonaws.com \ + --thumbprint-list 6938fd4d98bab03faadb97b34396831e3780aea1 +``` + +### 2. Create IAM Role for GitHub Actions + +Create an IAM role that GitHub Actions can assume: + +```bash +# Create trust policy file +cat > github-actions-trust-policy.json < ecr-push-policy.json < sh -c "python manage.py migrate && python manage.py runserver 0.0.0.0:8000" diff --git a/example.env b/example.env index 81e461f0..d08ed3bb 100644 --- a/example.env +++ b/example.env @@ -50,7 +50,7 @@ SENTRY_SEND_DEFAULT_PII=[True] # Creds needed to use AWS S3 for serving static assets -AWS_STORAGE_BUCKET_NAME=[BUCKET_NAMAE] +AWS_STORAGE_BUCKET_NAME=[BUCKET_NAME] BUCKET_REGION_NAME=[REGION_NAME] AWS_ACCESS_KEY_ID=[ACCESS_KEY_ID] AWS_SECRET_ACCESS_KEY=[SECRET_ACCESS_KEY] \ No newline at end of file diff --git a/poetry.lock b/poetry.lock index 1501342b..e64f84d6 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.3.0 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.3.1 and should not be changed by hand. [[package]] name = "ansicon" @@ -91,18 +91,6 @@ files = [ [package.extras] tests = ["mypy (>=1.14.0)", "pytest", "pytest-asyncio"] -[[package]] -name = "attrs" -version = "25.4.0" -description = "Classes Without Boilerplate" -optional = false -python-versions = ">=3.9" -groups = ["dev"] -files = [ - {file = "attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373"}, - {file = "attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11"}, -] - [[package]] name = "bandit" version = "1.9.2" @@ -193,57 +181,6 @@ files = [ tests = ["pytest (>=3.2.1,!=3.3.0)"] typecheck = ["mypy"] -[[package]] -name = "black" -version = "26.1.0" -description = "The uncompromising code formatter." -optional = false -python-versions = ">=3.10" -groups = ["dev"] -files = [ - {file = "black-26.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ca699710dece84e3ebf6e92ee15f5b8f72870ef984bf944a57a777a48357c168"}, - {file = "black-26.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5e8e75dabb6eb83d064b0db46392b25cabb6e784ea624219736e8985a6b3675d"}, - {file = "black-26.1.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eb07665d9a907a1a645ee41a0df8a25ffac8ad9c26cdb557b7b88eeeeec934e0"}, - {file = "black-26.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:7ed300200918147c963c87700ccf9966dceaefbbb7277450a8d646fc5646bf24"}, - {file = "black-26.1.0-cp310-cp310-win_arm64.whl", hash = "sha256:c5b7713daea9bf943f79f8c3b46f361cc5229e0e604dcef6a8bb6d1c37d9df89"}, - {file = "black-26.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3cee1487a9e4c640dc7467aaa543d6c0097c391dc8ac74eb313f2fbf9d7a7cb5"}, - {file = "black-26.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d62d14ca31c92adf561ebb2e5f2741bf8dea28aef6deb400d49cca011d186c68"}, - {file = "black-26.1.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fb1dafbbaa3b1ee8b4550a84425aac8874e5f390200f5502cf3aee4a2acb2f14"}, - {file = "black-26.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:101540cb2a77c680f4f80e628ae98bd2bd8812fb9d72ade4f8995c5ff019e82c"}, - {file = "black-26.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:6f3977a16e347f1b115662be07daa93137259c711e526402aa444d7a88fdc9d4"}, - {file = "black-26.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6eeca41e70b5f5c84f2f913af857cf2ce17410847e1d54642e658e078da6544f"}, - {file = "black-26.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:dd39eef053e58e60204f2cdf059e2442e2eb08f15989eefe259870f89614c8b6"}, - {file = "black-26.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9459ad0d6cd483eacad4c6566b0f8e42af5e8b583cee917d90ffaa3778420a0a"}, - {file = "black-26.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:a19915ec61f3a8746e8b10adbac4a577c6ba9851fa4a9e9fbfbcf319887a5791"}, - {file = "black-26.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:643d27fb5facc167c0b1b59d0315f2674a6e950341aed0fc05cf307d22bf4954"}, - {file = "black-26.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ba1d768fbfb6930fc93b0ecc32a43d8861ded16f47a40f14afa9bb04ab93d304"}, - {file = "black-26.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2b807c240b64609cb0e80d2200a35b23c7df82259f80bef1b2c96eb422b4aac9"}, - {file = "black-26.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1de0f7d01cc894066a1153b738145b194414cc6eeaad8ef4397ac9abacf40f6b"}, - {file = "black-26.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:91a68ae46bf07868963671e4d05611b179c2313301bd756a89ad4e3b3db2325b"}, - {file = "black-26.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:be5e2fe860b9bd9edbf676d5b60a9282994c03fbbd40fe8f5e75d194f96064ca"}, - {file = "black-26.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:9dc8c71656a79ca49b8d3e2ce8103210c9481c57798b48deeb3a8bb02db5f115"}, - {file = "black-26.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b22b3810451abe359a964cc88121d57f7bce482b53a066de0f1584988ca36e79"}, - {file = "black-26.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:53c62883b3f999f14e5d30b5a79bd437236658ad45b2f853906c7cbe79de00af"}, - {file = "black-26.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:f016baaadc423dc960cdddf9acae679e71ee02c4c341f78f3179d7e4819c095f"}, - {file = "black-26.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:66912475200b67ef5a0ab665011964bf924745103f51977a78b4fb92a9fc1bf0"}, - {file = "black-26.1.0-py3-none-any.whl", hash = "sha256:1054e8e47ebd686e078c0bb0eaf31e6ce69c966058d122f2c0c950311f9f3ede"}, - {file = "black-26.1.0.tar.gz", hash = "sha256:d294ac3340eef9c9eb5d29288e96dc719ff269a88e27b396340459dd85da4c58"}, -] - -[package.dependencies] -click = ">=8.0.0" -mypy-extensions = ">=0.4.3" -packaging = ">=22.0" -pathspec = ">=1.0.0" -platformdirs = ">=2" -pytokens = ">=0.3.0" - -[package.extras] -colorama = ["colorama (>=0.4.3)"] -d = ["aiohttp (>=3.10)"] -jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] -uvloop = ["uvloop (>=0.15.2)"] - [[package]] name = "blessed" version = "1.25.0" @@ -535,21 +472,6 @@ files = [ {file = "charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a"}, ] -[[package]] -name = "click" -version = "8.3.1" -description = "Composable command line interface toolkit" -optional = false -python-versions = ">=3.10" -groups = ["dev"] -files = [ - {file = "click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6"}, - {file = "click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a"}, -] - -[package.dependencies] -colorama = {version = "*", markers = "platform_system == \"Windows\""} - [[package]] name = "colorama" version = "0.4.6" @@ -1100,42 +1022,6 @@ tzdata = {version = "*", markers = "platform_system == \"Windows\""} [package.extras] tzdata = ["tzdata"] -[[package]] -name = "flake8" -version = "7.3.0" -description = "the modular source code checker: pep8 pyflakes and co" -optional = false -python-versions = ">=3.9" -groups = ["dev"] -files = [ - {file = "flake8-7.3.0-py2.py3-none-any.whl", hash = "sha256:b9696257b9ce8beb888cdbe31cf885c90d31928fe202be0889a7cdafad32f01e"}, - {file = "flake8-7.3.0.tar.gz", hash = "sha256:fe044858146b9fc69b551a4b490d69cf960fcb78ad1edcb84e7fbb1b4a8e3872"}, -] - -[package.dependencies] -mccabe = ">=0.7.0,<0.8.0" -pycodestyle = ">=2.14.0,<2.15.0" -pyflakes = ">=3.4.0,<3.5.0" - -[[package]] -name = "flake8-bugbear" -version = "25.11.29" -description = "A plugin for flake8 finding likely bugs and design problems in your program. Contains warnings that don't belong in pyflakes and pycodestyle." -optional = false -python-versions = ">=3.10" -groups = ["dev"] -files = [ - {file = "flake8_bugbear-25.11.29-py3-none-any.whl", hash = "sha256:9bf15e2970e736d2340da4c0a70493db964061c9c38f708cfe1f7b2d87392298"}, - {file = "flake8_bugbear-25.11.29.tar.gz", hash = "sha256:b5d06710f3d26e595541ad303ad4d5cb52578bd4bccbb2c2c0b2c72e243dafc8"}, -] - -[package.dependencies] -attrs = ">=22.2.0" -flake8 = ">=7.2.0" - -[package.extras] -dev = ["coverage", "hypothesis", "hypothesmith (>=0.2)", "pre-commit", "pytest", "tox"] - [[package]] name = "gunicorn" version = "23.0.0" @@ -1197,22 +1083,6 @@ files = [ {file = "iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730"}, ] -[[package]] -name = "isort" -version = "7.0.0" -description = "A Python utility / library to sort Python imports." -optional = false -python-versions = ">=3.10.0" -groups = ["dev"] -files = [ - {file = "isort-7.0.0-py3-none-any.whl", hash = "sha256:1bcabac8bc3c36c7fb7b98a76c8abb18e0f841a3ba81decac7691008592499c1"}, - {file = "isort-7.0.0.tar.gz", hash = "sha256:5513527951aadb3ac4292a41a16cbc50dd1642432f5e8c20057d414bdafb4187"}, -] - -[package.extras] -colors = ["colorama"] -plugins = ["setuptools"] - [[package]] name = "jinxed" version = "1.3.0" @@ -1280,18 +1150,6 @@ profiling = ["gprof2dot"] rtd = ["ipykernel", "jupyter_sphinx", "mdit-py-plugins (>=0.5.0)", "myst-parser", "pyyaml", "sphinx", "sphinx-book-theme (>=1.0,<2.0)", "sphinx-copybutton", "sphinx-design"] testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions", "requests"] -[[package]] -name = "mccabe" -version = "0.7.0" -description = "McCabe checker, plugin for flake8" -optional = false -python-versions = ">=3.6" -groups = ["dev"] -files = [ - {file = "mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e"}, - {file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"}, -] - [[package]] name = "mdurl" version = "0.1.2" @@ -1304,18 +1162,6 @@ files = [ {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"}, ] -[[package]] -name = "mypy-extensions" -version = "1.1.0" -description = "Type system extensions for programs checked with the mypy type checker." -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505"}, - {file = "mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558"}, -] - [[package]] name = "packaging" version = "25.0" @@ -1328,41 +1174,6 @@ files = [ {file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"}, ] -[[package]] -name = "pathspec" -version = "1.0.3" -description = "Utility library for gitignore style pattern matching of file paths." -optional = false -python-versions = ">=3.9" -groups = ["dev"] -files = [ - {file = "pathspec-1.0.3-py3-none-any.whl", hash = "sha256:e80767021c1cc524aa3fb14bedda9c34406591343cc42797b386ce7b9354fb6c"}, - {file = "pathspec-1.0.3.tar.gz", hash = "sha256:bac5cf97ae2c2876e2d25ebb15078eb04d76e4b98921ee31c6f85ade8b59444d"}, -] - -[package.extras] -hyperscan = ["hyperscan (>=0.7)"] -optional = ["typing-extensions (>=4)"] -re2 = ["google-re2 (>=1.1)"] -tests = ["pytest (>=9)", "typing-extensions (>=4.15)"] - -[[package]] -name = "platformdirs" -version = "4.5.1" -description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." -optional = false -python-versions = ">=3.10" -groups = ["dev"] -files = [ - {file = "platformdirs-4.5.1-py3-none-any.whl", hash = "sha256:d03afa3963c806a9bed9d5125c8f4cb2fdaf74a55ab60e5d59b3fde758104d31"}, - {file = "platformdirs-4.5.1.tar.gz", hash = "sha256:61d5cdcc6065745cdd94f0f878977f8de9437be93de97c1c12f853c9c0cdcbda"}, -] - -[package.extras] -docs = ["furo (>=2025.9.25)", "proselint (>=0.14)", "sphinx (>=8.2.3)", "sphinx-autodoc-typehints (>=3.2)"] -test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=8.4.2)", "pytest-cov (>=7)", "pytest-mock (>=3.15.1)"] -type = ["mypy (>=1.18.2)"] - [[package]] name = "pluggy" version = "1.6.0" @@ -1396,18 +1207,6 @@ files = [ {file = "psycopg2-2.9.11.tar.gz", hash = "sha256:964d31caf728e217c697ff77ea69c2ba0865fa41ec20bb00f0977e62fdcc52e3"}, ] -[[package]] -name = "pycodestyle" -version = "2.14.0" -description = "Python style guide checker" -optional = false -python-versions = ">=3.9" -groups = ["dev"] -files = [ - {file = "pycodestyle-2.14.0-py2.py3-none-any.whl", hash = "sha256:dd6bf7cb4ee77f8e016f9c8e74a35ddd9f67e1d5fd4184d86c3b98e07099f42d"}, - {file = "pycodestyle-2.14.0.tar.gz", hash = "sha256:c4b5b517d278089ff9d0abdec919cd97262a3367449ea1c8b49b91529167b783"}, -] - [[package]] name = "pycparser" version = "2.23" @@ -1421,18 +1220,6 @@ files = [ {file = "pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2"}, ] -[[package]] -name = "pyflakes" -version = "3.4.0" -description = "passive checker of Python programs" -optional = false -python-versions = ">=3.9" -groups = ["dev"] -files = [ - {file = "pyflakes-3.4.0-py2.py3-none-any.whl", hash = "sha256:f742a7dbd0d9cb9ea41e9a24a918996e8170c799fa528688d40dd582c8265f4f"}, - {file = "pyflakes-3.4.0.tar.gz", hash = "sha256:b24f96fafb7d2ab0ec5075b7350b3d2d2218eab42003821c06344973d3ea2f58"}, -] - [[package]] name = "pygments" version = "2.19.2" @@ -1500,6 +1287,26 @@ pygments = ">=2.7.2" [package.extras] dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"] +[[package]] +name = "pytest-cov" +version = "7.0.0" +description = "Pytest plugin for measuring coverage." +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861"}, + {file = "pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1"}, +] + +[package.dependencies] +coverage = {version = ">=7.10.6", extras = ["toml"]} +pluggy = ">=1.2" +pytest = ">=7" + +[package.extras] +testing = ["process-tests", "pytest-xdist", "virtualenv"] + [[package]] name = "pytest-django" version = "4.11.1" @@ -1582,21 +1389,6 @@ files = [ {file = "python_decouple-3.8-py3-none-any.whl", hash = "sha256:d0d45340815b25f4de59c974b855bb38d03151d81b037d9e3f463b0c9f8cbd66"}, ] -[[package]] -name = "pytokens" -version = "0.3.0" -description = "A Fast, spec compliant Python 3.14+ tokenizer that runs on older Pythons." -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "pytokens-0.3.0-py3-none-any.whl", hash = "sha256:95b2b5eaf832e469d141a378872480ede3f251a5a5041b8ec6e581d3ac71bbf3"}, - {file = "pytokens-0.3.0.tar.gz", hash = "sha256:2f932b14ed08de5fcf0b391ace2642f858f1394c0857202959000b68ed7a458a"}, -] - -[package.extras] -dev = ["black", "build", "mypy", "pytest", "pytest-cov", "setuptools", "tox", "twine", "wheel"] - [[package]] name = "pytz" version = "2025.2" @@ -1753,6 +1545,35 @@ pygments = ">=2.13.0,<3.0.0" [package.extras] jupyter = ["ipywidgets (>=7.5.1,<9)"] +[[package]] +name = "ruff" +version = "0.14.14" +description = "An extremely fast Python linter and code formatter, written in Rust." +optional = false +python-versions = ">=3.7" +groups = ["dev"] +files = [ + {file = "ruff-0.14.14-py3-none-linux_armv6l.whl", hash = "sha256:7cfe36b56e8489dee8fbc777c61959f60ec0f1f11817e8f2415f429552846aed"}, + {file = "ruff-0.14.14-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6006a0082336e7920b9573ef8a7f52eec837add1265cc74e04ea8a4368cd704c"}, + {file = "ruff-0.14.14-py3-none-macosx_11_0_arm64.whl", hash = "sha256:026c1d25996818f0bf498636686199d9bd0d9d6341c9c2c3b62e2a0198b758de"}, + {file = "ruff-0.14.14-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f666445819d31210b71e0a6d1c01e24447a20b85458eea25a25fe8142210ae0e"}, + {file = "ruff-0.14.14-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3c0f18b922c6d2ff9a5e6c3ee16259adc513ca775bcf82c67ebab7cbd9da5bc8"}, + {file = "ruff-0.14.14-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1629e67489c2dea43e8658c3dba659edbfd87361624b4040d1df04c9740ae906"}, + {file = "ruff-0.14.14-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:27493a2131ea0f899057d49d303e4292b2cae2bb57253c1ed1f256fbcd1da480"}, + {file = "ruff-0.14.14-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:01ff589aab3f5b539e35db38425da31a57521efd1e4ad1ae08fc34dbe30bd7df"}, + {file = "ruff-0.14.14-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1cc12d74eef0f29f51775f5b755913eb523546b88e2d733e1d701fe65144e89b"}, + {file = "ruff-0.14.14-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bb8481604b7a9e75eff53772496201690ce2687067e038b3cc31aaf16aa0b974"}, + {file = "ruff-0.14.14-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:14649acb1cf7b5d2d283ebd2f58d56b75836ed8c6f329664fa91cdea19e76e66"}, + {file = "ruff-0.14.14-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e8058d2145566510790eab4e2fad186002e288dec5e0d343a92fe7b0bc1b3e13"}, + {file = "ruff-0.14.14-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e651e977a79e4c758eb807f0481d673a67ffe53cfa92209781dfa3a996cf8412"}, + {file = "ruff-0.14.14-py3-none-musllinux_1_2_i686.whl", hash = "sha256:cc8b22da8d9d6fdd844a68ae937e2a0adf9b16514e9a97cc60355e2d4b219fc3"}, + {file = "ruff-0.14.14-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:16bc890fb4cc9781bb05beb5ab4cd51be9e7cb376bf1dd3580512b24eb3fda2b"}, + {file = "ruff-0.14.14-py3-none-win32.whl", hash = "sha256:b530c191970b143375b6a68e6f743800b2b786bbcf03a7965b06c4bf04568167"}, + {file = "ruff-0.14.14-py3-none-win_amd64.whl", hash = "sha256:3dde1435e6b6fe5b66506c1dff67a421d0b7f6488d466f651c07f4cab3bf20fd"}, + {file = "ruff-0.14.14-py3-none-win_arm64.whl", hash = "sha256:56e6981a98b13a32236a72a8da421d7839221fa308b223b9283312312e5ac76c"}, + {file = "ruff-0.14.14.tar.gz", hash = "sha256:2d0f819c9a90205f3a867dbbd0be083bee9912e170fd7d9704cc8ae45824896b"}, +] + [[package]] name = "s3transfer" version = "0.16.0" @@ -1932,4 +1753,4 @@ files = [ [metadata] lock-version = "2.1" python-versions = "^3.12" -content-hash = "1bd0caa435519c1d2699f2f225f03b1885d77837529a8add2227b5b9a5bcf157" +content-hash = "bad8a7860ed5dc225eb4a7a878ffe57a39415dab543b02b6348f1fcc591e95e5" diff --git a/poetry.toml b/poetry.toml new file mode 100644 index 00000000..52ade29d --- /dev/null +++ b/poetry.toml @@ -0,0 +1,22 @@ +# Poetry configuration for consistent development environment across team +# https://python-poetry.org/docs/configuration/ + +[virtualenvs] +# Create .venv in project directory for easier IDE integration and visibility +in-project = true + +# Use Python version from pyproject.toml, not active shell Python +# Ensures consistency across team members +prefer-active-python = false + +[installer] +# Enable parallel installation for faster dependency resolution (default in 2.x) +parallel = true + +# Limit concurrent workers to prevent resource exhaustion +max-workers = 10 + +[experimental] +# Use system git client instead of dulwich for better performance +# Helpful if you have git dependencies +system-git-client = true diff --git a/pyproject.toml b/pyproject.toml index acaf76b5..83b230ea 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,26 +38,33 @@ django-health-check = "^3.20" # Was ^3.18 [tool.poetry.group.dev.dependencies] bandit = "^1.9" # Was ^1.8 -black = ">=25.0" coverage = "^7.0" # Keep django-debug-toolbar = "^5.0" # Was ^4.4 (6.x has API changes) factory_boy = "^3.3" # Keep -flake8 = "^7.0" # Keep -flake8-bugbear = ">=25.0" -isort = ">=7.0" pyhumps = "^3.8" # Keep pytest = ">=9.0" pytest-django = "^4.8" # Keep pytest-env = "^1.1" # Keep pytest-mock = "^3.14" # Keep responses = "^0.25" # Keep +ruff = "^0.14.14" +pytest-cov = "^7.0.0" -[tool.isort] -line_length = 88 -multi_line_output = 3 -include_trailing_comma = true -force_grid_wrap = 0 -use_parentheses = true +[tool.ruff] +line-length = 88 +exclude = [ + "*/migrations/*", + ".venv", + "__pycache__", +] + +[tool.ruff.lint] +select = ["E", "F", "W", "I"] +ignore = ["E501"] # Line too long (handled by formatter) + +[tool.ruff.format] +quote-style = "double" +indent-style = "space" [build-system] requires = ["poetry>=2.3.0"] diff --git a/src/core/hashers.py b/src/core/hashers.py index b204954a..b2c1447b 100644 --- a/src/core/hashers.py +++ b/src/core/hashers.py @@ -1,6 +1,7 @@ """ Custom password hashers with tuned parameters for web authentication. """ + from django.contrib.auth.hashers import Argon2PasswordHasher @@ -21,6 +22,7 @@ class TunedArgon2PasswordHasher(Argon2PasswordHasher): - OWASP Password Storage Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html - Django default uses 100 MB which is optimized for maximum security but too slow for web """ + time_cost = 2 memory_cost = 19456 # 19 MB (OWASP minimum recommendation) - parallelism = 1 # Single-threaded for web servers + parallelism = 1 # Single-threaded for web servers diff --git a/src/core/serializers.py b/src/core/serializers.py index b0b9f426..6e3726fe 100644 --- a/src/core/serializers.py +++ b/src/core/serializers.py @@ -1,4 +1,3 @@ -from django.contrib.auth import get_user_model from dj_rest_auth.registration.serializers import ( RegisterSerializer as BaseRegisterSerializer, ) @@ -7,6 +6,7 @@ PasswordResetConfirmSerializer as BasePasswordResetConfirmSerializer, ) from dj_rest_auth.serializers import UserDetailsSerializer as BaseUserDetailsSerializer +from django.contrib.auth import get_user_model from rest_framework import serializers from core.models import Profile @@ -78,9 +78,12 @@ def validate(self, data): data["username"] = data.get("email", "") # Check for duplicate email - this prevents hitting DB unique constraint from django.contrib.auth import get_user_model + User = get_user_model() if User.objects.filter(email=data.get("email")).exists(): - raise serializers.ValidationError({"email": ["A user with that email already exists."]}) + raise serializers.ValidationError( + {"email": ["A user with that email already exists."]} + ) return data diff --git a/src/core/tasks.py b/src/core/tasks.py index e8e96e8a..f0008a65 100644 --- a/src/core/tasks.py +++ b/src/core/tasks.py @@ -5,7 +5,6 @@ from django.contrib.auth.models import User as AuthUser from django.core.mail import send_mail from django.template.loader import render_to_string -from django_q.tasks import async_task from mailchimp3 import MailChimp logger = logging.getLogger(__name__) diff --git a/src/core/urls.py b/src/core/urls.py index 6798eedc..a5371c4f 100644 --- a/src/core/urls.py +++ b/src/core/urls.py @@ -1,7 +1,7 @@ -from django.urls import include, path -from django.views.generic import TemplateView from dj_rest_auth.registration.views import VerifyEmailView from dj_rest_auth.views import PasswordChangeView, PasswordResetConfirmView +from django.urls import include, path +from django.views.generic import TemplateView from rest_framework_simplejwt.views import TokenRefreshView, TokenVerifyView from . import views @@ -13,6 +13,7 @@ class DummyPasswordResetConfirmView(TemplateView): is handled by the PasswordResetConfirmView via POST. This pattern exists to satisfy Django's reverse() call in password reset emails. """ + template_name = "" diff --git a/src/core/views.py b/src/core/views.py index d6e242e1..90b7a95a 100644 --- a/src/core/views.py +++ b/src/core/views.py @@ -1,9 +1,9 @@ +from dj_rest_auth.registration.views import RegisterView as BaseRegisterView from django.contrib.auth.models import User from django.utils.decorators import method_decorator from django.views.decorators.debug import sensitive_post_parameters from drf_yasg.openapi import IN_QUERY, TYPE_STRING, Parameter from drf_yasg.utils import swagger_auto_schema -from dj_rest_auth.registration.views import RegisterView as BaseRegisterView from rest_framework.exceptions import NotFound, ValidationError from rest_framework.generics import RetrieveUpdateAPIView from rest_framework.permissions import IsAuthenticated diff --git a/src/gunicorn_config.py b/src/gunicorn_config.py index ddf53692..52378c86 100644 --- a/src/gunicorn_config.py +++ b/src/gunicorn_config.py @@ -69,6 +69,7 @@ worker_connections = 1000 timeout = 30 keepalive = 2 +worker_tmp_dir = "/dev/shm" # preload_app - Load application code before forking worker processes. # This conserves memory and speeds up server boot times by loading @@ -211,8 +212,8 @@ def worker_int(worker): worker.log.info("worker received INT or QUIT signal") # get traceback info - import threading import sys + import threading import traceback id2name = {th.ident: th.name for th in threading.enumerate()} diff --git a/src/settings/__init__.py b/src/settings/__init__.py index a3a41a2d..0cbf47eb 100644 --- a/src/settings/__init__.py +++ b/src/settings/__init__.py @@ -5,6 +5,7 @@ To change settings file: `DJANGO_ENV=production python manage.py runserver` """ + from os import environ from split_settings.tools import include, optional diff --git a/src/settings/components/authentication.py b/src/settings/components/authentication.py index aed23dac..47a3adbe 100644 --- a/src/settings/components/authentication.py +++ b/src/settings/components/authentication.py @@ -61,7 +61,7 @@ # https://docs.allauth.org/en/latest/account/configuration.html ACCOUNT_LOGIN_METHODS = {"email"} # Replaces ACCOUNT_AUTHENTICATION_METHOD = "email" ACCOUNT_SIGNUP_FIELDS = [ - "email*", # Required (replaces ACCOUNT_EMAIL_REQUIRED = True) + "email*", # Required (replaces ACCOUNT_EMAIL_REQUIRED = True) "password1*", "password2*", # Note: username not included (replaces ACCOUNT_USERNAME_REQUIRED = False) @@ -88,7 +88,7 @@ # Development default: Use a simple string since HS256 needs symmetric key jwt_secret_key = config( "JWT_SECRET_KEY", - default="dev-secret-key-change-in-production-to-something-secure-and-random" + default="dev-secret-key-change-in-production-to-something-secure-and-random", ) # Simple JWT settings (replaces djangorestframework-jwt) diff --git a/src/settings/components/frontend.py b/src/settings/components/frontend.py index ace8b2ff..27a1c4ec 100644 --- a/src/settings/components/frontend.py +++ b/src/settings/components/frontend.py @@ -1,6 +1,7 @@ """ Configs for the temporary frontend app """ + from settings.components import config RECAPTCHA_PUBLIC_KEY = config("RECAPTCHA_PUBLIC_KEY", default="") diff --git a/src/settings/components/logging.py b/src/settings/components/logging.py index f320ad06..2edd28d8 100644 --- a/src/settings/components/logging.py +++ b/src/settings/components/logging.py @@ -42,7 +42,10 @@ def traces_sampler(sampling_context): # Sample health check endpoints at 1% (to catch errors but reduce noise) if request_path in ["/healthz", "/health", "/readiness", "/liveness"]: return 0.01 - if any(health in transaction_name for health in ["/healthz", "/health", "/readiness", "/liveness"]): + if any( + health in transaction_name + for health in ["/healthz", "/health", "/readiness", "/liveness"] + ): return 0.01 # Use the configured sample rate for everything else @@ -64,6 +67,7 @@ def before_send_transaction(event, hint): # noqa: ARG001 return event + # Sentry.io error tracking # https://docs.sentry.io/platforms/python/django/ SENTRY_DSN = config("SENTRY_DSN", default="") @@ -87,7 +91,11 @@ def before_send_transaction(event, hint): # noqa: ARG001 before_send_transaction=before_send_transaction, # Set profiles_sample_rate to 1.0 to profile 100% of sampled transactions. # We recommend adjusting this value in production. - profiles_sample_rate=config("SENTRY_PROFILES_SAMPLE_RATE", default=1.0, cast=float), + profiles_sample_rate=config( + "SENTRY_PROFILES_SAMPLE_RATE", default=1.0, cast=float + ), # Send default PII like user IP and user ID to Sentry - send_default_pii=config("SENTRY_SEND_DEFAULT_PII", default=True, cast=strtobool), + send_default_pii=config( + "SENTRY_SEND_DEFAULT_PII", default=True, cast=strtobool + ), ) diff --git a/src/settings/environments/development.py b/src/settings/environments/development.py index 39026fc1..991f10b2 100644 --- a/src/settings/environments/development.py +++ b/src/settings/environments/development.py @@ -2,6 +2,7 @@ This file contains all the settings that defines the development server. SECURITY WARNING: don't run with debug turned on in production! """ + from settings.components.authentication import MIDDLEWARE from settings.components.base import INSTALLED_APPS diff --git a/src/settings/environments/production.py b/src/settings/environments/production.py index 869ddba6..02c8d5e3 100644 --- a/src/settings/environments/production.py +++ b/src/settings/environments/production.py @@ -40,4 +40,3 @@ DEFAULT_FILE_STORAGE = "custom_storages.MediaStorage" EMAIL_BACKEND = "anymail.backends.mandrill.EmailBackend" - diff --git a/src/settings/environments/staging.py b/src/settings/environments/staging.py index fc50289b..3c474677 100644 --- a/src/settings/environments/staging.py +++ b/src/settings/environments/staging.py @@ -38,4 +38,3 @@ MEDIAFILES_LOCATION = "media" STATICFILES_STORAGE = "custom_storages.StaticStorage" DEFAULT_FILE_STORAGE = "custom_storages.MediaStorage" - diff --git a/src/settings/environments/testing.py b/src/settings/environments/testing.py index 758a4b64..481e2d19 100644 --- a/src/settings/environments/testing.py +++ b/src/settings/environments/testing.py @@ -1,4 +1,3 @@ -from settings.components import BASE_DIR from settings.components.base import INSTALLED_APPS from settings.components.rest import REST_FRAMEWORK diff --git a/src/tests/factories.py b/src/tests/factories.py index 24c0aaa5..d029e1b9 100644 --- a/src/tests/factories.py +++ b/src/tests/factories.py @@ -1,9 +1,9 @@ import threading +import factory from allauth.account.models import EmailAddress from django.conf import settings from django.db.models.signals import post_save -import factory from factory import ( LazyAttribute, LazyFunction, diff --git a/src/tests/integration/test_rest_login.py b/src/tests/integration/test_rest_login.py index 7bc035b0..738e9bfc 100644 --- a/src/tests/integration/test_rest_login.py +++ b/src/tests/integration/test_rest_login.py @@ -1,4 +1,3 @@ -import pytest from django.contrib.auth.models import User from django.urls import reverse from rest_framework.test import APIClient diff --git a/src/tests/unit/test_tokens.py b/src/tests/unit/test_tokens.py index 364bb01f..4563f5ce 100644 --- a/src/tests/unit/test_tokens.py +++ b/src/tests/unit/test_tokens.py @@ -1,4 +1,5 @@ import pytest + from core.handlers import CustomTokenObtainPairSerializer from .. import factories as f