Fix docker bloat (#21948)

* Refactor Docker build/runtime stages for slimmer images

Split runtime and build dependencies into separate stages and build the app in a dedicated stage before runtime copy.

Add a slim prune stage that removes non-runtime source trees, source maps, duplicate enterprise source videos, module test/doc folders, and extra vendored gem artifacts.

This ensures bytes are removed before the final slim copy, so layer size actually decreases while keeping runtime behavior intact.

* Add target-specific Docker image validation in CI

Introduce script/ci/docker_validate_image.sh with validations for slim, slim-bim, and all-in-one images.

Checks include runtime binary presence/absence, plugin asset/module integrity, slim pruning expectations, BIM tooling, and all-in-one API startup/embedded services.

Update docker workflow to run the validator for every matrix target before push.

* fix

* Generate YAML-safe auto Hocuspocus secret

All-in-one startup auto-generates OPENPROJECT_COLLABORATIVE__EDITING__HOCUSPOCUS__SECRET in the entrypoint.

Environment overrides are parsed through YAML, so leading punctuation in the previous charset (e.g. %) could trigger Psych parsing errors and abort boot.

Restrict generated secret characters to alphanumeric to keep parsing stable while preserving high entropy.

* Fix all-in-one hocuspocus runtime and validation

* Fix all-in-one memcached startup handover
This commit is contained in:
Cyril Rohr
2026-02-11 10:40:54 +01:00
committed by GitHub
parent 93044028f1
commit d482f1f708
10 changed files with 540 additions and 86 deletions
+2
View File
@@ -41,5 +41,7 @@ frontend/node_modules
node_modules node_modules
# travis # travis
vendor/bundle vendor/bundle
# Local checkout; all-in-one copies hocuspocus from its dedicated image.
vendor/hocuspocus
/public/assets /public/assets
/config/frontend_assets.manifest.json /config/frontend_assets.manifest.json
+5 -16
View File
@@ -255,23 +255,12 @@ jobs:
if [ -d vendor/bundle.bak ]; then if [ -d vendor/bundle.bak ]; then
mv vendor/bundle.bak vendor/bundle mv vendor/bundle.bak vendor/bundle
fi fi
- name: Test - name: Validate image
# We only test the native container. If that fails the builds for the others
# will be cancelled as well.
if: matrix.platform == 'linux/amd64' && matrix.target == 'all-in-one'
run: | run: |
docker run \ ./script/ci/docker_validate_image.sh \
--name openproject \ --image "${{ steps.build.outputs.imageid }}" \
-d -p 8080:80 --platform ${{ matrix.platform }} \ --target "${{ matrix.target }}" \
-e SUPERVISORD_LOG_LEVEL=debug \ --platform "${{ matrix.platform }}"
-e OPENPROJECT_LOGIN__REQUIRED=false \
-e OPENPROJECT_HTTPS=false \
${{ steps.build.outputs.imageid }}
sleep 60
docker logs openproject --tail 300
wget -O- --retry-on-http-error=503,502 --retry-connrefused http://localhost:8080/api/v3
- name: Push image - name: Push image
id: push id: push
uses: docker/build-push-action@v6 uses: docker/build-push-action@v6
+41 -7
View File
@@ -3,7 +3,7 @@ ARG DEBIAN_BASE="trixie"
# Add SBOM scan context for intermediate steps # Add SBOM scan context for intermediate steps
ARG BUILDKIT_SBOM_SCAN_CONTEXT=true ARG BUILDKIT_SBOM_SCAN_CONTEXT=true
ARG BUILDKIT_SBOM_SCAN_STAGE=true ARG BUILDKIT_SBOM_SCAN_STAGE=true
FROM ruby:${RUBY_VERSION}-slim-${DEBIAN_BASE} AS base FROM ruby:${RUBY_VERSION}-slim-${DEBIAN_BASE} AS runtime-base
LABEL maintainer="operations@openproject.com" LABEL maintainer="operations@openproject.com"
ARG NODE_VERSION="22.21.0" ARG NODE_VERSION="22.21.0"
@@ -19,6 +19,8 @@ ENV DOCKER=1
ENV APP_USER=app ENV APP_USER=app
ENV APP_PATH=/app ENV APP_PATH=/app
ENV APP_DATA_PATH=/var/openproject/assets ENV APP_DATA_PATH=/var/openproject/assets
ENV BUNDLE_PATH="$APP_PATH/vendor/bundle"
ENV BUNDLE_APP_CONFIG="$APP_PATH/.bundle"
ENV PGVERSION="17" ENV PGVERSION="17"
ENV PGVERSION_CHOICES="13 15 17" ENV PGVERSION_CHOICES="13 15 17"
ENV PGBIN="/usr/lib/postgresql/$PGVERSION/bin" ENV PGBIN="/usr/lib/postgresql/$PGVERSION/bin"
@@ -54,10 +56,19 @@ WORKDIR $APP_PATH
# upgrade bundler # upgrade bundler
RUN gem install bundler --no-document RUN gem install bundler --no-document
# system dependencies, nodejs # runtime dependencies
COPY ./docker/prod/setup/preinstall-common.sh ./docker/prod/setup/preinstall-common.sh COPY ./docker/prod/setup/preinstall-common.sh ./docker/prod/setup/preinstall-common.sh
RUN ./docker/prod/setup/preinstall-common.sh RUN ./docker/prod/setup/preinstall-common.sh
FROM runtime-base AS build-base
ARG NODE_VERSION="22.21.0"
# build-only dependencies
COPY ./docker/prod/setup/preinstall-build.sh ./docker/prod/setup/preinstall-build.sh
RUN ./docker/prod/setup/preinstall-build.sh
FROM build-base AS app-build
# stuff required for gems # stuff required for gems
COPY Gemfile Gemfile.* .ruby-version ./ COPY Gemfile Gemfile.* .ruby-version ./
COPY modules ./modules COPY modules ./modules
@@ -73,15 +84,32 @@ COPY . .
# Copy lock file again as the updated version was overriden by COPY just now # Copy lock file again as the updated version was overriden by COPY just now
RUN cp Gemfile.lock.bak Gemfile.lock && rm Gemfile.lock.bak && \ RUN cp Gemfile.lock.bak Gemfile.lock && rm Gemfile.lock.bak && \
./docker/prod/setup/precompile-assets.sh && \ ./docker/prod/setup/precompile-assets.sh
./docker/prod/setup/postinstall-common.sh && \
FROM app-build AS app-build-slim
COPY ./docker/prod/setup/prune-slim-runtime.sh ./docker/prod/setup/prune-slim-runtime.sh
RUN ./docker/prod/setup/prune-slim-runtime.sh
FROM runtime-base AS app-runtime
COPY --chown=$APP_USER:$APP_USER --from=app-build /app /app
RUN ./docker/prod/setup/postinstall-common.sh && \
cp ./config/database.production.yml config/database.yml && \
ln -s $APP_PATH/docker/prod/setup/.irbrc /home/$APP_USER/
FROM runtime-base AS app-runtime-slim
COPY --chown=$APP_USER:$APP_USER --from=app-build-slim /app /app
RUN ./docker/prod/setup/postinstall-common.sh && \
cp ./config/database.production.yml config/database.yml && \ cp ./config/database.production.yml config/database.yml && \
ln -s $APP_PATH/docker/prod/setup/.irbrc /home/$APP_USER/ ln -s $APP_PATH/docker/prod/setup/.irbrc /home/$APP_USER/
# ------------------------------------- # -------------------------------------
# slim (public) # slim (public)
# ------------------------------------- # -------------------------------------
FROM base AS slim FROM app-runtime-slim AS slim
USER $APP_USER USER $APP_USER
EXPOSE 8080 EXPOSE 8080
@@ -93,7 +121,7 @@ VOLUME ["$APP_DATA_PATH"]
# slim-bim (public) # slim-bim (public)
# same as slim but with BIM support enabled # same as slim but with BIM support enabled
# ------------------------------------- # -------------------------------------
FROM base AS slim-bim FROM app-runtime-slim AS slim-bim
USER $APP_USER USER $APP_USER
EXPOSE 8080 EXPOSE 8080
@@ -104,7 +132,7 @@ ENV OPENPROJECT_EDITION=bim
# ------------------------------------- # -------------------------------------
# all-in-one (public) # all-in-one (public)
# ------------------------------------- # -------------------------------------
FROM base AS all-in-one FROM app-runtime AS all-in-one
ENV OPENPROJECT_RAILS__CACHE__STORE=memcache ENV OPENPROJECT_RAILS__CACHE__STORE=memcache
ENV DATABASE_URL=postgres://openproject:openproject@127.0.0.1/openproject ENV DATABASE_URL=postgres://openproject:openproject@127.0.0.1/openproject
@@ -114,8 +142,14 @@ COPY --from=openproject/gosu /go/bin/gosu /usr/local/bin/gosu
RUN chmod +x /usr/local/bin/gosu && gosu nobody true RUN chmod +x /usr/local/bin/gosu && gosu nobody true
COPY --from=openproject/hocuspocus:17.0.3 --chown=$APP_USER:$APP_USER /app /opt/hocuspocus COPY --from=openproject/hocuspocus:17.0.3 --chown=$APP_USER:$APP_USER /app /opt/hocuspocus
# Keep node/npm in all-in-one for bundled hocuspocus even when BIM support is disabled.
COPY --from=build-base /usr/local/bin/node /usr/local/bin/node
COPY --from=build-base /usr/local/lib/node_modules /usr/local/lib/node_modules
RUN ./docker/prod/setup/postinstall-onprem.sh && \ RUN ./docker/prod/setup/postinstall-onprem.sh && \
ln -sf ../lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm && \
ln -sf ../lib/node_modules/npm/bin/npx-cli.js /usr/local/bin/npx && \
ln -sf ../lib/node_modules/corepack/dist/corepack.js /usr/local/bin/corepack && \
ln -s /app/docker/prod/setup/.irbrc /root/ ln -s /app/docker/prod/setup/.irbrc /root/
# Expose ports for apache and postgres # Expose ports for apache and postgres
+2 -1
View File
@@ -97,7 +97,8 @@ if [ "$(id -u)" = '0' ]; then
HP_HOST=${OPENPROJECT_HOST__NAME:="localhost"} HP_HOST=${OPENPROJECT_HOST__NAME:="localhost"}
export OPENPROJECT_COLLABORATIVE__EDITING__HOCUSPOCUS__URL="${HP_PROTOCOL}://${HP_HOST}/hocuspocus" export OPENPROJECT_COLLABORATIVE__EDITING__HOCUSPOCUS__URL="${HP_PROTOCOL}://${HP_HOST}/hocuspocus"
export OPENPROJECT_COLLABORATIVE__EDITING__HOCUSPOCUS__SECRET="$(tr -dc 'A-Za-z0-9!?%=' < /dev/urandom | head -c 32)" # Use a YAML-safe secret charset because environment values are parsed via YAML.
export OPENPROJECT_COLLABORATIVE__EDITING__HOCUSPOCUS__SECRET="$(tr -dc 'A-Za-z0-9' < /dev/urandom | head -c 32)"
fi fi
exec "$@" exec "$@"
+5
View File
@@ -10,3 +10,8 @@ cp Gemfile.lock Gemfile.lock.bak
rm -rf vendor/bundle/ruby/*/cache rm -rf vendor/bundle/ruby/*/cache
rm -rf vendor/bundle/ruby/*/gems/*/spec rm -rf vendor/bundle/ruby/*/gems/*/spec
rm -rf vendor/bundle/ruby/*/gems/*/test rm -rf vendor/bundle/ruby/*/gems/*/test
rm -rf vendor/bundle/ruby/*/gems/*/tests
rm -rf vendor/bundle/ruby/*/gems/*/{doc,docs,example,examples,benchmark,benchmarks}
rm -rf vendor/bundle/ruby/*/bundler/gems/*/.git
rm -rf vendor/bundle/ruby/*/bundler/gems/*/{spec,test,tests,doc,docs,example,examples,benchmark,benchmarks}
find vendor/bundle -type f \( -name '*.a' -o -name '*.o' \) -delete
+40
View File
@@ -0,0 +1,40 @@
#!/bin/bash
set -euxo pipefail
get_architecture() {
if command -v uname > /dev/null; then
ARCHITECTURE=$(uname -m)
case $ARCHITECTURE in
aarch64|arm64)
echo "arm64"
return 0
;;
ppc64le)
echo "ppc64le"
return 0
;;
esac
fi
echo "x64"
return 0
}
ARCHITECTURE=$(get_architecture)
apt-get update -qq
apt-get install -yq --no-install-recommends \
ca-certificates \
curl \
git \
build-essential \
libyaml-dev \
libpq-dev \
libclang-dev
if ! command -v node > /dev/null || ! command -v npm > /dev/null; then
curl -s https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-${ARCHITECTURE}.tar.gz | tar xzf - -C /usr/local --strip-components=1
fi
rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
truncate -s 0 /var/log/*log
+19 -13
View File
@@ -20,7 +20,6 @@ get_architecture() {
return 0 return 0
} }
set -exo pipefail
ARCHITECTURE=$(get_architecture) ARCHITECTURE=$(get_architecture)
apt-get update -qq apt-get update -qq
@@ -28,21 +27,18 @@ apt-get update -qq
apt-get upgrade -y apt-get upgrade -y
apt-get install -yq --no-install-recommends \ apt-get install -yq --no-install-recommends \
ca-certificates \
curl \ curl \
wget \ wget \
file \ file \
gnupg2 \ gnupg2 \
lsb-release lsb-release
# install node + npm wget --quiet -O- https://www.postgresql.org/media/keys/ACCC4CF8.asc | gpg --dearmor -o /usr/share/keyrings/postgresql.gpg -
curl -s https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-${ARCHITECTURE}.tar.gz | tar xzf - -C /usr/local --strip-components=1 echo "deb [signed-by=/usr/share/keyrings/postgresql.gpg] http://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" > /etc/apt/sources.list.d/pgdg.list
wget --quiet -O- https://www.postgresql.org/media/keys/ACCC4CF8.asc | gpg --dearmor -o /usr/share/keyrings/postgrsql.gpg -
echo "deb [signed-by=/usr/share/keyrings/postgrsql.gpg] http://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" > /etc/apt/sources.list.d/pgdg.list
apt-get update -qq apt-get update -qq
apt-get install -yq --no-install-recommends \ apt-get install -yq --no-install-recommends \
libpq-dev \
libpq5 \ libpq5 \
libffi8 \ libffi8 \
unrtf \ unrtf \
@@ -50,11 +46,7 @@ apt-get install -yq --no-install-recommends \
poppler-utils \ poppler-utils \
catdoc \ catdoc \
imagemagick \ imagemagick \
libclang-dev \ libjemalloc2
libjemalloc2 \
git \
build-essential \
libyaml-dev \
for version in $PGVERSION_CHOICES ; do for version in $PGVERSION_CHOICES ; do
apt-get install -yq --no-install-recommends postgresql-client-$version apt-get install -yq --no-install-recommends postgresql-client-$version
@@ -62,7 +54,10 @@ done
# Specifics for BIM edition # Specifics for BIM edition
if [ ! "$BIM_SUPPORT" = "false" ]; then if [ ! "$BIM_SUPPORT" = "false" ]; then
apt-get install -y wget unzip apt-get install -yq --no-install-recommends unzip
# Install node + npm for BIM runtime tools.
curl -s https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-${ARCHITECTURE}.tar.gz | tar xzf - -C /usr/local --strip-components=1
tmpdir=$(mktemp -d) tmpdir=$(mktemp -d)
cd $tmpdir cd $tmpdir
@@ -92,5 +87,16 @@ fi
id $APP_USER || useradd -d /home/$APP_USER -m $APP_USER id $APP_USER || useradd -d /home/$APP_USER -m $APP_USER
# Purge helper packages used only while building this stage.
apt-get purge -yq --auto-remove \
file \
gnupg2 \
lsb-release
# curl/wget are only needed during installation in this stage.
apt-get purge -yq --auto-remove \
curl \
wget
rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
truncate -s 0 /var/log/*log truncate -s 0 /var/log/*log
+38
View File
@@ -0,0 +1,38 @@
#!/bin/bash
set -euxo pipefail
APP_PATH=${APP_PATH:-/app}
# Remove source-only trees that are not needed for slim runtime images.
rm -rf \
"$APP_PATH/spec" \
"$APP_PATH/screenshots" \
"$APP_PATH/lookbook" \
"$APP_PATH/frontend"
# Keep precompiled enterprise media in public/assets and remove duplicate source videos.
if [ -d "$APP_PATH/public/assets/enterprise" ]; then
rm -rf "$APP_PATH/app/assets/videos/enterprise"
fi
# Source maps are useful during development, but unnecessary in slim runtime images.
find "$APP_PATH/public/assets" -type f -name '*.map' -delete
# Lookbook source is removed above, so its compiled static assets are unnecessary too.
rm -rf "$APP_PATH/public/assets/lookbook"
# Module test and documentation folders are not used at runtime.
find "$APP_PATH/modules" -mindepth 2 -maxdepth 2 -type d \
\( -name spec -o -name test -o -name tests -o -name doc -o -name docs \) \
-prune -exec rm -rf '{}' +
# Remove leftover git metadata and common non-runtime folders from vendored git gems.
for gem_root in "$APP_PATH/vendor/bundle"/ruby/*/gems "$APP_PATH/vendor/bundle"/ruby/*/bundler/gems; do
[ -d "$gem_root" ] || continue
rm -rf "$gem_root"/*/.git
rm -rf "$gem_root"/*/{doc,docs,example,examples,benchmark,benchmarks}
done
# Remove static/object files left by native builds.
find "$APP_PATH/vendor/bundle" -type f \( -name '*.a' -o -name '*.o' \) -delete
+18 -2
View File
@@ -57,16 +57,29 @@ install_plugins() {
popd >/dev/null popd >/dev/null
} }
stop_memcached_daemon() {
/etc/init.d/memcached stop >/dev/null 2>&1 || true
if command -v pkill >/dev/null 2>&1; then
pkill -x memcached >/dev/null 2>&1 || true
fi
rm -f /var/run/memcached/memcached.pid
}
start_memcached_daemon() {
stop_memcached_daemon
/etc/init.d/memcached start
}
migrate() { migrate() {
wait_for_postgres wait_for_postgres
pushd $APP_PATH >/dev/null pushd $APP_PATH >/dev/null
/etc/init.d/memcached start start_memcached_daemon
echo "-----> Running migrations..." echo "-----> Running migrations..."
bundle exec rake db:migrate bundle exec rake db:migrate
# run seed as app user so created attachments (and folder) belong to app, not root # run seed as app user so created attachments (and folder) belong to app, not root
echo "-----> Seeding database..." echo "-----> Seeding database..."
su app -c 'bundle exec rake db:seed' su app -c 'bundle exec rake db:seed'
/etc/init.d/memcached stop stop_memcached_daemon
popd >/dev/null popd >/dev/null
} }
@@ -141,6 +154,9 @@ fi
echo "-----> Database setup finished." echo "-----> Database setup finished."
echo " On first installation, the default admin credentials are login: admin, password: admin" echo " On first installation, the default admin credentials are login: admin, password: admin"
# Ensure supervisord can manage memcached itself without a stale daemon keeping port 11211 busy.
stop_memcached_daemon
echo "-----> Launching supervisord..." echo "-----> Launching supervisord..."
erb -r uri $APP_PATH/docker/prod/supervisord.conf.erb > /etc/supervisor/supervisord.conf erb -r uri $APP_PATH/docker/prod/supervisord.conf.erb > /etc/supervisor/supervisord.conf
exec /usr/bin/supervisord -c /etc/supervisor/supervisord.conf -e ${SUPERVISORD_LOG_LEVEL} exec /usr/bin/supervisord -c /etc/supervisor/supervisord.conf -e ${SUPERVISORD_LOG_LEVEL}
+323
View File
@@ -0,0 +1,323 @@
#!/usr/bin/env bash
set -euo pipefail
set -x
usage() {
cat <<'USAGE'
Usage: script/ci/docker_validate_image.sh --image <image-ref> --target <slim|slim-bim|all-in-one> [--platform <docker-platform>]
Validates target-specific runtime behavior of a built docker image.
USAGE
}
log() {
printf '[docker-validate] %s\n' "$*"
}
die() {
printf '[docker-validate] ERROR: %s\n' "$*" >&2
exit 1
}
IMAGE=""
TARGET=""
PLATFORM=""
VALIDATION_PORT="${VALIDATION_PORT:-18080}"
VALIDATION_TIMEOUT_SECONDS="${VALIDATION_TIMEOUT_SECONDS:-300}"
VALIDATION_CONTAINER_NAME=""
cleanup() {
if [[ -n "${VALIDATION_CONTAINER_NAME}" ]]; then
docker rm -f "${VALIDATION_CONTAINER_NAME}" >/dev/null 2>&1 || true
fi
}
trap cleanup EXIT
while [[ $# -gt 0 ]]; do
case "$1" in
--image)
IMAGE="${2:-}"
shift 2
;;
--target)
TARGET="${2:-}"
shift 2
;;
--platform)
PLATFORM="${2:-}"
shift 2
;;
-h|--help)
usage
exit 0
;;
*)
usage
die "Unknown argument: $1"
;;
esac
done
[[ -n "${IMAGE}" ]] || { usage; die "--image is required"; }
[[ -n "${TARGET}" ]] || { usage; die "--target is required"; }
command -v docker >/dev/null 2>&1 || die "docker is required"
command -v curl >/dev/null 2>&1 || die "curl is required"
run_in_image_shell() {
local shell_script="$1"
docker run --rm --entrypoint sh "${IMAGE}" -lc "${shell_script}"
}
validate_plugin_and_runtime_basics() {
run_in_image_shell "$(cat <<'SH'
set -eu
check_present() {
if ! command -v -- "$1" >/dev/null 2>&1; then
echo "Expected command '$1' to be present"
exit 1
fi
}
check_file() {
[ -f "$1" ] || {
echo "Expected file '$1' to exist"
exit 1
}
}
check_present bin/rails
bin/rails --version >/dev/null
[ "${BUNDLE_APP_CONFIG:-}" = "/app/.bundle" ] || {
echo "Expected BUNDLE_APP_CONFIG=/app/.bundle, got '${BUNDLE_APP_CONFIG:-}'"
exit 1
}
check_file /app/.bundle/config
grep -q 'BUNDLE_PATH: "vendor/bundle"' /app/.bundle/config || {
echo "Missing BUNDLE_PATH in /app/.bundle/config"
exit 1
}
grep -q 'BUNDLE_DEPLOYMENT: "true"' /app/.bundle/config || {
echo "Missing BUNDLE_DEPLOYMENT in /app/.bundle/config"
exit 1
}
check_file /app/config/frontend_assets.manifest.json
ls /app/public/assets/frontend/*.js >/dev/null 2>&1 || {
echo "Expected compiled frontend javascript assets to exist"
exit 1
}
for plugin in budgets costs openproject-avatars openproject-documents \
openproject-github_integration openproject-gitlab_integration openproject-meeting; do
grep -q -- "$plugin" /app/public/assets/frontend/*.js || {
echo "Expected plugin '${plugin}' to be present in compiled frontend assets"
exit 1
}
done
for plugin_dir in budgets costs avatars documents github_integration gitlab_integration meeting; do
[ -d "/app/modules/${plugin_dir}/frontend/module" ] || {
echo "Expected plugin frontend module directory '/app/modules/${plugin_dir}/frontend/module'"
exit 1
}
done
check_present convert
check_present tesseract
SH
)"
}
validate_slim_pruning() {
run_in_image_shell "$(cat <<'SH'
set -eu
check_absent_dir() {
[ ! -d "$1" ] || {
echo "Expected directory '$1' to be removed from slim image"
exit 1
}
}
check_present_dir() {
[ -d "$1" ] || {
echo "Expected directory '$1' to exist"
exit 1
}
}
check_absent_dir /app/frontend
check_absent_dir /app/spec
check_absent_dir /app/screenshots
check_absent_dir /app/lookbook
check_absent_dir /app/public/assets/lookbook
check_absent_dir /app/app/assets/videos/enterprise
check_present_dir /app/public/assets/enterprise
if find /app/public/assets -type f -name '*.map' | grep -q .; then
echo "Expected source maps to be removed from slim runtime assets"
exit 1
fi
if find /app/modules -mindepth 2 -maxdepth 2 -type d \
\( -name spec -o -name test -o -name tests -o -name doc -o -name docs \) | grep -q .; then
echo "Expected module test and doc folders to be removed from slim image"
exit 1
fi
SH
)"
}
validate_slim() {
validate_plugin_and_runtime_basics
validate_slim_pruning
run_in_image_shell "$(cat <<'SH'
set -eu
check_missing() {
if command -v -- "$1" >/dev/null 2>&1; then
echo "Expected command '$1' to be absent"
exit 1
fi
}
for tool in node npm gcc g++ make git svn hg; do
check_missing "$tool"
done
SH
)"
}
validate_slim_bim() {
validate_plugin_and_runtime_basics
validate_slim_pruning
run_in_image_shell "$(cat <<'SH'
set -eu
check_present() {
if ! command -v -- "$1" >/dev/null 2>&1; then
echo "Expected command '$1' to be present"
exit 1
fi
}
check_missing() {
if command -v -- "$1" >/dev/null 2>&1; then
echo "Expected command '$1' to be absent"
exit 1
fi
}
[ "${OPENPROJECT_EDITION:-}" = "bim" ] || {
echo "Expected OPENPROJECT_EDITION=bim, got '${OPENPROJECT_EDITION:-}'"
exit 1
}
for tool in node npm IfcConvert COLLADA2GLTF xeokit-metadata; do
check_present "$tool"
done
for tool in gcc g++ make git svn hg; do
check_missing "$tool"
done
SH
)"
}
validate_all_in_one() {
VALIDATION_CONTAINER_NAME="openproject-validate-${RANDOM}-${RANDOM}"
local deadline=$((SECONDS + VALIDATION_TIMEOUT_SECONDS))
local api_url="http://127.0.0.1:${VALIDATION_PORT}/api/v3"
local docker_run_args=(
--name "${VALIDATION_CONTAINER_NAME}"
-d
-p "${VALIDATION_PORT}:80"
-e SUPERVISORD_LOG_LEVEL=debug
-e OPENPROJECT_LOGIN__REQUIRED=false
-e OPENPROJECT_HTTPS=false
)
if [[ -n "${PLATFORM}" ]]; then
docker_run_args+=(--platform "${PLATFORM}")
fi
docker run "${docker_run_args[@]}" "${IMAGE}"
while true; do
if curl --silent --fail "${api_url}"; then
break
fi
if (( SECONDS >= deadline )); then
docker logs "${VALIDATION_CONTAINER_NAME}" --tail 400 || true
die "Timed out waiting for ${api_url}"
fi
sleep 2
done
docker exec "${VALIDATION_CONTAINER_NAME}" sh -lc '
set -eu
command -v -- gosu >/dev/null 2>&1
gosu nobody true
[ -d /opt/hocuspocus ]
[ -x /usr/lib/postgresql/17/bin/psql ]
command -v -- node >/dev/null 2>&1
command -v -- npm >/dev/null 2>&1
secret="$(tr "\0" "\n" < /proc/1/environ | sed -n "s/^OPENPROJECT_COLLABORATIVE__EDITING__HOCUSPOCUS__SECRET=//p" | head -n 1)"
[ -n "$secret" ]
case "$secret" in
(*[!A-Za-z0-9]*)
echo "Expected auto-generated hocuspocus secret to use YAML-safe alphanumeric characters only."
exit 1
;;
esac
ps -ef | grep -F "/opt/hocuspocus" | grep -v grep >/dev/null 2>&1 || {
echo "Expected bundled hocuspocus process to be running."
exit 1
}
ps -ef | grep -F "/usr/bin/memcached" | grep -v grep >/dev/null 2>&1 || {
echo "Expected memcached process to be running."
exit 1
}
'
if docker logs "${VALIDATION_CONTAINER_NAME}" 2>&1 | grep -q "gave up: hocuspocus entered FATAL state"; then
docker logs "${VALIDATION_CONTAINER_NAME}" --tail 200 || true
die "Bundled hocuspocus failed to start in all-in-one image."
fi
if docker logs "${VALIDATION_CONTAINER_NAME}" 2>&1 | grep -q "gave up: memcached entered FATAL state"; then
docker logs "${VALIDATION_CONTAINER_NAME}" --tail 200 || true
die "Bundled memcached failed to start in all-in-one image."
fi
}
case "${TARGET}" in
slim)
log "Validating slim image (${IMAGE})"
validate_slim
;;
slim-bim)
log "Validating slim-bim image (${IMAGE})"
validate_slim_bim
;;
all-in-one)
log "Validating all-in-one image (${IMAGE})"
validate_all_in_one
;;
*)
die "Unsupported target '${TARGET}'. Expected slim, slim-bim, or all-in-one."
;;
esac
log "Validation completed successfully for target '${TARGET}'."