From 4aefa6a702e3fa944b30a11fb61c3b277316f55e Mon Sep 17 00:00:00 2001 From: JacobusTheOne Date: Sat, 31 Oct 2026 12:59:31 -0300 Subject: [PATCH 01/11] Add database-backed API and production Docker setup --- .gitignore | 5 + src/.dockerignore | 1 + src/Dockerfile | 31 +++- src/alembic.ini | 148 ++++++++++++++++++ src/app/database.py | 40 +++++ src/app/init_db.py | 72 +++++++++ src/app/main.py | 63 +++++--- src/app/models.py | 34 ++++ src/app/schemas.py | 11 ++ src/migrations/README | 1 + src/migrations/env.py | 53 +++++++ src/migrations/script.py.mako | 28 ++++ .../13ed76ca1e84_create_albums_table.py | 41 +++++ src/requirements.txt | 9 +- 14 files changed, 507 insertions(+), 30 deletions(-) create mode 100644 src/alembic.ini create mode 100644 src/app/database.py create mode 100644 src/app/init_db.py create mode 100644 src/app/models.py create mode 100644 src/app/schemas.py create mode 100644 src/migrations/README create mode 100644 src/migrations/env.py create mode 100644 src/migrations/script.py.mako create mode 100644 src/migrations/versions/13ed76ca1e84_create_albums_table.py diff --git a/.gitignore b/.gitignore index d445939..b209c2d 100644 --- a/.gitignore +++ b/.gitignore @@ -226,3 +226,8 @@ 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 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..2f9283d 100644 --- a/src/Dockerfile +++ b/src/Dockerfile @@ -1,7 +1,32 @@ -FROM python:3.11 + +# Use a lightweight Python 3.11 image. +FROM python:3.11-slim + +# Set the application directory. WORKDIR /code + +# Prevent Python from writing bytecode and buffer logs. +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 + +# Install dependencies first for better Docker layer caching. 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 persistent data directory. +RUN useradd --create-home appuser \ + && mkdir -p /data \ + && chown -R appuser:appuser /code /data + +# Run the application without root privileges. +USER appuser + +# Document the application port. EXPOSE 8080 -CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8080"] \ No newline at end of file + +# Start FastAPI. +CMD ["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..a7d82e2 --- /dev/null +++ b/src/app/database.py @@ -0,0 +1,40 @@ +import os + +from sqlalchemy import create_engine +from sqlalchemy.orm import DeclarativeBase, sessionmaker + +DATABASE_URL = os.getenv( + "DATABASE_URL", + "sqlite:///./albums.db" +) + +# SQLite needs this setting for FastAPI's request handling. +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 +) + +SessionLocal = sessionmaker( + bind=engine, + autoflush=False, + autocommit=False +) + + +class Base(DeclarativeBase): + pass + + +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() \ No newline at end of file diff --git a/src/app/init_db.py b/src/app/init_db.py new file mode 100644 index 0000000..5963375 --- /dev/null +++ b/src/app/init_db.py @@ -0,0 +1,72 @@ +from sqlalchemy import select + +from app.database import Base, engine, SessionLocal +from app.models import Album + + +def initialize_database(): + + with SessionLocal() as db: + # Check whether albums are already stored. + existing_album = db.scalar( + select(Album.id).limit(1) + ) + + if existing_album is not None: + print("Albums already exist. Skipping seed.") + return + + # Insert our original sample albums. + albums = [ + Album( + id=1, + title="You, Me and an App Id", + artist="Daprize", + price=10.99, + image_url="https://aka.ms/albums-daprlogo" + ), + Album( + id=2, + title="Seven Revision Army", + artist="The Blue-Green Stripes", + price=13.99, + image_url="https://aka.ms/albums-containerappslogo" + ), + Album( + id=3, + title="Scale It Up", + artist="KEDA Club", + price=13.99, + image_url="https://aka.ms/albums-kedalogo" + ), + Album( + id=4, + title="Lost in Translation", + artist="MegaDNS", + price=12.99, + image_url="https://aka.ms/albums-envoylogo" + ), + Album( + id=5, + title="Lock Down Your Love", + artist="V is for VNET", + price=12.99, + image_url="https://aka.ms/albums-vnetlogo" + ), + Album( + id=6, + title="Sweet Container O' Mine", + artist="Guns N Probeses", + price=14.99, + image_url="https://aka.ms/albums-containerappslogo" + ) + ] + + db.add_all(albums) + db.commit() + + print(f"Successfully inserted {len(albums)} albums.") + + +if __name__ == "__main__": + initialize_database() \ No newline at end of file diff --git a/src/app/main.py b/src/app/main.py index 66f715d..db50993 100644 --- a/src/app/main.py +++ b/src/app/main.py @@ -1,40 +1,55 @@ -from typing import Optional +import os -from fastapi import FastAPI +from fastapi import Depends, FastAPI from fastapi.middleware.cors import CORSMiddleware +from sqlalchemy import select +from sqlalchemy.orm import Session -app = FastAPI() +from app.database import get_db +from app.models import Album +from app.schemas import AlbumResponse + + +app = FastAPI( + title="Album API", + description="Album API backed by SQLAlchemy and Alembic", + version="1.0.0" +) + + +# Configure CORS through environment variables. +allowed_origins = [ + origin.strip() + for origin in os.getenv( + "ALLOWED_ORIGINS", + "http://localhost:3000" + ).split(",") + if origin.strip() +] app.add_middleware( CORSMiddleware, - allow_origins=["*"], + allow_origins=allowed_origins, allow_credentials=True, - allow_methods="GET", + allow_methods=["GET"], allow_headers=["*"] ) -class Album(): - def __init__(self, id, title, artist, price, image_url): - self.id = id - self.title = title - self.artist = artist - self.price = price - self.image_url = image_url - -albums = [ - Album(1, "You, Me and an App Id", "Daprize", 10.99, "https://aka.ms/albums-daprlogo"), - Album(2, "Seven Revision Army", "The Blue-Green Stripes", 13.99, "https://aka.ms/albums-containerappslogo"), - Album(3, "Scale It Up", "KEDA Club", 13.99, "https://aka.ms/albums-kedalogo"), - Album(4, "Lost in Translation", "MegaDNS", 12.99,"https://aka.ms/albums-envoylogo"), - Album(5, "Lock Down Your Love", "V is for VNET", 12.99, "https://aka.ms/albums-vnetlogo"), - Album(6, "Sweet Container O' Mine", "Guns N Probeses", 14.99, "https://aka.ms/albums-containerappslogo") -] @app.get("/") def read_root(): - return {"Access /albums to see the list of albums"} + return {"message": "Album API is running"} + + +@app.get("/health") +def health(): + return {"status": "healthy"} + + +@app.get("/albums", response_model=list[AlbumResponse]) +def get_albums(db: Session = Depends(get_db)): + statement = select(Album).order_by(Album.id) + albums = db.scalars(statement).all() -@app.get("/albums") -def get_albums(): return albums diff --git a/src/app/models.py b/src/app/models.py new file mode 100644 index 0000000..8997116 --- /dev/null +++ b/src/app/models.py @@ -0,0 +1,34 @@ +from sqlalchemy import Float, Integer, String +from sqlalchemy.orm import Mapped, mapped_column + +from app.database import Base + + +class Album(Base): + __tablename__ = "albums" + + id: Mapped[int] = mapped_column( + Integer, + primary_key=True, + index=True + ) + + title: Mapped[str] = mapped_column( + String(200), + nullable=False + ) + + artist: Mapped[str] = mapped_column( + String(200), + nullable=False + ) + + price: Mapped[float] = mapped_column( + Float, + nullable=False + ) + + image_url: Mapped[str] = mapped_column( + String(500), + nullable=False + ) \ No newline at end of file diff --git a/src/app/schemas.py b/src/app/schemas.py new file mode 100644 index 0000000..af40f72 --- /dev/null +++ b/src/app/schemas.py @@ -0,0 +1,11 @@ +from pydantic import BaseModel, ConfigDict + + +class AlbumResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + title: str + artist: str + price: float + image_url: str \ No newline at end of file diff --git a/src/migrations/README b/src/migrations/README new file mode 100644 index 0000000..98e4f9c --- /dev/null +++ b/src/migrations/README @@ -0,0 +1 @@ +Generic single-database configuration. \ No newline at end of file diff --git a/src/migrations/env.py b/src/migrations/env.py new file mode 100644 index 0000000..f196494 --- /dev/null +++ b/src/migrations/env.py @@ -0,0 +1,53 @@ +from logging.config import fileConfig + +from alembic import context + +from app.database import Base, engine +from app import models # Registers Album with Base.metadata + + +config = context.config + +if config.config_file_name is not None: + fileConfig(config.config_file_name) + + +# Give Alembic access to our SQLAlchemy models. +target_metadata = Base.metadata + + +def run_migrations_offline() -> 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 engine.connect() 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..d46a159 100644 --- a/src/requirements.txt +++ b/src/requirements.txt @@ -1,3 +1,6 @@ -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 From 76c4b91138a570e7694bf4b2ddcb47d6572f863b Mon Sep 17 00:00:00 2001 From: JacobusTheOne Date: Sat, 31 Oct 2026 13:43:48 -0300 Subject: [PATCH 02/11] Add API GitHub Actions CI workflow --- .github/workflows/ci.yaml | 49 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 .github/workflows/ci.yaml diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..c699e7a --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,49 @@ + +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: 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 From de9669b4057459c144caca338e7fe965b79f578c Mon Sep 17 00:00:00 2001 From: JacobusTheOne Date: Sat, 31 Oct 2026 15:14:49 -0300 Subject: [PATCH 03/11] Add Azure ACR publishing workflow --- .github/workflows/publish-azure.yaml | 41 ++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 .github/workflows/publish-azure.yaml diff --git a/.github/workflows/publish-azure.yaml b/.github/workflows/publish-azure.yaml new file mode 100644 index 0000000..d14136b --- /dev/null +++ b/.github/workflows/publish-azure.yaml @@ -0,0 +1,41 @@ +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 }} From c7cc6694bcdbc26e5964511ffa9a4e9b8e205b94 Mon Sep 17 00:00:00 2001 From: JacobusTheOne Date: Sat, 31 Oct 2026 15:39:45 -0300 Subject: [PATCH 04/11] Add automated Azure Container Apps deployment --- .github/workflows/publish-azure.yaml | 30 ++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/.github/workflows/publish-azure.yaml b/.github/workflows/publish-azure.yaml index d14136b..1c7edb9 100644 --- a/.github/workflows/publish-azure.yaml +++ b/.github/workflows/publish-azure.yaml @@ -39,3 +39,33 @@ jobs: 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: + - 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 }} + + - name: Install Container Apps CLI extension + run: az extension add --name containerapp --upgrade + + - name: Deploy API image + run: | + az containerapp update \ + --name album-api \ + --resource-group rg-azure-container-apps-dev \ + --image ca7e04894508acr.azurecr.io/album-api:${{ github.sha }} + + - 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 From 97829cf775acfa292f91b8962e0d4dd0ab061baa Mon Sep 17 00:00:00 2001 From: JacobusTheOne Date: Sat, 31 Oct 2026 15:53:44 -0300 Subject: [PATCH 05/11] Initialize database before starting API --- src/Dockerfile | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/src/Dockerfile b/src/Dockerfile index 2f9283d..b0e0afe 100644 --- a/src/Dockerfile +++ b/src/Dockerfile @@ -2,31 +2,27 @@ # Use a lightweight Python 3.11 image. FROM python:3.11-slim -# Set the application directory. WORKDIR /code -# Prevent Python from writing bytecode and buffer logs. ENV PYTHONDONTWRITEBYTECODE=1 ENV PYTHONUNBUFFERED=1 +ENV DATABASE_URL=sqlite:////data/albums.db -# Install dependencies first for better Docker layer caching. +# Install dependencies. COPY requirements.txt . - RUN pip install --no-cache-dir -r requirements.txt # Copy application source. COPY . . -# Create a non-root user and prepare the persistent data directory. +# Create a non-root user and prepare the database directory. RUN useradd --create-home appuser \ && mkdir -p /data \ && chown -R appuser:appuser /code /data -# Run the application without root privileges. USER appuser -# Document the application port. EXPOSE 8080 -# Start FastAPI. -CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8080"] +# 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"] From 31f190d0899791bf2eb94cac41f1054edcb0d7b2 Mon Sep 17 00:00:00 2001 From: JacobusTheOne Date: Sun, 1 Nov 2026 13:56:14 -0300 Subject: [PATCH 06/11] Add Azure SQL managed identity support --- src/Dockerfile | 18 +++++++++++++++++- src/app/database.py | 43 ++++++++++++++++++++++++++++++++++++++++--- src/requirements.txt | 1 + 3 files changed, 58 insertions(+), 4 deletions(-) diff --git a/src/Dockerfile b/src/Dockerfile index b0e0afe..7c91df2 100644 --- a/src/Dockerfile +++ b/src/Dockerfile @@ -8,7 +8,23 @@ ENV PYTHONDONTWRITEBYTECODE=1 ENV PYTHONUNBUFFERED=1 ENV DATABASE_URL=sqlite:////data/albums.db -# Install dependencies. +# 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 pip install --no-cache-dir -r requirements.txt diff --git a/src/app/database.py b/src/app/database.py index a7d82e2..dbf3273 100644 --- a/src/app/database.py +++ b/src/app/database.py @@ -1,6 +1,8 @@ + import os +import struct -from sqlalchemy import create_engine +from sqlalchemy import create_engine, event from sqlalchemy.orm import DeclarativeBase, sessionmaker DATABASE_URL = os.getenv( @@ -8,7 +10,6 @@ "sqlite:///./albums.db" ) -# SQLite needs this setting for FastAPI's request handling. connect_args = ( {"check_same_thread": False} if DATABASE_URL.startswith("sqlite") @@ -21,6 +22,42 @@ 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"=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 \ No newline at end of file From 20636d1cf9ac724402c7d7e48e45bc5a6f1b2732 Mon Sep 17 00:00:00 2001 From: JacobusTheOne Date: Sun, 1 Nov 2026 15:09:31 -0300 Subject: [PATCH 07/11] Use runtime database configuration --- src/Dockerfile | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Dockerfile b/src/Dockerfile index 7c91df2..3b906df 100644 --- a/src/Dockerfile +++ b/src/Dockerfile @@ -6,7 +6,6 @@ WORKDIR /code ENV PYTHONDONTWRITEBYTECODE=1 ENV PYTHONUNBUFFERED=1 -ENV DATABASE_URL=sqlite:////data/albums.db # Install Microsoft ODBC Driver 18 for SQL Server. RUN set -eux; \ From d27b27386a81b0b6de64101fa66e859933b5bcea Mon Sep 17 00:00:00 2001 From: JacobusTheOne Date: Mon, 2 Nov 2026 08:09:08 -0300 Subject: [PATCH 08/11] Deploy via Bicep and add a database-backed readiness probe The deploy job ran `az containerapp update --image`, which only patches the image. Bicep also declared the image, so the next template deployment rolled it back, and configuration the template declares (probes, identity) was never applied. Deploy api.bicep instead, passing the tag just pushed, so the template is the single source of truth. The template lives in the deployment repo, checked out with DEPLOY_REPO_TOKEN. Add /ready, which runs SELECT 1 and returns 503 when the database is unreachable. /health answers as soon as the process is listening, which is what a liveness probe wants, but using it for readiness tells the ingress a replica can serve /albums before SQL is reachable. The two probes are wired to the matching endpoints in api.bicep, with failureThreshold allowing a 60s grace period for a database resume. Liveness deliberately stays off the database so a transient SQL failure cannot restart an otherwise healthy replica. Co-Authored-By: Claude Opus 5 --- .github/workflows/publish-azure.yaml | 31 +++++++++++++++++++++------- src/app/main.py | 22 ++++++++++++++++++-- 2 files changed, 44 insertions(+), 9 deletions(-) diff --git a/.github/workflows/publish-azure.yaml b/.github/workflows/publish-azure.yaml index 1c7edb9..fafa527 100644 --- a/.github/workflows/publish-azure.yaml +++ b/.github/workflows/publish-azure.yaml @@ -39,12 +39,21 @@ jobs: 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: @@ -52,15 +61,23 @@ jobs: tenant-id: ${{ vars.AZURE_TENANT_ID }} subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }} - - name: Install Container Apps CLI extension - run: az extension add --name containerapp --upgrade - - - name: Deploy API image + # 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 containerapp update \ - --name album-api \ + az deployment group create \ --resource-group rg-azure-container-apps-dev \ - --image ca7e04894508acr.azurecr.io/album-api:${{ github.sha }} + --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: | diff --git a/src/app/main.py b/src/app/main.py index db50993..3a280f5 100644 --- a/src/app/main.py +++ b/src/app/main.py @@ -1,8 +1,9 @@ import os -from fastapi import Depends, FastAPI +from fastapi import Depends, FastAPI, HTTPException, status from fastapi.middleware.cors import CORSMiddleware -from sqlalchemy import select +from sqlalchemy import select, text +from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.orm import Session from app.database import get_db @@ -46,6 +47,23 @@ def health(): return {"status": "healthy"} +@app.get("/ready") +def ready(db: Session = Depends(get_db)): + # /health answers as soon as the process is listening, which is what a + # liveness probe wants. Readiness has to keep failing while the database + # is still unreachable, otherwise the ingress routes traffic to a replica + # that cannot serve /albums yet. + try: + db.execute(text("SELECT 1")) + except SQLAlchemyError as exc: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="database unavailable" + ) from exc + + return {"status": "ready"} + + @app.get("/albums", response_model=list[AlbumResponse]) def get_albums(db: Session = Depends(get_db)): statement = select(Album).order_by(Album.id) From e2c391a28ffacb061765f55108176a5ffec7db4f Mon Sep 17 00:00:00 2001 From: JacobusTheOne Date: Tue, 3 Nov 2026 16:01:50 -0300 Subject: [PATCH 09/11] Retry the database connection Alembic uses at startup albumdb is serverless with a 60 minute auto-pause, so a container starting after an idle period hits a database that refuses connections for a minute or more while it resumes. Alembic opened that connection with no retry, so startup failed before migrations or seeding could run. Retry the connection Alembic actually goes on to use, rather than probing availability and then opening a second connection. Only the connect is retried: a failure in the caller's own work propagates untouched, because replaying a partially applied migration is more dangerous than failing loudly. Catch InterfaceError as well as OperationalError. A failed TCP connect surfaces as the latter, but pyodbc reports a login rejected by a resuming database as the former, which would otherwise escape the retry. Replaces wait_for_database, which was never called from anywhere. Needs the startup probe added to api.bicep in the deployment repo: the retry budget of roughly 134s exceeds the liveness budget of roughly 70s, so without it the platform restarts the container mid-resume and loops. Co-Authored-By: Claude Opus 5 --- src/app/database.py | 76 +++++++++++++++++++++++++++++++++++++++++++ src/migrations/env.py | 4 +-- 2 files changed, 78 insertions(+), 2 deletions(-) diff --git a/src/app/database.py b/src/app/database.py index dbf3273..0cd18be 100644 --- a/src/app/database.py +++ b/src/app/database.py @@ -1,10 +1,16 @@ 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" @@ -22,6 +28,7 @@ pool_pre_ping=True ) + # Configure Microsoft Entra access-token authentication for Azure SQL. if DATABASE_URL.startswith("mssql+pyodbc"): from azure.identity import DefaultAzureCredential @@ -58,6 +65,75 @@ def provide_access_token(dialect, conn_rec, cargs, cparams): } +# Azure SQL reports an unavailable or still-resuming database as either of +# these, depending on whether the failure lands in the TCP connect or the +# login that follows. +TRANSIENT_CONNECTION_ERRORS = (OperationalError, InterfaceError) + + +@contextmanager +def connect_with_retry(max_attempts=6, initial_delay=2, max_delay=15): + """ + Yield a live database connection, retrying transient failures. + + The connection yielded is the one the caller goes on to use, rather than + a throwaway opened only to test availability. A database resuming from + auto-pause can refuse connections for a minute or more, so the caller + would otherwise fail before doing any work. + + Only the connect is retried. A failure raised by the caller's own work + propagates untouched, because replaying a partially applied migration + is far more dangerous than failing loudly. + """ + delay = initial_delay + + for attempt in range(1, max_attempts + 1): + connection = None + + try: + connection = engine.connect() + # Connecting can succeed against a database that cannot yet + # serve queries, so confirm it really is reachable. + connection.exec_driver_sql("SELECT 1") + + except TRANSIENT_CONNECTION_ERRORS as exc: + if connection is not None: + connection.close() + + if attempt == max_attempts: + logger.exception( + "Database connection failed after %s attempts", + max_attempts + ) + raise + + logger.warning( + "Database unavailable. Attempt %s/%s failed. " + "Retrying in %s seconds. Error: %s", + attempt, + max_attempts, + delay, + exc + ) + + time.sleep(delay) + delay = min(delay * 2, max_delay) + + else: + logger.info( + "Database connection established on attempt %s/%s", + attempt, + max_attempts + ) + + try: + yield connection + finally: + connection.close() + + return + + SessionLocal = sessionmaker( bind=engine, autoflush=False, diff --git a/src/migrations/env.py b/src/migrations/env.py index f196494..746e4c1 100644 --- a/src/migrations/env.py +++ b/src/migrations/env.py @@ -2,7 +2,7 @@ from alembic import context -from app.database import Base, engine +from app.database import Base, engine, connect_with_retry from app import models # Registers Album with Base.metadata @@ -36,7 +36,7 @@ def run_migrations_offline() -> None: def run_migrations_online() -> None: """Run migrations using an actual database connection.""" - with engine.connect() as connection: + with connect_with_retry() as connection: context.configure( connection=connection, target_metadata=target_metadata, From 644dba409f4f498ea57a229f092c4f1e10258ab1 Mon Sep 17 00:00:00 2001 From: JacobusTheOne Date: Wed, 4 Nov 2026 14:31:15 -0300 Subject: [PATCH 10/11] test(api): add backend tests and integrate pytest into CI --- .github/workflows/ci.yaml | 4 ++ .gitignore | 7 +++- src/requirements.txt | 4 +- src/tests/test_main.py | 84 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 97 insertions(+), 2 deletions(-) create mode 100644 src/tests/test_main.py diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index c699e7a..ea4ccdc 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -38,6 +38,10 @@ jobs: 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 diff --git a/.gitignore b/.gitignore index b209c2d..7a170bf 100644 --- a/.gitignore +++ b/.gitignore @@ -10,7 +10,8 @@ .LSOverride # Icon must end with two \r -Icon +Icon + # Thumbnails ._* @@ -231,3 +232,7 @@ cython_debug/ *.db *.db-shm *.db-wal + +.venv/ +__pycache__/ +.pytest_cache/ diff --git a/src/requirements.txt b/src/requirements.txt index 82f3bcf..5a7294e 100644 --- a/src/requirements.txt +++ b/src/requirements.txt @@ -4,4 +4,6 @@ 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 \ No newline at end of file +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 From 0c7bd755b43ab209ebc06ee02e122f7da052056b Mon Sep 17 00:00:00 2001 From: JacobusTheOne Date: Wed, 4 Nov 2026 15:57:29 -0300 Subject: [PATCH 11/11] docs: document API health and readiness endpoints --- README.md | 59 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) 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.