From 257c0902f47fba36bc1a0978adbffeed23ce301f Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Fri, 30 May 2025 17:35:25 +0200 Subject: [PATCH 01/25] Patch for cards missing description The problem was that after redirecting back with morphing, the autosave mechanism would trigger an autosave call with an empty content. This is a first patch. The editor shouldn't be triggering these "change" events in the first place when loading the editor. --- app/javascript/controllers/auto_save_controller.js | 4 ++++ app/views/cards/container/_title.html.erb | 3 ++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/app/javascript/controllers/auto_save_controller.js b/app/javascript/controllers/auto_save_controller.js index 0323ab9ad..a99c08192 100644 --- a/app/javascript/controllers/auto_save_controller.js +++ b/app/javascript/controllers/auto_save_controller.js @@ -26,6 +26,10 @@ export default class extends Controller { } } + reset() { + this.#timer = null + } + // Private #scheduleSave() { diff --git a/app/views/cards/container/_title.html.erb b/app/views/cards/container/_title.html.erb index 1767fd854..673814b74 100644 --- a/app/views/cards/container/_title.html.erb +++ b/app/views/cards/container/_title.html.erb @@ -10,7 +10,8 @@ <% end %> <% else %> - <%= form_with model: card, url: collection_card_path(card.collection, card), id: "card_form", class: "card__content", data: { controller: "autoresize auto-save" } do |form| %> + <%= form_with model: card, url: collection_card_path(card.collection, card), id: "card_form", class: "card__content", data: { + controller: "autoresize auto-save", action: "turbo:morph@window->auto-save#reset" } do |form| %>

