diff --git a/.kamal/hooks/docker-setup.sample b/.kamal/hooks/docker-setup.sample new file mode 100755 index 0000000..2fb07d7 --- /dev/null +++ b/.kamal/hooks/docker-setup.sample @@ -0,0 +1,3 @@ +#!/bin/sh + +echo "Docker set up on $KAMAL_HOSTS..." diff --git a/.kamal/hooks/post-app-boot.sample b/.kamal/hooks/post-app-boot.sample new file mode 100755 index 0000000..70f9c4b --- /dev/null +++ b/.kamal/hooks/post-app-boot.sample @@ -0,0 +1,3 @@ +#!/bin/sh + +echo "Booted app version $KAMAL_VERSION on $KAMAL_HOSTS..." diff --git a/.kamal/hooks/post-deploy.sample b/.kamal/hooks/post-deploy.sample new file mode 100755 index 0000000..fd364c2 --- /dev/null +++ b/.kamal/hooks/post-deploy.sample @@ -0,0 +1,14 @@ +#!/bin/sh + +# A sample post-deploy hook +# +# These environment variables are available: +# KAMAL_RECORDED_AT +# KAMAL_PERFORMER +# KAMAL_VERSION +# KAMAL_HOSTS +# KAMAL_ROLES (if set) +# KAMAL_DESTINATION (if set) +# KAMAL_RUNTIME + +echo "$KAMAL_PERFORMER deployed $KAMAL_VERSION to $KAMAL_DESTINATION in $KAMAL_RUNTIME seconds" diff --git a/.kamal/hooks/post-proxy-reboot.sample b/.kamal/hooks/post-proxy-reboot.sample new file mode 100755 index 0000000..1435a67 --- /dev/null +++ b/.kamal/hooks/post-proxy-reboot.sample @@ -0,0 +1,3 @@ +#!/bin/sh + +echo "Rebooted kamal-proxy on $KAMAL_HOSTS" diff --git a/.kamal/hooks/pre-app-boot.sample b/.kamal/hooks/pre-app-boot.sample new file mode 100755 index 0000000..45f7355 --- /dev/null +++ b/.kamal/hooks/pre-app-boot.sample @@ -0,0 +1,3 @@ +#!/bin/sh + +echo "Booting app version $KAMAL_VERSION on $KAMAL_HOSTS..." diff --git a/.kamal/hooks/pre-build.sample b/.kamal/hooks/pre-build.sample new file mode 100755 index 0000000..c5a5567 --- /dev/null +++ b/.kamal/hooks/pre-build.sample @@ -0,0 +1,51 @@ +#!/bin/sh + +# A sample pre-build hook +# +# Checks: +# 1. We have a clean checkout +# 2. A remote is configured +# 3. The branch has been pushed to the remote +# 4. The version we are deploying matches the remote +# +# These environment variables are available: +# KAMAL_RECORDED_AT +# KAMAL_PERFORMER +# KAMAL_VERSION +# KAMAL_HOSTS +# KAMAL_ROLES (if set) +# KAMAL_DESTINATION (if set) + +if [ -n "$(git status --porcelain)" ]; then + echo "Git checkout is not clean, aborting..." >&2 + git status --porcelain >&2 + exit 1 +fi + +first_remote=$(git remote) + +if [ -z "$first_remote" ]; then + echo "No git remote set, aborting..." >&2 + exit 1 +fi + +current_branch=$(git branch --show-current) + +if [ -z "$current_branch" ]; then + echo "Not on a git branch, aborting..." >&2 + exit 1 +fi + +remote_head=$(git ls-remote $first_remote --tags $current_branch | cut -f1) + +if [ -z "$remote_head" ]; then + echo "Branch not pushed to remote, aborting..." >&2 + exit 1 +fi + +if [ "$KAMAL_VERSION" != "$remote_head" ]; then + echo "Version ($KAMAL_VERSION) does not match remote HEAD ($remote_head), aborting..." >&2 + exit 1 +fi + +exit 0 diff --git a/.kamal/hooks/pre-connect.sample b/.kamal/hooks/pre-connect.sample new file mode 100755 index 0000000..77744bd --- /dev/null +++ b/.kamal/hooks/pre-connect.sample @@ -0,0 +1,47 @@ +#!/usr/bin/env ruby + +# A sample pre-connect check +# +# Warms DNS before connecting to hosts in parallel +# +# These environment variables are available: +# KAMAL_RECORDED_AT +# KAMAL_PERFORMER +# KAMAL_VERSION +# KAMAL_HOSTS +# KAMAL_ROLES (if set) +# KAMAL_DESTINATION (if set) +# KAMAL_RUNTIME + +hosts = ENV["KAMAL_HOSTS"].split(",") +results = nil +max = 3 + +elapsed = Benchmark.realtime do + results = hosts.map do |host| + Thread.new do + tries = 1 + + begin + Socket.getaddrinfo(host, 0, Socket::AF_UNSPEC, Socket::SOCK_STREAM, nil, Socket::AI_CANONNAME) + rescue SocketError + if tries < max + puts "Retrying DNS warmup: #{host}" + tries += 1 + sleep rand + retry + else + puts "DNS warmup failed: #{host}" + host + end + end + + tries + end + end.map(&:value) +end + +retries = results.sum - hosts.size +nopes = results.count { |r| r == max } + +puts "Prewarmed %d DNS lookups in %.2f sec: %d retries, %d failures" % [ hosts.size, elapsed, retries, nopes ] diff --git a/.kamal/hooks/pre-deploy.sample b/.kamal/hooks/pre-deploy.sample new file mode 100755 index 0000000..05b3055 --- /dev/null +++ b/.kamal/hooks/pre-deploy.sample @@ -0,0 +1,122 @@ +#!/usr/bin/env ruby + +# A sample pre-deploy hook +# +# Checks the Github status of the build, waiting for a pending build to complete for up to 720 seconds. +# +# Fails unless the combined status is "success" +# +# These environment variables are available: +# KAMAL_RECORDED_AT +# KAMAL_PERFORMER +# KAMAL_VERSION +# KAMAL_HOSTS +# KAMAL_COMMAND +# KAMAL_SUBCOMMAND +# KAMAL_ROLES (if set) +# KAMAL_DESTINATION (if set) + +# Only check the build status for production deployments +if ENV["KAMAL_COMMAND"] == "rollback" || ENV["KAMAL_DESTINATION"] != "production" + exit 0 +end + +require "bundler/inline" + +# true = install gems so this is fast on repeat invocations +gemfile(true, quiet: true) do + source "https://rubygems.org" + + gem "octokit" + gem "faraday-retry" +end + +MAX_ATTEMPTS = 72 +ATTEMPTS_GAP = 10 + +def exit_with_error(message) + $stderr.puts message + exit 1 +end + +class GithubStatusChecks + attr_reader :remote_url, :git_sha, :github_client, :combined_status + + def initialize + @remote_url = github_repo_from_remote_url + @git_sha = `git rev-parse HEAD`.strip + @github_client = Octokit::Client.new(access_token: ENV["GITHUB_TOKEN"]) + refresh! + end + + def refresh! + @combined_status = github_client.combined_status(remote_url, git_sha) + end + + def state + combined_status[:state] + end + + def first_status_url + first_status = combined_status[:statuses].find { |status| status[:state] == state } + first_status && first_status[:target_url] + end + + def complete_count + combined_status[:statuses].count { |status| status[:state] != "pending"} + end + + def total_count + combined_status[:statuses].count + end + + def current_status + if total_count > 0 + "Completed #{complete_count}/#{total_count} checks, see #{first_status_url} ..." + else + "Build not started..." + end + end + + private + def github_repo_from_remote_url + url = `git config --get remote.origin.url`.strip.delete_suffix(".git") + if url.start_with?("https://github.com/") + url.delete_prefix("https://github.com/") + elsif url.start_with?("git@github.com:") + url.delete_prefix("git@github.com:") + else + url + end + end +end + + +$stdout.sync = true + +begin + puts "Checking build status..." + + attempts = 0 + checks = GithubStatusChecks.new + + loop do + case checks.state + when "success" + puts "Checks passed, see #{checks.first_status_url}" + exit 0 + when "failure" + exit_with_error "Checks failed, see #{checks.first_status_url}" + when "pending" + attempts += 1 + end + + exit_with_error "Checks are still pending, gave up after #{MAX_ATTEMPTS * ATTEMPTS_GAP} seconds" if attempts == MAX_ATTEMPTS + + puts checks.current_status + sleep(ATTEMPTS_GAP) + checks.refresh! + end +rescue Octokit::NotFound + exit_with_error "Build status could not be found" +end diff --git a/.kamal/hooks/pre-proxy-reboot.sample b/.kamal/hooks/pre-proxy-reboot.sample new file mode 100755 index 0000000..061f805 --- /dev/null +++ b/.kamal/hooks/pre-proxy-reboot.sample @@ -0,0 +1,3 @@ +#!/bin/sh + +echo "Rebooting kamal-proxy on $KAMAL_HOSTS..." diff --git a/.kamal/secrets b/.kamal/secrets new file mode 100644 index 0000000..ca72891 --- /dev/null +++ b/.kamal/secrets @@ -0,0 +1,18 @@ +# Secrets defined here are available for reference under registry/password, env/secret, builder/secrets, +# and accessories/*/env/secret in config/deploy.yml. All secrets should be pulled from either +# password manager, ENV, or a file. DO NOT ENTER RAW CREDENTIALS HERE! This file needs to be safe for git. + +# Option 1: Read secrets from the environment +KAMAL_REGISTRY_USERNAME=$KAMAL_REGISTRY_USERNAME +KAMAL_REGISTRY_PASSWORD=$KAMAL_REGISTRY_PASSWORD + +# Option 2: Read secrets via a command +RAILS_MASTER_KEY=$(cat config/credentials/production.key) + +# Option 3: Read secrets via kamal secrets helpers +# These will handle logging in and fetching the secrets in as few calls as possible +# There are adapters for 1Password, LastPass + Bitwarden +# +# SECRETS=$(kamal secrets fetch --adapter 1password --account my-account --from MyVault/MyItem KAMAL_REGISTRY_PASSWORD RAILS_MASTER_KEY) +# KAMAL_REGISTRY_PASSWORD=$(kamal secrets extract KAMAL_REGISTRY_PASSWORD $SECRETS) +# RAILS_MASTER_KEY=$(kamal secrets extract RAILS_MASTER_KEY $SECRETS) diff --git a/Dockerfile b/Dockerfile index c887a66..cf3774b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,12 @@ # syntax=docker/dockerfile:1 # check=error=true +# This Dockerfile is designed for production, not development. Use with Kamal or build'n'run by hand: +# docker build -t jumpstart . +# docker run -d -p 80:80 -e RAILS_MASTER_KEY= --name jumpstart jumpstart + +# For a containerized dev environment, see Dev Containers: https://guides.rubyonrails.org/getting_started_with_devcontainer.html + # Make sure RUBY_VERSION matches the Ruby version in .ruby-version ARG RUBY_VERSION=3.4.7 FROM quay.io/evl.ms/fullstaq-ruby:${RUBY_VERSION}-jemalloc-slim AS base @@ -10,31 +16,26 @@ LABEL fly_launch_runtime="rails" # Rails app lives here WORKDIR /rails -# Update gems and bundler -RUN gem update --system --no-document && \ - gem install -N bundler - # Install base packages RUN apt-get update -qq && \ - apt-get install --no-install-recommends -y curl sqlite3 && \ + apt-get install --no-install-recommends -y curl libjemalloc2 libvips sqlite3 postgresql-client && \ rm -rf /var/lib/apt/lists /var/cache/apt/archives # Set production environment -ENV BUNDLE_DEPLOYMENT="1" \ +ENV RAILS_ENV="production" \ + BUNDLE_DEPLOYMENT="1" \ BUNDLE_PATH="/usr/local/bundle" \ - BUNDLE_WITHOUT="development:test" \ - RAILS_ENV="production" - + BUNDLE_WITHOUT="development" # Throw-away build stage to reduce size of final image FROM base AS build # Install packages needed to build gems and node modules RUN apt-get update -qq && \ - apt-get install --no-install-recommends -y build-essential git libyaml-dev node-gyp pkg-config python-is-python3 && \ + apt-get install --no-install-recommends -y build-essential git libpq-dev libyaml-dev node-gyp pkg-config python-is-python3 imagemagick libvips libvips-dev libvips-tools poppler-utils && \ rm -rf /var/lib/apt/lists /var/cache/apt/archives -# Install Node.js +# Install JavaScript dependencies ARG NODE_VERSION=20.10.0 ARG PNPM_VERSION=10.8.0 ENV PATH=/usr/local/node/bin:$PATH @@ -44,14 +45,14 @@ RUN curl -sL https://github.com/nodenv/node-build/archive/master.tar.gz | tar xz rm -rf /tmp/node-build-master # Install application gems -COPY Gemfile Gemfile.lock ./ +COPY Gemfile Gemfile.lock ./.ruby-version vendor ./ RUN bundle install && \ rm -rf ~/.bundle/ "${BUNDLE_PATH}"/ruby/*/cache "${BUNDLE_PATH}"/ruby/*/bundler/gems/*/.git && \ bundle exec bootsnap precompile --gemfile # Install node modules -COPY package.json ./ -RUN pnpm install +COPY package.json pnpm-lock.yaml ./ +RUN pnpm install --frozen-lockfile # Copy application code COPY . . @@ -66,25 +67,18 @@ RUN SECRET_KEY_BASE_DUMMY=1 ./bin/rails assets:precompile # Final stage for app image FROM base - -# Copy built artifacts: gems, application -COPY --from=build "${BUNDLE_PATH}" "${BUNDLE_PATH}" -COPY --from=build /rails /rails - # Run and own only the runtime files as a non-root user for security RUN groupadd --system --gid 1000 rails && \ - useradd rails --uid 1000 --gid 1000 --create-home --shell /bin/bash && \ - mkdir /data && \ - chown -R 1000:1000 db log storage tmp /data + useradd rails --uid 1000 --gid 1000 --create-home --shell /bin/bash USER 1000:1000 -# Deployment options -ENV DATABASE_URL="sqlite3:///data/production.sqlite3" +# Copy built artifacts: gems, application +COPY --chown=rails:rails --from=build "${BUNDLE_PATH}" "${BUNDLE_PATH}" +COPY --chown=rails:rails --from=build /rails /rails # Entrypoint prepares the database. ENTRYPOINT ["/rails/bin/docker-entrypoint"] -# Start the server by default, this can be overwritten at runtime -EXPOSE 3000 -VOLUME /data -CMD ["./bin/rails", "server"] +# Start server via Thruster by default, this can be overwritten at runtime +EXPOSE 80 +CMD ["./bin/thrust", "./bin/rails", "server"] diff --git a/Gemfile b/Gemfile index ab67e0c..b59fa52 100644 --- a/Gemfile +++ b/Gemfile @@ -39,6 +39,9 @@ gem "tzinfo-data", platforms: %i[mingw mswin x64_mingw jruby] # Reduces boot times through caching; required in config/boot.rb gem "bootsnap", require: false +# Add HTTP asset caching/compression and X-Sendfile acceleration to Puma [https://github.com/basecamp/thruster/] +gem "thruster", require: false + # Use Sass to process CSS # gem "sassc-rails" @@ -81,3 +84,5 @@ gem "pry", "0.15.2" gem "tailwind_merge", "~> 1.3.1" gem "rouge", "~> 4.6" + +gem "kamal", github: "basecamp/kamal", branch: "main" diff --git a/Gemfile.lock b/Gemfile.lock index ab09981..25b2098 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,3 +1,20 @@ +GIT + remote: https://github.com/basecamp/kamal.git + revision: a293409fcb64cdaec145b795ad01cba4403edbd7 + branch: main + specs: + kamal (2.7.0) + activesupport (>= 7.0) + base64 (~> 0.2) + bcrypt_pbkdf (~> 1.0) + concurrent-ruby (~> 1.2) + dotenv (~> 3.1) + ed25519 (~> 1.4) + net-ssh (~> 7.3) + sshkit (>= 1.23.0, < 2.0) + thor (~> 1.3) + zeitwerk (>= 2.6.18, < 3.0) + GIT remote: https://github.com/phlex-ruby/phlex-rails.git revision: 2a0078767af4feea43e0772620e1d88529dc4d1a @@ -100,6 +117,7 @@ GEM public_suffix (>= 2.0.2, < 7.0) ast (2.4.3) base64 (0.3.0) + bcrypt_pbkdf (1.1.1) bigdecimal (3.3.1) bindex (0.8.1) bootsnap (1.19.0) @@ -124,7 +142,9 @@ GEM debug (1.11.0) irb (~> 1.10) reline (>= 0.3.8) + dotenv (3.1.8) drb (2.2.3) + ed25519 (1.4.0) erb (6.0.0) erubi (1.13.1) globalid (1.3.0) @@ -167,14 +187,20 @@ GEM net-protocol net-protocol (0.2.2) timeout + net-scp (4.1.0) + net-ssh (>= 2.6.5, < 8.0.0) + net-sftp (4.0.0) + net-ssh (>= 5.0.0, < 8.0.0) net-smtp (0.5.1) net-protocol + net-ssh (7.3.0) nio4r (2.7.5) nokogiri (1.18.10) mini_portile2 (~> 2.8.2) racc (~> 1.4) nokogiri (1.18.10-x86_64-linux-gnu) racc (~> 1.4) + ostruct (0.6.3) parallel (1.27.0) parser (3.3.9.0) ast (~> 2.4.1) @@ -277,6 +303,13 @@ GEM sqlite3 (2.8.0) mini_portile2 (~> 2.8.0) sqlite3 (2.8.0-x86_64-linux-gnu) + sshkit (1.24.0) + base64 + logger + net-scp (>= 1.1.2) + net-sftp (>= 2.1.2) + net-ssh (>= 2.8.0) + ostruct standard (1.52.0) language_server-protocol (~> 3.17.0.2) lint_roller (~> 1.0) @@ -295,6 +328,8 @@ GEM tailwind_merge (1.3.1) sin_lru_redux (~> 2.5) thor (1.4.0) + thruster (0.1.16) + thruster (0.1.16-x86_64-linux) timeout (0.4.4) tsort (0.2.0) turbo-rails (2.0.20) @@ -331,6 +366,7 @@ DEPENDENCIES cssbundling-rails (= 1.4.3) debug jsbundling-rails (= 1.3.1) + kamal! lucide-rails (= 0.7.1) phlex! phlex-rails! @@ -345,6 +381,7 @@ DEPENDENCIES standard stimulus-rails (= 1.3.4) tailwind_merge (~> 1.3.1) + thruster turbo-rails (= 2.0.20) tzinfo-data web-console diff --git a/bin/thrust b/bin/thrust new file mode 100755 index 0000000..36bde2d --- /dev/null +++ b/bin/thrust @@ -0,0 +1,5 @@ +#!/usr/bin/env ruby +require "rubygems" +require "bundler/setup" + +load Gem.bin_path("thruster", "thrust") diff --git a/config/deploy.yml b/config/deploy.yml new file mode 100644 index 0000000..e5c0473 --- /dev/null +++ b/config/deploy.yml @@ -0,0 +1,108 @@ +# Name of your application. Used to uniquely configure containers. +service: rubyui-web + +# Name of the container image. +image: rh63e/rubyui-web + +# Deploy to these servers. +servers: + web: + - 45.55.81.54 + # job: + # hosts: + # - 192.168.0.1 + # cmd: bin/jobs + +# Enable SSL auto certification via Let's Encrypt and allow for multiple apps on a single web server. +# Remove this section when using multiple web servers and ensure you terminate SSL at your load balancer. +# +# Note: If using Cloudflare, set encryption mode in SSL/TLS setting to "Full" to enable CF-to-app encryption. +proxy: + # ssl: true + host: next.rubyui.com + # Proxy connects to your container on port 80 by default. + app_port: 3000 + +# Credentials for your image host. +registry: + # Specify the registry server, if you're not using Docker Hub + # server: registry.digitalocean.com / ghcr.io / ... + # server: ghcr.io + username: + - KAMAL_REGISTRY_USERNAME + + # Always use an access token rather than real password (pulled from .kamal/secrets). + password: + - KAMAL_REGISTRY_PASSWORD + +# Configure builder setup. +builder: + arch: amd64 + # Pass in additional build args needed for your Dockerfile. + # args: + # RUBY_VERSION: <%= ENV["RBENV_VERSION"] || ENV["rvm_ruby_string"] || "#{RUBY_ENGINE}-#{RUBY_ENGINE_VERSION}" %> + +# Inject ENV variables into containers (secrets come from .kamal/secrets). +# +env: + # clear: + # DB_HOST: 192.168.0.2 + secret: + - RAILS_MASTER_KEY + +# Aliases are triggered with "bin/kamal ". You can overwrite arguments on invocation: +# "bin/kamal app logs -r job" will tail logs from the first server in the job section. +# +# aliases: +# shell: app exec --interactive --reuse "bash" +aliases: + console: app exec --interactive --reuse "bin/rails console" + shell: app exec --interactive --reuse "bash" + logs: app logs -f + dbc: app exec --interactive --reuse "bin/rails dbconsole" + +# Use a different ssh user than root +# +# ssh: +# user: app + +# Use a persistent storage volume. +# +# volumes: +# - "app_storage:/app/storage" + +# Bridge fingerprinted assets, like JS and CSS, between versions to avoid +# hitting 404 on in-flight requests. Combines all files from new and old +# version inside the asset_path. +# +# asset_path: /app/public/assets + +# Configure rolling deploys by setting a wait time between batches of restarts. +# +# boot: +# limit: 10 # Can also specify as a percentage of total hosts, such as "25%" +# wait: 2 + +# Use accessory services (secrets come from .kamal/secrets). +# +# accessories: +# db: +# image: mysql:8.0 +# host: 192.168.0.2 +# port: 3306 +# env: +# clear: +# MYSQL_ROOT_HOST: '%' +# secret: +# - MYSQL_ROOT_PASSWORD +# files: +# - config/mysql/production.cnf:/etc/mysql/my.cnf +# - db/production.sql:/docker-entrypoint-initdb.d/setup.sql +# directories: +# - data:/var/lib/mysql +# redis: +# image: valkey/valkey:8 +# host: 192.168.0.2 +# port: 6379 +# directories: +# - data:/data diff --git a/config/routes.rb b/config/routes.rb index a6da0ff..a29377d 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -66,5 +66,9 @@ match "/404", to: "errors#not_found", via: :all match "/500", to: "errors#internal_server_error", via: :all + # Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500. + # Can be used by load balancers and uptime monitors to verify that the app is live. + get "up" => "rails/health#show", :as => :rails_health_check + root "pages#home" end