From 20ae548ab151be69c31bd97bdc81b7b85476ea79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafael=20Mendon=C3=A7a=20Fran=C3=A7a?= Date: Wed, 22 Oct 2025 02:12:02 +0000 Subject: [PATCH 1/3] Return system as down when the lock file is stale (older than 2 hours) --- config.ru | 28 +++++++++++++++++++++++++++- config/deploy/production.rb | 1 + 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/config.ru b/config.ru index 667bec5..bfadcf2 100644 --- a/config.ru +++ b/config.ru @@ -15,6 +15,7 @@ end use Rack::CommonLogger, logger run_file = ENV["RUN_FILE"] || "#{__dir__}/run-rails-master-hook" +lock_file = ENV["LOCK_FILE"] scheduled = < 7200 # 2 hours in seconds + + if stale + logger.warn "Lock file #{lock_file} is stale (age: #{(file_age / 60).round(1)} minutes)" + else + logger.debug "Lock file #{lock_file} age: #{(file_age / 60).round(1)} minutes" + end + + stale + end +end + map "/rails-master-hook" do run ->(env) do request_method = env["REQUEST_METHOD"] @@ -47,6 +66,13 @@ end map "/" do run ->(_env) do - [200, {"Content-Type" => "text/plain", "Content-Length" => "4"}, ["PONG"]] + # Check if lockfile is stale (older than 2 hours) + if LockfileChecker.stale?(lock_file, logger) + error_msg = "System down: Lock file has been present for more than 2 hours" + logger.error error_msg + [503, {"Content-Type" => "text/plain", "Content-Length" => error_msg.length.to_s}, [error_msg]] + else + [200, {"Content-Type" => "text/plain", "Content-Length" => "4"}, ["PONG"]] + end end end diff --git a/config/deploy/production.rb b/config/deploy/production.rb index 0859050..44be457 100644 --- a/config/deploy/production.rb +++ b/config/deploy/production.rb @@ -8,6 +8,7 @@ set :puma_service_unit_env_vars, %w[ RUN_FILE=/home/rails/rails-master-hook/run-rails-master-hook + LOCK_FILE=/home/rails/rails-master-hook/lock-rails-master-hook ] set :puma_access_log, "journal" set :puma_error_log, "journal" From 30f2752abd42efc6af0c6a25a8fdc2b796c9e89c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafael=20Mendon=C3=A7a=20Fran=C3=A7a?= Date: Wed, 22 Oct 2025 03:10:44 +0000 Subject: [PATCH 2/3] Organize the rack application in its own class Also add tests for this app. --- Gemfile | 5 ++ Gemfile.lock | 5 ++ README.md | 9 ++ Rakefile | 12 +++ config.ru | 69 +--------------- lib/lockfile_checker.rb | 38 +++++++++ lib/rails_master_hook_app.rb | 70 ++++++++++++++++ test/integration_test.rb | 29 +++++++ test/lockfile_checker_test.rb | 112 +++++++++++++++++++++++++ test/rails_master_hook_test.rb | 146 +++++++++++++++++++++++++++++++++ test/test_helper.rb | 50 +++++++++++ 11 files changed, 480 insertions(+), 65 deletions(-) create mode 100644 Rakefile create mode 100644 lib/lockfile_checker.rb create mode 100644 lib/rails_master_hook_app.rb create mode 100644 test/integration_test.rb create mode 100644 test/lockfile_checker_test.rb create mode 100644 test/rails_master_hook_test.rb create mode 100644 test/test_helper.rb diff --git a/Gemfile b/Gemfile index ed7deba..f2f3d6c 100644 --- a/Gemfile +++ b/Gemfile @@ -5,3 +5,8 @@ gem "puma", "~> 6.0" gem "capistrano3-puma" gem "capistrano-rvm" + +group :test do + gem "minitest", "~> 5.0" + gem "rack-test", "~> 2.0" +end diff --git a/Gemfile.lock b/Gemfile.lock index 024b300..07e0a61 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -22,6 +22,7 @@ GEM i18n (1.14.7) concurrent-ruby (~> 1.0) logger (1.7.0) + minitest (5.26.0) net-scp (4.1.0) net-ssh (>= 2.6.5, < 8.0.0) net-sftp (4.0.0) @@ -32,6 +33,8 @@ GEM puma (6.6.1) nio4r (~> 2.0) rack (2.2.20) + rack-test (2.2.0) + rack (>= 1.3) rake (13.3.0) sshkit (1.24.0) base64 @@ -47,8 +50,10 @@ PLATFORMS DEPENDENCIES capistrano-rvm capistrano3-puma + minitest (~> 5.0) puma (~> 6.0) rack (~> 2.2) + rack-test (~> 2.0) BUNDLED WITH 2.5.22 diff --git a/README.md b/README.md index 887f074..5fc4214 100644 --- a/README.md +++ b/README.md @@ -9,3 +9,12 @@ Server management in encapsulated in the script `bin/server`: bin/server stop This webhook just touches a file meaning "we have been called". The docs server is responsible for monitoring the presence of said file somehow, and act accordingly. + +## Testing + +The application includes a comprehensive test suite using minitest and rack-test: + +```bash +# Run all tests +bundle exec rake test +``` diff --git a/Rakefile b/Rakefile new file mode 100644 index 0000000..b93452a --- /dev/null +++ b/Rakefile @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +require "rake/testtask" + +Rake::TestTask.new(:test) do |t| + t.libs << "test" + t.libs << "." + t.test_files = FileList["test/**/*_test.rb"] + t.verbose = true +end + +task default: :test diff --git a/config.ru b/config.ru index bfadcf2..5146d25 100644 --- a/config.ru +++ b/config.ru @@ -1,11 +1,11 @@ # frozen_string_literal: true -require "fileutils" -require "rack" require "logger" +require "rack" +require_relative "lib/rails_master_hook_app" # Setup logger - log to STDOUT for systemd -logger = Logger.new(STDOUT) +logger = Logger.new($stdout) logger.level = ENV["LOG_LEVEL"] ? Logger.const_get(ENV["LOG_LEVEL"].upcase) : Logger::INFO logger.formatter = proc do |severity, datetime, progname, msg| "#{severity}: #{msg}\n" @@ -14,65 +14,4 @@ end # Use Rack::CommonLogger for HTTP request logging use Rack::CommonLogger, logger -run_file = ENV["RUN_FILE"] || "#{__dir__}/run-rails-master-hook" -lock_file = ENV["LOCK_FILE"] -scheduled = < 7200 # 2 hours in seconds - - if stale - logger.warn "Lock file #{lock_file} is stale (age: #{(file_age / 60).round(1)} minutes)" - else - logger.debug "Lock file #{lock_file} age: #{(file_age / 60).round(1)} minutes" - end - - stale - end -end - -map "/rails-master-hook" do - run ->(env) do - request_method = env["REQUEST_METHOD"] - - if request_method == "POST" - logger.info "Triggering Rails master hook by touching #{run_file}" - FileUtils.touch(run_file) - logger.info "Rails master hook scheduled successfully" - [200, {"Content-Type" => "text/plain", "Content-Length" => scheduled.length.to_s}, [scheduled]] - else - logger.warn "Rejected non-POST request (#{request_method}) to /rails-master-hook" - [404, {"Content-Type" => "text/plain", "Content-Length" => "0"}, []] - end - end -end - -map "/" do - run ->(_env) do - # Check if lockfile is stale (older than 2 hours) - if LockfileChecker.stale?(lock_file, logger) - error_msg = "System down: Lock file has been present for more than 2 hours" - logger.error error_msg - [503, {"Content-Type" => "text/plain", "Content-Length" => error_msg.length.to_s}, [error_msg]] - else - [200, {"Content-Type" => "text/plain", "Content-Length" => "4"}, ["PONG"]] - end - end -end +run RailsMasterHookApp.new(logger: logger) diff --git a/lib/lockfile_checker.rb b/lib/lockfile_checker.rb new file mode 100644 index 0000000..d273ebe --- /dev/null +++ b/lib/lockfile_checker.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +# Utility class for checking if lock files are stale +# +# A lock file is considered stale if it's older than 2 hours, +# which indicates a potentially stuck or long-running process. +class LockfileChecker + # @param lock_file [String, nil] Path to the lock file to check + def initialize(lock_file) + @file_age_seconds = calculate_file_age(lock_file) + end + + # Check if the lock file is stale (older than 2 hours) + # + # @return [Boolean] true if the lock file is stale, false otherwise + def stale? + return false if @file_age_seconds.nil? + + @file_age_seconds > 7200 # 2 hours in seconds + end + + # Get the age of the lock file in minutes + # + # @return [Float, nil] Age in minutes, or nil if file doesn't exist + def age_in_minutes + return nil if @file_age_seconds.nil? + + (@file_age_seconds / 60).round(1) + end + + private + + def calculate_file_age(lock_file) + return nil unless lock_file && File.exist?(lock_file) + + Time.now - File.mtime(lock_file) + end +end diff --git a/lib/rails_master_hook_app.rb b/lib/rails_master_hook_app.rb new file mode 100644 index 0000000..634b2b4 --- /dev/null +++ b/lib/rails_master_hook_app.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +# Application wrapper for testing +# This extracts the core application logic without loading config.ru + +require "fileutils" +require "rack" +require "logger" +require_relative "lockfile_checker" + +class RailsMasterHookApp + def initialize(run_file: nil, lock_file: nil, logger:) + @run_file = run_file || ENV["RUN_FILE"] || File.expand_path("../run-rails-master-hook", __dir__) + @lock_file = lock_file || ENV["LOCK_FILE"] + @logger = logger + end + + def call(env) + request = Rack::Request.new(env) + + # Handle rails-master-hook routes (with or without trailing slash) + if request.path_info == "/rails-master-hook" || request.path_info == "/rails-master-hook/" + handle_rails_master_hook(request) + else + handle_root(request) + end + end + + private + + def handle_rails_master_hook(request) + if request.request_method == "POST" + @logger.info "Triggering Rails master hook by touching #{@run_file}" + FileUtils.touch(@run_file) + @logger.info "Rails master hook scheduled successfully" + + scheduled = <<~EOS + Rails master hook tasks scheduled: + + * updates the local checkout + * updates Rails Contributors + * generates and publishes edge docs + + If a new stable tag is detected it also + + * generates and publishes stable docs + + This needs typically a few minutes. + EOS + + [200, {"Content-Type" => "text/plain", "Content-Length" => scheduled.length.to_s}, [scheduled]] + else + @logger.warn "Rejected non-POST request (#{request.request_method}) to /rails-master-hook" + [404, {"Content-Type" => "text/plain", "Content-Length" => "0"}, []] + end + end + + def handle_root(request) + lockfile_checker = LockfileChecker.new(@lock_file) + + if lockfile_checker.stale? + age_minutes = lockfile_checker.age_in_minutes + error_msg = "System down: Lock file has been present for more than 2 hours" + @logger.error "#{error_msg} (actual age: #{age_minutes} minutes)" + [503, {"Content-Type" => "text/plain", "Content-Length" => error_msg.length.to_s}, [error_msg]] + else + [200, {"Content-Type" => "text/plain", "Content-Length" => "4"}, ["PONG"]] + end + end +end diff --git a/test/integration_test.rb b/test/integration_test.rb new file mode 100644 index 0000000..0cfce60 --- /dev/null +++ b/test/integration_test.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +require_relative "test_helper" + +class IntegrationTest < TestCase + def test_config_ru_app_behaves_correctly + app, _options = Rack::Builder.parse_file(File.expand_path("../config.ru", __dir__)) + + env = Rack::MockRequest.env_for("/") + status, headers, body = app.call(env) + + assert_equal 200, status + assert_equal "PONG", body.first + assert_equal "text/plain", headers["Content-Type"] + end + + def test_config_ru_rails_master_hook_endpoint + capture_io do + app, _options = Rack::Builder.parse_file(File.expand_path("../config.ru", __dir__)) + + env = Rack::MockRequest.env_for("/rails-master-hook", method: "POST") + status, headers, body = app.call(env) + + assert_equal 200, status + assert_match(/Rails master hook tasks scheduled/, body.first) + assert_equal "text/plain", headers["Content-Type"] + end + end +end diff --git a/test/lockfile_checker_test.rb b/test/lockfile_checker_test.rb new file mode 100644 index 0000000..d3dce5f --- /dev/null +++ b/test/lockfile_checker_test.rb @@ -0,0 +1,112 @@ +# frozen_string_literal: true + +require_relative "test_helper" + +class LockfileCheckerTest < TestCase + def test_stale_returns_false_when_no_lock_file + non_existent_file = File.join(@temp_dir, "non_existent") + checker = LockfileChecker.new(non_existent_file) + + refute checker.stale? + end + + def test_stale_returns_false_when_lock_file_is_nil + checker = LockfileChecker.new(nil) + + refute checker.stale? + end + + def test_stale_returns_false_when_lock_file_is_fresh + create_lock_file # Fresh file (0 seconds old) + checker = LockfileChecker.new(test_lock_file) + + refute checker.stale? + end + + def test_stale_returns_false_when_lock_file_is_under_threshold + create_lock_file(3600) # 1 hour old (under 2 hour threshold) + checker = LockfileChecker.new(test_lock_file) + + refute checker.stale? + end + + def test_stale_returns_false_when_lock_file_is_just_at_threshold + create_lock_file(7199) # Just under 2 hours old + checker = LockfileChecker.new(test_lock_file) + + refute checker.stale? + end + + def test_stale_returns_true_when_lock_file_is_over_threshold + create_lock_file(7201) # Just over 2 hours old + checker = LockfileChecker.new(test_lock_file) + + assert checker.stale? + end + + def test_stale_returns_true_when_lock_file_is_very_old + create_lock_file(86400) # 24 hours old + checker = LockfileChecker.new(test_lock_file) + + assert checker.stale? + end + + def test_age_in_minutes_returns_nil_when_file_does_not_exist + non_existent_file = File.join(@temp_dir, "non_existent") + checker = LockfileChecker.new(non_existent_file) + + assert_nil checker.age_in_minutes + end + + def test_age_in_minutes_returns_nil_when_file_is_nil + checker = LockfileChecker.new(nil) + + assert_nil checker.age_in_minutes + end + + def test_age_in_minutes_returns_correct_age_for_fresh_file + create_lock_file # Fresh file (0 seconds old) + checker = LockfileChecker.new(test_lock_file) + + age = checker.age_in_minutes + assert age >= 0 + assert age < 1 # Should be less than 1 minute + end + + def test_age_in_minutes_returns_correct_age_for_old_file + create_lock_file(5400) # 90 minutes old + checker = LockfileChecker.new(test_lock_file) + + age = checker.age_in_minutes + assert_equal 90.0, age + end + + def test_age_in_minutes_handles_fractional_minutes + create_lock_file(1830) # 30.5 minutes old + checker = LockfileChecker.new(test_lock_file) + + age = checker.age_in_minutes + assert_equal 30.5, age + end + + def test_age_remains_consistent_between_calls + create_lock_file(7201) # Just over 2 hours old (stale) + checker = LockfileChecker.new(test_lock_file) + + # First calls + first_stale_result = checker.stale? + first_age = checker.age_in_minutes + + # Sleep a bit to ensure time has passed + sleep 0.01 + + # Second calls - should return exactly the same values + second_stale_result = checker.stale? + second_age = checker.age_in_minutes + + assert_equal first_stale_result, second_stale_result + assert_equal first_age, second_age + assert first_stale_result # Should be stale (over threshold) + assert_equal 120.0, first_age # Should be approximately 120 minutes + end +end diff --git a/test/rails_master_hook_test.rb b/test/rails_master_hook_test.rb new file mode 100644 index 0000000..5a790e6 --- /dev/null +++ b/test/rails_master_hook_test.rb @@ -0,0 +1,146 @@ +# frozen_string_literal: true + +require_relative "test_helper" + +class RailsMasterHookTest < TestCase + def test_root_path_returns_pong_when_no_lock_file + get "/" + + assert_equal 200, last_response.status + assert_equal "PONG", last_response.body + assert_equal "text/plain", last_response.content_type + assert_equal "4", last_response.headers["Content-Length"] + end + + def test_root_path_returns_pong_when_lock_file_is_fresh + create_lock_file # Fresh lock file (0 seconds old) + + get "/" + + assert_equal 200, last_response.status + assert_equal "PONG", last_response.body + assert_equal "text/plain", last_response.content_type + end + + def test_root_path_returns_error_when_lock_file_is_stale + create_lock_file(7300) # 2+ hours old (stale) + + get "/" + + assert_equal 503, last_response.status + assert_equal "System down: Lock file has been present for more than 2 hours", last_response.body + assert_equal "text/plain", last_response.content_type + log_output = log_content.string + assert_match(/actual age: 121\.7 minutes/, log_output) + assert_match(/System down: Lock file has been present for more than 2 hours/, log_output) + end + + def test_root_path_returns_pong_when_lock_file_is_just_under_threshold + create_lock_file(7100) # Just under 2 hours (not stale) + + get "/" + + assert_equal 200, last_response.status + assert_equal "PONG", last_response.body + end + + def test_rails_master_hook_post_creates_run_file + refute run_file_exists?, "Run file should not exist initially" + + post "/rails-master-hook" + + assert_equal 200, last_response.status + assert run_file_exists?, "Run file should be created after POST" + assert_match(/Rails master hook tasks scheduled/, last_response.body) + assert_equal "text/plain", last_response.content_type + end + + def test_rails_master_hook_post_touches_existing_run_file + FileUtils.touch(test_run_file) + original_mtime = File.mtime(test_run_file) + + # Sleep to ensure time difference + sleep 0.1 + + post "/rails-master-hook" + + assert_equal 200, last_response.status + new_mtime = File.mtime(test_run_file) + assert new_mtime > original_mtime, "Run file should be touched (mtime updated)" + end + + def test_rails_master_hook_post_response_content + post "/rails-master-hook" + + assert_equal 200, last_response.status + + expected_content = <<~EOS + Rails master hook tasks scheduled: + + * updates the local checkout + * updates Rails Contributors + * generates and publishes edge docs + + If a new stable tag is detected it also + + * generates and publishes stable docs + + This needs typically a few minutes. + EOS + + assert_equal expected_content, last_response.body + assert_equal expected_content.length.to_s, last_response.headers["Content-Length"] + end + + def test_rails_master_hook_get_returns_404 + get "/rails-master-hook" + + assert_equal 404, last_response.status + assert_equal "", last_response.body + assert_equal "0", last_response.headers["Content-Length"] + end + + def test_rails_master_hook_put_returns_404 + put "/rails-master-hook" + + assert_equal 404, last_response.status + assert_equal "", last_response.body + end + + def test_rails_master_hook_delete_returns_404 + delete "/rails-master-hook" + + assert_equal 404, last_response.status + assert_equal "", last_response.body + end + + def test_unknown_path_returns_pong + get "/unknown-path" + + # Unknown paths are handled by the root mapper, so they return PONG + assert_equal 200, last_response.status + assert_equal "PONG", last_response.body + end + + def test_nested_unknown_path_returns_pong + get "/some/nested/path" + + # Unknown paths are handled by the root mapper, so they return PONG + assert_equal 200, last_response.status + assert_equal "PONG", last_response.body + end + + def test_rails_master_hook_with_trailing_slash + post "/rails-master-hook/" + + # Rack routing matches /rails-master-hook/ to /rails-master-hook + assert_equal 200, last_response.status + assert_match(/Rails master hook tasks scheduled/, last_response.body) + end + + private + + def run_file_exists? + File.exist?(test_run_file) + end +end diff --git a/test/test_helper.rb b/test/test_helper.rb new file mode 100644 index 0000000..1598aaf --- /dev/null +++ b/test/test_helper.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +require "minitest/autorun" +require "rack/test" +require "rack/lint" +require "tmpdir" +require "stringio" + +require_relative "../lib/rails_master_hook_app" + +class TestCase < Minitest::Test + include Rack::Test::Methods + + attr_reader :test_run_file, :test_lock_file, :log_content + + def app + @app ||= begin + test_logger = Logger.new(@log_content) + test_logger.level = Logger::ERROR + + rails_app = RailsMasterHookApp.new( + run_file: test_run_file, + lock_file: test_lock_file, + logger: test_logger + ) + + Rack::Lint.new(rails_app) + end + end + + def setup + @temp_dir = Dir.mktmpdir("rails-master-hook-test") + @test_run_file = File.join(@temp_dir, "run-rails-master-hook") + @test_lock_file = File.join(@temp_dir, "lock") + @log_content = StringIO.new + end + + def teardown + FileUtils.rm_rf(@temp_dir) if @temp_dir + end + + def create_lock_file(age_in_seconds = 0) + FileUtils.touch(test_lock_file) + # Set the file's modification time to simulate age + if age_in_seconds > 0 + past_time = Time.now - age_in_seconds + File.utime(past_time, past_time, test_lock_file) + end + end +end From 95b9ef9a21c090d3d4fa3fa1d95fd6315eac48f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafael=20Mendon=C3=A7a=20Fran=C3=A7a?= Date: Wed, 22 Oct 2025 03:19:52 +0000 Subject: [PATCH 3/3] Configure CI --- .github/workflows/ci.yml | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..a23ace1 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,21 @@ +name: CI + +on: + push: + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Ruby from .ruby-version + uses: ruby/setup-ruby@v1 + with: + bundler-cache: true + + - name: Run tests + run: bundle exec rake test