Skip to content

Commit 30f2752

Browse files
committed
Organize the rack application in its own class
Also add tests for this app.
1 parent 20ae548 commit 30f2752

File tree

11 files changed

+480
-65
lines changed

11 files changed

+480
-65
lines changed

Gemfile

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,8 @@ gem "puma", "~> 6.0"
55

66
gem "capistrano3-puma"
77
gem "capistrano-rvm"
8+
9+
group :test do
10+
gem "minitest", "~> 5.0"
11+
gem "rack-test", "~> 2.0"
12+
end

Gemfile.lock

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ GEM
2222
i18n (1.14.7)
2323
concurrent-ruby (~> 1.0)
2424
logger (1.7.0)
25+
minitest (5.26.0)
2526
net-scp (4.1.0)
2627
net-ssh (>= 2.6.5, < 8.0.0)
2728
net-sftp (4.0.0)
@@ -32,6 +33,8 @@ GEM
3233
puma (6.6.1)
3334
nio4r (~> 2.0)
3435
rack (2.2.20)
36+
rack-test (2.2.0)
37+
rack (>= 1.3)
3538
rake (13.3.0)
3639
sshkit (1.24.0)
3740
base64
@@ -47,8 +50,10 @@ PLATFORMS
4750
DEPENDENCIES
4851
capistrano-rvm
4952
capistrano3-puma
53+
minitest (~> 5.0)
5054
puma (~> 6.0)
5155
rack (~> 2.2)
56+
rack-test (~> 2.0)
5257

5358
BUNDLED WITH
5459
2.5.22

README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,12 @@ Server management in encapsulated in the script `bin/server`:
99
bin/server stop
1010

1111
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.
12+
13+
## Testing
14+
15+
The application includes a comprehensive test suite using minitest and rack-test:
16+
17+
```bash
18+
# Run all tests
19+
bundle exec rake test
20+
```

Rakefile

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
# frozen_string_literal: true
2+
3+
require "rake/testtask"
4+
5+
Rake::TestTask.new(:test) do |t|
6+
t.libs << "test"
7+
t.libs << "."
8+
t.test_files = FileList["test/**/*_test.rb"]
9+
t.verbose = true
10+
end
11+
12+
task default: :test

config.ru

Lines changed: 4 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
# frozen_string_literal: true
22

3-
require "fileutils"
4-
require "rack"
53
require "logger"
4+
require "rack"
5+
require_relative "lib/rails_master_hook_app"
66

77
# Setup logger - log to STDOUT for systemd
8-
logger = Logger.new(STDOUT)
8+
logger = Logger.new($stdout)
99
logger.level = ENV["LOG_LEVEL"] ? Logger.const_get(ENV["LOG_LEVEL"].upcase) : Logger::INFO
1010
logger.formatter = proc do |severity, datetime, progname, msg|
1111
"#{severity}: #{msg}\n"
@@ -14,65 +14,4 @@ end
1414
# Use Rack::CommonLogger for HTTP request logging
1515
use Rack::CommonLogger, logger
1616

17-
run_file = ENV["RUN_FILE"] || "#{__dir__}/run-rails-master-hook"
18-
lock_file = ENV["LOCK_FILE"]
19-
scheduled = <<EOS
20-
Rails master hook tasks scheduled:
21-
22-
* updates the local checkout
23-
* updates Rails Contributors
24-
* generates and publishes edge docs
25-
26-
If a new stable tag is detected it also
27-
28-
* generates and publishes stable docs
29-
30-
This needs typically a few minutes.
31-
EOS
32-
33-
# Helper class for lockfile checking
34-
class LockfileChecker
35-
def self.stale?(lock_file, logger)
36-
return false unless lock_file && File.exist?(lock_file)
37-
38-
file_age = Time.now - File.mtime(lock_file)
39-
stale = file_age > 7200 # 2 hours in seconds
40-
41-
if stale
42-
logger.warn "Lock file #{lock_file} is stale (age: #{(file_age / 60).round(1)} minutes)"
43-
else
44-
logger.debug "Lock file #{lock_file} age: #{(file_age / 60).round(1)} minutes"
45-
end
46-
47-
stale
48-
end
49-
end
50-
51-
map "/rails-master-hook" do
52-
run ->(env) do
53-
request_method = env["REQUEST_METHOD"]
54-
55-
if request_method == "POST"
56-
logger.info "Triggering Rails master hook by touching #{run_file}"
57-
FileUtils.touch(run_file)
58-
logger.info "Rails master hook scheduled successfully"
59-
[200, {"Content-Type" => "text/plain", "Content-Length" => scheduled.length.to_s}, [scheduled]]
60-
else
61-
logger.warn "Rejected non-POST request (#{request_method}) to /rails-master-hook"
62-
[404, {"Content-Type" => "text/plain", "Content-Length" => "0"}, []]
63-
end
64-
end
65-
end
66-
67-
map "/" do
68-
run ->(_env) do
69-
# Check if lockfile is stale (older than 2 hours)
70-
if LockfileChecker.stale?(lock_file, logger)
71-
error_msg = "System down: Lock file has been present for more than 2 hours"
72-
logger.error error_msg
73-
[503, {"Content-Type" => "text/plain", "Content-Length" => error_msg.length.to_s}, [error_msg]]
74-
else
75-
[200, {"Content-Type" => "text/plain", "Content-Length" => "4"}, ["PONG"]]
76-
end
77-
end
78-
end
17+
run RailsMasterHookApp.new(logger: logger)