<%= form.text_area :title, placeholder: "Name it…", class: "input input--textarea full-width borderless txt-align-start autoresize__textarea", From 68906321e8e8f32cd7ac957af61b8721bbf3b83f Mon Sep 17 00:00:00 2001 From: Andy Smith Date: Fri, 30 May 2025 13:11:36 -0500 Subject: [PATCH 02/25] Card shadow tweaks --- app/assets/stylesheets/_global.css | 12 +++++------- app/assets/stylesheets/card-columns.css | 17 ++++++++++++++++- app/assets/stylesheets/cards.css | 21 +++++---------------- app/views/cards/display/_preview.html.erb | 2 +- 4 files changed, 27 insertions(+), 25 deletions(-) diff --git a/app/assets/stylesheets/_global.css b/app/assets/stylesheets/_global.css index 5b2a966af..6861d229e 100644 --- a/app/assets/stylesheets/_global.css +++ b/app/assets/stylesheets/_global.css @@ -23,12 +23,10 @@ --border: 1px solid var(--color-ink-lighter); /* Shadows */ - --shadow: 0 0 0 1px oklch(var(--lch-black) / 0.02), - 0 .2em 1.6em -0.8em oklch(var(--lch-black) / 0.2), - 0 .4em 2.4em -1em oklch(var(--lch-black) / 0.3), - 0 .4em .8em -1.2em oklch(var(--lch-black) / 0.4), - 0 .8em 1.2em -1.6em oklch(var(--lch-black) / 0.5), - 0 1.2em 1.6em -2em oklch(var(--lch-black) / 0.6); + --shadow: 0 0 0 1px oklch(var(--lch-black) / 5%), + 0 0.2em 0.2em oklch(var(--lch-black) / 5%), + 0 0.4em 0.4em oklch(var(--lch-black) / 5%), + 0 0.8em 0.8em oklch(var(--lch-black) / 5%); /* Components */ --btn-size: 2.65em; @@ -186,7 +184,7 @@ --color-code-token__punctuation: oklch(var(--lch-ink-dark)); --color-code-token__selector: oklch(var(--lch-green-dark)); --color-code-token__variable: oklch(var(--lch-red-dark)); - + @media (prefers-color-scheme: dark) { --lch-canvas: 20% 0.0195 232.58; diff --git a/app/assets/stylesheets/card-columns.css b/app/assets/stylesheets/card-columns.css index 108e7fd19..692efdf5d 100644 --- a/app/assets/stylesheets/card-columns.css +++ b/app/assets/stylesheets/card-columns.css @@ -101,7 +101,6 @@ --text-small: 1.1em; background-color: var(--color-canvas); - box-shadow: 0 0 0 1px var(--color-ink-lighter); line-height: 1.2; .avatar { @@ -122,6 +121,22 @@ display: none; } + .card__collection { + background-color: var(--color-canvas); + color: color-mix(in srgb, var(--card-color) 40%, var(--color-ink)); + padding: 0 0 0 var(--inline-space); + } + + .card__collection-name { + border-inline-start: 1px solid color-mix(in srgb, var(--color-ink) 33%, var(--color-canvas)); + color: color-mix(in srgb, var(--card-color) 40%, var(--color-ink)); + } + + .card__header { + color: color-mix(in srgb, var(--card-color) 40%, var(--color-ink)); + margin: 0 0 calc(-0.5 * var(--card-padding-inline)) 0; + } + .card__title { margin-block-start: 0.1em; min-block-size: 0; diff --git a/app/assets/stylesheets/cards.css b/app/assets/stylesheets/cards.css index 53315b8a6..bb74bc503 100644 --- a/app/assets/stylesheets/cards.css +++ b/app/assets/stylesheets/cards.css @@ -25,6 +25,10 @@ .avatar { --avatar-size: 2.75em; } + + @media (prefers-color-scheme: dark) { + box-shadow: 0 0 0 1px var(--color-ink-lighter); + } } /* Children @@ -69,11 +73,6 @@ margin-block-start: calc(-1 * var(--card-padding-block)); margin-inline-start: calc(-1 * var(--card-padding-inline)); min-inline-size: 0; - - .cards--considering & { - color: color-mix(in srgb, var(--card-color) 40%, var(--color-ink)); - margin: 0 0 calc(-0.5 * var(--card-padding-inline)) 0; - } } .card__collection { @@ -83,12 +82,6 @@ font-weight: 800; padding: var(--block-space-half) var(--inline-space) var(--block-space-half) var(--inline-space-double); text-transform: uppercase; - - .cards--considering & { - background-color: var(--color-canvas); - color: color-mix(in srgb, var(--card-color) 40%, var(--color-ink)); - padding: 0 0 0 var(--inline-space); - } } .card__id { @@ -107,11 +100,6 @@ display: inline-flex; margin-inline-start: var(--inline-space-half); padding-inline-start: calc(var(--inline-space) * 0.75); - - .cards--considering & { - border-inline-start: 1px solid color-mix(in srgb, var(--color-ink) 33%, var(--color-canvas)); - color: color-mix(in srgb, var(--card-color) 40%, var(--color-ink)); - } } .card__tags { @@ -211,6 +199,7 @@ box-shadow: 0 0 0 1px color-mix(in srgb, var(--color-link) 20%, var(--color-canvas)); color: var(--btn-color); font-weight: 700; + transition: none; } .card__move-button { diff --git a/app/views/cards/display/_preview.html.erb b/app/views/cards/display/_preview.html.erb index 4bdccb6bb..eadeadaba 100644 --- a/app/views/cards/display/_preview.html.erb +++ b/app/views/cards/display/_preview.html.erb @@ -6,7 +6,7 @@ <%= card.id %> <%= link_to card.collection.name, cards_path(collection_ids: [ card.collection ]), - class: "card__collection-name txt-uppercase overflow-ellipsis" %> + class: "card__collection-name overflow-ellipsis" %> <%= render "cards/display/preview/tags", card: card %> From febab0758aa0253a2c25c5e94d07875170b7216d Mon Sep 17 00:00:00 2001 From: Mike Dalessio Date: Wed, 28 May 2025 12:56:17 -0400 Subject: [PATCH 03/25] Drop the unnecessary initializer creating storage paths --- config/initializers/storage_paths.rb | 5 ----- 1 file changed, 5 deletions(-) delete mode 100644 config/initializers/storage_paths.rb diff --git a/config/initializers/storage_paths.rb b/config/initializers/storage_paths.rb deleted file mode 100644 index 497a6d466..000000000 --- a/config/initializers/storage_paths.rb +++ /dev/null @@ -1,5 +0,0 @@ -Rails.application.config.after_initialize do - %w[ db files ].each do |dir| - Rails.root.join("storage", dir).mkpath - end -end From bd44d0558fbfdfed42e12d0b3196f34f215dd93e Mon Sep 17 00:00:00 2001 From: Mike Dalessio Date: Wed, 28 May 2025 14:45:11 -0400 Subject: [PATCH 04/25] Delete old multitenancy migration script --- script/migrate-to-multi-tenant | 271 --------------------------------- 1 file changed, 271 deletions(-) delete mode 100755 script/migrate-to-multi-tenant diff --git a/script/migrate-to-multi-tenant b/script/migrate-to-multi-tenant deleted file mode 100755 index 79db5c0e0..000000000 --- a/script/migrate-to-multi-tenant +++ /dev/null @@ -1,271 +0,0 @@ -#!/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 - - 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 - raise "No more markdown" - 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 From a17b024681abd0e3cea2f0ed730369c12f775266 Mon Sep 17 00:00:00 2001 From: Mike Dalessio Date: Fri, 30 May 2025 14:56:58 -0400 Subject: [PATCH 05/25] Update AR::Tenanted to have tenanted blob keys Also, a script to migrate blobs (on disk and in the database) to the new convention. ref: https://37s.fizzy.37signals.com/collections/7/cards/246 --- Gemfile | 1 + Gemfile.lock | 22 +++++++++++++++++++++- script/migrate-disk-service-blobs.rb | 28 ++++++++++++++++++++++++++++ 3 files changed, 50 insertions(+), 1 deletion(-) create mode 100755 script/migrate-disk-service-blobs.rb diff --git a/Gemfile b/Gemfile index 8c1219952..457f0790b 100644 --- a/Gemfile +++ b/Gemfile @@ -30,6 +30,7 @@ gem "jbuilder" gem "actiontext-lexical", bc: "actiontext-lexical" gem "image_processing", "~> 1.14" gem "platform_agent" +gem "aws-sdk-s3", require: false # Telemetry and logging gem "sentry-ruby" diff --git a/Gemfile.lock b/Gemfile.lock index 5fcb8b1ef..8afd7ccc9 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -7,7 +7,7 @@ GIT GIT remote: https://github.com/basecamp/active_record-tenanted - revision: 523947a0f0f49f66a8a62eb93e9ba1a345b3076b + revision: f444578d6e59a1bdd65eda0ff765c9b5dac73398 specs: active_record-tenanted (0.1.0) activerecord (>= 8.1.alpha) @@ -144,6 +144,24 @@ GEM addressable (2.8.7) public_suffix (>= 2.0.2, < 7.0) ast (2.4.3) + aws-eventstream (1.3.2) + aws-partitions (1.1108.0) + aws-sdk-core (3.224.1) + aws-eventstream (~> 1, >= 1.3.0) + aws-partitions (~> 1, >= 1.992.0) + aws-sigv4 (~> 1.9) + base64 + jmespath (~> 1, >= 1.6.1) + logger + aws-sdk-kms (1.101.0) + aws-sdk-core (~> 3, >= 3.216.0) + aws-sigv4 (~> 1.5) + aws-sdk-s3 (1.188.0) + aws-sdk-core (~> 3, >= 3.224.1) + aws-sdk-kms (~> 1) + aws-sigv4 (~> 1.5) + aws-sigv4 (1.11.0) + aws-eventstream (~> 1, >= 1.0.2) base64 (0.2.0) bcrypt (3.1.20) bcrypt_pbkdf (1.1.1) @@ -225,6 +243,7 @@ GEM jbuilder (2.13.0) actionview (>= 5.0.0) activesupport (>= 5.0.0) + jmespath (1.6.2) json (2.11.3) kamal (2.6.1) activesupport (>= 7.0) @@ -470,6 +489,7 @@ PLATFORMS DEPENDENCIES actiontext-lexical! active_record-tenanted! + aws-sdk-s3 bcrypt (~> 3.1.7) bootsnap brakeman diff --git a/script/migrate-disk-service-blobs.rb b/script/migrate-disk-service-blobs.rb new file mode 100755 index 000000000..540787239 --- /dev/null +++ b/script/migrate-disk-service-blobs.rb @@ -0,0 +1,28 @@ +#! /usr/bin/env ruby + +require_relative "../config/environment" + +ApplicationRecord.with_each_tenant do |tenant| + puts "\n## #{tenant}" + report = { updated: 0, skipped: 0 } + + ActiveStorage::Blob.find_each do |blob| + if blob.key.start_with?("#{tenant}/") + report[:skipped] += 1 + else + blob.update_column :key, "#{tenant}/#{blob.key}" + report[:updated] += 1 + end + end + pp report + + disk_service = ActiveStorage::Blob.services.fetch(:local) + new_root = File.join(disk_service.root, tenant) + old_root = File.join("storage", "tenants", Rails.env, tenant, "files") + + FileUtils.mkdir_p(new_root, verbose: true) unless File.directory?(new_root) + + Dir.glob(File.join(old_root, "??")).each_slice(20) do |blob_dirs| + FileUtils.mv(blob_dirs, new_root, verbose: true) + end +end From 1c85d8f2707f9681bec65d64d702405e38748457 Mon Sep 17 00:00:00 2001 From: Mike Dalessio Date: Fri, 30 May 2025 17:44:02 -0400 Subject: [PATCH 06/25] Add a kamal "ssh" alias --- config/deploy.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/config/deploy.yml b/config/deploy.yml index 39ffd195a..aa2f04862 100644 --- a/config/deploy.yml +++ b/config/deploy.yml @@ -28,3 +28,4 @@ env: aliases: console: app exec -i --reuse "bin/rails console" + ssh: app exec -i --reuse /bin/bash From 5d662f1ec9a9c033a93a0f3dc8039fbd8c7675dc Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Sat, 31 May 2025 12:30:04 +0200 Subject: [PATCH 07/25] Revert "Patch for cards missing description" This reverts commit 257c0902f47fba36bc1a0978adbffeed23ce301f. --- app/javascript/controllers/auto_save_controller.js | 4 ---- app/views/cards/container/_title.html.erb | 3 +-- 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/app/javascript/controllers/auto_save_controller.js b/app/javascript/controllers/auto_save_controller.js index a99c08192..0323ab9ad 100644 --- a/app/javascript/controllers/auto_save_controller.js +++ b/app/javascript/controllers/auto_save_controller.js @@ -26,10 +26,6 @@ export default class extends Controller { } } - reset() { - this.#timer = null - } - // Private #scheduleSave() { diff --git a/app/views/cards/container/_title.html.erb b/app/views/cards/container/_title.html.erb index 673814b74..1767fd854 100644 --- a/app/views/cards/container/_title.html.erb +++ b/app/views/cards/container/_title.html.erb @@ -10,8 +10,7 @@ <% end %> <% else %> - <%= form_with model: card, url: collection_card_path(card.collection, card), id: "card_form", class: "card__content", data: { - controller: "autoresize auto-save", action: "turbo:morph@window->auto-save#reset" } do |form| %> + <%= form_with model: card, url: collection_card_path(card.collection, card), id: "card_form", class: "card__content", data: { controller: "autoresize auto-save" } do |form| %>

<%= form.text_area :title, placeholder: "Name it…", class: "input input--textarea full-width borderless txt-align-start autoresize__textarea", From c3a9ac7391640525ae1d5b3b0030498da94ca92b Mon Sep 17 00:00:00 2001 From: Mike Dalessio Date: Sat, 31 May 2025 10:55:25 -0400 Subject: [PATCH 08/25] Create a distinct "beta" environment and move secrets into Rails credentials for both beta and production environments. --- .kamal/secrets.beta | 5 ++--- .kamal/secrets.dhh | 4 ++-- .kamal/secrets.production | 5 ++--- config/cable.yml | 5 ++++- config/cache.yml | 5 ++++- config/credentials.yml.enc | 1 - config/credentials/beta.yml.enc | 1 + config/credentials/production.yml.enc | 1 + config/database.yml | 5 ++++- config/deploy.beta.yml | 4 +++- config/deploy.production.yml | 3 +++ config/deploy.yml | 3 +-- config/environments/beta.rb | 5 +++++ config/queue.yml | 5 ++++- config/recurring.yml | 5 ++++- 15 files changed, 40 insertions(+), 17 deletions(-) delete mode 100644 config/credentials.yml.enc create mode 100644 config/credentials/beta.yml.enc create mode 100644 config/credentials/production.yml.enc create mode 100644 config/environments/beta.rb diff --git a/.kamal/secrets.beta b/.kamal/secrets.beta index ef220785d..60d911ced 100644 --- a/.kamal/secrets.beta +++ b/.kamal/secrets.beta @@ -1,6 +1,5 @@ -SECRETS=$(kamal secrets fetch --adapter 1password --account basecamp --from Deploy/Fizzy Deployments/BASECAMP_REGISTRY_PASSWORD Beta/SECRET_KEY_BASE Beta/OPEN_AI_API_KEY) +SECRETS=$(kamal secrets fetch --adapter 1password --account basecamp --from Deploy/Fizzy Deployments/BASECAMP_REGISTRY_PASSWORD Beta/RAILS_MASTER_KEY) GITHUB_TOKEN=$(gh config get -h github.com oauth_token) BASECAMP_REGISTRY_PASSWORD=$(kamal secrets extract BASECAMP_REGISTRY_PASSWORD $SECRETS) -SECRET_KEY_BASE=$(kamal secrets extract SECRET_KEY_BASE $SECRETS) -OPEN_AI_API_KEY=$(kamal secrets extract OPEN_AI_API_KEY $SECRETS) +RAILS_MASTER_KEY=$(kamal secrets extract RAILS_MASTER_KEY $SECRETS) diff --git a/.kamal/secrets.dhh b/.kamal/secrets.dhh index b427fe0c2..60d911ced 100644 --- a/.kamal/secrets.dhh +++ b/.kamal/secrets.dhh @@ -1,5 +1,5 @@ -SECRETS=$(kamal secrets fetch --adapter 1password --account basecamp --from Deploy/Fizzy Deployments/BASECAMP_REGISTRY_PASSWORD Production/SECRET_KEY_BASE) +SECRETS=$(kamal secrets fetch --adapter 1password --account basecamp --from Deploy/Fizzy Deployments/BASECAMP_REGISTRY_PASSWORD Beta/RAILS_MASTER_KEY) GITHUB_TOKEN=$(gh config get -h github.com oauth_token) BASECAMP_REGISTRY_PASSWORD=$(kamal secrets extract BASECAMP_REGISTRY_PASSWORD $SECRETS) -SECRET_KEY_BASE=$(kamal secrets extract SECRET_KEY_BASE $SECRETS) +RAILS_MASTER_KEY=$(kamal secrets extract RAILS_MASTER_KEY $SECRETS) diff --git a/.kamal/secrets.production b/.kamal/secrets.production index 8ac9f1e7c..b6d71edd3 100644 --- a/.kamal/secrets.production +++ b/.kamal/secrets.production @@ -1,6 +1,5 @@ -SECRETS=$(kamal secrets fetch --adapter 1password --account basecamp --from Deploy/Fizzy Deployments/BASECAMP_REGISTRY_PASSWORD Production/SECRET_KEY_BASE Production/OPEN_AI_API_KEY) +SECRETS=$(kamal secrets fetch --adapter 1password --account basecamp --from Deploy/Fizzy Deployments/BASECAMP_REGISTRY_PASSWORD Production/RAILS_MASTER_KEY) GITHUB_TOKEN=$(gh config get -h github.com oauth_token) BASECAMP_REGISTRY_PASSWORD=$(kamal secrets extract BASECAMP_REGISTRY_PASSWORD $SECRETS) -SECRET_KEY_BASE=$(kamal secrets extract SECRET_KEY_BASE $SECRETS) -OPEN_AI_API_KEY=$(kamal secrets extract OPEN_AI_API_KEY $SECRETS) +RAILS_MASTER_KEY=$(kamal secrets extract RAILS_MASTER_KEY $SECRETS) diff --git a/config/cable.yml b/config/cable.yml index 41e5f322d..535a25fe7 100644 --- a/config/cable.yml +++ b/config/cable.yml @@ -12,5 +12,8 @@ development: test: adapter: test -production: +production: &production <<: *cable + +beta: + <<: *production diff --git a/config/cache.yml b/config/cache.yml index 4b41834ab..c4fb2c2ae 100644 --- a/config/cache.yml +++ b/config/cache.yml @@ -17,6 +17,9 @@ development: test: <<: *default_options -production: +production: &production <<: *default_connection <<: *default_options + +beta: + <<: *production diff --git a/config/credentials.yml.enc b/config/credentials.yml.enc deleted file mode 100644 index 8112d68b5..000000000 --- a/config/credentials.yml.enc +++ /dev/null @@ -1 +0,0 @@ -5ZoHBjwBHPiVvPcnI4khzagL3ZR54mfvv5VdRLjDicl0s/6hGKd88rwmSb/8WOITFSDQZntfFcJ4/xlH8ZLr2az51/7CJr9J1QSJUCBexDtTpqa4g7/+s2TW1YDrLg3LybtVzuGcTVL2YE4llcW+5RLI6JuioAQQ0Sx9hGqd6n5/safGW/OXrBPS198b3YmPeAkSm4uA4L7yOmQYaeweJp1p47WSrgh5bnesttx+ey0L7nMSwWKj/pBFEP5N0ELQXm4G38RYf3SUsoAQkUl+TIsMHvFz3/toeS0+tuEvKeDQYlvXCyVTK4XHsvbu8pkOvZGBpDgkvW3aMOBTf/Bsrv0ylU1gF3pGs2Oc90KOmKxBmHbuOOS5VpNTYhZ6IPEaMz9t5rDYlYVupPQ6TTNrn4jAvq6f--InMaBENysICzAQUu--mMpwA+kXMLs3j/zWoA0NMA== \ No newline at end of file diff --git a/config/credentials/beta.yml.enc b/config/credentials/beta.yml.enc new file mode 100644 index 000000000..e8e9f14df --- /dev/null +++ b/config/credentials/beta.yml.enc @@ -0,0 +1 @@ +t6ny1JCvKxJ8bNWGATiCM7DxrxC8Zu6NkrDYmad+Lk1OeisO6uYzizMZ94dwJfun2Na09V+clw7nKhx6Gk6kG5jykfSoaegu355xzGUDHrmeAov5RSe2znytTNgVDuh9YSJTJUnF+ntDSRE3yzXXeG4+ytb6WVwl2tHQrRKuXDc0Mk3mmxb1yqFSjb7EdREMHrmZQ0nvM+nNpBmnfe7Cn8lr92IApPpCWyk0qVyoHY5sCSneaXRuQusqWUMvhCMC3C/2LmjW5oKbzlWLicH4YQo0/SNYS0wclNJMvN0M23BcLvWu0JIaLpOHmFPJB7Kp--69iYHxd7qLsiOxpm--GmtDrSgvzIOvSczwWWdIhQ== \ No newline at end of file diff --git a/config/credentials/production.yml.enc b/config/credentials/production.yml.enc new file mode 100644 index 000000000..bb8d1a2db --- /dev/null +++ b/config/credentials/production.yml.enc @@ -0,0 +1 @@ +Qfchpaf23CQC2m4NQ5sUeAlHdflPAFCIcrlTjNB41VJjr202jgAu38nof8MWHhUBOn1RLvrLvW3JALT0uP9q9pkYFvsr3bONw11Dtz6DDEO2ZurjS4Blv0u8MGYS/igdb9N3X/yZDpfolgJS9lYYEj2S21s7BtLLBc5aDrYLC3HHstZoMkGCGh9126tMucIdhNdf4au4A36llTfNeWV7K136MgeoacfoqfsfJu0H53I+HxiuGiAcXSm2WSjBKa0EIuzPzD+Rq02xNhVOE5s0xsv9JlgTulwHrCLAIEw9Yx4DkEoiz0SIpwj5SvPXPdQF--aBcwo7pQVCIiV2UH--quPq/fEI5YPKcSu68LypyA== \ No newline at end of file diff --git a/config/database.yml b/config/database.yml index d9584de0b..a242b1555 100644 --- a/config/database.yml +++ b/config/database.yml @@ -37,7 +37,7 @@ test: database: storage/tenants/<%= Rails.env %>/%{tenant}/db/main.sqlite3 tenanted: true -production: +production: &production primary: <<: *default database: storage/tenants/<%= Rails.env %>/%{tenant}/db/main.sqlite3 @@ -62,3 +62,6 @@ production: <<: *default database: storage/production_queue.sqlite3 migrations_paths: db/queue_migrate + +beta: + <<: *production diff --git a/config/deploy.beta.yml b/config/deploy.beta.yml index 52249bbcd..9f384148b 100644 --- a/config/deploy.beta.yml +++ b/config/deploy.beta.yml @@ -10,6 +10,9 @@ proxy: hosts: - fizzy.37signals.works # the single tenant is named "fizzy" +env: + clear: + RAILS_ENV: beta x-beamer-accessory: &beamer-accessory image: basecamp/beamer @@ -22,7 +25,6 @@ x-beamer-accessory: &beamer-accessory volumes: - fizzy:/storage - accessories: beamer-primary: <<: *beamer-accessory diff --git a/config/deploy.production.yml b/config/deploy.production.yml index 6263ee89e..3f2102f95 100644 --- a/config/deploy.production.yml +++ b/config/deploy.production.yml @@ -16,6 +16,9 @@ proxy: ssh: user: app +env: + clear: + RAILS_ENV: production x-beamer-accessory: &beamer-accessory image: basecamp/beamer diff --git a/config/deploy.yml b/config/deploy.yml index aa2f04862..d80015ed7 100644 --- a/config/deploy.yml +++ b/config/deploy.yml @@ -23,8 +23,7 @@ builder: env: secret: - - SECRET_KEY_BASE - - OPEN_AI_API_KEY + - RAILS_MASTER_KEY aliases: console: app exec -i --reuse "bin/rails console" diff --git a/config/environments/beta.rb b/config/environments/beta.rb new file mode 100644 index 000000000..7325cd37a --- /dev/null +++ b/config/environments/beta.rb @@ -0,0 +1,5 @@ +require_relative "production" + +Rails.application.configure do + config.action_mailer.default_url_options = { host: "%{tenant}.37signals.works" } +end diff --git a/config/queue.yml b/config/queue.yml index 9eace59c4..ecab73581 100644 --- a/config/queue.yml +++ b/config/queue.yml @@ -14,5 +14,8 @@ development: test: <<: *default -production: +production: &production <<: *default + +beta: + <<: *production diff --git a/config/recurring.yml b/config/recurring.yml index 7be0c716a..6862cfd5c 100644 --- a/config/recurring.yml +++ b/config/recurring.yml @@ -1,4 +1,4 @@ -production: +production: &production auto_close_all_due: class: Card::AutoCloseAllDueJob schedule: every hour @@ -14,3 +14,6 @@ production: delete_unused_tags: class: DeleteUnusedTagsJob schedule: every day at 04:00 + +beta: + <<: *production From 74435dc5178f2846308fa3543e56a0431d2559ec Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Sat, 31 May 2025 16:59:22 +0200 Subject: [PATCH 09/25] update lexical --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index 5fcb8b1ef..4c09f0a7d 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,6 +1,6 @@ GIT remote: https://github.com/basecamp/actiontext-lexical - revision: 9328297709d532fcb4cb5e47cfbddefda34d7c32 + revision: 95e5bf624a1598b78711b71a6c23ae4847d39763 specs: actiontext-lexical (0.1.0) rails (>= 8.0.2) From 75832857e3ba09ee5935353047bd195cd181f5a7 Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Sat, 31 May 2025 18:16:06 +0200 Subject: [PATCH 10/25] update lexical --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index 8afd7ccc9..8d216f67a 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,6 +1,6 @@ GIT remote: https://github.com/basecamp/actiontext-lexical - revision: 9328297709d532fcb4cb5e47cfbddefda34d7c32 + revision: 7f327ddd60f4902ac60bb426495e85b31b6f0ad2 specs: actiontext-lexical (0.1.0) rails (>= 8.0.2) From 9e0b18d5acfb2ab4ba18c32c178b0340e3cace8f Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Sat, 31 May 2025 18:21:40 +0200 Subject: [PATCH 11/25] Update lexical --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index 4c09f0a7d..a63d5222d 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,6 +1,6 @@ GIT remote: https://github.com/basecamp/actiontext-lexical - revision: 95e5bf624a1598b78711b71a6c23ae4847d39763 + revision: 7f327ddd60f4902ac60bb426495e85b31b6f0ad2 specs: actiontext-lexical (0.1.0) rails (>= 8.0.2) From df682cdfe1dc655f58411daece9b2bdd5ff028de Mon Sep 17 00:00:00 2001 From: Mike Dalessio Date: Sat, 31 May 2025 13:35:18 -0400 Subject: [PATCH 12/25] Make sure Sentry is configured for the beta env. --- config/initializers/sentry.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/initializers/sentry.rb b/config/initializers/sentry.rb index 3d1e0a07a..c1944f8dd 100644 --- a/config/initializers/sentry.rb +++ b/config/initializers/sentry.rb @@ -1,4 +1,4 @@ -if Rails.env.production? && ENV["SKIP_TELEMETRY"].blank? +if (Rails.env.production? || Rails.env.beta?) && ENV["SKIP_TELEMETRY"].blank? Sentry.init do |config| config.dsn = "https://ca338fb1fe6f677d6aeec2336a86f0ee@o33603.ingest.us.sentry.io/4508093839179776" config.breadcrumbs_logger = %i[ active_support_logger http_logger ] From 704e215ecac1fb95fe6fac400fa9823961a1a542 Mon Sep 17 00:00:00 2001 From: Mike Dalessio Date: Sat, 31 May 2025 13:09:53 -0400 Subject: [PATCH 13/25] Production mirrors Active Storage to `:purestorage` Also include a script to copy existing blobs to Pure. Local is still the primary. Beta will keep using local disk service for Active Storage because I think there are constraints on source IPs for the Pure cluster. --- app/controllers/uploads_controller.rb | 2 +- config/credentials/beta.yml.enc | 2 +- config/credentials/production.yml.enc | 2 +- config/environments/beta.rb | 3 ++ config/environments/production.rb | 2 +- config/storage.yml | 43 ++++++++++----------------- script/copy-blobs-to-pure.rb | 42 ++++++++++++++++++++++++++ 7 files changed, 65 insertions(+), 31 deletions(-) create mode 100755 script/copy-blobs-to-pure.rb diff --git a/app/controllers/uploads_controller.rb b/app/controllers/uploads_controller.rb index a6f4cab39..3a240af09 100644 --- a/app/controllers/uploads_controller.rb +++ b/app/controllers/uploads_controller.rb @@ -11,7 +11,7 @@ class UploadsController < ApplicationController def show expires_in 5.minutes, public: true - redirect_to @attachment.url + redirect_to @attachment.url, allow_other_host: true end private diff --git a/config/credentials/beta.yml.enc b/config/credentials/beta.yml.enc index e8e9f14df..52c0b25b6 100644 --- a/config/credentials/beta.yml.enc +++ b/config/credentials/beta.yml.enc @@ -1 +1 @@ -t6ny1JCvKxJ8bNWGATiCM7DxrxC8Zu6NkrDYmad+Lk1OeisO6uYzizMZ94dwJfun2Na09V+clw7nKhx6Gk6kG5jykfSoaegu355xzGUDHrmeAov5RSe2znytTNgVDuh9YSJTJUnF+ntDSRE3yzXXeG4+ytb6WVwl2tHQrRKuXDc0Mk3mmxb1yqFSjb7EdREMHrmZQ0nvM+nNpBmnfe7Cn8lr92IApPpCWyk0qVyoHY5sCSneaXRuQusqWUMvhCMC3C/2LmjW5oKbzlWLicH4YQo0/SNYS0wclNJMvN0M23BcLvWu0JIaLpOHmFPJB7Kp--69iYHxd7qLsiOxpm--GmtDrSgvzIOvSczwWWdIhQ== \ No newline at end of file +czCzGVSjZ8O5GBNCpzrejKPoFxhEI5Vbw2cAL4v33gwdZNdEE9HZ82icKOKTcvGUUMnExsnfgfd7MIW86R+mMhw9xd1idtwBoOLdKtqHflkH4cvv7ke8yD5GdEKaq4j+AOGW5fY+e3GRZLjPo61PXO+PfeTABBuarSRSVgP8Fhlzx3iN3NpEAgFNExUkU9cl7D//wwJOnURu0BmNVaKWHXFxQS8NjCqfcxafNc46A316qrOG2xBl9WIVyhjM0XoXxjLjxIzNQ4nyAmaV03Dic/+aQgxpRV5UIVvFAB3W5P7shNCP683Xd0Fsb+IUf3cHBaKXXwkb0ksEe16JiHga1cgxkU1TkcpES4ByITXKqKKMWeuNMc2jx0sCy4fo46EKxC367PbQnortb6F5Bb7WTBq2CVO4/JfayocL8+a2Lun8oUecvyLIxSW4alByOuA+/hpb3mJ11k54lObQGTHdozCYlVxwB7ZzQ3IqDoH5aEYUkVCsQFWkge5zYQKsGllg0txZfxUnK/9xYo/1eJEXeMMi18mvz7gsodySR2ISG5WC8IYGSg813QVWKl4RgmCd2sRHRmIgJE10KnA=--NyjrvkU5s2ys16X3--l7V+dHmpOJEIh+YL/1yMfw== \ No newline at end of file diff --git a/config/credentials/production.yml.enc b/config/credentials/production.yml.enc index bb8d1a2db..83814b698 100644 --- a/config/credentials/production.yml.enc +++ b/config/credentials/production.yml.enc @@ -1 +1 @@ -Qfchpaf23CQC2m4NQ5sUeAlHdflPAFCIcrlTjNB41VJjr202jgAu38nof8MWHhUBOn1RLvrLvW3JALT0uP9q9pkYFvsr3bONw11Dtz6DDEO2ZurjS4Blv0u8MGYS/igdb9N3X/yZDpfolgJS9lYYEj2S21s7BtLLBc5aDrYLC3HHstZoMkGCGh9126tMucIdhNdf4au4A36llTfNeWV7K136MgeoacfoqfsfJu0H53I+HxiuGiAcXSm2WSjBKa0EIuzPzD+Rq02xNhVOE5s0xsv9JlgTulwHrCLAIEw9Yx4DkEoiz0SIpwj5SvPXPdQF--aBcwo7pQVCIiV2UH--quPq/fEI5YPKcSu68LypyA== \ No newline at end of file +8zbS4KaK1RittKFTR3g90VWGerEa6e5gSdtaVr5Zmxxs61/9zmEybqYH61jL70zhYpYHS9R0bCPszDgpUymcJevEcSytNAwdY+ba7IrXy+yCQIVjsqYlUNXbb/56f1rMfAHwtUidmudFLUZ83KYOexnJdQ7sRVAc2AU8ZX91xDfzavz9Q6zqnCb9/MyzHewlrcxgl+Igvask3M/xXSLgbZztuPAN+ETNY9aTlLUZK0fhxUsmtniFWXwhE3Qocjw+waP4Tvt0//Ps8v8HAd0oujwhDWWhG2tnv+9CobjeKNucf7KJNJWrSK63sitdfw5gQK0N3PtgMi3GHpkEenymQ6+lM/zSr3SX+RHcfmTtF2e7jmKlW05sFjMFp/r+U7UlOHPjGJ2cuTFykUmcUq33mwB7GakzjimVhogTAJrMNTQUJNecPkriBm9FR2mORKtZHA3ZXbRx+GzN7OyP8UFNXPKiLbBQEU45PcegZzqcr0LJjjg4A/C2gJbAsbVCea57WYeIiBD+0Gujof7VyXp9stc6teE71odipbolYl7/9bOdyEezeWsCa8HZVMnOoxvdAlilABI1B1kWs3HFRoPwhQgT--4kvplyqzQKR1xwnc--NE2dTYNLVrDzrLQHy+Uk+w== \ No newline at end of file diff --git a/config/environments/beta.rb b/config/environments/beta.rb index 7325cd37a..a037b4159 100644 --- a/config/environments/beta.rb +++ b/config/environments/beta.rb @@ -2,4 +2,7 @@ require_relative "production" Rails.application.configure do config.action_mailer.default_url_options = { host: "%{tenant}.37signals.works" } + + # I couldn't figure out how to configure Pure to allow updates from Kevin's digital ocean instance. + config.active_storage.service = :local end diff --git a/config/environments/production.rb b/config/environments/production.rb index ba3cc4093..f960c0e81 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -31,7 +31,7 @@ Rails.application.configure do # config.action_dispatch.x_sendfile_header = "X-Accel-Redirect" # for NGINX # Store uploaded files on the local file system (see config/storage.yml for options). - config.active_storage.service = :local + config.active_storage.service = :mirror # Mount Action Cable outside main process or domain. # config.action_cable.mount_path = nil diff --git a/config/storage.yml b/config/storage.yml index ad39ec2d8..22983983d 100644 --- a/config/storage.yml +++ b/config/storage.yml @@ -1,36 +1,25 @@ test: service: Disk root: <%= Rails.root.join("tmp/storage/%{tenant}") %> - tenanted: true local: service: Disk root: <%= Rails.root.join("storage", "tenants", Rails.env, "%{tenant}", "files") %> - tenanted: true -# Use bin/rails credentials:edit to set the AWS secrets (as aws:access_key_id|secret_access_key) -# amazon: -# service: S3 -# access_key_id: <%= Rails.application.credentials.dig(:aws, :access_key_id) %> -# secret_access_key: <%= Rails.application.credentials.dig(:aws, :secret_access_key) %> -# region: us-east-1 -# collection: your_own_collection-<%= Rails.env %> +<% purestorage_env_name = Rails.env.beta? ? "staging" : Rails.env %> +purestorage: + service: S3 + bucket: fizzy-<%= purestorage_env_name %>-activestorage + endpoint: "https://storage.basecamp.com" + ssl_verify_peer: false # FIXME: using self-signed cert internally + force_path_style: true + request_checksum_calculation: when_required # default is when_supported with CRC64NVME checksum which FlashBlade doesn't support + response_checksum_validation: when_required # default is when_supported with CRC64NVME checksum which FlashBlade doesn't support + region: us-east-1 # default region required for signer + access_key_id: <%= Rails.application.credentials.dig(:active_storage, :purestorage_service, :access_key_id) %> + secret_access_key: <%= Rails.application.credentials.dig(:active_storage, :purestorage_service, :secret_access_key) %> -# Remember not to checkin your GCS keyfile to a repository -# google: -# service: GCS -# project: your_project -# credentials: <%= Rails.root.join("path/to/gcs.keyfile") %> -# collection: your_own_collection-<%= Rails.env %> - -# Use bin/rails credentials:edit to set the Azure Storage secret (as azure_storage:storage_access_key) -# microsoft: -# service: AzureStorage -# storage_account_name: your_account_name -# storage_access_key: <%= Rails.application.credentials.dig(:azure_storage, :storage_access_key) %> -# container: your_container_name-<%= Rails.env %> - -# mirror: -# service: Mirror -# primary: local -# mirrors: [ amazon, google, microsoft ] +mirror: + service: Mirror + primary: local + mirrors: [ purestorage ] diff --git a/script/copy-blobs-to-pure.rb b/script/copy-blobs-to-pure.rb new file mode 100755 index 000000000..01b51e66e --- /dev/null +++ b/script/copy-blobs-to-pure.rb @@ -0,0 +1,42 @@ +#! /usr/bin/env ruby + +require_relative "../config/environment" + +def migrate(source_service_name, target_service_name) + ApplicationRecord.with_each_tenant do |tenant| + puts "\n## #{tenant}" + report = { updated: 0, skipped: 0, errors: 0 } + + if ActiveStorage::Blob.count == 0 + puts "No blobs found, skipping." + next + end + + ActiveStorage::Blob.service = source_service = ActiveStorage::Blob.services.fetch(source_service_name) + target_service = ActiveStorage::Blob.services.fetch(target_service_name) + + ActiveStorage::Blob.find_each do |blob| + if target_service.exist?(blob.key) + report[:skipped] += 1 + putc "S" + else + begin + blob.open do |stream| + target_service.upload(blob.key, stream, checksum: blob.checksum) + end + report[:updated] += 1 + putc "." + rescue ActiveStorage::FileNotFoundError + report[:errors] += 1 + putc "E" + end + end + # blob.update_attribute :service_name, target_service_name + end + + puts + pp report + end +end + +migrate :local, :purestorage From bddfed3419ccc8160c419461fa48a40520bfbc9b Mon Sep 17 00:00:00 2001 From: Mike Dalessio Date: Sat, 31 May 2025 16:02:50 -0400 Subject: [PATCH 14/25] Flip the storage mirror to write to Pure as the primary --- config/storage.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/config/storage.yml b/config/storage.yml index 22983983d..1a6bf5c76 100644 --- a/config/storage.yml +++ b/config/storage.yml @@ -21,5 +21,5 @@ purestorage: mirror: service: Mirror - primary: local - mirrors: [ purestorage ] + primary: purestorage + mirrors: [ local ] From 80e84f31fc863c7a30a474a8f6d5993559beb934 Mon Sep 17 00:00:00 2001 From: Mike Dalessio Date: Sun, 1 Jun 2025 11:40:35 -0400 Subject: [PATCH 15/25] dep: add mission_control-jobs --- Gemfile | 5 +++-- Gemfile.lock | 11 +++++++++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/Gemfile b/Gemfile index 457f0790b..1984dc71d 100644 --- a/Gemfile +++ b/Gemfile @@ -2,7 +2,6 @@ source "https://rubygems.org" git_source(:bc) { |repo| "https://github.com/basecamp/#{repo}" } gem "rails", github: "rails/rails", branch: "main" -gem "active_record-tenanted", bc: "active_record-tenanted" # Assets & front end gem "importmap-rails" @@ -11,6 +10,7 @@ gem "stimulus-rails" gem "turbo-rails" # Deployment and drivers +gem "active_record-tenanted", bc: "active_record-tenanted" gem "bootsnap", require: false gem "kamal", require: false gem "puma", ">= 5.0" @@ -32,7 +32,8 @@ gem "image_processing", "~> 1.14" gem "platform_agent" gem "aws-sdk-s3", require: false -# Telemetry and logging +# Telemetry, logging, and operations +gem "mission_control-jobs" gem "sentry-ruby" gem "sentry-rails" gem "rails_structured_logging", bc: "rails-structured-logging" diff --git a/Gemfile.lock b/Gemfile.lock index 8d216f67a..143f0d718 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -275,6 +275,16 @@ GEM mini_mime (1.1.5) mini_portile2 (2.8.9) minitest (5.25.5) + mission_control-jobs (1.0.2) + actioncable (>= 7.1) + actionpack (>= 7.1) + activejob (>= 7.1) + activerecord (>= 7.1) + importmap-rails (>= 1.2.1) + irb (~> 1.13) + railties (>= 7.1) + stimulus-rails + turbo-rails msgpack (1.8.0) multipart-post (2.4.1) net-http (0.6.0) @@ -501,6 +511,7 @@ DEPENDENCIES importmap-rails jbuilder kamal + mission_control-jobs platform_agent propshaft puma (>= 5.0) From 29644ba122d5f5be1eb3344c77d0a6ce1b182bd7 Mon Sep 17 00:00:00 2001 From: Mike Dalessio Date: Sun, 1 Jun 2025 11:50:47 -0400 Subject: [PATCH 16/25] Mount mission control under `/admin/jobs` and add credentials for beta and production (in 1password). --- config/credentials/beta.yml.enc | 2 +- config/credentials/development.yml.enc | 2 +- config/credentials/production.yml.enc | 2 +- config/routes.rb | 4 ++++ 4 files changed, 7 insertions(+), 3 deletions(-) diff --git a/config/credentials/beta.yml.enc b/config/credentials/beta.yml.enc index 52c0b25b6..71b1e744d 100644 --- a/config/credentials/beta.yml.enc +++ b/config/credentials/beta.yml.enc @@ -1 +1 @@ -czCzGVSjZ8O5GBNCpzrejKPoFxhEI5Vbw2cAL4v33gwdZNdEE9HZ82icKOKTcvGUUMnExsnfgfd7MIW86R+mMhw9xd1idtwBoOLdKtqHflkH4cvv7ke8yD5GdEKaq4j+AOGW5fY+e3GRZLjPo61PXO+PfeTABBuarSRSVgP8Fhlzx3iN3NpEAgFNExUkU9cl7D//wwJOnURu0BmNVaKWHXFxQS8NjCqfcxafNc46A316qrOG2xBl9WIVyhjM0XoXxjLjxIzNQ4nyAmaV03Dic/+aQgxpRV5UIVvFAB3W5P7shNCP683Xd0Fsb+IUf3cHBaKXXwkb0ksEe16JiHga1cgxkU1TkcpES4ByITXKqKKMWeuNMc2jx0sCy4fo46EKxC367PbQnortb6F5Bb7WTBq2CVO4/JfayocL8+a2Lun8oUecvyLIxSW4alByOuA+/hpb3mJ11k54lObQGTHdozCYlVxwB7ZzQ3IqDoH5aEYUkVCsQFWkge5zYQKsGllg0txZfxUnK/9xYo/1eJEXeMMi18mvz7gsodySR2ISG5WC8IYGSg813QVWKl4RgmCd2sRHRmIgJE10KnA=--NyjrvkU5s2ys16X3--l7V+dHmpOJEIh+YL/1yMfw== \ No newline at end of file +I2oqW56RTDrRxQ5HUGXGUbSiVoeICiF6tjSo1oxmGuZKE6wu99S+RvQ6Mf0enCQdbHG0osoCpUvu7iuOMEA3X6vO7ku1YFbCDKugu6tY913bzRl166SWZdSlEwtcr6wtmUkXLoWfDzOt98lJ03nU8YbeDC2AzuBqcYWS2qjqpFrrDK8WQI7IukM4iFEDjesW2Noekyy6z+b5+DxB2lkCMFgim4ExW/ndFmrvDnt+QeR+rB9XW9BF6CEg5pPsBQ9EwtICsacCWdvFFOZ8EaNoOuF+hKIHoKP60UCfQj5EUir/r5pGE0Z83z91+UpdpVCuGvh62uSZoZOjdGAplg/DWWKpGcj5JOA7V2yyGEMlz7NueBInG8t/eEyeX/98fAy4TNUxxvncNB+xsmgPJIqlek7vXtYe06AMwrRvac4QSSpUuwWuKUV8JF4gYT5Gp92rzqmmwsTmjvEMiPlnnVYH83GCtd2f1Se7EkDVTvgLA3idnjLvFCelAU4Vdo9/pLqG+0xorSxQ4D/UXqykxtTSjz6pC2P8J1g6T91TQhcH4tDFuZICOYLyFV8F5OD3jPaP+ml01oME42FIKmyhvcNhMLqluUvFqqtOxgWp54jCnde6MBHJ9W7gejwfSmjyD+pr/5NXgaGaTq9h6NMdMkr0Bis6Cc/EgHZKBpJx6ARH1ulcp9uZ9tBjNCePBNxATrJLKwiXh0ZTLDfDTkgdznkj8u+FAKclxjIZ63td3hGe--5kL30BIpNtC7wcsf--YaV9PXL74Wl2ePaAdT5Crg== \ No newline at end of file diff --git a/config/credentials/development.yml.enc b/config/credentials/development.yml.enc index e14363fcd..affc4c6cd 100644 --- a/config/credentials/development.yml.enc +++ b/config/credentials/development.yml.enc @@ -1 +1 @@ -TebKB7S5umO6AQRNOZWHMihQAlRay0wPXY637HdvynV9d34ALokzb765aykzZYsUmeHdvFnSHdweRQG60NR7V3sXNL09h38pWo0JhcGkwmtEAe908mZRjC0JjXamOx0AgohRdfmjwlI9MROfjTwrKq0CtdIien7hNZDqUxEbWOjSX2RiPL65Ro++iAnvA9ezUoHW47TPPnXDiFq4I37KPVhl1Ze1bPP02VebysUB/K+nsy4GsRdl3jxLXQ==--7ie1Lx8/EJvL2Jbc--WJAy/xEQeki9ceKjC5Si1A== \ No newline at end of file +e6HcyVNJvgdJGTCcvW8FDHQ9pHYmTgNW2/71Espdwxd5LQX/Xb9+VkeqpEAL8ceFJo6ro49mMs85G2YngKbbXEh//8+XSS0inMR/kh9wTV3f6YBWnLVBbhrOnYgPcQ8vedsgxLG2Cs4y7ipfeT/dp9bb5m4IxV0OPiUjVFFJuAuGkMJt3pE7D6alqL1ACRxazV9UKV5WKEBfaQYeKpJ++5f86Nls1EwKo5tB1lJed9P5n2yH7meX/QyxjgQd38CIaPwp+F92y5DLKJD5MZRMJGgWf6MT6096LhjEC3vvtS3wwT/eDVcVKTplRkEszj7BZEfOs9Ondb9EVtbXmeoHYSgpaOq9/nEwtho5Uw==--KlHZy1apg3u1XxFs--N2ehRobLHNz+Sepc8p4EIA== \ No newline at end of file diff --git a/config/credentials/production.yml.enc b/config/credentials/production.yml.enc index 83814b698..ef3036025 100644 --- a/config/credentials/production.yml.enc +++ b/config/credentials/production.yml.enc @@ -1 +1 @@ -8zbS4KaK1RittKFTR3g90VWGerEa6e5gSdtaVr5Zmxxs61/9zmEybqYH61jL70zhYpYHS9R0bCPszDgpUymcJevEcSytNAwdY+ba7IrXy+yCQIVjsqYlUNXbb/56f1rMfAHwtUidmudFLUZ83KYOexnJdQ7sRVAc2AU8ZX91xDfzavz9Q6zqnCb9/MyzHewlrcxgl+Igvask3M/xXSLgbZztuPAN+ETNY9aTlLUZK0fhxUsmtniFWXwhE3Qocjw+waP4Tvt0//Ps8v8HAd0oujwhDWWhG2tnv+9CobjeKNucf7KJNJWrSK63sitdfw5gQK0N3PtgMi3GHpkEenymQ6+lM/zSr3SX+RHcfmTtF2e7jmKlW05sFjMFp/r+U7UlOHPjGJ2cuTFykUmcUq33mwB7GakzjimVhogTAJrMNTQUJNecPkriBm9FR2mORKtZHA3ZXbRx+GzN7OyP8UFNXPKiLbBQEU45PcegZzqcr0LJjjg4A/C2gJbAsbVCea57WYeIiBD+0Gujof7VyXp9stc6teE71odipbolYl7/9bOdyEezeWsCa8HZVMnOoxvdAlilABI1B1kWs3HFRoPwhQgT--4kvplyqzQKR1xwnc--NE2dTYNLVrDzrLQHy+Uk+w== \ No newline at end of file +Qkkt3krcGlOh76//7TmY5rXz31mhL3NNNiO6TCcRhx4lT6uZ8sLjW7X0yp2wZ30y2cJBeIsNMeOhL47JTpu0Z4d0FPtwgH4bzzXAiab0/YrfMjxyBt7/K1DzW/bePUvakJggIvVGGRA1NB1tPkwziQ4dgA34E/UUPAxUofFaoCIFVABmqt4DFg0DKYB8tImDDjZ5xUfSNxrKj3pH5mOIU9jqSK6LtBELFZcNxrJOI0UEgayRNwYAs44ujdM2/2zrAWlUCFZ3NnMU4bBJW02/d6LaxHtB809u8mx3bChEzWMtZB9CvpbRH7u6XfZJR1YGFztcNguzRbNX5x9pKZImAuAhVFKR7Fbi12Hg3w7U3A/YovwmyhHK6ksfOzYlYoDUTBnGYVwiqR16naaHCMekomK+uo0qtOn83RQkLuuqFJshPXn/lW8Rggt4dq/0THEl7Qj+dumadlWDVEgxhipI031RSBF1toNWw/zwaC31Ord1YXD8ZC09sPoj8Zd4hD2o2a0IUuIMXA8w5/2+AmdU8wF66opdKGdsZDRjM2Bul8aAbJ7/iV7xTuvlE4LmzXGArMvivIZ0GwrN3YAsDta2Y3ufibq7WZzSCOrpbPKJ9CEGg4a2AN8UGaGZ5ZEAYg8lhDgsZMjHRkGNuxk8QkDiVFDyNoI7H9ZQqbw+L1AJRTfDIcNB/9h5n+//Nk4ylLF1lnI7y/IQA/AuSQ+UN+okt3RhLugwGvWrs49MExJStLHpGveSoQ==--MQ2JycR3X6LIiKSY--45C7bleKX9FMGv4fc+G1yw== \ No newline at end of file diff --git a/config/routes.rb b/config/routes.rb index 5c256cdd3..92cf66e1f 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -123,4 +123,8 @@ Rails.application.routes.draw do root "events#index" + + namespace :admin do + mount MissionControl::Jobs::Engine, at: "/jobs" + end end From 4d5fbc9ff30eae0fd2f5718cddb5735ef44a4300 Mon Sep 17 00:00:00 2001 From: Mike Dalessio Date: Sun, 1 Jun 2025 12:27:07 -0400 Subject: [PATCH 17/25] Drop the action_text_markdowns table The model was removed in 10b2aad6 / fa04016c ref: https://37s.fizzy.37signals.com/collections/7/cards/731 --- ...250601161653_drop_action_text_markdowns.rb | 5 + db/schema.rb | 13 +- db/schema_cache.yml | 126 +++++------------- 3 files changed, 38 insertions(+), 106 deletions(-) create mode 100644 db/migrate/20250601161653_drop_action_text_markdowns.rb diff --git a/db/migrate/20250601161653_drop_action_text_markdowns.rb b/db/migrate/20250601161653_drop_action_text_markdowns.rb new file mode 100644 index 000000000..23a972df8 --- /dev/null +++ b/db/migrate/20250601161653_drop_action_text_markdowns.rb @@ -0,0 +1,5 @@ +class DropActionTextMarkdowns < ActiveRecord::Migration[8.1] + def change + drop_table :action_text_markdowns + end +end diff --git a/db/schema.rb b/db/schema.rb index 098674f3a..7acec01bd 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2025_05_23_084734) do +ActiveRecord::Schema[8.1].define(version: 2025_06_01_161653) do create_table "accesses", force: :cascade do |t| t.integer "collection_id", null: false t.datetime "created_at", null: false @@ -29,16 +29,6 @@ ActiveRecord::Schema[8.1].define(version: 2025_05_23_084734) do t.datetime "updated_at", null: false end - create_table "action_text_markdowns", force: :cascade do |t| - t.text "content", default: "", null: false - t.datetime "created_at", null: false - t.string "name", null: false - t.integer "record_id", null: false - t.string "record_type", null: false - t.datetime "updated_at", null: false - t.index ["record_type", "record_id"], name: "index_action_text_markdowns_on_record" - end - create_table "action_text_rich_texts", force: :cascade do |t| t.text "body" t.datetime "created_at", null: false @@ -308,7 +298,6 @@ ActiveRecord::Schema[8.1].define(version: 2025_05_23_084734) do t.datetime "created_at", null: false t.string "title" t.datetime "updated_at", null: false - t.index ["title"], name: "index_tags_on_account_id_and_title", unique: true end create_table "users", force: :cascade do |t| diff --git a/db/schema_cache.yml b/db/schema_cache.yml index 6cd19a67c..e4a61d5f1 100644 --- a/db/schema_cache.yml +++ b/db/schema_cache.yml @@ -114,57 +114,22 @@ columns: collation: comment: - *9 - action_text_markdowns: + action_text_rich_texts: - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: - name: content - cast_type: &11 !ruby/object:ActiveRecord::Type::Text + name: body + cast_type: &15 !ruby/object:ActiveRecord::Type::Text true: t false: f precision: scale: limit: - sql_type_metadata: &12 !ruby/object:ActiveRecord::ConnectionAdapters::SqlTypeMetadata + sql_type_metadata: &16 !ruby/object:ActiveRecord::ConnectionAdapters::SqlTypeMetadata sql_type: TEXT type: :text limit: precision: scale: - 'null': false - default: '' - default_function: - collation: - comment: - - *5 - - *6 - - *10 - - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column - auto_increment: - name: record_id - cast_type: *1 - sql_type_metadata: *2 - 'null': false - default: - default_function: - collation: - comment: - - &13 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column - auto_increment: - name: record_type - cast_type: *7 - sql_type_metadata: *8 - 'null': false - default: - default_function: - collation: - comment: - - *9 - action_text_rich_texts: - - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column - auto_increment: - name: body - cast_type: *11 - sql_type_metadata: *12 'null': true default: default_function: @@ -173,16 +138,16 @@ columns: - *5 - *6 - *10 - - &16 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + - &13 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: name: record_id - cast_type: &14 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3Adapter::SQLite3Integer + cast_type: &11 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3Adapter::SQLite3Integer precision: scale: limit: max: 9223372036854775808 min: -9223372036854775808 - sql_type_metadata: &15 !ruby/object:ActiveRecord::ConnectionAdapters::SqlTypeMetadata + sql_type_metadata: &12 !ruby/object:ActiveRecord::ConnectionAdapters::SqlTypeMetadata sql_type: bigint type: :integer limit: @@ -193,14 +158,23 @@ columns: default_function: collation: comment: - - *13 + - &14 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + auto_increment: + name: record_type + cast_type: *7 + sql_type_metadata: *8 + 'null': false + default: + default_function: + collation: + comment: - *9 active_storage_attachments: - &17 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: name: blob_id - cast_type: *14 - sql_type_metadata: *15 + cast_type: *11 + sql_type_metadata: *12 'null': false default: default_function: @@ -209,8 +183,8 @@ columns: - *5 - *6 - *10 - - *16 - *13 + - *14 - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: name: slug @@ -225,8 +199,8 @@ columns: - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: name: byte_size - cast_type: *14 - sql_type_metadata: *15 + cast_type: *11 + sql_type_metadata: *12 'null': false default: default_function: @@ -277,8 +251,8 @@ columns: - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: name: metadata - cast_type: *11 - sql_type_metadata: *12 + cast_type: *15 + sql_type_metadata: *16 'null': true default: default_function: @@ -445,8 +419,8 @@ columns: - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: name: status - cast_type: *11 - sql_type_metadata: *12 + cast_type: *15 + sql_type_metadata: *16 'null': false default: creating default_function: @@ -524,8 +498,8 @@ columns: - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: name: auto_close_period - cast_type: *14 - sql_type_metadata: *15 + cast_type: *11 + sql_type_metadata: *12 'null': true default: default_function: @@ -573,8 +547,8 @@ columns: - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: name: line - cast_type: *11 - sql_type_metadata: *12 + cast_type: *15 + sql_type_metadata: *16 'null': true default: default_function: @@ -1005,7 +979,6 @@ columns: primary_keys: accesses: id accounts: id - action_text_markdowns: id action_text_rich_texts: id active_storage_attachments: id active_storage_blobs: id @@ -1044,7 +1017,6 @@ primary_keys: data_sources: accesses: true accounts: true - action_text_markdowns: true action_text_rich_texts: true active_storage_attachments: true active_storage_blobs: true @@ -1132,24 +1104,6 @@ indexes: comment: valid: true accounts: [] - action_text_markdowns: - - !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition - table: action_text_markdowns - name: index_action_text_markdowns_on_record - unique: false - columns: - - record_type - - record_id - lengths: {} - orders: {} - opclasses: {} - where: - type: - using: - include: - nulls_not_distinct: - comment: - valid: true action_text_rich_texts: - !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition table: action_text_rich_texts @@ -2098,23 +2052,7 @@ indexes: nulls_not_distinct: comment: valid: true - tags: - - !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition - table: tags - name: index_tags_on_account_id_and_title - unique: true - columns: - - title - lengths: {} - orders: {} - opclasses: {} - where: - type: - using: - include: - nulls_not_distinct: - comment: - valid: true + tags: [] users: - !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition table: users @@ -2199,4 +2137,4 @@ indexes: comment: valid: true workflows: [] -version: 20250523084734 +version: 20250601161653 From 6d7fbd2175ea441835ef08b1b7baec7c47803837 Mon Sep 17 00:00:00 2001 From: Mike Dalessio Date: Sun, 1 Jun 2025 12:42:51 -0400 Subject: [PATCH 18/25] Clean up the purestorage config, and add explanatory comments. We're keeping beta on local disk for now. --- config/environments/beta.rb | 2 +- config/storage.yml | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/config/environments/beta.rb b/config/environments/beta.rb index a037b4159..c7f44040b 100644 --- a/config/environments/beta.rb +++ b/config/environments/beta.rb @@ -3,6 +3,6 @@ require_relative "production" Rails.application.configure do config.action_mailer.default_url_options = { host: "%{tenant}.37signals.works" } - # I couldn't figure out how to configure Pure to allow updates from Kevin's digital ocean instance. + # Let's keep beta on local disk. See https://github.com/basecamp/fizzy/pull/557 for context. config.active_storage.service = :local end diff --git a/config/storage.yml b/config/storage.yml index 1a6bf5c76..38ac38be7 100644 --- a/config/storage.yml +++ b/config/storage.yml @@ -6,10 +6,11 @@ local: service: Disk root: <%= Rails.root.join("storage", "tenants", Rails.env, "%{tenant}", "files") %> -<% purestorage_env_name = Rails.env.beta? ? "staging" : Rails.env %> +# We have "development", "staging", and "production" buckets configured. Note that we don't have a +# "beta" bucket. (As of 2025-06-01.) purestorage: service: S3 - bucket: fizzy-<%= purestorage_env_name %>-activestorage + bucket: fizzy-<%= Rails.env %>-activestorage endpoint: "https://storage.basecamp.com" ssl_verify_peer: false # FIXME: using self-signed cert internally force_path_style: true From a43db94a83e4fef2472e01bc2dbcbd1dec697388 Mon Sep 17 00:00:00 2001 From: Mike Dalessio Date: Sun, 1 Jun 2025 12:45:24 -0400 Subject: [PATCH 19/25] Update production storage to write to the Pure blob store Modify the blob copying script to update the blob service name. This completes the migration and allows us to (at some point) clean up local disk and remove the "mirror" service from the config. --- config/environments/production.rb | 2 +- config/storage.yml | 1 + script/copy-blobs-to-pure.rb | 9 +++++++-- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/config/environments/production.rb b/config/environments/production.rb index f960c0e81..73f7f6f1b 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -31,7 +31,7 @@ Rails.application.configure do # config.action_dispatch.x_sendfile_header = "X-Accel-Redirect" # for NGINX # Store uploaded files on the local file system (see config/storage.yml for options). - config.active_storage.service = :mirror + config.active_storage.service = :purestorage # Mount Action Cable outside main process or domain. # config.action_cable.mount_path = nil diff --git a/config/storage.yml b/config/storage.yml index 38ac38be7..2b062460e 100644 --- a/config/storage.yml +++ b/config/storage.yml @@ -20,6 +20,7 @@ purestorage: access_key_id: <%= Rails.application.credentials.dig(:active_storage, :purestorage_service, :access_key_id) %> secret_access_key: <%= Rails.application.credentials.dig(:active_storage, :purestorage_service, :secret_access_key) %> +# TODO: remove this after we update existing blobs' service names to purestorage. mirror: service: Mirror primary: purestorage diff --git a/script/copy-blobs-to-pure.rb b/script/copy-blobs-to-pure.rb index 01b51e66e..ae906d4ea 100755 --- a/script/copy-blobs-to-pure.rb +++ b/script/copy-blobs-to-pure.rb @@ -16,7 +16,10 @@ def migrate(source_service_name, target_service_name) target_service = ActiveStorage::Blob.services.fetch(target_service_name) ActiveStorage::Blob.find_each do |blob| - if target_service.exist?(blob.key) + if target_service.name.to_sym == blob.service_name.to_sym + report[:skipped] += 1 + putc "-" + elsif target_service.exist?(blob.key) report[:skipped] += 1 putc "S" else @@ -31,7 +34,9 @@ def migrate(source_service_name, target_service_name) putc "E" end end - # blob.update_attribute :service_name, target_service_name + + # Update the service name of the blob. + blob.update_column :service_name, target_service_name end puts From 9126c42eef63c2fa02657a0f1b36f2d08a484247 Mon Sep 17 00:00:00 2001 From: Mike Dalessio Date: Sun, 1 Jun 2025 13:04:30 -0400 Subject: [PATCH 20/25] Remove the "mirror" storage service because we've updated all the blobs to point to "purestorage" --- config/storage.yml | 6 ------ 1 file changed, 6 deletions(-) diff --git a/config/storage.yml b/config/storage.yml index 2b062460e..19b48b07f 100644 --- a/config/storage.yml +++ b/config/storage.yml @@ -19,9 +19,3 @@ purestorage: region: us-east-1 # default region required for signer access_key_id: <%= Rails.application.credentials.dig(:active_storage, :purestorage_service, :access_key_id) %> secret_access_key: <%= Rails.application.credentials.dig(:active_storage, :purestorage_service, :secret_access_key) %> - -# TODO: remove this after we update existing blobs' service names to purestorage. -mirror: - service: Mirror - primary: purestorage - mirrors: [ local ] From 5214a74c0393d39e3358d8dd17537703b78ea1c1 Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Sun, 1 Jun 2025 21:51:26 +0200 Subject: [PATCH 21/25] Autolink emails and URLs at rendering time --- app/helpers/html_helper.rb | 46 +++++++++++++++++++ .../action_text/contents/_content.html.erb | 2 +- test/helpers/html_helper_test.rb | 35 ++++++++++++++ 3 files changed, 82 insertions(+), 1 deletion(-) create mode 100644 app/helpers/html_helper.rb create mode 100644 test/helpers/html_helper_test.rb diff --git a/app/helpers/html_helper.rb b/app/helpers/html_helper.rb new file mode 100644 index 000000000..b989adf12 --- /dev/null +++ b/app/helpers/html_helper.rb @@ -0,0 +1,46 @@ +module HtmlHelper + def format_html(html) + fragment = Nokogiri::HTML.fragment(html) + + auto_link(fragment) + + fragment.to_html.html_safe + end + + private + EXCLUDED_ELEMENTS = %w[ a figcaption pre code ] + EMAIL_REGEXP = /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/ + URL_REGEXP = URI::DEFAULT_PARSER.make_regexp(%w[http https]) + + def auto_link(fragment) + fragment.traverse do |node| + next unless auto_linkable_node?(node) + + content = node.text + linked_content = content.dup + + auto_link_urls(linked_content) + auto_link_emails(linked_content) + + if linked_content != content + node.replace(Nokogiri::HTML.fragment(linked_content)) + end + end + end + + def auto_linkable_node?(node) + node.text? && node.ancestors.none? { |ancestor| EXCLUDED_ELEMENTS.include?(ancestor.name) } + end + + def auto_link_urls(linked_content) + linked_content.gsub!(URL_REGEXP) do |match| + %(#{match}) + end + end + + def auto_link_emails(text) + text.gsub!(EMAIL_REGEXP) do |match| + %(#{match}) + end + end +end diff --git a/app/views/layouts/action_text/contents/_content.html.erb b/app/views/layouts/action_text/contents/_content.html.erb index 0dec72775..b264733d7 100644 --- a/app/views/layouts/action_text/contents/_content.html.erb +++ b/app/views/layouts/action_text/contents/_content.html.erb @@ -1,3 +1,3 @@
- <%= yield -%> + <%= format_html yield -%>
diff --git a/test/helpers/html_helper_test.rb b/test/helpers/html_helper_test.rb new file mode 100644 index 000000000..818ffa57b --- /dev/null +++ b/test/helpers/html_helper_test.rb @@ -0,0 +1,35 @@ +require "test_helper" + +class HtmlHelperTest < ActionView::TestCase + test "converts URLs into anchor tags" do + assert_equal_html \ + %(

Check this: https://example.com

), + format_html("

Check this: https://example.com

") + end + + test "respect existing links" do + assert_equal_html \ + %(

Check this: https://example.com

), + format_html("

Check this: https://example.com

") + end + + test "converts email addresses into mailto links" do + assert_equal_html \ + %(

Contact us at support@example.com

), + format_html("

Contact us at support@example.com

") + end + + test "respect existing linked emails" do + assert_equal_html \ + %(

Contact us at support@example.com

), + format_html(%(

Contact us at support@example.com

)) + end + + test "don't autolink content in excluded elements" do + %w[ figcaption pre code ].each do |element| + assert_equal_html \ + "<#{element}>Check this: https://example.com", + format_html("<#{element}>Check this: https://example.com") + end + end +end From 28ed744d0c881a132ea31e16dbfaffc0747522c5 Mon Sep 17 00:00:00 2001 From: Mike Dalessio Date: Fri, 16 May 2025 10:35:51 -0400 Subject: [PATCH 22/25] refactor: Extract a Card::Entropy concern to encompass behavior related to auto-closing and auto-reconsidering. Note that putting this code in a single place reveals an asymmetry between the actions: - Auto-closing period is an optional attribute of a collection - Auto-reconsidering period is hard-coded to 30 days for all collections --- app/assets/stylesheets/bubble.css | 4 +- app/models/card.rb | 6 +-- app/models/card/closeable.rb | 26 ---------- app/models/card/engageable.rb | 22 +++----- app/models/card/entropy.rb | 47 +++++++++++++++++ app/views/cards/display/_preview.html.erb | 8 +-- .../cards/display/preview/_bubble.html.erb | 4 +- test/models/card/closeable_test.rb | 26 ---------- test/models/card/engageable_test.rb | 20 ++------ test/models/card/entropy_test.rb | 50 +++++++++++++++++++ 10 files changed, 117 insertions(+), 96 deletions(-) create mode 100644 app/models/card/entropy.rb create mode 100644 test/models/card/entropy_test.rb diff --git a/app/assets/stylesheets/bubble.css b/app/assets/stylesheets/bubble.css index 0b330b06a..43baf342b 100644 --- a/app/assets/stylesheets/bubble.css +++ b/app/assets/stylesheets/bubble.css @@ -52,12 +52,12 @@ } } -.bubble--closing { +.bubble--considering { inset-inline-start: calc(-1 * var(--bubble-gap)); translate: -100% -50%; } -.bubble--considering { +.bubble--doing { inset-inline-end: calc(-1 * var(--bubble-gap)); translate: 100% -50%; } diff --git a/app/models/card.rb b/app/models/card.rb index 69588ea88..bbb5e5843 100644 --- a/app/models/card.rb +++ b/app/models/card.rb @@ -1,7 +1,7 @@ class Card < ApplicationRecord - include Assignable, Colored, Engageable, Eventable, Golden, - Mentions, Pinnable, Closeable, Readable, Searchable, Staged, - Statuses, Taggable, Watchable + include Assignable, Colored, Engageable, Entropy, Eventable, + Golden, Mentions, Pinnable, Closeable, Readable, Searchable, + Staged, Statuses, Taggable, Watchable belongs_to :collection, touch: true belongs_to :creator, class_name: "User", default: -> { Current.user } diff --git a/app/models/card/closeable.rb b/app/models/card/closeable.rb index 46c3a9696..afcdbc53d 100644 --- a/app/models/card/closeable.rb +++ b/app/models/card/closeable.rb @@ -1,8 +1,6 @@ module Card::Closeable extend ActiveSupport::Concern - AUTO_CLOSE_REMINDER_BEFORE = 7.days - included do has_one :closure, dependent: :destroy @@ -10,30 +8,6 @@ module Card::Closeable scope :open, -> { where.missing(:closure) } scope :recently_closed_first, -> { closed.order("closures.created_at": :desc) } - scope :in_auto_closing_collection, -> { joins(:collection).merge(Collection.auto_closing) } - scope :due_to_be_closed, -> { considering.in_auto_closing_collection.where("last_active_at <= DATETIME('now', '-' || auto_close_period || ' seconds')") } - - delegate :auto_closing?, :auto_close_period, to: :collection - end - - class_methods do - def auto_close_all_due - due_to_be_closed.find_each do |card| - card.close(user: User.system, reason: "Closed") - end - end - end - - def auto_close_at - last_active_at + auto_close_period if auto_closing? && last_active_at - end - - def days_until_close - (auto_close_at.to_date - Date.current).to_i if auto_close_at - end - - def closing_soon? - considering? && auto_closing? && Time.current >= auto_close_at - AUTO_CLOSE_REMINDER_BEFORE end def closed? diff --git a/app/models/card/engageable.rb b/app/models/card/engageable.rb index aa5279d64..9db8e6a64 100644 --- a/app/models/card/engageable.rb +++ b/app/models/card/engageable.rb @@ -1,15 +1,11 @@ module Card::Engageable extend ActiveSupport::Concern - STAGNATED_AFTER = 30.days - AUTO_RECONSIDER_REMINDER_BEFORE = 7.days - included do has_one :engagement, dependent: :destroy, class_name: "Card::Engagement" scope :considering, -> { published_or_drafted_by(Current.user).open.where.missing(:engagement) } scope :doing, -> { published.open.joins(:engagement) } - scope :stagnated, -> { doing.where(last_active_at: ..STAGNATED_AFTER.ago) } scope :by_engagement_status, ->(status) do case status.to_s @@ -19,16 +15,6 @@ module Card::Engageable end end - class_methods do - def auto_reconsider_all_stagnated - stagnated.find_each(&:reconsider) - end - end - - def auto_reconsider_at - last_active_at + STAGNATED_AFTER if last_active_at - end - def doing? open? && published? && engagement.present? end @@ -37,6 +23,14 @@ module Card::Engageable open? && published? && engagement.blank? end + def engagement_status + if doing? + "doing" + elsif considering? + "considering" + end + end + def engage unless doing? transaction do diff --git a/app/models/card/entropy.rb b/app/models/card/entropy.rb new file mode 100644 index 000000000..b0eded7a3 --- /dev/null +++ b/app/models/card/entropy.rb @@ -0,0 +1,47 @@ +module Card::Entropy + extend ActiveSupport::Concern + + AUTO_RECONSIDER_PERIOD = 30.days + ENTROPY_REMINDER_BEFORE = 7.days + + included do + scope :in_auto_closing_collection, -> { joins(:collection).merge(Collection.auto_closing) } + + scope :stagnated, -> { doing.where(last_active_at: ..AUTO_RECONSIDER_PERIOD.ago) } + scope :due_to_be_closed, -> { considering.in_auto_closing_collection.where("last_active_at <= DATETIME('now', '-' || auto_close_period || ' seconds')") } + + delegate :auto_closing?, :auto_close_period, to: :collection + end + + class_methods do + def auto_close_all_due + due_to_be_closed.find_each do |card| + card.close(user: User.system, reason: "Closed") + end + end + + def auto_reconsider_all_stagnated + stagnated.find_each(&:reconsider) + end + end + + def subject_to_entropy? + doing? || (auto_closing? && considering?) + end + + def auto_close_at + last_active_at + auto_close_period if auto_closing? && last_active_at + end + + def days_until_close + (auto_close_at.to_date - Date.current).to_i if auto_close_at + end + + def auto_reconsider_at + last_active_at + AUTO_RECONSIDER_PERIOD if last_active_at + end + + def days_until_reconsider + (auto_reconsider_at.to_date - Date.current).to_i if auto_reconsider_at + end +end diff --git a/app/views/cards/display/_preview.html.erb b/app/views/cards/display/_preview.html.erb index eadeadaba..071630a8d 100644 --- a/app/views/cards/display/_preview.html.erb +++ b/app/views/cards/display/_preview.html.erb @@ -33,12 +33,8 @@ <%= render "cards/display/common/background", card: card %> - <% if card.auto_closing? %> - <% if card.considering? %> - <%= render "cards/display/preview/bubble", card: card, style: "closing" %> - <% elsif card.doing? %> - <%= render "cards/display/preview/bubble", card: card, style: "considering" %> - <% end %> + <% if card.subject_to_entropy? %> + <%= render "cards/display/preview/bubble", card: card %> <% end %> <% end %> <% end %> diff --git a/app/views/cards/display/preview/_bubble.html.erb b/app/views/cards/display/preview/_bubble.html.erb index 13faa9920..c05989755 100644 --- a/app/views/cards/display/preview/_bubble.html.erb +++ b/app/views/cards/display/preview/_bubble.html.erb @@ -1,6 +1,6 @@ - -
+
<%= comment.body %>
diff --git a/app/views/cards/container/_title.html.erb b/app/views/cards/container/_title.html.erb index 1767fd854..0a45685e9 100644 --- a/app/views/cards/container/_title.html.erb +++ b/app/views/cards/container/_title.html.erb @@ -4,7 +4,7 @@

<%= link_to card.title, edit_collection_card_path(card.collection, card), class: "card__title-link" %>

-
+
<%= card.description %>