This is an automated email from the ASF dual-hosted git repository.

potiuk pushed a commit to branch pin-os-deps-image-layer
in repository https://gitbox.apache.org/repos/asf/airflow.git

commit 82d2aa8dc55e1365fe96d26ca7556684aa68220d
Author: Jarek Potiuk <[email protected]>
AuthorDate: Sun Nov 1 13:33:06 2026 +0100

    Install the database clients with the pinned OS dependencies
    
    The MySQL, MSSQL and PostgreSQL clients were installed after the pinned 
OS-dependencies layer, so every CI and PROD build still ran apt against the 
live repositories for them - without the alignment with the dhi.io repository, 
which the skipped OS installation no longer ran first. Installing them with the 
OS dependencies puts them in the pinned images, and the marker now covers their 
scripts and selection, so a different choice reinstalls them. The runtime 
OS-dependencies build also s [...]
    
    Generated-by: Claude Opus 5
---
 Dockerfile                                         |  84 +++++++++------
 Dockerfile.ci                                      |  64 ++++++-----
 .../commands/release_management_commands.py        |  52 ++++++++-
 .../src/airflow_breeze/params/build_prod_params.py |   9 ++
 .../airflow_breeze/params/common_build_params.py   |   8 +-
 dev/breeze/tests/test_os_deps_images.py            | 118 ++++++++++++++++++++-
 docker-stack-docs/build-arg-ref.rst                |   4 +-
 docker-stack-docs/build.rst                        |  11 +-
 scripts/docker/install_os_dependencies.sh          |  41 +++++--
 9 files changed, 310 insertions(+), 81 deletions(-)

diff --git a/Dockerfile b/Dockerfile
index fd50894b59b..ebc7202ff86 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -133,17 +133,23 @@ if [[ "$#" != 1 ]]; then
     exit 1
 fi
 