lib/lockfile_checker.rb

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
# frozen_string_literal: true
2+
3+
# Utility class for checking if lock files are stale
4+
#
5+
# A lock file is considered stale if it's older than 2 hours,
6+
# which indicates a potentially stuck or long-running process.
7+
class LockfileChecker
8+
# @param lock_file [String, nil] Path to the lock file to check
9+
def initialize(lock_file)
10+
@file_age_seconds = calculate_file_age(lock_file)
11+
end
12+
13+
# Check if the lock file is stale (older than 2 hours)
14+
#
15+
# @return [Boolean] true if the lock file is stale, false otherwise
16+
def stale?
17+
return false if @file_age_seconds.nil?
18+
19+
@file_age_seconds > 7200 # 2 hours in seconds
20+
end
21+
22+
# Get the age of the lock file in minutes
23+
#
24+
# @return [Float, nil] Age in minutes, or nil if file doesn't exist
25+
def age_in_minutes
26+
return nil if @file_age_seconds.nil?
27+
28+
(@file_age_seconds / 60).round(1)
29+
end
30+
31+
private
32+
33+
def calculate_file_age(lock_file)
34+
return nil unless lock_file && File.exist?(lock_file)
35+
36+
Time.now - File.mtime(lock_file)
37+
end
38+
end

lib/rails_master_hook_app.rb

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
# frozen_string_literal: true
2+
3+
# Application wrapper for testing
4+
# This extracts the core application logic without loading config.ru
5+
6+
require "fileutils"
7+
require "rack"
8+
require "logger"
9+
require_relative "lockfile_checker"
10+
11+
class RailsMasterHookApp
12+
def initialize(run_file: nil, lock_file: nil, logger:)
13+
@run_file = run_file || ENV["RUN_FILE"] || File.expand_path("../run-rails-master-hook", __dir__)
14+
@lock_file = lock_file || ENV["LOCK_FILE"]
15+
@logger = logger
16+
end
17+
18+
def call(env)
19+
request = Rack::Request.new(env)
20+
21+
# Handle rails-master-hook routes (with or without trailing slash)
22+
if request.path_info == "/rails-master-hook" || request.path_info == "/rails-master-hook/"
23+
handle_rails_master_hook(request)
24+
else
25+
handle_root(request)
26+
end
27+
end
28+
29+
private
30+
31+
def handle_rails_master_hook(request)
32+
if request.request_method == "POST"
33+
@logger.info "Triggering Rails master hook by touching #{@run_file}"
34+
FileUtils.touch(@run_file)
35+
@logger.info "Rails master hook scheduled successfully"
36+
37+
scheduled = <<~EOS
38+
Rails master hook tasks scheduled:
39+
40+
* updates the local checkout
41+
* updates Rails Contributors
42+
* generates and publishes edge docs
43+
44+
If a new stable tag is detected it also
45+
46+
* generates and publishes stable docs
47+
48+
This needs typically a few minutes.
49+
EOS
50+
51+
[200, {"Content-Type" => "text/plain", "Content-Length" => scheduled.length.to_s}, [scheduled]]
52+
else
53+
@logger.warn "Rejected non-POST request (#{request.request_method}) to /rails-master-hook"
54+
[404, {"Content-Type" => "text/plain", "Content-Length" => "0"}, []]
55+
end
56+
end
57+
58+
def handle_root(request)
59+
lockfile_checker = LockfileChecker.new(@lock_file)
60+
61+
if lockfile_checker.stale?
62+
age_minutes = lockfile_checker.age_in_minutes
63+
error_msg = "System down: Lock file has been present for more than 2 hours"
64+
@logger.error "#{error_msg} (actual age: #{age_minutes} minutes)"
65+
[503, {"Content-Type" => "text/plain", "Content-Length" => error_msg.length.to_s}, [error_msg]]
66+
else
67+
[200, {"Content-Type" => "text/plain", "Content-Length" => "4"}, ["PONG"]]
68+
end
69+
end
70+
end

