diff --git a/.dockerignore b/.dockerignore index 6898dda39ab..5008cca0314 100644 --- a/.dockerignore +++ b/.dockerignore @@ -41,5 +41,7 @@ frontend/node_modules node_modules # travis vendor/bundle +# Local checkout; all-in-one copies hocuspocus from its dedicated image. +vendor/hocuspocus /public/assets /config/frontend_assets.manifest.json diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index f7f301dd62b..7c29ea86e23 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -255,23 +255,12 @@ jobs: if [ -d vendor/bundle.bak ]; then mv vendor/bundle.bak vendor/bundle fi - - name: Test - # 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' + - name: Validate image run: | - docker run \ - --name openproject \ - -d -p 8080:80 --platform ${{ matrix.platform }} \ - -e SUPERVISORD_LOG_LEVEL=debug \ - -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 + ./script/ci/docker_validate_image.sh \ + --image "${{ steps.build.outputs.imageid }}" \ + --target "${{ matrix.target }}" \ + --platform "${{ matrix.platform }}" - name: Push image id: push uses: docker/build-push-action@v6 diff --git a/docker/prod/Dockerfile b/docker/prod/Dockerfile index 082b9e0f90f..5b5c79b7e01 100755 --- a/docker/prod/Dockerfile +++ b/docker/prod/Dockerfile @@ -3,7 +3,7 @@ ARG DEBIAN_BASE="trixie" # Add SBOM scan context for intermediate steps ARG BUILDKIT_SBOM_SCAN_CONTEXT=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" ARG NODE_VERSION="22.21.0" @@ -19,6 +19,8 @@ ENV DOCKER=1 ENV APP_USER=app ENV APP_PATH=/app 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_CHOICES="13 15 17" ENV PGBIN="/usr/lib/postgresql/$PGVERSION/bin" @@ -54,10 +56,19 @@ WORKDIR $APP_PATH # upgrade bundler RUN gem install bundler --no-document -# system dependencies, nodejs +# runtime dependencies COPY ./docker/prod/setup/preinstall-common.sh ./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 COPY Gemfile Gemfile.* .ruby-version ./ COPY modules ./modules @@ -73,15 +84,32 @@ COPY . . # 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 && \ - ./docker/prod/setup/precompile-assets.sh && \ - ./docker/prod/setup/postinstall-common.sh && \ + ./docker/prod/setup/precompile-assets.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 && \ ln -s $APP_PATH/docker/prod/setup/.irbrc /home/$APP_USER/ # ------------------------------------- # slim (public) # ------------------------------------- -FROM base AS slim +FROM app-runtime-slim AS slim USER $APP_USER EXPOSE 8080 @@ -93,7 +121,7 @@ VOLUME ["$APP_DATA_PATH"] # slim-bim (public) # same as slim but with BIM support enabled # ------------------------------------- -FROM base AS slim-bim +FROM app-runtime-slim AS slim-bim USER $APP_USER EXPOSE 8080 @@ -104,7 +132,7 @@ ENV OPENPROJECT_EDITION=bim # ------------------------------------- # all-in-one (public) # ------------------------------------- -FROM base AS all-in-one +FROM app-runtime AS all-in-one ENV OPENPROJECT_RAILS__CACHE__STORE=memcache 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 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 && \ + 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/ # Expose ports for apache and postgres diff --git a/docker/prod/entrypoint.sh b/docker/prod/entrypoint.sh index d105d4494b7..0cc2e095162 100755 --- a/docker/prod/entrypoint.sh +++ b/docker/prod/entrypoint.sh @@ -97,7 +97,8 @@ if [ "$(id -u)" = '0' ]; then HP_HOST=${OPENPROJECT_HOST__NAME:="localhost"} 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 exec "$@" diff --git a/docker/prod/setup/bundle-install.sh b/docker/prod/setup/bundle-install.sh index 60140e09e77..f3b73b4d1af 100644 --- a/docker/prod/setup/bundle-install.sh +++ b/docker/prod/setup/bundle-install.sh @@ -10,3 +10,8 @@ cp Gemfile.lock Gemfile.lock.bak rm -rf vendor/bundle/ruby/*/cache rm -rf vendor/bundle/ruby/*/gems/*/spec 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 diff --git a/docker/prod/setup/preinstall-build.sh b/docker/prod/setup/preinstall-build.sh new file mode 100755 index 00000000000..a20dd17793a --- /dev/null +++ b/docker/prod/setup/preinstall-build.sh @@ -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 diff --git a/docker/prod/setup/preinstall-common.sh b/docker/prod/setup/preinstall-common.sh index 77efda56922..61113401021 100755 --- a/docker/prod/setup/preinstall-common.sh +++ b/docker/prod/setup/preinstall-common.sh @@ -2,25 +2,24 @@ 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 + 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 + echo "x64" + return 0 } -set -exo pipefail ARCHITECTURE=$(get_architecture) apt-get update -qq @@ -28,69 +27,76 @@ apt-get update -qq apt-get upgrade -y apt-get install -yq --no-install-recommends \ - curl \ - wget \ - file \ - gnupg2 \ - lsb-release + ca-certificates \ + curl \ + wget \ + file \ + gnupg2 \ + lsb-release -# install node + npm -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 - -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 +wget --quiet -O- https://www.postgresql.org/media/keys/ACCC4CF8.asc | gpg --dearmor -o /usr/share/keyrings/postgresql.gpg - +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 apt-get update -qq apt-get install -yq --no-install-recommends \ - libpq-dev \ - libpq5 \ - libffi8 \ - unrtf \ - tesseract-ocr \ - poppler-utils \ - catdoc \ - imagemagick \ - libclang-dev \ - libjemalloc2 \ - git \ - build-essential \ - libyaml-dev \ + libpq5 \ + libffi8 \ + unrtf \ + tesseract-ocr \ + poppler-utils \ + catdoc \ + imagemagick \ + libjemalloc2 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 done # Specifics for BIM edition if [ ! "$BIM_SUPPORT" = "false" ]; then - apt-get install -y wget unzip + apt-get install -yq --no-install-recommends unzip - tmpdir=$(mktemp -d) - cd $tmpdir + # 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 - # Install XKT converter - npm install -g @xeokit/xeokit-gltf-to-xkt@1.3.1 + tmpdir=$(mktemp -d) + cd $tmpdir - # Install COLLADA2GLTF - wget --no-verbose --tries 3 https://github.com/KhronosGroup/COLLADA2GLTF/releases/download/v2.1.5/COLLADA2GLTF-v2.1.5-linux.zip - unzip -q COLLADA2GLTF-v2.1.5-linux.zip - mv COLLADA2GLTF-bin "/usr/local/bin/COLLADA2GLTF" + # Install XKT converter + npm install -g @xeokit/xeokit-gltf-to-xkt@1.3.1 - # IFCconvert - wget --no-verbose --tries 3 https://s3.amazonaws.com/ifcopenshell-builds/IfcConvert-v0.7.11-fea8e3a-linux64.zip - unzip -q IfcConvert-v0.7.11-fea8e3a-linux64.zip - mv IfcConvert "/usr/local/bin/IfcConvert" + # Install COLLADA2GLTF + wget --no-verbose --tries 3 https://github.com/KhronosGroup/COLLADA2GLTF/releases/download/v2.1.5/COLLADA2GLTF-v2.1.5-linux.zip + unzip -q COLLADA2GLTF-v2.1.5-linux.zip + mv COLLADA2GLTF-bin "/usr/local/bin/COLLADA2GLTF" - wget --no-verbose --tries 3 https://github.com/opf/xeokit-metadata/releases/download/v1.1.0/xeokit-metadata-linux-x64.tar.gz - tar -zxvf xeokit-metadata-linux-x64.tar.gz - chmod +x xeokit-metadata-linux-x64/xeokit-metadata - cp -r xeokit-metadata-linux-x64/ "/usr/lib/xeokit-metadata" - ln -s /usr/lib/xeokit-metadata/xeokit-metadata /usr/local/bin/xeokit-metadata + # IFCconvert + wget --no-verbose --tries 3 https://s3.amazonaws.com/ifcopenshell-builds/IfcConvert-v0.7.11-fea8e3a-linux64.zip + unzip -q IfcConvert-v0.7.11-fea8e3a-linux64.zip + mv IfcConvert "/usr/local/bin/IfcConvert" - cd / - rm -rf $tmpdir + wget --no-verbose --tries 3 https://github.com/opf/xeokit-metadata/releases/download/v1.1.0/xeokit-metadata-linux-x64.tar.gz + tar -zxvf xeokit-metadata-linux-x64.tar.gz + chmod +x xeokit-metadata-linux-x64/xeokit-metadata + cp -r xeokit-metadata-linux-x64/ "/usr/lib/xeokit-metadata" + ln -s /usr/lib/xeokit-metadata/xeokit-metadata /usr/local/bin/xeokit-metadata + + cd / + rm -rf $tmpdir fi 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/* truncate -s 0 /var/log/*log diff --git a/docker/prod/setup/prune-slim-runtime.sh b/docker/prod/setup/prune-slim-runtime.sh new file mode 100755 index 00000000000..c02fb01140e --- /dev/null +++ b/docker/prod/setup/prune-slim-runtime.sh @@ -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 diff --git a/docker/prod/supervisord b/docker/prod/supervisord index 086b2f44876..cce9f4b4e2f 100755 --- a/docker/prod/supervisord +++ b/docker/prod/supervisord @@ -57,16 +57,29 @@ install_plugins() { 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() { wait_for_postgres pushd $APP_PATH >/dev/null - /etc/init.d/memcached start + start_memcached_daemon echo "-----> Running migrations..." bundle exec rake db:migrate # run seed as app user so created attachments (and folder) belong to app, not root echo "-----> Seeding database..." su app -c 'bundle exec rake db:seed' - /etc/init.d/memcached stop + stop_memcached_daemon popd >/dev/null } @@ -141,6 +154,9 @@ fi echo "-----> Database setup finished." 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..." 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} diff --git a/script/ci/docker_validate_image.sh b/script/ci/docker_validate_image.sh new file mode 100755 index 00000000000..47302be2095 --- /dev/null +++ b/script/ci/docker_validate_image.sh @@ -0,0 +1,323 @@ +#!/usr/bin/env bash + +set -euo pipefail +set -x + +usage() { + cat <<'USAGE' +Usage: script/ci/docker_validate_image.sh --image --target [--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}'."