+OS_DEPENDENCIES_SCRIPTS_DIR="$(dirname "${BASH_SOURCE[0]}")"
 OS_DEPENDENCIES_MARKER=/usr/share/airflow/os-dependencies.sha256
 OS_DEPENDENCIES_INPUTS_HASH=$(
     {
         echo "${1}"
-        sha256sum "${BASH_SOURCE[0]}" | cut -d ' ' -f 1
+        for file in "${BASH_SOURCE[0]}" 
"${OS_DEPENDENCIES_SCRIPTS_DIR}/common.sh" \
+                "${OS_DEPENDENCIES_SCRIPTS_DIR}/install_mysql.sh" 
"${OS_DEPENDENCIES_SCRIPTS_DIR}/install_mssql.sh" \
+                "${OS_DEPENDENCIES_SCRIPTS_DIR}/install_postgres.sh" 
"${OS_DEPENDENCIES_SCRIPTS_DIR}"/keys/*; do
+            sha256sum "${file}" | cut -d ' ' -f 1
+        done
         for variable in AIRFLOW_IMAGE_FLAVOR AIRFLOW_PYTHON_VERSION 
PYTHON_HOME \
                 DEV_APT_DEPS ADDITIONAL_DEV_APT_DEPS DEV_APT_COMMAND 
ADDITIONAL_DEV_APT_COMMAND \
                 ADDITIONAL_DEV_APT_ENV RUNTIME_APT_DEPS 
ADDITIONAL_RUNTIME_APT_DEPS RUNTIME_APT_COMMAND \
                 ADDITIONAL_RUNTIME_APT_COMMAND ADDITIONAL_RUNTIME_APT_ENV 
GOLANG_MAJOR_MINOR_VERSION \
                 TEMURIN_VERSION NODEJS_VERSION PNPM_VERSION 
RUSTUP_DEFAULT_TOOLCHAIN RUSTUP_VERSION \
-                RUSTUP_HOME CARGO_HOME COSIGN_VERSION LDFLAGS EXTRA_CFLAGS 
DEPENDENCIES_EPOCH_NUMBER; do
+                RUSTUP_HOME CARGO_HOME COSIGN_VERSION LDFLAGS EXTRA_CFLAGS 
DEPENDENCIES_EPOCH_NUMBER \
+                INSTALL_MYSQL_CLIENT INSTALL_MYSQL_CLIENT_TYPE 
INSTALL_MSSQL_CLIENT INSTALL_POSTGRES_CLIENT; do
             printf '%s=%s\n' "${variable}" "${!variable-}"
         done
     } | sha256sum | cut -d ' ' -f 1
@@ -864,6 +870,25 @@ function install_rustup() {
     rm -f /tmp/rustup-init
 }
 
+function install_database_clients() {
+    # Part of the OS dependencies, so the pinned OS-dependencies images carry 
the clients too and an
+    # image built on one does not install them from the live apt repositories. 
They run in the same
+    # order as before they moved here: after the rest of the OS dependencies, 
each cleaning up after apt.
+    if [[ "${INSTALLATION_TYPE}" == "RUNTIME" ]]; then
+        bash "${OS_DEPENDENCIES_SCRIPTS_DIR}/install_mysql.sh" prod
+        bash "${OS_DEPENDENCIES_SCRIPTS_DIR}/install_mssql.sh" prod
+        bash "${OS_DEPENDENCIES_SCRIPTS_DIR}/install_postgres.sh" prod
+        return
+    fi
+    # The CI image runs Airflow from the sources as well, so it also gets the 
MySQL runtime client.
+    if [[ "${INSTALLATION_TYPE}" == "CI" ]]; then
+        bash "${OS_DEPENDENCIES_SCRIPTS_DIR}/install_mysql.sh" prod
+    fi
+    bash "${OS_DEPENDENCIES_SCRIPTS_DIR}/install_mysql.sh" dev
+    bash "${OS_DEPENDENCIES_SCRIPTS_DIR}/install_mssql.sh" dev
+    bash "${OS_DEPENDENCIES_SCRIPTS_DIR}/install_postgres.sh" dev
+}
+
 function apt_clean() {
     apt-get purge -y --auto-remove -o 
APT::AutoRemove::RecommendsImportant=false
     rm -rf /var/lib/apt/lists/* /var/log/*
@@ -896,6 +921,7 @@ else
     install_docker_cli
     apt_clean
 fi
+install_database_clients
 
 mkdir -p "$(dirname "${OS_DEPENDENCIES_MARKER}")"
 echo "${OS_DEPENDENCIES_INPUTS_HASH}" > "${OS_DEPENDENCIES_MARKER}"
@@ -2283,7 +2309,21 @@ ENV RUSTUP_HOME="/usr/local/rustup"
 ENV CARGO_HOME="/home/airflow/.cargo"
 ENV PATH="${CARGO_HOME}/bin:${PATH}"
 
-COPY --from=scripts install_os_dependencies.sh /scripts/docker/
+ARG INSTALL_MYSQL_CLIENT="true"
+ARG INSTALL_MYSQL_CLIENT_TYPE="mariadb"
+ARG INSTALL_MSSQL_CLIENT="true"
+ARG INSTALL_POSTGRES_CLIENT="true"
+
+ENV INSTALL_MYSQL_CLIENT=${INSTALL_MYSQL_CLIENT} \
+    INSTALL_MYSQL_CLIENT_TYPE=${INSTALL_MYSQL_CLIENT_TYPE} \
+    INSTALL_MSSQL_CLIENT=${INSTALL_MSSQL_CLIENT} \
+    INSTALL_POSTGRES_CLIENT=${INSTALL_POSTGRES_CLIENT}
+
+# The database clients are part of the OS dependencies - 
install_os_dependencies.sh runs their
+# scripts, and its marker covers them, so the pinned OS-dependencies images 
carry them too.
+COPY --from=scripts install_os_dependencies.sh common.sh install_mysql.sh 
install_mssql.sh \
+    install_postgres.sh /scripts/docker/
+COPY scripts/docker/keys/ /scripts/docker/keys/
 RUN bash /scripts/docker/install_os_dependencies.sh dev
 
 FROM dev-os-deps as python-dist
@@ -2309,26 +2349,8 @@ SHELL ["/bin/bash", "-o", "pipefail", "-o", "errexit", 
"-o", "nounset", "-o", "n
 # python interpreter might break if accidentally the old system libraries are 
used.
 ENV LD_LIBRARY_PATH="/usr/python/lib"
 
-ARG INSTALL_MYSQL_CLIENT="true"
-ARG INSTALL_MYSQL_CLIENT_TYPE="mariadb"
-ARG INSTALL_MSSQL_CLIENT="true"
-ARG INSTALL_POSTGRES_CLIENT="true"
-
-ENV INSTALL_MYSQL_CLIENT=${INSTALL_MYSQL_CLIENT} \
-    INSTALL_MYSQL_CLIENT_TYPE=${INSTALL_MYSQL_CLIENT_TYPE} \
-    INSTALL_MSSQL_CLIENT=${INSTALL_MSSQL_CLIENT} \
-    INSTALL_POSTGRES_CLIENT=${INSTALL_POSTGRES_CLIENT}
-
 COPY --from=scripts common.sh /scripts/docker/
 
-# Only copy mysql/mssql installation scripts for now - so that changing the 
other
-# scripts which are needed much later will not invalidate the docker layer here
-COPY --from=scripts install_mysql.sh install_mssql.sh install_postgres.sh 
/scripts/docker/
-COPY scripts/docker/keys/ /scripts/docker/keys/
-
-RUN bash /scripts/docker/install_mysql.sh dev && \
-    bash /scripts/docker/install_mssql.sh dev && \
-    bash /scripts/docker/install_postgres.sh dev
 ENV PATH=${PATH}:/opt/mssql-tools/bin
 
 # By default we do not install from docker context files but if we decide to 
install from docker context
@@ -2560,7 +2582,11 @@ ENV RUNTIME_APT_DEPS=${RUNTIME_APT_DEPS} \
     AIRFLOW_IMAGE_FLAVOR=${AIRFLOW_IMAGE_FLAVOR}
 
 COPY --from=python-dist /python-dist/ /
-COPY --from=scripts install_os_dependencies.sh /scripts/docker/
+# The database clients are part of the OS dependencies - 
install_os_dependencies.sh runs their
+# scripts, and its marker covers them, so the pinned OS-dependencies images 
carry them too.
+COPY --from=scripts install_os_dependencies.sh common.sh install_mysql.sh 
install_mssql.sh \
+    install_postgres.sh /scripts/docker/
+COPY scripts/docker/keys/ /scripts/docker/keys/
 RUN bash /scripts/docker/install_os_dependencies.sh runtime
 
 FROM runtime-os-deps as main
@@ -2593,19 +2619,7 @@ ENV 
PATH="${AIRFLOW_USER_HOME_DIR}/.local/bin:/usr/python/bin:${PATH}" \
 
 COPY --from=scripts common.sh /scripts/docker/
 
-COPY scripts/docker/keys/ /scripts/docker/keys/
-
-# Only copy mysql/mssql installation scripts for now - so that changing the 
other
-# scripts which are needed much later will not invalidate the docker layer 
here.
-COPY --from=scripts install_mysql.sh install_mssql.sh install_postgres.sh 
/scripts/docker/
-# We run scripts with bash here to make sure we can execute the scripts. 
Changing to +x might have an
-# unexpected result - the cache for Dockerfiles might get invalidated in case 
the host system
-# had different umask set and group x bit was not set. In Azure the bit might 
be not set at all.
-# That also protects against AUFS Docker backend problem where changing the 
executable bit required sync
-RUN bash /scripts/docker/install_mysql.sh prod \
-    && bash /scripts/docker/install_mssql.sh prod \
-    && bash /scripts/docker/install_postgres.sh prod \
-    && adduser --gecos "First Last,RoomNumber,WorkPhone,HomePhone" 
--disabled-password \
+RUN adduser --gecos "First Last,RoomNumber,WorkPhone,HomePhone" 
--disabled-password \
            --quiet "airflow" --uid "${AIRFLOW_UID}" --gid "0" --home 
"${AIRFLOW_USER_HOME_DIR}" \
 # Make Airflow files belong to the root group and are accessible. This is to 
accommodate the guidelines from
 # OpenShift 
https://docs.openshift.com/enterprise/3.0/creating_images/guidelines.html
diff --git a/Dockerfile.ci b/Dockerfile.ci
index c1d7fa61d0e..75779430953 100644
--- a/Dockerfile.ci
+++ b/Dockerfile.ci
@@ -63,17 +63,23 @@ if [[ "$#" != 1 ]]; then
     exit 1
 fi
 
+OS_DEPENDENCIES_SCRIPTS_DIR="$(dirname "${BASH_SOURCE[0]}")"
 OS_DEPENDENCIES_MARKER=/usr/share/airflow/os-dependencies.sha256
 OS_DEPENDENCIES_INPUTS_HASH=$(
     {
         echo "${1}"
-        sha256sum "${BASH_SOURCE[0]}" | cut -d ' ' -f 1
+        for file in "${BASH_SOURCE[0]}" 
"${OS_DEPENDENCIES_SCRIPTS_DIR}/common.sh" \
+                "${OS_DEPENDENCIES_SCRIPTS_DIR}/install_mysql.sh" 
"${OS_DEPENDENCIES_SCRIPTS_DIR}/install_mssql.sh" \
+                "${OS_DEPENDENCIES_SCRIPTS_DIR}/install_postgres.sh" 
"${OS_DEPENDENCIES_SCRIPTS_DIR}"/keys/*; do
+            sha256sum "${file}" | cut -d ' ' -f 1
+        done
         for variable in AIRFLOW_IMAGE_FLAVOR AIRFLOW_PYTHON_VERSION 
PYTHON_HOME \
                 DEV_APT_DEPS ADDITIONAL_DEV_APT_DEPS DEV_APT_COMMAND 
ADDITIONAL_DEV_APT_COMMAND \
                 ADDITIONAL_DEV_APT_ENV RUNTIME_APT_DEPS 
ADDITIONAL_RUNTIME_APT_DEPS RUNTIME_APT_COMMAND \
                 ADDITIONAL_RUNTIME_APT_COMMAND ADDITIONAL_RUNTIME_APT_ENV 
GOLANG_MAJOR_MINOR_VERSION \
                 TEMURIN_VERSION NODEJS_VERSION PNPM_VERSION 
RUSTUP_DEFAULT_TOOLCHAIN RUSTUP_VERSION \
-                RUSTUP_HOME CARGO_HOME COSIGN_VERSION LDFLAGS EXTRA_CFLAGS 
DEPENDENCIES_EPOCH_NUMBER; do
+                RUSTUP_HOME CARGO_HOME COSIGN_VERSION LDFLAGS EXTRA_CFLAGS 
DEPENDENCIES_EPOCH_NUMBER \
+                INSTALL_MYSQL_CLIENT INSTALL_MYSQL_CLIENT_TYPE 
INSTALL_MSSQL_CLIENT INSTALL_POSTGRES_CLIENT; do
             printf '%s=%s\n' "${variable}" "${!variable-}"
         done
     } | sha256sum | cut -d ' ' -f 1
@@ -794,6 +800,25 @@ function install_rustup() {
     rm -f /tmp/rustup-init
 }
 
+function install_database_clients() {
+    # Part of the OS dependencies, so the pinned OS-dependencies images carry 
the clients too and an
+    # image built on one does not install them from the live apt repositories. 
They run in the same
+    # order as before they moved here: after the rest of the OS dependencies, 
each cleaning up after apt.
+    if [[ "${INSTALLATION_TYPE}" == "RUNTIME" ]]; then
+        bash "${OS_DEPENDENCIES_SCRIPTS_DIR}/install_mysql.sh" prod
+        bash "${OS_DEPENDENCIES_SCRIPTS_DIR}/install_mssql.sh" prod
+        bash "${OS_DEPENDENCIES_SCRIPTS_DIR}/install_postgres.sh" prod
+        return
+    fi
+    # The CI image runs Airflow from the sources as well, so it also gets the 
MySQL runtime client.
+    if [[ "${INSTALLATION_TYPE}" == "CI" ]]; then
+        bash "${OS_DEPENDENCIES_SCRIPTS_DIR}/install_mysql.sh" prod
+    fi
+    bash "${OS_DEPENDENCIES_SCRIPTS_DIR}/install_mysql.sh" dev
+    bash "${OS_DEPENDENCIES_SCRIPTS_DIR}/install_mssql.sh" dev
+    bash "${OS_DEPENDENCIES_SCRIPTS_DIR}/install_postgres.sh" dev
+}
+
 function apt_clean() {
     apt-get purge -y --auto-remove -o 
APT::AutoRemove::RecommendsImportant=false
     rm -rf /var/lib/apt/lists/* /var/log/*
@@ -826,6 +851,7 @@ else
     install_docker_cli
     apt_clean
 fi
+install_database_clients
 
 mkdir -p "$(dirname "${OS_DEPENDENCIES_MARKER}")"
 echo "${OS_DEPENDENCIES_INPUTS_HASH}" > "${OS_DEPENDENCIES_MARKER}"
@@ -2168,7 +2194,14 @@ ENV RUSTUP_HOME="/usr/local/rustup"
 ENV CARGO_HOME="/usr/local/cargo"
 ENV PATH="${CARGO_HOME}/bin:${PATH}"
 
-COPY --from=scripts install_os_dependencies.sh /scripts/docker/
+ARG INSTALL_MYSQL_CLIENT_TYPE="mariadb"
+ENV INSTALL_MYSQL_CLIENT_TYPE=${INSTALL_MYSQL_CLIENT_TYPE}
+
+# The database clients are part of the OS dependencies - 
install_os_dependencies.sh runs their
+# scripts, and its marker covers them, so the pinned OS-dependencies images 
carry them too.
+COPY --from=scripts install_os_dependencies.sh common.sh install_mysql.sh 
install_mssql.sh \
+    install_postgres.sh /scripts/docker/
+COPY scripts/docker/keys/ /scripts/docker/keys/
 
 RUN bash /scripts/docker/install_os_dependencies.sh ci
 
@@ -2183,35 +2216,18 @@ ARG 
AIRFLOW_IMAGE_REPOSITORY="https://github.com/apache/airflow";
 # python interpreter might break if accidentally the old system libraries are 
used.
 ENV LD_LIBRARY_PATH="/usr/python/lib"
 
-COPY --from=scripts common.sh /scripts/docker/
-
-# Only copy mysql/mssql installation scripts for now - so that changing the 
other
-# scripts which are needed much later will not invalidate the docker layer 
here.
-COPY --from=scripts install_mysql.sh install_mssql.sh install_postgres.sh 
/scripts/docker/
-COPY scripts/docker/keys/ /scripts/docker/keys/
-
 ARG HOME=/root
 ARG AIRFLOW_IMAGE_TYPE="ci"
 ARG AIRFLOW_HOME=/root/airflow
 ARG AIRFLOW_SOURCES=/opt/airflow
-ARG INSTALL_MYSQL_CLIENT_TYPE="mariadb"
 
 ENV HOME=${HOME} \
     AIRFLOW_IMAGE_TYPE=${AIRFLOW_IMAGE_TYPE} \
     AIRFLOW_HOME=${AIRFLOW_HOME} \
-    AIRFLOW_SOURCES=${AIRFLOW_SOURCES} \
-    INSTALL_MYSQL_CLIENT_TYPE=${INSTALL_MYSQL_CLIENT_TYPE}
-
-# We run scripts with bash here to make sure we can execute the scripts. 
Changing to +x might have an
-# unexpected result - the cache for Dockerfiles might get invalidated in case 
the host system
-# had different umask set and group x bit was not set. In Azure the bit might 
be not set at all.
-# That also protects against AUFS Docker backend problem where changing the 
executable bit required sync
-RUN bash /scripts/docker/install_mysql.sh prod \
-    && bash /scripts/docker/install_mysql.sh dev \
-    && bash /scripts/docker/install_mssql.sh dev \
-    && bash /scripts/docker/install_postgres.sh dev \
-    # The user is added to allow ssh debugging (you can connect with 
airflow/airflow over ssh)
-    && adduser --gecos "First Last,RoomNumber,WorkPhone,HomePhone" 
--disabled-password \
+    AIRFLOW_SOURCES=${AIRFLOW_SOURCES}
+
+# The user is added to allow ssh debugging (you can connect with 
airflow/airflow over ssh)
+RUN adduser --gecos "First Last,RoomNumber,WorkPhone,HomePhone" 
--disabled-password \
     --quiet "airflow" --home "/home/airflow" \
     && echo -e "airflow\nairflow" | passwd airflow 2>&1 \
     && echo "airflow ALL=(ALL) NOPASSWD: ALL" > /etc/sudoers.d/airflow \
diff --git 
a/dev/breeze/src/airflow_breeze/commands/release_management_commands.py 
b/dev/breeze/src/airflow_breeze/commands/release_management_commands.py
index 12b0171a9d3..8213d049c7f 100644
--- a/dev/breeze/src/airflow_breeze/commands/release_management_commands.py
+++ b/dev/breeze/src/airflow_breeze/commands/release_management_commands.py
@@ -127,6 +127,7 @@ from airflow_breeze.global_constants import (
     get_ts_sdk_version,
 )
 from airflow_breeze.params.build_ci_params import BuildCiParams
+from airflow_breeze.params.build_prod_params import BuildProdParams
 from airflow_breeze.params.shell_params import ShellParams
 from airflow_breeze.prepare_providers.provider_distributions import (
     PrepareReleasePackageErrorBuildingPackageException,
@@ -5420,14 +5421,26 @@ def get_os_deps_image_build_args(
 
     They must give the installation the same environment the CI and PROD 
builds give it, or the
     marker install_os_dependencies.sh writes would not match there and the 
installation would run again.
-    Those builds pass the Python patchlevel they are pinned to - the one in 
the base image tag.
+    Those builds pass the Python patchlevel they are pinned to - the one in 
the base image tag - and
+    the database clients of their default parameters, which the images install 
too.
     """
     build_args = {
         "BASE_IMAGE": 
f"{HARDENED_PYTHON_IMAGE_MIRROR}:{python_tag}@{python_digest}",
         "AIRFLOW_PYTHON_VERSION": python_tag.split("-", 1)[0],
     }
     if image_type.dockerfile == "Dockerfile":
-        build_args["AIRFLOW_IMAGE_FLAVOR"] = "hardened"
+        prod_params = BuildProdParams()
+        build_args.update(
+            {
+                "AIRFLOW_IMAGE_FLAVOR": "hardened",
+                "INSTALL_MYSQL_CLIENT": prod_params.install_mysql_client,
+                "INSTALL_MYSQL_CLIENT_TYPE": 
prod_params.install_mysql_client_type,
+                "INSTALL_MSSQL_CLIENT": prod_params.install_mssql_client,
+                "INSTALL_POSTGRES_CLIENT": prod_params.install_postgres_client,
+            }
+        )
+    else:
+        build_args["INSTALL_MYSQL_CLIENT_TYPE"] = 
BuildCiParams().install_mysql_client_type
     return build_args
 
 
@@ -5533,6 +5546,21 @@ option_base_image_digests = click.option(
     "'os-deps-base-image-digests' output of mirror-base-images. Tags missing 
from it are read from the mirror.",
 )
 
+
+def find_built_os_deps_image(build: OsDepsImageBuild, platform: str) -> str | 
None:
+    """
+    Return an already built OS-dependencies image of a build, for a build of 
another type to start from.
+
+    The merged image if it is published, the image of the platform otherwise - 
the one this job has just
+    built, or an earlier run that failed before publishing it.
+    """
+    for tag in (build.content_tag, get_platform_tag(build.content_tag, 
platform)):
+        image = f"{HARDENED_OS_DEPS_IMAGE_REPOSITORY}:{tag}"
+        if get_image_digest(image):
+            return image
+    return None
+
+
 option_os_deps_image_type = click.option(
     "--image-type",
     "image_types",
@@ -5561,6 +5589,11 @@ def build_os_deps_images(
     python: str | None, image_types: tuple[str, ...], base_image_digests: 
dict[str, str], platform: str
 ):
     failed: list[str] = []
+    # The dev images built - now or before - for each base image, which the 
runtime builds start from.
+    dev_builds = {
+        build.build_args["BASE_IMAGE"]: build
+        for build in get_os_deps_image_builds(python, ("dev",), 
base_image_digests)
+    }
     for build in get_os_deps_image_builds(python, image_types, 
base_image_digests):
         image = f"{HARDENED_OS_DEPS_IMAGE_REPOSITORY}:{build.content_tag}"
         platform_image = (
@@ -5570,6 +5603,14 @@ def build_os_deps_images(
             console_print(f"[info]{platform_image} is already built[/]")
             continue
         console_print(f"[info]Building {platform_image}[/]")
+        # Not part of the content tag: the runtime stage only copies the 
legacy flavor's Python from the
+        # dev stage, which is empty for the hardened flavor, so the runtime 
image does not change.
+        extra_build_args = {}
+        dev_build = dev_builds.get(build.build_args["BASE_IMAGE"])
+        if build.image_type.name == "runtime" and dev_build:
+            dev_image = find_built_os_deps_image(dev_build, platform)
+            if dev_image:
+                extra_build_args["DEV_OS_DEPS_IMAGE"] = dev_image
         result = run_command(
             [
                 "docker",
@@ -5583,14 +5624,15 @@ def build_os_deps_images(
                 platform,
                 # The point of the image is a fresh installation from the apt 
repositories - but only of
                 # the target stage: the runtime stage copies from one built on 
the dev OS dependencies,
-                # which the dev image built just before in the same job leaves 
in the cache, so this
-                # does not install them a second time. The installation types 
are built in this order.
+                # which starts from the dev image built before, so it does not 
install them a second time.
                 "--no-cache-filter",
                 build.image_type.target,
                 "--pull",
                 # A plain image manifest per platform, for 
publish-os-deps-images to merge.
                 "--provenance=false",
-                
*convert_build_args_dict_to_array_of_args(build_args=build.build_args),
+                *convert_build_args_dict_to_array_of_args(
+                    build_args={**build.build_args, **extra_build_args}
+                ),
                 "--tag",
                 platform_image,
                 "--push",
diff --git a/dev/breeze/src/airflow_breeze/params/build_prod_params.py 
b/dev/breeze/src/airflow_breeze/params/build_prod_params.py
index 54ca594f7a1..7bde13fbfc8 100644
--- a/dev/breeze/src/airflow_breeze/params/build_prod_params.py
+++ b/dev/breeze/src/airflow_breeze/params/build_prod_params.py
@@ -69,6 +69,15 @@ class BuildProdParams(CommonBuildParams):
             return get_legacy_base_image(self.debian_version)
         return super().python_base_image
 
+    def _has_custom_database_clients(self) -> bool:
+        return super()._has_custom_database_clients() or any(
+            [
+                self.disable_mssql_client_installation,
+                self.disable_mysql_client_installation,
+                self.disable_postgres_client_installation,
+            ]
+        )
+
     @property
     def _has_custom_runtime_apt_dependencies(self) -> bool:
         return any(
diff --git a/dev/breeze/src/airflow_breeze/params/common_build_params.py 
b/dev/breeze/src/airflow_breeze/params/common_build_params.py
index ac34ca387df..d12905c783f 100644
--- a/dev/breeze/src/airflow_breeze/params/common_build_params.py
+++ b/dev/breeze/src/airflow_breeze/params/common_build_params.py
@@ -143,17 +143,23 @@ class CommonBuildParams:
             ]
         )
 
+    def _has_custom_database_clients(self) -> bool:
+        """Whether the build installs other database clients than the 
OS-dependencies images carry."""
+        return self.install_mysql_client_type != 
ALLOWED_INSTALL_MYSQL_CLIENT_TYPES[0]
+
     def _can_use_pinned_os_deps_images(self) -> bool:
         """Whether the build may start from the pinned OS-dependencies images 
instead of BASE_IMAGE.
 
         Those images are built on the pinned base image of the default Debian 
version with the default
-        apt dependencies, so any other base image or apt customization 
installs from the repositories.
+        apt dependencies and database clients, so any other base image, apt 
customization or choice
+        of database clients installs from the repositories.
         A disabled image cache does not turn them off - the CI build retries 
with it after any failure.
         """
         return (
             self.use_pinned_os_deps_images
             and self.python_image is None
             and self.debian_version == OS_DEPS_IMAGE_DEBIAN_VERSION
+            and not self._has_custom_database_clients()
         )
 
     def _set_os_deps_image_arg(self, image_type: str, build_arg: str):
diff --git a/dev/breeze/tests/test_os_deps_images.py 
b/dev/breeze/tests/test_os_deps_images.py
index 9d5cd728fca..95cb8d5daea 100644
--- a/dev/breeze/tests/test_os_deps_images.py
+++ b/dev/breeze/tests/test_os_deps_images.py
@@ -17,6 +17,7 @@
 from __future__ import annotations
 
 import json
+import re
 from subprocess import CompletedProcess
 from unittest import mock
 
@@ -187,12 +188,20 @@ def 
test_extract_dockerfile_stage_fails_for_a_missing_stage():
         extract_dockerfile_stage("FROM scratch as main\n", "no-such-stage")
 
 
+PROD_CLIENT_ARGS = {
+    "INSTALL_MYSQL_CLIENT": "true",
+    "INSTALL_MYSQL_CLIENT_TYPE": "mariadb",
+    "INSTALL_MSSQL_CLIENT": "true",
+    "INSTALL_POSTGRES_CLIENT": "true",
+}
+
+
 @pytest.mark.parametrize(
     ("image_type", "expected"),
     [
-        pytest.param("ci", {}, id="ci"),
-        pytest.param("dev", {"AIRFLOW_IMAGE_FLAVOR": "hardened"}, id="dev"),
-        pytest.param("runtime", {"AIRFLOW_IMAGE_FLAVOR": "hardened"}, 
id="runtime"),
+        pytest.param("ci", {"INSTALL_MYSQL_CLIENT_TYPE": "mariadb"}, id="ci"),
+        pytest.param("dev", {"AIRFLOW_IMAGE_FLAVOR": "hardened", 
**PROD_CLIENT_ARGS}, id="dev"),
+        pytest.param("runtime", {"AIRFLOW_IMAGE_FLAVOR": "hardened", 
**PROD_CLIENT_ARGS}, id="runtime"),
     ],
 )
 def test_os_deps_image_build_args_match_the_image_builds(image_type, expected):
@@ -205,6 +214,25 @@ def 
test_os_deps_image_build_args_match_the_image_builds(image_type, expected):
     }
 
 
[email protected](
+    ("image_type", "params"),
+    [
+        pytest.param("ci", BuildCiParams(python="3.12"), id="ci"),
+        pytest.param("dev", BuildProdParams(python="3.12"), id="dev"),
+        pytest.param("runtime", BuildProdParams(python="3.12"), id="runtime"),
+    ],
+)
+def test_os_deps_images_install_what_the_default_image_builds_pass(image_type, 
params):
+    # Every arg the marker depends on must have the value the CI and PROD 
builds pass by default, or
+    # every build on the pinned image would install all of its OS dependencies 
again.
+    os_deps_build_args = get_os_deps_image_build_args(IMAGE_TYPES[image_type], 
PYTHON_TAG, PYTHON_DIGEST)
+    image_build_args = _build_args(params)
+
+    assert {name: image_build_args[name] for name in os_deps_build_args if 
name != "BASE_IMAGE"} == {
+        name: value for name, value in os_deps_build_args.items() if name != 
"BASE_IMAGE"
+    }
+
+
 @pytest.mark.parametrize(
     "change",
     [
@@ -537,6 +565,7 @@ def 
test_os_deps_image_builds_include_a_newer_mirrored_patchlevel():
             {
                 "BASE_IMAGE": f"{MIRROR}:{PYTHON_TAG}@{PYTHON_DIGEST}",
                 "AIRFLOW_PYTHON_VERSION": 
ALL_PYTHON_VERSION_TO_PATCHLEVEL_VERSION["3.12"],
+                "INSTALL_MYSQL_CLIENT_TYPE": "mariadb",
             },
         ),
         (
@@ -544,6 +573,7 @@ def 
test_os_deps_image_builds_include_a_newer_mirrored_patchlevel():
             {
                 "BASE_IMAGE": f"{MIRROR}:3.12.99-debian13-dev@{newer_digest}",
                 "AIRFLOW_PYTHON_VERSION": "3.12.99",
+                "INSTALL_MYSQL_CLIENT_TYPE": "mariadb",
             },
         ),
     ]
@@ -582,3 +612,85 @@ def 
test_os_deps_stages_expose_the_image_their_os_dependencies_come_from():
             (AIRFLOW_ROOT_PATH / image_type.dockerfile).read_text(), 
image_type.target
         )
         assert f"ENV AIRFLOW_OS_DEPS_IMAGE=${{{image_type.build_arg}}}" in 
stage
+
+
+CLIENT_SCRIPTS = ("install_mysql.sh", "install_mssql.sh", 
"install_postgres.sh")
+CLIENT_VARIABLES = (
+    "INSTALL_MYSQL_CLIENT",
+    "INSTALL_MYSQL_CLIENT_TYPE",
+    "INSTALL_MSSQL_CLIENT",
+    "INSTALL_POSTGRES_CLIENT",
+)
+
+
[email protected]("image_type", OS_DEPS_IMAGE_TYPES, ids=lambda 
image_type: image_type.name)
+def test_database_clients_are_installed_in_the_os_deps_stages(image_type):
+    dockerfile = (AIRFLOW_ROOT_PATH / image_type.dockerfile).read_text()
+    stage = extract_dockerfile_stage(dockerfile, image_type.target)
+    main_stage = extract_dockerfile_stage(dockerfile, "main")
+
+    copied = stage.split("COPY --from=scripts install_os_dependencies.sh", 
1)[1].split("\n\n", 1)[0]
+    assert all(script in copied for script in ("common.sh", *CLIENT_SCRIPTS))
+    assert "COPY scripts/docker/keys/ /scripts/docker/keys/" in stage
+    # Set in the stage, before its installation runs - from an arg, or fixed 
for the CI image.
+    before_installation = stage.split("RUN bash 
/scripts/docker/install_os_dependencies.sh", 1)[0]
+    assert all(f"{variable}=" in before_installation for variable in 
CLIENT_VARIABLES)
+    assert not [script for script in CLIENT_SCRIPTS if f"bash 
/scripts/docker/{script}" in main_stage]
+
+
+def test_marker_covers_the_database_clients():
+    script = (AIRFLOW_ROOT_PATH / "scripts" / "docker" / 
"install_os_dependencies.sh").read_text()
+    marker_block = script.split("OS_DEPENDENCIES_INPUTS_HASH=$(", 
1)[1].split("\n)\n", 1)[0]
+
+    assert all(f"/{name}" in marker_block for name in ("common.sh", 
*CLIENT_SCRIPTS))
+    assert "/keys/*" in marker_block
+    assert all(re.search(rf"\b{variable}\b", marker_block) for variable in 
CLIENT_VARIABLES)
+
+
[email protected](
+    "params",
+    [
+        pytest.param({"disable_mysql_client_installation": True}, 
id="no-mysql"),
+        pytest.param({"disable_mssql_client_installation": True}, 
id="no-mssql"),
+        pytest.param({"disable_postgres_client_installation": True}, 
id="no-postgres"),
+        pytest.param({"install_mysql_client_type": "mysql"}, 
id="other-mysql-client"),
+    ],
+)
[email protected](HARDENED_OS_DEPS_IMAGE_DIGESTS, PINS, clear=True)
+def test_prod_image_with_other_database_clients_installs_them_itself(params):
+    build_args = _build_args(BuildProdParams(python="3.12", **params))
+
+    assert not [name for name in build_args if name.endswith("_OS_DEPS_IMAGE")]
+
+
[email protected](HARDENED_OS_DEPS_IMAGE_DIGESTS, PINS, clear=True)
+def test_ci_image_with_another_mysql_client_installs_it_itself():
+    assert "CI_OS_DEPS_IMAGE" not in _build_args(
+        BuildCiParams(python="3.12", install_mysql_client_type="mysql")
+    )
+
+
[email protected]("existing", ["merged", "platform", "none"])
+def test_runtime_os_deps_image_build_starts_from_the_built_dev_image(registry, 
existing):
+    (dev_build,) = get_os_deps_image_builds("3.12", ("dev",))
+    (runtime_build,) = get_os_deps_image_builds("3.12", ("runtime",))
+    dev_image = {
+        "merged": f"{REPOSITORY}:{dev_build.content_tag}",
+        "platform": f"{REPOSITORY}:{dev_build.content_tag}-arm64",
+        "none": None,
+    }[existing]
+    if dev_image:
+        registry.digests[dev_image] = OS_DEPS_DIGEST
+
+    result = CliRunner().invoke(
+        build_os_deps_images,
+        ["--python", "3.12", "--image-type", "runtime", "--platform", 
"linux/arm64"],
+        catch_exceptions=False,
+    )
+
+    assert result.exit_code == 0
+    (command,) = registry.builds
+    build_args = dict(command[i + 1].split("=", 1) for i, arg in 
enumerate(command) if arg == "--build-arg")
+    assert build_args.get("DEV_OS_DEPS_IMAGE") == dev_image
+    # The dev image the runtime build starts from is not one of its inputs.
+    assert command[command.index("--tag") + 1] == 
f"{REPOSITORY}:{runtime_build.content_tag}-arm64"
diff --git a/docker-stack-docs/build-arg-ref.rst 
b/docker-stack-docs/build-arg-ref.rst
index 747fca5d97c..e56cefed5d8 100644
--- a/docker-stack-docs/build-arg-ref.rst
+++ b/docker-stack-docs/build-arg-ref.rst
@@ -59,7 +59,9 @@ Those are the most common arguments that you use when you 
want to build a custom
 |                                          |                                   
        | builds pass a pinned image that already has |
 |                                          |                                   
        | them, built from ``BASE_IMAGE`` by the      |
 |                                          |                                   
        | ``dev-os-deps`` stage. Any other image runs |
-|                                          |                                   
        | the installation.                           |
+|                                          |                                   
        | the installation. The OS dependencies       |
+|                                          |                                   
        | include the database clients the            |
+|                                          |                                   
        | ``INSTALL_*_CLIENT`` args select.           |
 
+------------------------------------------+-------------------------------------------+---------------------------------------------+
 | ``RUNTIME_OS_DEPS_IMAGE``                | ``BASE_IMAGE``                    
        | The image the final image installs its OS   |
 |                                          |                                   
        | dependencies on - like                      |
diff --git a/docker-stack-docs/build.rst b/docker-stack-docs/build.rst
index ef6a401bc93..5dabf354b5d 100644
--- a/docker-stack-docs/build.rst
+++ b/docker-stack-docs/build.rst
@@ -973,11 +973,12 @@ previous images that can matter when you extend or run 
the image are:
 * **The OS dependencies come from pinned images too.** A digest pins the base 
image, but not ``dhi.io``'s apt
   repository: it moves on - and drops the package versions it replaced - 
whenever Docker rebuilds its
   images, so installing the OS dependencies from it can fail for a commit that 
built fine before. Airflow's
-  CI and release builds therefore start their OS dependencies from images in 
which
-  ``install_os_dependencies.sh`` already ran, built daily on the mirrored base 
images and pinned to a digest
-  by the same upgrade, passed as ``DEV_OS_DEPS_IMAGE`` and 
``RUNTIME_OS_DEPS_IMAGE``. The script records what it
-  installed with and does nothing when nothing differs, so a build with other 
apt dependencies still
-  installs them. A plain ``docker build`` leaves both at ``BASE_IMAGE`` and 
installs everything as before.
+  CI and release builds therefore start their OS dependencies - the database 
clients included - from
+  images in which ``install_os_dependencies.sh`` already ran, built daily on 
the mirrored base images and
+  pinned to a digest by the same upgrade, passed as ``DEV_OS_DEPS_IMAGE`` and 
``RUNTIME_OS_DEPS_IMAGE``. The
+  script records what it installed with and does nothing when nothing differs, 
so a build with other apt
+  dependencies or database clients still installs them. A plain ``docker 
build`` leaves both at
+  ``BASE_IMAGE`` and installs everything as before.
   The ``AIRFLOW_OS_DEPS_IMAGE`` environment variable of an image names the 
image its OS dependencies came
   from, and ``/usr/share/airflow/os-dependencies.base-image`` the base image 
they were installed on - which
   can be an older digest than ``BASE_IMAGE``.
diff --git a/scripts/docker/install_os_dependencies.sh 
b/scripts/docker/install_os_dependencies.sh
index 8128142eee6..1ce527f1c9f 100644
--- a/scripts/docker/install_os_dependencies.sh
+++ b/scripts/docker/install_os_dependencies.sh
@@ -27,22 +27,29 @@ fi
 
 # The images built on a pinned OS-dependencies image (see "breeze 
release-management
 # build-os-deps-images") start from a layer this script already ran in. The 
marker records what it
-# ran with: this script, the installation type and every environment variable 
that changes what
-# gets installed - read before the defaults below are applied, as those are 
part of the script. The CI
-# image's DEPENDENCIES_EPOCH_NUMBER is among them, so bumping it reinstalls on 
a pinned image too.
-# When nothing differs there is nothing left to install. When anything does - 
a PR changing this
-# script or the apt dependencies - the installation runs again on top, so the 
change is exercised.
+# ran with: this script, the database client scripts it runs with common.sh 
and the keys they use, the
+# installation type and every environment variable that changes what gets 
installed - read before the
+# defaults below are applied, as those are part of the script. The CI image's 
DEPENDENCIES_EPOCH_NUMBER
+# is among them, so bumping it reinstalls on a pinned image too. When nothing 
differs there is nothing
+# left to install. When anything does - a PR changing one of the scripts, the 
apt dependencies or the
+# database clients - all of the installation runs again on top, so the change 
is exercised.
+OS_DEPENDENCIES_SCRIPTS_DIR="$(dirname "${BASH_SOURCE[0]}")"
 OS_DEPENDENCIES_MARKER=/usr/share/airflow/os-dependencies.sha256
 OS_DEPENDENCIES_INPUTS_HASH=$(
     {
         echo "${1}"
-        sha256sum "${BASH_SOURCE[0]}" | cut -d ' ' -f 1
+        for file in "${BASH_SOURCE[0]}" 
"${OS_DEPENDENCIES_SCRIPTS_DIR}/common.sh" \
+                "${OS_DEPENDENCIES_SCRIPTS_DIR}/install_mysql.sh" 
"${OS_DEPENDENCIES_SCRIPTS_DIR}/install_mssql.sh" \
+                "${OS_DEPENDENCIES_SCRIPTS_DIR}/install_postgres.sh" 
"${OS_DEPENDENCIES_SCRIPTS_DIR}"/keys/*; do
+            sha256sum "${file}" | cut -d ' ' -f 1
+        done
         for variable in AIRFLOW_IMAGE_FLAVOR AIRFLOW_PYTHON_VERSION 
PYTHON_HOME \
                 DEV_APT_DEPS ADDITIONAL_DEV_APT_DEPS DEV_APT_COMMAND 
ADDITIONAL_DEV_APT_COMMAND \
                 ADDITIONAL_DEV_APT_ENV RUNTIME_APT_DEPS 
ADDITIONAL_RUNTIME_APT_DEPS RUNTIME_APT_COMMAND \
                 ADDITIONAL_RUNTIME_APT_COMMAND ADDITIONAL_RUNTIME_APT_ENV 
GOLANG_MAJOR_MINOR_VERSION \
                 TEMURIN_VERSION NODEJS_VERSION PNPM_VERSION 
RUSTUP_DEFAULT_TOOLCHAIN RUSTUP_VERSION \
-                RUSTUP_HOME CARGO_HOME COSIGN_VERSION LDFLAGS EXTRA_CFLAGS 
DEPENDENCIES_EPOCH_NUMBER; do
+                RUSTUP_HOME CARGO_HOME COSIGN_VERSION LDFLAGS EXTRA_CFLAGS 
DEPENDENCIES_EPOCH_NUMBER \
+                INSTALL_MYSQL_CLIENT INSTALL_MYSQL_CLIENT_TYPE 
INSTALL_MSSQL_CLIENT INSTALL_POSTGRES_CLIENT; do
             printf '%s=%s\n' "${variable}" "${!variable-}"
         done
     } | sha256sum | cut -d ' ' -f 1
@@ -769,6 +776,25 @@ function install_rustup() {
     rm -f /tmp/rustup-init
 }
 
+function install_database_clients() {
+    # Part of the OS dependencies, so the pinned OS-dependencies images carry 
the clients too and an
+    # image built on one does not install them from the live apt repositories. 
They run in the same
+    # order as before they moved here: after the rest of the OS dependencies, 
each cleaning up after apt.
+    if [[ "${INSTALLATION_TYPE}" == "RUNTIME" ]]; then
+        bash "${OS_DEPENDENCIES_SCRIPTS_DIR}/install_mysql.sh" prod
+        bash "${OS_DEPENDENCIES_SCRIPTS_DIR}/install_mssql.sh" prod
+        bash "${OS_DEPENDENCIES_SCRIPTS_DIR}/install_postgres.sh" prod
+        return
+    fi
+    # The CI image runs Airflow from the sources as well, so it also gets the 
MySQL runtime client.
+    if [[ "${INSTALLATION_TYPE}" == "CI" ]]; then
+        bash "${OS_DEPENDENCIES_SCRIPTS_DIR}/install_mysql.sh" prod
+    fi
+    bash "${OS_DEPENDENCIES_SCRIPTS_DIR}/install_mysql.sh" dev
+    bash "${OS_DEPENDENCIES_SCRIPTS_DIR}/install_mssql.sh" dev
+    bash "${OS_DEPENDENCIES_SCRIPTS_DIR}/install_postgres.sh" dev
+}
+
 function apt_clean() {
     apt-get purge -y --auto-remove -o 
APT::AutoRemove::RecommendsImportant=false
     rm -rf /var/lib/apt/lists/* /var/log/*
@@ -801,6 +827,7 @@ else
     install_docker_cli
     apt_clean
 fi
+install_database_clients
 
 mkdir -p "$(dirname "${OS_DEPENDENCIES_MARKER}")"
 echo "${OS_DEPENDENCIES_INPUTS_HASH}" > "${OS_DEPENDENCIES_MARKER}"

Reply via email to