Repository navigation
Expand file tree
/
Copy pathDockerfile
More file actions
2526 lines (2260 loc) · 105 KB
/
Copy pathDockerfile
File metadata and controls
2526 lines (2260 loc) · 105 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
# syntax=docker/dockerfile:1.4
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
#
# THIS DOCKERFILE IS INTENDED FOR PRODUCTION USE AND DEPLOYMENT.
# NOTE! IT IS ALPHA-QUALITY FOR NOW - WE ARE IN A PROCESS OF TESTING IT
#
#
# This is a multi-segmented image. It actually contains two images:
#
# airflow-build-image - there all airflow dependencies can be installed (and
# built - for those dependencies that require
# build essentials). Airflow is installed there with
# ${HOME}/.local virtualenv which is also considered
# As --user folder by python when creating venv with
# --system-site-packages
#
# main - this is the actual production image that is much
# smaller because it does not contain all the build
# essentials. Instead the ${HOME}/.local folder
# is copied from the build-image - this way we have
# only result of installation and we do not need
# all the build essentials. This makes the image
# much smaller.
#
# Use the same builder frontend version for everyone
ARG AIRFLOW_EXTRAS="aiobotocore,amazon,async,celery,cncf-kubernetes,common-io,common-messaging,docker,elasticsearch,fab,ftp,git,google,google-auth,graphviz,grpc,hashicorp,http,ldap,microsoft-azure,mysql,odbc,openlineage,opensearch,pandas,postgres,redis,sendgrid,sftp,slack,snowflake,ssh,statsd,uv"
ARG ADDITIONAL_AIRFLOW_EXTRAS=""
ARG ADDITIONAL_PYTHON_DEPS=""
ARG AIRFLOW_HOME=/opt/airflow
ARG AIRFLOW_IMAGE_TYPE="prod"
ARG AIRFLOW_UID="50000"
ARG AIRFLOW_USER_HOME_DIR=/home/airflow
# latest released version here
ARG AIRFLOW_VERSION="3.3.2"
# BASE_IMAGE: the Docker Hardened Image that Python comes from. Both the build stage and the final
# image are based on it, so Python is never compiled during the build.
#
# The default is Airflow's public mirror of the upstream "dhi.io/python" image, so building needs no
# registry credentials. The upstream image itself requires a "docker login dhi.io".
#
# The FIPS-validated variants require a paid Docker subscription and cannot be mirrored or made the
# default, but you can build a FIPS-compliant image by pointing the build at one:
# docker build . --build-arg BASE_IMAGE="dhi.io/python:3.13.16-debian13-fips-dev"
#
# AIRFLOW_IMAGE_FLAVOR="legacy" builds the image the way it was built before the hardened base images:
# BASE_IMAGE is then a plain Debian image (for example "debian:bookworm-slim") and Python
# AIRFLOW_PYTHON_VERSION is downloaded from python.org, verified and compiled from sources.
ARG AIRFLOW_IMAGE_FLAVOR="hardened"
ARG BASE_IMAGE="ghcr.io/apache/airflow/base/python:3.13.16-debian13-dev"
ARG AIRFLOW_PYTHON_VERSION="3.13.16"
# You can swap comments between those two args to test pip from the main version
# When you attempt to test if the version of `pip` from specified branch works for our builds
# Also use `force pip` label on your PR to swap all places we use `uv` to `pip`
ARG AIRFLOW_PIP_VERSION=26.2.1
# ARG AIRFLOW_PIP_VERSION="git+https://github.com/pypa/pip.git@main"
ARG AIRFLOW_UV_VERSION=0.13.4
ARG AIRFLOW_USE_UV="false"
ARG AIRFLOW_IMAGE_REPOSITORY="https://github.com/apache/airflow"
ARG AIRFLOW_IMAGE_README_URL="https://raw.githubusercontent.com/apache/airflow/main/docs/docker-stack/README.md"
# By default we install latest airflow from PyPI so we do not need to copy sources of Airflow
# from the host - so we are using Dockerfile and copy it to /Dockerfile in target image
# because this is the only file we know exists locally. This way you can build the image in PyPI with
# **just** the Dockerfile and no need for any other files from Airflow repository.
# However, in case of breeze/development use we use latest sources and we override those
# SOURCES_FROM/TO with "." and "/opt/airflow" respectively - so that sources of Airflow (and all providers)
# are used to build the PROD image used in tests.
ARG AIRFLOW_SOURCES_FROM="Dockerfile"
ARG AIRFLOW_SOURCES_TO="/Dockerfile"
# By default latest released version of airflow is installed (when empty) but this value can be overridden
# and we can install version according to specification (For example ==2.0.2 or <3.0.0).
ARG AIRFLOW_VERSION_SPECIFICATION=""
# By default PIP has progress bar but you can disable it.
ARG PIP_PROGRESS_BAR="on"
##############################################################################################
# This is the script image where we keep all inlined bash scripts needed in other segments
##############################################################################################
FROM scratch as scripts
##############################################################################################
# Please DO NOT modify the inlined scripts manually. The content of those files will be
# replaced by prek automatically from the "scripts/docker/" folder.
# This is done in order to avoid problems with caching and file permissions and in order to
# make the PROD Dockerfile standalone
##############################################################################################
# The content below is automatically copied from scripts/docker/install_os_dependencies.sh
COPY <<"EOF" /install_os_dependencies.sh
#!/usr/bin/env bash
set -euo pipefail
if [[ "$#" != 1 ]]; then
echo
echo "ERROR! There should be 'runtime', 'ci' or 'dev' parameter passed as argument.".
echo
exit 1
fi
GOLANG_MAJOR_MINOR_VERSION=${GOLANG_MAJOR_MINOR_VERSION:-1.24.4}
TEMURIN_VERSION=${TEMURIN_VERSION:-11}
NODEJS_VERSION=${NODEJS_VERSION:-22.23.1}
PNPM_VERSION=${PNPM_VERSION:-10.28.1}
RUSTUP_DEFAULT_TOOLCHAIN=${RUSTUP_DEFAULT_TOOLCHAIN:-stable}
RUSTUP_VERSION=${RUSTUP_VERSION:-1.29.0}
AIRFLOW_IMAGE_FLAVOR=${AIRFLOW_IMAGE_FLAVOR:-hardened}
AIRFLOW_PYTHON_VERSION=${AIRFLOW_PYTHON_VERSION:-3.13.16}
COSIGN_VERSION=${COSIGN_VERSION:-3.0.5}
if [[ "${AIRFLOW_IMAGE_FLAVOR}" == "hardened" ]]; then
# The Debian 12 hardened images ship Python under /opt/python, the Debian 13 ones install it as
# Debian packages under /usr - so ask the base image's Python where it lives.
PYTHON_HOME=${PYTHON_HOME:-$(python3 -c 'import sys; print(sys.base_prefix)')}
# Read before apt runs: a package depending on python3 can pull another Python version in.
BASE_PYTHON_MAJOR_MINOR=$(python3 -c 'import sys; print(f"{sys.version_info[0]}.{sys.version_info[1]}")')
elif [[ "${AIRFLOW_IMAGE_FLAVOR}" == "legacy" ]]; then
PYTHON_HOME=${PYTHON_HOME:-/usr/python}
else
echo
echo "ERROR! AIRFLOW_IMAGE_FLAVOR should be 'hardened' or 'legacy', not '${AIRFLOW_IMAGE_FLAVOR}'."
echo
exit 1
fi
if [[ "${1}" == "runtime" ]]; then
INSTALLATION_TYPE="RUNTIME"
elif [[ "${1}" == "dev" ]]; then
INSTALLATION_TYPE="DEV"
elif [[ "${1}" == "ci" ]]; then
INSTALLATION_TYPE="CI"
else
echo
echo "ERROR! Wrong argument. Passed ${1} and it should be one of 'runtime', 'ci' or 'dev'.".
echo
exit 1
fi
function get_dev_apt_deps() {
if [[ "${DEV_APT_DEPS=}" == "" ]]; then
DEV_APT_DEPS="\
apt-transport-https \
apt-utils \
build-essential \
dirmngr \
freetds-bin \
freetds-dev \
git \
graphviz \
graphviz-dev \
gzip \
krb5-user \
ldap-utils \
libc6-dev \
libev-dev \
libev4 \
libffi-dev \
libgeos-dev \
libkrb5-dev \
libldap2-dev \
libleveldb-dev \
libleveldb1d \
libsasl2-2 \
libsasl2-dev \
libsasl2-modules \
libssl-dev \
libxmlsec1 \
libxmlsec1-dev \
locales \
openssh-client \
openssl \
pkg-config \
pkgconf \
sasl2-bin \
sqlite3 \
sudo \
tdsodbc \
unixodbc \
unixodbc-dev \
wget \
xz-utils \
zlib1g-dev \
"
if [[ "${AIRFLOW_IMAGE_FLAVOR}" == "legacy" ]]; then
# Needed only to compile Python from sources - without them it builds, but silently
# lacks modules such as sqlite3, lzma or readline.
local debian_version
# shellcheck disable=SC1091
debian_version=$(. /etc/os-release; printf '%s\n' "$VERSION_CODENAME")
if [[ "${debian_version}" == "bookworm" ]]; then
DEV_APT_DEPS+="lcov libbluetooth-dev libbz2-dev libdb-dev libgdbm-compat-dev libgdbm-dev \
liblzma-dev libncurses5-dev libreadline6-dev libsqlite3-dev libzstd-dev lzma lzma-dev tk-dev uuid-dev"
else
DEV_APT_DEPS+="lcov libbluetooth-dev libbz2-dev libdb-dev libgdbm-compat-dev libgdbm-dev \
liblzma-dev libncurses-dev libreadline-dev libsqlite3-dev libzstd-dev tk-dev uuid-dev"
fi
fi
export DEV_APT_DEPS
fi
}
function get_runtime_apt_deps() {
local debian_version
local debian_version_apt_deps
# Get debian version without installing lsb_release
# shellcheck disable=SC1091
debian_version=$(. /etc/os-release; printf '%s\n' "$VERSION_CODENAME";)
echo
echo "DEBIAN CODENAME: ${debian_version}"
echo
if [[ "${debian_version}" == "bookworm" ]]; then
debian_version_apt_deps="\
libffi8 \
libldap-2.5-0 \
libssl3 \
netcat-openbsd\
"
else
# trixie renamed the libraries that moved to a 64-bit time_t (libssl3 -> libssl3t64) and
# dropped the soname from the LDAP library package name.
debian_version_apt_deps="\
libffi8 \
libldap2 \
libssl3t64 \
netcat-openbsd\
"
fi
echo
echo "APPLIED INSTALLATION CONFIGURATION FOR DEBIAN VERSION: ${debian_version}"
echo
# libxmlsec1-openssl was added because libxmlsec1 ships no crypto engine of its own - the engines
# are separate packages - so the "xmlsec" module (pulled in by python3-saml) imported with
# "libxmlsec1-openssl.so.1: cannot open shared object file" without it.
if [[ "${RUNTIME_APT_DEPS=}" == "" ]]; then
RUNTIME_APT_DEPS="\
${debian_version_apt_deps} \
apt-transport-https \
apt-utils \
curl \
dumb-init \
freetds-bin \
git \
gnupg \
iputils-ping \
krb5-user \
ldap-utils \
libev4 \
libgeos-dev \
libsasl2-2 \
libsasl2-modules \
libxmlsec1 \
libxmlsec1-openssl \
locales \
openssh-client \
rsync \
sasl2-bin \
sqlite3 \
sudo \
tdsodbc \
unixodbc \
wget\
"
export RUNTIME_APT_DEPS
fi
}
function install_docker_cli() {
apt-get update
apt-get install ca-certificates curl
install -m 0755 -d /etc/apt/keyrings
curl -fsSL --retry 3 --retry-delay 5 https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc
chmod a+r /etc/apt/keyrings/docker.asc
# shellcheck disable=SC1091
echo \
"deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian \
$(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \
tee /etc/apt/sources.list.d/docker.list > /dev/null
apt-get update
apt-get install -y --no-install-recommends docker-ce-cli
}
function keep_image_conffiles() {
# The hardened base images ship a modified /etc/debian_version, so any apt run that upgrades
# base-files stops at dpkg's interactive conffile prompt and fails a non-interactive build. Making
# dpkg keep the image's copy by default covers every apt run - this build's and those of images
# extending it - so no install command has to remember the flags.
mkdir -p /etc/dpkg/dpkg.cfg.d
printf '%s\n' force-confdef force-confold > /etc/dpkg/dpkg.cfg.d/airflow-keep-conffiles
}
function refresh_ldconfig_after_dpkg() {
# The hardened base images ship libc-bin without its dpkg trigger, so installing a shared library
# does not refresh the ldconfig cache and ctypes.util.find_library() - which reads that cache -
# cannot find it (for example pyenchant's "The 'enchant' C library was not found"). The hook covers
# every apt run - this build's and those of images extending it.
mkdir -p /etc/apt/apt.conf.d
echo 'DPkg::Post-Invoke { "if [ -x /sbin/ldconfig ]; then /sbin/ldconfig; fi"; };' \
> /etc/apt/apt.conf.d/99airflow-ldconfig
}
function restore_debian_base_files() {
# The hardened base images ship a minimal /etc, but Debian maintainer scripts assume the files
# a stock Debian has: sasl2-bin chowns its run directory to the "sasl" group from base-passwd,
# and tmux registers its shell with add-shell, which reads /etc/shells. The base-passwd package
# only ships the reference copies of the account files - update-passwd is what merges them
# into /etc.
# libpam-runtime generates the /etc/pam.d/common-* files that the PAM configs already in the
# image "@include" - without them "adduser --gecos" aborts with a PAM error from chfn.
# Debian Policy lets a maintainer script rely on any essential package without declaring it, and
# libgcrypt20 takes that up: its postinst runs a helper with a "#!/bin/dash" shebang. The hardened
# images ship /bin/sh as a symlink to bash and no dash at all, so configuring the package aborts
# with exit 127 and takes the whole apt transaction with it. Installing it first restores the
# assumption, and hands /bin/sh back to dash the way a stock Debian has it.
apt-get install -y --no-install-recommends dash
apt-get install -y --no-install-recommends base-passwd libpam-runtime
update-passwd
# The Debian 13 hardened images strip /etc/pam.d further: libpam-runtime and passwd are already
# installed there, so installing them generates nothing, and passwd, chpasswd and chfn abort with
# "pam_start() failed". Reinstalling passwd brings back its deleted PAM configs, and
# pam-auth-update - which treats the deleted common-* files as local changes - needs --force.
apt-get install -y --no-install-recommends --reinstall -o Dpkg::Options::=--force-confmiss passwd
if [[ ! -e /etc/pam.d/common-auth ]]; then
pam-auth-update --package --force
fi
if [[ ! -e /etc/shells ]]; then
printf '%s\n' "# /etc/shells: valid login shells" /bin/sh /bin/bash > /etc/shells
fi
align_perl_base_with_perl
}
function align_perl_base_with_perl() {
# The hardened images ship their own build of perl-base, but the hardened apt repository does not
# always publish the matching perl - while it still publishes packages that depend on perl, such
# as git. Debian's perl depends on exactly its own perl-base version, so apt refuses to install
# git at all. Moving perl-base to the version of the perl apt can install keeps the two in step;
# when the repository does publish a matching perl, the versions already agree and this does nothing.
local perl_version
local perl_base_version
perl_version=$(apt-cache policy perl | awk '/Candidate:/ {print $2}')
perl_base_version=$(dpkg-query -W -f='${Version}' perl-base 2>/dev/null || true)
if [[ -n "${perl_version}" && "${perl_version}" != "(none)" && -n "${perl_base_version}" \
&& "${perl_base_version}" != "${perl_version}" ]]; then
apt-get install -y --no-install-recommends --allow-downgrades "perl-base=${perl_version}"
fi
}
function install_debian_dev_dependencies() {
if [[ "${AIRFLOW_IMAGE_FLAVOR}" == "hardened" ]]; then
keep_image_conffiles
refresh_ldconfig_after_dpkg
fi
apt-get update
apt-get install -yqq --no-install-recommends apt-utils >/dev/null 2>&1
if [[ "${AIRFLOW_IMAGE_FLAVOR}" == "hardened" ]]; then
restore_debian_base_files
fi
apt-get install -y --no-install-recommends wget curl gnupg2 ca-certificates
# shellcheck disable=SC2086
export ${ADDITIONAL_DEV_APT_ENV?}
if [[ ${DEV_APT_COMMAND} != "" ]]; then
bash -o pipefail -o errexit -o nounset -o nolog -c "${DEV_APT_COMMAND}"
fi
if [[ ${ADDITIONAL_DEV_APT_COMMAND} != "" ]]; then
bash -o pipefail -o errexit -o nounset -o nolog -c "${ADDITIONAL_DEV_APT_COMMAND}"
fi
apt-get update
local debian_version
local debian_version_apt_deps
# Get debian version without installing lsb_release
# shellcheck disable=SC1091
debian_version=$(. /etc/os-release; printf '%s\n' "$VERSION_CODENAME";)
echo
echo "DEBIAN CODENAME: ${debian_version}"
echo
# shellcheck disable=SC2086
apt-get install -y --no-install-recommends ${DEV_APT_DEPS}
}
function install_additional_dev_dependencies() {
if [[ "${ADDITIONAL_DEV_APT_DEPS=}" != "" ]]; then
# shellcheck disable=SC2086
apt-get install -y --no-install-recommends ${ADDITIONAL_DEV_APT_DEPS}
fi
}
function link_python() {
# Airflow images have always exposed Python under /usr/python - documentation, volume mounts and
# user customizations refer to that path - while the hardened base images ship it in /opt/python
# (Debian 12) or /usr (Debian 13), so keep the historical location working as a symlink.
if [[ ! -e /usr/python ]]; then
ln -sv "${PYTHON_HOME}" /usr/python
fi
# The hardened base images have no /usr/local tree at all
mkdir -p /usr/local/bin /usr/local/lib
# link python binaries to /usr/local/bin and /usr/python/bin with and without 3 suffix
# Links in /usr/local/bin are needed for tools that expect python to be there
# Links in /usr/python/bin are needed for tools that are detecting home of python installation including
# lib/site-packages. The /usr/python/bin should be first in PATH in order to help with the last part.
for dst in pip3 python3 python3-config; do
src="$(echo "${dst}" | tr -d 3)"
if [[ ! -e "/usr/python/bin/${dst}" ]]; then
continue
fi
echo "Linking ${dst} in /usr/local/bin and /usr/python/bin"
ln -sfv "/usr/python/bin/${dst}" "/usr/local/bin/${dst}"
for dir in /usr/local/bin /usr/python/bin; do
if [[ ! -e "${dir}/${src}" ]]; then
echo "Creating ${src} - > ${dst} link in ${dir}"
ln -sv "${dir}/${dst}" "${dir}/${src}"
fi
done
done
# A Python installed under /usr already has its libraries where the dynamic linker looks, and
# linking all of /usr/lib into /usr/local/lib would only shadow the system libraries.
if [[ "$(readlink -f "${PYTHON_HOME}")" != "/usr" ]]; then
for dst in /usr/python/lib/*
do
src="/usr/local/lib/$(basename "${dst}")"
if [[ -e "${src}" ]]; then
rm -rf "${src}"
fi
echo "Linking ${dst} to ${src}"
ln -sv "${dst}" "${src}"
done
fi
ldconfig
}
function restore_thread_stack_size() {
# The hardened base images build Python with -DTHREAD_STACK_SIZE=0x100000, so every thread Python
# starts gets a 1 MiB stack instead of glibc's default (the 8 MiB "ulimit -s" the previous images
# used). On Python 3.12 and 3.13 the C recursion guard is a fixed depth count sized for the larger
# stack, so deeply nested input - e.g. a JSON body parsed in a web server worker thread - overflows
# the stack and kills the process instead of raising RecursionError. A .pth file runs at every
# interpreter start-up without taking the sitecustomize module name users may already rely on.
local site_packages
site_packages="$(/usr/python/bin/python -c 'import sysconfig; print(sysconfig.get_paths()["purelib"])')"
echo "Restoring the 8 MiB default thread stack size in ${site_packages}"
echo "import threading; threading.stack_size(8 * 1024 * 1024)" > "${site_packages}/airflow-thread-stack-size.pth"
}
function compile_python_stdlib() {
# The hardened base images ship the standard library with no .pyc files at all. Python then misses
# the bytecode cache on every stdlib import, and in a read-only or non-writable directory it cannot
# create one - each miss leaves a negative dentry in the kernel, which grows without bound in a
# long-running container and can exhaust memory on the host. Compiling the standard library here
# restores what the previously compiled-in-image Python shipped.
# See https://github.com/apache/airflow/pull/58944 and https://lwn.net/Articles/814535/
local stdlib
stdlib="$(/usr/python/bin/python -c 'import sysconfig; print(sysconfig.get_paths()["stdlib"])')"
echo "Compiling Python standard library in ${stdlib}"
# compileall exits non-zero when any file fails to compile, and the standard library ships files
# that are meant not to compile (deliberately broken syntax used by the test suite).
/usr/python/bin/python -m compileall -q -j "$(nproc)" -o 0 -o 1 -o 2 "${stdlib}" || true
}
function hold_hardened_python() {
# The Debian 13 hardened images install their Python as packages that conflict with Debian's own
# python3. A package depending on python3 - for example NodeSource's nodejs - therefore makes apt
# remove the image's Python and install Debian's, without asking. Holding the packages turns that
# into an apt error instead, in this build and in images extending it.
local packages
packages=$(dpkg-query -W -f='${db:Status-Abbrev} ${Package}\n' 'python-3.*' 'libpython-3.*' 2>/dev/null \
| awk '$1 == "ii" {print $2}' || true)
if [[ -n "${packages}" ]]; then
# shellcheck disable=SC2086
apt-mark hold ${packages}
fi
}
function check_no_system_python() {
# Python - from the hardened base image or compiled from sources - must stay the only Python in the
# image. A system Python pulled in as a dependency of an apt package shares its shared libraries
# with ours and leads to errors such as:
# /usr/python/lib/python3.11/lib-dynload/_ssl.cpython-311-aarch64-linux-gnu.so: undefined symbol: _PyModule_Add
# Debian names its Python libraries "libpython3.13". The trixie hardened images register their own
# Python with dpkg as "libpython-3.13", which is the Python we want, so it must not match.
# Debian 13 hardened images package their Python as "python-3.13" and serve other versions from
# their apt repository, so a package depending on python3 can also pull in a hardened Python of a
# different version than the image's own.
local other_hardened_python=""
if [[ -n "${BASE_PYTHON_MAJOR_MINOR=}" ]]; then
other_hardened_python=$(dpkg -l | awk '/^ii (lib)?python-3\.[0-9]+/ {print $2}' \
| grep -v -E "python-${BASE_PYTHON_MAJOR_MINOR//./\\.}(-|:|$)" || true)
fi
if [[ -n "${other_hardened_python}" ]]; then
echo
echo "ERROR! A Python other than the image's ${BASE_PYTHON_MAJOR_MINOR} was installed: ${other_hardened_python//$'\n'/ }"
echo
apt-get install -yqq aptitude >/dev/null
aptitude why "$(echo "${other_hardened_python}" | head -1 | cut -d: -f1)"
echo
exit 1
fi
if dpkg -l | grep -E '^ii libpython3\.[0-9]+' >/dev/null; then
echo
echo "ERROR! System python is installed by one of the previous steps"
echo
echo "Please make sure that no python packages are installed by default. Displaying the reason why libpython is installed:"
echo
apt-get install -yqq aptitude >/dev/null
aptitude why "$(dpkg -l | grep -E '^ii libpython3\.[0-9]+' | head -1 | awk '{print $2}')"
echo
exit 1
else
echo
echo "GOOD! System python is not installed - OK"
echo
fi
}
function install_cosign() {
local arch
arch="$(dpkg --print-architecture)"
declare -A cosign_sha256s=(
# https://github.com/sigstore/cosign/releases/download/v${COSIGN_VERSION}/cosign_checksums.txt
[amd64]="db15cc99e6e4837daabab023742aaddc3841ce57f193d11b7c3e06c8003642b2"
[arm64]="d098f3168ae4b3aa70b4ca78947329b953272b487727d1722cb3cb098a1a20ab"
)
local cosign_sha256="${cosign_sha256s[${arch}]}"
if [[ -z "${cosign_sha256}" ]]; then
echo "Unsupported architecture for cosign: ${arch}"
exit 1
fi
curl -fsSL --retry 3 --retry-delay 5 \
"https://github.com/sigstore/cosign/releases/download/v${COSIGN_VERSION}/cosign-linux-${arch}" \
-o /tmp/cosign
echo "${cosign_sha256} /tmp/cosign" | sha256sum --check
chmod +x /tmp/cosign
}
function install_python() {
# Only the legacy image flavor compiles Python - the hardened flavor gets it from the base image.
wget --tries=3 --waitretry=5 -O python.tar.xz "https://www.python.org/ftp/python/${AIRFLOW_PYTHON_VERSION%%[a-z]*}/Python-${AIRFLOW_PYTHON_VERSION}.tar.xz"
local major_minor_version
major_minor_version="${AIRFLOW_PYTHON_VERSION%.*}"
echo "Verifying Python ${AIRFLOW_PYTHON_VERSION} (${major_minor_version})"
# Sigstore verification (PEP 761)
declare -A sigstore_identities=(
# https://peps.python.org/pep-0664/#release-manager-and-crew
[3.11]="pablogsal@python.org"
# https://peps.python.org/pep-0693/#release-manager-and-crew
[3.12]="thomas@python.org"
# https://peps.python.org/pep-0719/#release-manager-and-crew
[3.13]="thomas@python.org"
# https://peps.python.org/pep-0745/#release-manager-and-crew
[3.14]="hugo@python.org"
# https://peps.python.org/pep-0790/#release-manager-and-crew
[3.15]="hugo@python.org"
)
declare -A sigstore_issuers=(
[3.11]="https://accounts.google.com"
[3.12]="https://accounts.google.com"
[3.13]="https://accounts.google.com"
[3.14]="https://github.com/login/oauth"
[3.15]="https://github.com/login/oauth"
)
wget --tries=3 --waitretry=5 -O python.tar.xz.sigstore \
"https://www.python.org/ftp/python/${AIRFLOW_PYTHON_VERSION%%[a-z]*}/Python-${AIRFLOW_PYTHON_VERSION}.tar.xz.sigstore"
install_cosign
local identity="${sigstore_identities[${major_minor_version}]}"
local issuer="${sigstore_issuers[${major_minor_version}]}"
/tmp/cosign verify-blob \
--bundle python.tar.xz.sigstore \
--certificate-identity "${identity}" \
--certificate-oidc-issuer "${issuer}" \
python.tar.xz
rm -f python.tar.xz.sigstore /tmp/cosign
mkdir -p /usr/src/python
tar --extract --directory /usr/src/python --strip-components=1 --file python.tar.xz
rm python.tar.xz
cd /usr/src/python
arch="$(dpkg --print-architecture)"; arch="${arch##*-}"
gnuArch="$(dpkg-architecture --query DEB_BUILD_GNU_TYPE)"
EXTRA_CFLAGS="$(dpkg-buildflags --get CFLAGS)"
EXTRA_CFLAGS="${EXTRA_CFLAGS:-} -fno-omit-frame-pointer -mno-omit-leaf-frame-pointer";
LDFLAGS="$(dpkg-buildflags --get LDFLAGS)"
LDFLAGS="${LDFLAGS:--Wl},--strip-all"
local build_log
build_log=$(mktemp)
echo "Building Python ${AIRFLOW_PYTHON_VERSION} from source..."
if ! (
./configure --enable-optimizations --prefix=/usr/python/ --with-ensurepip --build="$gnuArch" \
--enable-loadable-sqlite-extensions --enable-option-checking=fatal \
--enable-shared --with-lto && \
make -s -j "$(nproc)" "EXTRA_CFLAGS=${EXTRA_CFLAGS:-}" \
"LDFLAGS=${LDFLAGS:--Wl},-rpath='\$\$ORIGIN/../lib'" python && \
make -s -j "$(nproc)" install
) > "${build_log}" 2>&1; then
echo
echo "ERROR! Python build failed. Build output:"
echo
cat "${build_log}"
rm -f "${build_log}"
exit 1
fi
rm -f "${build_log}"
# CPython builds without the optional modules whose -dev packages are missing, so check them here
# rather than finding out from a broken image.
/usr/python/bin/python3 -c "import bz2, ctypes, dbm.gnu, lzma, readline, sqlite3, ssl, uuid, zlib"
cd /
rm -rf /usr/src/python
find /usr/python -depth \
\( \
\( -type d -a \( -name test -o -name tests -o -name idle_test \) \) \
-o \( -type f -a \( -name 'libpython*.a' \) \) \
\) -exec rm -rf '{}' +
link_python
}
function install_debian_runtime_dependencies() {
if [[ "${AIRFLOW_IMAGE_FLAVOR}" == "hardened" ]]; then
keep_image_conffiles
refresh_ldconfig_after_dpkg
fi
apt-get update
apt-get install --no-install-recommends -yqq apt-utils >/dev/null 2>&1
if [[ "${AIRFLOW_IMAGE_FLAVOR}" == "hardened" ]]; then
restore_debian_base_files
fi
apt-get install -y --no-install-recommends wget curl gnupg2 ca-certificates
# shellcheck disable=SC2086
export ${ADDITIONAL_RUNTIME_APT_ENV?}
if [[ "${RUNTIME_APT_COMMAND}" != "" ]]; then
bash -o pipefail -o errexit -o nounset -o nolog -c "${RUNTIME_APT_COMMAND}"
fi
if [[ "${ADDITIONAL_RUNTIME_APT_COMMAND}" != "" ]]; then
bash -o pipefail -o errexit -o nounset -o nolog -c "${ADDITIONAL_RUNTIME_APT_COMMAND}"
fi
apt-get update
# shellcheck disable=SC2086
apt-get install -y --no-install-recommends ${RUNTIME_APT_DEPS} ${ADDITIONAL_RUNTIME_APT_DEPS}
apt-get autoremove -yqq --purge
apt-get clean
check_no_system_python
link_python
if [[ "${AIRFLOW_IMAGE_FLAVOR}" == "hardened" ]]; then
hold_hardened_python
restore_thread_stack_size
compile_python_stdlib
fi
rm -rf /var/lib/apt/lists/* /var/log/*
}
function install_golang() {
curl --retry 3 --retry-delay 5 "https://dl.google.com/go/go${GOLANG_MAJOR_MINOR_VERSION}.linux-$(dpkg --print-architecture).tar.gz" -o "go${GOLANG_MAJOR_MINOR_VERSION}.linux.tar.gz"
rm -rf /usr/local/go && tar -C /usr/local -xzf go"${GOLANG_MAJOR_MINOR_VERSION}".linux.tar.gz
rm -f go"${GOLANG_MAJOR_MINOR_VERSION}".linux.tar.gz
}
function install_jdk() {
# Install Eclipse Temurin JDK from the Adoptium apt repository (https://adoptium.net/installation/linux/).
apt-get update -qq
apt-get install -y --no-install-recommends wget gnupg apt-transport-https ca-certificates
mkdir -p /etc/apt/keyrings
wget -qO - https://packages.adoptium.net/artifactory/api/gpg/key/public \
| tee /etc/apt/keyrings/adoptium.asc > /dev/null
# shellcheck disable=SC1091
DISTRO_CODENAME=$(. /etc/os-release; echo "${VERSION_CODENAME}")
echo "deb [signed-by=/etc/apt/keyrings/adoptium.asc] \
https://packages.adoptium.net/artifactory/deb ${DISTRO_CODENAME} main" \
| tee /etc/apt/sources.list.d/adoptium.list > /dev/null
apt-get update -qq
apt-get install -y --no-install-recommends "temurin-${TEMURIN_VERSION}-jdk"
apt-get clean
rm -rf /var/lib/apt/lists/*
}
function install_nodejs() {
local arch
arch="$(dpkg --print-architecture)"
declare -A nodejs_targets=(
[amd64]="linux-x64"
[arm64]="linux-arm64"
)
declare -A nodejs_sha256s=(
# https://nodejs.org/dist/v${NODEJS_VERSION}/SHASUMS256.txt
[amd64]="9749e988f437343b7fa832c69ded82a312e41a03116d766797ac14f6f9eee578"
[arm64]="0294e8b915ab75f92c7513d2fcb830ae06e10684e6c603e99a87dbf8835389c1"
)
local target="${nodejs_targets[${arch}]}"
local nodejs_sha256="${nodejs_sha256s[${arch}]}"
if [[ -z "${target}" ]]; then
echo "Unsupported architecture for nodejs: ${arch}"
exit 1
fi
curl --retry 3 --retry-delay 5 \
"https://nodejs.org/dist/v${NODEJS_VERSION}/node-v${NODEJS_VERSION}-${target}.tar.xz" \
-o /tmp/nodejs.tar.xz
echo "${nodejs_sha256} /tmp/nodejs.tar.xz" | sha256sum --check
tar -xJf /tmp/nodejs.tar.xz --strip-components=1 -C /usr/local --no-same-owner
rm -f /tmp/nodejs.tar.xz
corepack enable --install-directory /usr/local/bin
# corepack enable only writes shims; prepare downloads and caches the pnpm binary into the
# image so it is available offline and matches the version ts-sdk pins.
corepack prepare "pnpm@${PNPM_VERSION}" --activate
}
function install_rustup() {
local arch
arch="$(dpkg --print-architecture)"
declare -A rustup_targets=(
[amd64]="x86_64-unknown-linux-gnu"
[arm64]="aarch64-unknown-linux-gnu"
)
declare -A rustup_sha256s=(
# https://static.rust-lang.org/rustup/archive/${RUSTUP_VERSION}/{target}/rustup-init.sha256
[amd64]="4acc9acc76d5079515b46346a485974457b5a79893cfb01112423c89aeb5aa10"
[arm64]="9732d6c5e2a098d3521fca8145d826ae0aaa067ef2385ead08e6feac88fa5792"
)
local target="${rustup_targets[${arch}]}"
local rustup_sha256="${rustup_sha256s[${arch}]}"
if [[ -z "${target}" ]]; then
echo "Unsupported architecture for rustup: ${arch}"
exit 1
fi
curl --proto '=https' --tlsv1.2 -sSf --retry 3 --retry-delay 5 \
"https://static.rust-lang.org/rustup/archive/${RUSTUP_VERSION}/${target}/rustup-init" \
-o /tmp/rustup-init
echo "${rustup_sha256} /tmp/rustup-init" | sha256sum --check
chmod +x /tmp/rustup-init
# Building wheels from source needs only rustc and cargo. The default profile also adds
# rust-docs, clippy and rustfmt, which add tens of thousands of files to the image.
/tmp/rustup-init -y --profile minimal --default-toolchain "${RUSTUP_DEFAULT_TOOLCHAIN}"
rm -f /tmp/rustup-init
}
function apt_clean() {
apt-get purge -y --auto-remove -o APT::AutoRemove::RecommendsImportant=false
rm -rf /var/lib/apt/lists/* /var/log/*
}
if [[ "${INSTALLATION_TYPE}" == "RUNTIME" ]]; then
get_runtime_apt_deps
install_debian_runtime_dependencies
install_docker_cli
apt_clean
else
get_dev_apt_deps
install_debian_dev_dependencies
check_no_system_python
if [[ "${AIRFLOW_IMAGE_FLAVOR}" == "hardened" ]]; then
link_python
hold_hardened_python
restore_thread_stack_size
compile_python_stdlib
else
install_python
fi
install_additional_dev_dependencies
install_rustup
if [[ "${INSTALLATION_TYPE}" == "CI" ]]; then
install_golang
install_jdk
install_nodejs
fi
install_docker_cli
apt_clean
fi
EOF
# The content below is automatically copied from scripts/docker/install_mysql.sh
COPY <<"EOF" /install_mysql.sh
#!/usr/bin/env bash
. "$( dirname "${BASH_SOURCE[0]}" )/common.sh"
set -euo pipefail
common::get_colors
declare -a packages
: "${INSTALL_MYSQL_CLIENT:?Should be true or false}"
: "${INSTALL_MYSQL_CLIENT_TYPE:-mariadb}"
if [[ "${INSTALL_MYSQL_CLIENT}" != "true" && "${INSTALL_MYSQL_CLIENT}" != "false" ]]; then
echo
echo "${COLOR_RED}INSTALL_MYSQL_CLIENT must be either true or false${COLOR_RESET}"
echo
exit 1
fi
if [[ "${INSTALL_MYSQL_CLIENT_TYPE}" != "mysql" && "${INSTALL_MYSQL_CLIENT_TYPE}" != "mariadb" ]]; then
echo
echo "${COLOR_RED}INSTALL_MYSQL_CLIENT_TYPE must be either mysql or mariadb${COLOR_RESET}"
echo
exit 1
fi
if [[ "${INSTALL_MYSQL_CLIENT_TYPE}" == "mysql" ]]; then
echo
echo "${COLOR_RED}The 'mysql' client type is not supported any more. Use 'mariadb' instead.${COLOR_RESET}"
echo
echo "The MySQL drivers are wrongly packaged and released by Oracle with an expiration date on their GPG keys,"
echo "which causes builds to fail after the expiration date. MariaDB client is protocol-compatible with MySQL client."
echo ""
echo "Every two years the MySQL packages fail and Oracle team is always surprised and struggling"
echo "with fixes and re-signing the packages which lasts few days"
echo "See https://bugs.mysql.com/bug.php?id=113432 for more details."
echo "As a community we are not able to support this broken packaging practice from Oracle"
echo "Feel free however to install MySQL drivers on your own as extension of the image."
echo
exit 1
fi
retry() {
local retries=3
local count=0
# adding delay of 10 seconds
local delay=10
until "$@"; do
exit_code=$?
count=$((count + 1))
if [[ $count -lt $retries ]]; then
echo "Command failed. Attempt $count/$retries. Retrying in ${delay}s..."
sleep $delay
else
echo "Command failed after $retries attempts."
return $exit_code
fi
done
}
install_mariadb_client() {
# https://mariadb.org/about/#maintenance-policy
# MariaDB publishes each LTS series only for the Debian releases current when it came out - 10.11
# has no trixie repository and 11.8 is the first LTS that does.
local mariadb_lts_version="10.11"
if [[ "$(common::debian_codename)" != "bookworm" ]]; then
mariadb_lts_version="11.8"
fi
# List of compatible package Oracle MySQL -> MariaDB:
# `mysql-client` -> `mariadb-client` or `mariadb-client-compat` (11+)
# `libmysqlclientXX` (where XX is a number) -> `libmariadb3-compat`
# `libmysqlclient-dev` -> `libmariadb-dev-compat`
#
# Different naming against Debian repo which we used before
# that some of packages might contains `-compat` suffix, Debian repo -> MariaDB repo:
# `libmariadb-dev` -> `libmariadb-dev-compat`
# `mariadb-client-core` -> `mariadb-client` or `mariadb-client-compat` (11+)
if [[ "${1}" == "dev" ]]; then
packages=("libmariadb-dev-compat" "mariadb-client")
elif [[ "${1}" == "prod" ]]; then
packages=("libmariadb3-compat" "mariadb-client")
else
echo
echo "${COLOR_RED}Specify either prod or dev${COLOR_RESET}"
echo
exit 1
fi
if [[ "${mariadb_lts_version%%.*}" -ge 11 ]]; then
# From 11.0 mariadb-client no longer ships the "mysql" named commands
packages+=("mariadb-client-compat")
fi
common::import_trusted_gpg "0xF1656F24C74CD1D8" "mariadb"
echo
echo "${COLOR_BLUE}Installing MariaDB client version ${mariadb_lts_version}: ${1}${COLOR_RESET}"
echo "${COLOR_YELLOW}MariaDB client protocol-compatible with MySQL client.${COLOR_RESET}"
echo
echo "deb [arch=amd64,arm64] https://archive.mariadb.org/mariadb-${mariadb_lts_version}/repo/debian/ $(common::debian_codename) main" > \
/etc/apt/sources.list.d/mariadb.list
# Make sure that dependencies from MariaDB repo are preferred over Debian dependencies
printf "Package: *\nPin: release o=MariaDB\nPin-Priority: 999\n" > /etc/apt/preferences.d/mariadb
retry apt-get update
retry apt-get install --no-install-recommends -y "${packages[@]}"
apt-get autoremove -yqq --purge
apt-get clean && rm -rf /var/lib/apt/lists/*
}
if [[ ${INSTALL_MYSQL_CLIENT:="true"} == "true" ]]; then
install_mariadb_client "${@}"
fi
EOF
# The content below is automatically copied from scripts/docker/install_mssql.sh
COPY <<"EOF" /install_mssql.sh
#!/usr/bin/env bash
. "$( dirname "${BASH_SOURCE[0]}" )/common.sh"
set -euo pipefail
common::get_colors
declare -a packages
: "${INSTALL_MSSQL_CLIENT:?Should be true or false}"
function install_mssql_client() {
# Install MsSQL client from Microsoft repositories
if [[ ${INSTALL_MSSQL_CLIENT:="true"} != "true" ]]; then
echo
echo "${COLOR_BLUE}Skip installing mssql client${COLOR_RESET}"
echo
return
fi
packages=("msodbcsql18")
common::import_trusted_gpg "EB3E94ADBE1229CF" "microsoft"
# The Debian 13 repository is signed with Microsoft's newer key, the older releases with the one above.
common::import_trusted_gpg "EE4D7792F748182B" "microsoft-2025"
echo
echo "${COLOR_BLUE}Installing mssql client${COLOR_RESET}"
echo
echo "deb [arch=amd64,arm64] https://packages.microsoft.com/debian/$(common::debian_release)/prod $(common::debian_codename) main" > \
/etc/apt/sources.list.d/mssql-release.list &&
mkdir -p /opt/microsoft/msodbcsql18 &&
touch /opt/microsoft/msodbcsql18/ACCEPT_EULA &&
apt-get update -yqq &&
apt-get upgrade -yqq &&
apt-get -yqq install --no-install-recommends "${packages[@]}" &&
apt-get autoremove -yqq --purge &&
apt-get clean &&
rm -rf /var/lib/apt/lists/*
}
install_mssql_client "${@}"
EOF
# The content below is automatically copied from scripts/docker/install_postgres.sh
COPY <<"EOF" /install_postgres.sh
#!/usr/bin/env bash
. "$( dirname "${BASH_SOURCE[0]}" )/common.sh"
set -euo pipefail
common::get_colors
declare -a packages
: "${INSTALL_POSTGRES_CLIENT:?Should be true or false}"
install_postgres_client() {
echo
echo "${COLOR_BLUE}Installing postgres client${COLOR_RESET}"
echo
if [[ "${1}" == "dev" ]]; then
packages=("libpq-dev" "postgresql-client")
elif [[ "${1}" == "prod" ]]; then
packages=("postgresql-client")
else
echo
echo "Specify either prod or dev"
echo
exit 1
fi
common::import_trusted_gpg "7FCC7D46ACCC4CF8" "postgres"
echo "deb [arch=amd64,arm64] https://apt.postgresql.org/pub/repos/apt/ $(common::debian_codename)-pgdg main" > \
/etc/apt/sources.list.d/pgdg.list
apt-get update
apt-get install --no-install-recommends -y "${packages[@]}"
apt-get autoremove -yqq --purge
apt-get clean && rm -rf /var/lib/apt/lists/*
}
if [[ ${INSTALL_POSTGRES_CLIENT:="true"} == "true" ]]; then
install_postgres_client "${@}"
fi
EOF
# The content below is automatically copied from scripts/docker/install_packaging_tools.sh
COPY <<"EOF" /install_packaging_tools.sh
#!/usr/bin/env bash
. "$( dirname "${BASH_SOURCE[0]}" )/common.sh"
common::get_colors
common::get_packaging_tool
common::show_packaging_tool_version_and_location
common::install_packaging_tools
EOF
# The content below is automatically copied from scripts/docker/common.sh
COPY <<"EOF" /common.sh
#!/usr/bin/env bash
set -euo pipefail