diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..ea4ccdc --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,53 @@ + +name: API CI + +on: + push: + branches: + - main + - feature/production-ready-api + pull_request: + branches: + - main + +permissions: + contents: read + +jobs: + build: + name: Validate and build API + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + cache: pip + cache-dependency-path: src/requirements.txt + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r src/requirements.txt + + - name: Check Python syntax + run: | + python -m compileall -q src/app src/migrations + + - name: Run backend tests + working-directory: ./src + run: python -m pytest -v + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build API Docker image + uses: docker/build-push-action@v6 + with: + context: ./src + push: false + tags: album-api:ci diff --git a/.github/workflows/publish-azure.yaml b/.github/workflows/publish-azure.yaml new file mode 100644 index 0000000..fafa527 --- /dev/null +++ b/.github/workflows/publish-azure.yaml @@ -0,0 +1,88 @@ +name: Publish API to Azure ACR + +on: + push: + branches: + - main + +permissions: + contents: read + id-token: write + +jobs: + publish: + name: Build and publish API image + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Login to Azure using OIDC + uses: azure/login@v2 + with: + client-id: ${{ vars.AZURE_CLIENT_ID }} + tenant-id: ${{ vars.AZURE_TENANT_ID }} + subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }} + + - name: Login to Azure Container Registry + run: az acr login --name ca7e04894508acr + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build and push API image + uses: docker/build-push-action@v6 + with: + context: ./src + push: true + tags: | + ca7e04894508acr.azurecr.io/album-api:latest + ca7e04894508acr.azurecr.io/album-api:${{ github.sha }} + + deploy: + name: Deploy API to Azure Container Apps + runs-on: ubuntu-latest + needs: publish + + steps: + # The template lives in the deployment repo, which owns all IaC. + - name: Checkout deployment repository + uses: actions/checkout@v4 + with: + repository: JacobusTheOne/album-app-deployment + token: ${{ secrets.DEPLOY_REPO_TOKEN }} + path: deployment + + - name: Login to Azure using deployment identity + uses: azure/login@v2 + with: + client-id: ${{ vars.AZURE_DEPLOY_CLIENT_ID }} + tenant-id: ${{ vars.AZURE_TENANT_ID }} + subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }} + + # Deploying the template rather than `az containerapp update --image` + # keeps Bicep the single source of truth: an imperative image update + # would be rolled back by the next deployment, and would never apply + # the probes or identity configuration the template declares. + - name: Deploy API with Bicep + env: + DATABASE_URL: ${{ secrets.DATABASE_URL }} + run: | + az deployment group create \ + --resource-group rg-azure-container-apps-dev \ + --template-file deployment/infra/api.bicep \ + --parameters apiImageTag='${{ github.sha }}' \ + databaseUrl="$DATABASE_URL" + + # az deployment needs no extension, but az containerapp does. + - name: Install Container Apps CLI extension + run: az extension add --name containerapp --upgrade + + - name: Verify deployment + run: | + az containerapp show \ + --name album-api \ + --resource-group rg-azure-container-apps-dev \ + --query "{image:properties.template.containers[0].image,latestRevision:properties.latestRevisionName}" \ + --output table diff --git a/.gitignore b/.gitignore index d445939..7a170bf 100644 --- a/.gitignore +++ b/.gitignore @@ -10,7 +10,8 @@ .LSOverride # Icon must end with two \r -Icon +Icon + # Thumbnails ._* @@ -226,3 +227,12 @@ cython_debug/ # Custom rules (everything added below won't be overriden by 'Generate .gitignore File' if you use 'Update' option) + +# Local SQLite databases +*.db +*.db-shm +*.db-wal + +.venv/ +__pycache__/ +.pytest_cache/ diff --git a/README.md b/README.md index fe1e9df..e1ca32e 100644 --- a/README.md +++ b/README.md @@ -6,3 +6,62 @@ This backend Album API sample is available in other languages: | [C#](https://github.com/azure-samples/containerapps-albumapi-csharp) | [Go](https://github.com/azure-samples/containerapps-albumapi-go) | [JavaScript](https://github.com/azure-samples/containerapps-albumapi-javascript) | [Java](https://github.com/azure-samples/containerapps-albumapi-java) | | -------------------------------------------------------------------- | ---------------------------------------------------------------- | ------------------------------------------------------------------------ | ---------------------------------------------------------------- | + +--- + +## API Endpoints + +The API exposes health and readiness endpoints for monitoring and deployment orchestration. + +### `GET /health` + +Checks whether the API application is running and responding. + +Successful response: + +HTTP `200 OK` + +```json +{ + "status": "healthy" +} +``` + +This endpoint does not check database connectivity. It is intended for liveness checks. + +### `GET /ready` + +Checks whether the API is ready to serve requests by executing a simple database query (`SELECT 1`). + +When the database is available: + +HTTP `200 OK` + +```json +{ + "status": "ready" +} +``` + +When the database is unavailable: + +HTTP `503 Service Unavailable` + +```json +{ + "detail": "database unavailable" +} +``` + +This endpoint is intended for readiness checks. A replica should not receive application traffic when its database dependency is unavailable. + +### Why separate health and readiness checks? + +An API process can be running while its database connection is unavailable. + +Separating these checks allows the platform to distinguish between: + +- **Liveness:** Is the application process responding? +- **Readiness:** Can the application currently serve requests? + +This is particularly useful when Azure Container Apps starts new replicas, restarts containers, or handles temporary database connectivity issues. diff --git a/src/.dockerignore b/src/.dockerignore index 325c65d..dfc7942 100644 --- a/src/.dockerignore +++ b/src/.dockerignore @@ -23,4 +23,5 @@ **/obj **/secrets.dev.yaml **/values.dev.yaml +**/*.db README.md diff --git a/src/Dockerfile b/src/Dockerfile index c07b5b9..3b906df 100644 --- a/src/Dockerfile +++ b/src/Dockerfile @@ -1,7 +1,43 @@ -FROM python:3.11 + +# Use a lightweight Python 3.11 image. +FROM python:3.11-slim + WORKDIR /code + +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 + +# Install Microsoft ODBC Driver 18 for SQL Server. +RUN set -eux; \ + apt-get update; \ + apt-get install -y --no-install-recommends ca-certificates curl; \ + . /etc/os-release; \ + DEBIAN_VERSION="${VERSION_ID%%.*}"; \ + curl -fsSLo /tmp/packages-microsoft-prod.deb \ + "https://packages.microsoft.com/config/debian/${DEBIAN_VERSION}/packages-microsoft-prod.deb"; \ + dpkg -i /tmp/packages-microsoft-prod.deb; \ + rm /tmp/packages-microsoft-prod.deb; \ + apt-get update; \ + ACCEPT_EULA=Y apt-get install -y --no-install-recommends \ + msodbcsql18 \ + libgssapi-krb5-2; \ + rm -rf /var/lib/apt/lists/* + +# Install Python dependencies. COPY requirements.txt . -RUN pip3 install --no-cache-dir -r requirements.txt +RUN pip install --no-cache-dir -r requirements.txt + +# Copy application source. COPY . . + +# Create a non-root user and prepare the database directory. +RUN useradd --create-home appuser \ + && mkdir -p /data \ + && chown -R appuser:appuser /code /data + +USER appuser + EXPOSE 8080 -CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8080"] \ No newline at end of file + +# Initialize the database, seed it, then start FastAPI. +CMD ["sh", "-c", "alembic upgrade head && python -m app.init_db && exec uvicorn app.main:app --host 0.0.0.0 --port 8080"] diff --git a/src/alembic.ini b/src/alembic.ini new file mode 100644 index 0000000..0959c0c --- /dev/null +++ b/src/alembic.ini @@ -0,0 +1,148 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts. +# this is typically a path given in POSIX (e.g. forward slashes) +# format, relative to the token %(here)s which refers to the location of this +# ini file +script_location = %(here)s/migrations + +# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s +# Uncomment the line below if you want the files to be prepended with date and time +# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file +# for all available tokens +# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s +# Or organize into date-based subdirectories (requires recursive_version_locations = true) +# file_template = %%(year)d/%%(month).2d/%%(day).2d_%%(hour).2d%%(minute).2d_%%(second).2d_%%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. for multiple paths, the path separator +# is defined by "path_separator" below. +prepend_sys_path = . + + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the tzdata library which can be installed by adding +# `alembic[tz]` to the pip requirements. +# string value is passed to ZoneInfo() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; This defaults +# to /versions. When using multiple version +# directories, initial revisions must be specified with --version-path. +# The path separator used here should be the separator specified by "path_separator" +# below. +# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions + +# path_separator; This indicates what character is used to split lists of file +# paths, including version_locations and prepend_sys_path within configparser +# files such as alembic.ini. +# The default rendered in new alembic.ini files is "os", which uses os.pathsep +# to provide os-dependent path splitting. +# +# Note that in order to support legacy alembic.ini files, this default does NOT +# take place if path_separator is not present in alembic.ini. If this +# option is omitted entirely, fallback logic is as follows: +# +# 1. Parsing of the version_locations option falls back to using the legacy +# "version_path_separator" key, which if absent then falls back to the legacy +# behavior of splitting on spaces and/or commas. +# 2. Parsing of the prepend_sys_path option falls back to the legacy +# behavior of splitting on spaces, commas, or colons. +# +# Valid values for path_separator are: +# +# path_separator = : +# path_separator = ; +# path_separator = space +# path_separator = newline +# +# Use os.pathsep. Default configuration used for new projects. +path_separator = os + +# set to 'true' to search source files recursively +# in each "version_locations" directory +# new in Alembic version 1.10 +# recursive_version_locations = false + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +# database URL. This is consumed by the user-maintained env.py script only. +# other means of configuring database URLs may be customized within the env.py +# file. + + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +# hooks = black +# black.type = console_scripts +# black.entrypoint = black +# black.options = -l 79 REVISION_SCRIPT_FILENAME + +# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module +# hooks = ruff +# ruff.type = module +# ruff.module = ruff +# ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Alternatively, use the exec runner to execute a binary found on your PATH +# hooks = ruff +# ruff.type = exec +# ruff.executable = ruff +# ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Logging configuration. This is also consumed by the user-maintained +# env.py script only. +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARNING +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARNING +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/src/app/database.py b/src/app/database.py new file mode 100644 index 0000000..0cd18be --- /dev/null +++ b/src/app/database.py @@ -0,0 +1,153 @@ + +import os +import struct +import time +import logging +from contextlib import contextmanager + +from sqlalchemy import create_engine, event +from sqlalchemy.exc import InterfaceError, OperationalError +from sqlalchemy.orm import DeclarativeBase, sessionmaker + +logger = logging.getLogger(__name__) + +DATABASE_URL = os.getenv( + "DATABASE_URL", + "sqlite:///./albums.db" +) + +connect_args = ( + {"check_same_thread": False} + if DATABASE_URL.startswith("sqlite") + else {} +) + +engine = create_engine( + DATABASE_URL, + connect_args=connect_args, + pool_pre_ping=True +) + + +# Configure Microsoft Entra access-token authentication for Azure SQL. +if DATABASE_URL.startswith("mssql+pyodbc"): + from azure.identity import DefaultAzureCredential + + credential = DefaultAzureCredential() + + @event.listens_for(engine, "do_connect") + def provide_access_token(dialect, conn_rec, cargs, cparams): + # SQLAlchemy adds Trusted_Connection when no username/password + # is supplied. Remove it because we authenticate using a token. + connection_parts = cargs[0].split(";") + connection_parts = [ + part for part in connection_parts + if not part.lower().startswith("trusted_connection=") + ] + cargs[0] = ";".join(connection_parts) + + # Obtain an access token for Azure SQL. + token = credential.get_token( + "https://database.windows.net/.default" + ).token.encode("utf-16-le") + + # Package the token in the format expected by ODBC Driver 18. + token_struct = struct.pack( + f" None: + """Run migrations without creating a database connection.""" + + url = engine.url.render_as_string(hide_password=False) + + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + compare_type=True + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + """Run migrations using an actual database connection.""" + + with connect_with_retry() as connection: + context.configure( + connection=connection, + target_metadata=target_metadata, + compare_type=True + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() \ No newline at end of file diff --git a/src/migrations/script.py.mako b/src/migrations/script.py.mako new file mode 100644 index 0000000..1101630 --- /dev/null +++ b/src/migrations/script.py.mako @@ -0,0 +1,28 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + """Upgrade schema.""" + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + """Downgrade schema.""" + ${downgrades if downgrades else "pass"} diff --git a/src/migrations/versions/13ed76ca1e84_create_albums_table.py b/src/migrations/versions/13ed76ca1e84_create_albums_table.py new file mode 100644 index 0000000..d8592e6 --- /dev/null +++ b/src/migrations/versions/13ed76ca1e84_create_albums_table.py @@ -0,0 +1,41 @@ +"""create albums table + +Revision ID: 13ed76ca1e84 +Revises: +Create Date: 2026-10-31 09:46:58.489690 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '13ed76ca1e84' +down_revision: Union[str, Sequence[str], None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('albums', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('title', sa.String(length=200), nullable=False), + sa.Column('artist', sa.String(length=200), nullable=False), + sa.Column('price', sa.Float(), nullable=False), + sa.Column('image_url', sa.String(length=500), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_albums_id'), 'albums', ['id'], unique=False) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_albums_id'), table_name='albums') + op.drop_table('albums') + # ### end Alembic commands ### diff --git a/src/requirements.txt b/src/requirements.txt index 194d919..5a7294e 100644 --- a/src/requirements.txt +++ b/src/requirements.txt @@ -1,3 +1,9 @@ -fastapi>=0.68.0,<0.69.0 -pydantic>=1.8.0,<2.0.0 -uvicorn>=0.15.0,<0.16.0 +fastapi>=0.115,<1.0 +pydantic>=2.7,<3.0 +uvicorn[standard]>=0.30,<1.0 +SQLAlchemy>=2.0,<3.0 +alembic>=1.13,<2.0 +pyodbc>=5.0,<6.0 +azure-identity>=1.17,<2.0 +pytest>=8.0,<9.0 +httpx>=0.27,<1.0 \ No newline at end of file diff --git a/src/tests/test_main.py b/src/tests/test_main.py new file mode 100644 index 0000000..f349938 --- /dev/null +++ b/src/tests/test_main.py @@ -0,0 +1,84 @@ +from types import SimpleNamespace +from unittest.mock import Mock + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy.exc import SQLAlchemyError +from sqlalchemy.orm import Session + +from app.database import get_db +from app.main import app + + +@pytest.fixture +def db(): + return Mock(spec=Session) + + +@pytest.fixture +def client(db): + app.dependency_overrides[get_db] = lambda: db + + with TestClient(app) as test_client: + yield test_client + + app.dependency_overrides.clear() + + +def test_health_returns_healthy(client): + response = client.get("/health") + + assert response.status_code == 200 + assert response.json() == {"status": "healthy"} + + +def test_ready_returns_ready_when_database_is_available(client, db): + response = client.get("/ready") + + assert response.status_code == 200 + assert response.json() == {"status": "ready"} + + db.execute.assert_called_once() + + +def test_ready_returns_503_when_database_is_unavailable(client, db): + db.execute.side_effect = SQLAlchemyError("Database connection failed") + + response = client.get("/ready") + + assert response.status_code == 503 + assert response.json() == {"detail": "database unavailable"} + + +def test_albums_returns_album_data(client, db): + albums = [ + SimpleNamespace( + id=1, + title="Album One", + artist="Artist One", + price=19.99, + image_url="https://example.com/album1.jpg", + ), + SimpleNamespace( + id=2, + title="Album Two", + artist="Artist Two", + price=24.99, + image_url="https://example.com/album2.jpg", + ), + ] + + db.scalars.return_value.all.return_value = albums + + response = client.get("/albums") + + assert response.status_code == 200 + + data = response.json() + + assert len(data) == 2 + assert data[0]["id"] == 1 + assert data[0]["title"] == "Album One" + assert data[0]["artist"] == "Artist One" + assert data[0]["price"] == 19.99 + assert data[0]["image_url"] == "https://example.com/album1.jpg" \ No newline at end of file