diff --git a/.github/workflows/image-smoke-test.yml b/.github/workflows/image-smoke-test.yml new file mode 100644 index 000000000..c56992a33 --- /dev/null +++ b/.github/workflows/image-smoke-test.yml @@ -0,0 +1,258 @@ +name: Image Smoke Tests + +on: + pull_request: + paths: + - 'docker/prod/**' + - '.github/workflows/image-smoke-test.yml' + workflow_dispatch: + +permissions: + contents: read + +jobs: + smoke: + name: Smoke (${{ matrix.mode }}) + runs-on: ubuntu-24.04 + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + mode: + - default + - puid-pgid + - openshift + - drop-never + - diagnostic + - puid-mismatch-warning + + steps: + - name: Check out code + uses: actions/checkout@v4 + + - name: Copy .env template + run: | + cp .env.production .env + rm .env.production .env.ci .env.example + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.3' + extensions: mbstring, dom, fileinfo, pgsql + + - name: Composer install + run: composer install --no-dev --no-ansi --no-interaction --prefer-dist --ignore-platform-reqs --classmap-authoritative + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: '20.x' + + - name: NPM ci + run: npm ci + + - name: NPM build + run: npm run build + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build smoke image + uses: docker/build-push-action@v6 + with: + context: . + file: docker/prod/Dockerfile + build-args: | + DOCKER_FILES_BASE_PATH=docker/prod/ + load: true + tags: solidtime-smoke:test + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: "Smoke: default (image config + fresh deploy with empty bind mounts)" + if: matrix.mode == 'default' + run: | + echo "[smoke] image's default USER is root (entrypoint needs root to drop privs)" + user=$(docker inspect --format '{{.Config.User}}' solidtime-smoke:test) + if [ "$user" != "root" ]; then + echo "Expected 'root', got '$user'. The Dockerfile must end with USER root so the entrypoint can chown/usermod and drop privileges." + exit 1 + fi + + echo "[smoke] storage tree is group-0 owned (OpenShift / arbitrary-UID compat)" + group=$(docker run --rm --entrypoint stat solidtime-smoke:test -c '%g' /var/www/html/storage) + if [ "$group" != "0" ]; then + echo "Expected group 0, got '$group'. The Dockerfile must chgrp -R 0 storage bootstrap/cache so arbitrary-UID containers can write." + exit 1 + fi + + mkdir -p test-storage test-cache + docker run --rm \ + -v "$(pwd)/test-storage:/var/www/html/storage" \ + -v "$(pwd)/test-cache:/var/www/html/bootstrap/cache" \ + solidtime-smoke:test \ + sh -c ' + set -e + echo "[smoke] framework subdirs exist" + test -d /var/www/html/storage/framework/cache/data + test -d /var/www/html/storage/framework/sessions + test -d /var/www/html/storage/framework/views + test -d /var/www/html/storage/framework/testing + test -d /var/www/html/storage/logs + test -d /var/www/html/storage/app/public + test -d /var/www/html/storage/app/private + test -d /var/www/html/bootstrap/cache + echo "[smoke] storage is writable" + touch /var/www/html/storage/framework/cache/data/test-file + echo "[smoke] running as octane (UID 1000)" + [ "$(id -u)" = "1000" ] + echo "[smoke] PASS" + ' + + - name: "Smoke: PUID/PGID remap" + if: matrix.mode == 'puid-pgid' + run: | + mkdir -p test-storage test-cache + sudo chown -R 1501:1501 test-storage test-cache + docker run --rm \ + -e PUID=1501 -e PGID=1501 \ + -v "$(pwd)/test-storage:/var/www/html/storage" \ + -v "$(pwd)/test-cache:/var/www/html/bootstrap/cache" \ + solidtime-smoke:test \ + sh -c ' + set -e + echo "[smoke] running as remapped UID/GID 1501" + [ "$(id -u)" = "1501" ] + [ "$(id -g)" = "1501" ] + echo "[smoke] storage is writable as 1501" + touch /var/www/html/storage/framework/cache/data/test-file + echo "[smoke] PASS" + ' + + - name: "Smoke: OpenShift / arbitrary UID + group 0" + if: matrix.mode == 'openshift' + run: | + mkdir -p test-storage test-cache + sudo chown -R 2000:0 test-storage test-cache + sudo chmod -R g+rwX test-storage test-cache + docker run --rm --user 2000:0 \ + -v "$(pwd)/test-storage:/var/www/html/storage" \ + -v "$(pwd)/test-cache:/var/www/html/bootstrap/cache" \ + solidtime-smoke:test \ + sh -c ' + set -e + echo "[smoke] running as arbitrary UID 2000, group 0" + [ "$(id -u)" = "2000" ] + [ "$(id -g)" = "0" ] + echo "[smoke] storage is writable via group 0" + touch /var/www/html/storage/framework/cache/data/test-file + echo "[smoke] PASS" + ' + + - name: "Smoke: SOLIDTIME_DROP_PRIVILEGES=never (run as root)" + if: matrix.mode == 'drop-never' + run: | + mkdir -p test-storage test-cache + docker run --rm \ + -e SOLIDTIME_DROP_PRIVILEGES=never \ + -v "$(pwd)/test-storage:/var/www/html/storage" \ + -v "$(pwd)/test-cache:/var/www/html/bootstrap/cache" \ + solidtime-smoke:test \ + sh -c ' + set -e + echo "[smoke] running as root (privilege drop disabled)" + [ "$(id -u)" = "0" ] + echo "[smoke] bootstrap still ran" + test -d /var/www/html/storage/framework/cache/data + echo "[smoke] storage writable as root" + touch /var/www/html/storage/framework/cache/data/test-file + echo "[smoke] PASS" + ' + + - name: "Smoke: PUID set + started non-root prints a warning but continues" + if: matrix.mode == 'puid-mismatch-warning' + run: | + mkdir -p test-storage test-cache + sudo chown -R 1500:1500 test-storage test-cache + + set +e + docker run --rm \ + --user 1500:1500 \ + -e PUID=1500 -e PGID=1500 \ + -v "$(pwd)/test-storage:/var/www/html/storage" \ + -v "$(pwd)/test-cache:/var/www/html/bootstrap/cache" \ + solidtime-smoke:test \ + sh -c ' + set -e + echo "[smoke] running as 1500 (user: directive wins)" + [ "$(id -u)" = "1500" ] + echo "[smoke] storage is writable as 1500" + touch /var/www/html/storage/framework/cache/data/test-file + echo "[smoke] container completed successfully" + ' \ + >stdout.log 2>stderr.log + exit_code=$? + set -e + + echo "[smoke] exit code: $exit_code" + echo "--- stderr ---" + cat stderr.log + echo "--- end stderr ---" + + if [ "$exit_code" -ne 0 ]; then + echo "Expected the entrypoint to continue (warning is non-fatal)." + exit 1 + fi + + for needle in "PUID/PGID is set but the container started as UID" "remove any 'user:' directive" "Continuing as UID"; do + if ! grep -q "$needle" stderr.log; then + echo "Missing warning fragment: $needle" + exit 1 + fi + done + echo "[smoke] PASS" + + - name: "Smoke: diagnostic error path (read-only storage mount)" + if: matrix.mode == 'diagnostic' + run: | + # Pre-create the full storage tree on the host so the entrypoint's + # bootstrap_storage_tree() is a no-op (mkdir -p on existing dirs + # returns 0 even on a read-only mount). The write test then fires + # against the RO mount and triggers our diagnostic. + mkdir -p test-storage/framework/cache/data \ + test-storage/framework/sessions \ + test-storage/framework/views \ + test-storage/framework/testing \ + test-storage/logs \ + test-storage/app/public \ + test-storage/app/private \ + test-cache + + set +e + docker run --rm \ + -v "$(pwd)/test-storage:/var/www/html/storage:ro" \ + -v "$(pwd)/test-cache:/var/www/html/bootstrap/cache:ro" \ + solidtime-smoke:test \ + true \ + >stdout.log 2>stderr.log + exit_code=$? + set -e + + echo "[smoke] exit code: $exit_code" + echo "--- stderr ---" + cat stderr.log + echo "--- end stderr ---" + + if [ "$exit_code" -eq 0 ]; then + echo "Expected the entrypoint to exit non-zero on an unwritable storage mount." + exit 1 + fi + + for needle in "not writable" "PUID=" "permissions"; do + if ! grep -q "$needle" stderr.log; then + echo "Missing diagnostic fragment: $needle" + exit 1 + fi + done + echo "[smoke] PASS" diff --git a/docker/prod/Dockerfile b/docker/prod/Dockerfile index 677c9f89c..1c946f274 100644 --- a/docker/prod/Dockerfile +++ b/docker/prod/Dockerfile @@ -68,6 +68,7 @@ RUN apt-get update; \ wget \ vim \ git \ + gosu \ ncdu \ procps \ unzip \ @@ -193,9 +194,20 @@ COPY --link --chown=${WWWUSER}:${WWWUSER} . . #COPY --link --chown=${WWWUSER}:${WWWUSER} --from=build ${ROOT}/public public RUN mkdir -p \ - storage/framework/{sessions,views,cache,testing} \ + storage/framework/{sessions,views,cache/data,testing} \ storage/logs \ - bootstrap/cache && chmod -R a+rw storage + storage/app/public \ + storage/app/private \ + bootstrap/cache && \ + ln -s ../storage/app/public public/storage && \ + chmod -R a+rw storage bootstrap/cache + +# OpenShift / arbitrary-UID compatibility: group 0 (root group) gets read+write+execute +# on writable paths. Any UID can run the container if it joins the root group. +# https://docs.openshift.com/container-platform/latest/openshift_images/create-images.html +USER root +RUN chgrp -R 0 storage bootstrap/cache && \ + chmod -R g+rwX storage bootstrap/cache #RUN composer install \ # --classmap-authoritative \ diff --git a/docker/prod/deployment/octane/FrankenPHP/supervisord.frankenphp.conf b/docker/prod/deployment/octane/FrankenPHP/supervisord.frankenphp.conf index 20dbc3eea..f4ceda81e 100644 --- a/docker/prod/deployment/octane/FrankenPHP/supervisord.frankenphp.conf +++ b/docker/prod/deployment/octane/FrankenPHP/supervisord.frankenphp.conf @@ -1,7 +1,6 @@ [program:octane] process_name = %(program_name)s_%(process_num)s command = php %(ENV_ROOT)s/artisan octane:frankenphp --host=0.0.0.0 --port=8000 --admin-port=2019 --caddyfile=%(ENV_ROOT)s/docker/prod/deployment/octane/FrankenPHP/Caddyfile -user = %(ENV_USER)s priority = 1 autostart = true autorestart = true @@ -14,7 +13,6 @@ stderr_logfile_maxbytes = 0 [program:horizon] process_name = %(program_name)s_%(process_num)s command = php %(ENV_ROOT)s/artisan horizon -user = %(ENV_USER)s priority = 3 autostart = %(ENV_WITH_HORIZON)s autorestart = true @@ -27,7 +25,6 @@ stopwaitsecs = 3600 [program:scheduler] process_name = %(program_name)s_%(process_num)s command = supercronic -overlapping /etc/supercronic/laravel -user = %(ENV_USER)s autostart = %(ENV_WITH_SCHEDULER)s autorestart = true stdout_logfile = %(ENV_ROOT)s/storage/logs/scheduler.log @@ -38,7 +35,6 @@ stderr_logfile_maxbytes = 200MB [program:clear-scheduler-cache] process_name = %(program_name)s_%(process_num)s command = php %(ENV_ROOT)s/artisan schedule:clear-cache -user = %(ENV_USER)s autostart = %(ENV_WITH_SCHEDULER)s autorestart = false startsecs = 0 @@ -51,7 +47,6 @@ stderr_logfile_maxbytes = 200MB [program:reverb] process_name = %(program_name)s_%(process_num)s command = php %(ENV_ROOT)s/artisan reverb:start -user = %(ENV_USER)s priority = 2 autostart = %(ENV_WITH_REVERB)s autorestart = true diff --git a/docker/prod/deployment/start-container b/docker/prod/deployment/start-container index 0fb3fcdb9..e2e3d50e7 100644 --- a/docker/prod/deployment/start-container +++ b/docker/prod/deployment/start-container @@ -1,6 +1,240 @@ -#!/usr/bin/env sh +#!/bin/bash set -e +# ============================================================================ +# Solidtime container entrypoint. +# +# Layout: +# 1. Storage tree bootstrap (idempotent, runs as any user) +# 2. UID/GID remap + chown (root only, controlled by PUID/PGID env vars) +# 3. Pre-flight write test (fails fast with a diagnosis message) +# 4. Privilege drop via gosu, then re-exec self as APP_USER +# 5. Original CONTAINER_MODE routing (runs as APP_USER) +# +# Env vars: +# PUID, PGID UID/GID for the application user. Defaults 1000:1000. +# Only takes effect when the container starts as root +# (which is the image's default — if you set a +# `user:` directive in compose, PUID/PGID are ignored +# and a startup warning is printed). +# SOLIDTIME_DROP_PRIVILEGES auto (default) | never +# auto: if started as root, drop privileges to APP_USER; otherwise just exec. +# never: never drop privileges. Run as whatever UID/GID was started. +# ============================================================================ + +APP_USER="octane" +APP_PATH="${ROOT:-/var/www/html}" +STORAGE_PATH="${APP_PATH}/storage" +CACHE_PATH="${APP_PATH}/bootstrap/cache" +DEFAULT_UID=1000 +DEFAULT_GID=1000 +TARGET_UID="${PUID:-${DEFAULT_UID}}" +TARGET_GID="${PGID:-${DEFAULT_GID}}" +DROP_PRIVS="${SOLIDTIME_DROP_PRIVILEGES:-auto}" +WRITABLE_PATHS=( + "${STORAGE_PATH}/framework/cache/data" + "${STORAGE_PATH}/framework/sessions" + "${STORAGE_PATH}/framework/views" + "${STORAGE_PATH}/framework/testing" + "${STORAGE_PATH}/logs" + "${STORAGE_PATH}/app/public" + "${STORAGE_PATH}/app/private" + "${CACHE_PATH}" +) + +case "${DROP_PRIVS}" in + never) SHOULD_DROP=0 ;; + auto) + if [ "$(id -u)" = "0" ]; then + SHOULD_DROP=1 + else + SHOULD_DROP=0 + fi + ;; + *) + echo "[entrypoint] ERROR: invalid SOLIDTIME_DROP_PRIVILEGES='${DROP_PRIVS}'" >&2 + echo "[entrypoint] Valid values: auto (default), never" >&2 + exit 1 + ;; +esac + +# Warn if PUID/PGID are set but the container started non-root. PUID/PGID only +# take effect during the drop-privileges flow, which requires starting as root. +# A common cause is leaving `user:` in the compose file alongside PUID env vars. +if { [ -n "${PUID}" ] || [ -n "${PGID}" ]; } \ + && [ "$(id -u)" != "0" ] \ + && [ "${SOLIDTIME_PRIVILEGES_DROPPED:-0}" != "1" ]; then + cat >&2 </dev/null || return 1 +} + +# Proactive warning when the existing storage directory is owned by a non-default +# UID (typical on NAS systems where host users aren't UID 1000) and PUID/PGID +# aren't set. Without this nudge, the chown step silently re-owns the files to +# 1000:1000 and the user only discovers the mismatch later when host-side tools +# (backup, file browser, rsync) show unfamiliar ownership. +maybe_warn_ownership_mismatch() { + [ "${SHOULD_DROP}" = "1" ] || return 0 + [ -n "${PUID}" ] && return 0 + [ -n "${PGID}" ] && return 0 + [ -d "${STORAGE_PATH}" ] || return 0 + + local owner_uid owner_gid + owner_uid="$(stat -c '%u' "${STORAGE_PATH}" 2>/dev/null)" || return 0 + owner_gid="$(stat -c '%g' "${STORAGE_PATH}" 2>/dev/null)" || return 0 + + # Root-owned: probably freshly created by the entrypoint, will be chowned shortly. + [ "${owner_uid}" = "0" ] && return 0 + # Already the target: nothing to warn about. + [ "${owner_uid}" = "${TARGET_UID}" ] && return 0 + + cat >&2 </dev/null || echo unknown)" + local runtime_uid + local runtime_gid + if [ "$(id -u)" = "0" ] && [ "${SHOULD_DROP}" = "1" ]; then + runtime_uid="${TARGET_UID}" + runtime_gid="${TARGET_GID}" + else + runtime_uid="$(id -u)" + runtime_gid="$(id -g)" + fi + local owner_uid + owner_uid="$(stat -c '%u' "${STORAGE_PATH}" 2>/dev/null || echo 1000)" + local owner_gid + owner_gid="$(stat -c '%g' "${STORAGE_PATH}" 2>/dev/null || echo 1000)" + cat >&2 < + +Or set PUID/PGID to match the host directory owner: + + PUID=${owner_uid} + PGID=${owner_gid} + +To run intentionally as root, set: + + SOLIDTIME_DROP_PRIVILEGES=never + +For more help: https://docs.solidtime.io/self-hosting/guides/permissions +============================================================ + +EOF +} + +write_test_as_user() { + local user="$1" + local script=' + set -e + for dir in "$@"; do + test_file="${dir}/.solidtime-write-test" + touch "${test_file}" + rm -f "${test_file}" + done + ' + if [ -n "${user}" ]; then + gosu "${user}" sh -c "${script}" sh "${WRITABLE_PATHS[@]}" 2>/dev/null + else + sh -c "${script}" sh "${WRITABLE_PATHS[@]}" 2>/dev/null + fi +} + +# ---------------------------------------------------------------------------- +# Root preamble: bootstrap, remap, chown, write-test, then drop and re-exec. +# ---------------------------------------------------------------------------- +if [ "$(id -u)" = "0" ]; then + if ! bootstrap_storage_tree; then + echo "[entrypoint] ERROR: failed to create storage subdirectories at ${STORAGE_PATH}" >&2 + exit 1 + fi + + if [ "${SHOULD_DROP}" = "1" ]; then + maybe_warn_ownership_mismatch + if [ "${TARGET_UID}" != "${DEFAULT_UID}" ] || [ "${TARGET_GID}" != "${DEFAULT_GID}" ]; then + echo "[entrypoint] Remapping ${APP_USER} to ${TARGET_UID}:${TARGET_GID}" + groupmod -o -g "${TARGET_GID}" "${APP_USER}" + usermod -o -u "${TARGET_UID}" "${APP_USER}" + fi + # Idempotent chown: only fix entries whose owner or group is wrong. + # On large storage volumes (lots of user uploads) this is dramatically + # faster than a blanket `chown -R` every restart. Pattern borrowed from + # docker-library/postgres and linuxserver.io's baseimage. + find "${STORAGE_PATH}" "${CACHE_PATH}" \ + \( ! -user "${TARGET_UID}" -o ! -group "${TARGET_GID}" \) \ + -exec chown "${TARGET_UID}:${TARGET_GID}" {} + 2>/dev/null || true + + if ! write_test_as_user "${APP_USER}"; then + print_write_test_failure + exit 1 + fi + + exec gosu "${APP_USER}" env SOLIDTIME_PRIVILEGES_DROPPED=1 "$0" "$@" + fi + + if ! write_test_as_user ""; then + print_write_test_failure + exit 1 + fi +else + if ! bootstrap_storage_tree; then + echo "[entrypoint] WARNING: could not create some storage subdirectories at ${STORAGE_PATH} (will continue if existing tree is writable)" >&2 + fi + if ! write_test_as_user ""; then + print_write_test_failure + exit 1 + fi +fi + +# ---------------------------------------------------------------------------- +# Application: runs as APP_USER (or whatever non-root UID was started). +# ---------------------------------------------------------------------------- + +unset SOLIDTIME_PRIVILEGES_DROPPED + container_mode=${CONTAINER_MODE:-"http"} octane_server=${OCTANE_SERVER} auto_db_migrate=${AUTO_DB_MIGRATE:-false} @@ -8,14 +242,16 @@ auto_db_migrate=${AUTO_DB_MIGRATE:-false} initialStuff() { echo "Container mode: $container_mode" - if [ ${auto_db_migrate} = "true" ]; then + if [ "${auto_db_migrate}" = "true" ]; then echo "Auto database migration enabled." php artisan migrate --isolated --force fi - php artisan storage:link; \ - php artisan optimize:clear; \ - php artisan optimize; + if [ ! -L "${APP_PATH}/public/storage" ]; then + php artisan storage:link + fi + php artisan optimize:clear + php artisan optimize } if [ "$1" != "" ]; then @@ -23,11 +259,11 @@ if [ "$1" != "" ]; then elif [ "${container_mode}" = "http" ]; then initialStuff echo "Octane Server: $octane_server" - if [ "${octane_server}" = "frankenphp" ]; then + if [ "${octane_server}" = "frankenphp" ]; then exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.frankenphp.conf - elif [ "${octane_server}" = "swoole" ]; then + elif [ "${octane_server}" = "swoole" ]; then exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.swoole.conf - elif [ "${octane_server}" = "roadrunner" ]; then + elif [ "${octane_server}" = "roadrunner" ]; then exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.roadrunner.conf else echo "Invalid Octane server supplied." diff --git a/docker/prod/deployment/supervisord.conf b/docker/prod/deployment/supervisord.conf index 80d8cdcd4..f556691a9 100644 --- a/docker/prod/deployment/supervisord.conf +++ b/docker/prod/deployment/supervisord.conf @@ -1,6 +1,5 @@ [supervisord] nodaemon = true -user = %(ENV_USER)s logfile = /var/log/supervisor/supervisord.log pidfile = /var/run/supervisord.pid diff --git a/docker/prod/deployment/supervisord.horizon.conf b/docker/prod/deployment/supervisord.horizon.conf index c57062c62..6bb689c99 100644 --- a/docker/prod/deployment/supervisord.horizon.conf +++ b/docker/prod/deployment/supervisord.horizon.conf @@ -1,7 +1,6 @@ [program:horizon] process_name = %(program_name)s_%(process_num)s command = php %(ENV_ROOT)s/artisan horizon -user = %(ENV_USER)s autostart = true autorestart = true stdout_logfile = /dev/stdout diff --git a/docker/prod/deployment/supervisord.reverb.conf b/docker/prod/deployment/supervisord.reverb.conf index 6e540eb1b..24c820337 100644 --- a/docker/prod/deployment/supervisord.reverb.conf +++ b/docker/prod/deployment/supervisord.reverb.conf @@ -1,7 +1,6 @@ [program:reverb] process_name = %(program_name)s_%(process_num)s command = php %(ENV_ROOT)s/artisan reverb:start -user = %(ENV_USER)s autostart = true autorestart = true stdout_logfile = /dev/stdout diff --git a/docker/prod/deployment/supervisord.scheduler.conf b/docker/prod/deployment/supervisord.scheduler.conf index 20722be51..7d8d57efc 100644 --- a/docker/prod/deployment/supervisord.scheduler.conf +++ b/docker/prod/deployment/supervisord.scheduler.conf @@ -1,7 +1,6 @@ [program:scheduler] process_name = %(program_name)s_%(process_num)s command = supercronic -overlapping /etc/supercronic/laravel -user = %(ENV_USER)s autostart = true autorestart = true stdout_logfile = /dev/stdout @@ -12,7 +11,6 @@ stderr_logfile_maxbytes = 0 [program:clear-scheduler-cache] process_name = %(program_name)s_%(process_num)s command = php %(ENV_ROOT)s/artisan schedule:clear-cache -user = %(ENV_USER)s autostart = true autorestart = false startsecs = 0 diff --git a/docker/prod/deployment/supervisord.worker.conf b/docker/prod/deployment/supervisord.worker.conf index 0d939c09a..7fcc6524d 100644 --- a/docker/prod/deployment/supervisord.worker.conf +++ b/docker/prod/deployment/supervisord.worker.conf @@ -1,7 +1,6 @@ [program:worker] process_name = %(program_name)s_%(process_num)s command = %(ENV_WORKER_COMMAND)s -user = %(ENV_USER)s autostart = true autorestart = true stdout_logfile = /dev/stdout