From 1a6c896e9e87987cf363f9c2b6e47c02ab6ac519 Mon Sep 17 00:00:00 2001 From: Craig Osterhout Date: Wed, 27 May 2026 09:44:07 -0700 Subject: [PATCH 1/4] guides: update Signed-off-by: Craig Osterhout --- .vale.ini | 2 +- .../guides/python/configure-github-actions.md | 40 +- content/guides/python/containerize.md | 346 ++-- content/guides/python/deploy.md | 115 +- content/guides/python/develop.md | 1567 ++++++++++++++--- content/guides/python/lint-format-typing.md | 121 +- content/guides/python/secure-supply-chain.md | 143 ++ layouts/_shortcodes/file.html | 78 + layouts/_shortcodes/files.html | 376 ++++ layouts/_shortcodes/files.markdown.md | 20 + 10 files changed, 2269 insertions(+), 539 deletions(-) create mode 100644 content/guides/python/secure-supply-chain.md create mode 100644 layouts/_shortcodes/file.html create mode 100644 layouts/_shortcodes/files.html create mode 100644 layouts/_shortcodes/files.markdown.md diff --git a/.vale.ini b/.vale.ini index 646e4dc5a900..398c9a2fd843 100644 --- a/.vale.ini +++ b/.vale.ini @@ -33,4 +33,4 @@ Vale.Terms = NO [*.md] BasedOnStyles = Docker, Vale TokenIgnores = ({{[^}]+}}\S*) -BlockIgnores = (?m)^[ \t]*({{[^}]+}})[ \t]*$, (\[[^\]]*{{[^}]+}}[^\]]*\]\([^)]*\)) +BlockIgnores = (?m)^[ \t]*({{[^}]+}})[ \t]*$, (\[[^\]]*{{[^}]+}}[^\]]*\]\([^)]*\)), ({{<\s*file\s[^>]*>}}[\s\S]*?{{<\s*/\s*file\s*>}}) diff --git a/content/guides/python/configure-github-actions.md b/content/guides/python/configure-github-actions.md index 1279368b071e..e3067fde75f6 100644 --- a/content/guides/python/configure-github-actions.md +++ b/content/guides/python/configure-github-actions.md @@ -27,12 +27,14 @@ If you didn't create a [GitHub repository](https://github.com/new) for your proj ## Overview -GitHub Actions is a CI/CD (Continuous Integration and Continuous Deployment) automation tool built into GitHub. It allows you to define custom workflows for building, testing, and deploying your code when specific events occur (e.g., pushing code, creating a pull request, etc.). A workflow is a YAML-based automation script that defines a sequence of steps to be executed when triggered. Workflows are stored in the `.github/workflows/` directory of a repository. +GitHub Actions is a CI/CD automation tool built into GitHub. A workflow is a +YAML file that tells GitHub which jobs to run when something happens in your +repository, like a push to a branch or a pull request opening. Workflows live +in the `.github/workflows/` directory of your repository. -In this section, you'll learn how to set up and use GitHub Actions to build your Docker image as well as push it to Docker Hub. You will complete the following steps: - -1. Define the GitHub Actions workflow. -2. Run the workflow. +In this section, you'll add a workflow that runs your linting, formatting, and +type checks on every push to the main branch, then builds your Docker image +and pushes it to Docker Hub. ## 1. Define the GitHub Actions workflow @@ -45,12 +47,15 @@ If you prefer to use the GitHub web interface, follow these steps: 2. Select **set up a workflow yourself**. This takes you to a page for creating a new GitHub Actions workflow file in - your repository. By default, the file is created under `.github/workflows/main.yml`, let's change it name to `build.yml`. + your repository. By default, the file is created under `.github/workflows/main.yml`. Change the file name to `build.yml`. If you prefer to use your text editor, create a new file named `build.yml` in the `.github/workflows/` directory of your repository. Add the following content to the file: +{{< files name="python-docker-example" >}} + +{{< file path=".github/workflows/build.yml" status="new" >}} ```yaml name: Build and push Docker image @@ -64,16 +69,17 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@{{% param "checkout_action_version" %}} - + - name: Set up Python uses: actions/setup-python@v6 with: - python-version: '3.14' + python-version: '3.12' - name: Install dependencies run: | python -m pip install --upgrade pip pip install -r requirements.txt + pip install pre-commit pyright - name: Run pre-commit hooks run: pre-commit run --all-files @@ -84,12 +90,21 @@ jobs: build_and_push: runs-on: ubuntu-latest steps: + - uses: actions/checkout@{{% param "checkout_action_version" %}} + - name: Login to Docker Hub uses: docker/login-action@{{% param "login_action_version" %}} with: username: ${{ vars.DOCKER_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} + - name: Login to Docker Hardened Images + uses: docker/login-action@{{% param "login_action_version" %}} + with: + registry: dhi.io + username: ${{ vars.DOCKER_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + - name: Set up Docker Buildx uses: docker/setup-buildx-action@{{% param "setup_buildx_action_version" %}} @@ -99,6 +114,9 @@ jobs: push: true tags: ${{ vars.DOCKER_USERNAME }}/${{ github.event.repository.name }}:latest ``` +{{< /file >}} + +{{< /files >}} Each GitHub Actions workflow includes one or several jobs. Each job consists of steps. Each step can either run a set of commands or use already [existing actions](https://github.com/marketplace?type=actions). The action above has three steps: @@ -128,9 +146,11 @@ Related information: - [Introduction to GitHub Actions](/guides/gha.md) - [Docker Build GitHub Actions](/manuals/build/ci/github-actions/_index.md) -- [Workflow syntax for GitHub Actions](https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions) +- [docker/login-action](https://github.com/docker/login-action) +- [docker/build-push-action](https://github.com/docker/build-push-action) +- [Create a Docker Hub access token](/manuals/security/access-tokens.md#create-an-access-token) ## Next steps -In the next section, you'll learn how you can develop locally using kubernetes. +In the next section, you'll learn how you can develop locally using Kubernetes. diff --git a/content/guides/python/containerize.md b/content/guides/python/containerize.md index 47df6e84dd86..193d191d4f0e 100644 --- a/content/guides/python/containerize.md +++ b/content/guides/python/containerize.md @@ -14,159 +14,66 @@ aliases: ## Prerequisites - You have installed the latest version of [Docker Desktop](/get-started/get-docker.md). -- You have a [Git client](https://git-scm.com/downloads). The examples in this section use a command-line based Git client, but you can use any client. ## Overview -This section walks you through containerizing and running a Python application. +Containerizing your application means packaging it together with its +dependencies, configuration, and runtime into a single portable unit called a +container image. Running that image creates a container, an isolated process +that behaves the same on any machine, whether it's your laptop, a CI runner, or +a production server. -## Get the sample application +In this section, you'll containerize a simple +[FastAPI](https://fastapi.tiangolo.com) web application. You'll write a +`Dockerfile` that describes how to build the image, add a `compose.yaml` file +that defines how Docker runs your container, and then build and start the +application with one command. -The sample application uses the popular [FastAPI](https://fastapi.tiangolo.com) framework. +You'll use [Docker Hardened Images](/dhi/) as the base. These are minimal, +secure Python images maintained by Docker. -Clone the sample application to use with this guide. Open a terminal, change directory to a directory that you want to work in, and run the following command to clone the repository: +## Create the application -```console -$ git clone https://github.com/estebanx64/python-docker-example && cd python-docker-example -``` - -## Create Docker assets - -Now that you have an application, you can create the necessary Docker assets to -containerize your application. - -> [!TIP] -> -> [Gordon](/ai/gordon/), Docker's AI assistant, can generate Docker assets for your project. Ask Gordon to create a Dockerfile, Compose file, and `.dockerignore` tailored to your application. - -Before creating your Dockerfile, you need to choose a base image. You can use the [Python Docker Official Image](https://hub.docker.com/_/python), -or a [Docker Hardened Image (DHI)](https://hub.docker.com/hardened-images/catalog/dhi/python). +The sample application is a minimal FastAPI service with a single endpoint +that returns a JSON greeting. Create the following files in a new +`python-docker-example` directory. To create all the files at once, switch to +the **Scaffold script** tab in the file browser and copy the shell command. -Docker Hardened Images (DHIs) are minimal, secure, and production-ready base images maintained by Docker. -They help reduce vulnerabilities and simplify compliance. For more details, see [Docker Hardened Images](/dhi/). +{{< files name="python-docker-example" >}} -{{< tabs >}} -{{< tab name="Using the official Docker image" >}} - -Create the following files in your project directory. - -Create a file named `Dockerfile` with the following contents. - -```dockerfile {collapse=true,title=Dockerfile} -# syntax=docker/dockerfile:1 - -# Comments are provided throughout this file to help you get started. -# If you need more help, visit the Dockerfile reference guide at -# https://docs.docker.com/go/dockerfile-reference/ +{{< file path="app.py" status="new" >}} +```python +# A minimal FastAPI application. +# The root endpoint (GET /) returns a JSON "Hello World" response. +# See https://fastapi.tiangolo.com/ for the framework reference. -# This Dockerfile uses Python Docker Official Image -ARG PYTHON_VERSION=3.12 -FROM python:${PYTHON_VERSION}-slim +from fastapi import FastAPI -# Prevents Python from writing pyc files. -ENV PYTHONDONTWRITEBYTECODE=1 +app = FastAPI() -# Keeps Python from buffering stdout and stderr to avoid situations where -# the application crashes without emitting any logs due to buffering. -ENV PYTHONUNBUFFERED=1 - -WORKDIR /app - -# Create a non-privileged user that the app will run under. -# See https://docs.docker.com/go/dockerfile-user-best-practices/ -ARG UID=10001 -RUN adduser \ - --disabled-password \ - --gecos "" \ - --home "/nonexistent" \ - --shell "/sbin/nologin" \ - --no-create-home \ - --uid "${UID}" \ - appuser - -# Download dependencies as a separate step to take advantage of Docker's caching. -# Leverage a cache mount to /root/.cache/pip to speed up subsequent builds. -# Leverage a bind mount to requirements.txt to avoid having to copy them into -# into this layer. -RUN --mount=type=cache,target=/root/.cache/pip \ - --mount=type=bind,source=requirements.txt,target=requirements.txt \ - python -m pip install -r requirements.txt - -# Switch to the non-privileged user to run the application. -USER appuser - -# Copy the source code into the container. -COPY . . - -# Expose the port that the application listens on. -EXPOSE 8000 -# Run the application. -CMD ["python3", "-m", "uvicorn", "app:app", "--host=0.0.0.0", "--port=8000"] +@app.get("/") +async def root(): + return {"message": "Hello World"} ``` +{{< /file >}} -Create a file named `compose.yaml` with the following contents. - -```yaml {collapse=true,title=compose.yaml} -# Comments are provided throughout this file to help you get started. -# If you need more help, visit the Docker Compose reference guide at -# https://docs.docker.com/go/compose-spec-reference/ +{{< file path="requirements.txt" status="new" >}} +```text +# Python package dependencies for the application, pinned for reproducible builds. +# See https://pip.pypa.io/en/stable/reference/requirements-file-format/ -# Here the instructions define your application as a service called "server". -# This service is built from the Dockerfile in the current directory. -# You can add other services your application may depend on here, such as a -# database or a cache. For examples, see the Awesome Compose repository: -# https://github.com/docker/awesome-compose -services: - server: - build: - context: . - ports: - - 8000:8000 +fastapi==0.115.12 +uvicorn==0.34.3 ``` +{{< /file >}} -Create a file named `.dockerignore` with the following contents. - -```text {collapse=true,title=".dockerignore"} -# Include any files or directories that you don't want to be copied to your -# container here (e.g., local build artifacts, temporary files, etc.). -# -# For more help, visit the .dockerignore file reference guide at -# https://docs.docker.com/go/build-context-dockerignore/ - -**/.DS_Store -**/__pycache__ -**/.venv -**/.classpath -**/.dockerignore -**/.env -**/.git -**/.gitignore -**/.project -**/.settings -**/.toolstarget -**/.vs -**/.vscode -**/*.*proj.user -**/*.dbmdl -**/*.jfm -**/bin -**/charts -**/docker-compose* -**/compose.y*ml -**/Dockerfile* -**/node_modules -**/npm-debug.log -**/obj -**/secrets.dev.yaml -**/values.dev.yaml -LICENSE -README.md -``` - -Create a file named `.gitignore` with the following contents. +{{< file path=".gitignore" status="new" >}} +```text +# Files and directories that Git should ignore. This is the standard Python +# template covering bytecode, build artifacts, virtual environments, and IDE +# settings. See https://git-scm.com/docs/gitignore for syntax reference. -```text {collapse=true,title=".gitignore"} # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] @@ -221,28 +128,85 @@ venv/ ENV/ env.bak/ venv.bak/ + +# Secrets +db/password.txt ``` +{{< /file >}} -{{< /tab >}} -{{< tab name="Using Docker Hardened Image" >}} +{{< /files >}} -Docker Hardened Images (DHIs) are available for Python in the [Docker Hardened Images catalog](https://hub.docker.com/hardened-images/catalog/dhi/python). Docker Hardened Images are freely available to everyone with no subscription required. You can pull and use them like any other Docker image after signing in to the DHI registry. For more information, see the [DHI quickstart](/dhi/get-started/) guide. +If you already have Python installed and want to verify the app works before +containerizing it, you can run it locally: -1. Sign in to the DHI registry: +```console +$ python3 -m venv .venv +$ source .venv/bin/activate +$ pip install -r requirements.txt +$ uvicorn app:app --reload +``` - ```console - $ docker login dhi.io - ``` +> [!NOTE] +> +> On Windows, activate the virtual environment with `.venv\Scripts\activate` +> instead of `source .venv/bin/activate`. -2. Pull the Python DHI (check the catalog for available versions): +If you don't have Python installed, skip ahead to the next section. The +remaining steps run the application in a container, with no local Python +required. - ```console - $ docker pull dhi.io/python:3.12.12-debian13-fips-dev - ``` +## Create the Docker assets -Create a file named `Dockerfile` with the following contents. +Sign in to the DHI registry so Docker can pull the Python base images during +the build. The available Python images are listed in the +[catalog](https://hub.docker.com/hardened-images/catalog/dhi/python). -```dockerfile {collapse=true,title=Dockerfile} +```console +$ docker login dhi.io +``` + +Add the following three files to your `python-docker-example` directory. The +`Dockerfile` describes how to build the image, `compose.yaml` defines how +Docker runs the container, and `.dockerignore` keeps unwanted files out of the +build context. + +> [!TIP] +> +> [Gordon](/ai/gordon/), Docker's AI assistant, can generate Docker assets for +> your project. Ask Gordon to create a Dockerfile, Compose file, and +> `.dockerignore` tailored to your application. + +{{< files name="python-docker-example" >}} + +{{< file path="app.py" >}} +```python +# A minimal FastAPI application. +# The root endpoint (GET /) returns a JSON "Hello World" response. +# See https://fastapi.tiangolo.com/ for the framework reference. + +from fastapi import FastAPI + +app = FastAPI() + + +@app.get("/") +async def root(): + return {"message": "Hello World"} +``` +{{< /file >}} + +{{< file path="requirements.txt" >}} +```text +# Python package dependencies for the application, pinned for reproducible builds. +# See https://pip.pypa.io/en/stable/reference/requirements-file-format/ + +fastapi==0.115.12 +uvicorn==0.34.3 +``` +{{< /file >}} + +{{< file path="Dockerfile" status="new" >}} +```dockerfile # syntax=docker/dockerfile:1 # Comments are provided throughout this file to help you get started. @@ -251,43 +215,30 @@ Create a file named `Dockerfile` with the following contents. # This Dockerfile uses Docker Hardened Images (DHI) for enhanced security. # For more information, see https://docs.docker.com/dhi/ -ARG PYTHON_VERSION=3.12.12-debian13-fips-dev -FROM dhi.io/python:${PYTHON_VERSION} - -# Prevents Python from writing pyc files. -ENV PYTHONDONTWRITEBYTECODE=1 -# Keeps Python from buffering stdout and stderr to avoid situations where -# the application crashes without emitting any logs due to buffering. -ENV PYTHONUNBUFFERED=1 - -#Add dependencies for adduser -RUN apt update -y && apt install adduser -y +# Use the dev image to build and install dependencies. +FROM dhi.io/python:3.12-dev AS builder WORKDIR /app -# Create a non-privileged user that the app will run under. -# See https://docs.docker.com/go/dockerfile-user-best-practices/ -ARG UID=10001 -RUN adduser \ - --disabled-password \ - --gecos "" \ - --home "/nonexistent" \ - --shell "/sbin/nologin" \ - --no-create-home \ - --uid "${UID}" \ - appuser +RUN python3 -m venv /venv +ENV PATH="/venv/bin:$PATH" # Download dependencies as a separate step to take advantage of Docker's caching. # Leverage a cache mount to /root/.cache/pip to speed up subsequent builds. # Leverage a bind mount to requirements.txt to avoid having to copy them into -# into this layer. +# this layer. RUN --mount=type=cache,target=/root/.cache/pip \ --mount=type=bind,source=requirements.txt,target=requirements.txt \ - python -m pip install -r requirements.txt + pip install -r requirements.txt + +# Use the minimal runtime image. It runs as nonroot by default. +FROM dhi.io/python:3.12 -# Switch to the non-privileged user to run the application. -USER appuser +WORKDIR /app + +COPY --from=builder /venv /venv +ENV PATH="/venv/bin:$PATH" # Copy the source code into the container. COPY . . @@ -296,12 +247,12 @@ COPY . . EXPOSE 8000 # Run the application. -CMD ["python3", "-m", "uvicorn", "app:app", "--host=0.0.0.0", "--port=8000"] +CMD ["/venv/bin/python3", "-m", "uvicorn", "app:app", "--host=0.0.0.0", "--port=8000"] ``` +{{< /file >}} -Create a file named `compose.yaml` with the following contents. - -```yaml {collapse=true,title=compose.yaml} +{{< file path="compose.yaml" status="new" >}} +```yaml # Comments are provided throughout this file to help you get started. # If you need more help, visit the Docker Compose reference guide at # https://docs.docker.com/go/compose-spec-reference/ @@ -318,10 +269,10 @@ services: ports: - 8000:8000 ``` +{{< /file >}} -Create a file named `.dockerignore` with the following contents. - -```text {collapse=true,title=".dockerignore"} +{{< file path=".dockerignore" status="new" >}} +```text # Include any files or directories that you don't want to be copied to your # container here (e.g., local build artifacts, temporary files, etc.). # @@ -357,10 +308,14 @@ Create a file named `.dockerignore` with the following contents. LICENSE README.md ``` +{{< /file >}} -Create a file named `.gitignore` with the following contents. +{{< file path=".gitignore" >}} +```text +# Files and directories that Git should ignore. This is the standard Python +# template covering bytecode, build artifacts, virtual environments, and IDE +# settings. See https://git-scm.com/docs/gitignore for syntax reference. -```text {collapse=true,title=".gitignore"} # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] @@ -415,30 +370,18 @@ venv/ ENV/ env.bak/ venv.bak/ -``` - -{{< /tab >}} -{{< /tabs >}} -You should now have the following contents in your `python-docker-example` -directory. - -```text -├── python-docker-example/ -│ ├── app.py -│ ├── requirements.txt -│ ├── .dockerignore -│ ├── .gitignore -│ ├── compose.yaml -│ ├── Dockerfile -│ └── README.md +# Secrets +db/password.txt ``` +{{< /file >}} + +{{< /files >}} -To learn more about the files, see the following: +To learn more about each file, see the following: - [Dockerfile](/reference/dockerfile.md) - [.dockerignore](/reference/dockerfile.md#dockerignore-file) -- [.gitignore](https://git-scm.com/docs/gitignore) - [compose.yaml](/reference/compose-file/_index.md) ## Run the application @@ -486,6 +429,9 @@ application using Docker. Related information: +- [Docker Hardened Images](/dhi/) +- [Dockerfile reference](/reference/dockerfile.md) +- [Multi-stage builds](/manuals/build/building/multi-stage.md) - [Docker Compose overview](/manuals/compose/_index.md) ## Next steps diff --git a/content/guides/python/deploy.md b/content/guides/python/deploy.md index ea1c51cfa066..7c574c12f998 100644 --- a/content/guides/python/deploy.md +++ b/content/guides/python/deploy.md @@ -16,13 +16,41 @@ aliases: ## Overview -In this section, you'll learn how to use Docker Desktop to deploy your application to a fully-featured Kubernetes environment on your development machine. This allows you to test and debug your workloads on Kubernetes locally before deploying. +[Kubernetes](https://kubernetes.io/) is an open source platform that runs and +orchestrates container workloads across one or more machines. You describe +what you want to run, like which container images, how many replicas, and +which network ports to expose, in YAML manifest files. Kubernetes reads the +manifests and makes the cluster match that description. + +In this section, you'll use the Kubernetes environment built into Docker +Desktop to deploy your application locally. You'll write two manifest files, +one for the PostgreSQL database and one for the FastAPI application, apply +them with `kubectl`, and verify the deployment by hitting your application +from a terminal. + +## Registry authentication + +The Docker Hardened Images used in this guide are hosted on `dhi.io`. Docker +Desktop's Kubernetes shares credentials with Docker Desktop, so the `docker login dhi.io` +you completed earlier is all that's needed. No additional image pull secret is required. + +> [!NOTE] +> +> If you're deploying to a Kubernetes cluster outside of Docker Desktop, you'll +> need to create an image pull secret and reference it in your pod specs. See +> [Use a Docker Hardened Image](/dhi/how-to/use/#use-with-kubernetes) for instructions. ## Create a Kubernetes YAML file -In your `python-docker-dev-example` directory, create a file named `docker-postgres-kubernetes.yaml`. Open the file in an IDE or text editor and add -the following contents. +Create the following two Kubernetes manifest files in your +`python-docker-example` directory. Before applying +`docker-python-kubernetes.yaml`, replace `DOCKER_USERNAME/REPO_NAME` with your +Docker username and the repository name that you created in [Configure CI/CD for +your Python application](./configure-github-actions.md). + +{{< files name="python-docker-example" >}} +{{< file path="docker-postgres-kubernetes.yaml" status="new" >}} ```yaml apiVersion: apps/v1 kind: Deployment @@ -41,7 +69,7 @@ spec: spec: containers: - name: postgres - image: postgres:18 + image: dhi.io/postgres:18 ports: - containerPort: 5432 env: @@ -94,12 +122,9 @@ type: Opaque data: POSTGRES_PASSWORD: cG9zdGdyZXNfcGFzc3dvcmQ= # Base64 encoded password (e.g., 'postgres_password') ``` +{{< /file >}} -In your `python-docker-dev-example` directory, create a file named -`docker-python-kubernetes.yaml`. Replace `DOCKER_USERNAME/REPO_NAME` with your -Docker username and the repository name that you created in [Configure CI/CD for -your Python application](./configure-github-actions.md). - +{{< file path="docker-python-kubernetes.yaml" status="new" >}} ```yaml apiVersion: apps/v1 kind: Deployment @@ -135,7 +160,7 @@ spec: - name: POSTGRES_PORT value: "5432" ports: - - containerPort: 8001 + - containerPort: 8000 --- apiVersion: v1 kind: Service @@ -147,12 +172,15 @@ spec: selector: service: fastapi ports: - - port: 8001 - targetPort: 8001 + - port: 8000 + targetPort: 8000 nodePort: 30001 ``` +{{< /file >}} + +{{< /files >}} -In these Kubernetes YAML file, there are various objects, separated by the `---`: +In these Kubernetes YAML files, there are various objects, separated by the `---`: - A Deployment, describing a scalable group of identical pods. In this case, you'll get just one replica, or copy of your pod. That pod, which is @@ -161,20 +189,20 @@ In these Kubernetes YAML file, there are various objects, separated by the `---` your Python application](configure-github-actions.md). - A Service, which will define how the ports are mapped in the containers. - A PersistentVolumeClaim, to define a storage that will be persistent through restarts for the database. -- A Secret, Keeping the database password as an example using secret kubernetes resource. +- A Secret, Keeping the database password as an example using secret Kubernetes resource. - A NodePort service, which will route traffic from port 30001 on your host to - port 8001 inside the pods it routes to, allowing you to reach your app + port 8000 inside the pods it routes to, so you can reach your app from the network. To learn more about Kubernetes objects, see the [Kubernetes documentation](https://kubernetes.io/docs/home/). > [!NOTE] > -> - The `NodePort` service is good for development/testing purposes. For production you should implement an [ingress-controller](https://kubernetes.io/docs/concepts/services-networking/ingress-controllers/). +> The `NodePort` service is good for development and testing. For production, implement an [ingress controller](https://kubernetes.io/docs/concepts/services-networking/ingress-controllers/) instead. ## Deploy and check your application -1. In a terminal, navigate to `python-docker-dev-example` and deploy your database to +1. In a terminal, navigate to `python-docker-example` and deploy your database to Kubernetes. ```console @@ -190,10 +218,10 @@ To learn more about Kubernetes objects, see the [Kubernetes documentation](https secret/postgres-secret created ``` - Now, deploy your python application. + Now, deploy your Python application. ```console - kubectl apply -f docker-python-kubernetes.yaml + $ kubectl apply -f docker-python-kubernetes.yaml ``` You should see output that looks like the following, indicating your Kubernetes objects were created successfully. @@ -229,20 +257,55 @@ To learn more about Kubernetes objects, see the [Kubernetes documentation](https NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE kubernetes ClusterIP 10.43.0.1 443/TCP 13h postgres ClusterIP 10.43.209.25 5432/TCP 3m10s - service-entrypoint NodePort 10.43.67.120 8001:30001/TCP 79s + service-entrypoint NodePort 10.43.67.120 8000:30001/TCP 79s ``` - In addition to the default `kubernetes` service, you can see your `service-entrypoint` service, accepting traffic on port 30001/TCP and the internal `ClusterIP` `postgres` with the port `5432` open to accept connections from you python app. + In addition to the default `kubernetes` service, you can see your `service-entrypoint` service, accepting traffic on port 30001/TCP and the internal `ClusterIP` `postgres` with the port `5432` open to accept connections from your Python app. -3. In a terminal, curl the service. Note that a database was not deployed in - this example. +3. In a terminal, curl the root endpoint to verify the application is running. ```console $ curl http://localhost:30001/ - Hello, Docker!!! + Hello, Docker! ``` -4. Run the following commands to tear down your application. +4. Exercise the database by creating a hero with a POST request: + + ```console + $ curl -X 'POST' \ + 'http://localhost:30001/heroes/' \ + -H 'accept: application/json' \ + -H 'Content-Type: application/json' \ + -d '{ + "id": 1, + "name": "my hero", + "secret_name": "austing", + "age": 12 + }' + ``` + + You should receive the following response: + + ```json + { + "age": 12, + "id": 1, + "name": "my hero", + "secret_name": "austing" + } + ``` + + Then read it back with a GET request: + + ```console + $ curl http://localhost:30001/heroes/ + ``` + + You should receive an array containing the hero you just created. This + confirms the application can read from and write to the PostgreSQL database + running in the cluster. + +5. Run the following commands to tear down your application. ```console $ kubectl delete -f docker-python-kubernetes.yaml @@ -257,4 +320,4 @@ Related information: - [Kubernetes documentation](https://kubernetes.io/docs/home/) - [Deploy on Kubernetes with Docker Desktop](/manuals/desktop/use-desktop/kubernetes.md) -- [Swarm mode overview](/manuals/engine/swarm/_index.md) +- [Use a Docker Hardened Image with Kubernetes](/dhi/how-to/use/#use-with-kubernetes) diff --git a/content/guides/python/develop.md b/content/guides/python/develop.md index 9ab35a862e94..d306d823bcb9 100644 --- a/content/guides/python/develop.md +++ b/content/guides/python/develop.md @@ -15,246 +15,879 @@ Complete [Containerize a Python application](containerize.md). ## Overview -In this section, you'll learn how to set up a development environment for your containerized application. This includes: - -- Adding a local database and persisting data -- Configuring Compose to automatically update your running Compose services as you edit and save your code - -## Get the sample application - -You'll need to clone a new repository to get a sample application that includes logic to connect to the database. - -1. Change to a directory where you want to clone the repository and run the following command. - - ```console - $ git clone https://github.com/estebanx64/python-docker-dev-example - ``` - -2. In the cloned repository's directory, create the necessary Docker assets. - - Create the following files in your project directory. - - Create a file named `Dockerfile` with the following contents. - - ```dockerfile {collapse=true,title=Dockerfile} - # syntax=docker/dockerfile:1 - - # Comments are provided throughout this file to help you get started. - # If you need more help, visit the Dockerfile reference guide at - # https://docs.docker.com/go/dockerfile-reference/ - - ARG PYTHON_VERSION=3.12 - FROM python:${PYTHON_VERSION}-slim - - # Prevents Python from writing pyc files. - ENV PYTHONDONTWRITEBYTECODE=1 - - # Keeps Python from buffering stdout and stderr to avoid situations where - # the application crashes without emitting any logs due to buffering. - ENV PYTHONUNBUFFERED=1 - - WORKDIR /app - - # Create a non-privileged user that the app will run under. - # See https://docs.docker.com/go/dockerfile-user-best-practices/ - ARG UID=10001 - RUN adduser \ - --disabled-password \ - --gecos "" \ - --home "/nonexistent" \ - --shell "/sbin/nologin" \ - --no-create-home \ - --uid "${UID}" \ - appuser - - # Download dependencies as a separate step to take advantage of Docker's caching. - # Leverage a cache mount to /root/.cache/pip to speed up subsequent builds. - # Leverage a bind mount to requirements.txt to avoid having to copy them into - # into this layer. - RUN --mount=type=cache,target=/root/.cache/pip \ - --mount=type=bind,source=requirements.txt,target=requirements.txt \ - python -m pip install -r requirements.txt - - # Switch to the non-privileged user to run the application. - USER appuser - - # Copy the source code into the container. - COPY . . - - # Expose the port that the application listens on. - EXPOSE 8001 - - # Run the application. - CMD ["python3", "-m", "uvicorn", "app:app", "--host=0.0.0.0", "--port=8001"] - ``` - - Create a file named `compose.yaml` with the following contents. - - ```yaml {collapse=true,title=compose.yaml} - # Comments are provided throughout this file to help you get started. - # If you need more help, visit the Docker Compose reference guide at - # https://docs.docker.com/go/compose-spec-reference/ - - # Here the instructions define your application as a service called "server". - # This service is built from the Dockerfile in the current directory. - # You can add other services your application may depend on here, such as a - # database or a cache. For examples, see the Awesome Compose repository: - # https://github.com/docker/awesome-compose - services: - server: - build: - context: . - ports: - - 8001:8001 - # The commented out section below is an example of how to define a PostgreSQL - # database that your application can use. `depends_on` tells Docker Compose to - # start the database before your application. The `db-data` volume persists the - # database data between container restarts. The `db-password` secret is used - # to set the database password. You must create `db/password.txt` and add - # a password of your choosing to it before running `docker compose up`. - # depends_on: - # db: - # condition: service_healthy - # db: - # image: postgres:18 - # restart: always - # user: postgres - # secrets: - # - db-password - # volumes: - # - db-data:/var/lib/postgresql - # environment: - # - POSTGRES_DB=example - # - POSTGRES_PASSWORD_FILE=/run/secrets/db-password - # expose: - # - 5432 - # healthcheck: - # test: [ "CMD", "pg_isready" ] - # interval: 10s - # timeout: 5s - # retries: 5 - # volumes: - # db-data: - # secrets: - # db-password: - # file: db/password.txt - ``` - - Create a file named `.dockerignore` with the following contents. - - ```text {collapse=true,title=".dockerignore"} - # Include any files or directories that you don't want to be copied to your - # container here (e.g., local build artifacts, temporary files, etc.). - # - # For more help, visit the .dockerignore file reference guide at - # https://docs.docker.com/go/build-context-dockerignore/ - - **/.DS_Store - **/__pycache__ - **/.venv - **/.classpath - **/.dockerignore - **/.env - **/.git - **/.gitignore - **/.project - **/.settings - **/.toolstarget - **/.vs - **/.vscode - **/*.*proj.user - **/*.dbmdl - **/*.jfm - **/bin - **/charts - **/docker-compose* - **/compose.y*ml - **/Dockerfile* - **/node_modules - **/npm-debug.log - **/obj - **/secrets.dev.yaml - **/values.dev.yaml - LICENSE - README.md - ``` - - Create a file named `.gitignore` with the following contents. - - ```text {collapse=true,title=".gitignore"} - # Byte-compiled / optimized / DLL files - __pycache__/ - *.py[cod] - *$py.class - - # C extensions - *.so - - # Distribution / packaging - .Python - build/ - develop-eggs/ - dist/ - downloads/ - eggs/ - .eggs/ - lib/ - lib64/ - parts/ - sdist/ - var/ - wheels/ - share/python-wheels/ - *.egg-info/ - .installed.cfg - *.egg - MANIFEST - - # Unit test / coverage reports - htmlcov/ - .tox/ - .nox/ - .coverage - .coverage.* - .cache - nosetests.xml - coverage.xml - *.cover - *.py,cover - .hypothesis/ - .pytest_cache/ - cover/ - - # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm - __pypackages__/ - - # Environments - .env - .venv - env/ - venv/ - ENV/ - env.bak/ - venv.bak/ - ``` +Once your application runs in a container, the next step is making the +container loop part of your everyday development workflow. Code changes should +show up quickly, and services your app depends on, like databases, should run +right alongside it. + +In this section, you'll extend the project from the previous topic by adding a +PostgreSQL database service to your `compose.yaml`, persisting the database +data in a named volume, and enabling Compose Watch so that changes you save in +your editor are picked up by the running container without a manual rebuild. + +## Update the application + +You'll update your application to connect to a PostgreSQL database. Continue +working in your `python-docker-example` directory. + +Replace `app.py` and `requirements.txt`, and add a new `config.py` file with the +following contents. + +> [!NOTE] +> +> The application won't run yet after this step. It tries to connect to a +> PostgreSQL database that doesn't exist. The next two sections add the +> database service and the Docker configuration needed to run everything +> together. + +{{< files name="python-docker-example" >}} + +{{< file path="app.py" status="modified" >}} +```python +from collections.abc import AsyncGenerator, Sequence +from contextlib import asynccontextmanager + +from fastapi import FastAPI +from sqlmodel import Field, Session, SQLModel, create_engine, select + +from config import settings + + +class Hero(SQLModel, table=True): + id: int | None = Field(default=None, primary_key=True) + name: str = Field(index=True) + secret_name: str + age: int | None = Field(default=None, index=True) + + +engine = create_engine(str(settings.SQLALCHEMY_DATABASE_URI)) + + +def create_db_and_tables() -> None: + SQLModel.metadata.create_all(engine) + + +@asynccontextmanager +async def lifespan(_app: FastAPI) -> AsyncGenerator[None, None]: + create_db_and_tables() + yield + + +app = FastAPI(lifespan=lifespan) + + +@app.get("/") +def hello() -> str: + return "Hello, Docker!" + + +@app.post("/heroes/") +def create_hero(hero: Hero) -> Hero: + with Session(engine) as session: + session.add(hero) + session.commit() + session.refresh(hero) + return hero + + +@app.get("/heroes/") +def read_heroes() -> Sequence[Hero]: + with Session(engine) as session: + heroes = session.exec(select(Hero)).all() + return heroes +``` +{{< /file >}} + +{{< file path="config.py" status="new" >}} +```python +import os +from typing import Any + +from pydantic import ( + PostgresDsn, + computed_field, + field_validator, + model_validator, +) +from pydantic_core import MultiHostUrl +from pydantic_settings import BaseSettings + + +class Settings(BaseSettings): + POSTGRES_SERVER: str + POSTGRES_PORT: int = 5432 + POSTGRES_USER: str + POSTGRES_PASSWORD: str | None = None + POSTGRES_PASSWORD_FILE: str | None = None + POSTGRES_DB: str + + @model_validator(mode="before") + @classmethod + def check_postgres_password(cls, data: Any) -> Any: + """Validate that either POSTGRES_PASSWORD or POSTGRES_PASSWORD_FILE is set.""" + if isinstance(data, dict): + password_file: str | None = data.get("POSTGRES_PASSWORD_FILE") # type: ignore + password: str | None = data.get("POSTGRES_PASSWORD") # type: ignore + if password_file is None and password is None: + raise ValueError( + "At least one of POSTGRES_PASSWORD_FILE and POSTGRES_PASSWORD must be set." + ) + return data # type: ignore + + @field_validator("POSTGRES_PASSWORD_FILE", mode="before") + @classmethod + def read_password_from_file(cls, v: str | None) -> str | None: + if v is not None: + file_path = v + if os.path.exists(file_path): + with open(file_path) as file: + return file.read().strip() + raise ValueError(f"Password file {file_path} does not exist.") + return v + + @computed_field + @property + def SQLALCHEMY_DATABASE_URI(self) -> PostgresDsn: + url = MultiHostUrl.build( + scheme="postgresql+psycopg", + username=self.POSTGRES_USER, + password=self.POSTGRES_PASSWORD + if self.POSTGRES_PASSWORD + else self.POSTGRES_PASSWORD_FILE, + host=self.POSTGRES_SERVER, + port=self.POSTGRES_PORT, + path=self.POSTGRES_DB, + ) + return PostgresDsn(url) + + +settings = Settings() # type: ignore +``` +{{< /file >}} + +{{< file path="requirements.txt" status="modified" hl_lines="5-7" >}} +```text +# Python package dependencies for the application, pinned for reproducible builds. +# See https://pip.pypa.io/en/stable/reference/requirements-file-format/ + +fastapi==0.115.12 +sqlmodel==0.0.24 +psycopg[binary]==3.2.9 +pydantic-settings==2.9.1 +uvicorn==0.34.3 +``` +{{< /file >}} + +{{< file path="Dockerfile" >}} +```dockerfile +# syntax=docker/dockerfile:1 + +# Comments are provided throughout this file to help you get started. +# If you need more help, visit the Dockerfile reference guide at +# https://docs.docker.com/go/dockerfile-reference/ + +# This Dockerfile uses Docker Hardened Images (DHI) for enhanced security. +# For more information, see https://docs.docker.com/dhi/ + +# Use the dev image to build and install dependencies. +FROM dhi.io/python:3.12-dev AS builder + +WORKDIR /app + +RUN python3 -m venv /venv +ENV PATH="/venv/bin:$PATH" + +# Download dependencies as a separate step to take advantage of Docker's caching. +# Leverage a cache mount to /root/.cache/pip to speed up subsequent builds. +# Leverage a bind mount to requirements.txt to avoid having to copy them into +# this layer. +RUN --mount=type=cache,target=/root/.cache/pip \ + --mount=type=bind,source=requirements.txt,target=requirements.txt \ + pip install -r requirements.txt + +# Use the minimal runtime image. It runs as nonroot by default. +FROM dhi.io/python:3.12 + +WORKDIR /app + +COPY --from=builder /venv /venv +ENV PATH="/venv/bin:$PATH" + +# Copy the source code into the container. +COPY . . + +# Expose the port that the application listens on. +EXPOSE 8000 + +# Run the application. +CMD ["/venv/bin/python3", "-m", "uvicorn", "app:app", "--host=0.0.0.0", "--port=8000"] +``` +{{< /file >}} + +{{< file path="compose.yaml" >}} +```yaml +# Comments are provided throughout this file to help you get started. +# If you need more help, visit the Docker Compose reference guide at +# https://docs.docker.com/go/compose-spec-reference/ + +# Here the instructions define your application as a service called "server". +# This service is built from the Dockerfile in the current directory. +# You can add other services your application may depend on here, such as a +# database or a cache. For examples, see the Awesome Compose repository: +# https://github.com/docker/awesome-compose +services: + server: + build: + context: . + ports: + - 8000:8000 +``` +{{< /file >}} + +{{< file path=".dockerignore" >}} +```text +# Include any files or directories that you don't want to be copied to your +# container here (e.g., local build artifacts, temporary files, etc.). +# +# For more help, visit the .dockerignore file reference guide at +# https://docs.docker.com/go/build-context-dockerignore/ + +**/.DS_Store +**/__pycache__ +**/.venv +**/.classpath +**/.dockerignore +**/.env +**/.git +**/.gitignore +**/.project +**/.settings +**/.toolstarget +**/.vs +**/.vscode +**/*.*proj.user +**/*.dbmdl +**/*.jfm +**/bin +**/charts +**/docker-compose* +**/compose.y*ml +**/Dockerfile* +**/node_modules +**/npm-debug.log +**/obj +**/secrets.dev.yaml +**/values.dev.yaml +LICENSE +README.md +``` +{{< /file >}} + +{{< file path=".gitignore" >}} +```text +# Files and directories that Git should ignore. This is the standard Python +# template covering bytecode, build artifacts, virtual environments, and IDE +# settings. See https://git-scm.com/docs/gitignore for syntax reference. + +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Secrets +db/password.txt +``` +{{< /file >}} + +{{< /files >}} + +## Update Docker assets + +Replace `Dockerfile` and `compose.yaml` with the following. + +{{< files name="python-docker-example" >}} + +{{< file path="app.py" >}} +```python +from collections.abc import AsyncGenerator, Sequence +from contextlib import asynccontextmanager + +from fastapi import FastAPI +from sqlmodel import Field, Session, SQLModel, create_engine, select + +from config import settings + + +class Hero(SQLModel, table=True): + id: int | None = Field(default=None, primary_key=True) + name: str = Field(index=True) + secret_name: str + age: int | None = Field(default=None, index=True) + + +engine = create_engine(str(settings.SQLALCHEMY_DATABASE_URI)) + + +def create_db_and_tables() -> None: + SQLModel.metadata.create_all(engine) + + +@asynccontextmanager +async def lifespan(_app: FastAPI) -> AsyncGenerator[None, None]: + create_db_and_tables() + yield + + +app = FastAPI(lifespan=lifespan) + + +@app.get("/") +def hello() -> str: + return "Hello, Docker!" + + +@app.post("/heroes/") +def create_hero(hero: Hero) -> Hero: + with Session(engine) as session: + session.add(hero) + session.commit() + session.refresh(hero) + return hero + + +@app.get("/heroes/") +def read_heroes() -> Sequence[Hero]: + with Session(engine) as session: + heroes = session.exec(select(Hero)).all() + return heroes +``` +{{< /file >}} + +{{< file path="config.py" >}} +```python +import os +from typing import Any + +from pydantic import ( + PostgresDsn, + computed_field, + field_validator, + model_validator, +) +from pydantic_core import MultiHostUrl +from pydantic_settings import BaseSettings + + +class Settings(BaseSettings): + POSTGRES_SERVER: str + POSTGRES_PORT: int = 5432 + POSTGRES_USER: str + POSTGRES_PASSWORD: str | None = None + POSTGRES_PASSWORD_FILE: str | None = None + POSTGRES_DB: str + + @model_validator(mode="before") + @classmethod + def check_postgres_password(cls, data: Any) -> Any: + """Validate that either POSTGRES_PASSWORD or POSTGRES_PASSWORD_FILE is set.""" + if isinstance(data, dict): + password_file: str | None = data.get("POSTGRES_PASSWORD_FILE") # type: ignore + password: str | None = data.get("POSTGRES_PASSWORD") # type: ignore + if password_file is None and password is None: + raise ValueError( + "At least one of POSTGRES_PASSWORD_FILE and POSTGRES_PASSWORD must be set." + ) + return data # type: ignore + + @field_validator("POSTGRES_PASSWORD_FILE", mode="before") + @classmethod + def read_password_from_file(cls, v: str | None) -> str | None: + if v is not None: + file_path = v + if os.path.exists(file_path): + with open(file_path) as file: + return file.read().strip() + raise ValueError(f"Password file {file_path} does not exist.") + return v + + @computed_field + @property + def SQLALCHEMY_DATABASE_URI(self) -> PostgresDsn: + url = MultiHostUrl.build( + scheme="postgresql+psycopg", + username=self.POSTGRES_USER, + password=self.POSTGRES_PASSWORD + if self.POSTGRES_PASSWORD + else self.POSTGRES_PASSWORD_FILE, + host=self.POSTGRES_SERVER, + port=self.POSTGRES_PORT, + path=self.POSTGRES_DB, + ) + return PostgresDsn(url) + + +settings = Settings() # type: ignore +``` +{{< /file >}} + +{{< file path="requirements.txt" >}} +```text +# Python package dependencies for the application, pinned for reproducible builds. +# See https://pip.pypa.io/en/stable/reference/requirements-file-format/ + +fastapi==0.115.12 +sqlmodel==0.0.24 +psycopg[binary]==3.2.9 +pydantic-settings==2.9.1 +uvicorn==0.34.3 +``` +{{< /file >}} + +{{< file path="Dockerfile" status="modified" hl_lines="11,27-34,37,45" >}} +```dockerfile +# syntax=docker/dockerfile:1 + +# Comments are provided throughout this file to help you get started. +# If you need more help, visit the Dockerfile reference guide at +# https://docs.docker.com/go/dockerfile-reference/ + +# This Dockerfile uses Docker Hardened Images (DHI) for enhanced security. +# For more information, see https://docs.docker.com/dhi/ + +# Use the dev image to build and install dependencies. +# The builder stage is also used directly in development (see compose.yaml). +FROM dhi.io/python:3.12-dev AS builder + +WORKDIR /app + +RUN python3 -m venv /venv +ENV PATH="/venv/bin:$PATH" + +# Download dependencies as a separate step to take advantage of Docker's caching. +# Leverage a cache mount to /root/.cache/pip to speed up subsequent builds. +# Leverage a bind mount to requirements.txt to avoid having to copy them +# into this layer. +RUN --mount=type=cache,target=/root/.cache/pip \ + --mount=type=bind,source=requirements.txt,target=requirements.txt \ + pip install -r requirements.txt + +# Copy the source code into the container. +COPY . . + +# Expose the port that the application listens on. +EXPOSE 8000 + +# Run the application. +CMD ["/venv/bin/python3", "-m", "uvicorn", "app:app", "--host=0.0.0.0", "--port=8000"] + + +# Use the minimal runtime image for production. It runs as nonroot by default. +FROM dhi.io/python:3.12 + +WORKDIR /app + +COPY --from=builder /venv /venv +ENV PATH="/venv/bin:$PATH" + +COPY --from=builder /app . + +EXPOSE 8000 + +CMD ["/venv/bin/python3", "-m", "uvicorn", "app:app", "--host=0.0.0.0", "--port=8000"] +``` +{{< /file >}} + +{{< file path="compose.yaml" status="modified" hl_lines="5" >}} +```yaml +services: + server: + build: + context: . + target: builder + ports: + - 8000:8000 +``` +{{< /file >}} + +{{< file path=".dockerignore" >}} +```text +# Include any files or directories that you don't want to be copied to your +# container here (e.g., local build artifacts, temporary files, etc.). +# +# For more help, visit the .dockerignore file reference guide at +# https://docs.docker.com/go/build-context-dockerignore/ + +**/.DS_Store +**/__pycache__ +**/.venv +**/.classpath +**/.dockerignore +**/.env +**/.git +**/.gitignore +**/.project +**/.settings +**/.toolstarget +**/.vs +**/.vscode +**/*.*proj.user +**/*.dbmdl +**/*.jfm +**/bin +**/charts +**/docker-compose* +**/compose.y*ml +**/Dockerfile* +**/node_modules +**/npm-debug.log +**/obj +**/secrets.dev.yaml +**/values.dev.yaml +LICENSE +README.md +``` +{{< /file >}} + +{{< file path=".gitignore" >}} +```text +# Files and directories that Git should ignore. This is the standard Python +# template covering bytecode, build artifacts, virtual environments, and IDE +# settings. See https://git-scm.com/docs/gitignore for syntax reference. + +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Secrets +db/password.txt +``` +{{< /file >}} + +{{< /files >}} + +### About these changes + +**`Dockerfile`**: The builder stage now includes `COPY . .` and a `CMD` +instruction, which makes it directly runnable. This lets Compose target the +builder stage during development without rebuilding the production stage. +The production stage at the bottom is unchanged and still produces a minimal, +nonroot runtime image for shipping. + +**`compose.yaml`**: The new `target: builder` line tells Compose to build and +run the builder stage of the Dockerfile during development. Unlike the minimal +production image, the development image includes a shell and additional tools +that make debugging easier. If you need a shell in a running production +container, use [Docker Debug](/reference/cli/docker/debug/) instead. ## Add a local database and persist data -You can use containers to set up local services, like a database. In this section, you'll update the `compose.yaml` file to define a database service and a volume to persist data. +You can use containers to set up local services, like a database. In this +section, you'll update the `compose.yaml` file to define a database service +and a volume to persist data, and add a `db/password.txt` file that holds the +database password. + +{{< files name="python-docker-example" >}} + +{{< file path="app.py" >}} +```python +from collections.abc import AsyncGenerator, Sequence +from contextlib import asynccontextmanager + +from fastapi import FastAPI +from sqlmodel import Field, Session, SQLModel, create_engine, select + +from config import settings + + +class Hero(SQLModel, table=True): + id: int | None = Field(default=None, primary_key=True) + name: str = Field(index=True) + secret_name: str + age: int | None = Field(default=None, index=True) + + +engine = create_engine(str(settings.SQLALCHEMY_DATABASE_URI)) + + +def create_db_and_tables() -> None: + SQLModel.metadata.create_all(engine) + + +@asynccontextmanager +async def lifespan(_app: FastAPI) -> AsyncGenerator[None, None]: + create_db_and_tables() + yield + + +app = FastAPI(lifespan=lifespan) + + +@app.get("/") +def hello() -> str: + return "Hello, Docker!" + + +@app.post("/heroes/") +def create_hero(hero: Hero) -> Hero: + with Session(engine) as session: + session.add(hero) + session.commit() + session.refresh(hero) + return hero + + +@app.get("/heroes/") +def read_heroes() -> Sequence[Hero]: + with Session(engine) as session: + heroes = session.exec(select(Hero)).all() + return heroes +``` +{{< /file >}} + +{{< file path="config.py" >}} +```python +import os +from typing import Any + +from pydantic import ( + PostgresDsn, + computed_field, + field_validator, + model_validator, +) +from pydantic_core import MultiHostUrl +from pydantic_settings import BaseSettings + + +class Settings(BaseSettings): + POSTGRES_SERVER: str + POSTGRES_PORT: int = 5432 + POSTGRES_USER: str + POSTGRES_PASSWORD: str | None = None + POSTGRES_PASSWORD_FILE: str | None = None + POSTGRES_DB: str + + @model_validator(mode="before") + @classmethod + def check_postgres_password(cls, data: Any) -> Any: + """Validate that either POSTGRES_PASSWORD or POSTGRES_PASSWORD_FILE is set.""" + if isinstance(data, dict): + password_file: str | None = data.get("POSTGRES_PASSWORD_FILE") # type: ignore + password: str | None = data.get("POSTGRES_PASSWORD") # type: ignore + if password_file is None and password is None: + raise ValueError( + "At least one of POSTGRES_PASSWORD_FILE and POSTGRES_PASSWORD must be set." + ) + return data # type: ignore + + @field_validator("POSTGRES_PASSWORD_FILE", mode="before") + @classmethod + def read_password_from_file(cls, v: str | None) -> str | None: + if v is not None: + file_path = v + if os.path.exists(file_path): + with open(file_path) as file: + return file.read().strip() + raise ValueError(f"Password file {file_path} does not exist.") + return v + + @computed_field + @property + def SQLALCHEMY_DATABASE_URI(self) -> PostgresDsn: + url = MultiHostUrl.build( + scheme="postgresql+psycopg", + username=self.POSTGRES_USER, + password=self.POSTGRES_PASSWORD + if self.POSTGRES_PASSWORD + else self.POSTGRES_PASSWORD_FILE, + host=self.POSTGRES_SERVER, + port=self.POSTGRES_PORT, + path=self.POSTGRES_DB, + ) + return PostgresDsn(url) + + +settings = Settings() # type: ignore +``` +{{< /file >}} + +{{< file path="requirements.txt" >}} +```text +# Python package dependencies for the application, pinned for reproducible builds. +# See https://pip.pypa.io/en/stable/reference/requirements-file-format/ + +fastapi==0.115.12 +sqlmodel==0.0.24 +psycopg[binary]==3.2.9 +pydantic-settings==2.9.1 +uvicorn==0.34.3 +``` +{{< /file >}} + +{{< file path="Dockerfile" >}} +```dockerfile +# syntax=docker/dockerfile:1 + +# Comments are provided throughout this file to help you get started. +# If you need more help, visit the Dockerfile reference guide at +# https://docs.docker.com/go/dockerfile-reference/ + +# This Dockerfile uses Docker Hardened Images (DHI) for enhanced security. +# For more information, see https://docs.docker.com/dhi/ + +# Use the dev image to build and install dependencies. +# The builder stage is also used directly in development (see compose.yaml). +FROM dhi.io/python:3.12-dev AS builder + +WORKDIR /app + +RUN python3 -m venv /venv +ENV PATH="/venv/bin:$PATH" + +# Download dependencies as a separate step to take advantage of Docker's caching. +# Leverage a cache mount to /root/.cache/pip to speed up subsequent builds. +# Leverage a bind mount to requirements.txt to avoid having to copy them +# into this layer. +RUN --mount=type=cache,target=/root/.cache/pip \ + --mount=type=bind,source=requirements.txt,target=requirements.txt \ + pip install -r requirements.txt -In the cloned repository's directory, open the `compose.yaml` file in an IDE or text editor and update it for your unique application. +# Copy the source code into the container. +COPY . . -In the `compose.yaml` file, you need to uncomment all of the database instructions. In addition, you need to add the database password file as an environment variable to the server service and specify the secret file to use . +# Expose the port that the application listens on. +EXPOSE 8000 -The following is the updated `compose.yaml` file. +# Run the application. +CMD ["/venv/bin/python3", "-m", "uvicorn", "app:app", "--host=0.0.0.0", "--port=8000"] -```yaml {hl_lines="7-43"} + +# Use the minimal runtime image for production. It runs as nonroot by default. +FROM dhi.io/python:3.12 + +WORKDIR /app + +COPY --from=builder /venv /venv +ENV PATH="/venv/bin:$PATH" + +COPY --from=builder /app . + +EXPOSE 8000 + +CMD ["/venv/bin/python3", "-m", "uvicorn", "app:app", "--host=0.0.0.0", "--port=8000"] +``` +{{< /file >}} + +{{< file path="compose.yaml" status="modified" hl_lines="8-40" >}} +```yaml services: server: build: context: . + target: builder ports: - - 8001:8001 + - 8000:8000 environment: - POSTGRES_SERVER=db - POSTGRES_USER=postgres @@ -266,7 +899,7 @@ services: secrets: - db-password db: - image: postgres:18 + image: dhi.io/postgres:18 restart: always user: postgres secrets: @@ -289,38 +922,125 @@ secrets: db-password: file: db/password.txt ``` +{{< /file >}} -> [!NOTE] -> -> To learn more about the instructions in the Compose file, see [Compose file -> reference](/reference/compose-file/). - -Before you run the application using Compose, notice that this Compose file specifies a `password.txt` file to hold the database's password. You must create this file as it's not included in the source repository. - -In the cloned repository's directory, create a new directory named `db` and inside that directory create a file named `password.txt` that contains the password for the database. Using your favorite IDE or text editor, add the following contents to the `password.txt` file. - +{{< file path="db/password.txt" status="new" >}} ```text mysecretpassword ``` +{{< /file >}} -Save and close the `password.txt` file. - -You should now have the following contents in your `python-docker-dev-example` -directory. +{{< file path=".dockerignore" >}} +```text +# Include any files or directories that you don't want to be copied to your +# container here (e.g., local build artifacts, temporary files, etc.). +# +# For more help, visit the .dockerignore file reference guide at +# https://docs.docker.com/go/build-context-dockerignore/ + +**/.DS_Store +**/__pycache__ +**/.venv +**/.classpath +**/.dockerignore +**/.env +**/.git +**/.gitignore +**/.project +**/.settings +**/.toolstarget +**/.vs +**/.vscode +**/*.*proj.user +**/*.dbmdl +**/*.jfm +**/bin +**/charts +**/docker-compose* +**/compose.y*ml +**/Dockerfile* +**/node_modules +**/npm-debug.log +**/obj +**/secrets.dev.yaml +**/values.dev.yaml +LICENSE +README.md +``` +{{< /file >}} +{{< file path=".gitignore" >}} ```text -├── python-docker-dev-example/ -│ ├── db/ -│ │ └── password.txt -│ ├── app.py -│ ├── config.py -│ ├── requirements.txt -│ ├── .dockerignore -│ ├── .gitignore -│ ├── compose.yaml -│ ├── Dockerfile -│ └── README.md +# Files and directories that Git should ignore. This is the standard Python +# template covering bytecode, build artifacts, virtual environments, and IDE +# settings. See https://git-scm.com/docs/gitignore for syntax reference. + +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Secrets +db/password.txt ``` +{{< /file >}} + +{{< /files >}} + +> [!NOTE] +> +> To learn more about the instructions in the Compose file, see [Compose file +> reference](/reference/compose-file/). Now, run the following `docker compose up` command to start your application. @@ -330,11 +1050,11 @@ $ docker compose up --build Now test your API endpoint. Open a new terminal then make a request to the server using the curl commands: -Let's create an object with a post method +Create an object with a POST request: ```console $ curl -X 'POST' \ - 'http://localhost:8001/heroes/' \ + 'http://localhost:8000/heroes/' \ -H 'accept: application/json' \ -H 'Content-Type: application/json' \ -d '{ @@ -356,15 +1076,15 @@ You should receive the following response: } ``` -Let's make a get request with the next curl command: +Now make a GET request: ```console -curl -X 'GET' \ - 'http://localhost:8001/heroes/' \ +$ curl -X 'GET' \ + 'http://localhost:8000/heroes/' \ -H 'accept: application/json' ``` -You should receive the same response as above because it's the only one object we have in database. +You should receive the same response as above because it's the only object in the database. ```json { @@ -383,16 +1103,210 @@ Use Compose Watch to automatically update your running Compose services as you edit and save your code. For more details about Compose Watch, see [Use Compose Watch](/manuals/compose/how-tos/file-watch.md). -Open your `compose.yaml` file in an IDE or text editor and then add the Compose -Watch instructions. The following is the updated `compose.yaml` file. +Open your `compose.yaml` file in an IDE or text editor and add the highlighted +Compose Watch instructions. + +{{< files name="python-docker-example" >}} + +{{< file path="app.py" >}} +```python +from collections.abc import AsyncGenerator, Sequence +from contextlib import asynccontextmanager + +from fastapi import FastAPI +from sqlmodel import Field, Session, SQLModel, create_engine, select + +from config import settings + + +class Hero(SQLModel, table=True): + id: int | None = Field(default=None, primary_key=True) + name: str = Field(index=True) + secret_name: str + age: int | None = Field(default=None, index=True) + + +engine = create_engine(str(settings.SQLALCHEMY_DATABASE_URI)) + + +def create_db_and_tables() -> None: + SQLModel.metadata.create_all(engine) + + +@asynccontextmanager +async def lifespan(_app: FastAPI) -> AsyncGenerator[None, None]: + create_db_and_tables() + yield + + +app = FastAPI(lifespan=lifespan) + + +@app.get("/") +def hello() -> str: + return "Hello, Docker!" + + +@app.post("/heroes/") +def create_hero(hero: Hero) -> Hero: + with Session(engine) as session: + session.add(hero) + session.commit() + session.refresh(hero) + return hero + + +@app.get("/heroes/") +def read_heroes() -> Sequence[Hero]: + with Session(engine) as session: + heroes = session.exec(select(Hero)).all() + return heroes +``` +{{< /file >}} + +{{< file path="config.py" >}} +```python +import os +from typing import Any + +from pydantic import ( + PostgresDsn, + computed_field, + field_validator, + model_validator, +) +from pydantic_core import MultiHostUrl +from pydantic_settings import BaseSettings + + +class Settings(BaseSettings): + POSTGRES_SERVER: str + POSTGRES_PORT: int = 5432 + POSTGRES_USER: str + POSTGRES_PASSWORD: str | None = None + POSTGRES_PASSWORD_FILE: str | None = None + POSTGRES_DB: str + + @model_validator(mode="before") + @classmethod + def check_postgres_password(cls, data: Any) -> Any: + """Validate that either POSTGRES_PASSWORD or POSTGRES_PASSWORD_FILE is set.""" + if isinstance(data, dict): + password_file: str | None = data.get("POSTGRES_PASSWORD_FILE") # type: ignore + password: str | None = data.get("POSTGRES_PASSWORD") # type: ignore + if password_file is None and password is None: + raise ValueError( + "At least one of POSTGRES_PASSWORD_FILE and POSTGRES_PASSWORD must be set." + ) + return data # type: ignore + + @field_validator("POSTGRES_PASSWORD_FILE", mode="before") + @classmethod + def read_password_from_file(cls, v: str | None) -> str | None: + if v is not None: + file_path = v + if os.path.exists(file_path): + with open(file_path) as file: + return file.read().strip() + raise ValueError(f"Password file {file_path} does not exist.") + return v + + @computed_field + @property + def SQLALCHEMY_DATABASE_URI(self) -> PostgresDsn: + url = MultiHostUrl.build( + scheme="postgresql+psycopg", + username=self.POSTGRES_USER, + password=self.POSTGRES_PASSWORD + if self.POSTGRES_PASSWORD + else self.POSTGRES_PASSWORD_FILE, + host=self.POSTGRES_SERVER, + port=self.POSTGRES_PORT, + path=self.POSTGRES_DB, + ) + return PostgresDsn(url) + + +settings = Settings() # type: ignore +``` +{{< /file >}} + +{{< file path="requirements.txt" >}} +```text +# Python package dependencies for the application, pinned for reproducible builds. +# See https://pip.pypa.io/en/stable/reference/requirements-file-format/ + +fastapi==0.115.12 +sqlmodel==0.0.24 +psycopg[binary]==3.2.9 +pydantic-settings==2.9.1 +uvicorn==0.34.3 +``` +{{< /file >}} + +{{< file path="Dockerfile" >}} +```dockerfile +# syntax=docker/dockerfile:1 -```yaml {hl_lines="17-20"} +# Comments are provided throughout this file to help you get started. +# If you need more help, visit the Dockerfile reference guide at +# https://docs.docker.com/go/dockerfile-reference/ + +# This Dockerfile uses Docker Hardened Images (DHI) for enhanced security. +# For more information, see https://docs.docker.com/dhi/ + +# Use the dev image to build and install dependencies. +# The builder stage is also used directly in development (see compose.yaml). +FROM dhi.io/python:3.12-dev AS builder + +WORKDIR /app + +RUN python3 -m venv /venv +ENV PATH="/venv/bin:$PATH" + +# Download dependencies as a separate step to take advantage of Docker's caching. +# Leverage a cache mount to /root/.cache/pip to speed up subsequent builds. +# Leverage a bind mount to requirements.txt to avoid having to copy them +# into this layer. +RUN --mount=type=cache,target=/root/.cache/pip \ + --mount=type=bind,source=requirements.txt,target=requirements.txt \ + pip install -r requirements.txt + +# Copy the source code into the container. +COPY . . + +# Expose the port that the application listens on. +EXPOSE 8000 + +# Run the application. +CMD ["/venv/bin/python3", "-m", "uvicorn", "app:app", "--host=0.0.0.0", "--port=8000"] + + +# Use the minimal runtime image for production. It runs as nonroot by default. +FROM dhi.io/python:3.12 + +WORKDIR /app + +COPY --from=builder /venv /venv +ENV PATH="/venv/bin:$PATH" + +COPY --from=builder /app . + +EXPOSE 8000 + +CMD ["/venv/bin/python3", "-m", "uvicorn", "app:app", "--host=0.0.0.0", "--port=8000"] +``` +{{< /file >}} + +{{< file path="compose.yaml" status="modified" hl_lines="18-21" >}} +```yaml services: server: build: context: . + target: builder ports: - - 8001:8001 + - 8000:8000 environment: - POSTGRES_SERVER=db - POSTGRES_USER=postgres @@ -408,7 +1322,7 @@ services: - action: rebuild path: . db: - image: postgres:18 + image: dhi.io/postgres:18 restart: always user: postgres secrets: @@ -431,6 +1345,120 @@ secrets: db-password: file: db/password.txt ``` +{{< /file >}} + +{{< file path="db/password.txt" >}} +```text +mysecretpassword +``` +{{< /file >}} + +{{< file path=".dockerignore" >}} +```text +# Include any files or directories that you don't want to be copied to your +# container here (e.g., local build artifacts, temporary files, etc.). +# +# For more help, visit the .dockerignore file reference guide at +# https://docs.docker.com/go/build-context-dockerignore/ + +**/.DS_Store +**/__pycache__ +**/.venv +**/.classpath +**/.dockerignore +**/.env +**/.git +**/.gitignore +**/.project +**/.settings +**/.toolstarget +**/.vs +**/.vscode +**/*.*proj.user +**/*.dbmdl +**/*.jfm +**/bin +**/charts +**/docker-compose* +**/compose.y*ml +**/Dockerfile* +**/node_modules +**/npm-debug.log +**/obj +**/secrets.dev.yaml +**/values.dev.yaml +LICENSE +README.md +``` +{{< /file >}} + +{{< file path=".gitignore" >}} +```text +# Files and directories that Git should ignore. This is the standard Python +# template covering bytecode, build artifacts, virtual environments, and IDE +# settings. See https://git-scm.com/docs/gitignore for syntax reference. + +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Secrets +db/password.txt +``` +{{< /file >}} + +{{< /files >}} Run the following command to run your application with Compose Watch. @@ -441,13 +1469,13 @@ $ docker compose watch In a terminal, curl the application to get a response. ```console -$ curl http://localhost:8001 +$ curl http://localhost:8000 Hello, Docker! ``` Any changes to the application's source files on your local machine will now be immediately reflected in the running container. -Open `python-docker-dev-example/app.py` in an IDE or text editor and update the `Hello, Docker!` string by adding a few more exclamation marks. +Open `python-docker-example/app.py` in an IDE or text editor and update the `Hello, Docker!` string by adding a few more exclamation marks. ```diff - return 'Hello, Docker!' @@ -457,7 +1485,7 @@ Open `python-docker-dev-example/app.py` in an IDE or text editor and update the Save the changes to `app.py` and then wait a few seconds for the application to rebuild. Curl the application again and verify that the updated text appears. ```console -$ curl http://localhost:8001 +$ curl http://localhost:8000 Hello, Docker!!! ``` @@ -471,9 +1499,10 @@ database and persist data. You also learned how to use Compose Watch to automati Related information: - [Compose file reference](/reference/compose-file/) -- [Compose file watch](/manuals/compose/how-tos/file-watch.md) +- [Compose secrets](/reference/compose-file/secrets.md) +- [Compose Watch](/manuals/compose/how-tos/file-watch.md) - [Multi-stage builds](/manuals/build/building/multi-stage.md) ## Next steps -In the next section, you'll learn how you can set up linting, formatting and type checking to follow the best practices in python apps. +In the next section, you'll learn how you can set up linting, formatting and type checking to follow the best practices in Python apps. diff --git a/content/guides/python/lint-format-typing.md b/content/guides/python/lint-format-typing.md index 9bdaa3cf8f3c..ffc8ae4734fa 100644 --- a/content/guides/python/lint-format-typing.md +++ b/content/guides/python/lint-format-typing.md @@ -10,30 +10,33 @@ aliases: ## Prerequisites -Complete [Develop your app](develop.md). +Complete [Develop your app](develop.md). This topic requires a local Python +installation because the tools and Git hooks introduced here run on your +host. If you don't want to install Python locally, skip this topic. The same +checks run in CI in the [next topic](configure-github-actions.md). ## Overview -In this section, you'll learn how to set up code quality tools for your Python application. This includes: +Linting, formatting, and type checking are automated ways to catch bugs, +enforce style, and spot type errors before code runs. Running them on every +commit, in CI, and in your editor catches problems early when they're cheap +to fix. -- Linting and formatting with Ruff -- Static type checking with Pyright -- Automating checks with pre-commit hooks +In this section, you'll configure three tools for your Python application. +Ruff handles linting and formatting in a single fast pass. Pyright statically +checks your code for type errors. Pre-commit hooks run both of these +automatically before each Git commit so problems never reach your remote +branch. ## Linting and formatting with Ruff Ruff is an extremely fast Python linter and formatter written in Rust. It replaces multiple tools like flake8, isort, and black with a single unified tool. -Before using Ruff, install it in your Python environment: +Create a `pyproject.toml` file in your `python-docker-example` directory: -```bash -pip install ruff -``` - -If you're using a virtual environment, make sure it is activated so the `ruff` command is available when you run the commands below. - -Create a `pyproject.toml` file: +{{< files name="python-docker-example" >}} +{{< file path="pyproject.toml" status="new" >}} ```toml [tool.ruff] target-version = "py312" @@ -56,61 +59,107 @@ ignore = [ "B904", # Allow raising exceptions without from e, for HTTPException ] ``` +{{< /file >}} -### Using Ruff +{{< /files >}} + +Install Ruff: + +```console +$ pip install ruff +``` + +If you're using a virtual environment, make sure it is activated so the `ruff` +command is available. Run these commands to check and format your code: -```bash +```console # Check for errors -ruff check . +$ ruff check . # Automatically fix fixable errors -ruff check --fix . +$ ruff check --fix . # Format code -ruff format . +$ ruff format . ``` ## Type checking with Pyright Pyright is a fast static type checker for Python that works well with modern Python features. -Add `Pyright` configuration in `pyproject.toml`: +Update `pyproject.toml` to add the Pyright configuration at the bottom. +{{< files name="python-docker-example" >}} + +{{< file path="pyproject.toml" status="modified" hl_lines="21-25" >}} ```toml +[tool.ruff] +target-version = "py312" + +[tool.ruff.lint] +select = [ + "E", # pycodestyle errors + "W", # pycodestyle warnings + "F", # pyflakes + "I", # isort + "B", # flake8-bugbear + "C4", # flake8-comprehensions + "UP", # pyupgrade + "ARG001", # unused arguments in functions +] +ignore = [ + "E501", # line too long, handled by black + "B008", # do not perform function calls in argument defaults + "W191", # indentation contains tabs + "B904", # Allow raising exceptions without from e, for HTTPException +] + [tool.pyright] typeCheckingMode = "strict" pythonVersion = "3.12" exclude = [".venv"] ``` +{{< /file >}} -### Running Pyright +{{< /files >}} -To check your code for type errors: +Install Pyright and run it: -```bash -pyright +```console +$ pip install pyright +$ pyright ``` ## Setting up pre-commit hooks -Pre-commit hooks automatically run checks before each commit. The following `.pre-commit-config.yaml` snippet sets up Ruff: +Pre-commit hooks run checks automatically before each commit on your local +machine. Create a `.pre-commit-config.yaml` file in your `python-docker-example` +directory to set up Ruff hooks: +{{< files name="python-docker-example" >}} + +{{< file path=".pre-commit-config.yaml" status="new" >}} ```yaml - https: https://github.com/charliermarsh/ruff-pre-commit - rev: v0.2.2 - hooks: - - id: ruff - args: [--fix] - - id: ruff-format +repos: + - repo: https://github.com/charliermarsh/ruff-pre-commit + rev: v0.2.2 + hooks: + - id: ruff + args: [--fix] + - id: ruff-format ``` +{{< /file >}} + +{{< /files >}} To install and use: -```bash -pre-commit install -git commit -m "Test commit" # Automatically runs checks +```console +$ pip install pre-commit +$ pre-commit install +$ git commit -m "Test commit" # Automatically runs checks ``` ## Summary @@ -123,6 +172,12 @@ In this section, you learned how to: These tools help maintain code quality and catch errors early in development. +Related information: + +- [Ruff documentation](https://docs.astral.sh/ruff/) +- [Pyright documentation](https://microsoft.github.io/pyright/) +- [pre-commit framework](https://pre-commit.com/) + ## Next steps - [Configure GitHub Actions](configure-github-actions.md) to run these checks automatically diff --git a/content/guides/python/secure-supply-chain.md b/content/guides/python/secure-supply-chain.md new file mode 100644 index 000000000000..6ae5e9cdb19d --- /dev/null +++ b/content/guides/python/secure-supply-chain.md @@ -0,0 +1,143 @@ +--- +title: Secure your Python image supply chain +linkTitle: Secure your supply chain +weight: 45 +keywords: python, sbom, provenance, attestations, docker scout, supply chain, security +description: Learn how to inspect, generate, and verify supply chain attestations for your Python container image. +--- + +## Prerequisites + +Complete [Configure CI/CD for your Python application](configure-github-actions.md). + +## Overview + +When you ship a container image, what's inside it and where it came from +matters. Supply chain attestations are signed records that answer questions +like which packages are in the image, what vulnerabilities affect them, how +the image was built, and what security checks it passed. + +In this section, you'll inspect the attestations that ship with your Docker +Hardened Image base, generate your own SBOM and provenance attestations +during CI, and pin the base image by digest so your builds are reproducible. + +The inspection commands in this topic are shown manually so you can see what +each one returns. In a real workflow you'd automate these checks with +[Docker Scout](/scout/), which runs the same scans on every push, +enforces policies in CI, and surfaces results in your registry and pull +requests. + +## Inspect the base image attestations + +Docker Hardened Images are built to SLSA Build Level 3 and ship with a set of +signed attestations covering bill-of-materials, vulnerabilities, build +provenance, and security scans. See +[DHI attestations](/manuals/dhi/core-concepts/attestations.md) for the full +list of types and how to verify their signatures with Cosign. + +List all the attestations available on the Python DHI: + +```console +$ docker scout attest list registry://dhi.io/python:3.12 +``` + +View the SBOM: + +```console +$ docker scout sbom registry://dhi.io/python:3.12 +``` + +Check known vulnerabilities: + +```console +$ docker scout cves registry://dhi.io/python:3.12 +``` + +> [!NOTE] +> +> The `registry://` prefix forces `docker scout` to fetch the image and its +> attestations from the registry instead of reading a locally pulled copy. If +> you've already pulled or built against the base image, the local copy +> doesn't have the attached attestations, so the prefix is required to see +> them. + +When you base your own image on a DHI image, these attestations stay attached to the base layer in the registry. Tools that inspect your image can follow the chain back to the DHI source. + +## Generate attestations for your image + +Update your GitHub Actions workflow to attach SBOM and provenance attestations to the image you push. + +Edit `.github/workflows/build.yml` and update the build-and-push step: + +```yaml {hl_lines="6-7"} +- name: Build and push Docker image + uses: docker/build-push-action@v6 + with: + context: . + push: true + sbom: true + provenance: mode=max + tags: ${{ steps.meta.outputs.tags }} +``` + +- `sbom: true` tells BuildKit to scan the built image and attach an SBOM attestation. +- `provenance: mode=max` records detailed build provenance, including the source repository, commit, and build parameters. + +The next time your workflow runs, the pushed image will carry these attestations alongside the image manifest in the registry. + +## Inspect your pushed image's attestations + +After your workflow pushes the image, inspect it the same way you inspected the base image: + +```console +$ docker scout attest list registry://DOCKER_USERNAME/REPO_NAME:latest +$ docker scout sbom registry://DOCKER_USERNAME/REPO_NAME:latest +``` + +The SBOM includes packages from every layer, including those inherited from `dhi.io/python:3.12`. The provenance record references the DHI base image by digest, so consumers of your image can trace the build chain back to the DHI source. + +## Pin the base image by digest + +Image tags like `dhi.io/python:3.12` move over time as new patches land. For reproducible builds, pin to an immutable digest. + +The Dockerfile uses two tags, `dhi.io/python:3.12-dev` in the builder stage +and `dhi.io/python:3.12` in the runtime stage. Each tag has its own digest, +so look up both: + +```console +$ docker buildx imagetools inspect dhi.io/python:3.12-dev --format "{{ .Manifest.Digest }}" +sha256:dev123... +$ docker buildx imagetools inspect dhi.io/python:3.12 --format "{{ .Manifest.Digest }}" +sha256:prod123... +``` + +Update your `Dockerfile` to reference each digest on the matching `FROM` line: + +```dockerfile +FROM dhi.io/python:3.12-dev@sha256:dev123... AS builder +# ... +FROM dhi.io/python:3.12@sha256:prod123... +``` + +> [!TIP] +> +> Pinning by digest also pins you to that image's vulnerabilities. Use [Dependabot](https://docs.github.com/en/code-security/dependabot) or [Renovate](https://docs.renovatebot.com/) to automate digest updates so you get a PR when a new patched image is available, with a changelog to review before merging. + +## Summary + +In this section, you learned how to: + +- Inspect the supply chain attestations that ship with the DHI base image, including SBOMs, CVE reports, VEX statements, and scan results +- Generate SBOM and provenance attestations for your own image in CI +- Pin base images by digest for reproducible builds + +Related information: + +- [DHI attestations](/manuals/dhi/core-concepts/attestations.md) +- [Verify a Docker Hardened Image](/manuals/dhi/how-to/verify.md) +- [Docker Scout](/scout/) +- [Build attestations](/manuals/build/metadata/attestations/_index.md) + +## Next steps + +In the next section, you'll deploy your application to Kubernetes. diff --git a/layouts/_shortcodes/file.html b/layouts/_shortcodes/file.html new file mode 100644 index 000000000000..3df6a6cdb949 --- /dev/null +++ b/layouts/_shortcodes/file.html @@ -0,0 +1,78 @@ +{{/* + file shortcode — child of the `files` shortcode. + + The body must be a fenced code block. The fence's info string is used as + the syntax-highlighting language. Wrapping the body in a fence keeps + markdownlint and Vale happy (they treat fence content as code and skip + lint rules that would otherwise fire on `#` comment lines). + + Usage: + {{< file path="app.py" >}} + ```python + from fastapi import FastAPI + ``` + {{< /file >}} + + Attributes: + path required. File path within the project, can include folders (e.g. "db/password.txt"). + lang optional. Overrides the fence info string for syntax highlighting. + status optional. One of "modified" or "new". Shows a colored badge in the file tree. + hl_lines optional. Line numbers/ranges to highlight (e.g. "8" or "6,8-11"). Passed to chroma. +*/}} + +{{ if ne .Parent.Name "files" }} + {{- errorf "file shortcode missing its 'files' parent: %s" .Position -}} +{{ end }} + +{{ $path := trim (.Get "path") " " }} +{{ if not $path }} + {{- errorf "file shortcode requires a path attribute: %s" .Position -}} +{{ end }} + +{{ if not (.Parent.Store.Get "files") }} + {{ .Parent.Store.Set "files" slice }} +{{ end }} + +{{/* Normalize line endings so CRLF source files behave identically to LF, + then trim leading/trailing whitespace so the fence on the first line + can be detected. */}} +{{ $content := .Inner }} +{{ $content = replace $content "\r\n" "\n" }} +{{ $content = replace $content "\r" "\n" }} +{{ $content = strings.TrimLeft "\n\t " $content }} +{{ $content = strings.TrimRight "\n\t " $content }} + +{{/* Body must be wrapped in a fenced code block. Strip the fence and adopt + the info string as the default language. */}} +{{ $lines := split $content "\n" }} +{{ $lineCount := len $lines }} +{{ $firstLine := index $lines 0 }} +{{ $lastLine := index $lines (sub $lineCount 1) }} +{{ if or (lt $lineCount 2) (not (hasPrefix $firstLine "```")) (ne (trim $lastLine " \t") "```") }} + {{- errorf "file %q: body must be a fenced code block (```language ... ```): %s" $path .Position -}} +{{ end }} +{{ $fenceLang := trim (strings.TrimPrefix "```" $firstLine) " \t" }} +{{ $content = delimit (after 1 (first (sub $lineCount 1) $lines)) "\n" }} + +{{/* Language: explicit lang= attribute wins; otherwise use the fence info string. */}} +{{ $lang := .Get "lang" | default $fenceLang }} + +{{/* Normalize optional status attribute. */}} +{{ $status := lower (.Get "status" | default "") }} +{{ if and $status (not (in (slice "" "modified" "new") $status)) }} + {{- errorf "file shortcode 'status' must be \"modified\", \"new\", or empty: %s" .Position -}} +{{ end }} + +{{/* hl_lines: accept either "8" or "8-10" or "6,8-10,12"; chroma expects spaces. */}} +{{ $hlLines := .Get "hl_lines" | default "" }} +{{ if $hlLines }} + {{ $hlLines = replace $hlLines "," " " }} +{{ end }} + +{{ $.Parent.Store.Add "files" (dict + "path" $path + "lang" $lang + "content" $content + "status" $status + "hl_lines" $hlLines +) }} diff --git a/layouts/_shortcodes/files.html b/layouts/_shortcodes/files.html new file mode 100644 index 000000000000..cd2b2b3b2fea --- /dev/null +++ b/layouts/_shortcodes/files.html @@ -0,0 +1,376 @@ +{{/* + files shortcode — renders a VS Code-style file browser with scaffolding commands. + + Usage: + {{< files name="my-project" >}} + {{< file path="app.py" >}} + ...contents... + {{< /file >}} + {{< file path="db/password.txt" >}} + secretpassword + {{< /file >}} + {{< /files >}} + + Attributes: + name required. Project name. Used as the window title and as the + top-level directory in the scaffolding commands. +*/}} + +{{ with .Inner }}{{/* trigger child rendering */}}{{ end }} + +{{ $name := trim (.Get "name") " " }} +{{ if not $name }} + {{- errorf "files shortcode requires a name attribute: %s" .Position -}} +{{ end }} + +{{ $files := .Store.Get "files" }} +{{ if not $files }} + {{- errorf "files shortcode is empty: %s" .Position -}} +{{ end }} + +{{/* Collect unique parent directories (relative to project root) for mkdir. */}} +{{ $dirs := slice }} +{{ range $files }} + {{ $d := path.Dir .path }} + {{ if and (ne $d ".") (not (in $dirs $d)) }} + {{ $dirs = $dirs | append $d }} + {{ end }} +{{ end }} + +{{/* Build the bash scaffolding command. */}} +{{ $bashLines := slice }} +{{ if $dirs }} + {{ $mkdirPaths := slice }} + {{ range $d := $dirs }} + {{ $mkdirPaths = $mkdirPaths | append (printf "%s/%s" $name $d) }} + {{ end }} + {{ $bashLines = $bashLines | append (printf "mkdir -p %s && cd %s" (delimit $mkdirPaths " ") $name) }} +{{ else }} + {{ $bashLines = $bashLines | append (printf "mkdir %s && cd %s" $name $name) }} +{{ end }} +{{ range $files }} + {{ $bashLines = $bashLines | append (printf "cat > %s <<'EOF'" .path) }} + {{ $bashLines = $bashLines | append .content }} + {{ $bashLines = $bashLines | append "EOF" }} +{{ end }} +{{ $bashScript := delimit $bashLines "\n" }} + +{{/* Build the PowerShell scaffolding command. */}} +{{ $psLines := slice }} +{{ $psLines = $psLines | append (printf "New-Item -ItemType Directory -Force -Path %s | Out-Null" $name) }} +{{ range $d := $dirs }} + {{ $psLines = $psLines | append (printf "New-Item -ItemType Directory -Force -Path %s/%s | Out-Null" $name $d) }} +{{ end }} +{{ $psLines = $psLines | append (printf "Set-Location %s" $name) }} +{{ range $files }} + {{ $psLines = $psLines | append "@'" }} + {{ $psLines = $psLines | append .content }} + {{ $psLines = $psLines | append (printf "'@ | Set-Content -Encoding utf8 -Path %s" .path) }} +{{ end }} +{{ $psScript := delimit $psLines "\n" }} + +{{/* + Build a tree-friendly view of the file list. + - $rootFiles: files at project root (dict with "index" and "name") + - $folderPaths: ordered, deduped list of folder paths (parents auto-included) + - $folderFiles: map of folder path -> slice of files in that folder + + We then sort $folderPaths lexicographically so parents naturally come + before their children when iterating. +*/}} +{{ $rootFiles := slice }} +{{ $folderPaths := slice }} +{{ $folderFiles := dict }} +{{ range $i, $f := $files }} + {{ $dir := path.Dir $f.path }} + {{ $entry := dict "index" $i "name" (path.Base $f.path) "path" $f.path "status" ($f.status | default "") }} + {{ if eq $dir "." }} + {{ $rootFiles = $rootFiles | append $entry }} + {{ else }} + {{/* Make sure every parent folder along the path is registered. */}} + {{ $parts := split $dir "/" }} + {{ $prefix := "" }} + {{ range $part := $parts }} + {{ if $prefix }} + {{ $prefix = printf "%s/%s" $prefix $part }} + {{ else }} + {{ $prefix = $part }} + {{ end }} + {{ if not (in $folderPaths $prefix) }} + {{ $folderPaths = $folderPaths | append $prefix }} + {{ end }} + {{ end }} + {{ $existing := index $folderFiles $dir | default slice }} + {{ $existing = $existing | append $entry }} + {{ $folderFiles = merge $folderFiles (dict $dir $existing) }} + {{ end }} +{{ end }} +{{ $folderPaths = sort $folderPaths }} + +
+
+
+ {{/* Window title bar */}} +
+
+
+ + + +
+ {{ $name }} +
+ {{/* Primary view toggle: Files | Scaffold script */}} +
+ + +
+
+ + {{/* Files view: sidebar + content (fixed height with internal scroll) */}} +
+ {{/* Sidebar */}} + + + {{/* Content pane. The outer `main` is the positioning context for the + copy button and stays fixed; the inner div is the scroll container + so content scrolls independently of the button. The chroma + `.highlight` wrapper's own overflow-x is disabled so the scroll + container owns both axes and scrollbars sit at the window edges. */}} +
+ {{/* Per-file copy button. Matches the codeblock copy style: icon-only, + hover-revealed, swaps to a check icon for two seconds on copy. */}} + + +
+ {{ range $i, $f := $files }} + {{ $opts := "" }} + {{ with $f.hl_lines }} + {{ $opts = printf "hl_lines=%s" . }} + {{ end }} +
+ {{ (transform.Highlight $f.content $f.lang $opts) | safeHTML }} +
+ {{ end }} +
+
+
+ + {{/* Scaffold script view, with Bash | PowerShell secondary toggle (fixed height with internal scroll) */}} +
+ {{/* Yellow callout: warning + shell selector */}} +
+
+ {{ partialCached "icon" "exclamation-triangle" "exclamation-triangle" }} + Overwrites existing files with the same names. Run from the parent of your project directory. +
+
+ + +
+
+ {{/* Code area. Outer div is the positioning context for the copy + button and stays fixed; inner div is the scroll container. */}} +
+ +
+
+ {{ (transform.Highlight $bashScript "bash" "") | safeHTML }} +
+
+ {{ (transform.Highlight $psScript "powershell" "") | safeHTML }} +
+
+
+
+
+
+
diff --git a/layouts/_shortcodes/files.markdown.md b/layouts/_shortcodes/files.markdown.md new file mode 100644 index 000000000000..8e5debc30d7e --- /dev/null +++ b/layouts/_shortcodes/files.markdown.md @@ -0,0 +1,20 @@ +{{- /* + Markdown output of the files shortcode (used for the *.md + alternative output format that LLMs consume). + + Renders the project name and each file as a fenced code block with + its path as a label. The file.html child populates the same .Store + this template reads from. +*/ -}} +{{- with .Inner }}{{/* trigger child shortcodes */}}{{ end -}} +{{- $name := trim (.Get "name") " " -}} +{{- $files := .Store.Get "files" -}} + +**`{{ $name }}/`** +{{ range $files }} +`{{ .path }}`{{ with .status }} ({{ . }}){{ end }}: + +```{{ .lang }} +{{ .content }} +``` +{{ end }} From ab12c3ff56d67044f88be9b382f36847ba1b1ef1 Mon Sep 17 00:00:00 2001 From: Craig Osterhout Date: Fri, 29 May 2026 14:18:24 -0700 Subject: [PATCH 2/4] feedback1 Signed-off-by: Craig Osterhout --- content/guides/python/develop.md | 10 +++++----- layouts/_shortcodes/files.html | 23 +++++++++++++++++++---- 2 files changed, 24 insertions(+), 9 deletions(-) diff --git a/content/guides/python/develop.md b/content/guides/python/develop.md index d306d823bcb9..ae6961d80733 100644 --- a/content/guides/python/develop.md +++ b/content/guides/python/develop.md @@ -668,13 +668,13 @@ db/password.txt ### About these changes -**`Dockerfile`**: The builder stage now includes `COPY . .` and a `CMD` +The `Dockerfile` builder stage now includes `COPY . .` and a `CMD` instruction, which makes it directly runnable. This lets Compose target the -builder stage during development without rebuilding the production stage. -The production stage at the bottom is unchanged and still produces a minimal, +builder stage during development without rebuilding the production stage. The +production stage at the bottom is unchanged and still produces a minimal, nonroot runtime image for shipping. -**`compose.yaml`**: The new `target: builder` line tells Compose to build and +In `compose.yaml`, the new `target: builder` line tells Compose to build and run the builder stage of the Dockerfile during development. Unlike the minimal production image, the development image includes a shell and additional tools that make debugging easier. If you need a shell in a running production @@ -1505,4 +1505,4 @@ Related information: ## Next steps -In the next section, you'll learn how you can set up linting, formatting and type checking to follow the best practices in Python apps. +In the next section, you'll learn how you can set up linting, formatting, and type checking to follow the best practices in Python apps. diff --git a/layouts/_shortcodes/files.html b/layouts/_shortcodes/files.html index cd2b2b3b2fea..47bf92f14b31 100644 --- a/layouts/_shortcodes/files.html +++ b/layouts/_shortcodes/files.html @@ -48,24 +48,39 @@ {{ else }} {{ $bashLines = $bashLines | append (printf "mkdir %s && cd %s" $name $name) }} {{ end }} +{{/* Use an unlikely heredoc delimiter so a file whose content contains a + bare `EOF` line (for example, a Dockerfile that itself has a `RUN < %s <<'EOF'" .path) }} + {{ $bashLines = $bashLines | append (printf "cat > %s <<'__DOCKER_DOCS_SCAFFOLD_EOF__'" .path) }} {{ $bashLines = $bashLines | append .content }} - {{ $bashLines = $bashLines | append "EOF" }} + {{ $bashLines = $bashLines | append "__DOCKER_DOCS_SCAFFOLD_EOF__" }} {{ end }} {{ $bashScript := delimit $bashLines "\n" }} {{/* Build the PowerShell scaffolding command. */}} +{{/* Write files via [System.IO.File]::WriteAllText with an explicit + UTF8Encoding($false), so output is UTF-8 without BOM in both Windows + PowerShell 5.1 and PowerShell 7+. The built-in `Set-Content -Encoding utf8` + emits a BOM on 5.1, which can break Dockerfile `# syntax=` directives and + some YAML parsers. */}} {{ $psLines := slice }} +{{ $psLines = $psLines | append "# Write files as UTF-8 without BOM. Works on Windows PowerShell 5.1 and PowerShell 7+." }} +{{ $psLines = $psLines | append "function WriteFile([string]$Path, [string]$Content) {" }} +{{ $psLines = $psLines | append " $full = Join-Path -Path (Get-Location).ProviderPath -ChildPath $Path" }} +{{ $psLines = $psLines | append " [System.IO.File]::WriteAllText($full, $Content, [System.Text.UTF8Encoding]::new($false))" }} +{{ $psLines = $psLines | append "}" }} +{{ $psLines = $psLines | append "" }} {{ $psLines = $psLines | append (printf "New-Item -ItemType Directory -Force -Path %s | Out-Null" $name) }} {{ range $d := $dirs }} {{ $psLines = $psLines | append (printf "New-Item -ItemType Directory -Force -Path %s/%s | Out-Null" $name $d) }} {{ end }} {{ $psLines = $psLines | append (printf "Set-Location %s" $name) }} {{ range $files }} - {{ $psLines = $psLines | append "@'" }} + {{ $psLines = $psLines | append (printf "WriteFile '%s' @'" .path) }} {{ $psLines = $psLines | append .content }} - {{ $psLines = $psLines | append (printf "'@ | Set-Content -Encoding utf8 -Path %s" .path) }} + {{ $psLines = $psLines | append "'@" }} {{ end }} {{ $psScript := delimit $psLines "\n" }} From 7f09db10d864fc76e211d5b2d4e9b6f7db908a9c Mon Sep 17 00:00:00 2001 From: Craig Osterhout Date: Fri, 29 May 2026 14:32:42 -0700 Subject: [PATCH 3/4] add more file comments Signed-off-by: Craig Osterhout --- .../guides/python/configure-github-actions.md | 4 ++ content/guides/python/deploy.md | 18 ++++++ content/guides/python/develop.md | 58 ++++++++++++++++++- content/guides/python/lint-format-typing.md | 13 ++++- 4 files changed, 89 insertions(+), 4 deletions(-) diff --git a/content/guides/python/configure-github-actions.md b/content/guides/python/configure-github-actions.md index e3067fde75f6..55a7d22a1655 100644 --- a/content/guides/python/configure-github-actions.md +++ b/content/guides/python/configure-github-actions.md @@ -57,6 +57,10 @@ Add the following content to the file: {{< file path=".github/workflows/build.yml" status="new" >}} ```yaml +# GitHub Actions workflow that runs on every push to main. +# - lint-test: runs pre-commit hooks (Ruff) and Pyright type checks. +# - build_and_push: signs in to Docker Hub and the DHI registry, then +# builds and pushes the image (with SBOM and provenance attestations). name: Build and push Docker image on: diff --git a/content/guides/python/deploy.md b/content/guides/python/deploy.md index 7c574c12f998..40a8329dfb28 100644 --- a/content/guides/python/deploy.md +++ b/content/guides/python/deploy.md @@ -52,6 +52,11 @@ your Python application](./configure-github-actions.md). {{< file path="docker-postgres-kubernetes.yaml" status="new" >}} ```yaml +# Kubernetes manifests for the PostgreSQL database used by the FastAPI app. +# Contains a Deployment, Service, PersistentVolumeClaim, and Secret. + +# Deployment: runs one PostgreSQL pod. The image, port, env vars, and the +# persistent volume mount are all defined here. apiVersion: apps/v1 kind: Deployment metadata: @@ -90,6 +95,8 @@ spec: persistentVolumeClaim: claimName: postgres-pvc --- +# Service: exposes PostgreSQL inside the cluster on port 5432 so the +# application pod can reach it by the DNS name `postgres`. apiVersion: v1 kind: Service metadata: @@ -101,6 +108,7 @@ spec: selector: app: postgres --- +# PersistentVolumeClaim: storage that survives pod restarts. apiVersion: v1 kind: PersistentVolumeClaim metadata: @@ -113,6 +121,8 @@ spec: requests: storage: 1Gi --- +# Secret: holds the database password (base64-encoded). Referenced by both +# the postgres Deployment and the application Deployment. apiVersion: v1 kind: Secret metadata: @@ -126,6 +136,12 @@ data: {{< file path="docker-python-kubernetes.yaml" status="new" >}} ```yaml +# Kubernetes manifests for the FastAPI application. +# Contains a Deployment and a NodePort Service. + +# Deployment: runs the FastAPI app. Connection details to the postgres +# service are passed in via environment variables, and the database +# password comes from the shared postgres-secret. apiVersion: apps/v1 kind: Deployment metadata: @@ -162,6 +178,8 @@ spec: ports: - containerPort: 8000 --- +# Service: exposes the FastAPI app on port 30001 of the cluster node so +# you can reach it from your host with `curl http://localhost:30001/`. apiVersion: v1 kind: Service metadata: diff --git a/content/guides/python/develop.md b/content/guides/python/develop.md index ae6961d80733..7cbfc9f378a5 100644 --- a/content/guides/python/develop.md +++ b/content/guides/python/develop.md @@ -44,6 +44,11 @@ following contents. {{< file path="app.py" status="modified" >}} ```python +# FastAPI application backed by a PostgreSQL database via SQLModel. +# The FastAPI lifespan handler creates database tables at startup. +# Endpoints: GET / (greeting), POST /heroes/ (create), GET /heroes/ (list). +# See https://fastapi.tiangolo.com/ and https://sqlmodel.tiangolo.com/ + from collections.abc import AsyncGenerator, Sequence from contextlib import asynccontextmanager @@ -100,6 +105,11 @@ def read_heroes() -> Sequence[Hero]: {{< file path="config.py" status="new" >}} ```python +# Pydantic settings that read PostgreSQL connection details from the +# environment. Supports a password file (Docker secrets) via +# POSTGRES_PASSWORD_FILE in addition to POSTGRES_PASSWORD. +# See https://docs.pydantic.dev/latest/concepts/pydantic_settings/ + import os from typing import Any @@ -359,6 +369,11 @@ Replace `Dockerfile` and `compose.yaml` with the following. {{< file path="app.py" >}} ```python +# FastAPI application backed by a PostgreSQL database via SQLModel. +# The FastAPI lifespan handler creates database tables at startup. +# Endpoints: GET / (greeting), POST /heroes/ (create), GET /heroes/ (list). +# See https://fastapi.tiangolo.com/ and https://sqlmodel.tiangolo.com/ + from collections.abc import AsyncGenerator, Sequence from contextlib import asynccontextmanager @@ -415,6 +430,11 @@ def read_heroes() -> Sequence[Hero]: {{< file path="config.py" >}} ```python +# Pydantic settings that read PostgreSQL connection details from the +# environment. Supports a password file (Docker secrets) via +# POSTGRES_PASSWORD_FILE in addition to POSTGRES_PASSWORD. +# See https://docs.pydantic.dev/latest/concepts/pydantic_settings/ + import os from typing import Any @@ -547,9 +567,12 @@ CMD ["/venv/bin/python3", "-m", "uvicorn", "app:app", "--host=0.0.0.0", "--port= ``` {{< /file >}} -{{< file path="compose.yaml" status="modified" hl_lines="5" >}} +{{< file path="compose.yaml" status="modified" hl_lines="8" >}} ```yaml services: + # Application service. The `target: builder` line builds the development + # image (includes a shell and tools); the production stage of the + # Dockerfile is unused in development. server: build: context: . @@ -691,6 +714,11 @@ database password. {{< file path="app.py" >}} ```python +# FastAPI application backed by a PostgreSQL database via SQLModel. +# The FastAPI lifespan handler creates database tables at startup. +# Endpoints: GET / (greeting), POST /heroes/ (create), GET /heroes/ (list). +# See https://fastapi.tiangolo.com/ and https://sqlmodel.tiangolo.com/ + from collections.abc import AsyncGenerator, Sequence from contextlib import asynccontextmanager @@ -747,6 +775,11 @@ def read_heroes() -> Sequence[Hero]: {{< file path="config.py" >}} ```python +# Pydantic settings that read PostgreSQL connection details from the +# environment. Supports a password file (Docker secrets) via +# POSTGRES_PASSWORD_FILE in addition to POSTGRES_PASSWORD. +# See https://docs.pydantic.dev/latest/concepts/pydantic_settings/ + import os from typing import Any @@ -879,9 +912,12 @@ CMD ["/venv/bin/python3", "-m", "uvicorn", "app:app", "--host=0.0.0.0", "--port= ``` {{< /file >}} -{{< file path="compose.yaml" status="modified" hl_lines="8-40" >}} +{{< file path="compose.yaml" status="modified" hl_lines="11-46" >}} ```yaml services: + # Application service. The `target: builder` line builds the development + # image (includes a shell and tools); the production stage of the + # Dockerfile is unused in development. server: build: context: . @@ -898,6 +934,9 @@ services: condition: service_healthy secrets: - db-password + # Database service. Reads the password from a Docker secret mounted at + # /run/secrets/db-password. Compose waits for the healthcheck to pass + # before starting the server, via the server's depends_on. db: image: dhi.io/postgres:18 restart: always @@ -1110,6 +1149,11 @@ Compose Watch instructions. {{< file path="app.py" >}} ```python +# FastAPI application backed by a PostgreSQL database via SQLModel. +# The FastAPI lifespan handler creates database tables at startup. +# Endpoints: GET / (greeting), POST /heroes/ (create), GET /heroes/ (list). +# See https://fastapi.tiangolo.com/ and https://sqlmodel.tiangolo.com/ + from collections.abc import AsyncGenerator, Sequence from contextlib import asynccontextmanager @@ -1166,6 +1210,11 @@ def read_heroes() -> Sequence[Hero]: {{< file path="config.py" >}} ```python +# Pydantic settings that read PostgreSQL connection details from the +# environment. Supports a password file (Docker secrets) via +# POSTGRES_PASSWORD_FILE in addition to POSTGRES_PASSWORD. +# See https://docs.pydantic.dev/latest/concepts/pydantic_settings/ + import os from typing import Any @@ -1298,9 +1347,12 @@ CMD ["/venv/bin/python3", "-m", "uvicorn", "app:app", "--host=0.0.0.0", "--port= ``` {{< /file >}} -{{< file path="compose.yaml" status="modified" hl_lines="18-21" >}} +{{< file path="compose.yaml" status="modified" hl_lines="21-24" >}} ```yaml services: + # Application service. The `target: builder` line builds the development + # image (includes a shell and tools); the production stage of the + # Dockerfile is unused in development. server: build: context: . diff --git a/content/guides/python/lint-format-typing.md b/content/guides/python/lint-format-typing.md index ffc8ae4734fa..699bc20bdd27 100644 --- a/content/guides/python/lint-format-typing.md +++ b/content/guides/python/lint-format-typing.md @@ -38,6 +38,10 @@ Create a `pyproject.toml` file in your `python-docker-example` directory: {{< file path="pyproject.toml" status="new" >}} ```toml +# Configuration for code-quality tools. +# - [tool.ruff]: linting and formatting (https://docs.astral.sh/ruff/) +# - [tool.pyright]: static type checking (https://microsoft.github.io/pyright/) + [tool.ruff] target-version = "py312" @@ -93,8 +97,12 @@ Update `pyproject.toml` to add the Pyright configuration at the bottom. {{< files name="python-docker-example" >}} -{{< file path="pyproject.toml" status="modified" hl_lines="21-25" >}} +{{< file path="pyproject.toml" status="modified" hl_lines="25-29" >}} ```toml +# Configuration for code-quality tools. +# - [tool.ruff]: linting and formatting (https://docs.astral.sh/ruff/) +# - [tool.pyright]: static type checking (https://microsoft.github.io/pyright/) + [tool.ruff] target-version = "py312" @@ -142,6 +150,9 @@ directory to set up Ruff hooks: {{< file path=".pre-commit-config.yaml" status="new" >}} ```yaml +# Pre-commit hook configuration. Runs Ruff (lint + format) on every +# `git commit`. See https://pre-commit.com/ + repos: - repo: https://github.com/charliermarsh/ruff-pre-commit rev: v0.2.2 From 18aea0bdca60f6744695fa3cdb8a89513588b9e8 Mon Sep 17 00:00:00 2001 From: Craig Osterhout Date: Fri, 29 May 2026 15:00:32 -0700 Subject: [PATCH 4/4] agent feedback Signed-off-by: Craig Osterhout --- content/guides/python/configure-github-actions.md | 3 ++- content/guides/python/deploy.md | 2 +- content/guides/python/lint-format-typing.md | 4 ++-- content/guides/python/secure-supply-chain.md | 11 ++++++----- 4 files changed, 11 insertions(+), 9 deletions(-) diff --git a/content/guides/python/configure-github-actions.md b/content/guides/python/configure-github-actions.md index 55a7d22a1655..e935147bec1a 100644 --- a/content/guides/python/configure-github-actions.md +++ b/content/guides/python/configure-github-actions.md @@ -156,5 +156,6 @@ Related information: ## Next steps -In the next section, you'll learn how you can develop locally using Kubernetes. +In the next section, you'll learn how to inspect and generate supply chain +attestations for your image. See [Secure your supply chain](secure-supply-chain.md). diff --git a/content/guides/python/deploy.md b/content/guides/python/deploy.md index 40a8329dfb28..a47ef1213127 100644 --- a/content/guides/python/deploy.md +++ b/content/guides/python/deploy.md @@ -207,7 +207,7 @@ In these Kubernetes YAML files, there are various objects, separated by the `--- your Python application](configure-github-actions.md). - A Service, which will define how the ports are mapped in the containers. - A PersistentVolumeClaim, to define a storage that will be persistent through restarts for the database. -- A Secret, Keeping the database password as an example using secret Kubernetes resource. +- A Secret, which stores the database password as a Kubernetes Secret resource. - A NodePort service, which will route traffic from port 30001 on your host to port 8000 inside the pods it routes to, so you can reach your app from the network. diff --git a/content/guides/python/lint-format-typing.md b/content/guides/python/lint-format-typing.md index 699bc20bdd27..a2df667ae6bf 100644 --- a/content/guides/python/lint-format-typing.md +++ b/content/guides/python/lint-format-typing.md @@ -154,8 +154,8 @@ directory to set up Ruff hooks: # `git commit`. See https://pre-commit.com/ repos: - - repo: https://github.com/charliermarsh/ruff-pre-commit - rev: v0.2.2 + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.15.15 hooks: - id: ruff args: [--fix] diff --git a/content/guides/python/secure-supply-chain.md b/content/guides/python/secure-supply-chain.md index 6ae5e9cdb19d..07c8b3f0ca60 100644 --- a/content/guides/python/secure-supply-chain.md +++ b/content/guides/python/secure-supply-chain.md @@ -106,17 +106,18 @@ so look up both: ```console $ docker buildx imagetools inspect dhi.io/python:3.12-dev --format "{{ .Manifest.Digest }}" -sha256:dev123... +sha256:4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945 $ docker buildx imagetools inspect dhi.io/python:3.12 --format "{{ .Manifest.Digest }}" -sha256:prod123... +sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 ``` -Update your `Dockerfile` to reference each digest on the matching `FROM` line: +Each digest is a 64-character hex string. Update your `Dockerfile` to reference +each digest on the matching `FROM` line: ```dockerfile -FROM dhi.io/python:3.12-dev@sha256:dev123... AS builder +FROM dhi.io/python:3.12-dev@sha256:4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945 AS builder # ... -FROM dhi.io/python:3.12@sha256:prod123... +FROM dhi.io/python:3.12@sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 ``` > [!TIP]