diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..943c324 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,22 @@ +* + +!/assets/ +!/bin/ +!/config/ +!/lib/ +!/public/ +!/script/console +!/script/server +!/views +!/.ruby-version +!/config.ru +!/Gemfile +!/Gemfile.lock +!/package.json +!/package-lock.json +!/postcss.config.js +!/Rakefile + +/assets/builds/ +/public/assets/ +**/*.key diff --git a/.node-version b/.node-version new file mode 100644 index 0000000..5bf4400 --- /dev/null +++ b/.node-version @@ -0,0 +1 @@ +24.15.0 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..f91bcf4 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,78 @@ +# 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 --tag dev-training-web --build-arg RUBY_VERSION="$(cat .ruby-version)" --build-arg NODE_VERSION=$(cat .node-version) --platform linux/amd64 . +# docker run --interactive --tty --publish 80:80 --env MASTER_KEY="$(cat config/dev-training-web.key)" dev-training-web + +# Make sure RUBY_VERSION matches the Ruby version in .ruby-version +ARG RUBY_VERSION=OVERRIDE_ME +FROM docker.io/library/ruby:$RUBY_VERSION-slim AS base + +# App lives here +WORKDIR /app + +# Install base packages +RUN apt-get update -qq && \ + apt-get install --no-install-recommends -y curl libjemalloc2 && \ + ln -s /usr/lib/$(uname -m)-linux-gnu/libjemalloc.so.2 /usr/local/lib/libjemalloc.so && \ + rm -rf /var/lib/apt/lists /var/cache/apt/archives + +# Set production environment variables and enable jemalloc for reduced memory usage and latency +ENV RACK_ENV="production" \ + BUNDLE_DEPLOYMENT="1" \ + BUNDLE_PATH="/usr/local/bundle" \ + BUNDLE_ONLY="default production" \ + NODE_ENV="production" \ + LD_PRELOAD="/usr/local/lib/libjemalloc.so" + + +# Throw-away build stage to reduce size of final image +FROM base AS build + +# Install packages needed to build gems +RUN apt-get update -qq && \ + apt-get install --no-install-recommends -y build-essential && \ + rm -rf /var/lib/apt/lists /var/cache/apt/archives + +# Install JavaScript dependencies +ARG NODE_VERSION=OVERRIDE_ME +ENV PATH=/usr/local/node/bin:$PATH +RUN curl -sL https://github.com/nodenv/node-build/archive/master.tar.gz | tar xz -C /tmp/ && \ + /tmp/node-build-master/bin/node-build "${NODE_VERSION}" /usr/local/node && \ + rm -rf /tmp/node-build-master + +# Install application gems +COPY .ruby-version Gemfile Gemfile.lock ./ + +RUN bundle install && \ + rm -rf ~/.bundle/ "${BUNDLE_PATH}"/ruby/*/cache "${BUNDLE_PATH}"/ruby/*/bundler/gems/*/.git + +# Install node modules +COPY package.json package-lock.json ./ +RUN npm ci + +# Copy application code +COPY . . + +# Precompiling assets for production without requiring secret RAILS_MASTER_KEY +RUN ./bin/rake assets:precompile + +RUN rm -rf node_modules + + +# Final stage for app image +FROM base + +# Copy built artifacts: gems, application +COPY --from=build "${BUNDLE_PATH}" "${BUNDLE_PATH}" +COPY --from=build /app /app + +# Run and own only the runtime files as a non-root user for security +RUN groupadd --system --gid 1000 dev-training-web && \ + useradd dev-training-web --uid 1000 --gid 1000 --create-home --shell /bin/bash && \ + mkdir -p /app/log /app/tmp && chown -R dev-training-web:dev-training-web /app/log /app/tmp +USER 1000:1000 + +EXPOSE 80 +CMD ["./bin/thrust", "./script/server", "--port=3000"] diff --git a/Gemfile b/Gemfile index e101de4..95be2c7 100644 --- a/Gemfile +++ b/Gemfile @@ -14,12 +14,8 @@ gem 'rake' gem 'sinatra', require: 'sinatra/base' gem 'tilt' -group :test do - gem 'faker' - gem 'rack-test', require: 'rack/test' - gem 'rspec' - gem 'rspec-html-matchers' - gem 'simplecov' +group :production do + gem 'thruster' end group :development do @@ -44,3 +40,11 @@ group :development, :test do gem 'debug' gem 'dotenv' end + +group :test do + gem 'faker' + gem 'rack-test', require: 'rack/test' + gem 'rspec' + gem 'rspec-html-matchers' + gem 'simplecov' +end diff --git a/Gemfile.lock b/Gemfile.lock index 3992f58..db7a5bd 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -288,6 +288,10 @@ GEM sysexits (1.2.0) temple (0.10.4) thor (1.5.0) + thruster (0.1.20) + thruster (0.1.20-arm64-darwin) + thruster (0.1.20-x86_64-darwin) + thruster (0.1.20-x86_64-linux) tilt (2.7.0) tsort (0.2.0) tzinfo (2.0.6) @@ -339,6 +343,7 @@ DEPENDENCIES rubocop-rspec simplecov sinatra + thruster tilt CHECKSUMS @@ -460,6 +465,10 @@ CHECKSUMS sysexits (1.2.0) sha256=598241c4ae57baa403c125182dfdcc0d1ac4c0fb606dd47fbed57e4aaf795662 temple (0.10.4) sha256=b7a1e94b6f09038ab0b6e4fe0126996055da2c38bec53a8a336f075748fff72c thor (1.5.0) sha256=e3a9e55fe857e44859ce104a84675ab6e8cd59c650a49106a05f55f136425e73 + thruster (0.1.20) sha256=c05f2fbcae527bbe093a6e6d84fb12d9d680617e7c162325d9b97e8e9d4b5201 + thruster (0.1.20-arm64-darwin) sha256=630cf8c273f562063b92ea5ccd7a721d7ba6130cc422c823727f4744f6d0770e + thruster (0.1.20-x86_64-darwin) sha256=4cc245f3ea2ad238b518ae3934048eade6cc2543bdcfef91a7f95f8194306432 + thruster (0.1.20-x86_64-linux) sha256=d579f252bf67aee6ba6d957e48f566b72e019d7657ba2f267a5db1e4d91d2479 tilt (2.7.0) sha256=0d5b9ba69f6a36490c64b0eee9f6e9aad517e20dcc848800a06eb116f08c6ab3 tsort (0.2.0) sha256=9650a793f6859a43b6641671278f79cfead60ac714148aabe4e3f0060480089f tzinfo (2.0.6) sha256=8daf828cc77bcf7d63b0e3bdb6caa47e2272dcfaf4fbfe46f8c3a9df087a829b diff --git a/bin/bundle b/bin/bundle deleted file mode 100755 index 5b593cb..0000000 --- a/bin/bundle +++ /dev/null @@ -1,114 +0,0 @@ -#!/usr/bin/env ruby -# frozen_string_literal: true - -# -# This file was generated by Bundler. -# -# The application 'bundle' is installed as part of a gem, and -# this file is here to facilitate running it. -# - -require "rubygems" - -m = Module.new do - module_function - - def invoked_as_script? - File.expand_path($0) == File.expand_path(__FILE__) - end - - def env_var_version - ENV["BUNDLER_VERSION"] - end - - def cli_arg_version - return unless invoked_as_script? # don't want to hijack other binstubs - return unless "update".start_with?(ARGV.first || " ") # must be running `bundle update` - bundler_version = nil - update_index = nil - ARGV.each_with_index do |a, i| - if update_index && update_index.succ == i && a =~ Gem::Version::ANCHORED_VERSION_PATTERN - bundler_version = a - end - next unless a =~ /\A--bundler(?:[= ](#{Gem::Version::VERSION_PATTERN}))?\z/ - bundler_version = $1 - update_index = i - end - bundler_version - end - - def gemfile - gemfile = ENV["BUNDLE_GEMFILE"] - return gemfile if gemfile && !gemfile.empty? - - File.expand_path("../../Gemfile", __FILE__) - end - - def lockfile - lockfile = - case File.basename(gemfile) - when "gems.rb" then gemfile.sub(/\.rb$/, gemfile) - else "#{gemfile}.lock" - end - File.expand_path(lockfile) - end - - def lockfile_version - return unless File.file?(lockfile) - lockfile_contents = File.read(lockfile) - return unless lockfile_contents =~ /\n\nBUNDLED WITH\n\s{2,}(#{Gem::Version::VERSION_PATTERN})\n/ - Regexp.last_match(1) - end - - def bundler_requirement - @bundler_requirement ||= - env_var_version || cli_arg_version || - bundler_requirement_for(lockfile_version) - end - - def bundler_requirement_for(version) - return "#{Gem::Requirement.default}.a" unless version - - bundler_gem_version = Gem::Version.new(version) - - requirement = bundler_gem_version.approximate_recommendation - - return requirement unless Gem.rubygems_version < Gem::Version.new("2.7.0") - - requirement += ".a" if bundler_gem_version.prerelease? - - requirement - end - - def load_bundler! - ENV["BUNDLE_GEMFILE"] ||= gemfile - - activate_bundler - end - - def activate_bundler - gem_error = activation_error_handling do - gem "bundler", bundler_requirement - end - return if gem_error.nil? - require_error = activation_error_handling do - require "bundler/version" - end - return if require_error.nil? && Gem::Requirement.new(bundler_requirement).satisfied_by?(Gem::Version.new(Bundler::VERSION)) - warn "Activating bundler (#{bundler_requirement}) failed:\n#{gem_error.message}\n\nTo install the version of bundler this project requires, run `gem install bundler -v '#{bundler_requirement}'`" - exit 42 - end - - def activation_error_handling - yield - nil - rescue StandardError, LoadError => e - e - end -end - -m.load_bundler! - -if m.invoked_as_script? - load Gem.bin_path("bundler", "bundle") -end diff --git a/bin/puma b/bin/puma deleted file mode 100755 index 01a92a3..0000000 --- a/bin/puma +++ /dev/null @@ -1,27 +0,0 @@ -#!/usr/bin/env ruby -# frozen_string_literal: true - -# -# This file was generated by Bundler. -# -# The application 'puma' is installed as part of a gem, and -# this file is here to facilitate running it. -# - -ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) - -bundle_binstub = File.expand_path("bundle", __dir__) - -if File.file?(bundle_binstub) - if File.read(bundle_binstub, 300).include?("This file was generated by Bundler") - load(bundle_binstub) - else - abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. -Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") - end -end - -require "rubygems" -require "bundler/setup" - -load Gem.bin_path("puma", "puma") diff --git a/bin/rake b/bin/rake index 9275675..9efbee9 100755 --- a/bin/rake +++ b/bin/rake @@ -8,20 +8,7 @@ # this file is here to facilitate running it. # -require "pathname" -ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../Gemfile", - Pathname.new(__FILE__).realpath) - -bundle_binstub = File.expand_path("../bundle", __FILE__) - -if File.file?(bundle_binstub) - if File.read(bundle_binstub, 300) =~ /This file was generated by Bundler/ - load(bundle_binstub) - else - abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. -Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") - end -end +ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) require "rubygems" require "bundler/setup" 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/lib/dev_training_application.rb b/lib/dev_training_application.rb index 3f0e323..f7d1e25 100644 --- a/lib/dev_training_application.rb +++ b/lib/dev_training_application.rb @@ -37,11 +37,7 @@ class DevTrainingApplication < Sinatra::Base set :session_secret, ApplicationSecrets.session_secret set :haml, layout: :application - # :nocov: - configure :production do - set :static, false - end - # :nocov: + use Rack::Sendfile set :asset_assembly, AssetAssembly.new configure :development, :test do use Propshaft::Server, settings.asset_assembly @@ -52,6 +48,10 @@ class DevTrainingApplication < Sinatra::Base request.env['rack.errors'] = settings.error_log end + before '/public/assets/*' do + cache_control :public, :immutable, max_age: 31_536_000 + end + helpers do def asset_path(file) # :nodoc: settings.asset_assembly.resolver.resolve(file) || raise(Propshaft::MissingAssetError, file) diff --git a/script/server b/script/server index a285218..ef73eac 100755 --- a/script/server +++ b/script/server @@ -1,12 +1,3 @@ -#!/usr/bin/env ruby +#!/usr/bin/env bash -# frozen_string_literal: true - -require 'fileutils' - -# path to your application root. -APP_ROOT = File.expand_path('..', __dir__) - -FileUtils.chdir APP_ROOT do - system 'bin/puma config.ru' -end +bundle exec puma config.ru "$@"