Files
fizzy/script/migrate-to-multi-tenant
T
2025-04-01 12:14:58 -04:00

290 lines
8.8 KiB
Ruby
Executable File

#!/usr/bin/env ruby
require_relative "../config/environment"
class EnactTenanting
CURRENT_DOMAIN = "https://fizzy.37signals.com"
TENANTED_DOMAIN = Rails.env.local? ? "http://%{tenant}.fizzy.localhost:3006" : "https://%{tenant}.fizzy.37signals.com"
PROJECT_MAPPING = {
693169841 => "37s", # Writebook improvements
693169850 => "37s", # Fizzy: Issues
693169853 => "37s", # Fizzy: 💡 IDEAS
693169858 => "37s", # Know It All: 💡 IDEAS
693169859 => "37s", # Cycle 1: BC4 Client access for templates
693169860 => "37s", # Know It All: Issues
693169862 => "37s", # Active Record Tenanting
693169863 => "37s", # Cycle 1: BC4 Hilltop View
693169865 => "37s", # [BC iOS] Search refactor
693169867 => "37s", # File icons refactor
693169870 => "37s", # HEY Preload Replies
693169872 => "37s", # [BC4 Android] Search Refactor
693169873 => "37s", # BC4
693169874 => "37s", # [HEY] ContentFilter pipeline rewrite
693169875 => "37s", # [Hey Calendar Android] Search
693169876 => "37s", # [HEY Calendar iOS] Search
693169877 => "37s", # [BC4] Turbo for comment and answer forms
693169842 => "dev", # Foobar
693169843 => "dev", # Test
693169851 => "dev", # Kevin
693169861 => "dev", # Testing space
693169869 => "dev", # Flower ideas
693169871 => "dev", # Mike's TODOs
693169856 => "qa", # QA Exploration
}
TENANTS = PROJECT_MAPPING.values.uniq
attr_reader :creation_counter, :update_counter
def initialize
@creation_counter = {}
@update_counter = {}
end
def up
setup
destroy_tenants
safety_check
copy_accounts_et_al
copy_buckets_et_al
copy_bubbles_et_al
copy_filters
copy_active_storage
cross_check
update_action_text_urls
pp ["updated:", update_counter]
hardlink_active_storage
end
def setup
ApplicationRecord.connects_to shards: { untenanted: { reading: :primary_original } }
end
def with_original_db(&block)
ApplicationRecord.connected_to(role: :reading, shard: :untenanted, &block)
end
def safety_check
buckets = with_original_db { Bucket.all.to_a }
unless buckets.map(&:id).sort == PROJECT_MAPPING.keys.sort
unknown_buckets = buckets.map(&:id) - PROJECT_MAPPING.keys
missing_buckets = PROJECT_MAPPING.keys - buckets.map(&:id)
raise "Surprising buckets. unknown #{unknown_buckets.inspect}, missing #{missing_buckets.inspect}"
end
end
def copy_accounts_et_al
account = with_original_db { Account.first }
users = with_original_db { User.all.to_a }
workflows = with_original_db { account.workflows.to_a }
workflow_stages = with_original_db { workflows.flat_map(&:stages) }
tags = with_original_db { account.tags.to_a }
TENANTS.each do |tenant|
ApplicationRecord.with_tenant(tenant) do
Account.create! name: tenant, id: account.id
upsert_all users
upsert_all workflows
upsert_all workflow_stages
upsert_all tags
end
end
end
def copy_buckets_et_al
PROJECT_MAPPING.each do |bucket_id, tenant|
bucket = with_original_db { Bucket.where(id: bucket_id).first }
accesses = with_original_db { bucket.accesses.to_a }
subscriptions = with_original_db { bucket.subscriptions.to_a }
ApplicationRecord.with_tenant(tenant) do
upsert_all bucket
upsert_all accesses
upsert_all subscriptions
end
end
end
def copy_bubbles_et_al
PROJECT_MAPPING.each do |bucket_id, tenant|
bubbles = with_original_db { Bucket.find(bucket_id).bubbles.to_a }
assignments = with_original_db { bubbles.flat_map(&:assignments) }
pops = with_original_db { bubbles.filter_map(&:pop) }
notifications = with_original_db { bubbles.flat_map(&:notifications) }
events = with_original_db { bubbles.flat_map(&:events) }
taggings = with_original_db { bubbles.flat_map(&:taggings) }
watches = with_original_db { bubbles.flat_map(&:watches) }
messages = with_original_db { bubbles.flat_map(&:messages) }
messageables = with_original_db { messages.filter_map(&:messageable) }
comments = messageables.select { _1.is_a?(Comment) }
markdowns = with_original_db { comments.map(&:markdown_body) }
reactions = with_original_db { comments.flat_map(&:reactions) }
ApplicationRecord.with_tenant(tenant) do
upsert_all bubbles
upsert_all assignments
upsert_all pops
upsert_all taggings
upsert_all watches
messageables.group_by(&:class).each do |klass, klass_messageables|
upsert_all klass_messageables
end
upsert_all markdowns
upsert_all reactions
upsert_all messages
upsert_all events
upsert_all notifications
end
end
end
def copy_filters
creation_counter["Filter"] = 0
TENANTS.each do |tenant|
buckets = ApplicationRecord.with_tenant(tenant) { Bucket.all.to_a }
filters = with_original_db do
Filter.all.filter_map do |filter| # hah no pun intended
next unless filter.buckets.empty? || buckets.any? { |b| filter.buckets.include?(b) }
filter.attributes.merge(filter.as_params)
end
end
ApplicationRecord.with_tenant(tenant) do
filters.each do |attr|
Filter.create! attr
end
end
creation_counter["Filter"] += filters.length
end
end
def copy_active_storage
attachments = with_original_db { ActiveStorage::Attachment.all.to_a }
blobs = with_original_db { ActiveStorage::Blob.all.to_a }
TENANTS.each do |tenant|
ApplicationRecord.with_tenant(tenant) do
upsert_all blobs
upsert_all attachments
end
end
end
def destroy_tenants
TENANTS.each do |tenant|
ApplicationRecord.destroy_tenant(tenant)
end
FileUtils.rm_rf "storage/tenants"
end
def cross_check
pp ["created:", creation_counter]
with_original_db do
assert_count User, User.count * TENANTS.length
assert_count Workflow, Workflow.count * TENANTS.length
assert_count Workflow::Stage, Workflow::Stage.count * TENANTS.length
assert_count Tag, Tag.count * TENANTS.length
assert_count Bucket, Bucket.count
assert_count Access, Access.count
assert_count Subscription, Subscription.count
assert_count Bubble, Bubble.count
assert_count Assignment, Assignment.count
assert_count Pop, Pop.count
assert_count EventSummary, EventSummary.count
assert_count Comment, Comment.count
assert_count Reaction, Reaction.count
assert_count Message, Message.count
assert_count Tagging, Tagging.count
assert_count Watch, Watch.count
# we're only copying the markdown records that are still accessible.
assert_count ActionText::Markdown, Comment.count
assert_count Event, Event.count
assert_count Notification, Notification.count
assert_count ActiveStorage::Attachment, ActiveStorage::Attachment.count * TENANTS.length
assert_count ActiveStorage::Blob, ActiveStorage::Blob.count * TENANTS.length
raise "Filter count is off" unless creation_counter["Filter"] >= Filter.count
end
end
def update_action_text_urls
update_counter["ActionText::Markdown"] = 0
TENANTS.each do |tenant|
tenanted_domain = sprintf(TENANTED_DOMAIN, tenant: tenant)
ApplicationRecord.with_tenant(tenant) do
ActionText::Markdown.all.each do |markdown|
content = markdown.content
next unless content =~ %r(#{CURRENT_DOMAIN}/u/)
content.gsub!(%r(#{CURRENT_DOMAIN}/u/), "#{tenanted_domain}/u/")
markdown.update_column :content, content
update_counter["ActionText::Markdown"] += 1
end
end
end
end
def hardlink_active_storage
files = Dir.glob("storage/files/*/*/*").map { _1.split("/")[2..].join("/") }
TENANTS.each do |tenant|
ApplicationRecord.with_tenant(tenant) do
destdir = ActiveStorage::Blob.service.root
files.each do |file|
FileUtils.mkdir_p File.join(destdir, File.dirname(file))
FileUtils.ln File.join("storage/files", file), File.join(destdir, file)
end
end
end
end
def upsert_all(originals)
originals = Array(originals)
return if originals.empty?
klass = originals.first.class
result = klass.upsert_all(originals.collect(&:attributes))
raise "Error upserting" unless result.rows.length == originals.length
creation_counter[klass.name] ||= 0
creation_counter[klass.name] += originals.length
nil
end
def assert_count(klass, expected)
actual = creation_counter[klass.name]
unless actual == expected
raise "#{klass} count is off: expected #{expected}, got #{actual}"
end
end
end
EnactTenanting.new.up
exit 0