Skip to content

Commit a0835a1

Browse files
committed
Harden rolling deploy staging diagnostics
1 parent 3ad7272 commit a0835a1

4 files changed

Lines changed: 81 additions & 17 deletions

File tree

react_on_rails/lib/react_on_rails/doctor.rb

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -750,9 +750,7 @@ def auto_fix_versions
750750

751751
report_sync_changes(result)
752752
report_skipped_specs(result)
753-
if result.changes.any?
754-
checker.add_info(" ℹ️ FIX=true only updates package.json; update Gemfile constraints manually if needed.")
755-
end
753+
checker.add_info(" ℹ️ FIX=true only updates package.json; update Gemfile constraints manually if needed.")
756754
rescue StandardError => e
757755
checker.add_warning(" ⚠️ FIX=true: Could not auto-sync versions: #{e.message}")
758756
end
@@ -2917,8 +2915,8 @@ def report_resolved_cache_dir
29172915
cache_dir = ReactOnRailsPro::Utils.resolve_renderer_cache_dir
29182916
if File.directory?(cache_dir)
29192917
temp_dir_pattern = rolling_deploy_temp_dir_pattern
2920-
subdirs = Dir.children(cache_dir).select do |c|
2921-
File.directory?(File.join(cache_dir, c)) && !c.match?(temp_dir_pattern)
2918+
subdirs = Dir.children(cache_dir).select do |entry|
2919+
File.directory?(File.join(cache_dir, entry)) && !entry.match?(temp_dir_pattern)
29222920
end
29232921
checker.add_info("ℹ️ Resolved renderer cache dir: #{cache_dir} (#{subdirs.length} bundle-hash subdir(s))")
29242922
else

react_on_rails/spec/lib/react_on_rails/doctor_spec.rb

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2530,6 +2530,33 @@ def self.upload(_hash, **_opts); end
25302530
end
25312531
end
25322532

2533+
context "when renderer cache contains rolling-deploy temporary directories" do
2534+
let(:cache_dir) { Dir.mktmpdir }
2535+
let(:adapter) do
2536+
Module.new do
2537+
def self.previous_bundle_hashes = %w[abc]
2538+
def self.fetch(_hash); end
2539+
def self.upload(_hash, **_opts); end
2540+
end
2541+
end
2542+
2543+
before do
2544+
FileUtils.mkdir_p(File.join(cache_dir, "abc"))
2545+
FileUtils.mkdir_p(File.join(cache_dir, "abc.staging-1234-deadbeef12"))
2546+
FileUtils.mkdir_p(File.join(cache_dir, "abc.previous-1234-feedface12"))
2547+
cache_dir_value = cache_dir
2548+
ReactOnRailsPro::Utils.define_singleton_method(:resolve_renderer_cache_dir) { cache_dir_value }
2549+
end
2550+
2551+
after { FileUtils.rm_rf(cache_dir) }
2552+
2553+
it "excludes temporary directories from the bundle-hash count" do
2554+
doctor.send(:check_rolling_deploy_adapter)
2555+
info = checker.messages.select { |m| m[:type] == :info }
2556+
expect(info.map { |m| m[:content] }).to include(a_string_matching(/\(1 bundle-hash subdir\(s\)\)/))
2557+
end
2558+
end
2559+
25332560
context "when adapter is missing required methods" do
25342561
let(:adapter) do
25352562
Module.new do

react_on_rails_pro/lib/react_on_rails_pro/rolling_deploy_cache_stager.rb

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -356,6 +356,7 @@ def self.replace_bundle_directory(staging_dir, bundle_dir)
356356
end
357357

358358
FileUtils.mv(staging_dir, bundle_dir)
359+
puts "[ReactOnRailsPro] Staged previous bundle hash into #{bundle_dir}"
359360
remove_previous_bundle_backup(backup_dir)
360361
rescue StandardError
361362
restore_previous_bundle_directory(backup_dir, bundle_dir)
@@ -377,13 +378,15 @@ def self.restore_previous_bundle_directory(backup_dir, bundle_dir)
377378
return unless backup_dir && File.exist?(backup_dir)
378379

379380
FileUtils.rm_rf(bundle_dir)
380-
# The `unless File.exist?` guard catches the narrow TOCTOU window where
381-
# another writer recreates `bundle_dir` between the rm_rf and the mv.
382-
# We deliberately skip the mv in that case rather than overwriting that
383-
# writer's work — the runtime 410-retry path is still a valid fallback,
384-
# and `backup_dir` will be swept by `sweep_stale_temporary_directories`
385-
# on the next run.
386-
FileUtils.mv(backup_dir, bundle_dir) unless File.exist?(bundle_dir)
381+
# Catch the narrow TOCTOU window where another writer recreates
382+
# bundle_dir between rm_rf and mv; warn through the rescue path instead
383+
# of overwriting that writer's work.
384+
if File.exist?(bundle_dir)
385+
raise ReactOnRailsPro::Error,
386+
"Cannot restore previous rolling-deploy bundle directory because #{bundle_dir} already exists."
387+
end
388+
389+
FileUtils.mv(backup_dir, bundle_dir)
387390
rescue StandardError => e
388391
warn "[ReactOnRailsPro] Could not restore previous rolling-deploy bundle directory #{backup_dir} " \
389392
"to #{bundle_dir}: #{e.class}: #{e.message}. Runtime 410-retry remains the fallback."

