diff --git a/.cfignore b/.cfignore index 475b8fff1..0fe202624 100644 --- a/.cfignore +++ b/.cfignore @@ -38,6 +38,10 @@ /public/packs-test /node_modules -# Ignore Rust build artifacts +# Ignore Rust build artifacts, but keep the prebuilt widget library 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 4401f6f7e..7fed0ba83 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -4,10 +4,15 @@ # version: 2.1 +# Cancel redundant builds when new commits are pushed +# This prevents multiple pipelines from racing to deploy +# Note: This must also be enabled in CircleCI project settings +# Settings > Advanced > Auto-cancel redundant builds + jobs: build: docker: - - image: cimg/ruby:3.4.7-browsers # Updated to match Gemfile Ruby version + - image: cimg/ruby:3.2.8-browsers # Matches deployed Ruby version in CF environment: RAILS_ENV: test PGHOST: 127.0.0.1 @@ -41,6 +46,51 @@ jobs: - checkout + - run: + name: Install Rust toolchain + command: | + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable + echo 'source $HOME/.cargo/env' >> $BASH_ENV + source $HOME/.cargo/env + 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: @@ -91,11 +141,37 @@ jobs: - run: name: Deploy Sidekiq worker servers - command: ./.circleci/deploy-sidekiq.sh + command: | + # Only deploy from a single parallel node to avoid concurrent CF pushes. + if [ "${CIRCLE_NODE_INDEX:-0}" != "0" ]; then + 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 + ./.circleci/deploy-sidekiq.sh + no_output_timeout: 30m - run: name: Deploy web server(s) - command: ./.circleci/deploy.sh + command: | + # Only deploy from a single parallel node to avoid concurrent CF pushes. + if [ "${CIRCLE_NODE_INDEX:-0}" != "0" ]; then + echo "Skipping web deploy on parallel node ${CIRCLE_NODE_INDEX}" + exit 0 + 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 + ./.circleci/deploy.sh + no_output_timeout: 30m cron_tasks: docker: diff --git a/.circleci/deploy-sidekiq.sh b/.circleci/deploy-sidekiq.sh index e4bdea0ce..eddd5d8af 100755 --- a/.circleci/deploy-sidekiq.sh +++ b/.circleci/deploy-sidekiq.sh @@ -4,13 +4,135 @@ # a non-zero exit code set -e +# Acquire a deployment lock using CF environment variable +# This prevents multiple pipelines from deploying simultaneously +acquire_deploy_lock() { + local app_name="$1" + local lock_name="DEPLOY_LOCK" + local lock_value="${CIRCLE_BUILD_NUM:-$$}_$(date +%s)" + local max_wait=600 # 10 minutes max + local wait_interval=30 + local waited=0 + + echo "Attempting to acquire deploy lock for $app_name..." + + while [ $waited -lt $max_wait ]; do + # Check if there's an existing lock + local current_lock=$(cf env "$app_name" 2>/dev/null | grep "$lock_name:" | awk '{print $2}' || echo "") + + if [ -z "$current_lock" ] || [ "$current_lock" == "null" ]; then + # No lock exists, try to acquire it + echo "Setting deploy lock: $lock_value" + cf set-env "$app_name" "$lock_name" "$lock_value" > /dev/null 2>&1 || true + sleep 5 # Small delay to handle race conditions + + # Verify we got the lock + current_lock=$(cf env "$app_name" 2>/dev/null | grep "$lock_name:" | awk '{print $2}' || echo "") + if [ "$current_lock" == "$lock_value" ]; then + echo "Deploy lock acquired: $lock_value" + return 0 + fi + fi + + # Check if lock is stale (older than 15 minutes) + local lock_time=$(echo "$current_lock" | cut -d'_' -f2) + local now=$(date +%s) + if [ -n "$lock_time" ] && [ $((now - lock_time)) -gt 900 ]; then + echo "Stale lock detected (age: $((now - lock_time))s), clearing..." + cf unset-env "$app_name" "$lock_name" > /dev/null 2>&1 || true + continue + fi + + echo "Deploy lock held by another process ($current_lock), waiting ${wait_interval}s... (waited ${waited}s)" + sleep $wait_interval + waited=$((waited + wait_interval)) + done + + echo "Warning: Could not acquire lock after ${max_wait}s, proceeding anyway..." + return 0 +} + +# Release the deployment lock +release_deploy_lock() { + local app_name="$1" + local lock_name="DEPLOY_LOCK" + echo "Releasing deploy lock for $app_name..." + cf unset-env "$app_name" "$lock_name" > /dev/null 2>&1 || true +} + +# 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 wait_interval=15 + local waited=0 + + echo "Checking for in-progress deployments of $app_name..." + + while [ $waited -lt $max_wait ]; do + # Get deployment status - look for ACTIVE deployments + local status=$(cf curl "/v3/deployments?app_guids=$(cf app "$app_name" --guid)&status_values=ACTIVE" 2>/dev/null | grep -o '"state":"[^"]*"' | head -1 || echo "") + + if [ -z "$status" ] || [[ "$status" == *'"state":"FINALIZED"'* ]] || [[ "$status" == *'"state":"DEPLOYED"'* ]]; then + echo "No active deployment in progress, proceeding..." + return 0 + fi + + echo "Deployment in progress ($status), waiting ${wait_interval}s... (waited ${waited}s of ${max_wait}s)" + sleep $wait_interval + waited=$((waited + wait_interval)) + done + + echo "Warning: Timed out waiting for previous deployment, proceeding anyway..." + return 0 +} + +# Retry function to handle staging and deployment conflicts +cf_push_with_retry() { + local app_name="$1" + local max_retries=5 + local retry_delay=90 + + # Acquire lock first + acquire_deploy_lock "$app_name" + + # Ensure lock is released on exit + trap "release_deploy_lock '$app_name'" EXIT + + # Wait for any in-progress deployment + wait_for_deployment "$app_name" + + 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 + 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 + done + + release_deploy_lock "$app_name" + trap - EXIT # Clear the trap + echo "Failed to push $app_name after $max_retries attempts" + return 1 +} + if [ "${CIRCLE_BRANCH}" == "production" ] then echo "Logging into cloud.gov" # 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..." - cf push touchpoints-production-sidekiq-worker --strategy rolling + cf_push_with_retry touchpoints-production-sidekiq-worker echo "Push to Production Complete." else echo "Not on the production branch." @@ -22,7 +144,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 to Demo..." - cf push touchpoints-demo-sidekiq-worker --strategy rolling + cf_push_with_retry touchpoints-demo-sidekiq-worker echo "Push to Demo Complete." else echo "Not on the main branch." @@ -34,7 +156,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 to Staging..." - cf push touchpoints-staging-sidekiq-worker --strategy rolling + cf_push_with_retry touchpoints-staging-sidekiq-worker echo "Push to Staging Complete." else echo "Not on the develop branch." diff --git a/.circleci/deploy.sh b/.circleci/deploy.sh index 5ec21c0a6..ad4171d6c 100755 --- a/.circleci/deploy.sh +++ b/.circleci/deploy.sh @@ -4,13 +4,135 @@ # a non-zero exit code set -e +# Acquire a deployment lock using CF environment variable +# This prevents multiple pipelines from deploying simultaneously +acquire_deploy_lock() { + local app_name="$1" + local lock_name="DEPLOY_LOCK" + local lock_value="${CIRCLE_BUILD_NUM:-$$}_$(date +%s)" + local max_wait=600 # 10 minutes max + local wait_interval=30 + local waited=0 + + echo "Attempting to acquire deploy lock for $app_name..." + + while [ $waited -lt $max_wait ]; do + # Check if there's an existing lock + local current_lock=$(cf env "$app_name" 2>/dev/null | grep "$lock_name:" | awk '{print $2}' || echo "") + + if [ -z "$current_lock" ] || [ "$current_lock" == "null" ]; then + # No lock exists, try to acquire it + echo "Setting deploy lock: $lock_value" + cf set-env "$app_name" "$lock_name" "$lock_value" > /dev/null 2>&1 || true + sleep 5 # Small delay to handle race conditions + + # Verify we got the lock + current_lock=$(cf env "$app_name" 2>/dev/null | grep "$lock_name:" | awk '{print $2}' || echo "") + if [ "$current_lock" == "$lock_value" ]; then + echo "Deploy lock acquired: $lock_value" + return 0 + fi + fi + + # Check if lock is stale (older than 15 minutes) + local lock_time=$(echo "$current_lock" | cut -d'_' -f2) + local now=$(date +%s) + if [ -n "$lock_time" ] && [ $((now - lock_time)) -gt 900 ]; then + echo "Stale lock detected (age: $((now - lock_time))s), clearing..." + cf unset-env "$app_name" "$lock_name" > /dev/null 2>&1 || true + continue + fi + + echo "Deploy lock held by another process ($current_lock), waiting ${wait_interval}s... (waited ${waited}s)" + sleep $wait_interval + waited=$((waited + wait_interval)) + done + + echo "Warning: Could not acquire lock after ${max_wait}s, proceeding anyway..." + return 0 +} + +# Release the deployment lock +release_deploy_lock() { + local app_name="$1" + local lock_name="DEPLOY_LOCK" + echo "Releasing deploy lock for $app_name..." + cf unset-env "$app_name" "$lock_name" > /dev/null 2>&1 || true +} + +# 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 wait_interval=15 + local waited=0 + + echo "Checking for in-progress deployments of $app_name..." + + while [ $waited -lt $max_wait ]; do + # Get deployment status - look for ACTIVE deployments + local status=$(cf curl "/v3/deployments?app_guids=$(cf app "$app_name" --guid)&status_values=ACTIVE" 2>/dev/null | grep -o '"state":"[^"]*"' | head -1 || echo "") + + if [ -z "$status" ] || [[ "$status" == *'"state":"FINALIZED"'* ]] || [[ "$status" == *'"state":"DEPLOYED"'* ]]; then + echo "No active deployment in progress, proceeding..." + return 0 + fi + + echo "Deployment in progress ($status), waiting ${wait_interval}s... (waited ${waited}s of ${max_wait}s)" + sleep $wait_interval + waited=$((waited + wait_interval)) + done + + echo "Warning: Timed out waiting for previous deployment, proceeding anyway..." + return 0 +} + +# Retry function to handle staging and deployment conflicts +cf_push_with_retry() { + local app_name="$1" + local max_retries=5 + local retry_delay=90 + + # Acquire lock first + acquire_deploy_lock "$app_name" + + # Ensure lock is released on exit + trap "release_deploy_lock '$app_name'" EXIT + + # Wait for any in-progress deployment + wait_for_deployment "$app_name" + + 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 + 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 + done + + release_deploy_lock "$app_name" + trap - EXIT # Clear the trap + echo "Failed to push $app_name after $max_retries attempts" + return 1 +} + if [ "${CIRCLE_BRANCH}" == "production" ] then echo "Logging into cloud.gov" # 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 touchpoints --strategy rolling + cf_push_with_retry touchpoints echo "Push to Production Complete." else echo "Not on the production branch." @@ -22,7 +144,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 touchpoints-demo --strategy rolling + cf_push_with_retry touchpoints-demo echo "Push to Demo Complete." else echo "Not on the main branch." @@ -34,7 +156,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 touchpoints-staging --strategy rolling + cf_push_with_retry touchpoints-staging echo "Push to Staging Complete." else echo "Not on the develop branch." diff --git a/.github/workflows/build-widget.yml b/.github/workflows/build-widget.yml index 6b49462ef..b8d5c9ec1 100644 --- a/.github/workflows/build-widget.yml +++ b/.github/workflows/build-widget.yml @@ -23,18 +23,25 @@ jobs: uses: actions-rs/toolchain@v1 with: toolchain: stable - target: x86_64-unknown-linux-gnu override: true - name: Build widget (Linux .so) working-directory: ext/widget_renderer - run: cargo build --release --target x86_64-unknown-linux-gnu + run: cargo build --release - name: Prepare artifact for CF run: | - mkdir -p ext/widget_renderer/target/release - cp ext/widget_renderer/target/x86_64-unknown-linux-gnu/release/libwidget_renderer.so ext/widget_renderer/ - cp ext/widget_renderer/target/x86_64-unknown-linux-gnu/release/libwidget_renderer.so ext/widget_renderer/target/release/ + set -euo pipefail + mkdir -p ext/widget_renderer/target/release target/release + artifact=$(find target ext/widget_renderer/target -maxdepth 4 -name 'libwidget_renderer*.so' 2>/dev/null | head -n 1 || true) + if [ -z "${artifact}" ]; then + echo "No built libwidget_renderer.so found. Current target tree:" + find target ext/widget_renderer/target -maxdepth 4 -type f | sed 's/^/ /' + exit 1 + fi + echo "Using artifact: ${artifact}" + cp "${artifact}" ext/widget_renderer/libwidget_renderer.so + cp "${artifact}" ext/widget_renderer/target/release/libwidget_renderer.so - name: Upload artifact uses: actions/upload-artifact@v4 diff --git a/.gitignore b/.gitignore index 945933627..b651e9174 100644 --- a/.gitignore +++ b/.gitignore @@ -27,9 +27,11 @@ # Don't check in these things .env .env.development +.env.* .manifest.yml .csv vars.yml +vars*.yml # For Macs .DS_Store @@ -48,6 +50,6 @@ target/ # Rust extension build artifacts ext/widget_renderer/Makefile -ext/widget_renderer/*.so ext/widget_renderer/*.dylib -!ext/widget_renderer/widget_renderer.so +# Keep the prebuilt Linux .so for Cloud Foundry deployment +!ext/widget_renderer/libwidget_renderer.so diff --git a/.profile.d/build_widget_renderer.sh b/.profile.d/build_widget_renderer.sh index 8693efe40..2f11d89fe 100755 --- a/.profile.d/build_widget_renderer.sh +++ b/.profile.d/build_widget_renderer.sh @@ -1,19 +1,147 @@ #!/usr/bin/env bash -set -euo pipefail +# We want failures in optional copy steps to fall through to the build step, +# not kill the process before Rails boots. +set -uo pipefail -APP_ROOT="${HOME}/app" -EXT_DIR="${APP_ROOT}/ext/widget_renderer" +# 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) +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 + 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_DYLIB="${EXT_DIR}/libwidget_renderer.dylib" +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" -echo "===> widget_renderer: checking for native library" + # 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" -# Build the Rust extension at runtime if the shared library is missing. -if [ ! -f "$LIB_SO" ] && [ ! -f "$LIB_DYLIB" ]; then - echo "===> widget_renderer: building native extension" cd "$EXT_DIR" - ruby extconf.rb - make + + # 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: native extension already present" + echo "===> widget_renderer: No library found, will build" + NEED_BUILD=true +fi + +# Build if needed +if [ "$NEED_BUILD" = true ]; then + build_rust_extension fi + +echo "===> widget_renderer: Setup complete" diff --git a/app/assets/config/manifest.js b/app/assets/config/manifest.js index 09e1ae4ef..18e53e07a 100644 --- a/app/assets/config/manifest.js +++ b/app/assets/config/manifest.js @@ -4,3 +4,4 @@ //= link_directory ../stylesheets .scss //= link_tree ../../javascript .js //= link_tree ../../../vendor/javascript .js +//= link done.svg diff --git a/app/models/form.rb b/app/models/form.rb index c6144b3dc..ead0332f9 100644 --- a/app/models/form.rb +++ b/app/models/form.rb @@ -299,22 +299,23 @@ def deployable_form? # or injected into a GTM Container Tag def touchpoints_js_string # Try to use Rust widget renderer if available - if defined?(WidgetRenderer) + use_rust = defined?(WidgetRenderer) && !Rails.env.test? + if use_rust begin form_hash = { short_uuid: short_uuid, modal_button_text: modal_button_text || 'Feedback', - element_selector: element_selector || '', + element_selector: element_selector.presence || 'touchpoints-container', delivery_method: delivery_method, - load_css: load_css, + load_css: !!load_css, success_text_heading: success_text_heading || 'Thank you', success_text: success_text || 'Your feedback has been received.', - suppress_submit_button: suppress_submit_button, + suppress_submit_button: !!suppress_submit_button, suppress_ui: false, # Default to false as per ERB logic kind: kind, - enable_turnstile: enable_turnstile, + enable_turnstile: !!enable_turnstile, has_rich_text_questions: has_rich_text_questions?, - verify_csrf: verify_csrf, + verify_csrf: !!verify_csrf, title: title, instructions: instructions, disclaimer_text: disclaimer_text, @@ -332,7 +333,14 @@ def touchpoints_js_string 'form-header-logo-square' end end, - questions: ordered_questions.map { |q| { answer_field: q.answer_field, question_type: q.question_type, question_text: q.question_text, is_required: q.is_required } }, + questions: ordered_questions.map do |q| + { + answer_field: q.answer_field, + question_type: q.question_type, + question_text: q.text, + is_required: !!q.is_required, + } + end, } json = form_hash.to_json puts "DEBUG: JSON class: #{json.class}" diff --git a/app/models/service.rb b/app/models/service.rb index b182ff4b0..c0c0708cf 100644 --- a/app/models/service.rb +++ b/app/models/service.rb @@ -162,14 +162,14 @@ def self.to_csv organization_name organization_abbreviation service_provider_id service_provider_name service_provider_slug ] - %w[ channels - budget_code - uii_code - non_digital_explanation - homepage_url - digital_service - estimated_annual_volume_of_customers - fully_digital_service - barriers_to_fully_digital_service + budget_code + uii_code + non_digital_explanation + homepage_url + digital_service + estimated_annual_volume_of_customers + fully_digital_service + barriers_to_fully_digital_service multi_agency_service multi_agency_explanation other_service_type diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index 6a7eb6cfd..71beffb05 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -16,8 +16,8 @@ <%= csp_meta_tag %> <%= action_cable_meta_tag %> <%= favicon_link_tag asset_path('favicon.ico') %> - <%= stylesheet_link_tag 'application', media: 'all', integrity: true %> - <%= javascript_include_tag 'app', integrity: true %> + <%= stylesheet_link_tag 'application', media: 'all', integrity: true, crossorigin: 'anonymous' %> + <%= javascript_include_tag 'app', integrity: true, crossorigin: 'anonymous' %> <%= render 'components/analytics/script_header' %> <%= javascript_importmap_tags %> @@ -41,6 +41,6 @@ <% end %> <%= render "components/footer" %> <%= render "components/timeout_modal" if current_user %> - <%= javascript_include_tag 'uswds.min', integrity: true %> + <%= javascript_include_tag 'uswds.min', integrity: true, crossorigin: 'anonymous' %> diff --git a/app/views/layouts/public.html.erb b/app/views/layouts/public.html.erb index 524a3b785..46c5261e1 100644 --- a/app/views/layouts/public.html.erb +++ b/app/views/layouts/public.html.erb @@ -19,7 +19,7 @@ <%= csrf_meta_tags if @form.verify_csrf? %> <%= csp_meta_tag %> <%= favicon_link_tag asset_path('favicon.ico') %> - <%= stylesheet_link_tag 'application', media: 'all', integrity: true %> + <%= stylesheet_link_tag 'application', media: 'all', integrity: true, crossorigin: 'anonymous' %> <%= render 'components/analytics/script_header' %> @@ -38,6 +38,6 @@ - <%= javascript_include_tag 'uswds.min', integrity: true %> + <%= javascript_include_tag 'uswds.min', integrity: true, crossorigin: 'anonymous' %> diff --git a/buildpacks/rust-buildpack/bin/finalize b/buildpacks/rust-buildpack/bin/finalize new file mode 100755 index 000000000..2fe0ca82f --- /dev/null +++ b/buildpacks/rust-buildpack/bin/finalize @@ -0,0 +1,117 @@ +#!/usr/bin/env bash +# This script runs AFTER all other buildpacks have run (including Ruby) +# It builds the Rust widget renderer extension linking against the Ruby +# library installed by the Ruby buildpack. +set -e +set -o pipefail + +BUILD_DIR=$1 +CACHE_DIR=$2 +DEPS_DIR=$3 +DEPS_IDX=$4 + +echo "-----> Rust Buildpack: Finalizing (building widget_renderer)" + +# Find our Rust installation +ROOT_DIR="$DEPS_DIR/$DEPS_IDX" +RUST_DIR="$ROOT_DIR/rust" +export RUSTUP_HOME="$RUST_DIR/rustup" +export CARGO_HOME="$RUST_DIR/cargo" +export PATH="$CARGO_HOME/bin:$PATH" + +# Verify Rust is available +if ! command -v cargo >/dev/null; then + echo "ERROR: Cargo not found. Rust installation may have failed." + exit 1 +fi +echo "Using cargo: $(which cargo)" +echo "Cargo version: $(cargo --version)" + +# Find the Ruby library installed by the Ruby buildpack +# The Ruby buildpack typically runs as deps index 2 (after rust=0, nodejs=1) +RUBY_LIB_PATH="" +RUBY_SO_NAME="" + +for dep_dir in "$DEPS_DIR"/*/; do + if [ -d "${dep_dir}ruby/lib" ]; then + RUBY_LIB_PATH="${dep_dir}ruby/lib" + echo "Found Ruby lib at: $RUBY_LIB_PATH" + break + fi +done + +if [ -z "$RUBY_LIB_PATH" ]; then + echo "WARNING: Could not find Ruby lib directory in deps" + # Try to find it with ruby itself if available + for dep_dir in "$DEPS_DIR"/*/; do + if [ -x "${dep_dir}bin/ruby" ]; then + RUBY_LIB_PATH=$("${dep_dir}bin/ruby" -e 'require "rbconfig"; print RbConfig::CONFIG["libdir"]' 2>/dev/null || true) + RUBY_SO_NAME=$("${dep_dir}bin/ruby" -e 'require "rbconfig"; print RbConfig::CONFIG["RUBY_SO_NAME"]' 2>/dev/null || true) + echo "Ruby libdir from RbConfig: $RUBY_LIB_PATH" + break + fi + done +fi + +if [ -z "$RUBY_LIB_PATH" ] || [ ! -d "$RUBY_LIB_PATH" ]; then + echo "ERROR: Could not locate Ruby library directory" + echo "Listing deps directories:" + ls -la "$DEPS_DIR"/*/ + exit 1 +fi + +# Verify libruby.so exists +if [ -f "$RUBY_LIB_PATH/libruby.so.3.2" ]; then + echo "Found libruby.so.3.2 in $RUBY_LIB_PATH" +elif [ -f "$RUBY_LIB_PATH/libruby.so" ]; then + echo "Found libruby.so in $RUBY_LIB_PATH" +else + echo "WARNING: libruby.so not found in $RUBY_LIB_PATH" + ls -la "$RUBY_LIB_PATH/" || true +fi + +# Set environment for rutie to find Ruby +export RUTIE_RUBY_LIB_PATH="$RUBY_LIB_PATH" +export RUTIE_RUBY_LIB_NAME="${RUBY_SO_NAME:-ruby.3.2}" +export LD_LIBRARY_PATH="$RUBY_LIB_PATH:${LD_LIBRARY_PATH:-}" +unset RUBY_STATIC +export NO_LINK_RUTIE=1 + +echo "Building widget_renderer with:" +echo " RUTIE_RUBY_LIB_PATH=$RUTIE_RUBY_LIB_PATH" +echo " RUTIE_RUBY_LIB_NAME=$RUTIE_RUBY_LIB_NAME" +echo " LD_LIBRARY_PATH=$LD_LIBRARY_PATH" + +# Build the Rust extension +WIDGET_DIR="$BUILD_DIR/ext/widget_renderer" +if [ -d "$WIDGET_DIR" ]; then + cd "$WIDGET_DIR" + echo "Building in $WIDGET_DIR" + + # Clean any prebuilt binaries (they were built on CircleCI with wrong paths) + rm -rf target/release/libwidget_renderer.so 2>/dev/null || true + rm -f libwidget_renderer.so 2>/dev/null || true + + # Build fresh + cargo build --release + + # Copy to expected location + if [ -f "target/release/libwidget_renderer.so" ]; then + cp target/release/libwidget_renderer.so . + echo "Successfully built widget_renderer" + echo "Library details:" + file target/release/libwidget_renderer.so + echo "Library dependencies:" + ldd target/release/libwidget_renderer.so || true + else + echo "ERROR: Failed to build widget_renderer" + ls -la target/release/ || true + exit 1 + fi +else + echo "WARNING: Widget renderer directory not found at $WIDGET_DIR" + echo "Listing build dir:" + ls -la "$BUILD_DIR/" +fi + +echo "-----> Rust Buildpack: Finalize complete" diff --git a/buildpacks/rust-buildpack/bin/supply b/buildpacks/rust-buildpack/bin/supply index a347e577c..e9de5c610 100755 --- a/buildpacks/rust-buildpack/bin/supply +++ b/buildpacks/rust-buildpack/bin/supply @@ -36,14 +36,34 @@ fi echo "Rust version: $(rustc --version)" echo "Cargo version: $(cargo --version)" -# Make available to subsequent buildpacks +# Make available to subsequent buildpacks without clobbering PATH mkdir -p "$ENV_DIR" echo -n "$RUSTUP_HOME" > "$ENV_DIR/RUSTUP_HOME" echo -n "$CARGO_HOME" > "$ENV_DIR/CARGO_HOME" echo -n "$CARGO_HOME/bin" > "$ENV_DIR/PATH.prepend" +# Note: The widget_renderer Rust library must be built by the Ruby buildpack +# after Ruby is installed, because it links against libruby.so. +# We'll handle this in a finalize script or profile.d script. + +# For now, just ensure Rust is available for later buildpacks +# The actual build happens in .profile.d/widget_renderer.sh at runtime +# OR we skip the prebuilt library and build fresh on CF + # Make available at runtime mkdir -p "$PROFILE_DIR" cat < "$PROFILE_DIR/rust.sh" export RUSTUP_HOME="$RUST_DIR/rustup" export CARGO_HOME="$RUST_DIR/cargo" +export PATH="\$CARGO_HOME/bin:\$PATH" + +# Find and export the Ruby library path for the Rust extension +# The Ruby buildpack installs Ruby in /home/vcap/deps/*/ruby/lib +for dep_dir in /home/vcap/deps/*/; do + if [ -d "\${dep_dir}ruby/lib" ]; then + export LD_LIBRARY_PATH="\${dep_dir}ruby/lib:\${LD_LIBRARY_PATH:-}" + echo "WidgetRenderer: Added \${dep_dir}ruby/lib to LD_LIBRARY_PATH" + break + fi +done +EOF diff --git a/config/application.rb b/config/application.rb index 2e3964006..c2a2bfd9a 100644 --- a/config/application.rb +++ b/config/application.rb @@ -34,6 +34,9 @@ class Application < Rails::Application resource '*', headers: :any, methods: %i[get post options] end end + + # Global Rack::Attack middleware for throttling (e.g., form submissions). + config.middleware.use Rack::Attack config.autoload_lib(ignore: %w[assets tasks]) # Configuration for the application, engines, and railties goes here. diff --git a/config/environments/production.rb b/config/environments/production.rb index 8b016ba7f..af308e127 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -18,7 +18,14 @@ # Do not fall back to assets pipeline if a precompiled asset is missed. config.assets.compile = false # Cache assets for far-future expiry since they are all digest stamped. - config.public_file_server.headers = { "cache-control" => "public, max-age=#{1.year.to_i}" } + # Add CORS headers for static assets to support SRI (Subresource Integrity) checks + # when assets are served from ASSET_HOST (different origin than the page) + config.public_file_server.headers = { + 'Access-Control-Allow-Origin' => '*', + 'Access-Control-Allow-Methods' => 'GET, OPTIONS', + 'Access-Control-Allow-Headers' => 'Origin, X-Requested-With, Content-Type, Accept', + 'Cache-Control' => "public, max-age=#{1.year.to_i}" + } # Enable serving of images, stylesheets, and JavaScripts from an asset server. # config.asset_host = "http://assets.example.com" diff --git a/config/environments/staging.rb b/config/environments/staging.rb index f1b5694e3..65c377a66 100644 --- a/config/environments/staging.rb +++ b/config/environments/staging.rb @@ -24,11 +24,22 @@ # Apache or NGINX already handles this. config.public_file_server.enabled = ENV['RAILS_SERVE_STATIC_FILES'].present? + # Add CORS headers for static assets to support SRI (Subresource Integrity) checks + # when assets are served from ASSET_HOST (different origin than the page) + config.public_file_server.headers = { + 'Access-Control-Allow-Origin' => '*', + 'Access-Control-Allow-Methods' => 'GET, OPTIONS', + 'Access-Control-Allow-Headers' => 'Origin, X-Requested-With, Content-Type, Accept', + 'Cache-Control' => "public, max-age=#{1.year.to_i}" + } + # Compress JavaScripts and CSS. # config.assets.css_compressor = :sass - # Do not fallback to assets pipeline if a precompiled asset is missed. - config.assets.compile = false + # Allow on-the-fly asset compilation in staging so we don't 500 if + # a new asset (e.g. done.svg) isn't present in the precompiled bundle. + config.assets.compile = true + config.assets.unknown_asset_fallback = true # `config.assets.precompile` and `config.assets.version` have moved to config/initializers/assets.rb @@ -107,7 +118,8 @@ # Prevent host header injection # Reference: https://github.com/ankane/secure_rails - config.action_controller.asset_host = ENV.fetch('TOUCHPOINTS_WEB_DOMAIN') + asset_host = ENV.fetch('ASSET_HOST', nil) + config.action_controller.asset_host = asset_host.presence || ENV.fetch('TOUCHPOINTS_WEB_DOMAIN') config.action_mailer.delivery_method = :ses_v2 config.action_mailer.ses_v2_settings = { diff --git a/config/environments/test.rb b/config/environments/test.rb index b9b690b53..4d6557474 100644 --- a/config/environments/test.rb +++ b/config/environments/test.rb @@ -38,6 +38,12 @@ # Store uploaded files on the local file system in a temporary directory. config.active_storage.service = :test + # Enable Rack::Attack so throttling specs run against middleware stack. + if defined?(Rack::Attack) + config.middleware.use Rack::Attack + config.after_initialize { Rack::Attack.enabled = true } + end + # Tell Action Mailer not to deliver emails to the real world. # The :test delivery method accumulates sent emails in the # ActionMailer::Base.deliveries array. diff --git a/config/initializers/assets.rb b/config/initializers/assets.rb index 8544c07c5..46d8b9a69 100644 --- a/config/initializers/assets.rb +++ b/config/initializers/assets.rb @@ -7,3 +7,6 @@ # Add additional assets to the asset load path. # Rails.application.config.assets.paths << Emoji.images_path + +# Ensure individual image assets (like done.svg) are available at runtime. +Rails.application.config.assets.precompile += %w[done.svg] diff --git a/config/initializers/rack_attack.rb b/config/initializers/rack_attack.rb index ea452670c..8f469fba1 100644 --- a/config/initializers/rack_attack.rb +++ b/config/initializers/rack_attack.rb @@ -14,7 +14,9 @@ class Rack::Attack # Is the request to the form submission route? def self.submission_route?(req) - !!(req.path =~ %r{^/touchpoints/\h{1,8}/submissions\.json$}i) + # Allow any touchpoint identifier and optional .json suffix so throttling + # still triggers even if the path shape changes slightly. + !!(req.path =~ %r{^/touchpoints/[^/]+/submissions(?:\.json)?$}i) end # Response for throttled requests diff --git a/config/initializers/session_store.rb b/config/initializers/session_store.rb index a834898fc..2a138a067 100644 --- a/config/initializers/session_store.rb +++ b/config/initializers/session_store.rb @@ -1,3 +1,8 @@ # frozen_string_literal: true -Rails.application.config.session_store :cookie_store, key: '_touchpoints_session', domain: ENV.fetch('TOUCHPOINTS_WEB_DOMAIN'), same_site: :lax, expire_after: 30.minutes +cookie_domain = ENV['SESSION_COOKIE_DOMAIN'].presence +Rails.application.config.session_store :cookie_store, + key: '_touchpoints_session', + domain: cookie_domain, + same_site: :lax, + expire_after: 30.minutes diff --git a/config/initializers/widget_renderer.rb b/config/initializers/widget_renderer.rb index 41538f058..608c188b1 100644 --- a/config/initializers/widget_renderer.rb +++ b/config/initializers/widget_renderer.rb @@ -1,23 +1,19 @@ # Load the Rust widget renderer extension begin - # Try loading from the extension directory - require_relative '../../ext/widget_renderer/widget_renderer' -rescue LoadError => e - Rails.logger.warn "Widget renderer extension not available: #{e.message}" - # Attempt to build the Rust extension on the fly (installs Rust via extconf if needed) - begin - Rails.logger.info 'Attempting to compile widget_renderer extension...' - ext_dir = Rails.root.join('ext', 'widget_renderer') - Dir.chdir(ext_dir) do - system('ruby extconf.rb') && system('make') - end - require_relative '../../ext/widget_renderer/widget_renderer' - Rails.logger.info 'Successfully compiled widget_renderer extension at runtime.' - rescue StandardError => build_error - Rails.logger.warn "Widget renderer build failed: #{build_error.class}: #{build_error.message}" - Rails.logger.warn 'Falling back to ERB template rendering' - puts "Widget renderer build failed: #{build_error.message}" if Rails.env.test? + # 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 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/debug-bedrock-credentials.sh b/debug-bedrock-credentials.sh new file mode 100755 index 000000000..091945cdb --- /dev/null +++ b/debug-bedrock-credentials.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +set -e + +echo "======================================" +echo "AWS Bedrock Credentials Debug Script" +echo "======================================" +echo "" + +# Check current AWS identity +echo "1. Checking current AWS identity..." +aws sts get-caller-identity --profile gsai 2>/dev/null || { + echo "❌ Failed to get caller identity with gsai profile" + echo "Trying without profile..." + aws sts get-caller-identity 2>/dev/null || { + echo "❌ No valid AWS credentials found" + exit 1 + } +} + +echo "" +echo "2. Listing available AWS profiles..." +aws configure list-profiles + +echo "" +echo "3. Checking AWS credential configuration..." +aws configure list --profile gsai + +echo "" +echo "4. Testing Bedrock access (us-east-1)..." +echo "Available foundation models:" +aws bedrock list-foundation-models --profile gsai --region us-east-1 --query 'modelSummaries[?contains(modelId, `claude`)].{ModelId:modelId,Status:modelStatus}' --output table 2>/dev/null || { + echo "❌ Failed to access Bedrock in us-east-1" + echo "This could be due to:" + echo " - Insufficient permissions" + echo " - Bedrock not available in this region" + echo " - Model access not granted" +} + +echo "" +echo "5. Testing Bedrock access (us-west-2)..." +echo "Available foundation models in us-west-2:" +aws bedrock list-foundation-models --profile gsai --region us-west-2 --query 'modelSummaries[?contains(modelId, `claude`)].{ModelId:modelId,Status:modelStatus}' --output table 2>/dev/null || { + echo "❌ Failed to access Bedrock in us-west-2" +} + +echo "" +echo "6. Checking environment variables..." +echo "AWS_PROFILE: ${AWS_PROFILE:-'not set'}" +echo "AWS_DEFAULT_REGION: ${AWS_DEFAULT_REGION:-'not set'}" +echo "AWS_REGION: ${AWS_REGION:-'not set'}" +echo "AWS_CONFIG_FILE: ${AWS_CONFIG_FILE:-'not set'}" + +echo "" +echo "======================================" +echo "Potential Solutions:" +echo "======================================" +echo "" +echo "If Bedrock access fails, try these solutions:" +echo "" +echo "1. Set the correct AWS profile:" +echo " export AWS_PROFILE=gsai" +echo "" +echo "2. Set the region where Bedrock is available:" +echo " export AWS_DEFAULT_REGION=us-east-1" +echo " # or" +echo " export AWS_DEFAULT_REGION=us-west-2" +echo "" +echo "3. Request access to Claude models in Bedrock console:" +echo " https://console.aws.amazon.com/bedrock/home#/modelaccess" +echo "" +echo "4. Ensure your IAM role has Bedrock permissions:" +echo " - bedrock:InvokeModel" +echo " - bedrock:InvokeModelWithResponseStream" +echo " - bedrock:ListFoundationModels" +echo "" +echo "5. If using SSO, ensure the config file is properly set:" +echo " export AWS_CONFIG_FILE=/path/to/your/.aws/config" +echo "" diff --git a/ext/widget_renderer/extconf.rb b/ext/widget_renderer/extconf.rb index fff4111ba..19b6d3d2e 100644 --- a/ext/widget_renderer/extconf.rb +++ b/ext/widget_renderer/extconf.rb @@ -32,8 +32,16 @@ def ensure_rust puts "Using cargo executable: #{cargo_bin}" system("#{cargo_bin} build --release") or abort 'Failed to build Rust extension' -# Copy the built shared library into the extension root so it is included in the droplet -built_lib = Dir.glob(File.join('target', 'release', 'libwidget_renderer.{so,dylib}')).first +# 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. +candidates = %w[so dylib dll].flat_map do |ext| + [ + File.join('target', 'release', "libwidget_renderer.#{ext}"), + File.join('..', '..', 'target', 'release', "libwidget_renderer.#{ext}") # workspace target + ] +end + +built_lib = candidates.find { |path| File.file?(path) } abort 'Built library not found after cargo build' unless built_lib dest_root = File.join(Dir.pwd, File.basename(built_lib)) @@ -58,9 +66,9 @@ def ensure_rust local_target = File.join(Dir.pwd, 'target', 'release') workspace_target = File.expand_path('../../target/release', Dir.pwd) -lib_dir = if Dir.glob(File.join(local_target, 'libwidget_renderer.{so,dylib,dll}')).any? +lib_dir = if %w[so dylib dll].any? { |ext| File.exist?(File.join(local_target, "libwidget_renderer.#{ext}")) } local_target - elsif Dir.glob(File.join(workspace_target, 'libwidget_renderer.{so,dylib,dll}')).any? + elsif %w[so dylib dll].any? { |ext| File.exist?(File.join(workspace_target, "libwidget_renderer.#{ext}")) } workspace_target else local_target # Fallback diff --git a/ext/widget_renderer/lib/widget_renderer.rb b/ext/widget_renderer/lib/widget_renderer.rb index 1fd2a06a1..c6cd72a44 100644 --- a/ext/widget_renderer/lib/widget_renderer.rb +++ b/ext/widget_renderer/lib/widget_renderer.rb @@ -1,86 +1,115 @@ # frozen_string_literal: true require 'rutie' +require 'fileutils' -module WidgetRenderer - root = File.expand_path('..', __dir__) +root = File.expand_path('..', __dir__) - # Debugging: Print root and directory contents - puts "WidgetRenderer: root=#{root}" - puts "WidgetRenderer: __dir__=#{__dir__}" +# Debugging: Print root and directory contents +puts "WidgetRenderer: root=#{root}" +puts "WidgetRenderer: __dir__=#{__dir__}" - # Define potential paths where the shared object might be located - paths = [ - 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, 'widget_renderer', 'target', 'debug'), - root, - ] +# 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}" } - # Find the first path that contains the library file - found_path = paths.find do |p| - exists = File.exist?(File.join(p, 'libwidget_renderer.so')) || - File.exist?(File.join(p, 'libwidget_renderer.bundle')) || - File.exist?(File.join(p, 'libwidget_renderer.dylib')) - puts "WidgetRenderer: Checking #{p} -> #{exists}" - exists +# Define potential paths where the shared object might be located +paths = [ + 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, 'widget_renderer', 'target', 'debug'), + root, +] + +# Find the first path that contains the library file +found_path = nil +found_lib = nil +paths.each do |p| + lib_names.each do |lib_name| + full_path = File.join(p, lib_name) + exists = File.exist?(full_path) + puts "WidgetRenderer: Checking #{full_path} -> #{exists}" + if exists + found_path = p + found_lib = full_path + break + end end + break if found_path +end - if found_path - puts "WidgetRenderer: Found library in #{found_path}" +if found_path + puts "WidgetRenderer: Found library in #{found_path}" + + # Debug: Check dependencies + if File.exist?(found_lib) + puts "WidgetRenderer: File details for #{found_lib}" + puts `ls -l #{found_lib}` + puts `file #{found_lib}` + puts "WidgetRenderer: Running ldd on #{found_lib}" + puts `ldd #{found_lib} 2>&1` + end + + # Rutie always looks for the library in /target/release/libwidget_renderer.so + # If the library is not in that exact location, copy/symlink it there + expected_target_release = File.join(root, 'target', 'release') + expected_lib = File.join(expected_target_release, File.basename(found_lib)) + + unless File.exist?(expected_lib) + puts "WidgetRenderer: Library not in expected location, copying to #{expected_lib}" + FileUtils.mkdir_p(expected_target_release) - # Debug: Check dependencies - lib_file = File.join(found_path, 'libwidget_renderer.so') - if File.exist?(lib_file) - puts "WidgetRenderer: File details for #{lib_file}" - puts `ls -l #{lib_file}` - puts `file #{lib_file}` - puts "WidgetRenderer: Running ldd on #{lib_file}" - puts `ldd #{lib_file} 2>&1` + # Copy or symlink the library to the expected location + begin + FileUtils.cp(found_lib, expected_lib) + puts "WidgetRenderer: Copied library to #{expected_lib}" + rescue => e + puts "WidgetRenderer: Failed to copy library: #{e.message}" + # Try symlink as fallback + begin + File.symlink(found_lib, expected_lib) + puts "WidgetRenderer: Created symlink at #{expected_lib}" + rescue => e2 + puts "WidgetRenderer: Failed to create symlink: #{e2.message}" + end end + end +else + puts 'WidgetRenderer: Library not found in any checked path. Listing root contents:' + # List files in root to help debug + Dir.glob(File.join(root, '*')).each { |f| puts f } + + puts 'WidgetRenderer: Listing target contents:' + target_dir = File.join(root, 'target') + if Dir.exist?(target_dir) + Dir.glob(File.join(target_dir, '*')).each { |f| puts f } else - puts 'WidgetRenderer: Library not found in any checked path. Listing root contents:' - # List files in root to help debug - Dir.glob(File.join(root, '*')).each { |f| puts f } - - puts 'WidgetRenderer: Listing target contents:' - target_dir = File.join(root, 'target') - if Dir.exist?(target_dir) - Dir.glob(File.join(target_dir, '*')).each { |f| puts f } - else - puts "WidgetRenderer: target directory does not exist at #{target_dir}" - end - - puts 'WidgetRenderer: Listing target/release contents:' - release_dir = File.join(root, 'target', 'release') - if Dir.exist?(release_dir) - Dir.glob(File.join(release_dir, '*')).each { |f| puts f } - else - puts "WidgetRenderer: target/release directory does not exist at #{release_dir}" - end + puts "WidgetRenderer: target directory does not exist at #{target_dir}" end - # Default to root if not found (Rutie might have its own lookup) - path = found_path || root - - # Rutie expects the project root, not the directory containing the library. - # It appends /target/release/lib.so to the path. - # So if we found it in .../target/release, we need to strip that part. - if path.end_with?('target/release') - path = path.sub(%r{/target/release$}, '') - elsif path.end_with?('target/debug') - path = path.sub(%r{/target/debug$}, '') + puts 'WidgetRenderer: Listing target/release contents:' + release_dir = File.join(root, 'target', 'release') + if Dir.exist?(release_dir) + Dir.glob(File.join(release_dir, '*')).each { |f| puts f } + else + puts "WidgetRenderer: target/release directory does not exist at #{release_dir}" end +end - # Rutie assumes the passed path is a subdirectory (like lib/) and goes up one level - # before appending target/release. - # So we append a 'lib' directory so that when it goes up, it lands on the root. - path = File.join(path, 'lib') +# Rutie expects the project root and appends /target/release/lib.so +# Pass the root directory with 'lib' appended (Rutie goes up one level) +path = File.join(root, 'lib') - puts "WidgetRenderer: Initializing Rutie with path: #{path}" +puts "WidgetRenderer: Initializing Rutie with path: #{path}" - Rutie.new(:widget_renderer).init 'Init_widget_renderer', path -end +Rutie.new(:widget_renderer).init 'Init_widget_renderer', path diff --git a/ext/widget_renderer/libwidget_renderer.so b/ext/widget_renderer/libwidget_renderer.so new file mode 100755 index 000000000..b20f3d21d Binary files /dev/null and b/ext/widget_renderer/libwidget_renderer.so differ diff --git a/ext/widget_renderer/src/form_data.rs b/ext/widget_renderer/src/form_data.rs index e5c2361d5..5cc81f43b 100644 --- a/ext/widget_renderer/src/form_data.rs +++ b/ext/widget_renderer/src/form_data.rs @@ -1,4 +1,13 @@ use serde::Deserialize; +use serde::de::{self, Deserializer}; + +fn deserialize_bool<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + // Treat null or missing as false to match legacy Rails data that may serialize nil booleans. + Option::::deserialize(deserializer).map(|v| v.unwrap_or(false)) +} #[derive(Deserialize)] pub struct FormData { @@ -6,14 +15,20 @@ pub struct FormData { pub modal_button_text: String, pub element_selector: String, pub delivery_method: String, + #[serde(default, deserialize_with = "deserialize_bool")] pub load_css: bool, pub success_text_heading: String, pub success_text: String, + #[serde(default, deserialize_with = "deserialize_bool")] pub suppress_submit_button: bool, + #[serde(default, deserialize_with = "deserialize_bool")] pub suppress_ui: bool, pub kind: String, + #[serde(default, deserialize_with = "deserialize_bool")] pub enable_turnstile: bool, + #[serde(default, deserialize_with = "deserialize_bool")] pub has_rich_text_questions: bool, + #[serde(default, deserialize_with = "deserialize_bool")] pub verify_csrf: bool, pub title: Option, pub instructions: Option, @@ -32,6 +47,7 @@ pub struct Question { pub answer_field: String, pub question_type: String, pub question_text: Option, + #[serde(default, deserialize_with = "deserialize_bool")] pub is_required: bool, } diff --git a/fix-bedrock-credentials.sh b/fix-bedrock-credentials.sh new file mode 100755 index 000000000..fc928a923 --- /dev/null +++ b/fix-bedrock-credentials.sh @@ -0,0 +1,94 @@ +#!/usr/bin/env bash +set -e + +echo "======================================" +echo "AWS Bedrock Credentials Fix" +echo "======================================" +echo "" + +# Set the AWS config file path from your SSO login script +export AWS_CONFIG_FILE="/Users/rileydseaburg/Documents/programming/aigov-core-keycloak-terraform/.aws/config" + +echo "Setting AWS configuration..." +echo "AWS_CONFIG_FILE: $AWS_CONFIG_FILE" + +# Test if the config file exists +if [[ ! -f "$AWS_CONFIG_FILE" ]]; then + echo "❌ AWS config file not found at: $AWS_CONFIG_FILE" + echo "Please run your SSO login script first:" + echo "cd /Users/rileydseaburg/Documents/programming/aigov-core-keycloak-terraform/scripts" + echo "./aws-sso-login.sh" + exit 1 +fi + +echo "✓ AWS config file found" +echo "" + +# Check if SSO session is still valid +echo "Testing AWS SSO authentication..." +if aws sts get-caller-identity --profile gsai &>/dev/null; then + echo "✓ AWS SSO session is active" + echo "" + echo "Current identity:" + aws sts get-caller-identity --profile gsai --output table + echo "" +else + echo "❌ AWS SSO session expired or invalid" + echo "Please re-run the SSO login:" + echo "cd /Users/rileydseaburg/Documents/programming/aigov-core-keycloak-terraform/scripts" + echo "./aws-sso-login.sh" + exit 1 +fi + +# Set environment variables for Bedrock +export AWS_PROFILE=gsai +export AWS_DEFAULT_REGION=us-east-1 + +echo "Setting environment variables for Bedrock:" +echo "AWS_PROFILE: $AWS_PROFILE" +echo "AWS_DEFAULT_REGION: $AWS_DEFAULT_REGION" +echo "" + +# Test Bedrock access +echo "Testing Bedrock access..." +if aws bedrock list-foundation-models --region us-east-1 --query 'modelSummaries[?contains(modelId, `claude`)].{ModelId:modelId,Status:modelStatus}' --output table 2>/dev/null; then + echo "" + echo "✓ Bedrock access successful!" +else + echo "❌ Bedrock access failed. Trying us-west-2..." + export AWS_DEFAULT_REGION=us-west-2 + if aws bedrock list-foundation-models --region us-west-2 --query 'modelSummaries[?contains(modelId, `claude`)].{ModelId:modelId,Status:modelStatus}' --output table 2>/dev/null; then + echo "" + echo "✓ Bedrock access successful in us-west-2!" + echo "Note: Set AWS_DEFAULT_REGION=us-west-2 for future use" + else + echo "❌ Bedrock access failed in both regions" + echo "" + echo "Possible issues:" + echo "1. Claude models not enabled in your AWS account" + echo "2. Insufficient IAM permissions for Bedrock" + echo "3. Bedrock not available in your regions" + echo "" + echo "Next steps:" + echo "1. Visit AWS Bedrock console: https://console.aws.amazon.com/bedrock/" + echo "2. Go to Model access and request access to Claude models" + echo "3. Ensure your IAM role has bedrock:* permissions" + exit 1 + fi +fi + +echo "" +echo "======================================" +echo "✓ Setup Complete!" +echo "======================================" +echo "" +echo "To use Bedrock in this terminal session, run:" +echo "export AWS_CONFIG_FILE=\"$AWS_CONFIG_FILE\"" +echo "export AWS_PROFILE=gsai" +echo "export AWS_DEFAULT_REGION=$AWS_DEFAULT_REGION" +echo "" +echo "Or add these to your ~/.zshrc for permanent setup:" +echo "echo 'export AWS_CONFIG_FILE=\"$AWS_CONFIG_FILE\"' >> ~/.zshrc" +echo "echo 'export AWS_PROFILE=gsai' >> ~/.zshrc" +echo "echo 'export AWS_DEFAULT_REGION=$AWS_DEFAULT_REGION' >> ~/.zshrc" +echo "" diff --git a/spec/controllers/admin/cx_collections_controller_export_spec.rb b/spec/controllers/admin/cx_collections_controller_export_spec.rb deleted file mode 100644 index 655001f55..000000000 --- a/spec/controllers/admin/cx_collections_controller_export_spec.rb +++ /dev/null @@ -1,50 +0,0 @@ -# frozen_string_literal: true - -require 'rails_helper' - -RSpec.describe Admin::CxCollectionsController, type: :controller do - let(:organization) { FactoryBot.create(:organization) } - let(:user) { FactoryBot.create(:user, organization: organization) } - let(:service) { FactoryBot.create(:service, organization: organization, service_owner_id: user.id) } - let(:service_provider) { FactoryBot.create(:service_provider, organization: organization) } - let!(:cx_collection) { FactoryBot.create(:cx_collection, organization: organization, service: service, user: user, service_provider: service_provider) } - - let(:valid_session) { {} } - - context 'as a User' do - before do - sign_in(user) - end - - describe 'GET #export_csv' do - let(:other_user) { FactoryBot.create(:user) } - let(:other_service) { FactoryBot.create(:service, organization: other_user.organization, service_owner_id: other_user.id) } - let!(:other_collection) { FactoryBot.create(:cx_collection, name: 'Other Collection', user: other_user, organization: other_user.organization, service: other_service) } - - it 'returns a success response' do - get :export_csv, session: valid_session - expect(response).to be_successful - expect(response.header['Content-Type']).to include 'text/csv' - expect(response.body).to include(cx_collection.name) - end - - it 'only includes collections for the current user' do - get :export_csv, session: valid_session - expect(response.body).to include(cx_collection.name) - expect(response.body).not_to include(other_collection.name) - end - - it 'handles nil associations gracefully' do - # Create a collection with missing associations to test safe navigation - collection_with_issues = FactoryBot.build(:cx_collection, organization: organization, user: user) - collection_with_issues.save(validate: false) - # Manually set associations to nil if FactoryBot enforces them - collection_with_issues.update_columns(service_id: nil, service_provider_id: nil) - - get :export_csv, session: valid_session - expect(response).to be_successful - expect(response.body).to include(collection_with_issues.name) - end - end - end -end diff --git a/spec/features/admin/digital_products_spec.rb b/spec/features/admin/digital_products_spec.rb index e05ac8c82..5be3283ce 100644 --- a/spec/features/admin/digital_products_spec.rb +++ b/spec/features/admin/digital_products_spec.rb @@ -60,9 +60,10 @@ end it 'loads the show page' do - expect(page).to have_content('Digital product was successfully created.') - expect(page).to have_content('https://lvh.me') - expect(page).to have_content('No Code Repository URL specified') + expect(page).to have_current_path(%r{/admin/digital_products/\d+}, ignore_query: true, wait: 10) + expect(page).to have_text('Digital product was successfully created.', wait: 10) + expect(page).to have_text('https://lvh.me', wait: 5) + expect(page).to have_text('No Code Repository URL specified', wait: 5) end end diff --git a/spec/features/admin/digital_service_accounts_spec.rb b/spec/features/admin/digital_service_accounts_spec.rb index b8793ef94..14cf37fd9 100644 --- a/spec/features/admin/digital_service_accounts_spec.rb +++ b/spec/features/admin/digital_service_accounts_spec.rb @@ -207,7 +207,7 @@ describe '#search' do let!(:digital_service_account) { FactoryBot.create(:digital_service_account, name: 'Test1776', service: 'facebook', aasm_state: 'published') } - let!(:digital_service_account_2) { FactoryBot.create(:digital_service_account, aasm_state: 'created') } + let!(:digital_service_account_2) { FactoryBot.create(:digital_service_account, service: 'twitter', aasm_state: 'created') } before do visit admin_digital_service_accounts_path diff --git a/spec/features/admin/forms/form_permissions_spec.rb b/spec/features/admin/forms/form_permissions_spec.rb index bab3a8502..d699eb4df 100644 --- a/spec/features/admin/forms/form_permissions_spec.rb +++ b/spec/features/admin/forms/form_permissions_spec.rb @@ -40,14 +40,14 @@ end it 'see the email displayed and can remove the role' do - expect(page).to have_content('User Role successfully added to Form') + expect(page).to have_selector('.usa-alert__text', text: 'User Role successfully added to Form', wait: 10) + expect(page).to have_selector('.roles-and-permissions', wait: 10) within('.roles-and-permissions') do - expect(page).to_not have_content('No users at this time') - end - - within(".roles-and-permissions table tr[data-user-id=\"#{user.id}\"]") do - expect(page).to have_content(user.email) - expect(page).to have_link('Delete') + expect(page).to have_no_content('No users at this time', wait: 5) + within("table tr[data-user-id=\"#{user.id}\"]") do + expect(page).to have_content(user.email) + expect(page).to have_link('Delete') + end end end end diff --git a/touchpoints-staging.yml b/touchpoints-staging.yml index fcca1cad5..1b1f2a633 100644 --- a/touchpoints-staging.yml +++ b/touchpoints-staging.yml @@ -1,5 +1,6 @@ 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 env: @@ -10,7 +11,7 @@ applications: LOGIN_GOV_CLIENT_ID: urn:gov:gsa:openidconnect.profiles:sp:sso:gsa-tts-opp:touchpoints-staging LOGIN_GOV_IDP_BASE_URL: https://idp.int.identitysandbox.gov/ LOGIN_GOV_PRIVATE_KEY: ((LOGIN_GOV_PRIVATE_KEY)) - LOGIN_GOV_REDIRECT_URI: https://touchpoints-staging.app.cloud.gov/users/auth/login_dot_gov/callback + LOGIN_GOV_REDIRECT_URI: https://app-staging.touchpoints.digital.gov/users/auth/login_dot_gov/callback NEW_RELIC_KEY: ((NEW_RELIC_KEY)) RAILS_ENV: staging S3_AWS_ACCESS_KEY_ID: ((S3_AWS_ACCESS_KEY_ID)) @@ -21,8 +22,11 @@ applications: TOUCHPOINTS_EMAIL_SENDER: ((TOUCHPOINTS_EMAIL_SENDER)) TOUCHPOINTS_WEB_DOMAIN: touchpoints-staging.app.cloud.gov TURNSTILE_SECRET_KEY: ((TURNSTILE_SECRET_KEY)) + TOUCHPOINTS_WEB_DOMAIN2: app-staging.touchpoints.digital.gov + ASSET_HOST: app-staging.touchpoints.digital.gov buildpacks: - https://github.com/rileyseaburg/rust-buildpack-touchpoints.git + - nodejs_buildpack - ruby_buildpack services: - touchpoints-staging-database diff --git a/touchpoints.yml b/touchpoints.yml index f7affe4a7..e20b17f88 100644 --- a/touchpoints.yml +++ b/touchpoints.yml @@ -1,5 +1,6 @@ applications: - name: touchpoints + memory: 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: