diff --git a/.github/workflows/ci-checks.yml b/.github/workflows/ci-checks.yml new file mode 100644 index 0000000000..4fdc1bcba5 --- /dev/null +++ b/.github/workflows/ci-checks.yml @@ -0,0 +1,40 @@ +name: Checks + +on: + pull_request: + +jobs: + security: + name: Security + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - uses: ruby/setup-ruby@v1 + with: + ruby-version: .ruby-version + bundler-cache: true + + - name: Gem audit + run: bin/bundler-audit check --update + + - name: Importmap audit + run: bin/importmap audit + + - name: Brakeman audit + run: bin/brakeman --quiet --no-pager --exit-on-warn --exit-on-error + + + lint: + name: Lint + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - uses: ruby/setup-ruby@v1 + with: + ruby-version: .ruby-version + bundler-cache: true + + - name: Lint code for consistent style + run: bin/rubocop diff --git a/.github/workflows/ci-oss.yml b/.github/workflows/ci-oss.yml new file mode 100644 index 0000000000..7ccd00bf77 --- /dev/null +++ b/.github/workflows/ci-oss.yml @@ -0,0 +1,11 @@ +name: CI (OSS) + +on: + pull_request: + if: github.event.pull_request.head.repo.full_name != github.repository + +jobs: + test: + uses: ./.github/workflows/test.yml + with: + saas: false diff --git a/.github/workflows/ci-saas.yml b/.github/workflows/ci-saas.yml new file mode 100644 index 0000000000..f1d8875b44 --- /dev/null +++ b/.github/workflows/ci-saas.yml @@ -0,0 +1,18 @@ +name: CI (SaaS) + +on: + push: + +jobs: + test_oss: + name: Test (OSS) + uses: ./.github/workflows/test.yml + with: + saas: false + test_saas: + name: Test (SaaS) + uses: ./.github/workflows/test.yml + with: + saas: true + secrets: + GH_TOKEN: ${{ secrets.GH_TOKEN }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000000..8b5925a13e --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,67 @@ +name: Test + +on: + workflow_call: + inputs: + saas: + type: boolean + required: true + secrets: + GH_TOKEN: + required: false + +jobs: + test: + name: Tests (${{ matrix.mode }}) + runs-on: ubuntu-latest + + strategy: + matrix: + include: + - mode: SQLite + db_adapter: sqlite + - mode: MySQL + db_adapter: mysql + + services: + mysql: + image: mysql:8.0 + env: + MYSQL_ALLOW_EMPTY_PASSWORD: yes + MYSQL_DATABASE: fizzy_test + ports: + - 3306:3306 + options: >- + --health-cmd="mysqladmin ping" + --health-interval=10s + --health-timeout=5s + --health-retries=3 + + env: + RAILS_ENV: test + DATABASE_ADAPTER: ${{ matrix.db_adapter }} + ${{ inputs.saas && 'SAAS' || 'SAAS_DISABLED' }}: ${{ inputs.saas && '1' || '' }} + BUNDLE_GEMFILE: ${{ inputs.saas && 'Gemfile.saas' || 'Gemfile' }} + MYSQL_HOST: 127.0.0.1 + MYSQL_PORT: 3306 + MYSQL_USER: root + FIZZY_DB_HOST: 127.0.0.1 + FIZZY_DB_PORT: 3306 + BUNDLE_GITHUB__COM: ${{ inputs.saas && format('x-access-token:{0}', secrets.GH_TOKEN) || '' }} + + steps: + - name: Install system packages + run: sudo apt-get update && sudo apt-get install --no-install-recommends -y libsqlite3-0 libvips curl ffmpeg + + - uses: actions/checkout@v4 + + - uses: ruby/setup-ruby@v1 + with: + ruby-version: .ruby-version + bundler-cache: true + + - name: Run tests + run: bin/rails db:setup test + + - name: Run system tests + run: bin/rails test:system diff --git a/.kamal/hooks/post-deploy b/.kamal/hooks/post-deploy deleted file mode 100755 index 8715060788..0000000000 --- a/.kamal/hooks/post-deploy +++ /dev/null @@ -1,17 +0,0 @@ -#!/usr/bin/env bash - -MESSAGE="$KAMAL_PERFORMER deployed $KAMAL_SERVICE_VERSION to $KAMAL_DESTINATION in $KAMAL_RUNTIME seconds" -CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD) - -bin/notify_dash_of_deployment "$MESSAGE" $KAMAL_VERSION $KAMAL_PERFORMER $CURRENT_BRANCH $KAMAL_DESTINATION $KAMAL_RUNTIME - -if [[ $CURRENT_BRANCH == "main" && $KAMAL_DESTINATION == "production" ]]; then - gh release create $KAMAL_SERVICE_VERSION --target $KAMAL_VERSION --generate-notes 2> /dev/null || true - - RELEASE_URL=$(gh release view $KAMAL_SERVICE_VERSION --json url,body --jq .url) - RELEASE_BODY=$(gh release view $KAMAL_SERVICE_VERSION --json url,body --jq .body) - - bin/broadcast_to_bc "$MESSAGE "$'\n'"$RELEASE_URL "$'\n'"$RELEASE_BODY" -else - bin/broadcast_to_bc "$MESSAGE" -fi diff --git a/.kamal/hooks/pre-build b/.kamal/hooks/pre-build deleted file mode 100755 index 99de8a8248..0000000000 --- a/.kamal/hooks/pre-build +++ /dev/null @@ -1,37 +0,0 @@ -#!/usr/bin/env ruby - -def exit_with_error(message) - $stderr.puts message - exit 1 -end - -def check_branch - if ENV["KAMAL_DESTINATION"] == "production" - current_branch = `git branch --show-current`.strip - - if current_branch != "main" - exit_with_error "Only the `main` branch should be deployed to production, current branch is #{current_branch}. If this is expected, try again with `SKIP_GIT_CHECKS=1` prepended to the command" - end - end -end - -def check_for_uncommitted_changes - if `git status --porcelain`.strip.length != 0 - exit_with_error "You have uncommitted changes, aborting" - end -end - -def check_local_and_remote_heads_match - remote_head = `git ls-remote origin --tags $(git branch --show-current) | cut -f1 | head -1`.strip - local_head = `git rev-parse HEAD`.strip - - if local_head != remote_head - exit_with_error "Remote HEAD #{remote_head}, differs from local HEAD #{local_head}, aborting" - end -end - -unless ENV["SKIP_GIT_CHECKS"] - check_branch - check_for_uncommitted_changes - check_local_and_remote_heads_match -end diff --git a/.kamal/hooks/pre-connect b/.kamal/hooks/pre-connect deleted file mode 100755 index 5f42f2cf2b..0000000000 --- a/.kamal/hooks/pre-connect +++ /dev/null @@ -1,82 +0,0 @@ -#!/usr/bin/env bash - -# Validate hostnames are FQDNs ending in -int.37signals.com -if command -v yq >/dev/null 2>&1; then - declare -A SUGGESTIONS - while IFS= read -r host; do - if [[ ! $host =~ -int\.37signals\.com$ ]]; then - if [[ $host =~ -4[0-9]{2}$ ]]; then - SUGGESTIONS["$host"]="$host.df-ams-int.37signals.com" - elif [[ $host =~ -1[0-9]{2}$ ]]; then - SUGGESTIONS["$host"]="$host.df-iad-int.37signals.com" - else - SUGGESTIONS["$host"]="$host.sc-chi-int.37signals.com" - fi - fi - done < <(bin/kamal config -d "${KAMAL_DESTINATION:-production}" 2>/dev/null | yq -r '.":hosts"[]') - - if [ ${#SUGGESTIONS[@]} -gt 0 ]; then - echo "Unqualified hostnames found in config/deploy.${KAMAL_DESTINATION:-production}.yml:" >&2 - echo "" >&2 - echo "Update to use fully-qualified hostnames:" >&2 - for host in "${!SUGGESTIONS[@]}"; do - echo " $host → ${SUGGESTIONS[$host]}" >&2 - done - exit 1 - fi -fi - -# Verify Tailscale connection and SSH authentication before deploying. -tailscale_cmd() { - if command -v tailscale >/dev/null 2>&1; then - tailscale "$@" - elif [ -f "/Applications/Tailscale.app/Contents/MacOS/Tailscale" ]; then - env TAILSCALE_BE_CLI=1 /Applications/Tailscale.app/Contents/MacOS/Tailscale "$@" - else - return 1 - fi -} - -on_tailscale() { - tailscale_cmd status --json 2>/dev/null | jq -e '.Self.Online' >/dev/null 2>&1 -} - -# Check Tailscale connection -if ! on_tailscale; then - echo "" >&2 - echo "You must be connected to Tailscale to deploy." >&2 - echo "" >&2 - echo "→ Connect to Tailscale and try again" >&2 - echo "" >&2 - exit 1 -fi - -# Verify SSH access -echo "Deploying via Tailscale. Verifying SSH access…" >&2 - -TEST_HOST="fizzy-app-101" - -SSH_OUTPUT=$(ssh -o ConnectTimeout=5 "app@$TEST_HOST" true 2>&1) -SSH_EXIT=$? - -echo "$SSH_OUTPUT" >&2 - -if echo "$SSH_OUTPUT" | grep -q "Permission denied"; then - GITHUB_USER=$(gh api user 2>/dev/null | jq -r '.login // "unknown"') - GITHUB_KEYS_URL="https://github.com/${GITHUB_USER}.keys" - - echo "" >&2 - echo "ERROR: SSH authentication failed" >&2 - echo "" >&2 - echo "You must deploy with an SSH key that's on your GitHub account." >&2 - echo "" >&2 - echo "→ Verify your public key is at $GITHUB_KEYS_URL" >&2 - echo " Add it at https://github.com/settings/keys if not" >&2 - echo "" >&2 - echo "Note that SSH keys are pulled from GitHub every 5 minutes, so if you've" >&2 - echo "just added a new key to GitHub, try again in five." >&2 - echo "" >&2 - exit 1 -fi - -exit $SSH_EXIT diff --git a/.kamal/secrets.beta b/.kamal/secrets.beta deleted file mode 100644 index 87583b01f7..0000000000 --- a/.kamal/secrets.beta +++ /dev/null @@ -1,12 +0,0 @@ -SECRETS=$(kamal secrets fetch --adapter 1password --account basecamp --from Deploy/Fizzy Deployments/BASECAMP_REGISTRY_PASSWORD Deployments/DASH_BASIC_AUTH_SECRET Beta/RAILS_MASTER_KEY Beta/MYSQL_ALTER_PASSWORD Beta/MYSQL_ALTER_USER Beta/MYSQL_APP_PASSWORD Beta/MYSQL_APP_USER Beta/MYSQL_READONLY_PASSWORD Beta/MYSQL_READONLY_USER) - -GITHUB_TOKEN=$(gh config get -h github.com oauth_token) -BASECAMP_REGISTRY_PASSWORD=$(kamal secrets extract BASECAMP_REGISTRY_PASSWORD $SECRETS) -DASH_BASIC_AUTH_SECRET=$(kamal secrets extract DASH_BASIC_AUTH_SECRET $SECRETS) -RAILS_MASTER_KEY=$(kamal secrets extract RAILS_MASTER_KEY $SECRETS) -MYSQL_ALTER_PASSWORD=$(kamal secrets extract MYSQL_ALTER_PASSWORD $SECRETS) -MYSQL_ALTER_USER=$(kamal secrets extract MYSQL_ALTER_USER $SECRETS) -MYSQL_APP_PASSWORD=$(kamal secrets extract MYSQL_APP_PASSWORD $SECRETS) -MYSQL_APP_USER=$(kamal secrets extract MYSQL_APP_USER $SECRETS) -MYSQL_READONLY_PASSWORD=$(kamal secrets extract MYSQL_READONLY_PASSWORD $SECRETS) -MYSQL_READONLY_USER=$(kamal secrets extract MYSQL_READONLY_USER $SECRETS) diff --git a/.kamal/secrets.dhh b/.kamal/secrets.dhh deleted file mode 100644 index 9d9a33d9f6..0000000000 --- a/.kamal/secrets.dhh +++ /dev/null @@ -1,6 +0,0 @@ -SECRETS=$(kamal secrets fetch --adapter 1password --account basecamp --from Deploy/Fizzy Deployments/BASECAMP_REGISTRY_PASSWORD Deployments/DASH_BASIC_AUTH_SECRET Beta/RAILS_MASTER_KEY) - -GITHUB_TOKEN=$(gh config get -h github.com oauth_token) -BASECAMP_REGISTRY_PASSWORD=$(kamal secrets extract BASECAMP_REGISTRY_PASSWORD $SECRETS) -DASH_BASIC_AUTH_SECRET=$(kamal secrets extract DASH_BASIC_AUTH_SECRET $SECRETS) -RAILS_MASTER_KEY=$(kamal secrets extract RAILS_MASTER_KEY $SECRETS) diff --git a/.kamal/secrets.production b/.kamal/secrets.production deleted file mode 100644 index 9ebe90ef66..0000000000 --- a/.kamal/secrets.production +++ /dev/null @@ -1,12 +0,0 @@ -SECRETS=$(kamal secrets fetch --adapter 1password --account basecamp --from Deploy/Fizzy Deployments/BASECAMP_REGISTRY_PASSWORD Deployments/DASH_BASIC_AUTH_SECRET Production/RAILS_MASTER_KEY Production/MYSQL_ALTER_PASSWORD Production/MYSQL_ALTER_USER Production/MYSQL_APP_PASSWORD Production/MYSQL_APP_USER Production/MYSQL_READONLY_PASSWORD Production/MYSQL_READONLY_USER) - -GITHUB_TOKEN=$(gh config get -h github.com oauth_token) -BASECAMP_REGISTRY_PASSWORD=$(kamal secrets extract BASECAMP_REGISTRY_PASSWORD $SECRETS) -DASH_BASIC_AUTH_SECRET=$(kamal secrets extract DASH_BASIC_AUTH_SECRET $SECRETS) -RAILS_MASTER_KEY=$(kamal secrets extract RAILS_MASTER_KEY $SECRETS) -MYSQL_ALTER_PASSWORD=$(kamal secrets extract MYSQL_ALTER_PASSWORD $SECRETS) -MYSQL_ALTER_USER=$(kamal secrets extract MYSQL_ALTER_USER $SECRETS) -MYSQL_APP_PASSWORD=$(kamal secrets extract MYSQL_APP_PASSWORD $SECRETS) -MYSQL_APP_USER=$(kamal secrets extract MYSQL_APP_USER $SECRETS) -MYSQL_READONLY_PASSWORD=$(kamal secrets extract MYSQL_READONLY_PASSWORD $SECRETS) -MYSQL_READONLY_USER=$(kamal secrets extract MYSQL_READONLY_USER $SECRETS) diff --git a/.kamal/secrets.staging b/.kamal/secrets.staging deleted file mode 100644 index da2952efa6..0000000000 --- a/.kamal/secrets.staging +++ /dev/null @@ -1,12 +0,0 @@ -SECRETS=$(kamal secrets fetch --adapter 1password --account basecamp --from Deploy/Fizzy Deployments/BASECAMP_REGISTRY_PASSWORD Deployments/DASH_BASIC_AUTH_SECRET Staging/RAILS_MASTER_KEY Staging/MYSQL_ALTER_PASSWORD Staging/MYSQL_ALTER_USER Staging/MYSQL_APP_PASSWORD Staging/MYSQL_APP_USER Staging/MYSQL_READONLY_PASSWORD Staging/MYSQL_READONLY_USER) - -GITHUB_TOKEN=$(gh config get -h github.com oauth_token) -BASECAMP_REGISTRY_PASSWORD=$(kamal secrets extract BASECAMP_REGISTRY_PASSWORD $SECRETS) -DASH_BASIC_AUTH_SECRET=$(kamal secrets extract DASH_BASIC_AUTH_SECRET $SECRETS) -RAILS_MASTER_KEY=$(kamal secrets extract RAILS_MASTER_KEY $SECRETS) -MYSQL_ALTER_PASSWORD=$(kamal secrets extract MYSQL_ALTER_PASSWORD $SECRETS) -MYSQL_ALTER_USER=$(kamal secrets extract MYSQL_ALTER_USER $SECRETS) -MYSQL_APP_PASSWORD=$(kamal secrets extract MYSQL_APP_PASSWORD $SECRETS) -MYSQL_APP_USER=$(kamal secrets extract MYSQL_APP_USER $SECRETS) -MYSQL_READONLY_PASSWORD=$(kamal secrets extract MYSQL_READONLY_PASSWORD $SECRETS) -MYSQL_READONLY_USER=$(kamal secrets extract MYSQL_READONLY_USER $SECRETS) diff --git a/AGENTS.md b/AGENTS.md index 51407a6fb6..8bdaa4481b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -137,72 +137,14 @@ Key recurring tasks (via `config/recurring.yml`): - Search records denormalized for performance - Models in `app/models/search/` -## Production Observability - -Grafana MCP tools provide access to production metrics and logs for performance analysis. - -### Datasources -| Name | UID | Use | -|------|-----|-----| -| Thanos (Prometheus) | `PC96415006F908B67` | Metrics, latencies | -| Loki | `e38bdfea-097e-47fa-a7ab-774fd2487741` | Application logs | - -### Key Metrics -- `rails_request_duration_seconds_bucket:rate1m:sum_by_app:quantiles{app="fizzy"}` - Request latency percentiles -- `rails_request_total:rate1m:sum_by_controller_action{app="fizzy"}` - Request rates by endpoint -- `fizzy_replica_wait_seconds` - Database replica consistency wait times - -### Loki Log Labels and Query Patterns - -**Base label selector:** -```logql -{service_namespace="fizzy", deployment_environment_name="production", service_name="rails"} -``` - -**Useful JSON fields:** `event_duration_ms`, `performance_time_db_ms`, `performance_time_cpu_ms`, `rails_endpoint`, `rails_controller`, `url_path`, `authentication_identity_id`, `http_response_status_code` - -**Query patterns:** -- Filter by fields: `{labels} | field_name = "value"` -- Multiple field filters: `{labels} | field1 = "value1" | field2 = "value2"` -- Reduce returned labels: `{labels} | filters | keep field1,field2,field3` (reduces label payload) -- Minimize log line content: `{labels} | filters | line_format "{{.field_name}}"` (replaces raw log line) -- Combine both for minimal tokens: `{labels} | filters | keep field1,field2 | line_format "{{.field1}}"` -- **Important:** Fields are pre-parsed by the OTel collector. Don't use string search (`|=`) when filtering structured fields -- **Important:** Do NOT use `| json` - it will cause JSONParserErr since fields are already parsed as labels - -**Token management (CRITICAL):** -- Always probe with `limit: 3` first to check response size before running larger queries -- Aggregations return time series (many data points), not single values - can explode token usage -- NEVER use `sum by (field)` - returns a time series per unique value, easily exceeds token limits -- For breakdowns by field: fetch raw logs with `| keep field | line_format "{{.field}}"` and count client-side - -**Aggregations for statistics (use instead of fetching raw logs):** -- `mcp__grafana__query_loki_logs` returns limited results (default 10, max ~100) and large responses get truncated; use aggregations for statistics on large datasets -- Count: `sum(count_over_time({labels} | filters [12h]))` -- Percentiles: `quantile_over_time(0.95, {labels} | filters | unwrap field_name | __error__="" [12h]) by ()` -- Average: `avg_over_time({labels} | filters | unwrap field_name | __error__="" [12h]) by ()` -- Min/Max: `min_over_time(...)` / `max_over_time(...)` -- The `| unwrap field_name | __error__=""` pattern extracts numeric values from pre-parsed labels -- Use `by ()` or wrap in `sum()` to avoid cardinality limits - -**Documentation:** For advanced LogQL syntax (aggregations, pattern matching, etc.), consult https://grafana.com/docs/loki/latest/query/ - -### Instrumentation -Yabeda-based metrics exported at `:9394/metrics`. Config in `config/initializers/yabeda.rb`. +## Tools ### Chrome MCP (Local Dev) + URL: `http://fizzy.localhost:3006` Login: david@37signals.com (passwordless magic link auth - check rails console for link) -Use Chrome MCP tools to interact with the running dev app for UI testing and debugging. - -### Sentry Error Tracking -Organization: `basecamp` | Project: `fizzy` | Region: `https://us.sentry.io` - -Use Sentry MCP tools to investigate production errors: -- `search_issues` - Find grouped issues by natural language query -- `get_issue_details` - Get full stacktrace and context for a specific issue -- `analyze_issue_with_seer` - AI-powered root cause analysis with code fix suggestions +Use Chrome MCP tools to interact with the running dev app for UI testing and debugging.`` ## Coding style diff --git a/Dockerfile b/Dockerfile index 6341f3fc19..3cd25c7f19 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,77 +1,76 @@ +# syntax=docker/dockerfile:1 +# check=error=true + +# This Dockerfile is designed for production, not development. Use with Kamal or build'n'run by hand: +# docker build -t fizzy . +# docker run -d -p 80:80 -e RAILS_MASTER_KEY= --name fizzy fizzy + +# For a containerized dev environment, see Dev Containers: https://guides.rubyonrails.org/getting_started_with_devcontainer.html + # Make sure RUBY_VERSION matches the Ruby version in .ruby-version ARG RUBY_VERSION=3.4.7 -FROM registry.docker.com/library/ruby:$RUBY_VERSION-slim AS base +FROM docker.io/library/ruby:$RUBY_VERSION-slim AS base # Rails app lives here WORKDIR /rails -# Set production environment +# Install base packages +RUN apt-get update -qq && \ + apt-get install --no-install-recommends -y curl libjemalloc2 libvips sqlite3 && \ + ln -s /usr/lib/$(uname -m)-linux-gnu/libjemalloc.so.2 /usr/local/lib/libjemalloc.so && \ + rm -rf /var/lib/apt/lists /var/cache/apt/archives + +# Set production environment variables and enable jemalloc for reduced memory usage and latency. ENV RAILS_ENV="production" \ BUNDLE_DEPLOYMENT="1" \ BUNDLE_PATH="/usr/local/bundle" \ - BUNDLE_WITHOUT="development" - + BUNDLE_WITHOUT="development" \ + LD_PRELOAD="/usr/local/lib/libjemalloc.so" # Throw-away build stage to reduce size of final image FROM base AS build # Install packages needed to build gems RUN apt-get update -qq && \ - apt-get install -y --no-install-recommends -y build-essential pkg-config git libvips libyaml-dev libssl-dev && \ + apt-get install --no-install-recommends -y build-essential git libyaml-dev pkg-config && \ rm -rf /var/lib/apt/lists /var/cache/apt/archives # Install application gems -COPY Gemfile Gemfile.lock .ruby-version ./ -COPY lib/bootstrap.rb ./lib/bootstrap.rb -COPY gems ./gems/ -RUN --mount=type=secret,id=GITHUB_TOKEN --mount=type=cache,id=fizzy-permabundle-${RUBY_VERSION},sharing=locked,target=/permabundle \ - gem install bundler && \ - BUNDLE_PATH=/permabundle BUNDLE_GITHUB__COM="$(cat /run/secrets/GITHUB_TOKEN):x-oauth-basic" bundle install && \ - cp -a /permabundle/. "$BUNDLE_PATH"/ && \ - bundle clean --force && \ - rm -rf "$BUNDLE_PATH"/ruby/*/bundler/gems/*/.git && \ - find "$BUNDLE_PATH" -type f \( -name '*.gem' -o -iname '*.a' -o -iname '*.o' -o -iname '*.h' -o -iname '*.c' -o -iname '*.hpp' -o -iname '*.cpp' \) -delete && \ - bundle exec bootsnap precompile --gemfile +COPY Gemfile Gemfile.lock vendor ./ + +RUN bundle install && \ + rm -rf ~/.bundle/ "${BUNDLE_PATH}"/ruby/*/cache "${BUNDLE_PATH}"/ruby/*/bundler/gems/*/.git && \ + # -j 1 disable parallel compilation to avoid a QEMU bug: https://github.com/rails/bootsnap/issues/495 + bundle exec bootsnap precompile -j 1 --gemfile # Copy application code COPY . . -# Precompile bootsnap code for faster boot times -RUN bundle exec bootsnap precompile app/ lib/ +# Precompile bootsnap code for faster boot times. +# -j 1 disable parallel compilation to avoid a QEMU bug: https://github.com/rails/bootsnap/issues/495 +RUN bundle exec bootsnap precompile -j 1 app/ lib/ # Precompiling assets for production without requiring secret RAILS_MASTER_KEY RUN SECRET_KEY_BASE_DUMMY=1 ./bin/rails assets:precompile + + + # Final stage for app image FROM base -# Install packages needed for deployment -RUN apt-get update -qq && \ - apt-get install --no-install-recommends -y curl libsqlite3-0 libvips build-essential ffmpeg groff libreoffice-writer libreoffice-impress libreoffice-calc mupdf-tools sqlite3 libjemalloc-dev && \ - rm -rf /var/lib/apt/lists /var/cache/apt/archives +# Run and own only the runtime files as a non-root user for security +RUN groupadd --system --gid 1000 rails && \ + useradd rails --uid 1000 --gid 1000 --create-home --shell /bin/bash +USER 1000:1000 # Copy built artifacts: gems, application -COPY --from=build /usr/local/bundle /usr/local/bundle -COPY --from=build /rails /rails - -# Run and own only the runtime files as a non-root user for security -RUN useradd rails --create-home --shell /bin/bash && \ - chown -R rails:rails db log storage tmp -USER rails:rails +COPY --chown=rails:rails --from=build "${BUNDLE_PATH}" "${BUNDLE_PATH}" +COPY --chown=rails:rails --from=build /rails /rails # Entrypoint prepares the database. ENTRYPOINT ["/rails/bin/docker-entrypoint"] -# Ruby GC tuning values pulled from Autotuner recommendations -ENV RUBY_GC_HEAP_0_INIT_SLOTS=692636 \ - RUBY_GC_HEAP_1_INIT_SLOTS=175943 \ - RUBY_GC_HEAP_2_INIT_SLOTS=148807 \ - RUBY_GC_HEAP_3_INIT_SLOTS=9169 \ - RUBY_GC_HEAP_4_INIT_SLOTS=3054 \ - RUBY_GC_MALLOC_LIMIT=33554432 \ - RUBY_GC_MALLOC_LIMIT_MAX=67108864 \ - LD_PRELOAD=/usr/lib/x86_64-linux-gnu/libjemalloc.so.2 - -# Start the server by default, this can be overwritten at runtime -EXPOSE 80 443 9394 +# Start server via Thruster by default, this can be overwritten at runtime +EXPOSE 80 CMD ["./bin/thrust", "./bin/rails", "server"] diff --git a/Dockerfile.dev b/Dockerfile.dev index 75b284570a..9c20fcda5d 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -18,7 +18,7 @@ RUN apt-get update -qq && \ # Install application gems COPY Gemfile Gemfile.lock .ruby-version ./ -COPY lib/bootstrap.rb ./lib/bootstrap.rb +COPY lib/fizzy.rb ./lib/fizzy.rb COPY gems ./gems/ RUN --mount=type=secret,id=GITHUB_TOKEN --mount=type=cache,id=fizzy-devbundle-${RUBY_VERSION},sharing=locked,target=/devbundle \ gem install bundler foreman && \ diff --git a/Gemfile b/Gemfile index d008f49073..63c98f89c2 100644 --- a/Gemfile +++ b/Gemfile @@ -1,7 +1,7 @@ source "https://rubygems.org" -git_source(:bc) { |repo| "https://github.com/basecamp/#{repo}" } gem "rails", github: "rails/rails", branch: "main" +git_source(:bc) { |repo| "https://github.com/basecamp/#{repo}" } # Assets & front end gem "importmap-rails" @@ -36,22 +36,9 @@ gem "net-http-persistent" gem "mittens" gem "useragent", bc: "useragent" -# Telemetry, logging, and operations -gem "mission_control-jobs" -gem "sentry-ruby" -gem "sentry-rails" -gem "rails_structured_logging", bc: "rails-structured-logging" -gem "yabeda" -gem "yabeda-actioncable" -gem "yabeda-activejob", github: "basecamp/yabeda-activejob", branch: "bulk-and-scheduled-jobs" -gem "yabeda-gc" -gem "yabeda-http_requests" -gem "yabeda-prometheus-mmap" -gem "yabeda-puma-plugin" -gem "yabeda-rails" -gem "webrick" # required for yabeda-prometheus metrics server -gem "prometheus-client-mmap", "~> 1.3" +# Operations gem "autotuner" +gem "mission_control-jobs" gem "benchmark" # indirect dependency, being removed from Ruby 3.5 stdlib so here to quash warnings group :development, :test do @@ -75,9 +62,3 @@ group :test do gem "vcr" gem "mocha" end - -require_relative "lib/bootstrap" -unless Bootstrap.oss_config? - eval_gemfile "gems/fizzy-saas/Gemfile" - gem "fizzy-saas", path: "gems/fizzy-saas" -end diff --git a/Gemfile.lock b/Gemfile.lock index f8001333cc..502c14965d 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,36 +1,9 @@ -GIT - remote: https://github.com/basecamp/queenbee-plugin - revision: eb01c697de1ad028afc65cc7d9b5345a7a8e849f - ref: eb01c697de1ad028afc65cc7d9b5345a7a8e849f - specs: - queenbee (3.2.0) - activeresource - builder - rexml - -GIT - remote: https://github.com/basecamp/rails-structured-logging - revision: 76960cb5c15fc2b6b5f7542e05d7dcc031cef9e6 - specs: - rails_structured_logging (0.2.1) - json - rails (>= 6.0.0) - GIT remote: https://github.com/basecamp/useragent revision: 433ca320a42db1266c4b89df74d0abdb9a880c5e specs: useragent (0.16.11) -GIT - remote: https://github.com/basecamp/yabeda-activejob.git - revision: 684973f77ff01d8b3dd75874538fae55961e15e6 - branch: bulk-and-scheduled-jobs - specs: - yabeda-activejob (0.6.0) - rails (>= 6.1) - yabeda (~> 0.6) - GIT remote: https://github.com/rails/rails.git revision: b22cb0c0cb39b103d816a66d560991acf0a57163 @@ -132,31 +105,13 @@ GIT tsort (>= 0.2) zeitwerk (~> 2.6) -PATH - remote: gems/fizzy-saas - specs: - fizzy-saas (0.1.0) - queenbee - rails (>= 8.1.0.beta1) - rails_structured_logging - GEM remote: https://rubygems.org/ specs: action_text-trix (2.1.15) railties - activemodel-serializers-xml (1.0.3) - activemodel (>= 5.0.0.a) - activesupport (>= 5.0.0.a) - builder (~> 3.1) - activeresource (6.2.0) - activemodel (>= 7.0) - activemodel-serializers-xml (~> 1.0) - activesupport (>= 7.0) addressable (2.8.8) public_suffix (>= 2.0.2, < 8.0) - anyway_config (2.7.2) - ruby-next-core (~> 1.0) ast (2.4.3) autotuner (1.1.0) aws-eventstream (1.4.0) @@ -216,7 +171,6 @@ GEM reline (>= 0.3.8) dotenv (3.1.8) drb (2.2.3) - dry-initializer (3.2.0) ed25519 (1.4.0) erb (6.0.0) erubi (1.13.1) @@ -259,7 +213,7 @@ GEM json (2.16.0) jwt (3.1.2) base64 - kamal (2.8.2) + kamal (2.9.0) activesupport (>= 7.0) base64 (~> 0.2) bcrypt_pbkdf (~> 1.0) @@ -352,31 +306,6 @@ GEM prettyprint prettyprint (0.2.0) prism (1.6.0) - prometheus-client-mmap (1.3.0) - base64 - bigdecimal - logger - rb_sys (~> 0.9.117) - prometheus-client-mmap (1.3.0-arm64-darwin) - base64 - bigdecimal - logger - rb_sys (~> 0.9.117) - prometheus-client-mmap (1.3.0-x86_64-darwin) - base64 - bigdecimal - logger - rb_sys (~> 0.9.117) - prometheus-client-mmap (1.3.0-x86_64-linux-gnu) - base64 - bigdecimal - logger - rb_sys (~> 0.9.117) - prometheus-client-mmap (1.3.0-x86_64-linux-musl) - base64 - bigdecimal - logger - rb_sys (~> 0.9.117) propshaft (1.3.1) actionpack (>= 7.0.0) activesupport (>= 7.0.0) @@ -408,9 +337,6 @@ GEM nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) rainbow (3.1.1) rake (13.3.1) - rake-compiler-dock (1.9.1) - rb_sys (0.9.117) - rake-compiler-dock (= 1.9.1) rdoc (6.16.0) erb psych (>= 4.0.0) @@ -453,7 +379,6 @@ GEM rubocop (>= 1.72) rubocop-performance (>= 1.24) rubocop-rails (>= 2.30) - ruby-next-core (1.1.2) ruby-progressbar (1.13.0) ruby-vips (2.2.5) ffi (~> 1.12) @@ -467,15 +392,6 @@ GEM rexml (~> 3.2, >= 3.2.5) rubyzip (>= 1.2.2, < 4.0) websocket (~> 1.0) - sentry-rails (6.1.2) - railties (>= 5.2.0) - sentry-ruby (~> 6.1.2) - sentry-ruby (6.1.2) - bigdecimal - concurrent-ruby (~> 1.0, >= 1.0.2) - sniffer (0.5.0) - anyway_config (>= 1.0) - dry-initializer (~> 3) solid_cable (3.0.12) actioncable (>= 7.2) activejob (>= 7.2) @@ -539,7 +455,6 @@ GEM addressable (>= 2.8.0) crack (>= 0.3.2) hashdiff (>= 0.4.0, < 2.0.0) - webrick (1.9.1) websocket (1.2.11) websocket-driver (0.8.0) base64 @@ -547,33 +462,6 @@ GEM websocket-extensions (0.1.5) xpath (3.2.0) nokogiri (~> 1.8) - yabeda (0.14.0) - anyway_config (>= 1.0, < 3) - concurrent-ruby - dry-initializer - yabeda-actioncable (0.2.2) - actioncable (>= 7.2) - activesupport (>= 7.2) - railties (>= 7.2) - yabeda (~> 0.8) - yabeda-gc (0.4.0) - yabeda (~> 0.6) - yabeda-http_requests (0.3.0) - anyway_config (>= 1.3, < 3.0) - sniffer - yabeda - yabeda-prometheus-mmap (0.4.0) - prometheus-client-mmap - yabeda (~> 0.10) - yabeda-puma-plugin (0.9.0) - json - puma - yabeda (~> 0.5) - yabeda-rails (0.10.0) - activesupport - anyway_config (>= 1.3, < 3) - railties - yabeda (~> 0.8) zeitwerk (2.7.3) PLATFORMS @@ -585,7 +473,6 @@ PLATFORMS x86_64-linux-musl DEPENDENCIES - activeresource autotuner aws-sdk-s3 bcrypt (~> 3.1.7) @@ -596,7 +483,6 @@ DEPENDENCIES capybara debug faker - fizzy-saas! geared_pagination (~> 1.2) image_processing (~> 1.14) importmap-rails @@ -609,20 +495,15 @@ DEPENDENCIES mocha net-http-persistent platform_agent - prometheus-client-mmap (~> 1.3) propshaft puma (>= 5.0) - queenbee! rack-mini-profiler rails! - rails_structured_logging! redcarpet rouge rqrcode rubocop-rails-omakase selenium-webdriver - sentry-rails - sentry-ruby solid_cable (>= 3.0) solid_cache (~> 1.0) solid_queue (~> 1.2) @@ -636,15 +517,6 @@ DEPENDENCIES web-console web-push webmock - webrick - yabeda - yabeda-actioncable - yabeda-activejob! - yabeda-gc - yabeda-http_requests - yabeda-prometheus-mmap - yabeda-puma-plugin - yabeda-rails BUNDLED WITH 2.7.2 diff --git a/Gemfile.saas b/Gemfile.saas new file mode 100644 index 0000000000..a56a7b6825 --- /dev/null +++ b/Gemfile.saas @@ -0,0 +1,24 @@ +# This Gemfile extends the base Gemfile with SaaS-specific dependencies +eval_gemfile "Gemfile" + +git_source(:bc) { |repo| "https://github.com/basecamp/#{repo}" } + + +gem "activeresource", require: "active_resource" +gem "queenbee", bc: "queenbee-plugin" +gem "fizzy-saas", bc: "fizzy-saas" +gem "rails_structured_logging", bc: "rails-structured-logging" +gem "sentry-ruby" +gem "sentry-rails" + +# Telemetry +gem "yabeda" +gem "yabeda-actioncable" +gem "yabeda-activejob", github: "basecamp/yabeda-activejob", branch: "bulk-and-scheduled-jobs" +gem "yabeda-gc" +gem "yabeda-http_requests" +gem "yabeda-prometheus-mmap" +gem "yabeda-puma-plugin" +gem "yabeda-rails" +gem "webrick" # required for yabeda-prometheus metrics server +gem "prometheus-client-mmap", "~> 1.3" diff --git a/Gemfile.saas.lock b/Gemfile.saas.lock new file mode 100644 index 0000000000..fc1bcd7584 --- /dev/null +++ b/Gemfile.saas.lock @@ -0,0 +1,662 @@ +GIT + remote: https://github.com/basecamp/fizzy-saas + revision: 7f392bbbf9f5170d334b6ee2f6d240569bd157ed + specs: + fizzy-saas (0.1.0) + prometheus-client-mmap + queenbee + rails (>= 8.1.0.beta1) + rails_structured_logging + sentry-rails + sentry-ruby + yabeda + yabeda-actioncable + yabeda-activejob + yabeda-gc + yabeda-http_requests + yabeda-prometheus-mmap + yabeda-puma-plugin + yabeda-rails + +GIT + remote: https://github.com/basecamp/queenbee-plugin + revision: 15faf03a876c5e66b67753d2e1ddb24f1eb5abb2 + specs: + queenbee (3.2.0) + activeresource + builder + rexml + +GIT + remote: https://github.com/basecamp/rails-structured-logging + revision: 76960cb5c15fc2b6b5f7542e05d7dcc031cef9e6 + specs: + rails_structured_logging (0.2.1) + json + rails (>= 6.0.0) + +GIT + remote: https://github.com/basecamp/useragent + revision: 433ca320a42db1266c4b89df74d0abdb9a880c5e + specs: + useragent (0.16.11) + +GIT + remote: https://github.com/basecamp/yabeda-activejob.git + revision: 684973f77ff01d8b3dd75874538fae55961e15e6 + branch: bulk-and-scheduled-jobs + specs: + yabeda-activejob (0.6.0) + rails (>= 6.1) + yabeda (~> 0.6) + +GIT + remote: https://github.com/rails/rails.git + revision: 4f7ab01bb5d6be78c7447dbb230c55027d08ae34 + branch: main + specs: + actioncable (8.2.0.alpha) + actionpack (= 8.2.0.alpha) + activesupport (= 8.2.0.alpha) + nio4r (~> 2.0) + websocket-driver (>= 0.6.1) + zeitwerk (~> 2.6) + actionmailbox (8.2.0.alpha) + actionpack (= 8.2.0.alpha) + activejob (= 8.2.0.alpha) + activerecord (= 8.2.0.alpha) + activestorage (= 8.2.0.alpha) + activesupport (= 8.2.0.alpha) + mail (>= 2.8.0) + actionmailer (8.2.0.alpha) + actionpack (= 8.2.0.alpha) + actionview (= 8.2.0.alpha) + activejob (= 8.2.0.alpha) + activesupport (= 8.2.0.alpha) + mail (>= 2.8.0) + rails-dom-testing (~> 2.2) + actionpack (8.2.0.alpha) + actionview (= 8.2.0.alpha) + activesupport (= 8.2.0.alpha) + nokogiri (>= 1.8.5) + rack (>= 2.2.4) + rack-session (>= 1.0.1) + rack-test (>= 0.6.3) + rails-dom-testing (~> 2.2) + rails-html-sanitizer (~> 1.6) + useragent (~> 0.16) + actiontext (8.2.0.alpha) + action_text-trix (~> 2.1.15) + actionpack (= 8.2.0.alpha) + activerecord (= 8.2.0.alpha) + activestorage (= 8.2.0.alpha) + activesupport (= 8.2.0.alpha) + globalid (>= 0.6.0) + nokogiri (>= 1.8.5) + actionview (8.2.0.alpha) + activesupport (= 8.2.0.alpha) + builder (~> 3.1) + erubi (~> 1.11) + rails-dom-testing (~> 2.2) + rails-html-sanitizer (~> 1.6) + activejob (8.2.0.alpha) + activesupport (= 8.2.0.alpha) + globalid (>= 0.3.6) + activemodel (8.2.0.alpha) + activesupport (= 8.2.0.alpha) + activerecord (8.2.0.alpha) + activemodel (= 8.2.0.alpha) + activesupport (= 8.2.0.alpha) + timeout (>= 0.4.0) + activestorage (8.2.0.alpha) + actionpack (= 8.2.0.alpha) + activejob (= 8.2.0.alpha) + activerecord (= 8.2.0.alpha) + activesupport (= 8.2.0.alpha) + marcel (~> 1.0) + activesupport (8.2.0.alpha) + base64 + bigdecimal + concurrent-ruby (~> 1.0, >= 1.3.1) + connection_pool (>= 2.2.5) + drb + i18n (>= 1.6, < 2) + json + logger (>= 1.4.2) + minitest (>= 5.1) + securerandom (>= 0.3) + tzinfo (~> 2.0, >= 2.0.5) + uri (>= 0.13.1) + rails (8.2.0.alpha) + actioncable (= 8.2.0.alpha) + actionmailbox (= 8.2.0.alpha) + actionmailer (= 8.2.0.alpha) + actionpack (= 8.2.0.alpha) + actiontext (= 8.2.0.alpha) + actionview (= 8.2.0.alpha) + activejob (= 8.2.0.alpha) + activemodel (= 8.2.0.alpha) + activerecord (= 8.2.0.alpha) + activestorage (= 8.2.0.alpha) + activesupport (= 8.2.0.alpha) + bundler (>= 1.15.0) + railties (= 8.2.0.alpha) + railties (8.2.0.alpha) + actionpack (= 8.2.0.alpha) + activesupport (= 8.2.0.alpha) + irb (~> 1.13) + rackup (>= 1.0.0) + rake (>= 12.2) + thor (~> 1.0, >= 1.2.2) + tsort (>= 0.2) + zeitwerk (~> 2.6) + +GEM + remote: https://rubygems.org/ + specs: + action_text-trix (2.1.15) + railties + activemodel-serializers-xml (1.0.3) + activemodel (>= 5.0.0.a) + activesupport (>= 5.0.0.a) + builder (~> 3.1) + activeresource (6.2.0) + activemodel (>= 7.0) + activemodel-serializers-xml (~> 1.0) + activesupport (>= 7.0) + addressable (2.8.7) + public_suffix (>= 2.0.2, < 7.0) + anyway_config (2.7.2) + ruby-next-core (~> 1.0) + ast (2.4.3) + autotuner (1.1.0) + aws-eventstream (1.4.0) + aws-partitions (1.1187.0) + aws-sdk-core (3.239.1) + aws-eventstream (~> 1, >= 1.3.0) + aws-partitions (~> 1, >= 1.992.0) + aws-sigv4 (~> 1.9) + base64 + bigdecimal + jmespath (~> 1, >= 1.6.1) + logger + aws-sdk-kms (1.118.0) + aws-sdk-core (~> 3, >= 3.239.1) + aws-sigv4 (~> 1.5) + aws-sdk-s3 (1.205.0) + aws-sdk-core (~> 3, >= 3.234.0) + aws-sdk-kms (~> 1) + aws-sigv4 (~> 1.5) + aws-sigv4 (1.12.1) + aws-eventstream (~> 1, >= 1.0.2) + base64 (0.3.0) + bcrypt (3.1.20) + bcrypt_pbkdf (1.1.1) + benchmark (0.5.0) + bigdecimal (3.3.1) + bindex (0.8.1) + bootsnap (1.19.0) + msgpack (~> 1.2) + brakeman (7.1.1) + racc + builder (3.3.0) + bundler-audit (0.9.2) + bundler (>= 1.2.0, < 3) + thor (~> 1.0) + capybara (3.40.0) + addressable + matrix + mini_mime (>= 0.1.3) + nokogiri (~> 1.11) + rack (>= 1.6.0) + rack-test (>= 0.6.3) + regexp_parser (>= 1.5, < 3.0) + xpath (~> 3.2) + childprocess (5.1.0) + logger (~> 1.5) + chunky_png (1.4.0) + concurrent-ruby (1.3.5) + connection_pool (2.5.5) + crack (1.0.1) + bigdecimal + rexml + crass (1.0.6) + date (3.5.0) + debug (1.11.0) + irb (~> 1.10) + reline (>= 0.3.8) + dotenv (3.1.8) + drb (2.2.3) + dry-initializer (3.2.0) + ed25519 (1.4.0) + erb (6.0.0) + erubi (1.13.1) + et-orbi (1.4.0) + tzinfo + faker (3.5.2) + i18n (>= 1.8.11, < 2) + ffi (1.17.2-aarch64-linux-gnu) + ffi (1.17.2-aarch64-linux-musl) + ffi (1.17.2-arm-linux-gnu) + ffi (1.17.2-arm-linux-musl) + ffi (1.17.2-x86_64-linux-gnu) + ffi (1.17.2-x86_64-linux-musl) + fugit (1.12.1) + et-orbi (~> 1.4) + raabro (~> 1.4) + geared_pagination (1.2.0) + activesupport (>= 5.0) + addressable (>= 2.5.0) + globalid (1.3.0) + activesupport (>= 6.1) + hashdiff (1.2.1) + i18n (1.14.7) + concurrent-ruby (~> 1.0) + image_processing (1.14.0) + mini_magick (>= 4.9.5, < 6) + ruby-vips (>= 2.0.17, < 3) + importmap-rails (2.2.2) + actionpack (>= 6.0.0) + activesupport (>= 6.0.0) + railties (>= 6.0.0) + io-console (0.8.1) + irb (1.15.3) + pp (>= 0.6.0) + rdoc (>= 4.0.0) + reline (>= 0.4.2) + jbuilder (2.14.1) + actionview (>= 7.0.0) + activesupport (>= 7.0.0) + jmespath (1.6.2) + json (2.16.0) + jwt (3.1.2) + base64 + kamal (2.9.0) + activesupport (>= 7.0) + base64 (~> 0.2) + bcrypt_pbkdf (~> 1.0) + concurrent-ruby (~> 1.2) + dotenv (~> 3.1) + ed25519 (~> 1.4) + net-ssh (~> 7.3) + sshkit (>= 1.23.0, < 2.0) + thor (~> 1.3) + zeitwerk (>= 2.6.18, < 3.0) + language_server-protocol (3.17.0.5) + launchy (3.1.1) + addressable (~> 2.8) + childprocess (~> 5.0) + logger (~> 1.6) + letter_opener (1.10.0) + launchy (>= 2.2, < 4) + lexxy (0.1.20.beta) + rails (>= 8.0.2) + lint_roller (1.1.0) + logger (1.7.0) + loofah (2.24.1) + crass (~> 1.0.2) + nokogiri (>= 1.12.0) + mail (2.9.0) + logger + mini_mime (>= 0.1.1) + net-imap + net-pop + net-smtp + marcel (1.1.0) + matrix (0.4.3) + mini_magick (5.3.1) + logger + mini_mime (1.1.5) + minitest (5.26.2) + mission_control-jobs (1.1.0) + actioncable (>= 7.1) + actionpack (>= 7.1) + activejob (>= 7.1) + activerecord (>= 7.1) + importmap-rails (>= 1.2.1) + irb (~> 1.13) + railties (>= 7.1) + stimulus-rails + turbo-rails + mittens (0.3.0) + mocha (2.8.2) + ruby2_keywords (>= 0.0.5) + msgpack (1.8.0) + net-http-persistent (4.0.6) + connection_pool (~> 2.2, >= 2.2.4) + net-imap (0.5.12) + date + net-protocol + net-pop (0.1.2) + net-protocol + net-protocol (0.2.2) + timeout + net-scp (4.1.0) + net-ssh (>= 2.6.5, < 8.0.0) + net-sftp (4.0.0) + net-ssh (>= 5.0.0, < 8.0.0) + net-smtp (0.5.1) + net-protocol + net-ssh (7.3.0) + nio4r (2.7.5) + nokogiri (1.18.10-aarch64-linux-gnu) + racc (~> 1.4) + nokogiri (1.18.10-aarch64-linux-musl) + racc (~> 1.4) + nokogiri (1.18.10-arm-linux-gnu) + racc (~> 1.4) + nokogiri (1.18.10-arm-linux-musl) + racc (~> 1.4) + nokogiri (1.18.10-x86_64-linux-gnu) + racc (~> 1.4) + nokogiri (1.18.10-x86_64-linux-musl) + racc (~> 1.4) + openssl (3.3.2) + ostruct (0.6.3) + parallel (1.27.0) + parser (3.3.10.0) + ast (~> 2.4.1) + racc + platform_agent (1.0.1) + activesupport (>= 5.2.0) + useragent (~> 0.16.3) + pp (0.6.3) + prettyprint + prettyprint (0.2.0) + prism (1.6.0) + prometheus-client-mmap (1.3.0) + base64 + bigdecimal + logger + rb_sys (~> 0.9.117) + prometheus-client-mmap (1.3.0-aarch64-linux-gnu) + base64 + bigdecimal + logger + rb_sys (~> 0.9.117) + prometheus-client-mmap (1.3.0-aarch64-linux-musl) + base64 + bigdecimal + logger + rb_sys (~> 0.9.117) + prometheus-client-mmap (1.3.0-x86_64-linux-gnu) + base64 + bigdecimal + logger + rb_sys (~> 0.9.117) + prometheus-client-mmap (1.3.0-x86_64-linux-musl) + base64 + bigdecimal + logger + rb_sys (~> 0.9.117) + propshaft (1.3.1) + actionpack (>= 7.0.0) + activesupport (>= 7.0.0) + rack + psych (5.2.6) + date + stringio + public_suffix (6.0.2) + puma (7.1.0) + nio4r (~> 2.0) + raabro (1.4.0) + racc (1.8.1) + rack (3.2.4) + rack-mini-profiler (4.0.1) + rack (>= 1.2.0) + rack-session (2.1.1) + base64 (>= 0.1.0) + rack (>= 3.0.0) + rack-test (2.2.0) + rack (>= 1.3) + rackup (2.2.1) + rack (>= 3) + rails-dom-testing (2.3.0) + activesupport (>= 5.0.0) + minitest + nokogiri (>= 1.6) + rails-html-sanitizer (1.6.2) + loofah (~> 2.21) + nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) + rainbow (3.1.1) + rake (13.3.1) + rake-compiler-dock (1.9.1) + rb_sys (0.9.117) + rake-compiler-dock (= 1.9.1) + rdoc (6.15.1) + erb + psych (>= 4.0.0) + tsort + redcarpet (3.6.1) + regexp_parser (2.11.3) + reline (0.6.3) + io-console (~> 0.5) + rexml (3.4.4) + rouge (4.6.1) + rqrcode (3.1.0) + chunky_png (~> 1.0) + rqrcode_core (~> 2.0) + rqrcode_core (2.0.0) + rubocop (1.81.7) + json (~> 2.3) + language_server-protocol (~> 3.17.0.2) + lint_roller (~> 1.1.0) + parallel (~> 1.10) + parser (>= 3.3.0.2) + rainbow (>= 2.2.2, < 4.0) + regexp_parser (>= 2.9.3, < 3.0) + rubocop-ast (>= 1.47.1, < 2.0) + ruby-progressbar (~> 1.7) + unicode-display_width (>= 2.4.0, < 4.0) + rubocop-ast (1.48.0) + parser (>= 3.3.7.2) + prism (~> 1.4) + rubocop-performance (1.26.1) + lint_roller (~> 1.1) + rubocop (>= 1.75.0, < 2.0) + rubocop-ast (>= 1.47.1, < 2.0) + rubocop-rails (2.34.0) + activesupport (>= 4.2.0) + lint_roller (~> 1.1) + rack (>= 1.1) + rubocop (>= 1.75.0, < 2.0) + rubocop-ast (>= 1.44.0, < 2.0) + rubocop-rails-omakase (1.1.0) + rubocop (>= 1.72) + rubocop-performance (>= 1.24) + rubocop-rails (>= 2.30) + ruby-next-core (1.1.2) + ruby-progressbar (1.13.0) + ruby-vips (2.2.5) + ffi (~> 1.12) + logger + ruby2_keywords (0.0.5) + rubyzip (3.2.2) + securerandom (0.4.1) + selenium-webdriver (4.38.0) + base64 (~> 0.2) + logger (~> 1.4) + rexml (~> 3.2, >= 3.2.5) + rubyzip (>= 1.2.2, < 4.0) + websocket (~> 1.0) + sentry-rails (6.1.1) + railties (>= 5.2.0) + sentry-ruby (~> 6.1.1) + sentry-ruby (6.1.1) + bigdecimal + concurrent-ruby (~> 1.0, >= 1.0.2) + sniffer (0.5.0) + anyway_config (>= 1.0) + dry-initializer (~> 3) + solid_cable (3.0.12) + actioncable (>= 7.2) + activejob (>= 7.2) + activerecord (>= 7.2) + railties (>= 7.2) + solid_cache (1.0.10) + activejob (>= 7.2) + activerecord (>= 7.2) + railties (>= 7.2) + solid_queue (1.2.4) + activejob (>= 7.1) + activerecord (>= 7.1) + concurrent-ruby (>= 1.3.1) + fugit (~> 1.11) + railties (>= 7.1) + thor (>= 1.3.1) + sqlite3 (2.8.0-aarch64-linux-gnu) + sqlite3 (2.8.0-aarch64-linux-musl) + sqlite3 (2.8.0-arm-linux-gnu) + sqlite3 (2.8.0-arm-linux-musl) + sqlite3 (2.8.0-x86_64-linux-gnu) + sqlite3 (2.8.0-x86_64-linux-musl) + sshkit (1.24.0) + base64 + logger + net-scp (>= 1.1.2) + net-sftp (>= 2.1.2) + net-ssh (>= 2.8.0) + ostruct + stimulus-rails (1.3.4) + railties (>= 6.0.0) + stringio (3.1.8) + thor (1.4.0) + thruster (0.1.16) + thruster (0.1.16-aarch64-linux) + thruster (0.1.16-x86_64-linux) + timeout (0.4.4) + trilogy (2.9.0) + tsort (0.2.0) + turbo-rails (2.0.20) + actionpack (>= 7.1.0) + railties (>= 7.1.0) + tzinfo (2.0.6) + concurrent-ruby (~> 1.0) + unicode-display_width (3.2.0) + unicode-emoji (~> 4.1) + unicode-emoji (4.1.0) + uri (1.1.1) + vcr (6.3.1) + base64 + web-console (4.2.1) + actionview (>= 6.0.0) + activemodel (>= 6.0.0) + bindex (>= 0.4.0) + railties (>= 6.0.0) + web-push (3.0.2) + jwt (~> 3.0) + openssl (~> 3.0) + webmock (3.26.1) + addressable (>= 2.8.0) + crack (>= 0.3.2) + hashdiff (>= 0.4.0, < 2.0.0) + webrick (1.9.1) + websocket (1.2.11) + websocket-driver (0.8.0) + base64 + websocket-extensions (>= 0.1.0) + websocket-extensions (0.1.5) + xpath (3.2.0) + nokogiri (~> 1.8) + yabeda (0.14.0) + anyway_config (>= 1.0, < 3) + concurrent-ruby + dry-initializer + yabeda-actioncable (0.2.2) + actioncable (>= 7.2) + activesupport (>= 7.2) + railties (>= 7.2) + yabeda (~> 0.8) + yabeda-gc (0.4.0) + yabeda (~> 0.6) + yabeda-http_requests (0.3.0) + anyway_config (>= 1.3, < 3.0) + sniffer + yabeda + yabeda-prometheus-mmap (0.4.0) + prometheus-client-mmap + yabeda (~> 0.10) + yabeda-puma-plugin (0.9.0) + json + puma + yabeda (~> 0.5) + yabeda-rails (0.10.0) + activesupport + anyway_config (>= 1.3, < 3) + railties + yabeda (~> 0.8) + zeitwerk (2.7.3) + +PLATFORMS + aarch64-linux + aarch64-linux-gnu + aarch64-linux-musl + arm-linux-gnu + arm-linux-musl + x86_64-linux-gnu + x86_64-linux-musl + +DEPENDENCIES + activeresource + autotuner + aws-sdk-s3 + bcrypt (~> 3.1.7) + benchmark + bootsnap + brakeman + bundler-audit + capybara + debug + faker + fizzy-saas! + geared_pagination (~> 1.2) + image_processing (~> 1.14) + importmap-rails + jbuilder + kamal + letter_opener + lexxy + mission_control-jobs + mittens + mocha + net-http-persistent + platform_agent + prometheus-client-mmap (~> 1.3) + propshaft + puma (>= 5.0) + queenbee! + rack-mini-profiler + rails! + rails_structured_logging! + redcarpet + rouge + rqrcode + rubocop-rails-omakase + selenium-webdriver + sentry-rails + sentry-ruby + solid_cable (>= 3.0) + solid_cache (~> 1.0) + solid_queue (~> 1.2) + sqlite3 (>= 2.0) + stimulus-rails + thruster + trilogy (~> 2.9) + turbo-rails + useragent! + vcr + web-console + web-push + webmock + webrick + yabeda + yabeda-actioncable + yabeda-activejob! + yabeda-gc + yabeda-http_requests + yabeda-prometheus-mmap + yabeda-puma-plugin + yabeda-rails + +BUNDLED WITH + 2.7.2 diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000000..4839325c29 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,10 @@ +# O'Saasy License Agreement + +Copyright © 2025, 37signals LLC. + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +1. The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. +2. No licensee or downstream recipient may use the Software (including any modified or derivative versions) to directly compete with the original Licensor by offering it to third parties as a hosted, managed, or Software-as-a-Service (SaaS) product or cloud service where the primary value of the service is the functionality of the Software itself. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md index ff8b48cb96..cb0c199905 100644 --- a/README.md +++ b/README.md @@ -1,22 +1,29 @@ # Fizzy -## Setting up for development +This is the source code of [Fizzy](https://fizzy.do/), the Kanban tracking tool for issues and ideas by [37signals](https://37signals.com). -First get everything installed and configured with: +## Development - bin/setup +### Setting up -If you'd like to load fixtures: +First, get everything installed and configured with: - bin/rails db:fixtures:load +```sh +bin/setup +bin/setup --reset # Reset the database and seed it +``` And then run the development server: - bin/dev +```sh +bin/dev +``` -You'll be able to access the app in development at http://fizzy.localhost:3006 +You'll be able to access the app in development at http://fizzy.localhost:3006. -## Running tests +To login, enter `david@37signals.com` and grab the verification code from the browser console to sign in. + +### Running tests For fast feedback loops, unit tests can be run with: @@ -26,47 +33,53 @@ The full continuous integration tests can be run with: bin/ci -### Tests +### Database configuration -### Outbound Emails +Fizzy supports SQLite (default, recommended for most scenarios) and MySQL. You can switch adapters with the `DATABASE_ADAPTER` environment variable. + +```sh +DATABASE_ADAPTER=mysql bin/rails +DATABASE_ADAPTER=mysql bin/test +bin/ci # Runs tests against both SQLite and MySQL +``` -#### Development +### Outbound Emails You can view email previews at http://fizzy.localhost:3006/rails/mailers. -You can enable or disable [`letter_opener`](https://github.com/ryanb/letter_opener) to -open sent emails automatically with: +You can enable or disable [`letter_opener`](https://github.com/ryanb/letter_opener) to open sent emails automatically with: bin/rails dev:email Under the hood, this will create or remove `tmp/email-dev.txt`. -## Environments - -Fizzy is deployed with Kamal. You'll need to have the 1Password CLI set up in order to access the secrets that are used when deploying. Provided you have that, it should be as simple as `bin/kamal deploy` to the correct environment. - -### Beta +## Deployment -Beta is primarily intended for testing product features. +We recommend [Kamal](https://kamal-deploy.org/) for deploying Fizzy. This project comes with a vanilla Rails template, you can find our production setup in [`fizzy-saas`](https://github.com/basecamp/fizzy-saas). -Beta tenant is: +### Web Push Notifications -- https://fizzy-beta.37signals.com +Fizzy uses VAPID (Voluntary Application Server Identification) keys to send browser push notifications. You'll need to generate a key pair and set these environment variables: -This environment uses local disk for Active Storage. +- `VAPID_PRIVATE_KEY` +- `VAPID_PUBLIC_KEY` +Generate them with the `web-push` gem: -### Staging +```ruby +vapid_key = WebPush.generate_key -Staging is primarily intended for testing infrastructure changes. +puts "VAPID_PRIVATE_KEY=#{vapid_key.private_key}" +puts "VAPID_PUBLIC_KEY=#{vapid_key.public_key}" +``` -- https://fizzy.37signals-staging.com/ +## SaaS gem -This environment uses a FlashBlade bucket for blob storage, and shares nothing with Production. We may periodically copy data here from production. +37signals bundles Fizzy with [`fizzy-saas`](https://github.com/basecamp/fizzy-saas), a companion gem that links Fizzy with our billing system and contains our production setup. +This gem depends on some private git repositories and it is not meant to be used by third parties. But we hope it can serve as inspiration for anyone wanting to run fizzy on their own infrastructure. -### Production +## License -- https://app.fizzy.do/ +Fizzy is released under the [O'Saasy License](LICENSE.md). -This environment uses a FlashBlade bucket for blob storage. diff --git a/STYLE.md b/STYLE.md index bf9dd3be3e..b31f49cab6 100644 --- a/STYLE.md +++ b/STYLE.md @@ -135,54 +135,51 @@ class SomeModule end ``` -## CRUD operations from controllers +## CRUD controllers -In general, we favor a vanilla Rails approach to CRUD operations. We create and update models from Rails controllers passing the parameters directly to the model constructor or update method. We do not use services or form objects to handle these operations. +We model web endpoints as CRUD operations on resources (REST). When an action doesn't map cleanly to a standard CRUD verb, we introduce a new resource rather than adding custom actions. -There are exceptional scenarios where we need to perform more complex operations, and we use form objects or higher-level service methods to handle them. We use the same pattern for both creations and updates. +```ruby +# Bad +resources :cards do + post :close + post :reopen +end -Related to this, we prefer to avoid [nested attributes](https://api.rubyonrails.org/classes/ActiveRecord/NestedAttributes/ClassMethods.html). If you find yourself wanting to use `accepts_nested_attributes_for`, that's a good smell that you might want to consider using a form object instead. +# Good +resources :cards do + resource :closure +end +``` + +## Controller and model interactions -As an example, you can check how we create and update messages in HEY's: `MessagesController`: +In general, we favor a [vanilla Rails](https://dev.37signals.com/vanilla-rails-is-plenty/) approach with thin controllers directly invoking a rich domain model. We don't use services or other artifacts to connect the two. + +Invoking plain Active Record operations is totally fine: ```ruby -class MessagesController < ApplicationController +class Cards::CommentsController < ApplicationController def create - @entry = Entry.enter \ - new_message, - on: new_topic, - status: :drafted, - address: entry_addressed_param, - scheduled_delivery_at: entry_scheduled_delivery_at_param, - scheduled_bubble_up_on: entry_scheduled_bubble_up_on_param - - respond_to_saved_entry @entry + @comment = @card.comments.create!(comment_params) end +end +``` - def update - previously_scheduled = @entry.scheduled_delivery - - @entry.revise \ - message_params, - status: :drafted, - is_delivery_imminent: !entry_status_param.drafted?, - address: entry_addressed_param, - scheduled_delivery_at: entry_scheduled_delivery_at_param, - scheduled_bubble_up_on: entry_scheduled_bubble_up_on_param +For more complex behavior, we prefer clear, intention-revealing model APIs that controllers call directly: - respond_to_saved_entry(@entry, previously_scheduled: previously_scheduled) +```ruby +class Cards::GoldnessesController < ApplicationController + def create + @card.gild end end +``` -class Entry < ApplicationRecord - def self.enter(*args, **kwargs) - Entry::Enter.new(*args, **kwargs).perform - end +When justified, it is fine to use services or form objects, but don't treat those as special artifacts: - def revise(*args, **kwargs) - Entry::Revise.new(self, *args, **kwargs).perform - end -end +```ruby +Signup.new(email_address: email_address).create_identity ``` ## Run async operations in jobs diff --git a/app/controllers/admin/stats_controller.rb b/app/controllers/admin/stats_controller.rb index 25d19ad312..92b49d3ce8 100644 --- a/app/controllers/admin/stats_controller.rb +++ b/app/controllers/admin/stats_controller.rb @@ -1,6 +1,4 @@ class Admin::StatsController < AdminController - disallow_account_scope - layout "public" def show diff --git a/app/controllers/admin_controller.rb b/app/controllers/admin_controller.rb index cc268a5675..0da7808129 100644 --- a/app/controllers/admin_controller.rb +++ b/app/controllers/admin_controller.rb @@ -1,3 +1,4 @@ class AdminController < ApplicationController + disallow_account_scope before_action :ensure_staff end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 89b94f079e..7f1cb65658 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -4,7 +4,6 @@ class ApplicationController < ActionController::Base include CurrentRequest, CurrentTimezone, SetPlatform include RequestForgeryProtection include TurboFlash, ViewTransitions - include Saas include RoutingHeaders etag { "v1" } diff --git a/app/controllers/concerns/authentication.rb b/app/controllers/concerns/authentication.rb index f4ad6a0131..23f82dbd29 100644 --- a/app/controllers/concerns/authentication.rb +++ b/app/controllers/concerns/authentication.rb @@ -81,7 +81,6 @@ def start_new_session_for(identity) end def set_current_session(session) - logger.struct " Authorized Identity##{session.identity.id}", authentication: { identity: { id: session.identity.id } } Current.session = session cookies.signed.permanent[:session_token] = { value: session.signed_id, httponly: true, same_site: :lax } end diff --git a/app/controllers/concerns/authorization.rb b/app/controllers/concerns/authorization.rb index 46769992e1..81bd0c781b 100644 --- a/app/controllers/concerns/authorization.rb +++ b/app/controllers/concerns/authorization.rb @@ -3,6 +3,7 @@ module Authorization included do before_action :ensure_can_access_account, if: -> { Current.account.present? && authenticated? } + before_action :ensure_only_staff_can_access_non_production_remote_environments, if: :authenticated? end class_methods do @@ -29,6 +30,10 @@ def ensure_can_access_account redirect_to session_menu_url(script_name: nil) if Current.user.blank? || !Current.user.active? end + def ensure_only_staff_can_access_non_production_remote_environments + head :forbidden unless Rails.env.local? || Rails.env.production? || Current.identity.staff? + end + def redirect_existing_user redirect_to root_path if Current.user end diff --git a/app/controllers/concerns/request_forgery_protection.rb b/app/controllers/concerns/request_forgery_protection.rb index f11ef8ad05..ddc47065fe 100644 --- a/app/controllers/concerns/request_forgery_protection.rb +++ b/app/controllers/concerns/request_forgery_protection.rb @@ -42,7 +42,7 @@ def report_on_forgery_protection_results(origin:, token:, sec_fetch_site:) Rails.logger.info "CSRF protection check: " + info.map { it.join(" ") }.join(", ") - if (origin && token) != sec_fetch_site + if Fizzy.saas? && (origin && token) != sec_fetch_site Sentry.capture_message "CSRF protection mismatch", level: :info, extra: { info: info } end end diff --git a/app/controllers/concerns/saas.rb b/app/controllers/concerns/saas.rb deleted file mode 100644 index fd4ca84915..0000000000 --- a/app/controllers/concerns/saas.rb +++ /dev/null @@ -1,11 +0,0 @@ -module Saas - extend ActiveSupport::Concern - - included do - helper_method :signups_allowed? - end - - def signups_allowed? - defined?(Signup) && defined?(saas) - end -end diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index 8c623b6ef0..aa765919da 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -19,13 +19,9 @@ def new def create if identity = Identity.find_by_email_address(email_address) - magic_link = identity.send_magic_link - flash[:magic_link_code] = magic_link&.code if Rails.env.development? - redirect_to session_magic_link_path - elsif signups_allowed? - Signup.new(email_address: email_address).create_identity - session[:return_to_after_authenticating] = saas.new_signup_completion_path - redirect_to session_magic_link_path + handle_existing_user(identity) + elsif + handle_new_signup end end @@ -38,4 +34,16 @@ def destroy def email_address params.expect(:email_address) end + + def handle_existing_user(identity) + magic_link = identity.send_magic_link + flash[:magic_link_code] = magic_link&.code if Rails.env.development? + redirect_to session_magic_link_path + end + + def handle_new_signup + Signup.new(email_address: email_address).create_identity + session[:return_to_after_authenticating] = new_signup_completion_path + redirect_to session_magic_link_path + end end diff --git a/gems/fizzy-saas/app/controllers/signup/completions_controller.rb b/app/controllers/signup/completions_controller.rb similarity index 100% rename from gems/fizzy-saas/app/controllers/signup/completions_controller.rb rename to app/controllers/signup/completions_controller.rb diff --git a/app/models/account.rb b/app/models/account.rb index 29912092c8..887ce708dd 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -11,6 +11,7 @@ class Account < ApplicationRecord has_many_attached :uploads + before_create :assign_external_account_id after_create :create_join_code validates :name, presence: true @@ -35,4 +36,9 @@ def account def system_user users.where(role: :system).first! end + + private + def assign_external_account_id + self.external_account_id ||= ExternalIdSequence.next + end end diff --git a/app/models/account/external_id_sequence.rb b/app/models/account/external_id_sequence.rb new file mode 100644 index 0000000000..8d9cecf6de --- /dev/null +++ b/app/models/account/external_id_sequence.rb @@ -0,0 +1,26 @@ +# Provides sequential IDs for +external_account_id+ when creating accounts without one. +class Account::ExternalIdSequence < ApplicationRecord + class << self + def next + with_lock do |sequence| + sequence.increment!(:value).value + end + end + + def value + first&.value + end + + private + def with_lock + transaction do + sequence = lock.first_or_create!(value: initial_value) + yield sequence + end + end + + def initial_value + Account.maximum(:external_account_id) || 0 + end + end +end diff --git a/gems/fizzy-saas/app/models/signup.rb b/app/models/signup.rb similarity index 58% rename from gems/fizzy-saas/app/models/signup.rb rename to app/models/signup.rb index ca76f5b081..230aafd0b5 100644 --- a/gems/fizzy-saas/app/models/signup.rb +++ b/app/models/signup.rb @@ -4,20 +4,13 @@ class Signup include ActiveModel::Validations attr_accessor :full_name, :email_address, :identity - attr_reader :queenbee_account, :account, :user + attr_reader :account, :user with_options on: :completion do validates_presence_of :full_name, :identity end def initialize(...) - @full_name = nil - @email_address = nil - @account = nil - @user = nil - @queenbee_account = nil - @identity = nil - super @email_address = @identity.email_address if @identity @@ -31,13 +24,12 @@ def create_identity def complete if valid?(:completion) begin - create_queenbee_account + @tenant = create_tenant create_account - true rescue => error destroy_account - destroy_queenbee_account + handle_account_creation_error(error) errors.add(:base, "Something went wrong, and we couldn't create your account. Please give it another try.") Rails.error.report(error, severity: :error) @@ -52,22 +44,20 @@ def complete end private - def create_queenbee_account - @account_name = AccountNameGenerator.new(identity: identity, name: full_name).generate - @queenbee_account = Queenbee::Remote::Account.create!(queenbee_account_attributes) - @tenant = queenbee_account.id.to_s + # Override to customize the handling of external accounts associated to the account. + def create_tenant + nil end - def destroy_queenbee_account - @queenbee_account&.cancel - @queenbee_account = nil + # Override to inject custom handling for account creation errors + def handle_account_creation_error(error) end def create_account @account = Account.create_with_admin_user( account: { external_account_id: @tenant, - name: @account_name + name: generate_account_name }, owner: { name: full_name, @@ -78,6 +68,11 @@ def create_account @account.setup_customer_template end + def generate_account_name + AccountNameGenerator.new(identity: identity, name: full_name).generate + end + + def destroy_account @account&.destroy! @@ -86,28 +81,6 @@ def destroy_account @tenant = nil end - def queenbee_account_attributes - {}.tap do |attributes| - attributes[:product_name] = "fizzy" - attributes[:name] = @account_name - attributes[:owner_name] = full_name - attributes[:owner_email] = email_address - - attributes[:trial] = true - attributes[:subscription] = subscription_attributes - attributes[:remote_request] = request_attributes - - # # TODO: Terms of Service - # attributes[:terms_of_service] = true - - # We've confirmed the email - attributes[:auto_allow] = true - - # Tell Queenbee to skip the request to create a local account. We've created it ourselves. - attributes[:skip_remote] = true - end - end - def subscription_attributes subscription = FreeV1Subscription diff --git a/gems/fizzy-saas/app/models/signup/account_name_generator.rb b/app/models/signup/account_name_generator.rb similarity index 100% rename from gems/fizzy-saas/app/models/signup/account_name_generator.rb rename to app/models/signup/account_name_generator.rb diff --git a/app/views/sessions/menus/show.html.erb b/app/views/sessions/menus/show.html.erb index 26b3d25bd7..8a36261bf2 100644 --- a/app/views/sessions/menus/show.html.erb +++ b/app/views/sessions/menus/show.html.erb @@ -24,13 +24,11 @@

You don’t have any Fizzy accounts.

<% end %> - <% if signups_allowed? %> -
- <%= link_to saas.new_signup_completion_path, class: "btn btn--plain txt-link center txt-small", data: { turbo_prefetch: false } do %> - Sign up for a new Fizzy account - <% end %> -
- <% end %> +
+ <%= link_to new_signup_completion_path, class: "btn btn--plain txt-link center txt-small", data: { turbo_prefetch: false } do %> + Sign up for a new Fizzy account + <% end %> +
<% end %> diff --git a/gems/fizzy-saas/app/views/signup/completions/new.html.erb b/app/views/signup/completions/new.html.erb similarity index 79% rename from gems/fizzy-saas/app/views/signup/completions/new.html.erb rename to app/views/signup/completions/new.html.erb index aee8f45869..971c39b1df 100644 --- a/gems/fizzy-saas/app/views/signup/completions/new.html.erb +++ b/app/views/signup/completions/new.html.erb @@ -3,10 +3,10 @@
">

<%= @page_title %>

- <%= form_with model: @signup, url: saas.signup_completion_path, scope: "signup", class: "flex flex-column gap", data: { controller: "form" } do |form| %> + <%= form_with model: @signup, url: signup_completion_path, scope: "signup", class: "flex flex-column gap", data: { controller: "form" } do |form| %> <%= form.text_field :full_name, class: "input txt-large", autocomplete: "name", placeholder: "Enter your full name…", autofocus: true, required: true %> -

You’re one step away. Just enter your name to get your own Fizzy account.

+

You're one step away. Just enter your name to get your own Fizzy account.

<% if @signup.errors.any? %>
diff --git a/gems/fizzy-saas/app/views/signup/new.html.erb b/app/views/signup/new.html.erb similarity index 84% rename from gems/fizzy-saas/app/views/signup/new.html.erb rename to app/views/signup/new.html.erb index 93166dd2fe..14da6328ed 100644 --- a/gems/fizzy-saas/app/views/signup/new.html.erb +++ b/app/views/signup/new.html.erb @@ -3,7 +3,7 @@
">

Sign up

- <%= form_with model: @signup, url: saas.signup_path, scope: "signup", class: "flex flex-column gap", data: { turbo: false, controller: "form" } do |form| %> + <%= form_with model: @signup, url: signup_path, scope: "signup", class: "flex flex-column gap", data: { turbo: false, controller: "form" } do |form| %> <%= form.email_field :email_address, class: "input", autocomplete: "username", placeholder: "Email address", required: true %> <% if @signup.errors.any? %> diff --git a/bin/kamal b/bin/kamal index cbe59b95ed..862f9036a6 100755 --- a/bin/kamal +++ b/bin/kamal @@ -22,6 +22,23 @@ Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this end require "rubygems" + +require_relative "../lib/fizzy" +Fizzy.configure_bundle + require "bundler/setup" +if Fizzy.saas? + gem_path = Gem::Specification.find_by_name("fizzy-saas").gem_dir + deploy_config = File.join(gem_path, "config", "deploy.yml") + + unless ARGV.include?("-c") || ARGV.include?("--config-file") + if ARGV.empty? || ARGV.first.start_with?("-") + ARGV.unshift("-c", deploy_config) + else + ARGV.insert(1, "-c", deploy_config) + end + end +end + load Gem.bin_path("kamal", "kamal") diff --git a/bin/rails b/bin/rails index ab22dd7709..8f0b2d8b3f 100755 --- a/bin/rails +++ b/bin/rails @@ -1,10 +1,11 @@ #!/usr/bin/env ruby -require_relative "../lib/bootstrap" -if !Bootstrap.oss_config? - # default from rails/test_unit/runner.rb but adding the saas gem test files - ENV["DEFAULT_TEST"] = "{gems/fizzy-saas/,}test/**/*_test.rb" - ENV["DEFAULT_TEST_EXCLUDE"] = "{gems/fizzy-saas/,}test/{system,dummy,fixtures}/**/*_test.rb" -end -APP_PATH = File.expand_path('../config/application', __dir__) -require_relative '../config/boot' -require 'rails/commands' +APP_PATH = File.expand_path("../config/application", __dir__) + +require_relative "../lib/fizzy" +Fizzy.configure_bundle + +require_relative "../config/boot" + +require "rails/commands" + +Fizzy::Saas.append_test_paths if Fizzy.saas? && Rails.env.test? diff --git a/bin/setup b/bin/setup index f410b2f2c4..5a3eda8820 100755 --- a/bin/setup +++ b/bin/setup @@ -8,6 +8,14 @@ app_root="$( )" export PATH="$app_root/bin:$PATH" +if [ -e tmp/saas.txt ]; then + export SAAS=1 +fi + +if [ -n "$SAAS" ]; then + export BUNDLE_GEMFILE="Gemfile.saas" +fi + # Install gum if needed if ! command -v gum &>/dev/null; then echo @@ -50,6 +58,24 @@ needs_seeding() { fi } + +setup_database() { + local adapter="$1" + local reset="$2" + local label="${adapter:+ ($adapter)}" + local env_cmd="${adapter:+env DATABASE_ADAPTER=$adapter}" + + if [ "$reset" = "true" ]; then + step "Resetting the database$label" $env_cmd rails db:reset + else + step "Preparing the database$label" $env_cmd rails db:prepare + + if needs_seeding; then + step "Seeding the database$label" $env_cmd rails db:seed + fi + fi +} + echo gum style --foreground 153 " ˚ ∘ ∘ ˚ " gum style --foreground 111 --bold " ∘˚˳°∘° 𝒻𝒾𝓏𝓏𝓎 °∘°˳˚∘ " @@ -71,22 +97,38 @@ step "Set up gh-signoff" bash -c "gh extension install basecamp/gh-signoff || gh bundle config set --local auto_install true step "Installing RubyGems" bundle install -if [ -e tmp/minio-dev.txt ]; then - step "Starting Docker services" bash -c "[ -d ~/Work/basecamp/docker-dev ] && git -C ~/Work/basecamp/docker-dev pull || gh repo clone basecamp/docker-dev ~/Work/basecamp/docker-dev && ~/Work/basecamp/docker-dev/setup minio" - - step "Configuring MinIO" bin/minio-setup +if [ -n "$SAAS" ]; then + saas_setup=$(bundle show fizzy-saas)/bin/setup + source "$saas_setup" +else + if ! nc -z localhost 3306 2>/dev/null; then + if docker ps -aq -f name=fizzy-mysql | grep -q .; then + step "Starting MySQL" docker start fizzy-mysql + else + step "Setting up MySQL" bash -c ' + docker pull mysql:8.4 + docker run -d \ + --name fizzy-mysql \ + -e MYSQL_ALLOW_EMPTY_PASSWORD=yes \ + -p 3306:3306 \ + mysql:8.4 + echo "MySQL is starting… (it may take a few seconds)" + ' + fi + fi fi -step "Starting mysql" bash -c "[ -d ~/Work/basecamp/docker-dev ] && git -C ~/Work/basecamp/docker-dev pull || gh repo clone basecamp/docker-dev ~/Work/basecamp/docker-dev && ~/Work/basecamp/docker-dev/setup mysql80" +reset_flag="" +[[ $* == *--reset* ]] && reset_flag="true" -if [[ $* == *--reset* ]]; then - step "Resetting the database" rails db:reset +if [ -n "$SAAS" ]; then + for adapter in saas sqlite; do + setup_database "$adapter" "$reset_flag" + done else - step "Preparing the database" rails db:prepare - - if needs_seeding; then - step "Seeding the database" rails db:seed - fi + for adapter in sqlite mysql; do + setup_database "$adapter" "$reset_flag" + done fi step "Cleaning up logs and tempfiles" rails log:clear tmp:clear diff --git a/config/application.rb b/config/application.rb index 27177a5cb9..ced9d93054 100644 --- a/config/application.rb +++ b/config/application.rb @@ -1,5 +1,7 @@ require_relative "boot" require "rails/all" +require_relative "../lib/fizzy" + Bundler.require(*Rails.groups) module Fizzy @@ -22,5 +24,7 @@ class Application < Rails::Application config.generators do |g| g.orm :active_record, primary_key_type: :uuid end + + config.mission_control.jobs.http_basic_auth_enabled = false end end diff --git a/config/ci.rb b/config/ci.rb index f6cbbf953d..b206cad0df 100644 --- a/config/ci.rb +++ b/config/ci.rb @@ -1,18 +1,27 @@ # Run using bin/ci +require_relative "../lib/fizzy" + +OSS_ENV = "SAAS=false BUNDLE_GEMFILE=Gemfile" +SAAS_ENV = "SAAS=true BUNDLE_GEMFILE=Gemfile.saas" + CI.run do step "Setup", "bin/setup --skip-server" step "Style: Ruby", "bin/rubocop" - step "Security: Gem audit", "bin/bundler-audit check --update" + step "Security: Gem audit", "bin/bundler-audit check --update" step "Security: Importmap audit", "bin/importmap audit" - step "Security: Brakeman audit", "bin/brakeman --quiet --no-pager --exit-on-warn --exit-on-error" - step "Security: Gitleaks audit", "bin/gitleaks-audit" + step "Security: Brakeman audit", "bin/brakeman --quiet --no-pager --exit-on-warn --exit-on-error" + step "Security: Gitleaks audit", "bin/gitleaks-audit" - step "Tests: Rails: SaaS config", "bin/rails test" - step "Tests: Rails: OSS config", "OSS_CONFIG=1 bin/rails test" - step "Tests: System", "bin/rails test:system" + if Fizzy.saas? + step "Tests: SaaS", "#{SAAS_ENV} bin/rails test:all" + step "Tests: SQLite", "#{OSS_ENV} DATABASE_ADAPTER=sqlite bin/rails test:all" + else + step "Tests: MySQL", "#{OSS_ENV} DATABASE_ADAPTER=mysql bin/rails test:all" + step "Tests: SQLite", "#{OSS_ENV} DATABASE_ADAPTER=sqlite bin/rails test:all" + end if success? step "Signoff: All systems go. Ready for merge and deploy.", "gh signoff" diff --git a/config/credentials/beta.yml.enc b/config/credentials/beta.yml.enc index 0fa2c3d57b..a4316dc2e2 100644 --- a/config/credentials/beta.yml.enc +++ b/config/credentials/beta.yml.enc @@ -1 +1 @@ -6Ciuxv75imDwiIXYs8lVxsDClQq+76RRr9gvVSxbDNpQhhuETHtMZZPiK2yfHtnFwwm/CrkpWhIRy+ZUt+9bQjnz3J3dMwPmsHdmKTnxUeiKuj+7k5QPKDHc0G1kfpxRANsB10WEcjt5IeoNcNxTrXYbja3NX2OhHYOFWMW/jLpGIXRPnBw0+JgQElKQI42vm1APix/9DRwqrM7TmgjXjJw4q4C7WeSgc4Y7+sHF5T2wJrtkdmExgD7X+9BTI7FOHkpwmQIYEWe34P1/BSFgFcy6tgwI/5U74QUcsj/dEgtKADukLQK9eMPLbfbJS4X7WUD1bDrOnmix5WQy0kdERq4NGSzdvumOhOY4LKYqS3otnPuTIZObbduLxPY9mgd8VFXfev8EjCrrVcVLVNUvtqTz+ri4fYAaHGTRQXttV2oOKtAik8JAggSrY3W5zZGWPEknDD704jyzi4ZRhJ2goMf+vvNZeH6pcQeWlLrIBxrfghGDrhDiHYFqaub2+2qeHhhkenOGtsttHiG4lpfcgmEa2Sr6ADdcxcmOi9qMt17LFmvplw2jR13aIUfglw4K2mzaikCUkDbgKRPqjKpbwkKbdJOaGFx69Oa4ZtiXX+ecKSgGhxhyIOyg7c8NtGeUNAZbjVEzdG8jqj+WBPLzozvpE+3y/dHgQ8MXegf5fsoov/2cpO2HK9tAepgjPyfAU7Rl5nzLrbGPsvgZ1/VRWr/Li6baZBVKrdZnWrjqLLfgqT28deVIHfwrlspifKq2wtm0eIyNGDs3Sm9wIYc1H2QMBe0zVly9rpQBnD8f901CzFQ4PN9ZIn1ZxdoNsdPdc/26mNHwa1UvVyPxgfLMBhlofRRpLNXLh1HOEzBYxuoHtJ3f86G0UwcxkhG7dTxbm8ujQ8RmYfHfgOEBJET/P7rILflXEtRsKPfkNjZqHIt0AeEjpnA5ZmSYZrpsjAQ8FnblgCj32/QfKBsW7MIvJ18w2AOap+z7v9RyJTB9Pq93KdR232xXg7U9sX/qUMI3j6+Gx0URrTFlWCDXpq8QYK+BN/vqUfe39rSxKh/qy+JCbk1B/Ftd52Cn+gsG/fHnVaKyCK04UZSrYllqg82gMrl6A39v8PAiD/x1VFxiZzSm/oi8rBAVI9elOzxXzSr0lDUr+w0iXmiJUzSzLkgtd6nf74cjEmitZQqRRgo9/YANJQkh2HiABHuevHsBgcebw+FOM4VO9y4lljY/Ex98echppRjZs4DgJYK2/R4uQBOMBTlxuqdwuSGodYSlmI5kW43bZGTTA5KJa7w1DRmpVhQfoReWg5EDxifXh2sUkFqTMOejOauQm5fjN9RxnXA1c7b7mxpvBgkepUEI+o+AcbzNlAXG/orcm9zlt2sC8YoDR1T01blYEhoAM6IwqXCXt6ODxKEgMGQ/S3EhDWQQ989lR2gmoMjjbkwxU75pT/SDWJi/A2kFBC+e952Al+iybFxaUM9VpNfPrLB2iNIOO5McQJCH/EjD8+/ZfhhSin5fAl0GFFZyyk0Shd0+TWkuJk0mtAf3ev1sohzGFieCcB5Shj4oNcqWTvPisb4DCoO2AOnEo3adNPA3zCDRvSWv+rwsXEzqW2w2Wav4cAV8VqyKj/6NYIbvwbjwD3ojnpB41705+OekhFubgTLLCnk3+tbNgaLlBcnUawuHW4akZezmUCUoOYoLRzGEQwxCGMG6Hn9lNDw46Uyv7VsggpaNVORTeYGX1Om25QcDM+OBoYQynD24gg==--UIlvvCt/ukIZVxPZ--en6RIr54eZ9nuOvN+prX4w== \ No newline at end of file +JwcXSv6qlqJvbzfHX3v0TB1WvqgI8CEqVRuR4m760EUTJDe6k6NeX1b41P33Zr341GT/LSyxnlxU5psfx4TxvSZhnJRv4e/YuAr04hFIhWnKnjvJtkwDQRgaZH689O4vgGVnQIEPln/DGag=--Q4Xa+Y5602cPApVn--s3PqHeC1cQ/7Fg2dTJ1w4w== \ No newline at end of file diff --git a/config/credentials/development.yml.enc b/config/credentials/development.yml.enc index 2f25a36ca3..fadb19009f 100644 --- a/config/credentials/development.yml.enc +++ b/config/credentials/development.yml.enc @@ -1 +1 @@ -lbv40Nq+bDCBc6DGVp3Q+CBuKLAt26v1vkKhOuUBJ/hPr07N98H13tejmM+C1GHF8DUJsTTsmSLYdYgelng0GDo9qhSpC/k+thZ1+Ne5pmZh00sH58PwItUNPBtLluqlpolRfNrJGMK3kjprWyTCbznAfGV6BZoT7Hbv12ZIri2bKsuTvsPZiIYLLAe2a2CWrNWjs4RezZbGWzbJpIyj+RIj3GKhGNCWNFhq79HyeRChaZNxioMaAgWBfAYKvXvkR2MuKEaspJM3lXnpnyxqVKWEWz4orhTCvlqbZchESDLF7hAuIkzXCXEC2EHpeEbzD0F52K/mLzT+kmi8dI+eLVimPJn0PZslccsHNAnXXHwvLvLOPFwzPz9N0jjGihu6yZKE1vl5r1dzo9mVWVTnsI5Z/g5SAmOtY+DB+pzssQdXSLFQZ/l2JcWvgYA9wCQyQbyGLEeirGe/QeCQciTkE+Z/bj7b+20rBgsRQIyHxNdzWDsmi4onJ75lIz6PA+1X4AIkwnAqUVzuBDwB+VxdwbzRZZeURMLBVzs76Ta/qSemIpuZ782cyt6MRmeYuAT6RMccnG/zwdvPHtpJ6C8fqKyOu+XkU2a49Yx/D6l/uvVtwiOayHTCwn2QmGYDlnniCBDJCzp0y0Y1M4d3XxAdV61XEL6rISMuM0mjdRZL3PYTpR8mrrflnYA4kFrLUnA+hEmcOq8stHSL1RYe83PI4dj6l1/hzQ6up2cGNBk+to7FXpQOhkuYV2+9xu+4PnJnl261G1D/v8zoWV+0OIn4ySC1o/t8BkvoYvFzDJkDIGYwNV6tTdhgitlZ3ljohwTj2HIclr0pZVwptQDJpe/ZYbpxq13uN0Xi6QdAWHJcUWzt3jZnX7N07nY34NW7DhM/D5SSDMkZbjPIPBS4t+n/lShh--MofA9sfR6Z6UuGQ2--jtVAHhYAxdvdXOJ/vb06Ug== \ No newline at end of file +vWcWTHpq+ngYMhjXPNuAdQoX0IWy18O+vXn8Ny//U81BzG0tZIw30hOJ2MYx9yqgG9TMo8skPDE8fYGjHjuNCmp0CAgay6tcJzDtue+8l7nosbVBhQDkdW4GGAs8zRzVevQFNVXiYggQBeY=--vOZV11N8QeP6HBLz--enCNPwzwui/5QILC4bceBg== \ No newline at end of file diff --git a/config/credentials/production.yml.enc b/config/credentials/production.yml.enc index 6dd50b314a..254ca92de3 100644 --- a/config/credentials/production.yml.enc +++ b/config/credentials/production.yml.enc @@ -1 +1 @@ -z0tKJ9f/hCNXOi7b6lZlfzmFm8HAGNafiluMTcRlKmFxTm5ygs6fk0N0JNnJyrxsv6huye3/mptfrqkMOHRQcWzmZQyWDmayLxtmghRkpXmX92QZ8DiSs6jgWFX9zKuMPEW9eFpuLjstF1khPskg70SYVXBc3cTRJbyx5JGhnv1c3qOBfN4PKGYvTWsxacoQ356LYfu3haezmiS0yPzREo1rhFdwNyveV8uZ1QX1ZNZFp+WN5RXeiM632kL4R73KhwkbGBSHj5z9xxl0v4BzLrGM+Jf3VsJFmhYXS10mN+XOsNELCHUaM1wQOqMNHegBfupTzwur6Q3R154XP7rrBLquFT3wkAlkrI8ox+NRJgnGQ/yCc3PM8zhxgdrLzmr9s35eByKRCMsuIkF0WSfcBeWpCfNQaeucw10fuKirlI+G5JsX6HaRfAea3dUvVsuKCeutKn2/YPmw7Qodat2G9sZgUhBOYRzDcmzWaUyudciAMXRzNfoTZqngwFXDvfcAlmq4FrnucnfAsMB/TtqrZzV/td4nPfQLpxZCCl7v3hIggQUJl7gmdR335J6wJ4r8i0eTQjTw+j/Xm8wF1AEHpwxz2DOg8BjAI+/F8Lgqc+YDQCEYINYn+bwy5eMUv7H3aLDGrF3ZKnsEwWj62Q5LtHF/Fi7ZIOWgQyk4d5lLc4+T6hx4qe9iqu29LlduIxwJRG1dWPgNxuikPM2hcl6dQP8ebP9dNpdeeMCGylzR/kZjro7mAawxgIKEdOvWE6NJs8xGkjV2JjqWu7ufiIpk+jurUBlO5mIJuZhVvRumpMgcxVwqXWjJRt5BTVT/qcuLdaPcYY9hUt3TQrqjSGIt5bSFjcCFEfY15DxXsIVe84Oi4KYQNmuM4h2ub1zfYQYYH4UaGg7bLbb2OeQIIkkTwXXAR6o3BKJWtskOD3GTdamk3gKhudhm+BOgbOavoiE4hpvv/4ajjl8OGseGOAjC4bj8hpNnhezjQFRe2pK1515hi8iyYQ2tEScGy4wmsAbSaoA54+tHki0yeLUF8oJ9GTr9UCwnz8nUhQmfrS4VJO6Q/J4MXRCon+mAt3cyHaC6z3RISREw0a4aO3T2PGvUBVDEP0kfhDJmZXM5Of0RdFX27pwaQUPo3hqzQ+BmyenGqUonC1nPd/npojmRWXan9v5iMRKRx8qEBfhm+WQPEQuJqi5szm8pla6gUtNKaL6P4zhJOnIZiKxpYSlnezlMWNof/xcOb5BqYeEYQJ9FTvIxGrmN7OxQFlfk21z+n1OJI82TKIaKB/Oo+bsTtta8BuXONJUtsRSP9HcqMlMnwq2We4jn8JLFf/9O0s+iQtB6um7EAcHkE48jBHkMVzJVWB4/9Z8xe4DIwrg9BZH6UNL/pc/K5/dOeW8QoOPceBWmHP9BGoWFVqmNmEwPeNmnQZ5H/zxGPD94oG5YfiUMfMrOtIiAwS3Qqbmr+nAa5eNe4Ij7/i5nlV5gJ8y3NSNQvjico4iqBU1WAEHL6bHV--JcYezF+/1oGWkoz5--lepA3Pi+iuzJTgNRR4ax8Q== \ No newline at end of file +ZZgOiLwd51WsQRpTwuCdPmDqmErel30sv5ZO+hOk7PrdqSgKuA7SCM3BaaM01gusiWCAq2760SmjTrrhaEBPkydR2dDERxPNcWe+zvqYklczYY8Qs4DqqLq3L8O89REO5GFvlbRulDQlO0w=--LC7VCBgZNnfp05RJ--svimv/2SjVLEJfVrBxC87Q== \ No newline at end of file diff --git a/config/credentials/staging.yml.enc b/config/credentials/staging.yml.enc index 663b370e1b..3cbcf5de64 100644 --- a/config/credentials/staging.yml.enc +++ b/config/credentials/staging.yml.enc @@ -1 +1 @@ -tTZW5futjROLhOOU4sULsj8f7nCQ73Z7Pmh/3HT2Y1hxhz7MxKOREq2ZjwEFPhL2/c/zim/C9PzgNql6MDjtUzYmd26Q+2HzFSS2JpFBqU7DnEgL6mWC7x5s9feTG61ilOhmlLIwZRgNvugwRAUTlCv2KZ8R5iaUviPcwf6fl4dOhM7pW2Fx8MhuJQBSbjkoqisyniIzdCvgsE2s4qIkxBz+qrbjVNhmyTvl/jdttjTcIYlubCOlELZdbT4zNsitcs/8xBTBlxsR4Vt9rBcwr+EPrhQfpvGVKnSleeiatc6EOi0kAniqbjwJFkcpJ95PrU3yhV8TP7RAzHpOIhYHSwDcrQS2ltP9z7fVP2CRicpWEIh7iFR79JtIsp1cYH6pbx08ZAnKKeu240SKkKA/wZH5BzyOrVaTHwQ5q18U8hzWon6loMiQMV9+/jRZtb8rOTHU4IpUi/oM7shUZoHxccBq68ZxpclLEDZs7CLNytXbRq9oaCqyg5tW5qW+Lws+4iRlmR43hTq2SHJ+eMjq4N8WxAdecDGbgPdNFaoY6Ymw9YEwsYiWzHx7fRC0EUYckbUPEccT6b8IuYTJg/QeQQ8HVenboM9U/9Cd6SODR4Vdyn6fGkFmc7mfPke01naCUEconH7RL0EB38yz/MMqW355PhsaZqsv7PlCE7KJIxkejp3gwiBwP8petq4OazxZY2MJahYLwvMQwFt35qu9RJVAMiAMN9DLvRQu7w+oNA0g3OSGOwnIzHtjKMRks3W4YNEgwMGrq3YzI11EmORB/1F3t+UDDN8GOxxGgBmIdVrFHwSazrw5igqv/kTFsrzxSHW1ja3/U27JVYOSGDBMZ5TlAH7CSFD6SgbDHreauN/O0zuesTH9QnkUARhOHw75dfTEvacjy6uIcO9GmrRConJGWzJdZj5joBROhCtoflcn9jfce3W6YJfzHgveUxPnRU1bhaNULGTW/lA+cclDmm1jL2AhpDYzk1hq6/I5we0fY9/VNMgFnm+c/qrkjrXChFNCT4w+EU+n7TaXJ5ocWdCMTyM2/RSnm1dBX8wGoygryOYEnHM+6GDeX64vfGSnWZ2yfpsKrAzn7zVFWAY6Ah2YEXm+pQdUPB4CYwvg6A1liaCvF71P2p9SZgX79YEwGvsODMzrBavxVMIlVa92jtOZ23yoOjfR8gWleqeyoAOCN2JaM4rJfPrzZrs5LKDfYtgpR4Jg36LjD1UKWp3Kab+kHOpxzkPOgq6v8px3Rk3xaGADwCPnE+4qSTFByYaHCdx6FNeQQit3jSEq4h6WNeVkrKnZ6ybCnd7LwBKiu9uQURB6jar7t4IqM6kYThN9J7q/oxp3HS5+TkNT+1Org13CuNL9iF3M6n+R/U/vJCCRUMbM9+O8cYHEt5MJ875qBD1NRxj4UbVCcv5rDXhAFq+GuJqJTLN2+NPWVJ2QhZk1bhmwxIsZJ5vlWTMUXoOZOxUJrLqPK+v8h2x6G8J8Cc0WpzsHAl1B8peN7TxXJgcOyaVkHbPr2CYao/z7mG7D+5SIHcqcbNUB09Nh4iMVbQa1SEpTnWPqum1EwXkmH8QjUcj+IMsXcNWTVkaAfAsme50fMS8XGLiOU9AGfV5QcryaJQ6w250ewQ==--IKMr2eXgh2p/VO+z--2sCRrfBLg9TEjHH93qFmcw== \ No newline at end of file +eqnqN1H7D3SMoWZAS61s1U+Km/tbbbb+1ofw+B+7AlD+Y6aNLBUuIbm6qSZPZ2L4bEl49RG9H3tLKtaawGWbqCCePygCZMw2wMlCt0Y08hKR1IAesRsJdMLRJUUn6vvTywXIm6leO/OdQlU=--H1k6MN/bYQAb3Kre--9jfEjn3753Brzy/63b56SA== \ No newline at end of file diff --git a/config/credentials/test.yml.enc b/config/credentials/test.yml.enc index 5374a7f13b..6726323b69 100644 --- a/config/credentials/test.yml.enc +++ b/config/credentials/test.yml.enc @@ -1 +1 @@ -OCWdPtq1nxi6erX3KVmolRjjazqRbSQ102v9t/MUwHy2Af7CztQt+S0N2fGBNnGb0QTNGbzAlETRVUo7trc7+pjO8w7lyuHS/eKO84wuuPh7qRkrZfwxF2R0D2jmcaCa2S2Hfs8Ws7xFN2j9xCdjWJKd4khP4OygyLFK9wah9fWW+QvMl1evgnSA8Vtko1Eh+/bBQC4Lb3/tlsjeqfv/N229oPywD6uM6XfRsSS0/tuCp3w1MPhs2lb1ZfTwt4y5Dsk+r0YoUUr0CDPUAPSohmDJn5++8NiipIt7CQoGe72M4dp7WZ4eQAiprID8QIWAQ2I8RdvwbJdjFRKjcjgeB7vYiLUTRgmav8q/7QDk+JxWCU5oQxDmVex63CJ+sZ8NI94EnyppqHqihyLDzViwbBHkOmWj3WsgPOQHNjwhX+LwMeJ8UkWjAJlA7q59EqEDvPPN7MAgpunPuRXL6YXsS6UBpUmpof6fwMyhHbnFpRgTx0QyUFW3Ta7cs2JWCST2PXI5lGQKJBKIhcLB60bO1NtJYtX3xlheywnbNUzvsBTQO3j+ezPxcy07REvW1NfXQInpAgOudsZ4ScW0mblwiO5v/rLGJN5cMUNWAg==--8NVEpFCH347bJZoD--6fw4f/Fad/N8XQH6oTwpQg== \ No newline at end of file +kd4RhBwzDS97pb8kht6DOzdg/NFgRQj0sAezzV7q7wiqk4pAMT2v5pZTJoq3pz0f8URB7nXK1KeHI6ceqN/9GKWMqpwyUyeM8A7LbCK5ffBiaJZH2YFMtKEnX0topl87sEDNlMawY0OT6ySi4KhLkrQMyEGqJx3XXmS1U4aGfF2P7i3GTGOjPpLtPntzSVT62cLU7/GSfDvXdqiW/WDRNvCpQJDqw+J1DdnlZtq+A6lo+B42o2clGHOVw09MY9INHuhfcscQfR035exSkRZ3wsvqDay3fez+D3xvDXOreyRrlCUpDfAxuXHPgavnYuPF73Xhg5Ov48B3EW9RLNHH5Nl57M/laxPvhkSa73IK6VPL9BSv9osW--MmZ9291ZbCvv0tqX--yLAI3tXJEW5rJoA/2KMk+Q== \ No newline at end of file diff --git a/config/database.mysql.yml b/config/database.mysql.yml new file mode 100644 index 0000000000..e3188b1acb --- /dev/null +++ b/config/database.mysql.yml @@ -0,0 +1,20 @@ +default: &default + adapter: trilogy + host: <%= ENV.fetch("MYSQL_HOST", "127.0.0.1") %> + port: <%= ENV.fetch("MYSQL_PORT", "3306") %> + username: <%= ENV.fetch("MYSQL_USER", "root") %> + password: <%= ENV["MYSQL_PASSWORD"] %> + pool: 50 + timeout: 5000 + +development: + <<: *default + database: fizzy_development + +test: + <<: *default + database: fizzy_test + +production: + <<: *default + database: fizzy_production diff --git a/config/database.sqlite.yml b/config/database.sqlite.yml new file mode 100644 index 0000000000..7cc7404c7f --- /dev/null +++ b/config/database.sqlite.yml @@ -0,0 +1,22 @@ +default: &default + adapter: sqlite3 + pool: 5 + timeout: 5000 + +development: + primary: + <<: *default + database: storage/development.sqlite3 + schema_dump: schema_sqlite.rb + +test: + primary: + <<: *default + database: storage/test.sqlite3 + schema_dump: schema_sqlite.rb + +production: + primary: + <<: *default + database: storage/production.sqlite3 + schema_dump: schema_sqlite.rb diff --git a/config/database.yml b/config/database.yml index 913ba7cba0..0229af3e39 100644 --- a/config/database.yml +++ b/config/database.yml @@ -1,126 +1,11 @@ <% - database_adapter = ENV.fetch("DATABASE_ADAPTER", "mysql") - use_sqlite = database_adapter == "sqlite" + require_relative "../lib/fizzy" - if ENV["MIGRATE"].present? - mysql_app_user_key = "MYSQL_ALTER_USER" - mysql_app_password_key = "MYSQL_ALTER_PASSWORD" - max_execution_time_ms = 0 # No limit + config_path = if Fizzy.saas? + gem_path = Gem::Specification.find_by_name("fizzy-saas").gem_dir + File.join(gem_path, "config", "database.yml") else - mysql_app_user_key = "MYSQL_APP_USER" - mysql_app_password_key = "MYSQL_APP_PASSWORD" - max_execution_time_ms = 5_000 + File.join(__dir__, "database.#{Fizzy.db_adapter}.yml") end - - mysql_app_user = ENV[mysql_app_user_key] - mysql_app_password = ENV[mysql_app_password_key] %> - -default: &default - <% if use_sqlite %> - adapter: sqlite3 - pool: 5 - timeout: 5000 - <% else %> - adapter: trilogy - host: <%= ENV.fetch "FIZZY_DB_HOST", "127.0.0.1" %> - port: <%= ENV.fetch "FIZZY_DB_PORT", 3306 %> - pool: 50 - timeout: 5000 - variables: - transaction_isolation: READ-COMMITTED - max_execution_time: <%= max_execution_time_ms %> - <% end %> - -development: - <% if use_sqlite %> - primary: - <<: *default - database: storage/development.sqlite3 - schema_dump: schema_sqlite.rb - <% else %> - primary: - <<: *default - database: fizzy_development - port: <%= ENV.fetch "FIZZY_DB_PORT", 33380 %> - replica: - <<: *default - database: fizzy_development - port: <%= ENV.fetch "FIZZY_DB_PORT", 33380 %> - replica: true - cable: - <<: *default - database: development_cable - port: <%= ENV.fetch "FIZZY_DB_PORT", 33380 %> - migrations_paths: db/cable_migrate - cache: - <<: *default - database: development_cache - port: <%= ENV.fetch "FIZZY_DB_PORT", 33380 %> - migrations_paths: db/cache_migrate - queue: - <<: *default - database: development_queue - port: <%= ENV.fetch "FIZZY_DB_PORT", 33380 %> - migrations_paths: db/queue_migrate - <% end %> - -# Warning: The database defined as "test" will be erased and -# re-generated from your development database when you run "rake". -# Do not set this db to the same as development or production. -test: - <% if use_sqlite %> - primary: - <<: *default - database: storage/test.sqlite3 - schema_dump: schema_sqlite.rb - <% else %> - primary: - <<: *default - database: fizzy_test - port: <%= ENV.fetch "FIZZY_DB_PORT", 33380 %> - replica: - <<: *default - database: fizzy_test - port: <%= ENV.fetch "FIZZY_DB_PORT", 33380 %> - replica: true - <% end %> - -production: &production - primary: - <<: *default - database: fizzy_production - host: <%= ENV["MYSQL_DATABASE_HOST"] %> - username: <%= mysql_app_user %> - password: <%= mysql_app_password %> - replica: - <<: *default - database: fizzy_production - host: <%= ENV["MYSQL_DATABASE_REPLICA_HOST"] %> - username: <%= ENV["MYSQL_READONLY_USER"] %> - password: <%= ENV["MYSQL_READONLY_PASSWORD"] %> - replica: true - cable: - <<: *default - database: fizzy_solidcable_production - host: <%= ENV["MYSQL_SOLID_CABLE_HOST"] %> - username: <%= mysql_app_user %> - password: <%= mysql_app_password %> - migrations_paths: db/cable_migrate - queue: - <<: *default - database: fizzy_solidqueue_production - host: <%= ENV["MYSQL_SOLID_QUEUE_HOST"] %> - username: <%= mysql_app_user %> - password: <%= mysql_app_password %> - migrations_paths: db/queue_migrate - cache: - <<: *default - database: fizzy_solidcache_production - host: <%= ENV["MYSQL_SOLID_CACHE_HOST"] %> - username: <%= mysql_app_user %> - password: <%= mysql_app_password %> - migrations_paths: db/cache_migrate - -beta: *production -staging: *production +<%= ERB.new(File.read(config_path)).result %> diff --git a/config/deploy.beta.yml b/config/deploy.beta.yml deleted file mode 100644 index 4c4c323f61..0000000000 --- a/config/deploy.beta.yml +++ /dev/null @@ -1,53 +0,0 @@ -servers: - web: - hosts: - - fizzy-beta-app-01.sc-chi-int.37signals.com: sc_chi - - fizzy-beta-app-101.df-iad-int.37signals.com: df_iad - labels: - otel_scrape_enabled: true - -# we don't run the jobs role in beta -allow_empty_roles: true - -proxy: - ssl: false - -ssh: - user: app - -env: - clear: - RAILS_ENV: beta - MYSQL_DATABASE_HOST: fizzy-mysql-primary - MYSQL_DATABASE_REPLICA_HOST: fizzy-mysql-replica - MYSQL_SOLID_CABLE_HOST: fizzy-mysql-primary - MYSQL_SOLID_QUEUE_HOST: fizzy-mysql-primary - MYSQL_SOLID_CACHE_HOST: fizzy-beta-solidcache-db-101 - secret: - - RAILS_MASTER_KEY - - MYSQL_ALTER_PASSWORD - - MYSQL_ALTER_USER - - MYSQL_APP_PASSWORD - - MYSQL_APP_USER - - MYSQL_READONLY_PASSWORD - - MYSQL_READONLY_USER - tags: - sc_chi: {} - df_iad: - PRIMARY_DATACENTER: true - df_ams: {} - -accessories: - load-balancer: - image: basecamp/kamal-proxy:lb - host: fizzy-beta-lb-01.sc-chi-int.37signals.com - labels: - otel_role: load-balancer - otel_service: fizzy-load-balancer - otel_scrape_enabled: true - options: - publish: - - 80:80 - - 443:443 - volumes: - - load-balancer:/home/kamal-proxy/.config/kamal-proxy diff --git a/config/deploy.production.yml b/config/deploy.production.yml deleted file mode 100644 index 495227c1f8..0000000000 --- a/config/deploy.production.yml +++ /dev/null @@ -1,72 +0,0 @@ -servers: - web: - hosts: - - fizzy-app-01.sc-chi-int.37signals.com: sc_chi - - fizzy-app-02.sc-chi-int.37signals.com: sc_chi - - fizzy-app-101.df-iad-int.37signals.com: df_iad - - fizzy-app-102.df-iad-int.37signals.com: df_iad - - fizzy-app-401.df-ams-int.37signals.com: df_ams - - fizzy-app-402.df-ams-int.37signals.com: df_ams - labels: - otel_scrape_enabled: true - jobs: - hosts: - - fizzy-jobs-101.df-iad-int.37signals.com: df_iad - - fizzy-jobs-102.df-iad-int.37signals.com: df_iad - labels: - otel_scrape_enabled: true - -proxy: - ssl: false - -ssh: - user: app - -env: - clear: - RAILS_ENV: production - MYSQL_DATABASE_HOST: fizzy-mysql-primary - MYSQL_DATABASE_REPLICA_HOST: fizzy-mysql-replica - MYSQL_SOLID_CABLE_HOST: fizzy-mysql-primary - MYSQL_SOLID_QUEUE_HOST: fizzy-mysql-primary - secret: - - RAILS_MASTER_KEY - - MYSQL_ALTER_PASSWORD - - MYSQL_ALTER_USER - - MYSQL_APP_PASSWORD - - MYSQL_APP_USER - - MYSQL_READONLY_PASSWORD - - MYSQL_READONLY_USER - tags: - sc_chi: - MYSQL_SOLID_CACHE_HOST: fizzy-solidcache-db-01.sc-chi-int.37signals.com - df_iad: - MYSQL_SOLID_CACHE_HOST: fizzy-solidcache-db-101.df-iad-int.37signals.com - PRIMARY_DATACENTER: true - df_ams: - MYSQL_SOLID_CACHE_HOST: fizzy-solidcache-db-401.df-ams-int.37signals.com - - -accessories: - load-balancer: - image: basecamp/kamal-proxy:lb - hosts: - - fizzy-lb-101.df-iad-int.37signals.com - - fizzy-lb-102.df-iad-int.37signals.com - - fizzy-lb-01.sc-chi-int.37signals.com - - fizzy-lb-02.sc-chi-int.37signals.com - - fizzy-lb-401.df-ams-int.37signals.com - - fizzy-lb-402.df-ams-int.37signals.com - labels: - otel_role: load-balancer - otel_service: fizzy-load-balancer - otel_scrape_enabled: true - options: - publish: - - 80:80 - - 443:443 - # NFS mount for certificates - # See https://3.basecamp.com/2914079/buckets/37331921/todos/9180260061 - mount: type=volume,src=certificates,dst=/certificates,volume-driver=local,volume-opt=type=nfs,volume-opt=device=:/fizzy-production-certificates,"volume-opt=o=addr=purestorage.sc-chi-int.37signals.com,nfsvers=3,rw,noatime,nconnect=8,soft,timeo=30,retrans=2" - volumes: - - load-balancer:/home/kamal-proxy/.config/kamal-proxy diff --git a/config/deploy.staging.yml b/config/deploy.staging.yml deleted file mode 100644 index 3163c7a7bf..0000000000 --- a/config/deploy.staging.yml +++ /dev/null @@ -1,69 +0,0 @@ -servers: - web: - hosts: - - fizzy-staging-app-101.df-iad-int.37signals.com: df_iad - - fizzy-staging-app-102.df-iad-int.37signals.com: df_iad - - fizzy-staging-app-01.sc-chi-int.37signals.com: sc_chi - - fizzy-staging-app-02.sc-chi-int.37signals.com: sc_chi - - fizzy-staging-app-401.df-ams-int.37signals.com: df_ams - - fizzy-staging-app-402.df-ams-int.37signals.com: df_ams - labels: - otel_scrape_enabled: true - jobs: - hosts: - - fizzy-staging-jobs-101.df-iad-int.37signals.com: df_iad - - fizzy-staging-jobs-102.df-iad-int.37signals.com: df_iad - labels: - otel_scrape_enabled: true - -proxy: - ssl: false - -ssh: - user: app - -env: - clear: - RAILS_ENV: staging - MYSQL_DATABASE_HOST: fizzy-staging-mysql-primary - MYSQL_DATABASE_REPLICA_HOST: fizzy-staging-mysql-replica - MYSQL_SOLID_CABLE_HOST: fizzy-staging-mysql-primary - MYSQL_SOLID_QUEUE_HOST: fizzy-staging-mysql-primary - secret: - - RAILS_MASTER_KEY - - MYSQL_ALTER_PASSWORD - - MYSQL_ALTER_USER - - MYSQL_APP_PASSWORD - - MYSQL_APP_USER - - MYSQL_READONLY_PASSWORD - - MYSQL_READONLY_USER - tags: - sc_chi: - MYSQL_SOLID_CACHE_HOST: fizzy-staging-solidcache-db-01.sc-chi-int.37signals.com - df_iad: - MYSQL_SOLID_CACHE_HOST: fizzy-staging-solidcache-db-101.df-iad-int.37signals.com - PRIMARY_DATACENTER: true - df_ams: - MYSQL_SOLID_CACHE_HOST: fizzy-staging-solidcache-db-401.df-ams-int.37signals.com - - -accessories: - load-balancer: - image: basecamp/kamal-proxy:lb - hosts: - - fizzy-staging-lb-01.sc-chi-int.37signals.com - - fizzy-staging-lb-101.df-iad-int.37signals.com - - fizzy-staging-lb-401.df-ams-int.37signals.com - labels: - otel_role: load-balancer - otel_service: fizzy-load-balancer - otel_scrape_enabled: true - options: - publish: - - 80:80 - - 443:443 - # NFS mount for certificates - # See https://3.basecamp.com/2914079/buckets/37331921/todos/9180260061 - mount: type=volume,src=certificates,dst=/certificates,volume-driver=local,volume-opt=type=nfs,volume-opt=device=:/fizzy-staging-certificates,"volume-opt=o=addr=purestorage.sc-chi-int.37signals.com,nfsvers=3,rw,noatime,nconnect=8,soft,timeo=30,retrans=2" - volumes: - - load-balancer:/home/kamal-proxy/.config/kamal-proxy diff --git a/config/deploy.yml b/config/deploy.yml index b565e5a504..be1e3c21e6 100644 --- a/config/deploy.yml +++ b/config/deploy.yml @@ -1,34 +1,120 @@ +# Name of your application. Used to uniquely configure containers. service: fizzy -image: basecamp/fizzy -asset_path: /rails/public/assets -servers: - jobs: - cmd: bin/jobs +# Name of the container image (use your-user/app-name on external registries). +image: fizzy -volumes: - - fizzy:/rails/storage +# Deploy to these servers. +servers: + web: + - 192.168.0.1 + # job: + # hosts: + # - 192.168.0.1 + # cmd: bin/jobs -proxy: - ssl: true +# Enable SSL auto certification via Let's Encrypt and allow for multiple apps on a single web server. +# If used with Cloudflare, set encryption mode in SSL/TLS setting to "Full" to enable CF-to-app encryption. +# +# Using an SSL proxy like this requires turning on config.assume_ssl and config.force_ssl in production.rb! +# +# Don't use this when deploying to multiple web servers (then you have to terminate SSL at your load balancer). +# +# proxy: +# ssl: true +# host: app.example.com +# Where you keep your container images. registry: - server: registry.37signals.com - username: robot$harbor-bot - password: - - BASECAMP_REGISTRY_PASSWORD + # Alternatives: hub.docker.com / registry.digitalocean.com / ghcr.io / ... + server: localhost:5555 -builder: - arch: amd64 - secrets: - - GITHUB_TOKEN - remote: ssh://app@docker-builder-102 - local: <%= ENV.fetch("KAMAL_BUILDER_LOCAL", "true") %> + # Needed for authenticated registries. + # username: your-user + # Always use an access token rather than real password when possible. + # password: + # - KAMAL_REGISTRY_PASSWORD + +# Inject ENV variables into containers (secrets come from .kamal/secrets). env: secret: - RAILS_MASTER_KEY + clear: + # Run the Solid Queue Supervisor inside the web server's Puma process to do jobs. + # When you start using multiple servers, you should split out job processing to a dedicated machine. + SOLID_QUEUE_IN_PUMA: true + + # Set number of processes dedicated to Solid Queue (default: 1) + # JOB_CONCURRENCY: 3 + + # Set number of cores available to the application on each server (default: 1). + # WEB_CONCURRENCY: 2 + + # Match this to any external database server to configure Active Record correctly + # Use fizzy-db for a db accessory server on same machine via local kamal docker network. + # DB_HOST: 192.168.0.2 + + # Log everything from Rails + # RAILS_LOG_LEVEL: debug +# Aliases are triggered with "bin/kamal ". You can overwrite arguments on invocation: +# "bin/kamal logs -r job" will tail logs from the first server in the job section. aliases: - console: app exec -i --reuse "bin/rails console" - ssh: app exec -i --reuse /bin/bash + console: app exec --interactive --reuse "bin/rails console" + shell: app exec --interactive --reuse "bash" + logs: app logs -f + dbc: app exec --interactive --reuse "bin/rails dbconsole --include-password" + +# Use a persistent storage volume for sqlite database files and local Active Storage files. +# Recommended to change this to a mounted volume path that is backed up off server. +volumes: + - "fizzy_storage:/rails/storage" + +# Bridge fingerprinted assets, like JS and CSS, between versions to avoid +# hitting 404 on in-flight requests. Combines all files from new and old +# version inside the asset_path. +asset_path: /rails/public/assets + + +# Configure the image builder. +builder: + arch: amd64 + + # # Build image via remote server (useful for faster amd64 builds on arm64 computers) + # remote: ssh://docker@docker-builder-server + # + # # Pass arguments and secrets to the Docker build process + # args: + # RUBY_VERSION: ruby-3.4.7 + # secrets: + # - GITHUB_TOKEN + # - RAILS_MASTER_KEY + +# Use a different ssh user than root +# ssh: +# user: app + +# Use accessory services (secrets come from .kamal/secrets). +# accessories: +# db: +# image: mysql:8.0 +# host: 192.168.0.2 +# # Change to 3306 to expose port to the world instead of just local network. +# port: "127.0.0.1:3306:3306" +# env: +# clear: +# MYSQL_ROOT_HOST: '%' +# secret: +# - MYSQL_ROOT_PASSWORD +# files: +# - config/mysql/production.cnf:/etc/mysql/my.cnf +# - db/production.sql:/docker-entrypoint-initdb.d/setup.sql +# directories: +# - data:/var/lib/mysql +# redis: +# image: valkey/valkey:8 +# host: 192.168.0.2 +# port: 6379 +# directories: +# - data:/data diff --git a/config/environments/development.rb b/config/environments/development.rb index e3833ed313..8b426d2c69 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -93,8 +93,4 @@ config.active_job.queue_adapter = :solid_queue config.solid_queue.connects_to = { database: { writing: :queue, reading: :queue } } end - - if Rails.root.join("tmp/structured-logging.txt").exist? - config.structured_logging.logger = ActiveSupport::Logger.new("log/structured-development.log") - end end diff --git a/config/environments/production.rb b/config/environments/production.rb index 31b7d27706..f1d7862790 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -32,9 +32,6 @@ # config.action_dispatch.x_sendfile_header = "X-Sendfile" # for Apache # config.action_dispatch.x_sendfile_header = "X-Accel-Redirect" # for NGINX - # Store uploaded files on the local file system (see config/storage.yml for options). - config.active_storage.service = :purestorage - # Mount Action Cable outside main process or domain. # config.action_cable.mount_path = nil # config.action_cable.url = "wss://example.com/cable" @@ -55,9 +52,6 @@ # Suppress unstructured log lines config.log_level = :fatal - # Structured JSON logging - config.structured_logging.logger = ActiveSupport::Logger.new(STDOUT) - # Prepend all log lines with the following tags. config.log_tags = [ :request_id ] diff --git a/config/initializers/authentication.rb b/config/initializers/authentication.rb deleted file mode 100644 index 5556a9661e..0000000000 --- a/config/initializers/authentication.rb +++ /dev/null @@ -1,2 +0,0 @@ -require "bootstrap" -Rails.application.config.x.oss_config = Bootstrap.oss_config? diff --git a/config/initializers/autotuner.rb b/config/initializers/autotuner.rb index 84b4de8de0..ec8572b117 100644 --- a/config/initializers/autotuner.rb +++ b/config/initializers/autotuner.rb @@ -7,18 +7,8 @@ # or somewhere else! Autotuner.reporter = proc do |report| Rails.logger.info "GCAUTOTUNE: #{report}" - Sentry.capture_message "Autotuner suggestion", level: :info, extra: { report: report.to_s } -end -# # This (optional) callback is called to provide metrics that can give you -# # insights about the performance of your app. It's recommended to send this -# # data to your observability service (e.g. Datadog, Prometheus, New Relic, etc). -# # Use a metric type that would allow you to calculate the average and percentiles. -# # On Datadog this would be the distribution type. On Prometheus this would be -# # the histogram type. -# Autotuner.metrics_reporter = proc do |metrics| -# # stats is a hash of metric name (string) to integer value. -# metrics.each do |key, val| -# StatsD.gauge(key, val) -# end -# end + if Fizzy.saas? + Sentry.capture_message "Autotuner suggestion", level: :info, extra: { report: report.to_s } + end +end diff --git a/config/initializers/mission_control.rb b/config/initializers/mission_control.rb index c2098204e2..2fe3c3ed01 100644 --- a/config/initializers/mission_control.rb +++ b/config/initializers/mission_control.rb @@ -1,5 +1,3 @@ Rails.application.config.before_initialize do - # We don't want normal tenanted authentication on mission control. - # Note that we're using HTTP basic auth configured via credentials. - MissionControl::Jobs.base_controller_class = "ActionController::Base" + MissionControl::Jobs.base_controller_class = "AdminController" end diff --git a/config/initializers/sentry.rb b/config/initializers/sentry.rb deleted file mode 100644 index 8d35e2670e..0000000000 --- a/config/initializers/sentry.rb +++ /dev/null @@ -1,9 +0,0 @@ -if !Rails.env.local? && ENV["SKIP_TELEMETRY"].blank? - Sentry.init do |config| - config.dsn = "https://ca338fb1fe6f677d6aeec2336a86f0ee@o33603.ingest.us.sentry.io/4508093839179776" - config.breadcrumbs_logger = %i[ active_support_logger http_logger ] - config.send_default_pii = false - config.release = ENV["GIT_REVISION"] - config.excluded_exceptions += [ "ActiveRecord::ConcurrentMigrationError" ] - end -end diff --git a/config/initializers/solid_queue.rb b/config/initializers/solid_queue.rb deleted file mode 100644 index 5fe44c0261..0000000000 --- a/config/initializers/solid_queue.rb +++ /dev/null @@ -1,5 +0,0 @@ -SolidQueue.on_start do - Process.warmup - - Yabeda::Prometheus::Exporter.start_metrics_server! -end diff --git a/config/initializers/tenanting/logging.rb b/config/initializers/tenanting/logging.rb deleted file mode 100644 index a8b834f310..0000000000 --- a/config/initializers/tenanting/logging.rb +++ /dev/null @@ -1,7 +0,0 @@ -ActiveSupport.on_load(:action_controller_base) do - before_action do - if Current.account.present? - logger.try(:struct, account: { queenbee_id: Current.account.external_account_id }) - end - end -end diff --git a/config/initializers/vapid.rb b/config/initializers/vapid.rb index 2739f6ca16..8567180a12 100644 --- a/config/initializers/vapid.rb +++ b/config/initializers/vapid.rb @@ -1,4 +1,4 @@ Rails.application.configure do - config.x.vapid.private_key = ENV.fetch("VAPID_PRIVATE_KEY", Rails.application.credentials.dig(:vapid, :private_key)) - config.x.vapid.public_key = ENV.fetch("VAPID_PUBLIC_KEY", Rails.application.credentials.dig(:vapid, :public_key)) + config.x.vapid.private_key = ENV["VAPID_PRIVATE_KEY"] + config.x.vapid.public_key = ENV["VAPID_PUBLIC_KEY"] end diff --git a/config/initializers/yabeda.rb b/config/initializers/yabeda.rb deleted file mode 100644 index cad88f80a5..0000000000 --- a/config/initializers/yabeda.rb +++ /dev/null @@ -1,15 +0,0 @@ -require "prometheus/client/support/puma" - -Prometheus::Client.configuration.logger = Rails.logger -Prometheus::Client.configuration.pid_provider = Prometheus::Client::Support::Puma.method(:worker_pid_provider) -Yabeda::Rails.config.controller_name_case = :camel - -Yabeda::ActiveJob.install! - -require "yabeda/solid_queue" -Yabeda::SolidQueue.install! - -Yabeda::ActionCable.configure do |config| - # Fizzy relies primarily on Turbo::StreamsChannel - config.channel_class_name = "ActionCable::Channel::Base" -end diff --git a/config/puma.rb b/config/puma.rb index 60b3712107..8d09b00acc 100644 --- a/config/puma.rb +++ b/config/puma.rb @@ -10,12 +10,14 @@ # Run Solid Queue with Puma plugin :solid_queue if ENV["SOLID_QUEUE_IN_PUMA"] -# Expose Prometheus metrics at http://0.0.0.0:9394/metrics. +# Expose Prometheus metrics at http://0.0.0.0:9394/metrics (SaaS only). # In dev, overridden to http://127.0.0.1:9306/metrics in .mise.toml. -control_uri = Rails.env.local? ? "unix://tmp/pumactl.sock" : "auto" -activate_control_app control_uri, no_token: true -plugin :yabeda -plugin :yabeda_prometheus +if Fizzy.saas? + control_uri = Rails.env.local? ? "unix://tmp/pumactl.sock" : "auto" + activate_control_app control_uri, no_token: true + plugin :yabeda + plugin :yabeda_prometheus +end if !Rails.env.local? # Because we expect fewer I/O waits than Rails apps that connect to the diff --git a/config/recurring.yml b/config/recurring.yml index bdabec6498..2afc11c854 100644 --- a/config/recurring.yml +++ b/config/recurring.yml @@ -1,3 +1,5 @@ +<% require_relative "../lib/fizzy" %> + production: &production # Application functionality: notifications and summaries deliver_bundled_notifications: @@ -26,10 +28,12 @@ production: &production command: "MagicLink.cleanup" schedule: every 4 hours +<% if Fizzy.saas? %> # Metrics yabeda_actioncable: command: "Yabeda::ActionCable.measure" schedule: every 60 seconds +<% end %> beta: *production staging: *production diff --git a/config/routes.rb b/config/routes.rb index 73acfa75ed..d4e4b17e60 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -155,6 +155,12 @@ end end + get "/signup/new", to: redirect("/session/new") + + namespace :signup do + resource :completion, only: %i[ new create ] + end + resource :landing namespace :my do @@ -224,10 +230,6 @@ get "manifest" => "rails/pwa#manifest", as: :pwa_manifest get "service-worker" => "pwa#service_worker" - unless Rails.application.config.x.oss_config - mount Fizzy::Saas::Engine, at: "/", as: "saas" - end - namespace :admin do mount MissionControl::Jobs::Engine, at: "/jobs" get "stats", to: "stats#show" diff --git a/config/storage.oss.yml b/config/storage.oss.yml new file mode 100644 index 0000000000..e50debd344 --- /dev/null +++ b/config/storage.oss.yml @@ -0,0 +1,18 @@ +test: + service: Disk + root: <%= Rails.root.join("tmp/storage/files") %> + +local: + service: Disk + root: <%= Rails.root.join("storage", Rails.env, "files") %> + +devminio: + service: S3 + bucket: fizzy-dev-activestorage + endpoint: "http://minio.localhost:39000" + force_path_style: true + request_checksum_calculation: when_required # default is when_supported with CRC64NVME checksum which FlashBlade doesn't support + response_checksum_validation: when_required # default is when_supported with CRC64NVME checksum which FlashBlade doesn't support + region: us-east-1 # default region required for signer + access_key_id: minioadmin + secret_access_key: minioadmin diff --git a/config/storage.yml b/config/storage.yml index ca2b64633f..302a7a63e9 100644 --- a/config/storage.yml +++ b/config/storage.yml @@ -1,33 +1,11 @@ -test: - service: Disk - root: <%= Rails.root.join("tmp/storage/files") %> +<% + require_relative "../lib/fizzy" -local: - service: Disk - root: <%= Rails.root.join("storage", Rails.env, "files") %> - -devminio: - service: S3 - bucket: fizzy-dev-activestorage - endpoint: "http://minio.localhost:39000" - force_path_style: true - request_checksum_calculation: when_required # default is when_supported with CRC64NVME checksum which FlashBlade doesn't support - response_checksum_validation: when_required # default is when_supported with CRC64NVME checksum which FlashBlade doesn't support - region: us-east-1 # default region required for signer - access_key_id: minioadmin - secret_access_key: minioadmin - -# We have "development", "staging", and "production" buckets configured. Note that we don't have a -# "beta" bucket. (As of 2025-06-01.) -<% pure_env = Rails.env.beta? ? "production" : Rails.env %> -purestorage: - service: S3 - bucket: fizzy-<%= pure_env %>-activestorage - endpoint: "https://storage.basecamp.com" - ssl_verify_peer: false # FIXME: using self-signed cert internally - force_path_style: true - request_checksum_calculation: when_required # default is when_supported with CRC64NVME checksum which FlashBlade doesn't support - response_checksum_validation: when_required # default is when_supported with CRC64NVME checksum which FlashBlade doesn't support - region: us-east-1 # default region required for signer - access_key_id: <%= Rails.application.credentials.dig(:active_storage, :purestorage_service, :access_key_id) %> - secret_access_key: <%= Rails.application.credentials.dig(:active_storage, :purestorage_service, :secret_access_key) %> + config_path = if Fizzy.saas? + gem_path = Gem::Specification.find_by_name("fizzy-saas").gem_dir + File.join(gem_path, "config", "storage.yml") + else + File.join(__dir__, "storage.oss.yml") + end +%> +<%= ERB.new(File.read(config_path)).result %> diff --git a/db/migrate/20251127000001_create_account_external_id_sequences.rb b/db/migrate/20251127000001_create_account_external_id_sequences.rb new file mode 100644 index 0000000000..6f03cb27f4 --- /dev/null +++ b/db/migrate/20251127000001_create_account_external_id_sequences.rb @@ -0,0 +1,9 @@ +class CreateAccountExternalIdSequences < ActiveRecord::Migration[8.0] + def change + create_table :account_external_id_sequences, id: :uuid do |t| + t.bigint :value, null: false, default: 0 + + t.index :value, unique: true + end + end +end diff --git a/db/schema.rb b/db/schema.rb index b3a9f05033..236c61df05 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.2].define(version: 2025_11_25_130010) do +ActiveRecord::Schema[8.2].define(version: 2025_11_27_000001) do create_table "accesses", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.datetime "accessed_at" t.uuid "account_id", null: false @@ -25,6 +25,11 @@ t.index ["user_id"], name: "index_accesses_on_user_id" end + create_table "account_external_id_sequences", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + t.bigint "value", default: 0, null: false + t.index ["value"], name: "index_account_external_id_sequences_on_value", unique: true + end + create_table "account_join_codes", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.uuid "account_id", null: false t.string "code", null: false diff --git a/db/schema_sqlite.rb b/db/schema_sqlite.rb index fe2aad1158..fd3cedd44a 100644 --- a/db/schema_sqlite.rb +++ b/db/schema_sqlite.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.2].define(version: 2025_11_25_130010) do +ActiveRecord::Schema[8.2].define(version: 2025_11_27_000001) do create_table "accesses", id: :uuid, force: :cascade do |t| t.datetime "accessed_at" t.uuid "account_id", null: false @@ -25,6 +25,11 @@ t.index ["user_id"], name: "index_accesses_on_user_id" end + create_table "account_external_id_sequences", id: :uuid, force: :cascade do |t| + t.bigint "value", default: 0, null: false + t.index ["value"], name: "index_account_external_id_sequences_on_value", unique: true + end + create_table "account_join_codes", id: :uuid, force: :cascade do |t| t.uuid "account_id", null: false t.string "code", limit: 255, null: false diff --git a/gems/fizzy-saas/.github/dependabot.yml b/gems/fizzy-saas/.github/dependabot.yml deleted file mode 100644 index 83610cfa4c..0000000000 --- a/gems/fizzy-saas/.github/dependabot.yml +++ /dev/null @@ -1,12 +0,0 @@ -version: 2 -updates: -- package-ecosystem: bundler - directory: "/" - schedule: - interval: weekly - open-pull-requests-limit: 10 -- package-ecosystem: github-actions - directory: "/" - schedule: - interval: weekly - open-pull-requests-limit: 10 diff --git a/gems/fizzy-saas/.github/workflows/ci.yml b/gems/fizzy-saas/.github/workflows/ci.yml deleted file mode 100644 index ef5e97c73e..0000000000 --- a/gems/fizzy-saas/.github/workflows/ci.yml +++ /dev/null @@ -1,69 +0,0 @@ -name: CI - -on: - pull_request: - push: - branches: [ main ] - -jobs: - lint: - runs-on: ubuntu-latest - env: - RUBY_VERSION: ruby-3.4.5 - RUBOCOP_CACHE_ROOT: tmp/rubocop - steps: - - name: Checkout code - uses: actions/checkout@v5 - - - name: Set up Ruby - uses: ruby/setup-ruby@v1 - with: - ruby-version: ${{ env.RUBY_VERSION }} - bundler-cache: true - - - name: Prepare RuboCop cache - uses: actions/cache@v4 - env: - DEPENDENCIES_HASH: ${{ hashFiles('**/.rubocop.yml', '**/.rubocop_todo.yml', 'Gemfile.lock') }} - with: - path: ${{ env.RUBOCOP_CACHE_ROOT }} - key: rubocop-${{ runner.os }}-${{ env.RUBY_VERSION }}-${{ env.DEPENDENCIES_HASH }}-${{ github.ref_name == github.event.repository.default_branch && github.run_id || 'default' }} - restore-keys: | - rubocop-${{ runner.os }}-${{ env.RUBY_VERSION }}-${{ env.DEPENDENCIES_HASH }}- - - - name: Lint code for consistent style - run: bin/rubocop -f github - - test: - runs-on: ubuntu-latest - - # services: - # redis: - # image: valkey/valkey:8 - # ports: - # - 6379:6379 - # options: --health-cmd "redis-cli ping" --health-interval 10s --health-timeout 5s --health-retries 5 - steps: - - name: Checkout code - uses: actions/checkout@v5 - - - name: Set up Ruby - uses: ruby/setup-ruby@v1 - with: - ruby-version: ruby-3.4.5 - bundler-cache: true - - - name: Run tests - env: - RAILS_ENV: test - # RAILS_MASTER_KEY: ${{ secrets.RAILS_MASTER_KEY }} - # REDIS_URL: redis://localhost:6379/0 - run: bin/rails db:test:prepare test - - - name: Keep screenshots from failed system tests - uses: actions/upload-artifact@v4 - if: failure() - with: - name: screenshots - path: ${{ github.workspace }}/tmp/screenshots - if-no-files-found: ignore diff --git a/gems/fizzy-saas/.gitignore b/gems/fizzy-saas/.gitignore deleted file mode 100644 index a3ee5aad36..0000000000 --- a/gems/fizzy-saas/.gitignore +++ /dev/null @@ -1,10 +0,0 @@ -/.bundle/ -/doc/ -/log/*.log -/pkg/ -/tmp/ -/test/dummy/db/*.sqlite3 -/test/dummy/db/*.sqlite3-* -/test/dummy/log/*.log -/test/dummy/storage/ -/test/dummy/tmp/ diff --git a/gems/fizzy-saas/.rubocop.yml b/gems/fizzy-saas/.rubocop.yml deleted file mode 100644 index f9d86d4a54..0000000000 --- a/gems/fizzy-saas/.rubocop.yml +++ /dev/null @@ -1,8 +0,0 @@ -# Omakase Ruby styling for Rails -inherit_gem: { rubocop-rails-omakase: rubocop.yml } - -# Overwrite or add rules to create your own house style -# -# # Use `[a, [b, c]]` not `[ a, [ b, c ] ]` -# Layout/SpaceInsideArrayLiteralBrackets: -# Enabled: false diff --git a/gems/fizzy-saas/Gemfile b/gems/fizzy-saas/Gemfile deleted file mode 100644 index 396a3192e0..0000000000 --- a/gems/fizzy-saas/Gemfile +++ /dev/null @@ -1,6 +0,0 @@ -source "https://rubygems.org" -git_source(:bc) { |repo| "https://github.com/basecamp/#{repo}" } - -# 37id and Queenbee integration -gem "queenbee", bc: "queenbee-plugin", ref: "eb01c697de1ad028afc65cc7d9b5345a7a8e849f" -gem "activeresource", require: "active_resource" # needed by queenbee diff --git a/gems/fizzy-saas/Gemfile.lock b/gems/fizzy-saas/Gemfile.lock deleted file mode 100644 index 5d83987cde..0000000000 --- a/gems/fizzy-saas/Gemfile.lock +++ /dev/null @@ -1,69 +0,0 @@ -GIT - remote: https://github.com/basecamp/queenbee-plugin - revision: eb01c697de1ad028afc65cc7d9b5345a7a8e849f - ref: eb01c697de1ad028afc65cc7d9b5345a7a8e849f - specs: - queenbee (3.2.0) - activeresource - builder - rexml - -GEM - remote: https://rubygems.org/ - specs: - activemodel (8.0.2.1) - activesupport (= 8.0.2.1) - activemodel-serializers-xml (1.0.3) - activemodel (>= 5.0.0.a) - activesupport (>= 5.0.0.a) - builder (~> 3.1) - activeresource (6.1.4) - activemodel (>= 6.0) - activemodel-serializers-xml (~> 1.0) - activesupport (>= 6.0) - activesupport (8.0.2.1) - base64 - benchmark (>= 0.3) - bigdecimal - concurrent-ruby (~> 1.0, >= 1.3.1) - connection_pool (>= 2.2.5) - drb - i18n (>= 1.6, < 2) - logger (>= 1.4.2) - minitest (>= 5.1) - securerandom (>= 0.3) - tzinfo (~> 2.0, >= 2.0.5) - uri (>= 0.13.1) - base64 (0.3.0) - benchmark (0.4.1) - bigdecimal (3.2.3) - builder (3.3.0) - concurrent-ruby (1.3.5) - connection_pool (2.5.4) - drb (2.2.3) - i18n (1.14.7) - concurrent-ruby (~> 1.0) - logger (1.7.0) - minitest (5.25.5) - rexml (3.4.4) - securerandom (0.4.1) - tzinfo (2.0.6) - concurrent-ruby (~> 1.0) - uri (1.0.3) - -PLATFORMS - aarch64-linux-gnu - aarch64-linux-musl - arm-linux-gnu - arm-linux-musl - arm64-darwin - x86_64-darwin - x86_64-linux-gnu - x86_64-linux-musl - -DEPENDENCIES - activeresource - queenbee! - -BUNDLED WITH - 2.7.0 diff --git a/gems/fizzy-saas/README.md b/gems/fizzy-saas/README.md deleted file mode 100644 index ecaa3ede6f..0000000000 --- a/gems/fizzy-saas/README.md +++ /dev/null @@ -1,28 +0,0 @@ -# Fizzy::Saas -Short description and motivation. - -## Usage -How to use my plugin. - -## Installation -Add this line to your application's Gemfile: - -```ruby -gem "fizzy-saas" -``` - -And then execute: -```bash -$ bundle -``` - -Or install it yourself as: -```bash -$ gem install fizzy-saas -``` - -## Contributing -Contribution directions go here. - -## License -The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). diff --git a/gems/fizzy-saas/Rakefile b/gems/fizzy-saas/Rakefile deleted file mode 100644 index e7793b5c12..0000000000 --- a/gems/fizzy-saas/Rakefile +++ /dev/null @@ -1,8 +0,0 @@ -require "bundler/setup" - -APP_RAKEFILE = File.expand_path("test/dummy/Rakefile", __dir__) -load "rails/tasks/engine.rake" - -load "rails/tasks/statistics.rake" - -require "bundler/gem_tasks" diff --git a/gems/fizzy-saas/app/assets/images/fizzy/saas/.keep b/gems/fizzy-saas/app/assets/images/fizzy/saas/.keep deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/gems/fizzy-saas/app/assets/stylesheets/fizzy/saas/application.css b/gems/fizzy-saas/app/assets/stylesheets/fizzy/saas/application.css deleted file mode 100644 index 0ebd7fe829..0000000000 --- a/gems/fizzy-saas/app/assets/stylesheets/fizzy/saas/application.css +++ /dev/null @@ -1,15 +0,0 @@ -/* - * This is a manifest file that'll be compiled into application.css, which will include all the files - * listed below. - * - * Any CSS and SCSS file within this directory, lib/assets/stylesheets, vendor/assets/stylesheets, - * or any plugin's vendor/assets/stylesheets directory can be referenced here using a relative path. - * - * You're free to add application-wide styles to this file and they'll appear at the bottom of the - * compiled file so the styles you add here take precedence over styles defined in any other CSS/SCSS - * files in this directory. Styles in this file should be added after the last require_* statement. - * It is generally better to create a new file per style scope. - * - *= require_tree . - *= require_self - */ diff --git a/gems/fizzy-saas/app/controllers/concerns/.keep b/gems/fizzy-saas/app/controllers/concerns/.keep deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/gems/fizzy-saas/app/models/concerns/.keep b/gems/fizzy-saas/app/models/concerns/.keep deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/gems/fizzy-saas/app/models/subscription.rb b/gems/fizzy-saas/app/models/subscription.rb deleted file mode 100644 index 5efb1a7fac..0000000000 --- a/gems/fizzy-saas/app/models/subscription.rb +++ /dev/null @@ -1,13 +0,0 @@ -class Subscription < Queenbee::Subscription - SHORT_NAMES = %w[ FreeV1 ] - - def self.short_name - name.demodulize - end - - class FreeV1 < Subscription - property :proper_name, "Free Subscription" - property :price, 0 - property :frequency, "yearly" - end -end diff --git a/gems/fizzy-saas/app/views/layouts/fizzy/saas/application.html.erb b/gems/fizzy-saas/app/views/layouts/fizzy/saas/application.html.erb deleted file mode 100644 index 144b378387..0000000000 --- a/gems/fizzy-saas/app/views/layouts/fizzy/saas/application.html.erb +++ /dev/null @@ -1,17 +0,0 @@ - - - - Fizzy saas - <%= csrf_meta_tags %> - <%= csp_meta_tag %> - - <%= yield :head %> - - <%= stylesheet_link_tag "fizzy/saas/application", media: "all" %> - - - -<%= yield %> - - - diff --git a/gems/fizzy-saas/bin/rails b/gems/fizzy-saas/bin/rails deleted file mode 100755 index 42a0e5bce3..0000000000 --- a/gems/fizzy-saas/bin/rails +++ /dev/null @@ -1,14 +0,0 @@ -#!/usr/bin/env ruby -# This command will automatically be run when you run "rails" with Rails gems -# installed from the root of your application. - -ENGINE_ROOT = File.expand_path("..", __dir__) -ENGINE_PATH = File.expand_path("../lib/fizzy/saas/engine", __dir__) -APP_PATH = File.expand_path("../test/dummy/config/application", __dir__) - -# Set up gems listed in the Gemfile. -ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) -require "bundler/setup" if File.exist?(ENV["BUNDLE_GEMFILE"]) - -require "rails/all" -require "rails/engine/commands" diff --git a/gems/fizzy-saas/bin/rubocop b/gems/fizzy-saas/bin/rubocop deleted file mode 100755 index 40330c0ff1..0000000000 --- a/gems/fizzy-saas/bin/rubocop +++ /dev/null @@ -1,8 +0,0 @@ -#!/usr/bin/env ruby -require "rubygems" -require "bundler/setup" - -# explicit rubocop config increases performance slightly while avoiding config confusion. -ARGV.unshift("--config", File.expand_path("../.rubocop.yml", __dir__)) - -load Gem.bin_path("rubocop", "rubocop") diff --git a/gems/fizzy-saas/config/routes.rb b/gems/fizzy-saas/config/routes.rb deleted file mode 100644 index 2b9639edb2..0000000000 --- a/gems/fizzy-saas/config/routes.rb +++ /dev/null @@ -1,9 +0,0 @@ -Fizzy::Saas::Engine.routes.draw do - get "/signup/new", to: redirect("/session/new") - - namespace :signup do - resource :completion, only: %i[ new create ] - end - - Queenbee.routes(self) -end diff --git a/gems/fizzy-saas/fizzy-saas.gemspec b/gems/fizzy-saas/fizzy-saas.gemspec deleted file mode 100644 index f1b28de00a..0000000000 --- a/gems/fizzy-saas/fizzy-saas.gemspec +++ /dev/null @@ -1,27 +0,0 @@ -require_relative "lib/fizzy/saas/version" - -Gem::Specification.new do |spec| - spec.name = "fizzy-saas" - spec.version = Fizzy::Saas::VERSION - spec.authors = [ "Mike Dalessio" ] - spec.email = [ "mike@37signals.com" ] - spec.homepage = "TODO" - spec.summary = "TODO: Summary of Fizzy::Saas." - spec.description = "TODO: Description of Fizzy::Saas." - - # Prevent pushing this gem to RubyGems.org. To allow pushes either set the "allowed_push_host" - # to allow pushing to a single host or delete this section to allow pushing to any host. - spec.metadata["allowed_push_host"] = "TODO: Set to 'http://mygemserver.com'" - - spec.metadata["homepage_uri"] = spec.homepage - spec.metadata["source_code_uri"] = "TODO: Put your gem's public repo URL here." - spec.metadata["changelog_uri"] = "TODO: Put your gem's CHANGELOG.md URL here." - - spec.files = Dir.chdir(File.expand_path(__dir__)) do - Dir["{app,config,db,lib}/**/*", "MIT-LICENSE", "Rakefile", "README.md"] - end - - spec.add_dependency "rails", ">= 8.1.0.beta1" - spec.add_dependency "queenbee" - spec.add_dependency "rails_structured_logging" -end diff --git a/gems/fizzy-saas/lib/fizzy/saas.rb b/gems/fizzy-saas/lib/fizzy/saas.rb deleted file mode 100644 index dd0d354926..0000000000 --- a/gems/fizzy-saas/lib/fizzy/saas.rb +++ /dev/null @@ -1,7 +0,0 @@ -require "fizzy/saas/version" -require "fizzy/saas/engine" - -module Fizzy - module Saas - end -end diff --git a/gems/fizzy-saas/lib/fizzy/saas/engine.rb b/gems/fizzy-saas/lib/fizzy/saas/engine.rb deleted file mode 100644 index 14ce037590..0000000000 --- a/gems/fizzy-saas/lib/fizzy/saas/engine.rb +++ /dev/null @@ -1,31 +0,0 @@ -require_relative "metrics" -require_relative "transaction_pinning" - -module Fizzy - module Saas - class Engine < ::Rails::Engine - # moved from config/initializers/queenbee.rb - Queenbee.host_app = Fizzy - - initializer "fizzy_saas.transaction_pinning" do |app| - if ActiveRecord::Base.replica_configured? - app.config.middleware.insert_after( - ActiveRecord::Middleware::DatabaseSelector, - TransactionPinning::Middleware - ) - end - end - - config.to_prepare do - Queenbee::Subscription.short_names = Subscription::SHORT_NAMES - Queenbee::ApiToken.token = Rails.application.credentials.dig(:queenbee_api_token) - - Subscription::SHORT_NAMES.each do |short_name| - const_name = "#{short_name}Subscription" - ::Object.send(:remove_const, const_name) if ::Object.const_defined?(const_name) - ::Object.const_set const_name, Subscription.const_get(short_name, false) - end - end - end - end -end diff --git a/gems/fizzy-saas/lib/fizzy/saas/metrics.rb b/gems/fizzy-saas/lib/fizzy/saas/metrics.rb deleted file mode 100644 index 80a2bc194b..0000000000 --- a/gems/fizzy-saas/lib/fizzy/saas/metrics.rb +++ /dev/null @@ -1,13 +0,0 @@ -Yabeda.configure do - SHORT_HISTOGRAM_BUCKETS = [ 0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5 ] - - group :fizzy do - counter :replica_stale, - comment: "Number of requests served from a stale replica" - - histogram :replica_wait, - unit: :seconds, - comment: "Time spent waiting for replica to catch up with transaction", - buckets: SHORT_HISTOGRAM_BUCKETS - end -end diff --git a/gems/fizzy-saas/lib/fizzy/saas/transaction_pinning.rb b/gems/fizzy-saas/lib/fizzy/saas/transaction_pinning.rb deleted file mode 100644 index ed3cf2060d..0000000000 --- a/gems/fizzy-saas/lib/fizzy/saas/transaction_pinning.rb +++ /dev/null @@ -1,65 +0,0 @@ -module TransactionPinning - class Middleware - SESSION_KEY = :last_txn - DEFAULT_MAX_WAIT = 0.25 - - def initialize(app) - @app = app - @timeout = Rails.application.config.x.transaction_pinning&.timeout&.to_f || DEFAULT_MAX_WAIT - end - - def call(env) - request = ActionDispatch::Request.new(env) - replica_metrics = {} - - if ApplicationRecord.current_role == :reading - wait_for_replica_catchup(request, replica_metrics) - end - - status, headers, body = @app.call(env) - headers.merge!(replica_metrics.transform_values(&:to_s)) - - if ApplicationRecord.current_role == :writing - capture_transaction_id(request) - end - - [ status, headers, body ] - end - - private - def wait_for_replica_catchup(request, replica_metrics) - if last_txn = request.session[SESSION_KEY].presence - has_transaction = tracking_replica_wait_time(replica_metrics) do - replica_has_transaction(last_txn) - end - - unless has_transaction - Yabeda.fizzy.replica_stale.increment - replica_metrics["X-Replica-Stale"] = true - end - end - end - - def capture_transaction_id(request) - request.session[SESSION_KEY] = ApplicationRecord.connection.show_variable("global.gtid_executed") - end - - def replica_has_transaction(txn) - sql = ApplicationRecord.sanitize_sql_array([ "SELECT WAIT_FOR_EXECUTED_GTID_SET(?, ?)", txn, @timeout ]) - ApplicationRecord.connection.select_value(sql) == 0 - rescue => e - Sentry.capture_exception(e, extra: { gtid: txn }) - true # Treat as if we're up to date, since we don't know - end - - def tracking_replica_wait_time(replica_metrics) - started_at = Time.current - - Yabeda.fizzy.replica_wait.measure do - yield - end.tap do - replica_metrics["X-Replica-Wait"] = Time.current - started_at - end - end - end -end diff --git a/gems/fizzy-saas/lib/fizzy/saas/version.rb b/gems/fizzy-saas/lib/fizzy/saas/version.rb deleted file mode 100644 index 7a95d2d052..0000000000 --- a/gems/fizzy-saas/lib/fizzy/saas/version.rb +++ /dev/null @@ -1,5 +0,0 @@ -module Fizzy - module Saas - VERSION = "0.1.0" - end -end diff --git a/gems/fizzy-saas/lib/tasks/fizzy/saas_tasks.rake b/gems/fizzy-saas/lib/tasks/fizzy/saas_tasks.rake deleted file mode 100644 index 8fe948d94a..0000000000 --- a/gems/fizzy-saas/lib/tasks/fizzy/saas_tasks.rake +++ /dev/null @@ -1,4 +0,0 @@ -# desc "Explaining what the task does" -# task :fizzy_saas do -# # Task goes here -# end diff --git a/gems/fizzy-saas/test/controllers/.keep b/gems/fizzy-saas/test/controllers/.keep deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/gems/fizzy-saas/test/fixtures/files/.keep b/gems/fizzy-saas/test/fixtures/files/.keep deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/gems/fizzy-saas/test/helpers/.keep b/gems/fizzy-saas/test/helpers/.keep deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/gems/fizzy-saas/test/integration/.keep b/gems/fizzy-saas/test/integration/.keep deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/gems/fizzy-saas/test/mailers/.keep b/gems/fizzy-saas/test/mailers/.keep deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/gems/fizzy-saas/test/models/.keep b/gems/fizzy-saas/test/models/.keep deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/gems/fizzy-saas/test/test_helper.rb b/gems/fizzy-saas/test/test_helper.rb deleted file mode 100644 index eeaf249547..0000000000 --- a/gems/fizzy-saas/test/test_helper.rb +++ /dev/null @@ -1,9 +0,0 @@ -require "queenbee/testing/mocks" - -Queenbee::Remote::Account.class_eval do - # because we use the account ID as the tenant name, we need it to be unique in each test to avoid - # parallelized tests clobbering each other. - def next_id - super + Random.rand(1000000) - end -end diff --git a/lib/bootstrap.rb b/lib/bootstrap.rb deleted file mode 100644 index 6d52f3f49e..0000000000 --- a/lib/bootstrap.rb +++ /dev/null @@ -1,5 +0,0 @@ -module Bootstrap - def self.oss_config? - ENV.fetch("OSS_CONFIG", "") != "" || !File.directory?(File.expand_path("../gems/fizzy-saas", __dir__)) - end -end diff --git a/lib/fizzy.rb b/lib/fizzy.rb new file mode 100644 index 0000000000..e009c8f78d --- /dev/null +++ b/lib/fizzy.rb @@ -0,0 +1,33 @@ +module Fizzy + class << self + def saas? + return @saas if defined?(@saas) + @saas = !!(((ENV["SAAS"] || File.exist?(File.expand_path("../tmp/saas.txt", __dir__))) && ENV["SAAS"] != "false")) + end + + def db_adapter + @db_adapter ||= DbAdapter.new ENV.fetch("DATABASE_ADAPTER", saas? ? "mysql" : "sqlite") + end + + def configure_bundle + if saas? + ENV["BUNDLE_GEMFILE"] = "Gemfile.saas" + end + end + end + + class DbAdapter + def initialize(name) + @name = name.to_s + end + + def to_s + @name + end + + # Not using inquiry so that it works before Rails env loads. + def sqlite? + @name == "sqlite" + end + end +end diff --git a/lib/tasks/saas.rake b/lib/tasks/saas.rake new file mode 100644 index 0000000000..4318da05a4 --- /dev/null +++ b/lib/tasks/saas.rake @@ -0,0 +1,18 @@ +namespace :saas do + SAAS_FILE_PATH = "tmp/saas.txt" + + desc "Enable SaaS mode" + task enable: :environment do + file_path = Rails.root.join(SAAS_FILE_PATH) + FileUtils.mkdir_p(File.dirname(file_path)) + FileUtils.touch(file_path) + puts "SaaS mode enabled (#{file_path} created)" + end + + desc "Disable SaaS mode" + task disable: :environment do + file_path = Rails.root.join(SAAS_FILE_PATH) + FileUtils.rm_f(file_path) + puts "SaaS mode disabled (#{file_path} removed)" + end +end diff --git a/lib/yabeda/solid_queue.rb b/lib/yabeda/solid_queue.rb deleted file mode 100644 index 082ac89363..0000000000 --- a/lib/yabeda/solid_queue.rb +++ /dev/null @@ -1,27 +0,0 @@ -module Yabeda - module SolidQueue - def self.install! - Yabeda.configure do - group :solid_queue - - gauge :jobs_failed_count, comment: "Number of failed jobs" - gauge :jobs_unreleased_count, comment: "Number of claimed jobs that don't belong to any process" - gauge :jobs_scheduled_and_delayed_count, comment: "Number of scheduled jobs that have over 5 minutes delay" - gauge :recurring_tasks_count, comment: "Number of recurring jobs scheduled" - gauge :recurring_tasks_delayed_count, comment: "Number of recurring jobs that haven't been enqueued within their schedule" - - collect do - if ::SolidQueue.supervisor? - solid_queue.jobs_failed_count.set({}, ::SolidQueue::FailedExecution.count) - solid_queue.jobs_unreleased_count.set({}, ::SolidQueue::ClaimedExecution.where(process: nil).count) - solid_queue.jobs_scheduled_and_delayed_count.set({}, ::SolidQueue::ScheduledExecution.where(scheduled_at: ..5.minutes.ago).count) - solid_queue.recurring_tasks_count.set({}, ::SolidQueue::RecurringTask.count) - solid_queue.recurring_tasks_delayed_count.set({}, ::SolidQueue::RecurringTask.count do |task| - task.last_enqueued_time.present? && (task.previous_time - task.last_enqueued_time) > 5.minutes - end) - end - end - end - end - end -end diff --git a/script/create-account.rb b/script/create-account.rb deleted file mode 100755 index a46f4310f7..0000000000 --- a/script/create-account.rb +++ /dev/null @@ -1,105 +0,0 @@ -#!/usr/bin/env ruby -# Usage: script/create-account "Company Name" "Owner Name" "owner@example.com" - -require_relative "../config/environment" - -# Parse arguments -if ARGV.size != 3 - puts "Usage: script/create-account " - puts "Example: script/create-account 'Acme Corp' 'John Doe' 'john@acme.com'" - exit 1 -end - -company_name, owner_name, owner_email = ARGV - -# Create a minimal Current context for the signup -Current.set( - ip_address: "127.0.0.1", - user_agent: "create-account script", - referrer: nil -) do - puts "Creating account..." - puts " Company: #{company_name}" - puts " Owner: #{owner_name}" - puts " Email: #{owner_email}" - puts - - # Step 1: Create the account in QueenBee - queenbee_account_attributes = { - skip_remote: true, - product_name: "fizzy", - name: company_name, - owner_name: owner_name, - owner_email: owner_email, - trial: true, - subscription: { - name: "FreeV1", - price: 0 - }, - remote_request: { - remote_address: Current.ip_address, - user_agent: Current.user_agent, - referrer: Current.referrer - } - } - - begin - queenbee_account = Queenbee::Remote::Account.create!(queenbee_account_attributes) - puts "✓ Account created in QueenBee" - rescue => error - puts "Error creating QueenBee account:" - puts " - #{error.message}" - exit 1 - end - - # Step 2: Create tenant with the QueenBee account ID - tenant_id = queenbee_account.id.to_s - - begin - ApplicationRecord.create_tenant(tenant_id) do - # Create account with admin user - account = Account.create_with_admin_user( - account: { - external_account_id: tenant_id, - name: company_name - }, - owner: { - name: owner_name, - email_address: owner_email - } - ) - end - - puts "✓ Tenant created" - puts "✓ Account setup completed" - rescue => error - # Clean up QueenBee account if tenant creation fails - queenbee_account&.cancel - - puts "Error setting up tenant:" - puts " - #{error.message}" - exit 1 - end - - # Step 3: Get or create join code - ApplicationRecord.with_tenant(tenant_id) do - account = Current.account - join_code = account.join_code - - puts "✓ Join code ready" - puts - puts "Account created successfully!" - puts "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - puts "Tenant: #{tenant_id}" - puts "Company: #{company_name}" - puts "Owner: #{owner_name}" - puts "Email: #{owner_email}" - puts "Join URL: #{Rails.application.routes.url_helpers.join_url( - join_code: join_code, - script_name: "/#{tenant_id}", - host: Rails.application.config.action_mailer.default_url_options[:host], - port: Rails.application.config.action_mailer.default_url_options[:port], - protocol: Rails.env.production? ? 'https' : 'http' - )}" - end -end diff --git a/test/controllers/admin/mission_control_test.rb b/test/controllers/admin/mission_control_test.rb new file mode 100644 index 0000000000..265090928d --- /dev/null +++ b/test/controllers/admin/mission_control_test.rb @@ -0,0 +1,23 @@ +require "test_helper" + +class Admin::MissionControlTest < ActionDispatch::IntegrationTest + test "staff can access mission control jobs" do + sign_in_as :david + + untenanted do + get "/admin/jobs" + end + + assert_response :success + end + + test "non-staff cannot access mission control jobs" do + sign_in_as :jz + + untenanted do + get "/admin/jobs" + end + + assert_response :forbidden + end +end diff --git a/test/controllers/admin/stats_controller_test.rb b/test/controllers/admin/stats_controller_test.rb new file mode 100644 index 0000000000..f87dd7cf44 --- /dev/null +++ b/test/controllers/admin/stats_controller_test.rb @@ -0,0 +1,23 @@ +require "test_helper" + +class Admin::StatsControllerTest < ActionDispatch::IntegrationTest + test "staff can access stats" do + sign_in_as :david + + untenanted do + get admin_stats_url + end + + assert_response :success + end + + test "non-staff cannot access stats" do + sign_in_as :jz + + untenanted do + get admin_stats_url + end + + assert_response :forbidden + end +end diff --git a/test/controllers/concerns/request_forgery_protection_test.rb b/test/controllers/concerns/request_forgery_protection_test.rb index 797c2039a4..8ac5327c2e 100644 --- a/test/controllers/concerns/request_forgery_protection_test.rb +++ b/test/controllers/concerns/request_forgery_protection_test.rb @@ -134,8 +134,10 @@ def assert_log(includes: [], excludes: [], &block) end def assert_report - Sentry.expects(:capture_message).with do |message, **kwargs| - message == "CSRF protection mismatch" && kwargs[:level] == :info + if Fizzy.saas? + Sentry.expects(:capture_message).with do |message, **kwargs| + message == "CSRF protection mismatch" && kwargs[:level] == :info + end end end diff --git a/test/controllers/non_production_remote_access_test.rb b/test/controllers/non_production_remote_access_test.rb new file mode 100644 index 0000000000..cb84eda35d --- /dev/null +++ b/test/controllers/non_production_remote_access_test.rb @@ -0,0 +1,34 @@ +require "test_helper" + +class NonProductionRemoteAccessTest < ActionDispatch::IntegrationTest + test "staff can access in staging environment" do + sign_in_as :david + + Rails.stubs(:env).returns(ActiveSupport::EnvironmentInquirer.new("staging")) + get cards_path + assert_response :success + end + + test "non-staff cannot access in staging environment" do + sign_in_as :jz + + Rails.stubs(:env).returns(ActiveSupport::EnvironmentInquirer.new("staging")) + get cards_path + assert_response :forbidden + end + + test "non-staff can access in production environment" do + sign_in_as :jz + + Rails.stubs(:env).returns(ActiveSupport::EnvironmentInquirer.new("production")) + get cards_path + assert_response :success + end + + test "non-staff can access in local environment" do + sign_in_as :jz + + get cards_path + assert_response :success + end +end diff --git a/test/controllers/sessions_controller_test.rb b/test/controllers/sessions_controller_test.rb index 623423fdf0..e4a2163b1d 100644 --- a/test/controllers/sessions_controller_test.rb +++ b/test/controllers/sessions_controller_test.rb @@ -21,35 +21,28 @@ class SessionsControllerTest < ActionDispatch::IntegrationTest end end - unless Bootstrap.oss_config? - test "create for a new user" do - untenanted do - assert_difference -> { Identity.count }, +1 do - assert_difference -> { MagicLink.count }, +1 do - post session_path, - params: { email_address: "nonexistent-#{SecureRandom.hex(6)}@example.com" }, - headers: http_basic_auth_headers("testname", "testpassword") - end + test "create for a new user" do + untenanted do + assert_difference -> { Identity.count }, +1 do + assert_difference -> { MagicLink.count }, +1 do + post session_path, + params: { email_address: "nonexistent-#{SecureRandom.hex(6)}@example.com" } end - - assert_redirected_to session_magic_link_path end - end - end - - test "destroy" do - sign_in_as :kevin - untenanted do - delete session_path - - assert_redirected_to new_session_path - assert_not cookies[:session_token].present? + assert_redirected_to session_magic_link_path end end private - def http_basic_auth_headers(user, password) - { "Authorization" => ActionController::HttpAuthentication::Basic.encode_credentials(user, password) } + test "destroy" do + sign_in_as :kevin + + untenanted do + delete session_path + + assert_redirected_to new_session_path + assert_not cookies[:session_token].present? + end end end diff --git a/gems/fizzy-saas/test/controllers/signups/completions_controller_test.rb b/test/controllers/signup/completions_controller_test.rb similarity index 84% rename from gems/fizzy-saas/test/controllers/signups/completions_controller_test.rb rename to test/controllers/signup/completions_controller_test.rb index 49628a188e..1b788ce312 100644 --- a/gems/fizzy-saas/test/controllers/signups/completions_controller_test.rb +++ b/test/controllers/signup/completions_controller_test.rb @@ -11,7 +11,7 @@ class Signup::CompletionsControllerTest < ActionDispatch::IntegrationTest test "new" do untenanted do - get saas.new_signup_completion_path + get new_signup_completion_path end assert_response :success @@ -19,7 +19,7 @@ class Signup::CompletionsControllerTest < ActionDispatch::IntegrationTest test "create" do untenanted do - post saas.signup_completion_path, params: { + post signup_completion_path, params: { signup: { full_name: @signup.full_name } @@ -31,7 +31,7 @@ class Signup::CompletionsControllerTest < ActionDispatch::IntegrationTest test "create with invalid params" do untenanted do - post saas.signup_completion_path, params: { + post signup_completion_path, params: { signup: { full_name: "" } diff --git a/test/fixtures/identities.yml b/test/fixtures/identities.yml index a014e801c2..af4c2a060a 100644 --- a/test/fixtures/identities.yml +++ b/test/fixtures/identities.yml @@ -1,11 +1,13 @@ david: email_address: david@37signals.com + staff: true jz: email_address: jz@37signals.com kevin: email_address: kevin@37signals.com + staff: true mike: email_address: mike@37signals.com diff --git a/test/models/account/external_id_sequence_test.rb b/test/models/account/external_id_sequence_test.rb new file mode 100644 index 0000000000..0ce72a0a8a --- /dev/null +++ b/test/models/account/external_id_sequence_test.rb @@ -0,0 +1,42 @@ +require "test_helper" + +class Account::ExternalIdSequenceTest < ActiveSupport::TestCase + setup do + Account::ExternalIdSequence.delete_all + end + + test "generate sequential values" do + first_value = Account::ExternalIdSequence.next + second_value = Account::ExternalIdSequence.next + third_value = Account::ExternalIdSequence.next + + assert_equal first_value + 1, second_value + assert_equal second_value + 1, third_value + end + + test "start from the maximum existing external account id" do + max_id = Account.maximum(:external_account_id) || 0 + + first_value = Account::ExternalIdSequence.next + + assert_equal max_id + 1, first_value + end + + test "use a single record for the sequence" do + 3.times { Account::ExternalIdSequence.next } + + assert_equal 1, Account::ExternalIdSequence.count + end + + test "handle concurrent access safely" do + values = 20.times.map do + Thread.new do + Account::ExternalIdSequence.next + end + end.map(&:value) + + assert_equal 20, values.uniq.size, "All values should be unique" + assert_equal values.min..values.max, values.sort.first..values.sort.last + assert_equal 20, values.max - values.min + 1, "Values should be sequential with no gaps" + end +end diff --git a/test/models/account_test.rb b/test/models/account_test.rb index 7b212e8ab6..541ceb654c 100644 --- a/test/models/account_test.rb +++ b/test/models/account_test.rb @@ -59,4 +59,23 @@ class AccountTest < ActiveSupport::TestCase account.system_user end end + + test "external_account_id auto-increments on creation" do + account1 = Account.create!(name: "First Account") + account2 = Account.create!(name: "Second Account") + + assert_not_nil account1.external_account_id + assert_not_nil account2.external_account_id + assert_equal account1.external_account_id + 1, account2.external_account_id + end + + test "external_account_id can be overridden" do + custom_id = 999999 + sequence_value_before = Account::ExternalIdSequence.first_or_create!(value: 0).value + + account = Account.create!(name: "Custom ID Account", external_account_id: custom_id) + + assert_equal custom_id, account.external_account_id + assert_equal sequence_value_before, Account::ExternalIdSequence.value + end end diff --git a/gems/fizzy-saas/test/models/signup/account_name_generator_test.rb b/test/models/signup/account_name_generator_test.rb similarity index 100% rename from gems/fizzy-saas/test/models/signup/account_name_generator_test.rb rename to test/models/signup/account_name_generator_test.rb diff --git a/gems/fizzy-saas/test/models/signup_test.rb b/test/models/signup_test.rb similarity index 92% rename from gems/fizzy-saas/test/models/signup_test.rb rename to test/models/signup_test.rb index 69c0e699f9..30910bf810 100644 --- a/gems/fizzy-saas/test/models/signup_test.rb +++ b/test/models/signup_test.rb @@ -30,11 +30,9 @@ class SignupTest < ActiveSupport::TestCase test "#complete" do Account.any_instance.expects(:setup_customer_template).once + Current.without_account do - signup = Signup.new( - full_name: "Kevin", - identity: identities(:kevin) - ) + signup = Signup.new(full_name: "Kevin", identity: identities(:kevin)) assert signup.complete diff --git a/test/test_helper.rb b/test/test_helper.rb index bad1fdb2a1..722d39d59f 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -152,7 +152,3 @@ def uuid_v7_with_timestamp(time, seed_string) ActiveSupport.on_load(:active_record_fixture_set) do prepend(FixturesTestHelper) end - -unless Rails.application.config.x.oss_config - load File.expand_path("../gems/fizzy-saas/test/test_helper.rb", __dir__) -end