Files
openproject/script/ci/docker_validate_image.sh
T
Cyril Rohr d482f1f708 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
2026-02-11 10:40:54 +01:00

324 lines
7.5 KiB
Bash
Executable File

#!/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}'."