react_on_rails_pro/spec/dummy/spec/rolling_deploy_cache_stager_spec.rb

Lines changed: 41 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -61,9 +61,11 @@ def source_file(name, contents: "// #{name}")
6161
end
6262

6363
it "copies bundle + assets into <cache>/<hash>/ in :copy mode" do
64-
described_class.call(cache_dir: cache_dir, current_hashes: [], mode: :copy)
65-
6664
bundle_dir = File.join(cache_dir, "abc123")
65+
promoted_bundle_dir = File.join(File.realpath(cache_dir), "abc123")
66+
expect { described_class.call(cache_dir: cache_dir, current_hashes: [], mode: :copy) }
67+
.to output(/Staged previous bundle hash into #{Regexp.escape(promoted_bundle_dir)}/).to_stdout
68+
6769
expect(File.exist?(File.join(bundle_dir, "abc123.js"))).to be(true)
6870
expect(File.exist?(File.join(bundle_dir, "loadable-stats.json"))).to be(true)
6971
expect(File.symlink?(File.join(bundle_dir, "abc123.js"))).to be(false)
@@ -378,6 +380,36 @@ def source_file(name, contents: "// #{name}")
378380
end
379381
end
380382

383+
context "when restore finds the bundle directory recreated by another process" do
384+
let(:src_bundle) { source_file("bundle-new.js", contents: "// new bundle") }
385+
let(:src_asset) { source_file("loadable-stats.json", contents: "{}") }
386+
let(:bundle_dir) { File.join(cache_dir, "abc123") }
387+
let(:existing_bundle) { File.join(bundle_dir, "abc123.js") }
388+
389+
before do
390+
FileUtils.mkdir_p(bundle_dir)
391+
File.write(existing_bundle, "// existing bundle")
392+
allow(adapter).to receive_messages(previous_bundle_hashes: ["abc123"])
393+
allow(adapter).to receive(:fetch).with("abc123").and_return(bundle: src_bundle, assets: [src_asset])
394+
allow(FileUtils).to receive(:mv).and_wrap_original do |original, source, destination, *args|
395+
raise Errno::EIO, "promotion failed" if source.to_s.include?("abc123.staging-")
396+
397+
original.call(source, destination, *args)
398+
end
399+
allow(FileUtils).to receive(:rm_rf).and_wrap_original do |original, path, *args|
400+
original.call(path, *args)
401+
FileUtils.mkdir_p(bundle_dir) if path.to_s.end_with?("/abc123")
402+
end
403+
end
404+
405+
it "warns instead of silently skipping backup restore" do
406+
expect { described_class.call(cache_dir: cache_dir, current_hashes: [], mode: :copy) }
407+
.to output(/Could not restore previous rolling-deploy bundle directory/).to_stderr
408+
409+
expect(Dir.children(cache_dir).grep(/abc123\.previous/)).not_to be_empty
410+
end
411+
end
412+
381413
context "when refreshing an existing seeded hash cannot remove the old backup after promotion" do
382414
let(:src_bundle) { source_file("bundle-new.js", contents: "// new bundle") }
383415
let(:src_asset) { source_file("loadable-stats.json", contents: "{}") }
@@ -405,25 +437,29 @@ def source_file(name, contents: "// #{name}")
405437
end
406438

407439
context "when stale temporary bundle directories are present" do
408-
let(:stale_staging_dir) { File.join(cache_dir, "abc123.staging-1234-deadbeef") }
409-
let(:fresh_previous_dir) { File.join(cache_dir, "abc123.previous-1234-feedface") }
440+
let(:stale_staging_dir) { File.join(cache_dir, "abc123.staging-1234-deadbeef12") }
441+
let(:fresh_previous_dir) { File.join(cache_dir, "abc123.previous-1234-feedface12") }
442+
let(:hash_like_dir) { File.join(cache_dir, "release.staging-123-deadbeef") }
410443

411444
before do
412445
stub_const("ReactOnRailsPro::RollingDeployCacheStager::STALE_TEMP_DIR_TTL_SECONDS", 60)
413446
allow(adapter).to receive_messages(previous_bundle_hashes: [])
414447
FileUtils.mkdir_p(stale_staging_dir)
415448
FileUtils.mkdir_p(fresh_previous_dir)
449+
FileUtils.mkdir_p(hash_like_dir)
416450
old_time = Time.now - 120
417451
File.utime(old_time, old_time, stale_staging_dir)
452+
File.utime(old_time, old_time, hash_like_dir)
418453
end
419454

420-
it "removes stale temp directories and keeps fresh ones" do
455+
it "removes stale temp directories while preserving hash-like names outside the temp pattern" do
421456
expect { described_class.call(cache_dir: cache_dir, current_hashes: [], mode: :copy) }
422457
.to output(/Removed stale rolling-deploy temp directory/).to_stderr
423458
.and output(/No previous bundle hashes/).to_stdout
424459

425460
expect(File.exist?(stale_staging_dir)).to be(false)
426461
expect(File.exist?(fresh_previous_dir)).to be(true)
462+
expect(File.exist?(hash_like_dir)).to be(true)
427463
end
428464

429465
it "does not match real bundle hash dirs that look superficially similar" do

0 commit comments

Comments
 (0)