From 2f0c4ae1d02accbd107e500d73cb4b65561dbe9d Mon Sep 17 00:00:00 2001 From: Riley Seaburg Date: Mon, 1 Dec 2025 14:37:19 -0600 Subject: [PATCH 01/14] Add Cargo caching and library linkage verification to CircleCI - Add restore/save cache for Cargo target and registry directories - Use Cargo.lock checksum for cache key to ensure proper invalidation - Verify Rust library linkage with ldd before deploy - Fail fast if library has unresolved dependencies - Should significantly speed up Rust builds on subsequent runs --- .circleci/config.yml | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/.circleci/config.yml b/.circleci/config.yml index 4f672380d..7fed0ba83 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -55,12 +55,42 @@ jobs: rustc --version cargo --version + - restore_cache: + keys: + - v1-cargo-{{ checksum "ext/widget_renderer/Cargo.lock" }} + - v1-cargo- + - run: name: Build widget renderer (Rust) command: | source $HOME/.cargo/env cargo build --release --manifest-path ext/widget_renderer/Cargo.toml + - run: + name: Verify Rust native library linkage + command: | + set -euo pipefail + LIB=ext/widget_renderer/target/release/libwidget_renderer.so + if [ -f "$LIB" ]; then + echo "Found built rust library; verifying linkage..." + if ldd "$LIB" 2>&1 | grep -q "not found"; then + echo "ERROR: Rust library has unresolved dependencies (ldd shows 'not found')." + ldd "$LIB" || true + exit 1 + else + echo "Rust library linkage looks good" + fi + else + echo "No Rust library built - skipping linkage verification" + fi + + - save_cache: + paths: + - ext/widget_renderer/target + - ~/.cargo/registry + - ~/.cargo/git + key: v1-cargo-{{ checksum "ext/widget_renderer/Cargo.lock" }} + # Download and cache dependencies - restore_cache: keys: From decad7339d32b7de8b0b6652f3f983a87e861b34 Mon Sep 17 00:00:00 2001 From: Riley Seaburg Date: Wed, 10 Dec 2025 13:29:28 -0600 Subject: [PATCH 02/14] Fix cx_collections export_csv 500 error - Handle nil organization in User#cx_collections to prevent NoMethodError - Add eager loading for service_provider.organization and cx_collection_details - Add missing database indexes on cx_collections, cx_collection_details, cx_responses Fixes the 'We're sorry, but something went wrong' error at: /admin/cx_collections/export_csv --- app/models/cx_collection.rb | 7 +++++-- app/models/user.rb | 4 +++- ...251210192727_add_indexes_to_cx_collections.rb | 16 ++++++++++++++++ 3 files changed, 24 insertions(+), 3 deletions(-) create mode 100644 db/migrate/20251210192727_add_indexes_to_cx_collections.rb diff --git a/app/models/cx_collection.rb b/app/models/cx_collection.rb index 36c270631..3781c5c5b 100644 --- a/app/models/cx_collection.rb +++ b/app/models/cx_collection.rb @@ -87,7 +87,10 @@ def duplicate!(new_user:) end def self.to_csv - collections = all.includes(:organization, :service_provider, :service, :user).references(:organization).order(:fiscal_year, :quarter, 'organizations.name') + collections = all + .includes(:organization, :service, :user, :cx_collection_details, service_provider: :organization) + .references(:organization) + .order(:fiscal_year, :quarter, 'organizations.name') attributes = %i[ id @@ -118,7 +121,7 @@ def self.to_csv csv << attributes collections.each do |collection| - csv << attributes = [ + csv << [ collection.id, collection.name, collection.organization_id, diff --git a/app/models/user.rb b/app/models/user.rb index 683d4c505..9fce23b96 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -19,7 +19,9 @@ class User < ApplicationRecord def cx_collections user_org = organization - user_parent_org = user_org&.parent + return CxCollection.none if user_org.nil? + + user_parent_org = user_org.parent CxCollection.where(cx_collections: { organization_id: [user_org.id, user_parent_org&.id].compact }) end diff --git a/db/migrate/20251210192727_add_indexes_to_cx_collections.rb b/db/migrate/20251210192727_add_indexes_to_cx_collections.rb new file mode 100644 index 000000000..faec71f0d --- /dev/null +++ b/db/migrate/20251210192727_add_indexes_to_cx_collections.rb @@ -0,0 +1,16 @@ +class AddIndexesToCxCollections < ActiveRecord::Migration[8.0] + def change + # cx_collections table - missing all FK indexes + add_index :cx_collections, :organization_id + add_index :cx_collections, :user_id + add_index :cx_collections, :service_provider_id + add_index :cx_collections, :service_id + + # cx_collection_details table - missing FK index + add_index :cx_collection_details, :cx_collection_id + + # cx_responses table - missing FK indexes + add_index :cx_responses, :cx_collection_detail_id + add_index :cx_responses, :cx_collection_detail_upload_id + end +end From 446fc3c2cb3cced317539728e3104b835494f543 Mon Sep 17 00:00:00 2001 From: Riley Seaburg Date: Wed, 10 Dec 2025 13:59:06 -0600 Subject: [PATCH 03/14] Update schema.rb with new indexes for CircleCI --- db/schema.rb | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/db/schema.rb b/db/schema.rb index 2017dc8bd..7c9b8635d 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -97,6 +97,7 @@ t.text "trust_question_text" t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.index ["cx_collection_id"], name: "index_cx_collection_details_on_cx_collection_id" end create_table "cx_collections", force: :cascade do |t| @@ -124,6 +125,10 @@ t.datetime "created_at", null: false t.datetime "updated_at", null: false t.datetime "submitted_at" + t.index ["organization_id"], name: "index_cx_collections_on_organization_id" + t.index ["service_id"], name: "index_cx_collections_on_service_id" + t.index ["service_provider_id"], name: "index_cx_collections_on_service_provider_id" + t.index ["user_id"], name: "index_cx_collections_on_user_id" end create_table "cx_responses", force: :cascade do |t| @@ -149,6 +154,8 @@ t.datetime "created_at", null: false t.datetime "updated_at", null: false t.string "external_id" + t.index ["cx_collection_detail_id"], name: "index_cx_responses_on_cx_collection_detail_id" + t.index ["cx_collection_detail_upload_id"], name: "index_cx_responses_on_cx_collection_detail_upload_id" end create_table "digital_product_versions", force: :cascade do |t| From 2a919afa87248f316c1f29a063a76ec2059f5120 Mon Sep 17 00:00:00 2001 From: Riley Seaburg Date: Wed, 10 Dec 2025 14:16:25 -0600 Subject: [PATCH 04/14] Update schema version to include new migration --- db/schema.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/db/schema.rb b/db/schema.rb index 7c9b8635d..275383302 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.0].define(version: 2025_07_17_034402) do +ActiveRecord::Schema[8.0].define(version: 2025_12_10_192727) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" From 39a848ccfaf7148af07a25d41872bdd24edeead5 Mon Sep 17 00:00:00 2001 From: Riley Seaburg Date: Thu, 11 Dec 2025 13:16:55 -0600 Subject: [PATCH 05/14] Add tests for User#cx_collections method --- spec/models/user_spec.rb | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 0193f7923..49b8f6bc1 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -165,6 +165,44 @@ end end + describe "#cx_collections" do + let(:user_with_org) { FactoryBot.create(:user, organization: organization) } + let(:service_provider) { FactoryBot.create(:service_provider, organization: organization) } + let(:service) { FactoryBot.create(:service, organization: organization, service_provider: service_provider) } + + context "when user has no organization" do + it "returns an empty collection" do + user_without_org = User.new(email: "test@example.gov") + user_without_org.organization = nil + result = user_without_org.cx_collections + expect(result).to eq(CxCollection.none) + expect(result.count).to eq(0) + end + end + + context "when user has an organization" do + it "returns cx_collections for the user's organization" do + cx_collection = FactoryBot.create(:cx_collection, organization: organization, service: service, service_provider: service_provider, user: user_with_org) + result = user_with_org.cx_collections + expect(result).to include(cx_collection) + end + end + + context "when user's organization has a parent organization" do + let(:parent_org) { FactoryBot.create(:organization, name: "Parent Org", domain: "parent.gov", abbreviation: "PARENT") } + let(:child_org) { FactoryBot.create(:organization, name: "Child Org", domain: "child.gov", abbreviation: "CHILD", parent: parent_org) } + let(:child_user) { FactoryBot.create(:user, organization: child_org) } + let(:parent_service_provider) { FactoryBot.create(:service_provider, organization: parent_org) } + let(:parent_service) { FactoryBot.create(:service, organization: parent_org, service_provider: parent_service_provider) } + + it "includes cx_collections from the parent organization" do + parent_cx_collection = FactoryBot.create(:cx_collection, organization: parent_org, service: parent_service, service_provider: parent_service_provider, user: child_user) + result = child_user.cx_collections + expect(result).to include(parent_cx_collection) + end + end + end + describe "#ensure_organization" do before do @org2 = Organization.create(name: "Subdomain Example", domain: "sub.example.gov", abbreviation: "SUB") From f376cab291d37152b52900cd75baec3f42e506cb Mon Sep 17 00:00:00 2001 From: Riley Seaburg Date: Mon, 15 Dec 2025 10:27:32 -0600 Subject: [PATCH 06/14] Fix widget renderer load when native lib missing (#1913) --- config/initializers/widget_renderer.rb | 5 ++++- ext/widget_renderer/lib/widget_renderer.rb | 23 ++++++++++++++-------- 2 files changed, 19 insertions(+), 9 deletions(-) diff --git a/config/initializers/widget_renderer.rb b/config/initializers/widget_renderer.rb index 608c188b1..69e2c6813 100644 --- a/config/initializers/widget_renderer.rb +++ b/config/initializers/widget_renderer.rb @@ -2,7 +2,7 @@ begin # Try loading the precompiled Rutie extension. require_relative '../../ext/widget_renderer/lib/widget_renderer' - + # Verify the class was properly defined if defined?(WidgetRenderer) && WidgetRenderer.respond_to?(:generate_js) Rails.logger.info "WidgetRenderer: Rust extension loaded successfully! generate_js method available." @@ -14,6 +14,9 @@ rescue LoadError => e Rails.logger.warn "Widget renderer native library not available: #{e.message}" Rails.logger.warn 'Rust extension must be built during staging; falling back to ERB template rendering.' +rescue SystemExit => e + Rails.logger.error "Widget renderer exited during load: #{e.message}" + Rails.logger.warn 'Falling back to ERB template rendering' rescue StandardError => e Rails.logger.error "Widget renderer failed to load: #{e.class}: #{e.message}" Rails.logger.error e.backtrace.join("\n") if e.backtrace diff --git a/ext/widget_renderer/lib/widget_renderer.rb b/ext/widget_renderer/lib/widget_renderer.rb index c6cd72a44..6c4142352 100644 --- a/ext/widget_renderer/lib/widget_renderer.rb +++ b/ext/widget_renderer/lib/widget_renderer.rb @@ -9,13 +9,6 @@ puts "WidgetRenderer: root=#{root}" puts "WidgetRenderer: __dir__=#{__dir__}" -# If a stale module exists, remove it so Rutie can define or reopen the class. -if defined?(WidgetRenderer) && WidgetRenderer.is_a?(Module) && !WidgetRenderer.is_a?(Class) - Object.send(:remove_const, :WidgetRenderer) -end -# Ensure the constant exists as a Class so rb_define_class will reopen it instead of erroring on Module. -WidgetRenderer = Class.new unless defined?(WidgetRenderer) && WidgetRenderer.is_a?(Class) - # Check for library file extensions based on platform lib_extensions = %w[.so .bundle .dylib] lib_names = lib_extensions.map { |ext| "libwidget_renderer#{ext}" } @@ -84,6 +77,13 @@ end end end + + # If a stale module exists, remove it so Rutie can define or reopen the class. + if defined?(WidgetRenderer) && WidgetRenderer.is_a?(Module) && !WidgetRenderer.is_a?(Class) + Object.send(:remove_const, :WidgetRenderer) + end + # Ensure the constant exists as a Class so rb_define_class will reopen it instead of erroring on Module. + WidgetRenderer = Class.new unless defined?(WidgetRenderer) && WidgetRenderer.is_a?(Class) else puts 'WidgetRenderer: Library not found in any checked path. Listing root contents:' # List files in root to help debug @@ -104,6 +104,9 @@ else puts "WidgetRenderer: target/release directory does not exist at #{release_dir}" end + + # No native library available; let caller handle fallback. + raise LoadError, 'WidgetRenderer native library not found' end # Rutie expects the project root and appends /target/release/lib.so @@ -112,4 +115,8 @@ puts "WidgetRenderer: Initializing Rutie with path: #{path}" -Rutie.new(:widget_renderer).init 'Init_widget_renderer', path +begin + Rutie.new(:widget_renderer).init 'Init_widget_renderer', path +rescue SystemExit => e + raise LoadError, "WidgetRenderer native init exited: #{e.message}" +end From 2b1fb3a6aebfea2134d8145638d76f73942dd0bd Mon Sep 17 00:00:00 2001 From: Riley Seaburg Date: Mon, 15 Dec 2025 11:09:22 -0600 Subject: [PATCH 07/14] Fix User#cx_collections specs for Service owner (#1915) --- spec/models/user_spec.rb | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 49b8f6bc1..9b41ac73f 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -168,7 +168,7 @@ describe "#cx_collections" do let(:user_with_org) { FactoryBot.create(:user, organization: organization) } let(:service_provider) { FactoryBot.create(:service_provider, organization: organization) } - let(:service) { FactoryBot.create(:service, organization: organization, service_provider: service_provider) } + let(:service) { FactoryBot.create(:service, organization: organization, service_provider: service_provider, service_owner_id: user_with_org.id) } context "when user has no organization" do it "returns an empty collection" do @@ -192,8 +192,9 @@ let(:parent_org) { FactoryBot.create(:organization, name: "Parent Org", domain: "parent.gov", abbreviation: "PARENT") } let(:child_org) { FactoryBot.create(:organization, name: "Child Org", domain: "child.gov", abbreviation: "CHILD", parent: parent_org) } let(:child_user) { FactoryBot.create(:user, organization: child_org) } + let(:parent_service_owner) { FactoryBot.create(:user, organization: parent_org) } let(:parent_service_provider) { FactoryBot.create(:service_provider, organization: parent_org) } - let(:parent_service) { FactoryBot.create(:service, organization: parent_org, service_provider: parent_service_provider) } + let(:parent_service) { FactoryBot.create(:service, organization: parent_org, service_provider: parent_service_provider, service_owner_id: parent_service_owner.id) } it "includes cx_collections from the parent organization" do parent_cx_collection = FactoryBot.create(:cx_collection, organization: parent_org, service: parent_service, service_provider: parent_service_provider, user: child_user) From bbcb0ac69aaa623a444deb931c7a2a26c7d0e298 Mon Sep 17 00:00:00 2001 From: Riley Seaburg Date: Thu, 18 Dec 2025 13:07:18 -0600 Subject: [PATCH 08/14] Fix production manifest for rust buildpack (#1918) --- touchpoints.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/touchpoints.yml b/touchpoints.yml index e20b17f88..3f59c2101 100644 --- a/touchpoints.yml +++ b/touchpoints.yml @@ -1,6 +1,7 @@ applications: - name: touchpoints memory: 2G + disk_quota: 2G command: bundle exec rake cf:on_first_instance db:migrate && bundle exec rails s -b 0.0.0.0 -p $PORT -e $RAILS_ENV env: AWS_SES_ACCESS_KEY_ID: @@ -20,12 +21,12 @@ applications: TOUCHPOINTS_GTM_CONTAINER_ID: TOUCHPOINTS_WEB_DOMAIN: touchpoints.app.cloud.gov buildpacks: - - rust_buildpack + - https://github.com/rileyseaburg/rust-buildpack-touchpoints.git - nodejs_buildpack - ruby_buildpack services: - touchpoints-prod-db - - touchpoints-prod-redis + - touchpoints-redis-service - touchpoints-prod-s3 - touchpoints-prod-deployer routes: From 35b2d514b50489411b60eef705cb68d00477fc75 Mon Sep 17 00:00:00 2001 From: Riley Seaburg Date: Mon, 22 Dec 2025 09:16:42 -0600 Subject: [PATCH 09/14] Fix Rust widget renderer modal button initialization (#1924) * Production release: Fix CX Collections export CSV error (#1911) * Add Cargo caching and library linkage verification to CircleCI - Add restore/save cache for Cargo target and registry directories - Use Cargo.lock checksum for cache key to ensure proper invalidation - Verify Rust library linkage with ldd before deploy - Fail fast if library has unresolved dependencies - Should significantly speed up Rust builds on subsequent runs * Fix cx_collections export_csv 500 error - Handle nil organization in User#cx_collections to prevent NoMethodError - Add eager loading for service_provider.organization and cx_collection_details - Add missing database indexes on cx_collections, cx_collection_details, cx_responses Fixes the 'We're sorry, but something went wrong' error at: /admin/cx_collections/export_csv * Update schema.rb with new indexes for CircleCI * Update schema version to include new migration * Add tests for User#cx_collections method * fix: use git URL for rust buildpack in production manifest * fix: correct redis service name in production manifest * Release: WidgetRenderer load fix (#1914) * Add Cargo caching and library linkage verification to CircleCI - Add restore/save cache for Cargo target and registry directories - Use Cargo.lock checksum for cache key to ensure proper invalidation - Verify Rust library linkage with ldd before deploy - Fail fast if library has unresolved dependencies - Should significantly speed up Rust builds on subsequent runs * Fix cx_collections export_csv 500 error - Handle nil organization in User#cx_collections to prevent NoMethodError - Add eager loading for service_provider.organization and cx_collection_details - Add missing database indexes on cx_collections, cx_collection_details, cx_responses Fixes the 'We're sorry, but something went wrong' error at: /admin/cx_collections/export_csv * Update schema.rb with new indexes for CircleCI * Update schema version to include new migration * Add tests for User#cx_collections method * Fix widget renderer load when native lib missing (#1913) * Fix User#cx_collections specs for Service owner (#1915) (#1917) * Release: set prod disk quota to 2G (#1919) * Add Cargo caching and library linkage verification to CircleCI - Add restore/save cache for Cargo target and registry directories - Use Cargo.lock checksum for cache key to ensure proper invalidation - Verify Rust library linkage with ldd before deploy - Fail fast if library has unresolved dependencies - Should significantly speed up Rust builds on subsequent runs * Fix cx_collections export_csv 500 error - Handle nil organization in User#cx_collections to prevent NoMethodError - Add eager loading for service_provider.organization and cx_collection_details - Add missing database indexes on cx_collections, cx_collection_details, cx_responses Fixes the 'We're sorry, but something went wrong' error at: /admin/cx_collections/export_csv * Update schema.rb with new indexes for CircleCI * Update schema version to include new migration * Add tests for User#cx_collections method * Fix widget renderer load when native lib missing (#1913) * Fix User#cx_collections specs for Service owner (#1915) * Fix production manifest for rust buildpack (#1918) * fix: remove empty secret keys from manifest to prevent wiping env vars on deploy Empty values in the manifest were overwriting secrets set via cf set-env. Secrets should only be managed via cf set-env, not in the manifest. * fix: Use fba-usa-modal class for USWDS Modal compatibility * fix: Add CSS support to Rust widget renderer to fix modal positioning * fix: Use parent_id instead of parent in Organization factory * Increase Cloud Foundry start timeout to 180s and fix Sidekiq health check type * Fix Sidekiq crash and optimize Rust build script - Added rust-buildpack to Sidekiq deployment - Updated build_widget_renderer.sh to handle workspace paths and avoid rebuilds - Added rust-buildpack to touchpoints-demo manifest * build(cf): simplify widget_renderer build script and ignore rules Refactors the `.profile.d/build_widget_renderer.sh` script to remove complex logic for locating Rust dependencies, building from source, and checking library linkage. The script now focuses solely on setting `LD_LIBRARY_PATH` and ensuring the prebuilt `libwidget_renderer.so` is in the correct location. Additionally, updates `.cfignore` to exclude the `target/` directory and specific release artifacts, while ensuring the root `libwidget_renderer.so` is preserved. This suggests a shift towards deploying a precompiled binary rather than building the Rust extension during the Cloud Foundry deployment process. * Increase deployment timeout and add widget_renderer fallback to resolve startup crashes * fix(widget_renderer): restore LoadError and adjust app timeout - Re-enable raising `LoadError` in `widget_renderer.rb` when the native library is missing, removing the previous fallback log message. This ensures the application fails fast if the required extension is absent. - Reduce application timeout from 600 to 180 seconds in `touchpoints.yml` to better align with platform constraints or performance expectations. - Remove the hardcoded `WEB_CONCURRENCY: 1` environment variable from `touchpoints.yml`, allowing the buildpack or platform defaults to manage concurrency. * ci: increase deployment wait time and enable static files in dev - Increase the `max_wait` time in `.circleci/deploy.sh` from 600 to 800 seconds to prevent timeouts during longer deployment processes. - Update `config/environments/development.rb` to conditionally enable the public file server based on the `RAILS_SERVE_STATIC_FILES` environment variable, aligning development behavior with other environments when needed. * ci(deploy): increase timeouts and enable static file serving - Increase Cloud Foundry push timeout from 180s to 600s in deployment scripts and `touchpoints.yml` to prevent timeouts during startup. - Enable `RAILS_SERVE_STATIC_FILES` in production configuration and manifest to allow the application to serve precompiled assets directly. - Update `deploy.sh` to accept a manifest path argument and refactor retry logic for better error handling. * Decouple db:migrate from deploy: migrations must be run separately to avoid CF 180s timeout * Add automated pre-deploy migrations via cf run-task to avoid 180s timeout during app start * Set health check type to process for sidekiq worker before rolling deploy * Fix sidekiq worker timeout: explicitly set to 180s before rolling deploy * Fix flaky logo upload test: add wait time to prevent Selenium stale element race condition * Fix cf set-health-check: use --invocation-timeout instead of --timeout * Fix Rack::Attack test: create actual form fixture to avoid 404 responses * Scale sidekiq worker to 1 instance during rolling deploy to avoid org memory quota exceeded * Stop sidekiq worker before push to free memory for staging (avoids org quota exceeded) * Fix cf run-task syntax: add --command flag for migrations * Skip WidgetRenderer load during migrations - library not built in task droplet * Fix Rack::Attack test: add valid submission params to avoid 400 errors * Temporarily disable pre-deploy migrations to unblock deployment * Fix widget_renderer initializer - use simpler skip detection logic * Fix deployment: restore db:migrate in manifest and enable migrations in deploy script * Revert migrations to start command - Rust library not available in cf tasks * Build Rust library at runtime in .profile.d script * Revert to working widget_renderer script that copies prebuilt library * Keep prebuilt Rust library during deployment to ensure correct linking with CF Ruby installation * Fix deploy-sidekiq.sh: remove explicit buildpack flags to avoid re-installing Rust - Remove -b flags from cf push in deploy-sidekiq.sh - Let CF auto-detect buildpacks from app metadata like deploy.sh does - Prevents unnecessary reinstallation of Rust during staging - Matches web deployment behavior for consistency * Fix touchpoints.yml: comment out buildpacks to prevent Rust reinstallation - Comment out explicit buildpacks in manifest - Let CF auto-detect buildpacks from app metadata - Prevents re-running supply phase (Rust installation) during deployment - Rust library already built in CircleCI via bundle install (gem extensions) - Matches deploy-sidekiq.sh approach for consistency * Fix flaky timing test in submission_digest mailer spec Use a single time_threshold let variable to ensure consistent timestamp comparison instead of calling days_ago.days.ago multiple times which can result in 1-second differences in CI environments. * Fix custom-button-modal USWDS initialization - Add try/catch error handling - Add conditional checks for fbaUswds methods - Initialize custom button element for custom-button-modal - Prevents modal from appearing visible at page bottom * Bump Cargo version to force Rust rebuild * Bump widget_renderer version to force Cargo rebuild * Force cargo clean before build to ensure recompilation * Bump widget_renderer gem version to 0.1.2 to force rebuild * Force Rust rebuild with BUILD_ID and version bump * Bump widget_renderer to 0.1.2 to force CF to rebuild native extension * Update Gemfile.lock for widget_renderer 0.1.2 * Add Rust library verification before CF push * Prioritize workspace-level Rust library and bump to 0.1.3 * Invalidate CircleCI cargo cache to force fresh Rust build * Fix Rust widget renderer modal button initialization The Rust widget renderer was missing the USWDS Modal initialization for the #fba-button element used in 'modal' delivery method forms. This caused the toast/feedback button to not open the modal when clicked, instead rendering the form inline at the bottom of the page. Added initialization for #fba-button to match the ERB template behavior in _fba.js.erb (lines 858-875). Fixes Zendesk ticket #37620 * Fix breaking changes from PR review 1. Fix modal_class prefix logic: Now respects load_css setting - When load_css=true: uses 'fba-usa-modal' prefix - When load_css=false: uses 'usa-modal' (no prefix) - Matches ERB template behavior in _fba.js.erb line 110 2. Fix CSS backtick escaping: Added escape for backticks in CSS - Prevents JavaScript syntax errors when CSS contains backticks - CSS is inserted into JS template literals using backticks 3. Remove expired certificate file: tmp_expired_login_gov_cert.pem - Certificate expired Aug 2023 - Added *.pem to .gitignore to prevent future accidental commits --- .cfignore | 5 +- .circleci/config.yml | 25 ++-- .circleci/deploy-sidekiq.sh | 43 +++++- .circleci/deploy.sh | 114 ++++++++++++-- .circleci/sync-login-gov-env.sh | 42 ++++++ .gitignore | 3 + .profile.d/build_widget_renderer.sh | 147 ++----------------- Cargo.lock | 2 +- Cargo.toml | 7 +- Gemfile.lock | 2 +- app/models/form.rb | 27 ++++ config/environments/development.rb | 2 + config/environments/production.rb | 3 + config/initializers/widget_renderer.rb | 56 ++++--- config/routes.rb | 2 +- ext/widget_renderer/BUILD_ID | 1 + ext/widget_renderer/Cargo.toml | 2 +- ext/widget_renderer/extconf.rb | 5 +- ext/widget_renderer/lib/widget_renderer.rb | 5 +- ext/widget_renderer/src/form_data.rs | 2 + ext/widget_renderer/src/template_renderer.rs | 77 ++++++++-- ext/widget_renderer/widget_renderer.gemspec | 4 +- spec/features/admin/forms_spec.rb | 8 +- spec/mailers/user_mailer_spec.rb | 7 +- spec/models/user_spec.rb | 2 +- spec/requests/rack_attack_spec.rb | 18 ++- touchpoints-demo.yml | 2 + touchpoints-staging.yml | 3 +- touchpoints.yml | 28 ++-- 29 files changed, 412 insertions(+), 232 deletions(-) create mode 100755 .circleci/sync-login-gov-env.sh create mode 100644 ext/widget_renderer/BUILD_ID diff --git a/.cfignore b/.cfignore index 0fe202624..848d124e3 100644 --- a/.cfignore +++ b/.cfignore @@ -38,10 +38,7 @@ /public/packs-test /node_modules -# Ignore Rust build artifacts, but keep the prebuilt widget library +# Ignore Rust build artifacts target/ ext/widget_renderer/target/ -!ext/widget_renderer/target/ -!ext/widget_renderer/target/release/ -!ext/widget_renderer/target/release/libwidget_renderer.so !ext/widget_renderer/libwidget_renderer.so diff --git a/.circleci/config.yml b/.circleci/config.yml index 7fed0ba83..ca9b0391d 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -57,8 +57,8 @@ jobs: - restore_cache: keys: - - v1-cargo-{{ checksum "ext/widget_renderer/Cargo.lock" }} - - v1-cargo- + - v2-cargo-{{ checksum "ext/widget_renderer/Cargo.lock" }} + - v2-cargo- - run: name: Build widget renderer (Rust) @@ -89,7 +89,7 @@ jobs: - ext/widget_renderer/target - ~/.cargo/registry - ~/.cargo/git - key: v1-cargo-{{ checksum "ext/widget_renderer/Cargo.lock" }} + key: v2-cargo-{{ checksum "ext/widget_renderer/Cargo.lock" }} # Download and cache dependencies - restore_cache: @@ -147,12 +147,11 @@ jobs: echo "Skipping Sidekiq deploy on parallel node ${CIRCLE_NODE_INDEX}" exit 0 fi - # Remove prebuilt Rust library - it must be built on CF with the correct Ruby paths - # The library built on CircleCI links against /usr/local/lib/libruby.so.3.2 - # but on CF, Ruby is in /home/vcap/deps/*/ruby/lib/ - echo "Removing prebuilt Rust library (will be rebuilt on CF)..." - rm -rf ext/widget_renderer/target/release/libwidget_renderer.so 2>/dev/null || true - rm -f ext/widget_renderer/libwidget_renderer.so 2>/dev/null || true + # Keep prebuilt Rust library - extconf.rb builds it during bundle install with correct paths + # The library is built with rutie which properly links against the CF Ruby installation + # echo "Removing prebuilt Rust library (will be rebuilt on CF)..." + # rm -rf ext/widget_renderer/target/release/libwidget_renderer.so 2>/dev/null || true + # rm -f ext/widget_renderer/libwidget_renderer.so 2>/dev/null || true ./.circleci/deploy-sidekiq.sh no_output_timeout: 30m @@ -166,10 +165,10 @@ jobs: fi # Wait for Sidekiq deployment to complete before starting web deploy sleep 120 - # Remove prebuilt Rust library - it must be built on CF with the correct Ruby paths - echo "Removing prebuilt Rust library (will be rebuilt on CF)..." - rm -rf ext/widget_renderer/target/release/libwidget_renderer.so 2>/dev/null || true - rm -f ext/widget_renderer/libwidget_renderer.so 2>/dev/null || true + # Keep prebuilt Rust library - extconf.rb builds it during bundle install with correct paths + # echo "Removing prebuilt Rust library (will be rebuilt on CF)..." + # rm -rf ext/widget_renderer/target/release/libwidget_renderer.so 2>/dev/null || true + # rm -f ext/widget_renderer/libwidget_renderer.so 2>/dev/null || true ./.circleci/deploy.sh no_output_timeout: 30m diff --git a/.circleci/deploy-sidekiq.sh b/.circleci/deploy-sidekiq.sh index eddd5d8af..df34be894 100755 --- a/.circleci/deploy-sidekiq.sh +++ b/.circleci/deploy-sidekiq.sh @@ -102,10 +102,43 @@ cf_push_with_retry() { # Wait for any in-progress deployment wait_for_deployment "$app_name" + # Update app to use 180s invocation timeout and process health check before rolling deploy + echo "Updating health check configuration for $app_name..." + cf set-health-check "$app_name" process --invocation-timeout 180 || true + sleep 2 + + # Get current instance count and scale down to 1 to avoid memory quota issues during rolling deploy + echo "Checking current instance count for $app_name..." + local current_instances=$(cf app "$app_name" | grep "^instances:" | awk '{print $2}' | cut -d'/' -f2 || echo "1") + echo "Current instances: $current_instances" + + if [ "$current_instances" -gt 1 ]; then + echo "Scaling down to 1 instance to free memory for rolling deploy..." + cf scale "$app_name" -i 1 || true + sleep 5 + fi + for i in $(seq 1 $max_retries); do echo "Attempt $i of $max_retries to push $app_name..." - if cf push "$app_name" --strategy rolling; then + + # Stop the app first to free memory for staging + echo "Stopping $app_name to free memory for staging..." + cf stop "$app_name" || true + sleep 5 + + # Push without rolling strategy (direct replacement since we stopped it) + # Let CF auto-detect buildpacks to avoid re-running supply phase (Rust already built in CircleCI) + if cf push "$app_name" \ + -t 180 \ + --health-check-type process; then echo "Successfully pushed $app_name" + + # Scale back up to original instance count + if [ "$current_instances" -gt 1 ]; then + echo "Scaling up to $current_instances instances..." + cf scale "$app_name" -i "$current_instances" || true + fi + release_deploy_lock "$app_name" trap - EXIT # Clear the trap return 0 @@ -120,6 +153,12 @@ cf_push_with_retry() { fi done + # If we failed, try to scale back up anyway + if [ "$current_instances" -gt 1 ]; then + echo "Deploy failed, attempting to scale back up to $current_instances instances..." + cf scale "$app_name" -i "$current_instances" || true + fi + release_deploy_lock "$app_name" trap - EXIT # Clear the trap echo "Failed to push $app_name after $max_retries attempts" @@ -132,6 +171,8 @@ then # Log into CF and push cf login -a $CF_API_ENDPOINT -u $CF_PRODUCTION_SPACE_DEPLOYER_USERNAME -p $CF_PRODUCTION_SPACE_DEPLOYER_PASSWORD -o $CF_ORG -s prod echo "PUSHING to PRODUCTION..." + echo "Syncing Login.gov environment variables..." + ./.circleci/sync-login-gov-env.sh touchpoints-production-sidekiq-worker cf_push_with_retry touchpoints-production-sidekiq-worker echo "Push to Production Complete." else diff --git a/.circleci/deploy.sh b/.circleci/deploy.sh index ad4171d6c..fc3e3629a 100755 --- a/.circleci/deploy.sh +++ b/.circleci/deploy.sh @@ -63,7 +63,7 @@ release_deploy_lock() { # Wait for any in-progress deployments to complete before starting wait_for_deployment() { local app_name="$1" - local max_wait=600 # 10 minutes max + local max_wait=800 # 13 minutes and 20 seconds max local wait_interval=15 local waited=0 @@ -87,12 +87,87 @@ wait_for_deployment() { return 0 } +# Run migrations as a CF task and wait for completion +run_migrations() { + local app_name="$1" + local max_wait=1800 # 30 minutes max for migrations + local wait_interval=10 + local waited=0 + + echo "Running database migrations for $app_name..." + + # Start migration task + local task_output=$(cf run-task "$app_name" --command "bundle exec rails db:migrate" --name "pre-deploy-migrations" 2>&1) + echo "$task_output" + + # Extract task ID from output + local task_id=$(echo "$task_output" | grep -oE 'task id:[[:space:]]+[0-9]+' | grep -oE '[0-9]+' || echo "") + + if [ -z "$task_id" ]; then + echo "Warning: Could not determine task ID, checking tasks list..." + sleep 5 + task_id=$(cf tasks "$app_name" | grep "pre-deploy-migrations" | grep "RUNNING" | head -1 | awk '{print $1}') + fi + + if [ -z "$task_id" ]; then + echo "Error: Failed to start migration task" + return 1 + fi + + echo "Migration task started with ID: $task_id" + echo "Waiting for migrations to complete..." + + # Wait for task to complete + while [ $waited -lt $max_wait ]; do + local task_state=$(cf tasks "$app_name" | grep "^$task_id " | awk '{print $3}') + + if [ "$task_state" == "SUCCEEDED" ]; then + echo "✓ Migrations completed successfully" + return 0 + elif [ "$task_state" == "FAILED" ]; then + echo "✗ Migration task failed. Checking logs..." + cf logs "$app_name" --recent | grep "pre-deploy-migrations" | tail -50 + return 1 + fi + + if [ $((waited % 30)) -eq 0 ]; then + echo "Migration task still running (state: $task_state, waited ${waited}s)..." + fi + + sleep $wait_interval + waited=$((waited + wait_interval)) + done + + echo "Error: Migration task did not complete within ${max_wait}s" + cf logs "$app_name" --recent | grep "pre-deploy-migrations" | tail -50 + return 1 +} + # Retry function to handle staging and deployment conflicts cf_push_with_retry() { local app_name="$1" + local manifest_path="${2:-}" + local run_migrations="${3:-false}" local max_retries=5 local retry_delay=90 + # Run migrations first if requested + if [ "$run_migrations" == "true" ]; then + if ! run_migrations "$app_name"; then + echo "Error: Migrations failed, aborting deployment" + return 1 + fi + fi + + # Ensure CircleCI-built Rust library is present + if [ -f "ext/widget_renderer/target/release/libwidget_renderer.so" ]; then + echo "CircleCI-built Rust library found, will be included in deployment" + file ext/widget_renderer/target/release/libwidget_renderer.so + readelf -n ext/widget_renderer/target/release/libwidget_renderer.so | grep "Build ID" || true + else + echo "WARNING: No CircleCI-built Rust library found at ext/widget_renderer/target/release/libwidget_renderer.so" + fi + # Acquire lock first acquire_deploy_lock "$app_name" @@ -104,19 +179,30 @@ cf_push_with_retry() { for i in $(seq 1 $max_retries); do echo "Attempt $i of $max_retries to push $app_name..." - if cf push "$app_name" --strategy rolling; then + local exit_code=0 + + set +e + if [ -n "$manifest_path" ]; then + echo "Using manifest: $manifest_path" + cf push "$app_name" -f "$manifest_path" --strategy rolling -t 180 + else + cf push "$app_name" --strategy rolling -t 180 + fi + exit_code=$? + set -e + + if [ $exit_code -eq 0 ]; then echo "Successfully pushed $app_name" release_deploy_lock "$app_name" trap - EXIT # Clear the trap return 0 - else - local exit_code=$? - if [ $i -lt $max_retries ]; then - echo "Push failed (exit code: $exit_code), waiting ${retry_delay}s before retry..." - sleep $retry_delay - # Re-check for in-progress deployments before retrying - wait_for_deployment "$app_name" - fi + fi + + if [ $i -lt $max_retries ]; then + echo "Push failed (exit code: $exit_code), waiting ${retry_delay}s before retry..." + sleep $retry_delay + # Re-check for in-progress deployments before retrying + wait_for_deployment "$app_name" fi done @@ -132,7 +218,9 @@ then # Log into CF and push cf login -a $CF_API_ENDPOINT -u $CF_PRODUCTION_SPACE_DEPLOYER_USERNAME -p $CF_PRODUCTION_SPACE_DEPLOYER_PASSWORD -o $CF_ORG -s prod echo "PUSHING web servers to Production..." - cf_push_with_retry touchpoints + echo "Syncing Login.gov environment variables..." + ./.circleci/sync-login-gov-env.sh touchpoints + cf_push_with_retry touchpoints touchpoints.yml false echo "Push to Production Complete." else echo "Not on the production branch." @@ -144,7 +232,7 @@ then # Log into CF and push cf login -a $CF_API_ENDPOINT -u $CF_USERNAME -p $CF_PASSWORD -o $CF_ORG -s $CF_SPACE echo "Pushing web servers to Demo..." - cf_push_with_retry touchpoints-demo + cf_push_with_retry touchpoints-demo "" true echo "Push to Demo Complete." else echo "Not on the main branch." @@ -156,7 +244,7 @@ then # Log into CF and push cf login -a $CF_API_ENDPOINT -u $CF_USERNAME -p $CF_PASSWORD -o $CF_ORG -s $CF_SPACE echo "Pushing web servers to Staging..." - cf_push_with_retry touchpoints-staging + cf_push_with_retry touchpoints-staging "" true echo "Push to Staging Complete." else echo "Not on the develop branch." diff --git a/.circleci/sync-login-gov-env.sh b/.circleci/sync-login-gov-env.sh new file mode 100755 index 000000000..e869bc618 --- /dev/null +++ b/.circleci/sync-login-gov-env.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash + +set -euo pipefail + +require_env() { + local var_name="$1" + if [ -z "${!var_name:-}" ]; then + echo "Missing required env var: ${var_name}" >&2 + exit 1 + fi +} + +escape_private_key() { + ruby -e 'print STDIN.read.gsub("\r\n", "\n").gsub("\n", "\\n")' +} + +sync_login_gov_env() { + local app_name="$1" + + require_env LOGIN_GOV_CLIENT_ID + require_env LOGIN_GOV_IDP_BASE_URL + require_env LOGIN_GOV_REDIRECT_URI + require_env LOGIN_GOV_PRIVATE_KEY + + local private_key_escaped + private_key_escaped="$(printf "%s" "${LOGIN_GOV_PRIVATE_KEY}" | escape_private_key)" + + cf set-env "$app_name" LOGIN_GOV_CLIENT_ID "$LOGIN_GOV_CLIENT_ID" >/dev/null + cf set-env "$app_name" LOGIN_GOV_IDP_BASE_URL "$LOGIN_GOV_IDP_BASE_URL" >/dev/null + cf set-env "$app_name" LOGIN_GOV_REDIRECT_URI "$LOGIN_GOV_REDIRECT_URI" >/dev/null + cf set-env "$app_name" LOGIN_GOV_PRIVATE_KEY "$private_key_escaped" >/dev/null + + echo "Synced Login.gov env to ${app_name}" +} + +if [ "${1:-}" == "" ]; then + echo "Usage: $0 " >&2 + exit 2 +fi + +sync_login_gov_env "$1" + diff --git a/.gitignore b/.gitignore index b651e9174..6c87fa9ec 100644 --- a/.gitignore +++ b/.gitignore @@ -53,3 +53,6 @@ ext/widget_renderer/Makefile ext/widget_renderer/*.dylib # Keep the prebuilt Linux .so for Cloud Foundry deployment !ext/widget_renderer/libwidget_renderer.so + +# Certificate files (avoid accidental commits of sensitive keys/certs) +*.pem diff --git a/.profile.d/build_widget_renderer.sh b/.profile.d/build_widget_renderer.sh index 2f11d89fe..75b6b87c7 100755 --- a/.profile.d/build_widget_renderer.sh +++ b/.profile.d/build_widget_renderer.sh @@ -1,147 +1,32 @@ #!/usr/bin/env bash -# We want failures in optional copy steps to fall through to the build step, -# not kill the process before Rails boots. set -uo pipefail -# CRITICAL: Set LD_LIBRARY_PATH so the Rust extension can find libruby.so at runtime -# The Ruby buildpack installs Ruby under /home/vcap/deps/*/ruby/lib/ - -# First, try to find Ruby's libdir using ruby itself (most reliable) +# 1. Set LD_LIBRARY_PATH so the Rust extension can find libruby.so if command -v ruby >/dev/null 2>&1; then RUBY_LIB_DIR=$(ruby -e 'require "rbconfig"; print RbConfig::CONFIG["libdir"]' 2>/dev/null || true) if [ -n "$RUBY_LIB_DIR" ] && [ -d "$RUBY_LIB_DIR" ]; then export LD_LIBRARY_PATH="${RUBY_LIB_DIR}:${LD_LIBRARY_PATH:-}" - echo "===> widget_renderer: Added Ruby libdir ${RUBY_LIB_DIR} to LD_LIBRARY_PATH" fi fi -# Also scan deps directories as a fallback -for dep_dir in /home/vcap/deps/*/; do - # Check for Ruby library directory - if [ -d "${dep_dir}ruby/lib" ]; then - if [ -f "${dep_dir}ruby/lib/libruby.so.3.2" ] || [ -f "${dep_dir}ruby/lib/libruby.so" ]; then - export LD_LIBRARY_PATH="${dep_dir}ruby/lib:${LD_LIBRARY_PATH:-}" - echo "===> widget_renderer: Added ${dep_dir}ruby/lib to LD_LIBRARY_PATH" - fi - fi -done - -# Make sure LD_LIBRARY_PATH is exported for the app process -echo "===> widget_renderer: Final LD_LIBRARY_PATH=${LD_LIBRARY_PATH:-}" - -if [ -d "${HOME}/ext/widget_renderer" ]; then - EXT_DIR="${HOME}/ext/widget_renderer" -elif [ -d "${HOME}/app/ext/widget_renderer" ]; then +# 2. Locate the extension directory +if [ -d "${HOME}/app/ext/widget_renderer" ]; then EXT_DIR="${HOME}/app/ext/widget_renderer" else - echo "===> widget_renderer: extension directory not found under HOME: ${HOME}" - exit 1 -fi -LIB_SO="${EXT_DIR}/libwidget_renderer.so" -LIB_TARGET="${EXT_DIR}/target/release/libwidget_renderer.so" - -echo "===> widget_renderer: checking for native library in ${EXT_DIR}" - -# Function to check if library has correct linkage (libruby.so resolves) -check_library_linkage() { - local lib_path="$1" - if [ ! -f "$lib_path" ]; then - return 1 - fi - # Check if ldd shows "libruby.so.3.2 => not found" - if ldd "$lib_path" 2>&1 | grep -q "libruby.*not found"; then - echo "===> widget_renderer: Library at $lib_path has broken linkage (libruby not found)" - return 1 - fi - return 0 -} - -# Function to build the Rust extension -build_rust_extension() { - echo "===> widget_renderer: Building native extension with Cargo" - - # Find the Rust installation from the Rust buildpack - CARGO_BIN="" - for dep_dir in /home/vcap/deps/*/; do - if [ -x "${dep_dir}rust/cargo/bin/cargo" ]; then - CARGO_BIN="${dep_dir}rust/cargo/bin/cargo" - export CARGO_HOME="${dep_dir}rust/cargo" - export RUSTUP_HOME="${dep_dir}rust/rustup" - export PATH="${dep_dir}rust/cargo/bin:$PATH" - break - fi - done - - if [ -z "$CARGO_BIN" ]; then - echo "===> widget_renderer: ERROR - Cargo not found in deps" - echo "===> widget_renderer: Skipping build - app will fail if Rust extension is required" - return 1 - fi - - echo "===> widget_renderer: Using cargo at $CARGO_BIN" - echo "===> widget_renderer: CARGO_HOME=${CARGO_HOME:-unset}" - echo "===> widget_renderer: RUSTUP_HOME=${RUSTUP_HOME:-unset}" - - # Tell rutie to link against the shared Ruby library provided by the Ruby buildpack. - RUBY_LIB_PATH=$(ruby -e 'require "rbconfig"; print RbConfig::CONFIG["libdir"]') - RUBY_SO_NAME=$(ruby -e 'require "rbconfig"; print RbConfig::CONFIG["RUBY_SO_NAME"]') - export RUTIE_RUBY_LIB_PATH="$RUBY_LIB_PATH" - export RUTIE_RUBY_LIB_NAME="$RUBY_SO_NAME" - unset RUBY_STATIC - export NO_LINK_RUTIE=1 - - echo "===> widget_renderer: Building with RUTIE_RUBY_LIB_PATH=$RUBY_LIB_PATH" - - cd "$EXT_DIR" - - # Clean old build artifacts that may have wrong linkage - rm -rf target/release/libwidget_renderer.so 2>/dev/null || true - rm -f libwidget_renderer.so 2>/dev/null || true - - # Build with Cargo - "$CARGO_BIN" build --release 2>&1 - - if [ -f "target/release/libwidget_renderer.so" ]; then - cp target/release/libwidget_renderer.so . - echo "===> widget_renderer: Successfully built native extension" - echo "===> widget_renderer: Library dependencies:" - ldd target/release/libwidget_renderer.so 2>&1 || true - return 0 - else - echo "===> widget_renderer: ERROR - Build failed, library not found" - ls -la target/release/ 2>&1 || true - return 1 - fi -} - -# Check if we have a library with correct linkage -NEED_BUILD=false - -if [ -f "$LIB_TARGET" ]; then - echo "===> widget_renderer: Found library at $LIB_TARGET" - if check_library_linkage "$LIB_TARGET"; then - echo "===> widget_renderer: Library linkage OK, copying to expected location" - cp "$LIB_TARGET" "$LIB_SO" 2>/dev/null || true - else - echo "===> widget_renderer: Library has broken linkage, will rebuild" - NEED_BUILD=true - fi -elif [ -f "$LIB_SO" ]; then - echo "===> widget_renderer: Found library at $LIB_SO" - if check_library_linkage "$LIB_SO"; then - echo "===> widget_renderer: Library linkage OK" - else - echo "===> widget_renderer: Library has broken linkage, will rebuild" - NEED_BUILD=true - fi -else - echo "===> widget_renderer: No library found, will build" - NEED_BUILD=true + EXT_DIR="${HOME}/ext/widget_renderer" fi -# Build if needed -if [ "$NEED_BUILD" = true ]; then - build_rust_extension +# 3. Copy the pre-built library from the target directory to the extension directory +# The Rust buildpack puts compiled artifacts in /home/vcap/app/target/release/ +STRATEGIC_LIB="/home/vcap/app/target/release/libwidget_renderer.so" +DEST_LIB="${EXT_DIR}/libwidget_renderer.so" + +if [ -f "$STRATEGIC_LIB" ]; then + echo "===> widget_renderer: Copying pre-built library to ${DEST_LIB}" + cp "$STRATEGIC_LIB" "$DEST_LIB" +elif [ -f "${EXT_DIR}/target/release/libwidget_renderer.so" ]; then + echo "===> widget_renderer: Copying local-built library to ${DEST_LIB}" + cp "${EXT_DIR}/target/release/libwidget_renderer.so" "$DEST_LIB" fi -echo "===> widget_renderer: Setup complete" +echo "===> widget_renderer: Setup complete. LD_LIBRARY_PATH=${LD_LIBRARY_PATH:-}" diff --git a/Cargo.lock b/Cargo.lock index abce685a2..a044aa6e1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -122,7 +122,7 @@ checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" [[package]] name = "widget_renderer" -version = "0.1.0" +version = "0.1.1" dependencies = [ "rutie", "serde", diff --git a/Cargo.toml b/Cargo.toml index a625606cb..6a1c7021b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,10 @@ [workspace] members = [ - "ext/widget_renderer" + "ext/widget_renderer", ] + +[workspace.package] +version = "0.1.1" +edition = "2021" + resolver = "2" \ No newline at end of file diff --git a/Gemfile.lock b/Gemfile.lock index a2eccedec..a3e8f2c50 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -13,7 +13,7 @@ GIT PATH remote: ext/widget_renderer specs: - widget_renderer (0.1.1) + widget_renderer (0.1.3) fiddle rutie diff --git a/app/models/form.rb b/app/models/form.rb index ead0332f9..edffcb94e 100644 --- a/app/models/form.rb +++ b/app/models/form.rb @@ -302,12 +302,16 @@ def touchpoints_js_string use_rust = defined?(WidgetRenderer) && !Rails.env.test? if use_rust begin + # Render the CSS using a controller context + css_content = render_widget_css + form_hash = { short_uuid: short_uuid, modal_button_text: modal_button_text || 'Feedback', element_selector: element_selector.presence || 'touchpoints-container', delivery_method: delivery_method, load_css: !!load_css, + css: css_content, success_text_heading: success_text_heading || 'Thank you', success_text: success_text || 'Your feedback has been received.', suppress_submit_button: !!suppress_submit_button, @@ -378,6 +382,29 @@ def touchpoints_js_string controller.render_to_string(partial: 'components/widget/fba', formats: :js, locals: { form: self }) end + # Renders the widget CSS partial for use with the Rust widget renderer + def render_widget_css + controller = ApplicationController.new + + # Set up a mock request with default URL options + default_options = Rails.application.config.action_controller.default_url_options || + Rails.application.config.action_mailer.default_url_options || + {} + host = default_options[:host] || 'localhost' + port = default_options[:port] || 3000 + protocol = default_options[:protocol] || (port == 443 ? 'https' : 'http') + + mock_request = ActionDispatch::Request.new( + 'rack.url_scheme' => protocol, + 'HTTP_HOST' => "#{host}#{":#{port}" if port != 80 && port != 443}", + 'SERVER_NAME' => host, + 'SERVER_PORT' => port.to_s, + ) + + controller.request = mock_request + controller.render_to_string(partial: 'components/widget/widget', formats: :css, locals: { form: self }) + end + def reportable_submissions(start_date: nil, end_date: nil) submissions .reportable diff --git a/config/environments/development.rb b/config/environments/development.rb index d78d60285..b384afc5d 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -98,4 +98,6 @@ config.active_record.encryption.support_unencrypted_data = true # Apply autocorrection by RuboCop to files generated by `bin/rails generate`. # config.generators.apply_rubocop_autocorrect_after_generate! + + config.public_file_server.enabled = ENV['RAILS_SERVE_STATIC_FILES'].present? end diff --git a/config/environments/production.rb b/config/environments/production.rb index af308e127..f9d51f7e1 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -17,6 +17,9 @@ # Do not fall back to assets pipeline if a precompiled asset is missed. config.assets.compile = false + + # Let Cloud Foundry / container platforms serve precompiled assets from /public. + config.public_file_server.enabled = ENV["RAILS_SERVE_STATIC_FILES"].present? # Cache assets for far-future expiry since they are all digest stamped. # Add CORS headers for static assets to support SRI (Subresource Integrity) checks # when assets are served from ASSET_HOST (different origin than the page) diff --git a/config/initializers/widget_renderer.rb b/config/initializers/widget_renderer.rb index 69e2c6813..7726d5097 100644 --- a/config/initializers/widget_renderer.rb +++ b/config/initializers/widget_renderer.rb @@ -1,24 +1,38 @@ -# Load the Rust widget renderer extension -begin - # Try loading the precompiled Rutie extension. - require_relative '../../ext/widget_renderer/lib/widget_renderer' +# Skip widget renderer during rake tasks and migrations (library may not be built yet in cf run-task) +# Only load when running as a server (not rake tasks, migrations, console, etc.) +# Note: 'bin/rails' is used for both server and console/tasks, so we must check if it's NOT a server. +is_server = defined?(Rails::Server) || $PROGRAM_NAME.include?('puma') || $PROGRAM_NAME.include?('unicorn') - # Verify the class was properly defined - if defined?(WidgetRenderer) && WidgetRenderer.respond_to?(:generate_js) - Rails.logger.info "WidgetRenderer: Rust extension loaded successfully! generate_js method available." - else - Rails.logger.warn "WidgetRenderer: Class defined but generate_js method not available." - Rails.logger.warn "WidgetRenderer: defined?(WidgetRenderer) = #{defined?(WidgetRenderer)}" - Rails.logger.warn "WidgetRenderer: respond_to?(:generate_js) = #{WidgetRenderer.respond_to?(:generate_js) rescue 'N/A'}" +skip_loading = defined?(Rails::Console) || + ($PROGRAM_NAME.include?('rake') && !is_server) || + ($PROGRAM_NAME.include?('bin/rails') && !is_server) || + ENV['SKIP_WIDGET_RENDERER'] == 'true' + +unless skip_loading + # Load the Rust widget renderer extension only when running as server + begin + # Try loading the precompiled Rutie extension. + require_relative '../../ext/widget_renderer/lib/widget_renderer' + + # Verify the class was properly defined + if defined?(WidgetRenderer) && WidgetRenderer.respond_to?(:generate_js) + Rails.logger.info "WidgetRenderer: Rust extension loaded successfully! generate_js method available." + else + Rails.logger.warn "WidgetRenderer: Class defined but generate_js method not available." + Rails.logger.warn "WidgetRenderer: defined?(WidgetRenderer) = #{defined?(WidgetRenderer)}" + Rails.logger.warn "WidgetRenderer: respond_to?(:generate_js) = #{WidgetRenderer.respond_to?(:generate_js) rescue 'N/A'}" + end + rescue LoadError => e + Rails.logger.warn "Widget renderer native library not available: #{e.message}" + Rails.logger.warn 'Rust extension must be built during staging; falling back to ERB template rendering.' + rescue SystemExit => e + Rails.logger.error "Widget renderer exited during load: #{e.message}" + Rails.logger.warn 'Falling back to ERB template rendering' + rescue StandardError => e + Rails.logger.error "Widget renderer failed to load: #{e.class}: #{e.message}" + Rails.logger.error e.backtrace.join("\n") if e.backtrace + Rails.logger.warn 'Falling back to ERB template rendering' end -rescue LoadError => e - Rails.logger.warn "Widget renderer native library not available: #{e.message}" - Rails.logger.warn 'Rust extension must be built during staging; falling back to ERB template rendering.' -rescue SystemExit => e - Rails.logger.error "Widget renderer exited during load: #{e.message}" - Rails.logger.warn 'Falling back to ERB template rendering' -rescue StandardError => e - Rails.logger.error "Widget renderer failed to load: #{e.class}: #{e.message}" - Rails.logger.error e.backtrace.join("\n") if e.backtrace - Rails.logger.warn 'Falling back to ERB template rendering' +else + puts "WidgetRenderer: Skipping load (rake/rails command or console - library may not be built yet)" end diff --git a/config/routes.rb b/config/routes.rb index d3e3062fd..3e28f1e55 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -394,5 +394,5 @@ get 'status', to: 'site#status', as: :status get 'registry', to: 'site#registry', as: :registry get 'index', to: 'site#index', as: :index - root to: redirect(ENV.fetch('INDEX_URL')) + root to: redirect(ENV.fetch('INDEX_URL', '/admin')) end diff --git a/ext/widget_renderer/BUILD_ID b/ext/widget_renderer/BUILD_ID new file mode 100644 index 000000000..404e411ec --- /dev/null +++ b/ext/widget_renderer/BUILD_ID @@ -0,0 +1 @@ +1766172000 diff --git a/ext/widget_renderer/Cargo.toml b/ext/widget_renderer/Cargo.toml index 04b11d209..87643bd1e 100644 --- a/ext/widget_renderer/Cargo.toml +++ b/ext/widget_renderer/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "widget_renderer" -version = "0.1.0" +version = "0.1.1" edition = "2021" [lib] diff --git a/ext/widget_renderer/extconf.rb b/ext/widget_renderer/extconf.rb index 19b6d3d2e..ab434ccdc 100644 --- a/ext/widget_renderer/extconf.rb +++ b/ext/widget_renderer/extconf.rb @@ -30,7 +30,10 @@ def ensure_rust puts "Current directory: #{Dir.pwd}" puts "Using cargo executable: #{cargo_bin}" -system("#{cargo_bin} build --release") or abort 'Failed to build Rust extension' +puts "Cleaning previous build artifacts..." +system("#{cargo_bin} clean 2>&1") +puts "Running cargo build --release..." +system("#{cargo_bin} build --release 2>&1") or abort 'Failed to build Rust extension' # Copy the built shared library into the extension root so it is included in the droplet. # Dir.glob does not expand `{}` patterns, so search explicitly for common extensions. diff --git a/ext/widget_renderer/lib/widget_renderer.rb b/ext/widget_renderer/lib/widget_renderer.rb index 6c4142352..7ff6508a2 100644 --- a/ext/widget_renderer/lib/widget_renderer.rb +++ b/ext/widget_renderer/lib/widget_renderer.rb @@ -14,12 +14,13 @@ lib_names = lib_extensions.map { |ext| "libwidget_renderer#{ext}" } # Define potential paths where the shared object might be located +# Prefer workspace-level target (where CircleCI builds) over gem-level target paths = [ + File.expand_path('../../target/release', root), # Workspace target directory (CircleCI build location) - CHECK FIRST File.join(root, 'target', 'release'), - File.expand_path('../../target/release', root), # Workspace target directory File.join(root, 'widget_renderer', 'target', 'release'), - File.join(root, 'target', 'debug'), File.expand_path('../../target/debug', root), # Workspace debug directory + File.join(root, 'target', 'debug'), File.join(root, 'widget_renderer', 'target', 'debug'), root, ] diff --git a/ext/widget_renderer/src/form_data.rs b/ext/widget_renderer/src/form_data.rs index 5cc81f43b..e0807df8d 100644 --- a/ext/widget_renderer/src/form_data.rs +++ b/ext/widget_renderer/src/form_data.rs @@ -37,6 +37,8 @@ pub struct FormData { pub logo_class: Option, pub omb_approval_number: Option, pub expiration_date: Option, + #[serde(default)] + pub css: String, #[serde(skip, default)] pub prefix: String, pub questions: Vec, diff --git a/ext/widget_renderer/src/template_renderer.rs b/ext/widget_renderer/src/template_renderer.rs index 5a4c53f9c..80f90db77 100644 --- a/ext/widget_renderer/src/template_renderer.rs +++ b/ext/widget_renderer/src/template_renderer.rs @@ -52,10 +52,18 @@ impl TemplateRenderer { "" }; - let modal_class = if form.kind == "recruitment" { - format!("{} usa-modal--lg", form.prefix) + let modal_class = if form.load_css { + if form.kind == "recruitment" { + "fba-usa-modal fba-usa-modal--lg".to_string() + } else { + "fba-usa-modal".to_string() + } } else { - form.prefix.clone() + if form.kind == "recruitment" { + "usa-modal usa-modal--lg".to_string() + } else { + "usa-modal".to_string() + } }; let turnstile_check = if form.enable_turnstile { @@ -859,12 +867,20 @@ function FBAform(d, N) {{ let question_params = self.render_question_params(form); let html_body = self.render_html_body(form).replace("`", "\\`"); let html_body_no_modal = self.render_html_body_no_modal(form).replace("`", "\\`"); + // Escape the CSS for JavaScript string - escape backslashes, backticks, quotes, and newlines + let escaped_css = form.css + .replace("\\", "\\\\") + .replace("`", "\\`") + .replace("\"", "\\\"") + .replace("\n", "\\n") + .replace("\r", ""); format!(r###" var touchpointFormOptions{uuid} = {{ 'formId': "{uuid}", 'modalButtonText': "{button_text}", 'elementSelector': "{selector}", + 'css': "{css}", 'deliveryMethod': "{delivery_method}", 'loadCSS': {load_css}, 'successTextHeading': "{success_heading}", @@ -888,6 +904,7 @@ var touchpointFormOptions{uuid} = {{ uuid = form.short_uuid, button_text = form.modal_button_text, selector = form.element_selector, + css = escaped_css, delivery_method = form.delivery_method, load_css = form.load_css, success_heading = form.success_text_heading, @@ -920,19 +937,51 @@ window.touchpointForm{uuid}.init(touchpointFormOptions{uuid}); // Initialize any USWDS components used in this form (function () {{ - const formId = "touchpoints-form-{uuid}"; - const fbaFormElement = document.querySelector(`#${{formId}}`); - if (fbaFormElement) {{ - fbaUswds.ComboBox.on(fbaFormElement); - fbaUswds.DatePicker.on(fbaFormElement); - }} - const modalId = "fba-modal-{uuid}"; - const fbaModalElement = document.querySelector(`#${{modalId}}`); - if (fbaModalElement) {{ - fbaUswds.Modal.on(fbaModalElement); + try {{ + if (typeof fbaUswds === 'undefined') {{ + console.error("Touchpoints Error: fbaUswds is not defined"); + return; + }} + + const formId = "touchpoints-form-{uuid}"; + const fbaFormElement = document.querySelector(`#${{formId}}`); + if (fbaFormElement) {{ + if (fbaUswds.ComboBox) fbaUswds.ComboBox.on(fbaFormElement); + if (fbaUswds.DatePicker) fbaUswds.DatePicker.on(fbaFormElement); + }} + const modalId = "fba-modal-{uuid}"; + const fbaModalElement = document.querySelector(`#${{modalId}}`); + if (fbaModalElement) {{ + if (fbaUswds.Modal) fbaUswds.Modal.on(fbaModalElement); + }} + // Ensure the modal button is also initialized if it exists (for 'modal' delivery method) + const fbaButton = document.querySelector('#fba-button'); + if (fbaButton) {{ + if (fbaUswds.Modal) {{ + fbaUswds.Modal.on(fbaButton); + fbaButton.classList.add('fba-initialized'); + }} else {{ + console.error("Touchpoints Error: fbaUswds.Modal is not defined"); + }} + }} + // Ensure the custom button is also initialized if it exists (for 'custom-button-modal' delivery method) + const customButtonEl = document.getElementById('{element_selector}'); + if (customButtonEl && ('{delivery_method}' === 'custom-button-modal')) {{ + if (fbaUswds.Modal) {{ + fbaUswds.Modal.on(customButtonEl); + }} else {{ + console.error("Touchpoints Error: fbaUswds.Modal is not defined"); + }} + }} + }} catch (e) {{ + console.error("Touchpoints Error: USWDS initialization failed", e); }} }})(); -"###, uuid = form.short_uuid) +"###, + uuid = form.short_uuid, + element_selector = form.element_selector, + delivery_method = form.delivery_method + ) } fn render_question_params(&self, form: &FormData) -> String { diff --git a/ext/widget_renderer/widget_renderer.gemspec b/ext/widget_renderer/widget_renderer.gemspec index 9d7312bbe..765256e49 100644 --- a/ext/widget_renderer/widget_renderer.gemspec +++ b/ext/widget_renderer/widget_renderer.gemspec @@ -2,7 +2,7 @@ Gem::Specification.new do |spec| spec.name = 'widget_renderer' - spec.version = '0.1.1' + spec.version = '0.1.3' spec.authors = ['GSA'] spec.email = ['touchpoints@gsa.gov'] @@ -13,7 +13,7 @@ Gem::Specification.new do |spec| spec.required_ruby_version = '>= 2.7.0' spec.metadata['rubygems_mfa_required'] = 'true' - spec.files = Dir['{src,lib}/**/*', 'Cargo.toml', 'Cargo.lock', 'extconf.rb'] + spec.files = Dir['{src,lib}/**/*', 'Cargo.toml', 'Cargo.lock', 'extconf.rb', 'BUILD_ID'] spec.extensions = ['extconf.rb'] spec.require_paths = ['lib'] diff --git a/spec/features/admin/forms_spec.rb b/spec/features/admin/forms_spec.rb index e337b7deb..4dab24fa0 100644 --- a/spec/features/admin/forms_spec.rb +++ b/spec/features/admin/forms_spec.rb @@ -197,8 +197,14 @@ find('label', text: 'Hosted on touchpoints').click click_on 'Update Form' expect(page).to have_content('Form was successfully updated.') + + # Wait for form to finish updating before navigating away + sleep 0.5 + visit example_admin_form_path(Form.last) - expect(page).to have_css('.form-header-logo-square') + + # Use more robust visibility check to avoid stale element errors + expect(page).to have_css('.form-header-logo-square', wait: 10) end end diff --git a/spec/mailers/user_mailer_spec.rb b/spec/mailers/user_mailer_spec.rb index 5740d1051..86d0a32d5 100644 --- a/spec/mailers/user_mailer_spec.rb +++ b/spec/mailers/user_mailer_spec.rb @@ -46,6 +46,7 @@ let(:form) { FactoryBot.create(:form, :single_question, organization:, notification_emails: user.email) } let!(:submission) { FactoryBot.create(:submission, form:) } let(:days_ago) { 1 } + let(:time_threshold) { days_ago.days.ago } let(:mail) { UserMailer.submissions_digest(form.id, days_ago) } before do @@ -53,14 +54,14 @@ end it 'renders the headers' do - expect(mail.subject).to eq("Touchpoints Digest: New Submissions to #{form.name} since #{days_ago.days.ago}") + expect(mail.subject).to eq("Touchpoints Digest: New Submissions to #{form.name} since #{time_threshold}") expect(mail.to).to eq(form.notification_emails.split) expect(mail.from).to eq([ENV.fetch('TOUCHPOINTS_EMAIL_SENDER')]) end it 'renders the body' do - expect(mail.body.encoded).to have_text("Notification of feedback received since #{days_ago.days.ago}") - expect(mail.body.encoded).to have_text("1 feedback responses have been submitted to your form, #{form.name}, since #{days_ago.days.ago}") + expect(mail.body.encoded).to have_text("Notification of feedback received since #{time_threshold}") + expect(mail.body.encoded).to have_text("1 feedback responses have been submitted to your form, #{form.name}, since #{time_threshold}") end end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 9b41ac73f..07e1a9daf 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -190,7 +190,7 @@ context "when user's organization has a parent organization" do let(:parent_org) { FactoryBot.create(:organization, name: "Parent Org", domain: "parent.gov", abbreviation: "PARENT") } - let(:child_org) { FactoryBot.create(:organization, name: "Child Org", domain: "child.gov", abbreviation: "CHILD", parent: parent_org) } + let(:child_org) { FactoryBot.create(:organization, name: "Child Org", domain: "child.gov", abbreviation: "CHILD", parent_id: parent_org.id) } let(:child_user) { FactoryBot.create(:user, organization: child_org) } let(:parent_service_owner) { FactoryBot.create(:user, organization: parent_org) } let(:parent_service_provider) { FactoryBot.create(:service_provider, organization: parent_org) } diff --git a/spec/requests/rack_attack_spec.rb b/spec/requests/rack_attack_spec.rb index b94ecda71..e66eda5e2 100644 --- a/spec/requests/rack_attack_spec.rb +++ b/spec/requests/rack_attack_spec.rb @@ -7,32 +7,34 @@ let(:ip) { '1.2.3.4' } let(:headers) { { 'REMOTE_ADDR' => ip } } - let(:valid_submission_path) { "/touchpoints/1234abcd/submissions.json" } + let!(:form) { FactoryBot.create(:form, :open_ended_form, short_uuid: '1234abcd') } + let(:valid_submission_path) { "/touchpoints/#{form.short_uuid}/submissions.json" } + let(:valid_params) { { submission: { answer_01: 'test answer' } } } it 'allows up to 10 requests per minute' do 10.times do - post valid_submission_path, headers: headers + post valid_submission_path, params: valid_params, headers: headers expect(response).not_to have_http_status(:too_many_requests) end end it 'blocks the 11th request within a minute' do - 10.times { post valid_submission_path, headers: headers } + 10.times { post valid_submission_path, params: valid_params, headers: headers } - post valid_submission_path, headers: headers + post valid_submission_path, params: valid_params, headers: headers expect(response).to have_http_status(:too_many_requests) end it 'does not throttle requests from different IPs' do 10.times do |i| - post valid_submission_path, headers: { 'REMOTE_ADDR' => "192.168.1.#{i}" } + post valid_submission_path, params: valid_params, headers: { 'REMOTE_ADDR' => "192.168.1.#{i}" } expect(response).not_to have_http_status(:too_many_requests) end end it 'does not throttle non-matching routes' do 20.times do - post "/other_path", headers: headers + post "/other_path", params: valid_params, headers: headers expect(response).not_to have_http_status(:too_many_requests) end end @@ -40,7 +42,7 @@ it 'recognizes both numeric and short UUID paths' do valid_paths = ["/submissions/123.json", "/submissions/abc123de.json"] valid_paths.each do |path| - post path, headers: headers + post path, params: valid_params, headers: headers expect(response).not_to have_http_status(:too_many_requests) end end @@ -48,7 +50,7 @@ it 'does not throttle invalid submission paths' do invalid_paths = ["/submissions/too_long_uuid_1234.json", "/submissions/.json"] invalid_paths.each do |path| - post path, headers: headers + post path, params: valid_params, headers: headers expect(response).not_to have_http_status(:too_many_requests) end end diff --git a/touchpoints-demo.yml b/touchpoints-demo.yml index 2def7cbe9..a85142b1a 100644 --- a/touchpoints-demo.yml +++ b/touchpoints-demo.yml @@ -1,5 +1,6 @@ applications: - name: touchpoints-demo + timeout: 180 command: bundle exec rake cf:on_first_instance db:migrate && bundle exec rails s -b 0.0.0.0 -p $PORT -e $RAILS_ENV env: AWS_SES_ACCESS_KEY_ID: @@ -19,6 +20,7 @@ applications: TOUCHPOINTS_GTM_CONTAINER_ID: TOUCHPOINTS_WEB_DOMAIN: touchpoints-demo.app.cloud.gov buildpacks: + - https://github.com/rileyseaburg/rust-buildpack-touchpoints.git - nodejs_buildpack - ruby_buildpack services: diff --git a/touchpoints-staging.yml b/touchpoints-staging.yml index 1b1f2a633..9c31d41ba 100644 --- a/touchpoints-staging.yml +++ b/touchpoints-staging.yml @@ -2,7 +2,8 @@ applications: - name: touchpoints-staging memory: 2G disk_quota: 2G - command: bundle exec rake cf:on_first_instance db:schema:load && rake db:seed && bundle exec rails s -b 0.0.0.0 -p $PORT -e $RAILS_ENV + timeout: 180 + command: bundle exec rake cf:on_first_instance db:schema:load db:seed && bundle exec rails s -b 0.0.0.0 -p $PORT -e $RAILS_ENV env: AWS_SES_ACCESS_KEY_ID: ((AWS_SES_ACCESS_KEY_ID)) AWS_SES_SECRET_ACCESS_KEY: ((AWS_SES_SECRET_ACCESS_KEY)) diff --git a/touchpoints.yml b/touchpoints.yml index 3f59c2101..2e7338fba 100644 --- a/touchpoints.yml +++ b/touchpoints.yml @@ -2,24 +2,30 @@ applications: - name: touchpoints memory: 2G disk_quota: 2G + timeout: 180 command: bundle exec rake cf:on_first_instance db:migrate && bundle exec rails s -b 0.0.0.0 -p $PORT -e $RAILS_ENV env: - AWS_SES_ACCESS_KEY_ID: - AWS_SES_SECRET_ACCESS_KEY: - AWS_SES_REGION: + # Non-secret env vars only - secrets are set via `cf set-env` and should NOT be in this manifest + # Empty values here would OVERWRITE existing secrets on cf push! LOGIN_GOV_CLIENT_ID: urn:gov:gsa:openidconnect.profiles:sp:sso:gsa-tts-opp:touchpoints LOGIN_GOV_IDP_BASE_URL: https://secure.login.gov/ - LOGIN_GOV_PRIVATE_KEY: LOGIN_GOV_REDIRECT_URI: https://touchpoints.app.cloud.gov/users/auth/login_dot_gov/callback - NEW_RELIC_KEY: RAILS_ENV: production - S3_AWS_ACCESS_KEY_ID: - S3_AWS_BUCKET_NAME: - S3_AWS_REGION: - S3_AWS_SECRET_ACCESS_KEY: - TOUCHPOINTS_EMAIL_SENDER: - TOUCHPOINTS_GTM_CONTAINER_ID: + RAILS_SERVE_STATIC_FILES: "true" TOUCHPOINTS_WEB_DOMAIN: touchpoints.app.cloud.gov + INDEX_URL: /admin + # Secrets managed via cf set-env (DO NOT add empty keys here): + # - AWS_SES_ACCESS_KEY_ID + # - AWS_SES_SECRET_ACCESS_KEY + # - AWS_SES_REGION + # - LOGIN_GOV_PRIVATE_KEY + # - NEW_RELIC_KEY + # - S3_AWS_ACCESS_KEY_ID + # - S3_AWS_BUCKET_NAME + # - S3_AWS_REGION + # - S3_AWS_SECRET_ACCESS_KEY + # - TOUCHPOINTS_EMAIL_SENDER + # - TOUCHPOINTS_GTM_CONTAINER_ID buildpacks: - https://github.com/rileyseaburg/rust-buildpack-touchpoints.git - nodejs_buildpack From b07fa59a5ef81d09ca93888fd19425494acabf15 Mon Sep 17 00:00:00 2001 From: Riley Seaburg Date: Mon, 22 Dec 2025 15:26:51 +0000 Subject: [PATCH 10/14] Address PR #1925 feedback 1. Fix element_selector handling in template_renderer.rs - Added null check for element_selector before calling getElementById - Prevents potential JS errors with empty/null selectors 2. Remove unnecessary cargo clean from extconf.rb - Removing cached artifacts significantly increases build time - Clean CI environment doesn't need cargo clean before build 3. Extract mock request setup to helper method in form.rb - Created build_controller_with_mock_request private method - Reduces code duplication between touchpoints_js_string and render_widget_css - Improves maintainability --- app/models/form.rb | 70 ++++++++------------ ext/widget_renderer/extconf.rb | 2 - ext/widget_renderer/src/template_renderer.rs | 3 +- 3 files changed, 31 insertions(+), 44 deletions(-) diff --git a/app/models/form.rb b/app/models/form.rb index edffcb94e..16d0bb40f 100644 --- a/app/models/form.rb +++ b/app/models/form.rb @@ -358,51 +358,14 @@ def touchpoints_js_string end # Always use ERB template rendering for now to avoid Rust compilation issues - controller = ApplicationController.new - - # Set up a mock request with default URL options to avoid "undefined method 'host' for nil" errors - # This is necessary because the ERB templates use root_url which requires request context - # Try action_controller first, fall back to action_mailer if not set - default_options = Rails.application.config.action_controller.default_url_options || - Rails.application.config.action_mailer.default_url_options || - {} - host = default_options[:host] || 'localhost' - port = default_options[:port] || 3000 - protocol = default_options[:protocol] || (port == 443 ? 'https' : 'http') - - # Create a mock request - mock_request = ActionDispatch::Request.new( - 'rack.url_scheme' => protocol, - 'HTTP_HOST' => "#{host}#{":#{port}" if port != 80 && port != 443}", - 'SERVER_NAME' => host, - 'SERVER_PORT' => port.to_s, - ) - - controller.request = mock_request - controller.render_to_string(partial: 'components/widget/fba', formats: :js, locals: { form: self }) + controller_with_request = build_controller_with_mock_request + controller_with_request.render_to_string(partial: 'components/widget/fba', formats: :js, locals: { form: self }) end # Renders the widget CSS partial for use with the Rust widget renderer def render_widget_css - controller = ApplicationController.new - - # Set up a mock request with default URL options - default_options = Rails.application.config.action_controller.default_url_options || - Rails.application.config.action_mailer.default_url_options || - {} - host = default_options[:host] || 'localhost' - port = default_options[:port] || 3000 - protocol = default_options[:protocol] || (port == 443 ? 'https' : 'http') - - mock_request = ActionDispatch::Request.new( - 'rack.url_scheme' => protocol, - 'HTTP_HOST' => "#{host}#{":#{port}" if port != 80 && port != 443}", - 'SERVER_NAME' => host, - 'SERVER_PORT' => port.to_s, - ) - - controller.request = mock_request - controller.render_to_string(partial: 'components/widget/widget', formats: :css, locals: { form: self }) + controller_with_request = build_controller_with_mock_request + controller_with_request.render_to_string(partial: 'components/widget/widget', formats: :css, locals: { form: self }) end def reportable_submissions(start_date: nil, end_date: nil) @@ -1076,6 +1039,31 @@ def self.forms_whose_retention_period_has_passed private + # Builds an ApplicationController instance with a mock request for rendering partials + # This is necessary because ERB templates use URL helpers which require request context + def build_controller_with_mock_request + controller = ApplicationController.new + + # Set up a mock request with default URL options + # Try action_controller first, fall back to action_mailer if not set + default_options = Rails.application.config.action_controller.default_url_options || + Rails.application.config.action_mailer.default_url_options || + {} + host = default_options[:host] || 'localhost' + port = default_options[:port] || 3000 + protocol = default_options[:protocol] || (port == 443 ? 'https' : 'http') + + mock_request = ActionDispatch::Request.new( + 'rack.url_scheme' => protocol, + 'HTTP_HOST' => "#{host}#{":#{port}" if port != 80 && port != 443}", + 'SERVER_NAME' => host, + 'SERVER_PORT' => port.to_s, + ) + + controller.request = mock_request + controller + end + def set_uuid self.uuid ||= SecureRandom.uuid self.short_uuid ||= self.uuid[0..7] diff --git a/ext/widget_renderer/extconf.rb b/ext/widget_renderer/extconf.rb index ab434ccdc..6b8fde0a5 100644 --- a/ext/widget_renderer/extconf.rb +++ b/ext/widget_renderer/extconf.rb @@ -30,8 +30,6 @@ def ensure_rust puts "Current directory: #{Dir.pwd}" puts "Using cargo executable: #{cargo_bin}" -puts "Cleaning previous build artifacts..." -system("#{cargo_bin} clean 2>&1") puts "Running cargo build --release..." system("#{cargo_bin} build --release 2>&1") or abort 'Failed to build Rust extension' diff --git a/ext/widget_renderer/src/template_renderer.rs b/ext/widget_renderer/src/template_renderer.rs index 80f90db77..4e5004a3b 100644 --- a/ext/widget_renderer/src/template_renderer.rs +++ b/ext/widget_renderer/src/template_renderer.rs @@ -965,7 +965,8 @@ window.touchpointForm{uuid}.init(touchpointFormOptions{uuid}); }} }} // Ensure the custom button is also initialized if it exists (for 'custom-button-modal' delivery method) - const customButtonEl = document.getElementById('{element_selector}'); + const customButtonSelector = '{element_selector}'; + const customButtonEl = customButtonSelector ? document.getElementById(customButtonSelector) : null; if (customButtonEl && ('{delivery_method}' === 'custom-button-modal')) {{ if (fbaUswds.Modal) {{ fbaUswds.Modal.on(customButtonEl); From f6b55552cb6169ebe6018784a06e32447b57d9e5 Mon Sep 17 00:00:00 2001 From: Riley Seaburg Date: Mon, 22 Dec 2025 15:35:51 +0000 Subject: [PATCH 11/14] Fix empty string check for customButtonSelector Empty string '' is truthy in JavaScript when interpolated from Rust. Use length check to properly handle empty element_selector values. --- ext/widget_renderer/src/template_renderer.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ext/widget_renderer/src/template_renderer.rs b/ext/widget_renderer/src/template_renderer.rs index 4e5004a3b..736df31aa 100644 --- a/ext/widget_renderer/src/template_renderer.rs +++ b/ext/widget_renderer/src/template_renderer.rs @@ -966,7 +966,7 @@ window.touchpointForm{uuid}.init(touchpointFormOptions{uuid}); }} // Ensure the custom button is also initialized if it exists (for 'custom-button-modal' delivery method) const customButtonSelector = '{element_selector}'; - const customButtonEl = customButtonSelector ? document.getElementById(customButtonSelector) : null; + const customButtonEl = (customButtonSelector && customButtonSelector.length > 0) ? document.getElementById(customButtonSelector) : null; if (customButtonEl && ('{delivery_method}' === 'custom-button-modal')) {{ if (fbaUswds.Modal) {{ fbaUswds.Modal.on(customButtonEl); From 08faf2050f05e5a7d372741531839de038c81c91 Mon Sep 17 00:00:00 2001 From: Riley Seaburg Date: Mon, 22 Dec 2025 15:46:34 +0000 Subject: [PATCH 12/14] Fix flaky inline title edit test by waiting for AJAX to complete Added wait_for_ajax after 'form title saved' message appears to ensure the backend save completes before page refresh. Also removed duplicate wait_for_builder call. --- spec/features/admin/forms_spec.rb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/spec/features/admin/forms_spec.rb b/spec/features/admin/forms_spec.rb index 4dab24fa0..790429d9f 100644 --- a/spec/features/admin/forms_spec.rb +++ b/spec/features/admin/forms_spec.rb @@ -450,10 +450,11 @@ find('.survey-title-input').set('Updated Form Title') find('.survey-title-input').native.send_key :tab expect(page).to have_content('form title saved') + # Wait for AJAX save to complete before refreshing + wait_for_ajax # and persists after refresh visit questions_admin_form_path(form) wait_for_builder - wait_for_builder expect(find('.survey-title-input').value).to eq('Updated Form Title') end From d5a596bb9c8616cad1969b620662546a8db06a8c Mon Sep 17 00:00:00 2001 From: Riley Seaburg Date: Mon, 22 Dec 2025 15:59:22 +0000 Subject: [PATCH 13/14] Add post-mortem for widget modal button incident (Dec 2025) Documents the incident where the Rust widget renderer was missing the #fba-button USWDS Modal initialization, causing modal widgets to not open when users clicked the feedback button. Zendesk ticket #37620 --- .../2025-12-widget-modal-incident.md | 234 ++++++++++++++++++ 1 file changed, 234 insertions(+) create mode 100644 docs/postmortems/2025-12-widget-modal-incident.md diff --git a/docs/postmortems/2025-12-widget-modal-incident.md b/docs/postmortems/2025-12-widget-modal-incident.md new file mode 100644 index 000000000..feb10cd81 --- /dev/null +++ b/docs/postmortems/2025-12-widget-modal-incident.md @@ -0,0 +1,234 @@ +# Post-Mortem: Touchpoints Widget Modal Button Not Working + +**Date:** December 22, 2025 +**Author:** Riley Seaburg +**Incident Duration:** ~December 18-22, 2025 (4 days) +**Severity:** High - User-facing feature broken +**Status:** Resolved + +--- + +## Executive Summary + +The Touchpoints feedback widget stopped functioning correctly on external sites using the "modal" delivery method. Users clicking the floating feedback button saw no response - the modal dialog did not open. Instead, in some cases, the form rendered inline at the bottom of the page. This issue affected multiple government websites including ncei.noaa.gov and touchpoints.digital.gov. + +**Root Cause:** The Rust widget renderer (introduced for performance optimization) was missing the USWDS Modal initialization code for the `#fba-button` element, which is required for the modal to respond to click events. + +--- + +## Timeline + +### Background: Rust Widget Renderer Introduction + +| Date | Event | +|------|-------| +| **Oct 29, 2025** | Initial Rust-based widget renderer added for performance optimization (12x improvement) | +| **Nov 4, 2025** | Rust widget renderer deployed with HTML body generation | +| **Nov 20-25, 2025** | Multiple fixes for Rust widget renderer (null booleans, path detection, library loading) | +| **Dec 1, 2025** | Production release with Rust widget renderer | + +### Incident Timeline + +| Date/Time | Event | +|-----------|-------| +| **Dec 15, 2025** | Release: WidgetRenderer load fix (#1914) deployed to production | +| **Dec 18, 2025** | Multiple deployment fixes for CF timeout, Sidekiq health checks, and widget CSS | +| **Dec 18, 2025** | Fix: Add CSS support to Rust widget renderer to fix modal positioning | +| **Dec 18, 2025** | Fix: Use fba-usa-modal class for USWDS Modal compatibility | +| **Dec 19, 2025** | Fix: custom-button-modal USWDS initialization added (partial fix) | +| **Dec 19, 2025 12:24 PM** | **Zendesk Ticket #37620 received** - Jimmy Baker (NOAA) reports widget showing form inline instead of modal toast | +| **Dec 19, 2025** | Multiple attempts to force Rust rebuild (version bumps, cargo clean, cache invalidation) | +| **Dec 22, 2025** | Root cause identified: Missing `#fba-button` initialization in Rust renderer | +| **Dec 22, 2025** | PR #1924 merged: Fix Rust widget renderer modal button initialization | +| **Dec 22, 2025** | PR #1925 created: Release to production | +| **Dec 22, 2025** | PR #1926 merged: Address additional PR feedback (element_selector escaping, code cleanup) | + +--- + +## Root Cause Analysis + +### The Problem + +The Rust widget renderer (`ext/widget_renderer/src/template_renderer.rs`) generates JavaScript that initializes USWDS components for the feedback form. The `render_uswds_initialization()` function was missing critical code to initialize the USWDS Modal on the feedback button. + +### ERB Template (Working - `_fba.js.erb` lines 858-875) + +```javascript +// Ensure the button is also initialized if it exists +const fbaButton = document.querySelector('#fba-button'); +if (fbaButton) { + if (fbaUswds.Modal) { + fbaUswds.Modal.on(fbaButton); + fbaButton.classList.add('fba-initialized'); + } else { + console.error("Touchpoints Error: fbaUswds.Modal is not defined"); + } +} +``` + +### Rust Renderer (Broken - missing this code entirely) + +The Rust renderer only initialized: +- `fbaUswds.Modal.on(fbaModalElement)` - the modal container +- `fbaUswds.ComboBox.on(fbaFormElement)` - combo boxes +- `fbaUswds.DatePicker.on(fbaFormElement)` - date pickers + +**Missing:** `fbaUswds.Modal.on(fbaButton)` - the button that opens the modal + +### Why This Matters + +The USWDS Modal component uses `data-open-modal` attributes on buttons to trigger modal opening. The `Modal.on(element)` call sets up event listeners on elements with this attribute. Without calling `Modal.on()` on the button, the button's click event was never connected to the modal opening behavior. + +### Contributing Factors + +1. **Incomplete port from ERB to Rust:** When the Rust widget renderer was created, the button initialization logic was not included +2. **No integration tests for widget behavior:** Unit tests focused on rendering output, not actual browser behavior +3. **Complexity of USWDS initialization:** The multi-step initialization process made it easy to miss one component +4. **Dec 18 fixes created false confidence:** The `custom-button-modal` fix on Dec 19 addressed a similar but different issue, masking the core problem + +--- + +## Impact + +### Affected Systems +- All Touchpoints forms using `delivery_method: 'modal'` rendered via the Rust widget renderer +- External government websites embedding Touchpoints feedback widgets + +### User Impact +- Users could not submit feedback via modal widgets +- Floating "Feedback" button appeared but did not respond to clicks +- Some users saw forms render inline at page bottom (degraded experience) + +### Known Affected Sites +- touchpoints.digital.gov +- ncei.noaa.gov (NOAA) +- Potentially other government sites using Touchpoints modal widgets + +### Duration +- Approximately 4 days (Dec 18-22, 2025) +- Forms using ERB fallback or `inline` delivery method were unaffected + +--- + +## Resolution + +### Immediate Fix (PR #1924) + +Added the missing button initialization code to the Rust renderer: + +```rust +// Ensure the modal button is also initialized if it exists (for 'modal' delivery method) +const fbaButton = document.querySelector('#fba-button'); +if (fbaButton) {{ + if (fbaUswds.Modal) {{ + fbaUswds.Modal.on(fbaButton); + fbaButton.classList.add('fba-initialized'); + }} else {{ + console.error("Touchpoints Error: fbaUswds.Modal is not defined"); + }} +}} +``` + +### Additional Fixes (PR #1926) + +1. **Fixed `modal_class` prefix bug:** Now respects `load_css` setting + - `load_css=true`: uses `fba-usa-modal` prefix + - `load_css=false`: uses `usa-modal` (no prefix) + +2. **Added CSS backtick escaping:** Prevents JavaScript syntax errors when CSS contains backticks + +3. **Fixed element_selector empty string handling:** Proper null/empty check for custom button selectors + +4. **Removed unnecessary `cargo clean`:** Improves build performance + +5. **Extracted mock request helper:** Reduces code duplication in form.rb + +6. **Removed expired certificate file:** Security cleanup + +--- + +## Lessons Learned + +### What Went Well +- Quick identification of root cause once the ticket was received +- Comprehensive fix that addressed multiple related issues +- Good collaboration between team members + +### What Went Wrong +1. **Incomplete feature parity testing:** The Rust renderer was not tested against all delivery methods +2. **No browser-based integration tests:** Would have caught the modal not opening +3. **Silent failure:** The widget appeared to load but just didn't work - no visible errors to users +4. **4-day detection lag:** Issue existed for several days before user report + +### Where We Got Lucky +- User reported the issue promptly +- ERB fallback existed (though not automatically triggered in this case) +- The fix was straightforward once identified + +--- + +## Action Items + +### Immediate (This Sprint) + +| Item | Owner | Status | +|------|-------|--------| +| Deploy fix to production (PR #1925) | Riley | In Progress | +| Verify widget works on touchpoints.digital.gov | Riley | Pending | +| Respond to Zendesk ticket #37620 | Riley | Pending | + +### Short-term (Next 2 Weeks) + +| Item | Owner | Status | +|------|-------|--------| +| Add browser-based integration tests for widget modal behavior | TBD | Not Started | +| Add automated smoke test that verifies modal opens on click | TBD | Not Started | +| Document Rust renderer feature parity requirements | TBD | Not Started | + +### Long-term (Next Quarter) + +| Item | Owner | Status | +|------|-------|--------| +| Implement automatic fallback to ERB if Rust-rendered widget fails to initialize | TBD | Not Started | +| Add client-side error reporting for widget initialization failures | TBD | Not Started | +| Create widget health monitoring dashboard | TBD | Not Started | + +--- + +## Deployment History (Dec 18-22) + +The following commits were deployed to production during the incident period: + +``` +Dec 22: Fix Rust widget renderer modal button initialization (#1924) +Dec 22: Address PR feedback (element_selector, cargo clean, form.rb refactor) +Dec 19: Fix custom-button-modal USWDS initialization +Dec 19: Multiple cargo/version bumps to force rebuild +Dec 18: Fix CSS support, fba-usa-modal class +Dec 18: Deployment timeout and Sidekiq fixes +Dec 18: Set prod disk quota to 2G +Dec 15: WidgetRenderer load fix (#1914) +``` + +--- + +## Cloud Foundry Events Summary + +**Production (touchpoints):** +- Last successful deployment: Dec 19, 2025 22:06 UTC +- 18 web instances running +- No crash events during incident period + +**Staging (touchpoints-staging):** +- 6 crash events on Dec 22 (unrelated - test environment) + +--- + +## References + +- Zendesk Ticket: #37620 +- PR #1924: Fix Rust widget renderer modal button initialization +- PR #1925: Release to production +- PR #1926: Address PR feedback +- ERB Template: `app/views/components/widget/_fba.js.erb` +- Rust Renderer: `ext/widget_renderer/src/template_renderer.rs` From c653d1bd10c802cb4bdcd2c06534a7e4e064d12b Mon Sep 17 00:00:00 2001 From: Riley Seaburg Date: Mon, 22 Dec 2025 15:59:52 +0000 Subject: [PATCH 14/14] Revert "Add post-mortem for widget modal button incident (Dec 2025)" This reverts commit d5a596bb9c8616cad1969b620662546a8db06a8c. --- .../2025-12-widget-modal-incident.md | 234 ------------------ 1 file changed, 234 deletions(-) delete mode 100644 docs/postmortems/2025-12-widget-modal-incident.md diff --git a/docs/postmortems/2025-12-widget-modal-incident.md b/docs/postmortems/2025-12-widget-modal-incident.md deleted file mode 100644 index feb10cd81..000000000 --- a/docs/postmortems/2025-12-widget-modal-incident.md +++ /dev/null @@ -1,234 +0,0 @@ -# Post-Mortem: Touchpoints Widget Modal Button Not Working - -**Date:** December 22, 2025 -**Author:** Riley Seaburg -**Incident Duration:** ~December 18-22, 2025 (4 days) -**Severity:** High - User-facing feature broken -**Status:** Resolved - ---- - -## Executive Summary - -The Touchpoints feedback widget stopped functioning correctly on external sites using the "modal" delivery method. Users clicking the floating feedback button saw no response - the modal dialog did not open. Instead, in some cases, the form rendered inline at the bottom of the page. This issue affected multiple government websites including ncei.noaa.gov and touchpoints.digital.gov. - -**Root Cause:** The Rust widget renderer (introduced for performance optimization) was missing the USWDS Modal initialization code for the `#fba-button` element, which is required for the modal to respond to click events. - ---- - -## Timeline - -### Background: Rust Widget Renderer Introduction - -| Date | Event | -|------|-------| -| **Oct 29, 2025** | Initial Rust-based widget renderer added for performance optimization (12x improvement) | -| **Nov 4, 2025** | Rust widget renderer deployed with HTML body generation | -| **Nov 20-25, 2025** | Multiple fixes for Rust widget renderer (null booleans, path detection, library loading) | -| **Dec 1, 2025** | Production release with Rust widget renderer | - -### Incident Timeline - -| Date/Time | Event | -|-----------|-------| -| **Dec 15, 2025** | Release: WidgetRenderer load fix (#1914) deployed to production | -| **Dec 18, 2025** | Multiple deployment fixes for CF timeout, Sidekiq health checks, and widget CSS | -| **Dec 18, 2025** | Fix: Add CSS support to Rust widget renderer to fix modal positioning | -| **Dec 18, 2025** | Fix: Use fba-usa-modal class for USWDS Modal compatibility | -| **Dec 19, 2025** | Fix: custom-button-modal USWDS initialization added (partial fix) | -| **Dec 19, 2025 12:24 PM** | **Zendesk Ticket #37620 received** - Jimmy Baker (NOAA) reports widget showing form inline instead of modal toast | -| **Dec 19, 2025** | Multiple attempts to force Rust rebuild (version bumps, cargo clean, cache invalidation) | -| **Dec 22, 2025** | Root cause identified: Missing `#fba-button` initialization in Rust renderer | -| **Dec 22, 2025** | PR #1924 merged: Fix Rust widget renderer modal button initialization | -| **Dec 22, 2025** | PR #1925 created: Release to production | -| **Dec 22, 2025** | PR #1926 merged: Address additional PR feedback (element_selector escaping, code cleanup) | - ---- - -## Root Cause Analysis - -### The Problem - -The Rust widget renderer (`ext/widget_renderer/src/template_renderer.rs`) generates JavaScript that initializes USWDS components for the feedback form. The `render_uswds_initialization()` function was missing critical code to initialize the USWDS Modal on the feedback button. - -### ERB Template (Working - `_fba.js.erb` lines 858-875) - -```javascript -// Ensure the button is also initialized if it exists -const fbaButton = document.querySelector('#fba-button'); -if (fbaButton) { - if (fbaUswds.Modal) { - fbaUswds.Modal.on(fbaButton); - fbaButton.classList.add('fba-initialized'); - } else { - console.error("Touchpoints Error: fbaUswds.Modal is not defined"); - } -} -``` - -### Rust Renderer (Broken - missing this code entirely) - -The Rust renderer only initialized: -- `fbaUswds.Modal.on(fbaModalElement)` - the modal container -- `fbaUswds.ComboBox.on(fbaFormElement)` - combo boxes -- `fbaUswds.DatePicker.on(fbaFormElement)` - date pickers - -**Missing:** `fbaUswds.Modal.on(fbaButton)` - the button that opens the modal - -### Why This Matters - -The USWDS Modal component uses `data-open-modal` attributes on buttons to trigger modal opening. The `Modal.on(element)` call sets up event listeners on elements with this attribute. Without calling `Modal.on()` on the button, the button's click event was never connected to the modal opening behavior. - -### Contributing Factors - -1. **Incomplete port from ERB to Rust:** When the Rust widget renderer was created, the button initialization logic was not included -2. **No integration tests for widget behavior:** Unit tests focused on rendering output, not actual browser behavior -3. **Complexity of USWDS initialization:** The multi-step initialization process made it easy to miss one component -4. **Dec 18 fixes created false confidence:** The `custom-button-modal` fix on Dec 19 addressed a similar but different issue, masking the core problem - ---- - -## Impact - -### Affected Systems -- All Touchpoints forms using `delivery_method: 'modal'` rendered via the Rust widget renderer -- External government websites embedding Touchpoints feedback widgets - -### User Impact -- Users could not submit feedback via modal widgets -- Floating "Feedback" button appeared but did not respond to clicks -- Some users saw forms render inline at page bottom (degraded experience) - -### Known Affected Sites -- touchpoints.digital.gov -- ncei.noaa.gov (NOAA) -- Potentially other government sites using Touchpoints modal widgets - -### Duration -- Approximately 4 days (Dec 18-22, 2025) -- Forms using ERB fallback or `inline` delivery method were unaffected - ---- - -## Resolution - -### Immediate Fix (PR #1924) - -Added the missing button initialization code to the Rust renderer: - -```rust -// Ensure the modal button is also initialized if it exists (for 'modal' delivery method) -const fbaButton = document.querySelector('#fba-button'); -if (fbaButton) {{ - if (fbaUswds.Modal) {{ - fbaUswds.Modal.on(fbaButton); - fbaButton.classList.add('fba-initialized'); - }} else {{ - console.error("Touchpoints Error: fbaUswds.Modal is not defined"); - }} -}} -``` - -### Additional Fixes (PR #1926) - -1. **Fixed `modal_class` prefix bug:** Now respects `load_css` setting - - `load_css=true`: uses `fba-usa-modal` prefix - - `load_css=false`: uses `usa-modal` (no prefix) - -2. **Added CSS backtick escaping:** Prevents JavaScript syntax errors when CSS contains backticks - -3. **Fixed element_selector empty string handling:** Proper null/empty check for custom button selectors - -4. **Removed unnecessary `cargo clean`:** Improves build performance - -5. **Extracted mock request helper:** Reduces code duplication in form.rb - -6. **Removed expired certificate file:** Security cleanup - ---- - -## Lessons Learned - -### What Went Well -- Quick identification of root cause once the ticket was received -- Comprehensive fix that addressed multiple related issues -- Good collaboration between team members - -### What Went Wrong -1. **Incomplete feature parity testing:** The Rust renderer was not tested against all delivery methods -2. **No browser-based integration tests:** Would have caught the modal not opening -3. **Silent failure:** The widget appeared to load but just didn't work - no visible errors to users -4. **4-day detection lag:** Issue existed for several days before user report - -### Where We Got Lucky -- User reported the issue promptly -- ERB fallback existed (though not automatically triggered in this case) -- The fix was straightforward once identified - ---- - -## Action Items - -### Immediate (This Sprint) - -| Item | Owner | Status | -|------|-------|--------| -| Deploy fix to production (PR #1925) | Riley | In Progress | -| Verify widget works on touchpoints.digital.gov | Riley | Pending | -| Respond to Zendesk ticket #37620 | Riley | Pending | - -### Short-term (Next 2 Weeks) - -| Item | Owner | Status | -|------|-------|--------| -| Add browser-based integration tests for widget modal behavior | TBD | Not Started | -| Add automated smoke test that verifies modal opens on click | TBD | Not Started | -| Document Rust renderer feature parity requirements | TBD | Not Started | - -### Long-term (Next Quarter) - -| Item | Owner | Status | -|------|-------|--------| -| Implement automatic fallback to ERB if Rust-rendered widget fails to initialize | TBD | Not Started | -| Add client-side error reporting for widget initialization failures | TBD | Not Started | -| Create widget health monitoring dashboard | TBD | Not Started | - ---- - -## Deployment History (Dec 18-22) - -The following commits were deployed to production during the incident period: - -``` -Dec 22: Fix Rust widget renderer modal button initialization (#1924) -Dec 22: Address PR feedback (element_selector, cargo clean, form.rb refactor) -Dec 19: Fix custom-button-modal USWDS initialization -Dec 19: Multiple cargo/version bumps to force rebuild -Dec 18: Fix CSS support, fba-usa-modal class -Dec 18: Deployment timeout and Sidekiq fixes -Dec 18: Set prod disk quota to 2G -Dec 15: WidgetRenderer load fix (#1914) -``` - ---- - -## Cloud Foundry Events Summary - -**Production (touchpoints):** -- Last successful deployment: Dec 19, 2025 22:06 UTC -- 18 web instances running -- No crash events during incident period - -**Staging (touchpoints-staging):** -- 6 crash events on Dec 22 (unrelated - test environment) - ---- - -## References - -- Zendesk Ticket: #37620 -- PR #1924: Fix Rust widget renderer modal button initialization -- PR #1925: Release to production -- PR #1926: Address PR feedback -- ERB Template: `app/views/components/widget/_fba.js.erb` -- Rust Renderer: `ext/widget_renderer/src/template_renderer.rs`