mirror of
https://github.com/we-promise/sure.git
synced 2026-07-12 21:05:20 +00:00
Bump the pinned Ruby version from 3.4.7 to 3.4.9 across .ruby-version, the application/preview/devcontainer Dockerfiles, and Gemfile.lock. https://claude.ai/code/session_01C6t7JgroFSe2Xswqma3EdL Co-authored-by: Claude <noreply@anthropic.com>
386 lines
15 KiB
Docker
386 lines
15 KiB
Docker
# syntax = docker/dockerfile:1
|
|
|
|
# Preview Dockerfile for Cloudflare Containers
|
|
# Includes PostgreSQL and Redis for self-contained development testing
|
|
|
|
ARG RUBY_VERSION=3.4.9
|
|
FROM registry.docker.com/library/ruby:$RUBY_VERSION-slim AS base
|
|
|
|
WORKDIR /rails
|
|
|
|
# Install base packages including PostgreSQL and Redis servers
|
|
RUN apt-get update -qq \
|
|
&& apt-get install --no-install-recommends -y \
|
|
curl libvips postgresql postgresql-client redis-server libyaml-0-2 procps sudo openssl strace \
|
|
&& rm -rf /var/lib/apt/lists /var/cache/apt/archives
|
|
|
|
# Set development environment
|
|
ARG BUILD_COMMIT_SHA
|
|
ENV RAILS_ENV="development" \
|
|
BUNDLE_PATH="/usr/local/bundle" \
|
|
BUILD_COMMIT_SHA=${BUILD_COMMIT_SHA}
|
|
|
|
# Build stage
|
|
FROM base AS build
|
|
|
|
RUN apt-get update -qq \
|
|
&& apt-get install --no-install-recommends -y build-essential libpq-dev git pkg-config libyaml-dev \
|
|
&& rm -rf /var/lib/apt/lists /var/cache/apt/archives
|
|
|
|
COPY .ruby-version Gemfile Gemfile.lock ./
|
|
RUN bundle install \
|
|
&& rm -rf ~/.bundle/ "${BUNDLE_PATH}"/ruby/*/cache "${BUNDLE_PATH}"/ruby/*/bundler/gems/*/.git \
|
|
&& bundle exec bootsnap precompile --gemfile -j 0
|
|
|
|
COPY . .
|
|
|
|
RUN bundle exec bootsnap precompile -j 0 app/ lib/
|
|
|
|
# Precompile assets
|
|
RUN SECRET_KEY_BASE_DUMMY=1 ./bin/rails assets:precompile
|
|
|
|
# Final stage
|
|
FROM base
|
|
|
|
# Create rails user and configure PostgreSQL/Redis permissions
|
|
RUN groupadd --system --gid 1000 rails && \
|
|
useradd rails --uid 1000 --gid 1000 --create-home --shell /bin/bash && \
|
|
echo "rails ALL=(ALL) NOPASSWD: /usr/bin/pg_ctlcluster, /usr/bin/redis-server" > /etc/sudoers.d/rails && \
|
|
chmod 0440 /etc/sudoers.d/rails
|
|
|
|
# Configure PostgreSQL to allow local connections. Target the highest-version
|
|
# cluster's pg_hba.conf -- the same one the entrypoint starts (ls
|
|
# /etc/postgresql | sort -V | tail -1) -- so the trust rules always land on the
|
|
# cluster that actually runs, even if multiple majors are present.
|
|
RUN PG_VERSION="$(ls /etc/postgresql 2>/dev/null | sort -V | tail -1)" && \
|
|
PG_HBA="/etc/postgresql/${PG_VERSION}/main/pg_hba.conf" && \
|
|
if [ -z "$PG_VERSION" ] || [ ! -f "$PG_HBA" ]; then \
|
|
echo "ERROR: pg_hba.conf not found for cluster '${PG_VERSION:-none}'; cannot configure local trust" >&2; \
|
|
exit 1; \
|
|
fi && \
|
|
echo "local all all trust" > "$PG_HBA" && \
|
|
echo "host all all 127.0.0.1/32 trust" >> "$PG_HBA" && \
|
|
echo "host all all ::1/128 trust" >> "$PG_HBA"
|
|
|
|
# Use file-backed dynamic shared memory instead of POSIX /dev/shm.
|
|
# Cloudflare Containers provide only a tiny /dev/shm, and PostgreSQL's default
|
|
# dynamic_shared_memory_type = posix FATALs on startup there with
|
|
# "could not resize shared memory segment ... No space left on device", which
|
|
# kills the container before it can serve a port. mmap keeps DSM in the data
|
|
# directory ($PGDATA/pg_dynshmem), removing the /dev/shm dependency. Local
|
|
# Docker hides this because its default /dev/shm is 64MB.
|
|
#
|
|
# Select the highest-version cluster's config -- the same one the entrypoint
|
|
# starts (ls /etc/postgresql | sort -V | tail -1) -- so the override always
|
|
# lands on the cluster that actually runs, even if multiple majors are present.
|
|
RUN PG_VERSION="$(ls /etc/postgresql 2>/dev/null | sort -V | tail -1)" && \
|
|
PG_CONF="/etc/postgresql/${PG_VERSION}/main/postgresql.conf" && \
|
|
if [ -z "$PG_VERSION" ] || [ ! -f "$PG_CONF" ]; then \
|
|
echo "ERROR: postgresql.conf not found for cluster '${PG_VERSION:-none}'; cannot disable /dev/shm DSM dependency" >&2; \
|
|
exit 1; \
|
|
fi && \
|
|
sed -i 's/^[[:space:]]*dynamic_shared_memory_type[[:space:]]*=/# &/' "$PG_CONF" && \
|
|
printf '\n# Preview: avoid /dev/shm dependency (small in Cloudflare Containers)\ndynamic_shared_memory_type = mmap\n' >> "$PG_CONF" && \
|
|
grep -qx 'dynamic_shared_memory_type = mmap' "$PG_CONF" && \
|
|
echo "Configured dynamic_shared_memory_type=mmap in $PG_CONF"
|
|
|
|
# Create database directory with correct permissions
|
|
RUN mkdir -p /var/run/postgresql && \
|
|
chown -R postgres:postgres /var/run/postgresql && \
|
|
chmod 2775 /var/run/postgresql
|
|
|
|
# Copy built artifacts
|
|
COPY --chown=rails:rails --from=build "${BUNDLE_PATH}" "${BUNDLE_PATH}"
|
|
COPY --chown=rails:rails --from=build /rails /rails
|
|
|
|
# Create preview entrypoint script inline
|
|
RUN cat > /rails/bin/preview-entrypoint << 'ENTRYPOINT_EOF'
|
|
#!/bin/bash
|
|
set -e
|
|
|
|
cd /rails
|
|
|
|
# Diagnostics posts are best-effort and must NEVER stall boot: the worker's
|
|
# Durable Object can be unresponsive while it waits for this container's port,
|
|
# so an unbounded curl here deadlocks startup against the port check.
|
|
emit_status_sync() {
|
|
if [ -n "$PREVIEW_ORIGIN" ] && [ -n "$PREVIEW_DIAGNOSTICS_NONCE" ]; then
|
|
local stage="$1"
|
|
local detail="$2"
|
|
local payload
|
|
payload=$(STAGE="$stage" DETAIL="$detail" ruby -rjson -e 'print JSON.generate({stage: ENV.fetch("STAGE"), detail: ENV.fetch("DETAIL", "")})' 2>/dev/null) || return 0
|
|
curl -fsS --connect-timeout 2 --max-time 5 -X POST "$PREVIEW_ORIGIN/_container_event" \
|
|
-H 'content-type: application/json' \
|
|
-H "x-preview-diagnostics-nonce: $PREVIEW_DIAGNOSTICS_NONCE" \
|
|
--data "$payload" >/dev/null || true
|
|
fi
|
|
}
|
|
|
|
emit_status() {
|
|
emit_status_sync "$1" "$2" &
|
|
}
|
|
|
|
summarize_log_tail() {
|
|
local file="$1"
|
|
local label="$2"
|
|
|
|
if [ ! -f "$file" ]; then
|
|
printf '%s log unavailable' "$label"
|
|
return 0
|
|
fi
|
|
|
|
tail -n 80 "$file" 2>&1 |
|
|
sed 's/"/'"'"'/g' |
|
|
tr '\n' ' ' |
|
|
sed 's/ */ /g' |
|
|
cut -c 1-1600
|
|
}
|
|
|
|
fail_preview() {
|
|
local detail="$1"
|
|
trap - ERR
|
|
# Always log to stderr too: the HTTP diagnostics channel can be unreachable
|
|
# while the worker's Durable Object is still waiting for this container's
|
|
# port, but stderr is captured by Cloudflare container observability, so the
|
|
# real reason survives even when the event POST does not.
|
|
echo "PREVIEW FAILED: ${detail}" >&2
|
|
emit_status_sync failed "$detail"
|
|
exit 1
|
|
}
|
|
|
|
postgres_cluster_snapshot() {
|
|
local snapshot=""
|
|
local cluster_status
|
|
local postgres_log
|
|
|
|
if command -v pg_lsclusters >/dev/null 2>&1; then
|
|
cluster_status="$(pg_lsclusters 2>&1 | tr '\n' '|' | sed 's/"/'"'"'/g' | cut -c 1-500)"
|
|
snapshot="clusters=${cluster_status}"
|
|
fi
|
|
|
|
postgres_log="/var/log/postgresql/postgresql-${PG_VERSION}-main.log"
|
|
if [ -f "$postgres_log" ]; then
|
|
snapshot="${snapshot} log=$(summarize_log_tail "$postgres_log" postgres)"
|
|
fi
|
|
|
|
printf '%s' "$snapshot"
|
|
}
|
|
|
|
trap 'emit_status_sync failed "preview-entrypoint failed on line ${LINENO}"' ERR
|
|
emit_status boot "preview-entrypoint started"
|
|
|
|
# Bind :3000 immediately with a tiny placeholder responder. Cloudflare's
|
|
# container supervisor only waits ~20s for the port, while the full stack
|
|
# (redis, postgres, migrations) needs 60s+ on a basic instance. The
|
|
# placeholder answers 503 with a meta-refresh; the worker still gates
|
|
# previewReady on the real Rails /up probe and sample data, so readiness
|
|
# semantics are unchanged. It is replaced by the real server below.
|
|
ruby -rsocket -e '
|
|
server = TCPServer.new("0.0.0.0", 3000)
|
|
body = "<!doctype html><html><head><meta http-equiv=\"refresh\" content=\"3\"><title>Sure preview warming</title></head><body>Sure preview is booting (database setup in progress). This page refreshes automatically.</body></html>"
|
|
loop do
|
|
client = begin
|
|
server.accept
|
|
rescue StandardError
|
|
next
|
|
end
|
|
begin
|
|
# Never read from the client: the response is static, and a blocking read
|
|
# would let one silent connection (e.g. a bare TCP port probe) wedge this
|
|
# single-threaded loop and starve every later probe.
|
|
client.write("HTTP/1.1 503 Service Unavailable\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: #{body.bytesize}\r\nRetry-After: 3\r\nConnection: close\r\n\r\n#{body}")
|
|
rescue StandardError
|
|
ensure
|
|
begin
|
|
client.close
|
|
rescue StandardError
|
|
end
|
|
end
|
|
end
|
|
' >/tmp/port-placeholder.log 2>&1 &
|
|
PLACEHOLDER_PID=$!
|
|
|
|
# Confirm the placeholder actually bound :3000 before reporting it. A plain
|
|
# TCP connect is used because the placeholder intentionally answers 503.
|
|
PLACEHOLDER_BOUND=0
|
|
for i in {1..10}; do
|
|
if (exec 3<>/dev/tcp/127.0.0.1/3000) 2>/dev/null; then
|
|
PLACEHOLDER_BOUND=1
|
|
break
|
|
fi
|
|
if ! kill -0 "$PLACEHOLDER_PID" 2>/dev/null; then
|
|
break
|
|
fi
|
|
sleep 0.2
|
|
done
|
|
if [ "$PLACEHOLDER_BOUND" -eq 1 ]; then
|
|
emit_status port-placeholder "bound :3000 placeholder responder (pid ${PLACEHOLDER_PID})"
|
|
else
|
|
echo "Warning: :3000 placeholder did not come up; continuing boot" >&2
|
|
emit_status port-placeholder-missing "placeholder responder failed to bind :3000: $(summarize_log_tail /tmp/port-placeholder.log port-placeholder)"
|
|
fi
|
|
|
|
REDIS_READY=0
|
|
POSTGRES_READY=0
|
|
|
|
# Start Redis
|
|
echo "Starting Redis..."
|
|
emit_status redis-start "starting redis"
|
|
sudo redis-server --daemonize yes --bind 127.0.0.1
|
|
|
|
# Wait for Redis to be ready
|
|
echo "Waiting for Redis to be ready..."
|
|
for i in {1..10}; do
|
|
if redis-cli ping > /dev/null 2>&1; then
|
|
echo "Redis is ready"
|
|
emit_status redis-ready "redis is ready"
|
|
REDIS_READY=1
|
|
break
|
|
fi
|
|
sleep 1
|
|
done
|
|
|
|
if [ "$REDIS_READY" -ne 1 ]; then
|
|
fail_preview "redis did not become ready in time"
|
|
fi
|
|
|
|
# Start PostgreSQL
|
|
echo "Starting PostgreSQL..."
|
|
emit_status postgres-start "starting postgres"
|
|
PG_VERSION=$(ls /etc/postgresql/ | sort -V | tail -1)
|
|
if [ -z "$PG_VERSION" ]; then
|
|
fail_preview "could not determine installed PostgreSQL version"
|
|
fi
|
|
if sudo pg_ctlcluster --skip-systemctl-redirect "$PG_VERSION" main status > /dev/null 2>&1; then
|
|
emit_status postgres-already-running "postgres cluster already running"
|
|
else
|
|
POSTGRES_START_LOG=/tmp/postgres-start.log
|
|
if ! sudo pg_ctlcluster --skip-systemctl-redirect "$PG_VERSION" main start >"$POSTGRES_START_LOG" 2>&1; then
|
|
fail_preview "pg_ctlcluster start failed: $(summarize_log_tail "$POSTGRES_START_LOG" pg_ctlcluster-start) | $(postgres_cluster_snapshot)"
|
|
fi
|
|
fi
|
|
|
|
# Wait for PostgreSQL to be ready
|
|
echo "Waiting for PostgreSQL to be ready..."
|
|
for i in {1..30}; do
|
|
if pg_isready -h localhost -U postgres > /dev/null 2>&1; then
|
|
echo "PostgreSQL is ready"
|
|
emit_status postgres-ready "postgres is ready"
|
|
POSTGRES_READY=1
|
|
break
|
|
fi
|
|
sleep 1
|
|
done
|
|
|
|
if [ "$POSTGRES_READY" -ne 1 ]; then
|
|
fail_preview "postgres did not become ready in time: $(postgres_cluster_snapshot)"
|
|
fi
|
|
|
|
# Create database user and database if they don't exist
|
|
echo "Setting up database..."
|
|
emit_status db-setup "setting up database"
|
|
psql -h localhost -U postgres -tc "SELECT 1 FROM pg_roles WHERE rolname='rails'" | grep -q 1 || \
|
|
psql -h localhost -U postgres -c "CREATE USER rails WITH SUPERUSER PASSWORD 'rails';"
|
|
|
|
psql -h localhost -U postgres -tc "SELECT 1 FROM pg_database WHERE datname='sure_development'" | grep -q 1 || \
|
|
psql -h localhost -U postgres -c "CREATE DATABASE sure_development OWNER rails;"
|
|
|
|
# Set DATABASE_URL if not already set
|
|
export DATABASE_URL="${DATABASE_URL:-postgres://rails:rails@localhost:5432/sure_development}"
|
|
|
|
# Set REDIS_URL if not already set
|
|
export REDIS_URL="${REDIS_URL:-redis://localhost:6379/0}"
|
|
|
|
# Generate SECRET_KEY_BASE if not set
|
|
export SECRET_KEY_BASE="${SECRET_KEY_BASE:-$(openssl rand -hex 64)}"
|
|
|
|
# Run database migrations
|
|
echo "Running database migrations..."
|
|
emit_status db-prepare "running rails db:prepare"
|
|
/rails/bin/rails db:prepare
|
|
emit_status db-prepare-done "rails db:prepare finished"
|
|
|
|
# Defer all demo-data creation until after Rails is up so preview can boot first
|
|
echo "Checking demo dataset..."
|
|
emit_status demo-data-check "checking for default demo user"
|
|
DEMO_EMAIL="${DEMO_USER_EMAIL:-user@example.com}"
|
|
DEMO_EMAIL_SQL=${DEMO_EMAIL//\'/\'\'}
|
|
DEMO_SEED="${DEMO_DATA_SEED:-880}"
|
|
DEMO_HAS_USER=0
|
|
DEMO_HAS_DATA=0
|
|
|
|
if psql "$DATABASE_URL" -tAc "SELECT 1 FROM users WHERE email = '${DEMO_EMAIL_SQL}' LIMIT 1" | grep -q 1; then
|
|
DEMO_HAS_USER=1
|
|
emit_status demo-data-user-present "default demo user already exists"
|
|
fi
|
|
|
|
if psql "$DATABASE_URL" -tAc "SELECT 1 FROM accounts a JOIN users u ON u.family_id = a.family_id WHERE u.email = '${DEMO_EMAIL_SQL}' LIMIT 1" | grep -q 1; then
|
|
DEMO_HAS_DATA=1
|
|
emit_status demo-data-skip "demo financial data already exists"
|
|
else
|
|
emit_status demo-data-deferred "deferring demo data creation until after rails boot"
|
|
fi
|
|
|
|
# Release :3000 for the real server. The brief listener gap is handled by the
|
|
# worker, which catches containerFetch errors and serves its wait page.
|
|
kill "$PLACEHOLDER_PID" 2>/dev/null || true
|
|
wait "$PLACEHOLDER_PID" 2>/dev/null || true
|
|
|
|
# Execute the main command with an internal readiness probe
|
|
echo "Starting Rails server..."
|
|
emit_status rails-start "starting rails server"
|
|
"$@" > /tmp/rails.log 2>&1 &
|
|
RAILS_PID=$!
|
|
|
|
for i in {1..180}; do
|
|
if curl -fsS http://127.0.0.1:3000/up > /dev/null 2>&1; then
|
|
emit_status rails-up-ready "rails responded on localhost:3000/up"
|
|
|
|
if [ "$DEMO_HAS_USER" -ne 1 ] || [ "$DEMO_HAS_DATA" -ne 1 ]; then
|
|
emit_status demo-data-load "creating/backfilling demo dataset in background (seed=${DEMO_SEED})"
|
|
(
|
|
(
|
|
DEMO_USER_EMAIL="$DEMO_EMAIL" DEMO_DATA_SEED="$DEMO_SEED" /rails/bin/rails runner '
|
|
email = ENV.fetch("DEMO_USER_EMAIL")
|
|
generator = Demo::Generator.new(seed: ENV.fetch("DEMO_DATA_SEED"))
|
|
user = User.find_by(email: email)
|
|
|
|
unless user
|
|
generator.generate_empty_data!(skip_clear: true)
|
|
user = User.find_by!(email: email)
|
|
end
|
|
|
|
has_accounts = user.family.accounts.exists?
|
|
generator.generate_new_user_data_for!(user.family, email: user.email) unless has_accounts
|
|
'
|
|
) > /tmp/demo-data.log 2>&1 && \
|
|
emit_status demo-data-ready "default demo dataset loaded in background" || \
|
|
emit_status demo-data-failed "background demo dataset load failed: $(summarize_log_tail /tmp/demo-data.log demo-data)"
|
|
) &
|
|
fi
|
|
|
|
break
|
|
fi
|
|
sleep 1
|
|
done
|
|
|
|
if ! curl -fsS http://127.0.0.1:3000/up > /dev/null 2>&1; then
|
|
emit_status rails-up-timeout "rails did not answer localhost:3000/up in time"
|
|
emit_status rails-process-status "$(ps -o pid=,ppid=,stat=,comm=,args= -p "$RAILS_PID" 2>/dev/null | tr -s ' ' | sed 's/^ //')"
|
|
emit_status rails-process-wchan "$(cat /proc/$RAILS_PID/wchan 2>/dev/null | tr '\n' ' ' | cut -c 1-200)"
|
|
emit_status rails-process-children "$(ps -o pid=,ppid=,stat=,comm=,args= --ppid "$RAILS_PID" 2>/dev/null | tail -n +2 | tr '\n' '|' | cut -c 1-600)"
|
|
emit_status rails-socket-state "$(ruby -e 'hex="0BB8"; rows=File.readlines("/proc/net/tcp")+File.readlines("/proc/net/tcp6"); hits=rows.select{|l| l.include?(":#{hex} ")}.map{|l| l.strip.split[3] rescue nil}.compact; puts(hits.empty? ? "no-listener" : hits.join(","))' 2>&1 | tr '\n' ' ' | cut -c 1-400)"
|
|
emit_status rails-log-tail "$(tail -n 40 /tmp/rails.log 2>&1 | sed 's/"/'"'"'/g' | tr '\n' ' ' | cut -c 1-1200)"
|
|
fi
|
|
|
|
wait "$RAILS_PID"
|
|
ENTRYPOINT_EOF
|
|
RUN chmod 755 /rails/bin/preview-entrypoint && chown rails:rails /rails/bin/preview-entrypoint
|
|
|
|
USER 1000:1000
|
|
|
|
ENTRYPOINT ["/rails/bin/preview-entrypoint"]
|
|
|
|
EXPOSE 3000
|
|
CMD ["bundle", "exec", "puma", "-C", "config/puma.rb"]
|