test/integration_test.rb

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
# frozen_string_literal: true
2+
3+
require_relative "test_helper"
4+
5+
class IntegrationTest < TestCase
6+
def test_config_ru_app_behaves_correctly
7+
app, _options = Rack::Builder.parse_file(File.expand_path("../config.ru", __dir__))
8+
9+
env = Rack::MockRequest.env_for("/")
10+
status, headers, body = app.call(env)
11+
12+
assert_equal 200, status
13+
assert_equal "PONG", body.first
14+
assert_equal "text/plain", headers["Content-Type"]
15+
end
16+
17+
def test_config_ru_rails_master_hook_endpoint
18+
capture_io do
19+
app, _options = Rack::Builder.parse_file(File.expand_path("../config.ru", __dir__))
20+
21+
env = Rack::MockRequest.env_for("/rails-master-hook", method: "POST")
22+
status, headers, body = app.call(env)
23+
24+
assert_equal 200, status
25+
assert_match(/Rails master hook tasks scheduled/, body.first)
26+
assert_equal "text/plain", headers["Content-Type"]
27+
end
28+
end
29+
end

test/lockfile_checker_test.rb

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
# frozen_string_literal: true
2+
3+
require_relative "test_helper"
4+
5+
class LockfileCheckerTest < TestCase
6+
def test_stale_returns_false_when_no_lock_file
7+
non_existent_file = File.join(@temp_dir, "non_existent")
8+
checker = LockfileChecker.new(non_existent_file)
9+
10+
refute checker.stale?
11+
end
12+
13+
def test_stale_returns_false_when_lock_file_is_nil
14+
checker = LockfileChecker.new(nil)
15+
16+
refute checker.stale?
17+
end
18+
19+
def test_stale_returns_false_when_lock_file_is_fresh
20+
create_lock_file # Fresh file (0 seconds old)
21+
checker = LockfileChecker.new(test_lock_file)
22+
23+
refute checker.stale?
24+
end
25+
26+
def test_stale_returns_false_when_lock_file_is_under_threshold
27+
create_lock_file(3600) # 1 hour old (under 2 hour threshold)
28+
checker = LockfileChecker.new(test_lock_file)
29+
30+
refute checker.stale?
31+
end
32+
33+
def test_stale_returns_false_when_lock_file_is_just_at_threshold
34+
create_lock_file(7199) # Just under 2 hours old
35+
checker = LockfileChecker.new(test_lock_file)
36+
37+
refute checker.stale?
38+
end
39+
40+
def test_stale_returns_true_when_lock_file_is_over_threshold
41+
create_lock_file(7201) # Just over 2 hours old
42+
checker = LockfileChecker.new(test_lock_file)
43+
44+
assert checker.stale?
45+
end
46+
47+
def test_stale_returns_true_when_lock_file_is_very_old
48+
create_lock_file(86400) # 24 hours old
49+
checker = LockfileChecker.new(test_lock_file)
50+
51+
assert checker.stale?
52+
end
53+
54+
def test_age_in_minutes_returns_nil_when_file_does_not_exist
55+
non_existent_file = File.join(@temp_dir, "non_existent")
56+
checker = LockfileChecker.new(non_existent_file)
57+
58+
assert_nil checker.age_in_minutes
59+
end
60+
61+
def test_age_in_minutes_returns_nil_when_file_is_nil
62+
checker = LockfileChecker.new(nil)
63+
64+
assert_nil checker.age_in_minutes
65+
end
66+
67+
def test_age_in_minutes_returns_correct_age_for_fresh_file
68+
create_lock_file # Fresh file (0 seconds old)
69+
checker = LockfileChecker.new(test_lock_file)
70+
71+
age = checker.age_in_minutes
72+
assert age >= 0
73+
assert age < 1 # Should be less than 1 minute
74+
end
75+
76+
def test_age_in_minutes_returns_correct_age_for_old_file
77+
create_lock_file(5400) # 90 minutes old
78+
checker = LockfileChecker.new(test_lock_file)
79+
80+
age = checker.age_in_minutes
81+
assert_equal 90.0, age
82+
end
83+
84+
def test_age_in_minutes_handles_fractional_minutes
85+
create_lock_file(1830) # 30.5 minutes old
86+
checker = LockfileChecker.new(test_lock_file)
87+
88+
age = checker.age_in_minutes
89+
assert_equal 30.5, age
90+
end
91+
92+
def test_age_remains_consistent_between_calls
93+
create_lock_file(7201) # Just over 2 hours old (stale)
94+
checker = LockfileChecker.new(test_lock_file)
95+
96+
# First calls
97+
first_stale_result = checker.stale?
98+
first_age = checker.age_in_minutes
99+
100+
# Sleep a bit to ensure time has passed
101+
sleep 0.01
102+
103+
# Second calls - should return exactly the same values
104+
second_stale_result = checker.stale?
105+
second_age = checker.age_in_minutes
106+
107+
assert_equal first_stale_result, second_stale_result
108+
assert_equal first_age, second_age
109+
assert first_stale_result # Should be stale (over threshold)
110+
assert_equal 120.0, first_age # Should be approximately 120 minutes
111+
end
112+
end

0 commit comments

Comments
 (0)