Files
Sojan Jose 81fc35e9e6 chore: upgrade Rails to 7.2.3.1 (#13437)
This upgrades Chatwoot to Rails 7.2.3.1 while retaining the current
Rails 7.0 framework defaults, so the runtime upgrade can be deployed and
observed independently from default-behavior changes.

## What changed

- Upgrade Rails and the compatible dependency set to Rails 7.2.3.1.
- Keep `config.load_defaults 7.0` for a staged, lower-risk rollout.
- Replace the unmaintained Azure Active Storage fork with the maintained
`azure-blob` adapter while preserving the `microsoft` service name.
- Pin Sidekiq 7.3.10 with `connection_pool` 2.x after validating
scheduled-job execution against Redis.
- Update Rails 7.2 compatibility surfaces in Active Record, strong
parameters, migrations, storage, and tests.
- Add read-only production preflight checks, an opt-in Active Storage
smoke script, a deployment runbook, and the full Rails 7.2/8.0/8.1
assessment.

## How to test

1. Sign in and verify the dashboard and conversation UI load normally.
2. Open the agent-management modal and confirm agent data is rendered.
3. Create an API inbox and wait for the asynchronous deletion flow to
complete.
4. Open Super Admin pages, including instance status and account-user
management.
5. Upload and download an attachment using the configured Active Storage
service.
6. Confirm recurring Sidekiq Cron jobs register and execute after
startup.

## Rollout

Follow `docs/rails_upgrades/7_2.md` for pre-deploy checks, deployment
order, smoke tests, monitoring, and rollback. Run `bundle exec rails
runner script/rails_upgrade/preflight.rb` against a
production-equivalent environment before rollout.

## Tracking

- [CW-5863 — Upgrade Rails to
8+](https://linear.app/chatwoot/issue/CW-5863/upgrade-rails-to-8)
- [Rails 7.2 to 8.1 upgrade and production rollout
plan](https://linear.app/chatwoot/document/chatwoot-rails-72-to-81-upgrade-and-production-rollout-plan-44e9f4964cb2)

---------

Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
Co-authored-by: Sony Mathew <2040199+sony-mathew@users.noreply.github.com>
Co-authored-by: Sony Mathew <sony@chatwoot.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2026-08-10 15:49:38 +05:30

206 lines
6.5 KiB
Ruby

# frozen_string_literal: true
require 'json'
module RailsUpgrade
end
class RailsUpgrade::Preflight
def initialize
@failures = 0
end
def run
Rails.application.eager_load!
check_version
check_framework_defaults
check_background_job_dependencies
check_migrations
check_required_columns
check_installation_configs
check_encrypted_attributes
check_active_storage
check_selected_blob
report('summary', @failures.zero?, failures: @failures)
exit 1 unless @failures.zero?
end
private
def check_version
expected = ENV.fetch('EXPECTED_RAILS_VERSION')
check('rails.version', expected: expected, actual: Rails.version) { Rails.version == expected }
end
def check_framework_defaults
expected = ENV.fetch('EXPECTED_CONFIG_DEFAULTS', '7.0')
actual = Rails.application.config.loaded_config_version.to_s
check('rails.framework_defaults', expected: expected, actual: actual) { actual == expected }
end
def check_background_job_dependencies
expected_sidekiq = ENV.fetch('EXPECTED_SIDEKIQ_VERSION', '7.3.10')
connection_pool = Gem.loaded_specs.fetch('connection_pool').version
check('sidekiq.version', expected: expected_sidekiq, actual: Sidekiq::VERSION) { expected_sidekiq == Sidekiq::VERSION }
check('sidekiq.connection_pool', actual: connection_pool.to_s, constraint: '< 3') { connection_pool < Gem::Version.new('3') }
end
def check_migrations
context = ActiveRecord::MigrationContext.new(ActiveRecord::Migrator.migrations_paths)
check('database.pending_migrations') { !context.needs_migration? }
end
def check_required_columns
required_columns = {
'active_storage_blobs' => %w[service_name checksum],
'active_storage_variant_records' => %w[blob_id variation_digest],
'users' => %w[otp_secret otp_backup_codes]
}
missing = required_columns.flat_map do |table, columns|
existing = ActiveRecord::Base.connection.columns(table).map(&:name)
columns.reject { |column| existing.include?(column) }.map { |column| "#{table}.#{column}" }
end
check('database.required_columns', missing: missing) { missing.empty? }
end
def check_installation_configs
scanned = 0
failures = []
InstallationConfig.unscoped.find_each do |configuration|
configuration.serialized_value
scanned += 1
rescue StandardError => e
failures << failure_reference(configuration, e)
end
check('data.installation_configs', scanned: scanned, failure_samples: failures.first(10)) { failures.empty? }
end
def check_encrypted_attributes
max_rows = Integer(ENV.fetch('PREFLIGHT_MAX_ENCRYPTED_ROWS', '0'), 10)
checks = encrypted_attribute_checks
check_encryption_keys(checks)
checks.each { |entry| check_encrypted_attribute(entry, max_rows) }
end
def check_encryption_keys(checks)
encrypted_rows_exist = checks.any? { |entry| entry[:available].positive? }
configured = Chatwoot.encryption_configured?
check('encryption.keys', configured: configured, encrypted_rows_exist: encrypted_rows_exist) do
!encrypted_rows_exist || configured
end
end
def check_encrypted_attribute(entry, max_rows)
model = entry.fetch(:model)
attribute = entry.fetch(:attribute)
failures = []
scanned = scan_encrypted_attribute(model, attribute, max_rows, failures)
check(
"encryption.#{model.name}.#{attribute}",
available: entry.fetch(:available),
scanned: scanned,
limited: max_rows.positive?,
failure_samples: failures.first(10)
) { failures.empty? }
end
def scan_encrypted_attribute(model, attribute, max_rows, failures)
relation = model.unscoped.where.not(attribute => nil)
relation = relation.limit(max_rows) if max_rows.positive?
scanned = 0
relation.find_each(batch_size: 500) do |record|
record.public_send(attribute)
scanned += 1
rescue StandardError => e
failures << failure_reference(record, e)
end
scanned
end
def encrypted_attribute_checks
checks = encrypted_models.flat_map do |model|
model.encrypted_attributes.filter_map { |attribute| encrypted_attribute_entry(model, attribute) }
end
checks.sort_by { |entry| [entry.fetch(:model).name, entry.fetch(:attribute).to_s] }
end
def encrypted_models
ActiveRecord::Base.descendants.select do |model|
attributes = model.encrypted_attributes if model.respond_to?(:encrypted_attributes)
!model.abstract_class? && model.base_class == model && attributes.present? && model.table_exists?
rescue ActiveRecord::StatementInvalid
false
end
end
def encrypted_attribute_entry(model, attribute)
return unless model.column_names.include?(attribute.to_s)
{ model: model, attribute: attribute, available: model.unscoped.where.not(attribute => nil).count }
end
def check_active_storage
configurations = Rails.configuration.active_storage.service_configurations.deep_stringify_keys
configured_names = configurations.keys
blob_service_names = ActiveStorage::Blob.unscoped.distinct.pluck(:service_name).compact
unknown_names = blob_service_names - configured_names
expected_azure_service = ENV.fetch('EXPECTED_AZURE_SERVICE', 'AzureBlob')
actual_azure_service = configurations.dig('microsoft', 'service')
check(
'active_storage.service_names',
configured: configured_names.sort,
persisted: blob_service_names.sort,
unknown: unknown_names.sort
) { unknown_names.empty? }
check(
'active_storage.azure_adapter',
expected: expected_azure_service,
actual: actual_azure_service
) { actual_azure_service == expected_azure_service }
end
def check_selected_blob
blob_id = ENV.fetch('ACTIVE_STORAGE_CHECK_BLOB_ID', nil)
return report('active_storage.selected_blob', true, skipped: true) if blob_id.blank?
blob = ActiveStorage::Blob.find(blob_id)
check(
'active_storage.selected_blob',
blob_id: blob.id,
service_name: blob.service_name
) { blob.service.exist?(blob.key) }
rescue StandardError => e
report('active_storage.selected_blob', false, error_class: e.class.name)
end
def failure_reference(record, error)
{ id: record.id, error_class: error.class.name }
end
def check(name, details = {})
report(name, yield, details)
rescue StandardError => e
report(name, false, details.merge(error_class: e.class.name))
end
def report(name, passed, details = {})
@failures += 1 unless passed
puts({ check: name, status: passed ? 'PASS' : 'FAIL' }.merge(details).to_json)
end
end
RailsUpgrade::Preflight.new.run