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..24958cd --- /dev/null +++ b/.kamal/secrets @@ -0,0 +1,19 @@ +# 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_PASSWORD=$KAMAL_REGISTRY_PASSWORD + +# Option 2: Read secrets via a command +# RAILS_MASTER_KEY=$(cat config/master.key) +# KAMAL_REGISTRY_PASSWORD=$(rails credentials:fetch kamal.registry_password) +MASTER_KEY=$(cat config/dev-training-web.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/Capfile b/Capfile deleted file mode 100644 index 2d7b30b..0000000 --- a/Capfile +++ /dev/null @@ -1,8 +0,0 @@ -# frozen_string_literal: true - -%w[setup deploy scm/git bundler passenger pending rails/assets].each do |lib| - require "capistrano/#{lib}" -end -install_plugin Capistrano::SCM::Git - -Dir.glob('lib/capistrano/tasks/*.rake').each { |r| import r } diff --git a/Gemfile b/Gemfile index 1c3c556..defa1c5 100644 --- a/Gemfile +++ b/Gemfile @@ -25,14 +25,10 @@ end group :development do gem 'bcrypt_pbkdf', require: false - gem 'capistrano', '~> 3.20', require: false - gem 'capistrano-bundler', require: false - gem 'capistrano-passenger', require: false - gem 'capistrano-pending', require: false - gem 'capistrano-rails', require: false gem 'ed25519', require: false gem 'haml_lint', require: false gem 'irb' + gem 'kamal', require: false gem 'railties', require: false gem 'rdoc', require: false gem 'rubocop', require: false @@ -41,6 +37,6 @@ group :development do end group :development, :test do - gem 'dotenv' gem 'debug' + gem 'dotenv' end diff --git a/Gemfile.lock b/Gemfile.lock index f399c66..062eef5 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -32,28 +32,12 @@ GEM uri (>= 0.13.1) addressable (2.8.9) public_suffix (>= 2.0.2, < 8.0) - airbrussh (1.6.0) - sshkit (>= 1.6.1, != 1.7.0) ast (2.4.3) base64 (0.3.0) bcrypt_pbkdf (1.1.2) bcrypt_pbkdf (1.1.2-arm64-darwin) bigdecimal (4.0.1) builder (3.3.0) - capistrano (3.20.0) - airbrussh (>= 1.0.0) - i18n - rake (>= 10.0.0) - sshkit (>= 1.9.0) - capistrano-bundler (2.2.0) - capistrano (~> 3.1) - capistrano-passenger (0.2.1) - capistrano (~> 3.0) - capistrano-pending (0.2.0) - capistrano (>= 3.2.0) - capistrano-rails (1.7.0) - capistrano (~> 3.1) - capistrano-bundler (>= 1.1, < 3) concurrent-ruby (1.3.6) connection_pool (3.0.2) crass (1.0.6) @@ -105,6 +89,17 @@ GEM reline (>= 0.4.2) json (2.19.2) jwt (2.7.1) + kamal (2.10.1) + 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) language_server-protocol (3.17.0.5) lint_roller (1.1.0) logger (1.7.0) @@ -124,7 +119,7 @@ GEM net-ssh (>= 2.6.5, < 8.0.0) net-sftp (4.0.0) net-ssh (>= 5.0.0, < 8.0.0) - net-ssh (7.3.0) + net-ssh (7.3.1) nio4r (2.7.5) nokogiri (1.19.1-aarch64-linux-gnu) racc (~> 1.4) @@ -241,9 +236,6 @@ GEM rubocop (~> 1.81) ruby-progressbar (1.13.0) ruby2_keywords (0.0.5) - sass-embedded (1.98.0) - google-protobuf (~> 4.31) - rake (>= 13) sass-embedded (1.98.0-aarch64-linux-gnu) google-protobuf (~> 4.31) sass-embedded (1.98.0-arm64-darwin) @@ -306,11 +298,6 @@ PLATFORMS DEPENDENCIES activesupport bcrypt_pbkdf - capistrano (~> 3.20) - capistrano-bundler - capistrano-passenger - capistrano-pending - capistrano-rails debug dotenv ed25519 @@ -319,6 +306,7 @@ DEPENDENCIES haml haml_lint irb + kamal octokit omniauth-github puma @@ -342,18 +330,12 @@ CHECKSUMS actionview (8.1.3) sha256=1347c88c7f3edb38100c5ce0e9fb5e62d7755f3edc1b61cce2eb0b2c6ea2fd5d activesupport (8.1.3) sha256=21a5e0dfbd4c3ddd9e1317ec6a4d782fa226e7867dc70b0743acda81a1dca20e addressable (2.8.9) sha256=cc154fcbe689711808a43601dee7b980238ce54368d23e127421753e46895485 - airbrussh (1.6.0) sha256=7e2cf581f2319d2c2b2b672c9fc486efb4dfcfed4bd2dadbef5f10b8b2a000d0 ast (2.4.3) sha256=954615157c1d6a382bc27d690d973195e79db7f55e9765ac7c481c60bdb4d383 base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b bcrypt_pbkdf (1.1.2) sha256=c2414c23ce66869b3eb9f643d6a3374d8322dfb5078125c82792304c10b94cf6 bcrypt_pbkdf (1.1.2-arm64-darwin) sha256=afdd6feb6ed5a97b8e44caacb3f2d641b98af78e6a516d4a3520b69af5cf9fea bigdecimal (4.0.1) sha256=8b07d3d065a9f921c80ceaea7c9d4ae596697295b584c296fe599dd0ad01c4a7 builder (3.3.0) sha256=497918d2f9dca528fdca4b88d84e4ef4387256d984b8154e9d5d3fe5a9c8835f - capistrano (3.20.0) sha256=0113e58dda99add0342e56a244f664734c59f442c5ed734f5303b0b559b479c9 - capistrano-bundler (2.2.0) sha256=47b4cf2ea17ea132bb0a5cabc5663443f5190a54f4da5b322d04e1558ff1468c - capistrano-passenger (0.2.1) sha256=07a1d25edd5c1d909c19d4fe45fe2ea5f11200569f6967f6bff1d605ade98e13 - capistrano-pending (0.2.0) sha256=f9e8c1e6b6a2ce760ed49ccb470c474f802c1d453704fb62d67ca4c1ee547066 - capistrano-rails (1.7.0) sha256=aca57455e8c5435785e0f938e16aa5b79c263694a755e1dca1c5d1743b40aae7 concurrent-ruby (1.3.6) sha256=6b56837e1e7e5292f9864f34b69c5a2cbc75c0cf5338f1ce9903d10fa762d5ab connection_pool (3.0.2) sha256=33fff5ba71a12d2aa26cb72b1db8bba2a1a01823559fb01d29eb74c286e62e0a crass (1.0.6) sha256=dc516022a56e7b3b156099abc81b6d2b08ea1ed12676ac7a5657617f012bd45d @@ -380,6 +362,7 @@ CHECKSUMS irb (1.17.0) sha256=168c4ddb93d8a361a045c41d92b2952c7a118fa73f23fe14e55609eb7a863aae json (2.19.2) sha256=e7e1bd318b2c37c4ceee2444841c86539bc462e81f40d134cf97826cb14e83cf jwt (2.7.1) sha256=07357cd2f180739b2f8184eda969e252d850ac996ed0a23f616e8ff0a90ae19b + kamal (2.10.1) sha256=53b7ecb4c33dd83b1aedfc7aacd1c059f835993258a552d70d584c6ce32b6340 language_server-protocol (3.17.0.5) sha256=fd1e39a51a28bf3eec959379985a72e296e9f9acfce46f6a79d31ca8760803cc lint_roller (1.1.0) sha256=2c0c845b632a7d172cb849cc90c1bce937a28c5c8ccccb50dfd46a485003cc87 logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203 @@ -390,7 +373,7 @@ CHECKSUMS net-http (0.9.1) sha256=25ba0b67c63e89df626ed8fac771d0ad24ad151a858af2cc8e6a716ca4336996 net-scp (4.1.0) sha256=a99b0b92a1e5d360b0de4ffbf2dc0c91531502d3d4f56c28b0139a7c093d1a5d net-sftp (4.0.0) sha256=65bb91c859c2f93b09826757af11b69af931a3a9155050f50d1b06d384526364 - net-ssh (7.3.0) sha256=172076c4b30ce56fb25a03961b0c4da14e1246426401b0f89cba1a3b54bf3ef0 + net-ssh (7.3.1) sha256=229d518b429211bebd89151e2a12febff0631138513ac259953aa7b7cd42b53b nio4r (2.7.5) sha256=6c90168e48fb5f8e768419c93abb94ba2b892a1d0602cb06eef16d8b7df1dca1 nokogiri (1.19.1-aarch64-linux-gnu) sha256=cfdb0eafd9a554a88f12ebcc688d2b9005f9fce42b00b970e3dc199587b27f32 nokogiri (1.19.1-arm64-darwin) sha256=dfe2d337e6700eac47290407c289d56bcf85805d128c1b5a6434ddb79731cb9e @@ -434,7 +417,6 @@ CHECKSUMS rubocop-rspec (3.9.0) sha256=8fa70a3619408237d789aeecfb9beef40576acc855173e60939d63332fdb55e2 ruby-progressbar (1.13.0) sha256=80fc9c47a9b640d6834e0dc7b3c94c9df37f08cb072b7761e4a71e22cff29b33 ruby2_keywords (0.0.5) sha256=ffd13740c573b7301cf7a2e61fc857b2a8e3d3aff32545d6f8300d8bae10e3ef - sass-embedded (1.98.0) sha256=397dcd0071170f079eb97562a035fa113358af10e3ff759a57bc7eef763e9f94 sass-embedded (1.98.0-aarch64-linux-gnu) sha256=019baa4ee850f9d6f3f53125fd4ee56b13ea5191bc8f8f9436825ec3e171b02d sass-embedded (1.98.0-arm64-darwin) sha256=fed4ee6de2f30b14e7a678ca648730aa78acc86be7a555e16ba3fdbc5e6aca69 sawyer (0.9.2) sha256=fa3a72d62a4525517b18857ddb78926aab3424de0129be6772a8e2ba240e7aca diff --git a/Rakefile b/Rakefile index 2b83fcf..5dec242 100644 --- a/Rakefile +++ b/Rakefile @@ -11,7 +11,7 @@ Rake::SprocketsTask.new do |sprockets| sprockets.assets = %w[manifest.js] end -# Aliases for capistrano-rails to invoke +# Aliases # rubocop:disable Rake/Desc namespace :assets do task(:precompile) { Rake::Task['assets'].invoke } diff --git a/config/deploy.rb b/config/deploy.rb deleted file mode 100644 index b3006a2..0000000 --- a/config/deploy.rb +++ /dev/null @@ -1,25 +0,0 @@ -# frozen_string_literal: true - -# config valid for current version and minor releases of Capistrano -lock '~> 3.17' - -set :application, 'dev-training-web' -set :repo_url, 'https://github.com/umts/dev-training-web.git' -set :branch, 'main' -set :deploy_to, "/srv/#{fetch :application}" - -set :app_env, fetch(:stage) -set :default_env, { RACK_ENV: fetch(:app_env) } - -set :keep_releases, 5 - -append :linked_files, 'config/dev-training-web.key' -append :linked_dirs, 'log', 'public/assets' - -before 'git:check', 'git:allow_shared' -before 'deploy:updated', 'npm:install' - -set :log_level, :info - -set :capistrano_pending_role, :app -set :bundle_version, 4 diff --git a/config/deploy.yml b/config/deploy.yml new file mode 100644 index 0000000..8567b75 --- /dev/null +++ b/config/deploy.yml @@ -0,0 +1,32 @@ +<% + def project_root + KAMAL.configured?[:config_file].join('../..') + end + + def user_for(host) + Net::SSH::Config.for(host).fetch(:user, ENV.fetch('USER', nil)) + end +%> + +service: dev-training-web +image: umts/dev-training-web +servers: + web: + - afs-umts-web4.admin.umass.edu +proxy: + ssl: true + host: https://umts-dt.admin.umass.edu +registry: + server: localhost:5555 +builder: + arch: amd64 + args: + RUBY_VERSION: <%= project_root.join('.ruby-version').read.strip %> + NODE_VERSION: <%= project_root.join('.node-version').read.strip %> +env: + secret: + - MASTER_KEY +ssh: + user: <%= user_for 'afs-umts-web4.admin.umass.edu' %> +volumes: + - "dev-training-web_log:/app/log" diff --git a/config/deploy/production.rb b/config/deploy/production.rb deleted file mode 100644 index f4f5968..0000000 --- a/config/deploy/production.rb +++ /dev/null @@ -1,7 +0,0 @@ -# frozen_string_literal: true - -set :passenger_restart_with_touch, true - -server 'af-transit-app4.admin.umass.edu', - roles: %w[app web], - ssh_options: { forward_agent: false } diff --git a/lib/capistrano/tasks/git.rake b/lib/capistrano/tasks/git.rake deleted file mode 100644 index 4c30624..0000000 --- a/lib/capistrano/tasks/git.rake +++ /dev/null @@ -1,14 +0,0 @@ -# frozen_string_literal: true - -namespace :git do - desc 'Allow use of shared git repository' - task :allow_shared do - on release_roles(:all), in: :groups, - limit: fetch(:git_max_concurrent_connections), - wait: fetch(:git_wait_interval) do - with fetch(:git_environmental_variables) do - execute :git, :config, '--global', '--replace-all', 'safe.directory', repo_path, repo_path - end - end - end -end diff --git a/lib/capistrano/tasks/npm.rake b/lib/capistrano/tasks/npm.rake deleted file mode 100644 index 2f131e0..0000000 --- a/lib/capistrano/tasks/npm.rake +++ /dev/null @@ -1,30 +0,0 @@ -# frozen_string_literal: true - -namespace :npm do - desc 'Install the project dependencies via npm.' - task :install do - on roles :web do - within release_path do - execute :npm, 'install', %w[--production --silent --no-spin] - end - end - end - - desc 'Remove extraneous packages via npm.' - task :prune do - on roles :web do - within release_path do - execute :npm, 'prune', %w[--production] - end - end - end - - desc 'Rebuild via npm' - task :rebuild do - on roles :web do - within release_path do - execute :npm, 'rebuild' - end - end - end -end